dex: an exact-output quote answers the exact-output question

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.
This commit is contained in:
2026-08-28 14:14:21 -07:00
parent 73f3159a94
commit a79ed1beee
9 changed files with 450 additions and 65 deletions
+4 -4
View File
@@ -70,8 +70,8 @@ func (s *evmStore) valueSlot(key []byte, word int) common.Hash {
// Get returns the value at key, or database.ErrNotFound when absent (length 0).
func (s *evmStore) Get(key []byte) ([]byte, error) {
lenWord := s.sdb.GetState(poolManagerAddr9999, s.valueSlot(key, 0))
n := int(bytesToU64(lenWord[24:32]))
if n == 0 {
n, ok := slotLen(lenWord)
if !ok {
return nil, database.ErrNotFound
}
out := make([]byte, n)
@@ -142,8 +142,8 @@ func (s *evmStore) indexSlot(poolID [32]byte, word int) common.Hash {
// indexIDs reads the current order-id list for poolID.
func (s *evmStore) indexIDs(poolID [32]byte) []uint64 {
countWord := s.sdb.GetState(poolManagerAddr9999, s.indexSlot(poolID, 0))
n := int(bytesToU64(countWord[24:32]))
if n == 0 {
n, ok := slotLen(countWord)
if !ok {
return nil
}
ids := make([]uint64, 0, n)
+139
View File
@@ -0,0 +1,139 @@
// 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)
}
}
+22 -2
View File
@@ -4,6 +4,7 @@
package dex
import (
"errors"
"fmt"
"math/big"
@@ -166,8 +167,7 @@ func (a *poolStateAdapter) TransferTokenFrom(token, from, to common.Address, amo
input = append(input, common.LeftPadBytes(from.Bytes(), 32)...)
input = append(input, common.LeftPadBytes(to.Bytes(), 32)...)
input = append(input, common.LeftPadBytes(amount.Bytes(), 32)...)
ret, _, err := c.Call(token, input, erc20GasBudget, nil)
return checkERC20Return(ret, err)
return a.moveToken(c, token, input)
}
// TransferTokenTo pushes amount of token from the vault (the precompile self, 0x9999) to
@@ -185,6 +185,26 @@ func (a *poolStateAdapter) TransferTokenTo(token, to common.Address, amount *big
input = append(input, selERC20Transfer...)
input = append(input, common.LeftPadBytes(to.Bytes(), 32)...)
input = append(input, common.LeftPadBytes(amount.Bytes(), 32)...)
return a.moveToken(c, token, input)
}
// ErrERC20NoCode refuses a token move against an address carrying no contract code.
// The EVM answers a CALL into such an address with success and no return data,
// which is also how a non-compliant ERC-20 reports success — so the return alone
// cannot tell the two apart, and reading it as a transfer retires value against a
// move that never happened. OpenZeppelin's SafeERC20 resolves it the same way, by
// requiring code at the target before an empty return may mean success.
var ErrERC20NoCode = errors.New("dex: ERC-20 token address carries no contract code in the executing state; refusing to move tokens against it")
// moveToken issues an ERC-20 transfer sub-call and applies SafeERC20 return
// semantics to the result. It is the one place a token move happens, so the code
// requirement and the return reading cannot drift apart between the pull and the
// push. A StateDB that cannot report code size (CodeSizeOf < 0) leaves the check to
// the return alone, exactly as before — the requirement binds where it is knowable.
func (a *poolStateAdapter) moveToken(c callableEnv, token common.Address, input []byte) error {
if a.CodeSizeOf(token) == 0 {
return ErrERC20NoCode
}
ret, _, err := c.Call(token, input, erc20GasBudget, nil)
return checkERC20Return(ret, err)
}
+12 -11
View File
@@ -831,10 +831,14 @@ func enforceProceedsPriceFloor(priceLimit uint64, limitIsUpper bool, spent, out
// (creditSettlementOutput: NO MINT — the seam's own pot must back it; it does,
// because the lock at SubmitSwapIntent added exactly this to seamReserve and no
// settlement drew it down past `remaining`).
// 7. STAGE a compensating C->D Remove of the ORIGINAL intent object (keyed by intentID)
// so a dexvm that has NOT yet imported the C->D object can never later fund a D order
// from a reclaimed intent (double-fund). If D already imported it, the Remove is a
// no-op on a missing key; the now-zero remaining still prevents a double payout.
//
// What reclaim guarantees, and where the guarantee ends: on C the principal exits
// exactly once, because the zeroed remaining caps any later settlement naming this
// intent at nothing. The C->D object exported at submit is a different question. A
// chain's atomic Requests carry RemoveRequests against the INBOUND side — the way a
// chain consumes an object a peer exported to it — so the export itself is not
// something the exporter can withdraw. Cross-domain backing therefore rests on the
// importer honouring the intent's deadline, not on anything C does here.
func (c *NativeDChainClient) ReclaimIntent(
state contract.AccessibleState,
atomicState contract.AtomicState,
@@ -889,13 +893,10 @@ func (c *NativeDChainClient) ReclaimIntent(
intent.Status = swapIntentReclaimed
putSwapIntentRecord(stateDB, intentID, intent)
// (6) PHASE A — stage the compensating C->D Remove + emit the reclaim event BEFORE the
// terminal refund, so the geth nested-call host bug cannot drop these 0x9999 writes.
// Staging a Remove of the original intent object stops a dexvm that has not yet imported
// it from later funding a D order from a reclaimed intent (double-fund); if D already
// imported it the Remove no-ops on a missing key and the now-zero remaining still prevents
// a double payout.
stageAtomicRemove(stateDB, dChainID, intentID)
// (6) PHASE A — emit the reclaim event BEFORE the terminal refund, so the geth
// nested-call host bug cannot drop this 0x9999 write. Nothing is staged: a remove
// names a key in the INBOUND (D->C) space, which is where an object a peer exported
// to C lives. The intent object went the other way, so no staged remove can name it.
emitNativeReclaimEvent(stateDB, intentID, intent.Owner, intent.AssetIn, refunded)
// (7) PHASE B — REFUND the locked principal from seamReserve as the frame's TERMINAL
+16 -2
View File
@@ -5,6 +5,7 @@ package dex
import (
"errors"
"math"
"github.com/luxfi/database"
"github.com/luxfi/geth/common"
@@ -156,8 +157,8 @@ func writeBytesToSlots(stateDB stateKV, prefix []byte, seq uint64, b []byte) {
func readBytesFromSlots(stateDB stateKV, prefix []byte, seq uint64) []byte {
lenWord := stateDB.GetState(poolManagerAddr9999, stageSlotKey(prefix, seq, 0))
n := int(bytesToU64(lenWord[24:32]))
if n == 0 {
n, ok := slotLen(lenWord)
if !ok {
return nil
}
out := make([]byte, n)
@@ -172,6 +173,19 @@ func readBytesFromSlots(stateDB stateKV, prefix []byte, seq uint64) []byte {
return out
}
// slotLen reads a byte length or element count out of the low 8 bytes of a storage
// word. A number that cannot be an int is not a length: int(uint64) is negative
// above 2^63, and make takes a negative length as a panic — which from a precompile
// is a halt, not a refusal. ok is false for zero and for anything that cannot
// address memory, and every caller reads that as an absent record.
func slotLen(w common.Hash) (int, bool) {
n := bytesToU64(w[24:32])
if n == 0 || n > math.MaxInt32 {
return 0, false
}
return int(n), true
}
func bytesToU64(b []byte) uint64 {
var v uint64
for _, x := range b {
+147
View File
@@ -0,0 +1,147 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"math/big"
"testing"
"github.com/luxfi/geth/common"
)
// quote_direction_test.go pins the direction of the 0x9998 projection. The eight
// quote selectors ask one of two questions — given an input, what output does it
// buy; given a desired output, what input does it cost — and the two answers are
// inverses of the same price-and-fee map, not the same number.
//
// The exact-output side rounds UP at every step. A quote of what a taker must
// supply may overstate by a unit; understating it is a quote the taker cannot
// fill.
// qdMarket is a market at price = sqrtP^2 / 2^192 with the given fee in pips.
func qdMarket(priceSqrtMultiple int64, fee uint24) MarketRecord {
return vpMarket(new(big.Int).Lsh(big.NewInt(priceSqrtMultiple), 96), fee)
}
// TestExactOutputAsksTheOtherQuestion is the defect: at any price other than 1 the
// input needed to buy N is not the output that N buys, so a projection that ignores
// the direction answers the wrong question with a confident number.
func TestExactOutputAsksTheOtherQuestion(t *testing.T) {
// sqrtP = 2*2^96 => price 4: one token0 buys four token1.
rec := qdMarket(2, 0)
want := big.NewInt(1_000)
in := projectSingleTick(rec, want, true /*zeroForOne*/, false /*exactOut*/)
if in.Int64() != 250 {
t.Fatalf("input to buy 1000 token1 at price 4 = %s, want 250", in)
}
out := projectSingleTick(rec, want, true, true /*exactIn*/)
if out.Int64() != 4_000 {
t.Fatalf("output bought by 1000 token0 at price 4 = %s, want 4000", out)
}
if in.Cmp(out) == 0 {
t.Fatal("the exact-output projection returned the exact-input answer")
}
// The other direction inverts: one token1 buys a quarter of a token0.
in = projectSingleTick(rec, want, false /*oneForZero*/, false)
if in.Int64() != 4_000 {
t.Fatalf("input to buy 1000 token0 at price 4 = %s, want 4000", in)
}
}
// TestExactOutputCoversTheOutputItQuotes is the property that matters: feeding the
// quoted input back through the exact-input projection must buy at least the output
// that was asked for. A round trip that comes up short is a quote that cannot fill.
func TestExactOutputCoversTheOutputItQuotes(t *testing.T) {
for _, fee := range []uint24{0, 500, 3_000, 10_000, FeeMax} {
for _, mult := range []int64{1, 2, 3, 7, 1_000} {
rec := qdMarket(mult, fee)
for _, zeroForOne := range []bool{true, false} {
for _, want := range []int64{1, 2, 7, 999, 1_000, 1_000_001} {
wantOut := big.NewInt(want)
in := projectSingleTick(rec, wantOut, zeroForOne, false)
got := projectSingleTick(rec, in, zeroForOne, true)
if got.Cmp(wantOut) < 0 {
t.Fatalf("fee=%d price=%d^2 zeroForOne=%v: quoted input %s buys %s, short of the %s asked for",
fee, mult, zeroForOne, in, got, wantOut)
}
// And it must not be wasteful: one unit less must come up short,
// which pins the rounding to the smallest covering input.
if in.Sign() > 0 {
less := new(big.Int).Sub(in, big.NewInt(1))
if projectSingleTick(rec, less, zeroForOne, true).Cmp(wantOut) >= 0 {
t.Fatalf("fee=%d price=%d^2 zeroForOne=%v: quoted input %s for %s is one above the smallest that covers it",
fee, mult, zeroForOne, in, wantOut)
}
}
}
}
}
}
}
// TestExactInputProjectionIsUnchanged pins that adding the inverse did not move the
// exact-input answer. Every existing caller reads that number.
func TestExactInputProjectionIsUnchanged(t *testing.T) {
for _, fee := range []uint24{0, 3_000, FeeMax} {
for _, mult := range []int64{1, 2, 5} {
rec := qdMarket(mult, fee)
for _, zeroForOne := range []bool{true, false} {
amount := big.NewInt(1_000_000)
if got, want := projectSingleTick(rec, amount, zeroForOne, true), spotOutputFeeAdjusted(rec, amount, zeroForOne); got.Cmp(want) != 0 {
t.Fatalf("fee=%d price=%d^2 zeroForOne=%v: exact-input projection = %s, want the unchanged %s",
fee, mult, zeroForOne, got, want)
}
}
}
}
}
// TestPricelessMarketQuotesZeroInBothDirections pins the degenerate market: a record
// with no registered price cannot answer either question, and must answer 0 rather
// than divide by it.
func TestPricelessMarketQuotesZeroInBothDirections(t *testing.T) {
for _, rec := range []MarketRecord{
{SqrtPriceX96: nil, Fee: 3_000, Status: MarketStatusActive},
{SqrtPriceX96: big.NewInt(0), Fee: 3_000, Status: MarketStatusActive},
} {
for _, zeroForOne := range []bool{true, false} {
for _, exactIn := range []bool{true, false} {
if got := projectSingleTick(rec, big.NewInt(1_000), zeroForOne, exactIn); got.Sign() != 0 {
t.Fatalf("priceless market quoted %s (zeroForOne=%v exactIn=%v), want 0", got, zeroForOne, exactIn)
}
}
}
}
}
// TestQuoterDispatchesTheDirection pins the wiring: the exact-output selectors must
// reach the exact-output projection through the real Run, not just in isolation.
func TestQuoterDispatchesTheDirection(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
q := &QuoterContract{}
addr := common.HexToAddress(DEXQuoterAddress)
amount := big.NewInt(1_000_000)
read := func(sel uint32) *big.Int {
t.Helper()
out, _, err := q.Run(h.state, h.caller, addr, quoteCalldata(sel, h.key, amount, true), 5_000_000, true)
if err != nil {
t.Fatalf("selector %#x: %v", sel, err)
}
return new(big.Int).SetBytes(out)
}
rec := loadMarket(readOnlyView{state: h.state}, h.key.ID())
if got, want := read(SelQExactInput), projectSingleTick(rec, amount, true, true); got.Cmp(want) != 0 {
t.Fatalf("exactInput selector = %s, want %s", got, want)
}
for _, sel := range []uint32{SelQExactOutput, SelQExactOutputSingle} {
if got, want := read(sel), projectSingleTick(rec, amount, true, false); got.Cmp(want) != 0 {
t.Fatalf("selector %#x = %s, want the exact-output projection %s", sel, got, want)
}
}
}
+41 -12
View File
@@ -207,9 +207,7 @@ func (q *QuoterContract) quote(
// (input, StateDB) so every validator computes the identical output — a live-D
// engine read (even write-isolated) returns per-node-divergent numbers that a
// reading contract could act on, so it is not used here. The C-side registry has
// no resting-book depth, so depthAvailable is always false (never faked). Note:
// _ = exactIn keeps the signature stable for callers; the projection is symmetric.
_ = exactIn
// no resting-book depth, so depthAvailable is always false (never faked).
amountOut := projectSingleTick(rec, amount, zeroForOne, exactIn)
feeAmount := feeOnInput(amount, rec.Fee)
@@ -232,16 +230,47 @@ func (q *QuoterContract) quote(
}
}
// projectSingleTick is the C-side deterministic fallback quote: one ComputeSwapStep
// at the registered price with no tick crossing. It is a COARSE preview (no resting
// depth, no multi-tick walk) — honest for an advisory quote when the node has no D
// book. amountIn is fed as V4-signed (negative = exact input).
// projectSingleTick is the C-side deterministic fallback quote at the registered
// price with no tick crossing. It is a COARSE preview (no resting depth, no
// multi-tick walk) — honest for an advisory quote when the node has no D book.
//
// exactIn selects which question is asked. Given an input, the output it buys;
// given a desired output, the input it costs. Those are inverses of one
// price-and-fee map, not one number: at price p the first scales by p and the
// second by 1/p, and the fee comes off the input in the first and is grossed onto
// it in the second.
func projectSingleTick(rec MarketRecord, amount *big.Int, zeroForOne, exactIn bool) *big.Int {
// Target the price extreme in the swap direction so a single step consumes as
// much as the liquidity allows; with zero liquidity in the registry we fall back
// to a constant-price projection (amountOut ≈ amount * price, fee-adjusted),
// which is the best a price-only registry can offer.
return spotOutputFeeAdjusted(rec, amount, zeroForOne)
if exactIn {
return spotOutputFeeAdjusted(rec, amount, zeroForOne)
}
return spotInputForOutput(rec, amount, zeroForOne)
}
// spotInputForOutput inverts spotOutputFeeAdjusted: the smallest input that buys
// amountOut at the registered spot price. Every step rounds UP, so the quoted input
// always covers the output asked for and no smaller input does — a quote of what a
// taker must supply may overstate by a unit, but one that understates is a quote
// that cannot fill. Returns 0 for a market carrying no price, matching spotOutput.
func spotInputForOutput(rec MarketRecord, amountOut *big.Int, zeroForOne bool) *big.Int {
if amountOut.Sign() <= 0 || rec.SqrtPriceX96 == nil || rec.SqrtPriceX96.Sign() == 0 {
return big.NewInt(0)
}
sq := new(big.Int).Mul(rec.SqrtPriceX96, rec.SqrtPriceX96) // price = sq / 2^192
var net *big.Int
if zeroForOne {
// out1 = floor(net0 * sq / 2^192) => net0 = ceil(out1 * 2^192 / sq)
net = UnsafeDivRoundingUp(new(big.Int).Lsh(amountOut, 192), sq)
} else {
// out0 = floor(net1 * 2^192 / sq) => net1 = ceil(out0 * sq / 2^192)
net = UnsafeDivRoundingUp(new(big.Int).Mul(amountOut, sq), new(big.Int).Lsh(bigOne, 192))
}
// The fee comes off the input, so net = in - floor(in*fee/1e6), which is
// ceil(in*(1e6-fee)/1e6). The smallest in reaching net is therefore
// ceil(((net-1)*1e6 + 1) / (1e6-fee)). FeeMax is a tenth of the denominator, so
// the divisor is never zero.
den := new(big.Int).Sub(feeDenominator, big.NewInt(int64(rec.Fee)))
num := new(big.Int).Mul(new(big.Int).Sub(net, bigOne), feeDenominator)
return UnsafeDivRoundingUp(num.Add(num, bigOne), den)
}
// spotOutput projects amount through the registered spot price with NO fee and NO
+8 -5
View File
@@ -250,17 +250,20 @@ func (v *StateViewContract) Run(
}
owner := common.BytesToAddress(data[12:32])
ids := OwnerOrderIDs(rv, owner)
// Per-order gas (each is an SLOAD-equivalent), charged BEFORE the reads it
// pays for. The order count is known from the index alone, so the caller is
// billed for the record loop before the loop runs — the sibling enumeration
// on 0x9996 (positionsOf) charges at the same point.
extra := GasStateView * uint64(len(ids))
if suppliedGas < GasStateView+extra {
return nil, 0, errors.New("dex: out of gas")
}
open := make([][32]byte, 0, len(ids))
for _, id := range ids {
if loadRestingOrder(rv, id).Status == OrderStatusOpen {
open = append(open, id)
}
}
// Per-order gas (each is an SLOAD-equivalent).
extra := GasStateView * uint64(len(ids))
if suppliedGas < GasStateView+extra {
return nil, 0, errors.New("dex: out of gas")
}
return encodeBytes32Array(open), suppliedGas - GasStateView - extra, nil
case SelectorGetReceiptStatus:
+61 -29
View File
@@ -7,6 +7,7 @@ import (
"bytes"
"errors"
"fmt"
"math"
"math/rand"
"sort"
"strings"
@@ -1172,29 +1173,23 @@ func TestZzstStoreIndexShrinkThenGrowHasNoGhosts(t *testing.T) {
}
// =========================================================================
// 7. DEFECT characterisation — an unvalidated length read out of state
// 7. A length read out of state is bounded before it sizes an allocation
// =========================================================================
// Get reads its record length straight out of a storage word and hands it to
// make([]byte, n) with no bound. For every length a Put can produce this is fine,
// and for a moderately corrupt word Get still returns a value rather than reading
// past anything. But a word with the top bits set converts to a negative or
// absurd int and make PANICS inside a precompile call frame.
//
// This test pins BOTH halves: the safe range must stay non-panicking, and the
// unbounded range is asserted to panic TODAY so the defect cannot be forgotten.
// core_store.go:74 is the line that needs a bound; when it gets one, this test
// must be updated to assert the refusal instead. See the report accompanying
// this file set — this is a characterisation test, not an endorsement.
func TestZzstStoreGetUnvalidatedLengthDEFECT(t *testing.T) {
// Get, indexIDs and readBytesFromSlots each read a length or a count out of a
// storage word and size an allocation with it. int(uint64) is negative above 2^63,
// and make takes a negative length as a panic — which from a precompile is a chain
// halt rather than a refusal. slotLen is the one bound: below it a record reads
// normally, above it the record reads as absent.
func TestZzstStoreBoundsALengthReadOutOfState(t *testing.T) {
setLen := func(sdb *MockStateDB, st *evmStore, key []byte, n uint64) {
var w common.Hash
putU64(w[24:32], n)
sdb.SetState(poolManagerAddr9999, st.valueSlot(key, 0), w)
}
// Safe half: a corrupt-but-small length returns a value of that length (the
// unwritten data slots read as zero) and never panics.
// In range: a corrupt-but-addressable length returns a value of that length
// (the unwritten data slots read as zero).
for _, n := range []uint64{1, 31, 32, 33, 1000, 1 << 16} {
sdb, st := zzstStore()
key := []byte("corrupt")
@@ -1208,19 +1203,56 @@ func TestZzstStoreGetUnvalidatedLengthDEFECT(t *testing.T) {
}
}
// Unbounded half: these panic today.
for _, n := range []uint64{1 << 62, ^uint64(0)} {
func() {
defer func() {
if r := recover(); r == nil {
t.Fatalf("core_store.go:74 Get with length word %#x no longer panics — the bound was "+
"added; update this characterisation test to assert the refusal", n)
}
}()
sdb, st := zzstStore()
key := []byte("corrupt")
setLen(sdb, st, key, n)
_, _ = st.Get(key)
}()
// Out of range: the record is absent, and the read never allocates.
for _, n := range []uint64{math.MaxInt32 + 1, 1 << 62, ^uint64(0)} {
sdb, st := zzstStore()
key := []byte("corrupt")
setLen(sdb, st, key, n)
zzstNoPanic(t, "Get", func() {
got, err := st.Get(key)
if !errors.Is(err, database.ErrNotFound) {
t.Errorf("Get with length word %#x: want ErrNotFound, got (%d bytes, %v)", n, len(got), err)
}
})
}
// The order-id index answers the same way: a count it cannot address is no list.
for _, n := range []uint64{math.MaxInt32 + 1, ^uint64(0)} {
sdb, st := zzstStore()
poolID := [32]byte{0xC0, 0x11}
var w common.Hash
putU64(w[24:32], n)
sdb.SetState(poolManagerAddr9999, st.indexSlot(poolID, 0), w)
zzstNoPanic(t, "indexIDs", func() {
if got := st.indexIDs(poolID); got != nil {
t.Errorf("indexIDs with count word %#x returned %d ids, want none", n, len(got))
}
})
}
// And so does the staging reader, which sizes its buffer the same way.
for _, n := range []uint64{math.MaxInt32 + 1, ^uint64(0)} {
sdb, _ := zzstStore()
var w common.Hash
putU64(w[24:32], n)
sdb.SetState(poolManagerAddr9999, stageSlotKey(stagePutPrefix, 1, 0), w)
zzstNoPanic(t, "readBytesFromSlots", func() {
if got := readBytesFromSlots(sdb, stagePutPrefix, 1); got != nil {
t.Errorf("readBytesFromSlots with length word %#x returned %d bytes, want none", n, len(got))
}
})
}
}
// zzstNoPanic turns a panic escaping a consensus read into a named failure. A panic
// inside a precompile frame aborts the block, so it is never an acceptable answer to
// a malformed record.
func zzstNoPanic(t *testing.T, ctx string, fn func()) {
t.Helper()
defer func() {
if r := recover(); r != nil {
t.Errorf("PANIC escaped %s (a chain halt, not a refusal): %v", ctx, r)
}
}()
fn()
}