@rev-net/core-v6 0.0.35 → 0.0.37

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 (32) hide show
  1. package/RISKS.md +19 -1
  2. package/package.json +9 -9
  3. package/src/REVDeployer.sol +19 -10
  4. package/src/REVLoans.sol +138 -89
  5. package/src/REVOwner.sol +6 -4
  6. package/test/REV.integrations.t.sol +14 -14
  7. package/test/REVInvincibility.t.sol +16 -16
  8. package/test/REVLifecycle.t.sol +32 -32
  9. package/test/REVLoansSourced.t.sol +15 -15
  10. package/test/TestCashOutCallerValidation.t.sol +8 -8
  11. package/test/TestConversionDocumentation.t.sol +2 -5
  12. package/test/TestCrossCurrencyReclaim.t.sol +72 -72
  13. package/test/TestLongTailEconomics.t.sol +56 -56
  14. package/test/TestSwapTerminalPermission.t.sol +21 -21
  15. package/test/audit/HiddenSupplyCashout.t.sol +61 -0
  16. package/test/audit/NemesisVerification.t.sol +97 -0
  17. package/test/audit/REVOwnerCurrencyMismatch.t.sol +188 -0
  18. package/test/audit/REVOwnerRemoteSurplusCurrencyMismatch.t.sol +140 -0
  19. package/test/audit/ReallocatePermission.t.sol +363 -0
  20. package/test/audit/RemoteLoanAccountingGap.t.sol +74 -0
  21. package/test/audit/SupportsInterfaceTest.t.sol +51 -0
  22. package/test/audit/TestFeeAllowanceLeak.t.sol +197 -0
  23. package/test/audit/TestLoansAndDeployerFixes.t.sol +576 -0
  24. package/test/fork/TestCashOutFork.t.sol +48 -48
  25. package/test/fork/TestLoanAdversarialFork.t.sol +744 -0
  26. package/test/fork/TestLoanERC20Fork.t.sol +2 -8
  27. package/test/fork/TestPermit2PaymentFork.t.sol +32 -32
  28. package/test/regression/TestBurnPermissionRequired.t.sol +5 -5
  29. package/test/regression/TestCashOutBuybackFeeLeak.t.sol +8 -8
  30. /package/test/audit/{CodexCrossChainBuybackRouteMismatch.t.sol → CrossChainBuybackRouteMismatch.t.sol} +0 -0
  31. /package/test/audit/{NemesisOperatorDelegation.t.sol → OperatorDelegation.t.sol} +0 -0
  32. /package/test/audit/{CodexPhantomSurplusTerminal.t.sol → PhantomSurplusTerminal.t.sol} +0 -0
package/RISKS.md CHANGED
@@ -27,7 +27,7 @@ This file focuses on the staged-economics, runtime-hook, hidden-supply, and loan
27
27
 
28
28
  - **Stage immutability cuts both ways.** A bad stage schedule or bad cash-out tax choice is expensive to unwind.
29
29
  - **Borrowability depends on live economics.** If surplus, supply, or cross-chain state are wrong, loan capacity becomes wrong.
30
- - **Zero or degraded price feeds can undercount debt.** If a source becomes invisible to debt aggregation, later borrowing can become too permissive.
30
+ - **Zero or degraded price feeds can undercount debt.** If a source becomes invisible to debt aggregation, later borrowing can become too permissive. Specifically, `_debtOf` skips sources where `pricePerUnitOf` returns zero, treating them as if the borrower has no debt in that source. If a feed breaks or returns zero, existing debt in that currency is effectively invisible, inflating the borrower's apparent borrowable amount.
31
31
  - **Hidden-token mechanics change visible supply.** That affects per-token cash-out value and can change the economics seen by other holders.
32
32
  - **Auto-issuance dilutes holders predictably but still materially.** Timing is permissionless, even if the amounts are fixed at deployment.
33
33
  - **Omnichain expansion can corrupt surplus aggregation.** Since borrowability aggregates surplus from all registered terminals across chains, a compromised or misconfigured terminal on a remote chain affects global surplus accounting.
@@ -87,3 +87,21 @@ The model assumes that attempts to inflate surplus through donations are not pro
87
87
  ### 8.5 Omnichain terminal expansion inherits remote-chain trust
88
88
 
89
89
  A project that expands to a new chain can register additional terminals on that chain. Because borrowability calculations aggregate surplus from all registered terminals across all chains, a compromised or misconfigured terminal on a remote chain can corrupt the project's surplus accounting globally. This is mitigated by including terminal addresses in the `encodedConfigurationHash` — cross-chain expansions via suckers must use the exact same terminal address as the host chain. Terminal addresses are deterministic across chains (same CREATE2 deployment), so this prevents expansions from silently using a different terminal. Project operators should still treat each chain expansion as a trust-boundary decision since bridge integrity and network assumptions remain outside protocol control.
