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
# 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:
workflow_dispatch:
push:
@@ -9,42 +15,41 @@ permissions:
contents: read
packages: write
jobs:
docker:
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
env:
IMAGE: ghcr.io/luxfi/bridge
notify-universe:
needs: docker
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
jobs:
build:
runs-on: lux-build
steps:
- uses: peter-evans/repository-dispatch@v3
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
token: ${{ secrets.UNIVERSE_PAT }}
repository: luxfi/universe
event-type: image-published
client-payload: |
{
"service": "bridge",
"image": "ghcr.io/luxfi/bridge",
"tag": "${{ github.ref_name }}",
"sha": "${{ github.sha }}"
}
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute tags
id: meta
run: |
set -e
tags=""
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
RUN apk add --no-cache libc6-compat python3 make g++ git
RUN corepack enable
RUN corepack enable && corepack prepare [email protected] --activate
WORKDIR /app
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
FROM ghcr.io/hanzoai/spa
+4 -4
View File
@@ -2,7 +2,7 @@
"name": "@luxbridge/lux-tenant",
"version": "1.0.0",
"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",
"author": "Lux Industries, Inc.",
"type": "module",
@@ -21,15 +21,15 @@
"@tanstack/react-query": "5.90.20",
"react": "catalog:",
"react-dom": "catalog:",
"react-native-web": "^0.19.0",
"react-native-web": "^0.21.2",
"viem": "2.30.5",
"wagmi": "2.15.5"
},
"devDependencies": {
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "^4.3.4",
"@vitejs/plugin-react": "^6.0.2",
"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) {
case SwapStatus.Failed:
return (
<div className={"inline-flex items-center " + className ?? ''} >
<div className={"inline-flex items-center " + (className ?? '')} >
<RedIcon />
<p>Failed</p>
</div>
)
case SwapStatus.Completed:
return (
<div className={"inline-flex items-center " + className ?? ''} >
<div className={"inline-flex items-center " + (className ?? '')} >
<GreenIcon />
<p>Completed</p>
</div>
)
case SwapStatus.Cancelled:
return (
<div className={"inline-flex items-center " + className ?? ''} >
<div className={"inline-flex items-center " + (className ?? '')} >
<GreyIcon />
<p>Cancelled</p>
</div>
)
case SwapStatus.UserTransferPending:
return (
<div className={"inline-flex items-center " + className ?? ''} >
<div className={"inline-flex items-center " + (className ?? '')} >
<YellowIcon />
<p>Deposite pending</p>
</div>
)
case SwapStatus.UserTransferDelayed:
return (
<div className={"inline-flex items-center " + className ?? ''} >
<div className={"inline-flex items-center " + (className ?? '')} >
<YellowIcon />
<p>Delayed</p>
</div>
)
case SwapStatus.BridgeTransferPending:
return (
<div className={"inline-flex items-center " + className ?? ''} >
<div className={"inline-flex items-center " + (className ?? '')} >
<YellowIcon />
<p>Pending</p>
</div>
)
case SwapStatus.Expired:
return (
<div className={"inline-flex items-center " + className ?? ''} >
<div className={"inline-flex items-center " + (className ?? '')} >
<GreyIcon />
<p>Expired</p>
</div>
+1 -1
View File
@@ -3,4 +3,4 @@
/// <reference types="next/navigation-types/compat/navigation" />
// 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"
},
"dependencies": {
"@headlessui/react": "^1.7.15",
"@heroicons/react": "^2.0.18",
"@luxdefi/ui": "0.2.3",
"@next/mdx": "14.1.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tooltip": "^1.0.6",
"axios": "^1.6.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"copy-to-clipboard": "^3.3.3",
"framer-motion": "^10.12.18",
"heroicons": "^2.0.18",
"lucide-react": "^0.307.0",
"@headlessui/react": "^2.2.10",
"@heroicons/react": "^2.2.0",
"@luxdefi/ui": "0.5.10",
"@next/mdx": "16.2.6",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"axios": "^1.16.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"copy-to-clipboard": "^4.0.2",
"framer-motion": "^12.40.0",
"heroicons": "^2.2.0",
"lucide-react": "^1.16.0",
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"swr": "^2.2.0",
"tailwind-merge": "^2.2.0"
"swr": "^2.4.1",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@mdx-js/react": "^3.0.0",
"@mdx-js/loader": "^3.0.0",
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@types/mdx": "^2.0.13",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/mdx": "^2.0.9",
"autoprefixer": "10.4.16",
"postcss": "8.4.33",
"postcss-import": "^16.0.0",
"autoprefixer": "10.5.0",
"postcss": "8.5.15",
"postcss-import": "^16.1.1",
"tailwindcss": "catalog:",
"tailwindcss-animate": "^1.0.7",
"typescript": "catalog:"
+36 -34
View File
@@ -41,48 +41,50 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/morgan": "^1.9.9",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/morgan": "^1.9.10",
"@types/multicoin-address-validator": "^0.5.3",
"@types/node": "^20.17.1",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.2.0",
"@typescript-eslint/parser": "^8.2.0",
"nodemon": "^3.1.4",
"prettier": "^3.3.3",
"@types/node": "^25.9.1",
"@types/uuid": "^11.0.0",
"@typescript-eslint/eslint-plugin": "^8.59.4",
"@typescript-eslint/parser": "^8.59.4",
"nodemon": "^3.1.14",
"prettier": "^3.8.3",
"ts-node": "^10.9.2",
"tsc-alias": "^1.8.10",
"tsc-alias": "^1.8.17",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.5.4",
"vitest": "^2.1.0"
"typescript": "^6.0.3",
"vitest": "^4.1.7"
},
"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/utila": "^3.0.0",
"@noble/ed25519": "^2.3.0",
"@prisma/client": "^6.3.1",
"@solana/addresses": "2.0.0",
"axios": "^1.7.4",
"consul": "^2.0.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"ethers": "^6.13.4",
"express": "^4.21.1",
"express-rate-limit": "^8.3.1",
"express-validator": "^7.2.0",
"@luxfi/utila": "workspace:^",
"@noble/ed25519": "^3.1.0",
"@prisma/client": "^6.19.3",
"@solana/addresses": "6.9.0",
"axios": "^1.16.1",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"ethers": "^6.16.0",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"express-validator": "^7.3.2",
"fireblocks-sdk": "^5.39.0",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"module-alias": "^2.2.3",
"morgan": "^1.10.0",
"multicoin-address-validator": "^0.5.22",
"nats": "^2.29.3",
"prisma": "^6.3.1",
"uuid": "^9.0.0",
"helmet": "^8.2.0",
"jsonwebtoken": "^9.0.3",
"module-alias": "^2.3.4",
"morgan": "^1.10.1",
"multicoin-address-validator": "^0.5.26",
"prisma": "^6.19.3",
"uuid": "^14.0.0",
"web3": "^4.16.0",
"winston": "^3.17.0"
"winston": "^3.19.0"
},
"pnpm": {
"overrides": {
+38
View File
@@ -118,10 +118,48 @@ model Swap {
quotes Quote?
deposit_actions DepositAction[]
transactions Transaction[]
cosigner_steps CosignerStep[]
@@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 {
id Int @id @default(autoincrement())
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", () => {
return {
FireblocksSDK: vi.fn().mockImplementation(() => ({
createTransaction: createTx,
getTransactionById: getTx,
})),
FireblocksSDK: class {
createTransaction = createTx
getTransactionById = getTx
},
PeerType: { VAULT_ACCOUNT: "VAULT_ACCOUNT" },
TransactionOperation: { RAW: "RAW" },
}
@@ -182,6 +182,6 @@ describe("runFireblocks — failed / transient", () => {
const [result] = await dispatchCosigners(opts)
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[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)", () => {
@@ -147,6 +262,86 @@ describe("validateCosigners — rejections (must throw BadCosignerIntent)", () =
).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", () => {
try {
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"
import { logger } from "../utils/logger"
import { mpcRequestSigner } from "../utils/mpc-signing"
import Web3 from "web3"
import { StringCodec } from "nats"
// Bridge → mpcd integration via HTTP.
//
// Replaces the legacy NATS/Consul-coupled BridgeMPCIntegration with a
// synchronous HTTP call against the canonical mpcd daemon
// (~/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 {
txId: string
@@ -32,17 +58,17 @@ interface BridgeSignResponse {
}
/**
* Bridge MPC Integration — NATS-based
* Publishes signing requests to NATS JetStream, MPC nodes respond via result topics.
* Bridge ↔ mpcd integration. Singleton. Lazy IAM + MPC client init so
* the bridge boots cleanly when MPC env is unset (standalone mode).
*/
export class BridgeMPCIntegration {
private static instance: BridgeMPCIntegration
private isInitialized = false
private pendingSignatures: Map<string, (result: any) => void> = new Map()
private iam?: IAMClient
private mpc?: MPCClient
private constructor() {
// Setup signature completion handler
mpcService.on("signatureComplete", this.handleSignatureComplete.bind(this))
// Nothing to do until initialize() runs.
}
static getInstance(): BridgeMPCIntegration {
@@ -52,77 +78,82 @@ export class BridgeMPCIntegration {
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> {
if (this.isInitialized) return
try {
await mpcRequestSigner.loadKey()
await mpcService.initialize()
const mpcUrl = process.env.BRIDGE_MPC_URL
const iamIssuer = process.env.BRIDGE_IAM_ISSUER
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
logger.info("Bridge MPC integration initialized")
// Subscribe to MPC results now that NATS is connected
this.subscribeToMpcResults()
} catch (error) {
logger.error("Failed to initialize Bridge MPC integration:", error)
throw error
return
}
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 {
if (!signData.txId || !signData.fromNetworkId || !signData.toNetworkId) {
return { status: false, msg: "Missing required fields" }
}
// 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)
const res = await this.mpc.sign({
walletId,
keyType: "secp256k1",
message,
})
// 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 {
status: true,
data: {
@@ -130,127 +161,56 @@ export class BridgeMPCIntegration {
contract: "",
from: "",
tokenAmount: "0",
signature: result.signature,
mpcSigner: result.mpcSigner || "0x" + "0".repeat(40),
signature: res.signature.startsWith("0x")
? res.signature
: "0x" + res.signature,
mpcSigner: walletId,
hashedTxId,
toTokenAddressHash: Web3.utils.keccak256(signData.toTokenAddress),
vault: false,
},
}
} catch (error) {
logger.error("MPC signature generation failed:", error)
return {
status: false,
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}`)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[bridge-mpc] sign failed for tx=${signData.txId}: ${msg}`)
return { status: false, msg: `MPC signature failed: ${msg}` }
}
}
/**
* Subscribe to MPC signing result topics.
* MPC nodes publish results with tx_id (not sessionId), so match by tx_id prefix.
* Record a completion marker for a swap. Persists to Postgres rather
* 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(
hashedTxId: string
hashedTxId: string,
): Promise<{ status: boolean; msg: string }> {
try {
await mpcService.consul.kv.set({
Key: `bridge/transactions/${hashedTxId}/completed`,
Value: JSON.stringify({
timestamp: Date.now(),
nodeId: mpcService.config.nodeId,
}),
await prisma.swap.updateMany({
where: { id: hashedTxId },
data: { metadata_sequence_number: Date.now() },
})
return { status: true, msg: "success" }
} catch (error) {
logger.error("Failed to complete swap:", error)
} catch (err) {
logger.error(`[bridge-mpc] completeSwap failed for ${hashedTxId}:`, err)
return {
status: false,
msg: error instanceof Error ? error.message : String(error),
msg: err instanceof Error ? err.message : String(err),
}
}
}
async getHealthStatus(): Promise<any> {
return mpcService.getNetworkStatus()
/** Cluster status — proxies through MPCClient when configured. */
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"
import axios from "axios"
// EVM signer wired through the canonical MPCClient.
//
// 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"
const MPC_API_TOKEN = process.env.MPC_API_TOKEN || ""
const MPC_WALLET_ID = process.env.MPC_WALLET_ID || ""
import {
JsonRpcProvider,
Transaction,
Interface,
getBytes,
} from "ethers"
interface MPCSignResult {
r: string
s: string
signature?: string
import { IAMClient } from "@/clients/iam"
import { MPCClient } from "@/clients/mpc"
import logger from "@/logger"
// 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 = () => ({
Authorization: `Bearer ${MPC_API_TOKEN}`,
"Content-Type": "application/json",
})
/** Test hook — drop the cached client so a fresh env config rebuilds it. */
export function _resetMpcClientForTests(): void {
_mpcClient = undefined
}
/**
* Sign and broadcast an EVM contract call via MPC.
* 1. Build unsigned tx (to, data, gas, nonce, chainId)
* 2. Serialize the unsigned tx hash
* 3. POST to MPC API /transactions with raw_tx = unsigned tx hash
* 4. Get back r, s signature components
* 5. Attach signature to tx and broadcast via provider
* Sign and broadcast an EVM contract call via the canonical MPCClient.
*
* Flow:
* 1. Resolve the bridge's settlement wallet (MPCClient.getWallet) to
* get its ETH address.
* 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(
provider: JsonRpcProvider,
@@ -30,22 +69,33 @@ export async function mpcSignAndSend(
data: string,
value: bigint = 0n,
): Promise<string> {
const feeData = await provider.getFeeData()
const network = await provider.getNetwork()
// Get nonce for the MPC wallet address
const walletInfo = await axios.get(
`${MPC_API_URL}/api/v1/wallets/${MPC_WALLET_ID}`,
{ headers: mpcHeaders() }
)
const fromAddress = walletInfo.data.ethAddress || walletInfo.data.eth_address
if (!fromAddress) {
throw new Error(`MPC wallet ${MPC_WALLET_ID} has no ETH address`)
const client = getMpcClient()
if (!client) {
throw new Error(
"MPC signing unavailable: BRIDGE_MPC_URL / BRIDGE_IAM_* env block is incomplete",
)
}
const walletId = process.env.BRIDGE_MPC_WALLET_ID
if (!walletId) {
throw new Error(
"MPC signing unavailable: BRIDGE_MPC_WALLET_ID is not set",
)
}
// 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)
// Build unsigned transaction
const tx = Transaction.from({
type: 2, // EIP-1559
to,
@@ -55,87 +105,72 @@ export async function mpcSignAndSend(
chainId: network.chainId,
maxFeePerGas: feeData.maxFeePerGas,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
gasLimit: 300000n,
gasLimit: 300_000n,
})
// Get the unsigned tx hash to sign
const unsignedHash = tx.unsignedHash
const rawTxHex = Buffer.from(getBytes(unsignedHash)).toString("hex")
// 3. Hash to sign.
const unsignedHashBytes = getBytes(tx.unsignedHash)
// Submit to MPC API for signing
const signResp = await axios.post(
`${MPC_API_URL}/api/v1/transactions`,
{
wallet_id: MPC_WALLET_ID,
tx_type: "bridge_payout",
chain: `eip155:${network.chainId}`,
to_address: to,
raw_tx: rawTxHex,
},
{ headers: mpcHeaders() }
)
// 4. Threshold sign — single round-trip.
const result = await client.sign({
walletId,
keyType: "secp256k1",
message: unsignedHashBytes,
})
const txRecord = signResp.data
// Poll for signature completion (MPC signing is async)
let signed = txRecord
for (let i = 0; i < 30; i++) {
if (signed.status === "signed" || signed.status === "confirmed") break
if (signed.status === "failed") throw new Error("MPC signing failed")
await new Promise((r) => setTimeout(r, 2000))
const poll = await axios.get(
`${MPC_API_URL}/api/v1/transactions/${signed.id}`,
{ headers: mpcHeaders() }
)
signed = poll.data
// 5. Attach signature + broadcast.
let r = result.r
let s = result.s
let v = result.v
if (!r || !s) {
// Some mpcd response shapes return the compact 65-byte signature in
// result.signature (hex). Split it manually as a fallback.
const sigHex = result.signature.replace(/^0x/, "")
if (sigHex.length < 130) {
throw new Error(
`MPC sign returned signature too short to split (got ${sigHex.length} hex chars; expected ≥130 for r||s||v)`,
)
}
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 = {
r: "0x" + rBuf.toString("hex"),
s: "0x" + sBuf.toString("hex"),
v: 0,
r: r.startsWith("0x") ? r : "0x" + r,
s: s.startsWith("0x") ? s : "0x" + s,
v,
}
// Broadcast
const signedTxHex = tx.serialized
const broadcastResult = await provider.broadcastTransaction(signedTxHex)
await broadcastResult.wait()
return broadcastResult.hash
const broadcast = await provider.broadcastTransaction(tx.serialized)
await broadcast.wait()
logger.info(
`[mpc-signer] broadcast tx=${broadcast.hash} from=${fromAddress} via mpcd wallet=${walletId}`,
)
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(
provider: JsonRpcProvider,
tokenAddress: string,
recipient: string,
amount: bigint,
abi: any[],
abi: unknown[],
): Promise<string> {
const iface = new Interface(abi)
const iface = new Interface(abi as ConstructorParameters<typeof Interface>[0])
const data = iface.encodeFunctionData("bridgeMint", [recipient, amount])
return mpcSignAndSend(provider, tokenAddress, data)
}
/**
* Send native token (LUX/ZOO) via MPC signer.
* Send native token via MPC signer.
*/
export async function mpcSendNative(
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 {
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 { IAMClient } from "@/clients/iam"
import { MPCClient } from "@/clients/mpc"
import { encodeSubstrateAddress, SUBSTRATE_NETWORKS } from "./substrate-address.js"
import { checkSubstrateDeposit } from "./substrate-deposit.js"
/**
* Native MPC wallet integration for Lux Bridge.
* Replaces Utila/Fireblocks — uses our own MPC API to create wallets
* and derives addresses for all supported chains from a single keygen.
* Native MPC wallet integration for the bridge.
*
* 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
const NETWORK_ADDRESS_TYPE: Record<string, 'eth' | 'btc' | 'sol' | 'ton' | 'xrp' | 'dot'> = {
// EVM chains use eth address
@@ -55,121 +82,115 @@ const NETWORK_ADDRESS_TYPE: Record<string, 'eth' | 'btc' | 'sol' | 'ton' | 'xrp'
CARDANO_MAINNET: 'sol',
}
interface MPCKeygenResult {
wallet_id: string
ecdsa_pub_key: string
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'
}
// The MPCClient.keygen / getWallet response shapes are defined in
// clients/mpc.ts. This module just consumes them; no per-bridge type
// duplication.
/**
* Create a new MPC wallet for a bridge deposit.
* Triggers keygen on our MPC cluster and returns the appropriate address
* for the given network.
* Create a new MPC wallet for a bridge deposit via the canonical
* MPCClient. Returns the network-appropriate address in the
* 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
addresses: { address: string }[]
}> {
const mpcUrl = getMpcApiUrl()
const walletId = `bridge-${networkInternalName.toLowerCase()}-${Date.now()}`
const client = getMpcClient()
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 {
const resp = await fetch(`${mpcUrl}/keygen`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ wallet_id: walletId }),
signal: AbortSignal.timeout(120_000),
})
logger.info(
`[mpc-wallet] keygen for ${networkInternalName} (vault=${vaultId}, name=${walletName}, protocol=${protocol})`,
)
if (!resp.ok) {
const errBody = await resp.text()
throw new Error(`MPC keygen failed (${resp.status}): ${errBody}`)
}
const result = await client.keygen({
vaultId,
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') {
throw new Error(`MPC keygen error: ${result.error || 'unknown'}`)
}
// Pick the right address for this network
const addrType = NETWORK_ADDRESS_TYPE[networkInternalName] || 'eth'
let address: string
switch (addrType) {
case 'btc':
address = result.btc_address
break
case 'sol':
case 'ton':
address = result.sol_address
break
case 'xrp':
// XRP uses secp256k1 but with different address derivation
// For now, use the ETH address as an identifier;
// proper rAddress derivation should be added
address = result.eth_address
break
case 'dot': {
// 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')
let address: string | undefined
switch (addrType) {
case "btc":
address = wallet.btcAddress
break
case "sol":
case "ton":
address = wallet.solAddress
break
case "xrp":
// XRP uses secp256k1 but with rAddress derivation. mpcd doesn't
// currently return rAddress, so use ETH address as the wallet
// identifier; bridge-side derivation should be added when XRPL
// settlement ships.
address = wallet.ethAddress
break
case "dot": {
// SS58-encode the ed25519 public key.
const pubKeyHex = wallet.eddsaPubKey
if (!pubKeyHex) {
throw new Error(
"MPC keygen did not return eddsa public key for Substrate address derivation",
)
address = encodeSubstrateAddress(pubKeyBytes, SUBSTRATE_NETWORKS.POLKADOT)
break
}
default:
address = result.eth_address
const pubKeyBytes = new Uint8Array(
Buffer.from(
pubKeyHex.startsWith("0x") ? pubKeyHex.slice(2) : pubKeyHex,
"hex",
),
)
address = encodeSubstrateAddress(pubKeyBytes, SUBSTRATE_NETWORKS.POLKADOT)
break
}
default:
address = wallet.ethAddress
}
if (!address) {
throw new Error(`No ${addrType} address returned from MPC keygen`)
}
if (!address) {
throw new Error(
`MPC keygen returned no ${addrType} address for ${networkInternalName} (wallet=${result.walletId})`,
)
}
logger.info(`MPC wallet created for ${networkInternalName}`, {
walletId: result.wallet_id,
address,
addrType,
})
logger.info(
`[mpc-wallet] created wallet=${result.walletId} address=${address} on ${networkInternalName}`,
)
// Return in Utila-compatible format: name###address
return {
name: result.wallet_id,
addresses: [{ address }],
}
} catch (error) {
logger.error(`Failed to create MPC wallet for ${networkInternalName}`, { error })
throw error
return {
name: result.walletId,
addresses: [{ address }],
}
}
+52
View File
@@ -13,6 +13,10 @@ import { createMPCWalletForDeposit, checkNativeDeposit, archiveMPCWallet, NETWOR
import { isMPCSigningEnabled, mpcBridgeMint, mpcSendNative } from "./mpc-signer"
import type { CosignerIntent } from "./cosigners"
import {
dispatchCosignersForSwap,
persistCosignerIntents,
} from "./cosigners"
export interface SwapData {
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
const nativeCurrency = await prisma.currency.findFirst({
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({
where: { id },
data: {
+1 -1
View File
@@ -25,7 +25,7 @@ router.get("/", async (req: Request, res: Response) => {
// method: GET and it's public
router.get("/:transaction_has", async (req: Request, res: Response) => {
try {
const transaction_has = req.params.transaction_has
const transaction_has = req.params.transaction_has as string
const result = await getHasBySwaps(transaction_has)
console.log("🚀 ~ result:", result)
res.status(200).json({ data: result })
+1 -1
View File
@@ -10,7 +10,7 @@ router.get(
"/from/:fromAsset/to/:toAsset",
async (req: Request, res: Response) => {
try {
const { fromAsset, toAsset } = req.params
const { fromAsset, toAsset } = req.params as { fromAsset: string; toAsset: string }
const version = req.query.version
res.status(200).json({
+11 -6
View File
@@ -11,12 +11,17 @@ router.get(
async (req: Request, res: Response) => {
try {
const {
source_network,
source_asset,
destination_network,
destination_asset
} = req.params;
const {
source_network,
source_asset,
destination_network,
destination_asset
} = req.params as {
source_network: string
source_asset: string
destination_network: string
destination_asset: string
};
const {
amount,
+2 -2
View File
@@ -223,7 +223,7 @@ router.put("/expire/:swapId", async (req: Request, res: Response) => {
// method: PUT and it's public
router.get("/payout/:swapId", async (req: Request, res: Response) => {
try {
const swapId = req.params.swapId
const swapId = req.params.swapId as string
const data = await handlerUtilaPayoutAction(swapId)
res.status(200).json(data)
} catch (err: any) {
@@ -237,7 +237,7 @@ router.get("/payout/:swapId", async (req: Request, res: Response) => {
// method: PUT and it's public
router.get("/deposit-check/:swapId", async (req: Request, res: Response) => {
try {
const swapId = req.params.swapId
const swapId = req.params.swapId as string
await handlerCheckDeposit(swapId)
res.status(200).json({ msg: "success" })
} catch (err: any) {
+1 -1
View File
@@ -8,7 +8,7 @@ const router: Router = Router()
// method: GET and it's public
router.get("/price/:token_id", async (req: Request, res: Response) => {
try {
const token_id = req.params.token_id
const token_id = req.params.token_id as string
const price = await getTokenPrice(token_id)
res.status(200).json({ data: {
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 networks from "@/routes/networks"
import exchanges from "@/routes/exchanges"
import { mpcService } from "@/services/mpc-service"
import { bridgeMPC } from "@/domain/mpc-bridge"
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) => {
try {
const mpcStatus = await mpcService.getNetworkStatus()
const mpcStatus = await bridgeMPC.getHealthStatus()
res.json({
status: "healthy",
mpc: mpcStatus,
timestamp: new Date().toISOString()
timestamp: new Date().toISOString(),
})
} catch (error) {
res.status(503).json({
status: "unhealthy",
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 () => {
logger.info(`>> Server Is Running On Port ${port}`)
// Initialize MPC signing service (NATS-based, used for bridge getsig endpoint)
// Wallet creation uses HTTP to MPC API directly via mpc-wallet.ts
// Initialize MPC integration (HTTP to mpcd — no NATS, no Consul).
// 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 {
logger.info("Initializing MPC signing service...")
await mpcService.initialize()
logger.info("Initializing MPC integration (HTTP)...")
await bridgeMPC.initialize()
logger.info("MPC signing service initialized successfully")
logger.info("MPC integration initialized")
} 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
-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' {
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
declare module '../utils/logger' {
export const logger: {
@@ -107,4 +20,4 @@ declare module '../utils/logger' {
warn(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": {
"ignoreDeprecations": "6.0",
"baseUrl": "./src", // Base directory for module resolution
"paths": {
"@/*": ["*"] // Map "@/..." to "./src/..." for cleaner imports
+8
View File
@@ -14,6 +14,14 @@ export default defineConfig({
resolve: {
alias: {
"@": 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:
# Lux ID Service (Casdoor-based unified authentication)
lux-id:
# Identity provider (Casdoor-based unified authentication).
# Brand-neutral container name + service id — tenants can point any
# OIDC provider here without renaming.
iam:
image: casbin/casdoor:latest
container_name: bridge-lux-id
container_name: bridge-iam
ports:
- "8000:8000"
environment:
- RUNNING_IN_DOCKER=true
volumes:
- ./config/casdoor/app.conf:/conf/app.conf
- lux-id-data:/var/lib/casdoor
- iam-data:/var/lib/casdoor
networks:
- bridge-network
depends_on:
lux-id-db:
iam-db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8000"]
@@ -21,16 +42,16 @@ services:
timeout: 3s
retries: 5
# Lux ID Database (PostgreSQL for Casdoor)
lux-id-db:
# Identity provider database.
iam-db:
image: ghcr.io/hanzoai/sql:latest
container_name: bridge-lux-id-db
container_name: bridge-iam-db
environment:
- POSTGRES_USER=casdoor
- POSTGRES_PASSWORD=${CASDOOR_DB_PASSWORD:-changeme}
- POSTGRES_PASSWORD=${IAM_DB_PASSWORD:-changeme}
- POSTGRES_DB=casdoor
volumes:
- lux-id-db-data:/var/lib/postgresql
- iam-db-data:/var/lib/postgresql/data
ports:
- "5434:5432"
networks:
@@ -41,63 +62,7 @@ services:
timeout: 3s
retries: 5
# KMS Service (Mock for local development)
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
# Bridge swap state (canonical, via Prisma).
postgres:
image: ghcr.io/hanzoai/sql:latest
container_name: bridge-postgres
@@ -118,17 +83,26 @@ services:
timeout: 3s
retries: 5
# MPC nodes are run locally using Go-installed tools:
# 1. make install-mpc # Install MPC tools via go install
# 2. make start-mpc-nodes # Start local MPC network
# 3. cd app/server && pnpm dev
# 4. cd app/bridge && pnpm dev
# mpcd (the canonical Lux threshold-MPC daemon) and the KMS service
# are NOT in this compose. Run them out-of-band:
#
# # mpcd — github.com/luxfi/mpc
# 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:
lux-id-data:
lux-id-db-data:
nats-data:
consul-data:
iam-data:
iam-db-data:
postgres-data:
networks:
@@ -136,4 +110,4 @@ networks:
driver: bridge
ipam:
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 —
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 ?? '[email protected]',
},
// …
}
```
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
- **`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.
## Publishing
@@ -185,6 +231,161 @@ brand: │ pkg/bridge/src/app/ │
`pulsar` (MLWE), `corona` (RLWE), `magnetar` (lattice research variant) —
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)
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"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
"@types/node": "20.14.15",
"typescript": "5.7.2"
"@playwright/test": "^1.60.0",
"@types/node": "25.9.1",
"typescript": "6.0.3"
}
}
+2 -2
View File
@@ -20,7 +20,7 @@
"author": "Lux Network",
"license": "MIT",
"dependencies": {
"@noble/ed25519": "^2.3.0"
"@noble/ed25519": "^3.1.0"
},
"workspaces": [
"app/*",
@@ -41,6 +41,6 @@
}
},
"devDependencies": {
"playwright": "^1.58.2"
"playwright": "^1.60.0"
}
}
+27 -28
View File
@@ -1,7 +1,7 @@
{
"name": "@luxfi/bridge",
"version": "1.0.3",
"description": "Lux Bridge SDK \u2014 embed bridge.lux.network into any host application",
"version": "1.0.5",
"description": "Lux Bridge SDK — embed bridge.lux.network into any host application",
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public",
@@ -77,7 +77,7 @@
"pub": "npm publish"
},
"dependencies": {
"@hanzo/gui": "^7.0.0",
"@hanzo/gui": "^7.2.2",
"@hanzogui/animate": "^7.0.0",
"@hanzogui/animate-presence": "^7.0.0",
"@hanzogui/button": "^7.0.0",
@@ -87,36 +87,35 @@
"@hanzogui/helpers": "^7.0.0",
"@hanzogui/input": "^7.0.0",
"@hanzogui/themes": "^7.0.0",
"@luxfi/brand": "^1.0.0",
"@luxfi/threshold": "workspace:*",
"@tanstack/react-query": "5.90.20",
"viem": "2.30.5",
"wagmi": "2.15.5"
"@tanstack/react-query": "5.100.14",
"viem": "2.50.4",
"wagmi": "3.6.15"
},
"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/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/progress": "^7.0.0",
"@hanzogui/visually-hidden": "^7.0.0",
"@hanzogui/image": "^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/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": {
"@hanzogui/popover": {
@@ -190,6 +189,6 @@
"happy-dom": "^20.9.0",
"jsdom": "^29.1.1",
"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.
import { useEffect, type FC, type ReactNode } from 'react'
// @hanzo/gui source exports `GuiProvider` (after the monorepo's postinstall
// rename) but its shipped .d.ts files still carry the upstream
// `HanzoguiProvider` name. We pull the runtime symbol directly from the
// untyped re-export module to avoid forcing every consumer of @luxfi/bridge
// to also ship the rename patch.
// @hanzo/gui ships two compatible naming surfaces across 7.x dists:
// 7.0.0 (empty dist, patched in by postinstall) exposes `GuiProvider`;
// 7.2.x (populated dist) exposes the upstream `HanzoguiProvider`. SDK
// accepts whichever is present so a tenant on either pin works.
import * as gui from '@hanzo/gui'
import { applyBrandMetadata, setConfig } from './config'
@@ -33,12 +32,10 @@ import { BridgeApp } from './app/BridgeApp'
import { setRpcClient } from './app/lib/bridge-api'
import { BridgeRPCClient } from './app/lib/bridge-rpc'
const GuiProvider: FC<{
children: ReactNode
defaultTheme?: 'light' | 'dark'
}> = (gui as unknown as {
GuiProvider: FC<{ children: ReactNode; defaultTheme?: 'light' | 'dark' }>
}).GuiProvider
const guiAny = gui as unknown as Record<string, unknown>
const GuiProvider = (guiAny.GuiProvider ?? guiAny.HanzoguiProvider) as
| FC<{ children: ReactNode; defaultTheme?: 'light' | 'dark' }>
| undefined
export interface BridgeProps {
/** 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
// runtime config is configured separately by `mountBridge` via
// `createGui(getDefaultGuiConfig())`.
if (!GuiProvider) {
throw new Error(
'@hanzo/gui exports neither GuiProvider nor HanzoguiProvider — install >=7.0.0',
)
}
return (
<GuiProvider defaultTheme="dark">
<BridgeApp />
+32
View File
@@ -439,6 +439,36 @@ export function useTransfers(): TransferState {
...(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
// createMPCWalletForDeposit() returns a chain-appropriate address
@@ -520,6 +550,8 @@ export function useTransfers(): TransferState {
cfg.env,
cfg.mpc?.utila,
cfg.mpc?.fireblocks,
cfg.mpc?.cloudHsm,
cfg.mpc?.fchain,
chains,
assets,
patch,
+41 -6
View File
@@ -196,9 +196,15 @@ export async function fetchQuote(
* Optional layered-cosigner intent.
*
* The SDK forwards PUBLIC identifiers only — `orgId`, `clientId`, `apiKey`,
* vault ids. The bridge backend uses these to look up the corresponding
* secret half from KMS and complete the cosign on behalf of the tenant.
* key references, vault ids. The bridge backend uses these to look up the
* 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.
*
* 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 =
| {
@@ -214,6 +220,18 @@ export type CosignerIntent =
apiHost?: 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). */
export interface CreateSwapParams {
@@ -494,11 +512,28 @@ function serializeCosigner(c: CosignerIntent): Record<string, unknown> {
...(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 {
kind: 'fireblocks',
api_key: c.apiKey,
...(c.apiHost ? { api_host: c.apiHost } : {}),
...(c.vaultAccountId ? { vault_account_id: c.vaultAccountId } : {}),
kind: 'fchain',
public_url: c.publicUrl,
...(c.scheme ? { scheme: c.scheme } : {}),
}
}
+35 -1
View File
@@ -146,7 +146,16 @@ export function buildWagmiConfig(cfg: BridgeConfig): Config {
metadata: {
name: brandName,
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,
},
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
* numeric wagmi/viem chainId. Returns null for non-EVM chains — those are
+36 -11
View File
@@ -1,6 +1,6 @@
// 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.
//
// import { mountBridge } from '@luxfi/bridge'
@@ -40,20 +40,45 @@ async function ensureGuiConfigured(): Promise<void> {
) {
return
}
// The @hanzo/gui package's source exports `createGui` (after the monorepo's
// postinstall rename of upstream `createHanzogui` → `createGui`), but its
// shipped .d.ts files still carry the original Hanzogui name. The runtime
// export name is canonical here — we widen the import type to `any` so
// tsc resolves the type-only mismatch without forcing every consumer to
// ship the same rename patch.
// @hanzo/gui ships two compatible naming surfaces across its 7.x
// dist artefacts:
// - 7.0.0 shipped an empty dist/; the consumer-side postinstall
// (scripts/patch-hanzogui-exports.cjs) rewrites package.json to
// point at src/, where the upstream `createHanzogui` /
// `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([
import('@hanzo/gui'),
import('@hanzogui/config-default'),
])
const createGui = (gui as unknown as {
createGui: (c: unknown) => unknown
}).createGui
createGui(cfgDefault.getDefaultGuiConfig())
const guiAny = gui as unknown as Record<string, unknown>
const cfgAny = cfgDefault as unknown as Record<string, unknown>
const createGui =
(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
}
/**
* 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.
*
@@ -127,11 +198,20 @@ export interface BridgeFireblocksConfig {
* wallets; the optional private cluster is reserved for treasury / fee
* accounts.
*
* Optionally layers external MPC custodians (Utila, Fireblocks) as
* additional cosigners alongside the native threshold network — both
* Optionally layers external MPC custodians (Utila, Fireblocks) and / or
* 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-
* 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 {
/**
@@ -171,6 +251,54 @@ export interface BridgeMPCConfig {
* Backend enforces 2-of-2 (native + fireblocks) before releasing settlement.
*/
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",
"dependencies": {},
"peerDependencies": {},
"devDependencies": {
"typescript": "^5.7.2"
"typescript": "^6.0.3"
}
}
+2 -3
View File
@@ -36,9 +36,8 @@
}
},
"types": "./dist/esm/index.d.ts",
"dependencies": {},
"devDependencies": {
"typescript": "^5.7.2",
"vitest": "^2.1.0"
"typescript": "^6.0.3",
"vitest": "^4.1.7"
}
}
+17 -15
View File
@@ -15,29 +15,31 @@
"format": "prettier --write \"**/*.{ts,json,md}\""
},
"devDependencies": {
"@types/jest": "^29.5.12",
"@types/node": "^20.11.24",
"@types/selenium-webdriver": "^4.1.21",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"dotenv": "^16.4.5",
"eslint": "^8.57.0",
"jest": "^29.7.0",
"prettier": "^3.2.5",
"selenium-webdriver": "^4.18.1",
"ts-jest": "^29.1.2",
"@types/jest": "^30.0.0",
"@types/node": "^25.9.1",
"@types/selenium-webdriver": "^4.35.5",
"@typescript-eslint/eslint-plugin": "^8.59.4",
"@typescript-eslint/parser": "^8.59.4",
"dotenv": "^17.4.2",
"eslint": "^10.4.0",
"jest": "^30.4.2",
"prettier": "^3.8.3",
"selenium-webdriver": "^4.44.0",
"ts-jest": "^29.4.11",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
"typescript": "^6.0.3"
},
"dependencies": {
"ethers": "^6.11.1",
"viem": "^2.9.9"
"ethers": "^6.16.0",
"viem": "^2.50.4"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"testTimeout": 120000,
"roots": ["<rootDir>/tests"],
"roots": [
"<rootDir>/tests"
],
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1"
}
+12 -12
View File
@@ -2,23 +2,23 @@
"name": "@luxfi/utila",
"version": "3.0.0",
"dependencies": {
"@bufbuild/buf": "1.46.0",
"@bufbuild/protobuf": "2.2.2",
"@connectrpc/connect": "2.0.0-beta.1",
"@connectrpc/connect-node": "2.0.0-beta.1",
"jsonwebtoken": "9.0.2",
"tslib": "2.3.0",
"viem": "2.16.0"
"@bufbuild/buf": "1.69.0",
"@bufbuild/protobuf": "2.12.0",
"@connectrpc/connect": "2.1.1",
"@connectrpc/connect-node": "2.1.1",
"jsonwebtoken": "9.0.3",
"tslib": "2.8.1",
"viem": "2.50.4"
},
"main": "./dist/index.cjs.js",
"module": "./dist/index.esm.js",
"typings": "./dist/index.cjs.d.ts",
"devDependencies": {
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-typescript": "^12.1.1",
"@types/jsonwebtoken": "^9.0.7",
"rollup": "^4.27.3"
"@rollup/plugin-commonjs": "^29.0.2",
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-typescript": "^12.3.0",
"@types/jsonwebtoken": "^9.0.10",
"rollup": "^4.60.4"
},
"scripts": {
"build": "rollup -c rollup.config.js",
+5656 -2255
View File
File diff suppressed because it is too large Load Diff