dex: LP D-committed liquidity on the native C<->D atomic seam (0x9999 modifyLiquidity commit + 0x9996/0x9999 collect)

Replace the prior C-side-only maker lock (makerLockedVault available->locked
reclassification that kept funds on C) with the CTO-locked committed-liquidity
model: an LP add COMMITS funds to a funded D position via a C->D atomic object
(DL01); collect/decrease/burn/cancel return funds via a consumed D->C object.

Two value states per asset, never both: CSpendable (EVM balance) <-> DCommitted
(committedPositions pot backing a live D position). Plus DPendingCollect -> CSettled.

- native_wire.go: DerivePositionCommitID (own positionCommitDomain, disjoint id space)
- native_state.go: committedPositions pot (4th orthogonal vault claim)
- native_dchain_client.go: SubmitPositionCommit (C->D DL01) + ImportPositionCollect
  (D->C, credits from committedPositions, rail-gated by ownerHasOpenPosition)
- position_commit.go: the LP-rail kernel (runSettleModifyLiquidity commit/withdraw,
  runSettleCollectPosition, position lifecycle Open/Closing/Closed)
- settle_module.go: collectPosition selector wired into 0x9999
- position_module.go: 0x9996 collect emits a D->C collect request (no longer Unsupported)
- settle_stm.go: Block-STM access set updated to committedPositions + staging seq

Ship rule: D position funded ONLY by consuming a C->D object; C credited (collect)
ONLY by consuming a D->C object; never CSpendable && DCommitted. All prior red-found
gates kept (full AccountID/AssetID, owner/asset/amount bind, one-time import,
callIndex, observed-delta ERC-20, nonReentrant, conservation invariant extended to
realHolding == settleVault + makerLockedVault + seamReserve + committedPositions).

