@rev-net/core-v6 0.0.57 → 0.0.61

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/src/REVLoans.sol CHANGED
@@ -32,8 +32,8 @@ import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
32
32
 
33
33
  import {IREVLoans} from "./interfaces/IREVLoans.sol";
34
34
  import {IREVOwner} from "./interfaces/IREVOwner.sol";
35
+ import {REVLoansSourceFees} from "./libraries/REVLoansSourceFees.sol";
35
36
  import {REVLoan} from "./structs/REVLoan.sol";
36
- import {REVLoanSource} from "./structs/REVLoanSource.sol";
37
37
 
38
38
  /// @notice Allows revnet token holders to borrow against their tokens instead of cashing out. The borrowable amount
39
39
  /// equals what a cash-out would return. Collateral tokens are burned on borrow and re-minted on repayment, keeping the
@@ -58,8 +58,8 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
58
58
 
59
59
  error REVLoans_CashOutDelayNotFinished(uint256 cashOutDelay, uint256 blockTimestamp);
60
60
  error REVLoans_CollateralExceedsLoan(uint256 collateralToReturn, uint256 loanCollateral);
61
+ error REVLoans_InvalidAccountingContext(uint256 revnetId, address token);
61
62
  error REVLoans_InvalidPrepaidFeePercent(uint256 prepaidFeePercent, uint256 min, uint256 max);
62
- error REVLoans_InvalidTerminal(address terminal, uint256 revnetId);
63
63
  error REVLoans_LoanExpired(uint256 timeSinceLoanCreated, uint256 loanLiquidationDuration);
64
64
  error REVLoans_LoanIdOverflow(uint256 revnetId, uint256 loanNumber, uint256 maxLoanNumber);
65
65
  error REVLoans_LoanOwnerChanged(uint256 loanId, address expectedOwner, address actualOwner);
@@ -71,12 +71,12 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
71
71
  error REVLoans_OverflowAlert(uint256 value, uint256 limit);
72
72
  error REVLoans_PermitAllowanceNotEnough(uint256 allowanceAmount, uint256 requiredAmount);
73
73
  error REVLoans_ReallocatingMoreCollateralThanBorrowedAmountAllows(uint256 newBorrowAmount, uint256 loanAmount);
74
- error REVLoans_SourceMismatch(
75
- address expectedToken, address actualToken, address expectedTerminal, address actualTerminal
76
- );
74
+ error REVLoans_ReentrantLoanAction();
75
+ error REVLoans_SourceMismatch(address expectedToken, address actualToken);
77
76
  error REVLoans_UnderMinBorrowAmount(uint256 minBorrowAmount, uint256 borrowAmount);
78
77
  error REVLoans_ZeroBorrowAmount(uint256 revnetId, uint256 collateralCount);
79
78
  error REVLoans_ZeroCollateralLoanIsInvalid(uint256 collateralCount);
79
+ error REVLoans_ZeroPrice(uint256 revnetId, uint256 pricingCurrency, uint256 unitCurrency);
80
80
 
81
81
  //*********************************************************************//
82
82
  // ------------------------- public constants ------------------------ //
@@ -108,15 +108,15 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
108
108
  // --------------- public immutable stored properties ---------------- //
109
109
  //*********************************************************************//
110
110
 
111
- /// @notice The Permit2 contract used for token approvals and transfers.
112
- IPermit2 public immutable override PERMIT2;
113
-
114
111
  /// @notice The controller of revnets that use this loans contract.
115
112
  IJBController public immutable override CONTROLLER;
116
113
 
117
114
  /// @notice The directory of terminals and controllers for revnets.
118
115
  IJBDirectory public immutable override DIRECTORY;
119
116
 
117
+ /// @notice The Permit2 contract used for token approvals and transfers.
118
+ IPermit2 public immutable override PERMIT2;
119
+
120
120
  /// @notice A contract that stores prices for each revnet.
121
121
  IJBPrices public immutable override PRICES;
122
122
 
@@ -126,62 +126,66 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
126
126
  /// @notice The sucker registry used to discover peer chain suckers for cross-chain awareness.
127
127
  IJBSuckerRegistry public immutable override SUCKER_REGISTRY;
128
128
 
129
+ /// @notice The canonical payout terminal that holds revnet treasury balances and sources all revnet loans.
130
+ IJBPayoutTerminal public immutable override TERMINAL;
131
+
129
132
  //*********************************************************************//
130
133
  // --------------------- public stored properties -------------------- //
131
134
  //*********************************************************************//
132
135
 
133
- /// @notice An indication if a revnet currently has outstanding loans from the specified terminal in the specified
134
- /// token.
135
- /// @custom:param revnetId The ID of the revnet to check.
136
- /// @custom:param terminal The terminal to check.
137
- /// @custom:param token The token to check.
138
- mapping(uint256 revnetId => mapping(IJBPayoutTerminal terminal => mapping(address token => bool)))
139
- public
140
- override isLoanSourceOf;
141
-
142
- /// @notice The cumulative number of loans ever created for a revnet, used as a loan ID sequence counter.
143
- /// @dev This counter only increments (on borrow, repay-with-new-loan, and reallocation) and never decrements.
144
- /// It does NOT represent the number of currently active loans. Repaid and liquidated loans leave permanent gaps
145
- /// in the ID sequence. Integrators should not use this to count active loans.
136
+ /// @notice An indication if a revnet currently has outstanding loans from the specified token source.
146
137
  /// @custom:param revnetId The ID of the revnet to check.
147
- mapping(uint256 revnetId => uint256) public override totalLoansBorrowedFor;
138
+ /// @custom:param token The token source to check.
139
+ mapping(uint256 revnetId => mapping(address token => bool)) public override isLoanSourceOf;
148
140
 
149
141
  /// @notice The contract resolving each project ID to its ERC721 URI.
150
142
  IJBTokenUriResolver public override tokenUriResolver;
151
143
 
152
- /// @notice The total amount loaned out by a revnet from a specified terminal in a specified token.
144
+ /// @notice The total amount loaned out by a revnet from a specified token source.
153
145
  /// @custom:param revnetId The ID of the revnet to check.
