Files
precompile/dex/settle_import.go
zeekayandHanzo Dev 566a8d89e9 dex: the two version stamps did not move when the object they version did
The cross-chain object shrank from the braided 118-byte form to the 60-byte
funding claim. Both stamps that exist to mark exactly that event stayed where
they were.

The staged-op record's leading version byte is still 1, and the comment above it
says an unknown version must fail the flush rather than silently mis-decode
value. Today the old record is refused, but by WIDTH — 183 bytes where 125 are
expected — which is a coincidence, not a statement. Two layouts that happen to
agree on width decode into each other. Bump it to 2.

This does NOT rescue a v0.19.8 staged Put; that record was fatal before and is
fatal now, and it should be. What changes is that the refusal is a version
mismatch instead of a width mismatch. Nothing has ever flushed a staged Put on
any live network, so the set of records this migrates is empty — which is the
only moment the stamp can be corrected at no cost.

The object commitment's domain is still ...import.object.v1 across the same
change. A declaration is a hash plus a promise about what was hashed, and the
domain is where that promise lives; leaving it behind lets a commitment from one
era be presented as a commitment from another. The previous object change
rotated it for this reason. Rotate it to v2.

Also fixes the collectPosition calldata comment, which still specified
object[69] with the braided field list, three widths behind the code beneath it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 21:34:50 -07:00

