precompile/dex: PoolManager → pure ingress + native lock-vault (kill the reserve); deposit/withdraw/balanceOf

Removed settleNativeLegs (the AMM-style native reserve). Native LUX is now lock/mint: real LUX
locked in 0x9010 vault, canonical balance minted in D-Chain available[caller][LUX]; withdraw burns
+ releases (refuses over-release). Added deposit/withdraw/balanceOf selectors + txHash-keyed
idempotency. Swap now BINDS caller identity into the submit frame (was zero → settlement fell back
to C-Chain) + InitializePool calls clob_open_market (markets were unbound → no-custody fallback).
ERC-20 deposit/withdraw return ErrERC20DepositUnsupported (explicit revert, NO fake reserve) pending
a real lock/mint Call() seam. Invariant balanceOf(0x9010)==Σavailable[LUX]+Σlocked[LUX].
This commit is contained in:
zeekay
2026-06-17 22:30:06 -07:00
parent bdb309d439
commit bfa49d10ff
13 changed files with 1448 additions and 95 deletions
+323
View File
@@ -0,0 +1,323 @@
// Command evmcustody is the LIVE EVM 0x9010 CUSTODY e2e on localnet 1337. It
// proves the CORRECTED CLOB custody model through real signed C-Chain
// transactions to the V4 PoolManager precompile:
//
// deposit(address(0), amount) with msg.value == amount
// -> EVM locks msg.value in the 0x9010 vault (the C-Chain lock leg)
// -> precompile relays clob_deposit -> MINTS D-Chain available[caller][LUX]
// modifyLiquidity(+) (place a resting order)
// -> the maker's D-Chain available -> locked; the 0x9010 vault is UNCHANGED
// (NO native reserve backs a resting order — the no-reserve gate)
// withdraw(address(0), want)
// -> precompile relays clob_withdraw -> BURNS D-Chain available
// -> RELEASES exactly the realized amount from the 0x9010 vault to the caller
//
// THE RELEASE GATE PROVEN: at every step
//
// balanceOf(0x9010 native) == Σ D-Chain available[*][LUX] + Σ D-Chain locked[*][LUX]
//
// and across deposit->withdraw, C-Chain value out == C-Chain value in. There is
// NO autoSettle, NO PoolManager reserve funding a trade, NO balance poke.
//
// Funding is a localnet genesis account derived from the LightMnemonic dev seed
// (m/44'/60'/0'/0/0) — NOT an EWOQ key.
package main
import (
"context"
"encoding/binary"
"flag"
"fmt"
"math/big"
"os"
"time"
dex "github.com/luxfi/precompile/dex"
geth "github.com/luxfi/geth"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/types"
"github.com/luxfi/geth/ethclient"
)
var lxpool = common.HexToAddress("0x0000000000000000000000000000000000009010")
const (
selDeposit = 0x47E7EF24 // deposit(address,uint256)
selWithdraw = 0xF3FEF3A3 // withdraw(address,uint256)
selBalanceOf = 0xF7888AEC // balanceOf(address,address)
selInitialize = 0x6276CBBE
selModifyLiquidity = 0x5A6BCFDA
)
func sel(s uint32) []byte { b := make([]byte, 4); binary.BigEndian.PutUint32(b, s); return b }
func word(v *big.Int) []byte {
out := make([]byte, 32)
v.FillBytes(out)
return out
}
func addrWord(a common.Address) []byte {
out := make([]byte, 32)
copy(out[12:], a.Bytes())
return out
}
func i256(v *big.Int) []byte {
out := make([]byte, 32)
if v.Sign() >= 0 {
v.FillBytes(out)
return out
}
new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 256), v).FillBytes(out)
return out
}
func sqrtPriceX96(price float64) *big.Int {
q96 := new(big.Float).SetPrec(256)
q96.SetInt(new(big.Int).Lsh(big.NewInt(1), 96))
sp := new(big.Float).SetPrec(256).Sqrt(big.NewFloat(price))
sp.Mul(sp, q96)
r, _ := sp.Int(nil)
return r
}
func encDeposit(asset common.Address, amount *big.Int) []byte {
return append(append(sel(selDeposit), addrWord(asset)...), word(amount)...)
}
func encWithdraw(asset common.Address, want *big.Int) []byte {
return append(append(sel(selWithdraw), addrWord(asset)...), word(want)...)
}
func encBalanceOf(account, asset common.Address) []byte {
return append(append(sel(selBalanceOf), addrWord(account)...), addrWord(asset)...)
}
func encInitialize(k dex.PoolKey, sqrtP *big.Int) []byte {
return append(append(sel(selInitialize), dex.EncodePoolKeyABI(k)...), word(sqrtP)...)
}
func encModifyLiquidity(k dex.PoolKey, tickLower, tickUpper int32, liqDelta *big.Int, salt [32]byte) []byte {
out := append(sel(selModifyLiquidity), dex.EncodePoolKeyABI(k)...)
out = append(out, i256(big.NewInt(int64(tickLower)))...)
out = append(out, i256(big.NewInt(int64(tickUpper)))...)
out = append(out, i256(liqDelta)...)
out = append(out, salt[:]...)
return out
}
var okAll = true
func check(name string, got, want *big.Int) {
if got.Cmp(want) != 0 {
fmt.Printf(" ASSERT FAIL %s: got %s want %s\n", name, got, want)
okAll = false
} else {
fmt.Printf(" ASSERT OK %s = %s\n", name, got)
}
}
func main() {
rpcURL := flag.String("rpc", "http://127.0.0.1:9650/ext/bc/C/rpc", "C-Chain RPC")
pkHex := flag.String("pk", "ed0c0416e953639c0ae02e313c2c73a84dd509937e64330b303342d16af7394e", "funded genesis privkey (LightMnemonic idx0 — NOT ewoq)")
flag.Parse()
ctx := context.Background()
cl, err := ethclient.Dial(*rpcURL)
must("dial", err)
chainID, err := cl.ChainID(ctx)
must("chainID", err)
pk, err := crypto.HexToECDSA(*pkHex)
must("key", err)
from := common.BytesToAddress(crypto.PubkeyToAddress(pk.PublicKey).Bytes())
fmt.Printf("EVM-CUSTODY e2e rpc=%s chainID=%s from=%s\n", *rpcURL, chainID, from.Hex())
native := common.Address{} // address(0) = native LUX
// Helpers ----------------------------------------------------------------
gp, err := cl.SuggestGasPrice(ctx)
must("gasprice", err)
// Track gas spend so the conservation accounting nets out fees explicitly.
totalGasWei := big.NewInt(0)
send := func(label string, data []byte, value *big.Int) *types.Receipt {
nonce, e := cl.PendingNonceAt(ctx, from)
must(label+" nonce", e)
tx := types.NewTx(&types.LegacyTx{Nonce: nonce, GasPrice: gp, Gas: 5_000_000, To: &lxpool, Value: value, Data: data})
signed, e := types.SignTx(tx, types.LatestSignerForChainID(chainID), pk)
must(label+" sign", e)
if e := cl.SendTransaction(ctx, signed); e != nil {
fmt.Printf(" %-18s SEND ERROR: %v\n", label, e)
return nil
}
r := waitReceipt(ctx, cl, signed.Hash())
if r == nil {
fmt.Printf(" %-18s tx=%s NO RECEIPT\n", label, signed.Hash().Hex())
okAll = false
return nil
}
st := "SUCCESS"
if r.Status == 0 {
st = "REVERT"
}
gasWei := new(big.Int).Mul(new(big.Int).SetUint64(r.GasUsed), gp)
totalGasWei.Add(totalGasWei, gasWei)
fmt.Printf(" %-18s tx=%s status=%d(%s) block=%d gasUsed=%d\n", label, r.TxHash.Hex(), r.Status, st, r.BlockNumber.Uint64(), r.GasUsed)
return r
}
// dchainAvail reads the D-Chain available balance for an asset via the
// read-only balanceOf(account, asset) view selector (eth_call, no tx).
dchainAvail := func(account, asset common.Address) *big.Int {
out, e := cl.CallContract(ctx, geth.CallMsg{From: from, To: &lxpool, Data: encBalanceOf(account, asset)}, nil)
if e != nil || len(out) < 32 {
fmt.Printf(" balanceOf err: %v\n", e)
return big.NewInt(0)
}
return new(big.Int).SetBytes(out[:32])
}
vault := func() *big.Int { b, _ := cl.BalanceAt(ctx, lxpool, nil); return b }
wallet := func() *big.Int { b, _ := cl.BalanceAt(ctx, from, nil); return b }
// ------------------------------------------------------------------------
w0 := wallet()
v0 := vault()
fmt.Printf("\nINITIAL wallet=%s vault(0x9010)=%s\n", w0, v0)
// ==== STEP 1: DEPOSIT native LUX into the D-Chain ledger ====
// deposit(address(0), amount) WITH msg.value == amount. The EVM locks the
// value into the 0x9010 vault; the precompile mints D-Chain available.
fmt.Println("\n--- STEP 1: deposit 100000 wei native LUX (msg.value == amount) ---")
depAmt := big.NewInt(100000)
r1 := send("deposit", encDeposit(native, depAmt), depAmt)
if r1 == nil || r1.Status == 0 {
fmt.Println("DEPOSIT failed — aborting"); finish()
}
availAfterDep := dchainAvail(from, native)
vaultAfterDep := vault()
walletAfterDep := wallet()
fmt.Printf(" after deposit: D-Chain available=%s vault=%s wallet=%s\n", availAfterDep, vaultAfterDep, walletAfterDep)
// GATE: D-Chain available increased by EXACTLY the deposit.
check("D-Chain available == deposit", availAfterDep, depAmt)
// GATE: the 0x9010 vault holds EXACTLY the deposit (lock leg), not a reserve.
check("vault == deposit (lock backing)", new(big.Int).Sub(vaultAfterDep, v0), depAmt)
// GATE: caller wallet decreased by deposit + gas (msg.value left the wallet).
walletDrop := new(big.Int).Sub(w0, walletAfterDep)
// walletDrop should equal depAmt + (gas for this tx). We assert it's >= depAmt
// and the non-gas part equals depAmt.
check("wallet drop net of gas == deposit", new(big.Int).Sub(walletDrop, gasOf(r1, gp)), depAmt)
// RELEASE INVARIANT after deposit: vault == Σ available + Σ locked.
check("INVARIANT vault == avail+locked", new(big.Int).Sub(vaultAfterDep, v0), availAfterDep)
// ==== STEP 2: initialize a native/quote market + place a resting order ====
// The no-reserve gate: placing an order LOCKS the maker's D-Chain available;
// the 0x9010 vault MUST be unchanged (no native reserve backs the order).
fmt.Println("\n--- STEP 2: initialize(native/quote, price=1) + place resting ASK ---")
quote := common.HexToAddress("0x000000000000000000000000000000000000C0DE")
k := dex.PoolKey{Currency0: dex.Currency{Address: native}, Currency1: dex.Currency{Address: quote}, Fee: 3000, TickSpacing: 60, Hooks: common.Address{}}
send("initialize", encInitialize(k, sqrtPriceX96(1)), big.NewInt(0))
vaultPrePlace := vault()
availPrePlace := dchainAvail(from, native)
// Place a resting ASK (sell native base) — locks native available into locked.
var salt [32]byte
salt[31] = 1
rPlace := send("place-ask", encModifyLiquidity(k, -60, 0, big.NewInt(1000), salt), big.NewInt(0))
vaultPostPlace := vault()
availPostPlace := dchainAvail(from, native)
fmt.Printf(" pre-place: vault=%s avail=%s | post-place: vault=%s avail=%s\n", vaultPrePlace, availPrePlace, vaultPostPlace, availPostPlace)
if rPlace != nil && rPlace.Status == 1 {
// THE NO-RESERVE GATE: vault unchanged by the place (no native reserve).
check("vault UNCHANGED by place (no reserve)", vaultPostPlace, vaultPrePlace)
// The locked funds came out of available (available decreased by the lock).
// available + locked is still == vault-v0 (conservation across the lock).
fmt.Printf(" NOTE locked moved %s out of available (stays in D-Chain ledger)\n", new(big.Int).Sub(availPrePlace, availPostPlace))
} else {
fmt.Println(" NOTE place did not rest (book/price) — vault no-reserve gate still checked:")
check("vault UNCHANGED by place (no reserve)", vaultPostPlace, vaultPrePlace)
}
// ==== STEP 3: cancel the resting order (unlock back to available) ====
// Cancel returns the locked funds to available; vault STILL unchanged.
fmt.Println("\n--- STEP 3: cancel the resting ASK (unlock locked -> available) ---")
send("cancel-ask", encModifyLiquidity(k, -60, 0, big.NewInt(-1000), salt), big.NewInt(0))
vaultPostCancel := vault()
availPostCancel := dchainAvail(from, native)
fmt.Printf(" post-cancel: vault=%s avail=%s\n", vaultPostCancel, availPostCancel)
check("vault UNCHANGED by cancel (no reserve)", vaultPostCancel, vaultPrePlace)
// ==== STEP 4: WITHDRAW everything back out ====
// withdraw(address(0), want) BURNS D-Chain available and RELEASES the vault.
fmt.Println("\n--- STEP 4: withdraw 100000 wei native LUX (burn ledger, release vault) ---")
availBeforeWdr := dchainAvail(from, native)
walletBeforeWdr := wallet()
rW := send("withdraw", encWithdraw(native, depAmt), big.NewInt(0))
availAfterWdr := dchainAvail(from, native)
vaultAfterWdr := vault()
walletAfterWdr := wallet()
fmt.Printf(" after withdraw: D-Chain available=%s vault=%s wallet=%s\n", availAfterWdr, vaultAfterWdr, walletAfterWdr)
if rW != nil && rW.Status == 1 {
// GATE: D-Chain available burned to zero.
check("D-Chain available drained", availAfterWdr, big.NewInt(0))
// GATE: vault released exactly the deposit (back to its pre-deposit level).
check("vault released to pre-deposit", vaultAfterWdr, v0)
// GATE: caller wallet got the realized amount back (net of this tx's gas).
walletGain := new(big.Int).Sub(walletAfterWdr, walletBeforeWdr)
check("wallet gain net of gas == realized", new(big.Int).Add(walletGain, gasOf(rW, gp)), availBeforeWdr)
}
// ==== STEP 5: REPLAY the withdraw — must NOT double-release ====
fmt.Println("\n--- STEP 5: replay withdraw (must release NOTHING more) ---")
vaultPreReplay := vault()
send("withdraw-replay", encWithdraw(native, depAmt), big.NewInt(0))
vaultPostReplay := vault()
availPostReplay := dchainAvail(from, native)
fmt.Printf(" after replay: vault=%s avail=%s\n", vaultPostReplay, availPostReplay)
// The ledger is empty, so a fresh withdraw realizes 0; vault unchanged.
check("replay releases nothing (vault unchanged)", vaultPostReplay, vaultPreReplay)
check("D-Chain available still drained", availPostReplay, big.NewInt(0))
// ==== RELEASE GATE: full conservation across the cycle ====
fmt.Println("\n--- RELEASE GATE: conservation across deposit -> withdraw ---")
// vault back to start, ledger empty, wallet = start - total gas (value conserved).
check("vault back to initial", vault(), v0)
check("D-Chain available zero", dchainAvail(from, native), big.NewInt(0))
walletNow := wallet()
walletDelta := new(big.Int).Sub(w0, walletNow) // total wallet outflow
fmt.Printf(" wallet: initial=%s final=%s outflow=%s totalGas=%s\n", w0, walletNow, walletDelta, totalGasWei)
// The ONLY value that left the wallet permanently is gas (deposit came back via
// withdraw). So outflow == total gas, to the wei — C-Chain value conserved.
check("wallet outflow == total gas (value conserved)", walletDelta, totalGasWei)
finish()
}
func finish() {
if okAll {
fmt.Println("\nEVM-CUSTODY RESULT: PASS (deposit->lock-vault+mint-ledger, place=no-reserve, withdraw->burn+release, replay-safe, value-conserving)")
os.Exit(0)
}
fmt.Println("\nEVM-CUSTODY RESULT: FAIL")
os.Exit(1)
}
func gasOf(r *types.Receipt, gp *big.Int) *big.Int {
if r == nil {
return big.NewInt(0)
}
return new(big.Int).Mul(new(big.Int).SetUint64(r.GasUsed), gp)
}
func waitReceipt(ctx context.Context, cl *ethclient.Client, h common.Hash) *types.Receipt {
dl := time.Now().Add(25 * time.Second)
for time.Now().Before(dl) {
r, err := cl.TransactionReceipt(ctx, h)
if err == nil && r != nil {
return r
}
time.Sleep(250 * time.Millisecond)
}
return nil
}
func must(stage string, err error) {
if err != nil {
fmt.Printf("FATAL %s: %v\n", stage, err)
os.Exit(1)
}
}
+1 -1
View File
@@ -73,7 +73,7 @@ func TestDefaultBackendIsInert(t *testing.T) {
if _, err := e.Initialize(big.NewInt(1)); !errors.Is(err, ErrDEXBackendNotConfigured) {
t.Fatalf("Initialize err = %v, want ErrDEXBackendNotConfigured", err)
}
if d, err := e.Swap(&PoolState{}, SwapParams{AmountSpecified: big.NewInt(-1)}); !errors.Is(err, ErrDEXBackendNotConfigured) || !d.IsZero() {
if d, err := e.Swap(&PoolState{}, common.Address{}, SwapParams{AmountSpecified: big.NewInt(-1)}); !errors.Is(err, ErrDEXBackendNotConfigured) || !d.IsZero() {
t.Fatalf("Swap = (%v,%v), want (zero, ErrDEXBackendNotConfigured)", d, err)
}
cd, fd, err := e.ModifyLiquidity(&PoolState{}, common.Address{}, ModifyLiquidityParams{LiquidityDelta: big.NewInt(1)})
+97
View File
@@ -0,0 +1,97 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"encoding/binary"
"testing"
"github.com/luxfi/geth/common"
)
// custody_frame_parity_test.go PINS the precompile's locally-declared custody
// wire frame to the FROZEN github.com/luxfi/dex/pkg/zapwire definitions. The
// precompile cannot import the cgo-tagged d-chain package, so it re-declares the
// frame constants (the same three-homes pattern the place/cancel/submit frames
// use). This test is the byte-parity guard: if zapwire's frame ever changes, the
// hardcoded canonical values below must be updated in lockstep across ALL homes
// (precompile, chains/dexvm/custody.go, the d-chain handler) or the wire breaks.
//
// Canonical values (github.com/luxfi/dex/pkg/zapwire, verified 2026-06-17):
//
// MethodDeposit = "clob_deposit"
// MethodWithdraw = "clob_withdraw"
// MethodOpenMarket = "clob_open_market"
// UserSize = 16
// AssetIDSize = 8
// DepositReqSize = UserSize + AssetIDSize + 8 = 32
// WithdrawReqSize = UserSize + AssetIDSize + 8 = 32
// OpenMarketReqSize = PoolIDSize(32) + AssetIDSize + AssetIDSize = 48
// BalanceRespSize = 1 + 8 = 9
// clob_balance request = UserSize + AssetIDSize = 24; response = available[8]+locked[8]
func TestCustodyFrameParity(t *testing.T) {
cases := []struct {
name string
got, want any
}{
{"ZAPMethodDeposit", ZAPMethodDeposit, "clob_deposit"},
{"ZAPMethodWithdraw", ZAPMethodWithdraw, "clob_withdraw"},
{"ZAPMethodOpenMarket", ZAPMethodOpenMarket, "clob_open_market"},
{"ZAPMethodBalance", ZAPMethodBalance, "clob_balance"},
{"zapUserSize", zapUserSize, 16},
{"zapAssetIDSize", zapAssetIDSize, 8},
{"depositReqSize", depositReqSize, 32},
{"withdrawReqSize", withdrawReqSize, 32},
{"openMarketReqSize", openMarketReqSize, 48},
{"balanceRespSize", balanceRespSize, 9},
}
for _, c := range cases {
if c.got != c.want {
t.Errorf("frame drift %s: precompile=%v != zapwire canonical=%v", c.name, c.got, c.want)
}
}
}
// TestCustodyFrameEncodings pins the exact byte layout the precompile produces
// for each custody frame, matching zapwire's Encode* output field-for-field.
func TestCustodyFrameEncodings(t *testing.T) {
// Deposit frame: user[16] + asset[8] + amount[8], byte-identical to
// zapwire.EncodeDeposit.
user := common.HexToAddress("0x35D64Ff3f618f7a17DF34DCb21be375A4686a8de")
const asset uint64 = 0x4c5558_00000001
const amount uint64 = 12345
payload := make([]byte, depositReqSize)
copy(payload[0:16], padUserHandle(user))
binary.BigEndian.PutUint64(payload[16:24], asset)
binary.BigEndian.PutUint64(payload[24:32], amount)
if len(payload) != 32 {
t.Fatalf("deposit payload len=%d want 32", len(payload))
}
if binary.BigEndian.Uint64(payload[16:24]) != asset {
t.Errorf("deposit asset field mismatch")
}
if binary.BigEndian.Uint64(payload[24:32]) != amount {
t.Errorf("deposit amount field mismatch")
}
// User field is the leading 16 bytes of the caller address.
for i := 0; i < 16; i++ {
if payload[i] != user.Bytes()[i] {
t.Errorf("deposit user byte %d mismatch", i)
}
}
// assetHandle native LUX == 0 (address(0) leading 8 bytes).
if h := assetHandle(NativeCurrency); h != 0 {
t.Errorf("native asset handle = %d, want 0", h)
}
// assetHandle of a non-zero currency == BE of its leading 8 address bytes.
// Address 0x0011223344556677.. -> handle 0x0011223344556677.
c := Currency{Address: common.HexToAddress("0x0011223344556677000000000000000000000000")}
const wantHandle uint64 = 0x0011223344556677
if h := assetHandle(c); h != wantHandle {
t.Errorf("currency asset handle = %#016x, want %#016x", h, wantHandle)
}
}
+336
View File
@@ -0,0 +1,336 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"errors"
"math/big"
"testing"
"time"
"github.com/luxfi/geth/common"
"github.com/holiman/uint256"
)
// custody_test.go exercises the CLOB CUSTODY MODEL at the precompile ingress
// boundary: native LUX DEPOSITS lock value in the 0x9010 vault and MINT a D-Chain
// available balance; orders LOCK/SETTLE that balance inside the D-Chain;
// WITHDRAWS burn the D-Chain balance and RELEASE the vault. The hard invariant
// these tests prove is that 0x9010 is a PASSIVE LOCK VAULT, never a trade
// counterparty / reserve, and that value is conserved across the rail.
//
// These run against the in-memory fakeCLOB double (which carries a minimal
// balance ledger) so they pin the precompile's adapter wire contract + the vault
// accounting. The LIVE e2e (cmd/dvenue-custody + the EVM driver) proves the same
// against the REAL D-Chain ledger.
// custodyPM builds a PoolManager wired to a fresh fakeCLOB + a tx-identified
// MockStateDB (so the deposit/withdraw idempotency bindings engage), and funds the
// caller with `seed` native LUX on C-Chain. Returns the manager, the fake book,
// the stateDB, and the caller address.
func custodyPM(t *testing.T, seed uint64) (*PoolManager, *fakeCLOB, *txStateDB, common.Address) {
t.Helper()
f := newFakeCLOB()
withFakeCLOB(t, f)
zap := NewZAPEngine("fake:0", 2*time.Second)
t.Cleanup(func() { _ = zap.Close() })
pm := NewPoolManager(zap)
sdb := &txStateDB{MockStateDB: NewMockStateDB()}
sdb.txHash = common.HexToHash("0xdead00000000000000000000000000000000000000000000000000000000beef")
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
// Fund the caller on C-Chain (their wallet balance, pre-deposit).
sdb.balances[caller] = uint256.NewInt(seed)
return pm, f, sdb, caller
}
// simulateDepositValueTransfer models the EVM's pre-dispatch value transfer: when
// a tx sends msg.value to 0x9010, geth moves it caller->0x9010 BEFORE the
// precompile runs (core/vm/evm.go Transfer precedes precompile dispatch). The
// precompile then sees the value already in the vault. We replicate that here so
// the test exercises lockNativeIntoVault's real precondition.
func simulateDepositValueTransfer(sdb *txStateDB, caller common.Address, amount uint64) {
amt := uint256.NewInt(amount)
sdb.SubBalance(caller, amt)
sdb.AddBalance(poolManagerAddr, amt)
}
// vaultBal / availBal / ledgerAvail read the three accounting surfaces.
func vaultBal(sdb *txStateDB) uint64 { return sdb.GetBalance(poolManagerAddr).Uint64() }
func walletBal(sdb *txStateDB, a common.Address) uint64 {
return sdb.GetBalance(a).Uint64()
}
func fakeAvail(f *fakeCLOB, a common.Address, asset Currency) uint64 {
f.mu.Lock()
defer f.mu.Unlock()
return f.ledger[ledgerKey(a.Bytes()[:16], assetHandle(asset))]
}
// --- HARD GATE: DEPOSIT ---
//
// C/EVM asset balance decreases AND D-Chain available increases by the EXACT
// amount; the native LUX lives in the 0x9010 vault (lock leg), NOT as a reserve
// the book draws from. Revert/replay cannot double-credit.
func TestCustody_Deposit_LocksVaultAndMintsLedger(t *testing.T) {
pm, f, sdb, caller := custodyPM(t, 1000)
const dep = uint64(250)
// EVM moves msg.value into the vault before the precompile runs.
simulateDepositValueTransfer(sdb, caller, dep)
if err := pm.Deposit(sdb, caller, NativeCurrency, new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("Deposit: %v", err)
}
// C-Chain wallet decreased by exactly dep.
if got := walletBal(sdb, caller); got != 1000-dep {
t.Fatalf("caller wallet = %d, want %d", got, 1000-dep)
}
// D-Chain available increased by exactly dep.
if got := fakeAvail(f, caller, NativeCurrency); got != dep {
t.Fatalf("D-Chain available = %d, want %d", got, dep)
}
// The native LUX is held in the 0x9010 vault as the lock backing.
if got := vaultBal(sdb); got != dep {
t.Fatalf("vault (0x9010) balance = %d, want %d", got, dep)
}
}
// A deposit not funded by msg.value (vault does not hold it) must be refused —
// never mint an unbacked D-Chain credit.
func TestCustody_Deposit_RefusesUnfunded(t *testing.T) {
pm, f, sdb, caller := custodyPM(t, 1000)
// Do NOT simulate the value transfer: the vault holds nothing.
err := pm.Deposit(sdb, caller, NativeCurrency, big.NewInt(100))
if !errors.Is(err, ErrInsufficientBalance) {
t.Fatalf("unfunded deposit err = %v, want ErrInsufficientBalance", err)
}
if got := fakeAvail(f, caller, NativeCurrency); got != 0 {
t.Fatalf("D-Chain available = %d after refused deposit, want 0 (no mint)", got)
}
}
// Replaying the SAME deposit tx (the EVM executes one tx ~5x) must credit the
// ledger exactly ONCE — the StateDB idempotency binding maps the re-exec to the
// prior result without a second mint.
func TestCustody_Deposit_ReplayDoesNotDoubleCredit(t *testing.T) {
pm, f, sdb, caller := custodyPM(t, 1000)
const dep = uint64(300)
simulateDepositValueTransfer(sdb, caller, dep)
if err := pm.Deposit(sdb, caller, NativeCurrency, new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("Deposit#1: %v", err)
}
// Replay the identical tx. The EVM does NOT re-transfer msg.value on a re-exec
// of the same tx, but even if the vault were re-funded the binding must dedup.
if err := pm.Deposit(sdb, caller, NativeCurrency, new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("Deposit#2 (replay): %v", err)
}
if got := fakeAvail(f, caller, NativeCurrency); got != dep {
t.Fatalf("D-Chain available after replay = %d, want %d (credited once)", got, dep)
}
}
// --- HARD GATE: WITHDRAW ---
//
// D-Chain available decreases; the realized amount is released from the vault to
// the caller; replay withdraw FAILS (returns the same realized once, no second
// release); a withdraw exceeding available is clamped (no mint).
func TestCustody_Withdraw_BurnsLedgerAndReleasesVault(t *testing.T) {
pm, f, sdb, caller := custodyPM(t, 1000)
const dep = uint64(400)
simulateDepositValueTransfer(sdb, caller, dep)
if err := pm.Deposit(sdb, caller, NativeCurrency, new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("Deposit: %v", err)
}
// Withdraw 150 < available 400.
sdb.txHash = common.HexToHash("0x01") // a distinct tx
realized, err := pm.Withdraw(sdb, caller, NativeCurrency, big.NewInt(150))
if err != nil {
t.Fatalf("Withdraw: %v", err)
}
if realized.Uint64() != 150 {
t.Fatalf("realized = %d, want 150", realized.Uint64())
}
// Ledger burned by 150.
if got := fakeAvail(f, caller, NativeCurrency); got != dep-150 {
t.Fatalf("D-Chain available = %d, want %d", got, dep-150)
}
// Vault released 150; still holds the remaining 250 backing.
if got := vaultBal(sdb); got != dep-150 {
t.Fatalf("vault = %d, want %d", got, dep-150)
}
// Caller wallet got the 150 back (started 1000, deposited 400, withdrew 150).
if got := walletBal(sdb, caller); got != 1000-dep+150 {
t.Fatalf("caller wallet = %d, want %d", got, 1000-dep+150)
}
}
// A replayed withdraw tx returns the SAME realized amount and does NOT release a
// second time (the binding dedups the burn+release).
func TestCustody_Withdraw_ReplayReleasesOnce(t *testing.T) {
pm, f, sdb, caller := custodyPM(t, 1000)
const dep = uint64(400)
simulateDepositValueTransfer(sdb, caller, dep)
if err := pm.Deposit(sdb, caller, NativeCurrency, new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("Deposit: %v", err)
}
sdb.txHash = common.HexToHash("0x02")
r1, err := pm.Withdraw(sdb, caller, NativeCurrency, big.NewInt(200))
if err != nil {
t.Fatalf("Withdraw#1: %v", err)
}
// Replay identical withdraw tx.
r2, err := pm.Withdraw(sdb, caller, NativeCurrency, big.NewInt(200))
if err != nil {
t.Fatalf("Withdraw#2 (replay): %v", err)
}
if r1.Uint64() != 200 || r2.Uint64() != 200 {
t.Fatalf("realized r1=%d r2=%d, want 200/200", r1.Uint64(), r2.Uint64())
}
// Released exactly once: vault = 400-200, ledger = 400-200.
if got := vaultBal(sdb); got != dep-200 {
t.Fatalf("vault = %d after replay, want %d (released once)", got, dep-200)
}
if got := fakeAvail(f, caller, NativeCurrency); got != dep-200 {
t.Fatalf("D-Chain available = %d after replay, want %d (burned once)", got, dep-200)
}
}
// A withdraw exceeding available is CLAMPED to availability; the vault never
// releases more than the ledger burned (no mint, failed-export-style safety).
func TestCustody_Withdraw_ClampsToAvailableNoMint(t *testing.T) {
pm, f, sdb, caller := custodyPM(t, 1000)
const dep = uint64(100)
simulateDepositValueTransfer(sdb, caller, dep)
if err := pm.Deposit(sdb, caller, NativeCurrency, new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("Deposit: %v", err)
}
sdb.txHash = common.HexToHash("0x03")
realized, err := pm.Withdraw(sdb, caller, NativeCurrency, big.NewInt(999)) // > available
if err != nil {
t.Fatalf("Withdraw: %v", err)
}
if realized.Uint64() != dep {
t.Fatalf("realized = %d, want %d (clamped to available)", realized.Uint64(), dep)
}
if got := fakeAvail(f, caller, NativeCurrency); got != 0 {
t.Fatalf("D-Chain available = %d, want 0 (drained)", got)
}
if got := vaultBal(sdb); got != 0 {
t.Fatalf("vault = %d, want 0 (released exactly the realized burn, no mint)", got)
}
}
// --- RELEASE GATE: the conservation invariant across deposit -> withdraw ---
//
// sum(vault) == sum(D-Chain available) at all times for a single asset; across a
// full deposit/withdraw cycle, C-Chain value out == C-Chain value in.
func TestCustody_ConservationInvariant_DepositWithdrawCycle(t *testing.T) {
pm, f, sdb, caller := custodyPM(t, 1000)
const dep = uint64(750)
simulateDepositValueTransfer(sdb, caller, dep)
if err := pm.Deposit(sdb, caller, NativeCurrency, new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("Deposit: %v", err)
}
// INVARIANT after deposit: vault == Σ available.
if vaultBal(sdb) != fakeAvail(f, caller, NativeCurrency) {
t.Fatalf("invariant breach: vault %d != Σ available %d", vaultBal(sdb), fakeAvail(f, caller, NativeCurrency))
}
// Withdraw everything.
sdb.txHash = common.HexToHash("0x04")
realized, err := pm.Withdraw(sdb, caller, NativeCurrency, new(big.Int).SetUint64(dep))
if err != nil {
t.Fatalf("Withdraw: %v", err)
}
if realized.Uint64() != dep {
t.Fatalf("realized = %d, want %d", realized.Uint64(), dep)
}
// INVARIANT after withdraw: vault == Σ available == 0.
if vaultBal(sdb) != 0 || fakeAvail(f, caller, NativeCurrency) != 0 {
t.Fatalf("invariant breach: vault %d, Σ available %d, want 0/0", vaultBal(sdb), fakeAvail(f, caller, NativeCurrency))
}
// C-Chain value conserved: caller wallet back to the initial seed.
if got := walletBal(sdb, caller); got != 1000 {
t.Fatalf("caller wallet = %d, want 1000 (C-Chain value conserved across the cycle)", got)
}
}
// --- NO-RESERVE GATE: a swap/place must NOT move native LUX caller<->0x9010 ---
//
// The OLD bug (settleNativeLegs) left native LUX sitting in 0x9010 "backing
// resting asks". This proves a place no longer touches the C-Chain vault: the
// only thing that changes the vault is an explicit deposit/withdraw.
func TestCustody_PlaceDoesNotTouchVault(t *testing.T) {
pm, _, sdb, caller := custodyPM(t, 1000)
// Deposit first so the maker has a ledger balance to lock.
const dep = uint64(500)
simulateDepositValueTransfer(sdb, caller, dep)
if err := pm.Deposit(sdb, caller, NativeCurrency, new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("Deposit: %v", err)
}
vaultAfterDeposit := vaultBal(sdb)
// Initialize a native/quote market and place a resting native-LUX ask.
key := conservationPoolKey() // currency0 = native LUX
if _, err := pm.Initialize(sdb, key, new(big.Int).Set(Q96), nil); err != nil {
t.Fatalf("Initialize: %v", err)
}
var salt [32]byte
salt[31] = 7
params := ModifyLiquidityParams{
TickLower: -60, TickUpper: 60, LiquidityDelta: big.NewInt(10), Salt: salt,
}
if _, _, err := pm.ModifyLiquidity(sdb, caller, key, params, nil); err != nil {
t.Fatalf("ModifyLiquidity place: %v", err)
}
// THE GATE: the vault balance is UNCHANGED by the place. The resting order's
// funds live in the D-Chain ledger's locked[], not in 0x9010.
if got := vaultBal(sdb); got != vaultAfterDeposit {
t.Fatalf("vault changed by place: %d -> %d (NO native reserve must back a resting order)", vaultAfterDeposit, got)
}
}
// --- ERC-20 DISABLED-WITH-HONEST-FLAG (this pass) ---
//
// A non-native deposit/withdraw is REFUSED explicitly (no fake reserve). It does
// NOT mint a D-Chain credit and does NOT touch any C-Chain balance.
func TestCustody_ERC20Deposit_RefusedNotFaked(t *testing.T) {
pm, f, sdb, caller := custodyPM(t, 1000)
erc20 := Currency{Address: common.HexToAddress("0x00000000000000000000000000000000000000A0")}
err := pm.Deposit(sdb, caller, erc20, big.NewInt(100))
if !errors.Is(err, ErrERC20DepositUnsupported) {
t.Fatalf("ERC-20 deposit err = %v, want ErrERC20DepositUnsupported", err)
}
if got := fakeAvail(f, caller, erc20); got != 0 {
t.Fatalf("ERC-20 D-Chain available = %d, want 0 (no mint)", got)
}
_, werr := pm.Withdraw(sdb, caller, erc20, big.NewInt(100))
if !errors.Is(werr, ErrERC20DepositUnsupported) {
t.Fatalf("ERC-20 withdraw err = %v, want ErrERC20DepositUnsupported", werr)
}
}
// An inert backend (no venue) refuses deposit/withdraw cleanly — never moves
// value, never mints.
func TestCustody_InertBackend_RefusesDeposit(t *testing.T) {
pm := NewPoolManager(newInertEngine())
sdb := &txStateDB{MockStateDB: NewMockStateDB()}
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
sdb.balances[caller] = uint256.NewInt(1000)
simulateDepositValueTransfer(sdb, caller, 100)
err := pm.Deposit(sdb, caller, NativeCurrency, big.NewInt(100))
if !errors.Is(err, ErrDEXBackendNotConfigured) {
t.Fatalf("inert deposit err = %v, want ErrDEXBackendNotConfigured", err)
}
}
+35 -4
View File
@@ -35,10 +35,11 @@ type Engine interface {
// Initialize computes the initial tick from sqrtPriceX96 and creates pool state.
Initialize(sqrtPriceX96 *big.Int) (int24, error)
// Swap executes the full V4 tick-crossing swap loop.
// All math happens in the engine (ComputeSwapStep, tick bitmap, fee growth).
// Returns balance delta (amount0, amount1).
Swap(pool *PoolState, params SwapParams) (BalanceDelta, error)
// Swap submits a marketable order to the CLOB and returns the fills' net
// BalanceDelta. The taker identity (caller) is bound so the order's spend is
// locked + settled against the caller's D-Chain ledger account (the funds the
// caller DEPOSITED via Deposit). Returns balance delta (amount0, amount1).
Swap(pool *PoolState, caller common.Address, params SwapParams) (BalanceDelta, error)
// ModifyLiquidity adds/removes concentrated liquidity.
// Engine handles tick updates, bitmap flips, position tracking, fee accrual.
@@ -59,6 +60,36 @@ type Engine interface {
Brand() string
}
// custodyEngine is the OPTIONAL seam a backend implements when it carries a
// per-account balance ledger that funds and settles orders — i.e. the CLOB
// custody model where "the money lives in the order book". The PoolManager calls
// it to move value INTO the ledger (Deposit, after locking the asset in the 0x9010
// vault) and OUT of it (Withdraw, before releasing the asset), and to bind a
// market's assets (OpenMarket) so the ledger value-checks orders.
//
// The inertEngine does NOT implement this (no ledger to fund). The PoolManager
// type-asserts for custodyEngine and a deposit/withdraw selector reverts cleanly
// when the backend is not custody-capable. ZAPEngine implements it by relaying
// clob_deposit / clob_withdraw / clob_open_market to the D-Chain.
//
// asset handles: a Currency's 20-byte address folds to the 8-byte D-Chain asset
// handle (assetHandle in engine_zap.go); native LUX (address(0)) folds to 0.
type custodyEngine interface {
// OpenMarket binds (base=currency0, quote=currency1) asset handles for poolID
// so the D-Chain custody gate value-checks orders. Idempotent.
OpenMarket(poolID [32]byte, base, quote Currency) error
// Deposit credits exactly amount of asset into account's available D-Chain
// balance. Called AFTER the vault lock leg. Refuses a short credit.
Deposit(account common.Address, asset Currency, amount uint64) error
// Withdraw debits up to want of asset from account's available balance and
// returns the realized amount (clamped). The caller releases exactly realized
// from the vault. realized > want is refused (mint guard).
Withdraw(account common.Address, asset Currency, want uint64) (uint64, error)
// Balance returns account's AVAILABLE balance for asset (read-only, no
// mutation). Used by the balanceOf view selector.
Balance(account common.Address, asset Currency) (uint64, error)
}
// poolRouter is the OPTIONAL seam a backend implements when its canonical pool
// state lives elsewhere (e.g. ZAPEngine, whose pools live on the D-Chain DEX
// server). The PoolManager, which alone knows the V4 poolId, threads it to the
+1 -1
View File
@@ -44,7 +44,7 @@ func (inertEngine) Initialize(_ *big.Int) (int24, error) {
}
// Swap refuses: there is no matcher to submit a marketable order to.
func (inertEngine) Swap(_ *PoolState, _ SwapParams) (BalanceDelta, error) {
func (inertEngine) Swap(_ *PoolState, _ common.Address, _ SwapParams) (BalanceDelta, error) {
return ZeroBalanceDelta(), ErrDEXBackendNotConfigured
}
+173 -2
View File
@@ -35,6 +35,35 @@ const (
ZAPMethodPlace = "clob_place"
ZAPMethodCancel = "clob_cancel"
ZAPMethodSubmit = "clob_submit"
// ZAPMethodOpenMarket binds a market's (base, quote) D-Chain asset handles so
// the D-Chain custody gate value-checks orders against deposited balances. The
// precompile calls it at InitializePool time (alongside ensure_market) so EVM-
// path markets are custody-active — without it the D-Chain falls into its
// no-custody fallback and a swap could not settle in the ledger.
ZAPMethodOpenMarket = "clob_open_market"
// ZAPMethodDeposit credits an account's available D-Chain balance (the funds-in
// leg). The precompile calls it after the EVM has locked the asset in the 0x9010
// vault (native LUX via msg.value, ERC-20 via transferFrom).
ZAPMethodDeposit = "clob_deposit"
// ZAPMethodWithdraw debits an account's realized available D-Chain balance,
// returning the realized amount the precompile then releases from the vault.
ZAPMethodWithdraw = "clob_withdraw"
// ZAPMethodBalance is the read-only available/locked balance observation
// (clob_balance). Request: user[16]+asset[8]. Response: available[8]+locked[8].
ZAPMethodBalance = "clob_balance"
)
// Frozen custody frame sizes (byte-identical to github.com/luxfi/dex/pkg/zapwire;
// the precompile cannot import the cgo-tagged d-chain package, so the canonical
// frame is re-declared here and pinned by a parity test — the same three-homes
// pattern the place/cancel/submit frames already use).
const (
zapUserSize = 16 // user identity field width
zapAssetIDSize = 8 // asset handle field width
depositReqSize = zapUserSize + zapAssetIDSize + 8 // user[16]+asset[8]+amount[8] = 32
withdrawReqSize = zapUserSize + zapAssetIDSize + 8 // = 32
openMarketReqSize = 32 + zapAssetIDSize + zapAssetIDSize // poolId[32]+base[8]+quote[8] = 48
balanceRespSize = 1 + 8 // status[1]+amount[8]
)
// brandFallback is the neutral, brand-free identity surfaced by ZAPEngine. The
@@ -240,7 +269,7 @@ func (z *ZAPEngine) InitializePool(ps *PoolState, poolID [32]byte, sqrtPriceX96
// CLOB taker size is |AmountSpecified| in base (currency0) units for exact-input
// of a sell / exact-output of a buy; for the cross-cases we still submit the
// magnitude as base size, which is the faithful single-leg CLOB primitive.
func (z *ZAPEngine) Swap(pool *PoolState, params SwapParams) (BalanceDelta, error) {
func (z *ZAPEngine) Swap(pool *PoolState, caller common.Address, params SwapParams) (BalanceDelta, error) {
// Replay-idempotency is enforced UPSTREAM by the PoolManager against StateDB
// (durable + consensus-shared), so by the time a Swap reaches this adapter it
// is the unique submit for its EVM tx. This method therefore stays a pure,
@@ -279,7 +308,13 @@ func (z *ZAPEngine) Swap(pool *PoolState, params SwapParams) (BalanceDelta, erro
}
putFloat64(payload[34:42], limitPrice)
putFloat64(payload[42:50], sizeF)
// user left zero: the taker identity is the EVM caller, settled on-chain.
// Bind the TAKER identity = the EVM caller's 16-byte handle, so the D-Chain
// locks the taker's spend from + settles the fills' proceeds INTO the caller's
// own ledger account (the funds the caller DEPOSITED). The submit frame's user
// field is bytes [50:66]. Leaving it zero (the old behaviour) keyed the taker's
// balance under user=0, so the trade could not settle against the real caller —
// the reason settlement formerly fell back to a C-Chain reserve move.
copy(payload[50:66], caller.Bytes()[:16])
ctx, cancel := context.WithTimeout(context.Background(), z.timeout)
defer cancel()
@@ -498,6 +533,142 @@ func (z *ZAPEngine) Close() error {
return nil
}
// =========================================================================
// Custody: deposit / withdraw / open-market (the funds-in/out + asset binding)
// =========================================================================
// assetHandle folds a 20-byte EVM currency address to the 8-byte D-Chain asset
// handle the ledger keys balances by (big-endian over the leading 8 bytes). It is
// the EVM-side view of the same 8-byte handle the proxy derives from a 32-byte
// cross-chain asset id (chains/dexvm.assetHandle), so an asset deposited via the
// precompile and one deposited via the atomic proxy address the SAME ledger
// balance. Native LUX (address(0)) folds to 0 — a unique handle, since no ERC-20
// address is the zero address. The deposit, the open-market binding, and the
// order MUST all use this fold so they name the same asset.
func assetHandle(c Currency) uint64 {
b := c.Address.Bytes() // 20 bytes
return binary.BigEndian.Uint64(b[:8])
}
// userHandle renders the caller's 20-byte address to the 16-byte user identity
// the frozen CLOB frames carry (the leading 16 bytes — the same width place/
// submit use). The D-Chain folds this 16-byte user to its 8-byte ledger key, so
// binding deposit/withdraw to the same 16-byte slice keeps the credited/debited
// account consistent with the orders that account places.
func userHandle(addr common.Address) string {
return string(addr.Bytes()[:16])
}
// OpenMarket binds a market's (base, quote) asset handles on the D-Chain so the
// custody gate value-checks orders against deposited balances. Idempotent. Called
// at InitializePool time. base is currency0, quote is currency1 (V4 sorts them).
func (z *ZAPEngine) OpenMarket(poolID [32]byte, base, quote Currency) error {
ctx, cancel := context.WithTimeout(context.Background(), z.timeout)
defer cancel()
payload := make([]byte, openMarketReqSize)
copy(payload[0:32], poolID[:])
binary.BigEndian.PutUint64(payload[32:40], assetHandle(base))
binary.BigEndian.PutUint64(payload[40:48], assetHandle(quote))
resp, err := z.call(ctx, ZAPMethodOpenMarket, payload)
if err != nil {
return fmt.Errorf("ZAP OpenMarket: %w", err)
}
if _, status, reason, derr := decodeAck(resp); derr != nil {
return fmt.Errorf("ZAP OpenMarket ack: %w", derr)
} else if status == clobStatusRejected {
return fmt.Errorf("ZAP OpenMarket rejected: %s", reason)
}
return nil
}
// Deposit credits exactly `amount` of `asset` into `account`'s available D-Chain
// balance (clob_deposit). It is the funds-in leg: the precompile calls it AFTER
// the EVM has locked the asset in the 0x9010 vault. It refuses if the D-Chain
// credited less than requested (a short credit would strand locked vault value).
func (z *ZAPEngine) Deposit(account common.Address, asset Currency, amount uint64) error {
ctx, cancel := context.WithTimeout(context.Background(), z.timeout)
defer cancel()
payload := make([]byte, depositReqSize)
copy(payload[0:16], padUserHandle(account))
binary.BigEndian.PutUint64(payload[16:24], assetHandle(asset))
binary.BigEndian.PutUint64(payload[24:32], amount)
resp, err := z.call(ctx, ZAPMethodDeposit, payload)
if err != nil {
return fmt.Errorf("ZAP Deposit: %w", err)
}
_, credited, derr := decodeBalanceResp(resp)
if derr != nil {
return fmt.Errorf("ZAP Deposit resp: %w", derr)
}
if credited != amount {
return fmt.Errorf("%w: credited %d != deposited %d", ErrSettlementFailed, credited, amount)
}
return nil
}
// Withdraw debits up to `want` of `asset` from `account`'s available D-Chain
// balance (clob_withdraw) and returns the REALIZED amount the ledger released
// (clamped to availability). The precompile then releases exactly the realized
// amount from the vault. A realized > want is refused upstream (mint guard). 0
// means nothing available — the precompile releases nothing.
func (z *ZAPEngine) Withdraw(account common.Address, asset Currency, want uint64) (uint64, error) {
ctx, cancel := context.WithTimeout(context.Background(), z.timeout)
defer cancel()
payload := make([]byte, withdrawReqSize)
copy(payload[0:16], padUserHandle(account))
binary.BigEndian.PutUint64(payload[16:24], assetHandle(asset))
binary.BigEndian.PutUint64(payload[24:32], want)
resp, err := z.call(ctx, ZAPMethodWithdraw, payload)
if err != nil {
return 0, fmt.Errorf("ZAP Withdraw: %w", err)
}
_, realized, derr := decodeBalanceResp(resp)
if derr != nil {
return 0, fmt.Errorf("ZAP Withdraw resp: %w", derr)
}
if realized > want {
return 0, fmt.Errorf("%w: withdraw realized %d > requested %d (mint risk)", ErrSettlementFailed, realized, want)
}
return realized, nil
}
// Balance returns account's AVAILABLE D-Chain balance for asset (clob_balance is
// a read-only observation; it does not mutate the ledger). The response carries
// available[8]+locked[8]; the EVM view returns available (the spendable claim).
func (z *ZAPEngine) Balance(account common.Address, asset Currency) (uint64, error) {
ctx, cancel := context.WithTimeout(context.Background(), z.timeout)
defer cancel()
req := make([]byte, zapUserSize+zapAssetIDSize)
copy(req[0:zapUserSize], padUserHandle(account))
binary.BigEndian.PutUint64(req[zapUserSize:], assetHandle(asset))
resp, err := z.call(ctx, ZAPMethodBalance, req)
if err != nil {
return 0, fmt.Errorf("ZAP Balance: %w", err)
}
if len(resp) < 16 {
return 0, fmt.Errorf("ZAP Balance: short response %d", len(resp))
}
return binary.BigEndian.Uint64(resp[0:8]), nil // available
}
// padUserHandle left-copies the caller's 16-byte user identity into a fresh
// 16-byte buffer (the frozen frame's user field encoding).
func padUserHandle(addr common.Address) []byte {
b := make([]byte, zapUserSize)
copy(b, addr.Bytes()[:16])
return b
}
// decodeBalanceResp reads (status, realized amount) from a clob_deposit /
// clob_withdraw response (status[1] + amount[8]). Byte-identical to
// zapwire.DecodeBalanceResp.
func decodeBalanceResp(resp []byte) (status uint8, amount uint64, err error) {
if len(resp) < balanceRespSize {
return 0, 0, fmt.Errorf("balance response too short: %d", len(resp))
}
return resp[0], binary.BigEndian.Uint64(resp[1:9]), nil
}
// =========================================================================
// Fill -> BalanceDelta (value-conserving by construction)
// =========================================================================
+59 -1
View File
@@ -56,10 +56,24 @@ type fakeCLOB struct {
submits int // count of clob_submit calls actually matched (replay probe)
failNext bool
closed bool
// ledger is a minimal available-balance ledger keyed by user[16]||asset[8] so
// the clob_deposit/withdraw/balance methods the precompile now drives have a
// deterministic test double. It is NOT the real D-Chain ledger (that lives in
// lx/dex/pkg/dchain and is exercised by the LIVE e2e); this only checks the
// precompile's adapter wire contract for the custody methods.
ledger map[string]uint64
}
func newFakeCLOB() *fakeCLOB {
return &fakeCLOB{markets: make(map[[32]byte][]*fakeOrder)}
return &fakeCLOB{markets: make(map[[32]byte][]*fakeOrder), ledger: make(map[string]uint64)}
}
// ledgerKey folds user[16]||asset[8] from a deposit/withdraw/balance payload.
func ledgerKey(user []byte, asset uint64) string {
var b [24]byte
copy(b[0:16], user)
binary.BigEndian.PutUint64(b[16:24], asset)
return string(b[:])
}
// conn returns a zapConn bound to this book for the zapDialer seam.
@@ -94,11 +108,55 @@ func (f *fakeCLOB) dispatch(method string, payload []byte) ([]byte, error) {
return f.cancel(payload)
case ZAPMethodSubmit:
return f.submit(payload)
case ZAPMethodOpenMarket:
// poolId[32]+base[8]+quote[8]; bind is a no-op for this double — just ack.
var id [32]byte
copy(id[:], payload[0:32])
if _, ok := f.markets[id]; !ok {
f.markets[id] = nil
}
return ackBytes(0, clobStatusPlaced, 1), nil
case ZAPMethodDeposit:
// user[16]+asset[8]+amount[8]: credit available, echo credited == amount.
user := payload[0:16]
asset := binary.BigEndian.Uint64(payload[16:24])
amount := binary.BigEndian.Uint64(payload[24:32])
f.ledger[ledgerKey(user, asset)] += amount
return balanceRespBytes(clobStatusPlaced, amount), nil
case ZAPMethodWithdraw:
// user[16]+asset[8]+want[8]: debit min(want,avail), return realized.
user := payload[0:16]
asset := binary.BigEndian.Uint64(payload[16:24])
want := binary.BigEndian.Uint64(payload[24:32])
k := ledgerKey(user, asset)
avail := f.ledger[k]
realized := want
if realized > avail {
realized = avail
}
f.ledger[k] = avail - realized
return balanceRespBytes(clobStatusPlaced, realized), nil
case ZAPMethodBalance:
// user[16]+asset[8]: available[8]+locked[8] (locked unused in this double).
user := payload[0:16]
asset := binary.BigEndian.Uint64(payload[16:24])
out := make([]byte, 16)
binary.BigEndian.PutUint64(out[0:8], f.ledger[ledgerKey(user, asset)])
return out, nil
default:
return rejectBytes(0, "unknown method"), nil
}
}
// balanceRespBytes builds a clob_deposit/withdraw response: status[1]+amount[8],
// byte-identical to zapwire.EncodeBalanceResp.
func balanceRespBytes(status uint8, amount uint64) []byte {
out := make([]byte, 9)
out[0] = status
binary.BigEndian.PutUint64(out[1:9], amount)
return out
}
func (f *fakeCLOB) place(payload []byte) ([]byte, error) {
var id [32]byte
copy(id[:], payload[0:32])
+140 -15
View File
@@ -142,6 +142,15 @@ var (
SelectorExtsload uint32 = 0x1E2EAEAF // extsload(bytes32)
SelectorExtsloadArray uint32 = 0xDBD035FF // extsload(bytes32[])
// CLOB custody selectors — the EVM ingress for funds in/out of the D-Chain
// ledger ("the money lives in the order book"). deposit LOCKS the asset in the
// 0x9010 vault (native LUX via msg.value) and MINTS the D-Chain available
// balance; withdraw BURNS the D-Chain balance and RELEASES the vault. The CLOB
// settles ONLY inside the D-Chain; these never fund a trade from 0x9010.
SelectorDeposit uint32 = 0x47E7EF24 // deposit(address,uint256) — asset, amount (msg.value==amount for native)
SelectorWithdraw uint32 = 0xF3FEF3A3 // withdraw(address,uint256) — asset, want
SelectorBalanceOf uint32 = 0xF7888AEC // balanceOf(address,address) — account, asset (read-only available)
// Admin pause/freeze selectors — computed in init() via keccak4.
SelectorPauseDEX uint32
SelectorResumeDEX uint32
@@ -173,6 +182,9 @@ func init() {
verifySelector("unlock", SelectorUnlock, "unlock(bytes)")
verifySelector("settle", SelectorSettle, "settle()")
verifySelector("take", SelectorTake, "take(address,address,uint256)")
verifySelector("deposit", SelectorDeposit, "deposit(address,uint256)")
verifySelector("withdraw", SelectorWithdraw, "withdraw(address,uint256)")
verifySelector("balanceOf", SelectorBalanceOf, "balanceOf(address,address)")
// Compute admin selectors at init time (not compile-time constants).
SelectorPauseDEX = keccak4("pauseDEX()")
@@ -317,6 +329,12 @@ func (c *DEXContract) Run(
return c.runSwap(accessibleState, caller, data, suppliedGas, readOnly)
case SelectorModifyLiquidity:
return c.runModifyLiquidity(accessibleState, caller, data, suppliedGas, readOnly)
case SelectorDeposit:
return c.runDeposit(accessibleState, caller, data, suppliedGas, readOnly)
case SelectorWithdraw:
return c.runWithdraw(accessibleState, caller, data, suppliedGas, readOnly)
case SelectorBalanceOf:
return c.runBalanceOf(accessibleState, data, suppliedGas)
case SelectorDonate:
return c.runDonate(accessibleState, caller, data, suppliedGas, readOnly)
case SelectorUnlock:
@@ -418,14 +436,17 @@ func (c *DEXContract) runSwap(
return nil, suppliedGas - GasSwap, err
}
// Settle the NATIVE-LUX leg on C-Chain (if either currency is address(0)). A
// CLOB has no two-leg C-Chain ERC-20 settlement: a non-native asset's value
// lives in the D-Chain book (deposited via the atomic rail, settled in the
// D-Chain ledger when the order filled). Only native LUX is C-Chain account
// balance backing the V4 facade. (Was autoSettle, which poked ERC-20 slots.)
if err := c.poolManager.settleNativeLegs(stateAdapter, caller, key, delta); err != nil {
return nil, suppliedGas - GasSwap, err
}
// NO C-CHAIN SETTLEMENT. A marketable order settles ENTIRELY inside the D-Chain
// ledger (the taker's locked spend moves to the maker, the maker's locked asset
// moves to the taker — dchain.settleFills, value-conserving by construction).
// The taker's funds were DEPOSITED into the D-Chain (deposit selector ->
// clob_deposit -> available) before this swap; the swap locked + spent them in
// the book. 0x9010 is a pure ingress adapter — it holds NO reserve, is NEVER a
// counterparty, and does NOT move value here. The returned BalanceDelta is the
// fills' net for the V4 ABI's caller, NOT an instruction to settle on C-Chain.
// (The former settleNativeLegs did a caller<->0x9010 native transfer = an
// AMM-style C-Chain reserve settlement; it left native LUX sitting in 0x9010
// "backing resting asks" — the exact reserve hazard the CLOB model forbids.)
// V4: Return BalanceDelta as single int256 (amount0 in upper 128 bits, amount1 in lower 128 bits)
result := PackBalanceDelta(delta.Amount0, delta.Amount1)
@@ -459,13 +480,12 @@ func (c *DEXContract) runModifyLiquidity(
return nil, suppliedGas - GasAddLiquidity, err
}
// Settle the NATIVE-LUX leg on C-Chain. Resting non-native liquidity is funded
// from the maker's D-Chain balance (the order locks it inside the book); only a
// native-LUX leg is C-Chain account balance. (Was autoSettle, which poked
// ERC-20 slots and failed for any deposited/non-standard token.)
if err := c.poolManager.settleNativeLegs(stateAdapter, caller, key, delta); err != nil {
return nil, suppliedGas - GasAddLiquidity, err
}
// NO C-CHAIN SETTLEMENT. Placing a resting order LOCKS the maker's already-
// DEPOSITED D-Chain balance (available -> locked, inside the book); cancelling
// UNLOCKS it. The maker's funds never touch C-Chain here and 0x9010 holds no
// reserve backing the order — the resting order's funds live in the D-Chain
// ledger's locked[maker][asset], not in 0x9010. (Was settleNativeLegs, which
// moved native LUX caller<->0x9010 to "back" the resting ask = a reserve.)
// V4: Return two packed BalanceDeltas (callerDelta + feesAccrued), each 32 bytes
result := make([]byte, 64)
@@ -474,6 +494,107 @@ func (c *DEXContract) runModifyLiquidity(
return result, suppliedGas - GasAddLiquidity, nil
}
// runDeposit is the EVM ingress for funds-IN: deposit(address asset, uint256
// amount). For native LUX (asset == address(0)) the caller MUST send msg.value ==
// amount; the EVM has already moved that value into the 0x9010 vault before this
// precompile runs, so the deposit LOCKS it there and MINTS the caller's available
// D-Chain balance. 0x9010 is a passive vault, never a trade counterparty.
//
// ABI: input = asset[32] (address, right-aligned) || amount[32] (uint256).
// Returns the deposited amount as uint256 (the credited available balance delta).
func (c *DEXContract) runDeposit(
state contract.AccessibleState,
caller common.Address,
input []byte,
suppliedGas uint64,
readOnly bool,
) ([]byte, uint64, error) {
if readOnly {
return nil, suppliedGas, fmt.Errorf("cannot write in read-only mode")
}
if suppliedGas < GasSettlement {
return nil, 0, fmt.Errorf("out of gas")
}
if len(input) < 64 {
return nil, suppliedGas - GasSettlement, fmt.Errorf("deposit: input too short")
}
asset := Currency{Address: common.BytesToAddress(input[12:32])}
amount := new(big.Int).SetBytes(input[32:64])
stateAdapter := &poolStateAdapter{stateDB: state.GetStateDB(), blockNumber: state.GetBlockContext().Number().Uint64()}
if err := c.poolManager.Deposit(stateAdapter, caller, asset, amount); err != nil {
return nil, suppliedGas - GasSettlement, err
}
out := make([]byte, 32)
amount.FillBytes(out)
return out, suppliedGas - GasSettlement, nil
}
// runWithdraw is the EVM ingress for funds-OUT: withdraw(address asset, uint256
// want). It BURNS up to `want` of the caller's available D-Chain balance (the
// ledger clamps to availability) and RELEASES exactly the realized amount from the
// 0x9010 vault back to the caller. Conserving: the vault never pays more than the
// ledger burned. Returns the realized amount as uint256.
func (c *DEXContract) runWithdraw(
state contract.AccessibleState,
caller common.Address,
input []byte,
suppliedGas uint64,
readOnly bool,
) ([]byte, uint64, error) {
if readOnly {
return nil, suppliedGas, fmt.Errorf("cannot write in read-only mode")
}
if suppliedGas < GasSettlement {
return nil, 0, fmt.Errorf("out of gas")
}
if len(input) < 64 {
return nil, suppliedGas - GasSettlement, fmt.Errorf("withdraw: input too short")
}
asset := Currency{Address: common.BytesToAddress(input[12:32])}
want := new(big.Int).SetBytes(input[32:64])
stateAdapter := &poolStateAdapter{stateDB: state.GetStateDB(), blockNumber: state.GetBlockContext().Number().Uint64()}
realized, err := c.poolManager.Withdraw(stateAdapter, caller, asset, want)
if err != nil {
return nil, suppliedGas - GasSettlement, err
}
out := make([]byte, 32)
realized.FillBytes(out)
return out, suppliedGas - GasSettlement, nil
}
// runBalanceOf is a read-only observation of an account's AVAILABLE D-Chain
// balance for an asset: balanceOf(address account, address asset). It forwards to
// the custody backend's Withdraw with want=0? No — a read must not mutate. It
// queries the D-Chain via the backend's read path. Returns the available balance
// as uint256. (The locked balance is queryable on the venue's clob_balance; this
// EVM view returns available, the spendable claim.)
func (c *DEXContract) runBalanceOf(
state contract.AccessibleState,
input []byte,
suppliedGas uint64,
) ([]byte, uint64, error) {
if suppliedGas < GasPoolLookup {
return nil, 0, fmt.Errorf("out of gas")
}
if len(input) < 64 {
return nil, suppliedGas - GasPoolLookup, fmt.Errorf("balanceOf: input too short")
}
account := common.BytesToAddress(input[12:32])
asset := Currency{Address: common.BytesToAddress(input[44:64])}
avail, err := c.poolManager.BalanceOf(account, asset)
if err != nil {
return nil, suppliedGas - GasPoolLookup, err
}
out := make([]byte, 32)
avail.FillBytes(out)
return out, suppliedGas - GasPoolLookup, nil
}
func (c *DEXContract) runTake(
_ contract.AccessibleState,
_ common.Address,
@@ -838,6 +959,10 @@ func (c *DEXContract) RequiredGas(input []byte) uint64 {
return GasSwap
case SelectorModifyLiquidity:
return GasAddLiquidity
case SelectorDeposit, SelectorWithdraw:
return GasSettlement
case SelectorBalanceOf:
return GasPoolLookup
case SelectorTake:
return GasBalanceUpdate
case SelectorSettle, SelectorSettleFor:
+276 -70
View File
@@ -43,6 +43,8 @@ var (
freezeStatePrefix = []byte("frzn")
swapBindPrefix = []byte("swpb")
modBindPrefix = []byte("modb")
depBindPrefix = []byte("depb") // deposit idempotency binding
wdrBindPrefix = []byte("wdrb") // withdraw idempotency binding
)
// PoolState extends the basic Pool with V4 tick-level state for concentrated
@@ -219,6 +221,85 @@ func slotToSigned(h common.Hash) *big.Int {
return v
}
// ---- custody (deposit / withdraw) idempotency bindings (RED H1) ----
//
// A clob_deposit (mint) and a clob_withdraw (burn + vault release) are IRREVERSIBLE
// D-Chain ops, so the EVM's repeated executions of ONE deposit/withdraw tx must
// issue each EXACTLY ONCE. The key is the consensus-deterministic tuple (txHash,
// asset, amount): same on every validator, durable across restart, revert-safe
// (StateDB snapshot). ok=false for a non-EVM caller (no tx identity).
// custodyBindKey derives the binding slot for a (txHash, asset, amount) custody op
// under the given prefix. amount is the requested deposit/withdraw magnitude.
func custodyBindKey(stateDB StateDB, prefix []byte, caller common.Address, asset Currency, amount *big.Int) (common.Hash, bool) {
idr, ok := stateDB.(txIdentified)
if !ok {
return common.Hash{}, false
}
txHash := idr.TxHash()
if txHash == (common.Hash{}) {
return common.Hash{}, false
}
h := blake3.New()
h.Write(txHash[:])
h.Write(caller.Bytes())
h.Write(asset.Address.Bytes())
var amtBuf [32]byte
if amount != nil {
amount.FillBytes(amtBuf[:])
}
h.Write(amtBuf[:])
var id [32]byte
h.Digest().Read(id[:])
return makeStorageKey(prefix, id[:]), true
}
func depositBindKey(stateDB StateDB, caller common.Address, asset Currency, amount *big.Int) (common.Hash, bool) {
return custodyBindKey(stateDB, depBindPrefix, caller, asset, amount)
}
func withdrawBindKey(stateDB StateDB, caller common.Address, asset Currency, want *big.Int) (common.Hash, bool) {
return custodyBindKey(stateDB, wdrBindPrefix, caller, asset, want)
}
// loadCustodyBinding / storeCustodyBinding record a one-bit "deposit committed"
// flag for a deposit binding. A deposit has no realized amount to carry (it
// credits exactly the requested amount), so a single presence flag suffices.
func loadCustodyBinding(stateDB StateDB, bindKey common.Hash) bool {
flag := stateDB.GetState(poolManagerAddr, makeStorageKey(depBindPrefix, append(bindKey[:], 'f')))
return flag[31] == 1
}
func storeCustodyBinding(stateDB StateDB, bindKey common.Hash) {
var flag common.Hash
flag[31] = 1
stateDB.SetState(poolManagerAddr, makeStorageKey(depBindPrefix, append(bindKey[:], 'f')), flag)
}
// loadWithdrawBinding / storeWithdrawBinding record a withdraw's REALIZED amount
// (clamped to availability) so a replay returns the same realized value without a
// second burn/release. The realized amount is stored as an unsigned word; a
// settled flag distinguishes a genuine realized-zero from "not yet settled".
func loadWithdrawBinding(stateDB StateDB, bindKey common.Hash) (*big.Int, bool) {
flag := stateDB.GetState(poolManagerAddr, makeStorageKey(wdrBindPrefix, append(bindKey[:], 'f')))
if flag[31] != 1 {
return big.NewInt(0), false
}
amt := stateDB.GetState(poolManagerAddr, makeStorageKey(wdrBindPrefix, append(bindKey[:], 'r')))
return new(big.Int).SetBytes(amt[:]), true
}
func storeWithdrawBinding(stateDB StateDB, bindKey common.Hash, realized *big.Int) {
var amt common.Hash
if realized != nil {
realized.FillBytes(amt[:])
}
stateDB.SetState(poolManagerAddr, makeStorageKey(wdrBindPrefix, append(bindKey[:], 'r')), amt)
var flag common.Hash
flag[31] = 1
stateDB.SetState(poolManagerAddr, makeStorageKey(wdrBindPrefix, append(bindKey[:], 'f')), flag)
}
// modifyBindKey is the ModifyLiquidity analog of swapBindKey (RED H1, same
// reasoning): a place/cancel is an IRREVERSIBLE clob_place/clob_cancel on the
// d-chain book, so the EVM's repeated executions of ONE modifyLiquidity tx
@@ -445,6 +526,18 @@ func (pm *PoolManager) Initialize(
pool.Tick = serverTick
}
// CUSTODY: bind the market's (base=currency0, quote=currency1) asset handles on
// the D-Chain so its custody gate value-checks orders against deposited
// balances (available -> locked on place/submit, settled maker<->taker on a
// fill). Without this the D-Chain falls into its no-custody fallback and a swap
// could not settle in the ledger — the reason settlement formerly leaked to a
// C-Chain reserve move. A non-custody backend (inert) skips this.
if custody, ok := pm.engine.(custodyEngine); ok {
if oerr := custody.OpenMarket(poolId, key.Currency0, key.Currency1); oerr != nil {
return 0, oerr
}
}
pm.setPool(stateDB, poolId, pool)
if key.Hooks != (common.Address{}) {
@@ -674,7 +767,7 @@ func (pm *PoolManager) Swap(
}
pm.routePool(poolId, ps)
delta, err := pm.engine.Swap(ps, params)
delta, err := pm.engine.Swap(ps, caller, params)
if err != nil {
return ZeroBalanceDelta(), err
}
@@ -1093,86 +1186,199 @@ func (pm *PoolManager) calculateFlashFee(amount *big.Int, fee uint24) *big.Int {
return feeAmount.Div(feeAmount, big.NewInt(1_000_000))
}
// transferToken moves a single NATIVE-LUX (address(0)) currency leg on C-Chain.
// lockNativeIntoVault is the C-Chain LOCK leg of a native-LUX DEPOSIT. The EVM
// has ALREADY moved msg.value from the caller into 0x9010 (the precompile
// address) before this precompile runs (core/vm/evm.go Transfer precedes the
// precompile dispatch), so the value is sitting in 0x9010's balance. This
// function only VERIFIES that 0x9010 holds at least `amount` (a defensive
// sufficiency check — the caller must have sent msg.value == amount) and leaves
// it there as the passive lock backing. It moves NOTHING and is NEVER a trade
// counterparty.
//
// It is NATIVE-ONLY by design (the CLOB custody model — see settleNativeLegs).
// The former ERC-20 slot-0 poke is REMOVED: a non-native asset's value lives in
// the D-Chain (deposited via the proxy's atomic import, settled in the D-Chain
// ledger), never in a C-Chain storage write. A non-native currency reaching here
// is a routing bug, not a settlement path — refused explicitly rather than
// silently poking a storage slot (the RED H3/C2 hazard). Native LUX moves as
// account balance with a full sufficiency check.
func (pm *PoolManager) transferToken(stateDB StateDB, currency Currency, from, to common.Address, amount *big.Int) error {
if amount.Sign() <= 0 {
return nil
// This is the native analog of an ERC-20 lock-and-mint bridge: the real asset
// (native LUX) is LOCKED in 0x9010 on C-Chain, and a canonical D-Chain balance is
// MINTED (credited via clob_deposit) against it by the caller. Withdraw burns the
// D-Chain balance and RELEASES the locked LUX. The invariant 0x9010 maintains is
//
// balanceOf(0x9010) == Σ available[*][LUX] + Σ locked[*][LUX]
//
// every unit of native LUX in the vault has exactly one D-Chain claim unit, so no
// trade is ever funded from 0x9010 and value is conserved across deposit ->
// (trade settles inside the D-Chain ledger) -> withdraw.
func (pm *PoolManager) lockNativeIntoVault(stateDB StateDB, amount *big.Int) error {
if amount == nil || amount.Sign() <= 0 {
return fmt.Errorf("%w: deposit amount must be positive", ErrInvalidAmount)
}
if !currency.IsNative() {
// A non-native leg must settle in the D-Chain (deposit/withdraw + ledger),
// never as a C-Chain ERC-20 storage poke.
return fmt.Errorf("%w: non-native currency %s settles in the D-Chain, not on C-Chain", ErrSettlementFailed, currency.Address.Hex())
}
fromBal := stateDB.GetBalance(from)
amountU256, overflow := uint256.FromBig(amount)
if overflow {
return fmt.Errorf("%w: amount overflows uint256", ErrInsufficientBalance)
return fmt.Errorf("%w: amount overflows uint256", ErrInvalidAmount)
}
if fromBal.Lt(amountU256) {
return fmt.Errorf("%w: native balance %s < transfer %s", ErrInsufficientBalance, fromBal, amountU256)
// The EVM credited 0x9010 with msg.value before dispatch. If 0x9010 does not
// hold at least `amount`, the caller did not send msg.value == amount and the
// deposit is unfunded — refuse rather than mint an unbacked D-Chain credit.
vaultBal := stateDB.GetBalance(poolManagerAddr)
if vaultBal.Lt(amountU256) {
return fmt.Errorf("%w: deposit %s not funded by msg.value (0x9010 holds %s)", ErrInsufficientBalance, amountU256, vaultBal)
}
stateDB.SubBalance(from, amountU256)
stateDB.AddBalance(to, amountU256)
// Value already locked in the vault by the EVM value transfer. Nothing to move.
return nil
}
// settleNativeLegs moves ONLY the native-LUX (address(0)) leg of a V4
// BalanceDelta on C-Chain. It is the corrected replacement for the former
// "autoSettle", which poked C-Chain ERC-20 balance slots for BOTH legs.
//
// WHY THIS IS RIGHT (the CLOB custody model): the D-Chain is a central-limit
// order book where the money LIVES IN THE BOOK. A token's value is DEPOSITED into
// the D-Chain (atomic shared-memory ImportTx via the chains/dexvm proxy), lives
// as the account's available D-Chain balance the book draws from, and is settled
// ENTIRELY inside D-Chain consensus when an order fills (the maker's locked base
// moves to the taker and the taker's locked quote moves to the maker — see
// dchain.settleFills). A swap therefore has NO two-leg C-Chain ERC-20 settlement:
// the token leg is already on the D-Chain. The previous autoSettle tried to
// transfer the token ON C-Chain (caller -> poolManager), which (a) required the
// caller to hold the C-Chain ERC-20 it had actually deposited into the D-Chain
// (the "ERC20 balance 0 < transfer N" e2e failure) and (b) poked a hardcoded
// storage slot that corrupted any non-standard token (RED H3/C2).
//
// The ONLY asset that genuinely settles on C-Chain is NATIVE LUX (address(0)):
// it is C-Chain account balance, and the V4 facade backs resting native
// liquidity with real native value held by the PoolManager (the e2e proved the
// PoolManager holding 17 wei behind 17 resting asks). A non-native leg is a
// D-Chain-canonical asset and is intentionally NOT moved here — its value
// conservation is the D-Chain ledger's job + the proxy's atomic import/export.
func (pm *PoolManager) settleNativeLegs(stateDB StateDB, caller common.Address, key PoolKey, delta BalanceDelta) error {
if key.Currency0.IsNative() {
if err := pm.settleNativeLeg(stateDB, caller, delta.Amount0); err != nil {
return fmt.Errorf("%w: currency0 native settlement: %v", ErrSettlementFailed, err)
}
}
if key.Currency1.IsNative() {
if err := pm.settleNativeLeg(stateDB, caller, delta.Amount1); err != nil {
return fmt.Errorf("%w: currency1 native settlement: %v", ErrSettlementFailed, err)
}
}
return nil
}
// settleNativeLeg moves one native-LUX leg: a positive delta (caller owes the
// pool) debits the caller and credits the PoolManager; a negative delta (pool
// owes the caller) does the reverse. Zero is a no-op.
func (pm *PoolManager) settleNativeLeg(stateDB StateDB, caller common.Address, amount *big.Int) error {
switch amount.Sign() {
case 1:
return pm.transferToken(stateDB, NativeCurrency, caller, poolManagerAddr, amount)
case -1:
return pm.transferToken(stateDB, NativeCurrency, poolManagerAddr, caller, new(big.Int).Neg(amount))
default:
// releaseNativeFromVault is the C-Chain RELEASE leg of a native-LUX WITHDRAW: it
// moves `amount` native LUX from the 0x9010 vault back to the caller, AFTER the
// D-Chain ledger has debited (burned) exactly `amount` of the caller's available
// balance. It refuses to release more than the vault holds (a release that would
// drain another account's locked backing) — but under the maintained invariant
// the vault always holds >= the realized D-Chain debit, so this is defense in
// depth, never the normal path. This is the only direction 0x9010 ever pays out
// native LUX, and only against a realized ledger burn — never as a trade.
func (pm *PoolManager) releaseNativeFromVault(stateDB StateDB, caller common.Address, amount *big.Int) error {
if amount == nil || amount.Sign() <= 0 {
return nil
}
amountU256, overflow := uint256.FromBig(amount)
if overflow {
return fmt.Errorf("%w: amount overflows uint256", ErrInvalidAmount)
}
vaultBal := stateDB.GetBalance(poolManagerAddr)
if vaultBal.Lt(amountU256) {
// The vault cannot back this release. Under the invariant this is
// impossible; refusing prevents a mint against the vault.
return fmt.Errorf("%w: vault %s < release %s (invariant breach)", ErrInsufficientBalance, vaultBal, amountU256)
}
stateDB.SubBalance(poolManagerAddr, amountU256)
stateDB.AddBalance(caller, amountU256)
return nil
}
// Deposit is the EVM ingress for funds-IN: it LOCKS the asset in the 0x9010 vault
// on C-Chain, then MINTS the canonical D-Chain balance (credits available) for
// the caller via the custody backend. It is the only way native value enters the
// D-Chain ledger from an EVM chain, and it makes 0x9010 a passive lock vault, not
// a trade counterparty.
//
// NATIVE LUX: the EVM has already moved msg.value (== amount) into 0x9010 before
// this precompile ran; lockNativeIntoVault verifies the vault holds it. The LUX
// stays locked in the vault; the matching D-Chain available balance is the
// caller's claim against it.
// ERC-20: not yet a real lock/mint (no on-chain token transferFrom path wired) —
// refused explicitly (ErrERC20DepositUnsupported) rather than minting an
// unbacked D-Chain credit. See the deposit handler.
//
// IDEMPOTENCY (RED H1): the EVM executes one tx ~5× (estimate/validate/build/
// verify) and only the canonical exec commits StateDB; a clob_deposit is an
// irreversible D-Chain credit. The deposit is bound on (txHash, asset, amount) in
// StateDB so a re-execution returns the prior result WITHOUT a second mint — the
// vault lock is idempotent too (the EVM transfers msg.value once per real tx, and
// a replay sees the binding before touching the vault).
func (pm *PoolManager) Deposit(stateDB StateDB, caller common.Address, asset Currency, amount *big.Int) error {
if amount == nil || amount.Sign() <= 0 {
return fmt.Errorf("%w: deposit amount must be positive", ErrInvalidAmount)
}
if !amount.IsUint64() {
return fmt.Errorf("%w: deposit amount exceeds uint64 ledger range", ErrInvalidAmount)
}
custody, ok := pm.engine.(custodyEngine)
if !ok {
return ErrDEXBackendNotConfigured
}
// Replay-idempotency: a committed deposit for this (txHash, asset, amount) is
// served from StateDB without a second vault lock or D-Chain mint.
bindKey, bound := depositBindKey(stateDB, caller, asset, amount)
if bound && loadCustodyBinding(stateDB, bindKey) {
return nil
}
// 1) LOCK leg (C-Chain). Native: verify msg.value is in the vault. ERC-20:
// refused upstream in the handler before reaching here.
if asset.IsNative() {
if err := pm.lockNativeIntoVault(stateDB, amount); err != nil {
return err
}
} else {
return ErrERC20DepositUnsupported
}
// 2) MINT leg (D-Chain): credit exactly the locked amount into available.
if err := custody.Deposit(caller, asset, amount.Uint64()); err != nil {
return fmt.Errorf("%w: clob_deposit: %v", ErrSettlementFailed, err)
}
if bound {
storeCustodyBinding(stateDB, bindKey)
}
return nil
}
// Withdraw is the EVM ingress for funds-OUT: it BURNS up to `want` of the
// caller's D-Chain available balance (the ledger clamps to availability and
// returns the realized amount), then RELEASES exactly the realized amount from the
// 0x9010 vault back to the caller. Conserving: the vault never releases more than
// the ledger burned, so 0x9010 cannot mint native value.
//
// NATIVE LUX: releaseNativeFromVault pays the realized amount from the vault.
// ERC-20: refused (no real release path) — the withdraw never burns the ledger.
//
// Returns the realized amount released (0 = nothing available). Idempotency mirrors
// Deposit: bound on (txHash, asset, want) so a replay does not double-burn/release.
func (pm *PoolManager) Withdraw(stateDB StateDB, caller common.Address, asset Currency, want *big.Int) (*big.Int, error) {
if want == nil || want.Sign() <= 0 {
return big.NewInt(0), fmt.Errorf("%w: withdraw amount must be positive", ErrInvalidAmount)
}
if !want.IsUint64() {
return big.NewInt(0), fmt.Errorf("%w: withdraw amount exceeds uint64 ledger range", ErrInvalidAmount)
}
if !asset.IsNative() {
return big.NewInt(0), ErrERC20DepositUnsupported
}
custody, ok := pm.engine.(custodyEngine)
if !ok {
return big.NewInt(0), ErrDEXBackendNotConfigured
}
// Replay-idempotency: a committed withdraw for this (txHash, asset, want)
// returns its realized amount without a second burn or release.
bindKey, bound := withdrawBindKey(stateDB, caller, asset, want)
if bound {
if realized, settled := loadWithdrawBinding(stateDB, bindKey); settled {
return realized, nil
}
}
// 1) BURN leg (D-Chain): debit realized (clamped) from available.
realizedU64, err := custody.Withdraw(caller, asset, want.Uint64())
if err != nil {
return big.NewInt(0), fmt.Errorf("%w: clob_withdraw: %v", ErrSettlementFailed, err)
}
realized := new(big.Int).SetUint64(realizedU64)
// 2) RELEASE leg (C-Chain): pay exactly the realized amount from the vault.
if realizedU64 > 0 {
if rerr := pm.releaseNativeFromVault(stateDB, caller, realized); rerr != nil {
return big.NewInt(0), rerr
}
}
if bound {
storeWithdrawBinding(stateDB, bindKey, realized)
}
return realized, nil
}
// BalanceOf returns account's AVAILABLE D-Chain balance for asset (read-only). It
// reads through the custody backend (clob_balance); no StateDB mutation. Returns
// 0 for a non-custody backend (nothing deposited).
func (pm *PoolManager) BalanceOf(account common.Address, asset Currency) (*big.Int, error) {
custody, ok := pm.engine.(custodyEngine)
if !ok {
return big.NewInt(0), nil
}
avail, err := custody.Balance(account, asset)
if err != nil {
return big.NewInt(0), err
}
return new(big.Int).SetUint64(avail), nil
}
func (pm *PoolManager) callHook(stateDB StateDB, hookAddr common.Address, flag HookFlags, args ...any) error {
+1 -1
View File
@@ -100,7 +100,7 @@ func (m *mockEngine) Initialize(sqrtPriceX96 *big.Int) (int24, error) {
return 0, nil
}
func (m *mockEngine) Swap(pool *PoolState, params SwapParams) (BalanceDelta, error) {
func (m *mockEngine) Swap(pool *PoolState, _ common.Address, params SwapParams) (BalanceDelta, error) {
if params.AmountSpecified.Sign() == 0 {
return ZeroBalanceDelta(), nil
}
+6
View File
@@ -422,6 +422,12 @@ var (
ErrLiquidationTooSmall = errors.New("liquidation amount too small")
ErrFlashLiquidationDisabled = errors.New("flash liquidation disabled")
ErrInvalidParameter = errors.New("invalid parameter")
// ErrERC20DepositUnsupported is returned by deposit/withdraw for a non-native
// (ERC-20) asset until the real C-Chain lock/mint bridge (transferFrom on
// deposit, transfer on release) is wired. The CLOB still settles ONLY inside
// the D-Chain ledger; this refusal prevents minting an UNBACKED D-Chain credit
// (or releasing an unbacked C-Chain token) rather than faking a reserve.
ErrERC20DepositUnsupported = errors.New("dex: ERC-20 deposit/withdraw not yet supported (native LUX only); use the atomic proxy import/export for canonical D-Chain assets")
)
// Errors - Perpetuals
Executable
BIN
View File
Binary file not shown.