@juicedollar/jusd 1.0.5 → 1.1.0

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/README.md CHANGED
@@ -213,32 +213,6 @@ USE_FORK=true BRIDGE_KEY=<KEY> npx hardhat run scripts/deployment/deploy/deployB
213
213
 
214
214
  Bridge configurations: `scripts/deployment/config/stablecoinBridgeConfig.ts`
215
215
 
216
- ### Hardhat Ignition Deployment
217
-
218
- ```bash
219
- # Deploy single module with verification
220
- npm run deploy ignition/modules/MODULE --network citrea --verify --deployment-id MODULE_ID_01
221
-
222
- # Deploy all modules
223
- npm run deploy -- --network citrea --verify
224
- ```
225
-
226
- **Output:**
227
- - `ignition/deployments/[deployment]/deployed_addresses.json`
228
- - `ignition/deployments/[deployment]/journal.jsonl`
229
- - `ignition/constructor-args/*.js`
230
-
231
- ### Manual Verification
232
-
233
- ```bash
234
- npx hardhat verify --network citrea \
235
- --constructor-args ./ignition/constructor-args/$FILE.js \
236
- $ADDRESS
237
-
238
- # Verify unrelated contracts
239
- npx hardhat ignition verify $DEPLOYMENT --include-unrelated-contracts
240
- ```
241
-
242
216
  ---
243
217
 
244
218
  ## NPM Package
