diff --git a/.audit-findings-codex-prev-20260323-211452/banny-retail-v6/codex-feynman-verified.md b/.audit-findings-codex-prev-20260323-211452/banny-retail-v6/codex-feynman-verified.md new file mode 100644 index 0000000..c454f63 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/banny-retail-v6/codex-feynman-verified.md @@ -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 diff --git a/.audit-findings-codex-prev-20260323-211452/banny-retail-v6/codex-nemesis-raw.md b/.audit-findings-codex-prev-20260323-211452/banny-retail-v6/codex-nemesis-raw.md new file mode 100644 index 0000000..618703a --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/banny-retail-v6/codex-nemesis-raw.md @@ -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 diff --git a/.audit-findings-codex-prev-20260323-211452/banny-retail-v6/codex-nemesis-verified.md b/.audit-findings-codex-prev-20260323-211452/banny-retail-v6/codex-nemesis-verified.md new file mode 100644 index 0000000..a831368 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/banny-retail-v6/codex-nemesis-verified.md @@ -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 diff --git a/.audit-findings-codex-prev-20260323-211452/banny-retail-v6/codex-state-inconsistency-verified.md b/.audit-findings-codex-prev-20260323-211452/banny-retail-v6/codex-state-inconsistency-verified.md new file mode 100644 index 0000000..5c4e06a --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/banny-retail-v6/codex-state-inconsistency-verified.md @@ -0,0 +1,77 @@ +# State Inconsistency Audit — Verified Findings + +## Coupled State Dependency Map + +| Coupled Pair | Invariant | Primary Mutation Points | +|-------------|-----------|-------------------------| +| `_attachedOutfitIdsOf[hook][body]` ↔ `_wearerOf[hook][outfit]` | Every equipped outfit held by the resolver must still resolve to the wearing body, and every tracked worn outfit must remain in the attachment array | `decorateBannyWith`, `_decorateBannyWithOutfits` | +| `_attachedBackgroundIdOf[hook][body]` ↔ `_userOf[hook][background]` | Every equipped background held by the resolver must still resolve to the using body, and vice versa | `decorateBannyWith`, `_decorateBannyWithBackground` | +| `svgHashOf[upc]` ↔ `_svgContentOf[upc]` | Stored SVG content must match the committed hash and remain immutable once set | `setSvgHashesOf`, `setSvgContentsOf` | + +## Mutation Matrix + +| State Variable | Mutating Function | Updates Coupled State? | +|---------------|-------------------|------------------------| +| `_attachedBackgroundIdOf` | `_decorateBannyWithBackground` | Partially — clears/overwrites attachment before confirming outgoing transfer | +| `_userOf` | `_decorateBannyWithBackground` | Partially — new background updated, previous background left stale/lazily ignored | +| `_attachedOutfitIdsOf` | `_decorateBannyWithOutfits` | Partially — overwritten even if outgoing transfer fails | +| `_wearerOf` | `_decorateBannyWithOutfits` | Partially — new outfits updated, removed outfits left stale/lazily ignored | + +## Parallel Path Comparison + +| Coupled State | Incoming Equip Path | Outgoing Replace/Unequip Path | +|---------------|---------------------|-------------------------------| +| Background attachment ↔ user mapping | Hard-fails if incoming transfer fails | Soft-fails if outgoing transfer fails | +| Outfit attachment ↔ wearer mapping | Hard-fails if incoming transfer fails | Soft-fails if outgoing transfer fails | + +## Verification Summary +| ID | Coupled Pair | Breaking Op | Original Severity | Verdict | Final Severity | +|----|-------------|-------------|-------------------|---------|----------------| +| SI-001 | `_attachedOutfitIdsOf/_wearerOf`, `_attachedBackgroundIdOf/_userOf` | `decorateBannyWith()` | HIGH | TRUE POSITIVE | HIGH | + +## Verified Findings + +### Finding SI-001: HIGH — Failed best-effort returns desynchronize custody mappings from real NFT ownership and strand live assets +**Severity:** HIGH +**Verification:** Hybrid — code trace + PoC + +**Coupled Pair:** `_attachedOutfitIdsOf[hook][body]` ↔ `_wearerOf[hook][outfit]`, `_attachedBackgroundIdOf[hook][body]` ↔ `_userOf[hook][background]` +**Invariant:** Any live NFT held by the resolver because it is equipped must remain recoverable through the body it is attached to. + +**Breaking Operation:** `decorateBannyWith()` in `src/Banny721TokenUriResolver.sol` +- Modifies attachment state: `src/Banny721TokenUriResolver.sol:L1213-L1214`, `src/Banny721TokenUriResolver.sol:L1227`, `src/Banny721TokenUriResolver.sol:L1355`, `src/Banny721TokenUriResolver.sol:L1392` +- Does not require successful coupled-state reconciliation on outgoing transfers: `src/Banny721TokenUriResolver.sol:L1218`, `src/Banny721TokenUriResolver.sol:L1231`, `src/Banny721TokenUriResolver.sol:L1338`, `src/Banny721TokenUriResolver.sol:L1380`, `src/Banny721TokenUriResolver.sol:L1424-L1426` + +**Trigger Sequence:** +1. A body owner that cannot receive ERC-721s equips a background and/or outfit. +2. The body owner calls `decorateBannyWith(..., 0, [])` or replaces the assets. +3. `_tryTransferFrom` catches the outgoing transfer revert. +4. The resolver keeps owning the NFT, but the body’s attachment state has already been cleared or overwritten. +5. Subsequent reads treat the asset as unattached, and subsequent writes reject it as unauthorized because the resolver itself is now the owner. + +**Consequence:** +- The NFT is live and still owned by the resolver. +- `assetIdsOf`, `wearerOf`, and `userOf` stop exposing the relationship. +- The current body owner cannot reclaim the asset, so custody is permanently lost unless a bespoke rescue mechanism is added. + +**Masking Code:** +```solidity +try IERC721(hook).safeTransferFrom({from: from, to: to, tokenId: assetId}) {} catch {} +``` + +**Fix:** +```solidity +// Defer clearing attachment mappings until the outgoing transfer succeeds, or +// explicitly preserve a recoverable attachment record for all non-burn failures. +``` + +## False Positives Eliminated +- Lazy reconciliation of stale `_userOf` / `_wearerOf` entries is harmless when the asset is actually gone (burned / removed-tier path). +- The verified issue only remains when the asset is still live and still owned by the resolver. + +## Summary +- Coupled state pairs mapped: 3 +- Mutation paths analyzed: 8 +- Raw findings (pre-verification): 1 +- After verification: 1 TRUE POSITIVE | 0 FALSE POSITIVE +- Final: 0 CRITICAL | 1 HIGH | 0 MEDIUM | 0 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/croptop-core-v6/codex-feynman-verified.md b/.audit-findings-codex-prev-20260323-211452/croptop-core-v6/codex-feynman-verified.md new file mode 100644 index 0000000..f9e33de --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/croptop-core-v6/codex-feynman-verified.md @@ -0,0 +1,137 @@ +# Feynman Audit — Verified Findings + +## Scope +- Language: Solidity 0.8.26 +- Modules analyzed: 14 Solidity files in `src/` and `script/` +- Functions analyzed: 34 +- Lines interrogated: focused review of all concrete contracts/scripts plus interface and struct assumptions + +## Verification Summary +| ID | Original Severity | Verdict | Final Severity | +|----|-------------------|---------|----------------| +| FF-001 | MEDIUM | TRUE POSITIVE | MEDIUM | +| FF-002 | LOW | TRUE POSITIVE | LOW | + +## Function-State Matrix +| Function | Reads | Writes | Guards | Calls | +|----|----|----|----|----| +| `CTPublisher.configurePostingCriteriaFor` | hook owner, hook projectId | `_packedAllowanceFor`, `_allowedAddresses` | `ADJUST_721_TIERS` via hook owner | `JBOwnable.owner`, `IJB721TiersHook.PROJECT_ID` | +| `CTPublisher.mintFrom` | `FEE_PROJECT_ID`, hook/store state, directory terminals | `tierIdForEncodedIPFSUriOf` via `_setupPosts` | allowance + allowlist checks | `hook.adjustTiers`, `terminal.pay` | +| `CTDeployer.claimCollectionOwnershipOf` | `hook.PROJECT_ID`, `PROJECTS.ownerOf` | none directly | project NFT owner check | `transferOwnershipToProject` | +| `CTDeployer.deployProjectFor` | `PROJECTS.count`, controller/project config | `dataHookOf[projectId]` | controller/projects consistency | hook deployer, controller launch, publisher config, sucker registry | +| `CTProjectOwner.onERC721Received` | `msg.sender`, `tokenId` | permission bitmap in `JBPermissions` | caller must be `PROJECTS` | `setPermissionsFor` | +| `DeployScript.deploy` | deployment state, `FEE_PROJECT_ID` | `FEE_PROJECT_ID` in-script | none | `createFor`, constructors | + +## Guard Consistency Analysis +- Hook ownership changes are not accompanied by a matching publisher permission migration. `deployProjectFor()` grants `CTPublisher` authority from `CTDeployer`, while `claimCollectionOwnershipOf()` switches future authorization checks to the project owner. +- Posting-criteria docs advertise `maximumTotalSupply == 0` as “unlimited”, but `configurePostingCriteriaFor()` rejects every such config because `minimumTotalSupply` must be non-zero. + +## Inverse Operation Parity +- Deploy path: `deployProjectFor()` establishes `CTDeployer` as hook owner and permission granter. +- Claim path: `claimCollectionOwnershipOf()` moves hook ownership to the project without re-establishing the permissions that `mintFrom()` and `configurePostingCriteriaFor()` still require. + +## Verified Findings + +### Finding FF-001: `claimCollectionOwnershipOf` breaks Croptop posting until the project owner manually re-grants publisher permissions +**Severity:** MEDIUM +**Module:** `CTDeployer` / `CTPublisher` +**Function:** `claimCollectionOwnershipOf`, `configurePostingCriteriaFor`, `mintFrom` +**Lines:** `src/CTDeployer.sol:224-235`, `src/CTDeployer.sol:339-346`, `src/CTPublisher.sol:256-260`, `src/CTPublisher.sol:358`, `src/CTPublisher.sol:517-549` +**Verification:** Hybrid — code trace + existing Foundry test `test/ClaimCollectionOwnership.t.sol::test_postClaim_publisherNeedsNewPermissions` + +**Feynman Question that exposed this:** +> Why does the claim path move hook ownership without also moving the permission source that the publisher relies on? + +**The code:** +```solidity +// CTDeployer.deployProjectFor +PERMISSIONS.setPermissionsFor({ + account: address(this), + permissionsData: JBPermissionsData({ + operator: address(owner), + projectId: uint64(projectId), + permissionIds: permissionIds + }) +}); + +// CTDeployer.claimCollectionOwnershipOf +JBOwnable(address(hook)).transferOwnershipToProject(projectId); + +// CTPublisher.configurePostingCriteriaFor +_requirePermissionFrom({ + account: JBOwnable(allowedPost.hook).owner(), + projectId: IJB721TiersHook(allowedPost.hook).PROJECT_ID(), + permissionId: JBPermissionIds.ADJUST_721_TIERS +}); +``` + +**Why this is wrong:** +`deployProjectFor()` grants Croptop-related permissions from `CTDeployer` as the authority account. After `claimCollectionOwnershipOf()`, the hook owner is no longer `CTDeployer`; `JBOwnable.owner()` resolves to `PROJECTS.ownerOf(projectId)`. Every later publisher permission check is therefore evaluated against the project owner’s permission bitmap, but no permission migration happens during `claimCollectionOwnershipOf()`. The posting flow silently changes trust domains without synchronizing the dependent authorization state. + +**Verification evidence:** +- Code trace: + - `CTPublisher.configurePostingCriteriaFor()` authorizes against `JBOwnable(hook).owner()` at [`src/CTPublisher.sol:256`](./src/CTPublisher.sol). + - `mintFrom()` always calls `hook.adjustTiers(...)` at [`src/CTPublisher.sol:358`](./src/CTPublisher.sol), so even batches that only reuse existing tiers still traverse the hook-owner permission surface. + - `claimCollectionOwnershipOf()` only calls `transferOwnershipToProject(projectId)` and performs no `setPermissionsFor` call at [`src/CTDeployer.sol:224`](./src/CTDeployer.sol). +- PoC-equivalent test: + - `forge test --match-contract ClaimCollectionOwnershipTest -vvv` + - `test_postClaim_publisherNeedsNewPermissions()` passes and demonstrates the exact revert after claim. + +**Attack scenario:** +1. A project is deployed through `CTDeployer`, making `CTDeployer` the hook owner and the source of Croptop permissions. +2. The project owner calls `claimCollectionOwnershipOf()` to transfer hook ownership to the project. +3. No new permission grant is made from the project owner to `CTPublisher`. +4. The next Croptop post or posting-criteria update reverts because authorization is now checked against the project owner instead of `CTDeployer`. + +**Impact:** +- Post-claim, Croptop publishing is DoSed until the owner performs an undocumented extra permission grant. +- This is reachable on the canonical ownership-claim path and affects normal project lifecycle, so it is more than an informational footgun. + +**Suggested fix:** +```solidity +// In claimCollectionOwnershipOf(), grant CTPublisher permission from the new authority +// before or atomically with the ownership transfer. +``` + +--- + +### Finding FF-002: `maximumTotalSupply == 0` is documented as “unlimited” but is impossible to configure +**Severity:** LOW +**Module:** `CTPublisher` +**Function:** `configurePostingCriteriaFor` +**Lines:** `src/CTPublisher.sol:262-271`, `src/interfaces/ICTPublisher.sol:43-55`, `src/structs/CTAllowedPost.sol:6-11`, `src/structs/CTDeployerAllowedPost.sol:6-11` +**Verification:** Code trace + +**Feynman Question that exposed this:** +> What exact behavior is protected by the `min <= max` check, and does it match the documented “0 means no limit” sentinel? + +**Why this is wrong:** +The public docs and struct comments say `maximumTotalSupply == 0` means “no limit”. The implementation requires `minimumTotalSupply > 0` and then reverts whenever `minimumTotalSupply > maximumTotalSupply`. That makes `maximumTotalSupply == 0` unreachable for every valid configuration. + +**Verification evidence:** +- `minimumTotalSupply == 0` reverts at [`src/CTPublisher.sol:262`](./src/CTPublisher.sol). +- `minimumTotalSupply > maximumTotalSupply` reverts at [`src/CTPublisher.sol:267`](./src/CTPublisher.sol). +- Therefore a zero max can never pass because `minimumTotalSupply` must be positive. + +**Impact:** +- Configuration and deployment tooling that relies on the advertised sentinel value will revert unexpectedly. +- No direct fund loss; this is a configuration/API mismatch. + +**Suggested fix:** +```solidity +// Either implement 0 as "unlimited" in configurePostingCriteriaFor/_setupPosts, +// or remove the sentinel from the docs and interfaces. +``` + +## False Positives Eliminated +- `dataHookOf[projectId]` being unset can make proxy calls revert, but the deploy path sets it immediately after project launch and no in-scope flow exposes an attacker-controlled path to leave a deployed project permanently stuck on `address(0)`. +- Underlying data-hook reverts can brick pay/cashout for a ruleset, but this repo’s own project lifecycle can escape by queueing a new ruleset without the Croptop data hook; that is an operational risk, not a permanent logic lock in this codebase. + +## Downgraded Findings +- Re-running `script/Deploy.s.sol` with `FEE_PROJECT_ID == 0` creates an extra orphan fee project before checking whether contracts already exist. This wastes a project ID and gas, but it does not compromise deployed Croptop contracts because `CTPublisher` retains the original immutable fee-project reference. + +## Summary +- Total functions analyzed: 34 +- Raw findings (pre-verification): 0 CRITICAL | 0 HIGH | 1 MEDIUM | 2 LOW +- After verification: 2 TRUE POSITIVE | 1 FALSE POSITIVE | 1 DOWNGRADED +- Final: 0 HIGH | 1 MEDIUM | 1 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/croptop-core-v6/codex-nemesis-raw.md b/.audit-findings-codex-prev-20260323-211452/croptop-core-v6/codex-nemesis-raw.md new file mode 100644 index 0000000..1df9fb8 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/croptop-core-v6/codex-nemesis-raw.md @@ -0,0 +1,103 @@ +# N E M E S I S — Raw Findings + +## Phase 0 — Nemesis Recon + +**Language:** Solidity 0.8.26 + +**Attack goals** +1. Break Croptop posting so a project can no longer mint or configure posts. +2. Extract or bypass the 5% mint fee by desynchronizing price, tier, or payment state. +3. Abuse the sucker/data-hook boundary to obtain fee-free cash-outs or unauthorized mint authority. + +**Novel code** +- `src/CTPublisher.sol` — custom fee routing, URI-to-tier caching, and posting-criteria packing. +- `src/CTDeployer.sol` — ownership handoff + data-hook proxy + sucker privilege bridge. +- `script/ConfigureFeeProject.s.sol` — multi-system deployment/config wiring across Croptop, Revnet, router terminal, and suckers. + +**Value stores + initial coupling hypothesis** +- `CTPublisher` holds transient ETH during `mintFrom`. + - Outflows: project terminal payment, fee-project terminal payment. + - Suspected coupled state: `totalPrice`, fee math, cached tier prices, contract ETH balance. +- `CTDeployer` holds project ownership briefly during deployment. + - Outflows: `PROJECTS.transferFrom(address(this), owner, projectId)`. + - Suspected coupled state: hook ownership, publisher permissions, `dataHookOf`. +- `CTProjectOwner` permanently holds project NFTs. + - Outflows: none. + - Suspected coupled state: project ownership, publisher permission grants. + +**Complex paths** +- `deployProjectFor -> launchProjectFor -> dataHookOf set -> configurePostingCriteriaFor -> deploySuckersFor -> transfer project NFT` +- `mintFrom -> _setupPosts -> adjustTiers -> terminal.pay -> fee project pay` +- `claimCollectionOwnershipOf -> JBOwnable.transferOwnershipToProject -> later publisher permission checks` + +**Priority order** +1. `CTDeployer` ownership and hook/data-hook coupling +2. `CTPublisher` fee and tier-cache accounting +3. Deployment scripts and fee-project configuration ordering + +## Phase 1 — Dual Mapping + +### 1A Function-State Matrix +| Function | Reads | Writes | Guards | External Calls | +|----|----|----|----|----| +| `CTPublisher.configurePostingCriteriaFor` | hook owner, projectId, allowed post params | packed allowance, allowlist | `ADJUST_721_TIERS` | hook owner + projectId lookups | +| `CTPublisher.mintFrom` | hook projectId, store prices, fee project id, terminals | URI->tier cache through `_setupPosts` | category/allowlist constraints | `adjustTiers`, `terminal.pay` x2 | +| `CTDeployer.beforeCashOutRecordedWith` | sucker registry, `dataHookOf` | none | none | data hook forwarding | +| `CTDeployer.beforePayRecordedWith` | `dataHookOf` | none | none | data hook forwarding | +| `CTDeployer.claimCollectionOwnershipOf` | hook projectId, project owner | none | project NFT owner | `transferOwnershipToProject` | +| `CTDeployer.deployProjectFor` | projects count, config | `dataHookOf[projectId]` | controller consistency | hook deployer, controller, publisher, sucker registry, projects | +| `CTProjectOwner.onERC721Received` | tokenId, caller | JB permission bitmap | caller must be `PROJECTS` | `setPermissionsFor` | + +### 1B Coupled State Dependency Map +| Pair | Invariant | Mutation points | +|----|----|----| +| Hook owner ↔ publisher permission authority | publisher authorization must be sourced from the current hook owner | `deployProjectFor`, `claimCollectionOwnershipOf` | +| `dataHookOf[projectId]` ↔ ruleset data-hook flags | proxy forwarding target must match active ruleset expectation | `deployProjectFor` | +| `tierIdForEncodedIPFSUriOf` ↔ store tier existence | cached URI must point to live tier or be cleared | `_setupPosts` | +| `_packedAllowanceFor` ↔ `_allowedAddresses` | category policy is threshold + address gating together | `configurePostingCriteriaFor` | + +### 1C Cross-Reference +| Function | Writes A | Writes B | A↔B Pair | Sync Status | +|----|----|----|----|----| +| `deployProjectFor()` | hook owner authority source | publisher permission source | owner↔permission | SYNCED | +| `claimCollectionOwnershipOf()` | hook owner authority source | publisher permission source | owner↔permission | GAP | +| `_setupPosts()` | URI cache | tier store reference coherence | uri↔tier liveness | SYNCED via stale-tier cleanup | +| `configurePostingCriteriaFor()` | packed thresholds | allowlist | packed↔allowlist | SYNCED | + +## Phase 2 — Feynman Pass 1 + +### Raw Suspects +1. `claimCollectionOwnershipOf()` changes hook ownership without migrating `CTPublisher` permissions. +2. `maximumTotalSupply == 0` is documented as unlimited but rejected by validation. +3. Data-hook proxy may revert if `dataHookOf[projectId]` is unset or underlying hook reverts. +4. `script/Deploy.s.sol` may create an orphan fee project on reruns before checking whether contracts already exist. + +## Phase 3 — State Pass 2 + +### Confirmed Gaps +1. **Owner/permission desync** + - `deployProjectFor()` grants permissions from `CTDeployer`. + - `claimCollectionOwnershipOf()` switches owner resolution to the project. + - No counterpart permission update exists. + +### Rejected/unclear items +1. `dataHookOf[projectId] == address(0)` is not reachable for successfully deployed projects in normal flow. +2. `tierIdForEncodedIPFSUriOf` stale mappings are explicitly cleaned when removed tiers are encountered. + +## Phase 4 — Feedback Loop + +### Pass 3 Feynman re-interrogation +- Why does claim not migrate permissions? Because all earlier grants are scoped to `account: address(this)` in `deployProjectFor()`, while later checks resolve `account` from `JBOwnable.owner()`. +- What breaks downstream? Both posting-criteria updates and `mintFrom()` via `adjustTiers`. + +### Pass 4 State re-analysis +- No additional coupled pairs surfaced from the confirmed owner/permission desync. +- No new mutation paths found beyond the claim path. + +## Raw Findings Ledger +| ID | Severity | Status | Note | +|----|----|----|----| +| NM-R1 | MEDIUM | VERIFIED | ownership claim breaks publisher authorization | +| NM-R2 | LOW | VERIFIED | unlimited max-supply sentinel is non-functional | +| NM-R3 | MEDIUM | FALSE POSITIVE | data-hook proxy unset/permanent brick | +| NM-R4 | LOW | DOWNGRADED | deploy rerun creates orphan fee project, no live-contract compromise | diff --git a/.audit-findings-codex-prev-20260323-211452/croptop-core-v6/codex-nemesis-verified.md b/.audit-findings-codex-prev-20260323-211452/croptop-core-v6/codex-nemesis-verified.md new file mode 100644 index 0000000..05cd00d --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/croptop-core-v6/codex-nemesis-verified.md @@ -0,0 +1,129 @@ +# N E M E S I S — Verified Findings + +## Scope +- Language: Solidity 0.8.26 +- Modules analyzed: 14 Solidity files +- Functions analyzed: 34 +- Coupled state pairs mapped: 4 +- Mutation paths traced: 8 +- Nemesis loop iterations: 2 targeted feedback iterations after the initial full Feynman and State passes + +## Nemesis Map (Phase 1 Cross-Reference) +| Function | Writes A | Writes B | A↔B Pair | Sync Status | +|----|----|----|----|----| +| `deployProjectFor()` | hook owner authority source | publisher permission source | owner↔permission | `SYNCED` | +| `claimCollectionOwnershipOf()` | hook owner authority source | publisher permission source | owner↔permission | `GAP` | +| `_setupPosts()` | URI cache | tier liveness coherence | uri↔tier | `SYNCED` | +| `configurePostingCriteriaFor()` | numeric thresholds | allowlist | packed↔allowlist | `SYNCED` | + +## Verification Summary +| ID | Source | Coupled Pair | Breaking Op | Severity | Verdict | +|----|----|----|----|----|----| +| NM-001 | Cross-feed P2→P3 | hook owner ↔ publisher permission authority | `claimCollectionOwnershipOf()` | MEDIUM | TRUE POSITIVE | +| NM-002 | Feynman-only | config docs ↔ validation semantics | `configurePostingCriteriaFor()` | LOW | TRUE POSITIVE | + +## Verified Findings + +### Finding NM-001: Ownership claims desynchronize hook ownership from the publisher permission authority +**Severity:** MEDIUM +**Source:** Cross-feed P2→P3 +**Verification:** Hybrid + +**Coupled Pair:** hook owner ↔ permission authority for `CTPublisher` +**Invariant:** The authority account returned by `JBOwnable(hook).owner()` must also be the account whose permission bitmap allows `CTPublisher` to adjust tiers. + +**Feynman Question that exposed it:** +> Why does `claimCollectionOwnershipOf()` move the hook owner but leave every Croptop permission grant anchored to `CTDeployer`? + +**State Mapper gap that confirmed it:** +> `deployProjectFor()` updates both sides of the owner/permission pair, while `claimCollectionOwnershipOf()` updates only the owner side. + +**Breaking Operation:** `claimCollectionOwnershipOf()` at [`src/CTDeployer.sol:224`](./src/CTDeployer.sol) +- Modifies State A: `JBOwnable(address(hook)).transferOwnershipToProject(projectId)` switches hook ownership to the project. +- Does NOT update State B: no new `PERMISSIONS.setPermissionsFor(...)` grant is created from the project owner to `CTPublisher`. + +**Trigger Sequence:** +1. Deploy a project via `deployProjectFor()`. This grants Croptop permissions from `CTDeployer` at [`src/CTDeployer.sol:339`](./src/CTDeployer.sol). +2. Project owner calls `claimCollectionOwnershipOf()`. +3. `JBOwnable(hook).owner()` now resolves to `PROJECTS.ownerOf(projectId)`. +4. A later `configurePostingCriteriaFor()` or `mintFrom()` authorizes against the new owner at [`src/CTPublisher.sol:256`](./src/CTPublisher.sol), but no matching permission exists. +5. Publishing reverts until the owner manually re-grants `ADJUST_721_TIERS` to the publisher. + +**Consequence:** +- Croptop publishing enters a broken state on the normal ownership-claim path. +- The failure is not self-healing and affects both new-tier publication and any `mintFrom()` call that traverses `adjustTiers`. + +**Verification Evidence:** +- Code trace: + - Permission grant from the deployer authority: [`src/CTDeployer.sol:339`](./src/CTDeployer.sol) + - Ownership handoff with no coupled permission update: [`src/CTDeployer.sol:224`](./src/CTDeployer.sol) + - Authorization check against current hook owner: [`src/CTPublisher.sol:256`](./src/CTPublisher.sol) + - Posting flow always reaches `hook.adjustTiers(...)`: [`src/CTPublisher.sol:358`](./src/CTPublisher.sol) +- PoC result: + - `forge test --match-contract ClaimCollectionOwnershipTest -vvv` + - `test_postClaim_publisherNeedsNewPermissions()` passes, confirming the post-claim revert scenario. + +**Fix:** +```solidity +// When claiming ownership, atomically grant CTPublisher the required permission +// from the new authority account or preserve an equivalent owner-independent auth path. +``` + +--- + +### Finding NM-002: The advertised “0 means unlimited max supply” sentinel is non-functional +**Severity:** LOW +**Source:** Feynman-only +**Verification:** Code trace + +**Coupled Pair:** Public configuration semantics ↔ on-chain validation +**Invariant:** The documented meaning of `maximumTotalSupply` must match what `configurePostingCriteriaFor()` accepts. + +**Feynman Question that exposed it:** +> If `0` is meant to mean “no limit”, what execution path actually accepts it? + +**Breaking Operation:** `configurePostingCriteriaFor()` at [`src/CTPublisher.sol:262`](./src/CTPublisher.sol) +- Modifies State A: validates and stores posting criteria. +- Does NOT preserve State B: docs/interfaces still advertise `0` as unlimited even though the validator rejects it. + +**Trigger Sequence:** +1. Caller follows the documented API and sets `maximumTotalSupply = 0`. +2. `minimumTotalSupply` must still be positive. +3. Validation reverts because `minimumTotalSupply > maximumTotalSupply`. + +**Consequence:** +- Integrators and deployment scripts can fail unexpectedly when using the documented sentinel value. +- No direct exploit or fund loss. + +**Verification Evidence:** +- Rejection path: + - non-zero minimum required at [`src/CTPublisher.sol:262`](./src/CTPublisher.sol) + - `min > max` revert at [`src/CTPublisher.sol:267`](./src/CTPublisher.sol) +- Conflicting docs: + - interface comment at [`src/interfaces/ICTPublisher.sol:43`](./src/interfaces/ICTPublisher.sol) + - struct comments at [`src/structs/CTAllowedPost.sol:6`](./src/structs/CTAllowedPost.sol) and [`src/structs/CTDeployerAllowedPost.sol:6`](./src/structs/CTDeployerAllowedPost.sol) + +**Fix:** +```solidity +// Either implement 0 as "unlimited" everywhere, or remove the sentinel from the public API/docs. +``` + +## Feedback Loop Discoveries +- `NM-001` required both passes: + - State pass found the missing counterpart update in the owner↔permission pair. + - Feynman re-interrogation traced why the gap becomes a user-visible DoS only after `claimCollectionOwnershipOf()`. + +## False Positives Eliminated +- Permanent bricking via reverting `dataHookOf[projectId]` target: operationally escapable through ruleset changes, so not reported as a verified vulnerability in this repo. + +## Downgraded Findings +- `script/Deploy.s.sol` creates an orphan fee project on reruns when `FEE_PROJECT_ID` remains zero. This is a deployment hygiene issue, not a live-contract exploit. + +## Summary +- Total functions analyzed: 34 +- Coupled state pairs mapped: 4 +- Nemesis loop iterations: 2 +- Raw findings (pre-verification): 0 C | 0 H | 1 M | 2 L +- Feedback loop discoveries: 1 +- After verification: 2 TRUE POSITIVE | 1 FALSE POSITIVE | 1 DOWNGRADED +- Final: 0 CRITICAL | 0 HIGH | 1 MEDIUM | 1 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/croptop-core-v6/codex-state-inconsistency-verified.md b/.audit-findings-codex-prev-20260323-211452/croptop-core-v6/codex-state-inconsistency-verified.md new file mode 100644 index 0000000..12588a4 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/croptop-core-v6/codex-state-inconsistency-verified.md @@ -0,0 +1,71 @@ +# State Inconsistency Audit — Verified Findings + +## Coupled State Dependency Map +- `hook owner` ↔ `who must authorize `ADJUST_721_TIERS` for `CTPublisher`` + - Invariant: whenever hook ownership changes, the authority bitmap that `CTPublisher` checks must change with it. + - Mutation points: `CTDeployer.deployProjectFor`, `CTDeployer.claimCollectionOwnershipOf`. +- `dataHookOf[projectId]` ↔ ruleset metadata `dataHook/useDataHookForPay/useDataHookForCashOut` + - Invariant: Croptop proxy forwarding only works when both the ruleset and `dataHookOf` point to the same hook. +- `tierIdForEncodedIPFSUriOf[hook][uri]` ↔ tier liveness in `JB721TiersHookStore` + - Invariant: cached tier IDs must either resolve to a live tier or be cleared before reuse. +- `_packedAllowanceFor[hook][category]` ↔ `_allowedAddresses[hook][category]` + - Invariant: category policy is the combination of numeric thresholds and address allowlist. + +## Mutation Matrix +| State Variable | Mutating Function | Updates Coupled State? | +|----|----|----| +| Hook owner / authority source | `CTDeployer.deployProjectFor` | `YES` — owner is `CTDeployer`, publisher permission is also granted from `CTDeployer` | +| Hook owner / authority source | `CTDeployer.claimCollectionOwnershipOf` | `NO` — owner changes to project, publisher permission source is not migrated | +| `dataHookOf[projectId]` | `CTDeployer.deployProjectFor` | `YES` | +| `tierIdForEncodedIPFSUriOf` | `CTPublisher._setupPosts` | `YES` on new tier and stale-tier cleanup | +| Packed allowance + allowlist | `CTPublisher.configurePostingCriteriaFor` | `YES` | + +## Parallel Path Comparison +| Coupled State | Deploy path | Claim path | +|----|----|----| +| Hook owner ↔ publisher permission source | `deployProjectFor()` sets owner authority and grants permissions from the same authority | `claimCollectionOwnershipOf()` changes owner authority only | + +## Verification Summary +| ID | Coupled Pair | Breaking Op | Original Severity | Verdict | Final Severity | +|----|----|----|----|----|----| +| SI-001 | hook owner ↔ publisher permission authority | `claimCollectionOwnershipOf()` | MEDIUM | TRUE POSITIVE | MEDIUM | + +## Verified Findings + +### Finding SI-001: Ownership claim updates the hook owner without updating the publisher’s dependent permission authority +**Severity:** MEDIUM +**Verification:** Hybrid + +**Coupled Pair:** hook owner ↔ permission authority for `CTPublisher` +**Invariant:** The account returned by `JBOwnable(hook).owner()` must be the same account whose permission bitmap authorizes `CTPublisher` to adjust tiers. + +**Breaking Operation:** `claimCollectionOwnershipOf()` in [`src/CTDeployer.sol:224`](./src/CTDeployer.sol) +- Modifies State A: moves hook ownership from `CTDeployer` to `projectId`. +- Does NOT update State B: no `setPermissionsFor` call gives `CTPublisher` `ADJUST_721_TIERS` permission from the new owner. + +**Trigger Sequence:** +1. Deploy a project through `deployProjectFor()`, which leaves `CTDeployer` as hook owner and permission account. +2. Owner calls `claimCollectionOwnershipOf()`. +3. `CTPublisher.configurePostingCriteriaFor()` or `mintFrom()` runs later. +4. Authorization is now checked against the project owner and fails unless the owner manually grants the publisher permission. + +**Consequence:** +- Croptop posting stops working after the ownership-claim path. +- This is a state-coupling bug between ownership resolution in `JBOwnable` and delegated authorization in `JBPermissions`. + +**Fix:** +```solidity +// When ownership is transferred to the project, atomically grant the publisher +// the matching permission from the new authority account. +``` + +## False Positives Eliminated +- `tierIdForEncodedIPFSUriOf` stale-cache risk is actively reconciled by the removal check at [`src/CTPublisher.sol:493`](./src/CTPublisher.sol). +- `dataHookOf[projectId]` unset state is observable but not reachable through the intended deployment lifecycle for a live Croptop project. + +## Summary +- Coupled state pairs mapped: 4 +- Mutation paths analyzed: 8 +- Raw findings (pre-verification): 1 +- After verification: 1 TRUE POSITIVE | 0 FALSE POSITIVE +- Final: 0 CRITICAL | 0 HIGH | 1 MEDIUM | 0 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/defifa-collection-deployer-v6/codex-feynman-verified.md b/.audit-findings-codex-prev-20260323-211452/defifa-collection-deployer-v6/codex-feynman-verified.md new file mode 100644 index 0000000..b82cff0 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/defifa-collection-deployer-v6/codex-feynman-verified.md @@ -0,0 +1,78 @@ +# Feynman Audit — Verified Findings + +## Scope +- Language: Solidity 0.8.26 +- Modules analyzed: 24 Solidity files under `src/` and `script/` +- Functions analyzed: 152 + +## Verification Summary +| ID | Original Severity | Verdict | Final Severity | +|----|-------------------|---------|----------------| +| FF-001 | HIGH | TRUE POSITIVE | HIGH | +| FF-002 | LOW | TRUE POSITIVE | LOW | + +## Verified Findings + +### Finding FF-001: HIGH — Default delegated attestation power follows `payer`, not the NFT recipient +**Module:** `DefifaHook` +**Function:** `_processPayment` +**Lines:** `src/DefifaHook.sol:950-979` +**Verification:** Hybrid — code trace + PoC `test/DefifaHookRegressions.t.sol::test_attestationUnitsFollowPayerInsteadOfBeneficiary` + +**Feynman question that exposed this:** +> Why does the mint path send the NFT to `context.beneficiary` but default the attestation delegate and attestation-unit transfer to `context.payer`? + +**Why this is wrong:** +When the payer leaves the delegate unset in pay metadata, the hook defaults `_attestationDelegate` to `context.payer` and mutates `_tierDelegation[context.payer]`, then mints attestation units to `context.payer`. The NFT itself is minted to `context.beneficiary`. That means the default path splits governance power from NFT ownership whenever `payer != beneficiary`. + +**Verification evidence:** +- Code trace: + - `src/DefifaHook.sol:950-953` defaults the delegate to `context.payer`. + - `src/DefifaHook.sol:968-979` reads and updates delegation/checkpoints for `context.payer`. + - `src/DefifaHook.sol:990` mints the NFT to `context.beneficiary`. +- PoC: + - `forge test --match-test test_attestationUnitsFollowPayerInsteadOfBeneficiary -vvv` + - Passes with the payer receiving positive attestation weight and the NFT beneficiary receiving zero. + +**Attack scenario:** +1. An attacker pays for outcome NFTs with `beneficiary` set to another account and metadata delegate left as `address(0)`. +2. The beneficiary receives the NFT and appears to own the position. +3. The attacker retains the attestation power for scorecard voting. +4. The attacker can accumulate quorum and ratify a self-serving scorecard without holding the NFTs they funded. + +**Impact:** +This breaks the core governance invariant that attestation power tracks the actual NFT holder unless a holder explicitly delegates it away. Sponsored mints, custodial flows, marketplaces, or UI defaults can silently assign voting power to the payer instead of the holder. + +**Suggested fix:** +Default attestation ownership to `context.beneficiary`, not `context.payer`, and key the default delegation/checkpoint transfer off the NFT recipient unless an explicit delegate override is provided. + +### Finding FF-002: LOW — Deployment helper reads `defifa-v5` artifacts while the deploy script publishes `defifa-v6` +**Module:** `DefifaDeploymentLib` +**Function:** `getDeployment` +**Lines:** `script/helpers/DefifaDeploymentLib.sol:25,52-73` +**Verification:** Code trace + +**Feynman question that exposed this:** +> Why does the deployment reader point at a different Sphinx project name than the deployment script writes? + +**Why this is wrong:** +`script/Deploy.s.sol:36-39` configures Sphinx with project name `defifa-v6`, but `script/helpers/DefifaDeploymentLib.sol:25` hardcodes `PROJECT_NAME = "defifa-v5"`. Any script or operational tool using `DefifaDeploymentLib` will read from the wrong artifact directory. + +**Verification evidence:** +- `script/Deploy.s.sol:37` sets `sphinxConfig.projectName = "defifa-v6"`. +- `script/helpers/DefifaDeploymentLib.sol:25` sets `PROJECT_NAME = "defifa-v5"`. +- `script/helpers/DefifaDeploymentLib.sol:52-73` uses that constant for every contract lookup. + +**Impact:** +Operational tooling can resolve stale or nonexistent deployment addresses and target the wrong contracts during post-deploy verification or integrations. + +**Suggested fix:** +Change `PROJECT_NAME` to `defifa-v6`, or derive it from a shared constant used by both the deploy script and helper library. + +## False Positives Eliminated +- The `payer != beneficiary` path is not always wrong when the payer explicitly sets the delegate to the beneficiary in pay metadata. The verified bug is the default-path behavior when the delegate is left unset. + +## Summary +- Raw findings reviewed: 2 +- After verification: 2 true positives, 0 false positives, 0 downgrades +- Final: 1 HIGH, 1 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/defifa-collection-deployer-v6/codex-nemesis-raw.md b/.audit-findings-codex-prev-20260323-211452/defifa-collection-deployer-v6/codex-nemesis-raw.md new file mode 100644 index 0000000..bb4a7ae --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/defifa-collection-deployer-v6/codex-nemesis-raw.md @@ -0,0 +1,57 @@ +# N E M E S I S — Raw Findings + +## Scope +- Language: Solidity 0.8.26 +- Files reviewed: 24 Solidity files under `src/` and `script/` +- Functions reviewed: 152 + +## Phase 0 — Recon + +### Attack Goals +1. Capture scorecard governance without holding the corresponding winning/losing NFTs. +2. Corrupt cash-out and fee-token accounting so game pots or side-token distributions can be extracted incorrectly. +3. Misconfigure deployment or post-deploy tooling so production integrations target the wrong contracts. + +### Novel Code +- `src/DefifaHook.sol` — custom attestation checkpoint system layered on top of JB721 mint/burn/transfer hooks. +- `src/DefifaGovernor.sol` — custom scorecard submission, attestation, quorum, and ratification flow. +- `src/DefifaDeployer.sol` — bespoke phase machine, commitment fulfillment, split normalization, and no-contest handling. + +### Value Stores + Initial Coupling Hypothesis +- Game pot in `JBMultiTerminal/JBTerminalStore` + - Coupled to `fulfilledCommitmentsOf`, `amountRedeemed`, scorecard-set cash-out weights. +- NFT ownership in `DefifaHook` + - Coupled to per-tier attestation checkpoints and governance eligibility. +- `_totalMintCost` + - Coupled to live/reserved mint population and fee-token claims. + +### Complex Paths +- `pay()` → `DefifaHook._processPayment()` → attestation checkpoints + NFT mint +- `submitScorecardFor()` / `attestToScorecardFrom()` / `ratifyScorecardFrom()` → `DefifaHook.setTierCashOutWeightsTo()` → `DefifaDeployer.fulfillCommitmentsOf()` +- `cashOutTokensOf()` → `beforeCashOutRecordedWith()` → `afterCashOutRecordedWith()` → fee-token claim flow + +## Pass 1 — Feynman Raw Findings +1. SUSPECT: `DefifaHook._processPayment()` defaults governance power to `context.payer` while minting NFTs to `context.beneficiary`. +2. SUSPECT: `DefifaDeploymentLib` hardcodes `defifa-v5` while `Deploy.s.sol` deploys `defifa-v6`. + +## Pass 2 — State Cross-Check +1. CONFIRMED GAP: Mint path updates ownership for beneficiary but attestation delegation/checkpoints for payer. +2. No additional coupled-state gaps of similar severity found in burn, transfer, reserve-mint, fulfillment, or scorecard ratification paths after call-chain tracing. + +## Targeted Re-Pass +- Re-interrogated the `payer/beneficiary` mint path. +- Verified that explicit delegate metadata can route power elsewhere, so the bug is the default path only. +- Re-checked transfer path: attestation units move correctly during `_update()`; no parallel transfer bug remained. +- Re-checked no-contest and fulfillment sequencing; no additional exploitable deltas surfaced. + +## Raw Finding Inventory +| ID | Source | Severity | Status | +|----|--------|----------|--------| +| NM-RAW-001 | Cross-feed P1→P2 | HIGH | Verified | +| NM-RAW-002 | Feynman-only | LOW | Verified | + +## Convergence +- Full passes completed: Feynman + State +- Targeted re-passes completed: 2 +- New findings in final pass: 0 +- Converged: yes diff --git a/.audit-findings-codex-prev-20260323-211452/defifa-collection-deployer-v6/codex-nemesis-verified.md b/.audit-findings-codex-prev-20260323-211452/defifa-collection-deployer-v6/codex-nemesis-verified.md new file mode 100644 index 0000000..28f063e --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/defifa-collection-deployer-v6/codex-nemesis-verified.md @@ -0,0 +1,131 @@ +# N E M E S I S — Verified Findings + +## Scope +- Language: Solidity 0.8.26 +- Modules analyzed: 24 Solidity files +- Functions analyzed: 152 +- Coupled state pairs mapped: 4 +- Mutation paths traced: 9 +- Nemesis loop iterations: 4 passes total (Feynman full, State full, Feynman targeted, State targeted) + +## Nemesis Map +| Function | Writes ownership/beneficiary | Writes attestation state | Coupled Pair | Sync Status | +|----|----|----|----|----| +| `DefifaHook._processPayment()` | Yes, to `context.beneficiary` | Yes, to `context.payer` | owner ↔ attestation | GAP | +| `DefifaHook._update()` | Yes, to `to`/`from` | Yes, moves attestation units with transfer | owner ↔ attestation | SYNCED | +| `DefifaHook.afterCashOutRecordedWith()` | Burns ownership | Burns attestation units through transfer/burn path | owner ↔ attestation | SYNCED | + +## Verification Summary +| ID | Source | Coupled Pair | Breaking Op | Severity | Verdict | +|----|--------|-------------|-------------|----------|---------| +| NM-001 | Cross-feed P1→P2 | NFT recipient ↔ attestation checkpoints | `_processPayment()` | HIGH | TRUE POSITIVE | +| NM-002 | Feynman-only | Deployment script/project-name consistency | `DefifaDeploymentLib.getDeployment()` | LOW | TRUE POSITIVE | + +## Verified Findings + +### Finding NM-001: HIGH — Sponsored mints give default governance power to the payer instead of the NFT holder +**Severity:** HIGH +**Source:** Cross-feed P1→P2 +**Verification:** Hybrid + +**Coupled Pair:** NFT recipient/owner ↔ tier attestation delegation and checkpoints +**Invariant:** A newly minted NFT’s default attestation power should follow the account receiving the NFT unless an explicit delegate override is supplied. + +**Feynman question that exposed it:** +> Why is the default delegate derived from `context.payer` while the NFT is minted to `context.beneficiary`? + +**State Mapper gap that confirmed it:** +> The mint path updates ERC721 ownership for `beneficiary`, but the coupled governance state writes `_tierDelegation[context.payer]` and mints attestation units to `context.payer`. + +**Breaking Operation:** `src/DefifaHook.sol:950-979` +- Modifies owner-side state: NFT mint goes to `context.beneficiary` at `src/DefifaHook.sol:990` +- Does not update the same account’s governance counterpart: attestation defaults and checkpoint writes are keyed to `context.payer` + +**Trigger Sequence:** +1. A user pays for a Defifa mint and sets `beneficiary` to another address. +2. The pay metadata leaves the attestation delegate unset (`address(0)`). +3. The hook defaults the delegate to `context.payer` and transfers attestation units through the payer path. +4. The beneficiary receives the NFT, but the payer receives the governance weight. +5. During scoring, the payer can attest and the NFT holder cannot. + +**Consequence:** +- Governance becomes decoupled from NFT ownership. +- A sponsor/custodian/attacker can buy outcome NFTs for others while silently retaining the ability to sway or ratify the scorecard. +- This undermines the protocol’s stated attestation model, where each tier’s holder community should determine the outcome weighting. + +**Verification Evidence:** +- Code trace: + - `src/DefifaHook.sol:950-953` + - `src/DefifaHook.sol:968-979` + - `src/DefifaHook.sol:990` +- PoC: + - `test/DefifaHookRegressions.t.sol:318-346` + - Command: `forge test --match-test test_attestationUnitsFollowPayerInsteadOfBeneficiary -vvv` + - Result: pass + +**Fix:** +```solidity +if (_attestationDelegate == address(0)) { + _attestationDelegate = + defaultAttestationDelegate != address(0) ? defaultAttestationDelegate : context.beneficiary; +} + +address _attestationAccount = context.beneficiary; +address _oldDelegate = _tierDelegation[_attestationAccount][_tierId]; +_delegateTier({_account: _attestationAccount, _delegatee: _attestationDelegate, _tierId: _tierId}); +_transferTierAttestationUnits({ + _from: address(0), + _to: _attestationAccount, + _tierId: _tierId, + _amount: _attestationAmounts[_i] +}); +``` + +### Finding NM-002: LOW — `DefifaDeploymentLib` resolves v5 deployment artifacts while the v6 deploy script publishes under `defifa-v6` +**Severity:** LOW +**Source:** Feynman-only +**Verification:** Code trace + +**Coupled Pair:** Deployment writer project name ↔ deployment reader project name +**Invariant:** Artifact readers must target the same Sphinx project namespace that the deploy script publishes. + +**Feynman question that exposed it:** +> Why does the artifact reader use a different Sphinx project name than the deploy script? + +**Breaking Operation:** `script/helpers/DefifaDeploymentLib.sol:25,52-73` +- `PROJECT_NAME = "defifa-v5"` +- Every deployment lookup uses that stale project name + +**Trigger Sequence:** +1. A v6 deployment is produced via `script/Deploy.s.sol`, which sets `sphinxConfig.projectName = "defifa-v6"`. +2. An operator or follow-up script uses `DefifaDeploymentLib.getDeployment(...)`. +3. The helper reads the `defifa-v5` artifact path instead of the v6 path. +4. The caller gets stale or missing addresses. + +**Consequence:** +- Post-deploy automation can target incorrect contracts or fail unexpectedly. +- This is operationally dangerous but does not directly affect on-chain fund safety. + +**Verification Evidence:** +- `script/Deploy.s.sol:36-39` +- `script/helpers/DefifaDeploymentLib.sol:25,52-73` + +**Fix:** +```solidity +string constant PROJECT_NAME = "defifa-v6"; +``` + +## Feedback Loop Discoveries +- `NM-001` is the highest-value Nemesis result. Feynman exposed the payer/beneficiary asymmetry, and the state pass confirmed it as a real ownership↔governance desynchronization instead of an intended delegation feature. + +## False Positives Eliminated +- Explicitly supplying the beneficiary as the delegate in pay metadata routes the attestation power correctly. The verified issue is the default delegate fallback, not every `payer != beneficiary` payment. + +## Summary +- Total functions analyzed: 152 +- Coupled state pairs mapped: 4 +- Nemesis loop iterations: 4 +- Raw findings (pre-verification): 0 critical, 1 high, 0 medium, 1 low +- Feedback loop discoveries: 1 +- After verification: 2 true positives, 0 false positives, 0 downgraded +- Final: 0 critical, 1 high, 0 medium, 1 low diff --git a/.audit-findings-codex-prev-20260323-211452/defifa-collection-deployer-v6/codex-state-inconsistency-verified.md b/.audit-findings-codex-prev-20260323-211452/defifa-collection-deployer-v6/codex-state-inconsistency-verified.md new file mode 100644 index 0000000..e9f6fdb --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/defifa-collection-deployer-v6/codex-state-inconsistency-verified.md @@ -0,0 +1,72 @@ +# State Inconsistency Audit — Verified Findings + +## Coupled State Dependency Map +- `ERC721 ownership / beneficiary` ↔ `tier attestation delegation + checkpoints` + - Invariant: default governance power should track the recipient of the NFT unless explicitly delegated elsewhere. +- `tier burn counters` ↔ `tokensRedeemedFrom[tier]` ↔ `cashOutWeight` +- `_totalMintCost` ↔ live/reserved mint population used for fee-token claims +- `fulfilledCommitmentsOf[gameId]` ↔ payout execution / remaining pot accounting + +## Mutation Matrix +| State Variable | Mutating Function | Updates Coupled State? | +|---|---|---| +| NFT ownership | `DefifaHook._mintAll()` | Writes beneficiary ownership | +| Attestation delegation/checkpoints | `DefifaHook._processPayment()` | Writes payer-side delegation/checkpoints | +| NFT ownership | `DefifaHook._update()` | Yes, transfer path moves attestation units correctly | + +## Parallel Path Comparison +| Coupled State | Mint Path | Transfer Path | +|---|---|---| +| Ownership ↔ attestation units | `beneficiary` ownership, `payer` default attestation | `from/to` ownership and attestation stay synchronized | + +## Verification Summary +| ID | Coupled Pair | Breaking Op | Original Severity | Verdict | Final Severity | +|----|-------------|-------------|-------------------|---------|----------------| +| SI-001 | NFT owner/beneficiary ↔ attestation checkpoints | `_processPayment()` | HIGH | TRUE POSITIVE | HIGH | + +## Verified Findings + +### Finding SI-001: HIGH — Mint path updates NFT ownership for `beneficiary` but governance checkpoints for `payer` +**Severity:** HIGH +**Verification:** Hybrid — code trace + PoC + +**Coupled Pair:** NFT recipient/owner ↔ attestation delegation + checkpoint balances +**Invariant:** The account that receives a newly minted Defifa NFT should receive its default governance power unless an explicit delegate override says otherwise. + +**Breaking Operation:** `_processPayment()` in `src/DefifaHook.sol:950-979` +- Modifies NFT ownership: `_mintAll(..., _beneficiary: context.beneficiary)` at `src/DefifaHook.sol:990` +- Does not update the same account’s attestation state: default delegate and attestation transfer are keyed to `context.payer` + +**Trigger Sequence:** +1. A payer calls `pay()` with `beneficiary != payer`. +2. The payer leaves the delegate as `address(0)` in pay metadata. +3. The hook defaults delegation to `context.payer` and credits attestation units through the payer’s delegation path. +4. The NFT is minted to `context.beneficiary`. +5. During scoring, the payer has attestation weight and the NFT holder has none. + +**Consequence:** +- Governance power silently detaches from NFT ownership. +- A sponsor/custodian/attacker can accumulate voting power across NFTs they do not hold. +- Scorecard ratification can reflect payer identity instead of holder consensus. + +**Verification Evidence:** +- Code trace: + - `src/DefifaHook.sol:950-953` + - `src/DefifaHook.sol:968-979` + - `src/DefifaHook.sol:990` +- PoC: + - `forge test --match-test test_attestationUnitsFollowPayerInsteadOfBeneficiary -vvv` + - Result: pass + +**Fix:** +Key the default delegation/checkpoint write path to `context.beneficiary`, or require the payer to supply an explicit delegate whenever `payer != beneficiary`. + +## False Positives Eliminated +- The transfer path in `DefifaHook._update()` is not affected. It correctly moves attestation units between `from` and `to`. + +## Summary +- Coupled state pairs mapped: 4 +- Mutation paths analyzed: 9 +- Raw findings reviewed: 1 +- After verification: 1 true positive, 0 false positives +- Final: 1 HIGH diff --git a/.audit-findings-codex-prev-20260323-211452/deploy-all-v6/codex-feynman-verified.md b/.audit-findings-codex-prev-20260323-211452/deploy-all-v6/codex-feynman-verified.md new file mode 100644 index 0000000..684708c --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/deploy-all-v6/codex-feynman-verified.md @@ -0,0 +1,133 @@ +# Feynman Audit — Verified Findings + +## Scope +- Language: Solidity 0.8.26 +- In-scope files analyzed: `script/Deploy.s.sol` +- `src/` Solidity files found: 0 +- Functions analyzed: 33 + +## Verification Summary +| ID | Original Severity | Verdict | Final Severity | +|----|-------------------|---------|----------------| +| FF-001 | HIGH | TRUE POSITIVE | HIGH | +| FF-002 | HIGH | TRUE POSITIVE | HIGH | +| FF-003 | MEDIUM | TRUE POSITIVE | MEDIUM | + +## Verified Findings + +### Finding FF-001: Sucker singleton constructors bake `REGISTRY = address(0)` into every clone +**Severity:** HIGH +**Module:** `script/Deploy.s.sol` +**Function:** `_deploySuckers` +**Lines:** `script/Deploy.s.sol:856-872`, `script/Deploy.s.sol:911-920`, `script/Deploy.s.sol:1219-1228` +**Verification:** Code trace + +**Feynman Question that exposed this:** +> Why are the sucker singletons deployed before the registry they receive in their constructor? + +**Why this is wrong:** +`_deploySuckers()` deploys all sucker singletons first, then deploys `_suckerRegistry` afterward. Both `JBOptimismSucker` and `JBCCIPSucker` constructors receive `_suckerRegistry` while it is still zero. In `JBSucker`, `REGISTRY` is immutable and set only in the constructor (`node_modules/@bananapus/suckers-v6/src/JBSucker.sol:107-108`, `:161-175`). `JBSuckerDeployer.createForSender()` then clones that singleton (`node_modules/@bananapus/suckers-v6/src/deployers/JBSuckerDeployer.sol:133-150`), so every deployed sucker inherits the zero-valued immutable forever. + +**Verification evidence:** +- Deployment order: `script/Deploy.s.sol:856-872` +- Zero registry passed into singleton constructor: + - `script/Deploy.s.sol:911-920` + - `script/Deploy.s.sol:1219-1228` +- Immutable assignment: `node_modules/@bananapus/suckers-v6/src/JBSucker.sol:161-175` +- Runtime use: `REGISTRY.toRemoteFee()` in `node_modules/@bananapus/suckers-v6/src/JBSucker.sol:682-683` + +**Attack scenario:** +1. Governance runs this deploy script on a fresh chain. +2. The script deploys sucker singletons before `_suckerRegistry` exists. +3. A project later deploys a sucker clone from one of those deployers. +4. The clone calls `toRemote()`. +5. `REGISTRY.toRemoteFee()` executes against `address(0)` and the bridge-out flow reverts. + +**Impact:** +Every sucker deployed from these singletons is permanently misconfigured. Cross-chain bridging is unavailable until the entire singleton/deployer stack is redeployed with the correct constructor argument. + +**Suggested fix:** +Deploy `_suckerRegistry` before any sucker singleton constructor runs, or redesign the sucker to read the registry from mutable storage initialized after cloning rather than from an immutable constructor argument. + +--- + +### Finding FF-002: Optimism Sepolia skips the Uniswap stack but still wires zero-address buyback and router dependencies into revnets +**Severity:** HIGH +**Module:** `script/Deploy.s.sol` +**Function:** `_shouldDeployUniswapStack`, `_deployRevnet`, `_deployRevFeeProject`, `_deployCpnRevnet`, `_deployNanaRevnet`, `_deployBanny` +**Lines:** `script/Deploy.s.sol:471-472`, `:1529-1553`, `:1570-1575`, `:1670-1675`, `:1841-1845`, `:1940-1945` +**Verification:** Code trace + +**Feynman Question that exposed this:** +> If the script intentionally skips the Uniswap stack on Optimism Sepolia, why do later phases still consume `_buybackRegistry` and `_routerTerminalRegistry` unconditionally? + +**Why this is wrong:** +`_shouldDeployUniswapStack()` returns false on Optimism Sepolia, so the script never deploys `_buybackRegistry` or `_routerTerminalRegistry`. Later phases still pass `IJBBuybackHookRegistry(address(_buybackRegistry))` into the `REVDeployer` constructor and still append `IJBTerminal(address(_routerTerminalRegistry))` to every revnet’s terminal config. `REVDeployer` then unconditionally calls `BUYBACK_HOOK.beforePayRecordedWith`, `BUYBACK_HOOK.beforeCashOutRecordedWith`, and `BUYBACK_HOOK.hasMintPermissionFor` (`node_modules/@rev-net/core-v6/src/REVDeployer.sol:275-277`, `:371`, `:409-410`). On Optimism Sepolia those calls target `address(0)`. + +**Verification evidence:** +- Uniswap stack skip: `script/Deploy.s.sol:471-472` +- Zero buyback registry injected into `REVDeployer`: `script/Deploy.s.sol:1529-1553` +- Zero router terminal injected into revnet configs: + - `script/Deploy.s.sol:1570-1575` + - `script/Deploy.s.sol:1670-1675` + - `script/Deploy.s.sol:1841-1845` + - `script/Deploy.s.sol:1940-1945` +- `REVDeployer` direct hook calls: + - `node_modules/@rev-net/core-v6/src/REVDeployer.sol:275-277` + - `node_modules/@rev-net/core-v6/src/REVDeployer.sol:371` + - `node_modules/@rev-net/core-v6/src/REVDeployer.sol:409-410` +- Terminal configs are consumed without zero-address filtering in `JBController._configureTerminals`: `node_modules/@bananapus/core-v6/src/JBController.sol:887-907` + +**Attack scenario:** +1. Governance deploys on Optimism Sepolia. +2. Uniswap-related phases are skipped by design. +3. Revnets are still deployed with zero buyback/router addresses. +4. First payment, cash-out, or mint-permission check hits `REVDeployer`. +5. The hook path calls the zero address and the revnet flow reverts. + +**Impact:** +The advertised “non-Uniswap rollout” is not actually deployable on Optimism Sepolia. Newly deployed revnets can be bricked on basic pay/cash-out paths, and terminal configuration may also persist a zero terminal address into project directory state. + +**Suggested fix:** +Gate revnet deployment on non-zero `_buybackRegistry` / `_routerTerminalRegistry`, or provide explicit no-op alternatives for the non-Uniswap rollout path. + +--- + +### Finding FF-003: Public project-ID squatting can halt deployment or shift Banny off canonical project ID 4 +**Severity:** MEDIUM +**Module:** `script/Deploy.s.sol` +**Function:** `_ensureProjectExists`, `_deployBanny` +**Lines:** `script/Deploy.s.sol:1908-1910`, `:2185-2191` +**Verification:** Code trace + +**Feynman Question that exposed this:** +> What guarantees that the next public Juicebox project IDs are still 2, 3, and 4 when this script runs? + +**Why this is wrong:** +`JBProjects.createFor()` is public and increments the global project counter for any caller (`node_modules/@bananapus/core-v6/src/JBProjects.sol:66-78`). `_ensureProjectExists()` only checks `count >= expectedProjectId`, then blindly returns that ID without verifying ownership or reserving gaps. `_deployBanny()` is stricter in the wrong place: it only returns early if project 4 already has a controller, otherwise it calls into `REVDeployer` with `revnetId: 0`, which creates the next available project instead of forcing ID 4. + +**Verification evidence:** +- Public project creation: `node_modules/@bananapus/core-v6/src/JBProjects.sol:66-78` +- Weak reservation helper: `script/Deploy.s.sol:2185-2191` +- Banny path only checks `controllerOf(4) != 0`: `script/Deploy.s.sol:1908-1910` + +**Attack scenario:** +1. An external user front-runs governance by calling `JBProjects.createFor()` before this script. +2. `_ensureProjectExists(2)` or `_ensureProjectExists(3)` returns an ID that governance does not own. +3. Later `_projects.approve(address(_revDeployer), projectId)` reverts, halting deployment. +4. Separately, if project 4 exists but is still controller-less, `_deployBanny()` will mint the next project ID instead of canonical ID 4. + +**Impact:** +A public outsider can grief deployment determinism, force full deployment failure, or break the script’s canonical project-ID assumptions across chains. + +**Suggested fix:** +Reserve IDs by actually creating missing projects in sequence and verifying ownership/controller state, or stop hard-coding public project IDs altogether. + +## False Positives Eliminated +- Did not report general “partial deploy is not resumable” observations because the repository documentation already treats reruns on partially deployed chains as operationally unsafe rather than as an unaccounted logic flaw in scope. + +## Summary +- Total functions analyzed: 33 +- Raw findings (pre-verification): 3 +- After verification: 3 TRUE POSITIVE +- Final: 2 HIGH, 1 MEDIUM diff --git a/.audit-findings-codex-prev-20260323-211452/deploy-all-v6/codex-nemesis-raw.md b/.audit-findings-codex-prev-20260323-211452/deploy-all-v6/codex-nemesis-raw.md new file mode 100644 index 0000000..f6698a4 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/deploy-all-v6/codex-nemesis-raw.md @@ -0,0 +1,64 @@ +# N E M E S I S — Raw Findings + +## Scope +- Language: Solidity +- In-scope files: `script/Deploy.s.sol` +- `src/**/*.sol` files present in this repo: none +- Functions analyzed: 33 + +## Phase 0 — Recon + +**Attack goals** +1. Ship a deployment that permanently bricks core protocol paths on one or more chains. +2. Misconfigure cross-chain infrastructure so bridging cannot execute after launch. +3. Corrupt canonical project-ID assumptions used by downstream integrations and governance operations. + +**Novel code** +- `script/Deploy.s.sol` — bespoke ecosystem bootstrapper with chain-specific branching, deterministic salts, and dependency ordering. + +**Value stores / deployment-critical state** +- Sucker deployers + singleton implementations +- Revnet data hook and terminal wiring +- Hard-coded project IDs 2/3/4 + +**Complex paths** +- Sucker deployment order: deployer → singleton → registry → future clone +- Optimism Sepolia non-Uniswap path: branch skip → revnet deployer ctor → later hook calls +- Canonical project creation: public `JBProjects` counter → helper reservation → later approvals and deployments + +**Priority order** +1. Sucker deployment ordering +2. Optimism Sepolia non-Uniswap branch +3. Hard-coded project-ID assumptions + +## Pass 1 — Feynman Raw Suspects +- SUSPECT: `_deploySuckers()` deploys singleton implementations before `_suckerRegistry`. +- SUSPECT: `_shouldDeployUniswapStack()` skips deployment, but later phases appear to consume `_buybackRegistry` and `_routerTerminalRegistry` regardless. +- SUSPECT: `_ensureProjectExists()` treats `count >= expectedProjectId` as ownership-equivalent. +- SUSPECT: `_deployBanny()` checks only `controllerOf(4)` before assuming project ID 4 can still be used. + +## Pass 2 — State Raw Gaps +- GAP: `_suckerRegistry` is not synchronized with constructor-time singleton immutables. +- GAP: non-Uniswap branch state is not synchronized with revnet dependency consumers. +- GAP: public project counter growth is not synchronized with hard-coded expected IDs. + +## Pass 3 — Targeted Feynman Re-Interrogation +- Confirmed root cause for sucker issue: clone architecture freezes constructor immutables from the singleton. +- Confirmed root cause for OP Sepolia issue: `REVDeployer` directly dereferences `BUYBACK_HOOK`, so zero-address injection is fatal. +- Confirmed root cause for project-ID issue: the helper never proves ownership, and Banny does not reserve project ID 4. + +## Pass 4 — Targeted State Re-Analysis +- No additional coupled pairs surfaced beyond: + - registry ↔ singleton immutables + - feature-gate branch ↔ downstream dependency wiring + - expected IDs ↔ actual owned IDs + +## Raw Findings +1. HIGH — Sucker singletons bake `REGISTRY = address(0)` due deployment ordering. +2. HIGH — Optimism Sepolia non-Uniswap rollout still deploys revnets with zero buyback/router dependencies. +3. MEDIUM — Public project-ID squatting can halt deployment or shift Banny off project ID 4. + +## Convergence +- Total passes: 4 +- New findings in last pass: 0 +- Status: Converged diff --git a/.audit-findings-codex-prev-20260323-211452/deploy-all-v6/codex-nemesis-verified.md b/.audit-findings-codex-prev-20260323-211452/deploy-all-v6/codex-nemesis-verified.md new file mode 100644 index 0000000..352ccda --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/deploy-all-v6/codex-nemesis-verified.md @@ -0,0 +1,171 @@ +# N E M E S I S — Verified Findings + +## Scope +- Language: Solidity +- Modules analyzed: `script/Deploy.s.sol` +- Functions analyzed: 33 +- Coupled state pairs mapped: 3 +- Mutation paths traced: 4 +- Nemesis loop iterations: 2 targeted re-passes after the two baseline passes + +## Verification Summary +| ID | Source | Coupled Pair | Breaking Op | Severity | Verdict | +|----|--------|-------------|-------------|----------|---------| +| NM-001 | Cross-feed P1→P2 | `_suckerRegistry` ↔ singleton `REGISTRY` | `_deploySuckers()` | HIGH | TRUE POS | +| NM-002 | Cross-feed P1→P2 | skip-Uniswap flag ↔ revnet dependency wiring | `_deployRevnet()` + revnet config builders | HIGH | TRUE POS | +| NM-003 | Feynman-only, then state-confirmed | expected project IDs ↔ actual owned project IDs | `_ensureProjectExists()` / `_deployBanny()` | MEDIUM | TRUE POS | + +## Verified Findings + +### Finding NM-001: Sucker singleton deployment order permanently zeroes the registry used by every future clone +**Severity:** HIGH +**Source:** Cross-feed Pass 1 → Pass 2 +**Verification:** Code trace + +**Coupled Pair:** `_suckerRegistry` ↔ `JBSucker.REGISTRY` +**Invariant:** The singleton implementation’s immutable registry must match the live registry used for cross-chain fee lookups. + +**Feynman Question that exposed it:** +> Why is the registry deployed after the singleton constructors that already consume it? + +**State Mapper gap that confirmed it:** +> `_deploySuckers()` mutates the singleton/deployer set before `_suckerRegistry` exists, so the constructor-time immutable and the later stored registry diverge. + +**Breaking Operation:** `_deploySuckers()` at `script/Deploy.s.sol:856` +- Modifies State A: deploys all singleton implementations and their deployers first. +- Does NOT update State B: only deploys `_suckerRegistry` afterward at `script/Deploy.s.sol:862-872`. + +**Trigger Sequence:** +1. Run the deployment script on a fresh chain. +2. `_deploySuckersOptimism()` / `_deployCCIPSuckerFor()` construct singleton implementations with `_suckerRegistry == address(0)`. +3. The registry is deployed only after those constructors finish. +4. A deployer later clones the singleton. +5. The clone executes `toRemote()` and tries to read `REGISTRY.toRemoteFee()`. + +**Consequence:** +- All future suckers created from those deployers revert on bridge-out. +- Cross-chain operations are unavailable until governance redeploys the affected singleton/deployer stack. + +**Verification Evidence:** +- Ordering: `script/Deploy.s.sol:856-872` +- Zero-registry constructor args: + - `script/Deploy.s.sol:911-920` + - `script/Deploy.s.sol:1219-1228` +- Immutable assignment: `node_modules/@bananapus/suckers-v6/src/JBSucker.sol:161-175` +- Clone flow: `node_modules/@bananapus/suckers-v6/src/deployers/JBSuckerDeployer.sol:133-150` +- Runtime failure point: `node_modules/@bananapus/suckers-v6/src/JBSucker.sol:682-683` + +**Fix:** +Deploy `JBSuckerRegistry` before constructing any singleton implementation, or move registry binding out of constructor immutables and into post-clone initialization. + +--- + +### Finding NM-002: Optimism Sepolia’s “non-Uniswap rollout” still injects zero buyback/router dependencies into revnets +**Severity:** HIGH +**Source:** Cross-feed Pass 1 → Pass 2 +**Verification:** Code trace + +**Coupled Pair:** `_shouldDeployUniswapStack()` result ↔ `_buybackRegistry` / `_routerTerminalRegistry` consumers +**Invariant:** If the stack is skipped, every downstream dependency consumer must also be skipped or replaced. + +**Feynman Question that exposed it:** +> If Uniswap deployment is disabled on Optimism Sepolia, what code prevents later revnet phases from dereferencing the skipped contracts? + +**State Mapper gap that confirmed it:** +> The skip branch leaves `_buybackRegistry` and `_routerTerminalRegistry` unset, but revnet deployment still consumes both values in constructors and terminal arrays. + +**Breaking Operation:** Optimism Sepolia path in `_shouldDeployUniswapStack()` at `script/Deploy.s.sol:471-472` +- Leaves `_buybackRegistry` unset before `_deployRevnet()` at `script/Deploy.s.sol:1529-1553` +- Leaves `_routerTerminalRegistry` unset before revnet terminal config builders at: + - `script/Deploy.s.sol:1570-1575` + - `script/Deploy.s.sol:1670-1675` + - `script/Deploy.s.sol:1841-1845` + - `script/Deploy.s.sol:1940-1945` + +**Trigger Sequence:** +1. Run deployment on Optimism Sepolia. +2. `_shouldDeployUniswapStack()` returns false. +3. Revnets are still deployed with `BUYBACK_HOOK = address(0)` and terminal config entry `address(_routerTerminalRegistry) = address(0)`. +4. First pay/cash-out/mint-permission flow calls into `REVDeployer`. +5. `REVDeployer` dereferences the zero-address buyback hook and reverts. + +**Consequence:** +- The advertised non-Uniswap deployment path is not internally valid. +- Revnet payment and cash-out flows can be bricked immediately after deployment. +- The project directory may also store a zero terminal because controller terminal configuration does not filter it out. + +**Verification Evidence:** +- Skip branch: `script/Deploy.s.sol:471-472` +- Zero buyback injection: `script/Deploy.s.sol:1529-1553` +- Zero terminal injection: + - `script/Deploy.s.sol:1570-1575` + - `script/Deploy.s.sol:1670-1675` + - `script/Deploy.s.sol:1841-1845` + - `script/Deploy.s.sol:1940-1945` +- Direct hook dereferences: + - `node_modules/@rev-net/core-v6/src/REVDeployer.sol:275-277` + - `node_modules/@rev-net/core-v6/src/REVDeployer.sol:371` + - `node_modules/@rev-net/core-v6/src/REVDeployer.sol:409-410` +- Terminal arrays accepted without zero-address filtering: + - `node_modules/@bananapus/core-v6/src/JBController.sol:887-907` + - `node_modules/@bananapus/core-v6/src/JBDirectory.sol:225-236` + +**Fix:** +Either require the Uniswap stack before any revnet deployment, or add a dedicated non-Uniswap configuration that substitutes safe no-op implementations and omits the router terminal entry. + +--- + +### Finding NM-003: Canonical project IDs are publicly squattable and the script does not verify ownership before reuse +**Severity:** MEDIUM +**Source:** Feynman-only, later state-confirmed +**Verification:** Code trace + +**Coupled Pair:** expected IDs `2/3/4` ↔ actual `JBProjects.count()` / owner/controller of those IDs +**Invariant:** A hard-coded project ID must be reserved or proven to belong to the deployer before downstream configuration depends on it. + +**Feynman Question that exposed it:** +> Why does `count >= expectedProjectId` prove that the expected project belongs to this deployment? + +**State Mapper gap that confirmed it:** +> `_ensureProjectExists()` advances only the public counter relationship, not the ownership relationship the rest of the script relies on. + +**Breaking Operation:** `_ensureProjectExists()` at `script/Deploy.s.sol:2185-2191` +- Accepts any pre-existing `expectedProjectId` once the public count is high enough. +- `_deployBanny()` only exits early if `controllerOf(4) != 0` at `script/Deploy.s.sol:1908-1910`, so a pre-created but unconfigured project 4 still causes project-ID drift. + +**Trigger Sequence:** +1. An external user calls `JBProjects.createFor()` before governance runs this deployment. +2. The public counter reaches or passes one of the script’s expected IDs. +3. `_ensureProjectExists()` returns that ID without proving governance owns it. +4. Approval/configuration later reverts, or Banny is deployed to the next ID instead of 4. + +**Consequence:** +- Anyone can grief deployment. +- Cross-chain canonical project-ID assumptions can diverge from the script’s documented numbering. + +**Verification Evidence:** +- Public project creation: `node_modules/@bananapus/core-v6/src/JBProjects.sol:66-78` +- Weak helper: `script/Deploy.s.sol:2185-2191` +- Banny controller-only check: `script/Deploy.s.sol:1908-1910` + +**Fix:** +Reserve project IDs in sequence before any dependent deployment, and verify ownership/controller state before reusing any existing ID. + +## Feedback Loop Discoveries +- NM-001 required both passes: the Feynman pass surfaced the suspicious ordering, and the state pass confirmed the actual broken coupling between deployment-time immutables and runtime registry lookups. +- NM-002 also required both passes: the Feynman pass exposed the contradictory branching, and the state pass mapped the precise zero-address consumers that make the non-Uniswap path invalid. + +## False Positives Eliminated +- Did not elevate generic resumability concerns because the repository already documents partial reruns as an operational limitation rather than an untracked logic break. + +## Downgraded Findings +- None. + +## Summary +- Total functions analyzed: 33 +- Coupled state pairs mapped: 3 +- Nemesis loop iterations: 2 +- Raw findings (pre-verification): 0 C | 2 H | 1 M | 0 L +- Feedback loop discoveries: 2 +- After verification: 3 TRUE POSITIVE | 0 FALSE POSITIVE | 0 DOWNGRADED +- Final: 0 CRITICAL | 2 HIGH | 1 MEDIUM | 0 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/deploy-all-v6/codex-state-inconsistency-verified.md b/.audit-findings-codex-prev-20260323-211452/deploy-all-v6/codex-state-inconsistency-verified.md new file mode 100644 index 0000000..cbfb426 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/deploy-all-v6/codex-state-inconsistency-verified.md @@ -0,0 +1,121 @@ +# State Inconsistency Audit — Verified Findings + +## Coupled State Dependency Map +- `_suckerRegistry` address ↔ sucker singleton constructor arguments + Invariant: every singleton cloned by a deployer must embed the live registry address it later queries for `toRemoteFee`. +- “Non-Uniswap rollout” flag ↔ revnet dependency injection + Invariant: if the Uniswap stack is skipped, later revnet deployments must not consume buyback/router addresses that were never deployed. +- Expected canonical project IDs ↔ actual `JBProjects.count()` / project ownership + Invariant: hard-coded project IDs must either be reserved by the deployer or verified to belong to the deployer before later configuration depends on them. + +## Mutation Matrix +| State Variable / Assumption | Mutating Function | Updates Coupled State? | +|-----------------------------|-------------------|------------------------| +| `_suckerRegistry` | `_deploySuckers()` | `✗` singleton constructors run before registry exists | +| `_buybackRegistry` | `_shouldDeployUniswapStack()` gates deployment | `✗` later revnet deploys still consume it | +| `_routerTerminalRegistry` | `_shouldDeployUniswapStack()` gates deployment | `✗` later terminal configs still consume it | +| Expected project ID ownership | `_ensureProjectExists()` | `✗` returns ID once `count` is high enough without ownership validation | + +## Parallel Path Comparison +| Coupled State | Path A | Path B | Result | +|---------------|--------|--------|--------| +| Registry address propagation | Deploy registry first, then singleton | Singleton first, registry later | Path B breaks every clone | +| Non-Uniswap deployment | Skip stack and skip dependent wiring | Skip stack but still wire dependencies | Path B bricks revnets | +| Canonical project reservation | Create/verify ID before configure | Assume `count >= expectedId` is enough | Path B allows squatting/drift | + +## Verification Summary +| ID | Coupled Pair | Breaking Op | Original Severity | Verdict | Final Severity | +|----|-------------|-------------|-------------------|---------|----------------| +| SI-001 | `_suckerRegistry` ↔ singleton immutables | `_deploySuckers()` | HIGH | TRUE POSITIVE | HIGH | +| SI-002 | Uniswap-stack flag ↔ revnet dependency wiring | `_deployRevnet()` and revnet config builders | HIGH | TRUE POSITIVE | HIGH | +| SI-003 | expected project IDs ↔ actual owned project IDs | `_ensureProjectExists()` / `_deployBanny()` | MEDIUM | TRUE POSITIVE | MEDIUM | + +## Verified Findings + +### Finding SI-001: Registry deployment ordering desynchronizes sucker deployers from the registry they later read +**Severity:** HIGH +**Verification:** Code trace + +**Coupled Pair:** `_suckerRegistry` ↔ `JBSucker.REGISTRY` +**Invariant:** The singleton implementation must be constructed with the same registry that future clones depend on for `toRemote()`. + +**Breaking Operation:** `_deploySuckers()` in `script/Deploy.s.sol:856-872` +- Modifies State A: deploys sucker singletons and deployers +- Does NOT update State B: the singleton constructors still receive `_suckerRegistry == address(0)` + +**Trigger Sequence:** +1. Run `_deploySuckers()` on a fresh chain. +2. `_deploySuckersOptimism()` / `_deployCCIPSuckerFor()` instantiate singleton implementations with `_suckerRegistry`. +3. Only afterward does `_deploySuckers()` deploy `JBSuckerRegistry`. +4. Deployer clones the singleton later. +5. Clone executes `toRemote()` and reads `REGISTRY.toRemoteFee()` from the wrong immutable. + +**Consequence:** +- All deployed suckers revert on bridge-out. +- Cross-chain state can never progress because the singleton/deployer pair is permanently tied to the wrong registry. + +**Fix:** +Deploy `JBSuckerRegistry` first and only then construct singleton implementations. + +--- + +### Finding SI-002: The “skip Uniswap stack” branch leaves revnet deployments coupled to zero buyback/router addresses +**Severity:** HIGH +**Verification:** Code trace + +**Coupled Pair:** `_shouldDeployUniswapStack()` result ↔ `_buybackRegistry` / `_routerTerminalRegistry` consumers +**Invariant:** Any branch that skips deployment of these addresses must also skip or replace every downstream consumer. + +**Breaking Operation:** Optimism Sepolia deployment path +- Modifies State A: leaves `_buybackRegistry` and `_routerTerminalRegistry` unset +- Does NOT update State B: later revnet constructors and terminal configs still consume those values + +**Trigger Sequence:** +1. Deploy on chain `11_155_420`. +2. `_shouldDeployUniswapStack()` returns false. +3. `_deployRevnet()` still passes `address(_buybackRegistry)` into `REVDeployer`. +4. Revnet terminal configs still append `address(_routerTerminalRegistry)`. +5. Payment/cash-out/mint-permission paths call the zero-address hook or store a zero terminal. + +**Consequence:** +- Revnets on Optimism Sepolia are not internally consistent with the intended “non-Uniswap rollout”. +- Core interaction paths revert. + +**Fix:** +Use an explicit non-Uniswap revnet configuration that avoids both dependencies entirely, or require those addresses to be present before revnet deployment proceeds. + +--- + +### Finding SI-003: Hard-coded project IDs are not synchronized with the public project counter +**Severity:** MEDIUM +**Verification:** Code trace + +**Coupled Pair:** expected project IDs `2/3/4` ↔ actual `JBProjects.count()` / owner of those IDs +**Invariant:** A deployment script that later configures hard-coded IDs must first reserve them or prove ownership. + +**Breaking Operation:** `_ensureProjectExists()` in `script/Deploy.s.sol:2185-2191` +- Modifies State A: accepts the current public counter as sufficient +- Does NOT update State B: does not verify that the deployer owns the returned project ID + +**Trigger Sequence:** +1. External user mints one or more project IDs before governance runs this script. +2. `_ensureProjectExists()` returns the occupied ID because `count >= expectedProjectId`. +3. Later approval/configuration assumes the deployer owns that project. +4. Deployment reverts or Banny is created at the next available project ID instead of 4. + +**Consequence:** +- Deployment can be griefed. +- Canonical cross-chain project-ID assumptions can drift. + +**Fix:** +Validate ownership/controller state for expected IDs before reuse, or stop depending on globally public sequential IDs. + +## False Positives Eliminated +- Did not report ordinary constructor sequencing where the later state is not read back by the earlier deployment artifact. + +## Summary +- Coupled state pairs mapped: 3 +- Mutation paths analyzed: 4 +- Raw findings (pre-verification): 3 +- After verification: 3 TRUE POSITIVE +- Final: 2 HIGH, 1 MEDIUM diff --git a/.audit-findings-codex-prev-20260323-211452/nana-721-hook-v6/codex-feynman-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-721-hook-v6/codex-feynman-verified.md new file mode 100644 index 0000000..835b47d --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-721-hook-v6/codex-feynman-verified.md @@ -0,0 +1,70 @@ +# Feynman Audit — Verified Findings + +## Scope +- Language: Solidity 0.8.26 +- Modules analyzed: all 34 Solidity files under `src/` and `script/` +- Functions analyzed: 211 + +## Verification Summary +| ID | Original Severity | Verdict | Final Severity | +|----|-------------------|---------|----------------| +| FF-001 | HIGH | TRUE POSITIVE | HIGH | + +## Verified TRUE POSITIVE Findings + +### Finding FF-001: Cross-currency split forwarding survives missing-price validation and traps funds +**Severity:** HIGH +**Module:** `JB721TiersHook` / `JB721TiersHookLib` / `JBTerminalStore` / `JBMultiTerminal` +**Function:** `beforePayRecordedWith()` / `_processPayment()` / `_computePayFrom()` / `_fulfillPayHookSpecificationsFor()` +**Lines:** `src/JB721TiersHook.sol:178`, `src/JB721TiersHook.sol:707`, `src/libraries/JB721TiersHookLib.sol:168`, `src/libraries/JB721TiersHookLib.sol:235`, `node_modules/@bananapus/core-v6/src/JBTerminalStore.sol:1020`, `node_modules/@bananapus/core-v6/src/JBMultiTerminal.sol:1029`, `node_modules/@bananapus/core-v6/src/JBMultiTerminal.sol:1356` +**Verification:** Hybrid — code trace + PoC `test/audit/CodexNemesis_CrossCurrencySplitNoPrices.t.sol` + +**Feynman question that exposed this:** +> Why does the hook validate missing price feeds in `afterPayRecordedWith`, but still compute and return nonzero forwarded split amounts in `beforePayRecordedWith`? + +**Why this is wrong:** +`beforePayRecordedWith()` always computes split amounts from tier prices. If the payment currency differs from the pricing currency and `PRICES == address(0)`, `convertSplitAmounts()` simply returns the original split amount unchanged instead of rejecting or zeroing it. Core then treats that amount as real hook-forwarded value, deducts it from the project balance diff, and calls `afterPayRecordedWith()` with native `msg.value` or an ERC-20 allowance. + +`afterPayRecordedWith()` then calls `normalizePaymentValue()`. On the same missing-price condition it returns `(0, false)`, and `_processPayment()` exits immediately before minting, crediting leftover funds, or distributing the forwarded split amount. That leaves the system in a partially-fulfilled state: + +- Native token path: the terminal already sent `msg.value` to the hook, so the forwarded amount stays trapped in the hook contract with no recovery path. +- ERC-20 path: the terminal already reduced store accounting and granted allowance to the hook, but the hook never pulls or redistributes the amount, leaving terminal balances and store balances out of sync. + +**Verification evidence:** +- `JB721TiersHookLib.normalizePaymentValue()` returns invalid on missing prices at `src/libraries/JB721TiersHookLib.sol:144`. +- `JB721TiersHookLib.convertSplitAmounts()` does **not** invalidate on the same condition and instead returns the unconverted split amount at `src/libraries/JB721TiersHookLib.sol:252`. +- `JB721TiersHook.beforePayRecordedWith()` still returns that amount as a live pay-hook specification at `src/JB721TiersHook.sol:187-214`. +- Core deducts the specification amount from `balanceDiff` in `JBTerminalStore` at `node_modules/@bananapus/core-v6/src/JBTerminalStore.sol:1020-1038`. +- Core then transfers native value or increases ERC-20 allowance before invoking the pay hook at `node_modules/@bananapus/core-v6/src/JBMultiTerminal.sol:1029-1035` and `:1401-1410`. +- The hook exits early on the invalid normalization result at `src/JB721TiersHook.sol:711-719`, so distribution at `:724-736` never runs. +- PoC: `forge test --match-path test/audit/CodexNemesis_CrossCurrencySplitNoPrices.t.sol -vvv` passes and proves a 50% split on a cross-currency payment with `PRICES == 0` leaves `0.5 ether` stuck in the hook while minting nothing. + +**Attack scenario:** +1. The project configures tiers in pricing currency `X`, allows split routing on those tiers, and deploys the hook with `PRICES == address(0)`. +2. A payer pays in a different currency `Y` and includes a split-bearing tier in pay metadata. +3. `beforePayRecordedWith()` returns a nonzero split amount and reduces issuance weight. +4. Core withholds/forwards that amount to the pay hook. +5. `afterPayRecordedWith()` exits early because the same currency mismatch is invalid for normalization. +6. The payer receives no NFT, no credits, and the forwarded split amount is stranded or unaccounted. + +**Impact:** +- Conditional direct fund loss for native-token payments. +- Broken accounting / stranded funds for ERC-20 payments. +- The repo’s documented “cross-currency without prices just skips minting” behavior is false once split-bearing tiers are involved. + +**Suggested fix:** +Reject the split path under the same condition that invalidates normalization. Minimal options: + +```solidity +if (amountCurrency != pricingCurrency && address(prices) == address(0)) { + return (0, bytes("")); +} +``` + +or make `beforePayRecordedWith()` revert on cross-currency split-bearing payments when `PRICES == address(0)`. + +## False Positives Eliminated +- No additional Feynman suspects survived code-trace verification after tracing lazy reconciliation, hook auth, and documented discount/cash-out asymmetries. + +## Downgraded Findings +- None. diff --git a/.audit-findings-codex-prev-20260323-211452/nana-721-hook-v6/codex-nemesis-raw.md b/.audit-findings-codex-prev-20260323-211452/nana-721-hook-v6/codex-nemesis-raw.md new file mode 100644 index 0000000..e2840f6 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-721-hook-v6/codex-nemesis-raw.md @@ -0,0 +1,103 @@ +# N E M E S I S — Raw Findings + +## Scope +- Fresh round on all Solidity under `src/` and `script/` +- `.audit/findings/` intentionally ignored as input +- Files reviewed: 34 +- Functions reviewed: 211 + +## Phase 0 — Recon + +**Language:** Solidity + +**Attack goals** +1. Lock or misroute payer funds during NFT purchases. +2. Break supply / reserve / cash-out accounting so NFTs reclaim more or less than intended. +3. Bypass hook/project ownership boundaries during deployment or configuration. + +**Novel code** +- `src/JB721TiersHook.sol`: custom pay/cashout hook with split forwarding and ERC-2771 ownership overlays. +- `src/JB721TiersHookStore.sol`: custom tier linked-list, reserve accounting, and soft-delete model. +- `src/libraries/JB721TiersHookLib.sol`: bespoke split conversion/distribution logic across core terminal boundaries. +- `src/JB721TiersHookProjectDeployer.sol`: custom project+hook launch wrapper that rewrites ruleset metadata. + +**Value stores + initial coupling hypothesis** +- Terminal/store balances in core ↔ pay-hook specification amounts. +- `remainingSupply` ↔ pending reserve count / reserve mints. +- Tier price / pricing currency ↔ mint path / split conversion / cash-out weight. +- `payCreditsOf` ↔ leftover payment value. +- `tierBalanceOf` ↔ ERC-721 ownership / burn counts / voting units. + +**Complex paths** +- `beforePayRecordedWith` → core `recordPaymentFrom` → core `_fulfillPayHookSpecificationsFor` → `afterPayRecordedWith` → `distributeAll`. +- `beforeCashOutRecordedWith` → core bonding-curve reclaim → `afterCashOutRecordedWith` → `_update` → store burn accounting. +- `launchProjectFor` / `launchRulesetsFor` / `queueRulesetsOf` with nested deployer salt scoping and ownership transfer. + +## Phase 1 — Unified Map Highlights +- `beforePayRecordedWith()` writes derived split amount + weight, but not project/store balances directly. +- Core `JBTerminalStore._computePayFrom()` subtracts each hook amount from `balanceDiff`. +- Core `JBMultiTerminal._fulfillPayHookSpecificationsFor()` forwards native value or ERC-20 allowance before invoking the pay hook. +- `_processPayment()` is the only place that reconciles the forwarded split amount by calling `distributeAll()`. +- `normalizePaymentValue()` and `convertSplitAmounts()` treat missing price feeds differently. + +## Pass 1 — Feynman (Full) + +### Primary suspects +1. `JB721TiersHook.beforePayRecordedWith()` + `JB721TiersHook._processPayment()` + - Question: why does missing-price invalidation happen only after core has already forwarded split value? + - Suspect state: `hookSpecifications.amount`, issuance `weight`, forwarded native/ERC-20 value. +2. `JB721TiersHookProjectDeployer` deterministic salt chaining + - Question: why is caller-scoping done in both project deployer and hook deployer layers? + - Result after trace: non-issue, still unique and non-hijackable. +3. Store soft-delete and reserve minting + - Question: why can removed tiers still mint reserves and contribute to cash-out weight? + - Result after trace: intentional and documented. + +## Pass 2 — State Inconsistency (Full, enriched by Pass 1) + +### New coupled pair +- `hookSpecifications.amount` ↔ `actual split distribution or restored accounting` + +### Gap +- On cross-currency payments with `PRICES == address(0)`: + - `beforePayRecordedWith()` can still produce a nonzero split amount. + - Core deducts and forwards that amount. + - `_processPayment()` exits before mint/credits/distribution. + - No path restores or consumes the forwarded amount. + +### Raw finding candidate NM-RAW-001 +- Severity hypothesis: HIGH +- Title: cross-currency split-bearing payments with no prices can trap forwarded funds and desync accounting + +## Pass 3 — Feynman Re-Interrogation +- Root cause confirmed: `convertSplitAmounts()` returns success-on-no-prices, while `normalizePaymentValue()` returns invalid-on-no-prices. +- Downstream break confirmed: + - Native path: forwarded ETH remains in the hook. + - ERC-20 path: store accounting excludes the amount even though the terminal still holds it. +- Multi-tx angle checked: repeated ERC-20 payments accumulate unconsumed allowance / unaccounted terminal surplus. + +## Pass 4 — State Re-Analysis +- No additional coupled pairs emerged from the root cause. +- Reserve, burn, voting-unit, and tier-balance mutation paths did not reveal a second verified inconsistency. + +## Convergence +- Pass 4 produced no new findings, no new coupled pairs, and no new suspects. +- Nemesis loop converged after 4 passes. + +## Verification Queue + +### NM-RAW-001 +- Method: Hybrid +- Code trace targets: + - `src/libraries/JB721TiersHookLib.sol:128-159` + - `src/libraries/JB721TiersHookLib.sol:168-265` + - `src/JB721TiersHook.sol:178-215` + - `src/JB721TiersHook.sol:707-736` + - `node_modules/@bananapus/core-v6/src/JBTerminalStore.sol:1020-1038` + - `node_modules/@bananapus/core-v6/src/JBMultiTerminal.sol:1029-1035` + - `node_modules/@bananapus/core-v6/src/JBMultiTerminal.sol:1356-1410` +- PoC: + - `forge test --match-path test/audit/CodexNemesis_CrossCurrencySplitNoPrices.t.sol -vvv` + +## Raw Counts +- Raw findings: 0 CRITICAL | 1 HIGH | 0 MEDIUM | 0 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-721-hook-v6/codex-nemesis-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-721-hook-v6/codex-nemesis-verified.md new file mode 100644 index 0000000..b4ef19e --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-721-hook-v6/codex-nemesis-verified.md @@ -0,0 +1,101 @@ +# N E M E S I S — Verified Findings + +## Scope +- Language: Solidity +- Modules analyzed: all 34 Solidity files under `src/` and `script/` +- Functions analyzed: 211 +- Coupled state pairs mapped: 8 +- Mutation paths traced: 19 +- Nemesis loop iterations: 2 targeted re-passes after the full Feynman + full State passes (4 total passes) + +## Nemesis Map (Phase 1 Cross-Reference) +| Function / Path | Writes / derives A | Writes / derives B | Coupling | Status | +|----|----|----|----|----| +| `beforePayRecordedWith()` | split amount | reduced weight | payment value ↔ forwarded split | synced only when conversion is valid | +| core `_computePayFrom()` | `balanceDiff -= spec.amount` | hook fulfillment obligation | project balance diff ↔ hook forwarding | synced | +| core `_fulfillPayHookSpecificationsFor()` | native `msg.value` / ERC-20 allowance | pay-hook context | forwarded amount ↔ hook execution | synced | +| `_processPayment()` | mints / credits / split distribution | consumes forwarded amount | forwarded amount ↔ reconciliation | **GAP on invalid normalization** | +| `recordMint()` | `remainingSupply--` | pending reserve constraint | supply ↔ reserve obligations | synced | +| `_update()` + `recordTransferForTier()` | ERC-721 ownership | tier balances | ownerOf ↔ tierBalanceOf | synced | +| `_didBurn()` + `recordBurn()` | burn count | outstanding supply | numberOfBurned ↔ total cash-out weight | synced | + +## Verification Summary +| ID | Source | Coupled Pair | Breaking Op | Severity | Verdict | +|----|--------|-------------|-------------|----------|---------| +| NM-001 | Cross-feed P1→P2→P3 | pay-hook forwarded amount ↔ split distribution/reconciliation | `beforePayRecordedWith()` / `_processPayment()` | HIGH | TRUE POSITIVE | + +## Verified Findings + +### Finding NM-001: Cross-currency split payments with no price feed can trap forwarded funds in the hook +**Severity:** HIGH +**Source:** Cross-feed P1→P2→P3 +**Verification:** Hybrid + +**Coupled Pair:** `hookSpecifications.amount` ↔ `actual split distribution / restored project accounting` +**Invariant:** A pay-hook amount deducted from terminal/store accounting must either be distributed to recipients or returned to project accounting. It cannot survive an early-return path. + +**Feynman question that exposed it:** +> Why does the hook invalidate missing-price cross-currency payments only in `_processPayment()`, after core has already honored any split-bearing pay-hook specification from `beforePayRecordedWith()`? + +**State Mapper gap that confirmed it:** +> `beforePayRecordedWith()` writes a live nonzero `hookSpecifications.amount`, core subtracts it from `balanceDiff` and forwards it, but `_processPayment()` can return at `src/JB721TiersHook.sol:719` before any distribution or credit update. + +**Breaking Operation:** `beforePayRecordedWith()` at [src/JB721TiersHook.sol](/Users/jango/Documents/jb/v6/evm/nana-721-hook-v6/src/JB721TiersHook.sol#L178) +- Modifies State A: computes `totalSplitAmount`, reduces token-minting `weight`, and returns a nonzero pay-hook specification. +- Does NOT guarantee State B: the matching fulfillment path later exits before minting, crediting, or split distribution when normalization is invalid. + +**Trigger Sequence:** +1. Deploy a hook with `PRICES == address(0)` and pricing currency `X`. +2. Configure a tier with `splitPercent > 0`. +3. Pay in a different currency `Y` and include that tier in pay metadata. +4. `beforePayRecordedWith()` computes a split amount from tier pricing and returns it unchanged because `convertSplitAmounts()` treats `PRICES == 0` as success. +5. Core deducts that amount from `balanceDiff` and forwards native `msg.value` or ERC-20 allowance to the pay hook. +6. `_processPayment()` calls `normalizePaymentValue()`, gets `(0, false)`, and returns immediately. + +**Consequence:** +- Native-token path: the forwarded value remains stuck in the hook contract with no sweep path. +- ERC-20 path: terminal/store accounting diverges because the terminal never transfers/distributes the excluded amount. +- The payer gets no NFT and no credits, even though part of the payment has already been carved out of the terminal-side flow. + +**Verification Evidence:** +- Missing-price invalidation exists only in [src/libraries/JB721TiersHookLib.sol](/Users/jango/Documents/jb/v6/evm/nana-721-hook-v6/src/libraries/JB721TiersHookLib.sol#L128) at line 144. +- The split conversion path instead returns the original amount on the same condition in [src/libraries/JB721TiersHookLib.sol](/Users/jango/Documents/jb/v6/evm/nana-721-hook-v6/src/libraries/JB721TiersHookLib.sol#L235) at line 252. +- The hook uses that amount directly in [src/JB721TiersHook.sol](/Users/jango/Documents/jb/v6/evm/nana-721-hook-v6/src/JB721TiersHook.sol#L178). +- Core subtracts the hook amount in `JBTerminalStore` and forwards funds in `JBMultiTerminal`: + - `node_modules/@bananapus/core-v6/src/JBTerminalStore.sol:1020-1038` + - `node_modules/@bananapus/core-v6/src/JBMultiTerminal.sol:1029-1035` + - `node_modules/@bananapus/core-v6/src/JBMultiTerminal.sol:1356-1410` +- The hook returns before `distributeAll()` at [src/JB721TiersHook.sol](/Users/jango/Documents/jb/v6/evm/nana-721-hook-v6/src/JB721TiersHook.sol#L707). +- PoC: [CodexNemesis_CrossCurrencySplitNoPrices.t.sol](/Users/jango/Documents/jb/v6/evm/nana-721-hook-v6/test/audit/CodexNemesis_CrossCurrencySplitNoPrices.t.sol#L14) proves a 50% split on a 1 ETH payment leaves `0.5 ether` trapped in the hook while minting nothing. + +**Fix:** +Reject or zero split forwarding under the same condition that makes payment normalization invalid. For example: + +```solidity +if (amountCurrency != pricingCurrency && address(prices) == address(0)) { + return (0, bytes("")); +} +``` + +or revert in `beforePayRecordedWith()` whenever a cross-currency split-bearing payment is attempted without a prices contract. + +## Feedback Loop Discoveries +- The bug did not appear from isolated hook review alone, because the terminal/core boundary is what turns the asymmetric missing-price behavior into trapped value. +- It also did not appear from pure state-mapping alone, because the missing synchronization is between hook-derived amounts and terminal fulfillment side effects, not two adjacent storage slots in the hook. + +## False Positives Eliminated +- Removed-tier reserve minting still matches the documented soft-delete model. +- Discount/cash-out asymmetry is intentional and documented. +- Deterministic salt scoping across the two deployers is awkward but not hijackable. + +## Downgraded Findings +- None. + +## Summary +- Total functions analyzed: 211 +- Coupled state pairs mapped: 8 +- Nemesis loop iterations: 4 total passes +- 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 diff --git a/.audit-findings-codex-prev-20260323-211452/nana-721-hook-v6/codex-state-inconsistency-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-721-hook-v6/codex-state-inconsistency-verified.md new file mode 100644 index 0000000..195fb79 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-721-hook-v6/codex-state-inconsistency-verified.md @@ -0,0 +1,74 @@ +# State Inconsistency Audit — Verified Findings + +## Coupled State Dependency Map +- `hookSpecification.amount` ↔ `actual forwarded/distributed amount` + Invariant: any amount deducted from terminal/store accounting and forwarded to the pay hook must either be distributed or returned to project accounting. +- `pricingContext.currency` ↔ `split conversion validity` + Invariant: if cross-currency normalization is invalid, split forwarding must also be invalid. +- `terminal/store balanceDiff` ↔ `hook execution side effects` + Invariant: funds removed from `balanceDiff` for pay hooks must not disappear on early-return paths. + +## Mutation Matrix +| State Variable / Derived Value | Mutating Function | Updates Coupled State? | +|----|----|----| +| `hookSpecifications[i].amount` | `JB721TiersHook.beforePayRecordedWith()` | `✗ GAP when PRICES == 0 and currencies differ` | +| `balanceDiff` | `JBTerminalStore._computePayFrom()` | `✓` subtracts pay-hook amount | +| forwarded funds / allowance | `JBMultiTerminal._fulfillPayHookSpecificationsFor()` | `✓` forwards native value or grants ERC-20 allowance | +| split distribution / credits / minting | `JB721TiersHook._processPayment()` | `✗ skipped on invalid normalization` | + +## Parallel Path Comparison +| Coupled State | Matching currency | Cross-currency with prices | Cross-currency without prices | +|----|----|----|----| +| Split amount ↔ actual distribution | `✓` | `✓` | `✗` | +| Forwarded amount ↔ minted/credited behavior | `✓` | `✓` | `✗` | + +## Verification Summary +| ID | Coupled Pair | Breaking Op | Original Severity | Verdict | Final Severity | +|----|-------------|-------------|-------------------|---------|----------------| +| SI-001 | pay-hook forwarded amount ↔ distribution/reconciliation | `beforePayRecordedWith()` + `_processPayment()` | HIGH | TRUE POSITIVE | HIGH | + +## Verified Findings + +### Finding SI-001: Cross-currency no-price path deducts and forwards split funds without any reconciliation +**Severity:** HIGH +**Verification:** Hybrid — code trace + PoC + +**Coupled Pair:** `hookSpecifications.amount` ↔ `distribution / returned project accounting` +**Invariant:** Any split amount deducted from the terminal-side payment flow must either be distributed to recipients or returned to project accounting. + +**Breaking Operation:** `beforePayRecordedWith()` in `src/JB721TiersHook.sol:178` +- Modifies State A: returns a nonzero pay-hook amount and lowers weight. +- Does NOT update State B: the matching fulfillment path is later skipped by `_processPayment()` when `normalizePaymentValue()` returns invalid. + +**Trigger Sequence:** +1. Deploy a hook with `PRICES == address(0)` and tiers priced in currency `X`. +2. Configure a tier with `splitPercent > 0`. +3. Pay in a different currency `Y` using that tier. +4. `beforePayRecordedWith()` computes a split amount and core forwards it. +5. `afterPayRecordedWith()` exits early on the same currency mismatch and never calls `distributeAll()`. + +**Consequence:** +- Native-token split amount remains trapped in the hook. +- ERC-20 flow leaves funds in the terminal while store accounting already excluded them. +- Users are under-issued and the project’s real/token accounting diverges. + +**Verification evidence:** +- Missing-price invalidation exists only in `normalizePaymentValue()` at `src/libraries/JB721TiersHookLib.sol:144`. +- `convertSplitAmounts()` returns the original amount on the same condition at `src/libraries/JB721TiersHookLib.sol:252`. +- Core removes the amount from `balanceDiff` at `node_modules/@bananapus/core-v6/src/JBTerminalStore.sol:1020-1038`. +- Core forwards native value / grants allowance at `node_modules/@bananapus/core-v6/src/JBMultiTerminal.sol:1401-1410`. +- Hook exits before `distributeAll()` at `src/JB721TiersHook.sol:719`. +- PoC: `test/audit/CodexNemesis_CrossCurrencySplitNoPrices.t.sol:14-117`. + +**Fix:** +Unify the validity check so the split path cannot produce a nonzero `hookSpecifications.amount` when payment normalization is invalid. + +## False Positives Eliminated +- Removed-tier reserve minting, discount updates on removed tiers, and tier-balance transfer accounting were traced and matched intended lazy/soft-delete semantics. + +## Summary +- Coupled state pairs mapped: 8 +- Mutation paths analyzed: 19 +- Raw findings (pre-verification): 1 +- After verification: 1 TRUE POSITIVE | 0 FALSE POSITIVE +- Final: 0 CRITICAL | 1 HIGH | 0 MEDIUM | 0 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-address-registry-v6/codex-feynman-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-address-registry-v6/codex-feynman-verified.md new file mode 100644 index 0000000..0a9ffa8 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-address-registry-v6/codex-feynman-verified.md @@ -0,0 +1,129 @@ +# Feynman Audit — Verified Findings + +## Scope +- Language: Solidity 0.8.26 +- Modules analyzed: + - `src/JBAddressRegistry.sol` + - `src/interfaces/IJBAddressRegistry.sol` + - `script/Deploy.s.sol` + - `script/helpers/AddressRegistryDeploymentLib.sol` +- Functions analyzed: 10 + +## Verification Summary + +| ID | Original Severity | Verdict | Final Severity | +|----|-------------------|---------|----------------| +| FF-001 | LOW | TRUE POSITIVE | LOW | + +## Function-State Matrix + +| Function | Reads | Writes | Guards | Calls | +|----------|-------|--------|--------|-------| +| `registerAddress(address,uint256)` | params | `deployerOf[computed]` | none | `_addressFrom`, `_registerAddress` | +| `registerAddress(address,bytes32,bytes)` | params | `deployerOf[computed]` | none | `_registerAddress` | +| `_addressFrom(address,uint256)` | params | none | `nonce <= uint64.max` | none | +| `_registerAddress(address,address)` | none | `deployerOf[addr]` | none | none | +| `configureSphinx()` | none | `sphinxConfig` | none | none | +| `run()` | `_isDeployed()` | deployment side effects | `sphinx` | `_isDeployed` | +| `_isDeployed(...)` | computed address code length | none | none | `vm.computeCreate2Address` | +| `getDeployment(string)` | `block.chainid` | none | none | overloaded `getDeployment` | +| `getDeployment(string,string)` | deployment artifact JSON | none | none | `_getDeploymentAddress` | +| `_getDeploymentAddress(...)` | deployment artifact JSON | none | none | `vm.readFile`, `stdJson.readAddress` | + +## Guard Consistency Analysis + +- No missing authorization guards in the registry contract. The permissionless registration model matches `RISKS.md`. +- No inconsistent state-writing guards between the CREATE and CREATE2 registration paths. + +## Inverse Operation Parity + +- No inverse-operation pairs exist in the registry contract. +- CREATE and CREATE2 registration paths both converge on `_registerAddress` and stay behaviorally aligned for mapping writes and event emission. + +## Verified Findings + +### Finding FF-001: `Deploy._isDeployed()` checks the wrong CREATE2 address under Sphinx +**Severity:** LOW +**Module:** `script/Deploy.s.sol` +**Function:** `run()` / `_isDeployed(...)` +**Lines:** `script/Deploy.s.sol:19-37` +**Verification:** Code trace + +**Feynman Question that exposed this:** +> Why does the preflight compute a CREATE2 address from the Arachnid deterministic deployment proxy when the actual deployment body runs under Sphinx? + +**The code:** +```solidity +function run() public sphinx { + if (!_isDeployed({ + salt: ADDRESS_REGISTRY_SALT, creationCode: type(JBAddressRegistry).creationCode, arguments: "" + })) { + new JBAddressRegistry{salt: ADDRESS_REGISTRY_SALT}(); + } +} + +function _isDeployed(bytes32 salt, bytes memory creationCode, bytes memory arguments) internal view returns (bool) { + address _deployedTo = vm.computeCreate2Address({ + salt: salt, + initCodeHash: keccak256(abi.encodePacked(creationCode, arguments)), + deployer: address(0x4e59b44847b379578588920cA78FbF26c0B4956C) + }); + return address(_deployedTo).code.length != 0; +} +``` + +**Why this is wrong:** +- Sphinx's `sphinx` modifier does `vm.startPrank(safeAddress())` before executing `run()`. +- That means `new JBAddressRegistry{salt: ...}()` is executed from the Gnosis Safe context, not from `0x4e59...`. +- CREATE2 addresses depend on the deployer address. A precheck against the Arachnid proxy therefore tests a different address than the one the script will actually deploy to. + +**Verification evidence:** +- `script/Deploy.s.sol:19-24` executes the deployment inside `run()`. +- `node_modules/@sphinx-labs/contracts/contracts/foundry/Sphinx.sol:276-306` shows the `sphinx` modifier pranking `safeAddress()` for the script body. +- `script/Deploy.s.sol:29-33` hardcodes the Arachnid proxy as the precheck deployer. +- I independently computed the Arachnid-proxy CREATE2 result for this salt/init code as `0x8233ecab4ab653aa6eE8103f1838e03a8d010e59`, which already differs from the README's published deployment address `0x2d9b78cb37ca724cfb9b32cd8e9a5dc1c88bc7bb`. + +**Attack / failure scenario:** +1. The registry is already deployed through the Sphinx-managed Safe path. +2. An operator reruns the deployment script expecting idempotent behavior. +3. `_isDeployed()` checks the wrong CREATE2 address and returns `false`. +4. The script attempts `new JBAddressRegistry{salt: ...}()` again. +5. The actual CREATE2 deployment path collides at the already-used Safe-based address and the rerun reverts instead of skipping cleanly. + +**Impact:** +- Deployment retries and recovery flows are brittle. +- This is an operational denial-of-service issue for deployment automation, not a live on-chain fund-loss issue. + +**Suggested fix:** +```solidity +function _isDeployed(bytes32 salt, bytes memory creationCode, bytes memory arguments) internal returns (bool) { + address deployedTo = vm.computeCreate2Address({ + salt: salt, + initCodeHash: keccak256(abi.encodePacked(creationCode, arguments)), + deployer: safeAddress() + }); + return deployedTo.code.length != 0; +} +``` + +## False Positives Eliminated + +- Permissionless spoofable registrations were not reported; `RISKS.md` explicitly documents that the registry proves a deployment *could* map to a deployer, not that the caller is trusted. +- Overwrite behavior in `deployerOf` was not reported; this is documented and no cheaper-than-keccak collision path exists. +- No CREATE/CREATE2 math or nonce-boundary bug survived verification; unit and edge tests cover the critical transitions. + +## Downgraded Findings + +- None. + +## LOW Findings (verified by inspection) + +| ID | Summary | +|----|---------| +| FF-001 | Sphinx deploy script idempotence check uses the wrong deployer address for CREATE2 preflight. | + +## Summary +- Total functions analyzed: 10 +- Raw findings (pre-verification): 0 CRITICAL | 0 HIGH | 0 MEDIUM | 1 LOW +- After verification: 1 TRUE POSITIVE | 0 FALSE POSITIVE | 0 DOWNGRADED +- Final: 0 HIGH | 0 MEDIUM | 1 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-address-registry-v6/codex-nemesis-raw.md b/.audit-findings-codex-prev-20260323-211452/nana-address-registry-v6/codex-nemesis-raw.md new file mode 100644 index 0000000..61faa57 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-address-registry-v6/codex-nemesis-raw.md @@ -0,0 +1,124 @@ +# N E M E S I S — Raw Findings + +## Scope +- Solidity files analyzed: + - `src/JBAddressRegistry.sol` + - `src/interfaces/IJBAddressRegistry.sol` + - `script/Deploy.s.sol` + - `script/helpers/AddressRegistryDeploymentLib.sol` +- Ignored by instruction: `.audit/findings/*` from parallel audits + +## Phase 0 — Nemesis Recon + +**Language:** Solidity 0.8.26 + +**Attack goals** +1. Corrupt deployer attribution so integrators trust a malicious contract. +2. Break deterministic address computation so legitimate deployments cannot be registered or are mis-attributed. +3. Break deployment automation so deterministic multi-chain rollout can be blocked or retried incorrectly. + +**Novel code** +- `src/JBAddressRegistry.sol` — custom RLP-based CREATE address derivation with manual nonce-range encoding. +- `script/Deploy.s.sol` — custom deployment-idempotence check around Sphinx deployment flow. + +**Value stores + initial coupling hypothesis** +- `deployerOf[addr]` holds trust metadata. + - Outflows: none. + - Suspected coupled state: computed address formula, emitted `AddressRegistered` event, deployment artifact resolution. + +**Complex paths** +- `Deploy.run()` under the `sphinx` modifier: Sphinx execution context -> Safe-pranked CREATE2 deployment -> local `_isDeployed()` precheck. +- `registerAddress(create)` path: nonce-range branch selection -> RLP bytes assembly -> keccak truncation -> mapping write. + +**Priority order** +1. `JBAddressRegistry._addressFrom()` — only nontrivial protocol logic; a boundary bug silently corrupts registry output. +2. `Deploy.run()` / `_isDeployed()` — deployment-script logic may check a different address than the one actually deployed by Sphinx. +3. `registerAddress(create2)` — EIP-1014 exactness and overwrite/event behavior. + +## Phase 1 — Function-State Matrix + +| Function | Reads | Writes | Guards | Internal Calls | External Calls | +|----------|-------|--------|--------|----------------|----------------| +| `JBAddressRegistry.registerAddress(address,uint256)` | none | `deployerOf[computed]` | none | `_addressFrom`, `_registerAddress` | none | +| `JBAddressRegistry.registerAddress(address,bytes32,bytes)` | none | `deployerOf[computed]` | none | `_registerAddress` | none | +| `JBAddressRegistry._addressFrom(address,uint256)` | params | none | `nonce <= uint64.max` | none | none | +| `JBAddressRegistry._registerAddress(address,address)` | none | `deployerOf[addr]` | none | none | none | +| `Deploy.configureSphinx()` | none | `sphinxConfig` | none | none | none | +| `Deploy.run()` | `_isDeployed(...)` result | deployment side effects | `sphinx` modifier | `_isDeployed` | CREATE2 deployment of `JBAddressRegistry` | +| `Deploy._isDeployed(...)` | code length at computed address | none | none | none | `vm.computeCreate2Address` cheatcode | +| `AddressRegistryDeploymentLib.getDeployment(string)` | `block.chainid` | none | none | overloaded `getDeployment` | deploys `new SphinxConstants()` | +| `AddressRegistryDeploymentLib.getDeployment(string,string)` | deployment artifact JSON | none | none | `_getDeploymentAddress` | `vm.readFile` cheatcode | +| `AddressRegistryDeploymentLib._getDeploymentAddress(...)` | deployment artifact JSON | none | none | none | `vm.readFile`, `stdJson.readAddress` | + +## Phase 1 — Coupled State Dependency Map + +| Pair | Invariant | Mutation points | +|------|-----------|-----------------| +| `deployerOf[computedAddress]` ↔ address computed from `(deployer, nonce)` | mapping key must equal EVM CREATE derivation for the provided pair | `registerAddress(address,uint256)` | +| `deployerOf[computedAddress]` ↔ address computed from `(deployer, salt, bytecodeHash)` | mapping key must equal EVM CREATE2 derivation for the provided triple | `registerAddress(address,bytes32,bytes)` | +| `deployerOf[addr]` ↔ `AddressRegistered(addr,deployer,caller)` event | event must reflect the exact mapping write and caller context | `_registerAddress` | +| Sphinx deployment context ↔ `_isDeployed` precheck target | precheck must compute the same address that `run()` will deploy to | `Deploy.run`, `Deploy._isDeployed` | + +## Pass 1 — Feynman Full Run + +### Cleared areas +- `JBAddressRegistry._addressFrom()` nonce branches align with RLP length transitions from `0` through `uint64.max`. +- Assembly extraction `mstore(0, hash); addr := mload(0)` is equivalent to taking the low 160 bits of the hash in this context. +- `registerAddress(create2)` matches EIP-1014 formula exactly. +- `_registerAddress()` only mutates `deployerOf[addr]` and emits one event with `caller = msg.sender`. + +### Raw suspect +1. **SUSPECT:** `script/Deploy.s.sol` + - `run()` executes under Sphinx's `sphinx` modifier, which pranks the Gnosis Safe before the body runs. + - `_isDeployed()` computes a CREATE2 address using the Arachnid deterministic deployment proxy (`0x4e59...`). + - Question: why is the precheck using the deterministic deployment proxy if the actual deployment is executed from the Safe context? + +## Pass 2 — State Full Run + +### Mutation Matrix + +| State Variable | Mutating Function | Updates Coupled State? | +|----------------|-------------------|-------------------------| +| `deployerOf[computed]` | `registerAddress(address,uint256)` | yes, computed key and event are synchronized through `_registerAddress` | +| `deployerOf[computed]` | `registerAddress(address,bytes32,bytes)` | yes, computed key and event are synchronized through `_registerAddress` | +| script deployment idempotence assumption | `Deploy.run()` | **gap**: precheck target and actual deployment context differ | + +### Parallel path comparison + +| Coupled State | CREATE path | CREATE2 path | Status | +|---------------|-------------|--------------|--------| +| computed address ↔ stored deployer | synced | synced | cleared | +| stored deployer ↔ emitted event | synced | synced | cleared | +| precheck target ↔ actual deployment target | n/a | gap in script path | escalated | + +## Pass 3 — Feynman Re-interrogation + +### Confirmed root cause +- `Deploy.run()` is wrapped by Sphinx's `sphinx` modifier. +- The modifier calls `vm.startPrank(safeAddress())` before executing the script body. +- Therefore `new JBAddressRegistry{salt: ADDRESS_REGISTRY_SALT}()` is executed as the Safe, not as the Arachnid proxy. +- `_isDeployed()` therefore checks a different CREATE2 preimage from the actual deployment path. + +## Pass 4 — State Re-analysis + +- No additional coupled-state gaps in registry storage. +- No hidden reconciliation logic needed; the deploy-script issue is independent and remains. + +## Convergence + +- Pass 1 produced 1 suspect. +- Pass 2 confirmed 1 script-state gap. +- Pass 3 confirmed root cause. +- Pass 4 found no new pairs or gaps. +- Converged after 4 passes. + +## Raw Findings + +### NM-RAW-001 +- **Title:** `Deploy._isDeployed()` checks a different CREATE2 deployer than the Sphinx-managed deployment path +- **Severity (raw):** LOW +- **Source:** Cross-feed P1->P2->P3 +- **Affected code:** + - `script/Deploy.s.sol:19-37` + - `node_modules/@sphinx-labs/contracts/contracts/foundry/Sphinx.sol:276-306` +- **Hypothesis:** Once the registry is already deployed at the real Safe-based CREATE2 address, rerunning the deploy script still returns false from `_isDeployed()` and attempts a second deployment, causing a CREATE2 collision revert instead of a clean no-op. diff --git a/.audit-findings-codex-prev-20260323-211452/nana-address-registry-v6/codex-nemesis-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-address-registry-v6/codex-nemesis-verified.md new file mode 100644 index 0000000..1f3a927 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-address-registry-v6/codex-nemesis-verified.md @@ -0,0 +1,105 @@ +# N E M E S I S — Verified Findings + +## Scope +- Language: Solidity 0.8.26 +- Modules analyzed: + - `src/JBAddressRegistry.sol` + - `src/interfaces/IJBAddressRegistry.sol` + - `script/Deploy.s.sol` + - `script/helpers/AddressRegistryDeploymentLib.sol` +- Functions analyzed: 10 +- Coupled state pairs mapped: 4 +- Mutation paths traced: 3 +- Nemesis loop iterations: 4 passes total (`Feynman -> State -> Feynman -> State`) + +## Nemesis Map (Phase 1 Cross-Reference) + +| Function | Writes A | Writes B | A↔B Pair | Sync Status | +|----------|----------|----------|----------|-------------| +| `registerAddress(address,uint256)` | `deployerOf[computed]` | `AddressRegistered` fields | stored deployer ↔ event | synced | +| `registerAddress(address,bytes32,bytes)` | `deployerOf[computed]` | `AddressRegistered` fields | stored deployer ↔ event | synced | +| `_registerAddress(address,address)` | `deployerOf[addr]` | `AddressRegistered` fields | stored deployer ↔ event | synced | +| `Deploy.run()` | deployment attempt | none | precheck target ↔ actual deployment target | gap | + +## Verification Summary + +| ID | Source | Coupled Pair | Breaking Op | Severity | Verdict | +|----|--------|-------------|-------------|----------|---------| +| NM-001 | Cross-feed P1→P2→P3 | precheck target ↔ actual deployment target | `Deploy.run()` | LOW | TRUE POSITIVE | + +## Verified Findings + +### Finding NM-001: Sphinx deploy script checks the wrong CREATE2 target before deployment +**Severity:** LOW +**Source:** Cross-feed P1→P2→P3 +**Verification:** Code trace + +**Coupled Pair:** deployment precheck target ↔ actual deployment target +**Invariant:** the address queried for existing code must be the same address the script later deploys to + +**Feynman Question that exposed it:** +> Why does the preflight use the Arachnid deterministic deployment proxy when the Sphinx modifier executes the deployment body from the Safe context? + +**State Mapper gap that confirmed it:** +> `Deploy.run()` mutates the deployment state through Safe-pranked CREATE2, while `_isDeployed()` observes a DDP-based CREATE2 address instead. + +**Breaking Operation:** `run()` at `script/Deploy.s.sol:19` +- Modifies deployment state by executing `new JBAddressRegistry{salt: ADDRESS_REGISTRY_SALT}()` +- Does **not** update/check the matching Safe-based target first; `_isDeployed()` instead computes a DDP-based target at `script/Deploy.s.sol:29-33` + +**Trigger Sequence:** +1. Deploy the registry once using the Sphinx flow. +2. Re-run the script expecting a no-op because the contract already exists. +3. `_isDeployed()` checks the wrong address and returns `false`. +4. The script attempts a second CREATE2 deployment from the Safe path. +5. The deployment collides and reverts instead of skipping cleanly. + +**Consequence:** +- Deployment retries are brittle. +- The script's idempotence assumption is false under Sphinx's real execution model. +- This is an operational issue, not a live-funds exploit in the registry contract. + +**Verification Evidence:** +- `script/Deploy.s.sol:19-24` performs the deployment. +- `script/Deploy.s.sol:29-33` computes the precheck target from `0x4e59b44847b379578588920cA78FbF26c0B4956C`. +- `node_modules/@sphinx-labs/contracts/contracts/foundry/Sphinx.sol:298-306` shows the `sphinx` modifier pranks `safeAddress()` around the script body. +- Independent recomputation of the DDP-based CREATE2 target for this salt/init code produced `0x8233ecab4ab653aa6eE8103f1838e03a8d010e59`, which does not match the published deployed address `0x2d9b78cb37ca724cfb9b32cd8e9a5dc1c88bc7bb`. + +**Fix:** +```solidity +function _isDeployed(bytes32 salt, bytes memory creationCode, bytes memory arguments) internal returns (bool) { + address deployedTo = vm.computeCreate2Address({ + salt: salt, + initCodeHash: keccak256(abi.encodePacked(creationCode, arguments)), + deployer: safeAddress() + }); + return deployedTo.code.length != 0; +} +``` + +## Feedback Loop Discoveries + +- The only verified finding required cross-feed: + - Feynman flagged a purpose/order mismatch in `Deploy._isDeployed()`. + - State analysis reframed it as a desync between the prechecked target and the actual deployment target. + - Targeted Feynman re-interrogation confirmed Sphinx's Safe-pranked execution context as the root cause. + +## False Positives Eliminated + +- Permissionless registration spoofing is documented design, not a bug. +- Mapping overwrite semantics are documented design, not a cheaper collision vector. +- No CREATE/CREATE2 formula bug survived boundary and regression verification. +- No state inconsistency exists inside `JBAddressRegistry` storage paths. + +## Downgraded Findings + +- None. + +## Summary +- Total functions analyzed: 10 +- Coupled state pairs mapped: 4 +- Nemesis loop iterations: 4 passes +- Raw findings (pre-verification): 0 C | 0 H | 0 M | 1 L +- Feedback loop discoveries: 1 +- After verification: 1 TRUE POSITIVE | 0 FALSE POSITIVE | 0 DOWNGRADED +- Final: 0 CRITICAL | 0 HIGH | 0 MEDIUM | 1 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-address-registry-v6/codex-state-inconsistency-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-address-registry-v6/codex-state-inconsistency-verified.md new file mode 100644 index 0000000..85a3c46 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-address-registry-v6/codex-state-inconsistency-verified.md @@ -0,0 +1,81 @@ +# State Inconsistency Audit — Verified Findings + +## Coupled State Dependency Map + +| Pair | Invariant | Mutation points | +|------|-----------|-----------------| +| `deployerOf[computedAddress]` ↔ CREATE-derived address from `(deployer, nonce)` | stored key must exactly match deterministic CREATE derivation | `registerAddress(address,uint256)` | +| `deployerOf[computedAddress]` ↔ CREATE2-derived address from `(deployer, salt, bytecodeHash)` | stored key must exactly match deterministic CREATE2 derivation | `registerAddress(address,bytes32,bytes)` | +| `deployerOf[addr]` ↔ `AddressRegistered(addr,deployer,caller)` | event must mirror the mapping write and caller context | `_registerAddress` | +| deploy-script precheck target ↔ actual Sphinx deployment target | idempotence check must observe the same address later deployed | `Deploy.run`, `Deploy._isDeployed` | + +## Mutation Matrix + +| State Variable | Mutating Function | Updates Coupled State? | +|----------------|-------------------|-------------------------| +| `deployerOf[computed]` | `registerAddress(address,uint256)` | yes | +| `deployerOf[computed]` | `registerAddress(address,bytes32,bytes)` | yes | +| deploy-script idempotence assumption | `Deploy.run()` | no, precheck target differs from deployment target | + +## Parallel Path Comparison + +| Coupled State | CREATE path | CREATE2 path | Script deployment path | +|---------------|-------------|--------------|------------------------| +| mapping key ↔ deterministic formula | synced | synced | n/a | +| mapping write ↔ emitted event | synced | synced | n/a | +| precheck target ↔ actual deployment target | n/a | n/a | gap | + +## Verification Summary + +| ID | Coupled Pair | Breaking Op | Original Severity | Verdict | Final Severity | +|----|-------------|-------------|-------------------|---------|----------------| +| SI-001 | precheck target ↔ actual deployment target | `Deploy.run()` | LOW | TRUE POSITIVE | LOW | + +## Verified Findings + +### Finding SI-001: Sphinx deploy precheck desynchronizes from the real CREATE2 deployment path +**Severity:** LOW +**Verification:** Code trace + +**Coupled Pair:** deployment precheck target ↔ actual CREATE2 deployment target +**Invariant:** the address checked for existing code must be the same address later used by `new JBAddressRegistry{salt: ...}()` + +**Breaking Operation:** `run()` in `script/Deploy.s.sol:19-24` +- Modifies the deployment state by attempting a CREATE2 deployment from the Sphinx-pranked Safe. +- Does **not** check the Safe-based target first; `_isDeployed()` instead checks a DDP-based target in `script/Deploy.s.sol:29-33`. + +**Trigger Sequence:** +1. Deploy `JBAddressRegistry` once through the Sphinx flow. +2. Rerun the deploy script expecting it to skip because the contract already exists. +3. `_isDeployed()` checks `vm.computeCreate2Address(..., deployer = 0x4e59...)`. +4. The actual deployment still occurs from `safeAddress()` because the `sphinx` modifier pranks the Safe. +5. The second run sees a stale/incorrect precheck result and attempts a duplicate deployment. + +**Consequence:** +- The deployment script is not truly idempotent under its own execution model. +- Operators can hit an avoidable redeploy revert during retries or recovery operations. + +**Fix:** +```solidity +function _isDeployed(bytes32 salt, bytes memory creationCode, bytes memory arguments) internal returns (bool) { + address deployedTo = vm.computeCreate2Address({ + salt: salt, + initCodeHash: keccak256(abi.encodePacked(creationCode, arguments)), + deployer: safeAddress() + }); + return deployedTo.code.length != 0; +} +``` + +## False Positives Eliminated + +- No storage desynchronization exists inside `JBAddressRegistry`; both registration paths funnel through `_registerAddress`. +- No partial-update or stale-event issue exists; event emission occurs immediately after the mapping write and uses identical values. +- No lazy reconciliation pattern was missed; the contract maintains no secondary accounting state beyond `deployerOf`. + +## Summary +- Coupled state pairs mapped: 4 +- Mutation paths analyzed: 3 +- Raw findings (pre-verification): 1 +- After verification: 1 TRUE POSITIVE | 0 FALSE POSITIVE +- Final: 0 CRITICAL | 0 HIGH | 0 MEDIUM | 1 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-buyback-hook-v6/codex-feynman-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-buyback-hook-v6/codex-feynman-verified.md new file mode 100644 index 0000000..e7afd16 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-buyback-hook-v6/codex-feynman-verified.md @@ -0,0 +1,100 @@ +# Feynman Audit — Verified Findings + +## Scope +- Language: Solidity 0.8.26 +- Files analyzed: 9 Solidity files under `src/` and `script/` +- Primary logic modules: `JBBuybackHook`, `JBBuybackHookRegistry`, `JBSwapLib`, `DeployScript`, `BuybackDeploymentLib` +- Functions interrogated: 77 total function definitions in scope + +## Verification Summary +| ID | Original Severity | Verdict | Final Severity | +|----|-------------------|---------|----------------| +| FF-001 | LOW | FALSE POSITIVE | — | +| FF-002 | LOW | FALSE POSITIVE | — | + +## Function-State Matrix +| Function | Reads | Writes | Guards | External Calls | +|----|----|----|----|----| +| `JBBuybackHook.beforePayRecordedWith` | ruleset, price feed, pool key, TWAP window, project token | none | none | controller, prices | +| `JBBuybackHook.afterPayRecordedWith` | hook metadata, terminal balances, ruleset | terminal balance via `addToBalanceOf`, project token supply via controller | terminal-only | terminal, controller, pool manager, ERC20 | +| `JBBuybackHook.beforeCashOutRecordedWith` | project token, pool config, protocol reclaim inputs | none | none | none | +| `JBBuybackHook.afterCashOutRecordedWith` | pool config, hook metadata | project token supply via remint, pool-side settlement | terminal-only | controller, pool manager, ERC20/native transfer | +| `JBBuybackHook.initializePoolFor` / `setPoolFor` | project ownership, token registry, pool state | `_poolKeyOf`, `_poolIsSet`, `twapWindowOf`, `projectTokenOf` | `SET_BUYBACK_POOL` | pool manager | +| `JBBuybackHook.setTwapWindowOf` | ownership, current window | `twapWindowOf` | `SET_BUYBACK_TWAP` | none | +| `JBBuybackHook.unlockCallback` | swap callback params | none in hook storage | pool-manager-only | pool manager, ERC20 | +| `JBBuybackHookRegistry.beforePayRecordedWith` | `_hookOf`, `defaultHook` | none | none | resolved hook | +| `JBBuybackHookRegistry.beforeCashOutRecordedWith` | `_hookOf`, `defaultHook` | none | none | resolved hook | +| `JBBuybackHookRegistry.setHookFor` / `lockHookFor` / `setDefaultHook` | project ownership, allowlist | `_hookOf`, `hasLockedHook`, `defaultHook`, `isHookAllowed` | owner or `SET_BUYBACK_HOOK` | none | +| `DeployScript.run` / `deploy` | deployment registries, chain id | deploys registry + hook, sets default hook | Sphinx / script-only | core deployment libs, router deployment libs | + +## Guard Consistency Analysis +- Pool mutation paths are consistently gated by `SET_BUYBACK_POOL` in both the registry and the hook. +- TWAP mutation is consistently gated by `SET_BUYBACK_TWAP`. +- Execution callbacks are consistently gated: + - `afterPayRecordedWith` and `afterCashOutRecordedWith` require a valid project terminal. + - `unlockCallback` requires `msg.sender == POOL_MANAGER`. +- Registry owner-only admin functions (`allowHook`, `disallowHook`, `setDefaultHook`) remain isolated from project-operator permissions. + +## Inverse Operation Parity +- Buy side: + - `beforePayRecordedWith` chooses route, `afterPayRecordedWith` executes it. + - Swap success burns purchased project tokens before reminting with reserved-percent logic. + - Swap failure falls back to minting on the terminal balance delta, preserving the no-worse-than-mint invariant. +- Sell side: + - `beforeCashOutRecordedWith` chooses route, `afterCashOutRecordedWith` remints burned tokens and sells them. + - No silent fallback exists on sell-side execution, so a bad swap reverts the whole cash-out instead of creating partial state. +- Registry: + - `setHookFor` remains mutable until `lockHookFor`. + - `lockHookFor` pins the resolved hook, including the default hook when no project-specific hook was set. + +## Verified Findings (TRUE POSITIVES only) +None. + +## False Positives Eliminated + +### FF-001: Explicit quote path can fabricate a pool route when no pool is configured +**Original severity:** LOW +**Verdict:** FALSE POSITIVE +**Verification:** Code trace + invariant/test review + +**Suspicious code:** [src/JBBuybackHook.sol](/Users/jango/Documents/jb/v6/evm/nana-buyback-hook-v6/src/JBBuybackHook.sol#L746) + +Reasoning: +- When the payer supplies explicit quote metadata, `beforePayRecordedWith` skips `_getQuote` and initializes `poolId` from the raw stored `PoolKey`. +- With no configured pool, that raw `PoolKey` is zeroed, so `toId()` yields a deterministic non-zero hash and the function can emit a pay-hook specification. +- This looked like a route-selection bug, but the execution path does not create a value-loss condition: + - The actual swap uses `_poolKeyOf[...]` again in `_swap`, so the zeroed key is reused. + - `POOL_MANAGER.unlock(...)` then reverts and `_swap` catches it, returning `(0, true)`. + - `afterPayRecordedWith` skips the slippage check on `swapFailed`, computes the true leftover balance delta, returns the tokens to the terminal with `addToBalanceOf`, and mints only from the recovered balance delta. +- Result: the payer can at worst self-grief on gas by supplying an unusable explicit quote when no pool exists; they cannot extract funds, bypass slippage, or worsen other users' outcomes. +- Supporting evidence: + - `swapFailed` fallback path in [src/JBBuybackHook.sol](/Users/jango/Documents/jb/v6/evm/nana-buyback-hook-v6/src/JBBuybackHook.sol#L902) + - Regression and invariant coverage for fallback + no-worse-than-mint behavior in `SwapFailureMintFallback.t.sol` and `BuybackHookInvariant.t.sol` + +### FF-002: Registry pool-forwarders silently call address(0) if no hook is resolved +**Original severity:** LOW +**Verdict:** FALSE POSITIVE +**Verification:** Code trace + deployment-path review + +**Suspicious code:** [src/JBBuybackHookRegistry.sol](/Users/jango/Documents/jb/v6/evm/nana-buyback-hook-v6/src/JBBuybackHookRegistry.sol#L196) + +Reasoning: +- `initializePoolFor` and `setPoolFor` resolve `_hookOf[projectId]` and then `defaultHook`, but they do not explicitly revert when both are zero. +- As isolated code, that is a misconfiguration footgun: the forwarding call can target `address(0)`. +- It is not a protocol vulnerability in the audited deployment model: + - The production deployment script sets the default hook in the same deployment flow at [script/Deploy.s.sol](/Users/jango/Documents/jb/v6/evm/nana-buyback-hook-v6/script/Deploy.s.sol#L80). + - The earlier default-clearing regression has already been fixed: `disallowHook` now reverts if asked to remove the current default. + - The payment path regression coverage confirms the intended invariant that the default hook cannot be cleared into `address(0)`. +- Impact is limited to operator misconfiguration before initial setup, not attacker-controlled state corruption or fund loss. + +## Downgraded Findings +None. + +## LOW Findings (verified by inspection) +None. + +## Summary +- Total functions analyzed: 77 +- Raw findings (pre-verification): 0 CRITICAL | 0 HIGH | 0 MEDIUM | 2 LOW hypotheses +- After verification: 0 TRUE POSITIVE | 2 FALSE POSITIVE | 0 DOWNGRADED +- Final: 0 HIGH | 0 MEDIUM | 0 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-buyback-hook-v6/codex-nemesis-raw.md b/.audit-findings-codex-prev-20260323-211452/nana-buyback-hook-v6/codex-nemesis-raw.md new file mode 100644 index 0000000..1e3cd54 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-buyback-hook-v6/codex-nemesis-raw.md @@ -0,0 +1,142 @@ +# N E M E S I S — Raw Working Notes + +## Scope +- Fresh round. No prior findings consumed. +- In-scope Solidity: all files under `src/` and `script/` recursively. +- Files audited: + - `src/JBBuybackHook.sol` + - `src/JBBuybackHookRegistry.sol` + - `src/libraries/JBSwapLib.sol` + - `src/interfaces/IJBBuybackHook.sol` + - `src/interfaces/IJBBuybackHookRegistry.sol` + - `src/interfaces/IGeomeanOracle.sol` + - `src/structs/SwapCallbackData.sol` + - `script/Deploy.s.sol` + - `script/helpers/BuybackDeploymentLib.sol` + +## Phase 0 — Nemesis Recon + +**Language:** Solidity 0.8.26 + +**Attack goals** +1. Force routing through the worse path and extract value from mint-vs-swap mispricing. +2. Abuse callback ordering to steal or strand terminal funds during swap settlement. +3. Break registry/default-hook resolution so project payments or pool setup become unusable. +4. Desynchronize terminal accounting from actual tokens held after partial swaps or fee-on-transfer behavior. + +**Novel code** +- `src/JBBuybackHook.sol` — custom route-selection and V4 settlement glue. +- `src/libraries/JBSwapLib.sol` — custom TWAP/slippage/price-limit math. +- `src/JBBuybackHookRegistry.sol` — project-hook resolution and lock semantics. +- `script/Deploy.s.sol` — hardcoded PoolManager selection and default-hook initialization. + +**Value stores + initial coupling hypotheses** +- Terminal payment value sits in the terminal until the pay hook diverts some/all of it. + - Coupled state: hook token balance delta ↔ `addToBalanceOf` refund ↔ minted token count. +- Project-token buyback path holds bought project tokens transiently in the hook. + - Coupled state: swap output ↔ burn amount ↔ reminted amount. +- Pool configuration is persistent protocol state. + - Coupled state: `_poolKeyOf` ↔ `_poolIsSet` ↔ `twapWindowOf` ↔ `projectTokenOf`. +- Registry routing state controls which implementation may mint on behalf of a project. + - Coupled state: `_hookOf` ↔ `defaultHook` ↔ `hasLockedHook` ↔ `isHookAllowed`. + +**Complex paths** +- `beforePayRecordedWith` → terminal pay hook fulfillment → `afterPayRecordedWith` → `_swap` → `unlockCallback` → PoolManager swap → burn → refund leftover → remint. +- `beforeCashOutRecordedWith` → terminal burn → `afterCashOutRecordedWith` → remint → `_swapExactInput` → beneficiary payout. +- Registry resolution + lock/default fallback + hook-forwarded pool initialization. + +**Priority order** +1. `JBBuybackHook.beforePayRecordedWith` / `afterPayRecordedWith` +2. `JBBuybackHook.unlockCallback` / `_swap` +3. `JBBuybackHook.beforeCashOutRecordedWith` / `afterCashOutRecordedWith` +4. `JBBuybackHookRegistry` +5. `DeployScript` + +## Phase 1 — Unified Nemesis Map + +| Function | Writes A | Writes B | Coupled Pair | Initial Sync Status | +|----|----|----|----|----| +| `_setPoolFor` | `_poolKeyOf` | `_poolIsSet`, `twapWindowOf`, `projectTokenOf` | pool config | synced | +| `setTwapWindowOf` | `twapWindowOf` | none | pool key ↔ twap | intended standalone mutation | +| `afterPayRecordedWith` | hook token balance delta | terminal refund + mint count | leftovers ↔ mint | synced | +| `_swap` | project token balance (via burn) | remint in caller | swap output ↔ supply | synced | +| `afterCashOutRecordedWith` | remint exact `cashOutCount` | swap proceeds to beneficiary | burned count ↔ sell amount | synced | +| `setHookFor` | `_hookOf` | none | hook ↔ lock/default | synced pending lock | +| `lockHookFor` | `hasLockedHook`, `_hookOf` | resolved default snapshot | hook ↔ lock | synced | +| `setDefaultHook` | `defaultHook` | `isHookAllowed` | default ↔ allowlist | synced | + +## Pass 1 — Feynman (full) + +### Core interrogations +- Why is buy-side route selection allowed to skip `_getQuote` on explicit payer quotes? + - Answer: explicit min-out is user sovereignty, not protocol trust. +- Why is swap failure caught on buy side but not sell side? + - Answer: buy side can degrade safely to minting; sell side cannot safely degrade after the holder initiated a cash-out route. +- Why are bought project tokens burned before remint? + - Answer: to reapply reserved-percent logic uniformly across mint and swap routes. +- Why does registry forward project-pool setup without local hook existence checks? + - Suspect: if no resolved hook exists, forwarding semantics degrade unexpectedly. + +### Raw suspects +1. **S1 / LOW:** explicit quote path can construct hook specifications even when `_poolIsSet` is false because `poolId` is read from raw storage before `_getQuote` would return `PoolId.wrap(0)`. +2. **S2 / LOW:** registry `initializePoolFor` / `setPoolFor` do not explicitly revert on missing resolved hook. + +## Pass 2 — State Inconsistency (full, enriched) + +### Coupled pairs checked +- `_poolKeyOf` / `_poolIsSet` / `twapWindowOf` / `projectTokenOf` +- hook terminal-token balance / terminal refund / minted amount +- `_hookOf` / `defaultHook` / `hasLockedHook` / `isHookAllowed` +- swap output / burn / remint +- cash-out burn / remint / beneficiary proceeds + +### State gaps searched +- functions that mutate pool metadata without the activation flag +- execution paths that refund leftover funds without matching mint accounting +- registry paths that mutate routing state without preserving default/lock invariants + +### State-pass result +- No confirmed coupled-state desyncs. +- S1 and S2 remained as verification candidates only. + +## Pass 3 — Targeted Feynman Re-interrogation + +### On S1 +- Why doesn’t `beforePayRecordedWith` require `_poolIsSet` before using explicit quotes? + - Because the explicit quote path only decides whether to attempt a swap. +- What breaks downstream if the pool is absent? + - `_swap` reverts inside `POOL_MANAGER.unlock`. +- Can attacker profit from choosing this sequence? + - No. The path falls back to balance-delta mint/refund behavior, preserving no-worse-than-mint. + +### On S2 +- Why doesn’t registry pool-forwarding reject missing hooks locally? + - The deployment model assumes a default hook is set immediately. +- What downstream function breaks? + - Only operator setup flows in a misconfigured registry; not a fund-moving path after correct deployment. +- Can attacker choose a sequence to profit? + - No. This is a setup footgun, not attacker-controlled privilege escalation or accounting drift. + +## Convergence +- No new findings after targeted re-interrogation. +- No additional coupled pairs surfaced from the targeted pass. +- Nemesis converged after 3 passes: full Feynman, full State, targeted Feynman. + +## Verification Evidence Collected +- Code traces: + - `beforePayRecordedWith` [src/JBBuybackHook.sol](/Users/jango/Documents/jb/v6/evm/nana-buyback-hook-v6/src/JBBuybackHook.sol#L686) + - `_getQuote` [src/JBBuybackHook.sol](/Users/jango/Documents/jb/v6/evm/nana-buyback-hook-v6/src/JBBuybackHook.sol#L1035) + - registry pool forwarders [src/JBBuybackHookRegistry.sol](/Users/jango/Documents/jb/v6/evm/nana-buyback-hook-v6/src/JBBuybackHookRegistry.sol#L196) + - deploy-time default hook configuration [script/Deploy.s.sol](/Users/jango/Documents/jb/v6/evm/nana-buyback-hook-v6/script/Deploy.s.sol#L80) +- Test execution: + - `forge test --match-contract Registry` + - `forge test --match-contract JBSwapLibTest` + - `forge test --match-contract TestAuditGaps` + - `forge test` + - Result: 148 tests passed; 5 fork tests failed only because the configured RPC endpoint returned HTTP 401. + +## Raw Finding Counts +- 0 Critical +- 0 High +- 0 Medium +- 2 Low-confidence hypotheses diff --git a/.audit-findings-codex-prev-20260323-211452/nana-buyback-hook-v6/codex-nemesis-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-buyback-hook-v6/codex-nemesis-verified.md new file mode 100644 index 0000000..74074d1 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-buyback-hook-v6/codex-nemesis-verified.md @@ -0,0 +1,107 @@ +# N E M E S I S — Verified Findings + +## Scope +- Language: Solidity 0.8.26 +- Modules analyzed: `JBBuybackHook`, `JBBuybackHookRegistry`, `JBSwapLib`, interfaces/structs, `DeployScript`, `BuybackDeploymentLib` +- Solidity files analyzed: 9 +- Functions analyzed: 77 total definitions in scope +- Coupled state pairs mapped: 8 +- Mutation paths traced: 18 +- Nemesis passes: 3 total + - Pass 1: Feynman full + - Pass 2: State full + - Pass 3: targeted Feynman re-interrogation + +## Nemesis Map (Phase 1 Cross-Reference) +| Function | Writes A | Writes B | A↔B Pair | Sync Status | +|----|----|----|----|----| +| `_setPoolFor` | `_poolKeyOf` | `_poolIsSet`, `twapWindowOf`, `projectTokenOf` | pool config | synced | +| `setTwapWindowOf` | `twapWindowOf` | — | pool key ↔ window | designed standalone update | +| `afterPayRecordedWith` | hook token balance delta | terminal refund, beneficiary mint count | leftover ↔ refund/mint | synced | +| `_swap` | project token balance via burn | remint in caller | bought tokens ↔ supply | synced | +| `afterCashOutRecordedWith` | remint count | sell proceeds | burned amount ↔ sell size | synced | +| `setHookFor` | `_hookOf` | — | hook ↔ lock/default | synced | +| `lockHookFor` | `hasLockedHook` | `_hookOf` default snapshot if needed | hook ↔ lock | synced | +| `setDefaultHook` | `defaultHook` | `isHookAllowed` | default ↔ allowlist | synced | + +## Verification Summary +| ID | Source | Coupled Pair | Breaking Op | Severity | Verdict | +|----|--------|-------------|-------------|----------|---------| +| NM-001 | Feynman-only | `_poolKeyOf` ↔ `_poolIsSet` | `beforePayRecordedWith()` | LOW | FALSE POSITIVE | +| NM-002 | Feynman-only | `_hookOf` ↔ `defaultHook` | registry pool forwarders | LOW | FALSE POSITIVE | + +## Verified Findings (TRUE POSITIVES only) +None. + +## Feedback Loop Discoveries +None. The State pass did not expose any new broken coupled pairs beyond the two Feynman-originated suspects, and the targeted re-pass eliminated both on verification. + +## False Positives Eliminated + +### Finding NM-001: Explicit quote branch can emit a hook spec even when no pool is configured +**Severity:** LOW +**Source:** Feynman-only +**Verification:** Code trace + +**Coupled Pair:** `_poolKeyOf[projectId][terminalToken]` ↔ `_poolIsSet[projectId][terminalToken]` +**Invariant:** A pool route is only executable when the configured pool is marked active. + +**Feynman question that exposed it:** +> Why does `beforePayRecordedWith()` trust raw pool metadata before `_getQuote()` enforces `_poolIsSet`? + +**State Mapper gap that checked it:** +> The explicit-quote path reads `poolId` from `_poolKeyOf` without consulting `_poolIsSet`. + +**Breaking operation candidate:** [src/JBBuybackHook.sol](/Users/jango/Documents/jb/v6/evm/nana-buyback-hook-v6/src/JBBuybackHook.sol#L746) + +**Verification evidence:** +- The actual execution path still uses the stored pool key in `_swap()`. +- With no configured pool, `POOL_MANAGER.unlock(...)` reverts and `_swap()` returns `(0, true)`. +- `afterPayRecordedWith()` then skips the slippage check, computes the real leftover delta, returns value to the terminal, and mints from the recovered balance delta only. +- No attacker-controlled sequence produces fund loss, stale state, or a worse-than-mint outcome. + +**Verdict:** FALSE POSITIVE. The branch is a self-griefing gas inefficiency at most, not a security issue. + +### Finding NM-002: Registry pool-forwarding does not locally reject a missing resolved hook +**Severity:** LOW +**Source:** Feynman-only +**Verification:** Code trace + +**Coupled Pair:** `_hookOf[projectId]` ↔ `defaultHook` +**Invariant:** The registry should forward only to a concrete resolved hook. + +**Feynman question that exposed it:** +> Why do `initializePoolFor()` and `setPoolFor()` forward blindly after resolving `_hookOf` and `defaultHook`? + +**State Mapper gap that checked it:** +> Pool-forwarding paths do not have the same explicit zero-hook passthrough used by `beforeCashOutRecordedWith()`. + +**Breaking operation candidate:** [src/JBBuybackHookRegistry.sol](/Users/jango/Documents/jb/v6/evm/nana-buyback-hook-v6/src/JBBuybackHookRegistry.sol#L196) + +**Verification evidence:** +- The only unsafe case is an operator deploying or using the registry before assigning either a project-specific hook or a default hook. +- The shipped deployment flow sets the default hook immediately in [script/Deploy.s.sol](/Users/jango/Documents/jb/v6/evm/nana-buyback-hook-v6/script/Deploy.s.sol#L80). +- The earlier default-clearing route was already fixed; `disallowHook` cannot clear the active default. +- No attacker can move a correctly configured deployment into this state. + +**Verdict:** FALSE POSITIVE. This is a setup precondition, not an exploitable protocol vulnerability. + +## Downgraded Findings +None. + +## Summary +- Total functions analyzed: 77 +- Coupled state pairs mapped: 8 +- Nemesis loop iterations: 1 targeted re-pass after the baseline passes +- Raw findings (pre-verification): 0 C | 0 H | 0 M | 2 L +- Feedback loop discoveries: 0 +- After verification: 0 TRUE POSITIVE | 2 FALSE POSITIVE | 0 DOWNGRADED +- Final: 0 CRITICAL | 0 HIGH | 0 MEDIUM | 0 LOW + +## Verification Notes +- Local verification passed on the non-fork suite: + - `forge test --match-contract Registry` + - `forge test --match-contract JBSwapLibTest` + - `forge test --match-contract TestAuditGaps` +- Full `forge test` run: 148 tests passed. +- Remaining 5 failures were fork-only tests blocked by an unavailable RPC provider (`rpc.ankr.com` returned HTTP 401), not by local logic regressions. diff --git a/.audit-findings-codex-prev-20260323-211452/nana-buyback-hook-v6/codex-state-inconsistency-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-buyback-hook-v6/codex-state-inconsistency-verified.md new file mode 100644 index 0000000..102d48f --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-buyback-hook-v6/codex-state-inconsistency-verified.md @@ -0,0 +1,82 @@ +# State Inconsistency Audit — Verified Findings + +## Coupled State Dependency Map +| Pair | Invariant | Mutation Points | +|----|----|----| +| `_poolKeyOf[projectId][terminalToken]` ↔ `_poolIsSet[projectId][terminalToken]` | A non-zero configured pool must only be considered active once the one-shot flag is set | `_setPoolFor` | +| `_poolKeyOf[...]` ↔ `twapWindowOf[...]` | Pool configuration and TWAP window must be initialized together for a new pair | `_setPoolFor` | +| `projectTokenOf[projectId]` ↔ `_poolKeyOf[...]` | Cached project token must match the token embedded in every configured pool key | `_setPoolFor` | +| Hook terminal-token balance delta ↔ `addToBalanceOf` refund ↔ `partialMintTokenCount` | Unswapped leftovers must be returned to terminal accounting before minting from the same balance delta | `afterPayRecordedWith` | +| Swap output amount ↔ `burnTokensOf` ↔ `mintTokensOf(..., useReservedPercent=true)` | Buyback path must apply the same reserved-percent economics as direct minting | `_swap`, `afterPayRecordedWith` | +| Burned cash-out amount ↔ remint-to-hook ↔ pool sell proceeds | Sell-side cash-out must remint exactly the burned amount before swapping | `afterCashOutRecordedWith` | +| `_hookOf[projectId]` ↔ `hasLockedHook[projectId]` | Once locked, a project's resolved hook cannot be changed | `setHookFor`, `lockHookFor` | +| `defaultHook` ↔ `isHookAllowed[defaultHook]` | Active default must remain allowlisted | `setDefaultHook`, `disallowHook` | + +## Mutation Matrix +| State Variable | Mutating Function | Updates Coupled State? | +|----|----|----| +| `_poolKeyOf` | `_setPoolFor` | Yes: sets `_poolIsSet`, `twapWindowOf`, `projectTokenOf` | +| `_poolIsSet` | `_setPoolFor` | Yes: written alongside `_poolKeyOf` | +| `twapWindowOf` | `_setPoolFor` | Yes: initial sync with pool config | +| `twapWindowOf` | `setTwapWindowOf` | Intended standalone update after pool setup | +| `projectTokenOf` | `_setPoolFor` | Yes: synchronized with configured pool | +| Hook token balance | `afterPayRecordedWith`, `unlockCallback`, `_swap` | Yes: refunded via `addToBalanceOf`, then minted from delta | +| `_hookOf` | `setHookFor`, `lockHookFor` | Yes: lock path snapshots default into project slot | +| `defaultHook` | `setDefaultHook` | Yes: also marks allowlist entry | +| `isHookAllowed` | `allowHook`, `disallowHook`, `setDefaultHook` | Yes: default hook cannot be disallowed | + +## Parallel Path Comparison +| Coupled State | Path A | Path B | Verdict | +|----|----|----|----| +| Pool config | `initializePoolFor` | `setPoolFor` | Both end in `_setPoolFor`; synchronized | +| Buy execution | Successful V4 swap | Failed V4 swap | Both preserve token conservation; failure path returns leftovers then mints | +| Cash-out routing | Protocol reclaim | Pool sell | Sell path remints exact burned amount; no partial-state gap | +| Registry resolution | Project hook set | Default hook fallback | `hookOf`, `beforePayRecordedWith`, and `hasMintPermissionFor` resolve consistently | + +## Verification Summary +| ID | Coupled Pair | Breaking Op | Original Severity | Verdict | Final Severity | +|----|-------------|-------------|-------------------|---------|----------------| +| SI-001 | `_poolKeyOf` ↔ `_poolIsSet` | `beforePayRecordedWith` explicit quote path | LOW | FALSE POSITIVE | — | +| SI-002 | `_hookOf` ↔ `defaultHook` ↔ `hasLockedHook` | registry forwarding with no hook | LOW | FALSE POSITIVE | — | + +## Verified Findings +None. + +## False Positives Eliminated + +### SI-001: Explicit quote path desynchronizes pool activation from stored pool metadata +**Severity:** LOW hypothesis +**Verification:** Code trace + +**Coupled Pair:** `_poolKeyOf[projectId][terminalToken]` ↔ `_poolIsSet[projectId][terminalToken]` +**Invariant:** A pool should only be executable when the one-shot activation flag is set. + +**Breaking operation candidate:** [src/JBBuybackHook.sol](/Users/jango/Documents/jb/v6/evm/nana-buyback-hook-v6/src/JBBuybackHook.sol#L746) + +Why it is not a true positive: +- The explicit-quote branch reads a `poolId` from raw storage before `_getQuote` enforces `_poolIsSet`. +- That creates an informational mismatch in `beforePayRecordedWith`, but execution still uses the stored `PoolKey` in `_swap`. +- Without `_poolIsSet`, the stored key is zeroed, `POOL_MANAGER.unlock(...)` reverts, and `_swap` falls back safely. +- The downstream balance-delta/refund logic keeps terminal accounting synchronized. + +### SI-002: Registry can resolve no hook on pool-forwarding paths +**Severity:** LOW hypothesis +**Verification:** Code trace + deployment review + +**Coupled Pair:** `_hookOf[projectId]` ↔ `defaultHook` +**Invariant:** Registry forwarding should only call a concrete resolved hook. + +**Breaking operation candidate:** [src/JBBuybackHookRegistry.sol](/Users/jango/Documents/jb/v6/evm/nana-buyback-hook-v6/src/JBBuybackHookRegistry.sol#L196) + +Why it is not a true positive: +- The state relationship is only unsafe in an uninitialized operator configuration where neither a project hook nor a default hook exists. +- The shipped deployment flow sets the default hook immediately after deploying both contracts. +- The previously dangerous path where an admin could clear the default hook has already been blocked by `disallowHook`. +- No attacker-controlled transition can desynchronize these states after normal deployment. + +## Summary +- Coupled state pairs mapped: 8 +- Mutation paths analyzed: 18 +- Raw findings (pre-verification): 2 low-confidence hypotheses +- After verification: 0 TRUE POSITIVE | 2 FALSE POSITIVE +- Final: 0 CRITICAL | 0 HIGH | 0 MEDIUM | 0 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-core-v6/codex-feynman-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-core-v6/codex-feynman-verified.md new file mode 100644 index 0000000..9fe5c89 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-core-v6/codex-feynman-verified.md @@ -0,0 +1,112 @@ +# Feynman Audit — Verified Findings + +## Scope +- Language: Solidity 0.8.26 +- Files analyzed: 89 Solidity files under `src/` and `script/` +- Functions analyzed: 398 +- Primary focus: `JBMultiTerminal`, `JBTerminalStore`, `JBController`, `JBRulesets`, `JBDirectory`, `JBPermissions`, payout/fee helpers, and both deployment scripts + +## Verification Summary +| ID | Original Severity | Verdict | Final Severity | +|----|-------------------|---------|----------------| +| FF-001 | MEDIUM | TRUE POSITIVE | MEDIUM | +| FF-002 | MEDIUM | TRUE POSITIVE | MEDIUM | +| FF-003 | LOW | TRUE POSITIVE | LOW | + +## Verified Findings + +### Finding FF-001: MEDIUM — `JBPermissions` allows wildcard `ROOT` grants despite the documented safety rail forbidding them +**Module:** `src/JBPermissions.sol` +**Lines:** `62-96` +**Verification:** Hybrid — code trace plus PoC test `test/audit/CodexPermissionsWildcardRoot.t.sol` + +**Feynman question that exposed this:** +> If wildcard `projectId = 0` and `ROOT` are explicitly documented as incompatible, where is that incompatibility enforced for the account itself? + +**The code:** +- [`src/JBPermissions.sol:62`](src/JBPermissions.sol#L62) gates the `ROOT`/wildcard restriction only inside `if (msgSender != account)`. +- When `msgSender == account`, the function writes `permissionsOf[operator][account][projectId] = packed` with no `ROOT + projectId == 0` check. + +**Why this is wrong:** +The repo’s own security model says `ROOT` must not be grantable on wildcard scope because that creates an all-project super-operator. The implementation only blocks existing operators from doing this, but the account itself can still create that forbidden grant in one call. + +**Verification evidence:** +- Code trace: the only guard is the branch at [`src/JBPermissions.sol:74-86`](src/JBPermissions.sol#L74), which is skipped when `msgSender == account`. +- PoC: `forge test --match-path test/audit/CodexPermissionsWildcardRoot.t.sol -vvv` + - Passed: `test_ownerCanGrantWildcardRootAndOperatorGetsAllProjectPermissions()` + - The owner granted `ROOT` on project `0`, and the operator successfully called `JBController.setUriOf(...)` on two distinct projects. + +**Attack / misuse scenario:** +1. A project owner signs what they believe is a normal operator grant. +2. The transaction grants `ROOT` with `projectId = 0`. +3. The operator now has every permission on every current and future project owned by that account. +4. The operator can also delegate non-ROOT permissions onward on specific projects because wildcard `ROOT` satisfies the `hasPermission(... includeRoot=true, includeWildcardProjectId=true)` check. + +**Impact:** +This collapses the intended project-by-project permission boundary into one global super-admin grant. The owner is still the source of the grant, so this is not a permissionless takeover, but it defeats an explicitly documented safety rail meant to prevent exactly this class of catastrophic over-delegation. + +**Suggested fix:** +Add an unconditional check before the operator branch: + +```solidity +if ( + permissionsData.projectId == WILDCARD_PROJECT_ID + && _includesPermission({permissions: packed, permissionId: JBPermissionIds.ROOT}) +) revert JBPermissions_CantSetRootPermissionForWildcardProject(); +``` + +### Finding FF-002: MEDIUM — The v6 deployment scripts are still hardwired to the v5 Sphinx project namespace +**Modules:** `script/Deploy.s.sol`, `script/DeployPeriphery.s.sol`, `script/helpers/CoreDeploymentLib.sol` +**Lines:** `Deploy.s.sol:45-48`, `DeployPeriphery.s.sol:49-52`, `CoreDeploymentLib.sol:43` +**Verification:** Code trace + +**Feynman question that exposed this:** +> Why does a v6 repo deploy under the `nana-core-v5` Sphinx project name, and why does the periphery loader read deployments from the same v5 namespace? + +**The code:** +- [`script/Deploy.s.sol:46`](script/Deploy.s.sol#L46) sets `sphinxConfig.projectName = "nana-core-v5";` +- [`script/DeployPeriphery.s.sol:50`](script/DeployPeriphery.s.sol#L50) does the same. +- [`script/helpers/CoreDeploymentLib.sol:43`](script/helpers/CoreDeploymentLib.sol#L43) hardcodes `PROJECT_NAME = "nana-core-v5";` +- The repo metadata is v6: [`package.json`](package.json) points at `nana-core-v6`, and the artifacts script uses `--project-name 'nana-core-v6'`. + +**Why this is wrong:** +The core deploy, periphery deploy, and deployment-address loader are all keyed to the wrong namespace. In the best case this causes operational confusion and artifact lookup failures. In the worst case, if v5 deployment JSON exists in the shared path, `DeployPeriphery.run()` loads those addresses and mutates the wrong live contracts when it adds price feeds or allowlists a controller. + +**Verification evidence:** +- `DeployPeriphery.run()` loads `core = CoreDeploymentLib.getDeployment(...)`, and that helper always reads from `deployments/nana-core-v5/...`. +- `package.json`’s artifacts command uses `nana-core-v6`, proving the repo’s own tooling is internally inconsistent. + +**Impact:** +Operators can silently target the wrong deployment set or fail to find the deployment they just created. Because `DeployPeriphery` performs privileged writes (`addPriceFeedFor`, `setIsAllowedToSetFirstController`), a namespace collision can mutate an unrelated deployment rather than merely failing closed. + +**Suggested fix:** +Rename all three constants to `nana-core-v6` and keep the package/tooling namespace consistent end to end. + +### Finding FF-003: LOW — Base Sepolia USDC/USD deployment is wired to a feed address the script itself marks as likely wrong +**Module:** `script/DeployPeriphery.s.sol` +**Lines:** `255-263` +**Verification:** Code trace + +**Feynman question that exposed this:** +> Why is the Base Sepolia USDC/USD branch shipping a feed address that the script comments already identify as the Arbitrum Sepolia ETH/USD feed? + +**The code:** +- [`script/DeployPeriphery.s.sol:257-261`](script/DeployPeriphery.s.sol#L257) contains a TODO stating `0xd30e2101...` is likely the wrong feed for Base Sepolia USDC/USD, then immediately deploys `JBChainlinkV3PriceFeed` with that address anyway. + +**Why this is wrong:** +The script acknowledges the address is probably not the intended oracle and still uses it. A periphery deployment on Base Sepolia will therefore register a price feed that may revert, price the wrong asset pair, or otherwise make testnet price-dependent flows invalid. + +**Impact:** +Testnet-only mispricing / DoS for Base Sepolia deployments. This does not affect the audited mainnet branches in the same script. + +**Suggested fix:** +Replace the placeholder with the correct Base Sepolia USDC/USD Chainlink feed before allowing this branch to deploy. + +## False Positives Eliminated +- The ERC-20 allowance persistence after hook/split forwarding in `JBMultiTerminal` initially looked like a cross-project drain. After tracing the accounting and pull model end to end, the callee could only delay collection of funds it was already entitled to receive, so I did not report it. +- The `JBRulesets._simulateCycledRulesetBasedOn(...)` mid-cycle start calculation looked suspicious on first read, but the `currentOf(...)` control flow returns the live stored ruleset whenever one is active, so the suspicious branch is only used for simulated rollover and did not produce a reachable bug in this round. + +## Summary +- Raw findings (pre-verification): 0 CRITICAL | 0 HIGH | 2 MEDIUM | 1 LOW +- After verification: 3 TRUE POSITIVE | 0 FALSE POSITIVE | 0 DOWNGRADED +- Final: 0 HIGH | 2 MEDIUM | 1 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-core-v6/codex-nemesis-raw.md b/.audit-findings-codex-prev-20260323-211452/nana-core-v6/codex-nemesis-raw.md new file mode 100644 index 0000000..f1eb4ac --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-core-v6/codex-nemesis-raw.md @@ -0,0 +1,76 @@ +# N E M E S I S — Raw Notes + +## Scope +- Fresh round over every Solidity file in `src/` and `script/` +- `.audit/findings/` intentionally ignored as instructed +- Files: 89 +- Functions: 398 + +## Phase 0 — Recon + +### Attack goals +1. Break fund conservation between `JBMultiTerminal` and `JBTerminalStore`. +2. Bypass payout / allowance / cash-out invariants to extract more value than configured. +3. Escalate cross-project permissions through wildcard or ROOT edge cases. +4. Corrupt deployment state through wrong addresses, wrong feeds, or namespace collisions. + +### Novel / priority code +- `JBMultiTerminal` / `JBTerminalStore`: all balance mutations and hook composition. +- `JBPermissions`: packed bitmaps plus wildcard/ROOT semantics. +- `JBRulesets`: approval-hook fallback and simulated cycles. +- `script/Deploy*.s.sol`: deployment namespace, oracle addresses, omnichain operator. + +### Initial coupling hypotheses +- Terminal real balance ↔ store `balanceOf` +- Used payout/allowance counters ↔ configured limit tables +- Reserved supply ↔ total-supply-with-reserved view +- Explicit primary terminals ↔ membership in terminal set +- Fee holding arrays ↔ processing index + +## Pass 1 — Feynman (full) + +### Confirmed suspects +1. `JBPermissions.setPermissionsFor(...)` + - Docs say wildcard `projectId = 0` cannot be combined with `ROOT`. + - Implementation only forbids that combination for `msgSender != account`. + - Candidate finding advanced to verification. + +2. Deployment namespace + - `Deploy.s.sol`, `DeployPeriphery.s.sol`, and `CoreDeploymentLib.sol` all still reference `nana-core-v5`. + - Candidate finding advanced to verification. + +3. Base Sepolia oracle config + - Script comment explicitly says selected USDC/USD feed is likely wrong, but deployment branch still uses it. + - Candidate finding advanced to verification. + +### Cleared / downgraded suspects +- ERC20 allowance persistence in terminal forwarding paths: cleared after end-to-end accounting trace. +- `JBRulesets` rollover simulation: suspicious expression did not become reachable through the actual `currentOf(...)` path used by core flows. + +## Pass 2 — State Inconsistency (full) + +### Coupled pairs mapped +- `balanceOf` / terminal balances +- `usedPayoutLimitOf` / payout limits +- `usedSurplusAllowanceOf` / surplus allowances +- reserved supply / total supply +- terminal set / primary terminal +- held fees array / next index +- split count / packed split storage + +### New gaps +- No verified missing-update state gap survived trace review. + +## Convergence +- Pass 2 produced no new verified coupled-state findings and no new delta requiring a targeted Pass 3. +- Converged after the two full baseline passes. + +## Verification queue +1. Wildcard ROOT grant: verify with PoC. +2. v5 deployment namespace mismatch: verify by code trace across scripts and package tooling. +3. Base Sepolia feed: verify by direct code trace. + +## Verification results +- Wildcard ROOT PoC: confirmed. +- Namespace mismatch: confirmed. +- Base Sepolia wrong feed: confirmed. diff --git a/.audit-findings-codex-prev-20260323-211452/nana-core-v6/codex-nemesis-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-core-v6/codex-nemesis-verified.md new file mode 100644 index 0000000..8a1f5bc --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-core-v6/codex-nemesis-verified.md @@ -0,0 +1,143 @@ +# N E M E S I S — Verified Findings + +## Scope +- Language: Solidity 0.8.26 +- Modules analyzed: all Solidity files under `src/` and `script/` +- Functions analyzed: 398 +- Coupled state pairs mapped: 10 major protocol invariants +- Mutation paths traced: 60+ +- Nemesis loop iterations: 1 full back-and-forth cycle after recon + +## Nemesis Map +- **Funds:** `JBMultiTerminal` actual balances ↔ `JBTerminalStore.balanceOf` +- **Limits:** `usedPayoutLimitOf` / `usedSurplusAllowanceOf` ↔ fund access limit tables +- **Supply:** ERC20 supply + credit supply + pending reserved supply +- **Routing:** terminal membership ↔ primary terminal pointers +- **Fee lifecycle:** held-fee array ↔ next-held-fee index ↔ fee processing path +- **Deployment state:** Sphinx namespace ↔ deployment-address loader ↔ periphery mutators + +## Verification Summary +| ID | Source | Coupled Pair | Breaking Op | Severity | Verdict | +|----|--------|-------------|-------------|----------|---------| +| NM-001 | Feynman-only | permission bitmap ↔ wildcard scope invariant | `JBPermissions.setPermissionsFor()` | MEDIUM | TRUE POS | +| NM-002 | Feynman-only | deployment namespace ↔ deployment loader | `configureSphinx()` / `CoreDeploymentLib.getDeployment()` | MEDIUM | TRUE POS | +| NM-003 | Feynman-only | chain selector ↔ oracle address | `_deployUSDCFeed()` | LOW | TRUE POS | + +## Verified Findings + +### Finding NM-001: MEDIUM — Wildcard `ROOT` can still be granted even though the protocol documents that it must revert +**Severity:** MEDIUM +**Source:** Feynman-only +**Verification:** Hybrid + +**Coupled Pair:** permission bitmap ↔ wildcard-scope safety invariant +**Invariant:** `ROOT` must never be grantable with `projectId = 0`. + +**Feynman question that exposed it:** +> Where is the unconditional check that forbids `ROOT + wildcard`, not just operator-mediated forwarding? + +**State Mapper confirmation:** +The dangerous state written is `permissionsOf[operator][account][0]`, and `hasPermission(... includeWildcardProjectId=true)` treats that slot as a cross-project fallback for every project. + +**Breaking Operation:** `setPermissionsFor()` at [`src/JBPermissions.sol:62`](src/JBPermissions.sol#L62) +- Writes wildcard permission storage for the operator. +- Does not reject `ROOT` when `msgSender == account`. + +**Trigger Sequence:** +1. The account calls `setPermissionsFor(account, { operator, projectId: 0, permissionIds: [ROOT] })`. +2. The write succeeds. +3. The operator now passes permission checks on any project owned by that account whenever `includeRoot` and `includeWildcardProjectId` are enabled. + +**Consequence:** +- One signature creates an all-project super-operator. +- The operator can perform privileged actions across every project owned by the account and can further delegate specific non-ROOT permissions project by project. + +**Verification Evidence:** +- Code trace: [`src/JBPermissions.sol:74-86`](src/JBPermissions.sol#L74) only enforces the restriction for `msgSender != account`. +- PoC: `test/audit/CodexPermissionsWildcardRoot.t.sol` + - `forge test --match-path test/audit/CodexPermissionsWildcardRoot.t.sol -vvv` + - Passed: the wildcard-ROOT operator successfully changed metadata on two separate projects. + +**Fix:** +```solidity +if ( + permissionsData.projectId == WILDCARD_PROJECT_ID + && _includesPermission({permissions: packed, permissionId: JBPermissionIds.ROOT}) +) revert JBPermissions_CantSetRootPermissionForWildcardProject(); +``` + +--- + +### Finding NM-002: MEDIUM — v6 deployment scripts still use the v5 Sphinx project namespace, so periphery operations can resolve the wrong deployment set +**Severity:** MEDIUM +**Source:** Feynman-only +**Verification:** Code trace + +**Coupled Pair:** deployment namespace ↔ deployment-address resolution +**Invariant:** the deploy script, periphery script, and deployment loader must target the same v6 namespace. + +**Breaking Operation:** namespace selection at: +- [`script/Deploy.s.sol:46`](script/Deploy.s.sol#L46) +- [`script/DeployPeriphery.s.sol:50`](script/DeployPeriphery.s.sol#L50) +- [`script/helpers/CoreDeploymentLib.sol:43`](script/helpers/CoreDeploymentLib.sol#L43) + +**Trigger Sequence:** +1. Operator runs the v6 deployment repo. +2. Core and periphery scripts use `nana-core-v5` as the Sphinx project name / deployment path. +3. `DeployPeriphery.run()` loads addresses from the v5 namespace. +4. Privileged writes execute against whatever deployment files are found there. + +**Consequence:** +- If v5 deployment artifacts exist, the periphery script can mutate the wrong live contracts. +- If tooling uses the v6 namespace elsewhere, artifact lookup becomes inconsistent and brittle. + +**Verification Evidence:** +- Scripts hardcode `nana-core-v5`. +- Repo metadata and artifacts tooling identify the repo as `nana-core-v6`. + +**Fix:** +Rename the Sphinx project name and helper constant to `nana-core-v6` everywhere, and keep the package/tooling namespace aligned. + +--- + +### Finding NM-003: LOW — Base Sepolia USDC/USD periphery deployment uses a feed address the script already flags as likely incorrect +**Severity:** LOW +**Source:** Feynman-only +**Verification:** Code trace + +**Coupled Pair:** selected chain ID ↔ selected price feed +**Invariant:** the Base Sepolia USDC/USD branch must use the correct Chainlink feed for that pair. + +**Breaking Operation:** `_deployUSDCFeed()` at [`script/DeployPeriphery.s.sol:255`](script/DeployPeriphery.s.sol#L255) + +**Trigger Sequence:** +1. Run `DeployPeriphery` on Base Sepolia (`chainid == 84532`). +2. The script reaches the Base Sepolia branch. +3. It deploys `JBChainlinkV3PriceFeed` with `0xd30e2101...`, even though the inline comment says this is likely the wrong address. + +**Consequence:** +- Base Sepolia price-dependent flows can be mispriced or broken. +- This is testnet-only, so impact is limited to deployments on that network. + +**Verification Evidence:** +- The comment at [`script/DeployPeriphery.s.sol:257-259`](script/DeployPeriphery.s.sol#L257) explicitly states the address is likely incorrect. +- The very next lines still use that address. + +**Fix:** +Replace the placeholder with the correct Base Sepolia USDC/USD Chainlink feed before deployment. + +## Feedback Loop Discoveries +- No verified cross-feed findings emerged after the state pass. The round converged with one runtime permission flaw and two deployment-script flaws. + +## False Positives Eliminated +- Allowance persistence after terminal forwarding was traced and rejected as a true positive because the callee could only defer collection of value already allocated to it. +- Suspected ruleset-cycle desync in `JBRulesets` did not survive control-flow tracing. + +## Summary +- Total functions analyzed: 398 +- Coupled state pairs mapped: 10 +- Nemesis loop iterations: 1 +- Raw findings (pre-verification): 0 C | 0 H | 2 M | 1 L +- Feedback loop discoveries: 0 +- After verification: 3 TRUE POSITIVE | 0 FALSE POSITIVE | 0 DOWNGRADED +- Final: 0 CRITICAL | 0 HIGH | 2 MEDIUM | 1 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-core-v6/codex-state-inconsistency-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-core-v6/codex-state-inconsistency-verified.md new file mode 100644 index 0000000..b810834 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-core-v6/codex-state-inconsistency-verified.md @@ -0,0 +1,48 @@ +# State Inconsistency Audit — Verified Findings + +## Coupled State Dependency Map +- `JBTerminalStore.balanceOf` ↔ actual terminal token balances +- `usedPayoutLimitOf` ↔ `JBFundAccessLimits.payoutLimitOf` +- `usedSurplusAllowanceOf` ↔ `JBFundAccessLimits.surplusAllowanceOf` +- `JBTokens.totalCreditSupplyOf + token.totalSupply()` ↔ `JBTokens.totalSupplyOf` +- `JBController.pendingReservedTokenBalanceOf` ↔ `totalTokenSupplyWithReservedTokensOf` +- `JBDirectory._terminalsOf` ↔ `JBDirectory._primaryTerminalOf` +- `JBSplits._splitCountOf` ↔ packed split storage slots +- `JBRulesets.latestRulesetIdOf` ↔ packed intrinsic/user/metadata slots +- `JBMultiTerminal._heldFeesOf` ↔ `_nextHeldFeeIndexOf` +- `JBMultiTerminal._feeFreeSurplusOf` ↔ zero-tax cash-out fee accounting + +## Mutation Matrix +- Reviewed every mutator touching the pairs above in: + - `JBMultiTerminal` + - `JBTerminalStore` + - `JBController` + - `JBDirectory` + - `JBTokens` + - `JBSplits` + - `JBRulesets` + - `JBFundAccessLimits` + +## Parallel Path Comparison +- `sendPayoutsOf` vs `useAllowanceOf`: both reconcile terminal store balance before external transfers. +- `pay` vs `addToBalanceOf`: both update terminal-store balance through the same accounting context path. +- `mintTokensOf` vs `sendReservedTokensToSplitsOf`: reserved supply bookkeeping remained synchronized in traced paths. +- `setPrimaryTerminalOf` vs `setTerminalsOf`: primary-terminal fallback correctly checks membership before returning an explicit primary. + +## Verification Summary +| ID | Coupled Pair | Breaking Op | Original Severity | Verdict | Final Severity | +|----|-------------|-------------|-------------------|---------|----------------| + +No verified state-inconsistency findings in this round. + +## False Positives Eliminated +- `JBMultiTerminal._heldFeesOf` / `_nextHeldFeeIndexOf`: re-read and pre-increment in `processHeldFeesOf(...)` prevents the obvious double-processing reentrancy path. +- `JBSplits` packed storage / `_splitCountOf`: stale slots are cleaned when split counts shrink, so I did not confirm a stale-read desync there. +- `JBDirectory._primaryTerminalOf` / `_terminalsOf`: explicit primary entries are ignored if the terminal is no longer in `_terminalsOf`, preventing the obvious dangling-primary inconsistency. + +## Summary +- Coupled state pairs mapped: 10 +- Mutation paths analyzed: 60+ +- Raw findings (pre-verification): 0 +- After verification: 0 TRUE POSITIVE | 0 FALSE POSITIVE +- Final: 0 CRITICAL | 0 HIGH | 0 MEDIUM | 0 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-fee-project-deployer-v6/codex-feynman-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-fee-project-deployer-v6/codex-feynman-verified.md new file mode 100644 index 0000000..05ed4ab --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-fee-project-deployer-v6/codex-feynman-verified.md @@ -0,0 +1,75 @@ +# Feynman Audit — Verified Findings + +## Scope +- Language: Solidity 0.8.26 +- Modules analyzed: `script/Deploy.s.sol` +- Functions analyzed: 3 +- Lines interrogated: 144 + +## Verification Summary +| ID | Original Severity | Verdict | Final Severity | +|----|-------------------|---------|----------------| +| FF-001 | MEDIUM | FALSE POSITIVE | — | +| FF-002 | LOW | FALSE POSITIVE | — | + +## Function-State Matrix +| Function | Reads | Writes | Guards | Calls | +|----------|-------|--------|--------|-------| +| `configureSphinx()` | none | `sphinxConfig.projectName`, `sphinxConfig.mainnets`, `sphinxConfig.testnets` | none | none | +| `run()` | env vars, `block.chainid` via deployment libs | `core`, `suckers`, `revnet`, `routerTerminal`, `operator` | none | `CoreDeploymentLib.getDeployment`, `SuckerDeploymentLib.getDeployment`, `RevnetCoreDeploymentLib.getDeployment`, `RouterTerminalDeploymentLib.getDeployment`, `safeAddress()`, `deploy()` | +| `deploy()` | `core`, `suckers`, `revnet`, `routerTerminal`, `operator`, `block.chainid`, constants | none in script storage; builds local deployment config | `sphinx` | `core.projects.approve`, `revnet.basic_deployer.deployFor` | + +## Guard Consistency Analysis +- `deploy()` is the only stateful execution entry point and is protected by the Sphinx execution modifier. +- `run()` is intentionally unguarded because it prepares deployment context before entering the Sphinx-pranked `deploy()` path. +- No inconsistent authorization boundary was found inside this script. + +## Inverse Operation Parity +- Not applicable. The script has no inverse lifecycle functions and no mutable runtime state beyond one-time setup of cached deployment references. + +## Verified Findings (TRUE POSITIVES only) +- None. + +## False Positives Eliminated + +### FF-001: Historical `NANA_START_TIME` could silently launch with decayed issuance +**Original severity:** MEDIUM +**Verdict:** FALSE POSITIVE +**Verification:** Deep code trace + +The hypothesis was that deploying after `2025-02-20 22:30:44 UTC` would unintentionally skip the intended initial issuance window. + +Why it is not a reportable bug: +- The revnet boundary explicitly supports deploying an already-started revnet configuration onto a new chain. +- [`REVDeployer._setCashOutDelayIfNeeded`]( /Users/jango/Documents/jb/v6/evm/nana-fee-project-deployer-v6/node_modules/@rev-net/core-v6/src/REVDeployer.sol#L1285 ) detects a past first-stage start and adds a temporary cash-out delay to handle late-chain deployments safely. +- The repo’s own docs describe the fixed launch timestamp as intentional protocol-wide configuration, not a forgotten stale constant. + +Residual risk: +- Operators must still understand that this is a global-timeline deployment, not a “start on execution” deployment. + +### FF-002: Missing non-zero validation for loaded deployment addresses allows zero-address terminals/deployers +**Original severity:** LOW +**Verdict:** FALSE POSITIVE +**Verification:** Deep code trace + +The hypothesis was that corrupted deployment artifacts could make the script configure zero addresses without immediate failure. + +Why it is not a reportable bug: +- This repo’s [`RISKS.md`]( /Users/jango/Documents/jb/v6/evm/nana-fee-project-deployer-v6/RISKS.md ) explicitly treats external deployment artifact integrity as a trust assumption. +- The script is a thin deployer that intentionally consumes pre-existing deployment metadata from sibling packages. +- Failing here requires compromised or operator-supplied bad artifacts rather than an exploit path available to an attacker through the deployed fee project. + +Residual risk: +- Artifact validation would improve operator ergonomics, but this is an operational hardening gap, not a verified security finding in the in-scope script. + +## Downgraded Findings +- None. + +## LOW Findings (verified by inspection) +- None. + +## Summary +- Total functions analyzed: 3 +- Raw findings (pre-verification): 0 CRITICAL | 0 HIGH | 1 MEDIUM | 1 LOW +- After verification: 0 TRUE POSITIVE | 2 FALSE POSITIVE | 0 DOWNGRADED +- Final: 0 HIGH | 0 MEDIUM | 0 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-fee-project-deployer-v6/codex-nemesis-raw.md b/.audit-findings-codex-prev-20260323-211452/nana-fee-project-deployer-v6/codex-nemesis-raw.md new file mode 100644 index 0000000..de7b515 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-fee-project-deployer-v6/codex-nemesis-raw.md @@ -0,0 +1,121 @@ +# N E M E S I S — Raw Findings + +Generated: 2026-03-23 10:11:49 UTC + +## Scope +- In scope Solidity: + - `script/Deploy.s.sol` +- `src/` contains no Solidity files in this repo. +- Excluded by instruction: + - `.audit/findings/*` from parallel audit runs + +## Phase 0 — Nemesis Recon + +### Language +- Solidity 0.8.26 + +### Attack Goals +1. Misconfigure fee project `#1` so protocol fees are redirected, stranded, or rendered uncollectable. +2. Bind the fee project to the wrong terminals, deployers, or chain peers through bad dependency resolution. +3. Break cross-chain topology so sucker deployment or fee-token mobility fails on one or more target chains. + +### Novel Code +- `script/Deploy.s.sol` — single custom deployment script; all risk is concentrated in parameter selection and dependency wiring. + +### Value Stores + Initial Coupling Hypothesis +- `operator` + - Outflows: receives 100% reserved split distributions and all auto-issued NANA. + - Suspected coupled state: split beneficiary, auto-issuance beneficiary, `REVConfig.splitOperator`. +- Dependency deployment refs (`core`, `revnet`, `suckers`, `routerTerminal`) + - Outflows: define which external contracts get approved/called. + - Suspected coupled state: current `block.chainid`, deployment artifact path, downstream `deployFor` / `approve` calls. +- `feeProjectId` + - Outflows: project NFT approval and revnet deployment target. + - Suspected coupled state: must remain `1` everywhere. + +### Complex Paths +- `run()` -> deployment libs -> `deploy()` -> `core.projects.approve()` -> `REVDeployer.deployFor()` +- `deploy()` -> chain-specific sucker config -> `REVDeployer._deploySuckersFor()` -> `JBSuckerRegistry.deploySuckersFor()` + +### Priority Order +1. `deploy()` — concentrates all economic parameters and chain-conditional wiring. +2. `run()` — all downstream safety depends on correct dependency resolution. +3. `configureSphinx()` — deployment network selection and Safe derivation boundary. + +## Phase 1A — Function-State Matrix +| Function | Reads | Writes | Guards | Internal Calls | External Calls | +|----------|-------|--------|--------|----------------|----------------| +| `configureSphinx()` | none | `sphinxConfig.*` | none | none | none | +| `run()` | env vars, chain ID | `core`, `suckers`, `revnet`, `routerTerminal`, `operator` | none | `deploy()` | deployment libs, `safeAddress()` | +| `deploy()` | cached deployment refs, `operator`, constants, chain ID | local config only | `sphinx` | none | `approve`, `deployFor` | + +## Phase 1B — Coupled State Dependency Map +| Coupled Pair | Invariant | +|--------------|-----------| +| `operator` ↔ split beneficiary | reserved token flow must route to the same Safe | +| `operator` ↔ auto-issuance beneficiaries | all pre-mints must go to the same Safe | +| `operator` ↔ `splitOperator` | the account controlling revnet split operations must match the beneficiary Safe | +| `feeProjectId` ↔ `approve(tokenId)` ↔ `deployFor(revnetId)` | all must remain `1` | +| `block.chainid` ↔ sucker deployer array shape | L1/mainnet-like chains use 3 deployers; L2 chains use 1 | +| deployment refs ↔ downstream external calls | each loaded address must correspond to the current chain’s deployment artifacts | + +## Phase 1C — Cross-Reference +| Function | Writes A | Writes B | A↔B Pair | Sync Status | +|----------|----------|----------|----------|-------------| +| `run()` | `operator` | `core/revnet/suckers/routerTerminal` | setup refs ↔ `deploy()` dependencies | SYNCED | +| `deploy()` | split beneficiary | auto-issuance beneficiaries | operator fan-out | SYNCED | +| `deploy()` | `approve(tokenId=1)` | `deployFor(revnetId=1)` | fee project ID identity | SYNCED | +| `deploy()` | sucker deployer list | `block.chainid` branch | chain topology | SYNCED | + +## Pass 1 — Feynman Raw Suspects + +### RF-001 +- Category: Assumptions / Ordering +- Question: Why is `NANA_START_TIME` fixed to `1740089444` instead of being derived at execution time? +- Suspect scenario: deploying after the start date could activate a later issuance cycle immediately. +- Initial severity: MEDIUM + +### RF-002 +- Category: Assumptions +- Question: Why are there no explicit non-zero sanity checks after loading `core`, `revnet`, `suckers`, and `routerTerminal` from artifacts? +- Suspect scenario: a zero or wrong artifact address propagates into `approve()` / `deployFor()` / terminal configuration. +- Initial severity: LOW + +### RF-003 +- Category: Consistency +- Question: Why does `deploy()` rely on `run()`-initialized state instead of deriving `operator` and deployment refs locally? +- Suspect scenario: direct `deploy()` invocation sees zeroed state. +- Initial severity: LOW + +## Pass 2 — State Cross-Check Deltas +- No confirmed state gaps were found. +- All state mutations in the script occur in `run()` and are consumed coherently in `deploy()`. +- Mainnet/L2 branch behavior stays internally consistent with the loaded deployment-ref model. + +## Pass 3 — Targeted Feynman Re-Interrogation +- `RF-001` traced into `REVDeployer._setCashOutDelayIfNeeded()` and the revnet cross-chain timeline design. No exploit; intentional behavior. +- `RF-002` traced into explicit repo trust assumptions about deployment artifact integrity. Operational risk only. +- `RF-003` traced into Sphinx’s execution model. Operator misuse only; not reachable in deployed protocol. + +## Pass 4 — Targeted State Re-Analysis +- No new coupled pairs. +- No new mutation paths. +- No new masking code. + +## Convergence +- Loop converged after 4 passes. +- New findings in last pass: 0 + +## Verification Notes +- `forge test`: + - 67 unit tests passed + - 1 fork suite failed due unavailable RPC credential (`rpc.ankr.com` 401), not due code failure + +## Raw Finding Counts +- 0 Critical +- 0 High +- 1 Medium +- 2 Low + +## Raw Conclusion +- No raw candidate survived verification into a reportable security finding. diff --git a/.audit-findings-codex-prev-20260323-211452/nana-fee-project-deployer-v6/codex-nemesis-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-fee-project-deployer-v6/codex-nemesis-verified.md new file mode 100644 index 0000000..683d7a4 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-fee-project-deployer-v6/codex-nemesis-verified.md @@ -0,0 +1,68 @@ +# N E M E S I S — Verified Findings + +## Scope +- Language: Solidity 0.8.26 +- Modules analyzed: `script/Deploy.s.sol` +- Functions analyzed: 3 +- Coupled state pairs mapped: 4 +- Mutation paths traced: 5 +- Nemesis loop iterations: 2 + +## Nemesis Map (Phase 1 Cross-Reference) +| Function | Writes A | Writes B | A↔B Pair | Sync Status | +|----------|----------|----------|----------|-------------| +| `run()` | deployment refs | `operator` | setup state consumed by `deploy()` | SYNCED | +| `deploy()` | split beneficiary | auto-issuance beneficiaries | operator fan-out | SYNCED | +| `deploy()` | `approve(tokenId=1)` | `deployFor(revnetId=1)` | fee project identity | SYNCED | +| `deploy()` | sucker deployer branch | `block.chainid` | chain topology | SYNCED | + +## Verification Summary +| ID | Source | Coupled Pair | Breaking Op | Severity | Verdict | +|----|--------|-------------|-------------|----------|---------| +| NM-001 | Feynman-only | `NANA_START_TIME` ↔ stage lifecycle | `deploy()` | MEDIUM | FALSE POSITIVE | +| NM-002 | Feynman-only | deployment refs ↔ external calls | `run()` / `deploy()` | LOW | FALSE POSITIVE | +| NM-003 | State-only | `run()` state ↔ `deploy()` consumption | `deploy()` | LOW | FALSE POSITIVE | + +## Verified Findings (TRUE POSITIVES only) +- None. + +## Feedback Loop Discoveries +- None. The Feynman and state passes converged without exposing a cross-feed bug that survived verification. + +## False Positives Eliminated + +### Finding NM-001: Historical start time looked like an unintended retroactive launch +**Source:** Feynman-only +**Verification:** Code trace + +The script fixes `startsAtOrAfter` to `1740089444` in [script/Deploy.s.sol](/Users/jango/Documents/jb/v6/evm/nana-fee-project-deployer-v6/script/Deploy.s.sol#L132). That initially looked like a stale-timestamp configuration error. + +Verification traced the downstream behavior into [`REVDeployer._setCashOutDelayIfNeeded`]( /Users/jango/Documents/jb/v6/evm/nana-fee-project-deployer-v6/node_modules/@rev-net/core-v6/src/REVDeployer.sol#L1285 ), which explicitly handles revnets whose first stage is already in progress when deployed on another chain. This repo’s documentation also frames the fee project as a global-timeline deployment. No exploitable invariant break was confirmed. + +### Finding NM-002: Loaded deployment addresses are not explicitly sanity-checked in the script +**Source:** Feynman-only +**Verification:** Code trace + +The script trusts the deployment helpers to return correct addresses before using them in [script/Deploy.s.sol](/Users/jango/Documents/jb/v6/evm/nana-fee-project-deployer-v6/script/Deploy.s.sol#L70) and [script/Deploy.s.sol](/Users/jango/Documents/jb/v6/evm/nana-fee-project-deployer-v6/script/Deploy.s.sol#L193). + +Verification showed this is an acknowledged trust assumption documented in [RISKS.md](/Users/jango/Documents/jb/v6/evm/nana-fee-project-deployer-v6/RISKS.md). Exploitation requires compromised operator inputs or corrupted dependency artifacts, not a user-triggerable protocol flaw. + +### Finding NM-003: `deploy()` depends on `run()`-initialized cached state +**Source:** State-only +**Verification:** Code trace + +`deploy()` consumes `core`, `suckers`, `revnet`, `routerTerminal`, and `operator` that are assigned in `run()`. That looked like a possible state-desync if `deploy()` were called directly. + +Verification showed the intended execution boundary is the Sphinx deployment flow, with [`Sphinx.sphinx`]( /Users/jango/Documents/jb/v6/evm/nana-fee-project-deployer-v6/node_modules/@sphinx-labs/contracts/contracts/foundry/Sphinx.sol#L276 ) governing the deployment entry point. This is an operator-invocation concern, not a reachable security issue in the deployed fee project. + +## Downgraded Findings +- None. + +## Summary +- Total functions analyzed: 3 +- Coupled state pairs mapped: 4 +- Nemesis loop iterations: 2 +- Raw findings (pre-verification): 0 C | 0 H | 1 M | 2 L +- Feedback loop discoveries: 0 +- After verification: 0 TRUE POSITIVE | 3 FALSE POSITIVE | 0 DOWNGRADED +- Final: 0 CRITICAL | 0 HIGH | 0 MEDIUM | 0 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-fee-project-deployer-v6/codex-state-inconsistency-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-fee-project-deployer-v6/codex-state-inconsistency-verified.md new file mode 100644 index 0000000..bdc9c2b --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-fee-project-deployer-v6/codex-state-inconsistency-verified.md @@ -0,0 +1,55 @@ +# State Inconsistency Audit — Verified Findings + +## Coupled State Dependency Map +| Pair | Invariant | Mutation points | +|------|-----------|-----------------| +| `core` / `revnet` / `suckers` / `routerTerminal` deployment refs ↔ `deploy()` external calls | `deploy()` must only use addresses loaded for the current chain in `run()` | `run()` | +| `operator` ↔ split beneficiary / auto-issuance beneficiary / `REVConfig.splitOperator` | All operator-facing configuration must resolve to the same Safe address | `run()`, `deploy()` | +| `feeProjectId` approval target ↔ `deployFor(revnetId)` target | The approved NFT and deployed revnet ID must remain identical (`1`) | `deploy()` | +| `block.chainid` ↔ sucker deployer set | Mainnet-style chains need three deployers; L2-style chains need exactly one L2->L1 deployer | `deploy()` | + +## Mutation Matrix +| State Variable | Mutating Function | Type of Mutation | Updates Coupled State? | +|----------------|-------------------|------------------|------------------------| +| `core` | `run()` | assignment | Yes | +| `suckers` | `run()` | assignment | Yes | +| `revnet` | `run()` | assignment | Yes | +| `routerTerminal` | `run()` | assignment | Yes | +| `operator` | `run()` | assignment | Yes | + +## Parallel Path Comparison +| Coupled State | Mainnet/`sepolia` path | L2 path | Result | +|---------------|------------------------|---------|--------| +| Sucker deployer selection | 3 explicit deployers | 1 fallback-selected deployer | Consistent with intended topology | +| Accepted terminals | `JBMultiTerminal` + router registry | `JBMultiTerminal` + router registry | Consistent | +| Operator propagation | splits + auto-issuance + split operator | splits + auto-issuance + split operator | Consistent | + +## Verification Summary +| ID | Coupled Pair | Breaking Op | Original Severity | Verdict | Final Severity | +|----|-------------|-------------|-------------------|---------|----------------| +| SI-001 | `operator` ↔ split/issuance config | `deploy()` | LOW | FALSE POSITIVE | — | + +## Verified Findings +- None. + +## False Positives Eliminated + +### SI-001: `deploy()` can observe stale zero-valued setup state if called directly +**Original severity:** LOW +**Verification:** Code trace + +Hypothesis: +- `deploy()` depends on `core`, `suckers`, `revnet`, `routerTerminal`, and `operator`, all populated in `run()`. +- A direct call into `deploy()` could therefore build invalid configuration from zero values. + +Why it is not a reportable bug: +- The intended execution model is `run() -> deploy()` under Sphinx. +- [`Sphinx.sphinx`]( /Users/jango/Documents/jb/v6/evm/nana-fee-project-deployer-v6/node_modules/@sphinx-labs/contracts/contracts/foundry/Sphinx.sol#L276 ) is the deployment boundary the script is written against. +- No attacker can reach this path in the deployed protocol; it is only an operator script invocation concern. + +## Summary +- Coupled state pairs mapped: 4 +- Mutation paths analyzed: 5 +- Raw findings (pre-verification): 1 +- After verification: 0 TRUE POSITIVE | 1 FALSE POSITIVE +- Final: 0 CRITICAL | 0 HIGH | 0 MEDIUM | 0 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-omnichain-deployers-v6/codex-feynman-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-omnichain-deployers-v6/codex-feynman-verified.md new file mode 100644 index 0000000..dcd8228 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-omnichain-deployers-v6/codex-feynman-verified.md @@ -0,0 +1,90 @@ +# Feynman Audit — Verified Findings + +## Scope +- Language: Solidity +- Modules analyzed: + - `src/JBOmnichainDeployer.sol` + - `src/interfaces/IJBOmnichainDeployer.sol` + - `src/structs/*.sol` + - `script/Deploy.s.sol` + - `script/helpers/DeployersDeploymentLib.sol` +- Functions analyzed: 31 executable functions +- Lines interrogated: 1,266 + +## Verification Summary +| ID | Original Severity | Verdict | Final Severity | +|----|-------------------|---------|----------------| +| FF-001 | HIGH | FALSE POSITIVE | — | +| FF-002 | HIGH | FALSE POSITIVE | — | +| FF-003 | MEDIUM | FALSE POSITIVE | — | +| FF-004 | MEDIUM | FALSE POSITIVE | — | + +## Function-State Matrix +| Function | Reads | Writes | Guards | Calls | +|---|---|---|---|---| +| `beforeCashOutRecordedWith` | ruleset hook configs, sucker registry | none | none | registry, 721 hook, extra hook | +| `beforePayRecordedWith` | ruleset hook configs | none | none | 721 hook, extra hook | +| `hasMintPermissionFor` | ruleset hook config, sucker registry | none | none | registry, extra hook | +| `deploySuckersFor` | project owner | none | `DEPLOY_SUCKERS` | registry | +| `launchProjectFor` / `_launchProjectFor` | `PROJECTS.count()` | hook mappings | none | hook deployer, controller, registry, projects | +| `launchRulesetsFor` / `_launchRulesetsFor` | project owner, controller directory | hook mappings | `LAUNCH_RULESETS`, `SET_TERMINALS` | controller, hook deployer, ownable | +| `queueRulesetsOf` / `_queueRulesetsOf` | project owner, latest ruleset ID | hook mappings | `QUEUE_RULESETS` | controller, hook deployer, ownable | +| `_setup721` | incoming ruleset metadata | both hook mappings, outgoing metadata | none | none | +| `script/Deploy.deploy` | deployment registries | none | Sphinx-controlled | constructor | +| `DeployersDeploymentLib.getDeployment*` | chain ID, network info, deployment JSON | none | none | local helper only | + +## Guard Consistency Analysis +- `launchRulesetsFor` correctly requires both `LAUNCH_RULESETS` and `SET_TERMINALS`, matching `core-v6` controller requirements. +- `queueRulesetsOf` correctly requires `QUEUE_RULESETS` only. +- `launchProjectFor` is intentionally permissionless because the project does not yet exist; no inconsistent access-control gap found. + +## Inverse Operation Parity +- `launchRulesetsFor` and `queueRulesetsOf` both validate the supplied controller against the directory before mutating hook mappings. +- `queueRulesetsOf` has an additional same-block predictability guard because it may follow an existing ruleset chain; `launchRulesetsFor` does not need it because `core-v6` only permits it when no prior rulesets exist. + +## Verified Findings (TRUE POSITIVES only) + +No verified true positives. + +## False Positives Eliminated + +### FF-001: Ruleset ID prediction can desync hook storage +- Verification: deep code trace +- Result: false positive +- Why eliminated: + - `core-v6` allocates ruleset IDs as `latestId >= block.timestamp ? latestId + 1 : block.timestamp`. + - `queueRulesetsOf` guards `latestRulesetIdOf(projectId) < block.timestamp` before `_setup721`, so the first predicted ID equals `block.timestamp` and subsequent IDs increment by one. + - `launchRulesetsFor` can only succeed when `latestRulesetIdOf(projectId) == 0`, which preserves the same alignment. + +### FF-002: Malicious controller can bind hooks to the wrong project in `launchProjectFor` +- Verification: deep code trace +- Result: false positive +- Why eliminated: + - `_launchProjectFor` predicts `PROJECTS.count() + 1` and reverts with `ProjectIdMismatch` unless the controller returns exactly that ID. + - Any mismatch reverts the whole transaction, rolling back hook deployment, mapping writes, and NFT minting. + +### FF-003: Wildcard `MAP_SUCKER_TOKEN` permission grants broad registry escalation +- Verification: deep code trace +- Result: false positive +- Why eliminated: + - The permission is granted for `account = address(this)` and `projectId = 0`, which lets the registry act on behalf of the deployer account only. + - It does not grant the registry blanket project-owner rights. + +### FF-004: Carry-forward path silently disables 721 handling when no prior hook exists +- Verification: deep code trace + test validation +- Result: false positive +- Why eliminated: + - `_queueRulesetsOf` explicitly reverts with `JBOmnichainDeployer_InvalidHook()` if the carried-forward hook slot is zero. + - The failure is loud and atomic rather than a silent misconfiguration. + +## Downgraded Findings +- None. + +## LOW Findings (verified by inspection) +- None reported. + +## Summary +- Total functions analyzed: 31 +- Raw findings (pre-verification): 0 CRITICAL | 2 HIGH | 2 MEDIUM | 0 LOW +- After verification: 0 TRUE POSITIVE | 4 FALSE POSITIVE | 0 DOWNGRADED +- Final: 0 HIGH | 0 MEDIUM | 0 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-omnichain-deployers-v6/codex-nemesis-raw.md b/.audit-findings-codex-prev-20260323-211452/nana-omnichain-deployers-v6/codex-nemesis-raw.md new file mode 100644 index 0000000..58d76dd --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-omnichain-deployers-v6/codex-nemesis-raw.md @@ -0,0 +1,120 @@ +# N E M E S I S — Raw Working Notes + +## Scope +- Language: Solidity 0.8.26 +- Files in scope: + - `src/JBOmnichainDeployer.sol` + - `src/interfaces/IJBOmnichainDeployer.sol` + - `src/structs/JBDeployerHookConfig.sol` + - `src/structs/JBOmnichain721Config.sol` + - `src/structs/JBSuckerDeploymentConfig.sol` + - `src/structs/JBTiered721HookConfig.sol` + - `script/Deploy.s.sol` + - `script/helpers/DeployersDeploymentLib.sol` + +## Phase 0 — Recon + +### Attack Goals +1. Break hook routing so 721/sucker behavior silently disables and users lose mint/cash-out invariants. +2. Escalate sucker privileges into unauthorized 0% cash-out tax or mint rights. +3. Misconfigure deployment so a canonical omnichain deployer is deployed with wrong dependency addresses or wrong deterministic salt. +4. Desync predicted ruleset IDs from stored hook mappings so pay/cash-out routing hits stale or zero config. + +### Novel Code +- `src/JBOmnichainDeployer.sol` + - Custom wrapper over core ruleset data hooks, 721 hook composition, sucker mint/tax bypass, and ruleset ID prediction. +- `script/Deploy.s.sol` + - Deterministic CREATE2 deployment path that wires three external deployment registries together. + +### Value Stores / Couplings +- `_tiered721HookOf[projectId][rulesetId]` ↔ actual ruleset IDs allocated by `core-v6`. +- `_extraDataHookOf[projectId][rulesetId]` ↔ the original per-ruleset custom hook metadata. +- `context.amount.value` ↔ 721 split amount ↔ forwarded `projectAmount` ↔ scaled mint `weight`. +- `cashOutTaxRate / cashOutCount / totalSupply` ↔ sequential 721-hook then extra-hook cash-out composition. +- project ownership NFT temporarily held by deployer ↔ later `transferOwnershipToProject` / `PROJECTS.transferFrom`. +- deterministic salts ↔ `_msgSender()` under ERC-2771 forwarding. + +### Priority Targets +1. `_setup721`, `_queueRulesetsOf`, `_launchRulesetsFor` +2. `beforePayRecordedWith`, `beforeCashOutRecordedWith`, `hasMintPermissionFor` +3. `launchProjectFor` controller trust and ownership-transfer ordering +4. `script/Deploy.s.sol` dependency wiring and CREATE2 deploy guard + +## Pass 1 — Feynman Suspects + +### Function-State Matrix (condensed) +| Function | Reads | Writes | External Calls | Notes | +|---|---|---|---|---| +| `beforeCashOutRecordedWith` | hook mappings, sucker registry | none | registry + 721 hook + custom hook | Sequential hook composition | +| `beforePayRecordedWith` | hook mappings | none | 721 hook + custom hook | Weight scaling after tier splits | +| `hasMintPermissionFor` | hook mappings, sucker registry | none | registry + custom hook | Sucker override first | +| `deploySuckersFor` | project owner | none | projects + sucker registry | Permissioned | +| `_launchProjectFor` | project count | hook mappings | hook deployer + controller + ownable + registry + projects | Most complex path | +| `_launchRulesetsFor` | project owner, directory | hook mappings | projects + controller + hook deployer + ownable | Launch path for existing project | +| `_queueRulesetsOf` | latest ruleset ID, prior hook | hook mappings | projects + controller + hook deployer + ownable | Same-block prediction guard | +| `_setup721` | incoming metadata | `_tiered721HookOf`, `_extraDataHookOf` | none | Prediction keying at `block.timestamp + i` | +| `script/Deploy.deploy` | dependency deployment structs | none | constructor only | CREATE2 guard via `_isDeployed` | +| `DeployersDeploymentLib.getDeployment` | Sphinx network info, JSON files | none | local file reads | Off-chain helper only | + +### Raw Hypotheses +1. `src/JBOmnichainDeployer.sol` + - Hypothesis: `block.timestamp + i` ruleset-key prediction can drift from `core-v6` ruleset IDs and orphan hook mappings. + - Status after trace: narrowed to a design assumption; no reachable mismatch in this repo's allowed call paths. +2. `src/JBOmnichainDeployer.sol` + - Hypothesis: `launchProjectFor` trusts an arbitrary controller and could wire hooks onto the wrong project. + - Status after trace: false positive for in-scope threat model; post-call `ProjectIdMismatch` check and atomic revert block silent misbinding. +3. `src/JBOmnichainDeployer.sol` + - Hypothesis: constructor wildcard `MAP_SUCKER_TOKEN` grant lets the registry escalate privileges outside deployer-managed projects. + - Status after trace: false positive; permission is scoped to `account = address(this)`, not arbitrary project owners. +4. `src/JBOmnichainDeployer.sol` + - Hypothesis: carrying forward the prior 721 hook in `_queueRulesetsOf` can resolve to zero and silently disable 721 behavior. + - Status after trace: false positive; explicit `InvalidHook` revert prevents silent carry-forward of zero. +5. `src/JBOmnichainDeployer.sol` + - Hypothesis: pay/cash-out hook composition can double-count split amounts or mis-scale custom hook weights. + - Status after trace + tests: false positive; 721 split amount is excluded from `projectAmount` before custom hook routing and weight rescaling preserves `weight=0`. +6. `src/JBOmnichainDeployer.sol` + - Hypothesis: sucker bypass could suppress reverting hooks for arbitrary callers. + - Status after trace + tests: false positive; bypass is gated strictly by `SUCKER_REGISTRY.isSuckerOf(projectId, addr)`. +7. `script/Deploy.s.sol` + - Hypothesis: deterministic deploy guard could mis-detect deployment due to mismatched constructor args or deployer address. + - Status after trace: false positive; `_isDeployed` hashes the exact creation code + constructor args + Sphinx safe address. + +## Pass 2 — State Inconsistency Map + +### Coupled State Dependency Map +| Pair | Invariant | Mutation Paths | +|---|---|---| +| predicted ruleset ID ↔ `_tiered721HookOf` entry | every queued/launched ruleset must resolve to the hook stored under its actual ID | `_setup721`, `_queueRulesetsOf`, `_launchRulesetsFor`, `_launchProjectFor` | +| predicted ruleset ID ↔ `_extraDataHookOf` entry | custom hook config must be retrievable under the same ruleset ID core uses | `_setup721` | +| total payment ↔ 721 split amount ↔ custom hook amount ↔ final weight | tokens minted must correspond only to the project-retained value | `beforePayRecordedWith` | +| 721 cash-out override ↔ extra hook override | later hook must receive already-updated values, not stale originals | `beforeCashOutRecordedWith` | +| project NFT held by deployer ↔ hook ownership transfer ↔ final owner transfer | deployer must not strand project or hook ownership on success | `_launchProjectFor` | +| queue carry-forward hook ↔ latest ruleset ID | new rulesets without new tiers must inherit the previous hook, not a stale/zero slot | `_queueRulesetsOf` | + +### Mutation Matrix / Gaps Checked +- `_setup721` writes both `_tiered721HookOf` and `_extraDataHookOf` consistently for the same predicted key. +- `_queueRulesetsOf` either writes a fresh hook mapping or reuses a validated non-zero prior mapping. +- `_launchProjectFor` predicts `projectId`, writes hook mappings, then verifies the controller returned the same `projectId` before any success path completes. +- `beforeCashOutRecordedWith` feeds the 721-adjusted values into the extra hook via a mutable memory context, preventing stale-state composition. +- `beforePayRecordedWith` computes `projectAmount = total - tierSplits`, forwards only `projectAmount` to the custom hook, and rescales the hook-returned weight accordingly. + +No state-gap candidate survived into a verified exploit. + +## Targeted Loop Deltas + +### Pass 3 — Feynman Re-Interrogation +- Re-checked why `_launchRulesetsFor` lacks the same-block `latestRulesetId` guard. +- Verified against `core-v6` that `launchRulesetsFor` is only callable when `latestRulesetIdOf(projectId) == 0`, so `block.timestamp + i` remains aligned with `JBRulesets.queueFor`. +- Re-checked whether `hasMintPermissionFor` should honor the extra hook only when pay/cash-out flags are enabled. +- No exploit path found: hook-side mint permission is intentionally independent of pay/cash-out flags. + +### Pass 4 — State Re-Analysis +- Re-traced all paths touching the predicted ruleset ID coupling after the Pass 3 controller/ruleset review. +- No new coupled pairs or missing update paths surfaced. + +## Convergence +- New findings after Pass 4: 0 +- New coupled pairs after Pass 4: 0 +- New suspects after Pass 4: 0 + +Audit converged with no verified true positives. diff --git a/.audit-findings-codex-prev-20260323-211452/nana-omnichain-deployers-v6/codex-nemesis-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-omnichain-deployers-v6/codex-nemesis-verified.md new file mode 100644 index 0000000..2b5b26f --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-omnichain-deployers-v6/codex-nemesis-verified.md @@ -0,0 +1,57 @@ +# N E M E S I S — Verified Findings + +## Scope +- Language: Solidity +- Modules analyzed: + - `src/JBOmnichainDeployer.sol` + - `src/interfaces/IJBOmnichainDeployer.sol` + - `src/structs/*.sol` + - `script/Deploy.s.sol` + - `script/helpers/DeployersDeploymentLib.sol` +- Functions analyzed: 31 executable functions +- Coupled state pairs mapped: 5 +- Mutation paths traced: 10 +- Nemesis loop iterations: 4 passes total (Feynman full, State full, Feynman targeted, State targeted) + +## Nemesis Map (Phase 1 Cross-Reference) +| Function | Writes / Controls A | Writes / Controls B | A↔B Pair | Sync Status | +|---|---|---|---|---| +| `_setup721` | `_tiered721HookOf[projectId][predictedId]` | `_extraDataHookOf[projectId][predictedId]` | predicted ruleset ID ↔ hook configs | SYNCED | +| `_queueRulesetsOf` | carried-forward `hook` selection | predicted ruleset IDs in `_setup721` | latest ruleset ID ↔ current hook | SYNCED | +| `beforePayRecordedWith` | `projectAmount` | scaled `weight` + merged hook specs | payment amount ↔ split amount ↔ mint weight | SYNCED | +| `beforeCashOutRecordedWith` | 721-adjusted tuple | extra-hook-adjusted tuple | cash-out values ↔ sequential hook composition | SYNCED | +| `_launchProjectFor` | predicted `projectId` | post-launch hook ownership / NFT transfer | project creation ↔ ownership finalization | SYNCED | + +## Verification Summary +| ID | Source | Coupled Pair | Breaking Op | Severity | Verdict | +|----|--------|-------------|-------------|----------|---------| +| NM-001 | Feynman→State | predicted ruleset ID ↔ hook mappings | `_setup721` | HIGH | FALSE POSITIVE | +| NM-002 | Feynman-only | projectId prediction ↔ controller return | `_launchProjectFor` | HIGH | FALSE POSITIVE | +| NM-003 | State-only | latest ruleset ID ↔ carried hook | `_queueRulesetsOf` | MEDIUM | FALSE POSITIVE | +| NM-004 | Cross-feed P1→P2 | split amount ↔ custom-hook amount ↔ weight | `beforePayRecordedWith` | MEDIUM | FALSE POSITIVE | + +## Verified Findings (TRUE POSITIVES only) + +No verified true positives. + +## Feedback Loop Discoveries +- The highest-value cross-feed candidate was the interaction between 721 split routing and custom-hook mint weight in `beforePayRecordedWith`. +- Targeted re-interrogation showed the wrapper’s `projectAmount` reduction and post-hook rescaling preserve the intended invariant, so the candidate was eliminated. + +## False Positives Eliminated +- `NM-001`: ruleset ID prediction desync rejected by reachable-state analysis against `core-v6`. +- `NM-002`: malicious controller misbinding rejected by `ProjectIdMismatch` and atomic revert. +- `NM-003`: carry-forward zero hook rejected by explicit revert. +- `NM-004`: split-induced overmint rejected by code trace and test verification. + +## Downgraded Findings +- None. + +## Summary +- Total functions analyzed: 31 +- Coupled state pairs mapped: 5 +- Nemesis loop iterations: 4 passes +- Raw findings (pre-verification): 0 C | 2 H | 2 M | 0 L +- Feedback loop discoveries: 1 candidate, 0 surviving +- After verification: 0 TRUE POSITIVE | 4 FALSE POSITIVE | 0 DOWNGRADED +- Final: 0 CRITICAL | 0 HIGH | 0 MEDIUM | 0 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-omnichain-deployers-v6/codex-state-inconsistency-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-omnichain-deployers-v6/codex-state-inconsistency-verified.md new file mode 100644 index 0000000..3572966 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-omnichain-deployers-v6/codex-state-inconsistency-verified.md @@ -0,0 +1,64 @@ +# State Inconsistency Audit — Verified Findings + +## Coupled State Dependency Map +| Coupled Pair | Invariant | +|---|---| +| predicted ruleset ID ↔ `_tiered721HookOf[projectId][rulesetId]` | each ruleset must resolve to the correct 721 hook config | +| predicted ruleset ID ↔ `_extraDataHookOf[projectId][rulesetId]` | each ruleset must resolve to the correct extra hook config | +| payment amount ↔ total tier split amount ↔ custom-hook amount ↔ final weight | only project-retained funds may influence mint weight | +| 721 cash-out outputs ↔ extra-hook cash-out inputs | later hook must receive already-updated tax/count/supply values | +| latest ruleset ID ↔ carried-forward 721 hook | queueing without new tiers must inherit the actual latest hook or revert | + +## Mutation Matrix +| State Variable / Derived State | Mutating Function | Updates Coupled State? | +|---|---|---| +| `_tiered721HookOf[projectId][predictedId]` | `_setup721` | Yes | +| `_extraDataHookOf[projectId][predictedId]` | `_setup721` | Yes | +| carried-forward hook selection | `_queueRulesetsOf` | Yes, or loud revert | +| `projectAmount` / scaled weight | `beforePayRecordedWith` | Yes | +| composed cash-out tuple | `beforeCashOutRecordedWith` | Yes | + +## Parallel Path Comparison +| Coupled State | `launchProjectFor` | `launchRulesetsFor` | `queueRulesetsOf` | +|---|---|---|---| +| ruleset ID prediction ↔ hook mapping | synced | synced | synced, guarded by same-block check | +| 721 hook ownership transfer | synced | synced | synced when new hook deployed | +| carry-forward hook validity | n/a | n/a | synced, zero-address blocked | + +## Verification Summary +| ID | Coupled Pair | Breaking Op | Original Severity | Verdict | Final Severity | +|----|-------------|-------------|-------------------|---------|----------------| +| SI-001 | ruleset ID ↔ hook mappings | `_setup721` | HIGH | FALSE POSITIVE | — | +| SI-002 | latest ruleset ID ↔ carried hook | `_queueRulesetsOf` | MEDIUM | FALSE POSITIVE | — | +| SI-003 | split amount ↔ custom-hook amount ↔ weight | `beforePayRecordedWith` | MEDIUM | FALSE POSITIVE | — | + +## Verified Findings + +No verified true positives. + +## False Positives Eliminated + +### SI-001: `_setup721` can store hook configs under the wrong ruleset ID +- Verification: code trace +- Why eliminated: + - `core-v6` ruleset allocation matches `block.timestamp + i` exactly in the only reachable launch/queue states this contract permits. + - `queueRulesetsOf` rejects the conflicting same-block state that would otherwise desync the first predicted ID. + +### SI-002: `queueRulesetsOf` can carry forward a stale zero-address hook +- Verification: code trace +- Why eliminated: + - The function reverts when the carried-forward hook is zero instead of storing broken state. + +### SI-003: `beforePayRecordedWith` can mint on the split-routed portion of a payment +- Verification: code trace + existing test coverage +- Why eliminated: + - The custom hook receives `projectAmount` only. + - Returned weight is rescaled by `projectAmount / totalAmount`. + - When the split consumes the whole payment, the wrapper forces `weight = 0`. + +## Summary +- Coupled state pairs mapped: 5 +- Mutation paths analyzed: 10 +- Raw findings (pre-verification): 3 +- After verification: 0 TRUE POSITIVE | 3 FALSE POSITIVE +- Final: 0 CRITICAL | 0 HIGH | 0 MEDIUM | 0 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-ownable-v6/codex-feynman-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-ownable-v6/codex-feynman-verified.md new file mode 100644 index 0000000..c2492d5 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-ownable-v6/codex-feynman-verified.md @@ -0,0 +1,109 @@ +# Feynman Audit — Verified Findings + +## Scope +- Language: Solidity 0.8.26 +- Modules analyzed: `src/JBOwnable.sol`, `src/JBOwnableOverrides.sol`, `src/interfaces/IJBOwnable.sol`, `src/structs/JBOwner.sol` +- Functions analyzed: 11 concrete functions/modifiers +- Lines interrogated: all in-scope Solidity under `src/` +- `script/`: no Solidity files present in this repo + +## Verification Summary +| ID | Original Severity | Verdict | Final Severity | +|----|-------------------|---------|----------------| +| FF-001 | LOW | TRUE POSITIVE | LOW | + +## Function-State Matrix +| Function | Reads | Writes | Guards | Calls | +|----------|-------|--------|--------|-------| +| `JBOwnable.onlyOwner` | `jbOwner`, `PROJECTS.ownerOf`, `PERMISSIONS` | — | — | `_checkOwner` | +| `JBOwnable._emitTransferEvent` | `PROJECTS.ownerOf`, `msg.sender` | — | — | `PROJECTS.ownerOf` | +| `JBOwnableOverrides.owner` | `jbOwner`, `PROJECTS.ownerOf` | — | — | `PROJECTS.ownerOf` | +| `JBOwnableOverrides._checkOwner` | `jbOwner`, `PROJECTS.ownerOf`, `PERMISSIONS` | — | — | `PROJECTS.ownerOf`, `_requirePermissionFrom` | +| `JBOwnableOverrides.renounceOwnership` | `jbOwner`, `PROJECTS.ownerOf`, `PERMISSIONS` | `jbOwner` | owner-only via `_checkOwner` | `_checkOwner`, `_transferOwnership` | +| `JBOwnableOverrides.setPermissionId` | `jbOwner`, `PROJECTS.ownerOf`, `PERMISSIONS` | `jbOwner.permissionId` | owner-only via `_checkOwner` | `_checkOwner`, `_setPermissionId` | +| `JBOwnableOverrides.transferOwnership` | `jbOwner`, `PROJECTS.ownerOf`, `PERMISSIONS` | `jbOwner` | owner-only via `_checkOwner` | `_checkOwner`, `_transferOwnership` | +| `JBOwnableOverrides.transferOwnershipToProject` | `jbOwner`, `PROJECTS.ownerOf`, `PROJECTS.count`, `PERMISSIONS` | `jbOwner` | owner-only via `_checkOwner` | `_checkOwner`, `PROJECTS.count`, `_transferOwnership` | +| `JBOwnableOverrides._setPermissionId` | — | `jbOwner.permissionId` | — | emits `PermissionIdChanged` | +| `JBOwnableOverrides._transferOwnership(address)` | — | — | — | `_transferOwnership(address,uint88)` | +| `JBOwnableOverrides._transferOwnership(address,uint88)` | `jbOwner`, `PROJECTS.ownerOf` | `jbOwner` | — | `PROJECTS.ownerOf`, `_emitTransferEvent` | + +## Guard Consistency Analysis +- All externally reachable mutators (`renounceOwnership`, `setPermissionId`, `transferOwnership`, `transferOwnershipToProject`) gate access through `_checkOwner()`. +- `_checkOwner()` and `owner()` use the same resolution branches for direct ownership and project ownership. +- No path mutates ownership state without going through `_transferOwnership(...)`. + +## Inverse Operation Parity +- `transferOwnership(address)` and `transferOwnershipToProject(uint256)` both funnel into `_transferOwnership(...)`, preserving mutual exclusivity and resetting `permissionId` to `0`. +- `renounceOwnership()` is the only terminal path and also uses `_transferOwnership(...)`, so the same reset semantics apply. + +## Verified Findings (TRUE POSITIVES only) + +### Finding FF-001: LOW — Event `caller` fields bypass `_msgSender()` and misattribute meta-transactions +**Severity:** LOW +**Module:** `JBOwnable`, `JBOwnableOverrides` +**Function:** `_emitTransferEvent`, `_setPermissionId` +**Lines:** `src/JBOwnable.sol:L73-L76`, `src/JBOwnableOverrides.sol:L207-L209` +**Verification:** Code trace + +**Feynman Question that exposed this:** +> What does this line assume about the caller, and is that assumption consistent with the access-control path? + +**The code:** +```solidity +emit OwnershipTransferred({ + previousOwner: previousOwner, + newOwner: newProjectId == 0 ? newOwner : PROJECTS.ownerOf(newProjectId), + caller: msg.sender +}); + +emit PermissionIdChanged({newId: permissionId, caller: msg.sender}); +``` + +**Why this is wrong:** +Access control is resolved through `_checkOwner()`, which delegates to `JBPermissioned._requirePermissionFrom(...)`, and that path uses `_msgSender()`. Inheritors can override `_msgSender()` for ERC-2771 meta-transactions. A real downstream inheritor already does this: `JB721TiersHook` inherits `JBOwnable` and overrides `_msgSender()` with `ERC2771Context._msgSender()`. + +That means a forwarded admin call can be authorized using the original signer, while the emitted `caller` field records the trusted forwarder instead. The state transition is correct, but the audit trail is not. + +**Verification evidence:** +- `JBOwnableOverrides._checkOwner()` resolves authorization via `_requirePermissionFrom(...)`, which reads `_msgSender()` in `JBPermissioned`: `node_modules/@bananapus/core-v6/src/abstract/JBPermissioned.sol:L42-L54`. +- `JB721TiersHook` is a concrete inheritor that overrides `_msgSender()` to `ERC2771Context._msgSender()`: `../nana-721-hook-v6/src/JB721TiersHook.sol:L39`, `../nana-721-hook-v6/src/JB721TiersHook.sol:L565-L566`. +- The event emitters in this repo use raw `msg.sender`, not `_msgSender()`: `src/JBOwnable.sol:L73-L76`, `src/JBOwnableOverrides.sol:L207-L209`. + +**Attack scenario:** +1. A project-owned or directly owned contract inherits `JBOwnable` and also uses ERC-2771, like `JB721TiersHook`. +2. The owner signs a meta-transaction to call `transferOwnership(...)` or `setPermissionId(...)`. +3. `_checkOwner()` authorizes the call using `_msgSender()` and therefore sees the signer. +4. The event records `caller = msg.sender`, which is the forwarder, not the signer. + +**Impact:** +- Off-chain monitoring, admin dashboards, and incident review can misattribute privileged actions. +- No on-chain privilege bypass or state corruption occurs. + +**Suggested fix:** +```solidity +emit OwnershipTransferred({ + previousOwner: previousOwner, + newOwner: newProjectId == 0 ? newOwner : PROJECTS.ownerOf(newProjectId), + caller: _msgSender() +}); + +emit PermissionIdChanged({newId: permissionId, caller: _msgSender()}); +``` + +## False Positives Eliminated +- Constructor acceptance of an unminted initial project ID is a deployer-controlled configuration trap, not an exploitable authorization bypass. The contract becomes unreachable until the referenced project exists, but no third party gains access. +- Project-ownership resolution falling back to `address(0)` on `ownerOf` revert does not create a bypass because `_requirePermissionFrom(address(0), ...)` still rejects ordinary callers, and permissions for `account = address(0)` cannot be bootstrapped by a nonzero sender. + +## Downgraded Findings +- None. + +## LOW Findings (verified by inspection) +| ID | Summary | Verdict | +|----|---------|---------| +| FF-001 | Meta-transaction event `caller` uses `msg.sender` instead of `_msgSender()` | TRUE POSITIVE | + +## Summary +- Total functions analyzed: 11 +- Raw findings (pre-verification): 0 CRITICAL | 0 HIGH | 0 MEDIUM | 1 LOW +- After verification: 1 TRUE POSITIVE | 0 FALSE POSITIVE | 0 DOWNGRADED +- Final: 0 HIGH | 0 MEDIUM | 1 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-ownable-v6/codex-nemesis-raw.md b/.audit-findings-codex-prev-20260323-211452/nana-ownable-v6/codex-nemesis-raw.md new file mode 100644 index 0000000..02bb390 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-ownable-v6/codex-nemesis-raw.md @@ -0,0 +1,120 @@ +# N E M E S I S — Raw Findings + +## Scope +- Language: Solidity 0.8.26 +- In-scope files: + - `src/JBOwnable.sol` + - `src/JBOwnableOverrides.sol` + - `src/interfaces/IJBOwnable.sol` + - `src/structs/JBOwner.sol` +- `script/`: no Solidity files present in this repo + +## Phase 0 — Nemesis Recon + +### Attack Goals +1. Gain unauthorized owner access over downstream hooks/extensions that rely on `JBOwnable`. +2. Permanently brick owner-only administration through an ownership-resolution or transition bug. +3. Preserve stale delegated access after ownership transfer. +4. Desynchronize the reported owner from the authorization owner so integrations trust the wrong principal. + +### Novel Code +- `src/JBOwnableOverrides.sol` — custom two-mode ownership state machine (`address` or `projectId`) layered onto Juicebox permissions. +- `src/JBOwnable.sol` — custom event-emission path that resolves the new owner dynamically for project-based transfers. + +### Value Stores + Initial Coupling Hypothesis +- `jbOwner` stores access-control authority. + - Outflows: `transferOwnership`, `transferOwnershipToProject`, `renounceOwnership`, constructor initialization. + - Suspected coupled state: + - `jbOwner.owner` ↔ `jbOwner.projectId` + - ownership identity ↔ `jbOwner.permissionId` + - `owner()` resolution ↔ `_checkOwner()` resolution + +### Complex Paths +- Project-based ownership resolution: + - `onlyOwner`/`_checkOwner` → `PROJECTS.ownerOf(projectId)` → `_requirePermissionFrom(...)` → `PERMISSIONS.hasPermission(...)` +- Ownership transfer to project: + - `transferOwnershipToProject` → `PROJECTS.count()` → `_transferOwnership` → `_emitTransferEvent` → `PROJECTS.ownerOf(newProjectId)` + +### Priority Order +1. `src/JBOwnableOverrides.sol` — holds the entire ownership state machine and access checks. +2. `src/JBOwnable.sol` — controls event emission and concrete `onlyOwner` behavior. +3. Dependency boundary with `JBPermissioned` / `JBPermissions` / `JBProjects`. + +## Phase 1 — Unified Nemesis Map +| Function | Writes A | Writes B | Coupled Pair | Sync Status | +|----------|----------|----------|--------------|-------------| +| constructor → `_transferOwnership` | `owner/projectId` | `permissionId=0` | ownership ↔ permission | ✓ SYNCED | +| `transferOwnership` | `owner/projectId` | `permissionId=0` | ownership ↔ permission | ✓ SYNCED | +| `transferOwnershipToProject` | `owner/projectId` | `permissionId=0` | ownership ↔ permission | ✓ SYNCED | +| `renounceOwnership` | `owner/projectId` | `permissionId=0` | ownership ↔ permission | ✓ SYNCED | +| `_setPermissionId` | `permissionId` | — | permission ↔ owner identity | ✓ allowed independent update | +| `owner` | reads resolved owner | — | `owner()` ↔ `_checkOwner()` | ✓ same resolution logic | +| `_checkOwner` | reads resolved owner | — | `owner()` ↔ `_checkOwner()` | ✓ same resolution logic | +| `_emitTransferEvent` | — | event caller/new owner projection | off-chain audit trail ↔ auth path | SUSPECT | + +## Pass 1 — Feynman (Full) + +### Suspects +1. `src/JBOwnable.sol:L73-L76` + - Question: Why does the event use `msg.sender` when authorization uses `_msgSender()` through `JBPermissioned`? + - Suspect: meta-transaction inheritors log the forwarder as `caller`. + +2. `src/JBOwnableOverrides.sol:L207-L209` + - Question: Is the `PermissionIdChanged` caller field consistent with the access-control path? + - Suspect: same meta-transaction mismatch. + +### Cleared During Pass 1 +- `_checkOwner()` cannot be bypassed when project resolution returns `address(0)` because `JBPermissioned` still requires either `sender == account` or a permission recorded against that exact `account`. +- Wildcard permissions do not create a bypass from the renounced state because `_checkOwner()` would still check permissions against `account = address(0)`, and permissions for `account = address(0)` cannot be bootstrapped by a nonzero sender. +- Ownership transfers all route through `_transferOwnership(...)`, which enforces mutual exclusivity and resets delegated permission state. + +## Pass 2 — State Inconsistency (Full, enriched) + +### Coupled Pairs Confirmed +1. `jbOwner.owner` ↔ `jbOwner.projectId` +2. ownership identity ↔ `jbOwner.permissionId` +3. `owner()` resolution ↔ `_checkOwner()` resolution + +### Gaps +- No storage-coupling gaps found. +- No parallel-path mismatch found between `transferOwnership`, `transferOwnershipToProject`, and `renounceOwnership`. + +### Masking Code +- None relevant. The `try/catch` around `PROJECTS.ownerOf(...)` is a deliberate graceful-degradation mechanism and both read/auth paths use it consistently. + +## Pass 3 — Feynman Re-Interrogation (Targeted) + +### Delta +- Confirmed the event mismatch is real in downstream ERC-2771 inheritors: + - `../nana-721-hook-v6/src/JB721TiersHook.sol:L39` + - `../nana-721-hook-v6/src/JB721TiersHook.sol:L565-L566` +- No new root-cause state bugs emerged from the state pass. + +## Pass 4 — State Re-Analysis (Targeted) + +### Delta +- No new coupled pairs. +- No new mutation paths. +- No state inconsistency caused by the event mismatch; impact remains off-chain attribution only. + +## Convergence +- Pass 4 produced no new findings or new coupled pairs. +- Nemesis loop converged after 4 passes total: Feynman full → State full → Feynman targeted → State targeted. + +## Raw Findings + +### RF-001: LOW — Event caller fields misattribute ERC-2771 admin actions +- Source: Feynman-only +- Affected code: + - `src/JBOwnable.sol:L73-L76` + - `src/JBOwnableOverrides.sol:L207-L209` +- Trigger sequence: + 1. Inherit `JBOwnable` in an ERC-2771-aware contract. + 2. Submit a forwarded call that passes `_checkOwner()` via `_msgSender()`. + 3. Observe `caller` in `OwnershipTransferred` / `PermissionIdChanged` records the forwarder, not the signer. +- Consequence: + - Off-chain monitoring and admin attribution can be wrong. +- Preliminary severity: LOW + +## Verification Queue +- RF-001: code trace sufficient; no C/H/M findings identified, so no PoC required by the verification gate. diff --git a/.audit-findings-codex-prev-20260323-211452/nana-ownable-v6/codex-nemesis-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-ownable-v6/codex-nemesis-verified.md new file mode 100644 index 0000000..779fb35 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-ownable-v6/codex-nemesis-verified.md @@ -0,0 +1,95 @@ +# N E M E S I S — Verified Findings + +## Scope +- Language: Solidity 0.8.26 +- Modules analyzed: `src/JBOwnable.sol`, `src/JBOwnableOverrides.sol`, `src/interfaces/IJBOwnable.sol`, `src/structs/JBOwner.sol` +- Functions analyzed: 11 concrete functions/modifiers +- Coupled state pairs mapped: 3 +- Mutation paths traced: 10 +- Nemesis loop iterations: 4 passes total +- `script/`: no Solidity files present in this repo + +## Nemesis Map (Phase 1 Cross-Reference) +| Function | Writes A | Writes B | A↔B Pair | Sync Status | +|----------|----------|----------|----------|-------------| +| constructor → `_transferOwnership` | `owner/projectId` | `permissionId=0` | ownership ↔ permission | ✓ SYNCED | +| `transferOwnership` | `owner/projectId` | `permissionId=0` | ownership ↔ permission | ✓ SYNCED | +| `transferOwnershipToProject` | `owner/projectId` | `permissionId=0` | ownership ↔ permission | ✓ SYNCED | +| `renounceOwnership` | `owner/projectId` | `permissionId=0` | ownership ↔ permission | ✓ SYNCED | +| `_setPermissionId` | `permissionId` | — | permission ↔ owner identity | ✓ BY DESIGN | +| `owner` | reads resolved owner | — | `owner()` ↔ `_checkOwner()` | ✓ SYNCED | +| `_checkOwner` | reads resolved owner | — | `owner()` ↔ `_checkOwner()` | ✓ SYNCED | +| `_emitTransferEvent` | — | event audit trail | auth path ↔ emitted caller | ✗ Feynman-only issue | + +## Verification Summary +| ID | Source | Coupled Pair | Breaking Op | Severity | Verdict | +|----|--------|-------------|-------------|----------|---------| +| NM-001 | Feynman-only | — | `_emitTransferEvent`, `_setPermissionId` | LOW | TRUE POS | + +## Verified Findings (TRUE POSITIVES only) + +### Finding NM-001: LOW — ERC-2771 inheritors emit the forwarder as `caller` +**Severity:** LOW +**Source:** Feynman-only +**Verification:** Code trace + +**Coupled Pair:** none on storage; this is an authorization/event-boundary mismatch +**Invariant:** the event-reported caller should match the principal that passed owner authorization + +**Feynman Question that exposed it:** +> What does this event line assume about the caller, and does that assumption match the authorization path? + +**State Mapper gap that confirmed it:** +> No storage gap existed; the issue survives only at the boundary between authorization state and emitted audit metadata. + +**Breaking Operation:** privileged calls that emit `OwnershipTransferred` or `PermissionIdChanged` +- Affected code: + - `src/JBOwnable.sol:L73-L76` + - `src/JBOwnableOverrides.sol:L207-L209` +- Modifies state correctly, but records `caller: msg.sender` instead of the meta-tx-aware `_msgSender()` + +**Trigger Sequence:** +1. A downstream contract inherits `JBOwnable` and also overrides `_msgSender()` via `ERC2771Context`. +2. The owner or an authorized operator signs a forwarded meta-transaction. +3. `_checkOwner()` succeeds because `JBPermissioned` uses `_msgSender()`. +4. The emitted event records the forwarder as `caller`. + +**Consequence:** +- On-chain state remains correct. +- Off-chain systems can misattribute who performed a privileged action. + +**Verification Evidence:** +- Authorization path: + - `src/JBOwnableOverrides.sol:L118-L135` + - `node_modules/@bananapus/core-v6/src/abstract/JBPermissioned.sol:L42-L54` +- Event path: + - `src/JBOwnable.sol:L73-L76` + - `src/JBOwnableOverrides.sol:L207-L209` +- Real downstream ERC-2771 inheritor: + - `../nana-721-hook-v6/src/JB721TiersHook.sol:L39` + - `../nana-721-hook-v6/src/JB721TiersHook.sol:L565-L566` + +**Fix:** +```solidity +caller: _msgSender() +``` + +## Feedback Loop Discoveries +- None. The state pass did not surface new coupled pairs or mutation gaps beyond the Feynman event-boundary suspect. + +## False Positives Eliminated +- No bypass exists when `PROJECTS.ownerOf(projectId)` reverts. Both `owner()` and `_checkOwner()` degrade to `address(0)`, and `JBPermissioned` still rejects normal callers. +- No stale permission survives ownership transfer. Every transfer path overwrites the packed `JBOwner` struct with `permissionId = 0`. +- No storage packing inconsistency exists. `forge inspect JBOwnableOverrides storage-layout` shows `jbOwner` occupies a single 32-byte slot. + +## Downgraded Findings +- None. + +## Summary +- Total functions analyzed: 11 +- Coupled state pairs mapped: 3 +- Nemesis loop iterations: 4 passes total +- Raw findings (pre-verification): 0 C | 0 H | 0 M | 1 L +- Feedback loop discoveries: 0 +- After verification: 1 TRUE POSITIVE | 0 FALSE POSITIVE | 0 DOWNGRADED +- Final: 0 CRITICAL | 0 HIGH | 0 MEDIUM | 1 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-ownable-v6/codex-state-inconsistency-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-ownable-v6/codex-state-inconsistency-verified.md new file mode 100644 index 0000000..53869b0 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-ownable-v6/codex-state-inconsistency-verified.md @@ -0,0 +1,49 @@ +# State Inconsistency Audit — Verified Findings + +## Coupled State Dependency Map +| Pair | Invariant | Mutation points | +|------|-----------|-----------------| +| `jbOwner.owner` ↔ `jbOwner.projectId` | Exactly one ownership mode is active, unless explicitly renounced | constructor, `transferOwnership`, `transferOwnershipToProject`, `renounceOwnership`, internal `_transferOwnership` | +| `jbOwner.permissionId` ↔ ownership identity | delegated owner permission must be cleared whenever ownership changes | constructor, `transferOwnership`, `transferOwnershipToProject`, `renounceOwnership`, internal `_transferOwnership` | +| `owner()` resolution ↔ `_checkOwner()` resolution | read path and authorization path must resolve the same owner from identical stored state | `owner`, `_checkOwner` | + +## Mutation Matrix +| State Variable | Mutating Function | Type of Mutation | Updates Coupled State? | +|----------------|-------------------|------------------|------------------------| +| `jbOwner.owner` | constructor via `_transferOwnership` | set | Yes | +| `jbOwner.owner` | `transferOwnership` via `_transferOwnership` | set | Yes | +| `jbOwner.owner` | `transferOwnershipToProject` via `_transferOwnership` | zeroed | Yes | +| `jbOwner.owner` | `renounceOwnership` via `_transferOwnership` | zeroed | Yes | +| `jbOwner.projectId` | constructor via `_transferOwnership` | set | Yes | +| `jbOwner.projectId` | `transferOwnership` via `_transferOwnership` | zeroed | Yes | +| `jbOwner.projectId` | `transferOwnershipToProject` via `_transferOwnership` | set | Yes | +| `jbOwner.projectId` | `renounceOwnership` via `_transferOwnership` | zeroed | Yes | +| `jbOwner.permissionId` | `_setPermissionId` | set | Not an ownership change | +| `jbOwner.permissionId` | `_transferOwnership` | reset to `0` | Yes | + +## Parallel Path Comparison +| Coupled State | `transferOwnership` | `transferOwnershipToProject` | `renounceOwnership` | +|---------------|---------------------|------------------------------|---------------------| +| `owner/projectId` mutual exclusivity | ✓ | ✓ | ✓ | +| `permissionId` reset on ownership change | ✓ | ✓ | ✓ | +| owner-resolution parity with `owner()` / `_checkOwner()` | ✓ | ✓ | ✓ | + +## Verification Summary +| ID | Coupled Pair | Breaking Op | Original Severity | Verdict | Final Severity | +|----|-------------|-------------|-------------------|---------|----------------| +| — | — | — | — | No verified state inconsistency findings | — | + +## Verified Findings +- None. + +## False Positives Eliminated +- No mutation path updates `jbOwner.owner` without also updating `jbOwner.projectId` and resetting `jbOwner.permissionId`. The packed-struct overwrite in `src/JBOwnableOverrides.sol:L244` preserves the intended coupling atomically. +- `owner()` and `_checkOwner()` both resolve project ownership through the same `try PROJECTS.ownerOf(projectId) ... catch { address(0) }` pattern, so no read/auth divergence was found. +- `forge inspect JBOwnableOverrides storage-layout` confirms `jbOwner` occupies one 32-byte slot, eliminating partial-slot coupling concerns. + +## Summary +- Coupled state pairs mapped: 3 +- Mutation paths analyzed: 10 +- Raw findings (pre-verification): 0 +- After verification: 0 TRUE POSITIVE | 0 FALSE POSITIVE +- Final: 0 CRITICAL | 0 HIGH | 0 MEDIUM | 0 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-permission-ids-v6/codex-feynman-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-permission-ids-v6/codex-feynman-verified.md new file mode 100644 index 0000000..0cb605c --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-permission-ids-v6/codex-feynman-verified.md @@ -0,0 +1,62 @@ +# Feynman Audit — Verified Findings + +## Scope +- Language: Solidity 0.8.x +- Modules analyzed: `src/JBPermissionIds.sol` +- Functions analyzed: 0 +- Lines interrogated: 67 + +## Verification Summary +| ID | Original Severity | Verdict | Final Severity | +|----|-------------------|---------|----------------| +| — | — | No findings verified | — | + +## Function-State Matrix +| Unit | Reads | Writes | Guards | Calls | +|------|-------|--------|--------|-------| +| `JBPermissionIds` library constants | None | None | None | None | + +## Guard Consistency Analysis +- No executable entry points exist in scope, so there are no intra-repo guard inconsistencies to analyze. +- Dependency-boundary verification confirmed each constant is consumed by the intended permission check in downstream repos: + - Core owner-scoped IDs map to `JBController`, `JBDirectory`, and `JBMultiTerminal`. + - Holder-scoped IDs map to holder-based checks in `burnTokensOf`, `claimTokensFor`, `transferCreditsFrom`, and `cashOutTokensOf`. + - Dual-purpose IDs map to both lock and set flows in the buyback-hook and router-terminal registries. + +## Inverse Operation Parity +- Not applicable in-repo. The library has no operations, only identifiers. +- Boundary review confirmed the only multi-operation coupling documented here is intentional: + - `SET_BUYBACK_HOOK` gates both `setHookFor` and `lockHookFor`. + - `SET_ROUTER_TERMINAL` gates both `setTerminalFor` and `lockTerminalFor`. + +## Verified Findings (TRUE POSITIVES only) +- None. + +## False Positives Eliminated +- None. No credible hypotheses survived Phase 0/1 mapping. + +## Downgraded Findings +- None. + +## LOW Findings (verified by inspection) +| ID | Verdict | +|----|---------| +| — | None | + +## Summary +- Total functions analyzed: 0 +- Raw findings (pre-verification): 0 CRITICAL | 0 HIGH | 0 MEDIUM | 0 LOW +- After verification: 0 TRUE POSITIVE | 0 FALSE POSITIVE | 0 DOWNGRADED +- Final: 0 HIGH | 0 MEDIUM | 0 LOW + +## Verification Evidence +- Constant assignment uniqueness and sequentiality passed: + - `PASS: All 33 IDs are unique and sequential (1-33)` +- Local build passed: + - `forge build` +- ROOT safety rails verified in `JBPermissions.setPermissionsFor` and `hasPermission`: + - `../nana-core-v6/src/JBPermissions.sol:62` + - `../nana-core-v6/src/JBPermissions.sol:193` +- Targeted runtime verification passed: + - `forge test --match-path test/TestPermissionsEdge.sol` + - `forge test --match-path test/PermissionEscalation.t.sol` diff --git a/.audit-findings-codex-prev-20260323-211452/nana-permission-ids-v6/codex-nemesis-raw.md b/.audit-findings-codex-prev-20260323-211452/nana-permission-ids-v6/codex-nemesis-raw.md new file mode 100644 index 0000000..0fd9aad --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-permission-ids-v6/codex-nemesis-raw.md @@ -0,0 +1,110 @@ +# N E M E S I S — Raw Findings + +## Phase 0 — Nemesis Recon + +**LANGUAGE:** Solidity + +**ATTACK GOALS** +1. Reassign a constant so one granted permission silently authorizes a different action. +2. Misdocument a constant so downstream integrations grant a permission with a stronger blast radius than intended. +3. Break holder-vs-owner scoping so project owners can operate on holder-owned assets. +4. Break ROOT safety assumptions so delegated operators can escalate to global or wildcard control. + +**NOVEL CODE (highest bug density)** +- `src/JBPermissionIds.sol` — hand-maintained append-only permission registry; bug density is in human-assigned numbering and comments, not runtime logic. + +**VALUE STORES + INITIAL COUPLING HYPOTHESIS** +- No direct value stores in scope. +- Suspected coupled state at the protocol boundary: + - constant name ↔ numeric ID + - numeric ID ↔ downstream `_requirePermissionFrom` site + - permission semantic ↔ `account` being checked (`holder` vs `PROJECTS.ownerOf(projectId)`) + - dual-purpose ID ↔ both lock and set endpoints + +**COMPLEX PATHS** +- Permission grant in `JBPermissions` → downstream `_requirePermissionFrom` in core/hooks/suckers. +- ROOT grant semantics in `JBPermissions.setPermissionsFor` → wildcard and delegation checks. + +**PRIORITY ORDER** +1. Holder-scoped IDs `4/11/12/13` — wrong scoping would expose direct fund-moving rights. +2. ROOT (`1`) — incorrect wildcard/delegation semantics would compromise all projects. +3. Dual-purpose IDs `28/29` — comments must disclose lock authority accurately. +4. Sequential uniqueness of all 33 IDs — any collision silently corrupts authorization. + +## Phase 1 — Unified Nemesis Map + +| Constant | ID | Claimed Consumer(s) | Verified Consumer(s) | Sync Status | +|----------|----|----------------------|----------------------|-------------| +| `ROOT` | 1 | `JBPermissions` | `JBPermissions` root logic | SYNCED | +| `QUEUE_RULESETS` | 2 | `JBController.queueRulesetsOf` | `JBController.queueRulesetsOf`; deployer wrappers | SYNCED | +| `LAUNCH_RULESETS` | 3 | `JBController.launchRulesetsFor` | `JBController.launchRulesetsFor` | SYNCED | +| `CASH_OUT_TOKENS` | 4 | `JBMultiTerminal.cashOutTokensOf` | `JBMultiTerminal.cashOutTokensOf` with `account: holder` | SYNCED | +| `SEND_PAYOUTS` | 5 | `JBMultiTerminal.sendPayoutsOf` | privileged payout path in `_sendPayoutsOf` | SYNCED | +| `MIGRATE_TERMINAL` | 6 | `JBMultiTerminal.migrateBalanceOf` | `JBMultiTerminal.migrateBalanceOf` | SYNCED | +| `SET_PROJECT_URI` | 7 | `JBController.setUriOf` | `JBController.setUriOf` | SYNCED | +| `DEPLOY_ERC20` | 8 | `JBController.deployERC20For` | `JBController.deployERC20For` | SYNCED | +| `SET_TOKEN` | 9 | `JBController.setTokenFor` | `JBController.setTokenFor` | SYNCED | +| `MINT_TOKENS` | 10 | `JBController.mintTokensOf` | `JBController.mintTokensOf` | SYNCED | +| `BURN_TOKENS` | 11 | `JBController.burnTokensOf` | `JBController.burnTokensOf` with `account: holder` | SYNCED | +| `CLAIM_TOKENS` | 12 | `JBController.claimTokensFor` | `JBController.claimTokensFor` with `account: holder` | SYNCED | +| `TRANSFER_CREDITS` | 13 | `JBController.transferCreditsFrom` | `JBController.transferCreditsFrom` with `account: holder` | SYNCED | +| `SET_CONTROLLER` | 14 | `JBDirectory.setControllerOf` | `JBDirectory.setControllerOf` | SYNCED | +| `SET_TERMINALS` | 15 | `JBDirectory.setTerminalsOf` | `JBDirectory.setTerminalsOf`; also required by `launchRulesetsFor` | SYNCED | +| `SET_PRIMARY_TERMINAL` | 16 | `JBDirectory.setPrimaryTerminalOf` | `JBDirectory.setPrimaryTerminalOf` | SYNCED | +| `USE_ALLOWANCE` | 17 | `JBMultiTerminal.useAllowanceOf` | `JBMultiTerminal.useAllowanceOf` | SYNCED | +| `SET_SPLIT_GROUPS` | 18 | `JBController.setSplitGroupsOf` | `JBController.setSplitGroupsOf` | SYNCED | +| `ADD_PRICE_FEED` | 19 | `JBController.addPriceFeedFor` | `JBController.addPriceFeedFor` | SYNCED | +| `ADD_ACCOUNTING_CONTEXTS` | 20 | `JBMultiTerminal.addAccountingContextsFor` | `JBMultiTerminal.addAccountingContextsFor` | SYNCED | +| `SET_TOKEN_METADATA` | 21 | `JBController.setTokenMetadataOf` | `JBController.setTokenMetadataOf` | SYNCED | +| `ADJUST_721_TIERS` | 22 | `JB721TiersHook.adjustTiers` | `JB721TiersHook.adjustTiers` | SYNCED | +| `SET_721_METADATA` | 23 | `JB721TiersHook.setMetadata` | `JB721TiersHook.setMetadata` | SYNCED | +| `MINT_721` | 24 | `JB721TiersHook.mintFor` | `JB721TiersHook.mintFor` | SYNCED | +| `SET_721_DISCOUNT_PERCENT` | 25 | `JB721TiersHook.setDiscountPercentOf` | both single and batch discount setters | SYNCED | +| `SET_BUYBACK_TWAP` | 26 | `JBBuybackHook.setTwapWindowOf` | `JBBuybackHook.setTwapWindowOf` | SYNCED | +| `SET_BUYBACK_POOL` | 27 | `JBBuybackHook.setPoolFor` | buyback hook pool setters/init | SYNCED | +| `SET_BUYBACK_HOOK` | 28 | `setHookFor` + `lockHookFor` | both registry endpoints | SYNCED | +| `SET_ROUTER_TERMINAL` | 29 | `setTerminalFor` + `lockTerminalFor` | both router registry endpoints | SYNCED | +| `MAP_SUCKER_TOKEN` | 30 | `JBSucker.mapToken` | `JBSucker._mapToken` | SYNCED | +| `DEPLOY_SUCKERS` | 31 | `JBSuckerRegistry.deploySuckersFor` | `JBSuckerRegistry.deploySuckersFor` | SYNCED | +| `SUCKER_SAFETY` | 32 | `JBSucker.enableEmergencyHatchFor` | `JBSucker.enableEmergencyHatchFor` | SYNCED | +| `SET_SUCKER_DEPRECATION` | 33 | `JBSucker.setDeprecation` | `JBSucker.setDeprecation` | SYNCED | + +## Pass 1 — Feynman Raw Output + +### Suspects +- None. + +### Exposed Assumptions +- The library assumes all permission semantics are enforced in downstream contracts, not locally. +- The safety of `ROOT` depends entirely on `JBPermissions`, not on this repo. +- The safety of holder-scoped IDs depends on downstream call sites passing `holder` as `account`. + +### Ordering Concerns +- None in-repo. No executable code. + +## Pass 2 — State Inconsistency Raw Output + +### Coupled State Dependency Map +- `constant symbol` ↔ `downstream permission site` +- `permission meaning` ↔ `account argument target` +- `lock/set pair comments` ↔ `paired downstream endpoints` + +### Mutation Matrix +- None in-repo. Constants are immutable. + +### Gaps +- None. + +### Masking Code +- None in scope. + +## Convergence +- Pass 1 produced no exploitable suspects. +- Pass 2 found no semantic coupling gaps. +- No delta existed for Pass 3, so the loop converged after the two full baseline passes. + +## Raw Finding Count +- 0 CRITICAL +- 0 HIGH +- 0 MEDIUM +- 0 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-permission-ids-v6/codex-nemesis-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-permission-ids-v6/codex-nemesis-verified.md new file mode 100644 index 0000000..a87ad6b --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-permission-ids-v6/codex-nemesis-verified.md @@ -0,0 +1,75 @@ +# N E M E S I S — Verified Findings + +## Scope +- Language: Solidity +- Modules analyzed: `src/JBPermissionIds.sol` +- Functions analyzed: 0 +- Coupled state pairs mapped: 4 semantic pair classes +- Mutation paths traced: 0 in-repo; boundary verification only +- Nemesis loop iterations: 2 baseline passes, 0 targeted re-passes (converged immediately after Pass 2) + +## Nemesis Map (Phase 1 Cross-Reference) +| Constant | ID | Boundary invariant | Verification result | +|----------|----|--------------------|---------------------| +| `ROOT` | 1 | Cannot be granted by an operator to others or on wildcard project `0` | Verified in `JBPermissions` code and tests | +| `CASH_OUT_TOKENS` / `BURN_TOKENS` / `CLAIM_TOKENS` / `TRANSFER_CREDITS` | 4 / 11 / 12 / 13 | Must be checked against holder, not project owner | Verified at all core call sites | +| `SET_TERMINALS` | 15 | Must gate `setTerminalsOf`; `launchRulesetsFor` must also require it | Verified | +| `SET_BUYBACK_HOOK` | 28 | Intentionally gates both set and lock | Verified | +| `SET_ROUTER_TERMINAL` | 29 | Intentionally gates both set and lock | Verified | +| `SUCKER_SAFETY` / `SET_SUCKER_DEPRECATION` | 32 / 33 | Must remain separated between emergency hatch and deprecation | Verified | +| All constants | 1-33 | Unique, sequential, `uint8`, non-zero | Verified | + +## Verification Summary +| ID | Source | Coupled Pair | Breaking Op | Severity | Verdict | +|----|--------|-------------|-------------|----------|---------| +| — | — | — | — | — | No verified findings | + +## Verified Findings (TRUE POSITIVES only) +- None. + +## Feedback Loop Discoveries +- None. The full Feynman pass exposed no executable-logic suspect inside this repo, and the state pass found no boundary desynchronization between constant definitions and downstream permission checks. + +## False Positives Eliminated +- None. + +## Downgraded Findings +- None. + +## Summary +- Total functions analyzed: 0 +- Coupled state pairs mapped: 4 semantic pair classes +- Nemesis loop iterations: 2 total passes +- Raw findings (pre-verification): 0 C | 0 H | 0 M | 0 L +- Feedback loop discoveries: 0 +- After verification: 0 TRUE POSITIVE | 0 FALSE POSITIVE | 0 DOWNGRADED +- Final: 0 CRITICAL | 0 HIGH | 0 MEDIUM | 0 LOW + +## Verification Evidence + +### Library integrity +- All constants are `uint8 internal constant` values from `1` through `33` in [`src/JBPermissionIds.sol`](/Users/jango/Documents/jb/v6/evm/nana-permission-ids-v6/src/JBPermissionIds.sol#L9). +- Sequential uniqueness check passed on `2026-03-23`: + - `PASS: All 33 IDs are unique and sequential (1-33)` +- Local compilation passed on `2026-03-23`: + - `forge build` + +### ROOT safety +- `JBPermissions.setPermissionsFor` forbids operator-granted ROOT and wildcard-project permission sets via the same gate at [`JBPermissions.sol`](/Users/jango/Documents/jb/v6/evm/nana-core-v6/src/JBPermissions.sol#L62). +- `JBPermissions.hasPermission` and `hasPermissions` only treat ROOT as an override during reads, not writes, at [`JBPermissions.sol`](/Users/jango/Documents/jb/v6/evm/nana-core-v6/src/JBPermissions.sol#L122) and [`JBPermissions.sol`](/Users/jango/Documents/jb/v6/evm/nana-core-v6/src/JBPermissions.sol#L193). +- Runtime confirmation passed: + - `forge test --match-path test/TestPermissionsEdge.sol` + - `forge test --match-path test/PermissionEscalation.t.sol` + +### Holder-scoped permissions +- `BURN_TOKENS` is checked against `holder` in [`JBController.sol`](/Users/jango/Documents/jb/v6/evm/nana-core-v6/src/JBController.sol#L236). +- `CLAIM_TOKENS` is checked against `holder` in [`JBController.sol`](/Users/jango/Documents/jb/v6/evm/nana-core-v6/src/JBController.sol#L270). +- `TRANSFER_CREDITS` is checked against `holder` in [`JBController.sol`](/Users/jango/Documents/jb/v6/evm/nana-core-v6/src/JBController.sol#L691). +- `CASH_OUT_TOKENS` is checked against `holder` in [`JBMultiTerminal.sol`](/Users/jango/Documents/jb/v6/evm/nana-core-v6/src/JBMultiTerminal.sol#L263). + +### Owner-scoped and dual-purpose permissions +- `LAUNCH_RULESETS` and `SET_TERMINALS` are both required in [`JBController.sol`](/Users/jango/Documents/jb/v6/evm/nana-core-v6/src/JBController.sol#L418). +- `SET_CONTROLLER`, `SET_PRIMARY_TERMINAL`, and `SET_TERMINALS` map correctly in [`JBDirectory.sol`](/Users/jango/Documents/jb/v6/evm/nana-core-v6/src/JBDirectory.sol#L94), [`JBDirectory.sol`](/Users/jango/Documents/jb/v6/evm/nana-core-v6/src/JBDirectory.sol#L175), and [`JBDirectory.sol`](/Users/jango/Documents/jb/v6/evm/nana-core-v6/src/JBDirectory.sol#L204). +- `SET_BUYBACK_HOOK` gates both lock and set in [`JBBuybackHookRegistry.sol`](/Users/jango/Documents/jb/v6/evm/nana-buyback-hook-v6/src/JBBuybackHookRegistry.sol#L125) and [`JBBuybackHookRegistry.sol`](/Users/jango/Documents/jb/v6/evm/nana-buyback-hook-v6/src/JBBuybackHookRegistry.sol#L172). +- `SET_ROUTER_TERMINAL` gates both lock and set in [`JBRouterTerminalRegistry.sol`](/Users/jango/Documents/jb/v6/evm/nana-router-terminal-v6/src/JBRouterTerminalRegistry.sol#L304) and [`JBRouterTerminalRegistry.sol`](/Users/jango/Documents/jb/v6/evm/nana-router-terminal-v6/src/JBRouterTerminalRegistry.sol#L405). +- `SUCKER_SAFETY`, `SET_SUCKER_DEPRECATION`, `MAP_SUCKER_TOKEN`, and `DEPLOY_SUCKERS` map correctly in [`JBSucker.sol`](/Users/jango/Documents/jb/v6/evm/nana-suckers-v6/src/JBSucker.sol#L404), [`JBSucker.sol`](/Users/jango/Documents/jb/v6/evm/nana-suckers-v6/src/JBSucker.sol#L631), [`JBSucker.sol`](/Users/jango/Documents/jb/v6/evm/nana-suckers-v6/src/JBSucker.sol#L881), and [`JBSuckerRegistry.sol`](/Users/jango/Documents/jb/v6/evm/nana-suckers-v6/src/JBSuckerRegistry.sol#L195). diff --git a/.audit-findings-codex-prev-20260323-211452/nana-permission-ids-v6/codex-state-inconsistency-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-permission-ids-v6/codex-state-inconsistency-verified.md new file mode 100644 index 0000000..bf0b696 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-permission-ids-v6/codex-state-inconsistency-verified.md @@ -0,0 +1,59 @@ +# State Inconsistency Audit — Verified Findings + +## Coupled State Dependency Map +- No mutable storage exists in `src/JBPermissionIds.sol`. +- The only meaningful couplings are semantic, at the dependency boundary: + - constant name ↔ numeric ID + - numeric ID ↔ downstream permission check site + - holder-scoped constant ↔ holder-based `account` argument + - dual-purpose constant ↔ paired set/lock call sites + +## Mutation Matrix +| State Variable | Mutating Function | Type of Mutation | +|----------------|-------------------|------------------| +| None | None | Library is immutable constants-only code | + +## Parallel Path Comparison +| Coupled State | Path A | Path B | Result | +|---------------|--------|--------|--------| +| `SET_BUYBACK_HOOK` usage | `setHookFor` | `lockHookFor` | Same permission ID enforced | +| `SET_ROUTER_TERMINAL` usage | `setTerminalFor` | `lockTerminalFor` | Same permission ID enforced | +| holder-scoped IDs | `burn/claim/transfer/cashOut` | owner-scoped admin flows | Correctly split by `account` target | + +## Verification Summary +| ID | Coupled Pair | Breaking Op | Original Severity | Verdict | Final Severity | +|----|-------------|-------------|-------------------|---------|----------------| +| — | — | — | — | No findings verified | — | + +## Verified Findings +- None. + +## False Positives Eliminated +- None. No missing synchronization or semantic drift was found between the constants library and the downstream permission checks reviewed. + +## Summary +- Coupled state pairs mapped: 4 semantic pair classes +- Mutation paths analyzed: 0 in-repo, boundary-only verification +- Raw findings (pre-verification): 0 +- After verification: 0 TRUE POSITIVE | 0 FALSE POSITIVE +- Final: 0 CRITICAL | 0 HIGH | 0 MEDIUM | 0 LOW + +## Verification Evidence +- `ROOT` cannot be set for wildcard projects and cannot be forwarded by a ROOT operator: + - `../nana-core-v6/src/JBPermissions.sol:62` + - validated by `test/TestPermissionsEdge.sol` and `test/PermissionEscalation.t.sol` +- Holder-scoped permissions are checked against `holder`, not project owner: + - `../nana-core-v6/src/JBController.sol:246` + - `../nana-core-v6/src/JBController.sol:280` + - `../nana-core-v6/src/JBController.sol:701` + - `../nana-core-v6/src/JBMultiTerminal.sol:277` +- Owner-scoped permissions are checked against project owner: + - `../nana-core-v6/src/JBController.sol:177` + - `../nana-core-v6/src/JBController.sol:435` + - `../nana-core-v6/src/JBDirectory.sol:99` + - `../nana-core-v6/src/JBMultiTerminal.sol:197` +- Dual-purpose IDs are consistently enforced on both paired paths: + - `../nana-buyback-hook-v6/src/JBBuybackHookRegistry.sol:125` + - `../nana-buyback-hook-v6/src/JBBuybackHookRegistry.sol:172` + - `../nana-router-terminal-v6/src/JBRouterTerminalRegistry.sol:304` + - `../nana-router-terminal-v6/src/JBRouterTerminalRegistry.sol:405` diff --git a/.audit-findings-codex-prev-20260323-211452/nana-router-terminal-v6/codex-feynman-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-router-terminal-v6/codex-feynman-verified.md new file mode 100644 index 0000000..b1826b2 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-router-terminal-v6/codex-feynman-verified.md @@ -0,0 +1,94 @@ +# Feynman Audit — Verified Findings + +## Scope +- Language: Solidity 0.8.26 +- Modules analyzed: 9 Solidity files in `src/` and `script/` +- Functions analyzed: 92 + +## Verification Summary +| ID | Original Severity | Verdict | Final Severity | +|----|-------------------|---------|----------------| +| FF-001 | HIGH | TRUE POSITIVE | HIGH | +| FF-002 | MEDIUM | TRUE POSITIVE | MEDIUM | +| FF-003 | MEDIUM | FALSE POSITIVE | — | + +## Verified TRUE POSITIVE Findings + +### Finding FF-001: HIGH — ERC20 partial fills strand unused input in the router +**Module:** `JBRouterTerminal` +**Function:** `_handleSwap` +**Lines:** `src/JBRouterTerminal.sol:812-842` +**Verification:** Hybrid — code trace plus Foundry PoC `test_routerPartialFill_trapsUnusedErc20Input` + +**Feynman Question that exposed this:** +> What does the post-swap balance delta actually measure after an ERC20 exact-input swap only consumes part of the approved input? + +**Why this is wrong:** +`balanceBefore` is sampled after the router already holds the full ERC20 input. A partial fill reduces that balance from `amount` down to `leftover`, so `balanceAfter` is smaller than `balanceBefore`. The refund branch only runs when `balanceAfter > balanceBefore`, which can never happen for ERC20 leftovers. The router therefore keeps the unspent ERC20 input forever. + +**Verification evidence:** +- Code trace: + - `_acceptFundsFor` pulls the full input into the router before `_handleSwap`. + - `_handleSwap` snapshots `IERC20(normalizedTokenIn).balanceOf(address(this))` into `balanceBefore`. + - After a partial fill, the pool callback consumes only part of the input. + - The refund branch at `if (balanceAfter > balanceBefore)` is unreachable for the leftover ERC20 path. +- PoC: + - `forge test --match-path test/audit/CodexNemesis.t.sol -vvv` + - `test_routerPartialFill_trapsUnusedErc20Input` passes. + - The PoC swaps `1000` input units, consumes `600`, forwards `100` output units, and leaves `400` input units stranded in `JBRouterTerminal`. + +**Attack scenario:** +1. A user pays through the router with an ERC20 that requires a swap. +2. The selected Uniswap pool only partially fills because `sqrtPriceLimitX96` is hit. +3. The router forwards the output amount to the destination terminal. +4. The unused ERC20 input remains stuck in the router instead of being refunded. + +**Impact:** +- Direct user fund loss on any reachable ERC20 partial-fill route. +- The router is explicitly designed to be stateless and has no sweep function, so stranded ERC20 leftovers are practically unrecoverable. + +**Suggested fix:** +Compute leftover from the pre-swap input amount rather than `balanceAfter - balanceBefore`, or snapshot the router balance before funds are accepted and separately track swap consumption. + +### Finding FF-002: MEDIUM — Registry-routed native partial fills revert when the router refunds leftovers to the registry +**Module:** `JBRouterTerminal` / `JBRouterTerminalRegistry` +**Function:** `_handleSwap`, `_transferFrom`, `pay` +**Lines:** `src/JBRouterTerminal.sol:829-841`, `src/JBRouterTerminal.sol:929-935`, `src/JBRouterTerminalRegistry.sol:350-377` +**Verification:** Hybrid — code trace plus Foundry PoC `test_registryNativeInput_partialFillRevertsOnRefundToRegistry` + +**Feynman Question that exposed this:** +> During a registry-mediated payment, who does `_msgSender()` resolve to at the exact point where leftover native input is refunded? + +**Why this is wrong:** +When users call `JBRouterTerminalRegistry.pay`, the registry becomes the direct caller of `JBRouterTerminal.pay`. On native partial fills, `_handleSwap` wraps the raw leftover ETH into WETH, unwraps it again, and refunds it to `_msgSender()`. At that point `_msgSender()` is the registry contract, not the original user. `_transferFrom` therefore uses `Address.sendValue` to send ETH to the registry, which has no payable `receive`/`fallback`, so the entire payment reverts. + +**Verification evidence:** +- Code trace: + - `JBRouterTerminalRegistry.pay` forwards to `JBRouterTerminal.pay`. + - `_handleSwap` refunds leftovers to `_msgSender()`. + - `_transferFrom` turns native refunds into `Address.sendValue`. + - The refund target is the registry contract, which cannot receive plain ETH. +- PoC: + - `forge test --match-path test/audit/CodexNemesis.t.sol -vvv` + - `test_registryNativeInput_partialFillRevertsOnRefundToRegistry` passes. + - The PoC shows a registry-routed native payment reverting when a partial fill leaves `400` units of native input to refund. + +**Attack scenario:** +1. A project is routed through the registry into the router. +2. A user pays with native ETH and the swap partially fills. +3. The router attempts to refund the leftover ETH to the registry. +4. The refund reverts, so the entire payment path is unusable for that partial-fill condition. + +**Impact:** +- Registry-mediated native routes can become a hard DoS under legitimate partial-fill conditions. +- Integrators relying on the registry entrypoint lose availability exactly when slippage protection is most important. + +**Suggested fix:** +Carry the original payer/refund recipient through the registry-to-router call chain and refund that address directly, or make the registry explicitly receive and forward native leftovers. + +## False Positives Eliminated + +### FF-003: Registry `addToBalanceOf` with no resolved terminal burns ETH to `address(0)` +- Verdict: FALSE POSITIVE +- Why: the call to `terminal.addToBalanceOf` reverts on the non-contract target before any value loss is committed. Verified with `test_registryAddToBalanceOf_withoutResolvedTerminal_reverts`. + diff --git a/.audit-findings-codex-prev-20260323-211452/nana-router-terminal-v6/codex-nemesis-raw.md b/.audit-findings-codex-prev-20260323-211452/nana-router-terminal-v6/codex-nemesis-raw.md new file mode 100644 index 0000000..cc84673 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-router-terminal-v6/codex-nemesis-raw.md @@ -0,0 +1,64 @@ +# N E M E S I S — Raw Findings + +## Phase 0 Recon +- Language: Solidity +- Scope: all 9 Solidity files under `src/` and `script/` +- Attack goals: + 1. Trap user funds inside the stateless router. + 2. Turn registry forwarding into a value-loss or DoS boundary. + 3. Break swap/cashout routing assumptions with partial fills or callback ordering. +- Novel code: + - `src/JBRouterTerminal.sol`: custom route discovery, cashout recursion, V3/V4 swap execution, and leftover handling. + - `src/JBRouterTerminalRegistry.sol`: forwarding layer that changes caller context and token custody. + - `src/libraries/JBSwapLib.sol`: custom sigmoid slippage math. +- Value stores: + - In-flight router balances during `_acceptFundsFor` / `_handleSwap`. + - Registry custody during forwarded `pay` / `addToBalanceOf`. +- Initial coupling hypothesis: + - Caller who funds a route must remain coupled to any leftover refund. + - Accepted input amount must remain coupled to the amount either swapped, forwarded, or refunded. + +## Pass 1 — Feynman (full) +- Suspect A: `_handleSwap` snapshots `balanceBefore` after the router already holds the full ERC20 input, then refunds only if `balanceAfter > balanceBefore`. +- Suspect B: native refund path uses `_msgSender()` after registry forwarding. +- Suspect C: unresolved terminal in `JBRouterTerminalRegistry.addToBalanceOf` may burn ETH to `address(0)`. + +## Pass 2 — State (full, enriched) +- Parallel-path mismatch confirmed: + - Direct router native partial fill can refund the caller. + - Registry-mediated native partial fill changes the refund recipient to the registry. +- No persistent storage desync was found in `defaultTerminal` / `_terminalOf` / `hasLockedTerminal`. +- Suspect C stayed open pending PoC. + +## Pass 3 — Feynman Re-interrogation +- Verified A: + - ERC20 partial fills decrease router input balance from `amount` to `leftover`. + - The refund branch is unreachable, so unused ERC20 input remains in the router. +- Verified B: + - Registry forwarding changes `_msgSender()` at refund time. + - Native refund attempts target the registry and revert. +- Suspect C downgraded to false positive pending runtime test. + +## Pass 4 — State Re-analysis +- No new coupled pairs or mutation paths. +- No additional registry/storage inconsistencies. +- Converged. + +## Raw Findings + +### RF-001 +- Title: ERC20 partial fills strand unused input in `JBRouterTerminal` +- Severity: HIGH +- Source: Feynman Pass 1 → verified in Pass 3 + +### RF-002 +- Title: Registry-mediated native partial fills revert on leftover refund +- Severity: MEDIUM +- Source: Pass 1 refund suspicion enriched by Pass 2 parallel-path comparison + +### RF-003 +- Title: `JBRouterTerminalRegistry.addToBalanceOf` without a terminal burns ETH +- Severity: MEDIUM +- Source: Feynman Pass 1 +- Status after verification: FALSE POSITIVE + diff --git a/.audit-findings-codex-prev-20260323-211452/nana-router-terminal-v6/codex-nemesis-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-router-terminal-v6/codex-nemesis-verified.md new file mode 100644 index 0000000..36b8166 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-router-terminal-v6/codex-nemesis-verified.md @@ -0,0 +1,120 @@ +# N E M E S I S — Verified Findings + +## Scope +- Language: Solidity +- Modules analyzed: 9 Solidity files +- Functions analyzed: 92 +- Coupled state pairs mapped: 3 +- Mutation paths traced: 10 +- Nemesis loop iterations: 4 passes (Feynman → State → Feynman → State) + +## Verification Summary +| ID | Source | Coupled Pair | Breaking Op | Severity | Verdict | +|----|--------|-------------|-------------|----------|---------| +| NM-001 | Feynman-only | input amount ↔ leftover refund | `_handleSwap()` | HIGH | TRUE POS | +| NM-002 | Cross-feed P1→P2 | payer context ↔ refund recipient | `JBRouterTerminalRegistry.pay()` → `_handleSwap()` | MEDIUM | TRUE POS | +| NM-003 | Feynman-only | unresolved terminal ↔ forwarded value | `JBRouterTerminalRegistry.addToBalanceOf()` | MEDIUM | FALSE POS | + +## Verified Findings (TRUE POSITIVES only) + +### Finding NM-001: HIGH — ERC20 partial fills trap unused input in the router +**Severity:** HIGH +**Source:** Feynman-only +**Verification:** Hybrid + +**Coupled Pair:** Input amount accepted by the router ↔ unused input refunded after swap +**Invariant:** For a partial fill, `accepted input = consumed input + refunded leftover`. + +**Feynman Question that exposed it:** +> After a partial ERC20 fill, how can `balanceAfter > balanceBefore` ever become true if the router already held the full input before the swap started? + +**State Mapper gap that confirmed it:** +> The ERC20 partial-fill path mutates the router’s input-token balance in the callback but has no mutation path that ever transfers the leftover back out when `balanceAfter < balanceBefore`. + +**Breaking Operation:** `_handleSwap()` at `src/JBRouterTerminal.sol:812-842` +- Modifies State A: the pool callback consumes only part of the router’s input balance. +- Does NOT update State B: the leftover refund branch is skipped because it only handles `balanceAfter > balanceBefore`. + +**Trigger Sequence:** +1. User pays the registry or router with an ERC20 that must be swapped. +2. `_acceptFundsFor` moves the full input into the router. +3. The chosen Uniswap pool partially fills because the price limit is hit. +4. `_handleSwap` forwards the output amount. +5. The remaining ERC20 input stays inside the router. + +**Consequence:** +- The user loses the unused portion of the ERC20 input. +- The router is intended to be stateless and exposes no rescue path, so the leftover is practically unrecoverable. + +**Verification Evidence:** +- Code trace: + - `src/JBRouterTerminal.sol:813` snapshots the full post-acceptance router balance. + - `src/JBRouterTerminal.sol:837-841` only refunds when `balanceAfter > balanceBefore`. + - Partial ERC20 fills necessarily leave `balanceAfter < balanceBefore`. +- PoC: + - `test/audit/CodexNemesis.t.sol` + - `test_routerPartialFill_trapsUnusedErc20Input` + - Swap input: `1000` + - Consumed by pool: `600` + - Forwarded output: `100` + - Left stranded in router: `400` + +**Fix:** +Refund `amount - actualAmountConsumed` (or equivalently `balanceBefore - balanceAfter` for ERC20 inputs) instead of relying on `balanceAfter > balanceBefore`. + +### Finding NM-002: MEDIUM — Registry-mediated native partial fills revert because leftovers are refunded to the registry +**Severity:** MEDIUM +**Source:** Cross-feed P1→P2 +**Verification:** Hybrid + +**Coupled Pair:** Original payer ↔ leftover refund recipient +**Invariant:** Leftover native input must be refunded to the account that funded the route. + +**Feynman Question that exposed it:** +> Once the registry forwards into the router, who exactly does `_msgSender()` refer to when `_handleSwap` performs the leftover refund? + +**State Mapper gap that confirmed it:** +> Parallel-path comparison showed direct router calls and registry-mediated calls diverge at refund time: the direct path refunds the user, while the forwarded path refunds the registry. + +**Breaking Operation:** `pay()` at `src/JBRouterTerminalRegistry.sol:350-377` and refund handling at `src/JBRouterTerminal.sol:829-841`, `src/JBRouterTerminal.sol:929-935` +- Modifies State A: registry forwarding changes the router’s caller context. +- Does NOT update State B: the original payer is never preserved as the refund recipient. + +**Trigger Sequence:** +1. User pays through `JBRouterTerminalRegistry.pay` with native ETH. +2. The router executes a swap that partially fills. +3. `_handleSwap` wraps the raw leftover ETH, unwraps it, and refunds to `_msgSender()`. +4. `_msgSender()` is the registry, so `_transferFrom` uses `Address.sendValue` to the registry. +5. The registry cannot receive plain ETH, so the payment reverts. + +**Consequence:** +- Registry-mediated native routes become unavailable under partial-fill conditions. +- The same swap path is callable directly through the router, so this is a boundary bug introduced by the forwarding layer. + +**Verification Evidence:** +- Code trace: + - `src/JBRouterTerminalRegistry.sol:376` calls into the router as the immediate caller. + - `src/JBRouterTerminal.sol:841` refunds leftovers to `_msgSender()`. + - `src/JBRouterTerminal.sol:932` turns native refunds into `Address.sendValue`. +- PoC: + - `test/audit/CodexNemesis.t.sol` + - `test_registryNativeInput_partialFillRevertsOnRefundToRegistry` + - The registry-routed payment reverts as soon as the router attempts the leftover refund. + +**Fix:** +Pass an explicit refund recipient from the registry into the router and use that address for all leftover refunds, or make the registry capable of receiving and forwarding ETH safely. + +## Feedback Loop Discoveries +- The state/parallel-path pass did not find a storage desync, but it did reveal that the same refund logic behaves differently under direct calls vs. registry forwarding. That delta produced NM-002; it was not obvious from the router in isolation. + +## False Positives Eliminated +- NM-003: `JBRouterTerminalRegistry.addToBalanceOf` with no resolved terminal was suspected to burn ETH. Runtime verification showed the call reverts on the non-contract target before any loss is committed. + +## Summary +- Total functions analyzed: 92 +- Coupled state pairs mapped: 3 +- Nemesis loop iterations: 4 passes +- Raw findings (pre-verification): 0 C | 1 H | 2 M | 0 L +- Feedback loop discoveries: 1 +- After verification: 2 TRUE POSITIVE | 1 FALSE POSITIVE | 0 DOWNGRADED +- Final: 0 CRITICAL | 1 HIGH | 1 MEDIUM | 0 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-router-terminal-v6/codex-state-inconsistency-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-router-terminal-v6/codex-state-inconsistency-verified.md new file mode 100644 index 0000000..5733b5b --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-router-terminal-v6/codex-state-inconsistency-verified.md @@ -0,0 +1,59 @@ +# State Inconsistency Audit — Verified Findings + +## Coupled State Dependency Map +- `defaultTerminal` ↔ `_terminalOf[projectId]` ↔ `hasLockedTerminal[projectId]` +- Router in-flight input balance ↔ post-swap leftover refund path +- Caller context (`_msgSender()`) ↔ refund recipient on registry-mediated routes + +## Mutation Matrix +- `defaultTerminal`: `setDefaultTerminal`, `disallowTerminal` +- `_terminalOf[projectId]`: `setTerminalFor`, `lockTerminalFor` +- `hasLockedTerminal[projectId]`: `lockTerminalFor` +- In-flight router balances: `_acceptFundsFor`, `uniswapV3SwapCallback`, `_handleSwap`, `_transferFrom` + +## Parallel Path Comparison +- Direct router call vs. registry-mediated router call: + - ERC20 partial fill: both paths leave unused ERC20 input in the router because `_handleSwap` never enters its refund branch. + - Native partial fill: direct router call can refund the user, but registry-mediated call resolves `_msgSender()` to the registry and reverts on the ETH refund. + +## Verification Summary +| ID | Coupled Pair | Breaking Op | Original Severity | Verdict | Final Severity | +|----|-------------|-------------|-------------------|---------|----------------| +| SI-001 | caller context ↔ refund recipient | `JBRouterTerminalRegistry.pay` → `JBRouterTerminal._handleSwap` | MEDIUM | TRUE POSITIVE | MEDIUM | + +## Verified Findings + +### Finding SI-001: Registry forwarding breaks the router’s native-leftover refund invariant +**Severity:** MEDIUM +**Verification:** Hybrid + +**Coupled Pair:** Original payer ↔ leftover refund recipient +**Invariant:** A partial-fill refund must return unused input to the payer who funded the swap. + +**Breaking Operation:** `pay()` in `JBRouterTerminalRegistry` forwards into `JBRouterTerminal.pay()` +- Modifies State A: the registry becomes the router’s `msg.sender`. +- Does NOT update State B: no original payer is forwarded for refund purposes. + +**Trigger Sequence:** +1. User pays through the registry using native ETH. +2. The router executes a partial-fill swap. +3. `_handleSwap` refunds native leftovers to `_msgSender()`, which is now the registry. +4. `_transferFrom` attempts `Address.sendValue` to the registry and the refund reverts. + +**Consequence:** +- Registry-mediated native partial fills are unavailable. +- The refund invariant holds for direct router callers but not for the registry path. + +**Fix:** +Thread an explicit refund recipient through the registry forwarding path and use it instead of `_msgSender()` for leftover refunds. + +## False Positives Eliminated +- No unresolved-terminal state desync was verified. `addToBalanceOf` reverts before any silent value loss occurs when no terminal is configured. + +## Summary +- Coupled state pairs mapped: 3 +- Mutation paths analyzed: 10 +- Raw findings (pre-verification): 2 +- After verification: 1 TRUE POSITIVE | 1 FALSE POSITIVE +- Final: 0 CRITICAL | 0 HIGH | 1 MEDIUM | 0 LOW + diff --git a/.audit-findings-codex-prev-20260323-211452/nana-suckers-v6/codex-feynman-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-suckers-v6/codex-feynman-verified.md new file mode 100644 index 0000000..6ae6b0b --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-suckers-v6/codex-feynman-verified.md @@ -0,0 +1,100 @@ +# Feynman Audit — Verified Findings + +## Scope +- Language: Solidity +- Modules analyzed: 47 Solidity files in `src/` and `script/` +- Functions analyzed: 198 +- Lines interrogated: focused full-pass on all entrypoints, deep-pass on bridge transport and deployment scripts + +## Verification Summary +| ID | Original Severity | Verdict | Final Severity | +|----|-------------------|---------|----------------| +| FF-001 | HIGH | TRUE POSITIVE | HIGH | +| FF-002 | MEDIUM | TRUE POSITIVE | MEDIUM | + +## Verified Findings + +### Finding FF-001: Arbitrum L2 fee handling bricks `toRemote()` +**Severity:** HIGH +**Module:** `JBArbitrumSucker` / `JBSucker` +**Function:** `toRemote()` -> `_sendRootOverAMB()` -> `_toL1()` +**Lines:** `src/JBSucker.sol:L683-L716`, `src/JBArbitrumSucker.sol:L146-L186` +**Verification:** Hybrid — code trace + PoC (`test/audit/ArbitrumL2ToRemoteFeeDoS.t.sol`) + +**Feynman Question that exposed this:** +> Why does the L2 bridge path re-read raw `msg.value` after `toRemote()` already derived and passed a bridge-specific `transportPayment`? + +**Why this is wrong:** +`JBSucker.toRemote()` always starts from the caller's raw `msg.value`, peels off `REGISTRY.toRemoteFee()`, and forwards only the remainder as `transportPayment`. That is the value each bridge implementation is supposed to reason about. +The Arbitrum L2 path does not use that derived value. `_sendRootOverAMB()` dispatches into `_toL1()`, and `_toL1()` rejects any non-zero raw `msg.value`. Because internal calls preserve the original callvalue, a non-zero registry fee means `_toL1()` always sees `msg.value > 0` and reverts before sending the message. + +**Verification evidence:** +- Code trace: + - `JBSucker.toRemote()` computes `transportPayment = msg.value - _toRemoteFee` and calls `_sendRoot(...)`. + - `JBArbitrumSucker._sendRootOverAMB()` passes control to `_toL1(...)` on L2 without forwarding `transportPayment`. + - `JBArbitrumSucker._toL1()` reverts on `if (msg.value != 0)`. +- PoC: + - `forge test --match-path test/audit/ArbitrumL2ToRemoteFeeDoS.t.sol -vvv` + - Result: `test_toRemoteRevertsOnArbitrumL2WhenRegistryFeeIsNonZero()` passes by proving the revert path with `JBSucker_UnexpectedMsgValue(1)`. + +**Attack / trigger scenario:** +1. The registry owner sets `toRemoteFee > 0` (default deployment does this). +2. A user prepares a bridge leaf on Arbitrum L2. +3. The user calls `toRemote{value: fee}(...)`. +4. `toRemote()` deducts the fee conceptually, but `_toL1()` still sees the original non-zero callvalue and reverts. +5. All L2->L1 bridge sends are unavailable until the global fee is reset to zero or code is upgraded/redeployed. + +**Impact:** +- Permanent functional DoS for one bridge direction under a normal fee configuration. +- Prepared leaves remain unsent, pushing users into deprecation/emergency-hatch recovery instead of normal bridging. + +**Suggested fix:** +Use `transportPayment`, not raw `msg.value`, in the Arbitrum L2 path. `_toL1()` should either: +- take `transportPayment` explicitly and require it to be zero, or +- rely solely on the already-validated caller-facing logic in `toRemote()`. + +### Finding FF-002: v6 deployment automation still points at the v5 namespace +**Severity:** MEDIUM +**Module:** `Deploy.s.sol`, `SuckerDeploymentLib.sol`, package scripts +**Function:** deployment/artifact resolution +**Lines:** `script/Deploy.s.sol:L53-L57`, `script/helpers/SuckerDeploymentLib.sol:L50-L91`, `package.json:L15-L18` +**Verification:** Code trace + +**Feynman Question that exposed this:** +> Why is a v6 repository still proposing deployments and reading deployment files from the `nana-suckers-v5` namespace? + +**Why this is wrong:** +The repo package metadata and artifact script are v6, but the actual deploy script and helper library are hardcoded to v5. That splits the deployment toolchain into two different namespaces: +- proposals are submitted under `nana-suckers-v5`, +- helper lookups read JSON from `nana-suckers-v5/...`, +- artifact retrieval asks Sphinx for `nana-suckers-v6`. + +That mismatch means automation can read stale v5 addresses, fail to find fresh v6 deployments, or publish/fetch artifacts from different logical projects. + +**Verification evidence:** +- `script/Deploy.s.sol` sets `sphinxConfig.projectName = "nana-suckers-v5"`. +- `script/helpers/SuckerDeploymentLib.sol` resolves registry/deployer JSON files under `projectName: "nana-suckers-v5"`. +- `package.json` defines `npm run artifacts` with `--project-name 'nana-suckers-v6'`. + +**Trigger scenario:** +1. Operators deploy from this v6 repo using `npm run deploy:*`. +2. Sphinx proposal/deployment metadata is recorded under the v5 namespace. +3. Later automation or scripts fetch artifacts under v6, or helper code reads v5 JSON files. +4. Operators resolve missing or stale addresses and can configure or integrate against the wrong contract set. + +**Impact:** +- Misdeployment / stale-address risk in an in-scope deployment path. +- Subsequent registry/deployer usage can point at outdated contracts, which is especially dangerous for peer-sensitive bridge deployments. + +**Suggested fix:** +Rename all hardcoded deployment namespaces in the v6 repo to `nana-suckers-v6`, and keep `package.json`, `Deploy.s.sol`, and `SuckerDeploymentLib.sol` consistent. + +## False Positives Eliminated +- Non-sequential inbox nonce acceptance is intentional and safe under append-only merkle roots. +- Emergency-exit bitmap key separation does not present a practical collision path. + +## Summary +- Total functions analyzed: 198 +- Raw findings (pre-verification): 1 HIGH | 1 MEDIUM +- After verification: 2 TRUE POSITIVE | 0 FALSE POSITIVE | 0 DOWNGRADED +- Final: 1 HIGH | 1 MEDIUM diff --git a/.audit-findings-codex-prev-20260323-211452/nana-suckers-v6/codex-nemesis-raw.md b/.audit-findings-codex-prev-20260323-211452/nana-suckers-v6/codex-nemesis-raw.md new file mode 100644 index 0000000..03d3151 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-suckers-v6/codex-nemesis-raw.md @@ -0,0 +1,82 @@ +# N E M E S I S — Raw Findings + +## Scope +- Language: Solidity 0.8.26 +- Files reviewed: 47 Solidity files under `src/` and `script/` +- Functions/constructors reviewed: 198 +- Fresh round: no `.audit/findings/*` inputs were read + +## Phase 0 Recon + +### Attack Goals +1. Mint bridged project tokens without matching backing assets. +2. Double-claim or double-spend a merkle leaf across chains or emergency exit paths. +3. Brick a bridge direction so prepared funds become stuck or operations become unavailable. +4. Misdeploy or misconfigure peers/deployers so cross-chain messages point at stale or wrong contracts. + +### Novel / High-Bug-Density Areas +- `src/JBSucker.sol`: custom merkle inbox/outbox accounting, emergency exit, fee handling, deprecation lifecycle. +- `src/JBArbitrumSucker.sol`: non-atomic two-ticket transport and layer-dependent bridge logic. +- `src/JBCCIPSucker.sol`: CCIP router receive/send flow and wrapped-native handling. +- `script/Deploy.s.sol` and `script/helpers/SuckerDeploymentLib.sol`: hardcoded project/deployment namespace logic. + +### Value Stores / Initial Coupling Hypotheses +- `JBSucker._outboxOf[token]` + - Outflows: `_sendRoot`, `exitThroughEmergencyHatch` + - Coupled state: contract token/native balance, `numberOfClaimsSent`, `tree.count`, `nonce` +- `JBSucker._inboxOf[token]` + - Outflows: `claim` + - Coupled state: `_executedFor[token]`, authenticated remote root ordering +- `JBSucker._remoteTokenFor[token]` + - Outflows: `prepare`, `toRemote`, `mapToken(s)`, emergency hatch + - Coupled state: `enabled`, `emergencyHatch`, `addr`, `minGas` +- `JBSuckerRegistry.toRemoteFee` + - Outflows: `JBSucker.toRemote` + - Coupled state: bridge-specific transport expectations + +## Phase 1 Nemesis Map (condensed) + +| Function | Writes | Coupled Counterpart | Status | +|---|---|---|---| +| `JBSucker.prepare` | `_outboxOf[token].tree`, `_outboxOf[token].balance` | contract balance, mapping enabled flag | synced | +| `JBSucker.toRemote` | fee payment side effect, `_sendRoot` path | `transportPayment`, bridge-specific fee expectations | suspect | +| `JBSucker._sendRoot` | `outbox.balance=0`, `outbox.nonce++`, `numberOfClaimsSent=count` | actual bridge transfer, AMB message | synced by design | +| `JBArbitrumSucker._sendRootOverAMB` | consumes `transportPayment` | `_toL1` / `_toL2` payment checks | gap on L2 | +| `JBArbitrumSucker._toL1` | reads raw `msg.value` | post-fee `transportPayment` | gap | +| `DeployScript.configureSphinx` | deployment namespace | package scripts / helper lookup namespace | suspect | +| `SuckerDeploymentLib.getDeployment` | deployment file path selection | Sphinx project name / artifact namespace | gap | + +## Raw Findings + +### NM-RAW-001 +- Severity: HIGH +- Title: Arbitrum L2 `toRemote()` reverts whenever the global registry fee is non-zero +- Affected code: + - `src/JBSucker.sol:L683-L716` + - `src/JBArbitrumSucker.sol:L146-L186` +- Hypothesis: + - `JBSucker.toRemote()` derives `transportPayment = msg.value - toRemoteFee`, but the Arbitrum L2 path ignores that derived value and re-checks raw `msg.value` inside `_toL1`. + - Any non-zero `toRemoteFee` therefore causes `_toL1` to revert with `JBSucker_UnexpectedMsgValue`, blocking all L2->L1 sends. + +### NM-RAW-002 +- Severity: MEDIUM +- Title: Deployment tooling still targets the v5 Sphinx/deployment namespace inside the v6 repo +- Affected code: + - `script/Deploy.s.sol:L53-L57` + - `script/helpers/SuckerDeploymentLib.sol:L50-L91` + - `package.json:L15-L18` +- Hypothesis: + - The deploy script proposes under `nana-suckers-v5`, helper lookups read `nana-suckers-v5`, but package artifact retrieval uses `nana-suckers-v6`. + - This can resolve stale or missing deployment metadata and point operators at the wrong registry/deployer set. + +## Raw Suspects Eliminated During Verification Queueing +- `fromRemote()` nonce-gap acceptance: + - append-only merkle roots make skipped roots provable against later roots; not a local auth or double-claim bug. +- Emergency-exit bitmap slot collision: + - requires a practical preimage collision against a 160-bit truncated keccak-derived slot; not realistically reachable. +- CCIP delivered-amount skip: + - not independently exploitable from this codebase without assuming CCIP itself violates delivery guarantees. + +## Verification Queue +- `NM-RAW-001`: Hybrid verification with dedicated Foundry PoC. +- `NM-RAW-002`: Deep code trace across deploy script, helper library, and package scripts. diff --git a/.audit-findings-codex-prev-20260323-211452/nana-suckers-v6/codex-nemesis-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-suckers-v6/codex-nemesis-verified.md new file mode 100644 index 0000000..8b80825 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-suckers-v6/codex-nemesis-verified.md @@ -0,0 +1,136 @@ +# N E M E S I S — Verified Findings + +## Scope +- Language: Solidity +- Modules analyzed: 47 Solidity files in `src/` and `script/` +- Functions analyzed: 198 +- Coupled state pairs mapped: 12 +- Mutation paths traced: 31 +- Nemesis loop iterations: 4 passes total (Feynman full -> State full -> Feynman targeted -> State targeted) + +## Nemesis Map (Phase 1 Cross-Reference) +| Function | Coupled Pair / Invariant | Result | +|----|----|----| +| `JBSucker.prepare()` | outbox tree count ↔ outbox balance ↔ contract balance | cleared | +| `JBSucker.claim()` | inbox root ↔ executed bitmap ↔ add-to-balance amount | cleared | +| `JBSucker.exitThroughEmergencyHatch()` | outbox root ↔ emergency bitmap ↔ `numberOfClaimsSent` bound | cleared | +| `JBSucker.toRemote()` | fee extraction ↔ bridge transport budget | **feeds NM-001** | +| `JBArbitrumSucker._toL1()` | downstream bridge path ↔ derived transport budget | **gap** | +| `DeployScript.configureSphinx()` | deployment namespace ↔ helper/artifact namespace | **feeds NM-002** | +| `SuckerDeploymentLib.getDeployment()` | helper lookup namespace ↔ actual deployment namespace | **gap** | + +## Verification Summary +| ID | Source | Coupled Pair | Breaking Op | Severity | Verdict | +|----|--------|-------------|-------------|----------|---------| +| NM-001 | Cross-feed P1->P2 | `msg.value` ↔ `transportPayment` | `JBArbitrumSucker._toL1()` | HIGH | TRUE POS | +| NM-002 | Feynman-only | deployment namespace ↔ helper/artifact namespace | `DeployScript.configureSphinx()` / `SuckerDeploymentLib.getDeployment()` | MEDIUM | TRUE POS | + +## Verified Findings + +### Finding NM-001: Non-zero registry fees permanently disable Arbitrum L2 -> L1 sends +**Severity:** HIGH +**Source:** Cross-feed P1->P2 +**Verification:** Hybrid + +**Coupled Pair:** raw `msg.value` ↔ derived `transportPayment` +**Invariant:** bridge-specific transport logic must consume the post-fee value derived by `JBSucker.toRemote()`, not the stale external callvalue. + +**Feynman Question that exposed it:** +> Why is `_toL1()` checking raw `msg.value` after `toRemote()` already separated fee collection from bridge transport? + +**State Mapper gap that confirmed it:** +> `JBSucker.toRemote()` mutates the transport budget (`transportPayment`) but the Arbitrum L2 send path never reads that updated value and instead consults stale raw callvalue. + +**Breaking Operation:** `JBArbitrumSucker._toL1()` at `src/JBArbitrumSucker.sol:L180-L186` +- Modifies / depends on State A: bridge send path selected after fee handling +- Does NOT update / honor State B: ignores `transportPayment`, rejects non-zero raw `msg.value` + +**Trigger Sequence:** +1. Project leaves are prepared on Arbitrum L2. +2. Registry fee is non-zero (default deployment uses a non-zero fee). +3. User calls `toRemote{value: fee}(token)`. +4. `JBSucker.toRemote()` computes `transportPayment = msg.value - fee`. +5. L2 dispatch reaches `_toL1()`, which re-checks stale raw `msg.value`. +6. `_toL1()` reverts with `JBSucker_UnexpectedMsgValue`, so the root is never sent. + +**Consequence:** +- L2->L1 bridging is unavailable whenever the global registry fee is non-zero. +- Prepared funds remain local and normal bridging halts until governance changes the fee or new contracts are deployed. + +**Verification Evidence:** +- Code trace: + - `src/JBSucker.sol:L683-L716` + - `src/JBArbitrumSucker.sol:L146-L186` +- PoC: + - `forge test --match-path test/audit/ArbitrumL2ToRemoteFeeDoS.t.sol -vvv` + - Result: `test_toRemoteRevertsOnArbitrumL2WhenRegistryFeeIsNonZero()` passes. + +**Fix:** +```solidity +// Thread the already-derived transportPayment into _toL1 and validate that. +if (transportPayment != 0) revert JBSucker_UnexpectedMsgValue(transportPayment); +``` + +--- + +### Finding NM-002: Deployment automation is split between v5 and v6 namespaces +**Severity:** MEDIUM +**Source:** Feynman-only +**Verification:** Code trace + +**Coupled Pair:** deployment namespace constant ↔ deployment-file / artifact lookup namespace +**Invariant:** the repo's deploy script, helper library, and package tooling must resolve the same logical deployment project. + +**Feynman Question that exposed it:** +> Why does a v6 repo still publish proposals and read deployment JSON from `nana-suckers-v5` while the package artifact task requests `nana-suckers-v6`? + +**State Mapper gap that confirmed it:** +> The namespace written by `DeployScript.configureSphinx()` is not the namespace read by `package.json` artifact automation or the helper lookup path. + +**Breaking Operation:** deployment metadata resolution in: +- `script/Deploy.s.sol:L53-L57` +- `script/helpers/SuckerDeploymentLib.sol:L50-L91` +- `package.json:L15-L18` + +**Trigger Sequence:** +1. Operator deploys from this v6 repo. +2. Sphinx proposal/deployment data is created under `nana-suckers-v5`. +3. Artifact retrieval or helper-based address resolution looks for a different namespace (`v6` in `package.json`, `v5` in helper code). +4. Tooling resolves stale v5 addresses or fails to resolve the new deployment set. + +**Consequence:** +- Operators can configure projects against the wrong registry/deployer addresses. +- Peer-sensitive bridge deployments can be misconfigured from stale deployment metadata, risking stuck or misrouted bridge traffic. + +**Verification Evidence:** +- `DeployScript.configureSphinx()` hardcodes `nana-suckers-v5`. +- `SuckerDeploymentLib.getDeployment()` reads every JSON artifact from `nana-suckers-v5`. +- `package.json` artifact command requests `nana-suckers-v6`, proving the toolchain is inconsistent inside the same repo. + +**Fix:** +```solidity +sphinxConfig.projectName = "nana-suckers-v6"; +``` +and update all helper lookups to the same namespace. + +## Feedback Loop Discoveries +- `NM-001` only became high-confidence after the state pass reframed the issue as a stale derived-value coupling: + - Feynman pass flagged the suspicious ordering/value flow in `toRemote()`. + - State pass confirmed the exact gap: the updated bridge budget (`transportPayment`) is never consumed on the Arbitrum L2 branch. + +## False Positives Eliminated +- `fromRemote()` accepting nonce gaps: + - append-only trees make earlier leaves provable against later roots; no verified fund-loss path. +- Emergency-exit bitmap slot collision: + - no practical path to collide with a real token address and bypass replay protection. +- CCIP amount validation skip: + - not independently exploitable from this codebase without assuming a compromised CCIP router. + +## Summary +- Total functions analyzed: 198 +- Coupled state pairs mapped: 12 +- Nemesis loop iterations: 4 passes +- Raw findings (pre-verification): 0 CRITICAL | 1 HIGH | 1 MEDIUM | 0 LOW +- Feedback loop discoveries: 1 +- After verification: 2 TRUE POSITIVE | 0 FALSE POSITIVE | 0 DOWNGRADED +- Final: 0 CRITICAL | 1 HIGH | 1 MEDIUM | 0 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/nana-suckers-v6/codex-state-inconsistency-verified.md b/.audit-findings-codex-prev-20260323-211452/nana-suckers-v6/codex-state-inconsistency-verified.md new file mode 100644 index 0000000..8638a49 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/nana-suckers-v6/codex-state-inconsistency-verified.md @@ -0,0 +1,88 @@ +# State Inconsistency Audit — Verified Findings + +## Coupled State Dependency Map +- `raw msg.value` ↔ `transportPayment` + - Invariant: once `toRemote()` derives the bridge budget after fee extraction, downstream bridge code must reason about that derived value instead of the stale raw callvalue. +- `_outboxOf[token].balance` ↔ contract token/native balance +- `_outboxOf[token].tree.count` ↔ `_outboxOf[token].numberOfClaimsSent` +- `_inboxOf[token].root` ↔ `_inboxOf[token].nonce` +- `_executedFor[token]` ↔ inbox root claims +- `_executedFor[emergencySlot]` ↔ outbox root emergency exits +- deployment namespace constant ↔ deployment file lookup namespace + +## Mutation Matrix +- `transportPayment` + - Mutated in `JBSucker.toRemote()` + - Expected consumer updates: all bridge implementations + - Actual inconsistent consumer: `JBArbitrumSucker._toL1()` re-reads `msg.value` +- deployment namespace + - Mutated/declared in `DeployScript.configureSphinx()` + - Expected counterpart: helper lookup namespace and package artifact namespace + - Actual inconsistent consumers: `SuckerDeploymentLib` (v5) vs `package.json` artifacts (v6) + +## Parallel Path Comparison +| Coupled State | OP / Base path | CCIP path | Arbitrum L2 path | +|---|---|---|---| +| `transportPayment` vs raw callvalue | uses `transportPayment` | uses `transportPayment` | **gap: uses raw `msg.value`** | + +## Verification Summary +| ID | Coupled Pair | Breaking Op | Original Severity | Verdict | Final Severity | +|----|-------------|-------------|-------------------|---------|----------------| +| SI-001 | `msg.value` ↔ `transportPayment` | `JBArbitrumSucker._toL1()` | HIGH | TRUE POSITIVE | HIGH | + +## Verified Findings + +### Finding SI-001: Arbitrum L2 consumes stale callvalue instead of the post-fee bridge budget +**Severity:** HIGH +**Verification:** Hybrid + +**Coupled Pair:** raw `msg.value` ↔ derived `transportPayment` +**Invariant:** once the fee is split out in `JBSucker.toRemote()`, downstream bridge logic must only enforce the remaining transport budget. + +**Breaking Operation:** `JBArbitrumSucker._toL1()` in `src/JBArbitrumSucker.sol:L180-L186` +- Modifies / depends on State A: downstream bridge send path chosen by `transportPayment` +- Does NOT update or honor State B: it ignores the already-derived `transportPayment` and checks stale `msg.value` + +**Trigger Sequence:** +1. Registry fee is set to any non-zero value. +2. User calls `toRemote{value: fee}(...)` on Arbitrum L2. +3. `JBSucker.toRemote()` derives `transportPayment = 0`. +4. Arbitrum L2 dispatch reaches `_toL1()`. +5. `_toL1()` sees stale raw `msg.value == fee` and reverts. + +**Consequence:** +- L2->L1 sends are blocked whenever the configured fee is non-zero. +- The fee split and bridge transport path diverge, so the bridge direction becomes unavailable despite valid outbox state. + +**Verification Evidence:** +- Code trace: + - `src/JBSucker.sol:L683-L716` + - `src/JBArbitrumSucker.sol:L146-L186` +- PoC: + - `forge test --match-path test/audit/ArbitrumL2ToRemoteFeeDoS.t.sol -vvv` + - Pass result confirms `JBSucker_UnexpectedMsgValue(1)`. + +**Fix:** +```solidity +function _toL1( + address token, + uint256 amount, + bytes memory data, + JBRemoteToken memory remoteToken, + uint256 transportPayment +) internal { + if (transportPayment != 0) revert JBSucker_UnexpectedMsgValue(transportPayment); + ... +} +``` + +## False Positives Eliminated +- No hidden reconciliation updates `transportPayment` inside the Arbitrum L2 branch. +- No lazy-evaluation pattern makes the raw `msg.value` check intentional; OP/Base and CCIP already consume the derived transport budget directly. + +## Summary +- Coupled state pairs mapped: 7 +- Mutation paths analyzed: 31 +- Raw findings (pre-verification): 1 +- After verification: 1 TRUE POSITIVE | 0 FALSE POSITIVE +- Final: 0 CRITICAL | 1 HIGH | 0 MEDIUM | 0 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/revnet-core-v6/codex-feynman-verified.md b/.audit-findings-codex-prev-20260323-211452/revnet-core-v6/codex-feynman-verified.md new file mode 100644 index 0000000..c4658da --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/revnet-core-v6/codex-feynman-verified.md @@ -0,0 +1,95 @@ +# Feynman Audit — Verified Findings + +## Scope +- Language: Solidity 0.8.26 +- Files analyzed: 17 +- Functions analyzed: 117 +- Priority targets: `src/REVDeployer.sol`, `src/REVLoans.sol`, `script/Deploy.s.sol` + +## Verification Summary +| ID | Original Severity | Verdict | Final Severity | +|----|-------------------|---------|----------------| +| FF-001 | HIGH | TRUE POSITIVE | HIGH | +| FF-002 | LOW | TRUE POSITIVE | LOW | + +## Verified Findings + +### Finding FF-001: Buyback cash-out path remints and sells the fee tranche that REVDeployer intended to burn +**Severity:** HIGH +**Module:** `src/REVDeployer.sol` + dependency boundary with core/buyback hook +**Functions:** `REVDeployer.beforeCashOutRecordedWith`, `JBMultiTerminal._cashOutTokensOf`, `JBBuybackHook.afterCashOutRecordedWith` +**Lines:** `src/REVDeployer.sol:302-331`, `node_modules/@bananapus/core-v6/src/JBMultiTerminal.sol:1074-1086`, `node_modules/@bananapus/core-v6/src/JBMultiTerminal.sol:1118-1133`, `node_modules/@bananapus/buyback-hook-v6/src/JBBuybackHook.sol:210-226` +**Verification:** Hybrid +PoC: `forge test --match-path test/regression/TestCashOutBuybackFeeBypass.t.sol -vvv` + +**Feynman question that exposed this:** +Why does the data hook reduce `cashOutCount` for the buyback path, but the downstream cash-out hook still receive the original count? + +**Why this is wrong:** +`REVDeployer.beforeCashOutRecordedWith` splits the user’s burn into: +- a non-fee tranche, passed to the buyback hook via `buybackHookContext.cashOutCount = nonFeeCashOutCount` +- a fee tranche, converted into a treasury-side `feeAmount` hook spec + +That logic assumes the buyback hook callback will only execute on the non-fee tranche. But the core terminal does not propagate the modified `cashOutCount` into the callback. `JBMultiTerminal` burns and forwards the original function argument `cashOutCount`, and `JBBuybackHook.afterCashOutRecordedWith` remints and sells `context.cashOutCount`. + +So when the buyback route is chosen: +1. REVDeployer computes fees as if only `nonFeeCashOutCount` should benefit the user. +2. JBMultiTerminal still burns the full original count. +3. JBBuybackHook remints and sells the full original count for the beneficiary. +4. The fee hook separately still extracts `feeAmount` from project surplus. + +The user therefore monetizes the fee tranche through the pool sale, while the project still pays the fee to the fee revnet. The fee stops being user-paid and becomes treasury-paid. + +**Verification evidence:** +- Code trace: + - `REVDeployer` shrinks the count before delegating to buyback: `src/REVDeployer.sol:302-309` + - `JBMultiTerminal` burns the original `cashOutCount`: `node_modules/@bananapus/core-v6/src/JBMultiTerminal.sol:1083-1086` + - `JBMultiTerminal` passes the original `cashOutCount` into cash-out hook fulfillment: `node_modules/@bananapus/core-v6/src/JBMultiTerminal.sol:1121-1125` + - `JBBuybackHook` remints and swaps `context.cashOutCount`: `node_modules/@bananapus/buyback-hook-v6/src/JBBuybackHook.sol:210-226` +- PoC: + - `test/regression/TestCashOutBuybackFeeBypass.t.sol` + - The test proves `REVDeployer.beforeCashOutRecordedWith` returns the reduced non-fee count, but the downstream hook callback receives the full original count. + +**Impact:** +- Conditional value loss for any revnet using the buyback sell path on cash-out. +- The cash-out fee is effectively charged to project surplus instead of to the exiting holder. +- Repeated sell-side exits leak extra value to exiting users and erode the treasury beyond intended fee semantics. + +**Suggested fix:** +Either: +- make the fee logic operate on forwarded reclaim value instead of on a split token count, or +- ensure the downstream sell hook receives the reduced non-fee `cashOutCount`, not the original terminal argument. + +### Finding FF-002: Sphinx deployment script always misses prior deployments and always creates a fresh fee project +**Severity:** LOW +**Module:** `script/Deploy.s.sol` +**Function:** `deploy`, `_isDeployed` +**Lines:** `script/Deploy.s.sol:344-410`, `script/Deploy.s.sol:413-425` +**Verification:** Code trace + +**Feynman question that exposed this:** +Why does `deploy()` create a new fee project before the script has proven whether the contracts already exist? + +**Why this is wrong:** +`deploy()` eagerly executes `core.projects.createFor(...)` at line 347, then uses `_isDeployed` to decide whether to reuse or redeploy `REVLoans` and `REVDeployer`. But `_isDeployed` deliberately computes the address against Arachnid’s deterministic deployer, while the script actually runs under Sphinx. The script itself documents that this prediction “will always return false when deploying via Sphinx.” + +That means rerunning the script is not idempotent: +1. A fresh fee project NFT is always created. +2. Both contracts are treated as undeployed. +3. A new fee sink can be introduced even when an older deployment already exists. + +**Verification evidence:** +- Project creation happens before any deployment check: `script/Deploy.s.sol:344-355` +- The helper explicitly states `_isDeployed` is always false under Sphinx: `script/Deploy.s.sol:422-425` + +**Impact:** +- Operational deployment risk in-scope for `script/`. +- Replay/reproposal can silently fork fee routing into a new fee project and a new contract set. + +## False Positives Eliminated +- “Source-fee refund in `REVLoans._adjust` is borrower-controlled and trivially exploitable” was eliminated. The refund path is real, but the testable control point is an already-broken or malicious source terminal configuration, which is a trusted setup issue rather than an unprivileged exploit path in this repo. + +## Summary +- Raw findings reviewed: 3 +- After verification: 2 true positives, 1 false positive +- Final: 1 HIGH, 1 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/revnet-core-v6/codex-nemesis-raw.md b/.audit-findings-codex-prev-20260323-211452/revnet-core-v6/codex-nemesis-raw.md new file mode 100644 index 0000000..e498925 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/revnet-core-v6/codex-nemesis-raw.md @@ -0,0 +1,43 @@ +# N E M E S I S — Raw Findings + +## Phase 0 Recon +- Language: Solidity +- Attack goals: + 1. Turn treasury-side fees into user-side value during cash-out / borrow flows. + 2. Break loan accounting so borrowers can overdraw or avoid fee accrual. + 3. Misdeploy revnet infrastructure so new treasuries or fee sinks are silently miswired. +- Novel / high-density code: + - `src/REVDeployer.sol`: custom pay/cash-out composition across 721 hook, buyback hook, and fee hook. + - `src/REVLoans.sol`: loan accounting around virtual surplus/supply and multi-source debt normalization. + - `script/Deploy.s.sol`: one-shot ecosystem deployment with hardwired fee project creation. +- Value stores: + - Revnet terminal balances in `JBMultiTerminal` / `JBTerminalStore` + - Loan accounting in `totalBorrowedFrom`, `totalCollateralOf`, `_loanOf` + - Fee sink revnet configured by deployment script +- Priority targets: + 1. `REVDeployer.beforeCashOutRecordedWith` + 2. `REVLoans._adjust`, `_addTo`, `_totalBorrowedFrom` + 3. `DeployScript.deploy` + +## Pass 1 — Feynman Suspects +1. `REVDeployer.beforeCashOutRecordedWith` splits `cashOutCount` for fee accounting, but core hooks may still consume the original count. +2. `REVLoans._adjust` refunds source fees on `terminal.pay` failure; candidate borrower fee bypass. +3. `DeployScript._isDeployed` appears incompatible with Sphinx runtime deployment. + +## Pass 2 — State Cross-Check +1. Confirmed gap: reduced count passed into buyback route is not the same count consumed in the callback path. +2. Confirmed deployment-state mismatch: fee project creation is not synchronized with deployment reuse detection. +3. Source-fee refund path appears dependent on already-broken source-terminal behavior, not an unprivileged state gap. + +## Targeted Re-Interrogation +- Why is the cash-out fee gap real? + - Because `REVDeployer` mutates the count at the data-hook boundary, but `JBMultiTerminal` later forwards the original function argument into the hook callback boundary. +- What breaks downstream? + - `JBBuybackHook.afterCashOutRecordedWith` remints and sells the full original count. + +## Raw Finding Set +| ID | Severity | Status | Notes | +|----|----------|--------|-------| +| NM-RAW-001 | HIGH | Verified | Buyback cash-out fee tranche sold for beneficiary due callback-count mismatch | +| NM-RAW-002 | LOW | Verified | Sphinx deployment idempotence broken; fresh fee project always created | +| NM-RAW-003 | MEDIUM | Eliminated | Source-fee refund path requires trusted/broken source-terminal configuration | diff --git a/.audit-findings-codex-prev-20260323-211452/revnet-core-v6/codex-nemesis-verified.md b/.audit-findings-codex-prev-20260323-211452/revnet-core-v6/codex-nemesis-verified.md new file mode 100644 index 0000000..397e978 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/revnet-core-v6/codex-nemesis-verified.md @@ -0,0 +1,108 @@ +# N E M E S I S — Verified Findings + +## Scope +- Language: Solidity +- Modules analyzed: `src/**/*.sol`, `script/**/*.sol` +- Files analyzed: 17 +- Functions analyzed: 117 +- Coupled state pairs mapped: 3 high-signal pairs +- Mutation paths traced: 8 +- Nemesis loop iterations: 2 full passes + 1 targeted re-pass to convergence + +## Verification Summary +| ID | Source | Coupled Pair | Breaking Op | Severity | Verdict | +|----|--------|-------------|-------------|----------|---------| +| NM-001 | Cross-feed P2→P3 | reduced hook count ↔ callback count | `beforeCashOutRecordedWith()` | HIGH | TRUE POSITIVE | +| NM-002 | Feynman-only | deploy existence check ↔ fee project identity | `DeployScript.deploy()` | LOW | TRUE POSITIVE | + +## Verified Findings + +### Finding NM-001: Buyback cash-out fee bypass turns a user fee into a treasury fee +**Severity:** HIGH +**Source:** Cross-feed P2→P3 +**Verification:** Hybrid + +**Coupled Pair:** reduced `cashOutCount` ↔ callback `context.cashOutCount` +**Invariant:** once REVDeployer excludes the fee tranche from the routed count, no later hook path should execute on that excluded tranche. + +**Feynman question that exposed it:** +Why does the cash-out callback see a different token count than the count REVDeployer just priced? + +**State gap that confirmed it:** +- `src/REVDeployer.sol:302-309` rewrites the buyback path to `nonFeeCashOutCount` +- `node_modules/@bananapus/core-v6/src/JBMultiTerminal.sol:1121-1125` still forwards the original `cashOutCount` + +**Breaking Operation:** `REVDeployer.beforeCashOutRecordedWith()` at `src/REVDeployer.sol:302` +- Modifies State/Flow A: fee split into `feeCashOutCount` and `nonFeeCashOutCount` +- Does NOT keep State/Flow B in sync: downstream callback still uses the original burn amount + +**Trigger Sequence:** +1. A revnet enables the buyback sell route on cash-out. +2. A holder cashes out when the buyback route is selected. +3. REVDeployer computes the fee as if only `nonFeeCashOutCount` benefits the holder. +4. Core burns the full original count and invokes the buyback callback with that original count. +5. Buyback hook remints and sells the full amount for the holder, while the fee hook still charges the treasury-side `feeAmount`. + +**Consequence:** +- Exiting holder monetizes the fee tranche through the pool route. +- Fee revnet still receives `feeAmount`. +- The revnet treasury pays the fee instead of the exiting holder. + +**Verification Evidence:** +- Code trace: + - `src/REVDeployer.sol:302-331` + - `node_modules/@bananapus/core-v6/src/JBMultiTerminal.sol:1074-1086` + - `node_modules/@bananapus/core-v6/src/JBMultiTerminal.sol:1118-1133` + - `node_modules/@bananapus/buyback-hook-v6/src/JBBuybackHook.sol:210-226` +- PoC: + - `test/regression/TestCashOutBuybackFeeBypass.t.sol` + - `forge test --match-path test/regression/TestCashOutBuybackFeeBypass.t.sol -vvv` + - Result: pass + +**Fix:** +```solidity +// One safe direction: ensure the downstream sell hook uses the non-fee count, +// not the terminal's original cashOutCount. +``` + +### Finding NM-002: Sphinx deploy script is non-idempotent and always creates a fresh fee project +**Severity:** LOW +**Source:** Feynman-only +**Verification:** Code trace + +**Coupled Pair:** deployment reuse check ↔ fee project identity +**Invariant:** the script must not mint a fresh fee project before it has correctly established whether the deployment already exists. + +**Breaking Operation:** `deploy()` at `script/Deploy.s.sol:344` +- Creates `FEE_PROJECT_ID` immediately via `core.projects.createFor(...)` +- Relies on `_isDeployed()`, which the script itself documents as always returning false under Sphinx + +**Trigger Sequence:** +1. Operator reruns or reproposes the deployment. +2. Script mints a new fee project ID. +3. `_isDeployed` misses prior deployments because it predicts addresses against Arachnid’s proxy, not Sphinx’s runtime path. +4. Fresh contract instances are deployed/configured against the new fee project. + +**Consequence:** +- Fee routing can fragment across multiple fee projects and contract generations. +- Prior deployments are not safely reused. + +**Verification Evidence:** +- `script/Deploy.s.sol:347` +- `script/Deploy.s.sol:422-425` + +## Feedback Loop Discoveries +- `NM-001` required both perspectives: + - State pass identified the count desynchronization at the hook boundary. + - Feynman re-interrogation traced the downstream consequence into `JBBuybackHook.afterCashOutRecordedWith`. + +## False Positives Eliminated +- Candidate fee-bypass via `REVLoans` source-fee refund path was rejected as a trusted/broken-source-terminal scenario, not an unprivileged exploit in this repo. + +## Summary +- Total functions analyzed: 117 +- Coupled state pairs mapped: 3 +- Nemesis loop iterations: 3 +- Raw findings (pre-verification): 0 critical, 2 high/medium candidates, 1 low candidate +- After verification: 2 true positives, 1 false positive +- Final: 0 CRITICAL, 1 HIGH, 0 MEDIUM, 1 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/revnet-core-v6/codex-state-inconsistency-verified.md b/.audit-findings-codex-prev-20260323-211452/revnet-core-v6/codex-state-inconsistency-verified.md new file mode 100644 index 0000000..b7d2245 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/revnet-core-v6/codex-state-inconsistency-verified.md @@ -0,0 +1,80 @@ +# State Inconsistency Audit — Verified Findings + +## Coupled State Dependency Map +- `cashOutCount` selected by the data hook ↔ `cashOutCount` consumed later by the cash-out hook callback + - Invariant: the callback must execute against the same token count the data hook priced. +- `feeCashOutCount` / `nonFeeCashOutCount` split ↔ beneficiary-facing execution path + - Invariant: the fee tranche must not be sold for the beneficiary after being carved out of the priced count. +- `script` deployment existence check ↔ fee project identity + - Invariant: idempotence logic must refer to the same deployer/address derivation the script actually uses. + +## Mutation Matrix +| State / Derived Value | Mutating Function | Expected Coupled Update | +|---|---|---| +| `buybackHookContext.cashOutCount` | `REVDeployer.beforeCashOutRecordedWith` | Downstream callback must use the same reduced count | +| terminal burn count / callback context count | `JBMultiTerminal._cashOutTokensOf` | Must stay synchronized with the hook-priced count | +| fee project ID in script | `DeployScript.deploy` | Must stay synchronized with actual deployment reuse logic | + +## Verification Summary +| ID | Coupled Pair | Breaking Op | Original Severity | Verdict | Final Severity | +|----|-------------|-------------|-------------------|---------|----------------| +| SI-001 | reduced hook count ↔ callback count | cash out via buyback route | HIGH | TRUE POSITIVE | HIGH | +| SI-002 | deployment reuse check ↔ fee project creation | `DeployScript.deploy()` | LOW | TRUE POSITIVE | LOW | + +## Verified Findings + +### Finding SI-001: REVDeployer prices only the non-fee cash-out tranche, but the buyback callback executes on the full burn amount +**Severity:** HIGH +**Verification:** Hybrid + +**Coupled Pair:** reduced `cashOutCount` ↔ callback `context.cashOutCount` +**Invariant:** the hook callback must consume the same token count that the data hook priced after removing the fee tranche. + +**Breaking Operation:** `REVDeployer.beforeCashOutRecordedWith()` in `src/REVDeployer.sol:302-331` +- Modifies state/flow A: passes only `nonFeeCashOutCount` into the buyback hook decision path. +- Does not update coupled state B: the later callback path in `JBMultiTerminal` still forwards the original `cashOutCount`. + +**Trigger Sequence:** +1. User holds revnet tokens and the revnet has a configured buyback sell path. +2. User cashes out through a route where the buyback hook chooses pool execution over direct reclaim. +3. REVDeployer computes `feeCashOutCount`, `nonFeeCashOutCount`, and `feeAmount`. +4. Core terminal burns the full original count and calls the buyback callback with the full original count. +5. Buyback hook remints/sells the full amount even though fee accounting only priced the non-fee amount. + +**Consequence:** +- The beneficiary receives execution on the fee tranche that should have been excluded. +- The fee project still receives `feeAmount`. +- Treasury-side value is consumed while the user escapes the intended fee haircut. + +**Verification evidence:** +- Code trace across: + - `src/REVDeployer.sol:302-331` + - `node_modules/@bananapus/core-v6/src/JBMultiTerminal.sol:1074-1086` + - `node_modules/@bananapus/core-v6/src/JBMultiTerminal.sol:1118-1133` + - `node_modules/@bananapus/buyback-hook-v6/src/JBBuybackHook.sol:210-226` +- PoC: + - `forge test --match-path test/regression/TestCashOutBuybackFeeBypass.t.sol -vvv` + - Pass result confirms: reduced count before routing, full original count in the callback. + +### Finding SI-002: The deployment script’s existence check is desynchronized from the deployer it actually uses +**Severity:** LOW +**Verification:** Code trace + +**Coupled Pair:** `_isDeployed` prediction ↔ actual Sphinx deployment path +**Invariant:** the idempotence check must derive addresses from the same deployer that the script uses at runtime. + +**Breaking Operation:** `_isDeployed()` in `script/Deploy.s.sol:413-425` +- Modifies state/flow A: uses Arachnid’s deterministic deployment proxy for address prediction. +- Does not update coupled state B: `deploy()` runs under Sphinx and creates a fresh fee project before checking reuse. + +**Consequence:** +- Re-running the deployment script creates new fee-project state and new contract instances instead of reusing the existing deployment. + +## False Positives Eliminated +- Loan-source fee refund path in `REVLoans` as a standalone unprivileged exploit. + +## Summary +- Coupled pairs mapped: 3 +- Mutation paths analyzed: 8 +- Verified: 2 true positives +- Final: 1 HIGH, 1 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/univ4-lp-split-hook-v6/codex-feynman-verified.md b/.audit-findings-codex-prev-20260323-211452/univ4-lp-split-hook-v6/codex-feynman-verified.md new file mode 100644 index 0000000..5588416 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/univ4-lp-split-hook-v6/codex-feynman-verified.md @@ -0,0 +1,108 @@ +# Feynman Audit — Verified Findings + +## Scope +- Language: Solidity 0.8.26 +- Modules analyzed: `src/JBUniswapV4LPSplitHook.sol`, `src/JBUniswapV4LPSplitHookDeployer.sol`, `script/Deploy.s.sol` +- Functions analyzed: 46 +- Lines interrogated: 1803 + +## Verification Summary +| ID | Original Severity | Verdict | Final Severity | +|----|-------------------|---------|----------------| +| FF-001 | HIGH | TRUE POSITIVE | HIGH | + +## Function-State Matrix +High-signal write paths: + +| Function | Writes | Notes | +|----------|--------|-------| +| `collectAndRouteLPFees` | `claimableFeeTokens`, fee-project token balance, project terminal balance | Permissionless fee harvest path | +| `deployPool` | `deployedPoolCount`, `_poolKeys`, `tokenIdOf`, `accumulatedProjectTokens` | First-stage to live-pool transition | +| `processSplitWith` | `accumulatedProjectTokens` or fee-project token burn path | Switches from accumulate to burn mode | +| `rebalanceLiquidity` | `tokenIdOf` via burn/remint, fee routing side effects | Reuses shared balances mid-flight | +| `claimFeeTokensFor` | `claimableFeeTokens` | Assumes fee-token backing is still present | + +## Verified Findings + +### Finding FF-001: HIGH — Fee project operations can destroy or consume fee tokens owed to other projects +**Severity:** HIGH +**Module:** `JBUniswapV4LPSplitHook` +**Function:** `deployPool`, `processSplitWith`, `_burnReceivedTokens`, `_handleLeftoverTokens`, `_routeFeesToProject`, `claimFeeTokensFor` +**Lines:** `src/JBUniswapV4LPSplitHook.sol:497`, `src/JBUniswapV4LPSplitHook.sol:758`, `src/JBUniswapV4LPSplitHook.sol:861`, `src/JBUniswapV4LPSplitHook.sol:1173`, `src/JBUniswapV4LPSplitHook.sol:1442` +**Verification:** Hybrid — code trace + Foundry PoC (`test/audit/FeeProjectSelfBurnPoC.t.sol`) + +**Feynman Question that exposed this:** +> Why is it safe for fee tokens owed to project A to live in the same ERC-20 balance that project B later treats as “all of my project tokens”? + +**The code:** +```solidity +claimableFeeTokens[projectId] += beneficiaryTokenCount; + +uint256 projectTokenAmount = IERC20(projectToken).balanceOf(address(this)); + +uint256 projectTokenBalance = IERC20(projectToken).balanceOf(address(this)); + +uint256 projectTokenLeftover = IERC20(projectToken).balanceOf(address(this)); +``` + +**Why this is wrong:** +`_routeFeesToProject()` books per-project entitlements in `claimableFeeTokens[projectId]`, but the actual ERC-20 fee tokens are stored in one shared wallet: `address(this)`. Later, whenever the fee project itself uses the hook, `deployPool()` and the post-deploy burn paths read the entire on-contract balance for that token and treat it as fee-project inventory. There is no segregation between: + +1. fee-project tokens owed to some other project as claimable fees, and +2. fee-project tokens legitimately belonging to the fee project’s own LP lifecycle. + +That means the fee project can accidentally absorb those claimable tokens into a new LP position, or burn them outright, while `claimableFeeTokens[otherProject]` still says they are withdrawable. + +**Verification evidence:** +- Code trace: + - `_routeFeesToProject()` credits `claimableFeeTokens[projectId]` from the change in `IERC20(feeProjectToken).balanceOf(address(this))` at [src/JBUniswapV4LPSplitHook.sol:1402](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L1402) through [src/JBUniswapV4LPSplitHook.sol:1442](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L1442). + - `claimFeeTokensFor()` later assumes those tokens are still present and blindly transfers `claimableFeeTokens[projectId]` at [src/JBUniswapV4LPSplitHook.sol:497](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L497) through [src/JBUniswapV4LPSplitHook.sol:502](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L502). + - But fee-project lifecycle paths reuse the full token balance: + - pre-mint LP sizing at [src/JBUniswapV4LPSplitHook.sol:758](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L758) + - post-deploy burn mode at [src/JBUniswapV4LPSplitHook.sol:624](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L624) and [src/JBUniswapV4LPSplitHook.sol:860](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L860) + - leftover burn at [src/JBUniswapV4LPSplitHook.sol:1173](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L1173) +- PoC: + - `forge test --match-path test/audit/FeeProjectSelfBurnPoC.t.sol -vvv` + - Result: pass + - The PoC first accrues claimable fee-project tokens for project 1, then deploys a pool for `FEE_PROJECT_ID`. + - During that deployment, the fee project’s LP mint consumes more fee-project tokens than the fee project accumulated for itself, proving it also pulled in project 1’s claimable balance. + - A subsequent `claimFeeTokensFor(PROJECT_ID, user)` reverts because `claimableFeeTokens[PROJECT_ID]` remains nonzero while the backing token balance has already been drained. + +**Attack scenario:** +1. Project A collects LP fees, causing `_routeFeesToProject()` to mint fee-project ERC-20s to the hook and increase `claimableFeeTokens[A]`. +2. The same hook instance is also used by the fee project itself (`projectId == FEE_PROJECT_ID`). +3. The fee project deploys or rebalances its own pool, or receives a post-deployment reserved-token split. +4. The hook reads `IERC20(feeProjectToken).balanceOf(address(this))` and consumes or burns the entire balance, including project A’s claimable fee tokens. +5. Project A later calls `claimFeeTokensFor(A, beneficiary)` and the transfer fails or the beneficiary receives less than accounting promised. + +**Impact:** +- Cross-project theft / permanent claim failure of fee tokens. +- The blast radius is all projects sharing a hook instance where the configured fee project also uses that hook for its own LP lifecycle. +- Because fee claims are user-facing accounting, this breaks the invariant that `claimableFeeTokens[projectId]` is fully backed by tokens held by the hook. + +**Suggested fix:** +```solidity +// Track fee-project tokens reserved for third-party claims separately, +// and never include them in fee-project operational balances. +mapping(uint256 projectId => uint256 reservedFeeProjectTokens) public reservedFeeProjectTokens; + +// When sizing/burning fee-project inventory: +uint256 usableProjectTokenBalance = + IERC20(projectToken).balanceOf(address(this)) - totalReservedFeeProjectTokens; +``` + +At minimum, all fee-project operational paths must exclude tokens already reserved by `claimableFeeTokens` from LP sizing, leftover burns, and post-deployment burns. + +## False Positives Eliminated +- Reentrancy through `collectAndRouteLPFees()` did not yield a verified accounting break under the current CEI ordering and existing tests. +- Permissionless `deployPool()` after 10x weight decay is intentional and matched the documented design. +- Deployment script review did not produce a verified address or ordering bug from local evidence. + +## Downgraded Findings +- None. + +## Summary +- Total functions analyzed: 46 +- 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 diff --git a/.audit-findings-codex-prev-20260323-211452/univ4-lp-split-hook-v6/codex-nemesis-raw.md b/.audit-findings-codex-prev-20260323-211452/univ4-lp-split-hook-v6/codex-nemesis-raw.md new file mode 100644 index 0000000..8ce160d --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/univ4-lp-split-hook-v6/codex-nemesis-raw.md @@ -0,0 +1,108 @@ +# N E M E S I S — Raw Findings + +## Scope +- Fresh round, no prior findings used +- Solidity files analyzed: + - `src/JBUniswapV4LPSplitHook.sol` + - `src/JBUniswapV4LPSplitHookDeployer.sol` + - `src/interfaces/IJBUniswapV4LPSplitHook.sol` + - `src/interfaces/IJBUniswapV4LPSplitHookDeployer.sol` + - `script/Deploy.s.sol` + +## Phase 0 — Recon + +### Attack Goals +1. Misroute or steal terminal/project tokens during pool deployment, fee collection, or rebalance. +2. Break fee-token accounting so projects cannot claim what the hook says they earned. +3. Abuse a deployment/configuration mistake to point the hook at the wrong V4 or Juicebox infrastructure. + +### Novel Code +- `src/JBUniswapV4LPSplitHook.sol` + - Custom LP lifecycle built on top of Juicebox reserved-token flows plus Uniswap V4 position management. +- `script/Deploy.s.sol` + - Hardcoded infra addresses and chain branching. + +### Value Stores + Initial Coupling Hypothesis +- Hook-held project tokens + - Outflows: `deployPool`, `processSplitWith` post-deploy, `_handleLeftoverTokens` + - Must stay consistent with: `accumulatedProjectTokens`, `deployedPoolCount`, `tokenIdOf` +- Hook-held terminal tokens + - Outflows: `_mintPosition`, `_addToProjectBalance`, fee routing + - Must stay consistent with: fee split math and project terminal balances +- Hook-held fee-project ERC-20 tokens + - Outflows: `claimFeeTokensFor` + - Must stay consistent with: `claimableFeeTokens[projectId]` + +### Priority Targets +1. `collectAndRouteLPFees` / `_routeFeesToProject` / `claimFeeTokensFor` +2. `deployPool` / `_addUniswapLiquidity` / `_handleLeftoverTokens` +3. `processSplitWith` post-deploy burn mode +4. `rebalanceLiquidity` +5. `script/Deploy.s.sol` + +## Phase 1 — Cross-Reference Map + +| Function | Writes | Coupled State | Initial Status | +|----------|--------|---------------|----------------| +| `_routeFeesToProject` | `claimableFeeTokens[projectId]`, fee-project token balance | claimable accounting ↔ actual hook balance | suspect | +| `claimFeeTokensFor` | zeroes `claimableFeeTokens[projectId]` and transfers tokens | claimable accounting ↔ actual hook balance | suspect | +| `_addUniswapLiquidity` | consumes raw `IERC20(projectToken).balanceOf(address(this))` | fee-project operational balance ↔ third-party claim backing | suspect | +| `_burnReceivedTokens` | burns raw `IERC20(projectToken).balanceOf(address(this))` | fee-project operational balance ↔ third-party claim backing | suspect | +| `_handleLeftoverTokens` | burns raw `IERC20(projectToken).balanceOf(address(this))` | fee-project operational balance ↔ third-party claim backing | suspect | + +## Pass 1 — Feynman (full) + +### Raw suspect F-1 +- Question: + - Why is `IERC20(projectToken).balanceOf(address(this))` treated as entirely owned by `projectId` after the same hook also escrows fee-project tokens on behalf of other projects? +- Suspect lines: + - `src/JBUniswapV4LPSplitHook.sol:758` + - `src/JBUniswapV4LPSplitHook.sol:861` + - `src/JBUniswapV4LPSplitHook.sol:1173` +- Hypothesis: + - If `projectId == FEE_PROJECT_ID`, fee-project deploy/burn paths can consume fee tokens already promised to some other project. + +## Pass 2 — State Inconsistency (full, enriched) + +### Coupled pair S-1 +- `claimableFeeTokens[projectId]` ↔ fee-project ERC-20 balance held by hook +- Required invariant: + - Sum of unclaimed fee entitlements must remain backed by live fee-project tokens in the hook wallet. + +### Gap S-1 +- `_routeFeesToProject` creates per-project claims, but later fee-project operational paths consume the shared balance without decrementing those claims. + +## Pass 3 — Feynman re-interrogation + +### Root cause confirmed +- The hook has one shared fee-project token wallet and no reserved sub-balance for claimants. +- `claimFeeTokensFor` trusts accounting that can be invalidated by unrelated fee-project operations. + +### Candidate finding NM-001 +- Severity: HIGH +- Title: + - Fee-project LP lifecycle can consume fee tokens owed to unrelated projects + +## Pass 4 — State re-analysis + +### Additional affected mutation paths +- `deployPool()` for `FEE_PROJECT_ID` +- `processSplitWith()` after `deployedPoolCount[FEE_PROJECT_ID] > 0` +- `_handleLeftoverTokens()` for `FEE_PROJECT_ID` + +### Delta +- No second independent root cause surfaced beyond the same shared-balance coupling failure. + +## Phase 5/6 — Verification notes +- Hybrid verification selected. +- Added PoC: + - `test/audit/FeeProjectSelfBurnPoC.t.sol` +- Command: + - `forge test --match-path test/audit/FeeProjectSelfBurnPoC.t.sol -vvv` +- Result: + - PASS + +## Raw Findings Summary +| ID | Source | Severity | Status | +|----|--------|----------|--------| +| NM-001 | Cross-feed P2→P3 | HIGH | Verified | diff --git a/.audit-findings-codex-prev-20260323-211452/univ4-lp-split-hook-v6/codex-nemesis-verified.md b/.audit-findings-codex-prev-20260323-211452/univ4-lp-split-hook-v6/codex-nemesis-verified.md new file mode 100644 index 0000000..14885c8 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/univ4-lp-split-hook-v6/codex-nemesis-verified.md @@ -0,0 +1,118 @@ +# N E M E S I S — Verified Findings + +## Scope +- Language: Solidity 0.8.26 +- Modules analyzed: + - `src/JBUniswapV4LPSplitHook.sol` + - `src/JBUniswapV4LPSplitHookDeployer.sol` + - `src/interfaces/IJBUniswapV4LPSplitHook.sol` + - `src/interfaces/IJBUniswapV4LPSplitHookDeployer.sol` + - `script/Deploy.s.sol` +- Functions analyzed: 46 +- Coupled state pairs mapped: 4 high-signal pairs +- Mutation paths traced: 14 +- Nemesis loop iterations: 4 passes (Feynman full → State full → Feynman targeted → State targeted/converged) + +## Nemesis Map (Phase 1 Cross-Reference) + +| Function | Writes A | Writes B | Coupled Pair | Sync Status | +|----------|----------|----------|--------------|-------------| +| `_routeFeesToProject` | fee-project token balance | `claimableFeeTokens[projectId]` | claimable fee accounting ↔ backing balance | `✓` on accrual | +| `claimFeeTokensFor` | `claimableFeeTokens[projectId]` | fee-project token balance | claimable fee accounting ↔ backing balance | `✓` if backing still exists | +| `_addUniswapLiquidity` for `FEE_PROJECT_ID` | fee-project token balance | `claimableFeeTokens[*]` | claimable fee accounting ↔ backing balance | `✗ GAP` | +| `_burnReceivedTokens` for `FEE_PROJECT_ID` | fee-project token balance | `claimableFeeTokens[*]` | claimable fee accounting ↔ backing balance | `✗ GAP` | +| `_handleLeftoverTokens` for `FEE_PROJECT_ID` | fee-project token balance | `claimableFeeTokens[*]` | claimable fee accounting ↔ backing balance | `✗ GAP` | + +## Verification Summary +| ID | Source | Coupled Pair | Breaking Op | Severity | Verdict | +|----|--------|-------------|-------------|----------|---------| +| NM-001 | Cross-feed P2→P3 | `claimableFeeTokens[project]` ↔ fee-project token balance in hook | `deployPool()` / `processSplitWith()` / leftover burn for `FEE_PROJECT_ID` | HIGH | TRUE POSITIVE | + +## Verified Findings + +### Finding NM-001: HIGH — Fee-project operations can consume fee tokens already owed to other projects +**Severity:** HIGH +**Source:** Cross-feed P2→P3 +**Verification:** Hybrid + +**Coupled Pair:** `claimableFeeTokens[projectId]` ↔ fee-project ERC-20 balance held by the hook + +**Invariant:** The hook must retain enough fee-project ERC-20 tokens to satisfy every outstanding `claimableFeeTokens[projectId]` balance until each project claims. + +**Feynman Question that exposed it:** +> Why does the fee project later treat `IERC20(projectToken).balanceOf(address(this))` as entirely its own inventory when the same contract also escrows fee-project tokens for unrelated projects? + +**State Mapper gap that confirmed it:** +> `_routeFeesToProject()` increments `claimableFeeTokens[projectId]`, but fee-project `deployPool()` / burn paths later mutate the same ERC-20 balance without touching any claimant accounting. + +**Breaking Operations:** +- `deployPool()` path via `_addUniswapLiquidity()` at [src/JBUniswapV4LPSplitHook.sol:758](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L758) + - Modifies State A: consumes the entire fee-project token balance in the hook when `projectId == FEE_PROJECT_ID` + - Does NOT update State B: leaves `claimableFeeTokens[otherProject]` unchanged +- `processSplitWith()` post-deploy via `_burnReceivedTokens()` at [src/JBUniswapV4LPSplitHook.sol:624](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L624) and [src/JBUniswapV4LPSplitHook.sol:860](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L860) + - Modifies State A: burns the entire fee-project token balance in the hook + - Does NOT update State B: leaves `claimableFeeTokens[otherProject]` unchanged +- Leftover burn via `_handleLeftoverTokens()` at [src/JBUniswapV4LPSplitHook.sol:1173](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L1173) + - Same coupling failure + +**Trigger Sequence:** +1. Project A accrues LP fees. +2. `_routeFeesToProject()` pays `FEE_PROJECT_ID`, mints fee-project ERC-20s to the hook, and records them as `claimableFeeTokens[A]` at [src/JBUniswapV4LPSplitHook.sol:1402](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L1402) through [src/JBUniswapV4LPSplitHook.sol:1442](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L1442). +3. The same hook instance is used by the fee project itself. +4. The fee project deploys/rebalances a pool, or receives a post-deployment reserved split. +5. The hook consumes or burns the shared fee-project token balance. +6. Project A later calls `claimFeeTokensFor(A, beneficiary)` at [src/JBUniswapV4LPSplitHook.sol:497](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L497) through [src/JBUniswapV4LPSplitHook.sol:502](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L502), but the promised backing tokens are gone. + +**Consequence:** +- Cross-project fee theft or permanent claim failure. +- The affected project’s accounting remains nonzero even though the claim is no longer backed by tokens. +- The consumed value is either: + - moved into the fee project’s LP position, or + - destroyed by a burn path. + +**Verification Evidence:** +- Code trace: + - Fee-token accrual and accounting: [src/JBUniswapV4LPSplitHook.sol:1402](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L1402) to [src/JBUniswapV4LPSplitHook.sol:1442](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L1442) + - Shared-balance consumption: [src/JBUniswapV4LPSplitHook.sol:758](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L758), [src/JBUniswapV4LPSplitHook.sol:861](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L861), [src/JBUniswapV4LPSplitHook.sol:1173](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L1173) + - Claim path that trusts stale accounting: [src/JBUniswapV4LPSplitHook.sol:497](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L497) to [src/JBUniswapV4LPSplitHook.sol:502](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L502) +- PoC: + - Added `test/audit/FeeProjectSelfBurnPoC.t.sol` + - Command: `forge test --match-path test/audit/FeeProjectSelfBurnPoC.t.sol -vvv` + - Result: PASS + - The PoC accrues claimable fee tokens for project 1, then deploys a pool for `FEE_PROJECT_ID`. + - During deployment, the fee project consumes more fee-project tokens than it accumulated for itself, proving it also absorbed project 1’s claimable fee inventory. + - `claimFeeTokensFor(PROJECT_ID, user)` then reverts because the accounting stayed nonzero while the backing balance was drained. + +**Fix:** +```solidity +// Keep third-party claim inventory segregated from fee-project operational inventory. +mapping(uint256 projectId => uint256) public claimableFeeTokens; +uint256 public totalOutstandingFeeClaims; + +uint256 usableFeeProjectBalance = + IERC20(projectToken).balanceOf(address(this)) - totalOutstandingFeeClaims; +``` + +Every fee-project operational path must use `usableFeeProjectBalance`, not the raw contract balance. Alternatively, escrow each project’s claimable fee tokens in isolated per-project vaults or transfer them out immediately on accrual. + +## Feedback Loop Discoveries +- The key bug only became obvious after combining: + - Feynman’s “why is the whole balance safe to burn/use?” interrogation, and + - the state mapper’s explicit coupling of `claimableFeeTokens[projectId]` to the shared fee-project token balance. + +## False Positives Eliminated +- No verified exploit was found in `src/JBUniswapV4LPSplitHookDeployer.sol`. +- No verified constructor/deployment-ordering bug was found in `script/Deploy.s.sol` from local evidence. +- Reentrancy concerns around fee collection did not produce a separate verified invariant break. + +## Downgraded Findings +- None. + +## Summary +- Total functions analyzed: 46 +- Coupled state pairs mapped: 4 +- Nemesis loop iterations: 4 passes +- 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 diff --git a/.audit-findings-codex-prev-20260323-211452/univ4-lp-split-hook-v6/codex-state-inconsistency-verified.md b/.audit-findings-codex-prev-20260323-211452/univ4-lp-split-hook-v6/codex-state-inconsistency-verified.md new file mode 100644 index 0000000..e46c999 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/univ4-lp-split-hook-v6/codex-state-inconsistency-verified.md @@ -0,0 +1,93 @@ +# State Inconsistency Audit — Verified Findings + +## Coupled State Dependency Map + +| Coupled State | Invariant | +|---------------|-----------| +| `claimableFeeTokens[projectId]` ↔ fee-project ERC-20 balance held by the hook | Every project’s claimable fee-token accounting must be fully backed by tokens actually custodied by the hook | +| `tokenIdOf[projectId][terminalToken]` ↔ `_poolKeys[projectId][terminalToken]` | A live LP position must have consistent pool metadata | +| `deployedPoolCount[projectId]` ↔ `processSplitWith()` mode | Before first deployment reserved splits accumulate; after deployment they burn | +| `accumulatedProjectTokens[projectId]` ↔ pre-deploy project-token balance | Deployment should only use that project’s accumulated inventory | + +## Mutation Matrix + +| State Variable | Mutating Function | Updates Coupled State? | +|----------------|-------------------|-------------------------| +| `claimableFeeTokens[projectId]` | `_routeFeesToProject()` | Writes accounting only | +| fee-project ERC-20 balance in hook | `_routeFeesToProject()` | Yes, via `terminal.pay()` mint | +| fee-project ERC-20 balance in hook | `deployPool()` / `_addUniswapLiquidity()` when `projectId == FEE_PROJECT_ID` | No, consumes entire balance without checking `claimableFeeTokens` | +| fee-project ERC-20 balance in hook | `processSplitWith()` post-deploy via `_burnReceivedTokens()` when `projectId == FEE_PROJECT_ID` | No, burns entire balance without checking `claimableFeeTokens` | +| fee-project ERC-20 balance in hook | `_handleLeftoverTokens()` when `projectId == FEE_PROJECT_ID` | No, burns leftovers without checking `claimableFeeTokens` | +| `claimableFeeTokens[projectId]` | `claimFeeTokensFor()` | Zeroes accounting and transfers tokens, assuming balance still exists | + +## Parallel Path Comparison + +| Coupled State | Fee accrual path | Fee-project deploy path | Fee-project post-deploy split path | +|---------------|------------------|-------------------------|------------------------------------| +| `claimableFeeTokens[projectId]` backing | `✓` credited | `✗` backing consumed into LP | `✗` backing burned wholesale | + +## Verification Summary +| ID | Coupled Pair | Breaking Op | Original Severity | Verdict | Final Severity | +|----|-------------|-------------|-------------------|---------|----------------| +| SI-001 | `claimableFeeTokens[project]` ↔ fee-project ERC-20 balance in hook | `deployPool()` / `processSplitWith()` for `FEE_PROJECT_ID` | HIGH | TRUE POSITIVE | HIGH | + +## Verified Findings + +### Finding SI-001: HIGH — Fee-project lifecycle paths desynchronize `claimableFeeTokens` from the tokens that back them +**Severity:** HIGH +**Verification:** Hybrid + +**Coupled Pair:** `claimableFeeTokens[projectId]` ↔ fee-project ERC-20 balance held by the hook + +**Invariant:** For every project, the fee-project ERC-20 tokens promised in `claimableFeeTokens[projectId]` must remain custodied by the hook until `claimFeeTokensFor(projectId, ...)` transfers them out. + +**Breaking Operation:** fee-project `deployPool()` and post-deploy burn paths in `JBUniswapV4LPSplitHook` +- Modifies backing token balance: consumes or burns all fee-project ERC-20 tokens held by the hook +- Does NOT update `claimableFeeTokens` for the projects whose fee tokens were consumed + +**Code references:** +- Accounting credit: [src/JBUniswapV4LPSplitHook.sol:1402](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L1402) to [src/JBUniswapV4LPSplitHook.sol:1442](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L1442) +- LP sizing from full balance: [src/JBUniswapV4LPSplitHook.sol:758](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L758) +- Post-deploy burn from full balance: [src/JBUniswapV4LPSplitHook.sol:624](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L624) and [src/JBUniswapV4LPSplitHook.sol:860](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L860) +- Leftover burn from full balance: [src/JBUniswapV4LPSplitHook.sol:1173](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L1173) +- Claim assumes backing still exists: [src/JBUniswapV4LPSplitHook.sol:497](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L497) to [src/JBUniswapV4LPSplitHook.sol:502](/Users/jango/Documents/jb/v6/evm/univ4-lp-split-hook-v6/src/JBUniswapV4LPSplitHook.sol#L502) + +**Trigger Sequence:** +1. Project A accrues LP fees; `_routeFeesToProject()` mints fee-project ERC-20s to the hook and increments `claimableFeeTokens[A]`. +2. The hook instance is also used by the fee project itself. +3. The fee project deploys a pool or later receives a reserved-token split after deployment. +4. The fee-project path reads `IERC20(projectToken).balanceOf(address(this))` and uses or burns the entire fee-project token balance. +5. `claimableFeeTokens[A]` remains unchanged even though its backing tokens are gone. +6. `claimFeeTokensFor(A, beneficiary)` reverts or underpays. + +**Consequence:** +- Fee-token claims for unrelated projects become unbacked. +- Depending on which path consumes the tokens, they are either: + - moved into the fee project’s LP position, or + - burned outright. + +**Verification Evidence:** +- Code trace confirmed there is no hidden segregation or lazy reconciliation between `claimableFeeTokens` and the actual ERC-20 balance. +- PoC test: `forge test --match-path test/audit/FeeProjectSelfBurnPoC.t.sol -vvv` + - Passed. + - Demonstrated that deploying a pool for `FEE_PROJECT_ID` consumed more fee-project tokens than the fee project accumulated for itself, then left `claimableFeeTokens[PROJECT_ID]` stale and unclaimable. + +**Fix:** +```solidity +// Exclude reserved fee-claim inventory from fee-project operational flows. +uint256 backingReservedForClaims = totalClaimableFeeProjectTokens(); +uint256 usableBalance = IERC20(projectToken).balanceOf(address(this)) - backingReservedForClaims; +``` + +All fee-project operational paths must use `usableBalance`, not the raw ERC-20 balance. + +## False Positives Eliminated +- No hidden reconciliation path updates `claimableFeeTokens` when fee-project tokens are later consumed. +- This is not lazy evaluation: `claimFeeTokensFor()` performs an immediate transfer and therefore requires live backing. + +## Summary +- Coupled state pairs mapped: 4 high-signal pairs +- Mutation paths analyzed: 14 +- Raw findings (pre-verification): 1 +- After verification: 1 TRUE POSITIVE | 0 FALSE POSITIVE +- Final: 0 CRITICAL | 1 HIGH | 0 MEDIUM | 0 LOW diff --git a/.audit-findings-codex-prev-20260323-211452/univ4-router-v6/codex-feynman-verified.md b/.audit-findings-codex-prev-20260323-211452/univ4-router-v6/codex-feynman-verified.md new file mode 100644 index 0000000..999f57d --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/univ4-router-v6/codex-feynman-verified.md @@ -0,0 +1,74 @@ +# Feynman Audit — Verified Findings + +## Scope +- Language: Solidity +- Modules analyzed: + - `src/JBUniswapV4Hook.sol` + - `src/libraries/Oracle.sol` + - `script/Deploy.s.sol` + - `script/helpers/Univ4RouterDeploymentLib.sol` +- Functions analyzed: 34 + +## Verification Summary + +No verified true-positive findings. + +## Function-State Matrix + +Key high-risk functions reviewed: +- `calculateExpectedTokensWithCurrency` + - Reads ruleset weight, reserved percent, price feed, token decimals. +- `_beforeSwap` + - Reads token/project mapping, route estimates, terminal availability, hookData. + - Calls `_routeThroughJuicebox(...)` only when JB output is strictly better. +- `_routeThroughJuicebox` + - Writes `_routing`, takes tokens from PoolManager, calls terminal, settles output. +- `_recordObservation` + - Updates oracle ring-buffer state and cardinality growth. +- `Oracle.write` / `Oracle.observeSingle` + - Maintain observation ordering and TWAP interpolation. + +## Guard Consistency Analysis + +No missing sibling guard produced a verified issue. + +Reviewed specifically: +- `_beforeSwap` exact-input restriction versus `_afterSwap` post-check behavior. +- `_routing` guard on recursive re-entry. +- oracle cardinality zero checks in `observe(...)` and `grow(...)`. + +## Inverse Operation Parity + +No broken inverse pair was confirmed. + +Reviewed specifically: +- `_afterInitialize` versus `_recordObservation` +- JB-routed swap path versus V4 passthrough path +- buy-path approval/pay flow versus sell-path cashout flow + +## Verified Findings + +None. + +## False Positives Eliminated + +- Static pay-side weight estimation divergence: + - Repo-documented composition limit with pay-side data hooks, not a newly uncovered bug. +- `_routing` persistence on revert: + - Reverts unwind the storage flag. +- oracle growth / same-block write drift: + - Ring-buffer state remains coherent by design. + +## Downgraded Findings + +None. + +## LOW Findings (verified by inspection) + +None promoted to a reportable low-severity finding in this round. + +## Summary +- Raw Feynman candidates: 5 +- Verified true positives: 0 +- False positives / documented accepted behaviors: 5 +- Final: 0 critical, 0 high, 0 medium, 0 low diff --git a/.audit-findings-codex-prev-20260323-211452/univ4-router-v6/codex-nemesis-raw.md b/.audit-findings-codex-prev-20260323-211452/univ4-router-v6/codex-nemesis-raw.md new file mode 100644 index 0000000..c4e2862 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/univ4-router-v6/codex-nemesis-raw.md @@ -0,0 +1,171 @@ +# N E M E S I S — Raw Notes + +## Scope +- Language: Solidity 0.8.26 +- Files: + - `src/JBUniswapV4Hook.sol` + - `src/libraries/Oracle.sol` + - `script/Deploy.s.sol` + - `script/helpers/Univ4RouterDeploymentLib.sol` +- Functions scanned: 34 + +## Phase 0 — Recon + +### Attack Goals +1. Break Uniswap v4 flash accounting during JB rerouting and extract or strand assets. +2. Force systematic routing to a worse path and steal value via stale or manipulable pricing. +3. Corrupt oracle state so TWAP reads revert or fall back to spot indefinitely. +4. Misdeploy the hook with wrong flags or chain addresses and silently disable protections. + +### Novel Code +- `src/JBUniswapV4Hook.sol` + - Novel router/hook composition between Uniswap v4 flash accounting and Juicebox terminals. +- `src/libraries/Oracle.sol` + - Custom retained-observation policy and hook-driven write cadence on top of the V3-style oracle. +- `script/Deploy.s.sol` + - Chain-specific PoolManager selection and CREATE2 hook flag mining. + +### Value Stores + Initial Coupling Hypothesis +- PoolManager flash-accounting balances + - Coupled to `BeforeSwapDelta` returned by `_beforeSwap` and to `_settleOutput(...)`. +- Oracle `observations[poolId]` + - Coupled to `states[poolId]` (`index`, `cardinality`, `cardinalityNext`). +- JB route decision + - Coupled to `amountOutMin`, current ruleset data, terminal availability, and output settlement. +- Deployment salt/address mining + - Coupled to exact hook flags and per-chain PoolManager address selection. + +### Complex Paths +- `beforeSwap -> estimate route -> poolManager.take -> terminal.pay/cashOutTokensOf -> CurrencySettler.settle` +- `afterSwap/afterAddLiquidity/afterRemoveLiquidity -> _recordObservation -> Oracle.write/grow` + +### Priority Order +1. `_beforeSwap` + `_routeThroughJuicebox` +2. `_recordObservation` + `Oracle.observeSingle/binarySearch` +3. Deployment script chain/address and hook-flag flow + +## Phase 1A — Function-State Matrix + +| Function | Reads | Writes | Guards | External Calls | +|---|---|---|---|---| +| `calculateExpectedOutputFromSelling` | none local; terminal fee/preview | none | none | terminal preview, fee lookup | +| `calculateExpectedTokensWithCurrency` | ruleset, prices, token decimals | none | none | controller, prices, token metadata | +| `estimateUniswapOutput` | oracle state, slot0, liquidity | none | none | poolManager | +| `observe` | oracle state, slot0, liquidity | none | none | poolManager | +| `observeTWAP` | observations | none | `secondsAgo != 0` | none | +| `_afterInitialize` | none | `states[poolId]`, `observations[poolId][0]` | hook permissioned | none | +| `_afterAddLiquidity` | pool state | oracle state | hook permissioned | poolManager | +| `_afterRemoveLiquidity` | pool state | oracle state | hook permissioned | poolManager | +| `_afterSwap` | hookData, delta, pool state | oracle state | hook permissioned | poolManager | +| `_beforeSwap` | `_routing`, hookData, token/project mapping, terminal lookup, ruleset/prices, oracle | may trigger JB route effects | `_routing == false`, exact-input only, hookData length == 32 | tokens, directory, controller, prices, poolManager, terminal | +| `_recordObservation` | pool state, oracle state | oracle state | none | poolManager | +| `_routeThroughJuicebox` | currencies, terminal | `_routing`; token approvals; PoolManager settlement side effects | caller ensured terminal exists | poolManager, terminal, ERC20 | +| `Deploy.run` | env, chainid | none | none | CoreDeploymentLib, HookMiner | +| `Deploy._getPoolManager` | `block.chainid` | none | supported-chain check | none | +| `Univ4RouterDeploymentLib.getDeployment` | `block.chainid`, filesystem JSON path | none | supported-chain check | SphinxConstants constructor, `readFile` | + +## Phase 1B — Coupled State Dependency Map + +| Pair | Invariant | +|---|---| +| `observations[poolId]` ↔ `states[poolId]` | `index < cardinality <= cardinalityNext <= 1024`; oldest/newest lookup must reflect populated window | +| `poolManager.take(...)` ↔ `_settleOutput(...)` ↔ returned `BeforeSwapDelta` | every JB-routed flash-accounting take must be matched by correct settlement and delta encoding | +| `hookData amountOutMin` in `_beforeSwap` ↔ `_afterSwap` slippage check | JB-routed swaps enforce min in terminal call; V4-routed swaps enforce min against realized delta | +| `token normalization` ↔ `terminal lookup/payment/cashout` | native ETH must map to JB native token only on terminal-facing paths, never on PoolManager-facing paths | +| `Deployment flags` ↔ mined hook address | deployed address must encode exact enabled callbacks | + +## Phase 1C — Cross-Reference + +| Function | Coupled Pair | Sync Status | +|---|---|---| +| `_afterInitialize` | `observations` ↔ `states` | synced | +| `_recordObservation` | `observations` ↔ `states` | synced | +| `_afterSwap` | `amountOutMin` ↔ realized output | synced | +| `_beforeSwap` | route decision ↔ terminal availability/output estimate | synced, but relies on assumptions about estimate accuracy | +| `_routeThroughJuicebox` | `take` ↔ `settle` ↔ delta | synced if terminal behavior matches interface assumptions | +| `Deploy.run` | flags ↔ hook address | synced | + +## Pass 1 — Feynman Findings/Suspects + +### Closed Suspect F1 +- Area: buy-side estimate uses static ruleset weight rather than terminal preview. +- Why flagged: + - `calculateExpectedTokensWithCurrency(...)` reads `currentRulesetOf(...)` directly instead of `previewPayFor(...)`. +- Result: + - Not a new finding. This is explicitly documented in repo comments and `RISKS.md` as a known composition limit with pay-side data hooks. + +### Closed Suspect F2 +- Area: strict `hookData.length == 32` in `_beforeSwap` versus `>= 32` in `_afterSwap`. +- Why flagged: + - Potential asymmetry between pre-swap route handling and post-swap slippage checks. +- Result: + - Not a new finding. Already documented. No new downstream invariant break was found beyond the known metadata-format restriction. + +### Closed Suspect F3 +- Area: `_routing` boolean around external terminal calls. +- Why flagged: + - Needed to confirm revert paths do not leave `_routing = true`. +- Result: + - False positive. Any revert in `_routeThroughJuicebox(...)` unwinds the storage write, and existing regression coverage for sell-path and buy-path reentrancy passed. + +### Closed Suspect F4 +- Area: force-approve and lingering ERC20 allowance. +- Why flagged: + - External call made after approval. +- Result: + - False positive. `forceApprove` intentionally resets exact allowance, and regression coverage exists for partial-consumption scenarios. + +### Closed Suspect F5 +- Area: observation growth and same-block write no-op. +- Why flagged: + - Needed to check whether `cardinalityNext` could diverge from actual writable window in a way that breaks TWAP. +- Result: + - False positive. The temporary `cardinalityNext > cardinality` state is intentional; `Oracle.write(...)` advances `cardinality` only on the next eligible block, preserving ordering. + +## Pass 2 — State Inconsistency Findings/Gaps + +No uncoupled mutation path survived trace review. + +Checked specifically: +- `_afterInitialize`, `_recordObservation`, `Oracle.write`, `Oracle.grow` +- `_beforeSwap`, `_afterSwap`, `_routeThroughJuicebox`, `_settleOutput` +- `Deploy.run`, `_getPoolManager`, deployment helper JSON resolution + +No path was found that mutates one side of a required pair without the counterpart update. + +## Targeted Re-Interrogation (Pass 3 / Pass 4) + +Re-checked the only meaningful cross-feed targets: + +1. Route estimate assumptions + - Static buy-side estimate is a documented limitation, not a hidden inconsistency. +2. Flash-accounting settlement path + - `poolManager.take(...)` is always followed by terminal transform and `CurrencySettler.settle(...)` in the same call path. +3. Oracle state update cadence + - Same-block dedup and growth behavior are coherent with the ring-buffer invariants. + +Convergence reached with no new deltas. + +## Raw Candidate Log + +| ID | Candidate | Status | Reason Closed | +|---|---|---|---| +| RAW-1 | Pay-side data-hook estimate drift | closed | documented accepted risk | +| RAW-2 | `hookData` length asymmetry | closed | documented behavior, no new exploit path | +| RAW-3 | `_routing` stuck on revert | closed | EVM revert unwinds flag | +| RAW-4 | lingering allowance on terminal | closed | `forceApprove` exact-reset behavior | +| RAW-5 | oracle growth / same-block desync | closed | intended ring-buffer behavior | + +## Verification Notes + +- `forge build` succeeded. +- Passed: + - `forge test --match-contract ThreeWayRouting -q` + - `forge test --match-contract OracleDeepTest -q` + - `forge test --match-contract JBUniswapV4Hook -q` + - `forge test --match-contract SellPathReentrancy -q` + +## Residual Gaps + +- I did not independently validate the hardcoded PoolManager addresses in `script/Deploy.s.sol` against current external Uniswap deployment records during this round. +- No new PoC was written because no C/H/M candidate survived trace verification. diff --git a/.audit-findings-codex-prev-20260323-211452/univ4-router-v6/codex-nemesis-verified.md b/.audit-findings-codex-prev-20260323-211452/univ4-router-v6/codex-nemesis-verified.md new file mode 100644 index 0000000..77717a9 --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/univ4-router-v6/codex-nemesis-verified.md @@ -0,0 +1,62 @@ +# N E M E S I S — Verified Findings + +## Scope +- Language: Solidity +- Modules analyzed: + - `src/JBUniswapV4Hook.sol` + - `src/libraries/Oracle.sol` + - `script/Deploy.s.sol` + - `script/helpers/Univ4RouterDeploymentLib.sol` +- Functions analyzed: 34 +- Coupled state pairs mapped: 5 +- Mutation paths traced: 9 +- Nemesis loop iterations: 4 passes total + +## Nemesis Map + +| Function / Area | Primary Coupling | Status | +|---|---|---| +| `_beforeSwap` | route estimate ↔ terminal availability/output min | cleared | +| `_routeThroughJuicebox` | flash-accounting take ↔ settle ↔ delta | cleared | +| `_afterSwap` | realized V4 output ↔ `amountOutMin` | cleared | +| `_recordObservation` / `Oracle.write` | observation buffer ↔ state metadata | cleared | +| deployment script | hook flags ↔ mined address | cleared internally | + +## Verification Summary + +No verified true-positive findings. + +## Verified Findings + +None. + +## Feedback Loop Discoveries + +The only cross-feed items worth re-interrogating were: +- pay-side route estimation versus data-hook overrides +- oracle same-block dedup versus cardinality growth +- `_routing` mutation versus external-call failure + +All three collapsed to documented behavior or false positive after trace review. + +## False Positives Eliminated + +| ID | Source | Reason Eliminated | +|---|---|---| +| NM-FP-1 | Feynman | pay-side static weight divergence is explicitly documented and accepted | +| NM-FP-2 | State | oracle growth transition is intentional and keeps invariants intact | +| NM-FP-3 | Cross-feed | `_routing` does not persist across revert paths | + +## Downgraded Findings + +None. + +## Summary +- Raw findings: 0 critical, 0 high, 0 medium, 0 low that survived initial screening +- Feedback loop discoveries: 0 +- After verification: 0 true positives, 0 downgraded, 3 closed false positives/documented behaviors +- Final: 0 critical, 0 high, 0 medium, 0 low + +## Residual Risk / Testing Gap + +- Hardcoded PoolManager addresses in `script/Deploy.s.sol` were reviewed for branching correctness, but not independently re-validated against external deployment records during this audit round. diff --git a/.audit-findings-codex-prev-20260323-211452/univ4-router-v6/codex-state-inconsistency-verified.md b/.audit-findings-codex-prev-20260323-211452/univ4-router-v6/codex-state-inconsistency-verified.md new file mode 100644 index 0000000..b83c47c --- /dev/null +++ b/.audit-findings-codex-prev-20260323-211452/univ4-router-v6/codex-state-inconsistency-verified.md @@ -0,0 +1,52 @@ +# State Inconsistency Audit — Verified Findings + +## Coupled State Dependency Map + +Mapped pairs: +1. `observations[poolId]` ↔ `states[poolId]` +2. `poolManager.take(...)` ↔ `_settleOutput(...)` ↔ returned `BeforeSwapDelta` +3. `_beforeSwap` route decision ↔ `_afterSwap` slippage enforcement +4. token normalization ↔ terminal-facing token selection +5. deployment flags ↔ mined hook address + +## Mutation Matrix + +| State Variable | Mutating Function | Coupled State Updated? | +|---|---|---| +| `states[poolId]` | `_afterInitialize` | yes | +| `states[poolId]` | `_recordObservation` | yes | +| `observations[poolId]` | `_afterInitialize` | yes | +| `observations[poolId]` | `_recordObservation` | yes | +| `_routing` | `_routeThroughJuicebox` | yes, revert-safe | + +## Parallel Path Comparison + +| Coupled State | Path A | Path B | Result | +|---|---|---|---| +| slippage enforcement | JB route in terminal call | V4 route in `_afterSwap` | coherent | +| oracle write cadence | after swap | after add/remove liquidity | coherent | +| flash-accounting settlement | JB route | V4 passthrough | coherent | + +## Verification Summary + +No verified state inconsistency findings. + +## Verified Findings + +None. + +## False Positives Eliminated + +- suspected desync between `cardinalityNext` and actual cardinality: + - intended transitional state; no stale-read exploit identified. +- suspected slippage-state mismatch between `_beforeSwap` and `_afterSwap`: + - split by route type, not a missing synchronization. +- suspected `_routing` state leak across terminal reverts: + - reverted atomically. + +## Summary +- Coupled state pairs mapped: 5 +- Mutation paths analyzed: 9 +- Raw findings: 0 surviving candidates +- Verified true positives: 0 +- Final: 0 critical, 0 high, 0 medium, 0 low diff --git a/CLAUDE_AUDIT.md b/CLAUDE_AUDIT.md new file mode 100644 index 0000000..b13d218 --- /dev/null +++ b/CLAUDE_AUDIT.md @@ -0,0 +1,747 @@ +# Claude Nemesis Audit — Aggregated Findings + +**Run:** 2026-03-23 (run ID `20260323-211448`) +**Repos audited:** 17 +**Total findings:** 54 verified true positives +**Breakdown:** 0 Critical | 0 High | 4 Medium | 47 Low | 7 Informational +**Clean repos:** 1 (nana-permission-ids-v6) +**No Claude findings:** 1 (deploy-all-v6 — only Codex findings produced) + +--- + +## Summary by Severity + +| Severity | Count | Repos affected | +|----------|-------|----------------| +| Critical | 0 | — | +| High | 0 | — | +| Medium | 4 | univ4-lp-split-hook-v6 (1), nana-721-hook-v6 (1), croptop-core-v6 (1 known), banny-retail-v6 (1) | +| Low | 47 | nana-core-v6 (4), univ4-lp-split-hook-v6 (2), revnet-core-v6 (2), nana-router-terminal-v6 (6), nana-721-hook-v6 (5), univ4-router-v6 (8), nana-buyback-hook-v6 (3), nana-suckers-v6 (3), defifa-collection-deployer-v6 (7), croptop-core-v6 (4), banny-retail-v6 (1), nana-omnichain-deployers-v6 (3), nana-ownable-v6 (2), nana-address-registry-v6 (2), nana-fee-project-deployer-v6 (2) | +| Informational | 7 | nana-buyback-hook-v6 (2), nana-suckers-v6 (1), banny-retail-v6 (2), nana-omnichain-deployers-v6 (2) | + +## Summary by Repo + +| # | Repo | C | H | M | L | I | Total | +|---|------|---|---|---|---|---|-------| +| 1 | nana-core-v6 | 0 | 0 | 0 | 4 | 0 | 4 | +| 2 | univ4-lp-split-hook-v6 | 0 | 0 | 1 | 2 | 0 | 3 | +| 3 | revnet-core-v6 | 0 | 0 | 0 | 2 | 0 | 2 | +| 4 | nana-router-terminal-v6 | 0 | 0 | 0 | 6 | 0 | 6 | +| 5 | nana-721-hook-v6 | 0 | 0 | 1 | 5 | 0 | 6 | +| 6 | univ4-router-v6 | 0 | 0 | 0 | 8 | 0 | 8 | +| 7 | nana-buyback-hook-v6 | 0 | 0 | 0 | 3 | 2 | 5 | +| 8 | nana-suckers-v6 | 0 | 0 | 0 | 3 | 1 | 4 | +| 9 | defifa-collection-deployer-v6 | 0 | 0 | 0 | 7 | 0 | 7 | +| 10 | croptop-core-v6 | 0 | 0 | 1 | 4 | 0 | 5 | +| 11 | banny-retail-v6 | 0 | 0 | 1 | 1 | 2 | 4 | +| 12 | nana-omnichain-deployers-v6 | 0 | 0 | 0 | 3 | 2 | 5 | +| 13 | nana-ownable-v6 | 0 | 0 | 0 | 2 | 0 | 2 | +| 14 | nana-address-registry-v6 | 0 | 0 | 0 | 2 | 0 | 2 | +| 15 | nana-permission-ids-v6 | 0 | 0 | 0 | 0 | 0 | 0 | +| 16 | nana-fee-project-deployer-v6 | 0 | 0 | 0 | 2 | 0 | 2 | +| 17 | deploy-all-v6 | — | — | — | — | — | — | + +--- + +## MEDIUM Findings + +### M-1: `_mintRebalancedPosition` includes fee tokens in LP balance calculation (univ4-lp-split-hook-v6) + +**Severity:** MEDIUM +**File:** `src/JBUniswapV4LPSplitHook.sol:1324` + +`_mintRebalancedPosition` reads raw `IERC20(projectToken).balanceOf(address(this))` without subtracting `_totalOutstandingFeeTokenClaims[projectToken]`. All three other functions that read this balance DO subtract. When fee tokens exist, `rebalanceLiquidity()` computes inflated liquidity, consumes fee tokens into the LP position, and then `_handleLeftoverTokens` underflows and reverts. + +**Impact:** `rebalanceLiquidity` is DoS'd whenever `_totalOutstandingFeeTokenClaims[tokenP] > 0`. No fund loss (atomic revert). Workaround: call `claimFeeTokensFor` for all projects with outstanding claims before rebalancing. + +**Fix:** +```solidity +// Line 1324 — add subtraction: +uint256 projectTokenBalance = IERC20(projectToken).balanceOf(address(this)) - _totalOutstandingFeeTokenClaims[projectToken]; +``` + +!!! Admin note: ok, fix. + +--- + +### M-2: ERC-20 beneficiary split uses raw `transfer()` — bricks payments for USDT (nana-721-hook-v6) + +**Severity:** MEDIUM +**File:** `src/libraries/JB721TiersHookLib.sol:608-612` + +```solidity +try IERC20(token).transfer(split.beneficiary, amount) returns (bool success) { +``` + +Non-standard ERC-20 tokens like USDT return `void` instead of `bool`. The ABI decoder failure happens INSIDE the try success handler, NOT in the try expression — so the catch block does NOT catch it. Per Solidity 0.8.28 docs, ABI decode errors inside try/catch success handlers propagate as uncaught reverts. + +Lines 503, 537, 550, 577, 591 in the same file all correctly use `SafeERC20.safeTransfer`. Line 608 is the sole exception. + +**Impact:** Any project using this hook with ERC-20 tier splits to beneficiary addresses is permanently unable to receive USDT payments. + +**Fix:** +```solidity +if (!SafeERC20.trySafeTransfer(IERC20(token), split.beneficiary, amount)) return false; +``` + +!!! Admin note: ok, fix. Adding USDT tests throughout all repo's test suites would actually be super helpful if it is a unique coin in some respects. Anything else that would break with USDT in the mix? + +--- + +### M-3: `claimCollectionOwnershipOf` breaks all `mintFrom()` calls (croptop-core-v6) + +**Severity:** MEDIUM (KNOWN/ACCEPTED — documented in NatSpec, tested) +**File:** `src/CTDeployer.sol:226-237` + +`claimCollectionOwnershipOf()` transfers hook ownership from CTDeployer to the project NFT owner. CTPublisher's existing `ADJUST_721_TIERS` permission (granted from CTDeployer) becomes inert because the hook now resolves its owner via `PROJECTS.ownerOf(projectId)`, not CTDeployer. + +**Impact:** All new-tier minting via `mintFrom()` is blocked. Existing-tier minting (reusing URIs) still works. Recovery: project owner grants CTPublisher `ADJUST_721_TIERS` permission via `JBPermissions.setPermissionsFor()`. + +**Status:** Documented in NatSpec at `CTDeployer.sol:219-224`. Test file `test/ClaimCollectionOwnership.t.sol` covers this (6 tests). + +--- + +### M-4: Failed-return retention breaks outfit category exclusivity (banny-retail-v6) + +**Severity:** MEDIUM +**File:** `src/Banny721TokenUriResolver.sol:1435-1465` + +When a NonReceiverContract owns a body and outfit return fails, `_storeOutfitsWithRetained` merges new outfits + retained old outfits WITHOUT revalidating category exclusivity. The conflict check at L1321-1337 only validates NEW outfits in isolation. + +**Trigger:** +1. NonReceiverContract owns body B, equips HEAD outfit H +2. Redecorates with EYES outfit E — conflict check passes (only checks new outfits) +3. H return fails → retained. Final state: `[E, H]` — HEAD and EYES coexist, violating category exclusivity + +**Impact:** Breaks the contract's explicit category exclusivity guarantee. Visual artifact in SVG rendering. No fund loss. + +**Fix:** Revalidate category exclusivity on the merged set after retention: +```solidity +_validateCategoryExclusivity(hook, mergedOutfitIds); +``` + +Admin note: ok, fix + +--- + +## LOW Findings + +### L-1: `_feeFreeSurplusOf` unbounded accumulation via payout-to-self cycles (nana-core-v6) + +**Severity:** LOW +**File:** `src/JBMultiTerminal.sol:370-371` + +When a project's payout splits pay itself back via the same terminal, `_feeFreeSurplusOf` increments with no cap relative to actual balance. After N ruleset cycles, the counter reaches N×balance while balance stays constant. All future zero-tax cash outs get the 2.5% fee applied. No fund extraction — fee goes to project #1. + +Admin note: ok, fix? + +--- + +### L-2: `_feeFreeSurplusOf` not cleared on terminal migration (nana-core-v6) + +**Severity:** LOW +**File:** `src/JBMultiTerminal.sol:474-519` + +`migrateBalanceOf` zeroes `balanceOf` but leaves `_feeFreeSurplusOf` untouched. If the project returns to the same terminal, stale values cause unexpected fees on zero-tax cash outs. + +Admin note: ok, fix? idk, wyt? + +--- + +### L-3: Phantom balance on post-migration held fee processing revert (nana-core-v6) + +**Severity:** LOW +**File:** `src/JBMultiTerminal.sol:594-641` + +After migration, if `processHeldFeesOf` reverts during fee processing, the catch block credits `balanceOf` without tokens arriving — creating a phantom balance bounded by 2.5% of held fees. Documented as accepted trade-off at L581-587. + +--- + +### L-4: Weight decay DoS for projects with tiny duration (nana-core-v6) + +**Severity:** LOW +**File:** `src/JBRulesets.sol:613-686` + +Projects with `duration=1` and `weightCutPercent!=0` hit `WeightCacheRequired` revert after ~5.5 hours without interaction. Fixable via permissionless `updateRulesetWeightCache` calls. + +Admin note: ok, fix? what do you suggest? + +--- + +### L-5: `processSplitWith` balance check inflated by fee tokens (univ4-lp-split-hook-v6) + +**Severity:** LOW +**File:** `src/JBUniswapV4LPSplitHook.sol:644` + +When `projectToken == feeProjectToken`, fee tokens inflate `balanceOf(this)`. Defense-in-depth check passes even if the controller didn't transfer enough tokens. + +Admin note: ok, fix + +--- + +### L-6: Constructor missing zero-address checks for `permit2` and `oracleHook` (univ4-lp-split-hook-v6) + +**Severity:** LOW +**File:** `src/JBUniswapV4LPSplitHook.sol:199-221` + +Constructor validates `directory`, `tokens`, `poolManager`, `positionManager` for address(0) but not `permit2` or `oracleHook`. In practice, deployment script uses hardcoded canonical addresses. + +Admin note: ok, fix? do we really need to validate in constructor? can we assume we'll pass in correct values? + +--- + +### L-7: Deploy.s.sol non-idempotent deployment — fee project created before singleton check (revnet-core-v6) + +**Severity:** LOW +**File:** `script/Deploy.s.sol:344-382` + +`deploy()` creates a fresh fee project before checking if singletons exist. Retry after partial failure creates a different fee project ID and cannot rediscover the original singleton. Sphinx mitigates in practice. + +--- + +### L-8: Loan-ID namespace cap not enforced in three minting paths (revnet-core-v6) + +**Severity:** LOW +**File:** `src/REVLoans.sol:584, 1183, 1312` + +`totalLoansBorrowedFor[revnetId]` increment paths don't check against `_ONE_TRILLION`. Requires 1 trillion loans — beyond realistic usage. + +--- + +### L-9: Missing `accountingContextsOf` guard in NATIVE/WETH equivalence path (nana-router-terminal-v6) + +**Severity:** LOW +**File:** `src/JBRouterTerminal.sol:1685-1690` + +Step 2b (NATIVE/WETH equivalence) lacks the `accountingContextsOf().length != 0` guard that step 2 has. If a project owner sets the router as primary terminal for WETH, self-referential routing causes gas exhaustion. Requires owner misconfiguration. + +Admin note: ok, fix? + +--- + +### L-10: Registry `_acceptFundsFor` returns nominal amount instead of balance delta (nana-router-terminal-v6) + +**Severity:** LOW +**File:** `src/JBRouterTerminalRegistry.sol:478-480` + +Fee-on-transfer tokens through the registry revert cleanly. Documented as intentional at L466-468. + +--- + +### L-11: Registry Permit2 `catch{}` lacks event emission (nana-router-terminal-v6) + +**Severity:** LOW +**File:** `src/JBRouterTerminalRegistry.sol:470-471` + +Router emits `Permit2AllowanceFailed` on Permit2 failure; registry has silent `catch {}`. Operational observability gap. + +Admin note: ok, fix. + +--- + +### L-12: `discoverPool()` V3-only wrapper returns address(0) when V4 pool wins (nana-router-terminal-v6) + +**Severity:** LOW +**File:** `src/JBRouterTerminal.sol:196-206` + +View function only. Internal routing correctly uses both V3+V4 via `_discoverPool()`. External integrators should use `discoverBestPool()`. + +Admin note: ok, fix? + +--- + +### L-13: Default terminal DoS after `disallowTerminal` (nana-router-terminal-v6) + +**Severity:** LOW +**File:** `src/JBRouterTerminalRegistry.sol:297-304` + +When owner disallows the current default terminal, `defaultTerminal` set to address(0). Unlocked projects relying on default get DoS'd. Recoverable by `setDefaultTerminal()`. Admin trust assumption. + +Admin note: probably shouldnt be allowed to disallow current default terminal without first replacing it. fix. also make sure this is accounted for in buyback registry too + +--- + +### L-14: Short TWAP window silent degradation (nana-router-terminal-v6) + +**Severity:** LOW +**File:** `src/JBRouterTerminal.sol:1261-1265` + +`MIN_TWAP_WINDOW` of 120s allows a 2-minute TWAP which offers less manipulation resistance than the intended 10-minute window. Sigmoid slippage formula provides additional protection (2% floor). + +Admin note: fix? + +--- + +### L-15: `useReserveBeneficiaryAsDefault` retroactively inflates `totalCashOutWeight` (nana-721-hook-v6) + +**Severity:** LOW +**File:** `src/JB721TiersHookStore.sol:914-921` + +Setting `useReserveBeneficiaryAsDefault = true` via `adjustTiers` retroactively creates pending reserves for existing tiers that previously had no beneficiary. 50 NFTs minted from a tier with reserveFrequency=5 suddenly gain 10 pending reserves, diluting cash-out value by ~17%. + +--- + +### L-16: ERC-20 tokens stuck in hook with no recovery path (nana-721-hook-v6) + +**Severity:** LOW +**File:** `src/libraries/JB721TiersHookLib.sol:374-380` + +When `_sendPayoutToSplit` returns false AND `_addToBalance` reverts, ERC-20 tokens remain in the hook with no built-in recovery mechanism. + +--- + +### L-17: Code comment falsely claims ERC-20 tokens can be recovered by project owner (nana-721-hook-v6) + +**Severity:** LOW +**File:** `src/libraries/JB721TiersHookLib.sol:378-380` + +No `recover` or `sweep` function exists. Comment is inaccurate. + +Admin note: fix + +--- + +### L-18: `recordSetDiscountPercentOf` does not check tier removal bitmap (nana-721-hook-v6) + +**Severity:** LOW +**File:** `src/JB721TiersHookStore.sol:1190-1212` + +Owner can set a discount on a removed tier. No economic impact — removed tiers can't be minted from. + +Admin note: might as well fix? + +--- + +### L-19: Implementation contract can be initialized (nana-721-hook-v6) + +**Severity:** LOW +**File:** `src/JB721TiersHook.sol` + +Anyone can call `initialize()` on the implementation contract. No impact — clones have separate storage, implementation holds no funds. + +Admin note: might as well fix? + +--- + +### L-20: `FEE()` call inside try success handler — uncaught revert path (univ4-router-v6) + +**Severity:** LOW +**File:** `src/JBUniswapV4Hook.sol:228` + +`FEE()` is called inside the try success handler, not the try expression. If a non-standard terminal doesn't implement `IJBFeeTerminal`, the revert propagates uncaught. Standard `JBMultiTerminal` always implements `FEE` as a constant — not affected. + +Admin note: fix? + +--- + +### L-21: Persistent storage for `_routing` flag — gas optimization (univ4-router-v6) + +**Severity:** LOW +**File:** `src/JBUniswapV4Hook.sol:144, 950, 1004` + +Uses persistent storage instead of EIP-1153 transient storage (available on Cancun). ~22,700 gas overhead per JB-routed swap. Functionally correct. + +Admin note: fix? + +--- + +### L-22: int56 `tickCumulative` overflow after ~1.4 years (univ4-router-v6) + +**Severity:** LOW +**File:** `src/libraries/Oracle.sol:56` + +At max tick (887,272) with continuous accumulation, overflow occurs after ~1.4 years. Documented in code comments and RISKS.md. Matches Uniswap V3's oracle design. + +--- + +### L-23: `hookData` length check inconsistency (univ4-router-v6) + +**Severity:** LOW +**File:** `src/JBUniswapV4Hook.sol:625 vs 573` + +`_beforeSwap` uses strict `== 32`, `_afterSwap` uses permissive `>= 32`. Safe because `_beforeSwap` executes first. Cosmetic inconsistency. + +Admin note: fix? + +--- + +### L-24: Static weight estimation divergence (univ4-router-v6) + +**Severity:** LOW +**File:** `src/JBUniswapV4Hook.sol:260-266` + +Estimation uses static ruleset `weight`, not data-hook-adjusted weight. Documented in NatSpec and RISKS.md. + +Admin note: fix? + +--- + +### L-25: Conservative sell-side estimation (univ4-router-v6) + +**Severity:** LOW +**File:** `src/JBUniswapV4Hook.sol:226-229` + +Always deducts protocol fee (even for feeless addresses). Biases routing toward V4. Intentionally conservative. + +--- + +### L-26: Both-JB-tokens buy-side-only evaluation (univ4-router-v6) + +**Severity:** LOW +**File:** `src/JBUniswapV4Hook.sol:668-671` + +When both tokens are JB project tokens, only buy-side evaluated. Gas optimization. Both-JB-token pools are uncommon. + +Admin note: fixable? worth it? + +--- + +### L-27: Deployment script address verification (univ4-router-v6) + +**Severity:** LOW +**File:** `script/Deploy.s.sol:62-76` + +All addresses verified correct for this repo. Noted that other repos have incorrect PoolManager addresses. + +--- + +### L-28: Registry pay path reverts when no hook configured (nana-buyback-hook-v6) + +**Severity:** LOW +**File:** `src/JBBuybackHookRegistry.sol:304-321` + +`beforePayRecordedWith` calls `hook.beforePayRecordedWith()` on address(0) when no default hook is set. Cash-out path has the address(0) guard but pay path doesn't. Intentional — no safe fallback for pay path. Deploy.s.sol always calls `setDefaultHook()`. + +--- + +### L-29: `setPoolFor(PoolKey)` allows non-oracle hooks (nana-buyback-hook-v6) + +**Severity:** LOW +**File:** `src/JBBuybackHook.sol:431-462` + +The PoolKey overload doesn't validate `poolKey.hooks == ORACLE_HOOK`. Pool is permanently set (one-shot). Result: permanent mint-only mode. Self-inflicted by project owner. The simplified overload always uses the correct oracle hook. + +--- + +### L-30: `setTwapWindowOf` does not validate `_poolIsSet` (nana-buyback-hook-v6) + +**Severity:** LOW +**File:** `src/JBBuybackHook.sol:493-516` + +Allows setting TWAP window for a non-existent pool. Value is harmless — overwritten by `_setPoolFor`. Could cause operator confusion. + +Admin note: ok, fix? + +--- + +### L-31: ERC2771 `_msgSender()` in bridge authentication paths (nana-suckers-v6) + +**Severity:** LOW +**File:** `src/JBSucker.sol:478-480`, `src/JBCCIPSucker.sol:131-133` + +Bridge auth uses `_msgSender()` which resolves through trusted forwarder. Current OZ `ERC2771Forwarder` requires ECDSA signatures from the `from` address (contracts can't produce these). Defense-in-depth concern — bridge messengers never use meta-transactions. + +**Fix:** Use `msg.sender` directly in bridge auth paths. + +Admin note: ok, verify, and if fixed add comments so future auditors dont revert. + +--- + +### L-32: `mapTokens()` ETH permanently stuck when no root flush needed (nana-suckers-v6) + +**Severity:** LOW +**File:** `src/JBSucker.sol:542-572` + +ETH stuck when `numberToDisable == 0` (all enables, or disables with already-flushed outboxes). NatSpec warns about scenario 1 but not scenario 2. + +--- + +### L-33: Fee payment best-effort bypass (nana-suckers-v6) + +**Severity:** LOW +**File:** `src/JBSucker.sol:711-727` + +When fee terminal reverts, fee silently redirected to bridge transport. Not attacker-controllable. Availability-over-correctness design choice. + +--- + +### L-34: Quorum uses live tier supply, not snapshotted (defifa-collection-deployer-v6) + +**Severity:** LOW +**File:** `src/DefifaGovernor.sol:423-443` + +Currently safe — SCORING phase blocks all supply changes. Fragile: relies on implicit coupling between cash-out weight system and governance. + +Admin note: should we improve? how so? + +--- + +### L-35: Zero slippage protection on fulfillment payouts (defifa-collection-deployer-v6) + +**Severity:** LOW +**File:** `src/DefifaDeployer.sol:335` + +`sendPayoutsOf()` called with `minTokensPaidOut: 0`. Safe for native ETH games (no swap). Theoretical risk for ERC-20 games. + +Admin note: only thing we could do here is first call previewPay. but might be overkill. recipient should have their own slippage protection in their pay hook. + +--- + +### L-36: Integer division dust permanently locked (defifa-collection-deployer-v6) + +**Severity:** LOW +**File:** `src/DefifaHookLib.sol:132` + +Max 128 wei per game locked from rounding. Economically insignificant. + +--- + +### L-37: Sentinel value (1 wei) accounting error after failed fulfillment (defifa-collection-deployer-v6) + +**Severity:** LOW +**File:** `src/DefifaDeployer.sol:340` + +`fulfilledCommitmentsOf[gameId] = 1` sentinel causes `currentGamePotOf(true)` to overstate by 1 wei. Display-only. + +--- + +### L-38: No reentrancy guards — CEI-only defense (defifa-collection-deployer-v6) + +**Severity:** LOW +**File:** Multiple in `src/DefifaDeployer.sol` + +All reentrancy protection relies on CEI ordering and idempotency guards. Currently correct. Fragile for future refactoring. + +Admin note: prefer sound function order design without slapping modifier reentrency protection just because. + +--- + +### L-39: External calls in view function — token URI resolver (defifa-collection-deployer-v6) + +**Severity:** LOW +**File:** `src/DefifaTokenUriResolver.sol` + +`tokenUriOf()` makes external view calls. Only affects off-chain metadata reads. + +--- + +### L-40: `_opsOf` written before project ID assertion (defifa-collection-deployer-v6) + +**Severity:** LOW +**File:** `src/DefifaDeployer.sol` + +Predicted game ID used before assertion. `assert` reverts entire transaction atomically, so stale writes are rolled back. Uses all remaining gas on failure. + +Admin note: fix? + +--- + +### L-41: Fee terminal address(0) reverts all `mintFrom()` calls (croptop-core-v6) + +**Severity:** LOW +**File:** `src/CTPublisher.sol:413-428` + +If fee project's primary ETH terminal is removed, all paid minting across all Croptop projects is blocked. Recoverable by re-adding terminal. + +Admin note: should we try catch? worth it? + +--- + +### L-42: Old owner retains 4 permissions after project NFT transfer (croptop-core-v6) + +**Severity:** LOW +**File:** `src/CTDeployer.sol:335-349` + +Permissions granted from CTDeployer persist after NFT transfer. Effectively inert after `claimCollectionOwnershipOf()`. Documented. + +--- + +### L-43: `tiersFor()` view returns stale data for removed tiers (croptop-core-v6) + +**Severity:** LOW +**File:** `src/CTPublisher.sol` + +Off-chain UIs see tier data for URIs no longer mintable. `_setupPosts()` correctly detects removed tiers at mint time. Documented. + +Admin note: anything to do here to improve? + +--- + +### L-44: Cannot fully disable a configured posting category (croptop-core-v6) + +**Severity:** LOW +**File:** `src/CTPublisher.sol` + +`configurePostingCriteriaFor()` requires `minimumTotalSupply > 0`. No separate disable function. Workaround: empty allowlist. Documented. + +Admin note: anything to do here? + +--- + +### L-45: Deployment CREATE2 address mismatch in `_isDeployed` (banny-retail-v6) + +**Severity:** LOW +**File:** `script/Deploy.s.sol:430-448` + +`_isDeployed` uses Arachnid proxy as deployer but actual deployment uses Solidity's `new {salt}` (different deployer). Idempotence check broken. + +Admin note: this isnt true. it works. + +--- + +### L-46: Reflexive controller validation can be spoofed (nana-omnichain-deployers-v6) + +**Severity:** LOW +**File:** `src/JBOmnichainDeployer.sol:872-876` + +`_validateController` queries `controller.DIRECTORY()` — a malicious controller can return a fake directory. Requires `LAUNCH_RULESETS`/`QUEUE_RULESETS` permission. Real project state unaffected. + +Admin note: anything to do here? + +--- + +### L-47: `_launchRulesetsFor` lacks ruleset ID prediction guard (nana-omnichain-deployers-v6) + +**Severity:** LOW +**File:** `src/JBOmnichainDeployer.sol:715-757` + +`_queueRulesetsOf` has the `latestRulesetId >= block.timestamp` guard; `_launchRulesetsFor` doesn't. Unreachable in practice — `launchRulesetsFor` requires no existing rulesets. + +--- + +### L-48: No post-hoc validation of returned ruleset IDs (nana-omnichain-deployers-v6) + +**Severity:** LOW +**File:** `src/JBOmnichainDeployer.sol:751, 817` + +Project ID is validated post-hoc but ruleset IDs are not. Safe under current core protocol ID assignment logic. + +--- + +### L-49: Constructor lacks explicit project existence check (nana-ownable-v6) + +**Severity:** LOW +**File:** `src/JBOwnableOverrides.sol:53-83` + +If `initialProjectIdOwner` refers to a non-existent project, `PROJECTS.ownerOf()` reverts with opaque ERC-721 error instead of clear `ProjectDoesNotExist`. Documented as intentional in NatSpec. + +--- + +### L-50: `_emitTransferEvent` asymmetric try-catch behavior (nana-ownable-v6) + +**Severity:** LOW +**File:** `src/JBOwnable.sol:64-78` + +Read paths (`owner()`, `_checkOwner()`) use try-catch; write path (`_emitTransferEvent`) does not. Intentional — strict failure on writes prevents event/state inconsistency. Documented in NatSpec. + +--- + +### L-51: Deploy script idempotency check uses wrong CREATE2 deployer (nana-address-registry-v6) + +**Severity:** LOW +**File:** `script/Deploy.s.sol:28-38` + +`_isDeployed` uses Arachnid deterministic-deployment-proxy address but `run()` deploys via Sphinx (different deployer). Sphinx handles idempotency at framework level. + +Admin note: not true. + +--- + +### L-52: Missing Sphinx emergency developer configuration (nana-address-registry-v6) + +**Severity:** LOW +**File:** `script/Deploy.s.sol:13` + +TODO comment indicates JB Emergency Developers should be configured but haven't been added. Near-zero impact for this permissionless registry with no admin functions. + +--- + +### L-53: `NANA_START_TIME` is in the past with no functional effect (nana-fee-project-deployer-v6) + +**Severity:** LOW +**File:** `script/Deploy.s.sol:52` + +`NANA_START_TIME = 1,740,089,444` (Feb 2025) passed as `startsAtOrAfter`. JBRulesets uses `max(block.timestamp, mustStartAtOrAfter)` — constant has zero effect. Potentially misleading for token economics planning. + +--- + +### L-54: Operator split is unlocked (`lockedUntil: 0`) (nana-fee-project-deployer-v6) + +**Severity:** LOW +**File:** `script/Deploy.s.sol:115-123` + +Operator split directs 100% of reserved tokens to the Sphinx multisig with `lockedUntil: 0`. Multisig can change split at any time. Documented trust assumption in RISKS.md. + +--- + +## INFORMATIONAL Findings + +### I-1: Cash-out swap path has no try/catch — unlike pay path (nana-buyback-hook-v6) + +**File:** `src/JBBuybackHook.sol` + +Pay path uses try/catch with mint fallback; cash-out path does not. Intentional — cash-out mints tokens BEFORE swap, so a silent failure would inflate supply. Hard revert is correct. + +--- + +### I-2: Empty `hookMetadata` yields zero slippage protection (nana-buyback-hook-v6) + +**File:** `src/JBBuybackHook.sol:210-214` + +When `hookMetadata.length == 0`, `minimumSwapAmountOut = 0`. Only occurs if a different data hook returns a specification pointing to the buyback hook. Normal flow always populates metadata. + +--- + +### I-3: Arbitrum cross-batch token fungibility (nana-suckers-v6) + +**File:** `src/JBSucker.sol` + +On Arbitrum, leftover tokens from a previous batch can satisfy claims from a new batch whose tokens haven't arrived. Economically sound (tokens fungible). May confuse off-chain monitoring. + +--- + +### I-4: Event emission reflects intent, not final state (banny-retail-v6) + +**File:** `src/Banny721TokenUriResolver.sol:1005-1007` + +`DecorateBanny` event emitted BEFORE execution. Background abort or outfit retention causes event/state divergence. Off-chain indexers should re-read `assetIdsOf()`. + +Admin note: anything to fix here? + +--- + +### I-5: Retained outfits accumulate in attachment array (banny-retail-v6) + +**File:** `src/Banny721TokenUriResolver.sol:1451-1463` + +Repeated redecorations by NonReceiverContract grow the array by +1 per failed return. Self-inflicted gas grief bounded by actual NFTs equipped. + +Admin note: anything to fix here? + +--- + +### I-6: Simplified overloads set `useDataHookForCashOut = false` by default (nana-omnichain-deployers-v6) + +**File:** `src/JBOmnichainDeployer.sol` + +By design — simplified path for projects without 721 cashout support. Explicit overloads with `JBOmnichain721Config` allow setting the flag. + +--- + +### I-7: Cash out spec merge hardcodes 721 hook to single specification (nana-omnichain-deployers-v6) + +**File:** `src/JBOmnichainDeployer.sol:204-206` + +Takes only `tiered721HookSpecifications[0]`. `JB721TiersHook` always returns exactly 0 or 1 spec. Documented. + +--- + +## Clean Repos + +### nana-permission-ids-v6 + +All 33 permission ID constants verified: unique (1-33, no gaps), correctly typed (`uint8 internal constant`), properly documented, and consistently used across the ecosystem. 5 invariants verified. Cross-repo verification confirmed correct usage across 7 consuming repos. + +### deploy-all-v6 + +No Claude nemesis findings were produced for this repo (only Codex findings exist in the findings directory). diff --git a/CODEX_AUDIT.md b/CODEX_AUDIT.md new file mode 100644 index 0000000..6ef67ab --- /dev/null +++ b/CODEX_AUDIT.md @@ -0,0 +1,427 @@ +# Codex Nemesis Audit — Aggregated Findings + +**Run:** 2026-03-23 (run ID `20260323-211452`) +**Repos audited:** 17 +**Total findings:** 31 verified true positives +**Breakdown:** 0 Critical | 5 High | 16 Medium | 10 Low +**Clean repos:** 3 (nana-ownable-v6, nana-address-registry-v6, nana-permission-ids-v6) + +--- + +## Summary by Severity + +| Severity | Count | Repos affected | +|----------|-------|----------------| +| Critical | 0 | — | +| High | 5 | univ4-lp-split-hook-v6 (2), nana-suckers-v6 (1), defifa-collection-deployer-v6 (1), deploy-all-v6 (1) | +| Medium | 16 | nana-core-v6 (1), univ4-lp-split-hook-v6 (1), revnet-core-v6 (1), nana-router-terminal-v6 (1), univ4-router-v6 (1), nana-buyback-hook-v6 (2), nana-suckers-v6 (1), croptop-core-v6 (2), banny-retail-v6 (1), nana-omnichain-deployers-v6 (2), nana-fee-project-deployer-v6 (2), deploy-all-v6 (1) | +| Low | 10 | revnet-core-v6 (1), nana-router-terminal-v6 (1), nana-721-hook-v6 (5), nana-suckers-v6 (2), banny-retail-v6 (1) | + +## Summary by Repo + +| # | Repo | C | H | M | L | Total | +|---|------|---|---|---|---|-------| +| 1 | nana-core-v6 | 0 | 0 | 1 | 0 | 1 | +| 2 | univ4-lp-split-hook-v6 | 0 | 2 | 1 | 0 | 3 | +| 3 | revnet-core-v6 | 0 | 0 | 1 | 1 | 2 | +| 4 | nana-router-terminal-v6 | 0 | 0 | 1 | 1 | 2 | +| 5 | nana-721-hook-v6 | 0 | 0 | 0 | 5 | 5 | +| 6 | univ4-router-v6 | 0 | 0 | 1 | 0 | 1 | +| 7 | nana-buyback-hook-v6 | 0 | 0 | 2 | 0 | 2 | +| 8 | nana-suckers-v6 | 0 | 1 | 1 | 2 | 4 | +| 9 | defifa-collection-deployer-v6 | 0 | 1 | 0 | 0 | 1 | +| 10 | croptop-core-v6 | 0 | 0 | 2 | 0 | 2 | +| 11 | banny-retail-v6 | 0 | 0 | 1 | 1 | 2 | +| 12 | nana-omnichain-deployers-v6 | 0 | 0 | 2 | 0 | 2 | +| 13 | nana-ownable-v6 | 0 | 0 | 0 | 0 | 0 | +| 14 | nana-address-registry-v6 | 0 | 0 | 0 | 0 | 0 | +| 15 | nana-permission-ids-v6 | 0 | 0 | 0 | 0 | 0 | +| 16 | nana-fee-project-deployer-v6 | 0 | 0 | 2 | 0 | 2 | +| 17 | deploy-all-v6 | 0 | 1 | 1 | 0 | 2 | + +--- + +## HIGH Findings + +### H-1: Reserved fee-token claims can be diverted during deploy/rebalance (univ4-lp-split-hook-v6) + +**Severity:** HIGH +**File:** `src/JBUniswapV4LPSplitHook.sol` + +Fee-project tokens promised to prior projects via `claimableFeeTokens` are not excluded from terminal-side leftover handling or rebalance balance snapshots. When the reserved token appears on the terminal side of a later project's pool, `_handleLeftoverTokens()` or `_mintRebalancedPosition()` can consume those reserved tokens, leaving the original claimant unable to recover them. + +**Trigger:** Project A collects LP fees as token `F`. Project B uses the same hook clone with `terminalToken == F`. B's `deployPool()` or `rebalanceLiquidity()` sweeps A's reserved `F` balance. + +**Fix:** Subtract `_totalOutstandingFeeTokenClaims[token]` from both project-token and terminal-token available balances before leftover handling and rebalancing. + +--- + +### H-2: Non-Ethereum deploys hardcode the wrong Uniswap V4 PoolManager (univ4-lp-split-hook-v6) + +**Severity:** HIGH +**File:** `script/Deploy.s.sol:70` + +Line 70 hardcodes `poolManager = IPoolManager(0x000000000004444c5dc75cB358380D2e3dE08A90)` for all chains. Only Ethereum mainnet uses that address. Verified against [Uniswap V4 deployments](https://docs.uniswap.org/contracts/v4/deployments): + +| Chain | Chain ID | Script uses | Correct PoolManager | +|-------|----------|-------------|---------------------| +| Ethereum | 1 | `0x00...8A90` | `0x000000000004444c5dc75cB358380D2e3dE08A90` (correct) | +| Optimism | 10 | `0x00...8A90` | `0x9a13f98cb987694c9f086b1f5eb990eea8264ec3` | +| Base | 8453 | `0x00...8A90` | `0x498581ff718922c3f8e6a244956af099b2652b2b` | +| Arbitrum | 42161 | `0x00...8A90` | `0x360e68faccca8ca495c1b759fd9eee466db9fb32` | +| Sepolia | 11155111 | `0x00...8A90` | `0xE03A1074c86CFeDd5C142C4F04F1a1536e203543` | +| Base Sepolia | 84532 | `0x00...8A90` | `0x05E73354cFDd6745C338b50BcFDfA3Aa6fA03408` | +| Arbitrum Sepolia | 421614 | `0x00...8A90` | `0xFB3e0C6F74eB1a21CC1Da29aeC80D2Dfe6C9a317` | + +Deploying on any non-Ethereum chain produces a hook with the wrong `POOL_MANAGER` immutable. Pool initialization, slot0 reads, and liquidity operations target a nonexistent or wrong contract. + +**Fix:** Use per-chain PoolManager resolution matching the router deployment script, or add a chain-conditional lookup similar to `positionManager` (lines 73-99). + +!!! Admin note: add the url https://docs.uniswap.org/contracts/v4/deployments where official addresses are found as an inline comment so others can verify. + +--- + +### H-3: Destination deprecation can permanently strand already-sent leaves (nana-suckers-v6) + +**Severity:** HIGH +**File:** `src/JBSucker.sol:477` + +If the bridge delivers a valid root after the destination sucker reaches `DEPRECATED`, `fromRemote()` discards the root. Destination claims fail. Source emergency exit also fails because the leaf was already counted in `numberOfClaimsSent`. Backing assets are permanently trapped. + +**Trigger:** User sends a leaf before deprecation. Bridge delivers root after deprecation completes. + +**Fix:** Continue accepting roots for already-sent leaves after deprecation, or add an explicit source-side recovery path for sent leaves with rejected roots. + +!!! Admin note: continue accepting. + +--- + +### H-4: Sponsored mints let one NFT create multiple full-governance positions (defifa-collection-deployer-v6) + +**Severity:** HIGH +**File:** `src/DefifaHook.sol:969-980` + +`_processPayment()` credits attestation units to `context.payer` but `_mintAll()` mints the NFT to `context.beneficiary`. When `payer != beneficiary`, a transfer triggers `_update()` which moves another full set of units. Both the beneficiary and the transfer recipient end up with full governance weight from a single NFT. This can be used to manipulate scorecard ratification and redirect treasury value. + +**Fix:** Credit attestation units to `context.beneficiary` (the actual NFT recipient) instead of `context.payer`. + +!!! Admin note: yes. + +--- + +### H-5: Banny can be deployed to the wrong project ID without any mismatch check (deploy-all-v6) + +**Severity:** HIGH +**File:** `script/Deploy.s.sol` + +`_deployBanny()` calls `REVDeployer.deployFor(revnetId: 0)` which creates a new project at `PROJECTS.count() + 1`. If `count >= 4` before Phase 09 runs and project 4 isn't the intended Banny revnet, Banny gets a drifted project ID. The script doesn't detect or assert the returned ID matches `_BAN_PROJECT_ID == 4`. + +**Fix:** Reserve/validate project 4 before BAN deployment and assert the returned revnet ID equals `_BAN_PROJECT_ID`. + +!!! Admin note: yes, validate deployFor returns project 4. + +--- + +## MEDIUM Findings + +### M-1: Stale fee-free surplus makes later zero-tax cashouts pay the wrong fee (nana-core-v6) + +**File:** `src/JBMultiTerminal.sol` + +`_feeFreeSurplusOf` is incremented by `executePayout()` and decremented by `_cashOutTokensOf()`, but `_useAllowanceOf()` removes surplus without updating this accumulator. After a fee-free payout is withdrawn through allowance, future zero-tax cashouts still see the stale fee-free amount and charge a 2.5% fee on new surplus that shouldn't be subject to it. + +**Fix:** Decrement `_feeFreeSurplusOf` proportionally when surplus leaves through `_useAllowanceOf()`. + +!!! Admin note: ok, fix. + +--- + +### M-2: Hook prices LP deployment from total surplus even when terminal only sees local surplus (univ4-lp-split-hook-v6) + +**File:** `src/JBUniswapV4LPSplitHook.sol:277-301` + +`_getCashOutRate()` always uses `currentTotalReclaimableSurplusOf(...)` regardless of the project's `useTotalSurplusForCashOuts` setting. When that setting is false, the hook overstates the terminal-token floor. Real `cashOutTokensOf()` uses only local surplus and returns less, causing deploy/rebalance revert or skewed LP positions. + +**Fix:** Query local reclaimable surplus unless the current ruleset explicitly enables total-surplus cashouts. + +!!! Admin note: ok, fix. + +--- + +### M-3: Deploy.s.sol is not restart-safe because it mutates the fee project ID before singleton discovery (revnet-core-v6) + +**File:** `script/Deploy.s.sol` + +The script creates a fresh fee project before checking whether singleton contracts (REVLoans, REVDeployer) already exist. Retries create `N+1` as the fee project, producing a different init-code hash that can no longer discover the original singleton pair. + +**Fix:** Resolve or persist the fee project ID before checking singleton existence. + +!!! Admin note: ok, fix. + +--- + +### M-4: Registry-wrapped `addToBalanceOf()` permanently traps partial-fill leftovers (nana-router-terminal-v6) + +**File:** `src/JBRouterTerminalRegistry.sol:252`, `src/JBRouterTerminal.sol:221` + +When a user calls `registry.addToBalanceOf()` with a swap that partially fills, the router refunds unused input to `_msgSender()` (the registry), not the original user. The registry has no recovery path, so the leftover input is permanently stuck. + +**Fix:** Preserve the original caller as the refund target across the wrapper boundary. + +!!! Admin note: ok. how though? + +--- + +### M-5: Buy-side route selection can systematically prefer V4 over a better Juicebox pay path (univ4-router-v6) + +**File:** `src/JBUniswapV4Hook.sol:243` + +The hook computes its own buy-side quote using token metadata and static ruleset fields instead of calling the terminal's `previewPayFor()`. For non-standard tokens where `decimals()` reverts, the hook falls back to 18 decimals and under-quotes the Juicebox path, routing users into strictly worse V4 execution. + +**Fix:** Use the terminal's canonical `previewPayFor()` for buy-side routing decisions. + +!!! Admin note: ok, fix. + +--- + +### M-6: Registry pool configuration can silently no-op until the first payment reverts (nana-buyback-hook-v6) + +**File:** `src/JBBuybackHookRegistry.sol` + +`lockHookFor()` rejects an unresolved hook, but `initializePoolFor()`, `setPoolFor()`, and `beforePayRecordedWith()` assume one exists. Setup transactions can succeed while leaving the project unconfigured. The first payment flow becomes a hard DoS. + +**Fix:** Add the same zero-hook guard used by `lockHookFor()` to the pool-configuration and pay-forwarding paths. + +!!! A default should exist at least. feel free to make sure of it. whatever you do in this repo, also do in Router terminal registry. + +--- + +### M-7: Deploy script uses unverified placeholder PoolManager addresses on supported testnets (nana-buyback-hook-v6) + +**File:** `script/Deploy.s.sol` + +Sepolia, OP Sepolia, Base Sepolia, and Arbitrum Sepolia all map to Ethereum mainnet's canonical PoolManager address. Uniswap warns against assuming cross-chain address reuse. + +**Fix:** Require per-chain PoolManager addresses explicitly or revert on testnets without verified addresses. + +!!! Admin note: verify https://docs.uniswap.org/contracts/v4/deployments + +--- + +### M-8: `deploySuckersFor()` only works if the registry has its own hidden mapping permission (nana-suckers-v6) + +**File:** `src/JBSuckerRegistry.sol:236` + +The registry deploys the sucker then calls `sucker.mapTokens()` as itself. `_mapToken()` checks `MAP_SUCKER_TOKEN` against `msg.sender == registry`, not the original operator. The documented one-call flow fails unless the registry is pre-granted that extra permission. + +**Fix:** Preserve the original operator for the mapping step, or move mapping into a path that doesn't require a second permission hop. + +!!! Admin note: ok, but this is good as is, dont make it worse or less safe. + +--- + +### M-9: Reentrant inner mint can capture another caller's parked fee payout (croptop-core-v6) + +**File:** `src/CTPublisher.sol:398-428` + +`mintFrom()` leaves the outer fee in the contract balance while paying the terminal. A reentrant inner call can sweep the entire balance (including the outer caller's fee) to a different `feeBeneficiary`. + +**Fix:** Pin the fee amount to a call-local variable before the external call. Send exactly that amount in the fee leg. + +!!! Admin note: ok, fix. + +--- + +### M-10: Initial owner keeps hook-management powers after selling the project (croptop-core-v6) + +**File:** `src/CTDeployer.sol:328-349` + +`deployProjectFor()` grants hook-management permissions under the `CTDeployer` account. Transferring the project NFT doesn't revoke these permissions. The seller retains tier adjustment, NFT minting, metadata, and discount permissions until the buyer manually calls `claimCollectionOwnershipOf()`. + +**Fix:** Transfer hook ownership to the project immediately, or auto-revoke stale operator grants on NFT transfer. + +!!! Admin note: no ned to auto-revoke since permissions are relative to the owner, and since owner changed, permissions need to be re-granted by new owner. + +--- + +### M-11: Failed-return retention lets mutually-exclusive outfits coexist on one body (banny-retail-v6) + +**File:** `src/Banny721TokenUriResolver.sol:1435` + +Category exclusivity checks only validate the fresh input. When returning old outfits fails (e.g., to a contract that rejects ERC-721 transfers), the old entries are retained and merged with new ones without re-checking category conflicts. `HEAD` and `EYES` (or `SUIT` with `SUIT_TOP`/`SUIT_BOTTOM`) can coexist on the same body. + +**Fix:** Re-run category exclusivity checks on the combined `new + retained` set before storing. + +!!! Admin note: ok, fix. + +--- + +### M-12: The omnichain wrapper erases the 721 hook's `issueTokensForSplits` behavior (nana-omnichain-deployers-v6) + +**File:** `src/JBOmnichainDeployer.sol:235-282` + +`beforePayRecordedWith()` keeps the 721 hook's split amount but discards its returned weight, re-scaling from `projectAmount / totalAmount`. When `issueTokensForSplits=true`, the payer receives fewer project tokens than configured. If splits consume the full payment, weight can be forced to zero. + +**Fix:** Start from the 721 hook's returned weight when a 721 hook is active. + +!!! Admin note: ok, yes seems meaningful. must be tested thoroughly. + +--- + +### M-13: `deploySuckersFor` is unusable for existing projects because the registry re-checks permissions (nana-omnichain-deployers-v6) + +**File:** `src/JBOmnichainDeployer.sol:390-403` + +The wrapper validates the caller locally, then calls `JBSuckerRegistry.deploySuckersFor`. The registry re-checks `DEPLOY_SUCKERS` against `msg.sender == JBOmnichainDeployer`, which doesn't have that permission by default. The public wrapper for adding suckers to existing projects reverts in the normal case. + +**Fix:** Add a registry-side omnichain operator override, or expose a registry entry point for authenticated wrappers. + +!!! Admin note: the project owner should give this permission to the omnichain deployer before calling deploySuckersFor. + +--- + +### M-14: Default artifact resolution path is non-functional and hard-reverts deployment (nana-fee-project-deployer-v6) + +**File:** `script/Deploy.s.sol` + +`run()` uses npm-default artifact paths, but installed packages contain no `deployments/` directory. `CoreDeploymentLib` hardcodes `nana-core-v5` as the project name. `vm.readFile` reverts immediately on a fresh clone. + +**Fix:** Make deployment artifact paths explicit via environment variables and fail fast if unset. + +!!! Admin note: the deployments dir will come, dont worry. + +--- + +### M-15: Hardcoded February 2025 stage start now launches with decayed issuance and forced cash-out delay (nana-fee-project-deployer-v6) + +**File:** `script/Deploy.s.sol:131` + +`stageConfigurations[0].startsAtOrAfter = 1740089444` (2025-02-20). Deploying after 2026-02-15, `JBRulesets.currentOf()` simulates elapsed cycles instead of initial issuance weight. As of audit date (2026-03-23), effective issuance is ~6,200 NANA/ETH instead of 10,000. Cash-outs would be delayed 30 days from deployment. + +**Fix:** Pass the intended start time through configuration instead of hardcoding a historical timestamp. + +!!! Admin note: ill fix at a later time. + +--- + +### M-16: Later-phase replay is blocked by unconditional CREATE2 deployments (deploy-all-v6) + +**File:** `script/Deploy.s.sol` + +Feed contracts and the Banny resolver are deployed with fixed salts without `_isDeployed(...)` guards, unlike most other phases. Replaying after a later-phase failure causes CREATE2 collisions. + +**Fix:** Adopt `_isDeployed(...)` guards for these CREATE2 artifacts. + +!!! Admin note: ok, fix. + +--- + +## LOW Findings + +### L-1: Loan ID collisions possible because namespace cap is never enforced where new IDs are minted (revnet-core-v6) + +**File:** `src/REVLoans.sol` + +`loanId = revnetId * 1e12 + loanNumber`. The counter is incremented in three mutation paths but the `REVLoans_LoanIdOverflow()` guard only appears in the liquidation path. After 1e12 loans, IDs enter the next revnet's namespace. + +**Fix:** Add `if (nextLoanNumber >= _ONE_TRILLION) revert REVLoans_LoanIdOverflow()` to all three minting paths. + +!!! Admin note: ok, fix. + +--- + +### L-2: `pay()` routes unused input to `beneficiary` instead of the payer (nana-router-terminal-v6) + +**File:** `src/JBRouterTerminal.sol:256` + +When Alice calls `pay()` with Bob as beneficiary and the swap partially fills, Bob receives the unused input remainder instead of Alice. + +!!! Admin note: this is necessary since the payer could be another contract... which is the case in Router Terminal forwarding. + +--- + +### L-3: Leftover split funds are stranded when fallback accounting has no primary terminal (nana-721-hook-v6) + +**File:** `src/libraries/JB721TiersHookLib.sol:425-460` + +If a tier split produces a `leftoverAmount` and the project has no primary terminal for that token, funds remain inside the hook contract. + +!!! Admin note: what should we do? + +--- + +### L-4: The implementation hook contract can be initialized by any caller (nana-721-hook-v6) + +**File:** `src/JB721TiersHook.sol:236-302` + +The implementation instance (deployed with `PROJECT_ID == 0`) can be initialized by any caller before anyone else does, storing attacker-chosen config. Clone functionality remains intact. + +!!! Admin note: what do you suggest? + +--- + +### L-5: `tokenURI` serves metadata for nonexistent and burned token IDs (nana-721-hook-v6) + +**File:** `src/JB721TiersHook.sol:317-319` + +The override resolves tier metadata without checking ownership/existence, unlike the base ERC-721 behavior. + +!!! Admin note: ok, what do you suggest? + +--- + +### L-6: `supportsInterface` advertises ERC-2981 support without implementing `royaltyInfo` (nana-721-hook-v6) + +**File:** `src/abstract/JB721Hook.sol:163-166` + +The hook returns `true` for `IERC2981` but has no `royaltyInfo` method. Royalty-aware integrations can fail. + +!!! Admin note: ok, what do you suggest? Remove IERC2981 if its not in use? + +--- + +### L-7: `balanceOf(address(0))` breaks ERC-721 semantics (nana-721-hook-v6) + +**File:** `src/JB721TiersHook.sol:168-170` + +Returns 0 instead of reverting, which is a standards-compliance regression from the base ERC-721. + +!!! Admin note: ok, fix. + +--- + +### L-8: `SuckerDeploymentLib` returns incomplete deployments on Ethereum Sepolia (nana-suckers-v6) + +**File:** `script/helpers/SuckerDeploymentLib.sol:58` + +`_isMainnet` checks `"sepolia"` instead of `"ethereum_sepolia"`, causing L1 deployers to be omitted. + +!!! Admin note: ok, fix. + +--- + +### L-9: CCIP deployment branch cannot be safely rerun after prior deployment attempts (nana-suckers-v6) + +**File:** `script/Deploy.s.sol:399, 561` + +Unlike OP/Base/Arbitrum branches, the CCIP path has no `_isDeployed(...)` guard. Reruns hit CREATE2 collisions. + +!!! Admin note: ok, fix. + +--- + +### L-10: Resolver deployment idempotence check computes the wrong CREATE2 address (banny-retail-v6) + +**File:** `script/Deploy.s.sol` + +`_isDeployed()` uses the Arachnid deterministic deployment proxy as deployer, but `deploy()` uses Solidity `new {salt}` which creates from the current contract. These are different CREATE2 namespaces. + +!!! Admin note: these work out to be the same in practice. + +--- + +## Clean Repos (No Findings) + +- **nana-ownable-v6** — 10 functions, 3 coupled pairs, all synced +- **nana-address-registry-v6** — 13 functions, 0 exploitable pairs +- **nana-permission-ids-v6** — 0 functions (constants-only library), all consumer cross-references verified diff --git a/CODEX_AUDIT3.md b/CODEX_AUDIT3.md new file mode 100644 index 0000000..d07be3e --- /dev/null +++ b/CODEX_AUDIT3.md @@ -0,0 +1,401 @@ +# Codex Nemesis Audit — Round 3 Triage + +**Run:** 2026-03-27 (run ID `20260327-143402`) +**Repos audited:** 17 +**Previous runs:** Round 1 (`20260323-211452`, `CODEX_AUDIT.md`), Round 2 (`20260325-200049`, `CODEX_AUDIT_2.md`) + +--- + +## Executive Summary + +Round 3 produced **25 raw findings** across 17 repos (including 1 self-eliminated false positive by Codex). After source-code verification and cross-referencing with Rounds 1 and 2: + +| Category | Count | +|----------|-------| +| **NEW true positives** (not in Round 1 or 2) | 12 | +| **Repeat/evolved findings** (confirming unfixed or newly-angled issues) | 9 | +| **False positives** (invalid after source verification) | 4 | +| **Post-triage resolved** (already fixed or not real) | 4 | + +**Net items requiring attention: 17** (3 High, 5 Medium, 9 Low) +**Already resolved in post-triage:** RPT-H-1 (CREATE2 canonical), RPT-M-3 (balance-delta fix), RPT-M-4 (dynamic owner), RPT-M-1 (partial — EOA case remains) +**Clean repos:** 3 (nana-omnichain-deployers-v6, nana-ownable-v6, nana-permission-ids-v6) + +--- + +## Severity Breakdown (All True Positives) + +| Severity | New | Repeat | Total | +|----------|-----|--------|-------| +| Critical | 0 | 0 | **0** | +| High | 2 | 3 | **5** | +| Medium | 3 | 4 | **7** | +| Low | 7 | 2 | **9** | +| **Total** | **12** | **9** | **21** | + +--- + +## Summary by Repo + +| # | Repo | C | H | M | L | Total | Notes | +|---|------|---|---|---|---|-------|-------| +| 1 | nana-core-v6 | 0 | 0 | 0 | 2 | 2 | 1 new, 1 repeat | +| 2 | univ4-lp-split-hook-v6 | 0 | 1 | 0 | 0 | 1 | 1 repeat (1 FP eliminated) | +| 3 | revnet-core-v6 | 0 | 0 | 0 | 1 | 1 | 1 repeat (1 FP eliminated) | +| 4 | nana-router-terminal-v6 | 0 | 0 | 1 | 0 | 1 | 1 repeat | +| 5 | nana-721-hook-v6 | 0 | 1 | 1 | 0 | 2 | 2 new | +| 6 | univ4-router-v6 | 0 | 0 | 1 | 0 | 1 | 1 repeat | +| 7 | nana-buyback-hook-v6 | 0 | 0 | 1 | 0 | 1 | 1 repeat | +| 8 | nana-suckers-v6 | 0 | 1 | 0 | 0 | 1 | 1 new | +| 9 | defifa-collection-deployer-v6 | 0 | 1 | 0 | 0 | 1 | 1 repeat (evolved from R2-NEW-H-2) | +| 10 | croptop-core-v6 | 0 | 0 | 1 | 1 | 2 | 1 new, 1 repeat (1 FP eliminated) | +| 11 | banny-retail-v6 | 0 | 0 | 1 | 0 | 1 | 1 new | +| 12 | nana-omnichain-deployers-v6 | 0 | 0 | 0 | 0 | 0 | Clean | +| 13 | nana-ownable-v6 | 0 | 0 | 0 | 0 | 0 | Clean | +| 14 | nana-address-registry-v6 | 0 | 0 | 0 | 2 | 2 | 2 new | +| 15 | nana-permission-ids-v6 | 0 | 0 | 0 | 0 | 0 | Clean | +| 16 | nana-fee-project-deployer-v6 | 0 | 0 | 0 | 2 | 2 | 2 new | +| 17 | deploy-all-v6 | 0 | 1 | 1 | 1 | 3 | 2 new, 1 repeat | + +--- + +## FALSE POSITIVES (4) + +### FP-1: Terminal-token leftover sweep steals fee-token claims (univ4-lp-split-hook-v6 NM-001) + +**Codex severity:** HIGH +**File:** `src/JBUniswapV4LPSplitHook.sol:1267-1274` + +Codex claimed `_handleLeftoverTokens()` sweeps terminal tokens without excluding `_totalOutstandingFeeTokenClaims[terminalToken]`, enabling cross-project theft. + +**Verdict:** FALSE POSITIVE — The finding conflates terminal tokens (ETH/USDC) with fee project tokens (JBX). Fee tokens are **project tokens**, not terminal tokens. `_routeFeesToProject()` spends terminal tokens paying into the fee project and receives back the fee project's ERC-20 token. `_totalOutstandingFeeTokenClaims` is only incremented for `feeProjectToken` (line 1534), never for `terminalToken`. The project-token balance read at line 1256 correctly subtracts `_totalOutstandingFeeTokenClaims[projectToken]`. The Round 1 H-1 fix is confirmed in place at lines 686, 826, 938, 1256, 1371, and 1534. + +--- + +### FP-2: Buyback-routed cash outs underpay the revnet fee (revnet-core-v6 NM-001) + +**Codex severity:** MEDIUM +**File:** `src/REVDeployer.sol:287-306` + +Codex claimed the fee is fixed from the bonding curve before the buyback hook upgrades the non-fee tranche to a better pool route, causing systematic fee underpayment. + +**Verdict:** FALSE POSITIVE — The fee is **token-based by design**, not value-based. `feeCashOutCount = cashOutCount * FEE / MAX_FEE` (line 287) splits 2.5% of tokens for the fee revnet, and the fee amount is their bonding-curve reclaim. The remaining 97.5% goes through whichever route is better for the user. The fee revnet always receives the bonding-curve value of its 2.5% token share, regardless of external pool conditions. Recomputing the fee based on pool price would create manipulation incentives and break the intended economic model. + +--- + +### FP-3: Ownership claim breaks future Croptop publishing (croptop-core-v6 NM-001) + +**Codex severity:** MEDIUM +**File:** `src/CTDeployer.sol:234-244`, `src/CTPublisher.sol:256` + +Codex claimed `claimCollectionOwnershipOf()` doesn't grant permissions to `CTPublisher` from the new owner, breaking publishing. + +**Verdict:** FALSE POSITIVE — This is explicitly documented behavior. The NatDoc at `CTDeployer.sol:228-232` states: *"The project owner must then grant CTPublisher the ADJUST_721_TIERS permission for the project so that mintFrom() continues to work. Without this permission grant, all subsequent posts will revert. This cannot be done atomically here because after transferring ownership to the project, this contract no longer has authority to set permissions on the project's behalf."* The function is opt-in, project-owner-initiated, and the two-step process is the only possible approach given the permission system design. + +--- + +### FP-4: Reverted payout / fee forwarding leaves spendable allowance (nana-core-v6 NM-003) + +**Codex severity:** N/A (self-eliminated) + +Codex correctly eliminated this via targeted PoC: the approval happens inside the reverting external frame, so it is rolled back. + +--- + +## NEW TRUE POSITIVES + +### NEW-H-1: Failed earlier tier splits overpay later recipients (nana-721-hook-v6) + +**Severity:** HIGH — **FIXED** +**File:** `src/libraries/JB721TiersHookLib.sol:399-423` +**Verified:** YES — confirmed against source code +**Fix:** Always decrement `leftoverAmount` before calling `_sendPayoutToSplit`; on failure, add back to leftover for routing to project balance. Existing tests pass; regression test confirms fix. + +In `_distributeSingleSplit()`, `leftoverPercentage` is decremented **unconditionally** (line 421) while `leftoverAmount` is only decremented **on success** (line 416). When a split recipient reverts, their percentage slot is consumed but the amount stays inflated, causing subsequent recipients to receive more than their intended share. + +**Example:** Two 50/50 splits distributing 1 ETH. Split 0 reverts → `leftoverAmount` stays at 1 ETH, `leftoverPercentage` drops to 50%. Split 1 computes `1 ETH * 50% / 50% = 1 ETH` instead of 0.5 ETH. + +**Contrast:** nana-core's `JBPayoutSplitGroupLib` (lines 89-94) always decrements `leftoverAmount` and restores failed amounts via `recordAddedBalanceFor()`. + +**Recommendation:** Always decrement `leftoverAmount` regardless of success, and route failed amounts back to the project via `addToBalanceOf`. + +--- + +### NEW-H-2: ERC-2771 sender rewriting allows forged bridge roots (nana-suckers-v6) + +**Severity:** HIGH (conditional — requires non-zero trusted forwarder) — **FIXED** +**File:** `src/JBSucker.sol:476-482` +**Verified:** YES — confirmed against source code +**Fix:** Replaced `_msgSender()` with `msg.sender` in `fromRemote()`. Regression test confirms forgery now reverts with `JBSucker_NotPeer`. + +`fromRemote()` gates access with `_isRemotePeer(_msgSender())` instead of `_isRemotePeer(msg.sender)`. `JBSucker` inherits `ERC2771Context` (line 45) with a configurable trusted forwarder (constructor line 167-169). When `msg.sender` is the trusted forwarder, `_msgSender()` returns the last 20 bytes of calldata — an attacker-controlled value. + +**Attack path (CCIP variant):** The CCIP sucker's `_isRemotePeer()` checks `sender == address(this)`, which is trivially spoofable via the calldata suffix. A forwarder could call `fromRemote()` directly, bypassing `ccipReceive()`. + +**Mitigating factors:** +- If trusted forwarder is `address(0)`, not exploitable +- A legitimate ERC2771 forwarder validates signatures, limiting the attacker to whoever can produce valid signatures for bridge-like addresses +- The code has a comment (line 478-479) acknowledging the choice but its reasoning is flawed + +**Recommendation:** Use `msg.sender` instead of `_msgSender()` in `fromRemote()`, since bridge messengers never use ERC2771 meta-transactions (as the comment itself states). + +--- + +### NEW-M-1: Pay credits cannot fully fund split-bearing NFT mints (nana-721-hook-v6) + +**Severity:** MEDIUM — **FIXED** +**File:** `src/JB721TiersHook.sol:187-214, 634-671`, `nana-core-v6/src/JBTerminalStore.sol:1028-1033` +**Fix:** Cap `totalSplitAmount` at `context.amount.value` in `beforePayRecordedWith`. Pay credits are virtual and cannot fund splits. + +`beforePayRecordedWith` computes the forwarded split amount from tier metadata and asks the terminal to forward the split portion from the new payment. The core terminal validates `specifiedAmount <= amount.value` before `_mintAndUpdateCredits` runs, where `payCreditsOf[beneficiary]` is consumed. At 100% split, a user with sufficient credits still cannot complete a purchase without bringing fresh funds equal to the full split amount. + +--- + +### NEW-M-2: Migration verification skips unrelated owners when fallback resolver holds the same tier (banny-retail-v6) + +**Severity:** MEDIUM — **FIXED** +**File:** `script/helpers/MigrationHelper.sol:93-118` +**Fix:** Skip fallback resolver as an owner (don't skip the tier). Compare `v5Balance <= v4Balance + v4FallbackResolverBalance` to allow for redistribution. + +`verifyTierBalances()` reads `v4FallbackResolverBalance` once per `(owner, tier)` and exits the tier check before reading the current owner's V4/V5 balances. When a V4 tier has NFTs held by the fallback resolver, the migration can over-allocate that tier to a normal owner in V5 and the validation passes unnoticed. The bad state is permanent once migration is executed. + +--- + +### NEW-M-3: Resume can preserve a wrong immutable price feed instead of halting (deploy-all-v6) + +**Severity:** MEDIUM — **FIXED** +**File:** `script/Resume.s.sol:2731-2739`, `script/Deploy.s.sol:2537` +**Fix:** Added `Resume_PriceFeedMismatch` revert when existing feed doesn't match expected, matching Deploy's strict validation. + +The deploy path validates both sides of the expected-feed/registered-feed pair with a strict mismatch revert, but the resume path only mutates the empty side. If a prior partial deployment inserted the wrong feed, resume silently accepts it. Since default feeds are immutable in `JBPrices`, the misconfiguration requires redeployment. + +--- + +### NEW-L-1: DeployPeriphery deadline skip-guard incompatible with Sphinx (nana-core-v6) + +**Severity:** LOW — **FIXED** +**File:** `script/DeployPeriphery.s.sol:180-193, 292-305` +**Fix:** Changed `_isDeployed()` to use `address(this)` as the deployer instead of the Arachnid proxy address. + +`_isDeployed()` hardcodes Arachnid's deterministic deployer address while the actual deployment uses Sphinx. Re-running after a Sphinx deployment returns `false` and attempts duplicate deployments. + +--- + +### NEW-L-2: Deploy.s.sol not idempotent with default FEE_PROJECT_ID (croptop-core-v6) + +**Severity:** LOW — **FIXED** +**File:** `script/Deploy.s.sol:66-151` +**Fix:** When `FEE_PROJECT_ID == 0`, scan existing projects for a deployed publisher singleton before creating a new fee project. + +Running the deploy script with `FEE_PROJECT_ID = 0` creates a fee project and deploys a suite. Re-running creates another fee project with different CREATE2 addresses, stranding the previous suite. + +--- + +### NEW-L-3: Zero-deployer registrations collapse "registered" and "unregistered" states (nana-address-registry-v6) + +**Severity:** LOW — **FIXED** +**File:** `src/JBAddressRegistry.sol:126-131` +**Fix:** Added `JBAddressRegistry_ZeroDeployer()` revert when `deployer == address(0)`. Tests updated to expect revert. + +`registerAddress(address(0), nonce)` succeeds and emits `AddressRegistered`, but the duplicate-registration check (`deployerOf[addr] != address(0)`) never triggers because the deployer is zero. Off-chain systems can be spammed with non-durable registrations. + +--- + +### NEW-L-4: Deployment script checks CREATE2 existence at wrong address (nana-address-registry-v6) + +**Severity:** LOW — **FIXED** +**File:** `script/Deploy.s.sol:28-37` +**Fix:** Changed deployer from Arachnid proxy to `address(this)` in `_isDeployed()` (same fix as NEW-L-1). + +`_isDeployed()` computes the target using the deterministic deployment proxy (`0x4e59...`) instead of the contract executing the `CREATE2`. Solidity `new C{salt}()` derives addresses from the current contract. Idempotency checks are unreliable. + +--- + +### NEW-L-5: Testnet deployments silently lose all configured auto-issuance (nana-fee-project-deployer-v6) + +**Severity:** LOW — **FIXED** +**File:** `script/Deploy.s.sol:63-64, 125-129` +**Fix:** Use `block.chainid` for the current chain's auto-issuance entry, mapping testnet chain IDs to their sepolia equivalents. + +`configureSphinx()` enables Sepolia testnets but `deploy()` creates `REVAutoIssuance` entries with mainnet chain IDs (1, 8453, 10, 42161). No entry matches Sepolia chain IDs, so `autoIssueFor()` reverts on testnets. + +--- + +### NEW-L-6: Project #1 terminal selection not locked (nana-fee-project-deployer-v6) + +**Severity:** LOW — **DOCUMENTED** +**File:** `script/Deploy.s.sol:107-113, 195-202` +**Fix:** Added documentation comment noting the risk and recommending post-deployment terminal locking. Actual lock requires directory-level change outside script scope. + +`deploy()` writes the registry terminal into the project config but never writes `_terminalOf[projectId]` or `hasLockedTerminal[projectId]` inside the registry. The registry owner changing `defaultTerminal` would silently redirect payments to the fee project. + +--- + +### NEW-L-7: Verify script marks deployment healthy without checking USDC feed (deploy-all-v6) + +**Severity:** LOW — **FIXED** +**File:** `script/Verify.s.sol:628` +**Fix:** Added USDC/USD feed verification with per-chain USDC addresses and sanity checks ($0.90-$1.10). + +`_verifyPriceFeeds()` only checks ETH/USD, ETH/native, and USD/ETH — not USDC/USD, which is registered during deployment. A deployment with broken USDC flows can be incorrectly accepted as healthy. + +--- + +## REPEAT/EVOLVED FINDINGS (Confirming Unfixed Issues) + +### RPT-H-1: Deployment script wires wrong Uniswap V4 contracts on non-Ethereum chains (univ4-lp-split-hook-v6) + +**Severity:** HIGH → **RESOLVED** +**Maps to:** Round 1 H-2, Round 2 repeat (NEEDS VERIFICATION status) +**File:** `script/Deploy.s.sol:69-145` + +**Post-triage verification:** RESOLVED — The V4 PositionManager address (`0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e`) is canonical across all chains via CREATE2 deterministic deployment. The same address is valid on Ethereum, Optimism, Arbitrum, Base, etc. No chain-specific lookup needed. + +--- + +### RPT-H-2: Pending reserve mints can reopen a succeeded scorecard (defifa-collection-deployer-v6) + +**Severity:** HIGH +**Maps to:** Round 2 NEW-H-2 (evolved — different attack vector) +**File:** `src/DefifaGovernor.sol:420-446, 488-494` +**Verified:** YES — PoC test exists at `test/audit/CodexPendingReserveQuorumGrief.t.sol` + +`stateOf()` uses live quorum from `currentSupplyOfTier()` while attestations are snapshotted. `mintReservesFor()` is permissionless during the SCORING phase (`pauseMintPendingReserves: false` at `DefifaDeployer.sol:865`). Minting pending reserves on a refunded tier revives it in the quorum calculation, causing a SUCCEEDED scorecard to revert to ACTIVE. + +**Note:** Round 2 NEW-H-2 focused on cash-out dilution; this is a distinct governance-griefing vector within the same live-quorum design flaw. + +--- + +### RPT-H-3: Resume uses different CREATE2 salt for Defifa phase (deploy-all-v6) + +**Severity:** HIGH +**Maps to:** Round 2 NEW-H-3 (evolved — specific salt mismatch identified) +**File:** `script/Deploy.s.sol:223` vs `script/Resume.s.sol:264` + +`Deploy._deployDefifa()` uses `keccak256("0.0.2")` while `Resume._resumeDefifa()` uses `_DEFIFA_SALTV6_`. Recovery deploys a second Defifa stack instead of recognizing the original. + +--- + +### RPT-M-1: Direct pay() routes partial-swap leftovers to beneficiary instead of payer (nana-router-terminal-v6) + +**Severity:** MEDIUM (upgraded from Round 1 L-2) → **FIXED** +**Maps to:** Round 1 L-2 (acknowledged design choice) +**File:** `src/JBRouterTerminal.sol:257, 278` + +**Post-triage verification:** PARTIALLY FIXED — Contract callers are handled correctly via `IJBPayerTracker` which resolves the actual payer. However, for EOA direct callers, `pay()` at line 278 passes `payable(beneficiary)` to `_resolveRefundTo` instead of `payable(_msgSender())`. The `addToBalanceOf()` path already correctly uses `_msgSender()`. + +**Fix applied:** Changed line 278 from `_resolveRefundTo(payable(beneficiary))` to `_resolveRefundTo(payable(_msgSender()))`, making `pay()` consistent with `addToBalanceOf()`. PoC test updated to verify payer receives refund. + +--- + +### RPT-M-2: Buy-side decimals fallback routes to inferior V4 swap (univ4-router-v6) + +**Severity:** MEDIUM → **FIXED** +**Maps to:** Round 1 M-5 +**File:** `src/JBUniswapV4Hook.sol:705-731, 855-863` + +**Fix applied:** `_getTokenDecimals()` now reverts instead of silently defaulting to 18. The buy-side fallback in `_determineSwap` wraps `calculateExpectedTokensWithCurrency()` in try-catch — on failure, `juiceboxExpectedOutput` stays 0, correctly favoring the V4 path only when Juicebox comparison genuinely unavailable. PoC test updated to verify revert behavior. + +--- + +### RPT-M-3: Fee-on-transfer fallback over-mints project tokens (nana-buyback-hook-v6) + +**Severity:** MEDIUM → **RESOLVED** +**Maps to:** Round 2 NEW-M-2 (was NEEDS INVESTIGATION for FOT scope) +**File:** `src/JBBuybackHook.sol:335, 353` + +**Post-triage verification:** RESOLVED — The buyback hook already uses a balance-delta pattern (checking actual balance change rather than nominal amount) to handle FOT tokens. Tests confirming this behavior exist in the repo. + +--- + +### RPT-M-4: Original project owner keeps collection-admin powers after NFT transfer (croptop-core-v6) + +**Severity:** MEDIUM → **FALSE POSITIVE** +**Maps to:** Round 1 M-10 +**File:** `src/CTDeployer.sol:339-343` + +**Post-triage verification:** FALSE POSITIVE — The permissions at line 349 are granted FROM `address(this)` (CTDeployer) TO `owner`, with the projectId scope. The hook's ownership is managed via `JBOwnable`, where `hook.owner()` resolves dynamically through `PROJECTS.ownerOf(projectId)`. When the project NFT transfers, `hook.owner()` returns the new owner. The granted permissions are for interacting with `CTDeployer` on behalf of the project — the original owner can only exercise them if they still own the project NFT. The comment at line 340-342 was incorrect and has been fixed (see FP-3 NatSpec fix). + +--- + +### RPT-L-1: Held fees remain on old terminal after balance migration (nana-core-v6) + +**Severity:** LOW → **DOCUMENTED** +**Maps to:** Round 2 FP (reconsidered — more specific scenario) +**File:** `src/JBMultiTerminal.sol:476-523, 600-636` + +Round 2 dismissed this as FP ("held fees remain backed"). Round 3 provides a more specific scenario: `migrateBalanceOf()` zeros the project balance on the old terminal but leaves `_heldFeesOf` and `_nextHeldFeeIndexOf` intact. After migration, `processHeldFeesOf()` on the old terminal operates on stale accounting without matching backing balance. Downgraded from MEDIUM to LOW because it requires the held-fee + migration lifecycle. + +**Fix applied:** Enhanced NatSpec comment on `migrateBalanceOf` documenting that held fees remain on the old terminal post-migration, and project owners should process or return held fees before migrating. + +--- + +### RPT-L-2: Deployment script not restart-idempotent after singleton discovery (revnet-core-v6) + +**Severity:** LOW → **FIXED** +**Maps to:** Related to Round 1 M-3 (different aspect — M-3 was about fee project ID mutation, this is about reconfiguration calls) +**File:** `script/Deploy.s.sol:345-469` + +Re-running after initial deployment finds the existing singleton but still calls `_basicDeployer.deployFor(FEE_PROJECT_ID, ...)` on a non-blank project. The "existing project" initialization path fails operationally. + +**Fix applied:** Wrapped `deployFor` call in `if (!_singletonsExist)` guard, skipping reconfiguration when singletons are already deployed. + +--- + +## Cross-Round Tracker + +### Findings Across All 3 Rounds + +| Issue Class | R1 | R2 | R3 | Status | +|------------|-----|-----|-----|--------| +| LP split hook fee-token claims | H-1 | Fixed | FP (fix confirmed) | **RESOLVED** | +| LP split hook wrong V4 addresses | H-2 | Repeat | RPT-H-1 | **RESOLVED (canonical CREATE2)** | +| Suckers destination deprecation stranding | H-3 | Repeat | Not found | Possibly fixed or out of scope | +| Defifa governance manipulation | H-4 | NEW-H-2 | RPT-H-2 | **FIXED (quorum snapshot)** | +| Deploy-all Banny project ID | H-5 | — | Not found | Status unknown | +| Core stale fee-free surplus | M-1 | — | Not found | Status unknown | +| Router terminal leftovers | M-4/L-2 | Repeat | RPT-M-1 | **FIXED** | +| V4 router buy-side fallback | M-5 | Repeat | RPT-M-2 | **FIXED** | +| Buyback hook FOT over-mint | — | NEW-M-2 | RPT-M-3 | **RESOLVED (balance-delta pattern)** | +| Croptop original owner permissions | M-10 | Repeat | RPT-M-4 | **FALSE POSITIVE (dynamic owner resolution)** | +| Deploy-all resume recovery | — | NEW-H-3 | RPT-H-3 | **FIXED (salt aligned)** | +| 721 hook split overpayment | — | — | NEW-H-1 | **FIXED** | +| Suckers ERC-2771 forged roots | — | — | NEW-H-2 | **FIXED** | +| 721 hook pay credits + splits | — | — | NEW-M-1 | **FIXED** | +| Banny migration verification | — | — | NEW-M-2 | **FIXED** | +| Deploy-all resume price feed | — | — | NEW-M-3 | **FIXED** | + +### Items Fixed Since Earlier Rounds (Not Re-Found in Round 3) + +| Round 1 ID | Finding | Last Status | +|------------|---------|-------------| +| H-1 | Fee-token claims diverted (univ4-lp-split-hook-v6) | Fixed (confirmed in R3 verification) | +| M-2 | Cashout rate uses total surplus (univ4-lp-split-hook-v6) | Fixed (R2) | +| M-3 | Deploy.s.sol fee project ID mutation (revnet-core-v6) | Fixed (R2), related issue persists | +| M-4 | Registry addToBalanceOf traps leftovers (nana-router-terminal-v6) | Fixed (R2) | +| M-6 | Registry pool config no-op (nana-buyback-hook-v6) | Fixed (R2) | +| L-1 | Loan ID namespace cap (revnet-core-v6) | Fixed (R2) | + +--- + +## Priority Recommendations + +### Must-Fix (Runtime, Fund Risk) + +1. ~~**NEW-H-1** (nana-721-hook-v6)~~: **FIXED** — split distribution loop always decrements `leftoverAmount`, restores on failure +2. ~~**NEW-H-2** (nana-suckers-v6)~~: **FIXED** — `fromRemote()` uses `msg.sender` instead of `_msgSender()` +3. ~~**RPT-H-2** (defifa-collection-deployer-v6)~~: **FIXED** — Quorum snapshotted at scorecard submission time; `stateOf` uses snapshot instead of live quorum. `quorum()` includes tiers with pending reserves (not just minted supply) since unminted reserves indicate real participation. + +### Should-Fix (Deployment/Script Safety) + +4. ~~**RPT-H-1** (univ4-lp-split-hook-v6)~~: **RESOLVED** — canonical CREATE2 addresses +5. ~~**RPT-H-3** (deploy-all-v6)~~: **FIXED** — Resume `DEFIFA_SALT` aligned with Deploy (`bytes32(keccak256("0.0.2"))`) +6. ~~**NEW-M-3** (deploy-all-v6)~~: **FIXED** — feed-mismatch validation added to Resume path +7. ~~**NEW-M-2** (banny-retail-v6)~~: **FIXED** — migration verification accounts for fallback resolver balance + +### Acknowledged / Design Review + +8. ~~**RPT-M-1** (nana-router-terminal-v6)~~: **FIXED** — `pay()` now uses `_msgSender()` for refund resolution +9. ~~**RPT-M-2** (univ4-router-v6)~~: **FIXED** — `_getTokenDecimals()` reverts instead of defaulting, buy-side fallback uses try-catch +10. ~~**RPT-M-3** (nana-buyback-hook-v6)~~: **RESOLVED** — balance-delta pattern already in place +11. ~~**RPT-M-4** (croptop-core-v6)~~: **FALSE POSITIVE** — permissions invalidate via dynamic owner resolution diff --git a/ClaudeQA.md b/ClaudeQA.md new file mode 100644 index 0000000..3c6e699 --- /dev/null +++ b/ClaudeQA.md @@ -0,0 +1,438 @@ +# Juicebox V6 EVM — Pre-Deployment Security Audit Report + +**Auditor:** Claude Opus 4.6 (Paranoid QA Mode) +**Date:** 2026-03-23 +**Scope:** All 17 submodule repos in the v6 EVM ecosystem +**Objective:** 10/10 confidence for immutable deployment managing billions of dollars + +--- + +## Executive Summary + +After deep line-by-line analysis of all 17 repos (~35,000+ lines of Solidity), review of 200+ test files, and targeted cross-component interaction analysis, I assess the ecosystem at **7.5/10 overall confidence** for immutable deployment. The core protocol (nana-core-v6) is well-hardened at 8.5/10. The primary gaps are in the UniV4 integration layer (LP split hook, router terminal) and specific cross-component interaction patterns that remain under-tested. + +**No showstopper critical vulnerabilities were found that would prevent deployment.** The most severe new findings are in the UniV4 LP split hook's fee token accounting and the permissionless MEV surface on fee routing. All other findings are either documented design decisions, configuration-dependent risks, or low-severity issues. + +### What It Would Take to Reach 10/10 + +1. Add `ReentrancyGuard` to `REVLoans` and `JBUniswapV4LPSplitHook` (2 contracts) +2. Fix the `_mintRebalancedPosition` fee token claim subtraction omission (1 line) +3. Add minimum slippage protection to fee routing in `JBUniswapV4LPSplitHook._routeFeesToProject` +4. Add end-to-end adversarial tests for composed hook paths (buyback + V4 routing) +5. Add reentrancy tests for cashout hooks re-entering pay +6. Verify OMNICHAIN_RULESET_OPERATOR deployed bytecode matches JBOmnichainDeployer on all chains +7. Add idempotency guard to `JBAddressRegistry._registerAddress` +8. Add zero-address guard in `CTDeployer.beforeCashOutRecordedWith` +9. Fix BAN project ID enforcement in deploy-all-v6 (use `_ensureProjectExists` pattern) +10. Verify start times and auto-issuance amounts against economic model at actual deployment timestamp + +--- + +## Methodology + +- 8 parallel deep-dive audit agents covering every repo +- Direct manual analysis of all critical fund flow paths in JBMultiTerminal, JBTerminalStore, JBCashOuts, JBFees, JBPayoutSplitGroupLib +- Cross-component interaction tracing for 4 major composition chains (documented in RISKS.md) +- Review of all test files for coverage gaps and adversarial testing quality +- Specific focus on: reentrancy without ReentrancyGuard, fee bypass vectors, bonding curve edge cases, cross-chain consistency, permission escalation, data hook trust boundaries + +--- + +## Per-Repo Confidence Scores + +| Repo | Confidence | Blocking Issues | Notes | +|------|-----------|----------------|-------| +| **nana-core-v6** | 8.5/10 | None | Rock-solid CEI ordering, comprehensive tests. | +| **revnet-core-v6** | 8/10 | REVLoans needs ReentrancyGuard | CEI gap in totalCollateralOf; wildcard USE_ALLOWANCE is ecosystem risk | +| **nana-suckers-v6** | 8/10 | None | Well-defended. Arbitrum non-atomicity is inherent. Emergency hatch correctly guarded. | +| **nana-721-hook-v6** | 8/10 | None | Discount/cashout weight asymmetry is config-dependent (cannotIncreaseDiscountPercent flag mitigates) | +| **nana-buyback-hook-v6** | 7.5/10 | Routing disagreement with V4 hook | Documented fallback behavior, but value leakage in composed deployments | +| **univ4-lp-split-hook-v6** | 6.5/10 | **Fee token accounting bug (CRIT-1), zero slippage on fee routing (CRIT-2)** | Most concerning repo. Needs fixes before deployment. | +| **univ4-router-v6** | 7.5/10 | _routing flag cleanup pattern | Theoretical DoS if flag persists; V4 hook routing is complex | +| **nana-router-terminal-v6** | 7/10 | V4 spot price sandwich risk (2% floor) | Well-documented but exploitable on L1 | +| **nana-omnichain-deployers-v6** | 8/10 | None | Sucker verification correct. Ruleset ID prediction needs guard in launch paths. | +| **croptop-core-v6** | 7.5/10 | CTDeployer zero-address revert on uninitialized dataHookOf | One-line fix needed | +| **banny-retail-v6** | 8.5/10 | None | nonReentrant properly applied. SVG injection is owner-trust issue. | +| **defifa-collection-deployer-v6** | 8/10 | None | Governance mechanism sound. Dust locked from integer division is accepted. | +| **nana-ownable-v6** | 9/10 | None | Clean design. Permission reset on transfer is correct. | +| **nana-fee-project-deployer-v6** | 8/10 | None | Operator address differs from standalone script — verify intentional | +| **deploy-all-v6** | 8/10 | BAN project ID not enforced | CREATE2 safe, idempotent, correct ordering. Fix BAN `_ensureProjectExists`. | +| **nana-address-registry-v6** | 7.5/10 | No idempotency guard on registration | Allows overwrites (computationally infeasible to exploit, but defense-in-depth) | +| **nana-permission-ids-v6** | 9/10 | None | Static definitions, well-tested | + +--- + +## Findings by Severity + +### CRITICAL (2 new) + +#### CRIT-1: `_mintRebalancedPosition` Omits Fee Token Claim Subtraction [univ4-lp-split-hook-v6] + +**File:** `JBUniswapV4LPSplitHook.sol:1324` +**Confidence:** 7/10 + +In `_mintRebalancedPosition`, the project token balance is read as: +```solidity +uint256 projectTokenBalance = IERC20(projectToken).balanceOf(address(this)); +``` +This does NOT subtract `_totalOutstandingFeeTokenClaims[projectToken]`, unlike `_addUniswapLiquidity` (line 782) and `_burnReceivedTokens` (line 893) which both subtract it. When the project token is the same as the fee project token, this inflates the amount deposited into the LP position, locking fee tokens belonging to other projects. + +**Impact:** Fee tokens reserved for other projects get permanently locked in the LP position. + +**Fix:** Add `- _totalOutstandingFeeTokenClaims[projectToken]` to line 1324. + +!!! Admin note: ok, fix. + +--- + +#### CRIT-2: Zero Slippage Protection on Fee Routing [univ4-lp-split-hook-v6] + +**File:** `JBUniswapV4LPSplitHook.sol:1452-1474` +**Confidence:** 8/10 + +`_routeFeesToProject` calls `terminal.pay()` with `minReturnedTokens: 0`. The terminal can invoke a buyback hook which performs a swap. With zero slippage protection, every fee routing transaction is sandwichable. Combined with `collectAndRouteLPFees` being permissionless (anyone can trigger it), this creates a permanent MEV extraction surface. + +**Impact:** Up to 2.5% value extraction per fee routing operation across all projects using the hook. Compounds over time. + +**Fix:** Add minimum slippage floor based on TWAP or oracle price, or make `collectAndRouteLPFees` permissioned. + +!!! Admin note: this is ok because the project itself should have slippage pertection inside of its pay hook. we could, however, add minReturnedTokens based on a call to previewPay if we want. what do you think? + +--- + +### HIGH (8) + +#### HIGH-1: OMNICHAIN_RULESET_OPERATOR Is a Hardcoded Universal Trust Root [nana-core-v6] + +**File:** `JBController.sol:97-111, 442-455, 590-595` +**Confidence:** 8/10 + +This immutable address bypasses ALL permission checks for `launchRulesetsFor`, `queueRulesetsOf`, and `SET_TERMINALS` on ANY project. If the JBOmnichainDeployer at this address has a vulnerability, is deployed with incorrect bytecode, or is compromised on any chain, an attacker could queue malicious rulesets for every project without approval hooks. + +**Action required:** Verify deployed bytecode matches JBOmnichainDeployer on every target chain. Projects with `duration == 0` and no approval hooks are most at risk. + +!!! Admin note: ok, fix. + +--- + +#### HIGH-2: REVLoans CEI Ordering Gap in totalCollateralOf [revnet-core-v6] + +**Confidence:** 7/10 + +`totalCollateralOf` is updated after external calls in certain loan paths, creating a window where the collateral accounting is stale. While no direct exploit was confirmed, the lack of `ReentrancyGuard` makes this a defense-in-depth concern for a contract holding wildcard `USE_ALLOWANCE` permission. + +**Action required:** Add `ReentrancyGuard` to REVLoans. + +!!! Admin note: the fn's ordering should prevent reentrency risk. dont add a reentrency prevention modifier willy nilly without reason... prefer to write the fn in a resiliant way. + +--- + +#### HIGH-3: Wildcard USE_ALLOWANCE on REVLoans [revnet-core-v6] + +**Confidence:** 9/10 + +`REVLoans` holds wildcard `USE_ALLOWANCE` (projectId=0) from `REVDeployer`, meaning it can draw surplus from ANY revnet's treasury. A vulnerability in REVLoans has ecosystem-wide blast radius. + +**Action required:** Ensure REVLoans has maximum audit scrutiny. The ReentrancyGuard addition (HIGH-2) is critical. + +!!! Admin note: by design... the wildcard is relative to project owner, which is REVDeployer, which should be tightly scoped. + +--- + +#### HIGH-4: V4 Spot Price Quoting Is Sandwich-Attackable [nana-router-terminal-v6] + +**File:** `JBRouterTerminal.sol:1306-1335` +**Confidence:** 9/10 + +When no user-provided quote is supplied, the router falls back to V4 spot price with sigmoid slippage (2% minimum floor). On L1 Ethereum, sophisticated MEV bots can extract up to 2% per transaction. + +**Action required:** Frontend integrations MUST always supply `quoteForSwap` metadata for V4 pools. Document this prominently. + +!!! Admin note: the router terminal may be called programmatically, in which case a quoteForSwap may be missing and we will rely on algorthmically provided quote. how might we make sure we provide as good a quote as possible to prevent much slippage? + +--- + +#### HIGH-5: No Reentrancy Guard on JBUniswapV4LPSplitHook [univ4-lp-split-hook-v6] + +**File:** `JBUniswapV4LPSplitHook.sol` (multiple functions) +**Confidence:** 6/10 + +`rebalanceLiquidity` has a window between position burn (old tokenId) and mint (new tokenId) where state is inconsistent. No `ReentrancyGuard` is used. The contract relies on state-ordering defenses that have gaps. + +**Action required:** Add `ReentrancyGuard` to `rebalanceLiquidity`, `collectAndRouteLPFees`, and `deployPool`. + +!!! Admin note: the fn's ordering should prevent reentrency risk. dont add a reentrency prevention modifier willy nilly without reason... prefer to write the fn in a resiliant way. + +--- + +#### HIGH-6: Discount/Cash-Out Weight Asymmetry [nana-721-hook-v6] + +**File:** `JB721TiersHookStore.sol:414-423, 1096-1100` +**Confidence:** 9/10 + +When `discountPercent = 200` (100% discount), NFTs can be minted for free but carry full cash-out weight. A project owner who increases the discount after paid mints can enable attackers to drain the treasury. + +**Mitigated by:** `cannotIncreaseDiscountPercent` flag. Projects that don't set this flag are vulnerable. + +**Action required:** Consider making `cannotIncreaseDiscountPercent` default to `true`. Document the risk in deployment tooling. + +!!! Admin note: this is ok, by design. + +--- + +#### HIGH-7: JBAddressRegistry Allows Deployer Mapping Overwrites [nana-address-registry-v6] + +**File:** `JBAddressRegistry.sol:122` +**Confidence:** 9/10 + +`_registerAddress` unconditionally writes to `deployerOf[addr]` with no idempotency guard. While exploiting this requires a hash collision (computationally infeasible), the lack of a simple `if (deployerOf[addr] != address(0)) revert` check violates defense-in-depth for an immutable registry. + +**Fix:** Add one-line idempotency guard. + +!!! Admin note: ok, fix if you wish. + +--- + +#### HIGH-8: CTDeployer.beforeCashOutRecordedWith Reverts on Uninitialized dataHookOf [croptop-core-v6] + +**File:** `CTDeployer.sol:150` +**Confidence:** 8/10 + +For projects where `dataHookOf[projectId]` is `address(0)`, the non-sucker cash-out path unconditionally calls the zero address, permanently blocking cash-outs. + +**Fix:** Add zero-address guard: `if (address(dataHookOf[context.projectId]) == address(0)) return (context.cashOutTaxRate, context.cashOutCount, context.totalSupply, hookSpecifications);` + +!!! Admin note: ok, fix. + +--- + +### MEDIUM (20) + +#### MED-1: Phantom Balance After Migration + Held Fee Processing [nana-core-v6] +After terminal migration, held fee processing failure credits phantom balance to the old terminal. Can inflate cross-terminal surplus if `useTotalSurplusForCashOuts` is true. Known, documented. + +#### MED-2: `_feeFreeSurplusOf` Persists Across Rulesets With No Reset [nana-core-v6] +Accumulator is never cleared when switching between zero-tax and non-zero-tax rulesets. Minor accounting artifact. + +#### MED-3: Sucker Trust Boundary — Registry Compromise Has Ecosystem-Wide Blast Radius [nana-suckers-v6] +`JBSuckerRegistry` holds wildcard `MAP_SUCKER_TOKEN` from all three deployers. A false sucker registration enables 0% cashout tax drain across all projects. + +#### MED-4: CCIP Amount Mismatch — No Validation of Delivered Tokens [nana-suckers-v6] +`ccipReceive` does not validate `root.amount` against `destTokenAmounts[0].amount`. By design (reverting is worse), but claims fail if insufficient tokens delivered. Recommend adding monitoring event. + +#### MED-5: `fromRemote` Silently Drops Roots in DEPRECATED State [nana-suckers-v6] +Tokens bridged just before deprecation can be stranded if root arrives after DEPRECATED. 14-day delay mitigates but doesn't guarantee delivery for all bridges. + +#### MED-6: Emergency Hatch Has No Timelock [nana-suckers-v6] +Instant activation by project owner. Irreversible. Correctly prevents double-spend via `numberOfClaimsSent` guard, but governance risk. + +#### MED-7: Arbitrum Non-Atomic Bridging [nana-suckers-v6] +ERC-20 token transfer and message are independent L2→L1. Causes DoS if message arrives before tokens. 7-day retryable ticket expiration could strand tokens. Documented. + +#### MED-8: Buyback + V4 Hook Routing Disagreement [nana-buyback-hook-v6 + univ4-router-v6] +Both hooks independently decide swap-vs-mint. Disagreement causes `JBUniswapV4Hook_ReentrantRouting` revert, caught by buyback try-catch, falls back to mint. Value leakage when pool would have given better rates. + +!!! Admin note: ok, fixable without over dependence on admin operations? + +#### MED-9: Multi-Hop Cashout Has Zero Per-Step Slippage [nana-router-terminal-v6] +`_cashOutLoop` sets `minTokensReclaimed = 0` after first iteration. Intermediate hops have no slippage protection. + +!!! Admin note: ok, fixable without over dependence on admin operations? + +#### MED-10: Permissionless Fee Collection Timing Attack [univ4-lp-split-hook-v6] +Anyone can call `collectAndRouteLPFees` to trigger fee routing at manipulated market conditions. Combined with zero slippage (CRIT-2). + +!!! Admin note: ok, fixable without over dependence on admin operations? + +#### MED-11: Oracle Auto-Growth Gas Spike [univ4-router-v6] +Growing observation array from 512 to 1024 requires ~10M gas. One-time cost per pool, bounded. + +#### MED-12: CTDeployer Grants Wildcard ADJUST_721_TIERS to CTPublisher [croptop-core-v6] +All CTDeployer hooks are affected if CTPublisher has a vulnerability. By design. + +#### MED-13: CTDeployer Permissions Bound to Initial Owner, Not Transferable [croptop-core-v6] +After project transfer, old owner retains 721 permissions, new owner doesn't get them automatically. + +#### MED-14: JBOmnichainDeployer Ruleset ID Prediction Without Validation in Launch Paths [nana-omnichain-deployers-v6] +`block.timestamp + i` prediction exists in `queueRulesetsOf` but not `launchRulesetsFor`. Low risk due to first-ruleset scenario. + +#### MED-15: Data Hook Can Override totalSupply to Drain Surplus [nana-core-v6] +Malicious data hook can set `totalSupply = cashOutCount` to return full surplus. Documented trust model — data hooks are set by project owners. + +#### MED-16: External ERC-20 via setTokenFor Creates Supply Manipulation Surface [nana-core-v6] +External token's `totalSupply()` can be inflated by separate minting authority. Requires `allowSetCustomToken` opt-in. + +#### MED-17: Locked Split Semantic Drift [nana-core-v6] +Locked splits reference project IDs, not behavior. The referenced project could change between lock and unlock. + +#### MED-18: Price Feed Inverse Precision Loss at Extreme Ratios [nana-core-v6] +`mulDiv(10^decimals, 10^decimals, price)` loses precision for extreme price disparities. Documented. + +#### MED-19: BAN Project ID Not Enforced in Deployment Script [deploy-all-v6] +Projects 1-3 use `_ensureProjectExists()` with explicit ID validation. BAN (project 4) uses `_revDeployer.deployFor({revnetId: 0})` which creates the next available ID without validation. If re-run after partial failure where someone else created project 4, BAN gets project 5, breaking cross-chain alignment. Fix: use `_ensureProjectExists(_BAN_PROJECT_ID)` pattern. + +#### MED-20: Start Times Are >1 Year in the Past [deploy-all-v6] +All project start times are set to Feb 2025. This triggers cash-out delay logic and means ~4 weight decay cycles have already elapsed at deployment. The auto-issuance amounts should be verified against the economic model at actual deployment timestamp. Likely intentional for cross-chain consistency but needs confirmation. + +--- + +### LOW (15+) + +- Optimizer mismatch: deploy-all-v6 uses `optimizer = false`, nana-fee-project-deployer-v6 uses `optimizer_runs = 200` (affects bytecode verification) +- No validation that sucker deployers are non-zero on L2 in `_buildSuckerConfig` (deploy-all-v6) — standalone fee deployer has the check +- Fee-on-transfer tokens not explicitly handled in payouts/cashouts (nana-core) +- Custom price feeds could enable manipulation (nana-core) +- Fee processing failure allows fee avoidance (nana-core) +- `processHeldFeesOf` has no access control beyond unlock timestamp (nana-core) +- Held fees stranded after migration — no guardrail prevents bad ordering (nana-core) +- CCIP refund failure permanently locks ETH in sucker (nana-suckers) +- `toRemote` fee payment can be bypassed if fee terminal absent (nana-suckers) +- Arbitrum `callValueRefundAddress` set to peer, not sender (nana-suckers) +- Category sort order not enforced against existing tiers in adjustTiers (nana-721-hook) +- Defifa integer division dust permanently locked (defifa-collection-deployer) +- JBBuybackHookRegistry `disallowHook` cannot force-remove from existing projects (nana-buyback-hook) +- RouterTerminalRegistry no FoT balance delta check (nana-router-terminal) +- SVG injection via owner-set content in Banny resolver (banny-retail) +- CTPublisher fee rounding dust (croptop-core) +- JBOwnable permissionId=0 reset semantics (nana-ownable) + +--- + +## Cross-Component Interaction Analysis + +### Chain 1: Price Feed → Surplus → Loans → LP Positioning +**Risk:** Stale/manipulated Chainlink feed simultaneously allows over-borrowing in REVLoans AND incorrect LP tick ranges in JBUniswapV4LPSplitHook. +**Assessment:** Mitigated by Chainlink staleness thresholds and L2 sequencer checks. No cross-component circuit breaker exists. **Acceptable risk** — feed failure causes DoS, not fund loss. + +### Chain 2: Data Hook → Buyback → V4 Router → Terminal +**Risk:** 4-contract delegation chain where the V4 hook's routing decision can disagree with the buyback hook's swap decision, causing silent fallback to mint. +**Assessment:** The `_routing` reentrancy flag correctly prevents infinite recursion. The fallback is suboptimal (mint instead of swap) but not unsafe. **Value leakage, not fund loss.** + +### Chain 3: Sucker Registry → Omnichain Deployer → 0% Cashout Tax +**Risk:** Registry holds wildcard `MAP_SUCKER_TOKEN` from 3 deployers. False registration enables cashout tax bypass. +**Assessment:** Registry requires `DEPLOY_SUCKERS` permission + deployer allowlist. Defense is adequate but the blast radius of a registry compromise is ecosystem-wide. **Highest systemic risk.** + +### Chain 4: Controller Migration → Terminal Migration → Held Fee Escape +**Risk:** Terminal migration moves balances but not held fees. Post-migration fee processing creates phantom balances. +**Assessment:** Known, documented, tested. No guardrail prevents the bad ordering. **Recommend processing held fees before migration.** + +--- + +## Test Coverage Assessment + +### Strong Areas (8+ / 10) +- Flash loan attacks: 12 vectors tested in nana-core +- Bonding curve formal properties: 7 properties proven +- Fee arithmetic formal properties: 6 properties proven +- Terminal store invariants: 5 invariants with fuzzing +- Economic simulation: 3 projects, 10 actors, 15 operations +- Permission escalation: Comprehensive ROOT boundary tests +- Sucker attack vectors: Double-claim, cross-chain authentication, amount conservation +- 721 hook: Supply bypass, discount exploitation, reserve timing + +### Coverage Gaps (Action Required) + +| Gap | Repo | Severity | Recommendation | +|-----|------|----------|---------------| +| No reentrancy test for cashout hook re-entering pay | nana-core | HIGH | Add test where cashout hook calls terminal.pay() | +| No test for `_mintRebalancedPosition` fee token accounting | univ4-lp-split-hook | HIGH | Add test with projectToken == feeProjectToken during rebalance | +| No composed hook test (buyback + V4 routing conflict) | nana-buyback-hook + univ4-router | HIGH | Add end-to-end adversarial test | +| No test for phantom balance + cross-terminal surplus | nana-core | MEDIUM | Test `useTotalSurplusForCashOuts` after migration | +| No test for V4 spot price sandwich attack | nana-router-terminal | MEDIUM | Add adversarial sandwich test | +| No test for `recordAddedBalanceFor` from non-terminal | nana-core | MEDIUM | Verify non-terminal calls are harmless | +| No test for CCIP amount mismatch | nana-suckers | MEDIUM | Test behavior when delivered < root.amount | +| No test for concurrent multi-project fee token accounting | univ4-lp-split-hook | MEDIUM | Test 2 projects on same hook clone | +| No test for ETH stranding when split payout + addToBalance both fail | nana-721-hook | LOW | Add double-failure edge case test | +| No test for OMNICHAIN_RULESET_OPERATOR abuse on duration=0 projects | nana-core | MEDIUM | Test arbitrary ruleset queuing | + +--- + +## Deployment Readiness Checklist + +### Must Fix Before Deployment (Blocking) + +- [ ] **CRIT-1:** Add `_totalOutstandingFeeTokenClaims` subtraction to `_mintRebalancedPosition` in JBUniswapV4LPSplitHook +- [ ] **CRIT-2:** Add minimum slippage to `_routeFeesToProject` OR make `collectAndRouteLPFees` permissioned +- [ ] **HIGH-2/3:** Add `ReentrancyGuard` to `REVLoans` +- [ ] **HIGH-5:** Add `ReentrancyGuard` to `JBUniswapV4LPSplitHook` critical functions +- [ ] **HIGH-7:** Add idempotency guard to `JBAddressRegistry._registerAddress` +- [ ] **HIGH-8:** Add zero-address guard in `CTDeployer.beforeCashOutRecordedWith` and `beforePayRecordedWith` +- [ ] **MED-19:** Fix BAN project ID enforcement — use `_ensureProjectExists(_BAN_PROJECT_ID)` pattern in deploy-all-v6 + +### Should Fix Before Deployment (Recommended) + +- [ ] **HIGH-1:** Verify OMNICHAIN_RULESET_OPERATOR bytecode on all target chains +- [ ] **HIGH-4:** Document V4 spot price risk; ensure all frontends supply `quoteForSwap` metadata +- [ ] **HIGH-6:** Consider defaulting `cannotIncreaseDiscountPercent` to `true` +- [ ] **MED-14:** Add `latestRulesetId >= block.timestamp` guard to `_launchRulesetsFor` +- [ ] **MED-20:** Verify start times and auto-issuance amounts match economic model at deployment timestamp +- [ ] Add the 10 missing test categories identified above +- [ ] Remove unused error `JBPermissions_CantSetRootPermissionForWildcardProject` (cosmetic) +- [ ] Verify each project operator address (NANA/CPN/REV/BAN) is the correct intended multisig + +### Accept and Document + +- [ ] MED-1: Phantom balance after migration — documented +- [ ] MED-3: Sucker registry blast radius — documented, deployment-guarded +- [ ] MED-7: Arbitrum non-atomicity — inherent to bridge architecture +- [ ] MED-8: Buyback + V4 routing disagreement — documented fallback +- [ ] MED-15: Data hook trust model — by design + +--- + +## Deployment Script Verification (deploy-all-v6) + +All external addresses hardcoded in the deployment script have been cross-verified: + +| Component | Status | Notes | +|-----------|--------|-------| +| Uniswap V4 PoolManager (4 chains) | **VERIFIED** | All match canonical deployments | +| Uniswap V4 PositionManager (4 chains) | **VERIFIED** | All match canonical deployments | +| Chainlink ETH/USD feeds (4 chains) | **VERIFIED** | Including L2 sequencer feeds | +| Chainlink USDC/USD feeds (4 chains) | **VERIFIED** | Staleness thresholds appropriate | +| OP/Base L1/L2 bridge addresses | **VERIFIED** | Canonical predeploy addresses | +| Arbitrum inbox/gateway addresses | **VERIFIED** | Via ARBAddresses library | +| OMNICHAIN_RULESET_OPERATOR | **VERIFIED** | Correctly set to JBOmnichainDeployer address | +| CREATE2 determinism | **VERIFIED** | Sphinx safe as deployer prevents front-running | +| Deployment ordering | **VERIFIED** | Correct dependency chain, idempotent re-execution | +| Project ID alignment (1-3) | **VERIFIED** | Uses `_ensureProjectExists` with explicit validation | +| Project ID alignment (4/BAN) | **NOT VERIFIED** | Uses `revnetId: 0` without ID validation (see MED-19) | +| Wildcard permission grants | **VERIFIED** | 3 non-ROOT grants, appropriate scope | + +**Permission model:** All 33 permission IDs (1-33) verified unique, complete, and correctly consumed. No operations found lacking permission checks. ROOT (ID 1) escalation prevention is comprehensive. + +**Ownership:** All protocol infrastructure retained by Sphinx safe (multisig). No ownership transfers occur during deployment. + +**Partial failure safety:** Sphinx executes atomically per chain. Idempotency guards (`_isDeployed()`) make re-execution safe. + +--- + +## Architecture Strengths + +The protocol demonstrates mature security engineering: + +1. **Consistent CEI ordering** across all fund flow paths — no explicit `ReentrancyGuard` but state is committed before external calls throughout nana-core +2. **Try-catch on all external calls** in the terminal — failed splits, fees, and payouts are caught and handled gracefully +3. **`_feeFreeSurplusOf` mechanism** correctly closes the fee bypass vector on same-terminal payouts +4. **Held fee lifecycle** — index advanced before external calls, re-read from storage each iteration +5. **Permission system** — ROOT escalation prevention is comprehensive (3-prong check) +6. **Sucker bridge** — bitmap-before-external-call prevents double-claiming; peer authentication is correct per bridge +7. **Bonding curve** — formally verified properties; flash loan profitability provably impossible with non-zero tax + +--- + +## Final Assessment + +The Juicebox V6 ecosystem is a well-engineered protocol with evidence of iterative security improvements across multiple audit rounds. The core fund flow contracts (JBMultiTerminal, JBTerminalStore, JBController) are at deployment-grade quality. The permission system, ruleset lifecycle, and fee mechanics are correctly implemented. + +The primary risk areas are: + +1. **UniV4 integration layer** (LP split hook, router terminal) — newest code, most complex interactions, lowest test coverage for adversarial scenarios +2. **Cross-component composition** — the 4-deep hook delegation chain and multi-singleton dependency create correlated failure modes +3. **Wildcard permissions** (REVLoans, SuckerRegistry, CTPublisher) — each is a single-contract-failure-away from ecosystem-wide impact + +With the 7 blocking fixes above, the ecosystem reaches **9/10 confidence**. The remaining gap to 10/10 is the test coverage additions and cross-chain deployment verification. + +--- + +*Report generated by Claude Opus 4.6 security audit. Findings should be verified by human security engineers before deployment decisions.* diff --git a/CodexQA.md b/CodexQA.md new file mode 100644 index 0000000..e194014 --- /dev/null +++ b/CodexQA.md @@ -0,0 +1,305 @@ +# Codex QA Deployment Readiness Review + +Date: 2026-03-24 + +This revision assumes the full test suite is green across all repos, including fork tests, per the latest user confirmation. I excluded hidden files and did not use nemesis content. + +## Verdict + +The v6 ecosystem is now technically near-ready for immutable deployment. + +My current confidence is: + +- `nana-core-v6`: 9/10 +- `nana-suckers-v6`: 8.5/10 +- `univ4-router-v6`: 8.5/10 +- `nana-buyback-hook-v6`: 9/10 +- `nana-721-hook-v6`: 8.5/10 +- `revnet-core-v6`: 8.5/10 +- `nana-router-terminal-v6`: 8.5/10 +- `nana-omnichain-deployers-v6`: 8/10 +- `croptop-core-v6`: 8/10 +- `deploy-all-v6`: 9/10 +- Ecosystem overall: 9/10 + +The technical assurance picture is now strong. The remaining risk is no longer “missing critical test coverage” in the same way it was before. The remaining risk is mostly release-integrity and operator-discipline risk. + +## What Changed Since The Prior Revision + +The following additions materially increased confidence and closed most of the previously open tail-risk items: + +- `deploy-all-v6/script/Resume.s.sol` + - real resumable recovery path + - deterministic CREATE2 recovery with idempotent state checks +- `deploy-all-v6/script/Verify.s.sol` + - real post-deploy verification path +- `deploy-all-v6/test/fork/DeployResumeRehearsalFork.t.sol` + - phase-boundary interruption rehearsals +- `deploy-all-v6/test/fork/WBTC8DecimalFork.t.sol` + - explicit 8-decimal full-stack path +- `deploy-all-v6/test/fork/MixedDecimalLoanComposition.t.sol` + - explicit USDC6 loan + stage transition + buyback + migration + repayment path +- `deploy-all-v6/test/fork/CrossFeatureLifecycleFork.t.sol` + - explicit four-way/five-way lifecycle composition path +- `deploy-all-v6/test/fork/WildcardPermissionKillChain.t.sol` + - explicit singleton wildcard abuse-boundary proof +- `deploy-all-v6/test/fork/LongHorizonChurnFork.t.sol` + - explicit long-horizon multi-project churn stress test over repeated rulesets +- `deploy-all-v6/test/fork/BaseChainFork.t.sol` + - explicit non-Ethereum production-shape proof on Base, including sequencer-aware pricing and Base-specific infra + +These additions directly address the biggest open concerns from the previous report. + +## What The Test Suite Proves Well Now + +### Strongest areas + +- `nana-core-v6` + - strongest local suite in the workspace + - very good on fees, permit2, timing, migration, decimals, sequencer-aware feeds, weird tokens, and exploit-shape behavior +- `deploy-all-v6` + - now a strong ecosystem assurance repo rather than only a deployment wrapper + - covers recovery, verification assumptions, mixed decimals, high-order compositions, wildcard boundaries, long-horizon churn, and Base-specific behavior +- `revnet-core-v6` + - strong on loans, stage transitions, fee recovery, economic edge cases, and autonomous deployment behavior +- `nana-buyback-hook-v6` and `univ4-router-v6` + - strong on manipulation-sensitive paths, route choice, TWAP/oracle behavior, USDC6/WBTC8 decimals, and fallback behavior +- `nana-suckers-v6` + - strong adversarial orientation with meaningful bridge-specific and claim-state coverage + +### High-value proofs added recently + +- `DeployResumeRehearsalFork` + - proves resuming after multiple partial-deploy boundaries preserves deterministic addresses and expected wiring +- `WBTC8DecimalFork` + - proves the ecosystem is not only ETH18 and USDC6 +- `MixedDecimalLoanComposition` + - proves one of the highest-risk mixed-decimal timing/composition paths directly +- `CrossFeatureLifecycleFork` + - proves several economic subsystems interacting sequentially in one lifecycle +- `WildcardPermissionKillChain` + - proves singleton wildcards are bounded by project ownership in the permission lookup path +- `LongHorizonChurnFork` + - proves repeated multi-project state churn does not obviously accumulate accounting drift in the tested envelope +- `BaseChainFork` + - proves a real non-Ethereum deployment shape with Base-specific addresses and sequencer-aware pricing + +## Remaining Finding + +### MEDIUM: deploy-all documentation is out of sync with the live deployment script + +Relevant files: + +- `deploy-all-v6/script/Deploy.s.sol` +- `deploy-all-v6/script/Resume.s.sol` +- `deploy-all-v6/script/Verify.s.sol` +- `deploy-all-v6/README.md` +- `deploy-all-v6/AUDIT_INSTRUCTIONS.md` +- `deploy-all-v6/ADMINISTRATION.md` +- `deploy-all-v6/RISKS.md` +- `deploy-all-v6/CHANGE_LOG.md` + +The code has advanced further than the docs. + +Observed mismatches: + +- `Deploy.s.sol` now includes Phase 10 Defifa deployment. +- README still says the current script does not deploy Defifa. +- README still says the repo does not ship a resumable recovery script. +- multiple deploy-all docs still describe Defifa as out of scope or commented out. + +This is not a Solidity exploit concern. It is a release-integrity concern. For an immutable rollout, stale operator docs are dangerous because they cause deployers and reviewers to authorize one scope while the script actually executes another. + +What must be true before deployment: + +1. Sync every deploy-all doc to the live script. +2. Publish one exact rollout manifest matching the real script and final commits. +3. Require operators to follow the updated runbook, not stale README guidance. + +## Test Readout + +### Coverage readout by repo + +- `nana-core-v6` + - strongest breadth in the workspace + - remaining gap: mostly that the real deployment composes core through higher-level systems +- `nana-router-terminal-v6` + - strong on fee-on-transfer, permit2 truncation, TWAP windows, partial fills, preview behavior, routing, sandwich-aware logic, and reentrancy + - remaining gap: little beyond broader rollout rehearsal +- `nana-buyback-hook-v6` + - strong on route choice, oracle failures, MEV/slippage, registry behavior, fallback behavior, USDC6, and WBTC8 + - remaining gap: little beyond final deployment discipline +- `nana-suckers-v6` + - strong on merkle correctness, claim uniqueness, concurrent root progression, deprecation, emergency handling, and chain-specific bridge behavior + - remaining gap: normal bridge-liveness assumptions remain external +- `nana-721-hook-v6` + - strong on tiers, reserves, split routing, reentrancy, project deployers, and cross-currency behavior + - remaining gap: little beyond optional extra ecosystem rehearsal +- `revnet-core-v6` + - very strong relative to its complexity + - improved materially by mixed-decimal composition, wildcard kill-chain, and long-horizon churn coverage + - remaining gap: little beyond final operator rehearsal +- `deploy-all-v6` + - now directly covers 8-decimal ecosystem paths, mixed-decimal loan composition, high-order lifecycle composition, wildcard singleton boundaries, long-horizon churn, Base-specific behavior, recovery, and verification + - remaining gap: documentation and runbook synchronization +- `univ4-router-v6` + - strong local manipulation and routing coverage + - remaining gap: optional extra chain-local proof, not a current blocker + +### What the newest tests assess + +- `DeployResumeRehearsalFork.t.sol` + - assesses interruption and deterministic resume safety + - residual note: project-count drift remains an operational constraint +- `WBTC8DecimalFork.t.sol` + - assesses 8-decimal accounting across payment, pricing, buyback, cashout, and 721 pricing +- `MixedDecimalLoanComposition.t.sol` + - assesses USDC6 lifecycle correctness across loan creation, timing transition, hook activation, migration, and repayment +- `CrossFeatureLifecycleFork.t.sol` + - assesses cross-currency payout conversion, NFT minting, reserved-token distribution, ruleset cycling, and cashout reconciliation in one scenario +- `WildcardPermissionKillChain.t.sol` + - assesses whether singleton wildcard permissions can cross the ownership boundary into victim-owned projects +- `LongHorizonChurnFork.t.sol` + - assesses repeated multi-project lifecycle churn across 10 ruleset cycles +- `BaseChainFork.t.sol` + - assesses a real non-Ethereum production shape on Base, including PoolManager, sequencer-aware pricing, pay/cashout/payout behavior, and Base-specific infra assumptions + +## Repo-by-Repo Confidence Notes + +### `nana-core-v6` — 9/10 + +Why high: + +- broad unit, fuzz, invariant, and exploit-style coverage +- many important protocol edge cases are directly represented + +To reach 10/10: + +- complete final ecosystem deployment rehearsal on the exact final package + +### `nana-router-terminal-v6` — 8.5/10 + +Why improved: + +- strong routing-focused local suite +- harder ecosystem compositions are now proven externally in deploy-all + +To reach 10/10: + +- complete final rollout rehearsal on the exact deployment package + +### `nana-buyback-hook-v6` — 9/10 + +Why improved: + +- strong local decimal and manipulation coverage +- ecosystem-level confirmation now exists for its hardest composition paths + +To reach 10/10: + +- complete final rollout rehearsal and manifest sync + +### `univ4-router-v6` — 8.5/10 + +Why solid: + +- strong local regression and routing/manipulation coverage + +To reach 10/10: + +- optional extra chain-local forks +- complete final deployment manifest sync + +### `nana-suckers-v6` — 8.5/10 + +Why improved: + +- already strong chain-specific fork coverage +- remaining trust assumptions are mostly external bridge assumptions, not local test gaps + +To reach 10/10: + +- complete final deployment rehearsal and operator verification flow + +### `nana-721-hook-v6` — 8.5/10 + +Why improved: + +- strong local suite +- materially better ecosystem proof now exists around cross-currency and long-lifecycle usage + +To reach 10/10: + +- complete final ecosystem rehearsal + +### `revnet-core-v6` — 8.5/10 + +Why improved: + +- explicit ecosystem proof now exists for mixed-decimal loan composition, wildcard boundaries, and long-horizon churn + +To reach 10/10: + +- complete final operator rehearsal against the exact deployment package + +### `nana-omnichain-deployers-v6` and `croptop-core-v6` — 8/10 each + +Why still lower: + +- composition-heavy deployers depend strongly on downstream systems + +To reach 10/10: + +- complete final deployment rehearsal and manifest sync + +### `deploy-all-v6` — 9/10 + +Why materially improved: + +- real resume script exists +- real verification script exists +- direct fork coverage now exists for recovery, mixed decimals, high-order composition, wildcard boundaries, long-horizon churn, and Base-specific behavior +- Defifa is now actually in the deploy path + +What keeps it below 10: + +- the docs and runbooks still do not match the live script +- operator discipline still matters around resume/rehearsal flow + +To reach 10/10: + +1. sync every deploy-all doc to the live script +2. rehearse full deployment and resume on final commits +3. run `Verify.s.sol` as a required gate after rehearsal and after real deployment + +## What It Takes To Reach 10/10 Confidence + +An engineer should treat the following as the final pre-deploy gate: + +1. Freeze a final rollout manifest: + - exact repos + - exact commits + - exact chains + - exact expected CREATE2 addresses + - exact deployed phases and scope +2. Sync the README, audit docs, risk docs, administration docs, and runbooks to the actual script behavior. +3. Rehearse the final deployment package end-to-end: + - normal deploy + - interruption at a few realistic phase boundaries + - resume using `Resume.s.sol` + - immediate verification using `Verify.s.sol` +4. Publish a chain-by-chain deployment matrix so operators and reviewers cannot confuse “reviewed ecosystem” with “actually deployed ecosystem.” + +## Final Recommendation + +I now view the system as technically close enough that I would not hold deployment on remaining test-surface concerns alone. + +The biggest prior QA concerns around deployment recovery, post-deploy verification, mixed-decimal ecosystem paths, high-order composition, wildcard singleton abuse boundaries, long-horizon churn, and non-Ethereum production shapes are now substantially addressed by real code and real tests. + +The remaining work is mostly release-integrity work: + +- sync the docs to the script +- freeze the final manifest +- rehearse the final operator flow with `Resume.s.sol` and `Verify.s.sol` + +If those gates are closed, this can plausibly move from roughly 9/10 to true immutable-deployment confidence. diff --git a/bump-solc.sh b/bump-solc.sh new file mode 100644 index 0000000..36cf704 --- /dev/null +++ b/bump-solc.sh @@ -0,0 +1,135 @@ +#!/bin/bash +set -e + +ROOT=$(pwd) +BRANCH="chore/solc-0.8.28" +REPOS=( + banny-retail-v6 + croptop-core-v6 + defifa-collection-deployer-v6 + deploy-all-v6 + nana-721-hook-v6 + nana-address-registry-v6 + nana-buyback-hook-v6 + nana-core-v6 + nana-fee-project-deployer-v6 + nana-omnichain-deployers-v6 + nana-ownable-v6 + nana-permission-ids-v6 + nana-router-terminal-v6 + nana-suckers-v6 + revnet-core-v6 + univ4-lp-split-hook-v6 + univ4-router-v6 +) + +for repo in "${REPOS[@]}"; do + echo "" + echo "==========================================" + echo "Processing: $repo" + echo "==========================================" + cd "$ROOT/$repo" + + DEFAULT_BRANCH="main" + echo "Default branch: $DEFAULT_BRANCH" + + # Checkout default branch and pull + git checkout "$DEFAULT_BRANCH" 2>/dev/null + git pull origin "$DEFAULT_BRANCH" 2>/dev/null + + # Create new branch + git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH" 2>/dev/null + + # 1. Update foundry.toml + if [ -f "foundry.toml" ]; then + sed -i '' "s/solc = '0.8.26'/solc = '0.8.28'/" foundry.toml + echo " Updated foundry.toml" + fi + + # 2. Update exact pragma solidity 0.8.26 -> 0.8.28 in all .sol files + find . -name "*.sol" -not -path "*/lib/*" -not -path "*/node_modules/*" -not -path "*/out/*" -not -path "*/cache/*" -type f | while read f; do + sed -i '' 's/pragma solidity 0\.8\.26;/pragma solidity 0.8.28;/g' "$f" + sed -i '' 's/pragma solidity \^0\.8\.26;/pragma solidity ^0.8.28;/g' "$f" + done + echo " Updated pragma versions" + + # 3. Bump npm version (patch) + if [ -f "package.json" ]; then + npm version patch --no-git-tag-version 2>/dev/null + NEW_VERSION=$(node -p "require('./package.json').version") + echo " npm version bumped to $NEW_VERSION" + fi + + echo " Done with changes for $repo" +done + +echo "" +echo "==========================================" +echo "All changes applied. Now building each repo..." +echo "==========================================" + +FAILED_BUILDS="" +for repo in "${REPOS[@]}"; do + echo "" + echo "--- Building: $repo ---" + cd "$ROOT/$repo" + if forge build 2>&1; then + echo " BUILD SUCCESS: $repo" + else + echo " BUILD FAILED: $repo" + FAILED_BUILDS="$FAILED_BUILDS $repo" + fi +done + +if [ -n "$FAILED_BUILDS" ]; then + echo "" + echo "FAILED BUILDS:$FAILED_BUILDS" + echo "Fix these before committing." + exit 1 +fi + +echo "" +echo "==========================================" +echo "All builds passed! Committing and pushing..." +echo "==========================================" + +for repo in "${REPOS[@]}"; do + echo "" + echo "--- Committing: $repo ---" + cd "$ROOT/$repo" + + DEFAULT_BRANCH="main" + + git add -A + git commit -m "$(cat <<'EOF' +chore: bump solc to 0.8.28 + +Co-Authored-By: Claude Opus 4.6 +EOF +)" + + git push -u origin "$BRANCH" + + # Create PR + gh pr create \ + --title "chore: bump solc to 0.8.28" \ + --body "$(cat <<'EOF' +## Summary +- Bump Solidity compiler version from 0.8.26 to 0.8.28 +- Update `foundry.toml`, all `pragma solidity` statements, and npm package version + +## Test plan +- [x] `forge build` passes + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" \ + --base "$DEFAULT_BRANCH" + + echo " PR created for $repo" +done + +echo "" +echo "==========================================" +echo "ALL DONE!" +echo "==========================================" diff --git a/nana-address-registry-v6 b/nana-address-registry-v6 index c349215..5a9a189 160000 --- a/nana-address-registry-v6 +++ b/nana-address-registry-v6 @@ -1 +1 @@ -Subproject commit c349215899a846e8c225e495fe760994c477f6ba +Subproject commit 5a9a18929a785e8cc2c6ce8477c88d8fdc93bcc7 diff --git a/run-security-sweep.sh b/run-security-sweep.sh new file mode 100755 index 0000000..774656e --- /dev/null +++ b/run-security-sweep.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash +# Security Sweep — Build the orchestrator, then run it (FOREGROUND) +# Usage: ./run-security-sweep.sh [--repo REPO] [--discipline NUM] +# Logs: scripts/security/reports/YYYYMMDD-HHMMSS/ +# Monitor: tail -f .audit-logs/security-sweep-*.log +# +# This script first builds the orchestrator (Tasks 1-6 from the plan), +# then runs it. Safe to re-run — skips building if already built. +# +# Everything runs in the foreground — progress streams directly to your terminal. + +set -euo pipefail + +BASE="/Users/jango/Documents/jb/v6/evm" +PLAN="$BASE/docs/superpowers/plans/2026-03-24-autonomous-security-orchestrator.md" +SECURITY_DIR="$BASE/scripts/security" +LOG_DIR="$BASE/.audit-logs" +TIMESTAMP=$(date +%Y%m%d-%H%M%S) +START_TIME=$(date +%s) + +mkdir -p "$LOG_DIR" + +# ────────────────────────────────────────────────────────────────────── +# Helpers: colored output for console highlights +# ────────────────────────────────────────────────────────────────────── +BOLD="\033[1m" +DIM="\033[2m" +GREEN="\033[32m" +YELLOW="\033[33m" +RED="\033[31m" +CYAN="\033[36m" +RESET="\033[0m" + +banner() { printf "\n${BOLD}${CYAN}══════════════════════════════════════════════════════════════${RESET}\n"; } +header() { printf "${BOLD}${CYAN} %s${RESET}\n" "$*"; } +success() { printf "${GREEN} ✓ %s${RESET}\n" "$*"; } +warn() { printf "${YELLOW} ⚠ %s${RESET}\n" "$*"; } +fail() { printf "${RED} ✗ %s${RESET}\n" "$*"; } +info() { printf "${DIM} %s${RESET}\n" "$*"; } +elapsed() { + local now=$(date +%s) + local secs=$((now - START_TIME)) + local mins=$((secs / 60)) + local s=$((secs % 60)) + printf "%dm%02ds" "$mins" "$s" +} + +banner +header "JB V6 SECURITY SWEEP" +header "$(date '+%Y-%m-%d %H:%M:%S')" +banner +echo "" + +# ────────────────────────────────────────────────────────────────────── +# STEP 0: Check prerequisites +# ────────────────────────────────────────────────────────────────────── +header "Prerequisites" + +PREREQ_OK=true + +check_tool() { + local name="$1" + local install_hint="$2" + if command -v "$name" &>/dev/null; then + local ver + ver=$("$name" --version 2>/dev/null | head -1 || echo "OK") + success "$name: $ver" + else + fail "$name not found. Install: $install_hint" + PREREQ_OK=false + fi +} + +check_tool "claude" "https://docs.anthropic.com/en/docs/claude-code" +check_tool "codex" "npm install -g @openai/codex" +check_tool "forge" "https://book.getfoundry.sh/getting-started/installation" +check_tool "jq" "brew install jq" + +# Check bash version (need 4.3+ for wait -n) +BASH_VERSION_MAJOR="${BASH_VERSINFO[0]}" +BASH_VERSION_MINOR="${BASH_VERSINFO[1]}" +if [ "$BASH_VERSION_MAJOR" -lt 4 ] || { [ "$BASH_VERSION_MAJOR" -eq 4 ] && [ "$BASH_VERSION_MINOR" -lt 3 ]; }; then + fail "bash 4.3+ required (you have $BASH_VERSION). Install: brew install bash" + info "Then run with: /opt/homebrew/bin/bash $0 $*" + PREREQ_OK=false +else + success "bash: $BASH_VERSION" +fi + +if [ "$PREREQ_OK" = false ]; then + echo "" + fail "Missing prerequisites. Fix the above and re-run." + exit 1 +fi + +echo "" + +# ────────────────────────────────────────────────────────────────────── +# STEP 1: Build the orchestrator if not already built +# ────────────────────────────────────────────────────────────────────── +if [ ! -f "$SECURITY_DIR/orchestrate.sh" ]; then + header "Building Orchestrator (first run)" + info "This uses Claude to create all security scripts from the plan." + info "Log: .audit-logs/security-build-$TIMESTAMP.log" + echo "" + + if [ ! -f "$PLAN" ]; then + fail "Plan not found at $PLAN" + info "Run the brainstorming session first to generate the plan." + exit 1 + fi + + BUILD_LOG="$LOG_DIR/security-build-$TIMESTAMP.log" + + # Run Claude in foreground — stream-json gives us real-time output + cd "$BASE" + claude -p "Read the implementation plan at $PLAN and execute Tasks 1 through 6 (everything up to but NOT including Task 7 'Smoke Test'). Create all files with the exact content specified in the plan. Do NOT run any tests or the orchestrator — just create the files. + +Important: +- Create the directory structure first: mkdir -p scripts/security/{lib,prompts/{claude,codex,review,synthesis},configs,reports} +- Write each file exactly as specified in the plan +- Make orchestrate.sh executable (chmod +x) +- The prompts should include the full Report Format and Quality Bar sections from the spec at docs/superpowers/specs/2026-03-24-autonomous-security-orchestrator-design.md +- Verify all .sh files parse correctly with bash -n" \ + --dangerously-skip-permissions \ + --max-turns 80 \ + --output-format stream-json \ + --verbose \ + 2>&1 | tee "$BUILD_LOG" + + echo "" + + if [ ! -f "$SECURITY_DIR/orchestrate.sh" ]; then + fail "Build failed — orchestrate.sh not created." + info "Check log: $BUILD_LOG" + exit 1 + fi + + success "Orchestrator built successfully. [$(elapsed)]" + echo "" +else + success "Orchestrator already built at $SECURITY_DIR/orchestrate.sh" + echo "" +fi + +# ────────────────────────────────────────────────────────────────────── +# STEP 2: Run the orchestrator (foreground, with progress highlights) +# ────────────────────────────────────────────────────────────────────── +SWEEP_LOG="$LOG_DIR/security-sweep-$TIMESTAMP.log" + +banner +header "RUNNING SECURITY SWEEP" +header "Args: ${*:-}" +header "Log: $SWEEP_LOG" +banner +echo "" + +# The orchestrator outputs phase markers that we highlight in real-time. +# We use a line-by-line filter that both logs everything AND highlights +# key progress lines to the console with color. + +cd "$BASE" +"$SECURITY_DIR/orchestrate.sh" "$@" 2>&1 | while IFS= read -r line; do + # Always write to log + printf '%s\n' "$line" >> "$SWEEP_LOG" + + # Highlight key progress lines to console + case "$line" in + *"=== Phase"*|*"PHASE"*) + banner + header "$line" + banner + ;; + *"Spawning"*|*"spawning"*|*"Starting"*|*"starting"*) + printf "${CYAN} → %s${RESET}\n" "$line" + ;; + *"Complete"*|*"complete"*|*"Finished"*|*"finished"*|*"DONE"*|*"done"*) + success "$line" + ;; + *"ERROR"*|*"FAIL"*|*"error"*|*"fail"*) + fail "$line" + ;; + *"WARNING"*|*"WARN"*|*"warning"*|*"warn"*) + warn "$line" + ;; + *"Finding"*|*"finding"*|*"CONFIRMED"*|*"LIKELY"*|*"DISPUTED"*) + printf "${YELLOW} ▸ %s${RESET}\n" "$line" + ;; + *"Summary"*|*"SUMMARY"*|*"Report"*|*"report"*) + printf "${BOLD} ◆ %s${RESET}\n" "$line" + ;; + *"Agent"*|*"agent"*|*"claude"*|*"codex"*) + printf "${DIM} │ %s${RESET}\n" "$line" + ;; + *"Budget"*|*"budget"*|*"cost"*|*"Cost"*) + printf "${YELLOW} $ %s${RESET}\n" "$line" + ;; + *"Repo:"*|*"repo:"*|*"━"*|*"───"*|*"==="*) + printf "${BOLD} %s${RESET}\n" "$line" + ;; + *) + # Regular output: print dimmed so highlights stand out + printf "${DIM} %s${RESET}\n" "$line" + ;; + esac +done + +EXIT_CODE=${PIPESTATUS[0]} + +echo "" +banner +if [ "$EXIT_CODE" -eq 0 ]; then + header "SWEEP COMPLETE [$(elapsed)]" +else + printf "${BOLD}${RED} SWEEP FAILED (exit $EXIT_CODE) [$(elapsed)]${RESET}\n" +fi +banner +echo "" + +info "Log: $SWEEP_LOG" + +# Show the report summary without overriding the orchestrator's exit code when +# no report directory was created. +LATEST_REPORT="" +if [ -d "$SECURITY_DIR/reports" ]; then + LATEST_REPORT=$(find "$SECURITY_DIR/reports" -mindepth 1 -maxdepth 1 -type d -print 2>/dev/null | sort -r | head -1 || true) +fi +if [ -n "$LATEST_REPORT" ]; then + info "Report: ${LATEST_REPORT}SUMMARY.md" + echo "" + if [ -f "${LATEST_REPORT}SUMMARY.md" ]; then + header "FINDINGS SUMMARY" + echo "" + head -80 "${LATEST_REPORT}SUMMARY.md" + echo "" + info "(Full report: ${LATEST_REPORT}SUMMARY.md)" + fi +fi + +exit "$EXIT_CODE"