mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
/v1/trade/swap asked the hosted provider registry for a price. This deployment
registers none on purpose, so it answered 500 while /v1/trade/quote priced the
same pair from the pools a line away — and then encoded whatever came back for
the PoolManager precompile at 0x9010 regardless of where the price came from. A
router on Ethereum quoted and a precompile on our chain addressed, in one
response. 0x9010 answers 0x on 96369, measured with the venue's own quoter
selector, so the calldata named a contract that is not there.
Venue has a Swap method now. Each one builds against the contract it read: the
V3 arm against the SwapRouter02 beside its quoter, the V2 arm against the same
Router02 that answered getAmountsOut. A venue that prices a market it cannot
settle returns nothing, and the surface says so naming it.
The selector was wrong in the way that spends money. calldata_lxrouter.go sent
04e45aaf under a signature it called ExactInputSingle(address,address,uint256,
uint256,uint160,bytes32); that hashes to f48f227e. 04e45aaf is SwapRouter02's
exactInputSingle((address,address,uint24,address,uint256,uint256,uint160)) — a
seven-field tuple. A router handed the six-field form reads amountIn where the
fee tier belongs and amountOutMinimum where the recipient belongs. Those
builders were reachable only from their own tests; the padding and decoding
survive in abi.go.
Proved end to end: the gateway's own calldata replayed through eth_call against
96369's real SwapRouter02, from the account holding the allowance, returns
997001483075056712 — the number the quote gave. The tier travels on
route[0].fee because a V3 quote asks four tiers and keeps one, and a swap that
does not name the same pool executes at a different price. amountOutMinimum is
99.5% of the quote by default, because a router handed the quoted amount as its
floor reverts on the movement the caller's own trade causes.
Permit2 is at one address everywhere it exists and does not exist everywhere:
9152 bytes on Ethereum, nothing on 96369. Both paths check first, so our chain
gets a sentence instead of calldata addressed to an empty account.
Nine more paths are gone because they could not work here. Five answered 500
"no providers available" — tokens, pools, pool/, positions, stats, price,
prices — because enumerating a chain is an index read and this reads one pool
by address, right now. api-explore.lux.cloud already answers all of it: 16
pools, $39.9M of TVL, real symbols and decimals for 96369. /v1/route is the
same read in a hat and returned {"routes":[]} forever, which a caller reads as
"no path exists" rather than "I cannot answer". The order paths were an
in-memory map behind two replicas, so an order placed on one pod was a 404 on
the other about half the time and every order died on restart — the D-Chain
venue is the order book and it is deployed beside this. The position builders
all addressed 0x9010.
Eight paths remain and all eight were exercised against the live chain.
Co-authored-by: Hanzo Dev <[email protected]>
247 lines
7.7 KiB
Go
247 lines
7.7 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/big"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// handleApprovalCheck handles POST /v1/trade/approval/check.
|
|
// Returns whether the spender may already move the amount asked for.
|
|
func (s *Server) handleApprovalCheck(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
s.writeError(w, http.StatusMethodNotAllowed, fmt.Errorf("method not allowed"))
|
|
return
|
|
}
|
|
|
|
var req ApprovalRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err))
|
|
return
|
|
}
|
|
|
|
if err := req.Validate(); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
|
|
want, ok := new(big.Int).SetString(req.Amount, 10)
|
|
if !ok || want.Sign() < 0 {
|
|
s.writeError(w, http.StatusBadRequest, fmt.Errorf("amount %q is not a whole number of the token's smallest unit", req.Amount))
|
|
return
|
|
}
|
|
|
|
held, err := s.read(s.requestContext(r), ChainID(req.ChainID), req.TokenAddress,
|
|
BuildERC20AllowanceCalldata(req.Owner, req.Spender))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
|
|
resp := ApprovalResponse{
|
|
Allowance: held[0].String(),
|
|
Approved: held[0].Cmp(want) >= 0,
|
|
}
|
|
resp.NeedsApproval = !resp.Approved
|
|
if resp.NeedsApproval {
|
|
// Only when it is needed. A tx handed back beside "you already
|
|
// approved" is a tx somebody sends.
|
|
if resp.ApproveTx, err = BuildApproveTx(req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
s.writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// permit2On reports whether Permit2 is deployed on a chain.
|
|
//
|
|
// It is at one address everywhere it exists, and it does not exist everywhere:
|
|
// measured, 0x000000000022D473030F116dDEE9F6B43aC78BA3 holds 9152 bytes on
|
|
// Ethereum and nothing at all on 96369. Without this, build answered 200 with
|
|
// calldata addressed to an empty account — a transaction that costs gas to do
|
|
// nothing, which is the one failure worse than an error.
|
|
func (s *Server) permit2On(ctx context.Context, chain ChainID) error {
|
|
rpc := s.chains.RPC(chain)
|
|
if rpc == "" {
|
|
return fmt.Errorf("no endpoint here reads chain %d", chain)
|
|
}
|
|
code, err := NewEVMClient(rpc).Code(ctx, Permit2Address)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(code) == 0 {
|
|
return fmt.Errorf("permit2 is not deployed on chain %d — approve the spender directly", chain)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// read asks a chain a question and returns the answer word by word.
|
|
//
|
|
// Both checks below reported allowance 0 and needsApproval on every request,
|
|
// under a comment saying the gateway had no way to read a chain. It has one per
|
|
// chain — the same endpoint the pool is quoted from, one field away. The cost
|
|
// of that guess was not a wrong number on a screen: a wallet told it needs an
|
|
// approval for a token it has already approved sends an approve transaction,
|
|
// and the user pays gas for a state change that changes nothing, before every
|
|
// swap, forever.
|
|
func (s *Server) read(ctx context.Context, chain ChainID, to string, data []byte) ([]*big.Int, error) {
|
|
rpc := s.chains.RPC(chain)
|
|
if rpc == "" {
|
|
return nil, fmt.Errorf("no endpoint here reads chain %d", chain)
|
|
}
|
|
out, err := NewEVMClient(rpc).CallContract(ctx, to, data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(out) < 32 {
|
|
return nil, fmt.Errorf("chain %d answered %d bytes for a call that returns words", chain, len(out))
|
|
}
|
|
words := make([]*big.Int, 0, len(out)/32)
|
|
for i := 0; i+32 <= len(out); i += 32 {
|
|
words = append(words, new(big.Int).SetBytes(out[i:i+32]))
|
|
}
|
|
return words, nil
|
|
}
|
|
|
|
// handleApprovalBuild handles POST /v1/trade/approval/build.
|
|
// Returns an unsigned ERC20 approve() transaction.
|
|
func (s *Server) handleApprovalBuild(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
s.writeError(w, http.StatusMethodNotAllowed, fmt.Errorf("method not allowed"))
|
|
return
|
|
}
|
|
|
|
var req ApprovalRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err))
|
|
return
|
|
}
|
|
|
|
tx, err := BuildApproveTx(req)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
|
|
s.writeJSON(w, http.StatusOK, tx)
|
|
}
|
|
|
|
// handlePermit2Check handles POST /v1/trade/permit2/check.
|
|
// Returns whether the owner has granted the spender a live Permit2 allowance
|
|
// for the amount asked for.
|
|
func (s *Server) handlePermit2Check(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
s.writeError(w, http.StatusMethodNotAllowed, fmt.Errorf("method not allowed"))
|
|
return
|
|
}
|
|
|
|
var req Permit2Request
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err))
|
|
return
|
|
}
|
|
|
|
if err := req.Validate(); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
|
|
want, ok := new(big.Int).SetString(req.Amount, 10)
|
|
if !ok || want.Sign() < 0 {
|
|
s.writeError(w, http.StatusBadRequest, fmt.Errorf("amount %q is not a whole number of the token's smallest unit", req.Amount))
|
|
return
|
|
}
|
|
|
|
if err := s.permit2On(s.requestContext(r), ChainID(req.ChainID)); err != nil {
|
|
s.writeError(w, http.StatusNotFound, err)
|
|
return
|
|
}
|
|
|
|
// allowance(owner, token, spender) -> (uint160 amount, uint48 expiration,
|
|
// uint48 nonce). The expiration is half the answer: a permit that has run
|
|
// out is not an allowance, and reading only the amount reports a wallet as
|
|
// ready to swap on a grant that expired.
|
|
held, err := s.read(s.requestContext(r), ChainID(req.ChainID), Permit2Address,
|
|
BuildPermit2AllowanceCalldata(req.Owner, req.TokenAddress, req.Spender))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
if len(held) < 2 {
|
|
s.writeError(w, http.StatusBadGateway, fmt.Errorf("permit2 on chain %d answered %d words, want 3", req.ChainID, len(held)))
|
|
return
|
|
}
|
|
|
|
live := held[1].Int64() > time.Now().Unix()
|
|
resp := Permit2Response{
|
|
Permit2Allowance: held[0].String(),
|
|
Permit2Approved: live && held[0].Cmp(want) >= 0,
|
|
}
|
|
if !resp.Permit2Approved {
|
|
if resp.SignatureRequest, err = BuildPermit2SignatureRequest(req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
s.writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// handlePermit2Build handles POST /v1/trade/permit2/build.
|
|
// Returns either:
|
|
// - An unsigned Permit2 approve() tx (on-chain allowance grant), or
|
|
// - An EIP-712 PermitSingle signature request (off-chain permit)
|
|
//
|
|
// Behavior is controlled by the "mode" query parameter:
|
|
// - ?mode=tx (default) returns an unsigned approve transaction
|
|
// - ?mode=sig returns an EIP-712 signature request
|
|
func (s *Server) handlePermit2Build(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
s.writeError(w, http.StatusMethodNotAllowed, fmt.Errorf("method not allowed"))
|
|
return
|
|
}
|
|
|
|
var req Permit2Request
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err))
|
|
return
|
|
}
|
|
|
|
mode := r.URL.Query().Get("mode")
|
|
if mode == "" {
|
|
mode = "tx"
|
|
}
|
|
if mode != "tx" && mode != "sig" {
|
|
s.writeError(w, http.StatusBadRequest, fmt.Errorf("invalid mode %q: must be \"tx\" or \"sig\"", mode))
|
|
return
|
|
}
|
|
|
|
if err := s.permit2On(s.requestContext(r), ChainID(req.ChainID)); err != nil {
|
|
s.writeError(w, http.StatusNotFound, err)
|
|
return
|
|
}
|
|
|
|
switch mode {
|
|
case "tx":
|
|
tx, err := BuildPermit2ApproveTx(req)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
s.writeJSON(w, http.StatusOK, tx)
|
|
|
|
case "sig":
|
|
sigReq, err := BuildPermit2SignatureRequest(req)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
s.writeJSON(w, http.StatusOK, sigReq)
|
|
}
|
|
}
|