@@ -32,6 +32,14 @@ contract MintingHub is IMintingHub, ERC165 {
32
32
  uint256 public constant CHALLENGER_REWARD = 20000; // 2%
33
33
  uint256 public constant EXPIRED_PRICE_FACTOR = 10;
34
34
 
35
+ /**
36
+ * @dev Maximum allowed message length for denial messages (prevents gas griefing attacks).
37
+ * This constant is intentionally duplicated in Position.sol for defense-in-depth.
38
+ * Hub validates as a second layer of protection; Position validates first to fail early.
39
+ * If changing this value, update Position.MAX_MESSAGE_LENGTH as well.
40
+ */
41
+ uint256 private constant MAX_MESSAGE_LENGTH = 500;
42
+
35
43
  IPositionFactory private immutable POSITION_FACTORY; // position contract to clone
36
44
 
37
45
  IJuiceDollar public immutable JUSD; // currency
@@ -49,7 +57,7 @@ contract MintingHub is IMintingHub, ERC165 {
49
57
 
50
58
  /**
51
59
  * @notice Tracks whether the first position has been created.
52
- * @dev The first position (genesis) can skip the 3-day init period requirement.
60
+ * @dev The first position (genesis) can skip the 14-day init period requirement.
53
61
  */
54
62
  bool private _genesisPositionCreated;
55
63
 
@@ -72,6 +80,7 @@ contract MintingHub is IMintingHub, ERC165 {
72
80
  );
73
81
  event PostponedReturn(address collateral, address indexed beneficiary, uint256 amount);
74
82
  event ForcedSale(address pos, uint256 amount, uint256 priceE36MinusDecimals);
83
+ // Note: PositionUpdate and PositionDeniedByGovernance events are defined in IMintingHub interface
75
84
 
76
85
  error UnexpectedPrice();
77
86
  error InvalidPos();
@@ -86,6 +95,8 @@ contract MintingHub is IMintingHub, ERC165 {
86
95
  error NativeOnlyForWCBTC();
87
96
  error ValueMismatch();
88
97
  error NativeTransferFailed();
98
+ error MessageTooLong(uint256 length, uint256 maxLength);
99
+ error EmptyMessage();
89
100
 
90
101
  modifier validPos(address position) {
91
102
  if (JUSD.getPositionParent(position) != address(this)) revert InvalidPos();
@@ -136,9 +147,9 @@ contract MintingHub is IMintingHub, ERC165 {
136
147
  if (CHALLENGER_REWARD > _reservePPM || _reservePPM > 1_000_000) revert InvalidReservePPM();
137
148
  if (IERC20Metadata(_collateralAddress).decimals() > 24) revert InvalidCollateralDecimals(); // leaves 12 digits for price
138
149
  if (_challengeSeconds < 1 days) revert ChallengeTimeTooShort();
139
- // First position (genesis) can skip init period, all others require 3 days minimum
150
+ // First position (genesis) can skip init period, all others require 14 days minimum
140
151
  if (_genesisPositionCreated) {
141
- if (_initPeriodSeconds < 3 days) revert InitPeriodTooShort();
152
+ if (_initPeriodSeconds < 14 days) revert InitPeriodTooShort();
142
153
  } else {
143
154
  _genesisPositionCreated = true;
144
155
  }
@@ -286,7 +297,12 @@ contract MintingHub is IMintingHub, ERC165 {
286
297
  * In phase 1 (aversion): bidder receives native. In phase 2 (liquidation): both
287
298
  * challenger refund and bidder acquisition are returned as native.
288
299
  */
289
- function bid(uint32 _challengeNumber, uint256 size, bool postponeCollateralReturn, bool returnCollateralAsNative) public {
300
+ function bid(
301
+ uint32 _challengeNumber,
302
+ uint256 size,
303
+ bool postponeCollateralReturn,
304
+ bool returnCollateralAsNative
305
+ ) public {
290
306
  Challenge memory _challenge = challenges[_challengeNumber];
291
307
  (uint256 liqPrice, uint40 phase) = _challenge.position.challengeData();
292
308
  size = _challenge.size < size ? _challenge.size : size; // cannot bid for more than the size of the challenge
@@ -295,8 +311,18 @@ contract MintingHub is IMintingHub, ERC165 {
295
311
  _avertChallenge(_challenge, _challengeNumber, liqPrice, size, returnCollateralAsNative);
296
312
  emit ChallengeAverted(address(_challenge.position), _challengeNumber, size);
297
313
  } else {
298
- _returnChallengerCollateral(_challenge, _challengeNumber, size, postponeCollateralReturn, returnCollateralAsNative);
299
- (uint256 transferredCollateral, uint256 offer) = _finishChallenge(_challenge, size, returnCollateralAsNative);
314
+ _returnChallengerCollateral(
315
+ _challenge,
316
+ _challengeNumber,
317
+ size,
318
+ postponeCollateralReturn,
319
+ returnCollateralAsNative
320
+ );
321
+ (uint256 transferredCollateral, uint256 offer) = _finishChallenge(
322
+ _challenge,
323
+ size,
324
+ returnCollateralAsNative
325
+ );
300
326
  emit ChallengeSucceeded(address(_challenge.position), _challengeNumber, offer, transferredCollateral, size);
301
327
  }
302
328
  }
@@ -322,8 +348,8 @@ contract MintingHub is IMintingHub, ERC165 {
322
348
  // enforced in Position.setPrice and knowing that collateral <= col.
323
349
  uint256 offer = _calculateOffer(_challenge, collateral);
324
350
 
325
- JUSD.transferFrom(msg.sender, address(this), offer); // get money from bidder
326
- uint256 reward = (offer * CHALLENGER_REWARD) / 1_000_000;
351
+ JUSD.transferFrom(msg.sender, address(this), offer); // get money from bidder
352
+ uint256 reward = (offer * CHALLENGER_REWARD) / 1_000_000;
327
353
  JUSD.transfer(_challenge.challenger, reward); // pay out the challenger reward
328
354
  uint256 fundsAvailable = offer - reward; // funds available after reward
329
355
 
@@ -359,7 +385,13 @@ contract MintingHub is IMintingHub, ERC165 {
359
385
  return (collateral, offer);
360
386
  }
361
387
 
362
- function _avertChallenge(Challenge memory _challenge, uint32 number, uint256 liqPrice, uint256 size, bool asNative) internal {
388
+ function _avertChallenge(
389
+ Challenge memory _challenge,
390
+ uint32 number,
391
+ uint256 liqPrice,
392
+ uint256 size,
393
+ bool asNative
394
+ ) internal {
363
395
  require(block.timestamp != _challenge.start); // do not allow to avert the challenge in the same transaction, see CS-ZCHF-037
364
396
  if (msg.sender == _challenge.challenger) {
365
397
  // allow challenger to cancel challenge without paying themselves
@@ -471,7 +503,13 @@ contract MintingHub is IMintingHub, ERC165 {
471
503
  returnPostponedCollateral(collateral, target, false);
472
504
  }
473
505
 
474
- function _returnCollateral(IERC20 collateral, address recipient, uint256 amount, bool postpone, bool asNative) internal {
506
+ function _returnCollateral(
507
+ IERC20 collateral,
508
+ address recipient,
509
+ uint256 amount,
510
+ bool postpone,
511
+ bool asNative
512
+ ) internal {
475
513
  if (postpone) {
476
514
  // Postponing helps in case the challenger was blacklisted or otherwise cannot receive at the moment.
477
515
  pendingReturns[address(collateral)][recipient] += amount;
@@ -508,7 +546,7 @@ contract MintingHub is IMintingHub, ERC165 {
508
546
  } else if (timePassed < 2 * challengePeriod) {
509
547
  // from 1x liquidation price to 0 in second phase
510
548
  uint256 timeLeft = 2 * challengePeriod - timePassed;
511
- return (liqprice * timeLeft) / challengePeriod;
549
+ return (liqprice * timeLeft) / challengePeriod;
512
550
  } else {
513
551
  // get collateral for free after both phases passed
514
552
  return 0;
@@ -527,7 +565,7 @@ contract MintingHub is IMintingHub, ERC165 {
527
565
  * @param upToAmount Maximum amount of collateral to buy
528
566
  * @param receiveAsNative If true and collateral is WcBTC, receive as native coin
529
567
  */
530
- function buyExpiredCollateral(IPosition pos, uint256 upToAmount, bool receiveAsNative) public returns (uint256) {
568
+ function buyExpiredCollateral(IPosition pos, uint256 upToAmount, bool receiveAsNative) public validPos(address(pos)) returns (uint256) {
531
569
  uint256 max = pos.collateral().balanceOf(address(pos));
532
570
  uint256 amount = upToAmount > max ? max : upToAmount;
533
571
  uint256 forceSalePrice = expiredPurchasePrice(pos);
@@ -566,10 +604,36 @@ contract MintingHub is IMintingHub, ERC165 {
566
604
  /**
567
605
  * @dev See {IERC165-supportsInterface}.
568
606
  */
569
- function supportsInterface(bytes4 interfaceId) public view override virtual returns (bool) {
570
- return
571
- interfaceId == type(IMintingHub).interfaceId ||
572
- super.supportsInterface(interfaceId);
607
+ function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
608
+ return interfaceId == type(IMintingHub).interfaceId || super.supportsInterface(interfaceId);
609
+ }
610
+
611
+ /**
612
+ * @notice Allows Position contracts to emit state updates through the hub for centralized monitoring.
613
+ * @dev Only callable by registered positions. Emits PositionUpdate event with the caller as position.
614
+ * @param _collateral Current collateral balance of the position
615
+ * @param _price Current liquidation price of the position
616
+ * @param _principal Current principal (debt) of the position
617
+ */
618
+ function emitPositionUpdate(
619
+ uint256 _collateral,
620
+ uint256 _price,
621
+ uint256 _principal
622
+ ) external virtual validPos(msg.sender) {
623
+ emit PositionUpdate(msg.sender, _collateral, _price, _principal);
624
+ }
625
+
626
+ /**
627
+ * @notice Allows Position contracts to emit governance denial events through the hub.
628
+ * @dev Only callable by registered positions. Emits PositionDeniedByGovernance event.
629
+ * @param denier Address of the governance participant who denied the position
630
+ * @param message Reason for denial (max 500 bytes to prevent gas exhaustion attacks)
631
+ */
632
+ function emitPositionDenied(address denier, string calldata message) external virtual validPos(msg.sender) {
633
+ uint256 messageLength = bytes(message).length;
634
+ if (messageLength == 0) revert EmptyMessage();
635
+ if (messageLength > MAX_MESSAGE_LENGTH) revert MessageTooLong(messageLength, MAX_MESSAGE_LENGTH);
636
+ emit PositionDeniedByGovernance(msg.sender, denier, message);
573
637
  }
574
638
 
575
639
  /**
@@ -22,6 +22,15 @@ contract Position is Ownable, IPosition, MathUtil {
22
22
  * the constant and immutable fields, but have their own values for the other fields.
23
23
  */
24
24
 
25
+ /**
26
+ * @notice Maximum allowed message length for denial messages (prevents gas griefing attacks).
27
+ * @dev This constant is intentionally duplicated in MintingHub.sol for defense-in-depth.
28
+ * Both contracts validate independently since Position contracts are deployed separately
29
+ * and must enforce limits even if the hub validation is bypassed or modified.
30
+ * If changing this value, update MintingHub.MAX_MESSAGE_LENGTH as well.
31
+ */
32
+ uint256 private constant MAX_MESSAGE_LENGTH = 500;
33
+
25
34
  /**
26
35
  * @notice The JUSD price per unit of the collateral below which challenges succeed, (36 - collateral.decimals) decimals
27
36
  */
@@ -126,6 +135,29 @@ contract Position is Ownable, IPosition, MathUtil {
126
135
  event MintingUpdate(uint256 collateral, uint256 price, uint256 principal);
127
136
  event PositionDenied(address indexed sender, string message); // emitted if closed by governance
128
137
 
138
+ /**
139
+ * @dev Emits MintingUpdate both locally and to the hub for centralized monitoring.
140
+ * This allows monitoring systems to track all position updates from a single address (the hub).
141
+ * @notice Adds ~3,800 gas overhead for the hub call.
142
+ */
143
+ function _emitUpdate(uint256 _collateral, uint256 _price, uint256 _principal) internal {
144
+ emit MintingUpdate(_collateral, _price, _principal);
145
+ IMintingHub(hub).emitPositionUpdate(_collateral, _price, _principal);
146
+ }
147
+
148
+ /**
149
+ * @dev Emits PositionDenied both locally and to the hub for centralized monitoring.
150
+ * @param sender The address that triggered the denial
151
+ * @param message Reason for denial (1-500 bytes, prevents gas griefing and ensures meaningful messages)
152
+ */
153
+ function _emitDenied(address sender, string memory message) internal {
154
+ uint256 messageLength = bytes(message).length;
155
+ if (messageLength == 0) revert EmptyMessage();
156
+ if (messageLength > MAX_MESSAGE_LENGTH) revert MessageTooLong(messageLength, MAX_MESSAGE_LENGTH);
157
+ emit PositionDenied(sender, message);
158
+ IMintingHub(hub).emitPositionDenied(sender, message);
159
+ }
160
+
129
161
  error InsufficientCollateral(uint256 needed, uint256 available);
130
162
  error TooLate();
131
163
  error RepaidTooMuch(uint256 excess);
@@ -140,6 +172,8 @@ contract Position is Ownable, IPosition, MathUtil {
140
172
  error NotOriginal();
141
173
  error InvalidExpiration();
142
174
  error AlreadyInitialized();
175
+ error MessageTooLong(uint256 length, uint256 maxLength);
176
+ error EmptyMessage();
143
177
  error PriceTooHigh(uint256 newPrice, uint256 maxPrice);
144
178
  error InvalidPriceReference();
145
179
  error NativeTransferFailed();
@@ -208,7 +242,7 @@ contract Position is Ownable, IPosition, MathUtil {
208
242
  reserveContribution = _reservePPM;
209
243
  minimumCollateral = _minCollateral;
210
244
  challengePeriod = _challengePeriod;
211
- start = uint40(block.timestamp) + _initPeriod; // at least three days time to deny the position
245
+ start = uint40(block.timestamp) + _initPeriod; // at least 14 days time to deny the position
212
246
  cooldown = start;
213
247
  expiration = start + _duration;
214
248
  limit = _initialLimit;
@@ -222,7 +256,8 @@ contract Position is Ownable, IPosition, MathUtil {
222
256
  */
223
257
  function initialize(address parent, uint40 _expiration) external onlyHub {
224
258
  if (expiration != 0) revert AlreadyInitialized();
225
- if (_expiration < block.timestamp || _expiration > Position(payable(original)).expiration()) revert InvalidExpiration(); // expiration must not be later than original
259
+ if (_expiration < block.timestamp || _expiration > Position(payable(original)).expiration())
260
+ revert InvalidExpiration(); // expiration must not be later than original
226
261
  expiration = _expiration;
227
262
  price = Position(payable(parent)).price();
228
263
  _fixRateToLeadrate(Position(payable(parent)).riskPremiumPPM());
@@ -282,7 +317,7 @@ contract Position is Ownable, IPosition, MathUtil {
282
317
  if (block.timestamp >= start) revert TooLate();
283
318
  IReserve(jusd.reserve()).checkQualified(msg.sender, helpers);
284
319
  _close();
285
- emit PositionDenied(msg.sender, message);
320
+ _emitDenied(msg.sender, message);
286
321
  }
287
322
 
288
323
  /**
@@ -322,7 +357,12 @@ contract Position is Ownable, IPosition, MathUtil {
322
357
  * @param newPrice The new liquidation price
323
358
  * @param withdrawAsNative If true, withdraw collateral as native coin instead of wrapped token
324
359
  */
325
- function adjust(uint256 newPrincipal, uint256 newCollateral, uint256 newPrice, bool withdrawAsNative) external payable onlyOwner {
360
+ function adjust(
361
+ uint256 newPrincipal,
362
+ uint256 newCollateral,
363
+ uint256 newPrice,
364
+ bool withdrawAsNative
365
+ ) external payable onlyOwner {
326
366
  _adjust(newPrincipal, newCollateral, newPrice, address(0), withdrawAsNative);
327
367
  }
328
368
 
@@ -337,7 +377,13 @@ contract Position is Ownable, IPosition, MathUtil {
337
377
  * @param referencePosition Reference position for cooldown-free price increase (address(0) for normal logic with cooldown)
338
378
  * @param withdrawAsNative If true, withdraw collateral as native coin instead of wrapped token
339
379
  */
340
- function adjustWithReference(uint256 newPrincipal, uint256 newCollateral, uint256 newPrice, address referencePosition, bool withdrawAsNative) external payable onlyOwner {
380
+ function adjustWithReference(
381
+ uint256 newPrincipal,
382
+ uint256 newCollateral,
383
+ uint256 newPrice,
384
+ address referencePosition,
385
+ bool withdrawAsNative
386
+ ) external payable onlyOwner {
341
387
  _adjust(newPrincipal, newCollateral, newPrice, referencePosition, withdrawAsNative);
342
388
  }
343
389
 
@@ -349,7 +395,13 @@ contract Position is Ownable, IPosition, MathUtil {
349
395
  * @param referencePosition Reference position for cooldown-free price increase (address(0) for normal logic)
350
396
  * @param withdrawAsNative If true and withdrawing collateral, unwrap to native coin
351
397
  */
352
- function _adjust(uint256 newPrincipal, uint256 newCollateral, uint256 newPrice, address referencePosition, bool withdrawAsNative) internal {
398
+ function _adjust(
399
+ uint256 newPrincipal,
400
+ uint256 newCollateral,
401
+ uint256 newPrice,
402
+ address referencePosition,
403
+ bool withdrawAsNative
404
+ ) internal {
353
405
  // Handle native coin deposit first (wraps to WCBTC)
354
406
  if (msg.value > 0) {
355
407
  IWrappedNative(address(collateral)).deposit{value: msg.value}();
@@ -378,7 +430,7 @@ contract Position is Ownable, IPosition, MathUtil {
378
430
  if (newPrice != price) {
379
431
  _adjustPrice(newPrice, referencePosition);
380
432
  }
381
- emit MintingUpdate(newCollateral, newPrice, newPrincipal);
433
+ _emitUpdate(newCollateral, newPrice, newPrincipal);
382
434
  }
383
435
 
384
436
  /**
@@ -389,7 +441,7 @@ contract Position is Ownable, IPosition, MathUtil {
389
441
  */
390
442
  function adjustPrice(uint256 newPrice) public onlyOwner {
391
443
  _adjustPrice(newPrice, address(0));
392
- emit MintingUpdate(_collateralBalance(), price, principal);
444
+ _emitUpdate(_collateralBalance(), price, principal);
393
445
  }
394
446
 
395
447
  /**
@@ -403,7 +455,7 @@ contract Position is Ownable, IPosition, MathUtil {
403
455
  */
404
456
  function adjustPriceWithReference(uint256 newPrice, address referencePosition) external onlyOwner {
405
457
  _adjustPrice(newPrice, referencePosition);
406
- emit MintingUpdate(_collateralBalance(), price, principal);
458
+ _emitUpdate(_collateralBalance(), price, principal);
407
459
  }
408
460
 
409
461
  /**
@@ -499,7 +551,7 @@ contract Position is Ownable, IPosition, MathUtil {
499
551
  function mint(address target, uint256 amount) public ownerOrRoller {
500
552
  uint256 collateralBalance = _collateralBalance();
501
553
  _mint(target, amount, collateralBalance);
502
- emit MintingUpdate(collateralBalance, price, principal);
554
+ _emitUpdate(collateralBalance, price, principal);
503
555
  }
504
556
 
505
557
  /**
@@ -511,7 +563,7 @@ contract Position is Ownable, IPosition, MathUtil {
511
563
 
512
564
  /**
513
565
  * @notice Computes the virtual price of the collateral in JUSD, which is the minimum collateral
514
- * price required to cover the entire debt with interest overcollateralization, lower bounded by the floor price.
566
+ * price required to cover the entire debt with interest overcollateralization, lower bounded by the floor price.
515
567
  * Returns the challenged price if a challenge is active.
516
568
  * @param colBalance The collateral balance of the position.
517
569
  * @param floorPrice The minimum price of the collateral in JUSD.
@@ -519,9 +571,9 @@ contract Position is Ownable, IPosition, MathUtil {
519
571
  function _virtualPrice(uint256 colBalance, uint256 floorPrice) internal view returns (uint256) {
520
572
  if (challengedAmount > 0) return challengedPrice;
521
573
  if (colBalance == 0) return floorPrice;
522
-
574
+
523
575
  uint256 virtPrice = (_getCollateralRequirement() * ONE_DEC18) / colBalance;
524
- return virtPrice < floorPrice ? floorPrice: virtPrice;
576
+ return virtPrice < floorPrice ? floorPrice : virtPrice;
525
577
  }
526
578
 
527
579
  /**
@@ -564,8 +616,9 @@ contract Position is Ownable, IPosition, MathUtil {
564
616
  // Single-division optimization for gas efficiency and precision. Equivalent formula:
565
617
  // uint256 usablePrincipal = (principal * (1_000_000 - reserveContribution)) / 1_000_000;
566
618
  // newInterest += (usablePrincipal * fixedAnnualRatePPM * delta) / (365 days * 1_000_000);
567
- newInterest += (principal * (1_000_000 - reserveContribution) * fixedAnnualRatePPM * delta)
568
- / (365 days * 1_000_000 * 1_000_000);
619
+ newInterest +=
620
+ (principal * (1_000_000 - reserveContribution) * fixedAnnualRatePPM * delta) /
621
+ (365 days * 1_000_000 * 1_000_000);
569
622
  }
570
623
 
571
624
  return newInterest;
@@ -594,7 +647,7 @@ contract Position is Ownable, IPosition, MathUtil {
594
647
  function getDebt() public view returns (uint256) {
595
648
  return _getDebt();
596
649
  }
597
-
650
+
598
651
  /**
599
652
  * @notice Public function to calculate current debt with overcollateralized interest
600
653
  * @return The total debt including overcollateralized interest
@@ -656,7 +709,7 @@ contract Position is Ownable, IPosition, MathUtil {
656
709
  */
657
710
  function repay(uint256 amount) public returns (uint256) {
658
711
  uint256 used = _payDownDebt(amount);
659
- emit MintingUpdate(_collateralBalance(), price, principal);
712
+ _emitUpdate(_collateralBalance(), price, principal);
660
713
  return used;
661
714
  }
662
715
 
@@ -713,7 +766,7 @@ contract Position is Ownable, IPosition, MathUtil {
713
766
  if (proceeds > 0) {
714
767
  jusd.transferFrom(buyer, owner(), proceeds);
715
768
  }
716
- emit MintingUpdate(_collateralBalance(), price, principal);
769
+ _emitUpdate(_collateralBalance(), price, principal);
717
770
  return;
718
771
  }
719
772
 
@@ -736,7 +789,7 @@ contract Position is Ownable, IPosition, MathUtil {
736
789
  jusd.transferFrom(buyer, owner(), proceeds);
737
790
  }
738
791
 
739
- emit MintingUpdate(_collateralBalance(), price, principal);
792
+ _emitUpdate(_collateralBalance(), price, principal);
740
793
  }
741
794
 
742
795
  /**
@@ -762,7 +815,7 @@ contract Position is Ownable, IPosition, MathUtil {
762
815
  */
763
816
  function withdrawCollateral(address target, uint256 amount) public ownerOrRoller {
764
817
  uint256 balance = _withdrawCollateral(target, amount);
765
- emit MintingUpdate(balance, price, principal);
818
+ _emitUpdate(balance, price, principal);
766
819
  }
767
820
 
768
821
  /**
@@ -773,7 +826,7 @@ contract Position is Ownable, IPosition, MathUtil {
773
826
  */
774
827
  function withdrawCollateralAsNative(address target, uint256 amount) public onlyOwner {
775
828
  uint256 balance = _withdrawCollateralAsNative(target, amount);
776
- emit MintingUpdate(balance, price, principal);
829
+ _emitUpdate(balance, price, principal);
777
830
  }
778
831
 
779
832
  function _withdrawCollateral(address target, uint256 amount) internal noCooldown noChallenge returns (uint256) {
@@ -786,7 +839,10 @@ contract Position is Ownable, IPosition, MathUtil {
786
839
  * @dev Internal helper for native coin withdrawal. Used by withdrawCollateralAsNative() and _adjust().
787
840
  * Does NOT emit MintingUpdate - callers are responsible for emitting.
788
841
  */
789
- function _withdrawCollateralAsNative(address target, uint256 amount) internal noCooldown noChallenge returns (uint256) {
842
+ function _withdrawCollateralAsNative(
843
+ address target,
844
+ uint256 amount
845
+ ) internal noCooldown noChallenge returns (uint256) {
790
846
  if (amount > 0) {
791
847
  IWrappedNative(address(collateral)).withdraw(amount);
792
848
  (bool success, ) = target.call{value: amount}("");
@@ -807,7 +863,7 @@ contract Position is Ownable, IPosition, MathUtil {
807
863
  */
808
864
  function transferChallengedCollateral(address target, uint256 amount) external onlyHub {
809
865
  uint256 newBalance = _sendCollateral(target, amount);
810
- emit MintingUpdate(newBalance, price, principal);
866
+ _emitUpdate(newBalance, price, principal);
811
867
  }
812
868
 
813
869
  function _sendCollateral(address target, uint256 amount) internal returns (uint256) {
@@ -830,7 +886,7 @@ contract Position is Ownable, IPosition, MathUtil {
830
886
  function _checkCollateral(uint256 collateralReserve, uint256 atPrice) internal view {
831
887
  uint256 relevantCollateral = collateralReserve < minimumCollateral ? 0 : collateralReserve;
832
888
  uint256 collateralRequirement = _getCollateralRequirement();
833
-
889
+
834
890
  if (relevantCollateral * atPrice < collateralRequirement * ONE_DEC18) {
835
891
  revert InsufficientCollateral(relevantCollateral * atPrice, collateralRequirement * ONE_DEC18);
836
892
  }
@@ -6,12 +6,22 @@ import {IPosition} from "./IPosition.sol";
6
6
  import {PositionRoller} from "../PositionRoller.sol";
7
7
 
8
8
  interface IMintingHub {
9
+ // Events for centralized position monitoring (emitted by hub, not individual positions)
10
+ event PositionUpdate(address indexed position, uint256 collateral, uint256 price, uint256 principal);
11
+
12
+ event PositionDeniedByGovernance(address indexed position, address indexed denier, string message);
13
+
9
14
  function RATE() external view returns (ILeadrate);
10
15
 
11
16
  function ROLLER() external view returns (PositionRoller);
12
17
 
13
18
  function WCBTC() external view returns (address);
14
19
 
20
+ // Position event forwarding functions
21
+ function emitPositionUpdate(uint256 collateral, uint256 price, uint256 principal) external;
22
+
23
+ function emitPositionDenied(address denier, string calldata message) external;
24
+
15
25
  function challenge(
16
26
  address _positionAddr,
17
27
  uint256 _collateralAmount,
@@ -20,7 +30,12 @@ interface IMintingHub {
20
30
 
21
31
  function bid(uint32 _challengeNumber, uint256 size, bool postponeCollateralReturn) external;
22
32
 
23
- function bid(uint32 _challengeNumber, uint256 size, bool postponeCollateralReturn, bool returnCollateralAsNative) external;
33
+ function bid(
34
+ uint32 _challengeNumber,
35
+ uint256 size,
36
+ bool postponeCollateralReturn,
37
+ bool returnCollateralAsNative
38
+ ) external;
24
39
 
25
40
  function returnPostponedCollateral(address collateral, address target) external;
26
41
 
@@ -30,5 +45,12 @@ interface IMintingHub {
30
45
 
31
46
  function buyExpiredCollateral(IPosition pos, uint256 upToAmount, bool receiveAsNative) external returns (uint256);
32
47
 
33
- function clone(address owner, address parent, uint256 _initialCollateral, uint256 _initialMint, uint40 expiration, uint256 _liqPrice) external payable returns (address);
48
+ function clone(
49
+ address owner,
50
+ address parent,
51
+ uint256 _initialCollateral,
52
+ uint256 _initialMint,
53
+ uint40 expiration,
54
+ uint256 _liqPrice
55
+ ) external payable returns (address);
34
56
  }
@@ -1,7 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity ^0.8.20;
3
3
 
4
- import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
5
4
  import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
6
5
  import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
7
6
  import {ERC4626, ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
@@ -11,18 +10,19 @@ import {ISavingsJUSD} from "./interface/ISavingsJUSD.sol";
11
10
  /**
12
11
  * @title SavingsVaultJUSD
13
12
  * @notice ERC-4626-compatible vault adapter for the JUSD Savings module.
14
- * This vault tracks interest-bearing deposits using a custom price-based mechanism,
15
- * where share value increases over time as interest accrues.
13
+ * This vault tracks interest-bearing deposits where share value increases over time
14
+ * as interest accrues in the underlying SAVINGS contract.
16
15
  *
17
- * @dev The vault mitigates dilution and price manipulation attacks on empty vaults
18
- * (a known vulnerability in ERC-4626) by using an explicit price model that starts at 1e18,
19
- * instead of relying on the default totalAssets / totalSupply ratio when supply is zero.
16
+ * @dev Inherits OpenZeppelin's ERC4626 virtual shares pattern for inflation attack mitigation.
17
+ * The base _convertToShares/_convertToAssets functions add virtual shares (+10**_decimalsOffset())
18
+ * and virtual assets (+1) to prevent first-depositor manipulation attacks.
20
19
  *
21
- * Interest is recognized through a manual `_accrueInterest()` call, which updates the internal
22
- * price based on newly accrued interest.
20
+ * Only totalAssets() is overridden to return the vault's balance in the SAVINGS contract
21
+ * plus any accrued interest. The base conversion functions use this through polymorphism.
22
+ *
23
+ * Interest is recognized through `_accrueInterest()` calls during deposits/withdrawals.
23
24
  */
24
25
  contract SavingsVaultJUSD is ERC4626 {
25
- using Math for uint256;
26
26
  using SafeCast for uint256;
27
27
 
28
28
  ISavingsJUSD public immutable SAVINGS;
@@ -30,6 +30,8 @@ contract SavingsVaultJUSD is ERC4626 {
30
30
 
31
31
  event InterestClaimed(uint256 interest, uint256 totalClaimed);
32
32
 
33
+ error ZeroShares();
34
+
33
35
  constructor(
34
36
  IERC20 _coin,
35
37
  ISavingsJUSD _savings,
@@ -50,11 +52,9 @@ contract SavingsVaultJUSD is ERC4626 {
50
52
  }
51
53
 
52
54
  /// @notice Returns the current price per share of the contract
53
- /// @dev If no shares exist, it defaults to 1 ether (implying 1:1 value)
55
+ /// @dev Uses virtual shares pattern for consistency with _convertToShares/_convertToAssets
54
56
  function price() public view returns (uint256) {
55
- uint256 totalShares = totalSupply();
56
- if (totalShares == 0) return 1 ether;
57
- return (totalAssets() * 1 ether) / totalShares;
57
+ return ((totalAssets() + 1) * 1 ether) / (totalSupply() + 10 ** _decimalsOffset());
58
58
  }
59
59
 
60
60
  /// @notice Calculates the accrued interest for this contract
@@ -69,17 +69,10 @@ contract SavingsVaultJUSD is ERC4626 {
69
69
  return SAVINGS.savings(address(this)).saved + _interest();
70
70
  }
71
71
 
72
- function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual override returns (uint256) {
73
- return assets.mulDiv(1 ether, price(), rounding);
74
- }
75
-
76
- function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view virtual override returns (uint256) {
77
- return shares.mulDiv(price(), 1 ether, rounding);
78
- }
79
-
80
72
  // ---------------------------------------------------------------------------------------
81
73
 
82
74
  function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual override {
75
+ if (shares == 0) revert ZeroShares();
83
76
  _accrueInterest();
84
77
 
85
78
  // If _asset is ERC-777, `transferFrom` can trigger a reentrancy BEFORE the transfer happens through the