@rev-net/core-v6 0.0.30 → 0.0.31

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 (62) hide show
  1. package/USER_JOURNEYS.md +11 -0
  2. package/package.json +8 -8
  3. package/script/Deploy.s.sol +4 -1
  4. package/src/REVDeployer.sol +4 -2
  5. package/src/REVLoans.sol +81 -59
  6. package/src/REVOwner.sol +40 -11
  7. package/src/interfaces/IREVLoans.sol +5 -0
  8. package/test/REV.integrations.t.sol +10 -1
  9. package/test/REVAutoIssuanceFuzz.t.sol +10 -1
  10. package/test/REVDeployerRegressions.t.sol +12 -1
  11. package/test/REVInvincibility.t.sol +21 -2
  12. package/test/REVLifecycle.t.sol +12 -1
  13. package/test/REVLoans.invariants.t.sol +12 -1
  14. package/test/REVLoansAttacks.t.sol +12 -1
  15. package/test/REVLoansFeeRecovery.t.sol +12 -1
  16. package/test/REVLoansFindings.t.sol +12 -1
  17. package/test/REVLoansRegressions.t.sol +12 -1
  18. package/test/REVLoansSourceFeeRecovery.t.sol +12 -1
  19. package/test/REVLoansSourced.t.sol +12 -1
  20. package/test/REVLoansUnSourced.t.sol +12 -1
  21. package/test/TestBurnHeldTokens.t.sol +12 -1
  22. package/test/TestCEIPattern.t.sol +12 -1
  23. package/test/TestCashOutCallerValidation.t.sol +13 -2
  24. package/test/TestConversionDocumentation.t.sol +12 -1
  25. package/test/TestCrossCurrencyReclaim.t.sol +12 -1
  26. package/test/TestCrossSourceReallocation.t.sol +12 -1
  27. package/test/TestERC2771MetaTx.t.sol +12 -1
  28. package/test/TestEmptyBuybackSpecs.t.sol +12 -1
  29. package/test/TestFlashLoanSurplus.t.sol +12 -1
  30. package/test/TestHiddenTokens.t.sol +12 -1
  31. package/test/TestHookArrayOOB.t.sol +12 -1
  32. package/test/TestLiquidationBehavior.t.sol +12 -1
  33. package/test/TestLoanSourceRotation.t.sol +12 -1
  34. package/test/TestLoansCashOutDelay.t.sol +12 -1
  35. package/test/TestLongTailEconomics.t.sol +12 -1
  36. package/test/TestLowFindings.t.sol +12 -1
  37. package/test/TestMixedFixes.t.sol +12 -1
  38. package/test/TestPermit2Signatures.t.sol +12 -1
  39. package/test/TestReallocationSandwich.t.sol +12 -1
  40. package/test/TestRevnetRegressions.t.sol +14 -2
  41. package/test/TestSplitWeightAdjustment.t.sol +12 -1
  42. package/test/TestSplitWeightE2E.t.sol +14 -1
  43. package/test/TestSplitWeightFork.t.sol +14 -1
  44. package/test/TestStageTransitionBorrowable.t.sol +12 -1
  45. package/test/TestSwapTerminalPermission.t.sol +12 -1
  46. package/test/TestUint112Overflow.t.sol +12 -1
  47. package/test/TestZeroAmountLoanGuard.t.sol +12 -1
  48. package/test/TestZeroRepayment.t.sol +12 -1
  49. package/test/audit/CodexPhantomSurplusTerminal.t.sol +367 -0
  50. package/test/audit/LoanIdOverflowGuard.t.sol +12 -1
  51. package/test/audit/NemesisOperatorDelegation.t.sol +12 -1
  52. package/test/fork/ForkTestBase.sol +14 -1
  53. package/test/mock/MockBuybackCashOutRecorder.sol +2 -0
  54. package/test/mock/MockBuybackDataHook.sol +3 -1
  55. package/test/mock/MockBuybackDataHookMintPath.sol +2 -0
  56. package/test/mock/MockSuckerRegistry.sol +17 -0
  57. package/test/regression/TestBurnPermissionRequired.t.sol +12 -1
  58. package/test/regression/TestCashOutBuybackFeeLeak.t.sol +14 -1
  59. package/test/regression/TestCrossRevnetLiquidation.t.sol +12 -1
  60. package/test/regression/TestCumulativeLoanCounter.t.sol +12 -1
  61. package/test/regression/TestLiquidateGapHandling.t.sol +12 -1
  62. package/test/regression/TestZeroPriceFeed.t.sol +12 -1
package/USER_JOURNEYS.md CHANGED
@@ -93,6 +93,17 @@
93
93
  2. Use only those surfaces rather than treating the project like a normal owner-governed Juicebox project.
94
94
  3. Audit cross-package behavior whenever the Revnet enabled buybacks, 721 hooks, router terminals, or suckers.
95
95
 
