202 Commits
Author SHA1 Message Date
zeekayandHanzo Dev eb72965d75 Typecheck on the native compiler, keep 6 for the tools
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]>
2026-09-16 23:05:44 -07:00
zeekay e661decabb chain routes: /v1/bc -> /v1/chain
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.
2026-09-01 08:20:22 -07:00
zeekay c8390b8b5c Restore the chain prefix the running nodes answer
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.
2026-08-30 21:25:48 -07:00
zeekay 2a0a574b1e chain routes: /v1/bc -> /v1/chain
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.
2026-08-30 21:06:27 -07:00
zeekay 13b6b73cbc bridge: the TypeScript twin, and Cardano can be watched
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.
2026-08-30 17:10:41 -07:00
zeekay 2c1d44b07d bridge: XRP gets an r-address
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.
2026-08-30 17:10:41 -07:00
zeekay f21c8303d0 bridge: Cardano gets a Cardano address
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.
2026-08-30 17:10:41 -07:00
zeekayandHanzo Dev d1a1ead5f5 Route and price the LUX<->ZOO and LUX<->ETH corridors
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]>
2026-08-21 14:10:51 -07:00
zeekay 703bd4f9da license: relicense Lux Bridge to BSD-3-Clause
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.
2026-08-08 03:15:36 -07:00
zeekayandHanzo Dev dfb777af99 don't rate-limit /health — it makes the kubelet kill the pod
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]>
2026-08-07 22:21:24 -07:00
zeekayandHanzo Dev 5ffbaa9260 keep the workspace layout in the runtime image
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]>
2026-08-07 21:29:09 -07:00
zeekayandHanzo Dev df36398766 copy scripts/ — the root postinstall needs it
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]>
2026-08-07 21:03:38 -07:00
zeekayandHanzo Dev bbbf7c90fe the server image could not build at all — copy and build @luxfi/utila
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]>
2026-08-07 21:00:56 -07:00
zeekayandHanzo Dev 2c178a9152 test the quote over the wire, not just the arithmetic
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]>
2026-08-07 17:36:57 -07:00
zeekayandHanzo Dev e2dd5cffa7 settle the terms before minting a custody address
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]>
2026-08-07 17:25:36 -07:00
zeekayandHanzo Dev c330427ce7 bridging is a wrap, not a trade — stop pricing it
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]>
2026-08-07 17:15:00 -07:00
zeekay 83f826794a kms.lux.cloud is the brand host; kms.lux.network is retired
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.
2026-08-05 07:51:13 -07:00
zeekayandHanzo Dev a702429811 Bridge and threshold RPC on /v1; @luxfi/bridge 1.0.7
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]>
2026-07-27 19:24:56 -07:00
zeekayandHanzo Dev e4caa184ee build: migrate tsconfig to TypeScript 7 (native compiler)
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]>
2026-07-27 14:18:44 -07:00
zeekayandHanzo Dev b4fd32d903 fix(bridge-server): allow every white-label tenant origin in CORS
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]>
2026-07-20 14:56:42 -07:00
fd3bc75a29 bridge: sync latest — docs trim, tenant config, internal broadcast/substrate/txassembler fixes (#406)
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]>
2026-07-07 12:39:25 -07:00
zeekay 7282219e12 merge: pull main (CI/docker/docs cleanup, brand-neutral)
# Conflicts:
#	.dockerignore
#	app/bridge/package.json
#	compose.local.yml
#	pkg/bridge/src/app/hooks/useTransfers.ts
#	pnpm-lock.yaml
2026-05-28 01:07:23 -07:00
zeekay 8f075365ae chore: brand-neutral cleanup — remove cross-tenant references 2026-05-25 15:12:52 -07:00
venuswhispers 202785aff8 feat: testnet configuration set, updated deposit workflow with mpc workflow, not teleport 2026-05-25 18:20:04 +02:00
zeekay cf5b4e558b test(app/server): 21 specs for refactored mpc-signer + mpc-wallet (MPCClient flow)
Verifies the v1.1.24 refactor that routed every bridge → mpcd call
through the canonical MPCClient. Mocks @/clients/mpc.MPCClient + a
minimal ethers JsonRpcProvider; no real network.

mpc-signer.test.ts — 11 specs covering:
  isMPCSigningEnabled():
    - true when full BRIDGE_MPC_* + BRIDGE_IAM_* + BRIDGE_MPC_WALLET_ID set
    - false when any required env var missing (5 variants)
  mpcSignAndSend():
    - getWallet → unsigned tx → sign → broadcast happy path
    - v < 27 normalized to v + 27
    - compact 65-byte signature splits to r/s/v fallback
    - too-short signature throws
    - clean error when env incomplete
    - clean error when BRIDGE_MPC_WALLET_ID unset
    - clean error when wallet has no eth_address
  mpcBridgeMint(): encodes bridgeMint(address, uint256) correctly
  mpcSendNative(): posts empty-data tx with requested value

