// 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 }