96
+ ## Journey 7: Receive Cross-Chain Payments With Correct Hook Routing
97
+
98
+ **Starting state:** a sucker pays the Revnet on behalf of a remote user via `payRemote`, and the hooks attached via `REVOwner.beforePayRecordedWith` need to see the real user.
99
+
100
+ **Success:** the 721 hook and buyback hook see the real remote user as the beneficiary so NFTs mint to and buyback routing benefits the correct person.
101
+
102
+ **Flow**
103
+ 1. The sucker calls `terminal.pay()` with relay-beneficiary metadata.
104
+ 2. `REVOwner.beforePayRecordedWith()` resolves the relay beneficiary from the metadata when the payer is a registered sucker.
105
+ 3. The swapped beneficiary is forwarded to both the 721 hook and the buyback hook.
106
+
96
107
  ## Hand-Offs
97
108
 
98
109
  - Use [nana-core-v6](../nana-core-v6/USER_JOURNEYS.md) for the underlying project, terminal, and ruleset mechanics that Revnets package and constrain.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rev-net/core-v6",
3
- "version": "0.0.30",
3
+ "version": "0.0.31",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,14 +19,14 @@
19
19
  "artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'revnet-core-v6'"
20
20
  },
21
21
  "dependencies": {
22
- "@bananapus/721-hook-v6": "^0.0.32",
23
- "@bananapus/buyback-hook-v6": "^0.0.26",
24
- "@bananapus/core-v6": "^0.0.32",
22
+ "@bananapus/721-hook-v6": "^0.0.33",
23
+ "@bananapus/buyback-hook-v6": "^0.0.27",
24
+ "@bananapus/core-v6": "^0.0.34",
25
25
  "@bananapus/ownable-v6": "^0.0.17",
26
- "@bananapus/permission-ids-v6": "^0.0.16",
26
+ "@bananapus/permission-ids-v6": "^0.0.17",
27
27
  "@bananapus/router-terminal-v6": "^0.0.26",
28
- "@bananapus/suckers-v6": "^0.0.22",
29
- "@croptop/core-v6": "^0.0.30",
28
+ "@bananapus/suckers-v6": "^0.0.25",
29
+ "@croptop/core-v6": "github:mejango/croptop-core-v6",
30
30
  "@openzeppelin/contracts": "^5.6.1",
31
31
  "@uniswap/v4-core": "^1.0.2",
32
32
  "@uniswap/v4-periphery": "^1.0.3"
@@ -35,4 +35,4 @@
35
35
  "@bananapus/address-registry-v6": "^0.0.17",
36
36
  "@sphinx-labs/plugins": "^0.33.2"
37
37
  }
