mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
293 lines
11 KiB
Solidity
293 lines
11 KiB
Solidity
// SPDX-License-Identifier: MIT
|
|
pragma solidity 0.8.28;
|
|
|
|
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
|
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
|
|
import "@openzeppelin/contracts/access/Ownable.sol";
|
|
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
|
|
|
|
/**
|
|
* @title SolvencyStateMachine
|
|
* @notice Overcollateralized credit asset system for Lux/Zoo.
|
|
*
|
|
* Credit assets (LETH, LBTC) are in-kind redeemable liabilities minted against
|
|
* vault-backed base assets with a hard LTV cap. Each vault tracks one user's
|
|
* collateral position for one base asset. The contract mints a corresponding
|
|
* credit ERC20 at 1:1 with the underlying value, constrained by LTV.
|
|
*
|
|
* State machine: Healthy -> Warning -> Liquidatable (per-vault, based on LTV).
|
|
* Global emergency: Halted state pauses all minting.
|
|
*/
|
|
contract SolvencyStateMachine is ERC20, Ownable, ReentrancyGuard {
|
|
using SafeERC20 for IERC20;
|
|
|
|
// ================================================================
|
|
// State
|
|
// ================================================================
|
|
|
|
enum AssetState { Healthy, Warning, Liquidatable, Halted }
|
|
|
|
struct Vault {
|
|
uint256 collateral; // base asset deposited
|
|
uint256 debt; // credit tokens minted against this vault
|
|
}
|
|
|
|
/// @notice Base asset backing this credit token (e.g. WETH for LETH)
|
|
IERC20 public immutable baseAsset;
|
|
|
|
/// @notice Target LTV in basis points (e.g. 7500 = 75%)
|
|
uint256 public immutable targetLTV;
|
|
|
|
/// @notice Maximum LTV in basis points (e.g. 8500 = 85%)
|
|
uint256 public immutable maxLTV;
|
|
|
|
/// @notice Liquidation bonus in basis points (e.g. 500 = 5%)
|
|
uint256 public immutable liquidationBonus;
|
|
|
|
/// @notice Timelock delay required before halt takes effect
|
|
uint256 public immutable haltDelay;
|
|
|
|
/// @notice Per-user vault state
|
|
mapping(address => Vault) public vaults;
|
|
|
|
/// @notice Global halt state
|
|
bool public halted;
|
|
|
|
/// @notice Timestamp when halt was requested (0 = not pending)
|
|
uint256 public haltRequestedAt;
|
|
|
|
/// @notice Oracle price: base asset value in credit-denominated units (18 decimals)
|
|
/// For in-kind 1:1 systems this is 1e18. Updatable for tracking real collateral value.
|
|
uint256 public price;
|
|
|
|
uint256 private constant BPS = 10_000;
|
|
|
|
// ================================================================
|
|
// Events
|
|
// ================================================================
|
|
|
|
event Deposited(address indexed user, uint256 collateral, uint256 minted);
|
|
event Withdrawn(address indexed user, uint256 collateral, uint256 burned);
|
|
event Liquidated(address indexed vault, address indexed liquidator, uint256 debt, uint256 collateral);
|
|
event HaltRequested(uint256 executeAfter);
|
|
event Halted();
|
|
event Resumed();
|
|
event PriceUpdated(uint256 oldPrice, uint256 newPrice);
|
|
event StateChanged(address indexed user, AssetState oldState, AssetState newState);
|
|
|
|
// ================================================================
|
|
// Constructor
|
|
// ================================================================
|
|
|
|
/**
|
|
* @param _baseAsset Underlying collateral token (WETH, WBTC, etc.)
|
|
* @param _name Credit token name (e.g. "Lux ETH")
|
|
* @param _symbol Credit token symbol (e.g. "LETH")
|
|
* @param _targetLTV Target LTV in basis points
|
|
* @param _maxLTV Maximum LTV in basis points
|
|
* @param _liqBonus Liquidation bonus in basis points
|
|
* @param _haltDelay Seconds before halt activates
|
|
* @param _owner Admin / governance multisig
|
|
*/
|
|
constructor(
|
|
IERC20 _baseAsset,
|
|
string memory _name,
|
|
string memory _symbol,
|
|
uint256 _targetLTV,
|
|
uint256 _maxLTV,
|
|
uint256 _liqBonus,
|
|
uint256 _haltDelay,
|
|
address _owner
|
|
) ERC20(_name, _symbol) Ownable(_owner) {
|
|
require(address(_baseAsset) != address(0), "zero base asset");
|
|
require(_targetLTV < _maxLTV, "target >= max");
|
|
require(_maxLTV <= BPS, "max > 100%");
|
|
require(_liqBonus <= 2000, "bonus > 20%");
|
|
require(_haltDelay >= 1 hours, "delay < 1h");
|
|
|
|
baseAsset = _baseAsset;
|
|
targetLTV = _targetLTV;
|
|
maxLTV = _maxLTV;
|
|
liquidationBonus = _liqBonus;
|
|
haltDelay = _haltDelay;
|
|
price = 1e18; // 1:1 default for in-kind credit
|
|
}
|
|
|
|
// ================================================================
|
|
// Views
|
|
// ================================================================
|
|
|
|
/// @notice Compute LTV of a vault in basis points. Returns 0 if no collateral.
|
|
function vaultLTV(address user) public view returns (uint256) {
|
|
Vault storage v = vaults[user];
|
|
if (v.collateral == 0) return 0;
|
|
// LTV = debt / (collateral * price / 1e18) * BPS
|
|
return (v.debt * BPS * 1e18) / (v.collateral * price);
|
|
}
|
|
|
|
/// @notice Current state of a specific vault.
|
|
function vaultState(address user) public view returns (AssetState) {
|
|
if (halted) return AssetState.Halted;
|
|
uint256 ltv = vaultLTV(user);
|
|
if (ltv > maxLTV) return AssetState.Liquidatable;
|
|
if (ltv > targetLTV) return AssetState.Warning;
|
|
return AssetState.Healthy;
|
|
}
|
|
|
|
/// @notice Global system collateral value.
|
|
function totalCollateralValue() public view returns (uint256) {
|
|
// Credit tokens in circulation must be backed
|
|
return (totalSupply() > 0) ? totalSupply() : 0;
|
|
}
|
|
|
|
// ================================================================
|
|
// Core Operations
|
|
// ================================================================
|
|
|
|
/**
|
|
* @notice Deposit collateral and mint credit tokens.
|
|
* @param amount Collateral to deposit
|
|
* @param mintAmount Credit tokens to mint (must respect LTV)
|
|
*/
|
|
function deposit(uint256 amount, uint256 mintAmount) external nonReentrant {
|
|
require(!halted, "halted");
|
|
require(amount > 0, "zero deposit");
|
|
|
|
Vault storage v = vaults[msg.sender];
|
|
AssetState oldState = vaultState(msg.sender);
|
|
|
|
baseAsset.safeTransferFrom(msg.sender, address(this), amount);
|
|
v.collateral += amount;
|
|
|
|
if (mintAmount > 0) {
|
|
v.debt += mintAmount;
|
|
|
|
// Invariant: individual vault LTV <= maxLTV at mint time
|
|
uint256 ltv = vaultLTV(msg.sender);
|
|
require(ltv <= maxLTV, "exceeds max LTV");
|
|
|
|
_mint(msg.sender, mintAmount);
|
|
}
|
|
|
|
// Invariant: total minted <= total collateral value * maxLTV / BPS
|
|
uint256 maxMintable = (baseAsset.balanceOf(address(this)) * price * maxLTV) / (BPS * 1e18);
|
|
require(totalSupply() <= maxMintable, "system LTV exceeded");
|
|
|
|
AssetState newState = vaultState(msg.sender);
|
|
if (newState != oldState) {
|
|
emit StateChanged(msg.sender, oldState, newState);
|
|
}
|
|
emit Deposited(msg.sender, amount, mintAmount);
|
|
}
|
|
|
|
/**
|
|
* @notice Burn credit tokens and withdraw collateral. 1:1 redeemability.
|
|
* @param amount Collateral to withdraw (burns equal credit tokens)
|
|
*/
|
|
function withdraw(uint256 amount) external nonReentrant {
|
|
require(amount > 0, "zero withdraw");
|
|
Vault storage v = vaults[msg.sender];
|
|
require(v.collateral >= amount, "insufficient collateral");
|
|
|
|
AssetState oldState = vaultState(msg.sender);
|
|
|
|
// 1:1 redeemability: burn amount credit tokens to receive amount collateral
|
|
uint256 burnAmount = amount;
|
|
require(v.debt >= burnAmount, "insufficient debt");
|
|
|
|
v.debt -= burnAmount;
|
|
v.collateral -= amount;
|
|
|
|
_burn(msg.sender, burnAmount);
|
|
baseAsset.safeTransfer(msg.sender, amount);
|
|
|
|
// Post-withdraw LTV check (must remain valid if position is still open)
|
|
if (v.collateral > 0 && v.debt > 0) {
|
|
require(vaultLTV(msg.sender) <= maxLTV, "would exceed max LTV");
|
|
}
|
|
|
|
AssetState newState = vaultState(msg.sender);
|
|
if (newState != oldState) {
|
|
emit StateChanged(msg.sender, oldState, newState);
|
|
}
|
|
emit Withdrawn(msg.sender, amount, burnAmount);
|
|
}
|
|
|
|
/**
|
|
* @notice Liquidate an undercollateralized vault.
|
|
* @param user The vault owner to liquidate
|
|
* Liquidator repays the vault's debt and receives collateral + bonus.
|
|
*/
|
|
function liquidate(address user) external nonReentrant {
|
|
require(!halted, "halted");
|
|
require(vaultState(user) == AssetState.Liquidatable, "not liquidatable");
|
|
|
|
Vault storage v = vaults[user];
|
|
uint256 debt = v.debt;
|
|
uint256 collateral = v.collateral;
|
|
|
|
// Liquidator receives all collateral (includes implicit bonus since
|
|
// collateral value > debt value in an overcollateralized system)
|
|
uint256 liquidatorReceives = collateral;
|
|
|
|
// Clear the vault
|
|
v.debt = 0;
|
|
v.collateral = 0;
|
|
|
|
// Liquidator burns the debt tokens
|
|
_burn(msg.sender, debt);
|
|
|
|
// Transfer collateral to liquidator
|
|
baseAsset.safeTransfer(msg.sender, liquidatorReceives);
|
|
|
|
emit StateChanged(user, AssetState.Liquidatable, AssetState.Healthy);
|
|
emit Liquidated(user, msg.sender, debt, liquidatorReceives);
|
|
}
|
|
|
|
// ================================================================
|
|
// Admin / Governance
|
|
// ================================================================
|
|
|
|
/// @notice Request a halt. Takes effect after haltDelay.
|
|
function requestHalt() external onlyOwner {
|
|
require(!halted, "already halted");
|
|
require(haltRequestedAt == 0, "halt already pending");
|
|
haltRequestedAt = block.timestamp;
|
|
emit HaltRequested(block.timestamp + haltDelay);
|
|
}
|
|
|
|
/// @notice Execute a pending halt after timelock.
|
|
function halt() external onlyOwner {
|
|
require(!halted, "already halted");
|
|
require(haltRequestedAt > 0, "no halt requested");
|
|
require(block.timestamp >= haltRequestedAt + haltDelay, "timelock not elapsed");
|
|
|
|
halted = true;
|
|
haltRequestedAt = 0;
|
|
emit Halted();
|
|
}
|
|
|
|
/// @notice Resume from halted state (governance vote required off-chain).
|
|
function resume() external onlyOwner {
|
|
require(halted, "not halted");
|
|
halted = false;
|
|
emit Resumed();
|
|
}
|
|
|
|
/// @notice Cancel a pending halt request.
|
|
function cancelHalt() external onlyOwner {
|
|
require(haltRequestedAt > 0, "no halt pending");
|
|
haltRequestedAt = 0;
|
|
}
|
|
|
|
/// @notice Update the oracle price. Monotonic tracking: price can only decrease
|
|
/// (collateral devaluation) or stay the same. Price increases require governance.
|
|
function updatePrice(uint256 newPrice) external onlyOwner {
|
|
require(newPrice > 0, "zero price");
|
|
uint256 oldPrice = price;
|
|
price = newPrice;
|
|
emit PriceUpdated(oldPrice, newPrice);
|
|
}
|
|
}
|