mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
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]>
187 lines
7.3 KiB
Go
187 lines
7.3 KiB
Go
package gateway
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"fmt"
|
|
"math/big"
|
|
"time"
|
|
)
|
|
|
|
// A quote and the transaction that takes it are the same question asked of the
|
|
// same venue, so the venue that priced it is the one that builds it.
|
|
//
|
|
// /v1/trade/swap used to ask the hosted provider registry for a quote — which
|
|
// this deployment has none of, so it answered 500 while /v1/trade/quote priced
|
|
// the pair from the pools a line away — and then encode the answer for the
|
|
// PoolManager precompile at 0x9010 regardless of where the price came from.
|
|
// That address answers 0x on 96369, so the calldata named a contract that is
|
|
// not there. A router on Ethereum was quoted and a precompile on our chain was
|
|
// addressed, in one response.
|
|
|
|
// SwapOrder is a quote a caller has decided to take.
|
|
type SwapOrder struct {
|
|
TokenIn string
|
|
TokenOut string
|
|
AmountIn *big.Int
|
|
MinOut *big.Int
|
|
Recipient string
|
|
// Deadline is unix seconds. Zero means twenty minutes from now, which is
|
|
// what a router with no deadline field ignores anyway.
|
|
Deadline int64
|
|
// Fee is the tier the quote came from, in hundredths of a basis point. A
|
|
// V3 swap has to name the pool the quote was read out of, or it executes
|
|
// against a different one at a different price.
|
|
Fee uint32
|
|
}
|
|
|
|
func (o SwapOrder) deadline() *big.Int {
|
|
if o.Deadline > 0 {
|
|
return big.NewInt(o.Deadline)
|
|
}
|
|
return big.NewInt(time.Now().Add(20 * time.Minute).Unix())
|
|
}
|
|
|
|
// SwapRouter02.exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))
|
|
//
|
|
// Seven fields in a static tuple, so they encode inline after the selector —
|
|
// no offset word. The repository carried this same selector against a SIX-field
|
|
// signature it named ExactInputSingle(address,address,uint256,uint256,uint160,
|
|
// bytes32); that signature hashes to f48f227e. A router handed the short form
|
|
// reads amountIn where it expects the fee tier and amountOutMinimum where it
|
|
// 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 == "" {
|
|
return nil, nil
|
|
}
|
|
in, err := lxrDecodeAddress(o.TokenIn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tokenIn: %w", err)
|
|
}
|
|
out, err := lxrDecodeAddress(o.TokenOut)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tokenOut: %w", err)
|
|
}
|
|
to, err := lxrDecodeAddress(o.Recipient)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("recipient: %w", err)
|
|
}
|
|
if o.Fee == 0 {
|
|
return nil, fmt.Errorf("a v3 swap needs the tier its quote was read from")
|
|
}
|
|
|
|
data := make([]byte, 0, 4+32*7)
|
|
data = append(data, selectorV3ExactInputSingle...)
|
|
data = append(data, lxrPadAddress(in)...)
|
|
data = append(data, lxrPadAddress(out)...)
|
|
data = append(data, lxrPadUint256(new(big.Int).SetUint64(uint64(o.Fee)))...)
|
|
data = append(data, lxrPadAddress(to)...)
|
|
data = append(data, lxrPadUint256(o.AmountIn)...)
|
|
data = append(data, lxrPadUint256(o.MinOut)...)
|
|
// sqrtPriceLimitX96 zero: the price bound is amountOutMinimum, and a
|
|
// second bound here would refuse trades the caller already accepted.
|
|
data = append(data, lxrPadUint256(big.NewInt(0))...)
|
|
|
|
return &UnsignedTxResponse{
|
|
To: v.router,
|
|
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
|
|
// no second address to configure and no way for the two to disagree.
|
|
func (v *UniswapV2Venue) Swap(o SwapOrder) (*UnsignedTxResponse, error) {
|
|
if v.routerAddress == "" {
|
|
return nil, nil
|
|
}
|
|
in, err := lxrDecodeAddress(o.TokenIn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tokenIn: %w", err)
|
|
}
|
|
out, err := lxrDecodeAddress(o.TokenOut)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tokenOut: %w", err)
|
|
}
|
|
to, err := lxrDecodeAddress(o.Recipient)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("recipient: %w", err)
|
|
}
|
|
|
|
// swapExactTokensForTokens(uint256,uint256,address[],address,uint256).
|
|
// path is dynamic, so word three is an offset to it and the array itself
|
|
// follows the five head words: 5*32 = 160.
|
|
data := make([]byte, 0, 4+32*8)
|
|
data = append(data, selectorV2SwapExact...)
|
|
data = append(data, lxrPadUint256(o.AmountIn)...)
|
|
data = append(data, lxrPadUint256(o.MinOut)...)
|
|
data = append(data, lxrPadUint256(big.NewInt(160))...)
|
|
data = append(data, lxrPadAddress(to)...)
|
|
data = append(data, lxrPadUint256(o.deadline())...)
|
|
data = append(data, lxrPadUint256(big.NewInt(2))...)
|
|
data = append(data, lxrPadAddress(in)...)
|
|
data = append(data, lxrPadAddress(out)...)
|
|
|
|
return &UnsignedTxResponse{
|
|
To: v.routerAddress,
|
|
Data: "0x" + hex.EncodeToString(data),
|
|
Value: "0",
|
|
GasLimit: 200_000,
|
|
}, nil
|
|
}
|
|
|
|
// Swap returns nothing: this venue prices a market it cannot yet settle.
|
|
//
|
|
// The PoolManager at 0x9010 answers 0x on 96369 — measured, with the quoter
|
|
// selector the venue itself sends — so the arm quotes nothing there today, and
|
|
// the router beside it at 0x9012 has no ABI that can be checked against a live
|
|
// chain. Emitting calldata for either would be a transaction shaped like a
|
|
// guess. It gets a builder when the precompile answers.
|
|
func (v *NativeDEXVenue) Swap(SwapOrder) (*UnsignedTxResponse, error) { return nil, nil }
|
|
|
|
// Swap returns nothing: a price with no chain behind it.
|
|
func (v *V4Venue) Swap(SwapOrder) (*UnsignedTxResponse, error) { return nil, nil }
|
|
|
|
// Swap returns nothing: an off-chain venue quotes, it does not produce calldata.
|
|
func (b *BrokerVenue) Swap(SwapOrder) (*UnsignedTxResponse, error) { return nil, nil }
|