@rev-net/core-v6 0.0.24 → 0.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,401 +1,134 @@
1
- # Audit Instructions -- revnet-core-v6
1
+ # Audit Instructions
2
2
 
3
- You are auditing the Revnet + Loans system for Juicebox V6. Revnets are autonomous, ownerless Juicebox projects with pre-programmed multi-stage tokenomics. REVLoans enables borrowing against locked revnet tokens using the bonding curve as the sole collateral valuation mechanism.
3
+ Revnets are autonomous Juicebox projects with staged economics and token-collateralized loans. Audit this repo as both a privileged deployer layer and a live economic system.
4
4
 
5
- Read [RISKS.md](./RISKS.md) for the trust model and known risks. Read [ARCHITECTURE.md](./ARCHITECTURE.md) for the system overview. Read [SKILLS.md](./SKILLS.md) for the complete function reference. Then come back here.
5
+ ## Objective
6
+
7
+ Find issues that:
8
+ - let a participant borrow more than intended against revnet collateral
9
+ - break stage transitions or immutable revnet economics
10
+ - mis-scale weights, fees, or split behavior in composed payment flows
11
+ - grant owner-like or operator-like powers outside the documented model
12
+ - leave deployed revnets or loans in states that cannot settle safely
6
13
 
7
14
  ## Scope
8
15
 
