precompile/dex: 0x9010 no-simulation + CLOB custody + ERC-20 atomic legs

Inert|ZAP-only (no embedded matcher; reverts when venue down; source-guarded). Native custody vault with conservation + drain regression. ERC-20 via SafeERC20 transferFrom/transfer with observed-delta (FoT-safe), no slot-poke. Full 32-byte injective asset key; reentrancy guard; reject unbound/zero-ref.
This commit is contained in:
zeekay
2026-06-19 11:34:41 -07:00
parent bd05e11f1d
commit 79d8adc86f
18 changed files with 2775 additions and 186 deletions
+75 -32
View File
@@ -16,20 +16,31 @@ import (
// 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.
// (precompile, chains/dexvm/{custody,relay}.go, the d-chain handler) or the wire
// breaks.
//
// Canonical values (github.com/luxfi/dex/pkg/zapwire, verified 2026-06-17):
// Canonical values (github.com/luxfi/dex/pkg/zapwire, verified 2026-06-18):
//
// 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
// AssetIDSize = 32 (FULL injective asset id — NOT a truncated handle)
// RefSize = 32 (idempotency ref = originating EVM txHash)
// DepositReqSize = UserSize + AssetIDSize + 8 + RefSize = 88
// WithdrawReqSize = UserSize + AssetIDSize + 8 + RefSize = 88
// OpenMarketReqSize = PoolIDSize(32) + AssetIDSize + AssetIDSize = 96
// BalanceReqSize = UserSize + AssetIDSize = 48
// BalanceRespSize = 1 + 8 = 9
// clob_balance request = UserSize + AssetIDSize = 24; response = available[8]+locked[8]
// custodyAmountOff = UserSize + AssetIDSize = 48
// custodyRefOff = UserSize + AssetIDSize + 8 = 56
//
// The asset field is the FULL 32-byte injective id (NOT a truncated 8-byte
// handle): a truncation maps distinct assets to the same balance key, so a
// worthless token whose id folds to the native-LUX key 0 could mint a native claim
// and drain the native vault (and two tokens sharing a leading prefix collide). The
// four ORDER frames (ensure/place/cancel/submit) are byte-unchanged by this. This
// parity guard moves in lockstep with zapwire.
func TestCustodyFrameParity(t *testing.T) {
cases := []struct {
name string
@@ -40,11 +51,14 @@ func TestCustodyFrameParity(t *testing.T) {
{"ZAPMethodOpenMarket", ZAPMethodOpenMarket, "clob_open_market"},
{"ZAPMethodBalance", ZAPMethodBalance, "clob_balance"},
{"zapUserSize", zapUserSize, 16},
{"zapAssetIDSize", zapAssetIDSize, 8},
{"depositReqSize", depositReqSize, 32},
{"withdrawReqSize", withdrawReqSize, 32},
{"openMarketReqSize", openMarketReqSize, 48},
{"zapAssetIDSize", zapAssetIDSize, 32},
{"zapRefSize", zapRefSize, 32},
{"depositReqSize", depositReqSize, 88},
{"withdrawReqSize", withdrawReqSize, 88},
{"openMarketReqSize", openMarketReqSize, 96},
{"balanceRespSize", balanceRespSize, 9},
{"custodyAmountOff", custodyAmountOff, 48},
{"custodyRefOff", custodyRefOff, 56},
}
for _, c := range cases {
if c.got != c.want {
@@ -53,45 +67,74 @@ func TestCustodyFrameParity(t *testing.T) {
}
}
// TestCustodyFrameEncodings pins the exact byte layout the precompile produces
// for each custody frame, matching zapwire's Encode* output field-for-field.
// TestCustodyFrameEncodings pins the exact byte layout the precompile produces for
// the deposit frame, matching zapwire.EncodeDeposit field-for-field: user[16] +
// asset[32] + amount[8] + ref[32], with the 32-byte injective asset id at
// [16:custodyAmountOff], the amount at [custodyAmountOff:custodyRefOff], and the
// 32-byte idempotency ref at the tail [custodyRefOff:].
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
asset := assetID(Currency{Address: common.HexToAddress("0xA0b86991C6218B36c1d19D4a2e9Eb0cE3606EB48")})
const amount uint64 = 12345
ref := [32]byte{0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04}
payload := make([]byte, depositReqSize)
copy(payload[0:16], padUserHandle(user))
binary.BigEndian.PutUint64(payload[16:24], asset)
binary.BigEndian.PutUint64(payload[24:32], amount)
copy(payload[16:custodyAmountOff], asset[:])
binary.BigEndian.PutUint64(payload[custodyAmountOff:custodyRefOff], amount)
copy(payload[custodyRefOff:custodyRefOff+zapRefSize], ref[:])
if len(payload) != 32 {
t.Fatalf("deposit payload len=%d want 32", len(payload))
if len(payload) != 88 {
t.Fatalf("deposit payload len=%d want 88", len(payload))
}
if binary.BigEndian.Uint64(payload[16:24]) != asset {
t.Errorf("deposit asset field mismatch")
// Asset field is the FULL 32 bytes at [16:48].
var gotAsset [32]byte
copy(gotAsset[:], payload[16:custodyAmountOff])
if gotAsset != asset {
t.Errorf("deposit asset field mismatch: got %x want %x", gotAsset, asset)
}
if binary.BigEndian.Uint64(payload[24:32]) != amount {
if binary.BigEndian.Uint64(payload[custodyAmountOff:custodyRefOff]) != amount {
t.Errorf("deposit amount field mismatch")
}
// Ref field is the trailing 32 bytes (offset custodyRefOff).
var gotRef [32]byte
copy(gotRef[:], payload[custodyRefOff:custodyRefOff+zapRefSize])
if gotRef != ref {
t.Errorf("deposit ref field mismatch: got %x want %x", gotRef[:8], ref[:8])
}
// 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)
// TestAssetIDInjective proves the address->asset-id map is INJECTIVE, the property
// that closes the collision class: native LUX (address(0)) maps to the all-zero id,
// and two DISTINCT token addresses — even ones sharing a leading 8-byte prefix that
// the old fold collapsed — map to DISTINCT ids. So no token can ever share a
// balance key with native (the vault-drain mint) or with another token.
func TestAssetIDInjective(t *testing.T) {
// Native LUX == all-zero id.
if id := assetID(NativeCurrency); id != ([32]byte{}) {
t.Errorf("native asset id = %x, want all-zero", id)
}
// 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)
// A token with 8 leading-zero address bytes (the old fold's collision with
// native) maps to a NON-zero id distinct from native's.
zeroLead := assetID(Currency{Address: common.HexToAddress("0x0000000000000000112233445566778899aabbcc")})
if zeroLead == ([32]byte{}) {
t.Errorf("zero-leading token folded to the native key — collision NOT closed")
}
// Two tokens sharing the leading 8 address bytes (the old fold's token<->token
// collision) map to DISTINCT ids.
a := assetID(Currency{Address: common.HexToAddress("0x00112233445566770000000000000000000000AA")})
b := assetID(Currency{Address: common.HexToAddress("0x00112233445566770000000000000000000000BB")})
if a == b {
t.Errorf("two tokens sharing a leading 8-byte prefix collide: %x == %x", a, b)
}
// And neither collides with native.
if a == ([32]byte{}) || b == ([32]byte{}) {
t.Errorf("a token mapped to the native key — collision NOT closed")
}
}
+195
View File
@@ -0,0 +1,195 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"errors"
"math/big"
"testing"
"github.com/holiman/uint256"
"github.com/luxfi/geth/common"
)
// custody_redteam_poc_test.go pins RED's two CRITICAL custody PoCs as PERMANENT
// regression tests, each FLIPPED from an exploit into a defense-holds assertion:
//
// CRIT #2 (asset-key collision -> native-vault drain): a worthless ERC-20 whose
// address has 8 leading-zero bytes folded — under the old 8-byte asset handle —
// to the SAME ledger key as native LUX (address(0) -> handle 0). Depositing the
// junk token therefore minted a NATIVE claim, and withdrawing native then
// drained real wei the junk token never backed. With the FULL 32-byte injective
// asset id, the junk token credits its OWN distinct key and a native withdraw
// drains ZERO. Asserted below.
//
// CRIT #1 (deposit reentrancy -> double mint): a malicious ERC-20 re-enters the
// custody entrypoint during its transferFrom sub-call. Without the global
// non-reentrant guard the reentrant pull lands inside the observed-delta window
// and BOTH calls mint, so minted > vault backing. With the guard the nested call
// is refused (ErrCustodyReentrant) and minted == vault. Asserted below.
// junkTokenAddr has 8 LEADING ZERO BYTES — the exact shape that collided with native
// LUX under the old assetHandle fold (BE(address[0:8]) == 0 == native handle). Its
// trailing bytes are non-zero so assetID (left-pad address into 32 bytes) yields a
// NON-zero id distinct from native's all-zero id.
var junkTokenAddr = common.HexToAddress("0x00000000000000000000000000000000DEADBEEF")
// TestCustody_JunkTokenDepositDoesNotDrainNative is RED CRIT #2 flipped: a junk
// ERC-20 deposit credits its OWN asset key, NEVER native, so a native withdraw by
// the depositor drains ZERO and the victim's native wei is untouched.
//
// PRE-fix: assetHandle(junkToken)=0=assetHandle(native), so the junk deposit
// credited the NATIVE key (minting a native claim) and a native withdraw drained
// the native vault. POST-fix the two keys are distinct (32-byte injective id), so
// the junk deposit cannot mint any native claim.
func TestCustody_JunkTokenDepositDoesNotDrainNative(t *testing.T) {
pm, f, sdb, reg, attacker := custodyPMToken(t, 0)
// A VICTIM holds real native LUX inside the 0x9010 vault (the drain target).
victim := common.HexToAddress("0x9999999999999999999999999999999999999999")
const victimNative = uint64(5000)
sdb.balances[victim] = uint256.NewInt(victimNative)
// EVM pre-moves the victim's msg.value into the vault, then the native deposit
// locks it and mints the victim's native available balance.
sdb.SubBalance(victim, uint256.NewInt(victimNative))
sdb.AddBalance(poolManagerAddr, uint256.NewInt(victimNative))
sdb.setTxHash(common.HexToHash("0x1111000000000000000000000000000000000000000000000000000000001111"))
if err := pm.Deposit(sdb, victim, NativeCurrency, new(big.Int).SetUint64(victimNative)); err != nil {
t.Fatalf("victim native deposit: %v", err)
}
// Sanity: the junk token's address WOULD have folded to the native key under the
// old 8-byte handle (leading 8 address bytes are zero), but its full id is NOT
// the native (all-zero) id — the collision is closed at the key.
if junkTokenAddr.Bytes()[0] != 0 || junkTokenAddr.Bytes()[7] != 0 {
t.Fatalf("junk token address must have 8 leading zero bytes to model the old-fold collision")
}
if assetID(erc20Curr(junkTokenAddr)) == assetID(NativeCurrency) {
t.Fatalf("junk token id collides with native id — the drain class is NOT closed")
}
// The ATTACKER deposits the junk token (its own real backing in the vault). Under
// the old fold this credited the NATIVE key; now it credits the junk token's key.
junk := newMockToken(0)
reg.register(junkTokenAddr, junk)
const junkAmt = uint64(1_000_000)
junk.mint(attacker, junkAmt)
junk.approve(attacker, poolManagerAddr, junkAmt)
sdb.setTxHash(common.HexToHash("0x2222000000000000000000000000000000000000000000000000000000002222"))
if err := pm.Deposit(sdb, attacker, erc20Curr(junkTokenAddr), new(big.Int).SetUint64(junkAmt)); err != nil {
t.Fatalf("attacker junk deposit: %v", err)
}
// THE FLIP: the junk deposit credited the JUNK key, NOT native. The attacker has
// ZERO native available — no native claim was minted by the junk deposit.
if got := fakeAvail(f, attacker, NativeCurrency); got != 0 {
t.Fatalf("attacker native available = %d, want 0 (junk deposit must NOT mint a native claim)", got)
}
// The junk token credited its OWN distinct key for exactly the deposited amount.
if got := fakeAvail(f, attacker, erc20Curr(junkTokenAddr)); got != junkAmt {
t.Fatalf("attacker junk available = %d, want %d (credited its own asset key)", got, junkAmt)
}
// The attacker tries to withdraw NATIVE — the drain. It must release ZERO: the
// attacker has no native claim, so the native vault (holding the victim's wei) is
// untouched.
sdb.setTxHash(common.HexToHash("0x3333000000000000000000000000000000000000000000000000000000003333"))
realized, err := pm.Withdraw(sdb, attacker, NativeCurrency, new(big.Int).SetUint64(victimNative))
if err != nil {
t.Fatalf("attacker native withdraw: %v", err)
}
if realized.Sign() != 0 {
t.Fatalf("attacker native withdraw realized %d, want 0 (NATIVE-VAULT DRAIN via asset-key collision)", realized)
}
// The native vault still holds exactly the victim's wei — nothing drained.
if got := vaultBal(sdb.txStateDB); got != victimNative {
t.Fatalf("native vault = %d after attack, want %d (victim's wei must be intact)", got, victimNative)
}
// The victim can still withdraw their full native balance — their funds were
// never drained by the junk token.
sdb.setTxHash(common.HexToHash("0x4444000000000000000000000000000000000000000000000000000000004444"))
rv, err := pm.Withdraw(sdb, victim, NativeCurrency, new(big.Int).SetUint64(victimNative))
if err != nil {
t.Fatalf("victim native withdraw: %v", err)
}
if rv.Uint64() != victimNative {
t.Fatalf("victim native withdraw realized %d, want %d (victim funds must be fully recoverable)", rv.Uint64(), victimNative)
}
}
// reentrantVault wraps a tokenStateDB and, on the FIRST transferFrom of a deposit,
// re-enters the custody entrypoint (pm.Deposit) DURING the token sub-call — the
// exact reentrancy window RED CRIT #1 exploits. It records the nested call's error
// so the test can assert the guard refused it. The nested attempt uses a DIFFERENT
// amount to prove the guard is GLOBAL (a per-(txHash,asset,amount) flag would let a
// distinct-amount variant slip through).
type reentrantVault struct {
*tokenStateDB
pm *PoolManager
caller common.Address
asset common.Address
reentered bool
nestedErr error
}
func (s *reentrantVault) TransferTokenFrom(token, from, to common.Address, amount *big.Int) error {
if !s.reentered {
s.reentered = true
// RE-ENTER the custody entrypoint mid-transfer, with a DIFFERENT amount. The
// global guard (set before this sub-call) must refuse it.
s.nestedErr = s.pm.Deposit(s, s.caller, erc20Curr(s.asset), new(big.Int).Add(amount, big.NewInt(1)))
}
return s.tokenStateDB.TransferTokenFrom(token, from, to, amount)
}
// TestCustody_DepositReentrancyRefused_MintEqualsVault is RED CRIT #1 flipped: a
// malicious token re-entering Deposit during its transferFrom is refused by the
// global non-reentrant guard, so exactly ONE deposit is minted and the D-Chain
// claim equals the vault backing (no double mint).
func TestCustody_DepositReentrancyRefused_MintEqualsVault(t *testing.T) {
pm, f, base, reg, caller := custodyPMToken(t, 0)
evil := newMockToken(0)
reg.register(lusdAddr, evil)
const dep = uint64(1000)
evil.mint(caller, 10_000)
evil.approve(caller, poolManagerAddr, 10_000) // ample allowance so a 2nd pull COULD happen
// Wrap the token-capable stateDB so its transferFrom re-enters pm.Deposit.
rv := &reentrantVault{tokenStateDB: base, pm: pm, caller: caller, asset: lusdAddr}
// The outer deposit succeeds; the reentrant nested deposit (mid-transferFrom) is
// refused by the guard.
if err := pm.Deposit(rv, caller, erc20Curr(lusdAddr), new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("outer deposit: %v", err)
}
// THE FLIP: the nested re-entry was REFUSED with ErrCustodyReentrant — it minted
// nothing.
if !rv.reentered {
t.Fatal("reentrancy hook never fired — the test did not exercise the guard")
}
if !errors.Is(rv.nestedErr, ErrCustodyReentrant) {
t.Fatalf("nested reentrant deposit err = %v, want ErrCustodyReentrant (double-mint window NOT closed)", rv.nestedErr)
}
// Exactly ONE transferFrom happened (the nested call was refused BEFORE its own
// pull), and the D-Chain credited exactly ONE deposit.
if evil.transfers != 1 {
t.Fatalf("token transfers = %d, want 1 (the reentrant pull must be refused before transferring)", evil.transfers)
}
minted := fakeAvail(f, caller, erc20Curr(lusdAddr))
if minted != dep {
t.Fatalf("D-Chain minted = %d, want %d (a double mint would show %d)", minted, dep, 2*dep)
}
// minted == vault backing: the precompile holds exactly the deposited token and
// the ledger claim matches it — no unbacked claim.
vaultHeld := evil.balanceOf(poolManagerAddr).Uint64()
if minted != vaultHeld {
t.Fatalf("MINT != VAULT: minted=%d vault=%d (reentrancy minted an unbacked claim)", minted, vaultHeld)
}
if got := loadVaultLedger(rv.tokenStateDB, lusdAddr).Uint64(); got != dep {
t.Fatalf("vault ledger = %d, want %d (one deposit's backing)", got, dep)
}
}
+324
View File
@@ -0,0 +1,324 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"math/big"
"testing"
"github.com/luxfi/geth/common"
"github.com/holiman/uint256"
)
// custody_replay_drain_test.go is the REGRESSION PROOF for the vault-drain theft
// bug (CRITICAL #2) and its deposit-strand mirror (HIGH #1).
//
// THE BUG (red-team PoC, confirmed on the real VM): the EVM precompile keyed
// custody replay-idempotency on the EVM txHash (withdrawBindKey = blake3(txHash‖
// caller‖asset‖want)) while the D-Chain keyed its dedup on CONTENT (txID =
// Checksum256(type‖user‖asset‖amount), NO nonce). Two definitions of "same op".
// So two GENUINE withdraws (distinct txHashes -> neither short-circuits the EVM
// binding) with byte-identical D-Chain frames collided on the D-Chain seen: index:
// the second returned the FIRST realized amount VERBATIM, debiting NOTHING, while
// the precompile's releaseNativeFromVault paid from the shared 0x9010 vault AGAIN
// for a burn that never happened. Repeatable until the vault (other users' funds)
// is drained.
//
// THE FIX: thread the EVM txHash through the clob_withdraw/clob_deposit frame as a
// 32-byte idempotency ref that the D-Chain folds into its content-addressed seen:
// key. Now "same op" means ONE thing end to end: a same-tx replay dedups at both
// layers (preserved), but a GENUINE second withdraw (distinct tx -> distinct ref)
// is distinct on the D-Chain too -> processed fresh -> re-clamps to CURRENT
// available (0 after the first) -> realized 0 -> the vault releases NOTHING.
//
// These tests use the hardened fakeCLOB, which models the D-Chain seen: index
// faithfully (returns the cached realized on a content/ref replay, NOT a live
// debit). On the PRE-fix code (all custody frames carrying the same content
// regardless of txHash) the second withdraw would collide on seen: and the vault
// would pay twice; this file asserts it does NOT.
// custodyVaultInvariant is the THEFT DETECTOR. The 0x9010 vault must always hold
// exactly the sum of every account's D-Chain balance for that asset (the fake
// ledger is the available-balance double; there is no locked balance in these
// custody-only scenarios). If the vault ever exceeds Σledger we stranded value; if
// it ever falls BELOW Σledger we MINTED a release the ledger never backed — the
// drain. balanceOf(0x9010 vault) == Σ available[*][asset] (+ Σ locked, here 0).
func custodyVaultInvariant(t *testing.T, f *fakeCLOB, sdb *txStateDB, asset Currency, where string) {
t.Helper()
vault := vaultBal(sdb)
var ledgerSum uint64
f.mu.Lock()
want := assetID(asset) // FULL 32-byte injective asset id (native == all-zero)
for k, v := range f.ledger {
// ledgerKey is user[16]||asset[32]; the asset is the trailing 32 bytes.
if len(k) != 16+32 {
f.mu.Unlock()
t.Fatalf("%s: fake ledger key wrong width %d", where, len(k))
}
if [32]byte([]byte(k)[16:48]) == want {
ledgerSum += v
}
}
f.mu.Unlock()
if vault != ledgerSum {
t.Fatalf("%s: VAULT-vs-LEDGER INVARIANT BROKEN: vault(0x9010)=%d != Σledger=%d (delta %d). "+
"vault > Σledger strands funds; vault < Σledger is the DRAIN (a release the ledger never burned).",
where, vault, ledgerSum, int64(vault)-int64(ledgerSum))
}
}
// txh derives a guaranteed-VALID, distinct 32-byte tx hash from a label. (Note:
// common.HexToHash silently zero-fills a NON-hex string — "0xWITHDRAW1" decodes to
// the ZERO hash, which would make every "distinct" tx collide on the zero hash and
// quietly disarm the EVM binding. BytesToHash takes arbitrary bytes and right-pads
// into 32 bytes, so distinct labels always give distinct, non-zero hashes.)
func txh(label string) common.Hash { return common.BytesToHash([]byte(label)) }
// TestCustody_DistinctTxHashWithdrawDoesNotDrainVault is RED's exact PoC as a
// regression test. deposit 1000 -> withdraw#1 (txHash A) realized 1000, avail->0
// -> withdraw#2 (txHash B, DISTINCT, identical amount) MUST return realized 0 and
// release NOTHING from the vault.
//
// PRE-fix: withdraw#2's frame was byte-identical to #1 (txHash not in the frame),
// so it collided on the D-Chain seen: index, replayed realized 1000, and the vault
// paid a second 1000 it did not have backing for -> theft. This asserts the vault
// is NOT drained.
func TestCustody_DistinctTxHashWithdrawDoesNotDrainVault(t *testing.T) {
// Two users so the vault holds OTHER people's funds — the drain target. The
// attacker is `caller`; the victim's funds sit in the shared 0x9010 vault.
pm, f, sdb, attacker := custodyPM(t, 2000)
victim := common.HexToAddress("0x2222222222222222222222222222222222222222")
sdb.balances[victim] = mustU256(5000)
// Victim deposits 5000 into the shared vault (their claim is a D-Chain balance).
sdb.txHash = txh("V1")
simulateDepositValueTransfer(sdb, victim, 5000)
if err := pm.Deposit(sdb, victim, NativeCurrency, big.NewInt(5000)); err != nil {
t.Fatalf("victim deposit: %v", err)
}
// Attacker deposits 1000 (their legitimate claim).
const dep = uint64(1000)
sdb.txHash = txh("A0")
simulateDepositValueTransfer(sdb, attacker, dep)
if err := pm.Deposit(sdb, attacker, NativeCurrency, new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("attacker deposit: %v", err)
}
custodyVaultInvariant(t, f, sdb, NativeCurrency, "after deposits") // vault == 6000 == Σledger
vaultBeforeAttack := vaultBal(sdb)
attackerWalletBefore := walletBal(sdb, attacker)
// withdraw#1 (txHash A): legitimate. Realized 1000, attacker available -> 0.
sdb.txHash = txh("AA11")
r1, err := pm.Withdraw(sdb, attacker, NativeCurrency, new(big.Int).SetUint64(dep))
if err != nil {
t.Fatalf("withdraw#1: %v", err)
}
if r1.Uint64() != dep {
t.Fatalf("withdraw#1 realized = %d, want %d", r1.Uint64(), dep)
}
if got := fakeAvail(f, attacker, NativeCurrency); got != 0 {
t.Fatalf("attacker available after withdraw#1 = %d, want 0", got)
}
custodyVaultInvariant(t, f, sdb, NativeCurrency, "after withdraw#1") // vault == 5000 == Σledger(victim)
// withdraw#2 (txHash B, DISTINCT from A, IDENTICAL amount 1000). This is the
// attack: a second GENUINE withdraw whose D-Chain frame was byte-identical to #1
// pre-fix. It MUST now realize 0 (attacker has 0 available) and release NOTHING.
sdb.txHash = txh("BB22")
r2, err := pm.Withdraw(sdb, attacker, NativeCurrency, new(big.Int).SetUint64(dep))
if err != nil {
t.Fatalf("withdraw#2: %v", err)
}
if r2.Uint64() != 0 {
t.Fatalf("THEFT: withdraw#2 (distinct txHash) realized = %d, want 0 — the D-Chain seen: "+
"index replayed the first realized amount against an already-paid vault (vault drain)", r2.Uint64())
}
// THE THEFT DETECTOR: the vault released nothing on withdraw#2. The victim's
// 5000 is intact; the attacker did not extract more than their 1000.
custodyVaultInvariant(t, f, sdb, NativeCurrency, "after withdraw#2 (attack)")
if got := vaultBal(sdb); got != vaultBeforeAttack-dep {
t.Fatalf("vault = %d after the attack, want %d (released exactly the ONE legitimate 1000, "+
"not a second drained 1000)", got, vaultBeforeAttack-dep)
}
if got := walletBal(sdb, attacker); got != attackerWalletBefore+dep {
t.Fatalf("attacker wallet = %d, want %d (extracted exactly their own 1000, no stolen funds)",
got, attackerWalletBefore+dep)
}
// Victim can still withdraw their full 5000 — their funds were never drained.
sdb.txHash = txh("V9")
rv, err := pm.Withdraw(sdb, victim, NativeCurrency, big.NewInt(5000))
if err != nil {
t.Fatalf("victim withdraw: %v", err)
}
if rv.Uint64() != 5000 {
t.Fatalf("victim realized = %d, want 5000 (their funds must be intact after the attack)", rv.Uint64())
}
custodyVaultInvariant(t, f, sdb, NativeCurrency, "after victim drains") // vault == 0 == Σledger
}
// TestCustody_RepeatedDistinctTxHashWithdrawsCannotOverdraw hammers the attack:
// after one legitimate withdraw drains the attacker's balance, MANY further
// distinct-txHash withdraws of the same amount must each realize 0 and release
// nothing. Pre-fix, each would have drained another 1000 from the vault. This is
// the "repeatable until the vault is drained" property, asserted closed.
func TestCustody_RepeatedDistinctTxHashWithdrawsCannotOverdraw(t *testing.T) {
pm, f, sdb, attacker := custodyPM(t, 1000)
victim := common.HexToAddress("0x3333333333333333333333333333333333333333")
sdb.balances[victim] = mustU256(10_000)
sdb.txHash = txh("VV")
simulateDepositValueTransfer(sdb, victim, 10_000)
if err := pm.Deposit(sdb, victim, NativeCurrency, big.NewInt(10_000)); err != nil {
t.Fatalf("victim deposit: %v", err)
}
const dep = uint64(1000)
sdb.txHash = txh("D0")
simulateDepositValueTransfer(sdb, attacker, dep)
if err := pm.Deposit(sdb, attacker, NativeCurrency, new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("attacker deposit: %v", err)
}
vaultAfterDeposits := vaultBal(sdb) // 11_000
custodyVaultInvariant(t, f, sdb, NativeCurrency, "after deposits")
// First (legitimate) withdraw drains the attacker's 1000.
sdb.txHash = txh("W000")
if r, err := pm.Withdraw(sdb, attacker, NativeCurrency, new(big.Int).SetUint64(dep)); err != nil || r.Uint64() != dep {
t.Fatalf("legit withdraw realized=%v err=%v, want 1000", r, err)
}
// 25 more genuine withdraws, each a DISTINCT txHash, each asking for 1000. Each
// must realize 0 (no balance) and release nothing. Pre-fix this loop would have
// drained 25_000 from a vault holding only the victim's 10_000.
for i := 0; i < 25; i++ {
sdb.txHash = common.BytesToHash([]byte{0xAB, byte(i)})
r, err := pm.Withdraw(sdb, attacker, NativeCurrency, new(big.Int).SetUint64(dep))
if err != nil {
t.Fatalf("attack withdraw %d: %v", i, err)
}
if r.Uint64() != 0 {
t.Fatalf("DRAIN at iteration %d: realized %d, want 0 (vault being looted via seen: replay)", i, r.Uint64())
}
custodyVaultInvariant(t, f, sdb, NativeCurrency, "during repeated attack")
}
// Vault fell by exactly the ONE legitimate 1000; the victim's 10_000 is intact.
if got := vaultBal(sdb); got != vaultAfterDeposits-dep {
t.Fatalf("vault = %d after 26 withdraws, want %d (only the single legit 1000 left)", got, vaultAfterDeposits-dep)
}
}
// TestCustody_SameTxHashWithdrawReplayStillDedups proves the FIX PRESERVES the
// legitimate same-tx idempotency: the EVM re-executes one withdraw tx ~5× and only
// the canonical exec commits StateDB, so a same-txHash replay must release exactly
// once (it short-circuits on the EVM binding before reaching the D-Chain). This is
// the property the bug fix must NOT break.
func TestCustody_SameTxHashWithdrawReplayStillDedups(t *testing.T) {
pm, f, sdb, caller := custodyPM(t, 1000)
const dep = uint64(600)
sdb.txHash = txh("DEAD")
simulateDepositValueTransfer(sdb, caller, dep)
if err := pm.Deposit(sdb, caller, NativeCurrency, new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("deposit: %v", err)
}
// Same txHash for both withdraw executions (the EVM's repeated exec of ONE tx).
sdb.txHash = txh("WREPLAY")
r1, err := pm.Withdraw(sdb, caller, NativeCurrency, big.NewInt(200))
if err != nil {
t.Fatalf("withdraw#1: %v", err)
}
r2, err := pm.Withdraw(sdb, caller, NativeCurrency, big.NewInt(200))
if err != nil {
t.Fatalf("withdraw#2 (same-tx replay): %v", err)
}
if r1.Uint64() != 200 || r2.Uint64() != 200 {
t.Fatalf("same-tx replay realized r1=%d r2=%d, want 200/200 (idempotent return)", r1.Uint64(), r2.Uint64())
}
// Released exactly ONCE: vault and ledger each fell by exactly 200, not 400.
if got := vaultBal(sdb); got != dep-200 {
t.Fatalf("vault = %d after same-tx replay, want %d (released ONCE)", got, dep-200)
}
if got := fakeAvail(f, caller, NativeCurrency); got != dep-200 {
t.Fatalf("available = %d after same-tx replay, want %d (burned ONCE)", got, dep-200)
}
custodyVaultInvariant(t, f, sdb, NativeCurrency, "after same-tx replay")
}
// TestCustody_DistinctTxHashDepositDoesNotStrandLock is the HIGH #1 mirror: two
// GENUINE deposits (distinct txHashes, identical amount) each lock msg.value in the
// vault and each MUST credit the D-Chain ledger separately. Pre-fix, the second
// deposit's D-Chain frame was byte-identical to the first, so the D-Chain
// content-deduped it (credited once) while BOTH EVM txs locked the vault -> the
// second lock was stranded (vault > Σledger). This asserts the second deposit
// credits and the invariant holds (no strand).
func TestCustody_DistinctTxHashDepositDoesNotStrandLock(t *testing.T) {
pm, f, sdb, caller := custodyPM(t, 5000)
const dep = uint64(1000)
// deposit#1 (txHash A): lock 1000, credit 1000.
sdb.txHash = txh("DEP_A")
simulateDepositValueTransfer(sdb, caller, dep)
if err := pm.Deposit(sdb, caller, NativeCurrency, new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("deposit#1: %v", err)
}
if got := fakeAvail(f, caller, NativeCurrency); got != dep {
t.Fatalf("available after deposit#1 = %d, want %d", got, dep)
}
custodyVaultInvariant(t, f, sdb, NativeCurrency, "after deposit#1")
// deposit#2 (txHash B, DISTINCT, IDENTICAL amount). A second genuine deposit:
// the EVM locks another 1000 in the vault, and the D-Chain MUST credit another
// 1000 (distinct ref -> distinct txID -> not a seen: replay). Pre-fix the
// D-Chain would dedup this and credit nothing, stranding the second lock.
sdb.txHash = txh("DEP_B")
simulateDepositValueTransfer(sdb, caller, dep)
if err := pm.Deposit(sdb, caller, NativeCurrency, new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("deposit#2: %v", err)
}
if got := fakeAvail(f, caller, NativeCurrency); got != 2*dep {
t.Fatalf("STRAND: available after deposit#2 = %d, want %d — the second genuine deposit was "+
"content-deduped by the D-Chain while its vault lock stood, stranding it", got, 2*dep)
}
// THE INVARIANT: vault (2000 locked) == Σledger (2000 credited). No strand.
custodyVaultInvariant(t, f, sdb, NativeCurrency, "after deposit#2")
// And both deposits are fully withdrawable (the claim matches the lock).
sdb.txHash = txh("DEP_WD")
r, err := pm.Withdraw(sdb, caller, NativeCurrency, new(big.Int).SetUint64(2*dep))
if err != nil {
t.Fatalf("withdraw all: %v", err)
}
if r.Uint64() != 2*dep {
t.Fatalf("withdraw realized = %d, want %d (both deposits claimable)", r.Uint64(), 2*dep)
}
custodyVaultInvariant(t, f, sdb, NativeCurrency, "after withdraw all")
}
// TestCustody_SameTxHashDepositReplayStillDedups proves the FIX PRESERVES same-tx
// deposit idempotency (the EVM's ~5× re-exec of one deposit credits once).
func TestCustody_SameTxHashDepositReplayStillDedups(t *testing.T) {
pm, f, sdb, caller := custodyPM(t, 5000)
const dep = uint64(800)
sdb.txHash = txh("SAMEDEP")
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 (same txHash). Must credit once.
if err := pm.Deposit(sdb, caller, NativeCurrency, new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("deposit#2 (same-tx replay): %v", err)
}
if got := fakeAvail(f, caller, NativeCurrency); got != dep {
t.Fatalf("available after same-tx deposit replay = %d, want %d (credited ONCE)", got, dep)
}
custodyVaultInvariant(t, f, sdb, NativeCurrency, "after same-tx deposit replay")
}
// mustU256 builds a uint256 balance for test seeding.
func mustU256(v uint64) *uint256.Int { return uint256.NewInt(v) }
+12 -10
View File
@@ -63,7 +63,7 @@ func walletBal(sdb *txStateDB, a common.Address) 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))]
return f.ledger[ledgerKey(a.Bytes()[:16], assetID(asset))]
}
// --- HARD GATE: DEPOSIT ---
@@ -298,25 +298,27 @@ func TestCustody_PlaceDoesNotTouchVault(t *testing.T) {
}
}
// --- ERC-20 DISABLED-WITH-HONEST-FLAG (this pass) ---
// --- ERC-20 ON A VAULT-LESS StateDB: FAIL-SECURE ---
//
// 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)
// A non-native deposit/withdraw against a StateDB that does NOT implement the
// erc20Vault capability is REFUSED (ErrERC20VaultUnavailable) — it never mints a
// D-Chain credit and never touches any balance. (The real ERC-20 rail, exercised
// against a token-capable StateDB, lives in erc20_custody_test.go.)
func TestCustody_ERC20Deposit_RefusedWithoutVaultCapability(t *testing.T) {
pm, f, sdb, caller := custodyPM(t, 1000) // txStateDB does NOT implement erc20Vault
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 !errors.Is(err, ErrERC20VaultUnavailable) {
t.Fatalf("ERC-20 deposit err = %v, want ErrERC20VaultUnavailable", 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)
if !errors.Is(werr, ErrERC20VaultUnavailable) {
t.Fatalf("ERC-20 withdraw err = %v, want ErrERC20VaultUnavailable", werr)
}
}
+115
View File
@@ -0,0 +1,115 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"errors"
"math/big"
"testing"
"github.com/luxfi/geth/common"
)
// custody_unbound_test.go pins R3 (defense-in-depth): a custody op whose StateDB
// carries NO originating-tx identity (a zero txHash, i.e. the bind lookup returns
// bound==false) is REFUSED before any value moves. A zero ref is a full-length 64B
// frame so length checks don't catch it, yet it would collide on the D-Chain seen:
// dedup key and reopen the replay/drain class. A committed EVM tx always supplies a
// unique non-zero hash, so refusing the unbound path closes the door without
// touching any real on-chain custody op (and is a prerequisite for the ERC-20 rail).
// TestCustody_Deposit_RejectsUnboundNoMint: a deposit with a zero txHash (unbound)
// is refused — no vault lock, no D-Chain mint.
func TestCustody_Deposit_RejectsUnboundNoMint(t *testing.T) {
pm, f, sdb, caller := custodyPM(t, 1000)
// Force the unbound path: a zero txHash means no valid EVM tx context.
sdb.txHash = common.Hash{}
const dep = uint64(250)
// Even if the EVM had moved msg.value into the vault, the unbound deposit must
// refuse BEFORE crediting the ledger — never mint against a zero idempotency ref.
simulateDepositValueTransfer(sdb, caller, dep)
err := pm.Deposit(sdb, caller, NativeCurrency, new(big.Int).SetUint64(dep))
if !errors.Is(err, ErrCustodyUnbound) {
t.Fatalf("unbound deposit err = %v, want ErrCustodyUnbound", err)
}
// No D-Chain mint occurred.
if got := fakeAvail(f, caller, NativeCurrency); got != 0 {
t.Fatalf("D-Chain available = %d after refused unbound deposit, want 0 (no mint)", got)
}
}
// TestCustody_Withdraw_RejectsUnboundNoRelease: a withdraw with a zero txHash
// (unbound) is refused — realized 0, no ledger burn, and the vault is NOT touched
// even though it holds releasable funds from a prior (bound) deposit.
func TestCustody_Withdraw_RejectsUnboundNoRelease(t *testing.T) {
pm, f, sdb, caller := custodyPM(t, 1000)
// First, a NORMAL bound deposit so the ledger AND the vault hold real funds the
// unbound withdraw could (wrongly) release. custodyPM seeds a non-zero txHash.
const dep = uint64(400)
simulateDepositValueTransfer(sdb, caller, dep)
if err := pm.Deposit(sdb, caller, NativeCurrency, new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("seed deposit: %v", err)
}
if got := vaultBal(sdb); got != dep {
t.Fatalf("vault after seed deposit = %d, want %d", got, dep)
}
if got := fakeAvail(f, caller, NativeCurrency); got != dep {
t.Fatalf("ledger after seed deposit = %d, want %d", got, dep)
}
// Now force the unbound path and attempt a withdraw.
sdb.txHash = common.Hash{}
realized, err := pm.Withdraw(sdb, caller, NativeCurrency, new(big.Int).SetUint64(dep))
if !errors.Is(err, ErrCustodyUnbound) {
t.Fatalf("unbound withdraw err = %v, want ErrCustodyUnbound", err)
}
if realized.Sign() != 0 {
t.Fatalf("unbound withdraw realized = %v, want 0", realized)
}
// The ledger was NOT burned and the vault was NOT released.
if got := fakeAvail(f, caller, NativeCurrency); got != dep {
t.Fatalf("ledger after refused unbound withdraw = %d, want %d (no burn)", got, dep)
}
if got := vaultBal(sdb); got != dep {
t.Fatalf("vault after refused unbound withdraw = %d, want %d (no release)", got, dep)
}
}
// TestCustody_BoundOpStillWorks: the normal path — a deposit then withdraw, both
// with a real (non-zero) txHash — still locks/mints and burns/releases exactly,
// proving R3 only refuses the unbound path and does not regress real custody.
func TestCustody_BoundOpStillWorks(t *testing.T) {
pm, f, sdb, caller := custodyPM(t, 1000) // custodyPM sets a non-zero txHash
const dep = uint64(300)
simulateDepositValueTransfer(sdb, caller, dep)
if err := pm.Deposit(sdb, caller, NativeCurrency, new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("bound deposit: %v", err)
}
if got := fakeAvail(f, caller, NativeCurrency); got != dep {
t.Fatalf("ledger after bound deposit = %d, want %d", got, dep)
}
if got := vaultBal(sdb); got != dep {
t.Fatalf("vault after bound deposit = %d, want %d", got, dep)
}
// A DISTINCT tx (the EVM gives the withdraw its own hash) withdraws the balance.
sdb.txHash = common.HexToHash("0xb0undw1thdraw")
realized, err := pm.Withdraw(sdb, caller, NativeCurrency, new(big.Int).SetUint64(dep))
if err != nil {
t.Fatalf("bound withdraw: %v", err)
}
if realized.Uint64() != dep {
t.Fatalf("bound withdraw realized = %v, want %d", realized, dep)
}
if got := fakeAvail(f, caller, NativeCurrency); got != 0 {
t.Fatalf("ledger after bound withdraw = %d, want 0 (burned)", got)
}
if got := vaultBal(sdb); got != 0 {
t.Fatalf("vault after bound withdraw = %d, want 0 (released)", got)
}
}
+24 -5
View File
@@ -72,19 +72,38 @@ type Engine interface {
// 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.
// asset ids: a Currency's 20-byte address maps INJECTIVELY to its full 32-byte
// D-Chain asset id (assetID in engine_zap.go — left-padded address); native LUX
// (address(0)) maps to the all-zero id. The full id (not a truncated handle) is
// what keys the ledger, so distinct assets never collide.
type custodyEngine interface {
// OpenMarket binds (base=currency0, quote=currency1) asset handles for poolID
// OpenMarket binds (base=currency0, quote=currency1) asset ids 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
//
// ref is the 32-byte originating-tx idempotency reference (the EVM txHash) the
// D-Chain folds into its content-addressed seen: dedup key, so the EVM-side
// idempotency identity and the D-Chain-side dedup identity are ONE thing. Two
// GENUINELY distinct deposits (distinct txHash, hence distinct ref) are distinct
// on the D-Chain too — each credited separately, matching each EVM vault lock
// (closes the deposit-strand). A byte-identical retry of one deposit (same ref)
// dedups to exactly-once. The ref is asset-generic (same field for native and
// ERC-20). See PoolManager.custodyRef.
Deposit(account common.Address, asset Currency, amount uint64, ref [32]byte) 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)
//
// ref is the originating-tx idempotency reference (see Deposit). It is the FIX
// for the vault-drain: a second GENUINE withdraw (distinct txHash -> distinct
// ref) is now distinct on the D-Chain seen: index, so it re-clamps to CURRENT
// available (0 after the first drained it) and returns realized 0 -> the caller
// releases NOTHING from the vault. Previously a content-identical second
// withdraw collided on seen:, replayed the FIRST realized amount, and the vault
// paid twice for one burn.
Withdraw(account common.Address, asset Currency, want uint64, ref [32]byte) (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)
+55 -27
View File
@@ -49,7 +49,7 @@ const (
// 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].
// (clob_balance). Request: user[16]+asset[32]. Response: available[8]+locked[8].
ZAPMethodBalance = "clob_balance"
)
@@ -57,13 +57,26 @@ const (
// 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).
//
// The asset field is the FULL 32-byte injective id (zapwire.AssetIDSize), NOT a
// truncated handle: a truncation maps distinct assets to the same balance key, so a
// worthless token whose id folds to the native-LUX key 0 could mint a native claim
// and drain the native vault (and two tokens sharing a leading prefix collide). The
// deposit/withdraw frames carry the 32-byte idempotency ref (zapwire.RefSize) at the
// tail: user[16]+asset[32]+amount[8]+ref[32] = 88. The ref is the EVM txHash the
// D-Chain folds into its content-addressed seen: dedup key, unifying the EVM-side
// and D-Chain-side idempotency identity (the vault-drain fix). The four order frames
// (ensure/place/cancel/submit) are byte-unchanged — this is additive to custody.
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]
zapAssetIDSize = 32 // FULL injective asset id field width (NOT a truncated handle)
zapRefSize = 32 // idempotency reference (originating EVM txHash) field width
depositReqSize = zapUserSize + zapAssetIDSize + 8 + zapRefSize // user[16]+asset[32]+amount[8]+ref[32] = 88
withdrawReqSize = zapUserSize + zapAssetIDSize + 8 + zapRefSize // = 88
openMarketReqSize = 32 + zapAssetIDSize + zapAssetIDSize // poolId[32]+base[32]+quote[32] = 96
balanceRespSize = 1 + 8 // status[1]+amount[8]
custodyAmountOff = zapUserSize + zapAssetIDSize // amount offset = 48
custodyRefOff = zapUserSize + zapAssetIDSize + 8 // ref tail offset = 56
)
// brandFallback is the neutral, brand-free identity surfaced by ZAPEngine. The
@@ -564,17 +577,26 @@ func (z *ZAPEngine) Close() error {
// 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])
// assetID maps a 20-byte EVM currency address to its canonical 32-byte D-Chain
// asset id, the FULL key the ledger keys balances by — NOT a truncated handle.
// The map is INJECTIVE: the 20-byte address is left-padded into 32 bytes (12 zero
// bytes + the 20 address bytes), so two distinct token addresses — or a token vs
// native — ALWAYS produce distinct ids. Native LUX (address(0)) maps to the
// all-zero id; since no real ERC-20 has the zero address, native never collides
// with a token. (The prior 8-byte fold truncated the address: a token whose
// address had 8 leading-zero bytes folded to the native key 0 and could mint a
// native claim that drained the native vault, and two tokens sharing the leading
// 8 bytes shared balances. The full id eliminates both collisions.)
//
// The deposit, the open-market binding, and the order MUST all use this id so they
// name the same asset. The chains/dexvm atomic proxy keys the same ledger by the
// full 32-byte cross-chain ids.ID (native == ids.Empty == all-zero), so a native
// op agrees across both rails; an ERC-20 only enters via this precompile rail.
func assetID(c Currency) [32]byte {
var id [32]byte
// Left-pad the 20-byte address into the low 20 bytes (12 high zero bytes).
copy(id[12:], c.Address.Bytes())
return id
}
// userHandle renders the caller's 20-byte address to the 16-byte user identity
@@ -586,16 +608,17 @@ 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
// OpenMarket binds a market's (base, quote) asset ids 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()
baseID, quoteID := assetID(base), assetID(quote)
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))
copy(payload[32:32+zapAssetIDSize], baseID[:])
copy(payload[32+zapAssetIDSize:32+2*zapAssetIDSize], quoteID[:])
resp, err := z.call(ctx, ZAPMethodOpenMarket, payload)
if err != nil {
return fmt.Errorf("ZAP OpenMarket: %w", err)
@@ -612,13 +635,15 @@ func (z *ZAPEngine) OpenMarket(poolID [32]byte, base, quote Currency) error {
// 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 {
func (z *ZAPEngine) Deposit(account common.Address, asset Currency, amount uint64, ref [32]byte) error {
ctx, cancel := context.WithTimeout(context.Background(), z.timeout)
defer cancel()
aid := assetID(asset)
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)
copy(payload[16:custodyAmountOff], aid[:])
binary.BigEndian.PutUint64(payload[custodyAmountOff:custodyRefOff], amount)
copy(payload[custodyRefOff:custodyRefOff+zapRefSize], ref[:])
resp, err := z.call(ctx, ZAPMethodDeposit, payload)
if err != nil {
return fmt.Errorf("ZAP Deposit: %w", err)
@@ -638,13 +663,15 @@ func (z *ZAPEngine) Deposit(account common.Address, asset Currency, amount uint6
// (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) {
func (z *ZAPEngine) Withdraw(account common.Address, asset Currency, want uint64, ref [32]byte) (uint64, error) {
ctx, cancel := context.WithTimeout(context.Background(), z.timeout)
defer cancel()
aid := assetID(asset)
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)
copy(payload[16:custodyAmountOff], aid[:])
binary.BigEndian.PutUint64(payload[custodyAmountOff:custodyRefOff], want)
copy(payload[custodyRefOff:custodyRefOff+zapRefSize], ref[:])
resp, err := z.call(ctx, ZAPMethodWithdraw, payload)
if err != nil {
return 0, fmt.Errorf("ZAP Withdraw: %w", err)
@@ -665,9 +692,10 @@ func (z *ZAPEngine) Withdraw(account common.Address, asset Currency, want uint64
func (z *ZAPEngine) Balance(account common.Address, asset Currency) (uint64, error) {
ctx, cancel := context.WithTimeout(context.Background(), z.timeout)
defer cancel()
aid := assetID(asset)
req := make([]byte, zapUserSize+zapAssetIDSize)
copy(req[0:zapUserSize], padUserHandle(account))
binary.BigEndian.PutUint64(req[zapUserSize:], assetHandle(asset))
copy(req[zapUserSize:], aid[:])
resp, err := z.call(ctx, ZAPMethodBalance, req)
if err != nil {
return 0, fmt.Errorf("ZAP Balance: %w", err)
+74 -20
View File
@@ -56,26 +56,59 @@ 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 is a minimal available-balance ledger keyed by user[16]||asset[32]
// (the FULL injective asset id) 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. The key width mirrors the d-chain balance:<user:8><asset:32>
// keyspace — distinct assets never collide.
ledger map[string]uint64
// custodySeen models the D-Chain's content-addressed idempotency index
// (seen:<txID>, dchain/state.go). The REAL D-Chain computes txID =
// Checksum256(type‖user‖asset‖amount‖ref) over the WHOLE custody frame and, on a
// frame whose txID was already applied, returns the FIRST realized amount
// VERBATIM via getSeenOutcome — WITHOUT touching the ledger again. This double
// MUST reproduce that, because the vault-drain bug lives in exactly that replay:
// a withdraw whose content was already seen returns the prior realized even
// though available is now 0. Keyed on the full frame bytes (which include the
// 32-byte ref) so two frames identical in (user,asset,amount) but distinct in
// ref are DISTINCT here, exactly as on-chain.
//
// (The PRE-fix double live-debited on every call, so a second content-identical
// withdraw saw avail=0 and returned 0 — which MASKED the bug. Modeling seen:
// faithfully is what lets the regression test expose it.)
custodySeen map[string]uint64
}
func newFakeCLOB() *fakeCLOB {
return &fakeCLOB{markets: make(map[[32]byte][]*fakeOrder), ledger: make(map[string]uint64)}
return &fakeCLOB{
markets: make(map[[32]byte][]*fakeOrder),
ledger: make(map[string]uint64),
custodySeen: 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)
// ledgerKey keys the fake ledger by user[16]||asset[32] — the FULL 32-byte
// injective asset id (native == all-zero), byte-faithful to the d-chain
// balance:<user:8><asset:32> keyspace so distinct assets never collide.
func ledgerKey(user []byte, asset [32]byte) string {
var b [zapUserSize + zapAssetIDSize]byte
copy(b[0:zapUserSize], user)
copy(b[zapUserSize:], asset[:])
return string(b[:])
}
// custodyTxID models the D-Chain content-addressed tx identity for a custody
// frame: the FULL frame bytes (type-agnostic here; the method already separates
// deposit vs withdraw). The d-chain hashes [type]‖body with Checksum256; the
// distinguishing input that matters for dedup is the body (which now carries the
// ref), so keying on method‖payload is byte-faithful to "same op?" on-chain.
func custodyTxID(method string, payload []byte) string {
return method + string(payload)
}
// conn returns a zapConn bound to this book for the zapDialer seam.
func (f *fakeCLOB) conn() zapConn { return &fakeConn{clob: f} }
@@ -109,7 +142,7 @@ func (f *fakeCLOB) dispatch(method string, payload []byte) ([]byte, error) {
case ZAPMethodSubmit:
return f.submit(payload)
case ZAPMethodOpenMarket:
// poolId[32]+base[8]+quote[8]; bind is a no-op for this double — just ack.
// poolId[32]+base[32]+quote[32]; 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 {
@@ -117,17 +150,36 @@ func (f *fakeCLOB) dispatch(method string, payload []byte) ([]byte, error) {
}
return ackBytes(0, clobStatusPlaced, 1), nil
case ZAPMethodDeposit:
// user[16]+asset[8]+amount[8]: credit available, echo credited == amount.
// user[16]+asset[32]+amount[8]+ref[32]: credit available, echo credited.
// seen:-replay (same content/ref already applied) -> return the FIRST
// realized VERBATIM, crediting NOTHING again (models getSeenOutcome).
txID := custodyTxID(method, payload)
if prior, seen := f.custodySeen[txID]; seen {
return balanceRespBytes(clobStatusPlaced, prior), nil
}
user := payload[0:16]
asset := binary.BigEndian.Uint64(payload[16:24])
amount := binary.BigEndian.Uint64(payload[24:32])
var asset [32]byte
copy(asset[:], payload[16:custodyAmountOff])
amount := binary.BigEndian.Uint64(payload[custodyAmountOff:custodyRefOff])
f.ledger[ledgerKey(user, asset)] += amount
f.custodySeen[txID] = amount
return balanceRespBytes(clobStatusPlaced, amount), nil
case ZAPMethodWithdraw:
// user[16]+asset[8]+want[8]: debit min(want,avail), return realized.
// user[16]+asset[32]+want[8]+ref[32]: debit min(want,avail), return realized.
// seen:-replay (same content/ref already applied) -> return the FIRST
// realized VERBATIM, debiting NOTHING again (models getSeenOutcome). THIS is
// the faithful model that exposes the vault-drain: a content-identical
// withdraw returns the prior realized even though available is now 0. With
// the fix, a GENUINE second withdraw carries a DISTINCT ref -> distinct txID
// -> falls through to a fresh debit that clamps to the CURRENT (0) available.
txID := custodyTxID(method, payload)
if prior, seen := f.custodySeen[txID]; seen {
return balanceRespBytes(clobStatusPlaced, prior), nil
}
user := payload[0:16]
asset := binary.BigEndian.Uint64(payload[16:24])
want := binary.BigEndian.Uint64(payload[24:32])
var asset [32]byte
copy(asset[:], payload[16:custodyAmountOff])
want := binary.BigEndian.Uint64(payload[custodyAmountOff:custodyRefOff])
k := ledgerKey(user, asset)
avail := f.ledger[k]
realized := want
@@ -135,11 +187,13 @@ func (f *fakeCLOB) dispatch(method string, payload []byte) ([]byte, error) {
realized = avail
}
f.ledger[k] = avail - realized
f.custodySeen[txID] = realized
return balanceRespBytes(clobStatusPlaced, realized), nil
case ZAPMethodBalance:
// user[16]+asset[8]: available[8]+locked[8] (locked unused in this double).
// user[16]+asset[32]: available[8]+locked[8] (locked unused in this double).
user := payload[0:16]
asset := binary.BigEndian.Uint64(payload[16:24])
var asset [32]byte
copy(asset[:], payload[16:zapUserSize+zapAssetIDSize])
out := make([]byte, 16)
binary.BigEndian.PutUint64(out[0:8], f.ledger[ledgerKey(user, asset)])
return out, nil
+546
View File
@@ -0,0 +1,546 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"errors"
"math/big"
"testing"
"github.com/luxfi/geth/common"
)
// erc20_custody_test.go exercises the ERC-20 D<->C custody rail end-to-end against
// the faithful in-memory token double (erc20_vault_test.go). It proves the rail
// REUSES the native primitives — vault lock/release + ZAP relay + (txHash,asset,
// amount) idempotency — with the ONE substitution that the locked asset is the
// token's own balance (moved via transferFrom/transfer), credited by the OBSERVED
// delta. The hard invariants:
// - deposit credits the OBSERVED transfer delta, never the requested amount
// (fee-on-transfer safe);
// - withdraw releases exactly the realized burn and refuses a vault underflow;
// - per-token conservation: token.balanceOf(0x9010) == Σ ledger(token);
// - native (address(0)) and ERC-20 ops never confuse or dedup each other;
// - settlement is via transferFrom/transfer ONLY — never a balance-slot poke.
// Realistic token addresses (keccak-derived shape). The D-Chain asset key is the
// FULL 32-byte injective id (assetID left-pads the 20-byte address into 32 bytes),
// so EVERY distinct address — including any with leading-zero bytes — maps to a
// distinct id that never collides with native LUX (address(0) -> all-zero id) or
// another token. These mirror real ERC-20 shapes. See TestAssetIDInjective for the
// injectivity proof that closes the old 8-byte-fold collision class.
var (
lusdAddr = common.HexToAddress("0xA0b86991C6218B36c1d19D4a2e9Eb0cE3606EB48") // standard 18-dec token (LUSD/USDC-style)
fotAddr = common.HexToAddress("0xF0E1d2C3b4A5968778695A4B3c2D1e0F00112233") // fee-on-transfer token
)
// vaultLedgerOf reads the precompile's per-token vault holdings (0x9010's own state).
func vaultLedgerOf(sdb *tokenStateDB, token common.Address) uint64 {
return loadVaultLedger(sdb, token).Uint64()
}
// erc20Curr wraps a token address as a Currency.
func erc20Curr(addr common.Address) Currency { return Currency{Address: addr} }
// assertPerTokenConservation pins token.balanceOf(0x9010) == precompile vault ledger
// for `token`. (The D-Chain available/locked sums live in the fakeCLOB ledger; this
// checks the C-Chain side of the invariant — the real token in the vault equals the
// precompile's recorded backing, which the D-Chain claims are matched against.)
func assertPerTokenConservation(t *testing.T, sdb *tokenStateDB, reg *mockTokenRegistry, token common.Address) {
t.Helper()
tok := reg.get(token)
vaultReal := tok.balanceOf(poolManagerAddr).Uint64()
ledger := vaultLedgerOf(sdb, token)
if vaultReal != ledger {
t.Fatalf("per-token conservation broken for %x: token.balanceOf(0x9010)=%d != vault ledger=%d", token[:4], vaultReal, ledger)
}
}
// --- DEPOSIT: standard token, observed delta == requested ---
func TestERC20_Deposit_StandardToken_CreditsObservedDelta(t *testing.T) {
pm, f, sdb, reg, caller := custodyPMToken(t, 0)
lusd := newMockToken(0) // standard, no fee
reg.register(lusdAddr, lusd)
const dep = uint64(250)
lusd.mint(caller, 1000)
lusd.approve(caller, poolManagerAddr, dep) // caller grants 0x9010 the allowance
if err := pm.Deposit(sdb, caller, erc20Curr(lusdAddr), new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("Deposit: %v", err)
}
// Caller's token balance dropped by exactly dep (no fee).
if got := lusd.balanceOf(caller).Uint64(); got != 1000-dep {
t.Fatalf("caller token balance = %d, want %d", got, 1000-dep)
}
// The vault physically holds dep of the token (moved via transferFrom).
if got := lusd.balanceOf(poolManagerAddr).Uint64(); got != dep {
t.Fatalf("vault token balance = %d, want %d", got, dep)
}
// D-Chain available credited exactly the observed delta (== dep here).
if got := fakeAvail(f, caller, erc20Curr(lusdAddr)); got != dep {
t.Fatalf("D-Chain available = %d, want %d", got, dep)
}
// Settlement used transferFrom (exactly 1 genuine move). The "no slot poke"
// guarantee is STRUCTURAL — the precompile reaches the token only through the
// erc20Vault seam (TokenBalanceOf/TransferTokenFrom/TransferTokenTo), which has
// NO balance-write method — so the move-count is the load-bearing assertion.
if lusd.transfers != 1 {
t.Fatalf("token transfers = %d, want 1 (transferFrom)", lusd.transfers)
}
assertPerTokenConservation(t, sdb, reg, lusdAddr)
}
// --- DEPOSIT: fee-on-transfer token — credit the OBSERVED (post-fee) delta ---
//
// THE SHARPEST EDGE: a fee-on-transfer token delivers LESS than `amount` to the
// vault. The D-Chain must be credited the OBSERVED delta, NOT the requested amount,
// or it mints an unbacked claim. Conservation must hold against the real (post-fee)
// vault balance.
func TestERC20_Deposit_FeeOnTransfer_CreditsObservedNotRequested(t *testing.T) {
pm, f, sdb, reg, caller := custodyPMToken(t, 0)
const feeBps = uint64(100) // 1% fee
fot := newMockToken(feeBps)
reg.register(fotAddr, fot)
const req = uint64(1000)
fot.mint(caller, 5000)
fot.approve(caller, poolManagerAddr, req)
if err := pm.Deposit(sdb, caller, erc20Curr(fotAddr), new(big.Int).SetUint64(req)); err != nil {
t.Fatalf("Deposit: %v", err)
}
// Recipient (vault) observed delta = req - 1% = 990. THAT is what must be credited.
const wantDelta = uint64(990)
if got := fot.balanceOf(poolManagerAddr).Uint64(); got != wantDelta {
t.Fatalf("vault token balance = %d, want %d (post-fee observed delta)", got, wantDelta)
}
// D-Chain credited the OBSERVED delta (990), NOT the requested 1000.
if got := fakeAvail(f, caller, erc20Curr(fotAddr)); got != wantDelta {
t.Fatalf("D-Chain available = %d, want %d (OBSERVED delta, NOT requested %d)", got, wantDelta, req)
}
if got := fakeAvail(f, caller, erc20Curr(fotAddr)); got == req {
t.Fatalf("D-Chain credited the REQUESTED amount %d — unbacked mint (must credit observed)", req)
}
// Caller debited the full requested amount (the fee is real cost to the caller).
if got := fot.balanceOf(caller).Uint64(); got != 5000-req {
t.Fatalf("caller token balance = %d, want %d", got, 5000-req)
}
// Conservation holds against the POST-FEE vault balance.
if got := vaultLedgerOf(sdb, fotAddr); got != wantDelta {
t.Fatalf("vault ledger = %d, want %d (tracks observed delta)", got, wantDelta)
}
assertPerTokenConservation(t, sdb, reg, fotAddr)
}
// --- WITHDRAW: burn D-Chain, transfer token out of the vault ---
func TestERC20_Withdraw_BurnsLedgerAndReleasesToken(t *testing.T) {
pm, f, sdb, reg, caller := custodyPMToken(t, 0)
lusd := newMockToken(0)
reg.register(lusdAddr, lusd)
const dep = uint64(400)
lusd.mint(caller, 1000)
lusd.approve(caller, poolManagerAddr, dep)
if err := pm.Deposit(sdb, caller, erc20Curr(lusdAddr), new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("Deposit: %v", err)
}
// Distinct tx for the withdraw (a genuinely different custody op).
sdb.setTxHash(common.HexToHash("0x1111000000000000000000000000000000000000000000000000000000001111"))
const wd = uint64(150)
realized, err := pm.Withdraw(sdb, caller, erc20Curr(lusdAddr), new(big.Int).SetUint64(wd))
if err != nil {
t.Fatalf("Withdraw: %v", err)
}
if realized.Uint64() != wd {
t.Fatalf("realized = %d, want %d", realized.Uint64(), wd)
}
// Token transferred from the vault back to the caller.
if got := lusd.balanceOf(caller).Uint64(); got != 1000-dep+wd {
t.Fatalf("caller token balance = %d, want %d", got, 1000-dep+wd)
}
if got := lusd.balanceOf(poolManagerAddr).Uint64(); got != dep-wd {
t.Fatalf("vault token balance = %d, want %d", got, dep-wd)
}
// D-Chain available burned by the realized amount.
if got := fakeAvail(f, caller, erc20Curr(lusdAddr)); got != dep-wd {
t.Fatalf("D-Chain available = %d, want %d", got, dep-wd)
}
assertPerTokenConservation(t, sdb, reg, lusdAddr)
}
// --- WITHDRAW: vault-underflow refusal (vaultBalanceOf < realized) ---
//
// The ERC-20 analog of releaseNativeFromVault's underflow guard: if the vault's
// recorded holdings for the token are below the realized burn, the release is
// refused (defense in depth — never pay out a token the vault does not hold).
func TestERC20_Withdraw_RefusesVaultUnderflow(t *testing.T) {
pm, _, sdb, reg, caller := custodyPMToken(t, 0)
lusd := newMockToken(0)
reg.register(lusdAddr, lusd)
const dep = uint64(300)
lusd.mint(caller, 1000)
lusd.approve(caller, poolManagerAddr, dep)
if err := pm.Deposit(sdb, caller, erc20Curr(lusdAddr), new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("Deposit: %v", err)
}
// Corrupt the vault ledger DOWN to simulate the invariant breach the guard
// defends against (the D-Chain would say `dep` available, but the vault holds
// less). The withdraw must REFUSE rather than overdraw the vault.
storeVaultLedger(sdb, lusdAddr, new(big.Int).SetUint64(50)) // < dep
sdb.setTxHash(common.HexToHash("0x2222000000000000000000000000000000000000000000000000000000002222"))
_, err := pm.Withdraw(sdb, caller, erc20Curr(lusdAddr), new(big.Int).SetUint64(dep))
if !errors.Is(err, ErrInsufficientBalance) {
t.Fatalf("underflow withdraw err = %v, want ErrInsufficientBalance", err)
}
// The vault ledger is untouched by the refused withdraw (we set it to 50).
if got := vaultLedgerOf(sdb, lusdAddr); got != 50 {
t.Fatalf("vault ledger = %d after refused withdraw, want 50 (no debit)", got)
}
}
// --- PER-TOKEN CONSERVATION across deposit -> trade -> withdraw ---
//
// A full custody cycle for one token, with a resting-order place in the middle (the
// no-reserve gate: a place moves D-Chain available->locked but NEVER touches the
// vault). token.balanceOf(0x9010) == vault ledger at every step.
func TestERC20_Conservation_DepositTradeWithdrawCycle(t *testing.T) {
pm, f, sdb, reg, caller := custodyPMToken(t, 0)
lusd := newMockToken(0)
reg.register(lusdAddr, lusd)
const dep = uint64(1000)
lusd.mint(caller, 5000)
lusd.approve(caller, poolManagerAddr, dep)
if err := pm.Deposit(sdb, caller, erc20Curr(lusdAddr), new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("Deposit: %v", err)
}
vaultAfterDeposit := lusd.balanceOf(poolManagerAddr).Uint64()
if vaultAfterDeposit != dep {
t.Fatalf("vault after deposit = %d, want %d", vaultAfterDeposit, dep)
}
assertPerTokenConservation(t, sdb, reg, lusdAddr)
// Place a resting order in an ERC-20/quote market (the D-Chain locks available;
// the vault is UNCHANGED — no native/token reserve backs a resting order).
// quoteAddr (0xFF..) > lusdAddr (0xA0..), so currency0=lusd, currency1=quote (sorted).
quoteAddr := common.HexToAddress("0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF")
key := PoolKey{
Currency0: erc20Curr(lusdAddr),
Currency1: erc20Curr(quoteAddr),
Fee: 3000, TickSpacing: 60,
}
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] = 9
if _, _, err := pm.ModifyLiquidity(sdb, caller, key, ModifyLiquidityParams{
TickLower: -60, TickUpper: 60, LiquidityDelta: big.NewInt(10), Salt: salt,
}, nil); err != nil {
t.Fatalf("ModifyLiquidity place: %v", err)
}
// THE GATE: the vault token balance is UNCHANGED by the place.
if got := lusd.balanceOf(poolManagerAddr).Uint64(); got != vaultAfterDeposit {
t.Fatalf("vault changed by place: %d -> %d (NO reserve must back a resting order)", vaultAfterDeposit, got)
}
assertPerTokenConservation(t, sdb, reg, lusdAddr)
// Withdraw the full deposit back out (distinct tx).
sdb.setTxHash(common.HexToHash("0x3333000000000000000000000000000000000000000000000000000000003333"))
realized, err := pm.Withdraw(sdb, caller, erc20Curr(lusdAddr), 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)
}
// Conservation: caller whole again, vault empty, ledger zero.
if got := lusd.balanceOf(caller).Uint64(); got != 5000 {
t.Fatalf("caller token balance = %d after full cycle, want 5000 (conserved)", got)
}
if got := lusd.balanceOf(poolManagerAddr).Uint64(); got != 0 {
t.Fatalf("vault token balance = %d after full cycle, want 0", got)
}
_ = f
assertPerTokenConservation(t, sdb, reg, lusdAddr)
}
// --- CROSS-ASSET ISOLATION: native (address(0)) and ERC-20 do NOT confuse/dedup ---
//
// A native deposit and an ERC-20 deposit by the same caller in the same tx context
// must be DISTINCT: distinct idempotency bindings (custodyBindKey folds the asset
// address) AND distinct D-Chain ledger keys (assetID maps native->all-zero id,
// ERC-20->its 20-byte address embedded injectively in 32 bytes). Neither dedups
// against the other.
func TestERC20_CrossAssetIsolation_NativeAndTokenDistinct(t *testing.T) {
pm, f, sdb, reg, caller := custodyPMToken(t, 1000)
lusd := newMockToken(0)
reg.register(lusdAddr, lusd)
const amt = uint64(200)
// Native deposit: EVM pre-moves msg.value into the vault.
sdb.SubBalance(caller, mustU256(amt))
sdb.AddBalance(poolManagerAddr, mustU256(amt))
if err := pm.Deposit(sdb, caller, NativeCurrency, new(big.Int).SetUint64(amt)); err != nil {
t.Fatalf("native Deposit: %v", err)
}
// ERC-20 deposit of the SAME amount by the SAME caller in the SAME tx context.
// If the asset were not part of the identity, this would dedup against the native
// deposit (the bug). It must instead be a fresh, distinct credit.
lusd.mint(caller, 1000)
lusd.approve(caller, poolManagerAddr, amt)
if err := pm.Deposit(sdb, caller, erc20Curr(lusdAddr), new(big.Int).SetUint64(amt)); err != nil {
t.Fatalf("erc20 Deposit: %v", err)
}
// Both credited SEPARATELY, each under its own asset handle.
if got := fakeAvail(f, caller, NativeCurrency); got != amt {
t.Fatalf("native D-Chain available = %d, want %d (not consumed by token dedup)", got, amt)
}
if got := fakeAvail(f, caller, erc20Curr(lusdAddr)); got != amt {
t.Fatalf("token D-Chain available = %d, want %d (not deduped against native)", got, amt)
}
// The two ledger keys are genuinely distinct (native all-zero id != token id).
if assetID(NativeCurrency) == assetID(erc20Curr(lusdAddr)) {
t.Fatalf("native and token share an asset id — cross-asset isolation broken")
}
// Vault holds BOTH: native wei AND the token, independently.
if got := vaultBal(sdb.txStateDB); got != amt {
t.Fatalf("native vault wei = %d, want %d", got, amt)
}
if got := lusd.balanceOf(poolManagerAddr).Uint64(); got != amt {
t.Fatalf("token vault balance = %d, want %d", got, amt)
}
assertPerTokenConservation(t, sdb, reg, lusdAddr)
}
// --- LUSD-style standard token settles via transferFrom/transfer, NOT a slot poke ---
//
// The exact failure Michael flagged: autoSettle's balance-slot WRITE broke a
// standard 18-decimal token. This rail moves value ONLY through the token's own
// transferFrom/transfer (which the mock counts), never by writing balanceOf slots —
// and structurally CANNOT write them, since the erc20Vault seam exposes no
// balance-write method. A full deposit+withdraw round-trips with transfers counted.
func TestERC20_LUSDStyle_SettlesViaTransfer_NoSlotPoke(t *testing.T) {
pm, _, sdb, reg, caller := custodyPMToken(t, 0)
lusd := newMockToken(0) // standard ERC-20 layout
reg.register(lusdAddr, lusd)
const dep = uint64(777)
lusd.mint(caller, 1000)
lusd.approve(caller, poolManagerAddr, dep)
if err := pm.Deposit(sdb, caller, erc20Curr(lusdAddr), new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("Deposit: %v", err)
}
sdb.setTxHash(common.HexToHash("0x4444000000000000000000000000000000000000000000000000000000004444"))
if _, err := pm.Withdraw(sdb, caller, erc20Curr(lusdAddr), new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("Withdraw: %v", err)
}
// Exactly two genuine moves: transferFrom (deposit) + transfer (withdraw). The
// precompile NEVER poked balanceOf slots — it CANNOT: the erc20Vault seam exposes
// no balance-write method, so the only paths to the token are these counted
// transfers (the structural "no slot poke" guarantee Michael's autoSettle bug
// violated). The move-count is the load-bearing assertion.
if lusd.transfers != 2 {
t.Fatalf("token transfers = %d, want 2 (transferFrom + transfer)", lusd.transfers)
}
// Round-tripped whole.
if got := lusd.balanceOf(caller).Uint64(); got != 1000 {
t.Fatalf("caller token balance = %d after round-trip, want 1000", got)
}
assertPerTokenConservation(t, sdb, reg, lusdAddr)
}
// --- OZ-SAFE FAILURE: a reverting transfer aborts the deposit, no mint ---
func TestERC20_Deposit_RevertingTransfer_NoMint(t *testing.T) {
pm, f, sdb, reg, caller := custodyPMToken(t, 0)
bad := newMockToken(0)
bad.revertTransfer = true
reg.register(lusdAddr, bad)
bad.mint(caller, 1000)
bad.approve(caller, poolManagerAddr, 100)
err := pm.Deposit(sdb, caller, erc20Curr(lusdAddr), big.NewInt(100))
if !errors.Is(err, ErrERC20TransferFailed) {
t.Fatalf("reverting deposit err = %v, want ErrERC20TransferFailed", err)
}
if got := fakeAvail(f, caller, erc20Curr(lusdAddr)); got != 0 {
t.Fatalf("D-Chain available = %d after reverted deposit, want 0 (no mint)", got)
}
if got := vaultLedgerOf(sdb, lusdAddr); got != 0 {
t.Fatalf("vault ledger = %d after reverted deposit, want 0", got)
}
}
// --- OZ-SAFE FAILURE: a false-returning transfer aborts the deposit, no mint ---
func TestERC20_Deposit_FalseReturningTransfer_NoMint(t *testing.T) {
pm, f, sdb, reg, caller := custodyPMToken(t, 0)
bad := newMockToken(0)
bad.returnsFalse = true
reg.register(lusdAddr, bad)
bad.mint(caller, 1000)
bad.approve(caller, poolManagerAddr, 100)
err := pm.Deposit(sdb, caller, erc20Curr(lusdAddr), big.NewInt(100))
if !errors.Is(err, ErrERC20TransferFailed) {
t.Fatalf("false-return deposit err = %v, want ErrERC20TransferFailed", err)
}
if got := fakeAvail(f, caller, erc20Curr(lusdAddr)); got != 0 {
t.Fatalf("D-Chain available = %d after false-return deposit, want 0 (no mint)", got)
}
}
// --- DEPOSIT IDEMPOTENCY: replaying the SAME ERC-20 deposit tx pulls ONCE ---
func TestERC20_Deposit_ReplayPullsOnce(t *testing.T) {
pm, f, sdb, reg, caller := custodyPMToken(t, 0)
lusd := newMockToken(0)
reg.register(lusdAddr, lusd)
const dep = uint64(300)
lusd.mint(caller, 1000)
lusd.approve(caller, poolManagerAddr, dep) // allowance for ONE pull only
if err := pm.Deposit(sdb, caller, erc20Curr(lusdAddr), new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("Deposit#1: %v", err)
}
// Replay the identical tx (same txHash). The binding must short-circuit BEFORE a
// second transferFrom — which would also fail on the now-zero allowance, but the
// binding must catch it first so the result is a clean no-op.
if err := pm.Deposit(sdb, caller, erc20Curr(lusdAddr), new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("Deposit#2 (replay): %v", err)
}
if got := fakeAvail(f, caller, erc20Curr(lusdAddr)); got != dep {
t.Fatalf("D-Chain available after replay = %d, want %d (credited once)", got, dep)
}
if lusd.transfers != 1 {
t.Fatalf("token transfers after replay = %d, want 1 (pulled once)", lusd.transfers)
}
assertPerTokenConservation(t, sdb, reg, lusdAddr)
}
// --- uint64 LEDGER BOUNDARY: a deposit delta beyond uint64 is REFUSED, not truncated ---
//
// The D-Chain ledger keys balances by uint64. A deposit whose OBSERVED delta exceeds
// 2^64-1 cannot be credited 1:1, so the rail REFUSES rather than truncating (a
// truncated credit would strand the untracked remainder in the vault). This pins the
// no-silent-loss behavior AND documents the cap: ~1.8e19 base units (~18 tokens at
// 18 decimals) — a real D-Chain-ledger constraint surfaced sharply by high-decimal
// ERC-20s. The transferFrom must NOT leave tokens stranded: on refusal the tx
// reverts, undoing the pull (same revert-safety as native).
func TestERC20_Deposit_ExceedsUint64Ledger_RefusedNotTruncated(t *testing.T) {
pm, f, sdb, reg, caller := custodyPMToken(t, 0)
lusd := newMockToken(0)
reg.register(lusdAddr, lusd)
// 2^64 (one past uint64 max).
over := new(big.Int).Lsh(big.NewInt(1), 64)
lusd.mintBig(caller, new(big.Int).Mul(over, big.NewInt(2)))
lusd.approveBig(caller, poolManagerAddr, over)
err := pm.Deposit(sdb, caller, erc20Curr(lusdAddr), over)
if !errors.Is(err, ErrInvalidAmount) {
t.Fatalf("over-uint64 deposit err = %v, want ErrInvalidAmount", err)
}
// No D-Chain credit recorded.
if got := fakeAvail(f, caller, erc20Curr(lusdAddr)); got != 0 {
t.Fatalf("D-Chain available = %d after refused over-cap deposit, want 0", got)
}
// NOTE: the in-memory double does not model EVM tx revert, so the mock's
// transferFrom moved tokens into the vault before the uint64 check rejected. In
// production the precompile error reverts the whole tx, undoing the transferFrom
// (identical to native's lockNativeIntoVault->relay-fail revert path). The vault
// LEDGER (the precompile's own state) was NOT updated past the check, so the
// precompile records no backing for an uncredited deposit.
if got := vaultLedgerOf(sdb, lusdAddr); got != 0 {
t.Fatalf("vault ledger = %d after refused over-cap deposit, want 0 (no backing recorded)", got)
}
}
// --- DONATION DOES NOT BREAK SAFETY: balanceOf(vault) >= Σ ledger ---
//
// The production conservation invariant is balanceOf(0x9010) >= Σ vault ledger: a
// direct ERC-20 transfer to 0x9010 OUTSIDE the deposit path ADDS unaccounted tokens
// (a donation) but NEVER subtracts backing. The withdraw guard checks the precompile
// LEDGER (not balanceOf), so a donation can never be over-released; the donated
// tokens are simply stuck (no D-Chain claim). This proves a donation cannot mint a
// D-Chain claim and cannot break the withdraw underflow guard.
func TestERC20_Donation_DoesNotMintOrUnderflow(t *testing.T) {
pm, f, sdb, reg, caller := custodyPMToken(t, 0)
lusd := newMockToken(0)
reg.register(lusdAddr, lusd)
const dep = uint64(500)
lusd.mint(caller, 2000)
lusd.approve(caller, poolManagerAddr, dep)
if err := pm.Deposit(sdb, caller, erc20Curr(lusdAddr), new(big.Int).SetUint64(dep)); err != nil {
t.Fatalf("Deposit: %v", err)
}
// DONATION: someone transfers tokens directly to the vault, bypassing deposit.
// (Modeled as a direct credit to the vault's token balance — NOT a precompile op.)
lusd.credit(poolManagerAddr, big.NewInt(777))
// balanceOf(vault) is now dep+777, but the ledger is still dep (donation unaccounted).
if got := lusd.balanceOf(poolManagerAddr).Uint64(); got != dep+777 {
t.Fatalf("vault token balance = %d, want %d (deposit + donation)", got, dep+777)
}
if got := vaultLedgerOf(sdb, lusdAddr); got != dep {
t.Fatalf("vault ledger = %d, want %d (donation NOT counted as backing)", got, dep)
}
// The donation did NOT mint a D-Chain claim — available is still just the deposit.
if got := fakeAvail(f, caller, erc20Curr(lusdAddr)); got != dep {
t.Fatalf("D-Chain available = %d, want %d (donation does not mint)", got, dep)
}
// The PRODUCTION invariant balanceOf(vault) >= ledger holds (strictly > here).
if lusd.balanceOf(poolManagerAddr).Cmp(loadVaultLedger(sdb, lusdAddr)) < 0 {
t.Fatalf("invariant breach: balanceOf(vault) < ledger")
}
// A withdraw can release UP TO the ledger (dep), never the donated surplus.
sdb.setTxHash(common.HexToHash("0x5555000000000000000000000000000000000000000000000000000000005555"))
realized, err := pm.Withdraw(sdb, caller, erc20Curr(lusdAddr), new(big.Int).SetUint64(dep))
if err != nil {
t.Fatalf("Withdraw: %v", err)
}
if realized.Uint64() != dep {
t.Fatalf("realized = %d, want %d (clamped to ledger-backed claim)", realized.Uint64(), dep)
}
// After releasing the full deposit, the donated 777 remains stuck in the vault
// (safe — no claim against it), ledger back to 0.
if got := vaultLedgerOf(sdb, lusdAddr); got != 0 {
t.Fatalf("vault ledger = %d after full withdraw, want 0", got)
}
if got := lusd.balanceOf(poolManagerAddr).Uint64(); got != 777 {
t.Fatalf("vault token balance = %d after full withdraw, want 777 (donation stuck, not drained)", got)
}
}
// --- WITHOUT A LIVE Call SURFACE: deposit fails-secure (no unbacked mint) ---
//
// poolStateAdapter's erc20Vault binding refuses when the execution environment does
// not expose a Call surface (GetPrecompileEnv()==nil through the current bridge).
// This pins the fail-secure behavior of the PRODUCTION adapter (distinct from the
// in-memory test double, which always has the capability): a token balance reads 0
// and a transfer errors, so the deposit refuses rather than minting unbacked.
func TestERC20_PoolStateAdapter_NoCallSurface_FailsSecure(t *testing.T) {
a := &poolStateAdapter{stateDB: nil, blockNumber: 1, accessibleState: nil}
if got := a.TokenBalanceOf(lusdAddr, lusdAddr); got.Sign() != 0 {
t.Fatalf("TokenBalanceOf with no env = %v, want 0", got)
}
if err := a.TransferTokenFrom(lusdAddr, lusdAddr, poolManagerAddr, big.NewInt(1)); !errors.Is(err, ErrERC20VaultUnavailable) {
t.Fatalf("TransferTokenFrom with no env = %v, want ErrERC20VaultUnavailable", err)
}
if err := a.TransferTokenTo(lusdAddr, lusdAddr, big.NewInt(1)); !errors.Is(err, ErrERC20VaultUnavailable) {
t.Fatalf("TransferTokenTo with no env = %v, want ErrERC20VaultUnavailable", err)
}
}
+208
View File
@@ -0,0 +1,208 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"errors"
"fmt"
"math/big"
"github.com/luxfi/geth/common"
)
// erc20_vault.go GENERALIZES the native-LUX custody rail to real ERC-20 tokens by
// REUSING the SAME two primitives the native rail uses — the atomic vault
// lock/release and the ZAP relay — with ONE substitution: where native uses the
// EVM's native balance (GetBalance/AddBalance/SubBalance on 0x9010) to hold the
// locked asset, an ERC-20 uses the token contract's own balance (transferFrom INTO
// 0x9010, transfer OUT of 0x9010), measured by the OBSERVED balance delta.
//
// Everything else is shared with native verbatim: the (txHash, asset, amount)
// idempotency binding (custodyBindKey already folds asset.Address, so a native
// address(0) op and an ERC-20 op NEVER collide), the R3 zero-ref guard, the
// asset-generic clob_deposit/clob_withdraw frame (assetID maps the token address
// INJECTIVELY to its full 32-byte D-Chain id; native maps to the all-zero id —
// distinct assets never collide on the ledger key), and the mint guards in the
// ZAP engine. The net-new is ONLY the C-Chain token movement leg.
//
// THE SHARPEST CORRECTNESS EDGE — OBSERVED DELTA, NOT REQUESTED AMOUNT:
// a fee-on-transfer or rebasing token delivers LESS than `amount` into the vault.
// Crediting the requested `amount` to the D-Chain ledger while the vault received
// less would mint an UNBACKED claim and break conservation. So the deposit credits
// the OBSERVED delta (balanceOf(vault)_after balanceOf(vault)_before), never the
// requested amount. This mirrors how OpenZeppelin's SafeERC20 callers measure the
// realized transfer for non-standard tokens. The withdraw releases exactly the
// realized D-Chain burn and refuses if the vault cannot back it (the ERC-20 analog
// of releaseNativeFromVault's vault-underflow guard).
// erc20Vault is the OPTIONAL capability a StateDB implements to move ERC-20 value
// in and out of the 0x9010 vault. It is the token analog of the native
// GetBalance/AddBalance/SubBalance methods the StateDB already exposes, kept as a
// SEPARATE capability (type-asserted, exactly like txIdentified and custodyEngine)
// so the core StateDB interface — and every existing mock/adapter that implements
// it — stays unchanged. A StateDB that does not implement erc20Vault refuses
// ERC-20 custody cleanly (ErrERC20VaultUnavailable), never faking a credit.
//
// SEMANTICS (each method is the safe, OZ-style boundary):
// - TokenBalanceOf(token, owner): the owner's CURRENT balance of `token`, read
// from the token contract. The deposit measures the vault's balance with this
// before and after the pull to derive the OBSERVED delta.
// - TransferTokenFrom(token, from, to, amount): pull `amount` of `token` from
// `from` to `to`, using the allowance `from` granted (the ERC-20 transferFrom).
// Returns an error on a reverted / false-returning transfer (OZ-safe: a token
// that returns false or reverts is a FAILED transfer, never silently ignored).
// - TransferTokenTo(token, to, amount): push `amount` of `token` from the vault
// (0x9010, the precompile self) to `to` (the ERC-20 transfer). Same OZ-safe
// failure semantics.
//
// PRODUCTION BINDING: these are EVM sub-calls into the token contract. The
// concrete binding lives in the host StateDB adapter (poolStateAdapter), forwarded
// to the EVM via the precompile execution environment. See poolStateAdapter and
// the build note there for the one external wire this seam needs.
type erc20Vault interface {
TokenBalanceOf(token, owner common.Address) *big.Int
TransferTokenFrom(token, from, to common.Address, amount *big.Int) error
TransferTokenTo(token, to common.Address, amount *big.Int) error
}
// vaultLedgerPrefix keys the precompile's OWN per-token record of how much of each
// ERC-20 the 0x9010 vault holds against D-Chain claims. It lives in 0x9010's state
// (poolManagerAddr), NOT in the token contract's storage — the precompile NEVER
// pokes an ERC-20's balance slots (that path broke autoSettle and is forbidden).
// This ledger is the per-token conservation accumulator: vaultLedger[token] is the
// sum the vault locked via deposits minus what it released via withdraws, and the
// conservation invariant pins it to the real token balance of the vault.
var vaultLedgerPrefix = []byte("vlt2") // ERC-20 vault per-token holdings ledger
var (
// ErrERC20VaultUnavailable is returned when an ERC-20 custody op is attempted
// against a StateDB that does not implement the erc20Vault capability (e.g. a
// non-EVM caller or a test double without token support). It refuses rather than
// mint an unbacked D-Chain credit — the same fail-secure posture as the native
// unfunded-deposit refusal.
ErrERC20VaultUnavailable = errors.New("dex: ERC-20 vault capability unavailable on this StateDB")
// ErrERC20TransferFailed is returned when the token's transferFrom/transfer
// reverts or returns false (OZ-safe semantics). No D-Chain credit/burn is
// recorded — the custody op aborts with nothing moved on either side.
ErrERC20TransferFailed = errors.New("dex: ERC-20 transfer failed (reverted or returned false)")
// ErrERC20ZeroDelta is returned when a deposit's transferFrom delivered nothing
// to the vault (observed delta <= 0). Crediting zero is a no-op that would still
// burn an idempotency binding for an empty deposit; refuse so the caller learns
// the transfer did not fund the vault.
ErrERC20ZeroDelta = errors.New("dex: ERC-20 deposit observed zero balance delta (transfer did not fund the vault)")
)
// safeTransferTokenFrom is the SINGLE, DRY transferFrom path both legs route
// through (OpenZeppelin SafeERC20.safeTransferFrom semantics). It pulls `amount`
// of `token` from `from` into the vault and returns the OBSERVED delta the vault
// actually received — measured as balanceOf(vault)_after balanceOf(vault)_before
// — so a fee-on-transfer / rebasing token is credited for what truly arrived, not
// the requested amount. A reverted / false transfer surfaces as
// ErrERC20TransferFailed; a non-positive observed delta surfaces as
// ErrERC20ZeroDelta. This is the ONLY place the deposit leg moves a token in.
func safeTransferTokenFrom(vault erc20Vault, token, from, to common.Address, amount *big.Int) (*big.Int, error) {
before := vault.TokenBalanceOf(token, to)
if err := vault.TransferTokenFrom(token, from, to, amount); err != nil {
return nil, fmt.Errorf("%w: %v", ErrERC20TransferFailed, err)
}
after := vault.TokenBalanceOf(token, to)
// OBSERVED delta — the realized credit, robust to fee-on-transfer/rebasing.
delta := new(big.Int).Sub(after, before)
if delta.Sign() <= 0 {
return nil, ErrERC20ZeroDelta
}
return delta, nil
}
// safeTransferTokenTo is the SINGLE, DRY transfer-out path the withdraw leg routes
// through (OpenZeppelin SafeERC20.safeTransfer semantics). It pushes exactly
// `amount` of `token` from the vault to `to`. A reverted / false transfer surfaces
// as ErrERC20TransferFailed. The vault-underflow guard (vault holds < amount) is
// enforced by the caller BEFORE this is reached (releaseTokenFromVault), the ERC-20
// analog of releaseNativeFromVault's check, so a release can never drain another
// account's backing.
func safeTransferTokenTo(vault erc20Vault, token, to common.Address, amount *big.Int) error {
if err := vault.TransferTokenTo(token, to, amount); err != nil {
return fmt.Errorf("%w: %v", ErrERC20TransferFailed, err)
}
return nil
}
// loadVaultLedger reads the precompile's recorded ERC-20 vault holdings for a
// token (0 if none). This is 0x9010's OWN view of how much of the token it locked
// against D-Chain claims, used by the withdraw underflow guard and the per-token
// conservation invariant.
func loadVaultLedger(stateDB StateDB, token common.Address) *big.Int {
v := stateDB.GetState(poolManagerAddr, makeStorageKey(vaultLedgerPrefix, token.Bytes()))
return new(big.Int).SetBytes(v[:])
}
// storeVaultLedger writes the precompile's recorded ERC-20 vault holdings for a
// token. Holdings are a uint256 word; a deposit adds the observed delta, a withdraw
// subtracts the realized release.
func storeVaultLedger(stateDB StateDB, token common.Address, amount *big.Int) {
var w common.Hash
if amount != nil && amount.Sign() > 0 {
amount.FillBytes(w[:])
}
stateDB.SetState(poolManagerAddr, makeStorageKey(vaultLedgerPrefix, token.Bytes()), w)
}
// lockTokenIntoVault is the C-Chain LOCK leg of an ERC-20 DEPOSIT — the token
// analog of lockNativeIntoVault. Where native relies on the EVM having pre-moved
// msg.value into 0x9010 and merely VERIFIES the vault holds it, an ERC-20 has no
// pre-transfer, so this leg performs the transferFrom (the caller granted 0x9010 an
// allowance) and MEASURES the observed delta the vault actually received. It then
// records that delta in the precompile's per-token vault ledger. Returns the
// OBSERVED delta — the amount the D-Chain ledger must be credited (never the
// requested amount, so fee-on-transfer tokens cannot mint an unbacked claim).
func (pm *PoolManager) lockTokenIntoVault(stateDB StateDB, vault erc20Vault, caller common.Address, token common.Address, amount *big.Int) (*big.Int, error) {
if amount == nil || amount.Sign() <= 0 {
return nil, fmt.Errorf("%w: deposit amount must be positive", ErrInvalidAmount)
}
// Pull the token into the vault and measure what truly arrived (observed delta).
delta, err := safeTransferTokenFrom(vault, token, caller, poolManagerAddr, amount)
if err != nil {
return nil, err
}
if !delta.IsUint64() {
// The D-Chain ledger keys balances by uint64 units; an observed delta beyond
// that range cannot be credited 1:1. Refuse rather than truncate (a truncated
// credit would strand the untracked remainder in the vault).
return nil, fmt.Errorf("%w: observed delta exceeds uint64 ledger range", ErrInvalidAmount)
}
// Record the locked holdings in the precompile's per-token ledger (0x9010's own
// state — never the token's slots). available[*][token] + locked[*][token] on the
// D-Chain now has this much real token backing in the vault.
cur := loadVaultLedger(stateDB, token)
storeVaultLedger(stateDB, token, new(big.Int).Add(cur, delta))
return delta, nil
}
// releaseTokenFromVault is the C-Chain RELEASE leg of an ERC-20 WITHDRAW — the
// token analog of releaseNativeFromVault. It refuses to release more than the vault
// holds for the token (the underflow guard: under the conservation invariant the
// vault always holds >= the realized D-Chain burn, so this is defense in depth),
// debits the precompile's per-token vault ledger, then transfers exactly `amount`
// of the token from the vault to the recipient. This is the only direction the
// vault ever pays out a token, and only against a realized ledger burn.
func (pm *PoolManager) releaseTokenFromVault(stateDB StateDB, vault erc20Vault, recipient common.Address, token common.Address, amount *big.Int) error {
if amount == nil || amount.Sign() <= 0 {
return nil
}
held := loadVaultLedger(stateDB, token)
if held.Cmp(amount) < 0 {
// The vault cannot back this release. Under the invariant this is impossible;
// refusing prevents paying out a token the vault does not hold for this asset.
return fmt.Errorf("%w: vault holds %s of token < release %s (invariant breach)", ErrInsufficientBalance, held, amount)
}
// Debit the per-token ledger FIRST (state before external effect), then pay out.
storeVaultLedger(stateDB, token, new(big.Int).Sub(held, amount))
if err := safeTransferTokenTo(vault, token, recipient, amount); err != nil {
return err
}
return nil
}
+232
View File
@@ -0,0 +1,232 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"math/big"
"testing"
"time"
"github.com/holiman/uint256"
"github.com/luxfi/geth/common"
)
// erc20_vault_test.go provides a FAITHFUL in-memory ERC-20 double for exercising
// the precompile's ERC-20 custody rail. The double models a real token's invariant:
// balanceOf is the SOLE source of truth and is mutated ONLY by the token's own
// transfer/transferFrom logic (respecting allowances). The precompile NEVER writes
// these balances directly — and CANNOT: the erc20Vault seam the precompile reaches
// the token through exposes only TokenBalanceOf/TransferTokenFrom/TransferTokenTo,
// with NO balance-write method, so the forbidden balance-slot-poke path that broke
// autoSettle is structurally unreachable. The tests assert the precompile touches
// the token ONLY through transferFrom/transfer, counted by mockToken.transfers (the
// load-bearing move-count that pins "no slot poke").
// mockToken is one ERC-20 contract's state: per-holder balances and per-(owner,
// spender) allowances. feeBps, if non-zero, models a fee-on-transfer token: a
// transferFrom/transfer of `amount` debits the sender `amount` but credits the
// recipient `amount - fee`, so the recipient observes LESS than requested (the fee
// is burned here for simplicity — what matters is the recipient's OBSERVED delta).
type mockToken struct {
balances map[common.Address]*big.Int
allowance map[common.Address]map[common.Address]*big.Int
feeBps uint64 // fee in basis points on transfer (0 = standard token)
// transfers counts genuine transfer/transferFrom moves — the load-bearing
// "no slot poke" assertion. There is no direct-balance-write counter because
// there is no balance-write path: the erc20Vault seam the precompile uses
// exposes none, so an out-of-band mutation is structurally impossible to reach.
// (The sole direct writes in this double are mint/credit/debit, which ARE the
// token's own transfer logic + test-only seeding, never a precompile poke.)
transfers int
// revertTransfer, if true, makes every transfer/transferFrom revert (models a
// token that rejects the move) — for the OZ-safe failure path.
revertTransfer bool
// returnsFalse, if true, makes transfer/transferFrom return false instead of
// reverting (models a non-reverting token that signals failure via the bool).
returnsFalse bool
}
func newMockToken(feeBps uint64) *mockToken {
return &mockToken{
balances: make(map[common.Address]*big.Int),
allowance: make(map[common.Address]map[common.Address]*big.Int),
feeBps: feeBps,
}
}
func (t *mockToken) balanceOf(a common.Address) *big.Int {
if b, ok := t.balances[a]; ok {
return new(big.Int).Set(b)
}
return big.NewInt(0)
}
// mint seeds a holder's balance — test setup ONLY (models the holder already owning
// the token before any custody op). This is the sole legitimate direct write, used
// only by tests to establish preconditions, and is NOT counted as a transfer.
func (t *mockToken) mint(a common.Address, amount uint64) {
t.mintBig(a, new(big.Int).SetUint64(amount))
}
// mintBig seeds a holder with an arbitrary-precision balance (for the uint64-ledger
// boundary test, where the deposit amount must exceed 2^64-1).
func (t *mockToken) mintBig(a common.Address, amount *big.Int) {
cur := t.balanceOf(a)
t.balances[a] = new(big.Int).Add(cur, amount)
}
func (t *mockToken) approveBig(owner, spender common.Address, amount *big.Int) {
if t.allowance[owner] == nil {
t.allowance[owner] = make(map[common.Address]*big.Int)
}
t.allowance[owner][spender] = new(big.Int).Set(amount)
}
func (t *mockToken) approve(owner, spender common.Address, amount uint64) {
if t.allowance[owner] == nil {
t.allowance[owner] = make(map[common.Address]*big.Int)
}
t.allowance[owner][spender] = new(big.Int).SetUint64(amount)
}
func (t *mockToken) getAllowance(owner, spender common.Address) *big.Int {
if m, ok := t.allowance[owner]; ok {
if v, ok := m[spender]; ok {
return new(big.Int).Set(v)
}
}
return big.NewInt(0)
}
// credit applies a transfer's recipient leg, applying the fee-on-transfer haircut.
// Returns the amount actually credited (== amount for a standard token, < amount
// for a fee token).
func (t *mockToken) credit(to common.Address, amount *big.Int) *big.Int {
credited := new(big.Int).Set(amount)
if t.feeBps > 0 {
fee := new(big.Int).Mul(amount, new(big.Int).SetUint64(t.feeBps))
fee.Div(fee, big.NewInt(10_000))
credited.Sub(credited, fee)
}
cur := t.balanceOf(to)
t.balances[to] = new(big.Int).Add(cur, credited)
return credited
}
// debit removes amount from `from` (the full requested amount; the fee is taken on
// the credit side). Returns false if `from` lacks the balance.
func (t *mockToken) debit(from common.Address, amount *big.Int) bool {
cur := t.balanceOf(from)
if cur.Cmp(amount) < 0 {
return false
}
t.balances[from] = new(big.Int).Sub(cur, amount)
return true
}
// mockTokenRegistry maps token addresses to their mockToken state. This is the
// "EVM world state" of ERC-20 contracts the precompile sub-calls reach.
type mockTokenRegistry struct {
tokens map[common.Address]*mockToken
}
func newMockTokenRegistry() *mockTokenRegistry {
return &mockTokenRegistry{tokens: make(map[common.Address]*mockToken)}
}
func (r *mockTokenRegistry) register(addr common.Address, t *mockToken) {
r.tokens[addr] = t
}
func (r *mockTokenRegistry) get(addr common.Address) *mockToken {
return r.tokens[addr]
}
// tokenStateDB is a txStateDB that ALSO implements the erc20Vault capability,
// backed by a mockTokenRegistry. It is the test analog of poolStateAdapter's
// production erc20Vault binding (which sub-calls the real token); here the "sub-call"
// resolves against the in-memory registry, modeling transferFrom/transfer/balanceOf
// with full allowance + fee-on-transfer fidelity.
type tokenStateDB struct {
*txStateDB
registry *mockTokenRegistry
}
func (s *tokenStateDB) TokenBalanceOf(token, owner common.Address) *big.Int {
t := s.registry.get(token)
if t == nil {
return big.NewInt(0)
}
return t.balanceOf(owner)
}
func (s *tokenStateDB) TransferTokenFrom(token, from, to common.Address, amount *big.Int) error {
t := s.registry.get(token)
if t == nil {
return ErrERC20TransferFailed
}
if t.revertTransfer {
return ErrERC20TransferFailed
}
if t.returnsFalse {
// A non-reverting token that signals failure via the bool: nothing moves.
return ErrERC20TransferFailed
}
// Allowance check (transferFrom uses the from->spender(0x9010) allowance).
allow := t.getAllowance(from, poolManagerAddr)
if allow.Cmp(amount) < 0 {
return ErrERC20TransferFailed
}
if !t.debit(from, amount) {
return ErrERC20TransferFailed
}
t.allowance[from][poolManagerAddr] = new(big.Int).Sub(allow, amount)
t.credit(to, amount)
t.transfers++
return nil
}
func (s *tokenStateDB) TransferTokenTo(token, to common.Address, amount *big.Int) error {
t := s.registry.get(token)
if t == nil {
return ErrERC20TransferFailed
}
if t.revertTransfer || t.returnsFalse {
return ErrERC20TransferFailed
}
// transfer moves from the vault (0x9010, the precompile self).
if !t.debit(poolManagerAddr, amount) {
return ErrERC20TransferFailed
}
t.credit(to, amount)
t.transfers++
return nil
}
var _ erc20Vault = (*tokenStateDB)(nil)
// custodyPMToken builds a PoolManager wired to a fresh fakeCLOB + a tokenStateDB
// (txIdentified + erc20Vault), so the ERC-20 custody rail engages end-to-end
// against the in-memory token double. Returns the manager, the fake book, the
// token-capable stateDB, the registry, and the caller address. The caller is funded
// with `nativeSeed` native LUX (for any native legs in the same test).
func custodyPMToken(t *testing.T, nativeSeed uint64) (*PoolManager, *fakeCLOB, *tokenStateDB, *mockTokenRegistry, common.Address) {
t.Helper()
f := newFakeCLOB()
withFakeCLOB(t, f)
zap := NewZAPEngine("fake:0", 2*time.Second)
t.Cleanup(func() { _ = zap.Close() })
pm := NewPoolManager(zap)
base := &txStateDB{MockStateDB: NewMockStateDB()}
base.txHash = common.HexToHash("0xdead00000000000000000000000000000000000000000000000000000000beef")
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
base.balances[caller] = uint256.NewInt(nativeSeed)
sdb := &tokenStateDB{txStateDB: base, registry: newMockTokenRegistry()}
return pm, f, sdb, sdb.registry, caller
}
// setTxHash lets a test give the stateDB a distinct tx identity (a genuinely
// distinct custody op), so idempotency bindings and the D-Chain seen: index treat
// it as a new operation rather than a replay.
func (s *tokenStateDB) setTxHash(h common.Hash) { s.txHash = h }
+15 -42
View File
@@ -7,8 +7,6 @@ import (
"errors"
"math/big"
"testing"
"github.com/luxfi/geth/common"
)
// TestUnconfiguredPrecompileRevertsNoEmbeddedFallback pins the one-way engine
@@ -48,20 +46,11 @@ func TestUnconfiguredPrecompileRevertsNoEmbeddedFallback(t *testing.T) {
t.Fatalf("Swap on unconfigured precompile = (%v, %v), want (zero delta, error)", d, err)
}
// The inert engine itself reverts EVERY value-moving op with the sentinel
// and returns zero deltas — the proof that no embedded fallback fabricates
// value. (Hit directly so the not-initialized gate above can't mask it.)
e := pm.engine
if d, err := e.Swap(&PoolState{}, common.Address{}, SwapParams{AmountSpecified: big.NewInt(-1)}); !errors.Is(err, ErrDEXBackendNotConfigured) || !d.IsZero() {
t.Fatalf("inert Swap = (%v, %v), want (zero, ErrDEXBackendNotConfigured)", d, err)
}
cd, fd, err := e.ModifyLiquidity(&PoolState{}, common.Address{}, ModifyLiquidityParams{TickLower: -60, TickUpper: 60, LiquidityDelta: big.NewInt(1)})
if !errors.Is(err, ErrDEXBackendNotConfigured) || !cd.IsZero() || !fd.IsZero() {
t.Fatalf("inert ModifyLiquidity = (%v, %v, %v), want (zero, zero, ErrDEXBackendNotConfigured)", cd, fd, err)
}
if d, err := e.Donate(&PoolState{}, big.NewInt(1), big.NewInt(1)); !errors.Is(err, ErrDEXBackendNotConfigured) || !d.IsZero() {
t.Fatalf("inert Donate = (%v, %v), want (zero, ErrDEXBackendNotConfigured)", d, err)
}
// The inert engine itself is the sentinel: no stateful seam and every value op
// reverts with ErrDEXBackendNotConfigured + zero delta — the proof that no
// embedded fallback fabricates value. (Hit directly so the not-initialized gate
// above can't mask it. Shared definition — see assertInertEngineSentinel.)
assertInertEngineSentinel(t, pm.engine)
}
// TestNoLocalQuotePath proves the embedded local-quote path is gone. It commits
@@ -80,40 +69,24 @@ func TestNoLocalQuotePath(t *testing.T) {
Fee: Fee030,
TickSpacing: TickSpacing030,
}
// Sanity: the committed liquid pool reads back initialized — so a surviving
// embedded AMM WOULD have priced it (this is what makes the zero-quote
// assertion below meaningful, not vacuous).
poolId := key.ID()
// Commit a liquid pool directly to StateDB (bypassing Initialize, which the
// inert engine refuses). This is the precompile-held *Pool state that the
// dead embedded path would have quoted against locally.
liquidPool := &Pool{
pm.setPool(stateDB, poolId, &Pool{
SqrtPriceX96: new(big.Int).Set(Q96),
Tick: 0,
Liquidity: big.NewInt(1_000_000_000),
FeeGrowth0X128: big.NewInt(0),
FeeGrowth1X128: big.NewInt(0),
}
pm.setPool(stateDB, poolId, liquidPool)
})
if !pm.getPool(stateDB, poolId).IsInitialized() {
t.Fatalf("test setup: committed pool must read back initialized")
}
// The single routed quote path: inert backend => zero, no local AMM math.
if q := pm.calculateSwapOutput(stateDB, key, poolId, big.NewInt(10_000), true); q.Sign() != 0 {
t.Fatalf("calculateSwapOutput on inert backend = %s, want 0 (no local-quote path)", q)
}
// And directly: the inert engine never quotes a liquid pool locally.
if q := pm.engine.Quote(liquidPool, big.NewInt(10_000), true); q.Sign() != 0 {
t.Fatalf("inert Quote of a liquid pool = %s, want 0", q)
}
// The inert engine is NOT a poolRouter (no canonical pool to route to) and
// NOT a custodyEngine (no ledger). These negative assertions lock the
// two-state model: only the configured ZAP backend implements those seams.
if _, ok := pm.engine.(poolRouter); ok {
t.Fatalf("inert engine must NOT implement poolRouter (would imply local pool state)")
}
if _, ok := pm.engine.(custodyEngine); ok {
t.Fatalf("inert engine must NOT implement custodyEngine (would imply a local ledger)")
}
// No local-quote path: the consolidated quote path and the raw engine BOTH
// return zero for that liquid pool, and the inert engine exposes no stateful
// seam. (Shared definitions — assertNoLocalQuotePath + assertInertEngineSentinel.)
assertNoLocalQuotePath(t, pm, stateDB, key)
assertInertEngineSentinel(t, pm.engine)
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"errors"
"math/big"
"testing"
"github.com/luxfi/geth/common"
)
// inert_sentinel_test.go is the ONE shared helper for the inert-engine sentinel /
// negative-seam assertions. Both route_not_simulate_test.go and
// inert_no_embedded_test.go pin the SAME two-state engine model — inert default
// refuses every value op and exposes no stateful seam, ZAP is the only configured
// backend — and both previously re-implemented the ~40-line negative-seam +
// behavioral-revert block. This consolidates that into one place (DRY): the seam
// assertions and the per-op revert assertions live here, and each test file calls
// the helper instead of repeating the block. There is exactly one definition of
// "this engine is the inert sentinel".
// assertInertEngineSentinel is the executable definition of the inert default: it
// holds NO stateful seam (poolRouter / custodyEngine / cancelAuthority — having any
// would imply an embedded pool / ledger / order map = a second matcher) AND every
// value-moving op refuses with ErrDEXBackendNotConfigured and a ZERO delta (it
// fabricates no fill, delta, or quote). This is the tripwire: if an embedded engine
// ever creeps back in, one of these assertions flips.
func assertInertEngineSentinel(t *testing.T, e Engine) {
t.Helper()
// Structural: no stateful seam => cannot embed a matcher/book/ledger/order-map.
if _, ok := e.(poolRouter); ok {
t.Fatal("inert engine implements poolRouter — implies embedded canonical pool state (second matcher)")
}
if _, ok := e.(custodyEngine); ok {
t.Fatal("inert engine implements custodyEngine — implies an embedded balance ledger")
}
if _, ok := e.(cancelAuthority); ok {
t.Fatal("inert engine implements cancelAuthority — implies an embedded resting-order map")
}
// Behavioral: every value-moving op refuses with the backend sentinel + zero
// delta. No op fabricates a fill, a delta, or a quote.
if d, err := e.Swap(&PoolState{}, common.Address{}, SwapParams{AmountSpecified: big.NewInt(-1)}); !errors.Is(err, ErrDEXBackendNotConfigured) || !d.IsZero() {
t.Fatalf("inert Swap = (%v,%v), want (zero, ErrDEXBackendNotConfigured)", d, err)
}
if cd, fd, err := e.ModifyLiquidity(&PoolState{}, common.Address{}, ModifyLiquidityParams{TickLower: -60, TickUpper: 60, LiquidityDelta: big.NewInt(1)}); !errors.Is(err, ErrDEXBackendNotConfigured) || !cd.IsZero() || !fd.IsZero() {
t.Fatalf("inert ModifyLiquidity = (%v,%v,%v), want (zero,zero,ErrDEXBackendNotConfigured)", cd, fd, err)
}
if d, err := e.Donate(&PoolState{}, big.NewInt(1), big.NewInt(1)); !errors.Is(err, ErrDEXBackendNotConfigured) || !d.IsZero() {
t.Fatalf("inert Donate = (%v,%v), want (zero, ErrDEXBackendNotConfigured)", d, err)
}
}
// assertNoLocalQuotePath pins that NO embedded AMM prices precompile-held pool
// state: it commits a fully-liquid pool DIRECTLY to StateDB (bypassing Initialize,
// which the inert backend refuses) and asserts BOTH the consolidated quote path
// (calculateSwapOutput) and the raw engine Quote return ZERO — there is no book to
// read and nothing computes locally. One quote path only: ZAP reads its canonical
// D-Chain pool, inert returns zero.
func assertNoLocalQuotePath(t *testing.T, pm *PoolManager, stateDB StateDB, key PoolKey) {
t.Helper()
poolId := key.ID()
liquid := &Pool{
SqrtPriceX96: new(big.Int).Set(Q96),
Tick: 0,
Liquidity: big.NewInt(1_000_000_000),
FeeGrowth0X128: big.NewInt(0),
FeeGrowth1X128: big.NewInt(0),
}
pm.setPool(stateDB, poolId, liquid)
if q := pm.calculateSwapOutput(stateDB, key, poolId, big.NewInt(10_000), true); q.Sign() != 0 {
t.Fatalf("calculateSwapOutput on inert backend = %s, want 0 (NO local-quote path)", q)
}
if q := pm.engine.Quote(liquid, big.NewInt(10_000), true); q.Sign() != 0 {
t.Fatalf("inert Quote of a liquid pool = %s, want 0 (NO embedded AMM)", q)
}
}
+31 -14
View File
@@ -396,7 +396,7 @@ func (c *DEXContract) runInitialize(
}
// Initialize pool
stateAdapter := &poolStateAdapter{stateDB: state.GetStateDB(), blockNumber: state.GetBlockContext().Number().Uint64()}
stateAdapter := newPoolStateAdapter(state)
tick, err := c.poolManager.Initialize(stateAdapter, key, sqrtPriceX96, hookData)
if err != nil {
return nil, suppliedGas - GasPoolCreate, err
@@ -429,7 +429,7 @@ func (c *DEXContract) runSwap(
return nil, suppliedGas - GasSwap, err
}
stateAdapter := &poolStateAdapter{stateDB: state.GetStateDB(), blockNumber: state.GetBlockContext().Number().Uint64()}
stateAdapter := newPoolStateAdapter(state)
delta, err := c.poolManager.Swap(stateAdapter, caller, key, params, hookData)
if err != nil {
@@ -466,7 +466,7 @@ func (c *DEXContract) runModifyLiquidity(
return nil, suppliedGas - GasAddLiquidity, err
}
stateAdapter := &poolStateAdapter{stateDB: state.GetStateDB(), blockNumber: state.GetBlockContext().Number().Uint64()}
stateAdapter := newPoolStateAdapter(state)
delta, feeDelta, err := c.poolManager.ModifyLiquidity(stateAdapter, caller, key, params, hookData)
if err != nil {
@@ -511,7 +511,7 @@ func (c *DEXContract) runDeposit(
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()}
stateAdapter := newPoolStateAdapter(state)
if err := c.poolManager.Deposit(stateAdapter, caller, asset, amount); err != nil {
return nil, suppliedGas - GasSettlement, err
}
@@ -545,7 +545,7 @@ func (c *DEXContract) runWithdraw(
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()}
stateAdapter := newPoolStateAdapter(state)
realized, err := c.poolManager.Withdraw(stateAdapter, caller, asset, want)
if err != nil {
return nil, suppliedGas - GasSettlement, err
@@ -646,7 +646,7 @@ func (c *DEXContract) runDonate(
amount0 := new(big.Int).SetBytes(input[160:192])
amount1 := new(big.Int).SetBytes(input[192:224])
stateAdapter := &poolStateAdapter{stateDB: state.GetStateDB(), blockNumber: state.GetBlockContext().Number().Uint64()}
stateAdapter := newPoolStateAdapter(state)
var hookData []byte
if len(input) > 224 {
@@ -690,7 +690,7 @@ func (c *DEXContract) runGetPool(
}
poolId := key.ID()
stateAdapter := &poolStateAdapter{stateDB: state.GetStateDB(), blockNumber: state.GetBlockContext().Number().Uint64()}
stateAdapter := newPoolStateAdapter(state)
pool := c.poolManager.getPool(stateAdapter, poolId)
if !pool.IsInitialized() {
return nil, suppliedGas - GasPoolLookup, fmt.Errorf("pool not found")
@@ -822,7 +822,7 @@ func (c *DEXContract) runPauseDEX(
if suppliedGas < GasAdmin {
return nil, 0, fmt.Errorf("out of gas")
}
stateAdapter := &poolStateAdapter{stateDB: state.GetStateDB(), blockNumber: state.GetBlockContext().Number().Uint64()}
stateAdapter := newPoolStateAdapter(state)
if err := c.poolManager.PauseDEX(stateAdapter, caller); err != nil {
return nil, suppliedGas - GasAdmin, err
}
@@ -841,7 +841,7 @@ func (c *DEXContract) runResumeDEX(
if suppliedGas < GasAdmin {
return nil, 0, fmt.Errorf("out of gas")
}
stateAdapter := &poolStateAdapter{stateDB: state.GetStateDB(), blockNumber: state.GetBlockContext().Number().Uint64()}
stateAdapter := newPoolStateAdapter(state)
if err := c.poolManager.ResumeDEX(stateAdapter, caller); err != nil {
return nil, suppliedGas - GasAdmin, err
}
@@ -866,7 +866,7 @@ func (c *DEXContract) runPausePool(
}
var poolId [32]byte
copy(poolId[:], input[:32])
stateAdapter := &poolStateAdapter{stateDB: state.GetStateDB(), blockNumber: state.GetBlockContext().Number().Uint64()}
stateAdapter := newPoolStateAdapter(state)
if err := c.poolManager.PausePool(stateAdapter, caller, poolId); err != nil {
return nil, suppliedGas - GasAdmin, err
}
@@ -891,7 +891,7 @@ func (c *DEXContract) runResumePool(
}
var poolId [32]byte
copy(poolId[:], input[:32])
stateAdapter := &poolStateAdapter{stateDB: state.GetStateDB(), blockNumber: state.GetBlockContext().Number().Uint64()}
stateAdapter := newPoolStateAdapter(state)
if err := c.poolManager.ResumePool(stateAdapter, caller, poolId); err != nil {
return nil, suppliedGas - GasAdmin, err
}
@@ -916,7 +916,7 @@ func (c *DEXContract) runFreezePool(
}
var poolId [32]byte
copy(poolId[:], input[:32])
stateAdapter := &poolStateAdapter{stateDB: state.GetStateDB(), blockNumber: state.GetBlockContext().Number().Uint64()}
stateAdapter := newPoolStateAdapter(state)
if err := c.poolManager.FreezePool(stateAdapter, caller, poolId); err != nil {
return nil, suppliedGas - GasAdmin, err
}
@@ -1008,9 +1008,26 @@ func extsloadArrayGas(data []byte) uint64 {
// poolStateAdapter adapts contract.StateDB to dex.StateDB.
// blockNumber must be set from the execution context (AccessibleState.GetBlockContext().Number())
// since contract.StateDB does not expose block number.
//
// accessibleState is retained so the adapter can reach the EVM execution
// environment (GetPrecompileEnv) for the ERC-20 custody legs, which must sub-call
// the token contract (transferFrom / transfer / balanceOf). See module_erc20.go
// for the erc20Vault binding and the optional-Call seam.
type poolStateAdapter struct {
stateDB contract.StateDB
blockNumber uint64
stateDB contract.StateDB
blockNumber uint64
accessibleState contract.AccessibleState
}
// newPoolStateAdapter builds the dex.StateDB adapter from the precompile execution
// context. The single construction point so every selector handler wires the same
// state, block number, and EVM environment (the ERC-20 vault seam needs the env).
func newPoolStateAdapter(state contract.AccessibleState) *poolStateAdapter {
return &poolStateAdapter{
stateDB: state.GetStateDB(),
blockNumber: state.GetBlockContext().Number().Uint64(),
accessibleState: state,
}
}
func (a *poolStateAdapter) GetState(addr common.Address, key common.Hash) common.Hash {
+194
View File
@@ -0,0 +1,194 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"fmt"
"math/big"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/contract"
)
// module_erc20.go binds the erc20Vault capability (erc20_vault.go) to the EVM by
// sub-calling the token contract for transferFrom / transfer / balanceOf. This is
// the C-Chain token-movement leg of ERC-20 custody — the analog of the EVM moving
// msg.value into 0x9010 for native LUX. The PoolManager's ERC-20 deposit/withdraw
// branches type-assert the StateDB for erc20Vault; poolStateAdapter satisfies it
// here, so the rail is live whenever the precompile execution environment exposes a
// Call surface (the optional callableEnv seam below).
//
// ─────────────────────────────────────────────────────────────────────────────
// THE ONE EXTERNAL WIRE (surfaced, not hidden):
//
// A precompile can only sub-call a contract through the EVM execution environment.
// The geth EVM environment (vm.PrecompileEnvironment) HAS a Call method, but the
// adapter chain that reaches the external DEX precompile narrows it away:
//
// geth vm.PrecompileEnvironment{Call,...} <- has Call
// └─> evm/core/precompile_overrider.accessibleStateAdapter (coreth iface,
// 4 methods, no GetPrecompileEnv) <- env captured, NOT exposed
// └─> evm/precompile/registry.accessibleStateBridge.GetPrecompileEnv()=nil
// └─> luxfi/precompile contract.AccessibleState.GetPrecompileEnv()
// -> contract.PrecompileEnvironment{ReadOnly()} <- no Call
//
// So GetPrecompileEnv() currently yields nil for this precompile, and an ERC-20
// deposit/withdraw refuses with ErrERC20VaultUnavailable rather than minting an
// unbacked claim — fail-secure. To make the live token leg execute, the env must be
// forwarded with a Call surface. The MINIMAL forwarding (kept off the shared,
// many-implementer contract.PrecompileEnvironment interface to avoid breaking every
// precompile's mocks) is to have the concrete adapters expose a Call method this
// seam type-asserts:
//
// 1. geth/core/vm/lux_precompiles.go precompileEnvAdapter: add
// func (p *precompileEnvAdapter) Call(addr common.Address, input []byte,
// gas uint64, value *big.Int) ([]byte, uint64, error) {
// return p.env.Call(addr, input, gas, value,
// vm.WithUNSAFECallerAddressProxying()) // for transferFrom: pull as caller's allowance to self(0x9010)
// }
// 2. evm/precompile/registry/bridge.go accessibleStateBridge.GetPrecompileEnv():
// return a bridge that forwards Call to the internal env (requires the
// coreth accessibleStateAdapter to expose the env — a concrete method, not
// an interface widening).
// 3. evm/core/precompile_overrider.go accessibleStateAdapter: expose the env
// (concrete GetPrecompileEnv or a Call passthrough) so the bridge can reach it.
//
// Until that wire lands, the precompile-rail ERC-20 deposit/withdraw is COMPLETE
// and conserving in logic (observed-delta credit, per-token vault ledger,
// vault-underflow guard, idempotency) and is exercised end-to-end by the unit
// suite against the in-memory token double; it simply refuses live ERC-20 custody
// on a chain whose env does not yet forward Call. Native LUX custody is unaffected.
// ─────────────────────────────────────────────────────────────────────────────
// callableEnv is the OPTIONAL Call surface this seam type-asserts on the precompile
// execution environment. It mirrors geth vm.PrecompileEnvironment.Call exactly so a
// concrete env that forwards Call satisfies it with no shared-interface change. The
// value is nil/value-less (custody moves no native value through these sub-calls);
// the token contract is the callee.
type callableEnv interface {
Call(addr common.Address, input []byte, gas uint64, value *big.Int) (ret []byte, gasLeft uint64, err error)
}
// erc20GasBudget caps the gas a single token sub-call may consume. ERC-20
// transfer/transferFrom/balanceOf on a well-behaved token are well under this; a
// token that needs more is treated as a failed transfer (fail-secure). The
// custody selector already debited GasSettlement from the precompile's budget; this
// is the inner sub-call allowance.
const erc20GasBudget uint64 = 100_000
// ERC-20 method selectors (keccak256(sig)[:4]).
var (
selERC20BalanceOf = crypto.Keccak256([]byte("balanceOf(address)"))[:4]
selERC20Transfer = crypto.Keccak256([]byte("transfer(address,uint256)"))[:4]
selERC20TransferFrom = crypto.Keccak256([]byte("transferFrom(address,address,uint256)"))[:4]
)
// callable returns the EVM Call surface for token sub-calls, or nil if this
// execution environment does not expose one (see the wiring note above). When nil,
// the erc20Vault methods fail-secure: balance reads return 0 and transfers error,
// so the custody legs refuse rather than mint/strand.
func (a *poolStateAdapter) callable() callableEnv {
if a.accessibleState == nil {
return nil
}
env := a.accessibleState.GetPrecompileEnv()
if env == nil {
return nil
}
c, ok := env.(callableEnv)
if !ok {
return nil
}
return c
}
// TokenBalanceOf reads owner's balance of token via a balanceOf(address)
// sub-call. A reverted / malformed return yields 0 (fail-secure: an unreadable
// balance makes the observed delta non-positive, which refuses the deposit rather
// than crediting an unverified amount).
func (a *poolStateAdapter) TokenBalanceOf(token, owner common.Address) *big.Int {
c := a.callable()
if c == nil {
return big.NewInt(0)
}
input := make([]byte, 0, 4+32)
input = append(input, selERC20BalanceOf...)
input = append(input, common.LeftPadBytes(owner.Bytes(), 32)...)
ret, _, err := c.Call(token, input, erc20GasBudget, nil)
if err != nil || len(ret) < 32 {
return big.NewInt(0)
}
return new(big.Int).SetBytes(ret[:32])
}
// TransferTokenFrom pulls amount of token from `from` to `to` via a
// transferFrom(address,address,uint256) sub-call, using the allowance `from`
// granted to 0x9010 (the precompile self). OZ-safe: a revert OR a false bool
// return is a FAILED transfer (errors); a no-return-data success (non-compliant
// tokens that return nothing) is accepted, matching SafeERC20.
func (a *poolStateAdapter) TransferTokenFrom(token, from, to common.Address, amount *big.Int) error {
c := a.callable()
if c == nil {
return ErrERC20VaultUnavailable
}
input := make([]byte, 0, 4+96)
input = append(input, selERC20TransferFrom...)
input = append(input, common.LeftPadBytes(from.Bytes(), 32)...)
input = append(input, common.LeftPadBytes(to.Bytes(), 32)...)
input = append(input, common.LeftPadBytes(amount.Bytes(), 32)...)
ret, _, err := c.Call(token, input, erc20GasBudget, nil)
return checkERC20Return(ret, err)
}
// TransferTokenTo pushes amount of token from the vault (0x9010, the precompile
// self) to `to` via a transfer(address,uint256) sub-call. Same OZ-safe semantics
// as TransferTokenFrom.
func (a *poolStateAdapter) TransferTokenTo(token, to common.Address, amount *big.Int) error {
c := a.callable()
if c == nil {
return ErrERC20VaultUnavailable
}
input := make([]byte, 0, 4+64)
input = append(input, selERC20Transfer...)
input = append(input, common.LeftPadBytes(to.Bytes(), 32)...)
input = append(input, common.LeftPadBytes(amount.Bytes(), 32)...)
ret, _, err := c.Call(token, input, erc20GasBudget, nil)
return checkERC20Return(ret, err)
}
// checkERC20Return applies OpenZeppelin SafeERC20 return semantics to a
// transfer/transferFrom result:
// - a non-nil call error (revert) is a failed transfer;
// - return data present and decoding to a single ABI bool that is false is a
// failed transfer (tokens that signal failure via the bool);
// - return data present and true, OR no return data at all (non-compliant tokens
// that return nothing on success), is a success.
func checkERC20Return(ret []byte, callErr error) error {
if callErr != nil {
return fmt.Errorf("token call reverted: %w", callErr)
}
if len(ret) == 0 {
// Non-compliant token returned no data; treat as success (SafeERC20 does).
return nil
}
if len(ret) < 32 {
return fmt.Errorf("token returned malformed data (%d bytes)", len(ret))
}
// ABI bool is right-aligned in a 32-byte word; nonzero => true.
for _, b := range ret[:32] {
if b != 0 {
return nil
}
}
return fmt.Errorf("token transfer returned false")
}
// Compile-time assertion: poolStateAdapter satisfies the erc20Vault capability the
// PoolManager type-asserts for ERC-20 custody.
var _ erc20Vault = (*poolStateAdapter)(nil)
// (kept here so a refactor of contract.AccessibleState that drops GetPrecompileEnv
// is caught at build time rather than silently disabling ERC-20 custody.)
var _ = func(s contract.AccessibleState) contract.PrecompileEnvironment { return s.GetPrecompileEnv() }
+178 -30
View File
@@ -46,8 +46,18 @@ var (
depBindPrefix = []byte("depb") // deposit idempotency binding
wdrBindPrefix = []byte("wdrb") // withdraw idempotency binding
cancelAuthPrefix = []byte("cana") // durable cancel-authorization binding (RED H4)
custodyGuardPrefix = []byte("creg") // GLOBAL non-reentrant guard for the custody entrypoint
)
// custodyGuardKey is the SINGLE, fixed storage slot in 0x9010's own state that
// holds the non-reentrant entry flag for the custody dispatch (Deposit/Withdraw).
// It is GLOBAL — one slot for the whole entrypoint, NOT per-(account,asset,amount)
// — because the reentrancy a malicious token mounts during its transferFrom
// sub-call can re-enter Deposit/Withdraw with ANY arguments (a distinct-amount
// variant defeats a per-bindKey flag). A single global flag set before any token
// sub-call and cleared after makes ANY nested custody call revert.
var custodyGuardKey = makeStorageKey(custodyGuardPrefix, []byte{0x01})
// PoolState extends the basic Pool with V4 tick-level state for concentrated
// liquidity. Each pool tracks per-tick info, a bitmap of initialized ticks,
// per-position fee growth, and fee/spacing parameters from the pool key.
@@ -256,6 +266,28 @@ func custodyBindKey(stateDB StateDB, prefix []byte, caller common.Address, asset
return makeStorageKey(prefix, id[:]), true
}
// custodyRef returns the 32-byte originating-tx idempotency reference threaded
// into the clob_deposit/clob_withdraw frame and, through it, into the D-Chain's
// content-addressed seen: dedup key. It is the EVM transaction hash (the same
// identity custodyBindKey keys the EVM-side replay guard on), so "the same custody
// operation" means ONE thing end to end: the EVM short-circuits a same-tx
// re-execution on its binding before ever calling the backend, while a GENUINELY
// distinct second deposit/withdraw (a different tx, hence a different ref) is
// distinct on the D-Chain too — processed fresh against CURRENT balances rather
// than replayed from seen:. This single, unified identity is the vault-drain fix.
//
// A non-EVM caller (test StateDB that cannot name the tx) yields the zero ref; the
// D-Chain then dedups on (zero-ref‖user‖asset‖amount) exactly as before the fix —
// acceptable for those non-production paths. The EVM custody ingress
// (runDeposit/runWithdraw) ALWAYS supplies a real, per-tx-distinct txHash.
func custodyRef(stateDB StateDB) [32]byte {
idr, ok := stateDB.(txIdentified)
if !ok {
return [32]byte{}
}
return idr.TxHash()
}
func depositBindKey(stateDB StateDB, caller common.Address, asset Currency, amount *big.Int) (common.Hash, bool) {
return custodyBindKey(stateDB, depBindPrefix, caller, asset, amount)
}
@@ -302,6 +334,37 @@ func storeWithdrawBinding(stateDB StateDB, bindKey common.Hash, realized *big.In
stateDB.SetState(poolManagerAddr, makeStorageKey(wdrBindPrefix, append(bindKey[:], 'f')), flag)
}
// enterCustody acquires the GLOBAL non-reentrant guard for the custody dispatch.
// It returns false if the guard is already held (a reentrant Deposit/Withdraw —
// the caller MUST refuse with ErrCustodyReentrant without moving value); otherwise
// it SETS the guard flag in 0x9010's own state and returns true. The flag is set
// BEFORE any token sub-call (transferFrom/transfer), so a malicious token that
// re-enters the custody entrypoint during its transfer hits the set flag and is
// refused — the double-mint window the post-interaction binding alone cannot close.
//
// The guard lives in the precompile's OWN state (poolManagerAddr), so it is
// visible to a nested call through the SAME StateDB within one EVM execution. It
// is per-execution: enterCustody/exitCustody bracket one top-level Deposit/Withdraw
// call; a fresh execution (the EVM re-runs a tx ~5×) sees a CLEARED flag (exit ran
// at the end of the prior execution), so the guard never interferes with the
// (txHash,asset,amount) replay-idempotency — it only stops NESTED reentry.
func enterCustody(stateDB StateDB) bool {
if stateDB.GetState(poolManagerAddr, custodyGuardKey)[31] == 1 {
return false
}
var on common.Hash
on[31] = 1
stateDB.SetState(poolManagerAddr, custodyGuardKey, on)
return true
}
// exitCustody clears the custody guard. It is deferred immediately after a
// successful enterCustody so the flag is released on EVERY exit path (success or
// any error return), leaving the entrypoint clean for the next genuine custody op.
func exitCustody(stateDB StateDB) {
stateDB.SetState(poolManagerAddr, custodyGuardKey, common.Hash{})
}
// 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
@@ -1375,9 +1438,12 @@ func (pm *PoolManager) releaseNativeFromVault(stateDB StateDB, caller common.Add
// 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.
// ERC-20: there is no msg.value pre-transfer, so lockTokenIntoVault performs the
// transferFrom (the caller granted 0x9010 an allowance) and credits the OBSERVED
// balance delta the vault actually received — never the requested amount, so a
// fee-on-transfer / rebasing token cannot mint an unbacked D-Chain claim. The
// same vault-lock-then-mint shape as native; only the asset-movement primitive
// differs (token balance vs native balance). See erc20_vault.go.
//
// 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
@@ -1392,36 +1458,75 @@ func (pm *PoolManager) Deposit(stateDB StateDB, caller common.Address, asset Cur
if !amount.IsUint64() {
return fmt.Errorf("%w: deposit amount exceeds uint64 ledger range", ErrInvalidAmount)
}
// GLOBAL non-reentrant guard (RED CRIT #1): set BEFORE any token sub-call so a
// malicious ERC-20 reentering Deposit/Withdraw during its transferFrom is
// refused — without it, the reentrant pull lands inside the observed-delta
// window and BOTH calls mint (minted > vault). Cleared on every exit path.
if !enterCustody(stateDB) {
return ErrCustodyReentrant
}
defer exitCustody(stateDB)
custody, ok := pm.engine.(custodyEngine)
if !ok {
return ErrDEXBackendNotConfigured
}
// Bind identity is the originating EVM txHash (the idempotency ref). bound==false
// means no valid EVM tx context (non-txIdentified StateDB or a zero txHash) —
// refuse rather than lock+mint with a zero ref. A zero ref is a full-length 64B
// frame (length checks don't catch it) that would collide on the D-Chain seen:
// key and reopen the replay/drain door; a committed EVM tx always has a unique
// non-zero hash, so this only refuses the non-production zero-hash path (R3).
bindKey, bound := depositBindKey(stateDB, caller, asset, amount)
if !bound {
return ErrCustodyUnbound
}
// 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) {
if 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() {
// 1) LOCK leg (C-Chain). Native: the EVM pre-moved msg.value into 0x9010; verify
// the vault holds it. ERC-20: there is no pre-transfer, so pull the token into
// the vault via transferFrom and MEASURE the OBSERVED delta the vault actually
// received (fee-on-transfer / rebasing tokens deliver less than requested). The
// credited amount is the OBSERVED delta, never the requested amount — crediting
// the requested amount on a short-delivering token would mint an unbacked claim.
credit := amount
if !asset.IsNative() {
vault, ok := stateDB.(erc20Vault)
if !ok {
return ErrERC20VaultUnavailable
}
delta, err := pm.lockTokenIntoVault(stateDB, vault, caller, asset.Address, amount)
if err != nil {
return err
}
credit = delta
} else {
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 {
// 2) MINT leg (D-Chain): credit exactly the LOCKED amount (native: the verified
// msg.value; ERC-20: the observed transfer delta) into available. The EVM
// txHash is threaded as the idempotency ref so this credit is identified
// identically on the D-Chain seen: index — a genuine second deposit (distinct
// tx) credits separately and matches its own vault lock (no strand). asset maps
// INJECTIVELY to its full 32-byte D-Chain id (native -> all-zero, ERC-20 ->
// left-padded token address), so distinct assets are DISTINCT on the ledger
// (cross-asset isolation; no truncation collision).
if err := custody.Deposit(caller, asset, credit.Uint64(), custodyRef(stateDB)); err != nil {
return fmt.Errorf("%w: clob_deposit: %v", ErrSettlementFailed, err)
}
if bound {
storeCustodyBinding(stateDB, bindKey)
}
// bound is guaranteed true here (the !bound case returned ErrCustodyUnbound
// above), so the replay binding is always recorded against the real txHash.
storeCustodyBinding(stateDB, bindKey)
return nil
}
@@ -1432,7 +1537,9 @@ func (pm *PoolManager) Deposit(stateDB StateDB, caller common.Address, asset Cur
// 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.
// ERC-20: releaseTokenFromVault transfers the realized token units from the vault
// to the caller, after a vault-underflow guard (vault holds < realized) — the
// token analog of releaseNativeFromVault's check. See erc20_vault.go.
//
// Returns the realized amount released (0 = nothing available). Idempotency mirrors
// Deposit: bound on (txHash, asset, want) so a replay does not double-burn/release.
@@ -1443,40 +1550,81 @@ func (pm *PoolManager) Withdraw(stateDB StateDB, caller common.Address, asset Cu
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
// GLOBAL non-reentrant guard (RED CRIT #1): set BEFORE the vault release sub-call
// so a malicious ERC-20 reentering Deposit/Withdraw during its transfer is
// refused (a reentrant withdraw could otherwise double-release the vault inside
// the burn/release window). Cleared on every exit path.
if !enterCustody(stateDB) {
return big.NewInt(0), ErrCustodyReentrant
}
defer exitCustody(stateDB)
custody, ok := pm.engine.(custodyEngine)
if !ok {
return big.NewInt(0), ErrDEXBackendNotConfigured
}
// ERC-20 withdraws release the token from the vault; resolve the vault capability
// up front so a StateDB without it refuses BEFORE burning the D-Chain ledger (a
// burn with no possible release would strand the caller's claim).
var vault erc20Vault
if !asset.IsNative() {
v, ok := stateDB.(erc20Vault)
if !ok {
return big.NewInt(0), ErrERC20VaultUnavailable
}
vault = v
}
// Bind identity is the originating EVM txHash (the idempotency ref). bound==false
// means no valid EVM tx context (non-txIdentified StateDB or a zero txHash) —
// refuse rather than burn the ledger and release the vault against a zero ref
// that would collide on the D-Chain seen: key (the drain class). A committed EVM
// tx always has a unique non-zero hash, so only the non-production zero-hash path
// is refused (R3); no value is burned and nothing is released from the vault.
bindKey, bound := withdrawBindKey(stateDB, caller, asset, want)
if !bound {
return big.NewInt(0), ErrCustodyUnbound
}
// 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
}
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())
// 1) BURN leg (D-Chain): debit realized (clamped) from available. The EVM
// txHash is threaded as the idempotency ref so this burn is identified
// identically on the D-Chain seen: index. THE FIX: a genuine second withdraw
// (distinct tx -> distinct ref) is distinct on the D-Chain, so it re-clamps to
// CURRENT available (0 after the first) and returns realized 0 -> the release
// leg below pays nothing. A content-identical replay no longer collides on
// seen: to return the first realized amount against an already-paid vault.
realizedU64, err := custody.Withdraw(caller, asset, want.Uint64(), custodyRef(stateDB))
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.
// Native: move realized wei from 0x9010 back to the caller. ERC-20: transfer
// realized token units from the vault to the caller, after a vault-underflow
// guard (the token analog of releaseNativeFromVault's check). Both refuse to
// release more than the vault holds — the vault can never mint value.
if realizedU64 > 0 {
if rerr := pm.releaseNativeFromVault(stateDB, caller, realized); rerr != nil {
return big.NewInt(0), rerr
if asset.IsNative() {
if rerr := pm.releaseNativeFromVault(stateDB, caller, realized); rerr != nil {
return big.NewInt(0), rerr
}
} else {
if rerr := pm.releaseTokenFromVault(stateDB, vault, caller, asset.Address, realized); rerr != nil {
return big.NewInt(0), rerr
}
}
}
if bound {
storeWithdrawBinding(stateDB, bindKey, realized)
}
// bound is guaranteed true here (the !bound case returned ErrCustodyUnbound
// above), so the realized amount is always recorded against the real txHash.
storeWithdrawBinding(stateDB, bindKey, realized)
return realized, nil
}
+396
View File
@@ -0,0 +1,396 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"context"
"encoding/binary"
"errors"
"math/big"
"os/exec"
"strings"
"testing"
"time"
"github.com/luxfi/geth/common"
)
// route_not_simulate_test.go is the executable enforcement of the CTO mesh rule:
//
// "0x9010 must NEVER locally simulate D — it routes to ZAP/D and REVERTS if
// they are unavailable; never fake a result."
//
// The LP-9010 precompile has EXACTLY TWO states and NO third:
//
// - INERT (default, engine_inert.go): no backend wired. Every value-moving op
// reverts ErrDEXBackendNotConfigured and returns a zero delta. It is NOT a
// poolRouter / custodyEngine / cancelAuthority — it CANNOT hold a book, a
// ledger, or an order map, so an embedded matcher is not even representable.
// - ZAP -> D-Chain (engine_zap.go): the stateless adapter that forwards every
// op to the d-chain CLOB over ZAP. When the venue is UNREACHABLE the adapter
// surfaces the dial/transport error; the PoolManager returns it verbatim; the
// Run() entrypoint returns it; the EVM reverts the call (RevertToSnapshot).
//
// There is NO embedded engine, NO AMM reserve-math fallback, NO local quote, and
// NO synthesized success on a dead venue. These tests pin all four facets:
// 1. TestPrecompileZapOnlyNoEmbeddedFallback — no local fill/match/quote path.
// 2. TestPrecompileZapOnlyConfiguredPath — a configured ZAP routes to D.
// 3. TestPrecompile0x9010RevertsWhenVenueUnavailable — venue down => REVERT, no
// fabricated/stale/simulated result (the critical safety property).
// 4. TestPrecompileLinksNoEmbeddedMatcher — source-guard: the precompile
// package links NO matching engine / GPU / venue-internal matcher.
// =========================================================================
// (3-seam) withUnreachableVenue installs a zapDialer that ALWAYS fails, exactly
// as a connection-refused / venue-down dial would. Restores the canonical dialer
// after the test. Mirrors withFakeCLOB's seam discipline (one way to stub ZAP).
// =========================================================================
// errVenueDown is the dial error a down venue surfaces. The assertions check the
// precompile's returned error WRAPS this — i.e. the revert reason is "the venue
// could not be reached", never a fabricated trade outcome.
var errVenueDown = errors.New("dial tcp 127.0.0.1:9100: connect: connection refused")
func withUnreachableVenue(t *testing.T) {
t.Helper()
orig := zapDialer
zapDialer = func(_ context.Context, _ string) (zapConn, error) { return nil, errVenueDown }
t.Cleanup(func() { zapDialer = orig })
}
// =========================================================================
// (1) No embedded fallback — the precompile holds no second matcher.
// =========================================================================
// TestPrecompileZapOnlyNoEmbeddedFallback proves there is NO code path where
// 0x9010 computes a swap / fill / match / quote LOCALLY. It pins the structural
// property at the root: the package DEFAULT engine (inert) is the only engine a
// chain runs until the venue installs ZAP, and the inert engine
//
// - reverts EVERY value-moving op with the backend sentinel and a ZERO delta
// (no fabricated value), AND
// - implements NONE of poolRouter / custodyEngine / cancelAuthority — the three
// seams a stateful/embedded backend would need to hold a pool, a ledger, or a
// resting-order map. An engine that holds no canonical state and exposes no
// routing/custody/cancel seam CANNOT be a matcher; it can only refuse.
//
// It then drives a fully-liquid pool committed straight to StateDB through the
// consolidated quote path and asserts ZERO output — there is no surviving
// embedded AMM that would price that liquidity. One matcher exists in the tree
// (the d-chain over ZAP); the precompile is a pure ABI shim.
func TestPrecompileZapOnlyNoEmbeddedFallback(t *testing.T) {
pm := NewPoolManager(newInertEngine())
stateDB := NewMockStateDB()
key := PoolKey{
Currency0: Currency{Address: testTokenA},
Currency1: Currency{Address: testTokenB},
Fee: Fee030,
TickSpacing: TickSpacing030,
}
// Structural + behavioral: the default engine is the inert sentinel — no
// stateful seam (poolRouter/custodyEngine/cancelAuthority) and every value op
// refuses with the backend sentinel + zero delta. (Shared definition: one place
// asserts "this engine is the inert sentinel" — see assertInertEngineSentinel.)
assertInertEngineSentinel(t, pm.engine)
// No local-quote path: a fully-liquid pool committed straight to StateDB still
// quotes ZERO through the consolidated path and the raw engine — no surviving
// embedded AMM prices that liquidity.
assertNoLocalQuotePath(t, pm, stateDB, key)
}
// =========================================================================
// (2) Configured path — a wired ZAP routes to the venue.
// =========================================================================
// TestPrecompileZapOnlyConfiguredPath proves that with a ZAP endpoint configured,
// 0x9010 ROUTES the swap to the venue and derives its delta from the SERVER's
// fills — not from any local computation. It seeds a resting ask on the fake
// CLOB (the d-chain stand-in), runs a marketable buy through the full PoolManager,
// and asserts (a) the venue actually received the submit, and (b) the returned
// delta is the server fill's value (which only the book could produce).
func TestPrecompileZapOnlyConfiguredPath(t *testing.T) {
f := newFakeCLOB()
withFakeCLOB(t, f)
zap := NewZAPEngine("fake:0", 2*time.Second)
defer zap.Close()
pm := NewPoolManager(zap)
stateDB := NewMockStateDB()
key := conservationPoolKey()
lp := common.HexToAddress("0x2222222222222222222222222222222222222222")
taker := common.HexToAddress("0x1111111111111111111111111111111111111111")
if _, err := pm.Initialize(stateDB, key, new(big.Int).Set(Q96), nil); err != nil {
t.Fatalf("Initialize (routes ensure-market to venue): %v", err)
}
askTick := tickForPriceLocal(t, 2.0)
askPrice := priceForTickLocal(t, askTick)
if _, _, err := pm.ModifyLiquidity(stateDB, lp, key, ModifyLiquidityParams{
TickLower: askTick,
TickUpper: askTick + TickSpacing030,
LiquidityDelta: big.NewInt(100),
}, nil); err != nil {
t.Fatalf("ModifyLiquidity (rests order on venue): %v", err)
}
submitsBefore := f.submits
const takeBase = int64(40)
delta, err := pm.Swap(stateDB, taker, key, SwapParams{
ZeroForOne: false,
AmountSpecified: big.NewInt(-takeBase),
SqrtPriceLimitX96: MaxSqrtRatio,
}, nil)
if err != nil {
t.Fatalf("Swap (must route to venue): %v", err)
}
// (a) The venue received the marketable submit — the swap was ROUTED, not
// computed in-precompile.
if f.submits <= submitsBefore {
t.Fatalf("swap did not reach the venue: fake CLOB submit count unchanged (%d)", f.submits)
}
// (b) The delta is the SERVER fill's value: taker receives base, owes quote at
// the resting ask's price. A local simulator could not know the venue's resting
// order.
if delta.Amount0.Cmp(big.NewInt(-takeBase)) != 0 {
t.Fatalf("routed base delta = %s, want -%d (server fill)", delta.Amount0, takeBase)
}
wantQuote := ceilQuote(askPrice * float64(takeBase))
if delta.Amount1.Cmp(wantQuote) != 0 {
t.Fatalf("routed quote delta = %s, want %s (server fill)", delta.Amount1, wantQuote)
}
}
// =========================================================================
// (3) THE CRITICAL SAFETY PROPERTY — venue unavailable => REVERT, never fake.
// =========================================================================
// TestPrecompile0x9010RevertsWhenVenueUnavailable is the heart of the mesh rule.
// With a ZAP backend CONFIGURED but the venue UNREACHABLE (every dial fails),
// every value-moving precompile operation must REVERT — return a non-nil error
// and a ZERO delta — and must NEVER fabricate a success, a stale value, or a
// locally-simulated result.
//
// It proves the property at BOTH boundaries:
//
// A) the PoolManager engine boundary (the value the Run() entrypoint returns
// verbatim — see runSwap/runDeposit/runWithdraw which return the PM error
// unchanged), for Initialize / Swap / ModifyLiquidity / Deposit / Withdraw;
// B) the EVM Run() entrypoint itself, for a swap on an ALREADY-INITIALIZED pool
// whose venue then goes DOWN — proving the error reaches the EVM (which then
// reverts the call via RevertToSnapshot) rather than committing a fake delta.
//
// Critically, the down-venue error is NOT ErrDEXBackendNotConfigured (a backend
// IS wired) — it is the dial failure. So "no backend" and "backend down" are
// distinct conditions that BOTH revert and NEITHER simulates.
func TestPrecompile0x9010RevertsWhenVenueUnavailable(t *testing.T) {
// ---- A) Engine/PoolManager boundary: every op reverts on a dead venue. ----
t.Run("PoolManagerBoundary", func(t *testing.T) {
withUnreachableVenue(t)
zap := NewZAPEngine("127.0.0.1:9100", 500*time.Millisecond)
defer zap.Close()
pm := NewPoolManager(zap)
stateDB := NewMockStateDB()
key := conservationPoolKey()
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
native := NativeCurrency
// Initialize: the venue is down, so the market cannot be created — REVERT,
// no fake tick/pool. (A fabricated pool here would let a later op self-match.)
if _, err := pm.Initialize(stateDB, key, new(big.Int).Set(Q96), nil); err == nil {
t.Fatal("Initialize on a down venue must REVERT (cannot create a market), got nil error")
} else if errors.Is(err, ErrDEXBackendNotConfigured) {
t.Fatalf("Initialize err = %v; a configured-but-down venue must NOT report 'no backend'", err)
}
// Because Initialize reverted, the pool was never created. Even so, drive
// the ZAP engine's value ops DIRECTLY to prove the adapter itself reverts on
// a dead dial and fabricates NOTHING — the no-simulate property at the
// engine, independent of the not-initialized gate.
ps := &PoolState{}
zap.SetPoolID(ps, key.ID()) // route is set, but the dial will still fail
if d, err := zap.Swap(ps, caller, SwapParams{
ZeroForOne: false,
AmountSpecified: big.NewInt(-40),
SqrtPriceLimitX96: MaxSqrtRatio,
}); err == nil || !d.IsZero() {
t.Fatalf("ZAP Swap on down venue = (%v,%v); want (zero delta, dial error) — NEVER a fabricated fill", d, err)
} else if errors.Is(err, ErrDEXBackendNotConfigured) {
t.Fatalf("ZAP Swap down-venue err = %v; must be the dial failure, not 'no backend'", err)
}
if cd, fd, err := zap.ModifyLiquidity(ps, caller, ModifyLiquidityParams{
TickLower: tickForPriceLocal(t, 2.0),
TickUpper: tickForPriceLocal(t, 2.0) + TickSpacing030,
LiquidityDelta: big.NewInt(100),
}); err == nil || !cd.IsZero() || !fd.IsZero() {
t.Fatalf("ZAP ModifyLiquidity on down venue = (%v,%v,%v); want (zero,zero,dial error)", cd, fd, err)
}
// Custody ops: Deposit must revert AFTER no D-Chain mint can be confirmed —
// it must not credit a balance the venue never recorded. Withdraw must revert
// rather than release vault funds against an unconfirmed burn.
if err := pm.Deposit(stateDB, caller, native, big.NewInt(1000)); err == nil {
t.Fatal("Deposit on a down venue must REVERT (no D-Chain mint confirmable), got nil")
}
if realized, err := pm.Withdraw(stateDB, caller, native, big.NewInt(1000)); err == nil || realized.Sign() != 0 {
t.Fatalf("Withdraw on a down venue = (%v,%v); want (0, error) — NEVER release against an unconfirmed burn", realized, err)
}
})
// ---- B) EVM Run() boundary: a swap reverts to the caller when the venue,
// previously up (pool initialized), goes DOWN. The error reaching Run() is what
// makes the EVM RevertToSnapshot — no fabricated delta is ever returned to the
// contract. ----
t.Run("EVMRunBoundaryAfterVenueGoesDown", func(t *testing.T) {
f := newFakeCLOB()
// Dialer: serve the fake while "up", then a flip to down. We control it via a
// boolean the closure reads, restoring the canonical dialer on cleanup.
venueUp := true
orig := zapDialer
zapDialer = func(_ context.Context, _ string) (zapConn, error) {
if !venueUp {
return nil, errVenueDown
}
return f.conn(), nil
}
t.Cleanup(func() { zapDialer = orig })
zap := NewZAPEngine("127.0.0.1:9100", 500*time.Millisecond)
defer zap.Close()
c := &DEXContract{poolManager: NewPoolManager(zap)}
state := &mockAccessibleState{stateDB: NewMockStateDB()}
key := conservationPoolKey()
// Initialize while the venue is UP (routes ensure-market to the fake).
if _, err := c.poolManager.Initialize(state.GetStateDB().(*contractStateDBWrapper).inner, key, new(big.Int).Set(Q96), nil); err != nil {
t.Fatalf("setup Initialize (venue up): %v", err)
}
// Venue goes DOWN. The connection the engine cached during Initialize is
// dropped so the next op must re-dial (and fail).
_ = zap.Close()
venueUp = false
// Build a valid V4 swap calldata and drive it through the EVM Run()
// entrypoint. With the venue down, Run() must return a non-nil error (=> the
// EVM reverts) and NOT a packed BalanceDelta success.
calldata := encodeSwapCalldataForTest(key, false, big.NewInt(-40), MaxSqrtRatio)
ret, _, err := c.Run(state, common.Address{0x11}, lxPoolAddr, calldata, 1_000_000, false)
if err == nil {
t.Fatalf("Run(swap) on a down venue returned nil error + ret=%x — the EVM would COMMIT a fabricated result; it MUST revert", ret)
}
if errors.Is(err, ErrDEXBackendNotConfigured) {
t.Fatalf("Run(swap) down-venue err = %v; a configured-but-down venue must surface the dial failure, not 'no backend'", err)
}
// A reverting precompile returns no usable result bytes.
if len(ret) != 0 {
t.Fatalf("Run(swap) on a down venue returned %d result bytes — a revert must yield no fabricated delta", len(ret))
}
})
}
// encodeSwapCalldataForTest builds the V4 swap calldata DecodeSwapInput parses:
//
// selector[4] ‖ PoolKey[160] ‖ zeroForOne[32] ‖ amountSpecified[int256:32] ‖
// sqrtPriceLimitX96[32]
//
// It is the inverse of DecodeSwapInput for the fields the engine path reads. Used
// only to drive Run() at the EVM boundary in the down-venue revert proof.
func encodeSwapCalldataForTest(key PoolKey, zeroForOne bool, amountSpecified, sqrtPriceLimit *big.Int) []byte {
out := make([]byte, 4+256)
binary.BigEndian.PutUint32(out[0:4], SelectorSwap)
copy(out[4:4+160], EncodePoolKeyABI(key))
if zeroForOne {
out[4+191] = 1
}
// amountSpecified as two's-complement int256 (negative = exact input).
copy(out[4+192:4+224], signedTo32BytesForTest(amountSpecified))
sqrtPriceLimit.FillBytes(out[4+224 : 4+256])
return out
}
// signedTo32BytesForTest renders v as a 32-byte big-endian two's-complement word
// (matches decodeSigned256's inverse for the negative exact-input amounts here).
func signedTo32BytesForTest(v *big.Int) []byte {
out := make([]byte, 32)
if v.Sign() >= 0 {
v.FillBytes(out)
return out
}
// two's complement: 2^256 + v
mod := new(big.Int).Lsh(big.NewInt(1), 256)
tc := new(big.Int).Add(mod, v) // v is negative
tc.FillBytes(out)
return out
}
// =========================================================================
// (4) SOURCE-GUARD — the precompile links NO matching engine / GPU / venue code.
// =========================================================================
// TestPrecompileLinksNoEmbeddedMatcher is the linkage proof, mirroring node's
// TestVenueEngineNotLinked: `go list -deps` is the ground-truth dependency graph
// of the compiled artifact, the only honest way to assert what a package links.
// The LP-9010 precompile is a PURE INGRESS ADAPTER — it holds nothing and computes
// no fills — so it MUST NOT link the venue's matching engine, the GPU kernels, the
// private workspace, or the lx order-book matcher. If any appears here, an
// embedded matcher crept back in (the exact regression the inert-default rip-out
// eliminated), and this test fails loudly.
func TestPrecompileLinksNoEmbeddedMatcher(t *testing.T) {
deps := goListDepsForGuard(t, ".")
// Each needle is a package-path substring that would indicate the precompile
// links a matcher / GPU accelerator / venue-internal engine.
forbidden := []struct{ needle, why string }{
{"luxcpp", "C++ GPU bindings (private venue accelerator)"},
{"gpu-kernels", "CUDA/Metal/HIP matcher kernels (private)"},
{"lux-private", "the private workspace"},
{"luxfi/dex", "the d-chain venue matcher module (CLOB engine + cgo/GPU)"},
{"orderbook", "an order-book matching engine"},
{"matcher", "a matching engine"},
}
for _, f := range forbidden {
if n := countDepsContaining(deps, f.needle); n != 0 {
t.Errorf("precompile links %q (%d packages) — %s; LP-9010 must hold NO matcher "+
"(it is a pure ZAP ingress adapter that routes to the d-chain)", f.needle, n, f.why)
}
}
// Positive control: the precompile DOES link the geth EVM types (it is an EVM
// precompile). If this is absent the dep query is broken and the negatives are
// vacuous.
if countDepsContaining(deps, "luxfi/geth") == 0 {
t.Fatal("dep query returned no luxfi/geth — the go list result is broken, negative assertions are vacuous")
}
}
// goListDepsForGuard returns `go list -deps pkg` run from the package directory
// (the precompile is its own buildable package: `go list .` resolves it). It is
// the same ground-truth technique node's vms_purity_test.go uses.
func goListDepsForGuard(t *testing.T, pkg string) []string {
t.Helper()
cmd := exec.Command("go", "list", "-deps", pkg)
cmd.Dir = "." // the dex precompile package directory
cmd.Env = append(cmd.Environ(), "CGO_ENABLED=0")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("go list -deps %s failed: %v\n%s", pkg, err, out)
}
return strings.Split(strings.TrimSpace(string(out)), "\n")
}
func countDepsContaining(deps []string, needle string) int {
n := 0
for _, d := range deps {
if strings.Contains(d, needle) {
n++
}
}
return n
}
+21 -6
View File
@@ -389,6 +389,27 @@ var (
// embedded in the precompile — an unconfigured venue must revert cleanly
// rather than run a wrong, second matcher. See engine_inert.go.
ErrDEXBackendNotConfigured = errors.New("dex: backend not configured (set dex-zap-endpoint to enable LP-9010)")
// ErrCustodyUnbound is returned by Deposit/Withdraw when the StateDB carries no
// originating-tx identity (not txIdentified, or a zero txHash). The txHash is the
// idempotency ref threaded into the clob_deposit/clob_withdraw frame and the
// D-Chain seen: dedup key; with a zero ref two genuinely-distinct custody ops
// collide on (zero-ref‖user‖asset‖amount) and a replay can mint or double-release
// (the vault-drain class). A committed EVM tx ALWAYS has a unique non-zero hash,
// so refusing here closes the zero-ref door (defense-in-depth; prerequisite for
// the ERC-20 rail) without touching any real on-chain custody op. Only the
// non-EVM / zero-hash test path is refused — the production poolStateAdapter
// supplies a real per-tx txHash.
ErrCustodyUnbound = errors.New("dex: custody refused — no originating-tx context (zero idempotency ref)")
// ErrCustodyReentrant is returned when a custody op (Deposit/Withdraw) is
// re-entered while another custody op is already in progress on the same call
// stack — the classic reentrancy a malicious ERC-20 mounts from inside its
// transferFrom/transfer sub-call to double-mint (land a second pull inside the
// observed-delta window). The GLOBAL non-reentrant guard on the custody
// entrypoint reverts the nested call before it can move value, so the vault and
// the D-Chain ledger stay in lockstep (minted == vault).
ErrCustodyReentrant = errors.New("dex: custody reentrancy refused — a deposit/withdraw is already in progress")
)
// Errors - Pause/Freeze Controls (ATS regulatory compliance)
@@ -422,12 +443,6 @@ 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