@rev-net/core-v6 0.0.14 → 0.0.16

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 (78) hide show
  1. package/ADMINISTRATION.md +5 -1
  2. package/ARCHITECTURE.md +69 -11
  3. package/AUDIT_INSTRUCTIONS.md +90 -7
  4. package/CHANGE_LOG.md +16 -3
  5. package/README.md +32 -7
  6. package/RISKS.md +26 -14
  7. package/SKILLS.md +168 -46
  8. package/STYLE_GUIDE.md +1 -1
  9. package/USER_JOURNEYS.md +20 -6
  10. package/foundry.toml +7 -0
  11. package/package.json +9 -10
  12. package/script/Deploy.s.sol +80 -16
  13. package/script/helpers/RevnetCoreDeploymentLib.sol +1 -1
  14. package/src/REVDeployer.sol +73 -21
  15. package/src/REVLoans.sol +27 -6
  16. package/test/REV.integrations.t.sol +1 -1
  17. package/test/REVAutoIssuanceFuzz.t.sol +1 -1
  18. package/test/REVDeployerRegressions.t.sol +7 -4
  19. package/test/REVInvincibility.t.sol +7 -19
  20. package/test/REVInvincibilityHandler.sol +1 -1
  21. package/test/REVLifecycle.t.sol +1 -1
  22. package/test/REVLoans.invariants.t.sol +1 -1
  23. package/test/REVLoansAttacks.t.sol +20 -12
  24. package/test/REVLoansFeeRecovery.t.sol +20 -12
  25. package/test/REVLoansFindings.t.sol +20 -12
  26. package/test/REVLoansRegressions.t.sol +20 -12
  27. package/test/REVLoansSourceFeeRecovery.t.sol +1 -1
  28. package/test/REVLoansSourced.t.sol +1 -9
  29. package/test/REVLoansUnSourced.t.sol +1 -1
  30. package/test/TestBurnHeldTokens.t.sol +1 -1
  31. package/test/TestCEIPattern.t.sol +1 -1
  32. package/test/TestCashOutCallerValidation.t.sol +75 -1
  33. package/test/TestConversionDocumentation.t.sol +1 -1
  34. package/test/TestCrossCurrencyReclaim.t.sol +1 -1
  35. package/test/TestCrossSourceReallocation.t.sol +1 -1
  36. package/test/TestERC2771MetaTx.t.sol +1 -1
  37. package/test/TestEmptyBuybackSpecs.t.sol +1 -1
  38. package/test/TestFlashLoanSurplus.t.sol +1 -1
  39. package/test/TestHookArrayOOB.t.sol +1 -1
  40. package/test/TestLiquidationBehavior.t.sol +1 -1
  41. package/test/TestLoanSourceRotation.t.sol +1 -1
  42. package/test/TestLongTailEconomics.t.sol +1 -1
  43. package/test/TestLowFindings.t.sol +4 -2
  44. package/test/TestMixedFixes.t.sol +7 -5
  45. package/test/TestPermit2Signatures.t.sol +1 -1
  46. package/test/TestReallocationSandwich.t.sol +1 -1
  47. package/test/TestRevnetRegressions.t.sol +1 -1
  48. package/test/TestSplitWeightAdjustment.t.sol +11 -6
  49. package/test/TestSplitWeightE2E.t.sol +1 -1
  50. package/test/TestSplitWeightFork.t.sol +9 -10
  51. package/test/TestStageTransitionBorrowable.t.sol +1 -1
  52. package/test/TestSwapTerminalPermission.t.sol +1 -1
  53. package/test/TestUint112Overflow.t.sol +1 -1
  54. package/test/TestZeroRepayment.t.sol +1 -1
  55. package/test/audit/LoanIdOverflowGuard.t.sol +497 -0
  56. package/test/fork/ForkTestBase.sol +8 -11
  57. package/test/fork/TestAutoIssuanceFork.t.sol +148 -0
  58. package/test/fork/TestCashOutFork.t.sol +23 -22
  59. package/test/fork/TestIssuanceDecayFork.t.sol +158 -0
  60. package/test/fork/TestLoanBorrowFork.t.sol +1 -1
  61. package/test/fork/TestLoanCrossRulesetFork.t.sol +1 -1
  62. package/test/fork/TestLoanERC20Fork.t.sol +463 -0
  63. package/test/fork/TestLoanLiquidationFork.t.sol +1 -1
  64. package/test/fork/TestLoanReallocateFork.t.sol +1 -1
  65. package/test/fork/TestLoanRepayFork.t.sol +3 -3
  66. package/test/fork/TestLoanTransferFork.t.sol +1 -1
  67. package/test/fork/TestPermit2PaymentFork.t.sol +299 -0
  68. package/test/fork/TestSplitWeightFork.t.sol +1 -1
  69. package/test/helpers/MaliciousContracts.sol +37 -23
  70. package/test/mock/MockBuybackCashOutRecorder.sol +82 -0
  71. package/test/mock/MockBuybackDataHook.sol +51 -7
  72. package/test/mock/MockBuybackDataHookMintPath.sol +1 -1
  73. package/test/regression/TestBurnPermissionRequired.t.sol +1 -1
  74. package/test/regression/TestCashOutBuybackFeeLeak.t.sol +205 -0
  75. package/test/regression/TestCrossRevnetLiquidation.t.sol +1 -1
  76. package/test/regression/TestCumulativeLoanCounter.t.sol +1 -1
  77. package/test/regression/TestLiquidateGapHandling.t.sol +1 -1
  78. package/test/regression/TestZeroPriceFeed.t.sol +1 -1
package/SKILLS.md CHANGED
@@ -15,42 +15,43 @@ Deploy and manage Revnets -- autonomous, unowned Juicebox projects with staged i
15
15
 
16
16
  ### Deployment
17
17
 