9
- **In scope:**
10
-
11
- | Contract | Lines | Role |
12
- |----------|-------|------|
13
- | `src/REVDeployer.sol` | ~19,746 bytes | Deploys revnets. Manages stages, splits, auto-issuance, buyback hook delegation, 721 hook deployment, suckers, split operator permissions, and all state storage. Split from original monolith to stay under EIP-170 (24,576 bytes). |
14
- | `src/REVOwner.sol` | ~8,434 bytes (~310 lines) | Runtime hook contract. Implements `IJBRulesetDataHook` + `IJBCashOutHook`. Set as the `dataHook` in each revnet's ruleset metadata. Handles `beforePayRecordedWith`, `beforeCashOutRecordedWith`, `afterCashOutRecordedWith`, `hasMintPermissionFor`, and sucker verification. Stores `cashOutDelayOf` and `tiered721HookOf` mappings (set by REVDeployer via DEPLOYER-restricted setters). **Key audit focus: the `setDeployer()` one-shot pattern, DEPLOYER-restricted setter access control, and circular dependency with REVDeployer.** |
15
- | `src/REVLoans.sol` | ~1,359 lines | Token-collateralized lending. Burns collateral on borrow, re-mints on repay. ERC-721 loan NFTs. Three-layer fee model. Permit2 integration. |
16
- | `src/interfaces/` | ~525 | Interface definitions for both contracts |
17
- | `src/structs/` | ~212 | All struct definitions |
18
-
19
- **Dependencies (assumed correct, but verify integration points):**
20
- - `@bananapus/core-v6` -- JBController, JBMultiTerminal, JBTerminalStore, JBTokens, JBPrices, JBRulesets
21
- - `@bananapus/721-hook-v6` -- IJB721TiersHook, IJB721TiersHookDeployer
22
- - `@bananapus/buyback-hook-v6` -- IJBBuybackHookRegistry
23
- - `@bananapus/suckers-v6` -- IJBSuckerRegistry
24
- - `@croptop/core-v6` -- CTPublisher
25
- - `@openzeppelin/contracts` -- ERC721, ERC2771Context, Ownable, SafeERC20
26
- - `@uniswap/permit2` -- IPermit2, IAllowanceTransfer
27
- - `@prb/math` -- mulDiv
28
-
29
- ## The System in 90 Seconds
30
-
31
- A **revnet** is a Juicebox project that nobody owns. REVDeployer deploys it and permanently holds its project NFT. REVOwner acts as the data hook for all payments and cash-outs (`dataHook` in ruleset metadata). The deployer/owner split was necessary to stay under the EIP-170 contract size limit. The revnet's economics are encoded as a sequence of **stages** that map 1:1 to Juicebox rulesets. Stages are immutable after deployment.
32
-
33
- Each stage defines:
34
- - **Initial issuance** (`initialIssuance`): tokens minted per unit of base currency
35
- - **Issuance decay** (`issuanceCutFrequency` + `issuanceCutPercent`): how issuance decreases over time
36
- - **Cash-out tax** (`cashOutTaxRate`): bonding curve parameter (0 = no tax, 9999 = max allowed)
37
- - **Split percent** (`splitPercent`): percentage of minted tokens sent to reserved splits
38
- - **Auto-issuances**: pre-configured token mints that can be claimed once per stage per beneficiary
39
-
40
- **REVLoans** lets users borrow against their revnet tokens:
41
- 1. Burn tokens as collateral
42
- 2. Borrow up to the bonding curve cash-out value of those tokens
43
- 3. Pay a three-layer fee (2.5% protocol + 1% REV + 2.5%-50% source prepaid)
44
- 4. Receive an ERC-721 representing the loan
45
- 5. Repay anytime to re-mint collateral tokens
46
- 6. After 10 years, anyone can liquidate (collateral permanently lost)
47
-
48
- ## How Revnets Interact with Juicebox Core
49
-
50
- Understanding this interaction is essential. REVDeployer wraps core Juicebox functions with revnet-specific logic.
51
-
52
- ### Payment Flow
53
-
54
- ```
55
- User pays terminal
56
- -> Terminal calls JBTerminalStore.recordPaymentFrom()
57
- -> Store calls REVOwner.beforePayRecordedWith() [data hook]
58
- -> REVOwner reads tiered721HookOf from its own storage
59
- -> REVOwner calls 721 hook's beforePayRecordedWith() for split specs
60
- -> REVOwner calls buyback hook's beforePayRecordedWith() for swap decision
61
- -> REVOwner scales weight: mulDiv(weight, projectAmount, totalAmount)
62
- -> Returns merged specs: [721 hook spec, buyback hook spec]
63
- -> Store records payment with modified weight
64
- -> Terminal mints tokens via Controller
65
- -> Terminal executes pay hook specs (721 hook first, then buyback hook)
66
- ```
67
-
68
- **Key insight:** The weight scaling in `REVOwner.beforePayRecordedWith` ensures the terminal only mints tokens proportional to the amount entering the project (excluding 721 tier split amounts). Without this scaling, payers would get token credit for the split portion too.
69
-
70
- ### Cash-Out Flow
71
-
72
- ```
73
- User cashes out via terminal
74
- -> Terminal calls JBTerminalStore.recordCashOutFor()
75
- -> Store calls REVOwner.beforeCashOutRecordedWith() [data hook]
76
- -> If sucker: return 0% tax, full amount (fee exempt)
77
- -> If cashOutDelay not passed (reads from REVOwner storage): revert
78
- -> If cashOutTaxRate == 0 or no fee terminal: return as-is
79
- -> Otherwise: split cashOutCount into fee portion + non-fee portion
80
- -> Compute reclaim for non-fee portion via bonding curve
81
- -> Compute fee amount via bonding curve on remaining surplus
82
- -> Return modified cashOutCount + hook spec for fee payment
83
- -> Store records cash-out with modified parameters
84
- -> Terminal burns tokens
85
- -> Terminal transfers reclaimed amount to user
86
- -> Terminal calls REVOwner.afterCashOutRecordedWith() [cash-out hook]
87
- -> REVOwner pays fee to fee revnet terminal
88
- -> On failure: returns funds to originating project
89
- ```
90
-
91
- **Key insight:** The cash-out fee is computed as a two-step bonding curve calculation, not a simple percentage of the reclaimed amount. This is because burning fewer tokens (non-fee portion) changes the surplus-to-supply ratio for the fee portion.
92
-
93
- ### Loan Flow
94
-
95
- ```
96
- Borrower calls REVLoans.borrowFrom()
97
- -> Prerequisite: caller must have granted BURN_TOKENS permission to REVLoans via JBPermissions
98
- -> Enforce cash-out delay: resolve REVOwner from ruleset dataHook, check IREVOwner.cashOutDelayOf(revnetId) (stored on REVOwner)
99
- -> Validate: collateral > 0, terminal registered, prepaidFeePercent in range
100
- -> Generate loan ID: revnetId * 1T + loanNumber
101
- -> Create loan in storage
102
- -> Calculate borrowAmount via bonding curve:
103
- -> totalSurplus = aggregate from all terminals
104
- -> totalBorrowed = aggregate from all loan sources
105
- -> borrowable = JBCashOuts.cashOutFrom(surplus + borrowed, collateral, supply + totalCollateral, taxRate)
106
- -> Calculate source fee: JBFees.feeAmountFrom(borrowAmount, prepaidFeePercent)
107
- -> _adjust():
108
- -> Write loan.amount and loan.collateral to storage (CEI)
109
- -> _addTo(): pull funds via useAllowanceOf, pay REV fee, transfer to beneficiary
110
- -> _addCollateralTo(): burn collateral tokens via Controller
111
- -> Pay source fee to terminal
112
- -> Mint loan ERC-721 to borrower
113
- ```
114
-
115
- **Key insight:** `_borrowableAmountFrom` includes `totalBorrowed` in the surplus calculation (`surplus + totalBorrowed`) and `totalCollateral` in the supply calculation (`totalSupply + totalCollateral`). This means outstanding loans don't reduce the borrowable amount for new loans -- the virtual surplus and virtual supply are used.
116
-
117
- ## Key State Variables
118
-
119
- ### REVDeployer Storage
120
-
121
- | Variable | Purpose | Audit Focus |
122
- |----------|---------|-------------|
123
- | `amountToAutoIssue[revnetId][stageId][beneficiary]` | Premint tokens per stage per beneficiary | Single-claim enforcement (zeroed before mint) |
124
- | `hashedEncodedConfigurationOf[revnetId]` | Config hash for cross-chain sucker validation | Gap: does NOT cover terminal configs |
125
- | `_extraOperatorPermissions[revnetId]` | Custom permissions for split operator | Set during deploy based on 721 hook prevention flags |
126
-
127
- ### REVOwner Storage
128
-
129
- | Variable | Purpose | Audit Focus |
130
- |----------|---------|-------------|
131
- | `DEPLOYER` | REVDeployer address | Set once via `setDeployer()` called from REVDeployer's constructor. **Not immutable** -- stored as a regular storage variable to break circular dependency. `setDeployer()` sets `msg.sender` as `DEPLOYER` and reverts if already set (`REVOwner_AlreadyInitialized`). Used to restrict access to `setCashOutDelayOf()` and `setTiered721HookOf()`. |
132
- | `cashOutDelayOf[revnetId]` | Timestamp when cash-outs unlock | Set by REVDeployer via `setCashOutDelayOf()` (DEPLOYER-restricted). Applied only for existing revnets deployed to new chains. **Read by REVLoans via IREVOwner.** Verify only DEPLOYER can call the setter. |
133
- | `tiered721HookOf[revnetId]` | 721 hook address | Set by REVDeployer via `setTiered721HookOf()` (DEPLOYER-restricted). Set once during deploy, never changed. **Read by REVOwner internally during pay hooks.** Verify only DEPLOYER can call the setter. |
134
-
135
- ### REVLoans Storage
136
-
137
- | Variable | Purpose | Audit Focus |
138
- |----------|---------|-------------|
139
- | `_loanOf[loanId]` | Per-loan state (REVLoan struct) | Deleted on repay/liquidate; verify no stale reads |
140
- | `totalCollateralOf[revnetId]` | Sum of all burned collateral for a revnet | Must match sum of active loan collaterals |
141
- | `totalBorrowedFrom[revnetId][terminal][token]` | Total debt per loan source | Must match sum of active loan amounts per source |
142
- | `totalLoansBorrowedFor[revnetId]` | Monotonically increasing loan counter | Used for loan ID generation; never decrements |
143
- | `isLoanSourceOf[revnetId][terminal][token]` | Whether a source has been used | Only set to true, never back to false |
144
- | `_loanSourcesOf[revnetId]` | Array of all loan sources | Only grows; iterated in `_totalBorrowedFrom` |
145
-
146
- ### REVLoan Struct (packed storage)
147
-
148
- ```solidity
149
- struct REVLoan {
150
- uint112 amount; // Borrowed amount in source token's decimals
151
- uint112 collateral; // Number of revnet tokens burned as collateral
152
- uint48 createdAt; // Block timestamp when loan was created
153
- uint16 prepaidFeePercent; // Fee percent prepaid (25-500, out of MAX_FEE=1000)
154
- uint32 prepaidDuration; // Seconds of interest-free window
155
- REVLoanSource source; // (token, terminal) pair
156
- }
157
- ```
158
-
159
- **Note:** `uint112` max is ~5.19e33. Amounts above this are checked in `_adjust` and revert with `REVLoans_OverflowAlert`.
160
-
161
- ## Priority Audit Areas
162
-
163
- Audit in this order. Earlier items have higher blast radius:
164
-
165
- ### 1. Loan collateral valuation and manipulation
166
-
167
- The bonding curve is the sole collateral oracle. Verify:
168
-
169
- - `_borrowableAmountFrom` correctly aggregates surplus across all terminals
170
- - `totalBorrowed` and `totalCollateral` adjustments in the virtual surplus/supply calculation are correct
171
- - Stage transitions don't allow arbitrage (borrow under old tax rate, benefit from new rate)
172
- - Rounding in `JBCashOuts.cashOutFrom` doesn't favor the borrower
173
- - Cross-currency aggregation in `_totalBorrowedFrom` handles decimal normalization correctly
174
- - Price feed failures (zero price) are handled gracefully (sources skipped, not reverted)
175
-
176
- ### 2. CEI pattern in loan operations
177
-
178
- No reentrancy guard. Verify the CEI ordering in:
179
-
180
- - `_adjust`: writes `loan.amount` and `loan.collateral` before `_addTo` / `_removeFrom` / `_addCollateralTo` / `_returnCollateralFrom`
181
- - `borrowFrom`: `_adjust` before `_mint` (ERC-721 onReceived callback)
182
- - `repayLoan`: `_burn` before `_adjust` before `_mint` (for partial repay)
183
- - `reallocateCollateralFromLoan`: `_reallocateCollateralFromLoan` before `borrowFrom` -- two full loan operations in sequence
184
- - `liquidateExpiredLoansFrom`: `_burn` and `delete` before storage updates
185
-
186
- **Specific concern:** In `reallocateCollateralFromLoan`, the reallocation creates a new loan NFT and then `borrowFrom` creates another. Between these two operations, tokens are minted back to the caller (returned collateral) which are then immediately burned (new loan collateral). If `borrowFrom` triggers an external callback (via pay hooks or the ERC-721 mint), can the caller manipulate state between the two operations?
16
+ In scope:
17
+ - `src/REVDeployer.sol`
18
+ - `src/REVOwner.sol`
19
+ - `src/REVLoans.sol`
20
+ - `src/interfaces/`
21
+ - `src/structs/`
22
+ - deployment scripts in `script/`
23
+
24
+ Key dependencies:
25
+ - `nana-core-v6`
26
+ - `nana-721-hook-v6`
27
+ - `nana-buyback-hook-v6`
28
+ - `nana-suckers-v6`
29
+ - `croptop-core-v6`
30
+
31
+ ## Start Here
32
+
33
+ Read in this order:
34
+ - `REVOwner`
35
+ - `REVDeployer`
36
+ - `REVLoans`
37
+
38
+ `REVOwner` is the fastest way to understand how a live revnet differs from plain Juicebox behavior.
39
+ `REVDeployer` explains why that behavior exists.
40
+ `REVLoans` is where those economics are turned into extractable collateral value.
41
+
42
+ ## System Model
43
+
44
+ The repo splits responsibilities:
45
+ - `REVDeployer`: launches revnets, encodes stage configs, manages optional 721 and sucker composition
46
+ - `REVOwner`: runtime data/cash-out hook used by deployed revnets
47
+ - `REVLoans`: loan system that burns collateral on borrow and re-mints on repayment
48
+
49
+ Important composition behavior:
50
+ - revnet payments may be proxied through 721 and buyback hooks
51
+ - cash-out behavior may be altered for suckers or by revnet-specific fee handling
52
+ - loan health depends on bonding-curve value and surplus, so core accounting and stage timing directly matter
53
+
54
+ Two mental models help here:
55
+ - `REVDeployer` is mostly a launch-time authority that permanently shapes economics
56
+ - `REVOwner` is a runtime hook that can make a launched revnet behave very differently from a plain Juicebox project
187
57
 
