Files
warp/envelope.go
zeekay 948ee85a9c warp: ship Warp 2.0 envelope (Beam + ML-DSA + Pulse, Prism-bound)
Implements the LP-021v2 / LP-105 §"Warp evolution" cross-chain envelope
that carries the Warp 1.x BLS Beam intact alongside transcript-binding
fields and two optional PQ lanes (Pulsar Pulse, ML-DSA cert set).

Wire format: leading 0x02 version byte + RLP-encoded EnvelopeV2 record
binding SourceNebulaRoot, SourceKeyEraID, SourceGeneration, HashSuiteID
(default "Pulsar-SHA3"), plus optional PulsarPulse and MLDSACertSet
lanes. Forward compatibility: ParseEnvelope decodes Warp 1.x bytes into
a v2 envelope with PQ lanes empty. Backward compatibility: v1 receivers
reject v2 bytes (the 0x02 leading byte is not a valid RLP-list prefix);
senders that need to reach v1-only verifiers emit Warp 1.x bytes on the
v1 channel. Replay protection is uniform: EnvelopeV2.ID() returns the
embedded Message.ID().

Verification: VerifyV2 checks structural invariants, hash-suite
consistency, BLS Beam (against the source-chain validator set), then
the optional ML-DSA cert-set and Pulsar Pulse lanes. VerifyPQLanes is
the standalone entrypoint for callers that already verified the Beam
through a separate code path.

Subpackage warp/pulsar holds the Pulse path:
  - KernelVerifier consumes a GroupKeyResolver (the destination chain's
    source-chain key registry) to fetch the source-chain GroupKey for
    a given (KeyEraID, Generation) and verifies the Pulse via the
    luxfi/pulsar kernel.
  - BuildSigningBytes produces the canonical transcript bytes under the
    WARP-PULSAR-ENVELOPE-v1 domain prefix (distinct from any Pulsar
    consensus-side prefix to prevent cross-domain replay).
  - HorizonFromEnvelope lifts a verified envelope into the LP-105
    HorizonCertificate shape consumed by destination consensus / bridge
    code.

Pulse byte format wraps the kernel Signature's C / Z / Delta components
in three little-endian length-prefixed frames so DeserializePulse can
dispatch each component into a fresh slice-backed lattigo buffer
(the recommended path for slice-backed reads).

Tests:
  - envelope_test.go: roundtrip, optional-field defaulting, forward /
    backward / unknown-version dispatch, ID stability, PQ-lane
    verifier dispatch (Pulse, CertSet, required-but-absent flags).
  - pulsar/pulsar_test.go: real t-of-n Pulsar ceremony, serialize /
    deserialize roundtrip, KernelVerifier accept / reject paths
    (transcript-tampering rejection, resolver failure, suite mismatch),
    BuildSigningBytes binds every transcript field, Horizon assembly.

Module wiring: adds github.com/luxfi/pulsar (replace ../pulsar) and
github.com/luxfi/lattice/v7 to warp/go.mod. Restores the pinned
luxfi/precompile h1: hash so go test ./... resolves cleanly.

Documentation: README.md and SPECIFICATION.md rewritten to describe the
shipping v1.x + v2.0 + v2-Private versions, the dispatch flow, and the
Pulse transcript-binding rules.

LP refs: LP-021 (Warp 1.x), LP-021v2 (this work), LP-073 (Pulsar
kernel), LP-075 (BLS aggregate), LP-105 §"Warp evolution".

All warp tests pass: GOWORK=off go test ./...
2026-05-11 22:31:20 -07:00

