mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
The deadline is honoured on every arm, not only the V2 one
SwapRouter02 dropped the deadline from exactInputSingle's tuple and takes it through multicall(uint256,bytes[]) instead, which is how Uniswap's own interface sends one. Without that wrap a deadline handed to /v1/trade/swap was accepted and silently dropped on every V3 route while the V2 route honoured it — one field meaning two things depending on which pool happened to win the quote. amountOutMinimum bounds the price; the deadline bounds the time. A signed swap that sits in a mempool and lands an hour later still passes its floor, at a price nobody would choose now. Unset means twenty minutes out, because zero is a deadline in 1970 and every swap would revert as too old. Measured against 96369's live SwapRouter02, both directions: deadline ahead of block.timestamp -> 997001483075056712, the quoted amount deadline behind it -> execution reverted: Transaction too old Worth knowing for anything that tests this: 96369 seals a block when there is a transaction, so block.timestamp ran 4709 seconds behind wall clock while this was measured. A deadline computed from wall clock is generous here rather than tight — the safe direction, but `now - 60` will not read as expired. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -372,6 +372,26 @@ the best, so it is a reading of ONE pool. The winning tier rides on
|
||||
`route[0].fee` and the swap names the same one — without it the transaction
|
||||
executes against a different pool at a different price.
|
||||
|
||||
**The deadline is honoured on every arm.** SwapRouter02 dropped the field from
|
||||
`exactInputSingle`'s tuple and takes it through `multicall(uint256,bytes[])`
|
||||
instead, which is how Uniswap's own interface sends one; the V2 router takes it
|
||||
directly. Without the wrap a deadline was accepted and silently dropped on
|
||||
every V3 route while V2 honoured it — one field meaning two things depending on
|
||||
which pool won the quote. `amountOutMinimum` bounds the price, the deadline
|
||||
bounds the time, and a signed swap that lands an hour late still passes its
|
||||
floor at a price nobody would choose now.
|
||||
|
||||
Both directions measured against 96369's live router:
|
||||
|
||||
deadline ahead of block.timestamp -> 997001483075056712, the quoted amount
|
||||
deadline behind it -> execution reverted: Transaction too old
|
||||
|
||||
⚠ **96369's clock is not wall clock.** It seals a block when there is a
|
||||
transaction, so `block.timestamp` was 4709 seconds behind wall clock while this
|
||||
was measured. A deadline computed from wall clock is generous here rather than
|
||||
tight, which is the safe direction — but a test that expects `now - 60` to be
|
||||
expired will not see it expire.
|
||||
|
||||
**A direction is not a missing market.** `getAmountsOut` and
|
||||
`quoteExactInputSingle` price a known INPUT and have no other direction, so an
|
||||
exact-output request gets nothing back however deep the pool is. Answering that
|
||||
|
||||
+11
-1
@@ -445,7 +445,17 @@ components:
|
||||
deadline:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Unix seconds after which the transaction must not execute.
|
||||
description: |
|
||||
Unix seconds after which the transaction must not execute. Twenty
|
||||
minutes out when unset. `amountOutMinimum` bounds the price; this
|
||||
bounds the time — a signed swap that sits in a mempool and lands an
|
||||
hour later still passes its floor, at a price nobody would choose
|
||||
now.
|
||||
|
||||
Honoured on every arm. SwapRouter02 dropped the field from
|
||||
`exactInputSingle` and takes it through `multicall(uint256,bytes[])`
|
||||
instead, which is what a V3 route is wrapped in; the V2 router takes
|
||||
it directly.
|
||||
|
||||
UnsignedTx:
|
||||
type: object
|
||||
|
||||
@@ -51,6 +51,19 @@ func (o SwapOrder) deadline() *big.Int {
|
||||
// expects the recipient.
|
||||
var selectorV3ExactInputSingle = lxrMustDecodeHex("04e45aaf")
|
||||
|
||||
// SwapRouter02.multicall(uint256 deadline, bytes[] data).
|
||||
//
|
||||
// The 02 router dropped the deadline from exactInputSingle's tuple and takes it
|
||||
// here instead, which is how Uniswap's own interface sends one. Without this
|
||||
// wrap a deadline handed to /v1/trade/swap was accepted and silently dropped on
|
||||
// every V3 route while the V2 route honoured it — one field meaning two things
|
||||
// depending on which pool happened to win the quote.
|
||||
//
|
||||
// amountOutMinimum bounds the PRICE; the deadline bounds the TIME. A signed
|
||||
// swap that sits in a mempool and lands an hour later still passes its floor,
|
||||
// at a price nobody would choose now.
|
||||
var selectorV3Multicall = lxrMustDecodeHex("5ae401dc")
|
||||
|
||||
// Swap builds a SwapRouter02 call for a quote this venue gave.
|
||||
func (v *UniswapV3Venue) Swap(o SwapOrder) (*UnsignedTxResponse, error) {
|
||||
if v.router == "" {
|
||||
@@ -86,12 +99,34 @@ func (v *UniswapV3Venue) Swap(o SwapOrder) (*UnsignedTxResponse, error) {
|
||||
|
||||
return &UnsignedTxResponse{
|
||||
To: v.router,
|
||||
Data: "0x" + hex.EncodeToString(data),
|
||||
Data: "0x" + hex.EncodeToString(withDeadline(data, o.deadline())),
|
||||
Value: "0",
|
||||
GasLimit: 220_000,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// withDeadline wraps one call in SwapRouter02's multicall(deadline, bytes[]).
|
||||
//
|
||||
// Head: selector, deadline, offset to the array. Then the array: its length,
|
||||
// one offset per element, and each element as a length-prefixed blob padded to
|
||||
// a word — the offsets are counted from the start of the array, not the start
|
||||
// of the calldata, which is the detail that makes a hand-written dynamic
|
||||
// encoding revert.
|
||||
func withDeadline(call []byte, deadline *big.Int) []byte {
|
||||
pad := (32 - len(call)%32) % 32
|
||||
|
||||
out := make([]byte, 0, 4+32*4+len(call)+pad)
|
||||
out = append(out, selectorV3Multicall...)
|
||||
out = append(out, lxrPadUint256(deadline)...)
|
||||
out = append(out, lxrPadUint256(big.NewInt(64))...) // the array starts after two head words
|
||||
|
||||
out = append(out, lxrPadUint256(big.NewInt(1))...) // one call
|
||||
out = append(out, lxrPadUint256(big.NewInt(32))...) // which starts one word into the array body
|
||||
out = append(out, lxrPadUint256(big.NewInt(int64(len(call))))...)
|
||||
out = append(out, call...)
|
||||
return append(out, make([]byte, pad)...)
|
||||
}
|
||||
|
||||
// Swap builds a Router02 call for a quote this venue gave.
|
||||
//
|
||||
// The same contract that answered getAmountsOut executes the swap, so there is
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"math/big"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The calldata a wallet signs is the one thing here that spends money, so its
|
||||
@@ -38,15 +39,12 @@ func TestV3SwapEncodesSwapRouter02(t *testing.T) {
|
||||
t.Errorf("to = %s, want the router this venue was given", tx.To)
|
||||
}
|
||||
|
||||
raw, err := hex.DecodeString(strings.TrimPrefix(tx.Data, "0x"))
|
||||
if err != nil {
|
||||
t.Fatalf("calldata: %v", err)
|
||||
}
|
||||
raw := unwrap(t, tx.Data)
|
||||
if got, want := hex.EncodeToString(raw[:4]), "04e45aaf"; got != want {
|
||||
t.Errorf("selector = %s, want %s", got, want)
|
||||
}
|
||||
if len(raw) != 4+32*7 {
|
||||
t.Fatalf("calldata is %d bytes, want %d — a seven-field tuple encodes inline", len(raw), 4+32*7)
|
||||
t.Fatalf("the call is %d bytes, want %d — a seven-field tuple encodes inline", len(raw), 4+32*7)
|
||||
}
|
||||
|
||||
word := func(i int) []byte { return raw[4+32*i : 4+32*(i+1)] }
|
||||
@@ -165,3 +163,88 @@ func TestTheFloorSitsBelowTheQuote(t *testing.T) {
|
||||
t.Error("the floor is not below the quote")
|
||||
}
|
||||
}
|
||||
|
||||
// SwapRouter02 dropped the deadline from exactInputSingle's tuple and takes it
|
||||
// through multicall(uint256,bytes[]) instead, which is how Uniswap's own
|
||||
// interface sends one. Without the wrap a deadline handed to /v1/trade/swap was
|
||||
// accepted and silently dropped on every V3 route while the V2 route honoured
|
||||
// it — one field meaning two things depending on which pool won the quote.
|
||||
//
|
||||
// amountOutMinimum bounds the price; the deadline bounds the time. A signed
|
||||
// swap that sits in a mempool and lands an hour later still passes its floor,
|
||||
// at a price nobody would choose now.
|
||||
func TestV3SwapCarriesTheDeadlineThroughMulticall(t *testing.T) {
|
||||
v := NewUniswapV3Venue(UniswapV3Config{
|
||||
RPCURL: "http://chain.invalid", QuoterAddress: testOther, RouterAddress: testOther,
|
||||
})
|
||||
tx, err := v.Swap(SwapOrder{
|
||||
TokenIn: testWETH, TokenOut: testLUSD,
|
||||
AmountIn: big.NewInt(1_000_000), MinOut: big.NewInt(995_000),
|
||||
Recipient: testOther, Fee: 3000, Deadline: 1893456000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
raw, _ := hex.DecodeString(strings.TrimPrefix(tx.Data, "0x"))
|
||||
|
||||
if got := hex.EncodeToString(raw[:4]); got != "5ae401dc" {
|
||||
t.Fatalf("selector = %s, want 5ae401dc — multicall(uint256,bytes[])", got)
|
||||
}
|
||||
word := func(i int) *big.Int { return new(big.Int).SetBytes(raw[4+32*i : 4+32*(i+1)]) }
|
||||
if word(0).Int64() != 1893456000 {
|
||||
t.Errorf("deadline = %s, want the one asked for", word(0))
|
||||
}
|
||||
if word(1).Int64() != 64 {
|
||||
t.Errorf("array offset = %s, want 64 — two head words", word(1))
|
||||
}
|
||||
if word(2).Int64() != 1 {
|
||||
t.Errorf("array length = %s, want 1", word(2))
|
||||
}
|
||||
// Offsets inside a dynamic array count from the start of the ARRAY, not the
|
||||
// start of the calldata. Counting from the wrong place is what makes a
|
||||
// hand-written encoding revert.
|
||||
if word(3).Int64() != 32 {
|
||||
t.Errorf("element offset = %s, want 32", word(3))
|
||||
}
|
||||
|
||||
inner := word(4).Int64()
|
||||
if inner != 4+32*7 {
|
||||
t.Fatalf("inner length = %d, want %d", inner, 4+32*7)
|
||||
}
|
||||
body := raw[4+32*5:]
|
||||
if got := hex.EncodeToString(body[:4]); got != "04e45aaf" {
|
||||
t.Errorf("wrapped call = %s, want exactInputSingle", got)
|
||||
}
|
||||
// The blob is padded to a whole word, and the padding is not part of it.
|
||||
if len(body)%32 != 0 {
|
||||
t.Errorf("calldata is %d bytes past the head — dynamic data pads to a word", len(body)%32)
|
||||
}
|
||||
if int64(len(body)) < inner {
|
||||
t.Errorf("the wrapped call is %d bytes but the length says %d", len(body), inner)
|
||||
}
|
||||
|
||||
// A deadline nobody asked for is twenty minutes out, not zero — zero is a
|
||||
// deadline in 1970 and every swap would revert as too old.
|
||||
tx, _ = v.Swap(SwapOrder{
|
||||
TokenIn: testWETH, TokenOut: testLUSD,
|
||||
AmountIn: big.NewInt(1), MinOut: big.NewInt(1), Recipient: testOther, Fee: 3000,
|
||||
})
|
||||
raw, _ = hex.DecodeString(strings.TrimPrefix(tx.Data, "0x"))
|
||||
if d := new(big.Int).SetBytes(raw[4 : 4+32]).Int64(); d < time.Now().Unix() {
|
||||
t.Errorf("unset deadline = %d, which is already past", d)
|
||||
}
|
||||
}
|
||||
|
||||
// unwrap returns the one call inside SwapRouter02's multicall(deadline, calls).
|
||||
func unwrap(t *testing.T, data string) []byte {
|
||||
t.Helper()
|
||||
raw, err := hex.DecodeString(strings.TrimPrefix(data, "0x"))
|
||||
if err != nil {
|
||||
t.Fatalf("calldata: %v", err)
|
||||
}
|
||||
if got := hex.EncodeToString(raw[:4]); got != "5ae401dc" {
|
||||
t.Fatalf("selector = %s, want the multicall the deadline rides in", got)
|
||||
}
|
||||
n := new(big.Int).SetBytes(raw[4+32*4 : 4+32*5]).Int64()
|
||||
return raw[4+32*5 : 4+32*5+int(n)]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user