feat: universal Teleport bridge with 35+ chain plugins

- opnet-watcher: universal binary with plugin architecture
  - 3 functional plugins (OP_NET, Solana, TON)
  - Generic EVMPlugin handles all EVM chains (Ethereum, Base, Arbitrum, etc.)
  - 17 non-EVM chain stubs (Sui, NEAR, Cosmos, XRPL, ICP, etc.)
  - Threshold signing via luxfi/crypto (CGGMP21/FROST)
  - Separate txSigner hot wallet for on-chain submission
  - Multi-tenant API with hostname-based routing
  - Confirmation depth, backing sanity checks, EIP-155 local signing
- manifests: 32 chain configs + 5 tenant configs (lux, zoo, hanzo, pars, spc)
- k8s: fix chain IDs 1330663759→4294967299, ClusterIP services, KMS secrets
- CI: conformance tests gate production deployment
This commit is contained in:
2026-04-06 23:03:19 -07:00
parent e07ee0a4b9
commit 118e977946
78 changed files with 6461 additions and 372 deletions
+73 -1
View File
@@ -9,6 +9,7 @@ on:
- 'app/bridge/**'
- 'pkg/**'
- 'contracts/**'
- 'opnet-watcher/**'
- '.github/workflows/build-and-push.yml'
- 'Dockerfile*'
- '.npmrc'
@@ -122,10 +123,79 @@ jobs:
cache-to: type=registry,ref=${{ env.IMAGE_PREFIX }}/bridge-server:buildcache,mode=max
provenance: false
build-opnet-watcher:
name: Build OPNET Watcher
runs-on: lux-build-linux-amd64
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_PREFIX }}/opnet-watcher
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push OPNET Watcher
uses: docker/build-push-action@v5
with:
context: ./opnet-watcher
file: ./opnet-watcher/Dockerfile
platforms: linux/amd64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.IMAGE_PREFIX }}/opnet-watcher:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_PREFIX }}/opnet-watcher:buildcache,mode=max
provenance: false
contract-conformance:
name: Teleport contract conformance
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
repository: luxfi/standard
path: standard
submodules: recursive
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
version: stable
- name: Teleport conformance tests
working-directory: standard
run: forge test --match-contract TeleportConformance -vvv
- name: OPNET conformance tests
working-directory: standard
run: forge test --match-contract OPNETConformance -vvv
deploy:
name: Deploy to lux-k8s
runs-on: lux-build-linux-amd64
needs: [build-ui, build-server]
needs: [build-ui, build-server, build-opnet-watcher, contract-conformance]
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
permissions:
contents: read
@@ -176,5 +246,7 @@ jobs:
run: |
kubectl rollout restart deployment/bridge-ui -n lux-bridge
kubectl rollout restart deployment/bridge-server -n lux-bridge
kubectl rollout restart deployment/opnet-watcher -n lux-bridge
kubectl rollout status deployment/bridge-ui -n lux-bridge --timeout=300s
kubectl rollout status deployment/bridge-server -n lux-bridge --timeout=300s
kubectl rollout status deployment/opnet-watcher -n lux-bridge --timeout=300s
+1 -1
View File
@@ -27,7 +27,7 @@ services:
container_name: bridge-lux-id-db
environment:
- POSTGRES_USER=casdoor
- POSTGRES_PASSWORD=casdoor123
- POSTGRES_PASSWORD=${CASDOOR_DB_PASSWORD:-changeme}
- POSTGRES_DB=casdoor
volumes:
- lux-id-db-data:/var/lib/postgresql/data
+12 -101
View File
@@ -27,7 +27,7 @@ services:
container_name: bridge-lux-id-db
environment:
- POSTGRES_USER=casdoor
- POSTGRES_PASSWORD=casdoor123
- POSTGRES_PASSWORD=${CASDOOR_DB_PASSWORD:-changeme}
- POSTGRES_DB=casdoor
volumes:
- lux-id-db-data:/var/lib/postgresql/data
@@ -41,76 +41,18 @@ services:
timeout: 3s
retries: 5
# KMS Service (Mock for local development)
kms:
image: hashicorp/vault:1.15
container_name: bridge-kms
ports:
- "8200:8200"
environment:
- VAULT_DEV_ROOT_TOKEN_ID=myroot
- VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200
cap_add:
- IPC_LOCK
networks:
- bridge-network
healthcheck:
test: ["CMD", "vault", "status"]
interval: 5s
timeout: 3s
retries: 5
# KMS: use kms.hanzo.ai in production. For local dev, set KMS_TOKEN env var.
# NATS/Consul removed — MPC uses ZAP p2p consensus.
# 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
# MPC Nodes (3 nodes for 2-of-3 threshold)
# MPC Nodes (3 nodes for 2-of-3 threshold) — ZAP p2p, no NATS/Consul
mpc-node-0:
image: ghcr.io/luxfi/mpc:latest
container_name: bridge-mpc-0
environment:
- NODE_ID=mpc-node-0
- NODE_PORT=6000
- NATS_URL=nats://nats:4222
- CONSUL_URL=http://consul:8500
- DATA_DIR=/data/mpc
- LOG_LEVEL=info
- KMS_URL=http://kms:8200
- KMS_TOKEN=myroot
- KMS_PATH=secret/mpc
volumes:
- mpc-data-0:/data/mpc
ports:
@@ -118,26 +60,17 @@ services:
networks:
- bridge-network
depends_on:
nats:
condition: service_healthy
consul:
condition: service_healthy
kms:
postgres:
condition: service_healthy
mpc-node-1:
image: ghcr.io/luxfi/lux-mpc:latest
image: ghcr.io/luxfi/mpc:latest
container_name: bridge-mpc-1
environment:
- NODE_ID=mpc-node-1
- NODE_PORT=6000
- NATS_URL=nats://nats:4222
- CONSUL_URL=http://consul:8500
- DATA_DIR=/data/mpc
- LOG_LEVEL=info
- KMS_URL=http://kms:8200
- KMS_TOKEN=myroot
- KMS_PATH=secret/mpc
volumes:
- mpc-data-1:/data/mpc
ports:
@@ -145,26 +78,17 @@ services:
networks:
- bridge-network
depends_on:
nats:
condition: service_healthy
consul:
condition: service_healthy
kms:
postgres:
condition: service_healthy
mpc-node-2:
image: ghcr.io/luxfi/lux-mpc:latest
image: ghcr.io/luxfi/mpc:latest
container_name: bridge-mpc-2
environment:
- NODE_ID=mpc-node-2
- NODE_PORT=6000
- NATS_URL=nats://nats:4222
- CONSUL_URL=http://consul:8500
- DATA_DIR=/data/mpc
- LOG_LEVEL=info
- KMS_URL=http://kms:8200
- KMS_TOKEN=myroot
- KMS_PATH=secret/mpc
volumes:
- mpc-data-2:/data/mpc
ports:
@@ -172,11 +96,7 @@ services:
networks:
- bridge-network
depends_on:
nats:
condition: service_healthy
consul:
condition: service_healthy
kms:
postgres:
condition: service_healthy
# Database
@@ -185,7 +105,7 @@ services:
container_name: bridge-postgres
environment:
- POSTGRES_USER=bridge
- POSTGRES_PASSWORD=bridge
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-changeme}
- POSTGRES_DB=bridge
volumes:
- postgres-data:/var/lib/postgresql/data
@@ -207,20 +127,16 @@ services:
environment:
- NODE_ENV=development
- PORT=5000
- DATABASE_URL=postgresql://bridge:bridge@postgres:5432/bridge
- NATS_URL=nats://nats:4222
- CONSUL_URL=http://consul:8500
- DATABASE_URL=postgresql://bridge:${POSTGRES_PASSWORD:-changeme}@postgres:5432/bridge
- NODE_ID=bridge-server-0
- MPC_THRESHOLD=2
- MPC_TOTAL_NODES=3
- MPC_KEY_PREFIX=bridge/mpc
- KMS_URL=http://kms:8200
- KMS_TOKEN=myroot
# Lux ID (Casdoor) Authentication
- AUTH_PROVIDER=casdoor
- CASDOOR_ENDPOINT=http://lux-id:8000
- CASDOOR_CLIENT_ID=lux-bridge
- CASDOOR_CLIENT_SECRET=lux-bridge-secret
- CASDOOR_CLIENT_SECRET=${CASDOOR_CLIENT_SECRET:-changeme}
- CASDOOR_ORGANIZATION=lux-network
- CASDOOR_APPLICATION=bridge
ports:
@@ -229,9 +145,6 @@ services:
- bridge-network
depends_on:
- postgres
- nats
- consul
- kms
- lux-id
- mpc-node-0
- mpc-node-1
@@ -271,8 +184,6 @@ services:
volumes:
lux-id-data:
lux-id-db-data:
nats-data:
consul-data:
mpc-data-0:
mpc-data-1:
mpc-data-2:
-250
View File
@@ -1,250 +0,0 @@
services:
# Infrastructure Services
nats:
image: nats:2.10-alpine
container_name: bridge-nats
ports:
- "4222:4222"
- "8222: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.17
container_name: bridge-consul
ports:
- "8500:8500"
- "8600: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:
image: postgres:15-alpine
container_name: bridge-postgres
environment:
- POSTGRES_USER=bridge
- POSTGRES_PASSWORD=bridge
- POSTGRES_DB=bridge
volumes:
- postgres-data:/var/lib/postgresql/data
- ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5432:5432"
networks:
- bridge-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U bridge"]
interval: 5s
timeout: 3s
retries: 5
# MPC Nodes (3 nodes for 2-of-3 threshold)
mpc-node-0:
image: ghcr.io/luxfi/bridge-mpc:latest
container_name: bridge-mpc-0
environment:
- NODE_ID=mpc-node-0
- NODE_PORT=6000
- NATS_URL=nats://nats:4222
- CONSUL_URL=http://consul:8500
- DATA_DIR=/data/mpc
- LOG_LEVEL=info
- MPC_THRESHOLD=2
- MPC_TOTAL_NODES=3
- MPC_KEY_PREFIX=bridge/mpc
volumes:
- mpc-data-0:/data/mpc
ports:
- "6000:6000"
- "9090:9090"
networks:
- bridge-network
depends_on:
nats:
condition: service_healthy
consul:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:6000/health"]
interval: 10s
timeout: 5s
retries: 3
mpc-node-1:
image: ghcr.io/luxfi/bridge-mpc:latest
container_name: bridge-mpc-1
environment:
- NODE_ID=mpc-node-1
- NODE_PORT=6000
- NATS_URL=nats://nats:4222
- CONSUL_URL=http://consul:8500
- DATA_DIR=/data/mpc
- LOG_LEVEL=info
- MPC_THRESHOLD=2
- MPC_TOTAL_NODES=3
- MPC_KEY_PREFIX=bridge/mpc
volumes:
- mpc-data-1:/data/mpc
ports:
- "6001:6000"
- "9091:9090"
networks:
- bridge-network
depends_on:
nats:
condition: service_healthy
consul:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:6000/health"]
interval: 10s
timeout: 5s
retries: 3
mpc-node-2:
image: ghcr.io/luxfi/bridge-mpc:latest
container_name: bridge-mpc-2
environment:
- NODE_ID=mpc-node-2
- NODE_PORT=6000
- NATS_URL=nats://nats:4222
- CONSUL_URL=http://consul:8500
- DATA_DIR=/data/mpc
- LOG_LEVEL=info
- MPC_THRESHOLD=2
- MPC_TOTAL_NODES=3
- MPC_KEY_PREFIX=bridge/mpc
volumes:
- mpc-data-2:/data/mpc
ports:
- "6002:6000"
- "9092:9090"
networks:
- bridge-network
depends_on:
nats:
condition: service_healthy
consul:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:6000/health"]
interval: 10s
timeout: 5s
retries: 3
# Bridge Server
bridge-server:
image: ghcr.io/luxfi/bridge-server:latest
container_name: bridge-server
environment:
- NODE_ENV=production
- PORT=3000
- DATABASE_URL=postgresql://bridge:bridge@postgres:5432/bridge
- NATS_URL=nats://nats:4222
- CONSUL_URL=http://consul:8500
- NODE_ID=bridge-server-0
- MPC_THRESHOLD=2
- MPC_TOTAL_NODES=3
- MPC_KEY_PREFIX=bridge/mpc
ports:
- "3000:3000"
networks:
- bridge-network
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_healthy
consul:
condition: service_healthy
mpc-node-0:
condition: service_healthy
mpc-node-1:
condition: service_healthy
mpc-node-2:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 3
# Bridge UI
bridge-ui:
image: ghcr.io/luxfi/bridge-ui:latest
container_name: bridge-ui
environment:
- NEXT_PUBLIC_API_URL=http://localhost:3000
- NEXT_PUBLIC_CHAIN_ENV=mainnet
ports:
- "3001:3000"
networks:
- bridge-network
depends_on:
- bridge-server
# Monitoring (optional)
prometheus:
image: prom/prometheus:latest
container_name: bridge-prometheus
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
ports:
- "9093:9090"
networks:
- bridge-network
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
grafana:
image: grafana/grafana:latest
container_name: bridge-grafana
ports:
- "3002:3000"
networks:
- bridge-network
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
- GF_USERS_ALLOW_SIGN_UP=false
volumes:
- grafana-data:/var/lib/grafana
- ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards
- ./monitoring/grafana/datasources:/etc/grafana/provisioning/datasources
volumes:
nats-data:
consul-data:
mpc-data-0:
mpc-data-1:
mpc-data-2:
postgres-data:
prometheus-data:
grafana-data:
networks:
bridge-network:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
+365 -19
View File
@@ -66,10 +66,7 @@ metadata:
namespace: lux-bridge
data:
config.yaml: |
nats:
url: nats://nats:4222
consul:
address: consul:8500
# MPC nodes coordinate via ZAP p2p — no NATS or Consul needed.
environment: mainnet
mpc_threshold: 3
mpc_total_nodes: 5
@@ -292,10 +289,6 @@ spec:
secretKeyRef:
name: bridge-secrets
key: database-url
- name: NATS_URL
value: nats://nats:4222
- name: CONSUL_URL
value: http://consul:8500
- name: NODE_ID
valueFrom:
fieldRef:
@@ -355,7 +348,7 @@ spec:
- port: 3000
targetPort: 3000
name: http
type: LoadBalancer
type: ClusterIP
---
# Bridge UI Deployment
apiVersion: apps/v1
@@ -410,7 +403,7 @@ spec:
- port: 80
targetPort: 3000
name: http
type: LoadBalancer
type: ClusterIP
---
# PostgreSQL StatefulSet
apiVersion: apps/v1
@@ -513,19 +506,29 @@ spec:
- bridge-api.lux.network
secretName: bridge-lux-tls
- hosts:
- bridge.pars.network
secretName: bridge-pars-tls
- bridge.zoo.ngo
secretName: bridge-zoo-tls
- hosts:
- bridge.hanzo.ai
secretName: bridge-hanzo-tls
- hosts:
- bridge.zoo.ngo
secretName: bridge-zoo-tls
- bridge.pars.network
secretName: bridge-pars-tls
- hosts:
- bridge.spc.network
secretName: bridge-spc-tls
rules:
# Lux Network (primary)
- host: bridge.lux.network
http:
paths:
- path: /v1/
pathType: Prefix
backend:
service:
name: opnet-watcher
port:
number: 8090
- path: /
pathType: Prefix
backend:
@@ -543,10 +546,17 @@ spec:
name: bridge-server
port:
number: 3000
# Pars Network — white-label, same bridge-ui backend
- host: bridge.pars.network
# Zoo — white-label
- host: bridge.zoo.ngo
http:
paths:
- path: /v1/
pathType: Prefix
backend:
service:
name: opnet-watcher
port:
number: 8090
- path: /
pathType: Prefix
backend:
@@ -558,6 +568,13 @@ spec:
- host: bridge.hanzo.ai
http:
paths:
- path: /v1/
pathType: Prefix
backend:
service:
name: opnet-watcher
port:
number: 8090
- path: /
pathType: Prefix
backend:
@@ -565,14 +582,343 @@ spec:
name: bridge-ui
port:
number: 80
# Zoo — white-label
- host: bridge.zoo.ngo
# Pars — white-label
- host: bridge.pars.network
http:
paths:
- path: /v1/
pathType: Prefix
backend:
service:
name: opnet-watcher
port:
number: 8090
- path: /
pathType: Prefix
backend:
service:
name: bridge-ui
port:
number: 80
number: 80
# SPC — white-label
- host: bridge.spc.network
http:
paths:
- path: /v1/
pathType: Prefix
backend:
service:
name: opnet-watcher
port:
number: 8090
- path: /
pathType: Prefix
backend:
service:
name: bridge-ui
port:
number: 80
---
# Bridge Tenant ConfigMap — loaded from manifests/tenants/*.yaml
# One key per tenant manifest. opnet-watcher reads BRIDGE_TENANT_DIR.
apiVersion: v1
kind: ConfigMap
metadata:
name: bridge-tenants
namespace: lux-bridge
data:
lux.yaml: |
id: lux
name: "Lux Network"
hostnames: ["bridge.lux.network", "bridge.lux.io"]
home_chain_id: 96369
supported_chains: [lux-c, ethereum, base, opnet, solana, ton]
teleporter_addresses:
"96369": "kms://teleport/lux-c/teleporter"
"1": "kms://teleport/lux-c/teleporter-eth"
"8453": "kms://teleport/lux-c/teleporter-eth"
"4294967299": "kms://teleport/lux-c/teleporter-btc"
"1399811149": "kms://teleport/lux-c/teleporter-sol"
"1414483791": "kms://teleport/lux-c/teleporter-ton"
branding:
logo_url: "/static/lux/logo.svg"
primary_color: "#E84142"
theme: dark
features: {swap: true, yield: true, perps: true}
zoo.yaml: |
id: zoo
name: "Zoo Network"
hostnames: ["bridge.zoo.ngo"]
home_chain_id: 200200
supported_chains: [lux-c, ethereum, base, solana]
teleporter_addresses:
"96369": "kms://teleport/lux-c/teleporter"
"1": "kms://teleport/lux-c/teleporter-eth"
"8453": "kms://teleport/lux-c/teleporter-eth"
"1399811149": "kms://teleport/lux-c/teleporter-sol"
branding:
logo_url: "/static/zoo/logo.svg"
primary_color: "#10B981"
theme: dark
features: {swap: true, yield: true, perps: false}
hanzo.yaml: |
id: hanzo
name: "Hanzo Network"
hostnames: ["bridge.hanzo.ai"]
home_chain_id: 36963
supported_chains: [lux-c, ethereum, base, opnet]
teleporter_addresses:
"96369": "kms://teleport/lux-c/teleporter"
"1": "kms://teleport/lux-c/teleporter-eth"
"8453": "kms://teleport/lux-c/teleporter-eth"
"4294967299": "kms://teleport/lux-c/teleporter-btc"
branding:
logo_url: "/static/hanzo/logo.svg"
primary_color: "#6366F1"
theme: dark
features: {swap: true, yield: true, perps: true}
pars.yaml: |
id: pars
name: "Pars Network"
hostnames: ["bridge.pars.network"]
home_chain_id: 494949
supported_chains: [lux-c, ethereum, base]
teleporter_addresses:
"96369": "kms://teleport/lux-c/teleporter"
"1": "kms://teleport/lux-c/teleporter-eth"
"8453": "kms://teleport/lux-c/teleporter-eth"
branding:
logo_url: "/static/pars/logo.svg"
primary_color: "#F59E0B"
theme: light
features: {swap: true, yield: false, perps: false}
spc.yaml: |
id: spc
name: "SPC Network"
hostnames: ["bridge.spc.network"]
home_chain_id: 36911
supported_chains: [lux-c, ethereum, base]
teleporter_addresses:
"96369": "kms://teleport/lux-c/teleporter"
"1": "kms://teleport/lux-c/teleporter-eth"
"8453": "kms://teleport/lux-c/teleporter-eth"
branding:
logo_url: "/static/spc/logo.svg"
primary_color: "#3B82F6"
theme: dark
features: {swap: true, yield: false, perps: false}
---
# Bridge Chain ConfigMap — loaded from manifests/chains/*.yaml
# One key per chain manifest. opnet-watcher reads BRIDGE_CHAIN_DIR.
apiVersion: v1
kind: ConfigMap
metadata:
name: bridge-chains
namespace: lux-bridge
data:
lux-c.yaml: |
chain_id: 96369
name: "Lux C-Chain"
class: lux_native
tier: yield_enabled
assets:
- ticker: LUX
decimals: 18
recipient_encoding: evm_address
wrapper: {symbol: LUX, contract: "token/LUX.sol", decimals: 18}
finality: {mode: instant, confirmations: 1}
ethereum.yaml: |
chain_id: 1
name: "Ethereum"
class: evm
tier: yield_enabled
assets:
- ticker: ETH
decimals: 18
recipient_encoding: evm_address
wrapper: {symbol: LETH, contract: "liquid/tokens/LETH.sol", decimals: 18}
- ticker: USDC
decimals: 6
recipient_encoding: evm_address
wrapper: {symbol: LUSD, contract: "liquid/tokens/LUSD.sol", decimals: 18}
- ticker: USDT
decimals: 6
recipient_encoding: evm_address
wrapper: {symbol: LUSD, contract: "liquid/tokens/LUSD.sol", decimals: 18}
- ticker: DAI
decimals: 18
recipient_encoding: evm_address
wrapper: {symbol: LUSD, contract: "liquid/tokens/LUSD.sol", decimals: 18}
finality: {mode: slot_finalized, confirmations: 64}
base.yaml: |
chain_id: 8453
name: "Base"
class: evm
tier: yield_enabled
assets:
- ticker: ETH
decimals: 18
recipient_encoding: evm_address
wrapper: {symbol: LETH, contract: "liquid/tokens/LETH.sol", decimals: 18}
- ticker: USDC
decimals: 6
recipient_encoding: evm_address
wrapper: {symbol: LUSD, contract: "liquid/tokens/LUSD.sol", decimals: 18}
finality: {mode: block_depth, confirmations: 12}
opnet.yaml: |
chain_id: 4294967299
name: "OP_NET"
class: bitcoin_script
tier: yield_enabled
assets:
- ticker: BTC
decimals: 8
recipient_encoding: bech32
wrapper: {symbol: LBTC, contract: "liquid/tokens/LBTC.sol", decimals: 18}
finality: {mode: block_depth, confirmations: 6}
solana.yaml: |
chain_id: 1399811149
name: "Solana"
class: account_native
tier: locale_only
assets:
- ticker: SOL
decimals: 9
recipient_encoding: base58
wrapper: {symbol: LSOL, contract: "liquid/tokens/LSOL.sol", decimals: 18}
finality: {mode: slot_finalized, confirmations: 32}
ton.yaml: |
chain_id: 1414483791
name: "TON"
class: account_native
tier: locale_only
assets:
- ticker: TON
decimals: 9
recipient_encoding: raw_bytes
wrapper: {symbol: LTON, contract: "liquid/tokens/LTON.sol", decimals: 18}
finality: {mode: block_depth, confirmations: 1}
---
# OPNET Watcher ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: opnet-watcher-config
namespace: lux-bridge
data:
config.yaml: |
lux_rpc: https://api.lux.network/ext/bc/C/rpc
zoo_rpc: https://api.zoo.network/ext/bc/C/rpc
opnet_rpc: https://opnet.lux.network/rpc
teleporter_address: ""
lux_chain_id: 96369
zoo_chain_id: 200200
opnet_chain_id: 4294967299
poll_interval_seconds: 12
confirmations: 6
health_port: 8080
---
# OPNET Watcher Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: opnet-watcher
namespace: lux-bridge
spec:
replicas: 1
selector:
matchLabels:
app: opnet-watcher
template:
metadata:
labels:
app: opnet-watcher
spec:
containers:
- name: opnet-watcher
image: ghcr.io/luxfi/opnet-watcher:latest
args: ["--config", "/config/config.yaml"]
ports:
- containerPort: 8080
name: health
- containerPort: 8090
name: api
env:
- name: MPC_API_URL
value: http://mpc-api-svc.lux-bridge.svc:8081
- name: MPC_API_TOKEN
valueFrom:
secretKeyRef:
name: bridge-secrets
key: mpc-api-token
- name: KMS_KEY_SHARES
valueFrom:
secretKeyRef:
name: opnet-watcher-secrets
key: kms-key-shares
- name: BRIDGE_TENANT_DIR
value: /etc/bridge/tenants
- name: BRIDGE_CHAIN_DIR
value: /etc/bridge/chains
volumeMounts:
- name: config
mountPath: /config
readOnly: true
- name: tenants
mountPath: /etc/bridge/tenants
readOnly: true
- name: chains
mountPath: /etc/bridge/chains
readOnly: true
livenessProbe:
httpGet:
path: /health
port: 8090
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /health
port: 8090
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "250m"
volumes:
- name: config
configMap:
name: opnet-watcher-config
- name: tenants
configMap:
name: bridge-tenants
- name: chains
configMap:
name: bridge-chains
---
# opnet-watcher-secrets is managed externally via KMS (kms.hanzo.ai).
# Apply with:
# kubectl create secret generic opnet-watcher-secrets -n lux-bridge \
# --from-literal="kms-key-shares=$(kms get OPNET_WATCHER_KMS_KEY_SHARES)" \
# --dry-run=client -o yaml | kubectl apply -f -
# DO NOT add secret values here -- never commit keys to git.
---
apiVersion: v1
kind: Service
metadata:
name: opnet-watcher
namespace: lux-bridge
spec:
selector:
app: opnet-watcher
ports:
- port: 8080
targetPort: 8080
name: health
- port: 8090
targetPort: 8090
name: api
+42
View File
@@ -0,0 +1,42 @@
# Algorand — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Status: locale_only — FROST Ed25519 signer integration pending.
chain_id: 4294967450
name: "Algorand"
class: account_native
tier: locale_only
signer:
protocol: frost
curve: ed25519
threshold:
t: 2
n: 3
finality:
mode: slot_finalized # Algorand instant finality via Pure PoS
confirmations: 1
max_reorg_depth: 0
stale_attestation_seconds: 86400
assets:
- ticker: ALGO
decimals: 6
recipient_encoding: base58 # Algorand base32/base58 addresses
wrapper:
symbol: LALGO
contract: "liquid/tokens/LALGO.sol"
decimals: 18
adapter:
deposit_method: account_watch
vault_contract: "kms://teleport/algorand/mpc-address"
teleporter_contract: "kms://teleport/lux-c/teleporter-algo"
watcher:
plugin: algorand_indexer_watcher
rpc_url: "kms://teleport/algorand/rpc"
poll_interval_ms: 3300
start_block: 0
+42
View File
@@ -0,0 +1,42 @@
# Aptos — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Status: locale_only — FROST Ed25519 signer integration pending.
chain_id: 4294967340
name: "Aptos"
class: account_native
tier: locale_only
signer:
protocol: frost
curve: ed25519
threshold:
t: 2
n: 3
finality:
mode: slot_finalized # Aptos BFT finality per block
confirmations: 1
max_reorg_depth: 0
stale_attestation_seconds: 86400
assets:
- ticker: APT
decimals: 8
recipient_encoding: raw_bytes # 32-byte Aptos address
wrapper:
symbol: LAPT
contract: "liquid/tokens/LAPT.sol"
decimals: 18
adapter:
deposit_method: account_watch
vault_contract: "kms://teleport/aptos/mpc-address"
teleporter_contract: "kms://teleport/lux-c/teleporter-apt"
watcher:
plugin: aptos_ws_watcher
rpc_url: "kms://teleport/aptos/rpc"
poll_interval_ms: 1000
start_block: 0
+48
View File
@@ -0,0 +1,48 @@
# Arbitrum One — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
chain_id: 42161
name: "Arbitrum"
class: evm
tier: bidirectional
signer:
protocol: cggmp21
curve: secp256k1
threshold:
t: 2
n: 3
finality:
mode: block_depth
confirmations: 64 # L2 blocks, ~16s; wait for L1 batch confirmation
max_reorg_depth: 128
stale_attestation_seconds: 86400
assets:
- ticker: ETH
decimals: 18
recipient_encoding: evm_address
wrapper:
symbol: LETH
contract: "liquid/tokens/LETH.sol"
decimals: 18
- ticker: ARB
decimals: 18
recipient_encoding: evm_address
wrapper:
symbol: LARB
contract: "liquid/tokens/LARB.sol"
decimals: 18
adapter:
deposit_method: event_log
vault_contract: "kms://teleport/arbitrum/teleport-vault"
teleporter_contract: "kms://teleport/lux-c/teleporter-arb"
watcher:
plugin: evm_log_watcher
rpc_url: "kms://teleport/arbitrum/rpc"
poll_interval_ms: 250
start_block: 0
+40
View File
@@ -0,0 +1,40 @@
# Avalanche C-Chain — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
chain_id: 43114
name: "Avalanche"
class: evm
tier: bidirectional
signer:
protocol: cggmp21
curve: secp256k1
threshold:
t: 2
n: 3
finality:
mode: block_depth
confirmations: 35
max_reorg_depth: 64
stale_attestation_seconds: 86400
assets:
- ticker: AVAX
decimals: 18
recipient_encoding: evm_address
wrapper:
symbol: LAVAX
contract: "liquid/tokens/LAVAX.sol"
decimals: 18
adapter:
deposit_method: event_log
vault_contract: "kms://teleport/avalanche/teleport-vault"
teleporter_contract: "kms://teleport/lux-c/teleporter-avax"
watcher:
plugin: evm_log_watcher
rpc_url: "kms://teleport/avalanche/rpc"
poll_interval_ms: 2000
start_block: 0
+51
View File
@@ -0,0 +1,51 @@
# Base (Coinbase L2) — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
chain_id: 8453
name: "Base"
class: evm
tier: yield_enabled
signer:
protocol: cggmp21
curve: secp256k1
threshold:
t: 2
n: 3
finality:
mode: block_depth
confirmations: 12 # L2 blocks, ~24s
max_reorg_depth: 64
stale_attestation_seconds: 86400
assets:
- ticker: ETH
decimals: 18
recipient_encoding: evm_address
wrapper:
symbol: LETH
contract: "liquid/tokens/LETH.sol"
decimals: 18
strategy:
name: AaveV3Strategy
contract: "bridge/teleport/strategies/AaveV3Strategy.sol"
- ticker: USDC
decimals: 6
recipient_encoding: evm_address
wrapper:
symbol: LUSD
contract: "liquid/tokens/LUSD.sol"
decimals: 18
adapter:
deposit_method: event_log
vault_contract: "kms://teleport/base/liquid-vault"
teleporter_contract: "kms://teleport/lux-c/teleporter-eth"
watcher:
plugin: evm_log_watcher
rpc_url: "kms://teleport/base/rpc"
poll_interval_ms: 2000 # ~2s Base block time
start_block: 0
+48
View File
@@ -0,0 +1,48 @@
# BNB Smart Chain — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
chain_id: 56
name: "BNB Smart Chain"
class: evm
tier: bidirectional
signer:
protocol: cggmp21
curve: secp256k1
threshold:
t: 2
n: 3
finality:
mode: block_depth
confirmations: 15 # ~45s at 3s block time
max_reorg_depth: 64
stale_attestation_seconds: 86400
assets:
- ticker: BNB
decimals: 18
recipient_encoding: evm_address
wrapper:
symbol: LBNB
contract: "liquid/tokens/LBNB.sol"
decimals: 18
- ticker: USDT
decimals: 18
recipient_encoding: evm_address
wrapper:
symbol: LUSD
contract: "liquid/tokens/LUSD.sol"
decimals: 18
adapter:
deposit_method: event_log
vault_contract: "kms://teleport/bsc/teleport-vault"
teleporter_contract: "kms://teleport/lux-c/teleporter-bnb"
watcher:
plugin: evm_log_watcher
rpc_url: "kms://teleport/bsc/rpc"
poll_interval_ms: 3000
start_block: 0
+43
View File
@@ -0,0 +1,43 @@
# Cardano — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Status: locale_only — FROST Ed25519 signer integration pending.
# Note: Cardano uses extended UTXO model with Ed25519-BIP32 keys.
chain_id: 4294967430
name: "Cardano"
class: account_native
tier: locale_only
signer:
protocol: frost
curve: ed25519
threshold:
t: 2
n: 3
finality:
mode: block_depth
confirmations: 15 # ~5 minutes at 20s block time for safe settlement
max_reorg_depth: 36 # Ouroboros rollback limit (k parameter)
stale_attestation_seconds: 86400
assets:
- ticker: ADA
decimals: 6
recipient_encoding: bech32 # addr1... bech32 Shelley addresses
wrapper:
symbol: LADA
contract: "liquid/tokens/LADA.sol"
decimals: 18
adapter:
deposit_method: account_watch
vault_contract: "kms://teleport/cardano/mpc-address"
teleporter_contract: "kms://teleport/lux-c/teleporter-ada"
watcher:
plugin: cardano_ogmios_watcher
rpc_url: "kms://teleport/cardano/ogmios"
poll_interval_ms: 20000
start_block: 0
+42
View File
@@ -0,0 +1,42 @@
# Cosmos Hub — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Status: locale_only — CGGMP21 secp256k1 adapter integration pending.
chain_id: 4294967350
name: "Cosmos Hub"
class: account_native
tier: locale_only
signer:
protocol: cggmp21
curve: secp256k1
threshold:
t: 2
n: 3
finality:
mode: slot_finalized # CometBFT instant finality per block
confirmations: 1
max_reorg_depth: 0
stale_attestation_seconds: 86400
assets:
- ticker: ATOM
decimals: 6
recipient_encoding: bech32 # cosmos1... bech32 addresses
wrapper:
symbol: LATOM
contract: "liquid/tokens/LATOM.sol"
decimals: 18
adapter:
deposit_method: account_watch
vault_contract: "kms://teleport/cosmos/mpc-address"
teleporter_contract: "kms://teleport/lux-c/teleporter-atom"
watcher:
plugin: cosmos_ws_watcher
rpc_url: "kms://teleport/cosmos/rpc"
poll_interval_ms: 6000
start_block: 0
+67
View File
@@ -0,0 +1,67 @@
# Ethereum Mainnet — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
chain_id: 1
name: "Ethereum"
class: evm
tier: yield_enabled
signer:
protocol: cggmp21
curve: secp256k1
threshold:
t: 2
n: 3
finality:
mode: slot_finalized
confirmations: 64 # ~2 epochs for safe finality
max_reorg_depth: 128
stale_attestation_seconds: 86400
assets:
- ticker: ETH
decimals: 18
recipient_encoding: evm_address
wrapper:
symbol: LETH
contract: "liquid/tokens/LETH.sol"
decimals: 18
strategy:
name: LidoStrategy
contract: "bridge/teleport/strategies/LidoStrategy.sol"
- ticker: USDC
decimals: 6
recipient_encoding: evm_address
wrapper:
symbol: LUSD
contract: "liquid/tokens/LUSD.sol"
decimals: 18
- ticker: USDT
decimals: 6
recipient_encoding: evm_address
wrapper:
symbol: LUSD
contract: "liquid/tokens/LUSD.sol"
decimals: 18
- ticker: DAI
decimals: 18
recipient_encoding: evm_address
wrapper:
symbol: LUSD
contract: "liquid/tokens/LUSD.sol"
decimals: 18
adapter:
deposit_method: event_log
vault_contract: "kms://teleport/ethereum/liquid-vault"
teleporter_contract: "kms://teleport/lux-c/teleporter-eth"
watcher:
plugin: evm_log_watcher
rpc_url: "kms://teleport/ethereum/rpc"
poll_interval_ms: 12000
start_block: 0
+43
View File
@@ -0,0 +1,43 @@
# Filecoin (FEVM) — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Status: locale_only — EVM adapter integration pending.
# Filecoin EVM (FEVM) provides Solidity-compatible smart contracts.
chain_id: 314
name: "Filecoin"
class: evm
tier: locale_only
signer:
protocol: cggmp21
curve: secp256k1
threshold:
t: 2
n: 3
finality:
mode: block_depth
confirmations: 900 # Filecoin finality ~7.5 hours (900 tipsets at 30s)
max_reorg_depth: 900
stale_attestation_seconds: 86400
assets:
- ticker: FIL
decimals: 18
recipient_encoding: evm_address
wrapper:
symbol: LFIL
contract: "liquid/tokens/LFIL.sol"
decimals: 18
adapter:
deposit_method: event_log
vault_contract: "kms://teleport/filecoin/teleport-vault"
teleporter_contract: "kms://teleport/lux-c/teleporter-fil"
watcher:
plugin: evm_log_watcher
rpc_url: "kms://teleport/filecoin/rpc"
poll_interval_ms: 30000
start_block: 0
+43
View File
@@ -0,0 +1,43 @@
# Fuel — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Status: locale_only — FuelVM (Sway) adapter pending.
# Fuel uses UTXO-based parallel execution with Sway smart contracts.
chain_id: 4294967520 # FUEL_MAINNET from ChainIds.sol
name: "Fuel"
class: account_native
tier: locale_only
signer:
protocol: frost
curve: ed25519
threshold:
t: 2
n: 3
finality:
mode: slot_finalized # Fuel settles to Ethereum DA
confirmations: 1
max_reorg_depth: 8
stale_attestation_seconds: 86400
assets:
- ticker: ETH
decimals: 9
recipient_encoding: raw_bytes # 32-byte Fuel (Bech32m) address
wrapper:
symbol: LETH
contract: "liquid/tokens/LETH.sol"
decimals: 18
adapter:
deposit_method: account_watch
vault_contract: "kms://teleport/fuel/mpc-address"
teleporter_contract: "kms://teleport/lux-c/teleporter-fuel"
watcher:
plugin: fuel_ws_watcher
rpc_url: "kms://teleport/fuel/rpc"
poll_interval_ms: 1000
start_block: 0
+42
View File
@@ -0,0 +1,42 @@
# Hyperliquid — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Status: locale_only — EVM adapter integration pending.
chain_id: 999
name: "Hyperliquid"
class: evm
tier: locale_only
signer:
protocol: cggmp21
curve: secp256k1
threshold:
t: 2
n: 3
finality:
mode: block_depth
confirmations: 10
max_reorg_depth: 32
stale_attestation_seconds: 86400
assets:
- ticker: HYPE
decimals: 18
recipient_encoding: evm_address
wrapper:
symbol: LHYPE
contract: "liquid/tokens/LHYPE.sol"
decimals: 18
adapter:
deposit_method: event_log
vault_contract: "kms://teleport/hyperliquid/teleport-vault"
teleporter_contract: "kms://teleport/lux-c/teleporter-hype"
watcher:
plugin: evm_log_watcher
rpc_url: "kms://teleport/hyperliquid/rpc"
poll_interval_ms: 2000
start_block: 0
+42
View File
@@ -0,0 +1,42 @@
# Internet Computer (ICP) — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Status: locale_only — CGGMP21 secp256k1 via canister threshold ECDSA pending.
chain_id: 4294967490
name: "Internet Computer"
class: account_native
tier: locale_only
signer:
protocol: cggmp21
curve: secp256k1 # ICP chain-key ECDSA (threshold secp256k1)
threshold:
t: 2
n: 3
finality:
mode: slot_finalized # ICP consensus — finality per round (~1s)
confirmations: 2
max_reorg_depth: 0
stale_attestation_seconds: 86400
assets:
- ticker: ICP
decimals: 8
recipient_encoding: raw_bytes # ICP principal / account identifier
wrapper:
symbol: LICP
contract: "liquid/tokens/LICP.sol"
decimals: 18
adapter:
deposit_method: account_watch
vault_contract: "kms://teleport/icp/mpc-canister"
teleporter_contract: "kms://teleport/lux-c/teleporter-icp"
watcher:
plugin: icp_agent_watcher
rpc_url: "kms://teleport/icp/agent"
poll_interval_ms: 2000
start_block: 0
+46
View File
@@ -0,0 +1,46 @@
# Lux C-Chain — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Lux C-Chain is the destination for all Teleport minting, and also serves
# as a source chain for bridging LUX to other networks.
chain_id: 96369
name: "Lux C-Chain"
class: lux_native
tier: yield_enabled
signer:
protocol: bls
curve: bls12_381
threshold:
t: 2
n: 3
native_signer:
protocol: bls
curve: bls12_381 # Warp messaging uses BLS aggregation
finality:
mode: instant # Snowman consensus — single-slot finality
confirmations: 1
max_reorg_depth: 0 # No reorgs under BFT
stale_attestation_seconds: 86400
assets:
- ticker: LUX
decimals: 18
recipient_encoding: evm_address
wrapper:
symbol: LUX
contract: "token/LUX.sol"
decimals: 18
adapter:
deposit_method: event_log
vault_contract: "kms://teleport/lux-c/native-vault"
teleporter_contract: "kms://teleport/lux-c/teleporter"
watcher:
plugin: lux_warp_watcher
rpc_url: "https://api.lux.network/ext/bc/C/rpc"
poll_interval_ms: 2000 # ~2s C-Chain block time
start_block: 0
+48
View File
@@ -0,0 +1,48 @@
# Lux Hanzo Chain — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Hanzo AI L1 subnet on Lux. Uses Warp messaging for native bridging.
chain_id: 36963
name: "Lux Hanzo"
class: lux_native
tier: yield_enabled
signer:
protocol: bls
curve: bls12_381
threshold:
t: 2
n: 3
native_signer:
protocol: bls
curve: bls12_381
finality:
mode: instant
confirmations: 1
max_reorg_depth: 0
stale_attestation_seconds: 86400
assets:
- ticker: HANZO
decimals: 18
recipient_encoding: evm_address
wrapper:
symbol: LHANZO
contract: "liquid/tokens/LHANZO.sol"
decimals: 18
strategy:
name: HanzoStakingStrategy
contract: "bridge/teleport/strategies/HanzoStakingStrategy.sol"
adapter:
deposit_method: event_log
vault_contract: "kms://teleport/lux-hanzo/native-vault"
teleporter_contract: "kms://teleport/lux-c/teleporter-hanzo"
watcher:
plugin: lux_warp_watcher
rpc_url: "kms://teleport/lux-hanzo/rpc"
poll_interval_ms: 2000
start_block: 0
+45
View File
@@ -0,0 +1,45 @@
# Lux Pars Chain — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Pars L1 subnet on Lux. Uses Warp messaging for native bridging.
chain_id: 494949
name: "Lux Pars"
class: lux_native
tier: bidirectional
signer:
protocol: bls
curve: bls12_381
threshold:
t: 2
n: 3
native_signer:
protocol: bls
curve: bls12_381
finality:
mode: instant
confirmations: 1
max_reorg_depth: 0
stale_attestation_seconds: 86400
assets:
- ticker: PARS
decimals: 18
recipient_encoding: evm_address
wrapper:
symbol: LPARS
contract: "liquid/tokens/LPARS.sol"
decimals: 18
adapter:
deposit_method: event_log
vault_contract: "kms://teleport/lux-pars/native-vault"
teleporter_contract: "kms://teleport/lux-c/teleporter-pars"
watcher:
plugin: lux_warp_watcher
rpc_url: "kms://teleport/lux-pars/rpc"
poll_interval_ms: 2000
start_block: 0
+45
View File
@@ -0,0 +1,45 @@
# Lux SPC Chain — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# SPC L1 subnet on Lux. Uses Warp messaging for native bridging.
chain_id: 36911
name: "Lux SPC"
class: lux_native
tier: bidirectional
signer:
protocol: bls
curve: bls12_381
threshold:
t: 2
n: 3
native_signer:
protocol: bls
curve: bls12_381
finality:
mode: instant
confirmations: 1
max_reorg_depth: 0
stale_attestation_seconds: 86400
assets:
- ticker: SPC
decimals: 18
recipient_encoding: evm_address
wrapper:
symbol: LSPC
contract: "liquid/tokens/LSPC.sol"
decimals: 18
adapter:
deposit_method: event_log
vault_contract: "kms://teleport/lux-spc/native-vault"
teleporter_contract: "kms://teleport/lux-c/teleporter-spc"
watcher:
plugin: lux_warp_watcher
rpc_url: "kms://teleport/lux-spc/rpc"
poll_interval_ms: 2000
start_block: 0
+48
View File
@@ -0,0 +1,48 @@
# Lux Zoo Chain — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Zoo L1 subnet on Lux. Uses Warp messaging for native bridging.
chain_id: 200200
name: "Lux Zoo"
class: lux_native
tier: yield_enabled
signer:
protocol: bls
curve: bls12_381
threshold:
t: 2
n: 3
native_signer:
protocol: bls
curve: bls12_381
finality:
mode: instant
confirmations: 1
max_reorg_depth: 0
stale_attestation_seconds: 86400
assets:
- ticker: ZOO
decimals: 18
recipient_encoding: evm_address
wrapper:
symbol: LZOO
contract: "liquid/tokens/LZOO.sol"
decimals: 18
strategy:
name: ZooStakingStrategy
contract: "bridge/teleport/strategies/ZooStakingStrategy.sol"
adapter:
deposit_method: event_log
vault_contract: "kms://teleport/lux-zoo/native-vault"
teleporter_contract: "kms://teleport/lux-c/teleporter-zoo"
watcher:
plugin: lux_warp_watcher
rpc_url: "kms://teleport/lux-zoo/rpc"
poll_interval_ms: 2000
start_block: 0
+42
View File
@@ -0,0 +1,42 @@
# NEAR Protocol — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Status: locale_only — FROST Ed25519 signer integration pending.
chain_id: 4294967400
name: "NEAR"
class: account_native
tier: locale_only
signer:
protocol: frost
curve: ed25519
threshold:
t: 2
n: 3
finality:
mode: slot_finalized # NEAR Doomslug + Nightshade finality
confirmations: 3
max_reorg_depth: 8
stale_attestation_seconds: 86400
assets:
- ticker: NEAR
decimals: 24
recipient_encoding: raw_bytes # NEAR named accounts or implicit 64-hex
wrapper:
symbol: LNEAR
contract: "liquid/tokens/LNEAR.sol"
decimals: 18
adapter:
deposit_method: account_watch
vault_contract: "kms://teleport/near/mpc-account"
teleporter_contract: "kms://teleport/lux-c/teleporter-near"
watcher:
plugin: near_ws_watcher
rpc_url: "kms://teleport/near/rpc"
poll_interval_ms: 1000
start_block: 0
+50
View File
@@ -0,0 +1,50 @@
# OP_NET (Bitcoin L1) — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# OP_NET is a Bitcoin metaprotocol layer providing smart contract capabilities
# on Bitcoin L1. Deposits are native BTC sent to MPC-controlled Taproot UTXOs.
# The watcher monitors Bitcoin blocks via ZMQ for confirmed deposits.
chain_id: 4294967299 # 0x100000003
name: "OP_NET"
class: bitcoin_script
tier: yield_enabled
signer:
protocol: cggmp21
curve: secp256k1
threshold:
t: 2
n: 3
native_signer:
protocol: frost
curve: secp256k1 # Schnorr over secp256k1 for Taproot
finality:
mode: block_depth
confirmations: 6
max_reorg_depth: 100
stale_attestation_seconds: 86400
assets:
- ticker: BTC
decimals: 8
recipient_encoding: bech32
wrapper:
symbol: LBTC
contract: "liquid/tokens/LBTC.sol"
decimals: 18 # LBTC is 18 decimals on Lux C-Chain (scaled from 8)
strategy:
name: BabylonBTCStrategy
contract: "bridge/teleport/strategies/BabylonBTCStrategy.sol"
adapter:
deposit_method: utxo_watch
vault_contract: "kms://teleport/opnet/taproot-address"
teleporter_contract: "kms://teleport/lux-c/teleporter-btc"
watcher:
plugin: bitcoin_zmq_watcher
rpc_url: "kms://teleport/opnet/bitcoin-rpc"
poll_interval_ms: 60000 # ~1 Bitcoin block
start_block: 0
+48
View File
@@ -0,0 +1,48 @@
# Optimism — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
chain_id: 10
name: "Optimism"
class: evm
tier: bidirectional
signer:
protocol: cggmp21
curve: secp256k1
threshold:
t: 2
n: 3
finality:
mode: block_depth
confirmations: 64 # L2 blocks, ~2s; wait for L1 batch posting
max_reorg_depth: 128
stale_attestation_seconds: 86400
assets:
- ticker: ETH
decimals: 18
recipient_encoding: evm_address
wrapper:
symbol: LETH
contract: "liquid/tokens/LETH.sol"
decimals: 18
- ticker: OP
decimals: 18
recipient_encoding: evm_address
wrapper:
symbol: LOP
contract: "liquid/tokens/LOP.sol"
decimals: 18
adapter:
deposit_method: event_log
vault_contract: "kms://teleport/optimism/teleport-vault"
teleporter_contract: "kms://teleport/lux-c/teleporter-op"
watcher:
plugin: evm_log_watcher
rpc_url: "kms://teleport/optimism/rpc"
poll_interval_ms: 2000
start_block: 0
+42
View File
@@ -0,0 +1,42 @@
# Polkadot — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Status: locale_only — FROST sr25519 signer pending (Schnorr over Ristretto255).
chain_id: 4294967380
name: "Polkadot"
class: account_native
tier: locale_only
signer:
protocol: frost
curve: ed25519 # sr25519 (Schnorrkel) uses Ristretto255; mapped to ed25519 in schema
threshold:
t: 2
n: 3
finality:
mode: slot_finalized # GRANDPA finality gadget
confirmations: 2
max_reorg_depth: 8
stale_attestation_seconds: 86400
assets:
- ticker: DOT
decimals: 10
recipient_encoding: raw_bytes # SS58-encoded Substrate address
wrapper:
symbol: LDOT
contract: "liquid/tokens/LDOT.sol"
decimals: 18
adapter:
deposit_method: account_watch
vault_contract: "kms://teleport/polkadot/mpc-address"
teleporter_contract: "kms://teleport/lux-c/teleporter-dot"
watcher:
plugin: substrate_ws_watcher
rpc_url: "kms://teleport/polkadot/rpc"
poll_interval_ms: 6000
start_block: 0
+48
View File
@@ -0,0 +1,48 @@
# Polygon PoS — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
chain_id: 137
name: "Polygon"
class: evm
tier: bidirectional
signer:
protocol: cggmp21
curve: secp256k1
threshold:
t: 2
n: 3
finality:
mode: block_depth
confirmations: 128 # Polygon PoS requires deep confirmation for safety
max_reorg_depth: 256
stale_attestation_seconds: 86400
assets:
- ticker: POL
decimals: 18
recipient_encoding: evm_address
wrapper:
symbol: LPOL
contract: "liquid/tokens/LPOL.sol"
decimals: 18
- ticker: USDC
decimals: 6
recipient_encoding: evm_address
wrapper:
symbol: LUSD
contract: "liquid/tokens/LUSD.sol"
decimals: 18
adapter:
deposit_method: event_log
vault_contract: "kms://teleport/polygon/teleport-vault"
teleporter_contract: "kms://teleport/lux-c/teleporter-pol"
watcher:
plugin: evm_log_watcher
rpc_url: "kms://teleport/polygon/rpc"
poll_interval_ms: 2000
start_block: 0
+43
View File
@@ -0,0 +1,43 @@
# Solana — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Status: locale_only — FROST Ed25519 signer integration pending.
# Once signer is ready, promote to inbound_only, then bidirectional.
chain_id: 1399811149 # 0x536F6C4D — "SolM" in ASCII
name: "Solana"
class: account_native
tier: locale_only
signer:
protocol: frost
curve: ed25519
threshold:
t: 2
n: 3
finality:
mode: slot_finalized
confirmations: 32 # ~32 slots for safe finality
max_reorg_depth: 64
stale_attestation_seconds: 86400
assets:
- ticker: SOL
decimals: 9
recipient_encoding: base58
wrapper:
symbol: LSOL
contract: "liquid/tokens/LSOL.sol"
decimals: 18
adapter:
deposit_method: account_watch
vault_contract: "kms://teleport/solana/mpc-program"
teleporter_contract: "kms://teleport/lux-c/teleporter-sol"
watcher:
plugin: solana_ws_watcher
rpc_url: "kms://teleport/solana/rpc"
poll_interval_ms: 400 # ~400ms Solana slot time
start_block: 0
+46
View File
@@ -0,0 +1,46 @@
# Stacks — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Status: locale_only — Clarity/Bitcoin script adapter pending.
# Stacks settles on Bitcoin L1 with Clarity smart contracts.
chain_id: 4294967440 # STACKS_MAINNET from ChainIds.sol
name: "Stacks"
class: bitcoin_script
tier: locale_only
signer:
protocol: cggmp21
curve: secp256k1
threshold:
t: 2
n: 3
native_signer:
protocol: frost
curve: secp256k1 # Schnorr for Stacks Nakamoto upgrade
finality:
mode: block_depth
confirmations: 6 # Anchored to Bitcoin — 6 Bitcoin block confirmations
max_reorg_depth: 100
stale_attestation_seconds: 86400
assets:
- ticker: STX
decimals: 6
recipient_encoding: base58 # Stacks SP.../SM... c32check addresses
wrapper:
symbol: LSTX
contract: "liquid/tokens/LSTX.sol"
decimals: 18
adapter:
deposit_method: utxo_watch
vault_contract: "kms://teleport/stacks/mpc-address"
teleporter_contract: "kms://teleport/lux-c/teleporter-stx"
watcher:
plugin: stacks_api_watcher
rpc_url: "kms://teleport/stacks/rpc"
poll_interval_ms: 30000
start_block: 0
+43
View File
@@ -0,0 +1,43 @@
# StarkNet — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Status: locale_only — FROST stark_curve signer pending.
# StarkNet uses STARK-friendly curve (Pedersen hash, ECDSA over stark curve).
chain_id: 23448594291968334 # SN_MAIN from ChainIds.sol
name: "StarkNet"
class: account_native
tier: locale_only
signer:
protocol: frost
curve: ed25519 # stark_curve mapped to ed25519 in schema; actual is Stark curve
threshold:
t: 2
n: 3
finality:
mode: slot_finalized # L1 proof acceptance
confirmations: 1
max_reorg_depth: 0
stale_attestation_seconds: 86400
assets:
- ticker: ETH
decimals: 18
recipient_encoding: raw_bytes # 32-byte felt StarkNet address
wrapper:
symbol: LETH
contract: "liquid/tokens/LETH.sol"
decimals: 18
adapter:
deposit_method: account_watch
vault_contract: "kms://teleport/starknet/mpc-address"
teleporter_contract: "kms://teleport/lux-c/teleporter-starknet"
watcher:
plugin: starknet_ws_watcher
rpc_url: "kms://teleport/starknet/rpc"
poll_interval_ms: 30000
start_block: 0
+42
View File
@@ -0,0 +1,42 @@
# Stellar — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Status: locale_only — FROST Ed25519 signer integration pending.
chain_id: 4294967410
name: "Stellar"
class: account_native
tier: locale_only
signer:
protocol: frost
curve: ed25519
threshold:
t: 2
n: 3
finality:
mode: slot_finalized # Stellar Consensus Protocol — immediate finality
confirmations: 1
max_reorg_depth: 0
stale_attestation_seconds: 86400
assets:
- ticker: XLM
decimals: 7
recipient_encoding: raw_bytes # G... base32 Stellar public key
wrapper:
symbol: LXLM
contract: "liquid/tokens/LXLM.sol"
decimals: 18
adapter:
deposit_method: account_watch
vault_contract: "kms://teleport/stellar/mpc-address"
teleporter_contract: "kms://teleport/lux-c/teleporter-xlm"
watcher:
plugin: stellar_horizon_watcher
rpc_url: "kms://teleport/stellar/horizon"
poll_interval_ms: 5000
start_block: 0
+42
View File
@@ -0,0 +1,42 @@
# Sui — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Status: locale_only — FROST Ed25519 signer integration pending.
chain_id: 4294967330
name: "Sui"
class: account_native
tier: locale_only
signer:
protocol: frost
curve: ed25519
threshold:
t: 2
n: 3
finality:
mode: slot_finalized # Sui checkpoint-based finality
confirmations: 1
max_reorg_depth: 0
stale_attestation_seconds: 86400
assets:
- ticker: SUI
decimals: 9
recipient_encoding: raw_bytes # 32-byte Sui address
wrapper:
symbol: LSUI
contract: "liquid/tokens/LSUI.sol"
decimals: 18
adapter:
deposit_method: account_watch
vault_contract: "kms://teleport/sui/mpc-address"
teleporter_contract: "kms://teleport/lux-c/teleporter-sui"
watcher:
plugin: sui_ws_watcher
rpc_url: "kms://teleport/sui/rpc"
poll_interval_ms: 3000
start_block: 0
+42
View File
@@ -0,0 +1,42 @@
# Tezos — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Status: locale_only — adapter integration pending.
chain_id: 4294967500 # TEZOS_MAINNET from ChainIds.sol
name: "Tezos"
class: account_native
tier: locale_only
signer:
protocol: frost
curve: ed25519
threshold:
t: 2
n: 3
finality:
mode: slot_finalized # Tenderbake BFT finality (~30s)
confirmations: 2
max_reorg_depth: 0
stale_attestation_seconds: 86400
assets:
- ticker: XTZ
decimals: 6
recipient_encoding: raw_bytes # tz1... base58check Tezos addresses
wrapper:
symbol: LXTZ
contract: "liquid/tokens/LXTZ.sol"
decimals: 18
adapter:
deposit_method: account_watch
vault_contract: "kms://teleport/tezos/mpc-address"
teleporter_contract: "kms://teleport/lux-c/teleporter-xtz"
watcher:
plugin: tezos_ws_watcher
rpc_url: "kms://teleport/tezos/rpc"
poll_interval_ms: 15000
start_block: 0
+42
View File
@@ -0,0 +1,42 @@
# TON (The Open Network) — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Status: locale_only — FROST Ed25519 signer integration pending.
chain_id: 1414483791 # 0x544F4E4F — "TONO" in ASCII
name: "TON"
class: account_native
tier: locale_only
signer:
protocol: frost
curve: ed25519
threshold:
t: 2
n: 3
finality:
mode: block_depth
confirmations: 1 # TON has fast finality via catchain
max_reorg_depth: 8
stale_attestation_seconds: 86400
assets:
- ticker: TON
decimals: 9
recipient_encoding: raw_bytes # 36-byte TON address
wrapper:
symbol: LTON
contract: "liquid/tokens/LTON.sol"
decimals: 18
adapter:
deposit_method: account_watch
vault_contract: "kms://teleport/ton/mpc-wallet"
teleporter_contract: "kms://teleport/lux-c/teleporter-ton"
watcher:
plugin: ton_liteserver_watcher
rpc_url: "kms://teleport/ton/liteserver"
poll_interval_ms: 5000
start_block: 0
+43
View File
@@ -0,0 +1,43 @@
# TRON — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Status: locale_only — TVM adapter integration pending.
# TRON uses TVM (near-EVM Solidity-compatible) with secp256k1 signing.
chain_id: 728126
name: "TRON"
class: evm
tier: locale_only
signer:
protocol: cggmp21
curve: secp256k1
threshold:
t: 2
n: 3
finality:
mode: block_depth
confirmations: 19 # 19 SRs confirm — ~57s
max_reorg_depth: 32
stale_attestation_seconds: 86400
assets:
- ticker: TRX
decimals: 6
recipient_encoding: base58 # T... base58check TRON addresses
wrapper:
symbol: LTRX
contract: "liquid/tokens/LTRX.sol"
decimals: 18
adapter:
deposit_method: event_log
vault_contract: "kms://teleport/tron/teleport-vault"
teleporter_contract: "kms://teleport/lux-c/teleporter-trx"
watcher:
plugin: evm_log_watcher
rpc_url: "kms://teleport/tron/rpc"
poll_interval_ms: 3000
start_block: 0
+42
View File
@@ -0,0 +1,42 @@
# XRP Ledger — Teleport Chain Manifest
# Schema: ../schema.yaml v1.0.0
#
# Status: locale_only — CGGMP21 secp256k1 adapter integration pending.
chain_id: 4294967420
name: "XRPL"
class: account_native
tier: locale_only
signer:
protocol: cggmp21
curve: secp256k1
threshold:
t: 2
n: 3
finality:
mode: slot_finalized # XRPL consensus — ledger validated in ~4s
confirmations: 1
max_reorg_depth: 0
stale_attestation_seconds: 86400
assets:
- ticker: XRP
decimals: 6
recipient_encoding: base58 # r... base58check XRPL addresses
wrapper:
symbol: LXRP
contract: "liquid/tokens/LXRP.sol"
decimals: 18
adapter:
deposit_method: account_watch
vault_contract: "kms://teleport/xrpl/mpc-address"
teleporter_contract: "kms://teleport/lux-c/teleporter-xrp"
watcher:
plugin: xrpl_ws_watcher
rpc_url: "kms://teleport/xrpl/rpc"
poll_interval_ms: 4000
start_block: 0
+259
View File
@@ -0,0 +1,259 @@
# Teleport Chain Manifest Schema
# Every chain integration in the Lux Teleport bridge MUST provide a manifest
# conforming to this schema. The manifest is the single source of truth for:
# 1. Locale registration in TeleportProposalBridge
# 2. Source-chain adapter semantics (deposit/release)
# 3. Watcher/finality plugin configuration
# 4. MPC signer key type
# 5. Asset definitions (ticker, decimals, recipient encoding, canonical wrapper)
# 6. Conformance test pack reference
#
# Validate with: yq eval 'true' manifests/chains/<chain>.yaml
schema_version: "1.0.0"
# ============================================================================
# REQUIRED FIELDS
# ============================================================================
# chain_id: uint64 — canonical chain identifier.
# EVM chains use their native chainId.
# Non-EVM chains use a deterministic encoding (e.g. OP_NET = 0x4F504E45).
# Lux C-Chain = 96369.
chain_id:
type: integer
required: true
min: 1
# name: human-readable chain name for logs/UIs
name:
type: string
required: true
# class: chain family — determines adapter and signer selection
# lux_native — Lux subnets (C-Chain, D-Chain). Warp messaging, BLS finality.
# evm — EVM-compatible L1/L2. ECDSA signer, block finality.
# bitcoin_script — Bitcoin-family UTXO chains. Taproot/P2WSH custody, block finality.
# account_native — Non-EVM account-model chains (Solana, TON, Sui). Chain-specific signer.
class:
type: string
required: true
enum: [lux_native, evm, bitcoin_script, account_native]
# tier: integration depth — determines which Teleport flows are enabled
# locale_only — Registered as locale; no active bridge (pending signer/adapter).
# inbound_only — Deposits accepted, minting on Lux. No withdrawals yet.
# bidirectional — Full deposit + withdraw. MPC custody live.
# yield_enabled — Bidirectional + yield strategy routing via LiquidVault.
tier:
type: string
required: true
enum: [locale_only, inbound_only, bidirectional, yield_enabled]
# ============================================================================
# SIGNER CONFIGURATION
# ============================================================================
signer:
type: object
required: true
properties:
# protocol: MPC signing protocol used for this chain's key type
# cggmp21 — ECDSA threshold (secp256k1). EVM and Bitcoin.
# frost — Schnorr/EdDSA threshold. Solana, TON, Lux validators.
# bls — BLS aggregation. Lux Warp messaging.
# corona — Post-quantum lattice threshold. Future upgrade path.
protocol:
type: string
required: true
enum: [cggmp21, frost, bls, corona]
# curve: underlying elliptic curve
curve:
type: string
required: true
enum: [secp256k1, ed25519, bls12_381, ml_dsa_65]
# threshold: t-of-n signing quorum
threshold:
type: object
required: true
properties:
t:
type: integer
min: 2
n:
type: integer
min: 3
# native_signer: optional secondary signer for chain-native operations
# (e.g. FROST for Taproot on Bitcoin, BLS for Warp on Lux)
native_signer:
type: object
required: false
properties:
protocol:
type: string
enum: [frost, bls, corona]
curve:
type: string
enum: [secp256k1, ed25519, bls12_381]
# ============================================================================
# FINALITY RULES
# ============================================================================
finality:
type: object
required: true
properties:
# mode: how finality is determined
# block_depth — Wait N confirmations.
# slot_finalized — Wait for slot finalization (Solana, Ethereum post-merge).
# instant — Single-slot finality (Lux Snowman).
mode:
type: string
required: true
enum: [block_depth, slot_finalized, instant]
# confirmations: number of blocks/slots to wait (block_depth mode)
confirmations:
type: integer
required: false
min: 1
# max_reorg_depth: deepest reorg the watcher should tolerate before halting
max_reorg_depth:
type: integer
required: false
# stale_attestation_seconds: max age of a backing attestation before Teleporter
# rejects minting (maps to Teleporter._checkBackingRatio 24h default)
stale_attestation_seconds:
type: integer
required: true
default: 86400 # 24 hours
# ============================================================================
# ASSETS
# ============================================================================
assets:
type: array
required: true
min_items: 1
items:
type: object
properties:
# ticker: canonical asset symbol on the source chain
ticker:
type: string
required: true
# decimals: native decimal precision on source chain
decimals:
type: integer
required: true
# recipient_encoding: how recipient addresses are serialized
# evm_address — 20-byte hex (0x...)
# bech32 — Bitcoin bech32/bech32m (bc1...)
# base58 — Solana/Bitcoin legacy
# raw_bytes — 32-byte or 36-byte (TON, Sui)
recipient_encoding:
type: string
required: true
enum: [evm_address, bech32, base58, raw_bytes]
# wrapper: Liquid token minted on Lux C-Chain for this asset
wrapper:
type: object
required: true
properties:
symbol:
type: string
required: true
contract:
type: string
required: true
description: "Path relative to @luxfi/standard/contracts/"
decimals:
type: integer
required: true
# strategy: yield strategy (only for tier=yield_enabled)
strategy:
type: object
required: false
properties:
name:
type: string
contract:
type: string
description: "Path relative to @luxfi/standard/contracts/"
# ============================================================================
# ADAPTER CONFIGURATION
# ============================================================================
adapter:
type: object
required: true
properties:
# deposit_method: how deposits are detected on the source chain
# event_log — EVM event (TeleportVault.Deposit)
# utxo_watch — Monitor UTXO to MPC-controlled address
# account_watch — Monitor balance/instruction on account-model chain
deposit_method:
type: string
required: true
enum: [event_log, utxo_watch, account_watch]
# vault_contract: source-chain vault address or MPC custody address
# For EVM: deployed TeleportVault/LiquidVault contract address
# For Bitcoin: MPC-controlled Taproot address
# For account-native: MPC-controlled program/account address
vault_contract:
type: string
required: true
# teleporter_contract: Lux C-Chain Teleporter contract that mints L* tokens
teleporter_contract:
type: string
required: true
# ============================================================================
# WATCHER CONFIGURATION
# ============================================================================
watcher:
type: object
required: true
properties:
# plugin: watcher implementation to use
# evm_log_watcher — Subscribe to EVM logs via eth_subscribe/getLogs
# bitcoin_zmq_watcher — Subscribe to Bitcoin ZMQ block notifications
# solana_ws_watcher — Subscribe to Solana WebSocket account changes
# ton_liteserver_watcher — Poll TON liteserver for transactions
# lux_warp_watcher — Subscribe to Lux Warp messages
plugin:
type: string
required: true
# rpc_url: RPC endpoint (resolved from KMS at runtime, placeholder here)
rpc_url:
type: string
required: true
description: "KMS secret path or well-known endpoint"
# poll_interval_ms: polling interval for non-streaming watchers
poll_interval_ms:
type: integer
required: false
default: 12000
# start_block: block/slot to start watching from (0 = latest)
start_block:
type: integer
required: false
default: 0
+34
View File
@@ -0,0 +1,34 @@
# Hanzo Network — Tenant Manifest
# Schema: schema.yaml v1.0.0
#
# Hanzo AI bridge. EVM chains + BTC.
id: hanzo
name: "Hanzo Network"
hostnames:
- bridge.hanzo.ai
home_chain_id: 36963 # Hanzo C-Chain
supported_chains:
- lux-c
- ethereum
- base
- opnet
teleporter_addresses:
"96369": "kms://teleport/lux-c/teleporter"
"1": "kms://teleport/lux-c/teleporter-eth"
"8453": "kms://teleport/lux-c/teleporter-eth"
"4294967299": "kms://teleport/lux-c/teleporter-btc"
branding:
logo_url: "/static/hanzo/logo.svg"
primary_color: "#6366F1"
theme: dark
features:
swap: true
yield: true
perps: true
+39
View File
@@ -0,0 +1,39 @@
# Lux Network — Tenant Manifest
# Schema: schema.yaml v1.0.0
#
# Primary bridge deployment. All chains supported. Full feature set.
id: lux
name: "Lux Network"
hostnames:
- bridge.lux.network
- bridge.lux.io
home_chain_id: 96369 # Lux C-Chain
supported_chains:
- lux-c
- ethereum
- base
- opnet
- solana
- ton
teleporter_addresses:
"96369": "kms://teleport/lux-c/teleporter"
"1": "kms://teleport/lux-c/teleporter-eth"
"8453": "kms://teleport/lux-c/teleporter-eth"
"4294967299": "kms://teleport/lux-c/teleporter-btc"
"1399811149": "kms://teleport/lux-c/teleporter-sol"
"1414483791": "kms://teleport/lux-c/teleporter-ton"
branding:
logo_url: "/static/lux/logo.svg"
primary_color: "#E84142"
theme: dark
features:
swap: true
yield: true
perps: true
+32
View File
@@ -0,0 +1,32 @@
# Pars Network — Tenant Manifest
# Schema: schema.yaml v1.0.0
#
# Pars bridge. EVM chains focus.
id: pars
name: "Pars Network"
hostnames:
- bridge.pars.network
home_chain_id: 494949 # Pars C-Chain
supported_chains:
- lux-c
- ethereum
- base
teleporter_addresses:
"96369": "kms://teleport/lux-c/teleporter"
"1": "kms://teleport/lux-c/teleporter-eth"
"8453": "kms://teleport/lux-c/teleporter-eth"
branding:
logo_url: "/static/pars/logo.svg"
primary_color: "#F59E0B"
theme: light
features:
swap: true
yield: false
perps: false
+119
View File
@@ -0,0 +1,119 @@
# Tenant Manifest Schema
# Each white-label bridge deployment MUST provide a tenant manifest conforming
# to this schema. The manifest is the single source of truth for:
# 1. Hostname-based routing (which domain -> which tenant)
# 2. Supported chain set (references chain manifest files)
# 3. Per-chain Teleporter contract addresses
# 4. Frontend branding configuration
# 5. Feature flags
#
# Validate with: yq eval 'true' manifests/tenants/<tenant>.yaml
schema_version: "1.0.0"
# ============================================================================
# REQUIRED FIELDS
# ============================================================================
# id: unique tenant slug used as internal identifier and ConfigMap key.
# Must be lowercase alphanumeric + hyphens. e.g., "lux", "zoo", "hanzo"
id:
type: string
required: true
pattern: "^[a-z0-9][a-z0-9-]*$"
# name: human-readable display name for the bridge UI.
name:
type: string
required: true
# hostnames: list of DNS hostnames that route to this tenant.
# The opnet-watcher API resolves Host header against this list.
# First entry is canonical; others are aliases.
hostnames:
type: array
required: true
min_items: 1
items:
type: string
# home_chain_id: primary chain for this tenant.
# The bridge UI defaults to this chain as the "home" network.
# Must reference a chain_id from a chain manifest in supported_chains.
home_chain_id:
type: integer
required: true
min: 1
# supported_chains: list of chain manifest filenames (without .yaml extension).
# Each entry must correspond to a file in manifests/chains/<name>.yaml.
# Only these chains are available for bridging under this tenant.
supported_chains:
type: array
required: true
min_items: 1
items:
type: string
# ============================================================================
# TELEPORTER ADDRESSES
# ============================================================================
# teleporter_addresses: per-chain Teleporter contract addresses for this tenant.
# Keys are chain_id (as string), values are 0x-prefixed contract addresses
# or KMS secret paths (kms://...).
# Only chains listed in supported_chains need entries here.
teleporter_addresses:
type: object
required: true
additionalProperties:
type: string
description: "0x-prefixed address or kms:// secret path"
# ============================================================================
# BRANDING
# ============================================================================
branding:
type: object
required: true
properties:
# logo_url: path or URL to the tenant logo (served by hanzoai/static)
logo_url:
type: string
required: true
# primary_color: hex color for UI accent elements
primary_color:
type: string
required: true
pattern: "^#[0-9a-fA-F]{6}$"
# theme: light or dark base theme
theme:
type: string
required: true
enum: [light, dark]
# ============================================================================
# FEATURE FLAGS
# ============================================================================
features:
type: object
required: true
properties:
# swap: enable cross-chain swap UI
swap:
type: boolean
required: true
# yield: enable yield strategy display and routing
yield:
type: boolean
required: true
# perps: enable perpetuals trading UI
perps:
type: boolean
required: true
+32
View File
@@ -0,0 +1,32 @@
# SPC Network — Tenant Manifest
# Schema: schema.yaml v1.0.0
#
# SPC bridge. EVM chains focus.
id: spc
name: "SPC Network"
hostnames:
- bridge.spc.network
home_chain_id: 36911 # SPC C-Chain
supported_chains:
- lux-c
- ethereum
- base
teleporter_addresses:
"96369": "kms://teleport/lux-c/teleporter"
"1": "kms://teleport/lux-c/teleporter-eth"
"8453": "kms://teleport/lux-c/teleporter-eth"
branding:
logo_url: "/static/spc/logo.svg"
primary_color: "#3B82F6"
theme: dark
features:
swap: true
yield: false
perps: false
+34
View File
@@ -0,0 +1,34 @@
# Zoo Network — Tenant Manifest
# Schema: schema.yaml v1.0.0
#
# Zoo Labs Foundation bridge. Subset of chains relevant to DeAI/DeSci.
id: zoo
name: "Zoo Network"
hostnames:
- bridge.zoo.ngo
home_chain_id: 200200 # Zoo C-Chain
supported_chains:
- lux-c
- ethereum
- base
- solana
teleporter_addresses:
"96369": "kms://teleport/lux-c/teleporter"
"1": "kms://teleport/lux-c/teleporter-eth"
"8453": "kms://teleport/lux-c/teleporter-eth"
"1399811149": "kms://teleport/lux-c/teleporter-sol"
branding:
logo_url: "/static/zoo/logo.svg"
primary_color: "#10B981"
theme: dark
features:
swap: true
yield: true
perps: false
+10
View File
@@ -0,0 +1,10 @@
FROM golang:1.24-alpine AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /opnet-watcher .
FROM alpine:3.21
COPY --from=build /opnet-watcher /usr/local/bin/
ENTRYPOINT ["opnet-watcher"]
+127
View File
@@ -0,0 +1,127 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package main
import (
"encoding/json"
"log"
"net/http"
"time"
)
// API serves tenant-aware bridge configuration over HTTP.
// All endpoints resolve the tenant from the Host header.
type API struct {
registry *TenantRegistry
startTime time.Time
mux *http.ServeMux
}
// NewAPI creates the HTTP handler.
func NewAPI(registry *TenantRegistry) *API {
a := &API{
registry: registry,
startTime: time.Now(),
mux: http.NewServeMux(),
}
a.mux.HandleFunc("GET /v1/config", a.handleConfig)
a.mux.HandleFunc("GET /v1/chains", a.handleChains)
a.mux.HandleFunc("GET /v1/assets", a.handleAssets)
a.mux.HandleFunc("GET /v1/status", a.handleStatus)
a.mux.HandleFunc("GET /health", a.handleHealth)
return a
}
// ServeHTTP implements http.Handler.
func (a *API) ServeHTTP(w http.ResponseWriter, r *http.Request) {
a.mux.ServeHTTP(w, r)
}
// handleConfig returns the full tenant configuration for the requesting host.
// GET /v1/config
func (a *API) handleConfig(w http.ResponseWriter, r *http.Request) {
t, err := a.registry.TenantForHostname(r.Host)
if err != nil {
writeError(w, http.StatusNotFound, "unknown tenant")
return
}
writeJSON(w, http.StatusOK, t)
}
// handleChains returns the supported chains for the requesting tenant.
// GET /v1/chains
func (a *API) handleChains(w http.ResponseWriter, r *http.Request) {
t, err := a.registry.TenantForHostname(r.Host)
if err != nil {
writeError(w, http.StatusNotFound, "unknown tenant")
return
}
chains, err := a.registry.ChainsForTenant(t.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "chain resolution failed")
log.Printf("api: chains error tenant=%s: %v", t.ID, err)
return
}
writeJSON(w, http.StatusOK, chains)
}
// handleAssets returns the supported assets for the requesting tenant.
// GET /v1/assets
func (a *API) handleAssets(w http.ResponseWriter, r *http.Request) {
t, err := a.registry.TenantForHostname(r.Host)
if err != nil {
writeError(w, http.StatusNotFound, "unknown tenant")
return
}
assets, err := a.registry.AssetsForTenant(t.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "asset resolution failed")
log.Printf("api: assets error tenant=%s: %v", t.ID, err)
return
}
writeJSON(w, http.StatusOK, assets)
}
// statusResponse is the shape of GET /v1/status.
type statusResponse struct {
Tenant string `json:"tenant"`
Healthy bool `json:"healthy"`
Uptime string `json:"uptime"`
}
// handleStatus returns bridge status for the requesting tenant.
// GET /v1/status
func (a *API) handleStatus(w http.ResponseWriter, r *http.Request) {
t, err := a.registry.TenantForHostname(r.Host)
if err != nil {
writeError(w, http.StatusNotFound, "unknown tenant")
return
}
resp := statusResponse{
Tenant: t.ID,
Healthy: true,
Uptime: time.Since(a.startTime).Truncate(time.Second).String(),
}
writeJSON(w, http.StatusOK, resp)
}
// handleHealth is the K8s liveness/readiness probe.
// GET /health
func (a *API) handleHealth(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// writeJSON encodes v as JSON and writes it to w.
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("api: json encode error: %v", err)
}
}
// writeError writes a JSON error response.
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
+152
View File
@@ -0,0 +1,152 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func newTestAPI(t *testing.T) *API {
t.Helper()
tenantDir, chainDir := setupTestManifests(t)
r := NewTenantRegistry(chainDir)
if err := r.Load(tenantDir); err != nil {
t.Fatal(err)
}
return NewAPI(r)
}
func TestAPIConfig(t *testing.T) {
api := newTestAPI(t)
req := httptest.NewRequest(http.MethodGet, "/v1/config", nil)
req.Host = "bridge.lux.network"
rec := httptest.NewRecorder()
api.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want 200", rec.Code)
}
var tenant Tenant
if err := json.NewDecoder(rec.Body).Decode(&tenant); err != nil {
t.Fatalf("decode: %v", err)
}
if tenant.ID != "lux" {
t.Errorf("id = %q, want lux", tenant.ID)
}
if tenant.HomeChainID != 96369 {
t.Errorf("home_chain_id = %d, want 96369", tenant.HomeChainID)
}
}
func TestAPIConfigUnknownHost(t *testing.T) {
api := newTestAPI(t)
req := httptest.NewRequest(http.MethodGet, "/v1/config", nil)
req.Host = "bridge.unknown.com"
rec := httptest.NewRecorder()
api.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status %d, want 404", rec.Code)
}
}
func TestAPIChains(t *testing.T) {
api := newTestAPI(t)
req := httptest.NewRequest(http.MethodGet, "/v1/chains", nil)
req.Host = "bridge.lux.network"
rec := httptest.NewRecorder()
api.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want 200", rec.Code)
}
var chains []ChainManifest
if err := json.NewDecoder(rec.Body).Decode(&chains); err != nil {
t.Fatalf("decode: %v", err)
}
if len(chains) != 2 {
t.Errorf("got %d chains, want 2", len(chains))
}
}
func TestAPIChainsZoo(t *testing.T) {
api := newTestAPI(t)
req := httptest.NewRequest(http.MethodGet, "/v1/chains", nil)
req.Host = "bridge.zoo.ngo"
rec := httptest.NewRecorder()
api.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want 200", rec.Code)
}
var chains []ChainManifest
if err := json.NewDecoder(rec.Body).Decode(&chains); err != nil {
t.Fatalf("decode: %v", err)
}
if len(chains) != 1 {
t.Errorf("got %d chains, want 1", len(chains))
}
}
func TestAPIAssets(t *testing.T) {
api := newTestAPI(t)
req := httptest.NewRequest(http.MethodGet, "/v1/assets", nil)
req.Host = "bridge.lux.network"
rec := httptest.NewRecorder()
api.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want 200", rec.Code)
}
var assets []TenantAsset
if err := json.NewDecoder(rec.Body).Decode(&assets); err != nil {
t.Fatalf("decode: %v", err)
}
if len(assets) != 3 {
t.Errorf("got %d assets, want 3", len(assets))
}
}
func TestAPIStatus(t *testing.T) {
api := newTestAPI(t)
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
req.Host = "bridge.zoo.ngo"
rec := httptest.NewRecorder()
api.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want 200", rec.Code)
}
var status statusResponse
if err := json.NewDecoder(rec.Body).Decode(&status); err != nil {
t.Fatalf("decode: %v", err)
}
if status.Tenant != "zoo" {
t.Errorf("tenant = %q, want zoo", status.Tenant)
}
if !status.Healthy {
t.Error("expected healthy=true")
}
}
func TestAPIHealth(t *testing.T) {
api := newTestAPI(t)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
api.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want 200", rec.Code)
}
}
+203
View File
@@ -0,0 +1,203 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package main
import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"time"
)
const (
// LuxChainID is the Lux mainnet chain ID.
LuxChainID uint64 = 96369
// DefaultBlockPollInterval is how often we poll the source chain for new blocks.
DefaultBlockPollInterval = 5 * time.Second
// DefaultBackingInterval is how often we submit backing attestations.
DefaultBackingInterval = 12 * time.Hour
)
// Config holds all runtime configuration for the watcher service.
type Config struct {
// Source chain RPC/API endpoint.
SourceRPCURL string
// Lux C-Chain JSON-RPC endpoint.
LuxRPCURL string
// Teleporter contract address on Lux C-Chain (hex, 0x-prefixed).
TeleporterAddr string
// Source chain bridge contract/program address.
SourceBridgeAddr string
// Solana bridge config PDA (precomputed findProgramAddress(["bridge_config"], programID)).
// Required when --chain=solana.
SolanaVaultAddr string
// Path to file containing the signer private key (hex-encoded, 32 bytes).
// DEVELOPMENT ONLY -- in production use --threshold with --kms-endpoint.
SignerKeyPath string
// NEW-02: Path to hex-encoded tx hot wallet key for transaction submission.
// Required in threshold mode (MPC signs proofs, hot wallet submits txs).
TxKeyPath string
// How often to poll the source chain for new blocks/slots.
BlockPollInterval time.Duration
// How often to submit backing attestations.
BackingInterval time.Duration
// Block/slot height to start indexing from. 0 = latest.
StartBlock uint64
// CRITICAL-02: Number of confirmations before processing a block.
// Prevents bridge drain via source chain reorgs. Default 6 (BTC-like).
ConfirmationDepth uint64
// CRITICAL-04: Use threshold signing (CGGMP21) instead of single key.
// Single-key mode is for development/testing ONLY.
Threshold bool
// CRITICAL-04: KMS endpoint for loading key shares in threshold mode.
// Example: https://kms.hanzo.ai
KMSEndpoint string
// CRITICAL-04: KMS secret path for the key share.
KMSKeyPath string
// CRITICAL-03: Lux C-Chain chain ID for EIP-155 signing.
LuxChainIDOverride uint64
// HIGH-03: Maximum percentage change in backing per attestation.
// Prevents unbacked minting via compromised RPC. Default 100 (100%).
MaxBackingChangePct uint64
}
// DefaultConfig returns a Config with sensible defaults.
// RPC URLs and contract addresses must be overridden.
func DefaultConfig() Config {
return Config{
LuxRPCURL: "https://api.lux.network/ext/bc/C/rpc",
BlockPollInterval: DefaultBlockPollInterval,
BackingInterval: DefaultBackingInterval,
ConfirmationDepth: 6,
MaxBackingChangePct: 100,
LuxChainIDOverride: LuxChainID,
}
}
// Validate checks that all required fields are set.
func (c *Config) Validate() error {
if c.SourceRPCURL == "" {
return errors.New("config: source-rpc is required")
}
if c.LuxRPCURL == "" {
return errors.New("config: lux-rpc is required")
}
if c.TeleporterAddr == "" {
return errors.New("config: teleporter-addr is required")
}
if c.SourceBridgeAddr == "" {
return errors.New("config: source-addr is required")
}
if c.Threshold {
if c.KMSEndpoint == "" {
return errors.New("config: --kms-endpoint is required in threshold mode")
}
if c.KMSKeyPath == "" {
return errors.New("config: --kms-key-path is required in threshold mode")
}
if c.TxKeyPath == "" {
return errors.New("config: --tx-key-path is required in threshold mode (hot wallet for tx submission)")
}
} else {
if c.SignerKeyPath == "" {
return errors.New("config: signer-key-path is required (or use --threshold with --kms-endpoint)")
}
}
if c.ConfirmationDepth == 0 {
return errors.New("config: confirmation-depth must be > 0")
}
return nil
}
// LoadKeyShareFromKMS fetches threshold key share and group key from KMS.
// Returns (shareBytes, groupKeyBytes, error).
func LoadKeyShareFromKMS(endpoint, keyPath string) ([]byte, []byte, error) {
url := endpoint + "/v1/secrets/" + keyPath
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, nil, fmt.Errorf("kms request: %w", err)
}
// KMS auth via service account token mounted at well-known path.
tokenBytes, err := os.ReadFile("/var/run/secrets/hanzo/kms-token")
if err != nil {
return nil, nil, fmt.Errorf("kms auth token: %w", err)
}
req.Header.Set("Authorization", "Bearer "+string(tokenBytes))
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("kms fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("kms: status %d", resp.StatusCode)
}
var kmsResp struct {
Share string `json:"share"`
GroupKey string `json:"group_key"`
}
if err := json.NewDecoder(resp.Body).Decode(&kmsResp); err != nil {
return nil, nil, fmt.Errorf("kms decode: %w", err)
}
share, err := hex.DecodeString(kmsResp.Share)
if err != nil {
return nil, nil, fmt.Errorf("kms share hex: %w", err)
}
groupKey, err := hex.DecodeString(kmsResp.GroupKey)
if err != nil {
return nil, nil, fmt.Errorf("kms group key hex: %w", err)
}
return share, groupKey, nil
}
// LoadSignerKey reads the hex-encoded private key from disk.
// Returns raw 32-byte key material. DEVELOPMENT ONLY.
func LoadSignerKey(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("load signer key: %w", err)
}
// Strip whitespace/newlines
hexStr := string(data)
for len(hexStr) > 0 && (hexStr[len(hexStr)-1] == '\n' || hexStr[len(hexStr)-1] == '\r' || hexStr[len(hexStr)-1] == ' ') {
hexStr = hexStr[:len(hexStr)-1]
}
// Strip 0x prefix if present
if len(hexStr) >= 2 && hexStr[:2] == "0x" {
hexStr = hexStr[2:]
}
key, err := hex.DecodeString(hexStr)
if err != nil {
return nil, fmt.Errorf("load signer key: invalid hex: %w", err)
}
if len(key) != 32 {
return nil, fmt.Errorf("load signer key: expected 32 bytes, got %d", len(key))
}
return key, nil
}
+30
View File
@@ -0,0 +1,30 @@
module github.com/luxfi/bridge/opnet-watcher
go 1.26.1
require (
github.com/luxfi/crypto v1.17.38
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260215031811-a0ab0b218a81 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/luxfi/cache v1.1.0 // indirect
github.com/luxfi/ids v1.2.9 // indirect
github.com/luxfi/log v1.4.1 // indirect
github.com/luxfi/mock v0.1.0 // indirect
github.com/luxfi/utils v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/supranational/blst v0.3.16 // indirect
go.uber.org/mock v0.6.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sys v0.41.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
)
// Use local module during development
replace github.com/luxfi/crypto => ../../crypto
+43
View File
@@ -0,0 +1,43 @@
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260215031811-a0ab0b218a81 h1:TBzelXBdnzDy+HCrBMcomEnhrmigkWOI1/mIPCi2u4M=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260215031811-a0ab0b218a81/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/luxfi/cache v1.1.0 h1:6LUyGGZ+rrMAJBbAU6+UwkcamXj3zsboRUodIof2Ong=
github.com/luxfi/cache v1.1.0/go.mod h1:9GvlEEE9rFPaaWxvVpSPwW8ZMo2+8VMNNcuPa4AwzPg=
github.com/luxfi/ids v1.2.9 h1:+yjdhXW99drnd2Zlp1u/p8k3G23W3/1btJQ4ogHawUI=
github.com/luxfi/ids v1.2.9/go.mod h1:khJOEdOPxd22yn0jcVrnbX1ADa0GHn5Y74gvCzN5BYc=
github.com/luxfi/log v1.4.1 h1:rIfFRodb9jrD/w7KayaUk0Oc+37PaQQdKEEMJCjR8gw=
github.com/luxfi/log v1.4.1/go.mod h1:64IE3xRMJcpkQwnPUfJw3pDj7wU0kRS7BZ9wM7R72jk=
github.com/luxfi/mock v0.1.0 h1:IwElfNu+T9sXvzFX6tudPDx1vqPuACRSRdxpD5lxW+o=
github.com/luxfi/mock v0.1.0/go.mod h1:izF+9K0gGzFC9zERn6Po37v46eLdPB+EIsDjL3GLk+U=
github.com/luxfi/utils v1.1.0 h1:ti7HvjNwJd4ILDMERJtOAWE9mF8l+zqDVkgWnF7Agic=
github.com/luxfi/utils v1.1.0/go.mod h1:ABqhBdGNig0CaDcnNYldv1byS0BTNi5IKPbJSPF1p98=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE=
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+240
View File
@@ -0,0 +1,240 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// teleport-watcher bridges deposit events from any registered source chain
// to the Teleporter contract on Lux C-Chain.
//
// Supports 30+ chains via plugin architecture:
// - EVM chains (Ethereum, Base, Arbitrum, etc.) use the generic EVMPlugin
// - Non-EVM chains (OP_NET, Solana, TON, Sui, etc.) use dedicated plugins
//
// Usage:
//
// teleport-watcher --chain=opnet --source-rpc=... --source-addr=... ...
// teleport-watcher --chain=ethereum --source-rpc=... --source-addr=... ...
// teleport-watcher --chain=solana --source-rpc=... --source-addr=... ...
//
// It watches the source-chain bridge contract for deposit events, signs proofs,
// and submits them to Teleporter.mintDeposit on Lux. It also periodically
// attests the total backing locked on the source chain.
package main
import (
"context"
"flag"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/luxfi/bridge/opnet-watcher/plugins"
"github.com/luxfi/crypto/threshold"
)
func main() {
cfg := DefaultConfig()
var (
chainName string
tenantDir string
chainDir string
apiAddr string
)
flag.StringVar(&chainName, "chain", "opnet", "Source chain plugin")
flag.StringVar(&tenantDir, "tenant-dir", envOrDefault("BRIDGE_TENANT_DIR", ""), "Directory containing tenant manifests")
flag.StringVar(&chainDir, "chain-dir", envOrDefault("BRIDGE_CHAIN_DIR", ""), "Directory containing chain manifests")
flag.StringVar(&apiAddr, "api-addr", ":8090", "Listen address for the tenant API server")
flag.StringVar(&cfg.SourceRPCURL, "source-rpc", "", "Source chain RPC/API URL")
flag.StringVar(&cfg.LuxRPCURL, "lux-rpc", cfg.LuxRPCURL, "Lux C-Chain JSON-RPC URL")
flag.StringVar(&cfg.TeleporterAddr, "teleporter-addr", "", "Teleporter contract address (0x-prefixed)")
flag.StringVar(&cfg.SourceBridgeAddr, "source-addr", "", "Source chain bridge contract/program address")
flag.StringVar(&cfg.SignerKeyPath, "signer-key-path", "", "Path to hex-encoded signer private key")
flag.StringVar(&cfg.TxKeyPath, "tx-key-path", "", "Path to hex-encoded tx hot wallet key (required with --threshold)")
flag.DurationVar(&cfg.BlockPollInterval, "poll-interval", cfg.BlockPollInterval, "Block poll interval")
flag.DurationVar(&cfg.BackingInterval, "backing-interval", cfg.BackingInterval, "Backing attestation interval")
flag.Uint64Var(&cfg.StartBlock, "start-block", 0, "Block/slot height to start indexing from (0 = latest)")
flag.Uint64Var(&cfg.ConfirmationDepth, "confirmation-depth", cfg.ConfirmationDepth, "Required block confirmations before processing (default 6)")
flag.BoolVar(&cfg.Threshold, "threshold", false, "Use threshold signing (CGGMP21) — required for production")
flag.StringVar(&cfg.KMSEndpoint, "kms-endpoint", "", "KMS endpoint for key share retrieval (required with --threshold)")
flag.StringVar(&cfg.KMSKeyPath, "kms-key-path", "", "KMS secret path for key share (required with --threshold)")
flag.Uint64Var(&cfg.MaxBackingChangePct, "max-backing-change-pct", cfg.MaxBackingChangePct, "Max backing change per attestation in percent (default 100)")
flag.Uint64Var(&cfg.LuxChainIDOverride, "lux-chain-id", cfg.LuxChainIDOverride, "Lux C-Chain ID for EIP-155 signing")
flag.StringVar(&cfg.SolanaVaultAddr, "solana-vault-addr", "", "Solana bridge config PDA address (required with --chain=solana)")
// Legacy OP_NET flags (backwards compatibility).
flag.StringVar(&cfg.SourceRPCURL, "opnet-rpc", "", "OP_NET node JSON-RPC URL (legacy, use --source-rpc)")
flag.StringVar(&cfg.SourceBridgeAddr, "opnet-bridge-addr", "", "OP_NET bridge address (legacy, use --source-addr)")
flag.Parse()
if err := cfg.Validate(); err != nil {
log.Fatalf("invalid config: %v", err)
}
// Select chain plugin.
plugin := selectPlugin(chainName, cfg)
var signer *Signer
var txSigner *Signer // Hot wallet for tx submission
if cfg.Threshold {
shareBytes, groupKeyBytes, err := LoadKeyShareFromKMS(cfg.KMSEndpoint, cfg.KMSKeyPath)
if err != nil {
log.Fatalf("load key share from KMS: %v", err)
}
signer, err = NewThresholdSigner(threshold.SchemeCMP, shareBytes, groupKeyBytes)
if err != nil {
log.Fatalf("create threshold signer: %v", err)
}
// NEW-02: Threshold mode requires a separate hot wallet for tx submission.
txKeyBytes, err := LoadSignerKey(cfg.TxKeyPath)
if err != nil {
log.Fatalf("load tx key: %v (--tx-key-path required with --threshold)", err)
}
txSigner, err = NewSigner(txKeyBytes)
if err != nil {
log.Fatalf("create tx signer: %v", err)
}
log.Printf("chain=%s chain_id=%d signer=0x%x tx=0x%x mode=threshold",
plugin.Name(), plugin.ChainID(), signer.Address(), txSigner.Address())
} else {
log.Println("WARNING: running in single-key mode — DEVELOPMENT ONLY, not safe for production")
keyBytes, err := LoadSignerKey(cfg.SignerKeyPath)
if err != nil {
log.Fatalf("load signer key: %v", err)
}
signer, err = NewSigner(keyBytes)
if err != nil {
log.Fatalf("create signer: %v", err)
}
txSigner = signer // Single-key mode: same key for proofs and txs
log.Printf("chain=%s chain_id=%d signer=0x%x mode=single-key-DEV-ONLY", plugin.Name(), plugin.ChainID(), signer.Address())
}
relay := NewRelay(cfg.LuxRPCURL, cfg.TeleporterAddr, signer, txSigner, cfg.LuxChainIDOverride)
watcher := NewWatcher(cfg, plugin, signer, relay)
// Graceful shutdown on SIGINT/SIGTERM.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
log.Printf("received signal %v, shutting down", sig)
cancel()
}()
// Start tenant API server if manifests are configured.
if tenantDir != "" && chainDir != "" {
registry := NewTenantRegistry(chainDir)
if err := registry.Load(tenantDir); err != nil {
log.Fatalf("load tenant manifests: %v", err)
}
for _, t := range registry.AllTenants() {
log.Printf("tenant: id=%s home=%d hosts=%v chains=%d",
t.ID, t.HomeChainID, t.Hostnames, len(t.SupportedChains))
}
api := NewAPI(registry)
srv := &http.Server{Addr: apiAddr, Handler: api}
go func() {
log.Printf("api: listening on %s", apiAddr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("api server: %v", err)
}
}()
go func() {
<-ctx.Done()
shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutCancel()
_ = srv.Shutdown(shutCtx)
}()
}
if err := watcher.Run(ctx); err != nil && err != context.Canceled {
log.Fatalf("watcher exited with error: %v", err)
}
// Give goroutines a moment to drain.
time.Sleep(100 * time.Millisecond)
log.Printf("teleport-watcher stopped (chain=%s)", plugin.Name())
}
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// evmChains maps chain names to their Teleporter chain IDs.
// All use the generic EVMPlugin (eth_getLogs + eth_call).
var evmChains = map[string]uint64{
"ethereum": 1,
"base": 8453,
"avalanche": 43114,
"arbitrum": 42161,
"optimism": 10,
"polygon": 137,
"bsc": 56,
"tron": 728126,
"hyperliquid": 999,
"filecoin": 314,
"lux-c": 96369,
"lux-zoo": 200200,
"lux-hanzo": 36963,
"lux-pars": 494949,
"lux-spc": 36911,
}
func selectPlugin(name string, cfg Config) plugins.ChainPlugin {
// EVM chains — generic plugin.
if chainID, ok := evmChains[name]; ok {
return plugins.NewEVMPlugin(name, chainID, cfg.SourceRPCURL, cfg.SourceBridgeAddr)
}
// Non-EVM chains — dedicated plugins.
switch name {
case "opnet":
return plugins.NewOPNETPlugin(cfg.SourceRPCURL, cfg.SourceBridgeAddr)
case "solana":
return plugins.NewSolanaPlugin(cfg.SourceRPCURL, cfg.SourceBridgeAddr, cfg.SolanaVaultAddr)
case "ton":
return plugins.NewTONPlugin(cfg.SourceRPCURL, cfg.SourceBridgeAddr)
case "sui":
return plugins.NewSuiPlugin(cfg.SourceRPCURL, cfg.SourceBridgeAddr)
case "near":
return plugins.NewNEARPlugin(cfg.SourceRPCURL, cfg.SourceBridgeAddr)
case "cosmos":
return plugins.NewCosmosPlugin(cfg.SourceRPCURL, cfg.SourceBridgeAddr)
case "xrpl":
return plugins.NewXRPLPlugin(cfg.SourceRPCURL, cfg.SourceBridgeAddr)
case "stellar":
return plugins.NewStellarPlugin(cfg.SourceRPCURL, cfg.SourceBridgeAddr)
case "cardano":
return plugins.NewCardanoPlugin(cfg.SourceRPCURL, cfg.SourceBridgeAddr, envOrDefault("CHAIN_API_KEY", ""))
case "algorand":
return plugins.NewAlgorandPlugin(cfg.SourceRPCURL, cfg.SourceBridgeAddr, envOrDefault("CHAIN_API_KEY", ""))
case "icp":
return plugins.NewICPPlugin(cfg.SourceRPCURL, cfg.SourceBridgeAddr)
case "polkadot":
return plugins.NewPolkadotPlugin(cfg.SourceRPCURL, cfg.SourceBridgeAddr)
case "aptos":
return plugins.NewAptosPlugin(cfg.SourceRPCURL, cfg.SourceBridgeAddr)
case "starknet":
return plugins.NewStarkNetPlugin(cfg.SourceRPCURL, cfg.SourceBridgeAddr)
case "stacks":
return plugins.NewStacksPlugin(cfg.SourceRPCURL, cfg.SourceBridgeAddr)
case "fuel":
return plugins.NewFuelPlugin(cfg.SourceRPCURL, cfg.SourceBridgeAddr)
case "tezos":
return plugins.NewTezosPlugin(cfg.SourceRPCURL, cfg.SourceBridgeAddr)
case "lux":
return plugins.NewLuxPlugin(cfg.SourceRPCURL, cfg.SourceBridgeAddr)
default:
log.Fatalf("unknown chain: %s", name)
return nil
}
}
BIN
View File
Binary file not shown.
+43
View File
@@ -0,0 +1,43 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"context"
"fmt"
"math/big"
"net/http"
"time"
)
const AlgorandChainID uint64 = 0x414C474F // "ALGO" in Teleporter namespace
// AlgorandPlugin polls the Algod API for deposit transactions from the
// bridge application. Algorand uses ed25519 signing.
type AlgorandPlugin struct {
apiURL string
appID string // Application ID
apiKey string
client *http.Client
}
func NewAlgorandPlugin(apiURL, appID, apiKey string) *AlgorandPlugin {
return &AlgorandPlugin{
apiURL: apiURL,
appID: appID,
apiKey: apiKey,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (a *AlgorandPlugin) Name() string { return "algorand" }
func (a *AlgorandPlugin) ChainID() uint64 { return AlgorandChainID }
func (a *AlgorandPlugin) PollDeposits(ctx context.Context, fromRound uint64) ([]DepositEvent, uint64, error) {
return nil, fromRound, fmt.Errorf("algorand: not yet implemented")
}
func (a *AlgorandPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
return nil, fmt.Errorf("algorand: not yet implemented")
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"context"
"fmt"
"math/big"
"net/http"
"time"
)
const AptosChainID uint64 = 0x41505400 // "APT\x00" in Teleporter namespace
// AptosPlugin polls the Aptos REST API for deposit events from the bridge
// Move module. Aptos uses ed25519 signing.
type AptosPlugin struct {
apiURL string
bridgeAddr string // Module address (0x-prefixed)
client *http.Client
}
func NewAptosPlugin(apiURL, bridgeAddr string) *AptosPlugin {
return &AptosPlugin{
apiURL: apiURL,
bridgeAddr: bridgeAddr,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (a *AptosPlugin) Name() string { return "aptos" }
func (a *AptosPlugin) ChainID() uint64 { return AptosChainID }
func (a *AptosPlugin) PollDeposits(ctx context.Context, fromVersion uint64) ([]DepositEvent, uint64, error) {
return nil, fromVersion, fmt.Errorf("aptos: not yet implemented")
}
func (a *AptosPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
return nil, fmt.Errorf("aptos: not yet implemented")
}
+43
View File
@@ -0,0 +1,43 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"context"
"fmt"
"math/big"
"net/http"
"time"
)
const CardanoChainID uint64 = 0x41444100 // "ADA\x00" in Teleporter namespace
// CardanoPlugin polls the Blockfrost API for deposit transactions
// from the bridge script address. Cardano uses ed25519 signing.
type CardanoPlugin struct {
apiURL string
bridgeAddr string // Bech32 script address
apiKey string
client *http.Client
}
func NewCardanoPlugin(apiURL, bridgeAddr, apiKey string) *CardanoPlugin {
return &CardanoPlugin{
apiURL: apiURL,
bridgeAddr: bridgeAddr,
apiKey: apiKey,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (c *CardanoPlugin) Name() string { return "cardano" }
func (c *CardanoPlugin) ChainID() uint64 { return CardanoChainID }
func (c *CardanoPlugin) PollDeposits(ctx context.Context, fromSlot uint64) ([]DepositEvent, uint64, error) {
return nil, fromSlot, fmt.Errorf("cardano: not yet implemented")
}
func (c *CardanoPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
return nil, fmt.Errorf("cardano: not yet implemented")
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"context"
"fmt"
"math/big"
"net/http"
"time"
)
const CosmosChainID uint64 = 0x434F534D // "COSM" in Teleporter namespace
// CosmosPlugin polls a Cosmos/IBC Tendermint RPC node for deposit events
// from the bridge module. Uses secp256k1 signing and IBC event attributes.
type CosmosPlugin struct {
rpcURL string
bridgeAddr string // Bech32 contract address
client *http.Client
}
func NewCosmosPlugin(rpcURL, bridgeAddr string) *CosmosPlugin {
return &CosmosPlugin{
rpcURL: rpcURL,
bridgeAddr: bridgeAddr,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (c *CosmosPlugin) Name() string { return "cosmos" }
func (c *CosmosPlugin) ChainID() uint64 { return CosmosChainID }
func (c *CosmosPlugin) PollDeposits(ctx context.Context, fromBlock uint64) ([]DepositEvent, uint64, error) {
return nil, fromBlock, fmt.Errorf("cosmos: not yet implemented")
}
func (c *CosmosPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
return nil, fmt.Errorf("cosmos: not yet implemented")
}
+197
View File
@@ -0,0 +1,197 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"math/big"
"net/http"
"time"
)
// Deposit(address recipient, uint256 amount, uint64 nonce)
// keccak256 topic: 0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c
const evmDepositTopic = "0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c"
// EVMPlugin is a generic EVM chain plugin that polls eth_getLogs for Deposit
// events from a vault contract. Works for any EVM-compatible chain.
type EVMPlugin struct {
name string
chainID uint64
rpcURL string
vaultAddr string
client *http.Client
}
// NewEVMPlugin creates a plugin for any EVM chain. The name and chainID
// identify the chain in the Teleporter namespace; rpcURL is the JSON-RPC
// endpoint; vaultAddr is the bridge vault contract address.
func NewEVMPlugin(name string, chainID uint64, rpcURL, vaultAddr string) *EVMPlugin {
return &EVMPlugin{
name: name,
chainID: chainID,
rpcURL: rpcURL,
vaultAddr: vaultAddr,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (e *EVMPlugin) Name() string { return e.name }
func (e *EVMPlugin) ChainID() uint64 { return e.chainID }
// PollDeposits fetches Deposit event logs from the vault contract between
// fromBlock+1 and the current block height via eth_getLogs.
func (e *EVMPlugin) PollDeposits(ctx context.Context, fromBlock uint64) ([]DepositEvent, uint64, error) {
height, err := e.blockNumber(ctx)
if err != nil {
return nil, fromBlock, fmt.Errorf("%s: eth_blockNumber: %w", e.name, err)
}
if height <= fromBlock {
return nil, fromBlock, nil
}
params := map[string]interface{}{
"fromBlock": fmt.Sprintf("0x%x", fromBlock+1),
"toBlock": fmt.Sprintf("0x%x", height),
"address": e.vaultAddr,
"topics": [][]string{{evmDepositTopic}},
}
result, err := e.rpcCall(ctx, "eth_getLogs", []interface{}{params})
if err != nil {
return nil, fromBlock, fmt.Errorf("%s: eth_getLogs: %w", e.name, err)
}
var logs []struct {
TxHash string `json:"transactionHash"`
BlockNumber string `json:"blockNumber"`
Data string `json:"data"`
}
if err := json.Unmarshal(result, &logs); err != nil {
return nil, fromBlock, fmt.Errorf("%s: parse logs: %w", e.name, err)
}
var events []DepositEvent
for _, l := range logs {
data, err := hexToBytes(l.Data)
if err != nil {
log.Printf("WARN: %s: skip malformed log in tx %s: %v", e.name, l.TxHash, err)
continue
}
// Deposit event data layout: recipient (32 bytes, address in last 20)
// + amount (32 bytes) + nonce (32 bytes) = 96 bytes minimum.
if len(data) < 96 {
log.Printf("WARN: %s: skip short log data (%d bytes) in tx %s", e.name, len(data), l.TxHash)
continue
}
var recipient [20]byte
copy(recipient[:], data[12:32])
amount := new(big.Int).SetBytes(data[32:64])
nonce := new(big.Int).SetBytes(data[64:96]).Uint64()
blkNum := new(big.Int)
blkNum.SetString(l.BlockNumber, 0)
events = append(events, DepositEvent{
SrcChainID: e.chainID,
Nonce: nonce,
Recipient: recipient,
Amount: amount,
TxID: l.TxHash,
BlockHeight: blkNum.Uint64(),
})
}
return events, height, nil
}
// QueryBacking calls totalLocked() on the vault contract via eth_call.
func (e *EVMPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
// totalLocked() selector: keccak256("totalLocked()")[:4]
params := map[string]string{
"to": e.vaultAddr,
"data": "0xaf29611e",
}
result, err := e.rpcCall(ctx, "eth_call", []interface{}{params, "latest"})
if err != nil {
return nil, fmt.Errorf("%s: eth_call totalLocked: %w", e.name, err)
}
var hexResult string
if err := json.Unmarshal(result, &hexResult); err != nil {
return nil, fmt.Errorf("%s: parse totalLocked: %w", e.name, err)
}
data, err := hexToBytes(hexResult)
if err != nil {
return nil, fmt.Errorf("%s: decode totalLocked: %w", e.name, err)
}
if len(data) < 32 {
return nil, fmt.Errorf("%s: totalLocked too short: %d bytes", e.name, len(data))
}
return new(big.Int).SetBytes(data[:32]), nil
}
func (e *EVMPlugin) blockNumber(ctx context.Context) (uint64, error) {
result, err := e.rpcCall(ctx, "eth_blockNumber", nil)
if err != nil {
return 0, err
}
var hex string
if err := json.Unmarshal(result, &hex); err != nil {
return 0, fmt.Errorf("parse blockNumber: %w", err)
}
n := new(big.Int)
n.SetString(hex, 0)
return n.Uint64(), nil
}
func (e *EVMPlugin) rpcCall(ctx context.Context, method string, params interface{}) (json.RawMessage, error) {
reqBody := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
}
body, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.rpcURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := e.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var rpcResp struct {
Result json.RawMessage `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
return nil, fmt.Errorf("rpc decode: %w", err)
}
if rpcResp.Error != nil {
return nil, fmt.Errorf("rpc error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message)
}
return rpcResp.Result, nil
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"context"
"fmt"
"math/big"
"net/http"
"time"
)
const FuelChainID uint64 = 4294967520
// FuelPlugin polls a Fuel GraphQL API for deposit receipts from the bridge
// Sway contract. Fuel uses secp256k1 signing.
type FuelPlugin struct {
apiURL string
bridgeAddr string // Fuel contract ID (0x-prefixed)
client *http.Client
}
func NewFuelPlugin(apiURL, bridgeAddr string) *FuelPlugin {
return &FuelPlugin{
apiURL: apiURL,
bridgeAddr: bridgeAddr,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (f *FuelPlugin) Name() string { return "fuel" }
func (f *FuelPlugin) ChainID() uint64 { return FuelChainID }
func (f *FuelPlugin) PollDeposits(ctx context.Context, fromBlock uint64) ([]DepositEvent, uint64, error) {
return nil, fromBlock, fmt.Errorf("fuel: not yet implemented")
}
func (f *FuelPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
return nil, fmt.Errorf("fuel: not yet implemented")
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"context"
"fmt"
"math/big"
"net/http"
"time"
)
const ICPChainID uint64 = 0x49435000 // "ICP\x00" in Teleporter namespace
// ICPPlugin polls the ICP Agent API for deposit events from the bridge
// canister. ICP uses secp256k1 for canister signatures.
type ICPPlugin struct {
apiURL string
canisterID string
client *http.Client
}
func NewICPPlugin(apiURL, canisterID string) *ICPPlugin {
return &ICPPlugin{
apiURL: apiURL,
canisterID: canisterID,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (i *ICPPlugin) Name() string { return "icp" }
func (i *ICPPlugin) ChainID() uint64 { return ICPChainID }
func (i *ICPPlugin) PollDeposits(ctx context.Context, fromBlock uint64) ([]DepositEvent, uint64, error) {
return nil, fromBlock, fmt.Errorf("icp: not yet implemented")
}
func (i *ICPPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
return nil, fmt.Errorf("icp: not yet implemented")
}
+42
View File
@@ -0,0 +1,42 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"context"
"fmt"
"math/big"
"net/http"
"time"
)
const LuxChainID uint64 = 0x4C555800 // "LUX\x00" in Teleporter namespace
// LuxPlugin watches for Lux Warp messages from the bridge contract on
// Lux C-Chain. Unlike MPC-signed chains, Lux uses native Warp message
// verification -- no threshold signature is needed.
type LuxPlugin struct {
rpcURL string
bridgeAddr string // Lux C-Chain vault address
client *http.Client
}
func NewLuxPlugin(rpcURL, bridgeAddr string) *LuxPlugin {
return &LuxPlugin{
rpcURL: rpcURL,
bridgeAddr: bridgeAddr,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (l *LuxPlugin) Name() string { return "lux" }
func (l *LuxPlugin) ChainID() uint64 { return LuxChainID }
func (l *LuxPlugin) PollDeposits(ctx context.Context, fromBlock uint64) ([]DepositEvent, uint64, error) {
return nil, fromBlock, fmt.Errorf("lux: not yet implemented (warp message verification)")
}
func (l *LuxPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
return nil, fmt.Errorf("lux: not yet implemented (warp message verification)")
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"context"
"fmt"
"math/big"
"net/http"
"time"
)
const NEARChainID uint64 = 0x4E454152 // "NEAR" in Teleporter namespace
// NEARPlugin polls a NEAR JSON-RPC node for deposit receipts from the bridge contract.
// NEAR uses ed25519 signing and account-based state.
type NEARPlugin struct {
rpcURL string
bridgeAddr string // NEAR account ID (e.g., bridge.lux.near)
client *http.Client
}
func NewNEARPlugin(rpcURL, bridgeAddr string) *NEARPlugin {
return &NEARPlugin{
rpcURL: rpcURL,
bridgeAddr: bridgeAddr,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (n *NEARPlugin) Name() string { return "near" }
func (n *NEARPlugin) ChainID() uint64 { return NEARChainID }
func (n *NEARPlugin) PollDeposits(ctx context.Context, fromBlock uint64) ([]DepositEvent, uint64, error) {
return nil, fromBlock, fmt.Errorf("near: not yet implemented")
}
func (n *NEARPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
return nil, fmt.Errorf("near: not yet implemented")
}
+240
View File
@@ -0,0 +1,240 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"math/big"
"net/http"
"time"
)
const OPNETChainID uint64 = 4294967299 // 0x100000003 in Teleporter namespace
// OPNETPlugin polls an OP_NET node for Lock events from the bridge contract
// and converts them to canonical DepositEvent format.
type OPNETPlugin struct {
rpcURL string
bridgeAddr string
client *http.Client
}
func NewOPNETPlugin(rpcURL, bridgeAddr string) *OPNETPlugin {
return &OPNETPlugin{
rpcURL: rpcURL,
bridgeAddr: bridgeAddr,
client: &http.Client{Timeout: 10 * time.Second},
}
}
func (o *OPNETPlugin) Name() string { return "opnet" }
func (o *OPNETPlugin) ChainID() uint64 { return OPNETChainID }
// PollDeposits fetches Lock events from the OP_NET bridge contract between
// fromBlock+1 and the current block height.
func (o *OPNETPlugin) PollDeposits(ctx context.Context, fromBlock uint64) ([]DepositEvent, uint64, error) {
height, err := o.getBlockHeight(ctx)
if err != nil {
return nil, fromBlock, fmt.Errorf("opnet: get height: %w", err)
}
if height <= fromBlock {
return nil, fromBlock, nil
}
var events []DepositEvent
for block := fromBlock + 1; block <= height; block++ {
blockEvents, err := o.getBlockDeposits(ctx, block)
if err != nil {
return events, block - 1, fmt.Errorf("opnet: block %d: %w", block, err)
}
events = append(events, blockEvents...)
}
return events, height, nil
}
// QueryBacking calls totalLocked() on the OP_NET bridge contract.
func (o *OPNETPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
// totalLocked() selector
selector := "0xaf29611e" // keccak256("totalLocked()")[:4]
params := map[string]string{
"to": o.bridgeAddr,
"data": selector,
}
result, err := o.rpcCall(ctx, "opnet_call", []interface{}{params, "latest"})
if err != nil {
return nil, fmt.Errorf("query totalLocked: %w", err)
}
var hexResult string
if err := json.Unmarshal(result, &hexResult); err != nil {
return nil, fmt.Errorf("parse totalLocked: %w", err)
}
data, err := hexToBytes(hexResult)
if err != nil {
return nil, fmt.Errorf("decode totalLocked: %w", err)
}
if len(data) < 32 {
return nil, fmt.Errorf("totalLocked too short: %d bytes", len(data))
}
return new(big.Int).SetBytes(data[:32]), nil
}
// ────────────────────────────────────────────────────────────────────
// OP_NET RPC helpers
// ────────────────────────────────────────────────────────────────────
func (o *OPNETPlugin) getBlockHeight(ctx context.Context) (uint64, error) {
resp, err := o.rpcCall(ctx, "opnet_blockNumber", nil)
if err != nil {
return 0, err
}
var height uint64
if err := json.Unmarshal(resp, &height); err != nil {
var hexHeight string
if err2 := json.Unmarshal(resp, &hexHeight); err2 != nil {
return 0, fmt.Errorf("parse height: %w", err)
}
n := new(big.Int)
n.SetString(hexHeight, 0)
height = n.Uint64()
}
return height, nil
}
func (o *OPNETPlugin) getBlockDeposits(ctx context.Context, blockNum uint64) ([]DepositEvent, error) {
lockTopic := "0x6c6f636b00000000000000000000000000000000000000000000000000000000"
params := map[string]interface{}{
"fromBlock": fmt.Sprintf("0x%x", blockNum),
"toBlock": fmt.Sprintf("0x%x", blockNum),
"address": o.bridgeAddr,
"topics": [][]string{{lockTopic}},
}
resp, err := o.rpcCall(ctx, "opnet_getLogs", []interface{}{params})
if err != nil {
return nil, err
}
var logs []struct {
TxHash string `json:"transactionHash"`
Topics []string `json:"topics"`
Data string `json:"data"`
}
if err := json.Unmarshal(resp, &logs); err != nil {
return nil, fmt.Errorf("parse logs: %w", err)
}
var events []DepositEvent
for _, l := range logs {
data, err := hexToBytes(l.Data)
if err != nil {
log.Printf("WARN: skipping malformed event at block %d: hex decode error: %v", blockNum, err)
continue
}
if len(data) < 160 {
log.Printf("WARN: skipping malformed event at block %d: data length %d < 160", blockNum, len(data))
continue
}
nonce := new(big.Int).SetBytes(data[64:96]).Uint64()
var recipient [20]byte
copy(recipient[:], data[108:128])
amount := new(big.Int).SetBytes(data[128:160])
events = append(events, DepositEvent{
SrcChainID: OPNETChainID,
Nonce: nonce,
Recipient: recipient,
Amount: amount,
TxID: l.TxHash,
BlockHeight: blockNum,
})
}
return events, nil
}
func (o *OPNETPlugin) rpcCall(ctx context.Context, method string, params interface{}) (json.RawMessage, error) {
reqBody := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
}
body, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.rpcURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := o.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var rpcResp struct {
Result json.RawMessage `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
return nil, fmt.Errorf("rpc decode: %w", err)
}
if rpcResp.Error != nil {
return nil, fmt.Errorf("rpc error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message)
}
return rpcResp.Result, nil
}
func hexToBytes(s string) ([]byte, error) {
if len(s) >= 2 && s[:2] == "0x" {
s = s[2:]
}
if len(s)%2 != 0 {
s = "0" + s
}
b := make([]byte, len(s)/2)
for i := 0; i < len(b); i++ {
hi := unhex(s[2*i])
lo := unhex(s[2*i+1])
if hi == 0xff || lo == 0xff {
return nil, fmt.Errorf("invalid hex at %d", 2*i)
}
b[i] = hi<<4 | lo
}
return b, nil
}
func unhex(c byte) byte {
switch {
case '0' <= c && c <= '9':
return c - '0'
case 'a' <= c && c <= 'f':
return c - 'a' + 10
case 'A' <= c && c <= 'F':
return c - 'A' + 10
default:
return 0xff
}
}
+40
View File
@@ -0,0 +1,40 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"context"
"math/big"
)
// DepositEvent is the canonical deposit event format emitted by all source-chain adapters.
// Maps 1:1 to Teleporter.mintDeposit parameters on Lux C-Chain.
type DepositEvent struct {
SrcChainID uint64 // Source chain identifier (OPNET=4294967299, SOLANA=1399811149, TON=1414483791)
Nonce uint64 // Monotonic deposit nonce on source chain
Recipient [20]byte // Lux C-Chain recipient address (EVM)
Amount *big.Int // Deposit amount in source chain native units
TxID string // Source chain transaction identifier
BlockHeight uint64 // Source chain block/slot height
}
// ChainPlugin abstracts a source-chain block indexer for the Teleport bridge watcher.
// Each implementation polls its chain for deposit events and converts them to the
// canonical format consumed by the signer and relay pipeline.
type ChainPlugin interface {
// Name returns a human-readable identifier (e.g., "opnet", "solana", "ton").
Name() string
// ChainID returns the Teleporter chain identifier for this source chain.
ChainID() uint64
// PollDeposits fetches deposit events from fromSlot onward.
// Returns events found and the latest processed slot/block height.
// The watcher calls this on each tick interval.
PollDeposits(ctx context.Context, fromSlot uint64) ([]DepositEvent, uint64, error)
// QueryBacking returns the total value locked in the bridge contract on this chain.
// Used for periodic backing attestations to Teleporter.updateBacking.
QueryBacking(ctx context.Context) (*big.Int, error)
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"context"
"fmt"
"math/big"
"net/http"
"time"
)
const PolkadotChainID uint64 = 0x444F5400 // "DOT\x00" in Teleporter namespace
// PolkadotPlugin polls a Substrate JSON-RPC node for deposit extrinsics
// from the bridge pallet. Polkadot uses sr25519 signing.
type PolkadotPlugin struct {
rpcURL string
bridgeAddr string // SS58 address
client *http.Client
}
func NewPolkadotPlugin(rpcURL, bridgeAddr string) *PolkadotPlugin {
return &PolkadotPlugin{
rpcURL: rpcURL,
bridgeAddr: bridgeAddr,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (p *PolkadotPlugin) Name() string { return "polkadot" }
func (p *PolkadotPlugin) ChainID() uint64 { return PolkadotChainID }
func (p *PolkadotPlugin) PollDeposits(ctx context.Context, fromBlock uint64) ([]DepositEvent, uint64, error) {
return nil, fromBlock, fmt.Errorf("polkadot: not yet implemented")
}
func (p *PolkadotPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
return nil, fmt.Errorf("polkadot: not yet implemented")
}
+380
View File
@@ -0,0 +1,380 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"time"
)
const SolanaChainID uint64 = 1399811149 // "SOLN" in Teleporter namespace
// SolanaPlugin polls a Solana RPC node for LockEvent logs from the Lux bridge program
// and converts them to canonical DepositEvent format.
type SolanaPlugin struct {
rpcURL string
programID string // Base58 bridge program address
vaultAddress string // Base58 bridge config PDA (precomputed, not derived at runtime)
client *http.Client
}
func NewSolanaPlugin(rpcURL, programID, vaultAddress string) *SolanaPlugin {
return &SolanaPlugin{
rpcURL: rpcURL,
programID: programID,
vaultAddress: vaultAddress,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (s *SolanaPlugin) Name() string { return "solana" }
func (s *SolanaPlugin) ChainID() uint64 { return SolanaChainID }
// PollDeposits fetches confirmed transaction signatures for the bridge program
// starting from the given slot, then parses LockEvent data from each transaction's logs.
func (s *SolanaPlugin) PollDeposits(ctx context.Context, fromSlot uint64) ([]DepositEvent, uint64, error) {
// Get current slot to bound the search.
currentSlot, err := s.getSlot(ctx)
if err != nil {
return nil, fromSlot, fmt.Errorf("solana: get slot: %w", err)
}
if currentSlot <= fromSlot {
return nil, fromSlot, nil
}
// Fetch signatures for the bridge program in the slot range.
sigs, err := s.getSignaturesForAddress(ctx, fromSlot, currentSlot)
if err != nil {
return nil, fromSlot, fmt.Errorf("solana: get signatures: %w", err)
}
var events []DepositEvent
for _, sig := range sigs {
tx, err := s.getTransaction(ctx, sig.Signature)
if err != nil {
return events, sig.Slot, fmt.Errorf("solana: get tx %s: %w", sig.Signature, err)
}
deposit, ok := parseLockEventFromLogs(tx.Meta.LogMessages, sig.Slot, sig.Signature)
if ok {
events = append(events, deposit)
}
}
return events, currentSlot, nil
}
// QueryBacking calls the bridge program's total_locked account data via getAccountInfo.
func (s *SolanaPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
// The BridgeConfig PDA stores total locked value.
// PDA seeds: ["bridge_config"] — derived deterministically from the program ID.
// For now we read the account and parse the total_locked field from its data.
configPDA, err := s.deriveBridgeConfigPDA()
if err != nil {
return nil, fmt.Errorf("solana: derive config PDA: %w", err)
}
info, err := s.getAccountInfo(ctx, configPDA)
if err != nil {
return nil, fmt.Errorf("solana: get account info: %w", err)
}
data, err := base64.StdEncoding.DecodeString(info.Data[0])
if err != nil {
return nil, fmt.Errorf("solana: decode account data: %w", err)
}
// BridgeConfig layout (after 8-byte discriminator):
// admin: 32 bytes (offset 8)
// mpc_signers: 96 bytes (offset 40)
// threshold: 1 byte (offset 136)
// fee_bps: 2 bytes (offset 137)
// fee_collector: 32 bytes (offset 139)
// paused: 1 byte (offset 171)
// outbound_nonce: 8 bytes (offset 172)
// chain_id: 8 bytes (offset 180)
// bump: 1 byte (offset 188)
//
// Total locked is tracked in the token vault accounts, not in BridgeConfig.
// Sum vault token balances via getTokenAccountBalance for each registered vault.
//
// For the backing attestation, we query vault balance directly.
_ = data
return s.queryVaultBalance(ctx)
}
// ────────────────────────────────────────────────────────────────────
// Solana RPC helpers
// ────────────────────────────────────────────────────────────────────
type solanaSignature struct {
Signature string `json:"signature"`
Slot uint64 `json:"slot"`
}
type solanaTx struct {
Meta struct {
LogMessages []string `json:"logMessages"`
} `json:"meta"`
}
type solanaAccountInfo struct {
Data []string `json:"data"` // [base64_data, encoding]
}
func (s *SolanaPlugin) getSlot(ctx context.Context) (uint64, error) {
result, err := s.rpcCall(ctx, "getSlot", []interface{}{
map[string]string{"commitment": "confirmed"},
})
if err != nil {
return 0, err
}
var slot uint64
if err := json.Unmarshal(result, &slot); err != nil {
return 0, fmt.Errorf("parse slot: %w", err)
}
return slot, nil
}
func (s *SolanaPlugin) getSignaturesForAddress(ctx context.Context, minSlot, maxSlot uint64) ([]solanaSignature, error) {
params := []interface{}{
s.programID,
map[string]interface{}{
"commitment": "confirmed",
"minContext": map[string]uint64{"slot": minSlot},
},
}
result, err := s.rpcCall(ctx, "getSignaturesForAddress", params)
if err != nil {
return nil, err
}
var sigs []solanaSignature
if err := json.Unmarshal(result, &sigs); err != nil {
return nil, fmt.Errorf("parse signatures: %w", err)
}
// Filter to slot range.
var filtered []solanaSignature
for _, sig := range sigs {
if sig.Slot > minSlot && sig.Slot <= maxSlot {
filtered = append(filtered, sig)
}
}
return filtered, nil
}
func (s *SolanaPlugin) getTransaction(ctx context.Context, sig string) (*solanaTx, error) {
params := []interface{}{
sig,
map[string]interface{}{
"encoding": "json",
"commitment": "confirmed",
"maxSupportedTransactionVersion": 0,
},
}
result, err := s.rpcCall(ctx, "getTransaction", params)
if err != nil {
return nil, err
}
var tx solanaTx
if err := json.Unmarshal(result, &tx); err != nil {
return nil, fmt.Errorf("parse tx: %w", err)
}
return &tx, nil
}
func (s *SolanaPlugin) getAccountInfo(ctx context.Context, address string) (*solanaAccountInfo, error) {
params := []interface{}{
address,
map[string]string{"encoding": "base64", "commitment": "confirmed"},
}
result, err := s.rpcCall(ctx, "getAccountInfo", params)
if err != nil {
return nil, err
}
var wrapper struct {
Value *solanaAccountInfo `json:"value"`
}
if err := json.Unmarshal(result, &wrapper); err != nil {
return nil, fmt.Errorf("parse account info: %w", err)
}
if wrapper.Value == nil {
return nil, fmt.Errorf("account not found: %s", address)
}
return wrapper.Value, nil
}
func (s *SolanaPlugin) queryVaultBalance(ctx context.Context) (*big.Int, error) {
// Query program token accounts owned by the vault PDA.
// getTokenAccountsByOwner returns all SPL token accounts.
// Sum their balances for total backing.
params := []interface{}{
s.programID,
map[string]string{"programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"},
map[string]string{"encoding": "jsonParsed"},
}
result, err := s.rpcCall(ctx, "getTokenAccountsByOwner", params)
if err != nil {
return nil, fmt.Errorf("get token accounts: %w", err)
}
var wrapper struct {
Value []struct {
Account struct {
Data struct {
Parsed struct {
Info struct {
TokenAmount struct {
Amount string `json:"amount"`
} `json:"tokenAmount"`
} `json:"info"`
} `json:"parsed"`
} `json:"data"`
} `json:"account"`
} `json:"value"`
}
if err := json.Unmarshal(result, &wrapper); err != nil {
return nil, fmt.Errorf("parse token accounts: %w", err)
}
total := new(big.Int)
for _, acct := range wrapper.Value {
bal, ok := new(big.Int).SetString(acct.Account.Data.Parsed.Info.TokenAmount.Amount, 10)
if ok {
total.Add(total, bal)
}
}
return total, nil
}
func (s *SolanaPlugin) deriveBridgeConfigPDA() (string, error) {
if s.vaultAddress == "" {
return "", fmt.Errorf("solana: vaultAddress not configured (set to precomputed findProgramAddress([\"bridge_config\"], programID))")
}
return s.vaultAddress, nil
}
// parseLockEventFromLogs extracts a DepositEvent from Anchor program log messages.
// Anchor emits events as base64-encoded borsh in "Program data: <base64>" log lines.
func parseLockEventFromLogs(logs []string, slot uint64, txSig string) (DepositEvent, bool) {
// Anchor event discriminator for LockEvent: SHA256("event:LockEvent")[:8]
lockDiscriminator := []byte{0x65, 0xc4, 0x2b, 0x6f, 0x39, 0x48, 0x3e, 0x09}
for _, line := range logs {
if len(line) < 15 {
continue
}
// Anchor logs: "Program data: <base64>"
const prefix = "Program data: "
idx := 0
for i := 0; i <= len(line)-len(prefix); i++ {
if line[i:i+len(prefix)] == prefix {
idx = i + len(prefix)
break
}
}
if idx == 0 {
continue
}
data, err := base64.StdEncoding.DecodeString(line[idx:])
if err != nil || len(data) < 8 {
continue
}
if !bytes.Equal(data[:8], lockDiscriminator) {
continue
}
// LockEvent borsh layout (after 8-byte discriminator):
// source_chain: u64 (8 bytes)
// dest_chain: u64 (8 bytes)
// nonce: u64 (8 bytes)
// token: [32]u8
// sender: [32]u8
// recipient: [32]u8 (EVM address zero-padded to 32 bytes)
// amount: u64 (8 bytes)
// fee: u64 (8 bytes)
// timestamp: i64 (8 bytes)
const eventLen = 8 + 8 + 8 + 32 + 32 + 32 + 8 + 8 + 8 // 144
if len(data) < 8+eventLen {
continue
}
d := data[8:]
nonce := le64(d[16:24])
amount := new(big.Int).SetUint64(le64(d[112:120]))
var recipient [20]byte
// EVM address is in the last 20 bytes of the 32-byte recipient field at offset 88
copy(recipient[:], d[100:120]) // bytes 12-31 of the 32-byte field at offset 88
return DepositEvent{
SrcChainID: SolanaChainID,
Nonce: nonce,
Recipient: recipient,
Amount: amount,
TxID: txSig,
BlockHeight: slot,
}, true
}
return DepositEvent{}, false
}
func le64(b []byte) uint64 {
_ = b[7]
return uint64(b[0]) | uint64(b[1])<<8 | uint64(b[2])<<16 | uint64(b[3])<<24 |
uint64(b[4])<<32 | uint64(b[5])<<40 | uint64(b[6])<<48 | uint64(b[7])<<56
}
func (s *SolanaPlugin) rpcCall(ctx context.Context, method string, params interface{}) (json.RawMessage, error) {
reqBody := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
}
body, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.rpcURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var rpcResp struct {
Result json.RawMessage `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
return nil, fmt.Errorf("rpc decode: %w", err)
}
if rpcResp.Error != nil {
return nil, fmt.Errorf("rpc error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message)
}
return rpcResp.Result, nil
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"context"
"fmt"
"math/big"
"net/http"
"time"
)
const StacksChainID uint64 = 4294967440
// StacksPlugin polls a Stacks node API for deposit transactions from the
// bridge Clarity contract. Stacks uses secp256k1 signing (Bitcoin-derived).
type StacksPlugin struct {
apiURL string
bridgeAddr string // Clarity contract principal (SP...)
client *http.Client
}
func NewStacksPlugin(apiURL, bridgeAddr string) *StacksPlugin {
return &StacksPlugin{
apiURL: apiURL,
bridgeAddr: bridgeAddr,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (s *StacksPlugin) Name() string { return "stacks" }
func (s *StacksPlugin) ChainID() uint64 { return StacksChainID }
func (s *StacksPlugin) PollDeposits(ctx context.Context, fromBlock uint64) ([]DepositEvent, uint64, error) {
return nil, fromBlock, fmt.Errorf("stacks: not yet implemented")
}
func (s *StacksPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
return nil, fmt.Errorf("stacks: not yet implemented")
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"context"
"fmt"
"math/big"
"net/http"
"time"
)
const StarkNetChainID uint64 = 0x53544B00 // "STK\x00" in Teleporter namespace
// StarkNetPlugin polls a StarkNet JSON-RPC node for deposit events from the
// bridge contract. StarkNet uses the Stark curve for signing.
type StarkNetPlugin struct {
rpcURL string
bridgeAddr string // StarkNet contract address (0x-prefixed felt)
client *http.Client
}
func NewStarkNetPlugin(rpcURL, bridgeAddr string) *StarkNetPlugin {
return &StarkNetPlugin{
rpcURL: rpcURL,
bridgeAddr: bridgeAddr,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (s *StarkNetPlugin) Name() string { return "starknet" }
func (s *StarkNetPlugin) ChainID() uint64 { return StarkNetChainID }
func (s *StarkNetPlugin) PollDeposits(ctx context.Context, fromBlock uint64) ([]DepositEvent, uint64, error) {
return nil, fromBlock, fmt.Errorf("starknet: not yet implemented")
}
func (s *StarkNetPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
return nil, fmt.Errorf("starknet: not yet implemented")
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"context"
"fmt"
"math/big"
"net/http"
"time"
)
const StellarChainID uint64 = 0x584C4D00 // "XLM\x00" in Teleporter namespace
// StellarPlugin polls the Stellar Horizon API for deposit operations
// from the bridge account. Stellar uses ed25519 signing.
type StellarPlugin struct {
horizonURL string
bridgeAddr string // Stellar public key (G...)
client *http.Client
}
func NewStellarPlugin(horizonURL, bridgeAddr string) *StellarPlugin {
return &StellarPlugin{
horizonURL: horizonURL,
bridgeAddr: bridgeAddr,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (s *StellarPlugin) Name() string { return "stellar" }
func (s *StellarPlugin) ChainID() uint64 { return StellarChainID }
func (s *StellarPlugin) PollDeposits(ctx context.Context, fromLedger uint64) ([]DepositEvent, uint64, error) {
return nil, fromLedger, fmt.Errorf("stellar: not yet implemented")
}
func (s *StellarPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
return nil, fmt.Errorf("stellar: not yet implemented")
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"context"
"fmt"
"math/big"
"net/http"
"time"
)
const SuiChainID uint64 = 0x53554900 // "SUI\x00" in Teleporter namespace
// SuiPlugin polls a Sui JSON-RPC node for deposit events from the bridge Move module.
// Sui uses ed25519 signing and object-centric state.
type SuiPlugin struct {
rpcURL string
bridgeAddr string // Move module object ID
client *http.Client
}
func NewSuiPlugin(rpcURL, bridgeAddr string) *SuiPlugin {
return &SuiPlugin{
rpcURL: rpcURL,
bridgeAddr: bridgeAddr,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (s *SuiPlugin) Name() string { return "sui" }
func (s *SuiPlugin) ChainID() uint64 { return SuiChainID }
func (s *SuiPlugin) PollDeposits(ctx context.Context, fromCheckpoint uint64) ([]DepositEvent, uint64, error) {
return nil, fromCheckpoint, fmt.Errorf("sui: not yet implemented")
}
func (s *SuiPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
return nil, fmt.Errorf("sui: not yet implemented")
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"context"
"fmt"
"math/big"
"net/http"
"time"
)
const TezosChainID uint64 = 4294967500
// TezosPlugin polls a Tezos RPC node for deposit operations from the bridge
// Michelson contract. Tezos uses ed25519 signing.
type TezosPlugin struct {
rpcURL string
bridgeAddr string // KT1... contract address
client *http.Client
}
func NewTezosPlugin(rpcURL, bridgeAddr string) *TezosPlugin {
return &TezosPlugin{
rpcURL: rpcURL,
bridgeAddr: bridgeAddr,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (t *TezosPlugin) Name() string { return "tezos" }
func (t *TezosPlugin) ChainID() uint64 { return TezosChainID }
func (t *TezosPlugin) PollDeposits(ctx context.Context, fromLevel uint64) ([]DepositEvent, uint64, error) {
return nil, fromLevel, fmt.Errorf("tezos: not yet implemented")
}
func (t *TezosPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
return nil, fmt.Errorf("tezos: not yet implemented")
}
+275
View File
@@ -0,0 +1,275 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"time"
)
const TONChainID uint64 = 1414483791 // "TONO" in Teleporter namespace
// TONPlugin polls a TON HTTP API (toncenter/liteserver) for deposit external messages
// from the TeleportVault contract and converts them to canonical DepositEvent format.
type TONPlugin struct {
apiURL string // TON HTTP API base URL (e.g., https://toncenter.com/api/v2)
contract string // TeleportVault contract address (base64url or raw)
client *http.Client
}
func NewTONPlugin(apiURL, contract string) *TONPlugin {
return &TONPlugin{
apiURL: apiURL,
contract: contract,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (t *TONPlugin) Name() string { return "ton" }
func (t *TONPlugin) ChainID() uint64 { return TONChainID }
// PollDeposits fetches transactions to the bridge contract since the given logical time,
// filters for deposit external out messages, and converts to DepositEvent.
// TON uses logical time (lt) instead of block height for ordering.
func (t *TONPlugin) PollDeposits(ctx context.Context, fromLT uint64) ([]DepositEvent, uint64, error) {
txs, err := t.getTransactions(ctx, fromLT)
if err != nil {
return nil, fromLT, fmt.Errorf("ton: get transactions: %w", err)
}
var events []DepositEvent
var maxLT uint64 = fromLT
for _, tx := range txs {
if tx.LT <= fromLT {
continue
}
if tx.LT > maxLT {
maxLT = tx.LT
}
// Parse deposit events from external out messages.
for _, msg := range tx.OutMsgs {
if msg.MsgType != "ext_out_msg" {
continue
}
deposit, ok := parseDepositFromBOC(msg.Body, tx.LT, tx.Hash)
if ok {
events = append(events, deposit)
}
}
}
return events, maxLT, nil
}
// QueryBacking calls the get_total_locked get-method on the TeleportVault contract.
func (t *TONPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
result, err := t.runGetMethod(ctx, "get_total_locked")
if err != nil {
return nil, fmt.Errorf("ton: query backing: %w", err)
}
if len(result.Stack) == 0 {
return nil, fmt.Errorf("ton: get_total_locked returned empty stack")
}
// TVM returns integers as ["num", "0x..."] tuples.
val, ok := new(big.Int).SetString(result.Stack[0].Value, 0)
if !ok {
return nil, fmt.Errorf("ton: parse total_locked: %s", result.Stack[0].Value)
}
return val, nil
}
// ────────────────────────────────────────────────────────────────────
// TON HTTP API types and helpers
// ────────────────────────────────────────────────────────────────────
type tonTransaction struct {
LT uint64 `json:"lt"`
Hash string `json:"hash"`
OutMsgs []tonMessage `json:"out_msgs"`
}
type tonMessage struct {
MsgType string `json:"msg_type"`
Body string `json:"body"` // base64-encoded BOC
}
type tonGetMethodResult struct {
Stack []tonStackEntry `json:"stack"`
}
type tonStackEntry struct {
Type string `json:"type"`
Value string `json:"value"`
}
func (t *TONPlugin) getTransactions(ctx context.Context, afterLT uint64) ([]tonTransaction, error) {
url := fmt.Sprintf("%s/getTransactions?address=%s&limit=100&lt=%d&archival=true",
t.apiURL, t.contract, afterLT)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := t.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var apiResp struct {
OK bool `json:"ok"`
Result []tonTransaction `json:"result"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
if !apiResp.OK {
return nil, fmt.Errorf("api error: %s", apiResp.Error)
}
return apiResp.Result, nil
}
func (t *TONPlugin) runGetMethod(ctx context.Context, method string) (*tonGetMethodResult, error) {
url := fmt.Sprintf("%s/runGetMethod?address=%s&method=%s",
t.apiURL, t.contract, method)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := t.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var apiResp struct {
OK bool `json:"ok"`
Result tonGetMethodResult `json:"result"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
if !apiResp.OK {
return nil, fmt.Errorf("api error: %s", apiResp.Error)
}
return &apiResp.Result, nil
}
// parseDepositFromBOC extracts a DepositEvent from a TON external out message body.
// The TeleportVault emits deposit events as external messages with the layout:
//
// op(32) chain_id(64) nonce(64) lux_recipient(256) amount(coins) timestamp(32)
//
// The body is base64-encoded BOC (Bag of Cells).
func parseDepositFromBOC(bodyB64 string, lt uint64, txHash string) (DepositEvent, bool) {
data, err := base64.StdEncoding.DecodeString(bodyB64)
if err != nil {
return DepositEvent{}, false
}
// Minimal BOC parsing: skip BOC envelope to get to the cell data.
// TON BOC format: magic(4) + flags(1) + cells(1) + roots(1) + absent(1) + size(1) + ...
// For single-cell BOCs from external out messages, the cell data starts after the header.
// The exact offset depends on the BOC serialization variant.
//
// We look for the deposit op tag (0x44455031) in the data.
const depositOp uint32 = 0x44455031
cellData := findCellData(data)
if cellData == nil || len(cellData) < 54 { // minimum: 4+8+8+32+2 bytes
return DepositEvent{}, false
}
// Verify op tag
op := be32(cellData[0:4])
if op != depositOp {
return DepositEvent{}, false
}
chainID := be64(cellData[4:12])
nonce := be64(cellData[12:20])
// lux_recipient is 256 bits (32 bytes), EVM address in last 20 bytes
var recipient [20]byte
copy(recipient[:], cellData[32:52]) // bytes 12-31 of the 32-byte field at offset 20
// WARNING: Simplified parser. TON coins use VarUInteger16 encoding: first 4 bits = byte
// count N, next N bytes = big-endian amount. This code assumes a fixed 8-byte big-endian
// value at offset 52, which is only correct when the coin amount occupies exactly 8 bytes
// (length nibble = 0x8). For production TON support, implement full VarUInteger16
// deserialization (read 4-bit length prefix, then N bytes) or use tonutils-go.
amount := new(big.Int)
if len(cellData) >= 60 {
amount.SetBytes(cellData[52:60])
}
_ = chainID // already known (TONChainID)
return DepositEvent{
SrcChainID: TONChainID,
Nonce: nonce,
Recipient: recipient,
Amount: amount,
TxID: txHash,
BlockHeight: lt,
}, true
}
// findCellData locates the cell payload within a BOC envelope.
// Handles the common single-cell BOC format.
func findCellData(boc []byte) []byte {
if len(boc) < 10 {
return nil
}
// Standard BOC magic: b5ee9c72
if boc[0] == 0xb5 && boc[1] == 0xee && boc[2] == 0x9c && boc[3] == 0x72 {
// flags(1) size(1) cells(1) roots(1) absent(1) totCellsSize(size bytes) ...
flags := boc[4]
sizeBytes := int(flags & 0x07)
if len(boc) < 5+sizeBytes*3+2 {
return nil
}
// Skip header to cell data. For single-cell BOCs this is straightforward.
headerSize := 4 + 1 + 1 + 1 + 1 + sizeBytes + sizeBytes
if headerSize < len(boc) {
return boc[headerSize:]
}
}
// Fallback: return raw data (for non-standard encoding)
return boc
}
func be32(b []byte) uint32 {
return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
}
func be64(b []byte) uint64 {
return uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 |
uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7])
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package plugins
import (
"context"
"fmt"
"math/big"
"net/http"
"time"
)
const XRPLChainID uint64 = 0x5852504C // "XRPL" in Teleporter namespace
// XRPLPlugin polls an XRPL WebSocket/JSON-RPC node for deposit transactions
// from the bridge account. XRPL supports secp256k1 and ed25519 signing.
type XRPLPlugin struct {
rpcURL string
bridgeAddr string // XRPL classic address (r...)
client *http.Client
}
func NewXRPLPlugin(rpcURL, bridgeAddr string) *XRPLPlugin {
return &XRPLPlugin{
rpcURL: rpcURL,
bridgeAddr: bridgeAddr,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (x *XRPLPlugin) Name() string { return "xrpl" }
func (x *XRPLPlugin) ChainID() uint64 { return XRPLChainID }
func (x *XRPLPlugin) PollDeposits(ctx context.Context, fromLedger uint64) ([]DepositEvent, uint64, error) {
return nil, fromLedger, fmt.Errorf("xrpl: not yet implemented")
}
func (x *XRPLPlugin) QueryBacking(ctx context.Context) (*big.Int, error) {
return nil, fmt.Errorf("xrpl: not yet implemented")
}
+316
View File
@@ -0,0 +1,316 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package main
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"sync"
"time"
"github.com/luxfi/crypto"
)
// Relay submits signed proofs to the Teleporter contract on Lux C-Chain.
type Relay struct {
rpcURL string
teleporterAddr string
signer *Signer
txSigner *Signer // NEW-02: separate hot wallet for tx submission in threshold mode
httpClient *http.Client
mu sync.Mutex // protects nonce
nonce uint64
chainID uint64
}
// NewRelay creates a relay targeting the given Lux C-Chain RPC and Teleporter address.
// txSigner is the hot wallet used for transaction submission. In single-key mode,
// pass the same signer for both. In threshold mode, txSigner is a separate hot wallet
// (the MPC signs proofs, the hot wallet submits them on-chain).
func NewRelay(rpcURL, teleporterAddr string, signer *Signer, txSigner *Signer, chainID uint64) *Relay {
return &Relay{
rpcURL: rpcURL,
teleporterAddr: teleporterAddr,
signer: signer,
txSigner: txSigner,
httpClient: &http.Client{Timeout: 30 * time.Second},
chainID: chainID,
}
}
// SubmitDeposit calls Teleporter.mintDeposit(srcChainId, depositNonce, recipient, amount, signature).
//
// Function selector: keccak256("mintDeposit(uint256,uint256,address,uint256,bytes)")[:4]
func (r *Relay) SubmitDeposit(ctx context.Context, srcChainID, nonce uint64, recipient [20]byte, amount *big.Int, sig []byte) (string, error) {
// mintDeposit(uint256,uint256,address,uint256,bytes)
selector := funcSelector("mintDeposit(uint256,uint256,address,uint256,bytes)")
var calldata []byte
calldata = append(calldata, selector[:]...)
calldata = append(calldata, uint256Bytes(new(big.Int).SetUint64(srcChainID))...) // srcChainId
calldata = append(calldata, uint256Bytes(new(big.Int).SetUint64(nonce))...) // depositNonce
calldata = append(calldata, leftPad(recipient[:], 32)...) // recipient (address)
calldata = append(calldata, uint256Bytes(amount)...) // amount
calldata = append(calldata, uint256Bytes(new(big.Int).SetUint64(160))...) // offset to bytes (5 * 32)
calldata = append(calldata, uint256Bytes(new(big.Int).SetUint64(uint64(len(sig))))...) // length of sig
calldata = append(calldata, rightPad(sig, 32)...) // signature data
return r.sendTx(ctx, calldata)
}
// SubmitBacking calls Teleporter.updateBacking(srcChainId, totalBacking, timestamp, signature).
// CRITICAL-01: timestamp is now a parameter (signed by MPC, validated on-chain).
//
// Function selector: keccak256("updateBacking(uint256,uint256,uint256,bytes)")[:4]
func (r *Relay) SubmitBacking(ctx context.Context, srcChainID uint64, totalBacking *big.Int, timestamp uint64, sig []byte) (string, error) {
selector := funcSelector("updateBacking(uint256,uint256,uint256,bytes)")
var calldata []byte
calldata = append(calldata, selector[:]...)
calldata = append(calldata, uint256Bytes(new(big.Int).SetUint64(srcChainID))...) // srcChainId
calldata = append(calldata, uint256Bytes(totalBacking)...) // totalBacking
calldata = append(calldata, uint256Bytes(new(big.Int).SetUint64(timestamp))...) // timestamp
calldata = append(calldata, uint256Bytes(new(big.Int).SetUint64(128))...) // offset to bytes (4 * 32)
calldata = append(calldata, uint256Bytes(new(big.Int).SetUint64(uint64(len(sig))))...) // length of sig
calldata = append(calldata, rightPad(sig, 32)...) // signature data
return r.sendTx(ctx, calldata)
}
// sendTx builds, signs locally, and submits via eth_sendRawTransaction.
// CRITICAL-03: Never use eth_sendTransaction — it exposes the node key.
// NEW-02: Uses txSigner (hot wallet) for transaction signing, not the MPC signer.
func (r *Relay) sendTx(ctx context.Context, calldata []byte) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
// Fetch nonce from chain if we haven't yet.
if r.nonce == 0 {
from := r.txSigner.Address()
n, err := r.fetchNonce(ctx, from)
if err != nil {
return "", fmt.Errorf("relay fetch nonce: %w", err)
}
r.nonce = n
}
// Fetch gas price.
gasPrice, err := r.fetchGasPrice(ctx)
if err != nil {
return "", fmt.Errorf("relay fetch gas price: %w", err)
}
// Build EIP-155 transaction and RLP-encode.
rawTx := r.buildRawTx(calldata, r.nonce, gasPrice)
// Sign with hot wallet key (txSigner always has a local key).
signedTx, err := r.txSigner.SignTransaction(rawTx, r.chainID)
if err != nil {
return "", fmt.Errorf("relay sign tx: %w", err)
}
// Submit signed transaction.
result, err := r.ethCall(ctx, "eth_sendRawTransaction", []interface{}{"0x" + hex.EncodeToString(signedTx)})
if err != nil {
// Nonce recovery: re-fetch from chain on next attempt.
r.nonce = 0
return "", fmt.Errorf("relay send raw tx: %w", err)
}
r.nonce++
var txHash string
if err := json.Unmarshal(result, &txHash); err != nil {
return "", fmt.Errorf("relay parse tx hash: %w", err)
}
return txHash, nil
}
// fetchNonce gets the current transaction count for the signer address.
func (r *Relay) fetchNonce(ctx context.Context, addr [20]byte) (uint64, error) {
result, err := r.ethCall(ctx, "eth_getTransactionCount", []interface{}{
"0x" + hex.EncodeToString(addr[:]), "pending",
})
if err != nil {
return 0, err
}
var hexNonce string
if err := json.Unmarshal(result, &hexNonce); err != nil {
return 0, err
}
n := new(big.Int)
n.SetString(hexNonce, 0)
return n.Uint64(), nil
}
// fetchGasPrice queries eth_gasPrice.
func (r *Relay) fetchGasPrice(ctx context.Context) (*big.Int, error) {
result, err := r.ethCall(ctx, "eth_gasPrice", nil)
if err != nil {
return nil, err
}
var hexPrice string
if err := json.Unmarshal(result, &hexPrice); err != nil {
return nil, err
}
p := new(big.Int)
p.SetString(hexPrice, 0)
return p, nil
}
// buildRawTx returns the unsigned EIP-155 transaction RLP payload.
// Format: RLP([nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0])
func (r *Relay) buildRawTx(calldata []byte, nonce uint64, gasPrice *big.Int) []byte {
addrHex := r.teleporterAddr
if len(addrHex) >= 2 && addrHex[:2] == "0x" {
addrHex = addrHex[2:]
}
to, _ := hex.DecodeString(addrHex)
gasLimit := uint64(1_000_000)
return rlpEncodeTx(nonce, gasPrice, gasLimit, to, big.NewInt(0), calldata, r.chainID)
}
// rlpEncodeTx produces EIP-155 unsigned tx: RLP([nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0])
func rlpEncodeTx(nonce uint64, gasPrice *big.Int, gasLimit uint64, to []byte, value *big.Int, data []byte, chainID uint64) []byte {
items := [][]byte{
rlpEncodeUint(nonce),
rlpEncodeBigInt(gasPrice),
rlpEncodeUint(gasLimit),
rlpEncodeBytes(to),
rlpEncodeBigInt(value),
rlpEncodeBytes(data),
rlpEncodeUint(chainID),
rlpEncodeBytes(nil), // 0
rlpEncodeBytes(nil), // 0
}
return rlpEncodeList(items)
}
// rlpEncodeUint encodes a uint64 as RLP.
func rlpEncodeUint(v uint64) []byte {
if v == 0 {
return []byte{0x80}
}
b := new(big.Int).SetUint64(v).Bytes()
return rlpEncodeBytes(b)
}
// rlpEncodeBigInt encodes a *big.Int as RLP.
func rlpEncodeBigInt(v *big.Int) []byte {
if v == nil || v.Sign() == 0 {
return []byte{0x80}
}
return rlpEncodeBytes(v.Bytes())
}
// rlpEncodeBytes encodes a byte slice as RLP string.
func rlpEncodeBytes(b []byte) []byte {
if len(b) == 0 {
return []byte{0x80}
}
if len(b) == 1 && b[0] < 0x80 {
return b
}
if len(b) < 56 {
return append([]byte{byte(0x80 + len(b))}, b...)
}
lenBytes := new(big.Int).SetUint64(uint64(len(b))).Bytes()
header := append([]byte{byte(0xb7 + len(lenBytes))}, lenBytes...)
return append(header, b...)
}
// rlpEncodeList encodes a list of already-encoded RLP items.
func rlpEncodeList(items [][]byte) []byte {
var payload []byte
for _, item := range items {
payload = append(payload, item...)
}
if len(payload) < 56 {
return append([]byte{byte(0xc0 + len(payload))}, payload...)
}
lenBytes := new(big.Int).SetUint64(uint64(len(payload))).Bytes()
header := append([]byte{byte(0xf7 + len(lenBytes))}, lenBytes...)
return append(header, payload...)
}
// ethCall makes a JSON-RPC call to the Lux C-Chain.
func (r *Relay) ethCall(ctx context.Context, method string, params interface{}) (json.RawMessage, error) {
reqBody := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
}
body, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.rpcURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var rpcResp struct {
Result json.RawMessage `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
return nil, fmt.Errorf("rpc decode: %w", err)
}
if rpcResp.Error != nil {
return nil, fmt.Errorf("rpc error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message)
}
return rpcResp.Result, nil
}
// funcSelector returns the first 4 bytes of keccak256(signature).
func funcSelector(sig string) [4]byte {
h := crypto.Keccak256([]byte(sig))
var sel [4]byte
copy(sel[:], h[:4])
return sel
}
// leftPad pads b on the left with zeros to size n.
func leftPad(b []byte, n int) []byte {
if len(b) >= n {
return b
}
padded := make([]byte, n)
copy(padded[n-len(b):], b)
return padded
}
// rightPad pads b on the right with zeros to a multiple of align.
func rightPad(b []byte, align int) []byte {
if len(b)%align == 0 {
return b
}
padded := make([]byte, len(b)+(align-len(b)%align))
copy(padded, b)
return padded
}
+356
View File
@@ -0,0 +1,356 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package main
import (
"context"
"crypto/ecdsa"
"fmt"
"math/big"
"github.com/luxfi/crypto"
"github.com/luxfi/crypto/secp256k1"
"github.com/luxfi/crypto/threshold"
// Register threshold schemes at init time.
_ "github.com/luxfi/crypto/cggmp21"
_ "github.com/luxfi/crypto/threshold/bls"
)
// Signer produces threshold ECDSA signatures over Teleporter-compatible proof hashes.
//
// Uses CGGMP21 (threshold ECDSA on secp256k1) for Lux C-Chain compatibility,
// since Teleporter.sol verifies via ECDSA.recover(). The produced signatures
// are indistinguishable from single-party ECDSA.
//
// For Bitcoin/OP_NET Taproot operations, FROST (Schnorr threshold) should be
// used instead — see threshold.SchemeFROST.
//
// Supported schemes via github.com/luxfi/crypto/threshold:
// - SchemeCMP (CGGMP21): threshold ECDSA on secp256k1 — for EVM chains
// - SchemeFROST: threshold Schnorr — for Bitcoin Taproot
// - SchemeBLS: BLS12-381 threshold — for Lux Warp internal
// - SchemeCorona: lattice-based — post-quantum future
type Signer struct {
// Single-key mode (development/testing).
key *ecdsa.PrivateKey
// Threshold mode (production).
scheme threshold.Scheme
signer threshold.Signer
aggregator threshold.Aggregator
groupKey threshold.PublicKey
}
// NewSigner creates a single-key signer for development.
// In production, use NewThresholdSigner with CGGMP21 key shares.
func NewSigner(keyBytes []byte) (*Signer, error) {
if len(keyBytes) != 32 {
return nil, fmt.Errorf("signer: key must be 32 bytes, got %d", len(keyBytes))
}
key, err := crypto.ToECDSA(keyBytes)
if err != nil {
return nil, fmt.Errorf("signer: invalid key: %w", err)
}
return &Signer{key: key}, nil
}
// NewThresholdSigner creates a threshold signer using the given scheme and key share.
// For Teleporter compatibility (ECDSA.recover), use threshold.SchemeCMP.
func NewThresholdSigner(schemeID threshold.SchemeID, shareBytes []byte, groupKeyBytes []byte) (*Signer, error) {
scheme, err := threshold.GetScheme(schemeID)
if err != nil {
return nil, fmt.Errorf("signer: get scheme %s: %w", schemeID, err)
}
share, err := scheme.ParseKeyShare(shareBytes)
if err != nil {
return nil, fmt.Errorf("signer: parse key share: %w", err)
}
groupKey, err := scheme.ParsePublicKey(groupKeyBytes)
if err != nil {
return nil, fmt.Errorf("signer: parse group key: %w", err)
}
signer, err := scheme.NewSigner(share)
if err != nil {
return nil, fmt.Errorf("signer: create signer: %w", err)
}
aggregator, err := scheme.NewAggregator(groupKey)
if err != nil {
return nil, fmt.Errorf("signer: create aggregator: %w", err)
}
return &Signer{
scheme: scheme,
signer: signer,
aggregator: aggregator,
groupKey: groupKey,
}, nil
}
// Address returns the Ethereum-style address derived from the signer's public key.
func (s *Signer) Address() [20]byte {
if s.key != nil {
addr := secp256k1.PubkeyToAddress(s.key.PublicKey)
return addr
}
// Threshold mode: derive from group public key.
// CGGMP21 group key is a standard secp256k1 public key.
hash := crypto.Keccak256(s.groupKey.Bytes())
var addr [20]byte
copy(addr[:], hash[12:])
return addr
}
// IsThreshold returns true if this signer uses threshold signing.
func (s *Signer) IsThreshold() bool {
return s.scheme != nil
}
// SignRaw signs a raw hash. In single-key mode, uses the local key.
// In threshold mode, uses the threshold signing protocol.
// Returns a 65-byte [R || S || V] signature with V = 27 or 28.
func (s *Signer) SignRaw(hash []byte) ([]byte, error) {
if s.key != nil {
return s.signSingleKey(hash)
}
return s.signThreshold(hash)
}
// SignTransaction signs a raw EIP-155 transaction and returns the RLP-encoded signed tx.
// CRITICAL-03: All transactions are signed locally, never via eth_sendTransaction.
// NEW-02: Accepts an explicit tx-signing key so threshold signers can still submit txs.
func (s *Signer) SignTransaction(unsignedRLP []byte, chainID uint64) ([]byte, error) {
if s.key == nil {
return nil, fmt.Errorf("signer: transaction signing requires single-key mode (use Relay.txKey for threshold)")
}
txHash := crypto.Keccak256(unsignedRLP)
sig, err := crypto.Sign(txHash, s.key)
if err != nil {
return nil, fmt.Errorf("signer: sign tx: %w", err)
}
// EIP-155: v = chainId * 2 + 35 + recovery_id
v := chainID*2 + 35 + uint64(sig[64])
r := new(big.Int).SetBytes(sig[:32])
rr := new(big.Int).SetBytes(sig[32:64])
// Re-encode as signed tx: RLP([nonce, gasPrice, gasLimit, to, value, data, v, r, s])
// We need to decode the unsigned tx, replace the last 3 items, and re-encode.
// The unsigned RLP has [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]
// We replace chainId->v, 0->r, 0->s.
return replaceEIP155Sig(unsignedRLP, v, r, rr)
}
// replaceEIP155Sig takes unsigned EIP-155 RLP and replaces the trailing (chainId, 0, 0) with (v, r, s).
func replaceEIP155Sig(unsignedRLP []byte, v uint64, r, ss *big.Int) ([]byte, error) {
// Decode the RLP list to get the first 6 fields, then append v, r, s.
items, err := rlpDecodeList(unsignedRLP)
if err != nil {
return nil, fmt.Errorf("decode unsigned tx: %w", err)
}
if len(items) < 9 {
return nil, fmt.Errorf("unsigned tx has %d fields, expected 9", len(items))
}
// Keep first 6 items (nonce, gasPrice, gasLimit, to, value, data), replace last 3.
signed := make([][]byte, 9)
copy(signed[:6], items[:6])
signed[6] = rlpEncodeUint(v)
signed[7] = rlpEncodeBytes(r.Bytes())
signed[8] = rlpEncodeBytes(ss.Bytes())
return rlpEncodeList(signed), nil
}
// rlpDecodeList decodes a top-level RLP list and returns the raw RLP-encoded items.
func rlpDecodeList(data []byte) ([][]byte, error) {
if len(data) == 0 {
return nil, fmt.Errorf("empty RLP")
}
prefix := data[0]
var payload []byte
if prefix >= 0xc0 && prefix <= 0xf7 {
length := int(prefix - 0xc0)
payload = data[1 : 1+length]
} else if prefix >= 0xf8 {
lenLen := int(prefix - 0xf7)
length := new(big.Int).SetBytes(data[1 : 1+lenLen]).Uint64()
payload = data[1+lenLen : 1+lenLen+int(length)]
} else {
return nil, fmt.Errorf("not an RLP list: prefix 0x%x", prefix)
}
var items [][]byte
for len(payload) > 0 {
itemLen := rlpItemLen(payload)
items = append(items, payload[:itemLen])
payload = payload[itemLen:]
}
return items, nil
}
// rlpItemLen returns the total encoded length of the next RLP item.
func rlpItemLen(data []byte) int {
if len(data) == 0 {
return 0
}
prefix := data[0]
switch {
case prefix < 0x80:
return 1
case prefix <= 0xb7:
return 1 + int(prefix-0x80)
case prefix <= 0xbf:
lenLen := int(prefix - 0xb7)
length := new(big.Int).SetBytes(data[1 : 1+lenLen]).Uint64()
return 1 + lenLen + int(length)
case prefix <= 0xf7:
return 1 + int(prefix-0xc0)
default:
lenLen := int(prefix - 0xf7)
length := new(big.Int).SetBytes(data[1 : 1+lenLen]).Uint64()
return 1 + lenLen + int(length)
}
}
// SignDeposit signs a deposit proof matching Teleporter.mintDeposit:
//
// keccak256(abi.encode(bytes32("DEPOSIT"), srcChainId, depositNonce, recipient, amount))
//
// Wrapped with EIP-191 prefix before signing.
func (s *Signer) SignDeposit(srcChainID, nonce uint64, recipient [20]byte, amount *big.Int) ([]byte, error) {
msg := packDeposit(srcChainID, nonce, recipient, amount)
return s.signEthMessage(msg)
}
// SignBacking signs a backing attestation matching Teleporter.updateBacking:
//
// keccak256(abi.encode(bytes32("BACKING"), srcChainId, totalBacking, timestamp))
func (s *Signer) SignBacking(srcChainID uint64, totalBacking *big.Int, timestamp uint64) ([]byte, error) {
msg := packBacking(srcChainID, totalBacking, timestamp)
return s.signEthMessage(msg)
}
// signEthMessage computes keccak256(data), wraps with EIP-191, signs.
func (s *Signer) signEthMessage(data []byte) ([]byte, error) {
msgHash := crypto.Keccak256(data)
prefix := []byte("\x19Ethereum Signed Message:\n32")
ethHash := crypto.Keccak256(append(prefix, msgHash...))
if s.key != nil {
return s.signSingleKey(ethHash)
}
return s.signThreshold(ethHash)
}
// signSingleKey signs with a single ECDSA key (development mode).
func (s *Signer) signSingleKey(hash []byte) ([]byte, error) {
sig, err := crypto.Sign(hash, s.key)
if err != nil {
return nil, fmt.Errorf("signer: sign: %w", err)
}
// crypto.Sign returns [R || S || V] with V in {0,1}
// Teleporter expects V = 27 or 28
sig[64] += 27
return sig, nil
}
// signThreshold signs via CGGMP21 threshold protocol.
// Each watcher produces a share; shares are aggregated into a standard ECDSA sig.
func (s *Signer) signThreshold(hash []byte) ([]byte, error) {
ctx := context.Background()
// Generate nonce commitment (round 1 of signing).
commitment, nonceState, err := s.signer.NonceGen(ctx)
if err != nil {
return nil, fmt.Errorf("signer: nonce gen: %w", err)
}
// Create our signature share (round 2).
// In production, signers list comes from ZAP consensus on which watchers participate.
signers := []int{s.signer.Index()}
share, err := s.signer.SignShare(ctx, hash, signers, nonceState)
if err != nil {
return nil, fmt.Errorf("signer: sign share: %w", err)
}
// In production: broadcast share + commitment via ZAP, collect t shares,
// then aggregate. For now, return the share for external aggregation.
//
// When running with multiple watchers:
// 1. Each watcher calls SignShare() and broadcasts via ZAP
// 2. Coordinator collects t+1 shares
// 3. Coordinator calls Aggregate() to produce final sig
// 4. Final sig is standard 65-byte [R || S || V] ECDSA
_ = commitment // broadcast this via ZAP to other watchers
// Single-watcher fallback: aggregate with just our share.
// This only works in threshold=1 mode for testing.
sig, err := s.aggregator.Aggregate(ctx, hash, []threshold.SignatureShare{share}, []threshold.NonceCommitment{commitment})
if err != nil {
return nil, fmt.Errorf("signer: aggregate: %w", err)
}
result := sig.Bytes()
if len(result) == 65 && result[64] < 27 {
result[64] += 27 // Normalize V for EIP-155
}
return result, nil
}
// packDeposit encodes matching Solidity abi.encode(bytes32("DEPOSIT"), srcChainId, nonce, recipient, amount).
// NEW-01: bytes32 tags are fixed-size — simply right-padded to 32 bytes.
func packDeposit(srcChainID, nonce uint64, recipient [20]byte, amount *big.Int) []byte {
var buf []byte
buf = append(buf, abiEncodeBytes32Tag("DEPOSIT")...)
buf = append(buf, uint256Bytes(new(big.Int).SetUint64(srcChainID))...)
buf = append(buf, uint256Bytes(new(big.Int).SetUint64(nonce))...)
buf = append(buf, leftPad(recipient[:], 32)...) // abi.encode pads address to 32 bytes
buf = append(buf, uint256Bytes(amount)...)
return buf
}
// packYield encodes matching Solidity abi.encode(bytes32("YIELD"), srcChainId, yieldNonce, amount).
func packYield(srcChainID, nonce uint64, amount *big.Int) []byte {
var buf []byte
buf = append(buf, abiEncodeBytes32Tag("YIELD")...)
buf = append(buf, uint256Bytes(new(big.Int).SetUint64(srcChainID))...)
buf = append(buf, uint256Bytes(new(big.Int).SetUint64(nonce))...)
buf = append(buf, uint256Bytes(amount)...)
return buf
}
// packBacking encodes matching Solidity abi.encode(bytes32("BACKING"), srcChainId, totalBacking, timestamp).
func packBacking(srcChainID uint64, totalBacking *big.Int, timestamp uint64) []byte {
var buf []byte
buf = append(buf, abiEncodeBytes32Tag("BACKING")...)
buf = append(buf, uint256Bytes(new(big.Int).SetUint64(srcChainID))...)
buf = append(buf, uint256Bytes(totalBacking)...)
buf = append(buf, uint256Bytes(new(big.Int).SetUint64(timestamp))...)
return buf
}
// abiEncodeBytes32Tag encodes a short ASCII tag as Solidity bytes32("TAG") does:
// right-padded with zeros to exactly 32 bytes. Matches abi.encode(bytes32(...)).
func abiEncodeBytes32Tag(tag string) []byte {
buf := make([]byte, 32)
copy(buf, []byte(tag))
return buf
}
// uint256Bytes returns a 32-byte big-endian representation of n.
func uint256Bytes(n *big.Int) []byte {
b := make([]byte, 32)
if n == nil {
return b
}
raw := n.Bytes()
copy(b[32-len(raw):], raw)
return b
}
+92
View File
@@ -0,0 +1,92 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package main
import (
"encoding/hex"
"math/big"
"testing"
)
func TestAbiEncodeBytes32Tag(t *testing.T) {
got := abiEncodeBytes32Tag("DEPOSIT")
// Solidity: bytes32("DEPOSIT") = 0x4445504f534954000000000000000000000000000000000000000000000000000
// Note: the expected hex in the task description has 65 hex chars (trailing 0).
// Correct bytes32 is exactly 32 bytes = 64 hex chars.
want := "4445504f534954000000000000000000000000000000000000000000000000000"
// Normalize: the expected value has an extra trailing 0 (odd length). Use the
// canonical 64-char representation.
wantCanonical := "4445504f53495400000000000000000000000000000000000000000000000000"
gotHex := hex.EncodeToString(got)
if gotHex != wantCanonical {
t.Fatalf("abiEncodeBytes32Tag(\"DEPOSIT\")\n got: 0x%s\n want: 0x%s", gotHex, wantCanonical)
}
// Also verify the task-provided vector (just trim trailing 0 if odd).
if len(want)%2 != 0 {
want = want[:len(want)-1]
}
if gotHex != want {
t.Fatalf("mismatch with task vector: got %s, want %s", gotHex, want)
}
}
func TestPackDepositKnownVector(t *testing.T) {
// Known inputs:
// tag: bytes32("DEPOSIT")
// srcChainID: 1
// nonce: 42
// recipient: 0x000000000000000000000000DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF
// amount: 1000000000000000000 (1e18)
var recipient [20]byte
hexDecode(t, recipient[:], "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF")
amount := new(big.Int).SetUint64(1_000_000_000_000_000_000)
packed := packDeposit(1, 42, recipient, amount)
// Verify structure: 5 * 32 = 160 bytes
if len(packed) != 160 {
t.Fatalf("packDeposit length: got %d, want 160", len(packed))
}
// Verify first 32 bytes = bytes32("DEPOSIT")
tagHex := hex.EncodeToString(packed[:32])
wantTag := "4445504f53495400000000000000000000000000000000000000000000000000"
if tagHex != wantTag {
t.Fatalf("deposit tag mismatch:\n got: %s\n want: %s", tagHex, wantTag)
}
// Verify srcChainID = 1 (uint256 big-endian)
chainID := new(big.Int).SetBytes(packed[32:64])
if chainID.Uint64() != 1 {
t.Fatalf("srcChainID: got %d, want 1", chainID.Uint64())
}
// Verify nonce = 42
nonce := new(big.Int).SetBytes(packed[64:96])
if nonce.Uint64() != 42 {
t.Fatalf("nonce: got %d, want 42", nonce.Uint64())
}
// Verify recipient (left-padded to 32 bytes)
recipientHex := hex.EncodeToString(packed[96:128])
wantRecipient := "000000000000000000000000deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
if recipientHex != wantRecipient {
t.Fatalf("recipient mismatch:\n got: %s\n want: %s", recipientHex, wantRecipient)
}
// Verify amount = 1e18
gotAmount := new(big.Int).SetBytes(packed[128:160])
if gotAmount.Cmp(amount) != 0 {
t.Fatalf("amount: got %s, want %s", gotAmount, amount)
}
}
func hexDecode(t *testing.T, dst []byte, src string) {
t.Helper()
b, err := hex.DecodeString(src)
if err != nil {
t.Fatal(err)
}
copy(dst, b)
}
+244
View File
@@ -0,0 +1,244 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"gopkg.in/yaml.v3"
)
// Tenant represents a white-label bridge deployment loaded from a manifest.
type Tenant struct {
ID string `yaml:"id" json:"id"`
Name string `yaml:"name" json:"name"`
Hostnames []string `yaml:"hostnames" json:"hostnames"`
HomeChainID uint64 `yaml:"home_chain_id" json:"home_chain_id"`
SupportedChains []string `yaml:"supported_chains" json:"supported_chains"`
TeleporterAddresses map[string]string `yaml:"teleporter_addresses" json:"teleporter_addresses"`
Branding Branding `yaml:"branding" json:"branding"`
Features Features `yaml:"features" json:"features"`
}
// Branding holds UI theme configuration for a tenant.
type Branding struct {
LogoURL string `yaml:"logo_url" json:"logo_url"`
PrimaryColor string `yaml:"primary_color" json:"primary_color"`
Theme string `yaml:"theme" json:"theme"`
}
// Features holds feature flags for a tenant.
type Features struct {
Swap bool `yaml:"swap" json:"swap"`
Yield bool `yaml:"yield" json:"yield"`
Perps bool `yaml:"perps" json:"perps"`
}
// ChainManifest is a minimal representation of a chain manifest for the API.
// Full schema is in manifests/chains/schema.yaml; we only load what the API needs.
type ChainManifest struct {
ChainID uint64 `yaml:"chain_id" json:"chain_id"`
Name string `yaml:"name" json:"name"`
Class string `yaml:"class" json:"class"`
Tier string `yaml:"tier" json:"tier"`
Assets []AssetManifest `yaml:"assets" json:"assets"`
Finality struct {
Mode string `yaml:"mode" json:"mode"`
Confirmations int `yaml:"confirmations" json:"confirmations"`
} `yaml:"finality" json:"finality"`
}
// AssetManifest is the asset portion of a chain manifest.
type AssetManifest struct {
Ticker string `yaml:"ticker" json:"ticker"`
Decimals int `yaml:"decimals" json:"decimals"`
RecipientEncoding string `yaml:"recipient_encoding" json:"recipient_encoding"`
Wrapper struct {
Symbol string `yaml:"symbol" json:"symbol"`
Contract string `yaml:"contract" json:"contract"`
Decimals int `yaml:"decimals" json:"decimals"`
} `yaml:"wrapper" json:"wrapper"`
}
// TenantRegistry loads and indexes tenant and chain manifests.
// Thread-safe after Load completes.
type TenantRegistry struct {
mu sync.RWMutex
tenants map[string]*Tenant // id -> Tenant
byHost map[string]*Tenant // hostname -> Tenant
chains map[string]*ChainManifest // manifest filename (no ext) -> ChainManifest
chainDir string
}
// NewTenantRegistry creates an empty registry.
func NewTenantRegistry(chainDir string) *TenantRegistry {
return &TenantRegistry{
tenants: make(map[string]*Tenant),
byHost: make(map[string]*Tenant),
chains: make(map[string]*ChainManifest),
chainDir: chainDir,
}
}
// Load reads all tenant manifests from tenantDir and all chain manifests from chainDir.
func (r *TenantRegistry) Load(tenantDir string) error {
r.mu.Lock()
defer r.mu.Unlock()
// Load chain manifests.
chainFiles, err := filepath.Glob(filepath.Join(r.chainDir, "*.yaml"))
if err != nil {
return fmt.Errorf("glob chains: %w", err)
}
for _, path := range chainFiles {
var cm ChainManifest
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read chain %s: %w", path, err)
}
if err := yaml.Unmarshal(data, &cm); err != nil {
return fmt.Errorf("parse chain %s: %w", path, err)
}
key := strings.TrimSuffix(filepath.Base(path), ".yaml")
r.chains[key] = &cm
}
// Load tenant manifests.
tenantFiles, err := filepath.Glob(filepath.Join(tenantDir, "*.yaml"))
if err != nil {
return fmt.Errorf("glob tenants: %w", err)
}
for _, path := range tenantFiles {
base := filepath.Base(path)
if base == "schema.yaml" {
continue
}
var t Tenant
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read tenant %s: %w", path, err)
}
if err := yaml.Unmarshal(data, &t); err != nil {
return fmt.Errorf("parse tenant %s: %w", path, err)
}
if err := r.validateTenant(&t); err != nil {
return fmt.Errorf("tenant %s: %w", t.ID, err)
}
r.tenants[t.ID] = &t
for _, h := range t.Hostnames {
r.byHost[strings.ToLower(h)] = &t
}
}
if len(r.tenants) == 0 {
return fmt.Errorf("no tenant manifests found in %s", tenantDir)
}
return nil
}
// validateTenant checks that a tenant references only known chain manifests.
func (r *TenantRegistry) validateTenant(t *Tenant) error {
if t.ID == "" {
return fmt.Errorf("id is required")
}
if len(t.Hostnames) == 0 {
return fmt.Errorf("at least one hostname is required")
}
if len(t.SupportedChains) == 0 {
return fmt.Errorf("at least one supported chain is required")
}
for _, ref := range t.SupportedChains {
if _, ok := r.chains[ref]; !ok {
return fmt.Errorf("references unknown chain manifest %q", ref)
}
}
return nil
}
// TenantForHostname resolves a hostname to a tenant.
// The hostname is normalized to lowercase and port is stripped.
func (r *TenantRegistry) TenantForHostname(hostname string) (*Tenant, error) {
r.mu.RLock()
defer r.mu.RUnlock()
// Strip port if present (e.g. "bridge.lux.network:443").
if idx := strings.IndexByte(hostname, ':'); idx != -1 {
hostname = hostname[:idx]
}
hostname = strings.ToLower(hostname)
t, ok := r.byHost[hostname]
if !ok {
return nil, fmt.Errorf("no tenant for hostname %q", hostname)
}
return t, nil
}
// ChainsForTenant returns the chain manifests for a tenant's supported chains.
func (r *TenantRegistry) ChainsForTenant(tenantID string) ([]*ChainManifest, error) {
r.mu.RLock()
defer r.mu.RUnlock()
t, ok := r.tenants[tenantID]
if !ok {
return nil, fmt.Errorf("unknown tenant %q", tenantID)
}
out := make([]*ChainManifest, 0, len(t.SupportedChains))
for _, ref := range t.SupportedChains {
cm, ok := r.chains[ref]
if !ok {
return nil, fmt.Errorf("tenant %s references missing chain %q", tenantID, ref)
}
out = append(out, cm)
}
return out, nil
}
// AssetsForTenant returns all assets across all supported chains for a tenant.
func (r *TenantRegistry) AssetsForTenant(tenantID string) ([]TenantAsset, error) {
chains, err := r.ChainsForTenant(tenantID)
if err != nil {
return nil, err
}
var assets []TenantAsset
for _, cm := range chains {
for _, a := range cm.Assets {
assets = append(assets, TenantAsset{
ChainID: cm.ChainID,
ChainName: cm.Name,
Ticker: a.Ticker,
Decimals: a.Decimals,
Wrapper: a.Wrapper.Symbol,
})
}
}
return assets, nil
}
// TenantAsset is a flattened asset view for the API.
type TenantAsset struct {
ChainID uint64 `json:"chain_id"`
ChainName string `json:"chain_name"`
Ticker string `json:"ticker"`
Decimals int `json:"decimals"`
Wrapper string `json:"wrapper"`
}
// AllTenants returns all loaded tenants. Used for health/debug.
func (r *TenantRegistry) AllTenants() []*Tenant {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]*Tenant, 0, len(r.tenants))
for _, t := range r.tenants {
out = append(out, t)
}
return out
}
+271
View File
@@ -0,0 +1,271 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package main
import (
"os"
"path/filepath"
"testing"
)
func setupTestManifests(t *testing.T) (tenantDir, chainDir string) {
t.Helper()
base := t.TempDir()
chainDir = filepath.Join(base, "chains")
tenantDir = filepath.Join(base, "tenants")
if err := os.MkdirAll(chainDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(tenantDir, 0o755); err != nil {
t.Fatal(err)
}
// Write chain manifests.
chains := map[string]string{
"lux-c.yaml": `
chain_id: 96369
name: "Lux C-Chain"
class: lux_native
tier: yield_enabled
assets:
- ticker: LUX
decimals: 18
recipient_encoding: evm_address
wrapper:
symbol: LUX
contract: "token/LUX.sol"
decimals: 18
finality:
mode: instant
confirmations: 1
`,
"ethereum.yaml": `
chain_id: 1
name: "Ethereum"
class: evm
tier: yield_enabled
assets:
- ticker: ETH
decimals: 18
recipient_encoding: evm_address
wrapper:
symbol: LETH
contract: "liquid/tokens/LETH.sol"
decimals: 18
- ticker: USDC
decimals: 6
recipient_encoding: evm_address
wrapper:
symbol: LUSD
contract: "liquid/tokens/LUSD.sol"
decimals: 18
finality:
mode: slot_finalized
confirmations: 64
`,
}
for name, content := range chains {
if err := os.WriteFile(filepath.Join(chainDir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// Write tenant manifests.
tenants := map[string]string{
"lux.yaml": `
id: lux
name: "Lux Network"
hostnames: ["bridge.lux.network", "bridge.lux.io"]
home_chain_id: 96369
supported_chains: [lux-c, ethereum]
teleporter_addresses:
"96369": "kms://teleport/lux-c/teleporter"
"1": "kms://teleport/lux-c/teleporter-eth"
branding:
logo_url: "/static/lux/logo.svg"
primary_color: "#E84142"
theme: dark
features:
swap: true
yield: true
perps: true
`,
"zoo.yaml": `
id: zoo
name: "Zoo Network"
hostnames: ["bridge.zoo.ngo"]
home_chain_id: 200200
supported_chains: [lux-c]
teleporter_addresses:
"96369": "kms://teleport/lux-c/teleporter"
branding:
logo_url: "/static/zoo/logo.svg"
primary_color: "#10B981"
theme: dark
features:
swap: true
yield: true
perps: false
`,
"schema.yaml": `
schema_version: "1.0.0"
`,
}
for name, content := range tenants {
if err := os.WriteFile(filepath.Join(tenantDir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
return tenantDir, chainDir
}
func TestTenantRegistryLoad(t *testing.T) {
tenantDir, chainDir := setupTestManifests(t)
r := NewTenantRegistry(chainDir)
if err := r.Load(tenantDir); err != nil {
t.Fatalf("Load: %v", err)
}
all := r.AllTenants()
if len(all) != 2 {
t.Fatalf("expected 2 tenants, got %d", len(all))
}
}
func TestTenantForHostname(t *testing.T) {
tenantDir, chainDir := setupTestManifests(t)
r := NewTenantRegistry(chainDir)
if err := r.Load(tenantDir); err != nil {
t.Fatal(err)
}
tests := []struct {
host string
wantID string
wantErr bool
}{
{"bridge.lux.network", "lux", false},
{"bridge.lux.io", "lux", false},
{"Bridge.Lux.Network", "lux", false}, // case insensitive
{"bridge.lux.network:443", "lux", false}, // port stripped
{"bridge.zoo.ngo", "zoo", false},
{"bridge.unknown.com", "", true},
}
for _, tt := range tests {
tenant, err := r.TenantForHostname(tt.host)
if tt.wantErr {
if err == nil {
t.Errorf("TenantForHostname(%q) expected error", tt.host)
}
continue
}
if err != nil {
t.Errorf("TenantForHostname(%q) error: %v", tt.host, err)
continue
}
if tenant.ID != tt.wantID {
t.Errorf("TenantForHostname(%q) = %q, want %q", tt.host, tenant.ID, tt.wantID)
}
}
}
func TestChainsForTenant(t *testing.T) {
tenantDir, chainDir := setupTestManifests(t)
r := NewTenantRegistry(chainDir)
if err := r.Load(tenantDir); err != nil {
t.Fatal(err)
}
chains, err := r.ChainsForTenant("lux")
if err != nil {
t.Fatalf("ChainsForTenant(lux): %v", err)
}
if len(chains) != 2 {
t.Fatalf("expected 2 chains for lux, got %d", len(chains))
}
chains, err = r.ChainsForTenant("zoo")
if err != nil {
t.Fatalf("ChainsForTenant(zoo): %v", err)
}
if len(chains) != 1 {
t.Fatalf("expected 1 chain for zoo, got %d", len(chains))
}
if chains[0].ChainID != 96369 {
t.Errorf("expected chain_id 96369, got %d", chains[0].ChainID)
}
_, err = r.ChainsForTenant("nonexistent")
if err == nil {
t.Error("ChainsForTenant(nonexistent) expected error")
}
}
func TestAssetsForTenant(t *testing.T) {
tenantDir, chainDir := setupTestManifests(t)
r := NewTenantRegistry(chainDir)
if err := r.Load(tenantDir); err != nil {
t.Fatal(err)
}
assets, err := r.AssetsForTenant("lux")
if err != nil {
t.Fatalf("AssetsForTenant(lux): %v", err)
}
// lux-c has 1 asset (LUX), ethereum has 2 (ETH, USDC)
if len(assets) != 3 {
t.Fatalf("expected 3 assets for lux, got %d", len(assets))
}
assets, err = r.AssetsForTenant("zoo")
if err != nil {
t.Fatalf("AssetsForTenant(zoo): %v", err)
}
if len(assets) != 1 {
t.Fatalf("expected 1 asset for zoo, got %d", len(assets))
}
if assets[0].Ticker != "LUX" {
t.Errorf("expected ticker LUX, got %s", assets[0].Ticker)
}
}
func TestValidateTenantBadChainRef(t *testing.T) {
base := t.TempDir()
chainDir := filepath.Join(base, "chains")
tenantDir := filepath.Join(base, "tenants")
os.MkdirAll(chainDir, 0o755)
os.MkdirAll(tenantDir, 0o755)
// Only lux-c chain exists.
os.WriteFile(filepath.Join(chainDir, "lux-c.yaml"), []byte(`
chain_id: 96369
name: "Lux C-Chain"
class: lux_native
tier: yield_enabled
assets: [{ticker: LUX, decimals: 18, recipient_encoding: evm_address, wrapper: {symbol: LUX, contract: "t.sol", decimals: 18}}]
finality: {mode: instant, confirmations: 1}
`), 0o644)
// Tenant references nonexistent chain.
os.WriteFile(filepath.Join(tenantDir, "bad.yaml"), []byte(`
id: bad
name: "Bad Tenant"
hostnames: ["bad.example.com"]
home_chain_id: 1
supported_chains: [lux-c, nonexistent]
teleporter_addresses: {"96369": "0xdead"}
branding: {logo_url: "/x", primary_color: "#000000", theme: dark}
features: {swap: false, yield: false, perps: false}
`), 0o644)
r := NewTenantRegistry(chainDir)
err := r.Load(tenantDir)
if err == nil {
t.Fatal("expected error for bad chain reference")
}
}
+185
View File
@@ -0,0 +1,185 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package main
import (
"context"
"log"
"math/big"
"time"
"github.com/luxfi/bridge/opnet-watcher/plugins"
)
// Watcher orchestrates the plugin -> signer -> relay pipeline.
// It runs the block polling loop and the backing attestor as separate goroutines,
// coordinating graceful shutdown via context cancellation.
type Watcher struct {
plugin plugins.ChainPlugin
signer *Signer
relay *Relay
cfg Config
lastPos uint64 // last processed block/slot/lt
lastBacking *big.Int // HIGH-03: last attested backing for sanity check
}
// NewWatcher assembles the full pipeline from config.
func NewWatcher(cfg Config, plugin plugins.ChainPlugin, signer *Signer, relay *Relay) *Watcher {
return &Watcher{
plugin: plugin,
signer: signer,
relay: relay,
cfg: cfg,
lastPos: cfg.StartBlock,
}
}
// Run starts the watcher. It blocks until ctx is cancelled.
func (w *Watcher) Run(ctx context.Context) error {
log.Printf("watcher: starting chain=%s chain_id=%d source=%s lux=%s",
w.plugin.Name(), w.plugin.ChainID(), w.cfg.SourceRPCURL, w.cfg.LuxRPCURL)
log.Printf("watcher: teleporter=%s bridge=%s poll=%s backing=%s depth=%d",
w.cfg.TeleporterAddr, w.cfg.SourceBridgeAddr, w.cfg.BlockPollInterval, w.cfg.BackingInterval, w.cfg.ConfirmationDepth)
// Start backing attestor in background.
go w.runBackingLoop(ctx)
// Main event loop: poll blocks, process events.
ticker := time.NewTicker(w.cfg.BlockPollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("watcher: shutting down")
return ctx.Err()
case <-ticker.C:
w.pollAndRelay(ctx)
}
}
}
// pollAndRelay fetches new deposit events from the plugin and relays them.
// CRITICAL-02: Only processes blocks with sufficient confirmations to prevent reorg-based drain.
func (w *Watcher) pollAndRelay(ctx context.Context) {
events, newPos, err := w.plugin.PollDeposits(ctx, w.lastPos)
if err != nil {
log.Printf("watcher: poll error: %v", err)
return
}
// CRITICAL-02: Only process blocks with confirmationDepth confirmations.
if newPos <= w.cfg.ConfirmationDepth {
return
}
confirmedPos := newPos - w.cfg.ConfirmationDepth
var confirmed []plugins.DepositEvent
for _, ev := range events {
if ev.BlockHeight <= confirmedPos {
confirmed = append(confirmed, ev)
}
}
for _, ev := range confirmed {
if err := w.processDeposit(ctx, ev); err != nil {
log.Printf("watcher: deposit error block=%d nonce=%d: %v", ev.BlockHeight, ev.Nonce, err)
}
}
if confirmedPos > w.lastPos {
if len(confirmed) > 0 {
log.Printf("watcher: processed %d deposits through pos=%d (tip=%d depth=%d)",
len(confirmed), confirmedPos, newPos, w.cfg.ConfirmationDepth)
}
w.lastPos = confirmedPos
}
}
// processDeposit signs a deposit proof and submits it to Teleporter.mintDeposit.
func (w *Watcher) processDeposit(ctx context.Context, ev plugins.DepositEvent) error {
log.Printf("watcher: deposit block=%d nonce=%d recipient=%x amount=%s",
ev.BlockHeight, ev.Nonce, ev.Recipient, ev.Amount.String())
chainID := w.plugin.ChainID()
sig, err := w.signer.SignDeposit(chainID, ev.Nonce, ev.Recipient, ev.Amount)
if err != nil {
return err
}
txHash, err := w.relay.SubmitDeposit(ctx, chainID, ev.Nonce, ev.Recipient, ev.Amount, sig)
if err != nil {
return err
}
log.Printf("watcher: deposit relayed nonce=%d tx=%s", ev.Nonce, txHash)
return nil
}
// runBackingLoop periodically queries total locked from the source chain
// and submits backing attestations to Teleporter.updateBacking.
func (w *Watcher) runBackingLoop(ctx context.Context) {
w.attestBacking(ctx)
ticker := time.NewTicker(w.cfg.BackingInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("backing: shutting down")
return
case <-ticker.C:
w.attestBacking(ctx)
}
}
}
func (w *Watcher) attestBacking(ctx context.Context) {
totalLocked, err := w.plugin.QueryBacking(ctx)
if err != nil {
log.Printf("backing: query failed: %v", err)
return
}
// HIGH-03: Sanity check — backing cannot change by more than MaxBackingChangePct per attestation.
if w.lastBacking != nil && w.lastBacking.Sign() > 0 {
maxChange := new(big.Int).Mul(w.lastBacking, new(big.Int).SetUint64(w.cfg.MaxBackingChangePct))
maxChange.Div(maxChange, big.NewInt(100))
diff := new(big.Int).Sub(totalLocked, w.lastBacking)
if diff.Sign() < 0 {
diff.Neg(diff)
}
if diff.Cmp(maxChange) > 0 {
log.Printf("backing: REJECTED — change too large: %s -> %s (max %d%% = %s)",
w.lastBacking.String(), totalLocked.String(), w.cfg.MaxBackingChangePct, maxChange.String())
return
}
// Log significant changes as warnings even within bounds.
tenPct := new(big.Int).Div(w.lastBacking, big.NewInt(10))
if diff.Cmp(tenPct) > 0 {
log.Printf("backing: WARNING — significant change: %s -> %s", w.lastBacking.String(), totalLocked.String())
}
}
chainID := w.plugin.ChainID()
timestamp := uint64(time.Now().Unix())
sig, err := w.signer.SignBacking(chainID, totalLocked, timestamp)
if err != nil {
log.Printf("backing: sign failed: %v", err)
return
}
// CRITICAL-01: Pass timestamp to SubmitBacking (signed by MPC, validated on-chain).
txHash, err := w.relay.SubmitBacking(ctx, chainID, totalLocked, timestamp, sig)
if err != nil {
log.Printf("backing: submit failed: %v", err)
return
}
w.lastBacking = totalLocked
log.Printf("backing: attested chain=%s total=%s tx=%s", w.plugin.Name(), totalLocked.String(), txHash)
}