90
+
91
+ ### 8.6 Cross-chain surplus staleness
92
+
93
+ `REVLoans._borrowableAmountFrom` and `REVOwner.beforeCashOutRecordedWith` add `remoteSurplusOf()` and `remoteTotalSupplyOf()` to local values. These remote values update only when `toRemote()` is called on the peer chain -- no heartbeat or staleness check. Stale data can inflate per-token borrowable amounts when remote supply has grown since the last bridge message. Primary safeguard: borrowable is capped at `localSurplus` (REVLoans line 386-387), preventing extraction beyond what the local terminal holds.
94
+
95
+ ### 8.7 REVLoans CEI violation in `_adjust`
96
+
97
+ In `REVLoans._adjust`, `totalCollateralOf[revnetId]` is incremented after external calls (`useAllowanceOf`, fee payment). A reentrant `borrowFrom` would see a lower `totalCollateralOf`. This is documented inline (lines 1128-1132) and requires an adversarial pay hook on the revnet's own terminal -- a trust-level configuration that is not realistic in standard deployments.
98
+
99
+ ### 8.8 Remote loan corrections not reflected in local borrowability
100
+
101
+ `_borrowableAmountFrom` adds back local `totalBorrowed` and `totalCollateral` to reconstitute pre-loan economic state for the bonding curve. However, remote chain snapshots (built by `JBSuckerLib.buildSnapshotMessage`) capture raw surplus/supply WITHOUT loan corrections from the remote chain. This is accepted because:
102
+
103
+ 1. Suckers are a general-purpose bridging layer and should not need knowledge of revnet-specific loan mechanics.
104
+ 2. The `localSurplus` cap (REVLoans line 386-387) prevents extraction beyond what the local terminal actually holds.
105
+ 3. The over-lending exposure is bounded by the difference between corrected and uncorrected remote values, which is proportional to remote outstanding loans — typically a small fraction of total surplus.
106
+
107
+ Project operators deploying cross-chain revnets with active loan markets on multiple chains should understand that local borrowability calculations do not account for remote outstanding loans.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rev-net/core-v6",
3
- "version": "0.0.35",
3
+ "version": "0.0.37",
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.35",
23
- "@bananapus/buyback-hook-v6": "^0.0.27",
24
- "@bananapus/core-v6": "^0.0.34",
25
- "@bananapus/ownable-v6": "^0.0.17",
26
- "@bananapus/permission-ids-v6": "^0.0.17",
27
- "@bananapus/router-terminal-v6": "^0.0.26",
28
- "@bananapus/suckers-v6": "^0.0.25",
29
- "@croptop/core-v6": "github:mejango/croptop-core-v6",
22
+ "@bananapus/721-hook-v6": "^0.0.38",
23
+ "@bananapus/buyback-hook-v6": "^0.0.30",
24
+ "@bananapus/core-v6": "^0.0.36",
25
+ "@bananapus/ownable-v6": "^0.0.20",
26
+ "@bananapus/permission-ids-v6": "^0.0.19",
27
+ "@bananapus/router-terminal-v6": "^0.0.30",
28
+ "@bananapus/suckers-v6": "^0.0.28",
29
+ "@croptop/core-v6": "^0.0.36",
30
30
  "@openzeppelin/contracts": "^5.6.1",
31
31
  "@uniswap/v4-core": "^1.0.2",
32
32
  "@uniswap/v4-periphery": "^1.0.3"
