mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
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).
609 lines
22 KiB
Go
609 lines
22 KiB
Go
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package swap
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"errors"
|
|
"math/big"
|
|
"testing"
|
|
|
|
"github.com/luxfi/geth/common"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
var errTokenReverted = errors.New("mock token: transfer reverted")
|
|
|
|
// Test fixtures.
|
|
var (
|
|
maker = common.HexToAddress("0x1111111111111111111111111111111111111111")
|
|
user = common.HexToAddress("0x2222222222222222222222222222222222222222")
|
|
watcher = common.HexToAddress("0x3333333333333333333333333333333333333333")
|
|
usdc = common.HexToAddress("0xA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48")
|
|
wbtc = common.HexToAddress("0x2260FAC5E5542A773AA44FBCFEDF7C193BC2C599")
|
|
)
|
|
|
|
const (
|
|
t0 = uint64(1_700_000_000)
|
|
timeout = t0 + 3600 // 1h, within [MinTimeout, MaxTimeout]
|
|
)
|
|
|
|
func preimageOf(b byte) [32]byte {
|
|
var p [32]byte
|
|
for i := range p {
|
|
p[i] = b
|
|
}
|
|
return p
|
|
}
|
|
|
|
func hashOf(p [32]byte) common.Hash {
|
|
s := sha256.Sum256(p[:])
|
|
return common.BytesToHash(s[:])
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Selector authority: derived selectors must equal the canonical values.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSelectorsAreAuthoritative(t *testing.T) {
|
|
require.Equal(t, uint32(0x4da2c728), selLock)
|
|
require.Equal(t, uint32(0x84cc9dfb), selClaim)
|
|
require.Equal(t, uint32(0x7249fbb6), selRefund)
|
|
require.Equal(t, uint32(0x3da0e66e), selGetSwap)
|
|
require.Equal(t, uint32(0x0f622b04), selGetPreimage)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Happy path: lock -> claim. Recipient is paid the stored amount, the secret is
|
|
// relayed, reserve and vault balance both fall by exactly the amount.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestLockThenClaim(t *testing.T) {
|
|
e := newEnv(t0)
|
|
amt := big.NewInt(1_000_000)
|
|
e.db().fund(usdc, maker, amt)
|
|
|
|
pre := preimageOf(0xAB)
|
|
h := hashOf(pre)
|
|
|
|
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
|
require.NoError(t, err)
|
|
require.NotEqual(t, common.Hash{}, id)
|
|
|
|
// Funds left the maker, sit in the vault, reserve credited.
|
|
eqBig(t, big.NewInt(0), e.db().bal(usdc, maker))
|
|
eqBig(t, amt, e.db().bal(usdc, swapAddr))
|
|
eqBig(t, amt, e.reserve(usdc))
|
|
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().bal(usdc, user))
|
|
eqBig(t, big.NewInt(0), e.db().bal(usdc, swapAddr))
|
|
eqBig(t, big.NewInt(0), e.reserve(usdc))
|
|
require.Equal(t, StatusClaimed, loadStatus(e.db(), id))
|
|
|
|
// Secret relayed on-chain for the counterparty leg.
|
|
got, err := e.getPreimage(h, true)
|
|
require.NoError(t, err)
|
|
require.Equal(t, pre, got)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Refund path: lock -> (timeout passes) -> refund to the stored refund address.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestLockThenRefundAfterTimeout(t *testing.T) {
|
|
e := newEnv(t0)
|
|
amt := big.NewInt(500)
|
|
e.db().fund(usdc, maker, amt)
|
|
h := hashOf(preimageOf(0x01))
|
|
|
|
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
|
require.NoError(t, err)
|
|
|
|
// Cannot refund before the timeout.
|
|
_, err = e.refund(maker, id, false)
|
|
require.ErrorIs(t, err, ErrNotExpired)
|
|
|
|
// 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().bal(usdc, maker))
|
|
eqBig(t, big.NewInt(0), e.db().bal(usdc, swapAddr))
|
|
eqBig(t, big.NewInt(0), e.reserve(usdc))
|
|
require.Equal(t, StatusRefunded, loadStatus(e.db(), id))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Wrong preimage is rejected with NO state change and NO payout.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestClaimWrongPreimageRejected(t *testing.T) {
|
|
e := newEnv(t0)
|
|
amt := big.NewInt(777)
|
|
e.db().fund(usdc, maker, amt)
|
|
h := hashOf(preimageOf(0x42))
|
|
|
|
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
|
require.NoError(t, err)
|
|
|
|
_, err = e.claim(user, id, preimageOf(0x43), false) // wrong secret
|
|
require.ErrorIs(t, err, ErrBadPreimage)
|
|
|
|
// Nothing moved; swap still claimable by the right secret.
|
|
require.Equal(t, StatusLocked, loadStatus(e.db(), id))
|
|
eqBig(t, amt, e.db().bal(usdc, swapAddr))
|
|
eqBig(t, amt, e.reserve(usdc))
|
|
eqBig(t, big.NewInt(0), e.db().bal(usdc, user))
|
|
|
|
got, err := e.getPreimage(h, true)
|
|
require.NoError(t, err)
|
|
require.Equal(t, [32]byte{}, got) // no secret leaked
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Disjoint boundary: claim requires T0 < timeout, refund requires T0 >= timeout.
|
|
// A swap is never simultaneously claimable and refundable.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestClaimRefundBoundaryDisjoint(t *testing.T) {
|
|
pre := preimageOf(0x55)
|
|
h := hashOf(pre)
|
|
amt := big.NewInt(100)
|
|
|
|
t.Run("just_before_timeout: claim ok, refund rejected", func(t *testing.T) {
|
|
e := newEnv(t0)
|
|
e.db().fund(usdc, maker, amt)
|
|
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
|
require.NoError(t, err)
|
|
e.setNow(timeout - 1)
|
|
_, err = e.refund(maker, id, false)
|
|
require.ErrorIs(t, err, ErrNotExpired)
|
|
_, err = e.claim(user, id, pre, false)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("at_timeout: claim rejected, refund ok", func(t *testing.T) {
|
|
e := newEnv(t0)
|
|
e.db().fund(usdc, maker, amt)
|
|
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
|
require.NoError(t, err)
|
|
e.setNow(timeout) // exactly at timeout
|
|
_, err = e.claim(user, id, pre, false)
|
|
require.ErrorIs(t, err, ErrExpired)
|
|
_, err = e.refund(maker, id, false)
|
|
require.NoError(t, err)
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Double-claim and double-refund are rejected (status flips once).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestDoubleSpendRejected(t *testing.T) {
|
|
pre := preimageOf(0x09)
|
|
h := hashOf(pre)
|
|
amt := big.NewInt(2024)
|
|
|
|
t.Run("double_claim", func(t *testing.T) {
|
|
e := newEnv(t0)
|
|
e.db().fund(usdc, maker, amt)
|
|
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
|
require.NoError(t, err)
|
|
_, err = e.claim(user, id, pre, false)
|
|
require.NoError(t, err)
|
|
_, err = e.claim(user, id, pre, false)
|
|
require.ErrorIs(t, err, ErrNotLocked)
|
|
// Recipient paid once, not twice.
|
|
eqBig(t, amt, e.db().bal(usdc, user))
|
|
eqBig(t, big.NewInt(0), e.db().bal(usdc, swapAddr))
|
|
})
|
|
|
|
t.Run("double_refund", func(t *testing.T) {
|
|
e := newEnv(t0)
|
|
e.db().fund(usdc, maker, amt)
|
|
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
|
require.NoError(t, err)
|
|
e.setNow(timeout)
|
|
_, err = e.refund(maker, id, false)
|
|
require.NoError(t, err)
|
|
_, err = e.refund(maker, id, false)
|
|
require.ErrorIs(t, err, ErrNotLocked)
|
|
eqBig(t, amt, e.db().bal(usdc, maker))
|
|
})
|
|
|
|
t.Run("claim_then_refund_blocked", func(t *testing.T) {
|
|
e := newEnv(t0)
|
|
e.db().fund(usdc, maker, amt)
|
|
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
|
require.NoError(t, err)
|
|
_, err = e.claim(user, id, pre, false)
|
|
require.NoError(t, err)
|
|
e.setNow(timeout) // even after expiry, a claimed swap cannot refund
|
|
_, err = e.refund(maker, id, false)
|
|
require.ErrorIs(t, err, ErrNotLocked)
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// claim/refund on a nonexistent swap, and various lock validations.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestLockValidation(t *testing.T) {
|
|
amt := big.NewInt(1000)
|
|
h := hashOf(preimageOf(0x11))
|
|
|
|
cases := []struct {
|
|
name string
|
|
hashlock common.Hash
|
|
recip common.Address
|
|
ref common.Address
|
|
asset common.Address
|
|
amount *big.Int
|
|
timeout uint64
|
|
wantErr error
|
|
}{
|
|
{"zero_hashlock", common.Hash{}, user, maker, usdc, amt, timeout, ErrZeroHashlock},
|
|
{"zero_recipient", h, common.Address{}, maker, usdc, amt, timeout, ErrZeroRecipient},
|
|
{"zero_refund", h, user, common.Address{}, usdc, amt, timeout, ErrZeroRefund},
|
|
{"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},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
e := newEnv(t0)
|
|
e.db().fund(usdc, maker, amt)
|
|
_, _, err := e.lock(maker, tc.hashlock, tc.recip, tc.ref, tc.asset, tc.amount, tc.timeout, false)
|
|
require.ErrorIs(t, err, tc.wantErr)
|
|
// No swap created on any rejection; no funds taken.
|
|
eqBig(t, amt, e.db().bal(usdc, maker))
|
|
eqBig(t, big.NewInt(0), e.db().bal(usdc, swapAddr))
|
|
eqBig(t, big.NewInt(0), e.reserve(usdc))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClaimRefundNonexistent(t *testing.T) {
|
|
e := newEnv(t0)
|
|
var id common.Hash
|
|
id[0] = 0xDE
|
|
_, err := e.claim(user, id, preimageOf(1), false)
|
|
require.ErrorIs(t, err, ErrNotLocked)
|
|
_, err = e.refund(user, id, false)
|
|
require.ErrorIs(t, err, ErrNotLocked)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
var native = common.Address{} // the native-LUX asset id
|
|
|
|
func TestNativeLockThenClaim(t *testing.T) {
|
|
e := newEnv(t0)
|
|
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))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fee-on-transfer / short-delivery is rejected at lock (delta != amount), and a
|
|
// reverting token aborts the lock with no swap created.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestLockFundsInSafety(t *testing.T) {
|
|
amt := big.NewInt(1_000_000)
|
|
h := hashOf(preimageOf(0x88))
|
|
|
|
t.Run("fee_on_transfer_rejected", func(t *testing.T) {
|
|
e := newEnv(t0)
|
|
e.db().fund(usdc, maker, amt)
|
|
e.db().feeBps[usdc] = 30 // delivers 0.3% less than requested
|
|
_, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
|
require.ErrorIs(t, err, ErrDeltaMismatch)
|
|
eqBig(t, big.NewInt(0), e.reserve(usdc))
|
|
})
|
|
|
|
t.Run("reverting_token_aborts", func(t *testing.T) {
|
|
e := newEnv(t0)
|
|
e.db().fund(usdc, maker, amt)
|
|
e.db().failTransfer[usdc] = true
|
|
_, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
|
require.ErrorIs(t, err, ErrTransferFailed)
|
|
eqBig(t, big.NewInt(0), e.reserve(usdc))
|
|
eqBig(t, amt, e.db().bal(usdc, maker))
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// read-only (static) calls: state transitions rejected, views allowed.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestReadOnlyGating(t *testing.T) {
|
|
e := newEnv(t0)
|
|
amt := big.NewInt(1000)
|
|
e.db().fund(usdc, maker, amt)
|
|
pre := preimageOf(0x21)
|
|
h := hashOf(pre)
|
|
|
|
_, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, true)
|
|
require.ErrorIs(t, err, ErrReadOnly)
|
|
|
|
// Lock for real, then prove claim/refund are read-only gated and views are not.
|
|
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
|
require.NoError(t, err)
|
|
_, err = e.claim(user, id, pre, true)
|
|
require.ErrorIs(t, err, ErrReadOnly)
|
|
_, err = e.refund(maker, id, true)
|
|
require.ErrorIs(t, err, ErrReadOnly)
|
|
|
|
_, err = e.getSwap(id, true)
|
|
require.NoError(t, err)
|
|
_, err = e.getPreimage(h, true)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reentrancy: a token that reenters lock during transferFrom is rejected by the
|
|
// global guard; the outer lock still completes correctly.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestReentrancyGuard(t *testing.T) {
|
|
e := newEnv(t0)
|
|
amt := big.NewInt(1000)
|
|
e.db().fund(usdc, maker, new(big.Int).Mul(amt, big.NewInt(2)))
|
|
h := hashOf(preimageOf(0x31))
|
|
|
|
var reentryErr error
|
|
e.db().reenter = func() {
|
|
// Attempt a nested lock during the outer lock's transferFrom.
|
|
_, _, reentryErr = e.lock(maker, hashOf(preimageOf(0x32)), user, maker, usdc, amt, timeout, false)
|
|
}
|
|
|
|
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
|
require.NoError(t, err)
|
|
require.ErrorIs(t, reentryErr, ErrReentrant)
|
|
// Exactly one swap funded; the reentrant attempt moved nothing extra.
|
|
eqBig(t, amt, e.db().bal(usdc, swapAddr))
|
|
eqBig(t, amt, e.reserve(usdc))
|
|
require.Equal(t, StatusLocked, loadStatus(e.db(), id))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Conservation invariant across a mixed sequence: at every step
|
|
// balanceOf(swapAddr) == reserve[asset], and the global pay-out never exceeds the
|
|
// global lock-in (no extraction).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestConservationAcrossSequence(t *testing.T) {
|
|
e := newEnv(t0)
|
|
totalIn := big.NewInt(0)
|
|
// Generous funding for many makers.
|
|
e.db().fund(usdc, maker, big.NewInt(1_000_000))
|
|
e.db().fund(wbtc, maker, big.NewInt(1_000_000))
|
|
|
|
check := func() {
|
|
// Per-asset: real vault balance equals the precompile's own reserve ledger.
|
|
eqBig(t, e.reserve(usdc), e.db().bal(usdc, swapAddr))
|
|
eqBig(t, e.reserve(wbtc), e.db().bal(wbtc, swapAddr))
|
|
}
|
|
|
|
lockOne := func(asset common.Address, amt *big.Int, secret byte, to uint64) common.Hash {
|
|
h := hashOf(preimageOf(secret))
|
|
id, _, err := e.lock(maker, h, user, maker, asset, amt, to, false)
|
|
require.NoError(t, err)
|
|
totalIn.Add(totalIn, amt)
|
|
check()
|
|
return id
|
|
}
|
|
|
|
a := lockOne(usdc, big.NewInt(100), 0xA1, timeout)
|
|
b := lockOne(usdc, big.NewInt(250), 0xA2, timeout)
|
|
c := lockOne(wbtc, big.NewInt(70), 0xA3, timeout)
|
|
|
|
// Claim a, refund c (after timeout), leave b live.
|
|
_, err := e.claim(user, a, preimageOf(0xA1), false)
|
|
require.NoError(t, err)
|
|
check()
|
|
|
|
e.setNow(timeout)
|
|
_, err = e.refund(maker, c, false)
|
|
require.NoError(t, err)
|
|
check()
|
|
|
|
// b is still locked: its asset's vault balance equals its amount.
|
|
require.Equal(t, StatusLocked, loadStatus(e.db(), b))
|
|
eqBig(t, big.NewInt(250), e.reserve(usdc))
|
|
eqBig(t, big.NewInt(250), e.db().bal(usdc, swapAddr))
|
|
|
|
// No extraction: total paid out (to user + back to maker) never exceeds
|
|
// total locked in. Sum across both assets of (out) = in - stillLocked.
|
|
stillLocked := new(big.Int).Add(e.reserve(usdc), e.reserve(wbtc))
|
|
paidOut := new(big.Int).Sub(totalIn, stillLocked)
|
|
// user received 100 (claim a); maker received 70 back (refund c) = 170.
|
|
eqBig(t, big.NewInt(170), paidOut)
|
|
require.True(t, paidOut.Cmp(totalIn) <= 0)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Two-leg atomic scenario: claiming leg A publishes the secret s; the maker's
|
|
// watchtower reads s via getPreimage and uses it to claim leg B (same hashlock).
|
|
// This is the cross-leg secret relay that makes the swap atomic.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestTwoLegAtomicScenario(t *testing.T) {
|
|
e := newEnv(t0)
|
|
pre := preimageOf(0xCC)
|
|
h := hashOf(pre)
|
|
|
|
// Leg A: maker locks USDC, recipient = user (the secret holder).
|
|
amtA := big.NewInt(10_000)
|
|
e.db().fund(usdc, maker, amtA)
|
|
legA, _, err := e.lock(maker, h, user, maker, usdc, amtA, timeout-10, false) // earlier timeout
|
|
require.NoError(t, err)
|
|
|
|
// Leg B: user locks WBTC, recipient = maker, SAME hashlock, LATER timeout.
|
|
amtB := big.NewInt(40)
|
|
e.db().fund(wbtc, user, amtB)
|
|
legB, _, err := e.lock(user, h, maker, user, wbtc, amtB, timeout, false)
|
|
require.NoError(t, err)
|
|
|
|
// User claims leg A with the secret -> receives USDC, publishes s.
|
|
_, err = e.claim(user, legA, pre, false)
|
|
require.NoError(t, err)
|
|
eqBig(t, amtA, e.db().bal(usdc, user))
|
|
|
|
// Maker's watchtower reads the published secret from the chain by hashlock...
|
|
relayed, err := e.getPreimage(h, true)
|
|
require.NoError(t, err)
|
|
require.Equal(t, pre, relayed)
|
|
|
|
// ...and uses it to claim leg B WITHOUT ever having been told s out-of-band.
|
|
_, err = e.claim(maker, legB, relayed, false)
|
|
require.NoError(t, err)
|
|
eqBig(t, amtB, e.db().bal(wbtc, maker))
|
|
|
|
// Both legs settled to the agreed recipients; both vaults empty; conserved.
|
|
require.Equal(t, StatusClaimed, loadStatus(e.db(), legA))
|
|
require.Equal(t, StatusClaimed, loadStatus(e.db(), legB))
|
|
eqBig(t, big.NewInt(0), e.db().bal(usdc, swapAddr))
|
|
eqBig(t, big.NewInt(0), e.db().bal(wbtc, swapAddr))
|
|
eqBig(t, big.NewInt(0), e.reserve(usdc))
|
|
eqBig(t, big.NewInt(0), e.reserve(wbtc))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Distinct callers / identical terms produce distinct swapIds (nonce binding),
|
|
// and getSwap reflects stored fields.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSwapIDUniquenessAndView(t *testing.T) {
|
|
e := newEnv(t0)
|
|
amt := big.NewInt(1000)
|
|
e.db().fund(usdc, maker, new(big.Int).Mul(amt, big.NewInt(2)))
|
|
h := hashOf(preimageOf(0x44))
|
|
|
|
id1, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
|
require.NoError(t, err)
|
|
id2, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
|
require.NoError(t, err)
|
|
require.NotEqual(t, id1, id2, "identical terms must yield distinct ids via the nonce")
|
|
|
|
out, err := e.getSwap(id1, true)
|
|
require.NoError(t, err)
|
|
require.Len(t, out, 8*32)
|
|
require.Equal(t, byte(StatusLocked), out[31]) // status word
|
|
require.Equal(t, h, common.BytesToHash(out[32:64])) // hashlock word
|
|
require.Equal(t, user, common.BytesToAddress(out[64:96])) // recipient word
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Out-of-gas and malformed input.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestGasAndMalformedInput(t *testing.T) {
|
|
e := newEnv(t0)
|
|
h := hashOf(preimageOf(0x66))
|
|
|
|
// Out of gas on lock.
|
|
in := selBytes(selLock)
|
|
in = append(in, h[:]...)
|
|
in = append(in, addrArg(user)...)
|
|
in = append(in, addrArg(maker)...)
|
|
in = append(in, addrArg(usdc)...)
|
|
in = append(in, amountArg(big.NewInt(1000))...)
|
|
in = append(in, u64Arg(timeout)...)
|
|
_, _, err := e.c.Run(e.st, maker, swapAddr, in, GasLock-1, false)
|
|
require.ErrorIs(t, err, ErrOutOfGas)
|
|
|
|
// Short input (< selector).
|
|
_, _, err = e.c.Run(e.st, maker, swapAddr, []byte{0x01, 0x02}, e.gas, false)
|
|
require.ErrorIs(t, err, ErrShortInput)
|
|
|
|
// Unknown selector.
|
|
_, _, err = e.c.Run(e.st, maker, swapAddr, []byte{0xff, 0xff, 0xff, 0xff}, e.gas, false)
|
|
require.ErrorIs(t, err, ErrUnknownSelector)
|
|
|
|
// Malformed lock args (wrong length).
|
|
bad := append(selBytes(selLock), 0x00)
|
|
_, _, err = e.c.Run(e.st, maker, swapAddr, bad, e.gas, false)
|
|
require.ErrorIs(t, err, ErrBadArgs)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Module registration sanity: the precompile is wired at LP-90A0.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestModuleRegistered(t *testing.T) {
|
|
require.Equal(t, swapAddr, Module.Address)
|
|
require.Equal(t, ConfigKey, Module.ConfigKey)
|
|
require.Equal(t, common.HexToAddress("0x00000000000000000000000000000000000090A0"), swapAddr)
|
|
}
|