@rev-net/core-v6 0.0.17 → 0.0.19

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.
Files changed (66) hide show
  1. package/ADMINISTRATION.md +14 -4
  2. package/ARCHITECTURE.md +14 -10
  3. package/AUDIT_INSTRUCTIONS.md +40 -17
  4. package/CHANGE_LOG.md +87 -0
  5. package/README.md +10 -5
  6. package/RISKS.md +15 -10
  7. package/SKILLS.md +31 -15
  8. package/USER_JOURNEYS.md +16 -12
  9. package/foundry.toml +1 -1
  10. package/package.json +8 -8
  11. package/script/Deploy.s.sol +60 -19
  12. package/src/REVDeployer.sol +21 -303
  13. package/src/REVLoans.sol +31 -0
  14. package/src/REVOwner.sol +430 -0
  15. package/src/interfaces/IREVDeployer.sol +4 -10
  16. package/src/interfaces/IREVOwner.sol +10 -0
  17. package/src/structs/REVBaseline721HookConfig.sol +0 -2
  18. package/test/REV.integrations.t.sol +14 -1
  19. package/test/REVAutoIssuanceFuzz.t.sol +14 -1
  20. package/test/REVDeployerRegressions.t.sol +17 -2
  21. package/test/REVInvincibility.t.sol +31 -3
  22. package/test/REVLifecycle.t.sol +16 -1
  23. package/test/REVLoans.invariants.t.sol +16 -1
  24. package/test/REVLoansAttacks.t.sol +16 -1
  25. package/test/REVLoansFeeRecovery.t.sol +16 -1
  26. package/test/REVLoansFindings.t.sol +16 -1
  27. package/test/REVLoansRegressions.t.sol +16 -1
  28. package/test/REVLoansSourceFeeRecovery.t.sol +16 -1
  29. package/test/REVLoansSourced.t.sol +16 -1
  30. package/test/REVLoansUnSourced.t.sol +16 -1
  31. package/test/TestBurnHeldTokens.t.sol +16 -1
  32. package/test/TestCEIPattern.t.sol +16 -1
  33. package/test/TestCashOutCallerValidation.t.sol +19 -4
  34. package/test/TestConversionDocumentation.t.sol +16 -1
  35. package/test/TestCrossCurrencyReclaim.t.sol +16 -1
  36. package/test/TestCrossSourceReallocation.t.sol +16 -1
  37. package/test/TestERC2771MetaTx.t.sol +16 -1
  38. package/test/TestEmptyBuybackSpecs.t.sol +18 -3
  39. package/test/TestFlashLoanSurplus.t.sol +16 -1
  40. package/test/TestHookArrayOOB.t.sol +17 -2
  41. package/test/TestLiquidationBehavior.t.sol +16 -1
  42. package/test/TestLoanSourceRotation.t.sol +16 -1
  43. package/test/TestLoansCashOutDelay.t.sol +482 -0
  44. package/test/TestLongTailEconomics.t.sol +16 -1
  45. package/test/TestLowFindings.t.sol +16 -1
  46. package/test/TestMixedFixes.t.sol +16 -1
  47. package/test/TestPermit2Signatures.t.sol +16 -1
  48. package/test/TestReallocationSandwich.t.sol +16 -1
  49. package/test/TestRevnetRegressions.t.sol +16 -1
  50. package/test/TestSplitWeightAdjustment.t.sol +43 -19
  51. package/test/TestSplitWeightE2E.t.sol +26 -3
  52. package/test/TestSplitWeightFork.t.sol +16 -2
  53. package/test/TestStageTransitionBorrowable.t.sol +16 -1
  54. package/test/TestSwapTerminalPermission.t.sol +16 -1
  55. package/test/TestUint112Overflow.t.sol +16 -1
  56. package/test/TestZeroRepayment.t.sol +16 -1
  57. package/test/audit/LoanIdOverflowGuard.t.sol +16 -1
  58. package/test/fork/ForkTestBase.sol +16 -2
  59. package/test/fork/TestPermit2PaymentFork.t.sol +4 -3
  60. package/test/helpers/REVEmpty721Config.sol +0 -1
  61. package/test/regression/TestBurnPermissionRequired.t.sol +16 -1
  62. package/test/regression/TestCashOutBuybackFeeLeak.t.sol +15 -1
  63. package/test/regression/TestCrossRevnetLiquidation.t.sol +16 -1
  64. package/test/regression/TestCumulativeLoanCounter.t.sol +16 -1
  65. package/test/regression/TestLiquidateGapHandling.t.sol +16 -1
  66. package/test/regression/TestZeroPriceFeed.t.sol +16 -1
package/ADMINISTRATION.md CHANGED
@@ -40,7 +40,7 @@ Admin privileges and their scope in revnet-core-v6. Revnets are designed to be a
40
40
  | `setSplitOperatorOf()` | Split Operator | Checked via `_checkIfIsSplitOperatorOf()` | Replaces the current split operator with a new address. Revokes all operator permissions from the caller and grants them to the new address. |
41
41
  | `autoIssueFor()` | Anyone | None | Mints pre-configured auto-issuance tokens for a beneficiary once the relevant stage has started. Amounts are set at deployment and can only be claimed once. |
42
42
  | `burnHeldTokensOf()` | Anyone | None | Burns any of a revnet's tokens held by the `REVDeployer` contract (e.g., from reserved token splits that did not sum to 100%). |
43
- | `afterCashOutRecordedWith()` | Anyone (called by terminal) | None | Processes cash-out fees. No caller validation needed because a non-terminal caller would only be donating their own funds. |
43
+ | `afterCashOutRecordedWith()` | Anyone (called by terminal) | None | **Note: This function lives on REVOwner, not REVDeployer.** Processes cash-out fees. No caller validation needed because a non-terminal caller would only be donating their own funds. |
44
44
 
45
45
  ### Split Operator Permissions (granted via JBPermissions)
46
46
 
@@ -95,8 +95,8 @@ Revnets are designed to operate without a traditional project owner. The followi
95
95
  - **No ruleset queuing.** The `REVDeployer` does not expose any function to queue new rulesets after deployment. The stage progression is fully determined at deploy time. Nobody -- not the split operator, not the deployer, not anyone -- can change the issuance schedule, cash-out tax rates, or stage timing after deployment.
96
96
  - **No approval hooks.** All rulesets are deployed with `approvalHook = address(0)`. There is no mechanism to block or delay stage transitions.
