@rev-net/core-v6 0.0.14 → 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILLS.md CHANGED
@@ -157,7 +157,7 @@ Deploy and manage Revnets -- autonomous, unowned Juicebox projects with staged i
157
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.
158
158
  18. **Permit2 fallback.** `REVLoans` uses permit2 for ERC-20 transfers as a fallback when standard allowance is insufficient. Wrapped in try-catch.
159
159
  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`.
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.launchProjectFor`) instead of bare `launchProjectFor`.
161
161
 
162
162
  ### NATIVE_TOKEN Accounting on Non-ETH Chains
163
163
 
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.15",
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",
22
+ "@bananapus/721-hook-v6": "^0.0.19",
23
+ "@bananapus/buyback-hook-v6": "^0.0.16",
24
+ "@bananapus/core-v6": "^0.0.24",
25
+ "@bananapus/ownable-v6": "^0.0.12",
27
26
  "@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",
27
+ "@bananapus/router-terminal-v6": "^0.0.16",
28
+ "@bananapus/suckers-v6": "^0.0.13",
29
+ "@croptop/core-v6": "^0.0.20",
31
30
  "@openzeppelin/contracts": "^5.6.1",
32
31
  "@uniswap/v4-core": "^1.0.2",
33
32
  "@uniswap/v4-periphery": "^1.0.3"
@@ -271,18 +271,19 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IJBRulesetDataHook, IJBCas
271
271
  IJBTerminal feeTerminal = DIRECTORY.primaryTerminalOf({projectId: FEE_REVNET_ID, token: context.surplus.token});
272
272
 
273
273
  // If there's no cash out tax (100% cash out tax rate), if there's no fee terminal, or if the beneficiary is
274
- // feeless (e.g. the router terminal routing value between projects), do not charge a fee.
274
+ // feeless (e.g. the router terminal routing value between projects), proxy directly to the buyback hook.
275
275
  if (context.cashOutTaxRate == 0 || address(feeTerminal) == address(0) || context.beneficiaryIsFeeless) {
276
- return (context.cashOutTaxRate, context.cashOutCount, context.totalSupply, hookSpecifications);
276
+ // slither-disable-next-line unused-return
277
+ return BUYBACK_HOOK.beforeCashOutRecordedWith(context);
277
278
  }
278
279
 
279
- // Get a reference to the number of tokens being used to pay the fee (out of the total being cashed out).
280
+ // Split the cashed-out tokens into a fee portion and a non-fee portion.
280
281
  // Micro cash outs (< 40 wei at 2.5% fee) round feeCashOutCount to zero, bypassing the fee.
281
282
  // Economically insignificant: the gas cost of the transaction far exceeds the bypassed fee. No fix needed.
282
283
  uint256 feeCashOutCount = mulDiv({x: context.cashOutCount, y: FEE, denominator: JBConstants.MAX_FEE});
283
284
  uint256 nonFeeCashOutCount = context.cashOutCount - feeCashOutCount;
284
285
 
285
- // Keep a reference to the amount claimable with non-fee tokens.
286
+ // Calculate how much surplus the non-fee tokens can reclaim via the bonding curve.
286
287
  uint256 postFeeReclaimedAmount = JBCashOuts.cashOutFrom({
287
288
  surplus: context.surplus.value,
288
289
  cashOutCount: nonFeeCashOutCount,
@@ -290,7 +291,7 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IJBRulesetDataHook, IJBCas
290
291
  cashOutTaxRate: context.cashOutTaxRate
291
292
  });
292
293
 
293
- // Keep a reference to the fee amount after the reclaimed amount is subtracted.
294
+ // Calculate how much the fee tokens reclaim from the remaining surplus after the non-fee reclaim.
294
295
  uint256 feeAmount = JBCashOuts.cashOutFrom({
295
296
  surplus: context.surplus.value - postFeeReclaimedAmount,
296
297
  cashOutCount: feeCashOutCount,
@@ -298,15 +299,36 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IJBRulesetDataHook, IJBCas
298
299
  cashOutTaxRate: context.cashOutTaxRate
299
300
  });
300
301
 