18
- | Function | What it does |
19
- |----------|-------------|
20
- | `REVDeployer.deployFor(revnetId, config, terminals, suckerConfig)` | Deploy a new revnet (`revnetId=0`) or convert an existing Juicebox project. Encodes stage configs into rulesets, deploys ERC-20 token, initializes buyback pool at 1:1 price, sets up split operator, suckers, loans permissions, and deploys a default empty tiered ERC-721 hook. |
21
- | `REVDeployer.deployFor(revnetId, config, terminals, suckerConfig, hookConfig, allowedPosts)` | Same as `deployFor` but deploys a tiered ERC-721 hook with pre-configured tiers. Optionally configures Croptop posting criteria and grants publisher permission to add tiers. |
22
- | `REVDeployer.deploySuckersFor(revnetId, suckerConfig)` | Deploy new cross-chain suckers post-launch. Split operator only. Validates ruleset allows sucker deployment (bit 2 of `extraMetadata`). Uses stored config hash for cross-chain matching. |
18
+ | Function | Permissions | What it does |
19
+ |----------|------------|-------------|
20
+ | `REVDeployer.deployFor(revnetId, config, terminals, suckerConfig)` | Permissionless | Deploy a new revnet (`revnetId=0`) or convert an existing Juicebox project. Encodes stage configs into rulesets, deploys ERC-20 token, initializes buyback pool at 1:1 price, sets up split operator, suckers, loans permissions, and deploys a default empty tiered ERC-721 hook. |
21
+ | `REVDeployer.deployFor(revnetId, config, terminals, suckerConfig, hookConfig, allowedPosts)` | Permissionless | Same as `deployFor` but deploys a tiered ERC-721 hook with pre-configured tiers. Optionally configures Croptop posting criteria and grants publisher permission to add tiers. |
22
+ | `REVDeployer.deploySuckersFor(revnetId, suckerConfig)` | Split operator | Deploy new cross-chain suckers post-launch. Validates ruleset allows sucker deployment (bit 2 of `extraMetadata`). Uses stored config hash for cross-chain matching. |
23
23
 
24
24
  ### Data Hooks
25
25
 
26
- | Function | What it does |
27
- |----------|-------------|
28
- | `REVDeployer.beforePayRecordedWith(context)` | Calls the 721 hook first for split specs, then calls the buyback hook with a reduced amount context (payment minus split amount). Adjusts the returned weight proportionally for splits (`weight = mulDiv(weight, amount - splitAmount, amount)`) so the terminal only mints tokens for the amount entering the project. Assembles pay hook specs (721 hook specs first, then buyback spec). |
29
- | `REVDeployer.beforeCashOutRecordedWith(context)` | If sucker: returns full amount with 0 tax (fee exempt). Otherwise: calculates 2.5% fee, enforces 30-day cash-out delay, returns modified count + fee hook spec. |
30
- | `REVDeployer.afterCashOutRecordedWith(context)` | Cash-out hook callback. Receives fee amount and pays it to the fee revnet's terminal. Falls back to returning funds if fee payment fails. |
31
- | `REVDeployer.hasMintPermissionFor(revnetId, ruleset, addr)` | Returns `true` for: loans contract, buyback hook, buyback hook delegates, or suckers. |
26
+ | Function | Permissions | What it does |
27
+ |----------|------------|-------------|
28
+ | `REVDeployer.beforePayRecordedWith(context)` | Terminal callback | Calls the 721 hook first for split specs, then calls the buyback hook with a reduced amount context (payment minus split amount). Adjusts the returned weight proportionally for splits (`weight = mulDiv(weight, amount - splitAmount, amount)`) so the terminal only mints tokens for the amount entering the project. Assembles pay hook specs (721 hook specs first, then buyback spec). |
29
+ | `REVDeployer.beforeCashOutRecordedWith(context)` | Terminal callback | If sucker: returns full amount with 0 tax (fee exempt). Otherwise: calculates 2.5% fee, enforces 30-day cash-out delay, returns modified count + fee hook spec. |
30
+ | `REVDeployer.afterCashOutRecordedWith(context)` | Permissionless | Cash-out hook callback. Receives fee amount and pays it to the fee revnet's terminal. Falls back to returning funds if fee payment fails. |
31
+ | `REVDeployer.hasMintPermissionFor(revnetId, ruleset, addr)` | View | Returns `true` for: loans contract, buyback hook, buyback hook delegates, or suckers. |
32
32
 
33
33
  ### Split Operator
34
34
 
35
- | Function | What it does |
36
- |----------|-------------|
37
- | `REVDeployer.setSplitOperatorOf(revnetId, newOperator)` | Replace the current split operator. Only callable by the current split operator. Revokes old permissions, grants new ones. |
35
+ | Function | Permissions | What it does |
36
+ |----------|------------|-------------|
37
+ | `REVDeployer.setSplitOperatorOf(revnetId, newOperator)` | Split operator | Replace the current split operator. Revokes old permissions, grants new ones. |
38
38
 
39
39
  ### Auto-Issuance
40
40
 
41
- | Function | What it does |
42
- |----------|-------------|
43
- | `REVDeployer.autoIssueFor(revnetId, stageId, beneficiary)` | **Permissionless.** Mint pre-configured auto-issuance tokens for a beneficiary once a stage has started. One-time per stage per beneficiary. |
44
- | `REVDeployer.burnHeldTokensOf(revnetId)` | **Permissionless.** Burn any reserved tokens held by the deployer (when splits < 100%). |
41
+ | Function | Permissions | What it does |
42
+ |----------|------------|-------------|
43
+ | `REVDeployer.autoIssueFor(revnetId, stageId, beneficiary)` | Permissionless | Mint pre-configured auto-issuance tokens for a beneficiary once a stage has started. One-time per stage per beneficiary. |
44
+ | `REVDeployer.burnHeldTokensOf(revnetId)` | Permissionless | Burn any reserved tokens held by the deployer (when splits < 100%). |
45
45
 
46
46
  ### Loans -- Borrowing
47
47
 
48
- | Function | What it does |
49
- |----------|-------------|
50
- | `REVLoans.borrowFrom(revnetId, source, minBorrowAmount, collateralCount, beneficiary, prepaidFeePercent)` | Open a loan: burn collateral tokens, pull funds from revnet via `useAllowanceOf`, pay REV fee (1%) + terminal fee (2.5%), transfer remainder to beneficiary, mint loan NFT. |
51
- | `REVLoans.repayLoan(loanId, maxRepayAmount, collateralToReturn, beneficiary, allowance)` | Repay fully or partially. Returns funds to revnet via `addToBalanceOf`, re-mints collateral tokens, burns/replaces the loan NFT. Supports permit2 signatures. |
52
- | `REVLoans.reallocateCollateralFromLoan(loanId, collateralToRemove, source, minBorrowAmount, collateralToAdd, beneficiary, prepaidFeePercent)` | Refinance: remove excess collateral from an existing loan and open a new loan with the freed collateral. Burns original, mints two replacements. |
53
- | `REVLoans.liquidateExpiredLoansFrom(revnetId, startingLoanId, count)` | **Permissionless.** Clean up loans past the 10-year liquidation duration. Burns NFTs and decrements accounting totals. Collateral is permanently lost. |
48
+ | Function | Permissions | What it does |
49
+ |----------|------------|-------------|
50
+ | `REVLoans.borrowFrom(revnetId, source, minBorrowAmount, collateralCount, beneficiary, prepaidFeePercent)` | Permissionless (caller must grant BURN_TOKENS to REVLoans) | Open a loan: burn collateral tokens, pull funds from revnet via `useAllowanceOf`, pay REV fee (1%) + terminal fee (2.5%), transfer remainder to beneficiary, mint loan NFT. |
51
+ | `REVLoans.repayLoan(loanId, maxRepayBorrowAmount, collateralCountToReturn, beneficiary, allowance)` | Loan NFT owner | Repay fully or partially. Returns funds to revnet via `addToBalanceOf`, re-mints collateral tokens, burns/replaces the loan NFT. Supports permit2 signatures. |
52
+ | `REVLoans.reallocateCollateralFromLoan(loanId, collateralCountToTransfer, source, minBorrowAmount, collateralCountToAdd, beneficiary, prepaidFeePercent)` | Loan NFT owner | Refinance: remove excess collateral from an existing loan and open a new loan with the freed collateral. Burns original, mints two replacements. |
53
+ | `REVLoans.liquidateExpiredLoansFrom(revnetId, startingLoanId, count)` | Permissionless | Clean up loans past the 10-year liquidation duration. Burns NFTs and decrements accounting totals. Collateral is permanently lost. |
54
+ | `REVLoans.setTokenUriResolver(resolver)` | Contract owner (`onlyOwner`) | Set the `IJBTokenUriResolver` used for loan NFT token URIs. |
54
55
 
