Files
precompile/dex/engine_zap.go
T
zeekay bfa49d10ff 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].
2026-06-17 22:30:06 -07:00

921 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"context"
"encoding/binary"
"fmt"
"math"
"math/big"
"sync"
"time"
"github.com/luxfi/geth/common"
"github.com/luxfi/rpc"
"github.com/zeebo/blake3"
)
// ZAP CLOB method names. These MUST match the server handlers registered by the
// lux/dex ZAP server (github.com/luxfi/dex/pkg/api). The V4 PoolManager facade
// at LP-9010 is a CENTRAL LIMIT ORDER BOOK, not an AMM: this adapter translates
// V4 PoolManager calls into CLOB operations and keeps NO canonical order/pool
// state — the resting book lives server-side on the D-Chain DEX.
//
// Mapping:
// - initialize(poolKey) -> clob_ensure_market (create the book)
// - modifyLiquidity(+delta) -> clob_place (rest a limit order)
// - modifyLiquidity(-delta) -> clob_cancel (pull a resting order)
// - swap(params) -> clob_submit (marketable order -> fills)
//
// Market identity on the wire is the 32-byte V4 poolId.
const (
ZAPMethodEnsureMarket = "clob_ensure_market"
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
// CLOB ZAP surface advertises no brand on the wire (the matcher is brand-free),
// so this is what Brand() always returns on the ZAP path.
const brandFallback = "DEX"
// fillWireSize is one fill in a clob_submit response: price[8]+size[8]+side[1].
const fillWireSize = 17
// zapConn is the connection contract used by ZAPEngine. Production uses
// *rpc.ZAPConn; the interface lets tests drive the engine without a socket.
type zapConn interface {
Call(ctx context.Context, method string, payload []byte) ([]byte, error)
Close() error
}
// zapDialer establishes a zapConn to addr. Overridable in tests; defaults to
// the canonical rpc.ZAPDial.
var zapDialer = func(ctx context.Context, addr string) (zapConn, error) {
return rpc.ZAPDial(ctx, addr)
}
// ZAPEngine is the V4 PoolManager -> D-Chain CLOB adapter. It implements the
// Engine contract by forwarding every operation to the canonical order book over
// ZAP (github.com/luxfi/rpc). It holds NO canonical state: the only per-pool
// data it keeps is a routing handle (poolId), so the resting book lives
// exclusively server-side.
//
// The precompile's *PoolState (sourced from C-Chain StateDB) is a thin
// read-through view: this adapter mirrors V4 surface onto a CLOB, where there is
// no continuous sqrtPrice/liquidity curve. So PoolState's AMM scalars are NOT
// synced from a curve — they are left as the initialize-time values; the
// authoritative truth is the server-side book, queried per operation.
type ZAPEngine struct {
addr string
timeout time.Duration
mu sync.Mutex
conn zapConn
// routes maps a precompile PoolState (by pointer identity, valid within the
// process) to the V4 poolId the server keys its market by. Set via
// SetPoolID before delegating; a routing handle, NOT state.
routes map[*PoolState][32]byte
// initPrice records the initialize-time price (currency1 per currency0) for
// each market, used to decide whether a single-tick LP order rests as a bid
// or an ask. It is a routing aid, not canonical state — the book is.
initPrice map[[32]byte]float64
// orderRef maps an authenticated maker order handle to the server-side
// orderID returned at place time. The key is BLAKE3(maker||poolId||salt) —
// a value derived from the EVM caller's own identity, NEVER from a
// caller-supplied raw orderID. Cancel resolves the orderID through this map
// so a caller can only cancel orders keyed to its OWN maker tuple (fixes the
// IDOR — RED H4). It is a routing handle, not canonical book state.
orderRef map[[32]byte]uint64
// orderMaker records, per server orderID, the maker that placed it, so cancel
// re-verifies caller==maker as defense in depth even after a resolve.
orderMaker map[uint64]common.Address
}
// Compile-time assertions: ZAPEngine satisfies the Engine contract and the
// internal poolRouter seam the PoolManager uses to thread the canonical poolId.
// Replay-idempotency is the PoolManager's StateDB concern, not the engine's, so
// there is no binder seam here.
var (
_ Engine = (*ZAPEngine)(nil)
_ poolRouter = (*ZAPEngine)(nil)
)
// NewZAPEngine creates a ZAP adapter targeting the DEX engine's ZAP endpoint
// (e.g. "localhost:9100"). Signature is stable: the EVM plugin constructs it as
// NewZAPEngine(endpoint, timeout).
func NewZAPEngine(addr string, timeout time.Duration) *ZAPEngine {
return &ZAPEngine{
addr: addr,
timeout: timeout,
routes: make(map[*PoolState][32]byte),
initPrice: make(map[[32]byte]float64),
orderRef: make(map[[32]byte]uint64),
orderMaker: make(map[uint64]common.Address),
}
}
// makerOrderKey derives the authenticated order handle from the maker's own
// identity (owner), the market (poolId) and the position salt. Because the key
// is bound to the caller's address, a caller can address ONLY its own resting
// orders — the raw server orderID never crosses the ABI from the caller.
func makerOrderKey(owner common.Address, poolID [32]byte, salt [32]byte) [32]byte {
h := blake3.New()
h.Write(owner.Bytes())
h.Write(poolID[:])
h.Write(salt[:])
var k [32]byte
h.Digest().Read(k[:])
return k
}
// SetPoolID records the canonical poolId for a PoolState so subsequent
// place/cancel/submit forward to the right server-side market. Called by the
// PoolManager (poolRouter seam) before each engine delegation.
func (z *ZAPEngine) SetPoolID(ps *PoolState, poolID [32]byte) {
z.mu.Lock()
z.routes[ps] = poolID
z.mu.Unlock()
}
// poolID returns the routing handle recorded for a PoolState, or an error when
// none was set — which means the PoolManager delegated without routing.
func (z *ZAPEngine) poolID(ps *PoolState) ([32]byte, error) {
z.mu.Lock()
id, ok := z.routes[ps]
z.mu.Unlock()
if !ok {
return [32]byte{}, fmt.Errorf("ZAP: no poolId routed for pool state (init not forwarded?)")
}
return id, nil
}
func (z *ZAPEngine) dial(ctx context.Context) (zapConn, error) {
z.mu.Lock()
defer z.mu.Unlock()
if z.conn != nil {
return z.conn, nil
}
c, err := zapDialer(ctx, z.addr)
if err != nil {
return nil, fmt.Errorf("ZAP dial %s: %w", z.addr, err)
}
z.conn = c
return c, nil
}
func (z *ZAPEngine) call(ctx context.Context, method string, payload []byte) ([]byte, error) {
c, err := z.dial(ctx)
if err != nil {
return nil, err
}
return c.Call(ctx, method, payload)
}
// =========================================================================
// Engine contract — every method forwards to the canonical CLOB server.
// =========================================================================
// Initialize computes the initial tick from sqrtPriceX96 with the shared V4
// TickMath. It creates no state (the market is created server-side by
// InitializePool on the ZAP path), so the Engine.Initialize contract stays a
// pure tick computation.
func (z *ZAPEngine) Initialize(sqrtPriceX96 *big.Int) (int24, error) {
tick, err := GetTickAtSqrtRatio(sqrtPriceX96)
if err != nil {
return 0, fmt.Errorf("ZAP Initialize: %w", err)
}
return tick, nil
}
// InitializePool ensures the canonical market (order book) exists on the server,
// keyed by poolID, and records the routing handle + the initialize-time price
// for the given PoolState. Returns the authoritative initial tick (recomputed
// from sqrtPriceX96 with the shared V4 math — the CLOB has no curve to report).
func (z *ZAPEngine) InitializePool(ps *PoolState, poolID [32]byte, sqrtPriceX96 *big.Int, tickSpacing int32, lpFee uint32) (int24, error) {
ctx, cancel := context.WithTimeout(context.Background(), z.timeout)
defer cancel()
tick, err := GetTickAtSqrtRatio(sqrtPriceX96)
if err != nil {
return 0, fmt.Errorf("ZAP InitializePool: %w", err)
}
payload := make([]byte, 32)
copy(payload[0:32], poolID[:])
resp, err := z.call(ctx, ZAPMethodEnsureMarket, payload)
if err != nil {
return 0, fmt.Errorf("ZAP InitializePool: %w", err)
}
if _, status, reason, derr := decodeAck(resp); derr != nil {
return 0, fmt.Errorf("ZAP InitializePool ack: %w", derr)
} else if status == clobStatusRejected {
return 0, fmt.Errorf("ZAP InitializePool rejected: %s", reason)
}
z.mu.Lock()
z.routes[ps] = poolID
z.initPrice[poolID] = sqrtPriceX96ToPrice(sqrtPriceX96)
z.mu.Unlock()
return tick, nil
}
// Swap submits a MARKETABLE order against the book and derives the V4
// BalanceDelta from the server-returned FILLS — never from any client-supplied
// amount. The delta is value-conserving by construction (see fillsToDelta).
//
// V4 sign convention (taker perspective): positive = user owes the pool,
// negative = pool owes the user.
// - zeroForOne (sell currency0 for currency1): Amount0 > 0, Amount1 < 0.
// - !zeroForOne (buy currency0 with currency1): Amount0 < 0, Amount1 > 0.
//
// AmountSpecified < 0 = exact input, > 0 = exact output (V4 convention). The
// 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, 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,
// stateless forward of one marketable order to the d-chain book.
id, err := z.poolID(pool)
if err != nil {
return ZeroBalanceDelta(), err
}
if params.AmountSpecified == nil || params.AmountSpecified.Sign() == 0 {
return ZeroBalanceDelta(), fmt.Errorf("ZAP Swap: zero amountSpecified")
}
size := new(big.Int).Abs(params.AmountSpecified)
sizeF := bigToFloat(size)
if sizeF <= 0 {
return ZeroBalanceDelta(), fmt.Errorf("ZAP Swap: non-positive size")
}
// zeroForOne sells currency0 -> CLOB side Sell (1); else Buy (0).
var side uint8
if params.ZeroForOne {
side = 1
}
// SqrtPriceLimitX96 becomes a CLOB worst-acceptable price bound. A limit at
// or beyond the global min/max is treated as a pure market order. A
// marketable order is IOC server-side: it crosses what it can and the
// remainder is dropped, never rested (SubmitMarketable on the d-chain).
limitPrice, isMarket := priceLimitToCLOB(params.ZeroForOne, params.SqrtPriceLimitX96)
payload := make([]byte, 66)
copy(payload[0:32], id[:])
payload[32] = side
if isMarket {
payload[33] = 1
}
putFloat64(payload[34:42], limitPrice)
putFloat64(payload[42:50], sizeF)
// 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()
resp, err := z.call(ctx, ZAPMethodSubmit, payload)
if err != nil {
return ZeroBalanceDelta(), fmt.Errorf("ZAP Swap: %w", err)
}
fills, err := decodeFills(resp)
if err != nil {
return ZeroBalanceDelta(), fmt.Errorf("ZAP Swap decode: %w", err)
}
delta, err := fillsToDelta(params.ZeroForOne, fills)
if err != nil {
return ZeroBalanceDelta(), fmt.Errorf("ZAP Swap delta: %w", err)
}
return delta, nil
}
// ModifyLiquidity maps to placing (+delta) or cancelling (-delta) a RESTING
// limit order — "liquidity" in a CLOB is resting orders. The order price is the
// price at TickLower; its side is bid if that price is below the market's
// initialize-time price, ask if above (a single-tick point of liquidity, the
// faithful CLOB analog of a V4 single-tick range).
//
// Returns the caller's BalanceDelta for the resting order (what the LP must post
// to the book) and zero fees: a resting maker order accrues no fee until it is
// taken, which happens via a counterparty's Swap.
func (z *ZAPEngine) ModifyLiquidity(pool *PoolState, owner common.Address, params ModifyLiquidityParams) (BalanceDelta, BalanceDelta, error) {
id, err := z.poolID(pool)
if err != nil {
return ZeroBalanceDelta(), ZeroBalanceDelta(), err
}
if params.LiquidityDelta == nil || params.LiquidityDelta.Sign() == 0 {
return ZeroBalanceDelta(), ZeroBalanceDelta(), fmt.Errorf("ZAP ModifyLiquidity: zero delta")
}
ctx, cancel := context.WithTimeout(context.Background(), z.timeout)
defer cancel()
price, err := tickToPrice(params.TickLower)
if err != nil {
return ZeroBalanceDelta(), ZeroBalanceDelta(), fmt.Errorf("ZAP ModifyLiquidity: %w", err)
}
// The maker order handle is derived from the CALLER's own identity (owner),
// the market, and the position salt — never from caller-supplied raw order
// bytes. A caller can therefore only address resting orders it placed itself.
refKey := makerOrderKey(owner, id, params.Salt)
if params.LiquidityDelta.Sign() < 0 {
// Cancel: resolve the server orderID through the authenticated handle.
// Reading it from caller-controlled Salt bytes was an IDOR — any caller
// could cancel any LP's order by guessing the sequential id (RED H4).
orderID, ok := z.lookupOrder(refKey, owner)
if !ok {
return ZeroBalanceDelta(), ZeroBalanceDelta(), fmt.Errorf("ZAP cancel: no resting order for caller at this position")
}
payload := make([]byte, 40)
copy(payload[0:32], id[:])
binary.BigEndian.PutUint64(payload[32:40], orderID)
resp, cerr := z.call(ctx, ZAPMethodCancel, payload)
if cerr != nil {
return ZeroBalanceDelta(), ZeroBalanceDelta(), fmt.Errorf("ZAP ModifyLiquidity cancel: %w", cerr)
}
if _, status, reason, derr := decodeAck(resp); derr != nil {
return ZeroBalanceDelta(), ZeroBalanceDelta(), derr
} else if status == clobStatusRejected {
return ZeroBalanceDelta(), ZeroBalanceDelta(), fmt.Errorf("ZAP cancel rejected: %s", reason)
}
z.forgetOrder(refKey, orderID)
// Removing liquidity returns funds to the LP: negative delta (pool owes).
delta := restingOrderDelta(z.side(id, price), price, bigToFloat(new(big.Int).Abs(params.LiquidityDelta)))
return delta.Negate(), ZeroBalanceDelta(), nil
}
// Place a resting limit order.
side := z.side(id, price)
sizeF := bigToFloat(params.LiquidityDelta)
payload := make([]byte, 65)
copy(payload[0:32], id[:])
payload[32] = side
putFloat64(payload[33:41], price)
putFloat64(payload[41:49], sizeF)
copy(payload[49:65], owner.Bytes()[:16]) // 16-byte maker identity
resp, err := z.call(ctx, ZAPMethodPlace, payload)
if err != nil {
return ZeroBalanceDelta(), ZeroBalanceDelta(), fmt.Errorf("ZAP ModifyLiquidity place: %w", err)
}
orderID, status, reason, derr := decodeAck(resp)
if derr != nil {
return ZeroBalanceDelta(), ZeroBalanceDelta(), derr
}
if status == clobStatusRejected {
return ZeroBalanceDelta(), ZeroBalanceDelta(), fmt.Errorf("ZAP place rejected: %s", reason)
}
// Bind the server orderID to the authenticated maker handle so only this
// maker can later cancel exactly this order. The position salt (owner+ticks)
// keys the handle, so a maker can hold one resting order per V4 position.
z.recordOrder(refKey, orderID, owner)
// Posting a resting order requires the LP to fund it: positive delta (LP owes
// the book).
delta := restingOrderDelta(side, price, sizeF)
return delta, ZeroBalanceDelta(), nil
}
// recordOrder binds an authenticated maker handle to the server orderID at place
// time, and records the maker per orderID for the cancel-time re-check.
func (z *ZAPEngine) recordOrder(refKey [32]byte, orderID uint64, maker common.Address) {
z.mu.Lock()
z.orderRef[refKey] = orderID
z.orderMaker[orderID] = maker
z.mu.Unlock()
}
// lookupOrder resolves the server orderID for an authenticated maker handle and
// re-verifies the recorded maker matches the caller (defense in depth). It
// returns ok=false if no order is bound to this caller's handle.
func (z *ZAPEngine) lookupOrder(refKey [32]byte, caller common.Address) (uint64, bool) {
z.mu.Lock()
defer z.mu.Unlock()
orderID, ok := z.orderRef[refKey]
if !ok {
return 0, false
}
if maker, ok := z.orderMaker[orderID]; !ok || maker != caller {
return 0, false
}
return orderID, true
}
// forgetOrder drops the binding after a successful cancel so a stale handle
// cannot be replayed against a recycled server orderID.
func (z *ZAPEngine) forgetOrder(refKey [32]byte, orderID uint64) {
z.mu.Lock()
delete(z.orderRef, refKey)
delete(z.orderMaker, orderID)
z.mu.Unlock()
}
// Donate has no CLOB analog: a central limit order book has no shared liquidity
// pool to gift fees into. It is a no-op that returns a zero delta — never a
// silent value transfer.
func (z *ZAPEngine) Donate(pool *PoolState, amount0, amount1 *big.Int) (BalanceDelta, error) {
if _, err := z.poolID(pool); err != nil {
return ZeroBalanceDelta(), err
}
return ZeroBalanceDelta(), nil
}
// Quote estimates output for amountIn by reading the resting book's best price.
// It does not mutate the book. A CLOB quote is the marginal price at the top of
// book; without walking depth it is a first-level estimate, used by the router.
func (z *ZAPEngine) Quote(pool *Pool, amountIn *big.Int, zeroForOne bool) *big.Int {
id, ok := z.poolIDForBase(pool)
if !ok {
return big.NewInt(0)
}
z.mu.Lock()
price := z.initPrice[id]
z.mu.Unlock()
if price <= 0 || amountIn == nil || amountIn.Sign() <= 0 {
return big.NewInt(0)
}
in := bigToFloat(amountIn)
var out float64
if zeroForOne {
out = in * price // sell base -> quote
} else {
out = in / price // buy base with quote
}
return roundToBig(out)
}
// poolIDForBase resolves a routing handle from a base *Pool by matching the
// embedded *Pool of a routed PoolState.
func (z *ZAPEngine) poolIDForBase(pool *Pool) ([32]byte, bool) {
z.mu.Lock()
defer z.mu.Unlock()
for ps, id := range z.routes {
if ps.Pool == pool {
return id, true
}
}
return [32]byte{}, false
}
// side decides whether a resting order at price rests as a bid (0) or ask (1):
// below the market's reference price -> bid; at/above -> ask.
func (z *ZAPEngine) side(id [32]byte, price float64) uint8 {
z.mu.Lock()
ref := z.initPrice[id]
z.mu.Unlock()
if ref > 0 && price < ref {
return 0 // buy / bid
}
return 1 // sell / ask
}
// Brand returns the neutral identity. The CLOB matcher advertises no brand on
// the wire, so the ZAP path is always the brand-free fallback.
func (z *ZAPEngine) Brand() string { return brandFallback }
// Close closes the ZAP connection.
func (z *ZAPEngine) Close() error {
z.mu.Lock()
defer z.mu.Unlock()
if z.conn != nil {
err := z.conn.Close()
z.conn = nil
return err
}
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)
// =========================================================================
// fill is one server-produced execution: price (currency1/currency0) and size
// (currency0 base units).
type fill struct {
price float64
size float64
}
// decodeFills parses a clob_submit response: count[4] then count×(price[8] +
// size[8] + side[1]). Every numeric is range-checked: a backend that lies (or a
// MITM on the socket) cannot inject a NaN/Inf or negative fill.
func decodeFills(data []byte) ([]fill, error) {
if len(data) < 4 {
return nil, fmt.Errorf("fills response too short: %d", len(data))
}
n := int(binary.BigEndian.Uint32(data[0:4]))
if 4+n*fillWireSize > len(data) {
return nil, fmt.Errorf("fills response truncated: count=%d len=%d", n, len(data))
}
fills := make([]fill, 0, n)
off := 4
for i := 0; i < n; i++ {
p := getFloat64(data[off : off+8])
s := getFloat64(data[off+8 : off+16])
off += fillWireSize
if math.IsNaN(p) || math.IsInf(p, 0) || p <= 0 {
return nil, fmt.Errorf("fill %d: invalid price %v", i, p)
}
if math.IsNaN(s) || math.IsInf(s, 0) || s <= 0 {
return nil, fmt.Errorf("fill %d: invalid size %v", i, s)
}
fills = append(fills, fill{price: p, size: s})
}
return fills, nil
}
// fillsToDelta turns server-returned fills into the taker's V4 BalanceDelta and
// ASSERTS value conservation: base traded = sum(size); quote traded =
// sum(price*size); both must be non-negative and finite. The two legs are equal
// and opposite across the taker/maker boundary by construction (the taker
// receives exactly what makers give), so the cross-counterparty net is zero —
// we encode the taker's half here and the makers' half is the negation that the
// resting orders represent.
//
// Sign convention (taker):
// - zeroForOne (sell base): Amount0 = +base (owes pool), Amount1 = -quote.
// - !zeroForOne (buy base): Amount0 = -base (pool owes), Amount1 = +quote.
//
// DIRECTIONAL ROUNDING — must match the cross-chain proxy leg EXACTLY (RED
// debit!=credit). Fills cross the ZAP wire as float64; both the C-Chain taker
// debit produced HERE and the proxy's cross-chain credit (chains/dexvm
// settleFromFills via quantToCredit/quantToCharge) settle the SAME fill stream,
// so they MUST round with the SAME asymmetric, conservation-safe rule or the two
// legs diverge per fill (a 4.5 notional that this leg round-to-nearests to 5
// while the proxy floors to 4 burns a unit; reverse the fraction and a unit is
// minted). The invariant for BOTH legs: a quantity the taker OWES the pool
// (positive delta) rounds UP (ceil); a quantity the pool OWES the taker
// (negative delta) rounds DOWN in magnitude (floor). The taker is therefore
// never credited a sub-unit it did not realize and never charged less than it
// truly consumed — the EVM leg, like the proxy leg, never mints in the taker's
// favor. Each leg's float aggregate is summed ONCE then rounded ONCE at the
// asset boundary (per-fill rounding would accumulate a directional leak).
func fillsToDelta(zeroForOne bool, fills []fill) (BalanceDelta, error) {
base := 0.0
quote := 0.0
for _, f := range fills {
base += f.size
quote += f.price * f.size
}
if base < 0 || quote < 0 || math.IsInf(base, 0) || math.IsInf(quote, 0) {
return ZeroBalanceDelta(), fmt.Errorf("non-conserving fills: base=%v quote=%v", base, quote)
}
var amount0, amount1 *big.Int
if zeroForOne {
// sell base: taker OWES base (ceil), RECEIVES quote (floor).
amount0 = ceilToBig(base)
amount1 = new(big.Int).Neg(floorToBig(quote))
} else {
// buy base: taker RECEIVES base (floor), OWES quote (ceil).
amount0 = new(big.Int).Neg(floorToBig(base))
amount1 = ceilToBig(quote)
}
return NewBalanceDelta(amount0, amount1), nil
}
// restingOrderDelta is the BalanceDelta an LP must post to back a resting order
// of `size` base at `price`. A bid (side 0) funds quote (Amount1); an ask
// (side 1) funds base (Amount0). Positive = LP owes the book. The funded amount
// the LP OWES rounds UP (ceil) — the same conservation-safe direction as an
// owed leg in fillsToDelta — so an LP can never back an order for more value
// than it posted. (The mirror cancel returns this exact delta negated, so the
// place/cancel pair is symmetric and nets to zero for an untaken order.)
func restingOrderDelta(side uint8, price, size float64) BalanceDelta {
if side == 0 { // bid: post quote = price*size
return NewBalanceDelta(big.NewInt(0), ceilToBig(price*size))
}
// ask: post base = size
return NewBalanceDelta(ceilToBig(size), big.NewInt(0))
}
// =========================================================================
// V4<->CLOB numeric conversions
// =========================================================================
// sqrtPriceX96ToPrice converts a Q64.96 sqrt price to a float price
// (currency1 per currency0): price = (sqrtP / 2^96)^2.
func sqrtPriceX96ToPrice(sqrtPriceX96 *big.Int) float64 {
if sqrtPriceX96 == nil || sqrtPriceX96.Sign() <= 0 {
return 0
}
sp := bigToFloat(sqrtPriceX96)
q96 := math.Ldexp(1, 96) // 2^96
r := sp / q96
return r * r
}
// tickToPrice converts a V4 tick to a float price via the shared TickMath.
func tickToPrice(tick int24) (float64, error) {
sqrtRatio, err := GetSqrtRatioAtTick(tick)
if err != nil {
return 0, err
}
return sqrtPriceX96ToPrice(sqrtRatio), nil
}
// priceLimitToCLOB converts a V4 SqrtPriceLimitX96 into a CLOB worst-acceptable
// price bound. A limit at/over the global min (zeroForOne) or max (!zeroForOne)
// means "no bound" -> pure market order. Otherwise the bound is the squared
// sqrt-price, the worst price the taker accepts.
func priceLimitToCLOB(zeroForOne bool, limit *big.Int) (price float64, isMarket bool) {
if limit == nil {
return 0, true
}
if zeroForOne {
// price decreases; a limit at MinSqrtRatio+1 (or below) is unbounded.
if limit.Cmp(new(big.Int).Add(MinSqrtRatio, big.NewInt(2))) <= 0 {
return 0, true
}
} else {
if limit.Cmp(new(big.Int).Sub(MaxSqrtRatio, big.NewInt(2))) >= 0 {
return 0, true
}
}
return sqrtPriceX96ToPrice(limit), false
}
// bigToFloat converts a big.Int to float64 (the CLOB price grid is float64).
func bigToFloat(v *big.Int) float64 {
if v == nil {
return 0
}
f, _ := new(big.Float).SetInt(v).Float64()
return f
}
// settlementRoundEpsilon is the relative tolerance for snapping a float aggregate
// to a neighboring integer before directional floor/ceil. It mirrors the proxy's
// constant of the same name (chains/dexvm atomic.go) so the two settlement legs
// snap identically: ~1e-9 dwarfs the ~1e-13 double-rounding error of summing a
// realistic fill stream yet is far below one asset unit, so it never moves a
// genuinely fractional notional across an integer. Deterministic over
// deterministic float inputs, so every node rounds the same.
const settlementRoundEpsilon = 1e-9
// snapNearInt returns the integer f is within settlementRoundEpsilon of, else f
// unchanged — so a mathematically-integral aggregate (e.g. 1.5*3+2.5*3 == 12 but
// evaluating to 12±1e-13) floors/ceils to that integer rather than to 11/13.
func snapNearInt(f float64) float64 {
r := math.Round(f)
if math.Abs(f-r) <= settlementRoundEpsilon*math.Max(1, math.Abs(f)) {
return r
}
return f
}
// floorToBig converts a non-negative float64 to a big.Int rounding DOWN — for a
// quantity the taker/LP RECEIVES (never credit a unit not realized). Mirrors the
// proxy's quantToCredit so the two settlement legs agree to the unit.
func floorToBig(f float64) *big.Int {
if f <= 0 || math.IsNaN(f) || math.IsInf(f, 0) {
return big.NewInt(0)
}
bf := new(big.Float).SetFloat64(math.Floor(snapNearInt(f)))
i, _ := bf.Int(nil)
return i
}
// ceilToBig converts a non-negative float64 to a big.Int rounding UP — for a
// quantity the taker/LP OWES the book (never understate what is owed). Mirrors
// the proxy's quantToCharge so the two settlement legs agree to the unit.
func ceilToBig(f float64) *big.Int {
if f <= 0 || math.IsNaN(f) || math.IsInf(f, 0) {
return big.NewInt(0)
}
bf := new(big.Float).SetFloat64(math.Ceil(snapNearInt(f)))
i, _ := bf.Int(nil)
return i
}
// roundToBig converts a non-negative float64 to a big.Int rounding to nearest.
// Used ONLY for non-settlement estimates (Quote), where neither over- nor
// under-statement touches conservation — it never produces a settlement delta.
func roundToBig(f float64) *big.Int {
if f <= 0 || math.IsNaN(f) || math.IsInf(f, 0) {
return big.NewInt(0)
}
bf := new(big.Float).SetFloat64(math.Round(f))
i, _ := bf.Int(nil)
return i
}
// =========================================================================
// ZAP ack / float wire codec (mirrors dex/pkg/api + maker/dex.go exactly)
// =========================================================================
const (
clobStatusPlaced uint8 = 0
clobStatusCanceled uint8 = 1
clobStatusRejected uint8 = 2
)
// decodeAck parses an ack/reject: order_id(8)+status(1)+seq(8), or a reject
// order_id(8)+status(1)+len(2)+reason.
func decodeAck(b []byte) (orderID uint64, status uint8, reason string, err error) {
if len(b) < 9 {
return 0, 0, "", fmt.Errorf("short ack: %d bytes", len(b))
}
orderID = binary.BigEndian.Uint64(b[0:8])
status = b[8]
if status == clobStatusRejected && len(b) >= 11 {
n := int(binary.BigEndian.Uint16(b[9:11]))
if 11+n <= len(b) {
reason = string(b[11 : 11+n])
}
}
return orderID, status, reason, nil
}
func putFloat64(b []byte, f float64) {
binary.BigEndian.PutUint64(b, math.Float64bits(f))
}
func getFloat64(b []byte) float64 {
return math.Float64frombits(binary.BigEndian.Uint64(b))
}