Files
chains/keyvm/wire.go
T
zeekayandHanzo Dev 9ec41cf150 Native-ZAP struct-is-wire for the four straggler VMs (aivm/keyvm/quantumvm/dexvm)
Completes the "one and only one way" wire migration: every VM in this repo now
serializes blocks and txs through luxfi/zap struct-is-wire at fixed field
offsets — no encoding/json, no hand-rolled big-endian, no cursor codec.

- aivm: Block + AIVertex + nested CIntent native-encoded. Fixes a latent
  data-loss bug — AIVertex had unexported fields, so encoding/json silently
  marshalled it as {}; the native encoder round-trips every field.
- keyvm: Transaction (signing preimage = SigningBytes(), excludes Auth/Sig;
  full wire = signing || sig object, so the signed bytes stay a genuine prefix
  and authenticate() re-derives a byte-identical preimage) + Block.
- quantumvm: Block + BaseTransaction. Block wire is the quantum-signature
  preimage (deterministic fixed offsets, ordered tx list).
- dexvm: block envelope (txs were already ZAP). Preserves UnixNano timestamps,
  deterministic tx order, and content-addressed carried fills.

Hardening from the adversarial crypto review (must-holds all verified: no
keyvm signature malleability/replay, no aivm CIntent id-forgery/escrow tamper,
no dexvm ordering/fill tamper, all parsers fail closed):

- keyvm (M1): make the wire strictly canonical. zap follows the root offset
  and ignores unreferenced padding inside a message's declared size, so a twin
  buffer could decode to identical fields with a different hash (id-malleability).
  ParseTransaction now binds the id to the re-serialized (canonical) form and
  rejects any non-canonical input at the door — exactly one byte-string
  authenticates per logical tx. Regression test: the padded twin is rejected.
- quantumvm (M2): derive block + tx ids as sha256(Bytes()), matching the other
  three VMs. The prior ids.ToID(parent||height) over 40 bytes (and ids.ToID over
  the >=32-byte tx wire) silently yielded ids.Empty for every block/tx,
  collapsing the block store and tx pool to a single slot. The block id is no
  longer stored in the wire, so the invariant id == sha256(Bytes()) holds
  unconditionally. Regression tests: ids are non-Empty and collision-distinct.

Not in scope (tracked separately, fail-closed today): aivm RewardPerOperator is
escrowed but not bound by ComputeIntentID — a pre-existing design gap that needs
a coordinated A-side + C-side intent-id preimage bump before any non-fail-closed
CCommitVerifier ships.

Builds + tests green under the canonical CGO=0 -mod=readonly recipe; go.mod/go.sum
are the go mod tidy canonical set (indirect deps resolved forward, all within
major). Cryptographer verdict: SAFE TO PUBLISH.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-16 13:44:24 -07:00

