mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
big.Int.Uint64 and .Int64 are documented "if x cannot be represented, the result is undefined". On calldata that is not a lossy conversion to reason about, it is a value substitution, and the substitution happens twice in series: Uint64 drops everything above bit 63, and the Go conversion that follows it -- uint24(x), which is uint32(x) because uint24 is an alias -- drops everything above bit 31. A reader who checks only the first concludes the site is safe. contract.Unsigned and contract.Signed refuse a word that does not fit its declared width, once, where the bytes become a value. Six production sites use them; nothing downstream has to remember which of its arguments were bounded. DecodePoolKey read fee and tickSpacing as 24-bit fields and enforced neither. Above 2^32 the narrowing substituted: a tickSpacing word of 2^32+6 arrived as 6, and 2^32+60 produced the same pool id as 60. Between 2^24 and 2^32 the field held the caller's value faithfully while EncodePoolKeyABI wrote only its low three bytes, so Fee 3000 and Fee 2^24+3000 hash to the SAME id -- PoolKey.ID is not injective over its own Go type, and the decoder handed back a key whose fields are not the ones its id was derived from. The encoder is unchanged; changing it renames every pool that exists. What changed is that no such key can be built from calldata, which empties the collision class at the only boundary that produces PoolKeys from untrusted bytes. Same bound on the two tick fields in DecodeModifyLiquidityInput and decodePMLifecycle. Three engines narrowed INSIDE the comparison meant to bound the value, so the check did not merely miss, it inverted. A margin removal to 2^64x leverage read as leverage zero and passed; a perp open at 2^64 in hundredths did the same; increasePosition wrapped the quotient into a uint32 field that then set the liquidation price. Leverage is now compared at full width. CalculateFee clamped AFTER narrowing, so a violent enough move came out cheaper than a calm one; it clamps in 256 bits and narrows once the clamp proves it fits. GetTimeToRepayment saturates rather than wrapping a horizon into a small number of blocks. fhe declares euint128/160/256 and loads them through EncryptUint64, whose bit loop reads value>>i off a uint64 -- zero for every i above 63. The container is genuinely wider (tfheRandom fills all 256 bits), so the missing bits were not rounded away, they were encrypted AS ZERO: measured, asEuint256(2^200) decrypted to 0, and asEaddress on two addresses sharing their last eight bytes produced the same plaintext, so a homomorphic equality over encrypted addresses answered true for two different accounts. Casts to a narrower type still truncate, matching Solidity and the existing asEuint4/8/16/32 masks; what is refused is a plaintext wider than the loader, which is not a cast but a value the encryptor cannot carry. Widening it needs an EncryptBig upstream -- BitCiphertext's fields are unexported and it has no constructor from bits. Same bound on the scalar ops, once ahead of the switch rather than five times inside it. Consensus-visible: words that decoded now refuse. Fee or tickSpacing wider than 24 bits, a tick wider than int24, an FHE plaintext or scalar wider than 64 bits. Every one of them previously resolved to a different value than the caller sent. Four prior lanes had pinned three of these as characterisation tests naming themselves DEFECT, each instructing that the test be inverted when the bound arrived; they are inverted here rather than deleted, so the history of why stays legible. 24 of 24 mutations caught, no survivors. contract 100.0%, dex 99.1 -> 99.2. Both CGO settings, -count=1, -count=2 and -race.
686 lines
20 KiB
Go
686 lines
20 KiB
Go
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package dex
|
|
|
|
import (
|
|
"errors"
|
|
"math/big"
|
|
"sync"
|
|
|
|
"github.com/luxfi/geth/common"
|
|
"github.com/luxfi/precompile/contract"
|
|
)
|
|
|
|
// Margin account types
|
|
const (
|
|
CrossMargin MarginAccountType = iota // Share margin across all positions
|
|
IsolatedMargin // Separate margin for each position
|
|
PortfolioMargin // Risk-based margining
|
|
)
|
|
|
|
// MarginAccountType represents the type of margin account
|
|
type MarginAccountType uint8
|
|
|
|
// Default margin parameters
|
|
const (
|
|
DefaultMaxLeverage = 100 // 100x for cross margin
|
|
IsolatedMaxLeverage = 200 // 200x for isolated margin
|
|
PortfolioMaxLeverage = 1111 // 1111x for portfolio margin (pro traders)
|
|
DefaultMaintenanceMargin = 500 // 0.5% (basis points)
|
|
DefaultInitialMargin = 1000 // 1% (basis points)
|
|
DefaultLiquidationPenalty = 500 // 0.5% goes to insurance fund
|
|
DefaultLiquidatorReward = 250 // 0.25% to liquidator
|
|
MarginPrecision = 10000 // Basis point precision
|
|
)
|
|
|
|
// MarginAccount represents a user's margin trading account
|
|
type MarginAccount struct {
|
|
Owner common.Address
|
|
AccountType MarginAccountType
|
|
Collateral map[common.Address]*big.Int // Asset -> Amount
|
|
CollateralValue *big.Int // Total USD value of collateral
|
|
Positions map[[32]byte]*MarginPosition
|
|
TotalBorrowed map[common.Address]*big.Int // Asset -> Borrowed amount
|
|
MaxLeverage uint32
|
|
MaintenanceMargin uint32 // Basis points
|
|
InitialMargin uint32 // Basis points
|
|
}
|
|
|
|
// MarginPosition represents a leveraged trading position
|
|
type MarginPosition struct {
|
|
MarketID [32]byte
|
|
Side PositionSide
|
|
Size *big.Int
|
|
EntryPrice *big.Int // Q96
|
|
MarkPrice *big.Int // Q96
|
|
Margin *big.Int // Allocated margin
|
|
UnrealizedPnL *big.Int
|
|
RealizedPnL *big.Int
|
|
Leverage uint32
|
|
LiquidationPrice *big.Int // Q96
|
|
StopLoss *big.Int // Q96 (optional)
|
|
TakeProfit *big.Int // Q96 (optional)
|
|
IsIsolated bool
|
|
}
|
|
|
|
// PositionSide represents long or short
|
|
type PositionSide uint8
|
|
|
|
const (
|
|
Long PositionSide = iota
|
|
Short
|
|
)
|
|
|
|
// MarginEngine manages margin accounts and positions
|
|
type MarginEngine struct {
|
|
Accounts map[common.Address]*MarginAccount
|
|
CollateralRates map[common.Address]*CollateralRate // Asset -> collateral parameters
|
|
InsuranceFund *big.Int
|
|
TotalCollateral map[common.Address]*big.Int
|
|
TotalBorrowed map[common.Address]*big.Int
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// CollateralRate defines collateral parameters for an asset
|
|
type CollateralRate struct {
|
|
Asset common.Address
|
|
CollateralRatio *big.Int // Discount applied (e.g., 0.9e18 = 90%)
|
|
BorrowRate *big.Int // Annual borrow rate (e.g., 0.05e18 = 5%)
|
|
MaxBorrowable *big.Int // Maximum borrowable amount
|
|
IsActive bool
|
|
}
|
|
|
|
// NewMarginEngine creates a new margin trading engine
|
|
func NewMarginEngine() *MarginEngine {
|
|
return &MarginEngine{
|
|
Accounts: make(map[common.Address]*MarginAccount),
|
|
CollateralRates: make(map[common.Address]*CollateralRate),
|
|
InsuranceFund: big.NewInt(0),
|
|
TotalCollateral: make(map[common.Address]*big.Int),
|
|
TotalBorrowed: make(map[common.Address]*big.Int),
|
|
}
|
|
}
|
|
|
|
// CreateAccount creates a new margin account
|
|
func (me *MarginEngine) CreateAccount(owner common.Address, accountType MarginAccountType) (*MarginAccount, error) {
|
|
me.mu.Lock()
|
|
defer me.mu.Unlock()
|
|
|
|
if _, exists := me.Accounts[owner]; exists {
|
|
return nil, ErrAccountExists
|
|
}
|
|
|
|
var maxLeverage uint32
|
|
switch accountType {
|
|
case CrossMargin:
|
|
maxLeverage = DefaultMaxLeverage
|
|
case IsolatedMargin:
|
|
maxLeverage = IsolatedMaxLeverage
|
|
case PortfolioMargin:
|
|
maxLeverage = PortfolioMaxLeverage
|
|
}
|
|
|
|
account := &MarginAccount{
|
|
Owner: owner,
|
|
AccountType: accountType,
|
|
Collateral: make(map[common.Address]*big.Int),
|
|
CollateralValue: big.NewInt(0),
|
|
Positions: make(map[[32]byte]*MarginPosition),
|
|
TotalBorrowed: make(map[common.Address]*big.Int),
|
|
MaxLeverage: maxLeverage,
|
|
MaintenanceMargin: DefaultMaintenanceMargin,
|
|
InitialMargin: DefaultInitialMargin,
|
|
}
|
|
|
|
me.Accounts[owner] = account
|
|
return account, nil
|
|
}
|
|
|
|
// DepositCollateral adds collateral to an account
|
|
func (me *MarginEngine) DepositCollateral(owner common.Address, asset common.Address, amount *big.Int) error {
|
|
me.mu.Lock()
|
|
defer me.mu.Unlock()
|
|
|
|
account := me.Accounts[owner]
|
|
if account == nil {
|
|
return ErrAccountNotFound
|
|
}
|
|
|
|
// Validate asset is accepted as collateral
|
|
rate := me.CollateralRates[asset]
|
|
if rate == nil || !rate.IsActive {
|
|
return ErrInvalidCollateral
|
|
}
|
|
|
|
// Update collateral
|
|
if account.Collateral[asset] == nil {
|
|
account.Collateral[asset] = big.NewInt(0)
|
|
}
|
|
account.Collateral[asset].Add(account.Collateral[asset], amount)
|
|
|
|
// Update totals
|
|
if me.TotalCollateral[asset] == nil {
|
|
me.TotalCollateral[asset] = big.NewInt(0)
|
|
}
|
|
me.TotalCollateral[asset].Add(me.TotalCollateral[asset], amount)
|
|
|
|
return nil
|
|
}
|
|
|
|
// WithdrawCollateral removes collateral from an account if safe
|
|
func (me *MarginEngine) WithdrawCollateral(owner common.Address, asset common.Address, amount *big.Int) error {
|
|
me.mu.Lock()
|
|
defer me.mu.Unlock()
|
|
|
|
account := me.Accounts[owner]
|
|
if account == nil {
|
|
return ErrAccountNotFound
|
|
}
|
|
|
|
collateral := account.Collateral[asset]
|
|
if collateral == nil || collateral.Cmp(amount) < 0 {
|
|
return ErrInsufficientCollateral
|
|
}
|
|
|
|
// Check if withdrawal keeps account healthy
|
|
newCollateral := new(big.Int).Sub(collateral, amount)
|
|
if !me.isAccountSafeWithCollateral(account, asset, newCollateral) {
|
|
return ErrWithdrawalUnsafe
|
|
}
|
|
|
|
account.Collateral[asset] = newCollateral
|
|
me.TotalCollateral[asset].Sub(me.TotalCollateral[asset], amount)
|
|
|
|
return nil
|
|
}
|
|
|
|
// OpenPosition opens a new leveraged position
|
|
func (me *MarginEngine) OpenPosition(
|
|
owner common.Address,
|
|
marketID [32]byte,
|
|
side PositionSide,
|
|
size *big.Int,
|
|
leverage uint32,
|
|
markPrice *big.Int, // Current mark price
|
|
isIsolated bool,
|
|
) (*MarginPosition, error) {
|
|
me.mu.Lock()
|
|
defer me.mu.Unlock()
|
|
|
|
account := me.Accounts[owner]
|
|
if account == nil {
|
|
return nil, ErrAccountNotFound
|
|
}
|
|
|
|
// Validate leverage
|
|
if leverage > account.MaxLeverage {
|
|
return nil, ErrExcessiveLeverage
|
|
}
|
|
|
|
// Calculate required margin
|
|
notionalValue := new(big.Int).Mul(size, markPrice)
|
|
notionalValue.Div(notionalValue, Q96)
|
|
|
|
requiredMargin := new(big.Int).Mul(notionalValue, big.NewInt(int64(account.InitialMargin)))
|
|
requiredMargin.Div(requiredMargin, big.NewInt(MarginPrecision))
|
|
|
|
// For isolated margin, require specific margin allocation
|
|
// For cross margin, check total account equity
|
|
if isIsolated || account.AccountType == IsolatedMargin {
|
|
// Need explicit margin allocation (handled by caller)
|
|
} else {
|
|
// Cross margin - check if account has enough free margin
|
|
freeMargin := me.calculateFreeMargin(account)
|
|
if freeMargin.Cmp(requiredMargin) < 0 {
|
|
return nil, ErrInsufficientMargin
|
|
}
|
|
}
|
|
|
|
// Calculate liquidation price
|
|
liquidationPrice := me.calculateLiquidationPrice(side, markPrice, leverage, account.MaintenanceMargin)
|
|
|
|
position := &MarginPosition{
|
|
MarketID: marketID,
|
|
Side: side,
|
|
Size: new(big.Int).Set(size),
|
|
EntryPrice: new(big.Int).Set(markPrice),
|
|
MarkPrice: new(big.Int).Set(markPrice),
|
|
Margin: new(big.Int).Set(requiredMargin),
|
|
UnrealizedPnL: big.NewInt(0),
|
|
RealizedPnL: big.NewInt(0),
|
|
Leverage: leverage,
|
|
LiquidationPrice: liquidationPrice,
|
|
IsIsolated: isIsolated,
|
|
}
|
|
|
|
// Check for existing position
|
|
existing := account.Positions[marketID]
|
|
if existing != nil {
|
|
// Combine positions or flip
|
|
if existing.Side == side {
|
|
// Same direction - increase position
|
|
return me.increasePosition(account, existing, position)
|
|
} else {
|
|
// Opposite direction - reduce or flip
|
|
return me.reduceOrFlipPosition(account, existing, position)
|
|
}
|
|
}
|
|
|
|
account.Positions[marketID] = position
|
|
return position, nil
|
|
}
|
|
|
|
// ClosePosition closes a leveraged position
|
|
func (me *MarginEngine) ClosePosition(
|
|
owner common.Address,
|
|
marketID [32]byte,
|
|
sizeToClose *big.Int,
|
|
closePrice *big.Int,
|
|
) (*big.Int, error) { // Returns realized PnL
|
|
me.mu.Lock()
|
|
defer me.mu.Unlock()
|
|
|
|
account := me.Accounts[owner]
|
|
if account == nil {
|
|
return nil, ErrAccountNotFound
|
|
}
|
|
|
|
position := account.Positions[marketID]
|
|
if position == nil {
|
|
return nil, ErrPositionNotFound
|
|
}
|
|
|
|
// Calculate PnL
|
|
pnl := me.calculatePnL(position, closePrice, sizeToClose)
|
|
|
|
// Update or remove position
|
|
if sizeToClose.Cmp(position.Size) >= 0 {
|
|
// Full close
|
|
delete(account.Positions, marketID)
|
|
} else {
|
|
// Partial close
|
|
position.Size.Sub(position.Size, sizeToClose)
|
|
position.RealizedPnL.Add(position.RealizedPnL, pnl)
|
|
|
|
// Proportionally reduce margin
|
|
closeFraction := new(big.Int).Mul(sizeToClose, big.NewInt(1e18))
|
|
closeFraction.Div(closeFraction, new(big.Int).Add(position.Size, sizeToClose))
|
|
marginReduction := new(big.Int).Mul(position.Margin, closeFraction)
|
|
marginReduction.Div(marginReduction, big.NewInt(1e18))
|
|
position.Margin.Sub(position.Margin, marginReduction)
|
|
}
|
|
|
|
return pnl, nil
|
|
}
|
|
|
|
// UpdatePositionMargin adds or removes margin from an isolated position
|
|
func (me *MarginEngine) UpdatePositionMargin(
|
|
owner common.Address,
|
|
marketID [32]byte,
|
|
marginDelta *big.Int, // Positive = add, negative = remove
|
|
) error {
|
|
me.mu.Lock()
|
|
defer me.mu.Unlock()
|
|
|
|
account := me.Accounts[owner]
|
|
if account == nil {
|
|
return ErrAccountNotFound
|
|
}
|
|
|
|
position := account.Positions[marketID]
|
|
if position == nil {
|
|
return ErrPositionNotFound
|
|
}
|
|
|
|
if !position.IsIsolated {
|
|
return ErrNotIsolatedPosition
|
|
}
|
|
|
|
newMargin := new(big.Int).Add(position.Margin, marginDelta)
|
|
if newMargin.Sign() <= 0 {
|
|
return ErrInsufficientMargin
|
|
}
|
|
|
|
// If removing margin, check if position is still safe
|
|
if marginDelta.Sign() < 0 {
|
|
// Calculate new leverage
|
|
notional := new(big.Int).Mul(position.Size, position.MarkPrice)
|
|
notional.Div(notional, Q96)
|
|
newLeverage := new(big.Int).Div(notional, newMargin)
|
|
|
|
// Compared as a 256-bit value. Narrowing first would let the check be
|
|
// beaten by exceeding it far enough: notional is a product of two
|
|
// unbounded words, so a leverage of 2^64+1 has low word 1 and reads as
|
|
// the safest position on the book.
|
|
if newLeverage.Cmp(new(big.Int).SetUint64(uint64(account.MaxLeverage))) > 0 {
|
|
return ErrExcessiveLeverage
|
|
}
|
|
}
|
|
|
|
position.Margin = newMargin
|
|
|
|
// Recalculate liquidation price
|
|
position.LiquidationPrice = me.calculateLiquidationPrice(
|
|
position.Side, position.EntryPrice, position.Leverage, account.MaintenanceMargin,
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetStopLoss sets stop loss for a position
|
|
func (me *MarginEngine) SetStopLoss(owner common.Address, marketID [32]byte, stopPrice *big.Int) error {
|
|
me.mu.Lock()
|
|
defer me.mu.Unlock()
|
|
|
|
account := me.Accounts[owner]
|
|
if account == nil {
|
|
return ErrAccountNotFound
|
|
}
|
|
|
|
position := account.Positions[marketID]
|
|
if position == nil {
|
|
return ErrPositionNotFound
|
|
}
|
|
|
|
position.StopLoss = new(big.Int).Set(stopPrice)
|
|
return nil
|
|
}
|
|
|
|
// SetTakeProfit sets take profit for a position
|
|
func (me *MarginEngine) SetTakeProfit(owner common.Address, marketID [32]byte, takeProfitPrice *big.Int) error {
|
|
me.mu.Lock()
|
|
defer me.mu.Unlock()
|
|
|
|
account := me.Accounts[owner]
|
|
if account == nil {
|
|
return ErrAccountNotFound
|
|
}
|
|
|
|
position := account.Positions[marketID]
|
|
if position == nil {
|
|
return ErrPositionNotFound
|
|
}
|
|
|
|
position.TakeProfit = new(big.Int).Set(takeProfitPrice)
|
|
return nil
|
|
}
|
|
|
|
// LiquidatePosition liquidates an underwater margin position
|
|
func (me *MarginEngine) LiquidatePosition(
|
|
liquidator common.Address,
|
|
owner common.Address,
|
|
marketID [32]byte,
|
|
currentPrice *big.Int,
|
|
) (*big.Int, error) { // Returns liquidator reward
|
|
me.mu.Lock()
|
|
defer me.mu.Unlock()
|
|
|
|
account := me.Accounts[owner]
|
|
if account == nil {
|
|
return nil, ErrAccountNotFound
|
|
}
|
|
|
|
position := account.Positions[marketID]
|
|
if position == nil {
|
|
return nil, ErrPositionNotFound
|
|
}
|
|
|
|
// Check if position is liquidatable
|
|
if !me.isPositionLiquidatable(position, currentPrice, account.MaintenanceMargin) {
|
|
return nil, ErrPositionNotLiquidatable
|
|
}
|
|
|
|
// Calculate remaining value
|
|
pnl := me.calculatePnL(position, currentPrice, position.Size)
|
|
remainingMargin := new(big.Int).Add(position.Margin, pnl)
|
|
|
|
// Calculate rewards
|
|
notional := new(big.Int).Mul(position.Size, currentPrice)
|
|
notional.Div(notional, Q96)
|
|
|
|
// Liquidator gets 0.25% of notional
|
|
liquidatorReward := new(big.Int).Mul(notional, big.NewInt(DefaultLiquidatorReward))
|
|
liquidatorReward.Div(liquidatorReward, big.NewInt(MarginPrecision))
|
|
|
|
// Insurance fund gets 0.5% of notional
|
|
insuranceFee := new(big.Int).Mul(notional, big.NewInt(DefaultLiquidationPenalty))
|
|
insuranceFee.Div(insuranceFee, big.NewInt(MarginPrecision))
|
|
|
|
// Cap rewards at remaining margin
|
|
totalFees := new(big.Int).Add(liquidatorReward, insuranceFee)
|
|
if totalFees.Cmp(remainingMargin) > 0 {
|
|
// Socialize loss
|
|
if remainingMargin.Sign() > 0 {
|
|
// Split remaining between liquidator and insurance
|
|
liquidatorReward.Div(remainingMargin, big.NewInt(2))
|
|
insuranceFee.Sub(remainingMargin, liquidatorReward)
|
|
} else {
|
|
// Use insurance fund
|
|
deficit := new(big.Int).Neg(remainingMargin)
|
|
if me.InsuranceFund.Cmp(deficit) >= 0 {
|
|
me.InsuranceFund.Sub(me.InsuranceFund, deficit)
|
|
}
|
|
liquidatorReward = big.NewInt(0)
|
|
insuranceFee = big.NewInt(0)
|
|
}
|
|
}
|
|
|
|
// Add to insurance fund
|
|
me.InsuranceFund.Add(me.InsuranceFund, insuranceFee)
|
|
|
|
// Remove position
|
|
delete(account.Positions, marketID)
|
|
|
|
return liquidatorReward, nil
|
|
}
|
|
|
|
// GetAccountHealth returns the health factor of an account (>1 = healthy)
|
|
func (me *MarginEngine) GetAccountHealth(owner common.Address) (*big.Int, error) {
|
|
me.mu.RLock()
|
|
defer me.mu.RUnlock()
|
|
|
|
account := me.Accounts[owner]
|
|
if account == nil {
|
|
return nil, ErrAccountNotFound
|
|
}
|
|
|
|
totalEquity := me.calculateTotalEquity(account)
|
|
totalMaintenanceMargin := me.calculateTotalMaintenanceMargin(account)
|
|
|
|
if totalMaintenanceMargin.Sign() == 0 {
|
|
return big.NewInt(1e18), nil // No positions, max health
|
|
}
|
|
|
|
// Health = equity / maintenanceMargin
|
|
health := new(big.Int).Mul(totalEquity, big.NewInt(1e18))
|
|
health.Div(health, totalMaintenanceMargin)
|
|
|
|
return health, nil
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func (me *MarginEngine) calculateFreeMargin(account *MarginAccount) *big.Int {
|
|
totalEquity := me.calculateTotalEquity(account)
|
|
usedMargin := me.calculateUsedMargin(account)
|
|
return new(big.Int).Sub(totalEquity, usedMargin)
|
|
}
|
|
|
|
func (me *MarginEngine) calculateTotalEquity(account *MarginAccount) *big.Int {
|
|
// Collateral value + unrealized PnL
|
|
equity := new(big.Int).Set(account.CollateralValue)
|
|
|
|
for _, pos := range account.Positions {
|
|
equity.Add(equity, pos.UnrealizedPnL)
|
|
}
|
|
|
|
return equity
|
|
}
|
|
|
|
func (me *MarginEngine) calculateUsedMargin(account *MarginAccount) *big.Int {
|
|
used := big.NewInt(0)
|
|
for _, pos := range account.Positions {
|
|
used.Add(used, pos.Margin)
|
|
}
|
|
return used
|
|
}
|
|
|
|
func (me *MarginEngine) calculateTotalMaintenanceMargin(account *MarginAccount) *big.Int {
|
|
total := big.NewInt(0)
|
|
for _, pos := range account.Positions {
|
|
notional := new(big.Int).Mul(pos.Size, pos.MarkPrice)
|
|
notional.Div(notional, Q96)
|
|
|
|
maintenance := new(big.Int).Mul(notional, big.NewInt(int64(account.MaintenanceMargin)))
|
|
maintenance.Div(maintenance, big.NewInt(MarginPrecision))
|
|
total.Add(total, maintenance)
|
|
}
|
|
return total
|
|
}
|
|
|
|
func (me *MarginEngine) calculateLiquidationPrice(
|
|
side PositionSide,
|
|
entryPrice *big.Int,
|
|
leverage uint32,
|
|
maintenanceMargin uint32,
|
|
) *big.Int {
|
|
// For long: liqPrice = entryPrice * (1 - 1/leverage + maintenanceMargin)
|
|
// For short: liqPrice = entryPrice * (1 + 1/leverage - maintenanceMargin)
|
|
|
|
leverageInverse := new(big.Int).Div(big.NewInt(MarginPrecision), big.NewInt(int64(leverage)))
|
|
maintenanceRatio := big.NewInt(int64(maintenanceMargin))
|
|
|
|
var priceMultiplier *big.Int
|
|
if side == Long {
|
|
// 1 - 1/leverage + maintenance
|
|
priceMultiplier = new(big.Int).Sub(big.NewInt(MarginPrecision), leverageInverse)
|
|
priceMultiplier.Add(priceMultiplier, maintenanceRatio)
|
|
} else {
|
|
// 1 + 1/leverage - maintenance
|
|
priceMultiplier = new(big.Int).Add(big.NewInt(MarginPrecision), leverageInverse)
|
|
priceMultiplier.Sub(priceMultiplier, maintenanceRatio)
|
|
}
|
|
|
|
liqPrice := new(big.Int).Mul(entryPrice, priceMultiplier)
|
|
liqPrice.Div(liqPrice, big.NewInt(MarginPrecision))
|
|
|
|
return liqPrice
|
|
}
|
|
|
|
func (me *MarginEngine) calculatePnL(position *MarginPosition, currentPrice *big.Int, size *big.Int) *big.Int {
|
|
priceDiff := new(big.Int).Sub(currentPrice, position.EntryPrice)
|
|
pnl := new(big.Int).Mul(size, priceDiff)
|
|
pnl.Div(pnl, Q96)
|
|
|
|
if position.Side == Short {
|
|
pnl.Neg(pnl)
|
|
}
|
|
|
|
return pnl
|
|
}
|
|
|
|
func (me *MarginEngine) isPositionLiquidatable(position *MarginPosition, currentPrice *big.Int, maintenanceMargin uint32) bool {
|
|
// Calculate PnL at current price
|
|
pnl := me.calculatePnL(position, currentPrice, position.Size)
|
|
|
|
// Current equity in position
|
|
equity := new(big.Int).Add(position.Margin, pnl)
|
|
|
|
// Required maintenance margin
|
|
notional := new(big.Int).Mul(position.Size, currentPrice)
|
|
notional.Div(notional, Q96)
|
|
|
|
required := new(big.Int).Mul(notional, big.NewInt(int64(maintenanceMargin)))
|
|
required.Div(required, big.NewInt(MarginPrecision))
|
|
|
|
return equity.Cmp(required) < 0
|
|
}
|
|
|
|
func (me *MarginEngine) isAccountSafeWithCollateral(account *MarginAccount, asset common.Address, newAmount *big.Int) bool {
|
|
// Simplified check - in production would need full recalculation
|
|
return newAmount.Sign() >= 0
|
|
}
|
|
|
|
func (me *MarginEngine) increasePosition(account *MarginAccount, existing *MarginPosition, incoming *MarginPosition) (*MarginPosition, error) {
|
|
// Calculate new average entry price
|
|
oldNotional := new(big.Int).Mul(existing.Size, existing.EntryPrice)
|
|
newNotional := new(big.Int).Mul(incoming.Size, incoming.EntryPrice)
|
|
totalNotional := new(big.Int).Add(oldNotional, newNotional)
|
|
totalSize := new(big.Int).Add(existing.Size, incoming.Size)
|
|
|
|
existing.EntryPrice.Div(totalNotional, totalSize)
|
|
existing.Size = totalSize
|
|
existing.Margin.Add(existing.Margin, incoming.Margin)
|
|
|
|
// Update leverage
|
|
notional := new(big.Int).Mul(existing.Size, existing.MarkPrice)
|
|
notional.Div(notional, Q96)
|
|
if existing.Margin.Sign() > 0 {
|
|
// Leverage is a uint32 field. A quotient past that width used to wrap into
|
|
// it, and the wrapped value then set the liquidation price — so the
|
|
// position most in need of liquidating got the price of a safe one.
|
|
lev, err := contract.Unsigned(notional.Div(notional, existing.Margin), 32)
|
|
if err != nil {
|
|
return nil, ErrExcessiveLeverage
|
|
}
|
|
existing.Leverage = uint32(lev)
|
|
}
|
|
|
|
// Recalculate liquidation price
|
|
existing.LiquidationPrice = me.calculateLiquidationPrice(
|
|
existing.Side, existing.EntryPrice, existing.Leverage, account.MaintenanceMargin,
|
|
)
|
|
|
|
return existing, nil
|
|
}
|
|
|
|
func (me *MarginEngine) reduceOrFlipPosition(account *MarginAccount, existing *MarginPosition, incoming *MarginPosition) (*MarginPosition, error) {
|
|
// Realize PnL on closed portion
|
|
if incoming.Size.Cmp(existing.Size) <= 0 {
|
|
// Partial reduce
|
|
pnl := me.calculatePnL(existing, incoming.EntryPrice, incoming.Size)
|
|
existing.RealizedPnL.Add(existing.RealizedPnL, pnl)
|
|
existing.Size.Sub(existing.Size, incoming.Size)
|
|
|
|
if existing.Size.Sign() == 0 {
|
|
delete(account.Positions, existing.MarketID)
|
|
return nil, nil
|
|
}
|
|
return existing, nil
|
|
}
|
|
|
|
// Flip position
|
|
pnl := me.calculatePnL(existing, incoming.EntryPrice, existing.Size)
|
|
remainingSize := new(big.Int).Sub(incoming.Size, existing.Size)
|
|
|
|
flipped := &MarginPosition{
|
|
MarketID: incoming.MarketID,
|
|
Side: incoming.Side,
|
|
Size: remainingSize,
|
|
EntryPrice: new(big.Int).Set(incoming.EntryPrice),
|
|
MarkPrice: new(big.Int).Set(incoming.MarkPrice),
|
|
Margin: new(big.Int).Set(incoming.Margin),
|
|
UnrealizedPnL: big.NewInt(0),
|
|
RealizedPnL: pnl,
|
|
Leverage: incoming.Leverage,
|
|
IsIsolated: incoming.IsIsolated,
|
|
}
|
|
|
|
flipped.LiquidationPrice = me.calculateLiquidationPrice(
|
|
flipped.Side, flipped.EntryPrice, flipped.Leverage, account.MaintenanceMargin,
|
|
)
|
|
|
|
account.Positions[incoming.MarketID] = flipped
|
|
return flipped, nil
|
|
}
|
|
|
|
// Additional errors for margin (others in types.go)
|
|
var (
|
|
ErrAccountExists = errors.New("account already exists")
|
|
ErrAccountNotFound = errors.New("account not found")
|
|
ErrInvalidCollateral = errors.New("invalid or inactive collateral asset")
|
|
ErrWithdrawalUnsafe = errors.New("withdrawal would make account unsafe")
|
|
ErrNotIsolatedPosition = errors.New("position is not isolated margin")
|
|
)
|