mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user