mpc-wallet.test.ts — 10 specs covering createMPCWalletForDeposit:
  EVM:        ETHEREUM_MAINNET + BASE_MAINNET → cggmp21 + secp256k1 + ethAddress
              (BASE_MAINNET also exercises BRIDGE_MPC_VAULT_ID env)
  BTC:        BITCOIN_MAINNET → cggmp21 + secp256k1 + btcAddress
  ed25519:    SOLANA_MAINNET + TON_MAINNET → frost + ed25519 + solAddress
  XRP:        XRP_MAINNET → cggmp21 + eth fallback (rAddress TODO)
  Substrate:  POLKADOT_MAINNET → SS58 derivation from eddsaPubKey
              (+ throws cleanly when eddsa pubkey missing)
  failure:    env incomplete → standalone error; no chain-family address → throws

Mock-shape pitfall avoided: vi.restoreAllMocks() in afterEach would
have nulled the MPCClient constructor's mock implementation (set once
in vi.mock factory at module load). Replaced with explicit per-fn
mockReset() in beforeEach.

Browser smoke (Playwright): http://localhost:3001 still renders the
'LUX Bridge / Connect Wallet' header with #bridge-root populated and
ZERO page errors.

Tests: 58 SDK + 144 server = 202 total. Typecheck clean on both packages.
2026-05-23 22:23:48 -07:00
zeekay d6f127dc26 feat(app/server): mpc-signer + mpc-wallet through MPCClient (eliminate legacy paths)
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.
2026-05-23 21:05:58 -07:00
zeekay 8a8942ca89 feat(app/server): brand-neutral env + classes; drop NATS/Consul; m-chain via lux/mpc HTTP
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
2026-05-23 20:06:36 -07:00
zeekay a16981d8e1 feat(app/server): native Lux IAM + KMS + MPC clients — m-chain wired (no env-var stubs)
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).
2026-05-23 17:06:17 -07:00
zeekay 66d80e9984 feat(app/server): real AWS KMS + Azure KV + Vault Transit + f-chain adapters (no stubs)
'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).
2026-05-23 16:34:29 -07:00
venuswhispers 0ff373526e feat: wallet connect function updated 2026-05-23 23:43:10 +02:00
zeekay 2f0312d6de feat: Cloud HSM (GCP real, AWS/Azure/Vault stubs) + f-chain native cosigner (#386)
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)
2026-05-22 20:27:02 -07:00
zeekay a7030ec59b feat(app/server): real Utila cosigner implementation + 13 tests (issue #386)
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.
2026-05-22 17:34:34 -07:00
zeekay dd406080d9 feat(app/server): CosignerStep Prisma model + state-machine gate (issue #386)
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.
2026-05-22 17:24:24 -07:00
zeekay 14cc983927 feat(app/server): real Fireblocks cosigner implementation + 10 tests (issue #386)
Replaces the runFireblocks STUB from v1.1.12 with a real RAW-sign
implementation against [email protected].0.

Flow:
  1. `new FireblocksSDK(secretPem, intent.api_key, intent.api_host)`
  2. `createTransaction({ operation: RAW, source: { type: VAULT_ACCOUNT,
     id: intent.vault_account_id ?? '0' }, extraParameters: {
     rawMessageData: { messages: [{ content: opts.txHash }] }, note:
     `lux-bridge cosign swap=${opts.swapId}` })`
  3. Poll `getTransactionById` every 1.5s, partition Fireblocks's wide
     status enum into three buckets:
       complete:  COMPLETED, BROADCASTING, CONFIRMING → approved
       reject:    REJECTED, CANCELLED, BLOCKED        → rejected
       transient: FAILED, TIMEOUT                     → failed
     Unknown statuses (SUBMITTED, QUEUED, PENDING_*, etc.) keep polling
     until terminal or the timeout ceiling fires.
  4. Ceiling: 60s default, overridable via
     FIREBLOCKS_COSIGNER_TIMEOUT_MS. Read on each call so config picks
     up without a restart.

Edge cases covered by tests:
  - happy: COMPLETED with signature → approved
  - happy: BROADCASTING/CONFIRMING (sig already attested) → approved
  - rejected: REJECTED with subStatus → rejected, reason surfaces both
  - rejected: CANCELLED, BLOCKED → rejected
  - failed: FAILED + INSUFFICIENT_FUNDS → failed
  - failed: COMPLETED but signedMessages empty (Fireblocks bug guard)
  - failed: createTransaction throws (transport / config error)
  - failed: PENDING_* indefinitely → timeout failure with externalId
  - failed: env-var fallback unset → clear KMS-TODO error

Env-var fallback path also hardened: fetchCosignerSecret now normalises
public IDs (hyphens / dots → underscores) before checking
`process.env.FIREBLOCKS_COSIGNER_PEM__<NORMALISED>` so a tenant id
like `pub-key-id` maps to `FIREBLOCKS_COSIGNER_PEM__PUB_KEY_ID`.

Tests:
  - SDK:    58/58 (unchanged)
  - Server: 26/26 — 16 validator + 10 Fireblocks (new)

What's still STUB (next-dev work on #386):
  - runUtila — Utila Connect-RPC flow needs the v1alpha2
    InitiateTransaction subtype shape worked out per tenant
  - Prisma persistence — Swap.metadata JSON or related CosignerStep model
  - State-machine gating — wire dispatchCosigners() after native MPC sign
2026-05-22 00:33:15 -07:00
zeekay 8c195080f9 feat(app/server): cosigner backend scaffold + 16 wire-shape validation tests (issue #386)
Lands the server-side foundation for the layered cosigner feature that
shipped in @luxfi/bridge v1.0.3. The SDK already forwards public-id-only
cosigners[] arrays on POST /api/swaps; this commit:

  - Adds domain/cosigners.ts — public types match SDK wire shape EXACTLY
    (snake_case, no drift); validator rejects secret-like fields up
    front (api_secret, private_key, jwt, token, etc.); dispatch
    entrypoint runs cosigners in parallel and surfaces per-step result.

  - Adds /api/swaps POST validation — the validator runs BEFORE the
    swap is persisted; a malformed cosigners[] returns 400 with a
    precise reason, never reaches the DB.

  - Threads cosigners through SwapData.handleSwapCreation as a logged
    pass-through. TODO comment marks where the new dev needs to:
      a. add Prisma schema for CosignerStep (or Swap.metadata JSON);
      b. call dispatchCosigners(...) after native MPC sign completes;
      c. gate broadcasting on all listed cosigners returning approved.

  - Adds vitest to @luxbridge/server + 16 unit tests covering happy
    paths (utila / fireblocks / both) AND rejections (unknown kind,
    missing required fields, every secret-like leak vector, index
    reporting in error messages).

What is wired:
  ✅ Wire-shape validation (reject secrets, accept valid intents)
  ✅ Pass-through through handleSwapCreation
  ✅ KMS-lookup interface with env-var fallback for local dev
  ✅ Dispatch entrypoint + parallel cosigner execution
  ✅ runUtila + runFireblocks STUB implementations with full TODO map

What is stubbed (issue #386 follow-ups):
  ⏳ Actual KMS round-trip in fetchCosignerSecret
  ⏳ @luxfi/utila transaction-approval flow (not the existing primary-
     signer mode in domain/utila.ts)
  ⏳ fireblocks-sdk install + FireblocksSDK client + raw-sign flow
  ⏳ Prisma persistence + state-machine gating

Tests: 16/16 cosigner tests passing on @luxbridge/server. Bridge SDK
53/53 still passing. Pre-existing workspace-dep typecheck errors on
server (@luxfi/utila not built in this worktree) are unchanged by this
commit.
2026-05-22 00:04:39 -07:00
zeekay a4facf7845 feat(pkg/bridge): add @luxfi/bridge SDK + publish workflow
Rationalize the bridge workspace around one canonical SDK entry:

  - @luxfi/bridge        (NEW)  public SDK consumers import
  - @luxbridge/*          private workspace internals
  - @luxfi/{core,threshold,utila} generic Lux building-block libs

@luxfi/bridge re-exports mountBridge(), <Bridge />, BridgeConfig, and
BrandConfig. The runtime lazy-imports @luxbridge/app as the actual UI
implementation. Mirrors the mountExchange() pattern from
@/exchange — declarative config-in, brand metadata applied
to document, React tree rendered into #bridge-root.

Workspace housekeeping:
  - pkg/settings: 0.1.0 -> 1.0.0, marked private
  - pkg/ui (ui-automation), app/server, app/bridge3: marked private
  - app/explorer: 0.1.0 -> 1.0.0
  - root bridge package: 1.0.0 -> 1.1.0
  - app/bridge: add main/types/exports so the SDK can dynamic-import the App component

Publish workflow at .github/workflows/publish.yml fires on v* tag push,
runs pnpm publish -r --access public --no-git-checks. pnpm skips
private packages automatically. Public packages: @luxfi/bridge, @luxfi/core,
@luxfi/threshold, @luxfi/utila.
2026-05-21 14:02:03 -07:00
zeekay 27c20123c9 fix: remove WL chain from bridge network config 2026-03-30 20:17:56 -07:00
zeekay 7c7817e337 feat: BRAND_PACKAGE env var — white-label via npm brand package at startup 2026-03-29 12:04:06 -07:00
zeekay 03136143ec security(medium): CORS whitelist + rate limiting on bridge server 2026-03-25 11:10:17 -07:00
zeekay 5d50e48f25 security: strip stack traces, add helmet, proper health endpoint
- Replace debug "Hello, Winston Logger!" with JSON health response
- Add helmet middleware for security headers
- Production error handler: generic message only, log details server-side
- Remove X-Powered-By header
2026-03-24 18:06:00 -07:00
zeekay 417700f426 chore: sync deps and local changes 2026-03-22 13:58:46 -07:00
zeekay 1efbe90d78 feat: add Polkadot/Substrate support to bridge
New chain support:
- Polkadot mainnet (DOT, 10 decimals)
- SS58 address encoding/decoding (zero-dependency, blake2b)
- Substrate JSON-RPC client (raw fetch, no @polkadot/api dep)
- Deposit detection following existing polling pattern
- SCALE extrinsic builder + MPC sr25519 signing interface
- LDOT/ZDOT wrapped tokens on Lux/Zoo

Files:
- substrate-address.ts — SS58 encode/decode (13 tests)
- substrate-client.ts — balance, nonce, broadcast (7 tests)
- substrate-deposit.ts — DOT deposit detection
- substrate-signer.ts — MPC signing integration
- Updated: networks.ts, mpc-wallet.ts, swap-pairs.ts, network-type.ts
2026-03-18 14:09:27 -07:00
zeekay 048c639d3e Add devnet settings and teleport processor
Add bridge devnet chain configuration and teleport transaction processor.
2026-03-12 16:44:53 -07:00
zeekay 4199614654 fix: update bridge server for MPC integration and k8s deployment
Updated MPC wallet integration, network configuration, swap handling,
and added k8s MPC deployment manifest.
2026-03-12 16:13:53 -07:00
zeekay e0e04dd515 feat: MPC-signed bridge payouts, replace raw private key signer
- Add mpc-signer.ts: signs EVM transactions via MPC API
- Update swaps.ts payout handler to use MPC when MPC_WALLET_ID is set
- Falls back to LUX_SIGNER private key when MPC is not configured
- MPC treasury wallets created: fee-collector + bridge-signer
- Env vars: MPC_API_URL, MPC_API_TOKEN, MPC_WALLET_ID, BRIDGE_FEE_COLLECTOR
2026-03-06 00:35:35 -08:00
zeekay 95dcacb985 feat: enable deposits and withdrawals on all networks and currencies
Enable is_deposit_enabled and is_withdrawal_enabled for all tokens
across mainnet and testnet configurations to support full bridge
functionality with MPC-based deposit detection.
2026-03-06 00:33:12 -08:00
zeekay 4afbecc570 fix: use workspace:* for @luxfi/core to resolve xrp type 2026-03-06 00:11:52 -08:00
zeekay a02573c632 fix: build workspace deps in CI, bump @luxfi/core for xrp type
- Dockerfile: copy and build pkg/core before app/server
- CI: build workspace dependencies before typecheck
- server.yml: use root context for Docker build
- Bump @luxfi/core to 10.0.6 (adds xrp NetworkType)
2026-03-05 23:58:32 -08:00
zeekay b86d48a050 feat: 1% bridge fee on exits from Lux/Zoo, fix build errors
- 1% service fee applied when source is Lux or Zoo (exits + inter-chain)
- 0% fee when bridging IN from external chains (BTC, ETH, SOL, etc.)
- Fee deducted from payout amount, collected to BRIDGE_FEE_COLLECTOR address
- Quote and rate APIs reflect directional fee
- Fix: add @noble/ed25519 dependency for MPC signing
- Fix: string|null type compatibility in checkDepositAction
2026-03-05 23:53:57 -08:00
zeekay 04e8ce82e7 feat: native MPC wallet support, replace Utila, enable all chains
- Replace Utila/Fireblocks wallet creation with native MPC API
  (new mpc-wallet.ts calls our MPC cluster directly via HTTP)
- Add native deposit checking via blockchain RPCs
  (EVM eth_getBalance, BTC Blockstream API, SOL getBalance, TON toncenter)
- Enable deposits on BTC (testnet+mainnet), SOL (devnet+mainnet),
  TON (testnet+mainnet)
- Add XRP Ledger as new chain (testnet+mainnet) with NetworkType "xrp"
- Add LXRP wrapped token to Lux/Zoo network configs
- Add XRP swap pairs (XRP <-> LXRP <-> ZXRP)
- Fix BTC testnet decimals (was 18, now 8)
- Update swap-pairs: SOL/TON now include native token pairs
2026-03-05 23:41:13 -08:00