@rev-net/core-v6 0.0.15 → 0.0.17
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 +5 -1
- package/ARCHITECTURE.md +69 -11
- package/AUDIT_INSTRUCTIONS.md +90 -7
- package/CHANGE_LOG.md +16 -3
- package/README.md +32 -7
- package/RISKS.md +26 -14
- package/SKILLS.md +167 -45
- package/STYLE_GUIDE.md +2 -2
- package/USER_JOURNEYS.md +20 -6
- package/foundry.toml +1 -1
- package/package.json +9 -9
- package/script/Deploy.s.sol +80 -16
- package/script/helpers/RevnetCoreDeploymentLib.sol +1 -1
- package/src/REVDeployer.sol +39 -9
- package/src/REVLoans.sol +26 -1
- package/test/REV.integrations.t.sol +1 -1
- package/test/REVAutoIssuanceFuzz.t.sol +1 -1
- package/test/REVDeployerRegressions.t.sol +1 -1
- package/test/REVInvincibility.t.sol +1 -1
- package/test/REVInvincibilityHandler.sol +1 -1
- package/test/REVLifecycle.t.sol +1 -1
- package/test/REVLoans.invariants.t.sol +1 -1
- package/test/REVLoansAttacks.t.sol +1 -1
- package/test/REVLoansFeeRecovery.t.sol +1 -1
- package/test/REVLoansFindings.t.sol +1 -1
- package/test/REVLoansRegressions.t.sol +1 -1
- package/test/REVLoansSourceFeeRecovery.t.sol +1 -1
- package/test/REVLoansSourced.t.sol +1 -1
- package/test/REVLoansUnSourced.t.sol +1 -1
- package/test/TestBurnHeldTokens.t.sol +1 -1
- package/test/TestCEIPattern.t.sol +1 -1
- package/test/TestCashOutCallerValidation.t.sol +1 -1
- package/test/TestConversionDocumentation.t.sol +1 -1
- package/test/TestCrossCurrencyReclaim.t.sol +1 -1
- package/test/TestCrossSourceReallocation.t.sol +1 -1
- package/test/TestERC2771MetaTx.t.sol +1 -1
- package/test/TestEmptyBuybackSpecs.t.sol +1 -1
- package/test/TestFlashLoanSurplus.t.sol +1 -1
- package/test/TestHookArrayOOB.t.sol +1 -1
- package/test/TestLiquidationBehavior.t.sol +1 -1
- package/test/TestLoanSourceRotation.t.sol +1 -1
- package/test/TestLongTailEconomics.t.sol +1 -1
- package/test/TestLowFindings.t.sol +1 -1
- package/test/TestMixedFixes.t.sol +1 -1
- package/test/TestPermit2Signatures.t.sol +1 -1
- package/test/TestReallocationSandwich.t.sol +1 -1
- package/test/TestRevnetRegressions.t.sol +1 -1
- package/test/TestSplitWeightAdjustment.t.sol +1 -1
- package/test/TestSplitWeightE2E.t.sol +1 -1
- package/test/TestSplitWeightFork.t.sol +9 -10
- package/test/TestStageTransitionBorrowable.t.sol +1 -1
- package/test/TestSwapTerminalPermission.t.sol +1 -1
- package/test/TestUint112Overflow.t.sol +1 -1
- package/test/TestZeroRepayment.t.sol +1 -1
- package/test/audit/LoanIdOverflowGuard.t.sol +497 -0
- package/test/fork/ForkTestBase.sol +8 -11
- package/test/fork/TestAutoIssuanceFork.t.sol +1 -1
- package/test/fork/TestCashOutFork.t.sol +1 -1
- package/test/fork/TestIssuanceDecayFork.t.sol +1 -1
- package/test/fork/TestLoanBorrowFork.t.sol +1 -1
- package/test/fork/TestLoanCrossRulesetFork.t.sol +1 -1
- package/test/fork/TestLoanERC20Fork.t.sol +1 -1
- package/test/fork/TestLoanLiquidationFork.t.sol +1 -1
- package/test/fork/TestLoanReallocateFork.t.sol +1 -1
- package/test/fork/TestLoanRepayFork.t.sol +1 -1
- package/test/fork/TestLoanTransferFork.t.sol +1 -1
- package/test/fork/TestPermit2PaymentFork.t.sol +1 -1
- package/test/fork/TestSplitWeightFork.t.sol +1 -1
- package/test/helpers/MaliciousContracts.sol +1 -1
- package/test/mock/MockBuybackCashOutRecorder.sol +82 -0
- package/test/mock/MockBuybackDataHook.sol +1 -1
- package/test/mock/MockBuybackDataHookMintPath.sol +1 -1
- package/test/regression/TestBurnPermissionRequired.t.sol +1 -1
- package/test/regression/TestCashOutBuybackFeeLeak.t.sol +205 -0
- package/test/regression/TestCrossRevnetLiquidation.t.sol +1 -1
- package/test/regression/TestCumulativeLoanCounter.t.sol +1 -1
- package/test/regression/TestLiquidateGapHandling.t.sol +1 -1
- package/test/regression/TestZeroPriceFeed.t.sol +1 -1
package/ADMINISTRATION.md
CHANGED
|
@@ -8,7 +8,7 @@ Admin privileges and their scope in revnet-core-v6. Revnets are designed to be a
|
|
|
8
8
|
|
|
9
9
|
- **How assigned:** Specified at deployment via `REVConfig.splitOperator`. After deployment, only the current split operator can transfer the role to a new address by calling `setSplitOperatorOf()`.
|
|
10
10
|
- **Scope:** Per-revnet. Each revnet has at most one split operator. The operator is the only human-controlled role in a deployed revnet.
|
|
11
|
-
- **
|
|
11
|
+
- **Can be permanently relinquished:** The split operator can transfer the role to `address(0)` via `setSplitOperatorOf()`, which permanently relinquishes operator powers. Permissions are granted to the zero address (which cannot execute transactions), effectively burning them. This is irreversible.
|
|
12
12
|
|
|
13
13
|
### REVLoans Owner (Ownable)
|
|
14
14
|
|
|
@@ -85,6 +85,7 @@ These permissions are granted in the `REVDeployer` constructor and apply globall
|
|
|
85
85
|
|---------|---------------|---------|
|
|
86
86
|
| `SUCKER_REGISTRY` | `MAP_SUCKER_TOKEN` | Allows the sucker registry to map tokens for all revnets. |
|
|
87
87
|
| `LOANS` | `USE_ALLOWANCE` | Allows the loans contract to use surplus allowance from all revnets to fund loans. |
|
|
88
|
+
| `BUYBACK_HOOK` | `SET_BUYBACK_POOL` | Allows the buyback hook registry to configure Uniswap V4 pools for all revnets. |
|
|
88
89
|
|
|
89
90
|
## Autonomous Design
|
|
90
91
|
|
|
@@ -134,6 +135,9 @@ The following parameters are set at deployment and can never be changed:
|
|
|
134
135
|
- `FEE_REVNET_ID` -- the project ID that receives cash-out fees
|
|
135
136
|
- `FEE` -- the cash-out fee (2.5%)
|
|
136
137
|
- `CASH_OUT_DELAY` -- 30 days for cross-chain deployments
|
|
138
|
+
- `DEFAULT_BUYBACK_POOL_FEE` -- 10,000 (1% Uniswap V4 fee tier)
|
|
139
|
+
- `DEFAULT_BUYBACK_TICK_SPACING` -- 200
|
|
140
|
+
- `DEFAULT_BUYBACK_TWAP_WINDOW` -- 2 days
|
|
137
141
|
|
|
138
142
|
### REVLoans (global, set at contract deployment)
|
|
139
143
|
- `CONTROLLER`, `DIRECTORY`, `PRICES`, `PROJECTS` -- protocol infrastructure
|
package/ARCHITECTURE.md
CHANGED
|
@@ -22,33 +22,62 @@ src/
|
|
|
22
22
|
```
|
|
23
23
|
Deployer → REVDeployer.deployFor()
|
|
24
24
|
→ Create JB project via JBController
|
|
25
|
-
→ Convert REV stages → JBRulesetConfigs
|
|
25
|
+
→ Convert REV stages → JBRulesetConfigs (see Stage-to-Ruleset Mapping below)
|
|
26
26
|
→ Each stage: duration, weight, cashOutTaxRate, splits
|
|
27
|
-
→ Auto-issuance:
|
|
27
|
+
→ Auto-issuance: record per-beneficiary token counts for later claiming
|
|
28
28
|
→ Set REVDeployer as data hook (controls pay + cashout behavior)
|
|
29
|
-
→ Initialize buyback pools at
|
|
29
|
+
→ Initialize buyback pools at fair issuance price (derived from initialIssuance)
|
|
30
30
|
→ Deploy suckers for cross-chain operation
|
|
31
31
|
→ Deploy tiered ERC-721 hook (always — empty by default, pre-configured if specified)
|
|
32
|
-
→ Compute matching hash for cross-chain deployment
|
|
32
|
+
→ Compute matching hash and store it for cross-chain sucker deployment
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
#### Matching Hash
|
|
36
|
+
|
|
37
|
+
The matching hash ensures that revnet deployments on different chains share identical economic parameters. It is computed inside `_makeRulesetConfigurations` by incrementally ABI-encoding the configuration fields, then taking the `keccak256` of the result.
|
|
38
|
+
|
|
39
|
+
Fields included in the hash (in encoding order):
|
|
40
|
+
1. **Base fields:** `baseCurrency`, `description.name`, `description.ticker`, `description.salt`
|
|
41
|
+
2. **Per-stage fields** (appended for each stage): `startsAtOrAfter` (defaults to `block.timestamp` for the first stage if zero), `splitPercent`, `initialIssuance`, `issuanceCutFrequency`, `issuanceCutPercent`, `cashOutTaxRate`
|
|
42
|
+
3. **Per-auto-issuance fields** (appended for each auto-issuance within a stage): `chainId`, `beneficiary`, `count`
|
|
43
|
+
|
|
44
|
+
The hash is stored in `hashedEncodedConfigurationOf[revnetId]` and used as part of the CREATE2 salt when deploying suckers via `SUCKER_REGISTRY.deploySuckersFor`. This guarantees that cross-chain sucker peers can only be deployed for revnets whose economic configuration matches exactly — a deployment on Chain B with different stage parameters would produce a different hash, a different salt, and therefore a different sucker address, preventing it from peering with Chain A's suckers.
|
|
45
|
+
|
|
46
|
+
Note that `splits` (the specific split recipient addresses) are **not** included in the hash. Splits may contain chain-specific addresses, so they are excluded to allow legitimate cross-chain deployments where the only difference is the split recipient addresses.
|
|
47
|
+
|
|
48
|
+
#### Auto-Issuance
|
|
49
|
+
|
|
50
|
+
Auto-issuance pre-allocates tokens to specified beneficiaries when a stage begins. Each `REVAutoIssuance` entry specifies a `chainId`, a `beneficiary` address, and a token `count`.
|
|
51
|
+
|
|
52
|
+
During deployment, the deployer records auto-issuance amounts in `amountToAutoIssue[revnetId][stageId][beneficiary]` — but only for entries whose `chainId` matches `block.chainid`. Entries for other chains are still included in the matching hash (ensuring cross-chain consistency) but are skipped for on-chain storage.
|
|
53
|
+
|
|
54
|
+
Claiming is a separate step: anyone can call `autoIssueFor(revnetId, stageId, beneficiary)` after the stage has started. This function verifies the stage's ruleset has begun (`ruleset.start <= block.timestamp`), zeroes the stored amount, and calls `CONTROLLER.mintTokensOf` to mint tokens directly to the beneficiary — bypassing the reserved percent so the full count goes to the beneficiary.
|
|
55
|
+
|
|
56
|
+
Stage IDs are assigned as `block.timestamp + i` (where `i` is the stage index), matching the JBRulesets ID assignment scheme when all stages are queued in a single transaction.
|
|
57
|
+
|
|
35
58
|
### Data Hook Behavior
|
|
36
59
|
```
|
|
37
60
|
Payment → REVDeployer.beforePayRecordedWith()
|
|
38
|
-
→
|
|
39
|
-
→
|
|
61
|
+
→ Query 721 tier hook for tier split specs (if configured)
|
|
62
|
+
→ Delegate remaining amount to buyback hook for swap-vs-mint decision
|
|
63
|
+
→ Scale weight so tokens are only minted for the project's share (after tier splits)
|
|
64
|
+
→ Return merged hook specifications (721 hook + buyback hook)
|
|
40
65
|
|
|
41
66
|
Cash Out → REVDeployer.beforeCashOutRecordedWith()
|
|
42
|
-
→ If caller is a sucker: 0% cash out tax (bridging privilege)
|
|
43
|
-
→
|
|
44
|
-
→
|
|
67
|
+
→ If caller is a sucker: 0% cash out tax, full reclaim (bridging privilege)
|
|
68
|
+
→ Enforce cash out delay (for cross-chain deployments of existing revnets)
|
|
69
|
+
→ If no tax, no fee terminal, or feeless beneficiary: delegate directly to buyback hook
|
|
70
|
+
→ Otherwise: split tokens into fee/non-fee portions via bonding curve
|
|
71
|
+
→ Delegate non-fee portion to buyback hook
|
|
72
|
+
→ Build fee hook spec routing fee amount to afterCashOutRecordedWith for processing
|
|
73
|
+
→ Return merged hook specifications (buyback hook + fee hook)
|
|
45
74
|
```
|
|
46
75
|
|
|
47
76
|
### Loan Flow
|
|
48
77
|
```
|
|
49
78
|
Borrower → REVLoans.borrowFrom()
|
|
50
79
|
→ Burn borrower's revnet tokens as collateral
|
|
51
|
-
→ Calculate
|
|
80
|
+
→ Calculate borrow amount from bonding curve value of collateral
|
|
52
81
|
→ Pull funds from treasury via USE_ALLOWANCE
|
|
53
82
|
→ Mint loan ERC-721 NFT to borrower
|
|
54
83
|
|
|
@@ -61,6 +90,31 @@ Liquidate → REVLoans.liquidateExpiredLoansFrom()
|
|
|
61
90
|
→ Collateral permanently destroyed (was burned at borrow time)
|
|
62
91
|
```
|
|
63
92
|
|
|
93
|
+
## Stage-to-Ruleset Mapping
|
|
94
|
+
|
|
95
|
+
Each `REVStageConfig` is converted to a `JBRulesetConfig` by `_makeRulesetConfiguration`. The mapping is direct — revnet stages are a constrained interface over Juicebox rulesets:
|
|
96
|
+
|
|
97
|
+
| REVStageConfig field | JBRulesetConfig field | Notes |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| `startsAtOrAfter` | `mustStartAtOrAfter` | Passed through directly |
|
|
100
|
+
| `issuanceCutFrequency` | `duration` | How often the issuance rate decays |
|
|
101
|
+
| `initialIssuance` | `weight` | Tokens per unit of base currency |
|
|
102
|
+
| `issuanceCutPercent` | `weightCutPercent` | Percent decrease each cycle (out of 1,000,000,000) |
|
|
103
|
+
| `splitPercent` | `metadata.reservedPercent` | Percent of new tokens split to recipients (out of 10,000) |
|
|
104
|
+
| `cashOutTaxRate` | `metadata.cashOutTaxRate` | Bonding curve tax on cash outs (out of 10,000) |
|
|
105
|
+
| `splits` | `splitGroups[0].splits` | Reserved token split recipients (group ID: RESERVED_TOKENS) |
|
|
106
|
+
| `extraMetadata` | `metadata.metadata` | 14-bit field for hook-specific flags |
|
|
107
|
+
|
|
108
|
+
Fields set automatically by the deployer (not configurable per stage):
|
|
109
|
+
- `metadata.baseCurrency` — from `REVConfig.baseCurrency`
|
|
110
|
+
- `metadata.useTotalSurplusForCashOuts` — always `true`
|
|
111
|
+
- `metadata.allowOwnerMinting` — always `true` (required for auto-issuance)
|
|
112
|
+
- `metadata.useDataHookForPay` — always `true`
|
|
113
|
+
- `metadata.useDataHookForCashOut` — always `true`
|
|
114
|
+
- `metadata.dataHook` — always `address(REVDeployer)`
|
|
115
|
+
- `approvalHook` — always `address(0)` (no approval hook; stages are immutable)
|
|
116
|
+
- `fundAccessLimitGroups` — set to `uint224.max` surplus allowance per terminal token for loan withdrawals
|
|
117
|
+
|
|
64
118
|
## Extension Points
|
|
65
119
|
|
|
66
120
|
| Point | Interface | Purpose |
|
|
@@ -79,9 +133,13 @@ Liquidate → REVLoans.liquidateExpiredLoansFrom()
|
|
|
79
133
|
- `@bananapus/permission-ids-v6` — Permission constants
|
|
80
134
|
- `@croptop/core-v6` — Croptop integration
|
|
81
135
|
- `@openzeppelin/contracts` — Standard utilities
|
|
136
|
+
- `@prb/math` — Fixed-point math (`mulDiv`, `sqrt`)
|
|
137
|
+
- `@uniswap/permit2` — Permit2 token allowances (REVLoans)
|
|
82
138
|
|
|
83
139
|
## Key Design Decisions
|
|
84
140
|
- Stages are immutable after deployment — no owner can change ruleset parameters
|
|
85
|
-
- Matching hash ensures cross-chain deployments have identical economic parameters
|
|
141
|
+
- Matching hash ensures cross-chain deployments have identical economic parameters. It covers all economic fields (issuance, decay, tax rates, auto-issuances) but intentionally excludes split recipient addresses, which may differ by chain. The hash is used as a CREATE2 salt component for sucker deployment, so mismatched configs produce different sucker addresses that cannot peer with each other.
|
|
86
142
|
- REVDeployer is the data hook for all revnets it deploys — centralizes behavioral control
|
|
87
143
|
- Loans use bonding curve value, not market price — independent of external DEX pricing
|
|
144
|
+
- Auto-issuance is deferred, not instant — token amounts are recorded at deploy time but minted via a separate `autoIssueFor` call after the stage starts. This separates deployment from issuance, allows anyone to trigger the mint permissionlessly, and ensures tokens are not minted before their stage is active.
|
|
145
|
+
- No approval hook — revnet rulesets set `approvalHook` to `address(0)` because stages are configured immutably at deployment. There is no governance or owner who could queue a change that would need approval.
|
package/AUDIT_INSTRUCTIONS.md
CHANGED
|
@@ -10,10 +10,10 @@ Read [RISKS.md](./RISKS.md) for the trust model and known risks. Read [ARCHITECT
|
|
|
10
10
|
|
|
11
11
|
| Contract | Lines | Role |
|
|
12
12
|
|----------|-------|------|
|
|
13
|
-
| `src/REVDeployer.sol` | ~1,
|
|
13
|
+
| `src/REVDeployer.sol` | ~1,373 | Deploys revnets. Acts as data hook and cash-out hook for all revnets. Manages stages, splits, auto-issuance, buyback hook delegation, 721 hook deployment, suckers, and split operator permissions. |
|
|
14
14
|
| `src/REVLoans.sol` | ~1,359 | Token-collateralized lending. Burns collateral on borrow, re-mints on repay. ERC-721 loan NFTs. Three-layer fee model. Permit2 integration. |
|
|
15
15
|
| `src/interfaces/` | ~525 | Interface definitions for both contracts |
|
|
16
|
-
| `src/structs/` | ~
|
|
16
|
+
| `src/structs/` | ~212 | All struct definitions |
|
|
17
17
|
|
|
18
18
|
**Dependencies (assumed correct, but verify integration points):**
|
|
19
19
|
- `@bananapus/core-v6` -- JBController, JBMultiTerminal, JBTerminalStore, JBTokens, JBPrices, JBRulesets
|
|
@@ -200,7 +200,7 @@ feeAmount = JBCashOuts.cashOutFrom(surplus - postFeeReclaimedAmount, feeCashOutC
|
|
|
200
200
|
Verify:
|
|
201
201
|
- `postFeeReclaimedAmount + feeAmount <= directReclaim` (total <= what you'd get without fee splitting)
|
|
202
202
|
- Micro cash-outs (< 40 wei at 2.5%) round `feeCashOutCount` to zero, bypassing the fee. This is documented as economically insignificant. Verify.
|
|
203
|
-
- The `cashOutCount` returned to the terminal is `nonFeeCashOutCount`, but the terminal still burns the full `cashOutCount` tokens. **
|
|
203
|
+
- 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).
|
|
204
204
|
|
|
205
205
|
### 5. Permission model
|
|
206
206
|
|
|
@@ -244,7 +244,7 @@ Three layers of fees on borrow:
|
|
|
244
244
|
|
|
245
245
|
1. **Protocol fee (2.5%)** -- charged by `useAllowanceOf` (JBMultiTerminal takes it automatically)
|
|
246
246
|
2. **REV fee (1%)** -- `JBFees.feeAmountFrom(borrowAmount, REV_PREPAID_FEE_PERCENT=10)` paid to REV revnet. Try-catch; zeroed on failure.
|
|
247
|
-
3. **Source prepaid fee (2.5%-50%)** -- `JBFees.feeAmountFrom(borrowAmount, prepaidFeePercent)` paid back to the revnet via `terminal.pay`.
|
|
247
|
+
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.
|
|
248
248
|
|
|
249
249
|
On repay, the source fee is time-proportional:
|
|
250
250
|
|
|
@@ -260,8 +260,18 @@ sourceFeeAmount = mulDiv(fullSourceFeeAmount, amount, loan.amount);
|
|
|
260
260
|
|
|
261
261
|
Verify:
|
|
262
262
|
- 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?
|
|
263
|
-
- The linear accrual formula: at `timeSinceLoanCreated = LOAN_LIQUIDATION_DURATION`, the fee percent approaches MAX_FEE (100%).
|
|
264
|
-
-
|
|
263
|
+
- 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.
|
|
264
|
+
- 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 `<=`.
|
|
265
|
+
|
|
266
|
+
## Invariants
|
|
267
|
+
|
|
268
|
+
Fuzzable properties that should hold for all valid inputs:
|
|
269
|
+
|
|
270
|
+
1. **Collateral accounting**: `totalCollateralOf[revnetId]` equals the sum of `_loanOf[loanId].collateral` for all active loans belonging to that revnet.
|
|
271
|
+
2. **Borrowed amount accounting**: `totalBorrowedFrom[revnetId][terminal][token]` equals the sum of `_loanOf[loanId].amount` for all active loans with that source.
|
|
272
|
+
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).
|
|
273
|
+
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.
|
|
274
|
+
5. **Stage monotonicity**: Stage transitions are monotonically increasing in time -- a later stage's `startsAtOrAfter` is always strictly greater than the previous stage's.
|
|
265
275
|
|
|
266
276
|
## How to Run Tests
|
|
267
277
|
|
|
@@ -286,10 +296,83 @@ forge test --gas-report
|
|
|
286
296
|
| Pattern | Where | Why |
|
|
287
297
|
|---------|-------|-----|
|
|
288
298
|
| `mulDiv` rounding direction | `beforePayRecordedWith` weight scaling, `_determineSourceFeeAmount`, `_borrowableAmountFrom` | Rounding in borrower's favor compounds over many loans |
|
|
289
|
-
| Source fee `pay`
|
|
299
|
+
| 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 |
|
|
290
300
|
| `delete _loanOf[loanId]` after external calls | `_repayLoan`, `_reallocateCollateralFromLoan` | Verify delete happens after all references to the loan are resolved |
|
|
291
301
|
| Loan storage read after `_adjust` mutates it | `_repayLoan` partial repay path | `_adjust` modifies `loan` via storage pointer; subsequent reads see mutated values |
|
|
292
302
|
| Unbounded loop in `_totalBorrowedFrom` | Called during every borrow operation | Gas griefing if many distinct loan sources accumulate |
|
|
293
303
|
| `uint112` truncation | `_adjust` explicit check | Verify all paths that set `loan.amount` or `loan.collateral` go through `_adjust` |
|
|
294
304
|
| Permit2 try-catch swallowing | `_acceptFundsFor` | If permit fails, fall through to regular transfer. Is the state consistent? |
|
|
295
305
|
| ERC-721 `_mint` callback | `borrowFrom`, `_repayLoan`, `_reallocateCollateralFromLoan` | `onERC721Received` can re-enter. Verify all state is settled before mint. |
|
|
306
|
+
|
|
307
|
+
## Previous Audit Findings
|
|
308
|
+
|
|
309
|
+
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.
|
|
310
|
+
|
|
311
|
+
## Coverage Gaps
|
|
312
|
+
|
|
313
|
+
- **Stage transition during active loans**: No test for borrowing under one stage's tax rate and the stage transitioning before repayment.
|
|
314
|
+
- **Multi-source loan aggregation**: `_totalBorrowedFrom` iterates all sources, but no test with >3 active sources testing gas and precision.
|
|
315
|
+
- **Concurrent borrow + cash out**: No test for a borrow and cash out on the same revnet in the same block.
|
|
316
|
+
- **Auto-issuance with sucker deployment**: No test for claiming auto-issuance on a cross-chain revnet during the cashOutDelay window.
|
|
317
|
+
- **Partial repay + reallocation**: No test for `reallocateCollateralFromLoan` with a partial repay in the same transaction.
|
|
318
|
+
- **Loan fee approaching 100%**: No test for repayment at `LOAN_LIQUIDATION_DURATION - 1 second` where the fee should be just under 100%.
|
|
319
|
+
|
|
320
|
+
## Error Reference
|
|
321
|
+
|
|
322
|
+
| Error | Contract | Trigger |
|
|
323
|
+
|-------|----------|---------|
|
|
324
|
+
| `REVDeployer_AutoIssuanceBeneficiaryZeroAddress` | REVDeployer | Auto-issuance configured with `beneficiary == address(0)` |
|
|
325
|
+
| `REVDeployer_CashOutDelayNotFinished` | REVDeployer | Cash-out attempted before `cashOutDelayOf[revnetId]` timestamp has passed |
|
|
326
|
+
| `REVDeployer_CashOutsCantBeTurnedOffCompletely` | REVDeployer | Stage configured with `cashOutTaxRate >= MAX_CASH_OUT_TAX_RATE` (10,000) |
|
|
327
|
+
| `REVDeployer_MustHaveSplits` | REVDeployer | Stage has `splitPercent > 0` but empty `splits` array |
|
|
328
|
+
| `REVDeployer_NothingToAutoIssue` | REVDeployer | `autoIssueFor` called but `amountToAutoIssue` is zero for the given beneficiary and stage |
|
|
329
|
+
| `REVDeployer_NothingToBurn` | REVDeployer | `burnFrom` called but REVDeployer holds zero tokens for the revnet |
|
|
330
|
+
| `REVDeployer_RulesetDoesNotAllowDeployingSuckers` | REVDeployer | `deploySuckersFor` called but current ruleset metadata disallows sucker deployment |
|
|
331
|
+
| `REVDeployer_StageNotStarted` | REVDeployer | `autoIssueFor` called for a stage whose `ruleset.start > block.timestamp` |
|
|
332
|
+
| `REVDeployer_StagesRequired` | REVDeployer | `deployFor` / `launchChainsFor` called with empty `stageConfigurations` array |
|
|
333
|
+
| `REVDeployer_StageTimesMustIncrease` | REVDeployer | Stage `startsAtOrAfter` timestamps are not strictly increasing |
|
|
334
|
+
| `REVDeployer_Unauthorized` | REVDeployer | Caller is not the split operator (for operator-gated functions) or not the project owner (for `launchChainsFor`) |
|
|
335
|
+
| `REVLoans_CollateralExceedsLoan` | REVLoans | `reallocateCollateralFromLoan` called with `collateralCountToReturn > loan.collateral` |
|
|
336
|
+
| `REVLoans_InvalidPrepaidFeePercent` | REVLoans | `prepaidFeePercent` outside `[MIN_PREPAID_FEE_PERCENT, MAX_PREPAID_FEE_PERCENT]` range (25-500) |
|
|
337
|
+
| `REVLoans_InvalidTerminal` | REVLoans | Loan source references a terminal not registered in `JBDirectory` for the revnet |
|
|
338
|
+
| `REVLoans_LoanExpired` | REVLoans | Repay/reallocation attempted after `LOAN_LIQUIDATION_DURATION` has elapsed since loan creation |
|
|
339
|
+
| `REVLoans_LoanIdOverflow` | REVLoans | Loan counter for a revnet exceeds 1 trillion (namespace collision with next revnet ID) |
|
|
340
|
+
| `REVLoans_NewBorrowAmountGreaterThanLoanAmount` | REVLoans | Partial repay would increase the loan's borrow amount above the original |
|
|
341
|
+
| `REVLoans_NoMsgValueAllowed` | REVLoans | `msg.value > 0` sent when the loan source token is not the native token |
|
|
342
|
+
| `REVLoans_NotEnoughCollateral` | REVLoans | `_reallocateCollateralFromLoan` attempts to remove more collateral than the loan holds |
|
|
343
|
+
| `REVLoans_NothingToRepay` | REVLoans | `repayLoan` called with both `repayBorrowAmount == 0` and `collateralCountToReturn == 0` |
|
|
344
|
+
| `REVLoans_OverMaxRepayBorrowAmount` | REVLoans | Actual repay cost (principal + accrued fee) exceeds caller's `maxRepayBorrowAmount` |
|
|
345
|
+
| `REVLoans_OverflowAlert` | REVLoans | Loan amount or collateral exceeds `uint112` max, or Permit2 amount exceeds `uint160` max |
|
|
346
|
+
| `REVLoans_PermitAllowanceNotEnough` | REVLoans | Permit2 allowance is less than the required transfer amount |
|
|
347
|
+
| `REVLoans_ReallocatingMoreCollateralThanBorrowedAmountAllows` | REVLoans | After reallocation, the remaining collateral's bonding curve value is less than the remaining borrow amount |
|
|
348
|
+
| `REVLoans_SourceMismatch` | REVLoans | Repay/reallocation called with a source (token, terminal) that does not match the loan's original source |
|
|
349
|
+
| `REVLoans_Unauthorized` | REVLoans | Caller is not the ERC-721 owner of the loan being managed |
|
|
350
|
+
| `REVLoans_UnderMinBorrowAmount` | REVLoans | Bonding curve returns a borrow amount below the caller's `minBorrowAmount` (slippage protection) |
|
|
351
|
+
| `REVLoans_ZeroBorrowAmount` | REVLoans | Bonding curve returns zero for the given collateral (e.g., zero surplus) |
|
|
352
|
+
| `REVLoans_ZeroCollateralLoanIsInvalid` | REVLoans | `borrowFrom` called with `collateralCount == 0` |
|
|
353
|
+
|
|
354
|
+
## Compiler and Version Info
|
|
355
|
+
|
|
356
|
+
- **Solidity**: 0.8.28
|
|
357
|
+
- **EVM target**: Cancun
|
|
358
|
+
- **Optimizer**: via-IR, 100 runs
|
|
359
|
+
- **Dependencies**: OpenZeppelin 5.x, PRBMath, Permit2, nana-core-v6, nana-721-hook-v6, nana-buyback-hook-v6, nana-suckers-v6
|
|
360
|
+
- **Build**: `forge build` (Foundry)
|
|
361
|
+
|
|
362
|
+
## How to Report Findings
|
|
363
|
+
|
|
364
|
+
For each finding:
|
|
365
|
+
|
|
366
|
+
1. **Title** -- one line, starts with severity (CRITICAL/HIGH/MEDIUM/LOW)
|
|
367
|
+
2. **Affected contract(s)** -- exact file path and line numbers
|
|
368
|
+
3. **Description** -- what is wrong, in plain language
|
|
369
|
+
4. **Trigger sequence** -- step-by-step, minimal steps to reproduce
|
|
370
|
+
5. **Impact** -- what an attacker gains, what a user loses (with numbers if possible)
|
|
371
|
+
6. **Proof** -- code trace showing the exact execution path, or a Foundry test
|
|
372
|
+
7. **Fix** -- minimal code change that resolves the issue
|
|
373
|
+
|
|
374
|
+
**Severity guide:**
|
|
375
|
+
- **CRITICAL**: Direct fund loss, collateral manipulation enabling undercollateralized loans, or permanent DoS.
|
|
376
|
+
- **HIGH**: Conditional fund loss, loan fee bypass, or broken invariant.
|
|
377
|
+
- **MEDIUM**: Value leakage, fee calculation inaccuracy, griefing.
|
|
378
|
+
- **LOW**: Informational, edge-case-only with no material impact.
|
package/CHANGE_LOG.md
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
# revnet-core-v6 Changelog (v5 -> v6)
|
|
2
2
|
|
|
3
|
-
This document describes all changes between `revnet-core` (v5, Solidity 0.8.23) and `revnet-core-v6` (v6, Solidity 0.8.
|
|
3
|
+
This document describes all changes between `revnet-core` (v5, Solidity 0.8.23) and `revnet-core-v6` (v6, Solidity 0.8.28).
|
|
4
|
+
|
|
5
|
+
## Summary
|
|
6
|
+
|
|
7
|
+
- **Buyback hook centralized**: Per-revnet `buybackHookOf` mapping replaced by a single immutable `BUYBACK_HOOK` registry — pools auto-initialized with default parameters during deployment.
|
|
8
|
+
- **Loans simplified**: Per-revnet `loansOf` mapping replaced by a single immutable `LOANS` address; fund access limits derived automatically from terminal configurations.
|
|
9
|
+
- **Every revnet gets a 721 hook**: The 4-arg `deployFor` overload auto-deploys an empty-tier 721 hook. `deployWith721sFor` merged into a 6-arg `deployFor` overload.
|
|
10
|
+
- **Permission flags inverted**: `splitOperatorCan*` (opt-in) → `preventSplitOperator*` (opt-out) — permissions granted by default unless explicitly prevented.
|
|
11
|
+
- **Weight scaling for tier splits**: `beforePayRecordedWith` now reduces minting weight proportionally when 721 tier splits consume part of the payment, preventing double-counting.
|
|
4
12
|
|
|
5
13
|
---
|
|
6
14
|
|
|
@@ -237,7 +245,7 @@ The following structs are identical between v5 and v6 (only `forge-lint` comment
|
|
|
237
245
|
|
|
238
246
|
| Change | Description |
|
|
239
247
|
|--------|-------------|
|
|
240
|
-
| **Solidity version** | Upgraded from `0.8.23` to `0.8.
|
|
248
|
+
| **Solidity version** | Upgraded from `0.8.23` to `0.8.28`. |
|
|
241
249
|
| **Buyback hook architecture** | Per-revnet `buybackHookOf` mapping replaced with a single immutable `BUYBACK_HOOK` (`IJBBuybackHookRegistry`). Pools are auto-initialized for each terminal token during deployment via `_tryInitializeBuybackPoolFor`. |
|
|
242
250
|
| **Loans architecture** | Per-revnet `loansOf` mapping replaced with a single immutable `LOANS` address. The deployer grants `USE_ALLOWANCE` permission to the loans contract for all revnets in the constructor (wildcard `revnetId=0`). |
|
|
243
251
|
| **Constructor permissions** | v6 constructor grants three wildcard permissions: `MAP_SUCKER_TOKEN` to the sucker registry, `USE_ALLOWANCE` to the loans contract, and `SET_BUYBACK_POOL` to the buyback hook. v5 only granted `MAP_SUCKER_TOKEN`. |
|
|
@@ -245,6 +253,9 @@ The following structs are identical between v5 and v6 (only `forge-lint` comment
|
|
|
245
253
|
| **Every revnet gets a 721 hook** | The 4-arg `deployFor` overload auto-deploys a default empty 721 hook with all split operator permissions granted. In v5, the simple `deployFor` did not deploy any 721 hook. |
|
|
246
254
|
| **721 permission semantics inverted** | v5 used opt-in flags (`splitOperatorCanAdjustTiers` etc.) that conditionally pushed permissions. v6 uses opt-out flags (`preventSplitOperatorAdjustingTiers` etc.) that grant permissions by default unless prevented. |
|
|
247
255
|
| **`beforePayRecordedWith` rewrite** | v5 fetched the buyback hook from `buybackHookOf[revnetId]` and the 721 hook separately, passing the 721 hook as a zero-amount `JBPayHookSpecification`. v6 queries the 721 hook first as a data hook to determine its tier split amount, reduces the payment context amount for the buyback hook query, and scales the buyback weight proportionally (`weight * projectAmount / totalAmount`) to prevent minting tokens for the split portion of payments. |
|
|
256
|
+
|
|
257
|
+
> **Why weight scaling matters**: Without proportional scaling, a payment of 1 ETH where 0.3 ETH goes to tier splits would still mint tokens as if the full 1 ETH entered the treasury. The `mulDiv(weight, projectAmount, totalAmount)` formula ensures tokens are only minted for the 0.7 ETH that actually enters the project, preventing dilution of existing token holders.
|
|
258
|
+
|
|
248
259
|
| **`hasMintPermissionFor` updated** | v5 checked `loansOf[revnetId]`, `buybackHookOf[revnetId]`, and suckers. v6 checks the immutable `LOANS`, the immutable `BUYBACK_HOOK`, and delegates to `BUYBACK_HOOK.hasMintPermissionFor` for buyback delegates. |
|
|
249
260
|
| **Loan fund access limits simplified** | v5 derived fund access limits from `configuration.loanSources` and validated them against terminal configurations via `_matchingCurrencyOf`. v6 derives them from all terminal configurations directly (one unlimited surplus allowance per terminal+token pair). The `_matchingCurrencyOf` helper is removed. |
|
|
250
261
|
| **`burnHeldTokensOf` added** | New function to burn any project tokens held by the deployer. Reverts with `REVDeployer_NothingToBurn` if the balance is zero. |
|
|
@@ -259,7 +270,7 @@ The following structs are identical between v5 and v6 (only `forge-lint` comment
|
|
|
259
270
|
|
|
260
271
|
| Change | Description |
|
|
261
272
|
|--------|-------------|
|
|
262
|
-
| **Solidity version** | Upgraded from `0.8.23` to `0.8.
|
|
273
|
+
| **Solidity version** | Upgraded from `0.8.23` to `0.8.28`. |
|
|
263
274
|
| **Deployer dependency removed** | v5 stored `REVNETS` (`IREVDeployer`) and validated that the revnet was owned by the expected deployer via `RevnetsMismatch`. v6 does not reference the deployer at all. Validation now checks the terminal directly via `DIRECTORY.isTerminalOf`. |
|
|
264
275
|
| **Constructor refactored** | v5 accepted `IREVDeployer revnets` and derived `CONTROLLER`, `DIRECTORY`, etc. from it. v6 accepts `IJBController controller` and `IJBProjects projects` directly. |
|
|
265
276
|
| **Terminal validation** | `borrowFrom` now validates that the source terminal is registered in the directory for the revnet, reverting with `REVLoans_InvalidTerminal` if not. v5 validated deployer ownership instead. |
|
|
@@ -319,3 +330,5 @@ Throughout the codebase, function calls were updated to use named argument synta
|
|
|
319
330
|
| `REVLoanSource` | `REVLoanSource` | Identical (lint comment added) |
|
|
320
331
|
| `REVStageConfig` | `REVStageConfig` | Identical (lint comment added) |
|
|
321
332
|
| `REVSuckerDeploymentConfig` | `REVSuckerDeploymentConfig` | Identical (lint comment added) |
|
|
333
|
+
|
|
334
|
+
> **Cross-repo impact**: Uses `nana-721-hook-v6` tier splits system for NFT payment routing. Uses `nana-buyback-hook-v6` registry for automatic pool configuration. `nana-omnichain-deployers-v6` implements a similar dual-hook composition pattern. `nana-fee-project-deployer-v6` removed its buyback/loan configuration in favor of the centralized approach here.
|
package/README.md
CHANGED
|
@@ -52,13 +52,39 @@ Revnets are autonomous Juicebox projects with predetermined economic stages. Eac
|
|
|
52
52
|
- **Prepaid fee model.** Borrowers choose a prepaid fee (2.5%-50%) that buys an interest-free window. After that window, a time-proportional source fee accrues.
|
|
53
53
|
- **Each loan is an ERC-721 NFT.** Loans can be transferred, and expired loans (10 years) can be liquidated by anyone.
|
|
54
54
|
|
|
55
|
+
#### Loan Flow
|
|
56
|
+
|
|
57
|
+
```mermaid
|
|
58
|
+
sequenceDiagram
|
|
59
|
+
participant Borrower
|
|
60
|
+
participant REVLoans
|
|
61
|
+
participant JBController
|
|
62
|
+
participant JBMultiTerminal
|
|
63
|
+
participant Beneficiary
|
|
64
|
+
|
|
65
|
+
Note over Borrower,Beneficiary: Borrow
|
|
66
|
+
Borrower->>REVLoans: borrowFrom(revnetId, source, collateral, ...)
|
|
67
|
+
REVLoans->>JBController: burnTokensOf(borrower, collateral)
|
|
68
|
+
REVLoans->>JBMultiTerminal: useAllowanceOf(revnetId, borrowAmount)
|
|
69
|
+
JBMultiTerminal-->>REVLoans: net funds (minus protocol fee)
|
|
70
|
+
REVLoans-->>Beneficiary: borrowed funds (minus prepaid fee)
|
|
71
|
+
REVLoans-->>Borrower: mint loan ERC-721 NFT
|
|
72
|
+
|
|
73
|
+
Note over Borrower,Beneficiary: Repay
|
|
74
|
+
Borrower->>REVLoans: repayLoan(loanId, collateralToReturn, ...)
|
|
75
|
+
REVLoans->>REVLoans: burn loan ERC-721 NFT
|
|
76
|
+
REVLoans->>JBMultiTerminal: addToBalanceOf(revnetId, repayAmount)
|
|
77
|
+
REVLoans->>JBController: mintTokensOf(beneficiary, collateral)
|
|
78
|
+
Note right of Beneficiary: Collateral tokens re-minted
|
|
79
|
+
```
|
|
80
|
+
|
|
55
81
|
### Deployer Variants
|
|
56
82
|
|
|
57
83
|
Every revnet gets a tiered ERC-721 hook deployed automatically — even if no tiers are configured at launch. This lets the split operator add and sell NFTs later without migration.
|
|
58
84
|
|
|
59
|
-
- **Basic revnet** -- `deployFor` with stage configurations mapped to Juicebox rulesets and an empty 721 hook.
|
|
60
|
-
- **Tiered 721 revnet** -- `deployFor` adds a tiered 721 pay hook with pre-configured tiers that mint NFTs as people pay.
|
|
61
|
-
- **Croptop revnet** -- A tiered 721 revnet with Croptop posting criteria, allowing the public to post content.
|
|
85
|
+
- **Basic revnet** -- `deployFor` with stage configurations mapped to Juicebox rulesets and an empty 721 hook. Choose this when the revnet only needs fungible token issuance and the split operator may optionally add NFT tiers later.
|
|
86
|
+
- **Tiered 721 revnet** -- `deployFor` adds a tiered 721 pay hook with pre-configured tiers that mint NFTs as people pay. Choose this when the revnet should sell specific NFT tiers from day one, such as membership passes or limited editions.
|
|
87
|
+
- **Croptop revnet** -- A tiered 721 revnet with Croptop posting criteria, allowing the public to post content. Choose this when the revnet should function as an open publishing platform where anyone can submit content that gets minted as NFTs according to the configured posting rules.
|
|
62
88
|
|
|
63
89
|
## Architecture
|
|
64
90
|
|
|
@@ -97,15 +123,15 @@ If `forge install` has issues, try `git submodule update --init --recursive`.
|
|
|
97
123
|
| Command | Description |
|
|
98
124
|
|---------|-------------|
|
|
99
125
|
| `forge build` | Compile contracts |
|
|
100
|
-
| `forge test` | Run tests (
|
|
126
|
+
| `forge test` | Run tests (55 test files covering deployment, lifecycle, loans, attacks, invariants, fork tests, regressions) |
|
|
101
127
|
| `forge test -vvvv` | Run tests with full traces |
|
|
102
128
|
|
|
103
129
|
## Repository Layout
|
|
104
130
|
|
|
105
131
|
```
|
|
106
132
|
src/
|
|
107
|
-
REVDeployer.sol # Revnet deployer + data hook (~1,
|
|
108
|
-
REVLoans.sol # Token-collateralized lending (~1,
|
|
133
|
+
REVDeployer.sol # Revnet deployer + data hook (~1,373 lines)
|
|
134
|
+
REVLoans.sol # Token-collateralized lending (~1,391 lines)
|
|
109
135
|
interfaces/
|
|
110
136
|
IREVDeployer.sol # Deployer interface + events
|
|
111
137
|
IREVLoans.sol # Loans interface + events
|
|
@@ -134,7 +160,6 @@ test/
|
|
|
134
160
|
REVLoansAttacks.t.sol # Flash loan, reentrancy scenarios
|
|
135
161
|
REVLoans.invariants.t.sol # Loan fuzzing invariants
|
|
136
162
|
REVLoansRegressions.t.sol # Loan regressions
|
|
137
|
-
TestPR09-32_*.t.sol # Per-PR regression tests
|
|
138
163
|
helpers/
|
|
139
164
|
MaliciousContracts.sol # Attack contract mocks
|
|
140
165
|
mock/
|
package/RISKS.md
CHANGED
|
@@ -34,7 +34,6 @@ Read [ARCHITECTURE.md](./ARCHITECTURE.md) and [SKILLS.md](./SKILLS.md) for proto
|
|
|
34
34
|
- **100% LTV with no safety margin.** Borrowable amount equals exact bonding curve cash-out value. When `cashOutTaxRate == 0`, this is true 100% LTV. Any decrease in surplus (other cash-outs, payouts, stage transitions) makes existing loans effectively under-collateralized. The protocol has no liquidation trigger for under-collateralized loans -- only the 10-year expiry.
|
|
35
35
|
- **Loans beat cash-outs above ~39% tax.** Above approximately 39.16% `cashOutTaxRate`, borrowing is more capital-efficient than cashing out because loans preserve upside while providing immediate liquidity. Based on CryptoEconLab research. This is by design but creates an incentive to borrow rather than cash out at higher tax rates, concentrating risk in the loan system.
|
|
36
36
|
- **10-year free put option.** Over the loan's lifetime, if the collateral's real value drops below the borrowed amount, the borrower has no incentive to repay. The borrower keeps the borrowed funds and forfeits worthless collateral. This is equivalent to a free put option with a 10-year expiry. The protocol absorbs this loss through permanent supply reduction (burned collateral never re-minted).
|
|
37
|
-
- **Surplus manipulation is economically irrational.** `_borrowableAmountFrom` reads live surplus. An attacker could inflate surplus via `addToBalanceOf`, but donations are permanent (no recovery), and the extra borrowable amount is always less than the donation. `pay` increases both surplus AND supply, neutralizing the effect. With non-zero `cashOutTaxRate`, the concave bonding curve makes this even worse for attackers.
|
|
38
37
|
|
|
39
38
|
### Stage transition edge cases
|
|
40
39
|
|
|
@@ -68,7 +67,7 @@ Read [ARCHITECTURE.md](./ARCHITECTURE.md) and [SKILLS.md](./SKILLS.md) for proto
|
|
|
68
67
|
### Liquidation concerns
|
|
69
68
|
|
|
70
69
|
- **No cascading liquidation mechanism.** There is no health factor, no margin call, and no keeper-triggered liquidation for under-collateralized loans. The only liquidation path is `liquidateExpiredLoansFrom` after 10 years. Under-collateralized loans persist indefinitely within that window.
|
|
71
|
-
- **Liquidation iterates by loan number.** `liquidateExpiredLoansFrom` takes `startingLoanId` and `count`, iterating sequentially. Repaid and already-liquidated loans are skipped (`createdAt == 0`), but the caller pays gas for every skip. If a revnet has thousands of loans with sparse gaps (many repaid), liquidation becomes expensive. The `count` parameter bounds gas per call, but a malicious actor could create many small loans to increase cleanup costs.
|
|
70
|
+
- **Liquidation iterates by loan number.** `liquidateExpiredLoansFrom` takes `startingLoanId` and `count`, iterating sequentially. Repaid and already-liquidated loans are skipped (`createdAt == 0`), but the caller pays gas for every skip. If a revnet has thousands of loans with sparse gaps (many repaid), liquidation becomes expensive. The `count` parameter bounds gas per call, but a malicious actor could create many small loans to increase cleanup costs.
|
|
72
71
|
- **Liquidation permanently destroys collateral.** Collateral was burned at borrow time. Upon liquidation, `totalCollateralOf` is decremented but no tokens are minted or returned. The collateral is permanently removed from the token supply. This deflates the total supply, increasing per-token value for remaining holders -- a mild positive externality from defaults.
|
|
73
72
|
|
|
74
73
|
### Loan source rotation after deployment
|
|
@@ -86,11 +85,6 @@ Read [ARCHITECTURE.md](./ARCHITECTURE.md) and [SKILLS.md](./SKILLS.md) for proto
|
|
|
86
85
|
|
|
87
86
|
- **Borrowers must grant BURN_TOKENS permission before calling `borrowFrom`.** The loans contract burns the caller's tokens as collateral via `JBController.burnTokensOf`, which requires the caller to have granted `BURN_TOKENS` permission to the loans contract for the revnet's project ID. Without this, the transaction reverts deep in `JBController` with `JBPermissioned_Unauthorized`. The prerequisite is documented in `borrowFrom`'s NatSpec.
|
|
88
87
|
|
|
89
|
-
### Borrow-repay arbitrage
|
|
90
|
-
|
|
91
|
-
- **Immediate repayment within prepaid window incurs zero source fee.** A borrower who pays the prepaid fee upfront can repay at any time within the prepaid duration with no additional cost. The prepaid fee is the minimum 2.5% + REV fee 1% = 3.5%. If the bonding curve value of the collateral increases (e.g., from new payments into the revnet) during the prepaid window, the borrower can repay, recover their collateral, and cash out at the higher value.
|
|
92
|
-
- **This is not profitable as a standalone strategy** because the 3.5% minimum fee exceeds the expected value gained from short-term surplus fluctuations. But for borrowers who need liquidity anyway, it provides free optionality.
|
|
93
|
-
|
|
94
88
|
---
|
|
95
89
|
|
|
96
90
|
## 4. Data Hook Proxy Risks
|
|
@@ -101,7 +95,7 @@ REVDeployer sits between the terminal and the actual hooks (buyback hook, 721 ho
|
|
|
101
95
|
|
|
102
96
|
- **721 hook revert in `beforePayRecordedWith`.** The call to `IJBRulesetDataHook(tiered721Hook).beforePayRecordedWith(context)` is NOT wrapped in try-catch. If the 721 hook reverts (e.g., due to a storage corruption or out-of-gas), the entire payment reverts. This is a single point of failure for all payments to revnets with 721 hooks.
|
|
103
97
|
- **Buyback hook is more resilient.** The `BUYBACK_HOOK.beforePayRecordedWith(buybackHookContext)` call is also not try-caught, but the buyback hook is a shared singleton controlled by the protocol. If it reverts, all revnets from that deployer are affected.
|
|
104
|
-
- **Cash-out fee terminal revert.** In `afterCashOutRecordedWith`, the fee payment to the fee terminal IS wrapped in try-catch with a fallback to `addToBalanceOf`. If the fallback also
|
|
98
|
+
- **Cash-out fee terminal revert.** In `afterCashOutRecordedWith`, the fee payment to the fee terminal IS wrapped in try-catch with a fallback to `addToBalanceOf`. If the fallback also reverts, the entire cashout transaction reverts — no funds are stuck, but the cashout is blocked until the terminal is available.
|
|
105
99
|
|
|
106
100
|
### Sucker bypass path (0% cashout tax)
|
|
107
101
|
|
|
@@ -154,12 +148,6 @@ REVDeployer sits between the terminal and the actual hooks (buyback hook, 721 ho
|
|
|
154
148
|
- **`_totalBorrowedFrom` iterates ALL sources on every borrow and repay.** Gas cost: ~20k per source (external `accountingContextForTokenOf` call + storage read + potential price feed call). With 10 sources, this adds ~200k gas per loan operation. With 50+ sources (unlikely but possible), operations become prohibitively expensive.
|
|
155
149
|
- **Mitigation.** `borrowFrom` checks `DIRECTORY.isTerminalOf` before accepting a new source. The number of registered terminals per project is practically bounded. But nothing prevents a terminal from being registered, used for one loan, de-registered, and then a new terminal registered -- leaving stale entries in the source array.
|
|
156
150
|
|
|
157
|
-
### Fee terminal unavailability
|
|
158
|
-
|
|
159
|
-
- **Source fee payment in `_adjust` IS try-caught.** If `loan.source.terminal.pay` reverts when paying the source fee, the fee amount is returned to the borrower instead. This prevents a reverting terminal from blocking all loan operations.
|
|
160
|
-
- **REV fee payment in `_addTo` IS try-caught.** If the REV fee terminal is unavailable, the fee is zeroed and the borrower receives it. This is graceful degradation.
|
|
161
|
-
- **Cash-out fee in `afterCashOutRecordedWith` IS try-caught.** Falls back to `addToBalanceOf`. If fallback also fails, the transaction still doesn't revert (the deployer absorbs the funds, which can only be recovered via `burnHeldTokensOf` for project tokens, not arbitrary tokens).
|
|
162
|
-
|
|
163
151
|
---
|
|
164
152
|
|
|
165
153
|
## 7. Invariants to Verify
|
|
@@ -192,3 +180,27 @@ These MUST hold. Breaking any of them is a finding.
|
|
|
192
180
|
- **Sucker privilege.** Only addresses returning `true` from `SUCKER_REGISTRY.isSuckerOf(projectId, addr)` get 0% cashout tax. No other code path grants this exemption.
|
|
193
181
|
- **Loan ownership.** Only `_ownerOf(loanId)` can call `repayLoan` and `reallocateCollateralFromLoan`. The loan NFT is burned before any state changes in repayment, preventing double-use.
|
|
194
182
|
- **Mint permission.** Only `LOANS`, `BUYBACK_HOOK`, buyback hook delegates (via `BUYBACK_HOOK.hasMintPermissionFor`), and suckers (via `_isSuckerOf`) can mint tokens. No other address passes the `hasMintPermissionFor` check.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## 8. Accepted Behaviors
|
|
187
|
+
|
|
188
|
+
### 8.1 Suckers receive 0% cashout tax (by design)
|
|
189
|
+
|
|
190
|
+
`beforeCashOutRecordedWith` returns `cashOutTaxRate = 0` for any address where `SUCKER_REGISTRY.isSuckerOf(projectId, addr)` returns true. This grants suckers the full pro-rata reclaim with no tax retention. This is intentional: suckers burn tokens on the source chain and mint equivalent tokens on the destination chain. The zero-tax path ensures bridged tokens preserve their full economic value across chains. The security boundary is the sucker registry — only addresses registered by authorized deployers (gated by `DEPLOY_SUCKERS` permission and per-stage `extraMetadata` bit 2) receive this privilege.
|
|
191
|
+
|
|
192
|
+
### 8.2 No liquidation trigger for under-collateralized loans (by design)
|
|
193
|
+
|
|
194
|
+
`REVLoans` has no health factor, no margin call, and no keeper-triggered liquidation. The only liquidation path is `liquidateExpiredLoansFrom` after 10 years. This is a conscious design choice: the protocol treats under-collateralized loans as free put options where the borrower forfeits worthless collateral and keeps the borrowed funds. The protocol absorbs this "loss" through permanent supply reduction (burned collateral), which is deflationary for remaining holders. A liquidation mechanism would add complexity, require oracles, and introduce MEV extraction opportunities at liquidation boundaries — all of which conflict with the revnet's minimal-trust design philosophy.
|
|
195
|
+
|
|
196
|
+
### 8.3 Auto-issuance dilution is permissionless but predictable
|
|
197
|
+
|
|
198
|
+
`autoIssueFor` can be called by anyone, diluting existing holders by minting pre-configured token amounts to beneficiaries. This is accepted because: (1) auto-issuance amounts are set immutably at deployment, so dilution is fully predictable, (2) the dilution only occurs once per `(revnetId, stageId, beneficiary)` tuple (single-claim guarantee), and (3) delaying the call only delays the inevitable — the configured amounts will eventually be minted. A griefing vector exists where someone calls `autoIssueFor` immediately before another user's cash-out, but the dilution magnitude is deterministic and can be priced in.
|
|
199
|
+
|
|
200
|
+
### 8.4 Surplus manipulation via donations is economically irrational (by design)
|
|
201
|
+
|
|
202
|
+
`_borrowableAmountFrom` reads live surplus. An attacker could inflate surplus via `addToBalanceOf`, but donations are permanent (no recovery), and the extra borrowable amount is always less than the donation. `pay` increases both surplus AND supply, neutralizing the effect. With non-zero `cashOutTaxRate`, the concave bonding curve makes this even worse for attackers. The attack is self-defeating by construction.
|
|
203
|
+
|
|
204
|
+
### 8.5 Borrow-repay arbitrage is unprofitable (by design)
|
|
205
|
+
|
|
206
|
+
A borrower who pays the prepaid fee upfront (minimum 2.5% + REV fee 1% = 3.5%) can repay at any time within the prepaid duration with no additional cost. If the bonding curve value of the collateral increases during the prepaid window, the borrower can repay, recover their collateral, and cash out at the higher value. This is not profitable as a standalone strategy because the 3.5% minimum fee exceeds the expected value gained from short-term surplus fluctuations. For borrowers who need liquidity anyway, it provides free optionality — which is the intended use case.
|