154
- /// @custom:param terminal The terminal to check.
155
- /// @custom:param token The token to check.
156
- mapping(uint256 revnetId => mapping(IJBPayoutTerminal terminal => mapping(address token => uint256)))
157
- public
158
- override totalBorrowedFrom;
146
+ /// @custom:param token The token source to check.
147
+ mapping(uint256 revnetId => mapping(address token => uint256)) public override totalBorrowedFrom;
159
148
 
160
149
  /// @notice The total amount of collateral supporting a revnet's loans.
161
150
  /// @custom:param revnetId The ID of the revnet to check.
162
151
  mapping(uint256 revnetId => uint256) public override totalCollateralOf;
163
152
 
153
+ /// @notice The cumulative number of loans ever created for a revnet, used as a loan ID sequence counter.
154
+ /// @dev This counter only increments (on borrow, repay-with-new-loan, and reallocation) and never decrements.
155
+ /// It does NOT represent the number of currently active loans. Repaid and liquidated loans leave permanent gaps
156
+ /// in the ID sequence. Integrators should not use this to count active loans.
157
+ /// @custom:param revnetId The ID of the revnet to check.
158
+ mapping(uint256 revnetId => uint256) public override totalLoansBorrowedFor;
159
+
164
160
  //*********************************************************************//
165
161
  // --------------------- internal stored properties ------------------ //
166
162
  //*********************************************************************//
167
163
 
168
- /// @notice The sources of each revnet's loan.
169
- /// @dev This array grows monotonically -- entries are appended when a new (terminal, token) pair is first used for
170
- /// borrowing, but are never removed. The `isLoanSourceOf` mapping tracks whether a source has been registered.
171
- /// Since the number of distinct (terminal, token) pairs per revnet is practically bounded (typically < 10),
172
- /// the gas cost of iterating this array in `loanSourcesOf` remains manageable.
173
- /// @custom:member revnetId The ID of the revnet to look up.
174
- mapping(uint256 revnetId => REVLoanSource[]) internal _loanSourcesOf;
175
-
176
164
  /// @notice The loans.
177
165
  /// @custom:member The ID of the loan.
178
166
  mapping(uint256 loanId => REVLoan) internal _loanOf;
179
167
 
168
+ /// @notice The sources of each revnet's loan.
169
+ /// @dev This array grows monotonically -- entries are appended when a token is first used for borrowing, but are
170
+ /// never removed. The `isLoanSourceOf` mapping tracks whether a source has been registered. Since sources are
171
+ /// bounded to the revnet's accepted accounting contexts on the canonical multi terminal, iteration remains
172
+ /// manageable.
173
+ /// @custom:member revnetId The ID of the revnet to look up.
174
+ mapping(uint256 revnetId => address[]) internal _loanSourceTokensOf;
175
+
176
+ //*********************************************************************//
177
+ // ------------------- transient stored properties ------------------- //
178
+ //*********************************************************************//
179
+
180
+ /// @notice Whether a loan-changing entrypoint is currently executing.
181
+ bool transient _loanActionEntered;
182
+
180
183
  //*********************************************************************//
181
184
  // -------------------------- constructor ---------------------------- //
182
185
  //*********************************************************************//
183
186
 
184
187
  /// @param controller The controller that manages revnets using this loans contract.
188
+ /// @param terminal The canonical payout terminal that holds revnet treasury balances and sources loans.
185
189
  /// @param suckerRegistry The registry used to discover peer chain suckers for cross-chain supply/surplus awareness.
186
190
  /// @param revId The ID of the REV revnet that will receive the fees.
187
191
  /// @param owner The owner of the contract that can set the URI resolver.
@@ -189,6 +193,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
189
193
  /// @param trustedForwarder A trusted forwarder of transactions to this contract.
190
194
  constructor(
191
195
  IJBController controller,
196
+ IJBPayoutTerminal terminal,
192
197
  IJBSuckerRegistry suckerRegistry,
193
198
  uint256 revId,
194
199
  address owner,
@@ -202,12 +207,26 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
202
207
  {
203
208
  CONTROLLER = controller;
204
209
  DIRECTORY = controller.DIRECTORY();
210
+ TERMINAL = terminal;
205
211
  PRICES = controller.PRICES();
206
212
  REV_ID = revId;
207
213
  PERMIT2 = permit2;
208
214
  SUCKER_REGISTRY = suckerRegistry;
209
215
  }
210
216
 
217
+ //*********************************************************************//
218
+ // ---------------------------- modifiers ---------------------------- //
219
+ //*********************************************************************//
220
+
221
+ /// @notice Prevent nested loan-changing calls while an external callback is in progress.
222
+ modifier nonReentrantLoanAction() {
223
+ if (_loanActionEntered) revert REVLoans_ReentrantLoanAction();
224
+
225
+ _loanActionEntered = true;
226
+ _;
227
+ _loanActionEntered = false;
228
+ }
229
+
211
230
  //*********************************************************************//
212
231
  // ------------------------- external views -------------------------- //
213
232
  //*********************************************************************//
@@ -240,7 +259,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
240
259
  collateralCount: collateralCount,
241
260
  decimals: decimals,
242
261
  currency: currency,
243
- terminals: _terminalsOf(revnetId),
262
+ multiTerminal: TERMINAL,
244
263
  currentStage: currentRuleset
245
264
  });
246
265
  }
@@ -251,12 +270,12 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
251
270
  return _loanOf[loanId];
252
271
  }
253
272
 
254
- /// @notice The sources of each revnet's loan.
273
+ /// @notice The source tokens of each revnet's loans.
255
274
  /// @dev This array only grows -- sources are never removed. The number of distinct sources is practically bounded
256
- /// by the number of unique (terminal, token) pairs used for borrowing, which is typically small.
275
+ /// by the number of accepted token accounting contexts, which is typically small.
257
276
  /// @param revnetId The ID of the revnet to look up.