55
56
  ### Loans -- Views
56
57
 
@@ -68,7 +69,7 @@ Deploy and manage Revnets -- autonomous, unowned Juicebox projects with staged i
68
69
  |------------|--------|----------|
69
70
  | `@bananapus/core-v6` | `IJBController`, `IJBDirectory`, `IJBPermissions`, `IJBProjects`, `IJBTerminal`, `IJBPrices`, `JBConstants`, `JBCashOuts`, `JBSurplus` | Project lifecycle, rulesets, token minting/burning, fund access, terminal payments, price feeds, bonding curve |
70
71
  | `@bananapus/721-hook-v6` | `IJB721TiersHook`, `IJB721TiersHookDeployer` | Deploying and registering tiered ERC-721 pay hooks |
71
- | `@bananapus/buyback-hook-v6` | `IJBBuybackHook` | Configuring Uniswap buyback pools per revnet |
72
+ | `@bananapus/buyback-hook-v6` | `IJBBuybackHookRegistry` | Configuring Uniswap buyback pools per revnet |
72
73
  | `@bananapus/suckers-v6` | `IJBSuckerRegistry` | Deploying cross-chain suckers, checking sucker status for fee exemption |
73
74
  | `@croptop/core-v6` | `CTPublisher` | Configuring Croptop posting criteria for 721 tiers |
74
75
  | `@bananapus/permission-ids-v6` | `JBPermissionIds` | Permission ID constants (SET_SPLIT_GROUPS, USE_ALLOWANCE, etc.) |
@@ -92,6 +93,71 @@ Deploy and manage Revnets -- autonomous, unowned Juicebox projects with staged i
92
93
  | `REVCroptopAllowedPost` | `category` (uint24), `minimumPrice` (uint104), `minimumTotalSupply` (uint32), `maximumTotalSupply` (uint32), `allowedAddresses[]` | Croptop posting criteria |
93
94
  | `REVSuckerDeploymentConfig` | `deployerConfigurations[]`, `salt` | Cross-chain sucker deployment |
94
95
 
