You were right that the crypto is already there. Cardano signs with the
same Ed25519 key Solana does, through FROST, which luxfi/threshold and
luxfi/mpc support natively. The key was never the problem.
The address was. Solana's address IS the public key, base58. Cardano's
is blake2b-224 of that key inside a typed, network-tagged bech32
envelope. NETWORK_ADDRESS_TYPE mapped CARDANO_MAINNET to 'sol' — the
comment beside it said "placeholder" — so the bridge handed out a
Solana-shaped string and told people to send ADA to it. That is offered
on bridge-api.lux.network today.
cardano-address.ts builds the CIP-19 enterprise address: payment
credential only, no staking part, which is the right shape for custody —
a base address would commit to a staking credential the committee does
not hold.
blake2b-224 is NOT blake2b-512 truncated. Blake2b takes its digest
length into the parameter block, so they differ from the first byte, and
Node's fixed-length blake2b512 cannot be used. There is a test asserting
exactly that, so anyone substituting a truncating implementation finds
out immediately rather than after the funds move.
Also asserted: the address is not the public key (the bug this
replaces), the network tag distinguishes mainnet from testnet, a key
that is not 32 bytes is refused rather than hashed into a plausible
address for a key that does not exist, and encode/decode agree.
The type union gained 'ada' rather than the map being widened to string,
so the next family added has to be handled in the switch instead of
falling through to 'eth' — which is how Cardano would have got an
Ethereum address if the default had caught it.
25 networks in the config now. Nineteen EVM, all assemblable; Bitcoin,
Solana, TON, XRP, Polkadot and Cardano correctly excluded from the EVM
assembler.
NOT VERIFIED against a live Cardano node — the encoder is
self-consistent and matches CIP-19 as written, but no address produced
here has been used on chain. Worth one testnet deposit before mainnet
funds move through it.
An empty teleporter is a mode, not a gap. Ethereum and Polygon carry
empty ones and cross fine, because the committee holds an address on the
chain and a deposit to it is the lock. I had read those empty strings as
"cannot bridge" and called three real networks fictional on the strength
of it. They are real; what was missing was our row, not their existence.
Custody already spans all of these — CGGMP21 on secp256k1 for every EVM,
FROST on Ed25519 for substrate — and the address type defaults to `eth`,
so every EVM network added here gets a correct deposit address with no
new key material and no new ceremony.
Added: Celo, Zora, Blast, Linea, Gnosis, Fantom, Aurora (EVM, chain ids
from the live registry rather than memory) and Polkadot (substrate, no
EIP-155 id, SS58 via encodeSubstrateAddress). Nineteen EVM networks are
now offered and all nineteen assemble.
Each carries its native asset row. A network in four of the five tables
a crossing needs is one whose gap is found after the user has sent
funds, so TestEveryOfferedNetworkHasItsNativeAsset now walks the config
and refuses that state.
CARDANO IS DELIBERATELY LEFT OUT, and this is the one to look at:
mpc-wallet.ts maps CARDANO_MAINNET to address type `sol`, with the
comment "placeholder — needs Ed25519". Both are Ed25519 so the key is
fine, but a Cardano address is bech32 addr1... and a Solana address is a
base58 public key. Anyone bridging to Cardano is handed a Solana-shaped
address to send ADA to. It is offered on bridge-api.lux.network today.
Adding it here would have spread a funds-losing bug rather than fixed
it; it goes in when it has an encoder.
BRIDGE_API_HOST fell back to https://bridge-api.lux.network. That is a
different server — the older Express backend — and it serves a different
set of networks. So an unset environment variable moved every user onto
another backend, silently.
Measured rather than assumed. bridge-api offers 22 networks, this origin
offers 13, and the nine extra are not nine extra capabilities:
Celo, Zora, Blast, Linea real teleporter and vault. Genuinely
missing from this origin's config.
Gnosis, Fantom, Aurora teleporter and vault are EMPTY STRINGS.
Offered to users and cannot bridge.
Cardano, Polkadot no contract entry at all.
Three of those nine are the Arbitrum bug again: a chain a user can
select, send funds to, and then discover has nowhere to go. A default is
not the place to change which backend answers.
Same origin is the one answer that cannot be wrong — whatever serves the
page serves its API — and it removes the whole class rather than
correcting the hostname.
The end state is neither server. B-Chain is the bridge, and the SDK
already has BridgeConfig.rpc to reach it. Today B-Chain answers a canned
stub — identical bytes for a real method, a nonsense method, or an empty
body — so it is not yet somewhere to point. The comment says that
instead of implying the switch is available.
Still to do, and not done here: the four real networks belong in this
origin's config, and the three fictional ones should stop being
advertised anywhere.
Arbitrum, Optimism and Avalanche were in the picker on
bridge.lux.network and the assembler had never heard of them. A swap to
any of the three is accepted, takes the user's funds, and then cannot
build the release — the failure lands after the money has moved, which
is the worst place in the flow to find a gap.
The cause was a second copy of data that already existed. The networks
file declares `chainId` and `type: evm` per network; evmChainIDs said it
again in Go, and the two drifted. configureEVM now reads the config, so
a network the operator offers is a network the assembler knows by
construction. The table stays as a fallback for a config that declares
no id, and only for that.
A network with no usable id stays unregistered rather than guessing. An
EIP-155 id is signed into the transaction: a wrong one produces a
signature valid on some OTHER chain, so refusing beats inventing.
TestEveryOfferedEVMNetworkCanBeAssembled walks the config and fails on
any EVM network the assembler lacks or disagrees with about the id.
Verified by mutation — dropping Arbitrum reports it by name. The
companion test checks the other direction: Bitcoin, Solana, TON and XRP
must NOT be registered as EVM, because giving a chain an EIP-155 id it
does not have is inventing a fact about it.
TestEstateChainIDsAreConfigured now passes nil networks, keeping the
fallback table as its subject rather than silently testing the config.
The bridge settled to Lux and Zoo but not to the other three chains Lux
runs, so the set it offered was smaller than the set the exchange lists.
Each is an EVM L1 whose custody address is the same secp256k1 key at the
same address it already derives, so a crossing needs no new key material.
A network reaches a user through five tables that are edited separately —
address family, deposit endpoint, broadcast endpoint, assembler endpoint,
asset row — and a chain in four of them fails after the user has already
sent funds. estate_test reads all five and the served config, so the next
chain either arrives everywhere or fails here.
The assembler's chain ids move out of main into evm.go: an EIP-155 id is
what goes into the signature, and a wrong one is valid somewhere else.
Native symbols are the ones each chain's own registry reports — Hanzo's
coin is AI. Zoo was serving live but missing from the checked-in config.
Co-authored-by: Hanzo Dev <[email protected]>
luxd registers /v1/ and nothing else; api.lux.network, api.zoo.ngo,
api.hanzo.network, api.pars.network and api.osage.network all answer 200 on
/v1/bc/C/rpc and 404 on /ext/bc/C/rpc. Every /ext/ path aimed at one of our
hosts was a request that could only fail.
/ext/ is Avalanche's prefix. Endpoints on api.avax.network keep it — they are
someone else's convention and a real bridge corridor depends on them.
Co-authored-by: Hanzo Dev <[email protected]>
The header named the signing protocol — `MPC · CGGMP21` — and a transfer row
badged it again. Neither is something a visitor can do anything with, and the
header is the surface everybody reads. The copy now says what a person needs:
which network, that a deposit address is involved, that no single party can
release the funds. The cluster still runs whatever BRIDGE_MPC_PROTOCOL selects.
The pickers could not be operated from a keyboard. Every key was answered on
the menu and nothing ever focused the menu, so opening with Enter left the
arrows dead. They are answered on the trigger now, which holds focus for the
whole interaction, with aria-activedescendant naming the row the cursor is on.
Each picker takes a label bound to it or a name read with it, and the prop type
admits no third shape.
The brand primary is #000000 and the SDK painted its selected row, its live
phase dot, the refuel toggle and the header's only call to action with it —
black on a black page. Marking a thing is the neutral scale's job. The accent
stays as the published brand hook; nothing inside the SDK reads it.
The rest is the page reading like the exchange it sits beside: a flat ground,
panels separated by an edge rather than a shadow, one type scale, field names
in one case, and hints raised out of the ink you have to lean in for.
@luxfi/bridge 1.0.9.
Lux Partners Limited is not the entity behind this work. Every place the
old name stood — copyright lines, site footers, brand config, legal and
compliance text — now names the company that actually holds it.
lux.fund is the exception and is not renamed to match: that site belongs
to Lux Assets Management LLC, a different company, and it says so.
Two kinds of line were left for a person rather than rewritten. A dated
news item recording that Lux Partners Limited was established in the Isle
of Man is a historical fact, and renaming it would falsify the record. And
a sentence asserting that the company is domiciled in the Isle of Man is a
claim about a jurisdiction, not a name — an Inc or an LLC is not an Isle
of Man company, so swapping the name there would assert something untrue.
The SPA build lived in .github/workflows/docker.yml, which the forge no longer
dispatches, so ghcr.io/luxfi/bridge stopped publishing on the plain semver line
while the `-server` line kept building from .hanzo/workflows. Deployments that
pin a plain tag were left on a June bundle whose chain RPCs still used the
retired /ext/ prefix and addressed zoo's EVM as `zoo` rather than `C`, so every
RPC call 404s. brand.json on main already carries the /v1/bc/C/rpc form; this
just gets it built.
Adds a sha- tag so a deployment can pin a digest, and drops the two superseded
.github workflows (docker.yml, and cmd-bridge-image.yml which bridge.yml
already replaced).
Co-authored-by: Hanzo Dev <[email protected]>
Quotes for the two flagship corridors returned "no bridge route": the
quote engine had only a 1:1 wrap table with no cross-asset edges. Add a
separate priced corridor table (LUX<->ZOO, LUX<->ETH, read symmetrically)
kept apart from the wrap table on purpose — a 1:1 cross-asset edge would
re-mint value. A corridor settles at amount * price(src)/price(dst) minus
the 1% Lux exit fee, pricing every core asset (LUX, ZOO, ETH, LBTC) from
the token price feed. It never invents a price: a missing leg throws and
surfaces as 503 price_unknown, cleanly distinct from the 400 of a
genuinely unroutable pair. Destination release still needs the live MPC
signer cluster.
Co-authored-by: Hanzo Dev <[email protected]>
github.com held commits this host lacked, which refuses the mirror push
("fetch first") and stops github receiving anything at all. Merging rather
than overwriting, so the mirror is a fast-forward again and neither side's
work is dropped.
Adds .hanzo/workflows/cicd.yml calling the canonical pipeline and drops the
GitHub-hosted workflows it replaces. Lands here because this is where the repo
is written — git.hanzo.ai is upstream and github.com is its push mirror, so the
same change made on github.com never reaches the runner fleet.
LLM.md is the source and stays the source; CLAUDE.md is a symlink to it, so there is
one file and it cannot drift from itself. What changes is that the link is committed
rather than left for each checkout to recreate.
Left untracked, the doc was conditional on a step outside the repo: a fresh clone had
no CLAUDE.md, nothing loaded LLM.md, and an agent worked here having never read what
this repo already explains. Most of the estate already tracks theirs; this is the rest
agreeing with them.
The app shipped its icon only as a data: URI in the markup, so nothing
existed at /favicon.ico. A browser asking for that path on its own fell
through the SPA catch-all and got index.html back with a text/html type,
which is not an icon by any reading.
Put the mark in public/ as favicon.svg and favicon.ico so the embedded
file server answers with an image type, and point the tags at them. The
tags stay in the static HTML because both the tenant rewrite in
frontend.go and bridge.config.ts need the elements to already be there.
The mark itself is @luxfi/[email protected] getFaviconSVG, the same hairline
triangle every other Lux surface now shows.
Co-authored-by: Hanzo Dev <[email protected]>
The Go all-in-one image (cmd/bridge/Dockerfile) — what lux/pars/zoo-bridge
actually run — was built by .github/workflows/cmd-bridge-image.yml, which the
forge stopped dispatching when it dropped .github/workflows on 2026-08-08. That
froze the deployed image at a June build (e450b15d). This ports the build here
with the forge's ghcr auth (GHCR_USER/GHCR_TOKEN), no private token (all deps
are public), tagged -server so it never collides with the SPA image.
Lux Bridge is core network infrastructure meant for OSS developers to
build on, so it moves from the proprietary Lux Ecosystem License
(patent-protected tier) to BSD 3-Clause — the same permissive license
the rest of the Lux core stack carries (cli, sdk, genesis). Also
unifies the seven per-package license fields (MIT/ISC/BSD mixed) onto
the one repository license. No vendored modified upstream, so no
separate NOTICE is owed; third-party deps keep their licenses through
the module graph.
Same patch landed on both sides during the mirror flip (github got it
directly, the forge got it as a cherry-pick); this merge makes the
github commit an ancestor so the push-mirror fast-forwards instead of
refusing a diverged ref.
Liveness probe failed: HTTP probe failed with statuscode: 429
Container bridge-server failed liveness probe, will be restarted
Observed in lux-bridge as a restart every few minutes. `trust proxy` is set, so
real traffic keys on the client, but probes arrive straight at the pod IP with no
X-Forwarded-For and therefore all share one key. Readiness every 5s plus liveness
every 10s is ~270 requests per 15-minute window against a limit of 100, so
/health starts answering 429 and the platform reads that as a dead process.
Health is infrastructure asking whether the process is alive. Rate-limiting the
answer only teaches the kubelet to kill it.
Surfaced by removing the cors-patch ConfigMap: production's entry point had been
a March build of server.ts that predates this limiter, so the limiter had never
actually run in this deployment until now.
Co-authored-by: Hanzo Dev <[email protected]>
Port of f3e25de from preserve/lux-bridge-golive onto main's
architecture. A BTC release now parks in
bridge_transfer_pending_confirmation on mempool admission and only
completes once mined; a release unconfirmed past --btc-confirm-timeout
(or fee-rejected at submit) is reset for re-sign at a bumped RBF
feerate — PreSign takes MinFeeRateSatPerVB as a floor over the live
estimate, and the paid rate persists on the swap (LastFeeRate) so each
rebuild strictly out-bids the last (BIP-125). Rebuild cycles share one
ceiling (default 5); past it the swap fails loudly. The broadcast
client answers confirmation status from the same mempool.space base it
posts to; /metrics gains bridge_btc_confirm_checks_total /
_timeouts_total / bridge_broadcast_rebuilds_total.
Liveness probe failed: HTTP probe failed with statuscode: 429
Container bridge-server failed liveness probe, will be restarted
Observed in lux-bridge as a restart every few minutes. `trust proxy` is set, so
real traffic keys on the client, but probes arrive straight at the pod IP with no
X-Forwarded-For and therefore all share one key. Readiness every 5s plus liveness
every 10s is ~270 requests per 15-minute window against a limit of 100, so
/health starts answering 429 and the platform reads that as a dead process.
Health is infrastructure asking whether the process is alive. Rate-limiting the
answer only teaches the kubelet to kill it.
Surfaced by removing the cors-patch ConfigMap: production's entry point had been
a March build of server.ts that predates this limiter, so the limiter had never
actually run in this deployment until now.
Co-authored-by: Hanzo Dev <[email protected]>
Error: Cannot find module 'express'
Require stack: - /app/dist/server.js
The image built, pushed and then could not start. pnpm links dependencies with
RELATIVE symlinks that count directories from where they sit —
app/server/node_modules/express -> ../../../node_modules/.pnpm/express@…
app/server/node_modules/@luxfi/core -> ../../../../pkg/core
— and the runtime stage flattened app/server/node_modules into /app/node_modules,
which moves every one of those links two directories too high. They then resolve
above the filesystem root and the first require fails. Nothing about the copy
looks wrong; the links are simply measured from somewhere else now.
So the layout is reproduced exactly rather than tidied, and WORKDIR moves to meet
it. /app/pkg comes along because @luxfi/core and @luxfi/utila are workspace links
that point into it.
Caught by the smoke rather than by production, which is the whole reason it runs
the container instead of trusting a green build.
Co-authored-by: Hanzo Dev <[email protected]>
RollupError: [plugin typescript] @rollup/plugin-typescript: Path of
Typescript compiler option 'outDir' must be located inside the same
directory as the Rollup 'file' option.
No tsconfig in the chain sets outDir, so the plugin defaults it to the
tsconfig's own directory — pkg/utila — and then rejects it, because it requires
outDir to sit inside the directory each Rollup `file` writes to. Both outputs go
to dist/, so one value satisfies both.
This fails identically on a laptop, so it is not a container problem: the
package has simply been unbuildable since the toolchain moved under it, and
nobody noticed because dist/ was built once long ago and has been sitting there
older than its own sources. It only surfaced now because app/server's image
needs to build it from a clean checkout.
Verified: dist/index.cjs.js exports createGrpcClient and
serviceAccountAuthStrategy, which are what domain/utila.ts and
domain/cosigners.ts import.
Co-authored-by: Hanzo Dev <[email protected]>
The daemon on these runners is a DinD sidecar, so `docker run -p 3000:3000`
publishes into the daemon's network namespace rather than the job's — `curl
localhost:3000` from the step would miss a perfectly healthy container and fail
the build for a reason that has nothing to do with the image. luxfi/kms, which
builds here, probes with `docker exec` for the same reason.
It also cannot use curl: the image is node:20-alpine and has none. node's global
fetch is already there, so the probe uses that and needs nothing installed.
Co-authored-by: Hanzo Dev <[email protected]>
Error: Cannot find module '/app/scripts/patch-hanzogui-exports.cjs'
The root package.json runs that script on postinstall, so pnpm install exits 1
without it and the deps stage never completes. Copied rather than skipped with
--ignore-scripts, which would also drop @prisma/client's postinstall and would
make the image's install a second, subtly different install from the one
everyone runs locally. It guards every path with existsSync, so with none of the
@hanzo/gui packages present it is a no-op.
Second failure in the same stage, found the same way: one broken step at a time,
now that the build actually reports.
Co-authored-by: Hanzo Dev <[email protected]>
ERR_PNPM_WORKSPACE_PKG_NOT_FOUND In app/server: "@luxfi/utila@workspace:^"
is in the dependencies but no package named "@luxfi/utila" is present
in the workspace
app/server took a dependency on @luxfi/utila and the Dockerfile was never told.
It copies a manifest per workspace dep, and pnpm resolves `workspace:` ranges
against the manifests present in the tree — a missing one is not a slow fallback
to the registry, it is a hard failure. So every build of this image since that
dependency landed has died in the deps stage, which is why ghcr's newest tag
predates it.
utila also has to be BUILT: its package.json points main at ./dist/index.cjs.js
and dist is gitignored, so an unbuilt dep fails at container start rather than at
build time. domain/utila.ts and domain/cosigners.ts import createGrpcClient and
serviceAccountAuthStrategy for real, not just as types.
Found because .hanzo/workflows/server.yml finally made the failure visible. It
was never a CI problem — .github/workflows queued forever against a dead pool and
reported nothing, so a broken Dockerfile looked exactly like a missing runner.
Co-authored-by: Hanzo Dev <[email protected]>
This is the UI that is actually deployed — the root Dockerfile builds app/bridge,
which mounts @luxfi/bridge, and lux-bridge, zoo-bridge and pars-bridge all run
that image. Its destination picker offers EVERY network for every source, so
choosing a pair the bridge cannot route is an ordinary thing to do, not an edge
case.
Until now such a pair returned 200 with a fabricated number. It now returns 400
and names the problem — "no bridge route between LBTC and ZETH" — and that
sentence is the whole diagnosis. The hook was throwing it away and rendering the
status code, which reads as the site being broken rather than as the route not
existing.
Falls back to the status when the body carries no message, so nothing ever
renders "undefined" or "[object Object]".
Co-authored-by: Hanzo Dev <[email protected]>
The forge synced e52e342, the file was present in its tree, and no job was ever
dispatched. luxfi/bridge arrives here as a MIRROR of GitHub, and a `paths:`
filter has to be matched against a diff that a mirror sync does not necessarily
compute, so the trigger silently never fires.
luxfi/kms builds on this same fleet and filters on nothing. Matching the shape
that demonstrably works is worth more than skipping the occasional build of an
untouched tree.
Co-authored-by: Hanzo Dev <[email protected]>
.github/workflows/server.yml could not ship this repo, three ways over:
- Its pool `lux-build-amd64` has had no GitHub-side runner since 2026-07-30.
Jobs queue and cancel at the 24h timeout, which is what every `cancelled
24h0m2s` run on this repo is. Nothing goes red; it just never happens.
- It only ran `kubectl rollout restart`, which delivers NOTHING against
bridge-server: the live deployment is digest-pinned with
imagePullPolicy IfNotPresent.
- It named no namespace, while the deployment lives in `lux-bridge`.
The forge runners are alive (16 of them) and advertise both `lux-build-amd64`
and `lux-build-linux-amd64` — read them back with
`kubectl -n hanzo exec git-runner-0 -- cat /data/.runner`. So the build moves to
.hanzo/workflows, which is where native CI belongs, and there is one lane again
rather than a live one and a dead one.
BUILD ≠ DEPLOY: this publishes an image and stops. bridge-server is governed by
Hanzo CD, so shipping is an edit to universe/deploy/lux-bridge/bridge-server.yaml.
Patching the live object from CI would be reverted ~90s later, which fails in the
worst way — the pod rolls and the old digest quietly returns. The manifest-verify
step prints the digest to pin.
The smoke is the point. A quote is not advisory: on an exit,
handlerUtilaPayoutAction reads back quotes.receive_amount as payoutAmount and
bridgeMints exactly it, and bridge-server serves /api for bridge.zoo.network,
bridge.zoo.ngo and bridge.pars.network — where 1000 LZOO quoted 47,142,857 ZOO
for months. So the image must answer 990 to that exact request, and 400 to an
unroutable pair, before a manifest is allowed to exist. Both run against the
built container, not the source tree.
docker.yml is left alone: it builds ghcr.io/luxfi/bridge from the root
Dockerfile, a different image and a different lane.
Co-authored-by: Hanzo Dev <[email protected]>
The unit tests cover convert(); these mount the real router on a real server and
speak HTTP to it, because the status code is part of the answer. The endpoint
used to return 200 with a confident number for a pair it could not honour, and
for an amount that was NaN — neither of which an arithmetic test can catch.
Six cases, each pinned to something production actually did: 990 where it
returned 900,000, the two directions agreeing where the ratio used to invert,
min_receive_amount equal to the quote, 400 for an unroutable pair, 400 for a
missing amount, and total_fee_in_usd null rather than the $1.00 that a caller
cannot tell from a real dollar.
axios is stubbed to fail. The only outbound call left on this path is the
display-only fee price, and "the lookup failed" is exactly the state under test
— it is also the common case, since the feed does not list 9 of the 38 symbols
the bridge asks about. Keeps the suite off the public internet.
No database: the quote path no longer touches one.
Co-authored-by: Hanzo Dev <[email protected]>
The dev server served a dead page: nav rendered, the swap form's asset pickers
stayed loading skeletons forever, and no request to /api/settings was ever made.
No error on screen.
The requested module '/@fs/.../use-sync-external-store/shim/index.js'
does not provide an export named 'useSyncExternalStore'
mobx-react-lite does a NAMED import from that shim, which is CommonJS
(`module.exports = require('../cjs/…')`). Vite can only discover the named
exports of a CJS module by pre-bundling it; left externalised it serves the file
raw and the import throws at module scope, taking out `observer()` and with it
every observed component — which is the whole swap form.
It started when use-sync-external-store 1.6.0 added an `exports` map. The shim's
code is byte-identical to 1.4.0; only its resolution changed, and the optimizer
stopped picking it up. So this names mobx-react-lite in optimizeDeps.include
rather than pinning the version back: the CJS-ness is permanent, and a pin would
just defer the next resolution bump.
It must be mobx-react-lite and not the shim itself — under pnpm the shim exists
only inside mobx-react-lite's own node_modules, so Vite cannot resolve it from
this app and rejects the entry outright ("Failed to resolve dependency ...
present in optimizeDeps.include"). Pre-bundling the package that owns the import
resolves it from where it actually lives; the shim's source ends up inlined.
optimizeDeps is dev-only — the production build goes through rollup's commonjs
plugin, which never had this problem.
Co-authored-by: Hanzo Dev <[email protected]>
Records the two things that are easy to undo by accident — that a bridge pair is
a wrap so no price belongs on the quote path, and that the pair relation is
symmetric and read rather than written twice — with the production numbers that
made the case, so the next person does not have to rediscover them.
Also records the delivery path as it actually is: the self-hosted pool has had
no runner since 2026-07-30, server.yml restarts a digest-pinned deployment (and
names no namespace), and the in-repo k8s manifest names an image production does
not run. Anyone reading only the repo would conclude a push to main ships.
Co-authored-by: Hanzo Dev <[email protected]>
Moving the conversion into handleSwapCreation left it downstream of two side
effects that outlive the request: createMPCWalletForDeposit mints a real
custody address, and prisma.swap.create writes the row the payout path later
reads back. Since convert refuses a pair the bridge has no route for, an
unroutable request would have minted an address and written a swap and only
then thrown — leaving a funded-looking address behind for input that was never
going to settle. The old code did not have this shape because it never refused
anything; it computed NaN and stored 0.
convert is pure — no clock, no network, no db — so it can run before any of
that, and now does. A test pins the purity, because the ordering is only safe
while it holds.
The route reports UnsupportedPair and a nonsense amount as 400. They are bad
input, and answering 500 would put them in the same bucket as a chain outage.
Co-authored-by: Hanzo Dev <[email protected]>
The destination picker was built from a local copy of the swap-pair table that
the client overwrote the server's response with — and the two had drifted. The
local copy was missing DOT and XRP entirely and did not list native SOL or TON
as destinations, so six assets the bridge routes could not be selected at all.
The server already sends the table it validates against; the client now uses
it. A second copy of a table is a copy that is already stale.
Reading it in one direction was the other half. 33 rows name a destination that
does not name them back — BTC lists LBTC, while LBTC lists WBTC, ZBTC and cbBTC
but not BTC — so the same route was offered inbound and refused outbound, with
nothing in the UI to say why. Wrapping and unwrapping are one route, so the
relation is symmetric by nature and is now read that way rather than written
twice; adding 33 reverse entries by hand would just re-open the gap the next
time an asset is added. 169 declared edges, 202 usable routes.
`swapPairs[src.asset].includes(...)` also threw outright for an asset with no
row of its own (LSIXR and ZSIXR are named as destinations but have none). The
throw escaped the mobx reaction, so setToNetworks never ran and the picker
rendered EMPTY — a crash that presented as a blank UI.
.env.example pointed at api-bridge.lux.network, which answers 530. The deployed
app has always called bridge-api.lux.network; copying the example into a .env
built an app that could not load its settings at all.
Co-authored-by: Hanzo Dev <[email protected]>
1000 ZLUX -> 900,000 LUX
1000 LZOO -> 47,142,857 ZOO
1000 LBTC -> 64,267,132 cbBTC
1000 LUX -> 1.089 ZLUX
measured against production. Every one should be 990. This is not a display
bug: on an exit, handlerUtilaPayoutAction sets payoutAmount from the stored
quote's receive_amount and bridgeMints exactly it, so the first three rows are
an unbacked mint and the fourth silently destroys 99.9% of the user's balance.
The quote was `amount * sourcePrice / destinationPrice`, priced per request off
Coinbase, and getTokenPrice answered 1 on any failure. Coinbase lists 29 of the
38 symbols the bridge asks about; the other 9 threw, so a third of lookups
returned an invented $1.00 that is indistinguishable from a real dollar peg.
A pair with one real price and one invented one gives a ratio that inverts when
you swap the legs — which is why the same route was wrong in opposite
directions, and why that looked like two bugs instead of one.
The formula was the mistake, not the feed. Take the connected components of the
pair table and all 33 are one asset wearing three names — {BTC, LBTC, WBTC,
ZBTC, cbBTC}, {ETH, LETH, WETH, ZETH}, {LUX, ZLUX}. There is no edge between
two different assets anywhere in it, so the rate is 1, exactly, both ways, and
only the fee comes off. No price is consulted, which retires the whole class:
an outage, a delisting, a rate-limit or a renamed ticker now change nothing
here, because nothing here asks. The executor already agreed — its entry path
is 1:1 and its fallback is amount*(1-fee); only the quote disputed it.
One `convert` in quote.ts; getQuote, getRate and the stored swap row project
from it. Three copies of the arithmetic could not promise that a swap settles
at the number the user was shown.
Also, because they were the same defect wearing other masks:
- Quoting an unroutable pair returned a confident number. `convert` refuses,
and the routes answer 400 rather than inventing a trade the bridge cannot
make.
- getTokenPrice returns undefined, not 1, when nobody quotes an asset. It is
display-only now, but a caller still has to be able to tell a price from the
absence of one.
- min_receive_amount advertised a 2.5% slippage band on an exact wrap, asking
the user to consent to less than the bridge can ever pay.
The peg classes are pinned in the test rather than derived: recovering them
means stripping an L/Z/W/cb prefix and comparing the remainder, and inferring
an asset's identity from the spelling of its ticker is the exact reasoning that
caused this (it also reads LUX as a wrapped "UX").
Co-authored-by: Hanzo Dev <[email protected]>
Port of the wallet-health work from preserve/lux-bridge-golive onto
main's architecture: canary-sign poller (10m default) against every
minted release wallet, cached snapshot surfaced on /metrics as
bridge_release_wallet_signable / _sign_latency_ms / _last_check_age,
PrometheusRule + ServiceMonitor for the o11y stack, and the operator
runbook section (re-mint, don't retry). Catches the June key-share
desync failure mode before a real payout does.
Small wrapper around Select, but two real bugs live in the mapping
layer if it regresses: the family->display-name table (with a
defensive fallback to the raw uppercased family for anything not in
the table -- covers a future ChainFamily addition landing before its
label does), and the SelectOption->Chain reverse lookup on commit,
where a wrong id match would silently call onChange with the wrong
chain entirely.
tokens/exchanges/limits/profileGET (the /v1/bridge/{tokens,exchanges,
limits,profile} handlers the SDK's config bootstrap depends on),
SetProfile, stripPathPrefix (the legacy Express-proxy Director), and
rpcErrToJSONRPC were all untested.
Worth calling out: stripPathPrefix's empty-path case (a request to
exactly the /v1/bridge prefix with nothing after it) must rewrite to
"/", not "" -- an empty URL.Path on an outbound proxied request is
invalid and would break against most backends. Also covers
rpcErrToJSONRPC's fallback contract: a *bchain.RPCError with Code==0
(a transport failure that never got a real JSON-RPC code) must still
map to -32603, not propagate 0, which isn't a defined JSON-RPC error
code.
New cmd/bridge/api_test.go, 13 tests.
Select's own header comment documents a real interaction contract --
click-outside closes, Escape closes, arrow keys move the highlight,
Enter/Space commits -- none of which had a dedicated test.
AssetInput.test.tsx exercises Select only incidentally (open + click
one option); this covers the contract directly, on the component that
owns it.
20 tests: open/close via click and via ArrowDown/Enter/Space at the
trigger, click-outside dismissing without committing (vs. a click
inside the popover, which must NOT dismiss), Escape closing and
returning focus to the trigger, arrow-key highlight movement clamped
at both ends, Home/End jumping to the first/last option, Enter and
Space both committing the HIGHLIGHTED option (not just whatever was
originally selected -- these are different indices once you've
navigated), hover updating the highlight, aria-selected reflecting the
current value, the secondary-label render, and the renderTrigger /
label props.
pkg/bridge test suite 130 -> 150 tests, all passing; typecheck clean.
TransferStatus renders the live transfer feed -- phase banners, the
deposit-address panel, MPC session detail, layered-cosigner badges --
the surface a user watches while their swap is mid-flight. Zero test
coverage despite the conditional logic (which banner shows for
lastError vs refunding vs refunded vs a terminal failure) being exactly
the kind of thing that's easy to get subtly wrong in a way that only
surfaces as "why does this say refunding when it already refunded."
17 tests cover: empty state, chain-name resolution (including the
fallback to the raw chainId for an unregistered chain), the deposit
panel's visibility rule (depositAddress set AND phase not yet terminal)
plus its copy-to-clipboard button, the MPC session line (including the
"session aborted" special case), the two layered-cosigner badges
appearing independently per config, and all four phase-banner branches
mutually excluding each other correctly.
useNetworks and getConfig are mocked (react-query + a mountBridge-gated
singleton this component's own logic doesn't need exercised); Card
renders for real -- it's a 37-line style wrapper with no logic.
Caught on a second pass: grep -I treats these as binary and skipped them, so the
first sweep reported clean while they still named a host that no longer resolves.
The .network host was retired 2026-07-27 and no longer resolves at all, so every
reference to it named an address that cannot answer. The brand host is .cloud,
matching kms.zoo.cloud and the rest of the white-label convention.
Left alone deliberately, because rewriting them would invert what they say: the
LLM.md line that RECORDS the retirement, the e2e spec that ASSERTS
kms.lux.network must not resolve, and the recorded applies + cluster backups,
which are faithful accounts of what was applied and are not config.
::error::@actions/artifact v2.0.0+, upload-artifact@v4+ and
download-artifact@v4+ are not currently supported on GHES.
git.hanzo.ai presents as GHES, so the v4 action refuses before doing any work.
The job runs its integration suite and then discards the artifact at the last
step, which reads as a failed build when the work succeeded.
Co-authored-by: Hanzo Dev <[email protected]>
The estate was spread across ten zip versions while the zipdoc/graft refactor
landed at v1.18.22. Measured before bumping: the API is additive across that
range -- 57 exported symbols at v1.17.2, 83 at v1.18.22, with exactly one
removal (`PluginStatus`), and no repo that pins zip references it.
Verified by building this module against the new version, not by assuming.
Co-authored-by: Hanzo Dev <[email protected]>
Every job in this file runs on a lux self-hosted pool, so GitHub is not the
executor -- but the login presented `secrets.GITHUB_TOKEN`, which git.hanzo.ai
mints and ghcr.io has never seen:
Error response from daemon: Get "https://ghcr.io/v2/": denied: denied
The login step failed and every job after it skipped, so the image was never
published. The pair is GitHub`s own idiom and is correct there; it is simply
not a credential anywhere else.
orgs/luxfi provisions exactly GHCR_USER + GHCR_TOKEN. Named directly rather
than through a `||` chain: a missing org secret evaluates to "", so a fallback
silently swaps credentials and the log never says which one was used -- that is
what made the same failure in luxfi/trader read as a registry problem.
Co-authored-by: Hanzo Dev <[email protected]>
Currency bump: golang builder images -> 1.26.5 (latest stable).
Adds ENV GOTOOLCHAIN=auto to each Go builder stage. The official golang
images set GOTOOLCHAIN=local, so a builder image older than the module's
go directive is a hard mid-build failure. The pin stays the hermetic fast
path; auto is the safety net for the next go.mod bump.
No go.mod go directive was changed. Builder images move; directives stay.
Co-authored-by: Hanzo Dev <[email protected]>
GOPRIVATE switches off the module proxy AND checksum verification together.
Covering github.com/luxfi/* meant every luxfi module was fetched straight from
git and trusted without verification — which is how moved tags got captured into
module caches and served from them indefinitely, making the same commit build on
one machine and fail on another.
Nearly all of luxfi is public and on proxy.golang.org, so it belongs under
normal verification. The private org is lux-private.
Twelve luxfi repos are still private AND still 404 on the proxy, so they are
listed individually rather than by wildcard — coverage now matches what actually
needs a direct fetch, and every other luxfi module is verified against
sum.golang.org again:
bridge broker compliance erc20-go fasthttp graph hsm indexer
mpc ordering password pool
The right end state is those repos moving to lux-private; until then the
explicit list keeps the exemption honest instead of blanket.
Co-authored-by: Hanzo Dev <[email protected]>
These hashes were recorded from direct VCS fetches made while GOPRIVATE covered
github.com/luxfi/*, which switches off the module proxy and checksum
verification together. Tags moved afterwards, so what is written down here no
longer matches what sum.golang.org holds and the build refuses to verify.
proxy.golang.org still serves the originally-published bytes and those verify
against the log, so dropping only the disagreeing lines and re-tidying restores
a verified module graph with no version change. Lines that agree are untouched;
this is not a regenerated go.sum.
The failure followed the machine rather than the repository — the same commit
built wherever the module cache was clean. Any machine or CI runner that
fetched luxfi modules under the old GOPRIVATE holds the same poisoned entries.
Co-authored-by: Hanzo Dev <[email protected]>
1.0.7 shipped `"@luxfi/threshold": "workspace:*"` in dependencies, so no
consumer outside this monorepo could install it — `npm publish` does not
rewrite the workspace protocol and `pnpm publish` does. npm versions are
immutable, so correcting it needs a bump.
@luxfi/threshold had never been published at ALL, which is why fixing the
specifier alone would not have been enough: the rewrite points at the
sibling's real version, and that version has to exist on the registry.
Published it first (1.0.0), then bridge, so the graph resolves.
Both verified before publishing — 0 workspace: refs, real dist (133 files
for bridge, 24 for threshold), and bridge's dependency rewritten to the
concrete "@luxfi/threshold": "1.0.0".
The fail-closed guard added in d7ec4757 ran on this publish and passed under
pnpm, which is what it is for.
Co-authored-by: Hanzo Dev <[email protected]>
@luxfi/[email protected] is broken on npm right now: its published manifest
carries `"@luxfi/threshold": "workspace:*"`, a specifier that only resolves
inside this monorepo. Every external consumer fails at install.
Cause: `workspace:*` is rewritten to a real version by `pnpm publish` and
NOT by `npm publish`. This package had no publish script, so it was
published by hand with npm and shipped the specifier verbatim. The source
still declares it, so the next hand-publish breaks again the same way.
The defect is invisible in CI and inside the monorepo — pnpm resolves
workspace:* fine locally, and only an external consumer ever sees it. So
the check belongs at publish time and has to fail closed: prepublishOnly
now refuses unless the publishing client is pnpm, including when the user
agent is absent.
Verified: refuses under npm, refuses with no user agent, passes under pnpm.
Not done here: republishing 1.0.7. That is outward-facing and affects
anyone already pinned, so it needs an owner.
Co-authored-by: Hanzo Dev <[email protected]>