97
97
  - **Cash outs cannot be fully disabled.** The deployer enforces `cashOutTaxRate < MAX_CASH_OUT_TAX_RATE` for every stage, guaranteeing that token holders always retain some ability to cash out.
98
- - **Data hook is the deployer itself.** The `REVDeployer` is set as the data hook (`metadata.dataHook = address(this)`) for all rulesets, ensuring consistent fee and sucker logic without external admin control.
99
- - **Mint permission is restricted.** Only the loans contract, the buyback hook (and its delegates), and registered suckers can mint tokens. The split operator cannot mint fungible revnet tokens.
98
+ - **Data hook is REVOwner.** `REVOwner` is set as the data hook (`metadata.dataHook = address(OWNER())`) for all rulesets, ensuring consistent fee and sucker logic without external admin control. REVOwner stores `cashOutDelayOf` and `tiered721HookOf` in its own storage (set by REVDeployer via DEPLOYER-restricted setters during deployment).
99
+ - **Mint permission is restricted.** Only the loans contract, the buyback hook (and its delegates), and registered suckers can mint tokens (as determined by `REVOwner.hasMintPermissionFor`). The split operator cannot mint fungible revnet tokens.
100
100
  - **No held fee manipulation.** The deployer has no function to process or return held fees arbitrarily.
101
101
  - **Owner minting is constrained.** While `allowOwnerMinting = true` is set in ruleset metadata, the "owner" is the `REVDeployer` contract. It only uses this to mint auto-issuance tokens (amounts fixed at deployment) and to return loan collateral.
102
102
 
@@ -138,6 +138,16 @@ The following parameters are set at deployment and can never be changed:
138
138
  - `DEFAULT_BUYBACK_POOL_FEE` -- 10,000 (1% Uniswap V4 fee tier)
139
139
  - `DEFAULT_BUYBACK_TICK_SPACING` -- 200
140
140
  - `DEFAULT_BUYBACK_TWAP_WINDOW` -- 2 days
141
+ - `OWNER()` -- view returning the REVOwner address
142
+
143
+ ### REVOwner (global, set at contract deployment + initialize)
144
+ - `BUYBACK_HOOK` -- the buyback hook (shared immutable with REVDeployer)
145
+ - `DIRECTORY` -- the Juicebox directory (shared immutable with REVDeployer)
146
+ - `FEE_REVNET_ID` -- the project ID that receives cash-out fees (shared immutable)
147
+ - `SUCKER_REGISTRY` -- the sucker registry (shared immutable)
148
+ - `LOANS` -- the loans contract address (shared immutable)
149
+ - `FEE` -- the cash-out fee constant (2.5%)
150
+ - `DEPLOYER` -- the REVDeployer address (storage variable, set once via `initialize()`)
141
151
 
142
152
  ### REVLoans (global, set at contract deployment)
143
153
  - `CONTROLLER`, `DIRECTORY`, `PRICES`, `PROJECTS` -- protocol infrastructure
@@ -186,5 +196,5 @@ What admins **cannot** do -- this is the most important section for understandin
186
196
  - Prevent token holders from eventually cashing out
187
197
  - Extract funds from the treasury without going through the bonding curve
188
198
  - Modify the fee structure (2.5% cash-out fee, loan fees)
189
- - Change which contract is the data hook for a revnet
199
+ - Change which contract is the data hook for a revnet (always REVOwner)
190
200
  - Alter auto-issuance amounts after deployment (they can only be claimed, not changed)
package/ARCHITECTURE.md CHANGED
@@ -2,16 +2,18 @@
2
2
 
3
3
  ## Purpose
4
4
 
5
- Autonomous revenue networks ("revnets") built on Juicebox V6. REVDeployer creates projects with pre-programmed multi-stage rulesets that cannot be changed after deployment. REVLoans enables borrowing against locked revnet tokens.
5
+ Autonomous revenue networks ("revnets") built on Juicebox V6. REVDeployer creates projects with pre-programmed multi-stage rulesets that cannot be changed after deployment. REVOwner handles all runtime hook behavior (pay hooks, cash-out hooks, mint permissions) as the `dataHook` for every revnet. REVLoans enables borrowing against locked revnet tokens.
6
6
 
7
7
  ## Contract Map
8
8
 
9
9
  ```
10
10
  src/
11
- ├── REVDeployer.sol — Deploys revnets: stages → rulesets, data hook, buyback, suckers, 721 tiers
11
+ ├── REVDeployer.sol — Deploys revnets: stages → rulesets, buyback, suckers, 721 tiers, state storage
12
+ ├── REVOwner.sol — Runtime data hook + cash-out hook for all revnets, stores cashOutDelayOf + tiered721HookOf (~310 lines)
12
13
  ├── REVLoans.sol — Borrow against burned revnet tokens (10-year max, permissionless liquidation)
13
14
  ├── interfaces/
14
15
  │ ├── IREVDeployer.sol
16
+ │ ├── IREVOwner.sol
15
17
  │ └── IREVLoans.sol
16
18
  └── structs/ — REVConfig, REVStageConfig, REVLoanSource, REVAutoIssuance, etc.
17
19
  ```
@@ -25,7 +27,7 @@ Deployer → REVDeployer.deployFor()
25
27
  → Convert REV stages → JBRulesetConfigs (see Stage-to-Ruleset Mapping below)
26
28
  → Each stage: duration, weight, cashOutTaxRate, splits
27
29
  → Auto-issuance: record per-beneficiary token counts for later claiming
28
- → Set REVDeployer as data hook (controls pay + cashout behavior)
30
+ → Set REVOwner as data hook (controls pay + cashout behavior)
29
31
  → Initialize buyback pools at fair issuance price (derived from initialIssuance)
30
32
  → Deploy suckers for cross-chain operation
31
33
  → Deploy tiered ERC-721 hook (always — empty by default, pre-configured if specified)
@@ -55,17 +57,18 @@ Claiming is a separate step: anyone can call `autoIssueFor(revnetId, stageId, be
55
57
 
56
58
  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
59
 
58
- ### Data Hook Behavior
60
+ ### Data Hook Behavior (REVOwner)
59
61
  ```
60
- Payment → REVDeployer.beforePayRecordedWith()
62
+ Payment → REVOwner.beforePayRecordedWith()
63
+ → Read tiered721HookOf from REVOwner storage
61
64
  → Query 721 tier hook for tier split specs (if configured)
62
65
  → Delegate remaining amount to buyback hook for swap-vs-mint decision
63
66
  → Scale weight so tokens are only minted for the project's share (after tier splits)
64
67
  → Return merged hook specifications (721 hook + buyback hook)
