Files
zeekay 687b9086a3 feat(swap): native-LUX HTLC locks are live (no gate, no mint)
The precompile previously gated native (asset == address(0)) behind
ErrNativeUnsupported, claiming it could not read msg.value. That was wrong:
the EVM moves msg.value into the precompile address BEFORE Run, so native is
handled by the exact observed-balance idiom dex/settle_custody.go already
uses. Native is now a first-class asset, fully symmetric to ERC-20:

- lock: delivered = GetBalance(swapAddr) - reserve[native]; require == amount
  exactly (rejects value==0, short, and over-delivery — the native analog of
  the ERC-20 transferFrom-delta in pullExact). Never mints; the value was
  moved by the caller's frame.
- claim/refund: real SubBalance(swapAddr)/AddBalance(to) via the single payOut
  dispatcher, underflow-guarded (SubBalance is uint256-modular from a
  precompile). Conservation balanceOf(swapAddr) == reserve[asset] holds for
  native exactly as for ERC-20.

No interface change, no new capability — uses contract.StateDB's existing
GetBalance/Add/SubBalance. ErrNativeUnsupported removed. LXSwapHTLC.sol doc
corrected (both legs custody native symmetrically). go test -race ./swap/...
green (21 tests incl. native lock/claim/refund + conservation).
2026-06-26 04:38:52 +00:00

295 lines
9.9 KiB
Go

// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package swap
import (
"context"
"encoding/binary"
"math/big"
"github.com/holiman/uint256"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/tracing"
ethtypes "github.com/luxfi/geth/core/types"
"github.com/luxfi/precompile/contract"
"github.com/luxfi/precompile/precompileconfig"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// mockState implements contract.StateDB AND the erc20Vault capability. Token
// balances live in a simple ledger so the conservation invariant can be checked
// against the precompile's own reserve accounting.
// ---------------------------------------------------------------------------
type stateKey struct {
addr common.Address
key common.Hash
}
type mockState struct {
storage map[stateKey]common.Hash
logs []*ethtypes.Log
// token -> owner -> balance
tokens map[common.Address]map[common.Address]*big.Int
// addr -> native LUX balance (the EVM balance ledger)
native map[common.Address]*big.Int
// negative-test knobs:
failTransfer map[common.Address]bool // tokens whose transfer always fails
feeBps map[common.Address]int // fee-on-transfer: bps withheld on transferFrom
reenter func() // invoked once inside the next transferFrom
}
func newMockState() *mockState {
return &mockState{
storage: make(map[stateKey]common.Hash),
tokens: make(map[common.Address]map[common.Address]*big.Int),
native: make(map[common.Address]*big.Int),
failTransfer: make(map[common.Address]bool),
feeBps: make(map[common.Address]int),
}
}
func (m *mockState) bal(token, owner common.Address) *big.Int {
if m.tokens[token] == nil {
return big.NewInt(0)
}
if v := m.tokens[token][owner]; v != nil {
return v
}
return big.NewInt(0)
}
func (m *mockState) setBal(token, owner common.Address, v *big.Int) {
if m.tokens[token] == nil {
m.tokens[token] = make(map[common.Address]*big.Int)
}
m.tokens[token][owner] = v
}
func (m *mockState) fund(token, owner common.Address, amt *big.Int) {
m.setBal(token, owner, new(big.Int).Add(m.bal(token, owner), amt))
}
// --- native LUX ledger ---
func (m *mockState) nativeBal(a common.Address) *big.Int {
if v := m.native[a]; v != nil {
return v
}
return big.NewInt(0)
}
func (m *mockState) setNative(a common.Address, v *big.Int) { m.native[a] = v }
// fundNative simulates the EVM moving msg.value into `a` BEFORE Run — the seam a
// native lock observes as its inbound delta.
func (m *mockState) fundNative(a common.Address, amt *big.Int) {
m.setNative(a, new(big.Int).Add(m.nativeBal(a), amt))
}
// --- erc20Vault capability ---
func (m *mockState) TokenBalanceOf(token, owner common.Address) *big.Int {
return new(big.Int).Set(m.bal(token, owner))
}
func (m *mockState) TransferTokenFrom(token, from, to common.Address, amount *big.Int) error {
if m.failTransfer[token] {
return errTokenReverted
}
if m.bal(token, from).Cmp(amount) < 0 {
return errTokenReverted
}
if r := m.reenter; r != nil {
m.reenter = nil // fire exactly once
r()
}
m.setBal(token, from, new(big.Int).Sub(m.bal(token, from), amount))
credit := amount
if bps := m.feeBps[token]; bps > 0 { // fee-on-transfer: deliver less than `amount`
fee := new(big.Int).Div(new(big.Int).Mul(amount, big.NewInt(int64(bps))), big.NewInt(10000))
credit = new(big.Int).Sub(amount, fee)
}
m.setBal(token, to, new(big.Int).Add(m.bal(token, to), credit))
return nil
}
func (m *mockState) TransferTokenTo(token, to common.Address, amount *big.Int) error {
if m.failTransfer[token] {
return errTokenReverted
}
if m.bal(token, swapAddr).Cmp(amount) < 0 {
return errTokenReverted
}
m.setBal(token, swapAddr, new(big.Int).Sub(m.bal(token, swapAddr), amount))
m.setBal(token, to, new(big.Int).Add(m.bal(token, to), amount))
return nil
}
// --- contract.StateDB ---
func (m *mockState) GetState(addr common.Address, key common.Hash) common.Hash {
return m.storage[stateKey{addr, key}]
}
func (m *mockState) SetState(addr common.Address, key, value common.Hash) common.Hash {
k := stateKey{addr, key}
prev := m.storage[k]
m.storage[k] = value
return prev
}
func (m *mockState) AddLog(l *ethtypes.Log) { m.logs = append(m.logs, l) }
func (m *mockState) Logs() []*ethtypes.Log { return m.logs }
func (m *mockState) TxHash() common.Hash { return common.Hash{} }
func (m *mockState) Snapshot() int { return 0 }
func (m *mockState) RevertToSnapshot(int) {}
func (m *mockState) CreateAccount(common.Address) {}
func (m *mockState) Exist(common.Address) bool { return true }
func (m *mockState) GetNonce(common.Address) uint64 { return 0 }
func (m *mockState) SetNonce(common.Address, uint64, tracing.NonceChangeReason) {}
func (m *mockState) GetBalance(a common.Address) *uint256.Int {
return uint256.MustFromBig(m.nativeBal(a))
}
func (m *mockState) AddBalance(a common.Address, amt *uint256.Int, _ tracing.BalanceChangeReason) uint256.Int {
prior := m.nativeBal(a)
m.setNative(a, new(big.Int).Add(prior, amt.ToBig()))
return *uint256.MustFromBig(prior)
}
func (m *mockState) SubBalance(a common.Address, amt *uint256.Int, _ tracing.BalanceChangeReason) uint256.Int {
prior := m.nativeBal(a)
m.setNative(a, new(big.Int).Sub(prior, amt.ToBig()))
return *uint256.MustFromBig(prior)
}
func (m *mockState) GetBalanceMultiCoin(common.Address, common.Hash) *big.Int { return big.NewInt(0) }
func (m *mockState) AddBalanceMultiCoin(common.Address, common.Hash, *big.Int) {}
func (m *mockState) SubBalanceMultiCoin(common.Address, common.Hash, *big.Int) {}
func (m *mockState) GetPredicateStorageSlots(common.Address, int) ([]byte, bool) {
return nil, false
}
// ---------------------------------------------------------------------------
// mock AccessibleState + BlockContext with a settable clock.
// ---------------------------------------------------------------------------
type mockBlock struct{ ts uint64 }
func (b *mockBlock) Number() *big.Int { return big.NewInt(1) }
func (b *mockBlock) Timestamp() uint64 { return b.ts }
func (b *mockBlock) GetPredicateResults(common.Hash, common.Address) []byte { return nil }
type mockAccessible struct {
db *mockState
blk *mockBlock
}
func (a *mockAccessible) GetStateDB() contract.StateDB { return a.db }
func (a *mockAccessible) GetBlockContext() contract.BlockContext { return a.blk }
func (a *mockAccessible) GetConsensusContext() context.Context { return context.Background() }
func (a *mockAccessible) GetChainConfig() precompileconfig.ChainConfig { return nil }
func (a *mockAccessible) GetPrecompileEnv() contract.PrecompileEnvironment { return nil }
// ---------------------------------------------------------------------------
// env: a precompile + state + clock bundle with typed call helpers.
// ---------------------------------------------------------------------------
type env struct {
c *SwapContract
st *mockAccessible
gas uint64
}
// eqBig asserts two *big.Int values are numerically equal (Cmp == 0), avoiding
// the nil-vs-empty `abs` pitfall of reflect-based require.Equal on big.Int.
func eqBig(t require.TestingT, want, got *big.Int) {
require.Truef(t, want.Cmp(got) == 0, "want %s, got %s", want, got)
}
func newEnv(now uint64) *env {
return &env{
c: &SwapContract{},
st: &mockAccessible{db: newMockState(), blk: &mockBlock{ts: now}},
gas: 10_000_000,
}
}
func (e *env) now() uint64 { return e.st.blk.ts }
func (e *env) setNow(ts uint64) { e.st.blk.ts = ts }
func (e *env) db() *mockState { return e.st.db }
func (e *env) reserve(a common.Address) *big.Int { return loadReserve(e.db(), a) }
func selBytes(sel uint32) []byte {
b := make([]byte, 4)
binary.BigEndian.PutUint32(b, sel)
return b
}
func leftPad(b []byte) []byte {
w := make([]byte, 32)
copy(w[32-len(b):], b)
return w
}
func addrArg(a common.Address) []byte { return leftPad(a.Bytes()) }
func amountArg(v *big.Int) []byte { return leftPad(v.Bytes()) }
func u64Arg(v uint64) []byte {
b := make([]byte, 8)
binary.BigEndian.PutUint64(b, v)
return leftPad(b)
}
// lock encodes and runs a lock call, returning the swapId and result.
func (e *env) lock(caller common.Address, hashlock common.Hash, recipient, refund, asset common.Address, amount *big.Int, timeout uint64, readOnly bool) (common.Hash, []byte, error) {
in := selBytes(selLock)
in = append(in, hashlock[:]...)
in = append(in, addrArg(recipient)...)
in = append(in, addrArg(refund)...)
in = append(in, addrArg(asset)...)
in = append(in, amountArg(amount)...)
in = append(in, u64Arg(timeout)...)
ret, _, err := e.c.Run(e.st, caller, swapAddr, in, e.gas, readOnly)
var id common.Hash
if err == nil {
copy(id[:], ret)
}
return id, ret, err
}
func (e *env) claim(caller common.Address, swapId common.Hash, preimage [32]byte, readOnly bool) ([]byte, error) {
in := selBytes(selClaim)
in = append(in, swapId[:]...)
in = append(in, preimage[:]...)
ret, _, err := e.c.Run(e.st, caller, swapAddr, in, e.gas, readOnly)
return ret, err
}
func (e *env) refund(caller common.Address, swapId common.Hash, readOnly bool) ([]byte, error) {
in := selBytes(selRefund)
in = append(in, swapId[:]...)
ret, _, err := e.c.Run(e.st, caller, swapAddr, in, e.gas, readOnly)
return ret, err
}
func (e *env) getSwap(swapId common.Hash, readOnly bool) ([]byte, error) {
in := append(selBytes(selGetSwap), swapId[:]...)
ret, _, err := e.c.Run(e.st, common.Address{}, swapAddr, in, e.gas, readOnly)
return ret, err
}
func (e *env) getPreimage(hashlock common.Hash, readOnly bool) ([32]byte, error) {
in := append(selBytes(selGetPreimage), hashlock[:]...)
ret, _, err := e.c.Run(e.st, common.Address{}, swapAddr, in, e.gas, readOnly)
var p [32]byte
if err == nil {
copy(p[:], ret)
}
return p, err
}