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
This commit is contained in:
2026-05-28 01:07:23 -07:00
63 changed files with 12085 additions and 3768 deletions
+41 -36
View File
@@ -1,4 +1,10 @@
name: Docker name: Docker
# Single-arch (linux/amd64) container build on the working `lux-build` ARC label.
# Multi-arch (arm64) will return once the per-arch `lux-build-linux-arm64`
# pool is verified online (current per-arch labels did not pick up jobs).
# No QEMU, no cross-org reusable.
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
@@ -9,42 +15,41 @@ permissions:
contents: read contents: read
packages: write packages: write
jobs: env:
docker: IMAGE: ghcr.io/luxfi/bridge
strategy:
fail-fast: false
matrix:
include:
- image: ghcr.io/luxfi/bridge
dockerfile: Dockerfile
context: .
- image: ghcr.io/luxfi/bridge/server
dockerfile: app/server/Dockerfile
context: .
- image: ghcr.io/luxfi/bridge-ui
dockerfile: app/bridge/Dockerfile
context: .
uses: hanzoai/.github/.github/workflows/docker-build.yml@main
with:
image: ${{ matrix.image }}
dockerfile: ${{ matrix.dockerfile }}
context: ${{ matrix.context }}
secrets: inherit
notify-universe: jobs:
needs: docker build:
if: startsWith(github.ref, 'refs/tags/v') runs-on: lux-build
runs-on: ubuntu-latest
steps: steps:
- uses: peter-evans/repository-dispatch@v3 - uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with: with:
token: ${{ secrets.UNIVERSE_PAT }} registry: ghcr.io
repository: luxfi/universe username: ${{ github.actor }}
event-type: image-published password: ${{ secrets.GITHUB_TOKEN }}
client-payload: | - name: Compute tags
{ id: meta
"service": "bridge", run: |
"image": "ghcr.io/luxfi/bridge", set -e
"tag": "${{ github.ref_name }}", tags=""
"sha": "${{ github.sha }}" if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
} v="${GITHUB_REF#refs/tags/}"
tags="ghcr.io/luxfi/bridge:${v},ghcr.io/luxfi/bridge:latest"
elif [[ "${GITHUB_REF}" == refs/heads/main ]]; then
tags="ghcr.io/luxfi/bridge:main"
elif [[ "${GITHUB_REF}" == refs/heads/dev ]]; then
tags="ghcr.io/luxfi/bridge:dev"
elif [[ "${GITHUB_REF}" == refs/heads/test ]]; then
tags="ghcr.io/luxfi/bridge:test"
fi
echo "tags=${tags}" >> "$GITHUB_OUTPUT"
- name: Build & push
if: steps.meta.outputs.tags != ''
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
+48
View File
@@ -0,0 +1,48 @@
name: Release (Go)
# Native binary release for the bridge Go CLIs (cmd/bridge, opnet-watcher)
# on `v*` tags. Six-platform matrix via goreleaser.
#
# Container image is built independently by .github/workflows/docker.yml
# (native multi-arch ARC runners — NO QEMU).
# npm publish is handled by .github/workflows/publish.yml.
#
# Runner labels: lux-build self-hosted ARC scale-sets per the org policy.
# GitHub-hosted runners are NOT a permitted fallback.
on:
push:
tags: ['v*']
workflow_dispatch:
permissions:
contents: write
packages: write
id-token: write
jobs:
goreleaser:
runs-on: lux-build
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install syft for SBOM
run: |
mkdir -p "$HOME/.local/bin"
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
| sh -s -- -b "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-9
View File
@@ -1,9 +0,0 @@
name: Workflow Sanity
on:
pull_request:
push:
branches: [main]
jobs:
sanity:
uses: hanzoai/.github/.github/workflows/workflow-sanity.yml@main
+63
View File
@@ -0,0 +1,63 @@
# goreleaser config for the bridge Go binaries.
#
# Triggered by the release workflow on `v*` tag push (same tag that
# drives the npm publish of @luxfi/bridge — the Go and TS sides ship
# together). Builds native binaries for all six policy platforms.
#
# Scope: cmd/bridge (the API + frontend host) — the canonical Go
# binary for this module. opnet-watcher/ is a separate Go module
# (its own go.mod) and ships from its own release pipeline; goreleaser
# cannot build it from the parent module's context.
#
# The Vite SPA + container images are built by .github/workflows/docker.yml
# (native multi-arch via hanzoai/.github reusable workflow).
version: 2
project_name: bridge
before:
hooks:
- go mod tidy
builds:
- id: bridge
main: ./cmd/bridge
binary: bridge
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
archives:
- id: bridge
ids: [bridge]
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
files:
- LICENSE*
- README*
checksum:
name_template: checksums.txt
sboms:
- artifacts: archive
snapshot:
version_template: "{{ incpatch .Version }}-next"
changelog:
use: github
sort: asc
release:
draft: false
prerelease: auto
mode: append
+8 -2
View File
@@ -5,10 +5,16 @@
FROM node:22-alpine AS build FROM node:22-alpine AS build
RUN apk add --no-cache libc6-compat python3 make g++ git RUN apk add --no-cache libc6-compat python3 make g++ git
RUN corepack enable RUN corepack enable && corepack prepare [email protected] --activate
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN pnpm install --frozen-lockfile || pnpm install RUN pnpm install --frozen-lockfile 2>/dev/null || pnpm install --no-frozen-lockfile
# Build only the workspace deps app/bridge transitively needs whose packages
# declare exports → dist/ (i.e. require a compile to be resolvable). pkg/bridge
# itself ships from src/ (exports point at src/*.ts), so Vite consumes its TS
# directly — no build needed. pkg/utila + pkg/settings + pkg/ui are not in
# app/bridge's dep graph and are intentionally skipped.
RUN pnpm -C pkg/threshold build && pnpm -C pkg/core build
RUN pnpm -C app/bridge build RUN pnpm -C app/bridge build
FROM ghcr.io/hanzoai/spa FROM ghcr.io/hanzoai/spa
+4 -4
View File
@@ -2,7 +2,7 @@
"name": "@luxbridge/lux-tenant", "name": "@luxbridge/lux-tenant",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"description": "bridge.lux.network \u2014 Lux-branded tenant build consuming @luxfi/bridge + @luxfi/brand.", "description": "bridge.lux.network — Lux-branded tenant build consuming @luxfi/bridge + @luxfi/brand.",
"license": "MIT", "license": "MIT",
"author": "Lux Industries, Inc.", "author": "Lux Industries, Inc.",
"type": "module", "type": "module",
@@ -21,15 +21,15 @@
"@tanstack/react-query": "5.90.20", "@tanstack/react-query": "5.90.20",
"react": "catalog:", "react": "catalog:",
"react-dom": "catalog:", "react-dom": "catalog:",
"react-native-web": "^0.19.0", "react-native-web": "^0.21.2",
"viem": "2.30.5", "viem": "2.30.5",
"wagmi": "2.15.5" "wagmi": "2.15.5"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "catalog:", "@types/react": "catalog:",
"@types/react-dom": "catalog:", "@types/react-dom": "catalog:",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^6.0.2",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "^5.4.11" "vite": "^8.0.14"
} }
} }
@@ -4,49 +4,49 @@ export function StatusIcon({ swap, className}: { swap: string | undefined , clas
switch (swap) { switch (swap) {
case SwapStatus.Failed: case SwapStatus.Failed:
return ( return (
<div className={"inline-flex items-center " + className ?? ''} > <div className={"inline-flex items-center " + (className ?? '')} >
<RedIcon /> <RedIcon />
<p>Failed</p> <p>Failed</p>
</div> </div>
) )
case SwapStatus.Completed: case SwapStatus.Completed:
return ( return (
<div className={"inline-flex items-center " + className ?? ''} > <div className={"inline-flex items-center " + (className ?? '')} >
<GreenIcon /> <GreenIcon />
<p>Completed</p> <p>Completed</p>
</div> </div>
) )
case SwapStatus.Cancelled: case SwapStatus.Cancelled:
return ( return (
<div className={"inline-flex items-center " + className ?? ''} > <div className={"inline-flex items-center " + (className ?? '')} >
<GreyIcon /> <GreyIcon />
<p>Cancelled</p> <p>Cancelled</p>
</div> </div>
) )
case SwapStatus.UserTransferPending: case SwapStatus.UserTransferPending:
return ( return (
<div className={"inline-flex items-center " + className ?? ''} > <div className={"inline-flex items-center " + (className ?? '')} >
<YellowIcon /> <YellowIcon />
<p>Deposite pending</p> <p>Deposite pending</p>
</div> </div>
) )
case SwapStatus.UserTransferDelayed: case SwapStatus.UserTransferDelayed:
return ( return (
<div className={"inline-flex items-center " + className ?? ''} > <div className={"inline-flex items-center " + (className ?? '')} >
<YellowIcon /> <YellowIcon />
<p>Delayed</p> <p>Delayed</p>
</div> </div>
) )
case SwapStatus.BridgeTransferPending: case SwapStatus.BridgeTransferPending:
return ( return (
<div className={"inline-flex items-center " + className ?? ''} > <div className={"inline-flex items-center " + (className ?? '')} >
<YellowIcon /> <YellowIcon />
<p>Pending</p> <p>Pending</p>
</div> </div>
) )
case SwapStatus.Expired: case SwapStatus.Expired:
return ( return (
<div className={"inline-flex items-center " + className ?? ''} > <div className={"inline-flex items-center " + (className ?? '')} >
<GreyIcon /> <GreyIcon />
<p>Expired</p> <p>Expired</p>
</div> </div>
+1 -1
View File
@@ -3,4 +3,4 @@
/// <reference types="next/navigation-types/compat/navigation" /> /// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information. // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+23 -23
View File
@@ -12,36 +12,36 @@
"cn": "rm -rf ./.next" "cn": "rm -rf ./.next"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^1.7.15", "@headlessui/react": "^2.2.10",
"@heroicons/react": "^2.0.18", "@heroicons/react": "^2.2.0",
"@luxdefi/ui": "0.2.3", "@luxdefi/ui": "0.5.10",
"@next/mdx": "14.1.0", "@next/mdx": "16.2.6",
"@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.0.6", "@radix-ui/react-tooltip": "^1.2.8",
"axios": "^1.6.0", "axios": "^1.16.1",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.0", "clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^4.0.2",
"framer-motion": "^10.12.18", "framer-motion": "^12.40.0",
"heroicons": "^2.0.18", "heroicons": "^2.2.0",
"lucide-react": "^0.307.0", "lucide-react": "^1.16.0",
"next": "catalog:", "next": "catalog:",
"react": "catalog:", "react": "catalog:",
"react-dom": "catalog:", "react-dom": "catalog:",
"swr": "^2.2.0", "swr": "^2.4.1",
"tailwind-merge": "^2.2.0" "tailwind-merge": "^3.6.0"
}, },
"devDependencies": { "devDependencies": {
"@mdx-js/react": "^3.0.0", "@mdx-js/loader": "^3.1.1",
"@mdx-js/loader": "^3.0.0", "@mdx-js/react": "^3.1.1",
"@types/mdx": "^2.0.13",
"@types/react": "catalog:", "@types/react": "catalog:",
"@types/react-dom": "catalog:", "@types/react-dom": "catalog:",
"@types/mdx": "^2.0.9", "autoprefixer": "10.5.0",
"autoprefixer": "10.4.16", "postcss": "8.5.15",
"postcss": "8.4.33", "postcss-import": "^16.1.1",
"postcss-import": "^16.0.0",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "catalog:" "typescript": "catalog:"
+36 -34
View File
@@ -41,48 +41,50 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.17", "@types/cors": "^2.8.19",
"@types/express": "^4.17.21", "@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.10",
"@types/morgan": "^1.9.9", "@types/morgan": "^1.9.10",
"@types/multicoin-address-validator": "^0.5.3", "@types/multicoin-address-validator": "^0.5.3",
"@types/node": "^20.17.1", "@types/node": "^25.9.1",
"@types/uuid": "^10.0.0", "@types/uuid": "^11.0.0",
"@typescript-eslint/eslint-plugin": "^8.2.0", "@typescript-eslint/eslint-plugin": "^8.59.4",
"@typescript-eslint/parser": "^8.2.0", "@typescript-eslint/parser": "^8.59.4",
"nodemon": "^3.1.4", "nodemon": "^3.1.14",
"prettier": "^3.3.3", "prettier": "^3.8.3",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsc-alias": "^1.8.10", "tsc-alias": "^1.8.17",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.5.4", "typescript": "^6.0.3",
"vitest": "^2.1.0" "vitest": "^4.1.7"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-kms": "^3.1053.0",
"@azure/identity": "^4.13.1",
"@azure/keyvault-keys": "^4.10.0",
"@google-cloud/kms": "^5.5.0",
"@luxfi/core": "workspace:*", "@luxfi/core": "workspace:*",
"@luxfi/utila": "^3.0.0", "@luxfi/utila": "workspace:^",
"@noble/ed25519": "^2.3.0", "@noble/ed25519": "^3.1.0",
"@prisma/client": "^6.3.1", "@prisma/client": "^6.19.3",
"@solana/addresses": "2.0.0", "@solana/addresses": "6.9.0",
"axios": "^1.7.4", "axios": "^1.16.1",
"consul": "^2.0.1", "cors": "^2.8.6",
"cors": "^2.8.5", "dotenv": "^17.4.2",
"dotenv": "^16.4.5", "ethers": "^6.16.0",
"ethers": "^6.13.4", "express": "^5.2.1",
"express": "^4.21.1", "express-rate-limit": "^8.5.2",
"express-rate-limit": "^8.3.1", "express-validator": "^7.3.2",
"express-validator": "^7.2.0",
"fireblocks-sdk": "^5.39.0", "fireblocks-sdk": "^5.39.0",
"helmet": "^8.0.0", "helmet": "^8.2.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.3",
"module-alias": "^2.2.3", "module-alias": "^2.3.4",
"morgan": "^1.10.0", "morgan": "^1.10.1",
"multicoin-address-validator": "^0.5.22", "multicoin-address-validator": "^0.5.26",
"nats": "^2.29.3", "prisma": "^6.19.3",
"prisma": "^6.3.1", "uuid": "^14.0.0",
"uuid": "^9.0.0",
"web3": "^4.16.0", "web3": "^4.16.0",
"winston": "^3.17.0" "winston": "^3.19.0"
}, },
"pnpm": { "pnpm": {
"overrides": { "overrides": {
+38
View File
@@ -118,10 +118,48 @@ model Swap {
quotes Quote? quotes Quote?
deposit_actions DepositAction[] deposit_actions DepositAction[]
transactions Transaction[] transactions Transaction[]
cosigner_steps CosignerStep[]
@@index([id]) @@index([id])
} }
/// Per-swap cosigner record — layered Utila / Fireblocks cosign on top
/// of the native MPC threshold network (issue #386). One row per
/// intent listed in the original POST /api/swaps cosigners[] array.
/// Lifecycle:
/// pending — created at swap-create time alongside the swap row
/// approved — external custodian signed; `signature` populated
/// rejected — external denial; `reason` carries subStatus / detail
/// failed — transport / config error or terminal Fireblocks FAILED;
/// also surfaced as fail_reason on the parent Swap
///
/// Public IDs only (`public_id` = org_id for utila, api_key for
/// fireblocks). NEVER stores secret material — secrets stay in KMS,
/// keyed off `public_id` at dispatch time.
model CosignerStep {
id Int @id @default(autoincrement())
created_date DateTime @default(now())
updated_date DateTime @updatedAt
swap_id String
swap Swap @relation(fields: [swap_id], references: [id])
// Discriminator + public identifiers — match the wire shape exactly.
kind String // "utila" | "fireblocks"
public_id String // org_id (utila) or api_key (fireblocks)
api_host String?
vault_id String? // utila vault_id OR fireblocks vault_account_id
// Step state.
status String @default("pending") // "pending" | "approved" | "rejected" | "failed"
signature String?
reason String?
external_id String? // utila tx name OR fireblocks tx id
@@index([swap_id])
@@index([status])
}
model Transaction { model Transaction {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
type String? @default("transfer") type String? @default("transfer")
@@ -0,0 +1,134 @@
// Lux IAM token-minting client.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { IAMTokenError, IAMClient } from "../iam"
const ISSUER = "https://iam.lux.network"
const TOKEN_URL = `${ISSUER}/oauth/token`
const cfg = {
issuer: ISSUER,
clientId: "lux-bridge",
clientSecret: "test-secret",
}
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"))
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
function jsonResponse(body: object, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
})
}
describe("IAMClient.mint", () => {
it("posts client_credentials form to /oauth/token and returns access_token", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({ access_token: "tok-1", expires_in: 3600 }),
)
const c = new IAMClient(cfg)
const token = await c.mint("kms")
expect(token).toBe("tok-1")
const [url, init] = fetchSpy.mock.calls[0]!
expect(url).toBe(TOKEN_URL)
expect((init as RequestInit).method).toBe("POST")
expect((init as RequestInit).headers).toMatchObject({
"Content-Type": "application/x-www-form-urlencoded",
})
const body = (init as RequestInit).body as string
const params = new URLSearchParams(body)
expect(params.get("grant_type")).toBe("client_credentials")
expect(params.get("client_id")).toBe("lux-bridge")
expect(params.get("client_secret")).toBe("test-secret")
expect(params.get("audience")).toBe("kms")
})
it("caches the token until close to expiry, refreshes after", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(jsonResponse({ access_token: "tok-1", expires_in: 120 }))
const c = new IAMClient(cfg)
expect(await c.mint("a")).toBe("tok-1")
expect(await c.mint("a")).toBe("tok-1") // cache hit
expect(fetchSpy).toHaveBeenCalledTimes(1)
// Advance past the cached TTL (expires_in=120s, earlyRefresh=60s → cache TTL = 60s).
vi.setSystemTime(new Date("2026-01-01T00:01:30Z"))
fetchSpy.mockResolvedValueOnce(
jsonResponse({ access_token: "tok-2", expires_in: 120 }),
)
expect(await c.mint("a")).toBe("tok-2")
expect(fetchSpy).toHaveBeenCalledTimes(2)
})
it("caches per-audience independently", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "for-kms", expires_in: 3600 }),
)
.mockResolvedValueOnce(
jsonResponse({ access_token: "for-mpc", expires_in: 3600 }),
)
const c = new IAMClient(cfg)
expect(await c.mint("kms")).toBe("for-kms")
expect(await c.mint("mpc")).toBe("for-mpc")
expect(await c.mint("kms")).toBe("for-kms") // cached
expect(fetchSpy).toHaveBeenCalledTimes(2)
})
it("invalidate(audience) forces a refetch", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok-1", expires_in: 3600 }),
)
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok-2", expires_in: 3600 }),
)
const c = new IAMClient(cfg)
expect(await c.mint("a")).toBe("tok-1")
c.invalidate("a")
expect(await c.mint("a")).toBe("tok-2")
expect(fetchSpy).toHaveBeenCalledTimes(2)
})
it("throws IAMTokenError on non-2xx", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response("invalid_client", { status: 401 }),
)
await expect(new IAMClient(cfg).mint("a")).rejects.toMatchObject({
name: "IAMTokenError",
httpStatus: 401,
})
})
it("throws IAMTokenError when access_token missing", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({ expires_in: 3600 }),
)
await expect(new IAMClient(cfg).mint("a")).rejects.toThrow(
/missing access_token/,
)
})
it("rejects construction without required fields", () => {
expect(() => new IAMClient({ ...cfg, clientId: "" })).toThrow(/clientId/)
expect(() => new IAMClient({ ...cfg, clientSecret: "" })).toThrow(
/clientSecret/,
)
expect(() => new IAMClient({ ...cfg, issuer: "" })).toThrow(/issuer/)
})
})
@@ -0,0 +1,128 @@
// Lux KMS HTTP client tests.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { KMSError, KMSSecretClient } from "../kms"
import { IAMClient } from "../iam"
const ISSUER = "https://iam.lux.network"
const KMS_URL = "https://kms.lux.network"
function jsonResponse(body: object, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
})
}
function textResponse(body: string, status = 200): Response {
return new Response(body, {
status,
headers: { "Content-Type": "text/plain" },
})
}
beforeEach(() => {})
afterEach(() => {
vi.restoreAllMocks()
})
function newClient() {
const iam = new IAMClient({
issuer: ISSUER,
clientId: "lux-bridge",
clientSecret: "test-secret",
})
return new KMSSecretClient({ url: KMS_URL, org: "lux", iam })
}
describe("KMSSecretClient.getSecret", () => {
it("GETs /v1/kms/orgs/{org}/secrets/{path} with Bearer token", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
// 1st call: IAM mint
.mockResolvedValueOnce(
jsonResponse({ access_token: "iam-tok", expires_in: 3600 }),
)
// 2nd call: KMS get (text body)
.mockResolvedValueOnce(textResponse("-----BEGIN PRIVATE KEY-----\nABC\n-----END"))
const c = newClient()
const secret = await c.getSecret("bridge/cosigners/utila/org-a/sa_pem")
expect(secret).toBe("-----BEGIN PRIVATE KEY-----\nABC\n-----END")
expect(fetchSpy).toHaveBeenCalledTimes(2)
const [url, init] = fetchSpy.mock.calls[1]!
expect(url).toBe(
`${KMS_URL}/v1/kms/orgs/lux/secrets/bridge/cosigners/utila/org-a/sa_pem`,
)
expect((init as RequestInit).headers).toMatchObject({
Authorization: "Bearer iam-tok",
})
})
it("unwraps JSON envelope { value: ... } when Content-Type is JSON", async () => {
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(jsonResponse({ value: "secret-bytes" }))
const c = newClient()
expect(await c.getSecret("p/q")).toBe("secret-bytes")
})
it("unwraps { secret: ... } variant too", async () => {
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(jsonResponse({ secret: "alt-value" }))
const c = newClient()
expect(await c.getSecret("p")).toBe("alt-value")
})
it("throws KMSError (404) when secret missing", async () => {
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(new Response("not found", { status: 404 }))
const c = newClient()
await expect(c.getSecret("nope")).rejects.toMatchObject({
name: "KMSError",
httpStatus: 404,
})
})
it("throws KMSError (401) and invalidates IAM cache", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(new Response("unauthorized", { status: 401 }))
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok-2", expires_in: 3600 }),
)
.mockResolvedValueOnce(textResponse("recovered"))
const c = newClient()
await expect(c.getSecret("p")).rejects.toMatchObject({
name: "KMSError",
httpStatus: 401,
})
// Next call refetches the token (because invalidate fired).
expect(await c.getSecret("p")).toBe("recovered")
expect(fetchSpy).toHaveBeenCalledTimes(4)
})
it("throws KMSError for other non-2xx", async () => {
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(new Response("server error", { status: 503 }))
const c = newClient()
await expect(c.getSecret("p")).rejects.toMatchObject({
name: "KMSError",
httpStatus: 503,
})
})
})
@@ -0,0 +1,237 @@
// Lux MPC daemon (mpcd) HTTP client tests.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { MPCClient, MPCError } from "../mpc"
import { IAMClient } from "../iam"
const ISSUER = "https://iam.lux.network"
const MPC_URL = "https://mpc.lux.network"
function jsonResponse(body: object, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
})
}
function newClient(timeoutMs?: number) {
const iam = new IAMClient({
issuer: ISSUER,
clientId: "lux-bridge",
clientSecret: "test-secret",
})
return new MPCClient({ url: MPC_URL, iam, ...(timeoutMs ? { timeoutMs } : {}) })
}
beforeEach(() => {})
afterEach(() => {
vi.restoreAllMocks()
})
describe("MPCClient.keygen", () => {
it("POSTs /v1/vaults/{vaultID}/wallets with key_type + protocol", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(
jsonResponse({
id: "wallet-1",
walletId: "wallet-1",
vaultId: "v",
name: "bridge-key",
public_key: "04abc...",
address: "0xabc",
status: "ready",
}),
)
const c = newClient()
const result = await c.keygen({
vaultId: "v",
name: "bridge-key",
keyType: "secp256k1",
protocol: "cggmp21",
})
expect(result).toMatchObject({
id: "wallet-1",
walletId: "wallet-1",
vaultId: "v",
keyType: "secp256k1",
protocol: "cggmp21",
publicKey: "04abc...",
address: "0xabc",
})
const [url, init] = fetchSpy.mock.calls[1]!
expect(url).toBe(`${MPC_URL}/v1/vaults/v/wallets`)
const body = JSON.parse((init as RequestInit).body as string)
expect(body).toMatchObject({
name: "bridge-key",
key_type: "secp256k1",
protocol: "cggmp21",
})
expect((init as RequestInit).headers).toMatchObject({
Authorization: "Bearer tok",
})
})
})
describe("MPCClient.sign", () => {
it("POSTs /v1/transactions with type=sign and base64 payload", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(
jsonResponse({
signature: "deadbeef",
r: "feed",
s: "face",
v: 27,
}),
)
const c = newClient()
const msg = new Uint8Array([0xde, 0xad, 0xbe, 0xef])
const result = await c.sign({
walletId: "wallet-1",
keyType: "secp256k1",
message: msg,
})
expect(result).toEqual({ signature: "deadbeef", r: "feed", s: "face", v: 27 })
const body = JSON.parse(
(fetchSpy.mock.calls[1]?.[1] as RequestInit).body as string,
)
expect(body).toMatchObject({
type: "sign",
wallet_id: "wallet-1",
key_type: "secp256k1",
payload: Buffer.from(msg).toString("base64"),
})
})
it("throws MPCError when response missing signature field", async () => {
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(jsonResponse({}))
const c = newClient()
await expect(
c.sign({
walletId: "w",
keyType: "secp256k1",
message: new Uint8Array([1]),
}),
).rejects.toThrow(/missing signature/)
})
it("invalidates IAM token on 401 (recovers on next call)", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok-1", expires_in: 3600 }),
)
.mockResolvedValueOnce(new Response("expired", { status: 401 }))
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok-2", expires_in: 3600 }),
)
.mockResolvedValueOnce(jsonResponse({ signature: "ok" }))
const c = newClient()
await expect(
c.sign({
walletId: "w",
keyType: "secp256k1",
message: new Uint8Array([1]),
}),
).rejects.toMatchObject({ httpStatus: 401 })
const ok = await c.sign({
walletId: "w",
keyType: "secp256k1",
message: new Uint8Array([1]),
})
expect(ok.signature).toBe("ok")
expect(fetchSpy).toHaveBeenCalledTimes(4)
})
it("aborts on timeout and throws MPCError", async () => {
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockImplementationOnce(
(_url, init) =>
new Promise((_resolve, reject) => {
;(init as { signal?: AbortSignal }).signal?.addEventListener(
"abort",
() =>
reject(Object.assign(new Error("aborted"), { name: "AbortError" })),
)
}),
)
const c = newClient(50) // 50ms timeout
await expect(
c.sign({
walletId: "w",
keyType: "secp256k1",
message: new Uint8Array([1]),
}),
).rejects.toMatchObject({ name: "MPCError" })
}, 1000)
})
describe("MPCClient.status", () => {
it("returns { online, total, protocols } parsed from /v1/status", async () => {
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(
jsonResponse({
online: 3,
total: 3,
protocols: ["cggmp21", "frost", "bls"],
threshold: 2,
}),
)
const c = newClient()
const s = await c.status()
expect(s.online).toBe(3)
expect(s.total).toBe(3)
expect(s.protocols).toContain("cggmp21")
expect(s.threshold).toBe(2)
})
})
describe("MPCClient construction", () => {
it("rejects construction without required fields", () => {
expect(() =>
new MPCClient({ url: "", iam: {} as never }),
).toThrow(/url/)
const iamOk = new IAMClient({
issuer: ISSUER,
clientId: "id",
clientSecret: "s",
})
expect(() =>
new MPCClient({ url: MPC_URL, iam: undefined as unknown as IAMClient }),
).toThrow(/iam/)
expect(new MPCClient({ url: MPC_URL, iam: iamOk })).toBeInstanceOf(
MPCClient,
)
})
})
// Defensive — MPCError export shape
describe("MPCError", () => {
it("carries httpStatus and endpoint", () => {
const e = new MPCError("boom", 500, "/v1/transactions")
expect(e.name).toBe("MPCError")
expect(e.httpStatus).toBe(500)
expect(e.endpoint).toBe("/v1/transactions")
})
})
+114
View File
@@ -0,0 +1,114 @@
// IAM OAuth2 client_credentials minter — brand-neutral.
//
// Mirrors the canonical Go client (github.com/luxfi/kms/pkg/iamclient)
// in TypeScript: short-lived bearer tokens scoped to an audience,
// cached per-audience with early refresh. The bridge backend mints:
// - audience=kms for the KMSClient (secrets)
// - audience=mpc for the MPCClient (threshold signer)
//
// The IAM service itself is jurisdiction-neutral. Tenants configure
// the issuer via BRIDGE_IAM_ISSUER at startup — could be
// `iam.lux.network`, `iam.zoo.network`, a self-hosted Casdoor, or any
// OIDC issuer that speaks client_credentials.
// Node 18+ has fetch globally. Look up at call time (not module load)
// so vi.spyOn(globalThis, 'fetch') in tests intercepts cleanly.
const _fetch = (...args: Parameters<typeof globalThis.fetch>) =>
globalThis.fetch(...args)
export interface IAMConfig {
/** OIDC issuer URL, e.g. `https://iam.lux.network` or any provider. */
issuer: string
/** OAuth2 client_id provisioned for the bridge service. */
clientId: string
/** OAuth2 client_secret. Resolved at startup from KMS or env (NEVER hardcoded). */
clientSecret: string
/** Override the default `/oauth/token` path. */
tokenPath?: string
/** Refresh this much before expiry (seconds). Default 60. */
earlyRefreshSec?: number
}
interface CachedToken {
token: string
/** Epoch ms when this token expires (with earlyRefresh applied). */
expiresAt: number
}
export class IAMTokenError extends Error {
constructor(
message: string,
public readonly httpStatus?: number,
public readonly body?: string,
) {
super(message)
this.name = "IAMTokenError"
}
}
export class IAMClient {
private readonly cache = new Map<string, CachedToken>()
private readonly tokenUrl: string
private readonly earlyRefreshMs: number
constructor(private readonly cfg: IAMConfig) {
if (!cfg.issuer) throw new Error("iam: issuer required")
if (!cfg.clientId) throw new Error("iam: clientId required")
if (!cfg.clientSecret) throw new Error("iam: clientSecret required")
const path = cfg.tokenPath ?? "/oauth/token"
this.tokenUrl = cfg.issuer.replace(/\/$/, "") + path
this.earlyRefreshMs = (cfg.earlyRefreshSec ?? 60) * 1000
}
/** Mint or reuse a cached bearer token for the given audience. */
async mint(audience: string): Promise<string> {
const cached = this.cache.get(audience)
const now = Date.now()
if (cached && cached.expiresAt > now) return cached.token
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: this.cfg.clientId,
client_secret: this.cfg.clientSecret,
audience,
})
let resp: Response
try {
resp = await _fetch(this.tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
})
} catch (err) {
throw new IAMTokenError(
`iam: token POST to ${this.tokenUrl} failed: ${err instanceof Error ? err.message : String(err)}`,
)
}
if (!resp.ok) {
const text = await resp.text().catch(() => "")
throw new IAMTokenError(
`iam: token endpoint returned ${resp.status}`,
resp.status,
text.slice(0, 500),
)
}
const json = (await resp.json()) as {
access_token?: string
expires_in?: number
}
if (!json.access_token) {
throw new IAMTokenError("iam: response missing access_token")
}
const ttlMs = (json.expires_in ?? 3600) * 1000
this.cache.set(audience, {
token: json.access_token,
expiresAt: now + ttlMs - this.earlyRefreshMs,
})
return json.access_token
}
/** Drop a cached token. The next mint() refetches. */
invalidate(audience: string): void {
this.cache.delete(audience)
}
}
+126
View File
@@ -0,0 +1,126 @@
// Lux KMS HTTP client — native bridge backend secret round-trip.
//
// Replaces every env-var fallback in cosigners.fetchCosignerSecret and
// every plaintext credential read in `domain/utila.ts`. Mirrors the
// shape of github.com/luxfi/kms/pkg/* Go clients but speaks the same
// HTTP surface so a TS bridge backend and Go services interoperate
// against one canonical KMS.
//
// HTTP surface (from ~/work/lux/kms/cmd/kms/main.go):
// POST /v1/kms/auth/login — exchange OIDC JWT for KMS session JWT
// GET /v1/kms/orgs/{org}/secrets/{path...} — fetch a secret
// POST /v1/kms/orgs/{org}/secrets — create a secret (admin)
// DELETE /v1/kms/orgs/{org}/secrets/{path...} — delete a secret (admin)
//
// Auth: Bearer JWT with `aud` claim matching the bridge's tenant id
// (e.g. `liquidity-bd`, `lux-bridge`). Token is minted by Lux IAM via
// `IAMClient.mint('kms')` and cached for its TTL.
//
// All secrets returned are STRINGS (PEM, JSON, bytes — caller decides
// how to parse). KMS itself stores them as opaque bytes; the path
// namespacing reflects the consumer (e.g. `bridge/cosigners/utila/{org}/sa_pem`).
import { IAMClient } from "./iam"
// Call-time lookup so vi.spyOn(globalThis, 'fetch') intercepts in tests.
const _fetch = (...args: Parameters<typeof globalThis.fetch>) =>
globalThis.fetch(...args)
export interface KMSConfig {
/** Lux KMS endpoint, e.g. `https://kms.lux.network`. */
url: string
/** Org slug whose secret namespace we're reading from. */
org: string
/** IAM token minter (shared across KMS / MPC clients). */
iam: IAMClient
/** Audience the IAM client mints for. Default `kms`. */
audience?: string
}
export class KMSError extends Error {
constructor(
message: string,
public readonly httpStatus?: number,
public readonly path?: string,
) {
super(message)
this.name = "KMSError"
}
}
export class KMSSecretClient {
private readonly baseUrl: string
private readonly audience: string
constructor(private readonly cfg: KMSConfig) {
if (!cfg.url) throw new Error("kms: url required")
if (!cfg.org) throw new Error("kms: org required")
if (!cfg.iam) throw new Error("kms: iam client required")
this.baseUrl = cfg.url.replace(/\/$/, "")
this.audience = cfg.audience ?? "kms"
}
/**
* Read a secret at the given path under the configured org. Throws
* KMSError on transport / auth / 4xx / 5xx; returns the raw string
* body on 200.
*/
async getSecret(path: string): Promise<string> {
const cleanPath = path.replace(/^\/+/, "")
const url = `${this.baseUrl}/v1/kms/orgs/${encodeURIComponent(this.cfg.org)}/secrets/${cleanPath}`
const token = await this.cfg.iam.mint(this.audience)
let resp: Response
try {
resp = await _fetch(url, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
})
} catch (err) {
throw new KMSError(
`kms: GET ${url} failed: ${err instanceof Error ? err.message : String(err)}`,
undefined,
cleanPath,
)
}
if (resp.status === 401) {
// Invalidate the cached token so the next call refetches.
this.cfg.iam.invalidate(this.audience)
throw new KMSError(
"kms: 401 unauthorized — IAM token may have expired or aud mismatch",
401,
cleanPath,
)
}
if (resp.status === 404) {
throw new KMSError(
`kms: secret not found at ${cleanPath}`,
404,
cleanPath,
)
}
if (!resp.ok) {
const text = await resp.text().catch(() => "")
throw new KMSError(
`kms: GET returned ${resp.status}: ${text.slice(0, 300)}`,
resp.status,
cleanPath,
)
}
// KMS may return either a raw string body or a JSON envelope
// `{ "value": "..." }`. Inspect the Content-Type to pick.
const ct = resp.headers.get("content-type") ?? ""
if (ct.includes("application/json")) {
const json = (await resp.json()) as { value?: string; secret?: string }
const value = json.value ?? json.secret
if (typeof value !== "string") {
throw new KMSError(
`kms: JSON response missing 'value' / 'secret' field for ${cleanPath}`,
200,
cleanPath,
)
}
return value
}
return await resp.text()
}
}
+305
View File
@@ -0,0 +1,305 @@
// Lux MPC daemon (mpcd) HTTP client — native m-chain threshold signer.
//
// Mirrors github.com/luxfi/kms/pkg/mpc/client.go in TypeScript. This is
// THE canonical entry point into Lux m-chain (public MPC, threshold-t
// signing cluster) for the bridge backend. No per-bridge MPC code lives
// in the bridge; everything threshold-related routes through mpcd.
//
// HTTP surface (from ~/work/lux/mpc/cmd/mpcd/main.go +
// ~/work/lux/kms/pkg/mpc/client.go):
// POST /v1/vaults/{vaultID}/wallets — distributed keygen (DKG); returns wallet+pubkey
// POST /v1/transactions — threshold sign a payload
// POST /v1/wallets/{id}/reshare — change threshold / participant set
// GET /v1/wallets/{id} — wallet metadata
// GET /v1/status — cluster status (online peers, share counts)
//
// Auth: Bearer JWT with `aud=mpc`. The bridge mints via the same
// IAMClient used by KMSSecretClient. Sign calls are RATE LIMITED at the
// daemon and have a per-call default timeout of 120s (matches mpcd's
// threshold-completion ceiling).
//
// Protocol selection (CGGMP21 / FROST / BLS / SR25519 / Pulsar /
// Corona / Magnetar / Doerner) is per-wallet at keygen time. Sign calls
// don't re-specify — the wallet record holds the protocol identifier.
import { IAMClient } from "./iam"
// Call-time lookup so vi.spyOn(globalThis, 'fetch') intercepts in tests.
const _fetch = (...args: Parameters<typeof globalThis.fetch>) =>
globalThis.fetch(...args)
export type MpcProtocol =
| "cggmp21"
| "frost"
| "bls"
| "sr25519"
| "doerner"
| "pulsar"
| "corona"
| "magnetar"
export type MpcKeyType =
| "secp256k1"
| "ed25519"
| "bls"
| "sr25519"
export interface MPCConfig {
/** mpcd base URL, e.g. `https://mpc.lux.network`. */
url: string
iam: IAMClient
/** Audience for IAM token. Default `mpc`. */
audience?: string
/** Per-call timeout (ms). Default 120000 (matches mpcd's ceiling). */
timeoutMs?: number
}
export interface KeygenRequest {
vaultId: string
name: string
keyType: MpcKeyType
protocol: MpcProtocol
}
export interface KeygenResult {
id: string
walletId: string
vaultId: string
name?: string
keyType: MpcKeyType
protocol: MpcProtocol
publicKey?: string
address?: string
status?: string
}
export interface SignRequest {
walletId: string
keyType: MpcKeyType
/** Bytes to sign — caller decides hashing convention. */
message: Uint8Array
}
export interface SignResult {
/** Raw signature bytes (provider-specific encoding) hex-encoded. */
signature: string
/** Optional split r/s for ECDSA. */
r?: string
s?: string
v?: number
}
export interface ClusterStatus {
online: number
total: number
protocols: MpcProtocol[]
threshold?: number
}
/** Wallet metadata returned by GET /v1/wallets/{id}. */
export interface WalletInfo {
id: string
walletId: string
vaultId?: string
keyType?: MpcKeyType
protocol?: MpcProtocol
/** Per-chain-family derived addresses. */
ethAddress?: string
btcAddress?: string
solAddress?: string
/** Per-curve public keys. */
ecdsaPubKey?: string
eddsaPubKey?: string
status?: string
}
export class MPCError extends Error {
constructor(
message: string,
public readonly httpStatus?: number,
public readonly endpoint?: string,
) {
super(message)
this.name = "MPCError"
}
}
export class MPCClient {
private readonly baseUrl: string
private readonly audience: string
private readonly timeoutMs: number
constructor(private readonly cfg: MPCConfig) {
if (!cfg.url) throw new Error("mpc: url required")
if (!cfg.iam) throw new Error("mpc: iam client required")
this.baseUrl = cfg.url.replace(/\/$/, "")
this.audience = cfg.audience ?? "mpc"
this.timeoutMs = cfg.timeoutMs ?? 120_000
}
async keygen(req: KeygenRequest): Promise<KeygenResult> {
const url = `${this.baseUrl}/v1/vaults/${encodeURIComponent(req.vaultId)}/wallets`
const body = JSON.stringify({
name: req.name,
key_type: req.keyType,
protocol: req.protocol,
})
const json = await this.do<Record<string, unknown>>("POST", url, body)
return {
id: String(json.id ?? json.wallet_id ?? ""),
walletId: String(json.walletId ?? json.wallet_id ?? json.id ?? ""),
vaultId: String(json.vaultId ?? json.vault_id ?? req.vaultId),
name: typeof json.name === "string" ? json.name : undefined,
keyType: req.keyType,
protocol: req.protocol,
publicKey:
typeof json.public_key === "string"
? json.public_key
: typeof json.publicKey === "string"
? json.publicKey
: undefined,
address:
typeof json.address === "string" ? json.address : undefined,
status: typeof json.status === "string" ? json.status : undefined,
}
}
async sign(req: SignRequest): Promise<SignResult> {
const url = `${this.baseUrl}/v1/transactions`
const body = JSON.stringify({
type: "sign",
wallet_id: req.walletId,
key_type: req.keyType,
// mpcd accepts base64 for byte payloads in transactions.
payload: Buffer.from(req.message).toString("base64"),
})
const json = await this.do<Record<string, unknown>>("POST", url, body)
const sig =
typeof json.signature === "string"
? json.signature
: typeof json.sig === "string"
? json.sig
: ""
if (!sig) {
throw new MPCError(
"mpc: sign response missing signature field",
200,
"/v1/transactions",
)
}
const result: SignResult = { signature: sig }
if (typeof json.r === "string") result.r = json.r
if (typeof json.s === "string") result.s = json.s
if (typeof json.v === "number") result.v = json.v
return result
}
async status(): Promise<ClusterStatus> {
const json = await this.do<Record<string, unknown>>(
"GET",
`${this.baseUrl}/v1/status`,
)
return {
online: Number(json.online ?? 0),
total: Number(json.total ?? 0),
protocols: Array.isArray(json.protocols)
? (json.protocols as MpcProtocol[])
: [],
threshold:
typeof json.threshold === "number" ? json.threshold : undefined,
}
}
/**
* Look up wallet metadata — derived addresses (per chain family),
* public keys, protocol, status. Bridge uses this to resolve the
* settlement wallet's EVM address before building an unsigned tx.
*/
async getWallet(walletId: string): Promise<WalletInfo> {
const url = `${this.baseUrl}/v1/wallets/${encodeURIComponent(walletId)}`
const json = await this.do<Record<string, unknown>>("GET", url)
const result: WalletInfo = {
id: String(json.id ?? json.wallet_id ?? walletId),
walletId: String(
json.walletId ?? json.wallet_id ?? json.id ?? walletId,
),
}
if (typeof json.vaultId === "string") result.vaultId = json.vaultId
else if (typeof json.vault_id === "string") result.vaultId = json.vault_id
if (typeof json.keyType === "string")
result.keyType = json.keyType as MpcKeyType
else if (typeof json.key_type === "string")
result.keyType = json.key_type as MpcKeyType
if (typeof json.protocol === "string")
result.protocol = json.protocol as MpcProtocol
if (typeof json.ethAddress === "string") result.ethAddress = json.ethAddress
else if (typeof json.eth_address === "string")
result.ethAddress = json.eth_address
if (typeof json.btcAddress === "string") result.btcAddress = json.btcAddress
else if (typeof json.btc_address === "string")
result.btcAddress = json.btc_address
if (typeof json.solAddress === "string") result.solAddress = json.solAddress
else if (typeof json.sol_address === "string")
result.solAddress = json.sol_address
if (typeof json.ecdsaPubKey === "string")
result.ecdsaPubKey = json.ecdsaPubKey
else if (typeof json.ecdsa_pub_key === "string")
result.ecdsaPubKey = json.ecdsa_pub_key
if (typeof json.eddsaPubKey === "string")
result.eddsaPubKey = json.eddsaPubKey
else if (typeof json.eddsa_pub_key === "string")
result.eddsaPubKey = json.eddsa_pub_key
if (typeof json.status === "string") result.status = json.status
return result
}
private async do<T>(
method: string,
url: string,
body?: string,
): Promise<T> {
const token = await this.cfg.iam.mint(this.audience)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), this.timeoutMs)
let resp: Response
try {
resp = await _fetch(url, {
method,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
...(body !== undefined ? { body } : {}),
signal: controller.signal,
})
} catch (err) {
const isAbort = err instanceof Error && err.name === "AbortError"
throw new MPCError(
isAbort
? `mpc: ${method} ${url} aborted after ${this.timeoutMs}ms`
: `mpc: ${method} ${url} failed: ${err instanceof Error ? err.message : String(err)}`,
undefined,
url,
)
} finally {
clearTimeout(timer)
}
if (resp.status === 401) {
this.cfg.iam.invalidate(this.audience)
throw new MPCError(
"mpc: 401 unauthorized — IAM token expired or aud mismatch",
401,
url,
)
}
if (!resp.ok) {
const text = await resp.text().catch(() => "")
throw new MPCError(
`mpc: ${method} returned ${resp.status}: ${text.slice(0, 300)}`,
resp.status,
url,
)
}
return (await resp.json()) as T
}
}
@@ -0,0 +1,605 @@
// Real-adapter tests for AWS KMS, Azure Key Vault, Vault Transit, f-chain.
//
// All four adapters mocked at the module level so unit tests never touch
// real cloud APIs. The contracts being tested:
// 1. Each adapter calls its provider with the right shape (key + digest
// + algorithm normalization).
// 2. Provider-native errors map cleanly to CloudSignerErrorCode → stable
// CosignResult.status semantics.
// 3. Algorithm allow-list is enforced before any network call.
// 4. Vault Transit auth requires both VAULT_ADDR + VAULT_TOKEN.
// 5. f-chain uses fetch against /v1/fhe/encrypt and treats the
// ciphertext as the signature blob.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
// vi.mock factories are hoisted; pull mock fns from vi.hoisted.
const mocks = vi.hoisted(() => ({
// AWS
awsSend: vi.fn(),
KMSClient: vi.fn(),
SignCommand: vi.fn(function (args: unknown) { return { __cmd: "Sign", args } }),
// Azure
azureSign: vi.fn(),
CryptographyClient: vi.fn(),
DefaultAzureCredential: vi.fn(),
// unused by these tests but cosigners.ts imports them at module level
initiateTransaction: vi.fn(),
getTransaction: vi.fn(),
createGrpcClient: vi.fn(),
serviceAccountAuthStrategy: vi.fn().mockReturnValue("interceptor"),
asymmetricSign: vi.fn(),
KeyManagementServiceClient: vi.fn(),
}))
vi.mock("@aws-sdk/client-kms", () => ({
KMSClient: mocks.KMSClient,
SignCommand: mocks.SignCommand,
}))
vi.mock("@azure/keyvault-keys", () => ({
CryptographyClient: mocks.CryptographyClient,
}))
vi.mock("@azure/identity", () => ({
DefaultAzureCredential: mocks.DefaultAzureCredential,
}))
vi.mock("@google-cloud/kms", () => ({
KeyManagementServiceClient: mocks.KeyManagementServiceClient,
}))
vi.mock("@luxfi/utila", () => ({
createGrpcClient: mocks.createGrpcClient,
serviceAccountAuthStrategy: mocks.serviceAccountAuthStrategy,
}))
vi.mock("fireblocks-sdk", () => ({
FireblocksSDK: vi.fn(),
PeerType: { VAULT_ACCOUNT: "VAULT_ACCOUNT" },
TransactionOperation: { RAW: "RAW" },
}))
import { dispatchCosigners } from "../cosigners"
const txHash =
"0xabcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
const baseOpts = {
swapId: "swap_test",
txHash,
nativeSignature: "0xnative",
}
const stubSig = new Uint8Array(64).fill(0xab)
const stubSigHex = "0x" + Buffer.from(stubSig).toString("hex")
beforeEach(() => {
mocks.awsSend.mockReset()
mocks.KMSClient.mockReset()
mocks.KMSClient.mockImplementation(function () { return { send: mocks.awsSend } })
mocks.azureSign.mockReset()
mocks.CryptographyClient.mockReset()
mocks.CryptographyClient.mockImplementation(function () { return { sign: mocks.azureSign } })
mocks.DefaultAzureCredential.mockReset()
mocks.DefaultAzureCredential.mockImplementation(function () { return {} })
})
afterEach(() => {
delete process.env.VAULT_ADDR
delete process.env.VAULT_TOKEN
delete process.env.FCHAIN_COSIGNER_TIMEOUT_MS
})
// ────────────────────────────────────────────────────────────────────────
// AWS KMS
// ────────────────────────────────────────────────────────────────────────
describe("runCloudHsm — aws_kms happy path", () => {
it("signs via SignCommand with ECDSA_SHA_256 + region from ARN", async () => {
mocks.awsSend.mockResolvedValueOnce({
Signature: stubSig,
KeyId:
"arn:aws:kms:us-east-1:111:key/00000000-0000-0000-0000-000000000000",
})
const [result] = await dispatchCosigners({
...baseOpts,
cosigners: [
{
kind: "cloud_hsm",
provider: "aws_kms",
key_ref:
"arn:aws:kms:us-east-1:111:key/00000000-0000-0000-0000-000000000000",
algorithm: "secp256k1_ecdsa_sha256",
},
],
})
expect(result?.status).toBe("approved")
expect(result?.signature).toBe(stubSigHex)
expect(result?.externalId).toBe(
"arn:aws:kms:us-east-1:111:key/00000000-0000-0000-0000-000000000000",
)
expect(mocks.KMSClient).toHaveBeenCalledWith({ region: "us-east-1" })
const cmd = mocks.awsSend.mock.calls[0]?.[0] as {
args: { KeyId: string; MessageType: string; SigningAlgorithm: string }
}
expect(cmd.args.MessageType).toBe("DIGEST")
expect(cmd.args.SigningAlgorithm).toBe("ECDSA_SHA_256")
})
it("rsa_pss_sha256 → RSASSA_PSS_SHA_256", async () => {
mocks.awsSend.mockResolvedValueOnce({ Signature: stubSig })
await dispatchCosigners({
...baseOpts,
cosigners: [
{
kind: "cloud_hsm",
provider: "aws_kms",
key_ref: "arn:aws:kms:eu-west-1:111:key/abc",
algorithm: "rsa_pss_sha256",
},
],
})
const cmd = mocks.awsSend.mock.calls[0]?.[0] as { args: { SigningAlgorithm: string } }
expect(cmd.args.SigningAlgorithm).toBe("RSASSA_PSS_SHA_256")
})
it("identityHint overrides region from ARN", async () => {
mocks.awsSend.mockResolvedValueOnce({ Signature: stubSig })
await dispatchCosigners({
...baseOpts,
cosigners: [
{
kind: "cloud_hsm",
provider: "aws_kms",
key_ref: "arn:aws:kms:us-east-1:111:key/abc",
algorithm: "secp256k1_ecdsa_sha256",
identity_hint: "us-west-2",
},
],
})
expect(mocks.KMSClient).toHaveBeenCalledWith({ region: "us-west-2" })
})
})
describe("runCloudHsm — aws_kms error mapping", () => {
it("AccessDeniedException → rejected/permission_denied", async () => {
mocks.awsSend.mockRejectedValueOnce(
Object.assign(new Error("User is not authorized"), {
name: "AccessDeniedException",
$metadata: { httpStatusCode: 403 },
}),
)
const [result] = await dispatchCosigners({
...baseOpts,
cosigners: [
{
kind: "cloud_hsm",
provider: "aws_kms",
key_ref: "arn:aws:kms:us-east-1:111:key/abc",
algorithm: "secp256k1_ecdsa_sha256",
},
],
})
expect(result?.status).toBe("rejected")
expect(result?.reason).toMatch(/permission_denied/)
})
it("NotFoundException → rejected/key_not_found", async () => {
mocks.awsSend.mockRejectedValueOnce(
Object.assign(new Error("Key does not exist"), {
name: "NotFoundException",
$metadata: { httpStatusCode: 404 },
}),
)
const [result] = await dispatchCosigners({
...baseOpts,
cosigners: [
{
kind: "cloud_hsm",
provider: "aws_kms",
key_ref: "arn:aws:kms:us-east-1:111:key/abc",
algorithm: "secp256k1_ecdsa_sha256",
},
],
})
expect(result?.status).toBe("rejected")
expect(result?.reason).toMatch(/key_not_found/)
})
it("KMSInvalidStateException → rejected/key_disabled", async () => {
mocks.awsSend.mockRejectedValueOnce(
Object.assign(new Error("Key is in disabled state"), {
name: "KMSInvalidStateException",
}),
)
const [result] = await dispatchCosigners({
...baseOpts,
cosigners: [
{
kind: "cloud_hsm",
provider: "aws_kms",
key_ref: "arn:aws:kms:us-east-1:111:key/abc",
algorithm: "secp256k1_ecdsa_sha256",
},
],
})
expect(result?.status).toBe("rejected")
expect(result?.reason).toMatch(/key_disabled/)
})
it("ThrottlingException → failed/rate_limited", async () => {
mocks.awsSend.mockRejectedValueOnce(
Object.assign(new Error("Rate exceeded"), {
name: "ThrottlingException",
$metadata: { httpStatusCode: 429 },
}),
)
const [result] = await dispatchCosigners({
...baseOpts,
cosigners: [
{
kind: "cloud_hsm",
provider: "aws_kms",
key_ref: "arn:aws:kms:us-east-1:111:key/abc",
algorithm: "secp256k1_ecdsa_sha256",
},
],
})
expect(result?.status).toBe("failed")
expect(result?.reason).toMatch(/rate_limited/)
})
it("rejects unsupported algorithm BEFORE any network call", async () => {
const [result] = await dispatchCosigners({
...baseOpts,
cosigners: [
{
kind: "cloud_hsm",
provider: "aws_kms",
key_ref: "arn:aws:kms:us-east-1:111:key/abc",
algorithm: "ed25519",
},
],
})
expect(result?.status).toBe("rejected")
expect(result?.reason).toMatch(/algorithm_mismatch/)
expect(mocks.awsSend).not.toHaveBeenCalled()
})
})
// ────────────────────────────────────────────────────────────────────────
// Azure Key Vault
// ────────────────────────────────────────────────────────────────────────
describe("runCloudHsm — azure_key_vault happy path", () => {
it("signs via CryptographyClient.sign('ES256K') + returns key version", async () => {
mocks.azureSign.mockResolvedValueOnce({
result: stubSig,
keyID: "https://v.vault.azure.net/keys/k/abcdef1234",
})
const [result] = await dispatchCosigners({
...baseOpts,
cosigners: [
{
kind: "cloud_hsm",
provider: "azure_key_vault",
key_ref: "https://v.vault.azure.net/keys/k",
algorithm: "secp256k1_ecdsa_sha256",
},
],
})
expect(result?.status).toBe("approved")
expect(result?.signature).toBe(stubSigHex)
expect(result?.externalId).toBe("https://v.vault.azure.net/keys/k/abcdef1234")
expect(mocks.CryptographyClient).toHaveBeenCalledWith(
"https://v.vault.azure.net/keys/k",
expect.anything(),
)
const sigCall = mocks.azureSign.mock.calls[0] as [string, Uint8Array]
expect(sigCall[0]).toBe("ES256K")
expect(sigCall[1]).toBeInstanceOf(Buffer)
})
it("rsa_pss_sha256 → 'PS256'", async () => {
mocks.azureSign.mockResolvedValueOnce({ result: stubSig })
await dispatchCosigners({
...baseOpts,
cosigners: [
{
kind: "cloud_hsm",
provider: "azure_key_vault",
key_ref: "https://v.vault.azure.net/keys/k",
algorithm: "rsa_pss_sha256",
},
],
})
expect(mocks.azureSign.mock.calls[0]?.[0]).toBe("PS256")
})
})
describe("runCloudHsm — azure_key_vault error mapping", () => {
it("Forbidden → rejected/permission_denied", async () => {
mocks.azureSign.mockRejectedValueOnce(
Object.assign(new Error("Caller is not authorized"), {
code: "Forbidden",
statusCode: 403,
}),
)
const [result] = await dispatchCosigners({
...baseOpts,
cosigners: [
{
kind: "cloud_hsm",
provider: "azure_key_vault",
key_ref: "https://v.vault.azure.net/keys/k",
algorithm: "secp256k1_ecdsa_sha256",
},
],
})
expect(result?.status).toBe("rejected")
expect(result?.reason).toMatch(/permission_denied/)
})
it("KeyNotFound → rejected/key_not_found", async () => {
mocks.azureSign.mockRejectedValueOnce(
Object.assign(new Error("Key not found"), {
code: "KeyNotFound",
statusCode: 404,
}),
)
const [result] = await dispatchCosigners({
...baseOpts,
cosigners: [
{
kind: "cloud_hsm",
provider: "azure_key_vault",
key_ref: "https://v.vault.azure.net/keys/k",
algorithm: "secp256k1_ecdsa_sha256",
},
],
})
expect(result?.status).toBe("rejected")
expect(result?.reason).toMatch(/key_not_found/)
})
it("ServiceUnavailable → failed/provider_unavailable", async () => {
mocks.azureSign.mockRejectedValueOnce(
Object.assign(new Error("Vault is unavailable"), {
code: "ServiceUnavailable",
statusCode: 503,
}),
)
const [result] = await dispatchCosigners({
...baseOpts,
cosigners: [
{
kind: "cloud_hsm",
provider: "azure_key_vault",
key_ref: "https://v.vault.azure.net/keys/k",
algorithm: "secp256k1_ecdsa_sha256",
},
],
})
expect(result?.status).toBe("failed")
expect(result?.reason).toMatch(/provider_unavailable/)
})
})
// ────────────────────────────────────────────────────────────────────────
// Vault Transit
// ────────────────────────────────────────────────────────────────────────
describe("runCloudHsm — vault_transit", () => {
it("requires VAULT_ADDR + VAULT_TOKEN", async () => {
// Neither env set.
const [result] = await dispatchCosigners({
...baseOpts,
cosigners: [
{
kind: "cloud_hsm",
provider: "vault_transit",
key_ref: "transit/keys/bridge-key",
algorithm: "ed25519",
},
],
})
expect(result?.status).toBe("failed")
expect(result?.reason).toMatch(/VAULT_ADDR/)
})
it("signs via POST /v1/transit/sign/<name> and unwraps vault:vN:<base64>", async () => {
process.env.VAULT_ADDR = "https://vault.example"
process.env.VAULT_TOKEN = "s.token123"
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(
JSON.stringify({
data: {
signature: "vault:v3:" + Buffer.from(stubSig).toString("base64"),
key_version: 3,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
)
const [result] = await dispatchCosigners({
...baseOpts,
cosigners: [
{
kind: "cloud_hsm",
provider: "vault_transit",
key_ref: "transit/keys/bridge-key",
algorithm: "secp256k1_ecdsa_sha256",
},
],
})
expect(result?.status).toBe("approved")
expect(result?.signature).toBe(stubSigHex)
expect(result?.externalId).toBe("v3")
// Verify URL + headers + body shape.
expect(fetchSpy).toHaveBeenCalledTimes(1)
const [url, init] = fetchSpy.mock.calls[0]!
expect(url).toBe("https://vault.example/v1/transit/sign/bridge-key")
expect((init as RequestInit).headers).toEqual({
"X-Vault-Token": "s.token123",
"Content-Type": "application/json",
})
const body = JSON.parse((init as RequestInit).body as string)
expect(body).toMatchObject({
hash_algorithm: "sha2-256",
prehashed: true,
marshaling_algorithm: "asn1",
})
expect(typeof body.input).toBe("string")
fetchSpy.mockRestore()
})
it("401 → rejected/permission_denied", async () => {
process.env.VAULT_ADDR = "https://vault.example"
process.env.VAULT_TOKEN = "bad"
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response('{"errors":["permission denied"]}', { status: 403 }),
)
const [result] = await dispatchCosigners({
...baseOpts,
cosigners: [
{
kind: "cloud_hsm",
provider: "vault_transit",
key_ref: "transit/keys/k",
algorithm: "secp256k1_ecdsa_sha256",
},
],
})
expect(result?.status).toBe("rejected")
expect(result?.reason).toMatch(/permission_denied/)
fetchSpy.mockRestore()
})
it("invalid keyRef without /keys/ → rejected/invalid_digest", async () => {
process.env.VAULT_ADDR = "https://vault.example"
process.env.VAULT_TOKEN = "s.token"
const [result] = await dispatchCosigners({
...baseOpts,
cosigners: [
{
kind: "cloud_hsm",
provider: "vault_transit",
key_ref: "malformed-ref",
algorithm: "secp256k1_ecdsa_sha256",
},
],
})
expect(result?.status).toBe("rejected")
expect(result?.reason).toMatch(/invalid_digest/)
})
})
// ────────────────────────────────────────────────────────────────────────
// f-chain (FHE attestation)
// ────────────────────────────────────────────────────────────────────────
describe("runFChain — Lux f-chain FHE attestation", () => {
it("POSTs txHash to /v1/fhe/encrypt and treats ciphertext as signature", async () => {
const ciphertext = "deadbeef".repeat(8)
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(
JSON.stringify({
ciphertext_hex: ciphertext,
session_id: "sess_xyz",
scheme: "ckks",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
)
const [result] = await dispatchCosigners({
...baseOpts,
cosigners: [
{
kind: "fchain",
public_url: "https://fchain.lux.network",
scheme: "ckks",
},
],
})
expect(result?.status).toBe("approved")
expect(result?.signature).toBe("0x" + ciphertext)
expect(result?.externalId).toBe("sess_xyz")
const [url, init] = fetchSpy.mock.calls[0]!
expect(url).toBe("https://fchain.lux.network/v1/fhe/encrypt")
const body = JSON.parse((init as RequestInit).body as string)
expect(body.plaintext_hex).toBe(txHash.slice(2))
expect(body.scheme).toBe("ckks")
fetchSpy.mockRestore()
})
it("403 from f-chain → rejected (policy denial)", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response("forbidden", { status: 403 }),
)
const [result] = await dispatchCosigners({
...baseOpts,
cosigners: [
{ kind: "fchain", public_url: "https://fchain.lux.network" },
],
})
expect(result?.status).toBe("rejected")
expect(result?.reason).toMatch(/fchain status=403/)
fetchSpy.mockRestore()
})
it("500 from f-chain → failed (retryable infra)", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response("internal", { status: 500 }),
)
const [result] = await dispatchCosigners({
...baseOpts,
cosigners: [
{ kind: "fchain", public_url: "https://fchain.lux.network" },
],
})
expect(result?.status).toBe("failed")
expect(result?.reason).toMatch(/fchain status=500/)
fetchSpy.mockRestore()
})
it("network error / abort → failed", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
const [result] = await dispatchCosigners({
...baseOpts,
cosigners: [
{ kind: "fchain", public_url: "https://fchain.lux.network" },
],
})
expect(result?.status).toBe("failed")
expect(result?.reason).toMatch(/ECONNREFUSED|fchain encrypt POST failed/)
fetchSpy.mockRestore()
})
it("missing ciphertext_hex → failed", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(JSON.stringify({ session_id: "x" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
)
const [result] = await dispatchCosigners({
...baseOpts,
cosigners: [
{ kind: "fchain", public_url: "https://fchain.lux.network" },
],
})
expect(result?.status).toBe("failed")
expect(result?.reason).toMatch(/missing ciphertext_hex/)
fetchSpy.mockRestore()
})
})
@@ -15,10 +15,10 @@ const getTx = vi.fn()
vi.mock("fireblocks-sdk", () => { vi.mock("fireblocks-sdk", () => {
return { return {
FireblocksSDK: vi.fn().mockImplementation(() => ({ FireblocksSDK: class {
createTransaction: createTx, createTransaction = createTx
getTransactionById: getTx, getTransactionById = getTx
})), },
PeerType: { VAULT_ACCOUNT: "VAULT_ACCOUNT" }, PeerType: { VAULT_ACCOUNT: "VAULT_ACCOUNT" },
TransactionOperation: { RAW: "RAW" }, TransactionOperation: { RAW: "RAW" },
} }
@@ -182,6 +182,6 @@ describe("runFireblocks — failed / transient", () => {
const [result] = await dispatchCosigners(opts) const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("failed") expect(result?.status).toBe("failed")
expect(result?.reason).toMatch(/secret not in KMS/) expect(result?.reason).toMatch(/cosigner secret unavailable/)
}) })
}) })
@@ -0,0 +1,256 @@
// GCP Cloud KMS cosigner — happy / structured-error / algorithm-mismatch.
//
// Mocks @google-cloud/kms's KeyManagementServiceClient. Tests:
// 1. CloudSigner interface contract: returns CloudSignResult shape
// 2. SHA-256 digest convention (cloudHsmDigest)
// 3. asymmetricSign(name, { sha256: digest }) is the right call
// 4. GCP gRPC error codes map cleanly to CloudSignerErrorCode:
// 7 (PERMISSION_DENIED) → permission_denied → rejected
// 16 (UNAUTHENTICATED) → permission_denied → rejected
// 5 (NOT_FOUND) → key_not_found → rejected
// 9 (FAILED_PRECONDITION)→ key_disabled → rejected
// 8 (RESOURCE_EXHAUSTED)→ rate_limited → failed
// 14 (UNAVAILABLE) → provider_unavailable → failed
// 5. Algorithm allow-list enforcement (only secp256k1_ecdsa_sha256 wired)
// 6. NO SA-JSON-in-env fallback — workload identity ONLY
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => ({
asymmetricSign: vi.fn(),
KeyManagementServiceClient: vi.fn(),
// Unused for these tests but needed because cosigners.ts also imports
// them at module level.
initiateTransaction: vi.fn(),
getTransaction: vi.fn(),
createGrpcClient: vi.fn(),
serviceAccountAuthStrategy: vi.fn().mockReturnValue("interceptor"),
}))
vi.mock("@google-cloud/kms", () => ({
KeyManagementServiceClient: mocks.KeyManagementServiceClient,
}))
vi.mock("@luxfi/utila", () => ({
createGrpcClient: mocks.createGrpcClient,
serviceAccountAuthStrategy: mocks.serviceAccountAuthStrategy,
}))
vi.mock("fireblocks-sdk", () => ({
FireblocksSDK: vi.fn(),
PeerType: { VAULT_ACCOUNT: "VAULT_ACCOUNT" },
TransactionOperation: { RAW: "RAW" },
}))
import {
cloudHsmDigest,
dispatchCosigners,
type CloudHsmCosignerIntent,
} from "../cosigners"
import { createHash } from "crypto"
const KEY_REF =
"projects/p/locations/global/keyRings/r/cryptoKeys/k/cryptoKeyVersions/1"
const intent: CloudHsmCosignerIntent = {
kind: "cloud_hsm",
provider: "gcp_kms",
key_ref: KEY_REF,
algorithm: "secp256k1_ecdsa_sha256",
}
const txHash = "0xabcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
const opts = {
swapId: "swap_gcp_test",
txHash,
nativeSignature: "0xnative",
cosigners: [intent],
}
const stubSig = new Uint8Array(64).fill(0xab)
beforeEach(() => {
mocks.asymmetricSign.mockReset()
mocks.KeyManagementServiceClient.mockReset()
mocks.KeyManagementServiceClient.mockImplementation(function () {
return { asymmetricSign: mocks.asymmetricSign }
})
})
afterEach(() => {})
// ── digest convention ───────────────────────────────────────────────────
describe("cloudHsmDigest", () => {
it("is SHA-256 over the txHash bytes (without 0x prefix)", () => {
const want = createHash("sha256")
.update(Buffer.from(txHash.slice(2), "hex"))
.digest()
expect(cloudHsmDigest(txHash)).toEqual(want)
})
it("accepts bare hex as well as 0x-prefixed", () => {
expect(cloudHsmDigest("0xdeadbeef")).toEqual(cloudHsmDigest("deadbeef"))
})
})
// ── happy path ──────────────────────────────────────────────────────────
describe("runCloudHsm — gcp_kms happy path", () => {
it("signs via workload identity (no creds passed to client ctor)", async () => {
mocks.asymmetricSign.mockResolvedValueOnce([{ signature: stubSig }])
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("approved")
expect(result?.signature).toBe(
"0x" + Buffer.from(stubSig).toString("hex"),
)
expect(result?.externalId).toBe(KEY_REF)
// Verify the call: asymmetricSign called with key_ref + SHA-256 digest.
expect(mocks.asymmetricSign).toHaveBeenCalledTimes(1)
const callArg = mocks.asymmetricSign.mock.calls[0]?.[0] as {
name: string
digest: { sha256: Buffer }
}
expect(callArg.name).toBe(KEY_REF)
expect(callArg.digest.sha256).toEqual(cloudHsmDigest(txHash))
// Workload-identity path — KeyManagementServiceClient constructed
// with NO arguments (no credentials).
expect(mocks.KeyManagementServiceClient).toHaveBeenCalledWith()
})
it("decodes base64 signature when the API returns it as a string", async () => {
const sigB64 = Buffer.from(stubSig).toString("base64")
mocks.asymmetricSign.mockResolvedValueOnce([{ signature: sigB64 }])
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("approved")
expect(result?.signature).toBe(
"0x" + Buffer.from(stubSig).toString("hex"),
)
})
it("identity_hint is forwarded but does not become credentials", async () => {
mocks.asymmetricSign.mockResolvedValueOnce([{ signature: stubSig }])
await dispatchCosigners({
...opts,
cosigners: [
{
...intent,
identity_hint: "[email protected]",
},
],
})
// No credentials passed — identity_hint is non-secret context only.
expect(mocks.KeyManagementServiceClient).toHaveBeenCalledWith()
})
})
// ── structured-error mapping ─────────────────────────────────────────────
describe("runCloudHsm — gcp_kms error mapping", () => {
it("gRPC code 7 PERMISSION_DENIED → rejected/permission_denied", async () => {
mocks.asymmetricSign.mockRejectedValueOnce(
Object.assign(new Error("Caller does not have permission"), { code: 7 }),
)
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("rejected")
expect(result?.reason).toMatch(/permission_denied/)
})
it("gRPC code 16 UNAUTHENTICATED → rejected/permission_denied", async () => {
mocks.asymmetricSign.mockRejectedValueOnce(
Object.assign(new Error("invalid grant"), { code: 16 }),
)
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("rejected")
expect(result?.reason).toMatch(/permission_denied/)
})
it("gRPC code 5 NOT_FOUND → rejected/key_not_found", async () => {
mocks.asymmetricSign.mockRejectedValueOnce(
Object.assign(new Error("Key not found"), { code: 5 }),
)
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("rejected")
expect(result?.reason).toMatch(/key_not_found/)
})
it("gRPC code 9 FAILED_PRECONDITION → rejected/key_disabled", async () => {
mocks.asymmetricSign.mockRejectedValueOnce(
Object.assign(new Error("Key is disabled"), { code: 9 }),
)
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("rejected")
expect(result?.reason).toMatch(/key_disabled/)
})
it("gRPC code 8 RESOURCE_EXHAUSTED → failed/rate_limited", async () => {
mocks.asymmetricSign.mockRejectedValueOnce(
Object.assign(new Error("Quota exceeded"), { code: 8 }),
)
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("failed")
expect(result?.reason).toMatch(/rate_limited/)
})
it("gRPC code 14 UNAVAILABLE → failed/provider_unavailable", async () => {
mocks.asymmetricSign.mockRejectedValueOnce(
Object.assign(new Error("backend unavailable"), { code: 14 }),
)
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("failed")
expect(result?.reason).toMatch(/provider_unavailable/)
})
it("unknown error → failed/network_error (fallback bucket)", async () => {
// No gRPC code and no recognised string pattern → falls through
// to network_error. `unreachable` and `UNAVAILABLE` match earlier
// branches, so use a generic transport error string.
mocks.asymmetricSign.mockRejectedValueOnce(
new Error("ECONNRESET: socket hang up"),
)
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("failed")
expect(result?.reason).toMatch(/network_error/)
})
it("API returns no signature → failed/provider_unavailable", async () => {
mocks.asymmetricSign.mockResolvedValueOnce([{}])
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("failed")
expect(result?.reason).toMatch(/no signature/)
})
})
// ── algorithm allow-list ─────────────────────────────────────────────────
describe("runCloudHsm — algorithm allow-list", () => {
it("rejects algorithms that aren't wired for gcp_kms yet", async () => {
const [result] = await dispatchCosigners({
...opts,
cosigners: [{ ...intent, algorithm: "ed25519" }],
})
expect(result?.status).toBe("rejected")
expect(result?.reason).toMatch(/algorithm_mismatch/)
expect(mocks.asymmetricSign).not.toHaveBeenCalled()
})
})
// Real-adapter tests for AWS / Azure / Vault Transit / f-chain live in
// cosigners-aws-azure-vault-fchain.test.ts — they mock the provider SDKs
// (and globalThis.fetch for the HTTP-direct adapters) the same way this
// file mocks @google-cloud/kms.
@@ -0,0 +1,196 @@
// fetchCosignerSecret — real Lux KMS round-trip with env-var fallback.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import {
_resetKmsClientForTests,
fetchCosignerSecret,
} from "../cosigners"
// Mocks for the upstream deps cosigners.ts imports — needed because the
// module-level imports otherwise try to instantiate real clients.
vi.mock("fireblocks-sdk", () => ({
FireblocksSDK: vi.fn(),
PeerType: { VAULT_ACCOUNT: "VAULT_ACCOUNT" },
TransactionOperation: { RAW: "RAW" },
}))
vi.mock("@google-cloud/kms", () => ({
KeyManagementServiceClient: vi.fn(),
}))
vi.mock("@aws-sdk/client-kms", () => ({
KMSClient: vi.fn(),
SignCommand: vi.fn(),
}))
vi.mock("@azure/keyvault-keys", () => ({
CryptographyClient: vi.fn(),
}))
vi.mock("@azure/identity", () => ({
DefaultAzureCredential: vi.fn(),
}))
vi.mock("@luxfi/utila", () => ({
createGrpcClient: vi.fn(),
serviceAccountAuthStrategy: vi.fn(),
}))
beforeEach(() => {
_resetKmsClientForTests()
})
afterEach(() => {
delete process.env.BRIDGE_KMS_URL
delete process.env.BRIDGE_KMS_ORG
delete process.env.BRIDGE_IAM_ISSUER
delete process.env.BRIDGE_IAM_CLIENT_ID
delete process.env.BRIDGE_IAM_CLIENT_SECRET
delete process.env.UTILA_COSIGNER_PEM__ORG_A
delete process.env.FIREBLOCKS_COSIGNER_PEM__PUB_KEY
vi.restoreAllMocks()
})
function jsonResponse(body: object, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
})
}
function textResponse(body: string, status = 200): Response {
return new Response(body, {
status,
headers: { "Content-Type": "text/plain" },
})
}
describe("fetchCosignerSecret — KMS-backed", () => {
it("reads utila PEM from bridge/cosigners/utila/{org}/sa_pem when KMS is configured", async () => {
process.env.BRIDGE_KMS_URL = "https://kms.lux.network"
process.env.BRIDGE_KMS_ORG = "lux"
process.env.BRIDGE_IAM_ISSUER = "https://iam.lux.network"
process.env.BRIDGE_IAM_CLIENT_ID = "lux-bridge"
process.env.BRIDGE_IAM_CLIENT_SECRET = "test"
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(textResponse("UTILA-PEM-BYTES"))
const pem = await fetchCosignerSecret({
kind: "utila",
org_id: "org-a",
client_id: "cid",
})
expect(pem).toBe("UTILA-PEM-BYTES")
const url = fetchSpy.mock.calls[1]?.[0] as string
expect(url).toBe(
"https://kms.lux.network/v1/kms/orgs/lux/secrets/bridge/cosigners/utila/ORG_A/sa_pem",
)
})
it("reads fireblocks PEM from bridge/cosigners/fireblocks/{api_key}/secret_pem", async () => {
process.env.BRIDGE_KMS_URL = "https://kms.lux.network"
process.env.BRIDGE_KMS_ORG = "lux"
process.env.BRIDGE_IAM_ISSUER = "https://iam.lux.network"
process.env.BRIDGE_IAM_CLIENT_ID = "lux-bridge"
process.env.BRIDGE_IAM_CLIENT_SECRET = "test"
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(textResponse("FB-PEM"))
const pem = await fetchCosignerSecret({
kind: "fireblocks",
api_key: "pub-key",
})
expect(pem).toBe("FB-PEM")
const url = fetchSpy.mock.calls[1]?.[0] as string
expect(url).toBe(
"https://kms.lux.network/v1/kms/orgs/lux/secrets/bridge/cosigners/fireblocks/PUB_KEY/secret_pem",
)
})
it("falls back to env var when KMS returns 404", async () => {
process.env.BRIDGE_KMS_URL = "https://kms.lux.network"
process.env.BRIDGE_KMS_ORG = "lux"
process.env.BRIDGE_IAM_ISSUER = "https://iam.lux.network"
process.env.BRIDGE_IAM_CLIENT_ID = "lux-bridge"
process.env.BRIDGE_IAM_CLIENT_SECRET = "test"
process.env.UTILA_COSIGNER_PEM__ORG_A = "DEV-PEM-FROM-ENV"
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(new Response("not found", { status: 404 }))
const pem = await fetchCosignerSecret({
kind: "utila",
org_id: "org-a",
client_id: "cid",
})
expect(pem).toBe("DEV-PEM-FROM-ENV")
})
it("uses env-var fallback when KMS is NOT configured (no BRIDGE_KMS_URL)", async () => {
process.env.FIREBLOCKS_COSIGNER_PEM__PUB_KEY = "LOCAL-DEV-PEM"
const fetchSpy = vi.spyOn(globalThis, "fetch")
const pem = await fetchCosignerSecret({
kind: "fireblocks",
api_key: "pub-key",
})
expect(pem).toBe("LOCAL-DEV-PEM")
// Never hit the network — KMS wasn't configured.
expect(fetchSpy).not.toHaveBeenCalled()
})
it("throws cleanly when neither KMS nor env var has the secret", async () => {
await expect(
fetchCosignerSecret({
kind: "utila",
org_id: "missing-org",
client_id: "cid",
}),
).rejects.toThrow(/cosigner secret unavailable/)
})
it("non-404 KMS error propagates (retryable on the caller side)", async () => {
process.env.BRIDGE_KMS_URL = "https://kms.lux.network"
process.env.BRIDGE_KMS_ORG = "lux"
process.env.BRIDGE_IAM_ISSUER = "https://iam.lux.network"
process.env.BRIDGE_IAM_CLIENT_ID = "lux-bridge"
process.env.BRIDGE_IAM_CLIENT_SECRET = "test"
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(new Response("upstream timeout", { status: 504 }))
await expect(
fetchCosignerSecret({
kind: "utila",
org_id: "org-a",
client_id: "cid",
}),
).rejects.toThrow(/504/)
})
it("rejects cloud_hsm / fchain kinds (defensive — they use workload identity)", async () => {
await expect(
fetchCosignerSecret({
kind: "cloud_hsm",
provider: "gcp_kms",
key_ref: "k",
algorithm: "secp256k1_ecdsa_sha256",
}),
).rejects.toThrow(/workload identity/)
await expect(
fetchCosignerSecret({
kind: "fchain",
public_url: "https://fchain.lux.network",
}),
).rejects.toThrow(/workload identity/)
})
})
@@ -0,0 +1,239 @@
// Persistence + state-machine helpers for layered cosigners.
//
// Covers:
// persistCosignerIntents — create one CosignerStep per intent at swap-create
// dispatchCosignersForSwap — read pending steps, dispatch, persist terminal state
//
// Prisma is mocked at the module level so unit tests never touch a real DB.
// Fireblocks SDK is mocked too — the dispatch path goes through runFireblocks
// which we've already proved out in cosigners-fireblocks.test.ts.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
// vi.mock factories are hoisted above all const declarations, so any
// mock-fn references they capture have to come from vi.hoisted (which
// runs in the same hoisted block).
const mocks = vi.hoisted(() => ({
cosignerStepCreate: vi.fn(),
cosignerStepFindMany: vi.fn(),
cosignerStepUpdate: vi.fn(),
prismaTx: vi.fn(async (ops: unknown[]) => ops.map(() => null)),
fbCreateTx: vi.fn(),
fbGetTx: vi.fn(),
}))
vi.mock("@/prisma-instance", () => ({
prisma: {
cosignerStep: {
create: mocks.cosignerStepCreate,
findMany: mocks.cosignerStepFindMany,
update: mocks.cosignerStepUpdate,
},
$transaction: mocks.prismaTx,
},
}))
vi.mock("fireblocks-sdk", () => ({
FireblocksSDK: class {
createTransaction = mocks.fbCreateTx
getTransactionById = mocks.fbGetTx
},
PeerType: { VAULT_ACCOUNT: "VAULT_ACCOUNT" },
TransactionOperation: { RAW: "RAW" },
}))
const { cosignerStepCreate, cosignerStepFindMany, cosignerStepUpdate, prismaTx, fbCreateTx, fbGetTx } = mocks
import {
dispatchCosignersForSwap,
persistCosignerIntents,
type CosignerIntent,
} from "../cosigners"
beforeEach(() => {
process.env.FIREBLOCKS_COSIGNER_PEM__PUB_KEY = "stub-pem"
process.env.FIREBLOCKS_COSIGNER_TIMEOUT_MS = "5000"
cosignerStepCreate.mockReset()
cosignerStepCreate.mockResolvedValue(undefined)
cosignerStepFindMany.mockReset()
cosignerStepUpdate.mockReset()
cosignerStepUpdate.mockResolvedValue(undefined)
prismaTx.mockClear()
prismaTx.mockImplementation(async (ops: unknown[]) => ops.map(() => null))
fbCreateTx.mockReset()
fbGetTx.mockReset()
})
afterEach(() => {
delete process.env.FIREBLOCKS_COSIGNER_PEM__PUB_KEY
delete process.env.FIREBLOCKS_COSIGNER_TIMEOUT_MS
})
describe("persistCosignerIntents", () => {
it("creates one CosignerStep row per intent in a single transaction", async () => {
const intents: CosignerIntent[] = [
{ kind: "utila", org_id: "org-a", client_id: "cid-a", vault_id: "v1" },
{ kind: "fireblocks", api_key: "pub-key", vault_account_id: "0" },
]
await persistCosignerIntents("swap_abc", intents)
expect(prismaTx).toHaveBeenCalledTimes(1)
// Two create() calls staged inside the transaction.
expect(cosignerStepCreate).toHaveBeenCalledTimes(2)
expect(cosignerStepCreate.mock.calls[0]?.[0]).toMatchObject({
data: {
swap_id: "swap_abc",
kind: "utila",
public_id: "org-a",
vault_id: "v1",
status: "pending",
},
})
expect(cosignerStepCreate.mock.calls[1]?.[0]).toMatchObject({
data: {
swap_id: "swap_abc",
kind: "fireblocks",
public_id: "pub-key",
vault_id: "0",
status: "pending",
},
})
})
it("no-ops for empty intent array (no DB write, no log noise)", async () => {
await persistCosignerIntents("swap_x", [])
expect(prismaTx).not.toHaveBeenCalled()
expect(cosignerStepCreate).not.toHaveBeenCalled()
})
})
describe("dispatchCosignersForSwap", () => {
it("returns no_steps when no pending rows exist", async () => {
cosignerStepFindMany.mockResolvedValueOnce([])
const v = await dispatchCosignersForSwap("swap_n", "0xabc", "0xtx")
expect(v.aggregate).toBe("no_steps")
expect(v.results).toEqual([])
expect(v.failingReasons).toEqual([])
expect(cosignerStepUpdate).not.toHaveBeenCalled()
})
it("returns all_approved when fireblocks step completes", async () => {
cosignerStepFindMany.mockResolvedValueOnce([
{
id: 1,
kind: "fireblocks",
public_id: "pub-key",
api_host: null,
vault_id: "0",
},
])
fbCreateTx.mockResolvedValueOnce({ id: "fb_tx_1", status: "SUBMITTED" })
fbGetTx.mockResolvedValueOnce({
id: "fb_tx_1",
status: "COMPLETED",
signedMessages: [{ signature: { fullSig: "0xfeed" } }],
})
const v = await dispatchCosignersForSwap("swap_y", "0xnative", "0xtx")
expect(v.aggregate).toBe("all_approved")
expect(v.results).toHaveLength(1)
expect(v.results[0]!.status).toBe("approved")
expect(v.results[0]!.signature).toBe("0xfeed")
expect(cosignerStepUpdate).toHaveBeenCalledTimes(1)
expect(cosignerStepUpdate.mock.calls[0]?.[0]).toMatchObject({
where: { id: 1 },
data: {
status: "approved",
signature: "0xfeed",
external_id: "fb_tx_1",
},
})
})
it("returns any_rejected when fireblocks rejects + persists rejection reason", async () => {
cosignerStepFindMany.mockResolvedValueOnce([
{
id: 7,
kind: "fireblocks",
public_id: "pub-key",
api_host: null,
vault_id: null,
},
])
fbCreateTx.mockResolvedValueOnce({ id: "fb_tx_x", status: "QUEUED" })
fbGetTx.mockResolvedValueOnce({
id: "fb_tx_x",
status: "REJECTED",
subStatus: "REJECTED_BY_USER",
})
const v = await dispatchCosignersForSwap("swap_z", "0xnative", "0xtx")
expect(v.aggregate).toBe("any_rejected")
expect(v.failingReasons[0]).toMatch(/fireblocks/)
expect(v.failingReasons[0]).toMatch(/REJECTED_BY_USER/)
expect(cosignerStepUpdate.mock.calls[0]?.[0]).toMatchObject({
where: { id: 7 },
data: {
status: "rejected",
reason: expect.stringMatching(/REJECTED_BY_USER/) as string,
external_id: "fb_tx_x",
},
})
})
it("returns any_failed (not any_rejected) when only failures present", async () => {
cosignerStepFindMany.mockResolvedValueOnce([
{
id: 99,
kind: "fireblocks",
public_id: "pub-key",
api_host: null,
vault_id: null,
},
])
fbCreateTx.mockRejectedValueOnce(new Error("ECONNRESET"))
const v = await dispatchCosignersForSwap("swap_f", "0xn", "0xtx")
expect(v.aggregate).toBe("any_failed")
expect(v.failingReasons[0]).toMatch(/ECONNRESET/)
})
it("preserves intent order across multiple steps", async () => {
cosignerStepFindMany.mockResolvedValueOnce([
{
id: 10,
kind: "fireblocks",
public_id: "pub-key",
api_host: null,
vault_id: null,
},
{
id: 11,
kind: "fireblocks",
public_id: "pub-key",
api_host: null,
vault_id: null,
},
])
// Both succeed.
fbCreateTx
.mockResolvedValueOnce({ id: "tx_a", status: "SUBMITTED" })
.mockResolvedValueOnce({ id: "tx_b", status: "SUBMITTED" })
fbGetTx
.mockResolvedValueOnce({
id: "tx_a",
status: "COMPLETED",
signedMessages: [{ signature: { fullSig: "0xaa" } }],
})
.mockResolvedValueOnce({
id: "tx_b",
status: "COMPLETED",
signedMessages: [{ signature: { fullSig: "0xbb" } }],
})
const v = await dispatchCosignersForSwap("swap_p", "0xn", "0xtx")
expect(v.aggregate).toBe("all_approved")
expect(cosignerStepUpdate).toHaveBeenCalledTimes(2)
expect(cosignerStepUpdate.mock.calls[0]?.[0]).toMatchObject({ where: { id: 10 } })
expect(cosignerStepUpdate.mock.calls[1]?.[0]).toMatchObject({ where: { id: 11 } })
})
})
@@ -0,0 +1,286 @@
// Utila cosigner — happy / rejected / failed / timeout / no-vault.
//
// We mock `@luxfi/utila`'s createGrpcClient + serviceAccountAuthStrategy
// so the unit tests never touch the real API. The contract being tested
// is the Utila TransactionState_Enum partition + evm_personal_sign /
// getTransaction round-trip. Real types come from
// pkg/utila/src/lib/gen/utila/api/v1alpha2/...
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
// vi.mock factories are hoisted; pull the mock fns from vi.hoisted so
// they're defined in the same hoist block.
const mocks = vi.hoisted(() => ({
initiateTransaction: vi.fn(),
getTransaction: vi.fn(),
createGrpcClient: vi.fn(),
serviceAccountAuthStrategy: vi.fn().mockReturnValue("interceptor"),
}))
vi.mock("@luxfi/utila", () => ({
createGrpcClient: mocks.createGrpcClient,
serviceAccountAuthStrategy: mocks.serviceAccountAuthStrategy,
}))
// Fireblocks is unused in this test file but the cosigners.ts module
// imports it at the top — mock it to a no-op so module load doesn't
// trip on a missing constructor.
vi.mock("fireblocks-sdk", () => ({
FireblocksSDK: vi.fn(),
PeerType: { VAULT_ACCOUNT: "VAULT_ACCOUNT" },
TransactionOperation: { RAW: "RAW" },
}))
import {
dispatchCosigners,
type UtilaCosignerIntent,
} from "../cosigners"
const intent: UtilaCosignerIntent = {
kind: "utila",
org_id: "[email protected]",
client_id: "0xfrom0000000000000000000000000000000000",
vault_id: "1b25635a5b3f",
}
const opts = {
swapId: "swap_utila_test",
txHash: "0xdeadbeef" + "00".repeat(28),
nativeSignature: "0xnative",
cosigners: [intent],
}
// Bytes for an example 65-byte ECDSA signature, returned by Utila
// (Uint8Array on the wire, converted to 0x-hex by runUtila).
const sigBytes = new Uint8Array([
0xfe, 0xed, 0xfa, 0xce, 0xde, 0xad, 0xbe, 0xef, 0xab, 0xcd, 0xef, 0x12,
0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, 0x55,
0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11,
0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99,
0xaa, 0xbb, 0xcc, 0xdd, 0x1c,
])
const sigHex =
"0x" +
Array.from(sigBytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
beforeEach(() => {
process.env.UTILA_COSIGNER_PEM__SERVICE_ACCOUNT_TENANT_X_UTILA_IO =
"-----BEGIN PRIVATE KEY-----\nstub\n-----END PRIVATE KEY-----"
process.env.UTILA_COSIGNER_TIMEOUT_MS = "5000"
mocks.initiateTransaction.mockReset()
mocks.getTransaction.mockReset()
mocks.createGrpcClient.mockReset()
mocks.createGrpcClient.mockReturnValue({
version: () => ({
initiateTransaction: mocks.initiateTransaction,
getTransaction: mocks.getTransaction,
}),
})
})
afterEach(() => {
delete process.env.UTILA_COSIGNER_PEM__SERVICE_ACCOUNT_TENANT_X_UTILA_IO
delete process.env.UTILA_COSIGNER_TIMEOUT_MS
})
describe("runUtila — happy path", () => {
it("returns approved + signature when state reaches SIGNED (4)", async () => {
mocks.initiateTransaction.mockResolvedValueOnce({
transaction: { name: "vaults/v1/transactions/tx_a" },
})
mocks.getTransaction
.mockResolvedValueOnce({ name: "tx_a", state: { state: 3 } }) // AWAITING_SIGNATURE
.mockResolvedValueOnce({
name: "tx_a",
state: { state: 4 }, // SIGNED
evmMessage: { signature: sigBytes },
})
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("approved")
expect(result?.signature).toBe(sigHex)
expect(result?.externalId).toBe("vaults/v1/transactions/tx_a")
})
it("treats CONFIRMED (13) as approved (sig already attested by SIGNED)", async () => {
mocks.initiateTransaction.mockResolvedValueOnce({
transaction: { name: "tx_c" },
})
mocks.getTransaction.mockResolvedValueOnce({
name: "tx_c",
state: { state: 13 }, // CONFIRMED
evmMessage: { signature: sigBytes },
})
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("approved")
expect(result?.signature).toBe(sigHex)
})
it("keeps polling when state is SIGNED but signature not yet present", async () => {
mocks.initiateTransaction.mockResolvedValueOnce({
transaction: { name: "tx_p" },
})
mocks.getTransaction
// First poll: SIGNED but sig not propagated yet.
.mockResolvedValueOnce({ name: "tx_p", state: { state: 4 } })
// Second poll: same state + sig now populated.
.mockResolvedValueOnce({
name: "tx_p",
state: { state: 4 },
evmMessage: { signature: sigBytes },
})
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("approved")
expect(result?.signature).toBe(sigHex)
})
})
describe("runUtila — rejected", () => {
it("returns rejected on DECLINED (9)", async () => {
mocks.initiateTransaction.mockResolvedValueOnce({
transaction: { name: "tx_r" },
})
mocks.getTransaction.mockResolvedValueOnce({
name: "tx_r",
state: { state: 9 }, // DECLINED
})
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("rejected")
expect(result?.reason).toMatch(/DECLINED|TAP/)
expect(result?.externalId).toBe("tx_r")
})
it("returns rejected on CANCELED (11)", async () => {
mocks.initiateTransaction.mockResolvedValueOnce({
transaction: { name: "tx_cn" },
})
mocks.getTransaction.mockResolvedValueOnce({
name: "tx_cn",
state: { state: 11 },
})
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("rejected")
})
it("returns rejected on EXPIRED (15) or DROPPED (12)", async () => {
mocks.initiateTransaction.mockResolvedValueOnce({
transaction: { name: "tx_e" },
})
mocks.getTransaction.mockResolvedValueOnce({
name: "tx_e",
state: { state: 15 },
})
const [result1] = await dispatchCosigners(opts)
expect(result1?.status).toBe("rejected")
mocks.initiateTransaction.mockResolvedValueOnce({
transaction: { name: "tx_d" },
})
mocks.getTransaction.mockResolvedValueOnce({
name: "tx_d",
state: { state: 12 },
})
const [result2] = await dispatchCosigners(opts)
expect(result2?.status).toBe("rejected")
})
})
describe("runUtila — failed", () => {
it("returns failed on FAILED (8)", async () => {
mocks.initiateTransaction.mockResolvedValueOnce({
transaction: { name: "tx_f" },
})
mocks.getTransaction.mockResolvedValueOnce({
name: "tx_f",
state: { state: 8 },
})
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("failed")
expect(result?.reason).toMatch(/state=8|FAILED/)
})
it("returns failed when initiateTransaction throws", async () => {
mocks.initiateTransaction.mockRejectedValueOnce(new Error("auth: bad jwt"))
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("failed")
expect(result?.reason).toMatch(/bad jwt/)
})
it("returns failed when initiateTransaction returns no name", async () => {
mocks.initiateTransaction.mockResolvedValueOnce({ transaction: {} })
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("failed")
expect(result?.reason).toMatch(/no transaction\.name/)
})
it("returns failed on timeout (stays in AWAITING_* past the ceiling)", async () => {
process.env.UTILA_COSIGNER_TIMEOUT_MS = "200"
mocks.initiateTransaction.mockResolvedValueOnce({
transaction: { name: "tx_t" },
})
mocks.getTransaction.mockResolvedValue({
name: "tx_t",
state: { state: 2 }, // AWAITING_APPROVAL forever
})
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("failed")
expect(result?.reason).toMatch(/timed out/)
expect(result?.externalId).toBe("tx_t")
})
it("returns failed when vault_id is missing (required for parent scope)", async () => {
const [result] = await dispatchCosigners({
...opts,
cosigners: [{ ...intent, vault_id: undefined }],
})
expect(result?.status).toBe("failed")
expect(result?.reason).toMatch(/vault_id/)
// initiateTransaction must not have been called — we bailed early.
expect(mocks.initiateTransaction).not.toHaveBeenCalled()
})
it("returns failed (env-var fallback missing) if no secret is configured", async () => {
delete process.env.UTILA_COSIGNER_PEM__SERVICE_ACCOUNT_TENANT_X_UTILA_IO
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("failed")
expect(result?.reason).toMatch(/cosigner secret unavailable/)
})
})
describe("runUtila — wire shape", () => {
it("calls initiateTransaction with evm_personal_sign + tenant vault parent", async () => {
mocks.initiateTransaction.mockResolvedValueOnce({
transaction: { name: "tx_w" },
})
mocks.getTransaction.mockResolvedValueOnce({
name: "tx_w",
state: { state: 4 },
evmMessage: { signature: sigBytes },
})
await dispatchCosigners(opts)
const call = mocks.initiateTransaction.mock.calls[0]?.[0] as {
parent: string
details: { details: { case: string; value: { fromAddress: string; messageHex: string } } }
note: string
}
expect(call.parent).toBe(`vaults/${intent.vault_id}`)
expect(call.details.details.case).toBe("evmPersonalSign")
expect(call.details.details.value.fromAddress).toBe(intent.client_id)
expect(call.details.details.value.messageHex).toBe(opts.txHash)
expect(call.note).toMatch(/lux-bridge cosign swap=swap_utila_test/)
})
})
@@ -67,6 +67,121 @@ describe("validateCosigners — happy paths (must pass)", () => {
expect(out[0]!.kind).toBe("utila") expect(out[0]!.kind).toBe("utila")
expect(out[1]!.kind).toBe("fireblocks") expect(out[1]!.kind).toBe("fireblocks")
}) })
it("accepts cloud_hsm/gcp_kms with key_ref + algorithm + identity_hint", () => {
const out = validateCosigners([
{
kind: "cloud_hsm",
provider: "gcp_kms",
key_ref:
"projects/p/locations/global/keyRings/r/cryptoKeys/k/cryptoKeyVersions/1",
algorithm: "secp256k1_ecdsa_sha256",
identity_hint: "[email protected]",
},
])
expect(out[0]).toMatchObject({
kind: "cloud_hsm",
provider: "gcp_kms",
algorithm: "secp256k1_ecdsa_sha256",
identity_hint: "[email protected]",
})
})
it("accepts cloud_hsm/aws_kms with full ARN as key_ref", () => {
const out = validateCosigners([
{
kind: "cloud_hsm",
provider: "aws_kms",
key_ref:
"arn:aws:kms:us-east-1:123456789012:key/00000000-0000-0000-0000-000000000000",
algorithm: "secp256k1_ecdsa_sha256",
},
])
expect(out[0]).toMatchObject({
kind: "cloud_hsm",
provider: "aws_kms",
})
})
it("accepts cloud_hsm/azure_key_vault with key URL as key_ref", () => {
const out = validateCosigners([
{
kind: "cloud_hsm",
provider: "azure_key_vault",
key_ref:
"https://my-vault.vault.azure.net/keys/my-key/abcdef1234567890",
algorithm: "secp256k1_ecdsa_sha256",
},
])
expect(out[0]).toMatchObject({
kind: "cloud_hsm",
provider: "azure_key_vault",
})
})
it("accepts cloud_hsm/vault_transit", () => {
const out = validateCosigners([
{
kind: "cloud_hsm",
provider: "vault_transit",
key_ref: "transit/keys/my-bridge-key",
algorithm: "ed25519",
},
])
expect(out[0]).toMatchObject({
kind: "cloud_hsm",
provider: "vault_transit",
algorithm: "ed25519",
})
})
it("accepts fchain with scheme", () => {
const out = validateCosigners([
{
kind: "fchain",
public_url: "https://fchain.lux.network",
scheme: "ckks",
},
])
expect(out[0]).toMatchObject({
kind: "fchain",
public_url: "https://fchain.lux.network",
scheme: "ckks",
})
})
it("accepts every cosigner kind layered together", () => {
const out = validateCosigners([
{ kind: "utila", org_id: "u", client_id: "c" },
{ kind: "fireblocks", api_key: "f" },
{
kind: "cloud_hsm",
provider: "gcp_kms",
key_ref: "k1",
algorithm: "secp256k1_ecdsa_sha256",
},
{
kind: "cloud_hsm",
provider: "aws_kms",
key_ref: "arn:...",
algorithm: "secp256k1_ecdsa_sha256",
},
{
kind: "cloud_hsm",
provider: "azure_key_vault",
key_ref: "https://v.vault.azure.net/keys/k",
algorithm: "secp256k1_ecdsa_sha256",
},
{
kind: "cloud_hsm",
provider: "vault_transit",
key_ref: "transit/keys/k",
algorithm: "ed25519",
},
{ kind: "fchain", public_url: "https://fchain.lux.network" },
])
expect(out).toHaveLength(7)
})
}) })
describe("validateCosigners — rejections (must throw BadCosignerIntent)", () => { describe("validateCosigners — rejections (must throw BadCosignerIntent)", () => {
@@ -147,6 +262,86 @@ describe("validateCosigners — rejections (must throw BadCosignerIntent)", () =
).toThrowError(/secret-like field/) ).toThrowError(/secret-like field/)
}) })
// — cloud_hsm validation —
it("rejects cloud_hsm with unknown provider", () => {
expect(() =>
validateCosigners([
{
kind: "cloud_hsm",
provider: "ibm_zhsm",
key_ref: "k",
algorithm: "secp256k1_ecdsa_sha256",
},
]),
).toThrowError(/cloud_hsm: unknown provider/)
})
it("rejects cloud_hsm missing key_ref", () => {
expect(() =>
validateCosigners([
{
kind: "cloud_hsm",
provider: "gcp_kms",
algorithm: "secp256k1_ecdsa_sha256",
},
]),
).toThrowError(/key_ref required/)
})
it("rejects cloud_hsm missing algorithm (required to reject early)", () => {
expect(() =>
validateCosigners([
{ kind: "cloud_hsm", provider: "gcp_kms", key_ref: "k" },
]),
).toThrowError(/algorithm required/)
})
it("rejects cloud_hsm with unsupported algorithm", () => {
expect(() =>
validateCosigners([
{
kind: "cloud_hsm",
provider: "gcp_kms",
key_ref: "k",
algorithm: "md5_ecdsa", // not in the allow-list
},
]),
).toThrowError(/algorithm required/)
})
it("rejects secret-like fields on cloud_hsm too", () => {
expect(() =>
validateCosigners([
{
kind: "cloud_hsm",
provider: "gcp_kms",
key_ref: "k",
algorithm: "secp256k1_ecdsa_sha256",
private_key: "-----BEGIN PRIVATE KEY-----",
},
]),
).toThrowError(/secret-like field/)
})
it("rejects fchain missing public_url", () => {
expect(() => validateCosigners([{ kind: "fchain" }])).toThrowError(
/public_url required/,
)
})
it("rejects fchain with unknown scheme", () => {
expect(() =>
validateCosigners([
{
kind: "fchain",
public_url: "https://fchain.lux.network",
scheme: "snake-oil",
},
]),
).toThrowError(/unknown scheme/)
})
it("reports the offending array index in the error message", () => { it("reports the offending array index in the error message", () => {
try { try {
validateCosigners([ validateCosigners([
@@ -0,0 +1,340 @@
// mpc-signer tests — verifies the MPCClient-routed signing path.
//
// Mocks @/clients/mpc.MPCClient and a minimal ethers JsonRpcProvider.
// The contract being tested:
// 1. isMPCSigningEnabled() reads the full BRIDGE_MPC_* + BRIDGE_IAM_*
// env block + BRIDGE_MPC_WALLET_ID — all must be set.
// 2. mpcSignAndSend() calls MPCClient.getWallet then MPCClient.sign,
// then builds + broadcasts an EIP-1559 tx with the returned r/s/v.
// 3. Compact 65-byte signature in result.signature falls back to
// manual r/s/v split.
// 4. v < 27 is normalized to v + 27.
// 5. mpcBridgeMint encodes bridgeMint(address, uint256) correctly.
// 6. mpcSendNative posts an empty-data tx with the requested value.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
// Hoisted mocks — vi.mock factories run before any module-level imports.
const mocks = vi.hoisted(() => ({
getWallet: vi.fn(),
sign: vi.fn(),
}))
vi.mock("@/clients/mpc", () => ({
MPCClient: class {
getWallet = mocks.getWallet
sign = mocks.sign
},
MPCError: class extends Error {},
}))
vi.mock("@/clients/iam", () => ({
IAMClient: class {},
}))
import {
_resetMpcClientForTests,
isMPCSigningEnabled,
mpcBridgeMint,
mpcSendNative,
mpcSignAndSend,
} from "../mpc-signer"
const ETHEREUM_CHAIN_ID = 1n
function makeProvider(opts: {
fromAddress: string
nonce?: number
broadcastHash?: string
feeData?: { maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }
}) {
const waitMock = vi.fn().mockResolvedValue({ status: 1 })
const broadcastMock = vi.fn().mockResolvedValue({
hash: opts.broadcastHash ?? "0xbroadcast",
wait: waitMock,
})
return {
getNetwork: vi.fn().mockResolvedValue({ chainId: ETHEREUM_CHAIN_ID }),
getTransactionCount: vi.fn().mockResolvedValue(opts.nonce ?? 0),
getFeeData: vi.fn().mockResolvedValue(
opts.feeData ?? {
maxFeePerGas: 100n,
maxPriorityFeePerGas: 2n,
},
),
broadcastTransaction: broadcastMock,
} as unknown as Parameters<typeof mpcSignAndSend>[0]
}
beforeEach(() => {
_resetMpcClientForTests()
mocks.getWallet.mockReset()
mocks.sign.mockReset()
// Default env: MPC signing enabled.
process.env.BRIDGE_MPC_URL = "https://mpc.example.test"
process.env.BRIDGE_IAM_ISSUER = "https://iam.example.test"
process.env.BRIDGE_IAM_CLIENT_ID = "bridge"
process.env.BRIDGE_IAM_CLIENT_SECRET = "sec"
process.env.BRIDGE_MPC_WALLET_ID = "wallet-eth"
})
afterEach(() => {
delete process.env.BRIDGE_MPC_URL
delete process.env.BRIDGE_IAM_ISSUER
delete process.env.BRIDGE_IAM_CLIENT_ID
delete process.env.BRIDGE_IAM_CLIENT_SECRET
delete process.env.BRIDGE_MPC_WALLET_ID
// Don't vi.restoreAllMocks — the MPCClient constructor mock is set
// in vi.mock factory once at module load; restoring would null it.
})
describe("isMPCSigningEnabled", () => {
it("true when every required env var is set", () => {
expect(isMPCSigningEnabled()).toBe(true)
})
it("false when any required env var is missing", () => {
for (const key of [
"BRIDGE_MPC_URL",
"BRIDGE_IAM_ISSUER",
"BRIDGE_IAM_CLIENT_ID",
"BRIDGE_IAM_CLIENT_SECRET",
"BRIDGE_MPC_WALLET_ID",
] as const) {
const saved = process.env[key]
delete process.env[key]
expect(isMPCSigningEnabled()).toBe(false)
process.env[key] = saved
}
// Sanity check — back to true after restoring.
expect(isMPCSigningEnabled()).toBe(true)
})
})
describe("mpcSignAndSend — happy path", () => {
it("resolves wallet → builds unsigned tx → signs via MPCClient → broadcasts", async () => {
mocks.getWallet.mockResolvedValueOnce({
id: "wallet-eth",
walletId: "wallet-eth",
ethAddress: "0x1234567890abcdef1234567890abcdef12345678",
keyType: "secp256k1",
protocol: "cggmp21",
})
mocks.sign.mockResolvedValueOnce({
signature: "0xabc",
r: "0x" + "11".repeat(32),
s: "0x" + "22".repeat(32),
v: 27,
})
const provider = makeProvider({
fromAddress: "0x1234567890abcdef1234567890abcdef12345678",
nonce: 7,
})
const hash = await mpcSignAndSend(
provider,
"0xaaaa0000000000000000000000000000000000aa",
"0x12345678",
0n,
)
expect(hash).toBe("0xbroadcast")
expect(mocks.getWallet).toHaveBeenCalledWith("wallet-eth")
expect(mocks.sign).toHaveBeenCalledTimes(1)
const signArg = mocks.sign.mock.calls[0]?.[0] as {
walletId: string
keyType: string
message: Uint8Array
}
expect(signArg.walletId).toBe("wallet-eth")
expect(signArg.keyType).toBe("secp256k1")
expect(signArg.message).toBeInstanceOf(Uint8Array)
expect(signArg.message.length).toBe(32) // EIP-1559 unsigned hash is keccak256 = 32 bytes
})
it("normalizes v < 27 to v + 27", async () => {
mocks.getWallet.mockResolvedValueOnce({
walletId: "wallet-eth",
id: "wallet-eth",
ethAddress: "0x1234567890abcdef1234567890abcdef12345678",
})
mocks.sign.mockResolvedValueOnce({
signature: "0x",
r: "0x" + "11".repeat(32),
s: "0x" + "22".repeat(32),
v: 0, // recovery id form (not yet offset)
})
const provider = makeProvider({
fromAddress: "0x1234567890abcdef1234567890abcdef12345678",
})
await mpcSignAndSend(
provider,
"0xaaaa0000000000000000000000000000000000aa",
"0x",
0n,
)
// The broadcast call should have happened (no throw). The v
// normalization is internal — we verify indirectly by getting
// through to broadcast.
expect(
(provider as { broadcastTransaction: { mock: unknown } })
.broadcastTransaction,
).toBeTruthy()
})
it("falls back to splitting compact 65-byte signature when r/s missing", async () => {
mocks.getWallet.mockResolvedValueOnce({
walletId: "wallet-eth",
id: "wallet-eth",
ethAddress: "0x1234567890abcdef1234567890abcdef12345678",
})
const r = "11".repeat(32)
const s = "22".repeat(32)
const v = "1b" // 27
mocks.sign.mockResolvedValueOnce({
signature: "0x" + r + s + v,
// No r/s/v fields — should split from signature.
})
const provider = makeProvider({
fromAddress: "0x1234567890abcdef1234567890abcdef12345678",
})
await mpcSignAndSend(
provider,
"0xaaaa0000000000000000000000000000000000aa",
"0x",
0n,
)
expect(mocks.sign).toHaveBeenCalledTimes(1)
// The broadcast happened, meaning split worked.
})
it("throws if compact signature is too short to split", async () => {
mocks.getWallet.mockResolvedValueOnce({
walletId: "wallet-eth",
id: "wallet-eth",
ethAddress: "0x1234567890abcdef1234567890abcdef12345678",
})
mocks.sign.mockResolvedValueOnce({
signature: "0x1234", // way too short
})
const provider = makeProvider({
fromAddress: "0x1234567890abcdef1234567890abcdef12345678",
})
await expect(
mpcSignAndSend(
provider,
"0xaaaa0000000000000000000000000000000000aa",
"0x",
),
).rejects.toThrow(/signature too short/)
})
})
describe("mpcSignAndSend — failure modes", () => {
it("throws when env block is incomplete (clean standalone error)", async () => {
delete process.env.BRIDGE_MPC_URL
_resetMpcClientForTests()
const provider = makeProvider({
fromAddress: "0x1234567890abcdef1234567890abcdef12345678",
})
await expect(
mpcSignAndSend(
provider,
"0xaaaa0000000000000000000000000000000000aa",
"0x",
),
).rejects.toThrow(/MPC signing unavailable/)
})
it("throws when BRIDGE_MPC_WALLET_ID is unset", async () => {
delete process.env.BRIDGE_MPC_WALLET_ID
const provider = makeProvider({
fromAddress: "0x1234567890abcdef1234567890abcdef12345678",
})
await expect(
mpcSignAndSend(
provider,
"0xaaaa0000000000000000000000000000000000aa",
"0x",
),
).rejects.toThrow(/BRIDGE_MPC_WALLET_ID is not set/)
})
it("throws when MPC wallet has no eth_address", async () => {
mocks.getWallet.mockResolvedValueOnce({
walletId: "wallet-eth",
id: "wallet-eth",
// No ethAddress.
})
const provider = makeProvider({
fromAddress: "0xinvalid",
})
await expect(
mpcSignAndSend(
provider,
"0xaaaa0000000000000000000000000000000000aa",
"0x",
),
).rejects.toThrow(/no eth_address/)
})
})
describe("mpcBridgeMint", () => {
it("encodes bridgeMint(recipient, amount) call data", async () => {
mocks.getWallet.mockResolvedValueOnce({
walletId: "wallet-eth",
id: "wallet-eth",
ethAddress: "0x1234567890abcdef1234567890abcdef12345678",
})
mocks.sign.mockResolvedValueOnce({
signature: "0xabc",
r: "0x" + "11".repeat(32),
s: "0x" + "22".repeat(32),
v: 27,
})
const provider = makeProvider({
fromAddress: "0x1234567890abcdef1234567890abcdef12345678",
})
const abi = [
"function bridgeMint(address recipient, uint256 amount)",
]
const recipient = "0x1111111111111111111111111111111111111111"
const amount = 1_000_000_000_000_000_000n // 1 ETH in wei
await mpcBridgeMint(
provider,
"0xaaaa0000000000000000000000000000000000bb",
recipient,
amount,
abi,
)
// sign got called with a hashed payload (32 bytes); we don't decode
// it back, but presence of the call confirms encodeFunctionData ran.
expect(mocks.sign).toHaveBeenCalledTimes(1)
})
})
describe("mpcSendNative", () => {
it("posts an empty-data tx with the requested native value", async () => {
mocks.getWallet.mockResolvedValueOnce({
walletId: "wallet-eth",
id: "wallet-eth",
ethAddress: "0x1234567890abcdef1234567890abcdef12345678",
})
mocks.sign.mockResolvedValueOnce({
signature: "0x",
r: "0x" + "11".repeat(32),
s: "0x" + "22".repeat(32),
v: 27,
})
const provider = makeProvider({
fromAddress: "0x1234567890abcdef1234567890abcdef12345678",
})
await mpcSendNative(
provider,
"0xcccc0000000000000000000000000000000000cc",
5n,
)
expect(mocks.sign).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,235 @@
// mpc-wallet tests — verifies createMPCWalletForDeposit routes through
// the canonical MPCClient (keygen + getWallet).
//
// Mocks @/clients/mpc.MPCClient. Asserts:
// - EVM chains pick cggmp21 + secp256k1 + ethAddress
// - SOL / TON / Polkadot pick frost + ed25519 + (sol|substrate) address
// - BTC picks cggmp21 + secp256k1 + btcAddress
// - XRP falls back to ethAddress (rAddress derivation TODO)
// - Substrate (DOT) derives SS58 from eddsa_pub_key
// - Returns Utila-compatible { name, addresses: [{ address }] } shape
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => ({
keygen: vi.fn(),
getWallet: vi.fn(),
}))
vi.mock("@/clients/mpc", () => ({
MPCClient: class {
keygen = mocks.keygen
getWallet = mocks.getWallet
},
}))
vi.mock("@/clients/iam", () => ({
IAMClient: class {},
}))
import {
_resetMpcWalletClientForTests,
createMPCWalletForDeposit,
} from "../mpc-wallet"
beforeEach(() => {
_resetMpcWalletClientForTests()
mocks.keygen.mockReset()
mocks.getWallet.mockReset()
process.env.BRIDGE_MPC_URL = "https://mpc.example.test"
process.env.BRIDGE_IAM_ISSUER = "https://iam.example.test"
process.env.BRIDGE_IAM_CLIENT_ID = "bridge"
process.env.BRIDGE_IAM_CLIENT_SECRET = "sec"
})
afterEach(() => {
delete process.env.BRIDGE_MPC_URL
delete process.env.BRIDGE_IAM_ISSUER
delete process.env.BRIDGE_IAM_CLIENT_ID
delete process.env.BRIDGE_IAM_CLIENT_SECRET
delete process.env.BRIDGE_MPC_VAULT_ID
// Don't vi.restoreAllMocks — the MPCClient constructor mock is set
// in vi.mock factory once at module load; restoring would null it.
})
describe("createMPCWalletForDeposit — EVM chains", () => {
it("ETHEREUM_MAINNET → cggmp21 + secp256k1 + ethAddress", async () => {
mocks.keygen.mockResolvedValueOnce({
id: "w-1",
walletId: "w-1",
vaultId: "bridge",
keyType: "secp256k1",
protocol: "cggmp21",
})
mocks.getWallet.mockResolvedValueOnce({
walletId: "w-1",
id: "w-1",
ethAddress: "0xabc1234567890abcdef1234567890abcdef12345",
ecdsaPubKey: "0x04...",
})
const out = await createMPCWalletForDeposit("ETHEREUM_MAINNET")
expect(out.name).toBe("w-1")
expect(out.addresses[0]?.address).toBe(
"0xabc1234567890abcdef1234567890abcdef12345",
)
const kgArg = mocks.keygen.mock.calls[0]?.[0] as {
protocol: string
keyType: string
vaultId: string
}
expect(kgArg.protocol).toBe("cggmp21")
expect(kgArg.keyType).toBe("secp256k1")
expect(kgArg.vaultId).toBe("bridge") // default
})
it("BASE_MAINNET + custom vault → still cggmp21 + ethAddress", async () => {
process.env.BRIDGE_MPC_VAULT_ID = "custom-vault"
_resetMpcWalletClientForTests()
mocks.keygen.mockResolvedValueOnce({
id: "w-base",
walletId: "w-base",
})
mocks.getWallet.mockResolvedValueOnce({
walletId: "w-base",
id: "w-base",
ethAddress: "0xbase00000000000000000000000000000000beef",
})
const out = await createMPCWalletForDeposit("BASE_MAINNET")
expect(out.addresses[0]?.address).toBe(
"0xbase00000000000000000000000000000000beef",
)
expect(mocks.keygen.mock.calls[0]?.[0].vaultId).toBe("custom-vault")
})
})
describe("createMPCWalletForDeposit — BTC", () => {
it("BITCOIN_MAINNET → cggmp21 + btcAddress", async () => {
mocks.keygen.mockResolvedValueOnce({
id: "w-btc",
walletId: "w-btc",
})
mocks.getWallet.mockResolvedValueOnce({
walletId: "w-btc",
id: "w-btc",
btcAddress: "bc1qxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
})
const out = await createMPCWalletForDeposit("BITCOIN_MAINNET")
expect(out.addresses[0]?.address).toBe(
"bc1qxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
)
expect(mocks.keygen.mock.calls[0]?.[0].protocol).toBe("cggmp21")
expect(mocks.keygen.mock.calls[0]?.[0].keyType).toBe("secp256k1")
})
})
describe("createMPCWalletForDeposit — ed25519 chains", () => {
it("SOLANA_MAINNET → frost + ed25519 + solAddress", async () => {
mocks.keygen.mockResolvedValueOnce({
id: "w-sol",
walletId: "w-sol",
})
mocks.getWallet.mockResolvedValueOnce({
walletId: "w-sol",
id: "w-sol",
solAddress: "SolPubKey11111111111111111111111111111111",
})
const out = await createMPCWalletForDeposit("SOLANA_MAINNET")
expect(out.addresses[0]?.address).toBe(
"SolPubKey11111111111111111111111111111111",
)
expect(mocks.keygen.mock.calls[0]?.[0].protocol).toBe("frost")
expect(mocks.keygen.mock.calls[0]?.[0].keyType).toBe("ed25519")
})
it("TON_MAINNET → frost + ed25519 + uses solAddress field", async () => {
mocks.keygen.mockResolvedValueOnce({
id: "w-ton",
walletId: "w-ton",
})
mocks.getWallet.mockResolvedValueOnce({
walletId: "w-ton",
id: "w-ton",
solAddress: "ton-ed25519-pubkey-derived-address",
})
const out = await createMPCWalletForDeposit("TON_MAINNET")
expect(out.addresses[0]?.address).toBe("ton-ed25519-pubkey-derived-address")
})
})
describe("createMPCWalletForDeposit — XRP (secp256k1 with eth fallback)", () => {
it("XRP_MAINNET → cggmp21 + ethAddress fallback (rAddress derivation TODO)", async () => {
mocks.keygen.mockResolvedValueOnce({
id: "w-xrp",
walletId: "w-xrp",
})
mocks.getWallet.mockResolvedValueOnce({
walletId: "w-xrp",
id: "w-xrp",
ethAddress: "0xxrpfallback00000000000000000000000000aaaa",
})
const out = await createMPCWalletForDeposit("XRP_MAINNET")
expect(out.addresses[0]?.address).toBe(
"0xxrpfallback00000000000000000000000000aaaa",
)
expect(mocks.keygen.mock.calls[0]?.[0].protocol).toBe("cggmp21")
})
})
describe("createMPCWalletForDeposit — Substrate (DOT)", () => {
it("POLKADOT_MAINNET → SS58 from eddsaPubKey", async () => {
mocks.keygen.mockResolvedValueOnce({
id: "w-dot",
walletId: "w-dot",
})
// 32-byte ed25519 public key (all zeros for determinism)
mocks.getWallet.mockResolvedValueOnce({
walletId: "w-dot",
id: "w-dot",
eddsaPubKey:
"0x0000000000000000000000000000000000000000000000000000000000000000",
})
const out = await createMPCWalletForDeposit("POLKADOT_MAINNET")
// SS58 encoded Polkadot address starts with '1' (network prefix 0).
expect(out.addresses[0]?.address).toMatch(/^1[A-HJ-NP-Za-km-z1-9]+$/)
expect(mocks.keygen.mock.calls[0]?.[0].protocol).toBe("frost")
})
it("Substrate throws when eddsa pubkey missing", async () => {
mocks.keygen.mockResolvedValueOnce({
id: "w-dot",
walletId: "w-dot",
})
mocks.getWallet.mockResolvedValueOnce({
walletId: "w-dot",
id: "w-dot",
// No eddsaPubKey.
})
await expect(
createMPCWalletForDeposit("POLKADOT_MAINNET"),
).rejects.toThrow(/eddsa public key/)
})
})
describe("createMPCWalletForDeposit — failure modes", () => {
it("throws cleanly when env block is incomplete", async () => {
delete process.env.BRIDGE_MPC_URL
_resetMpcWalletClientForTests()
await expect(createMPCWalletForDeposit("ETHEREUM_MAINNET")).rejects.toThrow(
/env block is incomplete/,
)
})
it("throws when getWallet returns no chain-family address", async () => {
mocks.keygen.mockResolvedValueOnce({
id: "w-bad",
walletId: "w-bad",
})
mocks.getWallet.mockResolvedValueOnce({
walletId: "w-bad",
id: "w-bad",
// No ethAddress, no btcAddress, etc.
})
await expect(createMPCWalletForDeposit("ETHEREUM_MAINNET")).rejects.toThrow(
/no eth address/,
)
})
})
File diff suppressed because it is too large Load Diff
+134 -174
View File
@@ -1,10 +1,36 @@
import { mpcService } from "../services/mpc-service" // Bridge → mpcd integration via HTTP.
import { logger } from "../utils/logger" //
import { mpcRequestSigner } from "../utils/mpc-signing" // Replaces the legacy NATS/Consul-coupled BridgeMPCIntegration with a
import Web3 from "web3" // synchronous HTTP call against the canonical mpcd daemon
import { StringCodec } from "nats" // (~/work/lux/mpc, github.com/luxfi/mpc). The bridge no longer needs
// NATS for pub/sub or Consul for shared state — mpcd's HTTP API does
// threshold signing in a single round-trip, and per-swap state lives
// in Postgres via Prisma.
//
// Public surface preserved so domain/mpc-modern.ts stays unchanged:
// bridgeMPC.initialize() — eager IAM warm-up
// bridgeMPC.generateMPCSignature(req) — threshold sign
// bridgeMPC.completeSwap(hashedTxId) — persists completion marker
// bridgeMPC.getHealthStatus() — cluster status
//
// Env (brand-neutral):
// BRIDGE_MPC_URL mpcd base URL (required for sign / status)
// BRIDGE_IAM_ISSUER OIDC issuer (required)
// BRIDGE_IAM_CLIENT_ID OAuth2 client id (required)
// BRIDGE_IAM_CLIENT_SECRET OAuth2 client secret (required, from KMS in prod)
// BRIDGE_MPC_WALLET_ID default wallet to sign with (default: bridge-settlement)
//
// Standalone-friendly: if any required env is missing, generateMPCSignature
// returns a clean error rather than crashing the process. The rest of
// the bridge (deposit ingestion, payout planning, swap-state machine)
// continues to function.
const sc = StringCodec() import Web3 from "web3"
import { IAMClient } from "@/clients/iam"
import { MPCClient } from "@/clients/mpc"
import { prisma } from "@/prisma-instance"
import logger from "@/logger"
interface BridgeSignRequest { interface BridgeSignRequest {
txId: string txId: string
@@ -32,17 +58,17 @@ interface BridgeSignResponse {
} }
/** /**
* Bridge MPC Integration — NATS-based * Bridge ↔ mpcd integration. Singleton. Lazy IAM + MPC client init so
* Publishes signing requests to NATS JetStream, MPC nodes respond via result topics. * the bridge boots cleanly when MPC env is unset (standalone mode).
*/ */
export class BridgeMPCIntegration { export class BridgeMPCIntegration {
private static instance: BridgeMPCIntegration private static instance: BridgeMPCIntegration
private isInitialized = false private isInitialized = false
private pendingSignatures: Map<string, (result: any) => void> = new Map() private iam?: IAMClient
private mpc?: MPCClient
private constructor() { private constructor() {
// Setup signature completion handler // Nothing to do until initialize() runs.
mpcService.on("signatureComplete", this.handleSignatureComplete.bind(this))
} }
static getInstance(): BridgeMPCIntegration { static getInstance(): BridgeMPCIntegration {
@@ -52,77 +78,82 @@ export class BridgeMPCIntegration {
return BridgeMPCIntegration.instance return BridgeMPCIntegration.instance
} }
/**
* Eagerly resolve env + warm the IAM cache for the `mpc` audience.
* No-op when env is unset (standalone mode); callers see a clear
* error from generateMPCSignature() instead.
*/
async initialize(): Promise<void> { async initialize(): Promise<void> {
if (this.isInitialized) return if (this.isInitialized) return
try { const mpcUrl = process.env.BRIDGE_MPC_URL
await mpcRequestSigner.loadKey() const iamIssuer = process.env.BRIDGE_IAM_ISSUER
await mpcService.initialize() const clientId = process.env.BRIDGE_IAM_CLIENT_ID
const clientSecret = process.env.BRIDGE_IAM_CLIENT_SECRET
if (!mpcUrl || !iamIssuer || !clientId || !clientSecret) {
logger.warn(
"[bridge-mpc] env unset (BRIDGE_MPC_URL / BRIDGE_IAM_*) — running in standalone mode; sign calls will reject cleanly",
)
this.isInitialized = true this.isInitialized = true
logger.info("Bridge MPC integration initialized") return
// Subscribe to MPC results now that NATS is connected
this.subscribeToMpcResults()
} catch (error) {
logger.error("Failed to initialize Bridge MPC integration:", error)
throw error
} }
this.iam = new IAMClient({ issuer: iamIssuer, clientId, clientSecret })
this.mpc = new MPCClient({ url: mpcUrl, iam: this.iam })
try {
await this.iam.mint("mpc")
const status = await this.mpc.status()
logger.info(
`[bridge-mpc] connected to ${mpcUrl} (${status.online}/${status.total} online, protocols=${status.protocols.join(",")})`,
)
} catch (err) {
logger.warn(
`[bridge-mpc] connectivity check failed (will retry on first sign): ${err instanceof Error ? err.message : String(err)}`,
)
}
this.isInitialized = true
} }
async generateMPCSignature(signData: BridgeSignRequest): Promise<BridgeSignResponse> { /**
* Threshold sign a bridge settlement message via mpcd.
*
* `signData.msgSignature` is the bridge's commitment to the
* settlement (per-tx digest). We forward it as the sign payload to
* the wallet identified by BRIDGE_MPC_WALLET_ID (default
* `bridge-settlement`). mpcd handles the threshold round internally
* and returns the final signature in one HTTP response.
*/
async generateMPCSignature(
signData: BridgeSignRequest,
): Promise<BridgeSignResponse> {
if (!signData.txId || !signData.fromNetworkId || !signData.toNetworkId) {
return { status: false, msg: "Missing required fields" }
}
await this.initialize()
if (!this.mpc) {
return {
status: false,
msg: "BRIDGE_MPC_URL is not configured — native MPC sign unavailable in standalone mode",
}
}
const walletId =
process.env.BRIDGE_MPC_WALLET_ID ?? "bridge-settlement"
const hashedTxId = Web3.utils.keccak256(signData.txId)
// Use the bridge's per-tx commitment as the sign payload. mpcd
// accepts an opaque byte payload — the signing algorithm picked at
// wallet keygen time determines how it's hashed internally.
const message = Buffer.from(
signData.msgSignature.replace(/^0x/, ""),
"hex",
)
try { try {
if (!signData.txId || !signData.fromNetworkId || !signData.toNetworkId) { const res = await this.mpc.sign({
return { status: false, msg: "Missing required fields" } walletId,
} keyType: "secp256k1",
message,
// Validate request (skip strict validation for now)
logger.info("Skipping signature validation for testing")
// Check MPC network readiness
const networkStatus = await mpcService.getNetworkStatus()
if (!networkStatus.ready) {
return {
status: false,
msg: `MPC network not ready. Only ${networkStatus.activeNodes}/${networkStatus.threshold} nodes active`,
}
}
const sessionId = `bridge-${signData.txId}-${Date.now()}`
const hashedTxId = Web3.utils.keccak256(signData.txId)
// Create promise to wait for signature result
const signaturePromise = new Promise<any>((resolve) => {
this.pendingSignatures.set(sessionId, resolve)
setTimeout(() => {
if (this.pendingSignatures.has(sessionId)) {
this.pendingSignatures.delete(sessionId)
resolve({ error: "Signature timeout" })
}
}, 60000)
}) })
// Build signing request for Go MPC nodes
const msgHash = Web3.utils.keccak256(signData.txId)
const signingRequest: any = {
key_type: "secp256k1",
wallet_id: "bridge-wallet-0",
network_internal_code: "ETH",
tx_id: signData.txId,
tx: Buffer.from(msgHash.replace("0x", ""), "hex").toString("base64"),
}
// Sign with initiator key
const signature = await mpcRequestSigner.signSigningRequest(signingRequest)
signingRequest.signature = signature
logger.info(`Signed request: ${JSON.stringify(signingRequest)}`)
// Publish to NATS
await this.publishSigningRequest(sessionId, signingRequest)
// Wait for result
const result = await signaturePromise
if (result.error) {
return { status: false, msg: result.error }
}
return { return {
status: true, status: true,
data: { data: {
@@ -130,127 +161,56 @@ export class BridgeMPCIntegration {
contract: "", contract: "",
from: "", from: "",
tokenAmount: "0", tokenAmount: "0",
signature: result.signature, signature: res.signature.startsWith("0x")
mpcSigner: result.mpcSigner || "0x" + "0".repeat(40), ? res.signature
: "0x" + res.signature,
mpcSigner: walletId,
hashedTxId, hashedTxId,
toTokenAddressHash: Web3.utils.keccak256(signData.toTokenAddress), toTokenAddressHash: Web3.utils.keccak256(signData.toTokenAddress),
vault: false, vault: false,
}, },
} }
} catch (error) { } catch (err) {
logger.error("MPC signature generation failed:", error) const msg = err instanceof Error ? err.message : String(err)
return { logger.error(`[bridge-mpc] sign failed for tx=${signData.txId}: ${msg}`)
status: false, return { status: false, msg: `MPC signature failed: ${msg}` }
msg: `MPC signature failed: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
async publishSigningRequest(sessionId: string, signedRequest: any): Promise<void> {
try {
const js = mpcService.nc.jetstream()
await js.publish(
`mpc.signing_request.${sessionId}`,
sc.encode(JSON.stringify(signedRequest))
)
} catch (pubErr) {
logger.warn("JetStream publish failed, using core NATS:", pubErr)
await mpcService.nc.publish(
`mpc.signing_request.${sessionId}`,
sc.encode(JSON.stringify(signedRequest))
)
}
logger.info(`Published signed signing request for session ${sessionId}`)
}
handleSignatureComplete(data: any): void {
const resolver = this.pendingSignatures.get(data.sessionId)
if (resolver) {
this.pendingSignatures.delete(data.sessionId)
resolver({ signature: data.signature, participants: data.participants })
logger.info(`Signature completed for session ${data.sessionId}`)
} }
} }
/** /**
* Subscribe to MPC signing result topics. * Record a completion marker for a swap. Persists to Postgres rather
* MPC nodes publish results with tx_id (not sessionId), so match by tx_id prefix. * than to Consul KV — single source of truth per HIP-0105.
*/ */
async subscribeToMpcResults(): Promise<void> {
if (!mpcService.nc) {
logger.warn("NATS not connected, cannot subscribe to MPC results")
return
}
logger.info("Subscribing to mpc.mpc_signing_result.*")
const sub = mpcService.nc.subscribe("mpc.mpc_signing_result.*")
;(async () => {
for await (const msg of sub) {
try {
const result = JSON.parse(sc.decode(msg.data))
logger.info("Received MPC signing result:", result)
// MPC result has tx_id, not sessionId. Match pending signatures by tx_id prefix.
const txId = result.tx_id
if (!txId) continue
// Find the pending signature whose sessionId starts with `bridge-${txId}-`
for (const [sessionId, resolver] of this.pendingSignatures) {
if (sessionId.startsWith(`bridge-${txId}-`)) {
this.pendingSignatures.delete(sessionId)
if (result.result_type === "success") {
// Reconstruct 65-byte Ethereum signature from r, s, recovery
const rBuf = Buffer.from(result.r, "base64")
const sBuf = Buffer.from(result.s, "base64")
const vBuf = result.signature_recovery
? Buffer.from(result.signature_recovery, "base64")
: Buffer.from([27])
const v = vBuf[0] < 27 ? vBuf[0] + 27 : vBuf[0]
const sigHex =
"0x" +
rBuf.toString("hex") +
sBuf.toString("hex") +
v.toString(16).padStart(2, "0")
resolver({ signature: sigHex })
} else {
resolver({
error: result.error_reason || "MPC signing failed",
})
}
break
}
}
} catch (error) {
logger.error("Error processing MPC result:", error)
}
}
})()
}
async completeSwap( async completeSwap(
hashedTxId: string hashedTxId: string,
): Promise<{ status: boolean; msg: string }> { ): Promise<{ status: boolean; msg: string }> {
try { try {
await mpcService.consul.kv.set({ await prisma.swap.updateMany({
Key: `bridge/transactions/${hashedTxId}/completed`, where: { id: hashedTxId },
Value: JSON.stringify({ data: { metadata_sequence_number: Date.now() },
timestamp: Date.now(),
nodeId: mpcService.config.nodeId,
}),
}) })
return { status: true, msg: "success" } return { status: true, msg: "success" }
} catch (error) { } catch (err) {
logger.error("Failed to complete swap:", error) logger.error(`[bridge-mpc] completeSwap failed for ${hashedTxId}:`, err)
return { return {
status: false, status: false,
msg: error instanceof Error ? error.message : String(error), msg: err instanceof Error ? err.message : String(err),
} }
} }
} }
async getHealthStatus(): Promise<any> { /** Cluster status — proxies through MPCClient when configured. */
return mpcService.getNetworkStatus() async getHealthStatus(): Promise<{
online: number
total: number
protocols: string[]
standalone?: boolean
}> {
await this.initialize()
if (!this.mpc) {
return { online: 0, total: 0, protocols: [], standalone: true }
}
return this.mpc.status()
} }
} }
+133 -90
View File
@@ -1,28 +1,67 @@
import { JsonRpcProvider, Transaction, parseUnits, Interface, keccak256, getBytes } from "ethers" // EVM signer wired through the canonical MPCClient.
import axios from "axios" //
// Replaces the legacy axios/polling-against-/api/v1/transactions code
// path with a single MPCClient.sign() round-trip plus a getWallet()
// for address lookup. All bridge → mpcd traffic now flows through the
// same wire protocol as ~/work/lux/kms/pkg/mpc/client.go.
//
// Env (brand-neutral):
// BRIDGE_MPC_URL required (mpcd base URL)
// BRIDGE_IAM_ISSUER required (auth)
// BRIDGE_IAM_CLIENT_ID required (auth)
// BRIDGE_IAM_CLIENT_SECRET required (auth — from KMS in prod)
// BRIDGE_MPC_WALLET_ID EVM signer wallet id (required to enable)
//
// Standalone-friendly: isMPCSigningEnabled() returns false when the env
// block is incomplete; callers fall back to LUX_SIGNER (legacy private
// key) as documented in domain/swaps.ts. No crashes at module load.
const MPC_API_URL = process.env.MPC_API_URL || "https://mpc.lux.network" import {
const MPC_API_TOKEN = process.env.MPC_API_TOKEN || "" JsonRpcProvider,
const MPC_WALLET_ID = process.env.MPC_WALLET_ID || "" Transaction,
Interface,
getBytes,
} from "ethers"
interface MPCSignResult { import { IAMClient } from "@/clients/iam"
r: string import { MPCClient } from "@/clients/mpc"
s: string import logger from "@/logger"
signature?: string
// Lazily-built singleton MPC client. Env is read on first call so test
// fixtures can mutate process.env without import-order issues.
let _mpcClient: MPCClient | undefined
function getMpcClient(): MPCClient | undefined {
if (_mpcClient) return _mpcClient
const url = process.env.BRIDGE_MPC_URL
const issuer = process.env.BRIDGE_IAM_ISSUER
const clientId = process.env.BRIDGE_IAM_CLIENT_ID
const clientSecret = process.env.BRIDGE_IAM_CLIENT_SECRET
if (!url || !issuer || !clientId || !clientSecret) return undefined
const iam = new IAMClient({ issuer, clientId, clientSecret })
_mpcClient = new MPCClient({ url, iam })
return _mpcClient
} }
const mpcHeaders = () => ({ /** Test hook — drop the cached client so a fresh env config rebuilds it. */
Authorization: `Bearer ${MPC_API_TOKEN}`, export function _resetMpcClientForTests(): void {
"Content-Type": "application/json", _mpcClient = undefined
}) }
/** /**
* Sign and broadcast an EVM contract call via MPC. * Sign and broadcast an EVM contract call via the canonical MPCClient.
* 1. Build unsigned tx (to, data, gas, nonce, chainId) *
* 2. Serialize the unsigned tx hash * Flow:
* 3. POST to MPC API /transactions with raw_tx = unsigned tx hash * 1. Resolve the bridge's settlement wallet (MPCClient.getWallet) to
* 4. Get back r, s signature components * get its ETH address.
* 5. Attach signature to tx and broadcast via provider * 2. Build an unsigned EIP-1559 transaction with the right nonce
* against `provider`.
* 3. Compute the unsigned tx hash bytes.
* 4. Call MPCClient.sign — one HTTP round-trip; mpcd handles the
* threshold round internally and returns r/s/v.
* 5. Attach the signature and broadcast.
*
* Compared to the previous /api/v1/transactions + polling implementation
* this is ~2× shorter and one network round-trip instead of N polls.
*/ */
export async function mpcSignAndSend( export async function mpcSignAndSend(
provider: JsonRpcProvider, provider: JsonRpcProvider,
@@ -30,22 +69,33 @@ export async function mpcSignAndSend(
data: string, data: string,
value: bigint = 0n, value: bigint = 0n,
): Promise<string> { ): Promise<string> {
const feeData = await provider.getFeeData() const client = getMpcClient()
const network = await provider.getNetwork() if (!client) {
throw new Error(
// Get nonce for the MPC wallet address "MPC signing unavailable: BRIDGE_MPC_URL / BRIDGE_IAM_* env block is incomplete",
const walletInfo = await axios.get( )
`${MPC_API_URL}/api/v1/wallets/${MPC_WALLET_ID}`, }
{ headers: mpcHeaders() } const walletId = process.env.BRIDGE_MPC_WALLET_ID
) if (!walletId) {
const fromAddress = walletInfo.data.ethAddress || walletInfo.data.eth_address throw new Error(
if (!fromAddress) { "MPC signing unavailable: BRIDGE_MPC_WALLET_ID is not set",
throw new Error(`MPC wallet ${MPC_WALLET_ID} has no ETH address`) )
} }
// 1. Wallet → ETH address.
const wallet = await client.getWallet(walletId)
const fromAddress = wallet.ethAddress
if (!fromAddress) {
throw new Error(
`MPC wallet ${walletId} has no eth_address (keygen incomplete?)`,
)
}
// 2. Unsigned tx.
const feeData = await provider.getFeeData()
const network = await provider.getNetwork()
const nonce = await provider.getTransactionCount(fromAddress) const nonce = await provider.getTransactionCount(fromAddress)
// Build unsigned transaction
const tx = Transaction.from({ const tx = Transaction.from({
type: 2, // EIP-1559 type: 2, // EIP-1559
to, to,
@@ -55,87 +105,72 @@ export async function mpcSignAndSend(
chainId: network.chainId, chainId: network.chainId,
maxFeePerGas: feeData.maxFeePerGas, maxFeePerGas: feeData.maxFeePerGas,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas, maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
gasLimit: 300000n, gasLimit: 300_000n,
}) })
// Get the unsigned tx hash to sign // 3. Hash to sign.
const unsignedHash = tx.unsignedHash const unsignedHashBytes = getBytes(tx.unsignedHash)
const rawTxHex = Buffer.from(getBytes(unsignedHash)).toString("hex")
// Submit to MPC API for signing // 4. Threshold sign — single round-trip.
const signResp = await axios.post( const result = await client.sign({
`${MPC_API_URL}/api/v1/transactions`, walletId,
{ keyType: "secp256k1",
wallet_id: MPC_WALLET_ID, message: unsignedHashBytes,
tx_type: "bridge_payout", })
chain: `eip155:${network.chainId}`,
to_address: to,
raw_tx: rawTxHex,
},
{ headers: mpcHeaders() }
)
const txRecord = signResp.data // 5. Attach signature + broadcast.
let r = result.r
// Poll for signature completion (MPC signing is async) let s = result.s
let signed = txRecord let v = result.v
for (let i = 0; i < 30; i++) { if (!r || !s) {
if (signed.status === "signed" || signed.status === "confirmed") break // Some mpcd response shapes return the compact 65-byte signature in
if (signed.status === "failed") throw new Error("MPC signing failed") // result.signature (hex). Split it manually as a fallback.
await new Promise((r) => setTimeout(r, 2000)) const sigHex = result.signature.replace(/^0x/, "")
const poll = await axios.get( if (sigHex.length < 130) {
`${MPC_API_URL}/api/v1/transactions/${signed.id}`, throw new Error(
{ headers: mpcHeaders() } `MPC sign returned signature too short to split (got ${sigHex.length} hex chars; expected ≥130 for r||s||v)`,
) )
signed = poll.data }
r = "0x" + sigHex.slice(0, 64)
s = "0x" + sigHex.slice(64, 128)
v = parseInt(sigHex.slice(128, 130), 16)
} }
// EIP-155: v should be 27 or 28 (or chainId*2 + 35/36, but tx.signature
// accepts the raw 27/28 form and ethers handles the rest at serialize).
if (typeof v !== "number") v = 27
if (v < 27) v += 27
if (signed.status !== "signed" && signed.status !== "confirmed") {
throw new Error(`MPC signing timed out (status: ${signed.status})`)
}
// Reconstruct signature
const rHex = signed.signatureR || signed.signature_r
const sHex = signed.signatureS || signed.signature_s
if (!rHex || !sHex) {
throw new Error("MPC response missing r/s signature components")
}
// Attach signature to tx
const rBuf = Buffer.from(rHex, "hex")
const sBuf = Buffer.from(sHex, "hex")
// Determine v (recovery id) — try both 0 and 1
tx.signature = { tx.signature = {
r: "0x" + rBuf.toString("hex"), r: r.startsWith("0x") ? r : "0x" + r,
s: "0x" + sBuf.toString("hex"), s: s.startsWith("0x") ? s : "0x" + s,
v: 0, v,
} }
// Broadcast const broadcast = await provider.broadcastTransaction(tx.serialized)
const signedTxHex = tx.serialized await broadcast.wait()
const broadcastResult = await provider.broadcastTransaction(signedTxHex) logger.info(
await broadcastResult.wait() `[mpc-signer] broadcast tx=${broadcast.hash} from=${fromAddress} via mpcd wallet=${walletId}`,
)
return broadcastResult.hash return broadcast.hash
} }
/** /**
* Sign and send a bridgeMint call via MPC. * Sign and send a `bridgeMint(recipient, amount)` call via MPC.
*/ */
export async function mpcBridgeMint( export async function mpcBridgeMint(
provider: JsonRpcProvider, provider: JsonRpcProvider,
tokenAddress: string, tokenAddress: string,
recipient: string, recipient: string,
amount: bigint, amount: bigint,
abi: any[], abi: unknown[],
): Promise<string> { ): Promise<string> {
const iface = new Interface(abi) const iface = new Interface(abi as ConstructorParameters<typeof Interface>[0])
const data = iface.encodeFunctionData("bridgeMint", [recipient, amount]) const data = iface.encodeFunctionData("bridgeMint", [recipient, amount])
return mpcSignAndSend(provider, tokenAddress, data) return mpcSignAndSend(provider, tokenAddress, data)
} }
/** /**
* Send native token (LUX/ZOO) via MPC signer. * Send native token via MPC signer.
*/ */
export async function mpcSendNative( export async function mpcSendNative(
provider: JsonRpcProvider, provider: JsonRpcProvider,
@@ -146,8 +181,16 @@ export async function mpcSendNative(
} }
/** /**
* Check if MPC signing is configured and available. * MPC signing is enabled iff the full BRIDGE_MPC_* + BRIDGE_IAM_* env
* block + BRIDGE_MPC_WALLET_ID are all set. Callers branch on this to
* fall back to LUX_SIGNER (legacy private key) when running standalone.
*/ */
export function isMPCSigningEnabled(): boolean { export function isMPCSigningEnabled(): boolean {
return !!(MPC_API_URL && MPC_API_TOKEN && MPC_WALLET_ID) return !!(
process.env.BRIDGE_MPC_URL &&
process.env.BRIDGE_IAM_ISSUER &&
process.env.BRIDGE_IAM_CLIENT_ID &&
process.env.BRIDGE_IAM_CLIENT_SECRET &&
process.env.BRIDGE_MPC_WALLET_ID
)
} }
+122 -101
View File
@@ -1,13 +1,40 @@
import logger from "@/logger" import logger from "@/logger"
import { IAMClient } from "@/clients/iam"
import { MPCClient } from "@/clients/mpc"
import { encodeSubstrateAddress, SUBSTRATE_NETWORKS } from "./substrate-address.js" import { encodeSubstrateAddress, SUBSTRATE_NETWORKS } from "./substrate-address.js"
import { checkSubstrateDeposit } from "./substrate-deposit.js" import { checkSubstrateDeposit } from "./substrate-deposit.js"
/** /**
* Native MPC wallet integration for Lux Bridge. * Native MPC wallet integration for the bridge.
* Replaces Utila/Fireblocks — uses our own MPC API to create wallets *
* and derives addresses for all supported chains from a single keygen. * createMPCWalletForDeposit goes through the canonical MPCClient
* (~/work/lux/mpc daemon HTTP API) so per-bridge MPC code is fully
* eliminated. Deposit checking continues to hit per-chain blockchain
* RPCs directly (no MPC-side dependency).
*
* Env (brand-neutral):
* BRIDGE_MPC_URL required for keygen
* BRIDGE_IAM_ISSUER + IAM_CLIENT_ID + IAM_CLIENT_SECRET required (auth)
* BRIDGE_MPC_VAULT_ID vault to create wallets under (default "bridge")
*/ */
let _mpcClient: MPCClient | undefined
function getMpcClient(): MPCClient | undefined {
if (_mpcClient) return _mpcClient
const url = process.env.BRIDGE_MPC_URL
const issuer = process.env.BRIDGE_IAM_ISSUER
const clientId = process.env.BRIDGE_IAM_CLIENT_ID
const clientSecret = process.env.BRIDGE_IAM_CLIENT_SECRET
if (!url || !issuer || !clientId || !clientSecret) return undefined
const iam = new IAMClient({ issuer, clientId, clientSecret })
_mpcClient = new MPCClient({ url, iam })
return _mpcClient
}
export function _resetMpcWalletClientForTests(): void {
_mpcClient = undefined
}
// Network type to address field mapping // Network type to address field mapping
const NETWORK_ADDRESS_TYPE: Record<string, 'eth' | 'btc' | 'sol' | 'ton' | 'xrp' | 'dot'> = { const NETWORK_ADDRESS_TYPE: Record<string, 'eth' | 'btc' | 'sol' | 'ton' | 'xrp' | 'dot'> = {
// EVM chains use eth address // EVM chains use eth address
@@ -55,121 +82,115 @@ const NETWORK_ADDRESS_TYPE: Record<string, 'eth' | 'btc' | 'sol' | 'ton' | 'xrp'
CARDANO_MAINNET: 'sol', CARDANO_MAINNET: 'sol',
} }
interface MPCKeygenResult { // The MPCClient.keygen / getWallet response shapes are defined in
wallet_id: string // clients/mpc.ts. This module just consumes them; no per-bridge type
ecdsa_pub_key: string // duplication.
eddsa_pub_key: string
eth_address: string
btc_address: string
sol_address: string
result_type?: string
error?: string
}
interface MPCWalletResult {
walletId: string
address: string
ethAddress: string
btcAddress: string
solAddress: string
ecdsaPubKey: string
eddsaPubKey: string
}
function getMpcApiUrl(): string {
// Use internal K8s service URL or env override
return process.env.MPC_API_URL || 'http://mpc-node-0.mpc-node-headless.lux-mpc.svc:9800'
}
function getMpcDashboardUrl(): string {
return process.env.MPC_DASHBOARD_URL || 'http://mpc-api-svc.lux-mpc.svc:8081'
}
/** /**
* Create a new MPC wallet for a bridge deposit. * Create a new MPC wallet for a bridge deposit via the canonical
* Triggers keygen on our MPC cluster and returns the appropriate address * MPCClient. Returns the network-appropriate address in the
* for the given network. * legacy Utila-compatible `{ name, addresses: [{ address }] }` shape
* so existing call-sites in domain/swaps.ts stay unchanged.
*
* Picks the chain-family-appropriate address from the keygen result:
* eth → ECDSA derived (EVM, XRPL secp256k1)
* btc → secp256k1 derived (P2WPKH default)
* sol/ton → ed25519 derived
* dot → SS58-encoded ed25519 public key
*/ */
export async function createMPCWalletForDeposit(networkInternalName: string): Promise<{ export async function createMPCWalletForDeposit(
networkInternalName: string,
): Promise<{
name: string name: string
addresses: { address: string }[] addresses: { address: string }[]
}> { }> {
const mpcUrl = getMpcApiUrl() const client = getMpcClient()
const walletId = `bridge-${networkInternalName.toLowerCase()}-${Date.now()}` if (!client) {
throw new Error(
"MPC wallet keygen unavailable: BRIDGE_MPC_URL / BRIDGE_IAM_* env block is incomplete",
)
}
const vaultId = process.env.BRIDGE_MPC_VAULT_ID || "bridge"
const walletName = `bridge-${networkInternalName.toLowerCase()}-${Date.now()}`
const addrType = NETWORK_ADDRESS_TYPE[networkInternalName] || "eth"
logger.info(`Creating MPC wallet for deposit on ${networkInternalName}`, { walletId, mpcUrl }) // Pick the right keygen protocol/curve for the destination chain.
// m-chain supports CGGMP21 (secp256k1) and FROST (ed25519); the bridge
// picks per-chain-family at keygen time. Each wallet record holds the
// protocol; the bridge does NOT mix curves on one wallet.
const protocol = addrType === "sol" || addrType === "ton" || addrType === "dot"
? "frost"
: "cggmp21"
const keyType = addrType === "sol" || addrType === "ton" || addrType === "dot"
? "ed25519"
: "secp256k1"
try { logger.info(
const resp = await fetch(`${mpcUrl}/keygen`, { `[mpc-wallet] keygen for ${networkInternalName} (vault=${vaultId}, name=${walletName}, protocol=${protocol})`,
method: 'POST', )
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ wallet_id: walletId }),
signal: AbortSignal.timeout(120_000),
})
if (!resp.ok) { const result = await client.keygen({
const errBody = await resp.text() vaultId,
throw new Error(`MPC keygen failed (${resp.status}): ${errBody}`) name: walletName,
} keyType,
protocol,
})
const result: MPCKeygenResult = await resp.json() // Fetch the wallet to resolve the per-chain-family address. keygen
// returns the protocol-level wallet record; getWallet returns the
// chain-family derived addresses.
const wallet = await client.getWallet(result.walletId)
if (result.result_type && result.result_type !== 'success') { let address: string | undefined
throw new Error(`MPC keygen error: ${result.error || 'unknown'}`) switch (addrType) {
} case "btc":
address = wallet.btcAddress
// Pick the right address for this network break
const addrType = NETWORK_ADDRESS_TYPE[networkInternalName] || 'eth' case "sol":
let address: string case "ton":
switch (addrType) { address = wallet.solAddress
case 'btc': break
address = result.btc_address case "xrp":
break // XRP uses secp256k1 but with rAddress derivation. mpcd doesn't
case 'sol': // currently return rAddress, so use ETH address as the wallet
case 'ton': // identifier; bridge-side derivation should be added when XRPL
address = result.sol_address // settlement ships.
break address = wallet.ethAddress
case 'xrp': break
// XRP uses secp256k1 but with different address derivation case "dot": {
// For now, use the ETH address as an identifier; // SS58-encode the ed25519 public key.
// proper rAddress derivation should be added const pubKeyHex = wallet.eddsaPubKey
address = result.eth_address if (!pubKeyHex) {
break throw new Error(
case 'dot': { "MPC keygen did not return eddsa public key for Substrate address derivation",
// Derive SS58 address from sr25519/ed25519 public key
// MPC keygen returns eddsa_pub_key which is ed25519 — usable for SS58
const pubKeyHex = result.eddsa_pub_key
if (!pubKeyHex) {
throw new Error('MPC keygen did not return eddsa_pub_key for Substrate address derivation')
}
const pubKeyBytes = new Uint8Array(
Buffer.from(pubKeyHex.startsWith('0x') ? pubKeyHex.slice(2) : pubKeyHex, 'hex')
) )
address = encodeSubstrateAddress(pubKeyBytes, SUBSTRATE_NETWORKS.POLKADOT)
break
} }
default: const pubKeyBytes = new Uint8Array(
address = result.eth_address Buffer.from(
pubKeyHex.startsWith("0x") ? pubKeyHex.slice(2) : pubKeyHex,
"hex",
),
)
address = encodeSubstrateAddress(pubKeyBytes, SUBSTRATE_NETWORKS.POLKADOT)
break
} }
default:
address = wallet.ethAddress
}
if (!address) { if (!address) {
throw new Error(`No ${addrType} address returned from MPC keygen`) throw new Error(
} `MPC keygen returned no ${addrType} address for ${networkInternalName} (wallet=${result.walletId})`,
)
}
logger.info(`MPC wallet created for ${networkInternalName}`, { logger.info(
walletId: result.wallet_id, `[mpc-wallet] created wallet=${result.walletId} address=${address} on ${networkInternalName}`,
address, )
addrType,
})
// Return in Utila-compatible format: name###address return {
return { name: result.walletId,
name: result.wallet_id, addresses: [{ address }],
addresses: [{ address }],
}
} catch (error) {
logger.error(`Failed to create MPC wallet for ${networkInternalName}`, { error })
throw error
} }
} }
+52
View File
@@ -13,6 +13,10 @@ import { createMPCWalletForDeposit, checkNativeDeposit, archiveMPCWallet, NETWOR
import { isMPCSigningEnabled, mpcBridgeMint, mpcSendNative } from "./mpc-signer" import { isMPCSigningEnabled, mpcBridgeMint, mpcSendNative } from "./mpc-signer"
import type { CosignerIntent } from "./cosigners" import type { CosignerIntent } from "./cosigners"
import {
dispatchCosignersForSwap,
persistCosignerIntents,
} from "./cosigners"
export interface SwapData { export interface SwapData {
amount: number amount: number
@@ -134,6 +138,15 @@ export async function handleSwapCreation(data: SwapData) {
} }
}) })
// Persist layered cosigner intents (issue #386). One CosignerStep row
// per intent at status="pending". The post-native-sign hook
// (handlerUpdateMpcSignAction) reads them back via
// dispatchCosignersForSwap and updates each row with its terminal
// status before allowing the swap to advance to broadcasting.
if (cosigners && cosigners.length > 0) {
await persistCosignerIntents(swap.id, cosigners)
}
// native currency for fee pay // native currency for fee pay
const nativeCurrency = await prisma.currency.findFirst({ const nativeCurrency = await prisma.currency.findFirst({
where: { where: {
@@ -904,6 +917,45 @@ export async function handlerUpdateMpcSignAction(id: string, txHash: string, amo
} }
} }
}) })
// Layered cosigner gate (issue #386). After native MPC has produced
// a signature, every configured external cosigner (Utila /
// Fireblocks) must also approve before we advance the swap. If any
// listed cosigner rejects or fails, the swap is marked Failed with
// a user-visible fail_reason and we return early — the user-payout
// step is NEVER reached without 2-of-2 (native + every listed
// cosigner). For native-only swaps (cosigner_steps row count = 0),
// dispatchCosignersForSwap returns "no_steps" instantly and the
// existing code path runs unchanged.
const cosignVerdict = await dispatchCosignersForSwap(id, /*nativeSignature*/ txHash, txHash)
if (cosignVerdict.aggregate === "any_rejected" || cosignVerdict.aggregate === "any_failed") {
const failReason = `Layered cosigner ${cosignVerdict.aggregate === "any_rejected" ? "rejection" : "failure"}: ${cosignVerdict.failingReasons.join(" | ")}`
logger.warn(`[swaps] swap=${id} ${failReason}`)
await prisma.swap.update({
where: { id },
data: {
status: SwapStatus.Failed,
fail_reason: failReason,
transactions: {
connect: { id: transaction.id },
},
},
})
return prisma.swap.findUnique({
where: { id },
include: {
source_network: true,
source_asset: true,
destination_network: true,
destination_asset: true,
deposit_actions: true,
quotes: true,
transactions: { include: { currency: true, network: true } },
cosigner_steps: true,
},
})
}
await prisma.swap.update({ await prisma.swap.update({
where: { id }, where: { id },
data: { data: {
+1 -1
View File
@@ -25,7 +25,7 @@ router.get("/", async (req: Request, res: Response) => {
// method: GET and it's public // method: GET and it's public
router.get("/:transaction_has", async (req: Request, res: Response) => { router.get("/:transaction_has", async (req: Request, res: Response) => {
try { try {
const transaction_has = req.params.transaction_has const transaction_has = req.params.transaction_has as string
const result = await getHasBySwaps(transaction_has) const result = await getHasBySwaps(transaction_has)
console.log("🚀 ~ result:", result) console.log("🚀 ~ result:", result)
res.status(200).json({ data: result }) res.status(200).json({ data: result })
+1 -1
View File
@@ -10,7 +10,7 @@ router.get(
"/from/:fromAsset/to/:toAsset", "/from/:fromAsset/to/:toAsset",
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
try { try {
const { fromAsset, toAsset } = req.params const { fromAsset, toAsset } = req.params as { fromAsset: string; toAsset: string }
const version = req.query.version const version = req.query.version
res.status(200).json({ res.status(200).json({
+11 -6
View File
@@ -11,12 +11,17 @@ router.get(
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
try { try {
const { const {
source_network, source_network,
source_asset, source_asset,
destination_network, destination_network,
destination_asset destination_asset
} = req.params; } = req.params as {
source_network: string
source_asset: string
destination_network: string
destination_asset: string
};
const { const {
amount, amount,
+2 -2
View File
@@ -223,7 +223,7 @@ router.put("/expire/:swapId", async (req: Request, res: Response) => {
// method: PUT and it's public // method: PUT and it's public
router.get("/payout/:swapId", async (req: Request, res: Response) => { router.get("/payout/:swapId", async (req: Request, res: Response) => {
try { try {
const swapId = req.params.swapId const swapId = req.params.swapId as string
const data = await handlerUtilaPayoutAction(swapId) const data = await handlerUtilaPayoutAction(swapId)
res.status(200).json(data) res.status(200).json(data)
} catch (err: any) { } catch (err: any) {
@@ -237,7 +237,7 @@ router.get("/payout/:swapId", async (req: Request, res: Response) => {
// method: PUT and it's public // method: PUT and it's public
router.get("/deposit-check/:swapId", async (req: Request, res: Response) => { router.get("/deposit-check/:swapId", async (req: Request, res: Response) => {
try { try {
const swapId = req.params.swapId const swapId = req.params.swapId as string
await handlerCheckDeposit(swapId) await handlerCheckDeposit(swapId)
res.status(200).json({ msg: "success" }) res.status(200).json({ msg: "success" })
} catch (err: any) { } catch (err: any) {
+1 -1
View File
@@ -8,7 +8,7 @@ const router: Router = Router()
// method: GET and it's public // method: GET and it's public
router.get("/price/:token_id", async (req: Request, res: Response) => { router.get("/price/:token_id", async (req: Request, res: Response) => {
try { try {
const token_id = req.params.token_id const token_id = req.params.token_id as string
const price = await getTokenPrice(token_id) const price = await getTokenPrice(token_id)
res.status(200).json({ data: { res.status(200).json({ data: {
asset: token_id, asset: token_id,
-55
View File
@@ -1,55 +0,0 @@
import { connect, StringCodec } from "nats"
const sc = StringCodec()
async function testSigning() {
try {
const nc = await connect({ servers: "nats://localhost:4223" })
console.log("Connected to NATS at localhost:4223")
const sessionId = `test-${Date.now()}`
const request = {
sessionId,
txId: "0x10f0b50717e880325d96f75bd7f237b511e3601eabe1fe78c6469f67cbda23a7",
fromNetworkId: "1",
toNetworkId: "56",
toTokenAddress: "0x0000000000000000000000000000000000000000",
msgSignature: "0x82f45522308949c25cad1d1fe3003213f85a72c15ce8757a32063195da2770126d7bb674246013af1066372c83f6afa4f2eaee8f389ab305418eb939192e83cd79",
receiverAddressHash: "0x21f9cf6aab4b051de0aa1a6abcc3bc59761a9a622f9951291091fe7c9646ad41",
nonce: 2,
tokenAmount: "0",
decimals: 18,
vault: false,
timestamp: Date.now(),
}
// Subscribe to results first
const sub = nc.subscribe("mpc.mpc_signing_result.*")
console.log("Subscribed to mpc.mpc_signing_result.*")
// Publish the signing request
const topic = `mpc.signing_request.${sessionId}`
await nc.publish(topic, sc.encode(JSON.stringify(request)))
console.log(`Published signing request to ${topic}`)
// Wait for result
console.log("Waiting for signing result...")
const timeout = setTimeout(() => {
console.log("Timeout waiting for result")
nc.close()
process.exit(1)
}, 30000)
for await (const msg of sub) {
console.log("Received result:", sc.decode(msg.data))
clearTimeout(timeout)
nc.close()
process.exit(0)
}
} catch (error) {
console.error("Error:", error)
process.exit(1)
}
}
testSigning()
-45
View File
@@ -1,45 +0,0 @@
import { connect, StringCodec } from "nats"
const sc = StringCodec()
async function triggerKeyGen() {
try {
// Connect to NATS
const nc = await connect({ servers: "nats://localhost:4223" })
console.log("Connected to NATS")
// Create key generation request
const keygenRequest = {
sessionId: `keygen-bridge-${Date.now()}`,
keyId: "bridge-key",
protocol: "ecdsa",
threshold: 2,
parties: 3,
curve: "secp256k1",
timestamp: Date.now()
}
console.log("Sending keygen request:", keygenRequest)
// Publish to the keygen request subject
await nc.publish(
"mpc.keygen_request.bridge",
sc.encode(JSON.stringify(keygenRequest))
)
console.log("Keygen request published")
// Wait a bit for processing
await new Promise(resolve => setTimeout(resolve, 2000))
// Close connection
await nc.close()
console.log("Done")
} catch (error) {
console.error("Error:", error)
process.exit(1)
}
}
triggerKeyGen()
+13 -11
View File
@@ -18,7 +18,6 @@ import rate from "@/routes/rate"
// import utila from "@/routes/utila" // import utila from "@/routes/utila"
import networks from "@/routes/networks" import networks from "@/routes/networks"
import exchanges from "@/routes/exchanges" import exchanges from "@/routes/exchanges"
import { mpcService } from "@/services/mpc-service"
import { bridgeMPC } from "@/domain/mpc-bridge" import { bridgeMPC } from "@/domain/mpc-bridge"
import { startTeleportProcessor } from "@/domain/teleport-processor" import { startTeleportProcessor } from "@/domain/teleport-processor"
@@ -107,20 +106,22 @@ try {
}) })
}) })
// Health endpoint // Health endpoint — bridge plus optional MPC status. Never fails the
// probe when MPC is unconfigured (standalone mode); just reports
// mpc.standalone=true.
app.get("/health", async (req: Request, res: Response) => { app.get("/health", async (req: Request, res: Response) => {
try { try {
const mpcStatus = await mpcService.getNetworkStatus() const mpcStatus = await bridgeMPC.getHealthStatus()
res.json({ res.json({
status: "healthy", status: "healthy",
mpc: mpcStatus, mpc: mpcStatus,
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}) })
} catch (error) { } catch (error) {
res.status(503).json({ res.status(503).json({
status: "unhealthy", status: "unhealthy",
error: error instanceof Error ? error.message : "Unknown error", error: error instanceof Error ? error.message : "Unknown error",
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}) })
} }
}) })
@@ -148,15 +149,16 @@ try {
app.listen(port, async () => { app.listen(port, async () => {
logger.info(`>> Server Is Running On Port ${port}`) logger.info(`>> Server Is Running On Port ${port}`)
// Initialize MPC signing service (NATS-based, used for bridge getsig endpoint) // Initialize MPC integration (HTTP to mpcd — no NATS, no Consul).
// Wallet creation uses HTTP to MPC API directly via mpc-wallet.ts // Standalone-friendly: when BRIDGE_MPC_URL is unset, initialize()
// logs a warn and the rest of the server runs normally; sign calls
// reject cleanly with a clear error.
try { try {
logger.info("Initializing MPC signing service...") logger.info("Initializing MPC integration (HTTP)...")
await mpcService.initialize()
await bridgeMPC.initialize() await bridgeMPC.initialize()
logger.info("MPC signing service initialized successfully") logger.info("MPC integration initialized")
} catch (error) { } catch (error) {
logger.warn("MPC signing service initialization failed (wallet creation still works via HTTP):", error) logger.warn("MPC initialization soft-failed (will retry on first sign):", error)
} }
// Start background processor for teleporter swaps stuck in TeleportProcessPending // Start background processor for teleporter swaps stuck in TeleportProcessPending
-499
View File
@@ -1,499 +0,0 @@
import { EventEmitter } from "events"
import { connect, StringCodec, NatsConnection, Subscription } from "nats"
import Consul from "consul"
import crypto from "crypto"
import Web3 from "web3"
import { logger } from "../utils/logger"
const sc = StringCodec()
interface MPCConfig {
nodeId: string
natsUrl: string
consulUrl: string
threshold: number
totalNodes: number
keyPrefix: string
}
interface SignRequest {
txId: string
fromNetworkId: string
toNetworkId: string
toTokenAddress: string
msgSignature: string
receiverAddressHash: string
tokenAmount: string
decimals: number
vault: boolean
}
interface SignatureShare {
sessionId: string
nodeId: string
share: string
timestamp: number
}
/**
* Modern MPC Service for Lux Bridge
* Uses NATS for communication, Consul for service discovery and key storage
*/
export class MPCService extends EventEmitter {
// Make these public for bridge access
public nc!: NatsConnection
public consul!: Consul
public config: MPCConfig
private nodePrivateKey: crypto.KeyObject
private nodePublicKey: string
private isInitialized: boolean = false
constructor(config: Partial<MPCConfig> = {}) {
super()
this.config = {
nodeId: process.env.NODE_ID || `mpc-node-${Math.random().toString(36).substring(7)}`,
natsUrl: process.env.NATS_URL || "nats://localhost:4222",
consulUrl: process.env.CONSUL_URL || "http://localhost:8500",
threshold: parseInt(process.env.MPC_THRESHOLD || "2"),
totalNodes: parseInt(process.env.MPC_TOTAL_NODES || "3"),
keyPrefix: process.env.MPC_KEY_PREFIX || "bridge/mpc",
...config
}
// Generate node keypair for authentication
const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519")
this.nodePrivateKey = privateKey
this.nodePublicKey = publicKey.export({ type: "spki", format: "pem" }).toString()
}
/**
* Initialize the MPC service
*/
async initialize(): Promise<void> {
if (this.isInitialized) return
try {
// Connect to NATS
this.nc = await connect({
servers: this.config.natsUrl,
name: this.config.nodeId,
})
logger.info(`Connected to NATS at ${this.config.natsUrl}`)
// Initialize Consul client
this.consul = new Consul({
host: new URL(this.config.consulUrl).hostname,
port: new URL(this.config.consulUrl).port || "8500",
promisify: true,
})
// Register this node with Consul
await this.registerNode()
// Setup message handlers
await this.setupHandlers()
// Initialize or recover key shares
await this.initializeKeyShares()
this.isInitialized = true
logger.info(`MPC Service initialized for node ${this.config.nodeId}`)
} catch (error) {
logger.error("Failed to initialize MPC service:", error)
throw error
}
}
/**
* Register this node with Consul for service discovery
*/
private async registerNode(): Promise<void> {
const service = {
ID: this.config.nodeId,
Name: "bridge-mpc",
Tags: ["mpc", "bridge", "signing"],
Address: process.env.NODE_ADDRESS || "localhost",
Port: parseInt(process.env.NODE_PORT || "6000"),
Check: {
HTTP: `http://localhost:${process.env.NODE_PORT || "6000"}/health`,
Interval: "10s",
},
Meta: {
nodeId: this.config.nodeId,
publicKey: this.nodePublicKey,
threshold: this.config.threshold.toString(),
totalNodes: this.config.totalNodes.toString(),
},
}
await this.consul.agent.service.register(service)
logger.info(`Registered node ${this.config.nodeId} with Consul`)
// Store node info in KV
await this.consul.kv.set({
Key: `${this.config.keyPrefix}/nodes/${this.config.nodeId}`,
Value: JSON.stringify({
nodeId: this.config.nodeId,
publicKey: this.nodePublicKey,
address: service.Address,
port: service.Port,
timestamp: Date.now(),
}),
})
}
/**
* Initialize or recover key shares from Consul
*/
private async initializeKeyShares(): Promise<void> {
const keySharePath = `${this.config.keyPrefix}/shares/${this.config.nodeId}`
try {
// Try to recover existing key share
const existingShare = await this.consul.kv.get(keySharePath)
if (existingShare && existingShare.Value) {
const shareData = JSON.parse(Buffer.from(existingShare.Value, "base64").toString())
logger.info(`Recovered existing key share for node ${this.config.nodeId}`)
this.emit("keyShareRecovered", shareData)
return
}
} catch (error) {
logger.warn("No existing key share found, will participate in new key generation")
}
// If no existing share, participate in distributed key generation
await this.participateInKeyGeneration()
}
/**
* Participate in distributed key generation
*/
private async participateInKeyGeneration(): Promise<void> {
logger.info("Participating in distributed key generation...")
// Subscribe to key generation coordination
const sub = this.nc.subscribe("bridge.mpc.keygen.coordinate")
// Signal readiness for key generation
await this.nc.publish("bridge.mpc.keygen.ready", sc.encode(JSON.stringify({
nodeId: this.config.nodeId,
publicKey: this.nodePublicKey,
})))
// Wait for coordination message
;(async () => {
for await (const msg of sub) {
const coordination = JSON.parse(sc.decode(msg.data))
if (coordination.type === "start" && coordination.participants.includes(this.config.nodeId)) {
// Participate in TSS key generation protocol
const keyShare = await this.generateKeyShare(coordination)
// Store encrypted key share in Consul
await this.storeKeyShare(keyShare)
// Signal completion
await this.nc.publish("bridge.mpc.keygen.complete", sc.encode(JSON.stringify({
nodeId: this.config.nodeId,
success: true,
})))
break
}
}
})().catch((err: any) => logger.error("Subscription error:", err))
}
/**
* Generate key share using TSS protocol
*/
private async generateKeyShare(coordination: any): Promise<any> {
// This would integrate with the actual Lux MPC TSS implementation
// For now, return a placeholder
return {
nodeId: this.config.nodeId,
share: crypto.randomBytes(32).toString("hex"),
publicKey: crypto.randomBytes(33).toString("hex"),
metadata: {
threshold: this.config.threshold,
totalNodes: this.config.totalNodes,
timestamp: Date.now(),
},
}
}
/**
* Store encrypted key share in Consul
*/
private async storeKeyShare(keyShare: any): Promise<void> {
// Encrypt the key share with the node's private key
const encrypted = this.encryptData(JSON.stringify(keyShare))
await this.consul.kv.set({
Key: `${this.config.keyPrefix}/shares/${this.config.nodeId}`,
Value: encrypted,
})
logger.info(`Stored encrypted key share for node ${this.config.nodeId}`)
}
/**
* Setup NATS message handlers
*/
private async setupHandlers(): Promise<void> {
// Handle signing requests
const signSub = this.nc.subscribe("bridge.mpc.sign.request")
this.handleSignRequests(signSub)
// Handle signature share broadcasts
const shareSub = this.nc.subscribe("bridge.mpc.sign.share")
this.handleSignatureShares(shareSub)
// Handle health checks
const healthSub = this.nc.subscribe("bridge.mpc.health")
this.handleHealthChecks(healthSub)
}
/**
* Handle incoming signing requests
*/
private async handleSignRequests(sub: Subscription): Promise<void> {
;(async () => {
for await (const msg of sub) {
try {
const request: SignRequest = JSON.parse(sc.decode(msg.data))
logger.info(`Received signing request for tx ${request.txId}`)
// Validate request
if (!this.validateSignRequest(request)) {
logger.error("Invalid signing request")
continue
}
// Generate signature share
const share = await this.generateSignatureShare(request)
// Broadcast share to other nodes
await this.nc.publish("bridge.mpc.sign.share", sc.encode(JSON.stringify({
sessionId: `${request.txId}-${Date.now()}`,
nodeId: this.config.nodeId,
share: share,
timestamp: Date.now(),
})))
} catch (error) {
logger.error("Error handling sign request:", error)
}
}
})().catch((err: any) => logger.error("Sign request handler error:", err))
}
/**
* Validate signing request
*/
private validateSignRequest(request: SignRequest): boolean {
// Verify message signature to prevent replay attacks
try {
const signer = Web3.utils.recover(
"Lux Bridge Signing Request", // Standard message
request.msgSignature
)
// Additional validation logic
return true
} catch (error) {
logger.error("Failed to validate request signature:", error)
return false
}
}
/**
* Generate signature share for the request
*/
private async generateSignatureShare(request: SignRequest): Promise<string> {
// Construct message following bridge protocol
const message = this.constructSigningMessage(request)
const messageHash = Web3.utils.keccak256(message)
// Use the node's key share to generate partial signature
// This would integrate with the actual TSS signing
const partialSig = this.signWithShare(messageHash)
return partialSig
}
/**
* Construct signing message according to bridge protocol
*/
private constructSigningMessage(request: SignRequest): string {
const toNetworkIdHash = Web3.utils.keccak256(request.toNetworkId)
const hashedTxId = Web3.utils.keccak256(request.txId)
const toTokenAddressHash = Web3.utils.keccak256(request.toTokenAddress)
return (
toNetworkIdHash +
hashedTxId +
toTokenAddressHash +
request.tokenAmount +
request.decimals +
request.receiverAddressHash +
request.vault
)
}
/**
* Sign with the node's key share
*/
private signWithShare(messageHash: string): string {
// Placeholder - would use actual TSS signing
const signature = crypto.sign(
"sha256",
Buffer.from(messageHash.slice(2), "hex"),
this.nodePrivateKey
)
return signature.toString("hex")
}
/**
* Handle signature shares from other nodes
*/
private async handleSignatureShares(sub: Subscription): Promise<void> {
const shareCollector = new Map<string, SignatureShare[]>()
;(async () => {
for await (const msg of sub) {
try {
const share: SignatureShare = JSON.parse(sc.decode(msg.data))
// Collect shares by session
const sessionShares = shareCollector.get(share.sessionId) || []
sessionShares.push(share)
shareCollector.set(share.sessionId, sessionShares)
// Check if we have enough shares
if (sessionShares.length >= this.config.threshold) {
// Combine shares into final signature
const finalSignature = await this.combineSignatures(sessionShares)
// Emit the final signature
this.emit("signatureComplete", {
sessionId: share.sessionId,
signature: finalSignature,
participants: sessionShares.map((s: any) => s.nodeId),
})
// Clean up
shareCollector.delete(share.sessionId)
}
} catch (error) {
logger.error("Error handling signature share:", error)
}
}
})().catch((err: any) => logger.error("Signature share handler error:", err))
}
/**
* Combine signature shares into final signature
*/
private async combineSignatures(shares: SignatureShare[]): Promise<string> {
// This would use the actual TSS signature combination
// For now, return a valid ECDSA signature format
const r = crypto.randomBytes(32).toString("hex")
const s = crypto.randomBytes(32).toString("hex")
const v = "1b"
return "0x" + r + s + v
}
/**
* Handle health check requests
*/
private async handleHealthChecks(sub: Subscription): Promise<void> {
;(async () => {
for await (const msg of sub) {
msg.respond(sc.encode(JSON.stringify({
nodeId: this.config.nodeId,
status: "healthy",
timestamp: Date.now(),
})))
}
})().catch(err => logger.error("Health check error:", err))
}
/**
* Encrypt data using node's private key
*/
private encryptData(data: string): string {
const cipher = crypto.createCipher("aes-256-cbc", this.nodePrivateKey.export())
let encrypted = cipher.update(data, "utf8", "hex")
encrypted += cipher.final("hex")
return encrypted
}
/**
* Get current MPC network status
*/
async getNetworkStatus(): Promise<any> {
try {
// Query MPC nodes via dashboard API
const mpcApiUrl = process.env.MPC_URL || "http://mpc-api-svc:8081"
const mpcApiKey = process.env.MPC_API_KEY || ""
const headers: Record<string, string> = {}
if (mpcApiKey) headers["X-API-Key"] = mpcApiKey
let nodes: any[] = []
let activeCount = 0
try {
const resp = await fetch(`${mpcApiUrl}/api/v1/nodes`, { headers })
if (resp.ok) {
const data = await resp.json() as any
nodes = Array.isArray(data.nodes) ? data.nodes : Array.isArray(data) ? data : []
activeCount = nodes.filter((n: any) => n.status === "ready" || n.online === true).length
}
} catch {
// fallback: treat all configured nodes as active if dashboard unreachable
activeCount = this.config.totalNodes
}
if (activeCount === 0) activeCount = this.config.totalNodes
return {
totalNodes: this.config.totalNodes,
activeNodes: activeCount,
threshold: this.config.threshold,
ready: activeCount >= this.config.threshold,
nodes,
}
} catch (error) {
logger.error("Error getting network status:", error)
return {
totalNodes: 0,
activeNodes: 0,
threshold: this.config.threshold,
ready: false,
nodes: [],
}
}
}
/**
* Gracefully shutdown the service
*/
async shutdown(): Promise<void> {
logger.info(`Shutting down MPC node ${this.config.nodeId}`)
// Deregister from Consul
await this.consul.agent.service.deregister(this.config.nodeId)
// Close NATS connection
await this.nc.close()
this.isInitialized = false
}
}
// Export singleton instance
export const mpcService = new MPCService()
+2 -89
View File
@@ -1,4 +1,4 @@
// Type definitions for missing modules // Type definitions for modules that ship without their own .d.ts.
declare module 'web3' { declare module 'web3' {
export default class Web3 { export default class Web3 {
@@ -12,93 +12,6 @@ declare module 'web3' {
} }
} }
declare module 'consul' {
interface ConsulOptions {
host?: string;
port?: string;
promisify?: boolean;
}
interface ServiceRegistration {
ID: string;
Name: string;
Tags?: string[];
Port?: number;
Address?: string;
Check?: {
HTTP?: string;
Interval?: string;
Timeout?: string;
};
Meta?: Record<string, string>;
}
interface KVPair {
Key: string;
Value: string | Buffer;
}
export default class Consul {
constructor(options?: ConsulOptions);
agent: {
service: {
register(service: ServiceRegistration): Promise<void>;
deregister(id: string): Promise<void>;
};
};
kv: {
get(key: string): Promise<{ Value: string } | null>;
set(options: KVPair): Promise<void>;
};
health: {
service(service: string, tag?: string, passing?: boolean, options?: any): Promise<any[]>;
};
}
}
declare module 'nats' {
export interface NatsConnection {
publish(subject: string, data: Uint8Array | string): void;
subscribe(subject: string, cb: (msg: Msg) => void): Subscription;
request(subject: string, data: Uint8Array | string, options?: RequestOptions): Promise<Msg>;
close(): Promise<void>;
jetstream(): JetStreamManager;
}
export interface Msg {
subject: string;
data: Uint8Array;
respond(data: Uint8Array | string): void;
}
export interface Subscription {
unsubscribe(): void;
}
export interface RequestOptions {
timeout?: number;
}
export interface JetStreamManager {
publish(subject: string, data: Uint8Array | string): Promise<PubAck>;
}
export interface PubAck {
seq: number;
duplicate: boolean;
}
export function connect(options?: ConnectionOptions): Promise<NatsConnection>;
export interface ConnectionOptions {
servers?: string | string[];
name?: string;
}
}
// Logger types // Logger types
declare module '../utils/logger' { declare module '../utils/logger' {
export const logger: { export const logger: {
@@ -107,4 +20,4 @@ declare module '../utils/logger' {
warn(message: string, ...args: any[]): void; warn(message: string, ...args: any[]): void;
debug(message: string, ...args: any[]): void; debug(message: string, ...args: any[]): void;
}; };
} }
-27
View File
@@ -1,27 +0,0 @@
// Temporary type declaration for NATS
declare module "nats" {
export interface StringCodec {
encode(str: string): Uint8Array
decode(arr: Uint8Array): string
}
export function StringCodec(): StringCodec
export interface NatsConnection {
subscribe(subject: string): Subscription
publish(subject: string, data?: Uint8Array): Promise<void>
close(): Promise<void>
}
export interface Subscription {
[Symbol.asyncIterator](): AsyncIterator<Msg>
}
export interface Msg {
data: Uint8Array
subject: string
respond(data?: Uint8Array): Promise<void>
}
export function connect(opts?: any): Promise<NatsConnection>
}
-78
View File
@@ -1,78 +0,0 @@
import { connect, StringCodec } from "nats"
import { mpcRequestSigner } from "./mpc-signing"
const sc = StringCodec()
async function triggerKeyGeneration() {
try {
// Load the initiator key
await mpcRequestSigner.loadKey()
// Connect to NATS
const nc = await connect({
servers: "nats://localhost:4223"
})
console.log("Connected to NATS")
// Create key generation request
const walletId = "bridge-wallet-0"
// For key generation, we sign just the wallet ID
const signature = await mpcRequestSigner.signWalletId(walletId)
// Create the message with signature as base64 string
const keygenMsg = {
wallet_id: walletId,
signature: Buffer.from(signature).toString('base64')
}
// Publish key generation request
const sessionId = `keygen-${Date.now()}`
await nc.publish(
`mpc.keygen_request.${sessionId}`,
sc.encode(JSON.stringify(keygenMsg))
)
console.log(`Published key generation request for wallet ${walletId}`)
console.log(`Session ID: ${sessionId}`)
// Subscribe to results
const sub = nc.subscribe("mpc.mpc_keygen_result.*")
console.log("Waiting for key generation result...")
// Wait for result with timeout
const timeout = setTimeout(() => {
console.log("Timeout waiting for key generation")
nc.close()
process.exit(1)
}, 60000)
for await (const msg of sub) {
const result = JSON.parse(sc.decode(msg.data))
console.log("Received key generation result:", result)
if (result.wallet_id === walletId) {
clearTimeout(timeout)
if (result.result_type === "success") {
console.log("Key generation successful!")
console.log("ECDSA Public key:", result.ecdsa_pub_key)
console.log("EdDSA Public key:", result.eddsa_pub_key)
} else {
console.log("Key generation failed:", result.error_reason)
}
nc.close()
break
}
}
} catch (error) {
console.error("Error:", error)
process.exit(1)
}
}
// Run the script
triggerKeyGeneration()
+1
View File
@@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"ignoreDeprecations": "6.0",
"baseUrl": "./src", // Base directory for module resolution "baseUrl": "./src", // Base directory for module resolution
"paths": { "paths": {
"@/*": ["*"] // Map "@/..." to "./src/..." for cleaner imports "@/*": ["*"] // Map "@/..." to "./src/..." for cleaner imports
+8
View File
@@ -14,6 +14,14 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(here, "src"), "@": path.resolve(here, "src"),
// @luxfi/utila ships from a published rollup-built dist/ to npm,
// but the workspace dist/ is not built in-place (the rollup
// config has a long-standing outDir-vs-file-path issue). For
// tests, point at the TypeScript source directly — vitest can
// handle TS imports natively. Production builds resolve through
// the package.json main field once the published artifact is
// installed from npm.
"@luxfi/utila": path.resolve(here, "../../pkg/utila/src/grpc-client.ts"),
}, },
}, },
}) })
+51 -77
View File
@@ -1,19 +1,40 @@
# Lux Bridge — LOCAL compose.
#
# Boots the full local development topology: identity provider (Casdoor)
# + Postgres for both Casdoor and bridge. NO NATS, NO Consul, NO Vault —
# the bridge backend no longer needs pub/sub or service discovery for
# MPC (HTTP to mpcd does it in one round-trip; per-swap state lives in
# Postgres).
#
# For TRUE bare-minimum standalone (just bridge + Postgres, no auth),
# use compose.standalone.yml instead.
#
# To enable native m-chain (threshold MPC) at boot, run the canonical
# Lux MPC daemon out-of-band:
# make install-mpc # go install github.com/luxfi/mpc/cmd/mpcd
# make start-mpc-nodes # 3-node local cluster
# export BRIDGE_MPC_URL=http://localhost:6000
#
# Same pattern for Lux KMS (~/work/lux/kms) — point BRIDGE_KMS_URL at it.
services: services:
# Lux ID Service (Casdoor-based unified authentication) # Identity provider (Casdoor-based unified authentication).
lux-id: # Brand-neutral container name + service id — tenants can point any
# OIDC provider here without renaming.
iam:
image: casbin/casdoor:latest image: casbin/casdoor:latest
container_name: bridge-lux-id container_name: bridge-iam
ports: ports:
- "8000:8000" - "8000:8000"
environment: environment:
- RUNNING_IN_DOCKER=true - RUNNING_IN_DOCKER=true
volumes: volumes:
- ./config/casdoor/app.conf:/conf/app.conf - ./config/casdoor/app.conf:/conf/app.conf
- lux-id-data:/var/lib/casdoor - iam-data:/var/lib/casdoor
networks: networks:
- bridge-network - bridge-network
depends_on: depends_on:
lux-id-db: iam-db:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8000"] test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8000"]
@@ -21,16 +42,16 @@ services:
timeout: 3s timeout: 3s
retries: 5 retries: 5
# Lux ID Database (PostgreSQL for Casdoor) # Identity provider database.
lux-id-db: iam-db:
image: ghcr.io/hanzoai/sql:latest image: ghcr.io/hanzoai/sql:latest
container_name: bridge-lux-id-db container_name: bridge-iam-db
environment: environment:
- POSTGRES_USER=casdoor - POSTGRES_USER=casdoor
- POSTGRES_PASSWORD=${CASDOOR_DB_PASSWORD:-changeme} - POSTGRES_PASSWORD=${IAM_DB_PASSWORD:-changeme}
- POSTGRES_DB=casdoor - POSTGRES_DB=casdoor
volumes: volumes:
- lux-id-db-data:/var/lib/postgresql - iam-db-data:/var/lib/postgresql/data
ports: ports:
- "5434:5432" - "5434:5432"
networks: networks:
@@ -41,63 +62,7 @@ services:
timeout: 3s timeout: 3s
retries: 5 retries: 5
# KMS Service (Mock for local development) # Bridge swap state (canonical, via Prisma).
kms:
image: hashicorp/vault:1.15
container_name: bridge-kms
ports:
- "8200:8200"
environment:
- VAULT_DEV_ROOT_TOKEN_ID=myroot
- VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200
cap_add:
- IPC_LOCK
networks:
- bridge-network
healthcheck:
test: ["CMD", "vault", "status"]
interval: 5s
timeout: 3s
retries: 5
# Infrastructure Services
nats:
image: nats:2.10-alpine
container_name: bridge-nats
ports:
- "4223:4222"
- "8223:8222"
command: "-js -m 8222 --store_dir /data"
volumes:
- nats-data:/data
networks:
- bridge-network
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "4222"]
interval: 5s
timeout: 3s
retries: 5
consul:
image: hashicorp/consul:1.16
container_name: bridge-consul
ports:
- "8501:8500"
- "8602:8600/udp"
command: "agent -server -bootstrap-expect=1 -ui -client=0.0.0.0"
volumes:
- consul-data:/consul/data
networks:
- bridge-network
environment:
- CONSUL_BIND_INTERFACE=eth0
healthcheck:
test: ["CMD", "consul", "members"]
interval: 5s
timeout: 3s
retries: 5
# Database
postgres: postgres:
image: ghcr.io/hanzoai/sql:latest image: ghcr.io/hanzoai/sql:latest
container_name: bridge-postgres container_name: bridge-postgres
@@ -118,17 +83,26 @@ services:
timeout: 3s timeout: 3s
retries: 5 retries: 5
# MPC nodes are run locally using Go-installed tools: # mpcd (the canonical Lux threshold-MPC daemon) and the KMS service
# 1. make install-mpc # Install MPC tools via go install # are NOT in this compose. Run them out-of-band:
# 2. make start-mpc-nodes # Start local MPC network #
# 3. cd app/server && pnpm dev # # mpcd — github.com/luxfi/mpc
# 4. cd app/bridge && pnpm dev # make install-mpc
# make start-mpc-nodes # 3-node local cluster on :6000-6002
#
# # KMS — github.com/luxfi/kms
# cd ~/work/lux/kms && make dev
#
# Then point the bridge backend at them:
# export BRIDGE_MPC_URL=http://localhost:6000
# export BRIDGE_KMS_URL=http://localhost:8080
#
# The bridge runs in standalone mode (clean error on sign calls)
# until those are set — no crash at startup.
volumes: volumes:
lux-id-data: iam-data:
lux-id-db-data: iam-db-data:
nats-data:
consul-data:
postgres-data: postgres-data:
networks: networks:
@@ -136,4 +110,4 @@ networks:
driver: bridge driver: bridge
ipam: ipam:
config: config:
- subnet: 172.25.0.0/16 - subnet: 172.25.0.0/16
+93
View File
@@ -0,0 +1,93 @@
# Lux Bridge — STANDALONE compose.
#
# Boots the absolute minimum services for a working bridge: Postgres
# (state) + bridge-server (API) + bridge-ui (Vite SPA). NO Casdoor /
# Vault / NATS / Consul / MPC nodes — every external dep is either
# optional at runtime (m-chain MPC, KMS, cosigners) or absent from this
# topology entirely.
#
# Use when:
# - Running on a dev laptop without spinning up the full Lux platform
# - Reproducing a swap state for debugging
# - White-label tenants who run their own IAM / KMS / MPC out-of-band
#
# When you want the full platform, run `docker compose -f compose.local.yml
# up` instead. That brings up Lux ID + Vault + NATS + Consul + 3-node MPC
# alongside this one.
#
# Quick start:
# 1. Copy ./config/env.standalone.example → ./config/env.standalone
# and fill in the bridge JWT signing key + any cosigner config.
# 2. docker compose -f compose.standalone.yml up --build
# 3. Bridge UI: http://localhost:3001
# Bridge API: http://localhost:5000/healthz
# Postgres: localhost:5433 (user=bridge, pass=bridge, db=bridge)
#
# To enable external services after boot, just set the corresponding env
# vars (BRIDGE_MPC_URL, BRIDGE_KMS_URL, …) — the bridge backend graceful-degrades
# when they're unset and starts using them when they appear.
services:
postgres:
image: ghcr.io/hanzoai/sql:latest
container_name: bridge-pg-standalone
environment:
POSTGRES_USER: bridge
POSTGRES_PASSWORD: bridge
POSTGRES_DB: bridge
ports:
- "5433:5432"
volumes:
- bridge-pg-standalone:/var/lib/postgresql/data
networks: [bridge-standalone]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U bridge"]
interval: 3s
timeout: 2s
retries: 10
bridge-server:
image: ghcr.io/luxfi/bridge-server:latest
container_name: bridge-server-standalone
depends_on:
postgres: { condition: service_healthy }
environment:
PORT: "5000"
POSTGRES_URL: postgres://bridge:bridge@postgres:5432/bridge
DATABASE_URL: postgres://bridge:bridge@postgres:5432/bridge
NODE_ENV: standalone
LOG_LEVEL: info
# All external integrations are OPTIONAL — uncomment to enable.
# BRIDGE_IAM_ISSUER: https://iam.lux.network
# BRIDGE_KMS_URL: https://kms.lux.network
# BRIDGE_KMS_ORG: lux
# BRIDGE_IAM_CLIENT_ID: lux-bridge
# BRIDGE_IAM_CLIENT_SECRET: <fetch from your secret store>
# BRIDGE_MPC_URL: https://mpc.lux.network
ports:
- "5000:5000"
networks: [bridge-standalone]
restart: unless-stopped
bridge-ui:
image: ghcr.io/luxfi/bridge:latest
container_name: bridge-ui-standalone
depends_on:
bridge-server: { condition: service_started }
environment:
# __ENV.js is templated by the SPA serving image at boot from these
# BRIDGE_* vars (one image, N envs).
BRIDGE_API_HOST: http://localhost:5000
BRIDGE_ENV: standalone
BRIDGE_IAM_ORG: lux
ports:
- "3001:80"
networks: [bridge-standalone]
restart: unless-stopped
volumes:
bridge-pg-standalone:
networks:
bridge-standalone:
driver: bridge
+146
View File
@@ -0,0 +1,146 @@
# Integrations — Go-side operator guide
This document describes the Go-side daemon architecture of the Lux
bridge for **operators** running validator / watcher / signer nodes.
It is not an SDK reference. The only npm package consumers ever import
is **`@luxfi/bridge`** — see that package's README for client usage.
The Go side is what an operator deploys to physically observe source
chains, run the threshold-signature protocol, and submit relays to the
destination chain. The two binaries built by goreleaser are:
| Binary | Path | Role |
|:-------|:-----|:-----|
| `bridge` | `cmd/bridge` | API + frontend host; coordinates the watcher fleet and serves the relay queue. |
| `opnet-watcher` | `opnet-watcher/` | Per-chain indexer + threshold cosigner. One instance per source chain, federated across operators. |
---
## 1. Cross-chain MPC bridge model
The bridge connects **15+ heterogeneous chain families** —
EVM L1s, EVM L2 rollups, non-EVM L1s, Cosmos zones, UTXO chains, and
a small number of niche substrates — into a uniform deposit / relay /
mint pipeline.
Each source chain is observed by a per-chain plugin that emits a
single canonical event type:
```go
type DepositEvent struct {
SrcChainID uint64
Nonce uint64
Recipient [20]byte // destination address on the relay chain
Amount *big.Int
TxID string // source-chain tx identifier
BlockHeight uint64
}
```
Operators never write chain-specific glue at the relay or signer
layer. A new chain only has to satisfy `ChainPlugin` (see
`opnet-watcher/plugins/plugin.go`):
```go
type ChainPlugin interface {
Name() string
ChainID() uint64
PollDeposits(ctx context.Context, fromSlot uint64) ([]DepositEvent, uint64, error)
QueryBacking(ctx context.Context) (*big.Int, error)
}
```
Everything downstream — proof-hash construction, signer participation,
relay submission, backing attestation — is chain-agnostic.
---
## 2. Threshold-signature scheme
The signer in `opnet-watcher/signer.go` is built on
`github.com/luxfi/crypto/threshold` and supports three production
schemes selected per destination context:
| Scheme | Curve | Use |
|:-------|:------|:----|
| **CGGMP21** (`SchemeCMP`) | secp256k1 | Threshold ECDSA for EVM-compatible destinations — the default relay path. |
| **FROST** (`SchemeFROST`) | secp256k1 / ed25519 | Threshold Schnorr for Taproot and ed25519-native destinations. |
| **BLS** (`SchemeBLS`) | BLS12-381 | Threshold BLS for aggregated internal attestations. |
CGGMP21 is the canonical choice because it is **non-interactive at
signing time** after a one-time interactive keygen, and it produces
signatures that are bit-for-bit identical to any standard secp256k1
ECDSA signature — destination contracts verify with `ecrecover` and
do not need to know that a threshold scheme produced the signature.
Key shares are generated once via the keygen ceremony in
`pkg/threshold/` (TS-side ceremony coordinator) and distributed to
operator HSMs out of band. The Go-side daemon never sees a full
private key — only its share — and `t-of-n` participation is required
for every relay signature.
---
## 3. The `WebhookHandler` pattern
A small but load-bearing convention in the Lux ecosystem: any
provider / plugin that can receive **asynchronous callbacks** from an
external system (chain RPC push, custodian webhook, oracle feed)
implements the optional `WebhookHandler` capability rather than
embedding the callback path inside its primary interface.
The canonical shape (mirrored from `forex/pkg/provider/provider.go`):
```go
type WebhookHandler interface {
ParseWebhook(eventType string, body []byte, headers http.Header) (*NormalizedEvent, error)
}
```
Rules:
- It is **optional** — providers/plugins that poll only do not
implement it. Consumers do an interface assertion and skip.
- The handler returns a **normalised event** in the same shape the
poll path emits. The downstream pipeline cannot tell whether an
event arrived by polling or by webhook.
- All authentication / signature verification of the inbound webhook
happens **inside** `ParseWebhook`, using `headers`. The caller does
not pre-validate.
Adopt this pattern for any new chain plugin that gains a push API.
Do not invent a parallel callback interface.
---
## 4. Adding a new chain
1. Drop a new file in `opnet-watcher/plugins/<chain>.go` implementing
`ChainPlugin`. Use one of the existing plugins as the closest
structural reference for your chain family (EVM, Cosmos, UTXO,
account-model non-EVM, rollup).
2. Add the chain ID to `cmd/bridge/networks.example.yaml`.
3. Register the plugin in `opnet-watcher/main.go` so it is wired into
the watcher fleet at boot.
4. If the chain provides a push API, additionally implement
`WebhookHandler` (§3) and register the route in `cmd/bridge/api.go`.
5. Write a `<chain>_test.go` that exercises `PollDeposits` against a
recorded fixture — every existing plugin ships one; the test
matrix in CI requires it.
No changes are required to the signer, relay, or destination
contract. The plugin contract is the only seam.
---
## 5. Public SDK boundary
`@luxfi/bridge` (the npm package in `pkg/bridge/`) is the **only**
surface a downstream application is ever expected to import. The Go
binaries here are operational infrastructure: an integrating
application does not run `bridge` or `opnet-watcher` in-process — it
talks to a federated relay network of operators who do.
If an integration question is about *what an app developer calls*, the
answer is in `pkg/bridge/README.md`. If it is about *what an operator
runs*, the answer is in this document.
+202 -1
View File
@@ -32,6 +32,52 @@ Rules:
- `@luxfi/{core,threshold,utila}` are generic Lux building-block libraries — - `@luxfi/{core,threshold,utila}` are generic Lux building-block libraries —
bridge-agnostic, may be reused elsewhere in the Lux ecosystem. bridge-agnostic, may be reused elsewhere in the Lux ecosystem.
### Canonical brand sourcing — `@luxfi/brand` is the only source
Every Lux-ecosystem tenant app — bridge, exchange, id, pay, etc. — pulls
its brand identity from **one** package: `@luxfi/brand` (source repo:
`~/work/lux/brand`). No Lux-specific brand strings, colors, logos, or
support email addresses are baked into the SDK or any other downstream
artefact.
How the bridge tenant does it (`app/bridge/src/bridge.config.ts`):
```ts
import luxBrandJson from '@luxfi/brand/brand.json'
const { shortName, primaryColor, supportEmail } = luxBrandJson.brand
export const bridgeConfig: BridgeConfig = {
apiHost: env('BRIDGE_API_HOST', 'https://api.bridge.lux.network')!,
brand: {
name: `${shortName ?? 'Lux'} Bridge`, // composed at runtime
primaryColor: primaryColor ?? '#000000',
supportEmail: supportEmail ?? 'support@lux.network',
},
// …
}
```
Rules:
- The **SDK** (`pkg/bridge/`) is **jurisdiction-neutral**. It declares
*no* Lux-specific brand. Tenants supply brand at mount time via
`BridgeConfig.brand`. The SDK has no direct dependency on
`@luxfi/brand` — that'd be a layering violation (Zoo, Hanzo, or any
white-label tenant must be able to consume `@luxfi/bridge` without
pulling Lux brand metadata into their bundle).
- **App-specific endpoints** (`api.bridge.lux.network`, `docs.lux.network/bridge`)
belong to the bridge product and are independent of `@luxfi/brand`'s
`appDomain` (`lux.exchange` — that's the exchange product's domain).
- **SSR / no-window fallbacks** in the SDK derive from `cfg.apiHost`
rather than hardcoding a Lux URL — see `deriveAppOrigin` in
`pkg/bridge/src/app/lib/wagmi-config.ts`.
Other Lux apps follow the same pattern: `~/work/lux/exchange` reads
the full brand block; `~/work/lux/id` reads `name` + `primaryColor` +
`appDomain`; etc. If you find a hardcoded brand string in any Lux app,
it's a leak — file it as a follow-up issue and route the value through
`@luxfi/brand`.
### History — what got folded together ### History — what got folded together
- **`app/bridge3/`** (formerly `@luxbridge/app-v3`) was **folded into - **`app/bridge3/`** (formerly `@luxbridge/app-v3`) was **folded into
@@ -59,7 +105,7 @@ mountBridge({
}) })
``` ```
Mirrors `mountExchange` from `@/exchange`. One declarative entry, Mirrors `mountExchange` from the upstream exchange SDK. One declarative entry,
build-time brand config, no hostname detection inside the SDK. build-time brand config, no hostname detection inside the SDK.
## Publishing ## Publishing
@@ -185,6 +231,161 @@ brand: │ pkg/bridge/src/app/ │
`pulsar` (MLWE), `corona` (RLWE), `magnetar` (lattice research variant) — `pulsar` (MLWE), `corona` (RLWE), `magnetar` (lattice research variant) —
PQ-safe, leaderless, permissionless-safe by design. PQ-safe, leaderless, permissionless-safe by design.
### Standalone mode — running the bridge without the Lux platform
The bridge can boot with the absolute minimum: Postgres + bridge-server +
bridge-ui. Every other Lux service (IAM / KMS / m-chain MPC / cosigners)
is **optional at runtime** — the backend graceful-degrades when the
corresponding env block is unset and picks up the integration the moment
the block appears.
```
docker compose -f compose.standalone.yml up --build
# bridge UI http://localhost:3001
# bridge API http://localhost:5000/healthz
# postgres localhost:5433 (user=bridge, pass=bridge, db=bridge)
```
What's intentionally NOT in `compose.standalone.yml` (but is in
`compose.local.yml`):
| Service | Why standalone can skip it |
|---|---|
| Casdoor (Lux ID) | bridge does not require auth in dev mode |
| Vault (KMS) | `fetchCosignerSecret` falls back to env-var pattern when `BRIDGE_KMS_URL` is unset |
| NATS | only `mpc-service.ts` consumes it; backend logs a warn and skips its initialise() |
| Consul | same — `mpc-service.ts` only |
| 3-node MPC cluster | `LuxMPCClient` is constructed on demand; native MPC sign requires it but cosigner-layered swaps don't |
To enable any of the optional layers, set the matching env block on the
`bridge-server` service in `compose.standalone.yml` (commented stubs are
already there). The bridge picks them up on next request — no restart
required for cosigner config, only `BRIDGE_MPC_URL` requires a restart (cached at server start).
### ZAP serialization — canonical home is `~/work/zap`, npm scope `@zap-proto`
The bridge currently uses plain JSON over HTTP for every wire call (mpcd,
KMS, IAM, cosigner adapters, f-chain). It does **not** depend on any
binary RPC codec. If a future surface needs Cap'n-Proto-style binary
RPC (e.g. mpcd → bridge events stream), use **ZAP** — the canonical
Lux / Hanzo / Liquidity zero-copy app protocol.
| Aspect | Canonical |
|---|---|
| Source repo | **`~/work/zap`** (not `~/work/lux/zap` or `~/work/hanzo/zap` — those are forks / legacy clones being consolidated upstream) |
| GitHub org | `zap-proto` |
| npm scope | `@zap-proto/*` |
| TS package | `@zap-proto/zap` (lives at `~/work/zap/zap-js`) — pure-TS codec, no third-party Cap'n Proto deps. The package.json once listed `capnp-es` in `dependencies` but no source file imports it; that dep is stale and slated for removal in the next ZAP TS publish |
| WASM binding | `@zap-proto/wasm` (`~/work/zap/zap-wasm`) |
| Go / Rust | wire-compatible siblings; same ZAP framing |
**Rules for the bridge** (and any other downstream consumer):
- **Never `pnpm add capnp-es`** to add binary RPC. If we need Cap'n-Proto-
style framing, use `@zap-proto/zap` (pure TS, no third-party dep).
The whole point of ZAP is to be brand-neutral and wire-compatible
across Go / Rust / TS without pulling in the upstream Cap'n Proto JS
ecosystem.
- The `@zap-proto/*` scope is brand-neutral. Don't publish `@luxfi/zap-*`
or `@hanzoai/zap-*` variants — they'd fork the wire format and break
cross-stack RPC.
- Source of truth for the wire format: `~/work/zap/README.md` (Go) +
`~/work/zap/zap-js/src/protocol.ts` (TS). Match either.
### Hanzo-side bridge / teleport — does not exist
There is no `~/work/hanzo/teleport` and no `~/work/hanzo/bridge`. The
canonical Lux cross-chain bridge protocol lives at `~/work/lux/teleport`
(Warp 2.0 envelopes + m-chain custody, LP-021v2 / LP-7330). The
user-facing UI + SDK is `~/work/lux/bridge` (this repo). If you ever
need a Hanzo-flavored bridge surface for a Hanzo-branded tenant, the
right play is to add a Hanzo tenant build under `app/` (mirror of
`app/bridge/`) that consumes `@luxfi/bridge` + `@hanzoai/brand` — not a
new repo. The SDK is the integration point; tenants are thin.
### Native Lux services — m-chain (MPC) + KMS + f-chain
The bridge backend talks to the canonical Lux ecosystem services via
HTTP clients in `app/server/src/clients/`:
- **`lux-iam.ts`** — `LuxIAMClient.mint(audience)` mints OAuth2
`client_credentials` JWTs against Lux IAM (`https://iam.lux.network`).
Per-audience cache with early refresh. The bridge mints one token for
`aud=lux-kms` and another for `aud=lux-mpc`; both rotate independently.
- **`lux-kms.ts`** — `LuxKMSClient.getSecret(path)` reads from
`~/work/lux/kms` (`https://kms.lux.network`). Bearer JWT auth, path
layout `bridge/cosigners/{utila|fireblocks}/{publicId}/{sa_pem|secret_pem}`.
Replaces every env-var fallback in `domain/cosigners.ts::fetchCosignerSecret`.
- **`lux-mpc.ts`** — `LuxMPCClient.keygen({...})` / `.sign({...})` /
`.status()` against `mpcd` (`https://mpc.lux.network`). This is THE
m-chain entrypoint — the canonical Lux MPC daemon at `~/work/lux/mpc`,
speaking the same wire protocol as the Go client at
`~/work/lux/kms/pkg/mpc/client.go`. Supports the full Lux protocol set
(CGGMP21 / FROST / BLS / SR25519 / Pulsar / Corona / Magnetar / Doerner).
Per-call 120s timeout matches mpcd's threshold-completion ceiling.
Configuration via env (bridge backend startup):
```
BRIDGE_IAM_ISSUER https://iam.lux.network
BRIDGE_KMS_URL https://kms.lux.network
BRIDGE_KMS_ORG lux
BRIDGE_IAM_CLIENT_ID lux-bridge
BRIDGE_IAM_CLIENT_SECRET (from KMS at boot OR --kms-bootstrap-secret)
BRIDGE_MPC_URL https://mpc.lux.network
```
When `BRIDGE_KMS_URL` is unset (local dev), `fetchCosignerSecret` falls
back to per-tenant env vars. Production deployments MUST set the KMS
env block — env-var fallback is dev-only.
### Lux software frontends (administrative UIs)
Three Lux ecosystem services ship their own administrative UIs separate
from the bridge tenant SDK:
| Service | Frontend repo / path | Stack | Domain |
|---|---|---|---|
| **m-chain (mpcd)** | `~/work/lux/mpc/dashboard/` (`@luxfi/mpc-dashboard`) | Next.js 15 + React 19 + zustand | `mpc.lux.network/dashboard` |
| **KMS** | `~/work/lux/kms/frontend/` (`@hanzo/kms-frontend`) | Vite + React | `kms.lux.network` |
| **f-chain (fhed)** | **DOES NOT EXIST** — see below | — | — |
The bridge does **not** embed these dashboards; they're separate
operator surfaces.
### f-chain frontend — what it would look like
`~/work/lux/fhe` (the FHE daemon `fhed`) currently has no dedicated UI.
The bridge tenant's `TRANSFERS` panel surfaces per-swap f-chain
attestation status (a `CosignerStep` row when the bridge backend
processes a swap with `fchain: { publicUrl, scheme }` config), but
that's per-bridge-tenant, not a cluster-wide operator surface.
A standalone f-chain dashboard, when built, would mirror the
mpc-dashboard layout and surface:
1. **Cluster status** — `GET /v1/fhe/cluster/status`: peer count, FHE
scheme registry, share health per node.
2. **Public key registry** — `GET /v1/fhe/publickey`: published FHE
public keys per scheme (CKKS / BGV / BFV), with an export button.
3. **Encryption sessions** — recent `/v1/fhe/encrypt` calls and their
ciphertext bindings (txHash → ciphertext_hex), filterable by
requesting service (bridge / pay / id / ...).
4. **Reshare history** — `/v1/fhe/cluster/reshare` events.
5. **Health probe** — `GET /v1/fhe/health` rolled up to per-node status.
Recommended scaffold (when scheduled):
```
~/work/lux/fhe/dashboard/ # new Next.js app, mirror of mpc/dashboard
package.json @luxfi/fchain-dashboard
app/ # Next.js app router
components/cluster/ # ClusterStatus, PeerList
components/keys/ # PublicKeyRegistry, ExportButton
components/sessions/ # EncryptHistory, CiphertextViewer
lib/fchain-client.ts # mirrors lux-mpc.ts / lux-kms.ts shape
```
### Optional layered cosigners (since SDK v1.0.3) ### Optional layered cosigners (since SDK v1.0.3)
External MPC custodians can be layered ON TOP of the native threshold network External MPC custodians can be layered ON TOP of the native threshold network
+3 -3
View File
@@ -13,8 +13,8 @@
"install:browsers": "playwright install chromium" "install:browsers": "playwright install chromium"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.49.0", "@playwright/test": "^1.60.0",
"@types/node": "20.14.15", "@types/node": "25.9.1",
"typescript": "5.7.2" "typescript": "6.0.3"
} }
} }
+2 -2
View File
@@ -20,7 +20,7 @@
"author": "Lux Network", "author": "Lux Network",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@noble/ed25519": "^2.3.0" "@noble/ed25519": "^3.1.0"
}, },
"workspaces": [ "workspaces": [
"app/*", "app/*",
@@ -41,6 +41,6 @@
} }
}, },
"devDependencies": { "devDependencies": {
"playwright": "^1.58.2" "playwright": "^1.60.0"
} }
} }
+27 -28
View File
@@ -1,7 +1,7 @@
{ {
"name": "@luxfi/bridge", "name": "@luxfi/bridge",
"version": "1.0.3", "version": "1.0.5",
"description": "Lux Bridge SDK \u2014 embed bridge.lux.network into any host application", "description": "Lux Bridge SDK — embed bridge.lux.network into any host application",
"publishConfig": { "publishConfig": {
"registry": "https://registry.npmjs.org/", "registry": "https://registry.npmjs.org/",
"access": "public", "access": "public",
@@ -77,7 +77,7 @@
"pub": "npm publish" "pub": "npm publish"
}, },
"dependencies": { "dependencies": {
"@hanzo/gui": "^7.0.0", "@hanzo/gui": "^7.2.2",
"@hanzogui/animate": "^7.0.0", "@hanzogui/animate": "^7.0.0",
"@hanzogui/animate-presence": "^7.0.0", "@hanzogui/animate-presence": "^7.0.0",
"@hanzogui/button": "^7.0.0", "@hanzogui/button": "^7.0.0",
@@ -87,36 +87,35 @@
"@hanzogui/helpers": "^7.0.0", "@hanzogui/helpers": "^7.0.0",
"@hanzogui/input": "^7.0.0", "@hanzogui/input": "^7.0.0",
"@hanzogui/themes": "^7.0.0", "@hanzogui/themes": "^7.0.0",
"@luxfi/brand": "^1.0.0",
"@luxfi/threshold": "workspace:*", "@luxfi/threshold": "workspace:*",
"@tanstack/react-query": "5.90.20", "@tanstack/react-query": "5.100.14",
"viem": "2.30.5", "viem": "2.50.4",
"wagmi": "2.15.5" "wagmi": "3.6.15"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0",
"@hanzogui/popover": "^7.0.0",
"@hanzogui/select": "^7.0.0",
"@hanzogui/dialog": "^7.0.0",
"@hanzogui/card": "^7.0.0",
"@hanzogui/text": "^7.0.0",
"@hanzogui/stacks": "^7.0.0",
"@hanzogui/spinner": "^7.0.0",
"@hanzogui/toast": "^7.0.0",
"@hanzogui/tabs": "^7.0.0",
"@hanzogui/label": "^7.0.0",
"@hanzogui/form": "^7.0.0",
"@hanzogui/list-item": "^7.0.0",
"@hanzogui/separator": "^7.0.0",
"@hanzogui/tooltip": "^7.0.0",
"@hanzogui/avatar": "^7.0.0", "@hanzogui/avatar": "^7.0.0",
"@hanzogui/image": "^7.0.0", "@hanzogui/card": "^7.0.0",
"@hanzogui/dialog": "^7.0.0",
"@hanzogui/form": "^7.0.0",
"@hanzogui/group": "^7.0.0", "@hanzogui/group": "^7.0.0",
"@hanzogui/progress": "^7.0.0", "@hanzogui/image": "^7.0.0",
"@hanzogui/visually-hidden": "^7.0.0", "@hanzogui/label": "^7.0.0",
"@hanzogui/list-item": "^7.0.0",
"@hanzogui/popover": "^7.0.0",
"@hanzogui/portal": "^7.0.0", "@hanzogui/portal": "^7.0.0",
"@hanzogui/sheet": "^7.0.0" "@hanzogui/progress": "^7.0.0",
"@hanzogui/select": "^7.0.0",
"@hanzogui/separator": "^7.0.0",
"@hanzogui/sheet": "^7.0.0",
"@hanzogui/spinner": "^7.0.0",
"@hanzogui/stacks": "^7.0.0",
"@hanzogui/tabs": "^7.0.0",
"@hanzogui/text": "^7.0.0",
"@hanzogui/toast": "^7.0.0",
"@hanzogui/tooltip": "^7.0.0",
"@hanzogui/visually-hidden": "^7.0.0",
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@hanzogui/popover": { "@hanzogui/popover": {
@@ -190,6 +189,6 @@
"happy-dom": "^20.9.0", "happy-dom": "^20.9.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"typescript": "catalog:", "typescript": "catalog:",
"vitest": "^2.1.0" "vitest": "^4.1.7"
} }
} }
+13 -11
View File
@@ -20,11 +20,10 @@
// runtime dependency on any sibling workspace package. // runtime dependency on any sibling workspace package.
import { useEffect, type FC, type ReactNode } from 'react' import { useEffect, type FC, type ReactNode } from 'react'
// @hanzo/gui source exports `GuiProvider` (after the monorepo's postinstall // @hanzo/gui ships two compatible naming surfaces across 7.x dists:
// rename) but its shipped .d.ts files still carry the upstream // 7.0.0 (empty dist, patched in by postinstall) exposes `GuiProvider`;
// `HanzoguiProvider` name. We pull the runtime symbol directly from the // 7.2.x (populated dist) exposes the upstream `HanzoguiProvider`. SDK
// untyped re-export module to avoid forcing every consumer of @luxfi/bridge // accepts whichever is present so a tenant on either pin works.
// to also ship the rename patch.
import * as gui from '@hanzo/gui' import * as gui from '@hanzo/gui'
import { applyBrandMetadata, setConfig } from './config' import { applyBrandMetadata, setConfig } from './config'
@@ -33,12 +32,10 @@ import { BridgeApp } from './app/BridgeApp'
import { setRpcClient } from './app/lib/bridge-api' import { setRpcClient } from './app/lib/bridge-api'
import { BridgeRPCClient } from './app/lib/bridge-rpc' import { BridgeRPCClient } from './app/lib/bridge-rpc'
const GuiProvider: FC<{ const guiAny = gui as unknown as Record<string, unknown>
children: ReactNode const GuiProvider = (guiAny.GuiProvider ?? guiAny.HanzoguiProvider) as
defaultTheme?: 'light' | 'dark' | FC<{ children: ReactNode; defaultTheme?: 'light' | 'dark' }>
}> = (gui as unknown as { | undefined
GuiProvider: FC<{ children: ReactNode; defaultTheme?: 'light' | 'dark' }>
}).GuiProvider
export interface BridgeProps { export interface BridgeProps {
/** Runtime config. Seeded into the SDK config cache on first render. */ /** Runtime config. Seeded into the SDK config cache on first render. */
@@ -95,6 +92,11 @@ export const Bridge: FC<BridgeProps> = ({ config }) => {
// SDK owns this wrapper so consumers don't have to know it exists — the // SDK owns this wrapper so consumers don't have to know it exists — the
// runtime config is configured separately by `mountBridge` via // runtime config is configured separately by `mountBridge` via
// `createGui(getDefaultGuiConfig())`. // `createGui(getDefaultGuiConfig())`.
if (!GuiProvider) {
throw new Error(
'@hanzo/gui exports neither GuiProvider nor HanzoguiProvider — install >=7.0.0',
)
}
return ( return (
<GuiProvider defaultTheme="dark"> <GuiProvider defaultTheme="dark">
<BridgeApp /> <BridgeApp />
+32
View File
@@ -439,6 +439,36 @@ export function useTransfers(): TransferState {
...(f.vaultAccountId ? { vaultAccountId: f.vaultAccountId } : {}), ...(f.vaultAccountId ? { vaultAccountId: f.vaultAccountId } : {}),
}) })
} }
// Cloud HSM cosigners — synchronous HSM-backed sign layers.
// Browser SDK declares the key reference + algorithm + optional
// identity hint only. The bridge backend resolves credentials via
// the cloud's native workload-identity mechanism (no secrets on
// the wire). For the Lux-Network tenant this array is empty —
// Lux uses m-chain + optional f-chain (see below) and nothing else.
if (cfg.mpc?.cloudHsm && cfg.mpc.cloudHsm.length > 0) {
for (const h of cfg.mpc.cloudHsm) {
cosigners.push({
kind: 'cloud_hsm',
provider: h.provider,
keyRef: h.keyRef,
algorithm: h.algorithm,
...(h.identityHint ? { identityHint: h.identityHint } : {}),
})
}
}
// f-chain (FHE attestation) — native Lux Network signer alongside
// m-chain. Cosigns the same txHash via FHE-secured key material,
// so the layer is entirely within the Lux primary network (no
// external custodians, no cloud HSM dependency). PQ-safe by
// construction (lattice-based FHE schemes).
if (cfg.mpc?.fchain) {
const f = cfg.mpc.fchain
cosigners.push({
kind: 'fchain',
publicUrl: f.publicUrl,
...(f.scheme ? { scheme: f.scheme } : {}),
})
}
// Every source uses the MPC-derived deposit address. The server's // Every source uses the MPC-derived deposit address. The server's
// createMPCWalletForDeposit() returns a chain-appropriate address // createMPCWalletForDeposit() returns a chain-appropriate address
@@ -520,6 +550,8 @@ export function useTransfers(): TransferState {
cfg.env, cfg.env,
cfg.mpc?.utila, cfg.mpc?.utila,
cfg.mpc?.fireblocks, cfg.mpc?.fireblocks,
cfg.mpc?.cloudHsm,
cfg.mpc?.fchain,
chains, chains,
assets, assets,
patch, patch,
+41 -6
View File
@@ -196,9 +196,15 @@ export async function fetchQuote(
* Optional layered-cosigner intent. * Optional layered-cosigner intent.
* *
* The SDK forwards PUBLIC identifiers only — `orgId`, `clientId`, `apiKey`, * The SDK forwards PUBLIC identifiers only — `orgId`, `clientId`, `apiKey`,
* vault ids. The bridge backend uses these to look up the corresponding * key references, vault ids. The bridge backend uses these to look up the
* secret half from KMS and complete the cosign on behalf of the tenant. * corresponding secret half from KMS (or to invoke a workload-identity-
* authenticated client) and complete the cosign on behalf of the tenant.
* No secret material ever leaves the browser bundle. * No secret material ever leaves the browser bundle.
*
* Async approval layers (utila, fireblocks) — bridge backend polls.
* Synchronous HSM-sign layers (cloud_hsm) — bridge backend signs in one call.
* Both shapes collapse into the same `cosigners[]` wire array; the backend
* dispatcher handles the timing difference.
*/ */
export type CosignerIntent = export type CosignerIntent =
| { | {
@@ -214,6 +220,18 @@ export type CosignerIntent =
apiHost?: string apiHost?: string
vaultAccountId?: string vaultAccountId?: string
} }
| {
kind: 'cloud_hsm'
provider: 'gcp_kms' | 'aws_kms' | 'azure_key_vault' | 'vault_transit'
keyRef: string
algorithm: 'secp256k1_ecdsa_sha256' | 'ed25519' | 'rsa_pss_sha256'
identityHint?: string
}
| {
kind: 'fchain'
publicUrl: string
scheme?: 'ckks' | 'bgv' | 'bfv'
}
/** POST `/api/swaps` payload (also used as input to the RPC submitBridgeRequest). */ /** POST `/api/swaps` payload (also used as input to the RPC submitBridgeRequest). */
export interface CreateSwapParams { export interface CreateSwapParams {
@@ -494,11 +512,28 @@ function serializeCosigner(c: CosignerIntent): Record<string, unknown> {
...(c.vaultId ? { vault_id: c.vaultId } : {}), ...(c.vaultId ? { vault_id: c.vaultId } : {}),
} }
} }
if (c.kind === 'fireblocks') {
return {
kind: 'fireblocks',
api_key: c.apiKey,
...(c.apiHost ? { api_host: c.apiHost } : {}),
...(c.vaultAccountId ? { vault_account_id: c.vaultAccountId } : {}),
}
}
if (c.kind === 'cloud_hsm') {
return {
kind: 'cloud_hsm',
provider: c.provider,
key_ref: c.keyRef,
algorithm: c.algorithm,
...(c.identityHint ? { identity_hint: c.identityHint } : {}),
}
}
// fchain — native FHE attestation; no credentials, just a public URL.
return { return {
kind: 'fireblocks', kind: 'fchain',
api_key: c.apiKey, public_url: c.publicUrl,
...(c.apiHost ? { api_host: c.apiHost } : {}), ...(c.scheme ? { scheme: c.scheme } : {}),
...(c.vaultAccountId ? { vault_account_id: c.vaultAccountId } : {}),
} }
} }
+35 -1
View File
@@ -146,7 +146,16 @@ export function buildWagmiConfig(cfg: BridgeConfig): Config {
metadata: { metadata: {
name: brandName, name: brandName,
description: `${brandName} cross-chain bridge`, description: `${brandName} cross-chain bridge`,
url: typeof window !== 'undefined' ? window.location.origin : 'https://bridge.lux.network', // Brand-agnostic by design — at runtime the browser
// window.location.origin resolves to whatever tenant
// hostname is loading the bundle. SSR fallback uses
// apiHost stripped of its API subdomain, otherwise a
// tenant-neutral placeholder. Never bake Lux-specific
// URLs into the SDK (jurisdiction neutrality).
url:
typeof window !== 'undefined'
? window.location.origin
: deriveAppOrigin(cfg.apiHost),
icons: brandIcon, icons: brandIcon,
}, },
showQrModal: true, showQrModal: true,
@@ -167,6 +176,31 @@ export function buildWagmiConfig(cfg: BridgeConfig): Config {
}) })
} }
/**
* Best-effort SSR fallback for the WalletConnect metadata URL.
*
* api.bridge.lux.network → https://bridge.lux.network
* api.bridge.zoo.network → https://bridge.zoo.network
* bridge-api.example.io → https://example.io (1-level strip)
*
* The SDK never makes a real network call against this URL; it's only
* the dapp identity card WalletConnect shows to the user mid-handshake.
* On client-side hydration, window.location.origin overrides this — so
* the fallback only matters for the very first SSR render frame.
*/
export function deriveAppOrigin(apiHost: string): string {
try {
const u = new URL(apiHost)
const host = u.hostname
if (host.startsWith('api.')) {
return `${u.protocol}//${host.slice(4)}`
}
return `${u.protocol}//${host}`
} catch {
return 'https://localhost'
}
}
/** /**
* Map a bridge-internal chain ID (`evm:1`, `lux:96369`, `svm:101`) to the * Map a bridge-internal chain ID (`evm:1`, `lux:96369`, `svm:101`) to the
* numeric wagmi/viem chainId. Returns null for non-EVM chains — those are * numeric wagmi/viem chainId. Returns null for non-EVM chains — those are
+36 -11
View File
@@ -1,6 +1,6 @@
// mountBridge — declarative SDK entrypoint. // mountBridge — declarative SDK entrypoint.
// //
// Mirrors the `mountExchange` pattern from @/exchange: the host // Mirrors the `mountExchange` pattern from the upstream exchange SDK: the host
// passes a config and we own the DOM + React root from there. // passes a config and we own the DOM + React root from there.
// //
// import { mountBridge } from '@luxfi/bridge' // import { mountBridge } from '@luxfi/bridge'
@@ -40,20 +40,45 @@ async function ensureGuiConfigured(): Promise<void> {
) { ) {
return return
} }
// The @hanzo/gui package's source exports `createGui` (after the monorepo's // @hanzo/gui ships two compatible naming surfaces across its 7.x
// postinstall rename of upstream `createHanzogui` → `createGui`), but its // dist artefacts:
// shipped .d.ts files still carry the original Hanzogui name. The runtime // - 7.0.0 shipped an empty dist/; the consumer-side postinstall
// export name is canonical here — we widen the import type to `any` so // (scripts/patch-hanzogui-exports.cjs) rewrites package.json to
// tsc resolves the type-only mismatch without forcing every consumer to // point at src/, where the upstream `createHanzogui` /
// ship the same rename patch. // `HanzoguiProvider` / `getDefaultHanzoguiConfig` were renamed
// to `createGui` / `GuiProvider` / `getDefaultGuiConfig`.
// - 7.2.x ships a populated dist/ — the patch script no longer
// rewrites it, so the dist/ exports use the upstream Hanzogui
// symbol names verbatim.
//
// The SDK accepts whichever pair is present so a tenant can sit on
// either 7.0.0 (with the patch) or 7.2.x (without) and mount cleanly.
// Helper widens types so tsc doesn't require both names to be present
// on the static declaration surface.
const [gui, cfgDefault] = await Promise.all([ const [gui, cfgDefault] = await Promise.all([
import('@hanzo/gui'), import('@hanzo/gui'),
import('@hanzogui/config-default'), import('@hanzogui/config-default'),
]) ])
const createGui = (gui as unknown as { const guiAny = gui as unknown as Record<string, unknown>
createGui: (c: unknown) => unknown const cfgAny = cfgDefault as unknown as Record<string, unknown>
}).createGui const createGui =
createGui(cfgDefault.getDefaultGuiConfig()) (guiAny.createGui as ((c: unknown) => unknown) | undefined) ??
(guiAny.createHanzogui as ((c: unknown) => unknown) | undefined)
if (!createGui) {
throw new Error(
'mountBridge: neither @hanzo/gui.createGui nor createHanzogui is exported — ' +
'install @hanzo/gui >=7.0.0 or run the patch-hanzogui-exports.cjs postinstall',
)
}
const getDefaultConfig =
(cfgAny.getDefaultGuiConfig as (() => unknown) | undefined) ??
(cfgAny.getDefaultHanzoguiConfig as (() => unknown) | undefined)
if (!getDefaultConfig) {
throw new Error(
'mountBridge: neither @hanzogui/config-default.getDefaultGuiConfig nor getDefaultHanzoguiConfig is exported',
)
}
createGui(getDefaultConfig())
} }
/** /**
+131 -3
View File
@@ -119,6 +119,77 @@ export interface BridgeFireblocksConfig {
vaultAccountId?: string vaultAccountId?: string
} }
/**
* Cloud-HSM cosigner provider. Each value is a synchronous,
* HSM-backed signing layer. Adding a new cloud here is a single
* adapter on the bridge backend (`domain/cosigners.ts::CloudSigner`).
*/
export type CloudHsmProvider =
| "gcp_kms"
| "aws_kms"
| "azure_key_vault"
| "vault_transit"
/**
* Signing algorithm. Bridge accepts a narrow allow-list — adding an
* algorithm requires evaluating its security against the existing
* settlement-signature acceptance rules on `app/server`. The default
* for EVM cross-chain settlement is `secp256k1_ecdsa_sha256`.
*/
export type CosignerAlgorithm =
| "secp256k1_ecdsa_sha256"
| "ed25519"
| "rsa_pss_sha256"
/**
* Cloud-HSM cosigner config — one signer per entry.
*
* Layers a synchronous HSM-backed signature on top of the native MPC
* threshold network. The bridge backend enforces 2-of-2 (or N-of-N
* across multiple cloud HSMs + native MPC) before releasing settlement.
* Right model when a tenant's policy is "this institutional treasury
* key in cloud HSM must attest to every bridge transfer."
*
* The browser SDK is **declarative only**. It never authenticates to
* any cloud KMS directly — the bridge backend resolves identity at
* signing time using the cloud's native workload-identity story:
*
* GCP: Workload Identity Federation / attached service account
* AWS: IAM role / OIDC role assumption
* Azure: Managed Identity / workload identity
* Vault: AppRole / Kubernetes auth / short-lived OIDC token
*
* `identityHint` is a NON-SECRET hint (e.g. a service-account email,
* a role ARN, a Vault role name) — never credentials. The backend
* uses it only to scope which workload-identity binding to consult.
* SA-JSON-key mode is intentionally not supported in the SDK
* configuration surface; tenants that lack a managed-identity setup
* should bind one before enabling cloud-HSM cosign.
*/
export interface BridgeCloudHsmConfig {
/** Cloud provider for this signer. */
provider: CloudHsmProvider
/**
* Provider-specific full key reference. All info needed to locate
* the key in the cloud lives here — no per-provider config blob is
* required because each provider's URI form is self-describing:
*
* gcp_kms `projects/{p}/locations/{l}/keyRings/{r}/cryptoKeys/{k}/cryptoKeyVersions/{v}`
* aws_kms `arn:aws:kms:{region}:{account}:key/{key-id}`
* azure_key_vault `https://{vault}.vault.azure.net/keys/{name}[/{version}]`
* vault_transit `transit/keys/{name}` (within a tenant's mount path)
*/
keyRef: string
/** Signing algorithm — required so the backend can reject early. */
algorithm: CosignerAlgorithm
/**
* Optional non-secret identity hint. Backend uses this only to scope
* which workload-identity binding to consult; never carries credentials.
* Examples: GCP SA email, AWS role ARN, Vault role name.
*/
identityHint?: string
}
/** /**
* MPC cluster config block. * MPC cluster config block.
* *
@@ -127,11 +198,20 @@ export interface BridgeFireblocksConfig {
* wallets; the optional private cluster is reserved for treasury / fee * wallets; the optional private cluster is reserved for treasury / fee
* accounts. * accounts.
* *
* Optionally layers external MPC custodians (Utila, Fireblocks) as * Optionally layers external MPC custodians (Utila, Fireblocks) and / or
* additional cosigners alongside the native threshold network — both * cloud-HSM signers (GCP Cloud KMS, AWS KMS, Azure Key Vault) as
* additional cosigners alongside the native threshold network. All
* blocks are independent and may be enabled together for belt-and- * blocks are independent and may be enabled together for belt-and-
* suspenders institutional flows. Native Lux MPC remains the primary * suspenders institutional flows. Native Lux MPC remains the primary
* signer; external custodians sit on top. * signer; layered cosigners sit on top — backend enforces "all listed
* must approve" before releasing settlement.
*
* Approval semantics differ by layer:
* - Utila / Fireblocks: async TAP policy, human approval, polling
* - Cloud HSM: synchronous, single API call, sub-second latency
*
* The SDK collapses both shapes into the same `cosigners[]` wire array;
* the backend dispatcher (`domain/cosigners.ts`) handles the difference.
*/ */
export interface BridgeMPCConfig { export interface BridgeMPCConfig {
/** /**
@@ -171,6 +251,54 @@ export interface BridgeMPCConfig {
* Backend enforces 2-of-2 (native + fireblocks) before releasing settlement. * Backend enforces 2-of-2 (native + fireblocks) before releasing settlement.
*/ */
fireblocks?: BridgeFireblocksConfig fireblocks?: BridgeFireblocksConfig
/**
* Optional cloud-HSM cosigners. Array form so a tenant can layer
* multiple cloud KMSs simultaneously (e.g. GCP + AWS for cross-region
* redundancy). Each entry is one synchronous HSM signer.
*
* For the Lux-Network tenant (bridge.lux.network), this is **left
* empty** — Lux relies on m-chain (native MPC) and optionally f-chain
* (FHE-secured attestation, see `fchain` below) and nothing else.
* Other tenants (Zoo / Hanzo / institutional white-labels) opt in
* here when their policy requires regulated-custodian cosign.
*/
cloudHsm?: BridgeCloudHsmConfig[]
/**
* Optional f-chain (FHE attestation) cosigner. Native to the Lux
* Network — f-chain is the FHE-secured sibling of m-chain, designed
* for confidential / leaderless attestation without leaking the
* txHash to plaintext quorum. Used as a SECOND native signer
* alongside m-chain when a tenant wants PQ-safe cosign without
* pulling in an external custodian.
*
* The Lux-Network bridge tenant may flip this on as the only
* "extra" layer beyond m-chain — keeps the signing surface entirely
* within the Lux primary network (b-chain ledger, m-chain MPC,
* f-chain FHE) with no external dependencies.
*/
fchain?: BridgeFChainConfig
}
/**
* f-chain (FHE attestation) config block.
*
* f-chain runs alongside m-chain on the Lux primary network. Where
* m-chain produces a classical threshold signature (CGGMP21 / FROST),
* f-chain produces an FHE-secured attestation over the same txHash —
* the cosign property without leaking the message to a plaintext
* threshold quorum. Same trust boundary as m-chain (Lux validator set),
* different computation model.
*/
export interface BridgeFChainConfig {
/** f-chain cluster URL. Distinct from m-chain's publicUrl. */
publicUrl: string
/**
* FHE scheme identifier. The default `ckks` works for general
* attestation; `bgv` and `bfv` are alternative lattice schemes if
* a tenant has cross-vendor interop requirements.
*/
scheme?: "ckks" | "bgv" | "bfv"
} }
/** /**
+1 -3
View File
@@ -31,9 +31,7 @@
} }
}, },
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"dependencies": {},
"peerDependencies": {},
"devDependencies": { "devDependencies": {
"typescript": "^5.7.2" "typescript": "^6.0.3"
} }
} }
+2 -3
View File
@@ -36,9 +36,8 @@
} }
}, },
"types": "./dist/esm/index.d.ts", "types": "./dist/esm/index.d.ts",
"dependencies": {},
"devDependencies": { "devDependencies": {
"typescript": "^5.7.2", "typescript": "^6.0.3",
"vitest": "^2.1.0" "vitest": "^4.1.7"
} }
} }
+17 -15
View File
@@ -15,29 +15,31 @@
"format": "prettier --write \"**/*.{ts,json,md}\"" "format": "prettier --write \"**/*.{ts,json,md}\""
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.12", "@types/jest": "^30.0.0",
"@types/node": "^20.11.24", "@types/node": "^25.9.1",
"@types/selenium-webdriver": "^4.1.21", "@types/selenium-webdriver": "^4.35.5",
"@typescript-eslint/eslint-plugin": "^7.1.0", "@typescript-eslint/eslint-plugin": "^8.59.4",
"@typescript-eslint/parser": "^7.1.0", "@typescript-eslint/parser": "^8.59.4",
"dotenv": "^16.4.5", "dotenv": "^17.4.2",
"eslint": "^8.57.0", "eslint": "^10.4.0",
"jest": "^29.7.0", "jest": "^30.4.2",
"prettier": "^3.2.5", "prettier": "^3.8.3",
"selenium-webdriver": "^4.18.1", "selenium-webdriver": "^4.44.0",
"ts-jest": "^29.1.2", "ts-jest": "^29.4.11",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.3.3" "typescript": "^6.0.3"
}, },
"dependencies": { "dependencies": {
"ethers": "^6.11.1", "ethers": "^6.16.0",
"viem": "^2.9.9" "viem": "^2.50.4"
}, },
"jest": { "jest": {
"preset": "ts-jest", "preset": "ts-jest",
"testEnvironment": "node", "testEnvironment": "node",
"testTimeout": 120000, "testTimeout": 120000,
"roots": ["<rootDir>/tests"], "roots": [
"<rootDir>/tests"
],
"moduleNameMapper": { "moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1" "^@/(.*)$": "<rootDir>/src/$1"
} }
+12 -12
View File
@@ -2,23 +2,23 @@
"name": "@luxfi/utila", "name": "@luxfi/utila",
"version": "3.0.0", "version": "3.0.0",
"dependencies": { "dependencies": {
"@bufbuild/buf": "1.46.0", "@bufbuild/buf": "1.69.0",
"@bufbuild/protobuf": "2.2.2", "@bufbuild/protobuf": "2.12.0",
"@connectrpc/connect": "2.0.0-beta.1", "@connectrpc/connect": "2.1.1",
"@connectrpc/connect-node": "2.0.0-beta.1", "@connectrpc/connect-node": "2.1.1",
"jsonwebtoken": "9.0.2", "jsonwebtoken": "9.0.3",
"tslib": "2.3.0", "tslib": "2.8.1",
"viem": "2.16.0" "viem": "2.50.4"
}, },
"main": "./dist/index.cjs.js", "main": "./dist/index.cjs.js",
"module": "./dist/index.esm.js", "module": "./dist/index.esm.js",
"typings": "./dist/index.cjs.d.ts", "typings": "./dist/index.cjs.d.ts",
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-commonjs": "^29.0.2",
"@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-typescript": "^12.1.1", "@rollup/plugin-typescript": "^12.3.0",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.10",
"rollup": "^4.27.3" "rollup": "^4.60.4"
}, },
"scripts": { "scripts": {
"build": "rollup -c rollup.config.js", "build": "rollup -c rollup.config.js",
+5656 -2255
View File
File diff suppressed because it is too large Load Diff