65
68
 
66
- Cash Out → REVDeployer.beforeCashOutRecordedWith()
69
+ Cash Out → REVOwner.beforeCashOutRecordedWith()
67
70
  → 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)
71
+ → Enforce cash out delay (reads cashOutDelayOf from REVOwner storage)
69
72
  → If no tax, no fee terminal, or feeless beneficiary: delegate directly to buyback hook
70
73
  → Otherwise: split tokens into fee/non-fee portions via bonding curve
71
74
  → Delegate non-fee portion to buyback hook
@@ -76,6 +79,7 @@ Cash Out → REVDeployer.beforeCashOutRecordedWith()
76
79
  ### Loan Flow
77
80
  ```
78
81
  Borrower → REVLoans.borrowFrom()
82
+ → Enforce cash-out delay if set (cross-chain deployment protection)
79
83
  → Burn borrower's revnet tokens as collateral
80
84
  → Calculate borrow amount from bonding curve value of collateral
81
85
  → Pull funds from treasury via USE_ALLOWANCE
@@ -111,7 +115,7 @@ Fields set automatically by the deployer (not configurable per stage):
111
115
  - `metadata.allowOwnerMinting` — always `true` (required for auto-issuance)
112
116
  - `metadata.useDataHookForPay` — always `true`
113
117
  - `metadata.useDataHookForCashOut` — always `true`
114
- - `metadata.dataHook` — always `address(REVDeployer)`
118
+ - `metadata.dataHook` — always `address(REVOwner)`
115
119
  - `approvalHook` — always `address(0)` (no approval hook; stages are immutable)
116
120
  - `fundAccessLimitGroups` — set to `uint224.max` surplus allowance per terminal token for loan withdrawals
117
121
 
@@ -119,7 +123,7 @@ Fields set automatically by the deployer (not configurable per stage):
119
123
 
120
124
  | Point | Interface | Purpose |
121
125
  |-------|-----------|---------|
122
- | Data hook | `IJBRulesetDataHook` | REVDeployer acts as data hook for all revnets |
126
+ | Data hook | `IJBRulesetDataHook` | REVOwner acts as data hook for all revnets |
123
127
  | Buyback hook | `IJBBuybackHook` | Swap-vs-mint decision on payments |
124
128
  | Sucker integration | `IJBSucker` | Cross-chain token bridging |
125
129
  | 721 tiers | `IJB721TiersHook` | NFT tier rewards |
@@ -139,7 +143,7 @@ Fields set automatically by the deployer (not configurable per stage):
139
143
  ## Key Design Decisions
140
144
  - Stages are immutable after deployment — no owner can change ruleset parameters
141
145
  - 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.
142
- - REVDeployer is the data hook for all revnets it deploys — centralizes behavioral control
146
+ - REVOwner is the data hook for all revnets — centralizes runtime behavioral control (pay hooks, cash-out hooks, mint permissions) and stores `cashOutDelayOf` and `tiered721HookOf` per revnet. REVDeployer handles deployment and configuration state storage. The split was necessary to stay under the EIP-170 contract size limit (24,576 bytes). Deploy order: REVOwner first, then REVDeployer(owner=REVOwner), then REVOwner.initialize(deployer). REVDeployer calls DEPLOYER-restricted setters on REVOwner (`setCashOutDelayOf`, `setTiered721HookOf`) during deployment.
143
147
  - Loans use bonding curve value, not market price — independent of external DEX pricing
144
148
  - 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
149
  - 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.
@@ -10,8 +10,9 @@ 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,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
- | `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. |
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,353 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 `initialize()` 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. |
15
16
  | `src/interfaces/` | ~525 | Interface definitions for both contracts |
16
17
  | `src/structs/` | ~212 | All struct definitions |
17
18
 
@@ -27,7 +28,7 @@ Read [RISKS.md](./RISKS.md) for the trust model and known risks. Read [ARCHITECT
27
28
 
28
29
  ## The System in 90 Seconds
29
30
 
30
- A **revnet** is a Juicebox project that nobody owns. REVDeployer deploys it, permanently holds its project NFT, and acts as the data hook for all payments and cash-outs. The revnet's economics are encoded as a sequence of **stages** that map 1:1 to Juicebox rulesets. Stages are immutable after deployment.
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.
31
32
 
32
33
  Each stage defines:
33
34
  - **Initial issuance** (`initialIssuance`): tokens minted per unit of base currency
@@ -53,26 +54,27 @@ Understanding this interaction is essential. REVDeployer wraps core Juicebox fun
53
54
  ```
54
55
  User pays terminal
55
56
  -> Terminal calls JBTerminalStore.recordPaymentFrom()
56
- -> Store calls REVDeployer.beforePayRecordedWith() [data hook]
57
- -> REVDeployer calls 721 hook's beforePayRecordedWith() for split specs
58
- -> REVDeployer calls buyback hook's beforePayRecordedWith() for swap decision
59
- -> REVDeployer scales weight: mulDiv(weight, projectAmount, totalAmount)
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)
60
62
  -> Returns merged specs: [721 hook spec, buyback hook spec]
61
63
  -> Store records payment with modified weight
62
64
  -> Terminal mints tokens via Controller
63
65
  -> Terminal executes pay hook specs (721 hook first, then buyback hook)
64
66
  ```
65
67
 
66
- **Key insight:** The weight scaling in `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.
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.
67
69
 
68
70
  ### Cash-Out Flow
69
71
 
70
72
  ```
71
73
  User cashes out via terminal
72
74
  -> Terminal calls JBTerminalStore.recordCashOutFor()
73
- -> Store calls REVDeployer.beforeCashOutRecordedWith() [data hook]
75
+ -> Store calls REVOwner.beforeCashOutRecordedWith() [data hook]
74
76
  -> If sucker: return 0% tax, full amount (fee exempt)
75
- -> If cashOutDelay not passed: revert
77
+ -> If cashOutDelay not passed (reads from REVOwner storage): revert
76
78
  -> If cashOutTaxRate == 0 or no fee terminal: return as-is
77
79
  -> Otherwise: split cashOutCount into fee portion + non-fee portion
78
80
  -> Compute reclaim for non-fee portion via bonding curve
@@ -81,8 +83,8 @@ User cashes out via terminal
81
83
  -> Store records cash-out with modified parameters
82
84
  -> Terminal burns tokens
83
85
  -> Terminal transfers reclaimed amount to user
84
- -> Terminal calls REVDeployer.afterCashOutRecordedWith() [cash-out hook]
85
- -> REVDeployer pays fee to fee revnet terminal
86
+ -> Terminal calls REVOwner.afterCashOutRecordedWith() [cash-out hook]
87
+ -> REVOwner pays fee to fee revnet terminal
86
88
  -> On failure: returns funds to originating project
87
89
  ```
88
90
 
@@ -93,6 +95,7 @@ User cashes out via terminal
93
95
  ```
94
96
  Borrower calls REVLoans.borrowFrom()
95
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)
96
99
  -> Validate: collateral > 0, terminal registered, prepaidFeePercent in range
