@rev-net/core-v6 0.0.23 → 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.
- package/ADMINISTRATION.md +27 -0
- package/ARCHITECTURE.md +53 -129
- package/AUDIT_INSTRUCTIONS.md +108 -375
- package/CHANGELOG.md +65 -0
- package/README.md +76 -175
- package/RISKS.md +16 -4
- package/SKILLS.md +30 -391
- package/STYLE_GUIDE.md +59 -20
- package/USER_JOURNEYS.md +55 -478
- package/package.json +3 -3
- package/references/operations.md +311 -0
- package/references/runtime.md +98 -0
- package/script/Deploy.s.sol +12 -12
- package/src/REVLoans.sol +10 -10
- package/src/interfaces/IREVDeployer.sol +1 -1
- package/test/TestSplitWeightE2E.t.sol +10 -6
- package/test/TestSplitWeightFork.t.sol +10 -6
- package/test/fork/ForkTestBase.sol +10 -6
- package/CHANGE_LOG.md +0 -420
package/AUDIT_INSTRUCTIONS.md
CHANGED
|
@@ -1,401 +1,134 @@
|
|
|
1
|
-
# Audit Instructions
|
|
1
|
+
# Audit Instructions
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
58
|
+
## Critical Invariants
|
|
189
59
|
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
210
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
+
## Threat Model
|
|
217
82
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
227
|
-
-
|
|
228
|
-
-
|
|
229
|
-
-
|
|
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
|
-
|
|
96
|
+
## Hotspots
|
|
232
97
|
|
|
233
|
-
|
|
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
|
-
|
|
236
|
-
amountToAutoIssue[revnetId][block.timestamp + i][beneficiary] += count;
|
|
237
|
-
```
|
|
104
|
+
## Sequences Worth Replaying
|
|
238
105
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
+
## Build And Verification
|
|
253
121
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
122
|
+
Standard workflow:
|
|
123
|
+
- `npm install`
|
|
124
|
+
- `forge build`
|
|
125
|
+
- `forge test`
|
|
257
126
|
|
|
258
|
-
|
|
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
|
-
|
|
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`
|