96
+ ## Events
97
+
98
+ ### REVDeployer
99
+
100
+ | Event | When It Fires |
101
+ |-------|---------------|
102
+ | `AutoIssue(revnetId, stageId, beneficiary, count, caller)` | When tokens are auto-issued for a beneficiary during a stage via `autoIssueFor`. |
103
+ | `BurnHeldTokens(revnetId, count, caller)` | When held tokens are burned from the deployer contract via `burnHeldTokensOf`. |
104
+ | `DeployRevnet(revnetId, configuration, terminalConfigurations, suckerDeploymentConfiguration, rulesetConfigurations, encodedConfigurationHash, caller)` | When a new revnet is deployed via `deployFor`. |
105
+ | `DeploySuckers(revnetId, encodedConfigurationHash, suckerDeploymentConfiguration, caller)` | When suckers are deployed for a revnet via `deploySuckersFor`. |
106
+ | `ReplaceSplitOperator(revnetId, newSplitOperator, caller)` | When the split operator of a revnet is replaced via `setSplitOperatorOf`. |
107
+ | `SetCashOutDelay(revnetId, cashOutDelay, caller)` | When the cash out delay is set for a revnet during deployment to a new chain. |
108
+ | `StoreAutoIssuanceAmount(revnetId, stageId, beneficiary, count, caller)` | When an auto-issuance amount is stored for a beneficiary during deployment. |
109
+
110
+ ### REVLoans
111
+
112
+ | Event | When It Fires |
113
+ |-------|---------------|
114
+ | `Borrow(loanId, revnetId, loan, source, borrowAmount, collateralCount, sourceFeeAmount, beneficiary, caller)` | When a loan is created by borrowing from a revnet via `borrowFrom`. |
115
+ | `Liquidate(loanId, revnetId, loan, caller)` | When a loan is liquidated after exceeding the 10-year liquidation duration via `liquidateExpiredLoansFrom`. |
116
+ | `ReallocateCollateral(loanId, revnetId, reallocatedLoanId, reallocatedLoan, removedCollateralCount, caller)` | When collateral is reallocated from one loan to a new loan via `reallocateCollateralFromLoan`. |
117
+ | `RepayLoan(loanId, revnetId, paidOffLoanId, loan, paidOffLoan, repayBorrowAmount, sourceFeeAmount, collateralCountToReturn, beneficiary, caller)` | When a loan is repaid via `repayLoan`. |
118
+ | `SetTokenUriResolver(resolver, caller)` | When the token URI resolver is changed via `setTokenUriResolver`. |
119
+
120
+ ## Errors
121
+
122
+ ### REVDeployer
123
+
124
+ | Error | When It Fires |
125
+ |-------|---------------|
126
+ | `REVDeployer_AutoIssuanceBeneficiaryZeroAddress()` | When an auto-issuance config has a zero-address beneficiary. |
127
+ | `REVDeployer_CashOutDelayNotFinished(cashOutDelay, blockTimestamp)` | When a cash out is attempted before the 30-day delay has elapsed. |
128
+ | `REVDeployer_CashOutsCantBeTurnedOffCompletely(cashOutTaxRate, maxCashOutTaxRate)` | When `cashOutTaxRate` equals `MAX_CASH_OUT_TAX_RATE` (10,000). Must be strictly less. |
129
+ | `REVDeployer_MustHaveSplits()` | When a stage with `splitPercent > 0` has no splits configured. |
130
+ | `REVDeployer_NothingToAutoIssue()` | When `autoIssueFor` is called but no tokens are available for auto-issuance. |
131
+ | `REVDeployer_NothingToBurn()` | When `burnHeldTokensOf` is called but the deployer holds no tokens. |
132
+ | `REVDeployer_RulesetDoesNotAllowDeployingSuckers()` | When `deploySuckersFor` is called but the current ruleset's `extraMetadata` bit 2 is not set. |
133
+ | `REVDeployer_StageNotStarted(stageId)` | When `autoIssueFor` is called for a stage that hasn't started yet. |
134
+ | `REVDeployer_StagesRequired()` | When `deployFor` is called with zero stage configurations. |
135
+ | `REVDeployer_StageTimesMustIncrease()` | When stage `startsAtOrAfter` values are not strictly increasing. |
136
+ | `REVDeployer_Unauthorized(revnetId, caller)` | When a non-split-operator calls a split-operator-only function. |
137
+
138
+ ### REVLoans
139
+
140
+ | Error | When It Fires |
141
+ |-------|---------------|
142
+ | `REVLoans_CollateralExceedsLoan(collateralToReturn, loanCollateral)` | When trying to return more collateral than the loan holds. |
143
+ | `REVLoans_InvalidPrepaidFeePercent(prepaidFeePercent, min, max)` | When `prepaidFeePercent` is outside the allowed range (2.5%--50%). |
144
+ | `REVLoans_InvalidTerminal(terminal, revnetId)` | When the specified terminal is not registered for the revnet. |
145
+ | `REVLoans_LoanExpired(timeSinceLoanCreated, loanLiquidationDuration)` | When trying to repay or reallocate an expired loan. |
146
+ | `REVLoans_LoanIdOverflow()` | When the loan ID counter exceeds the per-revnet trillion-ID namespace. |
147
+ | `REVLoans_NewBorrowAmountGreaterThanLoanAmount(newBorrowAmount, loanAmount)` | When a reallocation would produce a reduced loan with a larger borrow amount than the original. |
148
+ | `REVLoans_NoMsgValueAllowed()` | When `msg.value > 0` on a non-native-token repayment. |
149
+ | `REVLoans_NotEnoughCollateral()` | When the caller does not have enough tokens for the requested collateral. |
150
+ | `REVLoans_NothingToRepay()` | When `repayLoan` is called with zero repay amount and zero collateral to return. |
151
+ | `REVLoans_OverMaxRepayBorrowAmount(maxRepayBorrowAmount, repayBorrowAmount)` | When the actual repay cost exceeds the caller's `maxRepayBorrowAmount`. |
152
+ | `REVLoans_OverflowAlert(value, limit)` | When a value would overflow `uint112` storage. |
153
+ | `REVLoans_PermitAllowanceNotEnough(allowanceAmount, requiredAmount)` | When the permit2 allowance is insufficient for the repayment. |
154
+ | `REVLoans_ReallocatingMoreCollateralThanBorrowedAmountAllows(newBorrowAmount, loanAmount)` | When the collateral being transferred out would leave the original loan undercollateralized. |
155
+ | `REVLoans_SourceMismatch()` | When `reallocateCollateralFromLoan` specifies a source that doesn't match the existing loan's source. |
156
+ | `REVLoans_Unauthorized(caller, owner)` | When a non-owner tries to repay or reallocate someone else's loan. |
157
+ | `REVLoans_UnderMinBorrowAmount(minBorrowAmount, borrowAmount)` | When the actual borrow amount is less than the caller's `minBorrowAmount`. |
158
+ | `REVLoans_ZeroBorrowAmount()` | When a borrow or reallocation would result in zero borrowed funds. |
159
+ | `REVLoans_ZeroCollateralLoanIsInvalid()` | When a loan would end up with zero collateral. |
160
+
95
161
  ## Constants
96
162
 
97
163
  ### REVDeployer
@@ -102,6 +168,7 @@ Deploy and manage Revnets -- autonomous, unowned Juicebox projects with staged i
102
168
  | `FEE` | 25 (of MAX_FEE=1000) | 2.5% cash-out fee paid to fee revnet |
103
169
  | `DEFAULT_BUYBACK_POOL_FEE` | 10,000 | 1% Uniswap fee tier for default buyback pools |
104
170
  | `DEFAULT_BUYBACK_TWAP_WINDOW` | 2 days | TWAP observation window for buyback price |
171
+ | `DEFAULT_BUYBACK_TICK_SPACING` | 200 | Tick spacing for default buyback V4 pools |
105
172
 
106
173
  ### REVLoans
107
174
 
@@ -117,24 +184,25 @@ Deploy and manage Revnets -- autonomous, unowned Juicebox projects with staged i
117
184
 
118
185
  ### REVDeployer
119
186
 
120
- | Mapping | Type | Purpose |
121
- |---------|------|---------|
122
- | `amountToAutoIssue` | `revnetId => stageId => beneficiary => uint256` | Premint tokens per stage per beneficiary |
123
- | `cashOutDelayOf` | `revnetId => uint256` | Timestamp when cash outs unlock (0 = no delay) |
124
- | `hashedEncodedConfigurationOf` | `revnetId => bytes32` | Config hash for cross-chain sucker validation |
125
- | `tiered721HookOf` | `revnetId => address` | Deployed 721 hook address (if any) |
126
- | `_extraOperatorPermissions` | `revnetId => uint256[]` | Custom permissions for split operator |
187
+ | Mapping | Visibility | Type | Purpose |
188
+ |---------|-----------|------|---------|
189
+ | `amountToAutoIssue` | `public` | `revnetId => stageId => beneficiary => uint256` | Premint tokens per stage per beneficiary |
190
+ | `cashOutDelayOf` | `public` | `revnetId => uint256` | Timestamp when cash outs unlock (0 = no delay) |
191
+ | `hashedEncodedConfigurationOf` | `public` | `revnetId => bytes32` | Config hash for cross-chain sucker validation |
192
+ | `tiered721HookOf` | `public` | `revnetId => address` | Deployed 721 hook address (if any) |
193
+ | `_extraOperatorPermissions` | `internal` | `revnetId => uint256[]` | Custom permissions for split operator (no auto-getter) |
127
194
 
128
195
  ### REVLoans
129
196
 
