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:
2026-09-09 01:17:20 -07:00
co-authored by Hanzo Dev
parent c1067426a5
commit 5b9ac470d9
4 changed files with 155 additions and 7 deletions
+20
View File
@@ -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
View File
@@ -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
+36 -1
View File
@@ -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
+88 -5
View File
@@ -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)]
}