97
100
  -> Generate loan ID: revnetId * 1T + loanNumber
98
101
  -> Create loan in storage
@@ -118,11 +121,17 @@ Borrower calls REVLoans.borrowFrom()
118
121
  | Variable | Purpose | Audit Focus |
119
122
  |----------|---------|-------------|
120
123
  | `amountToAutoIssue[revnetId][stageId][beneficiary]` | Premint tokens per stage per beneficiary | Single-claim enforcement (zeroed before mint) |
121
- | `cashOutDelayOf[revnetId]` | Timestamp when cash-outs unlock | Applied only for existing revnets deployed to new chains |
122
124
  | `hashedEncodedConfigurationOf[revnetId]` | Config hash for cross-chain sucker validation | Gap: does NOT cover terminal configs |
123
- | `tiered721HookOf[revnetId]` | 721 hook address | Set once during deploy, never changed |
124
125
  | `_extraOperatorPermissions[revnetId]` | Custom permissions for split operator | Set during deploy based on 721 hook prevention flags |
125
126
 
127
+ ### REVOwner Storage
128
+
129
+ | Variable | Purpose | Audit Focus |
130
+ |----------|---------|-------------|
131
+ | `DEPLOYER` | REVDeployer address | Set once via `initialize()`. **Not immutable** -- stored as a regular storage variable to break circular dependency. Verify `initialize()` can only be called once and with the correct address. 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
+
126
135
  ### REVLoans Storage
127
136
 
128
137
  | Variable | Purpose | Audit Focus |
@@ -178,7 +187,7 @@ No reentrancy guard. Verify the CEI ordering in:
178
187
 
179
188
  ### 3. Data hook composition
180
189
 
181
- REVDeployer proxies between the terminal and two hooks. Verify:
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:
182
191
 
183
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?
184
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?
@@ -187,7 +196,7 @@ REVDeployer proxies between the terminal and two hooks. Verify:
187
196
 
188
197
  ### 4. Cash-out fee calculation
189
198
 
190
- The two-step bonding curve fee calculation in `beforeCashOutRecordedWith`:
199
+ The two-step bonding curve fee calculation in `REVOwner.beforeCashOutRecordedWith`:
191
200
 
