Files
evm/precompile/contracts/stakeweight/mainnet_fork_test.go
zeekayandHanzo Dev e8187ae5a5 Dial /v1, the only prefix luxd serves
luxd pins baseURL="/v1" (node/server/http/server.go:33) and has never
served /ext, so both exported constructors — plugin/evm/client.NewClient
and warp.NewClient — built a path no node answers. They are the seam
every out-of-tree caller goes through: cli/pkg/utils/staking.go,
sdk/validatormanager/uptime.go and netrunner/api all hand them a bare
node URI, and staking.go's own SplitRPCURI already parsed /v1/bc/<id>/rpc.
Those callers were already on /v1; this package was the lagging outlier,
so fixing it here fixes all of them without touching one.

Also corrects the simulator's default websocket endpoint, the hardhat
default, and the docs — including api.testnet.lux.network, which does not
serve RPC at all. The testnet host is api.lux-test.network (chainId
0x17870). Prefix-only sweeps miss that class of error.

Tests pin the constructed path against an httptest server, with both an
alias and a blockchain ID, so a future prefix change fails loudly instead
of silently 404ing in production.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 19:07:09 -07:00

198 lines
7.9 KiB
Go

// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package stakeweight
import (
"encoding/hex"
"testing"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/crypto/bls/signer/localsigner"
"github.com/luxfi/evm/precompile/precompileconfig"
"github.com/luxfi/evm/utils"
"github.com/luxfi/ids"
validators "github.com/luxfi/validators"
"github.com/luxfi/vm/chain"
"github.com/stretchr/testify/require"
)
// Read-only fork of Lux mainnet (chain ID 96369). Captured from
// platform.getCurrentValidators on https://api.lux.network/v1/bc/P while
// C-chain eth_blockNumber was 0x10c1cf. Nothing here writes to mainnet; the
// production validator set is used purely as fixture state.
//
// Every entry carries the validator's REAL registered BLS key (the P-chain
// `signer.publicKey`, 48-byte compressed) — the same key the precompile checks
// authorisations against.
var mainnetValidators = []struct {
nodeID string
pk string
weight uint64
}{
{"NodeID-DwsrqSkPoE3pXWrUt9nkJ5yBycwRQ246X", "8533063955b58b05a188baea620dfd35894657424e92ca378cc6b51f1fba4ded475265d59272f949e0a097f923211b1c", 500_000_000_000_000_000},
{"NodeID-8mY2fhUehN27v3LCU84BnnKEoeRfd2weC", "85e48ceb16986dc86796fced851749c303441d9ae7b2b639b4b5eea6dd94b21193981e518863d961210e57ec7e1e7d6c", 500_000_000_000_000_000},
{"NodeID-Ld9VFBQ9zGbd79z2vzaAqkQ3jHuqbtRpo", "865e60e9d5973af6b0c6d003afa104d337e8bf9ac8b8a711e260626781886073ce79b3efc3a8fd1534a4069c90b88182", 500_000_000_000_000_000},
{"NodeID-2TwSZ2oyeBK2mv7JiseEQ8m74rotDj4QR", "84e20183f945fea4e08eb126338763a0508b15409f5fbb7e525013d5134069b1e1037226dc6bc295ab18d9c7605368cb", 500_000_000_000_000_000},
{"NodeID-Mf3JfSY91oDwfBqf7rCLmhg4NDtDghw1f", "a9503da5b7b1fc48f6f699677ee5ba577b8cae2b6a98faa03fc1c8e33cafb075dd3ea93321fc3c13f8e22524bc25b17b", 500_000_000_000_000_000},
}
// mainnetPChainHeight is what platform.getHeight answers on 96369 today: the
// P-chain has produced no block beyond genesis, so every C-chain block is
// verified against P-chain height 0 and the validator set has never moved.
const mainnetPChainHeight uint64 = 0
func forkedMainnetSet(t testing.TB) vdrSet {
t.Helper()
set := make(vdrSet, len(mainnetValidators))
for _, v := range mainnetValidators {
nodeID, err := ids.NodeIDFromString(v.nodeID)
require.NoError(t, err)
pk, err := hex.DecodeString(v.pk)
require.NoError(t, err)
require.Len(t, pk, bls.PublicKeyLen)
// Every registered key must actually decode; a validator whose key does
// not is one that can never authorise a voter.
_, err = bls.PublicKeyFromCompressedBytes(pk)
require.NoError(t, err, "mainnet validator %s has an undecodable registered key", v.nodeID)
set[nodeID] = &validators.GetValidatorOutput{NodeID: nodeID, PublicKey: pk, Weight: v.weight}
}
require.Len(t, set, len(mainnetValidators), "duplicate nodeID in the fork fixture")
return set
}
// The electorate and the tally denominator come straight from the forked set.
func TestForkMainnetElectorate(t *testing.T) {
require := require.New(t)
set := forkedMainnetSet(t)
total, err := totalWeight(set)
require.NoError(err)
require.Equal(uint64(2_500_000_000_000_000_000), total)
require.Equal(5, len(set))
// One map entry per nodeID is what makes double-counting a delegation
// impossible: a delegator's stake is folded into its validator's single
// weight, never listed separately. Mainnet has no delegations today
// (delegatorWeight "0" on all five), so weight == own stake here.
for _, vdr := range set {
require.Equal(uint64(500_000_000_000_000_000), vdr.Weight)
}
}
// Nobody can vote a real mainnet validator's weight without that validator's
// registered secret key — including whoever can write arbitrary ballots.
func TestForkMainnetStakeIsNotVotableWithoutTheRegisteredKey(t *testing.T) {
require := require.New(t)
chainID := ids.GenerateTestID()
set := forkedMainnetSet(t)
ctx := newConsensusCtx(t, chainID, map[uint64]vdrSet{mainnetPChainHeight: set})
attacker, err := localsigner.New()
require.NoError(err)
for _, v := range mainnetValidators {
nodeID, err := ids.NodeIDFromString(v.nodeID)
require.NoError(err)
ballot := Ballot{
NodeID: nodeID,
Voter: voterAddr,
AuthEpoch: 1,
PChainHeight: mainnetPChainHeight,
Weight: v.weight,
TotalWeight: 2_500_000_000_000_000_000,
}
sig, err := attacker.Sign(ballot.SigningBytes(chainID))
require.NoError(err)
copy(ballot.Signature[:], bls.SignatureToBytes(sig))
err = NewConfig(utils.NewUint64(0)).VerifyPredicate(&precompileconfig.PredicateContext{
ConsensusCtx: ctx,
ProposerVMBlockCtx: &chain.Context{PChainHeight: mainnetPChainHeight},
}, predicateBytes(&ballot))
require.ErrorIs(err, errUnauthorizedVoter, "forged ballot accepted for %s", v.nodeID)
}
}
// End-to-end against the forked set: one validator authorises an EVM address,
// and that address votes the validator's real mainnet weight out of the real
// mainnet total. The authorising key stands in for the operator's — the secret
// halves of the production keys are, correctly, not available here.
func TestForkMainnetAuthorizedVoteCarriesRealWeight(t *testing.T) {
require := require.New(t)
chainID := ids.GenerateTestID()
set := forkedMainnetSet(t)
nodeID, err := ids.NodeIDFromString(mainnetValidators[0].nodeID)
require.NoError(err)
operator, err := localsigner.New()
require.NoError(err)
set[nodeID].PublicKey = bls.PublicKeyToCompressedBytes(operator.PublicKey())
ctx := newConsensusCtx(t, chainID, map[uint64]vdrSet{mainnetPChainHeight: set})
cfg := NewConfig(utils.NewUint64(0))
predicateCtx := &precompileconfig.PredicateContext{
ConsensusCtx: ctx,
ProposerVMBlockCtx: &chain.Context{PChainHeight: mainnetPChainHeight},
}
ballot := Ballot{
NodeID: nodeID,
Voter: voterAddr,
AuthEpoch: 1,
PChainHeight: mainnetPChainHeight,
Weight: mainnetValidators[0].weight,
TotalWeight: 2_500_000_000_000_000_000,
}
sig, err := operator.Sign(ballot.SigningBytes(chainID))
require.NoError(err)
copy(ballot.Signature[:], bls.SignatureToBytes(sig))
require.NoError(cfg.VerifyPredicate(predicateCtx, predicateBytes(&ballot)))
// The same authorisation is reusable for every later proposal — no
// re-signing, no registry write, nobody to ask.
second := ballot
second.Weight = 1
require.NoError(cfg.VerifyPredicate(predicateCtx, predicateBytes(&second)))
// 5e17 of 2.5e18 is exactly one fifth: a single mainnet validator is 20% of
// the electorate, so any threshold above 20% needs a second one.
require.Equal(uint64(5), ballot.TotalWeight/ballot.Weight)
// And Run() hands Solidity those exact numbers once the block records the pass.
ret, _, err := runGetVerifiedStakeWeight(t, predicateBytes(&ballot), true, nil, GetVerifiedStakeWeightGasCost)
require.NoError(err)
out, err := UnpackGetVerifiedStakeWeightOutput(ret)
require.NoError(err)
require.True(out.Valid)
require.Equal(ballot.Weight, out.StakeWeight.Weight.Uint64())
require.Equal(ballot.TotalWeight, out.StakeWeight.TotalWeight.Uint64())
}
// P-chain height 0 is the only height mainnet can be read at today, and the
// bound still holds: a ballot cannot name a height the block has not reached.
func TestForkMainnetHeightZeroBound(t *testing.T) {
require := require.New(t)
chainID := ids.GenerateTestID()
set := forkedMainnetSet(t)
nodeID, err := ids.NodeIDFromString(mainnetValidators[0].nodeID)
require.NoError(err)
ctx := newConsensusCtx(t, chainID, map[uint64]vdrSet{mainnetPChainHeight: set})
ballot := Ballot{
NodeID: nodeID,
Voter: voterAddr,
AuthEpoch: 1,
PChainHeight: 1, // one past what the P-chain has finalized
Weight: mainnetValidators[0].weight,
TotalWeight: 2_500_000_000_000_000_000,
}
err = NewConfig(utils.NewUint64(0)).VerifyPredicate(&precompileconfig.PredicateContext{
ConsensusCtx: ctx,
ProposerVMBlockCtx: &chain.Context{PChainHeight: mainnetPChainHeight},
}, predicateBytes(&ballot))
require.ErrorIs(err, errSnapshotInFuture)
}