typescript 7.0.2 is the native compiler and exports no JavaScript API, so
ts-jest and typescript-eslint cannot read it (an earlier bare-7 pin here
had to be reverted for exactly that). It comes in aliased as tsgo and does
the typechecking; typescript stays on the 6 line for the tools. tsgo
surfaced two native-compiler gaps not present under tsc 6.0.3: a JS-inference
miss on two @luxfi/utila exports (app/server), and a missing CSS side-effect
ambient declaration that also covers @luxdefi/ui's own unbuilt .tsx source
(app/explorer). Both fixed with narrow ambient declarations. 16 diagnostics
across the 11 workspace packages before and after.
Co-authored-by: Hanzo Dev <[email protected]>
luxd serves chain routes under /v1/chain, and the node, cli and sdk releases
already ship it. The deployed gateways still answer the old prefix, so remote
reads break until they are redeployed; the estate moves together rather than
sitting half converted.
Lines that record an observation - a probe, a status code, a past release -
keep the prefix they were written against, as do URLs on hosts that are not
ours. Avalanche endpoints move to /ext/bc, which is the path that host serves.
One family. Font stacks name Zen and fall back to the native faces, tokens
name the role rather than a face, and the packages that used to supply a
face now point at @hanzo/font.
Self-hosted @font-face blocks are removed rather than renamed: rewriting the
family in one would leave a src pointing at a file this repo no longer ships.
@hanzo/font carries the binaries and declares the faces, so the local copies
go with the declaration that used them.
Comments naming a face we no longer use are deleted, not reworded.
The rename to /v1/chain is real in luxd source, but the deployed fleet has not
taken it yet: probed just now, api.lux.network, api.lux-test.network,
api.zoo.network and api.hanzo.network each answer 200 on /v1/bc/C/rpc and 404 on
/v1/chain/C/rpc. Composing /v1/chain against those hosts reaches nothing, which
is the failure the rename set out to prevent.
The move belongs with the node redeploy, not ahead of it.
luxd serves chain routes under /v1/chain; the /v1/bc prefix it replaced is
gone, so anything composing it reaches nothing. Lines that record an
observation - a probe, a status code, a past release - keep the prefix they
were written against, as do URLs on hosts that are not ours.
luxd serves chain APIs under one segment from constants.ChainAliasPrefix
(constants v1.6.4, node v1.36.181):
/v1/bc/<alias>/... -> /v1/chain/<alias>/...
/ext/bc/<alias>/... -> /v1/chain/<alias>/...
Observations keep their original addresses: a dated note or a recorded status
code is a fact about when it was written, not an instruction to follow.
Two things, and the first is the one that mattered.
THE TWIN. app/server had the same default-to-eth I removed from the Go
side an hour ago — twice, in keygen and in the deposit check. Fixing one
language and leaving the other is the "one fact, two places, one fixed"
shape this session keeps turning up, and app/server is the one actually
deployed at bridge-api.lux.network. Both now refuse: minting a wallet
for a network with no known address format produces an address on the
wrong chain, and checking one with the EVM checker reads an 0x address
on a chain that has none and reports "no deposit" forever rather than
reporting that it cannot look.
CARDANO CAN NOW BE WATCHED, so it goes back into the offered set. A
checker over Koios — public, read-only, no key — on both sides. The test
that refused Cardano an hour ago passes it now, which is the test doing
its job in both directions rather than only saying no.
Two details worth keeping: an address that has never been used is ABSENT
from a Koios response rather than present with zero, so an empty array
is "nothing here" and not an error. And lovelace arrives as a decimal
STRING because the value exceeds what a JSON number carries exactly —
compared as integers, since a float parse introduces rounding exactly at
the threshold where a deposit is judged sufficient.
The same defect as Cardano, in a different family, and also live. XRP
fell through to wallet.ethAddress with a comment saying derivation
"should be added when XRPL settlement ships" — so anyone bridging to XRP
was handed an 0x string to send XRP to.
XRPL's base58 alphabet is its own: `r` where Bitcoin has `1`. An address
built with the standard alphabet is well-formed and names a DIFFERENT
account, which is the trap — nothing rejects it, it simply points
somewhere else. There is a test asserting the alphabet is XRPL's.
Verified against the published genesis vector — public key
0330E7FC…FD020 encodes to rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh. That is
interoperability rather than self-consistency, which is what the Cardano
encoder still lacks and what its commit said to check before funds move.
Also asserted: a one-character change fails the checksum, an Ed25519 key
gains its 0xED prefix before hashing (hashing the bare 32 bytes gives a
valid-looking address for an account nobody controls), and a key that is
neither 32 nor 33 bytes is refused rather than padded into something
plausible.
This is the second placeholder address type found by looking. Both were
comments admitting the gap next to code that shipped anyway.
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.
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.
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.
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]>
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]>
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.
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]>
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]>
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]>
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]>
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.
luxd serves exactly one prefix, /v1 (node/server/http/server.go:33), and
has never served /ext. createBridgeRPCClient's two defaults are the SDK's
whole contract for a caller who passes only nodeUrl — both pointed at a
404, and a caller had no way to see it from the outside.
Two Zoo endpoints were also aimed at the wrong network entirely:
api.lux.network/ext/bc/zoo/rpc and api.zoo.network/ext/bc/Z/rpc. Zoo is
sovereign on api.zoo.network (200200) and api.zoo-test.network (200201),
both confirmed serving /v1/bc/C/rpc; /v1/bc/Z/rpc and /v1/bc/zoo/rpc are
404. Env is the hostname, never a path segment.
Patch-bumps @luxfi/bridge 1.0.6 -> 1.0.7 so the fixed defaults ship. Note
1.0.6 was never actually on the registry — `npm view @luxfi/bridge` is a
hard 404 with no versions, while @luxfi/ui resolves fine from the same
scope, so this is a first publish rather than a re-publish.
Tests pin both defaults and assert the overrides still win. They fail on
/ext and pass on /v1. Full suite: 11 files, 94 tests.
Co-authored-by: Hanzo Dev <[email protected]>
The repo resolved three majors of the same set at once — 0.307.0 (via
@luxdefi/ui), 0.456.0 (the catalog, used by bridge3) and 1.16.0 (pinned
directly by explorer). Same import name, three different drawings. Every
app now takes lucide from `catalog:`, and an override collapses the
transitive copy, so one version resolves for the whole tree.
app/bridge3/app/components/icons/ was a second set wearing the first
one's clothes: avatar/bag/left-arrow/right-arrow were hand-drawn, typed
as LucideProps, off-grid (17x21, 9x16, 1019x1281) and filled rather than
stroked — three of them with `fill="white"` baked into the path, so they
were invisible on a light background and could never take the theme.
None had a consumer. Deleted; Lucide draws all four (User, ShoppingBag,
ChevronLeft, ChevronRight).
What stays in that directory is not an icon set: the Lux mark and the
third-party network marks are logos, and logos are not glyphs.
explorer also declared @heroicons/react and heroicons with zero imports
of either — a set you cannot see is still a set you can reach for.
Co-authored-by: Hanzo Dev <[email protected]>
TypeScript 7 is the native Go compiler and removes `baseUrl` and
`moduleResolution: node|node10`. Both appear here, so `tsc` from TS7
refuses the config outright (TS5102 / TS5108) and cannot typecheck.
`paths` targets resolve relative to `baseUrl` when it is set and relative
to the tsconfig file otherwise. Every `baseUrl` folded here already
pointed at the config's own directory, so dropping it moves nothing and
the targets are left byte-identical. Where a baseUrl pointed elsewhere,
each affected target was rewritten as join(baseUrl, target).
`moduleResolution` was chosen from the declared `module`: commonjs ->
node16, esnext/preserve -> bundler. Configs whose `module` is unset or
exotic were left alone rather than guessed at.
The result is accepted by BOTH toolchains, so nothing has to upgrade
TypeScript in lockstep. Verified on hanzo/chat packages/api: tsc 5.9
779 -> 778 errors (no regression), and tsc 7.0.2 now runs the project
in 2s where it previously refused the config.
Co-authored-by: Hanzo Dev <[email protected]>
The backend origin allow-list only permitted bridge.lux.network + *.lux.*,
so a swap POST from a tenant bridge (Zoo/Pars/Hanzo) — which carries an
Origin header even same-origin — was rejected (500). Add the explicit
tenant hosts (zoo.network, zoo.ngo, pars.network, hanzo.ai) alongside the
Lux regex. Networks (GET) already load same-origin via the ingress /api
route; this completes the swap path for the next bridge-server release.
Co-authored-by: Hanzo Dev <[email protected]>
Two white-label defects surfaced by the universe branded e2e suite:
1. HEAD <meta name="description"> stayed "Cross-chain bridge for the Lux
Network." on Zoo/Pars surfaces (title/favicon/logo were already
white-labeled) — a foreign brand in a non-Lux head. applyBrandMetadata
now rewrites description + og:description + twitter:description from the
brand block (creating them if absent). bridge.config.ts derives the
description from the already-white-labeled brand name (single source;
BRIDGE_DESCRIPTION overrides), so a non-Lux surface never leaks "Lux".
Extended the __ENV.js contract (docker-entrypoint.sh + public/__ENV.js).
2. Body font fell back to Times serif — the intended face never loaded and
.bridge-root only styles the mount subtree, leaving <body> at the UA
default. Ship Basel Grotesk (the shared BRAND_TOKENS face) as a bundled
webfont, prepend it to --bridge-font, and apply the font to html/body so
the page owner inherits the brand sans stack.
Tests: pkg/bridge 91/91 pass (config.test.ts adds DOM coverage for the
description rewrite + the zero-foreign-leak invariant).
Co-authored-by: Hanzo Dev <[email protected]>
bridge.config.ts already reads BRIDGE_BRAND_NAME, BRIDGE_FAVICON_URL,
BRIDGE_PRIMARY_COLOR, BRIDGE_SUPPORT_EMAIL, BRIDGE_DOCS_URL from
window.__ENV (added f4e17d69), but the /__ENV.js default template and
the docker entrypoint only wrote BRIDGE_LOGO_URL — so an env-based
tenant deploy could not set the brand name, favicon, footer email or
docs URL, leaving non-Lux tenants (zoo, pars) showing the Lux fallback.
Template all five keys at container boot and list them in the default
__ENV.js so one image white-labels by env: brand name -> header +
document.title, favicon URL -> tab icon, support email + docs -> footer.
Co-authored-by: Hanzo Dev <[email protected]>
The app/bridge SPA baked @luxfi/brand at build time and the Go server served
brand at /envs.js while the SPA loads /__ENV.js — so a white-label tenant
(e.g. zooai/bridge-shim) rendered the Lux brand. Complete the stated
'jurisdiction-neutral, tenant supplies brand' design:
- bridge.config.ts: brand name/favicon/primaryColor/supportEmail/docsUrl are now
window.__ENV-overridable (logoUrl already was); @luxfi/brand stays the
fallback so the Lux tenant is byte-unchanged when no override is set.
- frontend.go: serve /__ENV.js (the endpoint the SPA actually loads) projecting
the tenant brand+IAM into the BRIDGE_* keys; applyBrandMetadata() then themes
title/favicon/logo/color at mount. Empty when no tenant → Lux fallback.
- main.go: pass the tenant config into NewFrontend.
Squash of the local main lineage onto the canonical origin/main. Net tree only
(62 files): stale docs pages removed, tenant config + tests updated, minor Go
fixes, refreshed opnet-watcher build. No third-party white-label branding in
code or history.
Co-authored-by: Hanzo Dev <[email protected]>
Connect a wallet on every bridge ecosystem, not just EVM. wagmi stays the
EVM/Lux deposit signer; @luxwallet/connect adds the connect/identity layer
for Solana, Bitcoin, TON, XRP and Polkadot — chains wagmi cannot reach.
- useLuxWallet: non-EVM connect via getConnector(chain).connect() + SIWx login
- useBridgeWallet: composes wagmi + luxwallet, routes by chain family
- WalletConnect: family-aware — EVM picker vs non-EVM single-tap connect
- chains/registry: DEFAULT_CHAINS derived from @luxwallet/chains (one source
of truth for the selector); covers all six connectable ecosystems
- wallet-family: the one place mapping bridge family -> wallet stack
- SwapForm: source/dest address resolved across both stacks
- TON manifest for the tenant app; deps via file: (TODO -> npm)
Additive + non-breaking: existing EVM flow unchanged. No WalletConnect
projectId on the non-EVM path. MIT/Apache/ISC deps only, zero GPL.
87 tests pass (8 new); tsc clean for SDK + tenant app.
Rule: luxfi/bridge ships ONLY the Lux brand. Foreign-org bridge tenants
(Hanzo, Zoo, Pars, SPC, ) live in their own repos as thin
shims that consume @luxfi/bridge plus their own brand package.
- rm manifests/tenants/{hanzo,zoo,pars,spc}.yaml (carried foreign-brand
logos/colors/names into a Lux repo)
- REQUIREMENTS.md: drop tenant goal, drop Phase 2
Zoo-tenant stand-up + Hanzo-as-tenant deliverables, drop
@zooai/brand / @hanzoai/brand publication tasks; scope tenant work
to Lux only and embed target to luxfi/exchange
- docs/LLM.md (symlinked as CLAUDE.md/AGENTS.md/QWEN.md/GEMINI.md):
drop @zooai/brand / @hanzoai/brand from the architecture legend;
rewrite "Hanzo-side bridge" section as "foreign-brand tenants live
in their own repos"
- add app/bridge/public/.well-known/bridge.json declaring brandId=lux,
domain bridge.lux.network, peers [] (federation peers via runtime
ConfigMap), capabilities [bridge, mpc], apiVersion 1
- add BRAND-AUDIT.md documenting findings + actions
Kept (not org brand): "Liquid <TOKEN>" wrapped-asset names are Lux's
own bridge token brand (Liquid BTC, Liquid ETH, ...), wrapper minted
on Lux C-Chain per manifests/schema.yaml. Kept (chain identity):
manifests/tenants/lux.yaml, manifests/chains/*, chain RPC config,
teleporter contracts.
Final NATS/per-bridge-MPC residue swept. All bridge → mpcd traffic now
flows through the canonical MPCClient HTTP wire protocol — same one as
~/work/lux/kms/pkg/mpc/client.go in Go.
────────────────────────────────────────────────────────────────────────
mpc-signer.ts (153 → 184 LOC; net simpler — one round-trip vs polling)
────────────────────────────────────────────────────────────────────────
Was: axios.get(/api/v1/wallets/{id}) + axios.post(/api/v1/transactions)
+ 30-iter 2s poll loop reading /api/v1/transactions/{id} for status
transitions. Static MPC_API_TOKEN bearer (no IAM-minted JWT).
Now: MPCClient.getWallet(walletId) → resolves ETH address (single GET)
MPCClient.sign({ walletId, keyType, message }) → single POST,
threshold round
completes inside
mpcd; returns
r/s/v atomically.
Env (brand-neutral): MPC_API_URL/TOKEN/WALLET_ID replaced with
BRIDGE_MPC_URL + BRIDGE_IAM_ISSUER + BRIDGE_IAM_CLIENT_ID +
BRIDGE_IAM_CLIENT_SECRET + BRIDGE_MPC_WALLET_ID. isMPCSigningEnabled()
short-circuits to false when any are missing; swaps.ts falls back to
LUX_SIGNER (legacy private key) cleanly in standalone mode.
Robustness: if mpcd returns the compact 65-byte signature in
result.signature (hex) instead of separate r/s/v, mpc-signer splits it
manually as a fallback. v < 27 is normalized to v + 27 for EIP-1559
ethers compatibility.
────────────────────────────────────────────────────────────────────────
mpc-wallet.ts (createMPCWalletForDeposit refactor)
────────────────────────────────────────────────────────────────────────
Was: raw fetch(`${MPC_API_URL}/keygen`) — POST with { wallet_id },
auth via no header (or relying on cluster ingress), parsed a
bridge-specific MPCKeygenResult shape.
Now: MPCClient.keygen({ vaultId, name, keyType, protocol }) then
MPCClient.getWallet(walletId) to resolve per-chain-family
addresses (eth/btc/sol/ton/dot/xrp). Protocol pinned per
chain family:
eth/btc/xrp → cggmp21 (secp256k1)
sol/ton/dot → frost (ed25519)
Env: drops MPC_API_URL / MPC_DASHBOARD_URL. Adds BRIDGE_MPC_VAULT_ID
(default 'bridge') for vault scoping.
The Utila-compatible return shape ({ name, addresses: [{ address }] })
is preserved so call-sites in domain/swaps.ts stay unchanged.
────────────────────────────────────────────────────────────────────────
MPCClient.getWallet (new method)
────────────────────────────────────────────────────────────────────────
GET /v1/wallets/{walletId}. Returns the full WalletInfo type
(id/walletId/vaultId/keyType/protocol/ethAddress/btcAddress/solAddress
/ecdsaPubKey/eddsaPubKey/status). Accepts both camelCase and snake_case
fields from the wire so mpcd flavors that emit either shape work.
────────────────────────────────────────────────────────────────────────
Net effect
────────────────────────────────────────────────────────────────────────
Bridge backend has zero per-bridge MPC HTTP client code remaining.
Every threshold sign + keygen call goes through src/clients/mpc.ts
which implements the canonical Lux MPC HTTP API (matches the Go
reference at ~/work/lux/kms/pkg/mpc/client.go). Brand-neutral env vars
(BRIDGE_*) throughout.
Tests: 58 SDK + 123 server = 181. Typecheck clean on both packages.
User directive: 'drop LUX_ keep it brand neutral do not use nats/consul
must use standalone ~/work/lux/mpc with our native ~/work/lux/consensus
or b/m-chains'.
This commit lands all three asks.
────────────────────────────────────────────────────────────────────────
Brand neutralization
────────────────────────────────────────────────────────────────────────
Env vars renamed (LUX_* → BRIDGE_*):
LUX_IAM_ISSUER → BRIDGE_IAM_ISSUER
LUX_KMS_URL → BRIDGE_KMS_URL
LUX_KMS_ORG → BRIDGE_KMS_ORG
LUX_KMS_CLIENT_ID → BRIDGE_IAM_CLIENT_ID
LUX_KMS_CLIENT_SECRET → BRIDGE_IAM_CLIENT_SECRET
LUX_MPC_URL → BRIDGE_MPC_URL
(+ BRIDGE_MPC_WALLET_ID new — default wallet for native sign)
Client classes renamed (Lux* → brand-neutral):
LuxIAMClient → IAMClient
LuxKMSClient → KMSSecretClient (avoids collision w/ AWS SDK's KMSClient)
LuxMPCClient → MPCClient
Audience strings renamed (lux-* → bare service name):
'lux-kms' → 'kms'
'lux-mpc' → 'mpc'
File renames:
src/clients/lux-iam.ts → src/clients/iam.ts
src/clients/lux-kms.ts → src/clients/kms.ts
src/clients/lux-mpc.ts → src/clients/mpc.ts
(+ matching __tests__/ files)
────────────────────────────────────────────────────────────────────────
NATS + Consul: gone
────────────────────────────────────────────────────────────────────────
Deletions:
- app/server/src/services/mpc-service.ts (498 LOC NATS+Consul wrapper)
- app/server/src/utils/trigger-keygen.ts (dev script, NATS)
- app/server/src/scripts/trigger-keygen.ts (dev script, NATS)
- app/server/src/scripts/test-signing.ts (dev script, NATS)
- app/server/src/types/nats.d.ts (vendored type stubs)
- consul + nats sections of app/server/src/types/index.d.ts
- nats + consul devDependencies in app/server/package.json
- NATS + Consul + Vault containers in compose.local.yml
Rewritten:
- app/server/src/domain/mpc-bridge.ts (497 → 197 LOC)
Preserves the public surface (bridgeMPC.initialize() /
.generateMPCSignature() / .completeSwap() / .getHealthStatus())
so domain/mpc-modern.ts callers stay untouched. Internally:
- One MPCClient HTTP call replaces the NATS publish + subscribe
pub/sub dance. mpcd handles the threshold round atomically.
- Consul KV completion marker replaced with Prisma's
Swap.metadata_sequence_number update (single source of truth).
- Lazy IAM + MPCClient construction so the bridge boots cleanly
when BRIDGE_MPC_URL / BRIDGE_IAM_* are unset (standalone mode).
Sign calls return a clear error rather than crashing the
process.
- app/server/src/server.ts startup: no more mpcService.initialize();
bridgeMPC.initialize() now soft-fails (logger.warn) on missing
env. /health endpoint reads through bridgeMPC.getHealthStatus()
which reports { online: 0, total: 0, standalone: true } when
MPCClient isn't configured.
- compose.local.yml: replaces 6-service NATS/Consul/Vault topology
with bare 3-service (iam + iam-db + bridge postgres). Out-of-band
mpcd + KMS instructions documented in the file header.
────────────────────────────────────────────────────────────────────────
docs/LLM.md
────────────────────────────────────────────────────────────────────────
All LUX_* env mentions retargeted to BRIDGE_*. The 'Native Lux services'
section now describes the HTTP-only, env-neutral integration pattern.
Standalone mode section unchanged in shape but with refreshed env names.
────────────────────────────────────────────────────────────────────────
Tests + typecheck
────────────────────────────────────────────────────────────────────────
SDK pkg/bridge: 58 / 58
Server app/server: 123 / 123 (no regression — same set as v1.1.21
with renamed env vars)
Typecheck: clean on both packages
────────────────────────────────────────────────────────────────────────
Net effect
────────────────────────────────────────────────────────────────────────
Bridge backend is now genuinely brand-neutral:
- No 'LUX_' in any env var (the BRIDGE_ prefix is service-scoped, not
brand-scoped; same prefix the SDK uses for runtime config)
- No NATS, no Consul anywhere in the codebase or compose files
- Native m-chain signing goes through HTTP to mpcd (the canonical
~/work/lux/mpc daemon, github.com/luxfi/mpc), one round-trip per
sign, with the same wire protocol as ~/work/lux/kms/pkg/mpc/client.go
- Standalone mode preserved: when BRIDGE_MPC_URL is unset, the bridge
runs without crashing; sign calls return a clear error
User asked for native ~/work/lux/mpc + ~/work/lux/kms integration and
'pure b-chain/m-chain via mpc/kms software frontend'. Delivers the
canonical-Lux-services HTTP clients on the bridge backend.
Three new client modules in app/server/src/clients/:
lux-iam.ts LuxIAMClient — OAuth2 client_credentials minter against
Lux IAM (https://iam.lux.network). Per-audience cache
with early refresh; one cache slot per (aud=lux-kms,
aud=lux-mpc) downstream service. invalidate() on 401.
Mirrors ~/work/lux/kms/pkg/iamclient/client.go in TS.
lux-kms.ts LuxKMSClient.getSecret(path) — reads from ~/work/lux/kms
(https://kms.lux.network) at
/v1/kms/orgs/{org}/secrets/{path...}. Bearer JWT auth via
LuxIAMClient. Unwraps either raw text/* or JSON envelope
{ value | secret } responses. KMSError carries httpStatus
+ path for the caller to switch on.
lux-mpc.ts LuxMPCClient — the canonical m-chain entrypoint. Speaks
the same wire protocol as ~/work/lux/kms/pkg/mpc/client.go
(~/work/lux/mpc/cmd/mpcd HTTP API). Methods:
keygen({ vaultId, name, keyType, protocol })
→ POST /v1/vaults/{id}/wallets (DKG)
sign({ walletId, keyType, message })
→ POST /v1/transactions (type=sign,
base64 payload)
status() → GET /v1/status
Supports the full Lux protocol set: cggmp21 / frost /
bls / sr25519 / doerner / pulsar / corona / magnetar.
Per-call 120s timeout matches mpcd's threshold ceiling.
────────────────────────────────────────────────────────────────────────
fetchCosignerSecret — now KMS-backed
────────────────────────────────────────────────────────────────────────
domain/cosigners.ts::fetchCosignerSecret routes through LuxKMSClient
when LUX_KMS_URL + LUX_IAM_* are set. Path layout:
utila: bridge/cosigners/utila/{envSafe(org_id)}/sa_pem
fireblocks: bridge/cosigners/fireblocks/{envSafe(api_key)}/secret_pem
404 from KMS → quietly falls back to UTILA_COSIGNER_PEM__/
FIREBLOCKS_COSIGNER_PEM__ env vars (dev-only). Any other KMS error is
rethrown so the dispatcher surfaces it as a 'failed' (retryable)
CosignResult. _resetKmsClientForTests() lets tests rebuild the cached
client after mutating process.env.
────────────────────────────────────────────────────────────────────────
docs/LLM.md
────────────────────────────────────────────────────────────────────────
- New 'Native Lux services' section: documents IAM + KMS + MPC env
config and the audience scoping. Production MUST set the LUX_KMS_*
block; env-var fallback is dev-only.
- New 'Lux software frontends' section: lists the three operator UIs
(~/work/lux/mpc/dashboard Next.js, ~/work/lux/kms/frontend Vite) and
notes the f-chain frontend does NOT exist yet.
- New 'f-chain frontend — what it would look like' section: scaffolds
the proposed @luxfi/fchain-dashboard layout (cluster status, pubkey
registry, encrypt-session history, reshare history, health probe)
with the exact /v1/fhe/* endpoints each widget would consume. Note
that the bridge tenant's TRANSFERS panel currently surfaces per-swap
fchain attestation status (no separate UI needed for that scope).
────────────────────────────────────────────────────────────────────────
tests — 18 new
────────────────────────────────────────────────────────────────────────
+ clients/__tests__/lux-iam.test.ts (7 cases)
+ clients/__tests__/lux-kms.test.ts (6 cases)
+ clients/__tests__/lux-mpc.test.ts (8 cases — keygen / sign / status
/ 401 invalidate / abort)
+ domain/__tests__/cosigners-kms-secret-fetch.test.ts (7 cases —
KMS happy path, env-var fallback, 404 fallthrough, 5xx propagate,
cloud_hsm/fchain defensive guard)
Updated two pre-existing tests (cosigners-utila + cosigners-fireblocks
env-var-missing cases) to match the new 'cosigner secret unavailable'
error string from the refactored fetchCosignerSecret.
────────────────────────────────────────────────────────────────────────
final counts
────────────────────────────────────────────────────────────────────────
pkg/bridge SDK: 58 / 58
app/server: 95 → 123 (+28)
Typecheck: clean
Every cosigner adapter (utila / fireblocks / gcp_kms / aws_kms /
azure_key_vault / vault_transit / fchain) is a real network call.
fetchCosignerSecret routes through real Lux KMS.
Native m-chain signing wired via LuxMPCClient (caller integration in
domain/mpc-modern.ts is the next planned step — clients are now in
place and tested).
'no stubs all real killer no filler' — every TODO in cosigners.ts replaced
with a real, network-touching adapter. Cloud HSM layer is now production-
grade across all four providers; f-chain attests via the Lux FHE daemon.
Dependencies added to app/server/package.json:
@aws-sdk/client-kms AWS SDK v3, IAM role / IRSA workload identity
@azure/keyvault-keys CryptographyClient with sign('ES256K'|'PS256', …)
@azure/identity DefaultAzureCredential (MSI / WI / Azure CLI)
(no client lib for Vault — direct HTTP via fetch + X-Vault-Token)
(no client lib for f-chain — direct HTTP to /v1/fhe/encrypt)
────────────────────────────────────────────────────────────────────────
AWS KMS — real
────────────────────────────────────────────────────────────────────────
- KMSClient({ region }) — region from identityHint or parsed from key ARN
- SignCommand(KeyId, Message: digest, MessageType: DIGEST, SigningAlgorithm)
- AWS_ALGORITHM_MAP: secp256k1_ecdsa_sha256 → ECDSA_SHA_256,
rsa_pss_sha256 → RSASSA_PSS_SHA_256
- ed25519 NOT in map → algorithm_mismatch rejected before any RPC
- mapAwsError covers AWS exception names + HTTP status:
AccessDeniedException / 403 → permission_denied
NotFoundException / 404 → key_not_found
KMSInvalidStateException / Disabled → key_disabled
InvalidKeyUsageException → algorithm_mismatch
ThrottlingException / 429 → rate_limited
DependencyTimeout / 5xx → provider_unavailable
other → network_error
────────────────────────────────────────────────────────────────────────
Azure Key Vault — real
────────────────────────────────────────────────────────────────────────
- CryptographyClient(keyRef, DefaultAzureCredential())
- AZURE_ALGORITHM_MAP: secp256k1_ecdsa_sha256 → 'ES256K',
rsa_pss_sha256 → 'PS256'
- Returns key version from response.keyID (audit trail per CosignerStep row)
- mapAzureError covers Azure code + statusCode:
Forbidden / AuthenticationFailed / 403 → permission_denied
KeyNotFound / 404 → key_not_found
KeyDisabled → key_disabled
UnsupportedKeyOperation → algorithm_mismatch
TooManyRequests / 429 → rate_limited
ServiceUnavailable / 5xx → provider_unavailable
────────────────────────────────────────────────────────────────────────
Vault Transit — real
────────────────────────────────────────────────────────────────────────
- POST {VAULT_ADDR}/v1/{mount}/sign/{name} with X-Vault-Token
- Auto-splits keyRef on '/keys/' so tenants pin a non-default mount
- Body shape per algorithm:
secp256k1_ecdsa_sha256: hash_algorithm=sha2-256, prehashed=true,
marshaling_algorithm=asn1 (DER, parity with AWS/GCP)
rsa_pss_sha256: hash_algorithm=sha2-256, signature_algorithm=pss
ed25519: no prehash (Vault rejects prehashed ed25519)
- Parses 'vault:v{N}:<base64>' response shape — extracts both signature
bytes and key_version (returned as externalId='v3' etc.)
- mapVaultError maps status + body substrings:
401/403 / 'permission denied' → permission_denied
404 / 'no such key' → key_not_found
'key version disabled' → key_disabled
'unsupported' / 'invalid input' → algorithm_mismatch
429 / 'rate limit' → rate_limited
5xx / 'unavailable' / 'sealed' → provider_unavailable
────────────────────────────────────────────────────────────────────────
f-chain (Lux FHE attestation) — real
────────────────────────────────────────────────────────────────────────
- POST {publicUrl}/v1/fhe/encrypt with { plaintext_hex, scheme? }
- Endpoint contract from luxfi/fhe::cmd/fhed/main.go (the FHE daemon
already running in the Lux ecosystem; no new server to deploy)
- 30s timeout (FCHAIN_COSIGNER_TIMEOUT_MS env override) with AbortController
- Stores the FHE ciphertext as CosignerStep.signature (0x-prefixed hex)
— the cosign property: only f-chain (with its FHE key shares) could
produce that ciphertext for that txHash; downstream verifier obtains
f-chain's public key via /v1/fhe/publickey and checks equivalence
- 401/403 → rejected (policy denial)
- 5xx → failed (retryable infra)
- network/abort → failed
────────────────────────────────────────────────────────────────────────
Other touches
────────────────────────────────────────────────────────────────────────
- fetchCosignerSecret narrows kind 'fireblocks' explicitly so the union
with cloud_hsm + fchain typechecks
- cosigners-gcp-kms.test.ts: removes the four 'not yet implemented'
marker assertions (those now live as positive assertions in the new
cosigners-aws-azure-vault-fchain.test.ts)
Test counts:
SDK pkg/bridge: 58/58 unchanged
Server app/server: 77 → 95 (+18)
16 wire-shape validator
10 Fireblocks
7 persistence + dispatch-for-swap
13 Utila
13 GCP KMS + cloudHsmDigest + algorithm allow-list
18 NEW: 6 AWS + 3 Azure + 3 Vault Transit + 5 f-chain + 1 ed25519 reject
Typecheck: clean on both packages
No STUBs remain. Every adapter is a real network call with a real error
mapping. Workload-identity-only by design (no SA-JSON-key fallback).
User asked to confirm the rebuild is up to date with @hanzo/gui framework
and to run both Vite tenant + Next.js explorer locally to verify. Playwright
caught a regression: bumping @hanzo/gui to 7.2.2 (latest published) made
the bridge tenant render a blank page with 'createGui is not a function'.
Root cause: @hanzo/gui's published dist artefacts use the upstream Tamagui
symbol names verbatim (createHanzogui / HanzoguiProvider / getDefault-
HanzoguiConfig). The consumer-side scripts/patch-hanzogui-exports.cjs
ONLY runs when dist/ is empty — which was true for 7.0.0 (broken
publish) but is false for 7.2.x (real dist). So 7.2.2 exposes the
upstream Hanzogui* names and the bridge SDK code (which calls the
renamed createGui / GuiProvider / getDefaultGuiConfig) finds undefined
exports.
Fix: SDK accepts BOTH naming surfaces. pkg/bridge/src/mount.ts and
Bridge.tsx try the renamed symbol first (works on 7.0.0 + patch), fall
back to the upstream Hanzogui* name (works on 7.2.x dist). Throw a
clear error if neither is exported.
Playwright in-browser verification after the fix:
Title: 'LUX Bridge'
#bridge-root: 1 child rendered (Tamagui tree)
Console errors: 0 (only theme-token warnings, harmless)
UI rendering: header (LUX Bridge + Connect Wallet) ✓
swap form (FROM Lux Network → TO Ethereum) ✓
TRANSFERS panel ✓
footer (Network: mainnet · Docs · [email protected]) ✓
Also bumped:
- @hanzo/gui ^7.0.0 → ^7.2.2 (latest published)
- @luxfi/bridge 1.0.4 → 1.0.5
Plus an incidental fix to app/explorer/components/SwapHistory/StatusIcons.tsx
(pre-existing operator-precedence bug: 'a' + b ?? '' had its ?? branch
unreachable; wrapped className ?? '' in parens). Found while attempting
to run the legacy explorer locally; we're deprecating that whole app
per #387 but the type error blocked next dev / next build cold.
Tests:
- SDK pkg/bridge: 58/58 passing
- SDK typecheck: clean
- app/server: 77/77 passing (unchanged)
User feedback was that my first Cloud HSM pass had three architectural
weaknesses: (1) per-provider top-level kinds vs a single cloud_hsm
discriminator, (2) SA-JSON-in-KMS auth pattern, (3) missing first-class
algorithm + structured error codes. Plus the orthogonal point that for
bridge.lux.network specifically the right topology is m-chain (native
MPC) + optional f-chain (FHE attestation) and NO external cosigners.
This commit applies all of that.
SDK (@luxfi/bridge 1.0.4 → 1.0.5):
- BridgeCloudHsmConfig — single, uniform shape:
provider: 'gcp_kms' | 'aws_kms' | 'azure_key_vault' | 'vault_transit'
keyRef: <self-describing per-provider URI form>
algorithm: 'secp256k1_ecdsa_sha256' | 'ed25519' | 'rsa_pss_sha256'
identityHint?: <non-secret workload-identity scope hint>
Replaces the three separate gcpKms / awsKms / azureKv blocks from
the first pass. Tenants layer multiple HSMs via the cloudHsm array
on BridgeMPCConfig.
- BridgeFChainConfig — Lux Network native FHE attestation:
publicUrl: <f-chain cluster URL>
scheme?: 'ckks' | 'bgv' | 'bfv'
Lives alongside m-chain on the Lux primary network. Cosigns the
same txHash that m-chain signed via FHE-secured key material so
the message never crosses a plaintext threshold quorum. PQ-safe.
- Wire shape (bridge-api.ts CosignerIntent) collapses the three
cloud_hsm variants into one canonical record with the provider
sub-discriminator. Adds the fchain wire kind.
- useTransfers iterates cfg.mpc.cloudHsm[] + emits the optional
fchain intent.
Backend (app/server):
- CloudSigner interface matches user's exact spec:
readonly provider: CloudHsmProvider
sign(input: CloudSignInput): Promise<CloudSignResult>
where CloudSignInput / CloudSignResult carry the structured metadata
(keyRef, digest, algorithm, identityHint, requestId / signature,
publicKeyRef, provider, keyVersion, signedAt).
- CloudSignerError class with stable CloudSignerErrorCode union:
permission_denied | key_not_found | key_disabled
→ maps to CosignResult.status = 'rejected'
algorithm_mismatch | invalid_digest
→ maps to 'rejected' (terminal config errors)
rate_limited | network_error | provider_unavailable
→ maps to 'failed' (retryable infra errors)
statusForErrorCode() does the mapping; the dispatcher never has to
string-match on error messages again.
- Real GCP CKMS adapter:
✅ Workload-identity ONLY (no SA-JSON-in-KMS fallback). The
backend trusts the cloud's native identity layer.
✅ identityHint is non-secret context, not credentials.
✅ Algorithm allow-list — only secp256k1_ecdsa_sha256 wired today;
ed25519 / rsa_pss_sha256 reject early with algorithm_mismatch.
✅ Error mapping covers gRPC codes 5/7/8/9/14/16 + string
fallbacks. mapGcpError centralizes it.
- AWS KMS / Azure Key Vault / Vault Transit adapters are stubs that
return CloudSignerError 'provider_unavailable' with a clear marker.
Each stub has a full implementation sketch in comments so the next
dev (or me, next session) drops in @aws-sdk/client-kms / etc. and
wires through cleanly.
- f-chain (FHE attestation) stub — waiting on @luxfi/fchain workspace.
- Persistence: cloud_hsm rows persist as 'cloud_hsm:<provider>' in
CosignerStep.kind to avoid a Prisma migration. public_id = key_ref;
api_host = identity_hint; vault_id = algorithm. rowToIntent inverts
cleanly. First schema migration round will split into dedicated
columns; the compound kind is a stable round-trippable hack.
Tests: 77/77 passing.
pkg/bridge: 58/58 (unchanged)
app/server: 16 validator (now covers all 4 cloud providers + fchain)
+ 10 Fireblocks
+ 13 Utila
+ 7 persistence
+ 19 GCP KMS + Cloud HSM stubs + fchain
+ 12 new cloud-HSM validator cases
Lux Network topology (bridge.lux.network and all Lux-branded tenants):
- Primary: m-chain (native CGGMP21 / FROST threshold MPC)
- Optional: f-chain (FHE attestation, also native to Lux)
- NO external cosigners (Utila / Fireblocks / Cloud HSM) by default
- These cosigner blocks exist on BridgeMPCConfig for white-label
tenants whose policy requires institutional / cloud-HSM cosign.
User correction record:
> 'Use a provider sub-discriminator, not separate top-level kinds' ✓
> 'Do not fetch SA JSON from KMS at signing time' ✓
> 'Keep browser SDK config declarative only' ✓
> 'Separate SDK config from backend signer runtime config' ✓
> 'Normalize algorithms explicitly' ✓
> 'Shared interface should return structured metadata' ✓
> 'Preserve provider-specific error classification' ✓
> 'rely on just b-chain and m-chain ... f-chain for FHE' ✓ (fchain
config is optional + native; external cosigners are opt-in only)
Replaces the runUtila STUB with a real evm_personal_sign attestation
flow against @luxfi/utila Connect-RPC. After this commit the layered
cosigner feature is feature-complete for BOTH providers — no stubs
remain in app/server/src/domain/cosigners.ts.
Flow:
1. Build per-tenant gRPC client via createGrpcClient + serviceAccount-
AuthStrategy with { email: intent.org_id, privateKey: () => secretPem }
— mirrors the env-singleton client in domain/utila.ts but isolates
each tenant's auth context so multiple tenants cosign in parallel
without singleton contention.
2. initiateTransaction({
parent: vaults/<intent.vault_id>,
details: { details: { case: 'evmPersonalSign', value: {
fromAddress: intent.client_id, messageHex: opts.txHash
} } },
priority: NORMAL, note: 'lux-bridge cosign swap=<id>'
})
vault_id is REQUIRED — utila scopes initiateTransaction by parent
vault. Missing vault_id returns 'failed' before any RPC.
3. Poll getTransaction(name) every 1.5s, partition Utila's
TransactionState_Enum (proto path:
pkg/utila/src/lib/gen/utila/api/v1/transactions_pb.ts) into:
approved SIGNED=4, AWAITING_PUBLISH=5, PUBLISHED=6,
MINED=7, CONFIRMED=13
rejected DECLINED=9, CANCELED=11, DROPPED=12, EXPIRED=15
failed FAILED=8, REPLACED=10
If state is in 'approved' but evmMessage.signature isn't populated
yet (Utila publishes the sig in a later state transition), keep
polling — don't bail. Intermediates (AWAITING_APPROVAL=2,
AWAITING_POLICY_CHECK=14, AWAITING_SIGNATURE=3) also keep polling.
4. 60s timeout ceiling (UTILA_COSIGNER_TIMEOUT_MS env override).
Dynamic read on each call so config picks up without a restart.
5. Return CosignResult with signature converted from Uint8Array proto
bytes to 0x-prefixed hex; externalId = Utila transaction.name.
Edge cases covered by 13 tests (mocked @luxfi/utila):
- happy: SIGNED + signature → approved
- happy: CONFIRMED (later state, sig already attested) → approved
- happy: SIGNED-without-sig keeps polling, picks up next tick
- rejected: DECLINED, CANCELED, EXPIRED, DROPPED — all map to rejected
- failed: FAILED proto state
- failed: initiateTransaction throws (auth: bad jwt)
- failed: initiateTransaction returns no transaction.name
- failed: timeout (AWAITING_* indefinitely)
- failed: missing vault_id → fail BEFORE any RPC call
- failed: env-var fallback unset → clear KMS-TODO error
- wire-shape assertion: initiateTransaction is called with the right
parent + evmPersonalSign + fromAddress + messageHex + note
Test infra:
- vitest.config.ts gains a resolve.alias mapping @luxfi/utila to its
workspace src/grpc-client.ts. The published 3.0.0 rollup-built
dist/ is what npm consumers see, but the workspace package's
local rollup config has a long-standing outDir-vs-file-path issue
that prevents in-place builds. Vitest resolves the TS source
directly and runtime production is unaffected.
Tests: 91 → 104 total (58 SDK + 46 server).
Server breakdown: 16 validator + 10 Fireblocks + 7 persistence + 13 Utila.
After this commit there are no STUBs in the cosigner module. The
remaining work on #386 is the KMS round-trip in fetchCosignerSecret
(env-var fallback works for dev; production needs KMS path read).
User correction: 'utila was already 100% complete... please make sure
it's done' — addressed by replacing the runUtila STUB with the real
implementation that reuses the @luxfi/utila Connect-RPC machinery
already in the workspace.
Closes the persistence + state-machine gating gap from v1.1.12. After
this commit, layered cosigners are 2-of-2 enforced end-to-end on the
backend.
Prisma:
+ model CosignerStep — one row per intent. Public IDs only:
kind 'utila' | 'fireblocks'
public_id org_id (utila) or api_key (fireblocks)
api_host?, vault_id?
status 'pending' (default) | 'approved' | 'rejected' | 'failed'
signature?, reason?, external_id?
+ Swap.cosigner_steps relation (1:N)
Indexed on swap_id + status for the dispatch query.
Persistence path:
handleSwapCreation(...)
└─ persistCosignerIntents(swapId, cosigners) — Prisma ,
one create() per intent, all status='pending'. No-ops when no
intents listed (backwards compat — zero cost for native-MPC-only
tenants).
Dispatch path (issue #386 trust-boundary enforcement):
handlerUpdateMpcSignAction(swapId, txHash, ...)
└─ dispatchCosignersForSwap(swapId, signature, txHash)
├─ findMany({ swap_id, status: 'pending' })
├─ rowToIntent(...) — invert persistence
├─ dispatchCosigners(...) — runs Fireblocks (real) / Utila (stub)
├─ update each step with terminal state
└─ aggregate: 'all_approved' | 'any_rejected' | 'any_failed' | 'no_steps'
If aggregate is rejected/failed:
- Swap.status → SwapStatus.Failed
- Swap.fail_reason → 'Layered cosigner rejection: <kind>: <reason>'
- Never advances to UserPayoutPending; user payout step is unreachable
without 2-of-2 (native + every listed cosigner).
- cosigner_steps included in the response payload for traceability.
If aggregate is no_steps (native-only swap):
Existing code path runs unchanged — proves out the additive, opt-in
design from the SDK side.
Tests: 7 new persistence + dispatch tests (vi.hoisted Prisma mock).
- SDK: 58/58 unchanged
- Server: 26 → 33 (16 validator + 10 Fireblocks + 7 persistence)
What's still STUB after this commit:
- runUtila — waiting on the first Utila tenant to model the
InitiateTransaction subtype against their TAP policy
- fetchCosignerSecret KMS round-trip — env-var fallback works for
local dev; production needs the KMS path read
The Prisma schema change requires `prisma migrate dev` (in dev) +
`prisma migrate deploy` (in CI) to apply the CosignerStep table to
existing databases. The migration file itself is left for the new
dev to generate against the target Postgres instance — autogenerated
migrations need a connected DB and shouldn't be committed blind.