Files
zeekayandHanzo Dev 0c11cf1960 warp: name the message for what it now is
The identifiers still said unsigned, but warp no longer has an unsigned
message: the Message that the previous commit migrated to is the signed
subject, and the thing that carries a signature beside it is the
Envelope. Locals and parameters that held one drop the prefix, the
LocalSigner method that signs one becomes SignMessage, and the two
places where a test builds a deliberately malformed message or reads
one back out of an event name it invalidMsg and unpacked rather than
shadowing the valid message in the enclosing test.

PackSendWarpMessageEvent keeps unsignedMessageID, which mirrors the
field name in the SendWarpMessage ABI event rather than a Go type.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-06 03:52:57 -07:00

420 lines
16 KiB
Go

// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package warp
import (
"bytes"
"errors"
"fmt"
"sort"
"github.com/luxfi/constants"
"github.com/luxfi/crypto/bls"
evmconsensus "github.com/luxfi/evmgpu/consensus"
"github.com/luxfi/evmgpu/precompile/precompileconfig"
"github.com/luxfi/evmgpu/predicate"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
log "github.com/luxfi/log"
"github.com/luxfi/math"
consensuscontext "github.com/luxfi/runtime"
"github.com/luxfi/warp"
"github.com/luxfi/warp/payload"
)
const (
WarpDefaultQuorumNumerator uint64 = 67
WarpQuorumNumeratorMinimum uint64 = 33
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 unsigned message (TxHash: %s, LogIndex: %d): %w", txHash, logIndex, err)
}
log.Debug(
"Accepted warp unsigned message",
"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. TODO: 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
}
// 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)
}
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 the warp validator set for the requested chain.
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))
var totalWeight uint64
for nodeID, output := range vdrOutputs {
totalWeight += output.Weight
vdr := &warp.Validator{
NodeID: nodeID,
Weight: output.Weight,
}
// A validator registered without a BLS key cannot sign; it still counts
// toward total weight, and is dropped from the signer set below.
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)
}
// 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
}