Files
precompile/dex/position_commit.go
T
2026-08-07 17:23:58 -07:00

348 lines
17 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 classified as committedPositions[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 SHIP RULE (LP extension, enforced structurally): a D position is funded ONLY by
// consuming a C->D commit object (SubmitPositionCommit -> dexvm executeImport); a C
// balance is credited (collect/withdraw) ONLY by consuming a D->C export object
// (ImportPositionCollect). No fill/fee VALUE a relayer hands the precompile can
// credit C — the credit is a RECORDED D->C object's amount, drawn from the LP rail's
// OWN committedPositions pot, and absent a real object in shared memory it reverts.
//
// ORTHOGONALITY: this rail composes the SAME atomic primitives (encodeAtomicObject,
// DerivePositionCommitID, stageAtomicPut/Remove, the consumed/submitted replay sets)
// and the SAME position RECORD storage (RestingOrder + the owner index in
// settle_maker.go), and it owns its OWN value pot (committedPositions, native_state.go)
// distinct from the swap rail's seamReserve, the depositor settleVault, and the
// legacy makerLockedVault. Each pot backs only its own credits (the FIX-3 discipline).
// 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
// ImportPositionCollect's per-object gate accepts a collect naming 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 / ErrLPCollectBadAmount guard the collectPosition calldata.
ErrLPBadCollectInput = errors.New("dex: collectPosition input too short")
ErrLPCollectBadAmount = errors.New("dex: collectPosition claim amount out of range")
// 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")
)
// 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 DerivePositionCommitID). 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 := key.ID()
if loadMarket(stateDB, poolID).Status != MarketStatusActive {
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 (committedPositions[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 committedPositions[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 the committedPositions pot (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[69] // THE OBJECT ITSELF: rail|owner|asset|amount|spent, 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). ImportPositionCollect binds the
// object's RAIL (must be railLP — a swap-fill object is refused), 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 position record is C-side bookkeeping for a D-side position; the VALUE comes
// through the one funding rail, like every other crossing. positionID identifies
// which record this collect settles, and is not consulted to authorize the credit
// — the claim's own beneficiary is.
_ = positionID
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
}
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