@@ -798,7 +798,7 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IERC721Receiver {
798
798
  if (shouldDeployNewRevnet) {
799
799
  // If we're deploying a new revnet, launch a Juicebox project for it.
800
800
  // Sanity check that we deployed the `revnetId` that we expected to deploy.
801
- // slither-disable-next-line reentrancy-benign,reentrancy-events
801
+ // slither-disable-next-line incorrect-equality,reentrancy-benign,reentrancy-events
802
802
  assert(
803
803
  CONTROLLER.launchProjectFor({
804
804
  owner: address(this),
@@ -970,6 +970,12 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IERC721Receiver {
970
970
  JBFundAccessLimitGroup[] memory fundAccessLimitGroups =
971
971
  _makeLoanFundAccessLimits({terminalConfigurations: terminalConfigurations});
972
972
 
973
+ // Track the previous stage's effective start time for ordering validation.
974
+ // When stage 0 uses `startsAtOrAfter == 0`, the effective value is `block.timestamp`.
975
+ // Subsequent stages must be validated against this normalized value, not the raw calldata,
976
+ // so that cross-chain deployments can reproduce the same encoded configuration hash.
977
+ uint256 previousStageStart;
978
+
973
979
  // Iterate through each stage to set up its ruleset.
974
980
  for (uint256 i; i < configuration.stageConfigurations.length;) {
975
981
  // Set the stage being iterated on.
@@ -981,12 +987,19 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IERC721Receiver {
981
987
  revert REVDeployer_MustHaveSplits();
982
988
  }
983
989
 
990
+ // Compute the effective start time for this stage.
991
+ uint256 effectiveStart = (i == 0 && stageConfiguration.startsAtOrAfter == 0)
992
+ ? block.timestamp
993
+ : stageConfiguration.startsAtOrAfter;
994
+
984
995
  // If the stage's start time is not after the previous stage's start time, revert.
985
- if (i > 0 && stageConfiguration.startsAtOrAfter <= configuration.stageConfigurations[i - 1].startsAtOrAfter)
986
- {
996
+ if (i > 0 && effectiveStart <= previousStageStart) {
987
997
  revert REVDeployer_StageTimesMustIncrease();
988
998
  }
989
999
 
1000
+ // Store for the next iteration's ordering check.
1001
+ previousStageStart = effectiveStart;
1002
+
990
1003
  // Make sure the revnet doesn't prevent cashouts all together.
991
1004
  if (stageConfiguration.cashOutTaxRate >= JBConstants.MAX_CASH_OUT_TAX_RATE) {
992
1005
  revert REVDeployer_CashOutsCantBeTurnedOffCompletely(
@@ -1004,13 +1017,9 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IERC721Receiver {
1004
1017
  // Add the stage's properties to the byte-encoded configuration.
1005
1018
  encodedConfiguration = abi.encode(
1006
1019
  encodedConfiguration,
1007
- // If no start time is provided for the first stage, use the current block's timestamp.
1008
- // In the future, revnets deployed on other networks can match this revnet's encoded stage by specifying
1009
- // the
1010
- // same start time.
1011
- (i == 0 && stageConfiguration.startsAtOrAfter == 0)
1012
- ? block.timestamp
1013
- : stageConfiguration.startsAtOrAfter,
1020
+ // Use the effective start time (normalized from 0 to block.timestamp for the first stage).
1021
+ // Cross-chain deployments reproduce the hash by specifying the origin chain's timestamp.
1022
+ effectiveStart,
1014
1023
  stageConfiguration.splitPercent,
1015
1024
  stageConfiguration.initialIssuance,
1016
1025
  stageConfiguration.issuanceCutFrequency,
package/src/REVLoans.sol CHANGED
@@ -543,17 +543,18 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
543
543
  // Get a reference to the token being iterated on.
544
544
  REVLoanSource storage source = sources[i];
545
545
 
546
- // Get a reference to the accounting context for the source.
547
- // slither-disable-next-line calls-loop
548
- JBAccountingContext memory accountingContext =
549
- source.terminal.accountingContextForTokenOf({projectId: revnetId, token: source.token});
550
-
551
546
  // Get a reference to the amount of tokens loaned out.
552
547
  uint256 tokensLoaned = totalBorrowedFrom[revnetId][source.terminal][source.token];
553
548
 
554
- // Skip if no tokens are loaned from this source.
549
+ // Skip if no tokens are loaned from this source. Checked before the external call below to avoid
550
+ // reverting on stale sources whose terminals may no longer support this token.
555
551
  if (tokensLoaned == 0) continue;
556
552
 
553
+ // Get a reference to the accounting context for the source.
554
+ // slither-disable-next-line calls-loop
555
+ JBAccountingContext memory accountingContext =
556
+ source.terminal.accountingContextForTokenOf({projectId: revnetId, token: source.token});
557
+
557
558
  // Normalize the token amount from the source's decimals to the target decimals.
558
559
  uint256 normalizedTokens;
559
560
  if (accountingContext.decimals > decimals) {
@@ -626,93 +627,15 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
626
627
  // Note: the operator controls `beneficiary`, so they can direct borrowed funds to any address.
627
628
  _requirePermissionFrom({account: holder, projectId: revnetId, permissionId: JBPermissionIds.OPEN_LOAN});
628
629
 
629
- // A loan needs to have collateral.
630
- if (collateralCount == 0) revert REVLoans_ZeroCollateralLoanIsInvalid();
631
-
632
- // Make sure the source terminal is registered in the directory for this revnet.
633
- if (!DIRECTORY.isTerminalOf({projectId: revnetId, terminal: IJBTerminal(address(source.terminal))})) {
634
- revert REVLoans_InvalidTerminal(address(source.terminal), revnetId);
635
- }
636
-
637
- // Make sure the prepaid fee percent is between `MIN_PREPAID_FEE_PERCENT` and `MAX_PREPAID_FEE_PERCENT`. Meaning
638
- // an 16 year loan can be paid upfront with a
639
- // payment of 50% of the borrowed assets, the cheapest possible rate.
640
- if (prepaidFeePercent < MIN_PREPAID_FEE_PERCENT || prepaidFeePercent > MAX_PREPAID_FEE_PERCENT) {
641
- revert REVLoans_InvalidPrepaidFeePercent(
642
- prepaidFeePercent, MIN_PREPAID_FEE_PERCENT, MAX_PREPAID_FEE_PERCENT
643
- );
644
- }
645
-
646
- // Cache the current ruleset once — used by both _cashOutDelayOf and _borrowAmountFrom.
647
- JBRuleset memory currentRuleset = _currentRulesetOf(revnetId);
648
-
649
- // Enforce the cash out delay.
650
- {
651
- uint256 cashOutDelay = _cashOutDelayOf({revnetId: revnetId, currentRuleset: currentRuleset});
652
- if (cashOutDelay > block.timestamp) {
653
- revert REVLoans_CashOutDelayNotFinished(cashOutDelay, block.timestamp);
654
- }
655
- }
656
-
657
- // Prevent the loan number from exceeding the ID namespace for this revnet.
658
- if (totalLoansBorrowedFor[revnetId] >= _ONE_TRILLION) revert REVLoans_LoanIdOverflow();
659
-
660
- // Get a reference to the loan ID.
661
- loanId = _generateLoanId({revnetId: revnetId, loanNumber: ++totalLoansBorrowedFor[revnetId]});
662
-
663
- // Get a reference to the loan being created.
664
- REVLoan storage loan = _loanOf[loanId];
665
-
666
- // Set the loan's values.
667
- loan.source = source;
668
- loan.createdAt = uint48(block.timestamp);
669
- // forge-lint: disable-next-line(unsafe-typecast)
670
- loan.prepaidFeePercent = uint16(prepaidFeePercent);
671
- loan.prepaidDuration =
672
- uint32(mulDiv({x: prepaidFeePercent, y: LOAN_LIQUIDATION_DURATION, denominator: MAX_PREPAID_FEE_PERCENT}));
673
-
674
- // Get the amount of the loan, using the cached ruleset.
675
- uint256 borrowAmount = _borrowAmountFrom({
676
- loan: loan, revnetId: revnetId, collateralCount: collateralCount, currentRuleset: currentRuleset
677
- });
678
-
679
- // Revert if the bonding curve returns zero to prevent creating zero-amount loans.
680
- if (borrowAmount == 0) revert REVLoans_ZeroBorrowAmount();
681
-
682
- // Make sure the minimum borrow amount is met.
683
- if (borrowAmount < minBorrowAmount) revert REVLoans_UnderMinBorrowAmount(minBorrowAmount, borrowAmount);
684
-
685
- // Get the amount of additional fee to take for the revnet issuing the loan.
686
- // Fee rounding may leave a few wei of dust — economically insignificant relative to gas costs.
687
- uint256 sourceFeeAmount = JBFees.feeAmountFrom({amountBeforeFee: borrowAmount, feePercent: prepaidFeePercent});
688
-
689
- // Borrow the amount.
690
- _adjust({
691
- loan: loan,
630
+ return _borrowFrom({
692
631
  revnetId: revnetId,
693
- newBorrowAmount: borrowAmount,
694
- newCollateralCount: collateralCount,
695
- sourceFeeAmount: sourceFeeAmount,
696
- beneficiary: beneficiary,
697
- holder: holder
698
- });
699
-
700
- // Mint the loan NFT to the holder.
701
- _mint({to: holder, tokenId: loanId});
702
-
703
- emit Borrow({
704
- loanId: loanId,
705
- revnetId: revnetId,
706
- loan: loan,
707
632
  source: source,
708
- borrowAmount: borrowAmount,
633
+ minBorrowAmount: minBorrowAmount,
709
634
  collateralCount: collateralCount,
710
- sourceFeeAmount: sourceFeeAmount,
711
635
  beneficiary: beneficiary,
712
- caller: _msgSender()
636
+ prepaidFeePercent: prepaidFeePercent,
637
+ holder: holder
713
638
  });
714
-
715
- return (loanId, loan);
716
639
  }
717
640
 
718
641
  /// @notice Liquidates loans that have exceeded the 10-year liquidation duration.
@@ -837,7 +760,9 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
837
760
 
838
761
  // Make a new loan with the leftover collateral from reallocating.
839
762
  // The loan owner is the holder for the new loan (their tokens are used as collateral).
840
- (newLoanId, newLoan) = borrowFrom({
763
+ // Uses _borrowFrom to skip the OPEN_LOAN permission check — the caller already proved REALLOCATE_LOAN
764
+ // permission above, and requiring OPEN_LOAN here would block operators with only REALLOCATE_LOAN.
765
+ (newLoanId, newLoan) = _borrowFrom({
841
766
  revnetId: revnetId,
842
767
  source: source,
843
768
  minBorrowAmount: minBorrowAmount,
@@ -1225,6 +1150,127 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1225
1150
  return 0;
1226
1151
  }
1227
1152
 
1153
+ /// @notice Clears any token allowance granted by `_beforeTransferTo`.
1154
+ /// @param to The address that was granted the allowance.
1155
+ /// @param token The token whose allowance should be cleared.
1156
+ function _afterTransferTo(address to, address token) internal {
1157
+ if (token == JBConstants.NATIVE_TOKEN) return;
1158
+ IERC20(token).forceApprove({spender: to, value: 0});
1159
+ }
1160
+
1161
+ /// @notice Internal implementation of loan creation, without the OPEN_LOAN permission check.
1162
+ /// @dev Called by `borrowFrom` (after its own permission check) and by `reallocateCollateralFromLoan`
1163
+ /// (which only requires REALLOCATE_LOAN permission).
1164
+ /// @param revnetId The ID of the revnet being borrowed from.
1165
+ /// @param source The source of the loan being borrowed.
1166
+ /// @param minBorrowAmount The minimum amount being borrowed.
1167
+ /// @param collateralCount The amount of tokens to use as collateral for the loan.
1168
+ /// @param beneficiary The address that'll receive the borrowed funds and the tokens resulting from fee payments.
1169
+ /// @param prepaidFeePercent The fee percent that will be charged upfront.
1170
+ /// @param holder The address whose tokens are used as collateral and who receives the loan NFT.
1171
+ /// @return loanId The ID of the loan created from borrowing.
1172
+ /// @return loan The loan created from borrowing.
1173
+ function _borrowFrom(
1174
+ uint256 revnetId,
1175
+ REVLoanSource calldata source,
1176
+ uint256 minBorrowAmount,
1177
+ uint256 collateralCount,
1178
+ address payable beneficiary,
1179
+ uint256 prepaidFeePercent,
1180
+ address holder
1181
+ )
1182
+ internal
1183
+ returns (uint256 loanId, REVLoan memory)
1184
+ {
1185
+ // A loan needs to have collateral.
1186
+ if (collateralCount == 0) revert REVLoans_ZeroCollateralLoanIsInvalid();
1187
+
1188
+ // Make sure the source terminal is registered in the directory for this revnet.
1189
+ if (!DIRECTORY.isTerminalOf({projectId: revnetId, terminal: IJBTerminal(address(source.terminal))})) {
1190
+ revert REVLoans_InvalidTerminal(address(source.terminal), revnetId);
1191
+ }
1192
+
1193
+ // Make sure the prepaid fee percent is between `MIN_PREPAID_FEE_PERCENT` and `MAX_PREPAID_FEE_PERCENT`. Meaning
1194
+ // an 16 year loan can be paid upfront with a
1195
+ // payment of 50% of the borrowed assets, the cheapest possible rate.
1196
+ if (prepaidFeePercent < MIN_PREPAID_FEE_PERCENT || prepaidFeePercent > MAX_PREPAID_FEE_PERCENT) {
1197
+ revert REVLoans_InvalidPrepaidFeePercent(
1198
+ prepaidFeePercent, MIN_PREPAID_FEE_PERCENT, MAX_PREPAID_FEE_PERCENT
1199
+ );
1200
+ }
1201
+
1202
+ // Cache the current ruleset once — used by both _cashOutDelayOf and _borrowAmountFrom.
1203
+ JBRuleset memory currentRuleset = _currentRulesetOf(revnetId);
1204
+
1205
+ // Enforce the cash out delay.
1206
+ {
1207
+ uint256 cashOutDelay = _cashOutDelayOf({revnetId: revnetId, currentRuleset: currentRuleset});
1208
+ if (cashOutDelay > block.timestamp) {
1209
+ revert REVLoans_CashOutDelayNotFinished(cashOutDelay, block.timestamp);
1210
+ }
1211
+ }
1212
+
1213
+ // Prevent the loan number from exceeding the ID namespace for this revnet.
1214
+ if (totalLoansBorrowedFor[revnetId] >= _ONE_TRILLION) revert REVLoans_LoanIdOverflow();
1215
+
1216
+ // Get a reference to the loan ID.
1217
+ loanId = _generateLoanId({revnetId: revnetId, loanNumber: ++totalLoansBorrowedFor[revnetId]});
1218
+
1219
+ // Get a reference to the loan being created.
1220
+ REVLoan storage loan = _loanOf[loanId];
1221
+
1222
+ // Set the loan's values.
1223
+ loan.source = source;
1224
+ loan.createdAt = uint48(block.timestamp);
1225
+ // forge-lint: disable-next-line(unsafe-typecast)
1226
+ loan.prepaidFeePercent = uint16(prepaidFeePercent);
1227
+ loan.prepaidDuration =
1228
+ uint32(mulDiv({x: prepaidFeePercent, y: LOAN_LIQUIDATION_DURATION, denominator: MAX_PREPAID_FEE_PERCENT}));
1229
+
1230
+ // Get the amount of the loan, using the cached ruleset.
1231
+ uint256 borrowAmount = _borrowAmountFrom({
1232
+ loan: loan, revnetId: revnetId, collateralCount: collateralCount, currentRuleset: currentRuleset
1233
+ });
1234
+
1235
+ // Revert if the bonding curve returns zero to prevent creating zero-amount loans.
1236
+ if (borrowAmount == 0) revert REVLoans_ZeroBorrowAmount();
1237
+
1238
+ // Make sure the minimum borrow amount is met.
1239
+ if (borrowAmount < minBorrowAmount) revert REVLoans_UnderMinBorrowAmount(minBorrowAmount, borrowAmount);
1240
+
1241
+ // Get the amount of additional fee to take for the revnet issuing the loan.
1242
+ // Fee rounding may leave a few wei of dust — economically insignificant relative to gas costs.
1243
+ uint256 sourceFeeAmount = JBFees.feeAmountFrom({amountBeforeFee: borrowAmount, feePercent: prepaidFeePercent});
1244
+
1245
+ // Borrow the amount.
1246
+ _adjust({
1247
+ loan: loan,
1248
+ revnetId: revnetId,
1249
+ newBorrowAmount: borrowAmount,
1250
+ newCollateralCount: collateralCount,
1251
+ sourceFeeAmount: sourceFeeAmount,
1252
+ beneficiary: beneficiary,
1253
+ holder: holder
1254
+ });
1255
+
1256
+ // Mint the loan NFT to the holder.
1257
+ _mint({to: holder, tokenId: loanId});
1258
+
1259
+ emit Borrow({
1260
+ loanId: loanId,
1261
+ revnetId: revnetId,
1262
+ loan: loan,
1263
+ source: source,
1264
+ borrowAmount: borrowAmount,
1265
+ collateralCount: collateralCount,
1266
+ sourceFeeAmount: sourceFeeAmount,
1267
+ beneficiary: beneficiary,
1268
+ caller: _msgSender()
1269
+ });
1270
+
1271
+ return (loanId, loan);
1272
+ }
1273
+
1228
1274
  /// @notice Reallocates collateral from a loan by making a new loan based on the original, with reduced collateral.
1229
1275
  /// @param loanId The ID of the loan to reallocate collateral from.
1230
1276
  /// @param revnetId The ID of the revnet the loan is from.
@@ -1334,6 +1380,8 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1334
1380
  memo: "",
1335
1381
  metadata: bytes(abi.encodePacked(REV_ID))
1336
1382
  });
1383
+
1384
+ _afterTransferTo({to: address(loan.source.terminal), token: loan.source.token});
1337
1385
  }
1338
1386
 
1339
1387
  /// @notice Pays down a loan.
@@ -1532,6 +1580,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1532
1580
  metadata: bytes(abi.encodePacked(metadataProjectId))
1533
1581
  }) {
1534
1582
  success = true;
1583
+ _afterTransferTo({to: address(terminal), token: token});
1535
1584
  } catch (bytes memory) {
1536
1585
  if (token != JBConstants.NATIVE_TOKEN) {
1537
1586
  IERC20(token).safeDecreaseAllowance({spender: address(terminal), requestedDecrease: amount});
package/src/REVOwner.sol CHANGED
@@ -16,6 +16,7 @@ import {JBCashOutHookSpecification} from "@bananapus/core-v6/src/structs/JBCashO
16
16
  import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
17
17
  import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
18
18
  import {IJBSuckerRegistry} from "@bananapus/suckers-v6/src/interfaces/IJBSuckerRegistry.sol";
19
+ import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
19
20
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
20
21
  import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
21
22
  import {mulDiv} from "@prb/math/src/Common.sol";
@@ -177,7 +178,7 @@ contract REVOwner is IJBRulesetDataHook, IJBCashOutHook {
177
178
  + SUCKER_REGISTRY.remoteSurplusOf({
178
179
  projectId: context.projectId,
179
180
  decimals: context.surplus.decimals,
180
- currency: uint256(uint160(context.surplus.token))
181
+ currency: uint256(context.surplus.currency)
181
182
  });
182
183
 
183
184
  // If there's no cash out tax (100% cash out tax rate), if there's no fee terminal, or if the beneficiary is
@@ -396,8 +397,8 @@ contract REVOwner is IJBRulesetDataHook, IJBCashOutHook {
396
397
  if (context.forwardedAmount.token != JBConstants.NATIVE_TOKEN) {
397
398
  IERC20(context.forwardedAmount.token)
398
399
  .safeDecreaseAllowance({
399
- spender: address(feeTerminal), requestedDecrease: context.forwardedAmount.value
400
- });
400
+ spender: address(feeTerminal), requestedDecrease: context.forwardedAmount.value
401
+ });
401
402
  }
402
403
 
403
404
  // If the fee can't be processed, return the funds to the project.
@@ -458,7 +459,8 @@ contract REVOwner is IJBRulesetDataHook, IJBCashOutHook {
458
459
  /// @dev See `IERC165.supportsInterface`.
459
460
  /// @return A flag indicating if the provided interface ID is supported.
460
461
  function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
461
- return interfaceId == type(IJBRulesetDataHook).interfaceId || interfaceId == type(IJBCashOutHook).interfaceId;
462
+ return interfaceId == type(IERC165).interfaceId || interfaceId == type(IJBRulesetDataHook).interfaceId
463
+ || interfaceId == type(IJBCashOutHook).interfaceId;
462
464
  }
463
465
 
464
466
  //*********************************************************************//
@@ -531,25 +531,25 @@ contract REVnet_Integrations is TestBaseWorkflow {
531
531
  // The loans contract should have USE_ALLOWANCE permission for any revnet via the wildcard grant.
532
532
  bool hasPermission = jbPermissions()
533
533
  .hasPermission({
534
- operator: address(REV_DEPLOYER.LOANS()),
535
- account: address(REV_DEPLOYER),
536
- projectId: REVNET_ID,
537
- permissionId: JBPermissionIds.USE_ALLOWANCE,
538
- includeRoot: false,
539
- includeWildcardProjectId: true
540
- });
534
+ operator: address(REV_DEPLOYER.LOANS()),
535
+ account: address(REV_DEPLOYER),
536
+ projectId: REVNET_ID,
537
+ permissionId: JBPermissionIds.USE_ALLOWANCE,
538
+ includeRoot: false,
539
+ includeWildcardProjectId: true
540
+ });
541
541
  assertTrue(hasPermission, "LOANS should have USE_ALLOWANCE for deployed revnet");
542
542
 
543
543
  // Also holds for a revnet that doesn't exist yet — the wildcard covers all projects.
544
544
  bool hasPermissionForFuture = jbPermissions()
545
545
  .hasPermission({
546
- operator: address(REV_DEPLOYER.LOANS()),
547
- account: address(REV_DEPLOYER),
548
- projectId: 999,
549
- permissionId: JBPermissionIds.USE_ALLOWANCE,
550
- includeRoot: false,
551
- includeWildcardProjectId: true
552
- });
546
+ operator: address(REV_DEPLOYER.LOANS()),
547
+ account: address(REV_DEPLOYER),
548
+ projectId: 999,
549
+ permissionId: JBPermissionIds.USE_ALLOWANCE,
550
+ includeRoot: false,
551
+ includeWildcardProjectId: true
552
+ });
553
553
  assertTrue(hasPermissionForFuture, "LOANS should have USE_ALLOWANCE for any project via wildcard");
554
554
  }
555
555
 
@@ -692,14 +692,14 @@ contract REVInvincibility_PropertyTests is TestBaseWorkflow {
692
692
  vm.prank(USER);
693
693
  try jbMultiTerminal()
694
694
  .cashOutTokensOf({
695
- holder: USER,
696
- projectId: REVNET_ID,
697
- cashOutCount: tokens,
698
- tokenToReclaim: JBConstants.NATIVE_TOKEN,
699
- minTokensReclaimed: 0,
700
- beneficiary: payable(USER),
701
- metadata: ""
702
- }) returns (
695
+ holder: USER,
696
+ projectId: REVNET_ID,
697
+ cashOutCount: tokens,
698
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
699
+ minTokensReclaimed: 0,
700
+ beneficiary: payable(USER),
701
+ metadata: ""
702
+ }) returns (
703
703
  uint256 reclaimAmount
704
704
  ) {
705
705
  // The reclaim amount should be bounded by the bonding curve
@@ -944,14 +944,14 @@ contract REVInvincibility_PropertyTests is TestBaseWorkflow {
944
944
  vm.prank(USER);
945
945
  try jbMultiTerminal()
946
946
  .cashOutTokensOf({
947
- holder: USER,
948
- projectId: REVNET_ID,
949
- cashOutCount: tokens / 2,
950
- tokenToReclaim: JBConstants.NATIVE_TOKEN,
951
- minTokensReclaimed: 0,
952
- beneficiary: payable(USER),
953
- metadata: ""
954
- }) returns (
947
+ holder: USER,
948
+ projectId: REVNET_ID,
949
+ cashOutCount: tokens / 2,
950
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
951
+ minTokensReclaimed: 0,
952
+ beneficiary: payable(USER),
953
+ metadata: ""
954
+ }) returns (
955
955
  uint256 reclaimAmount
956
956
  ) {
957
957
  // The double fee means the fee project gets more than expected
@@ -265,14 +265,14 @@ contract REVLifecycle_Local is TestBaseWorkflow {
265
265
  vm.prank(USER1);
266
266
  uint256 reclaimed = jbMultiTerminal()
267
267
  .cashOutTokensOf({
268
- holder: USER1,
269
- projectId: REVNET_ID,
270
- cashOutCount: cashOutAmount,
271
- tokenToReclaim: JBConstants.NATIVE_TOKEN,
272
- minTokensReclaimed: 0,
273
- beneficiary: payable(USER1),
274
- metadata: ""
275
- });
268
+ holder: USER1,
269
+ projectId: REVNET_ID,
270
+ cashOutCount: cashOutAmount,
271
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
272
+ minTokensReclaimed: 0,
273
+ beneficiary: payable(USER1),
274
+ metadata: ""
275
+ });
276
276
  assertGt(reclaimed, 0, "should reclaim some ETH");
277
277
 
278
278
  // Total supply should decrease after cash out
@@ -311,14 +311,14 @@ contract REVLifecycle_Local is TestBaseWorkflow {
311
311
  vm.prank(USER1);
312
312
  uint256 reclaimedStage0 = jbMultiTerminal()
313
313
  .cashOutTokensOf({
314
- holder: USER1,
315
- projectId: REVNET_ID,
316
- cashOutCount: halfTokens,
317
- tokenToReclaim: JBConstants.NATIVE_TOKEN,
318
- minTokensReclaimed: 0,
319
- beneficiary: payable(USER1),
320
- metadata: ""
321
- });
314
+ holder: USER1,
315
+ projectId: REVNET_ID,
316
+ cashOutCount: halfTokens,
317
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
318
+ minTokensReclaimed: 0,
319
+ beneficiary: payable(USER1),
320
+ metadata: ""
321
+ });
322
322
  assertGt(reclaimedStage0, 0, "should reclaim in stage 0");
323
323
 
324
324
  // Cash out tax with 50% rate means you get less than proportional share
@@ -344,14 +344,14 @@ contract REVLifecycle_Local is TestBaseWorkflow {
344
344
  vm.prank(USER1);
345
345
  uint256 reclaimed = jbMultiTerminal()
346
346
  .cashOutTokensOf({
347
- holder: USER1,
348
- projectId: REVNET_ID,
349
- cashOutCount: tokens,
350
- tokenToReclaim: JBConstants.NATIVE_TOKEN,
351
- minTokensReclaimed: 0,
352
- beneficiary: payable(USER1),
353
- metadata: ""
354
- });
347
+ holder: USER1,
348
+ projectId: REVNET_ID,
349
+ cashOutCount: tokens,
350
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
351
+ minTokensReclaimed: 0,
352
+ beneficiary: payable(USER1),
353
+ metadata: ""
354
+ });
355
355
 
356
356
  // With 50% cash out tax and single holder, reclaiming full supply
357
357
  // should return less than full amount (due to tax)
@@ -386,14 +386,14 @@ contract REVLifecycle_Local is TestBaseWorkflow {
386
386
  vm.prank(USER1);
387
387
  uint256 reclaimed1 = jbMultiTerminal()
388
388
  .cashOutTokensOf({
389
- holder: USER1,
390
- projectId: REVNET_ID,
391
- cashOutCount: tokens1,
392
- tokenToReclaim: JBConstants.NATIVE_TOKEN,
393
- minTokensReclaimed: 0,
394
- beneficiary: payable(USER1),
395
- metadata: ""
396
- });
389
+ holder: USER1,
390
+ projectId: REVNET_ID,
391
+ cashOutCount: tokens1,
392
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
393
+ minTokensReclaimed: 0,
394
+ beneficiary: payable(USER1),
395
+ metadata: ""
396
+ });
397
397
 
398
398
  // Should reclaim proportional share (minus tax)
399
399
  assertGt(reclaimed1, 0, "user1 should reclaim some ETH");