192
201
  ```solidity
193
202
  feeCashOutCount = mulDiv(cashOutCount, FEE, MAX_FEE) // 2.5% of tokens
@@ -263,6 +272,18 @@ Verify:
263
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.
264
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 `<=`.
265
274
 
275
+ ### 8. REVOwner initialization and circular dependency
276
+
277
+ REVOwner and REVDeployer have a circular dependency broken by a one-shot `initialize()` call. Deploy order: REVOwner first, then REVDeployer(owner=REVOwner), then REVOwner.initialize(deployer). Verify:
278
+
279
+ - `initialize()` can only be called once (subsequent calls revert)
280
+ - `DEPLOYER` is a storage variable, not immutable, to break the circular dependency
281
+ - Before `initialize()` is called, the DEPLOYER-restricted setters (`setCashOutDelayOf`, `setTiered721HookOf`) would reject calls, leaving `cashOutDelayOf` and `tiered721HookOf` unpopulated
282
+ - No path allows `initialize()` to be called with the wrong deployer address after the correct one is set
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
+
266
287
  ## Invariants
267
288
 
268
289
  Fuzzable properties that should hold for all valid inputs:
@@ -272,6 +293,7 @@ Fuzzable properties that should hold for all valid inputs:
272
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).
273
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.
274
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 `initialize()` and matches the REVDeployer that references this REVOwner via `OWNER()`. Only the initialized `DEPLOYER` can call `setCashOutDelayOf()` and `setTiered721HookOf()`.
275
297
 
276
298
  ## How to Run Tests
277
299
 
@@ -295,7 +317,7 @@ forge test --gas-report
295
317
 
296
318
  | Pattern | Where | Why |
297
319
  |---------|-------|-----|
298
- | `mulDiv` rounding direction | `beforePayRecordedWith` weight scaling, `_determineSourceFeeAmount`, `_borrowableAmountFrom` | Rounding in borrower's favor compounds over many loans |
320
+ | `mulDiv` rounding direction | `REVOwner.beforePayRecordedWith` weight scaling, `_determineSourceFeeAmount`, `_borrowableAmountFrom` | Rounding in borrower's favor compounds over many loans |
299
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 |
300
322
  | `delete _loanOf[loanId]` after external calls | `_repayLoan`, `_reallocateCollateralFromLoan` | Verify delete happens after all references to the loan are resolved |
301
323
  | Loan storage read after `_adjust` mutates it | `_repayLoan` partial repay path | `_adjust` modifies `loan` via storage pointer; subsequent reads see mutated values |
@@ -332,6 +354,7 @@ No prior formal audit with finding IDs has been conducted on this codebase. All
332
354
  | `REVDeployer_StagesRequired` | REVDeployer | `deployFor` / `launchChainsFor` called with empty `stageConfigurations` array |
333
355
  | `REVDeployer_StageTimesMustIncrease` | REVDeployer | Stage `startsAtOrAfter` timestamps are not strictly increasing |
334
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) |
335
358
  | `REVLoans_CollateralExceedsLoan` | REVLoans | `reallocateCollateralFromLoan` called with `collateralCountToReturn > loan.collateral` |
336
359
  | `REVLoans_InvalidPrepaidFeePercent` | REVLoans | `prepaidFeePercent` outside `[MIN_PREPAID_FEE_PERCENT, MAX_PREPAID_FEE_PERCENT]` range (25-500) |
337
360
  | `REVLoans_InvalidTerminal` | REVLoans | Loan source references a terminal not registered in `JBDirectory` for the revnet |
package/CHANGE_LOG.md CHANGED
@@ -2,6 +2,84 @@
2
2
 
3
3
  This document describes all changes between `revnet-core` (v5, Solidity 0.8.23) and `revnet-core-v6` (v6, Solidity 0.8.28).
4
4
 
5
+ ## 0. REVDeployer/REVOwner Split (post-v6 refactor)
6
+
7
+ ### Motivation
8
+
9
+ REVDeployer exceeded the EIP-170 contract size limit (24,576 bytes) at 26,397 bytes. The contract was split into two:
10
+
11
+ | Contract | Size | Role |
12
+ |----------|------|------|
13
+ | `REVDeployer` | 19,746 bytes | Deployment, configuration, state storage, split operator management |
14
+ | `REVOwner` | 8,353 bytes (~310 lines) | Runtime hook behavior: `IJBRulesetDataHook` + `IJBCashOutHook` |
15
+
16
+ ### What moved to REVOwner
17
+
18
+ | Function | Description |
19
+ |----------|-------------|
20
+ | `beforePayRecordedWith()` | Pay data hook -- 721 hook delegation, buyback hook delegation, weight scaling |
21
+ | `beforeCashOutRecordedWith()` | Cash-out data hook -- sucker bypass, fee splitting, cash-out delay enforcement |
22
+ | `afterCashOutRecordedWith()` | Cash-out hook callback -- fee payment to fee revnet |
23
+ | `hasMintPermissionFor()` | Mint permission check for loans, buyback hook, suckers |
24
+ | `_isSuckerOf()` | Internal sucker verification helper |
25
+ | `_beforeTransferTo()` | Internal pre-transfer fee handling |
26
+ | `cashOutDelayOf(revnetId)` | View that returns the cash-out delay from REVOwner storage. Exposed via IREVOwner for REVLoans compatibility. |
27
+
28
+ ### What REVDeployer retains
29
+
30
+ - `deployFor()` -- revnet deployment (both overloads)
31
+ - `queueStagesToRevnetOf()` -- stage configuration
32
+ - `autoIssueFor()` -- auto-issuance claiming
33
+ - `setSplitOperatorOf()` -- operator management
34
+ - `replaceSplitsOf()` -- split management
35
+ - `deploySuckersFor()` -- sucker deployment
36
+ - Configuration state mappings: `amountToAutoIssue`, `hashedEncodedConfigurationOf`, etc.
37
+ - `OWNER()` -- new view returning the REVOwner address
38
+ - `supportsInterface()` -- no longer includes `IJBRulesetDataHook` or `IJBCashOutHook`
39
+
40
+ ### Storage migration: `cashOutDelayOf` and `tiered721HookOf` moved to REVOwner
41
+
42
+ The `cashOutDelayOf` and `tiered721HookOf` storage mappings were moved from REVDeployer to REVOwner. REVOwner now has DEPLOYER-restricted setter functions (`setCashOutDelayOf()` and `setTiered721HookOf()`) that only REVDeployer can call. REVDeployer calls these setters during deployment instead of writing to its own storage.
43
+
44
+ - `cashOutDelayOf` and `tiered721HookOf` removed from `IREVDeployer` interface
45
+ - `IREVOwner.sol` created at `src/interfaces/IREVOwner.sol` (exposes `cashOutDelayOf`)
46
+ - REVLoans now imports `IREVOwner` (not `IREVDeployer`) for `cashOutDelayOf` calls
47
+ - REVOwner reads `cashOutDelayOf` and `tiered721HookOf` directly from its own storage instead of delegating to `DEPLOYER`
48
+
49
+ ### Circular dependency pattern
50
+
51
+ REVDeployer needs REVOwner (as the `dataHook` address and to set `cashOutDelayOf`/`tiered721HookOf` via restricted setters), and REVOwner references REVDeployer (to restrict setter access). This circular dependency is broken by:
52
+
53
+ 1. Deploy REVOwner first
54
+ 2. Deploy REVDeployer with `owner=REVOwner`
55
+ 3. Call `REVOwner.initialize(deployer)` to set the `DEPLOYER` storage variable
56
+
57
+ `REVOwner.DEPLOYER` is a **storage variable** (not immutable) because the deployer address is not known at REVOwner construction time. The `initialize()` function can only be called once. After initialization, `DEPLOYER` is used to restrict access to `setCashOutDelayOf()` and `setTiered721HookOf()`.
58
+
59
+ ### Shared immutables
60
+
61
+ Both contracts independently store these as immutables (set at construction): `BUYBACK_HOOK`, `DIRECTORY`, `FEE_REVNET_ID`, `SUCKER_REGISTRY`, `LOANS`. The `FEE` constant (25 = 2.5%) is defined in both contracts.
62
+
63
+ ### Interface changes
64
+
65
+ | Change | Description |
66
+ |--------|-------------|
67
+ | `IREVDeployer.OWNER()` | New view function returning the REVOwner address |
68
+ | `IREVDeployer` | `cashOutDelayOf` and `tiered721HookOf` removed (moved to REVOwner) |
69
+ | `IREVOwner.sol` | New interface at `src/interfaces/IREVOwner.sol` — exposes `cashOutDelayOf` |
70
+ | `REVOwner.setCashOutDelayOf()` | New DEPLOYER-restricted setter for `cashOutDelayOf` |
71
+ | `REVOwner.setTiered721HookOf()` | New DEPLOYER-restricted setter for `tiered721HookOf` |
72
+ | `REVDeployer.supportsInterface()` | No longer reports `IJBRulesetDataHook` or `IJBCashOutHook` |
73
+ | Ruleset `metadata.dataHook` | Now set to `address(REVOwner)` instead of `address(REVDeployer)` |
74
+
75
+ ### Migration impact
76
+
77
+ - **Indexers/subgraphs**: Data hook events now originate from the REVOwner address, not REVDeployer. `cashOutDelayOf` and `tiered721HookOf` storage reads must target REVOwner, not REVDeployer.
78
+ - **REVLoans**: Now imports `IREVOwner` (not `IREVDeployer`) and calls `REVOwner.cashOutDelayOf(revnetId)` directly from REVOwner storage.
79
+ - **Direct callers**: Any code that read `cashOutDelayOf` or `tiered721HookOf` from REVDeployer must now read from REVOwner. Any code that cast the deployer address to `IJBRulesetDataHook` or `IJBCashOutHook` must now use the REVOwner address instead.
80
+
81
+ ---
82
+
5
83
  ## Summary
6
84
 
7
85
  - **Buyback hook centralized**: Per-revnet `buybackHookOf` mapping replaced by a single immutable `BUYBACK_HOOK` registry — pools auto-initialized with default parameters during deployment.
@@ -108,6 +186,7 @@ The boolean semantics are **inverted**: v5 used opt-in flags (`splitOperatorCan*
108
186
  | `REVLoans` | `REVLoans_NothingToRepay()` |
