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).
This commit is contained in:
2026-06-26 04:38:52 +00:00
parent 81bbd139f2
commit 687b9086a3
5 changed files with 195 additions and 47 deletions
+6 -4
View File
@@ -28,10 +28,12 @@ import {IERC20Minimal} from "../dex/IERC20Minimal.sol";
/// - state changes happen BEFORE the external pay-out (effects-before-
/// interaction) under a non-reentrant guard.
///
/// Unlike the precompile, this contract CAN custody the chain-native asset
/// (`asset == address(0)`, amount delivered as `msg.value`) — a Solidity
/// contract has the call-value seam the precompile still lacks
/// (`ErrNativeUnsupported`).
/// Both legs custody the chain-native asset (`asset == address(0)`)
/// symmetrically: here the amount is delivered as `msg.value`; on the
/// precompile the EVM moves the value into the precompile address before Run
/// and the lock measures it as an observed balance delta (the native analog
/// of the ERC-20 transferFrom delta). Native and ERC-20 behave identically on
/// both sides — no leg mints.
contract LXSwapHTLC {
// --- types ---------------------------------------------------------------
+33 -5
View File
@@ -33,6 +33,8 @@ type mockState struct {
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
@@ -44,6 +46,7 @@ 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),
}
@@ -70,6 +73,23 @@ 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 {
@@ -133,12 +153,20 @@ func (m *mockState) GetNonce(common.Address) uint64 { return 0 }
func (m *mockState) SetNonce(common.Address, uint64, tracing.NonceChangeReason) {}
func (m *mockState) GetBalance(common.Address) *uint256.Int { return uint256.NewInt(0) }
func (m *mockState) AddBalance(common.Address, *uint256.Int, tracing.BalanceChangeReason) uint256.Int {
return uint256.Int{}
func (m *mockState) GetBalance(a common.Address) *uint256.Int {
return uint256.MustFromBig(m.nativeBal(a))
}
func (m *mockState) SubBalance(common.Address, *uint256.Int, tracing.BalanceChangeReason) uint256.Int {
return uint256.Int{}
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) {}
+34 -30
View File
@@ -8,8 +8,12 @@
// address fixed at lock time (after the timeout). There is no owner, no pause, no
// freeze, no upgrade, and no set-balance selector — non-custody (G1) and
// strand-resistance (G4) are STRUCTURAL, not policy. No code path mints: every
// unit paid out was locked by its owner via a real ERC-20 transferFrom whose
// unit paid out was locked by its owner — an ERC-20 via a real transferFrom, or
// native LUX via the msg.value the EVM moved into swapAddr before Run — whose
// OBSERVED inbound delta backs the per-asset reserve ledger (Invariant: reserve).
// Native is a first-class asset (asset == address(0)): its inbound delta is read
// from the real balance (balanceOf(swapAddr) − reserve[native]) and its pay-out is
// a real SubBalance/AddBalance pair, exactly symmetric to the ERC-20 path.
//
// Hashlock is SHA-256 (matching Bitcoin OP_SHA256 and the EVM 0x02 precompile),
// NOT keccak — a single 32-byte preimage unlocks both legs of a cross-chain swap.
@@ -116,7 +120,6 @@ var (
ErrDustAmount = errors.New("swap: amount below MinSwapAmount")
ErrTimeoutBounds = errors.New("swap: timeout outside [T0+MinTimeout, T0+MaxTimeout]")
ErrSwapExists = errors.New("swap: swapId already in use")
ErrNativeUnsupported = errors.New("swap: native-LUX locks unsupported until the call-value seam lands; use a wrapped/ERC-20 asset")
ErrNotLocked = errors.New("swap: swap is not in the Locked state")
ErrExpired = errors.New("swap: timeout passed; claim window closed (refund instead)")
@@ -234,10 +237,6 @@ func (c *SwapContract) runLock(
if timeout < t0+MinTimeout || timeout > t0+MaxTimeout {
return nil, gas, fmt.Errorf("%w: T0=%d timeout=%d", ErrTimeoutBounds, t0, timeout)
}
// Native is gated until the call-value seam lands — NEVER minted.
if asset == (common.Address{}) {
return nil, gas, ErrNativeUnsupported
}
db := st.GetStateDB()
@@ -252,17 +251,29 @@ func (c *SwapContract) runLock(
return nil, gas, ErrSwapExists
}
// (5) Move funds IN via the transferFrom-delta idiom and REQUIRE the observed
// inbound delta to equal the requested amount exactly: an HTLC must lock the
// precise agreed amount the counterparty verified, so a fee-on-transfer /
// short-delivering token is rejected rather than silently locking less.
vault, ok := db.(erc20Vault)
if !ok {
return nil, gas, ErrVaultUnavailable
}
delta, err := pullExact(vault, asset, caller, swapAddr, amount)
if err != nil {
return nil, gas, err
// (5) Move funds IN via the OBSERVED-delta idiom and REQUIRE the inbound delta
// to equal the requested amount exactly: an HTLC must lock the precise agreed
// amount the counterparty verified, so a fee-on-transfer / short-delivering
// token — or a native call carrying the wrong value — is rejected rather than
// silently locking less. Native (asset == 0) measures the value the EVM already
// moved into swapAddr (delta = balanceOf − reserve); an ERC-20 pulls via
// transferFrom. Both yield `delta`, the amount actually delivered.
var delta *big.Int
if asset == (common.Address{}) {
if err := pullExactNative(db, loadReserve(db, asset), amount); err != nil {
return nil, gas, err
}
delta = amount
} else {
vault, ok := db.(erc20Vault)
if !ok {
return nil, gas, ErrVaultUnavailable
}
d, err := pullExact(vault, asset, caller, swapAddr, amount)
if err != nil {
return nil, gas, err
}
delta = d
}
// (6, second half) persist the swap, bump the per-asset reserve by the OBSERVED
@@ -341,13 +352,10 @@ func (c *SwapContract) runClaim(
emitClaimed(db, swapId, hashlock, preimage, recipient, amount)
// INTERACTION: pay the STORED recipient (caller-agnostic — a watchtower may
// submit). On failure the precompile returns an error and the EVM reverts the
// whole frame, including every effect above.
vault, ok := db.(erc20Vault)
if !ok {
return nil, gas, ErrVaultUnavailable
}
if err := pushOut(vault, asset, recipient, amount); err != nil {
// submit) via the single asset-kind-agnostic pay-out dispatcher. On failure the
// precompile returns an error and the EVM reverts the whole frame, including
// every effect above.
if err := payOut(db, asset, recipient, amount); err != nil {
return nil, gas, err
}
@@ -398,12 +406,8 @@ func (c *SwapContract) runRefund(
storeStatus(db, swapId, StatusRefunded)
emitRefunded(db, swapId, refund, amount)
// INTERACTION: pay the STORED refund address.
vault, ok := db.(erc20Vault)
if !ok {
return nil, gas, ErrVaultUnavailable
}
if err := pushOut(vault, asset, refund, amount); err != nil {
// INTERACTION: pay the STORED refund address via the single pay-out dispatcher.
if err := payOut(db, asset, refund, amount); err != nil {
return nil, gas, err
}
+65 -8
View File
@@ -257,7 +257,6 @@ func TestLockValidation(t *testing.T) {
{"dust", h, user, maker, usdc, big.NewInt(0), timeout, ErrDustAmount},
{"timeout_too_soon", h, user, maker, usdc, amt, t0 + MinTimeout - 1, ErrTimeoutBounds},
{"timeout_too_late", h, user, maker, usdc, amt, t0 + MaxTimeout + 1, ErrTimeoutBounds},
{"native_gated", h, user, maker, common.Address{}, amt, timeout, ErrNativeUnsupported},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -284,16 +283,74 @@ func TestClaimRefundNonexistent(t *testing.T) {
}
// ---------------------------------------------------------------------------
// Native (asset == 0) is gated, NOT minted: no reserve, no vault balance.
// Native (asset == address(0)) is a first-class asset, symmetric to ERC-20: its
// inbound delta is the value the EVM moved into swapAddr before Run (observed,
// never minted), and pay-out is a real SubBalance/AddBalance pair.
// ---------------------------------------------------------------------------
func TestNativeLockNotMinted(t *testing.T) {
var native = common.Address{} // the native-LUX asset id
func TestNativeLockThenClaim(t *testing.T) {
e := newEnv(t0)
h := hashOf(preimageOf(0x77))
_, _, err := e.lock(maker, h, user, maker, common.Address{}, big.NewInt(1e9), timeout, false)
require.ErrorIs(t, err, ErrNativeUnsupported)
eqBig(t, big.NewInt(0), e.reserve(common.Address{}))
eqBig(t, big.NewInt(0), e.db().bal(common.Address{}, swapAddr))
amt := big.NewInt(1_000_000_000)
pre := preimageOf(0x77)
h := hashOf(pre)
// The EVM moved msg.value into swapAddr before Run.
e.db().fundNative(swapAddr, amt)
id, _, err := e.lock(maker, h, user, maker, native, amt, timeout, false)
require.NoError(t, err)
require.NotEqual(t, common.Hash{}, id)
// Value sits in the vault, reserve credited by the observed delta.
eqBig(t, amt, e.db().nativeBal(swapAddr))
eqBig(t, amt, e.reserve(native))
require.Equal(t, StatusLocked, loadStatus(e.db(), id))
// A watchtower (not the recipient) submits the claim — caller-agnostic.
ret, err := e.claim(watcher, id, pre, false)
require.NoError(t, err)
require.Equal(t, boolWord(true), ret)
// Recipient paid exactly amount; vault drained; reserve zeroed.
eqBig(t, amt, e.db().nativeBal(user))
eqBig(t, big.NewInt(0), e.db().nativeBal(swapAddr))
eqBig(t, big.NewInt(0), e.reserve(native))
require.Equal(t, StatusClaimed, loadStatus(e.db(), id))
}
func TestNativeLockThenRefundAfterTimeout(t *testing.T) {
e := newEnv(t0)
amt := big.NewInt(500)
h := hashOf(preimageOf(0x78))
e.db().fundNative(swapAddr, amt)
id, _, err := e.lock(maker, h, user, maker, native, amt, timeout, false)
require.NoError(t, err)
// At/after timeout, refund pays the stored refund address (maker).
e.setNow(timeout)
ret, err := e.refund(watcher, id, false)
require.NoError(t, err)
require.Equal(t, boolWord(true), ret)
eqBig(t, amt, e.db().nativeBal(maker))
eqBig(t, big.NewInt(0), e.db().nativeBal(swapAddr))
eqBig(t, big.NewInt(0), e.reserve(native))
require.Equal(t, StatusRefunded, loadStatus(e.db(), id))
}
func TestNativeShortDeliveryRejected(t *testing.T) {
e := newEnv(t0)
amt := big.NewInt(1_000)
h := hashOf(preimageOf(0x79))
// EVM delivered one less than the lock requests — exact-delta rejects it (the
// same holds for value==0 or an over-delivering call: delivered != amount).
e.db().fundNative(swapAddr, new(big.Int).Sub(amt, big.NewInt(1)))
_, _, err := e.lock(maker, h, user, maker, native, amt, timeout, false)
require.ErrorIs(t, err, ErrDeltaMismatch)
eqBig(t, big.NewInt(0), e.reserve(native))
}
// ---------------------------------------------------------------------------
+57
View File
@@ -7,7 +7,10 @@ import (
"fmt"
"math/big"
"github.com/holiman/uint256"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/tracing"
"github.com/luxfi/precompile/contract"
)
// erc20Vault is the OPTIONAL capability the host StateDB implements to move ERC-20
@@ -57,3 +60,57 @@ func pushOut(vault erc20Vault, asset, to common.Address, amount *big.Int) error
}
return nil
}
// pullExactNative is the native-LUX analog of pullExact: it measures the value the
// current call actually delivered and REQUIRES it to equal `amount` exactly. The
// EVM moved msg.value into swapAddr BEFORE Run (geth Transfer precedes precompile
// execution), and every native-moving path keeps reserve[native] in lock-step with
// the vault's real native balance, so
//
// delivered = balanceOf(swapAddr) − reserveNative
//
// is exactly the value this call carried — the surplus over the accounted reserve.
// A value==0, short, OR over-delivering call yields delivered != amount and is
// rejected (ErrDeltaMismatch), the same exact-delta safety pullExact gives ERC-20.
// It never mints: delivered is read from real balance, never from the request.
func pullExactNative(db contract.StateDB, reserveNative, amount *big.Int) error {
delivered := new(big.Int).Sub(db.GetBalance(swapAddr).ToBig(), reserveNative)
if delivered.Cmp(amount) != 0 {
return fmt.Errorf("%w: requested %s, delivered %s", ErrDeltaMismatch, amount, delivered)
}
return nil
}
// pushOutNative pays exactly `amount` of native LUX from the vault to `to` via a
// real SubBalance/AddBalance pair (no mint, value-conserving). SubBalance is
// uint256-modular and does NOT revert on underflow from a precompile, so an
// explicit guard fails loud (ErrReserveUnderflow) rather than wrapping swapAddr's
// native balance — the caller already debited reserve[native] (CEI), so on error
// the EVM reverts the whole frame.
func pushOutNative(db contract.StateDB, to common.Address, amount *big.Int) error {
amt, overflow := uint256.FromBig(amount)
if overflow {
return ErrTransferFailed
}
if db.GetBalance(swapAddr).Cmp(amt) < 0 {
return ErrReserveUnderflow
}
db.SubBalance(swapAddr, amt, tracing.BalanceChangeUnspecified)
db.AddBalance(to, amt, tracing.BalanceChangeUnspecified)
return nil
}
// payOut is the single pay-out dispatcher used by BOTH claim and refund: native
// (asset == address(0)) settles via Sub/Add on the EVM balance ledger; an ERC-20
// asset settles via the erc20Vault capability (refused cleanly if absent). One
// function, one place — claim and refund stay identical across both asset kinds.
func payOut(db contract.StateDB, asset, to common.Address, amount *big.Int) error {
if asset == (common.Address{}) {
return pushOutNative(db, to, amount)
}
vault, ok := db.(erc20Vault)
if !ok {
return ErrVaultUnavailable
}
return pushOut(vault, asset, to, amount)
}