api.lux.cloud and api.lux.network answer the trading surface
`ghcr.io/luxfi/dex` carries a second binary, `gateway`, and nothing ran it, so every quote a Lux interface asked for reached nothing. It runs here as `trade` — not `gateway`, because this namespace already has one in front of the validators' RPC. Two routers on one host and the chain's is the more specific: /v1/chain keeps priority 300 and this takes 200, above the bare-host router in the ingress file provider that has been answering these paths out of hanzoai/cloud. The paths are an allowlist. `PathPrefix(/v1)` would be shorter and would publish /v1/admin/pool/freeze, which the binary authorises on a request header the caller writes; those handlers stay reachable from inside the cluster and from nowhere else, and the header is stripped on the way in as well. 96369 is read through the V3 quoter that landed with the AMM today, and read over the cluster Service rather than back through Cloudflare — same endpoint, 15ms instead of 149ms, and no dependency on the edge that is serving the answer. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
# The trading surface on api.lux.cloud and api.lux.network — quote, swap, pools,
|
||||
# prices, tokens, stats, history.
|
||||
#
|
||||
# `ghcr.io/luxfi/dex` carries two binaries. `dexd` is the D-Chain venue and is
|
||||
# declared in dex.yaml; `gateway` is this one — an HTTP server that reads a
|
||||
# market wherever that market lives and answers in one shape, so a screen does
|
||||
# not have to know which chain it is looking at. One image because the two share
|
||||
# the venue types, and shipping them apart is two versions of one contract
|
||||
# waiting to disagree.
|
||||
#
|
||||
# It is named `trade` and not `gateway` because this namespace already has a
|
||||
# `gateway`: api-gateway-gateway is the CEL filter in front of the validators'
|
||||
# RPC. Two different things cannot answer to one name.
|
||||
replicas: 2
|
||||
containerName: trade
|
||||
image:
|
||||
repository: ghcr.io/luxfi/dex
|
||||
tag: v1.14.40
|
||||
digest: sha256:d7a965997af3beaaeaba3168ec0c3706923589b1c601944b9fa359186544a158
|
||||
pullPolicy: IfNotPresent
|
||||
imagePullSecrets:
|
||||
- name: ghcr-luxfi
|
||||
labels:
|
||||
lux.network/component: trade
|
||||
partOf: lux
|
||||
# The image's ENTRYPOINT is dexd. This is the other binary in it.
|
||||
command:
|
||||
- /usr/local/bin/gateway
|
||||
|
||||
# ⚠ THE IMAGE IS amd64 ONLY, and this cluster is mixed: evo is amd64, spark is
|
||||
# arm64. The Dockerfile builds with GOARCH=amd64 written out, and the published
|
||||
# manifest is a single OCI image rather than an index, so a kubelet on spark
|
||||
# pulls it, believes it, and the container exits instantly with
|
||||
# `exec /usr/local/bin/…: exec format error`. dex-0 is doing that today: 12
|
||||
# restarts before something on that node started emulating it.
|
||||
#
|
||||
# So the constraint is stated rather than left to the scheduler. It is a
|
||||
# property of the bytes, not a preference.
|
||||
nodeSelector:
|
||||
kubernetes.io/arch: amd64
|
||||
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
servicePort: 80
|
||||
|
||||
env:
|
||||
- name: GATEWAY_ADDR
|
||||
value: ":8080"
|
||||
- name: LUX_CHAIN_ID
|
||||
value: "96369"
|
||||
|
||||
# ⚠ NOT https://api.lux.cloud/v1/chain/C/rpc, which is the binary's default and
|
||||
# the same endpoint by its public name. Read from in here it is the WRONG name
|
||||
# for it, twice over:
|
||||
#
|
||||
# - it leaves the cluster, crosses Cloudflare and comes back through the very
|
||||
# ingress that is serving the quote. A quote is two to eight eth_calls, so
|
||||
# the round trips compound, and under load the surface and its own data path
|
||||
# contend for one entrypoint.
|
||||
# - it makes 96369 quotes depend on Cloudflare and the tunnel being up, when
|
||||
# the validator answering them is 15ms away on the LAN.
|
||||
#
|
||||
# Measured from this network, same request, same node:
|
||||
# http://10.0.0.21:9730/v1/chain/C/rpc eth_chainId -> 0x17871 in 0.015s
|
||||
# https://api.lux.cloud/v1/chain/C/rpc eth_chainId -> 0x17871 in 0.149s
|
||||
#
|
||||
# `lux-chain` is that endpoint already declared as a cluster Service — the
|
||||
# selector-less Service and EndpointSlice in deploy/lux-api/route.yaml. Naming
|
||||
# it here is the same endpoint by its in-cluster name, and no second copy of the
|
||||
# address.
|
||||
- name: LUX_RPC
|
||||
value: http://lux-chain.lux-mainnet.svc.cluster.local/v1/chain/C/rpc
|
||||
|
||||
# The V3 QuoterV2 deployed to 96369, from luxfi/standard
|
||||
# deployments/venue/96369.json. With it, 1 WLUX quotes 0.997 LUSD at the 3000
|
||||
# tier; without it the chain has no arm the gateway can read and every pair on
|
||||
# it answers "no venue here holds this pair".
|
||||
- name: LUX_V3_QUOTER
|
||||
value: "0x6EA2F79f743C809D1152657Ef5d5907f47a34cfc"
|
||||
|
||||
# ⚠ LUX_V2_ROUTER IS DELIBERATELY UNSET. That file's `Router` key
|
||||
# (0xbbD8d9A1E6bf5627A2F6D2aF38664bbe1cEF63Bb) is the V3 SwapRouter, not a V2
|
||||
# Router02: its factory() answers 0x6E5884…, the V3 factory, and WETH() reverts.
|
||||
# Handing it to the V2 arm makes every V2 quote an `execution reverted` — a
|
||||
# venue that always fails, reported as a market that has no price. The 12 V2
|
||||
# pairs on this chain are real and hold reserves; they become quotable when a
|
||||
# Router02 is deployed and its address lands in that file, and not before.
|
||||
|
||||
# A pool moves when somebody trades in it, so this window is short by nature.
|
||||
# What it saves is the second and third reader of the same pair in the same
|
||||
# second, and every eth_call saved is one a public endpoint is entitled to
|
||||
# refuse.
|
||||
- name: GATEWAY_CACHE_TTL
|
||||
value: "10"
|
||||
|
||||
# Nothing here is dialled by a service link, and one of those variables is a
|
||||
# name away from colliding with the four above.
|
||||
enableServiceLinks: false
|
||||
|
||||
# ⚠ NOT /healthz. That handler reports the HOSTED provider registry, and this
|
||||
# deployment registers no hosted provider on purpose — the Uniswap trade API
|
||||
# answers ACCESS_DENIED without a contract, so a provider that always refuses is
|
||||
# worse than none. With an empty registry /healthz answers 503 forever, and a
|
||||
# probe on it would hold every pod out of the Service while the surface works.
|
||||
#
|
||||
# /providers is the honest question at this granularity: does the mux answer.
|
||||
# Whether one chain's RPC is reachable is not a reason to take the pod out of
|
||||
# rotation — the other six still quote.
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /providers
|
||||
port: 8080
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 2
|
||||
failureThreshold: 3
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /providers
|
||||
port: 8080
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: '1'
|
||||
memory: 512Mi
|
||||
|
||||
# It holds no state, writes no file, and needs no privilege.
|
||||
#
|
||||
# The uid is stated even though the image already declares `USER dexd`, because
|
||||
# the kubelet cannot check a NAME against runAsNonRoot — it reads the image's
|
||||
# user field, finds a string, and refuses the container rather than guess:
|
||||
# container has runAsNonRoot and image has non-numeric user (dexd)
|
||||
# 1000 is the uid the Dockerfile creates dexd with.
|
||||
securityContext:
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
runAsNonRoot: true
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
|
||||
middlewares:
|
||||
# The edge answers for the surface when it refuses a request, and a refusal a
|
||||
# browser cannot read is a refusal nobody can act on. A 429 is generated by the
|
||||
# rate limiter, never reaches the binary, and so carries none of the CORS the
|
||||
# binary sets on its own answers — lux.exchange sees a CORS failure and reports
|
||||
# that instead of the rate limit that actually happened.
|
||||
#
|
||||
# Set on the way out, outermost in the chain, so it covers what the limiter
|
||||
# writes as well as what the gateway writes. The value is the one the binary
|
||||
# already sends: this is a public read of public pools, no credential ever
|
||||
# rides it, and the header is Set rather than added, so an answer that already
|
||||
# carries it still carries exactly one.
|
||||
- name: cors
|
||||
spec:
|
||||
headers:
|
||||
customResponseHeaders:
|
||||
Access-Control-Allow-Origin: '*'
|
||||
|
||||
# ⚠ THE ROLE IS NOT THE CLIENT'S TO STATE.
|
||||
#
|
||||
# The binary's /v1/admin/* handlers — pause the DEX, pause a pool, and freeze a
|
||||
# pool, which its own code calls IRREVERSIBLE — authorise on the request header
|
||||
# `X-User-Role: admin`. A header is whatever the sender types, so those routes
|
||||
# are not routed below and this strips the claim as well, on the way in.
|
||||
#
|
||||
# Two independent guarantees, not one: the router allowlist means no public path
|
||||
# reaches /v1/admin, and this means a request that somehow does reach it arrives
|
||||
# with no role. Same shape as `strip-identity` in the ingress file provider,
|
||||
# which exists because o11y trusts X-Org-Id the same way.
|
||||
- name: strip-role
|
||||
spec:
|
||||
headers:
|
||||
customRequestHeaders:
|
||||
X-User-Role: ''
|
||||
X-User-ID: ''
|
||||
|
||||
# A quote on any chain but ours is eth_calls against a public endpoint we hold
|
||||
# no account with. Unmetered, one caller can spend that budget for everybody and
|
||||
# the failure lands on lux.exchange, not on them. Generous enough that a screen
|
||||
# opening every pair at once never sees it.
|
||||
#
|
||||
# The source is Cf-Connecting-Ip because these hosts are behind Cloudflare and
|
||||
# the remote address the ingress sees is the tunnel.
|
||||
- name: trade-rate
|
||||
spec:
|
||||
rateLimit:
|
||||
average: 30
|
||||
burst: 60
|
||||
period: 1s
|
||||
sourceCriterion:
|
||||
requestHeaderName: Cf-Connecting-Ip
|
||||
|
||||
routes:
|
||||
# ONE HOST, TWO ROUTERS, AND THE CHAIN'S IS THE MORE SPECIFIC.
|
||||
#
|
||||
# api.lux.cloud and api.lux.network already carry the chain at /v1/chain
|
||||
# (priority 300 in deploy/lux-api/route.yaml) and the C-chain at the bare root
|
||||
# (310). Everything else on those hosts falls through to a router in the ingress
|
||||
# file provider — `api-hanzo`, a bare Host rule with no priority stated, so
|
||||
# Traefik gives it its rule length, about 118 — and lands on hanzoai/cloud,
|
||||
# which is where the 404 with `x-api-version:` in its headers comes from.
|
||||
#
|
||||
# 200 sits between: above the catch-all, below the chain. The same number
|
||||
# api-hanzo-unified uses on api.hanzo.ai for exactly this shape.
|
||||
#
|
||||
# ⚠ THE PATHS ARE AN ALLOWLIST AND THAT IS THE POINT. `PathPrefix(/v1)` would be
|
||||
# shorter and would publish /v1/admin/pool/freeze to the internet. What is
|
||||
# listed is what this surface answers; the conversion-tracking writes
|
||||
# (/v1/leads, /v1/events) and the admin handlers are reachable from inside the
|
||||
# cluster and from nowhere else.
|
||||
- name: trade
|
||||
spec:
|
||||
entryPoints: [websecure]
|
||||
routes:
|
||||
- match: (Host(`api.lux.cloud`) || Host(`api.lux.network`)) && (PathPrefix(`/v1/quote`) || PathPrefix(`/v1/swap`) || PathPrefix(`/v1/pool`) || PathPrefix(`/v1/position`) || PathPrefix(`/v1/price`) || PathPrefix(`/v1/token`) || PathPrefix(`/v1/stats`) || PathPrefix(`/v1/history`) || PathPrefix(`/trading/`))
|
||||
kind: Rule
|
||||
priority: 200
|
||||
middlewares:
|
||||
- name: cors
|
||||
- name: strip-role
|
||||
- name: trade-rate
|
||||
services:
|
||||
- name: trade
|
||||
port: 80
|
||||
tls: {}
|
||||
Reference in New Issue
Block a user