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