Commit Graph
1468 Commits
Author SHA1 Message Date
zeekay 75c8c1bb78 feat(bridge): humanize XRP engine_result errors for SDK + UI
broadcast_driver.go::humanizeBroadcastErr now recognizes the most
common XRP engine_result codes and renders them as one-line user-
facing messages, matching the EVM-side humanization pattern:

  tecINSUFFICIENT_FUNDS / tecUNFUNDED → "Insufficient funds in release
                                          address — fund the MPC address"
  tecNO_DST                           → "XRP destination account is
                                          not activated — recipient must
                                          hold ≥10 XRP reserve"
  tefPAST_SEQ                         → "XRP sequence already used —
                                          bridge will requery and retry"
  tem*                                → "XRP transaction was malformed
                                          — bridge will retry"
  telINSUF_FEE_P                      → "XRP network fee too low for
                                          current load — bridge will retry"

The SDK + UI surface these from Swap.LastError so users see the
actionable cause (fund the wallet, activate the destination, etc.)
instead of the raw rippled error string.

Test: TestHumanizeBroadcastErr_XRPEngineResults covers all five new
mappings plus regression for the existing EVM mappings.
2026-05-27 18:15:03 -07:00
zeekay df4055fc25 feat(bridge): XRP end-to-end — txassembler, mchain pubkey, signing driver, release pool
internal/txassembler/xrp.go:
- XRPProvider interface + per-network XRPNetwork config (LastLedgerWindow,
  fee override, canonical-sig flag).
- Assembler.PreSignXRP: pulls Sequence + suggested fee from rippled,
  embeds the release wallet's compressed-secp256k1 pubkey, returns
  the 32-byte SHA-512Half (via internal/xrpl) the MPC quorum signs.
- Assembler.FinalizeXRP: parses (r, s) from the MPC's 65-byte R||S||V
  hex (ignores recovery byte — XRPL doesn't need ecrecover), DER-encodes
  with low-s canonicalization, attaches as TxnSignature, returns the
  wire blob (hex) + canonical XRPL tx hash.
- XRPRequiredDrops: amount + fee + 10-XRP reserve, used by the gas
  pre-check to refuse signing when the wallet can't cover.
- xrpFloatToDrops with the same string-format trick floatToWei uses,
  avoiding IEEE noise at 6-decimal native XRP precision.

internal/mchain/client.go:
- Wallet now carries ECDSAPubKeyHex (compressed-secp256k1 hex). EVM
  paths ignore it; XRP signing needs it on the wire.
- pickAddress(XRP) derives the canonical r-address from the ECDSAPubKey
  via internal/xrpl.AddressFromPubKey instead of falling back to the
  ETH slot. Fallback retained for legacy mocks without the pubkey field.

cmd/bridge/xrp_provider.go:
- HTTP wrapper over rippled JSON-RPC (account_info, fee). Returns
  (Sequence, validated_ledger_index) and the open_ledger_fee/median
  fee / base_fee chain. Treats actNotFound on balance lookup as
  "wallet not activated → balance 0" so the gas pre-check correctly
  reports an unactivated release wallet.

cmd/bridge/release_pool.go:
- ReleasePoolEntry gains Family + PubKeyHex fields (round-trips through
  JSON; pre-XRP entries leave them empty).
- AcquireForFamily filters entries by family — the signing driver now
  picks an XRP wallet for XRP destinations and an EVM wallet for EVM.
  Falls back to family-agnostic Acquire when no per-family match.
- SizeByFamily + Reload helpers so a parallel XRP-pool bootstrap can
  share the same store as the EVM pool without re-indexing.

cmd/bridge/signing_driver.go:
- signOne dispatches on mchain.AddressTypeFor(DestinationNetwork). XRP
  routes to signOneXRP, which mirrors the EVM ceremony but: builds a
  Payment via Assembler.PreSignXRP, runs an XRP gas pre-check (balance
  vs amount+fee+reserve) instead of eth_getBalance, hands the
  SHA-512Half digest to the MPC, then Assembler.FinalizeXRP emits the
  signed wire blob.
- xrpGasPrecheck short-circuits to SwapStatusFailedInsufficientReleaseGas
  with a humanized "balance=X drops, required=Y drops" reason. Probe
  failures DON'T block the swap (best-effort, broadcast layer still
  retries on transient rippled flakes).

cmd/bridge/main.go:
- New flags: --xrp-release-pool-size (default 5),
  --xrp-release-pool-mint-network (default XRP_TESTNET),
  --xrp-release-balance-threshold-drops (default 10 XRP).
- XRPProviderClient instantiated once, shared between the assembler
  (for PreSignXRP) and the gas pre-check (via assembler.XRPProviderFor).
- XRP pool minted on top of the existing EVM pool (via a helper-pool
  bound to the same store + Reload), so a single ReleasePool object
  serves both families.
- /health surfaces pool sizes broken out by family.

Tests:
- xrpl: address derivation round-trip, bad-pubkey rejection, DER
  encoding + low-s flip, PreSign digest matches SHA512Half(STX||body),
  full PreSign→Finalize round-trip through the rubblelabs parser.
- broadcast: tesSUCCESS happy path, terQUEUED retryable, tecINSUFFICIENT_FUNDS
  + tecNO_DST fatal, temBAD_AMOUNT malformed, telINSUF_FEE_P retryable,
  rippled high-level errors, classifyEngineResult table.
- txassembler: PreSignXRP populates Sequence / Fee / LastLedger,
  fee floor + pin, bad-input rejection, FinalizeXRP round-trip,
  XRPRequiredDrops math, xrpFloatToDrops table.
- mchain: XRP r-address derivation from ECDSAPubKey, legacy fallback
  to ETH slot, bad-hex rejection.
- release pool: XRP-family Bootstrap + entry shape + PubKey roundtrip,
  AcquireForFamily filtering, no-match returns ErrEmptyPool.