130
- | Mapping | Type | Purpose |
131
- |---------|------|---------|
132
- | `isLoanSourceOf` | `revnetId => terminal => token => bool` | Is this (terminal, token) pair used for loans? |
133
- | `totalLoansBorrowedFor` | `revnetId => uint256` | Counter for loan numbering |
134
- | `totalBorrowedFrom` | `revnetId => terminal => token => uint256` | Tracks debt per loan source |
135
- | `totalCollateralOf` | `revnetId => uint256` | Sum of all burned collateral |
136
- | `_loanOf` | `loanId => REVLoan` | Per-loan state |
137
- | `_loanSourcesOf` | `revnetId => REVLoanSource[]` | Array of all loan sources used |
197
+ | Mapping | Visibility | Type | Purpose |
198
+ |---------|-----------|------|---------|
199
+ | `isLoanSourceOf` | `public` | `revnetId => terminal => token => bool` | Is this (terminal, token) pair used for loans? |
200
+ | `totalLoansBorrowedFor` | `public` | `revnetId => uint256` | Counter for loan numbering |
201
+ | `totalBorrowedFrom` | `public` | `revnetId => terminal => token => uint256` | Tracks debt per loan source |
202
+ | `totalCollateralOf` | `public` | `revnetId => uint256` | Sum of all burned collateral |
203
+ | `_loanOf` | `internal` | `loanId => REVLoan` | Per-loan state (use `loanOf(loanId)` view) |
204
+ | `_loanSourcesOf` | `internal` | `revnetId => REVLoanSource[]` | Array of all loan sources used (use `loanSourcesOf(revnetId)` view) |
205
+ | `tokenUriResolver` | `public` | `IJBTokenUriResolver` | Resolver for loan NFT token URIs |
138
206
 
139
207
  ## Gotchas
140
208
 
@@ -154,10 +222,10 @@ Deploy and manage Revnets -- autonomous, unowned Juicebox projects with staged i
154
222
  14. **Fee revnet must have terminals.** Cash-out fees and loan protocol fees are paid to `FEE_REVNET_ID`. If that project has no terminal for the token, the fee silently fails (try-catch).
155
223
  15. **Buyback hook is immutable per deployer.** `REVDeployer.BUYBACK_HOOK` is set at construction time. All revnets deployed by the same deployer share the same buyback hook.
156
224
  16. **Cross-chain config matching.** `hashedEncodedConfigurationOf` covers economic parameters (baseCurrency, stages, auto-issuances) but NOT terminal configurations, accounting contexts, or sucker token mappings. Two deployments with identical hashes can have different terminal setups.
157
- 17. **Loan fee model.** Three layers: (1) REV protocol fee (1%) taken when funds pulled, (2) terminal fee (2.5%) charged by `useAllowanceOf`, (3) prepaid source fee (2.5%-50%, borrower-chosen) that buys an interest-free window. After the prepaid window, time-proportional source fee accrues linearly over the remaining 10-year loan duration.
225
+ 17. **Loan fee model has three layers.** See Constants table for exact values: REV protocol fee, terminal fee, and prepaid source fee (borrower-chosen, buys interest-free window). After the prepaid window, source fee accrues linearly over the remaining loan duration.
158
226
  18. **Permit2 fallback.** `REVLoans` uses permit2 for ERC-20 transfers as a fallback when standard allowance is insufficient. Wrapped in try-catch.
159
227
  19. **39.16% cash-out tax crossover.** Below ~39% cash-out tax, cashing out is more capital-efficient than borrowing. Above ~39%, loans become more efficient because they preserve upside while providing liquidity. Based on CryptoEconLab academic research. Design implication: revnets intended for active token trading should consider this threshold when setting `cashOutTaxRate`.
160
- 20. **REVDeployer always deploys a 721 hook** via `HOOK_DEPLOYER.deployHookFor` — even if `baseline721HookConfiguration` has empty tiers. This is correct by design: it lets the split operator add and sell NFTs later without migration. Non-revnet projects should follow the same pattern by using `JB721TiersHookProjectDeployer.launchProjectFor` (or `JBOmnichainDeployer.launch721ProjectFor`) instead of bare `launchProjectFor`.
228
+ 20. **REVDeployer always deploys a 721 hook** via `HOOK_DEPLOYER.deployHookFor` — even if `baseline721HookConfiguration` has empty tiers. This is correct by design: it lets the split operator add and sell NFTs later without migration. Non-revnet projects should follow the same pattern by using `JB721TiersHookProjectDeployer.launchProjectFor` (or `JBOmnichainDeployer.launchProjectFor`) instead of bare `launchProjectFor`.
161
229
 
162
230
  ### NATIVE_TOKEN Accounting on Non-ETH Chains
163
231
 
@@ -181,6 +249,60 @@ JBAccountingContext({
181
249
  })
182
250
  ```
183
251
 
252
+ ## Reading Revnet State
253
+
254
+ Quick-reference for common read operations. All functions are `view`/`pure` and permissionless.
255
+
256
+ ### Current Stage & Ruleset
257
+
258
+ | What | Call | Returns |
259
+ |------|------|---------|
260
+ | Current ruleset (stage) | `IJBController(CONTROLLER).currentRulesetOf(revnetId)` | `(JBRuleset, JBRulesetMetadata)` -- the active stage's parameters |
261
+ | All queued rulesets | `IJBController(CONTROLLER).allRulesetsOf(revnetId, startingId, size)` | `JBRulesetWithMetadata[]` -- paginated list of stages |
262
+ | Specific stage by ID | `IJBController(CONTROLLER).getRulesetOf(revnetId, stageId)` | `(JBRuleset, JBRulesetMetadata)` for that stage |
263
+
264
+ ### Split Operator
265
+
266
+ | What | Call | Returns |
267
+ |------|------|---------|
268
+ | Check if address is split operator | `REVDeployer.isSplitOperatorOf(revnetId, addr)` | `bool` |
269
+
270
+ ### Token Supply & Surplus
271
+
272
+ | What | Call | Returns |
273
+ |------|------|---------|
274
+ | Total supply (incl. pending reserved) | `IJBController(CONTROLLER).totalTokenSupplyWithReservedTokensOf(revnetId)` | `uint256` |
275
+ | Pending reserved tokens | `IJBController(CONTROLLER).pendingReservedTokenBalanceOf(revnetId)` | `uint256` |
276
+ | Current surplus (single terminal) | `IJBTerminalStore(STORE).currentSurplusOf(terminal, revnetId, configs, decimals, currency)` | `uint256` |
277
+
278
+ ### Auto-Issuance
279
+
280
+ | What | Call | Returns |
281
+ |------|------|---------|
282
+ | Remaining auto-issuance for beneficiary | `REVDeployer.amountToAutoIssue(revnetId, stageId, beneficiary)` | `uint256` (0 if already claimed) |
283
+
284
+ ### Loans
285
+
286
+ | What | Call | Returns |
287
+ |------|------|---------|
288
+ | Borrowable amount for collateral | `REVLoans.borrowableAmountFrom(revnetId, collateralCount, decimals, currency)` | `uint256` |
289
+ | Total borrowed (per source) | `REVLoans.totalBorrowedFrom(revnetId, terminal, token)` | `uint256` |
290
+ | Total collateral locked | `REVLoans.totalCollateralOf(revnetId)` | `uint256` |
291
+ | Loan details | `REVLoans.loanOf(loanId)` | `REVLoan` struct |
292
+ | All loan sources | `REVLoans.loanSourcesOf(revnetId)` | `REVLoanSource[]` |
293
+ | Loan count | `REVLoans.totalLoansBorrowedFor(revnetId)` | `uint256` |
294
+ | Source fee for repayment | `REVLoans.determineSourceFeeAmount(loan, amount)` | `uint256` |
295
+ | Revnet ID from loan ID | `REVLoans.revnetIdOfLoanWith(loanId)` | `uint256` (pure) |
296
+ | Loan NFT owner | `REVLoans.ownerOf(loanId)` | `address` (ERC-721) |
297
+
298
+ ### Deployer Config
299
+
300
+ | What | Call | Returns |
301
+ |------|------|---------|
302
+ | Config hash (cross-chain matching) | `REVDeployer.hashedEncodedConfigurationOf(revnetId)` | `bytes32` |
303
+ | 721 hook address | `REVDeployer.tiered721HookOf(revnetId)` | `IJB721TiersHook` |
304
+ | Cash-out delay timestamp | `REVDeployer.cashOutDelayOf(revnetId)` | `uint256` (0 = no delay) |
305
+
184
306
  ## Example Integration
185
307
 
186
308
  ```solidity
