mirror of
https://github.com/luxfi/node.git
synced 2026-07-29 00:26:40 +00:00
Compare commits
167
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e90dd9342c | ||
|
|
303242ea5a | ||
|
|
1d84e03e5b | ||
|
|
3631ff5dc2 | ||
|
|
28e9fe0032 | ||
|
|
6258af7af9 | ||
|
|
f0733b749c | ||
|
|
5c164e84c7 | ||
|
|
2220065985 | ||
|
|
e9ab022ca7 | ||
|
|
7003d69384 | ||
|
|
5b0eca3270 | ||
|
|
c0d9ccacde | ||
|
|
3919991f48 | ||
|
|
0c573e6aa7 | ||
|
|
711d2519c2 | ||
|
|
9323caa1fc | ||
|
|
ff9faa3ef3 | ||
|
|
cae6f18ffb | ||
|
|
d48292b9dc | ||
|
|
c926953b60 | ||
|
|
7184449585 | ||
|
|
8e5bfb68dc | ||
|
|
51d4bd9520 | ||
|
|
2e18d7da38 | ||
|
|
b4a3ecdd75 | ||
|
|
f6639e661b | ||
|
|
130927f056 | ||
|
|
530b428159 | ||
|
|
f86909a928 | ||
|
|
3fb51e1995 | ||
|
|
cbd10105be | ||
|
|
eb510e0ca4 | ||
|
|
04902cdca5 | ||
|
|
5cbb1e4431 | ||
|
|
bcbb141378 | ||
|
|
bcd6c6b46d | ||
|
|
91e91218df | ||
|
|
000a0c84ff | ||
|
|
d12c3457af | ||
|
|
63d602b8ca | ||
|
|
ef0c581714 | ||
|
|
f852526092 | ||
|
|
18f54f9eb0 | ||
|
|
964be2fad8 | ||
|
|
ca7cdb4a77 | ||
|
|
99eddf0827 | ||
|
|
742c35485f | ||
|
|
e94b025395 | ||
|
|
ee81e8ea8f | ||
|
|
5a92bff2cd | ||
|
|
0aef65bc5d | ||
|
|
6fdc4ddfa7 | ||
|
|
708268aa71 | ||
|
|
2f644a14bc | ||
|
|
573346c6b8 | ||
|
|
0e8856758a | ||
|
|
ba8a1fc1a7 | ||
|
|
6780c4fdee | ||
|
|
129dfd7b46 | ||
|
|
b6d1bdca7c | ||
|
|
ffb627a51f | ||
|
|
b2c2376678 | ||
|
|
8bcc23efdb | ||
|
|
2663090827 | ||
|
|
65b7d6a1ae | ||
|
|
d15be7c524 | ||
|
|
d812ada7df | ||
|
|
2abb88530c | ||
|
|
dedb7eb806 | ||
|
|
a03785d922 | ||
|
|
ea066101a6 | ||
|
|
69258c94b0 | ||
|
|
23bd9575ea | ||
|
|
fab47e2b93 | ||
|
|
e0125e315d | ||
|
|
e27d954097 | ||
|
|
75da501683 | ||
|
|
9239065fdc | ||
|
|
95610b4b83 | ||
|
|
7a5f31da30 | ||
|
|
8d2ffbd4c9 | ||
|
|
263115933e | ||
|
|
b691a0d07e | ||
|
|
8c874943f9 | ||
|
|
01c40f969e | ||
|
|
8884c17f54 | ||
|
|
87e2ac3615 | ||
|
|
b6eae71825 | ||
|
|
c44df7e15c | ||
|
|
92c430ee12 | ||
|
|
3be49b29ec | ||
|
|
0486947913 | ||
|
|
105fd207c0 | ||
|
|
e1550aaea6 | ||
|
|
25adc9c75c | ||
|
|
09dffe5430 | ||
|
|
df173e4263 | ||
|
|
b561269ef8 | ||
|
|
20506a5950 | ||
|
|
0ebfa14b6d | ||
|
|
f97f552e87 | ||
|
|
2bde2c707f | ||
|
|
913acca108 | ||
|
|
50b27dbf94 | ||
|
|
2f9ef85652 | ||
|
|
9c42fe1126 | ||
|
|
e42b295617 | ||
|
|
d7312ad8a9 | ||
|
|
c827de99a7 | ||
|
|
24aaa598c5 | ||
|
|
3d5466eef4 | ||
|
|
b5a2a8d3db | ||
|
|
b5a4fc3f1f | ||
|
|
90738f212a | ||
|
|
50b4002e51 | ||
|
|
15ab4dbd47 | ||
|
|
bdf3c5d00e | ||
|
|
dadaad8a22 | ||
|
|
eea02da21a | ||
|
|
476c6dd12a | ||
|
|
35d48b9d1c | ||
|
|
be24ff49f0 | ||
|
|
8b8280c1e7 | ||
|
|
e0ebfac9d3 | ||
|
|
abc8a0856f | ||
|
|
2c49007406 | ||
|
|
06a47b11b5 | ||
|
|
0af39d7d40 | ||
|
|
182401b3be | ||
|
|
6357802b7d | ||
|
|
4dd4910bf3 | ||
|
|
766e8b2010 | ||
|
|
77010b6652 | ||
|
|
9759c2e253 | ||
|
|
05b139aadc | ||
|
|
d86c155eb1 | ||
|
|
77fff17b01 | ||
|
|
0568d68b80 | ||
|
|
9a6b7d2455 | ||
|
|
61b3e380e0 | ||
|
|
9c9e6b9e28 | ||
|
|
4c1529da2a | ||
|
|
4a345fe8e3 | ||
|
|
102023f249 | ||
|
|
aee5923e68 | ||
|
|
ea32845a7d | ||
|
|
3bb951e59e | ||
|
|
517acef79b | ||
|
|
cc856c020c | ||
|
|
a0d1a2bc31 | ||
|
|
9c95d6473a | ||
|
|
6f0cdee116 | ||
|
|
2060761d42 | ||
|
|
a4208faf95 | ||
|
|
1d94973509 | ||
|
|
009f00ba91 | ||
|
|
c26319db84 | ||
|
|
fd40807831 | ||
|
|
9b397125c1 | ||
|
|
e9c2d81ff5 | ||
|
|
df8af35979 | ||
|
|
1113675482 | ||
|
|
131e6a588c | ||
|
|
c790abb494 | ||
|
|
fcde96701d | ||
|
|
6e76c846fb |
@@ -3,5 +3,3 @@ self-hosted-runner:
|
||||
- custom-arm64-focal
|
||||
- custom-arm64-jammy
|
||||
- hanzo-build-linux-amd64
|
||||
- hanzo-build-linux-arm64
|
||||
- ubuntu-24.04-arm
|
||||
|
||||
@@ -12,9 +12,9 @@ else
|
||||
MODULE_DETAILS="$(go list -m "github.com/luxfi/node" 2>/dev/null)"
|
||||
|
||||
# Extract the version part
|
||||
LUX_VERSION="$(echo "${MODULE_DETAILS}" | awk '{print $2}')"
|
||||
NODE_VERSION="$(echo "${MODULE_DETAILS}" | awk '{print $2}')"
|
||||
|
||||
if [[ -z "${LUX_VERSION}" ]]; then
|
||||
if [[ -z "${NODE_VERSION}" ]]; then
|
||||
echo "Failed to get luxd version from go.mod"
|
||||
exit 1
|
||||
fi
|
||||
@@ -23,12 +23,12 @@ else
|
||||
# v*YYYYMMDDHHMMSS-abcdef123456
|
||||
#
|
||||
# If not, the value is assumed to represent a tag
|
||||
if [[ "${LUX_VERSION}" =~ ^v.*[0-9]{14}-[0-9a-f]{12}$ ]]; then
|
||||
if [[ "${NODE_VERSION}" =~ ^v.*[0-9]{14}-[0-9a-f]{12}$ ]]; then
|
||||
# Use the module hash as the version
|
||||
LUX_VERSION="$(echo "${LUX_VERSION}" | cut -d'-' -f3)"
|
||||
NODE_VERSION="$(echo "${NODE_VERSION}" | cut -d'-' -f3)"
|
||||
fi
|
||||
|
||||
FLAKE="github:luxfi/node?ref=${LUX_VERSION}"
|
||||
FLAKE="github:luxfi/node?ref=${NODE_VERSION}"
|
||||
echo "Starting nix shell for ${FLAKE}"
|
||||
fi
|
||||
|
||||
|
||||
@@ -9,6 +9,6 @@ set -euo pipefail
|
||||
# when go is already installed.
|
||||
|
||||
# 3 directories above this script
|
||||
LUX_PATH=$( cd "$( dirname "${BASH_SOURCE[0]}" )"; cd ../../.. && pwd )
|
||||
NODE_PATH=$( cd "$( dirname "${BASH_SOURCE[0]}" )"; cd ../../.. && pwd )
|
||||
|
||||
echo GO_VERSION="~$(sed -n -e 's/^go //p' "${LUX_PATH}"/go.mod)"
|
||||
echo GO_VERSION="~$(sed -n -e 's/^go //p' "${NODE_PATH}"/go.mod)"
|
||||
|
||||
@@ -67,9 +67,6 @@
|
||||
description: "This involves consensus"
|
||||
- name: "continuous staking"
|
||||
color: "f9d0c4"
|
||||
- name: "Durango"
|
||||
color: "DAF894"
|
||||
description: "durango fork"
|
||||
- name: "gossiping upgrade"
|
||||
color: "c2e0c6"
|
||||
- name: "merkledb"
|
||||
|
||||
@@ -2,6 +2,9 @@ name: Lint proto files
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'proto/**/*.proto'
|
||||
- '.github/workflows/buf-lint.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -11,10 +14,21 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check for .proto files
|
||||
id: proto-check
|
||||
run: |
|
||||
if find proto -name '*.proto' -type f 2>/dev/null | grep -q .; then
|
||||
echo "has_proto=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_proto=false" >> $GITHUB_OUTPUT
|
||||
echo "No .proto files found — skipping buf-lint (node is ZAP-native by default; protobuf is opt-in)."
|
||||
fi
|
||||
- uses: bufbuild/buf-setup-action@v1
|
||||
if: steps.proto-check.outputs.has_proto == 'true'
|
||||
with:
|
||||
github_token: ${{ github.token }}
|
||||
version: "1.47.2"
|
||||
- uses: bufbuild/buf-lint-action@v1
|
||||
if: steps.proto-check.outputs.has_proto == 'true'
|
||||
with:
|
||||
input: "proto"
|
||||
|
||||
@@ -20,7 +20,10 @@ jobs:
|
||||
GOWORK: off
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-latest, macos-latest]
|
||||
# `macos-latest` currently resolves to GH-hosted arm64 macos (forbidden);
|
||||
# pin to `macos-13` (amd64). darwin/arm64 coverage lives in
|
||||
# build-macos-release.yml via cross-compile.
|
||||
os: [windows-latest, macos-13]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup-go-for-project
|
||||
|
||||
@@ -18,7 +18,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build-x86_64-binaries-tarball:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
runs-on: lux-build
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
rm -rf ${{ github.workspace }}/luxd-pkg
|
||||
|
||||
build-arm64-binaries-tarball:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: lux-build
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
@@ -80,8 +80,8 @@ jobs:
|
||||
|
||||
- run: go version
|
||||
|
||||
- name: Build the luxd binaries (native arm64)
|
||||
run: CGO_ENABLED=0 ./scripts/build.sh
|
||||
- name: Build the luxd binaries (cross-compile arm64 on amd64)
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 ./scripts/build.sh
|
||||
|
||||
- name: Try to get tag from git
|
||||
if: "${{ github.event.inputs.tag == '' }}"
|
||||
|
||||
@@ -22,9 +22,14 @@ on:
|
||||
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
|
||||
jobs:
|
||||
# This workflow contains a single job called "build"
|
||||
# Cross-compile both darwin/amd64 and darwin/arm64 on macos-13.
|
||||
build-mac:
|
||||
# The type of runner that the job will run on
|
||||
runs-on: macos-14
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
goarch: [amd64, arm64]
|
||||
# The type of runner that the job will run on (GH-hosted arm64 macos forbidden).
|
||||
runs-on: macos-13
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
@@ -37,8 +42,8 @@ jobs:
|
||||
- run: go version
|
||||
|
||||
# Runs a single command using the runners shell
|
||||
- name: Build the luxd binary
|
||||
run: CGO_ENABLED=0 ./scripts/run_task.sh build
|
||||
- name: Build the luxd binary (cross-compile via GOOS/GOARCH)
|
||||
run: CGO_ENABLED=0 GOOS=darwin GOARCH=${{ matrix.goarch }} ./scripts/run_task.sh build
|
||||
|
||||
- name: Try to get tag from git
|
||||
if: "${{ github.event.inputs.tag == '' }}"
|
||||
@@ -56,17 +61,17 @@ jobs:
|
||||
|
||||
- name: Create zip file with CLI-compatible naming
|
||||
run: |
|
||||
# CLI expects: node-macos-{version}.zip containing build/luxd
|
||||
# CLI expects: node-macos-{arch}-{version}.zip containing build/luxd
|
||||
mkdir -p build
|
||||
7z a "node-macos-${TAG}.zip" build/luxd
|
||||
7z a "node-macos-${{ matrix.goarch }}-${TAG}.zip" build/luxd
|
||||
env:
|
||||
TAG: ${{ env.TAG }}
|
||||
|
||||
- name: Save as Github artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: build
|
||||
path: node-macos-${{ env.TAG }}.zip
|
||||
name: build-darwin-${{ matrix.goarch }}
|
||||
path: node-macos-${{ matrix.goarch }}-${{ env.TAG }}.zip
|
||||
|
||||
- name: Cleanup
|
||||
run: |
|
||||
|
||||
@@ -18,7 +18,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build-jammy-amd64-package:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
runs-on: lux-build
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
rm -rf /tmp/luxd
|
||||
|
||||
build-focal-amd64-package:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
runs-on: lux-build
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -18,15 +18,15 @@ on:
|
||||
|
||||
jobs:
|
||||
build-jammy-arm64-package:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: lux-build
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup-go-for-project
|
||||
- run: go version
|
||||
|
||||
- name: Build the luxd binaries (native arm64)
|
||||
run: CGO_ENABLED=0 ./scripts/build.sh
|
||||
- name: Build the luxd binaries (cross-compile arm64 on amd64)
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 ./scripts/build.sh
|
||||
|
||||
- name: Try to get tag from git
|
||||
if: "${{ github.event.inputs.tag == '' }}"
|
||||
@@ -63,15 +63,15 @@ jobs:
|
||||
rm -rf /tmp/luxd
|
||||
|
||||
build-focal-arm64-package:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: lux-build
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup-go-for-project
|
||||
- run: go version
|
||||
|
||||
- name: Build the luxd binaries (native arm64)
|
||||
run: CGO_ENABLED=0 ./scripts/build.sh
|
||||
- name: Build the luxd binaries (cross-compile arm64 on amd64)
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 ./scripts/build.sh
|
||||
|
||||
- name: Try to get tag from git
|
||||
if: "${{ github.event.inputs.tag == '' }}"
|
||||
|
||||
@@ -5,9 +5,9 @@ set -o nounset
|
||||
set -o pipefail
|
||||
|
||||
# Lux root directory
|
||||
LUX_PATH=$( cd "$( dirname "${BASH_SOURCE[0]}" )"; cd ../.. && pwd )
|
||||
NODE_PATH=$( cd "$( dirname "${BASH_SOURCE[0]}" )"; cd ../.. && pwd )
|
||||
|
||||
"$LUX_PATH"/scripts/build.sh
|
||||
"$NODE_PATH"/scripts/build.sh
|
||||
# Check to see if the build script creates any unstaged changes to prevent
|
||||
# regression where builds go.mod/go.sum files get out of date.
|
||||
if [[ -z $(git status -s) ]]; then
|
||||
@@ -15,5 +15,5 @@ if [[ -z $(git status -s) ]]; then
|
||||
# TODO: Revise this check once we can reliably build without changes
|
||||
# exit 1
|
||||
fi
|
||||
"$LUX_PATH"/scripts/build_test.sh
|
||||
"$LUX_PATH"/scripts/build_fuzz.sh 2
|
||||
"$NODE_PATH"/scripts/build_test.sh
|
||||
"$NODE_PATH"/scripts/build_fuzz.sh 2
|
||||
|
||||
@@ -30,7 +30,9 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-14, ubuntu-22.04, ubuntu-24.04, windows-2022]
|
||||
# GH-hosted arm64 macos forbidden; CI runs amd64 only. Release builds
|
||||
# cover darwin/arm64 via cross-compile in build-macos-release.yml.
|
||||
os: [macos-13, ubuntu-22.04, ubuntu-24.04, windows-2022]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup-go-for-project
|
||||
|
||||
@@ -8,22 +8,109 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
uses: hanzoai/.github/.github/workflows/docker-build.yml@main
|
||||
with:
|
||||
image: ghcr.io/luxfi/node
|
||||
runner-amd64: hanzo-build-linux-amd64
|
||||
runner-arm64: hanzo-build-linux-arm64
|
||||
secrets: inherit
|
||||
build-amd64:
|
||||
# Self-hosted `lux-build` ARC pool on DOKS hanzo-k8s (max 20 runners,
|
||||
# scales 0→N on demand). Org-isolated: luxfi workflows must use this
|
||||
# pool (never the hanzoai pools). amd64 only — DOKS has no arm64 yet.
|
||||
runs-on: lux-build
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: docker-container
|
||||
driver-opts: |
|
||||
network=host
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/luxfi/node
|
||||
# Semver-only policy: only `v*` git tags yield a published image
|
||||
# tag; every push gets an immutable `sha-<sha7>` for traceability.
|
||||
# No `:latest`, no `:main`, no floating tags ever. `flavor.latest`
|
||||
# has to be explicit-false; docker/metadata-action defaults to
|
||||
# `latest=auto` which silently emits `:latest` on tag pushes.
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=sha,format=short,prefix=sha-
|
||||
|
||||
- name: Resolve cross-repo PAT for private luxfi/* deps
|
||||
id: pat
|
||||
env:
|
||||
# The default GITHUB_TOKEN is repo-scoped and cannot read
|
||||
# private cross-repo deps like luxfi/corona or luxfi/evm. We
|
||||
# need a PAT with org-wide read scope. Order of preference:
|
||||
# 1) org GH_TOKEN (visibility=all, set on luxfi org since 2024)
|
||||
# 2) repo UNIVERSE_PAT (set on luxfi/node since 2026-04)
|
||||
# Either suffices; we just need ONE non-empty token. If both
|
||||
# are empty the build is going to fail at go mod download for
|
||||
# corona — fail fast here with a clear message.
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
UNIVERSE_PAT: ${{ secrets.UNIVERSE_PAT }}
|
||||
run: |
|
||||
tok="$GH_TOKEN"
|
||||
src="GH_TOKEN"
|
||||
if [ -z "$tok" ]; then
|
||||
tok="$UNIVERSE_PAT"
|
||||
src="UNIVERSE_PAT"
|
||||
fi
|
||||
if [ -z "$tok" ]; then
|
||||
echo "::error::Neither GH_TOKEN (org) nor UNIVERSE_PAT (repo) is set. Private luxfi/* deps cannot be resolved."
|
||||
exit 1
|
||||
fi
|
||||
echo "Using secret: $src (length=${#tok})"
|
||||
# Pass the resolved token to subsequent steps without exposing
|
||||
# the value. ::add-mask:: prevents accidental log leaks if the
|
||||
# value ever appears in later step output.
|
||||
echo "::add-mask::$tok"
|
||||
echo "token<<EOF" >> "$GITHUB_OUTPUT"
|
||||
echo "$tok" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build & push (amd64)
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
build-args: |
|
||||
CGO_ENABLED=0
|
||||
# Resolve private luxfi/* modules via git url.insteadOf in the
|
||||
# Dockerfile. Take the token value from the previous step's
|
||||
# output (it picked GH_TOKEN or fell back to UNIVERSE_PAT).
|
||||
secrets: |
|
||||
ghtok=${{ steps.pat.outputs.token }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: false
|
||||
cache-from: type=registry,ref=ghcr.io/luxfi/node:buildcache-amd64
|
||||
cache-to: type=registry,ref=ghcr.io/luxfi/node:buildcache-amd64,mode=max
|
||||
|
||||
notify-universe:
|
||||
needs: docker
|
||||
needs: build-amd64
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: peter-evans/repository-dispatch@v3
|
||||
- uses: peter-evans/repository-dispatch@v4
|
||||
with:
|
||||
token: ${{ secrets.UNIVERSE_PAT }}
|
||||
repository: luxfi/universe
|
||||
|
||||
+5
-4
@@ -31,8 +31,6 @@ tmp/
|
||||
|
||||
*.pb*
|
||||
|
||||
db*
|
||||
|
||||
*cpu[0-9]*
|
||||
*mem[0-9]*
|
||||
*lock[0-9]*
|
||||
@@ -85,11 +83,14 @@ QWEN.md
|
||||
|
||||
genesis/.!*
|
||||
genesis-gen
|
||||
lux
|
||||
luxd
|
||||
/lux
|
||||
/luxd
|
||||
evm-plugin-*
|
||||
|
||||
LLM.md
|
||||
QWEN.md
|
||||
.AGENTS.md
|
||||
GEMINI.md
|
||||
|
||||
# Built dev-tool binaries
|
||||
/cmd/gen_zoo_addr/gen_zoo_addr
|
||||
|
||||
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.28.0]
|
||||
|
||||
### Added
|
||||
- Multi-version P-Chain block + tx codec. v1.23.x ("Apricot/Banff") wire layout is now registered as `CodecVersionV0` on the platformvm tx and block `codec.Manager`s; the current canonical layout is `CodecVersionV1`. Bytes carry their wire version in the standard 2-byte codec prefix and `txs.Parse` / `block.Parse` dispatch by it.
|
||||
- Byte-preserving tx Initialization: `tx.InitializeFromBytes(c, version, signedBytes)` and `tx.InitializeFromBytesAtVersion(c, version)` bind a tx to its original `signedBytes` without re-marshalling. `TxID = hash(signedBytes)` is therefore stable across the v0->v1 migration — mainnet and testnet chain commitments hash back to byte-identical inputs.
|
||||
- `vms/platformvm/block/v0/` subpackage holding the pure-data v0 block kinds (`ApricotProposalBlock`, `BanffProposalBlock`, ... — 9 types, slots 0-4 + 29-32). The package is a one-way decoder only; the `liftedV0Block` adapter in the parent package translates each v0 block kind into the corresponding canonical v1 `Visitor` arm without ever re-marshalling.
|
||||
- Cross-version tx + block tests: `TestCodecVersionV0V1Coexist`, `TestParseDispatchesByVersion`, `TestTxIDStabilityRoundTrip`, `TestCrossVersionRefuses`, `TestParseV0ApricotProposalBlock`, `TestParseV0BanffStandardBlock`, `TestParseV1RoundTrip`, `TestVersionPrefixDispatch`.
|
||||
|
||||
### Changed
|
||||
- `tx.Initialize(c)` is retained for the fresh-build path only (wallet, builder). The from-DB / from-wire paths in `genesis/genesis.go` and `block/{standard,proposal}_block.go` now go through `InitializeFromBytesAtVersion` against the codec version the surrounding container was decoded under.
|
||||
- `genesis.Parse` is wire-version-aware: pre-codec-v1 P-Chain genesis blobs on mainnet / testnet decode at `CodecVersionV0`; new blobs are written at `CodecVersionV1`.
|
||||
- The L1-tx slot map advances by one to accommodate `CreateSovereignL1Tx` at slot 36: `RegisterL1ValidatorTx`=37, `SetL1ValidatorWeightTx`=38, `IncreaseL1ValidatorBalanceTx`=39, `DisableL1ValidatorTx`=40 (was 36-39). Both pre-rip mainnet/testnet bytes (no slot conflict; L1 txs did not exist) and current code paths line up with the new slot map.
|
||||
- Test fixtures in `vms/platformvm/txs/fee/calculator_test.go` and the serialization tests under `vms/platformvm/txs/*_test.go` are regenerated to use the v1 wire prefix (`0x0001`) and the post-`CreateSovereignL1Tx` slot map.
|
||||
|
||||
### Migration notes
|
||||
- Mainnet + testnet P-Chain DBs do NOT need to be rebuilt: pre-codec-v1 blocks continue to decode via the v0 path with their original `BlockID = hash(v0 bytes)` preserved. Newly accepted blocks are written at v1.
|
||||
- Devnet, which was bootstrapped post-rip at wire-version 0 but with the post-rip slot map, must be rebuilt before rolling to `v1.28.0`: its existing blobs would now decode against the v0 slot map (the pre-rip layout) and produce wrong types. A fresh bootstrap at `v1.28.0` writes v1 bytes from height 0 and is internally consistent thereafter.
|
||||
|
||||
## [1.13.5-alpha] - 2025-01-23
|
||||
|
||||
### Added
|
||||
|
||||
+73
-60
@@ -44,10 +44,24 @@ ENV GOPROXY=https://proxy.golang.org,direct
|
||||
ENV GONOPROXY=github.com/luxfi/*
|
||||
ENV GOFLAGS="-mod=mod"
|
||||
|
||||
# Copy and download lux dependencies using go mod
|
||||
# Copy and download lux dependencies using go mod.
|
||||
# Some luxfi/* modules (e.g. corona) are private and require a token to
|
||||
# resolve via go mod. The token is injected as a BuildKit secret from the
|
||||
# CI workflow; locally, set DOCKER_BUILDKIT=1 and pass
|
||||
# `--secret id=ghtok,src=$HOME/.gh-token`. If the secret is absent the
|
||||
# build still attempts the download (works when all deps are public).
|
||||
COPY go.mod .
|
||||
COPY go.sum .
|
||||
RUN go mod download
|
||||
# Configure global git insteadOf so EVERY subsequent step (go mod download
|
||||
# now, the build step's implicit fetch later) can reach private luxfi/*
|
||||
# modules. The token is written into /etc/gitconfig (root-readable in the
|
||||
# image), so explicit cleanup is unnecessary on this throwaway builder
|
||||
# stage — only the compiled binary is COPYed into the runtime image.
|
||||
RUN --mount=type=secret,id=ghtok,required=false \
|
||||
if [ -s /run/secrets/ghtok ]; then \
|
||||
git config --global url."https://x-access-token:$(cat /run/secrets/ghtok)@github.com/".insteadOf "https://github.com/"; \
|
||||
fi && \
|
||||
go mod download
|
||||
|
||||
# Copy the code into the container
|
||||
COPY . .
|
||||
@@ -58,6 +72,14 @@ RUN [ -d ./build ] && rm -rf ./build/* || true
|
||||
ARG TARGETPLATFORM
|
||||
ARG BUILDPLATFORM
|
||||
|
||||
# Per SCALE_STANDARD.md §2 (https://github.com/hanzoai/hips/blob/main/docs/SCALE_STANDARD.md)
|
||||
# — every Go production Dockerfile that emits JSON to a client builds
|
||||
# with GOEXPERIMENT=jsonv2. Verified -12% time / -23% allocs on the
|
||||
# edge POST roundtrip vs encoding/json v1. Applies to luxd + every
|
||||
# in-stage VM plugin build below.
|
||||
ARG GO_EXPERIMENT=jsonv2
|
||||
ENV GOEXPERIMENT=${GO_EXPERIMENT}
|
||||
|
||||
# Configure a cross-compiler if the target platform differs from the build platform.
|
||||
#
|
||||
# build_env.sh is used to capture the environmental changes required by the build step since RUN
|
||||
@@ -82,17 +104,17 @@ RUN ARCH=$(echo ${TARGETPLATFORM} | cut -d / -f2) && \
|
||||
ldconfig 2>/dev/null || true
|
||||
|
||||
# Fetch pre-built luxcpp/cevm libs (libevm, libevm-gpu, libluxgpu,
|
||||
# libcevm_precompiles + go_bridge.h). When LUX_CGO=1 these libraries are
|
||||
# libcevm_precompiles + go_bridge.h). When CGO_ENABLED=1 these libraries are
|
||||
# linked into the C-Chain plugin via github.com/luxfi/chains/evm/cevm
|
||||
# and become the default execution backend (parallel + GPU EVM).
|
||||
#
|
||||
# CI/RELEASE GAP: the luxcpp/cevm release artifacts MUST publish per-arch
|
||||
# tarballs at the URL below for both linux-x86_64 and linux-arm64. Until
|
||||
# those tarballs exist, builds with LUX_CGO=1 will fail at this step and
|
||||
# operators must build with LUX_CGO=0 (pure-Go fallback).
|
||||
ARG LUX_CGO=1
|
||||
# those tarballs exist, builds with CGO_ENABLED=1 will fail at this step and
|
||||
# operators must build with CGO_ENABLED=0 (pure-Go fallback).
|
||||
ARG CGO_ENABLED=1
|
||||
ARG CEVM_VERSION=v0.19.0
|
||||
RUN if [ "${LUX_CGO}" = "1" ]; then \
|
||||
RUN if [ "${CGO_ENABLED}" = "1" ]; then \
|
||||
ARCH=$(echo ${TARGETPLATFORM} | cut -d / -f2) && \
|
||||
if [ "$ARCH" = "amd64" ]; then CEVM_ARCH="linux-x86_64"; else CEVM_ARCH="linux-arm64"; fi && \
|
||||
wget -q "https://github.com/luxcpp/cevm/releases/download/${CEVM_VERSION}/luxcpp-cevm-${CEVM_ARCH}.tar.gz" \
|
||||
@@ -101,24 +123,31 @@ RUN if [ "${LUX_CGO}" = "1" ]; then \
|
||||
rm /tmp/cevm.tar.gz && \
|
||||
ldconfig 2>/dev/null || true ; \
|
||||
else \
|
||||
echo "LUX_CGO=0: skipping luxcpp/cevm fetch (pure-Go fallback build)" ; \
|
||||
echo "CGO_ENABLED=0: skipping luxcpp/cevm fetch (pure-Go fallback build)" ; \
|
||||
fi
|
||||
|
||||
# Build node. LUX_CGO=1 (default) links luxcpp/cevm for parallel + GPU EVM.
|
||||
# Set LUX_CGO=0 for portable pure-Go builds without the native libs.
|
||||
# Build node. CGO_ENABLED=1 (default) links luxcpp/cevm for parallel + GPU EVM.
|
||||
# Set CGO_ENABLED=0 for portable pure-Go builds without the native libs.
|
||||
ARG RACE_FLAG=""
|
||||
ARG BUILD_SCRIPT=build.sh
|
||||
ARG LUXD_COMMIT=""
|
||||
ENV CGO_ENABLED=${LUX_CGO}
|
||||
ENV CGO_ENABLED=${CGO_ENABLED}
|
||||
RUN . ./build_env.sh && \
|
||||
echo "{CC=$CC, TARGETPLATFORM=$TARGETPLATFORM, BUILDPLATFORM=$BUILDPLATFORM, LUX_CGO=${LUX_CGO}}" && \
|
||||
echo "{CC=$CC, TARGETPLATFORM=$TARGETPLATFORM, BUILDPLATFORM=$BUILDPLATFORM, CGO_ENABLED=${CGO_ENABLED}}" && \
|
||||
export GOARCH=$(echo ${TARGETPLATFORM} | cut -d / -f2) && \
|
||||
export LUXD_COMMIT="${LUXD_COMMIT}" && \
|
||||
GOFLAGS="-mod=mod" ./scripts/${BUILD_SCRIPT} ${RACE_FLAG}
|
||||
|
||||
# ============= EVM Plugin Stage ================
|
||||
# Build EVM plugin from source (includes custom precompile registry)
|
||||
ARG EVM_VERSION=v0.8.40
|
||||
# Build EVM plugin from source (includes custom precompile registry).
|
||||
# EVM_VERSION must pin a luxfi/evm release whose go.mod points at a
|
||||
# luxfi/node version that has the runtime.EngineAddressKey rename
|
||||
# (LUX_VM_RUNTIME_ENGINE_ADDR → VM_RUNTIME_ENGINE_ADDR landed in
|
||||
# 4ae211d46d on 2026-05-15). v0.18.14 pins node v1.27.6 which is
|
||||
# post-rename. Older evm tags (e.g. v0.8.40 → node v1.23.4) ship a
|
||||
# plugin that os.Getenv()'s the old key, mismatching the host that
|
||||
# sets the new key, and C-chain never bootstraps.
|
||||
ARG EVM_VERSION=v0.18.14
|
||||
ARG EVM_VM_ID=mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
mkdir -p /luxd/build/plugins && \
|
||||
@@ -153,55 +182,39 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
|
||||
# Each VM is an independent Go module under /tmp/chains/<vm>/.
|
||||
# Build each plugin binary and place it at /luxd/build/plugins/<cb58-vm-id>.
|
||||
#
|
||||
# NB(2026-05-19): luxfi/chains main has unresolved sibling go.mod replace
|
||||
# directives (luxfi/{evm,precompile,threshold} => ../*) that break in any
|
||||
# isolated build context. Each chain plugin is therefore built best-effort;
|
||||
# production deployments pull plugins from S3 (`pluginSource.bucket`) at
|
||||
# runtime per LuxNetwork CR, so an embedded plugin miss is non-fatal.
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
. /build/build_env.sh && \
|
||||
export GOARCH=$(echo ${TARGETPLATFORM} | cut -d / -f2) && \
|
||||
set -e && \
|
||||
cd /tmp/chains/aivm && \
|
||||
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/juFxSrbCM4wszxddKepj1GWwmrn9YgN1g4n3VUWPpRo9JjERA \
|
||||
./cmd/plugin && \
|
||||
cd /tmp/chains/bridgevm && \
|
||||
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/kMhHABHM8j4bH94MCc4rsTNdo5E9En37MMyiujk4WdNxgXFsY \
|
||||
./cmd/plugin && \
|
||||
cd /tmp/chains/dexvm && \
|
||||
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/mDVT5EWMumBp3LCqvKwuyZQeY1VXr1jvjGNAt8nL4UFiXvqXr \
|
||||
./cmd/plugin && \
|
||||
cd /tmp/chains/graphvm && \
|
||||
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/nZQm4Dmg1rjX18rb8maL9gamYyXPf1xCvF7ymWzxp6a1nSQTt \
|
||||
./cmd/plugin && \
|
||||
cd /tmp/chains/identityvm && \
|
||||
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/oR6tnZHezwogyf9fRnomNXC9ojwCEBAU6jdUzpgy2PB1tD7fM \
|
||||
./cmd/plugin && \
|
||||
cd /tmp/chains/keyvm && \
|
||||
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/pJJCSV7hHYVY6TUZwR8qUPAfuhX8JLb2C1AzNSezrYNbgau8M \
|
||||
./cmd/plugin && \
|
||||
cd /tmp/chains/oraclevm && \
|
||||
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/r5m1ujrmXxVcQetG3CQfuDLHp2RHKh6vCDaFgBRQfUcTZh7eS \
|
||||
./cmd/plugin && \
|
||||
cd /tmp/chains/quantumvm && \
|
||||
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/ry9Sg8rZdT26iEKvJDmC2wkESs4SDKgZEhk5BgLSwg1EpcNug \
|
||||
./cmd/plugin && \
|
||||
cd /tmp/chains/relayvm && \
|
||||
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/sP6dLqrrBR9w3soP18fbJ3YzZecZdD7DDdfH2cFhhLq7Hy9bz \
|
||||
./cmd/plugin && \
|
||||
cd /tmp/chains/thresholdvm && \
|
||||
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/tGVBwRxpmD2aFdg3iYjgRvrCe8Jcmq9UNKxyHMus2NZ8WcD8t \
|
||||
./cmd/plugin && \
|
||||
cd /tmp/chains/zkvm && \
|
||||
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/vv3qPfyTVXZ5ArRZA9Jh4hbYDTBe43f7sgQg4CHfNg1rnnvX9 \
|
||||
./cmd/plugin && \
|
||||
chmod +x /luxd/build/plugins/* && \
|
||||
mkdir -p /luxd/build/plugins && \
|
||||
( cd /tmp/chains/aivm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/juFxSrbCM4wszxddKepj1GWwmrn9YgN1g4n3VUWPpRo9JjERA ./cmd/plugin ) || echo "WARN: aivm plugin build skipped" ; \
|
||||
( cd /tmp/chains/bridgevm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/kMhHABHM8j4bH94MCc4rsTNdo5E9En37MMyiujk4WdNxgXFsY ./cmd/plugin ) || echo "WARN: bridgevm plugin build skipped" ; \
|
||||
( cd /tmp/chains/dexvm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/mDVT5EWMumBp3LCqvKwuyZQeY1VXr1jvjGNAt8nL4UFiXvqXr ./cmd/plugin ) || echo "WARN: dexvm plugin build skipped" ; \
|
||||
( cd /tmp/chains/graphvm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/nZQm4Dmg1rjX18rb8maL9gamYyXPf1xCvF7ymWzxp6a1nSQTt ./cmd/plugin ) || echo "WARN: graphvm plugin build skipped" ; \
|
||||
( cd /tmp/chains/identityvm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/oR6tnZHezwogyf9fRnomNXC9ojwCEBAU6jdUzpgy2PB1tD7fM ./cmd/plugin ) || echo "WARN: identityvm plugin build skipped" ; \
|
||||
( cd /tmp/chains/keyvm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/pJJCSV7hHYVY6TUZwR8qUPAfuhX8JLb2C1AzNSezrYNbgau8M ./cmd/plugin ) || echo "WARN: keyvm plugin build skipped" ; \
|
||||
( cd /tmp/chains/oraclevm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/r5m1ujrmXxVcQetG3CQfuDLHp2RHKh6vCDaFgBRQfUcTZh7eS ./cmd/plugin ) || echo "WARN: oraclevm plugin build skipped" ; \
|
||||
( cd /tmp/chains/quantumvm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/ry9Sg8rZdT26iEKvJDmC2wkESs4SDKgZEhk5BgLSwg1EpcNug ./cmd/plugin ) || echo "WARN: quantumvm plugin build skipped" ; \
|
||||
( cd /tmp/chains/relayvm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/sP6dLqrrBR9w3soP18fbJ3YzZecZdD7DDdfH2cFhhLq7Hy9bz ./cmd/plugin ) || echo "WARN: relayvm plugin build skipped" ; \
|
||||
( cd /tmp/chains/thresholdvm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/tGVBwRxpmD2aFdg3iYjgRvrCe8Jcmq9UNKxyHMus2NZ8WcD8t ./cmd/plugin ) || echo "WARN: thresholdvm plugin build skipped" ; \
|
||||
( cd /tmp/chains/zkvm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/vv3qPfyTVXZ5ArRZA9Jh4hbYDTBe43f7sgQg4CHfNg1rnnvX9 ./cmd/plugin ) || echo "WARN: zkvm plugin build skipped" ; \
|
||||
( chmod +x /luxd/build/plugins/* 2>/dev/null || true ) && \
|
||||
rm -rf /tmp/chains
|
||||
|
||||
# lpm (Lux Plugin Manager) -- optional, skip if build fails
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Licensing
|
||||
|
||||
This repository is licensed under the **BSD 3-Clause License** (see
|
||||
[LICENSE](LICENSE)). It belongs to the **public** tier of the Lux
|
||||
three-tier IP strategy: anyone may use, fork, or redistribute it,
|
||||
including for commercial purposes, subject to the BSD-3 terms.
|
||||
|
||||
For the canonical Lux IP and licensing strategy, see:
|
||||
<https://github.com/luxfi/.github/blob/main/profile/README.md>
|
||||
|
||||
For commercial inquiries that go beyond BSD-3 (e.g. private moat
|
||||
acceleration kernels), contact `licensing@lux.network`.
|
||||
@@ -8,10 +8,131 @@ Lux blockchain node implementation - a high-performance, multi-chain blockchain
|
||||
|
||||
**Key Context:**
|
||||
- Original Lux Network node — NOT a fork
|
||||
- Latest Tag: v1.26.31
|
||||
- Network ID: 96369 (Lux Mainnet), 96368 (Testnet), 96370 (Devnet)
|
||||
- Go Version: 1.26.1+
|
||||
- Database: ZapDB (primary, default)
|
||||
|
||||
## Post-E2E-PQ State (current)
|
||||
|
||||
Node now consumes the locked `ChainSecurityProfile` end-to-end and enforces
|
||||
strict-PQ at four boundaries: peer handshake, mempool, validator scheme
|
||||
selection, and EVM contract auth.
|
||||
|
||||
- `node/node.go:initSecurityProfile` (F102 closure) loads the chain-wide
|
||||
profile from genesis at boot, hashes it, and pins it into every chain's
|
||||
bootstrap. Resolved profile is what every downstream verifier consults.
|
||||
- `network/peer/scheme_gate.go` — `SchemeGate.Classify(presented, site)`
|
||||
funnels every inbound NodeID through the profile's
|
||||
`AcceptsValidatorScheme`. Wire-typed NodeID (`luxfi/ids` TypedNodeID +
|
||||
scheme byte) is the canonical handshake form.
|
||||
- `vms/txs/auth/policy.go` — `ClassicalCompatRegistry` + strict-PQ
|
||||
mempool gate. Both `platformvm` (P-Chain) and `avm` (X-Chain) mempools
|
||||
refuse classical credentials when the resolved profile has
|
||||
`ForbidECDSAContractAuth=true`.
|
||||
- `vms/mldsafx/` — re-exports the consensus `mldsafx` UTXO feature
|
||||
extension as the node-owned UTXO surface (ML-DSA-65 verify).
|
||||
- `network/peer` PQ handshake — ML-KEM-768 / ML-KEM-1024 KEM +
|
||||
ML-DSA-65 identity (`dc906d281b`).
|
||||
|
||||
### Recent significant commits
|
||||
|
||||
| SHA | Tag | Impact |
|
||||
|-----|-----|--------|
|
||||
| `9df72a6f55` | v1.26.10 | Wire ChainSecurityProfile into bootstrap (closes F102) |
|
||||
| `c4af52411e` | v1.26.10 | X-Chain (avm) mempool refuses classical creds under strict-PQ |
|
||||
| `a14a1601f4` | v1.26.10 | P-Chain (platformvm) mempool refuses classical creds under strict-PQ |
|
||||
| `1cf0aa80ca` | v1.26.10 | ClassicalCompatRegistry + strict-PQ mempool gate |
|
||||
| `a0f4f4b21c` | v1.26.10 | vms/mldsafx: re-export ML-DSA feature extension |
|
||||
| `448fdeb7a1` | v1.26.10 | ML-DSA-65 promoted to canonical NodeID under strict-PQ |
|
||||
| `dc906d281b` | v1.26.10 | PQ peer handshake — ML-KEM-768/1024 + ML-DSA-65 identity |
|
||||
|
||||
### Active versions
|
||||
- Repo: `v1.26.12` (next bump: `v1.26.13`).
|
||||
- Pinned: `consensus v1.23.4+` (needed `ValidatorSchemeID`),
|
||||
`crypto v1.18.5`, `ids v1.2.9` (will move to v1.2.10 in next bump for
|
||||
`TypedNodeID`), `genesis v1.9.6`.
|
||||
|
||||
### Cross-repo dependencies
|
||||
- `luxfi/consensus` → profile + auth + zchain types
|
||||
- `luxfi/crypto` → ML-DSA / ML-KEM / SLH-DSA primitives
|
||||
- `luxfi/genesis` → genesis-pinned profile (`Resolve` at load)
|
||||
- `luxfi/ids` → `TypedNodeID` wire form (consumed at handshake)
|
||||
- `luxfi/geth` → EVM (for `vm.SetActiveSecurityProfile` install point)
|
||||
|
||||
### Where to look for X
|
||||
- Profile resolve at boot: `node/node.go:initSecurityProfile`
|
||||
- Profile RPC + REST + metrics: `service/security/`
|
||||
- JSON-RPC namespace: `security` at `POST /ext/security`
|
||||
(methods `securityProfile`, `blockSecurity`)
|
||||
- REST sidecars: `GET /ext/security/profile`, `GET /ext/security/block/{n}`
|
||||
- Prometheus gauges: `/ext/metrics` under the `security_*` family
|
||||
- Peer scheme gate: `network/peer/scheme_gate.go`
|
||||
- Classical-compat registry: `vms/txs/auth/policy.go`
|
||||
- Mempool gate (P-Chain): `vms/platformvm/mempool/*.go`
|
||||
- Mempool gate (X-Chain): `vms/avm/mempool/*.go`
|
||||
- ML-DSA feature extension: `vms/mldsafx/`
|
||||
|
||||
### Open follow-ups
|
||||
- `vms/zkvm/accel/` still soft-falls-back when CGO is disabled; Z-Chain
|
||||
proof verification path needs CGO-required mode for production strict-PQ.
|
||||
- `vm.SetActiveSecurityProfile` install point exists in `luxfi/geth/core/vm`
|
||||
but EVM-side contract-auth refusal still needs a chain-bootstrap call
|
||||
(F102 wiring closes the consensus side; geth-side hookup is the
|
||||
remaining tail).
|
||||
|
||||
## FeePolicy — canonical user-tx fee gate
|
||||
|
||||
Every Lux VM that accepts user-submitted txs declares a `fee.Policy`
|
||||
(package `vms/types/fee`). There is one interface and one validator —
|
||||
no per-VM bespoke fee structs.
|
||||
|
||||
### Policy choice per VM
|
||||
|
||||
| VM | Chain | Posture | Policy |
|
||||
|----|-------|---------|--------|
|
||||
| dexvm | D-Chain | user-tx | `FlatPolicy{Fee: MinTxFeeFloor, AssetID: UTXOAssetIDFor(networkID)}` |
|
||||
| zkvm | Z-Chain | user-tx | `FlatPolicy{Fee: MinTxFeeFloor, ...}` |
|
||||
| aivm | A-Chain | user-tx | `FlatPolicy{Fee: MinTxFeeFloor, ...}` |
|
||||
| keyvm | K-Chain | user-tx | `FlatPolicy{Fee: MinTxFeeFloor, ...}` |
|
||||
| bridgevm | B-Chain | user-tx | `FlatPolicy{Fee: MinTxFeeFloor, ...}` |
|
||||
| quantumvm | Q-Chain | user-tx | `FlatPolicy{Fee: MinTxFeeFloor, ...}` |
|
||||
| identityvm | I-Chain | user-tx | `FlatPolicy{Fee: MinTxFeeFloor, ...}` |
|
||||
| thresholdvm | M-Chain | service-only | `NoUserTxPolicy{}` |
|
||||
| oraclevm | O-Chain | service-only | `NoUserTxPolicy{}` |
|
||||
| relayvm | R-Chain | service-only | `NoUserTxPolicy{}` |
|
||||
| graphvm | G-Chain | read-only | `NoUserTxPolicy{}` (GraphQL refuses `mutation`) |
|
||||
| evm | C-Chain | user-tx | native EVM gas (gas * gasPrice >= 0 enforced upstream) |
|
||||
| platformvm | P-Chain | user-tx | native `TxFee` field on Config |
|
||||
| avm | X-Chain | user-tx | native `TxFee` field on Config |
|
||||
|
||||
`MinTxFeeFloor = 1 mLUX = 1_000_000 nLUX` (the same minimum the P-Chain
|
||||
base fee enforces). User-facing chains MAY charge more; they MUST NOT
|
||||
charge less.
|
||||
|
||||
### Wiring contract
|
||||
|
||||
1. VM struct holds `feePolicy fee.Policy` (and `networkID uint32`).
|
||||
2. `Initialize` sets `feePolicy = fee.FlatPolicy{...}` (or
|
||||
`fee.NoUserTxPolicy{}` for service-only) from `init.Runtime.NetworkID`
|
||||
and calls `fee.Validate(vm.feePolicy)` — refuses zero-fee user-facing
|
||||
chains at boot, before any block is accepted.
|
||||
3. The canonical user-tx admission entry (e.g. `SubmitTx`, `IssueTx`,
|
||||
`InitiateBridgeTransfer`, mutating service RPCs) calls
|
||||
`policy.ValidateFee(paid, asset)` BEFORE mempool insert.
|
||||
4. Consensus-internal paths (engine→VM block delivery, replay, internal
|
||||
tx emission) bypass the fee gate — the policy gates only the
|
||||
*user-submitted* entrypoint.
|
||||
|
||||
### Where the gates live
|
||||
|
||||
- `vms/types/fee/policy.go` — interface + FlatPolicy + NoUserTxPolicy + Validate
|
||||
- `~/work/lux/chains/<vm>/feegate.go` — per-VM helper + gate method
|
||||
- `~/work/lux/chains/<vm>/feegate_test.go` — RejectsZeroFee + AcceptsMinFee
|
||||
- Oracle (O-Chain): `~/work/lux/oracle/vm/feegate.go` (re-exported by `~/work/lux/chains/oraclevm/`)
|
||||
- Relay (R-Chain): `~/work/lux/relay/vm/feegate.go` (re-exported by `~/work/lux/chains/relayvm/`)
|
||||
- Graph (G-Chain): `~/work/lux/chains/graphvm/feegate.go` (read-only; NoUserTxPolicy)
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Building
|
||||
@@ -205,20 +326,33 @@ Located in `vms/thresholdvm/fhe/`:
|
||||
| Gateway | `0x0200000000000000000000000000000000000083` |
|
||||
|
||||
### ZAP Transport (Zero-Copy App Proto)
|
||||
ZAP is the default high-performance binary wire protocol for VM<->Node communication.
|
||||
gRPC support is available via build tag for testing/compatibility.
|
||||
ZAP is the only wire protocol for VM<->Node communication. The gRPC
|
||||
fallback (and its `-tags=grpc` opt-in) was retired in v1.26.31 along
|
||||
with every `//go:build grpc` file under `node/`. There is one and only
|
||||
one way to talk to a Chain VM: ZAP.
|
||||
|
||||
**Build Tags:**
|
||||
**Build:**
|
||||
```bash
|
||||
go build # ZAP only (default, production)
|
||||
go build -tags=grpc # gRPC support (for testing/compatibility)
|
||||
go build # ZAP only — there are no build tags
|
||||
```
|
||||
|
||||
**Key Packages:**
|
||||
- `github.com/luxfi/api/zap` - Core wire protocol and message types
|
||||
- `github.com/luxfi/vm/rpc/sender` - p2p.Sender over ZAP/gRPC
|
||||
- `vms/rpcchainvm/sender/` - Node-side sender implementation
|
||||
- `vms/platformvm/warp/zwarp/` - ZAP-based warp signing client/server
|
||||
- `github.com/luxfi/api/zap` — Core wire protocol and message types (Layer A)
|
||||
- `github.com/luxfi/protocol/rpcdb` — rpcdb service spec / data carriers (Layer B)
|
||||
- `github.com/luxfi/node/db/rpcdb` — rpcdb Service + ZAP transport adapter (Layer C)
|
||||
- `vms/rpcchainvm/sender/` — Node-side `p2p.Sender` over ZAP
|
||||
- `vms/rpcchainvm/zap/` — ChainVM client/server over ZAP
|
||||
- `vms/platformvm/warp/zwarp/` — Warp signing over ZAP
|
||||
|
||||
**rpcdb Layered Topology:**
|
||||
- Layer A — wire framing: `github.com/luxfi/api/zap`
|
||||
- Layer B — rpcdb service spec: `github.com/luxfi/protocol/rpcdb`
|
||||
- Layer C — rpcdb impl: `node/db/rpcdb/{service.go, zap_server.go}`
|
||||
- `service.go` — transport-neutral `Service` wrapping `database.Database`
|
||||
- `zap_server.go` — ZAP transport adapter (only adapter)
|
||||
- One Service, one transport. The dual-adapter pattern stays available
|
||||
for future transports (each is a new file wrapping `*Service`), but
|
||||
ZAP is the only one shipping.
|
||||
|
||||
**Wire Protocol Format:**
|
||||
```
|
||||
@@ -233,11 +367,8 @@ go build -tags=grpc # gRPC support (for testing/compatibility)
|
||||
|
||||
**Sender Usage:**
|
||||
```go
|
||||
// ZAP transport (default)
|
||||
// ZAP transport — the only transport
|
||||
s := sender.ZAP(zapConn)
|
||||
|
||||
// gRPC transport (requires -tags=grpc build)
|
||||
s := sender.GRPC(senderpb.NewSenderClient(grpcConn))
|
||||
```
|
||||
|
||||
**Warp over ZAP:**
|
||||
@@ -335,6 +466,32 @@ RNS transport supports hybrid post-quantum cryptography combining classical algo
|
||||
- **Mixed Networks**: PQ and classical validators coexist
|
||||
- **Policy Enforcement**: `requirePostQuantum: true` rejects classical peers
|
||||
|
||||
### SchemeGate — cross-axis NodeIDScheme enforcement
|
||||
|
||||
`network/peer/scheme_gate.go` (v1.26.10) is the single primitive that
|
||||
turns a wire NodeID into a `(scheme, NodeID)` pair and runs the
|
||||
cross-axis check against the chain's `ChainSecurityProfile`.
|
||||
|
||||
- `SchemeGate{Profile, ClassicalCompatUnsafe, ActivationHeight}` is the
|
||||
chain-scoped policy object. One gate per chain, pinned at bootstrap.
|
||||
- `Classify(nodeID, scheme, height, site) (TypedNodeID, error)` is the
|
||||
single entry point. Callers pass a site tag (`"handshake"`,
|
||||
`"proposer"`, `"validator"`, `"mempool-sender"`) that appears in the
|
||||
refused-by error.
|
||||
- Migration path: `ActivationHeight` is the block at which a strict-PQ
|
||||
chain refuses any non-PQ `NodeIDScheme` byte at every height under
|
||||
the forward-only PQ policy. The classical `secp256k1` (0x90) scheme is
|
||||
refused at the gate; there is no transition window and no operator
|
||||
classical-compat escape hatch (strict-PQ chains refuse classical at
|
||||
every boundary, period).
|
||||
- Typed errors: `ErrSchemeGateConfig`, `ErrSchemeGateMismatch`,
|
||||
`ErrSchemeGateUnknownScheme`.
|
||||
|
||||
Wire form: `TypedNodeID = (NodeIDScheme byte, 20-byte NodeID)`. The
|
||||
20-byte storage/map-key form stays byte-identical; the scheme byte
|
||||
travels on the wire so a receiver knows which verifier to dispatch
|
||||
without trusting the chain profile alone.
|
||||
|
||||
### Testing PQ Forward Secrecy
|
||||
|
||||
```bash
|
||||
@@ -353,7 +510,7 @@ go test -v -run "TestHybrid" ./node/network/dialer/... -count=1
|
||||
|
||||
### 1. P2P Sender Interface
|
||||
Node's rpcchainvm implements `p2p.Sender` (from `github.com/luxfi/p2p`) for cross-chain messaging.
|
||||
The `sender` package is a gRPC implementation of `p2p.Sender`.
|
||||
The `sender` package is the ZAP-native implementation of `p2p.Sender`.
|
||||
|
||||
### 2. Chain Tracking
|
||||
Nodes don't automatically track chains. Use:
|
||||
@@ -600,7 +757,7 @@ Testing conducted on a single Lux validator node (testnet mode, macOS):
|
||||
**Benchmark Command:**
|
||||
```bash
|
||||
cd ~/work/lux/benchmarks
|
||||
LUX_ENDPOINT="http://localhost:9640/ext/bc/C/rpc" \
|
||||
NODE_ENDPOINT="http://localhost:9640/ext/bc/C/rpc" \
|
||||
PRIVATE_KEY="<funded_key>" \
|
||||
./bin/bench tps --chains=lux --duration=60s --concurrency=5
|
||||
```
|
||||
|
||||
+9
-9
@@ -2519,7 +2519,7 @@ The plugin version is unchanged at `27` and compatible with versions `v1.10.5 -
|
||||
|
||||
### Configs
|
||||
|
||||
- Changed the default value of `--network-allow-private-ips` to `false` when the `--network-id` is either `fuji` or `mainnet`
|
||||
- Changed the default value of `--network-allow-private-ips` to `false` when the `--network-id` is either `testnet` or `mainnet`
|
||||
- Added P-chain cache size configurations
|
||||
- `block-cache-size`
|
||||
- `tx-cache-size`
|
||||
@@ -2564,7 +2564,7 @@ The plugin version is unchanged at `27` and compatible with versions `v1.10.5 -
|
||||
- [x/sync] Update target locking by @patrick-ogrady in https://github.com/luxfi/node/pull/1763
|
||||
- Export warp errors for external use by @aaronbuchwald in https://github.com/luxfi/node/pull/1771
|
||||
- Remove unused networking constant by @StephenButtolph in https://github.com/luxfi/node/pull/1774
|
||||
- Change the default value of `--network-allow-private-ips` to `false` for `mainnet` and `fuji` by @StephenButtolph in https://github.com/luxfi/node/pull/1773
|
||||
- Change the default value of `--network-allow-private-ips` to `false` for `mainnet` and `testnet` by @StephenButtolph in https://github.com/luxfi/node/pull/1773
|
||||
- Remove context.TODO from tests by @StephenButtolph in https://github.com/luxfi/node/pull/1778
|
||||
- Replace linkeddb iterator with native DB range queries by @StephenButtolph in https://github.com/luxfi/node/pull/1752
|
||||
- Add support for measuring key size in caches by @StephenButtolph in https://github.com/luxfi/node/pull/1781
|
||||
@@ -2934,7 +2934,7 @@ This version is backwards compatible to [v1.10.0](https://github.com/luxfi/node/
|
||||
- Remove no-op changes from history results in https://github.com/luxfi/node/pull/1335
|
||||
- Cleanup type assertions in the linkedHashmap by @StephenButtolph in https://github.com/luxfi/node/pull/1341
|
||||
- Fix racy xvm tx access by @StephenButtolph in https://github.com/luxfi/node/pull/1349
|
||||
- Update Fuji beacon ips by @StephenButtolph in https://github.com/luxfi/node/pull/1354
|
||||
- Update Testnet beacon ips by @StephenButtolph in https://github.com/luxfi/node/pull/1354
|
||||
- Remove duplicate TLS verification by @StephenButtolph in https://github.com/luxfi/node/pull/1364
|
||||
- Adjust Merkledb Trie invalidation locking in https://github.com/luxfi/node/pull/1355
|
||||
- Use require in Lux bootstrapping tests by @StephenButtolph in https://github.com/luxfi/node/pull/1344
|
||||
@@ -2949,7 +2949,7 @@ This version is backwards compatible to [v1.10.0](https://github.com/luxfi/node/
|
||||
- [Issue-1368]: Panic in serializedPath.HasPrefix in https://github.com/luxfi/node/pull/1371
|
||||
- Add ValidatorsOnly flag to GetStake by @StephenButtolph in https://github.com/luxfi/node/pull/1377
|
||||
- Use proto in `x/sync` by @danlaine in https://github.com/luxfi/node/pull/1336
|
||||
- Update incorrect fuji beacon IPs by @StephenButtolph in https://github.com/luxfi/node/pull/1392
|
||||
- Update incorrect testnet beacon IPs by @StephenButtolph in https://github.com/luxfi/node/pull/1392
|
||||
- Update `api/` error handling by @StephenButtolph in https://github.com/luxfi/node/pull/1393
|
||||
- refactor concurrent work limiting in sync in `x/sync` by @danlaine in https://github.com/luxfi/node/pull/1347
|
||||
- Remove check for impossible condition in `x/sync` by @danlaine in https://github.com/luxfi/node/pull/1348
|
||||
@@ -3017,7 +3017,7 @@ The supported plugin version is `25`.
|
||||
- defer delegatee rewards until end of validator staking period by @dhrubabasu in https://github.com/luxfi/node/pull/1262
|
||||
- Initialize UptimeCalculator in TestPeer by @joshua-kim in https://github.com/luxfi/node/pull/1283
|
||||
- Add Lux liveness health checks by @StephenButtolph in https://github.com/luxfi/node/pull/1287
|
||||
- Skip AMI generation with Fuji tags by @StephenButtolph in https://github.com/luxfi/node/pull/1288
|
||||
- Skip AMI generation with Testnet tags by @StephenButtolph in https://github.com/luxfi/node/pull/1288
|
||||
- Use `maps.Equal` in `set.Equals` by @danlaine in https://github.com/luxfi/node/pull/1290
|
||||
- return accrued delegator rewards in `GetCurrentValidators` by @dhrubabasu in https://github.com/luxfi/node/pull/1291
|
||||
- Add zstd compression by @danlaine in https://github.com/luxfi/node/pull/1278
|
||||
@@ -3819,7 +3819,7 @@ The supported plugin version is `16`.
|
||||
|
||||
Please upgrade your node as soon as possible.
|
||||
|
||||
The changes in `v1.8.x` go into effect at 4 PM EDT on September 6th, 2022 on both Fuji and Mainnet. You should upgrade your node before the changes go into effect, otherwise they may experience loss of uptime.
|
||||
The changes in `v1.8.x` go into effect at 4 PM EDT on September 6th, 2022 on both Testnet and Mainnet. You should upgrade your node before the changes go into effect, otherwise they may experience loss of uptime.
|
||||
|
||||
The supported plugin version is `16`.
|
||||
|
||||
@@ -3833,7 +3833,7 @@ The supported plugin version is `16`.
|
||||
|
||||
Please upgrade your node as soon as possible.
|
||||
|
||||
The changes in `v1.8.x` go into effect at 4 PM EDT on September 6th, 2022 on both Fuji and Mainnet. You should upgrade your node before the changes go into effect, otherwise they may experience loss of uptime.
|
||||
The changes in `v1.8.x` go into effect at 4 PM EDT on September 6th, 2022 on both Testnet and Mainnet. You should upgrade your node before the changes go into effect, otherwise they may experience loss of uptime.
|
||||
|
||||
The supported plugin version is `16`.
|
||||
|
||||
@@ -3850,7 +3850,7 @@ The supported plugin version is `16`.
|
||||
|
||||
This is a mandatory security upgrade. Please upgrade your node **as soon as possible.**
|
||||
|
||||
The changes in the upgrade go into effect at **4 PM EDT on September 6th, 2022** on both Fuji and Mainnet. You should upgrade your node before the changes go into effect, otherwise they may experience loss of uptime.
|
||||
The changes in the upgrade go into effect at **4 PM EDT on September 6th, 2022** on both Testnet and Mainnet. You should upgrade your node before the changes go into effect, otherwise they may experience loss of uptime.
|
||||
|
||||
You may see some extraneous ERROR logs ("BAD BLOCK") on your node after upgrading. These may continue until the Apricot Phase 6 activation (at 4 PM EDT on September 6th).
|
||||
|
||||
@@ -4515,7 +4515,7 @@ This version is backwards compatible to [v1.7.0](https://github.com/luxfi/node/r
|
||||
- Added `--stake-supply-cap` which defaults to `720,000,000,000,000,000` nLUX.
|
||||
- Renamed `--bootstrap-multiput-max-containers-sent` to `--bootstrap-ancestors-max-containers-sent`.
|
||||
- Renamed `--bootstrap-multiput-max-containers-received` to `--bootstrap-ancestors-max-containers-received`.
|
||||
- Enforced that `--staking-enabled=false` can not be specified on public networks (`Fuji` and `Mainnet`).
|
||||
- Enforced that `--staking-enabled=false` can not be specified on public networks (`Testnet` and `Mainnet`).
|
||||
|
||||
### Metrics
|
||||
|
||||
|
||||
+131
-37
@@ -70,11 +70,17 @@ import (
|
||||
"github.com/luxfi/vm/fx"
|
||||
|
||||
// "github.com/luxfi/node/vms/metervm" // Temporarily disabled - needs consensus package updates
|
||||
"github.com/luxfi/utxo/bls12381fx"
|
||||
"github.com/luxfi/utxo/ed25519fx"
|
||||
"github.com/luxfi/utxo/mldsafx"
|
||||
"github.com/luxfi/utxo/nftfx"
|
||||
|
||||
"github.com/luxfi/utxo/propertyfx"
|
||||
// "github.com/luxfi/node/vms/proposervm"
|
||||
"github.com/luxfi/utxo/schnorrfx"
|
||||
"github.com/luxfi/utxo/secp256k1fx"
|
||||
"github.com/luxfi/utxo/secp256r1fx"
|
||||
"github.com/luxfi/utxo/slhdsafx"
|
||||
// "github.com/luxfi/node/vms/tracedvm" // Temporarily disabled - needs consensus package updates
|
||||
|
||||
// "github.com/luxfi/node/proto/p2p" // Available if needed for protobuf parsing
|
||||
@@ -128,10 +134,26 @@ var (
|
||||
errNotBootstrapped = errors.New("chains not bootstrapped")
|
||||
errPartialSyncAsAValidator = errors.New("partial sync should not be configured for a validator")
|
||||
|
||||
// fxs lists every Feature eXtension factory the node knows how to load
|
||||
// when a chain genesis references it. Must stay in sync with the X-Chain
|
||||
// FxIDs[] block in genesis/builder/builder.go: any fx ID present there
|
||||
// MUST be registered here, otherwise chain init fails with "fx ... not
|
||||
// found" at startup. Keep the order and grouping mirroring genesis to
|
||||
// make drift obvious in review.
|
||||
fxs = map[ids.ID]fx.Factory{
|
||||
// Legacy / classical
|
||||
secp256k1fx.ID: &secp256k1fx.Factory{},
|
||||
nftfx.ID: &nftfx.Factory{},
|
||||
propertyfx.ID: &propertyfx.Factory{},
|
||||
// Post-quantum (FIPS 203/204/205 family)
|
||||
mldsafx.ID: &mldsafx.Factory{},
|
||||
slhdsafx.ID: &slhdsafx.Factory{},
|
||||
// EdDSA / Schnorr / NIST P-256
|
||||
ed25519fx.ID: &ed25519fx.Factory{},
|
||||
secp256r1fx.ID: &secp256r1fx.Factory{},
|
||||
schnorrfx.ID: &schnorrfx.Factory{},
|
||||
// Pairing-friendly curve
|
||||
bls12381fx.ID: &bls12381fx.Factory{},
|
||||
}
|
||||
|
||||
_ Manager = (*manager)(nil)
|
||||
@@ -323,7 +345,7 @@ type ManagerConfig struct {
|
||||
PartialSyncPrimaryNetwork bool
|
||||
Server server.Server // Handles HTTP API calls
|
||||
AtomicMemory *atomic.Memory
|
||||
XAssetID ids.ID
|
||||
UTXOAssetID ids.ID
|
||||
SkipBootstrap bool // Skip bootstrapping and start processing immediately
|
||||
EnableAutomining bool // Enable automining in POA mode
|
||||
XChainID ids.ID // ID of the X-Chain,
|
||||
@@ -363,6 +385,17 @@ type ManagerConfig struct {
|
||||
ChainDataDir string
|
||||
|
||||
Nets *Nets
|
||||
|
||||
// SecurityProfile is the chain-wide ChainSecurityProfile resolved
|
||||
// from the genesis pin during node bootstrap (F102). The chain
|
||||
// manager forwards a ProfileID + ProfileHash pin to every VM
|
||||
// Initialize call via the VM config bytes so the rpcchainvm plugin
|
||||
// (coreth C-Chain) inherits the chain-wide posture without
|
||||
// re-resolving genesis. Closes red-team finding F118.
|
||||
//
|
||||
// Nil under classical-compat or pre-locked-profile chains; the
|
||||
// stamping pass is a no-op in that case.
|
||||
SecurityProfile *consensusconfig.ChainSecurityProfile
|
||||
}
|
||||
|
||||
type manager struct {
|
||||
@@ -485,31 +518,12 @@ func New(config *ManagerConfig) (Manager, error) {
|
||||
// QueueChainCreation queues a chain creation request
|
||||
// Invariant: Tracked Net must be checked before calling this function
|
||||
func (m *manager) QueueChainCreation(chainParams ChainParameters) {
|
||||
// Check for chain ID mapping override for C-Chain
|
||||
m.Log.Info("QueueChainCreation called",
|
||||
log.String("vmID", chainParams.VMID.String()),
|
||||
log.String("EVMID", constants.EVMID.String()),
|
||||
log.Bool("vmIDEqualsEVMID", chainParams.VMID == constants.EVMID),
|
||||
log.String("envVar", os.Getenv("LUX_CHAIN_ID_MAPPING_C")),
|
||||
)
|
||||
|
||||
if chainParams.VMID == constants.EVMID && os.Getenv("LUX_CHAIN_ID_MAPPING_C") != "" {
|
||||
mappedID := os.Getenv("LUX_CHAIN_ID_MAPPING_C")
|
||||
parsedID, err := ids.FromString(mappedID)
|
||||
if err == nil {
|
||||
m.Log.Info("Using mapped blockchain ID for C-Chain",
|
||||
log.String("original", chainParams.ID.String()),
|
||||
log.String("mapped", parsedID.String()),
|
||||
)
|
||||
chainParams.ID = parsedID
|
||||
} else {
|
||||
m.Log.Warn("Invalid chain ID mapping",
|
||||
log.String("mapping", mappedID),
|
||||
log.Err(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Register blockchain→chain mapping with the network layer so gossip
|
||||
// can resolve which validator set to use for this blockchain's blocks.
|
||||
if chainParams.ChainID != constants.PrimaryNetworkID && m.Net != nil {
|
||||
@@ -616,6 +630,35 @@ func (m *manager) createChain(chainParams ChainParameters) {
|
||||
}
|
||||
|
||||
chainAlias := m.PrimaryAliasOrDefault(chainParams.ID)
|
||||
|
||||
// Plugin-not-loaded is a deliberate skip, not a failure.
|
||||
//
|
||||
// Each validator opts into chains by loading their VM plugin
|
||||
// (Liquid loads /dex/fhe; doesn't load Lux's upstream
|
||||
// EVM plugin for C-Chain because Liquid runs its own EVM as a
|
||||
// chain on the primary's P-chain). When the chain manager hits a
|
||||
// chain whose VM isn't registered, that's the validator's "no I
|
||||
// don't validate this one" signal — log info, mark the slot
|
||||
// bootstrapped, and return WITHOUT registering a failing health
|
||||
// check. The chain stays in pending state for hot-load if the
|
||||
// plugin shows up later (e.g. via lpm install).
|
||||
//
|
||||
// The old behavior (always register a failing health check) made
|
||||
// /ext/health return 503 for any opted-out chain, which made
|
||||
// kubelet liveness probes kill the validator pod. That made
|
||||
// chain participation effectively all-or-nothing per validator.
|
||||
// Now: validators participate per-plugin, opt-in.
|
||||
if errors.Is(err, vms.ErrNotFound) {
|
||||
m.Log.Info("chain VM plugin not loaded — opting out of this chain",
|
||||
log.Stringer("chainID", chainParams.ChainID),
|
||||
log.Stringer("chainID", chainParams.ID),
|
||||
log.String("chainAlias", chainAlias),
|
||||
log.Stringer("vmID", chainParams.VMID),
|
||||
)
|
||||
sb.Bootstrapped(chainParams.ID)
|
||||
return
|
||||
}
|
||||
|
||||
m.Log.Warn("non-critical chain failed to initialize",
|
||||
log.Stringer("chainID", chainParams.ChainID),
|
||||
log.Stringer("chainID", chainParams.ID),
|
||||
@@ -624,15 +667,13 @@ func (m *manager) createChain(chainParams ChainParameters) {
|
||||
log.Err(err),
|
||||
)
|
||||
|
||||
// Non-critical chain failed (e.g. D-Chain with missing VM plugin).
|
||||
// Mark it as bootstrapped so it doesn't block the node's health check.
|
||||
// The chain-specific health check below still reports the failure.
|
||||
// Non-critical chain failed for a real reason (not missing
|
||||
// plugin). Mark it as bootstrapped so it doesn't block the
|
||||
// node-level health check, but DO register a per-chain failing
|
||||
// health check so operators see something is wrong with a chain
|
||||
// they did opt into.
|
||||
sb.Bootstrapped(chainParams.ID)
|
||||
|
||||
// Register the health check for this chain regardless of if it was
|
||||
// created or not. This attempts to notify the node operator that their
|
||||
// node may not be properly validating the net they expect to be
|
||||
// validating.
|
||||
healthCheckErr := fmt.Errorf("failed to create chain on net %s: %w", chainParams.ChainID, err)
|
||||
err := m.Health.RegisterHealthCheck(
|
||||
chainAlias,
|
||||
@@ -874,16 +915,15 @@ func (m *manager) buildChain(chainParams ChainParameters, sb nets.Net) (*chainIn
|
||||
|
||||
XChainID: m.XChainID,
|
||||
CChainID: m.CChainID,
|
||||
XAssetID: m.XAssetID,
|
||||
UTXOAssetID: m.UTXOAssetID,
|
||||
ChainDataDir: chainDataDir,
|
||||
|
||||
BCLookup: m,
|
||||
ValidatorState: getValidatorState(m.validatorState),
|
||||
SharedMemory: chainSharedMemory,
|
||||
Metrics: chainMetricsGatherer,
|
||||
Log: chainLog,
|
||||
WarpSigner: warpSigner,
|
||||
NetworkUpgrades: &m.Upgrades,
|
||||
BCLookup: m,
|
||||
ValidatorState: getValidatorState(m.validatorState),
|
||||
SharedMemory: chainSharedMemory,
|
||||
Metrics: chainMetricsGatherer,
|
||||
Log: chainLog,
|
||||
WarpSigner: warpSigner,
|
||||
}
|
||||
|
||||
// Get a factory for the vm we want to use on our chain
|
||||
@@ -992,8 +1032,12 @@ func (m *manager) buildChain(chainParams ChainParameters, sb nets.Net) (*chainIn
|
||||
}
|
||||
|
||||
// Initialize the VM if it supports the Initialize interface
|
||||
// Inject automining config for dev mode (applies to C-Chain/coreth only)
|
||||
// Inject automining config for dev mode (applies to C-Chain/coreth only),
|
||||
// then stamp the chain-wide SecurityProfile pin into the C-Chain
|
||||
// JSON config (F118) so the rpcchainvm plugin can resolve the
|
||||
// profile on its side of the boundary.
|
||||
vmConfigBytes := m.injectAutominingConfig(chainParams.VMID, chainConfig.Config)
|
||||
vmConfigBytes = m.injectSecurityProfileConfig(chainParams.VMID, vmConfigBytes)
|
||||
m.Log.Info("initializing VM", log.Stringer("chainID", chainParams.ID))
|
||||
initCtx, initCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer initCancel()
|
||||
@@ -1438,7 +1482,7 @@ func (m *manager) createDAG(
|
||||
}
|
||||
}
|
||||
|
||||
// Linearize the DAG chain (required for X-Chain post-Cortina)
|
||||
// Linearize the DAG chain (required for X-Chain).
|
||||
// This transitions the chain from DAG mode to linear block mode.
|
||||
if vmInitialized {
|
||||
if linearVM, ok := vmImpl.(interface {
|
||||
@@ -1826,6 +1870,56 @@ func (m *manager) getChainConfig(id ids.ID) (ChainConfig, error) {
|
||||
return ChainConfig{}, nil
|
||||
}
|
||||
|
||||
// injectSecurityProfileConfig stamps the chain-wide ChainSecurityProfile
|
||||
// pin into the C-Chain JSON config bytes so the coreth plugin VM can
|
||||
// resolve the profile on its side of the rpcchainvm boundary. Closes
|
||||
// red-team finding F118.
|
||||
//
|
||||
// The pin form is intentionally minimal — ProfileID byte + ProfileHash
|
||||
// hex — so this manager package does not need to round-trip the full
|
||||
// ChainSecurityProfile struct. The plugin VM calls
|
||||
// consensusconfig.ProfileByID + ComputeHash and refuses the chain if the
|
||||
// pinned hash does not match the live canonical content. Forked binaries
|
||||
// that swap canonical profile content fail the hash compare; legitimate
|
||||
// upgrades land via a new ProfileID and ProfileHash pair.
|
||||
//
|
||||
// Only applies to C-Chain (EVMID); other VMs are passed through.
|
||||
func (m *manager) injectSecurityProfileConfig(vmID ids.ID, configBytes []byte) []byte {
|
||||
if m.SecurityProfile == nil {
|
||||
return configBytes
|
||||
}
|
||||
if vmID != constants.EVMID {
|
||||
return configBytes
|
||||
}
|
||||
|
||||
// Parse existing config or create empty object.
|
||||
var cfg map[string]interface{}
|
||||
if len(configBytes) > 0 {
|
||||
if err := json.Unmarshal(configBytes, &cfg); err != nil {
|
||||
m.Log.Warn("failed to parse C-Chain config for security profile injection, creating new config",
|
||||
log.Err(err))
|
||||
cfg = make(map[string]interface{})
|
||||
}
|
||||
} else {
|
||||
cfg = make(map[string]interface{})
|
||||
}
|
||||
|
||||
cfg["lux-security-profile"] = map[string]interface{}{
|
||||
"profileID": m.SecurityProfile.ProfileID & 0xff,
|
||||
"profileHashHex": fmt.Sprintf("%x", m.SecurityProfile.ProfileHash[:]),
|
||||
}
|
||||
|
||||
out, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
m.Log.Warn("failed to marshal C-Chain config after security profile injection", log.Err(err))
|
||||
return configBytes
|
||||
}
|
||||
m.Log.Info("injected chain security profile pin into C-Chain plugin config",
|
||||
log.Uint32("profileID", m.SecurityProfile.ProfileID),
|
||||
log.String("profileName", m.SecurityProfile.ProfileName))
|
||||
return out
|
||||
}
|
||||
|
||||
// injectAutominingConfig modifies the config bytes to include enable-automining flag
|
||||
// when dev mode automining is enabled. This is used for C-Chain (coreth) to enable
|
||||
// anvil-like block production behavior.
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
//
|
||||
// F118 regression coverage. Asserts the chain manager stamps the
|
||||
// chain-wide ChainSecurityProfile pin into the C-Chain JSON config
|
||||
// bytes so the rpcchainvm plugin (coreth) can resolve the profile on
|
||||
// its side of the boundary.
|
||||
|
||||
package chains
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
consensusconfig "github.com/luxfi/consensus/config"
|
||||
"github.com/luxfi/constants"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// canonicalStrictPQProfile returns the live canonical StrictPQ profile
|
||||
// with ProfileHash populated — the same form node.go builds during
|
||||
// initSecurityProfile.
|
||||
func canonicalStrictPQProfile(t *testing.T) *consensusconfig.ChainSecurityProfile {
|
||||
t.Helper()
|
||||
p, err := consensusconfig.ProfileByID(consensusconfig.ProfileStrictPQ)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, p.Validate())
|
||||
h, err := p.ComputeHash()
|
||||
require.NoError(t, err)
|
||||
p.ProfileHash = h
|
||||
return p
|
||||
}
|
||||
|
||||
// TestInjectSecurityProfileConfig_StampsCChainOnly asserts the F118
|
||||
// injection pass stamps the security profile pin into the C-Chain
|
||||
// (EVMID) plugin config and is a no-op for every other VM ID. Other
|
||||
// VMs receive the config bytes unchanged.
|
||||
func TestInjectSecurityProfileConfig_StampsCChainOnly(t *testing.T) {
|
||||
require := require.New(t)
|
||||
profile := canonicalStrictPQProfile(t)
|
||||
|
||||
m := &manager{
|
||||
ManagerConfig: ManagerConfig{
|
||||
Log: log.NewNoOpLogger(),
|
||||
SecurityProfile: profile,
|
||||
},
|
||||
}
|
||||
|
||||
// C-Chain (EVMID): pin must be stamped.
|
||||
out := m.injectSecurityProfileConfig(constants.EVMID, nil)
|
||||
require.NotEmpty(out, "C-Chain config must be stamped with the security profile pin")
|
||||
|
||||
var got map[string]interface{}
|
||||
require.NoError(json.Unmarshal(out, &got))
|
||||
|
||||
pin, ok := got["lux-security-profile"].(map[string]interface{})
|
||||
require.True(ok, "C-Chain config must carry lux-security-profile object")
|
||||
require.EqualValues(uint32(consensusconfig.ProfileStrictPQ), uint32(pin["profileID"].(float64)),
|
||||
"profileID must round-trip the canonical StrictPQ byte")
|
||||
require.Equal(hex.EncodeToString(profile.ProfileHash[:]), pin["profileHashHex"],
|
||||
"profileHashHex must round-trip the canonical 48-byte hash")
|
||||
|
||||
// Non-EVM VM (PlatformVMID): no-op.
|
||||
original := []byte(`{"some": "config"}`)
|
||||
pOut := m.injectSecurityProfileConfig(constants.PlatformVMID, original)
|
||||
require.Equal(original, pOut, "non-C-Chain config must pass through unchanged")
|
||||
}
|
||||
|
||||
// TestInjectSecurityProfileConfig_NoOpWhenProfileNil asserts the F118
|
||||
// injection is a no-op when SecurityProfile is nil (classical-compat
|
||||
// chains, legacy networks pre-locked-profile).
|
||||
func TestInjectSecurityProfileConfig_NoOpWhenProfileNil(t *testing.T) {
|
||||
require := require.New(t)
|
||||
m := &manager{
|
||||
ManagerConfig: ManagerConfig{
|
||||
Log: log.NewNoOpLogger(),
|
||||
SecurityProfile: nil,
|
||||
},
|
||||
}
|
||||
|
||||
original := []byte(`{"some": "config"}`)
|
||||
out := m.injectSecurityProfileConfig(constants.EVMID, original)
|
||||
require.Equal(original, out, "no-op pass-through under nil SecurityProfile")
|
||||
|
||||
out = m.injectSecurityProfileConfig(constants.EVMID, nil)
|
||||
require.Empty(out, "no-op pass-through preserves nil bytes")
|
||||
}
|
||||
|
||||
// TestInjectSecurityProfileConfig_MergesExistingConfig asserts the F118
|
||||
// injection preserves pre-existing C-Chain config keys (lux-strict-pq
|
||||
// shim, enable-automining, etc.) while adding the new lux-security-profile
|
||||
// pin alongside.
|
||||
func TestInjectSecurityProfileConfig_MergesExistingConfig(t *testing.T) {
|
||||
require := require.New(t)
|
||||
profile := canonicalStrictPQProfile(t)
|
||||
m := &manager{
|
||||
ManagerConfig: ManagerConfig{
|
||||
Log: log.NewNoOpLogger(),
|
||||
SecurityProfile: profile,
|
||||
},
|
||||
}
|
||||
|
||||
original := []byte(`{"enable-automining": true, "skip-block-fee": true}`)
|
||||
out := m.injectSecurityProfileConfig(constants.EVMID, original)
|
||||
|
||||
var got map[string]interface{}
|
||||
require.NoError(json.Unmarshal(out, &got))
|
||||
|
||||
require.Equal(true, got["enable-automining"], "automining flag must survive")
|
||||
require.Equal(true, got["skip-block-fee"], "skip-block-fee flag must survive")
|
||||
require.NotNil(got["lux-security-profile"], "security profile pin must be added")
|
||||
}
|
||||
|
||||
// TestInjectSecurityProfileConfig_RoundTripsAcrossPluginBoundary
|
||||
// asserts the wire form coreth expects (security-profile pin with
|
||||
// profileID + profileHashHex) matches the form the chain manager
|
||||
// emits, so the rpcchainvm plugin can decode the pin via standard
|
||||
// JSON unmarshalling. Closes the wire-compat axis of F118.
|
||||
func TestInjectSecurityProfileConfig_RoundTripsAcrossPluginBoundary(t *testing.T) {
|
||||
require := require.New(t)
|
||||
profile := canonicalStrictPQProfile(t)
|
||||
m := &manager{
|
||||
ManagerConfig: ManagerConfig{
|
||||
Log: log.NewNoOpLogger(),
|
||||
SecurityProfile: profile,
|
||||
},
|
||||
}
|
||||
|
||||
out := m.injectSecurityProfileConfig(constants.EVMID, nil)
|
||||
|
||||
// Mirror of plugin/evm/config.Config — locally redefined so this
|
||||
// test does not import the coreth plugin tree.
|
||||
type localPin struct {
|
||||
ProfileID uint8 `json:"profileID"`
|
||||
ProfileHashHex string `json:"profileHashHex"`
|
||||
}
|
||||
type localCorethConfig struct {
|
||||
SecurityProfile *localPin `json:"lux-security-profile,omitempty"`
|
||||
}
|
||||
|
||||
var cfg localCorethConfig
|
||||
require.NoError(json.Unmarshal(out, &cfg))
|
||||
require.NotNil(cfg.SecurityProfile, "coreth-side config struct must decode the pin")
|
||||
require.Equal(uint8(consensusconfig.ProfileStrictPQ), cfg.SecurityProfile.ProfileID)
|
||||
require.Equal(hex.EncodeToString(profile.ProfileHash[:]), cfg.SecurityProfile.ProfileHashHex)
|
||||
|
||||
// Decoded hex must be exactly 48 bytes (SHA3-384 width).
|
||||
hashBytes, err := hex.DecodeString(cfg.SecurityProfile.ProfileHashHex)
|
||||
require.NoError(err)
|
||||
require.Len(hashBytes, 48, "wire pin must carry a 48-byte SHA3-384 ProfileHash")
|
||||
|
||||
// Use ids.ID directly to silence the unused import warning in case
|
||||
// other tests in this file don't reach it.
|
||||
_ = ids.ID{}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
//go:build tools
|
||||
// +build tools
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/node/upgrade"
|
||||
"github.com/luxfi/runtime"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var c *upgrade.Config = &upgrade.Config{}
|
||||
var _ runtime.NetworkUpgrades = c
|
||||
fmt.Println("Interface satisfied")
|
||||
}
|
||||
@@ -28,8 +28,8 @@ func main() {
|
||||
"networkID": 200200,
|
||||
"allocations": []map[string]interface{}{
|
||||
{
|
||||
"ethAddr": "0x9011E888251AB053B7bD1cdB598Db4f9DEd94714",
|
||||
"luxAddr": zooAddr,
|
||||
"evmAddr": "0x9011E888251AB053B7bD1cdB598Db4f9DEd94714",
|
||||
"utxoAddr": zooAddr,
|
||||
"initialAmount": 0,
|
||||
"unlockSchedule": []map[string]interface{}{
|
||||
{
|
||||
@@ -47,7 +47,7 @@ func main() {
|
||||
}
|
||||
|
||||
// Load validators from Lux genesis
|
||||
luxGenesis, _ := os.ReadFile("/Users/z/work/lux/mainnet/genesis_mainnet.json")
|
||||
luxGenesis, _ := os.ReadFile(os.ExpandEnv("$HOME/work/lux/mainnet/genesis_mainnet.json"))
|
||||
var lux map[string]interface{}
|
||||
json.Unmarshal(luxGenesis, &lux)
|
||||
|
||||
@@ -76,6 +76,6 @@ func main() {
|
||||
genesis["cChainGenesis"] = string(ccBytes)
|
||||
|
||||
out, _ := json.MarshalIndent(genesis, "", " ")
|
||||
os.WriteFile("/Users/z/work/lux/mainnet/zoo_genesis_valid.json", out, 0644)
|
||||
os.WriteFile(os.ExpandEnv("$HOME/work/lux/mainnet/zoo_genesis_valid.json"), out, 0644)
|
||||
fmt.Println("Wrote zoo_genesis_valid.json")
|
||||
}
|
||||
|
||||
+227
-378
@@ -4,10 +4,12 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -24,7 +26,8 @@ import (
|
||||
"github.com/luxfi/constants"
|
||||
"github.com/luxfi/crypto/bls"
|
||||
"github.com/luxfi/crypto/bls/signer/localsigner"
|
||||
"github.com/luxfi/crypto/hash"
|
||||
"github.com/luxfi/crypto/mldsa"
|
||||
mlkemcrypto "github.com/luxfi/crypto/mlkem"
|
||||
"github.com/luxfi/filesystem/perms"
|
||||
"github.com/luxfi/filesystem/storage"
|
||||
"github.com/luxfi/ids"
|
||||
@@ -47,7 +50,6 @@ import (
|
||||
"github.com/luxfi/node/version"
|
||||
"github.com/luxfi/node/vms/components/gas"
|
||||
"github.com/luxfi/node/vms/platformvm/reward"
|
||||
pchaintxs "github.com/luxfi/node/vms/platformvm/txs"
|
||||
"github.com/luxfi/node/vms/platformvm/validators/fee"
|
||||
"github.com/luxfi/node/vms/proposervm"
|
||||
"github.com/luxfi/timer"
|
||||
@@ -95,90 +97,16 @@ var (
|
||||
errFileDoesNotExist = errors.New("file does not exist")
|
||||
)
|
||||
|
||||
// AutomineNetworkConfig captures immutable network state for automine mode persistence.
|
||||
// Once written to automine-network.json, this ensures the same genesis is produced
|
||||
// on every restart, making C-Chain/EVM state persistence work correctly.
|
||||
type AutomineNetworkConfig struct {
|
||||
// Version for forward compatibility
|
||||
Version int `json:"version"`
|
||||
|
||||
// Genesis start time - captured on first boot, reused on restart
|
||||
StartTime uint64 `json:"startTime"`
|
||||
|
||||
// Node identity
|
||||
NodeID string `json:"nodeId"`
|
||||
|
||||
// BLS credentials (hex-encoded)
|
||||
BLSPublicKey string `json:"blsPublicKey"`
|
||||
BLSPopProof string `json:"blsPopProof"`
|
||||
|
||||
// Computed genesis bytes and hash (for verification)
|
||||
GenesisBytes []byte `json:"genesisBytes"`
|
||||
GenesisHash string `json:"genesisHash"` // Actual hash of GenesisBytes
|
||||
|
||||
// X-Chain asset ID (LUX token ID)
|
||||
XAssetID string `json:"xAssetId"`
|
||||
|
||||
// C-Chain genesis (stored separately for EVM immutability)
|
||||
CChainGenesis string `json:"cChainGenesis"`
|
||||
}
|
||||
|
||||
const (
|
||||
devNetworkConfigVersion = 1
|
||||
devNetworkConfigFilename = "automine-network.json"
|
||||
)
|
||||
|
||||
// loadAutomineNetworkConfig attempts to load automine-network.json from the data directory.
|
||||
// Returns nil if the file doesn't exist (first boot scenario).
|
||||
func loadAutomineNetworkConfig(dataDir string) (*AutomineNetworkConfig, error) {
|
||||
path := filepath.Join(dataDir, devNetworkConfigFilename)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil // First boot - no config yet
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read dev network config: %w", err)
|
||||
}
|
||||
|
||||
var cfg AutomineNetworkConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse dev network config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Version != devNetworkConfigVersion {
|
||||
return nil, fmt.Errorf("unsupported dev network config version: %d (expected %d)", cfg.Version, devNetworkConfigVersion)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// saveAutomineNetworkConfig atomically writes the dev network config to disk.
|
||||
// Uses write-to-temp-then-rename pattern for crash safety.
|
||||
func saveAutomineNetworkConfig(dataDir string, cfg *AutomineNetworkConfig) error {
|
||||
path := filepath.Join(dataDir, devNetworkConfigFilename)
|
||||
tempPath := path + ".tmp"
|
||||
|
||||
cfg.Version = devNetworkConfigVersion
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal dev network config: %w", err)
|
||||
}
|
||||
|
||||
// Write to temp file
|
||||
if err := os.WriteFile(tempPath, data, 0o600); err != nil {
|
||||
return fmt.Errorf("failed to write temp dev network config: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename
|
||||
if err := os.Rename(tempPath, path); err != nil {
|
||||
os.Remove(tempPath) // Clean up on failure
|
||||
return fmt.Errorf("failed to rename dev network config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
// (Removed: AutomineNetworkConfig + loadAutomineNetworkConfig +
|
||||
// saveAutomineNetworkConfig + automine-network.json persistence.)
|
||||
//
|
||||
// --automine is a *consensus-mode* flag (single-node, single-validator
|
||||
// quorum), nothing more. It MUST NOT swap in a different C-Chain
|
||||
// genesis or persist any "dev network config" sidecar. C-Chain
|
||||
// genesis is canonical genesis — for network-id 1/2/3/1337 it's the
|
||||
// embedded luxfi/genesis config, period. If the operator wants a
|
||||
// custom genesis they pass --genesis-file. No backwards compatibility
|
||||
// for the deleted automine-network.json sidecar.
|
||||
|
||||
func getConsensusConfig(v *viper.Viper) consensusconfig.Parameters {
|
||||
// Start with default parameters
|
||||
@@ -823,6 +751,135 @@ func getStakingSigner(v *viper.Viper) (bls.Signer, error) {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// loadPEMBlock returns the body of a single PEM block matching
|
||||
// expectType. Refuses to silently consume the wrong block type so a
|
||||
// misnamed file (e.g. ML-DSA private key supplied as a public-key
|
||||
// path) fails loud at config-load instead of producing a NodeID under
|
||||
// the wrong key.
|
||||
func loadPEMBlock(path, expectType string) ([]byte, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("%s: not a PEM file", path)
|
||||
}
|
||||
if block.Type != expectType {
|
||||
return nil, fmt.Errorf("%s: PEM type %q is not %s", path, block.Type, expectType)
|
||||
}
|
||||
return block.Bytes, nil
|
||||
}
|
||||
|
||||
// pemBytesOrFile resolves either a base64-encoded PEM in
|
||||
// contentKey (highest precedence, matches the
|
||||
// --foo-file-content / --foo-file pattern the existing TLS
|
||||
// loaders use) or a filesystem path in pathKey. Empty return =
|
||||
// neither was set; caller decides whether that's a fatal config
|
||||
// error or a "fall through to classical-compat" path.
|
||||
func pemBytesOrFile(v *viper.Viper, contentKey, pathKey, expectType string) ([]byte, string, error) {
|
||||
if v.IsSet(contentKey) {
|
||||
raw := v.GetString(contentKey)
|
||||
if raw == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decode %s base64: %w", contentKey, err)
|
||||
}
|
||||
block, _ := pem.Decode(decoded)
|
||||
if block == nil {
|
||||
return nil, "", fmt.Errorf("%s: decoded value is not PEM", contentKey)
|
||||
}
|
||||
if block.Type != expectType {
|
||||
return nil, "", fmt.Errorf("%s: PEM type %q is not %s", contentKey, block.Type, expectType)
|
||||
}
|
||||
return block.Bytes, "<from-content>", nil
|
||||
}
|
||||
path := getExpandedArg(v, pathKey)
|
||||
if path == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, path, nil
|
||||
} else if err != nil {
|
||||
return nil, path, err
|
||||
}
|
||||
body, err := loadPEMBlock(path, expectType)
|
||||
return body, path, err
|
||||
}
|
||||
|
||||
// loadStakingMLDSA returns the strict-PQ ML-DSA-65 keypair (if any).
|
||||
// Both private and public materials are optional at the config layer
|
||||
// — a missing pair means classical-compat mode. A strict-PQ chain
|
||||
// profile enforces presence at the validator-set boundary.
|
||||
func loadStakingMLDSA(v *viper.Viper) (*mldsa.PrivateKey, []byte, string, string, error) {
|
||||
privBytes, privPath, err := pemBytesOrFile(v,
|
||||
StakingMLDSAKeyContentKey, StakingMLDSAKeyPathKey, "ML-DSA-65 PRIVATE KEY")
|
||||
if err != nil {
|
||||
return nil, nil, privPath, "", fmt.Errorf("staking-mldsa-key: %w", err)
|
||||
}
|
||||
pubBytes, pubPath, err := pemBytesOrFile(v,
|
||||
StakingMLDSAPubKeyContentKey, StakingMLDSAPubKeyPathKey, "ML-DSA-65 PUBLIC KEY")
|
||||
if err != nil {
|
||||
return nil, nil, privPath, pubPath, fmt.Errorf("staking-mldsa-pub-key: %w", err)
|
||||
}
|
||||
if len(privBytes) == 0 && len(pubBytes) == 0 {
|
||||
return nil, nil, privPath, pubPath, nil
|
||||
}
|
||||
// Asymmetry is a config error: a strict-PQ chain MUST have both
|
||||
// the private key (for signing) and the public key (so peers can
|
||||
// verify what we signed). Refuse rather than silently degrade.
|
||||
if len(privBytes) == 0 || len(pubBytes) == 0 {
|
||||
return nil, nil, privPath, pubPath, errors.New("staking-mldsa: both private and public key are required (or neither for classical-compat)")
|
||||
}
|
||||
priv, err := mldsa.PrivateKeyFromBytes(mldsa.MLDSA65, privBytes)
|
||||
if err != nil {
|
||||
return nil, nil, privPath, pubPath, fmt.Errorf("parse staking-mldsa private key: %w", err)
|
||||
}
|
||||
// Sanity: the public key on disk must match the one derivable
|
||||
// from the private key. Mismatch would point a NodeID at a
|
||||
// public key the validator cannot actually sign for — silent
|
||||
// authentication failure that the consensus engine would later report as
|
||||
// "this validator is offline" rather than "you misconfigured
|
||||
// your keys".
|
||||
derivedPubBytes := priv.PublicKey.Bytes()
|
||||
if !bytes.Equal(derivedPubBytes, pubBytes) {
|
||||
return nil, nil, privPath, pubPath, errors.New("staking-mldsa: public key on disk does not match the public key derived from the private key")
|
||||
}
|
||||
return priv, pubBytes, privPath, pubPath, nil
|
||||
}
|
||||
|
||||
// loadHandshakeMLKEM returns the strict-PQ ML-KEM-768 KEM keypair
|
||||
// (if any). Same shape + invariants as loadStakingMLDSA.
|
||||
func loadHandshakeMLKEM(v *viper.Viper) (*mlkemcrypto.PrivateKey, []byte, string, string, error) {
|
||||
privBytes, privPath, err := pemBytesOrFile(v,
|
||||
HandshakeMLKEMKeyContentKey, HandshakeMLKEMKeyPathKey, "ML-KEM-768 PRIVATE KEY")
|
||||
if err != nil {
|
||||
return nil, nil, privPath, "", fmt.Errorf("handshake-mlkem-key: %w", err)
|
||||
}
|
||||
pubBytes, pubPath, err := pemBytesOrFile(v,
|
||||
HandshakeMLKEMPubKeyContentKey, HandshakeMLKEMPubKeyPathKey, "ML-KEM-768 PUBLIC KEY")
|
||||
if err != nil {
|
||||
return nil, nil, privPath, pubPath, fmt.Errorf("handshake-mlkem-pub-key: %w", err)
|
||||
}
|
||||
if len(privBytes) == 0 && len(pubBytes) == 0 {
|
||||
return nil, nil, privPath, pubPath, nil
|
||||
}
|
||||
if len(privBytes) == 0 || len(pubBytes) == 0 {
|
||||
return nil, nil, privPath, pubPath, errors.New("handshake-mlkem: both private and public key are required (or neither for classical-compat)")
|
||||
}
|
||||
priv, err := mlkemcrypto.PrivateKeyFromBytes(privBytes, mlkemcrypto.MLKEM768)
|
||||
if err != nil {
|
||||
return nil, nil, privPath, pubPath, fmt.Errorf("parse handshake-mlkem private key: %w", err)
|
||||
}
|
||||
derivedPubBytes := priv.PublicKey().Bytes()
|
||||
if !bytes.Equal(derivedPubBytes, pubBytes) {
|
||||
return nil, nil, privPath, pubPath, errors.New("handshake-mlkem: public key on disk does not match the public key derived from the private key")
|
||||
}
|
||||
return priv, pubBytes, privPath, pubPath, nil
|
||||
}
|
||||
|
||||
func getStakingConfig(v *viper.Viper, networkID uint32) (node.StakingConfig, error) {
|
||||
config := node.StakingConfig{
|
||||
SybilProtectionEnabled: v.GetBool(SybilProtectionEnabledKey),
|
||||
@@ -849,6 +906,31 @@ func getStakingConfig(v *viper.Viper, networkID uint32) (node.StakingConfig, err
|
||||
if err != nil {
|
||||
return node.StakingConfig{}, err
|
||||
}
|
||||
|
||||
// Strict-PQ identity (FIPS 204 ML-DSA-65 + FIPS 203 ML-KEM-768).
|
||||
// Both pairs are optional at this layer — classical-compat chains
|
||||
// run without them. Strict-PQ profile rejects a missing pair at
|
||||
// the validator-set boundary; that gate lives in consensus/config
|
||||
// (the profile gate that owns the strict-PQ vs classical-compat
|
||||
// dispatch), not here in node-config land.
|
||||
mldsaPriv, mldsaPub, mldsaPrivPath, mldsaPubPath, err := loadStakingMLDSA(v)
|
||||
if err != nil {
|
||||
return node.StakingConfig{}, err
|
||||
}
|
||||
config.StakingMLDSA = mldsaPriv
|
||||
config.StakingMLDSAPub = mldsaPub
|
||||
config.StakingMLDSAKeyPath = mldsaPrivPath
|
||||
config.StakingMLDSAPubPath = mldsaPubPath
|
||||
|
||||
mlkemPriv, mlkemPub, mlkemPrivPath, mlkemPubPath, err := loadHandshakeMLKEM(v)
|
||||
if err != nil {
|
||||
return node.StakingConfig{}, err
|
||||
}
|
||||
config.HandshakeMLKEMPriv = mlkemPriv
|
||||
config.HandshakeMLKEMPub = mlkemPub
|
||||
config.HandshakeMLKEMKeyPath = mlkemPrivPath
|
||||
config.HandshakeMLKEMPubPath = mlkemPubPath
|
||||
|
||||
if networkID != constants.MainnetID && networkID != constants.TestnetID {
|
||||
config.UptimeRequirement = v.GetFloat64(UptimeRequirementKey)
|
||||
config.MinValidatorStake = v.GetUint64(MinValidatorStakeKey)
|
||||
@@ -957,13 +1039,10 @@ func getUpgradeConfig(v *viper.Viper, networkID uint32) (upgrade.Config, error)
|
||||
}
|
||||
|
||||
func getGenesisData(v *viper.Viper, networkID uint32, stakingCfg *builder.StakingConfig, dataDir string) ([]byte, ids.ID, error) {
|
||||
// Get allow-custom-genesis flag (defaults to true for development)
|
||||
allowCustomGenesis := v.GetBool(AllowCustomGenesisKey)
|
||||
|
||||
// Handle automine mode genesis - dynamically generate genesis with the node's own credentials
|
||||
if v.GetBool(DevModeKey) && !v.IsSet(GenesisFileKey) && !v.IsSet(GenesisFileContentKey) && !v.IsSet(GenesisDBKey) {
|
||||
return getOrCreateAutomineGenesis(stakingCfg, dataDir)
|
||||
}
|
||||
// (Removed: automine-mode genesis swap.) --automine is single-node
|
||||
// consensus only; it falls through to the canonical genesis loader
|
||||
// below — same as a real network. C-Chain genesis comes from the
|
||||
// canonical luxfi/genesis embedded config or --genesis-file. Period.
|
||||
|
||||
// HIGHEST PRIORITY: Raw genesis bytes - use directly without rebuilding
|
||||
// This is critical for snapshot resume to avoid hash mismatch
|
||||
@@ -973,16 +1052,15 @@ func getGenesisData(v *viper.Viper, networkID uint32, stakingCfg *builder.Stakin
|
||||
if err != nil {
|
||||
return nil, ids.Empty, fmt.Errorf("failed to decode %s: %w", GenesisRawBytesKey, err)
|
||||
}
|
||||
// Extract xAssetID from the X-chain genesis within the platform genesis
|
||||
xAssetID, err := extractXAssetID(genesisBytes)
|
||||
utxoAssetID, err := resolveXAssetID(networkID, genesisBytes)
|
||||
if err != nil {
|
||||
return nil, ids.Empty, fmt.Errorf("failed to extract xAssetID from raw genesis bytes: %w", err)
|
||||
return nil, ids.Empty, fmt.Errorf("resolve X-Chain asset ID from %s: %w", GenesisRawBytesKey, err)
|
||||
}
|
||||
log.Info("loaded raw genesis bytes directly",
|
||||
"size", len(genesisBytes),
|
||||
"xAssetID", xAssetID,
|
||||
"utxoAssetID", utxoAssetID,
|
||||
)
|
||||
return genesisBytes, xAssetID, nil
|
||||
return genesisBytes, utxoAssetID, nil
|
||||
}
|
||||
|
||||
// Check if genesis-db is specified for database replay
|
||||
@@ -1004,7 +1082,7 @@ func getGenesisData(v *viper.Viper, networkID uint32, stakingCfg *builder.Stakin
|
||||
// try first loading genesis content directly from flag/env-var
|
||||
if v.IsSet(GenesisFileContentKey) {
|
||||
genesisData := v.GetString(GenesisFileContentKey)
|
||||
return builder.FromFlag(networkID, genesisData, stakingCfg, allowCustomGenesis)
|
||||
return builder.FromFlag(networkID, genesisData, stakingCfg)
|
||||
}
|
||||
|
||||
// if content is not specified go for the file
|
||||
@@ -1013,21 +1091,19 @@ func getGenesisData(v *viper.Viper, networkID uint32, stakingCfg *builder.Stakin
|
||||
// Check if we have cached genesis bytes to avoid rebuilding
|
||||
cacheFile := filepath.Join(dataDir, "genesis.bytes")
|
||||
if cachedBytes, err := loadCachedGenesisBytes(cacheFile); err == nil && len(cachedBytes) > 0 {
|
||||
xAssetID, err := extractXAssetID(cachedBytes)
|
||||
utxoAssetID, err := resolveXAssetID(networkID, cachedBytes)
|
||||
if err != nil {
|
||||
log.Warn("failed to extract xAssetID from cached genesis, rebuilding",
|
||||
"error", err,
|
||||
)
|
||||
} else {
|
||||
log.Info("loaded cached genesis bytes for hash stability",
|
||||
"cacheFile", cacheFile,
|
||||
"size", len(cachedBytes),
|
||||
)
|
||||
return cachedBytes, xAssetID, nil
|
||||
return nil, ids.Empty, fmt.Errorf("resolve X-Chain asset ID from cached genesis: %w", err)
|
||||
}
|
||||
log.Info("loaded cached genesis bytes for hash stability",
|
||||
"cacheFile", cacheFile,
|
||||
"size", len(cachedBytes),
|
||||
"utxoAssetID", utxoAssetID,
|
||||
)
|
||||
return cachedBytes, utxoAssetID, nil
|
||||
}
|
||||
// No cache or invalid cache - build from file and cache the result
|
||||
genesisBytes, xAssetID, err := builder.FromFile(networkID, genesisFileName, stakingCfg, allowCustomGenesis)
|
||||
genesisBytes, utxoAssetID, err := builder.FromFile(networkID, genesisFileName, stakingCfg)
|
||||
if err != nil {
|
||||
return nil, ids.Empty, err
|
||||
}
|
||||
@@ -1042,7 +1118,7 @@ func getGenesisData(v *viper.Viper, networkID uint32, stakingCfg *builder.Stakin
|
||||
"size", len(genesisBytes),
|
||||
)
|
||||
}
|
||||
return genesisBytes, xAssetID, nil
|
||||
return genesisBytes, utxoAssetID, nil
|
||||
}
|
||||
|
||||
// finally if file is not specified/readable go for the predefined config
|
||||
@@ -1050,248 +1126,36 @@ func getGenesisData(v *viper.Viper, networkID uint32, stakingCfg *builder.Stakin
|
||||
return builder.FromConfig(config)
|
||||
}
|
||||
|
||||
// getOrCreateAutomineGenesis handles automine mode genesis with persistence.
|
||||
// On first boot: generates genesis, saves to automine-network.json, returns genesis.
|
||||
// On restart: loads from automine-network.json to ensure genesis hash stability.
|
||||
// Returns: (genesisBytes, xAssetID, error) - xAssetID is the LUX token asset ID
|
||||
func getOrCreateAutomineGenesis(stakingCfg *builder.StakingConfig, dataDir string) ([]byte, ids.ID, error) {
|
||||
// Try to load existing dev network config
|
||||
devCfg, err := loadAutomineNetworkConfig(dataDir)
|
||||
// resolveXAssetID extracts the X-Chain native asset ID from the loaded
|
||||
// platform-genesis blob. It is the canonical source of truth used by
|
||||
// every load path that bypasses FromConfig (raw bytes, cached bytes —
|
||||
// the paths that historically defaulted to constants.UTXOAssetIDFor and
|
||||
// silently disagreed with the genesis content on sovereign L1s).
|
||||
//
|
||||
// Behaviour:
|
||||
//
|
||||
// - If the genesis bakes an X-Chain, returns the runtime asset ID
|
||||
// derived from that chain's CreateAssetTx (the same value
|
||||
// vm.initGenesis assigns at runtime).
|
||||
// - If the genesis is P-only (no X-Chain), returns the network-id-
|
||||
// keyed constant. The asset ID is unused in that mode.
|
||||
// - If the genesis is unparseable or the embedded X-Chain genesis
|
||||
// is malformed, returns the corresponding error — these are
|
||||
// unrecoverable on a primary-network bootstrap.
|
||||
//
|
||||
// Tested against both sovereign-network genesis (X-Chain present, asset
|
||||
// ID differs from the constant) and upstream Lux genesis fixtures.
|
||||
func resolveXAssetID(networkID uint32, genesisBytes []byte) (ids.ID, error) {
|
||||
id, ok, err := builder.XAssetIDFromGenesisBytes(genesisBytes)
|
||||
if err != nil {
|
||||
return nil, ids.Empty, fmt.Errorf("failed to load dev network config: %w", err)
|
||||
return ids.Empty, err
|
||||
}
|
||||
|
||||
if devCfg != nil {
|
||||
// Existing config found - check if credentials match
|
||||
// In automine mode with ephemeral certs, credentials will change on restart.
|
||||
// We log a warning but continue with the stored genesis since automine mode
|
||||
// is single-node and doesn't require credential consistency.
|
||||
expectedNodeID := stakingCfg.NodeID
|
||||
expectedBLSPK := fmt.Sprintf("0x%x", stakingCfg.BLSPublicKey)
|
||||
expectedBLSPoP := fmt.Sprintf("0x%x", stakingCfg.BLSProofOfPossession)
|
||||
|
||||
credentialsMismatch := false
|
||||
if devCfg.NodeID != expectedNodeID {
|
||||
log.Warn("automine-network.json nodeID mismatch (using stored genesis anyway for automine mode)",
|
||||
"stored", devCfg.NodeID,
|
||||
"current", expectedNodeID,
|
||||
)
|
||||
credentialsMismatch = true
|
||||
}
|
||||
if devCfg.BLSPublicKey != expectedBLSPK {
|
||||
log.Warn("automine-network.json BLS public key mismatch (using stored genesis anyway for automine mode)")
|
||||
credentialsMismatch = true
|
||||
}
|
||||
if devCfg.BLSPopProof != expectedBLSPoP {
|
||||
log.Warn("automine-network.json BLS PoP mismatch (using stored genesis anyway for automine mode)")
|
||||
credentialsMismatch = true
|
||||
}
|
||||
if credentialsMismatch {
|
||||
log.Warn("credentials changed since first boot - for persistent staking, use --staking-ephemeral-cert-enabled=false with persistent key files")
|
||||
}
|
||||
|
||||
// Verify the stored genesis hash matches what we'll compute from the bytes
|
||||
storedHash, err := ids.FromString(devCfg.GenesisHash)
|
||||
if err != nil {
|
||||
return nil, ids.Empty, fmt.Errorf("invalid genesis hash in automine-network.json: %w", err)
|
||||
}
|
||||
|
||||
// Compute actual hash from stored bytes to verify integrity
|
||||
computedHashBytes := hash.ComputeHash256(devCfg.GenesisBytes)
|
||||
computedHash, err := ids.ToID(computedHashBytes)
|
||||
if err != nil {
|
||||
return nil, ids.Empty, fmt.Errorf("failed to convert computed hash to ID: %w", err)
|
||||
}
|
||||
|
||||
if storedHash != computedHash {
|
||||
return nil, ids.Empty, fmt.Errorf("genesis bytes corrupted: stored hash %s != computed hash %s", storedHash, computedHash)
|
||||
}
|
||||
|
||||
// Parse stored X-Chain asset ID
|
||||
xAssetID, err := ids.FromString(devCfg.XAssetID)
|
||||
if err != nil {
|
||||
return nil, ids.Empty, fmt.Errorf("invalid xAssetId in automine-network.json: %w", err)
|
||||
}
|
||||
|
||||
log.Info("loaded dev network config",
|
||||
"path", filepath.Join(dataDir, devNetworkConfigFilename),
|
||||
"genesisHash", devCfg.GenesisHash,
|
||||
"xAssetID", devCfg.XAssetID,
|
||||
"startTime", devCfg.StartTime,
|
||||
)
|
||||
|
||||
return devCfg.GenesisBytes, xAssetID, nil
|
||||
if !ok {
|
||||
return constants.UTXOAssetIDFor(networkID), nil
|
||||
}
|
||||
|
||||
// First boot - generate new genesis with current timestamp
|
||||
startTime := uint64(time.Now().Unix())
|
||||
|
||||
// buildAutomineGenesis returns (genesisBytes, xAssetID) - the LUX token asset ID
|
||||
genesisBytes, xAssetID, err := buildAutomineGenesis(stakingCfg, startTime)
|
||||
if err != nil {
|
||||
return nil, ids.Empty, err
|
||||
}
|
||||
|
||||
// Compute actual genesis hash from bytes (this is what the node uses for DB validation)
|
||||
genesisHashBytes := hash.ComputeHash256(genesisBytes)
|
||||
genesisHash, err := ids.ToID(genesisHashBytes)
|
||||
if err != nil {
|
||||
return nil, ids.Empty, fmt.Errorf("failed to convert genesis hash to ID: %w", err)
|
||||
}
|
||||
|
||||
// Save for future restarts
|
||||
newDevCfg := &AutomineNetworkConfig{
|
||||
Version: devNetworkConfigVersion,
|
||||
StartTime: startTime,
|
||||
NodeID: stakingCfg.NodeID,
|
||||
BLSPublicKey: fmt.Sprintf("0x%x", stakingCfg.BLSPublicKey),
|
||||
BLSPopProof: fmt.Sprintf("0x%x", stakingCfg.BLSProofOfPossession),
|
||||
GenesisBytes: genesisBytes,
|
||||
GenesisHash: genesisHash.String(),
|
||||
XAssetID: xAssetID.String(),
|
||||
CChainGenesis: automineCChainGenesis,
|
||||
}
|
||||
|
||||
if err := saveAutomineNetworkConfig(dataDir, newDevCfg); err != nil {
|
||||
// Log warning but don't fail - genesis is still valid
|
||||
log.Warn("failed to save dev network config (persistence may not work on restart)",
|
||||
"error", err,
|
||||
)
|
||||
} else {
|
||||
log.Info("created dev network config",
|
||||
"path", filepath.Join(dataDir, devNetworkConfigFilename),
|
||||
"genesisHash", genesisHash.String(),
|
||||
"xAssetID", xAssetID.String(),
|
||||
"startTime", startTime,
|
||||
)
|
||||
}
|
||||
|
||||
return genesisBytes, xAssetID, nil
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// buildAutomineGenesis creates a genesis configuration for single-node development mode.
|
||||
// It uses the node's own credentials as the sole validator.
|
||||
func buildAutomineGenesis(stakingCfg *builder.StakingConfig, startTime uint64) ([]byte, ids.ID, error) {
|
||||
// Parse node ID from staking config
|
||||
nodeID, err := ids.NodeIDFromString(stakingCfg.NodeID)
|
||||
if err != nil {
|
||||
return nil, ids.Empty, fmt.Errorf("failed to parse node ID for automine mode: %w", err)
|
||||
}
|
||||
|
||||
// Lux Treasury address: 0x9011E888251AB053B7bD1cdB598Db4f9DEd94714
|
||||
// This is derived from LUX_MNEMONIC and funded on C-Chain
|
||||
var rewardAddress ids.ShortID
|
||||
treasuryAddr := "9011E888251AB053B7bD1cdB598Db4f9DEd94714"
|
||||
treasuryBytes, err := ids.ShortFromString(treasuryAddr)
|
||||
if err == nil {
|
||||
rewardAddress = treasuryBytes
|
||||
} else {
|
||||
// Fall back to a deterministic address derived from node ID
|
||||
copy(rewardAddress[:], nodeID[:20])
|
||||
}
|
||||
|
||||
// Create automine mode config with embedded C-Chain genesis
|
||||
devCfg := builder.DevModeConfig{
|
||||
NodeID: nodeID,
|
||||
BLSPublicKey: fmt.Sprintf("0x%x", stakingCfg.BLSPublicKey),
|
||||
BLSPopProof: fmt.Sprintf("0x%x", stakingCfg.BLSProofOfPossession),
|
||||
RewardAddress: rewardAddress,
|
||||
CChainGenesis: automineCChainGenesis,
|
||||
StartTime: startTime, // Use provided start time for determinism
|
||||
}
|
||||
|
||||
return builder.ForDevMode(devCfg, stakingCfg)
|
||||
}
|
||||
|
||||
// automineCChainGenesis is the default C-Chain genesis for automine mode.
|
||||
// Network ID 1337, EVM Chain ID 31337.
|
||||
// Funds Lux Treasury (0x9011), light mnemonic accounts (BIP44 m/44'/9000'/0'/0/{0-4}), and Anvil/Hardhat accounts.
|
||||
const automineCChainGenesis = `{
|
||||
"config": {
|
||||
"chainId": 31337,
|
||||
"homesteadBlock": 0,
|
||||
"eip150Block": 0,
|
||||
"eip155Block": 0,
|
||||
"eip158Block": 0,
|
||||
"byzantiumBlock": 0,
|
||||
"constantinopleBlock": 0,
|
||||
"petersburgBlock": 0,
|
||||
"istanbulBlock": 0,
|
||||
"muirGlacierBlock": 0,
|
||||
"berlinBlock": 0,
|
||||
"londonBlock": 0,
|
||||
"arrowGlacierBlock": 0,
|
||||
"grayGlacierBlock": 0,
|
||||
"mergeNetsplitBlock": 0,
|
||||
"shanghaiTime": 0,
|
||||
"cancunTime": 0,
|
||||
"blobSchedule": {
|
||||
"cancun": {"target": 3, "max": 6, "baseFeeUpdateFraction": 3338477}
|
||||
},
|
||||
"terminalTotalDifficulty": 0,
|
||||
"chainEVMTimestamp": 0,
|
||||
"durangoTimestamp": 0,
|
||||
"etnaTimestamp": 0,
|
||||
"feeConfig": {
|
||||
"gasLimit": 30000000,
|
||||
"targetBlockRate": 1,
|
||||
"minBaseFee": 1000000000,
|
||||
"targetGas": 100000000,
|
||||
"baseFeeChangeDenominator": 48,
|
||||
"minBlockGasCost": 0,
|
||||
"maxBlockGasCost": 1000000,
|
||||
"blockGasCostStep": 200000
|
||||
},
|
||||
"warpConfig": {
|
||||
"blockTimestamp": 0,
|
||||
"quorumNumerator": 67,
|
||||
"requirePrimaryNetworkSigners": false
|
||||
}
|
||||
},
|
||||
"alloc": {
|
||||
"9011E888251AB053B7bD1cdB598Db4f9DEd94714": {
|
||||
"balance": "0x200000000000000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"5369615110ca435bdf798f31c20ba6163d7b0a54": {
|
||||
"balance": "0x200000000000000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"2e701063ccdffa2b1872c596222d8067d124d3ef": {
|
||||
"balance": "0x200000000000000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"9030463eb1aaa563c8247468416cc0bf06347502": {
|
||||
"balance": "0x200000000000000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"f77b06331152fd0e536de0af65688a6559c6f914": {
|
||||
"balance": "0x200000000000000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"944fd51713652b9922690b7d06498fbf8742beac": {
|
||||
"balance": "0x200000000000000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"f39Fd6e51aad88F6F4ce6aB8827279cffFb92266": {
|
||||
"balance": "0x200000000000000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"70997970C51812dc3A010C7d01b50e0d17dc79C8": {
|
||||
"balance": "0x200000000000000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"3C44CdDdB6a900fa2b585dd299e03d12FA4293BC": {
|
||||
"balance": "0x200000000000000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"90F79bf6EB2c4f870365E785982E1f101E93b906": {
|
||||
"balance": "0x200000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
},
|
||||
"nonce": "0x0",
|
||||
"timestamp": "0x0",
|
||||
"extraData": "0x",
|
||||
"gasLimit": "0x1c9c380",
|
||||
"difficulty": "0x0",
|
||||
"mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"coinbase": "0x0000000000000000000000000000000000000000",
|
||||
"number": "0x0",
|
||||
"gasUsed": "0x0",
|
||||
"parentHash": "0x0000000000000000000000000000000000000000000000000000000000000000"
|
||||
}`
|
||||
|
||||
func getTrackedChains(v *viper.Viper) (set.Set[ids.ID], error) {
|
||||
trackChainsStr := v.GetString(TrackChainsKey)
|
||||
|
||||
@@ -1849,19 +1713,25 @@ func GetNodeConfig(v *viper.Viper) (node.Config, error) {
|
||||
return node.Config{}, err
|
||||
}
|
||||
|
||||
// Chain tracking
|
||||
// Always populate TrackedChains from --track-chains flag/env, regardless of
|
||||
// TrackAllChains. TrackedChains is used in peer handshake (MyChains) to tell
|
||||
// peers which chains we're interested in. Without it, peers won't gossip
|
||||
// chain blocks to us even if we're tracking all chains locally.
|
||||
// Chain tracking — explicit per-chain opt-in. Each validator
|
||||
// declares which chains it wants to validate via --track-chains
|
||||
// (or the operator-emitted equivalent). TrackedChains is also used
|
||||
// in the peer handshake (MyChains) so peers gossip blocks for the
|
||||
// chains we asked for.
|
||||
//
|
||||
// TrackAllChains stays as an opt-in escape hatch only — it is NOT
|
||||
// auto-enabled when TrackedChains is empty. The previous default
|
||||
// (`if TrackedChains == nil { TrackAllChains = true }`) was a
|
||||
// footgun: any node started without an explicit --track-chains
|
||||
// list silently tried to spin up every chain in P-chain state,
|
||||
// including ones whose VM plugin wasn't loaded — fataling at the
|
||||
// chain manager. Empty TrackedChains now means "track only P/X
|
||||
// (built-in primary chains)".
|
||||
nodeConfig.TrackedChains, err = getTrackedChains(v)
|
||||
if err != nil {
|
||||
return node.Config{}, err
|
||||
}
|
||||
nodeConfig.TrackAllChains = v.GetBool(TrackAllChainsKey)
|
||||
if nodeConfig.TrackedChains == nil {
|
||||
nodeConfig.TrackAllChains = true
|
||||
}
|
||||
|
||||
|
||||
// HTTP APIs
|
||||
@@ -1989,11 +1859,10 @@ func GetNodeConfig(v *viper.Viper) (node.Config, error) {
|
||||
// Get data directory for dev network config persistence
|
||||
dataDir := getExpandedArg(v, DataDirKey)
|
||||
|
||||
nodeConfig.GenesisBytes, nodeConfig.LuxAssetID, err = getGenesisData(v, nodeConfig.NetworkID, &genesisStakingCfg, dataDir)
|
||||
nodeConfig.GenesisBytes, nodeConfig.UTXOAssetID, err = getGenesisData(v, nodeConfig.NetworkID, &genesisStakingCfg, dataDir)
|
||||
if err != nil {
|
||||
return node.Config{}, fmt.Errorf("unable to load genesis file: %w", err)
|
||||
}
|
||||
nodeConfig.AllowGenesisUpdate = v.GetBool(AllowGenesisUpdateKey)
|
||||
|
||||
// StateSync Configs
|
||||
nodeConfig.StateSyncConfig, err = getStateSyncConfig(v)
|
||||
@@ -2122,26 +1991,6 @@ func saveCachedGenesisBytes(cacheFile string, genesisBytes []byte) error {
|
||||
return os.WriteFile(cacheFile, genesisBytes, 0o600)
|
||||
}
|
||||
|
||||
// extractXAssetID extracts the LUX asset ID from raw platform genesis bytes.
|
||||
// This is needed when loading raw genesis bytes directly (for snapshot resume)
|
||||
// to avoid rebuilding genesis which causes hash mismatch.
|
||||
func extractXAssetID(genesisBytes []byte) (ids.ID, error) {
|
||||
// Get the X-chain creation TX from the platform genesis
|
||||
xChainTx, err := builder.VMGenesis(genesisBytes, constants.XVMID)
|
||||
if err != nil {
|
||||
return ids.Empty, fmt.Errorf("couldn't find X-chain genesis in platform genesis: %w", err)
|
||||
}
|
||||
|
||||
// Extract the XVM genesis bytes from the create chain TX
|
||||
createChainTx, ok := xChainTx.Unsigned.(*pchaintxs.CreateChainTx)
|
||||
if !ok {
|
||||
return ids.Empty, fmt.Errorf("X-chain genesis TX is not a CreateChainTx")
|
||||
}
|
||||
|
||||
// Use the builder.XAssetID function to extract the asset ID from XVM genesis
|
||||
return builder.XAssetID(createChainTx.GenesisData)
|
||||
}
|
||||
|
||||
func providedFlags(v *viper.Viper) map[string]interface{} {
|
||||
settings := v.AllSettings()
|
||||
customSettings := make(map[string]interface{}, len(settings))
|
||||
|
||||
+1
-1
@@ -586,7 +586,7 @@ Defaults to `0.1`.
|
||||
|
||||
Partial sync enables nodes that are not primary network validators to optionally sync
|
||||
only the P-chain on the primary network. Nodes that use this option can still track
|
||||
Chains. After the Etna upgrade, nodes that use this option can also validate L1s.
|
||||
Chains. Nodes that use this option can also validate L1s.
|
||||
This config defaults to `false`.
|
||||
|
||||
## Public IP
|
||||
|
||||
@@ -16,10 +16,14 @@ import (
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/constants"
|
||||
consensusconfig "github.com/luxfi/consensus/config"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/node/chains"
|
||||
"github.com/luxfi/node/genesis/builder"
|
||||
"github.com/luxfi/node/nets"
|
||||
pchaingenesis "github.com/luxfi/node/vms/platformvm/genesis"
|
||||
pchaintxs "github.com/luxfi/node/vms/platformvm/txs"
|
||||
)
|
||||
|
||||
const chainConfigFilenameExtension = ".ex"
|
||||
@@ -672,3 +676,57 @@ func TestDevModeFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveXAssetID_FromSovereignGenesis covers the canonical
|
||||
// behaviour the sovereign-L1 fix relies on: when the loaded platform
|
||||
// genesis bakes an X-Chain, resolveXAssetID returns the runtime asset
|
||||
// ID encoded IN the genesis (matches what FromConfig produces, matches
|
||||
// what the running X-Chain reports via platform.getStakingAssetID).
|
||||
//
|
||||
// Critically: NOT constants.UTXOAssetIDFor(networkID). That value is
|
||||
// network-id-keyed and would silently collide between two sovereign L1s
|
||||
// sharing a primary-network ID.
|
||||
func TestResolveXAssetID_FromSovereignGenesis(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
cfg := builder.GetConfig(constants.LocalID)
|
||||
require.NotNil(cfg)
|
||||
require.NotEmpty(cfg.XChainGenesis, "fixture must bake X-Chain")
|
||||
|
||||
genesisBytes, expectedID, err := builder.FromConfig(cfg)
|
||||
require.NoError(err)
|
||||
require.NotEqual(ids.Empty, expectedID)
|
||||
|
||||
gotID, err := resolveXAssetID(constants.LocalID, genesisBytes)
|
||||
require.NoError(err)
|
||||
require.Equal(expectedID, gotID,
|
||||
"resolveXAssetID must agree with FromConfig on the genesis-derived asset ID")
|
||||
}
|
||||
|
||||
// TestResolveXAssetID_POnlyFallback verifies that when the platform
|
||||
// genesis bakes no X-Chain (P-only mode), resolveXAssetID falls back
|
||||
// to constants.UTXOAssetIDFor(networkID). That value is unused at
|
||||
// runtime (no X-Chain to mint on) but keeps the existing nodeConfig
|
||||
// shape consistent for downstream consumers.
|
||||
func TestResolveXAssetID_POnlyFallback(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
pOnly := &pchaingenesis.Genesis{Chains: nil}
|
||||
pOnlyBytes, err := pchaingenesis.Codec.Marshal(pchaintxs.CodecVersion, pOnly)
|
||||
require.NoError(err)
|
||||
|
||||
gotID, err := resolveXAssetID(42, pOnlyBytes)
|
||||
require.NoError(err)
|
||||
require.Equal(constants.UTXOAssetIDFor(42), gotID,
|
||||
"P-only must fall through to UTXOAssetIDFor(networkID)")
|
||||
}
|
||||
|
||||
// TestResolveXAssetID_Malformed asserts that bad genesis bytes surface
|
||||
// an error rather than silently returning ids.Empty (which would
|
||||
// reintroduce the UTXOAssetIDFor fallback and defeat the fix on
|
||||
// sovereign L1s where the fallback value is wrong).
|
||||
func TestResolveXAssetID_Malformed(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
_, err := resolveXAssetID(1, []byte{0xff, 0xfe, 0xfd, 0xfc})
|
||||
require.Error(err)
|
||||
}
|
||||
|
||||
+20
-2
@@ -46,6 +46,12 @@ var (
|
||||
defaultStakingTLSKeyPath = filepath.Join(defaultStakingPath, "staker.key")
|
||||
defaultStakingCertPath = filepath.Join(defaultStakingPath, "staker.crt")
|
||||
defaultStakingSignerKeyPath = filepath.Join(defaultStakingPath, "signer.key")
|
||||
// Strict-PQ default paths — mirror downstream-tenant CLI `<tenantctl> key gen`
|
||||
// layout so the operator init container + lqd see the same files.
|
||||
defaultStakingMLDSAKeyPath = filepath.Join(defaultStakingPath, "mldsa.key")
|
||||
defaultStakingMLDSAPubKeyPath = filepath.Join(defaultStakingPath, "mldsa.pub")
|
||||
defaultHandshakeMLKEMKeyPath = filepath.Join(defaultStakingPath, "mlkem.key")
|
||||
defaultHandshakeMLKEMPubKeyPath = filepath.Join(defaultStakingPath, "mlkem.pub")
|
||||
defaultConfigDir = filepath.Join(defaultUnexpandedDataDir, "configs")
|
||||
defaultChainConfigDir = filepath.Join(defaultConfigDir, "chains")
|
||||
defaultVMConfigDir = filepath.Join(defaultConfigDir, "vms")
|
||||
@@ -109,8 +115,6 @@ func addNodeFlags(fs *pflag.FlagSet) {
|
||||
fs.String(GenesisDBKey, "", "Path to existing genesis database for replay. Cannot be used with genesis-file or genesis-file-content")
|
||||
fs.String(GenesisDBTypeKey, "zapdb", "Database type to use for genesis database. Must be one of {pebbledb, zapdb}")
|
||||
fs.Uint64(GenesisBlockLimitKey, 0, "Limit number of blocks to replay during genesis (0 = all blocks)")
|
||||
fs.Bool(AllowCustomGenesisKey, true, "Allow custom genesis for mainnet/testnet (default: true for development, set false for production)")
|
||||
fs.Bool(AllowGenesisUpdateKey, false, "Allow updating stored genesis hash when genesis changes (use for adding new primary network chains)")
|
||||
|
||||
// Upgrade
|
||||
fs.String(UpgradeFileKey, "", fmt.Sprintf("Specifies an upgrade config file path. Ignored when running standard networks or if %s is specified",
|
||||
@@ -290,6 +294,20 @@ func addNodeFlags(fs *pflag.FlagSet) {
|
||||
fs.Bool(StakingEphemeralSignerEnabledKey, false, "If true, the node uses an ephemeral staking signer key")
|
||||
fs.String(StakingSignerKeyPathKey, defaultStakingSignerKeyPath, fmt.Sprintf("Path to the signer private key for staking. Ignored if %s is specified", StakingSignerKeyContentKey))
|
||||
fs.String(StakingSignerKeyContentKey, "", "Specifies base64 encoded signer private key for staking")
|
||||
// Strict-PQ identity (FIPS 204 ML-DSA-65 + FIPS 203 ML-KEM-768).
|
||||
// When the ML-DSA pubkey is provided, NodeID derivation pivots from
|
||||
// the classical TLS-cert path to the wire-discriminated strict-PQ
|
||||
// scheme (ids.NodeIDSchemeMLDSA65.DeriveMLDSA). Both pairs default
|
||||
// to the layout downstream-tenant CLI `<tenantctl> key gen` writes — wire the
|
||||
// init container and lqd reads the files with zero extra config.
|
||||
fs.String(StakingMLDSAKeyPathKey, defaultStakingMLDSAKeyPath, fmt.Sprintf("Path to the ML-DSA-65 staking private key (FIPS 204 PEM). Ignored if %s is specified", StakingMLDSAKeyContentKey))
|
||||
fs.String(StakingMLDSAKeyContentKey, "", "Base64-encoded ML-DSA-65 staking private key (FIPS 204 PEM)")
|
||||
fs.String(StakingMLDSAPubKeyPathKey, defaultStakingMLDSAPubKeyPath, fmt.Sprintf("Path to the ML-DSA-65 staking public key (FIPS 204 PEM). Ignored if %s is specified", StakingMLDSAPubKeyContentKey))
|
||||
fs.String(StakingMLDSAPubKeyContentKey, "", "Base64-encoded ML-DSA-65 staking public key (FIPS 204 PEM)")
|
||||
fs.String(HandshakeMLKEMKeyPathKey, defaultHandshakeMLKEMKeyPath, fmt.Sprintf("Path to the ML-KEM-768 handshake private key (FIPS 203 PEM). Ignored if %s is specified", HandshakeMLKEMKeyContentKey))
|
||||
fs.String(HandshakeMLKEMKeyContentKey, "", "Base64-encoded ML-KEM-768 handshake private key (FIPS 203 PEM)")
|
||||
fs.String(HandshakeMLKEMPubKeyPathKey, defaultHandshakeMLKEMPubKeyPath, fmt.Sprintf("Path to the ML-KEM-768 handshake public key (FIPS 203 PEM). Ignored if %s is specified", HandshakeMLKEMPubKeyContentKey))
|
||||
fs.String(HandshakeMLKEMPubKeyContentKey, "", "Base64-encoded ML-KEM-768 handshake public key (FIPS 203 PEM)")
|
||||
fs.String(StakingKMSEndpointKey, "", "KMS endpoint for staking key retrieval (e.g., https://kms.dev.lux.network)")
|
||||
fs.String(StakingKMSSecretPathKey, "", "KMS secret path for staking keys (e.g., /staking/liquid-devnet/node-0)")
|
||||
fs.String(StakingKMSTokenKey, "", "KMS auth token for staking key retrieval")
|
||||
|
||||
+16
-2
@@ -20,8 +20,6 @@ const (
|
||||
GenesisDBKey = "genesis-db"
|
||||
GenesisDBTypeKey = "genesis-db-type"
|
||||
GenesisBlockLimitKey = "genesis-block-limit"
|
||||
AllowCustomGenesisKey = "allow-custom-genesis"
|
||||
AllowGenesisUpdateKey = "allow-genesis-update"
|
||||
UpgradeFileKey = "upgrade-file"
|
||||
UpgradeFileContentKey = "upgrade-file-content"
|
||||
NetworkNameKey = "network-id"
|
||||
@@ -101,6 +99,22 @@ const (
|
||||
StakingKMSEndpointKey = "staking-kms-endpoint"
|
||||
StakingKMSSecretPathKey = "staking-kms-secret-path"
|
||||
StakingKMSTokenKey = "staking-kms-token"
|
||||
// Strict-PQ staking identity (FIPS 204 ML-DSA-65). When set, the node
|
||||
// uses the ML-DSA-65 public key as the NodeID source via
|
||||
// ids.NodeIDSchemeMLDSA65.DeriveMLDSA(chainID, pubKey), replacing the
|
||||
// classical TLS-cert NodeID derivation. Strict-PQ profiles require
|
||||
// these; classical-compat chains ignore them.
|
||||
StakingMLDSAKeyPathKey = "staking-mldsa-key-file"
|
||||
StakingMLDSAKeyContentKey = "staking-mldsa-key-file-content"
|
||||
StakingMLDSAPubKeyPathKey = "staking-mldsa-pub-key-file"
|
||||
StakingMLDSAPubKeyContentKey = "staking-mldsa-pub-key-file-content"
|
||||
// Strict-PQ handshake KEM (FIPS 203 ML-KEM-768). Peer-facing public
|
||||
// key is published in the validator-set entry so peers can encapsulate
|
||||
// to it for session-key establishment with no classical fallback.
|
||||
HandshakeMLKEMKeyPathKey = "handshake-mlkem-key-file"
|
||||
HandshakeMLKEMKeyContentKey = "handshake-mlkem-key-file-content"
|
||||
HandshakeMLKEMPubKeyPathKey = "handshake-mlkem-pub-key-file"
|
||||
HandshakeMLKEMPubKeyContentKey = "handshake-mlkem-pub-key-file-content"
|
||||
SybilProtectionEnabledKey = "sybil-protection-enabled"
|
||||
SybilProtectionDisabledWeightKey = "sybil-protection-disabled-weight"
|
||||
NetworkInitialTimeoutKey = "network-initial-timeout"
|
||||
|
||||
+96
-3
@@ -5,6 +5,7 @@ package node
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
@@ -16,6 +17,8 @@ import (
|
||||
"github.com/luxfi/node/network"
|
||||
// "github.com/luxfi/consensus/core/router" // Unused
|
||||
"github.com/luxfi/crypto/bls"
|
||||
"github.com/luxfi/crypto/mldsa"
|
||||
mlkemcrypto "github.com/luxfi/crypto/mlkem"
|
||||
"github.com/luxfi/node/nets"
|
||||
"github.com/luxfi/node/network/tracker"
|
||||
"github.com/luxfi/node/upgrade"
|
||||
@@ -83,6 +86,30 @@ type StakingConfig struct {
|
||||
StakingKeyPath string `json:"stakingKeyPath"`
|
||||
StakingCertPath string `json:"stakingCertPath"`
|
||||
StakingSignerPath string `json:"stakingSignerPath"`
|
||||
|
||||
// Strict-PQ identity material. Set by config.GetNodeConfig when the
|
||||
// --staking-mldsa-*-file / --handshake-mlkem-*-file flags resolve.
|
||||
// All four fields are nil/empty under a classical-compat chain;
|
||||
// strict-PQ profile rejects a missing StakingMLDSAPub at boot.
|
||||
//
|
||||
// NodeID derivation pivots on StakingMLDSAPub: when non-empty,
|
||||
// ids.NodeIDSchemeMLDSA65.DeriveMLDSA(stakingChainID, StakingMLDSAPub)
|
||||
// replaces ids.NodeIDFromCert(StakingTLSCert) — the TLS cert becomes
|
||||
// transport-only, no longer the identity anchor.
|
||||
//
|
||||
// HandshakeMLKEMPub is the peer-facing KEM public key that lqd
|
||||
// publishes in its validator-set entry so peers can encapsulate to it
|
||||
// for session-key establishment with no classical fallback. The
|
||||
// HandshakeMLKEMPriv stays local to the pod.
|
||||
StakingMLDSA *mldsa.PrivateKey `json:"-"`
|
||||
StakingMLDSAPub []byte `json:"-"`
|
||||
HandshakeMLKEMPriv *mlkemcrypto.PrivateKey `json:"-"`
|
||||
HandshakeMLKEMPub []byte `json:"-"`
|
||||
// File paths kept for log-line context, mirroring StakingKeyPath etc.
|
||||
StakingMLDSAKeyPath string `json:"stakingMLDSAKeyPath,omitempty"`
|
||||
StakingMLDSAPubPath string `json:"stakingMLDSAPubPath,omitempty"`
|
||||
HandshakeMLKEMKeyPath string `json:"handshakeMLKEMKeyPath,omitempty"`
|
||||
HandshakeMLKEMPubPath string `json:"handshakeMLKEMPubPath,omitempty"`
|
||||
}
|
||||
|
||||
type StateSyncConfig struct {
|
||||
@@ -141,9 +168,8 @@ type Config struct {
|
||||
UpgradeConfig upgrade.Config `json:"upgradeConfig"`
|
||||
|
||||
// Genesis information
|
||||
GenesisBytes []byte `json:"-"`
|
||||
LuxAssetID ids.ID `json:"xAssetID"`
|
||||
AllowGenesisUpdate bool `json:"allowGenesisUpdate,omitempty"`
|
||||
GenesisBytes []byte `json:"-"`
|
||||
UTXOAssetID ids.ID `json:"utxoAssetID"`
|
||||
|
||||
// ID of the network this node should connect to
|
||||
NetworkID uint32 `json:"networkID"`
|
||||
@@ -240,3 +266,70 @@ type Config struct {
|
||||
LazyChainLoading bool `json:"lazyChainLoading"`
|
||||
SingleValidatorMode bool `json:"singleValidatorMode"`
|
||||
}
|
||||
|
||||
// IsStrictPQ reports whether the staking config has strict-PQ
|
||||
// identity material loaded. Strict-PQ requires an ML-DSA-65 public
|
||||
// key (signing identity) — the ML-KEM-768 KEM is a complementary
|
||||
// handshake primitive that the validator-set entry publishes for
|
||||
// peers but is NOT what defines the validator's identity. The
|
||||
// 0-byte slice case is "classical-compat": NodeID derives from the
|
||||
// TLS staking cert in StakingTLSCert.
|
||||
func (c *StakingConfig) IsStrictPQ() bool {
|
||||
return len(c.StakingMLDSAPub) > 0
|
||||
}
|
||||
|
||||
// DeriveNodeID returns the canonical 20-byte NodeID for this
|
||||
// staking identity under chainID:
|
||||
//
|
||||
// - Strict-PQ (StakingMLDSAPub set):
|
||||
// NodeID = SHAKE256-384("NODE_ID_V1" || chainID || 0x42 || pubKey)[:20]
|
||||
// via ids.NodeIDSchemeMLDSA65.DeriveMLDSA. chainID-bound so the
|
||||
// same key on a different chain produces a different NodeID,
|
||||
// blocking cross-chain replay of validator registrations.
|
||||
// - Classical-compat (no ML-DSA pub):
|
||||
// NodeID = ids.NodeIDFromCert(StakingTLSCert.Leaf) — the
|
||||
// legacy upstream derivation. A strict-PQ chain MUST refuse
|
||||
// this branch at the validator-set boundary; the choice lives
|
||||
// in the consensus profile, not here.
|
||||
//
|
||||
// This is the single seam every "what's my NodeID" call site should
|
||||
// route through. Direct calls to ids.NodeIDFromCert in new code are
|
||||
// a bug — they bypass the strict-PQ pivot.
|
||||
func (c *StakingConfig) DeriveNodeID(chainID ids.ID) (ids.NodeID, error) {
|
||||
if c.IsStrictPQ() {
|
||||
id, _, err := ids.NodeIDSchemeMLDSA65.DeriveMLDSA(chainID, c.StakingMLDSAPub)
|
||||
return id, err
|
||||
}
|
||||
if c.StakingTLSCert.Leaf == nil {
|
||||
return ids.EmptyNodeID, errStakingTLSCertLeafNil
|
||||
}
|
||||
return ids.NodeIDFromCert(&ids.Certificate{
|
||||
Raw: c.StakingTLSCert.Leaf.Raw,
|
||||
PublicKey: c.StakingTLSCert.Leaf.PublicKey,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// DeriveTypedNodeID returns the wire-canonical TypedNodeID — scheme
|
||||
// byte (0x42 for strict-PQ ML-DSA-65, 0x90 for classical secp256k1)
|
||||
// followed by the 20-byte NodeID. The scheme byte travels with the
|
||||
// NodeID on every handshake / validator-set serialization so a
|
||||
// receiver MUST know which verifier to dispatch before consulting
|
||||
// the chain profile. Profile is a downgrade-detection gate; scheme
|
||||
// byte is a primitive-mismatch gate.
|
||||
func (c *StakingConfig) DeriveTypedNodeID(chainID ids.ID) (ids.TypedNodeID, error) {
|
||||
if c.IsStrictPQ() {
|
||||
t, _, err := ids.TypedNodeIDFromMLDSA(
|
||||
ids.NodeIDSchemeMLDSA65, chainID, c.StakingMLDSAPub)
|
||||
return t, err
|
||||
}
|
||||
if c.StakingTLSCert.Leaf == nil {
|
||||
return ids.TypedNodeID{}, errStakingTLSCertLeafNil
|
||||
}
|
||||
return ids.TypedNodeIDFromCert(&ids.Certificate{
|
||||
Raw: c.StakingTLSCert.Leaf.Raw,
|
||||
PublicKey: c.StakingTLSCert.Leaf.PublicKey,
|
||||
}), nil
|
||||
}
|
||||
|
||||
var errStakingTLSCertLeafNil = errors.New(
|
||||
"node: StakingConfig.StakingTLSCert.Leaf is nil — neither ML-DSA-65 nor a parsed TLS cert is available")
|
||||
|
||||
+73
-7
@@ -131,13 +131,6 @@ func allFlags() []FlagSpec {
|
||||
Description: "Limit number of blocks to replay during genesis (0 = all blocks)",
|
||||
Category: CategoryGenesis,
|
||||
},
|
||||
{
|
||||
Key: "allow-custom-genesis",
|
||||
Type: TypeBool,
|
||||
Default: true,
|
||||
Description: "Allow custom genesis for mainnet/testnet (default: true for development, set false for production)",
|
||||
Category: CategoryGenesis,
|
||||
},
|
||||
{
|
||||
Key: "upgrade-file",
|
||||
Type: TypeString,
|
||||
@@ -1078,6 +1071,79 @@ func allFlags() []FlagSpec {
|
||||
Category: CategoryStaking,
|
||||
Sensitive: true,
|
||||
},
|
||||
// Strict-PQ staking identity (FIPS 204 ML-DSA-65). When supplied,
|
||||
// the node uses the ML-DSA-65 public key as the NodeID source via
|
||||
// ids.NodeIDSchemeMLDSA65.DeriveMLDSA(chainID, pubKey) — replacing
|
||||
// the classical TLS-cert NodeID derivation. The classical TLS
|
||||
// staker.crt/key + signer.key flags above are retained only for
|
||||
// legacy chains that ship no ChainSecurityProfile; strict-PQ
|
||||
// chains refuse classical material at every boundary.
|
||||
{
|
||||
Key: "staking-mldsa-key-file",
|
||||
Type: TypeString,
|
||||
Default: "$LUXD_DATA_DIR/staking/mldsa.key",
|
||||
Description: "Path to the ML-DSA-65 staking private key (FIPS 204 PEM). Ignored if staking-mldsa-key-file-content is specified",
|
||||
Category: CategoryStaking,
|
||||
Sensitive: true,
|
||||
},
|
||||
{
|
||||
Key: "staking-mldsa-key-file-content",
|
||||
Type: TypeString,
|
||||
Default: "",
|
||||
Description: "Base64-encoded ML-DSA-65 staking private key (FIPS 204 PEM)",
|
||||
Category: CategoryStaking,
|
||||
Sensitive: true,
|
||||
},
|
||||
{
|
||||
Key: "staking-mldsa-pub-key-file",
|
||||
Type: TypeString,
|
||||
Default: "$LUXD_DATA_DIR/staking/mldsa.pub",
|
||||
Description: "Path to the ML-DSA-65 staking public key (FIPS 204 PEM). Ignored if staking-mldsa-pub-key-file-content is specified",
|
||||
Category: CategoryStaking,
|
||||
},
|
||||
{
|
||||
Key: "staking-mldsa-pub-key-file-content",
|
||||
Type: TypeString,
|
||||
Default: "",
|
||||
Description: "Base64-encoded ML-DSA-65 staking public key (FIPS 204 PEM)",
|
||||
Category: CategoryStaking,
|
||||
},
|
||||
// Strict-PQ handshake KEM (FIPS 203 ML-KEM-768). Peer-facing public
|
||||
// key is published in the validator-set entry so peers can
|
||||
// encapsulate to it for session-key establishment — the PQ replacement
|
||||
// for classical TLS key agreement on the libp2p handshake. The
|
||||
// private key stays on the validator pod; the public key is the only
|
||||
// material that crosses the wire.
|
||||
{
|
||||
Key: "handshake-mlkem-key-file",
|
||||
Type: TypeString,
|
||||
Default: "$LUXD_DATA_DIR/staking/mlkem.key",
|
||||
Description: "Path to the ML-KEM-768 handshake private key (FIPS 203 PEM). Ignored if handshake-mlkem-key-file-content is specified",
|
||||
Category: CategoryStaking,
|
||||
Sensitive: true,
|
||||
},
|
||||
{
|
||||
Key: "handshake-mlkem-key-file-content",
|
||||
Type: TypeString,
|
||||
Default: "",
|
||||
Description: "Base64-encoded ML-KEM-768 handshake private key (FIPS 203 PEM)",
|
||||
Category: CategoryStaking,
|
||||
Sensitive: true,
|
||||
},
|
||||
{
|
||||
Key: "handshake-mlkem-pub-key-file",
|
||||
Type: TypeString,
|
||||
Default: "$LUXD_DATA_DIR/staking/mlkem.pub",
|
||||
Description: "Path to the ML-KEM-768 handshake public key (FIPS 203 PEM). Ignored if handshake-mlkem-pub-key-file-content is specified",
|
||||
Category: CategoryStaking,
|
||||
},
|
||||
{
|
||||
Key: "handshake-mlkem-pub-key-file-content",
|
||||
Type: TypeString,
|
||||
Default: "",
|
||||
Description: "Base64-encoded ML-KEM-768 handshake public key (FIPS 203 PEM)",
|
||||
Category: CategoryStaking,
|
||||
},
|
||||
{
|
||||
Key: "sybil-protection-enabled",
|
||||
Type: TypeBool,
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
version: v1
|
||||
plugins:
|
||||
- name: go
|
||||
out: pb
|
||||
opt: paths=source_relative
|
||||
- plugin: connect-go
|
||||
out: pb
|
||||
opt: paths=source_relative
|
||||
@@ -1,7 +0,0 @@
|
||||
# Generated by buf. DO NOT EDIT.
|
||||
version: v1
|
||||
deps:
|
||||
- remote: buf.build
|
||||
owner: metric
|
||||
repository: client-model
|
||||
commit: 1d56a02d481a412a83b3c4984eb90c2e
|
||||
@@ -1,27 +0,0 @@
|
||||
version: v1
|
||||
name: buf.build/luxfi/lux
|
||||
build:
|
||||
excludes:
|
||||
# for golang we handle metric as a buf dep so we exclude it from generate, this proto
|
||||
# file is required by languages such as rust.
|
||||
- io/metric
|
||||
breaking:
|
||||
use:
|
||||
- FILE
|
||||
deps:
|
||||
- buf.build/metric/client-model
|
||||
lint:
|
||||
use:
|
||||
- STANDARD
|
||||
except:
|
||||
- SERVICE_SUFFIX # service requirement of <name>+Service
|
||||
- RPC_REQUEST_STANDARD_NAME # explicit <rpc>+Request naming
|
||||
- RPC_RESPONSE_STANDARD_NAME # explicit <rpc>+Response naming
|
||||
- PACKAGE_VERSION_SUFFIX # versioned naming <service>.v1beta
|
||||
# allows RPC requests or responses to be google.protobuf.Empty messages. This can be set if you
|
||||
# want to allow messages to be void forever, that is they will never take any parameters.
|
||||
rpc_allow_google_protobuf_empty_requests: true
|
||||
rpc_allow_google_protobuf_empty_responses: true
|
||||
# allows the same message type to be used for a single RPC's request and response type.
|
||||
# TODO: this should not be tolerated and if it is only perscriptivly.
|
||||
rpc_allow_same_request_response: true
|
||||
@@ -1,288 +0,0 @@
|
||||
//go:build grpc
|
||||
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.35.1
|
||||
// protoc (unknown)
|
||||
// source: xsvm/service.proto
|
||||
|
||||
package xsvm
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type PingRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func (x *PingRequest) Reset() {
|
||||
*x = PingRequest{}
|
||||
mi := &file_xsvm_service_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *PingRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*PingRequest) ProtoMessage() {}
|
||||
|
||||
func (x *PingRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_xsvm_service_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use PingRequest.ProtoReflect.Descriptor instead.
|
||||
func (*PingRequest) Descriptor() ([]byte, []int) {
|
||||
return file_xsvm_service_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *PingRequest) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type PingReply struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func (x *PingReply) Reset() {
|
||||
*x = PingReply{}
|
||||
mi := &file_xsvm_service_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *PingReply) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*PingReply) ProtoMessage() {}
|
||||
|
||||
func (x *PingReply) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_xsvm_service_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use PingReply.ProtoReflect.Descriptor instead.
|
||||
func (*PingReply) Descriptor() ([]byte, []int) {
|
||||
return file_xsvm_service_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *PingReply) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type StreamPingRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func (x *StreamPingRequest) Reset() {
|
||||
*x = StreamPingRequest{}
|
||||
mi := &file_xsvm_service_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *StreamPingRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*StreamPingRequest) ProtoMessage() {}
|
||||
|
||||
func (x *StreamPingRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_xsvm_service_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use StreamPingRequest.ProtoReflect.Descriptor instead.
|
||||
func (*StreamPingRequest) Descriptor() ([]byte, []int) {
|
||||
return file_xsvm_service_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *StreamPingRequest) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type StreamPingReply struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func (x *StreamPingReply) Reset() {
|
||||
*x = StreamPingReply{}
|
||||
mi := &file_xsvm_service_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *StreamPingReply) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*StreamPingReply) ProtoMessage() {}
|
||||
|
||||
func (x *StreamPingReply) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_xsvm_service_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use StreamPingReply.ProtoReflect.Descriptor instead.
|
||||
func (*StreamPingReply) Descriptor() ([]byte, []int) {
|
||||
return file_xsvm_service_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *StreamPingReply) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var File_xsvm_service_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_xsvm_service_proto_rawDesc = []byte{
|
||||
0x0a, 0x12, 0x78, 0x73, 0x76, 0x6d, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x12, 0x04, 0x78, 0x73, 0x76, 0x6d, 0x22, 0x27, 0x0a, 0x0b, 0x50, 0x69,
|
||||
0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73,
|
||||
0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73,
|
||||
0x61, 0x67, 0x65, 0x22, 0x25, 0x0a, 0x09, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x70, 0x6c, 0x79,
|
||||
0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x2d, 0x0a, 0x11, 0x53, 0x74,
|
||||
0x72, 0x65, 0x61, 0x6d, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
|
||||
0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x2b, 0x0a, 0x0f, 0x53, 0x74, 0x72,
|
||||
0x65, 0x61, 0x6d, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x18, 0x0a, 0x07,
|
||||
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d,
|
||||
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x32, 0x74, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x2a,
|
||||
0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x11, 0x2e, 0x78, 0x73, 0x76, 0x6d, 0x2e, 0x50, 0x69,
|
||||
0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0f, 0x2e, 0x78, 0x73, 0x76, 0x6d,
|
||||
0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x40, 0x0a, 0x0a, 0x53, 0x74,
|
||||
0x72, 0x65, 0x61, 0x6d, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x17, 0x2e, 0x78, 0x73, 0x76, 0x6d, 0x2e,
|
||||
0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x1a, 0x15, 0x2e, 0x78, 0x73, 0x76, 0x6d, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x50,
|
||||
0x69, 0x6e, 0x67, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x28, 0x01, 0x30, 0x01, 0x42, 0x2c, 0x5a, 0x2a,
|
||||
0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x75, 0x78, 0x66, 0x69,
|
||||
0x2f, 0x6e, 0x6f, 0x64, 0x65, 0x2f, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x70, 0x72, 0x6f,
|
||||
0x74, 0x6f, 0x2f, 0x70, 0x62, 0x2f, 0x78, 0x73, 0x76, 0x6d, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74,
|
||||
0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
file_xsvm_service_proto_rawDescOnce sync.Once
|
||||
file_xsvm_service_proto_rawDescData = file_xsvm_service_proto_rawDesc
|
||||
)
|
||||
|
||||
func file_xsvm_service_proto_rawDescGZIP() []byte {
|
||||
file_xsvm_service_proto_rawDescOnce.Do(func() {
|
||||
file_xsvm_service_proto_rawDescData = protoimpl.X.CompressGZIP(file_xsvm_service_proto_rawDescData)
|
||||
})
|
||||
return file_xsvm_service_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_xsvm_service_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
|
||||
var file_xsvm_service_proto_goTypes = []any{
|
||||
(*PingRequest)(nil), // 0: xsvm.PingRequest
|
||||
(*PingReply)(nil), // 1: xsvm.PingReply
|
||||
(*StreamPingRequest)(nil), // 2: xsvm.StreamPingRequest
|
||||
(*StreamPingReply)(nil), // 3: xsvm.StreamPingReply
|
||||
}
|
||||
var file_xsvm_service_proto_depIdxs = []int32{
|
||||
0, // 0: xsvm.Ping.Ping:input_type -> xsvm.PingRequest
|
||||
2, // 1: xsvm.Ping.StreamPing:input_type -> xsvm.StreamPingRequest
|
||||
1, // 2: xsvm.Ping.Ping:output_type -> xsvm.PingReply
|
||||
3, // 3: xsvm.Ping.StreamPing:output_type -> xsvm.StreamPingReply
|
||||
2, // [2:4] is the sub-list for method output_type
|
||||
0, // [0:2] is the sub-list for method input_type
|
||||
0, // [0:0] is the sub-list for extension type_name
|
||||
0, // [0:0] is the sub-list for extension extendee
|
||||
0, // [0:0] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_xsvm_service_proto_init() }
|
||||
func file_xsvm_service_proto_init() {
|
||||
if File_xsvm_service_proto != nil {
|
||||
return
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_xsvm_service_proto_rawDesc,
|
||||
NumEnums: 0,
|
||||
NumMessages: 4,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_xsvm_service_proto_goTypes,
|
||||
DependencyIndexes: file_xsvm_service_proto_depIdxs,
|
||||
MessageInfos: file_xsvm_service_proto_msgTypes,
|
||||
}.Build()
|
||||
File_xsvm_service_proto = out.File
|
||||
file_xsvm_service_proto_rawDesc = nil
|
||||
file_xsvm_service_proto_goTypes = nil
|
||||
file_xsvm_service_proto_depIdxs = nil
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
//go:build grpc
|
||||
|
||||
// Code generated by protoc-gen-connect-go. DO NOT EDIT.
|
||||
//
|
||||
// Source: xsvm/service.proto
|
||||
|
||||
package xsvmconnect
|
||||
|
||||
import (
|
||||
connect "connectrpc.com/connect"
|
||||
context "context"
|
||||
errors "errors"
|
||||
xsvm "github.com/luxfi/node/connectproto/pb/xsvm"
|
||||
http "net/http"
|
||||
strings "strings"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file and the connect package are
|
||||
// compatible. If you get a compiler error that this constant is not defined, this code was
|
||||
// generated with a version of connect newer than the one compiled into your binary. You can fix the
|
||||
// problem by either regenerating this code with an older version of connect or updating the connect
|
||||
// version compiled into your binary.
|
||||
const _ = connect.IsAtLeastVersion1_13_0
|
||||
|
||||
const (
|
||||
// PingName is the fully-qualified name of the Ping service.
|
||||
PingName = "xsvm.Ping"
|
||||
)
|
||||
|
||||
// These constants are the fully-qualified names of the RPCs defined in this package. They're
|
||||
// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route.
|
||||
//
|
||||
// Note that these are different from the fully-qualified method names used by
|
||||
// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to
|
||||
// reflection-formatted method names, remove the leading slash and convert the remaining slash to a
|
||||
// period.
|
||||
const (
|
||||
// PingPingProcedure is the fully-qualified name of the Ping's Ping RPC.
|
||||
PingPingProcedure = "/xsvm.Ping/Ping"
|
||||
// PingStreamPingProcedure is the fully-qualified name of the Ping's StreamPing RPC.
|
||||
PingStreamPingProcedure = "/xsvm.Ping/StreamPing"
|
||||
)
|
||||
|
||||
// PingClient is a client for the xsvm.Ping service.
|
||||
type PingClient interface {
|
||||
Ping(context.Context, *connect.Request[xsvm.PingRequest]) (*connect.Response[xsvm.PingReply], error)
|
||||
StreamPing(context.Context) *connect.BidiStreamForClient[xsvm.StreamPingRequest, xsvm.StreamPingReply]
|
||||
}
|
||||
|
||||
// NewPingClient constructs a client for the xsvm.Ping service. By default, it uses the Connect
|
||||
// protocol with the binary Protobuf Codec, asks for gzipped responses, and sends uncompressed
|
||||
// requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or
|
||||
// connect.WithGRPCWeb() options.
|
||||
//
|
||||
// The URL supplied here should be the base URL for the Connect or gRPC server (for example,
|
||||
// http://api.acme.com or https://acme.com/grpc).
|
||||
func NewPingClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) PingClient {
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
pingMethods := xsvm.File_xsvm_service_proto.Services().ByName("Ping").Methods()
|
||||
return &pingClient{
|
||||
ping: connect.NewClient[xsvm.PingRequest, xsvm.PingReply](
|
||||
httpClient,
|
||||
baseURL+PingPingProcedure,
|
||||
connect.WithSchema(pingMethods.ByName("Ping")),
|
||||
connect.WithClientOptions(opts...),
|
||||
),
|
||||
streamPing: connect.NewClient[xsvm.StreamPingRequest, xsvm.StreamPingReply](
|
||||
httpClient,
|
||||
baseURL+PingStreamPingProcedure,
|
||||
connect.WithSchema(pingMethods.ByName("StreamPing")),
|
||||
connect.WithClientOptions(opts...),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// pingClient implements PingClient.
|
||||
type pingClient struct {
|
||||
ping *connect.Client[xsvm.PingRequest, xsvm.PingReply]
|
||||
streamPing *connect.Client[xsvm.StreamPingRequest, xsvm.StreamPingReply]
|
||||
}
|
||||
|
||||
// Ping calls xsvm.Ping.Ping.
|
||||
func (c *pingClient) Ping(ctx context.Context, req *connect.Request[xsvm.PingRequest]) (*connect.Response[xsvm.PingReply], error) {
|
||||
return c.ping.CallUnary(ctx, req)
|
||||
}
|
||||
|
||||
// StreamPing calls xsvm.Ping.StreamPing.
|
||||
func (c *pingClient) StreamPing(ctx context.Context) *connect.BidiStreamForClient[xsvm.StreamPingRequest, xsvm.StreamPingReply] {
|
||||
return c.streamPing.CallBidiStream(ctx)
|
||||
}
|
||||
|
||||
// PingHandler is an implementation of the xsvm.Ping service.
|
||||
type PingHandler interface {
|
||||
Ping(context.Context, *connect.Request[xsvm.PingRequest]) (*connect.Response[xsvm.PingReply], error)
|
||||
StreamPing(context.Context, *connect.BidiStream[xsvm.StreamPingRequest, xsvm.StreamPingReply]) error
|
||||
}
|
||||
|
||||
// NewPingHandler builds an HTTP handler from the service implementation. It returns the path on
|
||||
// which to mount the handler and the handler itself.
|
||||
//
|
||||
// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf
|
||||
// and JSON codecs. They also support gzip compression.
|
||||
func NewPingHandler(svc PingHandler, opts ...connect.HandlerOption) (string, http.Handler) {
|
||||
pingMethods := xsvm.File_xsvm_service_proto.Services().ByName("Ping").Methods()
|
||||
pingPingHandler := connect.NewUnaryHandler(
|
||||
PingPingProcedure,
|
||||
svc.Ping,
|
||||
connect.WithSchema(pingMethods.ByName("Ping")),
|
||||
connect.WithHandlerOptions(opts...),
|
||||
)
|
||||
pingStreamPingHandler := connect.NewBidiStreamHandler(
|
||||
PingStreamPingProcedure,
|
||||
svc.StreamPing,
|
||||
connect.WithSchema(pingMethods.ByName("StreamPing")),
|
||||
connect.WithHandlerOptions(opts...),
|
||||
)
|
||||
return "/xsvm.Ping/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case PingPingProcedure:
|
||||
pingPingHandler.ServeHTTP(w, r)
|
||||
case PingStreamPingProcedure:
|
||||
pingStreamPingHandler.ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// UnimplementedPingHandler returns CodeUnimplemented from all methods.
|
||||
type UnimplementedPingHandler struct{}
|
||||
|
||||
func (UnimplementedPingHandler) Ping(context.Context, *connect.Request[xsvm.PingRequest]) (*connect.Response[xsvm.PingReply], error) {
|
||||
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("xsvm.Ping.Ping is not implemented"))
|
||||
}
|
||||
|
||||
func (UnimplementedPingHandler) StreamPing(context.Context, *connect.BidiStream[xsvm.StreamPingRequest, xsvm.StreamPingReply]) error {
|
||||
return connect.NewError(connect.CodeUnimplemented, errors.New("xsvm.Ping.StreamPing is not implemented"))
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package xsvm;
|
||||
|
||||
option go_package = "github.com/luxfi/node/connectproto/pb/xsvm";
|
||||
|
||||
service Ping {
|
||||
rpc Ping(PingRequest) returns (PingReply);
|
||||
rpc StreamPing(stream StreamPingRequest) returns (stream StreamPingReply);
|
||||
}
|
||||
|
||||
message PingRequest {
|
||||
string message = 1;
|
||||
}
|
||||
|
||||
message PingReply {
|
||||
string message = 1;
|
||||
}
|
||||
|
||||
message StreamPingRequest {
|
||||
string message = 1;
|
||||
}
|
||||
|
||||
message StreamPingReply {
|
||||
string message = 1;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
//go:build grpc
|
||||
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// Package rpcdb re-exports the proto/rpcdb package for backwards compatibility
|
||||
package rpcdb
|
||||
|
||||
import "github.com/luxfi/node/proto/rpcdb"
|
||||
|
||||
// Type aliases for backwards compatibility
|
||||
type (
|
||||
DatabaseClient = rpcdb.DatabaseClient
|
||||
DatabaseServer = rpcdb.DatabaseServer
|
||||
)
|
||||
|
||||
// Function aliases for backwards compatibility
|
||||
var (
|
||||
NewClient = rpcdb.NewClient
|
||||
NewServer = rpcdb.NewServer
|
||||
)
|
||||
@@ -0,0 +1,264 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// Package rpcdb is luxd's canonical Layer C home for the rpcdb service:
|
||||
// one Service implementation backed by a database.Database, with a
|
||||
// transport adapter per supported wire protocol.
|
||||
//
|
||||
// Layered topology:
|
||||
//
|
||||
// Layer A — wire framing (github.com/luxfi/api/zap)
|
||||
// Layer B — service spec (data carriers) (github.com/luxfi/proto/rpcdb)
|
||||
// Layer C — service impl + transports (this package)
|
||||
//
|
||||
// One Service. Many transport adapters. Adding a new transport is a
|
||||
// new file that wraps `*Service`; the storage logic stays here and
|
||||
// stays orthogonal to framing concerns.
|
||||
//
|
||||
// Files:
|
||||
// - service.go (this) — transport-neutral Service struct + methods
|
||||
// - grpc_server.go (build tag `grpc`) — gRPC adapter
|
||||
// - zap_server.go (default) — ZAP adapter
|
||||
package rpcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/luxfi/database"
|
||||
rpcdb "github.com/luxfi/proto/rpcdb"
|
||||
)
|
||||
|
||||
// errUnknownIterator is the sentinel returned by IteratorNext / Error
|
||||
// / Release when the caller hands in an iterator id that was never
|
||||
// allocated (or has already been released). Transport adapters should
|
||||
// surface this however their wire spec demands.
|
||||
var errUnknownIterator = errors.New("rpcdb: unknown iterator")
|
||||
|
||||
// iterationBatchBytes is the upper bound on bytes batched into a
|
||||
// single IteratorNext response. Picked to match the cevm-side
|
||||
// expectation (RemoteZapDB::iterator_next reads up to ~128 KiB per
|
||||
// roundtrip).
|
||||
const iterationBatchBytes = 128 * 1024
|
||||
|
||||
// Service is the rpcdb service implementation. It holds one
|
||||
// `database.Database` and a server-managed iterator pool, and exposes
|
||||
// every rpcdb operation as a (request → response) method on Go data
|
||||
// carriers from the proto/rpcdb wire-types package.
|
||||
//
|
||||
// Service is transport-agnostic: it knows nothing about gRPC, ZAP, or
|
||||
// any other framing. Each transport adapter wraps `*Service` and
|
||||
// translates its own wire format into these method calls.
|
||||
type Service struct {
|
||||
db database.Database
|
||||
|
||||
// iteratorMu serializes mutations of nextIteratorID and iterators.
|
||||
// It does NOT serialize calls to a particular iterator — the
|
||||
// underlying database.Iterator is documented as not safe for
|
||||
// concurrent use, and the caller is expected to respect that.
|
||||
iteratorMu sync.RWMutex
|
||||
nextIteratorID uint64
|
||||
iterators map[uint64]database.Iterator
|
||||
}
|
||||
|
||||
// NewService wraps `db` so it can be served over any transport.
|
||||
func NewService(db database.Database) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
iterators: make(map[uint64]database.Iterator),
|
||||
}
|
||||
}
|
||||
|
||||
// DB returns the underlying database. Adapters that need to surface
|
||||
// transport-specific behavior (eg. health-check serialization) can
|
||||
// reach in for it; everyone else uses the methods.
|
||||
func (s *Service) DB() database.Database {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// CloseIterators releases every server-side iterator. Adapters call
|
||||
// this on shutdown so iterators don't leak past the listener.
|
||||
func (s *Service) CloseIterators() {
|
||||
s.iteratorMu.Lock()
|
||||
defer s.iteratorMu.Unlock()
|
||||
for id, it := range s.iterators {
|
||||
it.Release()
|
||||
delete(s.iterators, id)
|
||||
}
|
||||
}
|
||||
|
||||
// errToCode maps a database error to the wire enum.
|
||||
func errToCode(err error) rpcdb.Error {
|
||||
switch {
|
||||
case err == nil:
|
||||
return rpcdb.Error_ERROR_UNSPECIFIED
|
||||
case errors.Is(err, database.ErrNotFound):
|
||||
return rpcdb.Error_ERROR_NOT_FOUND
|
||||
case errors.Is(err, database.ErrClosed):
|
||||
return rpcdb.Error_ERROR_CLOSED
|
||||
default:
|
||||
// Any other error is reported as CLOSED on the wire — the
|
||||
// proto enum doesn't have a richer error space. Adapters that
|
||||
// can carry a transport-level error message should also pass
|
||||
// the original err out-of-band.
|
||||
return rpcdb.Error_ERROR_CLOSED
|
||||
}
|
||||
}
|
||||
|
||||
// CodeToErr maps a wire-typed Error back to a database sentinel. Used
|
||||
// by remote clients on the receive side; lives here so server and
|
||||
// client agree on the inverse of errToCode.
|
||||
func CodeToErr(code rpcdb.Error) error {
|
||||
switch code {
|
||||
case rpcdb.Error_ERROR_UNSPECIFIED:
|
||||
return nil
|
||||
case rpcdb.Error_ERROR_NOT_FOUND:
|
||||
return database.ErrNotFound
|
||||
case rpcdb.Error_ERROR_CLOSED:
|
||||
return database.ErrClosed
|
||||
default:
|
||||
return database.ErrClosed
|
||||
}
|
||||
}
|
||||
|
||||
// Has services rpcdb.Has.
|
||||
func (s *Service) Has(_ context.Context, req *rpcdb.HasRequest) (*rpcdb.HasResponse, error) {
|
||||
has, err := s.db.Has(req.Key)
|
||||
if err != nil {
|
||||
return &rpcdb.HasResponse{Has: false, Err: errToCode(err)}, nil
|
||||
}
|
||||
return &rpcdb.HasResponse{Has: has, Err: rpcdb.Error_ERROR_UNSPECIFIED}, nil
|
||||
}
|
||||
|
||||
// Get services rpcdb.Get.
|
||||
func (s *Service) Get(_ context.Context, req *rpcdb.GetRequest) (*rpcdb.GetResponse, error) {
|
||||
value, err := s.db.Get(req.Key)
|
||||
if err != nil {
|
||||
return &rpcdb.GetResponse{Value: nil, Err: errToCode(err)}, nil
|
||||
}
|
||||
return &rpcdb.GetResponse{Value: value, Err: rpcdb.Error_ERROR_UNSPECIFIED}, nil
|
||||
}
|
||||
|
||||
// Put services rpcdb.Put.
|
||||
func (s *Service) Put(_ context.Context, req *rpcdb.PutRequest) (*rpcdb.PutResponse, error) {
|
||||
return &rpcdb.PutResponse{Err: errToCode(s.db.Put(req.Key, req.Value))}, nil
|
||||
}
|
||||
|
||||
// Delete services rpcdb.Delete.
|
||||
func (s *Service) Delete(_ context.Context, req *rpcdb.DeleteRequest) (*rpcdb.DeleteResponse, error) {
|
||||
return &rpcdb.DeleteResponse{Err: errToCode(s.db.Delete(req.Key))}, nil
|
||||
}
|
||||
|
||||
// WriteBatch services rpcdb.WriteBatch.
|
||||
func (s *Service) WriteBatch(_ context.Context, req *rpcdb.WriteBatchRequest) (*rpcdb.WriteBatchResponse, error) {
|
||||
batch := s.db.NewBatch()
|
||||
for _, p := range req.Puts {
|
||||
if err := batch.Put(p.Key, p.Value); err != nil {
|
||||
return &rpcdb.WriteBatchResponse{Err: errToCode(err)}, nil
|
||||
}
|
||||
}
|
||||
for _, d := range req.Deletes {
|
||||
if err := batch.Delete(d.Key); err != nil {
|
||||
return &rpcdb.WriteBatchResponse{Err: errToCode(err)}, nil
|
||||
}
|
||||
}
|
||||
return &rpcdb.WriteBatchResponse{Err: errToCode(batch.Write())}, nil
|
||||
}
|
||||
|
||||
// Compact services rpcdb.Compact.
|
||||
func (s *Service) Compact(_ context.Context, req *rpcdb.CompactRequest) (*rpcdb.CompactResponse, error) {
|
||||
return &rpcdb.CompactResponse{Err: errToCode(s.db.Compact(req.Start, req.Limit))}, nil
|
||||
}
|
||||
|
||||
// Close services rpcdb.Close.
|
||||
func (s *Service) Close(_ context.Context, _ *rpcdb.CloseRequest) (*rpcdb.CloseResponse, error) {
|
||||
return &rpcdb.CloseResponse{Err: errToCode(s.db.Close())}, nil
|
||||
}
|
||||
|
||||
// HealthCheck services rpcdb.HealthCheck.
|
||||
func (s *Service) HealthCheck(ctx context.Context) (*rpcdb.HealthCheckResponse, error) {
|
||||
health, err := s.db.HealthCheck(ctx)
|
||||
if err != nil {
|
||||
return &rpcdb.HealthCheckResponse{Details: []byte(err.Error())}, nil
|
||||
}
|
||||
details := []byte("healthy")
|
||||
if health != nil {
|
||||
if str, ok := health.(string); ok {
|
||||
details = []byte(str)
|
||||
}
|
||||
}
|
||||
return &rpcdb.HealthCheckResponse{Details: details}, nil
|
||||
}
|
||||
|
||||
// NewIteratorWithStartAndPrefix services rpcdb.NewIteratorWithStartAndPrefix.
|
||||
func (s *Service) NewIteratorWithStartAndPrefix(_ context.Context, req *rpcdb.NewIteratorWithStartAndPrefixRequest) (*rpcdb.NewIteratorWithStartAndPrefixResponse, error) {
|
||||
it := s.db.NewIteratorWithStartAndPrefix(req.Start, req.Prefix)
|
||||
s.iteratorMu.Lock()
|
||||
id := s.nextIteratorID
|
||||
s.nextIteratorID++
|
||||
s.iterators[id] = it
|
||||
s.iteratorMu.Unlock()
|
||||
return &rpcdb.NewIteratorWithStartAndPrefixResponse{Id: id}, nil
|
||||
}
|
||||
|
||||
// IteratorNext services rpcdb.IteratorNext, returning up to
|
||||
// iterationBatchBytes worth of (key, value) pairs per call. An empty
|
||||
// Data slice signals exhaustion.
|
||||
//
|
||||
// Returned bytes are deep-copied so the caller can hold them past the
|
||||
// next iterator advance; the underlying database.Iterator is allowed
|
||||
// to recycle its internal buffers.
|
||||
func (s *Service) IteratorNext(_ context.Context, req *rpcdb.IteratorNextRequest) (*rpcdb.IteratorNextResponse, error) {
|
||||
s.iteratorMu.RLock()
|
||||
it, ok := s.iterators[req.Id]
|
||||
s.iteratorMu.RUnlock()
|
||||
if !ok {
|
||||
return nil, errUnknownIterator
|
||||
}
|
||||
|
||||
var (
|
||||
size int
|
||||
data []*rpcdb.PutRequest
|
||||
)
|
||||
for size < iterationBatchBytes && it.Next() {
|
||||
k := it.Key()
|
||||
v := it.Value()
|
||||
size += len(k) + len(v)
|
||||
|
||||
kc := make([]byte, len(k))
|
||||
copy(kc, k)
|
||||
vc := make([]byte, len(v))
|
||||
copy(vc, v)
|
||||
data = append(data, &rpcdb.PutRequest{Key: kc, Value: vc})
|
||||
}
|
||||
return &rpcdb.IteratorNextResponse{Data: data}, nil
|
||||
}
|
||||
|
||||
// IteratorError services rpcdb.IteratorError.
|
||||
func (s *Service) IteratorError(_ context.Context, req *rpcdb.IteratorErrorRequest) (*rpcdb.IteratorErrorResponse, error) {
|
||||
s.iteratorMu.RLock()
|
||||
it, ok := s.iterators[req.Id]
|
||||
s.iteratorMu.RUnlock()
|
||||
if !ok {
|
||||
return &rpcdb.IteratorErrorResponse{Err: rpcdb.Error_ERROR_NOT_FOUND}, nil
|
||||
}
|
||||
return &rpcdb.IteratorErrorResponse{Err: errToCode(it.Error())}, nil
|
||||
}
|
||||
|
||||
// IteratorRelease services rpcdb.IteratorRelease. Idempotent: calling
|
||||
// it twice for the same id returns UNSPECIFIED on the second call.
|
||||
func (s *Service) IteratorRelease(_ context.Context, req *rpcdb.IteratorReleaseRequest) (*rpcdb.IteratorReleaseResponse, error) {
|
||||
s.iteratorMu.Lock()
|
||||
it, ok := s.iterators[req.Id]
|
||||
if !ok {
|
||||
s.iteratorMu.Unlock()
|
||||
return &rpcdb.IteratorReleaseResponse{Err: rpcdb.Error_ERROR_UNSPECIFIED}, nil
|
||||
}
|
||||
delete(s.iterators, req.Id)
|
||||
s.iteratorMu.Unlock()
|
||||
|
||||
dbErr := it.Error()
|
||||
it.Release()
|
||||
return &rpcdb.IteratorReleaseResponse{Err: errToCode(dbErr)}, nil
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package rpcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
zapwire "github.com/luxfi/api/zap"
|
||||
"github.com/luxfi/database"
|
||||
rpcdb "github.com/luxfi/proto/rpcdb"
|
||||
)
|
||||
|
||||
// ZAP db channel MsgType IDs. These are the wire-level Layer-A
|
||||
// dispatch tags for the rpcdb service over ZAP. They live in their
|
||||
// own listener (one per VM plugin); they do NOT collide with the VM
|
||||
// lifecycle MsgTypes (1..31) or sender (40..49) or warp (50..59)
|
||||
// because each listener has its own dispatch table.
|
||||
//
|
||||
// Order is the canonical Lane A assignment — the cevm side hard-codes
|
||||
// these numbers in `RemoteZapDB::call`. Do not reorder.
|
||||
const (
|
||||
MsgDBHas zapwire.MessageType = 1
|
||||
MsgDBGet zapwire.MessageType = 2
|
||||
MsgDBPut zapwire.MessageType = 3
|
||||
MsgDBDelete zapwire.MessageType = 4
|
||||
MsgDBWriteBatch zapwire.MessageType = 5
|
||||
MsgDBCompact zapwire.MessageType = 6
|
||||
MsgDBClose zapwire.MessageType = 7
|
||||
MsgDBHealthCheck zapwire.MessageType = 8
|
||||
MsgDBIteratorNew zapwire.MessageType = 9
|
||||
MsgDBIteratorNext zapwire.MessageType = 10
|
||||
MsgDBIteratorError zapwire.MessageType = 11
|
||||
MsgDBIteratorRelease zapwire.MessageType = 12
|
||||
)
|
||||
|
||||
// Wire-byte values for the Error enum. These match
|
||||
// rpcdb.Error_ERROR_* but are encoded as a single byte on the ZAP
|
||||
// wire so the cevm side can decode them without pulling in protobuf.
|
||||
const (
|
||||
dbErrUnspecified uint8 = 0
|
||||
dbErrClosed uint8 = 1
|
||||
dbErrNotFound uint8 = 2
|
||||
)
|
||||
|
||||
func errCodeToByte(code rpcdb.Error) uint8 {
|
||||
switch code {
|
||||
case rpcdb.Error_ERROR_CLOSED:
|
||||
return dbErrClosed
|
||||
case rpcdb.Error_ERROR_NOT_FOUND:
|
||||
return dbErrNotFound
|
||||
default:
|
||||
return dbErrUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
// ZAPServer is the ZAP transport adapter for the rpcdb Service. It
|
||||
// owns the listener and the dispatch loop; the actual storage logic
|
||||
// lives in *Service. To swap the wire format (eg. to add framing
|
||||
// over a different reliable byte stream) write a new file with a new
|
||||
// adapter — never edit Service.
|
||||
type ZAPServer struct {
|
||||
svc *Service
|
||||
|
||||
listener *zapwire.Listener
|
||||
server *zapwire.Server
|
||||
|
||||
// closeOnce guards Close so callers can call it from both the
|
||||
// client lifecycle and a deferred test cleanup without panicking
|
||||
// on a double-close of the underlying listener.
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// NewZAPServer wraps a database.Database for serving over ZAP.
|
||||
//
|
||||
// Equivalent to NewZAPServerFromService(NewService(db)) — kept as a
|
||||
// one-liner because cevm-side test fixtures and the production
|
||||
// rpcchainvm/zap path both build it this way.
|
||||
func NewZAPServer(db database.Database) *ZAPServer {
|
||||
return NewZAPServerFromService(NewService(db))
|
||||
}
|
||||
|
||||
// NewZAPServerFromService wraps an existing Service for serving over
|
||||
// ZAP. Useful when a single Service needs multiple transport adapters
|
||||
// at once (eg. tests that want both ZAP and direct in-process calls).
|
||||
func NewZAPServerFromService(svc *Service) *ZAPServer {
|
||||
return &ZAPServer{svc: svc}
|
||||
}
|
||||
|
||||
// Listen binds the ZAP listener to addr.
|
||||
func (s *ZAPServer) Listen(addr string) error {
|
||||
listener, err := zapwire.Listen(addr, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rpcdb zap: listen %s: %w", addr, err)
|
||||
}
|
||||
s.listener = listener
|
||||
s.server = zapwire.NewServer(listener, zapwire.HandlerFunc(s.handle))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListenOn wraps an existing net.Listener (for ephemeral ports etc.).
|
||||
func (s *ZAPServer) ListenOn(raw net.Listener) {
|
||||
s.listener = zapwire.NewListener(raw, nil)
|
||||
s.server = zapwire.NewServer(s.listener, zapwire.HandlerFunc(s.handle))
|
||||
}
|
||||
|
||||
// Addr returns the bound address (or nil if not yet listening).
|
||||
func (s *ZAPServer) Addr() net.Addr {
|
||||
if s.listener == nil {
|
||||
return nil
|
||||
}
|
||||
return s.listener.Addr()
|
||||
}
|
||||
|
||||
// Serve blocks until ctx is cancelled or Close is called.
|
||||
func (s *ZAPServer) Serve(ctx context.Context) error {
|
||||
if s.server == nil {
|
||||
return errors.New("rpcdb zap: not initialized — call Listen first")
|
||||
}
|
||||
return s.server.Serve(ctx)
|
||||
}
|
||||
|
||||
// Close releases all iterators and closes the listener. Caller is
|
||||
// expected to also cancel the context passed to Serve so the accept
|
||||
// loop exits cleanly. Safe to call multiple times.
|
||||
//
|
||||
// We deliberately do NOT call s.server.Close() because the upstream
|
||||
// zapwire.Server.Close races with in-flight accept (it nils its conns
|
||||
// map mid-Serve, causing "assignment to entry in nil map"). Closing
|
||||
// the listener is enough to make Accept return; ctx cancellation does
|
||||
// the rest.
|
||||
func (s *ZAPServer) Close() error {
|
||||
var err error
|
||||
s.closeOnce.Do(func() {
|
||||
s.svc.CloseIterators()
|
||||
if s.listener != nil {
|
||||
err = s.listener.Close()
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ZAPServer) handle(ctx context.Context, msgType zapwire.MessageType, payload []byte) (zapwire.MessageType, []byte, error) {
|
||||
switch msgType {
|
||||
case MsgDBHas:
|
||||
return s.handleHas(ctx, payload)
|
||||
case MsgDBGet:
|
||||
return s.handleGet(ctx, payload)
|
||||
case MsgDBPut:
|
||||
return s.handlePut(ctx, payload)
|
||||
case MsgDBDelete:
|
||||
return s.handleDelete(ctx, payload)
|
||||
case MsgDBWriteBatch:
|
||||
return s.handleWriteBatch(ctx, payload)
|
||||
case MsgDBCompact:
|
||||
return s.handleCompact(ctx, payload)
|
||||
case MsgDBClose:
|
||||
return s.handleClose(ctx, payload)
|
||||
case MsgDBHealthCheck:
|
||||
return s.handleHealthCheck(ctx)
|
||||
case MsgDBIteratorNew:
|
||||
return s.handleIteratorNew(ctx, payload)
|
||||
case MsgDBIteratorNext:
|
||||
return s.handleIteratorNext(ctx, payload)
|
||||
case MsgDBIteratorError:
|
||||
return s.handleIteratorError(ctx, payload)
|
||||
case MsgDBIteratorRelease:
|
||||
return s.handleIteratorRelease(ctx, payload)
|
||||
default:
|
||||
return 0, nil, fmt.Errorf("rpcdb zap: unknown msg type %d", msgType)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ZAPServer) handleHas(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
|
||||
r := zapwire.NewReader(payload)
|
||||
key, err := r.ReadBytes()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("rpcdb Has decode: %w", err)
|
||||
}
|
||||
resp, err := s.svc.Has(ctx, &rpcdb.HasRequest{Key: key})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
buf := zapwire.GetBuffer()
|
||||
defer zapwire.PutBuffer(buf)
|
||||
if resp.Has {
|
||||
buf.WriteUint8(1)
|
||||
} else {
|
||||
buf.WriteUint8(0)
|
||||
}
|
||||
buf.WriteUint8(errCodeToByte(resp.Err))
|
||||
return MsgDBHas, append([]byte(nil), buf.Bytes()...), nil
|
||||
}
|
||||
|
||||
func (s *ZAPServer) handleGet(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
|
||||
r := zapwire.NewReader(payload)
|
||||
key, err := r.ReadBytes()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("rpcdb Get decode: %w", err)
|
||||
}
|
||||
resp, err := s.svc.Get(ctx, &rpcdb.GetRequest{Key: key})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
buf := zapwire.GetBuffer()
|
||||
defer zapwire.PutBuffer(buf)
|
||||
buf.WriteBytes(resp.Value)
|
||||
buf.WriteUint8(errCodeToByte(resp.Err))
|
||||
return MsgDBGet, append([]byte(nil), buf.Bytes()...), nil
|
||||
}
|
||||
|
||||
func (s *ZAPServer) handlePut(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
|
||||
r := zapwire.NewReader(payload)
|
||||
key, err := r.ReadBytes()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("rpcdb Put decode key: %w", err)
|
||||
}
|
||||
value, err := r.ReadBytes()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("rpcdb Put decode value: %w", err)
|
||||
}
|
||||
resp, err := s.svc.Put(ctx, &rpcdb.PutRequest{Key: key, Value: value})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
buf := zapwire.GetBuffer()
|
||||
defer zapwire.PutBuffer(buf)
|
||||
buf.WriteUint8(errCodeToByte(resp.Err))
|
||||
return MsgDBPut, append([]byte(nil), buf.Bytes()...), nil
|
||||
}
|
||||
|
||||
func (s *ZAPServer) handleDelete(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
|
||||
r := zapwire.NewReader(payload)
|
||||
key, err := r.ReadBytes()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("rpcdb Delete decode: %w", err)
|
||||
}
|
||||
resp, err := s.svc.Delete(ctx, &rpcdb.DeleteRequest{Key: key})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
buf := zapwire.GetBuffer()
|
||||
defer zapwire.PutBuffer(buf)
|
||||
buf.WriteUint8(errCodeToByte(resp.Err))
|
||||
return MsgDBDelete, append([]byte(nil), buf.Bytes()...), nil
|
||||
}
|
||||
|
||||
func (s *ZAPServer) handleWriteBatch(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
|
||||
r := zapwire.NewReader(payload)
|
||||
|
||||
nputs, err := r.ReadUint32()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("rpcdb WriteBatch decode nputs: %w", err)
|
||||
}
|
||||
puts := make([]*rpcdb.PutRequest, 0, nputs)
|
||||
for i := uint32(0); i < nputs; i++ {
|
||||
key, err := r.ReadBytes()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("rpcdb WriteBatch put[%d] key: %w", i, err)
|
||||
}
|
||||
value, err := r.ReadBytes()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("rpcdb WriteBatch put[%d] value: %w", i, err)
|
||||
}
|
||||
puts = append(puts, &rpcdb.PutRequest{Key: key, Value: value})
|
||||
}
|
||||
|
||||
ndels, err := r.ReadUint32()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("rpcdb WriteBatch decode ndels: %w", err)
|
||||
}
|
||||
dels := make([]*rpcdb.DeleteRequest, 0, ndels)
|
||||
for i := uint32(0); i < ndels; i++ {
|
||||
key, err := r.ReadBytes()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("rpcdb WriteBatch del[%d]: %w", i, err)
|
||||
}
|
||||
dels = append(dels, &rpcdb.DeleteRequest{Key: key})
|
||||
}
|
||||
|
||||
resp, err := s.svc.WriteBatch(ctx, &rpcdb.WriteBatchRequest{Puts: puts, Deletes: dels})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
buf := zapwire.GetBuffer()
|
||||
defer zapwire.PutBuffer(buf)
|
||||
buf.WriteUint8(errCodeToByte(resp.Err))
|
||||
return MsgDBWriteBatch, append([]byte(nil), buf.Bytes()...), nil
|
||||
}
|
||||
|
||||
func (s *ZAPServer) handleCompact(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
|
||||
r := zapwire.NewReader(payload)
|
||||
start, err := r.ReadBytes()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("rpcdb Compact decode start: %w", err)
|
||||
}
|
||||
limit, err := r.ReadBytes()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("rpcdb Compact decode limit: %w", err)
|
||||
}
|
||||
resp, err := s.svc.Compact(ctx, &rpcdb.CompactRequest{Start: start, Limit: limit})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
buf := zapwire.GetBuffer()
|
||||
defer zapwire.PutBuffer(buf)
|
||||
buf.WriteUint8(errCodeToByte(resp.Err))
|
||||
return MsgDBCompact, append([]byte(nil), buf.Bytes()...), nil
|
||||
}
|
||||
|
||||
func (s *ZAPServer) handleClose(ctx context.Context, _ []byte) (zapwire.MessageType, []byte, error) {
|
||||
resp, err := s.svc.Close(ctx, &rpcdb.CloseRequest{})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
buf := zapwire.GetBuffer()
|
||||
defer zapwire.PutBuffer(buf)
|
||||
buf.WriteUint8(errCodeToByte(resp.Err))
|
||||
return MsgDBClose, append([]byte(nil), buf.Bytes()...), nil
|
||||
}
|
||||
|
||||
func (s *ZAPServer) handleHealthCheck(ctx context.Context) (zapwire.MessageType, []byte, error) {
|
||||
resp, err := s.svc.HealthCheck(ctx)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
buf := zapwire.GetBuffer()
|
||||
defer zapwire.PutBuffer(buf)
|
||||
buf.WriteBytes(resp.Details)
|
||||
return MsgDBHealthCheck, append([]byte(nil), buf.Bytes()...), nil
|
||||
}
|
||||
|
||||
func (s *ZAPServer) handleIteratorNew(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
|
||||
r := zapwire.NewReader(payload)
|
||||
start, err := r.ReadBytes()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("rpcdb IteratorNew decode start: %w", err)
|
||||
}
|
||||
prefix, err := r.ReadBytes()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("rpcdb IteratorNew decode prefix: %w", err)
|
||||
}
|
||||
resp, err := s.svc.NewIteratorWithStartAndPrefix(ctx, &rpcdb.NewIteratorWithStartAndPrefixRequest{Start: start, Prefix: prefix})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
buf := zapwire.GetBuffer()
|
||||
defer zapwire.PutBuffer(buf)
|
||||
buf.WriteUint64(resp.Id)
|
||||
return MsgDBIteratorNew, append([]byte(nil), buf.Bytes()...), nil
|
||||
}
|
||||
|
||||
func (s *ZAPServer) handleIteratorNext(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
|
||||
r := zapwire.NewReader(payload)
|
||||
id, err := r.ReadUint64()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("rpcdb IteratorNext decode id: %w", err)
|
||||
}
|
||||
resp, err := s.svc.IteratorNext(ctx, &rpcdb.IteratorNextRequest{Id: id})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
buf := zapwire.GetBuffer()
|
||||
defer zapwire.PutBuffer(buf)
|
||||
buf.WriteUint32(uint32(len(resp.Data)))
|
||||
for _, e := range resp.Data {
|
||||
buf.WriteBytes(e.Key)
|
||||
buf.WriteBytes(e.Value)
|
||||
}
|
||||
return MsgDBIteratorNext, append([]byte(nil), buf.Bytes()...), nil
|
||||
}
|
||||
|
||||
func (s *ZAPServer) handleIteratorError(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
|
||||
r := zapwire.NewReader(payload)
|
||||
id, err := r.ReadUint64()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("rpcdb IteratorError decode id: %w", err)
|
||||
}
|
||||
resp, err := s.svc.IteratorError(ctx, &rpcdb.IteratorErrorRequest{Id: id})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
buf := zapwire.GetBuffer()
|
||||
defer zapwire.PutBuffer(buf)
|
||||
buf.WriteUint8(errCodeToByte(resp.Err))
|
||||
return MsgDBIteratorError, append([]byte(nil), buf.Bytes()...), nil
|
||||
}
|
||||
|
||||
func (s *ZAPServer) handleIteratorRelease(ctx context.Context, payload []byte) (zapwire.MessageType, []byte, error) {
|
||||
r := zapwire.NewReader(payload)
|
||||
id, err := r.ReadUint64()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("rpcdb IteratorRelease decode id: %w", err)
|
||||
}
|
||||
resp, err := s.svc.IteratorRelease(ctx, &rpcdb.IteratorReleaseRequest{Id: id})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
buf := zapwire.GetBuffer()
|
||||
defer zapwire.PutBuffer(buf)
|
||||
buf.WriteUint8(errCodeToByte(resp.Err))
|
||||
return MsgDBIteratorRelease, append([]byte(nil), buf.Bytes()...), nil
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package rpcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
zapwire "github.com/luxfi/api/zap"
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/database/memdb"
|
||||
)
|
||||
|
||||
// startZAPServer spawns a ZAPServer over an in-memory listener bound to
|
||||
// 127.0.0.1:0, returns the addr + the server (for shutdown).
|
||||
func startZAPServer(t *testing.T, db database.Database) (string, *ZAPServer, context.CancelFunc) {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
s := NewZAPServer(db)
|
||||
s.ListenOn(listener)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
_ = s.Serve(ctx)
|
||||
}()
|
||||
|
||||
// Wait for Serve to fully install accept loop.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return listener.Addr().String(), s, cancel
|
||||
}
|
||||
|
||||
// TestZAPServer_HasGetPutDelete exercises the four primitive ops.
|
||||
func TestZAPServer_HasGetPutDelete(t *testing.T) {
|
||||
require := require.New(t)
|
||||
mem := memdb.New()
|
||||
addr, srv, cancel := startZAPServer(t, mem)
|
||||
defer cancel()
|
||||
defer srv.Close()
|
||||
|
||||
conn, err := zapwire.Dial(context.Background(), addr, nil)
|
||||
require.NoError(err)
|
||||
defer conn.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Has (key absent) → has=false, err=NotFound
|
||||
{
|
||||
buf := zapwire.GetBuffer()
|
||||
buf.WriteBytes([]byte("foo"))
|
||||
_, resp, err := conn.Call(ctx, MsgDBHas, buf.Bytes())
|
||||
zapwire.PutBuffer(buf)
|
||||
require.NoError(err)
|
||||
r := zapwire.NewReader(resp)
|
||||
hasB, _ := r.ReadUint8()
|
||||
errB, _ := r.ReadUint8()
|
||||
require.Equal(uint8(0), hasB)
|
||||
require.Equal(dbErrUnspecified, errB) // memdb returns no err on Has-miss
|
||||
}
|
||||
|
||||
// Put
|
||||
{
|
||||
buf := zapwire.GetBuffer()
|
||||
buf.WriteBytes([]byte("foo"))
|
||||
buf.WriteBytes([]byte("bar"))
|
||||
_, resp, err := conn.Call(ctx, MsgDBPut, buf.Bytes())
|
||||
zapwire.PutBuffer(buf)
|
||||
require.NoError(err)
|
||||
r := zapwire.NewReader(resp)
|
||||
errB, _ := r.ReadUint8()
|
||||
require.Equal(dbErrUnspecified, errB)
|
||||
}
|
||||
|
||||
// Has (key present) → has=true
|
||||
{
|
||||
buf := zapwire.GetBuffer()
|
||||
buf.WriteBytes([]byte("foo"))
|
||||
_, resp, err := conn.Call(ctx, MsgDBHas, buf.Bytes())
|
||||
zapwire.PutBuffer(buf)
|
||||
require.NoError(err)
|
||||
r := zapwire.NewReader(resp)
|
||||
hasB, _ := r.ReadUint8()
|
||||
errB, _ := r.ReadUint8()
|
||||
require.Equal(uint8(1), hasB)
|
||||
require.Equal(dbErrUnspecified, errB)
|
||||
}
|
||||
|
||||
// Get
|
||||
{
|
||||
buf := zapwire.GetBuffer()
|
||||
buf.WriteBytes([]byte("foo"))
|
||||
_, resp, err := conn.Call(ctx, MsgDBGet, buf.Bytes())
|
||||
zapwire.PutBuffer(buf)
|
||||
require.NoError(err)
|
||||
r := zapwire.NewReader(resp)
|
||||
val, _ := r.ReadBytes()
|
||||
errB, _ := r.ReadUint8()
|
||||
require.Equal([]byte("bar"), val)
|
||||
require.Equal(dbErrUnspecified, errB)
|
||||
}
|
||||
|
||||
// Get (missing) → empty value, err=NotFound
|
||||
{
|
||||
buf := zapwire.GetBuffer()
|
||||
buf.WriteBytes([]byte("absent"))
|
||||
_, resp, err := conn.Call(ctx, MsgDBGet, buf.Bytes())
|
||||
zapwire.PutBuffer(buf)
|
||||
require.NoError(err)
|
||||
r := zapwire.NewReader(resp)
|
||||
val, _ := r.ReadBytes()
|
||||
errB, _ := r.ReadUint8()
|
||||
require.Empty(val)
|
||||
require.Equal(dbErrNotFound, errB)
|
||||
}
|
||||
|
||||
// Delete
|
||||
{
|
||||
buf := zapwire.GetBuffer()
|
||||
buf.WriteBytes([]byte("foo"))
|
||||
_, resp, err := conn.Call(ctx, MsgDBDelete, buf.Bytes())
|
||||
zapwire.PutBuffer(buf)
|
||||
require.NoError(err)
|
||||
r := zapwire.NewReader(resp)
|
||||
errB, _ := r.ReadUint8()
|
||||
require.Equal(dbErrUnspecified, errB)
|
||||
}
|
||||
|
||||
// Get post-delete → NotFound
|
||||
{
|
||||
buf := zapwire.GetBuffer()
|
||||
buf.WriteBytes([]byte("foo"))
|
||||
_, resp, err := conn.Call(ctx, MsgDBGet, buf.Bytes())
|
||||
zapwire.PutBuffer(buf)
|
||||
require.NoError(err)
|
||||
r := zapwire.NewReader(resp)
|
||||
_, _ = r.ReadBytes()
|
||||
errB, _ := r.ReadUint8()
|
||||
require.Equal(dbErrNotFound, errB)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZAPServer_WriteBatch_AndIterate covers batched writes plus the
|
||||
// iterator path used by cevm's load_persisted snapshot_with_prefix.
|
||||
func TestZAPServer_WriteBatch_AndIterate(t *testing.T) {
|
||||
require := require.New(t)
|
||||
mem := memdb.New()
|
||||
addr, srv, cancel := startZAPServer(t, mem)
|
||||
defer cancel()
|
||||
defer srv.Close()
|
||||
|
||||
conn, err := zapwire.Dial(context.Background(), addr, nil)
|
||||
require.NoError(err)
|
||||
defer conn.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// WriteBatch: 3 puts (prefix p:), 1 delete (no-op since absent).
|
||||
{
|
||||
buf := zapwire.GetBuffer()
|
||||
buf.WriteUint32(3)
|
||||
buf.WriteBytes([]byte("p:1"))
|
||||
buf.WriteBytes([]byte("v1"))
|
||||
buf.WriteBytes([]byte("p:2"))
|
||||
buf.WriteBytes([]byte("v2"))
|
||||
buf.WriteBytes([]byte("q:3"))
|
||||
buf.WriteBytes([]byte("v3"))
|
||||
buf.WriteUint32(1)
|
||||
buf.WriteBytes([]byte("absent"))
|
||||
_, resp, err := conn.Call(ctx, MsgDBWriteBatch, buf.Bytes())
|
||||
zapwire.PutBuffer(buf)
|
||||
require.NoError(err)
|
||||
r := zapwire.NewReader(resp)
|
||||
errB, _ := r.ReadUint8()
|
||||
require.Equal(dbErrUnspecified, errB)
|
||||
}
|
||||
|
||||
// IteratorNew with prefix "p:"
|
||||
var iterID uint64
|
||||
{
|
||||
buf := zapwire.GetBuffer()
|
||||
buf.WriteBytes(nil) // start
|
||||
buf.WriteBytes([]byte("p:")) // prefix
|
||||
_, resp, err := conn.Call(ctx, MsgDBIteratorNew, buf.Bytes())
|
||||
zapwire.PutBuffer(buf)
|
||||
require.NoError(err)
|
||||
r := zapwire.NewReader(resp)
|
||||
iterID, _ = r.ReadUint64()
|
||||
}
|
||||
|
||||
// IteratorNext — collect.
|
||||
collected := map[string]string{}
|
||||
for {
|
||||
buf := zapwire.GetBuffer()
|
||||
buf.WriteUint64(iterID)
|
||||
_, resp, err := conn.Call(ctx, MsgDBIteratorNext, buf.Bytes())
|
||||
zapwire.PutBuffer(buf)
|
||||
require.NoError(err)
|
||||
r := zapwire.NewReader(resp)
|
||||
n, _ := r.ReadUint32()
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
for i := uint32(0); i < n; i++ {
|
||||
k, _ := r.ReadBytes()
|
||||
v, _ := r.ReadBytes()
|
||||
collected[string(k)] = string(v)
|
||||
}
|
||||
}
|
||||
|
||||
// IteratorRelease
|
||||
{
|
||||
buf := zapwire.GetBuffer()
|
||||
buf.WriteUint64(iterID)
|
||||
_, resp, err := conn.Call(ctx, MsgDBIteratorRelease, buf.Bytes())
|
||||
zapwire.PutBuffer(buf)
|
||||
require.NoError(err)
|
||||
r := zapwire.NewReader(resp)
|
||||
errB, _ := r.ReadUint8()
|
||||
require.Equal(dbErrUnspecified, errB)
|
||||
}
|
||||
|
||||
require.Equal(map[string]string{"p:1": "v1", "p:2": "v2"}, collected)
|
||||
}
|
||||
|
||||
// TestZAPServer_ParityWithMemDB writes/reads via wire and via direct
|
||||
// memdb Get to confirm both paths see the same state — proves the ZAP
|
||||
// round-trip preserves bytes exactly.
|
||||
func TestZAPServer_ParityWithMemDB(t *testing.T) {
|
||||
require := require.New(t)
|
||||
mem := memdb.New()
|
||||
addr, srv, cancel := startZAPServer(t, mem)
|
||||
defer cancel()
|
||||
defer srv.Close()
|
||||
|
||||
conn, err := zapwire.Dial(context.Background(), addr, nil)
|
||||
require.NoError(err)
|
||||
defer conn.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
key := []byte{0x42, 0x00, 0x01, 0xff}
|
||||
value := make([]byte, 256)
|
||||
for i := range value {
|
||||
value[i] = byte(i)
|
||||
}
|
||||
|
||||
// Put via wire.
|
||||
buf := zapwire.GetBuffer()
|
||||
buf.WriteBytes(key)
|
||||
buf.WriteBytes(value)
|
||||
_, _, err = conn.Call(ctx, MsgDBPut, buf.Bytes())
|
||||
zapwire.PutBuffer(buf)
|
||||
require.NoError(err)
|
||||
|
||||
// Read directly.
|
||||
got, dErr := mem.Get(key)
|
||||
require.NoError(dErr)
|
||||
require.Equal(value, got)
|
||||
|
||||
// Read via wire.
|
||||
buf2 := zapwire.GetBuffer()
|
||||
buf2.WriteBytes(key)
|
||||
_, resp, err := conn.Call(ctx, MsgDBGet, buf2.Bytes())
|
||||
zapwire.PutBuffer(buf2)
|
||||
require.NoError(err)
|
||||
r := zapwire.NewReader(resp)
|
||||
wireVal, _ := r.ReadBytes()
|
||||
require.Equal(value, wireVal)
|
||||
}
|
||||
+6
-6
@@ -152,14 +152,14 @@ func TestNetworkConfiguration(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestXAssetID(t *testing.T) {
|
||||
// Verify XAssetID is used for native asset
|
||||
xAssetID := ids.Empty // Our implementation uses Empty ID for native asset
|
||||
// Verify UTXOAssetID is used for native asset
|
||||
utxoAssetID := ids.Empty // Our implementation uses Empty ID for native asset
|
||||
|
||||
if xAssetID != ids.Empty {
|
||||
t.Fatal("XAssetID should be Empty for native asset")
|
||||
if utxoAssetID != ids.Empty {
|
||||
t.Fatal("UTXOAssetID should be Empty for native asset")
|
||||
}
|
||||
|
||||
t.Log("✓ XAssetID verified for native LUX asset")
|
||||
t.Log("✓ UTXOAssetID verified for native LUX asset")
|
||||
}
|
||||
|
||||
func TestConsensusConfig(t *testing.T) {
|
||||
@@ -319,7 +319,7 @@ func TestSummary(t *testing.T) {
|
||||
t.Log("✓ Validator Manager: PASS")
|
||||
t.Log("✓ Network Configuration: PASS")
|
||||
t.Log("✓ Consensus Parameters: PASS")
|
||||
t.Log("✓ XAssetID Configuration: PASS")
|
||||
t.Log("✓ UTXOAssetID Configuration: PASS")
|
||||
t.Log("✓ Validator Lifecycle: PASS")
|
||||
t.Log("✓ Validator Set Interface: PASS")
|
||||
t.Log("----------------------------------------")
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ deploy_network() {
|
||||
echo "Deploying subnets to $network"
|
||||
echo "=============================="
|
||||
|
||||
LUX_PRIVATE_KEY="$KEY" "$DEPLOY_BIN" \
|
||||
PRIVATE_KEY="$KEY" "$DEPLOY_BIN" \
|
||||
--network="$network" \
|
||||
--state-dir="$STATE_DIR" 2>&1
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM golang:1.26-alpine AS builder
|
||||
FROM golang:1.26.3-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git make gcc musl-dev linux-headers bash
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Multi-chain node builder
|
||||
FROM golang:1.26-alpine AS builder
|
||||
FROM golang:1.26.3-alpine AS builder
|
||||
|
||||
ARG CHAIN_TYPE=platform
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.26-alpine AS builder
|
||||
FROM golang:1.26.3-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git make gcc musl-dev linux-headers bash
|
||||
|
||||
|
||||
@@ -140,8 +140,8 @@ else
|
||||
{
|
||||
"allocations": [
|
||||
{
|
||||
"luxAddr": "P-local1jfdgmqduuyuxjp9sq7szp08xpynav88sd7qxvv",
|
||||
"ethAddr": "0x9011E888251AB053B7bD1cdB598Db4f9DEd94714",
|
||||
"utxoAddr": "P-local1jfdgmqduuyuxjp9sq7szp08xpynav88sd7qxvv",
|
||||
"evmAddr": "0x9011E888251AB053B7bD1cdB598Db4f9DEd94714",
|
||||
"initialAmount": 1000000000000000000,
|
||||
"unlockSchedule": []
|
||||
}
|
||||
@@ -167,8 +167,8 @@ EOF
|
||||
{
|
||||
"allocations": [
|
||||
{
|
||||
"luxAddr": "X-local1jfdgmqduuyuxjp9sq7szp08xpynav88sd7qxvv",
|
||||
"ethAddr": "0x9011E888251AB053B7bD1cdB598Db4f9DEd94714",
|
||||
"utxoAddr": "X-local1jfdgmqduuyuxjp9sq7szp08xpynav88sd7qxvv",
|
||||
"evmAddr": "0x9011E888251AB053B7bD1cdB598Db4f9DEd94714",
|
||||
"initialAmount": 1000000000000000000,
|
||||
"unlockSchedule": []
|
||||
}
|
||||
|
||||
Executable
BIN
Binary file not shown.
+224
-260
@@ -22,10 +22,8 @@ import (
|
||||
"github.com/luxfi/math/set"
|
||||
"github.com/luxfi/net/endpoints"
|
||||
"github.com/luxfi/node/vms/components/gas"
|
||||
exchangevm "github.com/luxfi/node/vms/xvm"
|
||||
"github.com/luxfi/node/vms/xvm/fxs"
|
||||
xchaintxs "github.com/luxfi/node/vms/xvm/txs"
|
||||
"github.com/luxfi/node/vms/platformvm/genesis"
|
||||
xvmgenesis "github.com/luxfi/node/vms/xvm/genesis"
|
||||
"github.com/luxfi/node/vms/platformvm/reward"
|
||||
"github.com/luxfi/node/vms/platformvm/signer"
|
||||
pchaintxs "github.com/luxfi/node/vms/platformvm/txs"
|
||||
@@ -44,29 +42,22 @@ import (
|
||||
genesisconfigs "github.com/luxfi/genesis/configs"
|
||||
)
|
||||
|
||||
// Chain alias vars are derived from the single source of truth (Registry
|
||||
// in registry.go). Rebranding a chain — adding/removing aliases or
|
||||
// renaming the letter — edits one row in Registry; these vars and the
|
||||
// VMAliases map re-derive at package init.
|
||||
var (
|
||||
// PChainAliases are the default aliases for the P-Chain
|
||||
PChainAliases = []string{"P", "platform"}
|
||||
// XChainAliases are the default aliases for the X-Chain
|
||||
XChainAliases = []string{"X", "xvm"}
|
||||
// CChainAliases are the default aliases for the C-Chain
|
||||
CChainAliases = []string{"C", "evm"}
|
||||
// DChainAliases are the default aliases for the D-Chain (DEX)
|
||||
DChainAliases = []string{"D", "dex", "dexvm"}
|
||||
// QChainAliases are the default aliases for the Q-Chain (Quantum)
|
||||
QChainAliases = []string{"Q", "quantum", "quantumvm", "pq"}
|
||||
// AChainAliases are the default aliases for the A-Chain (Attestation/AI)
|
||||
AChainAliases = []string{"A", "attest", "ai", "aivm"}
|
||||
// BChainAliases are the default aliases for the B-Chain (Bridge)
|
||||
BChainAliases = []string{"B", "bridge", "bridgevm"}
|
||||
// TChainAliases are the default aliases for the T-Chain (Threshold)
|
||||
TChainAliases = []string{"T", "threshold", "thresholdvm", "mpc"}
|
||||
// ZChainAliases are the default aliases for the Z-Chain (ZK)
|
||||
ZChainAliases = []string{"Z", "zk", "zkvm"}
|
||||
// GChainAliases are the default aliases for the G-Chain (Graph)
|
||||
GChainAliases = []string{"G", "graph", "graphvm", "dgraph"}
|
||||
// KChainAliases are the default aliases for the K-Chain (KMS)
|
||||
KChainAliases = []string{"K", "key", "keyvm"}
|
||||
PChainAliases = AliasesFor("P")
|
||||
XChainAliases = AliasesFor("X")
|
||||
CChainAliases = AliasesFor("C")
|
||||
DChainAliases = AliasesFor("D")
|
||||
QChainAliases = AliasesFor("Q")
|
||||
AChainAliases = AliasesFor("A")
|
||||
BChainAliases = AliasesFor("B")
|
||||
TChainAliases = AliasesFor("T")
|
||||
ZChainAliases = AliasesFor("Z")
|
||||
GChainAliases = AliasesFor("G")
|
||||
KChainAliases = AliasesFor("K")
|
||||
|
||||
// Network-specific genesis messages (Latin for mainnet, descriptive for others)
|
||||
// Mainnet: "Lux et Libertas" - Light and Liberty
|
||||
@@ -78,31 +69,11 @@ var (
|
||||
// Local/Custom: "Carpe Diem" - Seize the day
|
||||
LocalChainGenesis = `{"version":1,"message":"Carpe Diem"}`
|
||||
|
||||
// VMAliases are the default aliases for VMs
|
||||
VMAliases = map[ids.ID][]string{
|
||||
constants.PlatformVMID: {"platform"},
|
||||
constants.XVMID: {"xvm"},
|
||||
constants.EVMID: {"evm"},
|
||||
constants.DexVMID: {"dexvm", "dex"},
|
||||
constants.QuantumVMID: {"quantumvm", "quantum", "pq"},
|
||||
constants.AIVMID: {"aivm", "attest", "ai"},
|
||||
constants.BridgeVMID: {"bridgevm", "bridge"},
|
||||
constants.ThresholdVMID: {"thresholdvm", "threshold", "mpc"},
|
||||
constants.ZKVMID: {"zkvm", "zk"},
|
||||
constants.GraphVMID: {"graphvm", "graph", "dgraph"},
|
||||
constants.KeyVMID: {"keyvm", "key"},
|
||||
secp256k1fx.ID: {"secp256k1fx"},
|
||||
nftfx.ID: {"nftfx"},
|
||||
propertyfx.ID: {"propertyfx"},
|
||||
mldsafx.ID: {"mldsafx"},
|
||||
slhdsafx.ID: {"slhdsafx"},
|
||||
ed25519fx.ID: {"ed25519fx"},
|
||||
secp256r1fx.ID: {"secp256r1fx"},
|
||||
schnorrfx.ID: {"schnorrfx"},
|
||||
bls12381fx.ID: {"bls12381fx"},
|
||||
}
|
||||
// VMAliases is the default (VMID -> aliases) map for the node's VM
|
||||
// manager. Chain entries are derived from Registry; fx entries are
|
||||
// the static feature-extension aliases (secp256k1fx, nftfx, ...).
|
||||
VMAliases = VMAliasesMap()
|
||||
|
||||
errNoTxs = errors.New("genesis creates no transactions")
|
||||
errOverridesStandardNetworkConfig = errors.New("overrides standard network genesis config")
|
||||
)
|
||||
|
||||
@@ -353,82 +324,110 @@ func GetConfig(networkID uint32) *genesiscfg.Config {
|
||||
return genesiscfg.GetConfig(networkID)
|
||||
}
|
||||
|
||||
// FromConfig builds genesis bytes from a config
|
||||
// FromConfig builds genesis bytes from a config.
|
||||
//
|
||||
// X-Chain is opt-in via config.XChainGenesis (a small JSON descriptor like
|
||||
//
|
||||
// {"symbol":"LUX","name":"Lux","denomination":9}
|
||||
//
|
||||
// ). When set, the builder constructs an XVM genesis whose primary asset
|
||||
// is that descriptor (initial holders sourced from config.Allocations) and
|
||||
// emits an X-Chain entry in the primary-network CreateChainTx set. When
|
||||
// empty, no XVM genesis is built and X-Chain is omitted from the chain
|
||||
// set — the path used by P-only L2s whose value capture lives on a
|
||||
// downstream EVM (downstream EVM etc.).
|
||||
//
|
||||
// The LUX asset ID returned is always the network-wide constant
|
||||
// (constants.UTXO_ASSET_ID), independent of whether X-Chain is baked.
|
||||
// This is what decouples the asset from any specific chain's genesis
|
||||
// bytes — P-Chain stake/UTXO references stay byte-stable across both
|
||||
// shapes of primary network.
|
||||
func FromConfig(config *genesiscfg.Config) ([]byte, ids.ID, error) {
|
||||
// Build XVM (X-Chain) genesis
|
||||
lux := exchangevm.GenesisAssetDefinition{
|
||||
Name: "Lux",
|
||||
Symbol: "LUX",
|
||||
Denomination: 9,
|
||||
InitialState: exchangevm.AssetInitialState{},
|
||||
}
|
||||
memoBytes := []byte{}
|
||||
|
||||
// Sort allocations for deterministic output
|
||||
type allocation struct {
|
||||
ETHAddr ids.ShortID
|
||||
LUXAddr ids.ShortID
|
||||
InitialAmount uint64
|
||||
}
|
||||
xAllocations := []allocation{}
|
||||
for _, a := range config.Allocations {
|
||||
if a.InitialAmount > 0 {
|
||||
xAllocations = append(xAllocations, allocation{
|
||||
ETHAddr: a.ETHAddr,
|
||||
LUXAddr: a.LUXAddr,
|
||||
InitialAmount: a.InitialAmount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get HRP for this network to format bech32 addresses
|
||||
// HRP is needed for X-Chain holder addresses, P-Chain protocol
|
||||
// allocations, staker allocations, and reward addresses — define once,
|
||||
// before the X-Chain conditional, so it's available on both paths.
|
||||
hrp := constants.GetHRP(config.NetworkID)
|
||||
|
||||
for _, a := range xAllocations {
|
||||
// Format address as bech32 for the X-Chain
|
||||
bech32Addr, err := address.FormatBech32(hrp, a.LUXAddr[:])
|
||||
// X-Chain is the first opt-in chain. Build its XVM genesis bytes only
|
||||
// when config.XChainGenesis is non-empty; otherwise leave the slot
|
||||
// empty and the optIn loop below skips emitting an X-Chain CreateChainTx.
|
||||
var xvmGenesisBytes []byte
|
||||
if config.XChainGenesis != "" {
|
||||
var asset xvmgenesis.AssetDescriptor
|
||||
if err := json.Unmarshal([]byte(config.XChainGenesis), &asset); err != nil {
|
||||
return nil, ids.Empty, fmt.Errorf("invalid xChainGenesis shard: %w", err)
|
||||
}
|
||||
|
||||
holders := []xvmgenesis.Holder{}
|
||||
memoBytes := []byte{}
|
||||
for _, a := range config.Allocations {
|
||||
if a.InitialAmount == 0 {
|
||||
continue
|
||||
}
|
||||
// Format address as bech32 for the X-Chain
|
||||
bech32Addr, err := address.FormatBech32(hrp, a.UTXOAddr[:])
|
||||
if err != nil {
|
||||
return nil, ids.Empty, fmt.Errorf("failed to format bech32 address: %w", err)
|
||||
}
|
||||
holders = append(holders, xvmgenesis.Holder{
|
||||
Amount: a.InitialAmount,
|
||||
Address: bech32Addr,
|
||||
})
|
||||
// Add EVM address to memo for reference (strip 0x prefix)
|
||||
if evmAddrStr := a.EVMAddr.Hex(); len(evmAddrStr) > 2 {
|
||||
memoBytes = append(memoBytes, []byte(evmAddrStr[2:])...)
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
xvmGenesisBytes, err = xvmgenesis.BuildBytes(config.NetworkID, asset, holders, memoBytes)
|
||||
if err != nil {
|
||||
return nil, ids.Empty, fmt.Errorf("failed to format bech32 address: %w", err)
|
||||
}
|
||||
lux.InitialState.FixedCap = append(lux.InitialState.FixedCap, exchangevm.GenesisHolder{
|
||||
Amount: a.InitialAmount,
|
||||
Address: bech32Addr,
|
||||
})
|
||||
// Add ETH address to memo for reference
|
||||
ethAddrStr := a.ETHAddr.Hex()
|
||||
if len(ethAddrStr) > 2 { // "0x" prefix
|
||||
memoBytes = append(memoBytes, []byte(ethAddrStr[2:])...)
|
||||
return nil, ids.Empty, err
|
||||
}
|
||||
}
|
||||
lux.Memo = memoBytes
|
||||
|
||||
xvmGenesis, err := exchangevm.NewGenesis(
|
||||
config.NetworkID,
|
||||
map[string]exchangevm.GenesisAssetDefinition{
|
||||
"LUX": lux,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, ids.Empty, err
|
||||
}
|
||||
xvmGenesisBytes, err := xvmGenesis.Bytes()
|
||||
if err != nil {
|
||||
return nil, ids.Empty, fmt.Errorf("couldn't serialize xvm genesis: %w", err)
|
||||
}
|
||||
|
||||
xAssetID, err := XAssetID(xvmGenesisBytes)
|
||||
if err != nil {
|
||||
return nil, ids.Empty, fmt.Errorf("couldn't generate LUX asset ID: %w", err)
|
||||
// X-Chain native asset ID is derived from the actual XVM genesis bytes
|
||||
// (the runtime ID of the first GenesisAsset.CreateAssetTx) so the value
|
||||
// the wallet builder reads back via platform.getStakingAssetID matches
|
||||
// the asset the X-Chain genuinely mints. On sovereign L1s (tenant networks,
|
||||
// MLC, VCC, future tenants) every primary network has its own X-Chain
|
||||
// genesis content (different validator set, different initial holders,
|
||||
// different denomination/name) so the genesis-derived asset ID is
|
||||
// distinct from any constants.UTXOAssetIDFor(networkID) value — those
|
||||
// are network-id-keyed constants and identical across every L1 sharing
|
||||
// the same primary-network ID, which would let two sovereign networks
|
||||
// collide.
|
||||
//
|
||||
// When X-Chain is opt-out (P-only mode, xvmGenesisBytes is nil) we keep
|
||||
// the network-id-keyed constant as a placeholder; the asset ID is
|
||||
// irrelevant in that mode since there is no X-Chain to mint on.
|
||||
var utxoAssetID ids.ID
|
||||
if len(xvmGenesisBytes) > 0 {
|
||||
var err error
|
||||
utxoAssetID, err = xvmgenesis.AssetIDFromBytes(xvmGenesisBytes)
|
||||
if err != nil {
|
||||
return nil, ids.Empty, fmt.Errorf("derive X-Chain asset ID from genesis: %w", err)
|
||||
}
|
||||
} else {
|
||||
utxoAssetID = constants.UTXOAssetIDFor(config.NetworkID)
|
||||
}
|
||||
|
||||
genesisTime := time.Unix(int64(config.StartTime), 0)
|
||||
|
||||
// Calculate initial supply
|
||||
// Calculate initial supply. Match the UTXO emission policy: when
|
||||
// unlockSchedule is non-empty it IS the allocation (initialAmount is a
|
||||
// redundant total in legacy-shaped configs); when empty,
|
||||
// initialAmount is the allocation. Either way, count exactly once —
|
||||
// the reported supply must equal the sum of actually-emitted UTXOs +
|
||||
// validator stakes, not a double-count of the two views of one number.
|
||||
initialSupply := uint64(0)
|
||||
for _, a := range config.Allocations {
|
||||
initialSupply += a.InitialAmount
|
||||
for _, unlock := range a.UnlockSchedule {
|
||||
initialSupply += unlock.Amount
|
||||
if len(a.UnlockSchedule) > 0 {
|
||||
for _, unlock := range a.UnlockSchedule {
|
||||
initialSupply += unlock.Amount
|
||||
}
|
||||
} else {
|
||||
initialSupply += a.InitialAmount
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,14 +440,14 @@ func FromConfig(config *genesiscfg.Config) ([]byte, ids.ID, error) {
|
||||
platformAllocations := []genesis.Allocation{}
|
||||
skippedAllocations := []genesiscfg.Allocation{}
|
||||
for _, a := range config.Allocations {
|
||||
if initiallyStaked.Contains(a.LUXAddr) {
|
||||
if initiallyStaked.Contains(a.UTXOAddr) {
|
||||
skippedAllocations = append(skippedAllocations, a)
|
||||
continue
|
||||
}
|
||||
for _, unlock := range a.UnlockSchedule {
|
||||
if unlock.Amount > 0 {
|
||||
// Format address as bech32 for the P-Chain
|
||||
bech32Addr, err := address.FormatBech32(hrp, a.LUXAddr[:])
|
||||
bech32Addr, err := address.FormatBech32(hrp, a.UTXOAddr[:])
|
||||
if err != nil {
|
||||
return nil, ids.Empty, fmt.Errorf("failed to format bech32 address for P-chain: %w", err)
|
||||
}
|
||||
@@ -456,14 +455,19 @@ func FromConfig(config *genesiscfg.Config) ([]byte, ids.ID, error) {
|
||||
Locktime: unlock.Locktime,
|
||||
Amount: unlock.Amount,
|
||||
Address: bech32Addr,
|
||||
Message: a.ETHAddr.Bytes(),
|
||||
Message: a.EVMAddr.Bytes(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Also create P-Chain UTXO for initialAmount (spendable, no locktime)
|
||||
if a.InitialAmount > 0 {
|
||||
bech32Addr, err := address.FormatBech32(hrp, a.LUXAddr[:])
|
||||
// when unlockSchedule is empty. Legacy genesis JSONs set
|
||||
// initialAmount AS THE SUM of unlockSchedule (two views of the same
|
||||
// total). Emitting both would double-mint that total. Devnet-style
|
||||
// configs set initialAmount with an empty unlockSchedule and rely on
|
||||
// this single UTXO. One semantic, one UTXO, no double-mint.
|
||||
if a.InitialAmount > 0 && len(a.UnlockSchedule) == 0 {
|
||||
bech32Addr, err := address.FormatBech32(hrp, a.UTXOAddr[:])
|
||||
if err != nil {
|
||||
return nil, ids.Empty, fmt.Errorf("failed to format bech32 address for P-chain initialAmount: %w", err)
|
||||
}
|
||||
@@ -471,7 +475,7 @@ func FromConfig(config *genesiscfg.Config) ([]byte, ids.ID, error) {
|
||||
Locktime: 0, // immediately spendable
|
||||
Amount: a.InitialAmount,
|
||||
Address: bech32Addr,
|
||||
Message: a.ETHAddr.Bytes(),
|
||||
Message: a.EVMAddr.Bytes(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -506,7 +510,7 @@ func FromConfig(config *genesiscfg.Config) ([]byte, ids.ID, error) {
|
||||
for _, a := range nodeAllocations {
|
||||
for _, unlock := range a.UnlockSchedule {
|
||||
// Format address as bech32 for staker allocations
|
||||
bech32Addr, err := address.FormatBech32(hrp, a.LUXAddr[:])
|
||||
bech32Addr, err := address.FormatBech32(hrp, a.UTXOAddr[:])
|
||||
if err != nil {
|
||||
return nil, ids.Empty, fmt.Errorf("failed to format bech32 address for staker allocation: %w", err)
|
||||
}
|
||||
@@ -514,7 +518,7 @@ func FromConfig(config *genesiscfg.Config) ([]byte, ids.ID, error) {
|
||||
Locktime: unlock.Locktime,
|
||||
Amount: unlock.Amount,
|
||||
Address: bech32Addr,
|
||||
Message: a.ETHAddr.Bytes(),
|
||||
Message: a.EVMAddr.Bytes(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -559,48 +563,31 @@ func FromConfig(config *genesiscfg.Config) ([]byte, ids.ID, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// Specify primary network chains
|
||||
// PRIMARY NETWORK = P + Q + Z (minimum quantum-safe validator set)
|
||||
// Primary-network chain set is data-driven.
|
||||
//
|
||||
// P-Chain is implicit (platformvm is the genesis itself).
|
||||
// Q-Chain provides Quasar PQ consensus (BLS + Corona + ML-DSA).
|
||||
// Z-Chain provides universal receipt registry and ZK verification.
|
||||
// P-Chain is implicit (the platform genesis itself). Every other
|
||||
// primary-network chain — including X-Chain — is opt-in via a
|
||||
// non-empty genesis string in the resolved Config. Entries with
|
||||
// empty GenesisData are SKIPPED — luxd will not start a chain
|
||||
// manager for them. Mainnet/testnet/devnet ship XChainGenesis so
|
||||
// X-Chain stays baked; P-only L2s leave it empty
|
||||
// and the chain is omitted.
|
||||
//
|
||||
// X, C, D, B, T, G, K, A, I chains are OPT-IN networks created via
|
||||
// CreateChainTx post-genesis. Validators stake separately to validate
|
||||
// each and earn its tx fees.
|
||||
getVMGenesis := func(data string) []byte {
|
||||
if data != "" {
|
||||
return []byte(data)
|
||||
}
|
||||
return []byte("{}")
|
||||
}
|
||||
|
||||
chains := []genesis.Chain{
|
||||
// Q-Chain (Quantum / PQ consensus) — MANDATORY
|
||||
{
|
||||
GenesisData: getVMGenesis(config.QChainGenesis),
|
||||
ChainID: constants.PrimaryNetworkID,
|
||||
VMID: constants.QuantumVMID,
|
||||
Name: "Q-Chain",
|
||||
},
|
||||
// Z-Chain (ZK / universal receipts) — MANDATORY
|
||||
{
|
||||
GenesisData: getVMGenesis(config.ZChainGenesis),
|
||||
ChainID: constants.PrimaryNetworkID,
|
||||
VMID: constants.ZKVMID,
|
||||
Name: "Z-Chain",
|
||||
},
|
||||
}
|
||||
|
||||
// X-Chain (assets) — INCLUDED for backward compat + LUX asset.
|
||||
// Staking and fees use X-Chain LUX UTXOs. Can't be removed without
|
||||
// migrating to a native P-Chain fee token.
|
||||
chains = append(chains, genesis.Chain{
|
||||
GenesisData: xvmGenesisBytes,
|
||||
ChainID: constants.PrimaryNetworkID,
|
||||
VMID: constants.XVMID,
|
||||
FxIDs: []ids.ID{
|
||||
// Order is fixed (X→C→D→B→T→Q→Z) and preserved across
|
||||
// presence/absence so byte-identical genesis holds when the same
|
||||
// shard set is supplied. Append-only — reordering shifts the
|
||||
// P-Chain genesis byte layout.
|
||||
//
|
||||
// Runtime tracking is separate: an operator decides which of the
|
||||
// baked chains this node actually runs via --track-chains /
|
||||
// --track-all-chains. Baked-but-untracked chains stay dormant.
|
||||
optIn := []struct {
|
||||
genesisData []byte
|
||||
vmID ids.ID
|
||||
name string
|
||||
fxIDs []ids.ID // X-Chain only; nil for everything else.
|
||||
}{
|
||||
{xvmGenesisBytes, constants.XVMID, "X-Chain", []ids.ID{
|
||||
secp256k1fx.ID,
|
||||
nftfx.ID,
|
||||
propertyfx.ID,
|
||||
@@ -610,55 +597,33 @@ func FromConfig(config *genesiscfg.Config) ([]byte, ids.ID, error) {
|
||||
secp256r1fx.ID,
|
||||
schnorrfx.ID,
|
||||
bls12381fx.ID,
|
||||
},
|
||||
Name: "X-Chain",
|
||||
})
|
||||
|
||||
// C-Chain (EVM) — OPT-IN. Only created if CChainGenesis provided.
|
||||
if config.CChainGenesis != "" {
|
||||
chains = append(chains, genesis.Chain{
|
||||
GenesisData: []byte(config.CChainGenesis),
|
||||
ChainID: constants.PrimaryNetworkID,
|
||||
VMID: constants.EVMID,
|
||||
Name: "C-Chain",
|
||||
})
|
||||
}},
|
||||
{[]byte(config.CChainGenesis), constants.EVMID, "C-Chain", nil},
|
||||
{[]byte(config.DChainGenesis), constants.DexVMID, "D-Chain", nil},
|
||||
{[]byte(config.BChainGenesis), constants.BridgeVMID, "B-Chain", nil},
|
||||
{[]byte(config.TChainGenesis), constants.ThresholdVMID, "T-Chain", nil},
|
||||
{[]byte(config.QChainGenesis), constants.QuantumVMID, "Q-Chain", nil},
|
||||
{[]byte(config.ZChainGenesis), constants.ZKVMID, "Z-Chain", nil},
|
||||
}
|
||||
|
||||
// D-Chain (DEX) — OPT-IN
|
||||
if config.DChainGenesis != "" {
|
||||
chains := []genesis.Chain{}
|
||||
for _, c := range optIn {
|
||||
if len(c.genesisData) == 0 {
|
||||
continue
|
||||
}
|
||||
chains = append(chains, genesis.Chain{
|
||||
GenesisData: getVMGenesis(config.DChainGenesis),
|
||||
GenesisData: c.genesisData,
|
||||
ChainID: constants.PrimaryNetworkID,
|
||||
VMID: constants.DexVMID,
|
||||
Name: "D-Chain",
|
||||
})
|
||||
}
|
||||
|
||||
// B-Chain (Bridge) — OPT-IN
|
||||
if config.BChainGenesis != "" {
|
||||
chains = append(chains, genesis.Chain{
|
||||
GenesisData: getVMGenesis(config.BChainGenesis),
|
||||
ChainID: constants.PrimaryNetworkID,
|
||||
VMID: constants.BridgeVMID,
|
||||
Name: "B-Chain",
|
||||
})
|
||||
}
|
||||
|
||||
// T-Chain (Threshold / FHE / MPC) — OPT-IN
|
||||
if config.TChainGenesis != "" {
|
||||
chains = append(chains, genesis.Chain{
|
||||
GenesisData: getVMGenesis(config.TChainGenesis),
|
||||
ChainID: constants.PrimaryNetworkID,
|
||||
VMID: constants.ThresholdVMID,
|
||||
Name: "T-Chain",
|
||||
VMID: c.vmID,
|
||||
FxIDs: c.fxIDs,
|
||||
Name: c.name,
|
||||
})
|
||||
}
|
||||
|
||||
// G/K/A/I chains are loaded as plugins and instantiated via
|
||||
// CreateChainTx post-genesis on their own subnets.
|
||||
// CreateChainTx post-genesis on their own chains.
|
||||
|
||||
pChainGenesis, err := genesis.New(
|
||||
xAssetID,
|
||||
utxoAssetID,
|
||||
config.NetworkID,
|
||||
platformAllocations,
|
||||
validators,
|
||||
@@ -674,23 +639,14 @@ func FromConfig(config *genesiscfg.Config) ([]byte, ids.ID, error) {
|
||||
if err != nil {
|
||||
return nil, ids.Empty, fmt.Errorf("problem while serializing platform chain's genesis state: %w", err)
|
||||
}
|
||||
return pChainGenesisBytes, xAssetID, nil
|
||||
return pChainGenesisBytes, utxoAssetID, nil
|
||||
}
|
||||
|
||||
// FromFile loads genesis config from file and builds genesis bytes
|
||||
func FromFile(networkID uint32, filepath string, stakingCfg *StakingConfig, allowCustomGenesis bool) ([]byte, ids.ID, error) {
|
||||
// Protect standard networks from custom genesis unless explicitly allowed
|
||||
if !allowCustomGenesis {
|
||||
switch networkID {
|
||||
case constants.MainnetID, constants.TestnetID:
|
||||
return nil, ids.Empty, fmt.Errorf(
|
||||
"%w: %s",
|
||||
errOverridesStandardNetworkConfig,
|
||||
constants.NetworkName(networkID),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// FromFile loads genesis config from file and builds genesis bytes.
|
||||
// Caller owns the choice — if a genesis file is set, we use it. No
|
||||
// "is this networkID standard?" guard, no allow-flag. Operator-owned
|
||||
// chainset.
|
||||
func FromFile(networkID uint32, filepath string, stakingCfg *StakingConfig) ([]byte, ids.ID, error) {
|
||||
config, err := genesiscfg.GetConfigFile(filepath)
|
||||
if err != nil {
|
||||
return nil, ids.Empty, fmt.Errorf("failed to load genesis file %s: %w", filepath, err)
|
||||
@@ -701,20 +657,9 @@ func FromFile(networkID uint32, filepath string, stakingCfg *StakingConfig, allo
|
||||
return FromConfig(config)
|
||||
}
|
||||
|
||||
// FromFlag parses base64-encoded genesis content and builds genesis bytes
|
||||
func FromFlag(networkID uint32, genesisContent string, stakingCfg *StakingConfig, allowCustomGenesis bool) ([]byte, ids.ID, error) {
|
||||
// Protect standard networks from custom genesis unless explicitly allowed
|
||||
if !allowCustomGenesis {
|
||||
switch networkID {
|
||||
case constants.MainnetID, constants.TestnetID:
|
||||
return nil, ids.Empty, fmt.Errorf(
|
||||
"%w: %s",
|
||||
errOverridesStandardNetworkConfig,
|
||||
constants.NetworkName(networkID),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// FromFlag parses base64-encoded genesis content and builds genesis bytes.
|
||||
// Same contract as FromFile — caller owns the override.
|
||||
func FromFlag(networkID uint32, genesisContent string, stakingCfg *StakingConfig) ([]byte, ids.ID, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(genesisContent)
|
||||
if err != nil {
|
||||
return nil, ids.Empty, fmt.Errorf("failed to decode base64 genesis content: %w", err)
|
||||
@@ -737,6 +682,54 @@ func FromDatabase(networkID uint32, dbPath string, dbType string, stakingCfg *St
|
||||
return FromConfig(config)
|
||||
}
|
||||
|
||||
// XAssetIDFromGenesisBytes returns the X-Chain native asset ID encoded
|
||||
// in a platform-genesis blob. It parses the platform genesis, finds the
|
||||
// X-Chain CreateChainTx (vmID == constants.XVMID), then decodes that
|
||||
// chain's embedded XVM genesis bytes to recover the runtime asset ID
|
||||
// (the same value vm.initGenesis assigns to the first GenesisAsset).
|
||||
//
|
||||
// Returns (ids.Empty, false, nil) when the platform genesis contains
|
||||
// no X-Chain (P-only mode) — callers fall back to whatever default
|
||||
// they prefer (e.g. constants.UTXOAssetIDFor(networkID)).
|
||||
//
|
||||
// Returns a non-nil error when the platform genesis is unparseable or
|
||||
// the X-Chain genesis bytes embedded inside it are malformed; both are
|
||||
// unrecoverable on a primary-network bootstrap.
|
||||
//
|
||||
// Used by config.getGenesisData when it loads genesis via the cached /
|
||||
// raw paths that skip FromConfig — those paths historically returned
|
||||
// constants.UTXOAssetIDFor(networkID), but on sovereign L1s that value
|
||||
// disagrees with what's actually in the genesis bytes. Wiring this
|
||||
// helper through getGenesisData means the node always reports the
|
||||
// genesis-derived asset ID via platform.getStakingAssetID regardless
|
||||
// of which load path was taken.
|
||||
func XAssetIDFromGenesisBytes(genesisBytes []byte) (ids.ID, bool, error) {
|
||||
// Parse the platform genesis directly so we can distinguish parse
|
||||
// failure ("garbage bytes — fatal") from missing X-Chain ("P-only
|
||||
// mode — caller falls back to UTXOAssetIDFor"). VMGenesis collapses
|
||||
// both into one error, which is the wrong shape here.
|
||||
gen, err := genesis.Parse(genesisBytes)
|
||||
if err != nil {
|
||||
return ids.Empty, false, fmt.Errorf("parse platform genesis: %w", err)
|
||||
}
|
||||
for _, chain := range gen.Chains {
|
||||
uChain, ok := chain.Unsigned.(*pchaintxs.CreateChainTx)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if uChain.VMID != constants.XVMID {
|
||||
continue
|
||||
}
|
||||
id, err := xvmgenesis.AssetIDFromBytes(uChain.GenesisData)
|
||||
if err != nil {
|
||||
return ids.Empty, false, fmt.Errorf("derive X-Chain asset ID from genesis data: %w", err)
|
||||
}
|
||||
return id, true, nil
|
||||
}
|
||||
// Parsed cleanly but no X-Chain CreateChainTx — P-only mode.
|
||||
return ids.Empty, false, nil
|
||||
}
|
||||
|
||||
// VMGenesis returns the genesis tx for a specific VM
|
||||
func VMGenesis(genesisBytes []byte, vmID ids.ID) (*pchaintxs.Tx, error) {
|
||||
gen, err := genesis.Parse(genesisBytes)
|
||||
@@ -873,35 +866,6 @@ func Aliases(genesisBytes []byte) (map[string][]string, map[ids.ID][]string, err
|
||||
return apiAliases, chainAliases, nil
|
||||
}
|
||||
|
||||
// XAssetID returns the LUX asset ID from XVM genesis bytes
|
||||
func XAssetID(xvmGenesisBytes []byte) (ids.ID, error) {
|
||||
parser, err := xchaintxs.NewParser(
|
||||
[]fxs.Fx{
|
||||
&secp256k1fx.Fx{},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return ids.Empty, err
|
||||
}
|
||||
|
||||
genesisCodec := parser.GenesisCodec()
|
||||
gen := exchangevm.Genesis{}
|
||||
if _, err := genesisCodec.Unmarshal(xvmGenesisBytes, &gen); err != nil {
|
||||
return ids.Empty, err
|
||||
}
|
||||
|
||||
if len(gen.Txs) == 0 {
|
||||
return ids.Empty, errNoTxs
|
||||
}
|
||||
genesisTx := gen.Txs[0]
|
||||
|
||||
tx := xchaintxs.Tx{Unsigned: &genesisTx.CreateAssetTx}
|
||||
if err := tx.Initialize(genesisCodec); err != nil {
|
||||
return ids.Empty, err
|
||||
}
|
||||
return tx.ID(), nil
|
||||
}
|
||||
|
||||
// validateConfig validates the genesis config
|
||||
func validateConfig(networkID uint32, config *genesiscfg.Config, stakingCfg *StakingConfig) error {
|
||||
if config.NetworkID != networkID {
|
||||
@@ -942,8 +906,8 @@ func ForDevMode(cfg DevModeConfig, stakingCfg *StakingConfig) ([]byte, ids.ID, e
|
||||
const oneBillionLUX = 1_000_000_000_000_000_000 // 1B LUX in nLUX
|
||||
|
||||
allocation := genesiscfg.Allocation{
|
||||
ETHAddr: cfg.RewardAddress, // Same as LUX addr for simplicity
|
||||
LUXAddr: cfg.RewardAddress,
|
||||
EVMAddr: cfg.RewardAddress, // Same as UTXO addr for simplicity
|
||||
UTXOAddr: cfg.RewardAddress,
|
||||
InitialAmount: oneMillionLUX, // Initial unlocked amount
|
||||
UnlockSchedule: []genesiscfg.LockedAmount{
|
||||
{
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package builder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/constants"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/node/vms/platformvm/genesis"
|
||||
pchaintxs "github.com/luxfi/node/vms/platformvm/txs"
|
||||
)
|
||||
|
||||
// TestXAssetIDFromGenesisBytes_Sovereign asserts the canonical
|
||||
// behaviour the sovereign-L1 fix relies on: when the platform genesis
|
||||
// bakes an X-Chain, the X-Chain asset ID returned by
|
||||
// XAssetIDFromGenesisBytes is the runtime ID encoded IN the genesis
|
||||
// (different across networks with different validator/holder sets),
|
||||
// NOT the network-id-keyed constants.UTXOAssetIDFor(networkID) value.
|
||||
//
|
||||
// The two values disagreeing is the whole reason the helper exists —
|
||||
// sovereign primary networks sharing a networkID would otherwise
|
||||
// silently collide on the constant.
|
||||
func TestXAssetIDFromGenesisBytes_Sovereign(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
// Use the local devnet config — it has an X-Chain genesis baked in,
|
||||
// so the helper must derive a real ID (not just fall back).
|
||||
cfg := GetConfig(constants.LocalID)
|
||||
require.NotNil(cfg)
|
||||
require.NotEmpty(cfg.XChainGenesis, "fixture must bake X-Chain")
|
||||
|
||||
genesisBytes, fromConfigID, err := FromConfig(cfg)
|
||||
require.NoError(err)
|
||||
require.NotEmpty(genesisBytes)
|
||||
require.NotEqual(ids.Empty, fromConfigID)
|
||||
|
||||
helperID, ok, err := XAssetIDFromGenesisBytes(genesisBytes)
|
||||
require.NoError(err)
|
||||
require.True(ok, "X-Chain is in genesis — helper must report ok=true")
|
||||
require.Equal(fromConfigID, helperID, "helper must agree with FromConfig")
|
||||
|
||||
// And — critically — the helper must NOT return the network-id-keyed
|
||||
// constant on a sovereign-style genesis (the holder set / network
|
||||
// fingerprint differs). UTXOAssetIDFor returns the constant in
|
||||
// network-id 1 (mainnet) and a network-keyed hash otherwise; on
|
||||
// LocalID the test asserts the genesis-derived value supersedes it
|
||||
// only when the genesis content differs from the upstream-Lux
|
||||
// baseline. For the current embedded local config the two HAPPEN
|
||||
// to coincide (single deployer, well-known fixture), so we only
|
||||
// assert the value is non-zero and matches FromConfig's return.
|
||||
}
|
||||
|
||||
// TestXAssetIDFromGenesisBytes_POnly verifies that when the platform
|
||||
// genesis has no X-Chain (P-only mode), the helper returns ok=false
|
||||
// and ids.Empty rather than an error. Callers fall back to
|
||||
// constants.UTXOAssetIDFor(networkID) in that case (the value is
|
||||
// unused at runtime since there is no X-Chain to mint on).
|
||||
func TestXAssetIDFromGenesisBytes_POnly(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
pOnly := &genesis.Genesis{Chains: nil}
|
||||
pOnlyBytes, err := genesis.Codec.Marshal(pchaintxs.CodecVersion, pOnly)
|
||||
require.NoError(err)
|
||||
|
||||
id, ok, err := XAssetIDFromGenesisBytes(pOnlyBytes)
|
||||
require.NoError(err, "P-only is a valid mode, not a parse error")
|
||||
require.False(ok, "P-only must report ok=false so caller falls back")
|
||||
require.Equal(ids.Empty, id)
|
||||
}
|
||||
|
||||
// TestXAssetIDFromGenesisBytes_Malformed asserts that bad input
|
||||
// surfaces an error — silently returning ids.Empty would reintroduce
|
||||
// the UTXOAssetIDFor(networkID) fallback path and defeat the fix.
|
||||
func TestXAssetIDFromGenesisBytes_Malformed(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
_, _, err := XAssetIDFromGenesisBytes([]byte{0xde, 0xad})
|
||||
require.Error(err)
|
||||
}
|
||||
|
||||
// TestVMGenesisOptInChains asserts that VMGenesis returns an error for VM IDs
|
||||
// not present in the genesis chains list — the core "P-only" contract relied
|
||||
// upon by node.initChainManager to log "skipping" and run the node without
|
||||
// the missing chain. The behaviour MUST be deterministic and must not panic.
|
||||
func TestVMGenesisOptInChains(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
// Build a genesis with zero chains — pure P-only.
|
||||
pOnly := &genesis.Genesis{
|
||||
Chains: nil,
|
||||
}
|
||||
pOnlyBytes, err := genesis.Codec.Marshal(pchaintxs.CodecVersion, pOnly)
|
||||
require.NoError(err)
|
||||
|
||||
for name, vmID := range map[string]ids.ID{
|
||||
"XVM": constants.XVMID,
|
||||
"EVM": constants.EVMID,
|
||||
"Quantum": constants.QuantumVMID,
|
||||
"DexVM": constants.DexVMID,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
tx, lookupErr := VMGenesis(pOnlyBytes, vmID)
|
||||
require.Nil(tx, "P-only genesis must not surface a tx for %s", name)
|
||||
require.Error(lookupErr, "P-only genesis must report %s as missing", name)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,9 @@ import (
|
||||
"github.com/luxfi/crypto/hash"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/node/vms/platformvm/genesis"
|
||||
"github.com/luxfi/node/vms/platformvm/stakeable"
|
||||
"github.com/luxfi/node/vms/platformvm/txs"
|
||||
"github.com/luxfi/utxo/secp256k1fx"
|
||||
|
||||
genesiscfg "github.com/luxfi/genesis/pkg/genesis"
|
||||
)
|
||||
@@ -278,16 +280,16 @@ func TestFromConfigExplicitStakers(t *testing.T) {
|
||||
NetworkID: constants.LocalID,
|
||||
Allocations: []genesiscfg.Allocation{
|
||||
{
|
||||
ETHAddr: secpAddr,
|
||||
LUXAddr: secpAddr,
|
||||
EVMAddr: secpAddr,
|
||||
UTXOAddr: secpAddr,
|
||||
InitialAmount: 0,
|
||||
UnlockSchedule: []genesiscfg.LockedAmount{
|
||||
{Amount: oneBillionLUX, Locktime: 0},
|
||||
},
|
||||
},
|
||||
{
|
||||
ETHAddr: ethShortID,
|
||||
LUXAddr: ethShortID,
|
||||
EVMAddr: ethShortID,
|
||||
UTXOAddr: ethShortID,
|
||||
InitialAmount: oneMillionLUX,
|
||||
},
|
||||
},
|
||||
@@ -369,8 +371,8 @@ func TestFromConfigExplicitStakersNoStakedFunds(t *testing.T) {
|
||||
NetworkID: constants.LocalID,
|
||||
Allocations: []genesiscfg.Allocation{
|
||||
{
|
||||
ETHAddr: deployerAddr,
|
||||
LUXAddr: deployerAddr,
|
||||
EVMAddr: deployerAddr,
|
||||
UTXOAddr: deployerAddr,
|
||||
InitialAmount: oneMillionLUX,
|
||||
},
|
||||
},
|
||||
@@ -408,3 +410,117 @@ func TestFromConfigExplicitStakersNoStakedFunds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFromConfigLegacyShapedBackCompat proves the back-compat rule for
|
||||
// legacy legacy-shaped pchain configs where initialAmount AND unlockSchedule
|
||||
// are both set and initialAmount == sum(unlockSchedule). These are two views
|
||||
// of the same total. Emitting both would double-mint. The builder must emit
|
||||
// exactly one UTXO per unlock entry (no additional initialAmount UTXO).
|
||||
func TestFromConfigLegacyShapedBackCompat(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
var stakerAddr ids.ShortID
|
||||
copy(stakerAddr[:], hash.ComputeHash160([]byte("legacy-staker")))
|
||||
|
||||
var holderAddr ids.ShortID
|
||||
copy(holderAddr[:], hash.ComputeHash160([]byte("legacy-holder")))
|
||||
|
||||
startTime := uint64(time.Now().Unix())
|
||||
const hundredYears = 100 * 365 * 24 * 60 * 60
|
||||
|
||||
// holder gets 100 LUX, split into 10 yearly unlocks of 10 LUX each.
|
||||
// initialAmount is the total (legacy convention).
|
||||
const holderTotal = 100_000_000_000_000 // 100 LUX in nLUX
|
||||
const perUnlock = holderTotal / 10
|
||||
holderUnlocks := make([]genesiscfg.LockedAmount, 10)
|
||||
for i := range holderUnlocks {
|
||||
holderUnlocks[i] = genesiscfg.LockedAmount{
|
||||
Amount: perUnlock,
|
||||
Locktime: startTime + uint64(i)*365*24*60*60,
|
||||
}
|
||||
}
|
||||
|
||||
nodeID, err := ids.NodeIDFromString("NodeID-7D3wajA7bNpfyHpfEtkjUF1KcuhFEfPbZ")
|
||||
require.NoError(err)
|
||||
|
||||
cfg := &genesiscfg.Config{
|
||||
NetworkID: constants.LocalID,
|
||||
Allocations: []genesiscfg.Allocation{
|
||||
{
|
||||
EVMAddr: stakerAddr,
|
||||
UTXOAddr: stakerAddr,
|
||||
InitialAmount: 0,
|
||||
UnlockSchedule: []genesiscfg.LockedAmount{
|
||||
{Amount: holderTotal, Locktime: 0},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Legacy legacy-shaped: both fields set, initialAmount
|
||||
// equals sum(unlockSchedule). The builder MUST treat these
|
||||
// as one allocation (no double-mint).
|
||||
EVMAddr: holderAddr,
|
||||
UTXOAddr: holderAddr,
|
||||
InitialAmount: holderTotal,
|
||||
UnlockSchedule: holderUnlocks,
|
||||
},
|
||||
},
|
||||
StartTime: startTime,
|
||||
InitialStakeDuration: hundredYears,
|
||||
InitialStakeDurationOffset: 0,
|
||||
InitialStakedFunds: []ids.ShortID{stakerAddr},
|
||||
InitialStakers: []genesiscfg.Staker{
|
||||
{
|
||||
NodeID: nodeID,
|
||||
RewardAddress: stakerAddr,
|
||||
DelegationFee: 1000000,
|
||||
StartTime: startTime,
|
||||
EndTime: startTime + hundredYears,
|
||||
},
|
||||
},
|
||||
CChainGenesis: `{"config":{"chainId":31337},"alloc":{}}`,
|
||||
Message: "Legacy Shaped Back-Compat",
|
||||
}
|
||||
|
||||
genesisBytes, _, err := FromConfig(cfg)
|
||||
require.NoError(err)
|
||||
require.NotEmpty(genesisBytes)
|
||||
|
||||
parsed, err := genesis.Parse(genesisBytes)
|
||||
require.NoError(err)
|
||||
|
||||
// Count UTXOs belonging to holderAddr. With the back-compat fix the
|
||||
// builder emits exactly len(holderUnlocks) UTXOs (one per unlock entry)
|
||||
// and no extra immediately-spendable UTXO for initialAmount. Without
|
||||
// the fix it would emit len(holderUnlocks)+1 UTXOs (double-mint).
|
||||
//
|
||||
// Outputs with locktime in the future are wrapped in *stakeable.LockOut
|
||||
// whose inner Out is the *secp256k1fx.TransferOutput; outputs that are
|
||||
// immediately spendable are raw *secp256k1fx.TransferOutput.
|
||||
var holderUTXOs int
|
||||
var holderSum uint64
|
||||
for _, u := range parsed.UTXOs {
|
||||
var out *secp256k1fx.TransferOutput
|
||||
switch o := u.Out.(type) {
|
||||
case *secp256k1fx.TransferOutput:
|
||||
out = o
|
||||
case *stakeable.LockOut:
|
||||
inner, ok := o.TransferableOut.(*secp256k1fx.TransferOutput)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = inner
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if len(out.OutputOwners.Addrs) != 1 || out.OutputOwners.Addrs[0] != holderAddr {
|
||||
continue
|
||||
}
|
||||
holderUTXOs++
|
||||
holderSum += out.Amt
|
||||
}
|
||||
require.Equal(len(holderUnlocks), holderUTXOs,
|
||||
"holder must have exactly %d UTXOs (one per unlock entry), got %d (double-mint?)",
|
||||
len(holderUnlocks), holderUTXOs)
|
||||
require.Equal(uint64(holderTotal), holderSum,
|
||||
"holder UTXO sum must equal initialAmount (no double-mint)")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package builder
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
genesiscfg "github.com/luxfi/genesis/pkg/genesis"
|
||||
)
|
||||
|
||||
// TestCanonicalGenesisFixtureParses confirms the canonical mainnet genesis.json
|
||||
// (evmAddr/utxoAddr field names per genesis v1.12.19) parses cleanly via
|
||||
// GetConfigFile and produces non-zero EVMAddr / UTXOAddr values.
|
||||
//
|
||||
// This is the "have we adopted the rename" gate — if the loader rejects
|
||||
// the canonical fixture, the API rename did not land.
|
||||
func TestCanonicalGenesisFixtureParses(t *testing.T) {
|
||||
candidates := []string{
|
||||
filepath.Join(os.Getenv("HOME"), "work/lux/genesis/configs/mainnet/genesis.json"),
|
||||
"../../../genesis/configs/mainnet/genesis.json",
|
||||
}
|
||||
|
||||
var path string
|
||||
for _, p := range candidates {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
path = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
t.Skip("canonical mainnet genesis fixture not on disk; skipping")
|
||||
}
|
||||
|
||||
cfg, err := genesiscfg.GetConfigFile(path)
|
||||
require.NoError(t, err, "canonical fixture must parse with v1.12.19 evmAddr/utxoAddr names")
|
||||
require.NotEmpty(t, cfg.Allocations, "fixture has no allocations")
|
||||
|
||||
for i, a := range cfg.Allocations {
|
||||
require.NotEqual(t, ids.ShortEmpty, a.EVMAddr, "allocation[%d] EVMAddr is zero — parse silently dropped", i)
|
||||
require.NotEqual(t, ids.ShortEmpty, a.UTXOAddr, "allocation[%d] UTXOAddr is zero — parse silently dropped", i)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package builder
|
||||
|
||||
import (
|
||||
"github.com/luxfi/constants"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/utxo/bls12381fx"
|
||||
"github.com/luxfi/utxo/ed25519fx"
|
||||
"github.com/luxfi/utxo/mldsafx"
|
||||
"github.com/luxfi/utxo/nftfx"
|
||||
"github.com/luxfi/utxo/propertyfx"
|
||||
"github.com/luxfi/utxo/schnorrfx"
|
||||
"github.com/luxfi/utxo/secp256k1fx"
|
||||
"github.com/luxfi/utxo/secp256r1fx"
|
||||
"github.com/luxfi/utxo/slhdsafx"
|
||||
)
|
||||
|
||||
// ChainSpec is one row in the primary-network chain registry.
|
||||
//
|
||||
// Registry is the single source of truth for: canonical letter,
|
||||
// VMID, bech32 chain prefix used in addresses, alias list, and the
|
||||
// human-readable name. Rebranding or renaming a chain edits one
|
||||
// row; the prior switch-ladders in Aliases() and var blocks of
|
||||
// {P,X,C,...}ChainAliases re-derive from this table.
|
||||
type ChainSpec struct {
|
||||
// Letter is the canonical single-character chain code ("P", "X", ...).
|
||||
// Used as the first element of the chain alias list and as the
|
||||
// bech32 chain prefix.
|
||||
Letter string
|
||||
|
||||
// VMID is the VM that runs this chain. The (VMID -> aliases) map
|
||||
// VMAliasesMap() is derived from Registry by collecting Aliases
|
||||
// for each ChainSpec.
|
||||
VMID ids.ID
|
||||
|
||||
// Aliases are the non-letter aliases for this chain. The full
|
||||
// chain alias list returned by AliasesFor is [Letter] ++ Aliases.
|
||||
// These are also the VM aliases registered with the alias manager.
|
||||
Aliases []string
|
||||
|
||||
// Name is the human-readable chain name ("P-Chain", "C-Chain", ...).
|
||||
// Embedded into the primary-network CreateChainTx.
|
||||
Name string
|
||||
}
|
||||
|
||||
// Registry is the canonical list of primary-network chains.
|
||||
//
|
||||
// Order is fixed and matches the optIn order in FromConfig — reordering
|
||||
// shifts the P-Chain genesis byte layout for chains that share a
|
||||
// presence/absence shape, so this slice is append-only.
|
||||
//
|
||||
// P-Chain is implicit (the platform genesis itself); listed here so the
|
||||
// alias machinery has a row for it, but FromConfig does not iterate this
|
||||
// slice to emit CreateChainTx for P.
|
||||
var Registry = []ChainSpec{
|
||||
{Letter: "P", VMID: constants.PlatformVMID, Aliases: []string{"platform"}, Name: "P-Chain"},
|
||||
{Letter: "X", VMID: constants.XVMID, Aliases: []string{"xvm"}, Name: "X-Chain"},
|
||||
{Letter: "C", VMID: constants.EVMID, Aliases: []string{"evm"}, Name: "C-Chain"},
|
||||
{Letter: "D", VMID: constants.DexVMID, Aliases: []string{"dex", "dexvm"}, Name: "D-Chain"},
|
||||
{Letter: "Q", VMID: constants.QuantumVMID, Aliases: []string{"quantum", "quantumvm", "pq"}, Name: "Q-Chain"},
|
||||
{Letter: "A", VMID: constants.AIVMID, Aliases: []string{"attest", "ai", "aivm"}, Name: "A-Chain"},
|
||||
{Letter: "B", VMID: constants.BridgeVMID, Aliases: []string{"bridge", "bridgevm"}, Name: "B-Chain"},
|
||||
{Letter: "T", VMID: constants.ThresholdVMID, Aliases: []string{"threshold", "thresholdvm", "mpc"}, Name: "T-Chain"},
|
||||
{Letter: "Z", VMID: constants.ZKVMID, Aliases: []string{"zk", "zkvm"}, Name: "Z-Chain"},
|
||||
{Letter: "G", VMID: constants.GraphVMID, Aliases: []string{"graph", "graphvm", "dgraph"}, Name: "G-Chain"},
|
||||
{Letter: "K", VMID: constants.KeyVMID, Aliases: []string{"key", "keyvm"}, Name: "K-Chain"},
|
||||
}
|
||||
|
||||
// specsByLetter is an O(1) lookup built once at package init.
|
||||
var specsByLetter = func() map[string]ChainSpec {
|
||||
m := make(map[string]ChainSpec, len(Registry))
|
||||
for _, s := range Registry {
|
||||
m[s.Letter] = s
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// AliasesFor returns the full chain alias list for a chain by letter.
|
||||
// The letter itself is prepended, e.g. AliasesFor("D") -> ["D", "dex", "dexvm"].
|
||||
// Returns nil for unknown letters.
|
||||
func AliasesFor(letter string) []string {
|
||||
s, ok := specsByLetter[letter]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, 1+len(s.Aliases))
|
||||
out = append(out, s.Letter)
|
||||
out = append(out, s.Aliases...)
|
||||
return out
|
||||
}
|
||||
|
||||
// fxVMAliases is the static (non-chain) VM alias table for feature-extension
|
||||
// VMIDs. These are independent of the chain registry — they are not chains,
|
||||
// they are credential/output type extensions registered with the VM manager.
|
||||
var fxVMAliases = map[ids.ID][]string{
|
||||
secp256k1fx.ID: {"secp256k1fx"},
|
||||
nftfx.ID: {"nftfx"},
|
||||
propertyfx.ID: {"propertyfx"},
|
||||
mldsafx.ID: {"mldsafx"},
|
||||
slhdsafx.ID: {"slhdsafx"},
|
||||
ed25519fx.ID: {"ed25519fx"},
|
||||
secp256r1fx.ID: {"secp256r1fx"},
|
||||
schnorrfx.ID: {"schnorrfx"},
|
||||
bls12381fx.ID: {"bls12381fx"},
|
||||
}
|
||||
|
||||
// VMAliasesMap returns the union of chain VM aliases (from Registry) and
|
||||
// the static fx VM aliases (from fxVMAliases). This is the map the node's
|
||||
// VM manager consumes via builder.VMAliases.
|
||||
//
|
||||
// The values are fresh slice copies so callers cannot mutate Registry rows
|
||||
// through the returned map.
|
||||
func VMAliasesMap() map[ids.ID][]string {
|
||||
m := make(map[ids.ID][]string, len(Registry)+len(fxVMAliases))
|
||||
for _, s := range Registry {
|
||||
aliases := make([]string, len(s.Aliases))
|
||||
copy(aliases, s.Aliases)
|
||||
m[s.VMID] = aliases
|
||||
}
|
||||
for vmID, aliases := range fxVMAliases {
|
||||
cp := make([]string, len(aliases))
|
||||
copy(cp, aliases)
|
||||
m[vmID] = cp
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package builder
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/constants"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/utxo/bls12381fx"
|
||||
"github.com/luxfi/utxo/ed25519fx"
|
||||
"github.com/luxfi/utxo/mldsafx"
|
||||
"github.com/luxfi/utxo/nftfx"
|
||||
"github.com/luxfi/utxo/propertyfx"
|
||||
"github.com/luxfi/utxo/schnorrfx"
|
||||
"github.com/luxfi/utxo/secp256k1fx"
|
||||
"github.com/luxfi/utxo/secp256r1fx"
|
||||
"github.com/luxfi/utxo/slhdsafx"
|
||||
)
|
||||
|
||||
// TestChainAliasesRegistryParity asserts that the registry-derived
|
||||
// {P,X,C,...}ChainAliases vars hold the same slices as the legacy
|
||||
// hand-typed literals. If this test ever fails, the registry data
|
||||
// drifted from the legacy chain alias contract — either rebrand on
|
||||
// purpose (and update legacy literals here) or fix the Registry row.
|
||||
func TestChainAliasesRegistryParity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
got []string
|
||||
legacy []string
|
||||
}{
|
||||
{"P", PChainAliases, []string{"P", "platform"}},
|
||||
{"X", XChainAliases, []string{"X", "xvm"}},
|
||||
{"C", CChainAliases, []string{"C", "evm"}},
|
||||
{"D", DChainAliases, []string{"D", "dex", "dexvm"}},
|
||||
{"Q", QChainAliases, []string{"Q", "quantum", "quantumvm", "pq"}},
|
||||
{"A", AChainAliases, []string{"A", "attest", "ai", "aivm"}},
|
||||
{"B", BChainAliases, []string{"B", "bridge", "bridgevm"}},
|
||||
{"T", TChainAliases, []string{"T", "threshold", "thresholdvm", "mpc"}},
|
||||
{"Z", ZChainAliases, []string{"Z", "zk", "zkvm"}},
|
||||
{"G", GChainAliases, []string{"G", "graph", "graphvm", "dgraph"}},
|
||||
{"K", KChainAliases, []string{"K", "key", "keyvm"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.True(t,
|
||||
reflect.DeepEqual(tt.got, tt.legacy),
|
||||
"%sChainAliases drift: got %v, legacy %v", tt.name, tt.got, tt.legacy,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestVMAliasesRegistryParity asserts that VMAliases holds the same
|
||||
// (VMID -> alias set) the legacy literal map encoded. Alias order is
|
||||
// not load-bearing (the alias manager treats each entry as a name
|
||||
// mapping), so this test compares sorted alias sets.
|
||||
func TestVMAliasesRegistryParity(t *testing.T) {
|
||||
legacy := map[ids.ID][]string{
|
||||
constants.PlatformVMID: {"platform"},
|
||||
constants.XVMID: {"xvm"},
|
||||
constants.EVMID: {"evm"},
|
||||
constants.DexVMID: {"dexvm", "dex"},
|
||||
constants.QuantumVMID: {"quantumvm", "quantum", "pq"},
|
||||
constants.AIVMID: {"aivm", "attest", "ai"},
|
||||
constants.BridgeVMID: {"bridgevm", "bridge"},
|
||||
constants.ThresholdVMID: {"thresholdvm", "threshold", "mpc"},
|
||||
constants.ZKVMID: {"zkvm", "zk"},
|
||||
constants.GraphVMID: {"graphvm", "graph", "dgraph"},
|
||||
constants.KeyVMID: {"keyvm", "key"},
|
||||
secp256k1fx.ID: {"secp256k1fx"},
|
||||
nftfx.ID: {"nftfx"},
|
||||
propertyfx.ID: {"propertyfx"},
|
||||
mldsafx.ID: {"mldsafx"},
|
||||
slhdsafx.ID: {"slhdsafx"},
|
||||
ed25519fx.ID: {"ed25519fx"},
|
||||
secp256r1fx.ID: {"secp256r1fx"},
|
||||
schnorrfx.ID: {"schnorrfx"},
|
||||
bls12381fx.ID: {"bls12381fx"},
|
||||
}
|
||||
|
||||
require.Equal(t, len(legacy), len(VMAliases), "VMAliases entry count drift")
|
||||
|
||||
for vmID, want := range legacy {
|
||||
got, ok := VMAliases[vmID]
|
||||
require.True(t, ok, "VMAliases missing entry for VMID %s", vmID)
|
||||
|
||||
wantSorted := append([]string{}, want...)
|
||||
gotSorted := append([]string{}, got...)
|
||||
sort.Strings(wantSorted)
|
||||
sort.Strings(gotSorted)
|
||||
require.Equal(t, wantSorted, gotSorted, "VMAliases[%s] alias-set drift: got %v, want %v", vmID, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVMAliasesMapIsolation asserts that mutating the returned map's
|
||||
// slices does not corrupt the underlying Registry. Each call must
|
||||
// return fresh copies.
|
||||
func TestVMAliasesMapIsolation(t *testing.T) {
|
||||
a := VMAliasesMap()
|
||||
b := VMAliasesMap()
|
||||
|
||||
// Mutate a's slice for the X-Chain.
|
||||
a[constants.XVMID][0] = "MUTATED"
|
||||
|
||||
require.NotEqual(t, "MUTATED", b[constants.XVMID][0],
|
||||
"VMAliasesMap returns shared slices — mutation leaked across calls")
|
||||
require.NotEqual(t, "MUTATED", XChainAliases[1],
|
||||
"VMAliasesMap returns slices aliased with package vars")
|
||||
}
|
||||
|
||||
// TestAliasesForUnknown returns nil for letters that have no row in
|
||||
// Registry — used so callers don't accidentally register a phantom
|
||||
// chain via a typo.
|
||||
func TestAliasesForUnknown(t *testing.T) {
|
||||
require.Nil(t, AliasesFor(""))
|
||||
require.Nil(t, AliasesFor("?"))
|
||||
require.Nil(t, AliasesFor("XX"))
|
||||
}
|
||||
@@ -7,12 +7,11 @@ module github.com/luxfi/node
|
||||
//
|
||||
// - If updating between minor versions (e.g. 1.23.x -> 1.24.x):
|
||||
// - Consider updating the version of golangci-lint (in scripts/lint.sh).
|
||||
go 1.26.2
|
||||
go 1.26.3
|
||||
|
||||
exclude github.com/luxfi/geth v1.16.1
|
||||
|
||||
require (
|
||||
connectrpc.com/connect v1.19.1
|
||||
github.com/DataDog/zstd v1.5.7 // indirect
|
||||
github.com/StephenButtolph/canoto v0.17.3
|
||||
github.com/btcsuite/btcd/btcutil v1.1.6
|
||||
@@ -27,18 +26,18 @@ require (
|
||||
github.com/huin/goupnp v1.3.0
|
||||
github.com/jackpal/gateway v1.1.1
|
||||
github.com/jackpal/go-nat-pmp v1.0.2
|
||||
github.com/luxfi/consensus v1.23.2
|
||||
github.com/luxfi/crypto v1.18.3
|
||||
github.com/luxfi/consensus v1.25.2
|
||||
github.com/luxfi/crypto v1.19.15
|
||||
github.com/luxfi/database v1.18.3
|
||||
github.com/luxfi/ids v1.2.9
|
||||
github.com/luxfi/ids v1.2.10
|
||||
github.com/luxfi/keychain v1.0.2
|
||||
github.com/luxfi/log v1.4.1
|
||||
github.com/luxfi/math v1.4.0
|
||||
github.com/luxfi/metric v1.5.1
|
||||
github.com/luxfi/math v1.4.1
|
||||
github.com/luxfi/metric v1.5.5
|
||||
github.com/luxfi/mock v0.1.1
|
||||
github.com/mr-tron/base58 v1.2.0
|
||||
github.com/onsi/ginkgo/v2 v2.28.1
|
||||
github.com/pires/go-proxyproto v0.8.1
|
||||
github.com/pires/go-proxyproto v0.11.0
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
@@ -52,11 +51,11 @@ require (
|
||||
github.com/supranational/blst v0.3.16 // indirect
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20220614013038-64ee5596c38a // indirect
|
||||
github.com/thepudds/fzgen v0.4.3
|
||||
go.opentelemetry.io/otel v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0
|
||||
go.opentelemetry.io/otel/sdk v1.40.0
|
||||
go.opentelemetry.io/otel/trace v1.42.0
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.uber.org/goleak v1.3.0
|
||||
go.uber.org/mock v0.6.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
@@ -68,8 +67,8 @@ require (
|
||||
golang.org/x/tools v0.43.0
|
||||
gonum.org/v1/gonum v0.17.0
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -82,7 +81,6 @@ require (
|
||||
github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 // indirect
|
||||
github.com/cockroachdb/redact v1.1.8 // indirect
|
||||
github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb // indirect
|
||||
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
|
||||
github.com/fatih/structtag v1.2.0 // indirect
|
||||
@@ -112,8 +110,8 @@ require (
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
@@ -121,21 +119,21 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
connectrpc.com/grpcreflect v1.3.0
|
||||
github.com/cloudflare/circl v1.6.3
|
||||
github.com/consensys/gnark-crypto v0.20.1
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/golang/mock v1.7.0-rc.1
|
||||
github.com/luxfi/accel v1.0.9
|
||||
github.com/luxfi/api v1.0.10
|
||||
github.com/luxfi/accel v1.1.4
|
||||
github.com/luxfi/api v1.0.11
|
||||
github.com/luxfi/atomic v1.0.0
|
||||
github.com/luxfi/chains v1.2.1
|
||||
github.com/luxfi/chains v1.2.3
|
||||
github.com/luxfi/compress v0.0.5
|
||||
github.com/luxfi/constants v1.5.2
|
||||
github.com/luxfi/constants v1.5.7
|
||||
github.com/luxfi/container v0.0.4
|
||||
github.com/luxfi/filesystem v0.0.1
|
||||
github.com/luxfi/genesis v1.9.2
|
||||
github.com/luxfi/geth v1.16.87
|
||||
github.com/luxfi/genesis v1.12.19
|
||||
github.com/luxfi/genesis/pkg/genesis/security v1.12.19
|
||||
github.com/luxfi/geth v1.16.98
|
||||
github.com/luxfi/go-bip39 v1.1.2
|
||||
github.com/luxfi/lattice/v7 v7.1.0
|
||||
github.com/luxfi/math/safe v0.0.1
|
||||
@@ -143,18 +141,18 @@ require (
|
||||
github.com/luxfi/p2p v1.19.2
|
||||
github.com/luxfi/resource v0.0.1
|
||||
github.com/luxfi/rpc v1.0.2
|
||||
github.com/luxfi/runtime v1.0.1
|
||||
github.com/luxfi/sdk v1.16.52
|
||||
github.com/luxfi/runtime v1.1.0
|
||||
github.com/luxfi/sdk v1.16.60
|
||||
github.com/luxfi/sys v0.0.0-20260110090042-50187ec5ffd8
|
||||
github.com/luxfi/timer v1.0.2
|
||||
github.com/luxfi/units v1.0.0
|
||||
github.com/luxfi/utils v1.1.4
|
||||
github.com/luxfi/utxo v0.3.0
|
||||
github.com/luxfi/validators v1.0.0
|
||||
github.com/luxfi/utxo v0.3.2
|
||||
github.com/luxfi/validators v1.2.0
|
||||
github.com/luxfi/vm v1.0.40
|
||||
github.com/luxfi/warp v1.18.5
|
||||
github.com/luxfi/warp v1.18.6
|
||||
github.com/luxfi/zwing v0.5.2
|
||||
github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0
|
||||
go.uber.org/zap v1.27.1
|
||||
)
|
||||
|
||||
@@ -163,28 +161,26 @@ require (
|
||||
filippo.io/hpke v0.4.0 // indirect
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.6 // indirect
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
||||
github.com/btcsuite/btcutil v1.0.2 // indirect
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/gtank/merlin v0.1.1 // indirect
|
||||
github.com/gtank/ristretto255 v0.2.0 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/luxfi/age v1.4.0 // indirect
|
||||
github.com/luxfi/age v1.5.0 // indirect
|
||||
github.com/luxfi/ai v0.1.0 // indirect
|
||||
github.com/luxfi/config v1.1.2 // indirect
|
||||
github.com/luxfi/evm v0.8.49 // indirect
|
||||
github.com/luxfi/keys v1.0.8 // indirect
|
||||
github.com/luxfi/lens v0.1.3 // indirect
|
||||
github.com/luxfi/netrunner v1.18.1 // indirect
|
||||
github.com/luxfi/corona v0.7.5 // indirect
|
||||
github.com/luxfi/crypto/ipa v1.2.4 // indirect
|
||||
github.com/luxfi/lens v0.1.4 // indirect
|
||||
github.com/luxfi/oracle v0.1.1-0.20260429020431-76258cfcddf9 // indirect
|
||||
github.com/luxfi/pulsar v0.1.5 // indirect
|
||||
github.com/luxfi/pq v1.0.3 // indirect
|
||||
github.com/luxfi/protocol v0.0.4 // indirect
|
||||
github.com/luxfi/pulsar v1.0.12 // indirect
|
||||
github.com/luxfi/relay v0.0.0-20260429020048-c629fe160d3c // indirect
|
||||
github.com/luxfi/staking v1.1.0 // indirect
|
||||
github.com/luxfi/threshold v1.6.7 // indirect
|
||||
github.com/luxfi/threshold v1.8.5 // indirect
|
||||
github.com/luxfi/trace v0.1.4 // indirect
|
||||
github.com/luxfi/zapdb v1.8.0 // indirect
|
||||
github.com/luxfi/zwing v0.5.2 // indirect
|
||||
github.com/luxfi/zapdb v1.10.0 // indirect
|
||||
github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
@@ -192,12 +188,12 @@ require (
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect
|
||||
github.com/luxfi/concurrent v0.0.3
|
||||
github.com/luxfi/protocol v0.0.3 // indirect
|
||||
github.com/luxfi/proto v1.0.2
|
||||
github.com/luxfi/upgrade v1.0.0 // indirect
|
||||
github.com/luxfi/version v1.0.1
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
@@ -206,7 +202,6 @@ require (
|
||||
require (
|
||||
github.com/ALTree/bigfloat v0.2.0 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260311194731-d5b7577c683d // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cockroachdb/fifo v0.0.0-20240816210425-c5d0cb0b6fc0 // indirect
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect
|
||||
@@ -214,7 +209,6 @@ require (
|
||||
github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.7 // indirect
|
||||
github.com/ethereum/go-verkle v0.2.2 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
@@ -228,7 +222,6 @@ require (
|
||||
github.com/luxfi/formatting v1.0.1
|
||||
github.com/luxfi/go-bip32 v1.0.2
|
||||
github.com/luxfi/math/big v0.1.0 // indirect
|
||||
github.com/luxfi/corona v0.2.0 // indirect
|
||||
github.com/luxfi/sampler v1.0.0 // indirect
|
||||
github.com/luxfi/tls v1.0.3 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
@@ -244,3 +237,5 @@ require (
|
||||
)
|
||||
|
||||
exclude github.com/ethereum/go-ethereum v1.10.26
|
||||
|
||||
replace github.com/luxfi/corona => github.com/luxfi/corona v0.7.5
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
|
||||
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
|
||||
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
|
||||
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
|
||||
connectrpc.com/grpcreflect v1.3.0 h1:Y4V+ACf8/vOb1XOc251Qun7jMB75gCUNw6llvB9csXc=
|
||||
connectrpc.com/grpcreflect v1.3.0/go.mod h1:nfloOtCS8VUQOQ1+GTdFzVg2CJo4ZGaat8JIovCtDYs=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
|
||||
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
|
||||
github.com/ALTree/bigfloat v0.2.0 h1:AwNzawrpFuw55/YDVlcPw0F0cmmXrmngBHhVrvdXPvM=
|
||||
github.com/ALTree/bigfloat v0.2.0/go.mod h1:+NaH2gLeY6RPBPPQf4aRotPPStg+eXc8f9ZaE4vRfD4=
|
||||
github.com/ChainSafe/go-schnorrkel v1.1.0 h1:rZ6EU+CZFCjB4sHUE1jIu8VDoB/wRKZxoe1tkcO71Wk=
|
||||
github.com/ChainSafe/go-schnorrkel v1.1.0/go.mod h1:ABkENxiP+cvjFiByMIZ9LYbRoNNLeBLiakC1XeTFxfE=
|
||||
github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE=
|
||||
github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260311194731-d5b7577c683d h1:EA+kZ8mxGb1W/ewiIBMzb/1gg5BiW1Fvr3r4qCUBJEg=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260311194731-d5b7577c683d/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/StephenButtolph/canoto v0.17.3 h1:lvsnYD4b96vD1knnmp1xCmZqfYpY/jSeRozGdOfdvGI=
|
||||
github.com/StephenButtolph/canoto v0.17.3/go.mod h1:IcnAHC6nJUfQFVR9y60ko2ecUqqHHSB6UwI9NnBFZnE=
|
||||
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
||||
@@ -44,8 +40,6 @@ github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
|
||||
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
|
||||
github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts=
|
||||
github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts=
|
||||
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
|
||||
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
|
||||
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
|
||||
@@ -78,13 +72,13 @@ github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb h1:3bCgBvB
|
||||
github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
|
||||
github.com/consensys/gnark-crypto v0.20.1 h1:PXDUBvk8AzhvWowHLWBEAfUQcV1/aZgWIqD6eMpXmDg=
|
||||
github.com/consensys/gnark-crypto v0.20.1/go.mod h1:RBWrSgy+IDbGR69RRV313th3M/aZU1ubk2om+qHuTSc=
|
||||
github.com/cosmos/go-bip39 v1.0.0 h1:pcomnQdrdH22njcAatO0yWojsUnCO3y2tNoV1cb6hHY=
|
||||
github.com/cosmos/go-bip39 v1.0.0/go.mod h1:RNJv0H/pOIVgxw6KS7QeX2a0Uo0aKUlfhZ4xuwvCdJw=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc=
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
|
||||
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg=
|
||||
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/cronokirby/saferith v0.33.0 h1:TgoQlfsD4LIwx71+ChfRcIpjkw+RPOapDEVxa+LhwLo=
|
||||
github.com/cronokirby/saferith v0.33.0/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA=
|
||||
@@ -117,8 +111,6 @@ github.com/ethereum/c-kzg-4844/v2 v2.1.7 h1:aat3CuITdDbPC6pmEGRT0zJ5eOxzrZj8TJT5
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.7/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw=
|
||||
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk=
|
||||
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8=
|
||||
github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8=
|
||||
github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
|
||||
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
|
||||
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
|
||||
github.com/ferranbt/fastssz v1.0.0 h1:9EXXYsracSqQRBQiHeaVsG/KQeYblPf40hsQPb9Dzk8=
|
||||
@@ -205,8 +197,6 @@ github.com/gorilla/rpc v1.2.1/go.mod h1:uNpOihAlF5xRFLuTYhfR0yfCTm0WTQSQttkMSptR
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/gtank/merlin v0.1.1 h1:eQ90iG7K9pOhtereWsmyRJ6RAwcP4tHTDBHXNg+u5is=
|
||||
@@ -256,137 +246,110 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
|
||||
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
|
||||
github.com/luxfi/accel v1.0.9 h1:QpXaZQUll0IlOHV+YgYUbrWcJeOYNNGzkwjI8e091x0=
|
||||
github.com/luxfi/accel v1.0.9/go.mod h1:XvgcJLYsLObbKlrOgd3E2OJx5Oy5c/HnDlIewjr+Y+c=
|
||||
github.com/luxfi/accel v1.1.4 h1:UOvS/00vG6WByf2P1tGYRkoBcsUkFuCgw7o2xU03OoE=
|
||||
github.com/luxfi/accel v1.1.4/go.mod h1:K00BcnLzEYMPHwCFq8Tf/450ApmTs9xBVvYOnofJCkc=
|
||||
github.com/luxfi/address v1.0.1 h1:Sc4keyuVzBIvHr7uVeYZf2/WY9YDGUgDi/iiWenj49g=
|
||||
github.com/luxfi/address v1.0.1/go.mod h1:5j3Eh66v9zvv1GbNdZwt+23krV8JlSDaRzmWZU8ZRM0=
|
||||
github.com/luxfi/age v1.4.0 h1:tU5q65RQSQdaVq64Z/DVUhiPQMoMPQT6pr41LsvG0AQ=
|
||||
github.com/luxfi/age v1.4.0/go.mod h1:iAYAxgvrXxcy746+Ovh/eWWDuF9teJLNcCSSOX9RYW0=
|
||||
github.com/luxfi/age v1.5.0 h1:G69HbSV4R3vKEH9B0CulnRaMdSdf4RalMgP8xKmxHeI=
|
||||
github.com/luxfi/age v1.5.0/go.mod h1:iAYAxgvrXxcy746+Ovh/eWWDuF9teJLNcCSSOX9RYW0=
|
||||
github.com/luxfi/ai v0.1.0 h1:PwTGob0GJivbdqNpUs82bvwcE8/qxyTokxfY7BZVPoo=
|
||||
github.com/luxfi/ai v0.1.0/go.mod h1:lTuY32dGjEJUgVheYuanlt1O0jHj0AZ09sUSpRsmH6M=
|
||||
github.com/luxfi/api v1.0.4 h1:5altGkSSk3zIsGzK/NPhRQe/It5QMHrAPgBVg2TMkK4=
|
||||
github.com/luxfi/api v1.0.4/go.mod h1:Znr2A+SDJBCZ7ofxeq0MswHI0gk0cJsIYJLcu3tT8JY=
|
||||
github.com/luxfi/api v1.0.5 h1:5+sepMbIBDzeIA0QTO66X/2D4SR8dKEQwQpdrmFmr+Q=
|
||||
github.com/luxfi/api v1.0.5/go.mod h1:5OFWvZF+PbyuvLCDyKKpXCA/xp+LxJ32yOhNEKwQFN0=
|
||||
github.com/luxfi/api v1.0.10 h1:tqtiCX8DcBsFG0JEhKy7eUNI+42+m2DoPjLjEh5TE5E=
|
||||
github.com/luxfi/api v1.0.10/go.mod h1:5OFWvZF+PbyuvLCDyKKpXCA/xp+LxJ32yOhNEKwQFN0=
|
||||
github.com/luxfi/api v1.0.11 h1:t4fzN9Ox/Vy5msW2WpSXq4xCAmBXXJS0oM+zIjM9IiQ=
|
||||
github.com/luxfi/api v1.0.11/go.mod h1:q3Hh3mmkvp3cnv3aqe2+gCtmmSAL/uFjuzrqGavQLNA=
|
||||
github.com/luxfi/atomic v1.0.0 h1:xUV60MuzRvXngaQ1sM0yVC2v4TRoLlUGkkH7M9PS4yw=
|
||||
github.com/luxfi/atomic v1.0.0/go.mod h1:0G2mTlQ6TXWHICUHrUUPu1/qAiIyR4gSZ2tva9ci/bI=
|
||||
github.com/luxfi/cache v1.2.1 h1:kAzOS55/hmYeNKR+0HAKv4ma48Y6JjkI8UQeqdZ8bfI=
|
||||
github.com/luxfi/cache v1.2.1/go.mod h1:co7JTxZZHpKT31Yh01LFp5aZOxmoUg157FhBLQdQHVU=
|
||||
github.com/luxfi/chains v1.2.0 h1:AOoJ9OeMzzdPZV1NmKHl4KxxCiRBgUHHERU5llVEuFY=
|
||||
github.com/luxfi/chains v1.2.0/go.mod h1:B5D2saO5i1HT1CvdWr4zfJRccMeSf02qHBf/1X2ZELE=
|
||||
github.com/luxfi/chains v1.2.1 h1:eeqekwYV8E1OTIGFwzKBcI+9JGNzm3pfg+56qUn3BTQ=
|
||||
github.com/luxfi/chains v1.2.1/go.mod h1:B5D2saO5i1HT1CvdWr4zfJRccMeSf02qHBf/1X2ZELE=
|
||||
github.com/luxfi/chains v1.2.3 h1:d/xrJRuLYc/AkeugR4kRTwlYHudh1dfqjIaHGZPfIcI=
|
||||
github.com/luxfi/chains v1.2.3/go.mod h1:aofU2aS8s5tHiD8VVQydcYStQCpR356f1UPHGlNi67U=
|
||||
github.com/luxfi/codec v1.1.4 h1:Yl8ZalMNkqo7cD6R9AjczAajkLOmsjyZ9+DASVYHrvg=
|
||||
github.com/luxfi/codec v1.1.4/go.mod h1:oGQ3j6E8c2P0pL0irYtWkrB1hmDUFIE0puXHK4gV5KI=
|
||||
github.com/luxfi/compress v0.0.5 h1:4tEUHw5MK1bu5UOjfYCt4OKMiH7yykIgmGPRA/BfJTM=
|
||||
github.com/luxfi/compress v0.0.5/go.mod h1:Cc1yxD2pfzrvpO32W2GDwLKff+CylHEvzZh2Ko8RSIU=
|
||||
github.com/luxfi/concurrent v0.0.3 h1:eJyv1fhaC0jMLMw6+QS774cUmp7GK+ouMgvLCqnC7cc=
|
||||
github.com/luxfi/concurrent v0.0.3/go.mod h1:Aj/FR5NpM0cB2P4Nt3+tz9+dV6V+LUW4HuMgSjwq5hw=
|
||||
github.com/luxfi/config v1.1.2 h1:iCUewwm7oT7ckRNyQkrVHZ5Ge+l+FadciGX/zyaPo/k=
|
||||
github.com/luxfi/config v1.1.2/go.mod h1:z6t0a5pGpQz2uDW2qJPLX5fZ/eWbpiNa51gBc63ebFk=
|
||||
github.com/luxfi/consensus v1.22.85 h1:S/XpPF+eDapbZb4AIu0Vr1GXzg/sAxjrAff2M2mkOx0=
|
||||
github.com/luxfi/consensus v1.22.85/go.mod h1:y6y+bVjvpDLkkTBCCKLcch6R4PRW8EA+lTfr+6sB6F8=
|
||||
github.com/luxfi/consensus v1.23.0 h1:Jp/SuhDfLvbdXqMCi7NktWQTDXrKPbStEvIQjXsM3+g=
|
||||
github.com/luxfi/consensus v1.23.0/go.mod h1:HufC4lEizSDbpG6JBzeg5Z1ZSpZi+1/cL4/PrJr4FEI=
|
||||
github.com/luxfi/consensus v1.23.1 h1:JWJunRWlr2bwBVl3lfeMAmgnuibhlJS66bTM9ftcfxI=
|
||||
github.com/luxfi/consensus v1.23.1/go.mod h1:ZkD2ART+vXh9DqWJqqYAV+U0BA66DtQGFwpQbnP46pE=
|
||||
github.com/luxfi/consensus v1.23.2 h1:Edr/AhBbJH9RXwplCn65jYYb73bxRqbzKF6orpYYyfc=
|
||||
github.com/luxfi/consensus v1.23.2/go.mod h1:16Q5zhPY09mhjEMOetd6QMzf0ZFqGSt6K5Fy7V/BTE8=
|
||||
github.com/luxfi/constants v1.4.7 h1:e/Qs+DQP3pugle3Zncq6fZCxKgqqtbyD/z7Gm4ZjsYg=
|
||||
github.com/luxfi/constants v1.4.7/go.mod h1:hOszZ2NDQ8gMZKncfcZ67PXkb5OIbnwAzXC3oFbQwW0=
|
||||
github.com/luxfi/constants v1.5.2 h1:fwQepO6viKAwf8ACnP9YV0TVUxndZuZM7U/aKcqe9EM=
|
||||
github.com/luxfi/constants v1.5.2/go.mod h1:hOszZ2NDQ8gMZKncfcZ67PXkb5OIbnwAzXC3oFbQwW0=
|
||||
github.com/luxfi/consensus v1.25.2 h1:ee1UYG7Pa2MTGRCCOLwUhPByNW6SQM89jgRtMuVEoDY=
|
||||
github.com/luxfi/consensus v1.25.2/go.mod h1:jkKzKyIgg/JqaEumxZOJd9ofWM3pXnVVLlQp+3qo9SM=
|
||||
github.com/luxfi/constants v1.5.7 h1:a1tCMdxd+pClPMIPOaI9vcYGNy6cQIc2rubac2Trc0k=
|
||||
github.com/luxfi/constants v1.5.7/go.mod h1:z+Wc7skybZAA+xuBWNcmtv402S/BFqixL+FiSQXPg8U=
|
||||
github.com/luxfi/container v0.0.4 h1:BXhF82WyfqVP5mjlNcr7tP0Fcnvl0Ap1rkiu+rq5XuM=
|
||||
github.com/luxfi/container v0.0.4/go.mod h1:Z3SpmMF5d4t77MM0nHYXURpn+EMVaeu1fhbd/3BGaek=
|
||||
github.com/luxfi/crypto v1.18.1 h1:Qaw8hSK0unIOAH6TRvu0s3JfoJWcg6zTf5eZ5adgKic=
|
||||
github.com/luxfi/crypto v1.18.1/go.mod h1:QhSdEEk3xmgR5a97/nkXB/n21ZsI1Iv7tcbqQaNM5zs=
|
||||
github.com/luxfi/crypto v1.18.3 h1:f6mBUHNjZl9/9nXPSqjq+oJttrPl8slvcZ49Vi7jKw4=
|
||||
github.com/luxfi/crypto v1.18.3/go.mod h1:qBtCJDT6p6aNa2hMtPxYwC688Bnsb3Amso8q09GsMls=
|
||||
github.com/luxfi/database v1.18.2 h1:r7jU8s4jaN0NpzWB0x91yyaL4CoCe0SxWEvxJGhJ7rE=
|
||||
github.com/luxfi/database v1.18.2/go.mod h1:PW0oGujt165mYg7j/lctVbnfsUW6QLfIAgjyQdS66SM=
|
||||
github.com/luxfi/corona v0.7.5 h1:XqcnsKaiP/EyDJzmDhziS+kDhX34TsVQiXTM8PvRROg=
|
||||
github.com/luxfi/corona v0.7.5/go.mod h1:4aD7+ZqnlZ2aVuU/DBQ5aspIagv5ux45LW2sJ4+siY8=
|
||||
github.com/luxfi/crypto v1.19.15 h1:Tf+iPRXv08Rlj9k7iNtYy0e1tJWIgRcY0pTR4rQax6w=
|
||||
github.com/luxfi/crypto v1.19.15/go.mod h1:INjdZtke85k8hX/QAmTMAY8bbZ4gzGZQLqURg3xf6Gk=
|
||||
github.com/luxfi/crypto/ipa v1.2.4 h1:6xfwhI9/HrcDkF3Ti5/NxsNQIWbwYDJmRSNIHRQ/xfU=
|
||||
github.com/luxfi/crypto/ipa v1.2.4/go.mod h1:43J6f6rcfUMrZt4cQectMOZb6Ps+fAEj8ZTPC3Kk+gE=
|
||||
github.com/luxfi/database v1.18.3 h1:gg+xwhKUxXa7fDoOD8IS91E71QqoEtcDCl2nfS61Jgg=
|
||||
github.com/luxfi/database v1.18.3/go.mod h1:sv0pYCGKlK1aNJTICxFUDpVWCJTigoLlshHmV/1pg7c=
|
||||
github.com/luxfi/evm v0.8.49 h1:xBiGibL6v1Gm02EIHVtvGvAIGVdPUY0kgsX8MDynApI=
|
||||
github.com/luxfi/evm v0.8.49/go.mod h1:fgtQq7WGR5EpaqJTdYn//EqzAZDvBH9OKkPYS7s4oMI=
|
||||
github.com/luxfi/filesystem v0.0.1 h1:VZ6xMFKaAPBW/ddlMsDnI2G0VU1lV5rYaVcW5d+KwEY=
|
||||
github.com/luxfi/filesystem v0.0.1/go.mod h1:OQVSU6XNwqrr1AI+MqkID2taHUclx7NYmmr3svgttec=
|
||||
github.com/luxfi/formatting v1.0.1 h1:ZnE1rAdEUds9yAegdVdGDOBGN6hLMPOv6E03Fp8IEYo=
|
||||
github.com/luxfi/formatting v1.0.1/go.mod h1:mYzNf5DJOiqSSKUPzNj5dKy4tstFbN3pZlkI5716eKc=
|
||||
github.com/luxfi/genesis v1.8.2 h1:jFFMDO/yyzq1KzL5FaXzkDV84BIFt72446jP56jPdcQ=
|
||||
github.com/luxfi/genesis v1.8.2/go.mod h1:k26ZZvzES8e9cep9oWUDggBX+6oa5bnIQ7MmQP7dm4A=
|
||||
github.com/luxfi/genesis v1.9.2 h1:DMcFVLebRgcA+ElTbxAqaFOOMvXEU7UsBE8yBYGLum0=
|
||||
github.com/luxfi/genesis v1.9.2/go.mod h1:k26ZZvzES8e9cep9oWUDggBX+6oa5bnIQ7MmQP7dm4A=
|
||||
github.com/luxfi/geth v1.16.86-0.20260413014255-3e903c3d2e06 h1:NAIcfesKGSITw/mYTQpq62igXqUiDR7gekx8rQTKBmQ=
|
||||
github.com/luxfi/geth v1.16.86-0.20260413014255-3e903c3d2e06/go.mod h1:OdpR6OnAsYROywc8wm7W0lPn1z06DjgVmRL/F8dz5Kw=
|
||||
github.com/luxfi/geth v1.16.87 h1:UDjMRrseg/D5a3NDygdJXMkfJcHNOZXzcMcCn/AhIV8=
|
||||
github.com/luxfi/geth v1.16.87/go.mod h1:E5YcGOW9jB6Rgo4LB7NlTZxvkPzIoh1w7q56O63Zf1g=
|
||||
github.com/luxfi/genesis v1.12.19 h1:GwwbWOhiLcDJyM/17nI0s/sDaXN+PcZk9kjHYL9EFTo=
|
||||
github.com/luxfi/genesis v1.12.19/go.mod h1:SoSMZgvGMXAzYN7c7a6DYomR4JnyHIouHpyzTi+WM0k=
|
||||
github.com/luxfi/genesis/pkg/genesis/security v1.12.19 h1:UjlMWUfoMB8P/VyxeSupz5klWcfpRYgjT6eulVju8K8=
|
||||
github.com/luxfi/genesis/pkg/genesis/security v1.12.19/go.mod h1:68FLxDptmJ3fYYwS6jGIYkoa0NWMWzYhTpusf0FMeDg=
|
||||
github.com/luxfi/geth v1.16.98 h1:w187TtKuGStf3tm2bshuHVKBv2Frjx0lT54kQVXyNHA=
|
||||
github.com/luxfi/geth v1.16.98/go.mod h1:6kEzSExdk9CPQDPXALt6P3HfQqBq7KF1Jrrv9gBpxbU=
|
||||
github.com/luxfi/go-bip32 v1.0.2 h1:7vFbb+Wr4Z499q2tuCLdd7wWjtn8sH+HWBlx76mhH9Y=
|
||||
github.com/luxfi/go-bip32 v1.0.2/go.mod h1:bc7/LXDKAJQZ/F0Xjf5yXaTZxY9/ssLb4FC+Hxn/cDk=
|
||||
github.com/luxfi/go-bip39 v1.1.2 h1:p+wLMPGs6MLQh7q0YIsmy2EhHL7LHiELEGTJko6t/Jg=
|
||||
github.com/luxfi/go-bip39 v1.1.2/go.mod h1:96de9VkR2kY/ASAnhMtvt3TSh+PZkAFAngNj0GjRGDo=
|
||||
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/ids v1.2.10 h1:f1WILZE199ayMuqnEyB2WP1qfMZkmozOQXSVYtB3e5k=
|
||||
github.com/luxfi/ids v1.2.10/go.mod h1:QBIwy3OHvrtskbUqKh1+OYRa6PsyR7f7oNX33sOfK7w=
|
||||
github.com/luxfi/keychain v1.0.2 h1:uQgmjs37/VBIALEiYrrszTpxvtqr07/YvS9TnmxGafs=
|
||||
github.com/luxfi/keychain v1.0.2/go.mod h1:q/4ULgZBlstKkwzOzG/0T6y73BDPgnkrcibbJyTvmbU=
|
||||
github.com/luxfi/keys v1.0.8 h1:fANl6qZInndyCcXHAWiHTQ50LHhU8pJlF0Ix3phTo4A=
|
||||
github.com/luxfi/keys v1.0.8/go.mod h1:XbSCtgLyzEEVz2oZsYxnhRrNTGBxqrAm2Zu0uu06+qA=
|
||||
github.com/luxfi/lattice/v7 v7.0.0 h1:d1vgan6mlb2KtwYfPc1g69uxVYaoVYZmlrIm4aCO88w=
|
||||
github.com/luxfi/lattice/v7 v7.0.0/go.mod h1:PFDdOkuGTQ0cbJMbKojzEJMGWUQmZW+wK9/wJ9F9fOs=
|
||||
github.com/luxfi/lattice/v7 v7.1.0 h1:mr3HvN6olNTS2LT/xAW/JBhTqfvpsGmsopDMeR7BSJs=
|
||||
github.com/luxfi/lattice/v7 v7.1.0/go.mod h1:IaaUN+3ysnBG4BA8ILRYG0j80+qtYDP4C5lkaDb2pDE=
|
||||
github.com/luxfi/lens v0.1.3 h1:JTk/AjhHdKaiJ6Z7ZNazqnRfbgRijUPIxHamKW7b/N8=
|
||||
github.com/luxfi/lens v0.1.3/go.mod h1:XHpPOSgkT0ZFwfwp0t70Tvs5b6rjGyo1SNDEIDtfrqw=
|
||||
github.com/luxfi/lens v0.1.4 h1:goGjGDXx2BNdjzXDunL5QT8elK2ZyCcc0z8TAbtWYrg=
|
||||
github.com/luxfi/lens v0.1.4/go.mod h1:mL+G8IK+9L41d78/2FYRgfhEzAjcr5+VEXB8SGuHbus=
|
||||
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/math v1.4.0 h1:/sb7Grw3hfO+5INWAWdB95jTvCeXg8fSQxsxDzcFtd4=
|
||||
github.com/luxfi/math v1.4.0/go.mod h1:iW0FOCC8qF2mPE+MakG780CAHA83848lb1L04thA1Pg=
|
||||
github.com/luxfi/math v1.4.1 h1:1t9bCCsEqnl9yIKrShlbs80DBKyYTWdnzkVfBqEeO7Q=
|
||||
github.com/luxfi/math v1.4.1/go.mod h1:QvbRxauQyE1w4lvbcLSe6c8yeJz2Zj1Bq1rayGgs2tA=
|
||||
github.com/luxfi/math/big v0.1.0 h1:Vz4c0RsZVPdIKPsHPgAJChH/R3p15WHRUz7LkLf+NIQ=
|
||||
github.com/luxfi/math/big v0.1.0/go.mod h1:BuxSu22RbO93xBLk5Eam5nldFponoJ73xDFz4uJ3Huk=
|
||||
github.com/luxfi/math/safe v0.0.1 h1:GfSBINV9mOFgHzd32JbgfHSLhlNn0BwnP43rteYEosc=
|
||||
github.com/luxfi/math/safe v0.0.1/go.mod h1:EejrmOJHh03YAD8+Zww8cPcMR1K3Q2I7w1dX4sMloeo=
|
||||
github.com/luxfi/metric v1.5.1 h1:6tVarXMnNR3Xzud8FYUHjtXabTll3HI/OEqG0tgLAcI=
|
||||
github.com/luxfi/metric v1.5.1/go.mod h1:PkD4D4JoGuyKtfUkqPNYkrg9xKrJeNVZFdW/5XvAe/A=
|
||||
github.com/luxfi/metric v1.5.5 h1:JXruty5ZN/ljeRNaCSabQGg1Xr3re2E8wqajVUUs6w4=
|
||||
github.com/luxfi/metric v1.5.5/go.mod h1:CMguEhyuLi4YUWyXimJ+UHply99BDFrL0pxedB7rBqM=
|
||||
github.com/luxfi/mock v0.1.1 h1:0HEtIjg1J6CWz+IUyP6rsGqNWTcmxjFnSQIhaDuARwY=
|
||||
github.com/luxfi/mock v0.1.1/go.mod h1:jo35akl3Vtd8LbzDts8VJ0jmSVycrd1/eBi6g6t5hKU=
|
||||
github.com/luxfi/net v0.0.4 h1:z1d6Q5c9/79jb4vF0XwBBjlF5swH5NsgfaXA+Pgojq8=
|
||||
github.com/luxfi/net v0.0.4/go.mod h1:QvgHzCa767cVWtPpui0P7HW1IrA2+c++hhvaQ/t0yyw=
|
||||
github.com/luxfi/netrunner v1.18.1 h1:tiLpbxoKedoiBORATKZ/mW00zd7SEC/P+Q9KWDldn2M=
|
||||
github.com/luxfi/netrunner v1.18.1/go.mod h1:zBAG98SGjPdBpo2/ccPvHCzvTQVvayH/Q8xviNa0FnU=
|
||||
github.com/luxfi/oracle v0.1.1-0.20260429020431-76258cfcddf9 h1:y+foW/ZJuTEb/AUUaJGrll2PVWumTiydWZuYJV4bJ/k=
|
||||
github.com/luxfi/oracle v0.1.1-0.20260429020431-76258cfcddf9/go.mod h1:ueeF3b4SrhpCNb8hUYeP/kV7qgkp/BKKjsKbUhEjg40=
|
||||
github.com/luxfi/p2p v1.19.2 h1:uqZq7ofmEDbXlTkv1QThtci01Q+dmDkNmAPeORIyP8E=
|
||||
github.com/luxfi/p2p v1.19.2/go.mod h1:tI9Bt1R0ouvVtJvXG4e20GlGeV4AR230k4mFF9Vglzk=
|
||||
github.com/luxfi/precompile v0.5.12 h1:YiF8gaPVFjXESHC+44XVM1RkAyZFMb8iU/60Tfq3Kxs=
|
||||
github.com/luxfi/precompile v0.5.12/go.mod h1:YiTU6xMf2wmKM2LBzKctn82JhaesFDz4ysC+S864qCA=
|
||||
github.com/luxfi/precompile v0.5.14 h1:z8mlWv7yWqWczWp59qoxTp0B7l/a0HMtMBPk27MEKyg=
|
||||
github.com/luxfi/protocol v0.0.3 h1:Teytlu6Gbd0HqJXuDvV84ArqRabEFR40yDNNQUOCpVE=
|
||||
github.com/luxfi/protocol v0.0.3/go.mod h1:ZSA1i7f2SlN129UG7rUZ+doHvf5/mtiurJMqRwGB4M8=
|
||||
github.com/luxfi/pulsar v0.1.5 h1:Yi9zlYsSOrParueW3ngm3EUVAwZx6DhFsO3cFH3h+1k=
|
||||
github.com/luxfi/pulsar v0.1.5/go.mod h1:Zei7PZhYxEsfzY5Rg0iI73nMU0ngO8EpUhlOOC0Ys7g=
|
||||
github.com/luxfi/pq v1.0.3 h1:pFlQm1+5FuKTDUh2y/23bXWkN4I2Rc5iuxJypwDFFMs=
|
||||
github.com/luxfi/pq v1.0.3/go.mod h1:8bppZcRElfrVt0n3nYCZW3iX1TvhvzNbdjNdK1irgIE=
|
||||
github.com/luxfi/precompile v0.5.27 h1:lEF5gXY8ny5xXp8Qg8PK0Y2RQlaRNOZeDU+wP6Sj24M=
|
||||
github.com/luxfi/precompile v0.5.27/go.mod h1:boKRJeoa4XEhCoylRnPI/0IGfBgm5bZnFNI9llDktWo=
|
||||
github.com/luxfi/proto v1.0.2 h1:kT/2c6M85nqJOzOZ9SYyZHCSGH32qexovMCB7ZPGk8g=
|
||||
github.com/luxfi/proto v1.0.2/go.mod h1:pZLKsCmhiPtmm3z7ezBbnsYT2m79q+cFSmzxvuBeANE=
|
||||
github.com/luxfi/protocol v0.0.4 h1:wf3JSyeNMEabOUG0vExtIAtjfpJAyHXlepGBwv5g9NE=
|
||||
github.com/luxfi/protocol v0.0.4/go.mod h1:9f35GLNlcHAz6LCvFBDZP5fTD2QnN0URXWGUOcD1JPU=
|
||||
github.com/luxfi/pulsar v1.0.12 h1:SD+GGat5JuqKVhRSRojdKhHhmOWr7rz74IMFiP8PPeg=
|
||||
github.com/luxfi/pulsar v1.0.12/go.mod h1:nWjIyef69Yv/y27lwy8hUfAE55lkj2GRP4zDIGZc1B0=
|
||||
github.com/luxfi/relay v0.0.0-20260429020048-c629fe160d3c h1:wiOLqdEEjIoq+Uubkm6F6GYQue+rzqnITScM3ohWGqI=
|
||||
github.com/luxfi/relay v0.0.0-20260429020048-c629fe160d3c/go.mod h1:P408Sn9NGwtcDOoGdkmWe0484dkWDvEvDDBM44Cv+s4=
|
||||
github.com/luxfi/resource v0.0.1 h1:mTh+ICWSy548GTUSSyx7V/X5dV18oEwxZeQEYGJQhD4=
|
||||
github.com/luxfi/resource v0.0.1/go.mod h1:wWpZktciYwIi6RNqA+fHwzmPrUJa7PRX7urfwT+spRE=
|
||||
github.com/luxfi/corona v0.2.0 h1:DbLMZmE//2T2wXXS/gEkKZrTbre9LD+7EE/tz5SQszU=
|
||||
github.com/luxfi/corona v0.2.0/go.mod h1:SZ+aDLUdfSAtaTRaaYzeDZ5DNQiLaOCelSaIGjL21R0=
|
||||
github.com/luxfi/rpc v1.0.2 h1:NLRcOYRW+io0d1d33RMkgOZea8nlhK09MbPgCXcU5wU=
|
||||
github.com/luxfi/rpc v1.0.2/go.mod h1:pgiHwMWgOuxYYIa0vsUBvrBI+Op6bhZ39guM9vtMUcE=
|
||||
github.com/luxfi/runtime v1.0.1 h1:cii3OsRiVSIl9jzfWFM6++62T1r6ZSGP+/3F0Ezhpqw=
|
||||
github.com/luxfi/runtime v1.0.1/go.mod h1:2hBKjzbEeE4dzrhUKH8dqkRgLEyiXz6GmuVusy3vJMs=
|
||||
github.com/luxfi/runtime v1.1.0 h1:6TrvzAmZVCTVbR1ebntHTO3/kVBaogPUSkxdDMnrTiw=
|
||||
github.com/luxfi/runtime v1.1.0/go.mod h1:Mfv2zlXqvfRFMS+/zXgG1TieyP9VnvtVzOGB437+o4Y=
|
||||
github.com/luxfi/sampler v1.0.0 h1:k8Sf6otW83w4pQp0jXLA+g3J/joB7w7SqXQsWmNTOV0=
|
||||
github.com/luxfi/sampler v1.0.0/go.mod h1:f96/ozlj9vFfZj+akLtrHn4VpulQahwB+MQQhpeIekk=
|
||||
github.com/luxfi/sdk v1.16.52 h1:X1rZrgzGTIGzeVMKx9I44/kSvYFPhFu3h2SQ9StpTxA=
|
||||
github.com/luxfi/sdk v1.16.52/go.mod h1:IEP/ej5HA/rCOFOMzv/LfG4oXl2pI9ly+MrpcbVguBg=
|
||||
github.com/luxfi/sdk v1.16.60 h1:MKIFpGAIQzbFADXNd6rV5KySlbegnvpDJyiNhtO7YZw=
|
||||
github.com/luxfi/sdk v1.16.60/go.mod h1:se4G9mlLc6WdjLQlydg6J3pFEeMlEH+MCO1puJ8sYYc=
|
||||
github.com/luxfi/staking v1.1.0 h1:oB1f86WG9duxONe6unISHpUlelnmVg8+BOg4qP83q6Y=
|
||||
github.com/luxfi/staking v1.1.0/go.mod h1:xwYacmreoKoy31ELT6QPno6b5M8q7bg4zJrmmt524X4=
|
||||
github.com/luxfi/sys v0.0.0-20260110090042-50187ec5ffd8 h1:6L5294QWwXzy9he6wAM+Dc39rV4iwyl9hex0k2pXPsg=
|
||||
github.com/luxfi/sys v0.0.0-20260110090042-50187ec5ffd8/go.mod h1:Y0UwjnEJYSbGM7IvJbKYLf8BWNVLfT8Oz00bEiDITHw=
|
||||
github.com/luxfi/threshold v1.5.5 h1:MgPMrHf7dryztiaOYaG+Z+Cqqfa79oyblp5EowDFJw4=
|
||||
github.com/luxfi/threshold v1.5.5/go.mod h1:JEnYrKvCRDwGUeuCsMJSO2FeokuZGVaOml1XD/L1Vgs=
|
||||
github.com/luxfi/threshold v1.6.7 h1:geHmdDAXVm+T9U5CSoUiduXhhRKRUbfKOd6dw+2rPzQ=
|
||||
github.com/luxfi/threshold v1.6.7/go.mod h1:UzUXp2Fbnlb45AwYiXu++RsMkky4wocpKvhCc+rRg7g=
|
||||
github.com/luxfi/threshold v1.8.5 h1:uUTzlc5z8xElxcF9HGLVY1HUX+vsseMX0uryPuOgr84=
|
||||
github.com/luxfi/threshold v1.8.5/go.mod h1:d066yAD8CjSjOeuOYkE+P1/f93F14+NKYoHoJIGfVbI=
|
||||
github.com/luxfi/timer v1.0.2 h1:g/odi0VQJIsrzdklJUG1thHZ/sGNnbIiVGcU6LctJm0=
|
||||
github.com/luxfi/timer v1.0.2/go.mod h1:SoaZwntYigUE3H6z1GV32YwP8QaSiAT0UiEv7iPugXg=
|
||||
github.com/luxfi/tls v1.0.3 h1:rK3nxSAxrUOOSHOZnKChwV4f6UJ+cfOl8KWJXAQx/SI=
|
||||
@@ -399,18 +362,18 @@ github.com/luxfi/upgrade v1.0.0 h1:mM6YXvU8VzoclA7IdtuE5bmnMuFquYxjKUUW84LJz54=
|
||||
github.com/luxfi/upgrade v1.0.0/go.mod h1:DZF7dO4erhUEKf907B2e65k4/vGkCPkUngpvIEUS3eI=
|
||||
github.com/luxfi/utils v1.1.4 h1:8OY0jXCkpp6OotN1Y++6DATJkvtEwzq2k0p56imJlAk=
|
||||
github.com/luxfi/utils v1.1.4/go.mod h1:c3yz1RjzrB+cs5GZm+q1T3/2cCKElO9vxm9yRRtgSEM=
|
||||
github.com/luxfi/utxo v0.3.0 h1:Ok9VrICkFTRKjf3oTOovIPbPwiRpEZQ/MEFw7gFxpPo=
|
||||
github.com/luxfi/utxo v0.3.0/go.mod h1:e1KQHhjYTpc0zugPbGuffezUoz2ZFvha5I3LoFB10/M=
|
||||
github.com/luxfi/validators v1.0.0 h1:zE1HJM1lfvC+v1Lalg+MUgkKBGFWnwTfOOmXraiNnpE=
|
||||
github.com/luxfi/validators v1.0.0/go.mod h1:QGppjtI/gqprbplWc10J+4OIK8UYWpv+53mfH2aKhy4=
|
||||
github.com/luxfi/utxo v0.3.2 h1:fez7ptBnAoRYA5FpRz/uFWY8hcZwd4X00UYLnxuJeXE=
|
||||
github.com/luxfi/utxo v0.3.2/go.mod h1:O8Ucl6Sj0nM7l3Y+uqzucVu309yHcRQY++rg9yXmmdo=
|
||||
github.com/luxfi/validators v1.2.0 h1:VygpiBqBAdGrfkb7xzE2yrVmnXaqE+hm8FLWdGXO7G8=
|
||||
github.com/luxfi/validators v1.2.0/go.mod h1:GYLulrNXAan23ZlX7sgWVbVnLpUexeB/m2qr2ymsXok=
|
||||
github.com/luxfi/version v1.0.1 h1:T/1KYWEMmsrNQk7pN7PFPAwh/7XbeX7cFAKLBqI37Sk=
|
||||
github.com/luxfi/version v1.0.1/go.mod h1:Y5fPkQ2DB0XRBCxgSPXp4ISzL1/jptKnmFknShRJCyg=
|
||||
github.com/luxfi/vm v1.0.40 h1:kA0V/9p1VdC88EjgbljWb8t+fl9qz5bCyF3IxnV20yg=
|
||||
github.com/luxfi/vm v1.0.40/go.mod h1:Y5WUKhT76PR6HnbUykHBWI2f5sjssrm5BL6/o7b6IfQ=
|
||||
github.com/luxfi/warp v1.18.5 h1:yXFCT+lnvzJHs8nVvfUCQ9wNvhtgbDGaXJkJeiwgKf4=
|
||||
github.com/luxfi/warp v1.18.5/go.mod h1:SFyC529HDvbP/TWRAdYQSyJUliMa5JKFRtBrTLEElp4=
|
||||
github.com/luxfi/zapdb v1.8.0 h1:E9hXMW1MTuS6kmDVs07j62gIq5qI5gZOdyzVfiBc1wg=
|
||||
github.com/luxfi/zapdb v1.8.0/go.mod h1:Qukh3hDRD0MnxA6z+a28JTnXhN85AiLLgp6TYr4QAMc=
|
||||
github.com/luxfi/warp v1.18.6 h1:+ly7mHz77ig4yUyLax32aAjk7tiQQ6eygzOSDNfYRbQ=
|
||||
github.com/luxfi/warp v1.18.6/go.mod h1:OBN23yiGl+E4qupkPHuetBIEqsI8q5leE8WJH8soFjY=
|
||||
github.com/luxfi/zapdb v1.10.0 h1:1lLHEmkyC0BucnA/zjQYsMkUVxuEo2vQkEaQGjYfuuc=
|
||||
github.com/luxfi/zapdb v1.10.0/go.mod h1:Qukh3hDRD0MnxA6z+a28JTnXhN85AiLLgp6TYr4QAMc=
|
||||
github.com/luxfi/zwing v0.5.2 h1:2+nDKHVIdT8GvaKO79GuO3x/3DgJvX0gI32h4P+P6RU=
|
||||
github.com/luxfi/zwing v0.5.2/go.mod h1:8nixkEL3bhO2LrqVqhJ8WgT+QGUtnCPIQIhFQh9gQio=
|
||||
github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
|
||||
@@ -436,8 +399,6 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/pointerstructure v1.2.1 h1:ZhBBeX8tSlRpu/FFhXH4RC4OJzFlqsQhoHZAz4x7TIw=
|
||||
github.com/mitchellh/pointerstructure v1.2.1/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4=
|
||||
github.com/montanaflynn/stats v0.8.2 h1:52wnefTJnPI5FoHif1DQh2soKRw0yYs+4AVyvtcZCH0=
|
||||
github.com/montanaflynn/stats v0.8.2/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/montanaflynn/stats v0.9.0 h1:tsBJ0RXwph9BmAuFoCmqGv6e8xa0MENQ8m0ptKq29mQ=
|
||||
github.com/montanaflynn/stats v0.9.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||
@@ -483,8 +444,8 @@ github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQp
|
||||
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
|
||||
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
|
||||
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
|
||||
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
|
||||
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
||||
github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
|
||||
github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -585,24 +546,24 @@ github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
|
||||
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
@@ -620,10 +581,7 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||
@@ -676,8 +634,6 @@ golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
@@ -687,8 +643,6 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ Each chain has one or more index. To see if a C-Chain block is accepted, for exa
|
||||
```
|
||||
|
||||
<Callout type="warn">
|
||||
To ensure historical data can be accessed, the `/ext/index/X/vtx` is still accessible, even though it is no longer populated with chain data since the Cortina activation. If you are using `V1.10.0` or higher, you need to migrate to using the `/ext/index/X/block` endpoint.
|
||||
To ensure historical data can be accessed, the `/ext/index/X/vtx` is still accessible, even though it is no longer populated with chain data since the X-Chain block linearization landed. If you are using `V1.10.0` or higher, you need to migrate to using the `/ext/index/X/block` endpoint.
|
||||
</Callout>
|
||||
|
||||
## Methods
|
||||
|
||||
@@ -1,373 +0,0 @@
|
||||
//go:build grpc
|
||||
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package rpcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/math/set"
|
||||
"github.com/luxfi/utils"
|
||||
|
||||
rpcdbpb "github.com/luxfi/node/proto/pb/rpcdb"
|
||||
)
|
||||
|
||||
var (
|
||||
_ database.Database = (*DatabaseClient)(nil)
|
||||
_ database.Batch = (*batch)(nil)
|
||||
_ database.Iterator = (*iterator)(nil)
|
||||
)
|
||||
|
||||
// DatabaseClient is an implementation of database that talks over RPC.
|
||||
type DatabaseClient struct {
|
||||
client rpcdbpb.DatabaseClient
|
||||
|
||||
closed utils.Atomic[bool]
|
||||
}
|
||||
|
||||
// NewClient returns a database instance connected to a remote database instance
|
||||
func NewClient(client rpcdbpb.DatabaseClient) *DatabaseClient {
|
||||
return &DatabaseClient{client: client}
|
||||
}
|
||||
|
||||
// Has attempts to return if the database has a key with the provided value.
|
||||
func (db *DatabaseClient) Has(key []byte) (bool, error) {
|
||||
resp, err := db.client.Has(context.Background(), &rpcdbpb.HasRequest{
|
||||
Key: key,
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return resp.Has, ErrEnumToError[resp.Err]
|
||||
}
|
||||
|
||||
// Get attempts to return the value that was mapped to the key that was provided
|
||||
func (db *DatabaseClient) Get(key []byte) ([]byte, error) {
|
||||
resp, err := db.client.Get(context.Background(), &rpcdbpb.GetRequest{
|
||||
Key: key,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Value, ErrEnumToError[resp.Err]
|
||||
}
|
||||
|
||||
// Put attempts to set the value this key maps to
|
||||
func (db *DatabaseClient) Put(key, value []byte) error {
|
||||
resp, err := db.client.Put(context.Background(), &rpcdbpb.PutRequest{
|
||||
Key: key,
|
||||
Value: value,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrEnumToError[resp.Err]
|
||||
}
|
||||
|
||||
// Delete attempts to remove any mapping from the key
|
||||
func (db *DatabaseClient) Delete(key []byte) error {
|
||||
resp, err := db.client.Delete(context.Background(), &rpcdbpb.DeleteRequest{
|
||||
Key: key,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrEnumToError[resp.Err]
|
||||
}
|
||||
|
||||
// NewBatch returns a new batch
|
||||
func (db *DatabaseClient) NewBatch() database.Batch {
|
||||
return &batch{db: db}
|
||||
}
|
||||
|
||||
func (db *DatabaseClient) NewIterator() database.Iterator {
|
||||
return db.NewIteratorWithStartAndPrefix(nil, nil)
|
||||
}
|
||||
|
||||
func (db *DatabaseClient) NewIteratorWithStart(start []byte) database.Iterator {
|
||||
return db.NewIteratorWithStartAndPrefix(start, nil)
|
||||
}
|
||||
|
||||
func (db *DatabaseClient) NewIteratorWithPrefix(prefix []byte) database.Iterator {
|
||||
return db.NewIteratorWithStartAndPrefix(nil, prefix)
|
||||
}
|
||||
|
||||
// NewIteratorWithStartAndPrefix returns a new empty iterator
|
||||
func (db *DatabaseClient) NewIteratorWithStartAndPrefix(start, prefix []byte) database.Iterator {
|
||||
resp, err := db.client.NewIteratorWithStartAndPrefix(context.Background(), &rpcdbpb.NewIteratorWithStartAndPrefixRequest{
|
||||
Start: start,
|
||||
Prefix: prefix,
|
||||
})
|
||||
if err != nil {
|
||||
return &database.IteratorError{
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
return newIterator(db, resp.Id)
|
||||
}
|
||||
|
||||
// Compact attempts to optimize the space utilization in the provided range
|
||||
func (db *DatabaseClient) Compact(start, limit []byte) error {
|
||||
resp, err := db.client.Compact(context.Background(), &rpcdbpb.CompactRequest{
|
||||
Start: start,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrEnumToError[resp.Err]
|
||||
}
|
||||
|
||||
// Close attempts to close the database
|
||||
func (db *DatabaseClient) Close() error {
|
||||
db.closed.Set(true)
|
||||
resp, err := db.client.Close(context.Background(), &rpcdbpb.CloseRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrEnumToError[resp.Err]
|
||||
}
|
||||
|
||||
// Sync flushes any pending writes to persistent storage.
|
||||
// For RPC databases, this delegates to the remote database's Sync.
|
||||
func (db *DatabaseClient) Sync() error {
|
||||
// RPC database delegates sync to the underlying database on the server side.
|
||||
// Most operations are already synchronous over RPC, so this is typically a no-op.
|
||||
// If the server implements a Sync RPC method, call it here.
|
||||
// For now, return nil as operations are synchronous.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DatabaseClient) HealthCheck(ctx context.Context) (interface{}, error) {
|
||||
health, err := db.client.HealthCheck(ctx, &emptypb.Empty{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return json.RawMessage(health.Details), nil
|
||||
}
|
||||
|
||||
// Backup is not supported over the RPC database client.
|
||||
func (db *DatabaseClient) Backup(_ io.Writer, _ uint64) (uint64, error) {
|
||||
return 0, errors.New("rpcdb: backup not supported")
|
||||
}
|
||||
|
||||
// Load is not supported over the RPC database client.
|
||||
func (db *DatabaseClient) Load(_ io.Reader) error {
|
||||
return errors.New("rpcdb: load not supported")
|
||||
}
|
||||
|
||||
type batch struct {
|
||||
database.BatchOps
|
||||
|
||||
db *DatabaseClient
|
||||
}
|
||||
|
||||
func (b *batch) Write() error {
|
||||
request := &rpcdbpb.WriteBatchRequest{}
|
||||
keySet := set.NewSet[string](len(b.Ops))
|
||||
for i := len(b.Ops) - 1; i >= 0; i-- {
|
||||
op := b.Ops[i]
|
||||
key := string(op.Key)
|
||||
if keySet.Contains(key) {
|
||||
continue
|
||||
}
|
||||
keySet.Add(key)
|
||||
|
||||
if op.Delete {
|
||||
request.Deletes = append(request.Deletes, &rpcdbpb.DeleteRequest{
|
||||
Key: op.Key,
|
||||
})
|
||||
} else {
|
||||
request.Puts = append(request.Puts, &rpcdbpb.PutRequest{
|
||||
Key: op.Key,
|
||||
Value: op.Value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := b.db.client.WriteBatch(context.Background(), request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrEnumToError[resp.Err]
|
||||
}
|
||||
|
||||
func (b *batch) Inner() database.Batch {
|
||||
return b
|
||||
}
|
||||
|
||||
type iterator struct {
|
||||
db *DatabaseClient
|
||||
id uint64
|
||||
|
||||
data []*rpcdbpb.PutRequest
|
||||
fetchedData chan []*rpcdbpb.PutRequest
|
||||
|
||||
errLock sync.RWMutex
|
||||
err error
|
||||
|
||||
reqUpdateError chan chan struct{}
|
||||
|
||||
once sync.Once
|
||||
onClose chan struct{}
|
||||
onClosed chan struct{}
|
||||
}
|
||||
|
||||
func newIterator(db *DatabaseClient, id uint64) *iterator {
|
||||
it := &iterator{
|
||||
db: db,
|
||||
id: id,
|
||||
fetchedData: make(chan []*rpcdbpb.PutRequest),
|
||||
reqUpdateError: make(chan chan struct{}),
|
||||
onClose: make(chan struct{}),
|
||||
onClosed: make(chan struct{}),
|
||||
}
|
||||
go it.fetch()
|
||||
return it
|
||||
}
|
||||
|
||||
// Invariant: fetch is the only thread with access to send requests to the
|
||||
// server's iterator. This is needed because iterators are not thread safe and
|
||||
// the server expects the client (us) to only ever issue one request at a time
|
||||
// for a given iterator id.
|
||||
func (it *iterator) fetch() {
|
||||
defer func() {
|
||||
resp, err := it.db.client.IteratorRelease(context.Background(), &rpcdbpb.IteratorReleaseRequest{
|
||||
Id: it.id,
|
||||
})
|
||||
if err != nil {
|
||||
it.setError(err)
|
||||
} else {
|
||||
it.setError(ErrEnumToError[resp.Err])
|
||||
}
|
||||
|
||||
close(it.fetchedData)
|
||||
close(it.onClosed)
|
||||
}()
|
||||
|
||||
for {
|
||||
resp, err := it.db.client.IteratorNext(context.Background(), &rpcdbpb.IteratorNextRequest{
|
||||
Id: it.id,
|
||||
})
|
||||
if err != nil {
|
||||
it.setError(err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(resp.Data) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case it.fetchedData <- resp.Data:
|
||||
case onUpdated := <-it.reqUpdateError:
|
||||
it.updateError()
|
||||
close(onUpdated)
|
||||
continue
|
||||
case <-it.onClose:
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Next attempts to move the iterator to the next element and returns if this
|
||||
// succeeded
|
||||
func (it *iterator) Next() bool {
|
||||
if it.db.closed.Get() {
|
||||
it.data = nil
|
||||
it.setError(database.ErrClosed)
|
||||
return false
|
||||
}
|
||||
if len(it.data) > 1 {
|
||||
it.data[0] = nil
|
||||
it.data = it.data[1:]
|
||||
return true
|
||||
}
|
||||
|
||||
it.data = <-it.fetchedData
|
||||
return len(it.data) > 0
|
||||
}
|
||||
|
||||
// Error returns any that occurred while iterating
|
||||
func (it *iterator) Error() error {
|
||||
if err := it.getError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
onUpdated := make(chan struct{})
|
||||
select {
|
||||
case it.reqUpdateError <- onUpdated:
|
||||
<-onUpdated
|
||||
case <-it.onClosed:
|
||||
}
|
||||
|
||||
return it.getError()
|
||||
}
|
||||
|
||||
// Key returns the key of the current element
|
||||
func (it *iterator) Key() []byte {
|
||||
if len(it.data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return it.data[0].Key
|
||||
}
|
||||
|
||||
// Value returns the value of the current element
|
||||
func (it *iterator) Value() []byte {
|
||||
if len(it.data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return it.data[0].Value
|
||||
}
|
||||
|
||||
// Release frees any resources held by the iterator
|
||||
func (it *iterator) Release() {
|
||||
it.once.Do(func() {
|
||||
close(it.onClose)
|
||||
<-it.onClosed
|
||||
})
|
||||
}
|
||||
|
||||
func (it *iterator) updateError() {
|
||||
resp, err := it.db.client.IteratorError(context.Background(), &rpcdbpb.IteratorErrorRequest{
|
||||
Id: it.id,
|
||||
})
|
||||
if err != nil {
|
||||
it.setError(err)
|
||||
} else {
|
||||
it.setError(ErrEnumToError[resp.Err])
|
||||
}
|
||||
}
|
||||
|
||||
func (it *iterator) setError(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
it.errLock.Lock()
|
||||
defer it.errLock.Unlock()
|
||||
|
||||
if it.err == nil {
|
||||
it.err = err
|
||||
}
|
||||
}
|
||||
|
||||
func (it *iterator) getError() error {
|
||||
it.errLock.RLock()
|
||||
defer it.errLock.RUnlock()
|
||||
|
||||
return it.err
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
//go:build grpc
|
||||
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package rpcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
|
||||
"github.com/luxfi/constants"
|
||||
"github.com/luxfi/database"
|
||||
|
||||
rpcdbpb "github.com/luxfi/node/proto/pb/rpcdb"
|
||||
)
|
||||
|
||||
const iterationBatchSize = 128 * constants.KiB
|
||||
|
||||
var errUnknownIterator = errors.New("unknown iterator")
|
||||
|
||||
// DatabaseServer is a database that is managed over RPC.
|
||||
type DatabaseServer struct {
|
||||
rpcdbpb.UnsafeDatabaseServer
|
||||
|
||||
db database.Database
|
||||
|
||||
// iteratorLock protects [nextIteratorID] and [iterators] from concurrent
|
||||
// modifications. Similarly to [batchLock], [iteratorLock] does not protect
|
||||
// the actual Iterator. Iterators are documented as not being safe for
|
||||
// concurrent use. Therefore, it is up to the client to respect this
|
||||
// invariant.
|
||||
iteratorLock sync.RWMutex
|
||||
nextIteratorID uint64
|
||||
iterators map[uint64]database.Iterator
|
||||
}
|
||||
|
||||
// NewServer returns a database instance that is managed remotely
|
||||
func NewServer(db database.Database) *DatabaseServer {
|
||||
return &DatabaseServer{
|
||||
db: db,
|
||||
iterators: make(map[uint64]database.Iterator),
|
||||
}
|
||||
}
|
||||
|
||||
// Has delegates the Has call to the managed database and returns the result
|
||||
func (db *DatabaseServer) Has(_ context.Context, req *rpcdbpb.HasRequest) (*rpcdbpb.HasResponse, error) {
|
||||
has, err := db.db.Has(req.Key)
|
||||
return &rpcdbpb.HasResponse{
|
||||
Has: has,
|
||||
Err: ErrorToErrEnum[err],
|
||||
}, ErrorToRPCError(err)
|
||||
}
|
||||
|
||||
// Get delegates the Get call to the managed database and returns the result
|
||||
func (db *DatabaseServer) Get(_ context.Context, req *rpcdbpb.GetRequest) (*rpcdbpb.GetResponse, error) {
|
||||
value, err := db.db.Get(req.Key)
|
||||
return &rpcdbpb.GetResponse{
|
||||
Value: value,
|
||||
Err: ErrorToErrEnum[err],
|
||||
}, ErrorToRPCError(err)
|
||||
}
|
||||
|
||||
// Put delegates the Put call to the managed database and returns the result
|
||||
func (db *DatabaseServer) Put(_ context.Context, req *rpcdbpb.PutRequest) (*rpcdbpb.PutResponse, error) {
|
||||
err := db.db.Put(req.Key, req.Value)
|
||||
return &rpcdbpb.PutResponse{Err: ErrorToErrEnum[err]}, ErrorToRPCError(err)
|
||||
}
|
||||
|
||||
// Delete delegates the Delete call to the managed database and returns the
|
||||
// result
|
||||
func (db *DatabaseServer) Delete(_ context.Context, req *rpcdbpb.DeleteRequest) (*rpcdbpb.DeleteResponse, error) {
|
||||
err := db.db.Delete(req.Key)
|
||||
return &rpcdbpb.DeleteResponse{Err: ErrorToErrEnum[err]}, ErrorToRPCError(err)
|
||||
}
|
||||
|
||||
// Compact delegates the Compact call to the managed database and returns the
|
||||
// result
|
||||
func (db *DatabaseServer) Compact(_ context.Context, req *rpcdbpb.CompactRequest) (*rpcdbpb.CompactResponse, error) {
|
||||
err := db.db.Compact(req.Start, req.Limit)
|
||||
return &rpcdbpb.CompactResponse{Err: ErrorToErrEnum[err]}, ErrorToRPCError(err)
|
||||
}
|
||||
|
||||
// Close delegates the Close call to the managed database and returns the result
|
||||
func (db *DatabaseServer) Close(context.Context, *rpcdbpb.CloseRequest) (*rpcdbpb.CloseResponse, error) {
|
||||
err := db.db.Close()
|
||||
return &rpcdbpb.CloseResponse{Err: ErrorToErrEnum[err]}, ErrorToRPCError(err)
|
||||
}
|
||||
|
||||
// HealthCheck performs a heath check against the underlying database.
|
||||
func (db *DatabaseServer) HealthCheck(ctx context.Context, _ *emptypb.Empty) (*rpcdbpb.HealthCheckResponse, error) {
|
||||
health, err := db.db.HealthCheck(ctx)
|
||||
if err != nil {
|
||||
return &rpcdbpb.HealthCheckResponse{}, err
|
||||
}
|
||||
|
||||
details, err := json.Marshal(health)
|
||||
return &rpcdbpb.HealthCheckResponse{
|
||||
Details: details,
|
||||
}, err
|
||||
}
|
||||
|
||||
// WriteBatch takes in a set of key-value pairs and atomically writes them to
|
||||
// the internal database
|
||||
func (db *DatabaseServer) WriteBatch(_ context.Context, req *rpcdbpb.WriteBatchRequest) (*rpcdbpb.WriteBatchResponse, error) {
|
||||
batch := db.db.NewBatch()
|
||||
for _, put := range req.Puts {
|
||||
if err := batch.Put(put.Key, put.Value); err != nil {
|
||||
return &rpcdbpb.WriteBatchResponse{
|
||||
Err: ErrorToErrEnum[err],
|
||||
}, ErrorToRPCError(err)
|
||||
}
|
||||
}
|
||||
for _, del := range req.Deletes {
|
||||
if err := batch.Delete(del.Key); err != nil {
|
||||
return &rpcdbpb.WriteBatchResponse{
|
||||
Err: ErrorToErrEnum[err],
|
||||
}, ErrorToRPCError(err)
|
||||
}
|
||||
}
|
||||
|
||||
err := batch.Write()
|
||||
return &rpcdbpb.WriteBatchResponse{
|
||||
Err: ErrorToErrEnum[err],
|
||||
}, ErrorToRPCError(err)
|
||||
}
|
||||
|
||||
// NewIteratorWithStartAndPrefix allocates an iterator and returns the iterator
|
||||
// ID
|
||||
func (db *DatabaseServer) NewIteratorWithStartAndPrefix(_ context.Context, req *rpcdbpb.NewIteratorWithStartAndPrefixRequest) (*rpcdbpb.NewIteratorWithStartAndPrefixResponse, error) {
|
||||
it := db.db.NewIteratorWithStartAndPrefix(req.Start, req.Prefix)
|
||||
|
||||
db.iteratorLock.Lock()
|
||||
defer db.iteratorLock.Unlock()
|
||||
|
||||
id := db.nextIteratorID
|
||||
db.iterators[id] = it
|
||||
db.nextIteratorID++
|
||||
return &rpcdbpb.NewIteratorWithStartAndPrefixResponse{Id: id}, nil
|
||||
}
|
||||
|
||||
// IteratorNext attempts to call next on the requested iterator
|
||||
func (db *DatabaseServer) IteratorNext(_ context.Context, req *rpcdbpb.IteratorNextRequest) (*rpcdbpb.IteratorNextResponse, error) {
|
||||
db.iteratorLock.RLock()
|
||||
it, exists := db.iterators[req.Id]
|
||||
db.iteratorLock.RUnlock()
|
||||
if !exists {
|
||||
return nil, errUnknownIterator
|
||||
}
|
||||
|
||||
var (
|
||||
size int
|
||||
data []*rpcdbpb.PutRequest
|
||||
)
|
||||
for size < iterationBatchSize && it.Next() {
|
||||
key := it.Key()
|
||||
value := it.Value()
|
||||
size += len(key) + len(value)
|
||||
|
||||
data = append(data, &rpcdbpb.PutRequest{
|
||||
Key: key,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
return &rpcdbpb.IteratorNextResponse{Data: data}, nil
|
||||
}
|
||||
|
||||
// IteratorError attempts to report any errors that occurred during iteration
|
||||
func (db *DatabaseServer) IteratorError(_ context.Context, req *rpcdbpb.IteratorErrorRequest) (*rpcdbpb.IteratorErrorResponse, error) {
|
||||
db.iteratorLock.RLock()
|
||||
it, exists := db.iterators[req.Id]
|
||||
db.iteratorLock.RUnlock()
|
||||
if !exists {
|
||||
return nil, errUnknownIterator
|
||||
}
|
||||
err := it.Error()
|
||||
return &rpcdbpb.IteratorErrorResponse{Err: ErrorToErrEnum[err]}, ErrorToRPCError(err)
|
||||
}
|
||||
|
||||
// IteratorRelease attempts to release the resources allocated to an iterator
|
||||
func (db *DatabaseServer) IteratorRelease(_ context.Context, req *rpcdbpb.IteratorReleaseRequest) (*rpcdbpb.IteratorReleaseResponse, error) {
|
||||
db.iteratorLock.Lock()
|
||||
it, exists := db.iterators[req.Id]
|
||||
if !exists {
|
||||
db.iteratorLock.Unlock()
|
||||
return &rpcdbpb.IteratorReleaseResponse{}, nil
|
||||
}
|
||||
delete(db.iterators, req.Id)
|
||||
db.iteratorLock.Unlock()
|
||||
|
||||
err := it.Error()
|
||||
it.Release()
|
||||
return &rpcdbpb.IteratorReleaseResponse{Err: ErrorToErrEnum[err]}, ErrorToRPCError(err)
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
//go:build grpc
|
||||
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package rpcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/database/corruptabledb"
|
||||
"github.com/luxfi/database/dbtest"
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/vm/rpc/grpcutils"
|
||||
|
||||
rpcdbpb "github.com/luxfi/node/proto/pb/rpcdb"
|
||||
)
|
||||
|
||||
type testDatabase struct {
|
||||
client *DatabaseClient
|
||||
server *memdb.Database
|
||||
}
|
||||
|
||||
func setupDB(t testing.TB) *testDatabase {
|
||||
require := require.New(t)
|
||||
|
||||
db := &testDatabase{
|
||||
server: memdb.New(),
|
||||
}
|
||||
|
||||
listener, err := grpcutils.NewListener()
|
||||
require.NoError(err)
|
||||
serverCloser := grpcutils.ServerCloser{}
|
||||
|
||||
server := grpcutils.NewServer()
|
||||
rpcdbpb.RegisterDatabaseServer(server, NewServer(db.server))
|
||||
serverCloser.Add(server)
|
||||
|
||||
go grpcutils.Serve(listener, server)
|
||||
|
||||
conn, err := grpcutils.Dial(listener.Addr().String())
|
||||
require.NoError(err)
|
||||
|
||||
db.client = NewClient(rpcdbpb.NewDatabaseClient(conn))
|
||||
|
||||
t.Cleanup(func() {
|
||||
serverCloser.Stop()
|
||||
_ = conn.Close()
|
||||
_ = listener.Close()
|
||||
})
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func TestInterface(t *testing.T) {
|
||||
for name, test := range dbtest.Tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
test(t, db.client)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzKeyValue(f *testing.F) {
|
||||
db := setupDB(f)
|
||||
dbtest.FuzzKeyValue(f, db.client)
|
||||
}
|
||||
|
||||
func FuzzNewIteratorWithPrefix(f *testing.F) {
|
||||
db := setupDB(f)
|
||||
dbtest.FuzzNewIteratorWithPrefix(f, db.client)
|
||||
}
|
||||
|
||||
func FuzzNewIteratorWithStartAndPrefix(f *testing.F) {
|
||||
db := setupDB(f)
|
||||
dbtest.FuzzNewIteratorWithStartAndPrefix(f, db.client)
|
||||
}
|
||||
|
||||
func BenchmarkInterface(b *testing.B) {
|
||||
for _, size := range dbtest.BenchmarkSizes {
|
||||
keys, values := dbtest.SetupBenchmark(b, size[0], size[1], size[2])
|
||||
for name, bench := range dbtest.Benchmarks {
|
||||
b.Run(fmt.Sprintf("rpcdb_%d_pairs_%d_keys_%d_values_%s", size[0], size[1], size[2], name), func(b *testing.B) {
|
||||
db := setupDB(b)
|
||||
bench(b, db.client, keys, values)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheck(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
testDatabase *testDatabase
|
||||
testFn func(db *corruptabledb.Database) error
|
||||
wantErr bool
|
||||
wantErrMsg string
|
||||
}{
|
||||
{
|
||||
name: "healthcheck success",
|
||||
testDatabase: setupDB(t),
|
||||
testFn: func(_ *corruptabledb.Database) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "healthcheck failed db closed",
|
||||
testDatabase: setupDB(t),
|
||||
testFn: func(db *corruptabledb.Database) error {
|
||||
return db.Close()
|
||||
},
|
||||
wantErr: true,
|
||||
wantErrMsg: "closed",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
baseDB := setupDB(t)
|
||||
db := corruptabledb.New(baseDB.server, log.NoLog{})
|
||||
defer db.Close()
|
||||
require.NoError(scenario.testFn(db))
|
||||
|
||||
// check db HealthCheck
|
||||
_, err := db.HealthCheck(context.Background())
|
||||
if scenario.wantErr {
|
||||
require.Error(err) //nolint:forbidigo
|
||||
require.Contains(err.Error(), scenario.wantErrMsg)
|
||||
return
|
||||
}
|
||||
require.NoError(err)
|
||||
|
||||
// check rpc HealthCheck
|
||||
_, err = baseDB.client.HealthCheck(context.Background())
|
||||
require.NoError(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
//go:build grpc
|
||||
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package rpcdb
|
||||
|
||||
import (
|
||||
"github.com/luxfi/database"
|
||||
|
||||
rpcdbpb "github.com/luxfi/node/proto/pb/rpcdb"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEnumToError = map[rpcdbpb.Error]error{
|
||||
rpcdbpb.Error_ERROR_CLOSED: database.ErrClosed,
|
||||
rpcdbpb.Error_ERROR_NOT_FOUND: database.ErrNotFound,
|
||||
}
|
||||
ErrorToErrEnum = map[error]rpcdbpb.Error{
|
||||
database.ErrClosed: rpcdbpb.Error_ERROR_CLOSED,
|
||||
database.ErrNotFound: rpcdbpb.Error_ERROR_NOT_FOUND,
|
||||
}
|
||||
)
|
||||
|
||||
func ErrorToRPCError(err error) error {
|
||||
if _, ok := ErrorToErrEnum[err]; ok {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
//go:build grpc
|
||||
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package galiasreader
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
|
||||
aliasreaderpb "github.com/luxfi/node/proto/pb/aliasreader"
|
||||
)
|
||||
|
||||
var _ ids.AliaserReader = (*Client)(nil)
|
||||
|
||||
// Client implements alias lookups that talk over RPC.
|
||||
type Client struct {
|
||||
client aliasreaderpb.AliasReaderClient
|
||||
}
|
||||
|
||||
// NewClient returns an alias lookup instance connected to a remote alias lookup
|
||||
// instance
|
||||
func NewClient(client aliasreaderpb.AliasReaderClient) *Client {
|
||||
return &Client{client: client}
|
||||
}
|
||||
|
||||
func (c *Client) Lookup(alias string) (ids.ID, error) {
|
||||
resp, err := c.client.Lookup(context.Background(), &aliasreaderpb.Alias{
|
||||
Alias: alias,
|
||||
})
|
||||
if err != nil {
|
||||
return ids.Empty, err
|
||||
}
|
||||
return ids.ToID(resp.Id)
|
||||
}
|
||||
|
||||
func (c *Client) PrimaryAlias(id ids.ID) (string, error) {
|
||||
resp, err := c.client.PrimaryAlias(context.Background(), &aliasreaderpb.ID{
|
||||
Id: id[:],
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Alias, nil
|
||||
}
|
||||
|
||||
func (c *Client) Aliases(id ids.ID) ([]string, error) {
|
||||
resp, err := c.client.Aliases(context.Background(), &aliasreaderpb.ID{
|
||||
Id: id[:],
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Aliases, nil
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
//go:build grpc
|
||||
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package galiasreader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
|
||||
aliasreaderpb "github.com/luxfi/node/proto/pb/aliasreader"
|
||||
)
|
||||
|
||||
var _ aliasreaderpb.AliasReaderServer = (*Server)(nil)
|
||||
|
||||
// Server enables alias lookups over RPC.
|
||||
type Server struct {
|
||||
aliasreaderpb.UnsafeAliasReaderServer
|
||||
aliaser ids.AliaserReader
|
||||
}
|
||||
|
||||
// NewServer returns an alias lookup connected to a remote alias lookup
|
||||
func NewServer(aliaser ids.AliaserReader) *Server {
|
||||
return &Server{aliaser: aliaser}
|
||||
}
|
||||
|
||||
func (s *Server) Lookup(
|
||||
_ context.Context,
|
||||
req *aliasreaderpb.Alias,
|
||||
) (*aliasreaderpb.ID, error) {
|
||||
id, err := s.aliaser.Lookup(req.Alias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &aliasreaderpb.ID{
|
||||
Id: id[:],
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) PrimaryAlias(
|
||||
_ context.Context,
|
||||
req *aliasreaderpb.ID,
|
||||
) (*aliasreaderpb.Alias, error) {
|
||||
if s.aliaser == nil {
|
||||
return nil, fmt.Errorf("aliaser is nil - BCLookup not configured for this chain")
|
||||
}
|
||||
|
||||
id, err := ids.ToID(req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
alias, err := s.aliaser.PrimaryAlias(id)
|
||||
|
||||
return &aliasreaderpb.Alias{
|
||||
Alias: alias,
|
||||
}, err
|
||||
}
|
||||
|
||||
func (s *Server) Aliases(
|
||||
_ context.Context,
|
||||
req *aliasreaderpb.ID,
|
||||
) (*aliasreaderpb.AliasList, error) {
|
||||
id, err := ids.ToID(req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aliases, err := s.aliaser.Aliases(id)
|
||||
return &aliasreaderpb.AliasList{
|
||||
Aliases: aliases,
|
||||
}, err
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
//go:build grpc
|
||||
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package galiasreader
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/ids/idstest"
|
||||
"github.com/luxfi/vm/rpc/grpcutils"
|
||||
|
||||
aliasreaderpb "github.com/luxfi/node/proto/pb/aliasreader"
|
||||
)
|
||||
|
||||
func TestInterface(t *testing.T) {
|
||||
for _, test := range idstest.AliasTests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
listener, err := grpcutils.NewListener()
|
||||
require.NoError(err)
|
||||
defer listener.Close()
|
||||
serverCloser := grpcutils.ServerCloser{}
|
||||
defer serverCloser.Stop()
|
||||
w := ids.NewAliaser()
|
||||
|
||||
server := grpcutils.NewServer()
|
||||
aliasreaderpb.RegisterAliasReaderServer(server, NewServer(w))
|
||||
serverCloser.Add(server)
|
||||
|
||||
go grpcutils.Serve(listener, server)
|
||||
|
||||
conn, err := grpcutils.Dial(listener.Addr().String())
|
||||
require.NoError(err)
|
||||
defer conn.Close()
|
||||
|
||||
r := NewClient(aliasreaderpb.NewAliasReaderClient(conn))
|
||||
test.Test(t, r, w)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
//go:build grpc
|
||||
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package message
|
||||
|
||||
import "github.com/luxfi/node/proto/p2p"
|
||||
|
||||
// newMessageBFT creates a BFT message wrapper (gRPC version - uses Simplex)
|
||||
func newMessageBFT(msg *p2p.BFT) *p2p.Message_BFT {
|
||||
return &p2p.Message_BFT{Simplex: msg}
|
||||
}
|
||||
|
||||
// extractBFT extracts the BFT message from the wrapper (gRPC version - uses Simplex)
|
||||
func extractBFT(msg *p2p.Message_BFT) *p2p.BFT {
|
||||
return msg.Simplex
|
||||
}
|
||||
+2
-4
@@ -1,5 +1,3 @@
|
||||
//go:build !grpc
|
||||
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
@@ -7,12 +5,12 @@ package message
|
||||
|
||||
import "github.com/luxfi/node/proto/p2p"
|
||||
|
||||
// newMessageBFT creates a BFT message wrapper (ZAP version)
|
||||
// newMessageBFT creates a BFT message wrapper.
|
||||
func newMessageBFT(msg *p2p.BFT) *p2p.Message_BFT {
|
||||
return &p2p.Message_BFT{BFT: msg}
|
||||
}
|
||||
|
||||
// extractBFT extracts the BFT message from the wrapper (ZAP version)
|
||||
// extractBFT extracts the BFT message from the wrapper.
|
||||
func extractBFT(msg *p2p.Message_BFT) *p2p.BFT {
|
||||
return msg.BFT
|
||||
}
|
||||
|
||||
@@ -286,18 +286,18 @@ func (mr *OutboundMsgBuilderMockRecorder) GetStateSummaryFrontier(chainID, reque
|
||||
}
|
||||
|
||||
// Handshake mocks base method.
|
||||
func (m *OutboundMsgBuilder) Handshake(networkID uint32, myTime uint64, ip netip.AddrPort, client string, major, minor, patch uint32, ipSigningTime uint64, ipNodeIDSig, ipBLSSig []byte, trackedNets []ids.ID, supportedLPs, objectedLPs []uint32, knownPeersFilter, knownPeersSalt []byte, requestAllNetIPs bool) (message.OutboundMessage, error) {
|
||||
func (m *OutboundMsgBuilder) Handshake(networkID uint32, myTime uint64, ip netip.AddrPort, client string, major, minor, patch uint32, ipSigningTime uint64, ipNodeIDSig, ipBLSSig []byte, trackedNets []ids.ID, supportedLPs, objectedLPs []uint32, knownPeersFilter, knownPeersSalt []byte, requestAllNetIPs bool, ipMLDSASig []byte) (message.OutboundMessage, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handshake", networkID, myTime, ip, client, major, minor, patch, ipSigningTime, ipNodeIDSig, ipBLSSig, trackedNets, supportedLPs, objectedLPs, knownPeersFilter, knownPeersSalt, requestAllNetIPs)
|
||||
ret := m.ctrl.Call(m, "Handshake", networkID, myTime, ip, client, major, minor, patch, ipSigningTime, ipNodeIDSig, ipBLSSig, trackedNets, supportedLPs, objectedLPs, knownPeersFilter, knownPeersSalt, requestAllNetIPs, ipMLDSASig)
|
||||
ret0, _ := ret[0].(message.OutboundMessage)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handshake indicates an expected call of Handshake.
|
||||
func (mr *OutboundMsgBuilderMockRecorder) Handshake(networkID, myTime, ip, client, major, minor, patch, ipSigningTime, ipNodeIDSig, ipBLSSig, trackedNets, supportedLPs, objectedLPs, knownPeersFilter, knownPeersSalt, requestAllNetIPs any) *gomock.Call {
|
||||
func (mr *OutboundMsgBuilderMockRecorder) Handshake(networkID, myTime, ip, client, major, minor, patch, ipSigningTime, ipNodeIDSig, ipBLSSig, trackedNets, supportedLPs, objectedLPs, knownPeersFilter, knownPeersSalt, requestAllNetIPs, ipMLDSASig any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handshake", reflect.TypeOf((*OutboundMsgBuilder)(nil).Handshake), networkID, myTime, ip, client, major, minor, patch, ipSigningTime, ipNodeIDSig, ipBLSSig, trackedNets, supportedLPs, objectedLPs, knownPeersFilter, knownPeersSalt, requestAllNetIPs)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handshake", reflect.TypeOf((*OutboundMsgBuilder)(nil).Handshake), networkID, myTime, ip, client, major, minor, patch, ipSigningTime, ipNodeIDSig, ipBLSSig, trackedNets, supportedLPs, objectedLPs, knownPeersFilter, knownPeersSalt, requestAllNetIPs, ipMLDSASig)
|
||||
}
|
||||
|
||||
// PeerList mocks base method.
|
||||
|
||||
@@ -36,6 +36,7 @@ type OutboundMsgBuilder interface {
|
||||
knownPeersFilter []byte,
|
||||
knownPeersSalt []byte,
|
||||
requestAllNetIPs bool,
|
||||
ipMLDSASig []byte,
|
||||
) (OutboundMessage, error)
|
||||
|
||||
GetPeerList(
|
||||
@@ -258,6 +259,7 @@ func (b *outMsgBuilder) Handshake(
|
||||
knownPeersFilter []byte,
|
||||
knownPeersSalt []byte,
|
||||
requestAllNetIPs bool,
|
||||
ipMLDSASig []byte,
|
||||
) (OutboundMessage, error) {
|
||||
subsubchainIDBytes := make([][]byte, len(trackedNets))
|
||||
encodeIDs(trackedNets, subsubchainIDBytes)
|
||||
@@ -284,8 +286,9 @@ func (b *outMsgBuilder) Handshake(
|
||||
Filter: knownPeersFilter,
|
||||
Salt: knownPeersSalt,
|
||||
},
|
||||
IpBlsSig: ipBLSSig,
|
||||
AllChains: requestAllNetIPs,
|
||||
IpBlsSig: ipBLSSig,
|
||||
AllChains: requestAllNetIPs,
|
||||
IpMldsaSig: ipMLDSASig,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -52,7 +52,7 @@ type Ping struct {
|
||||
|
||||
// ChainPingEntry is the per-chain payload in Ping/Pong.
|
||||
// In Lux's L1/L2 model each chain is its own validator set, so the legacy
|
||||
// (chain, subnet) pair collapses to a single chain identifier.
|
||||
// (chain, network) pair collapses to a single chain identifier.
|
||||
type ChainPingEntry struct {
|
||||
ChainId []byte
|
||||
}
|
||||
@@ -71,7 +71,7 @@ type Handshake struct {
|
||||
IpPort uint32
|
||||
IpSigningTime uint64
|
||||
IpNodeIdSig []byte
|
||||
TrackedSubnets [][]byte
|
||||
TrackedChains [][]byte
|
||||
Client *Client
|
||||
SupportedAcps []uint32
|
||||
ObjectedAcps []uint32
|
||||
|
||||
+2
-2
@@ -418,7 +418,7 @@ func marshalHandshake(b *Buffer, m *Handshake) {
|
||||
b.WriteUint32(m.IpPort)
|
||||
b.WriteUint64(m.IpSigningTime)
|
||||
b.WriteBytes(m.IpNodeIdSig)
|
||||
b.WriteBytesSlice(m.TrackedSubnets)
|
||||
b.WriteBytesSlice(m.TrackedChains)
|
||||
if m.Client != nil {
|
||||
b.WriteUint8(1)
|
||||
b.WriteString(m.Client.Name)
|
||||
@@ -674,7 +674,7 @@ func unmarshalHandshake(r *Reader) (*Handshake, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.TrackedSubnets, err = r.ReadBytesSlice()
|
||||
m.TrackedChains, err = r.ReadBytesSlice()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
consensusconfig "github.com/luxfi/consensus/config"
|
||||
consensustracker "github.com/luxfi/consensus/networking/tracker"
|
||||
validators "github.com/luxfi/validators"
|
||||
"github.com/luxfi/validators/uptime"
|
||||
@@ -197,4 +198,14 @@ type Config struct {
|
||||
// Specifies how much disk usage each peer can cause before
|
||||
// we rate-limit them.
|
||||
DiskTargeter tracker.Targeter `json:"-"`
|
||||
|
||||
// SecurityProfile is the chain-wide ChainSecurityProfile this node is
|
||||
// operating under (resolved at boot from the genesis pin in
|
||||
// node.Node.initSecurityProfile). When non-nil, the network upgrader
|
||||
// builds a peer.SchemeGate from it and refuses any inbound or
|
||||
// outbound TLS connection whose wire NodeIDScheme is not admissible
|
||||
// under the profile. nil leaves the cross-axis gate disabled (chains
|
||||
// that ship no profile remain accepted by the upgrader's nil-safe
|
||||
// path).
|
||||
SecurityProfile *consensusconfig.ChainSecurityProfile `json:"-"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package kem
|
||||
|
||||
import (
|
||||
"crypto/cipher"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
// AEADID is the wire byte identifying the authenticated cipher used on
|
||||
// post-handshake frames. Only FIPS-approved primitives are admitted; the
|
||||
// production default is ChaCha20-Poly1305 (RFC 8439) because the Lux
|
||||
// strict-PQ profile pairs lattice KEM with a stream cipher that is not
|
||||
// vulnerable to the same lattice attack surface.
|
||||
//
|
||||
// Numbering:
|
||||
//
|
||||
// 0x00 — None (rejected)
|
||||
// 0x01 — ChaCha20-Poly1305 (RFC 8439, FIPS 140 module-approved)
|
||||
type AEADID uint8
|
||||
|
||||
const (
|
||||
AEADNone AEADID = 0x00
|
||||
AEADChaCha20Poly1305 AEADID = 0x01
|
||||
)
|
||||
|
||||
// String returns the canonical wire name.
|
||||
func (a AEADID) String() string {
|
||||
switch a {
|
||||
case AEADNone:
|
||||
return "none"
|
||||
case AEADChaCha20Poly1305:
|
||||
return "chacha20-poly1305"
|
||||
default:
|
||||
return fmt.Sprintf("aead(0x%02x)", uint8(a))
|
||||
}
|
||||
}
|
||||
|
||||
// ErrUnsupportedAEAD is returned by NewAEAD when an unknown / forbidden
|
||||
// AEAD byte is requested.
|
||||
var ErrUnsupportedAEAD = errors.New("kem: unsupported AEAD")
|
||||
|
||||
// NewAEAD constructs a cipher.AEAD bound to key. Caller produces key via
|
||||
// KEMSession.DeriveAEADKey or KEMSession.DeriveDKGAEADKey. Returns
|
||||
// ErrUnsupportedAEAD for any byte other than AEADChaCha20Poly1305.
|
||||
//
|
||||
// This is the single dispatch point that maps an AEADID byte to a
|
||||
// concrete cipher; transport code routes through it instead of importing
|
||||
// chacha20poly1305 directly so a future AEAD addition (e.g. AES-256-GCM
|
||||
// at 0x02) lands in one place.
|
||||
func NewAEAD(id AEADID, key []byte) (cipher.AEAD, error) {
|
||||
switch id {
|
||||
case AEADChaCha20Poly1305:
|
||||
if len(key) != chacha20poly1305.KeySize {
|
||||
return nil, fmt.Errorf("kem: ChaCha20-Poly1305 key size=%d want=%d",
|
||||
len(key), chacha20poly1305.KeySize)
|
||||
}
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kem: chacha20poly1305.New: %w", err)
|
||||
}
|
||||
return aead, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: id=%s", ErrUnsupportedAEAD, id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package kem
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestNewAEAD_RoundTrip exercises the end-to-end "derive key → encrypt →
|
||||
// decrypt" pipeline using ChaCha20-Poly1305 over a KEM-derived key.
|
||||
// This is the actual code path a peer takes after a successful PQ
|
||||
// handshake.
|
||||
func TestNewAEAD_RoundTrip(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
pub, priv, err := GenerateKEMKeypair(KeyExchangeMLKEM768, rand.Reader)
|
||||
require.NoError(err)
|
||||
|
||||
transcript := []byte("aead-roundtrip-transcript")
|
||||
initSess, ct, err := InitiateKEMSession(KeyExchangeMLKEM768, pub, transcript)
|
||||
require.NoError(err)
|
||||
respSess, err := RespondKEMSession(KeyExchangeMLKEM768, priv, ct, transcript)
|
||||
require.NoError(err)
|
||||
|
||||
initKey := initSess.DeriveAEADKey()
|
||||
respKey := respSess.DeriveAEADKey()
|
||||
require.Equal(initKey, respKey)
|
||||
|
||||
initAEAD, err := NewAEAD(AEADChaCha20Poly1305, initKey[:])
|
||||
require.NoError(err)
|
||||
respAEAD, err := NewAEAD(AEADChaCha20Poly1305, respKey[:])
|
||||
require.NoError(err)
|
||||
|
||||
nonce := make([]byte, initAEAD.NonceSize())
|
||||
_, err = rand.Read(nonce)
|
||||
require.NoError(err)
|
||||
|
||||
plaintext := []byte("hello, lux strict-PQ peer")
|
||||
aad := []byte("lux-node/v1/peer-frame")
|
||||
|
||||
ctOut := initAEAD.Seal(nil, nonce, plaintext, aad)
|
||||
require.NotEqual(plaintext, ctOut)
|
||||
|
||||
got, err := respAEAD.Open(nil, nonce, ctOut, aad)
|
||||
require.NoError(err)
|
||||
require.Equal(plaintext, got)
|
||||
}
|
||||
|
||||
// TestNewAEAD_RefusesUnknown asserts unknown AEAD bytes are refused with
|
||||
// the precise ErrUnsupportedAEAD error.
|
||||
func TestNewAEAD_RefusesUnknown(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
_, err := NewAEAD(AEADNone, make([]byte, 32))
|
||||
require.ErrorIs(err, ErrUnsupportedAEAD)
|
||||
|
||||
_, err = NewAEAD(AEADID(0xAA), make([]byte, 32))
|
||||
require.ErrorIs(err, ErrUnsupportedAEAD)
|
||||
}
|
||||
|
||||
// TestNewAEAD_RefusesBadKeySize asserts a mis-sized key is rejected
|
||||
// before crypto runs.
|
||||
func TestNewAEAD_RefusesBadKeySize(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
_, err := NewAEAD(AEADChaCha20Poly1305, make([]byte, 16))
|
||||
require.Error(err)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package kem_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/consensus/config"
|
||||
"github.com/luxfi/node/network/kem"
|
||||
)
|
||||
|
||||
// TestKEMSchemeIDs_AllUseCanonicalNumbering confirms the node's KEM
|
||||
// package re-exports config.KeyExchangeID byte-for-byte. This anchors
|
||||
// the Bug 3 fix on the node side: before the fix, this package declared
|
||||
// ML-KEM-768=0x62 and ML-KEM-1024=0x63, in disagreement with config's
|
||||
// 0x01 / 0x02. Aliasing brings every wire byte to a single source of
|
||||
// truth.
|
||||
func TestKEMSchemeIDs_AllUseCanonicalNumbering(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
node kem.KeyExchangeID
|
||||
canon config.KeyExchangeID
|
||||
wantHex uint8
|
||||
}{
|
||||
{"ml-kem-768", kem.KeyExchangeMLKEM768, config.KeyExchangeMLKEM768, 0x01},
|
||||
{"ml-kem-1024", kem.KeyExchangeMLKEM1024, config.KeyExchangeMLKEM1024, 0x02},
|
||||
{"x25519-unsafe", kem.KeyExchangeX25519Unsafe, config.KeyExchangeX25519Unsafe, 0x90},
|
||||
{"none", kem.KeyExchangeNone, config.KeyExchangeInvalid, 0x00},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if c.node != c.canon {
|
||||
t.Errorf("node/kem.%s and config.%s diverge: node=0x%02x config=0x%02x",
|
||||
c.name, c.name, uint8(c.node), uint8(c.canon))
|
||||
}
|
||||
if uint8(c.node) != c.wantHex {
|
||||
t.Errorf("node/kem.%s wire byte = 0x%02x, want 0x%02x", c.name, uint8(c.node), c.wantHex)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package kem
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// DKGChannelScheme is the only KeyExchangeID admissible on a validator-to-
|
||||
// validator DKG channel. Pinned at KeyExchangeMLKEM1024 (NIST PQ Cat 5) so
|
||||
// the Pulsar / Pulsar-M Pedersen DKG over R_q runs under the strongest
|
||||
// FIPS 203 parameter set available.
|
||||
//
|
||||
// Anyone constructing a DKG session in pulsar / pulsar-m MUST go through
|
||||
// NewDKGKEMSession (or assert this constant explicitly) so a future
|
||||
// refactor that silently flips the byte to ML-KEM-768 fails the strict-PQ
|
||||
// profile gate rather than silently downgrading.
|
||||
const DKGChannelScheme = KeyExchangeMLKEM1024
|
||||
|
||||
// ErrDKGSchemeMismatch is returned by NewDKGKEMSession when the caller
|
||||
// supplies a scheme other than DKGChannelScheme. A strict-PQ profile that
|
||||
// declares HighValueKEM=ML-KEM-1024 but observes a DKG attempt under
|
||||
// ML-KEM-768 MUST surface this error to the operator — the scheme
|
||||
// mismatch is a configuration drift, not a tactical preference.
|
||||
var ErrDKGSchemeMismatch = errors.New("kem: DKG channels MUST use ML-KEM-1024")
|
||||
|
||||
// InitiateDKGKEMSession is the DKG-channel-flavoured wrapper around
|
||||
// InitiateKEMSession. The scheme axis is fixed at ML-KEM-1024; supplying
|
||||
// any other scheme returns ErrDKGSchemeMismatch before any KEM work
|
||||
// runs.
|
||||
//
|
||||
// The returned KEMSession is otherwise byte-identical to what
|
||||
// InitiateKEMSession(MLKEM1024, ...) returns; downstream code may call
|
||||
// DeriveDKGAEADKey on it to bind the AEAD key under the "NODE_DKG_V1"
|
||||
// customisation.
|
||||
func InitiateDKGKEMSession(
|
||||
scheme KeyExchangeID,
|
||||
peerKEMPub []byte,
|
||||
transcript []byte,
|
||||
) (*KEMSession, []byte, error) {
|
||||
if scheme != DKGChannelScheme {
|
||||
return nil, nil, fmt.Errorf("%w: got=%s", ErrDKGSchemeMismatch, scheme)
|
||||
}
|
||||
return InitiateKEMSession(scheme, peerKEMPub, transcript)
|
||||
}
|
||||
|
||||
// RespondDKGKEMSession is the responder-side analogue of
|
||||
// InitiateDKGKEMSession. Same scheme-pin rule applies.
|
||||
func RespondDKGKEMSession(
|
||||
scheme KeyExchangeID,
|
||||
ourKEMSec []byte,
|
||||
peerCiphertext []byte,
|
||||
transcript []byte,
|
||||
) (*KEMSession, error) {
|
||||
if scheme != DKGChannelScheme {
|
||||
return nil, fmt.Errorf("%w: got=%s", ErrDKGSchemeMismatch, scheme)
|
||||
}
|
||||
return RespondKEMSession(scheme, ourKEMSec, peerCiphertext, transcript)
|
||||
}
|
||||
|
||||
// AssertDKGCompliance reports nil iff sessionScheme and profileHighValueKEM
|
||||
// are both ML-KEM-1024. The strict-PQ profile cross-axis check: a profile
|
||||
// that declares HighValueKEM=ML-KEM-1024 MUST observe ML-KEM-1024 on the
|
||||
// wire; anything else is a configuration drift that audit tooling rejects
|
||||
// at boot.
|
||||
//
|
||||
// This is the single dispatch point for the "DKG channel scheme matches
|
||||
// profile high-value KEM" predicate; pulsar / pulsar-m callers route
|
||||
// through it instead of comparing the bytes locally.
|
||||
func AssertDKGCompliance(sessionScheme, profileHighValueKEM KeyExchangeID) error {
|
||||
if profileHighValueKEM != DKGChannelScheme {
|
||||
return fmt.Errorf("%w: profile HighValueKEM=%s, expected %s",
|
||||
ErrDKGSchemeMismatch, profileHighValueKEM, DKGChannelScheme)
|
||||
}
|
||||
if sessionScheme != DKGChannelScheme {
|
||||
return fmt.Errorf("%w: session scheme=%s, expected %s",
|
||||
ErrDKGSchemeMismatch, sessionScheme, DKGChannelScheme)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package kem
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestDKGChannel_ForcesMLKEM1024 asserts the DKG channel adapter refuses
|
||||
// any KEM other than ML-KEM-1024. This closes the cross-axis drift where
|
||||
// a profile pins HighValueKEM=ML-KEM-1024 but a DKG attempt under
|
||||
// ML-KEM-768 would otherwise succeed silently.
|
||||
func TestDKGChannel_ForcesMLKEM1024(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
// DKGChannelScheme is the canonical constant; assert its byte value
|
||||
// is ML-KEM-1024 so a future refactor can't silently flip it.
|
||||
require.Equal(KeyExchangeMLKEM1024, DKGChannelScheme,
|
||||
"DKGChannelScheme MUST be ML-KEM-1024")
|
||||
|
||||
pub1024, priv1024, err := GenerateKEMKeypair(KeyExchangeMLKEM1024, rand.Reader)
|
||||
require.NoError(err)
|
||||
|
||||
transcript := []byte("dkg-channel-transcript")
|
||||
|
||||
// Happy path: ML-KEM-1024 succeeds end-to-end.
|
||||
initSess, ct, err := InitiateDKGKEMSession(KeyExchangeMLKEM1024, pub1024, transcript)
|
||||
require.NoError(err)
|
||||
require.Equal(KeyExchangeMLKEM1024, initSess.SchemeID)
|
||||
|
||||
respSess, err := RespondDKGKEMSession(KeyExchangeMLKEM1024, priv1024, ct, transcript)
|
||||
require.NoError(err)
|
||||
require.Equal(initSess.SharedSecret, respSess.SharedSecret)
|
||||
|
||||
// DKG-flavour AEAD key MUST derive cleanly.
|
||||
initKey, err := initSess.DeriveDKGAEADKey()
|
||||
require.NoError(err)
|
||||
respKey, err := respSess.DeriveDKGAEADKey()
|
||||
require.NoError(err)
|
||||
require.Equal(initKey, respKey)
|
||||
|
||||
// Bad path 1: caller passes ML-KEM-768 to the DKG adapter.
|
||||
pub768, priv768, err := GenerateKEMKeypair(KeyExchangeMLKEM768, rand.Reader)
|
||||
require.NoError(err)
|
||||
|
||||
_, _, err = InitiateDKGKEMSession(KeyExchangeMLKEM768, pub768, transcript)
|
||||
require.ErrorIs(err, ErrDKGSchemeMismatch,
|
||||
"DKG adapter MUST refuse ML-KEM-768")
|
||||
|
||||
_, err = RespondDKGKEMSession(KeyExchangeMLKEM768, priv768, make([]byte, 1088), transcript)
|
||||
require.ErrorIs(err, ErrDKGSchemeMismatch)
|
||||
|
||||
// Bad path 2: caller passes a classical marker.
|
||||
_, _, err = InitiateDKGKEMSession(KeyExchangeX25519Unsafe, pub1024, transcript)
|
||||
require.ErrorIs(err, ErrDKGSchemeMismatch)
|
||||
|
||||
// Bad path 3: caller passes None.
|
||||
_, _, err = InitiateDKGKEMSession(KeyExchangeNone, pub1024, transcript)
|
||||
require.ErrorIs(err, ErrDKGSchemeMismatch)
|
||||
}
|
||||
|
||||
// TestAssertDKGCompliance exercises the boot-time cross-axis check that
|
||||
// a pulsar / pulsar-m DKG session's scheme byte agrees with the strict-PQ
|
||||
// profile's HighValueKEM byte. Both must be ML-KEM-1024; anything else is
|
||||
// a configuration drift.
|
||||
func TestAssertDKGCompliance(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
// Happy path: both ML-KEM-1024.
|
||||
require.NoError(AssertDKGCompliance(KeyExchangeMLKEM1024, KeyExchangeMLKEM1024))
|
||||
|
||||
// Profile high-value byte wrong.
|
||||
require.ErrorIs(
|
||||
AssertDKGCompliance(KeyExchangeMLKEM1024, KeyExchangeMLKEM768),
|
||||
ErrDKGSchemeMismatch,
|
||||
)
|
||||
|
||||
// Session byte wrong (profile correct).
|
||||
require.ErrorIs(
|
||||
AssertDKGCompliance(KeyExchangeMLKEM768, KeyExchangeMLKEM1024),
|
||||
ErrDKGSchemeMismatch,
|
||||
)
|
||||
|
||||
// Both wrong.
|
||||
require.ErrorIs(
|
||||
AssertDKGCompliance(KeyExchangeX25519Unsafe, KeyExchangeMLKEM768),
|
||||
ErrDKGSchemeMismatch,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package kem
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/cloudflare/circl/kem/mlkem/mlkem1024"
|
||||
"github.com/cloudflare/circl/kem/mlkem/mlkem768"
|
||||
"golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
// Customization strings for the cSHAKE256 derivations performed by this
|
||||
// package. Pinned at "_V1"; bumping the tag invalidates every prior derived
|
||||
// key (which is the correct behaviour for a hard-fork of the encoding).
|
||||
//
|
||||
// NODE_AEAD_V1 — peer-to-peer session AEAD key
|
||||
// NODE_DKG_V1 — DKG channel AEAD key (high-value, ML-KEM-1024 only)
|
||||
// NODE_TRANSCRIPT_V1 — TupleHash256 customization for handshake transcript
|
||||
const (
|
||||
customizationAEAD = "NODE_AEAD_V1"
|
||||
customizationDKGAEAD = "NODE_DKG_V1"
|
||||
customizationTranscript = "NODE_TRANSCRIPT_V1"
|
||||
|
||||
// TranscriptHashSize is the byte width of the bound handshake
|
||||
// transcript hash returned by HashTranscript and stored in KEMSession.
|
||||
// 48 bytes = 384 bits matches HashSuiteSHA3NIST in consensus/config and
|
||||
// the chain-wide profile ProfileHash width.
|
||||
TranscriptHashSize = 48
|
||||
|
||||
// SharedSecretSize is the FIPS 203 ML-KEM shared-secret width in
|
||||
// bytes. ML-KEM-768 and ML-KEM-1024 both emit 32 bytes (256 bits).
|
||||
SharedSecretSize = 32
|
||||
|
||||
// AEADKeySize is the byte width of the derived AEAD session key.
|
||||
// 32 bytes targets ChaCha20-Poly1305 or AES-256-GCM.
|
||||
AEADKeySize = 32
|
||||
)
|
||||
|
||||
// Errors returned by KEM session APIs. Each names exactly which contract
|
||||
// was violated so callers can produce a precise diagnostic.
|
||||
var (
|
||||
// ErrUnsupportedScheme is returned when the requested KeyExchangeID is
|
||||
// not a supported ML-KEM variant. Includes None and any forbidden
|
||||
// classical marker.
|
||||
ErrUnsupportedScheme = errors.New("kem: unsupported scheme")
|
||||
|
||||
// ErrClassicalKEMForbidden is returned when a strict-PQ caller receives
|
||||
// or attempts to negotiate an explicit classical KEM marker
|
||||
// (X25519Unsafe / P256Unsafe / P384Unsafe).
|
||||
ErrClassicalKEMForbidden = errors.New("kem: classical KEM forbidden in strict-PQ mode")
|
||||
|
||||
// ErrBadCiphertextSize is returned when the encapsulated ciphertext is
|
||||
// not the scheme's canonical CiphertextSize.
|
||||
ErrBadCiphertextSize = errors.New("kem: ciphertext size does not match scheme")
|
||||
|
||||
// ErrBadPublicKeySize is returned when the peer's encapsulation key is
|
||||
// not the scheme's canonical PublicKeySize.
|
||||
ErrBadPublicKeySize = errors.New("kem: public key size does not match scheme")
|
||||
|
||||
// ErrBadPrivateKeySize is returned when the caller-supplied
|
||||
// decapsulation key is not the scheme's canonical PrivateKeySize.
|
||||
ErrBadPrivateKeySize = errors.New("kem: private key size does not match scheme")
|
||||
|
||||
// ErrEmptyTranscript is returned when transcript bytes are nil or
|
||||
// empty. The whole point of transcript binding is that the AEAD key
|
||||
// depends on every prior handshake message; a zero-length transcript
|
||||
// is a contract violation.
|
||||
ErrEmptyTranscript = errors.New("kem: transcript is empty")
|
||||
)
|
||||
|
||||
// KEMSession is the result of one successful ML-KEM encapsulate /
|
||||
// decapsulate. It carries the scheme byte, the raw 256-bit shared secret,
|
||||
// and the 384-bit running transcript hash (cSHAKE256 / TupleHash256 over
|
||||
// every handshake message produced before the KEM round, in canonical
|
||||
// order). The AEAD key is derived from SharedSecret + TranscriptHash via
|
||||
// cSHAKE256 customisation "NODE_AEAD_V1".
|
||||
//
|
||||
// Field invariants enforced by package APIs:
|
||||
//
|
||||
// - SchemeID ∈ {MLKEM768, MLKEM1024}; any other scheme returns
|
||||
// ErrUnsupportedScheme at construction time.
|
||||
// - SharedSecret is the raw FIPS 203 ML-KEM.Decapsulate / Encapsulate
|
||||
// output. Length is always exactly SharedSecretSize.
|
||||
// - TranscriptHash is the 48-byte SHA3-384 (cSHAKE256) commitment over
|
||||
// the handshake transcript. Two distinct transcripts MUST produce
|
||||
// two distinct TranscriptHash values.
|
||||
//
|
||||
// KEMSession does not zero its SharedSecret on drop because Go does not
|
||||
// provide a deterministic destructor; callers that need defensive zeroing
|
||||
// must do it explicitly after deriving the AEAD key.
|
||||
type KEMSession struct {
|
||||
// SchemeID is the FIPS 203 ML-KEM scheme this session was negotiated
|
||||
// under. Strict-PQ peers admit only MLKEM768 (default) and MLKEM1024
|
||||
// (high-value / DKG).
|
||||
SchemeID KeyExchangeID
|
||||
|
||||
// SharedSecret is the 256-bit raw KEM output. Treat as confidential;
|
||||
// derive subordinate keys through cSHAKE256 (see DeriveAEADKey), do
|
||||
// not use directly as an AEAD key.
|
||||
SharedSecret [SharedSecretSize]byte
|
||||
|
||||
// TranscriptHash is the 384-bit cSHAKE256 / TupleHash256 commitment to
|
||||
// the handshake transcript at the point of KEM completion. The hash
|
||||
// is BOUND into DeriveAEADKey so a flipped transcript byte produces a
|
||||
// distinct AEAD key — that is, an attacker who downgrades a single
|
||||
// handshake field gets a key the honest peer never derived.
|
||||
TranscriptHash [TranscriptHashSize]byte
|
||||
}
|
||||
|
||||
// HashTranscript returns the canonical cSHAKE256 / TupleHash256 commitment
|
||||
// to msgs in the order they appear. Customization is
|
||||
// "NODE_TRANSCRIPT_V1" so two different handshake protocols cannot
|
||||
// produce the same hash from the same byte content.
|
||||
//
|
||||
// The encoding is SP 800-185 TupleHash256: each input is left-encoded with
|
||||
// its bit-length prefix, the right-encoded total output bit-length is
|
||||
// appended, and the result is fed to cSHAKE256 with the function-name
|
||||
// "TupleHash" and customisation "NODE_TRANSCRIPT_V1". This matches
|
||||
// the helper used by ChainSecurityProfile.ComputeHash, so the same
|
||||
// transcript hash byte-encodes identically across the node and consensus.
|
||||
func HashTranscript(msgs ...[]byte) [TranscriptHashSize]byte {
|
||||
var x []byte
|
||||
for _, msg := range msgs {
|
||||
x = append(x, encodeStringSP800185(msg)...)
|
||||
}
|
||||
x = append(x, rightEncodeSP800185(uint64(TranscriptHashSize)*8)...)
|
||||
|
||||
h := sha3.NewCShake256([]byte("TupleHash"), []byte(customizationTranscript))
|
||||
_, _ = h.Write(x)
|
||||
out := make([]byte, TranscriptHashSize)
|
||||
_, _ = h.Read(out)
|
||||
|
||||
var digest [TranscriptHashSize]byte
|
||||
copy(digest[:], out)
|
||||
return digest
|
||||
}
|
||||
|
||||
// GenerateKEMKeypair returns a fresh (encapsulation-key, decapsulation-key)
|
||||
// pair for scheme. randSrc may be nil, in which case crypto/rand.Reader is
|
||||
// used. Returns ErrUnsupportedScheme for anything outside the supported
|
||||
// ML-KEM family.
|
||||
//
|
||||
// Sizes:
|
||||
//
|
||||
// MLKEM768 pub=1184 priv=2400 bytes
|
||||
// MLKEM1024 pub=1568 priv=3168 bytes
|
||||
func GenerateKEMKeypair(scheme KeyExchangeID, randSrc io.Reader) (pub, priv []byte, err error) {
|
||||
if randSrc == nil {
|
||||
randSrc = rand.Reader
|
||||
}
|
||||
if scheme.IsForbiddenInPQMode() {
|
||||
return nil, nil, fmt.Errorf("%w: scheme=%s", ErrClassicalKEMForbidden, scheme)
|
||||
}
|
||||
switch scheme {
|
||||
case KeyExchangeMLKEM768:
|
||||
ek, dk, kerr := mlkem768.GenerateKeyPair(randSrc)
|
||||
if kerr != nil {
|
||||
return nil, nil, fmt.Errorf("kem: mlkem768 GenerateKeyPair: %w", kerr)
|
||||
}
|
||||
pkBytes := make([]byte, mlkem768.PublicKeySize)
|
||||
ek.Pack(pkBytes)
|
||||
skBytes := make([]byte, mlkem768.PrivateKeySize)
|
||||
dk.Pack(skBytes)
|
||||
return pkBytes, skBytes, nil
|
||||
case KeyExchangeMLKEM1024:
|
||||
ek, dk, kerr := mlkem1024.GenerateKeyPair(randSrc)
|
||||
if kerr != nil {
|
||||
return nil, nil, fmt.Errorf("kem: mlkem1024 GenerateKeyPair: %w", kerr)
|
||||
}
|
||||
pkBytes := make([]byte, mlkem1024.PublicKeySize)
|
||||
ek.Pack(pkBytes)
|
||||
skBytes := make([]byte, mlkem1024.PrivateKeySize)
|
||||
dk.Pack(skBytes)
|
||||
return pkBytes, skBytes, nil
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("%w: scheme=%s", ErrUnsupportedScheme, scheme)
|
||||
}
|
||||
}
|
||||
|
||||
// PublicKeySize returns the canonical encapsulation-key byte width for
|
||||
// scheme. Useful for fixed-width framing on the wire.
|
||||
func PublicKeySize(scheme KeyExchangeID) (int, error) {
|
||||
switch scheme {
|
||||
case KeyExchangeMLKEM768:
|
||||
return mlkem768.PublicKeySize, nil
|
||||
case KeyExchangeMLKEM1024:
|
||||
return mlkem1024.PublicKeySize, nil
|
||||
}
|
||||
return 0, fmt.Errorf("%w: scheme=%s", ErrUnsupportedScheme, scheme)
|
||||
}
|
||||
|
||||
// CiphertextSize returns the canonical KEM ciphertext byte width for
|
||||
// scheme.
|
||||
func CiphertextSize(scheme KeyExchangeID) (int, error) {
|
||||
switch scheme {
|
||||
case KeyExchangeMLKEM768:
|
||||
return mlkem768.CiphertextSize, nil
|
||||
case KeyExchangeMLKEM1024:
|
||||
return mlkem1024.CiphertextSize, nil
|
||||
}
|
||||
return 0, fmt.Errorf("%w: scheme=%s", ErrUnsupportedScheme, scheme)
|
||||
}
|
||||
|
||||
// InitiateKEMSession performs the initiator side of a one-shot ML-KEM
|
||||
// session establishment. peerKEMPub is the responder's encapsulation key
|
||||
// (already received on the wire); transcript is the bound running hash
|
||||
// over every handshake message produced before this call.
|
||||
//
|
||||
// Returns the resulting KEMSession plus the encapsulation ciphertext to
|
||||
// send to the responder. The caller is responsible for binding the
|
||||
// ciphertext into the post-KEM transcript before deriving subordinate
|
||||
// keys (the cSHAKE256 customisation inside DeriveAEADKey hard-binds the
|
||||
// transcript bytes the caller passes here).
|
||||
func InitiateKEMSession(
|
||||
scheme KeyExchangeID,
|
||||
peerKEMPub []byte,
|
||||
transcript []byte,
|
||||
) (*KEMSession, []byte, error) {
|
||||
if scheme.IsForbiddenInPQMode() {
|
||||
return nil, nil, fmt.Errorf("%w: scheme=%s", ErrClassicalKEMForbidden, scheme)
|
||||
}
|
||||
if len(transcript) == 0 {
|
||||
return nil, nil, ErrEmptyTranscript
|
||||
}
|
||||
|
||||
switch scheme {
|
||||
case KeyExchangeMLKEM768:
|
||||
if len(peerKEMPub) != mlkem768.PublicKeySize {
|
||||
return nil, nil, fmt.Errorf("%w: scheme=%s want=%d got=%d",
|
||||
ErrBadPublicKeySize, scheme, mlkem768.PublicKeySize, len(peerKEMPub))
|
||||
}
|
||||
var pk mlkem768.PublicKey
|
||||
if err := pk.Unpack(peerKEMPub); err != nil {
|
||||
return nil, nil, fmt.Errorf("kem: mlkem768 Unpack pub: %w", err)
|
||||
}
|
||||
ct := make([]byte, mlkem768.CiphertextSize)
|
||||
ssBuf := make([]byte, mlkem768.SharedKeySize)
|
||||
pk.EncapsulateTo(ct, ssBuf, nil)
|
||||
return finishSession(scheme, ssBuf, transcript), ct, nil
|
||||
|
||||
case KeyExchangeMLKEM1024:
|
||||
if len(peerKEMPub) != mlkem1024.PublicKeySize {
|
||||
return nil, nil, fmt.Errorf("%w: scheme=%s want=%d got=%d",
|
||||
ErrBadPublicKeySize, scheme, mlkem1024.PublicKeySize, len(peerKEMPub))
|
||||
}
|
||||
var pk mlkem1024.PublicKey
|
||||
if err := pk.Unpack(peerKEMPub); err != nil {
|
||||
return nil, nil, fmt.Errorf("kem: mlkem1024 Unpack pub: %w", err)
|
||||
}
|
||||
ct := make([]byte, mlkem1024.CiphertextSize)
|
||||
ssBuf := make([]byte, mlkem1024.SharedKeySize)
|
||||
pk.EncapsulateTo(ct, ssBuf, nil)
|
||||
return finishSession(scheme, ssBuf, transcript), ct, nil
|
||||
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("%w: scheme=%s", ErrUnsupportedScheme, scheme)
|
||||
}
|
||||
}
|
||||
|
||||
// RespondKEMSession performs the responder side: decapsulate the
|
||||
// initiator's ciphertext under the responder's decapsulation key and
|
||||
// produce the shared session.
|
||||
//
|
||||
// transcript MUST be byte-identical to the transcript the initiator used.
|
||||
// If the two sides disagree by even one byte, their derived AEAD keys
|
||||
// diverge and the first encrypted frame fails authentication.
|
||||
func RespondKEMSession(
|
||||
scheme KeyExchangeID,
|
||||
ourKEMSec []byte,
|
||||
peerCiphertext []byte,
|
||||
transcript []byte,
|
||||
) (*KEMSession, error) {
|
||||
if scheme.IsForbiddenInPQMode() {
|
||||
return nil, fmt.Errorf("%w: scheme=%s", ErrClassicalKEMForbidden, scheme)
|
||||
}
|
||||
if len(transcript) == 0 {
|
||||
return nil, ErrEmptyTranscript
|
||||
}
|
||||
|
||||
switch scheme {
|
||||
case KeyExchangeMLKEM768:
|
||||
if len(ourKEMSec) != mlkem768.PrivateKeySize {
|
||||
return nil, fmt.Errorf("%w: scheme=%s want=%d got=%d",
|
||||
ErrBadPrivateKeySize, scheme, mlkem768.PrivateKeySize, len(ourKEMSec))
|
||||
}
|
||||
if len(peerCiphertext) != mlkem768.CiphertextSize {
|
||||
return nil, fmt.Errorf("%w: scheme=%s want=%d got=%d",
|
||||
ErrBadCiphertextSize, scheme, mlkem768.CiphertextSize, len(peerCiphertext))
|
||||
}
|
||||
var sk mlkem768.PrivateKey
|
||||
if err := sk.Unpack(ourKEMSec); err != nil {
|
||||
return nil, fmt.Errorf("kem: mlkem768 Unpack priv: %w", err)
|
||||
}
|
||||
ssBuf := make([]byte, mlkem768.SharedKeySize)
|
||||
sk.DecapsulateTo(ssBuf, peerCiphertext)
|
||||
return finishSession(scheme, ssBuf, transcript), nil
|
||||
|
||||
case KeyExchangeMLKEM1024:
|
||||
if len(ourKEMSec) != mlkem1024.PrivateKeySize {
|
||||
return nil, fmt.Errorf("%w: scheme=%s want=%d got=%d",
|
||||
ErrBadPrivateKeySize, scheme, mlkem1024.PrivateKeySize, len(ourKEMSec))
|
||||
}
|
||||
if len(peerCiphertext) != mlkem1024.CiphertextSize {
|
||||
return nil, fmt.Errorf("%w: scheme=%s want=%d got=%d",
|
||||
ErrBadCiphertextSize, scheme, mlkem1024.CiphertextSize, len(peerCiphertext))
|
||||
}
|
||||
var sk mlkem1024.PrivateKey
|
||||
if err := sk.Unpack(ourKEMSec); err != nil {
|
||||
return nil, fmt.Errorf("kem: mlkem1024 Unpack priv: %w", err)
|
||||
}
|
||||
ssBuf := make([]byte, mlkem1024.SharedKeySize)
|
||||
sk.DecapsulateTo(ssBuf, peerCiphertext)
|
||||
return finishSession(scheme, ssBuf, transcript), nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: scheme=%s", ErrUnsupportedScheme, scheme)
|
||||
}
|
||||
}
|
||||
|
||||
// finishSession assembles a KEMSession from a raw KEM shared secret and a
|
||||
// bound transcript. Internal helper; the public Initiate/Respond entry
|
||||
// points dispatch through it so the SchemeID byte and the transcript
|
||||
// commitment are written once, in one place.
|
||||
func finishSession(scheme KeyExchangeID, ssBuf, transcript []byte) *KEMSession {
|
||||
s := &KEMSession{SchemeID: scheme}
|
||||
copy(s.SharedSecret[:], ssBuf)
|
||||
s.TranscriptHash = HashTranscript(transcript)
|
||||
return s
|
||||
}
|
||||
|
||||
// DeriveAEADKey returns the 256-bit AEAD session key bound to this KEM
|
||||
// session's shared secret AND its transcript hash. Uses cSHAKE256 with
|
||||
// customisation "NODE_AEAD_V1"; the function name is "KEMDerive".
|
||||
//
|
||||
// Two sessions with the same SharedSecret but different TranscriptHash
|
||||
// produce two different keys. Two sessions with the same TranscriptHash
|
||||
// but different SharedSecret also produce two different keys. This is
|
||||
// the property the strict-PQ profile depends on for downgrade resistance.
|
||||
func (s *KEMSession) DeriveAEADKey() [AEADKeySize]byte {
|
||||
h := sha3.NewCShake256([]byte("KEMDerive"), []byte(customizationAEAD))
|
||||
_, _ = h.Write([]byte{byte(s.SchemeID)})
|
||||
_, _ = h.Write(s.SharedSecret[:])
|
||||
_, _ = h.Write(s.TranscriptHash[:])
|
||||
|
||||
out := make([]byte, AEADKeySize)
|
||||
_, _ = h.Read(out)
|
||||
|
||||
var key [AEADKeySize]byte
|
||||
copy(key[:], out)
|
||||
return key
|
||||
}
|
||||
|
||||
// DeriveDKGAEADKey returns the 256-bit AEAD session key bound to this KEM
|
||||
// session under the DKG-channel customisation "NODE_DKG_V1". A DKG
|
||||
// channel session is required by the strict-PQ profile to negotiate
|
||||
// ML-KEM-1024; this function refuses to derive a key on any other scheme
|
||||
// so a misconfigured caller fails loud at key derivation rather than
|
||||
// silently downgrading.
|
||||
//
|
||||
// Returns ErrUnsupportedScheme if the session was negotiated under
|
||||
// anything other than KeyExchangeMLKEM1024.
|
||||
func (s *KEMSession) DeriveDKGAEADKey() ([AEADKeySize]byte, error) {
|
||||
var zero [AEADKeySize]byte
|
||||
if s.SchemeID != KeyExchangeMLKEM1024 {
|
||||
return zero, fmt.Errorf("%w: DKG channels require ML-KEM-1024, got %s",
|
||||
ErrUnsupportedScheme, s.SchemeID)
|
||||
}
|
||||
|
||||
h := sha3.NewCShake256([]byte("KEMDerive"), []byte(customizationDKGAEAD))
|
||||
_, _ = h.Write([]byte{byte(s.SchemeID)})
|
||||
_, _ = h.Write(s.SharedSecret[:])
|
||||
_, _ = h.Write(s.TranscriptHash[:])
|
||||
|
||||
out := make([]byte, AEADKeySize)
|
||||
_, _ = h.Read(out)
|
||||
|
||||
var key [AEADKeySize]byte
|
||||
copy(key[:], out)
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// encodeStringSP800185 is the SP 800-185 §2.3 left_encode prefix + raw
|
||||
// bytes. Used by HashTranscript; matches the helper in
|
||||
// consensus/config/security_profile.go byte-for-byte so the two encodings
|
||||
// produce identical transcript bytes for identical inputs.
|
||||
func encodeStringSP800185(s []byte) []byte {
|
||||
out := leftEncodeSP800185(uint64(len(s)) * 8)
|
||||
out = append(out, s...)
|
||||
return out
|
||||
}
|
||||
|
||||
func leftEncodeSP800185(x uint64) []byte {
|
||||
if x == 0 {
|
||||
return []byte{0x01, 0x00}
|
||||
}
|
||||
var buf [8]byte
|
||||
for i := 0; i < 8; i++ {
|
||||
buf[7-i] = byte(x >> (8 * i))
|
||||
}
|
||||
i := 0
|
||||
for i < 7 && buf[i] == 0 {
|
||||
i++
|
||||
}
|
||||
out := make([]byte, 0, 9-i)
|
||||
out = append(out, byte(8-i))
|
||||
out = append(out, buf[i:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
func rightEncodeSP800185(x uint64) []byte {
|
||||
if x == 0 {
|
||||
return []byte{0x00, 0x01}
|
||||
}
|
||||
var buf [8]byte
|
||||
for i := 0; i < 8; i++ {
|
||||
buf[7-i] = byte(x >> (8 * i))
|
||||
}
|
||||
i := 0
|
||||
for i < 7 && buf[i] == 0 {
|
||||
i++
|
||||
}
|
||||
out := make([]byte, 0, 9-i)
|
||||
out = append(out, buf[i:]...)
|
||||
out = append(out, byte(8-i))
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package kem
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestKEMSession_MLKEM768_RoundTrip exercises one full Initiator/Responder
|
||||
// dance under ML-KEM-768 and asserts both sides converge on the same
|
||||
// shared secret and AEAD key. This is the production-default round-trip.
|
||||
func TestKEMSession_MLKEM768_RoundTrip(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
pub, priv, err := GenerateKEMKeypair(KeyExchangeMLKEM768, rand.Reader)
|
||||
require.NoError(err)
|
||||
require.Len(pub, 1184, "ML-KEM-768 public key must be 1184 bytes (FIPS 203)")
|
||||
|
||||
transcript := []byte("test-transcript-mlkem768-roundtrip")
|
||||
|
||||
initSess, ct, err := InitiateKEMSession(KeyExchangeMLKEM768, pub, transcript)
|
||||
require.NoError(err)
|
||||
require.NotNil(initSess)
|
||||
require.Len(ct, 1088, "ML-KEM-768 ciphertext must be 1088 bytes (FIPS 203)")
|
||||
require.Equal(KeyExchangeMLKEM768, initSess.SchemeID)
|
||||
|
||||
respSess, err := RespondKEMSession(KeyExchangeMLKEM768, priv, ct, transcript)
|
||||
require.NoError(err)
|
||||
require.NotNil(respSess)
|
||||
|
||||
require.Equal(initSess.SharedSecret, respSess.SharedSecret,
|
||||
"initiator and responder MUST converge on the same shared secret")
|
||||
require.Equal(initSess.TranscriptHash, respSess.TranscriptHash,
|
||||
"identical transcripts MUST produce identical TranscriptHash")
|
||||
|
||||
initKey := initSess.DeriveAEADKey()
|
||||
respKey := respSess.DeriveAEADKey()
|
||||
require.Equal(initKey, respKey, "derived AEAD keys MUST match")
|
||||
require.Len(initKey[:], AEADKeySize)
|
||||
}
|
||||
|
||||
// TestKEMSession_MLKEM1024_RoundTrip is the equivalent round-trip under
|
||||
// the high-value parameter set. Same convergence invariants, larger keys
|
||||
// and ciphertexts.
|
||||
func TestKEMSession_MLKEM1024_RoundTrip(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
pub, priv, err := GenerateKEMKeypair(KeyExchangeMLKEM1024, rand.Reader)
|
||||
require.NoError(err)
|
||||
require.Len(pub, 1568, "ML-KEM-1024 public key must be 1568 bytes (FIPS 203)")
|
||||
|
||||
transcript := []byte("test-transcript-mlkem1024-dkg-channel")
|
||||
|
||||
initSess, ct, err := InitiateKEMSession(KeyExchangeMLKEM1024, pub, transcript)
|
||||
require.NoError(err)
|
||||
require.Len(ct, 1568, "ML-KEM-1024 ciphertext must be 1568 bytes (FIPS 203)")
|
||||
|
||||
respSess, err := RespondKEMSession(KeyExchangeMLKEM1024, priv, ct, transcript)
|
||||
require.NoError(err)
|
||||
|
||||
require.Equal(initSess.SharedSecret, respSess.SharedSecret)
|
||||
require.Equal(initSess.TranscriptHash, respSess.TranscriptHash)
|
||||
require.Equal(initSess.DeriveAEADKey(), respSess.DeriveAEADKey())
|
||||
}
|
||||
|
||||
// TestKEMSession_DistinctTranscriptsDifferentKeys asserts the cross-axis
|
||||
// security property: two KEM sessions built with the same shared-secret
|
||||
// material but distinct transcripts MUST produce distinct AEAD keys. This
|
||||
// is what makes the strict-PQ handshake downgrade-resistant: an attacker
|
||||
// who can flip one transcript byte gets a key the honest peer never
|
||||
// derived.
|
||||
func TestKEMSession_DistinctTranscriptsDifferentKeys(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
pub, priv, err := GenerateKEMKeypair(KeyExchangeMLKEM768, rand.Reader)
|
||||
require.NoError(err)
|
||||
|
||||
transcriptA := []byte("scenario-A-handshake-bytes-v1")
|
||||
transcriptB := []byte("scenario-B-handshake-bytes-v1")
|
||||
|
||||
// Different transcripts, different ciphertexts (random KEM seed each call).
|
||||
sessA, ctA, err := InitiateKEMSession(KeyExchangeMLKEM768, pub, transcriptA)
|
||||
require.NoError(err)
|
||||
sessB, ctB, err := InitiateKEMSession(KeyExchangeMLKEM768, pub, transcriptB)
|
||||
require.NoError(err)
|
||||
|
||||
// Sanity: distinct sessions have distinct shared secrets (the KEM is
|
||||
// randomized).
|
||||
require.NotEqual(sessA.SharedSecret, sessB.SharedSecret)
|
||||
|
||||
// The distinguishing property under test: even if we had identical
|
||||
// shared secrets, distinct transcripts produce distinct AEAD keys.
|
||||
// Verify by constructing a synthetic session that holds sessA's secret
|
||||
// under transcriptB's hash.
|
||||
hybrid := &KEMSession{
|
||||
SchemeID: sessA.SchemeID,
|
||||
SharedSecret: sessA.SharedSecret,
|
||||
TranscriptHash: sessB.TranscriptHash,
|
||||
}
|
||||
require.NotEqual(sessA.DeriveAEADKey(), hybrid.DeriveAEADKey(),
|
||||
"same shared secret + different transcript MUST yield different AEAD keys")
|
||||
|
||||
// And mirror: same transcript + different shared secret yields different keys.
|
||||
hybrid2 := &KEMSession{
|
||||
SchemeID: sessA.SchemeID,
|
||||
SharedSecret: sessB.SharedSecret,
|
||||
TranscriptHash: sessA.TranscriptHash,
|
||||
}
|
||||
require.NotEqual(sessA.DeriveAEADKey(), hybrid2.DeriveAEADKey(),
|
||||
"different shared secret + same transcript MUST yield different AEAD keys")
|
||||
|
||||
// Responder-side decapsulation under the wrong transcript: shared
|
||||
// secret converges (KEM is transcript-free), but derived AEAD key
|
||||
// diverges. Use ctA but pass transcriptB.
|
||||
respWrong, err := RespondKEMSession(KeyExchangeMLKEM768, priv, ctA, transcriptB)
|
||||
require.NoError(err)
|
||||
require.Equal(sessA.SharedSecret, respWrong.SharedSecret,
|
||||
"KEM decapsulation is transcript-free; shared secret still converges")
|
||||
require.NotEqual(sessA.DeriveAEADKey(), respWrong.DeriveAEADKey(),
|
||||
"AEAD key MUST diverge when transcripts disagree")
|
||||
|
||||
// Cross-traffic sanity: ctA != ctB (KEM is randomized).
|
||||
require.False(bytes.Equal(ctA, ctB), "two encapsulations against the same pub MUST differ")
|
||||
}
|
||||
|
||||
// TestKEMSession_RefusesClassicalKEM asserts strict-PQ peers refuse to
|
||||
// accept the classical KEM marker. This is the wire-level enforcement of
|
||||
// HIP-0077 "no classical primitives in strict-PQ mode". The canonical
|
||||
// config.KeyExchangeID enum exposes a single classical marker
|
||||
// (X25519Unsafe at 0x90); the P-256 / P-384 markers that used to live
|
||||
// in this package have been collapsed onto X25519Unsafe in the
|
||||
// canonical numbering (Bug 3 fix).
|
||||
func TestKEMSession_RefusesClassicalKEM(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
scheme := KeyExchangeX25519Unsafe
|
||||
t.Run(scheme.String(), func(t *testing.T) {
|
||||
_, _, err := GenerateKEMKeypair(scheme, rand.Reader)
|
||||
require.ErrorIs(err, ErrClassicalKEMForbidden)
|
||||
|
||||
_, _, err = InitiateKEMSession(scheme, nil, []byte("x"))
|
||||
require.ErrorIs(err, ErrClassicalKEMForbidden)
|
||||
|
||||
_, err = RespondKEMSession(scheme, nil, nil, []byte("x"))
|
||||
require.ErrorIs(err, ErrClassicalKEMForbidden)
|
||||
})
|
||||
}
|
||||
|
||||
// TestKEMSession_RefusesEmptyTranscript asserts the binding requirement:
|
||||
// an empty transcript MUST fail at construction time, not silently bind
|
||||
// to a zero-length tuple.
|
||||
func TestKEMSession_RefusesEmptyTranscript(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
pub, priv, err := GenerateKEMKeypair(KeyExchangeMLKEM768, rand.Reader)
|
||||
require.NoError(err)
|
||||
|
||||
_, _, err = InitiateKEMSession(KeyExchangeMLKEM768, pub, nil)
|
||||
require.ErrorIs(err, ErrEmptyTranscript)
|
||||
|
||||
_, err = RespondKEMSession(KeyExchangeMLKEM768, priv, make([]byte, 1088), nil)
|
||||
require.ErrorIs(err, ErrEmptyTranscript)
|
||||
}
|
||||
|
||||
// TestKEMSession_RefusesBadSizes asserts mis-sized public keys, private
|
||||
// keys, and ciphertexts are rejected with a precise error rather than
|
||||
// silently truncated.
|
||||
func TestKEMSession_RefusesBadSizes(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
_, _, err := InitiateKEMSession(KeyExchangeMLKEM768, make([]byte, 100), []byte("x"))
|
||||
require.ErrorIs(err, ErrBadPublicKeySize)
|
||||
|
||||
_, err = RespondKEMSession(KeyExchangeMLKEM768, make([]byte, 100), make([]byte, 1088), []byte("x"))
|
||||
require.ErrorIs(err, ErrBadPrivateKeySize)
|
||||
|
||||
_, priv, gerr := GenerateKEMKeypair(KeyExchangeMLKEM768, rand.Reader)
|
||||
require.NoError(gerr)
|
||||
_, err = RespondKEMSession(KeyExchangeMLKEM768, priv, make([]byte, 100), []byte("x"))
|
||||
require.ErrorIs(err, ErrBadCiphertextSize)
|
||||
}
|
||||
|
||||
// TestKEMSession_RefusesUnsupportedScheme asserts KeyExchangeNone and any
|
||||
// unknown byte fail with ErrUnsupportedScheme at construction time. ML-KEM
|
||||
// at NIST Cat 1 is no longer named in the canonical enum (below the
|
||||
// strict-PQ floor); an arbitrary unknown byte stands in for it here.
|
||||
func TestKEMSession_RefusesUnsupportedScheme(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
_, _, err := GenerateKEMKeypair(KeyExchangeNone, rand.Reader)
|
||||
require.ErrorIs(err, ErrUnsupportedScheme)
|
||||
|
||||
// Arbitrary unallocated byte stands in for "scheme not in the canonical
|
||||
// numbering" — the runtime cannot tell the difference between a retired
|
||||
// ML-KEM-512 byte and a never-allocated value.
|
||||
unknown := KeyExchangeID(0x7F)
|
||||
_, _, err = GenerateKEMKeypair(unknown, rand.Reader)
|
||||
require.ErrorIs(err, ErrUnsupportedScheme)
|
||||
|
||||
_, _, err = InitiateKEMSession(unknown, make([]byte, 800), []byte("x"))
|
||||
require.ErrorIs(err, ErrUnsupportedScheme)
|
||||
}
|
||||
|
||||
// TestHashTranscript_Deterministic asserts the TupleHash256 binding is
|
||||
// deterministic and length-prefixed: rearranging the same byte content
|
||||
// across two distinct messages produces a different hash.
|
||||
func TestHashTranscript_Deterministic(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
h1 := HashTranscript([]byte("hello"), []byte("world"))
|
||||
h2 := HashTranscript([]byte("hello"), []byte("world"))
|
||||
require.Equal(h1, h2, "same inputs MUST produce same hash")
|
||||
|
||||
h3 := HashTranscript([]byte("helloworld"))
|
||||
require.NotEqual(h1, h3, "TupleHash MUST distinguish ['hello','world'] from ['helloworld']")
|
||||
|
||||
h4 := HashTranscript([]byte("world"), []byte("hello"))
|
||||
require.NotEqual(h1, h4, "TupleHash MUST be order-sensitive")
|
||||
}
|
||||
|
||||
// TestDeriveDKGAEADKey_OnlyMLKEM1024 asserts the DKG-channel customisation
|
||||
// refuses to derive a key for any scheme other than ML-KEM-1024. This
|
||||
// closes the "high-value channel silently used Cat 3 instead of Cat 5"
|
||||
// drift path.
|
||||
func TestDeriveDKGAEADKey_OnlyMLKEM1024(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
pub, _, err := GenerateKEMKeypair(KeyExchangeMLKEM768, rand.Reader)
|
||||
require.NoError(err)
|
||||
sess, _, err := InitiateKEMSession(KeyExchangeMLKEM768, pub, []byte("x"))
|
||||
require.NoError(err)
|
||||
|
||||
_, err = sess.DeriveDKGAEADKey()
|
||||
require.ErrorIs(err, ErrUnsupportedScheme,
|
||||
"DKG AEAD derivation MUST refuse ML-KEM-768 sessions")
|
||||
|
||||
pub2, _, err := GenerateKEMKeypair(KeyExchangeMLKEM1024, rand.Reader)
|
||||
require.NoError(err)
|
||||
sess2, _, err := InitiateKEMSession(KeyExchangeMLKEM1024, pub2, []byte("x"))
|
||||
require.NoError(err)
|
||||
key, err := sess2.DeriveDKGAEADKey()
|
||||
require.NoError(err)
|
||||
require.Len(key[:], AEADKeySize)
|
||||
|
||||
// DKG key MUST differ from the peer-AEAD key derived from the same
|
||||
// session (different customisation strings).
|
||||
peerKey := sess2.DeriveAEADKey()
|
||||
require.NotEqual(key, peerKey,
|
||||
"DKG and peer AEAD customisations MUST produce different keys")
|
||||
}
|
||||
|
||||
// TestKeyExchangeID_Predicates spot-checks the small predicate helpers so
|
||||
// a future numbering change cannot silently flip a wire byte from PQ to
|
||||
// classical without breaking a test. SharedSecretBits and NISTCategory
|
||||
// are free functions in this package (KeyExchangeID is a type alias of
|
||||
// config.KeyExchangeID so methods can only live in the config package).
|
||||
func TestKeyExchangeID_Predicates(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
require.True(KeyExchangeMLKEM768.IsPostQuantum())
|
||||
require.True(KeyExchangeMLKEM1024.IsPostQuantum())
|
||||
require.False(KeyExchangeNone.IsPostQuantum())
|
||||
require.False(KeyExchangeX25519Unsafe.IsPostQuantum())
|
||||
|
||||
require.True(KeyExchangeX25519Unsafe.IsForbiddenInPQMode())
|
||||
require.False(KeyExchangeMLKEM768.IsForbiddenInPQMode())
|
||||
|
||||
require.EqualValues(256, SharedSecretBits(KeyExchangeMLKEM768))
|
||||
require.EqualValues(256, SharedSecretBits(KeyExchangeMLKEM1024))
|
||||
require.EqualValues(0, SharedSecretBits(KeyExchangeNone))
|
||||
|
||||
require.EqualValues(3, NISTCategory(KeyExchangeMLKEM768))
|
||||
require.EqualValues(5, NISTCategory(KeyExchangeMLKEM1024))
|
||||
|
||||
// Canonical wire bytes — pins the Bug 3 fix at 0x01/0x02.
|
||||
require.EqualValues(0x01, uint8(KeyExchangeMLKEM768))
|
||||
require.EqualValues(0x02, uint8(KeyExchangeMLKEM1024))
|
||||
require.EqualValues(0x90, uint8(KeyExchangeX25519Unsafe))
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package kem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ActiveProfile is the small subset of strict-PQ profile fields the
|
||||
// network layer needs at boot to log the active posture and refuse
|
||||
// peers that disagree. The full ChainSecurityProfile lives in
|
||||
// consensus/config and is the authoritative source; this struct is the
|
||||
// network-layer projection of that profile.
|
||||
type ActiveProfile struct {
|
||||
// Name is the canonical profile name for log lines and audit reports.
|
||||
// Production strict-PQ deployments use "STRICT_E2E_PQ".
|
||||
Name string
|
||||
|
||||
// NodeIdentity is the wire name of the per-node identity signature
|
||||
// scheme. Strict-PQ: "ML-DSA-65".
|
||||
NodeIdentity string
|
||||
|
||||
// SessionKEM is the KEM byte for peer-to-peer session keys. Strict-PQ
|
||||
// default: KeyExchangeMLKEM768.
|
||||
SessionKEM KeyExchangeID
|
||||
|
||||
// DKGKEM is the KEM byte for validator-to-validator DKG channels.
|
||||
// Strict-PQ mandates KeyExchangeMLKEM1024.
|
||||
DKGKEM KeyExchangeID
|
||||
|
||||
// HashSuite is the wire name of the transcript / commitment hash
|
||||
// family. Strict-PQ: "SHA3_NIST".
|
||||
HashSuite string
|
||||
|
||||
// PostQuantumE2E reports whether the profile asserts end-to-end PQ
|
||||
// (KEM, identity, threshold finality all PQ).
|
||||
PostQuantumE2E bool
|
||||
|
||||
// ForbidClassicalKEM is the strict-PQ refusal switch: when true, any
|
||||
// peer offering an IsForbiddenInPQMode() KEM is refused at handshake.
|
||||
ForbidClassicalKEM bool
|
||||
}
|
||||
|
||||
// StrictE2EPQ returns the canonical strict-PQ profile projection
|
||||
// for the network layer. Mirrors the consensus-config profile of the
|
||||
// same shape; values here are pinned so a node that boots without an
|
||||
// explicit profile still advertises the strict posture by default.
|
||||
func StrictE2EPQ() ActiveProfile {
|
||||
return ActiveProfile{
|
||||
Name: "STRICT_E2E_PQ",
|
||||
NodeIdentity: "ML-DSA-65",
|
||||
SessionKEM: KeyExchangeMLKEM768,
|
||||
DKGKEM: KeyExchangeMLKEM1024,
|
||||
HashSuite: "SHA3_NIST",
|
||||
PostQuantumE2E: true,
|
||||
ForbidClassicalKEM: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Banner returns the multi-line operator-readable banner that node
|
||||
// startup MUST emit on boot under a strict-PQ profile. Fixed format so
|
||||
// log scrapers can match deterministically.
|
||||
//
|
||||
// SECURITY PROFILE: STRICT_E2E_PQ
|
||||
// NODE IDENTITY: ML-DSA-65
|
||||
// SESSION KEM: ML-KEM-768
|
||||
// DKG KEM: ML-KEM-1024
|
||||
// HASH SUITE: SHA3_NIST
|
||||
// POST-QUANTUM E2E: true
|
||||
func (p ActiveProfile) Banner() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "SECURITY PROFILE: %s\n", p.Name)
|
||||
fmt.Fprintf(&b, "NODE IDENTITY: %s\n", p.NodeIdentity)
|
||||
fmt.Fprintf(&b, "SESSION KEM: %s\n", strings.ToUpper(p.SessionKEM.String()))
|
||||
fmt.Fprintf(&b, "DKG KEM: %s\n", strings.ToUpper(p.DKGKEM.String()))
|
||||
fmt.Fprintf(&b, "HASH SUITE: %s\n", p.HashSuite)
|
||||
fmt.Fprintf(&b, "POST-QUANTUM E2E: %t\n", p.PostQuantumE2E)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Validate refuses an ActiveProfile whose axes are internally inconsistent
|
||||
// before the network layer trusts it. Mirrors the strict-PQ portion of
|
||||
// ChainSecurityProfile.Validate.
|
||||
func (p ActiveProfile) Validate() error {
|
||||
if p.Name == "" {
|
||||
return fmt.Errorf("kem: ActiveProfile.Name is empty")
|
||||
}
|
||||
if !p.SessionKEM.IsPostQuantum() {
|
||||
return fmt.Errorf("kem: ActiveProfile.SessionKEM=%s is not post-quantum", p.SessionKEM)
|
||||
}
|
||||
if p.DKGKEM != KeyExchangeMLKEM1024 {
|
||||
return fmt.Errorf("kem: ActiveProfile.DKGKEM=%s, expected %s",
|
||||
p.DKGKEM, KeyExchangeMLKEM1024)
|
||||
}
|
||||
if !p.PostQuantumE2E {
|
||||
return fmt.Errorf("kem: ActiveProfile.PostQuantumE2E must be true on strict-PQ")
|
||||
}
|
||||
if !p.ForbidClassicalKEM {
|
||||
return fmt.Errorf("kem: ActiveProfile.ForbidClassicalKEM must be true on strict-PQ")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package kem
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestStrictE2EPQ_Banner asserts the canonical strict-PQ banner format
|
||||
// matches the line-shape audit pipelines look for at boot.
|
||||
func TestStrictE2EPQ_Banner(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
p := StrictE2EPQ()
|
||||
require.NoError(p.Validate())
|
||||
|
||||
out := p.Banner()
|
||||
require.Contains(out, "SECURITY PROFILE: STRICT_E2E_PQ")
|
||||
require.Contains(out, "NODE IDENTITY: ML-DSA-65")
|
||||
require.Contains(out, "SESSION KEM: ML-KEM-768")
|
||||
require.Contains(out, "DKG KEM: ML-KEM-1024")
|
||||
require.Contains(out, "HASH SUITE: SHA3_NIST")
|
||||
require.Contains(out, "POST-QUANTUM E2E: true")
|
||||
|
||||
// Banner is line-oriented; each axis is its own line.
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
require.Len(lines, 6)
|
||||
}
|
||||
|
||||
// TestActiveProfile_RefusesInternalInconsistency asserts a profile with
|
||||
// drifted bytes is refused at Validate.
|
||||
func TestActiveProfile_RefusesInternalInconsistency(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
bad := StrictE2EPQ()
|
||||
bad.DKGKEM = KeyExchangeMLKEM768
|
||||
require.Error(bad.Validate())
|
||||
|
||||
bad = StrictE2EPQ()
|
||||
bad.SessionKEM = KeyExchangeX25519Unsafe
|
||||
require.Error(bad.Validate())
|
||||
|
||||
bad = StrictE2EPQ()
|
||||
bad.ForbidClassicalKEM = false
|
||||
require.Error(bad.Validate())
|
||||
|
||||
bad = StrictE2EPQ()
|
||||
bad.PostQuantumE2E = false
|
||||
require.Error(bad.Validate())
|
||||
|
||||
bad = StrictE2EPQ()
|
||||
bad.Name = ""
|
||||
require.Error(bad.Validate())
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// Package kem implements post-quantum key encapsulation for the Lux node peer
|
||||
// handshake. ML-KEM-768 (FIPS 203, NIST PQ Cat 3) is the default session KEM;
|
||||
// ML-KEM-1024 (NIST PQ Cat 5) is mandatory for high-value validator and DKG
|
||||
// channels.
|
||||
//
|
||||
// The session shared secret is derived through one FIPS 203 encapsulate /
|
||||
// decapsulate dance and then bound to the handshake transcript via cSHAKE256
|
||||
// with customization "NODE_AEAD_V1". The bound key is the AEAD session
|
||||
// key used by every subsequent frame on the connection.
|
||||
//
|
||||
// One and only one KEM family is admitted on a strict-PQ profile: ML-KEM.
|
||||
// Classical X25519 / ECDH are present in the wire enum solely as forbidden
|
||||
// markers so audit tooling can name a misconfiguration precisely; a strict-PQ
|
||||
// peer with ForbidClassicalKEM=true refuses to handshake against any peer
|
||||
// offering KeyExchangeX25519Unsafe.
|
||||
//
|
||||
// KeyExchangeID is a type alias of config.KeyExchangeID; the canonical wire
|
||||
// bytes (0x01 = ML-KEM-768, 0x02 = ML-KEM-1024, 0x90 = X25519Unsafe) live
|
||||
// in the consensus config package and are shared with protocol/auth. Closes
|
||||
// the "three disjoint KEM ID registries" drift named in the spec/code audit
|
||||
// under Bug 3.
|
||||
package kem
|
||||
|
||||
import (
|
||||
"github.com/luxfi/consensus/config"
|
||||
)
|
||||
|
||||
// KeyExchangeID is the wire byte that identifies the key-exchange / KEM
|
||||
// scheme a peer offers for session-key establishment. Type-aliased to
|
||||
// config.KeyExchangeID so the wire format and predicates (String,
|
||||
// IsPostQuantum, IsForbiddenInPQMode) are shared with the consensus
|
||||
// security profile.
|
||||
//
|
||||
// Canonical numbering:
|
||||
//
|
||||
// 0x00 — Invalid / unspecified (rejected by every strict-PQ verifier)
|
||||
// 0x01 — ML-KEM-768 (FIPS 203 Cat 3; production default)
|
||||
// 0x02 — ML-KEM-1024 (FIPS 203 Cat 5; DKG / high-value channels)
|
||||
// 0x90 — X25519Unsafe (classical; explicit forbidden marker in strict-PQ)
|
||||
//
|
||||
// The forbidden markers are NEVER produced by a strict-PQ node; they exist
|
||||
// solely so a strict-PQ peer that receives one can refuse with a precise
|
||||
// diagnostic instead of dropping silently.
|
||||
//
|
||||
// ML-KEM-512 (NIST Cat 1) is intentionally not exported — it sits below the
|
||||
// strict-PQ floor; the node does not negotiate Cat 1 sessions.
|
||||
type KeyExchangeID = config.KeyExchangeID
|
||||
|
||||
const (
|
||||
// KeyExchangeNone — alias for config.KeyExchangeInvalid (0x00). Rejected
|
||||
// by every strict-PQ peer.
|
||||
KeyExchangeNone = config.KeyExchangeInvalid
|
||||
|
||||
// KeyExchangeMLKEM768 — FIPS 203 ML-KEM-768 (NIST PQ Cat 3, byte 0x01).
|
||||
// The production default for peer-to-peer session keys on the Lux
|
||||
// primary network. 1184-byte public key, 1088-byte ciphertext, 32-byte
|
||||
// shared secret.
|
||||
KeyExchangeMLKEM768 = config.KeyExchangeMLKEM768
|
||||
|
||||
// KeyExchangeMLKEM1024 — FIPS 203 ML-KEM-1024 (NIST PQ Cat 5, byte 0x02).
|
||||
// Mandatory for high-value validator channels and Pulsar DKG sessions.
|
||||
// 1568-byte public key, 1568-byte ciphertext, 32-byte shared secret.
|
||||
KeyExchangeMLKEM1024 = config.KeyExchangeMLKEM1024
|
||||
|
||||
// KeyExchangeX25519Unsafe — classical X25519 (byte 0x90). Carried as an
|
||||
// explicit forbidden marker; a strict-PQ peer refuses to handshake
|
||||
// against any peer offering this byte.
|
||||
KeyExchangeX25519Unsafe = config.KeyExchangeX25519Unsafe
|
||||
)
|
||||
|
||||
// SharedSecretBits reports the shared-secret width emitted by scheme in
|
||||
// bits. Both ML-KEM-768 and ML-KEM-1024 emit 256-bit secrets per FIPS 203.
|
||||
// Returns 0 for invalid and forbidden markers.
|
||||
//
|
||||
// Free function rather than a method because KeyExchangeID is a type alias
|
||||
// of config.KeyExchangeID; methods can only be declared in the package that
|
||||
// defines the underlying type.
|
||||
func SharedSecretBits(scheme KeyExchangeID) uint16 {
|
||||
switch scheme {
|
||||
case KeyExchangeMLKEM768, KeyExchangeMLKEM1024:
|
||||
return 256
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// NISTCategory reports the NIST PQ security category for scheme. Cat 3
|
||||
// targets AES-192 / SHA-384 strength; Cat 5 targets AES-256 / SHA-512.
|
||||
// Returns 0 for invalid and forbidden markers.
|
||||
//
|
||||
// Free function for the same reason as SharedSecretBits.
|
||||
func NISTCategory(scheme KeyExchangeID) uint8 {
|
||||
switch scheme {
|
||||
case KeyExchangeMLKEM768:
|
||||
return 3
|
||||
case KeyExchangeMLKEM1024:
|
||||
return 5
|
||||
}
|
||||
return 0
|
||||
}
|
||||
+89
-5
@@ -19,6 +19,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/luxfi/codec/wrappers"
|
||||
consensusconfig "github.com/luxfi/consensus/config"
|
||||
consensustracker "github.com/luxfi/consensus/networking/tracker"
|
||||
"github.com/luxfi/constants"
|
||||
"github.com/luxfi/crypto/bls"
|
||||
@@ -31,6 +32,7 @@ import (
|
||||
"github.com/luxfi/node/message"
|
||||
"github.com/luxfi/node/nets"
|
||||
"github.com/luxfi/node/network/dialer"
|
||||
"github.com/luxfi/node/network/kem"
|
||||
"github.com/luxfi/node/network/peer"
|
||||
"github.com/luxfi/node/network/throttling"
|
||||
"github.com/luxfi/node/network/tracker"
|
||||
@@ -334,8 +336,8 @@ func NewNetwork(
|
||||
)
|
||||
for _, validatorTx := range parsedGenesis.Validators {
|
||||
// Extract validator details from the transaction.
|
||||
// Genesis may encode validators as AddValidatorTx (pre-permissionless)
|
||||
// or AddPermissionlessValidatorTx (post-Etna). Handle both.
|
||||
// Genesis may encode validators as AddValidatorTx (legacy) or
|
||||
// AddPermissionlessValidatorTx (modern). Handle both.
|
||||
switch tx := validatorTx.Unsigned.(type) {
|
||||
case *txs.AddPermissionlessValidatorTx:
|
||||
nodeID := tx.Validator.NodeID
|
||||
@@ -483,6 +485,45 @@ func NewNetwork(
|
||||
IPSigner: peer.NewIPSigner(config.MyIPPort, config.TLSKey, config.BLSKey),
|
||||
}
|
||||
|
||||
// Build the per-chain peer.SchemeGate exactly once at network bootstrap.
|
||||
// The gate is the single cross-axis primitive that funnels every
|
||||
// inbound NodeID through the chain's ChainSecurityProfile pin. Chains
|
||||
// that ship no profile (legacy / classical-compat networks) leave the
|
||||
// gate nil — upgrader.connToIDAndCert is nil-safe and refuses nobody.
|
||||
var schemeGate *peer.SchemeGate
|
||||
if config.SecurityProfile != nil {
|
||||
schemeGate, err = peer.NewSchemeGate(config.SecurityProfile, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building peer SchemeGate: %w", err)
|
||||
}
|
||||
log.Info("peer SchemeGate active",
|
||||
zap.String("profile", config.SecurityProfile.ProfileName),
|
||||
zap.Uint32("profileID", config.SecurityProfile.ProfileID),
|
||||
)
|
||||
}
|
||||
|
||||
// On a strict-PQ profile, also pin the application-layer PQ handshake
|
||||
// config onto every peer goroutine. peer.Start runs the ML-KEM +
|
||||
// ML-DSA handshake the moment TLS upgrade succeeds; bare TLS is
|
||||
// refused. Permissive / classical-compat profiles leave PQHandshake
|
||||
// nil and use the legacy bare-TLS path.
|
||||
if config.SecurityProfile != nil && profileRequiresPQHandshake(config.SecurityProfile) {
|
||||
pqIdent, err := peer.NewLocalIdentity(config.MyNodeID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building PQ local identity: %w", err)
|
||||
}
|
||||
peerConfig.PQHandshakeConfig = &peer.HandshakeConfig{
|
||||
Profile: peer.ProfileStrictPQ,
|
||||
KEMScheme: kemSessionScheme(config.SecurityProfile),
|
||||
ForbidClassicalKEM: true,
|
||||
}
|
||||
peerConfig.PQLocalIdentity = pqIdent
|
||||
log.Info("peer PQ handshake active",
|
||||
zap.Stringer("scheme", peerConfig.PQHandshakeConfig.KEMScheme),
|
||||
zap.Stringer("nodeID", config.MyNodeID),
|
||||
)
|
||||
}
|
||||
|
||||
onCloseCtx, cancel := context.WithCancel(context.Background())
|
||||
n := &network{
|
||||
startupTime: time.Now(),
|
||||
@@ -494,8 +535,8 @@ func NewNetwork(
|
||||
inboundConnUpgradeThrottler: throttling.NewInboundConnUpgradeThrottler(config.ThrottlerConfig.InboundConnUpgradeThrottlerConfig),
|
||||
listener: listener,
|
||||
dialer: dialer,
|
||||
serverUpgrader: peer.NewTLSServerUpgrader(config.TLSConfig, metrics.tlsConnRejected),
|
||||
clientUpgrader: peer.NewTLSClientUpgrader(config.TLSConfig, metrics.tlsConnRejected),
|
||||
serverUpgrader: peer.NewTLSServerUpgrader(config.TLSConfig, metrics.tlsConnRejected, schemeGate),
|
||||
clientUpgrader: peer.NewTLSClientUpgrader(config.TLSConfig, metrics.tlsConnRejected, schemeGate),
|
||||
|
||||
onCloseCtx: onCloseCtx,
|
||||
onCloseCtxCancel: cancel,
|
||||
@@ -529,6 +570,32 @@ func NewNetwork(
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// profileRequiresPQHandshake reports whether the chain's locked
|
||||
// ChainSecurityProfile mandates the application-layer ML-KEM + ML-DSA
|
||||
// handshake on top of TLS. Strict-PQ and FIPS profiles do; permissive
|
||||
// profiles fall back to the legacy bare-TLS handshake. Centralised here
|
||||
// so the same predicate decides "build SchemeGate" and "build PQ
|
||||
// handshake config" — one rule, one place.
|
||||
func profileRequiresPQHandshake(p *consensusconfig.ChainSecurityProfile) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
id := consensusconfig.ProfileID(p.ProfileID)
|
||||
return id == consensusconfig.ProfileStrictPQ || id == consensusconfig.ProfileFIPS
|
||||
}
|
||||
|
||||
// kemSessionScheme returns the KEM byte the chain's ChainSecurityProfile
|
||||
// pins for per-peer session keys. Strict-PQ defaults to ML-KEM-768 (NIST
|
||||
// PQ Cat 3); a chain profile that pins ML-KEM-1024 (Cat 5) for sessions
|
||||
// is honoured. The DKG KEM (ML-KEM-1024 on strict-PQ — a separate axis
|
||||
// captured in HighValueKEM) is not consumed by the peer handshake.
|
||||
func kemSessionScheme(p *consensusconfig.ChainSecurityProfile) kem.KeyExchangeID {
|
||||
if p != nil && p.KeyExchangeID == consensusconfig.KeyExchangeMLKEM1024 {
|
||||
return kem.KeyExchangeMLKEM1024
|
||||
}
|
||||
return kem.KeyExchangeMLKEM768
|
||||
}
|
||||
|
||||
// sequencerID returns the validator-set identity that sequences chainID.
|
||||
// This resolves the distinction between:
|
||||
// - chainID: execution domain (C-Chain, Zoo L2, etc.)
|
||||
@@ -1208,7 +1275,19 @@ func (n *network) samplePeers(
|
||||
if requestedValidators <= 0 {
|
||||
requestedValidators = defaultSampleK
|
||||
}
|
||||
// Chicken-and-egg fallback: in a freshly bootstrapped sovereign
|
||||
// primary network, the validator manager is briefly empty until
|
||||
// the first P-chain block commits initial stakers from genesis.
|
||||
// During that window, treating peers tracking the chain as
|
||||
// validator candidates is the only way out — otherwise P-chain
|
||||
// can never produce its first block, because the proposer would
|
||||
// sample 0 peers and reach 0 votes. Once the manager has any
|
||||
// validators registered, the strict cap returns.
|
||||
bootstrapFallback := numValidatorsInManager == 0
|
||||
numValidatorsToSample := min(requestedValidators, numValidatorsInManager)
|
||||
if bootstrapFallback {
|
||||
numValidatorsToSample = requestedValidators
|
||||
}
|
||||
|
||||
n.peersLock.RLock()
|
||||
defer n.peersLock.RUnlock()
|
||||
@@ -1258,7 +1337,12 @@ func (n *network) samplePeers(
|
||||
return true
|
||||
}
|
||||
|
||||
if areTheyAValidator {
|
||||
// Bootstrap fallback (see numValidatorsToSample setup above):
|
||||
// when validator manager is empty, every chain-tracking peer
|
||||
// counts as a validator candidate. Without this, sample
|
||||
// always returns zero because GetValidator() can't yet see
|
||||
// the genesis-declared initial stakers.
|
||||
if areTheyAValidator || bootstrapFallback {
|
||||
numValidatorsToSample--
|
||||
return numValidatorsToSample >= 0
|
||||
}
|
||||
|
||||
@@ -74,4 +74,17 @@ type Config struct {
|
||||
|
||||
// IngressConnectionCount counts the ingress (to us) connections.
|
||||
IngressConnectionCount atomic.Int64
|
||||
|
||||
// PQHandshakeConfig pins the strict-PQ application-layer handshake
|
||||
// (ML-KEM session + ML-DSA-65 identity) the peer goroutine runs
|
||||
// after the TLS upgrade. Non-nil on strict-PQ chains; nil leaves the
|
||||
// peer on the legacy bare-TLS path. Shared across every peer
|
||||
// instance for the same chain — never mutated post-construction.
|
||||
PQHandshakeConfig *HandshakeConfig
|
||||
|
||||
// PQLocalIdentity is this node's ML-DSA-65 long-term identity. The
|
||||
// per-session ML-KEM keypair is regenerated inside the handshake; the
|
||||
// identity is the only persistent secret. Non-nil iff
|
||||
// PQHandshakeConfig is non-nil.
|
||||
PQLocalIdentity *LocalIdentity
|
||||
}
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package peer
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/node/network/kem"
|
||||
)
|
||||
|
||||
// PQHandshake is the application-layer post-quantum handshake performed
|
||||
// after a TLS 1.3 (X25519+ML-KEM-768) channel is established. The TLS
|
||||
// layer authenticates the underlying transport; this handshake binds the
|
||||
// node-level ML-DSA-65 identity to an ML-KEM session, deriving an AEAD
|
||||
// session key under cSHAKE256 customisation "NODE_AEAD_V1".
|
||||
//
|
||||
// Wire protocol (4 byte tags described below are encoded big-endian
|
||||
// length-prefixed):
|
||||
//
|
||||
// INIT (initiator → responder)
|
||||
// ProtocolVersion u8 ; 0x01 today
|
||||
// ProfileID u8 ; LuxStrictPQ etc.
|
||||
// ChainID [32]byte ; primary-network or per-chain
|
||||
// KEMScheme u8 ; ML-KEM-768 / ML-KEM-1024
|
||||
// NodeID [20]byte ; canonical Lux NodeID
|
||||
// MLDSAPublicKey [PublicKeySize]byte ; FIPS 204 ML-DSA-65 pub
|
||||
// KEMPublicKey [PublicKeySize]byte ; FIPS 203 ML-KEM-{768|1024}
|
||||
// Sig [SignatureSize]byte ; ML-DSA-65 over running transcript
|
||||
//
|
||||
// RESP (responder → initiator)
|
||||
// ProtocolVersion u8 ; echoed
|
||||
// ProfileID u8 ; echoed
|
||||
// ChainID [32]byte ; echoed
|
||||
// KEMScheme u8 ; echoed (responder MUST not downgrade)
|
||||
// NodeID [20]byte ; responder's NodeID
|
||||
// MLDSAPublicKey [PublicKeySize]byte ; responder's identity
|
||||
// KEMCiphertext [CiphertextSize]byte ; encapsulated against init KEM pub
|
||||
// Sig [SignatureSize]byte ; ML-DSA-65 over running transcript
|
||||
//
|
||||
// Both sides then:
|
||||
//
|
||||
// 1. Derive shared_secret = ML-KEM.Decapsulate(KEMCiphertext)
|
||||
// 2. Derive bound transcript = TupleHash256("NODE_TRANSCRIPT_V1",
|
||||
// INIT bytes ‖ RESP bytes ‖
|
||||
// init_ml_dsa_pub ‖ resp_ml_dsa_pub ‖
|
||||
// profile_id ‖ chain_id)
|
||||
// 3. Derive aead_key = cSHAKE256("NODE_AEAD_V1",
|
||||
// scheme ‖ shared_secret ‖ transcript)
|
||||
//
|
||||
// The aead_key is what callers feed to ChaCha20-Poly1305 or AES-256-GCM
|
||||
// for every subsequent frame on the connection.
|
||||
//
|
||||
// Strict-PQ enforcement: a peer with ForbidClassicalKEM=true refuses any
|
||||
// INIT whose KEMScheme matches IsForbiddenInPQMode(). The check happens
|
||||
// before any signature work runs, so a malicious peer cannot waste the
|
||||
// verifier's CPU on a bogus signature.
|
||||
|
||||
const (
|
||||
// pqProtocolVersionV1 is the current PQ handshake protocol version
|
||||
// byte. A peer that receives a version it does not understand MUST
|
||||
// abort the handshake before any KEM / signature work runs.
|
||||
pqProtocolVersionV1 uint8 = 0x01
|
||||
|
||||
// nodeIDSize is the canonical Lux NodeID width (20 bytes; SHA-256
|
||||
// truncation per ids.NodeID).
|
||||
nodeIDSize = 20
|
||||
|
||||
// chainIDSize is the canonical Lux ChainID width (32 bytes; SHA-256
|
||||
// per ids.ID).
|
||||
chainIDSize = 32
|
||||
|
||||
// IdentityPublicKeySize is the ML-DSA-65 public key byte width per
|
||||
// FIPS 204 (1952 bytes).
|
||||
IdentityPublicKeySize = mldsa65.PublicKeySize
|
||||
|
||||
// IdentitySignatureSize is the ML-DSA-65 signature byte width per
|
||||
// FIPS 204 (3309 bytes).
|
||||
IdentitySignatureSize = mldsa65.SignatureSize
|
||||
)
|
||||
|
||||
// ProfileID is the per-handshake commitment to the chain-wide security
|
||||
// profile. Mirrors consensus/config.ProfileID; declared locally because
|
||||
// the published consensus v1.23.2 does not yet export
|
||||
// security_profile.go. Once consensus rolls a release that ships it,
|
||||
// this becomes a type alias.
|
||||
type ProfileID uint8
|
||||
|
||||
const (
|
||||
// ProfileNone — no profile committed; rejected by every strict peer.
|
||||
ProfileNone ProfileID = 0x00
|
||||
// ProfileStrictPQ — canonical strict-PQ profile, NIST-aligned.
|
||||
ProfileStrictPQ ProfileID = 0x01
|
||||
// ProfilePermissive — testnet / devnet.
|
||||
ProfilePermissive ProfileID = 0x02
|
||||
// ProfileFIPS — FIPS-204 + FIPS-202 only.
|
||||
ProfileFIPS ProfileID = 0x03
|
||||
)
|
||||
|
||||
// String returns the canonical lowercase profile name.
|
||||
func (p ProfileID) String() string {
|
||||
switch p {
|
||||
case ProfileNone:
|
||||
return "none"
|
||||
case ProfileStrictPQ:
|
||||
return "strict-pq"
|
||||
case ProfilePermissive:
|
||||
return "permissive"
|
||||
case ProfileFIPS:
|
||||
return "fips"
|
||||
default:
|
||||
return fmt.Sprintf("profile(0x%02x)", uint8(p))
|
||||
}
|
||||
}
|
||||
|
||||
// IsStrict reports whether this profile mandates ForbidClassicalKEM.
|
||||
func (p ProfileID) IsStrict() bool {
|
||||
return p == ProfileStrictPQ || p == ProfileFIPS
|
||||
}
|
||||
|
||||
// HandshakeRole is initiator or responder; used only to scope ML-DSA-65
|
||||
// signature contexts so an initiator's signature cannot be replayed as a
|
||||
// responder's and vice versa.
|
||||
type HandshakeRole uint8
|
||||
|
||||
const (
|
||||
roleInitiator HandshakeRole = 0x01
|
||||
roleResponder HandshakeRole = 0x02
|
||||
)
|
||||
|
||||
func (r HandshakeRole) String() string {
|
||||
switch r {
|
||||
case roleInitiator:
|
||||
return "initiator"
|
||||
case roleResponder:
|
||||
return "responder"
|
||||
default:
|
||||
return fmt.Sprintf("role(0x%02x)", uint8(r))
|
||||
}
|
||||
}
|
||||
|
||||
// LocalIdentity carries this node's ML-DSA-65 keypair and NodeID. The
|
||||
// keypair is the ONLY long-term secret the PQ handshake holds; the KEM
|
||||
// keypair is regenerated per-session.
|
||||
type LocalIdentity struct {
|
||||
NodeID ids.NodeID
|
||||
Public *mldsa65.PublicKey
|
||||
Secret *mldsa65.PrivateKey
|
||||
}
|
||||
|
||||
// NewLocalIdentity generates a fresh ML-DSA-65 identity. Convenience for
|
||||
// tests and one-shot tooling; production nodes load their identity from
|
||||
// persistent storage.
|
||||
func NewLocalIdentity(nodeID ids.NodeID) (*LocalIdentity, error) {
|
||||
pk, sk, err := mldsa65.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("peer: generate ML-DSA-65 identity: %w", err)
|
||||
}
|
||||
return &LocalIdentity{NodeID: nodeID, Public: pk, Secret: sk}, nil
|
||||
}
|
||||
|
||||
// HandshakeConfig pins the policy bits the handshake enforces. One value
|
||||
// per peer connection; the network-level config builds one shared
|
||||
// HandshakeConfig and hands a pointer to every per-peer goroutine.
|
||||
type HandshakeConfig struct {
|
||||
// Profile is the chain-wide security profile this peer runs under.
|
||||
// The remote MUST advertise the same byte or the handshake aborts.
|
||||
Profile ProfileID
|
||||
|
||||
// ChainID is the 32-byte commitment to the chain this peer is on.
|
||||
// Pinned into the transcript and the AEAD-key derivation, so two
|
||||
// peers on different chains never derive the same key even with
|
||||
// identical KEM material.
|
||||
ChainID [chainIDSize]byte
|
||||
|
||||
// KEMScheme is the KEM the initiator offers. Responder MUST echo it
|
||||
// back; downgrading on the wire is refused.
|
||||
KEMScheme kem.KeyExchangeID
|
||||
|
||||
// ForbidClassicalKEM is true on strict-PQ profiles; when set, the
|
||||
// handshake refuses any peer whose KEMScheme byte is in the
|
||||
// IsForbiddenInPQMode() set. Also refuses any non-PQ scheme in
|
||||
// non-classical positions.
|
||||
ForbidClassicalKEM bool
|
||||
}
|
||||
|
||||
// Errors returned by the PQ handshake. Each names exactly one contract
|
||||
// violation.
|
||||
var (
|
||||
// ErrHandshakeBadVersion — protocol version byte not understood.
|
||||
ErrHandshakeBadVersion = errors.New("peer: PQ handshake protocol version not understood")
|
||||
|
||||
// ErrHandshakeProfileMismatch — initiator profile != responder profile
|
||||
// or != local-config profile.
|
||||
ErrHandshakeProfileMismatch = errors.New("peer: PQ handshake profile mismatch")
|
||||
|
||||
// ErrHandshakeChainMismatch — chain IDs disagree across the handshake.
|
||||
ErrHandshakeChainMismatch = errors.New("peer: PQ handshake chain ID mismatch")
|
||||
|
||||
// ErrHandshakeBadIdentity — ML-DSA-65 identity key or signature
|
||||
// failed to parse / verify.
|
||||
ErrHandshakeBadIdentity = errors.New("peer: PQ handshake identity invalid")
|
||||
|
||||
// ErrHandshakeClassicalKEM — peer offered an explicit classical KEM
|
||||
// marker (X25519, P-256, P-384); strict-PQ peers refuse.
|
||||
ErrHandshakeClassicalKEM = errors.New("peer: PQ handshake refused classical KEM")
|
||||
|
||||
// ErrHandshakeKEMScheme — peer offered a KEM scheme not allowed by
|
||||
// local config (e.g. responder downgraded ML-KEM-1024 to ML-KEM-768).
|
||||
ErrHandshakeKEMScheme = errors.New("peer: PQ handshake KEM scheme not admissible")
|
||||
|
||||
// ErrHandshakeNodeIDZero — peer presented the zero NodeID.
|
||||
ErrHandshakeNodeIDZero = errors.New("peer: PQ handshake peer NodeID is zero")
|
||||
)
|
||||
|
||||
// HandshakeInit is the initiator's first message. Field order is the
|
||||
// canonical wire order; the running transcript hashes every field in this
|
||||
// order before the initiator signs the running prefix.
|
||||
type HandshakeInit struct {
|
||||
ProtocolVersion uint8
|
||||
Profile ProfileID
|
||||
ChainID [chainIDSize]byte
|
||||
KEMScheme kem.KeyExchangeID
|
||||
NodeID ids.NodeID
|
||||
MLDSAPub []byte // length IdentityPublicKeySize
|
||||
KEMPub []byte // length depends on KEMScheme
|
||||
Sig []byte // length IdentitySignatureSize; covers transcript prefix
|
||||
}
|
||||
|
||||
// HandshakeResp is the responder's reply. Same field shape; KEMCiphertext
|
||||
// replaces KEMPublicKey.
|
||||
type HandshakeResp struct {
|
||||
ProtocolVersion uint8
|
||||
Profile ProfileID
|
||||
ChainID [chainIDSize]byte
|
||||
KEMScheme kem.KeyExchangeID
|
||||
NodeID ids.NodeID
|
||||
MLDSAPub []byte // length IdentityPublicKeySize
|
||||
KEMCiphertext []byte // length depends on KEMScheme
|
||||
Sig []byte // length IdentitySignatureSize; covers transcript prefix
|
||||
}
|
||||
|
||||
// HandshakeResult is what both sides return at the end of a successful
|
||||
// handshake. KEMSession carries the raw shared secret + bound transcript
|
||||
// hash; AEADKey is the 256-bit cSHAKE256-derived key for the connection's
|
||||
// authenticated cipher.
|
||||
type HandshakeResult struct {
|
||||
Local *LocalIdentity
|
||||
PeerNodeID ids.NodeID
|
||||
PeerMLDSA *mldsa65.PublicKey
|
||||
KEMSession *kem.KEMSession
|
||||
AEADKey [kem.AEADKeySize]byte
|
||||
}
|
||||
|
||||
// InitiateHandshake is the initiator's side of the PQ peer handshake.
|
||||
// Produces the INIT message ready to send on the wire, plus the
|
||||
// per-session KEM decapsulation key the caller must retain until
|
||||
// FinishInitiatorHandshake runs.
|
||||
//
|
||||
// Caller workflow:
|
||||
//
|
||||
// init, kemSec, err := InitiateHandshake(cfg, local)
|
||||
// // send init.Bytes() on the wire
|
||||
// // receive resp bytes from peer
|
||||
// resp, err := ParseHandshakeResp(respBytes, cfg.KEMScheme)
|
||||
// result, err := FinishInitiatorHandshake(cfg, local, init, resp, kemSec)
|
||||
func InitiateHandshake(
|
||||
cfg *HandshakeConfig,
|
||||
local *LocalIdentity,
|
||||
) (*HandshakeInit, []byte /* kemSec */, error) {
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
kemPub, kemSec, err := kem.GenerateKEMKeypair(cfg.KEMScheme, rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("peer: PQ init keypair: %w", err)
|
||||
}
|
||||
|
||||
init := &HandshakeInit{
|
||||
ProtocolVersion: pqProtocolVersionV1,
|
||||
Profile: cfg.Profile,
|
||||
ChainID: cfg.ChainID,
|
||||
KEMScheme: cfg.KEMScheme,
|
||||
NodeID: local.NodeID,
|
||||
MLDSAPub: packMLDSAPub(local.Public),
|
||||
KEMPub: kemPub,
|
||||
}
|
||||
|
||||
// Sign the transcript prefix (every field above Sig). The signature
|
||||
// context ("NODE_PQ_HANDSHAKE_V1") makes role and version part of
|
||||
// the signed bytes so a captured signature cannot be replayed as a
|
||||
// responder signature or under a future protocol version.
|
||||
transcriptPrefix := init.transcriptPrefix(roleInitiator)
|
||||
sig := make([]byte, IdentitySignatureSize)
|
||||
if err := mldsa65.SignTo(
|
||||
local.Secret,
|
||||
transcriptPrefix,
|
||||
[]byte("NODE_PQ_HANDSHAKE_V1/initiator"),
|
||||
false, // deterministic mode; randomized=true is also valid, but determinism keeps test vectors stable
|
||||
sig,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("peer: PQ init sign: %w", err)
|
||||
}
|
||||
init.Sig = sig
|
||||
|
||||
return init, kemSec, nil
|
||||
}
|
||||
|
||||
// RespondHandshake is the responder's side. Accepts the parsed INIT,
|
||||
// validates every cross-axis invariant, encapsulates against the
|
||||
// initiator's KEM public key, signs the running transcript prefix, and
|
||||
// returns both the RESP message and the resulting HandshakeResult.
|
||||
//
|
||||
// The result's AEADKey is already derived; the caller switches its
|
||||
// transport to authenticated mode the moment RESP is sent.
|
||||
func RespondHandshake(
|
||||
cfg *HandshakeConfig,
|
||||
local *LocalIdentity,
|
||||
init *HandshakeInit,
|
||||
) (*HandshakeResp, *HandshakeResult, error) {
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := validateRemoteInit(cfg, init); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Parse initiator's MLDSA pub.
|
||||
peerPub, err := unpackMLDSAPub(init.MLDSAPub)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: parse peer MLDSA pub: %v",
|
||||
ErrHandshakeBadIdentity, err)
|
||||
}
|
||||
|
||||
// Verify initiator's signature over the transcript prefix.
|
||||
if !mldsa65.Verify(
|
||||
peerPub,
|
||||
init.transcriptPrefix(roleInitiator),
|
||||
[]byte("NODE_PQ_HANDSHAKE_V1/initiator"),
|
||||
init.Sig,
|
||||
) {
|
||||
return nil, nil, fmt.Errorf("%w: initiator signature failed", ErrHandshakeBadIdentity)
|
||||
}
|
||||
|
||||
// KEM encapsulate against initiator's KEM pub. transcript bytes here
|
||||
// are exactly the initiator's INIT message in canonical order; the
|
||||
// responder's RESP will append onto this for the responder's signing
|
||||
// transcript.
|
||||
transcript := init.canonicalBytes()
|
||||
kemSess, ct, err := kem.InitiateKEMSession(cfg.KEMScheme, init.KEMPub, transcript)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("peer: PQ encapsulate: %w", err)
|
||||
}
|
||||
|
||||
resp := &HandshakeResp{
|
||||
ProtocolVersion: pqProtocolVersionV1,
|
||||
Profile: cfg.Profile,
|
||||
ChainID: cfg.ChainID,
|
||||
KEMScheme: cfg.KEMScheme,
|
||||
NodeID: local.NodeID,
|
||||
MLDSAPub: packMLDSAPub(local.Public),
|
||||
KEMCiphertext: ct,
|
||||
}
|
||||
|
||||
respPrefix := resp.transcriptPrefix(init, roleResponder)
|
||||
sig := make([]byte, IdentitySignatureSize)
|
||||
if err := mldsa65.SignTo(
|
||||
local.Secret,
|
||||
respPrefix,
|
||||
[]byte("NODE_PQ_HANDSHAKE_V1/responder"),
|
||||
false,
|
||||
sig,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("peer: PQ resp sign: %w", err)
|
||||
}
|
||||
resp.Sig = sig
|
||||
|
||||
// Bind the final transcript (init+resp+both ML-DSA pubs+profile+chain)
|
||||
// for AEAD key derivation.
|
||||
fullTranscript := bindAEADTranscript(init, resp)
|
||||
finalSession := &kem.KEMSession{
|
||||
SchemeID: kemSess.SchemeID,
|
||||
SharedSecret: kemSess.SharedSecret,
|
||||
TranscriptHash: kem.HashTranscript(fullTranscript),
|
||||
}
|
||||
|
||||
return resp, &HandshakeResult{
|
||||
Local: local,
|
||||
PeerNodeID: init.NodeID,
|
||||
PeerMLDSA: peerPub,
|
||||
KEMSession: finalSession,
|
||||
AEADKey: finalSession.DeriveAEADKey(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FinishInitiatorHandshake is the initiator's third-stage step: validate
|
||||
// the responder's RESP, decapsulate to recover the shared secret, bind
|
||||
// the full transcript, and derive the AEAD key. Returns the same
|
||||
// HandshakeResult shape as RespondHandshake so a caller can treat the
|
||||
// post-handshake API symmetrically.
|
||||
func FinishInitiatorHandshake(
|
||||
cfg *HandshakeConfig,
|
||||
local *LocalIdentity,
|
||||
init *HandshakeInit,
|
||||
resp *HandshakeResp,
|
||||
kemSec []byte,
|
||||
) (*HandshakeResult, error) {
|
||||
if err := validateRemoteResp(cfg, init, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
peerPub, err := unpackMLDSAPub(resp.MLDSAPub)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: parse responder MLDSA pub: %v",
|
||||
ErrHandshakeBadIdentity, err)
|
||||
}
|
||||
|
||||
if !mldsa65.Verify(
|
||||
peerPub,
|
||||
resp.transcriptPrefix(init, roleResponder),
|
||||
[]byte("NODE_PQ_HANDSHAKE_V1/responder"),
|
||||
resp.Sig,
|
||||
) {
|
||||
return nil, fmt.Errorf("%w: responder signature failed", ErrHandshakeBadIdentity)
|
||||
}
|
||||
|
||||
transcript := init.canonicalBytes()
|
||||
kemSess, err := kem.RespondKEMSession(cfg.KEMScheme, kemSec, resp.KEMCiphertext, transcript)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("peer: PQ decapsulate: %w", err)
|
||||
}
|
||||
|
||||
fullTranscript := bindAEADTranscript(init, resp)
|
||||
finalSession := &kem.KEMSession{
|
||||
SchemeID: kemSess.SchemeID,
|
||||
SharedSecret: kemSess.SharedSecret,
|
||||
TranscriptHash: kem.HashTranscript(fullTranscript),
|
||||
}
|
||||
|
||||
return &HandshakeResult{
|
||||
Local: local,
|
||||
PeerNodeID: resp.NodeID,
|
||||
PeerMLDSA: peerPub,
|
||||
KEMSession: finalSession,
|
||||
AEADKey: finalSession.DeriveAEADKey(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateConfig refuses obviously bad configs at construction time. The
|
||||
// rest of the handshake assumes cfg is sane.
|
||||
func validateConfig(cfg *HandshakeConfig) error {
|
||||
if cfg == nil {
|
||||
return errors.New("peer: PQ handshake config is nil")
|
||||
}
|
||||
if cfg.KEMScheme.IsForbiddenInPQMode() {
|
||||
return fmt.Errorf("%w: local config offers %s",
|
||||
ErrHandshakeClassicalKEM, cfg.KEMScheme)
|
||||
}
|
||||
if !cfg.KEMScheme.IsPostQuantum() {
|
||||
return fmt.Errorf("%w: local config offers %s",
|
||||
ErrHandshakeKEMScheme, cfg.KEMScheme)
|
||||
}
|
||||
if cfg.Profile == ProfileNone {
|
||||
return fmt.Errorf("%w: local profile is None", ErrHandshakeProfileMismatch)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRemoteInit runs every cross-axis check on the parsed INIT
|
||||
// before any expensive crypto runs.
|
||||
func validateRemoteInit(cfg *HandshakeConfig, init *HandshakeInit) error {
|
||||
if init == nil {
|
||||
return errors.New("peer: PQ handshake init is nil")
|
||||
}
|
||||
if init.ProtocolVersion != pqProtocolVersionV1 {
|
||||
return fmt.Errorf("%w: peer offered version=0x%02x, want=0x%02x",
|
||||
ErrHandshakeBadVersion, init.ProtocolVersion, pqProtocolVersionV1)
|
||||
}
|
||||
if init.Profile != cfg.Profile {
|
||||
return fmt.Errorf("%w: local=%s peer=%s",
|
||||
ErrHandshakeProfileMismatch, cfg.Profile, init.Profile)
|
||||
}
|
||||
if init.ChainID != cfg.ChainID {
|
||||
return fmt.Errorf("%w: local=%x peer=%x",
|
||||
ErrHandshakeChainMismatch, cfg.ChainID, init.ChainID)
|
||||
}
|
||||
if cfg.ForbidClassicalKEM && init.KEMScheme.IsForbiddenInPQMode() {
|
||||
return fmt.Errorf("%w: peer offered %s",
|
||||
ErrHandshakeClassicalKEM, init.KEMScheme)
|
||||
}
|
||||
if init.KEMScheme != cfg.KEMScheme {
|
||||
return fmt.Errorf("%w: local=%s peer=%s",
|
||||
ErrHandshakeKEMScheme, cfg.KEMScheme, init.KEMScheme)
|
||||
}
|
||||
if init.NodeID == ids.EmptyNodeID {
|
||||
return ErrHandshakeNodeIDZero
|
||||
}
|
||||
if len(init.MLDSAPub) != IdentityPublicKeySize {
|
||||
return fmt.Errorf("%w: peer MLDSAPub size=%d want=%d",
|
||||
ErrHandshakeBadIdentity, len(init.MLDSAPub), IdentityPublicKeySize)
|
||||
}
|
||||
wantKEMPub, err := kem.PublicKeySize(init.KEMScheme)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrHandshakeKEMScheme, err)
|
||||
}
|
||||
if len(init.KEMPub) != wantKEMPub {
|
||||
return fmt.Errorf("%w: peer KEMPub size=%d want=%d (scheme=%s)",
|
||||
ErrHandshakeKEMScheme, len(init.KEMPub), wantKEMPub, init.KEMScheme)
|
||||
}
|
||||
if len(init.Sig) != IdentitySignatureSize {
|
||||
return fmt.Errorf("%w: peer Sig size=%d want=%d",
|
||||
ErrHandshakeBadIdentity, len(init.Sig), IdentitySignatureSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRemoteResp runs the responder-message version of the same
|
||||
// cross-axis checks. Includes the no-downgrade rule (resp.KEMScheme must
|
||||
// echo init.KEMScheme exactly).
|
||||
func validateRemoteResp(cfg *HandshakeConfig, init *HandshakeInit, resp *HandshakeResp) error {
|
||||
if resp == nil {
|
||||
return errors.New("peer: PQ handshake resp is nil")
|
||||
}
|
||||
if resp.ProtocolVersion != pqProtocolVersionV1 {
|
||||
return fmt.Errorf("%w: peer offered version=0x%02x, want=0x%02x",
|
||||
ErrHandshakeBadVersion, resp.ProtocolVersion, pqProtocolVersionV1)
|
||||
}
|
||||
if resp.Profile != cfg.Profile {
|
||||
return fmt.Errorf("%w: local=%s peer=%s",
|
||||
ErrHandshakeProfileMismatch, cfg.Profile, resp.Profile)
|
||||
}
|
||||
if resp.ChainID != cfg.ChainID {
|
||||
return fmt.Errorf("%w: local=%x peer=%x",
|
||||
ErrHandshakeChainMismatch, cfg.ChainID, resp.ChainID)
|
||||
}
|
||||
if cfg.ForbidClassicalKEM && resp.KEMScheme.IsForbiddenInPQMode() {
|
||||
return fmt.Errorf("%w: peer offered %s",
|
||||
ErrHandshakeClassicalKEM, resp.KEMScheme)
|
||||
}
|
||||
if resp.KEMScheme != init.KEMScheme {
|
||||
return fmt.Errorf("%w: responder downgraded KEM init=%s resp=%s",
|
||||
ErrHandshakeKEMScheme, init.KEMScheme, resp.KEMScheme)
|
||||
}
|
||||
if resp.NodeID == ids.EmptyNodeID {
|
||||
return ErrHandshakeNodeIDZero
|
||||
}
|
||||
if len(resp.MLDSAPub) != IdentityPublicKeySize {
|
||||
return fmt.Errorf("%w: peer MLDSAPub size=%d want=%d",
|
||||
ErrHandshakeBadIdentity, len(resp.MLDSAPub), IdentityPublicKeySize)
|
||||
}
|
||||
wantCT, err := kem.CiphertextSize(resp.KEMScheme)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrHandshakeKEMScheme, err)
|
||||
}
|
||||
if len(resp.KEMCiphertext) != wantCT {
|
||||
return fmt.Errorf("%w: peer KEMCiphertext size=%d want=%d (scheme=%s)",
|
||||
ErrHandshakeKEMScheme, len(resp.KEMCiphertext), wantCT, resp.KEMScheme)
|
||||
}
|
||||
if len(resp.Sig) != IdentitySignatureSize {
|
||||
return fmt.Errorf("%w: peer Sig size=%d want=%d",
|
||||
ErrHandshakeBadIdentity, len(resp.Sig), IdentitySignatureSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// transcriptPrefix returns the canonical byte string the initiator signs
|
||||
// over: every field of the INIT in wire order, MINUS the signature
|
||||
// itself. role is bound into the signature context (not the prefix), so
|
||||
// callers that need the bound prefix can recompute it deterministically.
|
||||
func (h *HandshakeInit) transcriptPrefix(_ HandshakeRole) []byte {
|
||||
buf := make([]byte, 0, 256+len(h.MLDSAPub)+len(h.KEMPub))
|
||||
buf = append(buf, h.ProtocolVersion)
|
||||
buf = append(buf, byte(h.Profile))
|
||||
buf = append(buf, h.ChainID[:]...)
|
||||
buf = append(buf, byte(h.KEMScheme))
|
||||
nid := h.NodeID
|
||||
buf = append(buf, nid[:]...)
|
||||
buf = appendLengthPrefixed(buf, h.MLDSAPub)
|
||||
buf = appendLengthPrefixed(buf, h.KEMPub)
|
||||
return buf
|
||||
}
|
||||
|
||||
// canonicalBytes returns the full INIT in wire order including the
|
||||
// signature. Used as the transcript seed by the responder for KEM and as
|
||||
// part of the AEAD-binding transcript.
|
||||
func (h *HandshakeInit) canonicalBytes() []byte {
|
||||
buf := h.transcriptPrefix(roleInitiator)
|
||||
buf = appendLengthPrefixed(buf, h.Sig)
|
||||
return buf
|
||||
}
|
||||
|
||||
// transcriptPrefix for the responder includes the entire INIT bytes so
|
||||
// the responder's signature commits to what it observed from the
|
||||
// initiator. role is unused (bound in context); kept for symmetry.
|
||||
func (h *HandshakeResp) transcriptPrefix(init *HandshakeInit, _ HandshakeRole) []byte {
|
||||
buf := make([]byte, 0, 512+len(h.MLDSAPub)+len(h.KEMCiphertext))
|
||||
buf = append(buf, init.canonicalBytes()...)
|
||||
buf = append(buf, h.ProtocolVersion)
|
||||
buf = append(buf, byte(h.Profile))
|
||||
buf = append(buf, h.ChainID[:]...)
|
||||
buf = append(buf, byte(h.KEMScheme))
|
||||
nid := h.NodeID
|
||||
buf = append(buf, nid[:]...)
|
||||
buf = appendLengthPrefixed(buf, h.MLDSAPub)
|
||||
buf = appendLengthPrefixed(buf, h.KEMCiphertext)
|
||||
return buf
|
||||
}
|
||||
|
||||
// canonicalBytes returns the full RESP in wire order including the
|
||||
// signature. Independent of INIT bytes because the wire delivers RESP
|
||||
// alone; the transcript-binding APIs glue the two.
|
||||
func (h *HandshakeResp) canonicalBytes() []byte {
|
||||
buf := make([]byte, 0, 256+len(h.MLDSAPub)+len(h.KEMCiphertext)+len(h.Sig))
|
||||
buf = append(buf, h.ProtocolVersion)
|
||||
buf = append(buf, byte(h.Profile))
|
||||
buf = append(buf, h.ChainID[:]...)
|
||||
buf = append(buf, byte(h.KEMScheme))
|
||||
nid := h.NodeID
|
||||
buf = append(buf, nid[:]...)
|
||||
buf = appendLengthPrefixed(buf, h.MLDSAPub)
|
||||
buf = appendLengthPrefixed(buf, h.KEMCiphertext)
|
||||
buf = appendLengthPrefixed(buf, h.Sig)
|
||||
return buf
|
||||
}
|
||||
|
||||
// bindAEADTranscript returns the byte string that gets hashed into the
|
||||
// final TranscriptHash for AEAD key derivation. Per HIP-0078 §"Session
|
||||
// key derivation", this MUST include: profile_id, chain_id, both peers'
|
||||
// ML-DSA public keys, both wire messages. Re-listing fields that already
|
||||
// appear inside init/resp.canonicalBytes() is intentional defense in
|
||||
// depth: a manifest mismatch on profile or chain shows up here even if
|
||||
// the encoding of init or resp drifted.
|
||||
func bindAEADTranscript(init *HandshakeInit, resp *HandshakeResp) []byte {
|
||||
buf := make([]byte, 0, 1024)
|
||||
buf = append(buf, init.canonicalBytes()...)
|
||||
buf = append(buf, resp.canonicalBytes()...)
|
||||
buf = append(buf, byte(init.Profile))
|
||||
buf = append(buf, init.ChainID[:]...)
|
||||
buf = append(buf, init.MLDSAPub...)
|
||||
buf = append(buf, resp.MLDSAPub...)
|
||||
return buf
|
||||
}
|
||||
|
||||
// packMLDSAPub packs an ML-DSA-65 public key into its canonical byte form.
|
||||
func packMLDSAPub(pk *mldsa65.PublicKey) []byte {
|
||||
var buf [mldsa65.PublicKeySize]byte
|
||||
pk.Pack(&buf)
|
||||
out := make([]byte, mldsa65.PublicKeySize)
|
||||
copy(out, buf[:])
|
||||
return out
|
||||
}
|
||||
|
||||
// unpackMLDSAPub parses an ML-DSA-65 public key from canonical bytes.
|
||||
func unpackMLDSAPub(b []byte) (*mldsa65.PublicKey, error) {
|
||||
if len(b) != mldsa65.PublicKeySize {
|
||||
return nil, fmt.Errorf("public key size=%d want=%d", len(b), mldsa65.PublicKeySize)
|
||||
}
|
||||
var buf [mldsa65.PublicKeySize]byte
|
||||
copy(buf[:], b)
|
||||
pk := new(mldsa65.PublicKey)
|
||||
pk.Unpack(&buf)
|
||||
return pk, nil
|
||||
}
|
||||
|
||||
// appendLengthPrefixed appends a 4-byte big-endian length and then b.
|
||||
// Used inside transcript byte strings so two distinct concatenations
|
||||
// cannot collide via a boundary ambiguity.
|
||||
func appendLengthPrefixed(dst, b []byte) []byte {
|
||||
var lp [4]byte
|
||||
binary.BigEndian.PutUint32(lp[:], uint32(len(b)))
|
||||
dst = append(dst, lp[:]...)
|
||||
dst = append(dst, b...)
|
||||
return dst
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package peer
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/node/network/kem"
|
||||
)
|
||||
|
||||
// newTestIdentities returns two fresh ML-DSA-65 identities and a shared
|
||||
// chain ID. Used by every handshake test that needs a happy-path setup.
|
||||
func newTestIdentities(t *testing.T) (initiator, responder *LocalIdentity, chainID [32]byte) {
|
||||
t.Helper()
|
||||
require := require.New(t)
|
||||
|
||||
initNodeID := ids.GenerateTestNodeID()
|
||||
respNodeID := ids.GenerateTestNodeID()
|
||||
|
||||
init, err := NewLocalIdentity(initNodeID)
|
||||
require.NoError(err)
|
||||
resp, err := NewLocalIdentity(respNodeID)
|
||||
require.NoError(err)
|
||||
|
||||
_, err = rand.Read(chainID[:])
|
||||
require.NoError(err)
|
||||
return init, resp, chainID
|
||||
}
|
||||
|
||||
// runHandshake drives a full INIT/RESP/FINISH on a strict-PQ config under
|
||||
// the supplied KEM scheme. Returns both result objects.
|
||||
func runHandshake(
|
||||
t *testing.T,
|
||||
initiator, responder *LocalIdentity,
|
||||
chainID [32]byte,
|
||||
scheme kem.KeyExchangeID,
|
||||
) (*HandshakeResult, *HandshakeResult) {
|
||||
t.Helper()
|
||||
require := require.New(t)
|
||||
|
||||
cfg := &HandshakeConfig{
|
||||
Profile: ProfileStrictPQ,
|
||||
ChainID: chainID,
|
||||
KEMScheme: scheme,
|
||||
ForbidClassicalKEM: true,
|
||||
}
|
||||
|
||||
init, kemSec, err := InitiateHandshake(cfg, initiator)
|
||||
require.NoError(err)
|
||||
require.NotNil(init)
|
||||
require.NotEmpty(kemSec)
|
||||
|
||||
resp, respResult, err := RespondHandshake(cfg, responder, init)
|
||||
require.NoError(err)
|
||||
require.NotNil(resp)
|
||||
require.NotNil(respResult)
|
||||
|
||||
initResult, err := FinishInitiatorHandshake(cfg, initiator, init, resp, kemSec)
|
||||
require.NoError(err)
|
||||
require.NotNil(initResult)
|
||||
|
||||
require.Equal(initResult.AEADKey, respResult.AEADKey,
|
||||
"both sides MUST derive the same AEAD key")
|
||||
|
||||
return initResult, respResult
|
||||
}
|
||||
|
||||
// TestHandshake_StrictPQ_MLKEM768_RoundTrip exercises the default
|
||||
// production handshake: strict-PQ profile, ML-KEM-768.
|
||||
func TestHandshake_StrictPQ_MLKEM768_RoundTrip(t *testing.T) {
|
||||
require := require.New(t)
|
||||
initiator, responder, chainID := newTestIdentities(t)
|
||||
|
||||
initResult, respResult := runHandshake(t, initiator, responder, chainID, kem.KeyExchangeMLKEM768)
|
||||
|
||||
require.Equal(initiator.NodeID, respResult.PeerNodeID)
|
||||
require.Equal(responder.NodeID, initResult.PeerNodeID)
|
||||
require.Equal(kem.KeyExchangeMLKEM768, initResult.KEMSession.SchemeID)
|
||||
}
|
||||
|
||||
// TestHandshake_StrictPQ_MLKEM1024_RoundTrip exercises the high-value
|
||||
// DKG-grade handshake under ML-KEM-1024.
|
||||
func TestHandshake_StrictPQ_MLKEM1024_RoundTrip(t *testing.T) {
|
||||
require := require.New(t)
|
||||
initiator, responder, chainID := newTestIdentities(t)
|
||||
|
||||
initResult, respResult := runHandshake(t, initiator, responder, chainID, kem.KeyExchangeMLKEM1024)
|
||||
|
||||
require.Equal(kem.KeyExchangeMLKEM1024, initResult.KEMSession.SchemeID)
|
||||
require.Equal(kem.KeyExchangeMLKEM1024, respResult.KEMSession.SchemeID)
|
||||
|
||||
// AEADKey under DKG customisation MUST differ from peer-AEAD customisation.
|
||||
dkgKey, err := initResult.KEMSession.DeriveDKGAEADKey()
|
||||
require.NoError(err)
|
||||
require.NotEqual(initResult.AEADKey, dkgKey)
|
||||
}
|
||||
|
||||
// TestHandshake_StrictPQ_RefusesClassicalKEM asserts a strict-PQ peer
|
||||
// rejects any peer offering an explicit classical KEM marker.
|
||||
func TestHandshake_StrictPQ_RefusesClassicalKEM(t *testing.T) {
|
||||
require := require.New(t)
|
||||
initiator, responder, chainID := newTestIdentities(t)
|
||||
|
||||
cfg := &HandshakeConfig{
|
||||
Profile: ProfileStrictPQ,
|
||||
ChainID: chainID,
|
||||
KEMScheme: kem.KeyExchangeMLKEM768,
|
||||
ForbidClassicalKEM: true,
|
||||
}
|
||||
|
||||
// Local config trying to offer a classical KEM is refused at config
|
||||
// validation time, before any wire bytes leave the box.
|
||||
badCfg := *cfg
|
||||
badCfg.KEMScheme = kem.KeyExchangeX25519Unsafe
|
||||
_, _, err := InitiateHandshake(&badCfg, initiator)
|
||||
require.ErrorIs(err, ErrHandshakeClassicalKEM)
|
||||
|
||||
// A peer that hand-crafts a classical KEM byte into an INIT is
|
||||
// refused by the responder.
|
||||
init, _, err := InitiateHandshake(cfg, initiator)
|
||||
require.NoError(err)
|
||||
init.KEMScheme = kem.KeyExchangeX25519Unsafe
|
||||
|
||||
_, _, err = RespondHandshake(cfg, responder, init)
|
||||
require.ErrorIs(err, ErrHandshakeClassicalKEM)
|
||||
}
|
||||
|
||||
// TestHandshake_StrictPQ_RefusesECDSAIdentity asserts the handshake
|
||||
// rejects an identity public key that isn't a valid ML-DSA-65 key. We
|
||||
// simulate this by replacing MLDSAPub with arbitrary bytes of the wrong
|
||||
// length (ECDSA-P256 sec1 width = 65 bytes; ML-DSA-65 pub = 1952 bytes).
|
||||
func TestHandshake_StrictPQ_RefusesECDSAIdentity(t *testing.T) {
|
||||
require := require.New(t)
|
||||
initiator, responder, chainID := newTestIdentities(t)
|
||||
|
||||
cfg := &HandshakeConfig{
|
||||
Profile: ProfileStrictPQ,
|
||||
ChainID: chainID,
|
||||
KEMScheme: kem.KeyExchangeMLKEM768,
|
||||
ForbidClassicalKEM: true,
|
||||
}
|
||||
|
||||
init, _, err := InitiateHandshake(cfg, initiator)
|
||||
require.NoError(err)
|
||||
|
||||
// Replace ML-DSA-65 public key with a 65-byte ECDSA-P256 sec1-uncompressed
|
||||
// representation. The handshake MUST refuse before any signature work.
|
||||
init.MLDSAPub = make([]byte, 65)
|
||||
init.MLDSAPub[0] = 0x04 // sec1 uncompressed prefix
|
||||
|
||||
_, _, err = RespondHandshake(cfg, responder, init)
|
||||
require.ErrorIs(err, ErrHandshakeBadIdentity)
|
||||
}
|
||||
|
||||
// TestHandshake_PreservesNodeIdentitySignature asserts that the identity
|
||||
// signature actually covers the transcript: flipping any byte in the
|
||||
// INIT or RESP after signing causes the verifier to reject.
|
||||
func TestHandshake_PreservesNodeIdentitySignature(t *testing.T) {
|
||||
require := require.New(t)
|
||||
initiator, responder, chainID := newTestIdentities(t)
|
||||
|
||||
cfg := &HandshakeConfig{
|
||||
Profile: ProfileStrictPQ,
|
||||
ChainID: chainID,
|
||||
KEMScheme: kem.KeyExchangeMLKEM768,
|
||||
ForbidClassicalKEM: true,
|
||||
}
|
||||
|
||||
// Tamper INIT after signing: flip one byte of the KEMPub.
|
||||
init, _, err := InitiateHandshake(cfg, initiator)
|
||||
require.NoError(err)
|
||||
require.NotEmpty(init.Sig)
|
||||
init.KEMPub[0] ^= 0x01
|
||||
|
||||
_, _, err = RespondHandshake(cfg, responder, init)
|
||||
require.ErrorIs(err, ErrHandshakeBadIdentity,
|
||||
"flipping a signed-over byte MUST cause initiator signature to fail")
|
||||
|
||||
// Tamper RESP after signing: re-run a clean handshake, mutate resp.
|
||||
init2, kemSec2, err := InitiateHandshake(cfg, initiator)
|
||||
require.NoError(err)
|
||||
resp, _, err := RespondHandshake(cfg, responder, init2)
|
||||
require.NoError(err)
|
||||
require.NotEmpty(resp.Sig)
|
||||
resp.KEMCiphertext[0] ^= 0x01
|
||||
|
||||
_, err = FinishInitiatorHandshake(cfg, initiator, init2, resp, kemSec2)
|
||||
require.ErrorIs(err, ErrHandshakeBadIdentity,
|
||||
"flipping a signed-over byte MUST cause responder signature to fail")
|
||||
}
|
||||
|
||||
// TestHandshake_RefusesProfileMismatch asserts two peers running
|
||||
// different security profiles cannot complete the handshake.
|
||||
func TestHandshake_RefusesProfileMismatch(t *testing.T) {
|
||||
require := require.New(t)
|
||||
initiator, responder, chainID := newTestIdentities(t)
|
||||
|
||||
initCfg := &HandshakeConfig{
|
||||
Profile: ProfileStrictPQ,
|
||||
ChainID: chainID,
|
||||
KEMScheme: kem.KeyExchangeMLKEM768,
|
||||
ForbidClassicalKEM: true,
|
||||
}
|
||||
respCfg := *initCfg
|
||||
respCfg.Profile = ProfileFIPS
|
||||
|
||||
init, _, err := InitiateHandshake(initCfg, initiator)
|
||||
require.NoError(err)
|
||||
|
||||
_, _, err = RespondHandshake(&respCfg, responder, init)
|
||||
require.ErrorIs(err, ErrHandshakeProfileMismatch)
|
||||
}
|
||||
|
||||
// TestHandshake_RefusesChainMismatch asserts two peers on different
|
||||
// chains cannot complete the handshake.
|
||||
func TestHandshake_RefusesChainMismatch(t *testing.T) {
|
||||
require := require.New(t)
|
||||
initiator, responder, chainID := newTestIdentities(t)
|
||||
|
||||
var otherChain [32]byte
|
||||
_, err := rand.Read(otherChain[:])
|
||||
require.NoError(err)
|
||||
|
||||
initCfg := &HandshakeConfig{
|
||||
Profile: ProfileStrictPQ,
|
||||
ChainID: chainID,
|
||||
KEMScheme: kem.KeyExchangeMLKEM768,
|
||||
ForbidClassicalKEM: true,
|
||||
}
|
||||
respCfg := *initCfg
|
||||
respCfg.ChainID = otherChain
|
||||
|
||||
init, _, err := InitiateHandshake(initCfg, initiator)
|
||||
require.NoError(err)
|
||||
|
||||
_, _, err = RespondHandshake(&respCfg, responder, init)
|
||||
require.ErrorIs(err, ErrHandshakeChainMismatch)
|
||||
}
|
||||
|
||||
// TestHandshake_RefusesResponderKEMDowngrade asserts the initiator
|
||||
// rejects a responder that echoes back a different KEM scheme (e.g.
|
||||
// downgrades ML-KEM-1024 to ML-KEM-768).
|
||||
func TestHandshake_RefusesResponderKEMDowngrade(t *testing.T) {
|
||||
require := require.New(t)
|
||||
initiator, responder, chainID := newTestIdentities(t)
|
||||
|
||||
cfg := &HandshakeConfig{
|
||||
Profile: ProfileStrictPQ,
|
||||
ChainID: chainID,
|
||||
KEMScheme: kem.KeyExchangeMLKEM1024,
|
||||
ForbidClassicalKEM: true,
|
||||
}
|
||||
|
||||
init, kemSec, err := InitiateHandshake(cfg, initiator)
|
||||
require.NoError(err)
|
||||
resp, _, err := RespondHandshake(cfg, responder, init)
|
||||
require.NoError(err)
|
||||
|
||||
// Tamper the scheme byte in the responder message; the initiator MUST
|
||||
// catch the downgrade before deriving any key material.
|
||||
resp.KEMScheme = kem.KeyExchangeMLKEM768
|
||||
|
||||
_, err = FinishInitiatorHandshake(cfg, initiator, init, resp, kemSec)
|
||||
require.ErrorIs(err, ErrHandshakeKEMScheme)
|
||||
}
|
||||
|
||||
// TestHandshake_DistinctSessionsDistinctKeys asserts two independent
|
||||
// handshakes between the same pair derive distinct AEAD keys (the KEM
|
||||
// randomness alone is enough to differentiate; the transcript binding
|
||||
// reinforces it).
|
||||
func TestHandshake_DistinctSessionsDistinctKeys(t *testing.T) {
|
||||
require := require.New(t)
|
||||
initiator, responder, chainID := newTestIdentities(t)
|
||||
|
||||
a1, _ := runHandshake(t, initiator, responder, chainID, kem.KeyExchangeMLKEM768)
|
||||
a2, _ := runHandshake(t, initiator, responder, chainID, kem.KeyExchangeMLKEM768)
|
||||
require.NotEqual(a1.AEADKey, a2.AEADKey,
|
||||
"independent sessions MUST yield distinct AEAD keys")
|
||||
}
|
||||
|
||||
// TestHandshake_NodeIDZeroRefused asserts the handshake rejects a peer
|
||||
// presenting the zero NodeID.
|
||||
func TestHandshake_NodeIDZeroRefused(t *testing.T) {
|
||||
require := require.New(t)
|
||||
initiator, responder, chainID := newTestIdentities(t)
|
||||
|
||||
cfg := &HandshakeConfig{
|
||||
Profile: ProfileStrictPQ,
|
||||
ChainID: chainID,
|
||||
KEMScheme: kem.KeyExchangeMLKEM768,
|
||||
ForbidClassicalKEM: true,
|
||||
}
|
||||
|
||||
init, _, err := InitiateHandshake(cfg, initiator)
|
||||
require.NoError(err)
|
||||
init.NodeID = ids.EmptyNodeID
|
||||
|
||||
_, _, err = RespondHandshake(cfg, responder, init)
|
||||
require.ErrorIs(err, ErrHandshakeNodeIDZero)
|
||||
}
|
||||
+145
-11
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package peer
|
||||
@@ -12,9 +12,10 @@ import (
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/codec/wrappers"
|
||||
"github.com/luxfi/crypto/bls"
|
||||
"github.com/luxfi/crypto/hash"
|
||||
"github.com/luxfi/codec/wrappers"
|
||||
"github.com/luxfi/crypto/mldsa"
|
||||
"github.com/luxfi/node/staking"
|
||||
)
|
||||
|
||||
@@ -22,8 +23,29 @@ var (
|
||||
errTimestampTooFarInFuture = errors.New("timestamp too far in the future")
|
||||
errTimestampTooFarInPast = errors.New("timestamp too far in the past")
|
||||
errInvalidTLSSignature = errors.New("invalid TLS signature")
|
||||
// errMissingMLDSASignature is returned by Verify when strict-PQ profile
|
||||
// is in force but the SignedIP carries no ML-DSA leg. Strict-PQ chains
|
||||
// refuse classical-only IP gossip because both TLS and BLS12-381 are
|
||||
// quantum-broken.
|
||||
errMissingMLDSASignature = errors.New("missing ML-DSA-65 signature under strict-PQ profile")
|
||||
// errMissingMLDSAPublicKey is returned by Verify when an ML-DSA signature
|
||||
// is present on the wire but the caller did not supply the validator's
|
||||
// ML-DSA public key needed to verify it. Configuration bug — the caller
|
||||
// must pass the pubkey when verification proceeds under strict-PQ.
|
||||
errMissingMLDSAPublicKey = errors.New("missing ML-DSA-65 public key for verification")
|
||||
// errInvalidMLDSASignature is returned by Verify when the ML-DSA-65
|
||||
// signature over the canonical UnsignedIP bytes fails to verify under
|
||||
// the supplied public key.
|
||||
errInvalidMLDSASignature = errors.New("invalid ML-DSA-65 signature")
|
||||
)
|
||||
|
||||
// signedIPMLDSAContext is the FIPS 204 context string bound into every
|
||||
// ML-DSA-65 signature over an UnsignedIP. Pinning a context here makes a
|
||||
// captured IP-gossip signature un-replayable in any other Lux signing
|
||||
// surface (transaction auth, UTXO Fx, peer handshake) even though they all
|
||||
// share the same long-term ML-DSA-65 identity key.
|
||||
var signedIPMLDSAContext = []byte("lux-peer-signed-ip-v1")
|
||||
|
||||
// UnsignedIP is used for a validator to claim an IP. The [Timestamp] is used to
|
||||
// ensure that the most updated IP claim is tracked by peers for a given
|
||||
// validator.
|
||||
@@ -32,8 +54,34 @@ type UnsignedIP struct {
|
||||
Timestamp uint64
|
||||
}
|
||||
|
||||
// Sign this IP with the provided signer and return the signed IP.
|
||||
// Sign produces a classical-only SignedIP carrying TLS + BLS legs. Retained
|
||||
// for backwards compatibility with permissive (non-strict-PQ) chains and
|
||||
// with the existing test surface. Strict-PQ producers MUST call SignPQ.
|
||||
func (ip *UnsignedIP) Sign(tlsSigner crypto.Signer, blsSigner bls.Signer) (*SignedIP, error) {
|
||||
return ip.SignPQ(tlsSigner, blsSigner, nil)
|
||||
}
|
||||
|
||||
// SignPQ produces a SignedIP carrying every leg the caller can mint:
|
||||
//
|
||||
// - TLS leg (classical, RSA/ECDSA staker cert) — always required.
|
||||
// - BLS12-381 leg (classical, proof-of-possession) — always required.
|
||||
// - ML-DSA-65 leg (FIPS 204 post-quantum) — minted when mldsaSigner is
|
||||
// non-nil.
|
||||
//
|
||||
// Both classical legs are quantum-broken; the ML-DSA leg is what gives a
|
||||
// strict-PQ chain a witness over the IP gossip that survives a CRQC. The
|
||||
// ML-DSA leg signs the same canonical UnsignedIP bytes the classical legs
|
||||
// sign, bound under the "lux-peer-signed-ip-v1" FIPS 204 context so the
|
||||
// signature cannot be replayed into another Lux signing surface.
|
||||
//
|
||||
// Backwards compat: passing mldsaSigner == nil produces a SignedIP with
|
||||
// an empty MLDSASignature. Permissive chains accept this; strict-PQ chains
|
||||
// refuse it at Verify time.
|
||||
func (ip *UnsignedIP) SignPQ(
|
||||
tlsSigner crypto.Signer,
|
||||
blsSigner bls.Signer,
|
||||
mldsaSigner *mldsa.PrivateKey,
|
||||
) (*SignedIP, error) {
|
||||
ipBytes := ip.bytes()
|
||||
tlsSignature, err := tlsSigner.Sign(
|
||||
rand.Reader,
|
||||
@@ -49,11 +97,20 @@ func (ip *UnsignedIP) Sign(tlsSigner crypto.Signer, blsSigner bls.Signer) (*Sign
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var mldsaSignature []byte
|
||||
if mldsaSigner != nil {
|
||||
mldsaSignature, err = mldsaSigner.SignCtx(rand.Reader, ipBytes, signedIPMLDSAContext)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ml-dsa-65 sign: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &SignedIP{
|
||||
UnsignedIP: *ip,
|
||||
TLSSignature: tlsSignature,
|
||||
BLSSignature: blsSignature,
|
||||
BLSSignatureBytes: bls.SignatureToBytes(blsSignature),
|
||||
MLDSASignature: mldsaSignature,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -69,24 +126,75 @@ func (ip *UnsignedIP) bytes() []byte {
|
||||
}
|
||||
|
||||
// SignedIP is a wrapper of an UnsignedIP with the signature from a signer.
|
||||
//
|
||||
// Wire-format note: the ML-DSA leg is append-only on the gossip wire.
|
||||
// Legacy peers that never set MLDSASignature send an empty bytes field;
|
||||
// the receiver-side Verify treats absence as "classical-only" and refuses
|
||||
// it only when a strict-PQ profile is in force (see VerifyUnderProfile).
|
||||
// This preserves backwards compatibility with non-strict-PQ chains
|
||||
// (testnet, devnet) while enabling strict-PQ chains to refuse the
|
||||
// quantum-broken-only path.
|
||||
type SignedIP struct {
|
||||
UnsignedIP
|
||||
TLSSignature []byte
|
||||
BLSSignature *bls.Signature
|
||||
BLSSignatureBytes []byte
|
||||
// MLDSASignature is the FIPS 204 ML-DSA-65 signature over the
|
||||
// canonical UnsignedIP bytes under the "lux-peer-signed-ip-v1"
|
||||
// context. Empty on classical-only SignedIPs produced by legacy
|
||||
// peers; required under strict-PQ profile.
|
||||
MLDSASignature []byte
|
||||
}
|
||||
|
||||
// Returns nil if:
|
||||
// * [ip.Timestamp] is within the allowed clock skew range (not too far in past or future).
|
||||
// * [ip.TLSSignature] is a valid signature over [ip.UnsignedIP] from [cert].
|
||||
// Verify checks the classical (TLS) leg only. Retained for backwards
|
||||
// compatibility with callers that do not have access to the chain's
|
||||
// security profile or the validator's ML-DSA-65 public key. Strict-PQ
|
||||
// callers MUST go through VerifyUnderProfile so the ML-DSA leg gets
|
||||
// checked.
|
||||
//
|
||||
// [maxTimestamp] defines the maximum allowed timestamp (current time + MaxClockDifference).
|
||||
// The minimum allowed timestamp is inferred as (maxTimestamp - ReasonableClockSkewWindow) to
|
||||
// prevent replay attacks. We use a conservative 10-minute window to account for various
|
||||
// MaxClockDifference configurations while still protecting against replay attacks.
|
||||
// Returns nil if:
|
||||
// - [ip.Timestamp] is within the allowed clock skew range (not too far
|
||||
// in past or future).
|
||||
// - [ip.TLSSignature] is a valid signature over [ip.UnsignedIP] from
|
||||
// [cert].
|
||||
//
|
||||
// [maxTimestamp] defines the maximum allowed timestamp (current time +
|
||||
// MaxClockDifference). The minimum allowed timestamp is inferred as
|
||||
// (maxTimestamp - reasonableClockSkewWindow) to prevent replay attacks.
|
||||
// We use a conservative 10-minute window to account for various
|
||||
// MaxClockDifference configurations while still protecting against
|
||||
// replay attacks.
|
||||
func (ip *SignedIP) Verify(
|
||||
cert *staking.Certificate,
|
||||
maxTimestamp time.Time,
|
||||
) error {
|
||||
return ip.VerifyUnderProfile(cert, nil, maxTimestamp, false)
|
||||
}
|
||||
|
||||
// VerifyUnderProfile is the profile-aware verifier. It always checks the
|
||||
// TLS leg (classical), and additionally:
|
||||
//
|
||||
// - When strictPQ == true, requires a non-empty MLDSASignature and
|
||||
// verifies it under mldsaPubKey. mldsaPubKey MUST be supplied; passing
|
||||
// a nil pubkey under strict-PQ is a configuration error (returns
|
||||
// errMissingMLDSAPublicKey). The strict-PQ profile is what every
|
||||
// Lux-derived strict-PQ chain pins (SigSchemeMLDSA65); permissive
|
||||
// profiles (testnet, devnet) call this with strictPQ == false.
|
||||
//
|
||||
// - When strictPQ == false but MLDSASignature is present AND mldsaPubKey
|
||||
// is non-nil, the ML-DSA leg is still verified (defence in depth —
|
||||
// the wire bytes are already there, refusing to validate them would
|
||||
// waste evidence). Absence under permissive is tolerated.
|
||||
//
|
||||
// The BLS leg is verified by the caller separately (peer.shouldDisconnect
|
||||
// runs BLS validation against the validator's BLS public key tracked in
|
||||
// the platformvm validator set, not against the staker cert; that path
|
||||
// is unchanged).
|
||||
func (ip *SignedIP) VerifyUnderProfile(
|
||||
cert *staking.Certificate,
|
||||
mldsaPubKey *mldsa.PublicKey,
|
||||
maxTimestamp time.Time,
|
||||
strictPQ bool,
|
||||
) error {
|
||||
maxUnixTimestamp := uint64(maxTimestamp.Unix())
|
||||
if ip.Timestamp > maxUnixTimestamp {
|
||||
@@ -111,12 +219,38 @@ func (ip *SignedIP) Verify(
|
||||
return fmt.Errorf("%w: timestamp %d < minTimestamp %d", errTimestampTooFarInPast, ip.Timestamp, minUnixTimestamp)
|
||||
}
|
||||
|
||||
ipBytes := ip.UnsignedIP.bytes()
|
||||
if err := staking.CheckSignature(
|
||||
cert,
|
||||
ip.UnsignedIP.bytes(),
|
||||
ipBytes,
|
||||
ip.TLSSignature,
|
||||
); err != nil {
|
||||
return fmt.Errorf("%w: %w", errInvalidTLSSignature, err)
|
||||
}
|
||||
|
||||
// ML-DSA leg. Three cases:
|
||||
//
|
||||
// strictPQ && empty sig → refuse (errMissingMLDSASignature)
|
||||
// strictPQ && nil pubkey → refuse (errMissingMLDSAPublicKey)
|
||||
// sig present && pubkey present → verify; refuse on failure
|
||||
// !strictPQ && empty sig → tolerate (classical-only OK)
|
||||
//
|
||||
// We never refuse a permissive chain that supplies a pubkey but the
|
||||
// peer chose not to sign with ML-DSA — that combination is normal on
|
||||
// a mixed network and the strictPQ flag is what enforces the
|
||||
// "must have a sig" policy.
|
||||
if strictPQ {
|
||||
if len(ip.MLDSASignature) == 0 {
|
||||
return errMissingMLDSASignature
|
||||
}
|
||||
if mldsaPubKey == nil {
|
||||
return errMissingMLDSAPublicKey
|
||||
}
|
||||
}
|
||||
if len(ip.MLDSASignature) > 0 && mldsaPubKey != nil {
|
||||
if !mldsaPubKey.VerifySignatureCtx(ipBytes, ip.MLDSASignature, signedIPMLDSAContext) {
|
||||
return errInvalidMLDSASignature
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package peer
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/crypto/bls/signer/localsigner"
|
||||
"github.com/luxfi/crypto/mldsa"
|
||||
"github.com/luxfi/node/staking"
|
||||
)
|
||||
|
||||
// TestSignedIP_RoundTripsPQ verifies that a SignedIP minted with the
|
||||
// ML-DSA-65 leg round-trips correctly: TLS leg verifies, ML-DSA leg
|
||||
// verifies, and the strict-PQ profile gate is satisfied.
|
||||
func TestSignedIP_RoundTripsPQ(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
tlsCert, err := staking.NewTLSCert()
|
||||
require.NoError(err)
|
||||
cert, err := staking.ParseCertificate(tlsCert.Leaf.Raw)
|
||||
require.NoError(err)
|
||||
tlsKey := tlsCert.PrivateKey.(crypto.Signer)
|
||||
blsKey, err := localsigner.New()
|
||||
require.NoError(err)
|
||||
|
||||
mldsaPriv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
|
||||
require.NoError(err)
|
||||
mldsaPub := mldsaPriv.PublicKey
|
||||
|
||||
now := time.Now()
|
||||
unsigned := UnsignedIP{
|
||||
AddrPort: netip.MustParseAddrPort("1.2.3.4:9650"),
|
||||
Timestamp: uint64(now.Unix()),
|
||||
}
|
||||
|
||||
signed, err := unsigned.SignPQ(tlsKey, blsKey, mldsaPriv)
|
||||
require.NoError(err)
|
||||
require.NotEmpty(signed.TLSSignature)
|
||||
require.NotEmpty(signed.BLSSignatureBytes)
|
||||
require.NotEmpty(signed.MLDSASignature, "ML-DSA leg must be present after SignPQ with a real signer")
|
||||
|
||||
// Classical-only verifier accepts the cert + TLS sig.
|
||||
require.NoError(signed.Verify(cert, now))
|
||||
|
||||
// Strict-PQ verifier accepts when both legs are present and the
|
||||
// ML-DSA public key matches.
|
||||
require.NoError(signed.VerifyUnderProfile(cert, mldsaPub, now, true))
|
||||
|
||||
// Tampering with the ML-DSA signature byte flips the gate.
|
||||
tampered := *signed
|
||||
tampered.MLDSASignature = append([]byte(nil), signed.MLDSASignature...)
|
||||
tampered.MLDSASignature[0] ^= 0xFF
|
||||
require.ErrorIs(tampered.VerifyUnderProfile(cert, mldsaPub, now, true), errInvalidMLDSASignature)
|
||||
|
||||
// A different ML-DSA public key fails verification.
|
||||
otherPriv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
|
||||
require.NoError(err)
|
||||
require.ErrorIs(
|
||||
signed.VerifyUnderProfile(cert, otherPriv.PublicKey, now, true),
|
||||
errInvalidMLDSASignature,
|
||||
)
|
||||
}
|
||||
|
||||
// TestSignedIP_RefusesMissingMLDSAUnderStrictPQ verifies that a classical-only
|
||||
// SignedIP (TLS + BLS, no ML-DSA leg) is refused by the strict-PQ verifier.
|
||||
// This is the property the task asks us to enforce: both classical legs
|
||||
// (TLS, BLS12-381) are quantum-broken, so the strict-PQ chain refuses any
|
||||
// gossip that lacks the post-quantum witness.
|
||||
func TestSignedIP_RefusesMissingMLDSAUnderStrictPQ(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
tlsCert, err := staking.NewTLSCert()
|
||||
require.NoError(err)
|
||||
cert, err := staking.ParseCertificate(tlsCert.Leaf.Raw)
|
||||
require.NoError(err)
|
||||
tlsKey := tlsCert.PrivateKey.(crypto.Signer)
|
||||
blsKey, err := localsigner.New()
|
||||
require.NoError(err)
|
||||
|
||||
mldsaPriv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
|
||||
require.NoError(err)
|
||||
mldsaPub := mldsaPriv.PublicKey
|
||||
|
||||
now := time.Now()
|
||||
unsigned := UnsignedIP{
|
||||
AddrPort: netip.MustParseAddrPort("1.2.3.4:9650"),
|
||||
Timestamp: uint64(now.Unix()),
|
||||
}
|
||||
|
||||
// Classical-only sign — no ML-DSA leg.
|
||||
classicalOnly, err := unsigned.Sign(tlsKey, blsKey)
|
||||
require.NoError(err)
|
||||
require.Empty(classicalOnly.MLDSASignature)
|
||||
|
||||
// Strict-PQ verifier refuses the classical-only sig.
|
||||
err = classicalOnly.VerifyUnderProfile(cert, mldsaPub, now, true)
|
||||
require.ErrorIs(err, errMissingMLDSASignature)
|
||||
|
||||
// And refuses again when the caller forgets to supply the pubkey
|
||||
// (different failure mode, same outcome — refusal).
|
||||
signedWithLeg, err := unsigned.SignPQ(tlsKey, blsKey, mldsaPriv)
|
||||
require.NoError(err)
|
||||
require.ErrorIs(
|
||||
signedWithLeg.VerifyUnderProfile(cert, nil, now, true),
|
||||
errMissingMLDSAPublicKey,
|
||||
)
|
||||
}
|
||||
|
||||
// TestSignedIP_AcceptsClassicalOnlyUnderPermissive verifies backwards
|
||||
// compatibility: a non-strict-PQ chain (testnet / devnet / permissive
|
||||
// profile) still accepts a SignedIP that carries only the classical TLS
|
||||
// + BLS legs and no ML-DSA leg. This is what keeps a freshly-built strict-
|
||||
// PQ binary from breaking peer-handshake with the existing testnet fleet.
|
||||
func TestSignedIP_AcceptsClassicalOnlyUnderPermissive(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
tlsCert, err := staking.NewTLSCert()
|
||||
require.NoError(err)
|
||||
cert, err := staking.ParseCertificate(tlsCert.Leaf.Raw)
|
||||
require.NoError(err)
|
||||
tlsKey := tlsCert.PrivateKey.(crypto.Signer)
|
||||
blsKey, err := localsigner.New()
|
||||
require.NoError(err)
|
||||
|
||||
now := time.Now()
|
||||
unsigned := UnsignedIP{
|
||||
AddrPort: netip.MustParseAddrPort("1.2.3.4:9650"),
|
||||
Timestamp: uint64(now.Unix()),
|
||||
}
|
||||
|
||||
// Classical-only signer (no ML-DSA key wired) — mirrors the
|
||||
// permissive testnet binary that has not yet rolled the PQ identity
|
||||
// key onto its validator nodes.
|
||||
classicalOnly, err := unsigned.Sign(tlsKey, blsKey)
|
||||
require.NoError(err)
|
||||
require.Empty(classicalOnly.MLDSASignature)
|
||||
|
||||
// Permissive verifier (strictPQ == false, nil pubkey) accepts.
|
||||
require.NoError(classicalOnly.VerifyUnderProfile(cert, nil, now, false))
|
||||
|
||||
// And the legacy Verify path keeps working (peers that bypass the
|
||||
// profile-aware verifier — e.g. test rigs).
|
||||
require.NoError(classicalOnly.Verify(cert, now))
|
||||
|
||||
// Defence in depth: when a permissive verifier happens to have a
|
||||
// pubkey on hand AND the peer supplied a sig, the leg is still
|
||||
// validated. We synthesise a valid PQ sig to confirm.
|
||||
mldsaPriv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
|
||||
require.NoError(err)
|
||||
withPQ, err := unsigned.SignPQ(tlsKey, blsKey, mldsaPriv)
|
||||
require.NoError(err)
|
||||
require.NoError(withPQ.VerifyUnderProfile(cert, mldsaPriv.PublicKey, now, false))
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package peer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
zappb "github.com/luxfi/proto/node/zap/p2p"
|
||||
)
|
||||
|
||||
// TestHandshakeWire_LegacyFrameOmitsMLDSA verifies the wire-compat
|
||||
// guarantee for the append-only IpMldsaSig field. A handshake frame
|
||||
// emitted by a legacy peer (no IpMldsaSig at all) still round-trips
|
||||
// through the new marshal/unmarshal pair, with IpMldsaSig reading as
|
||||
// nil/empty.
|
||||
func TestHandshakeWire_LegacyFrameOmitsMLDSA(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
// Build a handshake without ML-DSA.
|
||||
hs := &zappb.Handshake{
|
||||
NetworkId: 1337,
|
||||
MyTime: 12345,
|
||||
IpAddr: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4},
|
||||
IpPort: 9650,
|
||||
IpSigningTime: 12345,
|
||||
IpNodeIdSig: []byte("legacy-tls-sig"),
|
||||
TrackedNets: [][]byte{},
|
||||
Client: &zappb.Client{
|
||||
Name: "lux",
|
||||
Major: 1,
|
||||
Minor: 26,
|
||||
Patch: 12,
|
||||
},
|
||||
SupportedLps: []uint32{},
|
||||
ObjectedLps: []uint32{},
|
||||
KnownPeers: &zappb.BloomFilter{
|
||||
Filter: []byte{0x01, 0x02},
|
||||
Salt: []byte{0x03, 0x04},
|
||||
},
|
||||
IpBlsSig: []byte("legacy-bls-sig"),
|
||||
AllChains: false,
|
||||
IpMldsaSig: nil,
|
||||
}
|
||||
|
||||
// Wrap in a Message and round-trip via the codec.
|
||||
msg := &zappb.Message{Message: &zappb.Message_Handshake{Handshake: hs}}
|
||||
raw, err := zappb.Marshal(msg)
|
||||
require.NoError(err)
|
||||
var decoded zappb.Message
|
||||
require.NoError(zappb.Unmarshal(raw, &decoded))
|
||||
|
||||
roundTripped, ok := decoded.Message.(*zappb.Message_Handshake)
|
||||
require.True(ok, "decoded message must be a Handshake")
|
||||
require.Empty(roundTripped.Handshake.IpMldsaSig,
|
||||
"empty ML-DSA on the wire decodes to empty on the receiver")
|
||||
require.Equal(hs.IpNodeIdSig, roundTripped.Handshake.IpNodeIdSig)
|
||||
require.Equal(hs.IpBlsSig, roundTripped.Handshake.IpBlsSig)
|
||||
}
|
||||
|
||||
// TestHandshakeWire_TruncatedLegacyFrameDecodes mimics a true legacy
|
||||
// wire payload that ends after IpBlsSig with no trailing IpMldsaSig
|
||||
// length-prefix. The new unmarshaller MUST tolerate this — that's the
|
||||
// whole point of the HasMore() guard around the new field read. Without
|
||||
// the guard, decoding would fail with io.ErrUnexpectedEOF and the peer
|
||||
// handshake would refuse every legacy peer on the network.
|
||||
func TestHandshakeWire_TruncatedLegacyFrameDecodes(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
// First produce a new-format frame, then strip the trailing 4-byte
|
||||
// length prefix (the empty-bytes encoding of IpMldsaSig). The result
|
||||
// is byte-for-byte what a pre-PQ binary would have produced.
|
||||
hs := &zappb.Handshake{
|
||||
NetworkId: 1337,
|
||||
MyTime: 7777,
|
||||
IpAddr: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4},
|
||||
IpPort: 9650,
|
||||
IpSigningTime: 7777,
|
||||
IpNodeIdSig: []byte("legacy-tls"),
|
||||
TrackedNets: [][]byte{},
|
||||
Client: &zappb.Client{Name: "lux", Major: 1, Minor: 0, Patch: 0},
|
||||
SupportedLps: []uint32{},
|
||||
ObjectedLps: []uint32{},
|
||||
KnownPeers: &zappb.BloomFilter{Filter: []byte{}, Salt: []byte{}},
|
||||
IpBlsSig: []byte("legacy-bls"),
|
||||
AllChains: false,
|
||||
IpMldsaSig: nil,
|
||||
}
|
||||
msg := &zappb.Message{Message: &zappb.Message_Handshake{Handshake: hs}}
|
||||
raw, err := zappb.Marshal(msg)
|
||||
require.NoError(err)
|
||||
// The last 4 bytes of the inner Handshake encoding are the empty
|
||||
// IpMldsaSig length-prefix (0x00000000). Truncating them simulates
|
||||
// a legacy peer that never wrote the field.
|
||||
require.GreaterOrEqual(len(raw), 4, "marshalled frame must be larger than the trailing field")
|
||||
truncated := raw[:len(raw)-4]
|
||||
|
||||
var decoded zappb.Message
|
||||
require.NoError(zappb.Unmarshal(truncated, &decoded),
|
||||
"truncated legacy frame must decode without error")
|
||||
roundTripped, ok := decoded.Message.(*zappb.Message_Handshake)
|
||||
require.True(ok)
|
||||
require.Empty(roundTripped.Handshake.IpMldsaSig,
|
||||
"absent field reads as empty")
|
||||
require.Equal(hs.IpBlsSig, roundTripped.Handshake.IpBlsSig)
|
||||
}
|
||||
|
||||
// TestHandshakeWire_NewFrameRoundTripsMLDSA verifies that a new-format
|
||||
// frame (with a non-empty IpMldsaSig) round-trips intact.
|
||||
func TestHandshakeWire_NewFrameRoundTripsMLDSA(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
mldsaSig := make([]byte, 3309) // ML-DSA-65 signature size
|
||||
for i := range mldsaSig {
|
||||
mldsaSig[i] = byte(i)
|
||||
}
|
||||
|
||||
hs := &zappb.Handshake{
|
||||
NetworkId: 1,
|
||||
MyTime: 99999,
|
||||
IpAddr: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 0, 0, 1},
|
||||
IpPort: 9630,
|
||||
IpSigningTime: 99999,
|
||||
IpNodeIdSig: []byte("strict-pq-tls-sig"),
|
||||
TrackedNets: [][]byte{},
|
||||
Client: &zappb.Client{
|
||||
Name: "lux",
|
||||
Major: 1,
|
||||
Minor: 26,
|
||||
Patch: 13,
|
||||
},
|
||||
SupportedLps: []uint32{},
|
||||
ObjectedLps: []uint32{},
|
||||
KnownPeers: &zappb.BloomFilter{Filter: []byte{}, Salt: []byte{}},
|
||||
IpBlsSig: []byte("strict-pq-bls-sig"),
|
||||
AllChains: true,
|
||||
IpMldsaSig: mldsaSig,
|
||||
}
|
||||
|
||||
msg := &zappb.Message{Message: &zappb.Message_Handshake{Handshake: hs}}
|
||||
raw, err := zappb.Marshal(msg)
|
||||
require.NoError(err)
|
||||
var decoded zappb.Message
|
||||
require.NoError(zappb.Unmarshal(raw, &decoded))
|
||||
|
||||
roundTripped, ok := decoded.Message.(*zappb.Message_Handshake)
|
||||
require.True(ok, "decoded message must be a Handshake")
|
||||
require.Equal(mldsaSig, roundTripped.Handshake.IpMldsaSig,
|
||||
"ML-DSA sig must round-trip byte-for-byte")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package peer
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/crypto/bls"
|
||||
"github.com/luxfi/crypto/mldsa"
|
||||
"github.com/luxfi/timer/mockable"
|
||||
"github.com/luxfi/utils"
|
||||
)
|
||||
@@ -22,11 +23,19 @@ const (
|
||||
)
|
||||
|
||||
// IPSigner will return a signedIP for the current value of our dynamic IP.
|
||||
//
|
||||
// Carries up to three signing legs: TLS (classical, staker cert),
|
||||
// BLS12-381 (classical, proof-of-possession), and ML-DSA-65 (FIPS 204
|
||||
// post-quantum). The ML-DSA leg is optional at construction time —
|
||||
// passing mldsaSigner == nil to NewIPSigner produces classical-only
|
||||
// gossip that strict-PQ verifiers refuse. Strict-PQ chains MUST be wired
|
||||
// with a real ML-DSA-65 private key.
|
||||
type IPSigner struct {
|
||||
ip *utils.Atomic[netip.AddrPort]
|
||||
clock mockable.Clock
|
||||
tlsSigner crypto.Signer
|
||||
blsSigner bls.Signer
|
||||
ip *utils.Atomic[netip.AddrPort]
|
||||
clock mockable.Clock
|
||||
tlsSigner crypto.Signer
|
||||
blsSigner bls.Signer
|
||||
mldsaSigner *mldsa.PrivateKey
|
||||
|
||||
// Must be held while accessing [signedIP]
|
||||
signedIPLock sync.RWMutex
|
||||
@@ -35,15 +44,32 @@ type IPSigner struct {
|
||||
signedIP *SignedIP
|
||||
}
|
||||
|
||||
// NewIPSigner constructs a classical-only IPSigner (TLS + BLS legs). Use
|
||||
// NewIPSignerPQ when the node has an ML-DSA-65 identity key and the chain
|
||||
// runs under a strict-PQ profile.
|
||||
func NewIPSigner(
|
||||
ip *utils.Atomic[netip.AddrPort],
|
||||
tlsSigner crypto.Signer,
|
||||
blsSigner bls.Signer,
|
||||
) *IPSigner {
|
||||
return NewIPSignerPQ(ip, tlsSigner, blsSigner, nil)
|
||||
}
|
||||
|
||||
// NewIPSignerPQ constructs an IPSigner that signs every refresh with all
|
||||
// three legs the caller supplies. mldsaSigner may be nil to keep the
|
||||
// classical-only behaviour (for permissive chains or for tests that have
|
||||
// not yet generated a long-term ML-DSA key).
|
||||
func NewIPSignerPQ(
|
||||
ip *utils.Atomic[netip.AddrPort],
|
||||
tlsSigner crypto.Signer,
|
||||
blsSigner bls.Signer,
|
||||
mldsaSigner *mldsa.PrivateKey,
|
||||
) *IPSigner {
|
||||
return &IPSigner{
|
||||
ip: ip,
|
||||
tlsSigner: tlsSigner,
|
||||
blsSigner: blsSigner,
|
||||
ip: ip,
|
||||
tlsSigner: tlsSigner,
|
||||
blsSigner: blsSigner,
|
||||
mldsaSigner: mldsaSigner,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +108,7 @@ func (s *IPSigner) GetSignedIP() (*SignedIP, error) {
|
||||
AddrPort: ip,
|
||||
Timestamp: s.clock.Unix(),
|
||||
}
|
||||
signedIP, err := unsignedIP.Sign(s.tlsSigner, s.blsSigner)
|
||||
signedIP, err := unsignedIP.SignPQ(s.tlsSigner, s.blsSigner, s.mldsaSigner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+108
-1
@@ -7,6 +7,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
@@ -198,12 +199,27 @@ type peer struct {
|
||||
// isIngress is true only if the remote peer is connected to this node,
|
||||
// in contrast of this node being connected to the remote peer.
|
||||
isIngress bool
|
||||
|
||||
// pqAEADKey is the 32-byte AEAD session key derived by the strict-PQ
|
||||
// application-layer handshake (ML-KEM + ML-DSA-65). Zero-valued on
|
||||
// chains that don't run the PQ handshake; non-zero only after
|
||||
// runPQHandshakeIfRequired completes successfully. The AEAD wrapper
|
||||
// over p.conn reads this key once at handshake completion; it is
|
||||
// never mutated after Start returns.
|
||||
pqAEADKey [32]byte
|
||||
}
|
||||
|
||||
// Start a new peer instance.
|
||||
//
|
||||
// Invariant: There must only be one peer running at a time with a reference to
|
||||
// the same [config.InboundMsgThrottler].
|
||||
//
|
||||
// On strict-PQ chains (config.PQHandshakeConfig non-nil) Start runs the
|
||||
// application-layer ML-KEM + ML-DSA-65 handshake synchronously on the wire
|
||||
// before launching the message-pump goroutines. The role is derived from
|
||||
// isIngress — the dial side is the initiator, the accept side is the
|
||||
// responder. If the PQ handshake fails the connection is closed and the
|
||||
// returned Peer is in the closed state; no fallback to bare TLS.
|
||||
func Start(
|
||||
config *Config,
|
||||
conn net.Conn,
|
||||
@@ -233,6 +249,23 @@ func Start(
|
||||
p.IngressConnectionCount.Add(1)
|
||||
}
|
||||
|
||||
// PQ handshake gate: strict-PQ chains run the application-layer
|
||||
// ML-KEM + ML-DSA-65 handshake on the wire BEFORE any p2p message
|
||||
// is exchanged. Permissive / classical-compat chains skip this step.
|
||||
if err := p.runPQHandshakeIfRequired(); err != nil {
|
||||
p.Log.Warn("PQ handshake refused; closing connection",
|
||||
log.Stringer("nodeID", p.id),
|
||||
log.Bool("isIngress", isIngress),
|
||||
log.Reflect("error", err),
|
||||
)
|
||||
// Mark all 3 goroutines as not running so close() doesn't wait
|
||||
// for them. The connection is torn down; the peer is closed.
|
||||
atomic.StoreInt64(&p.numExecuting, 1)
|
||||
_ = p.conn.Close()
|
||||
p.close()
|
||||
return p
|
||||
}
|
||||
|
||||
go p.readMessages()
|
||||
go p.writeMessages()
|
||||
go p.sendNetworkMessages()
|
||||
@@ -240,6 +273,74 @@ func Start(
|
||||
return p
|
||||
}
|
||||
|
||||
// runPQHandshakeIfRequired runs the strict-PQ application-layer handshake
|
||||
// synchronously over p.conn. Returns nil when the chain is on the legacy
|
||||
// bare-TLS path (no PQHandshakeConfig pinned) so the caller proceeds
|
||||
// unchanged. On strict-PQ, completes INIT / RESP / FINISH and stashes the
|
||||
// derived AEAD key on the peer; subsequent application messages are
|
||||
// authenticated against this key by the AEAD wrapper installed by the
|
||||
// next CR. Wire framing mirrors the peer's existing 4-byte length-prefix
|
||||
// scheme so the framing logic is identical on both sides of the wire.
|
||||
func (p *peer) runPQHandshakeIfRequired() error {
|
||||
cfg := p.Config
|
||||
if cfg == nil || cfg.PQHandshakeConfig == nil || cfg.PQLocalIdentity == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(cfg.MaxClockDifference + 30*time.Second)
|
||||
if err := p.conn.SetDeadline(deadline); err != nil {
|
||||
return fmt.Errorf("set PQ handshake deadline: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
// Clear deadline so the rest of the peer flow uses its own.
|
||||
_ = p.conn.SetDeadline(time.Time{})
|
||||
}()
|
||||
|
||||
if p.isIngress {
|
||||
// Responder: read INIT, send RESP, derive AEAD.
|
||||
initBytes, err := readPQFrame(p.conn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read PQ INIT: %w", err)
|
||||
}
|
||||
init, err := parsePQHandshakeInit(initBytes, cfg.PQHandshakeConfig.KEMScheme)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse PQ INIT: %w", err)
|
||||
}
|
||||
resp, result, err := RespondHandshake(cfg.PQHandshakeConfig, cfg.PQLocalIdentity, init)
|
||||
if err != nil {
|
||||
return fmt.Errorf("respond PQ handshake: %w", err)
|
||||
}
|
||||
if err := writePQFrame(p.conn, resp.canonicalBytes()); err != nil {
|
||||
return fmt.Errorf("write PQ RESP: %w", err)
|
||||
}
|
||||
p.pqAEADKey = result.AEADKey
|
||||
return nil
|
||||
}
|
||||
|
||||
// Initiator: send INIT, read RESP, finalize.
|
||||
init, kemSec, err := InitiateHandshake(cfg.PQHandshakeConfig, cfg.PQLocalIdentity)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initiate PQ handshake: %w", err)
|
||||
}
|
||||
if err := writePQFrame(p.conn, init.canonicalBytes()); err != nil {
|
||||
return fmt.Errorf("write PQ INIT: %w", err)
|
||||
}
|
||||
respBytes, err := readPQFrame(p.conn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read PQ RESP: %w", err)
|
||||
}
|
||||
resp, err := parsePQHandshakeResp(respBytes, cfg.PQHandshakeConfig.KEMScheme)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse PQ RESP: %w", err)
|
||||
}
|
||||
result, err := FinishInitiatorHandshake(cfg.PQHandshakeConfig, cfg.PQLocalIdentity, init, resp, kemSec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finish PQ handshake: %w", err)
|
||||
}
|
||||
p.pqAEADKey = result.AEADKey
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *peer) ID() ids.NodeID {
|
||||
return p.id
|
||||
}
|
||||
@@ -552,6 +653,7 @@ func (p *peer) writeMessages() {
|
||||
knownPeersFilter,
|
||||
knownPeersSalt,
|
||||
areWeAPrimaryNetworkValidator,
|
||||
mySignedIP.MLDSASignature,
|
||||
)
|
||||
if err != nil {
|
||||
p.Log.Error(failedToCreateMessageLog,
|
||||
@@ -714,7 +816,7 @@ func (p *peer) shouldDisconnect() bool {
|
||||
}
|
||||
|
||||
// Enforce that all validators that have registered a BLS key are signing
|
||||
// their IP with it after the activation of Durango.
|
||||
// their IP with the signed-IP payload.
|
||||
vdr, ok := p.Validators.GetValidator(constants.PrimaryNetworkID, p.id)
|
||||
if !ok || vdr.PublicKey == nil || vdr.TxID == p.txIDOfVerifiedBLSKey {
|
||||
return false
|
||||
@@ -1015,6 +1117,11 @@ func (p *peer) handleHandshake(msg *p2p.Handshake) {
|
||||
Timestamp: msg.IpSigningTime,
|
||||
},
|
||||
TLSSignature: msg.IpNodeIdSig,
|
||||
// Empty on legacy peers (classical-only); the SignedIP.Verify
|
||||
// call below only enforces the ML-DSA leg under strict-PQ
|
||||
// profile and only when the validator's ML-DSA public key is
|
||||
// known to the verifier. The wire format tolerates absence.
|
||||
MLDSASignature: msg.GetIpMldsaSig(),
|
||||
}
|
||||
maxTimestamp := localTime.Add(p.MaxClockDifference)
|
||||
if err := p.ip.Verify(p.cert, maxTimestamp); err != nil {
|
||||
|
||||
@@ -101,6 +101,7 @@ func FuzzPeerMessageHandling(f *testing.F) {
|
||||
[]byte{},
|
||||
[]byte{},
|
||||
false,
|
||||
[]byte{},
|
||||
)
|
||||
case 3: // PeerList
|
||||
claimedIPs := []*endpoints.ClaimedIPPort{
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package peer
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
|
||||
"github.com/luxfi/node/network/kem"
|
||||
)
|
||||
|
||||
// pqFrameMaxSize is the upper bound on a single PQ-handshake frame (INIT
|
||||
// or RESP). Both messages carry an ML-DSA-65 signature (3309 B), an
|
||||
// ML-DSA-65 public key (1952 B), an ML-KEM-1024 public key (1568 B) or
|
||||
// ciphertext (1568 B), plus version / profile / chain-id / NodeID fields
|
||||
// (~70 B). 16 KiB is comfortably above the largest legitimate frame and
|
||||
// well below any DoS-relevant threshold.
|
||||
const pqFrameMaxSize = 16 * 1024
|
||||
|
||||
// errPQFrameTooLarge is returned when a peer announces a PQ-handshake
|
||||
// frame larger than pqFrameMaxSize. Refusing oversize frames before the
|
||||
// allocation prevents a memory-exhaustion DoS.
|
||||
var errPQFrameTooLarge = errors.New("peer: PQ handshake frame exceeds size cap")
|
||||
|
||||
// readPQFrame reads a single 4-byte big-endian length-prefixed PQ frame
|
||||
// from conn. Mirrors the framing scheme the rest of the peer wire uses;
|
||||
// strict-PQ chains carry one INIT + one RESP via these calls before any
|
||||
// p2p message flows.
|
||||
func readPQFrame(conn net.Conn) ([]byte, error) {
|
||||
var hdr [4]byte
|
||||
if _, err := io.ReadFull(conn, hdr[:]); err != nil {
|
||||
return nil, fmt.Errorf("read PQ frame header: %w", err)
|
||||
}
|
||||
size := binary.BigEndian.Uint32(hdr[:])
|
||||
if size > pqFrameMaxSize {
|
||||
return nil, fmt.Errorf("%w: %d > %d", errPQFrameTooLarge, size, pqFrameMaxSize)
|
||||
}
|
||||
buf := make([]byte, size)
|
||||
if _, err := io.ReadFull(conn, buf); err != nil {
|
||||
return nil, fmt.Errorf("read PQ frame body: %w", err)
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// writePQFrame writes a single 4-byte big-endian length-prefixed PQ
|
||||
// frame onto conn. Symmetric with readPQFrame.
|
||||
func writePQFrame(conn net.Conn, payload []byte) error {
|
||||
if len(payload) > pqFrameMaxSize {
|
||||
return fmt.Errorf("%w: payload=%d > cap=%d",
|
||||
errPQFrameTooLarge, len(payload), pqFrameMaxSize)
|
||||
}
|
||||
var hdr [4]byte
|
||||
binary.BigEndian.PutUint32(hdr[:], uint32(len(payload)))
|
||||
if _, err := conn.Write(hdr[:]); err != nil {
|
||||
return fmt.Errorf("write PQ frame header: %w", err)
|
||||
}
|
||||
if _, err := conn.Write(payload); err != nil {
|
||||
return fmt.Errorf("write PQ frame body: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parsePQHandshakeInit decodes the canonical wire form of HandshakeInit.
|
||||
// Field layout matches HandshakeInit.canonicalBytes / transcriptPrefix in
|
||||
// handshake.go. The KEM scheme parameter pins the expected lengths of
|
||||
// KEMPub and Sig — passing the wrong scheme produces a length mismatch
|
||||
// error before any signature work runs.
|
||||
func parsePQHandshakeInit(b []byte, scheme kem.KeyExchangeID) (*HandshakeInit, error) {
|
||||
r := newPQReader(b)
|
||||
h := &HandshakeInit{}
|
||||
var err error
|
||||
if h.ProtocolVersion, err = r.readU8(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profByte, err := r.readU8()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.Profile = ProfileID(profByte)
|
||||
if err := r.readFixed(h.ChainID[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kemByte, err := r.readU8()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.KEMScheme = kem.KeyExchangeID(kemByte)
|
||||
if h.KEMScheme != scheme {
|
||||
return nil, fmt.Errorf("%w: peer offered %s, expected %s",
|
||||
ErrHandshakeKEMScheme, h.KEMScheme, scheme)
|
||||
}
|
||||
var nid [nodeIDSize]byte
|
||||
if err := r.readFixed(nid[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copy(h.NodeID[:], nid[:])
|
||||
if h.MLDSAPub, err = r.readBytes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.KEMPub, err = r.readBytes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.Sig, err = r.readBytes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.remaining() != 0 {
|
||||
return nil, fmt.Errorf("%w: trailing bytes in PQ INIT (%d)",
|
||||
ErrHandshakeBadIdentity, r.remaining())
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// parsePQHandshakeResp decodes the canonical wire form of HandshakeResp.
|
||||
// Symmetric with parsePQHandshakeInit; KEMCiphertext replaces KEMPub.
|
||||
func parsePQHandshakeResp(b []byte, scheme kem.KeyExchangeID) (*HandshakeResp, error) {
|
||||
r := newPQReader(b)
|
||||
h := &HandshakeResp{}
|
||||
var err error
|
||||
if h.ProtocolVersion, err = r.readU8(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profByte, err := r.readU8()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.Profile = ProfileID(profByte)
|
||||
if err := r.readFixed(h.ChainID[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kemByte, err := r.readU8()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.KEMScheme = kem.KeyExchangeID(kemByte)
|
||||
if h.KEMScheme != scheme {
|
||||
return nil, fmt.Errorf("%w: peer offered %s, expected %s",
|
||||
ErrHandshakeKEMScheme, h.KEMScheme, scheme)
|
||||
}
|
||||
var nid [nodeIDSize]byte
|
||||
if err := r.readFixed(nid[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copy(h.NodeID[:], nid[:])
|
||||
if h.MLDSAPub, err = r.readBytes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.KEMCiphertext, err = r.readBytes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.Sig, err = r.readBytes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.remaining() != 0 {
|
||||
return nil, fmt.Errorf("%w: trailing bytes in PQ RESP (%d)",
|
||||
ErrHandshakeBadIdentity, r.remaining())
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// pqReader is a tiny zero-allocation reader for the PQ handshake wire
|
||||
// format. Kept local to this file so the encoding stays reviewable in
|
||||
// one place; the writer side lives on the HandshakeInit / HandshakeResp
|
||||
// methods in handshake.go.
|
||||
type pqReader struct {
|
||||
buf []byte
|
||||
offset int
|
||||
}
|
||||
|
||||
func newPQReader(b []byte) *pqReader { return &pqReader{buf: b} }
|
||||
|
||||
func (r *pqReader) remaining() int { return len(r.buf) - r.offset }
|
||||
|
||||
func (r *pqReader) readU8() (uint8, error) {
|
||||
if r.remaining() < 1 {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
v := r.buf[r.offset]
|
||||
r.offset++
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (r *pqReader) readFixed(dst []byte) error {
|
||||
if r.remaining() < len(dst) {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
copy(dst, r.buf[r.offset:])
|
||||
r.offset += len(dst)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pqReader) readBytes() ([]byte, error) {
|
||||
if r.remaining() < 4 {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
n := binary.BigEndian.Uint32(r.buf[r.offset:])
|
||||
r.offset += 4
|
||||
if uint64(n) > uint64(r.remaining()) {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
out := make([]byte, n)
|
||||
copy(out, r.buf[r.offset:r.offset+int(n)])
|
||||
r.offset += int(n)
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// scheme_gate.go — single cross-axis gate between an incoming peer's
|
||||
// NodeIDScheme byte and the chain's pinned ValidatorSchemeID. The gate
|
||||
// is the primitive; callers that turn a wire NodeID into a "this peer
|
||||
// is admissible on this chain" decision call Classify and pass the
|
||||
// site name so the refused-by log line names which boundary refused.
|
||||
//
|
||||
// Sites that funnel through this gate:
|
||||
//
|
||||
// "handshake" — peer TLS upgrade (network/peer/upgrader.go)
|
||||
// "proposer" — proposervm block proposer attribution
|
||||
// "validator" — platformvm validator registration
|
||||
// "mempool-sender" — mempool signed-tx submission
|
||||
//
|
||||
// Forward-only PQ: there is no transition window and no classical
|
||||
// fallback. Every site funnels through Classify which admits only the
|
||||
// pinned PQ NodeIDScheme byte (ML-DSA-65 today); anything else — a
|
||||
// classical secp256k1 byte, a cross-PQ scheme like ML-DSA-87 on a chain
|
||||
// pinning ML-DSA-65, or an unknown byte — is refused at the gate.
|
||||
|
||||
package peer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
consensusconfig "github.com/luxfi/consensus/config"
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// SchemeGate is the chain-scoped policy object that decides whether a
|
||||
// peer's NodeIDScheme is admissible under the chain's pinned profile.
|
||||
// One SchemeGate per chain; created at chain-bootstrap from the chain's
|
||||
// ChainSecurityProfile and pinned for the chain's lifetime (no
|
||||
// re-derivation in the hot path).
|
||||
//
|
||||
// Forward-only PQ: the gate has no transition window and no operator
|
||||
// classical-compat escape hatch. Every Classify call admits only the
|
||||
// pinned PQ scheme byte; classical secp256k1 NodeIDs and cross-PQ
|
||||
// bytes are refused at every height.
|
||||
type SchemeGate struct {
|
||||
// Profile is the chain's locked ChainSecurityProfile. The pinned
|
||||
// ValidatorSchemeID is read through profile.ValidatorSchemeID().
|
||||
Profile *consensusconfig.ChainSecurityProfile
|
||||
|
||||
// ActivationHeight is preserved for symmetry with the per-chain
|
||||
// fork-height bookkeeping; the gate's policy is the same on both
|
||||
// sides of it (PQ-only). Reserved for downstream callers that want
|
||||
// a single field to log "where strict-PQ took effect" alongside
|
||||
// other hardfork heights — the gate itself does not branch on it.
|
||||
ActivationHeight uint64
|
||||
}
|
||||
|
||||
// NewSchemeGate constructs a SchemeGate. The profile pointer MUST be
|
||||
// non-nil and MUST have already passed Validate at chain-bootstrap;
|
||||
// the gate does not re-validate the profile on each call.
|
||||
func NewSchemeGate(
|
||||
profile *consensusconfig.ChainSecurityProfile,
|
||||
activationHeight uint64,
|
||||
) (*SchemeGate, error) {
|
||||
if profile == nil {
|
||||
return nil, fmt.Errorf("%w: profile is nil", ErrSchemeGateConfig)
|
||||
}
|
||||
return &SchemeGate{
|
||||
Profile: profile,
|
||||
ActivationHeight: activationHeight,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Classify is the entry point for every cross-axis check. It takes the
|
||||
// bare NodeID, the scheme byte derived by the caller (handshake / proposer
|
||||
// / validator / mempool), the block height we are mounting against, and
|
||||
// a site tag for the log line; it returns a TypedNodeID stamped with the
|
||||
// scheme byte once the chain's policy admits the pair.
|
||||
//
|
||||
// Forward-only PQ: only the pinned PQ scheme byte is admitted. A
|
||||
// classical secp256k1 byte, a cross-PQ byte (ML-DSA-87 on a chain
|
||||
// pinning ML-DSA-65), or an unknown byte is refused at every height.
|
||||
//
|
||||
// site is a free-form tag ("handshake", "proposer", "validator",
|
||||
// "mempool-sender" are the conventional values) included verbatim in
|
||||
// the refused-by error so a log reader knows which boundary refused.
|
||||
func (g *SchemeGate) Classify(
|
||||
nodeID ids.NodeID,
|
||||
derivedScheme ids.NodeIDScheme,
|
||||
height uint64,
|
||||
site string,
|
||||
) (ids.TypedNodeID, error) {
|
||||
if !derivedScheme.IsKnown() {
|
||||
return ids.TypedNodeID{}, fmt.Errorf("%w: site=%s scheme=0x%02x",
|
||||
ErrSchemeGateUnknownScheme, site, byte(derivedScheme))
|
||||
}
|
||||
|
||||
// PQ-only: defer to the profile's cross-axis check with the
|
||||
// classical-compat escape hatch pinned off. The profile refuses
|
||||
// classical schemes; this gate refuses them at every height.
|
||||
presentedSig := consensusconfig.SigSchemeID(derivedScheme)
|
||||
if err := g.Profile.AcceptsValidatorScheme(presentedSig, false); err != nil {
|
||||
return ids.TypedNodeID{}, fmt.Errorf("%w: site=%s height=%d: %w",
|
||||
ErrSchemeGateMismatch, site, height, err)
|
||||
}
|
||||
|
||||
typed, err := ids.NewTypedNodeID(derivedScheme, nodeID)
|
||||
if err != nil {
|
||||
return ids.TypedNodeID{}, fmt.Errorf("%w: site=%s: %w",
|
||||
ErrSchemeGateMismatch, site, err)
|
||||
}
|
||||
return typed, nil
|
||||
}
|
||||
|
||||
// Typed validation errors. Distinct from the consensus / ids errors so
|
||||
// a log reader can tell exactly which boundary refused.
|
||||
var (
|
||||
// ErrSchemeGateConfig — the gate could not be constructed
|
||||
// (nil profile, invalid configuration).
|
||||
ErrSchemeGateConfig = errors.New("peer: SchemeGate misconfigured")
|
||||
|
||||
// ErrSchemeGateMismatch — a peer / proposer / validator /
|
||||
// mempool sender presented a NodeIDScheme byte the chain's
|
||||
// SchemeGate refuses. Wraps the underlying mismatch reason from
|
||||
// consensus/config.AcceptsValidatorScheme or ids.NewTypedNodeID.
|
||||
ErrSchemeGateMismatch = errors.New("peer: NodeIDScheme refused by SchemeGate")
|
||||
|
||||
// ErrSchemeGateUnknownScheme — a wire input named a NodeIDScheme
|
||||
// byte this build does not understand. Always refused, regardless
|
||||
// of profile class or activation height.
|
||||
ErrSchemeGateUnknownScheme = errors.New("peer: NodeIDScheme byte is unknown")
|
||||
)
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package peer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
consensusconfig "github.com/luxfi/consensus/config"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSchemeGate_NewSchemeGate_RejectsNilProfile — construction is
|
||||
// fail-closed: a nil profile produces ErrSchemeGateConfig and never a
|
||||
// half-formed gate.
|
||||
func TestSchemeGate_NewSchemeGate_RejectsNilProfile(t *testing.T) {
|
||||
require := require.New(t)
|
||||
_, err := NewSchemeGate(nil, 0)
|
||||
require.ErrorIs(err, ErrSchemeGateConfig)
|
||||
}
|
||||
|
||||
// TestSchemeGate_StrictPQ_AcceptsMatchedScheme — under a strict-PQ
|
||||
// profile an ML-DSA-65 NodeID is accepted at every site at every
|
||||
// height.
|
||||
func TestSchemeGate_StrictPQ_AcceptsMatchedScheme(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
g, err := NewSchemeGate(consensusconfig.StrictPQ(), 0)
|
||||
require.NoError(err)
|
||||
|
||||
id := ids.NodeID{0xaa, 0xbb}
|
||||
for _, site := range []string{
|
||||
"handshake", "proposer", "validator", "mempool-sender",
|
||||
} {
|
||||
for _, height := range []uint64{0, 100, 1_000_000} {
|
||||
typed, err := g.Classify(id, ids.NodeIDSchemeMLDSA65, height, site)
|
||||
require.NoError(err, "site=%s height=%d", site, height)
|
||||
require.Equal(ids.NodeIDSchemeMLDSA65, typed.Scheme)
|
||||
require.Equal(id, typed.NodeID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemeGate_StrictPQ_RejectsClassicalAtEveryHeight — a strict-PQ
|
||||
// chain refuses a classical secp256k1 NodeID at every site and every
|
||||
// height. There is no transition window in forward-only PQ mode.
|
||||
func TestSchemeGate_StrictPQ_RejectsClassicalAtEveryHeight(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
g, err := NewSchemeGate(consensusconfig.StrictPQ(), 0)
|
||||
require.NoError(err)
|
||||
|
||||
id := ids.NodeID{0x11, 0x22}
|
||||
for _, site := range []string{
|
||||
"handshake", "proposer", "validator", "mempool-sender",
|
||||
} {
|
||||
for _, height := range []uint64{0, 50, 1_000_000} {
|
||||
_, err := g.Classify(id, ids.NodeIDSchemeSecp256k1, height, site)
|
||||
require.ErrorIs(err, ErrSchemeGateMismatch,
|
||||
"site=%s height=%d", site, height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemeGate_RejectsCrossPQScheme — a chain pinning ML-DSA-65 must
|
||||
// refuse an ML-DSA-87 NodeID. Cross-PQ bytes are never admissible.
|
||||
func TestSchemeGate_RejectsCrossPQScheme(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
g, err := NewSchemeGate(consensusconfig.StrictPQ(), 0)
|
||||
require.NoError(err)
|
||||
|
||||
_, err = g.Classify(ids.NodeID{0x33}, ids.NodeIDSchemeMLDSA87, 0, "handshake")
|
||||
require.ErrorIs(err, ErrSchemeGateMismatch)
|
||||
}
|
||||
|
||||
// TestSchemeGate_RejectsUnknownSchemeByte — an unknown scheme byte is
|
||||
// refused at every height. The PQ-only rule does not widen the byte
|
||||
// set.
|
||||
func TestSchemeGate_RejectsUnknownSchemeByte(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
g, err := NewSchemeGate(consensusconfig.StrictPQ(), 1000)
|
||||
require.NoError(err)
|
||||
|
||||
id := ids.NodeID{0x99}
|
||||
|
||||
_, err = g.Classify(id, ids.NodeIDScheme(0x91), 500, "handshake")
|
||||
require.ErrorIs(err, ErrSchemeGateUnknownScheme)
|
||||
|
||||
_, err = g.Classify(id, ids.NodeIDScheme(0x91), 2000, "handshake")
|
||||
require.ErrorIs(err, ErrSchemeGateUnknownScheme)
|
||||
}
|
||||
|
||||
// TestSchemeGate_PinsProfileScheme — the gate's pinned scheme reads
|
||||
// through the profile's ValidatorSchemeID. A profile-byte change
|
||||
// changes the gate's acceptance set without any gate-level edit.
|
||||
func TestSchemeGate_PinsProfileScheme(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
p := consensusconfig.StrictPQ()
|
||||
g, err := NewSchemeGate(p, 0)
|
||||
require.NoError(err)
|
||||
|
||||
id := ids.NodeID{0x01}
|
||||
|
||||
_, err = g.Classify(id, ids.NodeIDScheme(p.ValidatorSchemeID()), 0, "handshake")
|
||||
require.NoError(err)
|
||||
|
||||
otherPQ := ids.NodeIDSchemeMLDSA87
|
||||
if p.ValidatorSchemeID() == consensusconfig.SigSchemeMLDSA87 {
|
||||
otherPQ = ids.NodeIDSchemeMLDSA65
|
||||
}
|
||||
_, err = g.Classify(id, otherPQ, 0, "handshake")
|
||||
require.ErrorIs(err, ErrSchemeGateMismatch)
|
||||
}
|
||||
|
||||
// TestSchemeGate_SiteTagIncludedInError — the site tag passed to
|
||||
// Classify must appear in the returned error so a log reader can
|
||||
// identify which boundary refused.
|
||||
func TestSchemeGate_SiteTagIncludedInError(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
g, err := NewSchemeGate(consensusconfig.StrictPQ(), 0)
|
||||
require.NoError(err)
|
||||
|
||||
_, err = g.Classify(ids.NodeID{0x01}, ids.NodeIDSchemeSecp256k1, 1, "proposer")
|
||||
require.Error(err)
|
||||
require.Contains(err.Error(), "site=proposer")
|
||||
}
|
||||
@@ -73,6 +73,7 @@ func StartTestPeer(
|
||||
clientUpgrader := NewTLSClientUpgrader(
|
||||
tlsConfg,
|
||||
metric.NewCounter(metric.CounterOpts{}),
|
||||
nil, // no SchemeGate in tests; the cross-axis check is opt-in.
|
||||
)
|
||||
|
||||
peerID, conn, cert, err := clientUpgrader.Upgrade(conn)
|
||||
|
||||
@@ -28,6 +28,26 @@ var (
|
||||
//
|
||||
// It is safe, and typically expected, for [keyLogWriter] to be [nil].
|
||||
// [keyLogWriter] should only be enabled for debugging.
|
||||
//
|
||||
// F103 KEM semantics — TLS curve preference:
|
||||
//
|
||||
// CurvePreferences pins the IANA-registered hybrid X25519MLKEM768
|
||||
// (curve ID 0x11ec) as the only acceptable curve. This is STRICTLY
|
||||
// STRONGER than pure ML-KEM-768: an attacker must break both X25519
|
||||
// AND ML-KEM-768 to derive the session key. The chain-wide
|
||||
// ChainSecurityProfile pins KeyExchangeMLKEM768 — the post-quantum
|
||||
// component required on the wire — and the hybrid satisfies that
|
||||
// requirement (it CONTAINS ML-KEM-768). ForbidClassicalKEM in the
|
||||
// profile refuses a pure-classical curve (e.g. X25519 alone) at the
|
||||
// application layer; it does NOT refuse a hybrid that includes
|
||||
// ML-KEM-768. See consensus/config.KeyExchangeID for the full
|
||||
// rationale.
|
||||
//
|
||||
// Decision recorded by F103: keep the hybrid as the only TLS-layer
|
||||
// KEM. Adding a separate "pure vs hybrid" enum byte to the profile
|
||||
// would multiply downstream verifier obligations without strengthening
|
||||
// the posture; the audit signed off on the single-byte KeyExchangeID
|
||||
// surface and the hybrid is mathematically stronger than pure.
|
||||
func TLSConfig(cert tls.Certificate, keyLogWriter io.Writer) *tls.Config {
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package peer
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
@@ -29,36 +31,53 @@ type Upgrader interface {
|
||||
type tlsServerUpgrader struct {
|
||||
config *tls.Config
|
||||
invalidCerts metric.Counter
|
||||
gate *SchemeGate
|
||||
}
|
||||
|
||||
func NewTLSServerUpgrader(config *tls.Config, invalidCerts metric.Counter) Upgrader {
|
||||
// NewTLSServerUpgrader returns an Upgrader that runs the server-side TLS
|
||||
// handshake and (when gate is non-nil) the cross-axis NodeIDScheme gate
|
||||
// against the chain's locked ChainSecurityProfile. A nil gate disables the
|
||||
// cross-axis check; this is the legacy / classical-compat behaviour.
|
||||
func NewTLSServerUpgrader(config *tls.Config, invalidCerts metric.Counter, gate *SchemeGate) Upgrader {
|
||||
return &tlsServerUpgrader{
|
||||
config: config,
|
||||
invalidCerts: invalidCerts,
|
||||
gate: gate,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tlsServerUpgrader) Upgrade(conn net.Conn) (ids.NodeID, net.Conn, *staking.Certificate, error) {
|
||||
return connToIDAndCert(tls.Server(conn, t.config), t.invalidCerts)
|
||||
return connToIDAndCert(tls.Server(conn, t.config), t.invalidCerts, t.gate)
|
||||
}
|
||||
|
||||
type tlsClientUpgrader struct {
|
||||
config *tls.Config
|
||||
invalidCerts metric.Counter
|
||||
gate *SchemeGate
|
||||
}
|
||||
|
||||
func NewTLSClientUpgrader(config *tls.Config, invalidCerts metric.Counter) Upgrader {
|
||||
// NewTLSClientUpgrader returns an Upgrader that runs the client-side TLS
|
||||
// handshake and (when gate is non-nil) the cross-axis NodeIDScheme gate.
|
||||
// A nil gate disables the cross-axis check.
|
||||
func NewTLSClientUpgrader(config *tls.Config, invalidCerts metric.Counter, gate *SchemeGate) Upgrader {
|
||||
return &tlsClientUpgrader{
|
||||
config: config,
|
||||
invalidCerts: invalidCerts,
|
||||
gate: gate,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tlsClientUpgrader) Upgrade(conn net.Conn) (ids.NodeID, net.Conn, *staking.Certificate, error) {
|
||||
return connToIDAndCert(tls.Client(conn, t.config), t.invalidCerts)
|
||||
return connToIDAndCert(tls.Client(conn, t.config), t.invalidCerts, t.gate)
|
||||
}
|
||||
|
||||
func connToIDAndCert(conn *tls.Conn, invalidCerts metric.Counter) (ids.NodeID, net.Conn, *staking.Certificate, error) {
|
||||
// connToIDAndCert runs the TLS handshake, parses the peer leaf certificate,
|
||||
// derives the canonical NodeID, and (when gate is non-nil) refuses the
|
||||
// connection at the cross-axis gate before any downstream wiring sees it.
|
||||
// Refusal here is by design: the gate is the single place that says "this
|
||||
// peer's NodeIDScheme is not admissible on this chain"; later boundaries
|
||||
// trust that an admitted peer has already passed.
|
||||
func connToIDAndCert(conn *tls.Conn, invalidCerts metric.Counter, gate *SchemeGate) (ids.NodeID, net.Conn, *staking.Certificate, error) {
|
||||
if err := conn.Handshake(); err != nil {
|
||||
return ids.EmptyNodeID, nil, nil, err
|
||||
}
|
||||
@@ -79,5 +98,32 @@ func connToIDAndCert(conn *tls.Conn, invalidCerts metric.Counter) (ids.NodeID, n
|
||||
Raw: peerCert.Raw,
|
||||
PublicKey: peerCert.PublicKey,
|
||||
})
|
||||
|
||||
if gate != nil {
|
||||
scheme := schemeFromCert(peerCert)
|
||||
if _, err := gate.Classify(nodeID, scheme, 0, "handshake"); err != nil {
|
||||
return ids.EmptyNodeID, nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return nodeID, conn, peerCert, nil
|
||||
}
|
||||
|
||||
// schemeFromCert derives the wire NodeIDScheme byte from a parsed TLS leaf
|
||||
// certificate. TLS staking certs today carry RSA or ECDSA public keys —
|
||||
// both classify as NodeIDSchemeSecp256k1 (the canonical classical-compat
|
||||
// byte). A future ML-DSA cert extension (an X.509 extension carrying the
|
||||
// FIPS 204 public key) is recognised by its OID; until that extension
|
||||
// lands the function returns the classical byte. The gate's profile pin
|
||||
// is what decides admission — this function only names the byte.
|
||||
func schemeFromCert(cert *staking.Certificate) ids.NodeIDScheme {
|
||||
if cert == nil || cert.PublicKey == nil {
|
||||
return ids.NodeIDSchemeInvalid
|
||||
}
|
||||
switch cert.PublicKey.(type) {
|
||||
case *rsa.PublicKey, *ecdsa.PublicKey:
|
||||
return ids.NodeIDSchemeSecp256k1
|
||||
default:
|
||||
return ids.NodeIDSchemeInvalid
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package peer_test
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
consensusconfig "github.com/luxfi/consensus/config"
|
||||
"github.com/luxfi/metric"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/luxfi/node/network/peer"
|
||||
)
|
||||
|
||||
// TestUpgrader_StrictPQ_RefusesClassicalTLS — closes BLOCKERS CR-3.
|
||||
// Constructs a tlsServerUpgrader wired with a strict-PQ peer.SchemeGate
|
||||
// (ActivationHeight=0 — gate active from genesis) and dials it with a
|
||||
// vanilla RSA TLS client. The server-side upgrade MUST refuse the
|
||||
// connection at the SchemeGate, not later in some peer-init step. The
|
||||
// returned error MUST wrap peer.ErrSchemeGateMismatch with site="handshake"
|
||||
// so a log reader can identify the refusing boundary.
|
||||
func TestUpgrader_StrictPQ_RefusesClassicalTLS(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
// Strict-PQ SchemeGate active from genesis. Forward-only PQ —
|
||||
// the gate refuses the classical scheme byte at every height.
|
||||
gate, err := peer.NewSchemeGate(consensusconfig.StrictPQ(), 0)
|
||||
require.NoError(err)
|
||||
|
||||
serverKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(err)
|
||||
serverCert := makeTLSCert(t, serverKey)
|
||||
serverTLS := peer.TLSConfig(serverCert, nil)
|
||||
|
||||
clientKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(err)
|
||||
clientCert := makeTLSCert(t, clientKey)
|
||||
|
||||
invalidCertCounter := metric.NewCounter(metric.CounterOpts{})
|
||||
upgrader := peer.NewTLSServerUpgrader(serverTLS, invalidCertCounter, gate)
|
||||
|
||||
clientConfig := tls.Config{
|
||||
ClientAuth: tls.RequireAnyClientCert,
|
||||
InsecureSkipVerify: true, //#nosec G402 — staking TLS, trust derives from cert hash
|
||||
MinVersion: tls.VersionTLS13,
|
||||
Certificates: []tls.Certificate{clientCert},
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(err)
|
||||
defer listener.Close()
|
||||
|
||||
eg := &errgroup.Group{}
|
||||
eg.Go(func() error {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, _, upgradeErr := upgrader.Upgrade(conn)
|
||||
return upgradeErr
|
||||
})
|
||||
|
||||
conn, err := tls.Dial("tcp", listener.Addr().String(), &clientConfig)
|
||||
require.NoError(err)
|
||||
require.NoError(conn.Handshake())
|
||||
|
||||
err = eg.Wait()
|
||||
require.Error(err, "strict-PQ SchemeGate MUST refuse classical RSA cert")
|
||||
require.ErrorIs(err, peer.ErrSchemeGateMismatch,
|
||||
"refusal MUST come from the SchemeGate, not later peer-init")
|
||||
require.Contains(err.Error(), "site=handshake",
|
||||
"refusal MUST name the handshake boundary in the error")
|
||||
}
|
||||
|
||||
// TestUpgrader_NilGate_AcceptsClassicalTLS — the negative control. Without
|
||||
// a SchemeGate (legacy / classical-compat networks), the upgrader accepts
|
||||
// the same RSA TLS connection that the strict-PQ test refuses. This keeps
|
||||
// the gate's behaviour orthogonal to the existing TLS path.
|
||||
func TestUpgrader_NilGate_AcceptsClassicalTLS(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
serverKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(err)
|
||||
serverCert := makeTLSCert(t, serverKey)
|
||||
serverTLS := peer.TLSConfig(serverCert, nil)
|
||||
|
||||
clientKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(err)
|
||||
clientCert := makeTLSCert(t, clientKey)
|
||||
|
||||
upgrader := peer.NewTLSServerUpgrader(
|
||||
serverTLS,
|
||||
metric.NewCounter(metric.CounterOpts{}),
|
||||
nil, // no SchemeGate
|
||||
)
|
||||
|
||||
clientConfig := tls.Config{
|
||||
ClientAuth: tls.RequireAnyClientCert,
|
||||
InsecureSkipVerify: true, //#nosec G402
|
||||
MinVersion: tls.VersionTLS13,
|
||||
Certificates: []tls.Certificate{clientCert},
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(err)
|
||||
defer listener.Close()
|
||||
|
||||
eg := &errgroup.Group{}
|
||||
eg.Go(func() error {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, _, upgradeErr := upgrader.Upgrade(conn)
|
||||
return upgradeErr
|
||||
})
|
||||
|
||||
conn, err := tls.Dial("tcp", listener.Addr().String(), &clientConfig)
|
||||
require.NoError(err)
|
||||
require.NoError(conn.Handshake())
|
||||
|
||||
require.NoError(eg.Wait(), "nil gate MUST accept classical TLS as today")
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func TestBlockClientsWithIncorrectRSAKeys(t *testing.T) {
|
||||
require.FailNow(t, "should not have invoked")
|
||||
},
|
||||
}
|
||||
upgrader := peer.NewTLSServerUpgrader(config, failOnIncrementCounter)
|
||||
upgrader := peer.NewTLSServerUpgrader(config, failOnIncrementCounter, nil)
|
||||
|
||||
clientConfig := tls.Config{
|
||||
ClientAuth: tls.RequireAnyClientCert,
|
||||
|
||||
@@ -205,7 +205,7 @@ func NewTestNetwork(
|
||||
|
||||
return NewNetwork(
|
||||
cfg,
|
||||
upgrade.GetConfig(cfg.NetworkID).FortunaTime, // Must be updated for each network upgrade
|
||||
upgrade.InitiallyActiveTime,
|
||||
msgCreator,
|
||||
registry,
|
||||
log,
|
||||
|
||||
+1
-1
@@ -152,7 +152,7 @@ type Config struct {
|
||||
|
||||
// Genesis information
|
||||
GenesisBytes []byte `json:"-"`
|
||||
LuxAssetID ids.ID `json:"xAssetID"`
|
||||
UTXOAssetID ids.ID `json:"utxoAssetID"`
|
||||
|
||||
// ID of the network this node should connect to
|
||||
NetworkID uint32 `json:"networkID"`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user