dchain: a C chain id is not a rail, and the import half thought it was

executeImport refused only when the C chain id was empty. executeExport also
refused when shared memory was nil. The plugin server sets a C chain id
unconditionally and leaves shared memory nil whenever the node wired no atomic
server, which is the state of every live network today — so on the import side
60 self-authored bytes named a beneficiary and an amount and became real,
spendable balance, with no C-side debit anywhere behind them.

verifyImports exists to catch exactly that, and returned nil on the same nil
handle. Its reason was that "executeImport rejects without a C chain id": true,
and never the condition that holds. Both guards keyed on the wrong fact, and the
two holes covered for each other.

Bind both halves to the same condition. Execution refuses the credit without
shared memory; the block that carried the import is rejected rather than
accepted unproven. Either alone is sufficient, which is why there are two. A
block carrying no import is unaffected — the check is per-import, so an absent
rail is not an error, only crossing on one is.

commitAtomic's nil-shared-memory branch asserted this was already unreachable
while committing the state anyway and dropping the op. Now that both producers
really do refuse, make the claim self-enforcing: accumulated ops with nothing to
apply them to stop the node, the same answer an Apply that errors already gets.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-07 21:19:24 -07:00
co-authored by Hanzo Dev
parent dc4ae7c55a
commit d9da4dd317
3 changed files with 135 additions and 9 deletions
+14 -4
View File
@@ -286,7 +286,13 @@ func (vm *VM) sharedMemory() atomic.SharedMemory {
// claim can only credit the account the claim already names, so a relayer that
// refuses to deliver cannot strand anyone — any other participant can.
func (vm *VM) executeImport(db database.KeyValueReaderWriterDeleter, ar *atomicRequests, claimID ids.ID, object []byte) (credited uint64, ok bool, err error) {
if vm.cChainID == ids.Empty {
// BOTH ENDS OR NEITHER. The carried bytes are only ever a CLAIM about a recorded
// object; what makes them value is that shared memory holds the same object, proven
// on the block. With no shared memory that proof cannot be taken — so the credit
// cannot be given, or 60 self-authored bytes are money. A C chain id alone is not
// the rail: the plugin server sets one unconditionally, and every live network has
// one today with no atomic server behind it.
if vm.sharedMemory() == nil || vm.cChainID == ids.Empty {
return 0, false, nil // rail not wired: deterministic reject, no mint
}
@@ -402,9 +408,13 @@ func (vm *VM) commitAtomic(overlay versionDB, ar *atomicRequests) error {
}
sm := vm.sharedMemory()
if sm == nil {
// Operations were accumulated but no shared memory is wired — unreachable on
// this path (import/export reject when sm is nil). Commit state only.
return overlay.Commit()
// Operations were accumulated with nothing to apply them to. Both producers
// refuse when sm is nil, so reaching here means a new path started accumulating
// without that guard — and this branch used to COMMIT THE STATE ANYWAY while
// dropping the op, which is the "credit lands, remove never does" shape the
// whole file exists to prevent. An unappliable op is the same condition as an
// Apply that errors, and gets the same answer: stop.
return errors.New("dchain: rail ops accumulated with no shared memory")
}
if err := overlay.Commit(); err != nil {
return fmt.Errorf("dchain: rail state commit: %w", err)
+108
View File
@@ -11,11 +11,13 @@ import (
"github.com/luxfi/crypto"
"github.com/luxfi/database/memdb"
"github.com/luxfi/database/prefixdb"
"github.com/luxfi/database/versiondb"
"github.com/luxfi/dex/pkg/dex"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
luxvm "github.com/luxfi/vm"
"github.com/luxfi/vm/chains/atomic"
)
@@ -76,6 +78,112 @@ func newRailHarness(t *testing.T) *railHarness {
return &railHarness{vm: vm, dSM: dSM, cSM: cSM, cChainID: cChainID, dChainID: dChainID}
}
// newUnwiredVM builds a VM in the state EVERY LIVE NETWORK IS IN TODAY: the plugin
// server hands it a CChainID unconditionally, and SharedMemory stays nil because the
// node wired no atomic server. It is the exact configuration under which the two
// halves of the rail must still agree that there is no rail.
func newUnwiredVM(t *testing.T) *VM {
t.Helper()
logger := log.NewNoOpLogger()
baseDB := memdb.New()
vm := &VM{}
if err := vm.Initialize(context.Background(), block.Init{
Genesis: []byte(testDocument),
Runtime: &runtime.Runtime{
ChainID: ids.GenerateTestID(),
CChainID: ids.GenerateTestID(),
NetworkID: 96369,
Log: logger,
// SharedMemory deliberately absent.
},
DB: prefixdb.New([]byte{1}, baseDB),
Log: logger,
ToEngine: make(chan block.Message, 16),
Config: authConfig(t),
}); err != nil {
t.Fatalf("Initialize unwired vm: %v", err)
}
if err := vm.SetState(context.Background(), uint32(luxvm.Ready)); err != nil {
t.Fatalf("SetState: %v", err)
}
return vm
}
// TestRail_UnwiredRailCreditsNothing pins BOTH halves of the rail to the SAME wiring
// condition, because a rail with one end missing is not half a rail — it is a mint.
//
// The two halves used to disagree. Export refused without shared memory; import refused
// only without a C chain id, and the plugin server sets a C chain id unconditionally. So
// on a node with no atomic server — every live network, right now — 60 self-authored
// bytes named a beneficiary and an amount and became real, spendable balance. The block
// authenticator that exists to catch exactly that returned nil on the same nil handle,
// reasoning that import rejects without a C chain ID; it rejects without a C chain id,
// which was present. Two guards, both keyed to the wrong fact, and the money was free.
//
// The property is one sentence: WITHOUT SHARED MEMORY THERE IS NO PROOF, AND WITHOUT
// PROOF THERE IS NO CREDIT. Execution refuses the credit, and the block that carried the
// import is rejected rather than accepted unproven — either alone is sufficient, which is
// the point of having both.
func TestRail_UnwiredRailCreditsNothing(t *testing.T) {
vm := newUnwiredVM(t)
if vm.sharedMemory() != nil {
t.Fatal("setup: this test is only meaningful with no shared memory wired")
}
if vm.cChainID == ids.Empty {
t.Fatal("setup: the C chain id must be set — an absent one is the condition the old " +
"guard keyed on, and the whole finding is that it is present anyway")
}
mallory := addrOf(t, "unwired-mallory")
acct := Account16(dex.AuthSecp256k1, mallory)
var native [32]byte
const minted uint64 = 1_000_000_000_000
// A claim id nobody ever wrote, and an object Mallory authored herself. There is no
// C-side debit anywhere behind these bytes.
forgedID := ids.GenerateTestID()
forgedObject := encodeClaim(mallory, native, minted)
overlay := versiondb.New(vm.db)
before, err := getAvailable(overlay, acct, native)
if err != nil {
t.Fatalf("getAvailable: %v", err)
}
credited, ok, err := vm.executeImport(overlay, newAtomicRequests(), forgedID, forgedObject)
if err != nil {
t.Fatalf("executeImport: %v", err)
}
after, aerr := getAvailable(overlay, acct, native)
if aerr != nil {
t.Fatalf("getAvailable: %v", aerr)
}
if ok || credited != 0 {
t.Errorf("executeImport accepted a claim it cannot prove: ok=%v credited=%d — with no "+
"shared memory there is nothing behind these bytes but the bytes", ok, credited)
}
if after != before {
t.Errorf("balance moved %d -> %d on an unprovable import: %d units minted from nothing",
before, after, after-before)
}
// The block-level half, independently. It must reject rather than wave the import
// through, and it must say so about THIS claim.
blk := &Block{vm: vm, txs: []*Tx{{Type: TxImport, Body: EncodeImportBody(forgedID, forgedObject)}}}
if verr := blk.verifyImports(); verr == nil {
t.Error("verifyImports accepted a block carrying an import it had no way to authenticate; " +
"an unprovable import must cost the block, not become balance")
}
// And a block with no imports is still fine on an unwired node: the rail being absent
// is not an error, only crossing on it is.
plain := &Block{vm: vm, txs: []*Tx{depositTx(t, "unwired-plain", native, 1)}}
if verr := plain.verifyImports(); verr != nil {
t.Errorf("verifyImports rejected a block that carries no import at all: %v", verr)
}
}
// addrOf returns a test account's 20-byte C address. Account16 of it is the account
// their signed D orders draw from, so an import that credits the address's account
// credits exactly the balance they can trade with.
+13 -5
View File
@@ -1139,11 +1139,6 @@ func (b *Block) verifyImports() error {
return nil
}
sm := b.vm.sharedMemory()
if sm == nil {
// No cross-chain capability wired: an import could not have credited anything
// (executeImport rejects without a C chain id), so there is nothing to prove.
return nil
}
for _, tx := range b.txs {
if tx.Type != TxImport {
continue
@@ -1152,6 +1147,19 @@ func (b *Block) verifyImports() error {
if err != nil {
return fmt.Errorf("dchain: block %s: %w", b.id, err)
}
// NO HANDLE, NO PROOF, NO BLOCK. This used to return nil here for the whole
// block, on the reasoning that execution rejects an import without a C chain id.
// It rejects without a C chain ID, and the plugin server supplies one
// unconditionally — so the reasoning named a condition that is never the one
// that holds, and the authenticator waved through precisely the imports it could
// not authenticate. A node with no shared memory cannot tell a recorded object
// from a fabricated one; the only honest answer is to refuse the block. Blocks
// carrying no import are unaffected, because the check is per-import: an absent
// rail is not an error, crossing on one is.
if sm == nil {
return fmt.Errorf("dchain: import unprovable in block %s (height %d): claim %s carried with no shared memory wired",
b.id, b.height, claimID)
}
vals, gerr := sm.Get(b.vm.cChainID, [][]byte{claimID[:]})
if gerr != nil || len(vals) != 1 || len(vals[0]) == 0 {
return fmt.Errorf("dchain: import unbacked in block %s (height %d): claim %s is not in shared memory: %w",