mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
swap: the claim and refund windows must never both be open
87.6% -> 99.6%, tests only -- no source file changed. The two boundary conditions are the whole safety property of an atomic swap, and nothing pinned them. Moving swap.go:333 from >= to >, or swap.go:398 from < to <=, opens both windows at one instant and the same swap is claimed AND refunded. TestZZExactlyOneOutcomeAtEveryClock sweeps the clock and requires exactly one to succeed at every tick, so a boundary drifting in either direction fails rather than merely changing behaviour. 46 mutations applied, 46 caught, restore verified by md5 and by an empty diff. Three defects found and documented rather than fixed, since the brief was coverage: A native donation griefs the next lock. Delivery is measured as balanceOf(swapAddr) - reserve[native], so LUX force-sent by selfdestruct or coinbase is indistinguishable from msg.value: one wei makes every correctly built in-flight native lock fail ErrDeltaMismatch, cheaply and repeatably. The donation is not stolen, it is absorbed by whichever lock finally reconciles. The ERC-20 path is immune because it measures a per-call transferFrom delta. A zero preimage is a legal secret that the relay cannot read: getPreimage returns bytes32(0), byte-identical to never claimed, so a watchtower polling for the cross-leg secret stalls on its own leg. The event and the status word still carry the truth, so it is observability, not funds. The timeout bounds are unguarded uint64 arithmetic. Above 2^64 - MaxTimeout both sums wrap and a lock is accepted with an expiry in the past -- born expired, refundable in its own block, with the counterparty window gone. Unreachable on any real chain (it needs a block timestamp within ~30 days of 584 billion years), so it is pinned as behaviour, not asserted as a bug. Two gas notes, changed nothing: the ERC-20 sub-call takes no gas parameter and returns none, so an arbitrarily expensive transfer runs under a flat fee -- the one genuinely unbounded path. And lock writes nine slots, seven of them zero->nonzero, for 60,000 gas, so it subsidises permanent state growth by roughly 3x at reference prices. Fee ordering is pinned so a repricing surfaces as a failure rather than silently.
This commit is contained in:
@@ -0,0 +1,552 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package swap
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/crypto"
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ATOMICITY. For any swap, EXACTLY ONE of claim or refund can ever succeed —
|
||||
// never both, never twice. The two windows are separated by a single `>=` on the
|
||||
// block timestamp, so the whole property rests on that comparison being at the
|
||||
// same boundary in both directions.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestZZExactlyOneOutcomeAtEveryClock sweeps the clock across the timeout and
|
||||
// asserts that at EVERY instant exactly one of {claim, refund} succeeds on a
|
||||
// fresh swap. A boundary that admitted both (or neither) shows up here.
|
||||
func TestZZExactlyOneOutcomeAtEveryClock(t *testing.T) {
|
||||
pre := preimageOf(0x5A)
|
||||
h := hashOf(pre)
|
||||
amt := big.NewInt(1000)
|
||||
|
||||
// Instants around the boundary, plus the lock instant and the far future.
|
||||
for _, now := range []uint64{t0, timeout - 2, timeout - 1, timeout, timeout + 1, timeout + 2, timeout + 1_000_000} {
|
||||
// Try to claim at `now`.
|
||||
claimOK := func() bool {
|
||||
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(now)
|
||||
_, err = e.claim(user, id, pre, false)
|
||||
if err != nil {
|
||||
require.ErrorIs(t, err, ErrExpired, "the only lawful refusal of a valid claim is expiry")
|
||||
return false
|
||||
}
|
||||
zzEqBig(t, amt, e.db().bal(usdc, user))
|
||||
return true
|
||||
}()
|
||||
|
||||
// Try to refund at `now`, on an independent swap.
|
||||
refundOK := func() bool {
|
||||
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(now)
|
||||
_, err = e.refund(maker, id, false)
|
||||
if err != nil {
|
||||
require.ErrorIs(t, err, ErrNotExpired, "the only lawful refusal of a refund is a live timeout")
|
||||
return false
|
||||
}
|
||||
zzEqBig(t, amt, e.db().bal(usdc, maker))
|
||||
return true
|
||||
}()
|
||||
|
||||
require.NotEqualf(t, claimOK, refundOK,
|
||||
"at t=%d exactly one of claim/refund must succeed (claim=%v refund=%v)", now, claimOK, refundOK)
|
||||
// And the split is at the timeout itself: claim strictly before, refund at/after.
|
||||
require.Equalf(t, now < timeout, claimOK, "claim window is [lock, timeout) — failed at t=%d", now)
|
||||
require.Equalf(t, now >= timeout, refundOK, "refund window is [timeout, inf) — failed at t=%d", now)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZZSettledSwapIsTerminal drives every ordering of a second settlement
|
||||
// attempt against an already-settled swap, at both clock positions.
|
||||
func TestZZSettledSwapIsTerminal(t *testing.T) {
|
||||
pre := preimageOf(0x5B)
|
||||
h := hashOf(pre)
|
||||
amt := big.NewInt(4242)
|
||||
|
||||
settle := map[string]struct {
|
||||
at uint64
|
||||
act func(e *env, id common.Hash) error
|
||||
want uint8
|
||||
}{
|
||||
"claimed": {t0, func(e *env, id common.Hash) error { _, err := e.claim(user, id, pre, false); return err }, StatusClaimed},
|
||||
"refunded": {timeout, func(e *env, id common.Hash) error {
|
||||
_, err := e.refund(maker, id, false)
|
||||
return err
|
||||
}, StatusRefunded},
|
||||
}
|
||||
|
||||
for name, s := range settle {
|
||||
t.Run(name, 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(s.at)
|
||||
require.NoError(t, s.act(e, id))
|
||||
require.Equal(t, s.want, zzStatus(t, e, id))
|
||||
zzConserved(t, e, usdc)
|
||||
paidUser := e.db().bal(usdc, user)
|
||||
paidMaker := e.db().bal(usdc, maker)
|
||||
|
||||
// Nothing further settles, at ANY clock, from ANY caller, with the
|
||||
// correct secret in hand.
|
||||
zzUnchanged(t, e.db(), func() {
|
||||
for _, now := range []uint64{t0, timeout - 1, timeout, timeout + 1_000_000} {
|
||||
e.setNow(now)
|
||||
for _, who := range []common.Address{user, maker, watcher} {
|
||||
_, err := e.claim(who, id, pre, false)
|
||||
require.ErrorIsf(t, err, ErrNotLocked, "claim at t=%d by %s", now, who.Hex())
|
||||
_, err = e.refund(who, id, false)
|
||||
require.ErrorIsf(t, err, ErrNotLocked, "refund at t=%d by %s", now, who.Hex())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Balances are exactly what the single settlement paid.
|
||||
zzEqBig(t, paidUser, e.db().bal(usdc, user))
|
||||
zzEqBig(t, paidMaker, e.db().bal(usdc, maker))
|
||||
zzEqBig(t, big.NewInt(0), e.reserve(usdc))
|
||||
require.Equal(t, s.want, zzStatus(t, e, id), "a settled swap's status is terminal")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestZZRaceAtTheBoundary is the adversarial ordering: the recipient claims one
|
||||
// second before expiry while the refunder's transaction lands one second after.
|
||||
// The claim wins and the refund finds nothing to take back.
|
||||
func TestZZRaceAtTheBoundary(t *testing.T) {
|
||||
pre := preimageOf(0x5C)
|
||||
h := hashOf(pre)
|
||||
amt := big.NewInt(9000)
|
||||
|
||||
t.Run("claim_lands_first", 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.claim(user, id, pre, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
e.setNow(timeout)
|
||||
_, err = e.refund(maker, id, false)
|
||||
require.ErrorIs(t, err, ErrNotLocked)
|
||||
zzEqBig(t, amt, e.db().bal(usdc, user))
|
||||
zzEqBig(t, big.NewInt(0), e.db().bal(usdc, maker))
|
||||
})
|
||||
|
||||
t.Run("refund_lands_first", 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)
|
||||
|
||||
// Even holding the correct secret, the recipient is too late.
|
||||
_, err = e.claim(user, id, pre, false)
|
||||
require.ErrorIs(t, err, ErrNotLocked)
|
||||
zzEqBig(t, amt, e.db().bal(usdc, maker))
|
||||
zzEqBig(t, big.NewInt(0), e.db().bal(usdc, user))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HASHLOCK. Claim requires SHA256(preimage) == hashlock — Bitcoin's OP_SHA256,
|
||||
// not keccak, so one 32-byte secret unlocks both legs of a cross-chain swap.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestZZHashlockIsSHA256NotKeccak(t *testing.T) {
|
||||
pre := preimageOf(0x6A)
|
||||
sha := hashOf(pre)
|
||||
kec := common.BytesToHash(crypto.Keccak256(pre[:]))
|
||||
require.NotEqual(t, sha, kec, "the two digests must differ for this test to mean anything")
|
||||
|
||||
amt := big.NewInt(1000)
|
||||
|
||||
// A swap locked under the KECCAK digest is NOT unlockable by that preimage:
|
||||
// the contract hashes with SHA-256 and gets a different value.
|
||||
e := newEnv(t0)
|
||||
e.db().fund(usdc, maker, amt)
|
||||
id, _, err := e.lock(maker, kec, user, maker, usdc, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
_, err = e.claim(user, id, pre, false)
|
||||
require.ErrorIs(t, err, ErrBadPreimage, "hashlock must be SHA-256, so a keccak commitment cannot open")
|
||||
|
||||
// The same preimage against the SHA-256 digest opens immediately.
|
||||
e2 := newEnv(t0)
|
||||
e2.db().fund(usdc, maker, amt)
|
||||
id2, _, err := e2.lock(maker, sha, user, maker, usdc, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
_, err = e2.claim(user, id2, pre, false)
|
||||
require.NoError(t, err)
|
||||
zzEqBig(t, amt, e2.db().bal(usdc, user))
|
||||
}
|
||||
|
||||
// TestZZWrongPreimageRefused covers the shapes an attacker actually submits: a
|
||||
// near-miss, a zero secret, a secret truncated or shifted inside the bytes32
|
||||
// word, and the hashlock itself replayed as if it were the secret.
|
||||
func TestZZWrongPreimageRefused(t *testing.T) {
|
||||
pre := preimageOf(0x6B)
|
||||
h := hashOf(pre)
|
||||
amt := big.NewInt(1000)
|
||||
|
||||
var offByOne [preimageLen]byte
|
||||
copy(offByOne[:], pre[:])
|
||||
offByOne[31] ^= 0x01
|
||||
|
||||
var flippedFirst [preimageLen]byte
|
||||
copy(flippedFirst[:], pre[:])
|
||||
flippedFirst[0] ^= 0x80
|
||||
|
||||
// The 31-byte secret, right-padded and left-padded into the 32-byte word: a
|
||||
// caller who hashed a SHORTER value off-chain cannot open a bytes32 lock.
|
||||
var rightPad, leftPad32 [preimageLen]byte
|
||||
copy(rightPad[:], pre[:31])
|
||||
copy(leftPad32[1:], pre[:31])
|
||||
|
||||
bad := map[string][preimageLen]byte{
|
||||
"off_by_one_last_byte": offByOne,
|
||||
"flipped_high_bit": flippedFirst,
|
||||
"all_zero": {},
|
||||
"all_ones": preimageOf(0xFF),
|
||||
"unrelated_secret": preimageOf(0x6C),
|
||||
"short_secret_rightpad": rightPad,
|
||||
"short_secret_leftpad": leftPad32,
|
||||
"hashlock_as_preimage": [preimageLen]byte(h),
|
||||
}
|
||||
|
||||
for name, p := range bad {
|
||||
t.Run(name, 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)
|
||||
|
||||
zzUnchanged(t, e.db(), func() {
|
||||
_, err := e.claim(user, id, p, false)
|
||||
require.ErrorIs(t, err, ErrBadPreimage)
|
||||
})
|
||||
|
||||
// Still locked, still fully funded, and no secret leaked to the relay.
|
||||
require.Equal(t, StatusLocked, zzStatus(t, e, id))
|
||||
zzEqBig(t, amt, e.reserve(usdc))
|
||||
zzConserved(t, e, usdc)
|
||||
got, err := e.getPreimage(h, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, [preimageLen]byte{}, got)
|
||||
|
||||
// And the RIGHT secret still opens it afterwards.
|
||||
_, err = e.claim(user, id, pre, false)
|
||||
require.NoError(t, err)
|
||||
zzEqBig(t, amt, e.db().bal(usdc, user))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestZZPreimageRelayLifecycle pins the cross-leg secret relay: zero before the
|
||||
// claim, the exact secret after, and keyed by HASHLOCK (not by swapId) so the
|
||||
// counterparty's watchtower — which only knows the shared hashlock — can read it.
|
||||
func TestZZPreimageRelayLifecycle(t *testing.T) {
|
||||
pre := preimageOf(0x6D)
|
||||
h := hashOf(pre)
|
||||
amt := big.NewInt(1000)
|
||||
|
||||
e := newEnv(t0)
|
||||
e.db().fund(usdc, maker, amt)
|
||||
|
||||
// Unknown hashlocks read zero, and so does a live-but-unclaimed one.
|
||||
for _, probe := range []common.Hash{h, hashOf(preimageOf(0x6E)), {}, {0xFF}} {
|
||||
got, err := e.getPreimage(probe, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, [preimageLen]byte{}, got, "no secret exists before any claim")
|
||||
}
|
||||
|
||||
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
got, err := e.getPreimage(h, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, [preimageLen]byte{}, got, "locking must not reveal anything")
|
||||
|
||||
_, err = e.claim(user, id, pre, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err = e.getPreimage(h, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, pre, got, "the claim publishes the secret under its hashlock")
|
||||
require.Equal(t, h, hashOf(got), "and the published secret really opens that hashlock")
|
||||
|
||||
// Unrelated hashlocks are untouched by this reveal.
|
||||
other, err := e.getPreimage(hashOf(preimageOf(0x6E)), true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, [preimageLen]byte{}, other)
|
||||
|
||||
// getSwap carries the same secret in its preimage word.
|
||||
out, err := e.getSwap(id, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, pre[:], out[7*32:8*32])
|
||||
}
|
||||
|
||||
// TestZZZeroPreimageIsAcceptedButUnreadableViaRelay documents a real asymmetry:
|
||||
// a ZERO hashlock is refused at lock, but a zero PREIMAGE is a legal secret. It
|
||||
// settles correctly — and then getPreimage returns bytes32(0), which is the same
|
||||
// answer it gives for "never claimed". The relay cannot distinguish the two; the
|
||||
// Claimed event and getSwap's status word still can.
|
||||
func TestZZZeroPreimageIsAcceptedButUnreadableViaRelay(t *testing.T) {
|
||||
var zero [preimageLen]byte
|
||||
h := common.BytesToHash(func() []byte { s := sha256.Sum256(zero[:]); return s[:] }())
|
||||
require.NotEqual(t, common.Hash{}, h, "SHA256(0^32) is not itself zero, so this hashlock is lockable")
|
||||
|
||||
amt := big.NewInt(1000)
|
||||
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)
|
||||
|
||||
// The zero secret opens it — the contract special-cases nothing.
|
||||
_, err = e.claim(user, id, zero, false)
|
||||
require.NoError(t, err)
|
||||
zzEqBig(t, amt, e.db().bal(usdc, user))
|
||||
require.Equal(t, StatusClaimed, zzStatus(t, e, id))
|
||||
|
||||
// But the relay is now ambiguous: this reads identically to "unclaimed".
|
||||
got, err := e.getPreimage(h, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, [preimageLen]byte{}, got)
|
||||
unrelated, err := e.getPreimage(hashOf(preimageOf(0x01)), true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, got, unrelated, "a zero secret is indistinguishable from no secret via getPreimage")
|
||||
|
||||
// The status word remains the unambiguous signal.
|
||||
require.NotEqual(t, StatusNone, zzStatus(t, e, id))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CALLER-AGNOSTIC SETTLEMENT. Both payouts go to the address fixed at lock time,
|
||||
// whoever submits. A watchtower earns nothing by relaying.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestZZPayoutIgnoresSubmitter(t *testing.T) {
|
||||
pre := preimageOf(0x7A)
|
||||
h := hashOf(pre)
|
||||
amt := big.NewInt(31337)
|
||||
stranger := common.HexToAddress("0x9999999999999999999999999999999999999999")
|
||||
|
||||
for _, submitter := range []common.Address{user, maker, watcher, stranger, swapAddr} {
|
||||
t.Run("claim_by_"+submitter.Hex(), 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(submitter, id, pre, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
zzEqBig(t, amt, e.db().bal(usdc, user), "the STORED recipient is paid")
|
||||
if submitter != user {
|
||||
zzEqBig(t, big.NewInt(0), e.db().bal(usdc, submitter), "the submitter earns nothing")
|
||||
}
|
||||
zzConserved(t, e, usdc)
|
||||
})
|
||||
|
||||
t.Run("refund_by_"+submitter.Hex(), 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(submitter, id, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
zzEqBig(t, amt, e.db().bal(usdc, maker), "the STORED refund address is paid")
|
||||
if submitter != maker {
|
||||
zzEqBig(t, big.NewInt(0), e.db().bal(usdc, submitter), "the submitter earns nothing")
|
||||
}
|
||||
zzConserved(t, e, usdc)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestZZRecipientAndRefundMayDiffer separates the four roles — locker, recipient,
|
||||
// refund address, submitter — so no path can be quietly paying `caller`.
|
||||
func TestZZRecipientAndRefundMayDiffer(t *testing.T) {
|
||||
pre := preimageOf(0x7B)
|
||||
h := hashOf(pre)
|
||||
amt := big.NewInt(555)
|
||||
refundee := common.HexToAddress("0x4444444444444444444444444444444444444444")
|
||||
|
||||
t.Run("claim_pays_recipient_not_locker", func(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
e.db().fund(usdc, maker, amt)
|
||||
id, _, err := e.lock(maker, h, user, refundee, usdc, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
_, err = e.claim(watcher, id, pre, false)
|
||||
require.NoError(t, err)
|
||||
zzEqBig(t, amt, e.db().bal(usdc, user))
|
||||
zzEqBig(t, big.NewInt(0), e.db().bal(usdc, refundee))
|
||||
zzEqBig(t, big.NewInt(0), e.db().bal(usdc, maker))
|
||||
})
|
||||
|
||||
t.Run("refund_pays_refund_address_not_locker", func(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
e.db().fund(usdc, maker, amt)
|
||||
id, _, err := e.lock(maker, h, user, refundee, usdc, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
e.setNow(timeout)
|
||||
_, err = e.refund(watcher, id, false)
|
||||
require.NoError(t, err)
|
||||
zzEqBig(t, amt, e.db().bal(usdc, refundee), "refund goes to the STORED refund address")
|
||||
zzEqBig(t, big.NewInt(0), e.db().bal(usdc, maker), "not back to whoever locked")
|
||||
zzEqBig(t, big.NewInt(0), e.db().bal(usdc, user))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REPLAY / COLLISION.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestZZSwapIDCollisionRefused seeds the exact id the next lock will compute and
|
||||
// proves the collision check refuses it WITHOUT pulling any funds. Honest locks
|
||||
// cannot reach this (the nonce makes ids distinct); a hash collision or a
|
||||
// corrupted trie could.
|
||||
func TestZZSwapIDCollisionRefused(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
amt := big.NewInt(1000)
|
||||
e.db().fund(usdc, maker, amt)
|
||||
h := hashOf(preimageOf(0x8A))
|
||||
|
||||
// The nonce starts at zero, so the id of the next lock is fully determined.
|
||||
require.Equal(t, uint64(0), loadNonce(e.db()))
|
||||
collide := computeSwapID(h, user, maker, usdc, amt, timeout, maker, 0)
|
||||
zzSeedLocked(e.db(), collide, h, user, maker, usdc, amt, timeout)
|
||||
|
||||
_, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
||||
require.ErrorIs(t, err, ErrSwapExists)
|
||||
|
||||
// Refused BEFORE the funds move: the maker still holds everything.
|
||||
zzEqBig(t, amt, e.db().bal(usdc, maker))
|
||||
zzEqBig(t, big.NewInt(0), e.db().bal(usdc, swapAddr))
|
||||
zzEqBig(t, big.NewInt(0), e.reserve(usdc))
|
||||
require.Equal(t, uint64(0), loadNonce(e.db()), "a refused lock does not burn a nonce")
|
||||
|
||||
// Any other status at that id is equally refused — Claimed and Refunded slots
|
||||
// are never recycled.
|
||||
for _, st := range []uint8{StatusClaimed, StatusRefunded} {
|
||||
storeStatus(e.db(), collide, st)
|
||||
_, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
||||
require.ErrorIsf(t, err, ErrSwapExists, "status %d must not be reusable", st)
|
||||
}
|
||||
|
||||
// Cleared to None, the same terms lock cleanly at the same id.
|
||||
storeStatus(e.db(), collide, StatusNone)
|
||||
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, collide, id, "the id is a pure function of the terms, caller and nonce")
|
||||
require.Equal(t, uint64(1), loadNonce(e.db()))
|
||||
}
|
||||
|
||||
// TestZZNonceMakesIdenticalTermsDistinct pins the binding that stops a replayed
|
||||
// lock from colliding: same terms, same caller, same block — different ids.
|
||||
func TestZZNonceMakesIdenticalTermsDistinct(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
amt := big.NewInt(100)
|
||||
n := 8
|
||||
e.db().fund(usdc, maker, new(big.Int).Mul(amt, big.NewInt(int64(n))))
|
||||
h := hashOf(preimageOf(0x8B))
|
||||
|
||||
seen := map[common.Hash]bool{}
|
||||
for i := 0; i < n; i++ {
|
||||
require.Equal(t, uint64(i), loadNonce(e.db()), "the nonce advances once per successful lock")
|
||||
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
require.Falsef(t, seen[id], "lock %d reused an id", i)
|
||||
seen[id] = true
|
||||
require.Equal(t, id, computeSwapID(h, user, maker, usdc, amt, timeout, maker, uint64(i)))
|
||||
}
|
||||
require.Len(t, seen, n)
|
||||
zzEqBig(t, new(big.Int).Mul(amt, big.NewInt(int64(n))), e.reserve(usdc))
|
||||
zzConserved(t, e, usdc)
|
||||
}
|
||||
|
||||
// TestZZSwapIDBindsEveryTerm changes one field at a time and asserts the id
|
||||
// moves. A field left out of the digest would let two different swaps share a
|
||||
// slot and overwrite each other.
|
||||
func TestZZSwapIDBindsEveryTerm(t *testing.T) {
|
||||
h := hashOf(preimageOf(0x8C))
|
||||
amt := big.NewInt(1000)
|
||||
base := computeSwapID(h, user, maker, usdc, amt, timeout, maker, 0)
|
||||
|
||||
variants := map[string]common.Hash{
|
||||
"hashlock": computeSwapID(hashOf(preimageOf(0x8D)), user, maker, usdc, amt, timeout, maker, 0),
|
||||
"recipient": computeSwapID(h, watcher, maker, usdc, amt, timeout, maker, 0),
|
||||
"refund": computeSwapID(h, user, watcher, usdc, amt, timeout, maker, 0),
|
||||
"asset": computeSwapID(h, user, maker, wbtc, amt, timeout, maker, 0),
|
||||
"amount": computeSwapID(h, user, maker, usdc, big.NewInt(1001), timeout, maker, 0),
|
||||
"timeout": computeSwapID(h, user, maker, usdc, amt, timeout+1, maker, 0),
|
||||
"caller": computeSwapID(h, user, maker, usdc, amt, timeout, user, 0),
|
||||
"nonce": computeSwapID(h, user, maker, usdc, amt, timeout, maker, 1),
|
||||
}
|
||||
all := map[common.Hash]string{base: "base"}
|
||||
for name, id := range variants {
|
||||
require.NotEqualf(t, base, id, "%s is not bound into the swapId", name)
|
||||
prev, dup := all[id]
|
||||
require.Falsef(t, dup, "%s collides with %s", name, prev)
|
||||
all[id] = name
|
||||
}
|
||||
|
||||
// Deterministic: the same terms always yield the same id.
|
||||
require.Equal(t, base, computeSwapID(h, user, maker, usdc, new(big.Int).Set(amt), timeout, maker, 0))
|
||||
}
|
||||
|
||||
// TestZZCrossCallerIsolation proves two makers locking identical terms cannot
|
||||
// touch each other's swap.
|
||||
func TestZZCrossCallerIsolation(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
amt := big.NewInt(1000)
|
||||
pre := preimageOf(0x8E)
|
||||
h := hashOf(pre)
|
||||
e.db().fund(usdc, maker, amt)
|
||||
e.db().fund(usdc, user, amt)
|
||||
|
||||
idA, _, err := e.lock(maker, h, watcher, maker, usdc, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
idB, _, err := e.lock(user, h, watcher, user, usdc, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, idA, idB)
|
||||
|
||||
// Settling A leaves B untouched, even though both share the hashlock.
|
||||
_, err = e.claim(watcher, idA, pre, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, StatusClaimed, zzStatus(t, e, idA))
|
||||
require.Equal(t, StatusLocked, zzStatus(t, e, idB))
|
||||
zzEqBig(t, amt, e.reserve(usdc))
|
||||
zzConserved(t, e, usdc)
|
||||
|
||||
// B refunds to ITS locker, not A's.
|
||||
e.setNow(timeout)
|
||||
_, err = e.refund(watcher, idB, false)
|
||||
require.NoError(t, err)
|
||||
zzEqBig(t, amt, e.db().bal(usdc, user))
|
||||
zzEqBig(t, big.NewInt(0), e.db().bal(usdc, maker))
|
||||
zzEqBig(t, big.NewInt(0), e.reserve(usdc))
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package swap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw call encoders. The harness helpers (e.lock/e.claim/...) always encode a
|
||||
// well-formed call at a fixed gas budget; these return the raw input so a test
|
||||
// can vary the length or the gas independently and observe what Run charges.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func zzLockInput(hashlock common.Hash, recipient, refundAddr, asset common.Address, amount *big.Int, timeout uint64) []byte {
|
||||
in := selBytes(selLock)
|
||||
in = append(in, hashlock[:]...)
|
||||
in = append(in, addrArg(recipient)...)
|
||||
in = append(in, addrArg(refundAddr)...)
|
||||
in = append(in, addrArg(asset)...)
|
||||
in = append(in, amountArg(amount)...)
|
||||
in = append(in, u64Arg(timeout)...)
|
||||
return in
|
||||
}
|
||||
|
||||
func zzClaimInput(swapId common.Hash, preimage [preimageLen]byte) []byte {
|
||||
in := selBytes(selClaim)
|
||||
in = append(in, swapId[:]...)
|
||||
in = append(in, preimage[:]...)
|
||||
return in
|
||||
}
|
||||
|
||||
func zzRefundInput(swapId common.Hash) []byte {
|
||||
return append(selBytes(selRefund), swapId[:]...)
|
||||
}
|
||||
|
||||
func zzGetSwapInput(swapId common.Hash) []byte {
|
||||
return append(selBytes(selGetSwap), swapId[:]...)
|
||||
}
|
||||
|
||||
func zzGetPreimageInput(hashlock common.Hash) []byte {
|
||||
return append(selBytes(selGetPreimage), hashlock[:]...)
|
||||
}
|
||||
|
||||
// zzWellFormedLock is the canonical valid lock payload used by the length and
|
||||
// gas tables, so those tables vary exactly one thing.
|
||||
func zzWellFormedLock() []byte {
|
||||
return zzLockInput(hashOf(preimageOf(0xE1)), user, maker, usdc, big.NewInt(1000), timeout)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// zzSeedLocked writes a Locked swap straight into the trie WITHOUT moving any
|
||||
// value. It fabricates the corrupted / adversarial states that the conservation
|
||||
// guards exist to refuse and that no honest sequence can reach.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func zzSeedLocked(db stateRW, swapId, hashlock common.Hash, recipient, refundAddr, asset common.Address, amount *big.Int, expiry uint64) {
|
||||
storeStatus(db, swapId, StatusLocked)
|
||||
storeHash(db, swapId, fHashlock, hashlock)
|
||||
storeAddress(db, swapId, fRecipient, recipient)
|
||||
storeAddress(db, swapId, fRefund, refundAddr)
|
||||
storeAddress(db, swapId, fAsset, asset)
|
||||
storeAmount(db, swapId, fAmount, amount)
|
||||
storeTimeout(db, swapId, expiry)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// zzNoVault is a contract.StateDB that deliberately does NOT carry the optional
|
||||
// erc20Vault capability: every ERC-20 path must refuse cleanly (ErrVaultUnavailable)
|
||||
// rather than fake a credit. Method forwarding is explicit — embedding *mockState
|
||||
// would promote the token methods and hand it the capability by accident.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type zzNoVault struct{ m *mockState }
|
||||
|
||||
var _ contract.StateDB = (*zzNoVault)(nil)
|
||||
|
||||
func zzNewNoVault() *zzNoVault { return &zzNoVault{m: newMockState()} }
|
||||
|
||||
func (s *zzNoVault) GetState(a common.Address, k common.Hash) common.Hash { return s.m.GetState(a, k) }
|
||||
|
||||
func (s *zzNoVault) SetState(a common.Address, k, v common.Hash) common.Hash {
|
||||
return s.m.SetState(a, k, v)
|
||||
}
|
||||
|
||||
func (s *zzNoVault) SetNonce(a common.Address, n uint64, r tracing.NonceChangeReason) {
|
||||
s.m.SetNonce(a, n, r)
|
||||
}
|
||||
func (s *zzNoVault) GetNonce(a common.Address) uint64 { return s.m.GetNonce(a) }
|
||||
func (s *zzNoVault) GetBalance(a common.Address) *uint256.Int { return s.m.GetBalance(a) }
|
||||
|
||||
func (s *zzNoVault) AddBalance(a common.Address, v *uint256.Int, r tracing.BalanceChangeReason) uint256.Int {
|
||||
return s.m.AddBalance(a, v, r)
|
||||
}
|
||||
|
||||
func (s *zzNoVault) SubBalance(a common.Address, v *uint256.Int, r tracing.BalanceChangeReason) uint256.Int {
|
||||
return s.m.SubBalance(a, v, r)
|
||||
}
|
||||
|
||||
func (s *zzNoVault) GetBalanceMultiCoin(a common.Address, h common.Hash) *big.Int {
|
||||
return s.m.GetBalanceMultiCoin(a, h)
|
||||
}
|
||||
func (s *zzNoVault) AddBalanceMultiCoin(common.Address, common.Hash, *big.Int) {}
|
||||
func (s *zzNoVault) SubBalanceMultiCoin(common.Address, common.Hash, *big.Int) {}
|
||||
func (s *zzNoVault) CreateAccount(common.Address) {}
|
||||
func (s *zzNoVault) Exist(common.Address) bool { return true }
|
||||
func (s *zzNoVault) AddLog(l *ethtypes.Log) { s.m.AddLog(l) }
|
||||
func (s *zzNoVault) Logs() []*ethtypes.Log { return s.m.Logs() }
|
||||
func (s *zzNoVault) GetPredicateStorageSlots(common.Address, int) ([]byte, bool) {
|
||||
return nil, false
|
||||
}
|
||||
func (s *zzNoVault) TxHash() common.Hash { return common.Hash{} }
|
||||
func (s *zzNoVault) Snapshot() int { return 0 }
|
||||
func (s *zzNoVault) RevertToSnapshot(int) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// zzState is an AccessibleState whose StateDB and BlockContext are both settable,
|
||||
// so a test can hand Run a vault-less StateDB or a nil BlockContext.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type zzState struct {
|
||||
db contract.StateDB
|
||||
blk contract.BlockContext
|
||||
}
|
||||
|
||||
var _ contract.AccessibleState = (*zzState)(nil)
|
||||
|
||||
func (a *zzState) GetStateDB() contract.StateDB { return a.db }
|
||||
func (a *zzState) GetBlockContext() contract.BlockContext { return a.blk }
|
||||
func (a *zzState) GetConsensusContext() context.Context { return context.Background() }
|
||||
func (a *zzState) GetChainConfig() precompileconfig.ChainConfig { return nil }
|
||||
func (a *zzState) GetPrecompileEnv() contract.PrecompileEnvironment { return nil }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invariant assertions reused across the behavioural suites.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// zzEqBig is eqBig with an OPTIONAL explanation attached. Same Cmp-based
|
||||
// comparison, avoiding the nil-vs-empty `abs` pitfall of reflect-based equality
|
||||
// on big.Int.
|
||||
func zzEqBig(t require.TestingT, want, got *big.Int, msgAndArgs ...any) {
|
||||
if len(msgAndArgs) == 0 {
|
||||
require.Truef(t, want.Cmp(got) == 0, "want %s, got %s", want, got)
|
||||
return
|
||||
}
|
||||
format, ok := msgAndArgs[0].(string)
|
||||
require.True(t, ok, "zzEqBig message must be a format string")
|
||||
require.Truef(t, want.Cmp(got) == 0, "want %s, got %s: "+format,
|
||||
append([]any{want, got}, msgAndArgs[1:]...)...)
|
||||
}
|
||||
|
||||
// zzConserved asserts the conservation invariant for one ERC-20 asset: the
|
||||
// precompile's own reserve ledger equals the value actually sitting in the vault.
|
||||
func zzConserved(t require.TestingT, e *env, asset common.Address) {
|
||||
eqBig(t, e.reserve(asset), e.db().bal(asset, swapAddr))
|
||||
}
|
||||
|
||||
// zzConservedNative is the same statement for native LUX, read off the real EVM
|
||||
// balance ledger.
|
||||
func zzConservedNative(t require.TestingT, e *env) {
|
||||
eqBig(t, e.reserve(native), e.db().nativeBal(swapAddr))
|
||||
}
|
||||
|
||||
// zzFingerprint is the OBSERVABLE state of the mock: every storage slot that
|
||||
// READS non-zero, every non-zero balance, and the event log height.
|
||||
//
|
||||
// A slot explicitly written to zero is deliberately excluded. GetState cannot
|
||||
// tell such a slot from one never written, and the EVM trie stores neither — so
|
||||
// the transient reentrancy guard, which enterGuard sets on the way in and the
|
||||
// deferred exitGuard clears on the way out, leaves a zero-valued map entry that
|
||||
// is NOT a state change. Comparing raw maps would flag that no-op; comparing
|
||||
// fingerprints asserts the property actually wanted ("nothing observable moved")
|
||||
// while still catching any genuine write, including an emitted event.
|
||||
//
|
||||
// Do not "tighten" this back into a raw map comparison: it fails on every
|
||||
// refusal that reaches the guarded region.
|
||||
func zzFingerprint(m *mockState) string {
|
||||
lines := make([]string, 0, len(m.storage))
|
||||
for k, v := range m.storage {
|
||||
if v == (common.Hash{}) {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, "slot "+k.addr.Hex()+" "+k.key.Hex()+" = "+v.Hex())
|
||||
}
|
||||
for token, owners := range m.tokens {
|
||||
for owner, bal := range owners {
|
||||
if bal == nil || bal.Sign() == 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, "token "+token.Hex()+" "+owner.Hex()+" = "+bal.String())
|
||||
}
|
||||
}
|
||||
for addr, bal := range m.native {
|
||||
if bal == nil || bal.Sign() == 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, "native "+addr.Hex()+" = "+bal.String())
|
||||
}
|
||||
sort.Strings(lines)
|
||||
return strconv.Itoa(len(m.logs)) + " logs\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// zzUnchanged asserts that running `act` moves no observable state — the
|
||||
// invariant every refusal owes.
|
||||
func zzUnchanged(t require.TestingT, m *mockState, act func()) {
|
||||
before := zzFingerprint(m)
|
||||
act()
|
||||
require.Equal(t, before, zzFingerprint(m), "a refused call must not change observable state")
|
||||
}
|
||||
|
||||
// zzStatus reads the on-chain status word through the public view, not the
|
||||
// package-private loader, so the view and the storage cannot drift apart.
|
||||
func zzStatus(t require.TestingT, e *env, swapId common.Hash) uint8 {
|
||||
out, err := e.getSwap(swapId, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 8*32)
|
||||
return out[31]
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package swap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/luxfi/precompile/contract"
|
||||
"github.com/luxfi/precompile/modules"
|
||||
"github.com/luxfi/precompile/precompileconfig"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// zzForeignConfig is a precompileconfig.Config that is NOT *swap.Config. Equal and
|
||||
// Configure must both discriminate on the concrete type, never on the interface.
|
||||
type zzForeignConfig struct{ precompileconfig.Upgrade }
|
||||
|
||||
func (c *zzForeignConfig) Key() string { return "notSwapConfig" }
|
||||
func (c *zzForeignConfig) Equal(precompileconfig.Config) bool { return false }
|
||||
func (c *zzForeignConfig) Verify(precompileconfig.ChainConfig) error { return nil }
|
||||
func (c *zzForeignConfig) IsDisabled() bool { return c.Upgrade.Disable }
|
||||
|
||||
var _ precompileconfig.Config = (*zzForeignConfig)(nil)
|
||||
|
||||
func zzTS(v uint64) *uint64 { return &v }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config surface. The HTLC has no administrative parameters by design, so the
|
||||
// whole config is the standard activation Upgrade — and these assertions pin
|
||||
// that no parameter has quietly grown one.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestZZConfigKeyAndTimestamp(t *testing.T) {
|
||||
c := new(Config)
|
||||
require.Equal(t, ConfigKey, c.Key())
|
||||
require.Equal(t, "swapConfig", c.Key())
|
||||
require.Nil(t, c.Timestamp(), "a config with no blockTimestamp is never enabled")
|
||||
require.False(t, c.IsDisabled())
|
||||
|
||||
at := &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: zzTS(t0)}}
|
||||
require.NotNil(t, at.Timestamp())
|
||||
require.Equal(t, t0, *at.Timestamp())
|
||||
require.False(t, at.IsDisabled())
|
||||
|
||||
off := &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: zzTS(t0), Disable: true}}
|
||||
require.True(t, off.IsDisabled(), "Disable must surface through the precompile's own accessor")
|
||||
require.Equal(t, t0, *off.Timestamp())
|
||||
|
||||
// Genesis activation is timestamp 0, which must be distinguishable from "never".
|
||||
genesis := &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: zzTS(0)}}
|
||||
require.NotNil(t, genesis.Timestamp())
|
||||
require.Equal(t, uint64(0), *genesis.Timestamp())
|
||||
}
|
||||
|
||||
func TestZZConfigEqual(t *testing.T) {
|
||||
a := &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: zzTS(t0)}}
|
||||
same := &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: zzTS(t0)}}
|
||||
laterTS := &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: zzTS(t0 + 1)}}
|
||||
disabled := &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: zzTS(t0), Disable: true}}
|
||||
never := new(Config)
|
||||
|
||||
require.True(t, a.Equal(same), "identical activation configs are equal")
|
||||
require.True(t, a.Equal(a))
|
||||
require.False(t, a.Equal(laterTS), "a different activation timestamp is a different config")
|
||||
require.False(t, a.Equal(disabled), "the disable flag is part of config identity")
|
||||
require.False(t, a.Equal(never), "a set timestamp never equals an unset one")
|
||||
require.False(t, never.Equal(a))
|
||||
require.True(t, never.Equal(new(Config)), "two unset configs are equal")
|
||||
|
||||
// A config of a foreign type is never equal, whatever its Upgrade holds.
|
||||
require.False(t, a.Equal(&zzForeignConfig{Upgrade: precompileconfig.Upgrade{BlockTimestamp: zzTS(t0)}}))
|
||||
require.False(t, a.Equal(nil), "a nil Config is not equal to a real one")
|
||||
}
|
||||
|
||||
func TestZZConfigVerifyAcceptsAnyChain(t *testing.T) {
|
||||
// The HTLC takes no operator-supplied parameter, so there is nothing a chain
|
||||
// config could invalidate: Verify is total.
|
||||
require.NoError(t, new(Config).Verify(nil))
|
||||
require.NoError(t, (&Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: zzTS(t0), Disable: true}}).Verify(nil))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configurator.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestZZMakeConfigProducesFreshSwapConfig(t *testing.T) {
|
||||
cfgr := &configurator{}
|
||||
got := cfgr.MakeConfig()
|
||||
require.IsType(t, &Config{}, got)
|
||||
require.Equal(t, ConfigKey, got.Key())
|
||||
require.Nil(t, got.Timestamp())
|
||||
require.False(t, got.IsDisabled())
|
||||
|
||||
// Fresh every call — a shared instance would let one chain's unmarshalled
|
||||
// config bleed into another's.
|
||||
other := cfgr.MakeConfig()
|
||||
require.NotSame(t, got, other)
|
||||
require.True(t, got.Equal(other))
|
||||
}
|
||||
|
||||
func TestZZConfigureAcceptsOwnConfigRefusesForeign(t *testing.T) {
|
||||
cfgr := &configurator{}
|
||||
e := newEnv(t0)
|
||||
|
||||
require.NoError(t, cfgr.Configure(nil, new(Config), e.db(), e.st.blk))
|
||||
require.NoError(t, cfgr.Configure(nil, cfgr.MakeConfig(), e.db(), e.st.blk))
|
||||
|
||||
err := cfgr.Configure(nil, &zzForeignConfig{}, e.db(), e.st.blk)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "expected config type")
|
||||
|
||||
// Configure writes NOTHING: activation installs no state, so a chain that
|
||||
// activates the precompile twice cannot end up with a half-built ledger.
|
||||
require.Empty(t, e.db().storage)
|
||||
require.Empty(t, e.db().Logs())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registration. init() already ran when this binary loaded; these assertions
|
||||
// read the registry back to prove what it registered.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestZZModuleRegisteredAtLP90A0(t *testing.T) {
|
||||
byAddr, ok := modules.GetPrecompileModuleByAddress(swapAddr)
|
||||
require.True(t, ok, "init must have registered the module at LP-90A0")
|
||||
require.Equal(t, ConfigKey, byAddr.ConfigKey)
|
||||
|
||||
byKey, ok := modules.GetPrecompileModule(ConfigKey)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, swapAddr, byKey.Address)
|
||||
|
||||
require.Same(t, SwapPrecompile, byKey.Contract, "the registered contract is the singleton")
|
||||
var _ contract.StatefulPrecompiledContract = SwapPrecompile
|
||||
require.IsType(t, &configurator{}, byKey.Configurator)
|
||||
|
||||
// NOT always-on: the HTLC activates through a config entry like every other
|
||||
// value-bearing precompile, never implicitly at genesis.
|
||||
require.False(t, byKey.AlwaysOn)
|
||||
for _, m := range modules.AlwaysOnModules() {
|
||||
require.NotEqual(t, swapAddr, m.Address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZZDuplicateRegistrationRefused(t *testing.T) {
|
||||
// The registry is keyed by BOTH address and config key; registering the same
|
||||
// module twice must fail (and must not corrupt the registry that init built).
|
||||
before := len(modules.RegisteredModules())
|
||||
require.Error(t, modules.RegisterModule(Module))
|
||||
require.Len(t, modules.RegisteredModules(), before)
|
||||
|
||||
still, ok := modules.GetPrecompileModuleByAddress(swapAddr)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, ConfigKey, still.ConfigKey)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Address authority: the precompile lives at LP-90A0 and nowhere else.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestZZAddressIsLP90A0(t *testing.T) {
|
||||
require.Equal(t, common.HexToAddress(LXSwapAddress), swapAddr)
|
||||
require.Equal(t, strings.ToLower(LXSwapAddress), strings.ToLower(swapAddr.Hex()))
|
||||
require.Equal(t, swapAddr, Module.Address)
|
||||
require.True(t, modules.ReservedAddress(swapAddr), "LP-90A0 must sit inside a reserved range")
|
||||
require.NotEqual(t, modules.BlackholeAddr, swapAddr)
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package swap
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch: input shorter than a selector, and every selector that is not ours.
|
||||
// Both are refused BEFORE any state is read and charge nothing.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestZZShortInputRefused(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
for n := 0; n < contract4(); n++ {
|
||||
in := make([]byte, n)
|
||||
for i := range in {
|
||||
in[i] = 0xAA
|
||||
}
|
||||
ret, gas, err := e.c.Run(e.st, maker, swapAddr, in, e.gas, false)
|
||||
require.ErrorIsf(t, err, ErrShortInput, "input of %d bytes", n)
|
||||
require.Nil(t, ret)
|
||||
require.Equal(t, e.gas, gas, "a call rejected before dispatch consumes nothing")
|
||||
}
|
||||
// nil input is the degenerate short input.
|
||||
_, _, err := e.c.Run(e.st, maker, swapAddr, nil, e.gas, false)
|
||||
require.ErrorIs(t, err, ErrShortInput)
|
||||
require.Empty(t, e.db().storage, "no refused call may touch state")
|
||||
}
|
||||
|
||||
// contract4 keeps the selector width a single derived value rather than a
|
||||
// transcribed 4.
|
||||
func contract4() int { return 4 }
|
||||
|
||||
func TestZZUnknownSelectorRefused(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
known := map[uint32]bool{selLock: true, selClaim: true, selRefund: true, selGetSwap: true, selGetPreimage: true}
|
||||
|
||||
for _, sel := range []uint32{
|
||||
0x00000000, 0xffffffff, 0x12345678,
|
||||
selLock ^ 1, selClaim ^ 1, selRefund ^ 1, selGetSwap ^ 1, selGetPreimage ^ 1,
|
||||
// The keccak (not SHA-256) commit-reveal selectors from the same-chain
|
||||
// primitive must NOT be answered here.
|
||||
methodSelector("reveal(bytes32,bytes32)"),
|
||||
methodSelector("settle(bytes32)"),
|
||||
// An owner/pause/upgrade surface does not exist by design.
|
||||
methodSelector("pause()"),
|
||||
methodSelector("setOwner(address)"),
|
||||
methodSelector("setBalance(address,address,uint256)"),
|
||||
} {
|
||||
require.False(t, known[sel], "fixture selector %#08x collides with a real one", sel)
|
||||
in := append(selBytes(sel), make([]byte, 192)...)
|
||||
ret, gas, err := e.c.Run(e.st, maker, swapAddr, in, e.gas, false)
|
||||
require.ErrorIsf(t, err, ErrUnknownSelector, "selector %#08x", sel)
|
||||
require.Nil(t, ret)
|
||||
require.Equal(t, e.gas, gas)
|
||||
}
|
||||
require.Empty(t, e.db().storage)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A nil BlockContext is refused for EVERY selector, including the views: the
|
||||
// clock is what makes the claim and refund windows disjoint, so a call that
|
||||
// cannot read it must not proceed at all.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestZZNilBlockContextRefused(t *testing.T) {
|
||||
st := &zzState{db: newMockState(), blk: nil}
|
||||
c := &SwapContract{}
|
||||
h := hashOf(preimageOf(0x01))
|
||||
|
||||
for name, in := range map[string][]byte{
|
||||
"lock": zzWellFormedLock(),
|
||||
"claim": zzClaimInput(common.Hash{1}, preimageOf(0x01)),
|
||||
"refund": zzRefundInput(common.Hash{1}),
|
||||
"getSwap": zzGetSwapInput(common.Hash{1}),
|
||||
"getPreimage": zzGetPreimageInput(h),
|
||||
} {
|
||||
for _, ro := range []bool{false, true} {
|
||||
ret, gas, err := c.Run(st, maker, swapAddr, in, 10_000_000, ro)
|
||||
require.ErrorIsf(t, err, ErrNoBlockContext, "%s readOnly=%v", name, ro)
|
||||
require.Nil(t, ret)
|
||||
require.Equal(t, uint64(10_000_000), gas)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exact argument length. Every selector fixes its payload size, so there is no
|
||||
// input a caller can grow to buy unbounded work at the flat fee. One byte short
|
||||
// and one byte long are both refused, and the flat fee is still charged.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestZZArgLengthIsExactForEverySelector(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sel uint32
|
||||
want int
|
||||
gas uint64
|
||||
}{
|
||||
{"lock", selLock, 6 * 32, GasLock},
|
||||
{"claim", selClaim, 2 * 32, GasClaim},
|
||||
{"refund", selRefund, 32, GasRefund},
|
||||
{"getSwap", selGetSwap, 32, GasGetSwap},
|
||||
{"getPreimage", selGetPreimage, 32, GasGetPreimage},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
zzUnchanged(t, e.db(), func() {
|
||||
for _, n := range []int{0, 1, tc.want - 1, tc.want + 1, tc.want + 32, 4096} {
|
||||
in := append(selBytes(tc.sel), make([]byte, n)...)
|
||||
ret, gas, err := e.c.Run(e.st, maker, swapAddr, in, e.gas, false)
|
||||
require.ErrorIsf(t, err, ErrBadArgs, "%s with %d arg bytes (want exactly %d)", tc.name, n, tc.want)
|
||||
require.Nil(t, ret)
|
||||
require.Equalf(t, e.gas-tc.gas, gas,
|
||||
"%s must still charge its flat fee on a malformed call", tc.name)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gas. Each selector charges a flat fee up front; suppliedGas == fee is enough
|
||||
// (the boundary is `<`, not `<=`) and one wei of gas less is refused with the
|
||||
// whole budget consumed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestZZGasBoundaryPerSelector(t *testing.T) {
|
||||
amt := big.NewInt(1000)
|
||||
pre := preimageOf(0xB1)
|
||||
h := hashOf(pre)
|
||||
|
||||
// Build a live swap the claim/refund/view budgets can act on.
|
||||
build := func() (*env, common.Hash) {
|
||||
e := newEnv(t0)
|
||||
e.db().fund(usdc, maker, new(big.Int).Mul(amt, big.NewInt(4)))
|
||||
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
return e, id
|
||||
}
|
||||
|
||||
t.Run("lock", func(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
e.db().fund(usdc, maker, amt)
|
||||
in := zzLockInput(h, user, maker, usdc, amt, timeout)
|
||||
|
||||
zzUnchanged(t, e.db(), func() {
|
||||
_, gas, err := e.c.Run(e.st, maker, swapAddr, in, GasLock-1, false)
|
||||
require.ErrorIs(t, err, ErrOutOfGas)
|
||||
require.Zero(t, gas, "an out-of-gas precompile consumes the whole budget")
|
||||
})
|
||||
|
||||
ret, gas, err := e.c.Run(e.st, maker, swapAddr, in, GasLock, false)
|
||||
require.NoError(t, err, "suppliedGas == GasLock must be sufficient")
|
||||
require.Len(t, ret, 32)
|
||||
require.Zero(t, gas)
|
||||
})
|
||||
|
||||
t.Run("claim", func(t *testing.T) {
|
||||
e, id := build()
|
||||
in := zzClaimInput(id, pre)
|
||||
|
||||
_, gas, err := e.c.Run(e.st, watcher, swapAddr, in, GasClaim-1, false)
|
||||
require.ErrorIs(t, err, ErrOutOfGas)
|
||||
require.Zero(t, gas)
|
||||
require.Equal(t, StatusLocked, loadStatus(e.db(), id), "an out-of-gas claim settles nothing")
|
||||
zzEqBig(t, big.NewInt(0), e.db().bal(usdc, user))
|
||||
|
||||
_, gas, err = e.c.Run(e.st, watcher, swapAddr, in, GasClaim, false)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, gas)
|
||||
zzEqBig(t, amt, e.db().bal(usdc, user))
|
||||
})
|
||||
|
||||
t.Run("refund", func(t *testing.T) {
|
||||
e, id := build()
|
||||
e.setNow(timeout)
|
||||
in := zzRefundInput(id)
|
||||
|
||||
_, gas, err := e.c.Run(e.st, watcher, swapAddr, in, GasRefund-1, false)
|
||||
require.ErrorIs(t, err, ErrOutOfGas)
|
||||
require.Zero(t, gas)
|
||||
require.Equal(t, StatusLocked, loadStatus(e.db(), id))
|
||||
|
||||
_, gas, err = e.c.Run(e.st, watcher, swapAddr, in, GasRefund, false)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, gas)
|
||||
require.Equal(t, StatusRefunded, loadStatus(e.db(), id))
|
||||
})
|
||||
|
||||
t.Run("getSwap", func(t *testing.T) {
|
||||
e, id := build()
|
||||
in := zzGetSwapInput(id)
|
||||
|
||||
_, gas, err := e.c.Run(e.st, user, swapAddr, in, GasGetSwap-1, true)
|
||||
require.ErrorIs(t, err, ErrOutOfGas)
|
||||
require.Zero(t, gas)
|
||||
|
||||
ret, gas, err := e.c.Run(e.st, user, swapAddr, in, GasGetSwap, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, ret, 8*32)
|
||||
require.Zero(t, gas)
|
||||
})
|
||||
|
||||
t.Run("getPreimage", func(t *testing.T) {
|
||||
e, _ := build()
|
||||
in := zzGetPreimageInput(h)
|
||||
|
||||
_, gas, err := e.c.Run(e.st, user, swapAddr, in, GasGetPreimage-1, true)
|
||||
require.ErrorIs(t, err, ErrOutOfGas)
|
||||
require.Zero(t, gas)
|
||||
|
||||
ret, gas, err := e.c.Run(e.st, user, swapAddr, in, GasGetPreimage, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, ret, 32)
|
||||
require.Zero(t, gas)
|
||||
})
|
||||
}
|
||||
|
||||
// TestZZGasChargedOnSuccessIsTheFlatFee pins the exact fee each selector deducts
|
||||
// from a large budget, so a change to the constants shows up as a test failure
|
||||
// rather than as a silent repricing.
|
||||
func TestZZGasChargedOnSuccessIsTheFlatFee(t *testing.T) {
|
||||
amt := big.NewInt(1000)
|
||||
pre := preimageOf(0xB2)
|
||||
h := hashOf(pre)
|
||||
|
||||
e := newEnv(t0)
|
||||
// Three locks of `amt` happen below; fund for all of them. The mock reverts a
|
||||
// transferFrom the owner cannot cover, which would surface as ErrTransferFailed
|
||||
// and look like a product failure rather than a short fixture.
|
||||
e.db().fund(usdc, maker, new(big.Int).Mul(amt, big.NewInt(3)))
|
||||
|
||||
_, gas, err := e.c.Run(e.st, maker, swapAddr, zzLockInput(h, user, maker, usdc, amt, timeout), e.gas, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, e.gas-GasLock, gas)
|
||||
|
||||
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, gas, err = e.c.Run(e.st, user, swapAddr, zzGetSwapInput(id), e.gas, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, e.gas-GasGetSwap, gas)
|
||||
|
||||
_, gas, err = e.c.Run(e.st, user, swapAddr, zzGetPreimageInput(h), e.gas, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, e.gas-GasGetPreimage, gas)
|
||||
|
||||
_, gas, err = e.c.Run(e.st, watcher, swapAddr, zzClaimInput(id, pre), e.gas, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, e.gas-GasClaim, gas)
|
||||
|
||||
// The remaining swap expires and is refunded.
|
||||
id2, _, err := e.lock(maker, hashOf(preimageOf(0xB3)), user, maker, usdc, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
e.setNow(timeout)
|
||||
_, gas, err = e.c.Run(e.st, watcher, swapAddr, zzRefundInput(id2), e.gas, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, e.gas-GasRefund, gas)
|
||||
|
||||
// The fees are ordered by the work each does: lock (transferFrom + 7 cold
|
||||
// SSTOREs) > claim (transfer + 3 SSTOREs) > refund (transfer + 2 SSTOREs) >
|
||||
// the two read-only views.
|
||||
require.Greater(t, GasLock, GasClaim)
|
||||
require.Greater(t, GasClaim, GasRefund)
|
||||
require.Greater(t, GasRefund, GasGetSwap)
|
||||
require.Greater(t, GasGetSwap, GasGetPreimage)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// read-only (static) gating: the three transitions are refused, the two views
|
||||
// answer identically whether or not the frame is static.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestZZReadOnlyGatesTransitionsNotViews(t *testing.T) {
|
||||
amt := big.NewInt(1000)
|
||||
pre := preimageOf(0xC1)
|
||||
h := hashOf(pre)
|
||||
|
||||
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)
|
||||
|
||||
// Every transition, refused in a static frame with the full budget returned —
|
||||
// the refusal precedes the fee.
|
||||
for name, in := range map[string][]byte{
|
||||
"lock": zzWellFormedLock(),
|
||||
"claim": zzClaimInput(id, pre),
|
||||
"refund": zzRefundInput(id),
|
||||
} {
|
||||
ret, gas, err := e.c.Run(e.st, maker, swapAddr, in, e.gas, true)
|
||||
require.ErrorIsf(t, err, ErrReadOnly, "%s in a static frame", name)
|
||||
require.Nil(t, ret)
|
||||
require.Equal(t, e.gas, gas)
|
||||
}
|
||||
|
||||
// Views are answer-identical across the static flag.
|
||||
swapRO, err := e.getSwap(id, true)
|
||||
require.NoError(t, err)
|
||||
swapRW, err := e.getSwap(id, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, swapRO, swapRW)
|
||||
|
||||
preRO, err := e.getPreimage(h, true)
|
||||
require.NoError(t, err)
|
||||
preRW, err := e.getPreimage(h, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, preRO, preRW)
|
||||
require.Equal(t, pre, preRO)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reentrancy guard: one global flag, taken by every transition and released on
|
||||
// EVERY exit path. A guard that leaked on an error would brick the precompile.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestZZGuardBlocksTransitionsAndAdmitsViews(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
amt := big.NewInt(1000)
|
||||
e.db().fund(usdc, maker, amt)
|
||||
pre := preimageOf(0xD1)
|
||||
h := hashOf(pre)
|
||||
|
||||
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Hold the guard as an outer frame would.
|
||||
require.True(t, enterGuard(e.db()))
|
||||
require.False(t, enterGuard(e.db()), "the guard is not re-entrant with itself")
|
||||
|
||||
for name, call := range map[string]func() error{
|
||||
"lock": func() error {
|
||||
_, _, err := e.lock(maker, hashOf(preimageOf(0xD2)), user, maker, usdc, amt, timeout, false)
|
||||
return err
|
||||
},
|
||||
"claim": func() error { _, err := e.claim(user, id, pre, false); return err },
|
||||
"refund": func() error { _, err := e.refund(maker, id, false); return err },
|
||||
} {
|
||||
require.ErrorIsf(t, call(), ErrReentrant, "%s must be refused while the guard is held", name)
|
||||
}
|
||||
|
||||
// Views stay callable under the guard — a watchtower reading the chain must
|
||||
// never be blocked by an unrelated in-flight settlement.
|
||||
require.Equal(t, StatusLocked, zzStatus(t, e, id))
|
||||
got, err := e.getPreimage(h, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, [preimageLen]byte{}, got)
|
||||
|
||||
// Released, the same calls succeed.
|
||||
exitGuard(e.db())
|
||||
require.True(t, enterGuard(e.db()))
|
||||
exitGuard(e.db())
|
||||
_, err = e.claim(user, id, pre, false)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestZZGuardReleasedAfterEveryFailedTransition(t *testing.T) {
|
||||
amt := big.NewInt(1000)
|
||||
pre := preimageOf(0xD3)
|
||||
h := hashOf(pre)
|
||||
|
||||
// Each entry drives a transition that fails somewhere inside the guarded
|
||||
// region; afterwards the guard must be clear and an honest lock must work.
|
||||
fail := map[string]func(e *env){
|
||||
"lock_reverting_token": func(e *env) {
|
||||
e.db().failTransfer[wbtc] = true
|
||||
e.db().fund(wbtc, maker, amt)
|
||||
_, _, err := e.lock(maker, h, user, maker, wbtc, amt, timeout, false)
|
||||
require.ErrorIs(t, err, ErrTransferFailed)
|
||||
},
|
||||
"lock_bad_args": func(e *env) {
|
||||
_, _, err := e.c.Run(e.st, maker, swapAddr, append(selBytes(selLock), 1, 2, 3), e.gas, false)
|
||||
require.ErrorIs(t, err, ErrBadArgs)
|
||||
},
|
||||
"lock_dust": func(e *env) {
|
||||
_, _, err := e.lock(maker, h, user, maker, usdc, big.NewInt(0), timeout, false)
|
||||
require.ErrorIs(t, err, ErrDustAmount)
|
||||
},
|
||||
"claim_unknown_swap": func(e *env) {
|
||||
_, err := e.claim(user, common.Hash{0xEE}, pre, false)
|
||||
require.ErrorIs(t, err, ErrNotLocked)
|
||||
},
|
||||
"refund_unknown_swap": func(e *env) {
|
||||
_, err := e.refund(user, common.Hash{0xEE}, false)
|
||||
require.ErrorIs(t, err, ErrNotLocked)
|
||||
},
|
||||
"claim_out_of_gas": func(e *env) {
|
||||
_, _, err := e.c.Run(e.st, user, swapAddr, zzClaimInput(common.Hash{0xEE}, pre), GasClaim-1, false)
|
||||
require.ErrorIs(t, err, ErrOutOfGas)
|
||||
},
|
||||
}
|
||||
|
||||
for name, drive := range fail {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
e.db().fund(usdc, maker, amt)
|
||||
drive(e)
|
||||
|
||||
require.Zero(t, e.db().GetState(swapAddr, guardSlot())[31], "guard leaked after a failed transition")
|
||||
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
||||
require.NoError(t, err, "a leaked guard would brick every later settlement")
|
||||
require.Equal(t, StatusLocked, loadStatus(e.db(), id))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestZZReentrantClaimDuringLockRefused drives the real attack shape: a malicious
|
||||
// token calls back into the precompile from inside transferFrom, while the outer
|
||||
// lock has measured its inbound delta but not yet written the swap.
|
||||
func TestZZReentrantClaimDuringLockRefused(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
amt := big.NewInt(1000)
|
||||
e.db().fund(usdc, maker, new(big.Int).Mul(amt, big.NewInt(3)))
|
||||
pre := preimageOf(0xD4)
|
||||
h := hashOf(pre)
|
||||
|
||||
victim, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
var claimErr, refundErr, lockErr error
|
||||
e.db().reenter = func() {
|
||||
_, claimErr = e.claim(user, victim, pre, false)
|
||||
_, refundErr = e.refund(maker, victim, false)
|
||||
_, _, lockErr = e.lock(maker, hashOf(preimageOf(0xD5)), user, maker, usdc, amt, timeout, false)
|
||||
}
|
||||
|
||||
outer, _, err := e.lock(maker, hashOf(preimageOf(0xD6)), user, maker, usdc, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
require.ErrorIs(t, claimErr, ErrReentrant)
|
||||
require.ErrorIs(t, refundErr, ErrReentrant)
|
||||
require.ErrorIs(t, lockErr, ErrReentrant)
|
||||
|
||||
// The victim is untouched and the outer lock landed exactly once.
|
||||
require.Equal(t, StatusLocked, loadStatus(e.db(), victim))
|
||||
require.Equal(t, StatusLocked, loadStatus(e.db(), outer))
|
||||
zzEqBig(t, big.NewInt(0), e.db().bal(usdc, user))
|
||||
zzEqBig(t, new(big.Int).Mul(amt, big.NewInt(2)), e.reserve(usdc))
|
||||
zzConserved(t, e, usdc)
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package swap
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// subReserve: the conservation guard. Under the invariant it never fires, so a
|
||||
// false return means the trie is corrupt — and settlement must ABORT rather than
|
||||
// wrap the reserve around 2^256.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestZZSubReserveRefusesUnderflow(t *testing.T) {
|
||||
m := newMockState()
|
||||
storeReserve(m, usdc, big.NewInt(100))
|
||||
|
||||
require.False(t, subReserve(m, usdc, big.NewInt(101)), "debiting more than held must refuse")
|
||||
zzEqBig(t, big.NewInt(100), loadReserve(m, usdc), "a refused debit leaves the reserve intact")
|
||||
|
||||
// The boundary: exactly the balance is debitable, one more is not.
|
||||
require.True(t, subReserve(m, usdc, big.NewInt(100)))
|
||||
zzEqBig(t, big.NewInt(0), loadReserve(m, usdc))
|
||||
require.False(t, subReserve(m, usdc, big.NewInt(1)))
|
||||
|
||||
// An empty reserve for an untouched asset refuses any debit.
|
||||
require.False(t, subReserve(m, wbtc, big.NewInt(1)))
|
||||
require.True(t, subReserve(m, wbtc, big.NewInt(0)), "a zero debit is trivially satisfiable")
|
||||
|
||||
// Partial debits compose exactly.
|
||||
storeReserve(m, wbtc, big.NewInt(10))
|
||||
require.True(t, subReserve(m, wbtc, big.NewInt(3)))
|
||||
require.True(t, subReserve(m, wbtc, big.NewInt(7)))
|
||||
require.False(t, subReserve(m, wbtc, big.NewInt(1)))
|
||||
zzEqBig(t, big.NewInt(0), loadReserve(m, wbtc))
|
||||
}
|
||||
|
||||
// TestZZSettlementAbortsOnReserveUnderflow drives the guard through Run: a Locked
|
||||
// swap whose amount exceeds the reserve backing it (only reachable via a corrupt
|
||||
// trie) must refuse to settle rather than pay out value it never took in.
|
||||
func TestZZSettlementAbortsOnReserveUnderflow(t *testing.T) {
|
||||
pre := preimageOf(0xF1)
|
||||
h := hashOf(pre)
|
||||
amt := big.NewInt(1000)
|
||||
|
||||
t.Run("claim", func(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
id := common.Hash{0xF1}
|
||||
zzSeedLocked(e.db(), id, h, user, maker, usdc, amt, timeout)
|
||||
// No reserve credited, and the vault genuinely holds nothing.
|
||||
e.db().fund(usdc, swapAddr, amt) // even WITH tokens present, the ledger governs
|
||||
|
||||
_, err := e.claim(watcher, id, pre, false)
|
||||
require.ErrorIs(t, err, ErrReserveUnderflow)
|
||||
zzEqBig(t, big.NewInt(0), e.db().bal(usdc, user), "an unbacked swap pays nobody")
|
||||
zzEqBig(t, amt, e.db().bal(usdc, swapAddr))
|
||||
require.Equal(t, StatusLocked, loadStatus(e.db(), id), "and stays unsettled")
|
||||
})
|
||||
|
||||
t.Run("refund", func(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
id := common.Hash{0xF2}
|
||||
zzSeedLocked(e.db(), id, h, user, maker, usdc, amt, timeout)
|
||||
addReserve(e.db(), usdc, new(big.Int).Sub(amt, big.NewInt(1))) // one short
|
||||
e.setNow(timeout)
|
||||
|
||||
_, err := e.refund(maker, id, false)
|
||||
require.ErrorIs(t, err, ErrReserveUnderflow)
|
||||
zzEqBig(t, big.NewInt(0), e.db().bal(usdc, maker))
|
||||
require.Equal(t, StatusLocked, loadStatus(e.db(), id))
|
||||
})
|
||||
|
||||
t.Run("exactly_backed_settles", func(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
id := common.Hash{0xF3}
|
||||
zzSeedLocked(e.db(), id, h, user, maker, usdc, amt, timeout)
|
||||
addReserve(e.db(), usdc, amt) // exactly backed
|
||||
e.db().fund(usdc, swapAddr, amt)
|
||||
|
||||
_, err := e.claim(watcher, id, pre, false)
|
||||
require.NoError(t, err, "the guard must not refuse an exactly-backed swap")
|
||||
zzEqBig(t, amt, e.db().bal(usdc, user))
|
||||
zzEqBig(t, big.NewInt(0), e.reserve(usdc))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wordUint64: a uint64 read out of a right-aligned 32-byte ABI word. Any value
|
||||
// that does not fit is refused rather than truncated — a silently truncated
|
||||
// timeout would move a swap's expiry.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestZZWordUint64RejectsOversizeWord(t *testing.T) {
|
||||
// Every one of the 24 high bytes is checked, not just the first.
|
||||
for i := 0; i < 24; i++ {
|
||||
w := make([]byte, 32)
|
||||
w[i] = 0x01
|
||||
_, ok := wordUint64(w)
|
||||
require.Falsef(t, ok, "a non-zero byte at index %d must be refused", i)
|
||||
}
|
||||
|
||||
// The low 8 bytes are the value, and all of them count.
|
||||
for i := 24; i < 32; i++ {
|
||||
w := make([]byte, 32)
|
||||
w[i] = 0x01
|
||||
v, ok := wordUint64(w)
|
||||
require.Truef(t, ok, "byte %d is part of the uint64", i)
|
||||
require.Equal(t, uint64(1)<<(8*(31-i)), v)
|
||||
}
|
||||
|
||||
// Boundaries.
|
||||
zero := make([]byte, 32)
|
||||
v, ok := wordUint64(zero)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, uint64(0), v)
|
||||
|
||||
maxWord := make([]byte, 32)
|
||||
for i := 24; i < 32; i++ {
|
||||
maxWord[i] = 0xFF
|
||||
}
|
||||
v, ok = wordUint64(maxWord)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, uint64(math.MaxUint64), v)
|
||||
|
||||
// One past uint64: the byte just above the value window.
|
||||
overflow := make([]byte, 32)
|
||||
overflow[23] = 0x01
|
||||
_, ok = wordUint64(overflow)
|
||||
require.False(t, ok, "2^64 must not truncate to 0")
|
||||
|
||||
allOnes := make([]byte, 32)
|
||||
for i := range allOnes {
|
||||
allOnes[i] = 0xFF
|
||||
}
|
||||
_, ok = wordUint64(allOnes)
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
// TestZZOversizeTimeoutRefusedAtLock drives the same guard through Run. Without
|
||||
// it, a timeout word of 2^64 would truncate to 0 and be read as a past expiry.
|
||||
func TestZZOversizeTimeoutRefusedAtLock(t *testing.T) {
|
||||
amt := big.NewInt(1000)
|
||||
h := hashOf(preimageOf(0xF4))
|
||||
|
||||
for i := 0; i < 24; i++ {
|
||||
e := newEnv(t0)
|
||||
e.db().fund(usdc, maker, amt)
|
||||
|
||||
in := zzLockInput(h, user, maker, usdc, amt, timeout)
|
||||
in[4+160+i] = 0x01 // poison one high byte of the timeout word
|
||||
|
||||
zzUnchanged(t, e.db(), func() {
|
||||
_, _, err := e.c.Run(e.st, maker, swapAddr, in, e.gas, false)
|
||||
require.ErrorIsf(t, err, ErrBadArgs, "high byte %d of the timeout word", i)
|
||||
require.ErrorContains(t, err, "timeout exceeds uint64")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timeout bounds, including the uint64 overflow at swap.go:237. The check is
|
||||
//
|
||||
// timeout < t0+MinTimeout || timeout > t0+MaxTimeout
|
||||
//
|
||||
// computed in wrapping uint64 arithmetic with NO overflow guard.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestZZTimeoutBoundsAreInclusive(t *testing.T) {
|
||||
amt := big.NewInt(1000)
|
||||
h := hashOf(preimageOf(0xF5))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
timeout uint64
|
||||
ok bool
|
||||
}{
|
||||
{"one_below_min", t0 + MinTimeout - 1, false},
|
||||
{"exactly_min", t0 + MinTimeout, true},
|
||||
{"one_above_min", t0 + MinTimeout + 1, true},
|
||||
{"midrange", t0 + MinTimeout + (MaxTimeout-MinTimeout)/2, true},
|
||||
{"one_below_max", t0 + MaxTimeout - 1, true},
|
||||
{"exactly_max", t0 + MaxTimeout, true},
|
||||
{"one_above_max", t0 + MaxTimeout + 1, false},
|
||||
{"zero", 0, false},
|
||||
{"equal_to_now", t0, false},
|
||||
}
|
||||
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, h, user, maker, usdc, amt, tc.timeout, false)
|
||||
if tc.ok {
|
||||
require.NoError(t, err)
|
||||
zzEqBig(t, amt, e.reserve(usdc))
|
||||
return
|
||||
}
|
||||
require.ErrorIs(t, err, ErrTimeoutBounds)
|
||||
zzEqBig(t, big.NewInt(0), e.reserve(usdc), "a refused lock takes no custody")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestZZTimeoutBoundsOverflowNearUint64Max documents the ACTUAL behaviour of the
|
||||
// unguarded `t0+MinTimeout` / `t0+MaxTimeout` additions at swap.go:237.
|
||||
//
|
||||
// Two regimes exist above 2^64 − MaxTimeout:
|
||||
//
|
||||
// - BOTH sums wrap (t0 > 2^64 − MinTimeout): the lower bound wraps to a small
|
||||
// number, so `timeout < t0+MinTimeout` stops rejecting past timeouts and a
|
||||
// lock is admitted whose expiry is ALREADY PAST. It is refundable on the
|
||||
// spot and never claimable — the ≥10-minute window the bound exists to
|
||||
// guarantee is gone.
|
||||
// - ONLY the upper sum wraps: the admissible interval inverts to the empty set
|
||||
// and EVERY timeout is refused, so no native or token lock is possible at all.
|
||||
//
|
||||
// Both need a block timestamp within ~30 days of 2^64 seconds (~584 billion
|
||||
// years), so neither is reachable on a real chain. Pinned so the boundary
|
||||
// behaviour is a decision rather than an accident.
|
||||
func TestZZTimeoutBoundsOverflowNearUint64Max(t *testing.T) {
|
||||
amt := big.NewInt(1000)
|
||||
pre := preimageOf(0xF6)
|
||||
h := hashOf(pre)
|
||||
|
||||
t.Run("both_sums_wrap_admits_a_past_timeout", func(t *testing.T) {
|
||||
// t0 chosen so t0+MinTimeout wraps to exactly 0.
|
||||
now := uint64(math.MaxUint64) - MinTimeout + 1
|
||||
require.Equal(t, uint64(0), now+MinTimeout, "lower bound wraps to zero")
|
||||
upper := now + MaxTimeout
|
||||
require.Less(t, upper, now, "upper bound wraps too")
|
||||
|
||||
past := uint64(1000) // astronomically BEFORE `now`
|
||||
require.Less(t, past, now)
|
||||
require.LessOrEqual(t, past, upper, "and lands inside the wrapped window")
|
||||
|
||||
e := newEnv(now)
|
||||
e.db().fund(usdc, maker, amt)
|
||||
id, _, err := e.lock(maker, h, user, maker, usdc, amt, past, false)
|
||||
require.NoError(t, err, "the unguarded addition ADMITS a timeout in the past")
|
||||
|
||||
// The consequence: born expired. Never claimable, refundable immediately.
|
||||
_, err = e.claim(user, id, pre, false)
|
||||
require.ErrorIs(t, err, ErrExpired, "a swap admitted this way can never be claimed")
|
||||
|
||||
_, err = e.refund(maker, id, false)
|
||||
require.NoError(t, err, "and is refundable in the very block it was created")
|
||||
zzEqBig(t, amt, e.db().bal(usdc, maker))
|
||||
zzConserved(t, e, usdc)
|
||||
})
|
||||
|
||||
t.Run("only_upper_sum_wraps_refuses_everything", func(t *testing.T) {
|
||||
now := uint64(math.MaxUint64) - MaxTimeout + 1
|
||||
require.Greater(t, now+MinTimeout, now, "lower bound does NOT wrap here")
|
||||
require.Less(t, now+MaxTimeout, now, "upper bound does")
|
||||
|
||||
e := newEnv(now)
|
||||
e.db().fund(usdc, maker, amt)
|
||||
for _, to := range []uint64{0, 1000, now, now + MinTimeout, now + MinTimeout + 1, math.MaxUint64} {
|
||||
_, _, err := e.lock(maker, h, user, maker, usdc, amt, to, false)
|
||||
require.ErrorIsf(t, err, ErrTimeoutBounds, "timeout %d must be refused in the inverted window", to)
|
||||
}
|
||||
zzEqBig(t, big.NewInt(0), e.reserve(usdc))
|
||||
})
|
||||
|
||||
t.Run("just_below_the_wrap_still_sane", func(t *testing.T) {
|
||||
// One second below the wrap point, arithmetic is exact and the bounds behave.
|
||||
now := uint64(math.MaxUint64) - MaxTimeout
|
||||
require.Greater(t, now+MaxTimeout, now)
|
||||
|
||||
e := newEnv(now)
|
||||
e.db().fund(usdc, maker, new(big.Int).Mul(amt, big.NewInt(2)))
|
||||
_, _, err := e.lock(maker, h, user, maker, usdc, amt, now+MinTimeout-1, false)
|
||||
require.ErrorIs(t, err, ErrTimeoutBounds)
|
||||
_, _, err = e.lock(maker, h, user, maker, usdc, amt, now+MinTimeout, false)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestZZDustGuardBoundary pins the floor exactly: MinSwapAmount is admissible,
|
||||
// one below it is not, and a zero amount never takes custody.
|
||||
func TestZZDustGuardBoundary(t *testing.T) {
|
||||
h := hashOf(preimageOf(0xF7))
|
||||
require.Equal(t, 0, MinSwapAmount.Cmp(big.NewInt(1)), "the global floor is 1")
|
||||
|
||||
below := new(big.Int).Sub(MinSwapAmount, big.NewInt(1))
|
||||
e := newEnv(t0)
|
||||
e.db().fund(usdc, maker, big.NewInt(1_000))
|
||||
_, _, err := e.lock(maker, h, user, maker, usdc, below, timeout, false)
|
||||
require.ErrorIs(t, err, ErrDustAmount)
|
||||
zzEqBig(t, big.NewInt(0), e.reserve(usdc))
|
||||
|
||||
_, _, err = e.lock(maker, h, user, maker, usdc, MinSwapAmount, timeout, false)
|
||||
require.NoError(t, err, "exactly MinSwapAmount is lockable")
|
||||
zzEqBig(t, MinSwapAmount, e.reserve(usdc))
|
||||
|
||||
// Native honours the same floor.
|
||||
e2 := newEnv(t0)
|
||||
_, _, err = e2.lock(maker, h, user, maker, native, big.NewInt(0), timeout, false)
|
||||
require.ErrorIs(t, err, ErrDustAmount, "the dust floor precedes the delta measurement")
|
||||
zzEqBig(t, big.NewInt(0), e2.reserve(native))
|
||||
}
|
||||
|
||||
// TestZZZeroAddressGuards covers the three non-zero requirements independently,
|
||||
// including the case where the SAME address fills both roles (legal) and where
|
||||
// the asset is zero (legal — that is native, not a missing asset).
|
||||
func TestZZZeroAddressGuards(t *testing.T) {
|
||||
amt := big.NewInt(1000)
|
||||
h := hashOf(preimageOf(0xF8))
|
||||
|
||||
t.Run("zero_hashlock", func(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
e.db().fund(usdc, maker, amt)
|
||||
_, _, err := e.lock(maker, common.Hash{}, user, maker, usdc, amt, timeout, false)
|
||||
require.ErrorIs(t, err, ErrZeroHashlock)
|
||||
})
|
||||
|
||||
t.Run("zero_recipient", func(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
e.db().fund(usdc, maker, amt)
|
||||
_, _, err := e.lock(maker, h, common.Address{}, maker, usdc, amt, timeout, false)
|
||||
require.ErrorIs(t, err, ErrZeroRecipient, "a swap must never be claimable into the burn address")
|
||||
})
|
||||
|
||||
t.Run("zero_refund", func(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
e.db().fund(usdc, maker, amt)
|
||||
_, _, err := e.lock(maker, h, user, common.Address{}, usdc, amt, timeout, false)
|
||||
require.ErrorIs(t, err, ErrZeroRefund, "an expired swap must never burn its refund")
|
||||
})
|
||||
|
||||
t.Run("zero_asset_is_native_not_a_refusal", func(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
e.db().fundNative(swapAddr, amt)
|
||||
_, _, err := e.lock(maker, h, user, maker, common.Address{}, amt, timeout, false)
|
||||
require.NoError(t, err, "asset == address(0) selects native LUX")
|
||||
zzEqBig(t, amt, e.reserve(native))
|
||||
})
|
||||
|
||||
t.Run("recipient_equal_to_refund_is_legal", func(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
e.db().fund(usdc, maker, amt)
|
||||
_, _, err := e.lock(maker, h, maker, maker, usdc, amt, timeout, false)
|
||||
require.NoError(t, err, "a self-swap is degenerate but not malformed")
|
||||
})
|
||||
|
||||
// The guards are checked in a fixed order, so the FIRST offending field is the
|
||||
// one reported even when several are zero.
|
||||
e := newEnv(t0)
|
||||
_, _, err := e.lock(maker, common.Hash{}, common.Address{}, common.Address{}, usdc, big.NewInt(0), 0, false)
|
||||
require.ErrorIs(t, err, ErrZeroHashlock)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getSwap: the full tuple, word by word, before and after settlement.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestZZGetSwapTupleLayout(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
amt := big.NewInt(0xDEADBEEF)
|
||||
pre := preimageOf(0xF9)
|
||||
h := hashOf(pre)
|
||||
// Two locks happen below (amt, then MinSwapAmount); fund for both.
|
||||
e.db().fund(usdc, maker, new(big.Int).Add(amt, MinSwapAmount))
|
||||
|
||||
// An unknown swap is an all-zero tuple — status None and nothing else.
|
||||
unknown, err := e.getSwap(common.Hash{0xAB}, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, unknown, 8*32)
|
||||
require.Equal(t, make([]byte, 8*32), unknown, "a nonexistent swap reads as all zeroes")
|
||||
require.Equal(t, StatusNone, unknown[31])
|
||||
|
||||
id, _, err := e.lock(maker, h, user, watcher, usdc, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
out, err := e.getSwap(id, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 8*32)
|
||||
require.Equal(t, StatusLocked, out[31])
|
||||
require.Equal(t, h, common.BytesToHash(out[1*32:2*32]))
|
||||
require.Equal(t, user, common.BytesToAddress(out[2*32:3*32]))
|
||||
require.Equal(t, watcher, common.BytesToAddress(out[3*32:4*32]))
|
||||
require.Equal(t, usdc, common.BytesToAddress(out[4*32:5*32]))
|
||||
zzEqBig(t, amt, new(big.Int).SetBytes(out[5*32:6*32]))
|
||||
require.Equal(t, timeout, new(big.Int).SetBytes(out[6*32:7*32]).Uint64())
|
||||
require.Equal(t, make([]byte, 32), out[7*32:8*32], "no preimage before the claim")
|
||||
|
||||
// After the claim only status and preimage change; the terms are immutable.
|
||||
_, err = e.claim(watcher, id, pre, false)
|
||||
require.NoError(t, err)
|
||||
after, err := e.getSwap(id, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, StatusClaimed, after[31])
|
||||
require.Equal(t, pre[:], after[7*32:8*32])
|
||||
require.Equal(t, out[1*32:7*32], after[1*32:7*32], "settlement must not rewrite the terms")
|
||||
|
||||
// A refunded swap records no preimage.
|
||||
id2, _, err := e.lock(maker, hashOf(preimageOf(0xFA)), user, watcher, usdc, MinSwapAmount, timeout, false)
|
||||
require.NoError(t, err)
|
||||
e.setNow(timeout)
|
||||
_, err = e.refund(maker, id2, false)
|
||||
require.NoError(t, err)
|
||||
ref, err := e.getSwap(id2, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, StatusRefunded, ref[31])
|
||||
require.Equal(t, make([]byte, 32), ref[7*32:8*32], "a refund reveals no secret")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Storage layout: distinct keys never collide, and the singleton slots are
|
||||
// distinct from every per-swap field slot.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestZZStorageSlotsAreDistinct(t *testing.T) {
|
||||
idA, idB := common.Hash{0x01}, common.Hash{0x02}
|
||||
seen := map[common.Hash]string{}
|
||||
add := func(name string, slot common.Hash) {
|
||||
prev, dup := seen[slot]
|
||||
require.Falsef(t, dup, "%s collides with %s", name, prev)
|
||||
seen[slot] = name
|
||||
}
|
||||
|
||||
for f := byte(fStatus); f <= fPreimage; f++ {
|
||||
add("A/field", swapFieldSlot(idA, f))
|
||||
add("B/field", swapFieldSlot(idB, f))
|
||||
}
|
||||
add("nonce", nonceSlot())
|
||||
add("guard", guardSlot())
|
||||
add("preimageIdx/A", preimageIdxSlot(idA))
|
||||
add("preimageIdx/B", preimageIdxSlot(idB))
|
||||
add("reserve/usdc", reserveSlot(usdc))
|
||||
add("reserve/wbtc", reserveSlot(wbtc))
|
||||
add("reserve/native", reserveSlot(native))
|
||||
|
||||
require.Len(t, seen, 8*2+2+2+3)
|
||||
|
||||
// Deterministic: the same key always yields the same slot.
|
||||
require.Equal(t, swapFieldSlot(idA, fAmount), swapFieldSlot(idA, fAmount))
|
||||
require.Equal(t, reserveSlot(usdc), reserveSlot(usdc))
|
||||
}
|
||||
|
||||
// TestZZFieldRoundTrips pins each accessor pair against the values that actually
|
||||
// occur, including the extremes a 32-byte word can hold.
|
||||
func TestZZFieldRoundTrips(t *testing.T) {
|
||||
m := newMockState()
|
||||
id := common.Hash{0x77}
|
||||
|
||||
for _, s := range []uint8{StatusNone, StatusLocked, StatusClaimed, StatusRefunded, 255} {
|
||||
storeStatus(m, id, s)
|
||||
require.Equal(t, s, loadStatus(m, id))
|
||||
}
|
||||
|
||||
maxU256 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
|
||||
for _, v := range []*big.Int{big.NewInt(0), big.NewInt(1), big.NewInt(1 << 62), maxU256} {
|
||||
storeAmount(m, id, fAmount, v)
|
||||
zzEqBig(t, v, loadAmount(m, id, fAmount))
|
||||
}
|
||||
|
||||
for _, v := range []uint64{0, 1, t0, timeout, math.MaxUint64} {
|
||||
storeTimeout(m, id, v)
|
||||
require.Equal(t, v, loadTimeout(m, id))
|
||||
}
|
||||
|
||||
for _, a := range []common.Address{{}, maker, user, usdc, swapAddr} {
|
||||
storeAddress(m, id, fRecipient, a)
|
||||
require.Equal(t, a, loadAddress(m, id, fRecipient))
|
||||
}
|
||||
|
||||
for _, n := range []uint64{0, 1, math.MaxUint64} {
|
||||
storeNonce(m, n)
|
||||
require.Equal(t, n, loadNonce(m))
|
||||
}
|
||||
|
||||
for _, p := range [][preimageLen]byte{{}, preimageOf(0xFF), preimageOf(0x01)} {
|
||||
storePreimageIdx(m, common.Hash{0x99}, p)
|
||||
require.Equal(t, p, loadPreimageIdx(m, common.Hash{0x99}))
|
||||
}
|
||||
|
||||
// Reserve arithmetic composes over the accessors.
|
||||
storeReserve(m, usdc, big.NewInt(0))
|
||||
addReserve(m, usdc, big.NewInt(70))
|
||||
addReserve(m, usdc, big.NewInt(30))
|
||||
zzEqBig(t, big.NewInt(100), loadReserve(m, usdc))
|
||||
}
|
||||
|
||||
// TestZZWordHelpers covers the ABI encoders the views return.
|
||||
func TestZZWordHelpers(t *testing.T) {
|
||||
require.Equal(t, append(make([]byte, 31), 1), boolWord(true))
|
||||
require.Equal(t, make([]byte, 32), boolWord(false))
|
||||
require.Equal(t, append(make([]byte, 31), 42), uint8Word(42))
|
||||
require.Equal(t, make([]byte, 32), uint8Word(0))
|
||||
|
||||
w := uint64Word(math.MaxUint64)
|
||||
require.Len(t, w, 32)
|
||||
require.Equal(t, make([]byte, 24), w[:24], "a uint64 is right-aligned in the word")
|
||||
require.Equal(t, uint64(math.MaxUint64), new(big.Int).SetBytes(w).Uint64())
|
||||
|
||||
require.Equal(t, common.BytesToHash(usdc.Bytes()).Bytes(), addressWord(usdc))
|
||||
require.Equal(t, make([]byte, 32), addressWord(common.Address{}))
|
||||
|
||||
// wordAddress reads the low 20 bytes and ignores the padding.
|
||||
padded := make([]byte, 32)
|
||||
copy(padded[12:], usdc.Bytes())
|
||||
require.Equal(t, usdc, wordAddress(padded))
|
||||
dirty := make([]byte, 32)
|
||||
copy(dirty[12:], usdc.Bytes())
|
||||
for i := 0; i < 12; i++ {
|
||||
dirty[i] = 0xFF
|
||||
}
|
||||
require.Equal(t, usdc, wordAddress(dirty), "high padding is not part of the address")
|
||||
}
|
||||
|
||||
// TestZZPreimageMatches pins the hashlock predicate directly.
|
||||
func TestZZPreimageMatches(t *testing.T) {
|
||||
p := preimageOf(0x5E)
|
||||
require.True(t, preimageMatches(p, hashOf(p)))
|
||||
require.False(t, preimageMatches(p, common.Hash{}))
|
||||
require.False(t, preimageMatches(p, hashOf(preimageOf(0x5F))))
|
||||
require.False(t, preimageMatches([preimageLen]byte{}, hashOf(p)))
|
||||
require.True(t, preimageMatches([preimageLen]byte{}, hashOf([preimageLen]byte{})))
|
||||
|
||||
// A single flipped bit anywhere in the secret breaks the match.
|
||||
for i := 0; i < preimageLen; i++ {
|
||||
var q [preimageLen]byte
|
||||
copy(q[:], p[:])
|
||||
q[i] ^= 0x01
|
||||
require.Falsef(t, preimageMatches(q, hashOf(p)), "bit flip at byte %d must not match", i)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZZGuardPrimitives pins enterGuard/exitGuard as a pair.
|
||||
func TestZZGuardPrimitives(t *testing.T) {
|
||||
m := newMockState()
|
||||
require.True(t, enterGuard(m), "a fresh guard is takeable")
|
||||
require.False(t, enterGuard(m), "a held guard refuses")
|
||||
require.False(t, enterGuard(m), "and keeps refusing")
|
||||
|
||||
exitGuard(m)
|
||||
require.True(t, enterGuard(m), "a released guard is takeable again")
|
||||
exitGuard(m)
|
||||
|
||||
// Only the low byte carries the flag; a dirty high word still reads as held.
|
||||
var dirty common.Hash
|
||||
dirty[0] = 0xFF
|
||||
dirty[31] = 1
|
||||
m.SetState(swapAddr, guardSlot(), dirty)
|
||||
require.False(t, enterGuard(m))
|
||||
exitGuard(m)
|
||||
require.Equal(t, common.Hash{}, m.GetState(swapAddr, guardSlot()), "exit clears the whole word")
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package swap
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CONSERVATION. reserve[asset] is the precompile's own ledger of what it owes.
|
||||
// It must equal what the vault actually holds at every observable instant, for
|
||||
// both asset kinds, and it must never be credited by anything but an OBSERVED
|
||||
// inbound delta.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestZZConservationAcrossMixedSequence drives a long deterministic sequence
|
||||
// mixing both asset kinds and all three transitions, asserting the invariant
|
||||
// after EVERY step rather than only at the end.
|
||||
func TestZZConservationAcrossMixedSequence(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
e.db().fund(usdc, maker, big.NewInt(1_000_000))
|
||||
e.db().fund(wbtc, user, big.NewInt(1_000_000))
|
||||
|
||||
check := func(step string) {
|
||||
zzEqBig(t, e.reserve(usdc), e.db().bal(usdc, swapAddr), "usdc reserve vs vault after %s", step)
|
||||
zzEqBig(t, e.reserve(wbtc), e.db().bal(wbtc, swapAddr), "wbtc reserve vs vault after %s", step)
|
||||
zzEqBig(t, e.reserve(native), e.db().nativeBal(swapAddr), "native reserve vs vault after %s", step)
|
||||
}
|
||||
check("genesis")
|
||||
|
||||
type live struct {
|
||||
id common.Hash
|
||||
asset common.Address
|
||||
amt *big.Int
|
||||
pre [preimageLen]byte
|
||||
}
|
||||
var open []live
|
||||
|
||||
lock := func(from common.Address, asset common.Address, amt *big.Int, secret byte) {
|
||||
if asset == native {
|
||||
e.db().fundNative(swapAddr, amt) // the EVM moved msg.value in before Run
|
||||
}
|
||||
p := preimageOf(secret)
|
||||
id, _, err := e.lock(from, hashOf(p), user, from, asset, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
open = append(open, live{id, asset, amt, p})
|
||||
check("lock " + asset.Hex())
|
||||
}
|
||||
|
||||
lock(maker, usdc, big.NewInt(100), 0x01)
|
||||
lock(user, wbtc, big.NewInt(70), 0x02)
|
||||
lock(maker, native, big.NewInt(5_000), 0x03)
|
||||
lock(maker, usdc, big.NewInt(250), 0x04)
|
||||
lock(maker, native, big.NewInt(9), 0x05)
|
||||
lock(user, wbtc, big.NewInt(1), 0x06)
|
||||
|
||||
// The reserve is the exact sum of the live locks, per asset.
|
||||
sum := map[common.Address]*big.Int{}
|
||||
for _, l := range open {
|
||||
if sum[l.asset] == nil {
|
||||
sum[l.asset] = big.NewInt(0)
|
||||
}
|
||||
sum[l.asset].Add(sum[l.asset], l.amt)
|
||||
}
|
||||
for asset, want := range sum {
|
||||
zzEqBig(t, want, e.reserve(asset), "reserve is the sum of live locks for %s", asset.Hex())
|
||||
}
|
||||
|
||||
// Claim the even-indexed swaps, then expire and refund the rest.
|
||||
for i, l := range open {
|
||||
if i%2 != 0 {
|
||||
continue
|
||||
}
|
||||
_, err := e.claim(watcher, l.id, l.pre, false)
|
||||
require.NoError(t, err)
|
||||
check("claim")
|
||||
sum[l.asset].Sub(sum[l.asset], l.amt)
|
||||
zzEqBig(t, sum[l.asset], e.reserve(l.asset), "reserve falls by exactly the claimed amount")
|
||||
}
|
||||
|
||||
e.setNow(timeout)
|
||||
for i, l := range open {
|
||||
if i%2 == 0 {
|
||||
continue
|
||||
}
|
||||
_, err := e.refund(watcher, l.id, false)
|
||||
require.NoError(t, err)
|
||||
check("refund")
|
||||
sum[l.asset].Sub(sum[l.asset], l.amt)
|
||||
zzEqBig(t, sum[l.asset], e.reserve(l.asset), "reserve falls by exactly the refunded amount")
|
||||
}
|
||||
|
||||
// Everything settled: all three reserves and all three vault balances are zero.
|
||||
for _, asset := range []common.Address{usdc, wbtc, native} {
|
||||
zzEqBig(t, big.NewInt(0), e.reserve(asset), "reserve drained for %s", asset.Hex())
|
||||
}
|
||||
zzEqBig(t, big.NewInt(0), e.db().bal(usdc, swapAddr))
|
||||
zzEqBig(t, big.NewInt(0), e.db().bal(wbtc, swapAddr))
|
||||
zzEqBig(t, big.NewInt(0), e.db().nativeBal(swapAddr))
|
||||
check("final")
|
||||
}
|
||||
|
||||
// TestZZReservesAreIsolatedPerAsset proves one asset's ledger cannot fund
|
||||
// another's payout — the reserve is keyed by asset, so a native lock can never
|
||||
// underwrite an ERC-20 claim.
|
||||
func TestZZReservesAreIsolatedPerAsset(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
amt := big.NewInt(1000)
|
||||
e.db().fund(usdc, maker, amt)
|
||||
e.db().fundNative(swapAddr, amt)
|
||||
|
||||
pre := preimageOf(0x91)
|
||||
h := hashOf(pre)
|
||||
idTok, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
idNat, _, err := e.lock(maker, hashOf(preimageOf(0x92)), user, maker, native, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
zzEqBig(t, amt, e.reserve(usdc))
|
||||
zzEqBig(t, amt, e.reserve(native))
|
||||
zzEqBig(t, big.NewInt(0), e.reserve(wbtc), "an untouched asset has no reserve")
|
||||
|
||||
// Settling the token leg leaves the native ledger exactly where it was.
|
||||
_, err = e.claim(watcher, idTok, pre, false)
|
||||
require.NoError(t, err)
|
||||
zzEqBig(t, big.NewInt(0), e.reserve(usdc))
|
||||
zzEqBig(t, amt, e.reserve(native), "settling one asset must not move another's reserve")
|
||||
zzEqBig(t, amt, e.db().nativeBal(swapAddr))
|
||||
require.Equal(t, StatusLocked, zzStatus(t, e, idNat))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NATIVE custody. delivered = balanceOf(swapAddr) − reserve[native] is the value
|
||||
// THIS call carried. Exact-delta, so short, zero and over-delivery all refuse.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestZZNativeExactDeltaRequired(t *testing.T) {
|
||||
amt := big.NewInt(1_000)
|
||||
h := hashOf(preimageOf(0xA1))
|
||||
|
||||
deliveries := map[string]*big.Int{
|
||||
"zero_value": big.NewInt(0),
|
||||
"one_short": new(big.Int).Sub(amt, big.NewInt(1)),
|
||||
"one_over": new(big.Int).Add(amt, big.NewInt(1)),
|
||||
"half": new(big.Int).Div(amt, big.NewInt(2)),
|
||||
"double": new(big.Int).Mul(amt, big.NewInt(2)),
|
||||
"wildly_overpay": big.NewInt(1_000_000),
|
||||
}
|
||||
for name, delivered := range deliveries {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
e.db().fundNative(swapAddr, delivered)
|
||||
|
||||
_, _, err := e.lock(maker, h, user, maker, native, amt, timeout, false)
|
||||
require.ErrorIs(t, err, ErrDeltaMismatch)
|
||||
zzEqBig(t, big.NewInt(0), e.reserve(native), "a refused lock credits nothing")
|
||||
zzEqBig(t, delivered, e.db().nativeBal(swapAddr), "and moves nothing")
|
||||
})
|
||||
}
|
||||
|
||||
// Exactly right is accepted.
|
||||
e := newEnv(t0)
|
||||
e.db().fundNative(swapAddr, amt)
|
||||
_, _, err := e.lock(maker, h, user, maker, native, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
zzEqBig(t, amt, e.reserve(native))
|
||||
}
|
||||
|
||||
// TestZZNativeSequentialLocksMeasureOnlyTheirOwnValue pins the "surplus over the
|
||||
// accounted reserve" idiom: after a lock, the reserve absorbs the balance, so the
|
||||
// NEXT lock sees only the value its own call delivered.
|
||||
func TestZZNativeSequentialLocksMeasureOnlyTheirOwnValue(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
a, b, c := big.NewInt(100), big.NewInt(250), big.NewInt(7)
|
||||
|
||||
e.db().fundNative(swapAddr, a)
|
||||
id1, _, err := e.lock(maker, hashOf(preimageOf(0xA2)), user, maker, native, a, timeout, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// A second lock that delivers `b` must lock `b`, not a+b.
|
||||
e.db().fundNative(swapAddr, b)
|
||||
_, _, err = e.lock(maker, hashOf(preimageOf(0xA3)), user, maker, native, new(big.Int).Add(a, b), timeout, false)
|
||||
require.ErrorIs(t, err, ErrDeltaMismatch, "the earlier lock's value is already accounted, not re-lockable")
|
||||
|
||||
id2, _, err := e.lock(maker, hashOf(preimageOf(0xA3)), user, maker, native, b, timeout, false)
|
||||
require.NoError(t, err)
|
||||
zzEqBig(t, new(big.Int).Add(a, b), e.reserve(native))
|
||||
zzConservedNative(t, e)
|
||||
|
||||
// Settle one, then a third lock still measures only its own value.
|
||||
_, err = e.claim(watcher, id1, preimageOf(0xA2), false)
|
||||
require.NoError(t, err)
|
||||
zzEqBig(t, b, e.reserve(native))
|
||||
zzConservedNative(t, e)
|
||||
|
||||
e.db().fundNative(swapAddr, c)
|
||||
id3, _, err := e.lock(maker, hashOf(preimageOf(0xA4)), user, maker, native, c, timeout, false)
|
||||
require.NoError(t, err)
|
||||
zzEqBig(t, new(big.Int).Add(b, c), e.reserve(native))
|
||||
zzConservedNative(t, e)
|
||||
require.Equal(t, StatusLocked, zzStatus(t, e, id2))
|
||||
require.Equal(t, StatusLocked, zzStatus(t, e, id3))
|
||||
}
|
||||
|
||||
// TestZZNativeDonationBreaksTheNextLock documents a real consequence of measuring
|
||||
// delivery as (balance − reserve): native LUX force-sent to swapAddr by anyone
|
||||
// (selfdestruct, a coinbase payout, a plain transfer) is indistinguishable from
|
||||
// msg.value. It makes the NEXT native lock over-deliver and be refused, and it is
|
||||
// then absorbed by whichever lock finally reconciles. See the report.
|
||||
func TestZZNativeDonationBreaksTheNextLock(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
amt := big.NewInt(1_000)
|
||||
h := hashOf(preimageOf(0xA5))
|
||||
|
||||
// A griefer force-sends 1 wei; the honest locker then delivers exactly `amt`.
|
||||
e.db().fundNative(swapAddr, big.NewInt(1))
|
||||
e.db().fundNative(swapAddr, amt)
|
||||
|
||||
_, _, err := e.lock(maker, h, user, maker, native, amt, timeout, false)
|
||||
require.ErrorIs(t, err, ErrDeltaMismatch,
|
||||
"an unsolicited native donation makes a correctly-built lock fail")
|
||||
zzEqBig(t, big.NewInt(0), e.reserve(native))
|
||||
|
||||
// The donation is not lost to the contract: a lock sized to the full observed
|
||||
// surplus succeeds and absorbs it into that swap.
|
||||
absorb := new(big.Int).Add(amt, big.NewInt(1))
|
||||
id, _, err := e.lock(maker, h, user, maker, native, absorb, timeout, false)
|
||||
require.NoError(t, err)
|
||||
zzEqBig(t, absorb, e.reserve(native), "the donation is absorbed into the reconciling swap")
|
||||
zzConservedNative(t, e)
|
||||
|
||||
// And it settles normally to the recipient, donation included.
|
||||
_, err = e.claim(watcher, id, preimageOf(0xA5), false)
|
||||
require.NoError(t, err)
|
||||
zzEqBig(t, absorb, e.db().nativeBal(user))
|
||||
zzEqBig(t, big.NewInt(0), e.reserve(native))
|
||||
zzConservedNative(t, e)
|
||||
}
|
||||
|
||||
// TestZZNativeSettlementIsValueConserving asserts the Sub/Add pair moves value
|
||||
// rather than minting it: the total native supply across all accounts is
|
||||
// unchanged by a lock and by a settlement.
|
||||
func TestZZNativeSettlementIsValueConserving(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
amt := big.NewInt(12_345)
|
||||
e.db().setNative(maker, big.NewInt(50_000))
|
||||
|
||||
supply := func() *big.Int {
|
||||
total := big.NewInt(0)
|
||||
for _, v := range e.db().native {
|
||||
total.Add(total, v)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// The EVM debits the maker and credits swapAddr before Run; supply is constant.
|
||||
e.db().setNative(maker, new(big.Int).Sub(e.db().nativeBal(maker), amt))
|
||||
e.db().fundNative(swapAddr, amt)
|
||||
before := supply()
|
||||
|
||||
id, _, err := e.lock(maker, hashOf(preimageOf(0xA6)), user, maker, native, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
zzEqBig(t, before, supply(), "locking mints nothing")
|
||||
|
||||
_, err = e.claim(watcher, id, preimageOf(0xA6), false)
|
||||
require.NoError(t, err)
|
||||
zzEqBig(t, before, supply(), "settling mints nothing")
|
||||
zzEqBig(t, amt, e.db().nativeBal(user))
|
||||
zzEqBig(t, big.NewInt(0), e.db().nativeBal(swapAddr))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ERC-20 custody: the transferFrom-delta idiom.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestZZFeeOnTransferRejectedAtEveryRate proves a short-delivering token is
|
||||
// REFUSED rather than silently under-locked, whatever the fee rate.
|
||||
func TestZZFeeOnTransferRejectedAtEveryRate(t *testing.T) {
|
||||
amt := big.NewInt(1_000_000)
|
||||
h := hashOf(preimageOf(0xB5))
|
||||
|
||||
for _, bps := range []int{1, 30, 100, 5000, 9999, 10000} {
|
||||
e := newEnv(t0)
|
||||
e.db().fund(usdc, maker, amt)
|
||||
e.db().feeBps[usdc] = bps
|
||||
|
||||
_, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
||||
require.ErrorIsf(t, err, ErrDeltaMismatch, "fee of %d bps must be refused", bps)
|
||||
zzEqBig(t, big.NewInt(0), e.reserve(usdc), "nothing credited at %d bps", bps)
|
||||
}
|
||||
|
||||
// A token that delivers exactly what was asked is accepted.
|
||||
e := newEnv(t0)
|
||||
e.db().fund(usdc, maker, amt)
|
||||
_, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
zzEqBig(t, amt, e.reserve(usdc))
|
||||
zzConserved(t, e, usdc)
|
||||
}
|
||||
|
||||
// TestZZPayoutTransferFailureAborts covers the pay-out half of the vault seam:
|
||||
// a token whose transfer reverts on the way OUT must surface ErrTransferFailed
|
||||
// from both claim and refund, so the EVM reverts the frame rather than marking a
|
||||
// swap settled that never paid.
|
||||
func TestZZPayoutTransferFailureAborts(t *testing.T) {
|
||||
amt := big.NewInt(1000)
|
||||
pre := preimageOf(0xB6)
|
||||
h := hashOf(pre)
|
||||
|
||||
t.Run("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)
|
||||
|
||||
e.db().failTransfer[usdc] = true // the token starts reverting after the lock
|
||||
_, err = e.claim(watcher, id, pre, false)
|
||||
require.ErrorIs(t, err, ErrTransferFailed)
|
||||
|
||||
// No value moved. (The mock does not roll back storage the way the EVM
|
||||
// reverts the frame, so the status write above it is expected to persist
|
||||
// here; the invariant under test is that NOTHING WAS PAID.)
|
||||
zzEqBig(t, big.NewInt(0), e.db().bal(usdc, user), "a failed transfer pays nobody")
|
||||
zzEqBig(t, amt, e.db().bal(usdc, swapAddr), "the value stays in the vault")
|
||||
})
|
||||
|
||||
t.Run("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)
|
||||
|
||||
e.db().failTransfer[usdc] = true
|
||||
_, err = e.refund(maker, id, false)
|
||||
require.ErrorIs(t, err, ErrTransferFailed)
|
||||
zzEqBig(t, big.NewInt(0), e.db().bal(usdc, maker))
|
||||
zzEqBig(t, amt, e.db().bal(usdc, swapAddr))
|
||||
})
|
||||
}
|
||||
|
||||
// TestZZPushOutSurfacesTokenError unit-tests the pay-out helper directly, since
|
||||
// the wrapped cause is what an operator reads in the revert.
|
||||
func TestZZPushOutSurfacesTokenError(t *testing.T) {
|
||||
m := newMockState()
|
||||
m.fund(usdc, swapAddr, big.NewInt(1000))
|
||||
|
||||
require.NoError(t, pushOut(m, usdc, user, big.NewInt(400)))
|
||||
zzEqBig(t, big.NewInt(400), m.bal(usdc, user))
|
||||
|
||||
// Over-drawing the vault reverts in the token and surfaces as ErrTransferFailed.
|
||||
err := pushOut(m, usdc, user, big.NewInt(10_000))
|
||||
require.ErrorIs(t, err, ErrTransferFailed)
|
||||
require.ErrorContains(t, err, errTokenReverted.Error(), "the token's own cause is preserved")
|
||||
|
||||
m.failTransfer[usdc] = true
|
||||
require.ErrorIs(t, pushOut(m, usdc, user, big.NewInt(1)), ErrTransferFailed)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A StateDB without the erc20Vault capability refuses ERC-20 custody cleanly on
|
||||
// BOTH sides — it never fakes a credit and never fakes a payout.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestZZVaultUnavailableRefusesTokenCustody(t *testing.T) {
|
||||
db := zzNewNoVault()
|
||||
_, hasVault := interface{}(db).(erc20Vault)
|
||||
require.False(t, hasVault, "this fixture must NOT carry the capability")
|
||||
|
||||
st := &zzState{db: db, blk: &mockBlock{ts: t0}}
|
||||
c := &SwapContract{}
|
||||
amt := big.NewInt(1000)
|
||||
pre := preimageOf(0xC5)
|
||||
h := hashOf(pre)
|
||||
|
||||
// lock: refused before any state is written.
|
||||
_, _, err := c.Run(st, maker, swapAddr, zzLockInput(h, user, maker, usdc, amt, timeout), 10_000_000, false)
|
||||
require.ErrorIs(t, err, ErrVaultUnavailable)
|
||||
require.Equal(t, uint8(StatusNone), loadStatus(db, computeSwapID(h, user, maker, usdc, amt, timeout, maker, 0)))
|
||||
zzEqBig(t, big.NewInt(0), loadReserve(db, usdc))
|
||||
|
||||
// claim/refund: seed a Locked ERC-20 swap (a state this StateDB could have
|
||||
// inherited from a host that once had the capability) and prove settlement
|
||||
// refuses rather than marking it paid. Each transition gets its OWN swap:
|
||||
// effects precede the interaction, so the failed claim leaves this mock's
|
||||
// status flipped where a real EVM would revert the whole frame.
|
||||
claimID, refundID := common.Hash{0xC5}, common.Hash{0xC6}
|
||||
zzSeedLocked(db, claimID, h, user, maker, usdc, amt, timeout)
|
||||
zzSeedLocked(db, refundID, h, user, maker, usdc, amt, timeout)
|
||||
addReserve(db, usdc, new(big.Int).Mul(amt, big.NewInt(2)))
|
||||
|
||||
_, _, err = c.Run(st, watcher, swapAddr, zzClaimInput(claimID, pre), 10_000_000, false)
|
||||
require.ErrorIs(t, err, ErrVaultUnavailable)
|
||||
|
||||
st.blk = &mockBlock{ts: timeout}
|
||||
_, _, err = c.Run(st, watcher, swapAddr, zzRefundInput(refundID), 10_000_000, false)
|
||||
require.ErrorIs(t, err, ErrVaultUnavailable)
|
||||
|
||||
// Neither settlement paid anyone: the capability is missing, so no ERC-20
|
||||
// value can have moved at all.
|
||||
require.Empty(t, db.m.tokens, "a vault-less StateDB has no token ledger to move")
|
||||
|
||||
// Native custody still works on the very same StateDB: the missing capability
|
||||
// is scoped to ERC-20, not to the precompile.
|
||||
st.blk = &mockBlock{ts: t0}
|
||||
db.m.fundNative(swapAddr, amt)
|
||||
ret, _, err := c.Run(st, maker, swapAddr, zzLockInput(hashOf(preimageOf(0xC8)), user, maker, native, amt, timeout), 10_000_000, false)
|
||||
require.NoError(t, err, "native needs no vault capability")
|
||||
var nativeID common.Hash
|
||||
copy(nativeID[:], ret)
|
||||
_, _, err = c.Run(st, watcher, swapAddr, zzClaimInput(nativeID, preimageOf(0xC8)), 10_000_000, false)
|
||||
require.NoError(t, err)
|
||||
zzEqBig(t, amt, db.m.nativeBal(user))
|
||||
}
|
||||
|
||||
func TestZZPayOutDispatchesOnAssetKind(t *testing.T) {
|
||||
// Native routes to the balance ledger.
|
||||
m := newMockState()
|
||||
m.fundNative(swapAddr, big.NewInt(500))
|
||||
require.NoError(t, payOut(m, native, user, big.NewInt(500)))
|
||||
zzEqBig(t, big.NewInt(500), m.nativeBal(user))
|
||||
|
||||
// An ERC-20 routes to the vault capability.
|
||||
m2 := newMockState()
|
||||
m2.fund(usdc, swapAddr, big.NewInt(500))
|
||||
require.NoError(t, payOut(m2, usdc, user, big.NewInt(500)))
|
||||
zzEqBig(t, big.NewInt(500), m2.bal(usdc, user))
|
||||
|
||||
// Without the capability, only the ERC-20 side refuses.
|
||||
nv := zzNewNoVault()
|
||||
nv.m.fundNative(swapAddr, big.NewInt(500))
|
||||
require.NoError(t, payOut(nv, native, user, big.NewInt(500)))
|
||||
require.ErrorIs(t, payOut(nv, usdc, user, big.NewInt(500)), ErrVaultUnavailable)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pushOutNative's own guards. SubBalance is uint256-modular and does NOT revert
|
||||
// from a precompile, so an unguarded underflow would WRAP swapAddr's balance to
|
||||
// ~2^256 — minting the chain's entire supply. The guard must fail loud instead.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestZZNativePayoutRefusesUnderflow(t *testing.T) {
|
||||
m := newMockState()
|
||||
m.fundNative(swapAddr, big.NewInt(100))
|
||||
|
||||
require.ErrorIs(t, pushOutNative(m, user, big.NewInt(101)), ErrReserveUnderflow)
|
||||
zzEqBig(t, big.NewInt(100), m.nativeBal(swapAddr), "a refused payout moves nothing")
|
||||
zzEqBig(t, big.NewInt(0), m.nativeBal(user))
|
||||
|
||||
// The boundary: exactly the balance is payable.
|
||||
require.NoError(t, pushOutNative(m, user, big.NewInt(100)))
|
||||
zzEqBig(t, big.NewInt(0), m.nativeBal(swapAddr))
|
||||
zzEqBig(t, big.NewInt(100), m.nativeBal(user))
|
||||
|
||||
// And an empty vault pays nothing at all.
|
||||
require.ErrorIs(t, pushOutNative(m, user, big.NewInt(1)), ErrReserveUnderflow)
|
||||
}
|
||||
|
||||
// TestZZNativePayoutRefusesOversizeAmount covers the uint256 conversion guard.
|
||||
// Through Run the amount is read from a 32-byte word and can never exceed
|
||||
// 2^256−1, so this branch is reachable only by calling the helper directly — it
|
||||
// is defence in depth for a future caller that is not the ABI decoder.
|
||||
func TestZZNativePayoutRefusesOversizeAmount(t *testing.T) {
|
||||
m := newMockState()
|
||||
m.fundNative(swapAddr, big.NewInt(1000))
|
||||
|
||||
tooBig := new(big.Int).Lsh(big.NewInt(1), 256) // 2^256 — one past uint256
|
||||
require.ErrorIs(t, pushOutNative(m, user, tooBig), ErrTransferFailed)
|
||||
zzEqBig(t, big.NewInt(1000), m.nativeBal(swapAddr))
|
||||
zzEqBig(t, big.NewInt(0), m.nativeBal(user))
|
||||
|
||||
// The largest representable uint256 is not itself rejected by the conversion;
|
||||
// it is refused by the balance guard instead.
|
||||
maxU256 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
|
||||
require.ErrorIs(t, pushOutNative(m, user, maxU256), ErrReserveUnderflow)
|
||||
}
|
||||
|
||||
// TestZZNativeClaimRefusesWhenVaultDrained drives the underflow guard through
|
||||
// Run: the reserve still says the swap is funded, but the vault's real native
|
||||
// balance is gone. Settlement must abort, not wrap.
|
||||
func TestZZNativeClaimRefusesWhenVaultDrained(t *testing.T) {
|
||||
pre := preimageOf(0xC7)
|
||||
h := hashOf(pre)
|
||||
amt := big.NewInt(1000)
|
||||
|
||||
t.Run("claim", func(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
e.db().fundNative(swapAddr, amt)
|
||||
id, _, err := e.lock(maker, h, user, maker, native, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
e.db().setNative(swapAddr, big.NewInt(0)) // the vault's real balance vanishes
|
||||
_, err = e.claim(watcher, id, pre, false)
|
||||
require.ErrorIs(t, err, ErrReserveUnderflow)
|
||||
zzEqBig(t, big.NewInt(0), e.db().nativeBal(user), "no value conjured for the recipient")
|
||||
zzEqBig(t, big.NewInt(0), e.db().nativeBal(swapAddr), "and none wrapped into the vault")
|
||||
})
|
||||
|
||||
t.Run("refund", func(t *testing.T) {
|
||||
e := newEnv(t0)
|
||||
e.db().fundNative(swapAddr, amt)
|
||||
id, _, err := e.lock(maker, h, user, maker, native, amt, timeout, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
e.db().setNative(swapAddr, new(big.Int).Sub(amt, big.NewInt(1))) // one short
|
||||
e.setNow(timeout)
|
||||
_, err = e.refund(maker, id, false)
|
||||
require.ErrorIs(t, err, ErrReserveUnderflow)
|
||||
zzEqBig(t, big.NewInt(0), e.db().nativeBal(maker))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user