188
- ### 3. Data hook composition
58
+ ## Critical Invariants
189
59
 
190
- REVOwner proxies between the terminal and two hooks. REVOwner reads `tiered721HookOf` and `cashOutDelayOf` from its own storage (set by REVDeployer via DEPLOYER-restricted setters). Verify:
60
+ 1. Stage immutability
61
+ Once a revnet is launched, future stage economics must follow the encoded schedule and not become mutable through helper paths.
191
62
 
192
- - The 721 hook's `beforePayRecordedWith` is called with the full context, but the buyback hook's is called with a reduced amount. Is this always correct?
193
- - When the 721 hook returns specs with `amount >= context.amount.value`, `projectAmount` is 0 and weight is 0. This means no tokens are minted by the terminal (all funds go to 721 splits). Verify this is safe -- does the buyback hook handle a zero-amount context gracefully?
194
- - The `hookSpecifications` array sizing assumes at most one spec from each hook. Verify neither hook can return multiple specs.
195
- - The weight scaling `mulDiv(weight, projectAmount, context.amount.value)` -- can this produce a weight of 0 when it shouldn't, or a weight > 0 when it should be 0?
196
-
197
- ### 4. Cash-out fee calculation
63
+ 2. Payment accounting is scaled correctly
64
+ If only part of a payment enters the treasury because of split or hook routing, token issuance must reflect only that treasury-entering portion.
198
65
 