- signing driver: XRP happy path advances to broadcasting with
  populated DestRawTx, insufficient balance short-circuits, missing
  PubKeyHex rolls back with readable LastError.
- xrp_provider: account_info / fee / balance stubs, actNotFound → 0,
  rippled error surfacing.
2026-05-27 18:13:43 -07:00
zeekay 3b9643d5d5 feat(broadcast): XRP submit via rippled JSON-RPC
internal/broadcast/xrp.go: XRP-family broadcaster targeting rippled's
`submit` method.

- Wire: POST {method:"submit", params:[{tx_blob:"<hex>"}]} to
  https://s1.ripple.com:51234/ (mainnet) or
  https://s.altnet.rippletest.net:51234/ (testnet). Strips a 0x prefix
  if the caller (every other broadcaster in this package emits 0x...)
  appends one — rippled rejects 0x-prefixed blobs.
- classifyEngineResult: maps rippled's engine_result codes to one of
  success / retryable / fatal:
    tesSUCCESS         → success → return result.tx_json.hash
    ter*               → retryable (broadcast driver re-submits)
    tel*               → retryable (local server, may resolve next round)
    tec* / tem* / tef* → fatal (consumed-fee failure, malformed, or
                          server-rejected past-seq / already / etc.)
    unknown / empty    → retryable (rippled wasn't sure yet)
  Behaviour matches xrpl.org/transaction-results.html.
- doPlainJSON helper added to client.go: rippled is JSON-RPC-shaped
  (method, params) but does NOT emit the {jsonrpc,id,result,error}
  2.0 envelope, so it needs its own POST/decode helper.
- Broadcast() dispatch grows an AddressTypeXRP arm.
- rpcURLs table grows XRP_MAINNET + XRP_TESTNET defaults; operators
  shadow per-env via --source-rpc-overrides.

Tests: tesSUCCESS happy path (returns tx_json.hash), terQUEUED
retryable, tecINSUFFICIENT_FUNDS + tecNO_DST fatal, temBAD_AMOUNT
malformed fatal, telINSUF_FEE_P retryable, high-level rippled error
surfaced as RPCError, network transport error, success-without-hash
rejection, 0x-prefix stripping, classifyEngineResult table.
2026-05-27 17:59:02 -07:00
zeekay fbbb9639a9 feat(xrpl): Payment codec + address derivation + DER low-s signature
Introduces internal/xrpl, the bridge's XRP Ledger primitives package:

- AddressFromPubKey / AccountIDFromPubKey: derive the canonical r-address
  from a 33-byte compressed-secp256k1 public key. Hash chain is
  RIPEMD160(SHA256(pubkey)), base58check-encoded with the XRPL alphabet
  + 0x00 version byte. Round-trips through the rubblelabs codec.
- PreSign(PaymentUnsigned) → (32-byte digest, *SignCtx): builds a native
  XRP Payment, embeds the signing pubkey on the wire, returns the
  SHA-512Half of (0x53545800 || serialized_body) — exactly the message
  the MPC quorum must sign.
- Finalize(DER, *SignCtx) → (signedBlob, txHash): attaches the DER
  signature to TxnSignature, returns the full wire-format binary blob
  (suitable for submit's tx_blob param as hex) and the canonical XRPL
  tx hash (SHA-512Half of 0x54584E00 || blob).
- EncodeDERSignature(r, s): ASN.1 DER encode (r, s) with low-s
  normalization (rejects high-s as XRPL's tfFullyCanonicalSig requires)
  and the 0x00 leading-byte rule for INTEGERs with the MSB set.
- CanonicalizeS / SHA512Half exposed for tests + future TrustSet path.

Uses github.com/rubblelabs/ripple for the deep binary codec — every
field-id, type-byte, and length-prefix detail is the library's
responsibility. The bridge owns only the signing flow on top, so MPC
ECDSA can stand in for the library's local-key Sign helper.

Tests cover: canonical pubkey → r-address round-trip, rejection of
bad pubkey lengths/prefixes, DER encoding of standard and high-bit
cases, low-s flip for s > N/2, validation that high-s gets canonicalized
in EncodeDERSignature, PreSign digest matching independent SHA512Half
recomputation, full PreSign → Finalize round-trip via the parser.
2026-05-27 17:54:07 -07:00
zeekay b49e3e6552 feat(bridge): signing driver — release-pool rotation + gas pre-check
Wires the static release pool into the MPC signing driver and adds an
eth_getBalance pre-check that short-circuits swaps before the (slow)
ceremony when the release wallet can't cover destination-chain gas.

signing_driver.go:

- SetReleasePool / SetGasProbe inject the pool + balance probe at
  startup. Without a pool, the driver falls back to extracting the
  wallet ID from Swap.DepositAddress (v1 behaviour).
- signOne() now picks the release wallet from the pool when one is
  configured AND has entries, persisting ReleaseWalletID +
  ReleaseAddress on the Swap so the broadcast + refund drivers can
  read them later. The chosen address goes into the assembler's
  PreSign as SenderAddress so the unsigned tx targets the correct
  nonce + signs against the pool wallet.
- AFTER PreSign (which gave us the exact GasPrice * GasLimit + Value),
  the gas pre-check queries the release wallet's destination-chain
  balance. If balance < required cost, the swap moves to
  SwapStatusFailedInsufficientReleaseGas with a clear LastError
  ("Release wallet 0x… has insufficient balance: balance=… required=…
   gasCost=… value=… short=…"). The MPC ceremony is skipped.
  Probe failures are treated as best-effort: log + continue rather
  than refuse to sign a swap we couldn't verify.
- New ShortCircuited counter distinct from Failures (operator
  dashboards differentiate "operator needs to fund a pool wallet"
  from "transient MPC / RPC flake").

main.go:

- --release-pool-size (default 10), --release-pool-mint-network
  (default LUX_TESTNET), --release-balance-threshold-wei
  (default 1e17 wei = 0.1 LUX), --disable-gas-precheck flag, plus
  --mpc-dashboard-url / --mpc-dashboard-token / --mpc-dashboard-api-key
  for the dashboard signing wiring landed in commit 1.
- Bootstrap path: instantiates the balance probe, then the
  ReleasePool, then calls Bootstrap (which lazily mints the
  remaining wallets up to pool size, idempotent across restarts).
  Wires both probe and pool into the signing driver.
- /health endpoint exposes the pool size + threshold for ops triage.

gas_precheck_test.go covers the three core behaviours:
- insufficient balance → swap transitions to
  failed_insufficient_release_gas with no signer call
- sufficient balance → swap proceeds normally to broadcasting
- probe error → swap proceeds (best-effort, broadcast retry still works)

Together with the earlier dashboard-sign + pool foundation commits,
this closes the testnet refund-loop issue: the bridge now (a) speaks
the real /v1/mpc/sign endpoint, (b) rotates through pre-funded
release wallets, and (c) refuses to spend the MPC ceremony on swaps
that would fail on broadcast.
2026-05-27 17:13:57 -07:00
zeekay fbdfc9a60e feat(bridge): release-wallet pool + balance probe foundation
Introduces a static pool of pre-funded MPC release wallets so a single
under-funded wallet doesn't block all subsequent swaps. Previous
behaviour reused the deposit wallet for the release tx — a fresh
keygen with zero destination-chain balance.

This commit adds the foundation:

- release_pool.go: ReleasePool with round-robin Acquire(), lazy
  Bootstrap() that mints wallets up to a configured size via the
  Keygener interface (*mchain.Client satisfies). Pool entries persist
  across restarts via the new ReleasePoolStore interface.
- ReleasePoolStore implementations on both InMemoryStore (test/dev)
  and ZapStore (prod, persisted under releasepool:NNNNNN keys, sorted
  by zero-padded index for canonical iteration order).
- balance_probe.go: BalanceProbeClient — small JSON-RPC wrapper around
  eth_getBalance that reuses the broadcast + depositcheck RPC URL
  tables (with overrides). Used by both the pool's low-balance
  alerter and by the gas pre-check landing in a follow-up commit.
  Non-EVM networks return ErrFamilyNotSupportedForBalance.
- Swap.ReleaseWalletID + Swap.ReleaseAddress fields so the broadcast
  + refund drivers know which pool wallet signed each swap.
- SwapStatusFailedInsufficientReleaseGas — new terminal state for
  swaps the gas pre-check refuses to enter the MPC ceremony for.
- release_pool_test.go covers Bootstrap idempotency (reload doesn't
  re-mint), pool growth, keygen failure surfacing, round-robin
  fairness, balance-probe call gating on the threshold being set, and
  Entries() copy isolation.

No wiring into the signing driver yet — that's the next commit so
the signing-driver change is small and reviewable in isolation.
2026-05-27 17:13:11 -07:00
zeekay d85ec7f985 feat(mchain): wire SignForWallet to dashboard /v1/mpc/sign (real JWT/API-key path)
The live lux-mpc v2026-05 daemon does NOT expose /sign on the internal
port (:6000); only /keygen lives there. Signing is on the dashboard
listener (port 8081) at POST /v1/mpc/sign with {message, encoding,
walletId, sessionId, value} returning {signature, r, s}. That endpoint
requires a pre-created session minted by POST /v1/mpc/wallets/{id}/sessions.

This commit:

- Adds DashboardURL + DashboardToken (Bearer) + DashboardAPIKey
  (X-API-Key) fields to mchain.Client. Bearer wins when both are set.
- Adds internal/mchain/auth.go with EnsureDashboardSession() that
  lazily mints + caches one signing grant per (walletID, orgID) per
  process. Sessions live 24h with a 15-minute renewal skew.
- signViaDashboard() handles POST /v1/mpc/sign, including a single
  403-retry path that drops the cached session and re-mints. The
  dashboard sometimes returns only {r,s}; the client composes a
  canonical 65-byte EVM signature with v=27 (the assembler's
  ParseRSV tolerates the parity bit and any malleability would be
  caught at broadcast).
- pending_approval sessions are rejected as a hard error (the bridge
  cannot wait for a human approver mid-swap).
- SignForWalletWithOrg now routes through the dashboard when
  DashboardURL is set; APIURL-only configs keep the legacy /sign
  path for in-process mocks.
- New auth_test.go covers happy path, session caching across signs,
  403-triggered renew, Bearer vs API-key precedence, pending_approval,
  HTTP error surfacing, and r+s composition with zero-padding.

Builds on whispers/bridgev2 @ ab4adfd.
2026-05-27 17:02:08 -07:00
venuswhispers ab4adfde5a feat: refunding has been working 2026-05-28 00:10:03 +02:00
venuswhispers ca39b78776 feat: simple base swap is working now 2026-05-27 22:04:22 +02:00
venuswhispers 42f8a95191 feat: cmd/bridge api go has been updated 2026-05-27 17:41:29 +02:00
venuswhispers b76c3ecf2a feat: added mpc sign option 2026-05-27 14:49:55 +02:00
venuswhispers fc6a430f02 feat: base bridge functions like swap function has been set almost 2026-05-25 23:08:12 +02:00
venuswhispers 0a40e3dfcb feat: testnet configuration set, updated deposit workflow with mpc workflow, not teleport 2026-05-25 18:20:04 +02:00
venuswhispers 92040c82de feat: wallet connect function updated 2026-05-23 23:43:10 +02:00
venuswhispers 4fa09558c1 feat: UI bundle svg logo 2026-05-22 23:02:57 +02:00
venuswhispers 9d839f939e feat: UI- mobile responsive 2026-05-22 22:54:00 +02:00
venuswhispers a27060569b feat: bridge UI part is motly finished 2026-05-22 22:05:31 +02:00
venuswhispers 85caa9b091 chore: env has been set up successfully 2026-05-22 15:41:58 +02:00
venuswhispers 978e71e5e9 Merge branch 'main' of https://github.com/luxfi/bridge into whispers/bridgev2 2026-05-22 15:29:11 +02:00
venuswhispers 277210f356 chore: requirement updated 2026-05-22 15:27:07 +02:00
venuswhispers 2ea77c18b3 chore: updated env 2026-05-22 15:01:18 +02:00
zeekay 29b7a31655 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
v1.1.14
2026-05-22 00:33:15 -07:00
zeekay 5f79986acc test(pkg/bridge): 5 cosigner SDK tests — wire-shape + no-secret-leak (issue #390 partial)
Covers the SDK side of the layered cosigner contract while issue #386's
backend implementation is in progress:

  1. Omits cosigners[] entirely when no layered providers configured
     (backwards compat — no behavior change for existing tenants).
  2. Forwards utila intent in snake_case, public IDs only.
  3. Forwards fireblocks intent in snake_case, public IDs only.
  4. Forwards both utila + fireblocks together (belt + suspenders).
  5. Omits optional sub-fields (api_host, vault_id) when unset — no
     'foo: undefined' on the wire.

Each test that forwards an intent also asserts the full request body
contains zero secret-like substrings (private_key, api_secret, jwt,
token, service_account_private_key) — defense in depth against any
future regression where a consumer's bridge.config.ts accidentally
forwards a secret.

Pairs with the 16 server-side validator tests in app/server/src/domain/
__tests__/cosigners.test.ts — together they pin the wire contract from
both ends.

Total SDK tests: 53 → 58. All passing.
2026-05-22 00:26:50 -07:00
zeekay 2f967b673a k8s: draft luxfi-explorer manifest for app/explorer/ replacement (issue #387)
Lands a side-by-side draft of the new single-binary Go explorer at
`ghcr.io/luxfi/explorer` — the canonical replacement for the legacy
Next.js `app/explorer/` (`@luxbridge/explorer`).

Includes:
  - ConfigMap `luxfi-explorer-chains` — initial chain registry (lux +
    ethereum), hot-reloadable via the binary's /v1/explorer/admin/chains
    API once running.
  - Deployment — replicas=1, strategy: Recreate (single PVC writer per
    the SQLite+ZapDB embedded index docs), readiness + liveness probes
    against /healthz, resources sized small (100m/256Mi req → 2c/2Gi limit).
  - PVC 20Gi for the embedded chain index.
  - Service ClusterIP :80 → :3000.
  - Ingress on `bridge-explorer-v2.lux.network` for the soak window —
    legacy keeps `bridge-explorer.lux.network`. Cut over after 1-week QA.

Marked DRAFT in the file header. New bridge dev confirms the image tag
(pin off `:latest` before the legacy delete step) and the RPC config
before `kubectl apply`.

Status update on #387 will go in the issue once this lands on main.
v1.1.13
2026-05-22 00:09:01 -07:00
zeekay 125315a854 docs: refresh README + Nextra index + LLM key docs list (issue #391)
- README.md: replace stale 'Bridge UI → Bridge Server → MPC Nodes' diagram
    with the v1.1.x topology (inlined UI, direct threshold integration,
    layered cosigners). Remove dead links to docs that v1.1.12 deleted
    (MPC-GO-INTEGRATION, CI-CD-DOCKER-IMAGES). Add pointer to issue #392
    for the new bridge dev.

  - docs/LLM.md: refresh 'Key Documentation Files' list to remove the
    deleted MPC-GO/DEPLOYMENT/MIGRATION-TO-GO references; document the
    Nextra site path (`docs/pages/*.mdx`); reframe the migration-files
    history as a v1.1.12 deletion event with the commit hash.

  - docs/content/docs/index.mdx: refresh the architecture diagram inside
    the Nextra site itself so the public docs match the new topology.
    Add the protocol class table (classical vs PQ-safe lattice) and the
    optional-layered-cosigners line. The diagram now matches LLM.md and
    the top-level README — three places, one story.

Closes the remainder of #391.
2026-05-22 00:07:59 -07:00
zeekay 5358d73653 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.
v1.1.12
2026-05-22 00:04:39 -07:00
zeekay 2b9f8995e5 docs: delete 8 stale migration / status / setup .md files (issue #391)
Delete the leftover Rust→Go MPC migration notes and the Docker Compose
deployment guide. Those describe an architecture from before the SDK
rationalization and confuse new readers (e.g. LOCAL-SETUP.md was still
claiming images weren't published yet; MPC-MODERNIZATION-SUMMARY.md was
a celebration page for migration work that finished long ago).

  - BRIDGE-STATUS.md
  - LOCAL-SETUP.md
  - MPC-GO-INTEGRATION.md
  - MPC-INTEGRATION-COMPLETE.md
  - MPC-MODERNIZATION-SUMMARY.md
  - MIGRATION-TO-GO-MPC.md
  - DEPLOYMENT.md           (GG18 → CGG21MP migration; both pre-CGGMP21)
  - CI-CD-DOCKER-IMAGES.md  (described pre-self-hosted-runner workflow)

Migration history is in git log + GH releases. Live setup lives in
docs/LLM.md (now the single canonical AI/onboarding doc) plus the
top-level README.md.

Kept:
  - docs/LLM.md         (canonical source of truth, refreshed in v1.1.11)
  - docs/LUX-ID-INTEGRATION.md (still applicable; Casdoor-based auth)

Closes part of #391.
2026-05-21 19:02:48 -07:00
zeekay 5807b86a16 docs(LLM): correct stale diagram, document inlined UI + explorer migration + layered cosigners
Fact-check pass against actual repo state, fixing five stale claims that
referenced an architecture that hasn't existed since phase 1.5:

  1. app/bridge3/ (@luxbridge/app-v3) — DOES NOT EXIST. Folded into
     pkg/bridge/src/app/ at 9b12b12 ('feat(pkg/bridge): inline bridge UI
     under src/app/'). Bridge.tsx no longer lazy-imports any sibling
     workspace package; the cycle that produced the blank-page bug is gone.

  2. app/bridge/ — collapsed at 95a1981 ('phase2-r2: collapse to canonical
     app/bridge tenant') from a multi-variant Next.js setup to a single
     @luxbridge/lux-tenant (Vite + ~120 LOC).

  3. Frontend stack — Vite + React 18.3.1 + Tamagui (@hanzo/gui v7), NOT
     Next.js 14. SDK ships TypeScript source directly; consumer Vite
     bundles it.

  4. @luxfi/threshold — consumed BOTH by app/server and by the SDK
     directly (pkg/bridge/src/app/lib/mpc-session.ts since phase 3 R3),
     not server-only as the prior diagram showed.

  5. Service ports — tenant UI is 3001 (Vite), not 3000.

Adds:

  - System architecture ASCII diagram reflecting the inlined UI +
    direct threshold + layered-cosigner topology.
  - 'Optional layered cosigners' subsection documenting Utila +
    Fireblocks layering pattern (since SDK v1.0.3).
  - 'Explorer migration' section — app/explorer/ (Next.js) is on a
    deprecation path; canonical replacement is ghcr.io/luxfi/explorer
    (single Go binary embedding luxfi/indexer + luxfi/graph + luxfi/explore
    via go:embed). Same pattern as LiquidGraph cutover in liquidity/universe.
  - Publishing status — @luxfi/bridge + threshold still 404 on npm as of
    2026-05-22; likely missing NPM_TOKEN secret OR unavailable lux-build
    runner pool. v1.1.10 fail-fast guard will surface either clearly.
  - 'History — what got folded together' subsection so the prior
    architecture changes are findable from the docs (commit refs included).
v1.1.11
2026-05-21 18:40:31 -07:00
zeekay 36c90054d2 feat(pkg/bridge): optional Utila + Fireblocks layered cosigners (1.0.2 -> 1.0.3)
External MPC custodians can now be layered ON TOP of the native Lux
threshold network as additional cosigners. The bridge backend enforces
2-of-2 (native + listed cosigner) before releasing settlement. Use when
tenants are already on Utila or Fireblocks for institutional custody and
need regulated-cosigner gating without giving up the native threshold
property.

Browser-safe by construction: the SDK declares only PUBLIC identifiers
(orgId, clientId, apiKey id, vault ids). The Utila JWT and the
Fireblocks secret key live on app/server/ (sourced from KMS at boot).
No secret material is ever shipped in the page bundle.

Surface:

  BridgeMPCConfig {
    publicUrl?, privateUrl?, protocol?       (existing native MPC)
    utila?:      BridgeUtilaConfig           (new, optional)
    fireblocks?: BridgeFireblocksConfig      (new, optional)
  }

publicUrl is now optional too — pure-external custody (utila/fireblocks
only, no native threshold) is supported. The runMpcSignSession helper
throws if it's called without publicUrl, and useTransfers gates the
client-side sign session on its presence.

Per-swap, the SDK forwards a cosigners[] array to POST /api/swaps
carrying just the public IDs. The backend pairs each entry with its
KMS-held secret half and completes the cosign. Both blocks may be set
together (belt + suspenders) — backend enforces all listed.

Tests: 53/53 passing. Typecheck clean. No new runtime deps in pkg/bridge
(utila/fireblocks SDKs live on the server, not the browser bundle).
2026-05-21 18:40:15 -07:00
zeekay 07edf13f14 ci(publish): harden token auth + self-hosted runner config
Roll up four workflow hardening fixes that were stuck on the R2.5 branch
(commits ffd0f79, 0a8cbe1, 5be700e, a88fc50) — picked up here without
their root-package version bumps:

  - runs-on lux-build (self-hosted org runner; GitHub-hosted not authorised)
  - fetch-depth: 1 in actions/checkout (avoid 'cannot fetch both sha and
    refs/tags/v1.1.x' on self-hosted)
  - bake token into NPM_CONFIG_USERCONFIG path (pnpm does NOT expand
    ${VAR} references in .npmrc — literal token must be on disk)
  - accept NPM_TOKEN or NODE_AUTH_TOKEN, fail fast at auth step with a
    clear ::error:: message if both are blank (better than opaque pnpm
    publish failure)

Re-tagging v1.1.10 to retrigger the publish workflow with these fixes.
v1.1.10
2026-05-21 17:41:33 -07:00
zeekay 59f9e78215 pkg/bridge: bump @luxfi/bridge 1.0.1 -> 1.0.2 (Phase 3 R3 — real MPC wallet wiring) v1.1.9 2026-05-21 17:36:00 -07:00
zeekay 0bdb81978e merge: Phase 3 R3 — real MPC wallet wiring (wagmi + viem + @luxfi/threshold)
Phase 3 R3 wires the bridge SDK to real on-chain primitives, replacing
the DEADBEEF stubs from the Tamagui phase:

  - useWallet → wagmi-backed (WalletConnect v2 + injected + EVM chain set
    from BridgeConfig.wallet.{walletConnectProjectId,defaultChainId,supportedChainIds})
  - useSwap   → real /v1/quote backend round-trip (no static rate matrix)
  - useTransfers → POST /v1/swap + server-side polling of session status
  - mount.ts → installs WagmiProvider above GuiProvider above BridgeApp
  - BridgeConfig.mpc → @luxfi/threshold (T-Chain) session helper for the
    threshold-sig protocol selected by tenant (cggmp21|frost|bls|doerner|
    pulsar|corona|magnetar)

Bundle sanity:
  app/bridge dist DEADBEEF count: 0 (sourcemap only, JS clean)
  7 chunks, largest 484k core (Tamagui+wagmi+threshold runtime)

Closes #4.
2026-05-21 17:35:24 -07:00
zeekay faa3e91d3c pnpm-lock: refresh after rebase on f21dc6b (union of R3 deps + brand sweep) 2026-05-21 17:34:42 -07:00
zeekay c4542a08dd pkg/bridge: useTransfers POSTs real swaps + polls server, BridgeApp installs WagmiProvider
useTransfers replaces the setTimeout phase progression with the real
flow:
  1. POST ${cfg.apiHost}/api/swaps with the order (Idempotency-Key
     header set to the local transfer id, server snake_case body).
  2. Subscribe via GET /api/swaps/:id polling at 2s; map the server's
     free-form status string → TransferPhase via statusToPhase()
     (signing/broadcasting/completed/failed detection).
  3. On entry into the signing phase, start an MPC threshold session
     via runMpcSignSession() when the server record carries the
     keyId + messageHash. Progress is patched onto the Transfer.mpc
     field so the UI can render real signer activity.
  4. On unmount or clear(): abort every per-transfer controller so
     polls + MPC sessions stop cleanly.

Destination address defaults to the connected wallet (self-send across
chains is the common case); tenants needing sweep-to-treasury flows
pass an explicit address. New cases marked failed cleanly: no wallet
connected, unsupported chain pair, server hard-error.

submit() kept as a back-compat alias for initiate() so SwapForm.tsx
(off-limits — Phase 3 R2.5 is in parallel on components/) doesn't have
to retouch. The richer destinationAddress field is optional.

BridgeApp.tsx wraps its inner tree in <WagmiProvider> +
<QueryClientProvider>. The Config is built once per mount from
BridgeConfig.wallet via buildWagmiConfig(); the QueryClient defaults
disable refetch-on-focus/reconnect (bridge quotes are request-scoped
and shouldn't refresh behind the user's back).

4 tests cover initiate POST shape, the wallet-not-connected failure
mode, the unsupported-chain-pair failure mode, and clear() aborting
polls.
2026-05-21 17:28:05 -07:00
zeekay e429f20719 pkg/bridge: replace useSwap static rate matrix with real backend quotes
useSwap now hits ${cfg.apiHost}/api/quote (GET, query params per the
server's app/server/src/routes/quote.ts) with a 300ms debounce. Maps
bridge chain ids → server internal_name (ETHEREUM_MAINNET / LUX_MAINNET
/ etc, suffix-swapped for testnet/devnet env). In-flight requests are
aborted via AbortController on every keystroke so the backend isn't
flooded.

New SwapState fields: quoting (boolean) + quoteError (string | null).
quoteError surfaces 5xx + network errors to the UI rather than blanking
the quote silently. AbortError is suppressed (newer keystroke).

The previous static USD-rate matrix is gone — pricing now comes from
the server's getTokenPrice oracle. The Quote shape is preserved
(outAmount/feeUsd/destGas/etaText/rate) so SwapForm doesn't need to
change; minOut is added for slippage protection on submission.

5 tests cover debounce, same-chain noop, invalid input, server error,
and reverse-swap state.

Also flips vitest config to happy-dom so hook tests can mount under
a real DOM-ish environment (jsdom would also work; happy-dom starts
faster on cold init).
2026-05-21 17:28:05 -07:00
zeekay 50ccb1dd23 pkg/bridge: replace useWallet stub with real wagmi-backed wallet hook
useWallet now wraps wagmi's useAccount + useConnect + useDisconnect +
useSignMessage + useSwitchChain. Connector preference: WalletConnect
(when wallet.walletConnectProjectId is supplied) → injected (MetaMask,
Rabby, etc.) → Coinbase Wallet → first available.

Adds disconnect(), switchChain(bridgeId), signMessage(msg) to
WalletState — capabilities the SwapForm submission flow + future MPC
co-signing will need.

DEV fallback preserved: when import.meta.env.DEV is true AND no
connector is registered, the hook still throws a clear error
("install MetaMask, or configure wallet.walletConnectProjectId") rather
than silently faking a connection. The display-only DEADBEEF address
literal is kept only for the gated DEV branch — production builds
tree-shake it out (the gate is statically evaluable).

7 tests cover the wagmi delegation and the EVM-only switchChain guard.
2026-05-21 17:28:05 -07:00
zeekay 6eb9713402 pkg/bridge: add bridge backend API client + MPC threshold session helper
bridge-api.ts wraps the Express bridge server's /api/{quote,swaps,...}
routes (see app/server/src/routes). Pure functions, no React. Errors
throw BridgeApiError({ status, body }) so callers can surface or
retry. chainIdToInternalName maps SDK chain ids → server enum
("evm:1" → "ETHEREUM_MAINNET", suffix-rewritten for testnet/devnet).

mpc-session.ts wraps @luxfi/threshold's ThresholdClient with a
progress-reporting wrapper used by useTransfers to surface real
MPC ceremony state to the UI instead of a setTimeout placeholder.
Protocol defaults to cggmp21 (secp256k1, EVM-compatible); respects
BridgeMPCConfig.protocol when set (pulsar/corona/magnetar for the
post-quantum path).

14 tests across both modules: fetch shape, query/body construction,
error mapping, JSON-RPC happy-path + failure + abort.
2026-05-21 17:28:05 -07:00
zeekay 895f768ac8 pkg/bridge: add wagmi+viem+react-query, build wagmi Config from BridgeConfig.wallet
Pulls in wagmi 2.15.5 (matches lux/exchange/apps/web), viem 2.30.5,
@tanstack/react-query 5.90.20 as direct dependencies of @luxfi/bridge.
Also wires @luxfi/threshold as a workspace dep for the MPC layer used by
useTransfers (separate commit).

New module pkg/bridge/src/app/lib/wagmi-config.ts:
  - buildWagmiConfig(cfg) — pure function, creates a wagmi Config scoped to
    the BridgeApp mount with injected + Coinbase + optional WalletConnect
    connectors (WC only when wallet.walletConnectProjectId is set, so
    Reown's appkit doesn't 403 on missing project id for white-label
    deploys that haven't provisioned one).
  - bridgeIdToWagmiChainId / wagmiChainIdToBridgeId — boundary helpers
    between the SDK's namespaced chain ids ("evm:1") and wagmi's numeric
    ids. Non-EVM bridge ids return null — those legs sign via MPC, not
    wagmi.
  - Chain selection respects wallet.supportedChainIds (filter) and
    wallet.defaultChainId (hoisted to chains[0], wagmi's default for the
    unconnected state).

9 tests cover the config builder + the chain-id boundary mappers.
2026-05-21 17:28:05 -07:00
zeekay f21dc6bf7a merge: brand sweep nits (destructure brand, restore canonical bridge endpoints, drop secondaryColor) v1.1.8 2026-05-21 17:16:06 -07:00
zeekay 35e1daf017 bridge.config: destructure brand fields, restore canonical bridge endpoints, drop secondaryColor (red review)
Three nit fixes from red review of the @luxfi/brand wiring:

1. Destructure consumed fields only (shortName, primaryColor, supportEmail).
   Cuts the TypeScript surface to what's actually used; prevents accidental
   tenant-metadata leakage if a future SDK feature ever inspects the brand
   block. Note: Rollup keeps the full JSON literal alive in the bundle even
   after destructure (JSON modules aren't object-property tree-shaken), so
   bundle bloat for unused brand fields is unchanged until @luxfi/brand
   ships per-field named exports.

2. Restore canonical bridge endpoints. brand.appDomain is "lux.exchange"
   (the exchange's domain) — deriving api.bridge.${appDomain} pointed the
   bridge SDK at a host that doesn't exist. Bridge endpoints belong to
   the bridge subdomain, independent of the brand package's appDomain.

3. Drop secondaryColor. brand.theme.{light,dark}.accent1 is a per-theme
   foreground (white on dark, black on light), NOT a brand accent —
   wiring it to --brand-secondary breaks hover-color on the opposite-theme
   surface. Fall back to the SDK's compiled default
   (var(--brand-secondary, #7a9ff5) in pkg/bridge/src/app/styles/theme.css)
   until @luxfi/brand exposes a real brand-accent field.

Verified:
  - typecheck clean
  - vite build clean (754 modules, 4.3s)
  - api.bridge.lux.network appears in bundle (canonical fallback active)
  - DEADBEEF: 0 hits

Single surgical diff in app/bridge/src/bridge.config.ts.
2026-05-21 17:07:29 -07:00
zeekay 79b9151304 merge: e2e smoke + GuiProvider runtime fix 2026-05-21 17:04:30 -07:00
zeekay 3bff3e7fb6 app/bridge: explicit Lux Bridge favicon + exchange/bridge separation
- index.html favicon now uses @luxfi/logo's getFaviconSVG output
  (white triangle on black rounded square) verbatim, replacing the
  blue placeholder rect that was a build-template artifact.
- bridge.config.ts already destructures only org-level fields from
  @luxfi/brand (shortName, primaryColor, supportEmail) — the comment
  block now makes the exchange-vs-bridge separation explicit:
  @luxfi/brand is the Lux EXCHANGE product's brand; the bridge has
  its own endpoints, name, and docs URL. Only colors and the support
  email are shared with the exchange via the org brand.
2026-05-21 17:04:20 -07:00
zeekay e5b89d0c44 e2e: Playwright smoke + multi-tenant specs for tenant bridge SPA
Env-parametric specs covering bridge.lux.network (and bridge.zoo.network
+ future tenants). Mirrors the wider Lux e2e env routing convention:

  E2E_ENV=local (default) → http://localhost:3001 (auto-boots vite preview)
  E2E_ENV=dev             → https://bridge.lux-dev.network
  E2E_ENV=test            → https://bridge.lux-test.network
  E2E_ENV=main            → https://bridge.lux.network
  BRIDGE_TENANT=zoo       → https://bridge.zoo.network (mainnet only)

smoke.spec.ts (6 cases, ~16s):
  1. Renders, title matches brand, SVG brand logo from @luxfi/logo
  2. From-chain selector has ≥ 2 options
  3. From-asset selector has ≥ 2 options after switching to Ethereum
     (default chain Lux only has the native LUX asset)
  4. Connect-wallet button visible + clickable (no real wallet connect)
  5. Typing an amount updates the "You receive" estimate via useSwap
  6. Production gate: served HTML + JS bundles free of `DEADBEEF`
     (the dev-only stub MPC display address)

multi-tenant.spec.ts (skips unless E2E_ENV=main):
  - For each tenant in the registry, probe DNS, navigate, assert
    document.title and the --brand-primary CSS variable set by
    applyBrandMetadata reflect the tenant's pinned brand block.

Playwright config auto-boots `vite preview` on :3001 from app/bridge/
when E2E_ENV=local, so `pnpm test:smoke` is one-step after `pnpm build`.
README documents the build prereq and the runner contract.

Out of scope (TODOs in code):
  - Real wallet connect (Playwright wallet mocking is a separate PR)
  - Real backend swap submit (prod backend not reliably reachable from CI)
  - Visual regression / screenshot diffs (separate PR)
2026-05-21 17:00:58 -07:00
zeekay 73fa005ef9 pkg/bridge: bootstrap @hanzo/gui runtime + provider in mountBridge
Phase 3 R2 swapped <button>/<input> primitives onto @hanzo/gui's Button
and Input components in SwapForm, WalletConnect, and AssetInput, but the
runtime bootstrap was never added. The library's primitives read from
globalThis.__guiConfig at render time and throw Error("Err0") when null,
and the JSX tree also needs a GuiProvider for theme context — without
both, the SDK mounts to a blank page.

Wire both inside the SDK so consumers don't need to know about them:

  - mount.ts: call createGui(getDefaultGuiConfig()) before React render,
    idempotent (skips when globalThis.__guiConfig already populated).
  - Bridge.tsx: wrap <BridgeApp /> in <GuiProvider defaultTheme="dark">.

@hanzogui/config-default added as a direct dependency (was transitive
only). Imports cast to unknown to bridge the source/types mismatch
introduced by the monorepo's postinstall rename of upstream
createHanzogui/HanzoguiProvider → createGui/GuiProvider on .ts files
but not the shipped .d.ts.
2026-05-21 17:00:40 -07:00
zeekay 44b87b559f manifests: rename VITE_BRIDGE_* env keys → BRIDGE_*
The runtime shim (window.__ENV / /__ENV.js) reads BRIDGE_* names from
the container env at boot. The previous VITE_BRIDGE_* prefix only made
sense when the build was Vite-time inlining the values, which is no
longer the case (and was the bug behind manifest+runtime drift). One
canonical name across compose+k8s+entrypoint.
2026-05-21 16:52:35 -07:00
zeekay 35d2cc7226 merge: runtime env shim (window.__ENV + nginx docker-entrypoint)
# Conflicts:
#	app/bridge/src/bridge.config.ts
2026-05-21 16:51:50 -07:00
zeekay 3ee4b67877 merge: @luxfi/brand as canonical Lux brand source 2026-05-21 16:50:39 -07:00
zeekay b0a75e6816 app/bridge: Dockerfile templates /__ENV.js at container boot
Pair commit for the runtime env shim. The previous commit made the
SDK read window.__ENV from /__ENV.js; this commit makes the serving
image overwrite /__ENV.js from real env at boot.

Switch base from ghcr.io/hanzoai/spa (scratch, no shell — can't run
a docker-entrypoint script) to nginx:alpine, which already has the
/docker-entrypoint.d/*.sh hook for exactly this case. The hook
templates /__ENV.js via a heredoc — sh-only, no envsubst dep — so
the image only needs nginx:alpine, nothing else.

Tenants copy app/bridge/docker-entrypoint.sh verbatim and change the
env var names to match the keys their bridge.config.ts looks up.

Verified end-to-end on the host: running the entrypoint with
BRIDGE_* set produces a /__ENV.js with the values substituted in;
running it with nothing set produces the same template the build
ships (empty strings, SDK falls back to import.meta.env).
2026-05-21 16:48:19 -07:00
zeekay ebd9d71efb app/bridge: runtime env shim via window.__ENV / /__ENV.js
Vite inlines `import.meta.env.VITE_*` at build time, so setting those
vars at runtime on a deployed image is a no-op — the bundle already
carries baked URLs. Compose/k8s `environment:` blocks targeting
VITE_BRIDGE_* are cosmetic in that world.

Layer a tiny runtime shim on top:
  1. /__ENV.js (this commit ships the build-time template under
     public/) is loaded synchronously in <head> before the main
     bundle. The serving image overwrites it at boot from real env.
  2. bridge.config.ts reads window.__ENV first, then falls back to
     import.meta.env.VITE_* for dev and as a safety net.

This decouples the deployable image from its environment: one image,
N envs. Tenants (zoo, future white-labels) copy the public/__ENV.js
template + the env() helper pattern verbatim — only the key names
change.

The dist bundle (search: `window.__ENV`) wires the runtime read,
confirmed via Vite preview + Playwright eval — `window.__ENV` is an
Object with all six expected keys at page load. The pattern works
with or without the docker-entrypoint that ships in a follow-up
commit; an image that fails to template /__ENV.js still gets a
working SPA via build-time fallback.
2026-05-21 16:48:05 -07:00
zeekay 1c95699b99 app/bridge: read Lux brand from @luxfi/brand (single source of truth)
Replace the five hardcoded Lux strings/colors in the Lux tenant build
with reads from @luxfi/brand's brand.json:

  name           "Lux Bridge"                  -> `${shortName} Bridge`
  primaryColor   "#5b8def"                     -> brand.primaryColor (#000000)
  secondaryColor "#7a9ff5"                     -> theme.dark.accent1
  supportEmail   "[email protected]"         -> brand.supportEmail
  apiHost / docsUrl literal lux.network        -> derived from appDomain/docsDomain

Use the `@luxfi/brand/brand.json` subpath rather than the package's
main entry: the npm-published @luxfi/[email protected] omits its .d.ts shipping
artefacts, but brand.json is included and TypeScript infers full literal
types under `resolveJsonModule`. This also keeps the package as the
single source of truth — a brand block bump in @luxfi/brand propagates
here on the next install with no string copies.

Each lookup keeps a hard fallback string so the build never breaks when
@luxfi/brand omits a field in a future version. The fallbacks match the
pre-cutover hardcodes (lux.network defaults).

Tenant build verified:
  pnpm --filter @luxbridge/lux-tenant typecheck   clean
  pnpm --filter @luxbridge/lux-tenant build       clean (bundles brand.json)
  pnpm --filter @luxfi/bridge typecheck           clean (SDK unchanged)

SDK sweep clean: no hardcoded Lux brand overrides under pkg/bridge/src/app/.
The two remaining matches are:
  - pkg/bridge/src/app/styles/theme.css   header comment naming the SDK
  - pkg/bridge/src/app/lib/chains.ts:29   `name: 'Lux Network'` chain identity
    on `lux:96369` (structurally identical to `'Ethereum'` on `evm:1`).

DEADBEEF audit unchanged: 1 occurrence, gated behind `import.meta.env.DEV`
in pkg/bridge/src/app/hooks/useWallet.ts:32.
2026-05-21 16:47:21 -07:00