@juicedollar/jusd 1.0.2 → 1.0.4

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.
@@ -2,6 +2,7 @@
2
2
  pragma solidity ^0.8.0;
3
3
 
4
4
  import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol";
5
+ import {IWrappedNative} from "../interface/IWrappedNative.sol";
5
6
  import {IJuiceDollar} from "../interface/IJuiceDollar.sol";
6
7
  import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
7
8
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
@@ -36,6 +37,7 @@ contract MintingHub is IMintingHub, ERC165 {
36
37
  IJuiceDollar public immutable JUSD; // currency
37
38
  PositionRoller public immutable ROLLER; // helper to roll positions
38
39
  ILeadrate public immutable RATE; // to determine the interest rate
40
+ address public immutable WCBTC; // wrapped native token (cBTC) address
39
41
 
40
42
  Challenge[] public challenges; // list of open challenges
41
43
 
@@ -45,6 +47,12 @@ contract MintingHub is IMintingHub, ERC165 {
45
47
  */
46
48
  mapping(address collateral => mapping(address owner => uint256 amount)) public pendingReturns;
47
49
 
50
+ /**
51
+ * @notice Tracks whether the first position has been created.
52
+ * @dev The first position (genesis) can skip the 3-day init period requirement.
53
+ */
54
+ bool private _genesisPositionCreated;
55
+
48
56
  struct Challenge {
49
57
  address challenger; // the address from which the challenge was initiated
50
58
  uint40 start; // the start of the challenge
@@ -75,17 +83,20 @@ contract MintingHub is IMintingHub, ERC165 {
75
83
  error InvalidCollateralDecimals();
76
84
  error ChallengeTimeTooShort();
77
85
  error InitPeriodTooShort();
86
+ error NativeOnlyForWCBTC();
87
+ error ValueMismatch();
78
88
 
79
89
  modifier validPos(address position) {
80
90
  if (JUSD.getPositionParent(position) != address(this)) revert InvalidPos();
81
91
  _;
82
92
  }
83
93
 
84
- constructor(address _jusd, address _leadrate, address _roller, address _factory) {
94
+ constructor(address _jusd, address _leadrate, address payable _roller, address _factory, address _wcbtc) {
85
95
  JUSD = IJuiceDollar(_jusd);
86
96
  RATE = ILeadrate(_leadrate);
87
97
  POSITION_FACTORY = IPositionFactory(_factory);
88
98
  ROLLER = PositionRoller(_roller);
99
+ WCBTC = _wcbtc;
89
100
  }
90
101
 
91
102
  /**
@@ -118,15 +129,20 @@ contract MintingHub is IMintingHub, ERC165 {
118
129
  uint24 _riskPremium,
119
130
  uint256 _liqPrice,
120
131
  uint24 _reservePPM
121
- ) public returns (address) {
132
+ ) public payable returns (address) {
122
133
  {
123
134
  if (_riskPremium > 1_000_000) revert InvalidRiskPremium();
124
135
  if (CHALLENGER_REWARD > _reservePPM || _reservePPM > 1_000_000) revert InvalidReservePPM();
125
136
  if (IERC20Metadata(_collateralAddress).decimals() > 24) revert InvalidCollateralDecimals(); // leaves 12 digits for price
126
137
  if (_challengeSeconds < 1 days) revert ChallengeTimeTooShort();
127
- if (_initPeriodSeconds < 6 hours) revert InitPeriodTooShort();
138
+ // First position (genesis) can skip init period, all others require 3 days minimum
139
+ if (_genesisPositionCreated) {
140
+ if (_initPeriodSeconds < 3 days) revert InitPeriodTooShort();
141
+ } else {
142
+ _genesisPositionCreated = true;
143
+ }
128
144
  uint256 invalidAmount = IERC20(_collateralAddress).totalSupply() + 1;
129
- // TODO: Improve for older tokens that revert with assert,
145
+ // TODO: Improve for older tokens that revert with assert,
130
146
  // which consumes all gas and makes the entire tx fail (uncatchable)
131
147
  try IERC20(_collateralAddress).transfer(address(0x123), invalidAmount) {
132
148
  revert IncompatibleCollateral(); // we need a collateral that reverts on failed transfers
@@ -137,6 +153,7 @@ contract MintingHub is IMintingHub, ERC165 {
137
153
  // must start with at least 100 JUSD worth of collateral
138
154
  if (_minCollateral * _liqPrice < 100 ether * 10 ** 18) revert InsufficientCollateral();
139
155
  }
156
+
140
157
  IPosition pos = IPosition(
141
158
  POSITION_FACTORY.createNewPosition(
142
159
  msg.sender,
@@ -154,41 +171,65 @@ contract MintingHub is IMintingHub, ERC165 {
154
171
  );
155
172
  JUSD.registerPosition(address(pos));
156
173
  JUSD.collectProfits(msg.sender, OPENING_FEE);
157
- IERC20(_collateralAddress).transferFrom(msg.sender, address(pos), _initialCollateral); // TODO: Use SafeERC20
174
+
175
+ // Transfer collateral (handles native coin positions)
176
+ if (msg.value > 0) {
177
+ if (_collateralAddress != WCBTC) revert NativeOnlyForWCBTC();
178
+ if (msg.value != _initialCollateral) revert ValueMismatch();
179
+ IWrappedNative(WCBTC).deposit{value: msg.value}();
180
+ IERC20(WCBTC).transfer(address(pos), _initialCollateral);
181
+ } else {
182
+ IERC20(_collateralAddress).transferFrom(msg.sender, address(pos), _initialCollateral); // TODO: Use SafeERC20
183
+ }
158
184
 
159
185
  emit PositionOpened(msg.sender, address(pos), address(pos), _collateralAddress);
160
186
  return address(pos);
161
187
  }
162
188
 
163
- function clone(
164
- address parent,
165
- uint256 _initialCollateral,
166
- uint256 _initialMint,
167
- uint40 expiration
168
- ) public returns (address) {
169
- return clone(msg.sender, parent, _initialCollateral, _initialMint, expiration);
170
- }
171
-
172
189
  /**
173
190
  * @notice Clones an existing position and immediately tries to mint the specified amount using the given collateral.
174
191
  * @dev This needs an allowance to be set on the collateral contract such that the minting hub can get the collateral.
192
+ * For native coin positions (WcBTC), send msg.value equal to _initialCollateral.
193
+ * @param owner The owner of the cloned position
194
+ * @param parent The parent position to clone from
195
+ * @param _initialCollateral Amount of collateral to deposit
196
+ * @param _initialMint Amount of JUSD to mint
197
+ * @param expiration Expiration timestamp for the clone
198
+ * @param _liqPrice The liquidation price. If 0, inherits from parent.
175
199
  */
176
200
  function clone(
177
201
  address owner,
178
202
  address parent,
179
203
  uint256 _initialCollateral,
180
204
  uint256 _initialMint,
181
- uint40 expiration
182
- ) public validPos(parent) returns (address) {
205
+ uint40 expiration,
206
+ uint256 _liqPrice
207
+ ) public payable validPos(parent) returns (address) {
183
208
  address pos = POSITION_FACTORY.clonePosition(parent);
184
209
  IPosition child = IPosition(pos);
185
210
  child.initialize(parent, expiration);
186
211
  JUSD.registerPosition(pos);
187
212
  IERC20 collateral = child.collateral();
188
213
  if (_initialCollateral < child.minimumCollateral()) revert InsufficientCollateral();
189
- collateral.transferFrom(msg.sender, pos, _initialCollateral); // collateral must still come from sender for security
214
+
215
+ // Transfer collateral (handles native coin positions)
216
+ if (msg.value > 0) {
217
+ if (address(collateral) != WCBTC) revert NativeOnlyForWCBTC();
218
+ if (msg.value != _initialCollateral) revert ValueMismatch();
219
+ IWrappedNative(WCBTC).deposit{value: msg.value}();
220
+ collateral.transfer(pos, _initialCollateral);
221
+ } else {
222
+ collateral.transferFrom(msg.sender, pos, _initialCollateral); // collateral must still come from sender for security
223
+ }
224
+
190
225
  emit PositionOpened(owner, address(pos), parent, address(collateral));
191
226
  child.mint(owner, _initialMint);
227
+
228
+ // Adjust price if requested, incurs cooldown on price increase
229
+ if (_liqPrice > 0 && _liqPrice != child.price()) {
230
+ child.adjustPrice(_liqPrice);
231
+ }
232
+
192
233
  Ownable(address(child)).transferOwnership(owner);
193
234
  return address(pos);
194
235
  }
@@ -2,6 +2,7 @@
2
2
  pragma solidity ^0.8.0;
3
3
 
4
4
  import {IMintingHubGateway} from "../gateway/interface/IMintingHubGateway.sol";
5
+ import {IWrappedNative} from "../interface/IWrappedNative.sol";
5
6
  import {IJuiceDollar} from "../interface/IJuiceDollar.sol";
6
7
  import {IReserve} from "../interface/IReserve.sol";
7
8
  import {MathUtil} from "../utils/MathUtil.sol";
@@ -141,6 +142,8 @@ contract Position is Ownable, IPosition, MathUtil {
141
142
  error AlreadyInitialized();
142
143
  error PriceTooHigh(uint256 newPrice, uint256 maxPrice);
143
144
  error InvalidPriceReference();
145
+ error NativeTransferFailed();
146
+ error CannotRescueCollateral();
144
147
 
145
148
  modifier alive() {
146
149
  if (block.timestamp >= expiration) revert Expired(uint40(block.timestamp), expiration);
@@ -205,7 +208,7 @@ contract Position is Ownable, IPosition, MathUtil {
205
208
  reserveContribution = _reservePPM;
206
209
  minimumCollateral = _minCollateral;
207
210
  challengePeriod = _challengePeriod;
208
- start = uint40(block.timestamp) + _initPeriod; // at least six hours time to deny the position
211
+ start = uint40(block.timestamp) + _initPeriod; // at least three days time to deny the position
209
212
  cooldown = start;
210
213
  expiration = start + _duration;
211
214
  limit = _initialLimit;
@@ -219,10 +222,10 @@ contract Position is Ownable, IPosition, MathUtil {
219
222
  */
220
223
  function initialize(address parent, uint40 _expiration) external onlyHub {
221
224
  if (expiration != 0) revert AlreadyInitialized();
222
- if (_expiration < block.timestamp || _expiration > Position(original).expiration()) revert InvalidExpiration(); // expiration must not be later than original
225
+ if (_expiration < block.timestamp || _expiration > Position(payable(original)).expiration()) revert InvalidExpiration(); // expiration must not be later than original
223
226
  expiration = _expiration;
224
- price = Position(parent).price();
225
- _fixRateToLeadrate(Position(parent).riskPremiumPPM());
227
+ price = Position(payable(parent)).price();
228
+ _fixRateToLeadrate(Position(payable(parent)).riskPremiumPPM());
226
229
  _transferOwnership(hub);
227
230
  }
228
231
 
@@ -268,7 +271,7 @@ contract Position is Ownable, IPosition, MathUtil {
268
271
  if (address(this) == original) {
269
272
  return limit - totalMinted;
270
273
  } else {
271
- return Position(original).availableForClones();
274
+ return Position(payable(original)).availableForClones();
272
275
  }
273
276
  }
274
277
 
@@ -312,27 +315,46 @@ contract Position is Ownable, IPosition, MathUtil {
312
315
  /**
313
316
  * @notice "All in one" function to adjust the principal, the collateral amount,
314
317
  * and the price in one transaction.
318
+ * @dev For native coin positions (WCBTC), msg.value will be wrapped and added as collateral.
319
+ * If withdrawAsNative is true, collateral withdrawals will be unwrapped to native coin.
320
+ * @param newPrincipal The new principal amount
321
+ * @param newCollateral The new collateral amount
322
+ * @param newPrice The new liquidation price
323
+ * @param withdrawAsNative If true, withdraw collateral as native coin instead of wrapped token
315
324
  */
316
- function adjust(uint256 newPrincipal, uint256 newCollateral, uint256 newPrice) external onlyOwner {
317
- _adjust(newPrincipal, newCollateral, newPrice, address(0));
325
+ function adjust(uint256 newPrincipal, uint256 newCollateral, uint256 newPrice, bool withdrawAsNative) external payable onlyOwner {
326
+ _adjust(newPrincipal, newCollateral, newPrice, address(0), withdrawAsNative);
318
327
  }
319
328
 
320
329
  /**
321
330
  * @notice "All in one" function to adjust the principal, the collateral amount,
322
331
  * and the price in one transaction, with optional reference position for cooldown-free price increase.
332
+ * @dev For native coin positions (WCBTC), msg.value will be wrapped and added as collateral.
333
+ * If withdrawAsNative is true, collateral withdrawals will be unwrapped to native coin.
323
334
  * @param newPrincipal The new principal amount
324
335
  * @param newCollateral The new collateral amount
325
336
  * @param newPrice The new liquidation price
326
337
  * @param referencePosition Reference position for cooldown-free price increase (address(0) for normal logic with cooldown)
338
+ * @param withdrawAsNative If true, withdraw collateral as native coin instead of wrapped token
327
339
  */
328
- function adjust(uint256 newPrincipal, uint256 newCollateral, uint256 newPrice, address referencePosition) external onlyOwner {
329
- _adjust(newPrincipal, newCollateral, newPrice, referencePosition);
340
+ function adjustWithReference(uint256 newPrincipal, uint256 newCollateral, uint256 newPrice, address referencePosition, bool withdrawAsNative) external payable onlyOwner {
341
+ _adjust(newPrincipal, newCollateral, newPrice, referencePosition, withdrawAsNative);
330
342
  }
331
343
 
332
344
  /**
333
345
  * @dev Internal implementation of adjust() - handles collateral, principal, and price adjustments.
346
+ * @param newPrincipal The new principal amount
347
+ * @param newCollateral The new collateral amount
348
+ * @param newPrice The new liquidation price
349
+ * @param referencePosition Reference position for cooldown-free price increase (address(0) for normal logic)
350
+ * @param withdrawAsNative If true and withdrawing collateral, unwrap to native coin
334
351
  */
335
- function _adjust(uint256 newPrincipal, uint256 newCollateral, uint256 newPrice, address referencePosition) internal {
352
+ function _adjust(uint256 newPrincipal, uint256 newCollateral, uint256 newPrice, address referencePosition, bool withdrawAsNative) internal {
353
+ // Handle native coin deposit first (wraps to WCBTC)
354
+ if (msg.value > 0) {
355
+ IWrappedNative(address(collateral)).deposit{value: msg.value}();
356
+ }
357
+
336
358
  uint256 colbal = _collateralBalance();
337
359
  if (newCollateral > colbal) {
338
360
  collateral.transferFrom(msg.sender, address(this), newCollateral - colbal);
@@ -343,7 +365,11 @@ contract Position is Ownable, IPosition, MathUtil {
343
365
  _payDownDebt(debt - newPrincipal);
344
366
  }
345
367
  if (newCollateral < colbal) {
346
- _withdrawCollateral(msg.sender, colbal - newCollateral);
368
+ if (withdrawAsNative) {
369
+ _withdrawCollateralAsNative(msg.sender, colbal - newCollateral);
370
+ } else {
371
+ _withdrawCollateral(msg.sender, colbal - newCollateral);
372
+ }
347
373
  }
348
374
  // Must be called after collateral withdrawal
349
375
  if (newPrincipal > principal) {
@@ -359,6 +385,7 @@ contract Position is Ownable, IPosition, MathUtil {
359
385
  * @notice Allows the position owner to adjust the liquidation price as long as there is no pending challenge.
360
386
  * Lowering the liquidation price can be done with immediate effect, given that there is enough collateral.
361
387
  * Increasing the liquidation price triggers a cooldown period of 3 days, during which minting is suspended.
388
+ * @param newPrice The new liquidation price
362
389
  */
363
390
  function adjustPrice(uint256 newPrice) public onlyOwner {
364
391
  _adjustPrice(newPrice, address(0));
@@ -589,7 +616,7 @@ contract Position is Ownable, IPosition, MathUtil {
589
616
  _accrueInterest(); // accrue interest
590
617
  _fixRateToLeadrate(riskPremiumPPM); // sync interest rate with leadrate
591
618
 
592
- Position(original).notifyMint(amount);
619
+ Position(payable(original)).notifyMint(amount);
593
620
  jusd.mintWithReserve(target, amount, reserveContribution);
594
621
 
595
622
  principal += amount;
@@ -643,7 +670,7 @@ contract Position is Ownable, IPosition, MathUtil {
643
670
  */
644
671
  function _notifyRepaid(uint256 amount) internal {
645
672
  if (amount > principal) revert RepaidTooMuch(amount - principal);
646
- Position(original).notifyRepaid(amount);
673
+ Position(payable(original)).notifyRepaid(amount);
647
674
  principal -= amount;
648
675
  }
649
676
 
@@ -713,36 +740,68 @@ contract Position is Ownable, IPosition, MathUtil {
713
740
  }
714
741
 
715
742
  /**
716
- * @notice Withdraw any ERC20 token that might have ended up on this address.
717
- * Withdrawing collateral is subject to the same restrictions as withdrawCollateral(...).
743
+ * @notice Rescue ERC20 tokens that were accidentally sent to this address.
744
+ * @dev Cannot be used for collateral - use withdrawCollateral() instead.
745
+ * @param token The ERC20 token to rescue
746
+ * @param target The address to send rescued tokens to
747
+ * @param amount The amount of tokens to rescue
718
748
  */
719
- function withdraw(address token, address target, uint256 amount) external onlyOwner {
720
- if (token == address(collateral)) {
721
- withdrawCollateral(target, amount);
722
- } else {
723
- uint256 balance = _collateralBalance();
724
- IERC20(token).transfer(target, amount);
725
- require(balance == _collateralBalance()); // guard against double-entry-point tokens
726
- }
749
+ function rescueToken(address token, address target, uint256 amount) external onlyOwner {
750
+ if (token == address(collateral)) revert CannotRescueCollateral();
751
+ uint256 balance = _collateralBalance();
752
+ IERC20(token).transfer(target, amount);
753
+ require(balance == _collateralBalance()); // guard against double-entry-point tokens
727
754
  }
728
755
 
729
756
  /**
730
757
  * @notice Withdraw collateral from the position up to the extent that it is still well collateralized afterwards.
731
758
  * Not possible as long as there is an open challenge or the contract is subject to a cooldown.
732
- *
733
759
  * Withdrawing collateral below the minimum collateral amount formally closes the position.
760
+ * @param target Address to receive the collateral
761
+ * @param amount Amount of collateral to withdraw
734
762
  */
735
763
  function withdrawCollateral(address target, uint256 amount) public ownerOrRoller {
736
764
  uint256 balance = _withdrawCollateral(target, amount);
737
765
  emit MintingUpdate(balance, price, principal);
738
766
  }
739
767
 
768
+ /**
769
+ * @notice Withdraw collateral as native coin (unwrapped).
770
+ * @dev Only works for wrapped native collateral tokens.
771
+ * @param target Address to receive the native coin
772
+ * @param amount Amount of collateral to withdraw and unwrap
773
+ */
774
+ function withdrawCollateralAsNative(address target, uint256 amount) public onlyOwner {
775
+ uint256 balance = _withdrawCollateralAsNative(target, amount);
776
+ emit MintingUpdate(balance, price, principal);
777
+ }
778
+
740
779
  function _withdrawCollateral(address target, uint256 amount) internal noCooldown noChallenge returns (uint256) {
741
780
  uint256 balance = _sendCollateral(target, amount);
742
781
  _checkCollateral(balance, price);
743
782
  return balance;
744
783
  }
745
784
 
785
+ /**
786
+ * @dev Internal helper for native coin withdrawal. Used by withdrawCollateralAsNative() and _adjust().
787
+ * Does NOT emit MintingUpdate - callers are responsible for emitting.
788
+ */
789
+ function _withdrawCollateralAsNative(address target, uint256 amount) internal noCooldown noChallenge returns (uint256) {
790
+ if (amount > 0) {
791
+ IWrappedNative(address(collateral)).withdraw(amount);
792
+ (bool success, ) = target.call{value: amount}("");
793
+ if (!success) revert NativeTransferFailed();
794
+ }
795
+
796
+ uint256 balance = _collateralBalance();
797
+ if (balance < minimumCollateral) {
798
+ _close();
799
+ }
800
+
801
+ _checkCollateral(balance, price);
802
+ return balance;
803
+ }
804
+
746
805
  /**
747
806
  * @notice Transfer the challenged collateral to the bidder. Only callable by minting hub.
748
807
  */
@@ -903,4 +962,15 @@ contract Position is Ownable, IPosition, MathUtil {
903
962
 
904
963
  return (owner(), _size, principalToPay, interestToPay, reserveContribution);
905
964
  }
965
+
966
+ /**
967
+ * @notice Receive native coin and auto-wrap to collateral.
968
+ * @dev Reverts for non-native positions to prevent stuck funds.
969
+ * Checks if sender is collateral to prevent unwrap loops.
970
+ */
971
+ receive() external payable {
972
+ if (msg.sender != address(collateral)) {
973
+ IWrappedNative(address(collateral)).deposit{value: msg.value}();
974
+ }
975
+ }
906
976
  }
@@ -48,9 +48,9 @@ contract PositionFactory {
48
48
  * @return address of the newly created clone position
49
49
  */
50
50
  function clonePosition(address _parent) external returns (address) {
51
- Position parent = Position(_parent);
51
+ Position parent = Position(payable(_parent));
52
52
  parent.assertCloneable();
53
- Position clone = Position(_createClone(parent.original()));
53
+ Position clone = Position(payable(_createClone(parent.original())));
54
54
  return address(clone);
55
55
  }
56
56