501 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package warp
import (
"bytes"
"errors"
"fmt"
"io"
"github.com/luxfi/geth/rlp"
)
// Envelope versions on the wire. The first byte of any
// envelope-encoded warp message identifies the version. Warp 1.x
// messages have NO leading version byte and are decoded directly via
// ParseMessage / Codec.Unmarshal — the dispatcher in ParseEnvelope
// recognises them as the implicit "v1" envelope.
const (
// EnvelopeVersion1 is the implicit Warp 1.x envelope: a v1 Message
// (UnsignedMessage + BitSetSignature) with NO leading version byte.
// Warp 1.x verifiers see only this format on the wire and decode it
// straight from Codec.Unmarshal. Receivers that accept either v1 or
// v2 dispatch via ParseEnvelope, which detects v2's leading
// version byte and falls back to v1 parsing otherwise.
EnvelopeVersion1 = 0x01
// EnvelopeVersion2 is the Warp 2.0 envelope: a leading 0x02 byte
// followed by an RLP-encoded EnvelopeV2 record carrying the v1
// message plus the Pulse / ML-DSA cert set / Pulsar lineage fields
// pinned in LP-105 §"Warp evolution".
EnvelopeVersion2 = 0x02
// MaxEnvelopeV2Size is the upper bound on a serialised Warp 2.0
// envelope including the version byte. Bounded conservatively at
// 4×MaxMessageSize to leave room for the optional Pulse and MLDSA
// cert-set bytes alongside the v1 message.
MaxEnvelopeV2Size = 4 * MaxMessageSize
)
// Errors specific to the envelope dispatcher.
var (
// ErrUnknownEnvelopeVersion is returned when the dispatcher sees a
// leading version byte it does not understand.
ErrUnknownEnvelopeVersion = errors.New("unknown warp envelope version")
// ErrEnvelopeTooLarge is returned when a serialised v2 envelope
// exceeds MaxEnvelopeV2Size.
ErrEnvelopeTooLarge = errors.New("warp envelope exceeds maximum size")
// ErrEnvelopeEmpty is returned when ParseEnvelope is called on
// zero bytes.
ErrEnvelopeEmpty = errors.New("warp envelope is empty")
// ErrEnvelopeMissingMessage is returned when an EnvelopeV2 lacks
// the embedded v1 Message (Beam component).
ErrEnvelopeMissingMessage = errors.New("warp envelope missing v1 message")
// ErrEnvelopeBadSuiteID is returned when an EnvelopeV2's
// HashSuiteID is non-empty but does not match the configured
// HashSuite of the verifier.
ErrEnvelopeBadSuiteID = errors.New("warp envelope hash-suite mismatch")
)
// EnvelopeV2 is the Warp 2.0 wire envelope. It carries a v1 Message
// (so the Beam / BLS aggregate path remains unchanged) plus four
// transcript-binding fields and two optional PQ lanes (Pulse and the
// ML-DSA cert set) pinned in LP-105 §"Warp evolution".
//
// Wire layout, RLP-encoded after the leading EnvelopeVersion2 byte:
//
// [
// Message, // v1 Beam (UnsignedMessage + BitSetSignature)
// SourceNebulaRoot [32]byte, // Pulsar transcript anchor (LP-105)
// SourceKeyEraID uint64, // Pulsar lineage
// SourceGeneration uint64, // Pulsar lineage
// HashSuiteID string, // e.g. "Pulsar-SHA3"; "" defaults
// PulsarPulse []byte, // optional; Pulsar threshold sig bytes
// MLDSACertSet []byte, // optional; ML-DSA attestation bytes
// ]
//
// All fields after Message are unconditionally present in the RLP list;
// the optional PQ lanes are signalled by zero-length byte slices when
// absent. This keeps cross-version decoding unambiguous: the field
// count is fixed, and a verifier only needs to inspect the lengths.
//
// Backward compatibility: a Warp 1.x receiver that calls
// ParseEnvelope/ParseMessage directly on the v2 wire bytes will reject
// the stream because the leading 0x02 byte is not the start of a valid
// RLP list-of-Message; that's the correct refusal — the v1 verifier
// cannot validate v2 transcript binding. Senders that need to support
// v1-only verifiers MUST emit Warp 1.x bytes (call Message.Bytes()) on
// the v1 channel; the same UnsignedMessage may be embedded in a v2
// envelope on the v2 channel without re-signing the Beam.
//
// Forward compatibility: a Warp 2.0 receiver decoding Warp 1.x bytes
// uses ParseEnvelope, which falls back to ParseMessage when no v2
// version byte is present. The result is a Warp 2.0 envelope with
// only the Beam lane populated and all v2 fields zero-valued.
type EnvelopeV2 struct {
// Message is the v1 Beam: an UnsignedMessage plus BitSetSignature.
// Warp 2.0 keeps this byte-equal to the Warp 1.x format so a
// validator's BLS aggregate signature lives unchanged inside the
// envelope.
Message *Message `serialize:"true"`
// SourceNebulaRoot is the source-chain Nebula root (LP-105) the
// Pulsar transcript binds to. Zero-valued for messages that do not
// commit to a specific source-chain DAG state.
SourceNebulaRoot [32]byte `serialize:"true"`
// SourceKeyEraID is the Pulsar group-key lineage ID for the source
// chain at the time of signing. Bumps only at Reanchor.
SourceKeyEraID uint64 `serialize:"true"`
// SourceGeneration is the LSS resharing generation ID for the
// source chain at the time of signing. Bumps every Refresh /
// Reshare under the same GroupKey.
SourceGeneration uint64 `serialize:"true"`
// HashSuiteID pins the hash profile this envelope was produced
// under. Empty defaults to "Pulsar-SHA3" in HashSuiteOrDefault.
HashSuiteID string `serialize:"true"`
// PulsarPulse is the optional Pulsar threshold-signature bytes
// over the envelope's transcript. Zero-length when absent.
PulsarPulse []byte `serialize:"true"`
// MLDSACertSet is the optional ML-DSA attestation set bytes for
// the envelope's signers (or a Z-Chain Groth16 rollup of those
// attestations). Zero-length when absent.
MLDSACertSet []byte `serialize:"true"`
}
// DefaultHashSuiteID is the canonical hash profile for Warp 2.0
// envelopes when HashSuiteID is left empty. Matches the Pulsar default
// at github.com/luxfi/pulsar/hash.DefaultID.
const DefaultHashSuiteID = "Pulsar-SHA3"
// HashSuiteOrDefault returns the envelope's HashSuiteID, falling back
// to DefaultHashSuiteID when the field is empty.
func (e *EnvelopeV2) HashSuiteOrDefault() string {
if e == nil || e.HashSuiteID == "" {
return DefaultHashSuiteID
}
return e.HashSuiteID
}
// HasPulse reports whether the envelope carries a Pulsar pulse.
func (e *EnvelopeV2) HasPulse() bool {
return e != nil && len(e.PulsarPulse) > 0
}
// HasMLDSACertSet reports whether the envelope carries an ML-DSA cert
// set (or its Z-Chain Groth16 rollup).
func (e *EnvelopeV2) HasMLDSACertSet() bool {
return e != nil && len(e.MLDSACertSet) > 0
}
// Verify checks that the envelope's structural invariants hold:
// embedded v1 Message present and well-formed, optional PQ lane bytes
// bounded by MaxEnvelopeV2Size. It does NOT verify the BLS Beam, the
// Pulsar Pulse, or the ML-DSA cert set — those are independent calls
// (see VerifyV2 and pulsar/* helpers).
func (e *EnvelopeV2) Verify() error {
if e == nil {
return ErrInvalidMessage
}
if e.Message == nil {
return ErrEnvelopeMissingMessage
}
if err := e.Message.Verify(); err != nil {
return err
}
totalLen := len(e.PulsarPulse) + len(e.MLDSACertSet)
if totalLen > MaxEnvelopeV2Size {
return ErrEnvelopeTooLarge
}
return nil
}
// Bytes returns the wire-format serialisation of the envelope: a
// leading EnvelopeVersion2 byte followed by the RLP-encoded
// EnvelopeV2 list.
func (e *EnvelopeV2) Bytes() ([]byte, error) {
if e == nil {
return nil, ErrInvalidMessage
}
body, err := rlp.EncodeToBytes(e)
if err != nil {
return nil, fmt.Errorf("failed to encode envelope v2 body: %w", err)
}
out := make([]byte, 0, 1+len(body))
out = append(out, EnvelopeVersion2)
out = append(out, body...)
if len(out) > MaxEnvelopeV2Size {
return nil, ErrEnvelopeTooLarge
}
return out, nil
}
// ID returns the v1 Message ID (hash of the UnsignedMessage). v1 and
// v2 envelopes that wrap the same UnsignedMessage share an ID; this is
// deliberate so destination-chain replay protection works uniformly
// across versions.
func (e *EnvelopeV2) ID() (id [32]byte) {
if e == nil || e.Message == nil || e.Message.UnsignedMessage == nil {
return id
}
return e.Message.UnsignedMessage.ID()
}
// EncodeRLP implements rlp.Encoder for EnvelopeV2.
func (e *EnvelopeV2) EncodeRLP(w io.Writer) error {
if e == nil {
return ErrInvalidMessage
}
if e.Message == nil {
return ErrEnvelopeMissingMessage
}
pulse := e.PulsarPulse
if pulse == nil {
pulse = []byte{}
}
cert := e.MLDSACertSet
if cert == nil {
cert = []byte{}
}
return rlp.Encode(w, []interface{}{
e.Message,
e.SourceNebulaRoot,
e.SourceKeyEraID,
e.SourceGeneration,
e.HashSuiteID,
pulse,
cert,
})
}
// DecodeRLP implements rlp.Decoder for EnvelopeV2.
func (e *EnvelopeV2) DecodeRLP(s *rlp.Stream) error {
if _, err := s.List(); err != nil {
return err
}
e.Message = &Message{}
if err := s.Decode(e.Message); err != nil {
return fmt.Errorf("failed to decode v1 message in envelope v2: %w", err)
}
if err := s.Decode(&e.SourceNebulaRoot); err != nil {
return fmt.Errorf("failed to decode SourceNebulaRoot: %w", err)
}
if err := s.Decode(&e.SourceKeyEraID); err != nil {
return fmt.Errorf("failed to decode SourceKeyEraID: %w", err)
}
if err := s.Decode(&e.SourceGeneration); err != nil {
return fmt.Errorf("failed to decode SourceGeneration: %w", err)
}
if err := s.Decode(&e.HashSuiteID); err != nil {
return fmt.Errorf("failed to decode HashSuiteID: %w", err)
}
if err := s.Decode(&e.PulsarPulse); err != nil {
return fmt.Errorf("failed to decode PulsarPulse: %w", err)
}
if err := s.Decode(&e.MLDSACertSet); err != nil {
return fmt.Errorf("failed to decode MLDSACertSet: %w", err)
}
return s.ListEnd()
}
// ParseEnvelopeV2 decodes a Warp 2.0 envelope from its wire format
// (leading version byte + RLP body). The leading byte MUST equal
// EnvelopeVersion2; otherwise ErrUnknownEnvelopeVersion is returned.
func ParseEnvelopeV2(b []byte) (*EnvelopeV2, error) {
if len(b) == 0 {
return nil, ErrEnvelopeEmpty
}
if len(b) > MaxEnvelopeV2Size {
return nil, ErrEnvelopeTooLarge
}
if b[0] != EnvelopeVersion2 {
return nil, fmt.Errorf("%w: byte=0x%02x", ErrUnknownEnvelopeVersion, b[0])
}
env := &EnvelopeV2{}
if err := rlp.DecodeBytes(b[1:], env); err != nil {
return nil, fmt.Errorf("failed to decode envelope v2: %w", err)
}
if err := env.Verify(); err != nil {
return nil, err
}
return env, nil
}
// ParseEnvelope is the cross-version dispatcher. It accepts either:
//
// - Warp 2.0 wire bytes (leading 0x02 + RLP envelope), or
// - Warp 1.x wire bytes (RLP-encoded Message with no leading version),
//
// and returns a Warp 2.0 EnvelopeV2 in both cases. v1 inputs produce
// an envelope with only the Beam lane populated and all v2 fields
// zero-valued; the receiver can branch on env.HasPulse() / env.HasMLDSACertSet()
// to apply the appropriate verification policy.
//
// This is the recommended entrypoint for cross-chain receivers that
// want forward compatibility without breaking existing relayers.
func ParseEnvelope(b []byte) (*EnvelopeV2, error) {
if len(b) == 0 {
return nil, ErrEnvelopeEmpty
}
// Detect v2 by the leading version byte. RLP-encoded lists never
// start with 0x02 — RLP single-byte values < 0x80 ARE the value
// itself, but a Warp 1.x message is always an RLP list (starts at
// 0xc0..0xff for short lists or 0xf7..0xff for long lists). 0x02
// is therefore unambiguously a v2 version byte.
if b[0] == EnvelopeVersion2 {
return ParseEnvelopeV2(b)
}
// Fall back to v1: parse a bare Message and lift it into an
// envelope shape with empty v2 fields.
msg, err := ParseMessage(b)
if err != nil {
return nil, err
}
return &EnvelopeV2{Message: msg}, nil
}
// VerifyV1 verifies a Warp 1.x message against the source-chain
// validator set. It is exactly the v1 path — included here as a named
// entrypoint so callers that branch on envelope version have a uniform
// API surface.
func VerifyV1(
msg *Message,
networkID uint32,
validatorState ValidatorState,
quorumNum uint64,
quorumDen uint64,
) error {
return VerifyMessage(msg, networkID, validatorState, quorumNum, quorumDen)
}
// PulseVerifier verifies a Pulsar threshold pulse against the
// envelope's transcript-binding inputs. Implementations live in the
// warp/pulsar subpackage (so this package does not need to import the
// pulsar kernel directly — that would create an import cycle through
// the threshold orchestration framework).
type PulseVerifier interface {
// VerifyPulse checks that the given pulse bytes are a valid Pulsar
// threshold signature over the envelope's canonical transcript.
// The implementation MUST bind the verification to all of:
// SourceChainID, SourceNebulaRoot, SourceKeyEraID, SourceGeneration,
// HashSuiteID. msgBytes is the byte stream of the envelope's
// embedded UnsignedMessage.
VerifyPulse(env *EnvelopeV2, msgBytes []byte) error
}
// MLDSACertSetVerifier verifies the optional ML-DSA cert-set lane.
// Implementations may verify either an ML-DSA cert set or a Z-Chain
// Groth16 rollup of one. msgBytes is the byte stream of the envelope's
// embedded UnsignedMessage.
type MLDSACertSetVerifier interface {
VerifyCertSet(env *EnvelopeV2, msgBytes []byte) error
}
// VerifyV2Options bundles the optional verifiers a Warp 2.0 receiver
// applies to an envelope. A nil PulseVerifier means "skip the Pulsar
// lane"; a nil MLDSACertSetVerifier means "skip the ML-DSA lane".
//
// RequirePulse / RequireCertSet, when true, demand the corresponding
// lane be present AND the corresponding verifier be configured AND
// the verification succeed. When the bytes are absent and the
// requirement is true, ErrInvalidMessage is returned.
type VerifyV2Options struct {
// Required Beam configuration (always verified).
NetworkID uint32
ValidatorState ValidatorState
QuorumNum uint64
QuorumDen uint64
// Optional PQ-lane verifiers.
Pulse PulseVerifier
CertSet MLDSACertSetVerifier
RequirePulse bool
RequireCertSet bool
// HashSuiteID is the suite the verifier expects the envelope to
// have been produced under. Empty means "accept whatever the
// envelope declares" (after defaulting to DefaultHashSuiteID).
HashSuiteID string
// SkipBeam, when true, skips the BLS aggregate (Beam) verification.
// Use this only when the caller has already validated the Beam
// through a separate code path; otherwise leave it false. Tests
// also use this to exercise the PQ-lane plumbing without setting
// up a full validator set.
SkipBeam bool
}
// VerifyV2 verifies a Warp 2.0 envelope under the given options:
//
// 1. Structural invariants (Verify).
// 2. Hash-suite consistency (when opts.HashSuiteID is set).
// 3. Beam lane: BLS aggregate against the source-chain validator set
// and quorum (skipped when opts.SkipBeam is true; senders rarely
// want this — it exists for tests and for receivers that have
// already validated the Beam through a separate code path).
// 4. ML-DSA cert set lane (when configured / required).
// 5. Pulsar Pulse lane (when configured / required).
//
// Verification of independent lanes is independent: a verifier that
// only cares about the Beam can leave Pulse and CertSet nil (and
// Required* false) and VerifyV2 reduces to VerifyV1.
func VerifyV2(env *EnvelopeV2, opts VerifyV2Options) error {
if err := env.Verify(); err != nil {
return err
}
if opts.HashSuiteID != "" && env.HashSuiteOrDefault() != opts.HashSuiteID {
return fmt.Errorf("%w: expected %q, got %q",
ErrEnvelopeBadSuiteID, opts.HashSuiteID, env.HashSuiteOrDefault())
}
if !opts.SkipBeam {
if err := VerifyV1(env.Message, opts.NetworkID, opts.ValidatorState, opts.QuorumNum, opts.QuorumDen); err != nil {
return err
}
}
return verifyPQLanes(env, opts)
}
// verifyPQLanes runs the optional Pulsar / ML-DSA lane verifications
// declared in opts. Split out from VerifyV2 so callers that already
// own the Beam path (e.g. existing v1 receivers) can layer in PQ-lane
// validation without re-running BLS aggregate verification.
func verifyPQLanes(env *EnvelopeV2, opts VerifyV2Options) error {
msgBytes := env.Message.UnsignedMessage.Bytes()
// ML-DSA cert set lane.
if env.HasMLDSACertSet() {
if opts.CertSet == nil {
if opts.RequireCertSet {
return fmt.Errorf("%w: ML-DSA cert set lane required but no verifier configured", ErrInvalidMessage)
}
// Lane present but no verifier — accept (caller chose to ignore).
} else if err := opts.CertSet.VerifyCertSet(env, msgBytes); err != nil {
return fmt.Errorf("ml-dsa cert set verify: %w", err)
}
} else if opts.RequireCertSet {
return fmt.Errorf("%w: ML-DSA cert set lane required but absent from envelope", ErrInvalidMessage)
}
// Pulsar Pulse lane.
if env.HasPulse() {
if opts.Pulse == nil {
if opts.RequirePulse {
return fmt.Errorf("%w: Pulsar Pulse lane required but no verifier configured", ErrInvalidMessage)
}
} else if err := opts.Pulse.VerifyPulse(env, msgBytes); err != nil {
return fmt.Errorf("pulsar pulse verify: %w", err)
}
} else if opts.RequirePulse {
return fmt.Errorf("%w: Pulsar Pulse lane required but absent from envelope", ErrInvalidMessage)
}
return nil
}
// VerifyPQLanes runs only the Pulsar / ML-DSA lane verifications on
// the envelope (skipping Beam entirely). Use this when a caller has
// already verified the Beam via VerifyV1 / VerifyMessage and wants to
// layer PQ-lane checks on top without re-running BLS aggregate
// verification. opts.NetworkID / ValidatorState / QuorumNum /
// QuorumDen / SkipBeam are ignored.
func VerifyPQLanes(env *EnvelopeV2, opts VerifyV2Options) error {
if err := env.Verify(); err != nil {
return err
}
if opts.HashSuiteID != "" && env.HashSuiteOrDefault() != opts.HashSuiteID {
return fmt.Errorf("%w: expected %q, got %q",
ErrEnvelopeBadSuiteID, opts.HashSuiteID, env.HashSuiteOrDefault())
}
return verifyPQLanes(env, opts)
}
// Equal reports whether two envelopes are byte-equal under
// canonical RLP serialisation.
func (e *EnvelopeV2) Equal(other *EnvelopeV2) bool {
if e == nil || other == nil {
return e == other
}
a, errA := e.Bytes()
b, errB := other.Bytes()
if errA != nil || errB != nil {
return false
}
return bytes.Equal(a, b)
}