10 LP tests + 3 rewritten surface tests; dex 325 pass.
This commit is contained in:
2026-06-21 02:15:41 -07:00
parent 64bcb1478d
commit 7e07850660
13 changed files with 1447 additions and 269 deletions
+9 -3
View File
@@ -31,8 +31,13 @@ import (
// These tests exercise BOTH subsystems against the SAME asset and assert the pots
// never raid each other and the invariant holds end-to-end.
// vaultInvariantNative asserts realHolding == settleVault + makerLockedVault +
// seamReserve for the native asset, the FIX-3 vault-account invariant.
// vaultInvariantNative asserts the FULL vault-account invariant for the native asset:
//
// realHolding == settleVault + makerLockedVault + seamReserve + committedPositions
//
// the four orthogonal pots that partition the 0x9999 vault by CLAIM (depositor pot,
// legacy maker pot, swap-rail seam reserve, LP-rail committed positions). Each pot
// backs only its own credits; none can raid another.
func (h *settleHarness) vaultInvariantNative(t testing.TB, where string) {
t.Helper()
db := newPoolStateAdapter(h.state)
@@ -40,8 +45,9 @@ func (h *settleHarness) vaultInvariantNative(t testing.TB, where string) {
real := h.state.stateDB.GetBalance(poolManagerAddr9999).ToBig()
sum := new(big.Int).Add(loadSettleVault(db, native), loadMakerLockedVault(db, native))
sum.Add(sum, loadSeamReserve(db, native))
sum.Add(sum, loadCommittedPositions(db, native))
if real.Cmp(sum) != 0 {
t.Fatalf("%s: vault-account invariant violated: realHolding=%s != settleVault+makerLocked+seamReserve=%s", where, real, sum)
t.Fatalf("%s: vault-account invariant violated: realHolding=%s != settleVault+makerLocked+seamReserve+committedPositions=%s", where, real, sum)
}
}
+240 -9
View File
@@ -77,6 +77,11 @@ var (
ErrNativeSettleOwner = errors.New("dex: D->C settlement object owner does not match the recipient")
ErrNativeSettleAmount = errors.New("dex: D->C settlement object amount does not match the claim")
ErrNativeSettleUnbacked = errors.New("dex: vault cannot back the D->C credited amount (no mint)")
// LP-rail errors (C->D position commit / D->C collect-withdraw).
ErrLPCommitReplay = errors.New("dex: C->D position-commit id already submitted (replay)")
ErrLPCollectUnbacked = errors.New("dex: committed-positions reserve cannot back the D->C collect credit (no mint, no raid)")
ErrLPCollectNoPosition = errors.New("dex: D->C collect names no open/closing position for the recorded owner (cross-rail object refused)")
)
// --- Intent submission (C->D): FUND D, return an intent id, NEVER a fill. ------
@@ -167,20 +172,169 @@ func (c *NativeDChainClient) SubmitSwapIntent(
return intentID, nil
}
// SubmitModifyLiquidity locks an LP's funds on C and writes a C->D atomic object so
// D opens a FUNDED position. Same lock-then-export discipline as a swap intent;
// returns the positionID (== the object's UTXO key). The position rests/opens
// under D consensus; a later collect/decrease settles back via ImportSettlement.
// SubmitPositionCommit is the C->D LP-COMMIT leg (intent kind DL01): it moves an
// LP's funds OUT of CSpendable and INTO the DCommitted state, and writes a C->D
// position-commit atomic object D imports to open a FUNDED position. It is the LP
// rail's analog of SubmitSwapIntent, on its OWN orthogonal pot (committedPositions)
// and its OWN id space (DerivePositionCommitID, positionCommitDomain) — so an LP
// commit can never be confused with a taker intent.
//
// THE NEVER-BOTH MOVE (CEI + conservation): the LP's C value is debited FIRST — real
// SubBalance / observed-delta transferFrom from the caller's CSpendable balance into
// the 0x9999 vault, recorded as committedPositions[asset] += locked — so the unit
// LEAVES the caller's spendable balance and becomes committed backing in one atomic
// step (never both spendable and committed). Then the C->D commit object (owner,
// assetIn, locked) is STAGED keyed by the position-commit id; at block accept the
// host flushes it to shared memory and D's executeImport consumes it to open the
// funded position. D is funded ONLY by consuming this object.
func (c *NativeDChainClient) SubmitPositionCommit(
state contract.AccessibleState,
atomicState contract.AtomicState,
req IntentRequest,
) (positionID ids.ID, locked uint64, err error) {
sm := atomicState.AtomicMemory()
if sm == nil {
return ids.Empty, 0, ErrNativeNoAtomicMemory
}
if req.AmountIn == 0 {
return ids.Empty, 0, ErrNativeBadAmount
}
stateDB := newPoolStateAdapter(state)
// Lock the LP principal into the COMMITTED-POSITIONS pot (the LP rail's own pot),
// NOT the swap-rail seam reserve and NOT a depositor claim. This is the C-side
// debit that BACKS the C->D commit object: D imports value C has already removed
// from the caller's spendable balance, so no mint can occur on either side. locked
// is the value ACTUALLY committed (observed delta for ERC-20) — the authoritative
// amount the pot, the object, and the record all use, so none can overstate.
locked, lockErr := commitPositionInput(stateDB, req.Account, req.AssetIn, req.AssetInAddr, req.AmountIn)
if lockErr != nil {
return ids.Empty, 0, lockErr
}
// Derive the injective position-commit id (the shared-memory key) over the FULL
// identity, in the position-commit domain (disjoint from swap-intent ids).
cChainID := atomicState.CChainID()
dChainID := atomicState.ChainID()
if dID := loadDChainTarget(stateDB); dID != ids.Empty {
dChainID = dID
}
positionID = DerivePositionCommitID(
atomicState.NetworkID(), cChainID, dChainID,
atomicState.TxID(), atomicState.CallIndex(),
req.Account, req.AssetIn, locked, req.MarketID,
)
// Replay guard: the same commit id must not be submitted twice (a re-executed /
// reorged C tx). Durable, consensus-shared (StateDB), CEI before the export.
if isIntentSubmitted(stateDB, positionID) {
return ids.Empty, 0, ErrLPCommitReplay
}
markIntentSubmitted(stateDB, positionID, state.GetBlockContext().Number().Uint64())
// STAGE the C->D commit object (owner=account, asset=assetIn, amount=locked) keyed
// by positionID under the D chain's partition (revert-aware — discarded with the
// rolled-back lock if the tx reverts; flushed to shared memory at block accept).
obj := encodeAtomicObject(req.Account, req.AssetIn, locked)
stageAtomicPut(stateDB, dChainID, positionID, obj)
// Emit the DL01 routing event (kind=position) for the keeper to OPEN the D
// position against the committed collateral. The staged object backs the value.
emitNativePositionCommitEvent(stateDB, positionID, dChainID, req, locked)
return positionID, locked, nil
}
// SubmitModifyLiquidity is the on-ramp interface alias for an LP add: it routes to
// SubmitPositionCommit (the DL01 commit), returning the positionID. Kept so call
// sites that speak the modify-liquidity vocabulary reach the position rail.
func (c *NativeDChainClient) SubmitModifyLiquidity(
state contract.AccessibleState,
atomicState contract.AtomicState,
req IntentRequest,
) (positionID ids.ID, err error) {
// Funding a position is the SAME atomic primitive as funding a swap intent: lock
// C value, export a C->D object D imports to open the funded position. The only
// difference is the keeper's interpretation of the routing event (open position
// vs place taker order), which is carried in the event kind.
return c.SubmitSwapIntent(state, atomicState, req)
positionID, _, err = c.SubmitPositionCommit(state, atomicState, req)
return positionID, err
}
// ImportPositionCollect is the D->C LP-COLLECT/WITHDRAW leg: it consumes a D->C
// atomic object EXACTLY ONCE and credits C out of the COMMITTED-POSITIONS pot
// (DPendingCollect -> CSettled). It is the LP rail's analog of ImportSettlement, on
// the orthogonal committedPositions pot, with one ADDITIONAL gate: the recorded
// owner MUST have a position on C in Committed/Closing status (positionExists), so a
// swap-rail D->C object — which names no LP position — cannot be consumed here to
// drain the LP pot. The discipline otherwise mirrors ImportSettlement byte-for-byte:
//
// 1. read the RECORDED object (Get under the D source chain); missing => revert.
// 2. BIND asset == claimed, owner == recipient, amount == claimed (recorded value
// authoritative — a claim cannot consume a victim's object or re-denominate it).
// 3. RAIL gate: the recorded owner must have an open/closing C position.
// 4. REPLAY guard: reject an already-consumed object id (shared D->C consumed set —
// one object, one credit, globally, across the swap and LP rails).
// 5. MARK consumed (CEI) BEFORE moving value.
// 6. CREDIT C from committedPositions (NO MINT, NO RAID — the LP pot must back it).
// 7. STAGE the atomic Remove of the consumed object (flushed at block accept).
func (c *NativeDChainClient) ImportPositionCollect(
state contract.AccessibleState,
atomicState contract.AtomicState,
claim SettlementClaim,
) (credited uint64, err error) {
sm := atomicState.AtomicMemory()
if sm == nil {
return 0, ErrNativeNoAtomicMemory
}
stateDB := newPoolStateAdapter(state)
dChainID := loadDChainTarget(stateDB)
if dChainID == ids.Empty {
return 0, ErrNativeNoAtomicMemory
}
// (1) Read the RECORDED object. A missing object => never credit.
key := claim.OutputID
vals, gerr := sm.Get(dChainID, [][]byte{key[:]})
if gerr != nil || len(vals) != 1 || len(vals[0]) == 0 {
return 0, ErrNativeNoSettlement
}
recOwner, recAsset, recAmount, ok := decodeAtomicObject(vals[0])
if !ok {
return 0, ErrNativeSettleMalformed
}
// (2) BIND the credit to the RECORDED value (authoritative, not declared).
if recAsset != claim.Asset {
return 0, ErrNativeSettleAsset
}
if recOwner != claim.Recipient {
return 0, ErrNativeSettleOwner
}
if recAmount != claim.Amount || recAmount == 0 {
return 0, ErrNativeSettleAmount
}
// (3) RAIL gate: the recorded owner must hold an open/closing position on C. This
// closes the cross-rail consume (an LP cannot consume a swap-settlement object to
// drain the LP pot — a swap object names no position record).
if !ownerHasOpenPosition(stateDB, recOwner) {
return 0, ErrLPCollectNoPosition
}
// (4) REPLAY guard: one-time consumption across ALL D->C objects (shared set).
if isSettlementConsumed(stateDB, claim.OutputID) {
return 0, ErrNativeSettleReplay
}
// (5) MARK consumed BEFORE value movement (CEI; a failed credit rolls this back).
markSettlementConsumed(stateDB, claim.OutputID, state.GetBlockContext().Number().Uint64())
// (6) CREDIT C from the COMMITTED-POSITIONS pot — NO MINT, NO RAID.
if cerr := creditPositionCollect(stateDB, recOwner, recAsset, claim.AssetAddr, recAmount); cerr != nil {
return 0, cerr
}
// (7) STAGE the atomic Remove of the consumed object (revert-aware; flushed at
// block accept atomically with the committed credit).
stageAtomicRemove(stateDB, dChainID, key)
return recAmount, nil
}
// SubmitCancel submits a D cancel for a resting order/position. A cancel moves no
@@ -443,3 +597,80 @@ func creditSettlementOutput(stateDB StateDB, recipient common.Address, assetID [
}
return safeTransferTokenTo(vault, assetAddr, recipient, amt)
}
// --- LP-rail value movement (commit into committedPositions / collect out of it). --
// commitPositionInput debits amount of the LP's input asset from the caller's
// CSpendable balance into the 0x9999 vault, recording it in the COMMITTED-POSITIONS
// pot (the LP rail's own pot), and returns the value ACTUALLY committed (observed
// delta for ERC-20). Native moves via SubBalance(caller)/AddBalance(0x9999) with a
// balance pre-check; ERC-20 via the vault transferFrom observed delta. The committed
// value backs the C->D commit object's amount AND represents the DCommitted state
// (the unit left the caller's spendable balance). Byte-for-byte the same discipline
// as lockIntentInput, but on committedPositions instead of seamReserve — the
// orthogonal-pot rule (an LP commit can never appear in the swap rail's reserve).
func commitPositionInput(stateDB StateDB, caller common.Address, assetID [32]byte, assetAddr common.Address, amount uint64) (uint64, error) {
amt := new(big.Int).SetUint64(amount)
if isNativeAsset(assetID) {
u, of := uint256.FromBig(amt)
if of {
return 0, ErrNativeBadAmount
}
if stateDB.GetBalance(caller).Cmp(u) < 0 {
return 0, ErrNativeFundsShort
}
before := loadCommittedPositions(stateDB, assetID)
stateDB.SubBalance(caller, u)
stateDB.AddBalance(poolManagerAddr9999, u)
storeCommittedPositions(stateDB, assetID, new(big.Int).Add(before, amt))
return amount, nil
}
vault, ok := stateDBERC20(stateDB)
if !ok {
return 0, ErrNativeERC20Vault
}
delta, err := safeTransferTokenFrom(vault, assetAddr, caller, poolManagerAddr9999, amt)
if err != nil {
return 0, err
}
before := loadCommittedPositions(stateDB, assetID)
storeCommittedPositions(stateDB, assetID, new(big.Int).Add(before, delta))
if !delta.IsUint64() {
return 0, ErrNativeBadAmount
}
return delta.Uint64(), nil
}
// creditPositionCollect releases amount of the collected asset from the COMMITTED-
// POSITIONS pot to the LP — NO MINT (the LP pot must already hold it), NO RAID (the
// credit is conservation-checked against committedPositions[a], never the swap-rail
// seamReserve, the depositor settleVault, or the legacy makerLockedVault). This is
// the CSettled credit (the collected unit re-enters the LP's CSpendable balance).
// Native via SubBalance(0x9999)/AddBalance(recipient); ERC-20 via the vault transfer.
func creditPositionCollect(stateDB StateDB, recipient common.Address, assetID [32]byte, assetAddr common.Address, amount uint64) error {
amt := new(big.Int).SetUint64(amount)
held := loadCommittedPositions(stateDB, assetID)
if held.Cmp(amt) < 0 {
// NO MINT, NO RAID: the LP pot's own reserve must back the collect. A short
// committed-positions pot reverts even if other pots hold this asset.
return ErrLPCollectUnbacked
}
storeCommittedPositions(stateDB, assetID, new(big.Int).Sub(held, amt))
if isNativeAsset(assetID) {
u, of := uint256.FromBig(amt)
if of {
return ErrNativeBadAmount
}
if stateDB.GetBalance(poolManagerAddr9999).Cmp(u) < 0 {
return ErrLPCollectUnbacked
}
stateDB.SubBalance(poolManagerAddr9999, u)
stateDB.AddBalance(recipient, u)
return nil
}
vault, ok := stateDBERC20(stateDB)
if !ok {
return ErrNativeERC20Vault
}
return safeTransferTokenTo(vault, assetAddr, recipient, amt)
}
+26 -5
View File
@@ -43,10 +43,31 @@ const (
intentKindPosition nativeIntentKind = 1
)
// emitNativeIntentEvent logs a C->D intent's routing metadata for the keeper. The
// intentID is the indexed topic so a keeper subscribes per-intent; the rest is the
// order the keeper submits to D against the locked collateral.
// emitNativeIntentEvent logs a C->D SWAP intent's routing metadata for the keeper.
// The intentID is the indexed topic so a keeper subscribes per-intent; the rest is
// the taker order the keeper submits to D against the locked collateral. Kind is
// fixed intentKindSwap — this is the taker rail; the LP rail uses
// emitNativePositionCommitEvent (kind intentKindPosition).
func emitNativeIntentEvent(stateDB StateDB, intentID, dChainID ids.ID, req IntentRequest, locked uint64) {
emitNativeRoutingEvent(stateDB, intentID, dChainID, req, locked, intentKindSwap)
}
// emitNativePositionCommitEvent logs a C->D LP POSITION-COMMIT (DL01) routing event
// for the keeper. Identical wire to the swap intent event but kind=intentKindPosition,
// so the keeper OPENS a funded D position (range = the tick window carried in the
// position record) against the committed collateral instead of placing a taker order.
// The atomic commit object alone moves the value; this event only routes it into the
// D position-open. positionID is the C->D object key (the indexed topic).
func emitNativePositionCommitEvent(stateDB StateDB, positionID, dChainID ids.ID, req IntentRequest, locked uint64) {
emitNativeRoutingEvent(stateDB, positionID, dChainID, req, locked, intentKindPosition)
}
// emitNativeRoutingEvent is the SINGLE DRY emitter for both C->D rails: it logs the
// (account, assetIn, locked, marketID, minAmountOut, recipient, deadline, kind)
// routing metadata under the object id. kind selects the rail (swap vs position) so
// the keeper builds the right D op; the value-bearing identity is in the staged
// atomic object, never here.
func emitNativeRoutingEvent(stateDB StateDB, objectID, dChainID ids.ID, req IntentRequest, locked uint64, kind nativeIntentKind) {
data := make([]byte, 0, 7*32)
data = append(data, abiEncodeAddress(req.Account)...)
data = append(data, abiEncodeBytes32(req.AssetIn)...)
@@ -59,12 +80,12 @@ func emitNativeIntentEvent(stateDB StateDB, intentID, dChainID ids.ID, req Inten
data = append(data, abiEncodeBigInt(min)...)
data = append(data, abiEncodeAddress(req.Recipient)...)
data = append(data, abiEncodeBigInt(new(big.Int).SetUint64(req.Deadline))...)
data = append(data, abiEncodeBigInt(new(big.Int).SetUint64(uint64(intentKindSwap)))...)
data = append(data, abiEncodeBigInt(new(big.Int).SetUint64(uint64(kind)))...)
stateDB.AddLog(&ethtypes.Log{
Address: poolManagerAddr9999,
Topics: []common.Hash{
nativeIntentEventSig,
common.BytesToHash(intentID[:]),
common.BytesToHash(objectID[:]),
common.BytesToHash(dChainID[:]),
},
Data: data,
+44 -8
View File
@@ -178,6 +178,50 @@ func (h *settleHarness) fundVaultNativeOut(amount int64) {
storeSeamReserve(newPoolStateAdapter(h.state), [32]byte{}, big.NewInt(amount))
}
// seedCommittedNative seeds the 0x9999 vault's NATIVE holdings into the COMMITTED-
// POSITIONS pot (the LP rail's own reserve — operator fee backing, the symmetric
// analog of fundVaultNativeOut for the swap rail). The vault self-balance and the
// committedPositions pot move in lockstep, so a fee collect that exceeds an LP's own
// principal commit is backed without raiding any other pot. Used by the fee tests.
func (h *settleHarness) seedCommittedNative(amount int64) {
db := newPoolStateAdapter(h.state)
h.state.stateDB.AddBalance(poolManagerAddr9999, uint256.NewInt(uint64(amount)))
cur := loadCommittedPositions(db, [32]byte{})
storeCommittedPositions(db, [32]byte{}, new(big.Int).Add(cur, big.NewInt(amount)))
}
// commitNativePosition drives a native LP COMMIT through the 0x9999 modifyLiquidity
// selector: it funds the caller, runs the ADD, flushes the staged C->D object, and
// returns the position record id (MakerOrderID) and the C->D commit object id
// (DerivePositionCommitID, read off the record). The commit moves `amount` from the
// caller's CSpendable balance into committedPositions (DCommitted).
func (h *settleHarness) commitNativePosition(t testing.TB, tickLower, tickUpper int24, amount int64, salt [32]byte) (recordID, commitObjID ids.ID) {
t.Helper()
h.fundCallerNative(amount)
hookData := []byte{makerEnvelopeTag[0], makerEnvelopeTag[1], makerEnvelopeTag[2], makerEnvelopeTag[3], byte(MakerSideBid)}
args := buildModifyLiquidityArgs(h.key, tickLower, tickUpper, big.NewInt(amount), salt, hookData)
out, _, err := h.c.Run(h.state, h.caller, poolManagerAddr9999, prependSelector(SelectorModifyLiquidity, args), 5_000_000, false)
if err != nil {
t.Fatalf("commitNativePosition ADD: %v", err)
}
var rid [32]byte
copy(rid[:], out[0:32])
h.flushStaged(t)
db := newPoolStateAdapter(h.state)
commit := db.GetState(poolManagerAddr9999, orderSlot(rid, orderCommitObjSuffix))
return ids.ID(rid), ids.ID(commit)
}
// collectNative drives a native LP COLLECT/WITHDRAW through the 0x9999
// collectPosition selector: it consumes the D->C object at outputID for `amount` of
// native, crediting the caller out of committedPositions. Returns the call error.
func (h *settleHarness) collectNative(outputID ids.ID, amount uint64) ([]byte, error) {
input := EncodeCollectPositionInput(outputID, [32]byte{}, amount)
out, _, err := h.c.Run(h.state, h.caller, poolManagerAddr9999,
prependSelector(SelectorCollectPosition, input), 5_000_000, false)
return out, err
}
// flushStaged simulates the HOST's block-accept cross-domain commit using the
// DETERMINISTIC parent->current seq window (FlushAcceptedAtomicOps), the same model
// the production host uses. It tracks the harness's last-flushed seq as the "parent"
@@ -287,14 +331,6 @@ func buildSwapCalldata(key PoolKey, params SwapParams, hookData []byte) []byte {
return append(append(args, lenWord...), padded...)
}
// fundClaim seeds a depositor's claim (the maker-path reserve helper used by the
// surviving surface tests). Mirrors the quarantined helper.
func fundClaim(stateDB StateDB, account common.Address, assetID [32]byte, amount *big.Int) {
storeDepositorClaim(stateDB, account, assetID, amount)
cur := loadSettleVault(stateDB, assetID)
storeSettleVault(stateDB, assetID, new(big.Int).Add(cur, amount))
}
// prependSelector builds calldata = 4-byte selector || data (used by the surface
// tests to call a 0x9999 selector directly). Mirrors the quarantined helper.
func prependSelector(sel uint32, data []byte) []byte {
+526
View File
@@ -0,0 +1,526 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"math/big"
"testing"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
)
// native_lp_test.go — the LP D-COMMITTED LIQUIDITY tests. Each asserts a leg of the
// CTO-locked LP ship rule:
//
// A D position is funded ONLY by consuming a C->D commit object (DL01);
// a C balance is credited (collect/withdraw) ONLY by consuming a D->C object;
// a unit of value is CSpendable XOR DCommitted, NEVER both.
//
// They drive the REAL 0x9999 modifyLiquidity (commit) + collectPosition (collect)
// over the genuine atomic shared-memory channel (the same harness the swap tests use),
// and the dexvm-side seam tests (chains/dexvm) prove the D side consumes/produces the
// identical wire.
// lpSalt builds a distinct position salt per test.
func lpSalt(b byte) [32]byte {
var s [32]byte
s[31] = b
return s
}
// TestLP_ModifyLiquidityCommitsCToD — COMMIT moves funds OUT of CSpendable into
// DCommitted (committedPositions) and stages a C->D commit object D will import. It
// returns a positionID; it credits NO output. The C->D object records the LP's
// (owner, asset, amount) — the funding D consumes.
func TestLP_ModifyLiquidityCommitsCToD(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
h.fundCallerNative(1000)
native := h.inAssetID()
db := newPoolStateAdapter(h.state)
callerBefore := h.state.stateDB.GetBalance(h.caller).ToBig()
committedBefore := loadCommittedPositions(db, native)
salt := lpSalt(0x01)
hookData := []byte{makerEnvelopeTag[0], makerEnvelopeTag[1], makerEnvelopeTag[2], makerEnvelopeTag[3], byte(MakerSideBid)}
args := buildModifyLiquidityArgs(h.key, -60, 60, big.NewInt(400), salt, hookData)
out, _, err := h.c.Run(h.state, h.caller, poolManagerAddr9999, prependSelector(SelectorModifyLiquidity, args), 5_000_000, false)
if err != nil {
t.Fatalf("commit: %v", err)
}
if len(out) < 32 {
t.Fatal("commit must return (positionID, committed)")
}
var recordID [32]byte
copy(recordID[:], out[0:32])
// Funds LEFT CSpendable and BECAME DCommitted.
callerAfter := h.state.stateDB.GetBalance(h.caller).ToBig()
if new(big.Int).Sub(callerBefore, callerAfter).Int64() != 400 {
t.Fatalf("commit must debit caller CSpendable by 400, got %s", new(big.Int).Sub(callerBefore, callerAfter))
}
if new(big.Int).Sub(loadCommittedPositions(db, native), committedBefore).Int64() != 400 {
t.Fatalf("commit must add 400 to committedPositions (DCommitted), got %s", loadCommittedPositions(db, native))
}
if loadRestingOrder(db, recordID).Status != OrderStatusOpen {
t.Fatal("position must be OPEN (Committed)")
}
h.vaultInvariantNative(t, "after commit")
// The commit STAGED a C->D object (DL01) keyed by the commit object id; flush it
// and assert D can read it (the funding D consumes — D funded ONLY by this object).
h.flushStaged(t)
commitObjID := ids.ID(db.GetState(poolManagerAddr9999, orderSlot(recordID, orderCommitObjSuffix)))
if commitObjID == ids.Empty {
t.Fatal("commit must record the C->D commit object id on the position")
}
raw, ok := h.readCtoDObject(t, commitObjID)
if !ok {
t.Fatal("C->D commit object not found in shared memory after commit")
}
owner, asset, amount, decOK := decodeAtomicObject(raw)
if !decOK || owner != h.caller || asset != native || amount != 400 {
t.Fatalf("C->D commit object mismatch: ok=%v owner=%s asset=%x amount=%d", decOK, owner.Hex(), asset, amount)
}
}
// TestLP_CollectImportsDToCCredit — COLLECT consumes a D->C object ONCE and credits
// the LP out of committedPositions (DPendingCollect -> CSettled). The committed pot
// falls by exactly the credited amount; the LP's CSpendable balance rises by it.
func TestLP_CollectImportsDToCCredit(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
native := h.inAssetID()
db := newPoolStateAdapter(h.state)
// Commit 400 (committedPositions = 400, the C-side backing of the live position).
h.commitNativePosition(t, -60, 60, 400, lpSalt(0x02))
if loadCommittedPositions(db, native).Int64() != 400 {
t.Fatalf("committedPositions must be 400 after commit, got %s", loadCommittedPositions(db, native))
}
// D exported a D->C collect object for the LP (principal withdraw of 250).
outputID := ids.ID{0xC0, 0x11}
h.putDtoCObject(t, h.caller, outputID, native, 250)
callerBefore := h.state.stateDB.GetBalance(h.caller).ToBig()
out, err := h.collectNative(outputID, 250)
if err != nil {
t.Fatalf("collect: %v", err)
}
if new(big.Int).SetBytes(out).Int64() != 250 {
t.Fatalf("collect must credit 250, returned %s", new(big.Int).SetBytes(out))
}
// CSpendable rose by 250; committedPositions fell by 250.
if new(big.Int).Sub(h.state.stateDB.GetBalance(h.caller).ToBig(), callerBefore).Int64() != 250 {
t.Fatal("collect must credit the LP's CSpendable balance by 250")
}
if loadCommittedPositions(db, native).Int64() != 150 {
t.Fatalf("committedPositions must be 150 after collecting 250, got %s", loadCommittedPositions(db, native))
}
h.vaultInvariantNative(t, "after collect")
// Replay: consuming the SAME object again reverts (one-time D->C).
if _, err := h.collectNative(outputID, 250); err != ErrNativeSettleReplay {
t.Fatalf("re-collecting the same D->C object must revert ErrNativeSettleReplay, got: %v", err)
}
}
// TestLP_DecreaseLiquidityReturnsFundsToC — a DECREASE (REMOVE) marks the position
// Closing and emits the withdraw request; the funds return when the LP consumes the
// resulting D->C object via collectPosition (the full decrease->collect path).
func TestLP_DecreaseLiquidityReturnsFundsToC(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
native := h.inAssetID()
db := newPoolStateAdapter(h.state)
salt := lpSalt(0x03)
recordID, _ := h.commitNativePosition(t, -60, 60, 600, salt)
// DECREASE 200 (a partial withdraw request). Marks the position Closing; moves NO
// C value here.
hookData := []byte{makerEnvelopeTag[0], makerEnvelopeTag[1], makerEnvelopeTag[2], makerEnvelopeTag[3], byte(MakerSideBid)}
args := buildModifyLiquidityArgs(h.key, -60, 60, big.NewInt(-200), salt, hookData)
if _, _, err := h.c.Run(h.state, h.caller, poolManagerAddr9999, prependSelector(SelectorModifyLiquidity, args), 5_000_000, false); err != nil {
t.Fatalf("decrease (withdraw request): %v", err)
}
if loadRestingOrder(db, recordID).Status != OrderStatusClosing {
t.Fatal("decrease must mark the position CLOSING")
}
if loadCommittedPositions(db, native).Int64() != 600 {
t.Fatal("the withdraw request alone must NOT change committedPositions")
}
// D exports the D->C object for the 200 principal; the LP collects it.
outputID := ids.ID{0xDE, 0xC0}
h.putDtoCObject(t, h.caller, outputID, native, 200)
callerBefore := h.state.stateDB.GetBalance(h.caller).ToBig()
if _, err := h.collectNative(outputID, 200); err != nil {
t.Fatalf("collect of decreased principal: %v", err)
}
if new(big.Int).Sub(h.state.stateDB.GetBalance(h.caller).ToBig(), callerBefore).Int64() != 200 {
t.Fatal("decrease->collect must return 200 to the LP's CSpendable balance")
}
if loadCommittedPositions(db, native).Int64() != 400 {
t.Fatalf("committedPositions must be 400 after withdrawing 200 of 600, got %s", loadCommittedPositions(db, native))
}
h.vaultInvariantNative(t, "after decrease->collect")
}
// TestLP_BurnClosesPositionAndWithdraws — BURN (full REMOVE) marks the position
// Closing; the full principal returns via the D->C collect object; positionsOf drops
// the position (only OPEN listed).
func TestLP_BurnClosesPositionAndWithdraws(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
native := h.inAssetID()
db := newPoolStateAdapter(h.state)
salt := lpSalt(0x04)
recordID, _ := h.commitNativePosition(t, -60, 60, 500, salt)
// BURN: route a full REMOVE through 0x9996 burn (delta forced negative).
burnArgs := pmLifecycleCalldata(SelectorPMBurn, h.key, -60, 60, big.NewInt(500), salt, MakerSideBid)
if _, _, err := PositionManagerPrecompile.Run(h.state, h.caller, positionManagerAddr, burnArgs, 5_000_000, false); err != nil {
t.Fatalf("burn: %v", err)
}
if loadRestingOrder(db, recordID).Status != OrderStatusClosing {
t.Fatal("burn must mark the position CLOSING")
}
// positionsOf drops the closing position (only OPEN listed).
pOut, _, _ := PositionManagerPrecompile.Run(h.state, h.caller, positionManagerAddr,
prependSelector(SelectorPMPositionsOf, leftPad32(h.caller.Bytes())), 5_000_000, true)
if new(big.Int).SetBytes(pOut[32:64]).Int64() != 0 {
t.Fatal("positionsOf must be empty after burn")
}
// D exports the full principal; the LP collects it.
outputID := ids.ID{0xB0, 0x12}
h.putDtoCObject(t, h.caller, outputID, native, 500)
callerBefore := h.state.stateDB.GetBalance(h.caller).ToBig()
if _, err := h.collectNative(outputID, 500); err != nil {
t.Fatalf("collect of burned principal: %v", err)
}
if new(big.Int).Sub(h.state.stateDB.GetBalance(h.caller).ToBig(), callerBefore).Int64() != 500 {
t.Fatal("burn->collect must return the full 500 to the LP")
}
if loadCommittedPositions(db, native).Sign() != 0 {
t.Fatalf("committedPositions must be 0 after the full withdraw, got %s", loadCommittedPositions(db, native))
}
h.vaultInvariantNative(t, "after burn->collect")
}
// TestLP_CancelRefundsViaDToC — a CANCEL (REMOVE) refunds the LP's committed
// principal ONLY through a consumed D->C object; the cancel itself moves no C value.
func TestLP_CancelRefundsViaDToC(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
native := h.inAssetID()
db := newPoolStateAdapter(h.state)
salt := lpSalt(0x05)
h.commitNativePosition(t, -120, 120, 300, salt)
callerAfterCommit := h.state.stateDB.GetBalance(h.caller).ToBig()
// CANCEL via 0x9996 cancelLimit (REMOVE). No C credit here.
cancelArgs := pmLifecycleCalldata(SelectorPMCancelLimit, h.key, -120, 120, big.NewInt(300), salt, MakerSideBid)
if _, _, err := PositionManagerPrecompile.Run(h.state, h.caller, positionManagerAddr, cancelArgs, 5_000_000, false); err != nil {
t.Fatalf("cancel: %v", err)
}
if h.state.stateDB.GetBalance(h.caller).ToBig().Cmp(callerAfterCommit) != 0 {
t.Fatal("cancel alone must NOT credit the LP (funds are on D)")
}
if loadCommittedPositions(db, native).Int64() != 300 {
t.Fatal("cancel alone must NOT change committedPositions")
}
// The refund returns ONLY by consuming the D->C object.
outputID := ids.ID{0xCA, 0x11}
h.putDtoCObject(t, h.caller, outputID, native, 300)
if _, err := h.collectNative(outputID, 300); err != nil {
t.Fatalf("cancel refund collect: %v", err)
}
if new(big.Int).Sub(h.state.stateDB.GetBalance(h.caller).ToBig(), callerAfterCommit).Int64() != 300 {
t.Fatal("cancel->collect must refund the full 300")
}
h.vaultInvariantNative(t, "after cancel->collect")
}
// TestLP_NeverCSpendableAndDCommitted — the state-machine invariant: at every step a
// unit is EITHER in the LP's CSpendable EVM balance OR in committedPositions backing a
// D position, NEVER both. The conserved total (caller balance + committedPositions +
// the vault's other pots) is constant across commit and collect; the vault-account
// invariant holds throughout.
func TestLP_NeverCSpendableAndDCommitted(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
native := h.inAssetID()
db := newPoolStateAdapter(h.state)
h.fundCallerNative(1000)
// The conserved system total for native = caller CSpendable + the vault's real
// native holding (which == settleVault+makerLocked+seam+committedPositions).
systemTotal := func() *big.Int {
caller := h.state.stateDB.GetBalance(h.caller).ToBig()
vault := h.state.stateDB.GetBalance(poolManagerAddr9999).ToBig()
return new(big.Int).Add(caller, vault)
}
total0 := systemTotal()
// A unit is in exactly ONE place: assert caller_balance and committedPositions are
// disjoint and their movement is balanced at each step.
assertNeverBoth := func(where string, prevCaller, prevCommitted *big.Int) (*big.Int, *big.Int) {
caller := h.state.stateDB.GetBalance(h.caller).ToBig()
committed := loadCommittedPositions(db, native)
dCaller := new(big.Int).Sub(caller, prevCaller) // change in CSpendable
dCommitted := new(big.Int).Sub(committed, prevCommitted) // change in DCommitted
// Every unit that left CSpendable entered DCommitted (and vice-versa): the two
// deltas are exact negatives — a unit is never created in both nor lost from both.
if new(big.Int).Add(dCaller, dCommitted).Sign() != 0 {
t.Fatalf("%s: NEVER-BOTH violated: dCSpendable=%s + dDCommitted=%s != 0", where, dCaller, dCommitted)
}
if systemTotal().Cmp(total0) != 0 {
t.Fatalf("%s: system total drifted (mint/burn): %s != %s", where, systemTotal(), total0)
}
h.vaultInvariantNative(t, where)
return caller, committed
}
c, cm := h.state.stateDB.GetBalance(h.caller).ToBig(), loadCommittedPositions(db, native)
salt := lpSalt(0x06)
// COMMIT 400: 400 units move CSpendable -> DCommitted.
hookData := []byte{makerEnvelopeTag[0], makerEnvelopeTag[1], makerEnvelopeTag[2], makerEnvelopeTag[3], byte(MakerSideBid)}
addArgs := buildModifyLiquidityArgs(h.key, -60, 60, big.NewInt(400), salt, hookData)
if _, _, err := h.c.Run(h.state, h.caller, poolManagerAddr9999, prependSelector(SelectorModifyLiquidity, addArgs), 5_000_000, false); err != nil {
t.Fatalf("commit: %v", err)
}
h.flushStaged(t)
c, cm = assertNeverBoth("after commit 400", c, cm)
if cm.Int64() != 400 {
t.Fatalf("committed must be 400, got %s", cm)
}
// COMMIT another 100 (same range, re-commit): 100 more move CSpendable -> DCommitted.
h.fundCallerNative(0) // no-op; balance already present
addArgs2 := buildModifyLiquidityArgs(h.key, -60, 60, big.NewInt(100), salt, hookData)
if _, _, err := h.c.Run(h.state, h.caller, poolManagerAddr9999, prependSelector(SelectorModifyLiquidity, addArgs2), 5_000_000, false); err != nil {
t.Fatalf("commit 2: %v", err)
}
h.flushStaged(t)
c, cm = assertNeverBoth("after commit +100", c, cm)
if cm.Int64() != 500 {
t.Fatalf("committed must be 500, got %s", cm)
}
// COLLECT 300: 300 units move DCommitted -> CSpendable.
outputID := ids.ID{0x6A, 0x33}
h.putDtoCObject(t, h.caller, outputID, native, 300)
if _, err := h.collectNative(outputID, 300); err != nil {
t.Fatalf("collect: %v", err)
}
_, cm = assertNeverBoth("after collect 300", c, cm)
if cm.Int64() != 200 {
t.Fatalf("committed must be 200 after collecting 300 of 500, got %s", cm)
}
}
// TestLP_FeeAccrualCollectableOnlyViaDToCObject — fees (value earned on D beyond the
// LP's principal) are collectable ONLY by consuming a D->C object, and only up to the
// LP rail's backing. With the committedPositions pot operator-seeded for fee backing
// (the symmetric analog of the swap rail's seam seeding), a fee collect succeeds via a
// real object; with NO backing it reverts ErrLPCollectUnbacked (never minted, never
// raided from another pot).
func TestLP_FeeAccrualCollectableOnlyViaDToCObject(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
native := h.inAssetID()
db := newPoolStateAdapter(h.state)
salt := lpSalt(0x07)
// LP commits 1000 principal; operator seeds 50 of fee backing into the LP pot.
h.commitNativePosition(t, -60, 60, 1000, salt)
h.seedCommittedNative(50)
if loadCommittedPositions(db, native).Int64() != 1050 {
t.Fatalf("committedPositions must be 1000 principal + 50 fee backing, got %s", loadCommittedPositions(db, native))
}
h.vaultInvariantNative(t, "after commit + fee seed")
// D exports a D->C collect object for principal+fees = 1050. The LP collects it.
outputID := ids.ID{0xFE, 0xE5}
h.putDtoCObject(t, h.caller, outputID, native, 1050)
callerBefore := h.state.stateDB.GetBalance(h.caller).ToBig()
if _, err := h.collectNative(outputID, 1050); err != nil {
t.Fatalf("fee+principal collect via D->C object: %v", err)
}
if new(big.Int).Sub(h.state.stateDB.GetBalance(h.caller).ToBig(), callerBefore).Int64() != 1050 {
t.Fatal("collect must credit principal+fees = 1050 to the LP")
}
if loadCommittedPositions(db, native).Sign() != 0 {
t.Fatalf("committedPositions must be 0 after collecting 1050, got %s", loadCommittedPositions(db, native))
}
// A SECOND fee collect with NO remaining backing reverts (no mint, no raid). The
// position is still open (Committed) so the rail gate passes; the POT is empty.
outputID2 := ids.ID{0xFE, 0xE6}
h.putDtoCObject(t, h.caller, outputID2, native, 10)
if _, err := h.collectNative(outputID2, 10); err != ErrLPCollectUnbacked {
t.Fatalf("a fee collect beyond the LP pot backing must revert ErrLPCollectUnbacked, got: %v", err)
}
}
// TestRED_LP_PositionFundableOnlyByConsumingCToDObject — the ship rule (C->D leg): a
// position's funding exists ONLY as a consumable C->D commit object. There is no
// path that funds a D position without a commit object: the commit BOTH debits C AND
// stages the object atomically; a reverting commit leaves neither. The object D
// imports binds the recorded (owner, asset, amount) — D cannot be funded beyond what
// C committed.
func TestRED_LP_PositionFundableOnlyByConsumingCToDObject(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
native := h.inAssetID()
db := newPoolStateAdapter(h.state)
salt := lpSalt(0x08)
// A commit with INSUFFICIENT balance reverts — NO object is staged (no unbacked
// funding). Fund only 100, try to commit 400.
h.fundCallerNative(100)
hookData := []byte{makerEnvelopeTag[0], makerEnvelopeTag[1], makerEnvelopeTag[2], makerEnvelopeTag[3], byte(MakerSideBid)}
badArgs := buildModifyLiquidityArgs(h.key, -60, 60, big.NewInt(400), salt, hookData)
if _, _, err := h.c.Run(h.state, h.caller, poolManagerAddr9999, prependSelector(SelectorModifyLiquidity, badArgs), 5_000_000, false); err != ErrNativeFundsShort {
t.Fatalf("an unbacked commit must revert ErrNativeFundsShort, got: %v", err)
}
// committedPositions unchanged; no position recorded; staging seq unmoved => no
// C->D object was staged for an unbacked commit.
if loadCommittedPositions(db, native).Sign() != 0 {
t.Fatal("a reverted commit must not add to committedPositions")
}
if stageSeq(db) != 0 {
t.Fatal("a reverted commit must stage NO C->D object (no unbacked funding)")
}
// A SUCCESSFUL commit stages EXACTLY ONE C->D object whose amount == the C debit.
h.fundCallerNative(300) // now 400 total
goodArgs := buildModifyLiquidityArgs(h.key, -60, 60, big.NewInt(400), salt, hookData)
out, _, err := h.c.Run(h.state, h.caller, poolManagerAddr9999, prependSelector(SelectorModifyLiquidity, goodArgs), 5_000_000, false)
if err != nil {
t.Fatalf("backed commit: %v", err)
}
var recordID [32]byte
copy(recordID[:], out[0:32])
h.flushStaged(t)
commitObjID := ids.ID(db.GetState(poolManagerAddr9999, orderSlot(recordID, orderCommitObjSuffix)))
raw, ok := h.readCtoDObject(t, commitObjID)
if !ok {
t.Fatal("a backed commit must stage a C->D object D can consume")
}
_, _, amount, _ := decodeAtomicObject(raw)
if amount != 400 {
t.Fatalf("the C->D object amount must equal the C debit (400), got %d", amount)
}
}
// TestRED_LP_CollectCannotCreditWithoutDToCObject — the ship rule (D->C leg): a
// collect CANNOT credit C without a real D->C object in shared memory. A collect that
// names a non-existent object reverts (ErrNativeNoSettlement); a collect whose claim
// amount/owner does not match the recorded object reverts (bind failure). No declared
// amount can drive a credit.
func TestRED_LP_CollectCannotCreditWithoutDToCObject(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
native := h.inAssetID()
db := newPoolStateAdapter(h.state)
// Give the LP a live position (so the rail gate passes) and a fat committed pot.
h.commitNativePosition(t, -60, 60, 1000, lpSalt(0x09))
committedBefore := loadCommittedPositions(db, native)
// (a) collect with NO object in shared memory => revert, NO credit.
phantom := ids.ID{0x00, 0xDE, 0xAD}
if _, err := h.collectNative(phantom, 500); err != ErrNativeNoSettlement {
t.Fatalf("collect with no D->C object must revert ErrNativeNoSettlement, got: %v", err)
}
// (b) a real object exists for 250, but the claim DECLARES 500 => amount-bind
// failure, NO credit (the credit is the RECORDED amount, not the declared one).
realObj := ids.ID{0x00, 0xC0, 0x01}
h.putDtoCObject(t, h.caller, realObj, native, 250)
if _, err := h.collectNative(realObj, 500); err != ErrNativeSettleAmount {
t.Fatalf("collect declaring 500 against a 250 object must revert ErrNativeSettleAmount, got: %v", err)
}
// (c) an object owned by a DIFFERENT account => owner-bind failure when this caller
// claims it (recipient=caller != recorded owner).
other := common.HexToAddress("0x0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a")
victimObj := ids.ID{0x00, 0x71, 0x71}
h.putDtoCObject(t, other, victimObj, native, 250)
if _, err := h.collectNative(victimObj, 250); err != ErrNativeSettleOwner {
t.Fatalf("collect of another account's object must revert ErrNativeSettleOwner, got: %v", err)
}
// Through all the refused collects, committedPositions is UNTOUCHED (no credit).
if loadCommittedPositions(db, native).Cmp(committedBefore) != 0 {
t.Fatal("refused collects must not move committedPositions (no credit without a bound D->C object)")
}
}
// TestLP_ConservationAcrossCommitMatchCollect — end-to-end conservation: an LP
// commits, a position is funded on D (the C->D object), value is matched/earned on D
// (modeled as the operator fee backing the LP rail), and the LP collects via a D->C
// object. After the full cycle, the LP's net CSpendable change equals (collected −
// committed), the committedPositions pot returns to its post-fee-seed baseline minus
// what was collected, and the vault-account invariant holds at every step.
func TestLP_ConservationAcrossCommitMatchCollect(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
native := h.inAssetID()
db := newPoolStateAdapter(h.state)
salt := lpSalt(0x0A)
h.fundCallerNative(2000)
startCaller := h.state.stateDB.GetBalance(h.caller).ToBig()
h.vaultInvariantNative(t, "genesis")
// 1) COMMIT 1200: CSpendable -> DCommitted.
hookData := []byte{makerEnvelopeTag[0], makerEnvelopeTag[1], makerEnvelopeTag[2], makerEnvelopeTag[3], byte(MakerSideBid)}
addArgs := buildModifyLiquidityArgs(h.key, -60, 60, big.NewInt(1200), salt, hookData)
out, _, err := h.c.Run(h.state, h.caller, poolManagerAddr9999, prependSelector(SelectorModifyLiquidity, addArgs), 5_000_000, false)
if err != nil {
t.Fatalf("commit: %v", err)
}
var recordID [32]byte
copy(recordID[:], out[0:32])
h.flushStaged(t)
if loadCommittedPositions(db, native).Int64() != 1200 {
t.Fatalf("committed must be 1200, got %s", loadCommittedPositions(db, native))
}
h.vaultInvariantNative(t, "after commit")
// 2) D imports the C->D object and matches (modeled): the LP earns 80 fees. The
// operator-managed LP reserve gains the 80 fee backing on C (the cross-rail
// settlement of taker flow into the maker's collectable balance).
h.seedCommittedNative(80)
h.vaultInvariantNative(t, "after match (fee backing)")
// 3) COLLECT the full withdrawable: principal 1200 + fees 80 = 1280, via a D->C object.
outputID := ids.ID{0x0A, 0xCC}
h.putDtoCObject(t, h.caller, outputID, native, 1280)
if _, err := h.collectNative(outputID, 1280); err != nil {
t.Fatalf("collect principal+fees: %v", err)
}
h.vaultInvariantNative(t, "after collect")
// Net CSpendable change = collected(1280) − committed(1200) = +80 (the earned fee).
netCaller := new(big.Int).Sub(h.state.stateDB.GetBalance(h.caller).ToBig(), startCaller)
if netCaller.Int64() != 80 {
t.Fatalf("LP net CSpendable change must equal earned fees (+80), got %s", netCaller)
}
// committedPositions fully drained (1200 principal + 80 fee backing − 1280 collect).
if loadCommittedPositions(db, native).Sign() != 0 {
t.Fatalf("committedPositions must be 0 after full collect, got %s", loadCommittedPositions(db, native))
}
}
+43
View File
@@ -113,3 +113,46 @@ func storeSeamReserve(stateDB stateKV, assetID [32]byte, amount *big.Int) {
}
stateDB.SetState(poolManagerAddr9999, seamReserveKey(assetID), w)
}
// --- Committed-positions reserve: the LP RAIL's OWN per-asset pot inside the 0x9999
// vault, tracked SEPARATELY from the depositor pot (settleVault), the legacy maker
// C-side pot (makerLockedVault), and the swap-rail pot (seamReserve). This is the
// fourth orthogonal claim on the SAME 0x9999 vault account — the FIX-3 decomplection
// extended to the LP rail. It tracks the C-side BACKING of value COMMITTED to live D
// positions (the DCommitted state):
//
// committedPositions[a] = operator seed of a (fee backing)
// + Σ LP principal commits of a (C->D DL01 commits)
// − Σ collect/decrease/burn credits of a (D->C imports)
//
// THE VAULT-ACCOUNT INVARIANT becomes (per asset a):
//
// realHolding(0x9999, a) == settleVault[a] + makerLockedVault[a]
// + seamReserve[a] + committedPositions[a]
//
// An LP commit moves value from the caller's CSpendable EVM balance INTO
// committedPositions (DCommitted): the unit is no longer spendable on C and a C->D
// commit object backs a funded D position. A collect/decrease/burn draws ONLY on
// committedPositions (creditPositionCollect), so it can NEVER pay an LP out of a
// depositor's claim, a maker's legacy lock, or the swap rail's seam reserve — and a
// swap settlement (which draws ONLY seamReserve) can never raid an LP's committed
// principal. The pots are orthogonal; the blast radius of one rail can't reach
// another. The NEVER-BOTH invariant (a unit is CSpendable XOR DCommitted) is the
// commit debit + the committedPositions credit being a single balanced move.
var committedPositionsPrefix = []byte(settleStateNamespace + "cpos") // committedPositions[asset]
func committedPositionsKey(assetID [32]byte) common.Hash {
return makeStorageKey(committedPositionsPrefix, assetID[:])
}
func loadCommittedPositions(stateDB stateKV, assetID [32]byte) *big.Int {
return new(big.Int).SetBytes(stateDB.GetState(poolManagerAddr9999, committedPositionsKey(assetID)).Bytes())
}
func storeCommittedPositions(stateDB stateKV, assetID [32]byte, amount *big.Int) {
var w common.Hash
if amount != nil && amount.Sign() > 0 {
amount.FillBytes(w[:])
}
stateDB.SetState(poolManagerAddr9999, committedPositionsKey(assetID), w)
}
+53
View File
@@ -112,3 +112,56 @@ func DeriveIntentID(
// nativeIntentDomain scopes the intent-id derivation so an id minted for the DEX
// C->D atomic seam can never be confused with any other shared-memory object id.
const nativeIntentDomain = "lux.dex.native.intent.v1"
// DerivePositionCommitID computes the deterministic id of a C->D LP POSITION-COMMIT
// atomic object (intent kind DL01) — the C->D leg that FUNDS a D position. It is the
// shared-memory UTXO key the commit object is PUT under (and that D's import
// consumes). It is derived with its OWN domain (positionCommitDomain) so a
// position-commit id and a swap-intent id (DeriveIntentID, nativeIntentDomain) live
// in DISJOINT id spaces — a swap intent object can NEVER be consumed as a position
// commit, nor vice-versa, even with the same (account, asset, amount, market). The
// 60-byte object wire (owner|asset|amount) is IDENTICAL so D imports both natively;
// only the id DOMAIN (and the routing event KIND) distinguish the two rails.
//
// SHA-256( positionCommitDomain | networkID | cChainID | dChainID | txID |
// callIndex | account | assetIn | amountIn | poolID )
//
// Every component is fixed width and length-stable; callIndex disambiguates two
// commits in one tx; (networkID, cChainID, dChainID) scope the object to one rail;
// account/asset/amount/pool bind the economic payload so the id cannot be reused.
func DerivePositionCommitID(
networkID uint32,
cChainID, dChainID ids.ID,
txID ids.ID,
callIndex uint32,
account common.Address,
assetIn [32]byte,
amountIn uint64,
poolID [32]byte,
) ids.ID {
h := sha256.New()
h.Write([]byte(positionCommitDomain))
var u4 [4]byte
binary.BigEndian.PutUint32(u4[:], networkID)
h.Write(u4[:])
h.Write(cChainID[:])
h.Write(dChainID[:])
h.Write(txID[:])
binary.BigEndian.PutUint32(u4[:], callIndex)
h.Write(u4[:])
h.Write(account[:])
h.Write(assetIn[:])
var u8 [8]byte
binary.BigEndian.PutUint64(u8[:], amountIn)
h.Write(u8[:])
h.Write(poolID[:])
var out [32]byte
copy(out[:], h.Sum(nil))
return ids.ID(out)
}
// positionCommitDomain scopes the position-commit-id derivation so a DL01 LP commit
// object id can never collide with a DI01 swap-intent object id (nativeIntentDomain)
// or any other shared-memory object id. This is the id-space half of the rail
// separation (the routing-event KIND is the other half).
const positionCommitDomain = "lux.dex.native.poscommit.v1"
+333
View File
@@ -0,0 +1,333 @@
// 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 rail gate accepts it) until the position is fully drained.
// It extends the OrderStatus lifecycle (None=0, Open=1=Committed, Cancelled=2=Closed).
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")
)
// 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)
// ownerHasOpenPosition reports whether owner holds ANY position in Open (Committed)
// or Closing status — the rail gate ImportPositionCollect uses to refuse a D->C
// object that names no LP position (so a swap-settlement object cannot be consumed on
// the LP rail to drain committedPositions). It walks the owner's order index (O(k)
// in the owner's position count) and stops at the first live position.
func ownerHasOpenPosition(stateDB stateKV, owner common.Address) bool {
for _, id := range OwnerOrderIDs(stateDB, owner) {
switch loadRestingOrder(stateDB, id).Status {
case OrderStatusOpen, OrderStatusClosing:
return true
}
}
return false
}
// 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
}
// COMMIT to D: SubmitPositionCommit debits the caller's CSpendable balance into
// committedPositions, stages the C->D DL01 object, and emits the position-open
// routing event. Returns the commit object id (the shared-memory key D imports).
req := IntentRequest{
Account: caller,
AssetIn: lockedAsset,
AmountIn: delta.Uint64(),
AssetInAddr: assetAddress(lockedAsset),
MarketID: key.ID(),
Recipient: caller,
Deadline: 0,
}
commitObjID, committed, cerr := nativeClient.SubmitPositionCommit(state, atomicState, req)
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). Status Open
// == Committed (live on D).
existing := loadRestingOrder(stateDB, orderID)
newAmt := new(big.Int).SetUint64(committed)
if existing.Status == OrderStatusOpen || existing.Status == OrderStatusClosing {
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)
emitNativeCancelEvent(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 recorded object's asset
// amount[32] // the claimed amount (uint256; must fit uint64 + == recorded)
//
// The recipient is the CALLER (day-1, no delegation). ImportPositionCollect binds all
// three against the RECORDED object AND requires the recorded owner to hold a live
// position (the rail gate), so a claim cannot invent value, consume a victim's
// object, re-denominate it, or consume a swap-rail object on the LP pot.
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
}
if len(input) < 96 {
return nil, gasLeft, ErrLPBadCollectInput
}
var outputID ids.ID
copy(outputID[:], input[0:32])
var asset [32]byte
copy(asset[:], input[32:64])
amount := new(big.Int).SetBytes(input[64:96])
if !amount.IsUint64() || amount.Sign() <= 0 {
return nil, gasLeft, ErrLPCollectBadAmount
}
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)
claim := SettlementClaim{
OutputID: outputID,
Asset: asset,
AssetAddr: assetAddress(asset),
Amount: amount.Uint64(),
Recipient: caller, // day-1: no delegation; recipient is the caller.
}
credited, ierr := nativeClient.ImportPositionCollect(state, atomicState, claim)
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 (outputID, asset,
// amount) for the keeper's collect-tx builder and tests. The inverse of the decode in
// runSettleCollectPosition.
func EncodeCollectPositionInput(outputID ids.ID, asset [32]byte, amount uint64) []byte {
out := make([]byte, 96)
copy(out[0:32], outputID[:])
copy(out[32:64], asset[:])
new(big.Int).SetUint64(amount).FillBytes(out[64:96])
return out
}
+48 -12
View File
@@ -8,6 +8,7 @@ import (
"math/big"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
"github.com/luxfi/precompile/contract"
"github.com/luxfi/precompile/modules"
"github.com/luxfi/precompile/precompileconfig"
@@ -159,19 +160,15 @@ func (p *PositionManagerContract) Run(
case SelectorPMModifyPosition:
return p.routeLifecycle(state, caller, data, suppliedGas, readOnly, deltaSignAny)
// collect: accrued maker fees are D-authoritative (the CLOB tracks fill fees), so
// there is NO C-side fee balance to sweep. Rather than fake a distribution C
// cannot compute, collect reverts ErrUnsupported (honest boundary, mirrors 0x9999
// donate). A maker reads fills/fees from the D book; principal is freed by
// decrease/cancel (which DO route to the 0x9999 kernel and move reserve).
// collect: accrued maker fees (and principal) are D-authoritative — the CLOB tracks
// them in the maker's D-Chain balance. There is no C-side fee balance to sweep; the
// value returns via a D->C atomic object the LP consumes through 0x9999
// collectPosition. So 0x9996 collect REQUESTS the D-side fee export (emits the
// collect routing event for the keeper to forward to D) and returns the position
// id; the actual C credit lands when the LP/keeper calls collectPosition with the
// resulting D->C object. Bound by caller == position owner.
case SelectorPMCollect:
if readOnly {
return nil, suppliedGas, ErrUnsupported
}
if suppliedGas < GasPositionRead {
return nil, 0, errors.New("dex: out of gas")
}
return nil, suppliedGas - GasPositionRead, ErrUnsupported
return p.routeCollectRequest(state, caller, data, suppliedGas, readOnly)
// Reads.
case SelectorPMPositionsOf:
@@ -269,6 +266,45 @@ func (p *PositionManagerContract) routeLifecycle(
return SettlePrecompile.runSettleModifyLiquidity(state, caller, kernelInput, gas, readOnly)
}
// routeCollectRequest handles 0x9996 collect: it re-derives the position id from the
// lifecycle args, verifies the position exists and is owned by the caller, and emits
// the D->C collect routing event (for the keeper to make D export the LP's
// withdrawable principal + fees). It moves NO C value — the value returns when the LP
// consumes the resulting D->C object via 0x9999 collectPosition. The owner bind here
// is defense-in-depth; the value credit is bound again to the recorded object in
// ImportPositionCollect, so a non-owner cannot collect another LP's funds.
func (p *PositionManagerContract) routeCollectRequest(
state contract.AccessibleState, caller common.Address, data []byte, gas uint64, readOnly bool,
) ([]byte, uint64, error) {
if readOnly {
return nil, gas, ErrUnsupported // a collect request emits an event (a write); not a view.
}
if gas < GasPositionRead {
return nil, 0, errors.New("dex: out of gas")
}
gasLeft := gas - GasPositionRead
args, err := decodePMLifecycle(data)
if err != nil {
return nil, gasLeft, err
}
poolID := args.key.ID()
orderID := MakerOrderID(caller, poolID, args.salt, args.tickLower, args.tickUpper)
stateDB := newPoolStateAdapter(state)
order := loadRestingOrder(stateDB, orderID)
// A collect names an existing position owned by the caller. None => nothing to
// collect; a foreign owner => the orderID would differ (owner is folded in), so a
// mismatch is already a missing record. Defense-in-depth owner compare too.
if order.Status == OrderStatusNone || order.Owner != caller {
return nil, gasLeft, ErrLPNoPosition
}
// Emit the collect routing event (reuse the native collect event — the keeper
// forwards it to D to export the maker's withdrawable balance for this position).
emitNativeCollectEvent(stateDB, ids.ID(orderID), poolID, caller)
out := make([]byte, 32)
copy(out, orderID[:])
return out, gasLeft, nil
}
// positionsOf(owner) -> bytes32[] OPEN orderIDs. Deterministic enumeration via the
// 0x9999 ownerOrders index; skips CLOSED/CANCELLED (the design's "skip CLOSED").
func (p *PositionManagerContract) positionsOf(
+4 -139
View File
@@ -289,151 +289,16 @@ func storeRestingOrder(stateDB stateKV, orderID [32]byte, o RestingOrder) {
var (
ErrMakerZeroDelta = errors.New("dex: modifyLiquidity delta must be non-zero")
ErrMakerNoMarket = errors.New("dex: modifyLiquidity on an unregistered market (initialize first)")
ErrMakerInsufficient = errors.New("dex: modifyLiquidity ADD exceeds available reserve (deposit first)")
ErrMakerBadTickRange = errors.New("dex: modifyLiquidity tickLower must be < tickUpper, both in tick range")
ErrMakerNotOwner = errors.New("dex: modifyLiquidity cancel/remove: caller is not the order owner")
ErrMakerAlreadyClosed = errors.New("dex: modifyLiquidity cancel: order already cancelled")
ErrMakerAmountRange = errors.New("dex: modifyLiquidity delta exceeds uint256")
)
// runSettleModifyLiquidity handles 0x9999 `modifyLiquidity(PoolKey, params, hookData)`.
// ADD locks reserve + records a resting order; REMOVE unlocks + cancels. PURE
// arithmetic, no matching, full AccountID/AssetID. This is the kernel 0x9996 routes
// every position lifecycle op into.
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
}
stateDB := newPoolStateAdapter(state)
// The market must exist (initialize first). Reading the registry is deterministic.
poolID := key.ID()
if loadMarket(stateDB, poolID).Status != MarketStatusActive {
return nil, gasLeft, ErrMakerNoMarket
}
// Asset-halt gate on the locked asset (a halted asset cannot rest new orders;
// cancel/unlock is still allowed — funds must always be releasable).
side := decodeMakerSide(hookData)
lockedAsset := lockedAssetForSide(key, side)
// GLOBAL non-reentrant guard (same discipline as deposit/withdraw). A nested
// modifyLiquidity from inside any sub-call reverts before moving reserve.
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 {
// --- ADD: lock reserve, record order.
if isHalted(stateDB, makeStorageKey(haltAssetPrefix, lockedAsset[:])) {
return nil, gasLeft, ErrAssetHalted
}
delta := params.LiquidityDelta // already validated >0 and isWord; read-only here.
available := loadDepositorClaim(stateDB, caller, lockedAsset)
if available.Cmp(delta) < 0 {
return nil, gasLeft, ErrMakerInsufficient
}
// available -= delta ; locked += delta (CONSERVATION: sum unchanged).
storeDepositorClaim(stateDB, caller, lockedAsset, new(big.Int).Sub(available, delta))
storeLockedReserve(stateDB, caller, lockedAsset,
new(big.Int).Add(loadLockedReserve(stateDB, caller, lockedAsset), delta))
// MOVE the locked asset OUT of the swap-payable settleVault and INTO the maker-
// locked sub-ledger. This is the conservation fix: a swap settle() draws only on
// settleVault, so it can no longer pay a taker (or let a withdraw double-spend)
// out of this maker's now-locked funds. The asset stays in the 0x9999 vault
// account; only its CLASSIFICATION changes (swap-payable -> maker-locked).
storeSettleVault(stateDB, lockedAsset, new(big.Int).Sub(loadSettleVault(stateDB, lockedAsset), delta))
storeMakerLockedVault(stateDB, lockedAsset, new(big.Int).Add(loadMakerLockedVault(stateDB, lockedAsset), delta))
// Record / top-up the resting order. A re-add to the same (owner,pool,salt,
// range) accumulates the locked amount (the orderID is identical), which is
// the natural "increase position" semantic 0x9996 increaseLiquidity wants.
existing := loadRestingOrder(stateDB, orderID)
newAmt := delta
if existing.Status == OrderStatusOpen {
newAmt = new(big.Int).Add(existing.LockedAmt, delta)
}
storeRestingOrder(stateDB, orderID, RestingOrder{
Owner: caller,
PoolID: poolID,
Side: side,
LockedAsset: lockedAsset,
LockedAmt: newAmt,
TickLower: params.TickLower,
TickUpper: params.TickUpper,
Salt: params.Salt,
Status: OrderStatusOpen,
})
appendOwnerOrder(stateDB, caller, orderID)
return encodeOrderResult(orderID, newAmt), gasLeft, nil
}
// --- REMOVE / cancel: unlock |delta| (clamped to locked), mark cancelled.
order := loadRestingOrder(stateDB, orderID)
if order.Status == OrderStatusNone {
return nil, gasLeft, ErrMakerNotOwner // no such order for this caller's (pool,salt,range)
}
// Ownership: the orderID already binds owner, but defense-in-depth — refuse if
// the stored owner is not the caller (full AccountID compare).
if order.Owner != caller {
return nil, gasLeft, ErrMakerNotOwner
}
if order.Status == OrderStatusCancelled {
return nil, gasLeft, ErrMakerAlreadyClosed
}
want := new(big.Int).Abs(params.LiquidityDelta)
locked := loadLockedReserve(stateDB, caller, order.LockedAsset)
release := want
if release.Cmp(locked) > 0 {
release = new(big.Int).Set(locked) // clamp to locked (cannot release more than held).
}
if release.Cmp(order.LockedAmt) > 0 {
release = new(big.Int).Set(order.LockedAmt) // also clamp to THIS order's locked amount.
}
// locked -= release ; available += release (CONSERVATION: sum unchanged).
storeLockedReserve(stateDB, caller, order.LockedAsset, new(big.Int).Sub(locked, release))
storeDepositorClaim(stateDB, caller, order.LockedAsset,
new(big.Int).Add(loadDepositorClaim(stateDB, caller, order.LockedAsset), release))
// MOVE the released asset back from the maker-locked sub-ledger INTO the swap-
// payable settleVault (the inverse of the ADD move) so the now-available claim is
// once again backed by a withdrawable/swap-payable vault balance. Invariant
// preserved: settleVault[a] + makerLockedVault[a] == the vault's real holdings.
storeMakerLockedVault(stateDB, order.LockedAsset, new(big.Int).Sub(loadMakerLockedVault(stateDB, order.LockedAsset), release))
storeSettleVault(stateDB, order.LockedAsset, new(big.Int).Add(loadSettleVault(stateDB, order.LockedAsset), release))
remaining := new(big.Int).Sub(order.LockedAmt, release)
order.LockedAmt = remaining
if remaining.Sign() <= 0 {
order.Status = OrderStatusCancelled // fully removed.
}
storeRestingOrder(stateDB, orderID, order)
return encodeOrderResult(orderID, remaining), gasLeft, nil
}
// runSettleModifyLiquidity MOVED to position_commit.go — the LP D-committed rail
// (ADD commits to D via a C->D object; REMOVE requests a D->C withdraw). The storage
// helpers below (RestingOrder + the owner index + the per-owner reserve accumulator)
// are the POSITION RECORD that rail composes, kept here as the single record store.
// encodeOrderResult returns (orderID bytes32, remainingLocked uint256) ABI-packed.
func encodeOrderResult(orderID [32]byte, remaining *big.Int) []byte {
+17 -2
View File
@@ -50,6 +50,13 @@ var (
SelectorSetHaltGlobal uint32 // setHaltGlobal(bool)
SelectorSetHaltMarket uint32 // setHaltMarket(bytes32,bool)
SelectorSetHaltAsset uint32 // setHaltAsset(bytes32,bool)
// SelectorCollectPosition is the LP rail's D->C collect/withdraw money path:
// collectPosition(bytes32 outputID, address asset, uint256 amount) consumes a D->C
// atomic object ONCE and credits the caller out of the committedPositions pot. It
// is the SOLE C-credit path for an LP (collect, decrease, burn, cancel all return
// funds through here). Distinct selector, same orthogonal money-path discipline.
SelectorCollectPosition uint32 // collectPosition(bytes32,address,uint256)
)
// SettleModule registers the 0x9999 precompile.
@@ -66,6 +73,7 @@ func init() {
SelectorSetHaltGlobal = keccak4("setHaltGlobal(bool)")
SelectorSetHaltMarket = keccak4("setHaltMarket(bytes32,bool)")
SelectorSetHaltAsset = keccak4("setHaltAsset(bytes32,bool)")
SelectorCollectPosition = keccak4("collectPosition(bytes32,address,uint256)")
if err := modules.RegisterModule(SettleModule); err != nil {
panic(err)
@@ -167,11 +175,18 @@ func (s *SettleContract) Run(
case SelectorInitialize:
return s.runSettleInitialize(accessibleState, caller, data, suppliedGas, readOnly)
// Maker / resting-order path — pure reserve lock/unlock, NO matching
// (settle_maker.go). 0x9996 PositionManager routes its lifecycle ops here.
// LP D-committed liquidity path (position_commit.go): ADD commits the LP's funds
// to a D position via a C->D object; REMOVE requests a D->C withdraw. 0x9996
// PositionManager routes its lifecycle ops here.
case SelectorModifyLiquidity:
return s.runSettleModifyLiquidity(accessibleState, caller, data, suppliedGas, readOnly)
// LP D->C collect/withdraw money path (position_commit.go): consume a D->C atomic
// object ONCE and credit the caller out of committedPositions. The SOLE C-credit
// path for an LP — collect/decrease/burn/cancel all return funds through here.
case SelectorCollectPosition:
return s.runSettleCollectPosition(accessibleState, caller, data, suppliedGas, readOnly)
// donate is explicitly UNSUPPORTED in the receipt model (settle_views9999.go):
// LP fee growth is D-authoritative, so a safe C-only donate cannot exist.
case SelectorDonate:
+19 -15
View File
@@ -98,39 +98,43 @@ func PredictModifyLiquidityAccesses(
var as AccessSet
orderID := MakerOrderID(owner, poolID, salt, tickLower, tickUpper)
// READS: the market record (status), the asset-halt slot, the current reserve, the
// per-asset vault slots (the ADD/REMOVE moves between settleVault and
// makerLockedVault), the order record + count, and the reentrancy guard.
// READS: the market record (status), the asset-halt slot, the per-owner committed
// reserve, the LP rail's per-asset committed-positions pot (the ADD credits it; the
// withdraw/collect debits it via the D->C import), the order record + count, and the
// reentrancy guard. The swap rail's settleVault/seamReserve are NOT touched by the LP
// rail (orthogonal pots).
as.Reads = append(as.Reads,
marketSlot(poolID, marketMetaSuffix),
makeStorageKey(haltAssetPrefix, lockedAsset[:]),
depositorClaimKey(owner, lockedAsset),
lockedReserveKey(owner, lockedAsset),
settleVaultKey(lockedAsset),
makerLockedVaultKey(lockedAsset),
committedPositionsKey(lockedAsset),
orderSlot(orderID, orderMetaSuffix),
orderSlot(orderID, orderAmtSuffix),
ownerOrdersCountKey(owner),
custodyGuardKey9999,
)
// WRITES: reserve split (available/locked), the per-asset vault reclassification
// (settleVault <-> makerLockedVault), the FULL order record (storeRestingOrder
// writes all 6 slots), the index slot + count, and the reentrancy guard (set+clear
// within the call). Each keyed by owner / asset / order — NO global hot WRITE
// (settleVault/makerLockedVault are per-ASSET, so two makers contend only on the
// same asset, which is a genuine conflict, not a global counter).
// WRITES: the per-owner committed reserve, the LP rail's committed-positions pot
// (ADD: caller balance -> committedPositions; the unit becomes DCommitted), the
// FULL order record (storeRestingOrder writes 6 slots + the commit-object slot), the
// index slot + count, the C->D commit replay slot (markIntentSubmitted, keyed by a
// position-commit id the static predictor cannot know), the staging seq+slots
// (stageAtomicPut, also seq-keyed and not statically derivable), and the reentrancy
// guard. Each derivable write is keyed by owner / asset / order — NO global hot WRITE
// (committedPositions is per-ASSET, so two LPs contend only on the same asset, a
// genuine conflict; the staging seq is the ONE shared counter the seam serializes on,
// inherent to ordering cross-chain Puts deterministically — surfaced via stageSeqKey).
as.Writes = append(as.Writes,
depositorClaimKey(owner, lockedAsset),
lockedReserveKey(owner, lockedAsset),
settleVaultKey(lockedAsset),
makerLockedVaultKey(lockedAsset),
committedPositionsKey(lockedAsset),
orderSlot(orderID, orderMetaSuffix),
orderSlot(orderID, orderOwnerSuffix),
orderSlot(orderID, orderPoolSuffix),
orderSlot(orderID, orderAssetSuffix),
orderSlot(orderID, orderAmtSuffix),
orderSlot(orderID, orderSaltSuffix),
orderSlot(orderID, orderCommitObjSuffix),
ownerOrdersCountKey(owner),
stageSeqKey,
custodyGuardKey9999,
)
// NOTE: appendOwnerOrder also writes ownerOrdersAtKey(owner, count) — a NEW slot
+85 -76
View File
@@ -154,132 +154,140 @@ func TestInitialize_Deterministic(t *testing.T) {
// ── 0x9999 modifyLiquidity ───────────────────────────────────────────────────
func TestModifyLiquidity_LockUnlockConservation(t *testing.T) {
func TestModifyLiquidity_CommitConservation(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
// Seed the maker's available reserve of currency0 (the BID-side asset = native).
// The LP funds its CSpendable balance of currency0 (the BID-side asset = native).
// The committed model debits the caller's REAL balance (no prior deposit needed).
bidAsset := assetID(h.key.Currency0)
db := newPoolStateAdapter(h.state)
fundClaim(db, h.caller, bidAsset, big.NewInt(1000))
h.fundCallerNative(1000)
availBefore := loadDepositorClaim(db, h.caller, bidAsset)
lockedBefore := loadLockedReserve(db, h.caller, bidAsset)
sumBefore := new(big.Int).Add(availBefore, lockedBefore)
callerBefore := h.state.stateDB.GetBalance(h.caller).ToBig()
committedBefore := loadCommittedPositions(db, bidAsset)
h.vaultInvariantNative(t, "before commit")
var salt [32]byte
salt[31] = 0x07
// ADD 400: available -= 400, locked += 400 (sum invariant).
// COMMIT 400: caller CSpendable -= 400, committedPositions += 400 (the unit moves
// from CSpendable to DCommitted — never both).
out, _, err := h.c.Run(h.state, h.caller, poolManagerAddr9999,
modLiqCalldata(h.key, -60, 60, big.NewInt(400), salt, MakerSideBid), 5_000_000, false)
if err != nil {
t.Fatalf("modifyLiquidity ADD: %v", err)
t.Fatalf("modifyLiquidity COMMIT: %v", err)
}
if len(out) < 32 {
t.Fatal("modifyLiquidity must return (orderID, remaining)")
t.Fatal("modifyLiquidity must return (positionID, committed)")
}
availAfterAdd := loadDepositorClaim(db, h.caller, bidAsset)
lockedAfterAdd := loadLockedReserve(db, h.caller, bidAsset)
if availAfterAdd.Int64() != 600 || lockedAfterAdd.Int64() != 400 {
t.Fatalf("after ADD 400: available=%s locked=%s (want 600/400)", availAfterAdd, lockedAfterAdd)
callerAfter := h.state.stateDB.GetBalance(h.caller).ToBig()
committedAfter := loadCommittedPositions(db, bidAsset)
if new(big.Int).Sub(callerBefore, callerAfter).Int64() != 400 {
t.Fatalf("commit must debit caller CSpendable by exactly 400, got %s", new(big.Int).Sub(callerBefore, callerAfter))
}
if new(big.Int).Add(availAfterAdd, lockedAfterAdd).Cmp(sumBefore) != 0 {
t.Fatal("CONSERVATION violated by ADD (available+locked changed)")
if new(big.Int).Sub(committedAfter, committedBefore).Int64() != 400 {
t.Fatalf("commit must add 400 to committedPositions, got %s", new(big.Int).Sub(committedAfter, committedBefore))
}
// The resting order is recorded OPEN.
h.vaultInvariantNative(t, "after commit")
// The position is recorded OPEN (Committed) in the owner index.
orderID := MakerOrderID(h.caller, h.key.ID(), salt, -60, 60)
if loadRestingOrder(db, orderID).Status != OrderStatusOpen {
t.Fatal("order must be OPEN after ADD")
t.Fatal("position must be OPEN (Committed) after commit")
}
// It is in the owner index.
if len(OwnerOrderIDs(db, h.caller)) != 1 {
t.Fatal("owner index must have exactly one order")
t.Fatal("owner index must have exactly one position")
}
// REMOVE 400: locked -= 400, available += 400, order CANCELLED.
// REMOVE (withdraw request): marks the position CLOSING and emits the D->C withdraw
// request — it moves NO C value here (funds are on D; they return via collectPosition).
if _, _, err := h.c.Run(h.state, h.caller, poolManagerAddr9999,
modLiqCalldata(h.key, -60, 60, big.NewInt(-400), salt, MakerSideBid), 5_000_000, false); err != nil {
t.Fatalf("modifyLiquidity REMOVE: %v", err)
t.Fatalf("modifyLiquidity REMOVE (withdraw request): %v", err)
}
availAfterRm := loadDepositorClaim(db, h.caller, bidAsset)
lockedAfterRm := loadLockedReserve(db, h.caller, bidAsset)
if availAfterRm.Cmp(availBefore) != 0 || lockedAfterRm.Sign() != 0 {
t.Fatalf("after REMOVE: available=%s locked=%s (want %s/0)", availAfterRm, lockedAfterRm, availBefore)
if loadRestingOrder(db, orderID).Status != OrderStatusClosing {
t.Fatal("position must be CLOSING after a withdraw request")
}
if loadRestingOrder(db, orderID).Status != OrderStatusCancelled {
t.Fatal("order must be CANCELLED after full REMOVE")
// committedPositions is UNCHANGED by the withdraw request (the debit happens only
// when the LP consumes the D->C collect object). Conservation still holds.
if loadCommittedPositions(db, bidAsset).Int64() != committedAfter.Int64() {
t.Fatal("a withdraw request must not change committedPositions (D->C collect debits it)")
}
h.vaultInvariantNative(t, "after withdraw request")
}
// TestModifyLiquidity_InsufficientReserveReverts: ADD beyond available reverts.
func TestModifyLiquidity_InsufficientReserveReverts(t *testing.T) {
// TestModifyLiquidity_InsufficientBalanceReverts: a COMMIT beyond the caller's
// CSpendable balance reverts (the funds must come from the caller's real balance —
// no mint, no commit of value the LP does not hold).
func TestModifyLiquidity_InsufficientBalanceReverts(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
bidAsset := assetID(h.key.Currency0)
fundClaim(newPoolStateAdapter(h.state), h.caller, bidAsset, big.NewInt(100))
h.fundCallerNative(100)
var salt [32]byte
if _, _, err := h.c.Run(h.state, h.caller, poolManagerAddr9999,
modLiqCalldata(h.key, -60, 60, big.NewInt(500), salt, MakerSideBid), 5_000_000, false); err != ErrMakerInsufficient {
t.Fatalf("ADD beyond reserve must revert ErrMakerInsufficient, got: %v", err)
modLiqCalldata(h.key, -60, 60, big.NewInt(500), salt, MakerSideBid), 5_000_000, false); err != ErrNativeFundsShort {
t.Fatalf("COMMIT beyond balance must revert ErrNativeFundsShort, got: %v", err)
}
}
// TestModifyLiquidity_CrossOwnerCancelReverts: account B cannot cancel account A's
// order. The orderID binds owner; B's derived id is a DIFFERENT (empty) order, and
// even a forged id with A's owner stored reverts ErrMakerNotOwner.
// position. The positionID binds owner; B's derived id is a DIFFERENT (empty)
// position, so a withdraw request for it reverts ErrMakerNotOwner.
func TestModifyLiquidity_CrossOwnerCancelReverts(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
alice := h.caller
bob := common.HexToAddress("0xB0B0000000000000000000000000000000000000")
bidAsset := assetID(h.key.Currency0)
db := newPoolStateAdapter(h.state)
fundClaim(db, alice, bidAsset, big.NewInt(1000))
h.fundCallerNative(1000)
var salt [32]byte
salt[31] = 0x09
// Alice opens.
// Alice commits.
if _, _, err := h.c.Run(h.state, alice, poolManagerAddr9999,
modLiqCalldata(h.key, -60, 60, big.NewInt(300), salt, MakerSideBid), 5_000_000, false); err != nil {
t.Fatalf("alice ADD: %v", err)
t.Fatalf("alice COMMIT: %v", err)
}
// Bob tries to cancel WITH ALICE'S salt/range. Bob's derived orderID differs
// (owner is folded into the id), so there is no such order for Bob => revert.
// Bob tries to cancel WITH ALICE'S salt/range. Bob's derived positionID differs
// (owner is folded into the id), so there is no such position for Bob => revert.
if _, _, err := h.c.Run(h.state, bob, poolManagerAddr9999,
modLiqCalldata(h.key, -60, 60, big.NewInt(-300), salt, MakerSideBid), 5_000_000, false); err != ErrMakerNotOwner {
t.Fatalf("cross-owner cancel must revert ErrMakerNotOwner, got: %v", err)
}
// Alice's order is still OPEN and her locked reserve untouched.
if loadLockedReserve(db, alice, bidAsset).Int64() != 300 {
t.Fatal("alice's locked reserve must be untouched by bob's failed cancel")
// Alice's position is still OPEN (Committed) — bob's failed cancel did not close it.
aliceOrder := MakerOrderID(alice, h.key.ID(), salt, -60, 60)
if loadRestingOrder(db, aliceOrder).Status != OrderStatusOpen {
t.Fatal("alice's position must remain OPEN (Committed) after bob's failed cancel")
}
}
// TestModifyLiquidity_ReentrancyGuard: while the shared 0x9999 custody guard is
// held (as it would be inside a malicious token's transferFrom callback during a
// deposit/withdraw), a nested modifyLiquidity reverts ErrCustodyReentrant before
// moving any reserve. Simulated by setting the guard slot directly.
// held (as inside a malicious token's transferFrom callback during a custody op), a
// nested modifyLiquidity reverts ErrCustodyReentrant before committing any value.
func TestModifyLiquidity_ReentrancyGuard(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
bidAsset := assetID(h.key.Currency0)
db := newPoolStateAdapter(h.state)
fundClaim(db, h.caller, bidAsset, big.NewInt(1000))
h.fundCallerNative(1000)
// Simulate "a custody op is already in progress" (the guard is held).
db.SetState(poolManagerAddr9999, custodyGuardKey9999, common.Hash{31: 1})
var salt [32]byte
salt[31] = 0x21
availBefore := loadDepositorClaim(db, h.caller, bidAsset)
balBefore := h.state.stateDB.GetBalance(h.caller).ToBig()
committedBefore := loadCommittedPositions(db, bidAsset)
if _, _, err := h.c.Run(h.state, h.caller, poolManagerAddr9999,
modLiqCalldata(h.key, -60, 60, big.NewInt(100), salt, MakerSideBid), 5_000_000, false); err != ErrCustodyReentrant {
t.Fatalf("nested modifyLiquidity must revert ErrCustodyReentrant, got: %v", err)
}
if loadDepositorClaim(db, h.caller, bidAsset).Cmp(availBefore) != 0 {
t.Fatal("reentrant-refused modifyLiquidity must move no reserve")
if h.state.stateDB.GetBalance(h.caller).ToBig().Cmp(balBefore) != 0 {
t.Fatal("reentrant-refused modifyLiquidity must move no value")
}
if loadCommittedPositions(db, bidAsset).Cmp(committedBefore) != 0 {
t.Fatal("reentrant-refused modifyLiquidity must not commit to the LP pot")
}
}
@@ -484,25 +492,25 @@ func TestPositionManager_EnumerateAndCancelRoutesTo9999(t *testing.T) {
bidAsset := assetID(h.key.Currency0)
db := newPoolStateAdapter(h.state)
fundClaim(db, h.caller, bidAsset, big.NewInt(1000))
h.fundCallerNative(1000)
var salt [32]byte
salt[31] = 0x11
// openPosition (ADD via 0x9996) routes into the 0x9999 kernel: reserve locks.
// openPosition (ADD via 0x9996) routes into the 0x9999 kernel: COMMITS to D.
if _, _, err := pm.Run(h.state, h.caller, positionManagerAddr,
pmLifecycleCalldata(SelectorPMOpenPosition, h.key, -120, 120, big.NewInt(500), salt, MakerSideBid), 5_000_000, false); err != nil {
t.Fatalf("openPosition: %v", err)
}
if loadLockedReserve(db, h.caller, bidAsset).Int64() != 500 {
t.Fatal("openPosition must lock reserve via the 0x9999 kernel")
if loadCommittedPositions(db, bidAsset).Int64() != 500 {
t.Fatal("openPosition must commit to D via the 0x9999 kernel (committedPositions += 500)")
}
orderID := MakerOrderID(h.caller, h.key.ID(), salt, -120, 120)
if loadRestingOrder(db, orderID).Status != OrderStatusOpen {
t.Fatal("openPosition must record an OPEN order in 0x9999 state")
t.Fatal("openPosition must record an OPEN (Committed) position in 0x9999 state")
}
// positionsOf(owner) enumerates the OPEN order.
// positionsOf(owner) enumerates the OPEN position.
out, _, err := pm.Run(h.state, h.caller, positionManagerAddr, prependSelector(SelectorPMPositionsOf, leftPad32(h.caller.Bytes())), 5_000_000, true)
if err != nil {
t.Fatalf("positionsOf: %v", err)
@@ -510,10 +518,10 @@ func TestPositionManager_EnumerateAndCancelRoutesTo9999(t *testing.T) {
// bytes32[]: offset(0x20) | len | elems.
n := new(big.Int).SetBytes(out[32:64]).Int64()
if n != 1 {
t.Fatalf("positionsOf must list 1 open order, got %d", n)
t.Fatalf("positionsOf must list 1 open position, got %d", n)
}
if common.BytesToHash(out[64:96]) != common.BytesToHash(orderID[:]) {
t.Fatal("positionsOf must list the opened orderID")
t.Fatal("positionsOf must list the opened positionID")
}
// positionInfo(orderID) returns the record.
@@ -525,30 +533,32 @@ func TestPositionManager_EnumerateAndCancelRoutesTo9999(t *testing.T) {
t.Fatal("positionInfo owner mismatch")
}
if new(big.Int).SetBytes(pi[128:160]).Int64() != 500 {
t.Fatal("positionInfo lockedAmount must be 500")
t.Fatal("positionInfo committed amount must be 500")
}
// cancelLimit (REMOVE via 0x9996) routes into the kernel: reserve unlocks, order
// CANCELLED, and positionsOf drops it.
// collect (via 0x9996) emits a D->C collect REQUEST (returns the positionID) — the
// actual C credit lands when the LP consumes the resulting object via collectPosition.
cOut, _, err := pm.Run(h.state, h.caller, positionManagerAddr,
pmLifecycleCalldata(SelectorPMCollect, h.key, -120, 120, big.NewInt(1), salt, MakerSideBid), 5_000_000, false)
if err != nil {
t.Fatalf("collect request must succeed (emit event), got: %v", err)
}
if common.BytesToHash(cOut) != common.BytesToHash(orderID[:]) {
t.Fatal("collect request must return the positionID")
}
// cancelLimit (REMOVE via 0x9996) routes into the kernel: marks the position
// CLOSING (the withdraw request); positionsOf drops it (only OPEN listed).
if _, _, err := pm.Run(h.state, h.caller, positionManagerAddr,
pmLifecycleCalldata(SelectorPMCancelLimit, h.key, -120, 120, big.NewInt(500), salt, MakerSideBid), 5_000_000, false); err != nil {
t.Fatalf("cancelLimit: %v", err)
}
if loadLockedReserve(db, h.caller, bidAsset).Sign() != 0 {
t.Fatal("cancelLimit must unlock reserve via the 0x9999 kernel")
}
if loadRestingOrder(db, orderID).Status != OrderStatusCancelled {
t.Fatal("cancelLimit must mark the order CANCELLED")
if loadRestingOrder(db, orderID).Status != OrderStatusClosing {
t.Fatal("cancelLimit must mark the position CLOSING (withdraw requested)")
}
out2, _, _ := pm.Run(h.state, h.caller, positionManagerAddr, prependSelector(SelectorPMPositionsOf, leftPad32(h.caller.Bytes())), 5_000_000, true)
if new(big.Int).SetBytes(out2[32:64]).Int64() != 0 {
t.Fatal("positionsOf must be empty after cancel (CANCELLED skipped)")
}
// collect reverts ErrUnsupported (fees are D-authoritative).
if _, _, err := pm.Run(h.state, h.caller, positionManagerAddr,
pmLifecycleCalldata(SelectorPMCollect, h.key, -120, 120, big.NewInt(1), salt, MakerSideBid), 5_000_000, false); err != ErrUnsupported {
t.Fatalf("collect must revert ErrUnsupported, got: %v", err)
t.Fatal("positionsOf must be empty after cancel (only OPEN listed)")
}
}
@@ -560,8 +570,7 @@ func TestPositionManager_CrossOwnerCancelReverts(t *testing.T) {
pm := PositionManagerPrecompile
alice := h.caller
bob := common.HexToAddress("0xB0B0000000000000000000000000000000000000")
bidAsset := assetID(h.key.Currency0)
fundClaim(newPoolStateAdapter(h.state), alice, bidAsset, big.NewInt(1000))
h.fundCallerNative(1000)
var salt [32]byte
salt[31] = 0x13