mirror of
https://github.com/luxfi/precompile.git
synced 2026-07-27 03:33:45 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
// =========================================================================
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
Binary file not shown.
Reference in New Issue
Block a user