258
- function loanSourcesOf(uint256 revnetId) external view override returns (REVLoanSource[] memory) {
259
- return _loanSourcesOf[revnetId];
277
+ function loanSourceTokensOf(uint256 revnetId) external view override returns (address[] memory) {
278
+ return _loanSourceTokensOf[revnetId];
260
279
  }
261
280
 
262
281
  //*********************************************************************//
@@ -331,7 +350,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
331
350
  /// @param collateralCount The amount of collateral to secure the loan with.
332
351
  /// @param decimals The decimals to use for the resulting fixed point value.
333
352
  /// @param currency The currency to denominate the resulting amount in.
334
- /// @param terminals The terminals to borrow from.
353
+ /// @param multiTerminal The canonical multi terminal to borrow from.
335
354
  /// @param currentStage The pre-fetched current ruleset.
336
355
  /// @return borrowableAmount The amount that can be borrowed from the revnet.
337
356
  function _borrowableAmountFrom(
@@ -339,21 +358,27 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
339
358
  uint256 collateralCount,
340
359
  uint256 decimals,
341
360
  uint256 currency,
342
- IJBTerminal[] memory terminals,
361
+ IJBTerminal multiTerminal,
343
362
  JBRuleset memory currentStage
344
363
  )
345
364
  internal
346
365
  view
347
366
  returns (uint256)
348
367
  {
349
- // Get the surplus of all the revnet's terminals in terms of the native currency.
368
+ // Get the surplus of the revnet's canonical multi terminal in terms of the requested currency.
350
369
  uint256 totalSurplus = JBSurplus.currentSurplusOf({
351
- projectId: revnetId, terminals: terminals, tokens: new address[](0), decimals: decimals, currency: currency
370
+ projectId: revnetId,
371
+ terminals: _singleTerminalArray(multiTerminal),
372
+ tokens: new address[](0),
373
+ decimals: decimals,
374
+ currency: currency
352
375
  });
353
376
 
354
377
  // Get the total amount the revnet currently has loaned out, in terms of the native currency with 18
355
378
  // decimals.
356
- uint256 totalBorrowed = _totalBorrowedFrom({revnetId: revnetId, decimals: decimals, currency: currency});
379
+ uint256 totalBorrowed = _totalBorrowedFrom({
380
+ revnetId: revnetId, decimals: decimals, currency: currency, multiTerminal: multiTerminal
381
+ });
357
382
 
358
383
  // Get the total amount of tokens in circulation.
359
384
  uint256 totalSupply = CONTROLLER.totalTokenSupplyWithReservedTokensOf(revnetId);
@@ -361,8 +386,9 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
361
386
  // Get a reference to the collateral being used to secure loans.
362
387
  uint256 totalCollateral = totalCollateralOf[revnetId];
363
388
 
364
- // Hidden tokens are intentionally excluded from borrowing math. Operators can hide tokens as a security
365
- // handle without changing the fair loan market for visible token holders.
389
+ // Only live token supply is counted here, then loan collateral is added back because loans burn collateral
390
+ // while borrowers still have a repayable claim on it. Ordinary voluntary burns are not tracked as hidden
391
+ // supply in v6; they destroy the holder's claim and do not need to be added back.
366
392
  uint256 localSupply = totalSupply + totalCollateral;
367
393
 
368
394
  // The local surplus includes both the treasury surplus and the outstanding borrowed amounts.
@@ -411,19 +437,16 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
411
437
  // If there's no collateral, there's no loan.
412
438
  if (collateralCount == 0) return 0;
413
439
 
414
- // Get a reference to the accounting context for the source.
415
- JBAccountingContext memory accountingContext =
416
- loan.source.terminal.accountingContextForTokenOf({projectId: revnetId, token: loan.source.token});
417
-
418
- // Keep a reference to the revnet's terminals.
419
- IJBTerminal[] memory terminals = _terminalsOf(revnetId);
440
+ // Keep a reference to the token's accounting context from the canonical treasury terminal.
441
+ JBAccountingContext memory context =
442
+ TERMINAL.accountingContextForTokenOf({projectId: revnetId, token: loan.sourceToken});
420
443
 
421
444
  return _borrowableAmountFrom({
422
445
  revnetId: revnetId,
423
446
  collateralCount: collateralCount,
424
- decimals: accountingContext.decimals,
425
- currency: accountingContext.currency,
426
- terminals: terminals,
447
+ decimals: context.decimals,
448
+ currency: context.currency,
449
+ multiTerminal: TERMINAL,
427
450
  currentStage: currentRuleset
428
451
  });
429
452
  }
@@ -460,36 +483,17 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
460
483
  /// @param amount The amount to pay off.
461
484
  /// @return The source fee amount for the loan.
462
485
  function _determineSourceFeeAmount(REVLoan memory loan, uint256 amount) internal view returns (uint256) {
463
- // Keep a reference to the time since the loan was created.
486
+ // Keep a reference to the loan age here because production uses the live block timestamp while formal proofs
487
+ // pass explicit elapsed-time values into the same source-fee library.
464
488
  uint256 timeSinceLoanCreated = block.timestamp - loan.createdAt;
465
489
 
466
- // If the loan period has passed the prepaid time frame, take a fee.
467
- if (timeSinceLoanCreated <= loan.prepaidDuration) return 0;
468
-
469
- // If the loan period has passed the liquidation time frame, do not allow loan management.
470
- // Uses `>` (not `>=`) so the exact boundary second is still repayable — the liquidation path
471
- // uses `<=`, and matching `>=` here would create a 1-second window where neither path is available.
472
- if (timeSinceLoanCreated > LOAN_LIQUIDATION_DURATION) {
473
- revert REVLoans_LoanExpired({
474
- timeSinceLoanCreated: timeSinceLoanCreated, loanLiquidationDuration: LOAN_LIQUIDATION_DURATION
475
- });
476
- }
477
-
478
- // Get a reference to the amount prepaid for the full loan.
479
- uint256 prepaid = JBFees.feeAmountFrom({amountBeforeFee: loan.amount, feePercent: loan.prepaidFeePercent});
480
-
481
- // This source fee ramps with elapsed time.
482
- uint256 fullSourceFeeAmount = JBFees.feeAmountFrom({
483
- amountBeforeFee: loan.amount - prepaid,
484
- feePercent: mulDiv({
485
- x: timeSinceLoanCreated - loan.prepaidDuration,
486
- y: JBConstants.MAX_FEE,
487
- denominator: LOAN_LIQUIDATION_DURATION - loan.prepaidDuration
488
- })
490
+ // Delegate the arithmetic so Halmos can prove the exact fee schedule without loading the full loan contract.
491
+ return REVLoansSourceFees.sourceFeeAmountFrom({
492
+ loan: loan,
493
+ amount: amount,
494
+ timeSinceLoanCreated: timeSinceLoanCreated,
495
+ loanLiquidationDuration: LOAN_LIQUIDATION_DURATION
489
496
  });
490
-
491
- // Calculate the source fee amount for the amount being paid off.
492
- return mulDiv({x: fullSourceFeeAmount, y: amount, denominator: loan.amount});
493
497
  }
494
498
 
495
499
  /// @notice Generate an ID for a loan given a revnet ID and a loan number within that revnet.
@@ -515,19 +519,20 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
515
519
  return ERC2771Context._msgSender();
516
520
  }
517
521
 
518
- /// @notice Returns the terminals for a revnet. Consolidates ABI encode/decode to a single site.
519
- /// @param revnetId The ID of the revnet.
520
- /// @return The terminals registered for the revnet.
521
- function _terminalsOf(uint256 revnetId) internal view returns (IJBTerminal[] memory) {
522
- return DIRECTORY.terminalsOf(revnetId);
522
+ /// @notice Returns a single-terminal array for surplus calculations.
523
+ /// @param terminal The terminal to place in the array.
524
+ /// @return terminals The one-item terminal array.
525
+ function _singleTerminalArray(IJBTerminal terminal) internal pure returns (IJBTerminal[] memory terminals) {
526
+ terminals = new IJBTerminal[](1);
527
+ terminals[0] = terminal;
523
528
  }
524
529
 
525
530
  /// @notice The total borrowed amount from a revnet, aggregated across all loan sources.
526
531
  /// @dev Each source's `totalBorrowedFrom` is stored in the source token's native decimals (e.g. 6 for USDC,
527
532
  /// 18 for ETH). Before aggregation, each amount is normalized to the target `decimals` to prevent mixed-decimal
528
533
  /// arithmetic errors. For cross-currency sources, the normalized amount is then converted via the price feed.
529
- /// @dev Inverse price feeds may truncate to zero at low decimal counts (e.g. a feed returning 1e21 at 6 decimals
530
- /// inverts to mulDiv(1e6, 1e6, 1e21) = 0). Sources with a zero price are skipped to prevent division-by-zero.
534
+ /// @dev Cross-currency sources fail closed if the price is zero. Core `JBPrices` reverts before returning zero;
535
+ /// the local zero check below covers mocked or nonconforming price modules so a source is never silently ignored.
531
536
  /// @param revnetId The ID of the revnet to check.
532
537
  /// @param decimals The decimals to use for the resulting fixed point value.
533
538
  /// @param currency The currency to denominate the resulting value in.
@@ -535,7 +540,8 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
535
540
  function _totalBorrowedFrom(
536
541
  uint256 revnetId,
537
542
  uint256 decimals,
538
- uint256 currency
543
+ uint256 currency,
544
+ IJBTerminal multiTerminal
539
545
  )
540
546
  internal
541
547
  view
@@ -543,50 +549,50 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
543
549
  {
544
550
  // Keep a reference to all sources being used to loaned out from this revnet.
545
551
  // Use storage ref to avoid bulk-copying the entire array to memory.
546
- REVLoanSource[] storage sources = _loanSourcesOf[revnetId];
552
+ address[] storage sources = _loanSourceTokensOf[revnetId];
547
553
 
548
554
  // Iterate over all sources being used to loaned out.
549
555
  for (uint256 i; i < sources.length; i++) {
550
556
  // Get a reference to the token being iterated on.
551
- REVLoanSource storage source = sources[i];
557
+ address sourceToken = sources[i];
552
558
 
553
559
  // Get a reference to the amount of tokens loaned out.
554
- uint256 tokensLoaned = totalBorrowedFrom[revnetId][source.terminal][source.token];
560
+ uint256 tokensLoaned = totalBorrowedFrom[revnetId][sourceToken];
555
561
 
556
- // Skip if no tokens are loaned from this source. Checked before the external call below to avoid
557
- // reverting on stale sources whose terminals may no longer support this token.
562
+ // Skip if no tokens are loaned from this source.
558
563
  if (tokensLoaned == 0) continue;
559
564
 
560
- // Get a reference to the accounting context for the source.
561
- JBAccountingContext memory accountingContext =
562
- source.terminal.accountingContextForTokenOf({projectId: revnetId, token: source.token});
565
+ // Get the current accounting context for the source token from the terminal being evaluated.
566
+ JBAccountingContext memory context =
567
+ multiTerminal.accountingContextForTokenOf({projectId: revnetId, token: sourceToken});
563
568
 
564
569
  // Normalize the token amount from the source's decimals to the target decimals.
565
570
  uint256 normalizedTokens;
566
- if (accountingContext.decimals > decimals) {
567
- normalizedTokens = tokensLoaned / (10 ** (accountingContext.decimals - decimals));
568
- } else if (accountingContext.decimals < decimals) {
569
- normalizedTokens = tokensLoaned * (10 ** (decimals - accountingContext.decimals));
571
+ if (context.decimals > decimals) {
572
+ normalizedTokens = tokensLoaned / (10 ** (context.decimals - decimals));
573
+ } else if (context.decimals < decimals) {
574
+ normalizedTokens = tokensLoaned * (10 ** (decimals - context.decimals));
570
575
  } else {
571
576
  normalizedTokens = tokensLoaned;
572
577
  }
573
578
 
574
579
  // If the currency matches, add the normalized amount directly.
575
- if (accountingContext.currency == currency) {
580
+ if (context.currency == currency) {
576
581
  borrowedAmount += normalizedTokens;
577
582
  } else {
578
- // Otherwise, convert via the price feed.
583
+ // Otherwise, convert via the price feed. `JBPrices` itself rejects a zero price, but the explicit
584
+ // local check keeps the same fail-closed behavior if tests or future modules return 0 directly.
579
585
  uint256 pricePerUnit = PRICES.pricePerUnitOf({
580
- projectId: revnetId,
581
- pricingCurrency: accountingContext.currency,
582
- unitCurrency: currency,
583
- decimals: decimals
586
+ projectId: revnetId, pricingCurrency: context.currency, unitCurrency: currency, decimals: decimals
584
587
  });
585
588
 
586
- // If the price feed returns zero, skip this source to avoid a division-by-zero panic
587
- // that would DoS all loan operations. This intentionally understates total debt for
588
- // the affected source — an acceptable tradeoff vs. blocking every borrow/repay.
589
- if (pricePerUnit == 0) continue;
589
+ // A zero denominator would either panic below or, if skipped, hide outstanding debt. Revert instead
590
+ // so misconfigured cross-currency sources cannot make borrowers appear safer than they are.
591
+ if (pricePerUnit == 0) {
592
+ revert REVLoans_ZeroPrice({
593
+ revnetId: revnetId, pricingCurrency: context.currency, unitCurrency: currency
594
+ });
595
+ }
590
596
 
591
597
  borrowedAmount += mulDiv({x: normalizedTokens, y: 10 ** decimals, denominator: pricePerUnit});
592
598
  }
@@ -606,9 +612,8 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
606
612
  /// @dev A delegated operator (with OPEN_LOAN permission) can set `beneficiary` to any address, directing borrowed
607
613
  /// funds away from the holder. Holders should only grant OPEN_LOAN to fully trusted operators.
608
614
  /// @param revnetId The ID of the revnet to borrow from.
609
- /// @param source The source of the loan (terminal and token).
610
- /// @param minBorrowAmount The minimum amount to borrow, denominated in the token of the source's accounting
611
- /// context.
615
+ /// @param token The token to borrow from the revnet's canonical multi terminal.
616
+ /// @param minBorrowAmount The minimum amount to borrow, denominated in `token`.
612
617
  /// @param collateralCount The amount of tokens to use as collateral for the loan.
613
618
  /// @param beneficiary The address that will receive the borrowed funds and the tokens resulting from fee payments.
614
619
  /// @param prepaidFeePercent The fee percent to charge upfront. Prepaying a fee is cheaper than paying later.
@@ -616,7 +621,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
616
621
  /// @return loan The loan created.
617
622
  function borrowFrom(
618
623
  uint256 revnetId,
619
- REVLoanSource calldata source,
624
+ address token,
620
625
  uint256 minBorrowAmount,
621
626
  uint256 collateralCount,
622
627
  address payable beneficiary,
@@ -625,6 +630,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
625
630
  )
626
631
  public
627
632
  override
633
+ nonReentrantLoanAction
628
634
  returns (uint256 loanId, REVLoan memory)
629
635
  {
630
636
  // Only the holder or a permissioned operator can open a loan on the holder's behalf.
@@ -633,7 +639,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
633
639
 
634
640
  return _borrowFrom({
635
641
  revnetId: revnetId,
636
- source: source,
642
+ token: token,
637
643
  minBorrowAmount: minBorrowAmount,
638
644
  collateralCount: collateralCount,
639
645
  beneficiary: beneficiary,
@@ -655,7 +661,15 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
655
661
  /// @param revnetId The ID of the revnet to liquidate loans from.
656
662
  /// @param startingLoanId The loan number to start iterating from.
657
663
  /// @param count The number of loans to iterate over.
658
- function liquidateExpiredLoansFrom(uint256 revnetId, uint256 startingLoanId, uint256 count) external override {
664
+ function liquidateExpiredLoansFrom(
665
+ uint256 revnetId,
666
+ uint256 startingLoanId,
667
+ uint256 count
668
+ )
669
+ external
670
+ override
671
+ nonReentrantLoanAction
672
+ {
659
673
  // Prevent cross-revnet accounting corruption: loan numbers must stay within the revnet's ID namespace.
660
674
  uint256 endLoanNumber = startingLoanId + count;
661
675
  if (endLoanNumber > _ONE_TRILLION) {
@@ -700,7 +714,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
700
714
 
701
715
  if (loan.amount > 0) {
702
716
  // Decrement the amount loaned.
703
- totalBorrowedFrom[revnetId][loan.source.terminal][loan.source.token] -= loan.amount;
717
+ totalBorrowedFrom[revnetId][loan.sourceToken] -= loan.amount;
704
718
  }
705
719
 
706
720
  emit Liquidate({loanId: loanId, revnetId: revnetId, loan: loan, caller: sender});
@@ -716,9 +730,8 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
716
730
  /// borrowed funds from the new loan away from the loan owner. Grant this permission only to trusted operators.
717
731
  /// @param loanId The ID of the loan to reallocate collateral from.
718
732
  /// @param collateralCountToTransfer The amount of collateral to transfer from the original loan.
719
- /// @param source The source of the new loan (terminal and token). Must match the existing loan's source.
720
- /// @param minBorrowAmount The minimum amount to borrow, denominated in the token of the source's accounting
721
- /// context.
733
+ /// @param token The token of the new loan. Must match the existing loan's source token.
734
+ /// @param minBorrowAmount The minimum amount to borrow, denominated in `token`.
722
735
  /// @param collateralCountToAdd The amount of collateral to add to the new loan from your balance.
723
736
  /// @param beneficiary The address that will receive the borrowed funds and the tokens resulting from fee payments.
724
737
  /// @param prepaidFeePercent The fee percent to charge upfront for the new loan.
@@ -729,7 +742,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
729
742
  function reallocateCollateralFromLoan(
730
743
  uint256 loanId,
731
744
  uint256 collateralCountToTransfer,
732
- REVLoanSource calldata source,
745
+ address token,
733
746
  uint256 minBorrowAmount,
734
747
  uint256 collateralCountToAdd,
735
748
  address payable beneficiary,
@@ -737,6 +750,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
737
750
  )
738
751
  external
739
752
  override
753
+ nonReentrantLoanAction
740
754
  returns (uint256 reallocatedLoanId, uint256 newLoanId, REVLoan memory reallocatedLoan, REVLoan memory newLoan)
741
755
  {
742
756
  // Keep a reference to the revnet ID of the loan being reallocated.
@@ -747,6 +761,14 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
747
761
  address loanOwner = _ownerOf(loanId);
748
762
  _requirePermissionFrom({account: loanOwner, projectId: revnetId, permissionId: JBPermissionIds.REALLOCATE_LOAN});
749
763
 
764
+ // If the caller is adding fresh holder collateral on top of the reallocated amount, they must also have
765
+ // OPEN_LOAN permission for the loan owner: that fresh collateral comes from the owner's project-token
766
+ // balance and would otherwise let a REALLOCATE_LOAN-only operator open a brand-new loan against the owner's
767
+ // tokens through this entry point, bypassing the OPEN_LOAN gate that `borrowFrom` enforces.
768
+ if (collateralCountToAdd != 0) {
769
+ _requirePermissionFrom({account: loanOwner, projectId: revnetId, permissionId: JBPermissionIds.OPEN_LOAN});
770
+ }
771
+
750
772
  // Make sure the loan hasn't expired.
751
773
  // forge-lint: disable-next-line(block-timestamp)
752
774
  if (block.timestamp - _loanOf[loanId].createdAt > LOAN_LIQUIDATION_DURATION) {
@@ -758,14 +780,9 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
758
780
 
759
781
  // Make sure the new loan's source matches the existing loan's source to prevent cross-source value extraction.
760
782
  {
761
- REVLoanSource storage existingSource = _loanOf[loanId].source;
762
- if (source.token != existingSource.token || source.terminal != existingSource.terminal) {
763
- revert REVLoans_SourceMismatch({
764
- expectedToken: existingSource.token,
765
- actualToken: source.token,
766
- expectedTerminal: address(existingSource.terminal),
767
- actualTerminal: address(source.terminal)
768
- });
783
+ address existingToken = _loanOf[loanId].sourceToken;
784
+ if (token != existingToken) {
785
+ revert REVLoans_SourceMismatch({expectedToken: existingToken, actualToken: token});
769
786
  }
770
787
  }
771
788
 
@@ -782,7 +799,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
782
799
  // permission above, and requiring OPEN_LOAN here would block operators with only REALLOCATE_LOAN.
783
800
  (newLoanId, newLoan) = _borrowFrom({
784
801
  revnetId: revnetId,
785
- source: source,
802
+ token: token,
786
803
  minBorrowAmount: minBorrowAmount,
787
804
  collateralCount: collateralCountToTransfer + collateralCountToAdd,
788
805
  beneficiary: beneficiary,
@@ -812,6 +829,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
812
829
  external
813
830
  payable
814
831
  override
832
+ nonReentrantLoanAction
815
833
  returns (uint256 paidOffLoanId, REVLoan memory paidOffloan)
816
834
  {
817
835
  // Cache the sender to avoid repeated ERC2771 context reads.
@@ -883,7 +901,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
883
901
 
884
902
  // Accept the funds that'll be used to pay off loans.
885
903
  maxRepayBorrowAmount =
886
- _acceptFundsFor({token: loan.source.token, amount: maxRepayBorrowAmount, allowance: allowance});
904
+ _acceptFundsFor({token: loan.sourceToken, amount: maxRepayBorrowAmount, allowance: allowance});
887
905
 
888
906
  // Re-check ownership: an ERC-777/ERC-1363 source token can reenter during the transfer above and transfer
889
907
  // the loan NFT to another account. Without this check, `_repayLoan` would burn the new owner's NFT while
@@ -903,7 +921,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
903
921
  }
904
922
 
905
923
  // Cache the source token before _repayLoan deletes the loan storage.
906
- address sourceToken = loan.source.token;
924
+ address sourceToken = loan.sourceToken;
907
925
 
908
926
  (paidOffLoanId, paidOffloan) = _repayLoan({
909
927
  loanId: loanId,
@@ -1019,39 +1037,39 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1019
1037
  )
1020
1038
  internal
1021
1039
  {
1040
+ address sourceToken = loan.sourceToken;
1041
+
1022
1042
  // Register the source if this is the first time its being used for this revnet.
1023
- // Note: Sources are only appended, never removed. Gas accumulation from iteration is bounded
1024
- // because the number of distinct (terminal, token) pairs per revnet is practically small (~5-20).
1025
- if (!isLoanSourceOf[revnetId][loan.source.terminal][loan.source.token]) {
1026
- isLoanSourceOf[revnetId][loan.source.terminal][loan.source.token] = true;
1027
- _loanSourcesOf[revnetId].push(REVLoanSource({token: loan.source.token, terminal: loan.source.terminal}));
1043
+ // Note: Sources are only appended, never removed. Gas accumulation from iteration is bounded by the revnet's
1044
+ // accepted accounting contexts.
1045
+ if (!isLoanSourceOf[revnetId][sourceToken]) {
1046
+ isLoanSourceOf[revnetId][sourceToken] = true;
1047
+ _loanSourceTokensOf[revnetId].push(sourceToken);
1028
1048
  }
1029
1049
 
1030
- // Increment the amount of the token borrowed from the revnet from the terminal.
1031
- totalBorrowedFrom[revnetId][loan.source.terminal][loan.source.token] += addedBorrowAmount;
1050
+ // Increment the amount of the token borrowed from the revnet.
1051
+ totalBorrowedFrom[revnetId][sourceToken] += addedBorrowAmount;
1032
1052
 
1033
1053
  uint256 netAmountPaidOut;
1034
1054
  {
1035
- // Get a reference to the accounting context for the source.
1036
- JBAccountingContext memory accountingContext =
1037
- loan.source.terminal.accountingContextForTokenOf({projectId: revnetId, token: loan.source.token});
1055
+ JBAccountingContext memory context =
1056
+ TERMINAL.accountingContextForTokenOf({projectId: revnetId, token: sourceToken});
1038
1057
 
1039
1058
  // Pull the amount to be loaned out of the revnet. This will incure the protocol fee.
1040
- netAmountPaidOut = loan.source.terminal
1041
- .useAllowanceOf({
1042
- projectId: revnetId,
1043
- token: loan.source.token,
1044
- amount: addedBorrowAmount,
1045
- currency: accountingContext.currency,
1046
- minTokensPaidOut: 0,
1047
- beneficiary: payable(address(this)),
1048
- feeBeneficiary: beneficiary,
1049
- memo: ""
1050
- });
1059
+ netAmountPaidOut = TERMINAL.useAllowanceOf({
1060
+ projectId: revnetId,
1061
+ token: sourceToken,
1062
+ amount: addedBorrowAmount,
1063
+ currency: context.currency,
1064
+ minTokensPaidOut: 0,
1065
+ beneficiary: payable(address(this)),
1066
+ feeBeneficiary: beneficiary,
1067
+ memo: ""
1068
+ });
1051
1069
  }
1052
1070
 
1053
1071
  // Keep a reference to the fee terminal.
1054
- IJBTerminal feeTerminal = DIRECTORY.primaryTerminalOf({projectId: REV_ID, token: loan.source.token});
1072
+ IJBTerminal feeTerminal = DIRECTORY.primaryTerminalOf({projectId: REV_ID, token: sourceToken});
1055
1073
 
1056
1074
  // Get the amount of additional fee to take for REV.
1057
1075
  uint256 revFeeAmount = address(feeTerminal) == address(0)
@@ -1063,7 +1081,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1063
1081
  if (!_tryPayFee({
1064
1082
  terminal: feeTerminal,
1065
1083
  projectId: REV_ID,
1066
- token: loan.source.token,
1084
+ token: sourceToken,
1067
1085
  amount: revFeeAmount,
1068
1086
  beneficiary: beneficiary,
1069
1087
  metadataProjectId: revnetId
@@ -1079,17 +1097,15 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1079
1097
  _transferFrom({
1080
1098
  from: address(this),
1081
1099
  to: beneficiary,
1082
- token: loan.source.token,
1100
+ token: sourceToken,
1083
1101
  amount: netAmountPaidOut - revFeeAmount - sourceFeeAmount
1084
1102
  });
1085
1103
  }
1086
1104
 
1087
1105
  /// @notice Adjust a loan -- pay it back, add more, or return excess collateral.
1088
- /// @dev CEI ordering note: `totalCollateralOf` is not incremented until `_addCollateralTo` executes,
1089
- /// which happens after the external calls in `_addTo` (useAllowanceOf, fee payment, transfer). A reentrant
1090
- /// `borrowFrom` during those calls would see a lower `totalCollateralOf`, potentially passing collateral
1091
- /// checks that should fail. Practically infeasible — requires an adversarial pay hook on the revnet's own
1092
- /// terminal that calls back into `borrowFrom`, which is not a realistic deployment configuration.
1106
+ /// @dev `borrowFrom`, `reallocateCollateralFromLoan`, and `repayLoan` hold a transient lock across this function.
1107
+ /// External terminal, token, and beneficiary callbacks may observe in-progress loan state, but they cannot nest
1108
+ /// another loan-changing action before aggregate collateral and borrowed accounting have finished updating.
1093
1109
  /// @param loan The loan to adjust.
1094
1110
  /// @param revnetId The ID of the revnet the loan is in.
1095
1111
  /// @param newBorrowAmount The new amount of the loan, denominated in the token of the source's accounting
@@ -1110,8 +1126,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1110
1126
  internal
1111
1127
  {
1112
1128
  // Cache frequently-read storage fields to avoid repeated SLOAD.
1113
- address sourceToken = loan.source.token;
1114
- IJBPayoutTerminal sourceTerminal = loan.source.terminal;
1129
+ address sourceToken = loan.sourceToken;
1115
1130
 
1116
1131
  // Snapshot deltas from current state before writing.
1117
1132
  uint256 addedBorrowAmount = newBorrowAmount > loan.amount ? newBorrowAmount - loan.amount : 0;
@@ -1160,7 +1175,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1160
1175
  // Try to pay the source fee. If it fails, transfer the amount to the beneficiary instead.
1161
1176
  if (sourceFeeAmount > 0) {
1162
1177
  if (!_tryPayFee({
1163
- terminal: IJBTerminal(address(sourceTerminal)),
1178
+ terminal: TERMINAL,
1164
1179
  projectId: revnetId,
1165
1180
  token: sourceToken,
1166
1181
  amount: sourceFeeAmount,
@@ -1197,7 +1212,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1197
1212
  /// @dev Called by `borrowFrom` (after its own permission check) and by `reallocateCollateralFromLoan`
1198
1213
  /// (which only requires REALLOCATE_LOAN permission).
1199
1214
  /// @param revnetId The ID of the revnet to borrow from.
1200
- /// @param source The source of the loan (terminal and token).
1215
+ /// @param token The token to borrow.
1201
1216
  /// @param minBorrowAmount The minimum amount to borrow.
1202
1217
  /// @param collateralCount The amount of tokens to use as collateral for the loan.
1203
1218
  /// @param beneficiary The address that will receive the borrowed funds and fee payment tokens.
@@ -1207,7 +1222,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1207
1222
  /// @return loan The loan created.
1208
1223
  function _borrowFrom(
1209
1224
  uint256 revnetId,
1210
- REVLoanSource calldata source,
1225
+ address token,
1211
1226
  uint256 minBorrowAmount,
1212
1227
  uint256 collateralCount,
1213
1228
  address payable beneficiary,
@@ -1220,10 +1235,14 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1220
1235
  // A loan needs to have collateral.
1221
1236
  if (collateralCount == 0) revert REVLoans_ZeroCollateralLoanIsInvalid({collateralCount: collateralCount});
1222
1237
 
1223
- // Make sure the source terminal is registered in the directory for this revnet.
1224
- if (!DIRECTORY.isTerminalOf({projectId: revnetId, terminal: IJBTerminal(address(source.terminal))})) {
1225
- revert REVLoans_InvalidTerminal({terminal: address(source.terminal), revnetId: revnetId});
1226
- }
1238
+ // Cache the current ruleset once used by source validation, _cashOutDelayOf, and _borrowAmountFrom.
1239
+ JBRuleset memory currentRuleset = _currentRulesetOf(revnetId);
1240
+
1241
+ // Make sure the token's accounting context exists on the canonical multi terminal for this revnet. An
1242
+ // unaccepted token reads as an empty accounting context from the terminal store, which must not be treated as
1243
+ // a valid zero-decimal/zero-currency loan source.
1244
+ JBAccountingContext memory context = TERMINAL.accountingContextForTokenOf({projectId: revnetId, token: token});
1245
+ if (context.token != token) revert REVLoans_InvalidAccountingContext({revnetId: revnetId, token: token});
1227
1246
 
1228
1247
  // Make sure the prepaid fee percent is between `MIN_PREPAID_FEE_PERCENT` and `MAX_PREPAID_FEE_PERCENT`. Meaning
1229
1248
  // an 16 year loan can be paid upfront with a
@@ -1234,9 +1253,6 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1234
1253
  });
1235
1254
  }
1236
1255
 
1237
- // Cache the current ruleset once — used by both _cashOutDelayOf and _borrowAmountFrom.
1238
- JBRuleset memory currentRuleset = _currentRulesetOf(revnetId);
1239
-
1240
1256
  // Enforce the cash out delay.
1241
1257
  {
1242
1258
  uint256 cashOutDelay = _cashOutDelayOf({revnetId: revnetId, currentRuleset: currentRuleset});
@@ -1253,7 +1269,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1253
1269
  REVLoan storage loan = _loanOf[loanId];
1254
1270
 
1255
1271
  // Set the loan's values.
1256
- loan.source = source;
1272
+ loan.sourceToken = token;
1257
1273
  loan.createdAt = uint48(block.timestamp);
1258
1274
  // forge-lint: disable-next-line(unsafe-typecast)
1259
1275
  loan.prepaidFeePercent = uint16(prepaidFeePercent);
@@ -1294,7 +1310,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1294
1310
  loanId: loanId,
1295
1311
  revnetId: revnetId,
1296
1312
  loan: loan,
1297
- source: source,
1313
+ token: token,
1298
1314
  borrowAmount: borrowAmount,
1299
1315
  collateralCount: collateralCount,
1300
1316
  sourceFeeAmount: sourceFeeAmount,
@@ -1375,7 +1391,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1375
1391
  reallocatedLoan.createdAt = loan.createdAt;
1376
1392
  reallocatedLoan.prepaidFeePercent = loan.prepaidFeePercent;
1377
1393
  reallocatedLoan.prepaidDuration = loan.prepaidDuration;
1378
- reallocatedLoan.source = loan.source;
1394
+ reallocatedLoan.sourceToken = loan.sourceToken;
1379
1395
 
1380
1396
  // Reduce the collateral of the reallocated loan.
1381
1397
  _adjust({
@@ -1411,25 +1427,25 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1411
1427
  /// @param repaidBorrowAmount The amount to pay off, denominated in the token of the source's accounting
1412
1428
  /// context.
1413
1429
  function _removeFrom(REVLoan memory loan, uint256 revnetId, uint256 repaidBorrowAmount) internal {
1414
- // Decrement the total amount of a token being loaned out by the revnet from its terminal.
1415
- totalBorrowedFrom[revnetId][loan.source.terminal][loan.source.token] -= repaidBorrowAmount;
1430
+ address sourceToken = loan.sourceToken;
1431
+
1432
+ // Decrement the total amount of a token being loaned out by the revnet.
1433
+ totalBorrowedFrom[revnetId][sourceToken] -= repaidBorrowAmount;
1416
1434
 
1417
1435
  // Increase the allowance for the beneficiary.
1418
- uint256 payValue = _beforeTransferTo({
1419
- to: address(loan.source.terminal), token: loan.source.token, amount: repaidBorrowAmount
1420
- });
1436
+ uint256 payValue = _beforeTransferTo({to: address(TERMINAL), token: sourceToken, amount: repaidBorrowAmount});
1421
1437
 
1422
1438
  // Add the loaned amount back to the revnet.
1423
- loan.source.terminal.addToBalanceOf{value: payValue}({
1439
+ TERMINAL.addToBalanceOf{value: payValue}({
1424
1440
  projectId: revnetId,
1425
- token: loan.source.token,
1441
+ token: sourceToken,
1426
1442
  amount: repaidBorrowAmount,
1427
1443
  shouldReturnHeldFees: false,
1428
1444
  memo: "",
1429
1445
  metadata: bytes(abi.encodePacked(REV_ID))
1430
1446
  });
1431
1447
 
1432
- _afterTransferTo({to: address(loan.source.terminal), token: loan.source.token});
1448
+ _afterTransferTo({to: address(TERMINAL), token: sourceToken});
1433
1449
  }
1434
1450
 
1435
1451
  /// @notice Pay down a loan.
@@ -1506,7 +1522,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1506
1522
  paidOffLoan.createdAt = loan.createdAt;
1507
1523
  paidOffLoan.prepaidFeePercent = loan.prepaidFeePercent;
1508
1524
  paidOffLoan.prepaidDuration = loan.prepaidDuration;
1509
- paidOffLoan.source = loan.source;
1525
+ paidOffLoan.sourceToken = loan.sourceToken;
1510
1526
 
1511
1527
  // Mint the replacement loan to the loan owner FIRST so it exists before _adjust writes data.
1512
1528
  _mint({to: loanOwner, tokenId: paidOffLoanId});