@rev-net/core-v6 0.0.7 → 0.0.9

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 (59) hide show
  1. package/ADMINISTRATION.md +186 -0
  2. package/ARCHITECTURE.md +87 -0
  3. package/README.md +4 -2
  4. package/RISKS.md +49 -0
  5. package/SKILLS.md +22 -2
  6. package/STYLE_GUIDE.md +482 -0
  7. package/foundry.toml +6 -6
  8. package/package.json +13 -10
  9. package/script/Deploy.s.sol +3 -2
  10. package/src/REVDeployer.sol +129 -72
  11. package/src/REVLoans.sol +174 -165
  12. package/src/interfaces/IREVDeployer.sol +111 -72
  13. package/src/interfaces/IREVLoans.sol +116 -76
  14. package/src/structs/REV721TiersHookFlags.sol +14 -0
  15. package/src/structs/REVBaseline721HookConfig.sol +27 -0
  16. package/src/structs/REVDeploy721TiersHookConfig.sol +2 -2
  17. package/test/REV.integrations.t.sol +4 -3
  18. package/test/REVAutoIssuanceFuzz.t.sol +12 -8
  19. package/test/REVDeployerAuditRegressions.t.sol +4 -3
  20. package/test/REVInvincibility.t.sol +8 -6
  21. package/test/REVInvincibilityHandler.sol +1 -0
  22. package/test/REVLifecycle.t.sol +4 -3
  23. package/test/REVLoans.invariants.t.sol +5 -3
  24. package/test/REVLoansAttacks.t.sol +4 -3
  25. package/test/REVLoansAuditRegressions.t.sol +13 -24
  26. package/test/REVLoansFeeRecovery.t.sol +4 -3
  27. package/test/REVLoansSourced.t.sol +4 -3
  28. package/test/REVLoansUnSourced.t.sol +4 -3
  29. package/test/REVLoans_AuditFindings.t.sol +644 -0
  30. package/test/TestEmptyBuybackSpecs.t.sol +4 -3
  31. package/test/TestPR09_ConversionDocumentation.t.sol +4 -3
  32. package/test/TestPR10_LiquidationBehavior.t.sol +4 -3
  33. package/test/TestPR11_LowFindings.t.sol +4 -3
  34. package/test/TestPR12_FlashLoanSurplus.t.sol +4 -3
  35. package/test/TestPR13_CrossSourceReallocation.t.sol +4 -3
  36. package/test/TestPR15_CashOutCallerValidation.t.sol +4 -3
  37. package/test/TestPR16_ZeroRepayment.t.sol +4 -3
  38. package/test/TestPR21_Uint112Overflow.t.sol +4 -3
  39. package/test/TestPR22_HookArrayOOB.t.sol +4 -3
  40. package/test/TestPR26_BurnHeldTokens.t.sol +4 -3
  41. package/test/TestPR27_CEIPattern.t.sol +4 -3
  42. package/test/TestPR29_SwapTerminalPermission.t.sol +4 -3
  43. package/test/TestPR32_MixedFixes.t.sol +4 -3
  44. package/test/TestSplitWeightAdjustment.t.sol +445 -0
  45. package/test/TestSplitWeightE2E.t.sol +528 -0
  46. package/test/TestSplitWeightFork.t.sol +821 -0
  47. package/test/TestStageTransitionBorrowable.t.sol +4 -3
  48. package/test/fork/ForkTestBase.sol +617 -0
  49. package/test/fork/TestCashOutFork.t.sol +245 -0
  50. package/test/fork/TestLoanBorrowFork.t.sol +163 -0
  51. package/test/fork/TestLoanLiquidationFork.t.sol +129 -0
  52. package/test/fork/TestLoanReallocateFork.t.sol +103 -0
  53. package/test/fork/TestLoanRepayFork.t.sol +184 -0
  54. package/test/fork/TestSplitWeightFork.t.sol +186 -0
  55. package/test/mock/MockBuybackDataHook.sol +11 -4
  56. package/test/mock/MockBuybackDataHookMintPath.sol +11 -3
  57. package/test/regression/TestI20_CumulativeLoanCounter.t.sol +6 -5
  58. package/test/regression/TestL27_LiquidateGapHandling.t.sol +7 -6
  59. package/SECURITY.md +0 -68
@@ -29,7 +29,7 @@ import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/
29
29
  /// @notice Documents and verifies that stage transitions change the borrowable amount for the same collateral.
30
30
  /// This is by design: loan value tracks the current bonding curve parameters (cashOutTaxRate),