264 lines
7.7 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package keyvm
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"fmt"
"time"
"github.com/luxfi/ids"
"github.com/luxfi/zap"
)
// Native-ZAP struct-is-wire for K-Chain (keyvm). No hand-rolled big-endian, no
// cursor codec — the Transaction and the Block each own their marshal/parse over
// zap objects at FIXED field offsets. Re-genesis authorized, so the on-wire
// format is exactly these offsets (canonical: parse rejects trailing bytes).
//
// SECURITY — the signing/authentication architecture is preserved byte-for-byte
// in meaning, only the encoding changes:
//
// - SigningBytes() is the deterministic preimage the payer SIGNS. It is one
// zap object binding every semantically-meaningful field (Type, Algorithm,
// Payer, KeyID, GasLimit, Nonce, Payload) and EXCLUDING Auth/Sig. Because
// Payer is bound here and authenticate() requires Payer == addressOf(Auth),
// an attacker cannot swap in a different public key.
//
// - Bytes() is SigningBytes() ‖ sigObject, where sigObject carries Auth+Sig.
// The signed bytes are therefore a genuine byte-prefix of the full wire (the
// node's proposervm signed-block idiom). ID() = sha256(Bytes()).
//
// - authenticate() re-derives SigningBytes() from the parsed fields and
// verifies the signature against it; a deterministic zap marshal guarantees
// the re-derived preimage equals the signed prefix.
// ---- Transaction signing object (the SIGNED preimage; excludes Auth/Sig) ----
//
// Type u8 @ 0
// Payer 20B @ 1
// KeyID 32B @ 21
// GasLimit u64 @ 53
// Nonce u64 @ 61
// Algorithm bytes @ 69
// Payload bytes @ 77
const (
txType = 0
txPayer = 1
txKeyID = 21
txGas = 53
txNonce = 61
txAlgo = 69
txPayld = 77
txSize = 85
)
// ---- Transaction sig object (appended after the signing object) ----
//
// Auth bytes @ 0
// Sig bytes @ 8
const (
sgAuth = 0
sgSig = 8
sgSize = 16
)
// SigningBytes is the deterministic encoding the payer signs. It binds every
// semantically meaningful field — including Payer — but excludes Auth and Sig.
func (tx *Transaction) SigningBytes() []byte {
b := zap.NewBuilder(zap.HeaderSize + txSize + len(tx.Algorithm) + len(tx.Payload) + 64)
ob := b.StartObject(txSize)
ob.SetUint8(txType, tx.Type)
ob.SetBytesFixed(txPayer, tx.Payer[:])
ob.SetBytesFixed(txKeyID, tx.KeyID[:])
ob.SetUint64(txGas, tx.GasLimit)
ob.SetUint64(txNonce, tx.Nonce)
ob.SetBytes(txAlgo, []byte(tx.Algorithm))
ob.SetBytes(txPayld, tx.Payload)
ob.FinishAsRoot()
return b.Finish()
}
// Bytes is the full wire encoding: the signing object followed by an appended
// zap object carrying Auth and Sig. The signing prefix is byte-identical to
// SigningBytes(), so the payer's signature covers exactly Bytes()[:zapLen].
func (tx *Transaction) Bytes() []byte {
signing := tx.SigningBytes()
sb := zap.NewBuilder(zap.HeaderSize + sgSize + len(tx.Auth) + len(tx.Sig) + 32)
so := sb.StartObject(sgSize)
so.SetBytes(sgAuth, tx.Auth)
so.SetBytes(sgSig, tx.Sig)
so.FinishAsRoot()
sig := sb.Finish()
out := make([]byte, 0, len(signing)+len(sig))
out = append(out, signing...)
out = append(out, sig...)
return out
}
// ParseTransaction decodes a transaction from its wire encoding: the leading
// signing object, then the appended Auth/Sig object. Canonical: rejects
// trailing bytes.
func ParseTransaction(data []byte) (*Transaction, error) {
n, err := zapLen(data)
if err != nil {
return nil, err
}
sm, err := zap.Parse(data[:n])
if err != nil {
return nil, err
}
gm, err := zap.Parse(data[n:])
if err != nil {
return nil, err
}
if n+gm.Size() != len(data) {
return nil, fmt.Errorf("keyvm: %w: trailing bytes", ErrInvalidPayload)
}
so := sm.Root()
go_ := gm.Root()
tx := &Transaction{
Type: so.Uint8(txType),
Algorithm: string(so.Bytes(txAlgo)),
GasLimit: so.Uint64(txGas),
Nonce: so.Uint64(txNonce),
Payload: appendBytes(so.Bytes(txPayld)),
Auth: appendBytes(go_.Bytes(sgAuth)),
Sig: appendBytes(go_.Bytes(sgSig)),
}
copy(tx.Payer[:], so.BytesFixedSlice(txPayer, ids.ShortIDLen))
copy(tx.KeyID[:], so.BytesFixedSlice(txKeyID, 32))
// Canonical wire: zap follows the root offset and ignores unreferenced
// padding inside a message's declared size, so distinct byte-strings can
// decode to identical fields. Bind the id to the RE-SERIALIZED (canonical)
// form and reject any input that is not already canonical — exactly one
// byte-string authenticates per logical tx (no id-malleability, fail-closed).
canonical := tx.Bytes()
if !bytes.Equal(data, canonical) {
return nil, fmt.Errorf("keyvm: %w: non-canonical tx encoding", ErrInvalidPayload)
}
tx.id = ids.ID(sha256.Sum256(canonical))
return tx, nil
}
// ---- Block ----
//
// ParentID 32B @ 0
// Height u64 @ 32
// Timestamp i64 @ 40 (Unix seconds — K-Chain's block-time resolution)
// TxLens list @ 48 (u32 per Transaction wire length)
// TxBlob bytes @ 56 (concatenated Transaction wire objects)
const (
blkParent = 0
blkHeight = 32
blkTime = 40
blkTxLens = 48
blkTxBlob = 56
blkSize = 64
)
// Bytes serializes the block (parent, height, timestamp, transactions).
func (b *Block) Bytes() []byte {
txLens := make([]uint32, len(b.transactions))
var txBlob []byte
for i, tx := range b.transactions {
txb := tx.Bytes()
txLens[i] = uint32(len(txb))
txBlob = append(txBlob, txb...)
}
bld := zap.NewBuilder(zap.HeaderSize + blkSize + len(txBlob) + 4*len(txLens) + 128)
txLensOff := writeU32List(bld, txLens)
ob := bld.StartObject(blkSize)
ob.SetBytesFixed(blkParent, b.parentID[:])
ob.SetUint64(blkHeight, b.height)
ob.SetInt64(blkTime, b.timestamp.Unix())
ob.SetList(blkTxLens, txLensOff, len(txLens))
ob.SetBytes(blkTxBlob, txBlob)
ob.FinishAsRoot()
return bld.Finish()
}
func parseBlock(vm *VM, data []byte) (*Block, error) {
msg, err := zap.Parse(data)
if err != nil {
return nil, err
}
if msg.Size() != len(data) {
return nil, fmt.Errorf("keyvm: %w: block trailing bytes", ErrInvalidPayload)
}
o := msg.Root()
b := &Block{vm: vm}
copy(b.parentID[:], o.BytesFixedSlice(blkParent, 32))
b.height = o.Uint64(blkHeight)
b.timestamp = time.Unix(o.Int64(blkTime), 0)
lens := readU32List(o, blkTxLens)
blob := o.Bytes(blkTxBlob)
b.transactions = make([]*Transaction, 0, len(lens))
pos := 0
for _, l := range lens {
if pos+int(l) > len(blob) {
return nil, fmt.Errorf("keyvm: %w: tx blob out of bounds", ErrInvalidPayload)
}
tx, err := ParseTransaction(blob[pos : pos+int(l)])
if err != nil {
return nil, err
}
b.transactions = append(b.transactions, tx)
pos += int(l)
}
b.id = b.computeID()
return b, nil
}
// ---- shared helpers ----
// zapLen returns the total length of the leading self-delimiting zap message in
// b (its header size field @[12:16]) — the split point between the signing
// prefix and the appended Auth/Sig object.
func zapLen(b []byte) (int, error) {
if len(b) < zap.HeaderSize {
return 0, fmt.Errorf("keyvm: %w: short buffer", ErrInvalidPayload)
}
n := int(binary.LittleEndian.Uint32(b[12:16]))
if n < zap.HeaderSize || n > len(b) {
return 0, fmt.Errorf("keyvm: %w: bad zap length", ErrInvalidPayload)
}
return n, nil
}
func writeU32List(b *zap.Builder, xs []uint32) int {
lb := b.StartList(4)
for _, x := range xs {
lb.AddUint32(x)
}
off, _ := lb.Finish()
return off
}
func readU32List(o zap.Object, ptrOff int) []uint32 {
l := o.ListStride(ptrOff, 4)
n := l.Len()
out := make([]uint32, n)
for i := 0; i < n; i++ {
out[i] = l.Uint32(i)
}
return out
}
func appendBytes(b []byte) []byte {
if len(b) == 0 {
return nil
}
return append([]byte(nil), b...)
}