mirror of
https://github.com/luxfi/node.git
synced 2026-07-28 18:57:33 +00:00
Compare commits
39
Commits
release/v1.36.22
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd2edc135f | ||
|
|
bdd7179164 | ||
|
|
50fe597bf7 | ||
|
|
8873e231e8 | ||
|
|
2a724fb090 | ||
|
|
23d93bf60b | ||
|
|
8a797f4805 | ||
|
|
9f4819cf77 | ||
|
|
84a08c2b9c | ||
|
|
011e9bf99d | ||
|
|
c39a85522d | ||
|
|
1c92026c7e | ||
|
|
f7e501d021 | ||
|
|
a57251c318 | ||
|
|
2f6ef061ab | ||
|
|
7d2f01eb0c | ||
|
|
c76fcda71d | ||
|
|
e1e3917772 | ||
|
|
e7137ed28d | ||
|
|
3398024a19 | ||
|
|
e77b696662 | ||
|
|
eb82872612 | ||
|
|
4fce5875c5 | ||
|
|
304717c199 | ||
|
|
ad76f2e709 | ||
|
|
26d419c3ad | ||
|
|
6c39b7d391 | ||
|
|
dada5a3169 | ||
|
|
3a44f0dcd3 | ||
|
|
d5eff75934 | ||
|
|
66d5940f88 | ||
|
|
e22009db91 | ||
|
|
011c3df6bd | ||
|
|
52de39485d | ||
|
|
e70f687c10 | ||
|
|
88bb914cd6 | ||
|
|
4a3a131757 | ||
|
|
45a3dcfff1 | ||
|
|
c3d6105804 |
@@ -1 +0,0 @@
|
||||
# CI Status Check - 2025-09-23 23:30:58
|
||||
@@ -1 +0,0 @@
|
||||
# Triggering CI - Version 1.13.5 Ready
|
||||
@@ -5,6 +5,12 @@ 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.36.31]
|
||||
|
||||
### Fixed
|
||||
- **`/v1/health` reported a chain the node denies exists.** The `bootstrapped` check publishes `chains.Nets.Bootstrapping()` verbatim as its message, but `Nets.chains` is keyed by **net** ID and the aggregate appended the map **key**, not the chain. Every primary-network chain that failed to converge therefore surfaced as the single ID `11111111111111111111111111111111LpoYY` — `constants.PrimaryNetworkID` (`ids.Empty`) — so an operator resolving it got "there is no chain with alias/ID", and N stuck chains collapsed into one indistinguishable entry (measured on devnet/testnet: `"message":["11111111111111111111111111111111LpoYY"],"contiguousFailures":3213`). The net owns the bootstrapping set, so the net now names its own chains: new `nets.Net.Bootstrapping() []ids.ID` (`nets/net.go`), and `chains/chains.go` aggregates those instead of the keys. `TestNetsBootstrappingReportsChainsNotNets` asserts `ids.Empty` never appears and that two stuck chains are individually named; `TestNetsBootstrapping` no longer asserts the bug (it demanded the net ID).
|
||||
- Ships the GET `/v1/health` encoder fix from `dada5a31` (`{"healthy":…,"error":"health reply encode failed"}` on every node — jsonv2 has no representation for `apihealth.Result.Duration`), which was on `main` but had never been tagged.
|
||||
|
||||
## [1.36.13]
|
||||
|
||||
### Fixed
|
||||
|
||||
+44
-19
@@ -124,22 +124,27 @@ RUN --mount=type=secret,id=ghtok,required=false \
|
||||
# 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 CGO_ENABLED=1 will fail at this step and
|
||||
# operators must build with CGO_ENABLED=0 (pure-Go fallback).
|
||||
# The release assets live in the PRIVATE lux-private/cevm repo, so the fetch is
|
||||
# authenticated with the same `ghtok` secret as the private go modules and
|
||||
# lux-accel above. Best-effort, like lux-accel: only a build that asks for the
|
||||
# cevm backend (EVM_CGO=1 below) actually needs these.
|
||||
ARG CGO_ENABLED=1
|
||||
ARG CEVM_VERSION=v0.19.0
|
||||
RUN if [ "${CGO_ENABLED}" = "1" ]; then \
|
||||
ARG CEVM_VERSION=v0.51.10
|
||||
ARG CEVM_REPO=lux-private/cevm
|
||||
RUN --mount=type=secret,id=ghtok,required=false \
|
||||
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" \
|
||||
-O /tmp/cevm.tar.gz && \
|
||||
tar -xzf /tmp/cevm.tar.gz -C /usr/local && \
|
||||
rm /tmp/cevm.tar.gz && \
|
||||
ldconfig 2>/dev/null || true ; \
|
||||
AUTH=""; [ -s /run/secrets/ghtok ] && AUTH="--header=Authorization: Bearer $(cat /run/secrets/ghtok)"; \
|
||||
( wget -q ${AUTH:+"$AUTH"} \
|
||||
"https://github.com/${CEVM_REPO}/releases/download/${CEVM_VERSION}/luxcpp-cevm-${CEVM_ARCH}.tar.gz" \
|
||||
-O /tmp/cevm.tar.gz \
|
||||
&& tar -xzf /tmp/cevm.tar.gz -C /usr/local \
|
||||
&& rm /tmp/cevm.tar.gz \
|
||||
&& ldconfig 2>/dev/null \
|
||||
) || echo "WARN: cevm ${CEVM_VERSION} fetch skipped (unreachable; needed only when EVM_CGO=1)" ; \
|
||||
else \
|
||||
echo "CGO_ENABLED=0: skipping luxcpp/cevm fetch (pure-Go fallback build)" ; \
|
||||
echo "CGO_ENABLED=0: skipping cevm fetch (pure-Go fallback build)" ; \
|
||||
fi
|
||||
|
||||
# Build node. CGO_ENABLED=1 (default) links luxcpp/cevm for parallel + GPU EVM.
|
||||
@@ -265,7 +270,27 @@ RUN . ./build_env.sh && \
|
||||
# and EVERY EVM chain (C + L2s hanzo/zoo/pars/spc, all mgj786) fails to
|
||||
# initialize. The api bump is code-free for plugins (chains v1.7.4->v1.7.5
|
||||
# adopted it with a go.mod-only diff), so v1.104.9 = v1.104.8 + api alignment.
|
||||
ARG EVM_VERSION=v1.104.9
|
||||
# C-Chain execution backend. The default is the pure-Go EVM, which is what
|
||||
# every image has shipped so far. EVM_CGO=1 EVM_TAGS=cevm links luxcpp/cevm
|
||||
# instead — that pair is what makes AutoEVM resolve to CppEVM. The libraries
|
||||
# come from the cevm fetch in this same stage above.
|
||||
ARG EVM_CGO=0
|
||||
ARG EVM_TAGS=""
|
||||
|
||||
# v1.104.22 realigns the plugin with THIS node's own go.mod: api v1.0.16 ->
|
||||
# v1.1.1, vm v1.2.6 -> v1.3.1, geth v1.17.12 -> v1.20.1. Node main pins api
|
||||
# v1.1.1, so pinning an evm at api v1.0.16 reintroduces the exact
|
||||
# InitializeResponse decode mismatch described above, just in the opposite
|
||||
# direction — keep this ARG and node's go.mod on the same api/vm/geth line.
|
||||
#
|
||||
# v1.104.14 is also the FIRST evm tag carrying the C-Chain fee-split seam
|
||||
# (core/fee_split.go creditTxFee + extras.FeeSplitTimestamp/FeeRewardVault).
|
||||
# Below it, encoding/json silently DISCARDS the genesis "feeSplitTimestamp"
|
||||
# key: a chain configured for the 50/50 split instead routes 100% of every fee
|
||||
# to the block coinbase and the reward vault 0x0100..0002 stays 0 forever, with
|
||||
# nothing burned. The split stays dormant wherever feeSplitTimestamp is absent
|
||||
# (mainnet), so this bump is behaviour-preserving there.
|
||||
ARG EVM_VERSION=v1.104.23
|
||||
ARG EVM_VM_ID=mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6
|
||||
# the pinned evm go.mod may pin a dead luxfi/upgrade pseudo-version
|
||||
# (v1.0.1-0.20260603055252-f51810805436 — commit pruned from origin). Heal it to
|
||||
@@ -292,8 +317,8 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
go mod edit -require=github.com/luxfi/chains@v1.7.0 && \
|
||||
find /tmp/evm -name go.sum -exec sed -i -E '/^github.com\/(luxfi|hanzoai)\//d' {} + && \
|
||||
GOARCH=$(echo ${TARGETPLATFORM} | cut -d / -f2) \
|
||||
CGO_ENABLED=0 GOFLAGS=-mod=mod \
|
||||
go build -ldflags="-s -w" -o /luxd/build/plugins/${EVM_VM_ID} ./plugin && \
|
||||
CGO_ENABLED=${EVM_CGO} GOFLAGS=-mod=mod \
|
||||
go build -ldflags="-s -w" -tags "${EVM_TAGS}" -o /luxd/build/plugins/${EVM_VM_ID} ./plugin && \
|
||||
chmod +x /luxd/build/plugins/${EVM_VM_ID} && \
|
||||
rm -rf /tmp/evm
|
||||
|
||||
@@ -310,7 +335,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
# oraclevm -> r5m1ujrmXxVcQetG3CQfuDLHp2RHKh6vCDaFgBRQfUcTZh7eS
|
||||
# quantumvm -> ry9Sg8rZdT26iEKvJDmC2wkESs4SDKgZEhk5BgLSwg1EpcNug
|
||||
# relayvm -> sP6dLqrrBR9w3soP18fbJ3YzZecZdD7DDdfH2cFhhLq7Hy9bz
|
||||
# mpcvm -> tGVBwRxpmD2aFdg3iYjgRvrCe8Jcmq9UNKxyHMus2NZ8WcD8t
|
||||
# mpcvm -> qCURact1n41FcoNBch8iMVBwc9AWie48D118ZNJ5tBdWrvryS
|
||||
# zkvm -> vv3qPfyTVXZ5ArRZA9Jh4hbYDTBe43f7sgQg4CHfNg1rnnvX9
|
||||
|
||||
# MUST track node's go.mod luxfi/chains (the D-Chain dexvm + 10 VM plugins).
|
||||
@@ -354,7 +379,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
( 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/mpcvm && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags="-s -w" \
|
||||
-o /luxd/build/plugins/tGVBwRxpmD2aFdg3iYjgRvrCe8Jcmq9UNKxyHMus2NZ8WcD8t ./cmd/plugin ) || echo "WARN: mpcvm plugin build skipped" ; \
|
||||
-o /luxd/build/plugins/qCURact1n41FcoNBch8iMVBwc9AWie48D118ZNJ5tBdWrvryS ./cmd/plugin ) || echo "WARN: mpcvm 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 ) && \
|
||||
@@ -367,7 +392,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
r5m1ujrmXxVcQetG3CQfuDLHp2RHKh6vCDaFgBRQfUcTZh7eS \
|
||||
ry9Sg8rZdT26iEKvJDmC2wkESs4SDKgZEhk5BgLSwg1EpcNug \
|
||||
sP6dLqrrBR9w3soP18fbJ3YzZecZdD7DDdfH2cFhhLq7Hy9bz \
|
||||
tGVBwRxpmD2aFdg3iYjgRvrCe8Jcmq9UNKxyHMus2NZ8WcD8t \
|
||||
qCURact1n41FcoNBch8iMVBwc9AWie48D118ZNJ5tBdWrvryS \
|
||||
vv3qPfyTVXZ5ArRZA9Jh4hbYDTBe43f7sgQg4CHfNg1rnnvX9 ; do \
|
||||
test -s /luxd/build/plugins/$p \
|
||||
|| { echo "FATAL: required chain-VM plugin $p missing/empty — its build failed above (see the matching WARN line); the runtime-stage hard COPY would otherwise fail cryptically. Surface & fix the real Go build error, or remove the plugin from BOTH the build list and the runtime COPY."; exit 1; } ; \
|
||||
@@ -477,7 +502,7 @@ COPY --from=builder \
|
||||
/luxd/build/plugins/r5m1ujrmXxVcQetG3CQfuDLHp2RHKh6vCDaFgBRQfUcTZh7eS \
|
||||
/luxd/build/plugins/ry9Sg8rZdT26iEKvJDmC2wkESs4SDKgZEhk5BgLSwg1EpcNug \
|
||||
/luxd/build/plugins/sP6dLqrrBR9w3soP18fbJ3YzZecZdD7DDdfH2cFhhLq7Hy9bz \
|
||||
/luxd/build/plugins/tGVBwRxpmD2aFdg3iYjgRvrCe8Jcmq9UNKxyHMus2NZ8WcD8t \
|
||||
/luxd/build/plugins/qCURact1n41FcoNBch8iMVBwc9AWie48D118ZNJ5tBdWrvryS \
|
||||
/luxd/build/plugins/vv3qPfyTVXZ5ArRZA9Jh4hbYDTBe43f7sgQg4CHfNg1rnnvX9 \
|
||||
/luxd/build/plugins/
|
||||
WORKDIR /luxd/build
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
# Lux Mainnet Launch Checklist
|
||||
|
||||
Node: luxfi/node v1.24.11
|
||||
Consensus: Quasar (BLS+Corona, slashing, stake-weighted sampling)
|
||||
EVM: GPU ecrecover, 18 precompiles
|
||||
Genesis: networkID=1, startTime=2025-12-12T21:06:51Z (mainnet), networkID=2, startTime=2026-02-10T16:00:00Z (testnet)
|
||||
Precompile constraint: all activations MUST be after 2025-12-25
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
| Environment | Cluster | DOKS ID | K8s Version | Namespace | Validators |
|
||||
|-------------|---------|---------|-------------|-----------|------------|
|
||||
| Testnet | do-sfo3-lux-test-k8s | `005ec3c4` | 1.35.1-do.0 | lux-testnet | 11 (target) |
|
||||
| Rehearsal | do-sfo3-lux-dev-k8s | `0ff340e1` | 1.35.1-do.0 | lux-devnet | 21 (target) |
|
||||
| Mainnet | do-sfo3-lux-k8s | `04c46df5` | 1.34.1-do.4 | lux-mainnet | 21 (target) |
|
||||
|
||||
Current state: lux-k8s runs 5 validators (v1.23.31) via LuxNetwork CRD. Testnet cluster (lux-test-k8s) has a 3-replica StatefulSet (v1.23.40).
|
||||
|
||||
Image: `ghcr.io/luxfi/node:v1.24.11` (built via CI/CD, linux/amd64+arm64)
|
||||
Staking keys: KMS at `kms.lux.network`, project `lux-infra`, synced via KMSSecret CRD
|
||||
Secrets: never in manifests, never in env files, never committed
|
||||
Genesis configs: `/Users/z/work/lux/genesis/configs/{testnet,mainnet,devnet}/`
|
||||
K8s manifests: `/Users/z/work/lux/universe/k8s/`
|
||||
Profiles: `standard.json` (~100MB/node), `max.json` (~512MB/node)
|
||||
|
||||
Bootstrappers:
|
||||
- Mainnet: 5 seeds (ports 9631) -- `209.38.118.46`, `209.38.174.69`, `24.144.69.101`, `134.199.187.56`, `143.198.246.173`
|
||||
- Testnet: 2 seeds (ports 9641) -- `134.199.187.16`, `209.38.174.84`
|
||||
|
||||
Consensus parameters (mainnet): K=20, AlphaPreference=15, AlphaConfidence=15, Beta=20, ConcurrentPolls=4
|
||||
|
||||
Tokenomics: 10B total supply (9 decimals), min validator stake 1M LUX, min delegator stake 25K LUX, combined staking allowed (NFT+delegation), 80% uptime threshold
|
||||
|
||||
C-Chain: chainId=96369 (mainnet), 96368 (testnet), gasLimit=12M, targetBlockRate=2s, minBaseFee=25gwei
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Testnet (lux-test-k8s, networkID=2)
|
||||
|
||||
Target: validate all consensus, EVM, and staking behavior with K=11 validators.
|
||||
|
||||
### 1.1 Deployment
|
||||
|
||||
- [ ] Update LuxNetwork CRD in `universe/k8s/lux-k8s/validators/statefulset.yaml` (testnet section): `validators: 11`, `image.tag: v1.24.11`
|
||||
- [ ] Update lux-test-k8s StatefulSet in `universe/k8s/lux-test-k8s/testnet/statefulset.yaml`: replicas=11, image=v1.24.11
|
||||
- [ ] Generate 11 staking key pairs via `lux cli` and store in KMS (`lux-infra/testnet/staking/`)
|
||||
- [ ] Add 9 new bootstrapper entries to `genesis/configs/testnet/bootstrappers.json` (currently 2)
|
||||
- [ ] Apply `max.json` profile for testnet validators (512MB/node for stress testing headroom)
|
||||
- [ ] Regenerate testnet genesis with 11 initial validators via `genesis` tool
|
||||
- [ ] Verify all precompile activation timestamps are after 2025-12-25
|
||||
- [ ] Deploy via PaaS (platform.hanzo.ai), not manual kubectl
|
||||
- [ ] Verify all 11 pods reach Running state
|
||||
- [ ] Verify all 11 nodes report healthy via `/v1/health/liveness`
|
||||
|
||||
### 1.2 Bootstrap and Connectivity
|
||||
|
||||
- [ ] Verify all 11 validators discover each other via P2P (check `info.peers` RPC, expect 10 peers per node)
|
||||
- [ ] Verify staking port 9641 reachable between all pods (`luxd-{0..10}.luxd-headless.testnet.svc.cluster.local:9641`)
|
||||
- [ ] Verify P-chain bootstraps and all validators appear in `platform.getCurrentValidators`
|
||||
- [ ] Verify C-chain bootstraps and produces blocks
|
||||
- [ ] Verify X-chain bootstraps and processes UTXO transactions
|
||||
|
||||
### 1.3 Quasar Consensus Verification
|
||||
|
||||
- [ ] Submit transactions, verify Quasar finalization with K=11
|
||||
- [ ] Verify BLS aggregate signatures in block headers
|
||||
- [ ] Verify Corona optimistic fast path activates when all 11 validators are online
|
||||
- [ ] Measure finality latency (target: sub-second with Corona)
|
||||
- [ ] Verify stake-weighted sampling: validators with more stake get polled proportionally
|
||||
|
||||
### 1.4 EVM Execution
|
||||
|
||||
- [ ] Deploy a test contract, call all standard opcodes
|
||||
- [ ] Submit 100 sequential transactions, verify correct nonce ordering
|
||||
- [ ] Verify `eth_call` and `eth_estimateGas` return correct results
|
||||
- [ ] Verify block gas limit is 12M (from cchain.json config)
|
||||
- [ ] Verify minBaseFee=25gwei is enforced
|
||||
|
||||
### 1.5 GPU ecrecover
|
||||
|
||||
- [ ] Verify GPU backend auto-detection: CUDA on Linux DOKS nodes, Metal on macOS
|
||||
- [ ] Run ecrecover-heavy workload (1000 signature verifications per block)
|
||||
- [ ] Compare ecrecover throughput: GPU vs CPU fallback
|
||||
- [ ] Verify graceful fallback to CPU when GPU unavailable (set `--gpu-backend=cpu`)
|
||||
|
||||
### 1.6 Precompiles (all 18)
|
||||
|
||||
- [ ] Test each precompile individually via contract calls
|
||||
- [ ] Verify DEX precompile (LP-9010 PoolManager): pool creation, swaps, flash loans
|
||||
- [ ] Verify DEX router precompile (LP-9012): multi-hop routing
|
||||
- [ ] Verify all precompile addresses are deterministic and match spec
|
||||
- [ ] Verify precompile gas metering is correct (no underpriced or overpriced ops)
|
||||
- [ ] Verify precompiles revert correctly on invalid input
|
||||
|
||||
### 1.7 Slashing
|
||||
|
||||
- [ ] Craft equivocation evidence: have a validator sign two different blocks at same height
|
||||
- [ ] Submit equivocation proof to P-chain slashing precompile
|
||||
- [ ] Verify slashed validator's stake is burned
|
||||
- [ ] Verify slashed validator is removed from active set
|
||||
- [ ] Verify honest validators are unaffected
|
||||
|
||||
### 1.8 Uptime and Rewards
|
||||
|
||||
- [ ] Stop 1 validator (scale pod to 0)
|
||||
- [ ] Wait for reward period to elapse
|
||||
- [ ] Verify stopped validator's uptime drops below 80%
|
||||
- [ ] Verify rewards are withheld for the stopped validator
|
||||
- [ ] Restart the validator, verify it re-bootstraps and resumes
|
||||
- [ ] Verify validators with >80% uptime receive expected rewards
|
||||
|
||||
### 1.9 Stress Test
|
||||
|
||||
- [ ] Run stress test: maximum TPS with 1B gas blocks (increase gas limit temporarily)
|
||||
- [ ] Measure sustained TPS over 1 hour (target: verify consensus is the bottleneck, not EVM)
|
||||
- [ ] Monitor memory usage per node (should stay within `max.json` profile ~512MB)
|
||||
- [ ] Monitor disk I/O and database growth rate
|
||||
- [ ] Verify no consensus stalls under load
|
||||
- [ ] Verify block production rate stays at targetBlockRate=2s
|
||||
|
||||
### 1.10 Validator Join/Leave
|
||||
|
||||
- [ ] Add a 12th validator via `platform.addPermissionlessValidator` (permissionless staking)
|
||||
- [ ] Verify new validator bootstraps from existing state
|
||||
- [ ] Verify new validator begins participating in consensus
|
||||
- [ ] Remove a validator via unstaking (wait for stake period to end or use testnet short periods)
|
||||
- [ ] Verify removed validator exits gracefully
|
||||
- [ ] Verify remaining validators continue producing blocks
|
||||
|
||||
### 1.11 Formal Verification
|
||||
|
||||
- [ ] Run Lean proofs for Quasar consensus safety and liveness
|
||||
- [ ] Run TLA+ model checker for consensus state machine
|
||||
- [ ] Run Tamarin prover for BLS+Corona security properties
|
||||
- [ ] Run Halmos for EVM precompile correctness (symbolic execution)
|
||||
- [ ] All proofs pass with zero counterexamples
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Mainnet Rehearsal (lux-dev-k8s, networkID=3)
|
||||
|
||||
Target: full mainnet simulation with real parameters for 72 hours.
|
||||
|
||||
### 2.1 Deployment
|
||||
|
||||
- [ ] Update LuxNetwork CRD (devnet section): `validators: 21`, `image.tag: v1.24.11`
|
||||
- [ ] Generate 21 staking key pairs, store in KMS (`lux-infra/devnet/staking/`)
|
||||
- [ ] Use mainnet genesis parameters (networkID=3, but same tokenomics, same stake amounts)
|
||||
- [ ] Apply `max.json` profile
|
||||
- [ ] Deploy via PaaS
|
||||
- [ ] Verify all 21 pods healthy
|
||||
|
||||
### 2.2 Real Staking Parameters
|
||||
|
||||
- [ ] Configure minimum validator stake: 1M LUX
|
||||
- [ ] Configure minimum delegator stake: 25K LUX
|
||||
- [ ] Configure max delegation ratio: 10x
|
||||
- [ ] Configure NFT staking tiers (Genesis 500K/2x, Pioneer 750K/1.5x, Standard 1M/1x)
|
||||
- [ ] Verify combined staking logic: NFT value + delegation + staked >= 1M
|
||||
- [ ] Verify B-chain validators require 100M LUX + KYC
|
||||
|
||||
### 2.3 72-Hour Soak Test
|
||||
|
||||
- [ ] Start clock. Record block height and timestamp.
|
||||
- [ ] Continuous transaction load: 50 TPS sustained
|
||||
- [ ] Monitor: CPU, memory, disk, network per node (Prometheus + Grafana via PaaS)
|
||||
- [ ] Monitor: consensus latency p50/p95/p99
|
||||
- [ ] Monitor: block production rate (target: 1 block per 2s)
|
||||
- [ ] Monitor: peer count stability (all 21 connected)
|
||||
- [ ] Monitor: no OOMKills, no pod restarts, no crashloops
|
||||
- [ ] At hour 24: rolling restart of 5 validators (verify zero downtime)
|
||||
- [ ] At hour 48: simulate network partition (isolate 7 nodes), verify chain halts (< 2/3 online)
|
||||
- [ ] Restore partition, verify chain resumes within 30s
|
||||
- [ ] At hour 72: record final block height, calculate actual vs expected blocks
|
||||
- [ ] Pass criteria: zero consensus faults, zero data loss, <1% block time variance
|
||||
|
||||
### 2.4 Security Audit
|
||||
|
||||
- [ ] External security audit firm engaged (Red team)
|
||||
- [ ] Audit scope: consensus, EVM, precompiles, staking, slashing, P2P networking
|
||||
- [ ] Audit result: 0 critical findings, 0 high findings
|
||||
- [ ] All medium findings remediated or accepted with documented risk
|
||||
- [ ] Audit report signed and archived
|
||||
|
||||
### 2.5 Bridge / Teleport (B-Chain + T-Chain)
|
||||
|
||||
- [ ] Deploy MPC threshold signing (5 nodes, threshold 3) in `lux-mpc` namespace
|
||||
- [ ] Deploy bridge UI and API in `lux-bridge` namespace
|
||||
- [ ] Verify CGGMP21 keygen: 5 parties generate shared key
|
||||
- [ ] Verify threshold signing: 3-of-5 produces valid signature
|
||||
- [ ] Test cross-chain transfer: lock on source chain, mint on Lux
|
||||
- [ ] Test reverse: burn on Lux, unlock on source chain
|
||||
- [ ] Verify MPC API at `mpc-api.lux.network` responds
|
||||
- [ ] Verify bridge handles partial MPC node failure (2 down, 3 still sign)
|
||||
|
||||
### 2.6 DEX (D-Chain + Precompiles)
|
||||
|
||||
- [ ] Deploy DEX precompile PoolManager (LP-9010) -- already active from genesis
|
||||
- [ ] Deploy DEX Router precompile (LP-9012) -- already active from genesis
|
||||
- [ ] Deploy off-chain CLOB matching engine
|
||||
- [ ] Create liquidity pool via precompile
|
||||
- [ ] Execute swap via router precompile
|
||||
- [ ] Verify AMM pricing matches expected curve
|
||||
- [ ] Verify flash loan execution and repayment
|
||||
- [ ] Test CLOB: place limit order, verify fill
|
||||
- [ ] Verify DEX on lux.exchange frontend connects to devnet
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Mainnet Launch (lux-k8s, networkID=1)
|
||||
|
||||
Target: production network with real value.
|
||||
|
||||
### 3.1 Pre-launch
|
||||
|
||||
- [ ] All Phase 1 items passed
|
||||
- [ ] All Phase 2 items passed
|
||||
- [ ] Security audit sign-off received
|
||||
- [ ] Formal verification suite green
|
||||
- [ ] Legal review complete (terms of service, validator agreements)
|
||||
- [ ] Incident response runbook written and tested
|
||||
|
||||
### 3.2 Genesis Ceremony
|
||||
|
||||
- [ ] Final genesis config reviewed: `genesis/configs/mainnet/genesis.json` (networkID=1)
|
||||
- [ ] Genesis startTime confirmed: 2025-12-12T21:06:51Z
|
||||
- [ ] Initial allocations verified (500M initial + unlock schedule)
|
||||
- [ ] All 5 bootstrapper IPs confirmed reachable on port 9631
|
||||
- [ ] Genesis hash computed and published to lux.network
|
||||
- [ ] Genesis block signed by founding validators
|
||||
|
||||
### 3.3 Validator Onboarding
|
||||
|
||||
- [ ] Update LuxNetwork CRD (mainnet section): `validators: 21`, `image.tag: v1.24.11`
|
||||
- [ ] Scale from 5 current validators to 21
|
||||
- [ ] Generate 16 new staking key pairs in KMS (`lux-infra/mainnet/staking/`)
|
||||
- [ ] Update bootstrappers.json with all 21 validator endpoints
|
||||
- [ ] Deploy via PaaS with rolling update strategy
|
||||
- [ ] Verify all 21 validators healthy and in consensus
|
||||
- [ ] Publish validator onboarding guide for external operators
|
||||
- [ ] Open permissionless staking after initial stabilization period
|
||||
|
||||
### 3.4 Public RPC Endpoints
|
||||
|
||||
- [ ] Deploy KrakenD API gateway in `lux-gateway` namespace
|
||||
- [ ] Configure rate limiting per IP and per API key
|
||||
- [ ] Configure Cloudflare DNS (proxied, full SSL):
|
||||
- `api.lux.network` -> gateway (C-chain + P-chain + X-chain RPC)
|
||||
- `ws.lux.network` -> gateway (WebSocket subscriptions)
|
||||
- [ ] Verify `eth_chainId` returns `0x17871` (96369)
|
||||
- [ ] Verify `net_version` returns `96369`
|
||||
- [ ] Verify RPC endpoints handle 10K req/s without degradation
|
||||
- [ ] Verify WebSocket subscriptions for `newHeads`, `logs`, `pendingTransactions`
|
||||
|
||||
### 3.5 Explorer Deployment
|
||||
|
||||
- [ ] Deploy explorer (luxfi/explorer) in `lux-explorer` namespace (already has manifests for 5 chains)
|
||||
- [ ] Configure for C-chain (chainId 96369)
|
||||
- [ ] Configure indexers for all active chains
|
||||
- [ ] Configure Cloudflare DNS: `explore.lux.network`
|
||||
- [ ] Verify block display, transaction search, contract verification
|
||||
- [ ] Deploy exchange frontend: `lux.exchange`
|
||||
|
||||
### 3.6 Bridge Activation
|
||||
|
||||
- [ ] Deploy MPC production cluster (5 nodes, threshold 3)
|
||||
- [ ] Generate production MPC keys (CGGMP21 keygen ceremony)
|
||||
- [ ] Store MPC key shares in KMS (`lux-infra/mainnet/mpc/`)
|
||||
- [ ] Deploy bridge contracts on supported chains (ETH, BNB, Polygon, Arbitrum, Base, Optimism)
|
||||
- [ ] Deploy bridge UI at bridge domain
|
||||
- [ ] Configure Cloudflare DNS
|
||||
- [ ] Enable deposits (one chain at a time, small limits first)
|
||||
- [ ] Monitor for 24h, then raise limits
|
||||
|
||||
### 3.7 Post-Launch Monitoring
|
||||
|
||||
- [ ] Prometheus + Grafana dashboards live (via PaaS o11y stack)
|
||||
- [ ] Alerts configured:
|
||||
- Validator down (any pod not Ready for >5min)
|
||||
- Consensus stall (no new block for >30s)
|
||||
- Peer count drop (any node <15 peers)
|
||||
- Memory usage >80% of limit
|
||||
- Disk usage >70%
|
||||
- Error rate >1% on RPC endpoints
|
||||
- [ ] On-call rotation established
|
||||
- [ ] Runbook covers: validator restart, chain halt recovery, emergency upgrade, key rotation
|
||||
|
||||
---
|
||||
|
||||
## Port Reference
|
||||
|
||||
| Network | HTTP | Staking | Metrics |
|
||||
|---------|------|---------|---------|
|
||||
| Mainnet | 9630 | 9631 | 9090 |
|
||||
| Testnet | 9640 | 9641 | 9090 |
|
||||
| Devnet | 9650 | 9651 | 9090 |
|
||||
|
||||
## Chain IDs
|
||||
|
||||
| Chain | Mainnet | Testnet | Devnet |
|
||||
|-------|---------|---------|--------|
|
||||
| C-Chain | 96369 | 96368 | 96370 |
|
||||
| Zoo EVM | 200200 | 200201 | 200202 |
|
||||
| Hanzo EVM | 36963 | 36964 | 36964 |
|
||||
| SPC EVM | 36911 | 36910 | 36912 |
|
||||
| Pars EVM | 494949 | 7071 | 494951 |
|
||||
|
||||
## File References
|
||||
|
||||
| What | Path |
|
||||
|------|------|
|
||||
| Node source | `~/work/lux/node/` |
|
||||
| Genesis configs | `~/work/lux/genesis/configs/{mainnet,testnet,devnet}/` |
|
||||
| Chain configs | `~/work/lux/genesis/configs/chain-configs/` |
|
||||
| K8s manifests | `~/work/lux/universe/k8s/` |
|
||||
| Validator CRD | `~/work/lux/universe/k8s/lux-k8s/validators/statefulset.yaml` |
|
||||
| Testnet StatefulSet | `~/work/lux/universe/k8s/lux-test-k8s/testnet/statefulset.yaml` |
|
||||
| Node profiles | `~/work/lux/node/config/profiles/{standard,max}.json` |
|
||||
| Tokenomics config | `~/work/lux/node/config/tokenomics.go` |
|
||||
| GPU config | `~/work/lux/node/config/gpu.go` |
|
||||
| Health/consensus params | `~/work/lux/node/config/health.go` |
|
||||
| Network registry | `~/work/lux/universe/NETWORKS.yaml` |
|
||||
@@ -253,6 +253,275 @@ when v1.36.12 image is ready → set devnet sts image v1.36.12, delete luxd-1, c
|
||||
C-Chain inits + reaches tip; then finish devnet (durable-fix proof + RewardManager),
|
||||
then gated testnet→mainnet→zoo.
|
||||
|
||||
## Crash-boot recovery armor + upstream delta verdict (vms/proposervm, test-only, 2026-07-28)
|
||||
|
||||
**Upstream review (avalanchego master `bcc851822d..c5d3c8aafe`, 6 commits): ZERO code
|
||||
ports.** grpc bump (no named bug; would cascade through luxfi/vm), README typo, typed
|
||||
sync-client scaffolding (client-side only, no serving handlers even upstream; our sync
|
||||
direction is ZAP-native — reference for task #66 only), a Firewood `debug_intermediateRoots`
|
||||
enabler (our tracers API is `.disabled`, twice N/A), and two streaming-VM-only mempool
|
||||
guards whose failure modes we already bracket (`miner/worker.go:62 targetTxsSize=1800KiB`
|
||||
build cap + txpool `ErrGasLimit`/128KB caps under the 2MiB wire cap). **Nothing in the
|
||||
delta touches proposervm — the open `vm.go:380` preferred-fetch failure gets no upstream
|
||||
help.** One upstream BRANCH does touch proposervm — `containerman17/proposervm-dedup-capability`
|
||||
(+72 vm.go): inner-bytes dedup in the block store plus an extra boot-repair arm for the
|
||||
unclean-shutdown window where the outer index lands above the inner survivor (the very
|
||||
window `OuterCommittedInnerNot` below pins) — storage-layer work, not the build-path
|
||||
preferred fetch, so it changes nothing about `vm.go:380` either. What WAS worth taking is
|
||||
their recovery-test discipline, ported as:
|
||||
|
||||
**`vm_crashboot_test.go`** — copy the COMMITTED bytes out from under a RUNNING proposervm
|
||||
(no Shutdown), boot a SECOND, cold VM over the copy (repair + metadata, Initialize order),
|
||||
assert source-equality (finality pointer, fork height, every height's envelope openable
|
||||
cold) and then BUILD on the recovered tip (outer parent == recovered envelope, inner
|
||||
parent == its inner block — the errInnerParentMismatch invariant from a cold boot).
|
||||
Matrix: nothing-accepted / first-accept / longer run / copy-while-source-runs-on, plus
|
||||
`OuterCommittedInnerNot`: the one crash window the accept path leaves open
|
||||
(`acceptPostForkBlock` commits BEFORE the inner accept) must boot via the roll-back arm
|
||||
and re-propose. Harness seams added in height_lag_repro_test.go (`testVMOnBase`,
|
||||
`acceptRangeThroughProposervm`). **Negative control executed**: deleting the
|
||||
`vm.db.Commit()` from `acceptPostForkBlock` fails every persistence-bearing scenario
|
||||
(4 of 6) — the armor bites on flush-order regressions, not just on green paths.
|
||||
|
||||
## v1.36.35 — the certified-descendant false halt + the plugin-killing map fatal (devnet 96367, 2026-07-28)
|
||||
|
||||
> Shipped as v1.36.35, not v1.36.34: the v1.36.34 tag inherited a build break that
|
||||
> `011e9bf99d` ("deps: drop go.sum lines that disagree with the checksum log") had
|
||||
> already pushed to main — it bumped `zap-proto/http` from a pseudo-version to v0.3.0,
|
||||
> whose `Server.Handler` is a `fasthttp.RequestHandler` and whose `NewTransport` is now
|
||||
> `Dial(network, addr)`, so `server/http/zap_listener.go` no longer compiled. **main was
|
||||
> unbuildable from that commit until this one.** Repaired here by bridging the one
|
||||
> net/http handler chain with `fasthttpadaptor.NewFastHTTPHandler` (the two transports
|
||||
> stay behaviourally identical — only the wire encoding differs) and moving the
|
||||
> round-trip test onto the fasthttp request/response pair. v1.36.34 produced no image
|
||||
> and its tag was deleted.
|
||||
|
||||
Rolling devnet to v1.36.33 fixed the build→verify-fail→drop loop and unmasked two
|
||||
DIFFERENT failures. Both are fixed here; each has a regression test that fails before
|
||||
and passes after.
|
||||
|
||||
**P0-1 — five validators `os.Exit(1)` on a benign state.** Each devnet node hit, once:
|
||||
|
||||
```
|
||||
error VM accepted head is CONSENSUS-CERTIFIED and conflicts with the newly finalized
|
||||
block — refusing to orphan it orphanedHeight=1364
|
||||
fatal SetPreference would orphan a CONSENSUS-CERTIFIED block — refusing (fail-closed)
|
||||
error="cannot orphan finalized block at height: 1364 to common block at height: 1363"
|
||||
```
|
||||
|
||||
at three distinct height pairs (1258/1259, 1363/1364, 1364/1365), ALWAYS with
|
||||
`certified = head − 1`, with every surviving node holding byte-identical blocks at all
|
||||
five heights — no fork anywhere. (The "1168/1169" pair in the original report never
|
||||
appears in any fatal line; those two blocks are a normal parent/child present on all
|
||||
live nodes.)
|
||||
|
||||
Two defects in `luxfi/consensus`, one crash — fixed in **consensus v1.36.12**:
|
||||
|
||||
- *Producer.* `acceptWithCertCore` releases `t.mu` across every VM call-out, then steers
|
||||
the VM with its STALE local `blockID`. A finalize that completed in that window has
|
||||
already advanced the ledger AND the EVM to `blockID+1`, so the steer is BACKWARDS and
|
||||
the EVM's accepted-irreversibility guard (`evm/core/blockchain.go:1987`,
|
||||
`commonBlock < lastAccepted`) correctly refuses it. **That guard is right and is
|
||||
untouched.** Now steers at the live build anchor (`PreferredBuildTip` →
|
||||
`ledger.BuildAnchor`), which the accept ordering (ApplyCert BEFORE VM.Accept) keeps at
|
||||
or above the VM's own accepted head. Same value the build path already uses.
|
||||
- *Classifier.* `reconcileVMToCertified` asked only "is the head the ledger's certified
|
||||
canonical at ITS OWN height?" — trivially true for every healthy node whose head is
|
||||
certified — and called that a two-blocks-at-one-height double-finalization. It never
|
||||
established that `certified` was at that same height. The ledger holds one canonical
|
||||
per height along one contiguous chain, so when both are certified at their own heights
|
||||
they lie on that one chain and the head merely DESCENDS from the target: nothing is
|
||||
orphaned. **The fail-closed halt is not weakened** — it now fires on the state that is
|
||||
actually unsafe (steering off a certified head onto a block our own ledger does not
|
||||
certify at its height).
|
||||
|
||||
Direction: NEITHER roll the head back NOR certify forward. The VM head is legitimately
|
||||
ahead and already CONTAINS the certified block; `FinalityLedger.BuildAnchor` already
|
||||
documents `head > certified` as the designed state, and rolling back is exactly what
|
||||
`blockchain.go:1987` exists to refuse. The correct action is no action — plus not issuing
|
||||
the backwards steer at all.
|
||||
|
||||
**P0-2 — the EVM plugin process dies and never comes back.** devnet luxd-1:
|
||||
|
||||
```
|
||||
fatal error: concurrent map read and map write
|
||||
vm/components/chain.(*State).getCachedBlock state.go:216
|
||||
vm/components/chain.(*State).ParseBlock state.go:267
|
||||
evm/plugin/evm.(*VM).ParseBlock vm.go:340
|
||||
vm/rpc.(*zapVMServer).handleParseBlock vm_server_zap.go:477
|
||||
```
|
||||
|
||||
`chain.State` was written against avalanchego's contract that the consensus engine holds
|
||||
the chain lock across every VM call. The ZAP VM server does NOT reinstate it — it
|
||||
dispatches ParseBlock/GetBlock and the Verify/Accept/Reject wrappers concurrently.
|
||||
`verifiedBlocks` (plain map) and `lastAcceptedBlock` (pointer) are the only State that is
|
||||
not self-synchronising; every `cache.Cacher` carries its own mutex. Fixed in **vm v1.3.3**
|
||||
by giving State one RWMutex for exactly those two, never held across a call into the
|
||||
inner VM. A Go map fatal is unrecoverable: it kills the plugin, **luxd survives and keeps
|
||||
answering `info.getNodeVersion` while its chain is gone** — pod-Ready and `/v1/health`
|
||||
both stay green — and there is no self-heal.
|
||||
|
||||
**Devnet roll result (10:23–10:45Z).** All five pods on v1.36.35, `restarts=0` on every one
|
||||
for 20+ minutes. Both fixed defects are GONE fleet-wide: `CONSENSUS-CERTIFIED` fatal = 0
|
||||
(previously all five died within minutes), `concurrent map` = 0. luxd-1, whose C-Chain had
|
||||
been dead ~45 min with no self-heal, came back at tip parity on first boot; luxd-3, stuck at
|
||||
1524, caught up immediately. A transaction of ours got a receipt with identical status,
|
||||
blockHash and resulting balance on all five nodes (`0xcb5e9a06…`, block 1636, status 0x1).
|
||||
|
||||
**NOT accepted — a THIRD, pre-existing defect blocks five-way parity.** Two nodes (luxd-2 at
|
||||
1789, luxd-4 at 1825) freeze their tip while 0/1/3 advance in lockstep, emitting
|
||||
|
||||
```
|
||||
error unexpected build block failure error="not found"
|
||||
reason="failed to fetch preferred block; no distinct last-accepted fallback"
|
||||
```
|
||||
|
||||
from `vms/proposervm/vm.go:380`. That branch is reached when the preferred block is
|
||||
unfetchable AND last-accepted is either unreadable or the same id — a local storage/index
|
||||
condition, not a steering choice. It is **NOT a regression from this release**: the identical
|
||||
line appears on binaries built long before it — mainnet luxd-1 on **v1.36.2** (2,223
|
||||
occurrences, and that is the one mainnet node still producing) and testnet luxd-3 on
|
||||
**v1.36.24** (35,693). It is also survivable: the stalled node keeps VOTING, so quorum holds
|
||||
and the chain keeps finalizing (devnet reached 1913 with two nodes frozen), and devnet luxd-1
|
||||
self-recovered from it once (1609 → 1634). Left unfixed and unchained-to — it needs its own
|
||||
diagnosis at the proposervm layer.
|
||||
|
||||
**Quorum arithmetic (reported, not changed).** Devnet and testnet both run
|
||||
`--consensus-sample-size=5 --consensus-quorum-size=4`. With only 3 live C-Chains the
|
||||
quorum is arithmetically unreachable; devnet demonstrated both directions in one session
|
||||
(pinned at 1378 on 3 live, 1378→1395→1396 the moment luxd-4 restored a 4th). This is a
|
||||
config value, not a code defect, and lowering it lowers the safety margin — left as is.
|
||||
|
||||
### Testnet 96368 roll (15:32–16:00Z) — accepted, after two blockers the devnet roll never hit
|
||||
|
||||
Testnet was frozen at **1779** with only three live C-Chains, below `--consensus-quorum-size=4`
|
||||
(`ceil(2·5/3)+1`), so no block could be accepted. luxd-1 and luxd-4 had no C-Chain at all —
|
||||
`/v1/bc/C/rpc` 404 — on `failed to repair accepted chain by height: proposervm finality index
|
||||
(height 1453 / 1463) is BEHIND the inner VM tip (height 1491)`. v1.36.35 turns that fatal into
|
||||
a repair, and it worked on the first boot of each: `proposervm finality index REBUILT from the
|
||||
local block store — index and inner tip agree fromHeight=1453 toHeight=1491`. The freeze broke
|
||||
the instant a **fourth** C-Chain came up. Final: five nodes on v1.36.35, every binary
|
||||
self-reporting `luxd/1.36.35`, `1779 → 1888` and climbing, tips in exact agreement, and one
|
||||
real transaction (`0x333d5bbd…`, block `0x728`) returning `status=0x1` with **identical
|
||||
blockHash `0x07fd0c68…` and `gasUsed=0x5208` on all five nodes**. α was NOT lowered.
|
||||
|
||||
Two defects had to be fixed first. Both are invisible on devnet and both apply to any fleet.
|
||||
|
||||
**1. The RLP startup import is fatal on re-run — it kills the C-Chain on EVERY boot.**
|
||||
Testnet's startup script passes `--import-chain-data` on every boot, relying on
|
||||
`isNothingToImportError` to make re-importing an already-imported chain a no-op. That guard
|
||||
only ever existed on `luxfi/evm` `hotfix/v1.104.9` (commit `c58d307e`, tags
|
||||
`v1.104.9-hotfix.2/3/4`); `git merge-base --is-ancestor c58d307e main` = **false**. Only the
|
||||
*other* half of that commit was forward-ported. So images built from evm main die with
|
||||
`startup import failed: no blocks imported (parsed=0)`. Proven from the two plugin binaries
|
||||
in-cluster, with a positive control:
|
||||
|
||||
| string in `plugins/mgj786NP7…` | v1.36.24 | v1.36.35 |
|
||||
|---|---|---|
|
||||
| `ImportChain: resuming from current head` | 1 | 1 ← control |
|
||||
| `nothing to import` | 1 | **0** ← the guard |
|
||||
| `no blocks imported (parsed=` | 1 | 1 |
|
||||
|
||||
Fixed **twice, on purpose**: the guard is restored in `luxfi/evm` main (with
|
||||
`startup_import_idempotency_test.go` locking the contract), and the flag is now gated on a
|
||||
per-PVC sentinel in `universe/k8s/lux-testnet/luxd-startup.yaml` — a completed one-time
|
||||
migration must not re-run forever. Sentinel pre-seeded on all five PVCs before the ConfigMap
|
||||
was patched. **Mainnet is NOT exposed**: its `luxd-startup` ConfigMap (39,719 bytes, contains
|
||||
`consensus-quorum-size` twice as a control) has zero `--import-chain-data` and no `.rlp` on disk.
|
||||
|
||||
**2. 🚨 The `luxfi/vm` map-race fix never reached the C-Chain.** node v1.36.35 bumped
|
||||
`luxfi/vm` to v1.3.3 for it, but the C-Chain is a **plugin built from `luxfi/evm`**, which
|
||||
still pinned **v1.3.1**. Read off the two binaries inside one v1.36.35 pod:
|
||||
|
||||
```
|
||||
/luxd/build/luxd github.com/luxfi/vm@v1.3.3
|
||||
/luxd/build/plugins/mgj786NP7… github.com/luxfi/vm@v1.3.1 ← verifies the blocks
|
||||
```
|
||||
|
||||
It fired on testnet luxd-0 five minutes after the roll: `fatal error: concurrent map writes`
|
||||
in `luxfi/vm@v1.3.1/components/chain/block.go:44 (*BlockWrapper).Verify` via
|
||||
`rpc/vm_server_zap.go:580 handleBlockVerify`. v1.3.3 is precisely the fix for that line — it
|
||||
takes `state.blocksLock` around `verifiedBlocks[blkID] = bw`, which v1.3.1 wrote unlocked from
|
||||
every concurrent ZAP RPC handler. `luxfi/evm` main is now on v1.3.3; **the next node image
|
||||
must be built after that bump, or this race ships again.**
|
||||
|
||||
⚠️ **The readiness probe cannot see this.** The plugin dies while luxd survives, so the pod
|
||||
stays `ready=true`, `restarts=0`, and `info.isBootstrapped(C)` keeps answering `true` while
|
||||
`eth_blockNumber` times out — a dead C-Chain still in the Service, the exact failure the probe
|
||||
was redesigned to catch. Only a per-node **tip** probe sees it. Recovery is a pod delete.
|
||||
|
||||
## v1.36.33 — the build→self-verify-fail→drop loop (devnet 96367 / testnet 96368, 2026-07-28)
|
||||
|
||||
**Symptom.** Every proposer built a block and then rejected the block it had just
|
||||
built, forever: `built block … height=1047` immediately followed by
|
||||
`built block failed verification — dropping error="inner parentID didn't match
|
||||
expected parent"`, 83–456 drops/min per node, accepted tip frozen two heights BELOW
|
||||
what the builder kept proposing (devnet tip 1045, builds 1047).
|
||||
|
||||
**Root cause (one line).** `buildChild` asked the inner VM for a block without first
|
||||
pointing the inner VM at the parent's inner block. The inner VM builds on ITS OWN
|
||||
head (`luxfi/evm` miner reads `bc.CurrentBlock()`); the verify path requires
|
||||
`child.innerBlk.Parent() == parent.innerBlk.ID()`. Two different pointers, one
|
||||
required equal to the other, nothing asserting it. The head drifts on its own:
|
||||
verifying a GOSSIPED block whose parent is the current head optimistically sets the
|
||||
head (`core/blockchain.go writeBlockAndSetHead → newTip → writeCanonicalBlockWithLogs`),
|
||||
and `VM.SetPreference` short-circuits on an unchanged outer preference so it never
|
||||
re-pushes the inner preference to drag the head back. Non-self-correcting by
|
||||
construction.
|
||||
|
||||
**Fix.** `VM.anchorInnerBuildParent` (vms/proposervm/vm.go) — one inner
|
||||
`SetPreference` at the point of use, called from BOTH build delegations
|
||||
(`postForkCommonComponents.buildChild`, `preForkBlock.buildChild`). On a healthy node
|
||||
the inner `setPreference` early-returns on `current.Hash() == block.Hash()`, so it is
|
||||
a lookup and no state change; when it fails, the head is provably not the parent's
|
||||
inner block, so refusing to build beats emitting a block we would drop.
|
||||
Regression proof: `vms/proposervm/build_inner_parent_test.go` (models the three evm
|
||||
head semantics — build-on-head, verify-advances-head, SetPreference-reorgs-head).
|
||||
|
||||
**NOT the cause, measured:** the P-Chain. `info.isBootstrapped{"chain":"P"}` = true on
|
||||
15/15 nodes and `platform.getHeight` = 0 on 15/15 **including mainnet**, whose built
|
||||
blocks also carry `pChainHeight=0`. `bootstrapped.message:["111…LpoYY"]` is the
|
||||
primary-network NET id (`chains/chains.go Nets.Bootstrapping` keys by net), not the
|
||||
P-Chain — the P-Chain's chain id prints `111…P` (`constants.PlatformChainID =
|
||||
ids.PChainID`, while `PrimaryNetworkID = ids.Empty`). The verify path DOES read
|
||||
pChainHeight before the parent check, but only as monotonicity
|
||||
(`childPChainHeight < parentPChainHeight`, and 0 < 0 is false, so it passes); every
|
||||
P-Chain-DEPENDENT validation — epoch, `GetCurrentHeight`, proposer window — is gated
|
||||
behind `consensusState == Ready` and sits AFTER the parent check. So `pChainHeight=0`
|
||||
cannot produce `errInnerParentMismatch`.
|
||||
|
||||
**Sibling failure modes on the same fleets (already fixed in 1.36.32, needs the roll):**
|
||||
outer index BEHIND the inner tip ⇒ `refusing to build`/boot repair
|
||||
(`height_backfill.go`, 7d2f01eb), and preferred-absent-locally ⇒ last-accepted
|
||||
fallback (`vm.go BuildBlock`). All three are the same invariant seen from three sides.
|
||||
|
||||
**Roll surface — measured 2026-07-28T07:5xZ, do not use the LuxNetwork CR.**
|
||||
`lux-operator` and `lux-operator-devnet` are **0/0** in `lux-system`, so nothing
|
||||
reconciles `luxnetworks.lux.cloud/luxd`; its tags are stale garbage (mainnet
|
||||
`v1.34.0`, testnet `v1.32.12` — v1.34.0 exists in no registry). The live image is on
|
||||
the **StatefulSet**, hand-maintained: mainnet `ghcr.io/luxfi/node:v1.36.2`
|
||||
(`kubectl-patch` 07-25T00:27:04Z), devnet `v1.36.25@sha256:ca497eff…`, testnet
|
||||
`v1.36.24@sha256:91e2542b…`, all three `updateStrategy: OnDelete`. Change the image in
|
||||
the universe manifest and apply, then delete one pod. Editing the CR and deleting a pod
|
||||
reboots it on the OLD image.
|
||||
|
||||
**Mainnet 96369 is NOT a clean control — 3 of 5 nodes have Mode B, invisibly.**
|
||||
luxd-0/3/4 `eth_blockNumber` = `0x10c1cf` (1098191, block ts 2026-07-24T15:46:19Z) and
|
||||
have not moved in 3+ days while luxd-1 mines (1098341, tx status 0x1, 07:58Z); luxd-2
|
||||
serves 404 (no C-Chain). Not a fork — 1098191 hashes identically on luxd-0 and luxd-1.
|
||||
3998 of luxd-0's last 4000 log lines are the same `built block … height=1098196`. The
|
||||
drop line is absent because the **binary** lacks it: `grep -c "built block failed
|
||||
verification" /luxd/build/luxd` = **0** on mainnet v1.36.2, **1** on devnet v1.36.25.
|
||||
`/v1/health` says `{"healthy":true,"error":"health reply encode failed"}` on the frozen
|
||||
node and the mining node alike — never gate on it, use tip parity.
|
||||
Two hazards for the roll: the 4 broken mainnet pods are exactly the ones on
|
||||
ControllerRevision `luxd-596857c9d6` (rev 147, kubectl-patch 07-25: GOMEMLIMIT=6GiB,
|
||||
mem limit 16Gi→12Gi, request 4Gi→7Gi) and the one mining node, luxd-1, is the only pod
|
||||
still on rev 144 — **deleting luxd-1 recreates it on the revision every other node
|
||||
broke on**. And `ghcr.io/luxfi/node:v1.36.2` has no git tag at all
|
||||
(`git ls-remote origin refs/tags/v1.36.2` → empty; v1.36.24/25 → present), so what
|
||||
mainnet runs is not reproducible from this repo.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Release & build (canonical) — via platform.hanzo.ai, NOT GitHub Actions
|
||||
@@ -935,4 +1204,14 @@ v2 semantic differences worth knowing (these change wire shape):
|
||||
|
||||
---
|
||||
|
||||
## Housekeeping
|
||||
|
||||
Removed 6 generated write-ups / stale root artifacts (`LAUNCH_CHECKLIST.md`,
|
||||
`rename_app.sh`, `replace_imports.sh`, `gen_zoo_addr` binary, `.ci-status-check.md`,
|
||||
`.ci-trigger`) plus the 73MB `.claude/worktrees/` agent scratch tree. Release and
|
||||
launch state live in this file, `CHANGELOG.md`, `RELEASE.md`; chain IDs/ports in
|
||||
`~/work/lux/universe/NETWORKS.yaml` and `~/work/lux/genesis/configs/`.
|
||||
|
||||
---
|
||||
|
||||
*Last Updated*: 2026-06-06
|
||||
|
||||
@@ -7,8 +7,15 @@ CGO_ENABLED ?= 1
|
||||
FIPS_STRICT ?= 0
|
||||
|
||||
# Go 1.26 experimental features:
|
||||
# runtimesecret - zeroes stack/register state after secret.Do() for forward secrecy
|
||||
# runtimesecret - zeroes stack/register state after secret.Do() for forward secrecy.
|
||||
# It SIGSEGVs at startup under the WSL2 kernel (confirmed on go1.26.3 and go1.26.4), so enable
|
||||
# it only off-WSL; forward secrecy stays on for real Linux/macOS/production builds.
|
||||
WSL := $(shell grep -qiE 'microsoft|WSL' /proc/sys/kernel/osrelease 2>/dev/null && echo 1)
|
||||
ifeq ($(WSL),1)
|
||||
GOEXPERIMENT ?= none
|
||||
else
|
||||
GOEXPERIMENT ?= runtimesecret
|
||||
endif
|
||||
export GOEXPERIMENT
|
||||
|
||||
# FIPS 140-3 always enabled (required for blockchain/financial systems)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package chains
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
version "github.com/luxfi/version"
|
||||
"github.com/luxfi/vm/chain"
|
||||
)
|
||||
|
||||
// recordingConnector is a chain.ChainVM that records the version it is handed on
|
||||
// Connected. Every other method comes from the embedded (nil) interface and is
|
||||
// never invoked by the connect path.
|
||||
type recordingConnector struct {
|
||||
chain.ChainVM
|
||||
|
||||
mu sync.Mutex
|
||||
called bool
|
||||
gotVersion *version.Application
|
||||
}
|
||||
|
||||
func (c *recordingConnector) Connected(_ context.Context, _ ids.NodeID, nodeVersion *chain.VersionInfo) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.called = true
|
||||
c.gotVersion = nodeVersion
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *recordingConnector) Disconnected(context.Context, ids.NodeID) error { return nil }
|
||||
|
||||
// TestBlockHandlerConnectedWithVersionDeliversRealVersion is the regression
|
||||
// guard for RED CRITICAL #1 (C-Chain nil-version panic).
|
||||
//
|
||||
// Before the fix blockHandler.Connected forwarded connector.Connected(ctx,
|
||||
// nodeID, nil) — a hardcoded nil version. proposervm promotes Connected to the
|
||||
// inner C-Chain VM (coreth), whose state-sync peer tracker compares peer
|
||||
// versions; a nil version there dereferences nil (version.Application.Compare)
|
||||
// and PANICS a state-syncing node (a fresh join, or a validator rejoining after
|
||||
// falling behind — the launch's core invariant).
|
||||
//
|
||||
// The fix plumbs the REAL peer version through ConnectedWithVersion to the
|
||||
// connector. This test drives the real blockHandler with a real version and
|
||||
// asserts the connector receives that non-nil version — never nil.
|
||||
func TestBlockHandlerConnectedWithVersionDeliversRealVersion(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
rc := &recordingConnector{}
|
||||
bh := newBlockHandler(
|
||||
nil, // BlockBuilder — unused by the connect path
|
||||
rc, // connector under test
|
||||
log.Noop(), // logger
|
||||
nil, // engine
|
||||
nil, // net
|
||||
nil, // msgCreator
|
||||
ids.Empty, // chainID
|
||||
ids.Empty, // networkID
|
||||
nil, // beacons
|
||||
ids.NodeID{}, // selfNodeID
|
||||
false, // expectsStakedBeacons
|
||||
)
|
||||
|
||||
nodeID := ids.GenerateTestNodeID()
|
||||
peerVersion := &version.Application{Name: "lux", Major: 1, Minor: 36, Patch: 27}
|
||||
|
||||
require.NoError(bh.ConnectedWithVersion(context.Background(), nodeID, peerVersion))
|
||||
|
||||
rc.mu.Lock()
|
||||
defer rc.mu.Unlock()
|
||||
require.True(rc.called, "connector.Connected must be invoked")
|
||||
require.NotNil(rc.gotVersion, "connector must receive a NON-nil version (coreth dereferences it in state-sync)")
|
||||
require.Equal("lux", rc.gotVersion.Name)
|
||||
require.Equal(1, rc.gotVersion.Major)
|
||||
require.Equal(36, rc.gotVersion.Minor)
|
||||
require.Equal(27, rc.gotVersion.Patch)
|
||||
}
|
||||
|
||||
// TestBlockHandlerConnectedDedupsButStillCarriesVersion confirms the version
|
||||
// survives the once-only dedup: the first (versioned) dispatch reaches the
|
||||
// connector; a duplicate is a no-op (not a nil-version overwrite).
|
||||
func TestBlockHandlerConnectedDedupsButStillCarriesVersion(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
rc := &recordingConnector{}
|
||||
bh := newBlockHandler(nil, rc, log.Noop(), nil, nil, nil, ids.Empty, ids.Empty, nil, ids.NodeID{}, false)
|
||||
|
||||
nodeID := ids.GenerateTestNodeID()
|
||||
peerVersion := &version.Application{Name: "lux", Major: 1, Minor: 36, Patch: 27}
|
||||
|
||||
require.NoError(bh.ConnectedWithVersion(context.Background(), nodeID, peerVersion))
|
||||
// A duplicate connect (e.g. dispatched again per tracked network) must be a
|
||||
// no-op — never a second call that could clobber the stored version with nil.
|
||||
require.NoError(bh.ConnectedWithVersion(context.Background(), nodeID, nil))
|
||||
|
||||
rc.mu.Lock()
|
||||
defer rc.mu.Unlock()
|
||||
require.NotNil(rc.gotVersion, "the deduped duplicate must not overwrite the real version with nil")
|
||||
require.Equal(27, rc.gotVersion.Patch)
|
||||
}
|
||||
@@ -57,14 +57,28 @@ const (
|
||||
// tip, so a single (even >⅔-stake) validator cannot alone determine the frontier.
|
||||
// Capped at the beacon-set size for tiny networks.
|
||||
bootstrapMinAgreeingBeacons = 2
|
||||
// bootstrapNamingWindow bounds the ancestry the ANCESTOR-TOLERANT frontier tally fetches
|
||||
// per candidate anchor to resolve the bleeding-edge skew between honest beacons. On a live
|
||||
// chain that skew is tiny (the canary was ONE block: 2 producers at N, 1 at N+1), so a
|
||||
// single 256-block window covers it with enormous margin. A ⅔-common height more than this
|
||||
// far below the highest tip is not a healthy bleeding-edge split — the tally names nothing
|
||||
// (the loop then retries / fails safe), never a wrong block. Matches the consensus descent's
|
||||
// per-round fetch size.
|
||||
// bootstrapNamingWindow is the PER-FETCH ancestry chunk of the ANCESTOR-TOLERANT frontier
|
||||
// tally. Ancestry returns FULL BLOCKS, so one fetch must stay inside a network message —
|
||||
// this is a TRANSPORT bound, matching the consensus descent's per-round fetch size.
|
||||
//
|
||||
// It was previously ALSO doing two other jobs: the total ancestry budget, and a HEALTH
|
||||
// HEURISTIC ("a ⅔-common height more than this far below the highest tip is not a healthy
|
||||
// bleeding-edge split"). That heuristic is true for a LIVE chain and FALSE for a HALTED
|
||||
// one, where the skew between a straggler and a node that ran on alone is arbitrarily large
|
||||
// and perfectly healthy — no fork, just a stopped chain. Conflating the three WEDGED
|
||||
// mainnet 96369: responders were split 1098726 (1 node) / 1098191 (2 nodes), 1098191 IS an
|
||||
// ancestor of 1098726, and the ⅔-of-responder floor would have named it — but the gap was
|
||||
// 535 blocks, the single 256-block fetch never reached down far enough for the high tip to
|
||||
// vouch for it, and the tally named nothing on every retry, forever. Recovery from a halt
|
||||
// is exactly when this path matters most, and it was disabled precisely then.
|
||||
// The total budget is now maxNamingDepth; the health heuristic is gone.
|
||||
bootstrapNamingWindow = 256
|
||||
// bootstrapMaxNamingDepth is the TOTAL ancestry one anchor may be walked down, in
|
||||
// bootstrapNamingWindow-sized chunks. Purely a resource bound (with maxNamingAnchors and
|
||||
// bootstrapNamingTimeout): 32768 covers ~9h of 1s blocks of halt-skew, and the common
|
||||
// healthy case never fetches at all — a single tip clearing the floor takes the exact fast
|
||||
// path with zero fetches. Safety does NOT come from this number; see maxNamingDepth().
|
||||
bootstrapMaxNamingDepth = 32768
|
||||
// maxNamingAnchors bounds how many DISTINCT reported tips the ancestor-tolerant tally will
|
||||
// fetch ancestry for in one round. Honest beacons cluster on a handful of adjacent tips, so
|
||||
// this is never reached in practice; it caps the work a Byzantine swarm reporting many
|
||||
|
||||
+52
-11
@@ -243,10 +243,13 @@ type BootstrapPolicy struct {
|
||||
// CheckpointVerifier authenticates the Checkpoint's authority signature (INVARIANT 4). nil ⇒ a
|
||||
// configured Checkpoint is NOT trusted (fail closed) — a bare (id,height) is never enough.
|
||||
CheckpointVerifier CheckpointVerifier
|
||||
// NamingWindow bounds the ancestry fetched per anchor; MaxAnchors bounds how many distinct
|
||||
// reported tips are resolved. Both default to the package constants when zero.
|
||||
NamingWindow int
|
||||
MaxAnchors int
|
||||
// NamingWindow is the per-FETCH ancestry chunk (Ancestry returns FULL blocks, so one fetch
|
||||
// must stay inside a network message); MaxNamingDepth is the TOTAL ancestry a single anchor
|
||||
// may be walked down in NamingWindow-sized chunks; MaxAnchors bounds how many distinct
|
||||
// reported tips are resolved. All default to the package constants when zero.
|
||||
NamingWindow int
|
||||
MaxNamingDepth int
|
||||
MaxAnchors int
|
||||
// NamingTimeout TOTAL-bounds the ancestor-tolerant resolution (all anchor fetches combined) so
|
||||
// a partition that ANSWERS the frontier query but WITHHOLDS ancestry cannot make the decision
|
||||
// hang — it returns what it found (or nothing → ErrNoBootstrapQuorum) and the caller's bounded
|
||||
@@ -299,6 +302,17 @@ func (p *BootstrapPolicy) namingWindow() int {
|
||||
return bootstrapNamingWindow
|
||||
}
|
||||
|
||||
// maxNamingDepth is the TOTAL ancestry one anchor may be walked down, in namingWindow-sized
|
||||
// chunks. It is a RESOURCE bound and nothing else. Safety comes from hash-verified ancestry
|
||||
// (a forged chain cannot link), MinResponders distinct voters, the ⅔-of-RESPONDER-stake floor,
|
||||
// and the full re-Verify every block gets on the descent — none of which depend on this number.
|
||||
func (p *BootstrapPolicy) maxNamingDepth() int {
|
||||
if p.MaxNamingDepth > 0 {
|
||||
return p.MaxNamingDepth
|
||||
}
|
||||
return bootstrapMaxNamingDepth
|
||||
}
|
||||
|
||||
func (p *BootstrapPolicy) maxAnchors() int {
|
||||
if p.MaxAnchors > 0 {
|
||||
return p.MaxAnchors
|
||||
@@ -505,14 +519,41 @@ func (p *BootstrapPolicy) nameFrontier(ctx context.Context, stakeOnTip map[ids.I
|
||||
break
|
||||
}
|
||||
fetches++
|
||||
refs, err := p.Source.Ancestry(ctx, tip, p.namingWindow())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, ref := range refs {
|
||||
if _, ok := index[ref.ID]; !ok {
|
||||
index[ref.ID] = ref
|
||||
// CHUNKED DESCENT. namingWindow is the per-FETCH size (Ancestry returns FULL blocks, so
|
||||
// one fetch must fit a network message) — NOT the total ancestry worth walking. Keep
|
||||
// following the parent chain in window-sized chunks up to maxNamingDepth, so a fleet
|
||||
// that HALTED with a straggler far below the highest tip still resolves its ⅔-backed
|
||||
// common ancestor. A single un-chunked window silently capped this at 256 and wedged
|
||||
// mainnet at a 535-block gap. ctx already TOTAL-bounds every fetch (namingTimeout).
|
||||
cur := tip
|
||||
for depth := 0; depth < p.maxNamingDepth(); {
|
||||
// The deadline must bind the WALK, not just each fetch: a Byzantine peer serving a
|
||||
// long fabricated chain cheaply (or an in-process Source) would otherwise run the
|
||||
// full depth budget before anyone checked the clock.
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
refs, err := p.Source.Ancestry(ctx, cur, p.namingWindow())
|
||||
if err != nil || len(refs) == 0 {
|
||||
break
|
||||
}
|
||||
deepest, first := BlockRef{}, true
|
||||
for _, ref := range refs {
|
||||
if _, ok := index[ref.ID]; !ok {
|
||||
index[ref.ID] = ref
|
||||
}
|
||||
if first || ref.Height < deepest.Height {
|
||||
deepest, first = ref, false
|
||||
}
|
||||
}
|
||||
depth += len(refs) // len(refs) >= 1 here, so depth strictly advances -> terminates
|
||||
if deepest.Parent == ids.Empty {
|
||||
break // reached genesis
|
||||
}
|
||||
if _, covered := index[deepest.Parent]; covered {
|
||||
break // an earlier anchor's chain already covers everything below
|
||||
}
|
||||
cur = deepest.Parent
|
||||
}
|
||||
}
|
||||
if len(index) == 0 {
|
||||
|
||||
@@ -916,3 +916,175 @@ func TestBootstrapTrust_EclipseOwnHeightNotNamedRoutesToCaughtUp(t *testing.T) {
|
||||
require.Equal(t, refs[N].ID, f.ID, "boundary: N is named iff its height is STRICTLY ABOVE MinFrontierHeight")
|
||||
require.Equal(t, uint64(N), f.Height)
|
||||
}
|
||||
|
||||
// ----- H: HALT-SKEW RECOVERY (mainnet 96369 wedge) ---------------------------
|
||||
|
||||
// TestBootstrapTrust_H_HaltSkewDeeperThanOneWindow reproduces the live mainnet 96369 wedge and
|
||||
// proves the chunked descent fixes it.
|
||||
//
|
||||
// SHAPE (measured per-node, in-pod, 2026-07-28): the fleet HALTED below its α=4-of-5 threshold.
|
||||
// One node ran on alone to 1098726 while two stayed at 1098191; 1098191 IS an ancestor of
|
||||
// 1098726 (no fork — luxd-3/luxd-4's `latest` IS 1098191, they hold nothing competing), so the
|
||||
// ⅔-of-RESPONDER floor is satisfied at 1098191 by all three responders and it MUST be named.
|
||||
//
|
||||
// It was not. nameFrontier fetched one bootstrapNamingWindow (256) of ancestry per anchor, and
|
||||
// the gap is 535 — so the high tip's ancestry never reached down far enough to vouch for the
|
||||
// common block. 1098191 held only its 2 direct responders (2 of 3 = below the ⅔ floor of 2,
|
||||
// which the strict `>` rejects), the tally named NOTHING, and every retry did the same. The
|
||||
// node sat at height 0 forever and mainnet could not regain quorum.
|
||||
//
|
||||
// The gap here (535) is deliberately > one window (256) and < maxNamingDepth. With the
|
||||
// single-fetch window this FAILS (frontier is nil → ErrNoBootstrapQuorum); with the chunked
|
||||
// descent the walk continues past the first chunk and names the common ancestor.
|
||||
func TestBootstrapTrust_H_HaltSkewDeeperThanOneWindow(t *testing.T) {
|
||||
const w uint64 = 100
|
||||
const gap = 535 // luxd-1 1098726 - luxd-3/luxd-4 1098191, the measured mainnet skew
|
||||
|
||||
// common is the last block the whole fleet accepted; `ahead` extends it by `gap`.
|
||||
refs, byID := refChain(1000)
|
||||
common := refs[1000]
|
||||
prev := common
|
||||
for i := 0; i < gap; i++ {
|
||||
c := childRef(prev)
|
||||
byID[c.ID] = c
|
||||
prev = c
|
||||
}
|
||||
ahead := prev
|
||||
require.Equal(t, common.Height+gap, ahead.Height)
|
||||
require.Greater(t, gap, bootstrapNamingWindow, "gap MUST exceed one fetch window or this proves nothing")
|
||||
require.Less(t, gap, bootstrapMaxNamingDepth, "gap must stay inside the total depth budget")
|
||||
|
||||
beacons := nodeIDs(3) // the three responders with a live C-Chain
|
||||
policy := &BootstrapPolicy{
|
||||
TrustedBeacons: equalBeacons(beacons, w),
|
||||
MinResponses: 3,
|
||||
Source: &stubAncestry{byID: byID},
|
||||
}
|
||||
replies := []BeaconReply{
|
||||
reply(beacons[0], ahead.ID, w), // luxd-1, ran on alone
|
||||
reply(beacons[1], common.ID, w), // luxd-3
|
||||
reply(beacons[2], common.ID, w), // luxd-4
|
||||
}
|
||||
|
||||
f, err := policy.AcceptsFrontier(context.Background(), replies)
|
||||
require.NoError(t, err, "a fleet split across ONE chain must name a frontier, not wedge")
|
||||
require.NotNil(t, f)
|
||||
require.Equal(t, common.ID, f.ID,
|
||||
"must name the ⅔-backed COMMON ancestor: the high tip vouches for it via ancestry")
|
||||
require.Equal(t, common.Height, f.Height)
|
||||
}
|
||||
|
||||
// TestBootstrapTrust_H_HaltSkewBeyondDepthStillFailsSafe pins the resource bound's edge: a skew
|
||||
// DEEPER than maxNamingDepth still names nothing rather than guessing. Depth is a work bound, so
|
||||
// exhausting it must degrade to the SAME fail-safe as before, never to a wrong block.
|
||||
func TestBootstrapTrust_H_HaltSkewBeyondDepthStillFailsSafe(t *testing.T) {
|
||||
const w uint64 = 100
|
||||
refs, byID := refChain(10)
|
||||
common := refs[10]
|
||||
prev := common
|
||||
for i := 0; i < 64; i++ {
|
||||
c := childRef(prev)
|
||||
byID[c.ID] = c
|
||||
prev = c
|
||||
}
|
||||
ahead := prev
|
||||
|
||||
beacons := nodeIDs(3)
|
||||
policy := &BootstrapPolicy{
|
||||
TrustedBeacons: equalBeacons(beacons, w),
|
||||
MinResponses: 3,
|
||||
NamingWindow: 4, // tiny chunk...
|
||||
MaxNamingDepth: 8, // ...and a depth budget far shallower than the 64-block skew
|
||||
Source: &stubAncestry{byID: byID},
|
||||
}
|
||||
_, err := policy.AcceptsFrontier(context.Background(), []BeaconReply{
|
||||
reply(beacons[0], ahead.ID, w),
|
||||
reply(beacons[1], common.ID, w),
|
||||
reply(beacons[2], common.ID, w),
|
||||
})
|
||||
require.Error(t, err, "a skew beyond the depth budget must fail SAFE, never name a guess")
|
||||
}
|
||||
|
||||
// TestBootstrapTrust_H_AcceptanceMatrix pins the owner-specified recovery matrix at the policy
|
||||
// layer. Each case is the SAME five-validator mainnet shape with a different responder pattern.
|
||||
//
|
||||
// The rule being pinned is "highest verifiably-vouched descendant wins", NOT "numerically highest
|
||||
// tip advertised". A tip whose ancestry does not link into the fleet's chain earns NO credit no
|
||||
// matter how high it claims to be, so a Byzantine node cannot pull the fleet onto a fabricated
|
||||
// chain by advertising a tall one.
|
||||
func TestBootstrapTrust_H_AcceptanceMatrix(t *testing.T) {
|
||||
const w uint64 = 100
|
||||
mk := func() (BlockRef, BlockRef, map[ids.ID]BlockRef) {
|
||||
refs, byID := refChain(600)
|
||||
common := refs[600]
|
||||
prev := common
|
||||
for i := 0; i < 535; i++ { // the measured mainnet skew, > one 256 window
|
||||
c := childRef(prev)
|
||||
byID[c.ID] = c
|
||||
prev = c
|
||||
}
|
||||
return common, prev, byID
|
||||
}
|
||||
|
||||
t.Run("1_high_2_low_2_unavailable__names_common_ancestor", func(t *testing.T) {
|
||||
common, ahead, byID := mk()
|
||||
b := nodeIDs(3)
|
||||
p := &BootstrapPolicy{TrustedBeacons: equalBeacons(b, w), MinResponses: 3, Source: &stubAncestry{byID: byID}}
|
||||
f, err := p.AcceptsFrontier(context.Background(), []BeaconReply{
|
||||
reply(b[0], ahead.ID, w), reply(b[1], common.ID, w), reply(b[2], common.ID, w)})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, common.ID, f.ID)
|
||||
})
|
||||
|
||||
t.Run("3_high_2_unavailable__names_high_tip", func(t *testing.T) {
|
||||
_, ahead, byID := mk()
|
||||
b := nodeIDs(3)
|
||||
p := &BootstrapPolicy{TrustedBeacons: equalBeacons(b, w), MinResponses: 3, Source: &stubAncestry{byID: byID}}
|
||||
f, err := p.AcceptsFrontier(context.Background(), []BeaconReply{
|
||||
reply(b[0], ahead.ID, w), reply(b[1], ahead.ID, w), reply(b[2], ahead.ID, w)})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ahead.ID, f.ID, "unanimous high tip must be named, not an ancestor")
|
||||
})
|
||||
|
||||
t.Run("fabricated_tall_tip_2_low__rejects_fake_names_common", func(t *testing.T) {
|
||||
common, _, byID := mk()
|
||||
// A Byzantine node advertises a tip on a chain of its own that never links into the
|
||||
// fleet's history. Its ancestry earns credit only for ITS OWN blocks, never for the
|
||||
// fleet's — so it cannot outvote the two honest low responders.
|
||||
fakeRefs, fakeByID := refChain(9_000_000)
|
||||
fake := fakeRefs[9_000_000]
|
||||
for id, r := range fakeByID {
|
||||
byID[id] = r
|
||||
}
|
||||
b := nodeIDs(3)
|
||||
p := &BootstrapPolicy{TrustedBeacons: equalBeacons(b, w), MinResponses: 3, Source: &stubAncestry{byID: byID}}
|
||||
f, err := p.AcceptsFrontier(context.Background(), []BeaconReply{
|
||||
reply(b[0], fake.ID, w), reply(b[1], common.ID, w), reply(b[2], common.ID, w)})
|
||||
// The fake tip earns credit ONLY on its own disjoint chain (100), far below the ⅔ floor
|
||||
// of 200, so it can never be named however tall it claims to be. `common` earns exactly
|
||||
// 200 from its two honest backers — and the floor is STRICT (`> floor`), so 200 is not
|
||||
// enough either: the Byzantine node withheld the third vouch by sitting on a chain that
|
||||
// does not link. Correct outcome is a SAFE HALT, not "fall back to the low tip".
|
||||
// This is the honest BFT trade: 1 of 3 responders can cost LIVENESS, never SAFETY.
|
||||
require.Error(t, err, "must halt safely; must NOT follow a taller unvouched chain")
|
||||
require.Nil(t, f)
|
||||
})
|
||||
|
||||
t.Run("two_conflicting_branches_same_height__halts_safely", func(t *testing.T) {
|
||||
refs, byID := refChain(600)
|
||||
common := refs[600]
|
||||
// Two disjoint branches of equal height off the common block, each backed by one node,
|
||||
// and NO responder majority on either. Nothing may be named by height tiebreak.
|
||||
l, r := childRef(common), childRef(common)
|
||||
byID[l.ID], byID[r.ID] = l, r
|
||||
b := nodeIDs(3)
|
||||
p := &BootstrapPolicy{
|
||||
TrustedBeacons: equalBeacons(b, w), MinResponses: 3,
|
||||
MinFrontierHeight: common.Height, // node already holds `common`; only a tip AHEAD may be named
|
||||
Source: &stubAncestry{byID: byID},
|
||||
}
|
||||
_, err := p.AcceptsFrontier(context.Background(), []BeaconReply{
|
||||
reply(b[0], l.ID, w), reply(b[1], r.ID, w), reply(b[2], common.ID, w)})
|
||||
require.Error(t, err, "conflicting equal-height branches must halt safely, never pick by height")
|
||||
})
|
||||
}
|
||||
|
||||
+11
-4
@@ -72,15 +72,22 @@ func (s *Nets) IsChainBootstrapped(chainID ids.ID) bool {
|
||||
|
||||
// Bootstrapping returns the chainIDs of any chains that are still
|
||||
// bootstrapping.
|
||||
//
|
||||
// s.chains is keyed by NET id, not chain id. Reporting the key named the net
|
||||
// instead of the chain: every primary-network chain that failed to converge
|
||||
// surfaced in /v1/health as the single ID
|
||||
// "11111111111111111111111111111111LpoYY" — constants.PrimaryNetworkID, i.e.
|
||||
// ids.Empty — a "chain" the chain manager has never heard of, so the operator
|
||||
// chasing it got "there is no chain with alias/ID". Worse, N stuck chains
|
||||
// collapsed into one indistinguishable entry. Ask each net which of ITS chains
|
||||
// are still bootstrapping; the net owns that set.
|
||||
func (s *Nets) Bootstrapping() []ids.ID {
|
||||
s.lock.RLock()
|
||||
defer s.lock.RUnlock()
|
||||
|
||||
chainsBootstrapping := make([]ids.ID, 0, len(s.chains))
|
||||
for chainID, chain := range s.chains {
|
||||
if !chain.IsBootstrapped() {
|
||||
chainsBootstrapping = append(chainsBootstrapping, chainID)
|
||||
}
|
||||
for _, chain := range s.chains {
|
||||
chainsBootstrapping = append(chainsBootstrapping, chain.Bootstrapping()...)
|
||||
}
|
||||
|
||||
return chainsBootstrapping
|
||||
|
||||
+44
-2
@@ -158,12 +158,54 @@ func TestNetsBootstrapping(t *testing.T) {
|
||||
chain, ok := chains.GetOrCreate(netID)
|
||||
require.True(ok)
|
||||
|
||||
// Start bootstrapping
|
||||
// Start bootstrapping. What comes back is the CHAIN that is syncing, never
|
||||
// the net that holds it — this assertion used to demand netID, which is
|
||||
// how the phantom "11111111111111111111111111111111LpoYY" survived review.
|
||||
chain.AddChain(chainID)
|
||||
bootstrapping := chains.Bootstrapping()
|
||||
require.Contains(bootstrapping, netID)
|
||||
require.Equal([]ids.ID{chainID}, bootstrapping)
|
||||
require.NotContains(bootstrapping, netID)
|
||||
|
||||
// Finish bootstrapping
|
||||
chain.Bootstrapped(chainID)
|
||||
require.Empty(chains.Bootstrapping())
|
||||
}
|
||||
|
||||
// The "bootstrapped" health check publishes Nets.Bootstrapping() verbatim as
|
||||
// its message. s.chains is keyed by NET id, so returning the key reported
|
||||
// constants.PrimaryNetworkID (ids.Empty, cb58
|
||||
// "11111111111111111111111111111111LpoYY") as though it were an unbootstrapped
|
||||
// CHAIN. Operators saw a chain ID the chain manager denies exists, and every
|
||||
// stuck chain on the net collapsed into that one phantom entry.
|
||||
func TestNetsBootstrappingReportsChainsNotNets(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
chains, err := NewNets(ids.EmptyNodeID, map[ids.ID]nets.Config{
|
||||
constants.PrimaryNetworkID: {},
|
||||
})
|
||||
require.NoError(err)
|
||||
|
||||
primary, _ := chains.GetOrCreate(constants.PrimaryNetworkID)
|
||||
cChainID := ids.GenerateTestID()
|
||||
dChainID := ids.GenerateTestID()
|
||||
primary.AddChain(cChainID)
|
||||
primary.AddChain(dChainID)
|
||||
|
||||
bootstrapping := chains.Bootstrapping()
|
||||
|
||||
// The phantom: never the net's own ID.
|
||||
require.NotContains(bootstrapping, constants.PrimaryNetworkID)
|
||||
require.NotContains(
|
||||
bootstrapping,
|
||||
ids.Empty,
|
||||
"health check reported the primary NET id as an unbootstrapped chain",
|
||||
)
|
||||
// Both stuck chains must be individually nameable.
|
||||
require.ElementsMatch([]ids.ID{cChainID, dChainID}, bootstrapping)
|
||||
|
||||
primary.Bootstrapped(cChainID)
|
||||
require.Equal([]ids.ID{dChainID}, chains.Bootstrapping())
|
||||
|
||||
primary.Bootstrapped(dChainID)
|
||||
require.Empty(chains.Bootstrapping())
|
||||
}
|
||||
|
||||
+91
-9
@@ -69,6 +69,7 @@ import (
|
||||
"github.com/luxfi/node/upgrade"
|
||||
"github.com/luxfi/node/vms"
|
||||
validators "github.com/luxfi/validators"
|
||||
version "github.com/luxfi/version"
|
||||
"github.com/luxfi/vm/fx"
|
||||
|
||||
// "github.com/luxfi/node/vms/metervm" // Temporarily disabled - needs consensus package updates
|
||||
@@ -372,8 +373,11 @@ type ManagerConfig struct {
|
||||
// ProposerWindowDuration overrides the proposervm proposer-slot spacing (0 ⇒
|
||||
// the 5s mainnet default). Small local/dev nets set it low for fast cadence.
|
||||
ProposerWindowDuration time.Duration
|
||||
StakingBLSKey bls.Signer
|
||||
TracingEnabled bool
|
||||
// ProposerMinBlockDelay overrides the proposervm minimum block delay (0 ⇒ the
|
||||
// 1s default). High-throughput / DEX nets set it low (e.g. 1ms).
|
||||
ProposerMinBlockDelay time.Duration
|
||||
StakingBLSKey bls.Signer
|
||||
TracingEnabled bool
|
||||
// Must not be used unless [TracingEnabled] is true as this may be nil.
|
||||
Tracer trace.Tracer
|
||||
Log log.Logger
|
||||
@@ -1289,10 +1293,14 @@ func (m *manager) buildChain(chainParams ChainParameters, sb nets.Net) (*chainIn
|
||||
if regErr != nil {
|
||||
return nil, fmt.Errorf("failed to register proposervm metrics for chain %s: %w", chainParams.ID, regErr)
|
||||
}
|
||||
minBlkDelay := proposervm.DefaultMinBlockDelay
|
||||
if m.ProposerMinBlockDelay > 0 {
|
||||
minBlkDelay = m.ProposerMinBlockDelay
|
||||
}
|
||||
engineVM = proposervm.New(vmTyped, proposervm.Config{
|
||||
Upgrades: m.Upgrades,
|
||||
NetworkID: networkID, // CRITICAL-3: windower uses the SAME set ID as the cert side
|
||||
MinBlkDelay: proposervm.DefaultMinBlockDelay,
|
||||
MinBlkDelay: minBlkDelay,
|
||||
NumHistoricalBlocks: proposervm.DefaultNumHistoricalBlocks,
|
||||
StakingLeafSigner: m.StakingTLSSigner,
|
||||
StakingCertLeaf: m.StakingTLSCert,
|
||||
@@ -1305,7 +1313,7 @@ func (m *manager) buildChain(chainParams ChainParameters, sb nets.Net) (*chainIn
|
||||
log.Stringer("chainID", chainParams.ID),
|
||||
log.Int("K", consensusParams.K),
|
||||
log.Stringer("windowerNetworkID", networkID),
|
||||
log.Duration("minBlockDelay", proposervm.DefaultMinBlockDelay))
|
||||
log.Duration("minBlockDelay", minBlkDelay))
|
||||
}
|
||||
|
||||
m.Log.Info("initializing VM", log.Stringer("chainID", chainParams.ID))
|
||||
@@ -1697,7 +1705,10 @@ func (m *manager) buildChain(chainParams ChainParameters, sb nets.Net) (*chainIn
|
||||
// through (blockBuilder == the P-chain-height wrapper on K>1, the inner VM
|
||||
// on K==1), so the container bytes it parses match the bytes the engine
|
||||
// framed — one codec, no raw-vs-wrapped split.
|
||||
bh := newBlockHandler(blockBuilder, m.Log, consensusEngine, m.Net, m.MsgCreator, chainParams.ID, networkID, beacons, m.NodeID, expectsStakedBeacons)
|
||||
// engineVM is the connectable VM (the proposervm on a wrapped chain, the
|
||||
// inner VM otherwise); it carries Connected/Disconnected, which the router
|
||||
// dispatches so the P-chain uptime tracker observes validator connectivity.
|
||||
bh := newBlockHandler(blockBuilder, engineVM, m.Log, consensusEngine, m.Net, m.MsgCreator, chainParams.ID, networkID, beacons, m.NodeID, expectsStakedBeacons)
|
||||
// Gate this native chain's bootstrap frontier-TRUST on the P-chain having finished its
|
||||
// initial sync, so the staked beacon set (and thus the stake-majority floor denominator)
|
||||
// is the TRUE full validator set, not a partial mid-replay set. Wired ONLY for native
|
||||
@@ -2837,6 +2848,15 @@ type blockHandler struct {
|
||||
chainID ids.ID // Chain ID for message routing
|
||||
networkID ids.ID // Network ID for validator routing
|
||||
|
||||
// connector receives peer connect/disconnect notifications routed from the
|
||||
// node's chainRouter and forwards them to this chain's VM (e.g. the P-chain
|
||||
// uptime tracker; other VMs use them for their own peer sets). connectedNodes
|
||||
// dedups delivery so a peer the router dispatches once per tracked network is
|
||||
// forwarded to the VM exactly once. A nil connector makes forwarding a no-op.
|
||||
connector chain.ChainVM
|
||||
connMu sync.Mutex
|
||||
connectedNodes set.Set[ids.NodeID]
|
||||
|
||||
// Context sync support - when a block fails verification due to missing context,
|
||||
// we request the prerequisite blocks from the peer to catch up
|
||||
pendingContext map[ids.ID]contextRequest // Map from blockID to pending context request
|
||||
@@ -3011,9 +3031,10 @@ type contextRequest struct {
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
func newBlockHandler(vm consensuschain.BlockBuilder, logger log.Logger, engine *consensuschain.Runtime, net network.Network, msgCreator message.OutboundMsgBuilder, chainID ids.ID, networkID ids.ID, beacons validators.Manager, selfNodeID ids.NodeID, expectsStakedBeacons bool) *blockHandler {
|
||||
func newBlockHandler(vm consensuschain.BlockBuilder, connector chain.ChainVM, logger log.Logger, engine *consensuschain.Runtime, net network.Network, msgCreator message.OutboundMsgBuilder, chainID ids.ID, networkID ids.ID, beacons validators.Manager, selfNodeID ids.NodeID, expectsStakedBeacons bool) *blockHandler {
|
||||
return &blockHandler{
|
||||
vm: vm,
|
||||
connector: connector,
|
||||
logger: logger,
|
||||
engine: engine,
|
||||
net: net,
|
||||
@@ -3026,6 +3047,7 @@ func newBlockHandler(vm consensuschain.BlockBuilder, logger log.Logger, engine *
|
||||
pendingContext: make(map[ids.ID]contextRequest),
|
||||
maxContextBlocks: 256, // Default max context blocks to request/serve
|
||||
pendingQbits: make(map[ids.ID][]QbitEvent),
|
||||
connectedNodes: set.NewSet[ids.NodeID](16),
|
||||
bsAncestorCh: make(map[uint32]chan [][]byte),
|
||||
}
|
||||
}
|
||||
@@ -4044,9 +4066,69 @@ func (b *blockHandler) GetStateSummary(ctx context.Context, nodeID ids.NodeID, r
|
||||
func (b *blockHandler) StateSummary(ctx context.Context, nodeID ids.NodeID, requestID uint32, summary []byte) error {
|
||||
return nil
|
||||
}
|
||||
func (b *blockHandler) Connected(ctx context.Context, nodeID ids.NodeID) error { return nil }
|
||||
func (b *blockHandler) Disconnected(ctx context.Context, nodeID ids.NodeID) error { return nil }
|
||||
func (b *blockHandler) HealthCheck(ctx context.Context) (interface{}, error) { return nil, nil }
|
||||
|
||||
// Connected satisfies handler.Handler, whose interface carries no peer version.
|
||||
// The real delivery path is ConnectedWithVersion (chainRouter uses it via the
|
||||
// versionedConnector capability); this nil-version entry exists only for the
|
||||
// interface contract and any non-router caller.
|
||||
func (b *blockHandler) Connected(ctx context.Context, nodeID ids.NodeID) error {
|
||||
return b.connect(ctx, nodeID, nil)
|
||||
}
|
||||
|
||||
// ConnectedWithVersion forwards a peer connection WITH its real application
|
||||
// version to this chain's VM. chainRouter invokes this (detecting the
|
||||
// versionedConnector capability) so the version survives to the inner VM:
|
||||
// proposervm promotes Connected to the C-Chain (coreth), whose state-sync peer
|
||||
// tracker compares peer versions — a nil version there dereferences nil and
|
||||
// panics a state-syncing node (a fresh join OR a validator rejoining after
|
||||
// falling behind). This mirrors avalanchego, which delivers msg.NodeVersion to
|
||||
// engine.Connected.
|
||||
func (b *blockHandler) ConnectedWithVersion(ctx context.Context, nodeID ids.NodeID, nodeVersion *version.Application) error {
|
||||
return b.connect(ctx, nodeID, nodeVersion)
|
||||
}
|
||||
|
||||
// connect forwards a peer connection to this chain's VM exactly once, carrying
|
||||
// nodeVersion (nil only on the interface path). The P-chain VM records it in its
|
||||
// uptime tracker; the C-Chain/X-Chain VMs store the version for their peer sets.
|
||||
// A nil connector makes this a no-op. On a forwarding error the dedup entry is
|
||||
// rolled back so a subsequent dispatch of the same connection retries.
|
||||
func (b *blockHandler) connect(ctx context.Context, nodeID ids.NodeID, nodeVersion *version.Application) error {
|
||||
if b.connector == nil {
|
||||
return nil
|
||||
}
|
||||
b.connMu.Lock()
|
||||
if b.connectedNodes.Contains(nodeID) {
|
||||
b.connMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
b.connectedNodes.Add(nodeID)
|
||||
b.connMu.Unlock()
|
||||
|
||||
if err := b.connector.Connected(ctx, nodeID, nodeVersion); err != nil {
|
||||
b.connMu.Lock()
|
||||
b.connectedNodes.Remove(nodeID)
|
||||
b.connMu.Unlock()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnected forwards a peer disconnection to this chain's VM exactly once.
|
||||
func (b *blockHandler) Disconnected(ctx context.Context, nodeID ids.NodeID) error {
|
||||
if b.connector == nil {
|
||||
return nil
|
||||
}
|
||||
b.connMu.Lock()
|
||||
if !b.connectedNodes.Contains(nodeID) {
|
||||
b.connMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
b.connectedNodes.Remove(nodeID)
|
||||
b.connMu.Unlock()
|
||||
|
||||
return b.connector.Disconnected(ctx, nodeID)
|
||||
}
|
||||
func (b *blockHandler) HealthCheck(ctx context.Context) (interface{}, error) { return nil, nil }
|
||||
func (b *blockHandler) Stop(ctx context.Context) {
|
||||
if b.pollerCancel != nil {
|
||||
b.pollerCancel()
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
# Robust RPC Handler Registration System
|
||||
|
||||
## Overview
|
||||
|
||||
This package provides a bulletproof RPC handler registration system for the Lux node, designed to handle the complexities of local development where nodes are frequently restarted. It replaces the fragile inline registration logic with a robust, maintainable solution.
|
||||
|
||||
## Key Features
|
||||
|
||||
### 🔄 Automatic Retry Logic
|
||||
- Exponential backoff for transient failures
|
||||
- Configurable retry count and wait times
|
||||
- Context-aware cancellation support
|
||||
|
||||
### ✅ Built-in Health Checks
|
||||
- Automatic validation after registration
|
||||
- Batch health checking for all chains
|
||||
- Detailed diagnostics for failures
|
||||
|
||||
### 🎯 Single Source of Truth
|
||||
- Centralized route construction logic
|
||||
- Consistent path formatting
|
||||
- No duplicate code or magic strings
|
||||
|
||||
### 🛡️ Defensive Programming
|
||||
- Nil checks on all inputs
|
||||
- Handler validation before registration
|
||||
- Graceful degradation on failures
|
||||
|
||||
### 📊 Developer-Friendly Debugging
|
||||
- Clear, actionable error messages
|
||||
- Comprehensive logging at appropriate levels
|
||||
- Built-in diagnostic tools
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Chain Manager │
|
||||
└──────────┬──────────┘
|
||||
│ Creates Chain
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ ChainHandlerRegistrar│
|
||||
└──────────┬──────────┘
|
||||
│ Extracts Handlers
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Handler Manager │
|
||||
└──────────┬──────────┘
|
||||
│ Registers with Retries
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ API Server │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Integration
|
||||
|
||||
Replace the handler registration code in `chains/manager.go` (lines 941-990) with:
|
||||
|
||||
```go
|
||||
// Create robust registrar
|
||||
registrar := rpc.NewChainHandlerRegistrar(
|
||||
m.Server,
|
||||
m.Log,
|
||||
m.CChainID,
|
||||
m.PChainID,
|
||||
)
|
||||
|
||||
// Register handlers
|
||||
if err := registrar.RegisterChainHandlers(ctx, chainParams.ID, chain.VM); err != nil {
|
||||
m.Log.Error("Failed to register handlers", log.Err(err))
|
||||
// Decide if this should be fatal or not
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```go
|
||||
// Development environment - fail fast
|
||||
registrar.SetRetryConfig(2, 50*time.Millisecond)
|
||||
|
||||
// Production environment - more robust
|
||||
registrar.SetRetryConfig(5, 200*time.Millisecond)
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
```go
|
||||
// Get route information
|
||||
info, exists := registrar.GetRouteInfo(chainID)
|
||||
if exists {
|
||||
fmt.Printf("Chain %s routes: %v\n", chainID, info.Endpoints)
|
||||
}
|
||||
|
||||
// Run health checks
|
||||
results := registrar.HealthCheckAll()
|
||||
for chainID, healthy := range results {
|
||||
fmt.Printf("Chain %s: %v\n", chainID, healthy)
|
||||
}
|
||||
|
||||
// Validate specific endpoint
|
||||
err := registrar.ValidateEndpoint(chainID, "/rpc")
|
||||
```
|
||||
|
||||
### Using the Debug Tool
|
||||
|
||||
```go
|
||||
// Quick diagnosis from CLI
|
||||
rpc.QuickDiagnose("localhost:9650", chainID, "C")
|
||||
|
||||
// Programmatic diagnosis
|
||||
tool := rpc.NewDebugTool("localhost:9650", logger)
|
||||
report := tool.DiagnoseEndpoint(chainID, "C")
|
||||
fmt.Println(report.String())
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### HandlerManager (`handler_manager.go`)
|
||||
Core registration logic with retry mechanism and health checks.
|
||||
|
||||
**Key Methods:**
|
||||
- `RegisterChainHandlers()` - Main registration entry point
|
||||
- `HealthCheckRoute()` - Validates handler responsiveness
|
||||
- `GetRouteInfo()` - Retrieves registration details
|
||||
|
||||
### ChainHandlerRegistrar (`chain_integration.go`)
|
||||
Bridge between chain manager and handler manager.
|
||||
|
||||
**Key Methods:**
|
||||
- `RegisterChainHandlers()` - Extracts and registers handlers
|
||||
- `ValidateEndpoint()` - Tests specific endpoints
|
||||
- `GetAllRoutes()` - Returns all registered routes
|
||||
|
||||
### DebugTool (`debug_tool.go`)
|
||||
Comprehensive endpoint diagnostics for developers.
|
||||
|
||||
**Key Methods:**
|
||||
- `DiagnoseEndpoint()` - Full endpoint analysis
|
||||
- `QuickDiagnose()` - CLI-friendly diagnosis
|
||||
|
||||
## Error Handling
|
||||
|
||||
The system uses clear, actionable errors:
|
||||
|
||||
```go
|
||||
errNilHandler = errors.New("handler is nil")
|
||||
errNilServer = errors.New("server is nil")
|
||||
errEmptyEndpoint = errors.New("endpoint is empty")
|
||||
errRegistrationFailed = errors.New("handler registration failed")
|
||||
errHealthCheckFailed = errors.New("health check failed")
|
||||
```
|
||||
|
||||
Each error includes context about what failed and why.
|
||||
|
||||
## Testing
|
||||
|
||||
Comprehensive test coverage including:
|
||||
- Successful registration scenarios
|
||||
- Validation failure cases
|
||||
- Retry logic verification
|
||||
- Health check validation
|
||||
- Context cancellation
|
||||
- Performance benchmarks
|
||||
|
||||
Run tests:
|
||||
```bash
|
||||
go test ./chains/rpc/... -v
|
||||
```
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue: Handlers not accessible after registration
|
||||
**Solution:** Check health status with `HealthCheckAll()` and review debug output.
|
||||
|
||||
### Issue: Registration fails with "already exists"
|
||||
**Solution:** The retry logic handles this. If persistent, check for duplicate registration attempts.
|
||||
|
||||
### Issue: Slow registration during development
|
||||
**Solution:** Reduce retry count and wait time using `SetRetryConfig()`.
|
||||
|
||||
### Issue: Can't find the correct endpoint URL
|
||||
**Solution:** Use `DebugTool.DiagnoseEndpoint()` to test all URL patterns.
|
||||
|
||||
## Migration Guide
|
||||
|
||||
1. **Update imports:**
|
||||
```go
|
||||
import "github.com/luxfi/node/chains/rpc"
|
||||
```
|
||||
|
||||
2. **Replace inline registration (lines 941-990 in manager.go):**
|
||||
```go
|
||||
// Old code: complex type checking and manual registration
|
||||
// New code: single function call
|
||||
registrar := rpc.NewChainHandlerRegistrar(...)
|
||||
registrar.RegisterChainHandlers(...)
|
||||
```
|
||||
|
||||
3. **Add health monitoring (optional):**
|
||||
```go
|
||||
go func() {
|
||||
time.Sleep(5 * time.Second)
|
||||
registrar.HealthCheckAll()
|
||||
}()
|
||||
```
|
||||
|
||||
4. **Add debugging endpoints (optional):**
|
||||
```go
|
||||
http.HandleFunc("/debug/handlers", func(w http.ResponseWriter, r *http.Request) {
|
||||
routes := registrar.GetAllRoutes()
|
||||
json.NewEncoder(w).Encode(routes)
|
||||
})
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
- Registration: ~1ms per handler (without retries)
|
||||
- Health check: ~10ms per chain
|
||||
- Memory overhead: ~1KB per registered chain
|
||||
- No goroutine leaks or resource issues
|
||||
|
||||
## Future Improvements
|
||||
|
||||
Potential enhancements:
|
||||
- Metrics integration for registration success/failure rates
|
||||
- Automatic re-registration on failure
|
||||
- WebSocket-specific health checks
|
||||
- gRPC handler support
|
||||
- Handler versioning for upgrades
|
||||
|
||||
## Philosophy
|
||||
|
||||
This implementation follows core Go principles:
|
||||
- **Explicit over implicit** - Clear registration flow
|
||||
- **Errors are values** - Proper error handling throughout
|
||||
- **Simple over clever** - Straightforward retry logic
|
||||
- **Composition over inheritance** - Small, focused components
|
||||
- **Documentation is code** - Self-documenting with clear names
|
||||
|
||||
The system is designed to be bulletproof for development while remaining simple to understand and maintain.
|
||||
@@ -1,168 +0,0 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/node/server/http"
|
||||
"github.com/luxfi/node/vms"
|
||||
)
|
||||
|
||||
// ChainHandlerRegistrar provides a clean interface for chain manager to register handlers.
|
||||
// This replaces the inline registration logic with a more robust, testable solution.
|
||||
type ChainHandlerRegistrar struct {
|
||||
manager *HandlerManager
|
||||
server server.Server
|
||||
log log.Logger
|
||||
cChainID ids.ID // Special handling for C-Chain
|
||||
pChainID ids.ID // Platform chain ID for validation
|
||||
}
|
||||
|
||||
// NewChainHandlerRegistrar creates a registrar for chain handler registration.
|
||||
// Encapsulates all the registration logic in one place.
|
||||
func NewChainHandlerRegistrar(
|
||||
server server.Server,
|
||||
logger log.Logger,
|
||||
cChainID ids.ID,
|
||||
pChainID ids.ID,
|
||||
) *ChainHandlerRegistrar {
|
||||
return &ChainHandlerRegistrar{
|
||||
manager: NewHandlerManager(server, logger),
|
||||
server: server,
|
||||
log: logger,
|
||||
cChainID: cChainID,
|
||||
pChainID: pChainID,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterChainHandlers is the main entry point from chain manager.
|
||||
// Handles all the complexity of VM type checking and handler extraction.
|
||||
func (r *ChainHandlerRegistrar) RegisterChainHandlers(
|
||||
ctx context.Context,
|
||||
chainID ids.ID,
|
||||
vm interface{},
|
||||
) error {
|
||||
r.log.Info("Attempting to register chain handlers",
|
||||
log.Stringer("chainID", chainID),
|
||||
log.String("vmType", fmt.Sprintf("%T", vm)))
|
||||
|
||||
// Don't register handlers for Platform VM
|
||||
if chainID == r.pChainID {
|
||||
r.log.Debug("Skipping handler registration for Platform VM")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract handlers from VM
|
||||
handlers, err := r.extractHandlers(ctx, vm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract handlers: %w", err)
|
||||
}
|
||||
|
||||
if len(handlers) == 0 {
|
||||
r.log.Info("VM does not provide any handlers",
|
||||
log.Stringer("chainID", chainID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Determine chain alias (special case for C-Chain)
|
||||
alias := r.getChainAlias(chainID)
|
||||
|
||||
// Register with robust handler manager
|
||||
return r.manager.RegisterChainHandlers(ctx, chainID, alias, handlers)
|
||||
}
|
||||
|
||||
// extractHandlers attempts to get handlers from the VM using multiple strategies.
|
||||
// Handles different VM wrapper types gracefully.
|
||||
func (r *ChainHandlerRegistrar) extractHandlers(
|
||||
ctx context.Context,
|
||||
vm interface{},
|
||||
) (map[string]http.Handler, error) {
|
||||
// First try direct interface check
|
||||
if provider, ok := vm.(vms.HandlerProvider); ok {
|
||||
r.log.Debug("VM directly implements HandlerProvider")
|
||||
return provider.CreateHandlers(ctx)
|
||||
}
|
||||
|
||||
// Try using the delegate helper (handles wrapped VMs)
|
||||
handlers, err := vms.DelegateHandlers(ctx, vm)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("handler delegation failed: %w", err)
|
||||
}
|
||||
|
||||
if len(handlers) > 0 {
|
||||
r.log.Debug("Successfully extracted handlers via delegation",
|
||||
log.Int("count", len(handlers)))
|
||||
}
|
||||
|
||||
return handlers, nil
|
||||
}
|
||||
|
||||
// getChainAlias returns the appropriate alias for a chain.
|
||||
// C-Chain gets special treatment, others use their ID.
|
||||
func (r *ChainHandlerRegistrar) getChainAlias(chainID ids.ID) string {
|
||||
if chainID == r.cChainID {
|
||||
return "C"
|
||||
}
|
||||
// Could extend this for X-Chain and P-Chain if needed
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetRouteInfo returns information about a specific chain's registered routes.
|
||||
// Useful for debugging and operational visibility.
|
||||
func (r *ChainHandlerRegistrar) GetRouteInfo(chainID ids.ID) (*RouteInfo, bool) {
|
||||
return r.manager.GetRouteInfo(chainID)
|
||||
}
|
||||
|
||||
// GetAllRoutes returns all registered routes across all chains.
|
||||
// Complete visibility for monitoring and debugging.
|
||||
func (r *ChainHandlerRegistrar) GetAllRoutes() map[string]*RouteInfo {
|
||||
return r.manager.GetAllRoutes()
|
||||
}
|
||||
|
||||
// HealthCheckAll performs health checks on all registered routes.
|
||||
// Returns a map of chainID -> healthy status.
|
||||
func (r *ChainHandlerRegistrar) HealthCheckAll() map[string]bool {
|
||||
return r.manager.HealthCheckAll()
|
||||
}
|
||||
|
||||
// SetRetryConfig allows tuning of retry behavior for different environments.
|
||||
// Production might want more retries, dev might want faster failures.
|
||||
func (r *ChainHandlerRegistrar) SetRetryConfig(maxRetries int, initialWait time.Duration) {
|
||||
r.manager.SetRetryConfig(maxRetries, initialWait)
|
||||
}
|
||||
|
||||
// ValidateEndpoint performs a test request against a specific endpoint.
|
||||
// Useful for debugging specific handler issues.
|
||||
func (r *ChainHandlerRegistrar) ValidateEndpoint(
|
||||
chainID ids.ID,
|
||||
endpoint string,
|
||||
) error {
|
||||
info, exists := r.manager.GetRouteInfo(chainID)
|
||||
if !exists {
|
||||
return fmt.Errorf("no routes registered for chain %s", chainID)
|
||||
}
|
||||
|
||||
// Build the full URL
|
||||
fullURL := fmt.Sprintf("/v1/%s%s", info.Base, endpoint)
|
||||
|
||||
r.log.Info("Validating endpoint",
|
||||
log.Stringer("chainID", chainID),
|
||||
log.String("url", fullURL))
|
||||
|
||||
// Validates endpoint registration
|
||||
for _, registered := range info.Endpoints {
|
||||
if registered == endpoint {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("endpoint %s not found in registered endpoints: %v",
|
||||
endpoint, info.Endpoints)
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/go-json-experiment/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// DebugTool provides utilities for debugging RPC handler issues.
|
||||
// Developer-friendly diagnostics with clear, actionable output.
|
||||
type DebugTool struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
// NewDebugTool creates a debug tool for RPC endpoint testing.
|
||||
func NewDebugTool(baseURL string, logger log.Logger) *DebugTool {
|
||||
if !strings.HasPrefix(baseURL, "http") {
|
||||
baseURL = "http://" + baseURL
|
||||
}
|
||||
|
||||
return &DebugTool{
|
||||
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
log: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// DiagnoseEndpoint performs comprehensive diagnostics on an RPC endpoint.
|
||||
// Returns detailed information about what's working and what's not.
|
||||
func (d *DebugTool) DiagnoseEndpoint(chainID ids.ID, alias string) *DiagnosticReport {
|
||||
report := &DiagnosticReport{
|
||||
ChainID: chainID,
|
||||
Alias: alias,
|
||||
Timestamp: time.Now(),
|
||||
Tests: make([]TestResult, 0),
|
||||
}
|
||||
|
||||
// Test different URL patterns
|
||||
urlPatterns := d.getURLPatterns(chainID, alias)
|
||||
|
||||
for _, pattern := range urlPatterns {
|
||||
result := d.testEndpoint(pattern)
|
||||
report.Tests = append(report.Tests, result)
|
||||
}
|
||||
|
||||
// Test common RPC methods
|
||||
if bestURL := report.GetBestURL(); bestURL != "" {
|
||||
report.RPCTests = d.testRPCMethods(bestURL)
|
||||
}
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
// getURLPatterns returns all possible URL patterns to test.
|
||||
func (d *DebugTool) getURLPatterns(chainID ids.ID, alias string) []string {
|
||||
patterns := []string{
|
||||
fmt.Sprintf("%s/v1/bc/%s/rpc", d.baseURL, chainID.String()),
|
||||
fmt.Sprintf("%s/v1/bc/%s/ws", d.baseURL, chainID.String()),
|
||||
fmt.Sprintf("%s/v1/bc/%s", d.baseURL, chainID.String()),
|
||||
}
|
||||
|
||||
if alias != "" && alias != chainID.String() {
|
||||
patterns = append(patterns,
|
||||
fmt.Sprintf("%s/v1/bc/%s/rpc", d.baseURL, alias),
|
||||
fmt.Sprintf("%s/v1/bc/%s/ws", d.baseURL, alias),
|
||||
fmt.Sprintf("%s/v1/bc/%s", d.baseURL, alias),
|
||||
)
|
||||
}
|
||||
|
||||
// Also test without /v1 prefix (some setups might differ)
|
||||
patterns = append(patterns,
|
||||
fmt.Sprintf("%s/bc/%s/rpc", d.baseURL, chainID.String()),
|
||||
)
|
||||
|
||||
return patterns
|
||||
}
|
||||
|
||||
// testEndpoint tests a single endpoint URL.
|
||||
func (d *DebugTool) testEndpoint(url string) TestResult {
|
||||
result := TestResult{
|
||||
URL: url,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
// First try a simple GET
|
||||
resp, err := d.client.Get(url)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("GET failed: %v", err)
|
||||
result.Success = false
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
result.StatusCode = resp.StatusCode
|
||||
|
||||
// Read body for debugging
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
result.Response = string(bodyBytes)
|
||||
|
||||
// Now try a POST with JSON-RPC
|
||||
rpcReq := map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "web3_clientVersion",
|
||||
"params": []interface{}{},
|
||||
"id": 1,
|
||||
}
|
||||
|
||||
jsonBytes, _ := json.Marshal(rpcReq)
|
||||
postResp, err := d.client.Post(url, "application/json", bytes.NewReader(jsonBytes))
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("POST failed: %v", err)
|
||||
result.Success = false
|
||||
return result
|
||||
}
|
||||
defer postResp.Body.Close()
|
||||
|
||||
result.StatusCode = postResp.StatusCode
|
||||
|
||||
// Check if we got a valid JSON-RPC response
|
||||
var rpcResp map[string]interface{}
|
||||
if err := json.UnmarshalRead(postResp.Body, &rpcResp); err == nil {
|
||||
if _, hasResult := rpcResp["result"]; hasResult {
|
||||
result.Success = true
|
||||
result.Response = fmt.Sprintf("Valid JSON-RPC response: %v", rpcResp["result"])
|
||||
} else if errObj, hasError := rpcResp["error"]; hasError {
|
||||
result.Success = false
|
||||
result.Response = fmt.Sprintf("JSON-RPC error: %v", errObj)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// testRPCMethods tests common RPC methods against an endpoint.
|
||||
func (d *DebugTool) testRPCMethods(url string) []RPCTest {
|
||||
methods := []string{
|
||||
"web3_clientVersion",
|
||||
"eth_blockNumber",
|
||||
"eth_chainId",
|
||||
"net_version",
|
||||
"eth_syncing",
|
||||
}
|
||||
|
||||
tests := make([]RPCTest, 0, len(methods))
|
||||
|
||||
for _, method := range methods {
|
||||
test := RPCTest{
|
||||
Method: method,
|
||||
URL: url,
|
||||
}
|
||||
|
||||
req := map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": []interface{}{},
|
||||
"id": 1,
|
||||
}
|
||||
|
||||
jsonBytes, _ := json.Marshal(req)
|
||||
resp, err := d.client.Post(url, "application/json", bytes.NewReader(jsonBytes))
|
||||
if err != nil {
|
||||
test.Error = err.Error()
|
||||
test.Success = false
|
||||
} else {
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.UnmarshalRead(resp.Body, &result); err == nil {
|
||||
if res, ok := result["result"]; ok {
|
||||
test.Success = true
|
||||
test.Result = fmt.Sprintf("%v", res)
|
||||
} else if errObj, ok := result["error"]; ok {
|
||||
test.Error = fmt.Sprintf("%v", errObj)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tests = append(tests, test)
|
||||
}
|
||||
|
||||
return tests
|
||||
}
|
||||
|
||||
// DiagnosticReport contains comprehensive endpoint diagnostic information.
|
||||
type DiagnosticReport struct {
|
||||
ChainID ids.ID
|
||||
Alias string
|
||||
Timestamp time.Time
|
||||
Tests []TestResult
|
||||
RPCTests []RPCTest
|
||||
}
|
||||
|
||||
// TestResult represents a single endpoint test result.
|
||||
type TestResult struct {
|
||||
URL string
|
||||
Success bool
|
||||
StatusCode int
|
||||
Response string
|
||||
Error string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// RPCTest represents a test of a specific RPC method.
|
||||
type RPCTest struct {
|
||||
Method string
|
||||
URL string
|
||||
Success bool
|
||||
Result string
|
||||
Error string
|
||||
}
|
||||
|
||||
// GetBestURL returns the first working URL from the tests.
|
||||
func (r *DiagnosticReport) GetBestURL() string {
|
||||
for _, test := range r.Tests {
|
||||
if test.Success {
|
||||
return test.URL
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// String returns a human-readable report.
|
||||
func (r *DiagnosticReport) String() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(fmt.Sprintf("=== RPC Endpoint Diagnostic Report ===\n"))
|
||||
b.WriteString(fmt.Sprintf("Chain ID: %s\n", r.ChainID))
|
||||
if r.Alias != "" {
|
||||
b.WriteString(fmt.Sprintf("Alias: %s\n", r.Alias))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("Timestamp: %s\n\n", r.Timestamp.Format(time.RFC3339)))
|
||||
|
||||
b.WriteString("=== Endpoint Tests ===\n")
|
||||
for _, test := range r.Tests {
|
||||
status := "❌ FAILED"
|
||||
if test.Success {
|
||||
status = "✅ SUCCESS"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\n%s %s\n", status, test.URL))
|
||||
b.WriteString(fmt.Sprintf(" Status Code: %d\n", test.StatusCode))
|
||||
if test.Error != "" {
|
||||
b.WriteString(fmt.Sprintf(" Error: %s\n", test.Error))
|
||||
}
|
||||
if test.Response != "" && len(test.Response) < 200 {
|
||||
b.WriteString(fmt.Sprintf(" Response: %s\n", test.Response))
|
||||
}
|
||||
}
|
||||
|
||||
if len(r.RPCTests) > 0 {
|
||||
b.WriteString("\n=== RPC Method Tests ===\n")
|
||||
for _, test := range r.RPCTests {
|
||||
status := "❌"
|
||||
if test.Success {
|
||||
status = "✅"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s %s: ", status, test.Method))
|
||||
if test.Success {
|
||||
b.WriteString(test.Result)
|
||||
} else {
|
||||
b.WriteString(test.Error)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n=== Recommendations ===\n")
|
||||
if bestURL := r.GetBestURL(); bestURL != "" {
|
||||
b.WriteString(fmt.Sprintf("✅ Use this endpoint: %s\n", bestURL))
|
||||
} else {
|
||||
b.WriteString("❌ No working endpoints found. Check:\n")
|
||||
b.WriteString(" 1. Is the node running?\n")
|
||||
b.WriteString(" 2. Is the chain bootstrapped?\n")
|
||||
b.WriteString(" 3. Are handlers properly registered?\n")
|
||||
b.WriteString(" 4. Check node logs for handler registration errors\n")
|
||||
b.WriteString(" 5. Try restarting the node\n")
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// QuickDiagnose performs a quick endpoint check and prints results.
|
||||
// Convenience function for CLI tools.
|
||||
func QuickDiagnose(nodeURL string, chainID ids.ID, alias string) {
|
||||
logger := log.NewNoOpLogger()
|
||||
tool := NewDebugTool(nodeURL, logger)
|
||||
report := tool.DiagnoseEndpoint(chainID, alias)
|
||||
fmt.Println(report.String())
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// Package rpc provides robust RPC handler registration with retries, health checks, and clear debugging.
|
||||
// Follows Go principles: fail fast with clear errors, single responsibility, minimal dependencies.
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/node/server/http"
|
||||
)
|
||||
|
||||
var (
|
||||
// Errors follow Go convention: lowercase, descriptive, actionable
|
||||
errNilHandler = errors.New("handler is nil")
|
||||
errNilServer = errors.New("server is nil")
|
||||
errEmptyEndpoint = errors.New("endpoint is empty")
|
||||
errRegistrationFailed = errors.New("handler registration failed")
|
||||
errHealthCheckFailed = errors.New("health check failed")
|
||||
)
|
||||
|
||||
// HandlerManager manages RPC handler registration with robust error handling and health checks.
|
||||
// Single responsibility: reliable handler registration with observability.
|
||||
type HandlerManager struct {
|
||||
server server.Server
|
||||
log log.Logger
|
||||
mu sync.RWMutex
|
||||
routes map[string]*RouteInfo // chainID -> route info
|
||||
retries int // max registration retries
|
||||
retryWait time.Duration // initial retry wait time
|
||||
}
|
||||
|
||||
// RouteInfo contains complete information about a registered route.
|
||||
// Everything needed for debugging in one place.
|
||||
type RouteInfo struct {
|
||||
ChainID ids.ID
|
||||
ChainAlias string
|
||||
Base string // e.g., "bc/C" or "bc/<chainID>"
|
||||
Endpoints []string // e.g., ["/rpc", "/ws"]
|
||||
Handler http.Handler
|
||||
Healthy bool
|
||||
LastCheck time.Time
|
||||
}
|
||||
|
||||
// NewHandlerManager creates a handler manager with sensible defaults.
|
||||
// Simple factory, no magic.
|
||||
func NewHandlerManager(server server.Server, logger log.Logger) *HandlerManager {
|
||||
return &HandlerManager{
|
||||
server: server,
|
||||
log: logger,
|
||||
routes: make(map[string]*RouteInfo),
|
||||
retries: 3,
|
||||
retryWait: 100 * time.Millisecond,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterChainHandlers registers all handlers for a chain with retry logic and health checks.
|
||||
// This is the main entry point - handles everything needed for robust registration.
|
||||
func (m *HandlerManager) RegisterChainHandlers(
|
||||
ctx context.Context,
|
||||
chainID ids.ID,
|
||||
chainAlias string,
|
||||
handlers map[string]http.Handler,
|
||||
) error {
|
||||
if m.server == nil {
|
||||
return errNilServer
|
||||
}
|
||||
|
||||
m.log.Info("Starting chain handler registration",
|
||||
log.Stringer("chainID", chainID),
|
||||
log.String("alias", chainAlias),
|
||||
log.Int("handlerCount", len(handlers)))
|
||||
|
||||
// Validate handlers first - fail fast
|
||||
if err := m.validateHandlers(handlers); err != nil {
|
||||
return fmt.Errorf("handler validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Build route info
|
||||
info := &RouteInfo{
|
||||
ChainID: chainID,
|
||||
ChainAlias: chainAlias,
|
||||
Endpoints: make([]string, 0, len(handlers)),
|
||||
}
|
||||
|
||||
// Determine base paths
|
||||
bases := m.getBasePaths(chainID, chainAlias)
|
||||
|
||||
// Register each handler with retries
|
||||
var registrationErrors []error
|
||||
for endpoint, handler := range handlers {
|
||||
info.Endpoints = append(info.Endpoints, endpoint)
|
||||
|
||||
for _, base := range bases {
|
||||
if err := m.registerWithRetry(ctx, base, endpoint, handler); err != nil {
|
||||
registrationErrors = append(registrationErrors,
|
||||
fmt.Errorf("failed to register %s%s: %w", base, endpoint, err))
|
||||
m.log.Error("Handler registration failed",
|
||||
log.String("base", base),
|
||||
log.String("endpoint", endpoint),
|
||||
log.Err(err))
|
||||
} else {
|
||||
m.log.Info("Handler registered successfully",
|
||||
log.String("route", fmt.Sprintf("/v1/%s%s", base, endpoint)),
|
||||
log.Stringer("chainID", chainID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store route info for monitoring
|
||||
m.mu.Lock()
|
||||
info.Base = bases[0] // Primary base
|
||||
info.Handler = handlers["/rpc"] // Store primary handler for health checks
|
||||
m.routes[chainID.String()] = info
|
||||
m.mu.Unlock()
|
||||
|
||||
// Run health checks
|
||||
if err := m.healthCheckRoute(info); err != nil {
|
||||
m.log.Warn("Health check failed for newly registered chain",
|
||||
log.Stringer("chainID", chainID),
|
||||
log.Err(err))
|
||||
}
|
||||
|
||||
// Return aggregate error if any registrations failed
|
||||
if len(registrationErrors) > 0 {
|
||||
return fmt.Errorf("%w: %v", errRegistrationFailed, registrationErrors)
|
||||
}
|
||||
|
||||
m.log.Info("Chain handler registration completed",
|
||||
log.Stringer("chainID", chainID),
|
||||
log.String("routes", strings.Join(m.getFullRoutes(bases, info.Endpoints), ", ")))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateHandlers ensures all handlers are valid before attempting registration.
|
||||
// Fail fast with clear errors - no silent failures.
|
||||
func (m *HandlerManager) validateHandlers(handlers map[string]http.Handler) error {
|
||||
if len(handlers) == 0 {
|
||||
return errors.New("no handlers provided")
|
||||
}
|
||||
|
||||
for endpoint, handler := range handlers {
|
||||
if handler == nil {
|
||||
return fmt.Errorf("%w for endpoint %s", errNilHandler, endpoint)
|
||||
}
|
||||
if endpoint == "" {
|
||||
return errEmptyEndpoint
|
||||
}
|
||||
// Ensure endpoint starts with /
|
||||
if !strings.HasPrefix(endpoint, "/") {
|
||||
return fmt.Errorf("endpoint %s must start with /", endpoint)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getBasePaths returns all base paths for a chain (with and without alias).
|
||||
// Single source of truth for path construction.
|
||||
func (m *HandlerManager) getBasePaths(chainID ids.ID, chainAlias string) []string {
|
||||
bases := []string{}
|
||||
|
||||
// If we have an alias (like "C" for C-Chain), use it as primary
|
||||
if chainAlias != "" && chainAlias != chainID.String() {
|
||||
bases = append(bases, fmt.Sprintf("bc/%s", chainAlias))
|
||||
}
|
||||
|
||||
// Always include the full chain ID path
|
||||
bases = append(bases, fmt.Sprintf("bc/%s", chainID.String()))
|
||||
|
||||
return bases
|
||||
}
|
||||
|
||||
// getFullRoutes constructs full route paths for logging.
|
||||
// Clear, complete information for operators.
|
||||
func (m *HandlerManager) getFullRoutes(bases []string, endpoints []string) []string {
|
||||
routes := []string{}
|
||||
for _, base := range bases {
|
||||
for _, endpoint := range endpoints {
|
||||
routes = append(routes, fmt.Sprintf("/v1/%s%s", base, endpoint))
|
||||
}
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
// registerWithRetry attempts registration with exponential backoff.
|
||||
// Handles transient failures gracefully.
|
||||
func (m *HandlerManager) registerWithRetry(
|
||||
ctx context.Context,
|
||||
base string,
|
||||
endpoint string,
|
||||
handler http.Handler,
|
||||
) error {
|
||||
wait := m.retryWait
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt < m.retries; attempt++ {
|
||||
// Check context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// Try registration
|
||||
if err := m.server.AddRoute(handler, base, endpoint); err == nil {
|
||||
return nil // Success!
|
||||
} else {
|
||||
lastErr = err
|
||||
m.log.Debug("Registration attempt failed, retrying",
|
||||
log.Int("attempt", attempt+1),
|
||||
log.String("base", base),
|
||||
log.String("endpoint", endpoint),
|
||||
log.Err(err))
|
||||
}
|
||||
|
||||
// Don't wait after last attempt
|
||||
if attempt < m.retries-1 {
|
||||
select {
|
||||
case <-time.After(wait):
|
||||
wait *= 2 // Exponential backoff
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed after %d attempts: %w", m.retries, lastErr)
|
||||
}
|
||||
|
||||
// healthCheckRoute performs a basic health check on a registered route.
|
||||
// Validates that handlers are actually responding.
|
||||
func (m *HandlerManager) healthCheckRoute(info *RouteInfo) error {
|
||||
if info.Handler == nil {
|
||||
return fmt.Errorf("no handler to check for chain %s", info.ChainID)
|
||||
}
|
||||
|
||||
// Create a test request
|
||||
req := httptest.NewRequest("POST", "/", strings.NewReader(`{"jsonrpc":"2.0","method":"web3_clientVersion","params":[],"id":1}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Record the response
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
// Call the handler
|
||||
info.Handler.ServeHTTP(recorder, req)
|
||||
|
||||
// Check response
|
||||
info.LastCheck = time.Now()
|
||||
if recorder.Code == http.StatusOK || recorder.Code == http.StatusMethodNotAllowed {
|
||||
info.Healthy = true
|
||||
m.log.Debug("Health check passed",
|
||||
log.Stringer("chainID", info.ChainID),
|
||||
log.Int("status", recorder.Code))
|
||||
return nil
|
||||
}
|
||||
|
||||
info.Healthy = false
|
||||
return fmt.Errorf("%w: status %d", errHealthCheckFailed, recorder.Code)
|
||||
}
|
||||
|
||||
// GetRouteInfo returns information about a registered chain's routes.
|
||||
// Useful for debugging and monitoring.
|
||||
func (m *HandlerManager) GetRouteInfo(chainID ids.ID) (*RouteInfo, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
info, exists := m.routes[chainID.String()]
|
||||
return info, exists
|
||||
}
|
||||
|
||||
// GetAllRoutes returns all registered route information.
|
||||
// Complete visibility for operators.
|
||||
func (m *HandlerManager) GetAllRoutes() map[string]*RouteInfo {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Return a copy to prevent external modification
|
||||
routes := make(map[string]*RouteInfo, len(m.routes))
|
||||
for k, v := range m.routes {
|
||||
routes[k] = v
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
// HealthCheckAll performs health checks on all registered routes.
|
||||
// Batch operation for monitoring systems.
|
||||
func (m *HandlerManager) HealthCheckAll() map[string]bool {
|
||||
m.mu.RLock()
|
||||
routes := make([]*RouteInfo, 0, len(m.routes))
|
||||
for _, info := range m.routes {
|
||||
routes = append(routes, info)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
results := make(map[string]bool)
|
||||
for _, info := range routes {
|
||||
err := m.healthCheckRoute(info)
|
||||
results[info.ChainID.String()] = err == nil
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// SetRetryConfig allows customization of retry behavior.
|
||||
// Flexibility for different deployment scenarios.
|
||||
func (m *HandlerManager) SetRetryConfig(maxRetries int, initialWait time.Duration) {
|
||||
if maxRetries > 0 {
|
||||
m.retries = maxRetries
|
||||
}
|
||||
if initialWait > 0 {
|
||||
m.retryWait = initialWait
|
||||
}
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/node/server/http"
|
||||
"github.com/luxfi/runtime"
|
||||
"github.com/luxfi/vm"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mockServer implements a test server for handler registration
|
||||
type mockServer struct {
|
||||
routes map[string]http.Handler
|
||||
failCount int
|
||||
maxFailures int
|
||||
returnError error
|
||||
aliases map[string][]string
|
||||
}
|
||||
|
||||
func newMockServer() *mockServer {
|
||||
return &mockServer{
|
||||
routes: make(map[string]http.Handler),
|
||||
maxFailures: 0,
|
||||
aliases: make(map[string][]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *mockServer) AddRoute(handler http.Handler, base, endpoint string) error {
|
||||
// Simulate transient failures for retry testing
|
||||
if s.failCount < s.maxFailures {
|
||||
s.failCount++
|
||||
return errors.New("transient failure")
|
||||
}
|
||||
|
||||
// Return configured error if any
|
||||
if s.returnError != nil {
|
||||
return s.returnError
|
||||
}
|
||||
|
||||
// Store the route
|
||||
key := base + endpoint
|
||||
s.routes[key] = handler
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mockServer) AddAliases(endpoint string, aliases ...string) error {
|
||||
s.aliases[endpoint] = aliases
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mockServer) AddRouteWithReadLock(handler http.Handler, base, endpoint string) error {
|
||||
return s.AddRoute(handler, base, endpoint)
|
||||
}
|
||||
|
||||
func (s *mockServer) AddAliasesWithReadLock(endpoint string, aliases ...string) error {
|
||||
return s.AddAliases(endpoint, aliases...)
|
||||
}
|
||||
|
||||
func (s *mockServer) Dispatch() error { return nil }
|
||||
func (s *mockServer) RegisterChain(chainName string, rt *runtime.Runtime, vm vm.VM) {
|
||||
}
|
||||
func (s *mockServer) Shutdown() error { return nil }
|
||||
func (s *mockServer) SetRootInfoProvider(_ server.RootInfoProvider) {}
|
||||
|
||||
func TestHandlerManager_RegisterChainHandlers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
chainID ids.ID
|
||||
chainAlias string
|
||||
handlers map[string]http.Handler
|
||||
serverError error
|
||||
expectError bool
|
||||
expectRoutes int
|
||||
}{
|
||||
{
|
||||
name: "successful registration with alias",
|
||||
chainID: ids.GenerateTestID(),
|
||||
chainAlias: "C",
|
||||
handlers: map[string]http.Handler{
|
||||
"/rpc": http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}),
|
||||
"/ws": http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}),
|
||||
},
|
||||
expectError: false,
|
||||
expectRoutes: 4, // 2 endpoints × 2 bases (alias + ID)
|
||||
},
|
||||
{
|
||||
name: "successful registration without alias",
|
||||
chainID: ids.GenerateTestID(),
|
||||
handlers: map[string]http.Handler{
|
||||
"/rpc": http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}),
|
||||
},
|
||||
expectError: false,
|
||||
expectRoutes: 1,
|
||||
},
|
||||
{
|
||||
name: "nil handler validation",
|
||||
chainID: ids.GenerateTestID(),
|
||||
chainAlias: "X",
|
||||
handlers: map[string]http.Handler{"/rpc": nil},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty endpoint validation",
|
||||
chainID: ids.GenerateTestID(),
|
||||
handlers: map[string]http.Handler{"": http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "no handlers provided",
|
||||
chainID: ids.GenerateTestID(),
|
||||
handlers: map[string]http.Handler{},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid endpoint format",
|
||||
chainID: ids.GenerateTestID(),
|
||||
handlers: map[string]http.Handler{"rpc": http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Setup
|
||||
server := newMockServer()
|
||||
server.returnError = tt.serverError
|
||||
logger := log.NewNoOpLogger()
|
||||
manager := NewHandlerManager(server, logger)
|
||||
|
||||
// Execute
|
||||
ctx := context.Background()
|
||||
err := manager.RegisterChainHandlers(ctx, tt.chainID, tt.chainAlias, tt.handlers)
|
||||
|
||||
// Verify
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, server.routes, tt.expectRoutes)
|
||||
|
||||
// Verify route info was stored
|
||||
info, exists := manager.GetRouteInfo(tt.chainID)
|
||||
require.True(t, exists)
|
||||
require.Equal(t, tt.chainID, info.ChainID)
|
||||
require.Equal(t, tt.chainAlias, info.ChainAlias)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerManager_RetryLogic(t *testing.T) {
|
||||
// Setup server that fails twice then succeeds
|
||||
server := newMockServer()
|
||||
server.maxFailures = 2
|
||||
|
||||
logger := log.NewNoOpLogger()
|
||||
manager := NewHandlerManager(server, logger)
|
||||
manager.SetRetryConfig(3, 10*time.Millisecond)
|
||||
|
||||
// Create test handler
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Register with retries
|
||||
ctx := context.Background()
|
||||
chainID := ids.GenerateTestID()
|
||||
require.NoError(t, manager.RegisterChainHandlers(ctx, chainID, "TEST", map[string]http.Handler{
|
||||
"/rpc": handler,
|
||||
}))
|
||||
require.Equal(t, 2, server.failCount) // Failed twice, succeeded on third try
|
||||
require.Len(t, server.routes, 2) // Both alias and ID routes
|
||||
}
|
||||
|
||||
func TestHandlerManager_HealthCheck(t *testing.T) {
|
||||
server := newMockServer()
|
||||
logger := log.NewNoOpLogger()
|
||||
manager := NewHandlerManager(server, logger)
|
||||
|
||||
// Register a healthy handler
|
||||
healthyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"jsonrpc":"2.0","result":"test","id":1}`))
|
||||
})
|
||||
|
||||
// Register an unhealthy handler
|
||||
unhealthyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
chainID1 := ids.GenerateTestID()
|
||||
chainID2 := ids.GenerateTestID()
|
||||
|
||||
// Register healthy chain
|
||||
require.NoError(t, manager.RegisterChainHandlers(ctx, chainID1, "", map[string]http.Handler{
|
||||
"/rpc": healthyHandler,
|
||||
}))
|
||||
|
||||
// Register unhealthy chain
|
||||
// Registration succeeds even if health check fails
|
||||
require.NoError(t, manager.RegisterChainHandlers(ctx, chainID2, "", map[string]http.Handler{
|
||||
"/rpc": unhealthyHandler,
|
||||
})) // Registration should succeed regardless of handler health
|
||||
|
||||
// Check health status
|
||||
results := manager.HealthCheckAll()
|
||||
require.True(t, results[chainID1.String()])
|
||||
require.False(t, results[chainID2.String()])
|
||||
}
|
||||
|
||||
func TestHandlerManager_GetBasePaths(t *testing.T) {
|
||||
manager := &HandlerManager{}
|
||||
chainID := ids.GenerateTestID()
|
||||
|
||||
// Test with alias
|
||||
bases := manager.getBasePaths(chainID, "C")
|
||||
require.Equal(t, []string{"bc/C", "bc/" + chainID.String()}, bases)
|
||||
|
||||
// Test without alias
|
||||
bases = manager.getBasePaths(chainID, "")
|
||||
require.Equal(t, []string{"bc/" + chainID.String()}, bases)
|
||||
|
||||
// Test when alias equals chain ID (shouldn't duplicate)
|
||||
bases = manager.getBasePaths(chainID, chainID.String())
|
||||
require.Equal(t, []string{"bc/" + chainID.String()}, bases)
|
||||
}
|
||||
|
||||
func TestHandlerManager_ContextCancellation(t *testing.T) {
|
||||
// Create a server that delays to test cancellation
|
||||
server := &mockServer{
|
||||
routes: make(map[string]http.Handler),
|
||||
returnError: errors.New("slow server"),
|
||||
}
|
||||
|
||||
logger := log.NewNoOpLogger()
|
||||
manager := NewHandlerManager(server, logger)
|
||||
manager.SetRetryConfig(10, 100*time.Millisecond) // Many retries with delays
|
||||
|
||||
// Create cancelled context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
// Try to register - should fail with context error
|
||||
chainID := ids.GenerateTestID()
|
||||
err := manager.RegisterChainHandlers(ctx, chainID, "", map[string]http.Handler{
|
||||
"/rpc": http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "context canceled")
|
||||
}
|
||||
|
||||
// Benchmark to ensure performance doesn't degrade
|
||||
func BenchmarkHandlerRegistration(b *testing.B) {
|
||||
server := newMockServer()
|
||||
logger := log.NewNoOpLogger()
|
||||
manager := NewHandlerManager(server, logger)
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
chainID := ids.GenerateTestID()
|
||||
manager.RegisterChainHandlers(ctx, chainID, "TEST", map[string]http.Handler{
|
||||
"/rpc": handler,
|
||||
"/ws": handler,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/node/server/http"
|
||||
)
|
||||
|
||||
// IntegrationExample shows how to modify the existing createChain function in manager.go.
|
||||
// This replaces lines 941-990 with cleaner, more robust code.
|
||||
func IntegrationExample(
|
||||
ctx context.Context,
|
||||
chainID ids.ID,
|
||||
vm interface{},
|
||||
server server.Server,
|
||||
logger log.Logger,
|
||||
cChainID ids.ID,
|
||||
pChainID ids.ID,
|
||||
isDevMode bool,
|
||||
) error {
|
||||
// BEFORE: 50+ lines of complex type checking and error-prone registration
|
||||
// AFTER: Clean, robust registration with proper error handling
|
||||
|
||||
// Step 1: Create the registrar
|
||||
registrar := NewChainHandlerRegistrar(server, logger, cChainID, pChainID)
|
||||
|
||||
// Step 2: Configure based on environment
|
||||
if isDevMode {
|
||||
// Development: Fast failures for quick iteration
|
||||
registrar.SetRetryConfig(2, 50*time.Millisecond)
|
||||
logger.Info("Using development handler registration settings")
|
||||
} else {
|
||||
// Production: More robust with retries
|
||||
registrar.SetRetryConfig(5, 200*time.Millisecond)
|
||||
logger.Info("Using production handler registration settings")
|
||||
}
|
||||
|
||||
// Step 3: Register handlers (replaces all the complex VM type checking)
|
||||
startTime := time.Now()
|
||||
err := registrar.RegisterChainHandlers(ctx, chainID, vm)
|
||||
duration := time.Since(startTime)
|
||||
|
||||
// Step 4: Handle registration result
|
||||
if err != nil {
|
||||
// Log error but don't fail chain creation
|
||||
// Handlers are not critical for chain operation
|
||||
logger.Error("RPC handler registration failed",
|
||||
log.Stringer("chainID", chainID),
|
||||
log.Err(err),
|
||||
log.Duration("duration", duration),
|
||||
log.String("action", "Chain will operate without HTTP/RPC access"))
|
||||
|
||||
// Could emit metrics here if available
|
||||
// metric.HandlerRegistrationFailed.Inc()
|
||||
|
||||
// Non-fatal: return nil to allow chain to continue
|
||||
// Change to 'return err' if you want this to be fatal
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 5: Log success with useful information
|
||||
if info, exists := registrar.GetRouteInfo(chainID); exists {
|
||||
logger.Info("RPC handlers registered successfully",
|
||||
log.Stringer("chainID", chainID),
|
||||
log.String("alias", info.ChainAlias),
|
||||
log.Strings("endpoints", info.Endpoints),
|
||||
log.Duration("duration", duration),
|
||||
log.Bool("healthCheckPassed", info.Healthy))
|
||||
|
||||
// Print developer-friendly message
|
||||
if isDevMode && len(info.Endpoints) > 0 {
|
||||
baseURL := "http://localhost:9630"
|
||||
fmt.Printf("\n✅ Chain %s RPC endpoints ready:\n", chainID)
|
||||
for _, endpoint := range info.Endpoints {
|
||||
if info.ChainAlias != "" {
|
||||
fmt.Printf(" %s/v1/bc/%s%s\n", baseURL, info.ChainAlias, endpoint)
|
||||
}
|
||||
fmt.Printf(" %s/v1/bc/%s%s\n", baseURL, chainID, endpoint)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Schedule async health monitoring (optional)
|
||||
if !isDevMode {
|
||||
go monitorHandlerHealth(ctx, registrar, chainID, logger)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// monitorHandlerHealth runs periodic health checks in the background.
|
||||
// This helps detect and log handler issues early.
|
||||
func monitorHandlerHealth(
|
||||
ctx context.Context,
|
||||
registrar *ChainHandlerRegistrar,
|
||||
chainID ids.ID,
|
||||
logger log.Logger,
|
||||
) {
|
||||
// Initial delay to let chain fully initialize
|
||||
select {
|
||||
case <-time.After(10 * time.Second):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
// Run initial health check
|
||||
checkHealth(registrar, chainID, logger)
|
||||
|
||||
// Periodic health checks
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
checkHealth(registrar, chainID, logger)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkHealth performs a health check and logs results.
|
||||
func checkHealth(registrar *ChainHandlerRegistrar, chainID ids.ID, logger log.Logger) {
|
||||
results := registrar.HealthCheckAll()
|
||||
|
||||
for chainIDStr, healthy := range results {
|
||||
if chainIDStr == chainID.String() {
|
||||
if healthy {
|
||||
logger.Debug("Handler health check passed",
|
||||
log.String("chainID", chainIDStr))
|
||||
} else {
|
||||
logger.Warn("Handler health check failed",
|
||||
log.String("chainID", chainIDStr),
|
||||
log.String("action", "Will continue monitoring"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MinimalIntegration shows the absolute minimum code needed.
|
||||
// This is what you'd actually put in manager.go.
|
||||
func MinimalIntegration(
|
||||
ctx context.Context,
|
||||
chainID ids.ID,
|
||||
vm interface{},
|
||||
server server.Server,
|
||||
logger log.Logger,
|
||||
cChainID ids.ID,
|
||||
) error {
|
||||
// Just three lines to replace 50+ lines of complex code!
|
||||
registrar := NewChainHandlerRegistrar(server, logger, cChainID, ids.Empty)
|
||||
if err := registrar.RegisterChainHandlers(ctx, chainID, vm); err != nil {
|
||||
logger.Error("Handler registration failed", log.Err(err))
|
||||
}
|
||||
return nil // Non-fatal
|
||||
}
|
||||
@@ -1766,6 +1766,10 @@ func GetNodeConfig(v *viper.Viper) (node.Config, error) {
|
||||
if nodeConfig.ProposerWindowDuration < 0 {
|
||||
return node.Config{}, fmt.Errorf("%s must not be negative", ProposerVMWindowDurationKey)
|
||||
}
|
||||
nodeConfig.ProposerMinBlockDelay = v.GetDuration(ProposerVMMinBlockDelayKey)
|
||||
if nodeConfig.ProposerMinBlockDelay < 0 {
|
||||
return node.Config{}, fmt.Errorf("%s must not be negative", ProposerVMMinBlockDelayKey)
|
||||
}
|
||||
// Halflife of continuous averager used in health checks
|
||||
healthCheckAveragerHalflife := v.GetDuration(HealthCheckAveragerHalflifeKey)
|
||||
if healthCheckAveragerHalflife <= 0 {
|
||||
|
||||
@@ -182,6 +182,11 @@ type Config struct {
|
||||
// 1s) so block cadence is not floored at 5s per proposer slot.
|
||||
ProposerWindowDuration time.Duration `json:"proposerWindowDuration"`
|
||||
|
||||
// ProposerMinBlockDelay is the proposervm minimum delay between consecutive
|
||||
// blocks (the hard cadence floor). Zero keeps the 1s default; high-throughput
|
||||
// / DEX nets set it low (e.g. 1ms) to approach the consensus-finality floor.
|
||||
ProposerMinBlockDelay time.Duration `json:"proposerMinBlockDelay"`
|
||||
|
||||
// Network configuration
|
||||
NetworkConfig network.Config `json:"networkConfig"`
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,173 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package builder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/constants"
|
||||
"github.com/luxfi/node/vms/platformvm/genesis"
|
||||
pchaintxs "github.com/luxfi/node/vms/platformvm/txs"
|
||||
)
|
||||
|
||||
// parsePolicy reads the operator form "3-of-5" into (K, N).
|
||||
//
|
||||
// The canonical parser is luxfi/threshold/pkg/quorum, but the node's genesis
|
||||
// builder is a boot-critical path and does not take a dependency on a
|
||||
// threshold-crypto library to read three integers. What this test pins is the
|
||||
// FORM — that the genesis blob states a quorum in a spelling that cannot be
|
||||
// confused with a polynomial degree — and the form is checkable here.
|
||||
func parsePolicy(t *testing.T, s string) (k, n int) {
|
||||
t.Helper()
|
||||
_, err := fmt.Sscanf(s, "%d-of-%d", &k, &n)
|
||||
require.NoErrorf(t, err, "policy %q is not in the operator form \"3-of-5\"", s)
|
||||
require.Greaterf(t, k, 1, "policy %q: k=1 is not a threshold policy", s)
|
||||
require.LessOrEqualf(t, k, n, "policy %q can never be satisfied", s)
|
||||
return k, n
|
||||
}
|
||||
|
||||
// canonicalMPCVMID is M-Chain's vmID in the encoding the node's plugin registry
|
||||
// resolves against. Pinned literally: the plugin binary's FILENAME must equal
|
||||
// it, and a change that leaves the two out of step produces a chain that is
|
||||
// declared in genesis but that no node can start.
|
||||
//
|
||||
// The predecessor value, tGVBwRxpmD2aFdg3iYjgRvrCe8Jcmq9UNKxyHMus2NZ8WcD8t, is
|
||||
// the retired `thresholdvm` identifier. It was what the Dockerfile installed
|
||||
// the plugin under while genesis declared MPCVMID, so the two never met.
|
||||
const canonicalMPCVMID = "qCURact1n41FcoNBch8iMVBwc9AWie48D118ZNJ5tBdWrvryS"
|
||||
|
||||
// M-Chain must be a GENESIS chain — in the P-Chain's chain set at height 0,
|
||||
// tracked by every validator from boot — not a chain created later by a
|
||||
// CreateChainTx someone has to remember to submit.
|
||||
//
|
||||
// The difference matters for custody specifically. A post-genesis chain exists
|
||||
// only from the height its tx landed, so a node syncing from genesis has a
|
||||
// window with no custody state, and the chain's very existence depends on an
|
||||
// operator action that can be forgotten or fumbled. Bridged funds should not
|
||||
// depend on that.
|
||||
func TestMChainIsAGenesisChain(t *testing.T) {
|
||||
for _, networkID := range []uint32{
|
||||
constants.MainnetID,
|
||||
constants.TestnetID,
|
||||
constants.LocalID,
|
||||
} {
|
||||
cfg := GetConfig(networkID)
|
||||
require.NotNilf(t, cfg, "network %d has no genesis config", networkID)
|
||||
require.NotEmptyf(t, cfg.MChainGenesis,
|
||||
"network %d carries no mchain.json, so the builder skips M-Chain entirely", networkID)
|
||||
|
||||
raw, _, err := FromConfig(cfg)
|
||||
require.NoErrorf(t, err, "network %d genesis build", networkID)
|
||||
|
||||
parsed, err := genesis.Parse(raw)
|
||||
require.NoErrorf(t, err, "network %d genesis parse", networkID)
|
||||
|
||||
// Genesis chains are CreateChainTx entries executed at height 0 — the
|
||||
// chain exists from the first block, without anyone submitting a tx.
|
||||
var found *pchaintxs.CreateChainTx
|
||||
for _, c := range parsed.Chains {
|
||||
u, ok := c.Unsigned.(*pchaintxs.CreateChainTx)
|
||||
require.True(t, ok, "a genesis chain entry must be a CreateChainTx")
|
||||
if u.VMID() == constants.MPCVMID {
|
||||
found = u
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNilf(t, found,
|
||||
"network %d: no genesis chain with vmID %s (M-Chain)", networkID, constants.MPCVMID)
|
||||
require.Equal(t, "M-Chain", found.BlockchainName())
|
||||
require.Equalf(t, []byte(cfg.MChainGenesis), found.GenesisData(),
|
||||
"network %d: the VM must receive exactly the mchain.json bytes", networkID)
|
||||
require.Equalf(t, canonicalMPCVMID, found.VMID().String(),
|
||||
"network %d: genesis declares a vmID the plugin registry will look up by filename", networkID)
|
||||
}
|
||||
}
|
||||
|
||||
// The genesis blob decides how many custodians must cooperate to move bridged
|
||||
// funds, so it must state that in a form nothing can misread as a polynomial
|
||||
// degree. A bare `"threshold": 3` reads as the signer count to an operator and
|
||||
// as the degree to every threshold library, and those differ by one.
|
||||
func TestMChainGenesisPolicyIsUnambiguousAndDeployable(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
networkID uint32
|
||||
want string
|
||||
}{
|
||||
{constants.MainnetID, "3-of-5"},
|
||||
{constants.TestnetID, "3-of-5"},
|
||||
{constants.LocalID, "2-of-3"},
|
||||
} {
|
||||
cfg := GetConfig(tc.networkID)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
var blob struct {
|
||||
Policy string `json:"policy"`
|
||||
VM string `json:"vm"`
|
||||
}
|
||||
require.NoErrorf(t, json.Unmarshal([]byte(cfg.MChainGenesis), &blob),
|
||||
"network %d mchain.json must decode, policy included", tc.networkID)
|
||||
|
||||
require.Equalf(t, tc.want, blob.Policy, "network %d policy", tc.networkID)
|
||||
k, n := parsePolicy(t, blob.Policy)
|
||||
require.Greaterf(t, 2*k, n,
|
||||
"network %d: %s admits two disjoint quorums, so two halves of the committee could authorise contradictory releases",
|
||||
tc.networkID, blob.Policy)
|
||||
require.Equalf(t, "mpcvm", blob.VM,
|
||||
"network %d must name the current VM, not the retired ThresholdVM", tc.networkID)
|
||||
}
|
||||
}
|
||||
|
||||
// The ambiguous fields must be gone, not merely ignored. Leaving them in place
|
||||
// means the next reader has two numbers to choose between, and the whole point
|
||||
// of the policy field is that there is exactly one.
|
||||
func TestMChainGenesisHasNoAmbiguousThresholdFields(t *testing.T) {
|
||||
for _, networkID := range []uint32{constants.MainnetID, constants.TestnetID, constants.LocalID} {
|
||||
cfg := GetConfig(networkID)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
var blob map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(cfg.MChainGenesis), &blob))
|
||||
for _, dead := range []string{"mpcThreshold", "mpcParties", "threshold", "totalParties"} {
|
||||
require.NotContainsf(t, blob, dead,
|
||||
"network %d mchain.json still carries %q; the policy field is the only quorum statement",
|
||||
networkID, dead)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A policy is only real if the network has enough validators to hold its
|
||||
// shares. M-Chain draws its committee from the validator set, and RunKeygen
|
||||
// refuses a policy needing more parties than the committee has — so a genesis
|
||||
// declaring 7-of-10 on a five-validator network fails closed forever: no
|
||||
// custody key can ever be generated, and the failure only shows up the first
|
||||
// time someone tries to bridge.
|
||||
//
|
||||
// mainnet's mchain.json shipped exactly that mismatch for as long as the field
|
||||
// existed, harmlessly, because nothing read it. This test is what keeps it
|
||||
// harmless now that the policy is authoritative.
|
||||
func TestMChainPolicyIsSatisfiableByTheGenesisValidatorSet(t *testing.T) {
|
||||
for _, networkID := range []uint32{constants.MainnetID, constants.TestnetID, constants.LocalID} {
|
||||
cfg := GetConfig(networkID)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
raw, _, err := FromConfig(cfg)
|
||||
require.NoError(t, err)
|
||||
parsed, err := genesis.Parse(raw)
|
||||
require.NoError(t, err)
|
||||
|
||||
var blob struct {
|
||||
Policy string `json:"policy"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(cfg.MChainGenesis), &blob))
|
||||
_, n := parsePolicy(t, blob.Policy)
|
||||
|
||||
require.LessOrEqualf(t, n, len(parsed.Validators),
|
||||
"network %d: M-Chain policy %s needs %d parties but genesis declares %d validators; "+
|
||||
"keygen would fail closed and no custody key could ever exist",
|
||||
networkID, blob.Policy, n, len(parsed.Validators))
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ 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.36.7
|
||||
github.com/luxfi/consensus v1.36.12
|
||||
github.com/luxfi/crypto v1.20.2
|
||||
github.com/luxfi/database v1.21.1
|
||||
github.com/luxfi/ids v1.3.2
|
||||
@@ -40,7 +40,7 @@ require (
|
||||
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
|
||||
github.com/prometheus/common v0.68.0 // indirect
|
||||
github.com/rs/cors v1.11.1
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
@@ -56,13 +56,13 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.uber.org/goleak v1.3.0
|
||||
go.uber.org/mock v0.6.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 // indirect
|
||||
golang.org/x/mod v0.36.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/mod v0.37.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/time v0.15.0
|
||||
golang.org/x/tools v0.45.0
|
||||
golang.org/x/tools v0.47.0
|
||||
gonum.org/v1/gonum v0.17.0
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
@@ -73,7 +73,7 @@ require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0
|
||||
github.com/cockroachdb/errors v1.12.0 // indirect
|
||||
github.com/cockroachdb/errors v1.13.0 // indirect
|
||||
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
|
||||
@@ -81,7 +81,7 @@ require (
|
||||
github.com/deckarep/golang-set/v2 v2.9.0 // indirect
|
||||
github.com/fatih/structtag v1.2.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/getsentry/sentry-go v0.44.1 // indirect
|
||||
github.com/getsentry/sentry-go v0.46.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
@@ -90,7 +90,7 @@ require (
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.18.6
|
||||
github.com/klauspost/compress v1.19.1
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
@@ -98,7 +98,7 @@ require (
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||
github.com/sanity-io/litter v1.5.5 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
@@ -107,8 +107,8 @@ require (
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -120,17 +120,17 @@ require (
|
||||
github.com/luxfi/accel v1.2.4
|
||||
github.com/luxfi/api v1.1.1
|
||||
github.com/luxfi/atomic v1.0.0
|
||||
github.com/luxfi/chains v1.7.7
|
||||
github.com/luxfi/chains v1.7.9
|
||||
github.com/luxfi/codec v1.2.1
|
||||
github.com/luxfi/compress v0.1.1
|
||||
github.com/luxfi/constants v1.6.2
|
||||
github.com/luxfi/container v0.2.1
|
||||
github.com/luxfi/filesystem v0.0.1
|
||||
github.com/luxfi/genesis v1.16.2
|
||||
github.com/luxfi/genesis v1.16.4
|
||||
github.com/luxfi/genesis/pkg/genesis/security v1.13.8
|
||||
github.com/luxfi/geth v1.20.1
|
||||
github.com/luxfi/go-bip39 v1.2.0
|
||||
github.com/luxfi/keys v1.4.1
|
||||
github.com/luxfi/kms v1.12.10
|
||||
github.com/luxfi/math/safe v0.0.1
|
||||
github.com/luxfi/net v0.1.1
|
||||
github.com/luxfi/p2p v1.22.1
|
||||
@@ -145,33 +145,34 @@ require (
|
||||
github.com/luxfi/utils v1.3.1
|
||||
github.com/luxfi/utxo v0.5.8
|
||||
github.com/luxfi/validators v1.3.1
|
||||
github.com/luxfi/vm v1.3.1
|
||||
github.com/luxfi/vm v1.3.3
|
||||
github.com/luxfi/warp v1.24.1
|
||||
github.com/luxfi/zap v1.2.6
|
||||
github.com/luxfi/zwing v0.6.1
|
||||
github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354
|
||||
github.com/zap-proto/http v0.0.0-20260506200741-fd6047874433
|
||||
github.com/valyala/fasthttp v1.73.0
|
||||
github.com/zap-proto/http v0.3.0
|
||||
go.uber.org/zap v1.27.1
|
||||
)
|
||||
|
||||
require (
|
||||
capnproto.org/go/capnp/v3 v3.0.1-alpha.2 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
filippo.io/hpke v0.4.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect
|
||||
@@ -180,7 +181,6 @@ require (
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.5.0 // indirect
|
||||
github.com/btcsuite/btcd/chainhash/v2 v2.0.0 // indirect
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||
github.com/colega/zeropool v0.0.0-20230505084239-6fb4a4f75381 // 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
|
||||
@@ -191,11 +191,14 @@ require (
|
||||
github.com/hanzos3/go-sdk v1.0.2 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/luxfi/age v1.6.0 // indirect
|
||||
github.com/luxfi/bft v0.1.5 // indirect
|
||||
github.com/luxfi/corona v0.10.4 // indirect
|
||||
github.com/luxfi/crypto/ipa v1.2.4 // indirect
|
||||
github.com/luxfi/dkg v0.3.5 // indirect
|
||||
github.com/luxfi/keys v1.4.1 // indirect
|
||||
github.com/luxfi/lattice/v7 v7.1.4 // indirect
|
||||
github.com/luxfi/lens v0.2.1 // indirect
|
||||
github.com/luxfi/light v1.0.0 // indirect
|
||||
github.com/luxfi/magnetar v1.2.3 // indirect
|
||||
github.com/luxfi/mdns v0.1.1 // indirect
|
||||
github.com/luxfi/mlwe v0.3.0 // indirect
|
||||
@@ -213,6 +216,8 @@ require (
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/zap-proto/go v1.1.0 // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
)
|
||||
@@ -256,7 +261,6 @@ require (
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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=
|
||||
capnproto.org/go/capnp/v3 v3.0.1-alpha.2 h1:W/cf+XEArUSwcBBE/9wS2NpWDkM5NLQOjmzEiHZpYi0=
|
||||
capnproto.org/go/capnp/v3 v3.0.1-alpha.2/go.mod h1:2vT5D2dtG8sJGEoEKU17e+j7shdaYp1Myl8X03B3hmc=
|
||||
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=
|
||||
@@ -19,10 +17,14 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA
|
||||
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=
|
||||
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.13 h1:5KgbxMaS2coSWRrx9TX/QtWbqzgQkOdEa3sZPhBhCSg=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.13/go.mod h1:8zz7wedqtCbw5e9Mi2doEwDyEgHcEE9YOJp6a8jdSMY=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.13 h1:mA59E3fokBvyEGHKFdnpNNrvaR351cqiHgRg+JzOSRI=
|
||||
@@ -37,16 +39,24 @@ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 h1:eZioDaZGJ0tMM4gzmkNIO2aAoQd+je7Ug7TkvAzlmkU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18/go.mod h1:CCXwUKAJdoWr6/NcxZ+zsiPr6oH/Q5aTooRGYieAyj4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10 h1:fJvQ5mIBVfKtiyx0AHY6HeWcRX5LGANLpq8SVR+Uazs=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10/go.mod h1:Kzm5e6OmNH8VMkgK9t+ry5jEih4Y8whqs+1hrkxim1I=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 h1:/A/xDuZAVD2BpsS2fftFRo/NoEKQJ8YTnJDEHBy2Gtg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18/go.mod h1:hWe9b4f+djUQGmyiGEeOnZv69dtMSgpDRIvNMvuvzvY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2 h1:M1A9AjcFwlxTLuf0Faj88L8Iqw0n/AJHjpZTQzMMsSc=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2/go.mod h1:KsdTV6Q9WKUZm2mNJnUFmIoXfZux91M3sr/a4REX8e0=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 h1:GcLE9ba5ehAQma6wlopUesYg/hbcOhFNWTjELkiWkh4=
|
||||
@@ -99,8 +109,8 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f h1:otljaYPt5hWxV3MUfO5dFPFiOXg9CyG5/kCfayTqsJ4=
|
||||
github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU=
|
||||
github.com/cockroachdb/errors v1.12.0 h1:d7oCs6vuIMUQRVbi6jWWWEJZahLCfJpnJSVobd1/sUo=
|
||||
github.com/cockroachdb/errors v1.12.0/go.mod h1:SvzfYNNBshAVbZ8wzNc/UPK3w1vf0dKDUP41ucAIf7g=
|
||||
github.com/cockroachdb/errors v1.13.0 h1:BoCcJeiP9hpBJDETkX19qi8Tb8So37srSsp3stTaDMQ=
|
||||
github.com/cockroachdb/errors v1.13.0/go.mod h1:bjxt/4E5+OyuAnacpTIU9rn2mzPu1VlthvHP+xpROq0=
|
||||
github.com/cockroachdb/fifo v0.0.0-20240816210425-c5d0cb0b6fc0 h1:pU88SPhIFid6/k0egdR5V6eALQYq2qbSmukrkgIh/0A=
|
||||
github.com/cockroachdb/fifo v0.0.0-20240816210425-c5d0cb0b6fc0/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M=
|
||||
github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 h1:ASDL+UJcILMqgNeV5jiqR4j+sTuvQNHdf2chuKj1M5k=
|
||||
@@ -111,8 +121,6 @@ github.com/cockroachdb/redact v1.1.8 h1:8eVLLj6juKxiKrAEw2b8cJvNqWq++U8WOfQFuL7K
|
||||
github.com/cockroachdb/redact v1.1.8/go.mod h1:GceHHpJ0rMDpYARL5In88Alq/xMBUtVlz7Qxix6ZVkw=
|
||||
github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb h1:3bCgBvB8PbJVMX1ouCcSIxvsqKPYM7gs72o0zC76n9g=
|
||||
github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
|
||||
github.com/colega/zeropool v0.0.0-20230505084239-6fb4a4f75381 h1:d5EKgQfRQvO97jnISfR89AiCCCJMwMFoSxUiU0OGCRU=
|
||||
github.com/colega/zeropool v0.0.0-20230505084239-6fb4a4f75381/go.mod h1:OU76gHeRo8xrzGJU3F3I1CqX1ekM8dfJw0+wPeMwnp0=
|
||||
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=
|
||||
@@ -167,8 +175,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/getsentry/sentry-go v0.44.1 h1:/cPtrA5qB7uMRrhgSn9TYtcEF36auGP3Y6+ThvD/yaI=
|
||||
github.com/getsentry/sentry-go v0.44.1/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0=
|
||||
github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns=
|
||||
github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw=
|
||||
github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
|
||||
github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
|
||||
github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
|
||||
@@ -280,8 +288,8 @@ github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlT
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
@@ -305,18 +313,22 @@ github.com/luxfi/api v1.1.1 h1:CXD7m0quPmUm+Qw35TrF+E7b0Fq4qz9gHfrZ5gyrjHU=
|
||||
github.com/luxfi/api v1.1.1/go.mod h1:g6J0iohVqaIj2aO1u/ZJPqjiX2tog0NM3/SBf7wJ4cA=
|
||||
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/bft v0.1.5 h1:5xVLPkog4e5LTgaVlb9pgxA0EWE6tkrKwHPZVRz+RZw=
|
||||
github.com/luxfi/bft v0.1.5/go.mod h1:5I8Ft8yA69xZlDe3RB0i4MgbqFKLZe65o/sha8JuKvU=
|
||||
github.com/luxfi/cache v1.3.1 h1:grQhi/B5GKypG7avDMeY143QTgFbfEvQICKNIh1Cw6U=
|
||||
github.com/luxfi/cache v1.3.1/go.mod h1:2MokdbeNUy/9O3mdREWkE6BiN7tRvePkXiKkcb+4M7g=
|
||||
github.com/luxfi/chains v1.7.7 h1:LOOF3hxI4/hGCHMOMyKkThphU67GNCEXe3ouPGkNX2Q=
|
||||
github.com/luxfi/chains v1.7.7/go.mod h1:wsUtdcLtnbnS7AJcCpTpa0TcZ2vylvvDko+QI98mHKU=
|
||||
github.com/luxfi/chains v1.7.9 h1:sDF/WQn56o/4i1/uCS8i5ENDMuyWi8+iVcnYGAstMDM=
|
||||
github.com/luxfi/chains v1.7.9/go.mod h1:Qtx9JNLOWYyhnvq434YSjPINT6we9nfViUIIuGD/2bc=
|
||||
github.com/luxfi/codec v1.2.1 h1:NA/O3dWm9QejQPdjLEIVx42ddVqAXsy6Y6igL/V1+aU=
|
||||
github.com/luxfi/codec v1.2.1/go.mod h1:xjWOTEbw9gxY/N8nZwQPvRCfPnK/ugJHBWsb3BZ0HHs=
|
||||
github.com/luxfi/compress v0.1.1 h1:cQjRYQFRrw8HinjW1T8FmKgqdRwrFYDCdecc1t0i+uQ=
|
||||
github.com/luxfi/compress v0.1.1/go.mod h1:d4CkRRmwPzafIp57Jxra3RmAmNwNwU8Oc0QBBRlTqGo=
|
||||
github.com/luxfi/concurrent v0.1.1 h1:LkAmNXybOsAm97nFILqMDBp/YLgleuLmyGomFT7YNxU=
|
||||
github.com/luxfi/concurrent v0.1.1/go.mod h1:GlkfiJtPBpatNxvROf7hkPi4gsnmdHGmqhDnVWFrkZE=
|
||||
github.com/luxfi/consensus v1.36.7 h1:ijJh/4sl1Y65ziUFsQJa2DLwnAhb1h/c72FHybEXKps=
|
||||
github.com/luxfi/consensus v1.36.7/go.mod h1:m4aMFbKYxwM2X4oPYRjq4LmDSShfCgAHnhmxIGKDyFU=
|
||||
github.com/luxfi/consensus v1.36.10 h1:LJPvHc2zL7VFhbqfYu11rA50yvxzTK17Z4mRN04HQhU=
|
||||
github.com/luxfi/consensus v1.36.10/go.mod h1:iwlx11D7BRdEaUqyUvueAe2Fit1K1k26avBK2eLYdnc=
|
||||
github.com/luxfi/consensus v1.36.12 h1:MJKq6j9+04Y2NC+NArGQgv5QNcjprYCk+gppCz1PdSg=
|
||||
github.com/luxfi/consensus v1.36.12/go.mod h1:mCtA6k31FVW/H6rsYQ7V8VvS198WsFS8oFo/z3IMI+A=
|
||||
github.com/luxfi/constants v1.6.2 h1:pXHdKIFbfE9qX4xOjq2LxYvagNhhNvspUVEbPcIEKfA=
|
||||
github.com/luxfi/constants v1.6.2/go.mod h1:r0oH8C/+r/XFYBq1AJxt6zWRKKRKgDzrEMop/CCs9rI=
|
||||
github.com/luxfi/container v0.2.1 h1:MTnfKXzS5+oxV5jKZerdOxSA6iMPaQI9/FWGufizzaw=
|
||||
@@ -329,16 +341,14 @@ github.com/luxfi/crypto/ipa v1.2.4 h1:6xfwhI9/HrcDkF3Ti5/NxsNQIWbwYDJmRSNIHRQ/xf
|
||||
github.com/luxfi/crypto/ipa v1.2.4/go.mod h1:43J6f6rcfUMrZt4cQectMOZb6Ps+fAEj8ZTPC3Kk+gE=
|
||||
github.com/luxfi/database v1.21.1 h1:GNnoWVa82l+n2dK7x2aG8LR2NEToq6ZCRX0sQjmK0OM=
|
||||
github.com/luxfi/database v1.21.1/go.mod h1:Gc7Z2OPrrcYLnAL8B1trOnguXauOlSDV5tkviLN6Xec=
|
||||
github.com/luxfi/dex v1.14.0 h1:4PtorVbRmbD73S6YWPvgp5GRCb9jeuaedl7mo1CbzCI=
|
||||
github.com/luxfi/dex v1.14.0/go.mod h1:wYWQmwospvdKBWQ6OJMXb8I+kfgEtE46R1rD8H7vHCQ=
|
||||
github.com/luxfi/dkg v0.3.5 h1:s2L2mMQaz+n9m0b0ghvoV5VZNxiwb2z4WrGugvK0udY=
|
||||
github.com/luxfi/dkg v0.3.5/go.mod h1:M+WH7GFRN+YUD851Rlnumdp0Md98kplNN8pVx65U8I8=
|
||||
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.1.1 h1:MJhVXIPh1dbysvYEjtaEA/Z0FUTiI7n0DwOF54FS08c=
|
||||
github.com/luxfi/formatting v1.1.1/go.mod h1:zhBWp6fLZduhpiAdPgVDdPVOyhw4FvwRUksF6+xKQCE=
|
||||
github.com/luxfi/genesis v1.16.2 h1:OLQg5+ln8qgORBYnJuQsZxar8AfZlsXg5g/f95AraPI=
|
||||
github.com/luxfi/genesis v1.16.2/go.mod h1:piaSqJY80eVpgov8DUWQXll9IdlCroWgvhnwC7/3lTA=
|
||||
github.com/luxfi/genesis v1.16.4 h1:8ZtqvgPnICgGpjBzAjYAPrI3qwr1g1DPbum+hgjc4bg=
|
||||
github.com/luxfi/genesis v1.16.4/go.mod h1:0F6hV6GfwDSmIWsueB7/vqPZFxyS7A4Jo3p1QU87fFc=
|
||||
github.com/luxfi/genesis/pkg/genesis/security v1.13.8 h1:9ier0p55ErSpoXHRJpZ04WV5HwwMB1uDrU7PHGBKG2U=
|
||||
github.com/luxfi/genesis/pkg/genesis/security v1.13.8/go.mod h1:DzU+GYUFv12ja4Vc46bWKNBBmNYbcow3u/DASx4wpfI=
|
||||
github.com/luxfi/geth v1.20.1 h1:QUGQr4AKvADjwMi7t8a0OfoyxShgEcI9pwie1jFYfm0=
|
||||
@@ -353,10 +363,14 @@ github.com/luxfi/keychain v1.1.1 h1:dTYEPy6CGVC1sogMci4iJogUvW6VdTmemplQdzRqnAs=
|
||||
github.com/luxfi/keychain v1.1.1/go.mod h1:hAzBcwxGumtoYrM5hfhwdt8wE0p7r2JCd5AxswqfkoY=
|
||||
github.com/luxfi/keys v1.4.1 h1:2Zcoovaz9OLPz7m7VGXfRrGnrlqt0GeUpJclsPBi4EU=
|
||||
github.com/luxfi/keys v1.4.1/go.mod h1:P8EUP5DKrR1SUZBGZjDT3rWcp2P1miUlVh7IBRNBphU=
|
||||
github.com/luxfi/kms v1.12.10 h1:fXgisnBkixFSrqhOFbZVRQkf8cX4q4vS3Gix1qjL3PQ=
|
||||
github.com/luxfi/kms v1.12.10/go.mod h1:CCcWDXIlDT2TwfYAUxedLyzShFRJAlVlp15zQ2L3CrU=
|
||||
github.com/luxfi/lattice/v7 v7.1.4 h1:hQR02M6cHTAV5+joOPi9gb9Gm+z/hKJnhJF4IlciIJs=
|
||||
github.com/luxfi/lattice/v7 v7.1.4/go.mod h1:DmIQFi3mJiehVsR235l1NKYEU0JhU649OX5p7gMEW2c=
|
||||
github.com/luxfi/lens v0.2.1 h1:5Qd0GdjbM+XUVgwDbZ452tKkR7yeE8QnBTHHaH8fJNY=
|
||||
github.com/luxfi/lens v0.2.1/go.mod h1:6FIhC8weEE5RbNMF3SaE+XPSB9cr6FmjypYBoHkz4JQ=
|
||||
github.com/luxfi/light v1.0.0 h1:zTJgp5M0xX8rIwRjYDQgOGhLas3rgXyhkszrTX+ne3s=
|
||||
github.com/luxfi/light v1.0.0/go.mod h1:1G0kgjEe/srlBMCIrFq4IvhnrMuHKFG18CRUWfw1z30=
|
||||
github.com/luxfi/log v1.4.3 h1:xkUKRWvQ4ZwvlUC2e0/RTtHYZOYSMvSQ9W9lbjwBmiI=
|
||||
github.com/luxfi/log v1.4.3/go.mod h1:myIkufyiQomSQH34K981kbz6cG4WUoerRUh7F4XhlQI=
|
||||
github.com/luxfi/magnetar v1.2.3 h1:n4UrJZLK+mhDDZr1HLl2H/KgA6o6v62r5oiC61R7awE=
|
||||
@@ -423,8 +437,10 @@ github.com/luxfi/validators v1.3.1 h1:+/7j0CTXlMKyaSLFM+gd6Fq64/edORJfrD/xGnf7Xc
|
||||
github.com/luxfi/validators v1.3.1/go.mod h1:sIQyUZOvXoJ/9/RCOhHSzjumZIoxiqacNOn5mQWVozU=
|
||||
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.3.1 h1:rLCbygaajehVkUoJ5czwhpUAJaC5J5okq3p+j2QSPSo=
|
||||
github.com/luxfi/vm v1.3.1/go.mod h1:uViH3COP8hhCbj42v1MTohkPCDwYQCZanNIOb/StWqY=
|
||||
github.com/luxfi/vm v1.3.1 h1:U2Bv7IDRFv8JtjUARfY53ZnOPRB2iPrPHSMDCQ+T0Js=
|
||||
github.com/luxfi/vm v1.3.1/go.mod h1:6YR/uFV2FoofmogQShv+HM7V8alz5rbrQYjp5w0bNVA=
|
||||
github.com/luxfi/vm v1.3.3 h1:BEBamD9VUcPG8mtL2Ga0Us1lTQ+ubaZqng/PZjNqzPc=
|
||||
github.com/luxfi/vm v1.3.3/go.mod h1:v2XjW0yymxHwy1xPsSz338s0J8nRqOIb6RiDf0wUEgk=
|
||||
github.com/luxfi/warp v1.24.1 h1:9F+z6fy4sxmXp+3LlR9m3TiZ8Dfthh+s5KOeewSJL30=
|
||||
github.com/luxfi/warp v1.24.1/go.mod h1:kRGQDt6EB3oRLYo71aXqnmJuCBVfND3oQ5/2miaLoyg=
|
||||
github.com/luxfi/zap v1.2.6 h1:NBpbm9Gib41Oi/XAkAZKQ3hb+xCafo7JsrUjw+bKiAc=
|
||||
@@ -517,15 +533,15 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/common v0.68.0 h1:8rQJvQmYltsR2L7h8Zw0Iyj8WYNNmpwikoQTZXwfVeA=
|
||||
github.com/prometheus/common v0.68.0/go.mod h1:4soH+U8yJSROk7OJ//hmTiWKsxapv6zRGgTt3keN8gQ=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
||||
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
@@ -583,27 +599,33 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk=
|
||||
github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk=
|
||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s=
|
||||
github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zap-proto/http v0.0.0-20260506200741-fd6047874433 h1:7WsCr/pZvWozimdYNffL3B9K6gLr8w0Z7WAi1+eZWtc=
|
||||
github.com/zap-proto/http v0.0.0-20260506200741-fd6047874433/go.mod h1:xySyVTIwjknmVE+p+6rukkX86rFZtXeAu/QY7KXMYqw=
|
||||
github.com/zap-proto/go v1.1.0 h1:DDGhuTBNqkmNax7QV/VvkInJR7hiOcybwUjN3YBt8fg=
|
||||
github.com/zap-proto/go v1.1.0/go.mod h1:914SNGTH6Rv3Yu1MweWJBPEN8FZlo5C39QyhaB0C7Q0=
|
||||
github.com/zap-proto/http v0.3.0 h1:l7DvlngiYqmzNY6fzyRYw2ZIAhF35FqwOe6mvAOqpMg=
|
||||
github.com/zap-proto/http v0.3.0/go.mod h1:UYfGhDDCetgxs65XSev8Lpf65COg5vKQK+cWwZGh4zQ=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
|
||||
@@ -642,16 +664,16 @@ golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnf
|
||||
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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 h1:4d4PbuBNwaxMXkXI8yiIYjydtMU+04RHeuSxJdgKftM=
|
||||
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
@@ -666,15 +688,15 @@ golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -697,8 +719,8 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -706,8 +728,8 @@ 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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -717,8 +739,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
+12
@@ -34,6 +34,11 @@ type Net interface {
|
||||
// IsBootstrapped returns true if the chains in this chain are done bootstrapping
|
||||
IsBootstrapped() bool
|
||||
|
||||
// Bootstrapping returns the IDs of the chains in this net that have NOT yet
|
||||
// finished initial sync. The net owns the bootstrapping set, so it is the
|
||||
// net — not its caller — that can name those chains.
|
||||
Bootstrapping() []ids.ID
|
||||
|
||||
// IsChainBootstrapped reports whether a SPECIFIC chain in this net has finished
|
||||
// initial sync — i.e. Bootstrapped(chainID) was called for it (the chain reached
|
||||
// the network frontier and its VM went to normal operation). This is the per-chain
|
||||
@@ -75,6 +80,13 @@ func (s *chain) IsBootstrapped() bool {
|
||||
return s.bootstrapping.Len() == 0
|
||||
}
|
||||
|
||||
func (s *chain) Bootstrapping() []ids.ID {
|
||||
s.lock.RLock()
|
||||
defer s.lock.RUnlock()
|
||||
|
||||
return s.bootstrapping.List()
|
||||
}
|
||||
|
||||
// IsChainBootstrapped assumes MONOTONIC per-process bootstrapped state: a chain
|
||||
// only ever moves bootstrapping→bootstrapped (Bootstrapped is forward-only and
|
||||
// AddChain refuses to re-add a chain already in either set), so this signal — and
|
||||
|
||||
@@ -68,3 +68,26 @@ func TestIsAllowed(t *testing.T) {
|
||||
require.False(s.IsAllowed(ids.GenerateTestNodeID(), false), "Non-validator should not be allowed with validator only rules and allowed nodes")
|
||||
require.True(s.IsAllowed(allowedNodeID, true), "Non-validator allowed node should be allowed with validator only rules and allowed nodes")
|
||||
}
|
||||
|
||||
// Bootstrapping must name the CHAINS still syncing. The net-level aggregate
|
||||
// IsBootstrapped() only says "something here is unconverged"; only the chain
|
||||
// IDs say what.
|
||||
func TestNetBootstrappingNamesTheChains(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
chainID0 := ids.GenerateTestID()
|
||||
chainID1 := ids.GenerateTestID()
|
||||
|
||||
s := New(ids.GenerateTestNodeID(), Config{})
|
||||
require.Empty(s.Bootstrapping())
|
||||
|
||||
s.AddChain(chainID0)
|
||||
s.AddChain(chainID1)
|
||||
require.ElementsMatch([]ids.ID{chainID0, chainID1}, s.Bootstrapping())
|
||||
|
||||
s.Bootstrapped(chainID0)
|
||||
require.Equal([]ids.ID{chainID1}, s.Bootstrapping())
|
||||
|
||||
s.Bootstrapped(chainID1)
|
||||
require.Empty(s.Bootstrapping())
|
||||
}
|
||||
|
||||
+74
-5
@@ -14,9 +14,10 @@ import (
|
||||
"github.com/luxfi/math/set"
|
||||
"github.com/luxfi/node/message"
|
||||
"github.com/luxfi/node/proto/p2p"
|
||||
"github.com/luxfi/node/trace"
|
||||
"github.com/luxfi/node/version"
|
||||
"github.com/luxfi/timer"
|
||||
"github.com/luxfi/node/trace"
|
||||
luxversion "github.com/luxfi/version"
|
||||
)
|
||||
|
||||
// router implements Router interface for routing messages to chain handlers
|
||||
@@ -193,26 +194,94 @@ func (r *chainRouter) AddChain(ctx context.Context, chainID ids.ID, h handler.Ha
|
||||
)
|
||||
}
|
||||
|
||||
// versionedConnector is the capability a chain handler advertises when it can
|
||||
// forward a peer's REAL application version to its VM. The consensus
|
||||
// handler.Handler.Connected signature carries only the nodeID (the version was
|
||||
// dropped at that boundary); a handler that implements this receives the real
|
||||
// version instead. blockHandler implements it. The version must survive to the
|
||||
// inner VM: the C-Chain (coreth) state-sync peer tracker compares peer versions
|
||||
// and dereferences a nil version, panicking a state-syncing node.
|
||||
type versionedConnector interface {
|
||||
ConnectedWithVersion(ctx context.Context, nodeID ids.NodeID, nodeVersion *luxversion.Application) error
|
||||
}
|
||||
|
||||
// toAppVersion converts the node's peer version (github.com/luxfi/node/version)
|
||||
// to the github.com/luxfi/version.Application the VM Connected boundary
|
||||
// (chain.VersionInfo) expects. nil-safe: a nil peer version maps to nil.
|
||||
func toAppVersion(v *version.Application) *luxversion.Application {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return &luxversion.Application{
|
||||
Name: v.Name,
|
||||
Major: v.Major,
|
||||
Minor: v.Minor,
|
||||
Patch: v.Patch,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *chainRouter) Connected(nodeID ids.NodeID, nodeVersion *version.Application, netID ids.ID) {
|
||||
r.lock.Lock()
|
||||
defer r.lock.Unlock()
|
||||
|
||||
r.connectedPeers.Add(nodeID)
|
||||
handlers := make([]handler.Handler, 0, len(r.chains))
|
||||
for _, h := range r.chains {
|
||||
handlers = append(handlers, h)
|
||||
}
|
||||
r.lock.Unlock()
|
||||
|
||||
r.log.Debug("peer connected",
|
||||
log.Stringer("nodeID", nodeID),
|
||||
log.Any("version", nodeVersion),
|
||||
log.Stringer("netID", netID),
|
||||
)
|
||||
|
||||
// Deliver the connection to every chain handler so VMs observe peer
|
||||
// connectivity — the P-chain uptime tracker in particular. Handlers dedup, so
|
||||
// repeated dispatch of the same connection (once per tracked network) is
|
||||
// safe. Dispatch OUTSIDE the router lock: a handler must never re-enter the
|
||||
// router while we hold it.
|
||||
//
|
||||
// Deliver the REAL peer version through the versionedConnector capability so
|
||||
// it reaches the inner VM (proposervm → coreth state-sync). Only handlers
|
||||
// that cannot carry a version fall back to the plain nodeID-only Connected.
|
||||
appVersion := toAppVersion(nodeVersion)
|
||||
for _, h := range handlers {
|
||||
var err error
|
||||
if vc, ok := h.(versionedConnector); ok {
|
||||
err = vc.ConnectedWithVersion(context.Background(), nodeID, appVersion)
|
||||
} else {
|
||||
err = h.Connected(context.Background(), nodeID)
|
||||
}
|
||||
if err != nil {
|
||||
r.log.Debug("chain handler Connected failed",
|
||||
log.Stringer("nodeID", nodeID),
|
||||
log.Err(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *chainRouter) Disconnected(nodeID ids.NodeID) {
|
||||
r.lock.Lock()
|
||||
defer r.lock.Unlock()
|
||||
|
||||
r.connectedPeers.Remove(nodeID)
|
||||
handlers := make([]handler.Handler, 0, len(r.chains))
|
||||
for _, h := range r.chains {
|
||||
handlers = append(handlers, h)
|
||||
}
|
||||
r.lock.Unlock()
|
||||
|
||||
r.log.Debug("peer disconnected",
|
||||
log.Stringer("nodeID", nodeID),
|
||||
)
|
||||
|
||||
for _, h := range handlers {
|
||||
if err := h.Disconnected(context.Background(), nodeID); err != nil {
|
||||
r.log.Debug("chain handler Disconnected failed",
|
||||
log.Stringer("nodeID", nodeID),
|
||||
log.Err(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *chainRouter) Benched(chainID ids.ID, nodeID ids.NodeID) {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/consensus/networking/handler"
|
||||
"github.com/luxfi/constants"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/math/set"
|
||||
"github.com/luxfi/node/version"
|
||||
luxversion "github.com/luxfi/version"
|
||||
)
|
||||
|
||||
// fakeVersionedHandler implements handler.Handler AND the versionedConnector
|
||||
// capability, recording which path the router used and the version delivered.
|
||||
type fakeVersionedHandler struct {
|
||||
mu sync.Mutex
|
||||
versionedHit bool
|
||||
plainHit bool
|
||||
gotAppVersion *luxversion.Application
|
||||
}
|
||||
|
||||
func (h *fakeVersionedHandler) HandleInbound(context.Context, handler.Message) error { return nil }
|
||||
func (h *fakeVersionedHandler) HandleOutbound(context.Context, handler.Message) error { return nil }
|
||||
|
||||
func (h *fakeVersionedHandler) Connected(context.Context, ids.NodeID) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.plainHit = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *fakeVersionedHandler) Disconnected(context.Context, ids.NodeID) error { return nil }
|
||||
|
||||
func (h *fakeVersionedHandler) ConnectedWithVersion(_ context.Context, _ ids.NodeID, v *luxversion.Application) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.versionedHit = true
|
||||
h.gotAppVersion = v
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestChainRouterConnectedDeliversConvertedVersion is the router half of the
|
||||
// RED CRITICAL #1 fix: chainRouter.Connected must deliver the REAL peer version
|
||||
// to a version-capable handler, converting it from the node's peer version type
|
||||
// (github.com/luxfi/node/version) to the VM boundary type
|
||||
// (github.com/luxfi/version, aka chain.VersionInfo). The old code dropped the
|
||||
// version at dispatch (h.Connected(ctx, nodeID)); the fix routes through the
|
||||
// versionedConnector capability so the real, converted version survives.
|
||||
func TestChainRouterConnectedDeliversConvertedVersion(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
h := &fakeVersionedHandler{}
|
||||
chainID := ids.GenerateTestID()
|
||||
|
||||
r := &chainRouter{
|
||||
log: log.Noop(),
|
||||
chains: map[ids.ID]handler.Handler{chainID: h},
|
||||
connectedPeers: set.NewSet[ids.NodeID](1),
|
||||
}
|
||||
|
||||
nodeID := ids.GenerateTestNodeID()
|
||||
peerVersion := &version.Application{Name: "lux", Major: 1, Minor: 36, Patch: 27}
|
||||
|
||||
r.Connected(nodeID, peerVersion, constants.PrimaryNetworkID)
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
require.True(h.versionedHit, "router must use the versioned capability path for a version-capable handler")
|
||||
require.False(h.plainHit, "router must NOT fall back to the nil-version plain Connected")
|
||||
require.NotNil(h.gotAppVersion, "handler must receive a non-nil converted version")
|
||||
require.Equal("lux", h.gotAppVersion.Name)
|
||||
require.Equal(1, h.gotAppVersion.Major)
|
||||
require.Equal(36, h.gotAppVersion.Minor)
|
||||
require.Equal(27, h.gotAppVersion.Patch)
|
||||
}
|
||||
|
||||
// TestToAppVersionNilSafe documents that a nil peer version converts to nil
|
||||
// (never a panic) — the conversion is defensive at the boundary.
|
||||
func TestToAppVersionNilSafe(t *testing.T) {
|
||||
require.Nil(t, toAppVersion(nil))
|
||||
}
|
||||
-265
@@ -1,265 +0,0 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package node
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/crypto/bls"
|
||||
"github.com/luxfi/genesis/pkg/genesis"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/math/set"
|
||||
"github.com/luxfi/node/server/http"
|
||||
"github.com/luxfi/node/benchlist"
|
||||
"github.com/luxfi/node/chains"
|
||||
"github.com/luxfi/node/nets"
|
||||
"github.com/luxfi/node/network"
|
||||
"github.com/luxfi/node/trace"
|
||||
"github.com/luxfi/node/upgrade"
|
||||
"github.com/luxfi/node/vms/platformvm/txs/fee"
|
||||
"github.com/luxfi/timer"
|
||||
"github.com/luxfi/node/utils/profiler"
|
||||
)
|
||||
|
||||
type APIIndexerConfig struct {
|
||||
IndexAPIEnabled bool `json:"indexAPIEnabled"`
|
||||
IndexAllowIncomplete bool `json:"indexAllowIncomplete"`
|
||||
}
|
||||
|
||||
type TargeterConfig struct {
|
||||
// VdrAlloc is the percentage of resource usage that is attributed to validators
|
||||
// The range is [0, 1], defaults to 1 (100%)
|
||||
VdrAlloc float64 `json:"vdrAlloc"`
|
||||
// MaxNonVdrUsage is the maximum amount of resources that non-validators can use
|
||||
// The range is [0, 1], defaults to 0
|
||||
MaxNonVdrUsage float64 `json:"maxNonVdrUsage"`
|
||||
// MaxNonVdrNodeUsage is the maximum amount of resources that a non-validator node can use
|
||||
// The range is [0, 1], defaults to 0
|
||||
MaxNonVdrNodeUsage float64 `json:"maxNonVdrNodeUsage"`
|
||||
}
|
||||
|
||||
type HTTPConfig struct {
|
||||
server.HTTPConfig
|
||||
APIConfig `json:"apiConfig"`
|
||||
HTTPHost string `json:"httpHost"`
|
||||
HTTPPort uint16 `json:"httpPort"`
|
||||
|
||||
HTTPSEnabled bool `json:"httpsEnabled"`
|
||||
HTTPSKey []byte `json:"-"`
|
||||
HTTPSCert []byte `json:"-"`
|
||||
|
||||
HTTPAllowedOrigins []string `json:"httpAllowedOrigins"`
|
||||
HTTPAllowedHosts []string `json:"httpAllowedHosts"`
|
||||
|
||||
ShutdownTimeout time.Duration `json:"shutdownTimeout"`
|
||||
ShutdownWait time.Duration `json:"shutdownWait"`
|
||||
}
|
||||
|
||||
type APIConfig struct {
|
||||
APIIndexerConfig `json:"indexerConfig"`
|
||||
|
||||
// Enable/Disable APIs
|
||||
AdminAPIEnabled bool `json:"adminAPIEnabled"`
|
||||
InfoAPIEnabled bool `json:"infoAPIEnabled"`
|
||||
KeystoreAPIEnabled bool `json:"keystoreAPIEnabled"`
|
||||
MetricsAPIEnabled bool `json:"metricsAPIEnabled"`
|
||||
HealthAPIEnabled bool `json:"healthAPIEnabled"`
|
||||
}
|
||||
|
||||
type IPConfig struct {
|
||||
PublicIP string `json:"publicIP"`
|
||||
PublicIPResolutionService string `json:"publicIPResolutionService"`
|
||||
PublicIPResolutionFreq time.Duration `json:"publicIPResolutionFreq"`
|
||||
// The host portion of the address to listen on. The port to
|
||||
// listen on will be sourced from IPPort.
|
||||
//
|
||||
// - If empty, listen on all interfaces (both ipv4 and ipv6).
|
||||
// - If populated, listen only on the specified address.
|
||||
ListenHost string `json:"listenHost"`
|
||||
ListenPort uint16 `json:"listenPort"`
|
||||
}
|
||||
|
||||
type StakingConfig struct {
|
||||
genesis.StakingConfig
|
||||
SybilProtectionEnabled bool `json:"sybilProtectionEnabled"`
|
||||
PartialSyncPrimaryNetwork bool `json:"partialSyncPrimaryNetwork"`
|
||||
StakingTLSCert tls.Certificate `json:"-"`
|
||||
StakingSigningKey *bls.SecretKey `json:"-"`
|
||||
SybilProtectionDisabledWeight uint64 `json:"sybilProtectionDisabledWeight"`
|
||||
StakingKeyPath string `json:"stakingKeyPath"`
|
||||
StakingCertPath string `json:"stakingCertPath"`
|
||||
StakingSignerPath string `json:"stakingSignerPath"`
|
||||
}
|
||||
|
||||
type StateSyncConfig struct {
|
||||
StateSyncIDs []ids.NodeID `json:"stateSyncIDs"`
|
||||
StateSyncIPs []netip.AddrPort `json:"stateSyncIPs"`
|
||||
}
|
||||
|
||||
type BootstrapConfig struct {
|
||||
// Timeout before emitting a warn log when connecting to bootstrapping beacons
|
||||
BootstrapBeaconConnectionTimeout time.Duration `json:"bootstrapBeaconConnectionTimeout"`
|
||||
|
||||
// Max number of containers in an ancestors message sent by this node.
|
||||
BootstrapAncestorsMaxContainersSent int `json:"bootstrapAncestorsMaxContainersSent"`
|
||||
|
||||
// This node will only consider the first [AncestorsMaxContainersReceived]
|
||||
// containers in an ancestors message it receives.
|
||||
BootstrapAncestorsMaxContainersReceived int `json:"bootstrapAncestorsMaxContainersReceived"`
|
||||
|
||||
// Max time to spend fetching a container and its
|
||||
// ancestors while responding to a GetAncestors message
|
||||
BootstrapMaxTimeGetAncestors time.Duration `json:"bootstrapMaxTimeGetAncestors"`
|
||||
|
||||
Bootstrappers []genesis.Bootstrapper `json:"bootstrappers"`
|
||||
|
||||
// Skip bootstrapping and start processing immediately
|
||||
SkipBootstrap bool `json:"skipBootstrap"`
|
||||
|
||||
// Enable automining in POA mode
|
||||
EnableAutomining bool `json:"enableAutomining"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
// If true, all writes are to memory and are discarded at node shutdown.
|
||||
ReadOnly bool `json:"readOnly"`
|
||||
|
||||
// Path to database
|
||||
Path string `json:"path"`
|
||||
|
||||
// Name of the database type to use
|
||||
Name string `json:"name"`
|
||||
|
||||
// Path to config file
|
||||
Config []byte `json:"-"`
|
||||
}
|
||||
|
||||
// Config contains all of the configurations of a Lux node.
|
||||
type Config struct {
|
||||
HTTPConfig `json:"httpConfig"`
|
||||
IPConfig `json:"ipConfig"`
|
||||
StakingConfig `json:"stakingConfig"`
|
||||
fee.StaticConfig `json:"txFeeConfig"`
|
||||
StateSyncConfig `json:"stateSyncConfig"`
|
||||
BootstrapConfig `json:"bootstrapConfig"`
|
||||
DatabaseConfig `json:"databaseConfig"`
|
||||
|
||||
UpgradeConfig upgrade.Config `json:"upgradeConfig"`
|
||||
|
||||
// Genesis information
|
||||
GenesisBytes []byte `json:"-"`
|
||||
UTXOAssetID ids.ID `json:"utxoAssetID"`
|
||||
|
||||
// ID of the network this node should connect to
|
||||
NetworkID uint32 `json:"networkID"`
|
||||
|
||||
// Health
|
||||
HealthCheckFreq time.Duration `json:"healthCheckFreq"`
|
||||
|
||||
// Network configuration
|
||||
NetworkConfig network.Config `json:"networkConfig"`
|
||||
|
||||
AdaptiveTimeoutConfig timer.AdaptiveTimeoutConfig `json:"adaptiveTimeoutConfig"`
|
||||
|
||||
BenchlistConfig benchlist.Config `json:"benchlistConfig"`
|
||||
|
||||
ProfilerConfig profiler.Config `json:"profilerConfig"`
|
||||
|
||||
// LoggingConfig log.Config `json:"loggingConfig"` // log.Config doesn't exist
|
||||
|
||||
PluginDir string `json:"pluginDir"`
|
||||
// DevMode enables local PoA + auto-mine mode (network-id=local, sybil-protection disabled)
|
||||
DevMode bool `json:"devMode"`
|
||||
|
||||
// File Descriptor Limit
|
||||
FdLimit uint64 `json:"fdLimit"`
|
||||
|
||||
// Metrics
|
||||
MeterVMEnabled bool `json:"meterVMEnabled"`
|
||||
// Delay between automatic block proposals when DevMode is set
|
||||
DevBlockDelay time.Duration `json:"devBlockDelay"`
|
||||
|
||||
RouterHealthConfig HealthConfig `json:"routerHealthConfig"`
|
||||
ConsensusShutdownTimeout time.Duration `json:"consensusShutdownTimeout"`
|
||||
// Poll for new frontiers every [FrontierPollFrequency]
|
||||
FrontierPollFrequency time.Duration `json:"consensusGossipFreq"`
|
||||
// ConsensusAppConcurrency defines the maximum number of goroutines to
|
||||
// handle App messages per chain.
|
||||
ConsensusAppConcurrency int `json:"consensusAppConcurrency"`
|
||||
|
||||
TrackedChains set.Set[ids.ID] `json:"trackedChains"`
|
||||
TrackAllChains bool `json:"trackAllChains"`
|
||||
|
||||
NetConfigs map[ids.ID]nets.Config `json:"chainConfigs"`
|
||||
|
||||
ChainConfigs map[string]chains.ChainConfig `json:"-"`
|
||||
ChainAliases map[ids.ID][]string `json:"chainAliases"`
|
||||
|
||||
VMAliases map[ids.ID][]string `json:"vmAliases"`
|
||||
|
||||
// Halflife to use for the processing requests tracker.
|
||||
// Larger halflife --> usage metrics change more slowly.
|
||||
SystemTrackerProcessingHalflife time.Duration `json:"systemTrackerProcessingHalflife"`
|
||||
|
||||
// Frequency to check the real resource usage of tracked processes.
|
||||
// More frequent checks --> usage metrics are more accurate, but more
|
||||
// expensive to track
|
||||
SystemTrackerFrequency time.Duration `json:"systemTrackerFrequency"`
|
||||
|
||||
// Halflife to use for the cpu tracker.
|
||||
// Larger halflife --> cpu usage metrics change more slowly.
|
||||
SystemTrackerCPUHalflife time.Duration `json:"systemTrackerCPUHalflife"`
|
||||
|
||||
// Halflife to use for the disk tracker.
|
||||
// Larger halflife --> disk usage metrics change more slowly.
|
||||
SystemTrackerDiskHalflife time.Duration `json:"systemTrackerDiskHalflife"`
|
||||
|
||||
CPUTargeterConfig TargeterConfig `json:"cpuTargeterConfig"`
|
||||
|
||||
DiskTargeterConfig TargeterConfig `json:"diskTargeterConfig"`
|
||||
|
||||
RequiredAvailableDiskSpace uint64 `json:"requiredAvailableDiskSpace"`
|
||||
WarningThresholdAvailableDiskSpace uint64 `json:"warningThresholdAvailableDiskSpace"`
|
||||
|
||||
TraceConfig trace.Config `json:"traceConfig"`
|
||||
|
||||
// See comment on [UseCurrentHeight] in platformvm.Config
|
||||
UseCurrentHeight bool `json:"useCurrentHeight"`
|
||||
|
||||
// ProvidedFlags contains all the flags set by the user
|
||||
ProvidedFlags map[string]interface{} `json:"-"`
|
||||
|
||||
// ChainDataDir is the root path for per-chain directories where VMs can
|
||||
// write arbitrary data.
|
||||
ChainDataDir string `json:"chainDataDir"`
|
||||
|
||||
// ImportChainData is the path to import blockchain data from another chain
|
||||
ImportChainData string `json:"importChainData"`
|
||||
|
||||
// Path to write process context to (including PID, API URI, and
|
||||
// staking address).
|
||||
ProcessContextFilePath string `json:"processContextFilePath"`
|
||||
|
||||
// POA Mode Configuration
|
||||
POAModeEnabled bool `json:"poaModeEnabled"`
|
||||
POASingleNodeMode bool `json:"poaSingleNodeMode"`
|
||||
POAMinBlockTime time.Duration `json:"poaMinBlockTime"`
|
||||
POAAuthorizedNodes []string `json:"poaAuthorizedNodes"`
|
||||
|
||||
// Low Memory Configuration
|
||||
LowMemoryEnabled bool `json:"lowMemoryEnabled"`
|
||||
DBCacheSize uint64 `json:"dbCacheSize"`
|
||||
DBMemtableSize uint64 `json:"dbMemtableSize"`
|
||||
StateCacheSize uint64 `json:"stateCacheSize"`
|
||||
BlockCacheSize uint64 `json:"blockCacheSize"`
|
||||
DisableBloomFilters bool `json:"disableBloomFilters"`
|
||||
LazyChainLoading bool `json:"lazyChainLoading"`
|
||||
SingleValidatorMode bool `json:"singleValidatorMode"`
|
||||
|
||||
// Logging
|
||||
Log log.Logger `json:"-"`
|
||||
}
|
||||
@@ -1357,6 +1357,7 @@ func (n *Node) initChainManager(utxoAssetID ids.ID) error {
|
||||
StakingMLDSASigner: n.Config.StakingConfig.StakingMLDSA,
|
||||
StakingMLDSAPub: n.Config.StakingConfig.StakingMLDSAPub,
|
||||
ProposerWindowDuration: n.Config.ProposerWindowDuration,
|
||||
ProposerMinBlockDelay: n.Config.ProposerMinBlockDelay,
|
||||
StakingBLSKey: n.Config.StakingSigningKey,
|
||||
Log: n.Log,
|
||||
LogFactory: n.LogFactory,
|
||||
|
||||
+24
-12
@@ -114,18 +114,29 @@ var CoreVMs = map[ids.ID]CoreVM{
|
||||
// today's opt-in-flag behavior) while the gate itself remains fail-closed.
|
||||
//
|
||||
// C (evm) and the remaining app VMs are plugin-loaded but ungated today.
|
||||
//
|
||||
// Declaring a VM here is a statement of intent, not a guarantee that a binary
|
||||
// exists: the registry scan simply finds nothing and the chain cannot start.
|
||||
// fhevm (F-Chain) is exactly that case today — see its entry below.
|
||||
var OptionalVMs = map[ids.ID]PluginSpec{
|
||||
constants.DexVMID: {Name: "dexvm", RequiredNFT: &NFTRequirement{Collection: "dex-operator", GroupID: 0}},
|
||||
constants.BridgeVMID: {Name: "bridgevm", RequiredNFT: &NFTRequirement{Collection: "bridge-operator", GroupID: 0}},
|
||||
constants.EVMID: {Name: "evm"},
|
||||
constants.AIVMID: {Name: "aivm"},
|
||||
constants.GraphVMID: {Name: "graphvm"},
|
||||
constants.IdentityVMID: {Name: "identityvm"},
|
||||
constants.KeyVMID: {Name: "keyvm"},
|
||||
constants.OracleVMID: {Name: "oraclevm"},
|
||||
constants.RelayVMID: {Name: "relayvm"},
|
||||
constants.MPCVMID: {Name: "mpcvm"},
|
||||
constants.FHEVMID: {Name: "fhevm"},
|
||||
constants.DexVMID: {Name: "dexvm", RequiredNFT: &NFTRequirement{Collection: "dex-operator", GroupID: 0}},
|
||||
constants.BridgeVMID: {Name: "bridgevm", RequiredNFT: &NFTRequirement{Collection: "bridge-operator", GroupID: 0}},
|
||||
constants.EVMID: {Name: "evm"},
|
||||
constants.AIVMID: {Name: "aivm"},
|
||||
constants.GraphVMID: {Name: "graphvm"},
|
||||
constants.IdentityVMID: {Name: "identityvm"},
|
||||
constants.KeyVMID: {Name: "keyvm"},
|
||||
constants.OracleVMID: {Name: "oraclevm"},
|
||||
constants.RelayVMID: {Name: "relayvm"},
|
||||
constants.MPCVMID: {Name: "mpcvm"},
|
||||
// F-Chain. Reserved ID, no shipping VM: luxfi/chains has no fhevm/
|
||||
// directory, so `make` produces no fhevm binary and this scan always comes
|
||||
// up empty. The FHE runtime library lives at luxfi/chains/mpcvm/fhe as a
|
||||
// package inside mpcvm, not as a standalone chain. F-Chain is a spec
|
||||
// (LP-8200, LP-167). Kept declared so the intent stays visible and the ID
|
||||
// stays reserved — remove it only when F-Chain is cancelled, not merely
|
||||
// because it is unbuilt.
|
||||
constants.FHEVMID: {Name: "fhevm"},
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -155,7 +166,8 @@ func assertRegistriesDisjoint() error {
|
||||
// registerCoreVMs registers the in-process core VMs that carry a concrete
|
||||
// factory (Q, Z). P and X are registered separately in node.go because their
|
||||
// factories require live node dependencies (RegisteredInNodeGo=true in
|
||||
// CoreVMs). The optional chain VMs (A/B/C/D/G/I/K/O/R/T) are NEVER registered
|
||||
// CoreVMs). The optional chain VMs (A/B/C/D/F/G/I/K/M/O/R — the keys of
|
||||
// OptionalVMs; there is no T, LP-134 dissolved it) are NEVER registered
|
||||
// here — they load from PluginDir via the VMRegistry scan, exactly like any
|
||||
// other plugin. Registering an optional VM in-process would shadow its plugin
|
||||
// (the registry skips any VMID the manager already has — vms/registry/
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Rename types, methods, and functions to remove "App" prefix
|
||||
# Targeted at p2p message types and builder functions
|
||||
|
||||
# 1. Rename Message Types and Fields (Getters)
|
||||
find . -name "*.go" -type f -print0 | xargs -0 sed -i '' \
|
||||
-e 's/AppRequest/Request/g' \
|
||||
-e 's/AppResponse/Response/g' \
|
||||
-e 's/AppGossip/Gossip/g' \
|
||||
-e 's/AppError/Error/g' \
|
||||
-e 's/GetAppRequest/GetRequest/g' \
|
||||
-e 's/GetAppResponse/GetResponse/g' \
|
||||
-e 's/GetAppGossip/GetGossip/g' \
|
||||
-e 's/GetAppError/GetError/g'
|
||||
|
||||
# 2. Rename Builder Functions (Inbound/Outbound)
|
||||
# Note: s/AppRequest/Request/g above already handled the suffix.
|
||||
# Now we need to handle prefixes if they still exist.
|
||||
# e.g. InboundAppRequest -> InboundRequest (if AppRequest matched first, it became InboundRequest)
|
||||
# Check: InboundAppRequest -> InboundRequest.
|
||||
# So IsInboundAppRequest -> IsInboundRequest?
|
||||
# We should just ensure "InboundApp" -> "Inbound" (for any other patterns)
|
||||
find . -name "*.go" -type f -print0 | xargs -0 sed -i '' \
|
||||
-e 's/InboundApp/Inbound/g' \
|
||||
-e 's/OutboundApp/Outbound/g'
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Replace package declarations in moved files
|
||||
# Ensure we are fixing package names for moved files
|
||||
# vm/manager was 'package manager', so it fits node/vms/manager
|
||||
# vm/rpc was 'package rpc' (likely), needs to be 'package rpcchainvm'
|
||||
|
||||
if [ -d "vms/manager" ]; then
|
||||
sed -i '' 's/^package.*/package manager/' vms/manager/*.go
|
||||
fi
|
||||
if [ -d "vms/rpcchainvm" ]; then
|
||||
sed -i '' 's/^package.*/package rpcchainvm/' vms/rpcchainvm/*.go
|
||||
fi
|
||||
|
||||
# Global replacements
|
||||
DIRS=". ../precompile ../coreth ../evm ../rpc ../wallet ../staking"
|
||||
|
||||
for dir in $DIRS; do
|
||||
if [ -d "$dir" ]; then
|
||||
echo "Processing $dir..."
|
||||
find "$dir" -name "*.go" -type f -print0 | xargs -0 sed -i '' \
|
||||
-e 's|github.com/luxfi/consensus/runtime|github.com/luxfi/runtime|g' \
|
||||
-e 's|github.com/luxfi/consensus/validator|github.com/luxfi/validators|g' \
|
||||
-e 's|github.com/luxfi/consensus/version|github.com/luxfi/version|g' \
|
||||
-e 's|github.com/luxfi/vm/manager|github.com/luxfi/node/vms/manager|g' \
|
||||
-e 's|github.com/luxfi/vm/rpc|github.com/luxfi/node/vms/rpcchainvm|g' \
|
||||
-e 's|github.com/luxfi/vm/chains|github.com/luxfi/node/chains|g' \
|
||||
-e 's|version\.Current()|version.CurrentApp|g' \
|
||||
-e 's|consensusversion\.Current()|version.CurrentApp|g'
|
||||
fi
|
||||
done
|
||||
+13
-5
@@ -54,8 +54,16 @@ source "${REPO_ROOT}"/scripts/constants.sh
|
||||
# Determine the git commit hash to use for the build
|
||||
source "${REPO_ROOT}"/scripts/git_commit.sh
|
||||
|
||||
# Configure build based on profile
|
||||
tags=""
|
||||
# Configure build based on profile.
|
||||
#
|
||||
# `metrics` is a base tag, not a profile choice: without it luxfi/metric's
|
||||
# NewRegistry() resolves to the no-op registry (registry_noop.go, //go:build
|
||||
# !metrics), every metric registers into a black hole, and /v1/metrics answers
|
||||
# 200 with a zero-byte body — while --api-metrics-enabled=true still reports
|
||||
# metrics as on. Whether metrics are served is the runtime flag's decision
|
||||
# alone; the build must not silently overrule it.
|
||||
base_tags="metrics"
|
||||
tags="${base_tags}"
|
||||
ldflags="-X github.com/luxfi/node/version.GitCommit=$git_commit \
|
||||
-X github.com/luxfi/node/version.VersionMajor=$version_major \
|
||||
-X github.com/luxfi/node/version.VersionMinor=$version_minor \
|
||||
@@ -67,7 +75,7 @@ upx_compress=false
|
||||
case "${profile}" in
|
||||
minimal)
|
||||
echo "Profile: minimal (ZAP, all VMs, NAT, stripped)"
|
||||
tags="nattraversal"
|
||||
tags="${base_tags},nattraversal"
|
||||
strip_flags="-s -w"
|
||||
;;
|
||||
core)
|
||||
@@ -77,7 +85,7 @@ case "${profile}" in
|
||||
;;
|
||||
full)
|
||||
echo "Profile: full (gRPC+ZAP, all VMs, all features, stripped)"
|
||||
tags="grpc,nattraversal,zxcvbn,metrics"
|
||||
tags="${base_tags},grpc,nattraversal,zxcvbn"
|
||||
strip_flags="-s -w"
|
||||
;;
|
||||
dev)
|
||||
@@ -86,7 +94,7 @@ case "${profile}" in
|
||||
;;
|
||||
tiny)
|
||||
echo "Profile: tiny (all VMs, NAT + UPX compressed)"
|
||||
tags="nattraversal"
|
||||
tags="${base_tags},nattraversal"
|
||||
strip_flags="-s -w"
|
||||
upx_compress=true
|
||||
;;
|
||||
|
||||
@@ -60,7 +60,7 @@ PLUGINS=(
|
||||
r5m1ujrmXxVcQetG3CQfuDLHp2RHKh6vCDaFgBRQfUcTZh7eS # oraclevm
|
||||
ry9Sg8rZdT26iEKvJDmC2wkESs4SDKgZEhk5BgLSwg1EpcNug # quantumvm
|
||||
sP6dLqrrBR9w3soP18fbJ3YzZecZdD7DDdfH2cFhhLq7Hy9bz # relayvm
|
||||
tGVBwRxpmD2aFdg3iYjgRvrCe8Jcmq9UNKxyHMus2NZ8WcD8t # mpcvm
|
||||
qCURact1n41FcoNBch8iMVBwc9AWie48D118ZNJ5tBdWrvryS # mpcvm
|
||||
vv3qPfyTVXZ5ArRZA9Jh4hbYDTBe43f7sgQg4CHfNg1rnnvX9 # zkvm
|
||||
)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/valyala/fasthttp/fasthttpadaptor"
|
||||
zaphttp "github.com/zap-proto/http"
|
||||
|
||||
log "github.com/luxfi/log"
|
||||
@@ -53,7 +54,10 @@ func startZapRPCListener(logger log.Logger, handler http.Handler, addr string) *
|
||||
if addr == "" {
|
||||
return nil
|
||||
}
|
||||
srv := &zaphttp.Server{Addr: addr, Handler: handler}
|
||||
// zap-proto/http >= v0.2.0 takes a fasthttp.RequestHandler. Adapt the very
|
||||
// same net/http handler chain the HTTP listener serves, so the two
|
||||
// transports stay behaviourally identical — only the wire encoding differs.
|
||||
srv := &zaphttp.Server{Addr: addr, Handler: fasthttpadaptor.NewFastHTTPHandler(handler)}
|
||||
go func() {
|
||||
logger.Info("ZAP-RPC API listening", log.UserString("addr", addr))
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
zaphttp "github.com/zap-proto/http"
|
||||
|
||||
log "github.com/luxfi/log"
|
||||
@@ -22,8 +23,8 @@ func TestZapRPCListenAddr(t *testing.T) {
|
||||
val string
|
||||
want string
|
||||
}{
|
||||
{set: false, want: ""}, // unset → disabled (default)
|
||||
{set: true, val: "", want: ""}, // empty → disabled
|
||||
{set: false, want: ""}, // unset → disabled (default)
|
||||
{set: true, val: "", want: ""}, // empty → disabled
|
||||
{set: true, val: "off", want: ""},
|
||||
{set: true, val: "OFF", want: ""},
|
||||
{set: true, val: "0", want: ""},
|
||||
@@ -74,18 +75,32 @@ func TestStartZapRPCListener_RoundTrip(t *testing.T) {
|
||||
// Give the goroutine a beat to bind.
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
client := &http.Client{Transport: zaphttp.NewTransport(addr)}
|
||||
resp, err := client.Post("http://"+addr+"/v1/bc/C/rpc", "application/json", nil)
|
||||
if err != nil {
|
||||
// zap-proto/http >= v0.2.0 speaks fasthttp on both ends: Transport.Do takes
|
||||
// a fasthttp request/response pair rather than implementing
|
||||
// http.RoundTripper. The round trip being asserted is unchanged — a real
|
||||
// ZAP request over the wire must reach the net/http handler the listener
|
||||
// was given, through the fasthttpadaptor bridge in startZapRPCListener.
|
||||
// v0.3.0 replaced NewTransport with Dial, mirroring net.Dial: the network is
|
||||
// a VALUE ("tcp", "unix") rather than a family of constructors.
|
||||
transport := zaphttp.Dial("tcp", addr)
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
req, resp := fasthttp.AcquireRequest(), fasthttp.AcquireResponse()
|
||||
defer fasthttp.ReleaseRequest(req)
|
||||
defer fasthttp.ReleaseResponse(resp)
|
||||
|
||||
req.SetRequestURI("http://" + addr + "/v1/bc/C/rpc")
|
||||
req.Header.SetMethod(http.MethodPost)
|
||||
req.Header.SetContentType("application/json")
|
||||
|
||||
if err := transport.Do(req, resp); err != nil {
|
||||
t.Fatalf("ZAP round-trip POST failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
if string(got) != body {
|
||||
if got := string(resp.Body()); got != body {
|
||||
t.Fatalf("ZAP round-trip body = %q, want %q", got, body)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
|
||||
if ct := string(resp.Header.ContentType()); ct != "application/json" {
|
||||
t.Fatalf("ZAP round-trip Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// logger_level_test.go — admin.setLoggerLevel / admin.getLoggerLevel must actually
|
||||
// move a live logger's level.
|
||||
//
|
||||
// They did not. SetLoggerLevel computed the logger names and threw them away
|
||||
// (`loggerNames := a.getLoggerNames(...); _ = loggerNames`) and getLogLevels returned
|
||||
// an empty map unconditionally, so BOTH endpoints answered 200 OK having done nothing.
|
||||
// That is why the 2026-07-28 devnet/testnet build-loop diagnosis had to be run off boot
|
||||
// logs: raising a running node's log level was impossible.
|
||||
//
|
||||
// The tests drive the REAL log.Factory (the same one the node builds), not a double,
|
||||
// so a passing SetLoggerLevel means the level the logger actually filters on moved.
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
apiadmin "github.com/luxfi/api/admin"
|
||||
"github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// newLevelTestService returns an admin service over a real log.Factory that already
|
||||
// holds one registered logger — the shape the node runs in.
|
||||
func newLevelTestService(t *testing.T, loggerName string) *Service {
|
||||
t.Helper()
|
||||
|
||||
factory := log.NewFactory()
|
||||
t.Cleanup(factory.Close)
|
||||
|
||||
_, err := factory.Make(loggerName)
|
||||
require.NoError(t, err)
|
||||
|
||||
return &Service{Config: Config{Log: log.Noop(), LogFactory: factory}}
|
||||
}
|
||||
|
||||
// TestSetLoggerLevel_MovesTheLevelAndGetReportsIt is the regression: set, then read
|
||||
// back through the API. Before the fix the set was discarded and the get returned an
|
||||
// empty map, so both halves silently lied.
|
||||
func TestSetLoggerLevel_MovesTheLevelAndGetReportsIt(t *testing.T) {
|
||||
require := require.New(t)
|
||||
ctx := context.Background()
|
||||
|
||||
svc := newLevelTestService(t, "C")
|
||||
|
||||
_, err := svc.SetLoggerLevel(ctx, &apiadmin.SetLoggerLevelArgs{
|
||||
LoggerName: "C",
|
||||
LogLevel: "debug",
|
||||
DisplayLevel: "error",
|
||||
})
|
||||
require.NoError(err)
|
||||
|
||||
reply, err := svc.GetLoggerLevel(ctx, &apiadmin.GetLoggerLevelArgs{LoggerName: "C"})
|
||||
require.NoError(err)
|
||||
require.Equal(
|
||||
map[string]apiadmin.LogAndDisplayLevels{"C": {
|
||||
LogLevel: log.DebugLevel.String(),
|
||||
DisplayLevel: log.ErrorLevel.String(),
|
||||
}},
|
||||
reply.LoggerLevels,
|
||||
"setLoggerLevel must move the live logger's level and getLoggerLevel must report it",
|
||||
)
|
||||
|
||||
// The factory is the single source of truth — assert against it directly too, so a
|
||||
// getLoggerLevel that merely echoed the request back could not pass this test.
|
||||
logLevel, err := svc.LogFactory.GetLogLevel("C")
|
||||
require.NoError(err)
|
||||
require.Equal(log.DebugLevel, logLevel)
|
||||
}
|
||||
|
||||
// TestSetLoggerLevel_OneLevelAtATime pins that omitting a level leaves it alone rather
|
||||
// than resetting it to the zero Level.
|
||||
func TestSetLoggerLevel_OneLevelAtATime(t *testing.T) {
|
||||
require := require.New(t)
|
||||
ctx := context.Background()
|
||||
|
||||
svc := newLevelTestService(t, "node")
|
||||
|
||||
_, err := svc.SetLoggerLevel(ctx, &apiadmin.SetLoggerLevelArgs{
|
||||
LoggerName: "node", LogLevel: "trace", DisplayLevel: "warn",
|
||||
})
|
||||
require.NoError(err)
|
||||
|
||||
// Only displayLevel this time — logLevel must survive.
|
||||
_, err = svc.SetLoggerLevel(ctx, &apiadmin.SetLoggerLevelArgs{
|
||||
LoggerName: "node", DisplayLevel: "fatal",
|
||||
})
|
||||
require.NoError(err)
|
||||
|
||||
reply, err := svc.GetLoggerLevel(ctx, &apiadmin.GetLoggerLevelArgs{LoggerName: "node"})
|
||||
require.NoError(err)
|
||||
require.Equal(log.TraceLevel.String(), reply.LoggerLevels["node"].LogLevel)
|
||||
require.Equal(log.FatalLevel.String(), reply.LoggerLevels["node"].DisplayLevel)
|
||||
}
|
||||
|
||||
// TestLoggerLevel_RejectsUnservableAndInvalidArgs — every refusal is explicit. log.Factory
|
||||
// addresses loggers BY NAME and exposes no enumeration, so an empty name cannot be served;
|
||||
// returning 200 OK for it is the bug, not the contract.
|
||||
func TestLoggerLevel_RejectsUnservableAndInvalidArgs(t *testing.T) {
|
||||
require := require.New(t)
|
||||
ctx := context.Background()
|
||||
|
||||
svc := newLevelTestService(t, "C")
|
||||
|
||||
_, err := svc.SetLoggerLevel(ctx, &apiadmin.SetLoggerLevelArgs{LogLevel: "debug"})
|
||||
require.ErrorIs(err, errNoLoggerName)
|
||||
|
||||
_, err = svc.GetLoggerLevel(ctx, &apiadmin.GetLoggerLevelArgs{})
|
||||
require.ErrorIs(err, errNoLoggerName)
|
||||
|
||||
_, err = svc.SetLoggerLevel(ctx, &apiadmin.SetLoggerLevelArgs{LoggerName: "C"})
|
||||
require.ErrorIs(err, errNoLogLevel)
|
||||
|
||||
// An unparseable level must be refused BEFORE anything is mutated.
|
||||
before, err := svc.LogFactory.GetLogLevel("C")
|
||||
require.NoError(err)
|
||||
_, err = svc.SetLoggerLevel(ctx, &apiadmin.SetLoggerLevelArgs{LoggerName: "C", LogLevel: "loud"})
|
||||
require.Error(err)
|
||||
after, err := svc.LogFactory.GetLogLevel("C")
|
||||
require.NoError(err)
|
||||
require.Equal(before, after, "a rejected level must leave the logger untouched")
|
||||
}
|
||||
@@ -40,6 +40,7 @@ const (
|
||||
var (
|
||||
errAliasTooLong = errors.New("alias length is too long")
|
||||
errNoLogLevel = errors.New("need to specify either displayLevel or logLevel")
|
||||
errNoLoggerName = errors.New("need to specify loggerName: loggers are addressed by name and cannot be enumerated")
|
||||
)
|
||||
|
||||
// ChainTracker is the interface for tracking chains at runtime.
|
||||
@@ -209,11 +210,39 @@ func (a *Service) SetLoggerLevel(ctx context.Context, args *apiadmin.SetLoggerLe
|
||||
return nil, errNoLogLevel
|
||||
}
|
||||
|
||||
// Parse before mutating: a rejected level must leave every logger untouched.
|
||||
var logLevel, displayLevel log.Level
|
||||
if args.LogLevel != "" {
|
||||
var err error
|
||||
if logLevel, err = log.ToLevel(args.LogLevel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if args.DisplayLevel != "" {
|
||||
var err error
|
||||
if displayLevel, err = log.ToLevel(args.DisplayLevel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
loggerNames, err := a.getLoggerNames(args.LoggerName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.lock.Lock()
|
||||
defer a.lock.Unlock()
|
||||
|
||||
loggerNames := a.getLoggerNames(args.LoggerName)
|
||||
_ = loggerNames
|
||||
for _, name := range loggerNames {
|
||||
// Only the levels the caller supplied — an omitted level keeps its value
|
||||
// instead of being reset to the zero Level.
|
||||
if args.LogLevel != "" {
|
||||
a.LogFactory.SetLogLevel(name, logLevel)
|
||||
}
|
||||
if args.DisplayLevel != "" {
|
||||
a.LogFactory.SetDisplayLevel(name, displayLevel)
|
||||
}
|
||||
}
|
||||
return &apiadmin.EmptyReply{}, nil
|
||||
}
|
||||
|
||||
@@ -225,10 +254,14 @@ func (a *Service) GetLoggerLevel(ctx context.Context, args *apiadmin.GetLoggerLe
|
||||
log.String("loggerName", args.LoggerName),
|
||||
)
|
||||
|
||||
loggerNames, err := a.getLoggerNames(args.LoggerName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.lock.RLock()
|
||||
defer a.lock.RUnlock()
|
||||
|
||||
loggerNames := a.getLoggerNames(args.LoggerName)
|
||||
loggerLevels, err := a.getLogLevels(loggerNames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -401,15 +434,35 @@ func (a *Service) GetTrackedChains(ctx context.Context) (*apiadmin.GetTrackedCha
|
||||
return &apiadmin.GetTrackedChainsReply{TrackedChains: trackedChains}, nil
|
||||
}
|
||||
|
||||
func (a *Service) getLoggerNames(loggerName string) []string {
|
||||
if len(loggerName) == 0 {
|
||||
return []string{}
|
||||
// getLoggerNames resolves the loggerName argument to the loggers to act on — the one
|
||||
// place either logger-level endpoint decides what it is addressing.
|
||||
//
|
||||
// log.Factory addresses loggers BY NAME and exposes no enumeration, so the "every
|
||||
// logger" form (an empty name) cannot be served. Refuse it explicitly: answering 200 OK
|
||||
// while doing nothing is what made these endpoints unusable.
|
||||
func (a *Service) getLoggerNames(loggerName string) ([]string, error) {
|
||||
if loggerName == "" {
|
||||
return nil, errNoLoggerName
|
||||
}
|
||||
return []string{loggerName}
|
||||
return []string{loggerName}, nil
|
||||
}
|
||||
|
||||
func (a *Service) getLogLevels(loggerNames []string) (map[string]apiadmin.LogAndDisplayLevels, error) {
|
||||
loggerLevels := make(map[string]apiadmin.LogAndDisplayLevels)
|
||||
loggerLevels := make(map[string]apiadmin.LogAndDisplayLevels, len(loggerNames))
|
||||
for _, name := range loggerNames {
|
||||
logLevel, err := a.LogFactory.GetLogLevel(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
displayLevel, err := a.LogFactory.GetDisplayLevel(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loggerLevels[name] = apiadmin.LogAndDisplayLevels{
|
||||
LogLevel: logLevel.String(),
|
||||
DisplayLevel: displayLevel.String(),
|
||||
}
|
||||
}
|
||||
return loggerLevels, nil
|
||||
}
|
||||
|
||||
|
||||
+16
-10
@@ -5,11 +5,10 @@ package health
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-json-experiment/json"
|
||||
"github.com/go-json-experiment/json/jsontext"
|
||||
"github.com/gorilla/rpc/v2"
|
||||
|
||||
apihealth "github.com/luxfi/api/health"
|
||||
@@ -56,17 +55,24 @@ func NewGetHandler(reporter func(tags ...string) (map[string]apihealth.Result, b
|
||||
// If a health check has failed, we should return a 503.
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
// Buffer the reply — a streaming encoder that errors part-way
|
||||
// (jsonv2 rejects invalid UTF-8, and check Details may embed raw
|
||||
// chain-ID bytes) leaves the client a torn body whose
|
||||
// Content-Length matches the truncation. Buffering makes the
|
||||
// reply atomic; AllowInvalidUTF8 turns binary detail bytes into
|
||||
// replacement runes instead of an encode error.
|
||||
// One encoder for one wire type: apihealth.APIReply is defined with
|
||||
// encoding/json tags and carries a time.Duration per check, so it is
|
||||
// encoding/json that defines its representation. The POST (jsonrpc)
|
||||
// path already encodes it through that codec; encoding it here the
|
||||
// same way is what makes GET and POST agree. jsonv2 cannot encode
|
||||
// this type at all — time.Duration has no default representation
|
||||
// there — so every GET reply used to degrade to the error fallback
|
||||
// below. encoding/json also replaces invalid UTF-8 (check Details
|
||||
// may embed raw chain-ID bytes) with U+FFFD rather than failing.
|
||||
//
|
||||
// Buffer first: a streaming encoder that errors part-way would leave
|
||||
// the client a torn body whose Content-Length matches the
|
||||
// truncation. Buffering makes the reply atomic.
|
||||
var buf bytes.Buffer
|
||||
err := json.MarshalWrite(&buf, apihealth.APIReply{
|
||||
err := json.NewEncoder(&buf).Encode(apihealth.APIReply{
|
||||
Checks: checks,
|
||||
Healthy: healthy,
|
||||
}, jsontext.AllowInvalidUTF8(true))
|
||||
})
|
||||
if err != nil {
|
||||
buf.Reset()
|
||||
fmt.Fprintf(&buf, `{"healthy":%t,"error":"health reply encode failed"}`, healthy)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package health
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
apihealth "github.com/luxfi/api/health"
|
||||
)
|
||||
|
||||
// The GET handler must emit the real check set. It previously encoded through
|
||||
// jsonv2, which cannot represent apihealth.Result.Duration (a time.Duration),
|
||||
// so every reply on every node degraded to {"healthy":…,"error":"health reply
|
||||
// encode failed"} while the status code still looked correct — k8s probes
|
||||
// passed and operators saw nothing.
|
||||
func TestGetHandlerEncodesDurationBearingChecks(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
checks := map[string]apihealth.Result{
|
||||
"bls": {
|
||||
Details: "node has the correct BLS key",
|
||||
Duration: 134597 * time.Nanosecond,
|
||||
Timestamp: time.Unix(1753479996, 0).UTC(),
|
||||
},
|
||||
}
|
||||
h := NewGetHandler(func(...string) (map[string]apihealth.Result, bool) {
|
||||
return checks, true
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/health", nil))
|
||||
|
||||
require.Equal(http.StatusOK, rec.Code)
|
||||
|
||||
var reply apihealth.APIReply
|
||||
require.NoError(json.Unmarshal(rec.Body.Bytes(), &reply))
|
||||
require.True(reply.Healthy)
|
||||
require.Len(reply.Checks, 1)
|
||||
require.Equal(134597*time.Nanosecond, reply.Checks["bls"].Duration)
|
||||
require.NotContains(rec.Body.String(), "health reply encode failed")
|
||||
}
|
||||
|
||||
// An unhealthy node must still return the full diagnostic body alongside the
|
||||
// 503 — the body is the only thing that says *why*.
|
||||
func TestGetHandlerUnhealthyStillCarriesChecks(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
errMsg := "network layer is unhealthy reason: primary network validator has no inbound connections"
|
||||
checks := map[string]apihealth.Result{
|
||||
"network": {
|
||||
Details: map[string]any{"connectedPeers": 4},
|
||||
Error: &errMsg,
|
||||
Duration: 40357 * time.Nanosecond,
|
||||
ContiguousFailures: 31997,
|
||||
},
|
||||
}
|
||||
h := NewGetHandler(func(...string) (map[string]apihealth.Result, bool) {
|
||||
return checks, false
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/health", nil))
|
||||
|
||||
require.Equal(http.StatusServiceUnavailable, rec.Code)
|
||||
|
||||
var reply apihealth.APIReply
|
||||
require.NoError(json.Unmarshal(rec.Body.Bytes(), &reply))
|
||||
require.False(reply.Healthy)
|
||||
require.Equal(&errMsg, reply.Checks["network"].Error)
|
||||
require.Equal(int64(31997), reply.Checks["network"].ContiguousFailures)
|
||||
}
|
||||
|
||||
// Check Details may embed raw chain-ID bytes. Invalid UTF-8 must degrade to
|
||||
// replacement runes, never to an encode failure that drops the whole reply.
|
||||
func TestGetHandlerInvalidUTF8InDetailsDoesNotDropReply(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
checks := map[string]apihealth.Result{
|
||||
"database": {
|
||||
Details: string([]byte{0xff, 0xfe, 0x00}),
|
||||
Duration: 19137 * time.Nanosecond,
|
||||
},
|
||||
}
|
||||
h := NewGetHandler(func(...string) (map[string]apihealth.Result, bool) {
|
||||
return checks, true
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/health", nil))
|
||||
|
||||
require.Equal(http.StatusOK, rec.Code)
|
||||
var reply apihealth.APIReply
|
||||
require.NoError(json.Unmarshal(rec.Body.Bytes(), &reply))
|
||||
require.Len(reply.Checks, 1)
|
||||
require.NotContains(rec.Body.String(), "health reply encode failed")
|
||||
}
|
||||
@@ -12,6 +12,7 @@ github.com/Ladicle/tabwriter v1.0.0 h1:DZQqPvMumBDwVNElso13afjYLNp0Z7pHqHnu0r4t9
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
|
||||
github.com/StephenButtolph/canoto v0.17.3 h1:lvsnYD4b96vD1knnmp1xCmZqfYpY/jSeRozGdOfdvGI=
|
||||
github.com/StephenButtolph/canoto v0.17.3/go.mod h1:IcnAHC6nJUfQFVR9y60ko2ecUqqHHSB6UwI9NnBFZnE=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/chroma/v2 v2.17.2 h1:Rm81SCZ2mPoH+Q8ZCc/9YvzPUN/E7HgPiPJD8SLV6GI=
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
|
||||
@@ -56,6 +57,7 @@ github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucV
|
||||
github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
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/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47AQUwhrg8E=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
@@ -247,6 +249,7 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac h1:TSSpLIG4v+p0rPv1pNOQtl1I8knsO4S9trOxNMOLVP4=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.32.11
|
||||
1.36.36
|
||||
|
||||
@@ -72,7 +72,29 @@
|
||||
"v1.36.7",
|
||||
"v1.36.8",
|
||||
"v1.36.9",
|
||||
"v1.36.10"
|
||||
"v1.36.10",
|
||||
"v1.36.11",
|
||||
"v1.36.12",
|
||||
"v1.36.13",
|
||||
"v1.36.14",
|
||||
"v1.36.15",
|
||||
"v1.36.16",
|
||||
"v1.36.17",
|
||||
"v1.36.18",
|
||||
"v1.36.19",
|
||||
"v1.36.20",
|
||||
"v1.36.21",
|
||||
"v1.36.22",
|
||||
"v1.36.23",
|
||||
"v1.36.24",
|
||||
"v1.36.25",
|
||||
"v1.36.26",
|
||||
"v1.36.27",
|
||||
"v1.36.28",
|
||||
"v1.36.30",
|
||||
"v1.36.31",
|
||||
"v1.36.32",
|
||||
"v1.36.33"
|
||||
],
|
||||
"41": [
|
||||
"v1.13.2"
|
||||
@@ -187,4 +209,4 @@
|
||||
"v1.8.5",
|
||||
"v1.8.6"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ var (
|
||||
const (
|
||||
defaultMajor = 1
|
||||
defaultMinor = 36
|
||||
defaultPatch = 13
|
||||
defaultPatch = 35
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// Package dexvm re-exports the canonical DEX VM from
|
||||
// github.com/luxfi/chains/dexvm so existing callers that imported
|
||||
// github.com/luxfi/node/vms/dexvm pre-extraction keep working
|
||||
// without source-level changes.
|
||||
//
|
||||
// New code should import the canonical path:
|
||||
// "github.com/luxfi/chains/dexvm"
|
||||
//
|
||||
// This package is a thin backward-compatibility alias. The underlying
|
||||
// chains/dexvm is the pure-Go stateless atomic proxy (zero private deps).
|
||||
// Unlike the always-on genesis VMs, dexvm is registered in OptionalVMs and is
|
||||
// NFT-gated (see node/vms.go:118, RequiredNFT "dex-operator"): a node only
|
||||
// tracks/validates the D-Chain when the network has configured that operator
|
||||
// collection, so it is plugin-loaded on demand, not linked unconditionally.
|
||||
package dexvm
|
||||
|
||||
import (
|
||||
"github.com/luxfi/chains/dexvm"
|
||||
)
|
||||
|
||||
// Re-export the public surface.
|
||||
type (
|
||||
Block = dexvm.Block
|
||||
ChainVM = dexvm.ChainVM
|
||||
Factory = dexvm.Factory
|
||||
OrderKey = dexvm.OrderKey
|
||||
DexVertex = dexvm.DexVertex
|
||||
Status = dexvm.Status
|
||||
)
|
||||
|
||||
var (
|
||||
// VMID identifies the canonical primary-network D-Chain VM.
|
||||
VMID = dexvm.VMID
|
||||
|
||||
// NewChainVM constructs a fresh DEX chain VM.
|
||||
NewChainVM = dexvm.NewChainVM
|
||||
)
|
||||
@@ -1,32 +0,0 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// Package mpcvm — backend selection re-export.
|
||||
//
|
||||
// The canonical dlopen probe lives in github.com/luxfi/chains/mpcvm.
|
||||
// Its init() runs once at package load time and pins a process-wide
|
||||
// GPUBackend handle (cuda → hip → metal → vulkan → webgpu probe order).
|
||||
// This file is the node-side entry point for diagnostics and ops tooling.
|
||||
//
|
||||
// Note: the luxd VM manager registration for ThresholdVM (M-Chain) is
|
||||
// NOT flipped here per the project memory note ("M-Chain code exists but
|
||||
// NOT yet registered in running luxd"). This file provides the bridge
|
||||
// surface only — flipping the manager hook-up is a separate ops PR.
|
||||
|
||||
package mpcvm
|
||||
|
||||
// SelectGPUBackend returns the resolved GPU plugin (or nil) and a single
|
||||
// human-readable diagnostic string. luxd startup logs use this to surface
|
||||
// "mpcvm-gpu backend=<name>" lines alongside the cevm backend
|
||||
// selection, matching the cevm.go pattern at
|
||||
// ~/work/lux/chains/evm/backend_cgo.go.
|
||||
//
|
||||
// Calling this multiple times is cheap — the underlying probe runs once
|
||||
// at package init via sync.Once in chains/mpcvm/backend.go.
|
||||
func SelectGPUBackend() (*GPUBackend, string) {
|
||||
g := GPUBackendInstance()
|
||||
if g == nil || !g.IsAvailable() {
|
||||
return nil, "mpcvm-gpu: no plugin resolved (CPU-only)"
|
||||
}
|
||||
return g, "mpcvm-gpu: backend=" + g.Kind.String() + " path=" + g.Path
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// Package mpcvm re-exports the canonical Threshold (FHE / MPC)
|
||||
// VM from github.com/luxfi/chains/mpcvm so existing callers
|
||||
// that imported github.com/luxfi/node/vms/mpcvm pre-extraction
|
||||
// keep working without source changes.
|
||||
//
|
||||
// New code should import the canonical path:
|
||||
// "github.com/luxfi/chains/mpcvm"
|
||||
//
|
||||
// This file is a thin alias wrapper kept for backward compatibility.
|
||||
package mpcvm
|
||||
|
||||
import "github.com/luxfi/chains/mpcvm"
|
||||
|
||||
type (
|
||||
Block = mpcvm.Block
|
||||
BlockError = mpcvm.BlockError
|
||||
Client = mpcvm.Client
|
||||
Operation = mpcvm.Operation
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidOperation = mpcvm.ErrInvalidOperation
|
||||
NewClient = mpcvm.NewClient
|
||||
)
|
||||
@@ -1,78 +0,0 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//go:build cgo
|
||||
|
||||
// Package mpcvm re-exports the GPU bridge from
|
||||
// github.com/luxfi/chains/mpcvm so callers that import the legacy
|
||||
// node path keep working without source changes.
|
||||
//
|
||||
// One and only one way to dlopen: the canonical bridge lives in
|
||||
// chains/mpcvm. This package is a typed alias layer — both the
|
||||
// types and the GPUBackend singleton come straight from the upstream.
|
||||
// There is exactly one dlopen handle, one symbol table, one init()
|
||||
// probe across the entire luxd process. Consumers of either import path
|
||||
// share the same plugin instance.
|
||||
|
||||
package mpcvm
|
||||
|
||||
import "github.com/luxfi/chains/mpcvm"
|
||||
|
||||
// =============================================================================
|
||||
// Type re-exports — Go aliases preserve the public API so external callers
|
||||
// (e.g. node/chains, node/main) can drop the import path without source
|
||||
// changes. The GPU- prefix avoids collisions with the domain types
|
||||
// already aliased above (Block, Client, Operation).
|
||||
// =============================================================================
|
||||
|
||||
type (
|
||||
GPUBackend = mpcvm.GPUBackend
|
||||
GPUBackendKind = mpcvm.GPUBackendKind
|
||||
|
||||
GPUCeremony = mpcvm.GPUCeremony
|
||||
GPUKeyShare = mpcvm.GPUKeyShare
|
||||
GPUContribution = mpcvm.GPUContribution
|
||||
GPUMPCVMState = mpcvm.GPUMPCVMState
|
||||
GPUMPCVMRoundDescriptor = mpcvm.GPUMPCVMRoundDescriptor
|
||||
GPUCeremonyOp = mpcvm.GPUCeremonyOp
|
||||
GPUContributionOp = mpcvm.GPUContributionOp
|
||||
GPUMPCVMTransitionResult = mpcvm.GPUMPCVMTransitionResult
|
||||
)
|
||||
|
||||
// Backend constants re-exported. Matches the chains/mpcvm dlopen
|
||||
// probe order: cuda → hip → metal → vulkan → webgpu.
|
||||
const (
|
||||
GPUBackendNone = mpcvm.GPUBackendNone
|
||||
GPUBackendCUDA = mpcvm.GPUBackendCUDA
|
||||
GPUBackendHIP = mpcvm.GPUBackendHIP
|
||||
GPUBackendMetal = mpcvm.GPUBackendMetal
|
||||
GPUBackendVulkan = mpcvm.GPUBackendVulkan
|
||||
GPUBackendWebGPU = mpcvm.GPUBackendWebGPU
|
||||
)
|
||||
|
||||
// ErrGPUNotAvailable is the canonical "no plugin loaded" error. Callers
|
||||
// `errors.Is(err, mpcvm.ErrGPUNotAvailable)` to distinguish a
|
||||
// fallback-to-CPU condition from a hard launcher failure.
|
||||
var ErrGPUNotAvailable = mpcvm.ErrGPUNotAvailable
|
||||
|
||||
// GPUBackendInstance returns the dlopen'd GPU plugin handle resolved at
|
||||
// chains/mpcvm package init. nil means no plugin was loaded
|
||||
// (CPU-only mode). The handle is shared across the whole process — both
|
||||
// the node and chains paths see the same instance.
|
||||
//
|
||||
// The name is GPUBackendInstance (not GPUBackend / Backend) because Go
|
||||
// disallows a function named the same as a type alias in the same
|
||||
// package, and `Backend` is already a domain term in the threshold
|
||||
// state machine. Callers write:
|
||||
//
|
||||
// if g := mpcvm.GPUBackendInstance(); g != nil && g.IsAvailable() {
|
||||
// _, err := g.CeremonyApply(desc, ops, ceremonies)
|
||||
// ...
|
||||
// }
|
||||
//
|
||||
// This mirrors the cevm pattern `cevm.AvailableBackends()` /
|
||||
// `cevm.LibraryABIVersion()` — discovery via package-scope function,
|
||||
// not via a global variable.
|
||||
func GPUBackendInstance() *GPUBackend {
|
||||
return mpcvm.Backend()
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//go:build !cgo
|
||||
|
||||
// nocgo re-export of the chains/mpcvm GPU bridge. Under !cgo the
|
||||
// upstream GPUBackend methods all return ErrGPUNotAvailable and
|
||||
// Backend() returns nil; this layer transparently passes that through
|
||||
// so callers see identical behaviour regardless of build mode.
|
||||
//
|
||||
// One and only one nocgo stub for the entire process: it lives in
|
||||
// chains/mpcvm. This package just re-exports the types and the
|
||||
// sentinel error.
|
||||
|
||||
package mpcvm
|
||||
|
||||
import "github.com/luxfi/chains/mpcvm"
|
||||
|
||||
type (
|
||||
GPUBackend = mpcvm.GPUBackend
|
||||
GPUBackendKind = mpcvm.GPUBackendKind
|
||||
|
||||
GPUCeremony = mpcvm.GPUCeremony
|
||||
GPUKeyShare = mpcvm.GPUKeyShare
|
||||
GPUContribution = mpcvm.GPUContribution
|
||||
GPUMPCVMState = mpcvm.GPUMPCVMState
|
||||
GPUMPCVMRoundDescriptor = mpcvm.GPUMPCVMRoundDescriptor
|
||||
GPUCeremonyOp = mpcvm.GPUCeremonyOp
|
||||
GPUContributionOp = mpcvm.GPUContributionOp
|
||||
GPUMPCVMTransitionResult = mpcvm.GPUMPCVMTransitionResult
|
||||
)
|
||||
|
||||
const (
|
||||
GPUBackendNone = mpcvm.GPUBackendNone
|
||||
GPUBackendCUDA = mpcvm.GPUBackendCUDA
|
||||
GPUBackendHIP = mpcvm.GPUBackendHIP
|
||||
GPUBackendMetal = mpcvm.GPUBackendMetal
|
||||
GPUBackendVulkan = mpcvm.GPUBackendVulkan
|
||||
GPUBackendWebGPU = mpcvm.GPUBackendWebGPU
|
||||
)
|
||||
|
||||
var ErrGPUNotAvailable = mpcvm.ErrGPUNotAvailable
|
||||
|
||||
// GPUBackendInstance returns nil under !cgo — the upstream Backend()
|
||||
// returns nil because no dlopen ever happens. Callers branch on the
|
||||
// IsAvailable() check (or `g == nil`) and route to the CPU reference.
|
||||
func GPUBackendInstance() *GPUBackend {
|
||||
return mpcvm.Backend()
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package mpcvm
|
||||
|
||||
// TestGPUBridgeCgoNocgoParity is the node-side mirror of the parity
|
||||
// test in chains/mpcvm — proves that the re-exported GPUBackend
|
||||
// method set produces byte-identical MPC ceremony state transitions
|
||||
// regardless of build flavor (cgo / nocgo) and plugin presence.
|
||||
//
|
||||
// The node package is a thin alias layer over chains/mpcvm —
|
||||
// type aliases on the wire structs and a re-exported GPUBackendInstance
|
||||
// accessor — so the parity properties of the canonical bridge transfer
|
||||
// directly. We re-run the four-op pipeline through the node-side
|
||||
// surface to pin that the alias layer doesn't drop any state on the
|
||||
// floor.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
chainsthreshold "github.com/luxfi/chains/mpcvm"
|
||||
)
|
||||
|
||||
// TestGPUBridgeCgoNocgoParity runs a 3-of-5 FROST keygen ceremony
|
||||
// through GPUBackendInstance() (the node-side accessor) and compares
|
||||
// the resulting arena byte-for-byte to a fresh run via the canonical
|
||||
// bridge in chains/mpcvm. Under cgo, the comparison is the
|
||||
// upstream cgo bridge vs itself (both runs hit the same dispatcher).
|
||||
// Under !cgo, the comparison is the upstream nocgo bridge vs itself.
|
||||
// Either path proves the alias layer is transparent and the substrate
|
||||
// transitions deterministically.
|
||||
func TestGPUBridgeCgoNocgoParity(t *testing.T) {
|
||||
const cid uint64 = 0xCEFA01CA001
|
||||
const N = 16 // power of 2 for the open-addressing locator
|
||||
|
||||
var subject, seed [32]byte
|
||||
for i := 0; i < 32; i++ {
|
||||
subject[i] = byte(i + 1)
|
||||
seed[i] = byte(0xA0 ^ i)
|
||||
}
|
||||
|
||||
beginOps := []chainsthreshold.GPUCeremonyOp{
|
||||
{
|
||||
CeremonyID: cid,
|
||||
DeadlineNs: 10_000_000_000,
|
||||
Kind: 0, // kCeremonyOpBegin
|
||||
CeremonyKind: 0, // kKindFrostKeygen → 3 rounds
|
||||
Threshold: 3,
|
||||
TotalParticipants: 5,
|
||||
Subject: subject,
|
||||
CeremonySeed: seed,
|
||||
},
|
||||
}
|
||||
|
||||
buildRound := func(round uint32) []chainsthreshold.GPUContributionOp {
|
||||
ops := make([]chainsthreshold.GPUContributionOp, 5)
|
||||
for h := uint32(0); h < 5; h++ {
|
||||
var payload [384]byte
|
||||
for k := 0; k < 16; k++ {
|
||||
payload[k] = byte((round * 16) + h*4 + uint32(k))
|
||||
}
|
||||
ops[h] = chainsthreshold.GPUContributionOp{
|
||||
CeremonyID: cid,
|
||||
HolderAddr: uint64(0xDEAD0000 | h),
|
||||
Round: round,
|
||||
HolderIndex: h,
|
||||
PayloadLen: 16,
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
r1 := buildRound(0)
|
||||
r2 := buildRound(1)
|
||||
r3 := buildRound(2)
|
||||
|
||||
desc1 := &GPUMPCVMRoundDescriptor{
|
||||
ChainID: 0xABBA,
|
||||
Round: 1,
|
||||
TimestampNs: 1_000_000_000,
|
||||
Epoch: 1,
|
||||
CeremonyOpCount: 1,
|
||||
ContributionOpCount: 5,
|
||||
}
|
||||
desc2 := &GPUMPCVMRoundDescriptor{
|
||||
ChainID: 0xABBA,
|
||||
Round: 2,
|
||||
TimestampNs: 2_000_000_000,
|
||||
Epoch: 1,
|
||||
ContributionOpCount: 5,
|
||||
}
|
||||
desc3 := &GPUMPCVMRoundDescriptor{
|
||||
ChainID: 0xABBA,
|
||||
Round: 3,
|
||||
TimestampNs: 3_000_000_000,
|
||||
Epoch: 1,
|
||||
ContributionOpCount: 5,
|
||||
}
|
||||
descClose := &GPUMPCVMRoundDescriptor{
|
||||
ChainID: 0xABBA,
|
||||
Round: 4,
|
||||
TimestampNs: 4_000_000_000,
|
||||
Epoch: 1,
|
||||
ClosingFlag: 1,
|
||||
}
|
||||
|
||||
// Run via node-side re-export.
|
||||
runVia := func(t *testing.T) (
|
||||
[]GPUCeremony,
|
||||
[]GPUKeyShare,
|
||||
[]GPUContribution,
|
||||
GPUMPCVMState,
|
||||
GPUMPCVMTransitionResult,
|
||||
) {
|
||||
t.Helper()
|
||||
cer := make([]GPUCeremony, N)
|
||||
keys := make([]GPUKeyShare, N)
|
||||
con := make([]GPUContribution, N)
|
||||
|
||||
b := GPUBackendInstance() // nil-receiver-safe via fallback to CPU reference
|
||||
|
||||
if _, err := b.CeremonyApply(desc1, beginOps, cer); err != nil {
|
||||
if strings.Contains(err.Error(), "GPU backend not available") {
|
||||
t.Skip("GPU plugin not dlopened in this build; CPU-only path covered elsewhere")
|
||||
}
|
||||
t.Fatalf("CeremonyApply r0: %v", err)
|
||||
}
|
||||
if _, err := b.ContributionApply(desc1, r1, cer, con, 1); err != nil {
|
||||
t.Fatalf("ContributionApply r0: %v", err)
|
||||
}
|
||||
if _, _, _, err := b.KeyShareApply(desc1, cer, keys, con, 1); err != nil {
|
||||
t.Fatalf("KeyShareApply r0: %v", err)
|
||||
}
|
||||
|
||||
if _, err := b.ContributionApply(desc2, r2, cer, con, 6); err != nil {
|
||||
t.Fatalf("ContributionApply r1: %v", err)
|
||||
}
|
||||
if _, _, _, err := b.KeyShareApply(desc2, cer, keys, con, 1); err != nil {
|
||||
t.Fatalf("KeyShareApply r1: %v", err)
|
||||
}
|
||||
|
||||
if _, err := b.ContributionApply(desc3, r3, cer, con, 11); err != nil {
|
||||
t.Fatalf("ContributionApply r2: %v", err)
|
||||
}
|
||||
if _, _, _, err := b.KeyShareApply(desc3, cer, keys, con, 1); err != nil {
|
||||
t.Fatalf("KeyShareApply r2: %v", err)
|
||||
}
|
||||
|
||||
var state GPUMPCVMState
|
||||
res, err := b.MPCTransition(descClose, cer, keys, con, &state)
|
||||
if err != nil {
|
||||
t.Fatalf("MPCTransition: %v", err)
|
||||
}
|
||||
return cer, keys, con, state, *res
|
||||
}
|
||||
|
||||
cerA, keysA, conA, stateA, resA := runVia(t)
|
||||
cerB, keysB, conB, stateB, resB := runVia(t)
|
||||
|
||||
for i := range cerA {
|
||||
if cerA[i] != cerB[i] {
|
||||
t.Errorf("ceremony slot %d differs across runs:\nA=%+v\nB=%+v", i, cerA[i], cerB[i])
|
||||
}
|
||||
}
|
||||
for i := range keysA {
|
||||
if keysA[i] != keysB[i] {
|
||||
t.Errorf("keyShare slot %d differs across runs:\nA=%+v\nB=%+v", i, keysA[i], keysB[i])
|
||||
}
|
||||
}
|
||||
for i := range conA {
|
||||
if conA[i] != conB[i] {
|
||||
t.Errorf("contribution slot %d differs across runs:\nA=%+v\nB=%+v", i, conA[i], conB[i])
|
||||
}
|
||||
}
|
||||
if stateA != stateB {
|
||||
t.Errorf("state differs across runs:\nA=%+v\nB=%+v", stateA, stateB)
|
||||
}
|
||||
if resA != resB {
|
||||
t.Errorf("result differs across runs:\nA=%+v\nB=%+v", resA, resB)
|
||||
}
|
||||
|
||||
// Sanity — the ceremony MUST have finalized.
|
||||
if stateA.FinalizedCeremonyCount != 1 {
|
||||
t.Errorf("expected 1 finalized ceremony, got %d", stateA.FinalizedCeremonyCount)
|
||||
}
|
||||
if stateA.KeyShareCount != 5 {
|
||||
t.Errorf("expected 5 emitted key shares, got %d", stateA.KeyShareCount)
|
||||
}
|
||||
var zero [32]byte
|
||||
if stateA.MPCVMStateRoot == zero {
|
||||
t.Errorf("mpcvm_state_root is zero — the fold produced no output")
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package mpcvm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// TestNodeGPULayoutSizes pins the re-exported wire structs to the same
|
||||
// device-side __align__(16) values declared in
|
||||
// the GPU plugin install tree ops/mpcvm/cuda/mpcvm_kernels_common.cuh.
|
||||
//
|
||||
// Even though the structs are type aliases to chains/mpcvm, this
|
||||
// test sits at the node import boundary — if upstream sizes drift the
|
||||
// node-side surface MUST also fail loud rather than miscompile.
|
||||
func TestNodeGPULayoutSizes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
want uintptr
|
||||
got uintptr
|
||||
}{
|
||||
{"GPUCeremony", 128, unsafe.Sizeof(GPUCeremony{})},
|
||||
{"GPUKeyShare", 368, unsafe.Sizeof(GPUKeyShare{})},
|
||||
{"GPUContribution", 432, unsafe.Sizeof(GPUContribution{})},
|
||||
{"GPUMPCVMState", 160, unsafe.Sizeof(GPUMPCVMState{})},
|
||||
{"GPUMPCVMRoundDescriptor", 96, unsafe.Sizeof(GPUMPCVMRoundDescriptor{})},
|
||||
{"GPUCeremonyOp", 96, unsafe.Sizeof(GPUCeremonyOp{})},
|
||||
{"GPUContributionOp", 416, unsafe.Sizeof(GPUContributionOp{})},
|
||||
{"GPUMPCVMTransitionResult", 176, unsafe.Sizeof(GPUMPCVMTransitionResult{})},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s: sizeof=%d want=%d", c.name, c.got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeGPUBackendRoundTrip is the dlopen round-trip required by the
|
||||
// task spec. Two acceptable outcomes:
|
||||
//
|
||||
// 1. Plugin resolved: GPUBackendInstance() != nil && IsAvailable(); a
|
||||
// zero-fixture CeremonyApply against the best backend returns rc=0
|
||||
// (no ops processed) with no error.
|
||||
//
|
||||
// 2. Plugin absent: GPUBackendInstance() == nil; nil-receiver methods
|
||||
// return ErrGPUNotAvailable. SelectGPUBackend reports the CPU-only
|
||||
// diagnostic string.
|
||||
//
|
||||
// Both outcomes count as passing — the bridge correctly surfaces GPU
|
||||
// state through the public node API.
|
||||
func TestNodeGPUBackendRoundTrip(t *testing.T) {
|
||||
b, diag := SelectGPUBackend()
|
||||
t.Logf("SelectGPUBackend: %s", diag)
|
||||
if b == nil {
|
||||
// Plugin-absent path. The nil receiver contract must hold —
|
||||
// every method returns ErrGPUNotAvailable, no panic.
|
||||
_, err := (*GPUBackend)(nil).CeremonyApply(nil, nil, nil)
|
||||
if !errors.Is(err, ErrGPUNotAvailable) {
|
||||
t.Fatalf("nil receiver CeremonyApply: want ErrGPUNotAvailable, got %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Plugin-resolved path. Zero fixture: 1-slot ceremony arena, no ops.
|
||||
// The kernel walks zero ops and returns applied=0 with rc=0. Any
|
||||
// non-zero rc means a real launcher failure.
|
||||
desc := &GPUMPCVMRoundDescriptor{
|
||||
ChainID: 0xCAFEBABE,
|
||||
Round: 1,
|
||||
TimestampNs: 1700000000_000000000,
|
||||
Epoch: 1,
|
||||
}
|
||||
ceremonies := make([]GPUCeremony, 1)
|
||||
applied, err := b.CeremonyApply(desc, nil, ceremonies)
|
||||
if err != nil {
|
||||
t.Fatalf("CeremonyApply(zero): %v", err)
|
||||
}
|
||||
if applied != 0 {
|
||||
t.Errorf("CeremonyApply(zero): applied=%d want=0", applied)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeGPUStubContract verifies the nocgo-equivalent contract on a
|
||||
// zero-value GPUBackend: IsAvailable()==false and every state-machine
|
||||
// method returns ErrGPUNotAvailable. The same contract holds under cgo
|
||||
// when no plugin is dlopened — making this test build-flavor-independent.
|
||||
func TestNodeGPUStubContract(t *testing.T) {
|
||||
var b GPUBackend
|
||||
if b.IsAvailable() {
|
||||
t.Fatal("zero GPUBackend.IsAvailable() must be false")
|
||||
}
|
||||
if _, err := b.CeremonyApply(nil, nil, nil); !errors.Is(err, ErrGPUNotAvailable) {
|
||||
t.Errorf("CeremonyApply: want ErrGPUNotAvailable, got %v", err)
|
||||
}
|
||||
if _, _, _, err := b.KeyShareApply(nil, nil, nil, nil, 0); !errors.Is(err, ErrGPUNotAvailable) {
|
||||
t.Errorf("KeyShareApply: want ErrGPUNotAvailable, got %v", err)
|
||||
}
|
||||
if _, err := b.ContributionApply(nil, nil, nil, nil, 0); !errors.Is(err, ErrGPUNotAvailable) {
|
||||
t.Errorf("ContributionApply: want ErrGPUNotAvailable, got %v", err)
|
||||
}
|
||||
if _, err := b.MPCTransition(nil, nil, nil, nil, nil); !errors.Is(err, ErrGPUNotAvailable) {
|
||||
t.Errorf("MPCTransition: want ErrGPUNotAvailable, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -87,10 +87,24 @@ func (b *Block) Verify(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (b *Block) Accept(context.Context) error {
|
||||
// Serialize the block's state commit (acceptor → state.Apply/CommitBatch →
|
||||
// state.write) with the VM's OTHER writers of the same shared state — the
|
||||
// peer Disconnect and Start/StopTracking uptime flushes, which run on
|
||||
// goroutines the consensus engine does not serialize against accept (it
|
||||
// invokes VM.Accept as a lock-free call-out). Holding the VM's stateLock for
|
||||
// the whole visit makes accept atomic w.r.t. those commits, closing the
|
||||
// concurrent-map-write in state.write(). Mirrors avalanchego's ctx.Lock,
|
||||
// which serializes block accept with engine.Connected/Disconnected.
|
||||
b.manager.stateLock.Lock()
|
||||
defer b.manager.stateLock.Unlock()
|
||||
return b.Visit(b.manager.acceptor)
|
||||
}
|
||||
|
||||
func (b *Block) Reject(context.Context) error {
|
||||
// Held under the same lock as Accept so a block DECISION (accept or reject)
|
||||
// is uniformly serialized with the VM's peer-lifecycle state commits.
|
||||
b.manager.stateLock.Lock()
|
||||
defer b.manager.stateLock.Unlock()
|
||||
return b.Visit(b.manager.rejector)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/luxfi/vm/chain"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/math/set"
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/luxfi/node/vms/platformvm/txs/fee"
|
||||
"github.com/luxfi/node/vms/platformvm/validators"
|
||||
"github.com/luxfi/node/vms/txs/mempool"
|
||||
"github.com/luxfi/vm/chain"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -57,6 +58,15 @@ func NewManager(
|
||||
s state.State,
|
||||
txExecutorBackend *executor.Backend,
|
||||
validatorManager validators.Manager,
|
||||
// stateLock serializes a block's accept/reject state commit with the VM's
|
||||
// OTHER writers of the same shared state — the peer-lifecycle (Disconnect)
|
||||
// and normal-ops (Start/StopTracking) uptime flushes, which run on
|
||||
// goroutines the consensus engine does not serialize. The platform VM owns
|
||||
// it and holds the SAME lock in those paths; without it a peer disconnect's
|
||||
// state.Commit races a block accept's state.CommitBatch inside state.write()
|
||||
// (concurrent Go map writes → fatal). Must be non-nil (the VM always
|
||||
// supplies &vm.stateLock).
|
||||
stateLock *sync.Mutex,
|
||||
) Manager {
|
||||
lastAccepted := s.GetLastAccepted()
|
||||
backend := &backend{
|
||||
@@ -81,6 +91,7 @@ func NewManager(
|
||||
preferred: lastAccepted,
|
||||
txExecutorBackend: txExecutorBackend,
|
||||
validatorManager: validatorManager,
|
||||
stateLock: stateLock,
|
||||
Log: log.Noop(),
|
||||
}
|
||||
}
|
||||
@@ -93,7 +104,10 @@ type manager struct {
|
||||
preferred ids.ID
|
||||
txExecutorBackend *executor.Backend
|
||||
validatorManager validators.Manager
|
||||
Log log.Logger
|
||||
// stateLock serializes block accept/reject (Block.Accept/Reject) with the
|
||||
// VM's peer-lifecycle / normal-ops state commits. See NewManager.
|
||||
stateLock *sync.Mutex
|
||||
Log log.Logger
|
||||
}
|
||||
|
||||
func (m *manager) GetBlock(blkID ids.ID) (chain.Block, error) {
|
||||
|
||||
@@ -926,11 +926,12 @@ func (s *Service) getPrimaryOrNetValidators(netID ids.ID, nodeIDs set.Set[ids.No
|
||||
// Transform this to a percentage (0-100) to make it consistent
|
||||
// with observedUptime in info.peers API
|
||||
currentUptime := avajson.Float32(rawUptime * 100)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// connected field left nil - IsConnected method no longer exists
|
||||
uptime = ¤tUptime
|
||||
|
||||
// Report whether this validator currently has a live connection
|
||||
// to us, read from the same tracker that measured its uptime.
|
||||
isConnected := s.vm.tracker != nil && s.vm.tracker.IsConnected(currentStaker.NodeID)
|
||||
connected = &isConnected
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package platformvm
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/constants"
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/metric"
|
||||
"github.com/luxfi/runtime"
|
||||
validators "github.com/luxfi/validators"
|
||||
|
||||
"github.com/luxfi/node/upgrade"
|
||||
"github.com/luxfi/node/vms/platformvm/config"
|
||||
"github.com/luxfi/node/vms/platformvm/genesis/genesistest"
|
||||
platformvmmetrics "github.com/luxfi/node/vms/platformvm/metrics"
|
||||
"github.com/luxfi/node/vms/platformvm/reward"
|
||||
"github.com/luxfi/node/vms/platformvm/state"
|
||||
)
|
||||
|
||||
// newRaceTestState builds a REAL platform state (state.New, memdb + genesis) —
|
||||
// the same constructor the VM uses. The genesis carries a validator set, so
|
||||
// state.write (invoked by BOTH state.Commit and state.CommitBatch) iterates
|
||||
// non-empty validator/metadata maps: exactly the shared maps that mutate
|
||||
// concurrently in the fatal this test guards against.
|
||||
func newRaceTestState(t *testing.T) state.State {
|
||||
t.Helper()
|
||||
require := require.New(t)
|
||||
|
||||
reg := metric.NewRegistry()
|
||||
m, err := platformvmmetrics.New(reg)
|
||||
require.NoError(err)
|
||||
|
||||
execCfg, err := config.GetConfig(nil)
|
||||
require.NoError(err)
|
||||
|
||||
st, err := state.New(
|
||||
memdb.New(),
|
||||
genesistest.NewBytes(t, genesistest.Config{}),
|
||||
reg,
|
||||
validators.NewManager(),
|
||||
upgrade.GetConfig(constants.UnitTestID),
|
||||
execCfg,
|
||||
&runtime.Runtime{
|
||||
NetworkID: constants.UnitTestID,
|
||||
ChainID: constants.PlatformChainID,
|
||||
Log: log.Noop(),
|
||||
},
|
||||
m,
|
||||
reward.NewCalculator(reward.Config{
|
||||
MaxConsumptionRate: 120_000,
|
||||
MinConsumptionRate: 100_000,
|
||||
MintingPeriod: 365 * 24 * time.Hour,
|
||||
SupplyCap: 720 * constants.MegaLux,
|
||||
}),
|
||||
)
|
||||
require.NoError(err)
|
||||
return st
|
||||
}
|
||||
|
||||
// TestStateCommitSerializedWithAcceptNoRace is the regression guard for RED
|
||||
// CRITICAL #2 (P-chain state race).
|
||||
//
|
||||
// Before the fix the event-delivery plumbing drove VM.Disconnected on the
|
||||
// node's peer-lifecycle goroutine, where it called state.Commit() (→ state.write)
|
||||
// concurrently with the block acceptor's state.CommitBatch() (→ state.write) on
|
||||
// the consensus accept goroutine. Both mutate the shared currentValidator /
|
||||
// staker / metadata maps with no common lock → Go "concurrent map writes" FATAL
|
||||
// on ordinary peer churn.
|
||||
//
|
||||
// The fix installs ONE lock — the platform VM's stateLock — held by BOTH sides:
|
||||
// - block DECISION: block/executor Block.Accept/Reject hold &vm.stateLock
|
||||
// (supplied to executor.NewManager), around the whole acceptor visit, and
|
||||
// - peer/lifecycle commits: VM.Disconnected and the Start/StopTracking uptime
|
||||
// flushes hold vm.stateLock directly.
|
||||
//
|
||||
// This test drives the two REAL racing state operations — state.Commit()
|
||||
// (VM.Disconnected's exact call) and state.SetUptime()+state.CommitBatch()
|
||||
// (the tracker-flush + acceptor's exact calls) — from two goroutines, each
|
||||
// holding the SAME lock the fix installs. Under `-race` it must complete with
|
||||
// no data race and no fatal. Remove the shared lock and `-race` immediately
|
||||
// reports the concurrent write inside state.write() (the regression).
|
||||
func TestStateCommitSerializedWithAcceptNoRace(t *testing.T) {
|
||||
st := newRaceTestState(t)
|
||||
|
||||
// The single serializer the fix installs. In production this is
|
||||
// &vm.stateLock, held by Block.Accept (via the executor manager) and by
|
||||
// VM.Disconnected/Start/StopTracking.
|
||||
var stateLock sync.Mutex
|
||||
|
||||
// A genesis validator so SetUptime writes a real record (mirrors
|
||||
// tracker.Disconnect → state.SetUptime before state.Commit).
|
||||
nodeID := genesistest.DefaultNodeIDs[0]
|
||||
netID := constants.PrimaryNetworkID
|
||||
|
||||
const iterations = 300
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
acceptErr error
|
||||
disconnectErr error
|
||||
)
|
||||
wg.Add(2)
|
||||
|
||||
// Accept side: the acceptor's state.CommitBatch() + state.Abort() — the exact
|
||||
// calls block/executor acceptor.standardBlock makes, both routed through
|
||||
// state.write(). Block.Accept holds stateLock around this.
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
stateLock.Lock()
|
||||
_, err := st.CommitBatch()
|
||||
st.Abort()
|
||||
stateLock.Unlock()
|
||||
if err != nil {
|
||||
acceptErr = err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Disconnect side: tracker.Disconnect (state.SetUptime, error ignored exactly
|
||||
// as the tracker's updateUptimeLocked ignores ErrNotFound) followed by
|
||||
// state.Commit() — VM.Disconnected's exact sequence. Holds the SAME stateLock.
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
stateLock.Lock()
|
||||
_ = st.SetUptime(nodeID, netID, time.Duration(i)*time.Second, time.Unix(int64(i), 0))
|
||||
err := st.Commit()
|
||||
stateLock.Unlock()
|
||||
if err != nil {
|
||||
disconnectErr = err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
require := require.New(t)
|
||||
require.NoError(acceptErr)
|
||||
require.NoError(disconnectErr)
|
||||
}
|
||||
+218
-114
@@ -4,91 +4,230 @@
|
||||
package platformvm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/validators/uptime"
|
||||
)
|
||||
|
||||
// uptimeTracker implements uptime.Calculator by tracking peer connections
|
||||
// and computing real uptime percentages from persistent state.
|
||||
var (
|
||||
errAlreadyStartedTracking = errors.New("uptime tracker already started tracking")
|
||||
errNotStartedTracking = errors.New("uptime tracker has not started tracking")
|
||||
)
|
||||
|
||||
// uptimeTracker implements uptime.Calculator plus the connection/tracking hooks
|
||||
// the platform VM drives directly (Connect, Disconnect, StartTracking,
|
||||
// StopTracking, IsConnected).
|
||||
//
|
||||
// It wraps an uptime.State (backed by platformvm state) that stores
|
||||
// cumulative upDuration and lastUpdated per validator. On Connect, it
|
||||
// records the connection time. On Disconnect, it flushes the elapsed
|
||||
// connected time into the persistent state. CalculateUptimePercent
|
||||
// reads from state and accounts for any currently-connected time.
|
||||
// It is a faithful port of avalanchego's snow/uptime.Manager, adapted to the
|
||||
// luxfi validators uptime.State interface (which keys by netID and returns
|
||||
// lastUpdated encoded as a second-granular duration since the Unix epoch).
|
||||
//
|
||||
// Model, in one sentence: each connected peer's session accrues into a per
|
||||
// validator up-duration that is folded forward to "now" on read, and persisted
|
||||
// on flush.
|
||||
//
|
||||
// - Connect records a peer's connection time. It is captured from the very
|
||||
// first handshake — including during bootstrap, before StartTracking — so a
|
||||
// stable, continuously-connected validator set is fully observed the moment
|
||||
// tracking begins.
|
||||
// - StartTracking, called once at P-chain normal-operations start, baselines
|
||||
// every validator (crediting the pre-tracking window as online, matching
|
||||
// avalanchego) and switches into live-tracking mode.
|
||||
// - CalculateUptime folds the currently-connected session forward to now, so a
|
||||
// validator that stays connected accrues up-duration WITHOUT ever needing a
|
||||
// Disconnect.
|
||||
// - Disconnect / StopTracking flush the accrued session into persistent state.
|
||||
//
|
||||
// The prior custom tracker could not accrue uptime for a stable set: it never
|
||||
// started tracking, only flushed on Disconnect, and — because peer connection
|
||||
// events were never delivered to the VM — never populated its connected map.
|
||||
type uptimeTracker struct {
|
||||
mu sync.RWMutex
|
||||
clk func() time.Time
|
||||
state uptime.State
|
||||
netID ids.ID
|
||||
connected map[ids.NodeID]time.Time // nodeID -> time they connected
|
||||
mu sync.RWMutex
|
||||
clk func() time.Time
|
||||
state uptime.State
|
||||
netID ids.ID
|
||||
|
||||
// connections maps a currently-connected nodeID to the (second-granular)
|
||||
// time it connected.
|
||||
connections map[ids.NodeID]time.Time
|
||||
|
||||
// startedTracking gates live-session accounting. Before StartTracking, a
|
||||
// validator is assumed online since its last persisted update (so the
|
||||
// bootstrap window is not spuriously counted as downtime). After
|
||||
// StartTracking, only genuinely-connected sessions accrue.
|
||||
startedTracking bool
|
||||
}
|
||||
|
||||
func newUptimeTracker(state uptime.State, netID ids.ID, clk func() time.Time) *uptimeTracker {
|
||||
return &uptimeTracker{
|
||||
clk: clk,
|
||||
state: state,
|
||||
netID: netID,
|
||||
connected: make(map[ids.NodeID]time.Time),
|
||||
clk: clk,
|
||||
state: state,
|
||||
netID: netID,
|
||||
connections: make(map[ids.NodeID]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// Connect records that a validator connected.
|
||||
// now returns the tracker clock truncated to second precision. lastUpdated is
|
||||
// persisted at second granularity (state stores lastUpdated.Unix()), so working
|
||||
// at one resolution keeps the interval arithmetic exact and side-steps
|
||||
// monotonic-clock skew.
|
||||
func (t *uptimeTracker) now() time.Time {
|
||||
return time.Unix(t.clk().Unix(), 0)
|
||||
}
|
||||
|
||||
// lastUpdatedFromDuration reconstructs the persisted lastUpdated timestamp from
|
||||
// the second-granular "duration since epoch" the uptime.State returns.
|
||||
func lastUpdatedFromDuration(d time.Duration) time.Time {
|
||||
return time.Unix(int64(d/time.Second), 0)
|
||||
}
|
||||
|
||||
// Connect records that [nodeID] connected. It is idempotent: a duplicate Connect
|
||||
// for an already-connected node keeps the original connection time, so repeated
|
||||
// router dispatch of the same live connection can never reset the session clock.
|
||||
func (t *uptimeTracker) Connect(nodeID ids.NodeID) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if _, ok := t.connected[nodeID]; ok {
|
||||
return // already connected
|
||||
if _, ok := t.connections[nodeID]; ok {
|
||||
return
|
||||
}
|
||||
t.connected[nodeID] = t.clk()
|
||||
t.connections[nodeID] = t.now()
|
||||
}
|
||||
|
||||
// Disconnect records that a validator disconnected and flushes
|
||||
// the accumulated uptime into persistent state.
|
||||
func (t *uptimeTracker) Disconnect(nodeID ids.NodeID) error {
|
||||
// IsConnected reports whether [nodeID] currently has a live connection.
|
||||
func (t *uptimeTracker) IsConnected(nodeID ids.NodeID) bool {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
_, connected := t.connections[nodeID]
|
||||
return connected
|
||||
}
|
||||
|
||||
// Disconnect records that [nodeID] disconnected, flushing its accrued session
|
||||
// into persistent state. It returns whether that flush actually wrote uptime
|
||||
// state (a SetUptime the caller must Commit). Flushing is a no-op — mutated
|
||||
// false — before StartTracking (the session is not yet being measured, matching
|
||||
// avalanchego) and for a peer with no uptime record (a non-validator), so the
|
||||
// caller can skip an empty state.Commit.
|
||||
func (t *uptimeTracker) Disconnect(nodeID ids.NodeID) (mutated bool, err error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.disconnectLocked(nodeID)
|
||||
}
|
||||
|
||||
func (t *uptimeTracker) disconnectLocked(nodeID ids.NodeID) error {
|
||||
connectedAt, ok := t.connected[nodeID]
|
||||
if !ok {
|
||||
return nil // wasn't connected
|
||||
defer delete(t.connections, nodeID)
|
||||
if !t.startedTracking {
|
||||
return false, nil
|
||||
}
|
||||
delete(t.connected, nodeID)
|
||||
now := t.clk()
|
||||
elapsed := now.Sub(connectedAt)
|
||||
return t.addUptime(nodeID, elapsed, now)
|
||||
return t.updateUptimeLocked(nodeID)
|
||||
}
|
||||
|
||||
// addUptime adds elapsed duration to the validator's persistent uptime.
|
||||
func (t *uptimeTracker) addUptime(nodeID ids.NodeID, elapsed time.Duration, now time.Time) error {
|
||||
upDuration, _, err := t.state.GetUptime(nodeID, t.netID)
|
||||
if err != nil {
|
||||
// Validator not in state (e.g., not a current validator). Skip.
|
||||
return nil
|
||||
}
|
||||
return t.state.SetUptime(nodeID, t.netID, upDuration+elapsed, now)
|
||||
}
|
||||
|
||||
// Shutdown flushes all connected validators' uptime to state.
|
||||
// Call this before persisting state on node shutdown.
|
||||
func (t *uptimeTracker) Shutdown() error {
|
||||
// StartTracking baselines every validator in [nodeIDs] and switches into live
|
||||
// tracking mode. It is called once at P-chain normal-operations start. Each
|
||||
// validator's persisted up-duration is advanced to now assuming it was online
|
||||
// since its last update (the standard avalanchego assumption), so a validator
|
||||
// that has been in the set is not penalized for the un-measured pre-tracking
|
||||
// window.
|
||||
func (t *uptimeTracker) StartTracking(nodeIDs []ids.NodeID) error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
for nodeID := range t.connected {
|
||||
if err := t.disconnectLocked(nodeID); err != nil {
|
||||
if t.startedTracking {
|
||||
return errAlreadyStartedTracking
|
||||
}
|
||||
for _, nodeID := range nodeIDs {
|
||||
if _, err := t.updateUptimeLocked(nodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
t.startedTracking = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// CalculateUptime returns (upDuration, totalDuration, error) for a validator.
|
||||
// StopTracking flushes every validator in [nodeIDs] and leaves tracking mode.
|
||||
// It is called on the normal-ops → bootstrapping / shutdown transition so the
|
||||
// connected sessions are durably persisted before the node stops measuring.
|
||||
func (t *uptimeTracker) StopTracking(nodeIDs []ids.NodeID) error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if !t.startedTracking {
|
||||
return errNotStartedTracking
|
||||
}
|
||||
for _, nodeID := range nodeIDs {
|
||||
if _, err := t.updateUptimeLocked(nodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
t.startedTracking = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartedTracking reports whether StartTracking has run and StopTracking has not
|
||||
// since. The VM lifecycle uses it to avoid double-start and to gate shutdown
|
||||
// flushing.
|
||||
func (t *uptimeTracker) StartedTracking() bool {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return t.startedTracking
|
||||
}
|
||||
|
||||
// calculateUptimeLocked mirrors avalanchego snow/uptime.Manager.CalculateUptime:
|
||||
// it returns [nodeID]'s up-duration folded forward to now, plus that now (the
|
||||
// new lastUpdated). The caller must hold t.mu (read or write).
|
||||
func (t *uptimeTracker) calculateUptimeLocked(nodeID ids.NodeID) (time.Duration, time.Time, error) {
|
||||
upDuration, lastUpdatedDur, err := t.state.GetUptime(nodeID, t.netID)
|
||||
if err != nil {
|
||||
return 0, time.Time{}, err
|
||||
}
|
||||
lastUpdated := lastUpdatedFromDuration(lastUpdatedDur)
|
||||
now := t.now()
|
||||
|
||||
// Clock skew: never subtract time or double-count.
|
||||
if now.Before(lastUpdated) {
|
||||
return upDuration, lastUpdated, nil
|
||||
}
|
||||
|
||||
// Before tracking, assume the node was online since its last update.
|
||||
if !t.startedTracking {
|
||||
return upDuration + now.Sub(lastUpdated), now, nil
|
||||
}
|
||||
|
||||
// Tracking, but not connected: offline since its last update.
|
||||
connectedAt, isConnected := t.connections[nodeID]
|
||||
if !isConnected {
|
||||
return upDuration, now, nil
|
||||
}
|
||||
|
||||
// Tracking and connected: credit from the later of (connect, lastUpdated) so
|
||||
// no interval is double-counted.
|
||||
if connectedAt.Before(lastUpdated) {
|
||||
connectedAt = lastUpdated
|
||||
}
|
||||
if now.Before(connectedAt) {
|
||||
return upDuration, now, nil
|
||||
}
|
||||
return upDuration + now.Sub(connectedAt), now, nil
|
||||
}
|
||||
|
||||
// updateUptimeLocked persists the current up-duration for [nodeID], returning
|
||||
// whether it actually wrote (mutated). A node without a state record (i.e. not a
|
||||
// current validator) is silently skipped — mutated false — so tracking
|
||||
// non-validator peers is harmless and never dirties the state. The caller must
|
||||
// hold t.mu (write).
|
||||
func (t *uptimeTracker) updateUptimeLocked(nodeID ids.NodeID) (mutated bool, err error) {
|
||||
upDuration, lastUpdated, err := t.calculateUptimeLocked(nodeID)
|
||||
if errors.Is(err, database.ErrNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := t.state.SetUptime(nodeID, t.netID, upDuration, lastUpdated); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CalculateUptime returns (upDuration, totalDuration) for [nodeID], where
|
||||
// totalDuration is the maximum possible uptime since the validator's start.
|
||||
func (t *uptimeTracker) CalculateUptime(nodeID ids.NodeID, netID ids.ID) (time.Duration, time.Duration, error) {
|
||||
if netID != t.netID {
|
||||
return 0, 0, nil
|
||||
@@ -99,97 +238,62 @@ func (t *uptimeTracker) CalculateUptime(nodeID ids.NodeID, netID ids.ID) (time.D
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
now := t.clk()
|
||||
totalDuration := now.Sub(startTime)
|
||||
if totalDuration <= 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
upDuration, _, err := t.state.GetUptime(nodeID, netID)
|
||||
if err != nil {
|
||||
return 0, totalDuration, nil
|
||||
}
|
||||
|
||||
// Add any currently-connected time that hasn't been flushed yet.
|
||||
t.mu.RLock()
|
||||
if connectedAt, ok := t.connected[nodeID]; ok {
|
||||
upDuration += now.Sub(connectedAt)
|
||||
}
|
||||
upDuration, _, err := t.calculateUptimeLocked(nodeID)
|
||||
t.mu.RUnlock()
|
||||
|
||||
if upDuration > totalDuration {
|
||||
upDuration = totalDuration
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return upDuration, totalDuration, nil
|
||||
|
||||
total := t.now().Sub(startTime)
|
||||
if total < 0 {
|
||||
total = 0
|
||||
}
|
||||
return upDuration, total, nil
|
||||
}
|
||||
|
||||
// CalculateUptimePercent returns the uptime as a fraction in [0, 1].
|
||||
// CalculateUptimePercent returns [nodeID]'s uptime over its whole staking period
|
||||
// as a fraction in [0, 1].
|
||||
func (t *uptimeTracker) CalculateUptimePercent(nodeID ids.NodeID, netID ids.ID) (float64, error) {
|
||||
if netID != t.netID {
|
||||
return 0, nil
|
||||
}
|
||||
upDuration, totalDuration, err := t.CalculateUptime(nodeID, netID)
|
||||
startTime, err := t.state.GetStartTime(nodeID, netID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if totalDuration == 0 {
|
||||
return 1, nil // no time elapsed, consider 100%
|
||||
}
|
||||
return float64(upDuration) / float64(totalDuration), nil
|
||||
return t.CalculateUptimePercentFrom(nodeID, netID, startTime)
|
||||
}
|
||||
|
||||
// CalculateUptimePercentFrom returns the uptime as a fraction since [from].
|
||||
// CalculateUptimePercentFrom returns [nodeID]'s uptime since [from] as a fraction
|
||||
// in [0, 1].
|
||||
func (t *uptimeTracker) CalculateUptimePercentFrom(nodeID ids.NodeID, netID ids.ID, from time.Time) (float64, error) {
|
||||
if netID != t.netID {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
now := t.clk()
|
||||
totalDuration := now.Sub(from)
|
||||
if totalDuration <= 0 {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
upDuration, _, err := t.state.GetUptime(nodeID, netID)
|
||||
if err != nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Subtract uptime before [from] by using startTime.
|
||||
// If from > startTime, some of the stored upDuration may predate [from].
|
||||
// We approximate by assuming the same uptime rate.
|
||||
startTime, err := t.state.GetStartTime(nodeID, netID)
|
||||
if err != nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
totalSinceStart := now.Sub(startTime)
|
||||
if totalSinceStart <= 0 {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// Add any currently-connected time.
|
||||
t.mu.RLock()
|
||||
if connectedAt, ok := t.connected[nodeID]; ok {
|
||||
upDuration += now.Sub(connectedAt)
|
||||
}
|
||||
upDuration, _, err := t.calculateUptimeLocked(nodeID)
|
||||
t.mu.RUnlock()
|
||||
|
||||
if upDuration > totalSinceStart {
|
||||
upDuration = totalSinceStart
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Scale upDuration to the [from, now] window.
|
||||
if from.After(startTime) {
|
||||
rate := float64(upDuration) / float64(totalSinceStart)
|
||||
return rate, nil
|
||||
bestPossible := t.now().Sub(from)
|
||||
if bestPossible <= 0 {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// from <= startTime, just use overall rate.
|
||||
return float64(upDuration) / float64(totalSinceStart), nil
|
||||
fraction := float64(upDuration) / float64(bestPossible)
|
||||
if fraction > 1 {
|
||||
fraction = 1
|
||||
}
|
||||
return fraction, nil
|
||||
}
|
||||
|
||||
// SetCalculator is a no-op; this tracker doesn't delegate.
|
||||
// SetCalculator is a no-op: this tracker is a concrete Calculator and does not
|
||||
// delegate. It exists only to satisfy uptime.Calculator; registration is done by
|
||||
// the enclosing uptime.LockedCalculator.
|
||||
func (t *uptimeTracker) SetCalculator(ids.ID, uptime.Calculator) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,11 +8,17 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// fakeUptimeState implements uptime.State for testing.
|
||||
// fakeUptimeState implements uptime.State for testing, mirroring how the real
|
||||
// platformvm state stores uptime: an up-duration plus a second-granular
|
||||
// lastUpdated, returned as a "duration since the Unix epoch". Validators must be
|
||||
// registered first; GetUptime/SetUptime on an unregistered node return
|
||||
// database.ErrNotFound, exactly like metadata_validator.go.
|
||||
type fakeUptimeState struct {
|
||||
uptimes map[ids.NodeID]time.Duration
|
||||
lastUpdate map[ids.NodeID]time.Time
|
||||
@@ -27,8 +33,7 @@ func newFakeUptimeState() *fakeUptimeState {
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeUptimeState) addValidator(nodeID ids.NodeID, netID ids.ID, startTime time.Time) {
|
||||
_ = netID
|
||||
func (f *fakeUptimeState) addValidator(nodeID ids.NodeID, _ ids.ID, startTime time.Time) {
|
||||
f.uptimes[nodeID] = 0
|
||||
f.lastUpdate[nodeID] = startTime
|
||||
f.startTimes[nodeID] = startTime
|
||||
@@ -37,7 +42,7 @@ func (f *fakeUptimeState) addValidator(nodeID ids.NodeID, netID ids.ID, startTim
|
||||
func (f *fakeUptimeState) GetUptime(nodeID ids.NodeID, _ ids.ID) (time.Duration, time.Duration, error) {
|
||||
up, ok := f.uptimes[nodeID]
|
||||
if !ok {
|
||||
return 0, 0, errNotFound
|
||||
return 0, 0, database.ErrNotFound
|
||||
}
|
||||
lastUpdatedDuration := time.Duration(f.lastUpdate[nodeID].Unix()) * time.Second
|
||||
return up, lastUpdatedDuration, nil
|
||||
@@ -45,7 +50,7 @@ func (f *fakeUptimeState) GetUptime(nodeID ids.NodeID, _ ids.ID) (time.Duration,
|
||||
|
||||
func (f *fakeUptimeState) SetUptime(nodeID ids.NodeID, _ ids.ID, uptime time.Duration, lastUpdated time.Time) error {
|
||||
if _, ok := f.uptimes[nodeID]; !ok {
|
||||
return errNotFound
|
||||
return database.ErrNotFound
|
||||
}
|
||||
f.uptimes[nodeID] = uptime
|
||||
f.lastUpdate[nodeID] = lastUpdated
|
||||
@@ -55,18 +60,23 @@ func (f *fakeUptimeState) SetUptime(nodeID ids.NodeID, _ ids.ID, uptime time.Dur
|
||||
func (f *fakeUptimeState) GetStartTime(nodeID ids.NodeID, _ ids.ID) (time.Time, error) {
|
||||
st, ok := f.startTimes[nodeID]
|
||||
if !ok {
|
||||
return time.Time{}, errNotFound
|
||||
return time.Time{}, database.ErrNotFound
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
var errNotFound = errTestNotFound{}
|
||||
|
||||
type errTestNotFound struct{}
|
||||
|
||||
func (errTestNotFound) Error() string { return "not found" }
|
||||
|
||||
func TestUptimeTrackerConnectDisconnect(t *testing.T) {
|
||||
// TestUptimeTrackerLongRunningValidatorAccruesUptime is the regression test for
|
||||
// the ~165M LUX reward gate. A validator that has been staked for 30 days must
|
||||
// report ~100% uptime, not 0%.
|
||||
//
|
||||
// This test uses ONLY the shared Calculator surface (newUptimeTracker +
|
||||
// CalculateUptimePercentFrom) that both the old and the fixed tracker expose, so
|
||||
// it can be run against either implementation:
|
||||
// - OLD tracker: returns ~0.0 — stored upDuration is 0 and the tracker had no
|
||||
// baseline for the un-measured window, so upDuration/total = 0/30d = 0.
|
||||
// - FIXED tracker: returns ~1.0 — before tracking begins, a validator is
|
||||
// assumed online since its last persisted update (avalanchego semantics).
|
||||
func TestUptimeTrackerLongRunningValidatorAccruesUptime(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
state := newFakeUptimeState()
|
||||
@@ -76,87 +86,240 @@ func TestUptimeTrackerConnectDisconnect(t *testing.T) {
|
||||
now := time.Now()
|
||||
clk := func() time.Time { return now }
|
||||
|
||||
state.addValidator(nodeID, netID, now.Add(-time.Hour))
|
||||
startTime := now.Add(-30 * 24 * time.Hour) // staked 30 days ago
|
||||
state.addValidator(nodeID, netID, startTime)
|
||||
|
||||
tracker := newUptimeTracker(state, netID, clk)
|
||||
|
||||
// Before connect: 0% uptime.
|
||||
pct, err := tracker.CalculateUptimePercentFrom(nodeID, netID, startTime)
|
||||
require.NoError(err)
|
||||
require.InDelta(1.0, pct, 0.001, "a continuously-staked validator must report ~100%% uptime, not 0%%")
|
||||
}
|
||||
|
||||
// TestUptimeTrackerStartTrackingBaselines verifies that StartTracking credits the
|
||||
// un-measured pre-tracking window (assuming the validator was online) and moves
|
||||
// the tracker into live-tracking mode.
|
||||
func TestUptimeTrackerStartTrackingBaselines(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
state := newFakeUptimeState()
|
||||
netID := ids.GenerateTestID()
|
||||
nodeID := ids.GenerateTestNodeID()
|
||||
|
||||
now := time.Now().Truncate(time.Second)
|
||||
clk := func() time.Time { return now }
|
||||
|
||||
startTime := now.Add(-time.Hour)
|
||||
state.addValidator(nodeID, netID, startTime)
|
||||
|
||||
tracker := newUptimeTracker(state, netID, clk)
|
||||
require.False(tracker.StartedTracking())
|
||||
|
||||
require.NoError(tracker.StartTracking([]ids.NodeID{nodeID}))
|
||||
require.True(tracker.StartedTracking())
|
||||
|
||||
// The hour since lastUpdated (== startTime) is baked into the persisted
|
||||
// up-duration, and lastUpdated advanced to now.
|
||||
require.Equal(time.Hour, state.uptimes[nodeID])
|
||||
require.Equal(now.Unix(), state.lastUpdate[nodeID].Unix())
|
||||
}
|
||||
|
||||
// TestUptimeTrackerContinuouslyConnectedClimbs is the core behavioral fix: a
|
||||
// validator that connects (during bootstrap) and stays connected accrues uptime
|
||||
// over time WITHOUT ever disconnecting, and IsConnected reports true.
|
||||
func TestUptimeTrackerContinuouslyConnectedClimbs(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
state := newFakeUptimeState()
|
||||
netID := ids.GenerateTestID()
|
||||
nodeID := ids.GenerateTestNodeID()
|
||||
|
||||
now := time.Now().Truncate(time.Second)
|
||||
clk := func() time.Time { return now }
|
||||
|
||||
startTime := now
|
||||
state.addValidator(nodeID, netID, startTime)
|
||||
|
||||
tracker := newUptimeTracker(state, netID, clk)
|
||||
|
||||
// Peer connects during bootstrap, before tracking starts.
|
||||
tracker.Connect(nodeID)
|
||||
require.True(tracker.IsConnected(nodeID))
|
||||
|
||||
// Normal operations begin.
|
||||
require.NoError(tracker.StartTracking([]ids.NodeID{nodeID}))
|
||||
|
||||
// One hour passes with the validator continuously connected — no Disconnect.
|
||||
now = now.Add(time.Hour)
|
||||
|
||||
pct, err := tracker.CalculateUptimePercent(nodeID, netID)
|
||||
require.NoError(err)
|
||||
require.InDelta(0.0, pct, 0.01)
|
||||
require.InDelta(1.0, pct, 0.001, "continuously-connected validator must climb to ~100%%")
|
||||
require.True(tracker.IsConnected(nodeID))
|
||||
|
||||
// Connect.
|
||||
tracker.Connect(nodeID)
|
||||
|
||||
// Advance clock by 30 minutes.
|
||||
now = now.Add(30 * time.Minute)
|
||||
|
||||
// While connected: should show ~50% (30min connected / 60min+30min total).
|
||||
// Two hours in, still ~100%.
|
||||
now = now.Add(time.Hour)
|
||||
pct, err = tracker.CalculateUptimePercent(nodeID, netID)
|
||||
require.NoError(err)
|
||||
// 30min / 90min = 0.333...
|
||||
require.InDelta(0.333, pct, 0.01)
|
||||
|
||||
// Disconnect.
|
||||
require.NoError(tracker.Disconnect(nodeID))
|
||||
|
||||
// State should now have 30 minutes of uptime persisted.
|
||||
require.Equal(30*time.Minute, state.uptimes[nodeID])
|
||||
|
||||
// After disconnect, no live connection bonus.
|
||||
pct, err = tracker.CalculateUptimePercent(nodeID, netID)
|
||||
require.NoError(err)
|
||||
require.InDelta(0.333, pct, 0.01)
|
||||
require.InDelta(1.0, pct, 0.001)
|
||||
}
|
||||
|
||||
func TestUptimeTrackerDoubleConnect(t *testing.T) {
|
||||
// TestUptimeTrackerConnectDisconnectFlush verifies that, once tracking, a
|
||||
// connected session is flushed into persistent state on Disconnect.
|
||||
func TestUptimeTrackerConnectDisconnectFlush(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
state := newFakeUptimeState()
|
||||
netID := ids.GenerateTestID()
|
||||
nodeID := ids.GenerateTestNodeID()
|
||||
|
||||
now := time.Now()
|
||||
now := time.Now().Truncate(time.Second)
|
||||
clk := func() time.Time { return now }
|
||||
|
||||
state.addValidator(nodeID, netID, now.Add(-time.Hour))
|
||||
startTime := now
|
||||
state.addValidator(nodeID, netID, startTime)
|
||||
|
||||
tracker := newUptimeTracker(state, netID, clk)
|
||||
require.NoError(tracker.StartTracking([]ids.NodeID{nodeID}))
|
||||
require.Equal(time.Duration(0), state.uptimes[nodeID])
|
||||
|
||||
tracker.Connect(nodeID)
|
||||
tracker.Connect(nodeID) // second connect should be no-op
|
||||
now = now.Add(30 * time.Minute)
|
||||
mutated, err := tracker.Disconnect(nodeID)
|
||||
require.NoError(err)
|
||||
require.True(mutated) // a tracked validator's session flush writes uptime
|
||||
|
||||
now = now.Add(10 * time.Minute)
|
||||
require.NoError(tracker.Disconnect(nodeID))
|
||||
require.Equal(30*time.Minute, state.uptimes[nodeID])
|
||||
require.False(tracker.IsConnected(nodeID))
|
||||
|
||||
// Should be exactly 10 minutes, not 20.
|
||||
require.Equal(10*time.Minute, state.uptimes[nodeID])
|
||||
// After disconnect, no further live-session bonus; percent reflects 30m/60m.
|
||||
now = now.Add(30 * time.Minute)
|
||||
pct, err := tracker.CalculateUptimePercent(nodeID, netID)
|
||||
require.NoError(err)
|
||||
require.InDelta(0.5, pct, 0.001)
|
||||
}
|
||||
|
||||
func TestUptimeTrackerShutdown(t *testing.T) {
|
||||
// TestUptimeTrackerDisconnectBeforeTrackingIsNoWrite is the regression test for
|
||||
// the empty-commit elimination (RED round-2 LOW). A peer disconnect during
|
||||
// bootstrap — before StartTracking — must report mutated=false so VM.Disconnected
|
||||
// skips an empty state.Commit (a full-write+fsync). Under bootstrap churn that
|
||||
// commit ran on every disconnect for no reason.
|
||||
func TestUptimeTrackerDisconnectBeforeTrackingIsNoWrite(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
state := newFakeUptimeState()
|
||||
netID := ids.GenerateTestID()
|
||||
nodeA := ids.GenerateTestNodeID()
|
||||
nodeB := ids.GenerateTestNodeID()
|
||||
nodeID := ids.GenerateTestNodeID()
|
||||
|
||||
now := time.Now()
|
||||
now := time.Now().Truncate(time.Second)
|
||||
clk := func() time.Time { return now }
|
||||
|
||||
state.addValidator(nodeA, netID, now.Add(-time.Hour))
|
||||
state.addValidator(nodeB, netID, now.Add(-time.Hour))
|
||||
state.addValidator(nodeID, netID, now)
|
||||
|
||||
tracker := newUptimeTracker(state, netID, clk)
|
||||
// Deliberately NO StartTracking — we are still bootstrapping.
|
||||
|
||||
tracker.Connect(nodeA)
|
||||
tracker.Connect(nodeB)
|
||||
tracker.Connect(nodeID)
|
||||
now = now.Add(30 * time.Minute)
|
||||
|
||||
now = now.Add(5 * time.Minute)
|
||||
require.NoError(tracker.Shutdown())
|
||||
|
||||
require.Equal(5*time.Minute, state.uptimes[nodeA])
|
||||
require.Equal(5*time.Minute, state.uptimes[nodeB])
|
||||
mutated, err := tracker.Disconnect(nodeID)
|
||||
require.NoError(err)
|
||||
require.False(mutated) // no write → VM.Disconnected must skip Commit
|
||||
require.False(tracker.IsConnected(nodeID))
|
||||
require.Equal(time.Duration(0), state.uptimes[nodeID]) // untouched
|
||||
}
|
||||
|
||||
// TestUptimeTrackerDisconnectedValidatorGetsZero verifies that, once tracking, a
|
||||
// validator that never connects earns 0% uptime (it is offline from this node's
|
||||
// perspective) — the property that lets the reward gate withhold rewards.
|
||||
func TestUptimeTrackerDisconnectedValidatorGetsZero(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
state := newFakeUptimeState()
|
||||
netID := ids.GenerateTestID()
|
||||
nodeID := ids.GenerateTestNodeID()
|
||||
|
||||
now := time.Now().Truncate(time.Second)
|
||||
clk := func() time.Time { return now }
|
||||
|
||||
startTime := now
|
||||
state.addValidator(nodeID, netID, startTime)
|
||||
|
||||
tracker := newUptimeTracker(state, netID, clk)
|
||||
require.NoError(tracker.StartTracking([]ids.NodeID{nodeID}))
|
||||
|
||||
now = now.Add(time.Hour) // an hour passes, never connected
|
||||
|
||||
pct, err := tracker.CalculateUptimePercent(nodeID, netID)
|
||||
require.NoError(err)
|
||||
require.InDelta(0.0, pct, 0.001, "never-connected validator (while tracking) must earn 0%%")
|
||||
require.False(tracker.IsConnected(nodeID))
|
||||
}
|
||||
|
||||
// TestUptimeTrackerStopTrackingFlushesAll verifies that StopTracking persists all
|
||||
// connected validators' sessions and leaves tracking mode.
|
||||
func TestUptimeTrackerStopTrackingFlushesAll(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
state := newFakeUptimeState()
|
||||
netID := ids.GenerateTestID()
|
||||
|
||||
now := time.Now().Truncate(time.Second)
|
||||
clk := func() time.Time { return now }
|
||||
|
||||
const numValidators = 10
|
||||
nodeIDs := make([]ids.NodeID, numValidators)
|
||||
for i := range nodeIDs {
|
||||
nodeIDs[i] = ids.GenerateTestNodeID()
|
||||
state.addValidator(nodeIDs[i], netID, now)
|
||||
}
|
||||
|
||||
tracker := newUptimeTracker(state, netID, clk)
|
||||
require.NoError(tracker.StartTracking(nodeIDs))
|
||||
for _, nid := range nodeIDs {
|
||||
tracker.Connect(nid)
|
||||
}
|
||||
|
||||
now = now.Add(3 * time.Minute)
|
||||
require.NoError(tracker.StopTracking(nodeIDs))
|
||||
require.False(tracker.StartedTracking())
|
||||
|
||||
for _, nid := range nodeIDs {
|
||||
require.Equal(3*time.Minute, state.uptimes[nid])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUptimeTrackerDoubleStartTracking verifies StartTracking is not re-entrant.
|
||||
func TestUptimeTrackerDoubleStartTracking(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
state := newFakeUptimeState()
|
||||
netID := ids.GenerateTestID()
|
||||
nodeID := ids.GenerateTestNodeID()
|
||||
now := time.Now().Truncate(time.Second)
|
||||
|
||||
state.addValidator(nodeID, netID, now)
|
||||
tracker := newUptimeTracker(state, netID, func() time.Time { return now })
|
||||
|
||||
require.NoError(tracker.StartTracking([]ids.NodeID{nodeID}))
|
||||
require.ErrorIs(tracker.StartTracking([]ids.NodeID{nodeID}), errAlreadyStartedTracking)
|
||||
}
|
||||
|
||||
// TestUptimeTrackerStopWithoutStart verifies StopTracking errors before start.
|
||||
func TestUptimeTrackerStopWithoutStart(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
state := newFakeUptimeState()
|
||||
netID := ids.GenerateTestID()
|
||||
now := time.Now().Truncate(time.Second)
|
||||
tracker := newUptimeTracker(state, netID, func() time.Time { return now })
|
||||
|
||||
require.ErrorIs(tracker.StopTracking(nil), errNotStartedTracking)
|
||||
}
|
||||
|
||||
// TestUptimeTrackerUnknownValidator verifies non-validators are handled safely:
|
||||
// StartTracking/Connect/Disconnect skip them without error, and a percent query
|
||||
// surfaces the not-found error.
|
||||
func TestUptimeTrackerUnknownValidator(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
@@ -164,21 +327,28 @@ func TestUptimeTrackerUnknownValidator(t *testing.T) {
|
||||
netID := ids.GenerateTestID()
|
||||
unknownNode := ids.GenerateTestNodeID()
|
||||
|
||||
now := time.Now()
|
||||
now := time.Now().Truncate(time.Second)
|
||||
clk := func() time.Time { return now }
|
||||
|
||||
tracker := newUptimeTracker(state, netID, clk)
|
||||
|
||||
// Connect an unknown validator. Disconnect should not error.
|
||||
// StartTracking must not fail on a node that has no state record.
|
||||
require.NoError(tracker.StartTracking([]ids.NodeID{unknownNode}))
|
||||
|
||||
// Connecting then disconnecting an unknown node is a no-op, not an error, and
|
||||
// must NOT report a state mutation (no uptime record to write).
|
||||
tracker.Connect(unknownNode)
|
||||
now = now.Add(time.Minute)
|
||||
require.NoError(tracker.Disconnect(unknownNode))
|
||||
mutated, err := tracker.Disconnect(unknownNode)
|
||||
require.NoError(err)
|
||||
require.False(mutated)
|
||||
|
||||
// CalculateUptimePercent for unknown validator should error.
|
||||
_, err := tracker.CalculateUptimePercent(unknownNode, netID)
|
||||
// A percent query for an unknown validator surfaces the error.
|
||||
_, err = tracker.CalculateUptimePercent(unknownNode, netID)
|
||||
require.Error(err)
|
||||
}
|
||||
|
||||
// TestUptimeTrackerWrongNet verifies a query for a different network returns 0.
|
||||
func TestUptimeTrackerWrongNet(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
@@ -187,41 +357,52 @@ func TestUptimeTrackerWrongNet(t *testing.T) {
|
||||
otherNet := ids.GenerateTestID()
|
||||
nodeID := ids.GenerateTestNodeID()
|
||||
|
||||
now := time.Now()
|
||||
now := time.Now().Truncate(time.Second)
|
||||
clk := func() time.Time { return now }
|
||||
|
||||
state.addValidator(nodeID, netID, now.Add(-time.Hour))
|
||||
tracker := newUptimeTracker(state, netID, clk)
|
||||
|
||||
// Querying the wrong net should return 0.
|
||||
pct, err := tracker.CalculateUptimePercent(nodeID, otherNet)
|
||||
require.NoError(err)
|
||||
require.Equal(0.0, pct)
|
||||
|
||||
up, total, err := tracker.CalculateUptime(nodeID, otherNet)
|
||||
require.NoError(err)
|
||||
require.Equal(time.Duration(0), up)
|
||||
require.Equal(time.Duration(0), total)
|
||||
}
|
||||
|
||||
func TestUptimeTrackerDisconnectWithoutConnect(t *testing.T) {
|
||||
// TestUptimeTrackerDoubleConnect verifies a duplicate Connect keeps the original
|
||||
// connection time (repeated router dispatch cannot inflate uptime).
|
||||
func TestUptimeTrackerDoubleConnect(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
state := newFakeUptimeState()
|
||||
netID := ids.GenerateTestID()
|
||||
nodeID := ids.GenerateTestNodeID()
|
||||
|
||||
now := time.Now()
|
||||
now := time.Now().Truncate(time.Second)
|
||||
clk := func() time.Time { return now }
|
||||
|
||||
state.addValidator(nodeID, netID, now.Add(-time.Hour))
|
||||
state.addValidator(nodeID, netID, now)
|
||||
tracker := newUptimeTracker(state, netID, clk)
|
||||
require.NoError(tracker.StartTracking([]ids.NodeID{nodeID}))
|
||||
|
||||
// Disconnect without prior connect should be no-op.
|
||||
require.NoError(tracker.Disconnect(nodeID))
|
||||
require.Equal(time.Duration(0), state.uptimes[nodeID])
|
||||
tracker.Connect(nodeID)
|
||||
now = now.Add(5 * time.Minute)
|
||||
tracker.Connect(nodeID) // duplicate — must NOT reset the 5-minute-old session
|
||||
now = now.Add(5 * time.Minute)
|
||||
mutated, err := tracker.Disconnect(nodeID)
|
||||
require.NoError(err)
|
||||
require.True(mutated)
|
||||
|
||||
// Ten minutes total, not five.
|
||||
require.Equal(10*time.Minute, state.uptimes[nodeID])
|
||||
}
|
||||
|
||||
// --- Additional edge-case and inversion tests ---
|
||||
|
||||
// TestUptimeTrackerRapidConnectDisconnect verifies that rapid Connect/Disconnect
|
||||
// cycling does not accumulate phantom uptime. Each cycle should only account
|
||||
// for the exact clock delta during that connection.
|
||||
// TestUptimeTrackerRapidConnectDisconnect verifies rapid cycling never fabricates
|
||||
// uptime beyond the exact connected intervals.
|
||||
func TestUptimeTrackerRapidConnectDisconnect(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
@@ -229,179 +410,51 @@ func TestUptimeTrackerRapidConnectDisconnect(t *testing.T) {
|
||||
netID := ids.GenerateTestID()
|
||||
nodeID := ids.GenerateTestNodeID()
|
||||
|
||||
now := time.Now()
|
||||
now := time.Now().Truncate(time.Second)
|
||||
clk := func() time.Time { return now }
|
||||
|
||||
state.addValidator(nodeID, netID, now.Add(-time.Hour))
|
||||
state.addValidator(nodeID, netID, now)
|
||||
tracker := newUptimeTracker(state, netID, clk)
|
||||
require.NoError(tracker.StartTracking([]ids.NodeID{nodeID}))
|
||||
|
||||
// Rapid connect/disconnect 100 times with no clock advance.
|
||||
// 100 cycles with no clock advance — zero accrual.
|
||||
for i := 0; i < 100; i++ {
|
||||
tracker.Connect(nodeID)
|
||||
require.NoError(tracker.Disconnect(nodeID))
|
||||
_, err := tracker.Disconnect(nodeID)
|
||||
require.NoError(err)
|
||||
}
|
||||
|
||||
// No time passed, so uptime should be 0.
|
||||
require.Equal(time.Duration(0), state.uptimes[nodeID])
|
||||
|
||||
// Now do 50 cycles with 1ms advance each.
|
||||
// 50 cycles, each holding the connection for one second.
|
||||
for i := 0; i < 50; i++ {
|
||||
tracker.Connect(nodeID)
|
||||
now = now.Add(1 * time.Millisecond)
|
||||
require.NoError(tracker.Disconnect(nodeID))
|
||||
now = now.Add(time.Second)
|
||||
_, err := tracker.Disconnect(nodeID)
|
||||
require.NoError(err)
|
||||
}
|
||||
|
||||
// Should be exactly 50ms of uptime.
|
||||
require.Equal(50*time.Millisecond, state.uptimes[nodeID])
|
||||
require.Equal(50*time.Second, state.uptimes[nodeID])
|
||||
}
|
||||
|
||||
// TestUptimeTrackerShutdownFlushesAll verifies that Shutdown flushes all
|
||||
// currently connected validators and leaves the connected map empty.
|
||||
func TestUptimeTrackerShutdownFlushesAll(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
state := newFakeUptimeState()
|
||||
netID := ids.GenerateTestID()
|
||||
|
||||
now := time.Now()
|
||||
clk := func() time.Time { return now }
|
||||
|
||||
const numValidators = 10
|
||||
nodeIDs := make([]ids.NodeID, numValidators)
|
||||
for i := range nodeIDs {
|
||||
nodeIDs[i] = ids.GenerateTestNodeID()
|
||||
state.addValidator(nodeIDs[i], netID, now.Add(-time.Hour))
|
||||
}
|
||||
|
||||
tracker := newUptimeTracker(state, netID, clk)
|
||||
|
||||
// Connect all
|
||||
for _, nid := range nodeIDs {
|
||||
tracker.Connect(nid)
|
||||
}
|
||||
|
||||
now = now.Add(3 * time.Minute)
|
||||
require.NoError(tracker.Shutdown())
|
||||
|
||||
// All should have 3 minutes of uptime
|
||||
for _, nid := range nodeIDs {
|
||||
require.Equal(3*time.Minute, state.uptimes[nid])
|
||||
}
|
||||
|
||||
// Connected map should be empty after shutdown
|
||||
tracker.mu.RLock()
|
||||
require.Len(tracker.connected, 0)
|
||||
tracker.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestUptimeTrackerNeverConnected verifies that a validator that was never
|
||||
// connected has 0% uptime.
|
||||
func TestUptimeTrackerNeverConnected(t *testing.T) {
|
||||
// TestUptimeTrackerConcurrent races Connect/Disconnect/reads and StartTracking to
|
||||
// assert there are no data races (run with -race).
|
||||
func TestUptimeTrackerConcurrent(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
state := newFakeUptimeState()
|
||||
netID := ids.GenerateTestID()
|
||||
nodeID := ids.GenerateTestNodeID()
|
||||
|
||||
now := time.Now()
|
||||
clk := func() time.Time { return now }
|
||||
|
||||
// Validator has been registered for 1 hour but never connected
|
||||
state.addValidator(nodeID, netID, now.Add(-time.Hour))
|
||||
tracker := newUptimeTracker(state, netID, clk)
|
||||
|
||||
pct, err := tracker.CalculateUptimePercent(nodeID, netID)
|
||||
require.NoError(err)
|
||||
require.Equal(0.0, pct, "never-connected validator must have 0%% uptime")
|
||||
}
|
||||
|
||||
// TestUptimeTrackerAlwaysConnected verifies that a validator connected for
|
||||
// the entire tracking period reports 100% uptime.
|
||||
func TestUptimeTrackerAlwaysConnected(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
state := newFakeUptimeState()
|
||||
netID := ids.GenerateTestID()
|
||||
nodeID := ids.GenerateTestNodeID()
|
||||
|
||||
now := time.Now()
|
||||
clk := func() time.Time { return now }
|
||||
|
||||
startTime := now
|
||||
state.addValidator(nodeID, netID, startTime)
|
||||
tracker := newUptimeTracker(state, netID, clk)
|
||||
|
||||
// Connect immediately at start
|
||||
tracker.Connect(nodeID)
|
||||
|
||||
// Advance clock by 1 hour
|
||||
now = now.Add(time.Hour)
|
||||
|
||||
pct, err := tracker.CalculateUptimePercent(nodeID, netID)
|
||||
require.NoError(err)
|
||||
require.InDelta(1.0, pct, 0.001, "always-connected validator must have ~100%% uptime")
|
||||
}
|
||||
|
||||
// TestUptimeTrackerConcurrentConnect verifies that concurrent Connect calls
|
||||
// from multiple goroutines do not cause data races or phantom uptime.
|
||||
func TestUptimeTrackerConcurrentConnect(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
state := newFakeUptimeState()
|
||||
netID := ids.GenerateTestID()
|
||||
nodeID := ids.GenerateTestNodeID()
|
||||
|
||||
now := time.Now()
|
||||
clk := func() time.Time { return now }
|
||||
|
||||
state.addValidator(nodeID, netID, now.Add(-time.Hour))
|
||||
tracker := newUptimeTracker(state, netID, clk)
|
||||
|
||||
// Race 50 goroutines calling Connect on the same node.
|
||||
const goroutines = 50
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
tracker.Connect(nodeID)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Should have exactly one entry in connected map.
|
||||
tracker.mu.RLock()
|
||||
_, exists := tracker.connected[nodeID]
|
||||
require.True(exists)
|
||||
tracker.mu.RUnlock()
|
||||
|
||||
// Advance clock and disconnect
|
||||
now = now.Add(5 * time.Minute)
|
||||
require.NoError(tracker.Disconnect(nodeID))
|
||||
|
||||
// Uptime should be exactly 5 minutes, not 5*50 minutes.
|
||||
require.Equal(5*time.Minute, state.uptimes[nodeID])
|
||||
}
|
||||
|
||||
// TestUptimeTrackerConcurrentConnectDisconnect races Connect and Disconnect
|
||||
// from multiple goroutines on the same node.
|
||||
func TestUptimeTrackerConcurrentConnectDisconnect(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
state := newFakeUptimeState()
|
||||
netID := ids.GenerateTestID()
|
||||
nodeID := ids.GenerateTestNodeID()
|
||||
|
||||
now := time.Now()
|
||||
mu := sync.Mutex{}
|
||||
var clkMu sync.Mutex
|
||||
now := time.Now().Truncate(time.Second)
|
||||
clk := func() time.Time {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
clkMu.Lock()
|
||||
defer clkMu.Unlock()
|
||||
return now
|
||||
}
|
||||
|
||||
state.addValidator(nodeID, netID, now.Add(-time.Hour))
|
||||
state.addValidator(nodeID, netID, now)
|
||||
tracker := newUptimeTracker(state, netID, clk)
|
||||
require.NoError(tracker.StartTracking([]ids.NodeID{nodeID}))
|
||||
|
||||
const goroutines = 100
|
||||
var wg sync.WaitGroup
|
||||
@@ -409,46 +462,22 @@ func TestUptimeTrackerConcurrentConnectDisconnect(t *testing.T) {
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
if idx%2 == 0 {
|
||||
switch idx % 4 {
|
||||
case 0:
|
||||
tracker.Connect(nodeID)
|
||||
} else {
|
||||
_ = tracker.Disconnect(nodeID)
|
||||
case 1:
|
||||
_, _ = tracker.Disconnect(nodeID)
|
||||
case 2:
|
||||
_, _ = tracker.CalculateUptimePercent(nodeID, netID)
|
||||
case 3:
|
||||
_ = tracker.IsConnected(nodeID)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Should not panic. Final state depends on ordering but must be consistent.
|
||||
// Clean up: ensure we can still shutdown.
|
||||
mu.Lock()
|
||||
clkMu.Lock()
|
||||
now = now.Add(time.Minute)
|
||||
mu.Unlock()
|
||||
require.NoError(tracker.Shutdown())
|
||||
}
|
||||
|
||||
func TestUptimeTrackerCalculateUptimePercentFrom(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
state := newFakeUptimeState()
|
||||
netID := ids.GenerateTestID()
|
||||
nodeID := ids.GenerateTestNodeID()
|
||||
|
||||
now := time.Now()
|
||||
clk := func() time.Time { return now }
|
||||
|
||||
startTime := now.Add(-2 * time.Hour)
|
||||
state.addValidator(nodeID, netID, startTime)
|
||||
tracker := newUptimeTracker(state, netID, clk)
|
||||
|
||||
// Connect for 1 hour.
|
||||
tracker.Connect(nodeID)
|
||||
now = now.Add(time.Hour)
|
||||
require.NoError(tracker.Disconnect(nodeID))
|
||||
|
||||
// Total time is 3 hours (2h before + 1h connected).
|
||||
// Connected for 1 hour. Rate = 1/3.
|
||||
// From startTime: same rate applies.
|
||||
pct, err := tracker.CalculateUptimePercentFrom(nodeID, netID, startTime)
|
||||
require.NoError(err)
|
||||
require.InDelta(0.333, pct, 0.01)
|
||||
clkMu.Unlock()
|
||||
require.NoError(tracker.StopTracking([]ids.NodeID{nodeID}))
|
||||
}
|
||||
|
||||
+118
-22
@@ -100,6 +100,23 @@ type VM struct {
|
||||
chainID ids.ID
|
||||
state state.State
|
||||
|
||||
// stateLock serializes every commit of the shared platform state (state.write)
|
||||
// that originates OUTSIDE the consensus engine's serialized accept path:
|
||||
// - a block DECISION (block/executor Block.Accept/Reject → state.CommitBatch);
|
||||
// the executor manager holds THIS lock (passed as &vm.stateLock),
|
||||
// - a peer Disconnect (tracker.Disconnect → state.SetUptime, then state.Commit),
|
||||
// - normal-ops Start/StopTracking uptime flushes (state.SetUptime + state.Commit).
|
||||
// The engine invokes VM.Accept as a lock-free call-out (its t.mu is released
|
||||
// before the call-out per its lock discipline), and peer connect/disconnect run
|
||||
// on the node's peer-lifecycle goroutine, so nothing else serializes these
|
||||
// writers against one another. Without this lock a disconnect's state.Commit
|
||||
// races an accept's state.CommitBatch inside state.write() → Go "concurrent map
|
||||
// writes" fatal. This is the avalanchego ctx.Lock invariant (accept serialized
|
||||
// with engine.Connected/Disconnected), scoped to the state the platform VM owns.
|
||||
// It is DISTINCT from vm.lock (which guards API/service reads): a separate lock
|
||||
// keeps the accept path off the API lock and avoids any ordering coupling.
|
||||
stateLock sync.Mutex
|
||||
|
||||
fx fx.Fx
|
||||
|
||||
// Bootstrapped remembers if this chain has finished bootstrapping or not
|
||||
@@ -260,9 +277,17 @@ func (vm *VM) Initialize(
|
||||
validatorManager := pvalidators.NewManager(vm.Internal, vm.state, vm.metrics, &vm.nodeClock)
|
||||
vm.State = validatorManager
|
||||
utxoHandler := utxo.NewHandler(context.Background(), &vm.nodeClock, vm.fx)
|
||||
// Create uptime manager - use the configured UptimeLockedCalculator which
|
||||
// delegates to its fallback calculator (NoOp by default, but tests can
|
||||
// configure ZeroUptimeCalculator for "never connected" scenarios)
|
||||
|
||||
// Create the real uptime tracker for the primary network NOW, at Initialize,
|
||||
// so peer Connect events delivered during bootstrap are captured — the
|
||||
// connected map must already be populated by the time StartTracking runs at
|
||||
// normal-operations start. Register it with the thread-safe LockedCalculator,
|
||||
// through which both the API (service.go getCurrentValidators) and the reward
|
||||
// gate (block/executor/options.go prefersCommit) read uptime.
|
||||
vm.tracker = newUptimeTracker(vm.state, constants.PrimaryNetworkID, vm.nodeClock.Time)
|
||||
if err := vm.UptimeLockedCalculator.SetCalculator(constants.PrimaryNetworkID, vm.tracker); err != nil {
|
||||
return fmt.Errorf("failed to register uptime tracker: %w", err)
|
||||
}
|
||||
vm.uptimeManager = vm.UptimeLockedCalculator
|
||||
|
||||
txExecutorBackend := &txexecutor.Backend{
|
||||
@@ -294,6 +319,7 @@ func (vm *VM) Initialize(
|
||||
vm.state,
|
||||
txExecutorBackend,
|
||||
validatorManager,
|
||||
&vm.stateLock,
|
||||
)
|
||||
|
||||
txVerifier := network.NewLockedTxVerifier(&vm.lock, vm.manager)
|
||||
@@ -531,6 +557,25 @@ func (vm *VM) createNet(netID ids.ID) error {
|
||||
func (vm *VM) onBootstrapStarted() error {
|
||||
vm.bootstrapped.Set(false)
|
||||
vm.bootstrappedConsensus.Set(false)
|
||||
|
||||
// On a normal-ops → re-bootstrap transition, flush and stop uptime tracking
|
||||
// so connected sessions are persisted before we stop measuring. This is a
|
||||
// no-op on the first bootstrap (tracking hasn't started yet). StopTracking
|
||||
// flushes uptime into shared state (state.SetUptime → state.write), so hold
|
||||
// stateLock to serialize it with any block accept still in flight.
|
||||
if err := func() error {
|
||||
vm.stateLock.Lock()
|
||||
defer vm.stateLock.Unlock()
|
||||
if vm.tracker != nil && vm.tracker.StartedTracking() {
|
||||
primaryVdrIDs := vm.Validators.GetValidatorIDs(constants.PrimaryNetworkID)
|
||||
if err := vm.tracker.StopTracking(primaryVdrIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
return vm.fx.Bootstrapping()
|
||||
}
|
||||
|
||||
@@ -546,16 +591,32 @@ func (vm *VM) onReady() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create and register the real uptime tracker for the primary network.
|
||||
vm.tracker = newUptimeTracker(vm.state, constants.PrimaryNetworkID, vm.nodeClock.Time)
|
||||
if err := vm.UptimeLockedCalculator.SetCalculator(constants.PrimaryNetworkID, vm.tracker); err != nil {
|
||||
return err
|
||||
}
|
||||
vm.log.Info("uptime tracker registered for primary network")
|
||||
// Begin tracking validator uptime for the primary network. The tracker was
|
||||
// created and registered at Initialize (so bootstrap-time peer connections
|
||||
// were already captured); StartTracking baselines every current validator's
|
||||
// uptime record and switches the tracker into live-tracking mode. Mirrors
|
||||
// avalanchego's onNormalOperationsStarted.
|
||||
//
|
||||
// StartTracking (state.SetUptime) and the trailing state.Commit both write
|
||||
// shared state, so hold stateLock across them to serialize with any block
|
||||
// accept (Block.Accept holds the same lock) — the same guard as the peer
|
||||
// Disconnect path.
|
||||
if err := func() error {
|
||||
vm.stateLock.Lock()
|
||||
defer vm.stateLock.Unlock()
|
||||
if vm.tracker != nil && !vm.tracker.StartedTracking() {
|
||||
primaryVdrIDs := vm.Validators.GetValidatorIDs(constants.PrimaryNetworkID)
|
||||
if err := vm.tracker.StartTracking(primaryVdrIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
vm.log.Info("uptime tracking started for primary network",
|
||||
log.Int("validators", len(primaryVdrIDs)))
|
||||
}
|
||||
|
||||
// Commit state BEFORE starting background goroutines to avoid race conditions
|
||||
// between state readers (forwardNotifications) and state writers (Commit)
|
||||
if err := vm.state.Commit(); err != nil {
|
||||
// Commit state BEFORE starting background goroutines to avoid race conditions
|
||||
// between state readers (forwardNotifications) and state writers (Commit)
|
||||
return vm.state.Commit()
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -592,13 +653,25 @@ func (vm *VM) Shutdown(context.Context) error {
|
||||
|
||||
vm.onShutdownCtxCancel()
|
||||
|
||||
if vm.tracker != nil {
|
||||
if err := vm.tracker.Shutdown(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vm.state.Commit(); err != nil {
|
||||
return err
|
||||
// Flush uptime for all primary-network validators before closing state, so
|
||||
// connected sessions are durably persisted across the restart. StopTracking
|
||||
// (state.SetUptime) + state.Commit write shared state, so hold stateLock to
|
||||
// serialize with any block accept still draining as the chain stops.
|
||||
if err := func() error {
|
||||
vm.stateLock.Lock()
|
||||
defer vm.stateLock.Unlock()
|
||||
if vm.tracker != nil && vm.tracker.StartedTracking() {
|
||||
primaryVdrIDs := vm.Validators.GetValidatorIDs(constants.PrimaryNetworkID)
|
||||
if err := vm.tracker.StopTracking(primaryVdrIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vm.state.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var errs []error
|
||||
@@ -762,14 +835,37 @@ func (vm *VM) Connected(ctx context.Context, nodeID ids.NodeID, nodeVersion *cha
|
||||
}
|
||||
|
||||
func (vm *VM) Disconnected(ctx context.Context, nodeID ids.NodeID) error {
|
||||
// This runs on the node's peer-lifecycle goroutine, which the consensus
|
||||
// engine does NOT serialize against block accept. tracker.Disconnect flushes
|
||||
// the peer's uptime into shared state (state.SetUptime) and state.Commit
|
||||
// persists the whole diff (state.write). Hold stateLock so both are atomic
|
||||
// w.r.t. a concurrent block accept (Block.Accept holds the same lock);
|
||||
// otherwise state.write races the acceptor's state.write → concurrent map
|
||||
// writes fatal. The p2p Network.Disconnected below touches only the p2p peer
|
||||
// set (its own lock), so it stays OUTSIDE stateLock to keep the critical
|
||||
// section to the state commit.
|
||||
//
|
||||
// Commit ONLY when the flush actually wrote uptime state. Before StartTracking
|
||||
// (bootstrap churn) and for non-validator peers, Disconnect is a no-op, and an
|
||||
// unconditional Commit would be an empty full-write+fsync on every such
|
||||
// disconnect. A no-write disconnect leaves the state clean, so skipping Commit
|
||||
// is correct — every writer under stateLock (block accept, Start/StopTracking)
|
||||
// commits its own diff, so there is never an orphaned write relying on us.
|
||||
vm.stateLock.Lock()
|
||||
if vm.tracker != nil {
|
||||
if err := vm.tracker.Disconnect(nodeID); err != nil {
|
||||
mutated, err := vm.tracker.Disconnect(nodeID)
|
||||
if err != nil {
|
||||
vm.stateLock.Unlock()
|
||||
return err
|
||||
}
|
||||
if mutated {
|
||||
if err := vm.state.Commit(); err != nil {
|
||||
vm.stateLock.Unlock()
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := vm.state.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
vm.stateLock.Unlock()
|
||||
return vm.Network.Disconnected(ctx, nodeID)
|
||||
}
|
||||
|
||||
|
||||
@@ -250,7 +250,7 @@ func (p *postForkCommonComponents) Verify(
|
||||
// It is a pure function of (parentTimestamp, now) so it is idempotent for any two calls in the
|
||||
// same slot — the property the liveness fix relies on.
|
||||
func slotSnappedChildTimestamp(parentTimestamp, now time.Time) time.Time {
|
||||
ts := now.Truncate(time.Second)
|
||||
ts := now.Truncate(proposer.TimestampGranularity())
|
||||
if ts.Before(parentTimestamp) {
|
||||
return parentTimestamp
|
||||
}
|
||||
@@ -301,6 +301,12 @@ func (p *postForkCommonComponents) buildChild(
|
||||
_ = pChainHeight
|
||||
contextPChainHeight := epoch.PChainHeight
|
||||
|
||||
// The inner VM builds on ITS OWN head, and Verify above requires the child's inner
|
||||
// parent to be exactly p.innerBlk. Anchor the two together before delegating.
|
||||
if err := p.vm.anchorInnerBuildParent(ctx, parentID, p.innerBlk.ID()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var innerBlock chain.Block
|
||||
if p.vm.blockBuilderVM != nil {
|
||||
builtBlock, err := p.vm.blockBuilderVM.BuildBlockWithRuntime(ctx, &runtime.Runtime{
|
||||
|
||||
@@ -144,7 +144,7 @@ func (b *statelessBlock) initialize(bytes []byte) error {
|
||||
}
|
||||
|
||||
root := b.msg.Root()
|
||||
b.timestamp = time.Unix(root.Int64(offTimestamp), 0)
|
||||
b.timestamp = time.UnixMilli(root.Int64(offTimestamp))
|
||||
|
||||
// Proposer-identity slot: [scheme:1B | identity]. Empty ⇒ unsigned block.
|
||||
idSlot := root.Bytes(offCert)
|
||||
|
||||
@@ -31,7 +31,7 @@ func BuildUnsigned(
|
||||
// No identity, no signature: the block bytes are just the unsigned body. This
|
||||
// is the pre-fork→post-fork transition block and every K=1 block (no proposer
|
||||
// window), so small local nets never exercise a signed path.
|
||||
unsignedBytes := buildUnsignedBuffer(parentID, timestamp.Unix(), pChainHeight, epoch, nil, blockBytes)
|
||||
unsignedBytes := buildUnsignedBuffer(parentID, timestamp.UnixMilli(), pChainHeight, epoch, nil, blockBytes)
|
||||
|
||||
block := &statelessBlock{}
|
||||
if err := block.initialize(unsignedBytes); err != nil {
|
||||
@@ -103,7 +103,7 @@ func buildSigned(
|
||||
idSlot = append(idSlot, scheme)
|
||||
idSlot = append(idSlot, identity...)
|
||||
|
||||
unsignedBytes := buildUnsignedBuffer(parentID, timestamp.Unix(), pChainHeight, epoch, idSlot, blockBytes)
|
||||
unsignedBytes := buildUnsignedBuffer(parentID, timestamp.UnixMilli(), pChainHeight, epoch, idSlot, blockBytes)
|
||||
|
||||
// The block ID is the hash of the unsigned prefix; the proposer signs a
|
||||
// header binding (chainID, parentID, blockID).
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// build_inner_parent_test.go — the deterministic repro of the SELF-REJECTION LOOP
|
||||
// measured on devnet 96367 and testnet 96368 on 2026-07-28:
|
||||
//
|
||||
// info built block blkID=2srokbs… height=1047 innerBlkID=1DMGHAoo… pChainHeight=0
|
||||
// error built block failed verification — dropping
|
||||
// error="inner parentID didn't match expected parent" height=1047 parentID=2V6mi4tX…
|
||||
//
|
||||
// 83–456 drops/min per node, forever, with the externally visible tip frozen two heights
|
||||
// BELOW what the builder kept proposing. Every node rejected the block it had just built.
|
||||
//
|
||||
// The invariant being violated is postForkCommonComponents.Verify's third check
|
||||
// (block.go, errInnerParentMismatch): a child's inner parent MUST be the inner block
|
||||
// wrapped by the outer parent it extends. buildChild did not establish it — it asked the
|
||||
// inner VM to build, and the inner VM builds on ITS OWN head. These tests model the inner
|
||||
// VM with the luxfi/evm head semantics that make the two pointers diverge, and assert the
|
||||
// invariant on the block buildChild actually returns.
|
||||
package proposervm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/database/versiondb"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
validatorstest "github.com/luxfi/validators/validatorstest"
|
||||
vmchain "github.com/luxfi/vm/chain"
|
||||
"github.com/luxfi/vm/chain/blocktest"
|
||||
|
||||
"github.com/luxfi/node/vms/proposervm/block"
|
||||
"github.com/luxfi/node/vms/proposervm/proposer"
|
||||
"github.com/luxfi/node/vms/proposervm/state"
|
||||
)
|
||||
|
||||
// evmLikeInner models the inner execution VM's HEAD, which is the only part of the inner
|
||||
// VM this defect involves. Three behaviours are copied from luxfi/evm:
|
||||
//
|
||||
// - BuildBlock builds on the head (miner/worker.go: parent = chain.CurrentBlock())
|
||||
// - verifyGossiped advances the head (core/blockchain.go writeBlockAndSetHead →
|
||||
// newTip(block) → writeCanonicalBlockWithLogs → writeHeadBlock)
|
||||
// - SetPreference reorgs the head, and is a no-op when it already matches
|
||||
// (core/blockchain.go setPreference: early return on current.Hash() == block.Hash(),
|
||||
// otherwise writeKnownBlock)
|
||||
//
|
||||
// Nothing else is stubbed: the test drives the REAL postForkBlock.buildChild.
|
||||
type evmLikeInner struct {
|
||||
vmchain.ChainVM
|
||||
blocks map[ids.ID]*blocktest.Block // every block inserted into the inner chain
|
||||
head ids.ID
|
||||
setPrefCalls int
|
||||
reorgs int
|
||||
}
|
||||
|
||||
func newEVMLikeInner(genesis *blocktest.Block) *evmLikeInner {
|
||||
return &evmLikeInner{
|
||||
blocks: map[ids.ID]*blocktest.Block{genesis.IDV: genesis},
|
||||
head: genesis.IDV,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *evmLikeInner) insert(b *blocktest.Block) { c.blocks[b.IDV] = b }
|
||||
|
||||
// child mints a block extending [parent] without touching the head.
|
||||
func (c *evmLikeInner) child(parent *blocktest.Block) *blocktest.Block {
|
||||
id := ids.GenerateTestID()
|
||||
b := &blocktest.Block{
|
||||
IDV: id,
|
||||
ParentV: parent.IDV,
|
||||
HeightV: parent.HeightV + 1,
|
||||
BytesV: id[:],
|
||||
TimestampV: parent.TimestampV.Add(time.Second),
|
||||
}
|
||||
c.insert(b)
|
||||
return b
|
||||
}
|
||||
|
||||
// verifyGossiped is the drift mechanism: verifying a peer's block whose parent is the
|
||||
// current head OPTIMISTICALLY makes it the head — no accept, no proposervm involvement.
|
||||
func (c *evmLikeInner) verifyGossiped(b *blocktest.Block) {
|
||||
c.insert(b)
|
||||
if b.ParentV == c.head { // core/blockchain.go newTip
|
||||
c.head = b.IDV
|
||||
}
|
||||
}
|
||||
|
||||
func (c *evmLikeInner) BuildBlock(context.Context) (vmchain.Block, error) {
|
||||
return c.child(c.blocks[c.head]), nil
|
||||
}
|
||||
|
||||
func (c *evmLikeInner) SetPreference(_ context.Context, id ids.ID) error {
|
||||
c.setPrefCalls++
|
||||
blk, ok := c.blocks[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("evm-like inner VM: block %s not in chain", id)
|
||||
}
|
||||
if c.head == blk.IDV { // setPreference early return — already the head
|
||||
return nil
|
||||
}
|
||||
c.head = blk.IDV
|
||||
c.reorgs++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *evmLikeInner) GetBlock(_ context.Context, id ids.ID) (vmchain.Block, error) {
|
||||
blk, ok := c.blocks[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("evm-like inner VM: block %s not found", id)
|
||||
}
|
||||
return blk, nil
|
||||
}
|
||||
|
||||
// anyoneCanProposeWindower makes every slot open, so buildChild takes the UNSIGNED path
|
||||
// and needs no staking key. The proposer election is orthogonal to the parent invariant.
|
||||
type anyoneCanProposeWindower struct{}
|
||||
|
||||
func (anyoneCanProposeWindower) Proposers(context.Context, uint64, uint64, int) ([]ids.NodeID, error) {
|
||||
return nil, proposer.ErrAnyoneCanPropose
|
||||
}
|
||||
|
||||
func (anyoneCanProposeWindower) Delay(context.Context, uint64, uint64, ids.NodeID, int) (time.Duration, error) {
|
||||
return 0, proposer.ErrAnyoneCanPropose
|
||||
}
|
||||
|
||||
func (anyoneCanProposeWindower) ExpectedProposer(context.Context, uint64, uint64, uint64) (ids.NodeID, error) {
|
||||
return ids.EmptyNodeID, proposer.ErrAnyoneCanPropose
|
||||
}
|
||||
|
||||
func (anyoneCanProposeWindower) MinDelayForProposer(context.Context, uint64, uint64, ids.NodeID, uint64) (time.Duration, error) {
|
||||
return 0, proposer.ErrAnyoneCanPropose
|
||||
}
|
||||
|
||||
// newBuildParentTestVM returns a proposervm whose outer PREFERRED envelope wraps
|
||||
// innerParent, plus the inner chain, at the moment before a build attempt.
|
||||
func newBuildParentTestVM(t *testing.T, innerParent *blocktest.Block, inner *evmLikeInner) (*VM, *postForkBlock) {
|
||||
t.Helper()
|
||||
|
||||
vm := &VM{
|
||||
verifiedBlocks: map[ids.ID]PostForkBlock{},
|
||||
logger: log.Noop(),
|
||||
}
|
||||
vm.State = state.New(versiondb.New(memdb.New()))
|
||||
vm.ChainVM = inner
|
||||
vm.Windower = anyoneCanProposeWindower{}
|
||||
vm.validatorState = &validatorstest.State{
|
||||
GetCurrentHeightF: func(context.Context) (uint64, error) { return 0, nil },
|
||||
}
|
||||
// Deterministic clock, one second after the parent envelope: slot 0, so the child
|
||||
// timestamp is not window-snapped and the parent check is the only thing under test.
|
||||
parentTimestamp := innerParent.TimestampV
|
||||
vm.Clock.Set(parentTimestamp.Add(time.Second))
|
||||
|
||||
statelessParent, err := block.BuildUnsigned(
|
||||
ids.GenerateTestID(), // outer grandparent
|
||||
parentTimestamp,
|
||||
0, // pChainHeight — 0 fleet-wide, mainnet included; orthogonal to this defect
|
||||
block.Epoch{},
|
||||
innerParent.BytesV,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return vm, &postForkBlock{
|
||||
SignedBlock: statelessParent,
|
||||
postForkCommonComponents: postForkCommonComponents{vm: vm, innerBlk: innerParent},
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildChild_InnerHeadDriftedByGossipVerify_ChildExtendsOuterParentsInner is the
|
||||
// repro. The node's outer preferred wraps inner height 1045 (the accepted tip); it then
|
||||
// VERIFIES a gossiped inner 1046, which optimistically becomes the EVM head. The next
|
||||
// build must still produce 1046-off-1045 — the block its own Verify will accept.
|
||||
//
|
||||
// BEFORE THE FIX buildChild delegated straight to the inner VM, which built off the
|
||||
// drifted head: a child at height 1047 whose inner parent is 1046, while the outer parent
|
||||
// wraps 1045 ⇒ errInnerParentMismatch on the node's own block, dropped, repeat forever.
|
||||
// That is the live devnet signature exactly: built 1047 against an accepted tip of 1045.
|
||||
func TestBuildChild_InnerHeadDriftedByGossipVerify_ChildExtendsOuterParentsInner(t *testing.T) {
|
||||
require := require.New(t)
|
||||
ctx := context.Background()
|
||||
|
||||
genesis := &blocktest.Block{
|
||||
IDV: ids.GenerateTestID(),
|
||||
HeightV: 1044,
|
||||
BytesV: []byte("1044"),
|
||||
TimestampV: time.Unix(1_785_216_000, 0),
|
||||
}
|
||||
inner := newEVMLikeInner(genesis)
|
||||
|
||||
// The accepted inner tip, wrapped by this node's outer preferred envelope.
|
||||
acceptedTip := inner.child(genesis) // height 1045
|
||||
inner.head = acceptedTip.IDV
|
||||
|
||||
_, outerParent := newBuildParentTestVM(t, acceptedTip, inner)
|
||||
|
||||
// DRIFT: a gossiped inner block at 1046 verifies and optimistically takes the head.
|
||||
gossiped := inner.child(acceptedTip) // height 1046
|
||||
inner.verifyGossiped(gossiped)
|
||||
require.Equal(gossiped.IDV, inner.head, "precondition: the EVM head drifted off the accepted tip")
|
||||
|
||||
built, err := outerParent.buildChild(ctx)
|
||||
require.NoError(err)
|
||||
|
||||
child, ok := built.(*postForkBlock)
|
||||
require.True(ok)
|
||||
|
||||
// THE INVARIANT — verbatim the condition postForkCommonComponents.Verify enforces
|
||||
// (expectedInnerParentID := p.innerBlk.ID(); innerParentID := child.innerBlk.Parent()).
|
||||
require.Equal(acceptedTip.ID(), child.innerBlk.Parent(),
|
||||
"the built child's inner parent must be the outer parent's inner block, or the node "+
|
||||
"rejects its own block with errInnerParentMismatch and drops it forever")
|
||||
require.Equal(acceptedTip.HeightV+1, child.Height(),
|
||||
"the child must be built one above the outer parent's inner block, not above the drifted head")
|
||||
|
||||
// And the head was reorged back — the drift is repaired, not merely tolerated.
|
||||
require.Equal(acceptedTip.IDV, inner.head)
|
||||
require.Equal(1, inner.reorgs)
|
||||
}
|
||||
|
||||
// TestBuildChild_HealthyHead_NoReorg pins the no-behaviour-change half: when the inner
|
||||
// head already IS the outer parent's inner block (every healthy node, every block), the
|
||||
// re-anchor is a no-op inside the inner VM — no reorg, no head movement.
|
||||
func TestBuildChild_HealthyHead_NoReorg(t *testing.T) {
|
||||
require := require.New(t)
|
||||
ctx := context.Background()
|
||||
|
||||
genesis := &blocktest.Block{
|
||||
IDV: ids.GenerateTestID(),
|
||||
HeightV: 100,
|
||||
BytesV: []byte("100"),
|
||||
TimestampV: time.Unix(1_785_216_000, 0),
|
||||
}
|
||||
inner := newEVMLikeInner(genesis)
|
||||
acceptedTip := inner.child(genesis)
|
||||
inner.head = acceptedTip.IDV
|
||||
|
||||
_, outerParent := newBuildParentTestVM(t, acceptedTip, inner)
|
||||
|
||||
built, err := outerParent.buildChild(ctx)
|
||||
require.NoError(err)
|
||||
|
||||
require.Equal(acceptedTip.ID(), built.(*postForkBlock).innerBlk.Parent())
|
||||
require.Equal(acceptedTip.IDV, inner.head, "head must not move on a healthy build")
|
||||
require.Zero(inner.reorgs, "no reorg on a healthy node — the inner SetPreference early-returns")
|
||||
}
|
||||
|
||||
// TestBuildChild_PreForkTransition_ChildExtendsThisBlock covers the OTHER build
|
||||
// delegation, preForkBlock.buildChild, which produces the pre-fork→post-fork transition
|
||||
// block and is verified by preForkBlock.verifyPostForkChild's identical inner-parent check
|
||||
// ("Make sure [b] is the parent of [child]'s inner block", pre_fork_block.go).
|
||||
//
|
||||
// At the fork height every validator may emit its own unsigned transition candidate, so a
|
||||
// gossiped sibling verifying here is the norm, not an edge case — and it drifts the inner
|
||||
// head exactly as on a live chain. Without the anchor the transition wedges at chain
|
||||
// start, which is every fresh net's first two blocks.
|
||||
func TestBuildChild_PreForkTransition_ChildExtendsThisBlock(t *testing.T) {
|
||||
require := require.New(t)
|
||||
ctx := context.Background()
|
||||
|
||||
genesis := &blocktest.Block{
|
||||
IDV: ids.GenerateTestID(),
|
||||
HeightV: 0,
|
||||
BytesV: []byte("genesis"),
|
||||
TimestampV: time.Unix(1_785_216_000, 0),
|
||||
}
|
||||
inner := newEVMLikeInner(genesis)
|
||||
|
||||
vm, _ := newBuildParentTestVM(t, genesis, inner)
|
||||
preFork := &preForkBlock{Block: genesis, vm: vm}
|
||||
|
||||
// A peer's competing transition candidate verifies and takes the inner head.
|
||||
sibling := inner.child(genesis)
|
||||
inner.verifyGossiped(sibling)
|
||||
require.Equal(sibling.IDV, inner.head)
|
||||
|
||||
built, err := preFork.buildChild(ctx)
|
||||
require.NoError(err)
|
||||
|
||||
require.Equal(genesis.ID(), built.(*postForkBlock).innerBlk.Parent(),
|
||||
"the transition block's inner parent must be the pre-fork block itself")
|
||||
require.Equal(genesis.HeightV+1, built.Height())
|
||||
require.Equal(genesis.IDV, inner.head)
|
||||
}
|
||||
|
||||
// TestBuildChild_InnerParentNotInInnerChain_FailsInsteadOfBuildingADoomedBlock covers the
|
||||
// error case: if the inner VM cannot anchor on the outer parent's inner block, then the
|
||||
// head is provably NOT that block, so any child built would fail this node's own Verify.
|
||||
// Returning the error is strictly better than emitting a block we will drop.
|
||||
func TestBuildChild_InnerParentNotInInnerChain_FailsInsteadOfBuildingADoomedBlock(t *testing.T) {
|
||||
require := require.New(t)
|
||||
ctx := context.Background()
|
||||
|
||||
genesis := &blocktest.Block{
|
||||
IDV: ids.GenerateTestID(),
|
||||
HeightV: 100,
|
||||
BytesV: []byte("100"),
|
||||
TimestampV: time.Unix(1_785_216_000, 0),
|
||||
}
|
||||
inner := newEVMLikeInner(genesis)
|
||||
|
||||
// An inner parent the inner VM does not hold (never inserted).
|
||||
orphanID := ids.GenerateTestID()
|
||||
orphan := &blocktest.Block{
|
||||
IDV: orphanID,
|
||||
ParentV: genesis.IDV,
|
||||
HeightV: 101,
|
||||
BytesV: orphanID[:],
|
||||
TimestampV: genesis.TimestampV.Add(time.Second),
|
||||
}
|
||||
|
||||
_, outerParent := newBuildParentTestVM(t, orphan, inner)
|
||||
|
||||
_, err := outerParent.buildChild(ctx)
|
||||
require.ErrorContains(err, "failed to anchor inner build parent")
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// height_backfill.go — RECOVERY for a proposervm finality index that sits BELOW
|
||||
// the inner VM's accepted tip.
|
||||
//
|
||||
// THE INVARIANT. Every post-fork accept commits the outer envelope, its height
|
||||
// index entry and the last-accepted pointer in ONE versiondb batch BEFORE the
|
||||
// inner block is accepted (vm.acceptPostForkBlock → postForkBlock.Accept), so the
|
||||
// proposervm index can only ever be AHEAD of the inner VM, never behind.
|
||||
//
|
||||
// HOW IT BREAKS ANYWAY. The proposervm has one accept path that advances the inner
|
||||
// VM while leaving the outer index untouched: preForkBlock.Accept, whose
|
||||
// acceptOuterBlk() is a no-op by construction. Post-fork that path was reachable
|
||||
// because BOTH of the proposervm's block constructors fall back to it silently —
|
||||
// ParseBlock() falls back to parsePreForkBlock when the outer envelope does not
|
||||
// parse, and getBlock() falls back to getPreForkBlock when the id is not an outer
|
||||
// id (which is exactly what the CANONICAL id recorded by the consensus ledger is:
|
||||
// postForkCommonComponents.CanonicalID() == innerBlk.ID()). Either fallback hands
|
||||
// the engine a preForkBlock wrapping a post-fork inner block; accepting it moves
|
||||
// the inner VM and NOT the index. Nothing complained, because nothing asserted the
|
||||
// invariant at the moment it was violated — it was only ever checked at the NEXT
|
||||
// boot, by which point the node could not start. That is why the lag looked like
|
||||
// a mysterious "persistence" problem: the writes were never issued at all.
|
||||
//
|
||||
// The prevention half lives at the two fallbacks + preForkBlock.acceptOuterBlk
|
||||
// (see pre_fork_block.go and vm.getPreForkBlock): post-fork, a pre-fork block is
|
||||
// never constructed and never accepted.
|
||||
//
|
||||
// THE RECOVERY half lives here, and it must work for nodes that are ALREADY
|
||||
// damaged. It is two escalating steps, both OUTER-ONLY — they never re-execute,
|
||||
// re-verify or re-accept an inner block, so a node whose EVM is already at the tip
|
||||
// is repaired without touching the EVM:
|
||||
//
|
||||
// 1. rebuildOuterIndexFromStore: re-derive the height index and last-accepted
|
||||
// pointer from the outer envelopes ALREADY in this node's block store, binding
|
||||
// each candidate to the inner block this node itself accepted at that height.
|
||||
// Fully local, no network, no operator. This is what turns "refuses to start"
|
||||
// into "starts, repairs itself, and keeps its finality pointer".
|
||||
//
|
||||
// 2. If step 1 cannot reach the inner tip, the node still STARTS, in an explicit
|
||||
// backfill-pending state: loud, fail-safe (it will not BUILD a block while its
|
||||
// finality index is incomplete) and repairable through BackfillOuterBlock,
|
||||
// which accepts the missing envelopes from any source and applies the SAME
|
||||
// binding checks. The alternative — the previous behaviour — was a dead
|
||||
// C-Chain and a destructive resync.
|
||||
//
|
||||
// WHAT IS NEVER DONE, and why: the finality pointer is never dropped
|
||||
// (DeleteLastAccepted). proposervm.LastAccepted would then fall back to an
|
||||
// INNER-namespace id whose ParentID is contiguity-incompatible with the network's
|
||||
// OUTER wrappers, permanently wedging bootstrap, catch-up and live Verify at the
|
||||
// inner tip — a silent wedge strictly worse than the loud stop it would replace.
|
||||
// Every path below only ever moves the pointer FORWARD, onto an envelope that was
|
||||
// proven to wrap the inner block this node already accepted at that height.
|
||||
package proposervm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
|
||||
statelessblock "github.com/luxfi/node/vms/proposervm/block"
|
||||
"github.com/luxfi/node/vms/proposervm/state"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrOuterBackfillNotPending is returned by BackfillOuterBlock when the index
|
||||
// is already whole — the repair is idempotent, not an error to call twice.
|
||||
ErrOuterBackfillNotPending = errors.New("proposervm: no outer-index backfill is pending")
|
||||
|
||||
// ErrOuterBackfillRejected is returned when a candidate envelope does not bind
|
||||
// to this node's own accepted chain. Fail-closed: an envelope is only indexed
|
||||
// when it provably wraps the inner block WE accepted at that height and chains
|
||||
// off the envelope WE already hold at height-1, so a peer (or a stale file)
|
||||
// cannot steer the finality pointer.
|
||||
ErrOuterBackfillRejected = errors.New("proposervm: outer backfill candidate rejected")
|
||||
|
||||
// errPreForkAfterFork is the prevention guard: post-fork, a block at or above
|
||||
// the recorded fork height is NEVER a pre-fork block. Constructing or accepting
|
||||
// one is what silently strands the finality index below the inner tip.
|
||||
errPreForkAfterFork = errors.New("proposervm: refusing a pre-fork block at or above the fork height")
|
||||
)
|
||||
|
||||
// outerBackfill is the state of an incomplete outer index. Zero value = whole.
|
||||
//
|
||||
// It is guarded by its own leaf mutex (vm.backfillMu) rather than vm.lock: the
|
||||
// accept path, the build path and the operator repair path all read it, and it is
|
||||
// never held across a callout, so it cannot participate in a deadlock.
|
||||
type outerBackfill struct {
|
||||
// next is the FIRST height whose outer envelope is missing. It advances as
|
||||
// envelopes land, so it doubles as the resume point across restarts.
|
||||
next uint64
|
||||
// tip is the inner VM's accepted height — the height the index must reach.
|
||||
tip uint64
|
||||
// innerTipID is the inner block at [tip], recorded for diagnostics.
|
||||
innerTipID ids.ID
|
||||
}
|
||||
|
||||
// NeedsOuterBackfill reports the still-missing outer height range [from, to] and
|
||||
// whether a backfill is pending. It is the seam an operator tool or a node-layer
|
||||
// fetch driver reads; when pending is false the index is whole.
|
||||
func (vm *VM) NeedsOuterBackfill() (from uint64, to uint64, pending bool) {
|
||||
vm.backfillMu.RLock()
|
||||
defer vm.backfillMu.RUnlock()
|
||||
if vm.backfill == nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return vm.backfill.next, vm.backfill.tip, true
|
||||
}
|
||||
|
||||
// outerBackfillPending is the internal fast check used by the build gate.
|
||||
func (vm *VM) outerBackfillPending() bool {
|
||||
vm.backfillMu.RLock()
|
||||
defer vm.backfillMu.RUnlock()
|
||||
return vm.backfill != nil
|
||||
}
|
||||
|
||||
// innerBlockAtHeight returns the inner VM's ACCEPTED block at [height] together
|
||||
// with its bytes — the binding target every repair candidate must match. It reads
|
||||
// the inner VM's own height index, so it is the inner VM's truth, not ours.
|
||||
func (vm *VM) innerBlockAtHeight(ctx context.Context, height uint64) (ids.ID, []byte, error) {
|
||||
innerID, err := vm.ChainVM.GetBlockIDAtHeight(ctx, height)
|
||||
if err != nil {
|
||||
return ids.Empty, nil, err
|
||||
}
|
||||
innerBlk, err := vm.ChainVM.GetBlock(ctx, innerID)
|
||||
if err != nil {
|
||||
return ids.Empty, nil, err
|
||||
}
|
||||
return innerID, innerBlk.Bytes(), nil
|
||||
}
|
||||
|
||||
// indexOuterAtHeight commits ONE outer envelope into the finality index: the
|
||||
// envelope itself, its height entry, and the last-accepted pointer, in a single
|
||||
// versiondb batch — byte-for-byte what acceptPostForkBlock writes on the live
|
||||
// path. It deliberately does NOT touch the inner VM.
|
||||
func (vm *VM) indexOuterAtHeight(height uint64, blk statelessblock.Block) error {
|
||||
if err := vm.State.PutBlock(blk); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vm.State.SetBlockIDAtHeight(height, blk.ID()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vm.State.SetLastAccepted(blk.ID()); err != nil {
|
||||
return err
|
||||
}
|
||||
return vm.db.Commit()
|
||||
}
|
||||
|
||||
// rebuildOuterIndexFromStore walks the gap (proHeight, innerHeight] forward,
|
||||
// re-indexing each height from an outer envelope ALREADY present in this node's
|
||||
// block store.
|
||||
//
|
||||
// Soundness. A candidate at height h is accepted ONLY if
|
||||
//
|
||||
// envelope.Block() == the bytes of the inner block WE accepted at h, and
|
||||
// envelope.ParentID() == the envelope we just indexed at h-1
|
||||
//
|
||||
// so the rebuilt chain is the unique outer chain that wraps our own accepted inner
|
||||
// chain. Nothing is inferred and nothing is fabricated: an unmatched height stops
|
||||
// the walk immediately.
|
||||
//
|
||||
// Returns the height the index reached (>= proHeight). A return equal to
|
||||
// innerHeight means the index is whole again.
|
||||
func (vm *VM) rebuildOuterIndexFromStore(
|
||||
ctx context.Context,
|
||||
proHeight uint64,
|
||||
proID ids.ID,
|
||||
innerHeight uint64,
|
||||
) (uint64, error) {
|
||||
// One pass over the block store: inner-bytes digest -> outer envelope. Keyed by
|
||||
// digest rather than by inner id because deriving an inner id from bytes would
|
||||
// mean parsing untrusted bytes through the inner VM; the digest comparison is
|
||||
// exact and side-effect free.
|
||||
byInnerDigest := make(map[[sha256.Size]byte]statelessblock.Block)
|
||||
if err := state.ScanBlocks(vm.db, func(blk statelessblock.Block) error {
|
||||
byInnerDigest[sha256.Sum256(blk.Block())] = blk
|
||||
return nil
|
||||
}); err != nil {
|
||||
return proHeight, fmt.Errorf("failed to scan the proposervm block store: %w", err)
|
||||
}
|
||||
|
||||
reached := proHeight
|
||||
parentID := proID
|
||||
for h := proHeight + 1; h <= innerHeight; h++ {
|
||||
_, innerBytes, err := vm.innerBlockAtHeight(ctx, h)
|
||||
if err != nil {
|
||||
vm.logger.Warn("proposervm outer-index rebuild: inner block unreadable, stopping walk",
|
||||
log.Uint64("height", h), log.Err(err))
|
||||
break
|
||||
}
|
||||
candidate, ok := byInnerDigest[sha256.Sum256(innerBytes)]
|
||||
if !ok || candidate.ParentID() != parentID {
|
||||
break
|
||||
}
|
||||
if err := vm.indexOuterAtHeight(h, candidate); err != nil {
|
||||
return reached, fmt.Errorf("failed to re-index outer block at height %d: %w", h, err)
|
||||
}
|
||||
reached = h
|
||||
parentID = candidate.ID()
|
||||
}
|
||||
return reached, nil
|
||||
}
|
||||
|
||||
// enterOuterBackfill records an incomplete index and lets the VM start anyway.
|
||||
// Fail-safe, not fail-open: BuildBlock is gated off while pending (a node whose
|
||||
// finality index has a hole must never PROPOSE), the height index still reports
|
||||
// ErrNotFound inside the gap so nothing reads a wrong id, and the last-accepted
|
||||
// pointer is left exactly where the rebuild got to.
|
||||
func (vm *VM) enterOuterBackfill(from, tip uint64, innerTipID ids.ID) {
|
||||
vm.backfillMu.Lock()
|
||||
vm.backfill = &outerBackfill{next: from, tip: tip, innerTipID: innerTipID}
|
||||
vm.backfillMu.Unlock()
|
||||
|
||||
vm.logger.Warn("proposervm STARTING WITH AN INCOMPLETE FINALITY INDEX — outer backfill pending",
|
||||
log.Uint64("firstMissingHeight", from),
|
||||
log.Uint64("innerTipHeight", tip),
|
||||
log.Stringer("innerTipID", innerTipID),
|
||||
log.String("effect", "this chain will NOT build blocks until the missing outer envelopes are supplied"),
|
||||
log.String("recovery", "feed the outer proposervm envelopes for the missing heights via "+
|
||||
"BackfillOuterBlock (cmd/repair-proposervm backfill) — each is bound to the inner block "+
|
||||
"this node already accepted, so no EVM re-execution and no resync are required"),
|
||||
)
|
||||
}
|
||||
|
||||
// BackfillOuterBlock supplies ONE missing outer envelope, oldest-first, and is the
|
||||
// operator/driver half of the recovery. It is fail-closed and idempotent:
|
||||
//
|
||||
// - not pending -> ErrOuterBackfillNotPending
|
||||
// - envelope below the next height -> nil (already indexed; tolerate replay)
|
||||
// - signature/format invalid -> error (parsed WITH verification)
|
||||
// - not the next height, wrong parent,
|
||||
// or wrapping a different inner block-> ErrOuterBackfillRejected
|
||||
//
|
||||
// On the final height it clears the pending state and refreshes the in-memory
|
||||
// last-accepted metadata, after which the chain behaves exactly like one that
|
||||
// booted whole.
|
||||
func (vm *VM) BackfillOuterBlock(ctx context.Context, outerBytes []byte) error {
|
||||
vm.backfillMu.Lock()
|
||||
defer vm.backfillMu.Unlock()
|
||||
|
||||
if vm.backfill == nil {
|
||||
return ErrOuterBackfillNotPending
|
||||
}
|
||||
height := vm.backfill.next
|
||||
|
||||
// Parse WITH proposer-signature verification: the envelope must be a real
|
||||
// block of THIS chain, not merely well-formed bytes.
|
||||
blk, err := statelessblock.Parse(outerBytes, vm.rt.ChainID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: envelope did not parse/verify: %w", ErrOuterBackfillRejected, err)
|
||||
}
|
||||
|
||||
// BIND 1 — the envelope must chain off the one we already hold.
|
||||
parentID, err := vm.State.GetLastAccepted()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: cannot read the current finality pointer: %w", ErrOuterBackfillRejected, err)
|
||||
}
|
||||
if blk.ParentID() != parentID {
|
||||
return fmt.Errorf("%w: envelope parent %s != current finality pointer %s (feed oldest-first)",
|
||||
ErrOuterBackfillRejected, blk.ParentID(), parentID)
|
||||
}
|
||||
|
||||
// BIND 2 — the envelope must wrap the inner block WE accepted at this height.
|
||||
// This is what makes accepting bytes from an untrusted source safe.
|
||||
innerID, innerBytes, err := vm.innerBlockAtHeight(ctx, height)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: inner block at height %d unreadable: %w", ErrOuterBackfillRejected, height, err)
|
||||
}
|
||||
if !bytes.Equal(blk.Block(), innerBytes) {
|
||||
return fmt.Errorf("%w: envelope at height %d does not wrap our accepted inner block %s",
|
||||
ErrOuterBackfillRejected, height, innerID)
|
||||
}
|
||||
|
||||
if err := vm.indexOuterAtHeight(height, blk); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vm.logger.Info("proposervm outer backfill: height repaired",
|
||||
log.Uint64("height", height),
|
||||
log.Stringer("outerID", blk.ID()),
|
||||
log.Uint64("remaining", vm.backfill.tip-height),
|
||||
)
|
||||
|
||||
if height >= vm.backfill.tip {
|
||||
tip := vm.backfill.tip
|
||||
vm.backfill = nil
|
||||
if err := vm.setLastAcceptedMetadata(ctx); err != nil {
|
||||
return fmt.Errorf("outer backfill completed at height %d but last-accepted metadata failed: %w", tip, err)
|
||||
}
|
||||
vm.logger.Info("proposervm FINALITY INDEX FULLY REPAIRED — backfill complete",
|
||||
log.Uint64("height", tip))
|
||||
return nil
|
||||
}
|
||||
vm.backfill.next = height + 1
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// height_backfill_test.go — RECOVERY: a node whose proposervm finality index is
|
||||
// ALREADY behind the inner VM tip must BOOT and REPAIR itself instead of killing
|
||||
// the chain ("non-critical chain failed to initialize chainAlias=C" — a dead
|
||||
// C-Chain on a pod that still reports 1/1 Ready).
|
||||
//
|
||||
// Two paths, both OUTER-ONLY (no inner re-execution, no EVM rollback, no resync):
|
||||
// - the envelopes for the gap are still in the local block store -> rebuilt at
|
||||
// boot with no network and no operator;
|
||||
// - they are not -> the chain STARTS anyway, loud, build-gated, and heals from
|
||||
// supplied envelopes through BackfillOuterBlock.
|
||||
//
|
||||
// The shared harness lives in height_lag_repro_test.go.
|
||||
package proposervm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
|
||||
statelessblock "github.com/luxfi/node/vms/proposervm/block"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. RECOVERY — a node whose index is ALREADY behind boots and heals.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestOuterIndexRebuild_FromLocalStore_BootsAndHeals: the damaged node still holds
|
||||
// the outer envelopes for the gap in its own block store (the truncated-index /
|
||||
// inconsistently-restored-snapshot case). Boot must rebuild the index from them —
|
||||
// no network, no operator, no resync — and must NOT return an error.
|
||||
func TestOuterIndexRebuild_FromLocalStore_BootsAndHeals(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ic := newInnerChain(t, 8)
|
||||
vm := testVM(t, ic)
|
||||
|
||||
outers := acceptThroughProposervm(t, vm, ic, 8)
|
||||
|
||||
// DAMAGE: roll the finality pointer + height index back to 5 while the inner VM
|
||||
// stays at 8 — byte-for-byte the on-disk state luxd-3 booted with (index 6,
|
||||
// inner 98), except the envelopes survive in the block store.
|
||||
if err := vm.State.SetLastAccepted(outers[5].ID()); err != nil {
|
||||
t.Fatalf("SetLastAccepted: %v", err)
|
||||
}
|
||||
for h := uint64(6); h <= 8; h++ {
|
||||
if err := vm.State.DeleteBlockIDAtHeight(h); err != nil {
|
||||
t.Fatalf("DeleteBlockIDAtHeight(%d): %v", h, err)
|
||||
}
|
||||
}
|
||||
if err := vm.db.Commit(); err != nil {
|
||||
t.Fatalf("commit damage: %v", err)
|
||||
}
|
||||
if got := indexHeight(t, vm); got != 5 {
|
||||
t.Fatalf("precondition: damaged index must read 5, got %d", got)
|
||||
}
|
||||
|
||||
// BOOT.
|
||||
if err := vm.repairAcceptedChainByHeight(ctx); err != nil {
|
||||
t.Fatalf("boot with a behind index must REPAIR, not fail: %v", err)
|
||||
}
|
||||
|
||||
if _, _, pending := vm.NeedsOuterBackfill(); pending {
|
||||
t.Fatal("the local store held every missing envelope — no backfill should be pending")
|
||||
}
|
||||
if got := indexHeight(t, vm); got != 8 {
|
||||
t.Fatalf("index must be rebuilt to the inner tip 8, got %d", got)
|
||||
}
|
||||
lastAccepted, err := vm.State.GetLastAccepted()
|
||||
if err != nil || lastAccepted != outers[8].ID() {
|
||||
t.Fatalf("finality pointer must be the canonical envelope at 8 (%s), got %s (err %v)",
|
||||
outers[8].ID(), lastAccepted, err)
|
||||
}
|
||||
for h := uint64(6); h <= 8; h++ {
|
||||
got, err := vm.State.GetBlockIDAtHeight(h)
|
||||
if err != nil || got != outers[h].ID() {
|
||||
t.Fatalf("height index at %d must be %s, got %s (err %v)", h, outers[h].ID(), got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOuterBackfill_HealsFromSuppliedEnvelopes: the envelopes for the gap are NOT
|
||||
// in the local store (the pre-fork-fallback case — they were never persisted).
|
||||
// The node must still START — the old code killed the C-Chain here — come up
|
||||
// explicitly backfill-pending, refuse to build, and then heal from supplied
|
||||
// envelopes with no EVM re-execution.
|
||||
func TestOuterBackfill_HealsFromSuppliedEnvelopes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ic := newInnerChain(t, 8)
|
||||
vm := testVM(t, ic)
|
||||
|
||||
acceptThroughProposervm(t, vm, ic, 5)
|
||||
lastGood, err := vm.State.GetLastAccepted()
|
||||
if err != nil {
|
||||
t.Fatalf("GetLastAccepted: %v", err)
|
||||
}
|
||||
|
||||
// DAMAGE: the inner VM ran on to 8 without the index (exactly what the pre-fork
|
||||
// fallback did in production). No envelopes for 6..8 exist locally.
|
||||
ic.accept(8)
|
||||
|
||||
// BOOT — must not error.
|
||||
if err := vm.repairAcceptedChainByHeight(ctx); err != nil {
|
||||
t.Fatalf("boot with a behind index must START the chain, got: %v", err)
|
||||
}
|
||||
from, to, pending := vm.NeedsOuterBackfill()
|
||||
if !pending || from != 6 || to != 8 {
|
||||
t.Fatalf("want backfill pending 6..8, got from=%d to=%d pending=%v", from, to, pending)
|
||||
}
|
||||
|
||||
// FAIL-SAFE: a node with a hole in its finality index must not propose.
|
||||
if _, err := vm.BuildBlock(ctx); err == nil {
|
||||
t.Fatal("BuildBlock must refuse while the finality index is incomplete")
|
||||
}
|
||||
|
||||
// NEGATIVE 1 — an envelope wrapping a DIFFERENT inner block than the one we
|
||||
// accepted at this height is rejected (a peer cannot steer the pointer).
|
||||
bogus, err := statelessblock.BuildUnsigned(
|
||||
lastGood, time.Unix(6, 0), 0, statelessblock.Epoch{}, []byte("not-our-inner-block"))
|
||||
if err != nil {
|
||||
t.Fatalf("BuildUnsigned: %v", err)
|
||||
}
|
||||
if err := vm.BackfillOuterBlock(ctx, bogus.Bytes()); !errors.Is(err, ErrOuterBackfillRejected) {
|
||||
t.Fatalf("an envelope that does not wrap our accepted inner block must be rejected, got %v", err)
|
||||
}
|
||||
|
||||
// NEGATIVE 2 — out of order (height 7 before 6) is rejected: it does not chain
|
||||
// off the current finality pointer.
|
||||
outOfOrder := outerFor(t, ic, 7, lastGood)
|
||||
if err := vm.BackfillOuterBlock(ctx, outOfOrder.Bytes()); !errors.Is(err, ErrOuterBackfillRejected) {
|
||||
t.Fatalf("an out-of-order envelope must be rejected, got %v", err)
|
||||
}
|
||||
|
||||
// HEAL — feed 6, 7, 8 oldest-first, as a healthy peer or the repair tool would.
|
||||
parent := lastGood
|
||||
want := map[uint64]ids.ID{}
|
||||
for h := uint64(6); h <= 8; h++ {
|
||||
sb := outerFor(t, ic, h, parent)
|
||||
if err := vm.BackfillOuterBlock(ctx, sb.Bytes()); err != nil {
|
||||
t.Fatalf("BackfillOuterBlock(h=%d): %v", h, err)
|
||||
}
|
||||
want[h] = sb.ID()
|
||||
parent = sb.ID()
|
||||
}
|
||||
|
||||
if _, _, pending := vm.NeedsOuterBackfill(); pending {
|
||||
t.Fatal("backfill must be complete after the last height")
|
||||
}
|
||||
if got := indexHeight(t, vm); got != 8 {
|
||||
t.Fatalf("index must reach the inner tip 8, got %d", got)
|
||||
}
|
||||
for h := uint64(6); h <= 8; h++ {
|
||||
got, err := vm.State.GetBlockIDAtHeight(h)
|
||||
if err != nil || got != want[h] {
|
||||
t.Fatalf("height index at %d must be %s, got %s (err %v)", h, want[h], got, err)
|
||||
}
|
||||
}
|
||||
// Idempotent: nothing pending, so a replayed call says so rather than corrupting.
|
||||
if err := vm.BackfillOuterBlock(ctx, outOfOrder.Bytes()); !errors.Is(err, ErrOuterBackfillNotPending) {
|
||||
t.Fatalf("want ErrOuterBackfillNotPending after completion, got %v", err)
|
||||
}
|
||||
// The chain is a full participant again.
|
||||
if _, _, pending := vm.NeedsOuterBackfill(); pending {
|
||||
t.Fatal("build gate must have lifted")
|
||||
}
|
||||
// And a fresh boot on the repaired state is a clean no-op.
|
||||
if err := vm.repairAcceptedChainByHeight(ctx); err != nil {
|
||||
t.Fatalf("boot after repair must be a no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootWithBehindIndex_NeverKillsTheChain is the direct regression lock on the
|
||||
// production symptom: repairAcceptedChainByHeight must NEVER return an error for a
|
||||
// behind index, because chains/manager.go turns that error into
|
||||
// "non-critical chain failed to initialize chainAlias=C" — a dead C-Chain on a pod
|
||||
// that still reports 1/1 Ready.
|
||||
func TestBootWithBehindIndex_NeverKillsTheChain(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, gap := range []uint64{1, 2, 6, 91} {
|
||||
ic := newInnerChain(t, 100)
|
||||
vm := testVM(t, ic)
|
||||
acceptThroughProposervm(t, vm, ic, 100-gap)
|
||||
ic.accept(100)
|
||||
|
||||
if err := vm.repairAcceptedChainByHeight(ctx); err != nil {
|
||||
t.Fatalf("gap %d: boot must not fail, got %v", gap, err)
|
||||
}
|
||||
from, to, pending := vm.NeedsOuterBackfill()
|
||||
if !pending || from != 100-gap+1 || to != 100 {
|
||||
t.Fatalf("gap %d: want pending %d..100, got %d..%d pending=%v", gap, 100-gap+1, from, to, pending)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustForkHeight(t *testing.T, vm *VM) uint64 {
|
||||
t.Helper()
|
||||
h, err := vm.State.GetForkHeight()
|
||||
if err != nil {
|
||||
t.Fatalf("GetForkHeight: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// TestOuterBackfill_SelfHealsFromOrdinaryParseTraffic proves the loop closes with
|
||||
// no new transport and no operator: every envelope a peer serves passes through
|
||||
// VM.ParseBlock, and a backfill-pending node absorbs exactly the ones it needs.
|
||||
// This is what lets an ALREADY-DAMAGED node come back by itself.
|
||||
func TestOuterBackfill_SelfHealsFromOrdinaryParseTraffic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ic := newInnerChain(t, 8)
|
||||
vm := testVM(t, ic)
|
||||
|
||||
acceptThroughProposervm(t, vm, ic, 5)
|
||||
lastGood, err := vm.State.GetLastAccepted()
|
||||
if err != nil {
|
||||
t.Fatalf("GetLastAccepted: %v", err)
|
||||
}
|
||||
ic.accept(8) // the inner VM ran on without the index
|
||||
|
||||
if err := vm.repairAcceptedChainByHeight(ctx); err != nil {
|
||||
t.Fatalf("boot must start the chain, got %v", err)
|
||||
}
|
||||
if _, _, pending := vm.NeedsOuterBackfill(); !pending {
|
||||
t.Fatal("precondition: backfill must be pending")
|
||||
}
|
||||
|
||||
// Peer traffic, oldest-first, delivered through the ORDINARY parse path.
|
||||
parent := lastGood
|
||||
for h := uint64(6); h <= 8; h++ {
|
||||
sb := outerFor(t, ic, h, parent)
|
||||
if _, err := vm.ParseBlock(ctx, sb.Bytes()); err != nil {
|
||||
t.Fatalf("ParseBlock(h=%d): %v", h, err)
|
||||
}
|
||||
parent = sb.ID()
|
||||
}
|
||||
|
||||
if _, _, pending := vm.NeedsOuterBackfill(); pending {
|
||||
t.Fatal("the node must have healed itself from ordinary parse traffic")
|
||||
}
|
||||
if got := indexHeight(t, vm); got != 8 {
|
||||
t.Fatalf("index must reach the inner tip 8, got %d", got)
|
||||
}
|
||||
// The build gate lifted with the heal (the gate itself is asserted in
|
||||
// TestOuterBackfill_HealsFromSuppliedEnvelopes; driving the real builder here
|
||||
// would need a validator set this harness deliberately does not stand up).
|
||||
if _, _, pending := vm.NeedsOuterBackfill(); pending {
|
||||
t.Fatal("build gate must have lifted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// height_lag_repro_test.go — the DETERMINISTIC REPRODUCTION of the proposervm
|
||||
// finality-index lag, written against the PRE-EXISTING proposervm API only (no
|
||||
// symbol introduced by the fix), so it fails on the unfixed code and passes on the
|
||||
// fixed code without being tautological.
|
||||
//
|
||||
// The production failure it reproduces (lux-devnet luxd-3, C-Chain, verbatim):
|
||||
//
|
||||
// VM initialization failed error="failed to repair accepted chain by height:
|
||||
// proposervm finality index (height 6, id ns5qGN4i…) is BEHIND the inner VM tip
|
||||
// (height 98, id TUTR74eA…)" chainID=21HieZng…
|
||||
// non-critical chain failed to initialize … chainAlias=C
|
||||
//
|
||||
// THE MECHANISM. Every post-fork accept commits the envelope, its height index
|
||||
// entry and the last-accepted pointer in ONE versiondb batch BEFORE the inner
|
||||
// block is accepted, so the index can only run AHEAD. But the proposervm has one
|
||||
// accept path that moves the inner VM and writes NOTHING: preForkBlock.Accept,
|
||||
// whose acceptOuterBlk() is a no-op. Post-fork it was reachable because getBlock()
|
||||
// silently falls back to constructing a preForkBlock whenever the id is not an
|
||||
// OUTER envelope id — and the id this fork's consensus ledger records as canonical
|
||||
// IS the inner block's id (postForkCommonComponents.CanonicalID). One such accept
|
||||
// and the index is stranded: invisible while running, fatal at the next boot.
|
||||
//
|
||||
// This file also holds the shared harness the recovery tests use.
|
||||
package proposervm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/database/versiondb"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/metric"
|
||||
"github.com/luxfi/runtime"
|
||||
vmchain "github.com/luxfi/vm/chain"
|
||||
"github.com/luxfi/vm/chain/blocktest"
|
||||
|
||||
"github.com/luxfi/node/cache/lru"
|
||||
statelessblock "github.com/luxfi/node/vms/proposervm/block"
|
||||
"github.com/luxfi/node/vms/proposervm/state"
|
||||
"github.com/luxfi/node/vms/proposervm/tree"
|
||||
)
|
||||
|
||||
// innerChain is a stand-in for the inner execution VM (the C-Chain EVM): a
|
||||
// contiguous accepted chain 1..tip answering LastAccepted / GetBlock /
|
||||
// GetBlockIDAtHeight / ParseBlock exactly as the real one does.
|
||||
type innerChain struct {
|
||||
byHeight map[uint64]*blocktest.Block
|
||||
byID map[ids.ID]*blocktest.Block
|
||||
byBytes map[string]*blocktest.Block
|
||||
tip uint64
|
||||
}
|
||||
|
||||
func newInnerChain(t *testing.T, tip uint64) *innerChain {
|
||||
t.Helper()
|
||||
ic := &innerChain{
|
||||
byHeight: map[uint64]*blocktest.Block{},
|
||||
byID: map[ids.ID]*blocktest.Block{},
|
||||
byBytes: map[string]*blocktest.Block{},
|
||||
}
|
||||
parent := ids.Empty
|
||||
for h := uint64(1); h <= tip; h++ {
|
||||
id := ids.GenerateTestID()
|
||||
blk := &blocktest.Block{
|
||||
IDV: id,
|
||||
ParentV: parent,
|
||||
HeightV: h,
|
||||
BytesV: append([]byte("inner"), id[:]...),
|
||||
TimestampV: time.Unix(int64(h), 0),
|
||||
}
|
||||
ic.byHeight[h] = blk
|
||||
ic.byID[id] = blk
|
||||
ic.byBytes[string(blk.BytesV)] = blk
|
||||
parent = id
|
||||
}
|
||||
ic.tip = tip
|
||||
return ic
|
||||
}
|
||||
|
||||
// accept models the inner VM accepting a block: its head advances. This is the
|
||||
// ONLY way the inner tip moves, exactly as in the real EVM.
|
||||
func (ic *innerChain) accept(h uint64) { ic.tip = h }
|
||||
|
||||
func (ic *innerChain) vm() *blocktest.VM {
|
||||
return &blocktest.VM{
|
||||
LastAcceptedF: func(context.Context) (ids.ID, error) {
|
||||
return ic.byHeight[ic.tip].IDV, nil
|
||||
},
|
||||
GetBlockF: func(_ context.Context, id ids.ID) (vmchain.Block, error) {
|
||||
blk, ok := ic.byID[id]
|
||||
if !ok {
|
||||
return nil, errors.New("inner: block not found")
|
||||
}
|
||||
return blk, nil
|
||||
},
|
||||
GetBlockIDAtHeightF: func(_ context.Context, h uint64) (ids.ID, error) {
|
||||
blk, ok := ic.byHeight[h]
|
||||
if !ok {
|
||||
return ids.Empty, errors.New("inner: height not indexed")
|
||||
}
|
||||
return blk.IDV, nil
|
||||
},
|
||||
ParseBlockF: func(_ context.Context, b []byte) (vmchain.Block, error) {
|
||||
blk, ok := ic.byBytes[string(b)]
|
||||
if !ok {
|
||||
return nil, errors.New("inner: unparseable bytes")
|
||||
}
|
||||
return blk, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// testVM builds a proposervm over memdb wrapping [ic] — the real VM struct with
|
||||
// the real State, so every write below goes through the production code paths.
|
||||
func testVM(t *testing.T, ic *innerChain) *VM {
|
||||
t.Helper()
|
||||
return testVMOnBase(t, ic, memdb.New())
|
||||
}
|
||||
|
||||
// testVMOnBase is testVM over a caller-supplied base database — the seam the
|
||||
// crash-boot tests (vm_crashboot_test.go) use to boot a SECOND, cold VM over
|
||||
// bytes copied out of a running one.
|
||||
func testVMOnBase(t *testing.T, ic *innerChain, base database.Database) *VM {
|
||||
t.Helper()
|
||||
db := versiondb.New(base)
|
||||
metrics := metric.New("")
|
||||
vm := &VM{
|
||||
ChainVM: ic.vm(),
|
||||
db: db,
|
||||
State: state.New(db),
|
||||
Tree: tree.New(),
|
||||
verifiedBlocks: map[ids.ID]PostForkBlock{},
|
||||
innerBlkCache: lru.NewSizedCache(innerBlkCacheSize, cachedBlockSize),
|
||||
logger: log.Noop(),
|
||||
rt: &runtime.Runtime{ChainID: ids.GenerateTestID()},
|
||||
}
|
||||
vm.lastAcceptedTimestampGaugeVec = metrics.NewGaugeVec(
|
||||
"last_accepted_timestamp", "timestamp of the last block accepted", []string{"block_type"})
|
||||
return vm
|
||||
}
|
||||
|
||||
// outerFor wraps the inner block at [h] in a real proposervm envelope chained off
|
||||
// [parentOuter] — the same shape a proposer builds.
|
||||
func outerFor(t *testing.T, ic *innerChain, h uint64, parentOuter ids.ID) statelessblock.SignedBlock {
|
||||
t.Helper()
|
||||
sb, err := statelessblock.BuildUnsigned(
|
||||
parentOuter, time.Unix(int64(h), 0), 0, statelessblock.Epoch{}, ic.byHeight[h].BytesV)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildUnsigned(h=%d): %v", h, err)
|
||||
}
|
||||
return sb
|
||||
}
|
||||
|
||||
// acceptThroughProposervm drives heights 1..through through the REAL post-fork
|
||||
// accept path, so index and inner head advance in lock-step. This is a healthy
|
||||
// node, and it is what records the fork height.
|
||||
func acceptThroughProposervm(t *testing.T, vm *VM, ic *innerChain, through uint64) map[uint64]statelessblock.SignedBlock {
|
||||
t.Helper()
|
||||
return acceptRangeThroughProposervm(t, vm, ic, 1, through, ids.Empty)
|
||||
}
|
||||
|
||||
// acceptRangeThroughProposervm is acceptThroughProposervm for heights
|
||||
// from..through, chaining the first envelope off parentOuter — so a test can
|
||||
// keep a source node accepting AFTER a crash copy was taken.
|
||||
func acceptRangeThroughProposervm(t *testing.T, vm *VM, ic *innerChain, from, through uint64, parentOuter ids.ID) map[uint64]statelessblock.SignedBlock {
|
||||
t.Helper()
|
||||
outers := map[uint64]statelessblock.SignedBlock{}
|
||||
for h := from; h <= through; h++ {
|
||||
sb := outerFor(t, ic, h, parentOuter)
|
||||
blk := &postForkBlock{
|
||||
SignedBlock: sb,
|
||||
postForkCommonComponents: postForkCommonComponents{vm: vm, innerBlk: ic.byHeight[h]},
|
||||
}
|
||||
vm.Tree.Add(ic.byHeight[h])
|
||||
if err := blk.Accept(context.Background()); err != nil {
|
||||
t.Fatalf("post-fork Accept(h=%d): %v", h, err)
|
||||
}
|
||||
ic.accept(h)
|
||||
outers[h] = sb
|
||||
parentOuter = sb.ID()
|
||||
}
|
||||
return outers
|
||||
}
|
||||
|
||||
// indexHeight reads the height the PERSISTED finality index sits at — the exact
|
||||
// quantity repairAcceptedChainByHeight compares against the inner tip at boot.
|
||||
func indexHeight(t *testing.T, vm *VM) uint64 {
|
||||
t.Helper()
|
||||
id, err := vm.State.GetLastAccepted()
|
||||
if err != nil {
|
||||
t.Fatalf("GetLastAccepted: %v", err)
|
||||
}
|
||||
blk, err := vm.getPostForkBlock(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("getPostForkBlock(%s): %v", id, err)
|
||||
}
|
||||
return blk.Height()
|
||||
}
|
||||
|
||||
// TestFinalityIndexLag_IndexMustNeverFallBehindInnerTip is THE reproduction.
|
||||
//
|
||||
// It asks the proposervm for the block at the CANONICAL id of height 6 — the id
|
||||
// the consensus ledger records for a proposervm-wrapped block — accepts whatever
|
||||
// comes back, and then asserts the two things a correct proposervm guarantees:
|
||||
//
|
||||
// 1. the persisted finality index is never below the inner VM tip, and
|
||||
// 2. a boot-time reconciliation of that state never kills the chain.
|
||||
//
|
||||
// UNFIXED, getBlock hands back a preForkBlock, its Accept is a silent no-op on the
|
||||
// outer index, the inner VM advances to 6, and this test fails on (1) — printing
|
||||
// the same divergence luxd-3 died of. FIXED, the pre-fork construction/accept is
|
||||
// refused, nothing diverges, and both assertions hold.
|
||||
//
|
||||
// It uses NO symbol introduced by the fix, so it is a genuine before/after probe.
|
||||
func TestFinalityIndexLag_IndexMustNeverFallBehindInnerTip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ic := newInnerChain(t, 8)
|
||||
vm := testVM(t, ic)
|
||||
|
||||
acceptThroughProposervm(t, vm, ic, 5)
|
||||
if got, tip := indexHeight(t, vm), ic.tip; got != 5 || tip != 5 {
|
||||
t.Fatalf("precondition: want index=5 inner=5, got index=%d inner=%d", got, tip)
|
||||
}
|
||||
forkHeight, err := vm.State.GetForkHeight()
|
||||
if err != nil {
|
||||
t.Fatalf("GetForkHeight: %v", err)
|
||||
}
|
||||
|
||||
// The engine asks for height 6 by its CANONICAL (inner) id.
|
||||
if blk, err := vm.getBlock(ctx, ic.byHeight[6].IDV); err == nil {
|
||||
if _, isPreFork := blk.(*preForkBlock); isPreFork {
|
||||
t.Logf("getBlock(canonical id @6) returned a preForkBlock — fork height is %d", forkHeight)
|
||||
}
|
||||
if err := blk.Accept(ctx); err == nil {
|
||||
ic.accept(6) // the inner VM applied it
|
||||
}
|
||||
}
|
||||
|
||||
// ASSERTION 1 — the invariant.
|
||||
if got := indexHeight(t, vm); got < ic.tip {
|
||||
// Errorf, not Fatalf: assertion 2 below must also run, so one failing run
|
||||
// shows the WHOLE production symptom chain (lag now -> dead chain at boot).
|
||||
t.Errorf("FINALITY INDEX LAG REPRODUCED: index is at height %d but the inner VM tip is %d "+
|
||||
"(fork height %d). An accept advanced the inner VM without writing the proposervm index; "+
|
||||
"this node is now unbootable", got, ic.tip, forkHeight)
|
||||
}
|
||||
|
||||
// ASSERTION 2 — boot must not kill the chain.
|
||||
if err := vm.repairAcceptedChainByHeight(ctx); err != nil {
|
||||
t.Fatalf("BOOT KILLED THE CHAIN: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreForkAcceptStillWorksBeforeFork is the positive control for the guard: on
|
||||
// a chain that has NOT forked there is no fork height, every block is legitimately
|
||||
// pre-fork, and both construction and Accept must still work. A guard that broke
|
||||
// this would brick every chain before its transition block.
|
||||
func TestPreForkAcceptStillWorksBeforeFork(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ic := newInnerChain(t, 3)
|
||||
vm := testVM(t, ic)
|
||||
|
||||
if _, err := vm.State.GetForkHeight(); err == nil {
|
||||
t.Fatal("precondition: a fresh chain must have no fork height")
|
||||
}
|
||||
pre := &preForkBlock{Block: ic.byHeight[1], vm: vm}
|
||||
if err := pre.Accept(ctx); err != nil {
|
||||
t.Fatalf("pre-fork accept before the fork must succeed, got %v", err)
|
||||
}
|
||||
if _, err := vm.getPreForkBlock(ctx, ic.byHeight[1].IDV); err != nil {
|
||||
t.Fatalf("pre-fork getBlock before the fork must succeed, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -65,8 +65,20 @@ func (b *preForkBlock) Accept(ctx context.Context) error {
|
||||
return b.acceptInnerBlk(ctx)
|
||||
}
|
||||
|
||||
func (*preForkBlock) acceptOuterBlk() error {
|
||||
return nil
|
||||
// acceptOuterBlk is a NO-OP for a genuine pre-fork block — a pre-fork block has no
|
||||
// outer envelope, so there is nothing to index — and a hard REFUSAL once the chain
|
||||
// has forked.
|
||||
//
|
||||
// The refusal is the second half of the finality-index root-cause fix (the first is
|
||||
// vm.getPreForkBlock). Accepting a pre-fork block at or above the fork height
|
||||
// advances the inner VM while leaving the proposervm index exactly where it was:
|
||||
// the index silently falls behind the inner tip during normal operation, and the
|
||||
// NEXT boot cannot start the chain. Refusing here converts that invisible,
|
||||
// deferred, fatal corruption into an immediate, attributable error at the exact
|
||||
// accept that would have caused it — and the engine's accept path is fail-closed,
|
||||
// so finality stops rather than diverging.
|
||||
func (b *preForkBlock) acceptOuterBlk() error {
|
||||
return b.vm.refusePreForkAfterFork(b.Height())
|
||||
}
|
||||
|
||||
func (b *preForkBlock) acceptInnerBlk(ctx context.Context) error {
|
||||
@@ -203,7 +215,7 @@ func (b *preForkBlock) buildChild(ctx context.Context) (Block, error) {
|
||||
// "chain is currently forking" path is the only one taken.
|
||||
parentTimestamp := b.Timestamp()
|
||||
parentID := b.ID()
|
||||
newTimestamp := b.vm.Time().Truncate(time.Second)
|
||||
newTimestamp := b.vm.Time().Truncate(proposer.TimestampGranularity())
|
||||
if newTimestamp.Before(parentTimestamp) {
|
||||
newTimestamp = parentTimestamp
|
||||
}
|
||||
@@ -275,6 +287,16 @@ func (b *preForkBlock) buildChild(ctx context.Context) (Block, error) {
|
||||
}
|
||||
// else: we ARE the elected proposer for this slot — build the unsigned block.
|
||||
|
||||
// The inner VM builds on ITS OWN head, and verifyPostForkChild below requires the
|
||||
// transition block's inner parent to be exactly THIS block's inner block. Anchor the
|
||||
// two together before delegating (see VM.anchorInnerBuildParent): at the fork height
|
||||
// every validator may emit its own unsigned transition candidate, so a gossiped
|
||||
// sibling that verifies here would otherwise drift the inner head and wedge the
|
||||
// transition — the same self-rejection loop, at chain start.
|
||||
if err := b.vm.anchorInnerBuildParent(ctx, parentID, b.Block.ID()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var innerBlock chain.Block
|
||||
if b.vm.blockBuilderVM != nil {
|
||||
// VM supports BuildBlockWithRuntime
|
||||
|
||||
@@ -42,6 +42,20 @@ var (
|
||||
MaxLookAheadWindow = MaxLookAheadSlots * WindowDuration // 1h at the default
|
||||
)
|
||||
|
||||
// TimestampGranularity is the resolution block timestamps are truncated to. It
|
||||
// tracks WindowDuration so the proposer-slot clock (TimeToSlot = elapsed /
|
||||
// WindowDuration) and the block-timestamp clock advance at the SAME rate: with a
|
||||
// coarser 1s truncation, a sub-second WindowDuration would inflate slot numbers
|
||||
// without finer time resolution, so blocks could not actually be produced faster
|
||||
// than 1/s. Capped at 1s so mainnet (WindowDuration >= 1s) keeps the original
|
||||
// 1-second block-time granularity exactly.
|
||||
func TimestampGranularity() time.Duration {
|
||||
if WindowDuration > time.Second {
|
||||
return time.Second
|
||||
}
|
||||
return WindowDuration
|
||||
}
|
||||
|
||||
// SetWindowDuration overrides the proposer-slot spacing. It MUST be called at
|
||||
// startup, before any chain begins block production: WindowDuration is read by
|
||||
// both the windower delay math and TimeToSlot, so changing it mid-flight would
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package state
|
||||
|
||||
import (
|
||||
"github.com/luxfi/database/prefixdb"
|
||||
"github.com/luxfi/database/versiondb"
|
||||
|
||||
"github.com/luxfi/node/vms/proposervm/block"
|
||||
)
|
||||
|
||||
// ScanBlocks visits every OUTER proposervm block persisted in the block store of
|
||||
// [db], in key order.
|
||||
//
|
||||
// It is a REPAIR-PATH reader, deliberately kept OUT of the State interface: the
|
||||
// hot path addresses blocks by id, and only the boot-time index rebuild
|
||||
// (proposervm.rebuildOuterIndexFromStore) needs to enumerate them. Keeping it a
|
||||
// free function over the same [blockStatePrefix] the store writes under means the
|
||||
// prefix stays defined exactly once, and no mock/interface churn is imposed on
|
||||
// callers that will never scan.
|
||||
//
|
||||
// Records that fail to decode are SKIPPED, not fatal: a rebuild must extract every
|
||||
// usable wrapper from a store that may be partially damaged — that is the whole
|
||||
// point of running it. Only an iterator-level error aborts the scan.
|
||||
func ScanBlocks(db *versiondb.Database, visit func(block.Block) error) error {
|
||||
blockDB := prefixdb.New(blockStatePrefix, db)
|
||||
it := blockDB.NewIterator()
|
||||
defer it.Release()
|
||||
|
||||
for it.Next() {
|
||||
wrapper := blockWrapper{}
|
||||
if err := parseBlockWrapper(it.Value(), &wrapper); err != nil {
|
||||
continue
|
||||
}
|
||||
blk, err := block.ParseWithoutVerification(wrapper.Block)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := visit(blk); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return it.Error()
|
||||
}
|
||||
+179
-29
@@ -121,6 +121,16 @@ type VM struct {
|
||||
// [postForkBlock] and its inner block.
|
||||
lastAcceptedTimestampGaugeVec metric.GaugeVec
|
||||
|
||||
// backfillMu guards [backfill]. It is a LEAF lock — never held across a
|
||||
// callout — so it cannot deadlock against [lock], the [Tree] or the inner VM.
|
||||
backfillMu sync.RWMutex
|
||||
// backfill is non-nil ONLY while this node booted with a finality index that
|
||||
// the local block store could not fully rebuild (see height_backfill.go). While
|
||||
// it is set the chain runs read-only-ish: it refuses to BUILD blocks, and
|
||||
// BackfillOuterBlock is the seam that completes the index. nil = index whole,
|
||||
// which is the state of every healthy node.
|
||||
backfill *outerBackfill
|
||||
|
||||
// quasarGate is the OPTIONAL post-quantum finality-cert gate. nil (the
|
||||
// default) means PQ-finality verification is OFF — the accept path is
|
||||
// unchanged classical Snow. When set AND forward-dated activation is reached,
|
||||
@@ -332,6 +342,18 @@ func (vm *VM) SetState(ctx context.Context, newState uint32) error {
|
||||
}
|
||||
|
||||
func (vm *VM) BuildBlock(ctx context.Context) (vmchain.Block, error) {
|
||||
// FAIL-SAFE while the finality index has a hole. A node that booted with an
|
||||
// incomplete outer index (height_backfill.go) does not know the canonical
|
||||
// envelope at its own tip, so anything it proposed would extend a parent the
|
||||
// network does not recognise. It stays a follower until BackfillOuterBlock
|
||||
// closes the gap. Serving RPC and following the chain are unaffected — this is
|
||||
// exactly the difference between the old dead C-Chain and a live one.
|
||||
if from, to, pending := vm.NeedsOuterBackfill(); pending {
|
||||
return nil, fmt.Errorf(
|
||||
"proposervm: refusing to build — finality index incomplete, outer envelopes for heights %d..%d are missing",
|
||||
from, to)
|
||||
}
|
||||
|
||||
preferredBlock, err := vm.getBlock(ctx, vm.preferred)
|
||||
if err == nil {
|
||||
return preferredBlock.buildChild(ctx)
|
||||
@@ -383,6 +405,17 @@ func (vm *VM) BuildBlock(ctx context.Context) (vmchain.Block, error) {
|
||||
}
|
||||
|
||||
func (vm *VM) ParseBlock(ctx context.Context, b []byte) (vmchain.Block, error) {
|
||||
// SELF-HEAL SEAM. Every outer envelope this node receives — gossip, GetAncestors,
|
||||
// catch-up — arrives here. If this node booted with a hole in its finality index
|
||||
// (height_backfill.go), offer the bytes to the backfill: it takes ONLY the exact
|
||||
// next missing height, only if the envelope verifies AND wraps the inner block we
|
||||
// already accepted there, so a wrong or hostile block cannot land. That makes a
|
||||
// damaged node repair itself from ordinary peer traffic with no new transport and
|
||||
// no operator step. Errors are expected and ignored (almost every block is not the
|
||||
// one we need); when nothing is pending this is one RLock and out.
|
||||
if vm.outerBackfillPending() {
|
||||
_ = vm.BackfillOuterBlock(ctx, b)
|
||||
}
|
||||
if blk, err := vm.parsePostForkBlock(ctx, b, true); err == nil {
|
||||
return blk, nil
|
||||
}
|
||||
@@ -474,6 +507,56 @@ func (vm *VM) SetPreference(ctx context.Context, preferred ids.ID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// anchorInnerBuildParent points the inner VM at [innerParentID] — the inner block wrapped
|
||||
// by the outer parent a build is about to extend — and is called from both build
|
||||
// delegations (postForkCommonComponents.buildChild and preForkBlock.buildChild) as the
|
||||
// last step before asking the inner VM for a block.
|
||||
//
|
||||
// THE INVARIANT it establishes is the one the verify path enforces:
|
||||
// child.innerBlk.Parent() == parent.innerBlk.ID(), else errInnerParentMismatch
|
||||
// (block.go, and pre_fork_block.go for the transition block). The inner VM never reads
|
||||
// the proposervm's parent — it builds on ITS OWN head (luxfi/evm: the miner reads
|
||||
// bc.CurrentBlock()) — so build and verify read two different pointers, and the builder's
|
||||
// is not ours to assume.
|
||||
//
|
||||
// WHY MAINTAINING IT IN SetPreference ALONE IS NOT ENOUGH. The inner head moves for
|
||||
// reasons the proposervm never observes: verifying a GOSSIPED block whose parent is the
|
||||
// current head optimistically makes it the head (evm core/blockchain.go
|
||||
// writeBlockAndSetHead → newTip → writeCanonicalBlockWithLogs → writeHeadBlock), with no
|
||||
// proposervm involvement and no accept. SetPreference cannot undo that drift either — it
|
||||
// short-circuits on an unchanged outer preference (above), so re-affirming the same tip
|
||||
// never re-pushes the inner preference. Every subsequent build then extends the drifted
|
||||
// head, the node REJECTS THE BLOCK IT JUST BUILT, drops it, and repeats forever:
|
||||
// devnet 96367 / testnet 96368 on 2026-07-28, "built block failed verification —
|
||||
// dropping / inner parentID didn't match expected parent", 83–456 drops/min per node,
|
||||
// the accepted tip frozen while the builder ran two heights ahead of it. Never
|
||||
// self-corrects, on any node, ever.
|
||||
//
|
||||
// SetPreference on the INNER VM is the right primitive and the only one: it is that VM's
|
||||
// own head-reorg entry point (evm VM.SetPreference → BlockChain.SetPreference →
|
||||
// writeKnownBlock, which performs the reorg side effects). Asserting it at the point of
|
||||
// use — rather than trusting a distant caller to have done it — is what makes the
|
||||
// requirement local to the code that depends on it.
|
||||
//
|
||||
// COST AND SAFETY. On a healthy node the head already IS the parent's inner block and the
|
||||
// inner SetPreference returns early on that identity (evm setPreference:
|
||||
// `current.Hash() == block.Hash()`), so this is one lookup and no state change — zero
|
||||
// behaviour change in the common case. When it fails, the head is provably NOT the
|
||||
// parent's inner block, so any child built would fail this node's own Verify; refusing to
|
||||
// build is strictly better than emitting a block we are guaranteed to drop.
|
||||
func (vm *VM) anchorInnerBuildParent(ctx context.Context, parentID, innerParentID ids.ID) error {
|
||||
if err := vm.ChainVM.SetPreference(ctx, innerParentID); err != nil {
|
||||
vm.logger.Error("unexpected build block failure",
|
||||
log.String("reason", "failed to anchor the inner VM on the parent's inner block"),
|
||||
log.Stringer("parentID", parentID),
|
||||
log.Stringer("innerParentID", innerParentID),
|
||||
log.Err(err),
|
||||
)
|
||||
return fmt.Errorf("failed to anchor inner build parent %s: %w", innerParentID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vm *VM) WaitForEvent(ctx context.Context) (vmcore.Message, error) {
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
@@ -551,7 +634,7 @@ func (vm *VM) timeToBuild(ctx context.Context) (time.Time, bool, error) {
|
||||
parentTimestamp = blk.Timestamp()
|
||||
nextStartTime time.Time
|
||||
)
|
||||
currentTime := vm.Clock.Time().Truncate(time.Second)
|
||||
currentTime := vm.Clock.Time().Truncate(proposer.TimestampGranularity())
|
||||
if nextStartTime, err = vm.getPostDurangoSlotTime(
|
||||
ctx,
|
||||
childBlockHeight,
|
||||
@@ -602,7 +685,7 @@ func (vm *VM) timeToBuildPreForkTransitionLocked(ctx context.Context) (time.Time
|
||||
var (
|
||||
parentTimestamp = pre.Timestamp()
|
||||
childHeight = pre.Height() + 1
|
||||
currentTime = vm.Clock.Time().Truncate(time.Second)
|
||||
currentTime = vm.Clock.Time().Truncate(proposer.TimestampGranularity())
|
||||
slot = proposer.TimeToSlot(parentTimestamp, currentTime)
|
||||
)
|
||||
// MinDelayForProposer returns the delay until THIS node's earliest slot in the
|
||||
@@ -783,35 +866,51 @@ func (vm *VM) repairAcceptedChainByHeight(ctx context.Context) error {
|
||||
return nil
|
||||
|
||||
case heightBehind:
|
||||
// INVARIANT VIOLATION and UNRECOVERABLE LOCALLY: the proposervm's finality
|
||||
// index sits BELOW the inner VM's accepted tip. In a correct system this
|
||||
// never happens — the proposervm last-accepted pointer and height index
|
||||
// commit in the SAME versiondb batch as every inner accept, so they cannot
|
||||
// lag. Reaching here means the on-disk proposervm state was truncated
|
||||
// relative to the inner EVM — e.g. a snapshot restored inconsistently across
|
||||
// the two databases (the devnet-C "index 7 < inner 8").
|
||||
// INVARIANT VIOLATION: the proposervm's finality index sits BELOW the inner
|
||||
// VM's accepted tip. Every post-fork accept commits the envelope, its height
|
||||
// entry and the last-accepted pointer in ONE versiondb batch BEFORE the inner
|
||||
// block is accepted, so this can only mean some accept advanced the inner VM
|
||||
// WITHOUT going through acceptPostForkBlock (the pre-fork fallback — now
|
||||
// prevented, see height_backfill.go) or that the on-disk proposervm state was
|
||||
// truncated relative to the inner EVM.
|
||||
//
|
||||
// We FAIL LOUD rather than "self-heal", because there is no correct local
|
||||
// heal: the proposervm cannot fabricate the missing outer wrapper blocks for
|
||||
// heights (pro, inner]. In particular, dropping the finality pointer
|
||||
// (DeleteLastAccepted) is NOT a heal — proposervm.LastAccepted() would then
|
||||
// fall back to the inner-namespace id (see LastAccepted), whose ParentID is
|
||||
// contiguity-incompatible with the network's OUTER wrappers, permanently
|
||||
// wedging bootstrap (the first-block anchor), catch-up (the parent==tip
|
||||
// guard) and live Verify (the parent lookup) at the inner tip — and since
|
||||
// every path skips blocks at height <= the tip, the missing wrapper is never
|
||||
// rebuilt. That silent wedge is strictly worse than this loud, actionable
|
||||
// stop. The correct remedy is operator action; surface it explicitly.
|
||||
return fmt.Errorf(
|
||||
"proposervm finality index (height %d, id %s) is BEHIND the inner VM tip (height %d, id %s): "+
|
||||
"the on-disk proposervm state is truncated/inconsistent relative to the inner EVM "+
|
||||
"(e.g. a snapshot restored inconsistently across the proposervm and EVM databases). "+
|
||||
"This cannot be repaired locally — the proposervm cannot rebuild the missing outer wrapper "+
|
||||
"blocks. RECOVERY: restore a snapshot that is consistent across BOTH databases, or fully "+
|
||||
"resync this node from peers (wipe this chain's db and re-bootstrap). Refusing to auto-reset "+
|
||||
"the finality pointer, which would silently wedge this node at the inner tip forever",
|
||||
proLastAcceptedHeight, proLastAcceptedID, innerLastAcceptedHeight, innerLastAcceptedID,
|
||||
// This used to be a hard init failure, which killed the chain on the node
|
||||
// ("non-critical chain failed to initialize chainAlias=C") and made every
|
||||
// restart fatal. It is now REPAIRED — outer-only, with no inner
|
||||
// re-execution:
|
||||
//
|
||||
// 1. re-derive the index from the outer envelopes already in this node's
|
||||
// block store, each bound to the inner block WE accepted at that height;
|
||||
// 2. if that cannot reach the tip, START ANYWAY in an explicit, loud,
|
||||
// build-gated backfill-pending state that BackfillOuterBlock completes.
|
||||
//
|
||||
// The finality pointer is only ever moved FORWARD onto a proven envelope and
|
||||
// is NEVER dropped: a DeleteLastAccepted here would make LastAccepted() fall
|
||||
// back to an inner-namespace id whose ParentID is contiguity-incompatible
|
||||
// with the network's outer wrappers, silently wedging bootstrap/catch-up/live
|
||||
// Verify at the inner tip forever.
|
||||
vm.logger.Warn("proposervm finality index is BEHIND the inner VM tip — repairing",
|
||||
log.Uint64("indexHeight", proLastAcceptedHeight),
|
||||
log.Stringer("indexID", proLastAcceptedID),
|
||||
log.Uint64("innerTipHeight", innerLastAcceptedHeight),
|
||||
log.Stringer("innerTipID", innerLastAcceptedID),
|
||||
)
|
||||
|
||||
reached, err := vm.rebuildOuterIndexFromStore(
|
||||
ctx, proLastAcceptedHeight, proLastAcceptedID, innerLastAcceptedHeight)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reached >= innerLastAcceptedHeight {
|
||||
vm.logger.Info("proposervm finality index REBUILT from the local block store — index and inner tip agree",
|
||||
log.Uint64("fromHeight", proLastAcceptedHeight),
|
||||
log.Uint64("toHeight", reached),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
vm.enterOuterBackfill(reached+1, innerLastAcceptedHeight, innerLastAcceptedID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// heightAhead: the inner vm is BEHIND the proposer vm (the inner rolled back or
|
||||
@@ -1028,16 +1127,67 @@ func (vm *VM) getPreForkBlock(ctx context.Context, blkID ids.ID) (*preForkBlock,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// ROOT-CAUSE GUARD (the finality-index lag). getBlock() falls back here
|
||||
// whenever [blkID] is not an OUTER envelope id — and the id the consensus
|
||||
// ledger records as canonical IS the inner block's id
|
||||
// (postForkCommonComponents.CanonicalID). Without this check, asking the
|
||||
// proposervm for a canonical id post-fork silently returns a preForkBlock
|
||||
// wrapping a post-fork inner block, whose Accept advances the inner VM and
|
||||
// leaves the outer index untouched (preForkBlock.acceptOuterBlk is a no-op).
|
||||
// That is how the index ends up BEHIND the inner tip while everything looks
|
||||
// healthy, and why the NEXT boot could not start the chain.
|
||||
//
|
||||
// Post-fork, a block at or above the recorded fork height is by definition NOT
|
||||
// a pre-fork block: there is no such thing as a legitimate pre-fork block at a
|
||||
// height the proposervm has already claimed. Refuse to construct it.
|
||||
if err := vm.refusePreForkAfterFork(engineBlk.Height()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &preForkBlock{
|
||||
Block: engineBlk,
|
||||
vm: vm,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// refusePreForkAfterFork returns errPreForkAfterFork when [height] is at or above
|
||||
// the recorded proposervm fork height. Before the fork (no fork height recorded)
|
||||
// every block is legitimately pre-fork and this is a no-op, so a chain that has
|
||||
// not yet transitioned is completely unaffected. ONE predicate, used by both the
|
||||
// construction guard (getPreForkBlock) and the accept guard
|
||||
// (preForkBlock.acceptOuterBlk), so the two can never disagree.
|
||||
func (vm *VM) refusePreForkAfterFork(height uint64) error {
|
||||
forkHeight, err := vm.State.GetForkHeight()
|
||||
if err == database.ErrNotFound {
|
||||
return nil // chain has not forked; everything is pre-fork
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if height < forkHeight {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: height %d >= fork height %d", errPreForkAfterFork, height, forkHeight)
|
||||
}
|
||||
|
||||
func (vm *VM) acceptPostForkBlock(blk PostForkBlock) error {
|
||||
height := blk.Height()
|
||||
blkID := blk.ID()
|
||||
|
||||
// EARLY DETECTOR for the finality-index lag. The index must advance one height
|
||||
// at a time; a jump means some accept moved the inner VM without moving the
|
||||
// index (the pre-fork fallback the guards above now refuse) and the gap will be
|
||||
// fatal at the next boot. Surfacing it HERE names the height where the hole
|
||||
// opened instead of leaving a post-mortem for the next restart. Not fatal: the
|
||||
// accept itself is correct and the boot-time rebuild can close a hole; refusing
|
||||
// finality on a warning would be the worse trade.
|
||||
if vm.lastAcceptedHeight != 0 && height != vm.lastAcceptedHeight+1 {
|
||||
vm.logger.Warn("proposervm finality index is NOT contiguous — a height was accepted without being indexed",
|
||||
log.Uint64("previousIndexedHeight", vm.lastAcceptedHeight),
|
||||
log.Uint64("acceptedHeight", height),
|
||||
log.Stringer("blkID", blkID),
|
||||
)
|
||||
}
|
||||
|
||||
vm.lastAcceptedHeight = height
|
||||
vm.forgetVerifiedBlock(blkID)
|
||||
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// vm_crashboot_test.go — CRASH-COPY RECOVERY ARMOR.
|
||||
//
|
||||
// Every recovery test before this file repaired the SAME warm VM value: its
|
||||
// Tree, verifiedBlocks map and caches were still populated, so a repair that
|
||||
// silently leaned on in-memory state would still have passed. These tests
|
||||
// remove that crutch. They copy the COMMITTED bytes out from under a RUNNING
|
||||
// proposervm — no Shutdown, no extra Commit beyond what the accept path itself
|
||||
// performed — and boot a SECOND, cold VM over the copy: a process kill plus
|
||||
// restart, byte-faithful. The recovered VM must
|
||||
//
|
||||
// 1. BOOT — repairAcceptedChainByHeight then setLastAcceptedMetadata, the
|
||||
// Initialize order — without error;
|
||||
// 2. AGREE with the source's committed view at the copy instant: finality
|
||||
// pointer, fork height, and an openable envelope at EVERY height; and
|
||||
// 3. BUILD — produce a child whose outer parent is the recovered tip and
|
||||
// whose inner parent is that tip's inner block. This is the
|
||||
// errInnerParentMismatch invariant (build_inner_parent_test.go) proven
|
||||
// from a cold boot on persisted state, not just at steady state.
|
||||
//
|
||||
// The matrix pins the persistence boundaries: nothing accepted yet, exactly
|
||||
// one accept (the fork-height record), a longer run, and a copy taken while
|
||||
// the source keeps accepting (the copy must be an immutable snapshot).
|
||||
// TestCrashBoot_OuterCommittedInnerNot pins the ONE window the accept path
|
||||
// leaves open: acceptPostForkBlock commits the outer batch BEFORE the inner
|
||||
// VM accepts, so a crash between the two strands the index one ahead — boot
|
||||
// must roll the pointer back onto the inner survivor and keep building.
|
||||
//
|
||||
// The shared harness lives in height_lag_repro_test.go.
|
||||
package proposervm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/ids"
|
||||
validatorstest "github.com/luxfi/validators/validatorstest"
|
||||
vmchain "github.com/luxfi/vm/chain"
|
||||
"github.com/luxfi/vm/chain/blocktest"
|
||||
)
|
||||
|
||||
// crashCopy copies every committed key/value out of a live base database into
|
||||
// a fresh memdb — a byte-level snapshot taken with the source still running.
|
||||
// This is exactly what a crash leaves behind: versiondb buffers uncommitted
|
||||
// writes in memory, and they die with the process.
|
||||
func crashCopy(t *testing.T, base database.Database) *memdb.Database {
|
||||
t.Helper()
|
||||
cp := memdb.New()
|
||||
it := base.NewIterator()
|
||||
defer it.Release()
|
||||
for it.Next() {
|
||||
if err := cp.Put(it.Key(), it.Value()); err != nil {
|
||||
t.Fatalf("crashCopy Put: %v", err)
|
||||
}
|
||||
}
|
||||
if err := it.Error(); err != nil {
|
||||
t.Fatalf("crashCopy iterator: %v", err)
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
// snapshot clones the inner chain as its own database would survive a crash at
|
||||
// this instant: every block ever inserted (verified-but-unaccepted included,
|
||||
// as the real EVM persists bodies on insert) with the acceptance tip pinned at
|
||||
// [tip]. The clone is immune to the source's subsequent progress.
|
||||
func (ic *innerChain) snapshot(tip uint64) *innerChain {
|
||||
cp := &innerChain{
|
||||
byHeight: make(map[uint64]*blocktest.Block, len(ic.byHeight)),
|
||||
byID: make(map[ids.ID]*blocktest.Block, len(ic.byID)),
|
||||
byBytes: make(map[string]*blocktest.Block, len(ic.byBytes)),
|
||||
tip: tip,
|
||||
}
|
||||
for h, b := range ic.byHeight {
|
||||
cp.byHeight[h] = b
|
||||
}
|
||||
for id, b := range ic.byID {
|
||||
cp.byID[id] = b
|
||||
}
|
||||
for s, b := range ic.byBytes {
|
||||
cp.byBytes[s] = b
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
// buildableVM is ic.vm() plus the two calls a build needs. SetPreference is
|
||||
// anchorInnerBuildParent's seam — it moves the head exactly as the EVM does.
|
||||
// BuildBlock mints a FRESH child of the head (building is not accepting, and a
|
||||
// replacement proposal after a crash is a new block, not the lost one).
|
||||
func (ic *innerChain) buildableVM() *blocktest.VM {
|
||||
vm := ic.vm()
|
||||
vm.SetPreferenceF = func(_ context.Context, id ids.ID) error {
|
||||
blk, ok := ic.byID[id]
|
||||
if !ok {
|
||||
return errors.New("inner: preference not in chain")
|
||||
}
|
||||
ic.tip = blk.HeightV
|
||||
return nil
|
||||
}
|
||||
vm.BuildBlockF = func(context.Context) (vmchain.Block, error) {
|
||||
parent := ic.byHeight[ic.tip]
|
||||
id := ids.GenerateTestID()
|
||||
child := &blocktest.Block{
|
||||
IDV: id,
|
||||
ParentV: parent.IDV,
|
||||
HeightV: parent.HeightV + 1,
|
||||
BytesV: append([]byte("inner"), id[:]...),
|
||||
TimestampV: parent.TimestampV.Add(time.Second),
|
||||
}
|
||||
ic.byID[id] = child
|
||||
ic.byBytes[string(child.BytesV)] = child
|
||||
return child, nil
|
||||
}
|
||||
return vm
|
||||
}
|
||||
|
||||
// bootRecoveredVM stands a COLD VM up over copied bytes and runs the boot
|
||||
// sequence in Initialize order. Windower/validator-state/no-op wiring matches
|
||||
// build_inner_parent_test.go: every slot open, so builds take the unsigned
|
||||
// path and the recovery properties are the only thing under test.
|
||||
func bootRecoveredVM(t *testing.T, ic *innerChain, base database.Database) *VM {
|
||||
t.Helper()
|
||||
vm := testVMOnBase(t, ic, base)
|
||||
vm.ChainVM = ic.buildableVM()
|
||||
vm.Windower = anyoneCanProposeWindower{}
|
||||
vm.validatorState = &validatorstest.State{
|
||||
GetCurrentHeightF: func(context.Context) (uint64, error) { return 0, nil },
|
||||
}
|
||||
if err := vm.repairAcceptedChainByHeight(context.Background()); err != nil {
|
||||
t.Fatalf("BOOT KILLED THE CHAIN (repair): %v", err)
|
||||
}
|
||||
if err := vm.setLastAcceptedMetadata(context.Background()); err != nil {
|
||||
t.Fatalf("BOOT KILLED THE CHAIN (metadata): %v", err)
|
||||
}
|
||||
return vm
|
||||
}
|
||||
|
||||
// mustBuildOnRecoveredTip drives the recovered VM's REAL public build path —
|
||||
// SetPreference(last accepted) then BuildBlock, what the engine does after a
|
||||
// restart — and asserts the built child extends the recovered tip on BOTH
|
||||
// layers: outer parent == the tip envelope, inner parent == the tip's inner
|
||||
// block. A recovered node failing either would reject its own proposal.
|
||||
func mustBuildOnRecoveredTip(t *testing.T, vm *VM, ic *innerChain) {
|
||||
t.Helper()
|
||||
require := require.New(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Deterministic clock strictly after the recovered tip's timestamp
|
||||
// (harness blocks carry time.Unix(height, 0)).
|
||||
vm.Clock.Set(time.Unix(int64(ic.tip)+2, 0))
|
||||
|
||||
lastAccepted, err := vm.LastAccepted(ctx)
|
||||
require.NoError(err)
|
||||
require.NoError(vm.SetPreference(ctx, lastAccepted),
|
||||
"the recovered tip must be fetchable — SetPreference validates before assigning")
|
||||
|
||||
built, err := vm.BuildBlock(ctx)
|
||||
require.NoError(err, "a crash-recovered node must be able to build")
|
||||
child, ok := built.(*postForkBlock)
|
||||
require.True(ok, "the child of a recovered tip is a post-fork envelope")
|
||||
|
||||
wantInnerParent := ic.byHeight[ic.tip].IDV
|
||||
require.Equal(wantInnerParent, child.innerBlk.Parent(),
|
||||
"the built child's inner parent must be the recovered tip's inner block "+
|
||||
"(errInnerParentMismatch invariant, from a cold boot)")
|
||||
require.Equal(ic.tip+1, child.Height())
|
||||
require.Equal(lastAccepted, child.Parent(),
|
||||
"the built child's outer parent must be the recovered finality pointer")
|
||||
}
|
||||
|
||||
// TestCrashBoot_ColdVMOverDirtyCopy_AgreesAndBuilds is the persistence-boundary
|
||||
// matrix: commit-state copies taken with NO shutdown, each booted by a second,
|
||||
// cold VM that must agree with the source's committed view at the copy instant
|
||||
// and then build on it.
|
||||
func TestCrashBoot_ColdVMOverDirtyCopy_AgreesAndBuilds(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
accepted uint64 // heights accepted through the proposervm before the copy
|
||||
runOnAfter uint64 // heights the SOURCE keeps accepting after the copy
|
||||
}{
|
||||
// Nothing ever accepted through the proposervm: no fork height, no
|
||||
// outer state. Boot must be a no-op and the pre-fork world intact.
|
||||
{name: "nothing_accepted", accepted: 0, runOnAfter: 0},
|
||||
// Exactly one accept — the boundary that records the fork height.
|
||||
{name: "first_accept_records_fork", accepted: 1, runOnAfter: 0},
|
||||
{name: "several_accepted", accepted: 8, runOnAfter: 0},
|
||||
// The copy is taken and the source RUNS ON: later accepts must not
|
||||
// leak into the recovered view (the copy is an immutable snapshot).
|
||||
{name: "copy_while_source_runs_on", accepted: 5, runOnAfter: 3},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
ctx := context.Background()
|
||||
|
||||
ic := newInnerChain(t, tt.accepted+tt.runOnAfter+2)
|
||||
base := memdb.New()
|
||||
src := testVMOnBase(t, ic, base)
|
||||
outers := acceptThroughProposervm(t, src, ic, tt.accepted)
|
||||
|
||||
// THE COPY — no Shutdown, the source stays live. Snapshot both
|
||||
// layers at the same instant, as one machine's disk would be.
|
||||
cp := crashCopy(t, base)
|
||||
icAtCopy := ic.snapshot(ic.tip)
|
||||
|
||||
if tt.runOnAfter > 0 {
|
||||
acceptRangeThroughProposervm(
|
||||
t, src, ic, tt.accepted+1, tt.accepted+tt.runOnAfter, outers[tt.accepted].ID())
|
||||
if got := indexHeight(t, src); got != tt.accepted+tt.runOnAfter {
|
||||
t.Fatalf("source must have run on to %d, at %d", tt.accepted+tt.runOnAfter, got)
|
||||
}
|
||||
}
|
||||
|
||||
vm2 := bootRecoveredVM(t, icAtCopy, cp)
|
||||
|
||||
if tt.accepted == 0 {
|
||||
// Pre-fork world: no outer state exists, none may be invented.
|
||||
_, err := vm2.State.GetLastAccepted()
|
||||
require.ErrorIs(err, database.ErrNotFound)
|
||||
_, err = vm2.State.GetForkHeight()
|
||||
require.ErrorIs(err, database.ErrNotFound)
|
||||
// LastAccepted falls through to the inner chain.
|
||||
la, err := vm2.LastAccepted(ctx)
|
||||
require.NoError(err)
|
||||
require.Equal(icAtCopy.byHeight[icAtCopy.tip].IDV, la)
|
||||
} else {
|
||||
// The recovered finality pointer is the copy-instant tip —
|
||||
// not genesis, not the source's later progress.
|
||||
got, err := vm2.State.GetLastAccepted()
|
||||
require.NoError(err)
|
||||
require.Equal(outers[tt.accepted].ID(), got)
|
||||
require.Equal(mustForkHeight(t, src), mustForkHeight(t, vm2))
|
||||
|
||||
// Every height's envelope is indexed AND openable cold.
|
||||
for h := uint64(1); h <= tt.accepted; h++ {
|
||||
id, err := vm2.State.GetBlockIDAtHeight(h)
|
||||
require.NoError(err, "height %d must be indexed", h)
|
||||
require.Equal(outers[h].ID(), id, "height %d", h)
|
||||
blk, err := vm2.getPostForkBlock(ctx, id)
|
||||
require.NoError(err, "envelope at height %d must be openable on the recovered VM", h)
|
||||
require.Equal(h, blk.Height())
|
||||
}
|
||||
}
|
||||
if _, _, pending := vm2.NeedsOuterBackfill(); pending {
|
||||
t.Fatal("a lock-step copy has no gap — no backfill may be pending")
|
||||
}
|
||||
|
||||
mustBuildOnRecoveredTip(t, vm2, icAtCopy)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrashBoot_OuterCommittedInnerNot_RollsBackAndBuilds pins the one crash
|
||||
// window the accept path leaves open. acceptPostForkBlock commits the envelope,
|
||||
// height index and finality pointer in ONE batch BEFORE the inner VM accepts
|
||||
// (that ordering is why the index can never legitimately fall behind). A crash
|
||||
// inside the window therefore persists an index one AHEAD of the inner
|
||||
// survivor. Boot must take repairAcceptedChainByHeight's roll-back arm — move
|
||||
// the pointer back onto the height the inner VM survived at — and the node
|
||||
// must then build its replacement proposal there.
|
||||
func TestCrashBoot_OuterCommittedInnerNot_RollsBackAndBuilds(t *testing.T) {
|
||||
require := require.New(t)
|
||||
ctx := context.Background()
|
||||
|
||||
ic := newInnerChain(t, 8)
|
||||
base := memdb.New()
|
||||
src := testVMOnBase(t, ic, base)
|
||||
outers := acceptThroughProposervm(t, src, ic, 4)
|
||||
require.Equal(uint64(4), ic.tip, "precondition: lock-step through 4")
|
||||
|
||||
// Height 5's outer accept COMMITS…
|
||||
sb := outerFor(t, ic, 5, outers[4].ID())
|
||||
blk := &postForkBlock{
|
||||
SignedBlock: sb,
|
||||
postForkCommonComponents: postForkCommonComponents{vm: src, innerBlk: ic.byHeight[5]},
|
||||
}
|
||||
src.Tree.Add(ic.byHeight[5])
|
||||
require.NoError(blk.Accept(ctx))
|
||||
// …and the process dies BEFORE the inner VM accepts 5 (no ic.accept(5)).
|
||||
|
||||
cp := crashCopy(t, base)
|
||||
icAtCopy := ic.snapshot(4)
|
||||
vm2 := bootRecoveredVM(t, icAtCopy, cp)
|
||||
|
||||
got, err := vm2.State.GetLastAccepted()
|
||||
require.NoError(err)
|
||||
require.Equal(outers[4].ID(), got,
|
||||
"boot must roll the finality pointer back onto the height the inner VM survived at")
|
||||
|
||||
// The node re-proposes at 5: a NEW envelope over a NEW inner child of 4 —
|
||||
// the lost block is gone, not resurrected.
|
||||
mustBuildOnRecoveredTip(t, vm2, icAtCopy)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"github.com/luxfi/crypto/secp256k1"
|
||||
"github.com/luxfi/go-bip32"
|
||||
"github.com/luxfi/go-bip39"
|
||||
"github.com/luxfi/keys"
|
||||
"github.com/luxfi/kms/pkg/mnemonic"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -31,10 +31,10 @@ const (
|
||||
|
||||
// MustLoadKey loads a secp256k1 private key from (in order of priority):
|
||||
// 1. MNEMONIC environment variable (BIP-39 mnemonic phrase)
|
||||
// 2. via native ZAP — when KMS_ADDR + KMS_ENV +
|
||||
// 2. via native ZAP — when KMS_ADDR + KMS_ENV +
|
||||
// KMS_MNEMONIC_PATH are set in the environment. Uses the canonical
|
||||
// luxfi/kms keys.LoadMnemonic loader so every Lux-derived
|
||||
// service resolves keys the same way.
|
||||
// kms/pkg/mnemonic loader so every Lux-derived service resolves
|
||||
// keys the same way.
|
||||
// 3. Key name provided as first command-line argument
|
||||
// 4. ~/.lux/keys/default/ if it exists
|
||||
//
|
||||
@@ -58,28 +58,31 @@ func MustLoadKey() *secp256k1.PrivateKey {
|
||||
// LoadKey attempts to load a private key using the priority order above.
|
||||
func LoadKey() (*secp256k1.PrivateKey, error) {
|
||||
// 1. MNEMONIC env var — local dev + CI test seam.
|
||||
if mnemonic := strings.TrimSpace(os.Getenv("MNEMONIC")); mnemonic != "" {
|
||||
return keyFromMnemonic(mnemonic)
|
||||
if phrase := strings.TrimSpace(os.Getenv("MNEMONIC")); phrase != "" {
|
||||
return keyFromMnemonic(phrase)
|
||||
}
|
||||
|
||||
// 2. via native ZAP — production path for any Lux-derived
|
||||
// service running under a KMS-projected env. The canonical loader
|
||||
// lives in luxfi/keys (alongside the BIP-39 derivation primitives)
|
||||
// so luxd, netrunner, lux/cli, and every descending L1's bootstrap
|
||||
// all resolve mnemonics the same way.
|
||||
// Trust at the network boundary (NetworkPolicy + ZAP wire) — the
|
||||
// staking material itself comes from the KMS-held operational
|
||||
// mnemonic at KMS_MNEMONIC_PATH (default /mnemonic).
|
||||
// lives in kms/pkg/mnemonic, not luxfi/keys: keys must not import
|
||||
// kms, because kms already imports keys for ServiceIdentity, and
|
||||
// the back edge would close an import cycle. luxd, netrunner,
|
||||
// lux/cli, and every descending L1's bootstrap all resolve
|
||||
// mnemonics through it.
|
||||
// Trust at the network boundary (NetworkPolicy + ZAP wire) — hence
|
||||
// a nil ServiceIdentity — the staking material itself comes from
|
||||
// the KMS-held operational mnemonic at KMS_MNEMONIC_PATH.
|
||||
if addr := os.Getenv("KMS_ADDR"); addr != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
mnemonic, err := keys.LoadMnemonicFromKMS(ctx, addr,
|
||||
phrase, err := mnemonic.LoadFromKMS(ctx, addr,
|
||||
os.Getenv("KMS_ENV"),
|
||||
envOr("KMS_MNEMONIC_PATH", "/mnemonic"))
|
||||
envOr("KMS_MNEMONIC_PATH", "/mnemonic"),
|
||||
nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load mnemonic from KMS: %w", err)
|
||||
}
|
||||
return keyFromMnemonic(mnemonic)
|
||||
return keyFromMnemonic(phrase)
|
||||
}
|
||||
|
||||
// 3. Key name from command-line arguments.
|
||||
|
||||
Reference in New Issue
Block a user