@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.
- package/RISKS.md +19 -1
- package/package.json +9 -9
- package/src/REVDeployer.sol +19 -10
- package/src/REVLoans.sol +138 -89
- package/src/REVOwner.sol +6 -4
- package/test/REV.integrations.t.sol +14 -14
- package/test/REVInvincibility.t.sol +16 -16
- package/test/REVLifecycle.t.sol +32 -32
- package/test/REVLoansSourced.t.sol +15 -15
- package/test/TestCashOutCallerValidation.t.sol +8 -8
- package/test/TestConversionDocumentation.t.sol +2 -5
- package/test/TestCrossCurrencyReclaim.t.sol +72 -72
- package/test/TestLongTailEconomics.t.sol +56 -56
- package/test/TestSwapTerminalPermission.t.sol +21 -21
- package/test/audit/HiddenSupplyCashout.t.sol +61 -0
- package/test/audit/NemesisVerification.t.sol +97 -0
- package/test/audit/REVOwnerCurrencyMismatch.t.sol +188 -0
- package/test/audit/REVOwnerRemoteSurplusCurrencyMismatch.t.sol +140 -0
- package/test/audit/ReallocatePermission.t.sol +363 -0
- package/test/audit/RemoteLoanAccountingGap.t.sol +74 -0
- package/test/audit/SupportsInterfaceTest.t.sol +51 -0
- package/test/audit/TestFeeAllowanceLeak.t.sol +197 -0
- package/test/audit/TestLoansAndDeployerFixes.t.sol +576 -0
- package/test/fork/TestCashOutFork.t.sol +48 -48
- package/test/fork/TestLoanAdversarialFork.t.sol +744 -0
- package/test/fork/TestLoanERC20Fork.t.sol +2 -8
- package/test/fork/TestPermit2PaymentFork.t.sol +32 -32
- package/test/regression/TestBurnPermissionRequired.t.sol +5 -5
- package/test/regression/TestCashOutBuybackFeeLeak.t.sol +8 -8
- /package/test/audit/{CodexCrossChainBuybackRouteMismatch.t.sol → CrossChainBuybackRouteMismatch.t.sol} +0 -0
- /package/test/audit/{NemesisOperatorDelegation.t.sol → OperatorDelegation.t.sol} +0 -0
- /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.
|
|
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.
|
|
23
|
-
"@bananapus/buyback-hook-v6": "^0.0.
|
|
24
|
-
"@bananapus/core-v6": "^0.0.
|
|
25
|
-
"@bananapus/ownable-v6": "^0.0.
|
|
26
|
-
"@bananapus/permission-ids-v6": "^0.0.
|
|
27
|
-
"@bananapus/router-terminal-v6": "^0.0.
|
|
28
|
-
"@bananapus/suckers-v6": "^0.0.
|
|
29
|
-
"@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"
|
package/src/REVDeployer.sol
CHANGED
|
@@ -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 &&
|
|
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
|
-
//
|
|
1008
|
-
//
|
|
1009
|
-
|
|
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
|
-
|
|
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
|
-
|
|
633
|
+
minBorrowAmount: minBorrowAmount,
|
|
709
634
|
collateralCount: collateralCount,
|
|
710
|
-
sourceFeeAmount: sourceFeeAmount,
|
|
711
635
|
beneficiary: beneficiary,
|
|
712
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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
|
package/test/REVLifecycle.t.sol
CHANGED
|
@@ -265,14 +265,14 @@ contract REVLifecycle_Local is TestBaseWorkflow {
|
|
|
265
265
|
vm.prank(USER1);
|
|
266
266
|
uint256 reclaimed = jbMultiTerminal()
|
|
267
267
|
.cashOutTokensOf({
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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");
|