mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
SettleSwap took the caller's PoolKey and never asked whether that pool existed, so key.ID() fed the halt slot, the volume shard and the fill event alike. A fabricated key names a slot nobody set, and the swap settles while the real market is halted. Resolving once above the phase switch and passing the resolved poolID down means no phase -- including one added later -- can key on anything the registry has not admitted. checkHalt no longer accepts a key, so it cannot derive one. Co-authored-by: Hanzo Dev <[email protected]>
410 lines
20 KiB
Go
410 lines
20 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package dex
|
|
|
|
import (
|
|
"errors"
|
|
"math/big"
|
|
|
|
"github.com/luxfi/geth/common"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/precompile/contract"
|
|
)
|
|
|
|
// position_commit.go is the C SIDE of the LP D-COMMITTED LIQUIDITY rail — the
|
|
// CTO-locked model where an LP's value is COMMITTED to a funded D position over the
|
|
// native C<->D atomic seam, never held as a C-side-only lock. It REPLACES the prior
|
|
// maker model (settle_maker.go's available->locked reclassification that kept funds
|
|
// on C): an LP add now MOVES value out of the caller's CSpendable balance into the
|
|
// DCommitted state via a C->D commit object D imports to open the position; a
|
|
// decrease/burn/cancel requests a D->C withdraw whose object C consumes to credit
|
|
// back (DPendingCollect -> CSettled).
|
|
//
|
|
// TWO VALUE STATES PER UNIT, NEVER BOTH (the locked invariant):
|
|
//
|
|
// CSpendable — a unit in the LP's normal EVM balance (spendable on C).
|
|
// DCommitted — a unit backing a live D position: it has LEFT the LP's balance,
|
|
// sits in the 0x9999 vault as custody[asset],
|
|
// and a C->D commit object funds the position on D.
|
|
// (transient) DPendingCollect — a unit D has exported as a D->C object, not yet
|
|
// consumed on C; CSettled — consumed and credited back to CSpendable.
|
|
//
|
|
// THE RULE, ENFORCED STRUCTURALLY: a D position is funded ONLY by consuming a C->D
|
|
// claim; a C balance is credited (collect/withdraw) ONLY by consuming a D->C claim.
|
|
// No fee or fill amount a caller hands the precompile can credit C — the credit is a
|
|
// recorded claim's amount, drawn from custody, and a forged claim kills the block.
|
|
//
|
|
// AN LP CROSSING IS AN ORDINARY CROSSING. It uses the SAME funding rail as every
|
|
// other one — the same claim wire, the same custody pot, the same consumed set — and
|
|
// there is no LP lane. A position is a D-side object; the record here is C-side
|
|
// bookkeeping that FOLLOWS the value rather than gating it (collectPositionRecord),
|
|
// because what backs a credit is custody and what stops a second one is the claim
|
|
// being consumable once. Both are properties of the value, not of a position.
|
|
|
|
// OrderStatusClosing marks a position whose withdraw/collect was REQUESTED: a D->C
|
|
// object is expected and the recorded owner is still a legitimate collector (so
|
|
// collectPositionRecord applies the collect to THIS record) until
|
|
// the position is fully drained, at which point collectPositionRecord flips it to
|
|
// Closed. It extends the OrderStatus lifecycle (None=0, Open=1=Committed,
|
|
// Cancelled=2=Closed, Closing=3).
|
|
const OrderStatusClosing OrderStatus = 3
|
|
|
|
var (
|
|
// ErrLPNoAtomicState is returned when an LP commit/withdraw is attempted on a
|
|
// StateDB without the cross-chain atomic capability (single-chain dev). Fail-secure:
|
|
// the LP rail moves value ONLY as atomic objects, so it refuses rather than fall
|
|
// back to a C-side-only lock (the exact model being replaced).
|
|
ErrLPNoAtomicState = errors.New("dex: LP commit/withdraw requires the cross-chain atomic capability")
|
|
// ErrLPNoPosition is returned when a withdraw/collect names no position for the
|
|
// caller (nothing committed to withdraw).
|
|
ErrLPNoPosition = errors.New("dex: no committed position for caller's (pool,salt,range)")
|
|
// ErrLPBadCollectInput guards the collectPosition calldata width. There is no
|
|
// amount sentinel beside it: the amount is the uint64 inside the claim object,
|
|
// so a value the pot cannot hold is unrepresentable rather than rejected.
|
|
ErrLPBadCollectInput = errors.New("dex: collectPosition input malformed")
|
|
// ErrLPCommitWhileClosing forbids topping up a position whose withdraw is in flight
|
|
// (Closing). The LP must let the D->C collect settle, or commit to a fresh
|
|
// salt/range — re-arming a Closing record back to Open is the lifecycle hole FIX-3
|
|
// closes.
|
|
ErrLPCommitWhileClosing = errors.New("dex: cannot commit to a position with a withdraw in flight (closing); use a fresh range or collect first")
|
|
// ErrLPForeignPosition is returned when a collect names a live position record
|
|
// belonging to somebody else. The claim proves whose VALUE it is; it says nothing
|
|
// about whose RECORD this is, and those are different questions.
|
|
ErrLPForeignPosition = errors.New("dex: collectPosition names a position record owned by another account")
|
|
)
|
|
|
|
// orderCommitObjSuffix stores the C->D commit OBJECT id of a position record, so an
|
|
// auditor/keeper can correlate the C-side record (keyed by MakerOrderID) with the
|
|
// shared-memory commit object D imported (keyed by DeriveClaimID). Two ids,
|
|
// two purposes: the record id is re-derivable from (owner,pool,salt,range) for
|
|
// lifecycle ops; the object id is injective over the tx identity for replay safety.
|
|
var orderCommitObjSuffix = []byte("c") // commit object id (bytes32)
|
|
|
|
// runSettleModifyLiquidity is the 0x9999 LP-COMMIT kernel — the CTO-locked
|
|
// committed-liquidity path. ADD (delta>0) COMMITS the LP's funds to a D position via
|
|
// a C->D object; REMOVE (delta<0) REQUESTS a D->C withdraw (the funds return when the
|
|
// LP consumes the D->C object via collectPosition). PURE atomic-seam routing, no
|
|
// matching (matching/fee-accrual lives on D). 0x9996 PositionManager routes its
|
|
// lifecycle ops here. Replaces the prior C-side-only available->locked model.
|
|
func (s *SettleContract) runSettleModifyLiquidity(
|
|
state contract.AccessibleState, caller common.Address, input []byte, gas uint64, readOnly bool,
|
|
) ([]byte, uint64, error) {
|
|
if readOnly {
|
|
return nil, gas, errors.New("dex: cannot modifyLiquidity in read-only mode")
|
|
}
|
|
if gas < GasAddLiquidity {
|
|
return nil, 0, errors.New("dex: out of gas")
|
|
}
|
|
gasLeft := gas - GasAddLiquidity
|
|
|
|
key, params, hookData, err := DecodeModifyLiquidityInput(input)
|
|
if err != nil {
|
|
return nil, gasLeft, err
|
|
}
|
|
if params.LiquidityDelta == nil || params.LiquidityDelta.Sign() == 0 {
|
|
return nil, gasLeft, ErrMakerZeroDelta
|
|
}
|
|
if !isWord(new(big.Int).Abs(params.LiquidityDelta)) {
|
|
return nil, gasLeft, ErrMakerAmountRange
|
|
}
|
|
if params.TickLower >= params.TickUpper || params.TickLower < MinTick || params.TickUpper > MaxTick {
|
|
return nil, gasLeft, ErrMakerBadTickRange
|
|
}
|
|
|
|
// The LP rail moves value ONLY as atomic objects — REQUIRE the cross-chain atomic
|
|
// capability (fail-secure; never a C-side-only lock). A single-chain dev StateDB
|
|
// reverts cleanly here, exactly as SettleSwap does.
|
|
atomicState, ok := state.(contract.AtomicState)
|
|
if !ok || atomicState.AtomicMemory() == nil {
|
|
return nil, gasLeft, ErrLPNoAtomicState
|
|
}
|
|
|
|
stateDB := newPoolStateAdapter(state)
|
|
|
|
_, poolID, known := marketFor(stateDB, key)
|
|
if !known {
|
|
return nil, gasLeft, ErrMakerNoMarket
|
|
}
|
|
|
|
side := decodeMakerSide(hookData)
|
|
lockedAsset := lockedAssetForSide(key, side)
|
|
|
|
// GLOBAL non-reentrant guard (the SAME single 0x9999 custody slot deposit/withdraw/
|
|
// swap take). Set BEFORE any value movement so a malicious ERC-20's transferFrom
|
|
// callback cannot re-enter any custody op mid-commit.
|
|
if !enterCustodyKV(stateDB) {
|
|
return nil, gasLeft, ErrCustodyReentrant
|
|
}
|
|
defer exitCustodyKV(stateDB)
|
|
|
|
orderID := MakerOrderID(caller, poolID, params.Salt, params.TickLower, params.TickUpper)
|
|
|
|
if params.LiquidityDelta.Sign() > 0 {
|
|
return s.commitPosition(state, atomicState, stateDB, caller, key, params, side, lockedAsset, orderID, gasLeft)
|
|
}
|
|
return s.requestPositionWithdraw(stateDB, caller, params, orderID, gasLeft)
|
|
}
|
|
|
|
// commitPosition handles the ADD leg: it COMMITS delta of the LP's CSpendable balance
|
|
// to a funded D position via a C->D commit object, records/tops-up the position, and
|
|
// returns (orderID, lockedAmt). The value LEAVES the caller's balance and becomes
|
|
// DCommitted (custody[lockedAsset]); D is funded ONLY by importing the
|
|
// staged commit object.
|
|
func (s *SettleContract) commitPosition(
|
|
state contract.AccessibleState, atomicState contract.AtomicState, stateDB *poolStateAdapter,
|
|
caller common.Address, key PoolKey, params ModifyLiquidityParams, side MakerSide, lockedAsset [32]byte,
|
|
orderID [32]byte, gasLeft uint64,
|
|
) ([]byte, uint64, error) {
|
|
// A halted asset cannot commit new positions (cancel/withdraw is always allowed —
|
|
// funds must stay releasable).
|
|
if isHalted(stateDB, makeStorageKey(haltAssetPrefix, lockedAsset[:])) {
|
|
return nil, gasLeft, ErrAssetHalted
|
|
}
|
|
delta := params.LiquidityDelta // validated >0 and isWord.
|
|
if !delta.IsUint64() || delta.Sign() <= 0 {
|
|
return nil, gasLeft, ErrMakerAmountRange
|
|
}
|
|
|
|
// FUND D: Export debits the caller's C balance into custody and stages a C->D
|
|
// claim naming them as beneficiary. Returns the claim id (the shared-memory key D
|
|
// imports). The POSITION is opened on D against the funded account; the crossing
|
|
// itself opens nothing.
|
|
commitObjID, committed, cerr := nativeClient.Export(state, atomicState, Transfer{
|
|
Owner: caller,
|
|
Beneficiary: caller,
|
|
Asset: lockedAsset,
|
|
AssetAddr: assetAddress(lockedAsset),
|
|
Amount: delta.Uint64(),
|
|
})
|
|
if cerr != nil {
|
|
return nil, gasLeft, cerr
|
|
}
|
|
// `committed` is the value ACTUALLY committed (observed delta for ERC-20), the
|
|
// authoritative amount the pot AND the C->D object carry — so the record below
|
|
// never overstates the LP's committed principal (no fee-on-transfer drift).
|
|
|
|
// Track the per-owner committed reserve (the LP's own committed total of this
|
|
// asset) — the same fine-grained accumulator the prior model used for locked
|
|
// reserve, now meaning "committed to D positions". Defense-in-depth bookkeeping
|
|
// alongside the authoritative custody[asset] pot.
|
|
storeLockedReserve(stateDB, caller, lockedAsset,
|
|
new(big.Int).Add(loadLockedReserve(stateDB, caller, lockedAsset), new(big.Int).SetUint64(committed)))
|
|
|
|
// Record / top-up the position. A re-commit to the same (owner,pool,salt,range)
|
|
// accumulates the committed amount (the increase-position semantic) ONLY while the
|
|
// position is Open. A CLOSING position is mid-withdraw (a D->C collect object is
|
|
// expected); re-committing to it would flip it back to Open and let new principal
|
|
// share an in-flight withdraw's record — so re-commit while Closing is FORBIDDEN
|
|
// (FIX-3): the LP must let the withdraw settle, or use a fresh salt/range. A fully
|
|
// collected (Closed) or never-used (None) slot starts a brand-new position.
|
|
existing := loadRestingOrder(stateDB, orderID)
|
|
if existing.Status == OrderStatusClosing {
|
|
return nil, gasLeft, ErrLPCommitWhileClosing
|
|
}
|
|
newAmt := new(big.Int).SetUint64(committed)
|
|
if existing.Status == OrderStatusOpen {
|
|
newAmt = new(big.Int).Add(existing.LockedAmt, newAmt)
|
|
}
|
|
storeRestingOrder(stateDB, orderID, RestingOrder{
|
|
Owner: caller,
|
|
PoolID: key.ID(),
|
|
Side: side,
|
|
LockedAsset: lockedAsset,
|
|
LockedAmt: newAmt,
|
|
TickLower: params.TickLower,
|
|
TickUpper: params.TickUpper,
|
|
Salt: params.Salt,
|
|
Status: OrderStatusOpen,
|
|
})
|
|
// Store the commit object id on the record for keeper/audit correlation.
|
|
stateDB.SetState(poolManagerAddr9999, orderSlot(orderID, orderCommitObjSuffix), common.BytesToHash(commitObjID[:]))
|
|
appendOwnerOrder(stateDB, caller, orderID)
|
|
return encodeOrderResult(orderID, newAmt), gasLeft, nil
|
|
}
|
|
|
|
// requestPositionWithdraw handles the REMOVE/cancel/burn leg: it marks the position
|
|
// CLOSING and emits a D->C withdraw request (a routing event the keeper forwards to D
|
|
// to export the withdrawable principal + fees). It moves NO C value here — the value
|
|
// is on D; it returns when the LP consumes the resulting D->C object via
|
|
// collectPosition. Idempotent ownership-checked, exactly like the prior cancel.
|
|
func (s *SettleContract) requestPositionWithdraw(
|
|
stateDB *poolStateAdapter, caller common.Address, params ModifyLiquidityParams, orderID [32]byte, gasLeft uint64,
|
|
) ([]byte, uint64, error) {
|
|
order := loadRestingOrder(stateDB, orderID)
|
|
if order.Status == OrderStatusNone {
|
|
return nil, gasLeft, ErrMakerNotOwner // no such position for this caller's (pool,salt,range)
|
|
}
|
|
if order.Owner != caller {
|
|
return nil, gasLeft, ErrMakerNotOwner
|
|
}
|
|
if order.Status == OrderStatusCancelled {
|
|
return nil, gasLeft, ErrMakerAlreadyClosed
|
|
}
|
|
|
|
// Mark the position CLOSING — the recorded owner remains a legitimate collector
|
|
// (the rail gate accepts it) until the D->C object lands and is collected. Emit
|
|
// the withdraw request for the keeper; the funds return via collectPosition.
|
|
order.Status = OrderStatusClosing
|
|
storeRestingOrder(stateDB, orderID, order)
|
|
emitPositionClosing(stateDB, ids.ID(orderID), order.PoolID, caller)
|
|
return encodeOrderResult(orderID, order.LockedAmt), gasLeft, nil
|
|
}
|
|
|
|
// runSettleCollectPosition is the 0x9999 LP D->C COLLECT/WITHDRAW handler — the
|
|
// Phase-B analog for the LP rail. It consumes a D->C atomic object EXACTLY ONCE and
|
|
// credits the caller out of custody (DPendingCollect -> CSettled).
|
|
// This is the SOLE C-credit path for an LP: collect, decrease, burn, and cancel all
|
|
// return funds through here, by consuming a D->C object D exported.
|
|
//
|
|
// CALLDATA (deterministic, fixed width, bounds-checked):
|
|
//
|
|
// outputID[32] // the D->C atomic object's shared-memory key (D's UTXO id)
|
|
// asset[32] // the claimed asset (address left-padded; native all-zero) —
|
|
// // EQUALITY-checked against the object's asset
|
|
// positionID[32] // the position RECORD id (MakerOrderID) the collect draws against
|
|
// // — the object's owner must hold THIS position (Open/Closing) and
|
|
// // the credit is bounded by its remaining committed backing
|
|
// object[60] // THE OBJECT ITSELF: beneficiary|asset|amount, as D exported
|
|
// // it. Carried by the tx so execution never reads shared memory and
|
|
// // the block replays identically; Verify proves it (settle_import.go).
|
|
//
|
|
// The recipient is the CALLER (day-1, no delegation). Import binds the
|
|
// claim's recorded value (beneficiary, asset, amount), its
|
|
// owner/asset/amount, AND the named position record, so a claim cannot invent value,
|
|
// consume a victim's object, re-denominate it, consume a swap-rail object on the LP
|
|
// pot, or draw beyond the object owner's OWN committed backing.
|
|
func (s *SettleContract) runSettleCollectPosition(
|
|
state contract.AccessibleState, caller common.Address, input []byte, gas uint64, readOnly bool,
|
|
) ([]byte, uint64, error) {
|
|
if readOnly {
|
|
return nil, gas, errors.New("dex: cannot collectPosition in read-only mode")
|
|
}
|
|
if gas < GasNativeSettlement {
|
|
return nil, 0, errors.New("dex: out of gas")
|
|
}
|
|
gasLeft := gas - GasNativeSettlement
|
|
|
|
atomicState, ok := state.(contract.AtomicState)
|
|
if !ok || atomicState.AtomicMemory() == nil {
|
|
return nil, gasLeft, ErrLPNoAtomicState
|
|
}
|
|
// FIXED width: claimID(32) | asset(32) | positionID(32) | claim(60). The object
|
|
// rides in the CALLDATA for the same reason the swap rail's does — execution must
|
|
// never read shared memory, or a node can never re-execute this block. There is no
|
|
// separate `amount` word: the amount IS the object's amount.
|
|
if len(input) != collectPositionInputLen {
|
|
return nil, gasLeft, ErrLPBadCollectInput
|
|
}
|
|
var claimID ids.ID
|
|
copy(claimID[:], input[0:32])
|
|
var asset [32]byte
|
|
copy(asset[:], input[32:64])
|
|
var positionID [32]byte
|
|
copy(positionID[:], input[64:96])
|
|
object := append([]byte(nil), input[96:collectPositionInputLen]...)
|
|
|
|
stateDB := newPoolStateAdapter(state)
|
|
|
|
// GLOBAL non-reentrant guard (shared 0x9999 custody slot) — the credit's ERC-20
|
|
// transfer can hand control to a malicious token; single-in-flight the whole
|
|
// custody surface so two interleaved collects can't clobber the pot read-modify-write.
|
|
if !enterCustodyKV(stateDB) {
|
|
return nil, gasLeft, ErrCustodyReentrant
|
|
}
|
|
defer exitCustodyKV(stateDB)
|
|
|
|
// The VALUE comes through the one funding rail, like every other crossing: the
|
|
// claim names its own beneficiary and custody backs it. Nothing about a position
|
|
// authorizes the credit.
|
|
credited, ierr := nativeClient.Import(state, atomicState, Claim{
|
|
ID: claimID,
|
|
Asset: asset,
|
|
Beneficiary: caller, // day-1: no delegation; the beneficiary is the caller.
|
|
Object: object,
|
|
})
|
|
if ierr != nil {
|
|
return nil, gasLeft, ierr
|
|
}
|
|
// The position record is C-side bookkeeping for a position that lives on D. It
|
|
// follows the value rather than gating it: the collected amount leaves the record's
|
|
// withdrawable and the owner's committed total, and the record closes once it is
|
|
// fully collected. It follows the CALLER'S value into the CALLER'S record — the
|
|
// claim proved whose value this is and says nothing about whose record that is.
|
|
if rerr := collectPositionRecord(stateDB, positionID, caller, credited); rerr != nil {
|
|
return nil, gasLeft, rerr
|
|
}
|
|
out := make([]byte, 32)
|
|
new(big.Int).SetUint64(credited).FillBytes(out)
|
|
return out, gasLeft, nil
|
|
}
|
|
|
|
// EncodeCollectPositionInput builds collectPosition calldata (claimID, asset,
|
|
// amount, positionID) for the keeper's collect-tx builder and tests. The inverse of
|
|
// the decode in runSettleCollectPosition.
|
|
func EncodeCollectPositionInput(claimID ids.ID, asset [32]byte, positionID [32]byte, object []byte) []byte {
|
|
out := make([]byte, collectPositionInputLen)
|
|
copy(out[0:32], claimID[:])
|
|
copy(out[32:64], asset[:])
|
|
copy(out[64:96], positionID[:])
|
|
copy(out[96:collectPositionInputLen], object)
|
|
return out
|
|
}
|
|
|
|
// collectPositionInputLen is the fixed collectPosition calldata width:
|
|
// claimID(32) | asset(32) | positionID(32) | claim(60).
|
|
const collectPositionInputLen = 32 + 32 + 32 + claimSize
|
|
|
|
// collectPositionRecord applies a collect to the named position record and the
|
|
// owner's committed total: it subtracts the collected amount from the record's
|
|
// LockedAmt and from loadLockedReserve(owner, asset), and marks the record Closed
|
|
// once its LockedAmt reaches 0.
|
|
//
|
|
// This is BOOKKEEPING, not a gate. It used to bound the credit — a collect could not
|
|
// exceed the record's LockedAmt — and that bound was expressed in position terms on
|
|
// what is now a funding rail. What backs the credit is custody, and what stops a
|
|
// double credit is the claim being consumable exactly once; both are properties of
|
|
// the value, not of a position. So the record follows the value: it never refuses a
|
|
// credit, and it never has to be consulted to authorize one.
|
|
//
|
|
// A record already at zero (or absent) simply stays there — the claim was for value
|
|
// this record does not track, which the rail is entitled to move regardless.
|
|
//
|
|
// IT IS NOT A GATE, WHICH IS EXACTLY WHY IT NEEDS AN OWNER. The record is bookkeeping,
|
|
// so it never refuses a credit — and the positionID that selects it rode in as calldata,
|
|
// checked against nothing. A stranger with a legitimate one-wei claim of their own could
|
|
// name any LP's live record and this would faithfully apply their collect to it, driving
|
|
// it to a terminal status. The LP's value is on D and the record that brings it back is
|
|
// closed, so the only way out is committing new money. The claim authenticates whose
|
|
// VALUE moved; whose RECORD it lands in is a separate fact, and taking it from the
|
|
// calldata is taking it from the attacker. So the owner is a parameter: there is no way
|
|
// to call this without saying whose record it is.
|
|
func collectPositionRecord(stateDB stateKV, positionID [32]byte, owner common.Address, amount uint64) error {
|
|
order := loadRestingOrder(stateDB, positionID)
|
|
if order.Status != OrderStatusOpen && order.Status != OrderStatusClosing {
|
|
return nil
|
|
}
|
|
if order.Owner != owner {
|
|
return ErrLPForeignPosition
|
|
}
|
|
amt := new(big.Int).SetUint64(amount)
|
|
if order.LockedAmt == nil {
|
|
order.LockedAmt = new(big.Int)
|
|
}
|
|
if order.LockedAmt.Cmp(amt) < 0 {
|
|
amt = new(big.Int).Set(order.LockedAmt)
|
|
}
|
|
order.LockedAmt = new(big.Int).Sub(order.LockedAmt, amt)
|
|
if order.LockedAmt.Sign() == 0 {
|
|
order.Status = OrderStatusCancelled // == Closed (terminal): fully collected.
|
|
}
|
|
storeRestingOrder(stateDB, positionID, order)
|
|
|
|
reserve := loadLockedReserve(stateDB, order.Owner, order.LockedAsset)
|
|
if reserve.Cmp(amt) < 0 {
|
|
reserve = new(big.Int).Set(amt)
|
|
}
|
|
storeLockedReserve(stateDB, order.Owner, order.LockedAsset, new(big.Int).Sub(reserve, amt))
|
|
return nil
|
|
}
|