Files
evm/precompile/contracts/warp/config.go
zeekayandHanzo Dev 9c6d66ef6b eth,warp: gate EXPORT surfaces on the ⅔-stake Quasar tier, not the Nova accept tip (v1.36)
The two-tier consensus (v1.36) advances VM.Accept at a BARE MAJORITY (Nova), which is reorgable
and can fork under f=1 equivocation. The Ethereum `finalized`/`safe` tags and warp cross-chain
exports were still reading the accept tip — the "semantic collapse" the split exists to prevent.
Re-point every EXPORT surface onto the export-final (Quasar, ⅔-by-stake) frontier consensus
already exposes.

#1 CRITICAL — eth `finalized`/`safe` tags:
- eth.Ethereum gains a DURABLE, monotone lastQuasarHeight (SetLastQuasarHeight/LastQuasarHeight),
  pushed by consensus strictly AFTER accept. The plugin VM's SetLastQuasarFinalized persists it to
  acceptedBlockDB and reloads it on boot (readLastQuasarHeight), so the export surface never
  regresses on restart.
- api_backend HeaderByNumber/BlockByNumber resolve Finalized(-3)/Safe(-4) to GetBlockByNumber(
  LastQuasarHeight) BEFORE the IsAccepted() catch-all — NEVER a block above the Quasar height.
  `latest`/`pending` stay on the accept tip (correct — not finality tags).
- Test: TestFinalizedSafeResolveToQuasarNeverAboveIt proves finalized == the Quasar height,
  monotone, and never above the accepted tip.

#2 HIGH — warp cross-chain export double-spend:
- warp.verifyBlockMessage now requires the block be EXPORT-final (height ≤ BlockClient.
  LastQuasarHeight), so a validator signs a block hash only at Quasar finality, never on a
  reorgable Nova accept (two conflicting Nova forks could otherwise each aggregate a valid export).
- WarpQuorumNumeratorMinimum floored 33 → 67 (⅔): any warp message needs ≥⅔ stake to verify, so at
  most one of two conflicting exports can validate (two ⅔ sets intersect on >⅓ honest stake) —
  restoring the ⅔-accept safety the pre-split accept tier gave for free.

The consensus engine pushes the frontier via NetworkConfig.QuasarObserver; the node bridges it into
the VM (separate node commit). Nova-only chains (no export surface) are unaffected.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-10 04:58:54 -07:00

475 lines
18 KiB
Go

// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package warp
import (
"bytes"
"context"
"errors"
"fmt"
"sort"
"github.com/luxfi/constants"
"github.com/luxfi/crypto/bls"
evmconsensus "github.com/luxfi/evm/consensus"
"github.com/luxfi/evm/precompile/precompileconfig"
"github.com/luxfi/evm/predicate"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
log "github.com/luxfi/log"
"github.com/luxfi/math"
consensuscontext "github.com/luxfi/runtime"
validators "github.com/luxfi/validators"
"github.com/luxfi/warp"
"github.com/luxfi/warp/payload"
)
const (
WarpDefaultQuorumNumerator uint64 = 67
// WarpQuorumNumeratorMinimum is floored at ⅔ (67/100). Under the two-tier consensus, local
// Accept is a BARE MAJORITY (Nova) which is reorgable and can fork under f=1 equivocation. A
// warp message is a CROSS-CHAIN EXPORT: if a route could require < ⅔ stake to verify, the two
// conflicting Nova forks {1,2,E}/{3,4,E} could EACH aggregate a valid warp export (a
// double-spend). Requiring ≥⅔ stake to verify ANY warp message means at most one of two
// conflicting exports can ever validate (two ⅔-stake sets must intersect on >⅓ honest stake),
// restoring the ⅔-accept safety the pre-split Accept tier gave for free. (Block-hash exports
// are ALSO gated on Quasar finality at the signing side — see warp.verifyBlockMessage.)
WarpQuorumNumeratorMinimum uint64 = 67
WarpQuorumDenominator uint64 = 100
)
var (
_ precompileconfig.Config = (*Config)(nil)
_ precompileconfig.Predicater = (*Config)(nil)
_ precompileconfig.Accepter = (*Config)(nil)
)
var (
errOverflowSignersGasCost = errors.New("overflow calculating warp signers gas cost")
errInvalidPredicateBytes = errors.New("cannot unpack predicate bytes")
errInvalidWarpMsg = errors.New("cannot unpack warp message")
errCannotParseWarpMsg = errors.New("cannot parse warp message")
errInvalidWarpMsgPayload = errors.New("cannot unpack warp message payload")
errInvalidAddressedPayload = errors.New("cannot unpack addressed payload")
errInvalidBlockHashPayload = errors.New("cannot unpack block hash payload")
errCannotGetNumSigners = errors.New("cannot fetch num signers from warp message")
errWarpCannotBeActivated = errors.New("warp cannot be activated before Durango")
errFailedVerification = errors.New("cannot verify warp signature")
errCannotRetrieveValidatorSet = errors.New("cannot retrieve validator set")
)
// Config implements the precompileconfig.Config interface and
// adds specific configuration for Warp.
type Config struct {
precompileconfig.Upgrade
QuorumNumerator uint64 `json:"quorumNumerator"`
RequirePrimaryNetworkSigners bool `json:"requirePrimaryNetworkSigners"`
}
// NewConfig returns a config for a network upgrade at [blockTimestamp] that enables
// Warp with the given quorum numerator.
func NewConfig(blockTimestamp *uint64, quorumNumerator uint64, requirePrimaryNetworkSigners bool) *Config {
return &Config{
Upgrade: precompileconfig.Upgrade{BlockTimestamp: blockTimestamp},
QuorumNumerator: quorumNumerator,
RequirePrimaryNetworkSigners: requirePrimaryNetworkSigners,
}
}
// NewDefaultConfig returns a config for a network upgrade at [blockTimestamp] that enables
// Warp with the default quorum numerator (0 denotes using the default).
func NewDefaultConfig(blockTimestamp *uint64) *Config {
return NewConfig(blockTimestamp, 0, false)
}
// NewDisableConfig returns config for a network upgrade at [blockTimestamp]
// that disables Warp.
func NewDisableConfig(blockTimestamp *uint64) *Config {
return &Config{
Upgrade: precompileconfig.Upgrade{
BlockTimestamp: blockTimestamp,
Disable: true,
},
}
}
// Key returns the key for the Warp precompileconfig.
// This should be the same key as used in the precompile module.
func (*Config) Key() string { return ConfigKey }
// Verify tries to verify Config and returns an error accordingly.
func (c *Config) Verify(chainConfig precompileconfig.ChainConfig) error {
if c.Timestamp() != nil {
// If Warp attempts to activate before Durango, fail verification
timestamp := *c.Timestamp()
if !chainConfig.IsDurango(timestamp) {
return errWarpCannotBeActivated
}
}
if c.QuorumNumerator > WarpQuorumDenominator {
return fmt.Errorf("cannot specify quorum numerator (%d) > quorum denominator (%d)", c.QuorumNumerator, WarpQuorumDenominator)
}
// If a non-default quorum numerator is specified and it is less than the minimum, return an error
if c.QuorumNumerator != 0 && c.QuorumNumerator < WarpQuorumNumeratorMinimum {
return fmt.Errorf("cannot specify quorum numerator (%d) < min quorum numerator (%d)", c.QuorumNumerator, WarpQuorumNumeratorMinimum)
}
return nil
}
// Equal returns true if [s] is a [*Config] and it has been configured identical to [c].
func (c *Config) Equal(s precompileconfig.Config) bool {
// typecast before comparison
other, ok := (s).(*Config)
if !ok {
return false
}
equals := c.Upgrade.Equal(&other.Upgrade)
return equals && c.QuorumNumerator == other.QuorumNumerator
}
func (c *Config) Accept(acceptCtx *precompileconfig.AcceptContext, blockHash common.Hash, blockNumber uint64, txHash common.Hash, logIndex int, topics []common.Hash, logData []byte) error {
msg, err := UnpackSendWarpEventDataToMessage(logData)
if err != nil {
return fmt.Errorf("failed to parse warp log data into signed core (TxHash: %s, LogIndex: %d): %w", txHash, logIndex, err)
}
log.Debug(
"Accepted warp signed core",
"blockHash", blockHash,
"blockNumber", blockNumber,
"txHash", txHash,
"logIndex", logIndex,
"logData", common.Bytes2Hex(logData),
"warpMessageID", msg.ID(),
)
if err := acceptCtx.Warp.AddMessage(msg); err != nil {
return fmt.Errorf("failed to add warp message during accept (TxHash: %s, LogIndex: %d): %w", txHash, logIndex, err)
}
return nil
}
// PredicateGas returns the amount of gas necessary to verify the predicate
// PredicateGas charges for:
// 1. Base cost of the message
// 2. Size of the message
// 3. Number of signers
// 4. Lookup of the validator set
//
// If the payload of the warp message fails parsing, return a non-nil error invalidating the transaction.
func (c *Config) PredicateGas(predicateBytes []byte) (uint64, error) {
totalGas := GasCostPerSignatureVerification
bytesGasCost, overflow := math.SafeMul(GasCostPerWarpMessageBytes, uint64(len(predicateBytes)))
if overflow {
return 0, fmt.Errorf("overflow calculating gas cost for warp message bytes of size %d", len(predicateBytes))
}
totalGas, overflow = math.SafeAdd(totalGas, bytesGasCost)
if overflow {
return 0, fmt.Errorf("overflow adding bytes gas cost of size %d", len(predicateBytes))
}
unpackedPredicateBytes, err := predicate.UnpackPredicate(predicateBytes)
if err != nil {
return 0, fmt.Errorf("%w: %s", errInvalidPredicateBytes, err)
}
warpMessage, err := warp.ParseEnvelope(unpackedPredicateBytes)
if err != nil {
return 0, fmt.Errorf("%w: %s", errInvalidWarpMsg, err)
}
_, err = payload.ParsePayload(warpMessage.Message.Payload)
if err != nil {
return 0, fmt.Errorf("%w: %s", errInvalidWarpMsgPayload, err)
}
// The Beam (BLS aggregate lane) is a concrete field post-ZAP; count signers.
numSigners := uint64(warpMessage.Beam.Signers.Len())
if numSigners == 0 {
return 0, fmt.Errorf("%w: no signers in bit set", errCannotGetNumSigners)
}
signerGas, overflow := math.SafeMul(uint64(numSigners), GasCostPerWarpSigner)
if overflow {
return 0, errOverflowSignersGasCost
}
totalGas, overflow = math.SafeAdd(totalGas, signerGas)
if overflow {
return 0, fmt.Errorf("overflow adding signer gas (PrevTotal: %d, VerificationGas: %d)", totalGas, signerGas)
}
return totalGas, nil
}
// ValidatorOutputGetter is an optional interface that can be implemented
// by validator states to provide full validator output including public keys
type ValidatorOutputGetter interface {
GetValidatorSetWithOutput(ctx context.Context, height uint64, chainID ids.ID) (map[ids.NodeID]*validators.GetValidatorOutput, error)
}
// VerifyPredicate returns whether the predicate described by [predicateBytes] passes verification.
func (c *Config) VerifyPredicate(predicateContext *precompileconfig.PredicateContext, predicateBytes []byte) error {
unpackedPredicateBytes, err := predicate.UnpackPredicate(predicateBytes)
if err != nil {
return fmt.Errorf("%w: %w", errInvalidPredicateBytes, err)
}
// Note: PredicateGas should be called before VerifyPredicate, so we should never reach an error case here.
warpMsg, err := warp.ParseEnvelope(unpackedPredicateBytes)
if err != nil {
return fmt.Errorf("%w: %w", errCannotParseWarpMsg, err)
}
// Reject a message stamped with a different origin network than this node's,
// matching warp.VerifyEnvelope. The bespoke verify path must enforce the same
// checks (one and one way); D folds NetworkID, so this is defense in depth
// against cross-network replay.
if localNetworkID := consensuscontext.GetNetworkID(predicateContext.ConsensusCtx); warpMsg.Message.NetworkID != localNetworkID {
return fmt.Errorf("%w: message network ID %d does not match local network ID %d", errFailedVerification, warpMsg.Message.NetworkID, localNetworkID)
}
quorumNumerator := WarpDefaultQuorumNumerator
if c.QuorumNumerator != 0 {
quorumNumerator = c.QuorumNumerator
}
// Get ValidatorState from context
validatorState := consensuscontext.GetValidatorState(predicateContext.ConsensusCtx)
if validatorState == nil {
return fmt.Errorf("validator state not found in context")
}
// Get the source chain ID from the warp message
sourceChainID := warpMsg.Message.SourceChainID
// Get network ID from validator state for the source chain
sourceNetworkID, err := validatorState.GetNetworkID(sourceChainID)
if err != nil {
return fmt.Errorf("failed to get network ID for source chain: %w", err)
}
// Get the receiving chain ID (the chain this VM is running on)
receivingChainID := evmconsensus.GetChainID(predicateContext.ConsensusCtx)
// Determine which chain's validators to use
// The logic is:
// 1. For non-primary network sources (sourceNetworkID != Empty): Use source chain's validators
// 2. For P-Chain sources: Use receiving chain's validators (P-Chain exempt)
// 3. For other primary network sources (e.g., C-Chain):
// - With RequirePrimaryNetworkSigners=true: Use primary network's validators
// - With RequirePrimaryNetworkSigners=false: Use receiving chain's validators
var requestedChainID ids.ID
if sourceNetworkID != ids.Empty && sourceNetworkID != constants.PrimaryNetworkID {
// Source is from a non-primary network - use that chain's validators
requestedChainID = sourceChainID
} else if sourceChainID == constants.PlatformChainID {
// P-Chain source - always use receiving chain's validators
requestedChainID = receivingChainID
} else if c.RequirePrimaryNetworkSigners {
// Other primary network source with RequirePrimaryNetworkSigners
// Use primary network validators
requestedChainID = constants.PrimaryNetworkID
} else {
// Other primary network source without RequirePrimaryNetworkSigners
// Use receiving chain's validators
requestedChainID = receivingChainID
}
pChainHeight := predicateContext.ProposerVMBlockCtx.PChainHeight
// Build warp validators - try to get full validator output with public keys
var allValidators []*warp.Validator
var totalWeight uint64
// Check if the validator state supports getting full output with public keys
if outputGetter, ok := validatorState.(ValidatorOutputGetter); ok {
// Use the full validator output which includes public keys
vdrOutputs, err := outputGetter.GetValidatorSetWithOutput(predicateContext.ConsensusCtx, pChainHeight, requestedChainID)
if err != nil {
return fmt.Errorf("%w: %w", errCannotRetrieveValidatorSet, err)
}
allValidators = make([]*warp.Validator, 0, len(vdrOutputs))
for nodeID, output := range vdrOutputs {
totalWeight += output.Weight
vdr := &warp.Validator{
NodeID: nodeID,
Weight: output.Weight,
}
// Parse public key if available
if len(output.PublicKey) > 0 {
pk, pkErr := warp.ParsePublicKey(output.PublicKey)
if pkErr == nil {
vdr.PublicKey = pk
vdr.PublicKeyBytes = output.PublicKey
} else {
log.Debug("failed to parse validator public key", "nodeID", nodeID, "err", pkErr)
}
}
allValidators = append(allValidators, vdr)
}
} else {
// Fallback: get full output (signature verification will fail without public keys)
vdrOutputs, err := validatorState.GetValidatorSet(predicateContext.ConsensusCtx, pChainHeight, requestedChainID)
if err != nil {
return fmt.Errorf("%w: %w", errCannotRetrieveValidatorSet, err)
}
allValidators = make([]*warp.Validator, 0, len(vdrOutputs))
for nodeID, output := range vdrOutputs {
totalWeight += output.Weight
vdr := &warp.Validator{
NodeID: nodeID,
Weight: output.Weight,
}
// Parse public key if available
if len(output.PublicKey) > 0 {
pk, pkErr := warp.ParsePublicKey(output.PublicKey)
if pkErr == nil {
vdr.PublicKey = pk
vdr.PublicKeyBytes = output.PublicKey
}
}
allValidators = append(allValidators, vdr)
}
}
// Aggregate validators by public key - this handles the case where multiple
// validators share the same public key. We sum their weights and keep one
// representative validator per unique public key.
pkeyToWeight := make(map[string]uint64)
pkeyToValidator := make(map[string]*warp.Validator)
for _, vdr := range allValidators {
if len(vdr.PublicKeyBytes) == 0 {
continue // Skip validators without public keys
}
pkStr := string(vdr.PublicKeyBytes)
pkeyToWeight[pkStr] += vdr.Weight
if _, exists := pkeyToValidator[pkStr]; !exists {
pkeyToValidator[pkStr] = vdr
}
}
// Build canonical validator set with aggregated weights
canonicalValidators := make([]*warp.Validator, 0, len(pkeyToValidator))
for pkStr, vdr := range pkeyToValidator {
// Create a copy with aggregated weight
aggregatedVdr := &warp.Validator{
NodeID: vdr.NodeID,
Weight: pkeyToWeight[pkStr],
PublicKey: vdr.PublicKey,
PublicKeyBytes: vdr.PublicKeyBytes,
}
canonicalValidators = append(canonicalValidators, aggregatedVdr)
}
// Sort validators by public key bytes for canonical ordering
// This matches the order used when creating signatures
sort.Slice(canonicalValidators, func(i, j int) bool {
return bytes.Compare(canonicalValidators[i].PublicKeyBytes, canonicalValidators[j].PublicKeyBytes) < 0
})
// The Beam (BLS aggregate lane) is a concrete field post-ZAP.
beam := warpMsg.Beam
// Calculate signed weight using canonical validators (with aggregated weights).
signedWeight, err := beamSignedWeight(beam, canonicalValidators)
if err != nil {
return fmt.Errorf("%w: %w", errFailedVerification, err)
}
// Verify quorum. NOTE: totalWeight is the weight of ALL source validators
// (including those without a BLS public key) — the correct quorum
// denominator. This is why the precompile cannot route through
// warp.VerifyEnvelope, whose GetCanonicalValidatorSet both rejects the
// shared-public-key aggregation done above and excludes keyless validators
// from the denominator.
err = warp.VerifyWeight(signedWeight, totalWeight, quorumNumerator, WarpQuorumDenominator)
if err != nil {
return fmt.Errorf("%w: %w", errFailedVerification, err)
}
// Verify the BLS Beam over the ZAP digest D = warpMsg.Message.ID(). The Beam
// signs warp.BeamSigningBytes(D), authenticating the entire Message
// (network ID, source chain, PQ lineage, payload) — not stale RLP bytes.
err = verifyBeam(beam, warpMsg.Message.ID(), canonicalValidators)
if err != nil {
return fmt.Errorf("%w: %w", errFailedVerification, err)
}
return nil
}
// beamSignedWeight returns the total weight of the canonical validators whose
// bit is set in the Beam's signer bitset. It mirrors the warp package's
// internal (unexported) signedWeight; the precompile needs it directly because
// it supplies its own canonical set (shared-public-key validators aggregated
// by weight) which warp.GetCanonicalValidatorSet would reject.
func beamSignedWeight(beam warp.BitSetSignature, validators []*warp.Validator) (uint64, error) {
signers := beam.Signers
if signers.HighestSetBit() > len(validators) {
return 0, fmt.Errorf("signer index %d exceeds validator count %d", signers.HighestSetBit()-1, len(validators))
}
var weight uint64
for i := 0; i < signers.BitLen(); i++ {
if !signers.Contains(i) {
continue
}
if i >= len(validators) {
return 0, fmt.Errorf("signer index %d exceeds validator count %d", i, len(validators))
}
newWeight, overflow := math.SafeAdd(weight, validators[i].Weight)
if overflow {
return 0, errors.New("weight overflow")
}
weight = newWeight
}
return weight, nil
}
// verifyBeam checks the Beam BLS aggregate over warp.BeamSigningBytes(d),
// aggregating the public keys of the canonical validators selected by the
// signer bitset. It mirrors the warp package's internal (unexported) verify
// over the exported digest, for the same reason as beamSignedWeight.
func verifyBeam(beam warp.BitSetSignature, d ids.ID, validators []*warp.Validator) error {
signers := beam.Signers
if signers.Len() == 0 {
return errors.New("no signers")
}
if signers.HighestSetBit() > len(validators) {
return fmt.Errorf("signer index %d exceeds validator count %d", signers.HighestSetBit()-1, len(validators))
}
pks := make([]*bls.PublicKey, 0, signers.Len())
for i := 0; i < signers.BitLen(); i++ {
if !signers.Contains(i) {
continue
}
if i >= len(validators) {
return fmt.Errorf("signer index %d exceeds validator count %d", i, len(validators))
}
pk := validators[i].PublicKey
if pk == nil {
return fmt.Errorf("validator %d has nil public key", i)
}
pks = append(pks, pk)
}
if len(pks) == 0 {
return errors.New("no valid signers")
}
aggPK, err := bls.AggregatePublicKeys(pks)
if err != nil {
return fmt.Errorf("failed to aggregate public keys: %w", err)
}
sig, err := bls.SignatureFromBytes(beam.Signature[:])
if err != nil {
return fmt.Errorf("failed to parse signature: %w", err)
}
if !bls.Verify(aggPK, sig, warp.BeamSigningBytes(d)) {
return warp.ErrInvalidSignature
}
return nil
}