mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
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:
@@ -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
@@ -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
@@ -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(ðtypes.Log{
|
||||
Address: poolManagerAddr9999,
|
||||
Topics: []common.Hash{
|
||||
nativeIntentEventSig,
|
||||
common.BytesToHash(intentID[:]),
|
||||
common.BytesToHash(objectID[:]),
|
||||
common.BytesToHash(dChainID[:]),
|
||||
},
|
||||
Data: data,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user