mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
Move once, trade many. Value crosses the C<->D boundary twice in its life; in
between, D owns it and every order, match and trade is a local balance movement.
So the crossing has exactly one job — move ownership exactly once — and the
object shrinks to what that job needs:
beneficiary(20) | asset(32) | amount(8) = 60 bytes
Out of the wire: the 49-byte market/side/limit/size tail, the `spent` execution
witness, and the lane byte selecting a trading pot. Out of the precompile: the
per-order escrow with its principal cap and deadline, the taker-authenticated
price floor, the deadline reclaim and its selector, the shared-memory discovery
trait, and the .v2 domain rotation that existed only because the tail had
changed the object's width.
ONE CUSTODY POT. seamReserve and committedPositions were the swap lane and the
LP lane of one quantity — "value C holds because D owns it" — and the lane byte
was the only thing keeping them apart. Splitting them is what created the
cross-lane drain the byte then had to defend against. One number per asset, one
credit path, nothing to cross.
WHAT REPLACES THE PER-ORDER CAP AND THE PRICE FLOOR: D refuses an over-debit on
export, C refuses a credit custody cannot back, a claim is consumable exactly
once, and Block.Verify proves the carried bytes are the recorded object. Those
are properties of the value. The cap and the floor were properties of an order,
which is why the order had to ride the object for them to be checkable at all.
The claim id is one derivation for both directions and both repos:
SHA-256(domain | source | dest | sourceTx | index). It replaces DeriveOrderID,
DerivePositionCommitID and deriveSeamOutputID, which were three spellings of the
same fact.
Co-authored-by: Hanzo Dev <[email protected]>
231 lines
11 KiB
Go
231 lines
11 KiB
Go
// 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/holiman/uint256"
|
|
"github.com/luxfi/geth/common"
|
|
"github.com/luxfi/ids"
|
|
)
|
|
|
|
// native_conservation_test.go is the FIX-3 regression suite: the conservation
|
|
// blast-radius between the SEAM (Phase-A lock / Phase-B credit) and CUSTODY
|
|
// (deposit / withdraw), which share the 0x9999 vault ACCOUNT but must keep DISJOINT
|
|
// claims on it.
|
|
//
|
|
// THE BUG (Red-found): both subsystems wrote ONE pot (settleVault[a]), and a Phase-B
|
|
// settlement credit checked only the TOTAL settleVault[a] >= amount. So a settlement
|
|
// could pay a taker out of a custody DEPOSITOR's withdrawable claim, and a depositor's
|
|
// withdraw could drain the pot and strand a legitimately-backed settlement.
|
|
//
|
|
// THE FIX: the seam owns a separate seamReserve[a] pot; settleVault[a] is now PURELY
|
|
// the depositor pot (== Σ depositorClaim). The vault-account invariant per asset a is
|
|
//
|
|
// realHolding(0x9999, a) == settleVault[a] + seamReserve[a] + committedPositions[a]
|
|
//
|
|
// and a settlement credit draws ONLY seamReserve[a], a withdraw ONLY settleVault[a].
|
|
// 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 the FULL vault-account invariant for the native asset:
|
|
//
|
|
// realHolding == settleVault + seamReserve + committedPositions
|
|
//
|
|
// the three orthogonal pots that partition the 0x9999 vault by CLAIM (depositor pot,
|
|
// swap-rail seam reserve, LP-rail committed positions). Each pot backs only its own
|
|
// credits; none can raid another. (The vestigial makerLockedVault pot — always 0, never
|
|
// written — was removed in the settle-only decomplect.)
|
|
func (h *settleHarness) vaultInvariantNative(t testing.TB, where string) {
|
|
t.Helper()
|
|
db := newPoolStateAdapter(h.state)
|
|
native := [32]byte{}
|
|
real := h.state.stateDB.GetBalance(poolManagerAddr9999).ToBig()
|
|
sum := new(big.Int).Set(loadSettleVault(db, native))
|
|
sum.Add(sum, loadCustody(db, native))
|
|
if real.Cmp(sum) != 0 {
|
|
t.Fatalf("%s: vault-account invariant violated: realHolding=%s != settleVault+custody=%s", where, real, sum)
|
|
}
|
|
}
|
|
|
|
// depositNative funds a depositor's claim through the custody deposit selector using
|
|
// the native observed-delta path: the EVM moves msg.value into 0x9999 before Run, so
|
|
// we add the balance first (the host frame), then call deposit(asset=0, amount).
|
|
func (h *settleHarness) depositNative(t testing.TB, depositor common.Address, amount int64) {
|
|
t.Helper()
|
|
h.state.stateDB.AddBalance(depositor, uint256.NewInt(uint64(amount)))
|
|
h.state.stateDB.AddBalance(poolManagerAddr9999, uint256.NewInt(uint64(amount))) // host frame moved msg.value
|
|
data := make([]byte, 64) // asset = address(0) (native), amount
|
|
new(big.Int).SetInt64(amount).FillBytes(data[32:64])
|
|
_, _, err := h.c.Run(h.state, depositor, poolManagerAddr9999,
|
|
prependSelector(SelectorDeposit, data), 5_000_000, false)
|
|
if err != nil {
|
|
t.Fatalf("depositNative(%d): %v", amount, err)
|
|
}
|
|
}
|
|
|
|
// withdrawNative pulls a depositor's claim back through the custody withdraw selector.
|
|
func (h *settleHarness) withdrawNative(t testing.TB, depositor common.Address, amount int64) {
|
|
t.Helper()
|
|
data := make([]byte, 64) // asset = address(0), want
|
|
new(big.Int).SetInt64(amount).FillBytes(data[32:64])
|
|
_, _, err := h.c.Run(h.state, depositor, poolManagerAddr9999,
|
|
prependSelector(SelectorWithdraw, data), 5_000_000, false)
|
|
if err != nil {
|
|
t.Fatalf("withdrawNative(%d): %v", amount, err)
|
|
}
|
|
}
|
|
|
|
// TestFIX3_SettlementCannotRaidDepositorClaim — a Phase-B settlement credit draws ONLY
|
|
// on the seam reserve. With a fat depositor claim in settleVault but ZERO seam reserve,
|
|
// a settlement that consumes a real D->C object REVERTS (unbacked) — it can NOT pay the
|
|
// taker out of the depositor's funds. The OLD code (one pot, total check) would have
|
|
// let it through and silently un-backed the depositor's claim.
|
|
func TestFIX3_SettlementCannotRaidDepositorClaim(t *testing.T) {
|
|
h := newSettleHarness(t)
|
|
depositor := common.HexToAddress("0xD0905170000000000000000000000000000000A1")
|
|
native := [32]byte{}
|
|
|
|
// A depositor funds the vault — this is the DEPOSITOR pot (settleVault), NOT seam.
|
|
h.depositNative(t, depositor, 1000)
|
|
if loadDepositorClaim(newPoolStateAdapter(h.state), depositor, native).Int64() != 1000 {
|
|
t.Fatal("depositor claim must be 1000")
|
|
}
|
|
if loadCustody(newPoolStateAdapter(h.state), native).Sign() != 0 {
|
|
t.Fatal("seam reserve must be ZERO (no operator seed, no order locks)")
|
|
}
|
|
h.vaultInvariantNative(t, "after deposit")
|
|
|
|
// D exported a D->C native settlement object for the taker. The vault HAS 1000
|
|
// native (the depositor's), but the seam reserve is 0.
|
|
outputID := ids.ID{0x5E, 0x77}
|
|
h.putDtoCObject(t, h.caller, outputID, native, 500)
|
|
|
|
credited, err := h.c.atomicImport(h.state, Claim{
|
|
ID: outputID, Asset: native, Beneficiary: h.caller,
|
|
Object: encodeClaim(h.caller, native, 500),
|
|
})
|
|
if err != ErrCustodyUnbacked {
|
|
t.Fatalf("BLAST RADIUS: a settlement with empty seam reserve must REVERT (ErrCustodyUnbacked), not raid the depositor pot; got credited=%d err=%v", credited, err)
|
|
}
|
|
// The depositor's claim is untouched — they can still withdraw all 1000.
|
|
if loadDepositorClaim(newPoolStateAdapter(h.state), depositor, native).Int64() != 1000 {
|
|
t.Fatal("depositor claim must remain 1000 after the refused settlement")
|
|
}
|
|
h.vaultInvariantNative(t, "after refused settlement")
|
|
}
|
|
|
|
// TestFIX3_WithdrawCannotStrandSettlement — a depositor withdrawing their FULL claim
|
|
// touches only settleVault; the seam reserve (operator-seeded) is untouched, so a
|
|
// backed settlement still succeeds afterward. The OLD code's shared pot let a withdraw
|
|
// drain the funds a settlement relied on.
|
|
func TestFIX3_WithdrawCannotStrandSettlement(t *testing.T) {
|
|
h := newSettleHarness(t)
|
|
depositor := common.HexToAddress("0xD0905170000000000000000000000000000000A2")
|
|
native := [32]byte{}
|
|
|
|
// Operator seeds the SEAM reserve (counterparty backing for settlements).
|
|
h.fundVaultNativeOut(500) // -> custody[native] = 500, real += 500
|
|
// A depositor independently funds their claim (depositor pot).
|
|
h.depositNative(t, depositor, 1000) // -> settleVault[native] = 1000, real += 1000
|
|
h.vaultInvariantNative(t, "after seed + deposit")
|
|
|
|
// The depositor withdraws their ENTIRE claim. This must NOT reach the seam reserve.
|
|
h.withdrawNative(t, depositor, 1000)
|
|
db := newPoolStateAdapter(h.state)
|
|
if loadDepositorClaim(db, depositor, native).Sign() != 0 {
|
|
t.Fatal("depositor claim must be 0 after full withdraw")
|
|
}
|
|
if loadCustody(db, native).Int64() != 500 {
|
|
t.Fatalf("seam reserve must remain 500 after the depositor's withdraw, got %s", loadCustody(db, native))
|
|
}
|
|
h.vaultInvariantNative(t, "after full withdraw")
|
|
|
|
// A backed settlement (consuming a real D->C object) still succeeds — the seam
|
|
// reserve was never raided by the withdraw.
|
|
outputID := ids.ID{0x5E, 0x78}
|
|
h.putDtoCObject(t, h.caller, outputID, native, 500)
|
|
credited, err := h.c.atomicImport(h.state, Claim{
|
|
ID: outputID, Asset: native, Beneficiary: h.caller,
|
|
Object: encodeClaim(h.caller, native, 500),
|
|
})
|
|
if err != nil || credited != 500 {
|
|
t.Fatalf("a seam-backed settlement must succeed after a depositor withdraw: credited=%d err=%v", credited, err)
|
|
}
|
|
if loadCustody(db, native).Sign() != 0 {
|
|
t.Fatal("seam reserve must be 0 after the settlement consumed it")
|
|
}
|
|
h.vaultInvariantNative(t, "after backed settlement")
|
|
}
|
|
|
|
// TestFIX3_VaultInvariantAcrossBothSubsystems — the FIX-3 vault-account invariant holds
|
|
// across a full interleaving of BOTH subsystems on the SAME native asset: operator
|
|
// seed, depositor deposit, Phase-A order lock, Phase-B settlement credit, depositor
|
|
// withdraw. After every step realHolding == settleVault + makerLockedVault + seamReserve.
|
|
func TestFIX3_VaultInvariantAcrossBothSubsystems(t *testing.T) {
|
|
h := newSettleHarness(t)
|
|
h.registerMarket(t)
|
|
depositor := common.HexToAddress("0xD0905170000000000000000000000000000000A3")
|
|
native := [32]byte{}
|
|
|
|
h.vaultInvariantNative(t, "genesis")
|
|
|
|
// 1) Operator seeds the seam reserve (so a settlement of the SWAP'S INPUT asset —
|
|
// native here — can be refunded/settled back).
|
|
h.fundVaultNativeOut(2000)
|
|
h.vaultInvariantNative(t, "after seed")
|
|
|
|
// 2) A custody depositor funds their claim of the SAME native asset.
|
|
h.depositNative(t, depositor, 3000)
|
|
h.vaultInvariantNative(t, "after deposit")
|
|
|
|
// 3) A taker submits a Phase-A order that LOCKS native tokenIn into the seam
|
|
// reserve (the caller funds it). seamReserve grows; settleVault (depositor) is
|
|
// untouched.
|
|
h.fundCallerNative(1000)
|
|
seamBeforeLock := loadCustody(newPoolStateAdapter(h.state), native)
|
|
if _, err := h.runSwap(t, h.crossCalldata(), false); err != nil {
|
|
t.Fatalf("phase-A order: %v", err)
|
|
}
|
|
seamAfterLock := loadCustody(newPoolStateAdapter(h.state), native)
|
|
if new(big.Int).Sub(seamAfterLock, seamBeforeLock).Int64() != 100 { // AmountSpecified = -100
|
|
t.Fatalf("order must add 100 to the seam reserve, delta=%s", new(big.Int).Sub(seamAfterLock, seamBeforeLock))
|
|
}
|
|
if loadDepositorClaim(newPoolStateAdapter(h.state), depositor, native).Int64() != 3000 {
|
|
t.Fatal("a Phase-A order lock must NOT touch the depositor pot")
|
|
}
|
|
h.vaultInvariantNative(t, "after order lock")
|
|
|
|
// 4) A backed Phase-B settlement credits the taker out of the seam reserve only.
|
|
outputID := ids.ID{0x5E, 0x79}
|
|
h.putDtoCObject(t, h.caller, outputID, native, 1500)
|
|
seamBeforeCredit := loadCustody(newPoolStateAdapter(h.state), native)
|
|
credited, err := h.c.atomicImport(h.state, Claim{
|
|
ID: outputID, Asset: native, Beneficiary: h.caller,
|
|
Object: encodeClaim(h.caller, native, 1500),
|
|
})
|
|
if err != nil || credited != 1500 {
|
|
t.Fatalf("backed settlement: credited=%d err=%v", credited, err)
|
|
}
|
|
seamAfterCredit := loadCustody(newPoolStateAdapter(h.state), native)
|
|
if new(big.Int).Sub(seamBeforeCredit, seamAfterCredit).Int64() != 1500 {
|
|
t.Fatalf("settlement must debit 1500 from the seam reserve, delta=%s", new(big.Int).Sub(seamBeforeCredit, seamAfterCredit))
|
|
}
|
|
if loadDepositorClaim(newPoolStateAdapter(h.state), depositor, native).Int64() != 3000 {
|
|
t.Fatal("a settlement credit must NOT touch the depositor pot")
|
|
}
|
|
h.vaultInvariantNative(t, "after settlement credit")
|
|
|
|
// 5) The depositor withdraws part of their claim — settleVault only.
|
|
h.withdrawNative(t, depositor, 1000)
|
|
if loadDepositorClaim(newPoolStateAdapter(h.state), depositor, native).Int64() != 2000 {
|
|
t.Fatal("depositor claim must be 2000 after withdrawing 1000")
|
|
}
|
|
h.vaultInvariantNative(t, "after partial withdraw")
|
|
}
|
|
|
|
var _ = ids.Empty
|