@@ -258,9 +380,9 @@ loans.borrowFrom({
258
380
 
259
381
  loans.repayLoan({
260
382
  loanId: loanId,
261
- maxRepayAmount: type(uint256).max, // Repay in full
262
- collateralToReturn: loan.collateral, // Return all collateral
263
- beneficiary: msg.sender, // Receive re-minted tokens
264
- allowance: IAllowanceTransfer.PermitSingle({ ... }) // Optional permit2
383
+ maxRepayBorrowAmount: type(uint256).max, // Repay in full
384
+ collateralCountToReturn: loan.collateral, // Return all collateral
385
+ beneficiary: msg.sender, // Receive re-minted tokens
386
+ allowance: JBSingleAllowance({ ... }) // Optional permit2
265
387
  });
266
388
  ```
package/STYLE_GUIDE.md CHANGED
@@ -21,7 +21,7 @@ One contract/interface/struct/enum per file. Name the file after the type it con
21
21
 
22
22
  ```solidity
23
23
  // Contracts — pin to exact version
24
- pragma solidity 0.8.26;
24
+ pragma solidity ^0.8.26;
25
25
 
26
26
  // Interfaces, structs, enums — caret for forward compatibility
27
27
  pragma solidity ^0.8.0;
package/USER_JOURNEYS.md CHANGED
@@ -74,6 +74,12 @@ Or: `REVDeployer.deployFor(revnetId=0, configuration, terminalConfigurations, su
74
74
  5. Cash-out delay applied if first stage has already started
75
75
  6. Same remaining steps as new deployment (ERC-20, buyback pools, suckers, 721 hook)
76
76
 
77
+ **Events:**
78
+ - `DeployRevnet(revnetId, configuration, terminalConfigurations, suckerDeploymentConfiguration, rulesetConfigurations, encodedConfigurationHash, caller)`
79
+ - `StoreAutoIssuanceAmount(revnetId, stageId, beneficiary, count, caller)` for each auto-issuance on this chain
80
+ - `DeploySuckers(...)` if suckers are deployed
81
+ - `SetCashOutDelay(revnetId, cashOutDelay, caller)` if applicable
82
+
77
83
  **Edge cases:**
78
84
  - This is a **one-way operation**. The project NFT is permanently locked in REVDeployer.
79
85
  - `launchRulesetsFor` reverts if rulesets already exist. `setControllerOf` reverts if a controller is already set.
@@ -102,6 +108,10 @@ This is a standard Juicebox payment, but REVDeployer intervenes as the data hook
102
108
  - 721 hook processes tier purchases
103
109
  - Buyback hook processes swap (if applicable)
104
110
 
111
+ **Preview**: Call `JBMultiTerminal.previewPayFor(revnetId, token, amount, beneficiary, metadata)` to simulate the full payment including REVDeployer's data hook effects (buyback routing, 721 tier splits, weight adjustment). Returns the expected token count and hook specifications. When the buyback hook is active, noop specs may carry routing diagnostics (TWAP tick, liquidity, pool ID) even when the protocol mint path wins.
112
+
113
+ **Events:** No revnet-specific events. The payment is handled by `JBMultiTerminal` and `JBController` (see nana-core-v6). REVDeployer's `beforePayRecordedWith` is a `view` function and emits nothing.
114
+
105
115
  **Edge cases:**
106
116
  - If the buyback hook determines a DEX swap is better, weight = 0 and the buyback hook spec receives the full project amount. The buyback hook buys tokens on the DEX and mints them to the payer.
107
117
  - If `totalSplitAmount >= context.amount.value`, `projectAmount = 0`, weight = 0, and no tokens are minted by the terminal. All funds go to 721 tier splits.
@@ -133,12 +143,16 @@ This is a standard Juicebox payment, but REVDeployer intervenes as the data hook
133
143
  - Pays fee to fee revnet's terminal via `feeTerminal.pay`
134
144
  - On failure: returns funds to the originating project via `addToBalanceOf`
135
145
 
146
+ **Preview**: Call `JBMultiTerminal.previewCashOutFrom(holder, revnetId, cashOutCount, tokenToReclaim, beneficiary, metadata)` to simulate the full cash out including REVDeployer's data hook effects (fee splitting, tax rate). Returns the expected reclaim amount and hook specifications. For a simpler estimate without data hook effects, use `JBTerminalStore.currentTotalReclaimableSurplusOf(revnetId, cashOutCount, decimals, currency)`.
147
+
148
+ **Events:** No revnet-specific events. Cash-out events are emitted by `JBMultiTerminal` and `JBController`. REVDeployer's `beforeCashOutRecordedWith` is a `view` function. The `afterCashOutRecordedWith` hook processes fees but does not emit events.
149
+
136
150
  **Edge cases:**
137
151
  - Suckers bypass both the cash-out fee AND the cash-out delay. The `_isSuckerOf` check is the only gate.
138
- - `cashOutTaxRate == 0` means no tax and no revnet fee (but the terminal's 2.5% protocol fee still applies).
152
+ - `cashOutTaxRate == 0` means no tax and no revnet fee. The terminal's 2.5% protocol fee only applies up to the `feeFreeSurplusOf` amount (round-trip prevention), not the full reclaim.
139
153
  - Micro cash-outs (< 40 wei at 2.5%) round `feeCashOutCount` to 0, bypassing the fee. Gas cost far exceeds the bypassed fee.
140
154
  - The fee is paid to `FEE_REVNET_ID`, not `REV_ID`. These may be different projects.
141
- - Cash-out fees stack: the terminal takes its 2.5% protocol fee, THEN the revnet takes its 2.5% fee from the remaining amount.
155
+ - Both the revnet fee and the terminal protocol fee apply. The revnet fee is computed first (at the data hook level, by splitting the cashout token count into fee and non-fee portions), then the terminal's 2.5% protocol fee is applied to all outbound fund amounts (both the beneficiary's reclaim and the hook-forwarded fee amount).
142
156
 
143
157
  ---
144
158
 
@@ -185,7 +199,7 @@ This is a standard Juicebox payment, but REVDeployer intervenes as the data hook
185
199
  - Pays REV fee (1%) to `REV_ID` via `feeTerminal.pay` (try-catch; zeroed on failure)
186
200
  - Transfers remaining: `netAmountPaidOut - revFeeAmount - sourceFeeAmount` to beneficiary
187
201
  - `_addCollateralTo`: increments `totalCollateralOf`, burns collateral via `CONTROLLER.burnTokensOf`
188
- - Pays source fee to revnet via `terminal.pay` (NOT try-catch -- reverts on failure)
202
+ - Pays source fee to revnet via `terminal.pay` (try-catch on failure, returns fee amount to beneficiary)
189
203
  8. **Mint loan ERC-721** to `_msgSender()`
190
204
 
191
205
  **Events:** `Borrow(loanId, revnetId, loan, source, borrowAmount, collateralCount, sourceFeeAmount, beneficiary, caller)`
@@ -194,7 +208,7 @@ This is a standard Juicebox payment, but REVDeployer intervenes as the data hook
194
208
  - Revnets always deploy an ERC-20 at creation, so collateral is always ERC-20 tokens (never credits).
195
209
  - The `minBorrowAmount` check is against the raw bonding curve output, BEFORE fees are deducted. The actual amount received is less.
196
210
  - `prepaidDuration` at minimum (25): `25 * 3650 days / 500 = 182.5 days`. At maximum (500): `500 * 3650 days / 500 = 3650 days`.
197
- - The REV fee payment failure is non-fatal (borrower gets the fee amount instead). The source fee payment failure IS fatal (entire transaction reverts).
211
+ - Both the REV fee payment and the source fee payment failures are non-fatal. If either `feeTerminal.pay` or `source.terminal.pay` reverts, the fee amount is transferred to the beneficiary instead.
198
212
  - Loan NFT is minted to `_msgSender()`, not `beneficiary`. The caller owns the loan; the beneficiary receives the funds.
199
213
 
200
214
  ---
@@ -224,7 +238,7 @@ This is a standard Juicebox payment, but REVDeployer intervenes as the data hook
224
238
  4. **Nothing-to-do check:** Reverts if `repayBorrowAmount == 0 && collateralCountToReturn == 0`
225
239
  5. **Source fee calculation:**
226
240
  - If within prepaid window (`timeSinceCreated <= prepaidDuration`): fee = 0
227
- - If expired (`timeSinceCreated >= LOAN_LIQUIDATION_DURATION`): revert `REVLoans_LoanExpired`
241
+ - If expired (`timeSinceCreated > LOAN_LIQUIDATION_DURATION`): revert `REVLoans_LoanExpired`
228
242
  - Otherwise: linear fee based on time elapsed beyond prepaid window
229
243
  - `repayBorrowAmount += sourceFeeAmount` (fee added to repayment)
230
244
  6. **Accept funds:** `_acceptFundsFor` handles native token (uses `msg.value`) or ERC-20 (with optional permit2)
@@ -378,7 +392,7 @@ This is a standard Juicebox payment, but REVDeployer intervenes as the data hook
378
392
  2. Reverts with `REVDeployer_NothingToBurn` if balance is 0
379
393
  3. Burns all held tokens via `CONTROLLER.burnTokensOf`
380
394
 
381
- **Events:** `BurnHeldTokens(revnetId, balance, caller)`
395
+ **Events:** `BurnHeldTokens(revnetId, count, caller)`
382
396
 
383
397
  ---
384
398
 
package/foundry.toml CHANGED
@@ -14,6 +14,13 @@ runs = 1024
14
14
  depth = 100
15
15
  fail_on_revert = false
16
16
 
17
+ [profile.ci.fuzz]
18
+ runs = 256
19
+
20
+ [profile.ci.invariant]
21
+ runs = 64
22
+ depth = 50
23
+
17
24
  [lint]
18
25
  lint_on_build = false
19
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rev-net/core-v6",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,7 +10,6 @@
10
10
  "node": ">=20.0.0"
11
11
  },
12
12
  "scripts": {
13
- "postinstall": "find node_modules -name '*.sol' -type f | xargs grep -l 'pragma solidity 0.8.23;' 2>/dev/null | xargs sed -i '' 's/pragma solidity 0.8.23;/pragma solidity 0.8.26;/g' 2>/dev/null || true",
14
13
  "test": "forge test",
15
14
  "coverage": "forge coverage --match-path \"./src/*.sol\" --report lcov --report summary",
16
15
  "deploy:mainnets": "source ./.env && export START_TIME=$(date +%s) && npx sphinx propose ./script/Deploy.s.sol --networks mainnets",
@@ -20,14 +19,14 @@
20
19
  "artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'revnet-core-v6'"
21
20
  },
22
21
  "dependencies": {
23
- "@bananapus/721-hook-v6": "^0.0.17",
24
- "@bananapus/buyback-hook-v6": "^0.0.13",
25
- "@bananapus/core-v6": "^0.0.17",
26
- "@bananapus/ownable-v6": "^0.0.10",
27
- "@bananapus/permission-ids-v6": "^0.0.10",
28
- "@bananapus/router-terminal-v6": "^0.0.13",
29
- "@bananapus/suckers-v6": "^0.0.11",
30
- "@croptop/core-v6": "^0.0.18",
22
+ "@bananapus/721-hook-v6": "^0.0.20",
23
+ "@bananapus/buyback-hook-v6": "^0.0.19",
24
+ "@bananapus/core-v6": "^0.0.26",
25
+ "@bananapus/ownable-v6": "^0.0.13",
26
+ "@bananapus/permission-ids-v6": "^0.0.12",
27
+ "@bananapus/router-terminal-v6": "^0.0.19",
28
+ "@bananapus/suckers-v6": "^0.0.16",
29
+ "@croptop/core-v6": "^0.0.21",
31
30
  "@openzeppelin/contracts": "^5.6.1",
32
31
  "@uniswap/v4-core": "^1.0.2",
33
32
  "@uniswap/v4-periphery": "^1.0.3"
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity ^0.8.26;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "@bananapus/721-hook-v6/script/helpers/Hook721DeploymentLib.sol";
@@ -342,20 +342,86 @@ contract DeployScript is Script, Sphinx {
342
342
  }
343
343
 
344
344
  function deploy() public sphinx {
345
- // TODO figure out how to reference project ID if the contracts are already deployed.
345
+ // Check if singletons are already deployed before creating a new fee project.
346
+ // This prevents creating orphan projects on script restarts.
346
347
  // forge-lint: disable-next-line(mixed-case-variable)
347
- uint256 FEE_PROJECT_ID = core.projects.createFor(safeAddress());
348
+ uint256 FEE_PROJECT_ID;
349
+
350
+ // Predict the REVLoans address for an arbitrary fee project ID to check if it exists.
351
+ // We can't predict without a fee project ID, so we first check the REVDeployer which also stores it.
352
+ // Try a two-step approach: compute addresses assuming singletons exist, then check.
353
+
354
+ // First, check if REVLoans is already deployed by trying with project ID = 0 (placeholder).
355
+ // We need to iterate: if either singleton exists, extract the fee project ID from it.
356
+ // Since both encode FEE_PROJECT_ID, we check if any code exists at the predicted address
357
+ // for sequential project IDs starting from 1.
358
+
359
+ // A simpler approach: predict the REVDeployer address for each possible fee project ID
360
+ // until we find one that's deployed, or give up and create a new one.
361
+ // In practice, the fee project is always one of the first few projects created.
362
+
363
+ // Check the next project ID that would be created — if singletons were deployed with a previous ID,
364
+ // that ID must be less than the current count.
365
+ uint256 _nextProjectId = core.projects.count() + 1;
366
+
367
+ // Try to find an existing deployment by checking all project IDs that have already been created.
368
+ // Whether previously deployed singletons were found.
369
+ bool _singletonsExist;
370
+ // The address of the previously deployed REVLoans, if found.
371
+ address _existingRevloansAddr;
372
+ // The address of the previously deployed REVDeployer, if found.
373
+ address _existingDeployerAddr;
374
+
375
+ for (uint256 _candidateId = 1; _candidateId < _nextProjectId; _candidateId++) {
376
+ // Predict REVLoans address for this candidate fee project ID.
377
+ (address _candidateRevloansAddr, bool _candidateRevloansDeployed) = _isDeployed({
378
+ salt: REVLOANS_SALT,
379
+ creationCode: type(REVLoans).creationCode,
380
+ arguments: abi.encode(
381
+ core.controller, core.projects, _candidateId, LOANS_OWNER, PERMIT2, TRUSTED_FORWARDER
382
+ )
383
+ });
384
+
385
+ if (_candidateRevloansDeployed) {
386
+ // Verify the fee project ID matches by reading the immutable.
387
+ if (REVLoans(payable(_candidateRevloansAddr)).REV_ID() == _candidateId) {
388
+ // Record the fee project ID from the existing deployment.
389
+ FEE_PROJECT_ID = _candidateId;
390
+ // Record the existing REVLoans address.
391
+ _existingRevloansAddr = _candidateRevloansAddr;
392
+ // Flag that singletons were found.
393
+ _singletonsExist = true;
394
+
395
+ // Also predict and verify the deployer.
396
+ (_existingDeployerAddr,) = _isDeployed({
397
+ salt: DEPLOYER_SALT,
398
+ creationCode: type(REVDeployer).creationCode,
399
+ arguments: abi.encode(
400
+ core.controller,
401
+ suckers.registry,
402
+ _candidateId,
403
+ hook.hook_deployer,
404
+ croptop.publisher,
405
+ IJBBuybackHookRegistry(address(buybackHook.registry)),
406
+ _candidateRevloansAddr,
407
+ TRUSTED_FORWARDER
408
+ )
409
+ });
410
+ // Stop searching — we found the deployed singletons.
411
+ break;
412
+ }
413
+ }
414
+ }
415
+
416
+ // Only create a new fee project if no singletons were found.
417
+ if (!_singletonsExist) {
418
+ // forge-lint: disable-next-line(mixed-case-variable)
419
+ FEE_PROJECT_ID = core.projects.createFor(safeAddress());
420
+ }
348
421
 
349
422
  // Deploy REVLoans first — it only depends on the controller.
350
- (address _revloansAddr, bool _revloansIsDeployed) = _isDeployed({
351
- salt: REVLOANS_SALT,
352
- creationCode: type(REVLoans).creationCode,
353
- arguments: abi.encode(
354
- core.controller, core.projects, FEE_PROJECT_ID, LOANS_OWNER, PERMIT2, TRUSTED_FORWARDER
355
- )
356
- });
357
- REVLoans revloans = _revloansIsDeployed
358
- ? REVLoans(payable(_revloansAddr))
423
+ REVLoans revloans = _singletonsExist
424
+ ? REVLoans(payable(_existingRevloansAddr))
359
425
  : new REVLoans{salt: REVLOANS_SALT}({
360
426
  controller: core.controller,
361
427
  projects: core.projects,
@@ -410,6 +476,8 @@ contract DeployScript is Script, Sphinx {
410
476
  });
411
477
  }
412
478
 
479
+ /// @notice Check whether a contract has already been deployed at its deterministic address.
480
+ /// @dev Uses the Arachnid deterministic-deployment-proxy address to predict the CREATE2 address.
413
481
  function _isDeployed(
414
482
  bytes32 salt,
415
483
  bytes memory creationCode,
@@ -419,10 +487,6 @@ contract DeployScript is Script, Sphinx {
419
487
  view
420
488
  returns (address deployedTo, bool isDeployed)
421
489
  {
422
- // Note: This uses the Arachnid deterministic-deployment-proxy address, which differs from
423
- // the Sphinx deployer used at runtime. As a result, the predicted address won't match and
424
- // _isDeployed will always return false when deploying via Sphinx. This is benign — it just
425
- // means contracts are always freshly deployed rather than skipped.
426
490
  address _deployedTo = vm.computeCreate2Address({
427
491
  salt: salt,
428
492
  initCodeHash: keccak256(abi.encodePacked(creationCode, arguments)),
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity ^0.8.26;
3
3
 
4
4
  import {stdJson} from "forge-std/Script.sol";
5
5
  import {Vm} from "forge-std/Vm.sol";