184 lines
8.5 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"errors"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
ethtypes "github.com/luxfi/geth/core/types"
"github.com/luxfi/ids"
)
// settle_import.go is THE ONE PLACE a D->C claim enters C.
//
// WHY IT EXISTS — EXECUTION MUST BE A PURE FUNCTION OF THE BLOCK.
//
// The previous shape read the object out of shared memory INSIDE EVM execution
// (sm.Get) while the matching Remove landed at block ACCEPT. Those two facts are
// irreconcilable: a node re-executing that block later — a bootstrapping node
// descending the chain through InsertBlockManual, a state-sync catch-up, an
// archive re-trace — finds the object GONE, computes a reverted receipt where the
// network computed a successful one, and dies with `invalid receipt root hash`.
// The seam worked live and wedged every node that tried to sync past it.
//
// THE MODEL (the primary network's own import discipline, which is why the owner
// pointed at it): the object's BYTES travel IN THE TRANSACTION, exactly as a
// platformvm/coreth ImportTx carries its imported inputs. Execution binds value to
// the bytes it was HANDED, so it replays byte-identically forever with no access
// to mutable peer-chain state. The proof that those bytes are the real recorded
// object is a BLOCK-level rule, checked at Verify against shared memory, where a
// mismatch REJECTS THE BLOCK instead of diverging a receipt.
//
// THE SHIP RULE DID NOT WEAKEN — IT MOVED. It is still true that "a C balance can
// be credited by 0x9999 ONLY by consuming a D->C atomic object". What changed is
// WHERE that is proven:
//
// before: execution reads shared memory -> non-reproducible, unsyncable
// after: execution binds tx-carried bytes -> reproducible forever
// + Block.Verify proves those bytes -> a forged object kills the BLOCK
//
// The declaration is emitted as a LOG, so it is covered by the receipt root and is
// therefore consensus-agreed, byte-identical on every validator, and visible to
// the host WITHOUT re-reading state — including for a consumption made from a
// NESTED call, which a calldata scan would miss. The host enumerates it with
// DecodeSettleImports and authenticates each entry against shared memory.
//
// FORGERY IS NOT PROFITABLE. A transaction that supplies fabricated object bytes
// still executes (it must — execution cannot consult shared memory), but the block
// carrying it is rejected by every validator, including the producer, which
// verifies its own block before proposing it. The forger buys a rejected block, not
// value: nothing it credited is ever accepted.
// exportedObjectHashDomain domain-separates the object commitment so an object
// hash can never be confused with any other 32-byte digest on the rail (an
// order id, a pool id, a market id). The commitment is over the FULL canonical
// object bytes, so equality of hashes is equality of (beneficiary, asset, amount)
// under keccak collision resistance.
//
// THE DOMAIN CARRIES THE OBJECT'S VERSION, so it rotates whenever the object does.
// v2 is the 60-byte funding claim; v1 was the 118-byte braided object, and the domain
// stayed at v1 across that change. A declaration is a hash plus a promise about what
// was hashed, and the domain is where that promise lives — leaving it behind lets a
// commitment from one era be presented as a commitment from another, and the whole
// point of the digest is that the two sides cannot drift.
var exportedObjectHashDomain = []byte("lux.dex.native.import.object.v2")
// SettleObjectHash is the CANONICAL commitment to a cross-chain object's bytes.
// It is the single definition both sides use: the precompile commits to the bytes
// the transaction supplied, and the host hashes the bytes it read out of shared
// memory. One function, so the two can never drift.
func SettleObjectHash(object []byte) common.Hash {
return common.BytesToHash(crypto.Keccak256(exportedObjectHashDomain, object))
}
// settleImportEventSig is DEXSettleImport(bytes32,bytes32,bytes32) — the block's
// DECLARATION that it consumed a specific cross-chain object:
//
// topic0 = DEXSettleImport(bytes32,bytes32,bytes32)
// topic1 = outputID (indexed: the object's shared-memory key)
// data = sourceChainID | objectHash
//
// It is emitted by the consuming path itself, so a reverted consumption emits
// nothing (the EVM drops a reverted frame's logs) and the declaration set is
// exactly the set of consumptions the block committed.
var settleImportEventSig = common.BytesToHash(crypto.Keccak256([]byte("DEXSettleImport(bytes32,bytes32,bytes32)")))
// SettleImportTopic is the topic0 a host filters on to find the declarations.
func SettleImportTopic() common.Hash { return settleImportEventSig }
// SettleImport is ONE declared cross-chain consumption: "this block consumed the
// object at OutputID, exported by SourceChainID, whose bytes hash to ObjectHash".
// The host must prove each one against shared memory or reject the block.
type SettleImport struct {
SourceChainID ids.ID
OutputID ids.ID
ObjectHash common.Hash
}
// emitSettleImportDeclaration writes the consumption into the receipt. Called by
// the ONE binding primitive below, so no consuming path can forget it.
func emitSettleImportDeclaration(stateDB StateDB, sourceChainID, outputID ids.ID, object []byte) {
data := make([]byte, 0, 64)
data = append(data, sourceChainID[:]...)
h := SettleObjectHash(object)
data = append(data, h[:]...)
stateDB.AddLog(&ethtypes.Log{
Address: poolManagerAddr9999,
Topics: []common.Hash{
settleImportEventSig,
common.BytesToHash(outputID[:]),
},
Data: data,
})
}
// settleImportDataLen is the fixed declaration payload width: sourceChainID(32) |
// objectHash(32).
const settleImportDataLen = 64
// DecodeSettleImports is the HOST's enumerator: it extracts every cross-chain
// consumption a block declared, from that block's logs. It is a PURE function of
// the logs — no state read, no calldata scan (so a consumption made from a nested
// call is found too), no chain context.
//
// It accepts ONLY logs emitted by 0x9999 itself carrying the declaration topic and
// the exact payload width; anything else is not a declaration and is ignored. A
// contract cannot forge one: the EVM stamps the emitting address on every log, and
// only the precompile executes at 0x9999.
func DecodeSettleImports(logs []*ethtypes.Log) []SettleImport {
var out []SettleImport
for _, l := range logs {
if l == nil || l.Address != poolManagerAddr9999 {
continue
}
if len(l.Topics) != 2 || l.Topics[0] != settleImportEventSig {
continue
}
if len(l.Data) != settleImportDataLen {
continue
}
var imp SettleImport
copy(imp.OutputID[:], l.Topics[1][:])
copy(imp.SourceChainID[:], l.Data[0:32])
imp.ObjectHash = common.BytesToHash(l.Data[32:64])
out = append(out, imp)
}
return out
}
// ErrImportObjectMalformed is returned when a transaction supplies object bytes
// that are not EXACTLY the canonical width. Refusing here (rather than decoding a
// short/long blob) is what stops a caller from steering the decode into a
// different (rail, owner, asset, amount) reading of the same bytes.
var ErrImportObjectMalformed = errors.New("dex: supplied cross-chain object is malformed")
// bindImportObject is THE binding primitive: it takes the object bytes the
// TRANSACTION supplied, refuses anything that is not canonical, and DECLARES the
// consumption into the receipt so the host can authenticate it at Verify.
//
// It deliberately does NOT read shared memory. The value a consumer credits is the
// value in these bytes; whether these bytes are the real recorded object is proven
// one level up, on the block. Every C-side consume path goes through here, so the
// declaration can never be omitted.
func bindImportObject(
stateDB StateDB,
sourceChainID, claimID ids.ID,
object []byte,
) (beneficiary common.Address, asset [32]byte, amount uint64, err error) {
beneficiary, asset, amount, ok := decodeClaim(object)
if !ok {
return common.Address{}, [32]byte{}, 0, ErrImportObjectMalformed
}
// A ZERO-AMOUNT object is not a value transfer, and refusing it HERE is what makes
// "no object" a single clear refusal: an absent object that some caller zero-pads
// to the canonical width decodes as a well-formed all-zero claim.
if amount == 0 {
return common.Address{}, [32]byte{}, 0, ErrImportAmount
}
emitSettleImportDeclaration(stateDB, sourceChainID, claimID, object)
return beneficiary, asset, amount, nil
}