199
- The two-step bonding curve fee calculation in `REVOwner.beforeCashOutRecordedWith`:
66
+ 3. Loan collateralization is sound
67
+ Borrow, repay, refinance, and liquidation paths must never let a borrower extract more value than the design permits.
200
68
 
201
- ```solidity
202
- feeCashOutCount = mulDiv(cashOutCount, FEE, MAX_FEE) // 2.5% of tokens
203
- nonFeeCashOutCount = cashOutCount - feeCashOutCount
69
+ 4. Hook privilege stays narrow
70
+ `REVOwner` and deployer-only setters must not be callable by arbitrary actors or stale deployment helpers.
204
71
 
205
- postFeeReclaimedAmount = JBCashOuts.cashOutFrom(surplus, nonFeeCashOutCount, totalSupply, taxRate)
206
- feeAmount = JBCashOuts.cashOutFrom(surplus - postFeeReclaimedAmount, feeCashOutCount, totalSupply - nonFeeCashOutCount, taxRate)
207
- ```
72
+ 5. Sucker and operator exemptions are precise
73
+ Fee-free or mint-enabled paths meant for registered omnichain components must not be reusable by arbitrary callers.
208
74
 
209
- Verify:
210
- - `postFeeReclaimedAmount + feeAmount <= directReclaim` (total <= what you'd get without fee splitting)
211
- - Micro cash-outs (< 40 wei at 2.5%) round `feeCashOutCount` to zero, bypassing the fee. This is documented as economically insignificant. Verify.
212
- - The `cashOutCount` returned to the terminal is `nonFeeCashOutCount`, but the terminal still burns the full `cashOutCount` tokens. **Open question**: Does the terminal burn the full original `cashOutCount` or only the `nonFeeCashOutCount`? Trace through `JBMultiTerminal.cashOutTokensOf()` to verify. If the full count is burned, the fee tokens are effectively destroyed -- this may be intentional (fee is taken from the surplus).
75
+ 6. Collateral burn/remint symmetry holds
76
+ Loan collateral that is burned on borrow and re-minted on repay must not be duplicable, strandable, or recoverable by the wrong party.
213
77
 
214
- ### 5. Permission model
78
+ 7. Stage transitions do not create hidden refinancing windows
79
+ Changes in issuance or cash-out economics across stages must not let a borrower lock in value that the system intended to become unavailable.
215
80
 
216
- REVDeployer grants wildcard permissions during construction:
81
+ ## Threat Model
217
82
 
218
- ```solidity
219
- constructor() {
220
- _setPermission(SUCKER_REGISTRY, 0, MAP_SUCKER_TOKEN); // All revnets
221
- _setPermission(LOANS, 0, USE_ALLOWANCE); // All revnets
222
- _setPermission(BUYBACK_HOOK, 0, SET_BUYBACK_POOL); // All revnets
223
- }
224
- ```
83
+ Prioritize:
84
+ - surplus manipulation before and after borrowing
85
+ - stage-boundary timing attacks
86
+ - cash-out delay bypasses
87
+ - array or hook-spec assumptions that depend on non-empty returns
88
+ - split-weight accounting during 721 compositions
89
+ - Permit2 and ERC-2771 assisted loan flows
225
90
 
226
- These are projectId=0 (wildcard) permissions. Verify:
227
- - `JBPermissions` resolves wildcard correctly -- these grant the permission for ALL revnets owned by REVDeployer, not just project 0
228
- - The LOANS contract can call `useAllowanceOf` on any revnet's terminal -- verify this is constrained by the bonding curve calculation in `borrowFrom`
229
- - No other permission is granted at wildcard level
91
+ The best attacker mindsets here are:
92
+ - a borrower who can move surplus or stage timing before and after borrowing
93
+ - a caller exploiting the fact that revnets are composed from several optional subsystems, not one monolith
94
+ - an operator or deployer helper that retained one capability too many
230
95
 
231
- ### 6. Auto-issuance timing
96
+ ## Hotspots
232
97
 
233
- Stage IDs computed during deployment must match JBRulesets-assigned IDs:
98
+ - `REVOwner.beforePayRecordedWith`
99
+ - `REVOwner.beforeCashOutRecordedWith`
100
+ - deployer-only linkage between `REVDeployer` and `REVOwner`
101
+ - `REVLoans` borrowable amount, fee accrual, and liquidation logic
102
+ - any path that assumes a valid tiered 721 hook or sucker mapping exists
234
103
 
235
- ```solidity
236
- amountToAutoIssue[revnetId][block.timestamp + i][beneficiary] += count;
237
- ```
104
+ ## Sequences Worth Replaying
238
105
 
239
- Later claimed via:
240
- ```solidity
241
- (JBRuleset memory ruleset,) = CONTROLLER.getRulesetOf(revnetId, stageId);
242
- if (ruleset.start > block.timestamp) revert REVDeployer_StageNotStarted(stageId);
243
- ```
106
+ 1. Pay into a revnet with 721 and buyback composition enabled, then inspect how weight is scaled before and after hook specs are consumed.
107
+ 2. Borrow near a stage boundary, then repay, refinance, or liquidate after the next stage becomes active.
108
+ 3. Borrow after surplus inflation, then force or observe surplus contraction before liquidation.
109
+ 4. Cash out through a legitimate sucker path versus a near-sucker spoof path.
110
+ 5. Any path where `REVOwner` expects hook arrays or external replies to be non-empty.
244
111
 
245
- Verify:
246
- - JBRulesets assigns IDs as `latestId >= block.timestamp ? latestId + 1 : block.timestamp`. Does this produce `block.timestamp, block.timestamp+1, block.timestamp+2, ...` when all stages are queued in one transaction?
247
- - What if another contract queued a ruleset for the same project in the same block? (Shouldn't be possible since REVDeployer owns the project, but verify.)
248
- - `getRulesetOf` returns the ruleset by ID. If the stage hasn't started yet, `ruleset.start` is the derived start time, not the queue time. The timing guard uses `ruleset.start`, which is correct. But what if `startsAtOrAfter` is 0 for the first stage and `block.timestamp` is used? The stage starts immediately -- can auto-issuance be claimed in the same transaction as deployment?
112
+ ## Finding Bar
249
113
 
250
- ### 7. Loan fee model
114
+ The best findings in this repo usually prove one of these:
115
+ - a revnet mints or redeems on economics different from the stage schedule users think they are on
116
+ - the runtime hook scales payment or cash-out accounting incorrectly during composition
117
+ - the loan system can externalize loss to the treasury through timing, surplus movement, or fee math
118
+ - a deployer-only or operator-only assumption survives launch and remains exploitable at runtime
251
119
 
252
- Three layers of fees on borrow:
120
+ ## Build And Verification
253
121
 
254
- 1. **Protocol fee (2.5%)** -- charged by `useAllowanceOf` (JBMultiTerminal takes it automatically)
255
- 2. **REV fee (1%)** -- `JBFees.feeAmountFrom(borrowAmount, REV_PREPAID_FEE_PERCENT=10)` paid to REV revnet. Try-catch; zeroed on failure.
256
- 3. **Source prepaid fee (2.5%-50%)** -- `JBFees.feeAmountFrom(borrowAmount, prepaidFeePercent)` paid back to the revnet via `terminal.pay`. Try-catch; on failure the fee is refunded to the borrower instead of being paid to the revnet.
122
+ Standard workflow:
123
+ - `npm install`
124
+ - `forge build`
125
+ - `forge test`
257
126
 
258
- On repay, the source fee is time-proportional:
127
+ Current tests emphasize:
128
+ - lifecycle and invincibility properties
129
+ - loan invariants and attacks
130
+ - fee recovery
131
+ - split-weight adjustments
132
+ - regressions around low-severity edge cases
259
133
 
260
- ```solidity
261
- if (timeSinceLoanCreated <= prepaidDuration) return 0; // Free window
262
- // After prepaid window: linear accrual
263
- fullSourceFeeAmount = JBFees.feeAmountFrom(
264
- loan.amount - prepaid,
265
- mulDiv(timeSinceLoanCreated - prepaidDuration, MAX_FEE, LOAN_LIQUIDATION_DURATION - prepaidDuration)
266
- );
267
- sourceFeeAmount = mulDiv(fullSourceFeeAmount, amount, loan.amount);
268
- ```
269
-
270
- Verify:
271
- - The `prepaidDuration` calculation: `mulDiv(prepaidFeePercent, LOAN_LIQUIDATION_DURATION, MAX_PREPAID_FEE_PERCENT)`. At 2.5% (25), this is `25 * 3650 days / 500 = 182.5 days`. At 50% (500), it's `500 * 3650 days / 500 = 3650 days` (full duration). Is this the intended mapping?
272
- - The linear accrual formula: at `timeSinceLoanCreated = LOAN_LIQUIDATION_DURATION`, the fee percent approaches MAX_FEE (100%). The borrower would owe the full remaining loan amount as a fee, making repayment impossible.
273
- - At the boundary, `_determineSourceFeeAmount` reverts with `REVLoans_LoanExpired` before the fee reaches 100%. The revert uses `>` (not `>=`) so the exact boundary second is still repayable -- verify this matches the liquidation path which uses `<=`.
274
-
275
- ### 8. REVOwner initialization and circular dependency
276
-
277
- REVOwner and REVDeployer have a circular dependency broken by `setDeployer()`, called atomically from REVDeployer's constructor. Deploy order: REVOwner first, then REVDeployer(owner=REVOwner) -- the constructor calls `REVOwner.setDeployer()` atomically. Verify:
278
-
279
- - `setDeployer()` sets `msg.sender` as `DEPLOYER` and reverts if already set (`REVOwner_AlreadyInitialized`)
280
- - `DEPLOYER` is a storage variable, not immutable, to break the circular dependency
281
- - Before `setDeployer()` is called, the DEPLOYER-restricted setters (`setCashOutDelayOf`, `setTiered721HookOf`) would reject calls, leaving `cashOutDelayOf` and `tiered721HookOf` unpopulated
282
- - After `setDeployer()` has been called once, no subsequent call can change the `DEPLOYER` address
283
- - Only DEPLOYER can call `setCashOutDelayOf()` and `setTiered721HookOf()` -- verify access control on these setters
284
- - `cashOutDelayOf` and `tiered721HookOf` are stored on REVOwner (not REVDeployer) -- verify REVOwner reads from its own storage and the setters cannot be called by unauthorized addresses
285
- - Both contracts define `FEE = 25` independently -- verify they stay in sync
286
-
287
- ## Invariants
288
-
289
- Fuzzable properties that should hold for all valid inputs:
290
-
291
- 1. **Collateral accounting**: `totalCollateralOf[revnetId]` equals the sum of `_loanOf[loanId].collateral` for all active loans belonging to that revnet.
292
- 2. **Borrowed amount accounting**: `totalBorrowedFrom[revnetId][terminal][token]` equals the sum of `_loanOf[loanId].amount` for all active loans with that source.
293
- 3. **Loan NFT ownership**: The ERC-721 owner of a loan NFT is the only address authorized to repay, reallocate, or manage that loan (absent ROOT or explicit permission grants).
294
- 4. **No flash-loan profit**: Borrowing and repaying in the same block (zero time elapsed) should never yield a net profit to the borrower after all fees.
295
- 5. **Stage monotonicity**: Stage transitions are monotonically increasing in time -- a later stage's `startsAtOrAfter` is always strictly greater than the previous stage's.
296
- 6. **REVOwner initialization**: `DEPLOYER` is set exactly once via `setDeployer()` (called from REVDeployer's constructor) and matches the REVDeployer that references this REVOwner via `OWNER()`. Only the initialized `DEPLOYER` can call `setCashOutDelayOf()` and `setTiered721HookOf()`.
297
-
298
- ## How to Run Tests
299
-
300
- ```bash
301
- cd revnet-core-v6
302
- npm install
303
- forge build
304
- forge test
305
-
306
- # Run with verbosity for debugging
307
- forge test -vvvv --match-test testName
308
-
309
- # Write a PoC
310
- forge test --match-path test/audit/ExploitPoC.t.sol -vvv
311
-
312
- # Gas analysis
313
- forge test --gas-report
314
- ```
315
-
316
- ## Anti-Patterns to Hunt
317
-
318
- | Pattern | Where | Why |
319
- |---------|-------|-----|
320
- | `mulDiv` rounding direction | `REVOwner.beforePayRecordedWith` weight scaling, `_determineSourceFeeAmount`, `_borrowableAmountFrom` | Rounding in borrower's favor compounds over many loans |
321
- | Source fee `pay` silently caught on revert | `REVLoans._adjust` try-catch block | The catch block silently returns funds to the borrower instead of paying the fee, which could allow borrowers to intentionally cause fee payment reverts to avoid paying the source fee |
322
- | `delete _loanOf[loanId]` after external calls | `_repayLoan`, `_reallocateCollateralFromLoan` | Verify delete happens after all references to the loan are resolved |
323
- | Loan storage read after `_adjust` mutates it | `_repayLoan` partial repay path | `_adjust` modifies `loan` via storage pointer; subsequent reads see mutated values |
324
- | Unbounded loop in `_totalBorrowedFrom` | Called during every borrow operation | Gas griefing if many distinct loan sources accumulate |
325
- | `uint112` truncation | `_adjust` explicit check | Verify all paths that set `loan.amount` or `loan.collateral` go through `_adjust` |
326
- | Permit2 try-catch swallowing | `_acceptFundsFor` | If permit fails, fall through to regular transfer. Is the state consistent? |
327
- | ERC-721 `_mint` callback | `borrowFrom`, `_repayLoan`, `_reallocateCollateralFromLoan` | `onERC721Received` can re-enter. Verify all state is settled before mint. |
328
-
329
- ## Previous Audit Findings
330
-
331
- No prior formal audit with finding IDs has been conducted on this codebase. All risk analysis is internal. See [RISKS.md](./RISKS.md) for the trust model and known risks.
332
-
333
- ## Coverage Gaps
334
-
335
- - **Stage transition during active loans**: No test for borrowing under one stage's tax rate and the stage transitioning before repayment.
336
- - **Multi-source loan aggregation**: `_totalBorrowedFrom` iterates all sources, but no test with >3 active sources testing gas and precision.
337
- - **Concurrent borrow + cash out**: No test for a borrow and cash out on the same revnet in the same block.
338
- - **Auto-issuance with sucker deployment**: No test for claiming auto-issuance on a cross-chain revnet during the cashOutDelay window.
339
- - **Partial repay + reallocation**: No test for `reallocateCollateralFromLoan` with a partial repay in the same transaction.
340
- - **Loan fee approaching 100%**: No test for repayment at `LOAN_LIQUIDATION_DURATION - 1 second` where the fee should be just under 100%.
341
-
342
- ## Error Reference
343
-
344
- | Error | Contract | Trigger |
345
- |-------|----------|---------|
346
- | `REVDeployer_AutoIssuanceBeneficiaryZeroAddress` | REVDeployer | Auto-issuance configured with `beneficiary == address(0)` |
347
- | `REVDeployer_CashOutDelayNotFinished` | REVDeployer | Cash-out attempted before `cashOutDelayOf[revnetId]` timestamp has passed |
348
- | `REVDeployer_CashOutsCantBeTurnedOffCompletely` | REVDeployer | Stage configured with `cashOutTaxRate >= MAX_CASH_OUT_TAX_RATE` (10,000) |
349
- | `REVDeployer_MustHaveSplits` | REVDeployer | Stage has `splitPercent > 0` but empty `splits` array |
350
- | `REVDeployer_NothingToAutoIssue` | REVDeployer | `autoIssueFor` called but `amountToAutoIssue` is zero for the given beneficiary and stage |
351
- | `REVDeployer_NothingToBurn` | REVDeployer | `burnFrom` called but REVDeployer holds zero tokens for the revnet |
352
- | `REVDeployer_RulesetDoesNotAllowDeployingSuckers` | REVDeployer | `deploySuckersFor` called but current ruleset metadata disallows sucker deployment |
353
- | `REVDeployer_StageNotStarted` | REVDeployer | `autoIssueFor` called for a stage whose `ruleset.start > block.timestamp` |
354
- | `REVDeployer_StagesRequired` | REVDeployer | `deployFor` / `launchChainsFor` called with empty `stageConfigurations` array |
355
- | `REVDeployer_StageTimesMustIncrease` | REVDeployer | Stage `startsAtOrAfter` timestamps are not strictly increasing |
356
- | `REVDeployer_Unauthorized` | REVDeployer | Caller is not the split operator (for operator-gated functions) or not the project owner (for `launchChainsFor`) |
357
- | `REVLoans_CashOutDelayNotFinished` | REVLoans | `borrowFrom` called during the 30-day cash-out delay period (cross-chain deployment protection) |
358
- | `REVLoans_CollateralExceedsLoan` | REVLoans | `reallocateCollateralFromLoan` called with `collateralCountToReturn > loan.collateral` |
359
- | `REVLoans_InvalidPrepaidFeePercent` | REVLoans | `prepaidFeePercent` outside `[MIN_PREPAID_FEE_PERCENT, MAX_PREPAID_FEE_PERCENT]` range (25-500) |
360
- | `REVLoans_InvalidTerminal` | REVLoans | Loan source references a terminal not registered in `JBDirectory` for the revnet |
361
- | `REVLoans_LoanExpired` | REVLoans | Repay/reallocation attempted after `LOAN_LIQUIDATION_DURATION` has elapsed since loan creation |
362
- | `REVLoans_LoanIdOverflow` | REVLoans | Loan counter for a revnet exceeds 1 trillion (namespace collision with next revnet ID) |
363
- | `REVLoans_NewBorrowAmountGreaterThanLoanAmount` | REVLoans | Partial repay would increase the loan's borrow amount above the original |
364
- | `REVLoans_NoMsgValueAllowed` | REVLoans | `msg.value > 0` sent when the loan source token is not the native token |
365
- | `REVLoans_NotEnoughCollateral` | REVLoans | `_reallocateCollateralFromLoan` attempts to remove more collateral than the loan holds |
366
- | `REVLoans_NothingToRepay` | REVLoans | `repayLoan` called with both `repayBorrowAmount == 0` and `collateralCountToReturn == 0` |
367
- | `REVLoans_OverMaxRepayBorrowAmount` | REVLoans | Actual repay cost (principal + accrued fee) exceeds caller's `maxRepayBorrowAmount` |
368
- | `REVLoans_OverflowAlert` | REVLoans | Loan amount or collateral exceeds `uint112` max, or Permit2 amount exceeds `uint160` max |
369
- | `REVLoans_PermitAllowanceNotEnough` | REVLoans | Permit2 allowance is less than the required transfer amount |
370
- | `REVLoans_ReallocatingMoreCollateralThanBorrowedAmountAllows` | REVLoans | After reallocation, the remaining collateral's bonding curve value is less than the remaining borrow amount |
371
- | `REVLoans_SourceMismatch` | REVLoans | Repay/reallocation called with a source (token, terminal) that does not match the loan's original source |
372
- | `REVLoans_Unauthorized` | REVLoans | Caller is not the ERC-721 owner of the loan being managed |
373
- | `REVLoans_UnderMinBorrowAmount` | REVLoans | Bonding curve returns a borrow amount below the caller's `minBorrowAmount` (slippage protection) |
374
- | `REVLoans_ZeroBorrowAmount` | REVLoans | Bonding curve returns zero for the given collateral (e.g., zero surplus) |
375
- | `REVLoans_ZeroCollateralLoanIsInvalid` | REVLoans | `borrowFrom` called with `collateralCount == 0` |
376
-
377
- ## Compiler and Version Info
378
-
379
- - **Solidity**: 0.8.28
380
- - **EVM target**: Cancun
381
- - **Optimizer**: via-IR, 100 runs
382
- - **Dependencies**: OpenZeppelin 5.x, PRBMath, Permit2, nana-core-v6, nana-721-hook-v6, nana-buyback-hook-v6, nana-suckers-v6
383
- - **Build**: `forge build` (Foundry)
384
-
385
- ## How to Report Findings
386
-
387
- For each finding:
388
-
389
- 1. **Title** -- one line, starts with severity (CRITICAL/HIGH/MEDIUM/LOW)
390
- 2. **Affected contract(s)** -- exact file path and line numbers
391
- 3. **Description** -- what is wrong, in plain language
392
- 4. **Trigger sequence** -- step-by-step, minimal steps to reproduce
393
- 5. **Impact** -- what an attacker gains, what a user loses (with numbers if possible)
394
- 6. **Proof** -- code trace showing the exact execution path, or a Foundry test
395
- 7. **Fix** -- minimal code change that resolves the issue
396
-
397
- **Severity guide:**
398
- - **CRITICAL**: Direct fund loss, collateral manipulation enabling undercollateralized loans, or permanent DoS.
399
- - **HIGH**: Conditional fund loss, loan fee bypass, or broken invariant.
400
- - **MEDIUM**: Value leakage, fee calculation inaccuracy, griefing.
401
- - **LOW**: Informational, edge-case-only with no material impact.
134
+ Strong findings in this repo usually combine economics and composition: a bug is especially valuable if it only appears once a revnet is wired into the rest of the ecosystem.
package/CHANGELOG.md ADDED
@@ -0,0 +1,65 @@
1
+ # Changelog
2
+
3
+ ## Scope
4
+
5
+ This file describes the verified change from `revnet-core-v5` to the current `revnet-core-v6` repo.
6
+
7
+ ## Current v6 surface
8
+
9
+ - `REVDeployer`
10
+ - `REVOwner`
11
+ - `REVLoans`
12
+ - `IREVDeployer`
13
+ - `IREVOwner`
14
+ - `IREVLoans`
15
+
16
+ ## Summary
17
+
18
+ - The current repo assumes 721 hooks are part of the normal revnet deployment path rather than a separate special case.
19
+ - Buyback and loans configuration are more centralized than in v5. The repo is oriented around shared infrastructure instead of repeating per-revnet setup.
20
+ - `REVOwner` is now a real part of the repo's runtime surface. That split matters because the hook behavior no longer lives only on `REVDeployer`.
21
+ - The v6 test tree is substantially broader than the v5 tree, with dedicated regression, fork, attack, and invariant coverage for loans, cash-outs, split weights, and lifecycle edges.
22
+ - The repo moved from the v5 `0.8.23` baseline to `0.8.28`.
23
+
24
+ ## Verified deltas
25
+
26
+ - `IREVDeployer.deployWith721sFor(...)` is gone.
27
+ - `IREVDeployer.deployFor(...)` now has overloads that return `(uint256, IJB721TiersHook)`.
28
+ - `IREVDeployer.BUYBACK_HOOK()`, `LOANS()`, and `OWNER()` are explicit v6 surface area.
29
+ - `IREVOwner` is a new interface and runtime counterpart to the deployer.
30
+ - The old caller-supplied `REVBuybackHookConfig` path is no longer part of the deployer interface.
31
+
32
+ ## Breaking ABI changes
33
+
34
+ - `deployWith721sFor(...)` was removed.
35
+ - `deployFor(...)` overloads changed shape and return the deployed 721 hook.
36
+ - `REVConfig` no longer carries `loanSources` or `loans`.
37
+ - `REVDeploy721TiersHookConfig` now uses `REVBaseline721HookConfig` and inverted `preventSplitOperator*` booleans.
38
+ - `IREVOwner` is a new interface that some integrations must track separately from `IREVDeployer`.
39
+
40
+ ## Indexer impact
41
+
42
+ - Runtime hook activity may now come from `REVOwner`, not only `REVDeployer`.
43
+ - Deployment indexing should assume a 721 hook is returned and present by default.
44
+ - Any schema built around caller-supplied buyback-hook config in deploy events needs to be revisited.
45
+
46
+ ## Migration notes
47
+
48
+ - Re-check any integration that assumed `REVDeployer` was the only important runtime address. `REVOwner` now matters.
49
+ - Update deployment and indexing code for the default-721-hook assumption.
50
+ - Rebuild ABI expectations from the current interfaces and structs. The revnet surface is not a light-touch v5 upgrade.
51
+
52
+ ## ABI appendix
53
+
54
+ - Removed functions
55
+ - `deployWith721sFor(...)`
56
+ - Changed functions
57
+ - `deployFor(...)` overloads now return the 721 hook
58
+ - Added interfaces / runtime addresses
59
+ - `IREVOwner`
60
+ - `OWNER()`
61
+ - Changed structs
62
+ - `REVConfig`
63
+ - `REVDeploy721TiersHookConfig`
64
+ - Removed config path
65
+ - caller-supplied `REVBuybackHookConfig`