38
- }
38
+ }
@@ -389,7 +389,9 @@ contract DeployScript is Script, Sphinx {
389
389
  (address _candidateRevloansAddr, bool _candidateRevloansDeployed) = _isDeployed({
390
390
  salt: REVLOANS_SALT,
391
391
  creationCode: type(REVLoans).creationCode,
392
- arguments: abi.encode(core.controller, _candidateId, LOANS_OWNER, PERMIT2, TRUSTED_FORWARDER)
392
+ arguments: abi.encode(
393
+ core.controller, suckers.registry, _candidateId, LOANS_OWNER, PERMIT2, TRUSTED_FORWARDER
394
+ )
393
395
  });
394
396
 
395
397
  if (_candidateRevloansDeployed) {
@@ -469,6 +471,7 @@ contract DeployScript is Script, Sphinx {
469
471
  ? REVLoans(payable(_existingRevloansAddr))
470
472
  : new REVLoans{salt: REVLOANS_SALT}({
471
473
  controller: core.controller,
474
+ suckerRegistry: suckers.registry,
472
475
  revId: FEE_PROJECT_ID,
473
476
  owner: LOANS_OWNER,
474
477
  permit2: PERMIT2,
@@ -375,7 +375,7 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IERC721Receiver {
375
375
  uint256[] memory customSplitOperatorPermissionIndexes = _extraOperatorPermissions[revnetId];
376
376
 
377
377
  // Make the array that merges the default and custom operator permissions.
378
- allOperatorPermissions = new uint256[](9 + customSplitOperatorPermissionIndexes.length);
378
+ allOperatorPermissions = new uint256[](10 + customSplitOperatorPermissionIndexes.length);
379
379
  allOperatorPermissions[0] = JBPermissionIds.SET_SPLIT_GROUPS;
380
380
  allOperatorPermissions[1] = JBPermissionIds.SET_BUYBACK_POOL;
381
381
  allOperatorPermissions[2] = JBPermissionIds.SET_BUYBACK_TWAP;
@@ -385,10 +385,11 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IERC721Receiver {
385
385
  allOperatorPermissions[6] = JBPermissionIds.SET_BUYBACK_HOOK;
386
386
  allOperatorPermissions[7] = JBPermissionIds.SET_ROUTER_TERMINAL;
387
387
  allOperatorPermissions[8] = JBPermissionIds.SET_TOKEN_METADATA;
388
+ allOperatorPermissions[9] = JBPermissionIds.SIGN_FOR_ERC20;
388
389
 
389
390
  // Copy the custom permissions into the array.
390
391
  for (uint256 i; i < customSplitOperatorPermissionIndexes.length;) {
391
- allOperatorPermissions[9 + i] = customSplitOperatorPermissionIndexes[i];
392
+ allOperatorPermissions[10 + i] = customSplitOperatorPermissionIndexes[i];
392
393
  unchecked {
393
394
  ++i;
394
395
  }
@@ -833,6 +834,7 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IERC721Receiver {
833
834
  // Store the cash out delay of the revnet if its stages are already in progress.
834
835
  // This prevents cash out liquidity/arbitrage issues for existing revnets which
835
836
  // are deploying to a new chain.
837
+ // slither-disable-next-line reentrancy-events
836
838
  _setCashOutDelayIfNeeded({revnetId: revnetId, firstStageConfig: configuration.stageConfigurations[0]});
837
839
 
838
840
  // Deploy the revnet's ERC-20 token.
package/src/REVLoans.sol CHANGED
@@ -1,15 +1,14 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity 0.8.28;
3
3
 
4
+ import {JBPermissioned} from "@bananapus/core-v6/src/abstract/JBPermissioned.sol";
4
5
  import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
5
6
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
6
7
  import {IJBPayoutTerminal} from "@bananapus/core-v6/src/interfaces/IJBPayoutTerminal.sol";
7
8
  import {IJBPermissioned} from "@bananapus/core-v6/src/interfaces/IJBPermissioned.sol";
8
- import {JBPermissioned} from "@bananapus/core-v6/src/abstract/JBPermissioned.sol";
9
9
  import {IJBPrices} from "@bananapus/core-v6/src/interfaces/IJBPrices.sol";
10
10
  import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
11
11
  import {IJBTokenUriResolver} from "@bananapus/core-v6/src/interfaces/IJBTokenUriResolver.sol";
12
- import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
13
12
  import {JBCashOuts} from "@bananapus/core-v6/src/libraries/JBCashOuts.sol";
14
13
  import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
15
14
  import {JBFees} from "@bananapus/core-v6/src/libraries/JBFees.sol";
@@ -18,6 +17,8 @@ import {JBSurplus} from "@bananapus/core-v6/src/libraries/JBSurplus.sol";
18
17
  import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
19
18
  import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
20
19
  import {JBSingleAllowance} from "@bananapus/core-v6/src/structs/JBSingleAllowance.sol";
20
+ import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
21
+ import {IJBSuckerRegistry} from "@bananapus/suckers-v6/src/interfaces/IJBSuckerRegistry.sol";
21
22
  import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
22
23
  import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol";
23
24
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
@@ -124,6 +125,9 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
124
125
  /// @notice The ID of the REV revnet that will receive the fees.
125
126
  uint256 public immutable override REV_ID;
126
127
 
128
+ /// @notice The sucker registry used to discover peer chain suckers for cross-chain awareness.
129
+ IJBSuckerRegistry public immutable override SUCKER_REGISTRY;
130
+
127
131
  //*********************************************************************//
128
132
  // --------------------- public stored properties -------------------- //
129
133
  //*********************************************************************//
@@ -180,12 +184,14 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
180
184
  //*********************************************************************//
181
185
 
182
186
  /// @param controller The controller that manages revnets using this loans contract.
187
+ /// @param suckerRegistry The registry used to discover peer chain suckers for cross-chain supply/surplus awareness.
183
188
  /// @param revId The ID of the REV revnet that will receive the fees.
184
189
  /// @param owner The owner of the contract that can set the URI resolver.
185
190
  /// @param permit2 A permit2 utility.
186
191
  /// @param trustedForwarder A trusted forwarder of transactions to this contract.
187
192
  constructor(
188
193
  IJBController controller,
194
+ IJBSuckerRegistry suckerRegistry,
189
195
  uint256 revId,
190
196
  address owner,
191
197
  IPermit2 permit2,
@@ -201,6 +207,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
201
207
  PRICES = controller.PRICES();
202
208
  REV_ID = revId;
203
209
  PERMIT2 = permit2;
210
+ SUCKER_REGISTRY = suckerRegistry;
204
211
  }
205
212
 
206
213
  //*********************************************************************//
@@ -355,17 +362,29 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
355
362
  // Get a refeerence to the collateral being used to secure loans.
356
363
  uint256 totalCollateral = totalCollateralOf[revnetId];
357
364
 
365
+ // The local supply includes both circulating tokens and tokens locked as loan collateral.
366
+ uint256 localSupply = totalSupply + totalCollateral;
367
+
368
+ // The local surplus includes both the treasury surplus and the outstanding borrowed amounts.
369
+ uint256 localSurplus = totalSurplus + totalBorrowed;
370
+
358
371
  // Proportional — uses the CURRENT stage's cashOutTaxRate.
359
372
  // NOTE: When a revnet transitions between stages with different cashOutTaxRate values, the borrowable amount
360
373
  // for the same collateral changes. A lower cashOutTaxRate in a later stage means more borrowable value per
361
374
  // collateral. This is by design: loan value tracks the current bonding curve parameters, just as cash-out
362
375
  // value does. Borrowers benefit from decreasing tax rates and are constrained by increasing ones.
363
- return JBCashOuts.cashOutFrom({
364
- surplus: totalSurplus + totalBorrowed,
376
+ // Add cross-chain remote values for proportional reclaim.
377
+ uint256 omnichainSurplus =
378
+ localSurplus + SUCKER_REGISTRY.remoteSurplusOf({projectId: revnetId, decimals: 18, currency: currency});
379
+ uint256 omnichainSupply = localSupply + SUCKER_REGISTRY.remoteTotalSupplyOf(revnetId);
380
+ uint256 reclaimable = JBCashOuts.cashOutFrom({
381
+ surplus: omnichainSurplus,
365
382
  cashOutCount: collateralCount,
366
- totalSupply: totalSupply + totalCollateral,
383
+ totalSupply: omnichainSupply,
367
384
  cashOutTaxRate: currentStage.cashOutTaxRate()
368
385
  });
386
+ // Cap at local surplus — can't borrow more than what this chain's terminals actually hold.
387
+ return reclaimable > localSurplus ? localSurplus : reclaimable;
369
388
  }
370
389
 
371
390
  /// @notice The amount of the loan that should be borrowed for the given collateral amount.
@@ -1078,33 +1097,16 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1078
1097
  ? 0
1079
1098
  : JBFees.feeAmountFrom({amountBeforeFee: addedBorrowAmount, feePercent: REV_PREPAID_FEE_PERCENT});
1080
1099
 
1100
+ // Try to pay the REV fee. If it fails, revFeeAmount is zeroed so the borrower receives it instead.
1081
1101
  if (revFeeAmount > 0) {
1082
- // Increase the allowance for the fee terminal.
1083
- uint256 payValue =
1084
- _beforeTransferTo({to: address(feeTerminal), token: loan.source.token, amount: revFeeAmount});
1085
-
1086
- // Pay the fee. Send the REV to the beneficiary. If fee payment fails, give the amount back to the borrower.
1087
- // NOTE: When terminal.pay() reverts (e.g. due to a misconfigured terminal or paused payments),
1088
- // the REV fee is refunded to the borrower, resulting in an effectively interest-free loan for the
1089
- // REV fee portion. This is acceptable — it requires a broken/misconfigured fee terminal and the
1090
- // borrower still pays the source fee and protocol fee.
1091
- // slither-disable-next-line arbitrary-send-eth,unused-return
1092
- try feeTerminal.pay{value: payValue}({
1093
- projectId: REV_ID,
1094
- token: loan.source.token,
1095
- amount: revFeeAmount,
1096
- beneficiary: beneficiary,
1097
- minReturnedTokens: 0,
1098
- memo: "",
1099
- metadata: bytes(abi.encodePacked(revnetId))
1100
- }) {}
1101
- catch (bytes memory) {
1102
- // If the fee can't be processed, decrease the ERC-20 allowance and zero out the fee
1103
- // so the borrower receives it instead.
1104
- if (loan.source.token != JBConstants.NATIVE_TOKEN) {
1105
- IERC20(loan.source.token)
1106
- .safeDecreaseAllowance({spender: address(feeTerminal), requestedDecrease: revFeeAmount});
1107
- }
1102
+ if (!_tryPayFee({
1103
+ terminal: feeTerminal,
1104
+ projectId: REV_ID,
1105
+ token: loan.source.token,
1106
+ amount: revFeeAmount,
1107
+ beneficiary: beneficiary,
1108
+ metadataProjectId: revnetId
1109
+ })) {
1108
1110
  revFeeAmount = 0;
1109
1111
  }
1110
1112
  }
@@ -1195,35 +1197,16 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1195
1197
  });
1196
1198
  }
1197
1199
 
1198
- // If there is a source fee, pay it. Wrapped in try-catch so a reverting source terminal
1199
- // cannot block all loan operations (matching the REV fee pattern above).
1200
+ // Try to pay the source fee. If it fails, transfer the amount to the beneficiary instead.
1200
1201
  if (sourceFeeAmount > 0) {
1201
- // Increase the allowance for the source terminal.
1202
- uint256 payValue =
1203
- _beforeTransferTo({to: address(sourceTerminal), token: sourceToken, amount: sourceFeeAmount});
1204
-
1205
- // Pay the fee. If it fails, reclaim the allowance and give the amount back to the borrower.
1206
- // NOTE: When terminal.pay() reverts (e.g. due to a misconfigured terminal or paused payments),
1207
- // the source fee is refunded to the borrower, resulting in an effectively interest-free loan for the
1208
- // source fee portion. This is acceptable — it requires a broken/misconfigured source terminal and
1209
- // the borrower still pays the REV fee and protocol fee.
1210
- // slither-disable-next-line unused-return,arbitrary-send-eth
1211
- try sourceTerminal.pay{value: payValue}({
1212
- projectId: revnetId,
1213
- token: sourceToken,
1214
- amount: sourceFeeAmount,
1215
- beneficiary: beneficiary,
1216
- minReturnedTokens: 0,
1217
- memo: "",
1218
- metadata: bytes(abi.encodePacked(REV_ID))
1219
- }) {}
1220
- catch (bytes memory) {
1221
- // If the fee can't be processed, decrease the ERC-20 allowance and return the amount
1222
- // to the beneficiary instead.
1223
- if (sourceToken != JBConstants.NATIVE_TOKEN) {
1224
- IERC20(sourceToken)
1225
- .safeDecreaseAllowance({spender: address(sourceTerminal), requestedDecrease: sourceFeeAmount});
1226
- }
1202
+ if (!_tryPayFee({
1203
+ terminal: IJBTerminal(address(sourceTerminal)),
1204
+ projectId: revnetId,
1205
+ token: sourceToken,
1206
+ amount: sourceFeeAmount,
1207
+ beneficiary: beneficiary,
1208
+ metadataProjectId: REV_ID
1209
+ })) {
1227
1210
  _transferFrom({from: address(this), to: beneficiary, token: sourceToken, amount: sourceFeeAmount});
1228
1211
  }
1229
1212
  }
@@ -1517,6 +1500,45 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1517
1500
  PERMIT2.transferFrom({from: from, to: to, amount: uint160(amount), token: token});
1518
1501
  }
1519
1502
 
1503
+ /// @notice Attempts to pay a fee to a terminal. On failure, cleans up the ERC-20 allowance and returns false.
1504
+ /// @param terminal The terminal to pay the fee to.
1505
+ /// @param projectId The project receiving the fee.
1506
+ /// @param token The token being used to pay the fee.
1507
+ /// @param amount The fee amount.
1508
+ /// @param beneficiary The address to credit for the fee payment.
1509
+ /// @param metadataProjectId The project ID encoded in the payment metadata.
1510
+ /// @return success Whether the fee was successfully paid.
1511
+ function _tryPayFee(
1512
+ IJBTerminal terminal,
1513
+ uint256 projectId,
1514
+ address token,
1515
+ uint256 amount,
1516
+ address beneficiary,
1517
+ uint256 metadataProjectId
1518
+ )
1519
+ internal
1520
+ returns (bool success)
1521
+ {
1522
+ uint256 payValue = _beforeTransferTo({to: address(terminal), token: token, amount: amount});
1523
+
1524
+ // slither-disable-next-line arbitrary-send-eth,unused-return
1525
+ try terminal.pay{value: payValue}({
1526
+ projectId: projectId,
1527
+ token: token,
1528
+ amount: amount,
1529
+ beneficiary: beneficiary,
1530
+ minReturnedTokens: 0,
1531
+ memo: "",
1532
+ metadata: bytes(abi.encodePacked(metadataProjectId))
1533
+ }) {
1534
+ success = true;
1535
+ } catch (bytes memory) {
1536
+ if (token != JBConstants.NATIVE_TOKEN) {
1537
+ IERC20(token).safeDecreaseAllowance({spender: address(terminal), requestedDecrease: amount});
1538
+ }
1539
+ }
1540
+ }
1541
+
1520
1542
  fallback() external payable {}
1521
1543
  receive() external payable {}
1522
1544
  }
package/src/REVOwner.sol CHANGED
@@ -137,7 +137,8 @@ contract REVOwner is IJBRulesetDataHook, IJBCashOutHook {
137
137
  /// @return cashOutTaxRate The cash out tax rate, which influences the amount of terminal tokens which get cashed
138
138
  /// out.
139
139
  /// @return cashOutCount The number of revnet tokens that are cashed out.
140
- /// @return totalSupply The total revnet token supply.
140
+ /// @return totalSupply The total token supply across all chains (for both proportional reclaim and tax).
141
+ /// @return effectiveSurplusValue The global surplus across all chains for proportional reclaim.
141
142
  /// @return hookSpecifications The amount of funds and the data to send to cash out hooks (this contract).
142
143
  function beforeCashOutRecordedWith(JBBeforeCashOutRecordedContext calldata context)
143
144
  external
@@ -147,6 +148,7 @@ contract REVOwner is IJBRulesetDataHook, IJBCashOutHook {
147
148
  uint256 cashOutTaxRate,
148
149
  uint256 cashOutCount,
149
150
  uint256 totalSupply,
151
+ uint256 effectiveSurplusValue,
150
152
  JBCashOutHookSpecification[] memory hookSpecifications
151
153
  )
152
154
  {
@@ -154,7 +156,7 @@ contract REVOwner is IJBRulesetDataHook, IJBCashOutHook {
154
156
  // This relies on the sucker registry to only contain trusted sucker contracts deployed via
155
157
  // the registry's own deploySuckersFor flow — external addresses cannot register as suckers.
156
158
  if (_isSuckerOf({revnetId: context.projectId, addr: context.holder})) {
157
- return (0, context.cashOutCount, context.totalSupply, hookSpecifications);
159
+ return (0, context.cashOutCount, context.totalSupply, context.surplus.value, hookSpecifications);
158
160
  }
159
161
 
160
162
  // Keep a reference to the cash out delay of the revnet.
@@ -168,11 +170,23 @@ contract REVOwner is IJBRulesetDataHook, IJBCashOutHook {
168
170
  // Get the terminal that will receive the cash out fee.
169
171
  IJBTerminal feeTerminal = DIRECTORY.primaryTerminalOf({projectId: FEE_REVNET_ID, token: context.surplus.token});
170
172
 
173
+ // Compute the cross-chain total supply (local + remote peer chain supplies) for cross-chain-aware bonding
174
+ // curve.
175
+ totalSupply = context.totalSupply + SUCKER_REGISTRY.remoteTotalSupplyOf(context.projectId);
176
+ effectiveSurplusValue = context.surplus.value
177
+ + SUCKER_REGISTRY.remoteSurplusOf({
178
+ projectId: context.projectId,
179
+ decimals: context.surplus.decimals,
180
+ currency: uint256(uint160(context.surplus.token))
181
+ });
182
+
171
183
  // If there's no cash out tax (100% cash out tax rate), if there's no fee terminal, or if the beneficiary is
172
- // feeless (e.g. the router terminal routing value between projects), proxy directly to the buyback hook.
184
+ // feeless (e.g. the router terminal routing value between projects), proxy to the buyback hook with our
185
+ // totalSupply and effectiveSurplusValue.
173
186
  if (context.cashOutTaxRate == 0 || address(feeTerminal) == address(0) || context.beneficiaryIsFeeless) {
174
187
  // slither-disable-next-line unused-return
175
- return BUYBACK_HOOK.beforeCashOutRecordedWith(context);
188
+ (cashOutTaxRate, cashOutCount,,, hookSpecifications) = BUYBACK_HOOK.beforeCashOutRecordedWith(context);
189
+ return (cashOutTaxRate, cashOutCount, totalSupply, effectiveSurplusValue, hookSpecifications);
176
190
  }
177
191
 
178
192
  // Split the cashed-out tokens into a fee portion and a non-fee portion.
@@ -185,20 +199,33 @@ contract REVOwner is IJBRulesetDataHook, IJBCashOutHook {
185
199
  uint256 nonFeeCashOutCount = context.cashOutCount - feeCashOutCount;
186
200
 
187
201
  // Calculate how much surplus the non-fee tokens can reclaim via the bonding curve.
202
+ // Use effective (cross-chain) surplus; cap at local surplus.
188
203
  uint256 postFeeReclaimedAmount = JBCashOuts.cashOutFrom({
189
- surplus: context.surplus.value,
204
+ surplus: effectiveSurplusValue,
190
205
  cashOutCount: nonFeeCashOutCount,
191
- totalSupply: context.totalSupply,
206
+ totalSupply: totalSupply,
192
207
  cashOutTaxRate: context.cashOutTaxRate
193
208
  });
209
+ // Cap at local surplus — the bonding curve uses cross-chain effective surplus which can exceed what this
210
+ // chain's terminal actually holds.
211
+ if (postFeeReclaimedAmount > context.surplus.value) postFeeReclaimedAmount = context.surplus.value;
194
212
 
195
213
  // Calculate how much the fee tokens reclaim from the remaining surplus after the non-fee reclaim.
214
+ // Use remaining effective surplus; cap at remaining local surplus.
196
215
  uint256 feeAmount = JBCashOuts.cashOutFrom({
197
- surplus: context.surplus.value - postFeeReclaimedAmount,
216
+ surplus: effectiveSurplusValue > postFeeReclaimedAmount
217
+ ? effectiveSurplusValue - postFeeReclaimedAmount
218
+ : 0,
198
219
  cashOutCount: feeCashOutCount,
199
- totalSupply: context.totalSupply - nonFeeCashOutCount,
220
+ totalSupply: totalSupply - nonFeeCashOutCount,
200
221
  cashOutTaxRate: context.cashOutTaxRate
201
222
  });
223
+ // Cap the fee reclaim at remaining local surplus. The bonding curve uses the cross-chain effective surplus,
224
+ // which can exceed what's actually held locally. Without this cap, the terminal would try to send more than
225
+ // it has.
226
+ if (feeAmount > context.surplus.value - postFeeReclaimedAmount) {
227
+ feeAmount = context.surplus.value - postFeeReclaimedAmount;
228
+ }
202
229
 
203
230
  // Build a context for the buyback hook using only the non-fee token count.
204
231
  JBBeforeCashOutRecordedContext memory buybackHookContext = context;
@@ -206,11 +233,13 @@ contract REVOwner is IJBRulesetDataHook, IJBCashOutHook {
206
233
 
207
234
  // Let the buyback hook adjust the cash out parameters and optionally return a hook specification.
208
235
  JBCashOutHookSpecification[] memory buybackHookSpecifications;
209
- (cashOutTaxRate, cashOutCount, totalSupply, buybackHookSpecifications) =
236
+ (cashOutTaxRate, cashOutCount,,, buybackHookSpecifications) =
210
237
  BUYBACK_HOOK.beforeCashOutRecordedWith(buybackHookContext);
211
238
 
212
239
  // If the fee rounds down to zero, return the buyback hook's response directly — no fee to process.
213
- if (feeAmount == 0) return (cashOutTaxRate, cashOutCount, totalSupply, buybackHookSpecifications);
240
+ if (feeAmount == 0) {
241
+ return (cashOutTaxRate, cashOutCount, totalSupply, effectiveSurplusValue, buybackHookSpecifications);
242
+ }
214
243
 
215
244
  // Build a hook spec that routes the fee amount to this contract's `afterCashOutRecordedWith` for processing.
216
245
  JBCashOutHookSpecification memory feeSpec = JBCashOutHookSpecification({
@@ -232,7 +261,7 @@ contract REVOwner is IJBRulesetDataHook, IJBCashOutHook {
232
261
  hookSpecifications[0] = feeSpec;
233
262
  }
234
263
 
235
- return (cashOutTaxRate, cashOutCount, totalSupply, hookSpecifications);
264
+ return (cashOutTaxRate, cashOutCount, totalSupply, effectiveSurplusValue, hookSpecifications);
236
265
  }
237
266
 
238
267
  /// @notice Before a revnet processes an incoming payment, determine the weight and pay hooks to use.
@@ -7,6 +7,7 @@ import {IJBPayoutTerminal} from "@bananapus/core-v6/src/interfaces/IJBPayoutTerm
7
7
  import {IJBPrices} from "@bananapus/core-v6/src/interfaces/IJBPrices.sol";
8
8
  import {IJBTokenUriResolver} from "@bananapus/core-v6/src/interfaces/IJBTokenUriResolver.sol";
9
9
  import {JBSingleAllowance} from "@bananapus/core-v6/src/structs/JBSingleAllowance.sol";
10
+ import {IJBSuckerRegistry} from "@bananapus/suckers-v6/src/interfaces/IJBSuckerRegistry.sol";
10
11
  import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
11
12
  import {REVLoan} from "../structs/REVLoan.sol";
12
13
  import {REVLoanSource} from "../structs/REVLoanSource.sol";
@@ -167,6 +168,10 @@ interface IREVLoans {
167
168
  /// @return The REV revnet ID.
168
169
  function REV_ID() external view returns (uint256);
169
170
 
171
+ /// @notice The sucker registry used to discover peer chain suckers for cross-chain supply/surplus awareness.
172
+ /// @return The sucker registry.
173
+ function SUCKER_REGISTRY() external view returns (IJBSuckerRegistry);
174
+
170
175
  /// @notice The fee percent charged by the REV revnet on each loan, in terms of `JBConstants.MAX_FEE`.
171
176
  /// @return The REV prepaid fee percent.
172
177
  function REV_PREPAID_FEE_PERCENT() external view returns (uint256);
@@ -40,6 +40,8 @@ import {JBArbitrumSucker, JBLayer, IArbGatewayRouter, IInbox} from "@bananapus/s
40
40
 
41
41
  import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
42
42
  import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
43
+ import {JB721CheckpointsDeployer} from "@bananapus/721-hook-v6/src/JB721CheckpointsDeployer.sol";
44
+ import {IJB721CheckpointsDeployer} from "@bananapus/721-hook-v6/src/interfaces/IJB721CheckpointsDeployer.sol";
43
45
  import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
44
46
  import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
45
47
 
@@ -211,7 +213,14 @@ contract REVnet_Integrations is TestBaseWorkflow {
211
213
  HOOK_STORE = new JB721TiersHookStore();
212
214
 
213
215
  EXAMPLE_HOOK = new JB721TiersHook(
214
- jbDirectory(), jbPermissions(), jbPrices(), jbRulesets(), HOOK_STORE, jbSplits(), multisig()
216
+ jbDirectory(),
217
+ jbPermissions(),
218
+ jbPrices(),
219
+ jbRulesets(),
220
+ HOOK_STORE,
221
+ jbSplits(),
222
+ IJB721CheckpointsDeployer(address(new JB721CheckpointsDeployer())),
223
+ multisig()
215
224
  );
216
225
 
217
226
  ADDRESS_REGISTRY = new JBAddressRegistry();
@@ -32,6 +32,8 @@ import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
32
32
  import {JB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
33
33
  import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
34
34
  import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
35
+ import {JB721CheckpointsDeployer} from "@bananapus/721-hook-v6/src/JB721CheckpointsDeployer.sol";
36
+ import {IJB721CheckpointsDeployer} from "@bananapus/721-hook-v6/src/interfaces/IJB721CheckpointsDeployer.sol";
35
37
  import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
36
38
  import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
37
39
  import {REVEmpty721Config} from "./helpers/REVEmpty721Config.sol";
@@ -77,7 +79,14 @@ contract REVAutoIssuanceFuzz_Local is TestBaseWorkflow {
77
79
  SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
78
80
  HOOK_STORE = new JB721TiersHookStore();
79
81
  EXAMPLE_HOOK = new JB721TiersHook(
80
- jbDirectory(), jbPermissions(), jbPrices(), jbRulesets(), HOOK_STORE, jbSplits(), multisig()
82
+ jbDirectory(),
83
+ jbPermissions(),
84
+ jbPrices(),
85
+ jbRulesets(),
86
+ HOOK_STORE,
87
+ jbSplits(),
88
+ IJB721CheckpointsDeployer(address(new JB721CheckpointsDeployer())),
89
+ multisig()
81
90
  );
82
91
  ADDRESS_REGISTRY = new JBAddressRegistry();
83
92
  HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
@@ -35,10 +35,13 @@ import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
35
35
  import {JB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
36
36
  import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
37
37
  import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
38
+ import {JB721CheckpointsDeployer} from "@bananapus/721-hook-v6/src/JB721CheckpointsDeployer.sol";
39
+ import {IJB721CheckpointsDeployer} from "@bananapus/721-hook-v6/src/interfaces/IJB721CheckpointsDeployer.sol";
38
40
  import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
39
41
  import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
40
42
  import {REVOwner} from "../src/REVOwner.sol";
41
43
  import {IREVDeployer} from "../src/interfaces/IREVDeployer.sol";
44
+ import {MockSuckerRegistry} from "./mock/MockSuckerRegistry.sol";
42
45
 
43
46
  /// @notice Regression tests for REVDeployer.
44
47
  contract REVDeployerRegressions is TestBaseWorkflow {
@@ -83,7 +86,14 @@ contract REVDeployerRegressions is TestBaseWorkflow {
83
86
  SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
84
87
  HOOK_STORE = new JB721TiersHookStore();
85
88
  EXAMPLE_HOOK = new JB721TiersHook(
86
- jbDirectory(), jbPermissions(), jbPrices(), jbRulesets(), HOOK_STORE, jbSplits(), multisig()
89
+ jbDirectory(),
90
+ jbPermissions(),
91
+ jbPrices(),
92
+ jbRulesets(),
93
+ HOOK_STORE,
94
+ jbSplits(),
95
+ IJB721CheckpointsDeployer(address(new JB721CheckpointsDeployer())),
96
+ multisig()
87
97
  );
88
98
  ADDRESS_REGISTRY = new JBAddressRegistry();
89
99
  HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
@@ -92,6 +102,7 @@ contract REVDeployerRegressions is TestBaseWorkflow {
92
102
 
93
103
  LOANS_CONTRACT = new REVLoans({
94
104
  controller: jbController(),
105
+ suckerRegistry: IJBSuckerRegistry(address(new MockSuckerRegistry())),
95
106
  revId: FEE_PROJECT_ID,
96
107
  owner: address(this),
97
108
  permit2: permit2(),
@@ -41,6 +41,8 @@ import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
41
41
  import {JB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
42
42
  import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
43
43
  import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
44
+ import {JB721CheckpointsDeployer} from "@bananapus/721-hook-v6/src/JB721CheckpointsDeployer.sol";
45
+ import {IJB721CheckpointsDeployer} from "@bananapus/721-hook-v6/src/interfaces/IJB721CheckpointsDeployer.sol";
44
46
  import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
45
47
  import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
46
48
  import {mulDiv} from "@prb/math/src/Common.sol";
@@ -50,6 +52,7 @@ import {BrokenFeeTerminal} from "./helpers/MaliciousContracts.sol";
50
52
  import {REVEmpty721Config} from "./helpers/REVEmpty721Config.sol";
51
53
  import {REVOwner} from "../src/REVOwner.sol";
52
54
  import {IREVDeployer} from "../src/interfaces/IREVDeployer.sol";
55
+ import {MockSuckerRegistry} from "./mock/MockSuckerRegistry.sol";
53
56
 
54
57
  // =========================================================================
55
58
  // Shared config struct
@@ -234,7 +237,14 @@ contract REVInvincibility_PropertyTests is TestBaseWorkflow {
234
237
  SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
235
238
  HOOK_STORE = new JB721TiersHookStore();
236
239
  EXAMPLE_HOOK = new JB721TiersHook(
237
- jbDirectory(), jbPermissions(), jbPrices(), jbRulesets(), HOOK_STORE, jbSplits(), multisig()
240
+ jbDirectory(),
241
+ jbPermissions(),
242
+ jbPrices(),
243
+ jbRulesets(),
244
+ HOOK_STORE,
245
+ jbSplits(),
246
+ IJB721CheckpointsDeployer(address(new JB721CheckpointsDeployer())),
247
+ multisig()
238
248
  );
239
249
  ADDRESS_REGISTRY = new JBAddressRegistry();
240
250
  HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
@@ -244,6 +254,7 @@ contract REVInvincibility_PropertyTests is TestBaseWorkflow {
244
254
 
245
255
  LOANS_CONTRACT = new REVLoans({
246
256
  controller: jbController(),
257
+ suckerRegistry: IJBSuckerRegistry(address(new MockSuckerRegistry())),
247
258
  revId: FEE_PROJECT_ID,
248
259
  owner: address(this),
249
260
  permit2: permit2(),
@@ -1019,7 +1030,14 @@ contract REVInvincibility_Invariants is StdInvariant, TestBaseWorkflow {
1019
1030
  SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
1020
1031
  HOOK_STORE = new JB721TiersHookStore();
1021
1032
  EXAMPLE_HOOK = new JB721TiersHook(
1022
- jbDirectory(), jbPermissions(), jbPrices(), jbRulesets(), HOOK_STORE, jbSplits(), multisig()
1033
+ jbDirectory(),
1034
+ jbPermissions(),
1035
+ jbPrices(),
1036
+ jbRulesets(),
1037
+ HOOK_STORE,
1038
+ jbSplits(),
1039
+ IJB721CheckpointsDeployer(address(new JB721CheckpointsDeployer())),
1040
+ multisig()
1023
1041
  );
1024
1042
  ADDRESS_REGISTRY = new JBAddressRegistry();
1025
1043
  HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
@@ -1028,6 +1046,7 @@ contract REVInvincibility_Invariants is StdInvariant, TestBaseWorkflow {
1028
1046
 
1029
1047
  LOANS_CONTRACT = new REVLoans({
1030
1048
  controller: jbController(),
1049
+ suckerRegistry: IJBSuckerRegistry(address(new MockSuckerRegistry())),
1031
1050
  revId: FEE_PROJECT_ID,
1032
1051
  owner: address(this),
1033
1052
  permit2: permit2(),