mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
HIGH (inert attestation gate): FillAttestationPubKey was read by settleCarried but NEVER written — parseConfig copied only 10 enumerated fields and parseGenesis carried only DexZapEndpoint+TrustedChains. So in prod the pubkey was nil, verifyFillAttestation returned nil, trustCarried was always true, and the fabricated-fills guard could never engage: a malicious/MITM proposer could settle fabricated fills and drain a cross-taker's seam reserve via the proceeds leg. Fix: add FillAttestationPubKey (hex) to the Genesis struct + parseGenesis, assigned into vm.Config. It is GENESIS-pinned only — deliberately absent from parseConfig — because it is consensus-affecting (gates trustCarried -> escrow-consume + export legs -> computeStateRoot); a per-node value would fork the StateRoot. A wrong-width key is a FATAL genesis error (fail-closed). FillAttestationSeed stays json:"-" (KMS-only, never in a genesis/config blob). MEDIUM (consensus comment): config.go now states the stronger invariant — the pubkey is STRICTER than DexZapEndpoint (which is node-local transport, not recomputed in the deterministic settle path) and MUST be a single genesis-pinned constant. MEDIUM (taker-authenticated MEV floor): the proceeds export object now carries the matched input (AtomicOutput.Spent; shared-memory object 61->69 bytes, both repos in lockstep) so the C-side ImportSettlement can enforce the taker's OWN recorded slippage limit (proceeds >= spent*worstRate) independently of the keeper's relay-supplied priceLimit. A keeper that zeroes the relay limit to sandwich the taker still produces a proceeds object C will refuse; understating spent loosens the floor but inflates refund, which the per-taker principal cap + seam-reserve conservation independently bound. Tests (redteam, CGO_ENABLED=0 pure Go): redteam_genesis_attestation_test drives a REAL genesis blob through Initialize->parseGenesis (NOT a direct Config write) and proves the pubkey reaches Config, a fabricated/unattested block becomes a full refund by virtue of genesis alone, a wrong-width genesis key fails Initialize closed, and the runtime config blob cannot set the consensus-affecting pubkey. native_seam canary pins the joint 69-byte object wire (incl. spent) across both repos. Full dexvm redteam + unit suites green.
515 lines
22 KiB
Go
515 lines
22 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.
|
|
//
|
|
// ASSET BIND (the native-aliasing fix): an import credits the ledger with the
|
|
// asset of the UTXO it ACTUALLY consumes, never an asset it merely declares. The
|
|
// consumed UTXO's recorded value (owner|asset|amount, written by the export side
|
|
// via encodeExportedOutput) is read back from shared memory and is authoritative:
|
|
// the declared input asset/amount MUST equal the recorded asset/amount, every
|
|
// consumed UTXO must be the SAME asset, and every credited output must name that
|
|
// asset. Composed with ImportTx.Verify (output.Asset == input.Asset), the credit
|
|
// is provably the consumed asset — so a bogus-token UTXO cannot be imported as
|
|
// native value. (When the runtime has no shared memory — single-chain test mode —
|
|
// there is no real cross-chain UTXO to read or alias; the structural Verify bind
|
|
// still holds.)
|
|
func (vm *VM) executeImport(tx *txs.ImportTx, ar *atomicRequests) error {
|
|
if err := tx.Verify(); err != nil {
|
|
return fmt.Errorf("import verify: %w", err)
|
|
}
|
|
|
|
sm := vm.sharedMemory()
|
|
var (
|
|
importedAsset ids.ID
|
|
importedOwner ids.ShortID
|
|
importedRail txs.Rail
|
|
haveAsset bool
|
|
)
|
|
// PASS 1 — VALIDATE every input + output with NO state mutation. The import is
|
|
// atomic: a rejection on ANY input/output bind must leave the consumed-set
|
|
// UNTOUCHED, so MarkConsumed is deferred to pass 2 below. (A bind failure after
|
|
// MarkConsumed would burn the UTXO on a rejected import, blocking the rightful
|
|
// owner's later claim — the all-or-nothing discipline the conservation invariant
|
|
// requires.)
|
|
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)
|
|
}
|
|
// Bind the declared input to the UTXO the chain ACTUALLY holds. The remove
|
|
// has not been applied yet (it commits at accept), so the recorded value is
|
|
// still readable here. A UTXO that does not exist in shared memory errors —
|
|
// the proxy never credits an unbacked input.
|
|
if sm != nil {
|
|
id := in.UTXOID
|
|
vals, gerr := sm.Get(tx.SourceChain, [][]byte{id[:]})
|
|
if gerr != nil {
|
|
return fmt.Errorf("import: read source UTXO %s: %w", in.UTXOID, gerr)
|
|
}
|
|
recRail, recOwner, recAsset, recAmount, _, ok := decodeExportedOutput(vals[0])
|
|
if !ok {
|
|
return fmt.Errorf("import: UTXO %s: %w", in.UTXOID, errImportUTXOValueMalformed)
|
|
}
|
|
if in.Asset != recAsset {
|
|
return fmt.Errorf("import: UTXO %s: %w (declared %s, recorded %s)", in.UTXOID, errImportAssetMismatch, in.Asset, recAsset)
|
|
}
|
|
if in.Amount != recAmount {
|
|
return fmt.Errorf("import: UTXO %s: %w (declared %d, recorded %d)", in.UTXOID, errImportAmountMismatch, in.Amount, recAmount)
|
|
}
|
|
if !haveAsset {
|
|
importedAsset = recAsset
|
|
importedOwner = recOwner
|
|
importedRail = recRail
|
|
haveAsset = true
|
|
} else {
|
|
if importedAsset != recAsset {
|
|
return fmt.Errorf("import: %w", errImportMixedAssets)
|
|
}
|
|
// Every consumed UTXO must share ONE recorded owner: a deposit credits
|
|
// one (owner,asset) ledger row, and mixing owners would let one UTXO's
|
|
// owner authorize spending another's value.
|
|
if importedOwner != recOwner {
|
|
return fmt.Errorf("import: %w (UTXO %s owner %s != %s)", errImportWrongOwner, in.UTXOID, recOwner, importedOwner)
|
|
}
|
|
// Every consumed UTXO must share ONE recorded rail (lane): an import
|
|
// funds a single lane, and mixing lanes would let a swap object and an LP
|
|
// object be claimed in one credit (the cross-rail consume H1 closes).
|
|
if importedRail != recRail {
|
|
return fmt.Errorf("import: %w (UTXO %s rail %d != %d)", errImportMixedRails, in.UTXOID, recRail, importedRail)
|
|
}
|
|
}
|
|
}
|
|
id := in.UTXOID
|
|
utxoIDs = append(utxoIDs, id[:])
|
|
}
|
|
|
|
// Every credited output must be denominated in the asset actually consumed AND
|
|
// owned by the consumed UTXO's recorded owner — the authoritative half of the
|
|
// native-aliasing bind (asset axis; the structural output==input half is also
|
|
// pinned in ImportTx.Verify) and the owner-aliasing bind (owner axis; an attacker
|
|
// must not consume a victim's exported UTXO and credit it to their own account).
|
|
// Skipped only when no shared memory was read (single-chain test mode), where
|
|
// Verify's structural bind already holds (and there is no real cross-chain UTXO
|
|
// to alias).
|
|
if haveAsset {
|
|
for _, o := range tx.Outputs {
|
|
if o.Asset != importedAsset {
|
|
return fmt.Errorf("import: %w (output %s, consumed %s)", errImportOutputAsset, o.Asset, importedAsset)
|
|
}
|
|
if o.Owner != importedOwner {
|
|
return fmt.Errorf("import: %w (output owner %s, consumed %s)", errImportWrongOwner, o.Owner, importedOwner)
|
|
}
|
|
// Authoritative rail bind: every credited output must be on the SAME lane
|
|
// the consumed UTXO recorded — so an import cannot re-lane value (credit a
|
|
// swap object onto the LP lane or vice-versa). Composed with Verify's
|
|
// structural output-rail-uniformity, the credit is provably the consumed
|
|
// object's rail (the H1 fix, owner/asset/rail all pinned to the record).
|
|
if o.Rail != importedRail {
|
|
return fmt.Errorf("import: %w (output rail %d, consumed %d)", errImportMixedRails, o.Rail, importedRail)
|
|
}
|
|
}
|
|
}
|
|
|
|
// PASS 2 — COMMIT: all binds passed, so mark every consumed UTXO now (the import
|
|
// is accepted as a whole). After this point the import cannot fail.
|
|
for _, in := range tx.ImportedInputs {
|
|
if err := vm.state.MarkConsumed(in.UTXOID); err != nil {
|
|
return fmt.Errorf("import: mark consumed: %w", err)
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// The escrow's OWNER is the AUTHENTICATED owner of the consumed C->D object —
|
|
// importedOwner, read back from shared memory and bound to every credited output
|
|
// above. This is the CRITICAL escrow-theft fix: settleFromFills derives BOTH the
|
|
// settle authority and the proceeds/refund payout target from this recorded
|
|
// owner, never from the unauthenticated relay tx sender, so a relay naming a
|
|
// victim's collateral ref cannot settle it or redirect its value. When there is
|
|
// no shared memory (single-chain test mode) there is no real cross-chain UTXO to
|
|
// authenticate against; the structurally-verified credited owner (Outputs[0].Owner,
|
|
// pinned uniform with the asset/rail axes in Verify) is the escrow owner.
|
|
if len(tx.Outputs) > 0 {
|
|
ref := tx.ImportedInputs[0].UTXOID
|
|
lockedAsset := tx.Outputs[0].Asset
|
|
escrowOwner := importedOwner
|
|
if !haveAsset {
|
|
escrowOwner = tx.Outputs[0].Owner
|
|
}
|
|
var locked uint64
|
|
for _, o := range tx.Outputs {
|
|
locked += o.Amount
|
|
}
|
|
if err := vm.state.PutEscrow(ref, escrowOwner, 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[:]))
|
|
}
|
|
|
|
// exportedOutputSize is the fixed shared-memory UTXO value width: rail(1) |
|
|
// owner(20) | asset(32) | amount(8) | spent(8). IDENTICAL to the precompile's
|
|
// exportedOutputSize9999 — the rail byte (the H1 lane tag) leads the object and the
|
|
// trailing spent(8) carries the matched INPUT amount on a swap proceeds leg (0 on every
|
|
// other leg) so the C-side ImportSettlement can enforce the taker's recorded slippage
|
|
// limit independently of the keeper (the taker-authenticated MEV floor). Both repos
|
|
// extend this width in lockstep; native_seam_parity_test pins it.
|
|
const exportedOutputSize = 1 + 20 + 32 + 8 + 8
|
|
|
|
// encodeExportedOutput serializes an AtomicOutput as the shared-memory value:
|
|
// rail(1) | owner(20) | asset(32) | amount(8) | spent(8). Fixed-width, deterministic.
|
|
// The rail byte is the lane the value travels (RailSwap / RailLP), bound by the
|
|
// precompile's matching consume path on the C side; spent is the matched input that
|
|
// produced a proceeds output (0 elsewhere), the price-binding witness the proceeds floor
|
|
// checks.
|
|
func encodeExportedOutput(out txs.AtomicOutput) []byte {
|
|
v := make([]byte, exportedOutputSize)
|
|
v[0] = byte(out.Rail)
|
|
copy(v[1:21], out.Owner[:])
|
|
copy(v[21:53], out.Asset[:])
|
|
binary.BigEndian.PutUint64(v[53:61], out.Amount)
|
|
binary.BigEndian.PutUint64(v[61:69], out.Spent)
|
|
return v
|
|
}
|
|
|
|
// decodeExportedOutput is the inverse of encodeExportedOutput: it reads back the
|
|
// (rail, owner, asset, amount, spent) a consumed source UTXO RECORDED in shared memory.
|
|
// executeImport uses this to bind the credited rail, asset AND owner to the value the
|
|
// chain actually holds for that UTXO — not what the importing tx merely declares — so
|
|
// a bogus-token UTXO can never be imported as native value (the native-aliasing fix),
|
|
// a victim's exported UTXO can never be credited to an attacker's account (the
|
|
// owner-aliasing fix), and a cross-rail object can never fund the wrong lane (the H1
|
|
// rail fix). spent is meaningful only on a swap proceeds leg (the MEV floor witness);
|
|
// import ignores it. ok=false for any value that is not exactly the canonical width, so
|
|
// a corrupt/garbage record is never reinterpreted into a credit.
|
|
func decodeExportedOutput(v []byte) (rail txs.Rail, owner ids.ShortID, asset ids.ID, amount, spent uint64, ok bool) {
|
|
if len(v) != exportedOutputSize {
|
|
return 0, ids.ShortEmpty, ids.Empty, 0, 0, false
|
|
}
|
|
rail = txs.Rail(v[0])
|
|
copy(owner[:], v[1:21])
|
|
copy(asset[:], v[21:53])
|
|
amount = binary.BigEndian.Uint64(v[53:61])
|
|
spent = binary.BigEndian.Uint64(v[61:69])
|
|
return rail, owner, asset, amount, spent, true
|
|
}
|
|
|
|
// 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)
|