301
- // Assemble a cash out hook specification to invoke `afterCashOutRecordedWith(…)` with, to process the fee.
302
- hookSpecifications = new JBCashOutHookSpecification[](1);
303
- hookSpecifications[0] = JBCashOutHookSpecification({
304
- hook: IJBCashOutHook(address(this)), amount: feeAmount, metadata: abi.encode(feeTerminal)
302
+ // Build a context for the buyback hook using only the non-fee token count.
303
+ JBBeforeCashOutRecordedContext memory buybackHookContext = context;
304
+ buybackHookContext.cashOutCount = nonFeeCashOutCount;
305
+
306
+ // Let the buyback hook adjust the cash out parameters and optionally return a hook specification.
307
+ JBCashOutHookSpecification[] memory buybackHookSpecifications;
308
+ (cashOutTaxRate, cashOutCount, totalSupply, buybackHookSpecifications) =
309
+ BUYBACK_HOOK.beforeCashOutRecordedWith(buybackHookContext);
310
+
311
+ // If the fee rounds down to zero, return the buyback hook's response directly — no fee to process.
312
+ if (feeAmount == 0) return (cashOutTaxRate, cashOutCount, totalSupply, buybackHookSpecifications);
313
+
314
+ // Build a hook spec that routes the fee amount to this contract's `afterCashOutRecordedWith` for processing.
315
+ JBCashOutHookSpecification memory feeSpec = JBCashOutHookSpecification({
316
+ hook: IJBCashOutHook(address(this)), noop: false, amount: feeAmount, metadata: abi.encode(feeTerminal)
305
317
  });
306
318
 
307
- // Return the cash out rate and the number of revnet tokens to cash out, minus the tokens being used to pay the
308
- // fee.
309
- return (context.cashOutTaxRate, nonFeeCashOutCount, context.totalSupply, hookSpecifications);
319
+ // Compose the final hook specifications: buyback spec (if any) + fee spec.
320
+ if (buybackHookSpecifications.length > 0) {
321
+ // The buyback hook returned a spec — include it before the fee spec.
322
+ hookSpecifications = new JBCashOutHookSpecification[](2);
323
+ hookSpecifications[0] = buybackHookSpecifications[0];
324
+ hookSpecifications[1] = feeSpec;
325
+ } else {
326
+ // No buyback spec — only the fee spec.
327
+ hookSpecifications = new JBCashOutHookSpecification[](1);
328
+ hookSpecifications[0] = feeSpec;
329
+ }
330
+
331
+ return (cashOutTaxRate, cashOutCount, totalSupply, hookSpecifications);
310
332
  }
311
333
 
312
334
  /// @notice Before a revnet processes an incoming payment, determine the weight and pay hooks to use.
package/src/REVLoans.sol CHANGED
@@ -339,11 +339,7 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
339
339
 
340
340
  // Get the surplus of all the revnet's terminals in terms of the native currency.
341
341
  uint256 totalSurplus = JBSurplus.currentSurplusOf({
342
- projectId: revnetId,
343
- terminals: terminals,
344
- accountingContexts: new JBAccountingContext[](0),
345
- decimals: decimals,
346
- currency: currency
342
+ projectId: revnetId, terminals: terminals, tokens: new address[](0), decimals: decimals, currency: currency
347
343
  });
348
344
 
349
345
  // Get the total amount the revnet currently has loaned out, in terms of the native currency with 18
@@ -138,7 +138,8 @@ contract REVDeployerRegressions is TestBaseWorkflow {
138
138
  assertEq(correctIndex, 0, "buyback hook should use index 0 when no tiered hook");
139
139
 
140
140
  // Write to the correct index (no revert)
141
- specs[correctIndex] = JBPayHookSpecification({hook: IJBPayHook(address(0xbeef)), amount: 1 ether, metadata: ""});
141
+ specs[correctIndex] =
142
+ JBPayHookSpecification({hook: IJBPayHook(address(0xbeef)), noop: false, amount: 1 ether, metadata: ""});
142
143
  }
143
144
 
144
145
  /// @notice Verify both hooks present works fine (no OOB).
@@ -150,8 +151,10 @@ contract REVDeployerRegressions is TestBaseWorkflow {
150
151
  assertEq(arraySize, 2, "array should be size 2");
151
152
 
152
153
  JBPayHookSpecification[] memory specs = new JBPayHookSpecification[](arraySize);
153
- specs[0] = JBPayHookSpecification({hook: IJBPayHook(address(0xdead)), amount: 1 ether, metadata: ""});
154
- specs[1] = JBPayHookSpecification({hook: IJBPayHook(address(0xbeef)), amount: 2 ether, metadata: ""});
154
+ specs[0] =
155
+ JBPayHookSpecification({hook: IJBPayHook(address(0xdead)), noop: false, amount: 1 ether, metadata: ""});
156
+ specs[1] =
157
+ JBPayHookSpecification({hook: IJBPayHook(address(0xbeef)), noop: false, amount: 2 ether, metadata: ""});
155
158
  }
156
159
 
157
160
  //*********************************************************************//
@@ -368,7 +368,8 @@ contract REVInvincibility_PropertyTests is TestBaseWorkflow {
368
368
 
369
369
  // Verify safe write
370
370
  JBPayHookSpecification[] memory specs = new JBPayHookSpecification[](arraySize);
371
- specs[correctIndex] = JBPayHookSpecification({hook: IJBPayHook(address(0xbeef)), amount: 1 ether, metadata: ""});
371
+ specs[correctIndex] =
372
+ JBPayHookSpecification({hook: IJBPayHook(address(0xbeef)), noop: false, amount: 1 ether, metadata: ""});
372
373
  }
373
374
 
374
375
  /// @notice Reentrancy — _adjust calls terminal.pay() BEFORE writing loan state.
@@ -909,12 +910,8 @@ contract REVInvincibility_PropertyTests is TestBaseWorkflow {
909
910
  // Record fee project balance before cash-out
910
911
  uint256 feeBalanceBefore;
911
912
  {
912
- JBAccountingContext[] memory feeCtx = new JBAccountingContext[](1);
913
- feeCtx[0] = JBAccountingContext({
914
- token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
915
- });
916
913
  feeBalanceBefore = jbMultiTerminal()
917
- .currentSurplusOf(FEE_PROJECT_ID, feeCtx, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
914
+ .currentSurplusOf(FEE_PROJECT_ID, new address[](0), 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
918
915
  }
919
916
 
920
917
  // Cash out
@@ -935,12 +932,8 @@ contract REVInvincibility_PropertyTests is TestBaseWorkflow {
935
932
  // because both the terminal fee AND the revnet fee route to it
936
933
  uint256 feeBalanceAfter;
937
934
  {
938
- JBAccountingContext[] memory feeCtx = new JBAccountingContext[](1);
939
- feeCtx[0] = JBAccountingContext({
940
- token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
941
- });
942
935
  feeBalanceAfter = jbMultiTerminal()
943
- .currentSurplusOf(FEE_PROJECT_ID, feeCtx, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
936
+ .currentSurplusOf(FEE_PROJECT_ID, new address[](0), 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
944
937
  }
945
938
 
946
939
  // Fee project should have received fees from the cash-out
@@ -1206,13 +1199,8 @@ contract REVInvincibility_Invariants is StdInvariant, TestBaseWorkflow {
1206
1199
  function invariant_REV_1_surplusCoversLoans() public {
1207
1200
  uint256 totalBorrowed = LOANS_CONTRACT.totalBorrowedFrom(REVNET_ID, jbMultiTerminal(), JBConstants.NATIVE_TOKEN);
1208
1201
 
1209
- JBAccountingContext[] memory ctxArray = new JBAccountingContext[](1);
1210
- ctxArray[0] = JBAccountingContext({
1211
- token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
1212
- });
1213
-
1214
- uint256 storeBalance =
1215
- jbMultiTerminal().currentSurplusOf(REVNET_ID, ctxArray, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
1202
+ uint256 storeBalance = jbMultiTerminal()
1203
+ .currentSurplusOf(REVNET_ID, new address[](0), 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
1216
1204
 
1217
1205
  // Note: storeBalance is surplus (after payout limits), but the terminal holds at least this much
1218
1206
  // The total borrowed should not exceed what the terminal can cover
@@ -42,6 +42,8 @@ import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressReg
42
42
  import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
43
43
  import {REVEmpty721Config} from "./helpers/REVEmpty721Config.sol";
44
44
  import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol";
45
+ import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
46
+ import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
45
47
 
46
48
  /// @notice A malicious terminal that re-enters REVLoans during fee payment in _adjust().
47
49
  /// @dev Reentrancy during pay() callback in _adjust.
@@ -128,17 +130,7 @@ contract ReentrantTerminal is ERC165, IJBPayoutTerminal {
128
130
  override
129
131
  {}
130
132
 
131
- function currentSurplusOf(
132
- uint256,
133
- JBAccountingContext[] memory,
134
- uint256,
135
- uint256
136
- )
137
- external
138
- pure
139
- override
140
- returns (uint256)
141
- {
133
+ function currentSurplusOf(uint256, address[] calldata, uint256, uint256) external pure override returns (uint256) {
142
134
  return 0;
143
135
  }
144
136
 
@@ -168,6 +160,22 @@ contract ReentrantTerminal is ERC165, IJBPayoutTerminal {
168
160
  return 0;
169
161
  }
170
162
 
163
+ function previewPayFor(
164
+ uint256,
165
+ address,
166
+ uint256,
167
+ address,
168
+ bytes calldata
169
+ )
170
+ external
171
+ pure
172
+ override
173
+ returns (JBRuleset memory, uint256, uint256, JBPayHookSpecification[] memory)
174
+ {
175
+ JBRuleset memory ruleset;
176
+ return (ruleset, 0, 0, new JBPayHookSpecification[](0));
177
+ }
178
+
171
179
  function supportsInterface(bytes4 interfaceId) public view override(ERC165, IERC165) returns (bool) {
172
180
  return interfaceId == type(IJBTerminal).interfaceId || interfaceId == type(IJBPayoutTerminal).interfaceId
173
181
  || super.supportsInterface(interfaceId);
@@ -42,6 +42,8 @@ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStor
42
42
  import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
43
43
  import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
44
44
  import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol";
45
+ import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
46
+ import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
45
47
  import {REVEmpty721Config} from "./helpers/REVEmpty721Config.sol";
46
48
 
47
49
  /// @notice A terminal mock that always reverts on pay(), used to simulate fee payment failure.
@@ -87,17 +89,7 @@ contract RevertingFeeTerminal is ERC165, IJBPayoutTerminal {
87
89
  override
88
90
  {}
89
91
 
90
- function currentSurplusOf(
91
- uint256,
92
- JBAccountingContext[] memory,
93
- uint256,
94
- uint256
95
- )
96
- external
97
- pure
98
- override
99
- returns (uint256)
100
- {
92
+ function currentSurplusOf(uint256, address[] calldata, uint256, uint256) external pure override returns (uint256) {
101
93
  return 0;
102
94
  }
103
95
 
@@ -127,6 +119,22 @@ contract RevertingFeeTerminal is ERC165, IJBPayoutTerminal {
127
119
  return 0;
128
120
  }
129
121
 
122
+ function previewPayFor(
123
+ uint256,
124
+ address,
125
+ uint256,
126
+ address,
127
+ bytes calldata
128
+ )
129
+ external
130
+ pure
131
+ override
132
+ returns (JBRuleset memory, uint256, uint256, JBPayHookSpecification[] memory)
133
+ {
134
+ JBRuleset memory ruleset;
135
+ return (ruleset, 0, 0, new JBPayHookSpecification[](0));
136
+ }
137
+
130
138
  function supportsInterface(bytes4 interfaceId) public view override(ERC165, IERC165) returns (bool) {
131
139
  return interfaceId == type(IJBTerminal).interfaceId || interfaceId == type(IJBPayoutTerminal).interfaceId
132
140
  || super.supportsInterface(interfaceId);
@@ -42,6 +42,8 @@ import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
42
42
  import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
43
43
  import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
44
44
  import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
45
+ import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
46
+ import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
45
47
  import {REVEmpty721Config} from "./helpers/REVEmpty721Config.sol";
46
48
 
47
49
  /// @notice A fake terminal that returns garbage accounting contexts.
@@ -91,17 +93,7 @@ contract GarbageTerminal is ERC165, IJBPayoutTerminal {
91
93
  override
92
94
  {}
93
95
 
94
- function currentSurplusOf(
95
- uint256,
96
- JBAccountingContext[] memory,
97
- uint256,
98
- uint256
99
- )
100
- external
101
- pure
102
- override
103
- returns (uint256)
104
- {
96
+ function currentSurplusOf(uint256, address[] calldata, uint256, uint256) external pure override returns (uint256) {
105
97
  return 0;
106
98
  }
107
99
 
@@ -130,6 +122,22 @@ contract GarbageTerminal is ERC165, IJBPayoutTerminal {
130
122
  return 0;
131
123
  }
132
124
 
125
+ function previewPayFor(
126
+ uint256,
127
+ address,
128
+ uint256,
129
+ address,
130
+ bytes calldata
131
+ )
132
+ external
133
+ pure
134
+ override
135
+ returns (JBRuleset memory, uint256, uint256, JBPayHookSpecification[] memory)
136
+ {
137
+ JBRuleset memory ruleset;
138
+ return (ruleset, 0, 0, new JBPayHookSpecification[](0));
139
+ }
140
+
133
141
  function supportsInterface(bytes4 interfaceId) public view override(ERC165, IERC165) returns (bool) {
134
142
  return interfaceId == type(IJBTerminal).interfaceId || interfaceId == type(IJBPayoutTerminal).interfaceId
135
143
  || super.supportsInterface(interfaceId);
@@ -38,6 +38,8 @@ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStor
38
38
  import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
39
39
  import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
40
40
  import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol";
41
+ import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
42
+ import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
41
43
  import {REVEmpty721Config} from "./helpers/REVEmpty721Config.sol";
42
44
 
43
45
  /// @notice A fake terminal that tracks whether useAllowanceOf was called.
@@ -92,17 +94,7 @@ contract FakeTerminal is ERC165, IJBPayoutTerminal {
92
94
  override
93
95
  {}
94
96
 
95
- function currentSurplusOf(
96
- uint256,
97
- JBAccountingContext[] memory,
98
- uint256,
99
- uint256
100
- )
101
- external
102
- pure
103
- override
104
- returns (uint256)
105
- {
97
+ function currentSurplusOf(uint256, address[] calldata, uint256, uint256) external pure override returns (uint256) {
106
98
  return 0;
107
99
  }
108
100
 
@@ -131,6 +123,22 @@ contract FakeTerminal is ERC165, IJBPayoutTerminal {
131
123
  return 0;
132
124
  }
133
125
 
126
+ function previewPayFor(
127
+ uint256,
128
+ address,
129
+ uint256,
130
+ address,
131
+ bytes calldata
132
+ )
133
+ external
134
+ pure
135
+ override
136
+ returns (JBRuleset memory, uint256, uint256, JBPayHookSpecification[] memory)
137
+ {
138
+ JBRuleset memory ruleset;
139
+ return (ruleset, 0, 0, new JBPayHookSpecification[](0));
140
+ }
141
+
134
142
  function supportsInterface(bytes4 interfaceId) public view override(ERC165, IERC165) returns (bool) {
135
143
  return interfaceId == type(IJBTerminal).interfaceId || interfaceId == type(IJBPayoutTerminal).interfaceId
136
144
  || super.supportsInterface(interfaceId);
@@ -756,14 +756,6 @@ contract REVLoansSourcedTests is TestBaseWorkflow {
756
756
 
757
757
  uint256 balanceBefore = _balanceOf(token, USER);
758
758
 
759
- // Ensure that the hook was called.
760
- vm.expectCall(address(REV_DEPLOYER), abi.encode(REVDeployer.beforeCashOutRecordedWith.selector));
761
-
762
- // It only adds itself as a `after` cashoutHook if there is a cashout tax rate.
763
- if (cashOutTaxRate > 0) {
764
- vm.expectCall(address(REV_DEPLOYER), abi.encode(REVDeployer.afterCashOutRecordedWith.selector));
765
- }
766
-
767
759
  // Perform a cashout.
768
760
  vm.prank(USER);
769
761
  jbMultiTerminal().cashOutTokensOf(USER, revnetProjectId, tokensToCashout, token, 0, payable(USER), bytes(""));
@@ -22,8 +22,12 @@ import "@bananapus/suckers-v6/script/helpers/SuckerDeploymentLib.sol";
22
22
  import "@croptop/core-v6/script/helpers/CroptopDeploymentLib.sol";
23
23
  // forge-lint: disable-next-line(unaliased-plain-import)
24
24
  import "@bananapus/router-terminal-v6/script/helpers/RouterTerminalDeploymentLib.sol";
25
+ import {JBCashOuts} from "@bananapus/core-v6/src/libraries/JBCashOuts.sol";
25
26
  import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
26
27
  import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
28
+ import {JBBeforeCashOutRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforeCashOutRecordedContext.sol";
29
+ import {JBCashOutHookSpecification} from "@bananapus/core-v6/src/structs/JBCashOutHookSpecification.sol";
30
+ import {JBTokenAmount} from "@bananapus/core-v6/src/structs/JBTokenAmount.sol";
27
31
  import {MockERC20} from "@bananapus/core-v6/test/mock/MockERC20.sol";
28
32
  import {REVLoans} from "../src/REVLoans.sol";
29
33
  import {REVStageConfig, REVAutoIssuance} from "../src/structs/REVStageConfig.sol";
@@ -302,6 +306,76 @@ contract TestCashOutCallerValidation is TestBaseWorkflow {
302
306
  assertGt(feeBalanceAfter, feeBalanceBefore, "Fee project balance should increase from cash out fee");
303
307
  }
304
308
 
309
+ /// @notice Revnet cash-out fees and buyback sell-side specs are composed together.
310
+ function test_beforeCashOutRecordedWith_proxiesIntoBuybackAndAppendsFeeSpec() public {
311
+ bytes memory buybackMetadata = abi.encode(uint256(123));
312
+ MOCK_BUYBACK.configureCashOutResult({
313
+ cashOutTaxRate: JBConstants.MAX_CASH_OUT_TAX_RATE,
314
+ cashOutCount: 0,
315
+ totalSupply: 0,
316
+ hookAmount: 0,
317
+ hookMetadata: buybackMetadata
318
+ });
319
+
320
+ JBBeforeCashOutRecordedContext memory context = JBBeforeCashOutRecordedContext({
321
+ terminal: address(jbMultiTerminal()),
322
+ holder: USER,
323
+ projectId: REVNET_ID,
324
+ rulesetId: 0,
325
+ cashOutCount: 1000,
326
+ totalSupply: 10_000,
327
+ surplus: JBTokenAmount({
328
+ token: JBConstants.NATIVE_TOKEN,
329
+ value: 100 ether,
330
+ decimals: 18,
331
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
332
+ }),
333
+ useTotalSurplus: true,
334
+ cashOutTaxRate: 3000,
335
+ beneficiaryIsFeeless: false,
336
+ metadata: ""
337
+ });
338
+
339
+ (
340
+ uint256 cashOutTaxRate,
341
+ uint256 cashOutCount,
342
+ uint256 totalSupply,
343
+ JBCashOutHookSpecification[] memory hookSpecifications
344
+ ) = REV_DEPLOYER.beforeCashOutRecordedWith(context);
345
+
346
+ uint256 feeCashOutCount = context.cashOutCount * REV_DEPLOYER.FEE() / JBConstants.MAX_FEE;
347
+ uint256 nonFeeCashOutCount = context.cashOutCount - feeCashOutCount;
348
+ uint256 postFeeReclaimedAmount = JBCashOuts.cashOutFrom({
349
+ surplus: context.surplus.value,
350
+ cashOutCount: nonFeeCashOutCount,
351
+ totalSupply: context.totalSupply,
352
+ cashOutTaxRate: context.cashOutTaxRate
353
+ });
354
+ uint256 feeAmount = JBCashOuts.cashOutFrom({
355
+ surplus: context.surplus.value - postFeeReclaimedAmount,
356
+ cashOutCount: feeCashOutCount,
357
+ totalSupply: context.totalSupply - nonFeeCashOutCount,
358
+ cashOutTaxRate: context.cashOutTaxRate
359
+ });
360
+
361
+ assertEq(cashOutTaxRate, JBConstants.MAX_CASH_OUT_TAX_RATE, "Buyback cash out tax rate should be forwarded");
362
+ assertEq(cashOutCount, nonFeeCashOutCount, "Buyback should receive the non-fee cash out count");
363
+ assertEq(totalSupply, context.totalSupply, "Total supply should pass through");
364
+ assertEq(hookSpecifications.length, 2, "Buyback spec and revnet fee spec should both be returned");
365
+
366
+ assertEq(address(hookSpecifications[0].hook), address(MOCK_BUYBACK), "First hook spec should come from buyback");
367
+ assertEq(hookSpecifications[0].amount, 0, "Buyback sell-side spec should preserve its forwarded amount");
368
+ assertEq(hookSpecifications[0].metadata, buybackMetadata, "Buyback metadata should be preserved");
369
+
370
+ assertEq(address(hookSpecifications[1].hook), address(REV_DEPLOYER), "Second hook spec should charge fee");
371
+ assertEq(hookSpecifications[1].amount, feeAmount, "Fee spec amount should match the revnet fee math");
372
+ assertEq(
373
+ hookSpecifications[1].metadata,
374
+ abi.encode(jbMultiTerminal()),
375
+ "Fee spec metadata should encode the fee terminal"
376
+ );
377
+ }
378
+
305
379
  /// @notice Test that afterCashOutRecordedWith has no access control — anyone can call it.
306
380
  /// A non-terminal caller would just be donating their own funds as fees.
307
381
  function test_nonTerminalCaller_justDonatesOwnFunds() public {
@@ -627,7 +627,9 @@ contract TestLowFindings is TestBaseWorkflow {
627
627
  assertEq(borrowable, 0, "Borrowable amount for 1 wei of collateral should be 0");
628
628
 
629
629
  // Mock the BURN permission (permission ID 11) for the loans contract.
630
- mockExpect(
630
+ // Use vm.mockCall only (not mockExpect which also adds vm.expectCall) because
631
+ // borrowFrom reverts with REVLoans_ZeroBorrowAmount before the permission check is reached.
632
+ vm.mockCall(
631
633
  address(jbPermissions()),
632
634
  abi.encodeCall(IJBPermissions.hasPermission, (address(LOANS_CONTRACT), USER, revnetId, 11, true, true)),
633
635
  abi.encode(true)
@@ -240,14 +240,16 @@ contract TestMixedFixes is TestBaseWorkflow {
240
240
 
241
241
  REVLoan memory loan = LOANS_CONTRACT.loanOf(loanId);
242
242
 
243
- // Warp to exactly LOAN_LIQUIDATION_DURATION after creation
244
- vm.warp(loan.createdAt + LOANS_CONTRACT.LOAN_LIQUIDATION_DURATION());
243
+ // Warp to one second past LOAN_LIQUIDATION_DURATION after creation.
244
+ // The contract uses `>` (not `>=`) so the exact boundary is still repayable;
245
+ // we need to exceed the boundary by 1 second to trigger the revert.
246
+ vm.warp(loan.createdAt + LOANS_CONTRACT.LOAN_LIQUIDATION_DURATION() + 1);
245
247
 
246
- // With the >= fix, this should revert because timeSinceLoanCreated == LOAN_LIQUIDATION_DURATION
248
+ // timeSinceLoanCreated > LOAN_LIQUIDATION_DURATION revert
247
249
  vm.expectRevert(
248
250
  abi.encodeWithSelector(
249
251
  REVLoans.REVLoans_LoanExpired.selector,
250
- LOANS_CONTRACT.LOAN_LIQUIDATION_DURATION(),
252
+ LOANS_CONTRACT.LOAN_LIQUIDATION_DURATION() + 1,
251
253
  LOANS_CONTRACT.LOAN_LIQUIDATION_DURATION()
252
254
  )
253
255
  );
@@ -225,7 +225,8 @@ contract TestSplitWeightAdjustment is TestBaseWorkflow {
225
225
 
226
226
  // Mock 721 hook returning 0.3 ETH split on 1 ETH payment.
227
227
  JBPayHookSpecification[] memory hookSpecs = new JBPayHookSpecification[](1);
228
- hookSpecs[0] = JBPayHookSpecification({hook: IJBPayHook(mock721), amount: 0.3 ether, metadata: bytes("")});
228
+ hookSpecs[0] =
229
+ JBPayHookSpecification({hook: IJBPayHook(mock721), noop: false, amount: 0.3 ether, metadata: bytes("")});
229
230
  vm.mockCall(
230
231
  mock721,
231
232
  abi.encodeWithSelector(IJBRulesetDataHook.beforePayRecordedWith.selector),
@@ -267,7 +268,8 @@ contract TestSplitWeightAdjustment is TestBaseWorkflow {
267
268
 
268
269
  // Mock 721 hook returning full 1 ETH split.
269
270
  JBPayHookSpecification[] memory hookSpecs = new JBPayHookSpecification[](1);
270
- hookSpecs[0] = JBPayHookSpecification({hook: IJBPayHook(mock721), amount: 1 ether, metadata: bytes("")});
271
+ hookSpecs[0] =
272
+ JBPayHookSpecification({hook: IJBPayHook(mock721), noop: false, amount: 1 ether, metadata: bytes("")});
271
273
  vm.mockCall(
272
274
  mock721,
273
275
  abi.encodeWithSelector(IJBRulesetDataHook.beforePayRecordedWith.selector),
@@ -351,7 +353,8 @@ contract TestSplitWeightAdjustment is TestBaseWorkflow {
351
353
 
352
354
  // Mock 721 hook returning 0.4 ETH split on 1 ETH payment.
353
355
  JBPayHookSpecification[] memory hookSpecs = new JBPayHookSpecification[](1);
354
- hookSpecs[0] = JBPayHookSpecification({hook: IJBPayHook(mock721), amount: 0.4 ether, metadata: bytes("")});
356
+ hookSpecs[0] =
357
+ JBPayHookSpecification({hook: IJBPayHook(mock721), noop: false, amount: 0.4 ether, metadata: bytes("")});
355
358
  vm.mockCall(
356
359
  mock721,
357
360
  abi.encodeWithSelector(IJBRulesetDataHook.beforePayRecordedWith.selector),
@@ -400,7 +403,8 @@ contract TestSplitWeightAdjustment is TestBaseWorkflow {
400
403
 
401
404
  // Mock 721 hook returning 0.2 ETH split.
402
405
  JBPayHookSpecification[] memory hookSpecs = new JBPayHookSpecification[](1);
403
- hookSpecs[0] = JBPayHookSpecification({hook: IJBPayHook(mock721), amount: 0.2 ether, metadata: bytes("")});
406
+ hookSpecs[0] =
407
+ JBPayHookSpecification({hook: IJBPayHook(mock721), noop: false, amount: 0.2 ether, metadata: bytes("")});
404
408
  vm.mockCall(
405
409
  mock721,
406
410
  abi.encodeWithSelector(IJBRulesetDataHook.beforePayRecordedWith.selector),
@@ -449,7 +453,8 @@ contract TestSplitWeightAdjustment is TestBaseWorkflow {
449
453
  // Mock 721 hook returning split amount with metadata.
450
454
  bytes memory splitMeta = abi.encode(uint256(42));
451
455
  JBPayHookSpecification[] memory hookSpecs = new JBPayHookSpecification[](1);
452
- hookSpecs[0] = JBPayHookSpecification({hook: IJBPayHook(mock721), amount: 0.5 ether, metadata: splitMeta});
456
+ hookSpecs[0] =
457
+ JBPayHookSpecification({hook: IJBPayHook(mock721), noop: false, amount: 0.5 ether, metadata: splitMeta});
453
458
  vm.mockCall(
454
459
  mock721,
455
460
  abi.encodeWithSelector(IJBRulesetDataHook.beforePayRecordedWith.selector),