Files
chains/dexvm/atomic.go
T
zeekay 562bee534a chains/dexvm: #9 consensus-fork fix — proposer-relays-once + carry-fills-in-block
Decomplect obtain-fills (proposer, BuildBlock, once network-wide) from settle-from-carried-
fills (deterministic, Accept). No validator relays at Verify/Accept -> block output is a pure
fn of (height, carried ts, tx bytes, carried fills) -> byte-identical StateRoot. NEW
carried_fills.go wire section (block + vertex) with a RESERVED fill-signature field for the
future trustless path (no 2nd wire bump). #7 computeStateRoot completed (commits state+atomic).
TestRED_PerValidatorRelay_SplitsConsensus PASS (0 cross-validator relay); redteam 21/0 (37/0);
conservation/determinism green; race-clean. NETWORK-UPGRADE-GATED wire-format change.
2026-06-17 17:12:55 -07:00

364 lines
14 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"encoding/binary"
"fmt"
"hash"
"math"
"sort"
"github.com/luxfi/database"
"github.com/luxfi/ids"
"github.com/luxfi/vm/chains/atomic"
"github.com/luxfi/chains/dexvm/txs"
)
// atomic.go — the VALUE SETTLEMENT leg of the stateless atomic ZAP proxy.
//
// This is the ONLY primitive in the proxy that moves value between two chains
// in a single atomic commit, modeled byte-for-byte on the platformvm Import/
// Export executors (node/vms/platformvm/txs/executor/standard_tx_executor.go
// ImportTx/ExportTx) and committed at block accept via atomic.SharedMemory.Apply
// (the acceptor.go:103-110 pattern). It is the X<->C shared-memory import/export
// the proxy was specced to do.
//
// CONSERVATION ORDERING (non-negotiable): import (atomic C-Chain debit into the
// proxy) -> ZAP submit to the d-chain (relay.go) -> export (atomic C-Chain
// credit) derived ONLY from the d-chain's returned fills; unfilled IOC remainder
// refunded via the export leg. The proxy NEVER mints — every exported element's
// value is value already locked by an import or already returned as a confirmed
// fill.
// atomicRequests is the per-block accumulation of cross-chain operations to
// apply atomically with the state batch at accept time.
type atomicRequests struct {
// reqs maps a peer chainID to its remove (import) / put (export) operations.
reqs map[ids.ID]*atomic.Requests
}
func newAtomicRequests() *atomicRequests {
return &atomicRequests{reqs: make(map[ids.ID]*atomic.Requests)}
}
// empty reports whether no cross-chain operations were accumulated.
func (a *atomicRequests) empty() bool { return len(a.reqs) == 0 }
// forChain returns (creating if needed) the Requests for a peer chain.
func (a *atomicRequests) forChain(chainID ids.ID) *atomic.Requests {
r, ok := a.reqs[chainID]
if !ok {
r = &atomic.Requests{}
a.reqs[chainID] = r
}
return r
}
// hashInto folds a deterministic commitment of the accumulated cross-chain
// operations into h. The atomic requests are the block's OTHER mutated output
// (alongside the persisted state) — the import UTXO removes and the export UTXO
// puts that move value across chains. Binding them into the StateRoot is what
// makes a block whose settlement legs differ (one node exported a settle, a peer
// whose relay failed exported nothing) produce a different root, even when its
// state keys and block hash match.
//
// Determinism: the per-chain map is walked in sorted chainID order (Go map
// iteration is randomized). Within a chain the RemoveRequests and PutRequests
// keep their accumulation order — that order is itself deterministic (txs are
// processed in block order). Every variable-length field is length-prefixed so
// no two distinct request sets can collide by concatenation.
func (a *atomicRequests) hashInto(h hash.Hash) {
chainIDs := make([]ids.ID, 0, len(a.reqs))
for id := range a.reqs {
chainIDs = append(chainIDs, id)
}
sort.Slice(chainIDs, func(i, j int) bool { return chainIDs[i].Compare(chainIDs[j]) < 0 })
var lenBuf [8]byte
writeChunk := func(b []byte) {
binary.BigEndian.PutUint64(lenBuf[:], uint64(len(b)))
h.Write(lenBuf[:])
h.Write(b)
}
for _, id := range chainIDs {
req := a.reqs[id]
idCopy := id
h.Write(idCopy[:])
binary.BigEndian.PutUint64(lenBuf[:], uint64(len(req.RemoveRequests)))
h.Write(lenBuf[:])
for _, rm := range req.RemoveRequests {
writeChunk(rm)
}
binary.BigEndian.PutUint64(lenBuf[:], uint64(len(req.PutRequests)))
h.Write(lenBuf[:])
for _, e := range req.PutRequests {
writeChunk(e.Key)
writeChunk(e.Value)
binary.BigEndian.PutUint64(lenBuf[:], uint64(len(e.Traits)))
h.Write(lenBuf[:])
for _, tr := range e.Traits {
writeChunk(tr)
}
}
}
}
// executeImport applies an ImportTx: it claims value exported from the source
// chain by consuming its UTXOs from shared memory. The consumed-UTXO set
// guarantees each exported UTXO is claimable exactly once (no double-import).
// The RemoveRequests are accumulated and committed atomically at accept.
//
// Mirrors platformvm standard_tx_executor.go:285 ImportTx — Consume the source
// UTXOs via a shared-memory RemoveRequest keyed by the source chain.
func (vm *VM) executeImport(tx *txs.ImportTx, ar *atomicRequests) error {
if err := tx.Verify(); err != nil {
return fmt.Errorf("import verify: %w", err)
}
utxoIDs := make([][]byte, 0, len(tx.ImportedInputs))
for _, in := range tx.ImportedInputs {
// Double-spend guard: a UTXO already consumed by a prior import cannot
// be claimed again. This is the proxy's replay protection for the
// atomic-value leg (the conservation invariant — the proxy never mints).
consumed, err := vm.state.IsConsumed(in.UTXOID)
if err != nil {
return fmt.Errorf("import: consumed check: %w", err)
}
if consumed {
return fmt.Errorf("import: %w: %s", errUTXOAlreadyImported, in.UTXOID)
}
if err := vm.state.MarkConsumed(in.UTXOID); err != nil {
return fmt.Errorf("import: mark consumed: %w", err)
}
id := in.UTXOID
utxoIDs = append(utxoIDs, id[:])
}
// Accumulate the atomic remove against the source chain. Applied with the
// state batch at accept (sm.Apply), exactly as platformvm does.
req := ar.forChain(tx.SourceChain)
req.RemoveRequests = append(req.RemoveRequests, utxoIDs...)
// Record the locked collateral so the settle leg can refund whatever the
// d-chain does NOT fill (value conservation: value_in == proceeds + refund).
// Keyed by the import's collateral ref — its first imported UTXO id, the same
// id a relay binds to via RelayOrderTx.CollateralRef. The locked amount is
// the credited output total; its asset is the locked asset. An import with no
// credited outputs (pure fee burn) locks nothing to refund.
if len(tx.Outputs) > 0 {
ref := tx.ImportedInputs[0].UTXOID
lockedAsset := tx.Outputs[0].Asset
var locked uint64
for _, o := range tx.Outputs {
locked += o.Amount
}
if err := vm.state.PutEscrow(ref, lockedAsset, locked); err != nil {
return fmt.Errorf("import: record escrow: %w", err)
}
}
return nil
}
// executeExport applies an ExportTx: it settles proceeds back to the
// destination chain by writing UTXO elements into shared memory. The exported
// amounts MUST have been derived ONLY from confirmed d-chain fills (the caller's
// responsibility — see settleFromFills); this executor enforces the wire shape
// and accumulates the PutRequests for the atomic accept-time commit.
//
// Mirrors platformvm standard_tx_executor.go:378 ExportTx — Produce exported
// UTXO elements (Key=utxoID, Value=utxoBytes, Traits=owner) into a PutRequest
// keyed by the destination chain.
func (vm *VM) executeExport(tx *txs.ExportTx, ar *atomicRequests) error {
if err := tx.Verify(); err != nil {
return fmt.Errorf("export verify: %w", err)
}
txID := tx.ID()
elems := make([]*atomic.Element, 0, len(tx.ExportedOutputs))
for i, out := range tx.ExportedOutputs {
utxoID := deriveUTXOID(txID, uint32(i))
elems = append(elems, &atomic.Element{
Key: utxoID[:],
Value: encodeExportedOutput(out),
// Trait = owner address, so the destination chain can index the UTXO
// by recipient (mirrors lux.Addressable.Addresses()).
Traits: [][]byte{out.Owner[:]},
})
}
req := ar.forChain(tx.DestinationChain)
req.PutRequests = append(req.PutRequests, elems...)
return nil
}
// commitAtomic applies the accumulated cross-chain operations atomically with
// the proxy's state batch — the single commit point (the acceptor.go:103-110
// pattern). When the runtime has no SharedMemory (the no-atomic test harness),
// it falls back to writing the batch directly so value semantics still hold.
func (vm *VM) commitAtomic(ar *atomicRequests, batch database.Batch) error {
sm := vm.sharedMemory()
if sm == nil {
// No-atomic fallback: write the state batch ourselves (the acceptor's
// else-branch). A nil shared memory means single-chain test mode.
if batch != nil {
return batch.Write()
}
return nil
}
if ar.empty() {
// Nothing cross-chain this block; still commit the state batch.
if batch != nil {
return batch.Write()
}
return nil
}
if batch == nil {
return sm.Apply(ar.reqs)
}
return sm.Apply(ar.reqs, batch)
}
// sharedMemory returns the per-chain atomic.SharedMemory handed to the proxy by
// the chain manager (runtime.Runtime.SharedMemory), or nil in the test harness.
func (vm *VM) sharedMemory() atomic.SharedMemory {
if vm.consensusRuntime == nil {
return nil
}
sm := vm.consensusRuntime.GetSharedMemory()
if sm == nil {
return nil
}
// runtime.SharedMemory is a type alias for atomic.SharedMemory.
return sm
}
// ---------------------------------------------------------------------------
// Wire helpers for exported UTXO elements + fill decoding.
// ---------------------------------------------------------------------------
// deriveUTXOID computes a deterministic UTXO id from (txID, outputIndex),
// mirroring lux.UTXOID.InputID() — SHA-256 over txID||index. Keeping it local
// avoids dragging the platformvm codec into the proxy.
func deriveUTXOID(txID ids.ID, index uint32) ids.ID {
var buf [36]byte
copy(buf[0:32], txID[:])
binary.BigEndian.PutUint32(buf[32:36], index)
return ids.ID(idHash(buf[:]))
}
// encodeExportedOutput serializes an AtomicOutput as the shared-memory value:
// owner(20) | asset(32) | amount(8). Fixed-width, deterministic.
func encodeExportedOutput(out txs.AtomicOutput) []byte {
v := make([]byte, 20+32+8)
copy(v[0:20], out.Owner[:])
copy(v[20:52], out.Asset[:])
binary.BigEndian.PutUint64(v[52:60], out.Amount)
return v
}
// float64FromBits reads a big-endian IEEE-754 float64 (the ZAP fill wire codec,
// byte-identical with dex/pkg/api and the precompile).
func float64FromBits(b []byte) float64 {
return math.Float64frombits(binary.BigEndian.Uint64(b))
}
// isFinitePositive reports whether f is a finite, strictly positive number — the
// only values a real fill can carry. Rejects NaN/Inf/<=0.
func isFinitePositive(f float64) bool {
return !math.IsNaN(f) && !math.IsInf(f, 0) && f > 0
}
// ---------------------------------------------------------------------------
// Conservation-safe float->integer rounding for settlement.
//
// Fills cross the ZAP wire as float64 (price, size); on-chain value moves in
// integer asset units (uint64). Converting a fractional notional to an integer
// MUST round in the direction that protects the conservation invariant — the
// proxy NEVER mints — so the rounding is asymmetric BY PURPOSE:
//
// - quantToCredit (FLOOR): a quantity the taker RECEIVES (proceeds) or that
// REDUCES what we owe back. Round DOWN so the proxy never credits a unit it
// did not truly realize. Worst case the taker is under-credited by <1 unit;
// that sub-unit is conserved on the d-chain side (the maker's leg), never
// fabricated out of the proxy.
// - quantToCharge (CEIL): a quantity the taker SPENDS against locked escrow
// (so refund = locked - spent). Round UP so spent is never UNDERstated.
// Understating spent is exactly the escrow-truncation mint (RED): it inflates
// the refund, letting the taker walk away with proceeds PLUS more refund than
// the unspent remainder. Ceiling spent caps refund at the true unspent value.
//
// Both take an EXACT float aggregate (summed over fills once, not per-fill
// truncated — per-fill truncation accumulates a directional leak) and apply the
// rounding ONCE at the asset boundary. A small relative epsilon absorbs IEEE-754
// representation error so an aggregate that is mathematically integral (e.g.
// 1.5*3 + 2.5*3 == 12, but may evaluate to 12 ± 1e-13) snaps to that integer
// instead of spuriously rounding to 11 or 13. epsilon is deterministic (a fixed
// constant over deterministic float inputs) so every validator rounds identically.
// settlementRoundEpsilon is the relative tolerance for snapping a float aggregate
// to a neighboring integer before directional rounding. ~1e-9 dwarfs the ~1e-13
// double-rounding error of summing realistic fill streams yet is far below one
// asset unit, so it never moves a genuinely fractional notional across an integer.
const settlementRoundEpsilon = 1e-9
// nearestIntWithin returns (n, true) when f is within a relative epsilon of the
// integer n, else (0, false). Used to snap a mathematically-integral aggregate
// back to its integer before floor/ceil.
func nearestIntWithin(f float64) (float64, bool) {
r := math.Round(f)
tol := settlementRoundEpsilon * math.Max(1, math.Abs(f))
if math.Abs(f-r) <= tol {
return r, true
}
return 0, false
}
// quantToCredit converts a proceeds aggregate to integer asset units, rounding
// DOWN (never credit more than realized). Non-finite or negative input, or a
// result exceeding uint64, is refused — the proxy must not settle a value it
// cannot represent exactly.
func quantToCredit(f float64) (uint64, error) {
if math.IsNaN(f) || math.IsInf(f, 0) || f < 0 {
return 0, fmt.Errorf("settle: non-finite proceeds %v", f)
}
if r, ok := nearestIntWithin(f); ok {
f = r
} else {
f = math.Floor(f)
}
if f > maxSettlementUnit {
return 0, fmt.Errorf("settle: proceeds %v exceeds uint64", f)
}
return uint64(f), nil
}
// quantToCharge converts a spent aggregate to integer asset units, rounding UP
// (never charge less than consumed, so the refund is never inflated). Same
// finiteness / range guards as quantToCredit.
func quantToCharge(f float64) (uint64, error) {
if math.IsNaN(f) || math.IsInf(f, 0) || f < 0 {
return 0, fmt.Errorf("settle: non-finite notional %v", f)
}
if r, ok := nearestIntWithin(f); ok {
f = r
} else {
f = math.Ceil(f)
}
if f > maxSettlementUnit {
return 0, fmt.Errorf("settle: notional %v exceeds uint64", f)
}
return uint64(f), nil
}
// maxSettlementUnit is the largest float64 that converts to uint64 without
// overflow on rounding. 2^64 is not exactly representable; the largest float64
// strictly below it is 2^64 - 2048. Using <= against this constant keeps the
// uint64() conversion in-range and deterministic.
const maxSettlementUnit = float64(1<<64 - 2048)