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