mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
chains/dexvm: atomic custody rail (custody.go) — deposit=import+credit, withdraw=debit+export
executeDeposit = executeImport (atomic C/X debit, consume-once) + clob_deposit relay (credit D-Chain balance; refuses credited!=imported). executeWithdraw = clob_withdraw (debit, realized clamped) + executeExport (credit exactly realized; refuses realized>want). Reuses the conservation-tested ImportTx/ExportTx rail. custody_conservation_test: real atomic.SharedMemory, deposit→trade→withdraw conserves C in==out, UTXO consumed once. NOTE: not yet wired into the carried-fills BuildBlock plan (network-upgrade-gated follow-up, like #9).
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package dexvm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
|
||||
"github.com/luxfi/chains/dexvm/txs"
|
||||
)
|
||||
|
||||
// custody.go is the FUNDS-IN / FUNDS-OUT rail that connects the proxy's atomic
|
||||
// shared-memory leg (atomic.go) to the D-Chain balance ledger (where the money
|
||||
// lives in the order book). It is the CLOB deposit/withdraw model, decomplected
|
||||
// into the two primitives the proxy already owns plus a single relay:
|
||||
//
|
||||
// DEPOSIT = atomic IMPORT (debit C/X, consume the UTXO exactly once) THEN
|
||||
// relay clob_deposit (credit the account's available D-Chain balance).
|
||||
// The credited D-Chain amount EQUALS the imported value — no escrow,
|
||||
// no settlement math; the value simply MOVES into the book's ledger.
|
||||
//
|
||||
// WITHDRAW = relay clob_withdraw (debit the account's realized D-Chain balance,
|
||||
// returning the REALIZED amount the ledger actually released) THEN
|
||||
// atomic EXPORT of exactly that realized amount (credit C/X).
|
||||
// A withdraw can only export realized, un-escrowed balance, so the
|
||||
// exported value never exceeds what the ledger debited — no mint.
|
||||
//
|
||||
// CONSERVATION (the whole point): every unit that leaves C/X on a deposit import
|
||||
// is credited into the D-Chain ledger; every unit the ledger releases on a
|
||||
// withdraw is exported back to C/X. Across deposit -> (trade inside the D-Chain)
|
||||
// -> withdraw, C/X value out == C/X value in for each account, to the unit. The
|
||||
// trade itself conserves inside the D-Chain ledger (dchain.settleFills moves
|
||||
// maker<->taker exact integers); the proxy never mints because it only ever
|
||||
// imports (consume-once) and exports (realized-only).
|
||||
//
|
||||
// CONSENSUS NOTE (the relay LOCATION): exactly like the order relay (RED #9),
|
||||
// the irreversible/non-deterministic D-Chain relay for deposit/withdraw must run
|
||||
// ONCE network-wide at the PROPOSER's BuildBlock and have its result CARRIED in
|
||||
// the block bytes, so every validator settles the atomic leg from carried bytes
|
||||
// (never a per-validator relay). The primitives here are the relay+atomic
|
||||
// composition; wiring them into BuildBlock's carried-result plan (a withdraw
|
||||
// carries its realized amount; a deposit carries only an ack) is the same
|
||||
// proposer-once+carry discipline obtainFills/settleCarried already implement for
|
||||
// submits, and the block/vertex wire that carries them is the network-upgrade-
|
||||
// gated change. These functions are written to be driven from that plan: deposit
|
||||
// returns nothing to carry (idempotent credit), withdraw returns the realized
|
||||
// amount to carry into the export leg.
|
||||
|
||||
// assetHandle folds a 32-byte cross-chain asset id to the 8-byte asset handle the
|
||||
// D-Chain ledger keys balances by (big-endian over the leading 8 bytes). The same
|
||||
// fold MUST be used wherever a balance is credited/debited for an asset, so the
|
||||
// deposit and withdraw of one asset agree on its handle. (The D-Chain side keys
|
||||
// balance:<user:8><asset:8>; this is the proxy's view of that 8-byte handle.)
|
||||
func assetHandle(asset ids.ID) uint64 {
|
||||
return binary.BigEndian.Uint64(asset[:8])
|
||||
}
|
||||
|
||||
// userHandle renders a 20-byte account address to the 16-byte user identity the
|
||||
// frozen CLOB frames carry (the leading 16 bytes of the address, the same width
|
||||
// zapwire.UserSize uses). The D-Chain folds this 16-byte user to its 8-byte
|
||||
// UserID; binding the proxy's deposit/withdraw to the same 16-byte slice keeps
|
||||
// the credited/debited account consistent with the orders that account places.
|
||||
func userHandle(addr ids.ShortID) string {
|
||||
return string(addr[:])
|
||||
}
|
||||
|
||||
// encodeDepositFrame builds the FROZEN clob_deposit payload: user[16] + asset[8]
|
||||
// + amount[8]. Byte-identical with github.com/luxfi/dex/pkg/zapwire.EncodeDeposit
|
||||
// (the proxy cannot import the cgo-tagged d-chain package; a parity test pins it).
|
||||
func encodeDepositFrame(user string, asset, amount uint64) []byte {
|
||||
out := make([]byte, DepositReqSize)
|
||||
copy(out[0:16], padUser(user))
|
||||
binary.BigEndian.PutUint64(out[16:24], asset)
|
||||
binary.BigEndian.PutUint64(out[24:32], amount)
|
||||
return out
|
||||
}
|
||||
|
||||
// encodeWithdrawFrame builds the FROZEN clob_withdraw payload: user[16] +
|
||||
// asset[8] + amount[8].
|
||||
func encodeWithdrawFrame(user string, asset, amount uint64) []byte {
|
||||
out := make([]byte, WithdrawReqSize)
|
||||
copy(out[0:16], padUser(user))
|
||||
binary.BigEndian.PutUint64(out[16:24], asset)
|
||||
binary.BigEndian.PutUint64(out[24:32], amount)
|
||||
return out
|
||||
}
|
||||
|
||||
// padUser left-copies a user identity into a fresh 16-byte (UserSize) buffer,
|
||||
// null-padded — the frozen frame's user field encoding.
|
||||
func padUser(user string) []byte {
|
||||
b := make([]byte, 16)
|
||||
copy(b, user)
|
||||
return b
|
||||
}
|
||||
|
||||
// decodeBalanceResp reads (status, realized amount) from a clob_deposit /
|
||||
// clob_withdraw response (status[1] + amount[8]). A short response is malformed
|
||||
// wire and is refused — the proxy never derives a settlement amount from garbage.
|
||||
func decodeBalanceResp(resp []byte) (status uint8, amount uint64, err error) {
|
||||
if len(resp) < BalanceRespSize {
|
||||
return 0, 0, fmt.Errorf("dexvm custody: balance response too short: %d", len(resp))
|
||||
}
|
||||
return resp[0], binary.BigEndian.Uint64(resp[1:9]), nil
|
||||
}
|
||||
|
||||
// executeDeposit runs the DEPOSIT rail for one import: atomically claim the
|
||||
// imported value (consume-once) and relay a clob_deposit to credit exactly that
|
||||
// value into the importer's available D-Chain balance. The credited amount is the
|
||||
// import's total output value (the value locked by the atomic leg), so the
|
||||
// D-Chain ledger gains precisely what C/X lost — conservation across the rail.
|
||||
//
|
||||
// The relay is the irreversible/non-deterministic leg; in production it runs once
|
||||
// at the proposer's BuildBlock (carried as an idempotent ack). Here it composes
|
||||
// the already-tested executeImport with the relay so a caller (BuildBlock plan,
|
||||
// or the conservation test) gets the full funds-in path in one call.
|
||||
func (vm *VM) executeDeposit(ctx context.Context, tx *txs.ImportTx, ar *atomicRequests) error {
|
||||
// 1) Atomic import: consume the source UTXO(s), accumulate the remove. This is
|
||||
// the SAME consume-once-guarded primitive the escrow path used; the only
|
||||
// difference is what happens to the value next (credited to the D-Chain
|
||||
// ledger, not recorded as proxy escrow).
|
||||
if err := vm.executeImport(tx, ar); err != nil {
|
||||
return err
|
||||
}
|
||||
// 2) Credit the D-Chain ledger with exactly the imported value, in the imported
|
||||
// asset, for the importer. A deposit with no credited outputs (pure fee
|
||||
// burn) credits nothing.
|
||||
if len(tx.Outputs) == 0 {
|
||||
return nil
|
||||
}
|
||||
owner := tx.Outputs[0].Owner
|
||||
asset := assetHandle(tx.Outputs[0].Asset)
|
||||
var amount uint64
|
||||
for _, o := range tx.Outputs {
|
||||
// All outputs of a single deposit are the same (owner, asset) — sum the
|
||||
// imported value into one ledger credit.
|
||||
amount += o.Amount
|
||||
}
|
||||
frame := encodeDepositFrame(userHandle(owner), asset, amount)
|
||||
resp, err := vm.relay.Relay(ctx, ZAPMethodDeposit, frame)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dexvm custody: deposit relay: %w", err)
|
||||
}
|
||||
status, credited, derr := decodeBalanceResp(resp)
|
||||
if derr != nil {
|
||||
return derr
|
||||
}
|
||||
// The D-Chain must credit exactly the imported value; a short credit would
|
||||
// strand value (imported but not in the ledger). Refuse the divergence rather
|
||||
// than silently lose funds.
|
||||
if credited != amount {
|
||||
return fmt.Errorf("dexvm custody: deposit credited %d != imported %d (status %d)", credited, amount, status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeWithdraw runs the WITHDRAW rail for one account/asset: relay a
|
||||
// clob_withdraw to debit the account's realized available D-Chain balance
|
||||
// (returning the REALIZED amount the ledger released, clamped to availability),
|
||||
// then atomically EXPORT exactly that realized amount back to the destination
|
||||
// chain. The export value is the ledger's realized debit, so C/X gains precisely
|
||||
// what the D-Chain ledger released — never more (no mint).
|
||||
//
|
||||
// owner is the account (its address); asset is the cross-chain asset id; want is
|
||||
// the requested withdrawal (the ledger clamps it to available). destChain is
|
||||
// where the value is exported (C-Chain). fillRef binds the export for audit. The
|
||||
// returned realized amount is what was exported (0 => nothing available, no export
|
||||
// built). The relay is the irreversible leg (proposer-once + carry the realized
|
||||
// amount in production).
|
||||
func (vm *VM) executeWithdraw(ctx context.Context, owner ids.ShortID, asset ids.ID, want uint64, destChain, fillRef ids.ID, txIndex uint32, createdAt int64, ar *atomicRequests) (uint64, error) {
|
||||
handle := assetHandle(asset)
|
||||
frame := encodeWithdrawFrame(userHandle(owner), handle, want)
|
||||
resp, err := vm.relay.Relay(ctx, ZAPMethodWithdraw, frame)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("dexvm custody: withdraw relay: %w", err)
|
||||
}
|
||||
_, realized, derr := decodeBalanceResp(resp)
|
||||
if derr != nil {
|
||||
return 0, derr
|
||||
}
|
||||
if realized == 0 {
|
||||
return 0, nil // nothing available to withdraw; no export leg
|
||||
}
|
||||
if realized > want {
|
||||
// The ledger released MORE than requested — impossible for a clamped
|
||||
// withdraw and a mint risk. Refuse rather than export the excess.
|
||||
return 0, fmt.Errorf("dexvm custody: withdraw realized %d exceeds requested %d (mint risk)", realized, want)
|
||||
}
|
||||
// Atomic export of EXACTLY the realized amount in the withdrawn asset.
|
||||
out := []txs.AtomicOutput{{Owner: owner, Asset: asset, Amount: realized}}
|
||||
exportTx := txs.NewSettlementExportTx(owner, txIndex, destChain, out, fillRef, createdAt)
|
||||
if err := vm.executeExport(exportTx, ar); err != nil {
|
||||
return 0, fmt.Errorf("dexvm custody: withdraw export: %w", err)
|
||||
}
|
||||
return realized, nil
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// custody_conservation_test.go proves the FULL CLOB custody rail conserves value
|
||||
// across the C-Chain <-> D-Chain boundary for a NON-NATIVE asset: atomically
|
||||
// DEPOSIT into the D-Chain (ImportTx + clob_deposit), TRADE inside the D-Chain
|
||||
// (modeled by the ledger matcher moving balances), and WITHDRAW back out
|
||||
// (clob_withdraw + ExportTx). The acceptance criterion is the same one the
|
||||
// taker-only escrow model could not meet for the maker leg: C-Chain value OUT ==
|
||||
// C-Chain value IN, to the unit, with the proxy minting NOTHING (import consumes
|
||||
// once; export releases only realized ledger balance).
|
||||
//
|
||||
// REAL atomic.SharedMemory drives the import/export legs; a ledger-modeling fake
|
||||
// matcher stands in for the D-Chain over the zapDialer seam, so clob_deposit
|
||||
// credits and clob_withdraw debits a real in-test balance ledger — exactly what
|
||||
// the dchain.VM does in consensus (pkg/dchain/conservation_test.go proves THAT
|
||||
// side end-to-end against the real VM).
|
||||
|
||||
package dexvm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/database/prefixdb"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/runtime"
|
||||
"github.com/luxfi/vm"
|
||||
"github.com/luxfi/vm/chains/atomic"
|
||||
"github.com/luxfi/warp"
|
||||
|
||||
"github.com/luxfi/chains/dexvm/config"
|
||||
"github.com/luxfi/chains/dexvm/txs"
|
||||
)
|
||||
|
||||
// ledgerMatcher is a fake D-Chain over the zapConn seam that maintains a real
|
||||
// per-(user,asset) available-balance ledger. clob_deposit credits it; clob_submit
|
||||
// (a trade) moves balance between two accounts; clob_withdraw debits it (clamped
|
||||
// to available) and reports the realized amount. It models EXACTLY the invariant
|
||||
// the real d-chain enforces — sum of balances is conserved across deposit ->
|
||||
// trade -> withdraw — so the proxy rail can be proven against a faithful stand-in.
|
||||
type ledgerMatcher struct {
|
||||
bal map[string]uint64 // key = user16 || asset8
|
||||
received [][]byte
|
||||
}
|
||||
|
||||
func newLedgerMatcher() *ledgerMatcher { return &ledgerMatcher{bal: map[string]uint64{}} }
|
||||
|
||||
func ledgerKey(user []byte, asset uint64) string {
|
||||
var a [8]byte
|
||||
binary.BigEndian.PutUint64(a[:], asset)
|
||||
return string(user) + string(a[:])
|
||||
}
|
||||
|
||||
func (m *ledgerMatcher) Call(_ context.Context, method string, payload []byte) ([]byte, error) {
|
||||
m.received = append(m.received, append([]byte(nil), payload...))
|
||||
switch method {
|
||||
case ZAPMethodDeposit:
|
||||
user := payload[0:16]
|
||||
asset := binary.BigEndian.Uint64(payload[16:24])
|
||||
amount := binary.BigEndian.Uint64(payload[24:32])
|
||||
m.bal[ledgerKey(user, asset)] += amount
|
||||
return balResp(0, amount), nil // credited exactly
|
||||
case ZAPMethodWithdraw:
|
||||
user := payload[0:16]
|
||||
asset := binary.BigEndian.Uint64(payload[16:24])
|
||||
want := binary.BigEndian.Uint64(payload[24:32])
|
||||
k := ledgerKey(user, asset)
|
||||
realized := want
|
||||
if realized > m.bal[k] {
|
||||
realized = m.bal[k] // clamp to available (no mint)
|
||||
}
|
||||
m.bal[k] -= realized
|
||||
st := uint8(0)
|
||||
if realized == 0 {
|
||||
st = 2
|
||||
}
|
||||
return balResp(st, realized), nil
|
||||
default:
|
||||
// place/cancel/ensure/open acks.
|
||||
ack := make([]byte, 9)
|
||||
return ack, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ledgerMatcher) Close() error { return nil }
|
||||
|
||||
// trade moves `base` of `baseAsset` from seller->buyer and `quote` of `quoteAsset`
|
||||
// from buyer->seller inside the ledger — what an accepted D-Chain block's
|
||||
// settleFills does. Used by the test to model the cross between the deposit and
|
||||
// withdraw legs (the proxy does not trade; the D-Chain does). buyer/seller are
|
||||
// the 16-byte frame-form user identities (frameUser).
|
||||
func (m *ledgerMatcher) trade(buyer, seller []byte, baseAsset, quoteAsset, base, quote uint64) {
|
||||
m.bal[ledgerKey(buyer, quoteAsset)] -= quote
|
||||
m.bal[ledgerKey(seller, quoteAsset)] += quote
|
||||
m.bal[ledgerKey(seller, baseAsset)] -= base
|
||||
m.bal[ledgerKey(buyer, baseAsset)] += base
|
||||
}
|
||||
|
||||
// frameUser returns the 16-byte (UserSize) frame-form user identity for an
|
||||
// address — the exact bytes the deposit/withdraw frame carries (the proxy
|
||||
// truncates a 20-byte address to the 16-byte user field; the D-Chain then folds
|
||||
// THAT to its 8-byte handle). The test keys its ledger by this so it matches the
|
||||
// matcher's view byte-for-byte.
|
||||
func frameUser(addr ids.ShortID) []byte { return padUser(userHandle(addr)) }
|
||||
|
||||
func balResp(status uint8, amount uint64) []byte {
|
||||
out := make([]byte, BalanceRespSize)
|
||||
out[0] = status
|
||||
binary.BigEndian.PutUint64(out[1:9], amount)
|
||||
return out
|
||||
}
|
||||
|
||||
// custodyHarness wires a proxy VM with REAL two-chain shared memory and the
|
||||
// ledger matcher.
|
||||
type custodyHarness struct {
|
||||
vm *VM
|
||||
cChainSM atomic.SharedMemory
|
||||
proxyChain ids.ID
|
||||
cChain ids.ID
|
||||
ledger *ledgerMatcher
|
||||
}
|
||||
|
||||
func newCustodyHarness(t *testing.T) *custodyHarness {
|
||||
t.Helper()
|
||||
logger := log.NewNoOpLogger()
|
||||
baseDB := memdb.New()
|
||||
memoryDB := prefixdb.New([]byte{0}, baseDB)
|
||||
m := atomic.NewMemory(memoryDB)
|
||||
|
||||
proxyChain := ids.GenerateTestID()
|
||||
cChain := ids.GenerateTestID()
|
||||
proxySM := m.NewSharedMemory(proxyChain)
|
||||
cChainSM := m.NewSharedMemory(cChain)
|
||||
|
||||
ledger := newLedgerMatcher()
|
||||
prev := zapDialer
|
||||
zapDialer = func(_ context.Context, _ string) (zapConn, error) { return ledger, nil }
|
||||
t.Cleanup(func() { zapDialer = prev })
|
||||
|
||||
rt := &runtime.Runtime{ChainID: proxyChain, CChainID: cChain, NetworkID: 96369, Log: logger, SharedMemory: proxySM}
|
||||
v := &VM{}
|
||||
v.Config = config.DefaultConfig()
|
||||
v.Config.DexZapEndpoint = "127.0.0.1:0"
|
||||
if err := v.Initialize(context.Background(), vm.Init{
|
||||
Runtime: rt, DB: prefixdb.New([]byte{1}, baseDB), ToEngine: make(chan vm.Message, 8),
|
||||
Sender: warp.FakeSender{}, Log: logger,
|
||||
}); err != nil {
|
||||
t.Fatalf("init proxy vm: %v", err)
|
||||
}
|
||||
v.bootstrapped = true
|
||||
return &custodyHarness{vm: v, cChainSM: cChainSM, proxyChain: proxyChain, cChain: cChain, ledger: ledger}
|
||||
}
|
||||
|
||||
// fundCChain exports `amount` of `asset` owned by `owner` into shared memory (the
|
||||
// proxy can claim it on an import), returning the source UTXO id.
|
||||
func (h *custodyHarness) fundCChain(t *testing.T, owner ids.ShortID, asset ids.ID, amount uint64) ids.ID {
|
||||
t.Helper()
|
||||
srcUTXOID := deriveUTXOID(ids.GenerateTestID(), 0)
|
||||
val := encodeExportedOutput(txs.AtomicOutput{Owner: owner, Asset: asset, Amount: amount})
|
||||
if err := h.cChainSM.Apply(map[ids.ID]*atomic.Requests{
|
||||
h.proxyChain: {PutRequests: []*atomic.Element{{Key: srcUTXOID[:], Value: val, Traits: [][]byte{owner[:]}}}},
|
||||
}); err != nil {
|
||||
t.Fatalf("fund C-Chain: %v", err)
|
||||
}
|
||||
return srcUTXOID
|
||||
}
|
||||
|
||||
// exportedTo sums the value the proxy exported back to C-Chain for an owner.
|
||||
func (h *custodyHarness) exportedTo(t *testing.T, owner ids.ShortID) uint64 {
|
||||
t.Helper()
|
||||
vals, _, _, err := h.cChainSM.Indexed(h.proxyChain, [][]byte{owner[:]}, nil, nil, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Indexed: %v", err)
|
||||
}
|
||||
var total uint64
|
||||
for _, v := range vals {
|
||||
if len(v) >= 60 {
|
||||
total += binary.BigEndian.Uint64(v[52:60])
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// TestCustodyRailFullCycleConserves is the headline proxy proof: two accounts
|
||||
// DEPOSIT a non-native asset into the D-Chain via the atomic rail, the D-Chain
|
||||
// matches them (modeled), and BOTH WITHDRAW — asserting C-Chain value out ==
|
||||
// C-Chain value in (to the unit) and that the source UTXOs were consumed exactly
|
||||
// once. This is the maker+taker conservation the prior taker-only model lacked.
|
||||
func TestCustodyRailFullCycleConserves(t *testing.T) {
|
||||
h := newCustodyHarness(t)
|
||||
ctx := context.Background()
|
||||
|
||||
maker := ids.GenerateTestShortID()
|
||||
taker := ids.GenerateTestShortID()
|
||||
lux := ids.GenerateTestID() // base asset (LUX)
|
||||
lusd := ids.GenerateTestID() // quote asset (LUSD)
|
||||
|
||||
// --- DEPOSIT: maker funds 100 LUX, taker funds 1000 LUSD, atomically. ---
|
||||
makerUTXO := h.fundCChain(t, maker, lux, 100)
|
||||
takerUTXO := h.fundCChain(t, taker, lusd, 1000)
|
||||
|
||||
arDep := newAtomicRequests()
|
||||
makerImport := txs.NewImportTx(maker, 0, h.cChain,
|
||||
[]txs.AtomicInput{{UTXOID: makerUTXO, Asset: lux, Amount: 100}},
|
||||
[]txs.AtomicOutput{{Owner: maker, Asset: lux, Amount: 100}})
|
||||
takerImport := txs.NewImportTx(taker, 0, h.cChain,
|
||||
[]txs.AtomicInput{{UTXOID: takerUTXO, Asset: lusd, Amount: 1000}},
|
||||
[]txs.AtomicOutput{{Owner: taker, Asset: lusd, Amount: 1000}})
|
||||
|
||||
if err := h.vm.executeDeposit(ctx, makerImport, arDep); err != nil {
|
||||
t.Fatalf("maker deposit: %v", err)
|
||||
}
|
||||
if err := h.vm.executeDeposit(ctx, takerImport, arDep); err != nil {
|
||||
t.Fatalf("taker deposit: %v", err)
|
||||
}
|
||||
// Commit the atomic deposit legs (consumes the source UTXOs).
|
||||
if err := h.vm.commitAtomic(arDep, nil); err != nil {
|
||||
t.Fatalf("commit deposits: %v", err)
|
||||
}
|
||||
|
||||
// The D-Chain ledger now holds the deposited value.
|
||||
if got := h.ledger.bal[ledgerKey(frameUser(maker), assetHandle(lux))]; got != 100 {
|
||||
t.Fatalf("maker LUX in ledger = %d, want 100", got)
|
||||
}
|
||||
if got := h.ledger.bal[ledgerKey(frameUser(taker), assetHandle(lusd))]; got != 1000 {
|
||||
t.Fatalf("taker LUSD in ledger = %d, want 1000", got)
|
||||
}
|
||||
|
||||
// --- TRADE inside the D-Chain: taker buys 10 LUX @ 5 = 50 LUSD. ---
|
||||
// (The proxy does not trade; the D-Chain matcher does. We model the accepted
|
||||
// block's settleFills moving balances.)
|
||||
h.ledger.trade(frameUser(taker), frameUser(maker), assetHandle(lux), assetHandle(lusd), 10, 50)
|
||||
|
||||
// Ledger after trade: maker 90 LUX + 50 LUSD ; taker 10 LUX + 950 LUSD.
|
||||
assertLedger(t, h, maker, lux, 90)
|
||||
assertLedger(t, h, maker, lusd, 50)
|
||||
assertLedger(t, h, taker, lux, 10)
|
||||
assertLedger(t, h, taker, lusd, 950)
|
||||
|
||||
// --- WITHDRAW: every account pulls its full realized balance, atomically. ---
|
||||
arW := newAtomicRequests()
|
||||
withdrawAll := func(owner ids.ShortID, asset ids.ID, want uint64, idx uint32) uint64 {
|
||||
r, err := h.vm.executeWithdraw(ctx, owner, asset, want, h.cChain, ids.GenerateTestID(), idx, 1, arW)
|
||||
if err != nil {
|
||||
t.Fatalf("withdraw owner=%x asset=%x: %v", owner[:4], asset[:4], err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
var totalOut uint64
|
||||
totalOut += withdrawAll(maker, lux, 90, 0)
|
||||
totalOut += withdrawAll(maker, lusd, 50, 1)
|
||||
totalOut += withdrawAll(taker, lux, 10, 2)
|
||||
totalOut += withdrawAll(taker, lusd, 950, 3)
|
||||
if err := h.vm.commitAtomic(arW, nil); err != nil {
|
||||
t.Fatalf("commit withdrawals: %v", err)
|
||||
}
|
||||
|
||||
// --- CONSERVATION across the rail ---
|
||||
const totalIn = 100 + 1000
|
||||
if totalOut != totalIn {
|
||||
t.Fatalf("CONSERVATION VIOLATED: withdrawn %d != deposited %d", totalOut, totalIn)
|
||||
}
|
||||
// C-Chain can now claim exactly the exported proceeds for each account.
|
||||
if got := h.exportedTo(t, maker); got != 90+50 {
|
||||
t.Fatalf("maker C-Chain proceeds = %d, want 140", got)
|
||||
}
|
||||
if got := h.exportedTo(t, taker); got != 10+950 {
|
||||
t.Fatalf("taker C-Chain proceeds = %d, want 960", got)
|
||||
}
|
||||
// The ledger is empty — every deposited unit was either traded or withdrawn.
|
||||
for k, v := range h.ledger.bal {
|
||||
if v != 0 {
|
||||
t.Errorf("ledger not drained: key %x has %d", []byte(k), v)
|
||||
}
|
||||
}
|
||||
// Source UTXOs consumed exactly once (double-spend guard held).
|
||||
for _, u := range []ids.ID{makerUTXO, takerUTXO} {
|
||||
if c, _ := h.vm.state.IsConsumed(u); !c {
|
||||
t.Errorf("source UTXO %x not consumed", u[:4])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCustodyWithdrawCannotExceedRealized proves a withdraw exports only the
|
||||
// ledger's realized (clamped) balance: asking for more than is available exports
|
||||
// only what is there — the proxy never mints on the export leg.
|
||||
func TestCustodyWithdrawCannotExceedRealized(t *testing.T) {
|
||||
h := newCustodyHarness(t)
|
||||
ctx := context.Background()
|
||||
owner := ids.GenerateTestShortID()
|
||||
asset := ids.GenerateTestID()
|
||||
|
||||
// Fund + deposit 40.
|
||||
u := h.fundCChain(t, owner, asset, 40)
|
||||
ar := newAtomicRequests()
|
||||
imp := txs.NewImportTx(owner, 0, h.cChain,
|
||||
[]txs.AtomicInput{{UTXOID: u, Asset: asset, Amount: 40}},
|
||||
[]txs.AtomicOutput{{Owner: owner, Asset: asset, Amount: 40}})
|
||||
if err := h.vm.executeDeposit(ctx, imp, ar); err != nil {
|
||||
t.Fatalf("deposit: %v", err)
|
||||
}
|
||||
if err := h.vm.commitAtomic(ar, nil); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
|
||||
// Try to withdraw 100; only 40 is realized.
|
||||
arW := newAtomicRequests()
|
||||
realized, err := h.vm.executeWithdraw(ctx, owner, asset, 100, h.cChain, ids.GenerateTestID(), 0, 1, arW)
|
||||
if err != nil {
|
||||
t.Fatalf("withdraw: %v", err)
|
||||
}
|
||||
if realized != 40 {
|
||||
t.Fatalf("over-withdraw realized %d, want clamped 40", realized)
|
||||
}
|
||||
if err := h.vm.commitAtomic(arW, nil); err != nil {
|
||||
t.Fatalf("commit withdraw: %v", err)
|
||||
}
|
||||
if got := h.exportedTo(t, owner); got != 40 {
|
||||
t.Fatalf("exported %d, want 40 (no mint)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCustodyDepositCreditsExactImport proves the deposit rail credits the
|
||||
// D-Chain ledger with EXACTLY the imported value (no skim, no inflation), and a
|
||||
// short/over credit from a lying matcher is refused.
|
||||
func TestCustodyDepositCreditsExactImport(t *testing.T) {
|
||||
h := newCustodyHarness(t)
|
||||
ctx := context.Background()
|
||||
owner := ids.GenerateTestShortID()
|
||||
asset := ids.GenerateTestID()
|
||||
|
||||
u := h.fundCChain(t, owner, asset, 777)
|
||||
ar := newAtomicRequests()
|
||||
imp := txs.NewImportTx(owner, 0, h.cChain,
|
||||
[]txs.AtomicInput{{UTXOID: u, Asset: asset, Amount: 777}},
|
||||
[]txs.AtomicOutput{{Owner: owner, Asset: asset, Amount: 777}})
|
||||
if err := h.vm.executeDeposit(ctx, imp, ar); err != nil {
|
||||
t.Fatalf("deposit: %v", err)
|
||||
}
|
||||
if got := h.ledger.bal[ledgerKey(frameUser(owner), assetHandle(asset))]; got != 777 {
|
||||
t.Fatalf("ledger credited %d, want exactly 777", got)
|
||||
}
|
||||
}
|
||||
|
||||
func assertLedger(t *testing.T, h *custodyHarness, owner ids.ShortID, asset ids.ID, want uint64) {
|
||||
t.Helper()
|
||||
if got := h.ledger.bal[ledgerKey(frameUser(owner), assetHandle(asset))]; got != want {
|
||||
t.Fatalf("ledger owner=%x asset=%x = %d, want %d", owner[:4], asset[:4], got, want)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,30 @@ const (
|
||||
ZAPMethodPlace = "clob_place"
|
||||
ZAPMethodCancel = "clob_cancel"
|
||||
ZAPMethodSubmit = "clob_submit"
|
||||
|
||||
// Custody methods — the funds-in / funds-out rail between the atomic
|
||||
// shared-memory leg and the D-Chain balance ledger (where the money lives in
|
||||
// the order book). FROZEN, byte-identical with the d-chain gateway
|
||||
// (github.com/luxfi/dex/pkg/zapwire Method{Deposit,Withdraw,OpenMarket}).
|
||||
// - clob_deposit : credit an account's available D-Chain balance from value
|
||||
// this proxy atomically IMPORTED (so the book can draw from it).
|
||||
// - clob_withdraw: debit an account's realized D-Chain balance; the returned
|
||||
// realized amount is what this proxy atomically EXPORTS back out.
|
||||
// - clob_open_market: bind a market's (base,quote) asset handles.
|
||||
ZAPMethodDeposit = "clob_deposit"
|
||||
ZAPMethodWithdraw = "clob_withdraw"
|
||||
ZAPMethodOpenMarket = "clob_open_market"
|
||||
)
|
||||
|
||||
// Custody wire sizes (FROZEN, == github.com/luxfi/dex/pkg/zapwire). The proxy
|
||||
// re-defines them since it cannot import the cgo-tagged d-chain package; a
|
||||
// parity test pins byte-equality.
|
||||
const (
|
||||
// DepositReqSize / WithdrawReqSize: user[16] + asset[8] + amount[8].
|
||||
DepositReqSize = 16 + 8 + 8 // 32
|
||||
WithdrawReqSize = 16 + 8 + 8 // 32
|
||||
// BalanceRespSize: status[1] + realizedAmount[8].
|
||||
BalanceRespSize = 1 + 8
|
||||
)
|
||||
|
||||
// FillWireSize is one fill in a clob_submit response: price[8]+size[8]+side[1].
|
||||
|
||||
Reference in New Issue
Block a user