109
187
  | `REVLoans` | `REVLoans_ZeroBorrowAmount()` |
110
188
  | `REVLoans` | `REVLoans_SourceMismatch()` |
189
+ | `REVLoans` | `REVLoans_CashOutDelayNotFinished(uint256 cashOutDelay, uint256 blockTimestamp)` |
111
190
  | `REVLoans` | `REVLoans_LoanIdOverflow()` |
112
191
 
113
192
  ### 2.4 New Constants
@@ -129,6 +208,13 @@ The boolean semantics are **inverted**: v5 used opt-in flags (`splitOperatorCan*
129
208
 
130
209
  ## 3. Event Changes
131
210
 
211
+ ### 3.0 Indexer Notes
212
+
213
+ For revnet-focused subgraphs:
214
+ - both deployment flows now correlate to `deployFor` rather than a split `deployFor`/`deployWith721sFor` model;
215
+ - revnet deployment entities should expect an associated 721 hook by default;
216
+ - any entity that previously depended on caller-supplied buyback-hook config should be updated for the v6 auto-configured buyback path.
217
+
132
218
  ### 3.1 Added Events
133
219
 
134
220
  See section 2.2 above.
@@ -289,6 +375,7 @@ The following structs are identical between v5 and v6 (only `forge-lint` comment
289
375
  | **Source fee try-catch hardening** | The source fee payment in `_adjust` is now wrapped in a try-catch block. If the source terminal's `pay` call reverts, the ERC-20 allowance is reclaimed and the fee amount is returned to the beneficiary instead of blocking the entire loan operation. v5 called `terminal.pay` directly without error handling. |
290
376
  | **Timestamp cast fix** | `borrowFrom` now casts `block.timestamp` to `uint48` when setting `loan.createdAt`, matching the `REVLoan.createdAt` field width. v5 used `uint40`, which would silently truncate timestamps after the year 36812. |
291
377
  | **`ReallocateCollateral` event typo fix** | v5 used `removedcollateralCount` (lowercase 'c'). v6 fixes it to `removedCollateralCount` (uppercase 'C'). |
378
+ | **Cash out delay enforced in loans** | `borrowFrom` now resolves REVOwner from the ruleset's `dataHook` and checks `IREVOwner.cashOutDelayOf(revnetId)` (stored on REVOwner). If the 30-day cross-chain deployment delay hasn't passed, `borrowFrom` reverts with `REVLoans_CashOutDelayNotFinished`. `borrowableAmountFrom` returns 0 during the delay so UIs reflect the restriction. v5 did not enforce the cash out delay in the loans contract. |
292
379
  | **NatSpec documentation** | Extensive NatSpec added to all functions, views, and internal helpers. Flash loan safety analysis documented in `_borrowableAmountFrom`. |
293
380
 
294
381
  ### 6.3 Named Arguments
package/README.md CHANGED
@@ -17,7 +17,7 @@ Revnets are autonomous Juicebox projects with predetermined economic stages. Eac
17
17
  ```
18
18
  1. Deploy revnet with stage configurations
19
19
  → REVDeployer.deployFor(revnetId=0, config, terminals, suckerConfig)
20
- → Creates Juicebox project owned by REVDeployer (permanently)
20
+ → Creates Juicebox project owned by REVDeployer (permanently), dataHook = REVOwner
21
21
  → Deploys ERC-20 token, initializes buyback pools at 1:1 price, deploys suckers
22
22
  |
23
23
  2. Stage 1 begins (startsAtOrAfter or block.timestamp)
@@ -90,18 +90,20 @@ Every revnet gets a tiered ERC-721 hook deployed automatically — even if no ti
90
90
 
91
91
  | Contract | Description |
92
92
  |----------|-------------|
93
- | `REVDeployer` | Deploys revnets as Juicebox projects owned by the deployer contract itself (no human owner). Translates stage configurations into Juicebox rulesets, manages buyback hooks, tiered 721 hooks, suckers, split operators, auto-issuance, and cash-out fees. Acts as the ruleset data hook and cash-out hook for every revnet it deploys. When 721 tier splits are active, adjusts the payment weight so the terminal only mints tokens proportional to the amount entering the project treasury (the split portion is forwarded separately). |
93
+ | `REVDeployer` | Deploys revnets as Juicebox projects owned by the deployer contract itself (no human owner). Translates stage configurations into Juicebox rulesets, manages buyback hooks, suckers, split operators, auto-issuance, and configuration state storage. Handles deployment, configuration, and split operator management. Calls DEPLOYER-restricted setters on REVOwner during deployment to store `cashOutDelayOf` and `tiered721HookOf`. |
94
+ | `REVOwner` | Runtime hook contract for all revnets. Implements `IJBRulesetDataHook` and `IJBCashOutHook`. Set as the `dataHook` in each revnet's ruleset metadata. Handles `beforePayRecordedWith`, `beforeCashOutRecordedWith`, `afterCashOutRecordedWith`, `hasMintPermissionFor`, and sucker verification. When 721 tier splits are active, adjusts the payment weight so the terminal only mints tokens proportional to the amount entering the project treasury (the split portion is forwarded separately). Stores `cashOutDelayOf` and `tiered721HookOf` mappings (set by REVDeployer via DEPLOYER-restricted setters `setCashOutDelayOf()` and `setTiered721HookOf()`). |
94
95
  | `REVLoans` | Lets participants borrow against their revnet tokens. Collateral tokens are burned on borrow and re-minted on repayment. Each loan is an ERC-721 NFT. Charges a prepaid fee (2.5% min, 50% max) that determines the interest-free duration; after that window, a time-proportional source fee accrues. Loans liquidate after 10 years. |
95
96
 
96
97
  ### How They Relate
97
98
 
98
- `REVDeployer` owns every revnet's Juicebox project NFT and holds all administrative permissions. During deployment it grants `REVLoans` the `USE_ALLOWANCE` permission so loans can pull funds from the revnet's terminal. `REVLoans` verifies that a revnet was deployed by its expected `REVDeployer` before issuing any loan.
99
+ `REVDeployer` owns every revnet's Juicebox project NFT and holds all administrative permissions. `REVOwner` is set as the `dataHook` for every revnet's rulesets, handling all runtime hook behavior (pay hooks, cash-out hooks, mint permissions) and storing `cashOutDelayOf` and `tiered721HookOf` per revnet (set by REVDeployer via DEPLOYER-restricted setters during deployment). Deploy order: REVOwner is deployed first, then REVDeployer (with `owner=REVOwner`), then `REVOwner.initialize(deployer)` is called to link them. Both contracts share immutables: `BUYBACK_HOOK`, `DIRECTORY`, `FEE_REVNET_ID`, `SUCKER_REGISTRY`, `LOANS`. During deployment, REVDeployer grants `REVLoans` the `USE_ALLOWANCE` permission so loans can pull funds from the revnet's terminal. `REVLoans` imports `IREVOwner` (not `IREVDeployer`) for `cashOutDelayOf` calls and verifies that a revnet was deployed by its expected `REVDeployer` before issuing any loan.
99
100
 
100
101
  ### Interfaces
101
102
 
102
103
  | Interface | Description |
103
104
  |-----------|-------------|
104
- | `IREVDeployer` | Deployment, data hooks, auto-issuance, split operator management, sucker deployment, plus events. |
105
+ | `IREVDeployer` | Deployment, auto-issuance, split operator management, sucker deployment, configuration state storage, `OWNER()` view, plus events. |
106
+ | `IREVOwner` | Exposes `cashOutDelayOf` view. Used by REVLoans to read cash-out delay. |
105
107
  | `IREVLoans` | Borrow, repay, refinance, liquidate, views, plus events. |
106
108
 
107
109
  ## Install
@@ -130,10 +132,12 @@ If `forge install` has issues, try `git submodule update --init --recursive`.
130
132
 
131
133
  ```
132
134
  src/
133
- REVDeployer.sol # Revnet deployer + data hook (~1,373 lines)
135
+ REVDeployer.sol # Revnet deployer + configuration + state storage
136
+ REVOwner.sol # Runtime data hook + cash-out hook (~310 lines)
134
137
  REVLoans.sol # Token-collateralized lending (~1,391 lines)
135
138
  interfaces/
136
139
  IREVDeployer.sol # Deployer interface + events
140
+ IREVOwner.sol # Owner interface (cashOutDelayOf view)
137
141
  IREVLoans.sol # Loans interface + events
138
142
  structs/
139
143
  REVConfig.sol # Top-level deployment config
@@ -205,6 +209,7 @@ Plus optional from 721 hook config: `ADJUST_721_TIERS`, `SET_721_METADATA`, `MIN
205
209
  ## Risks
206
210
 
207
211
  - **No human owner.** `REVDeployer` permanently holds the project NFT. There is no function to release it. This is by design -- revnets are ownerless. But it means bugs in stage configurations cannot be fixed after deployment.
212
+ - **REVOwner circular dependency.** REVOwner and REVDeployer have a circular dependency broken by a one-shot `initialize()` call. `REVOwner.DEPLOYER` is a storage variable (not immutable) set via `initialize()`. If `initialize()` is never called or called with the wrong address, the DEPLOYER-restricted setters (`setCashOutDelayOf`, `setTiered721HookOf`) cannot be called correctly, and all runtime hook behavior breaks.
208
213
  - **Loan flash-loan exposure.** `borrowableAmountFrom` reads live surplus, which can be inflated via flash loans. A borrower could temporarily inflate the treasury to borrow more than the sustained value would support.
209
214
  - **uint112 truncation.** `REVLoan.amount` and `REVLoan.collateral` are `uint112` -- values above ~5.19e33 truncate silently.
210
215
  - **Cash-out fee stacking.** Cash outs incur both the Juicebox terminal fee (2.5%) and the revnet cash-out fee (2.5% to fee revnet). These compound.
package/RISKS.md CHANGED
@@ -10,14 +10,15 @@ Read [ARCHITECTURE.md](./ARCHITECTURE.md) and [SKILLS.md](./SKILLS.md) for proto
10
10
 
11
11
  ### What the system assumes to be correct
12
12
 
13
- - **REVDeployer is a singleton data hook.** Every revnet shares one `beforePayRecordedWith` and `beforeCashOutRecordedWith` implementation. A bug in either function affects ALL revnets deployed by that deployer simultaneously. There is no per-project isolation and no circuit breaker.
13
+ - **REVOwner is a singleton data hook.** Every revnet shares one `beforePayRecordedWith` and `beforeCashOutRecordedWith` implementation in REVOwner. A bug in either function affects ALL revnets deployed by that deployer simultaneously. There is no per-project isolation and no circuit breaker.
14
+ - **REVOwner circular dependency.** REVOwner and REVDeployer have a circular dependency broken by a one-shot `initialize()` call. `REVOwner.DEPLOYER` is a storage variable (not immutable) set via `initialize()`. If `initialize()` is never called, REVDeployer cannot call the DEPLOYER-restricted setters (`setCashOutDelayOf`, `setTiered721HookOf`) on REVOwner, and `cashOutDelayOf`/`tiered721HookOf` will never be populated, breaking all runtime hook behavior. If called with the wrong deployer address, an unauthorized address could set incorrect state. The `initialize()` function must be called exactly once with the correct address immediately after deploying both contracts.
14
15
  - **Stage immutability is the trust model.** Once `deployFor()` completes, stage parameters (issuance, `cashOutTaxRate`, splits, auto-issuances) are locked forever. No owner, no governance, no upgrade path. A misconfigured deployment is permanent. This is intentional -- the absence of admin keys IS the security property.
15
16
  - **Bonding curve is the sole collateral oracle.** `REVLoans` uses `JBCashOuts.cashOutFrom` to value collateral. There is no external price oracle, no liquidation margin, and no health factor. The borrowable amount equals the cash-out value at the moment of borrowing.
16
17
  - **Juicebox core contracts are correct.** `JBController`, `JBMultiTerminal`, `JBTerminalStore`, `JBTokens`, `JBPrices` -- a bug in any of these is a bug in every revnet.
17
18
  - **Buyback hook operates correctly.** `BUYBACK_HOOK` handles swap-vs-mint routing. All revnets from the same deployer share one instance. Failure falls back to direct minting (not a revert), so the failure mode is economic inefficiency, not fund loss.
18
19
  - **Suckers are honest bridges.** Suckers get 0% cashout tax in `beforeCashOutRecordedWith`. A compromised or malicious sucker registered in `SUCKER_REGISTRY` can extract funds from any revnet at zero cost.
19
20
  - **Auto-issuance beneficiaries are set at deployment.** Beneficiary addresses are baked into the stage configuration. If a beneficiary address is a contract that becomes compromised, or an EOA whose keys are lost, those auto-issuance tokens are either captured or permanently unclaimable.
20
- - **REVLoans contract address is immutable per deployer.** `LOANS` is set once in the REVDeployer constructor with wildcard `USE_ALLOWANCE` permission (`projectId=0`). If the loans contract has a vulnerability, every revnet's surplus is exposed.
21
+ - **REVLoans contract address is immutable per deployer.** `LOANS` is set once in the REVDeployer constructor (and shared as an immutable on REVOwner) with wildcard `USE_ALLOWANCE` permission (`projectId=0`). If the loans contract has a vulnerability, every revnet's surplus is exposed.
21
22
 
22
23
  ### What you do NOT need to trust
23
24
 
@@ -81,6 +82,10 @@ Read [ARCHITECTURE.md](./ARCHITECTURE.md) and [SKILLS.md](./SKILLS.md) for proto
81
82
  - **Source mismatch check.** `reallocateCollateralFromLoan` enforces that the new loan's source matches the existing loan's source (`source.token == existingSource.token && source.terminal == existingSource.terminal`). This prevents cross-source value extraction.
82
83
  - **MEV opportunity at stage boundaries.** If a borrower knows a stage transition will decrease `cashOutTaxRate`, they can wait until just after the transition and `reallocateCollateralFromLoan` to extract more borrowed funds from the same collateral. This is predictable and not preventable by design.
83
84
 
85
+ ### Cross-chain cash-out delay enforcement
86
+
87
+ - **Loans enforce the same 30-day cash-out delay as direct cash outs.** When a revnet is deployed to a new chain where its first stage has already started, REVDeployer calls `REVOwner.setCashOutDelayOf()` to set a 30-day delay (stored on REVOwner). `borrowFrom` resolves the REVOwner from the current ruleset's `dataHook` and checks `IREVOwner.cashOutDelayOf(revnetId)` (read directly from REVOwner storage), reverting with `REVLoans_CashOutDelayNotFinished` if the delay hasn't passed. `borrowableAmountFrom` returns 0 during the delay. This prevents cross-chain arbitrage via the loan system (bridging tokens to a new chain and immediately borrowing against them before prices equilibrate).
88
+
84
89
  ### BURN_TOKENS permission prerequisite
85
90
 
86
91
  - **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.
@@ -89,22 +94,22 @@ Read [ARCHITECTURE.md](./ARCHITECTURE.md) and [SKILLS.md](./SKILLS.md) for proto
89
94
 
90
95
  ## 4. Data Hook Proxy Risks
91
96
 
92
- REVDeployer sits between the terminal and the actual hooks (buyback hook, 721 hook). This proxy pattern creates composition risks.
97
+ REVOwner sits between the terminal and the actual hooks (buyback hook, 721 hook). This proxy pattern creates composition risks.
93
98
 
94
99
  ### Underlying hook reverts
95
100
 
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.
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.
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.
101
+ - **721 hook revert in `beforePayRecordedWith`.** The call to `IJBRulesetDataHook(tiered721Hook).beforePayRecordedWith(context)` in REVOwner 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.
102
+ - **Buyback hook is more resilient.** The `BUYBACK_HOOK.beforePayRecordedWith(buybackHookContext)` call in REVOwner 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.
103
+ - **Cash-out fee terminal revert.** In `REVOwner.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.
99
104
 
100
105
  ### Sucker bypass path (0% cashout tax)
101
106
 
102
- - **Suckers bypass all economic protections.** `beforeCashOutRecordedWith` returns `(0, context.cashOutCount, context.totalSupply, hookSpecifications)` for suckers -- zero tax, no fee. A compromised sucker effectively has a backdoor to extract the full pro-rata surplus of any token it holds.
107
+ - **Suckers bypass all economic protections.** `REVOwner.beforeCashOutRecordedWith` returns `(0, context.cashOutCount, context.totalSupply, hookSpecifications)` for suckers -- zero tax, no fee. A compromised sucker effectively has a backdoor to extract the full pro-rata surplus of any token it holds.
103
108
  - **Sucker registration is controlled by `SUCKER_REGISTRY`.** The registry has `MAP_SUCKER_TOKEN` wildcard permission from the deployer. The split operator has `SUCKER_SAFETY` permission. Verify that `deploySuckersFor` correctly checks the `extraMetadata` bit (bit 2) for sucker deployment permission per stage.
104
109
 
105
110
  ### Permission escalation through proxy
106
111
 
107
- - **`hasMintPermissionFor` grants mint to four categories.** The loans contract, buyback hook, buyback hook delegates, and suckers all have unrestricted mint permission. If any of these contracts have a vulnerability that allows arbitrary calls, they can mint unlimited revnet tokens for any revnet.
112
+ - **`hasMintPermissionFor` grants mint to four categories.** `REVOwner.hasMintPermissionFor` grants mint to: the loans contract, buyback hook, buyback hook delegates, and suckers. If any of these contracts have a vulnerability that allows arbitrary calls, they can mint unlimited revnet tokens for any revnet.
108
113
  - **Wildcard permissions.** REVDeployer grants `USE_ALLOWANCE` to `LOANS` with `projectId=0` (wildcard). This means the loans contract can drain surplus from ANY revnet deployed by this deployer, constrained only by the loan logic itself. A bug in `REVLoans._addTo` that miscalculates `addedBorrowAmount` could drain treasuries.
109
114
 
110
115
  ---
@@ -179,7 +184,7 @@ These MUST hold. Breaking any of them is a finding.
179
184
 
180
185
  - **Sucker privilege.** Only addresses returning `true` from `SUCKER_REGISTRY.isSuckerOf(projectId, addr)` get 0% cashout tax. No other code path grants this exemption.
181
186
  - **Loan ownership.** Only `_ownerOf(loanId)` can call `repayLoan` and `reallocateCollateralFromLoan`. The loan NFT is burned before any state changes in repayment, preventing double-use.
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.
187
+ - **Mint permission.** Only `LOANS`, `BUYBACK_HOOK`, buyback hook delegates (via `BUYBACK_HOOK.hasMintPermissionFor`), and suckers (via `REVOwner._isSuckerOf`) can mint tokens. No other address passes the `REVOwner.hasMintPermissionFor` check.
183
188
 
184
189
  ---
185
190
 
@@ -187,7 +192,7 @@ These MUST hold. Breaking any of them is a finding.
187
192
 
188
193
  ### 8.1 Suckers receive 0% cashout tax (by design)
189
194
 
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.
195
+ `REVOwner.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
196
 
192
197
  ### 8.2 No liquidation trigger for under-collateralized loans (by design)
193
198