mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
quoteExactOutput and quoteExactOutputSingle discarded their direction into `_` and returned the exact-input projection, so a caller asking what 1000 token1 costs got what 1000 token0 buys -- wrong by the square of the price, with the fee taken off the wrong side. At price 4 it answered 4000 where the answer is 250. The two questions are inverses of one price-and-fee map: the exact-output side divides where the other multiplies and grosses the fee onto the input rather than deducting it. Every step rounds up, so the quoted input covers the output asked for and no smaller input does -- swept over five fees, five prices, both directions and six amounts, with the one-unit-lower input asserted short. The exact-input answer is unchanged, which is asserted directly. A token move refuses an address carrying no contract code. The EVM answers a CALL into such an address with success and no return data, and that is also how a non-compliant ERC-20 reports success, so the return alone cannot separate them; SafeERC20 separates them by requiring code, and now so does this. It matters on the push leg: withdraw debits the claim before it moves the token, so reading that empty return as delivery retires a claim against a transfer that did not happen. The pull leg was already covered by its delivered-amount check. One moveToken now issues both, so the requirement and the return reading cannot drift apart. Mutation: dropping the code check fails two tests. A token that HAS code and returns nothing still settles, which is asserted separately -- the refusal is bought by the code check alone. getOpenOrders charges for the record loop before running it. The order count comes from the index alone, so the bill is knowable first; the sibling enumeration on 0x9996 already charged at that point. A length read out of a storage word is bounded before it sizes an allocation. int(uint64) is negative above 2^63 and make takes that as a panic, which from a precompile is a halt rather than a refusal. slotLen is the one bound and the three readers -- record, order index, staging -- answer absent above it. This replaces a characterisation test that asserted the panic and asked to be rewritten once the bound existed. reclaim no longer stages a remove it cannot deliver. A remove names a key in the inbound space, where objects a peer exported to this chain live; the intent object went the other way, so the staged remove could never name it, and against a missing key it writes a tombstone into a namespace that has nothing to do with it. What reclaim guarantees on this chain -- the principal exits once, because the zeroed remaining caps any later settlement -- is unchanged and now stated where the comment claimed more.
140 lines
6.2 KiB
Go
140 lines
6.2 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package dex
|
|
|
|
import (
|
|
"errors"
|
|
"math/big"
|
|
"testing"
|
|
|
|
"github.com/luxfi/geth/common"
|
|
"github.com/luxfi/precompile/contract"
|
|
)
|
|
|
|
// erc20_code_test.go pins what an empty return from a token sub-call means.
|
|
//
|
|
// The EVM answers a CALL to an address carrying no code with success and no
|
|
// return data. A non-compliant ERC-20 that returns nothing on success is
|
|
// indistinguishable from that, looking at the return alone — so SafeERC20 does
|
|
// not look at the return alone: it requires code at the target before it will
|
|
// read an empty return as success. The vault's push leg debits the depositor's
|
|
// claim before it moves the token, so accepting an empty return from an address
|
|
// with no code retires a claim against a transfer that never happened.
|
|
|
|
// codeStateDB is the callSeam StateDB plus the EXTCODESIZE surface a live geth
|
|
// StateDB carries, which is what poolStateAdapter.CodeSizeOf reads.
|
|
type codeStateDB struct {
|
|
*callSeamStateDB
|
|
size map[common.Address]int
|
|
}
|
|
|
|
func (s *codeStateDB) GetCodeSize(addr common.Address) int { return s.size[addr] }
|
|
|
|
// emptyReturnEnv answers a call to an address with no token exactly as the EVM
|
|
// answers a call to an address with no code: success, no return data. The
|
|
// callSeam env reverts instead, which is the one behaviour that hides this.
|
|
type emptyReturnEnv struct{ *callSeamEnv }
|
|
|
|
func (e *emptyReturnEnv) Call(addr common.Address, input []byte, gas uint64, value *big.Int) ([]byte, uint64, error) {
|
|
if _, ok := e.tokens[addr]; !ok {
|
|
return nil, gas, nil
|
|
}
|
|
return e.callSeamEnv.Call(addr, input, gas, value)
|
|
}
|
|
|
|
// codeState is the AccessibleState over both halves.
|
|
type codeState struct {
|
|
*callSeamState
|
|
sdb *codeStateDB
|
|
env *emptyReturnEnv
|
|
}
|
|
|
|
func (m *codeState) GetStateDB() contract.StateDB { return m.sdb }
|
|
func (m *codeState) GetPrecompileEnv() contract.PrecompileEnvironment { return m.env }
|
|
|
|
// codeHarness wires a vault whose token leg goes through the EVM Call surface,
|
|
// with a per-address code size the test controls.
|
|
func codeHarness(t *testing.T, tokens map[common.Address]*tokenContract) *codeState {
|
|
t.Helper()
|
|
inner := &callSeamStateDB{inner: NewMockStateDB()}
|
|
st := &codeState{
|
|
callSeamState: &callSeamState{sdb: inner, env: &callSeamEnv{self: poolManagerAddr9999, tokens: tokens}, timestamp: harnessBlockTime},
|
|
sdb: &codeStateDB{callSeamStateDB: inner, size: map[common.Address]int{}},
|
|
env: &emptyReturnEnv{&callSeamEnv{self: poolManagerAddr9999, tokens: tokens}},
|
|
}
|
|
// Load-bearing: the StateDB must NOT be an erc20Vault, or the in-state ledger
|
|
// resolves and the Call seam under test is never reached.
|
|
assertNotERC20Vault(t, st.GetStateDB())
|
|
if _, ok := st.GetPrecompileEnv().(callableEnv); !ok {
|
|
t.Fatal("the env must satisfy callableEnv or the token leg refuses for the wrong reason")
|
|
}
|
|
return st
|
|
}
|
|
|
|
// TestTokenTransferRefusesAnAddressWithNoCode is the defect: an empty return from
|
|
// an address carrying no code is the EVM's answer to a call into nothing, and
|
|
// reading it as a successful transfer retires value against a move that did not
|
|
// occur.
|
|
func TestTokenTransferRefusesAnAddressWithNoCode(t *testing.T) {
|
|
token := common.HexToAddress("0x000000000000000000000000000000000000C0DE")
|
|
holder := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
|
st := codeHarness(t, map[common.Address]*tokenContract{})
|
|
v := newPoolStateAdapter(st)
|
|
|
|
if err := v.TransferTokenTo(token, holder, big.NewInt(100)); !errors.Is(err, ErrERC20NoCode) {
|
|
t.Fatalf("push into an address with no code: want ErrERC20NoCode, got %v", err)
|
|
}
|
|
if err := v.TransferTokenFrom(token, holder, poolManagerAddr9999, big.NewInt(100)); !errors.Is(err, ErrERC20NoCode) {
|
|
t.Fatalf("pull from an address with no code: want ErrERC20NoCode, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestTokenTransferAcceptsAnEmptyReturnFromRealCode pins the other side: a token
|
|
// that HAS code and returns nothing on success is a real, common, non-compliant
|
|
// ERC-20, and it must keep working. The refusal above must be bought by the code
|
|
// check alone, never by tightening what a return may look like.
|
|
func TestTokenTransferAcceptsAnEmptyReturnFromRealCode(t *testing.T) {
|
|
token := common.HexToAddress("0x000000000000000000000000000000000000C0DE")
|
|
holder := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
|
st := codeHarness(t, map[common.Address]*tokenContract{})
|
|
st.sdb.size[token] = 1 // deployed, but the env still returns no data
|
|
|
|
v := newPoolStateAdapter(st)
|
|
if err := v.TransferTokenTo(token, holder, big.NewInt(100)); err != nil {
|
|
t.Fatalf("push into a deployed token returning no data: %v", err)
|
|
}
|
|
if err := v.TransferTokenFrom(token, holder, poolManagerAddr9999, big.NewInt(100)); err != nil {
|
|
t.Fatalf("pull from a deployed token returning no data: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestWithdrawKeepsTheClaimWhenTheTokenIsGone is the consequence at the surface
|
|
// that matters. withdraw debits the claim and the vault before it moves the
|
|
// token, so a push that cannot happen must fail the call rather than report
|
|
// success — otherwise the depositor's claim is retired and nothing arrives.
|
|
func TestWithdrawKeepsTheClaimWhenTheTokenIsGone(t *testing.T) {
|
|
token := common.HexToAddress("0x000000000000000000000000000000000000C0DE")
|
|
depositor := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
|
st := codeHarness(t, map[common.Address]*tokenContract{})
|
|
aid := assetID(Currency{Address: token})
|
|
|
|
// A claim exists from an earlier deposit; the token address now carries no code.
|
|
db := newPoolStateAdapter(st)
|
|
storeDepositorClaim(db, depositor, aid, big.NewInt(1_000))
|
|
storeSettleVault(db, aid, big.NewInt(1_000))
|
|
|
|
data := make([]byte, 64)
|
|
copy(data[12:32], token.Bytes())
|
|
big.NewInt(1_000).FillBytes(data[32:64])
|
|
c := &SettleContract{}
|
|
if _, _, err := c.Run(st, depositor, poolManagerAddr9999, prependSelector(SelectorWithdraw, data), 5_000_000, false); err == nil {
|
|
t.Fatal("withdraw against a token with no code reported success")
|
|
}
|
|
// The refusal reverts the frame in production; here the handler must at least
|
|
// not have reported a delivery it could not make.
|
|
if got := newPoolStateAdapter(st).GetBalance(depositor); got.Sign() != 0 {
|
|
t.Fatalf("a refused withdraw moved native value: %s", got)
|
|
}
|
|
}
|