31
31
  /// just as cash-out value does.
32
- contract TestStageTransitionBorrowable is TestBaseWorkflow, JBTest {
32
+ contract TestStageTransitionBorrowable is TestBaseWorkflow {
33
33
  bytes32 REV_DEPLOYER_SALT = "REVDeployer";
34
34
 
35
35
  REVDeployer REV_DEPLOYER;
@@ -110,7 +110,8 @@ contract TestStageTransitionBorrowable is TestBaseWorkflow, JBTest {
110
110
  FEE_PROJECT_ID = jbProjects().createFor(multisig());
111
111
  SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
112
112
  HOOK_STORE = new JB721TiersHookStore();
113
- EXAMPLE_HOOK = new JB721TiersHook(jbDirectory(), jbPermissions(), jbRulesets(), HOOK_STORE, multisig());
113
+ EXAMPLE_HOOK =
114
+ new JB721TiersHook(jbDirectory(), jbPermissions(), jbRulesets(), HOOK_STORE, jbSplits(), multisig());
114
115
  ADDRESS_REGISTRY = new JBAddressRegistry();
115
116
  HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
116
117
  PUBLISHER = new CTPublisher(jbDirectory(), jbPermissions(), FEE_PROJECT_ID, multisig());
@@ -129,7 +130,7 @@ contract TestStageTransitionBorrowable is TestBaseWorkflow, JBTest {
129
130
  FEE_PROJECT_ID,
130
131
  HOOK_DEPLOYER,
131
132
  PUBLISHER,
132
- IJBRulesetDataHook(address(MOCK_BUYBACK)),
133
+ IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
133
134
  address(LOANS_CONTRACT),
134
135
  TRUSTED_FORWARDER
135
136
  );
@@ -0,0 +1,617 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.26;
3
+
4
+ import "forge-std/Test.sol";
5
+ import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
6
+ import /* {*} from */ "../../src/REVDeployer.sol";
7
+ import "@croptop/core-v6/src/CTPublisher.sol";
8
+ import "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
9
+ import "@bananapus/721-hook-v6/script/helpers/Hook721DeploymentLib.sol";
10
+ import "@bananapus/suckers-v6/script/helpers/SuckerDeploymentLib.sol";
11
+ import "@croptop/core-v6/script/helpers/CroptopDeploymentLib.sol";
12
+ import "@bananapus/router-terminal-v6/script/helpers/RouterTerminalDeploymentLib.sol";
13
+
14
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
15
+ import {JBMetadataResolver} from "@bananapus/core-v6/src/libraries/JBMetadataResolver.sol";
16
+ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
17
+ import {REVLoans} from "../../src/REVLoans.sol";
18
+ import {REVLoan} from "../../src/structs/REVLoan.sol";
19
+ import {REVStageConfig, REVAutoIssuance} from "../../src/structs/REVStageConfig.sol";
20
+ import {REVLoanSource} from "../../src/structs/REVLoanSource.sol";
21
+ import {REVDescription} from "../../src/structs/REVDescription.sol";
22
+ import {IREVLoans} from "../../src/interfaces/IREVLoans.sol";
23
+ import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSuckerDeployerConfig.sol";
24
+ import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
25
+ import {JB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
26
+ import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
27
+ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
28
+ import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
29
+ import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
30
+ import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
31
+ import {IJBBuybackHookRegistry} from "@bananapus/buyback-hook-v6/src/interfaces/IJBBuybackHookRegistry.sol";
32
+ import {IJBPayHook} from "@bananapus/core-v6/src/interfaces/IJBPayHook.sol";
33
+ import {JB721TierConfig} from "@bananapus/721-hook-v6/src/structs/JB721TierConfig.sol";
34
+ import {JB721InitTiersConfig} from "@bananapus/721-hook-v6/src/structs/JB721InitTiersConfig.sol";
35
+ import {IJB721TokenUriResolver} from "@bananapus/721-hook-v6/src/interfaces/IJB721TokenUriResolver.sol";
36
+ import {REVDeploy721TiersHookConfig} from "../../src/structs/REVDeploy721TiersHookConfig.sol";
37
+ import {REVBaseline721HookConfig} from "../../src/structs/REVBaseline721HookConfig.sol";
38
+ import {REV721TiersHookFlags} from "../../src/structs/REV721TiersHookFlags.sol";
39
+ import {REVCroptopAllowedPost} from "../../src/structs/REVCroptopAllowedPost.sol";
40
+
41
+ // Buyback hook
42
+ import {JBBuybackHook} from "@bananapus/buyback-hook-v6/src/JBBuybackHook.sol";
43
+ import {JBBuybackHookRegistry} from "@bananapus/buyback-hook-v6/src/JBBuybackHookRegistry.sol";
44
+ import {IJBBuybackHook} from "@bananapus/buyback-hook-v6/src/interfaces/IJBBuybackHook.sol";
45
+ import {IWETH9} from "@bananapus/buyback-hook-v6/src/interfaces/external/IWETH9.sol";
46
+ import {IGeomeanOracle} from "@bananapus/buyback-hook-v6/src/interfaces/IGeomeanOracle.sol";
47
+
48
+ // Uniswap V4
49
+ import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
50
+ import {IUnlockCallback} from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol";
51
+ import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol";
52
+ import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
53
+ import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol";
54
+ import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol";
55
+ import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
56
+ import {ModifyLiquidityParams} from "@uniswap/v4-core/src/types/PoolOperation.sol";
57
+ import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol";
58
+ import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol";
59
+
60
+ import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol";
61
+
62
+ /// @notice Helper that adds liquidity to a V4 pool via the unlock/callback pattern.
63
+ contract LiquidityHelper is IUnlockCallback {
64
+ IPoolManager public immutable poolManager;
65
+
66
+ struct AddLiqParams {
67
+ PoolKey key;
68
+ int24 tickLower;
69
+ int24 tickUpper;
70
+ int256 liquidityDelta;
71
+ }
72
+
73
+ constructor(IPoolManager _poolManager) {
74
+ poolManager = _poolManager;
75
+ }
76
+
77
+ function addLiquidity(
78
+ PoolKey calldata key,
79
+ int24 tickLower,
80
+ int24 tickUpper,
81
+ int256 liquidityDelta
82
+ )
83
+ external
84
+ payable
85
+ {
86
+ bytes memory data = abi.encode(AddLiqParams(key, tickLower, tickUpper, liquidityDelta));
87
+ poolManager.unlock(data);
88
+ }
89
+
90
+ function unlockCallback(bytes calldata data) external override returns (bytes memory) {
91
+ require(msg.sender == address(poolManager), "only PM");
92
+
93
+ AddLiqParams memory params = abi.decode(data, (AddLiqParams));
94
+
95
+ (BalanceDelta callerDelta,) = poolManager.modifyLiquidity(
96
+ params.key,
97
+ ModifyLiquidityParams({
98
+ tickLower: params.tickLower,
99
+ tickUpper: params.tickUpper,
100
+ liquidityDelta: params.liquidityDelta,
101
+ salt: bytes32(0)
102
+ }),
103
+ ""
104
+ );
105
+
106
+ _settleIfNegative(params.key.currency0, callerDelta.amount0());
107
+ _settleIfNegative(params.key.currency1, callerDelta.amount1());
108
+ _takeIfPositive(params.key.currency0, callerDelta.amount0());
109
+ _takeIfPositive(params.key.currency1, callerDelta.amount1());
110
+
111
+ return abi.encode(callerDelta);
112
+ }
113
+
114
+ function _settleIfNegative(Currency currency, int128 delta) internal {
115
+ if (delta >= 0) return;
116
+ uint256 amount = uint256(uint128(-delta));
117
+
118
+ if (currency.isAddressZero()) {
119
+ poolManager.settle{value: amount}();
120
+ } else {
121
+ poolManager.sync(currency);
122
+ IERC20(Currency.unwrap(currency)).transfer(address(poolManager), amount);
123
+ poolManager.settle();
124
+ }
125
+ }
126
+
127
+ function _takeIfPositive(Currency currency, int128 delta) internal {
128
+ if (delta <= 0) return;
129
+ uint256 amount = uint256(uint128(delta));
130
+ poolManager.take(currency, address(this), amount);
131
+ }
132
+
133
+ receive() external payable {}
134
+ }
135
+
136
+ /// @notice Shared base for fork tests. Deploys full JB core + REVDeployer infrastructure on a mainnet fork.
137
+ ///
138
+ /// Requires: RPC_ETHEREUM_MAINNET env var for mainnet fork (real PoolManager).
139
+ abstract contract ForkTestBase is TestBaseWorkflow {
140
+ using JBMetadataResolver for bytes;
141
+ using PoolIdLibrary for PoolKey;
142
+ using CurrencyLibrary for Currency;
143
+ using StateLibrary for IPoolManager;
144
+
145
+ // ───────────────────────── Mainnet constants
146
+ // ─────────────────────────
147
+
148
+ address constant POOL_MANAGER_ADDR = 0x000000000004444c5dc75cB358380D2e3dE08A90;
149
+ address constant WETH_ADDR = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
150
+
151
+ /// @notice Full-range tick bounds for tickSpacing = 60.
152
+ int24 constant TICK_LOWER = -887_220;
153
+ int24 constant TICK_UPPER = 887_220;
154
+
155
+ // ───────────────────────── State
156
+ // ─────────────────────────
157
+
158
+ REVDeployer REV_DEPLOYER;
159
+ JBBuybackHook BUYBACK_HOOK;
160
+ JBBuybackHookRegistry BUYBACK_REGISTRY;
161
+ JB721TiersHook EXAMPLE_HOOK;
162
+ IJB721TiersHookDeployer HOOK_DEPLOYER;
163
+ IJB721TiersHookStore HOOK_STORE;
164
+ IJBAddressRegistry ADDRESS_REGISTRY;
165
+ IREVLoans LOANS_CONTRACT;
166
+ IJBSuckerRegistry SUCKER_REGISTRY;
167
+ CTPublisher PUBLISHER;
168
+ IPoolManager poolManager;
169
+ IWETH9 weth;
170
+ LiquidityHelper liqHelper;
171
+
172
+ uint256 FEE_PROJECT_ID;
173
+
174
+ address private constant TRUSTED_FORWARDER = 0xB2b5841DBeF766d4b521221732F9B618fCf34A87;
175
+ address PAYER = makeAddr("payer");
176
+ address BORROWER = makeAddr("borrower");
177
+ address SPLIT_BENEFICIARY = makeAddr("splitBeneficiary");
178
+
179
+ // Tier configuration: 1 ETH tier with 30% split.
180
+ uint104 constant TIER_PRICE = 1 ether;
181
+ uint32 constant SPLIT_PERCENT = 300_000_000; // 30% of SPLITS_TOTAL_PERCENT (1_000_000_000)
182
+ uint112 constant INITIAL_ISSUANCE = 1000e18; // 1000 tokens per ETH
183
+
184
+ // ───────────────────────── Setup
185
+ // ─────────────────────────
186
+
187
+ function setUp() public virtual override {
188
+ // Fork mainnet first — we need the real V4 PoolManager.
189
+ string memory rpcUrl = vm.envOr("RPC_ETHEREUM_MAINNET", string(""));
190
+ if (bytes(rpcUrl).length == 0) {
191
+ vm.skip(true);
192
+ return;
193
+ }
194
+ vm.createSelectFork(rpcUrl);
195
+
196
+ // Verify V4 PoolManager is deployed.
197
+ require(POOL_MANAGER_ADDR.code.length > 0, "PoolManager not deployed at expected address");
198
+
199
+ // Deploy fresh JB core on the forked mainnet.
200
+ super.setUp();
201
+
202
+ poolManager = IPoolManager(POOL_MANAGER_ADDR);
203
+ weth = IWETH9(WETH_ADDR);
204
+ liqHelper = new LiquidityHelper(poolManager);
205
+
206
+ FEE_PROJECT_ID = jbProjects().createFor(multisig());
207
+
208
+ SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
209
+ HOOK_STORE = new JB721TiersHookStore();
210
+ EXAMPLE_HOOK =
211
+ new JB721TiersHook(jbDirectory(), jbPermissions(), jbRulesets(), HOOK_STORE, jbSplits(), multisig());
212
+ ADDRESS_REGISTRY = new JBAddressRegistry();
213
+ HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
214
+ PUBLISHER = new CTPublisher(jbDirectory(), jbPermissions(), FEE_PROJECT_ID, multisig());
215
+
216
+ // Deploy REAL buyback hook with real PoolManager.
217
+ BUYBACK_HOOK = new JBBuybackHook(
218
+ jbDirectory(),
219
+ jbPermissions(),
220
+ jbPrices(),
221
+ jbProjects(),
222
+ jbTokens(),
223
+ weth,
224
+ poolManager,
225
+ address(0) // trustedForwarder
226
+ );
227
+
228
+ // Deploy the registry and set the buyback hook as the default.
229
+ BUYBACK_REGISTRY = new JBBuybackHookRegistry(
230
+ jbPermissions(),
231
+ jbProjects(),
232
+ address(this), // owner
233
+ address(0) // trustedForwarder
234
+ );
235
+ BUYBACK_REGISTRY.setDefaultHook(IJBRulesetDataHook(address(BUYBACK_HOOK)));
236
+
237
+ LOANS_CONTRACT = new REVLoans({
238
+ controller: jbController(),
239
+ projects: jbProjects(),
240
+ revId: FEE_PROJECT_ID,
241
+ owner: address(this),
242
+ permit2: permit2(),
243
+ trustedForwarder: TRUSTED_FORWARDER
244
+ });
245
+
246
+ REV_DEPLOYER = new REVDeployer{salt: "REVDeployer_Fork"}(
247
+ jbController(),
248
+ SUCKER_REGISTRY,
249
+ FEE_PROJECT_ID,
250
+ HOOK_DEPLOYER,
251
+ PUBLISHER,
252
+ IJBBuybackHookRegistry(address(BUYBACK_REGISTRY)),
253
+ address(LOANS_CONTRACT),
254
+ TRUSTED_FORWARDER
255
+ );
256
+
257
+ vm.prank(multisig());
258
+ jbProjects().approve(address(REV_DEPLOYER), FEE_PROJECT_ID);
259
+
260
+ // Fund payer and borrower.
261
+ vm.deal(PAYER, 100 ether);
262
+ vm.deal(BORROWER, 100 ether);
263
+ }
264
+
265
+ modifier onlyFork() {
266
+ string memory rpcUrl = vm.envOr("RPC_ETHEREUM_MAINNET", string(""));
267
+ if (bytes(rpcUrl).length == 0) return;
268
+ _;
269
+ }
270
+
271
+ // ───────────────────────── Config Helpers
272
+ // ─────────────────────────
273
+
274
+ function _buildMinimalConfig(uint16 cashOutTaxRate)
275
+ internal
276
+ view
277
+ returns (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc)
278
+ {
279
+ JBAccountingContext[] memory acc = new JBAccountingContext[](1);
280
+ acc[0] = JBAccountingContext({
281
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
282
+ });
283
+ tc = new JBTerminalConfig[](1);
284
+ tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: acc});
285
+
286
+ REVStageConfig[] memory stages = new REVStageConfig[](1);
287
+ JBSplit[] memory splits = new JBSplit[](1);
288
+ splits[0].beneficiary = payable(multisig());
289
+ splits[0].percent = 10_000;
290
+ stages[0] = REVStageConfig({
291
+ startsAtOrAfter: uint40(block.timestamp),
292
+ autoIssuances: new REVAutoIssuance[](0),
293
+ splitPercent: 0,
294
+ splits: splits,
295
+ initialIssuance: INITIAL_ISSUANCE,
296
+ issuanceCutFrequency: 0,
297
+ issuanceCutPercent: 0,
298
+ cashOutTaxRate: cashOutTaxRate,
299
+ extraMetadata: 0
300
+ });
301
+
302
+ cfg = REVConfig({
303
+ description: REVDescription("Fork Test", "FORK", "ipfs://fork", "FORK_SALT"),
304
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
305
+ splitOperator: multisig(),
306
+ stageConfigurations: stages
307
+ });
308
+
309
+ sdc = REVSuckerDeploymentConfig({
310
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256(abi.encodePacked("FORK_TEST"))
311
+ });
312
+ }
313
+
314
+ function _build721Config() internal view returns (REVDeploy721TiersHookConfig memory) {
315
+ JB721TierConfig[] memory tiers = new JB721TierConfig[](1);
316
+ JBSplit[] memory tierSplits = new JBSplit[](1);
317
+ tierSplits[0] = JBSplit({
318
+ preferAddToBalance: false,
319
+ percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT),
320
+ projectId: 0,
321
+ beneficiary: payable(SPLIT_BENEFICIARY),
322
+ lockedUntil: 0,
323
+ hook: IJBSplitHook(address(0))
324
+ });
325
+
326
+ tiers[0] = JB721TierConfig({
327
+ price: TIER_PRICE,
328
+ initialSupply: 100,
329
+ votingUnits: 0,
330
+ reserveFrequency: 0,
331
+ reserveBeneficiary: address(0),
332
+ encodedIPFSUri: bytes32("tier1"),
333
+ category: 1,
334
+ discountPercent: 0,
335
+ allowOwnerMint: false,
336
+ useReserveBeneficiaryAsDefault: false,
337
+ transfersPausable: false,
338
+ useVotingUnits: false,
339
+ cannotBeRemoved: false,
340
+ cannotIncreaseDiscountPercent: false,
341
+ splitPercent: SPLIT_PERCENT,
342
+ splits: tierSplits
343
+ });
344
+
345
+ return REVDeploy721TiersHookConfig({
346
+ baseline721HookConfiguration: REVBaseline721HookConfig({
347
+ name: "Fork NFT",
348
+ symbol: "FNFT",
349
+ baseUri: "ipfs://",
350
+ tokenUriResolver: IJB721TokenUriResolver(address(0)),
351
+ contractUri: "ipfs://contract",
352
+ tiersConfig: JB721InitTiersConfig({
353
+ tiers: tiers,
354
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
355
+ decimals: 18,
356
+ prices: IJBPrices(address(0))
357
+ }),
358
+ reserveBeneficiary: address(0),
359
+ flags: REV721TiersHookFlags({
360
+ noNewTiersWithReserves: false,
361
+ noNewTiersWithVotes: false,
362
+ noNewTiersWithOwnerMinting: false,
363
+ preventOverspending: false
364
+ })
365
+ }),
366
+ salt: bytes32("FORK_721"),
367
+ splitOperatorCanAdjustTiers: false,
368
+ splitOperatorCanUpdateMetadata: false,
369
+ splitOperatorCanMint: false,
370
+ splitOperatorCanIncreaseDiscountPercent: false
371
+ });
372
+ }
373
+
374
+ // ───────────────────────── Deployment Helpers
375
+ // ─────────────────────────
376
+
377
+ /// @notice Deploy the fee project using the given tax rate.
378
+ function _deployFeeProject(uint16 cashOutTaxRate) internal {
379
+ (REVConfig memory feeCfg, JBTerminalConfig[] memory feeTc, REVSuckerDeploymentConfig memory feeSdc) =
380
+ _buildMinimalConfig(cashOutTaxRate);
381
+ feeCfg.description = REVDescription("Fee", "FEE", "ipfs://fee", "FEE_SALT");
382
+
383
+ vm.prank(multisig());
384
+ REV_DEPLOYER.deployFor({
385
+ revnetId: FEE_PROJECT_ID,
386
+ configuration: feeCfg,
387
+ terminalConfigurations: feeTc,
388
+ suckerDeploymentConfiguration: feeSdc
389
+ });
390
+ }
391
+
392
+ /// @notice Deploy a revnet (no 721 hook) with the given cash out tax rate.
393
+ function _deployRevnet(uint16 cashOutTaxRate) internal returns (uint256 revnetId) {
394
+ (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc) =
395
+ _buildMinimalConfig(cashOutTaxRate);
396
+
397
+ revnetId = REV_DEPLOYER.deployFor({
398
+ revnetId: 0, configuration: cfg, terminalConfigurations: tc, suckerDeploymentConfiguration: sdc
399
+ });
400
+ }
401
+
402
+ /// @notice Deploy a revnet with 721 tiers and the given cash out tax rate.
403
+ function _deployRevnetWith721(uint16 cashOutTaxRate) internal returns (uint256 revnetId, IJB721TiersHook hook) {
404
+ (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc) =
405
+ _buildMinimalConfig(cashOutTaxRate);
406
+ REVDeploy721TiersHookConfig memory hookConfig = _build721Config();
407
+
408
+ (revnetId, hook) = REV_DEPLOYER.deployWith721sFor({
409
+ revnetId: 0,
410
+ configuration: cfg,
411
+ terminalConfigurations: tc,
412
+ suckerDeploymentConfiguration: sdc,
413
+ tiered721HookConfiguration: hookConfig,
414
+ allowedPosts: new REVCroptopAllowedPost[](0)
415
+ });
416
+ }
417
+
418
+ // ───────────────────────── Pool Helpers
419
+ // ─────────────────────────
420
+
421
+ /// @notice Set up a V4 pool for the revnet's project token / WETH pair at 1:1 price.
422
+ function _setupPool(uint256 revnetId, uint256 liquidityTokenAmount) internal returns (PoolKey memory key) {
423
+ address projectToken = address(jbTokens().tokenOf(revnetId));
424
+ require(projectToken != address(0), "project token not deployed");
425
+
426
+ address token0;
427
+ address token1;
428
+ if (projectToken < WETH_ADDR) {
429
+ token0 = projectToken;
430
+ token1 = WETH_ADDR;
431
+ } else {
432
+ token0 = WETH_ADDR;
433
+ token1 = projectToken;
434
+ }
435
+
436
+ key = PoolKey({
437
+ currency0: Currency.wrap(token0),
438
+ currency1: Currency.wrap(token1),
439
+ fee: REV_DEPLOYER.DEFAULT_BUYBACK_POOL_FEE(),
440
+ tickSpacing: REV_DEPLOYER.DEFAULT_BUYBACK_TICK_SPACING(),
441
+ hooks: IHooks(address(0))
442
+ });
443
+
444
+ uint160 sqrtPrice = TickMath.getSqrtPriceAtTick(0);
445
+ poolManager.initialize(key, sqrtPrice);
446
+
447
+ // Fund LiquidityHelper with project tokens via JBTokens.mintFor (not deal).
448
+ vm.prank(address(jbController()));
449
+ jbTokens().mintFor(address(liqHelper), revnetId, liquidityTokenAmount);
450
+ vm.deal(address(liqHelper), liquidityTokenAmount);
451
+ vm.prank(address(liqHelper));
452
+ IWETH9(WETH_ADDR).deposit{value: liquidityTokenAmount}();
453
+
454
+ vm.startPrank(address(liqHelper));
455
+ IERC20(projectToken).approve(address(poolManager), type(uint256).max);
456
+ IERC20(WETH_ADDR).approve(address(poolManager), type(uint256).max);
457
+ vm.stopPrank();
458
+
459
+ int256 liquidityDelta = int256(liquidityTokenAmount / 2);
460
+ vm.prank(address(liqHelper));
461
+ liqHelper.addLiquidity(key, TICK_LOWER, TICK_UPPER, liquidityDelta);
462
+
463
+ _mockOracle(liquidityDelta, 0, uint32(REV_DEPLOYER.DEFAULT_BUYBACK_TWAP_WINDOW()));
464
+
465
+ uint256 twapWindow = REV_DEPLOYER.DEFAULT_BUYBACK_TWAP_WINDOW();
466
+ vm.prank(multisig());
467
+ BUYBACK_HOOK.setPoolFor({
468
+ projectId: revnetId, poolKey: key, twapWindow: twapWindow, terminalToken: JBConstants.NATIVE_TOKEN
469
+ });
470
+ }
471
+
472
+ /// @notice Mock the IGeomeanOracle at address(0) for hookless pools.
473
+ function _mockOracle(int256 liquidity, int24 tick, uint32 twapWindow) internal {
474
+ vm.etch(address(0), hex"00");
475
+
476
+ int56[] memory tickCumulatives = new int56[](2);
477
+ tickCumulatives[0] = 0;
478
+ tickCumulatives[1] = int56(tick) * int56(int32(twapWindow));
479
+
480
+ uint160[] memory secondsPerLiquidityCumulativeX128s = new uint160[](2);
481
+ secondsPerLiquidityCumulativeX128s[0] = 0;
482
+ uint256 liq = uint256(liquidity > 0 ? liquidity : -liquidity);
483
+ if (liq == 0) liq = 1;
484
+ secondsPerLiquidityCumulativeX128s[1] = uint160((uint256(twapWindow) << 128) / liq);
485
+
486
+ vm.mockCall(
487
+ address(0),
488
+ abi.encodeWithSelector(IGeomeanOracle.observe.selector),
489
+ abi.encode(tickCumulatives, secondsPerLiquidityCumulativeX128s)
490
+ );
491
+ }
492
+
493
+ // ───────────────────────── Balance Helpers
494
+ // ─────────────────────────
495
+
496
+ /// @notice Get a project's balance in the terminal store.
497
+ function _terminalBalance(uint256 projectId, address token) internal view returns (uint256) {
498
+ return jbTerminalStore().balanceOf(address(jbMultiTerminal()), projectId, token);
499
+ }
500
+
501
+ // ───────────────────────── Payment Helpers
502
+ // ─────────────────────────
503
+
504
+ /// @notice Pay the revnet with ETH.
505
+ function _payRevnet(uint256 revnetId, address payer, uint256 amount) internal returns (uint256 tokensReceived) {
506
+ vm.prank(payer);
507
+ tokensReceived = jbMultiTerminal().pay{value: amount}({
508
+ projectId: revnetId,
509
+ token: JBConstants.NATIVE_TOKEN,
510
+ amount: amount,
511
+ beneficiary: payer,
512
+ minReturnedTokens: 0,
513
+ memo: "",
514
+ metadata: ""
515
+ });
516
+ }
517
+
518
+ // ───────────────────────── Metadata Helpers
519
+ // ─────────────────────────
520
+
521
+ /// @notice Build payment metadata with both 721 tier selection AND buyback quote.
522
+ function _buildPayMetadataWithQuote(
523
+ address hookMetadataTarget,
524
+ uint256 amountToSwapWith,
525
+ uint256 minimumSwapAmountOut
526
+ )
527
+ internal
528
+ view
529
+ returns (bytes memory)
530
+ {
531
+ uint16[] memory tierIds = new uint16[](1);
532
+ tierIds[0] = 1;
533
+ bytes memory tierData = abi.encode(true, tierIds);
534
+ bytes4 tierMetadataId = JBMetadataResolver.getId("pay", hookMetadataTarget);
535
+
536
+ bytes memory quoteData = abi.encode(amountToSwapWith, minimumSwapAmountOut);
537
+ bytes4 quoteMetadataId = JBMetadataResolver.getId("quote");
538
+
539
+ bytes4[] memory ids = new bytes4[](2);
540
+ ids[0] = tierMetadataId;
541
+ ids[1] = quoteMetadataId;
542
+ bytes[] memory datas = new bytes[](2);
543
+ datas[0] = tierData;
544
+ datas[1] = quoteData;
545
+
546
+ return JBMetadataResolver.createMetadata(ids, datas);
547
+ }
548
+
549
+ /// @notice Build payment metadata with only 721 tier selection (no quote -> TWAP/spot fallback).
550
+ function _buildPayMetadataNoQuote(address hookMetadataTarget) internal view returns (bytes memory) {
551
+ uint16[] memory tierIds = new uint16[](1);
552
+ tierIds[0] = 1;
553
+ bytes memory tierData = abi.encode(true, tierIds);
554
+ bytes4 tierMetadataId = JBMetadataResolver.getId("pay", hookMetadataTarget);
555
+
556
+ bytes4[] memory ids = new bytes4[](1);
557
+ ids[0] = tierMetadataId;
558
+ bytes[] memory datas = new bytes[](1);
559
+ datas[0] = tierData;
560
+
561
+ return JBMetadataResolver.createMetadata(ids, datas);
562
+ }
563
+
564
+ // ───────────────────────── ERC721 Helpers
565
+ // ─────────────────────────
566
+
567
+ /// @notice Get the owner of a loan NFT. REVLoans is ERC721 but typed as IREVLoans.
568
+ function _loanOwnerOf(uint256 loanId) internal view returns (address) {
569
+ return REVLoans(payable(address(LOANS_CONTRACT))).ownerOf(loanId);
570
+ }
571
+
572
+ // ───────────────────────── Loan Helpers
573
+ // ─────────────────────────
574
+
575
+ /// @notice Build a native token loan source.
576
+ function _nativeLoanSource() internal view returns (REVLoanSource memory) {
577
+ return REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
578
+ }
579
+
580
+ /// @notice Grant BURN_TOKENS permission to the loans contract for a given account.
581
+ function _grantBurnPermission(address account, uint256 revnetId) internal {
582
+ // Permission ID 11 = BURN_TOKENS in JBPermissionIds
583
+ mockExpect(
584
+ address(jbPermissions()),
585
+ abi.encodeCall(IJBPermissions.hasPermission, (address(LOANS_CONTRACT), account, revnetId, 11, true, true)),
586
+ abi.encode(true)
587
+ );
588
+ }
589
+
590
+ /// @notice Create a loan for the given borrower. Returns the loan ID and loan struct.
591
+ function _createLoan(
592
+ uint256 revnetId,
593
+ address borrower,
594
+ uint256 collateral,
595
+ uint256 prepaidFeePercent
596
+ )
597
+ internal
598
+ returns (uint256 loanId, REVLoan memory loan)
599
+ {
600
+ REVLoanSource memory source = _nativeLoanSource();
601
+ uint256 borrowable =
602
+ LOANS_CONTRACT.borrowableAmountFrom(revnetId, collateral, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
603
+ require(borrowable > 0, "no borrowable amount");
604
+
605
+ _grantBurnPermission(borrower, revnetId);
606
+
607
+ vm.prank(borrower);
608
+ (loanId, loan) = LOANS_CONTRACT.borrowFrom({
609
+ revnetId: revnetId,
610
+ source: source,
611
+ minBorrowAmount: 0,
612
+ collateralCount: collateral,
613
+ beneficiary: payable(borrower),
614
+ prepaidFeePercent: prepaidFeePercent
615
+ });
616
+ }
617
+ }