Files
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

378 lines
13 KiB
Go

// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package stakeweight
import (
"context"
"testing"
"github.com/luxfi/constants"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/crypto/bls/signer/localsigner"
"github.com/luxfi/evm/precompile/precompileconfig"
"github.com/luxfi/evm/precompile/precompiletest"
"github.com/luxfi/evm/predicate"
"github.com/luxfi/evm/utils"
"github.com/luxfi/evm/utils/utilstest"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
consensuscontext "github.com/luxfi/runtime"
validators "github.com/luxfi/validators"
"github.com/luxfi/validators/validatorstest"
"github.com/luxfi/vm/chain"
"github.com/stretchr/testify/require"
)
// Heights used throughout: the snapshot a proposal pins, and a later height at
// which ballots land.
const (
snapshotHeight uint64 = 100
blockHeight uint64 = 120
)
var (
// Mainnet-shaped numbers, measured 2026-07 from
// platform.getCurrentValidators on https://api.lux.network/v1/bc/P:
// 5 validators, each weight 5e17 nLUX, total 2.5e18.
mainnetVdrWeight uint64 = 500_000_000_000_000_000
mainnetTotalWeight uint64 = 2_500_000_000_000_000_000
voterAddr = common.HexToAddress("0x9011E888251AB053B7bD1cdB598Db4f9DEd94714")
)
type testValidator struct {
nodeID ids.NodeID
signer *localsigner.LocalSigner
pkBytes []byte
}
func newTestValidator(t testing.TB) *testValidator {
t.Helper()
sk, err := localsigner.New()
require.NoError(t, err)
return &testValidator{
nodeID: ids.GenerateTestNodeID(),
signer: sk,
pkBytes: bls.PublicKeyToCompressedBytes(sk.PublicKey()),
}
}
func (v *testValidator) output(weight uint64) *validators.GetValidatorOutput {
return &validators.GetValidatorOutput{
NodeID: v.nodeID,
PublicKey: v.pkBytes,
Weight: weight,
}
}
type vdrSet map[ids.NodeID]*validators.GetValidatorOutput
// setsByHeight backs a validator state that answers differently per height —
// the whole point of snapshot semantics.
func newConsensusCtx(t testing.TB, chainID ids.ID, setsByHeight map[uint64]vdrSet) context.Context {
t.Helper()
base := &validatorstest.State{
GetValidatorSetF: func(_ context.Context, height uint64, netID ids.ID) (map[ids.NodeID]*validators.GetValidatorOutput, error) {
require.Equal(t, constants.PrimaryNetworkID, netID, "stake weight must read the PRIMARY NETWORK set")
set, ok := setsByHeight[height]
if !ok {
return map[ids.NodeID]*validators.GetValidatorOutput{}, nil
}
return set, nil
},
}
rt := utilstest.NewTestRuntime(t, chainID)
rt.ValidatorState = utilstest.NewTestValidatorStateFromBase(base)
return consensuscontext.WithContext(context.Background(), rt)
}
// signedBallot returns a ballot whose authorisation signature is valid for
// (chainID, nodeID, voter, authEpoch).
func signedBallot(t testing.TB, v *testValidator, chainID ids.ID, b Ballot) *Ballot {
t.Helper()
b.NodeID = v.nodeID
sig, err := v.signer.Sign(b.SigningBytes(chainID))
require.NoError(t, err)
copy(b.Signature[:], bls.SignatureToBytes(sig))
return &b
}
func predicateBytes(b *Ballot) []byte {
return predicate.PackPredicate(b.Bytes())
}
func newTest(ctx context.Context, b *Ballot, expectedErr error) precompiletest.PredicateTest {
return precompiletest.PredicateTest{
Config: NewConfig(utils.NewUint64(0)),
PredicateContext: &precompileconfig.PredicateContext{
ConsensusCtx: ctx,
ProposerVMBlockCtx: &chain.Context{PChainHeight: blockHeight},
},
PredicateBytes: predicateBytes(b),
Gas: PredicateGasCost,
ExpectedErr: expectedErr,
}
}
func TestVerifyPredicate(t *testing.T) {
chainID := ids.GenerateTestID()
vdr := newTestValidator(t)
other := newTestValidator(t)
// The primary-network set at both heights. Every validator's weight ALREADY
// includes the LUX delegated to it — the P-chain folds each delegator's
// weight into the validator's single entry (node
// vms/platformvm/state/state_validators.go: AddStaker(own stake) followed by
// AddWeight(delegator weight) under the same nodeID). There is exactly one
// map entry per nodeID, so a delegation cannot be counted twice.
steady := vdrSet{
vdr.nodeID: vdr.output(mainnetVdrWeight),
other.nodeID: other.output(mainnetTotalWeight - mainnetVdrWeight),
}
ctx := newConsensusCtx(t, chainID, map[uint64]vdrSet{
snapshotHeight: steady,
blockHeight: steady,
})
valid := Ballot{
Voter: voterAddr,
AuthEpoch: 1,
PChainHeight: snapshotHeight,
Weight: mainnetVdrWeight,
TotalWeight: mainnetTotalWeight,
}
tests := map[string]precompiletest.PredicateTest{
"valid ballot": newTest(ctx, signedBallot(t, vdr, chainID, valid), nil),
"under-claiming is allowed": newTest(ctx, signedBallot(t, vdr, chainID, func() Ballot {
b := valid
b.Weight = 1
return b
}()), nil),
"claiming more than the snapshot weight": newTest(ctx, signedBallot(t, vdr, chainID, func() Ballot {
b := valid
b.Weight = mainnetVdrWeight + 1
return b
}()), errWeightOverstated),
"zero weight": newTest(ctx, signedBallot(t, vdr, chainID, func() Ballot {
b := valid
b.Weight = 0
return b
}()), errZeroWeight),
"understated total inflates the share": newTest(ctx, signedBallot(t, vdr, chainID, func() Ballot {
b := valid
b.TotalWeight = mainnetVdrWeight
return b
}()), errTotalWeightMismatch),
"snapshot height in the future": newTest(ctx, signedBallot(t, vdr, chainID, func() Ballot {
b := valid
b.PChainHeight = blockHeight + 1
return b
}()), errSnapshotInFuture),
"nodeID absent at the snapshot height": newTest(ctx, signedBallot(t, newTestValidator(t), chainID, valid), errNotAValidator),
// Signature bound to a different voter address: an attacker who copies a
// ballot cannot re-point it at itself.
"signature does not cover this voter": newTest(ctx, func() *Ballot {
b := signedBallot(t, vdr, chainID, valid)
b.Voter = common.HexToAddress("0xdead")
return b
}(), errUnauthorizedVoter),
"signature does not cover this epoch": newTest(ctx, func() *Ballot {
b := signedBallot(t, vdr, chainID, valid)
b.AuthEpoch = 2
return b
}(), errUnauthorizedVoter),
"signature minted for another chain": newTest(ctx, signedBallot(t, vdr, ids.GenerateTestID(), valid), errUnauthorizedVoter),
"signature by a different validator's key": newTest(ctx, func() *Ballot {
b := signedBallot(t, other, chainID, valid)
b.NodeID = vdr.nodeID // claim vdr's weight with other's signature
return b
}(), errUnauthorizedVoter),
"garbage signature": newTest(ctx, func() *Ballot {
b := signedBallot(t, vdr, chainID, valid)
b.Signature = [bls.SignatureLen]byte{}
return b
}(), errInvalidSignature),
}
precompiletest.RunPredicateTests(t, tests)
}
// A validator that has left the set, or whose weight shrank because a
// delegation expired, cannot vote the weight it had at the snapshot.
func TestVerifyPredicateLiveness(t *testing.T) {
chainID := ids.GenerateTestID()
vdr := newTestValidator(t)
other := newTestValidator(t)
snapshot := vdrSet{
vdr.nodeID: vdr.output(mainnetVdrWeight),
other.nodeID: other.output(mainnetTotalWeight - mainnetVdrWeight),
}
valid := Ballot{
Voter: voterAddr,
AuthEpoch: 1,
PChainHeight: snapshotHeight,
Weight: mainnetVdrWeight,
TotalWeight: mainnetTotalWeight,
}
exited := newConsensusCtx(t, chainID, map[uint64]vdrSet{
snapshotHeight: snapshot,
blockHeight: {other.nodeID: other.output(mainnetTotalWeight - mainnetVdrWeight)},
})
// endTime passed / stake withdrawn between the snapshot and the ballot.
shrunk := newConsensusCtx(t, chainID, map[uint64]vdrSet{
snapshotHeight: snapshot,
blockHeight: {
vdr.nodeID: vdr.output(mainnetVdrWeight / 5), // delegations expired
other.nodeID: other.output(mainnetTotalWeight - mainnetVdrWeight),
},
})
// Stake ADDED after the snapshot must not count: the snapshot bound applies.
flash := newConsensusCtx(t, chainID, map[uint64]vdrSet{
snapshotHeight: snapshot,
blockHeight: {
vdr.nodeID: vdr.output(mainnetVdrWeight * 4),
other.nodeID: other.output(mainnetTotalWeight - mainnetVdrWeight),
},
})
tests := map[string]precompiletest.PredicateTest{
"validator exited before the ballot landed": newTest(exited, signedBallot(t, vdr, chainID, valid), errValidatorNotLive),
"weight shrank after the snapshot": newTest(shrunk, signedBallot(t, vdr, chainID, valid), errWeightOverstated),
"shrunk validator may still vote its live weight": newTest(shrunk, signedBallot(t, vdr, chainID, func() Ballot {
b := valid
b.Weight = mainnetVdrWeight / 5
return b
}()), nil),
"flash stake cannot exceed the snapshot": newTest(flash, signedBallot(t, vdr, chainID, func() Ballot {
b := valid
b.Weight = mainnetVdrWeight * 4
return b
}()), errWeightOverstated),
"flash staker still votes only its snapshot weight": newTest(flash, signedBallot(t, vdr, chainID, valid), nil),
}
precompiletest.RunPredicateTests(t, tests)
}
// A ballot whose snapshot height equals the block height must take the
// single-lookup path and reach the same verdict.
func TestVerifyPredicateSameHeight(t *testing.T) {
chainID := ids.GenerateTestID()
vdr := newTestValidator(t)
set := vdrSet{vdr.nodeID: vdr.output(mainnetVdrWeight)}
ctx := newConsensusCtx(t, chainID, map[uint64]vdrSet{blockHeight: set})
b := signedBallot(t, vdr, chainID, Ballot{
Voter: voterAddr,
AuthEpoch: 1,
PChainHeight: blockHeight,
Weight: mainnetVdrWeight,
TotalWeight: mainnetVdrWeight,
})
newTest(ctx, b, nil).Run(t)
}
func TestVerifyPredicateContextFailures(t *testing.T) {
require := require.New(t)
chainID := ids.GenerateTestID()
vdr := newTestValidator(t)
set := vdrSet{vdr.nodeID: vdr.output(mainnetVdrWeight)}
b := signedBallot(t, vdr, chainID, Ballot{
Voter: voterAddr,
AuthEpoch: 1,
PChainHeight: snapshotHeight,
Weight: mainnetVdrWeight,
TotalWeight: mainnetVdrWeight,
})
cfg := NewConfig(utils.NewUint64(0))
// No ProposerVM context => no agreed P-chain height => refuse.
err := cfg.VerifyPredicate(&precompileconfig.PredicateContext{
ConsensusCtx: newConsensusCtx(t, chainID, map[uint64]vdrSet{snapshotHeight: set, blockHeight: set}),
}, predicateBytes(b))
require.ErrorIs(err, errMissingProposerVMCtx)
// No validator state in the consensus context => refuse.
rt := utilstest.NewTestRuntime(t, chainID)
rt.ValidatorState = nil
err = cfg.VerifyPredicate(&precompileconfig.PredicateContext{
ConsensusCtx: consensuscontext.WithContext(context.Background(), rt),
ProposerVMBlockCtx: &chain.Context{PChainHeight: blockHeight},
}, predicateBytes(b))
require.ErrorIs(err, errNoValidatorState)
// Unknown chain ID => the signing domain is unknown => fail closed rather
// than verify against a degenerate domain another chain could also produce.
err = cfg.VerifyPredicate(&precompileconfig.PredicateContext{
ConsensusCtx: newConsensusCtx(t, ids.Empty, map[uint64]vdrSet{snapshotHeight: set, blockHeight: set}),
ProposerVMBlockCtx: &chain.Context{PChainHeight: blockHeight},
}, predicateBytes(b))
require.ErrorIs(err, errUnknownChainID)
}
func TestPredicateGas(t *testing.T) {
require := require.New(t)
cfg := NewConfig(utils.NewUint64(0))
gas, err := cfg.PredicateGas(predicate.PackPredicate(make([]byte, BallotLen)))
require.NoError(err)
require.Equal(PredicateGasCost, gas)
require.Equal(uint64(400_000), PredicateGasCost)
_, err = cfg.PredicateGas(predicate.PackPredicate(make([]byte, BallotLen-1)))
require.ErrorIs(err, errInvalidBallotLen)
_, err = cfg.PredicateGas(make([]byte, 32)) // all zero: no end delimiter
require.ErrorIs(err, errInvalidPredicateBytes)
}
// A validator with no registered BLS key cannot authorise anyone: there is no
// key to check against, and inventing a fallback would be a back door.
func TestValidatorWithoutPublicKey(t *testing.T) {
chainID := ids.GenerateTestID()
vdr := newTestValidator(t)
out := vdr.output(mainnetVdrWeight)
out.PublicKey = nil
ctx := newConsensusCtx(t, chainID, map[uint64]vdrSet{
snapshotHeight: {vdr.nodeID: out},
blockHeight: {vdr.nodeID: out},
})
b := signedBallot(t, vdr, chainID, Ballot{
Voter: voterAddr,
AuthEpoch: 1,
PChainHeight: snapshotHeight,
Weight: mainnetVdrWeight,
TotalWeight: mainnetVdrWeight,
})
newTest(ctx, b, errNoRegisteredPublicKey).Run(t)
}
// The total is summed with overflow checking: the P-chain itself cannot
// represent a larger total, so wrapping would silently mint voting power.
func TestTotalWeightOverflow(t *testing.T) {
a, b := newTestValidator(t), newTestValidator(t)
_, err := totalWeight(vdrSet{
a.nodeID: a.output(1 << 63),
b.nodeID: b.output(1 << 63),
})
require.ErrorIs(t, err, errTotalWeightOverflow)
}