merge: dexvm C<->D keeper RPC seam (dex.submitTx + dex.getSettlement)

Exposes the two RPCs the off-chain dexkeeper drives the 0x9999 two-phase atomic
DEX seam with:
  - dex.submitTx: mempool entry so the keeper can inject ImportTx (consume the
    C->D atomic object) + the settling RelayOrderTx (clob_submit, CollateralRef-bound)
  - dex.getSettlement(collateralRef): returns the D->C proceeds object's
    (outputID, amount) recorded deterministically in settleFromFills, the Phase-B input

State.Put/GetSettlement write through versiondb (commit-atomic, consensus-safe).
RelayOrderTx.Verify permits an unsigned relay (authority binds to the escrow owner,
not From). dexvm/api + dexvm/state tests pass.
This commit is contained in:
2026-06-22 02:43:37 -07:00
6 changed files with 531 additions and 9 deletions
+152 -1
View File
@@ -14,12 +14,15 @@ import (
"errors"
"fmt"
"net/http"
"github.com/luxfi/ids"
)
var (
ErrNotBootstrapped = errors.New("DEX proxy not bootstrapped")
ErrInvalidRequest = errors.New("invalid request")
ErrRelayNotAvailable = errors.New("d-chain relay not configured")
ErrSubmitUnavailable = errors.New("tx submission not available on this VM instance")
)
// Relayer is the relay surface the proxy VM exposes to the API: forward an
@@ -34,18 +37,59 @@ type Relayer interface {
type VM interface {
IsBootstrapped() bool
Relay() Relayer
// GetSettlement returns the proceeds D->C object's (outputID, amount) recorded
// under a collateral ref (the C->D intentID) once a swap settled with proceeds.
// found is false until the settling block is accepted. Purely informational — it
// is the seam the keeper polls to build the C-side Phase-B ImportSettlement.
GetSettlement(ref ids.ID) (outputID ids.ID, amount uint64, found bool, err error)
}
// TxSubmitter is the OPTIONAL mempool-entry surface. The atomic proxy txs
// (ImportTx / RelayOrderTx / ExportTx) are deterministic block inputs; the
// proposer builds a block from whatever sits in the pending pool. SubmitTx is
// the canonical PUBLIC entry that appends a wire-encoded tx to that pool and
// notifies the consensus engine. It is implemented by the ChainVM wrapper (which
// owns the pending pool + the engine channel), NOT the inner proxy VM — so the
// dex service exposes it only when wired against a TxSubmitter (the node path).
// A nil/absent submitter => SubmitTx RPC reports ErrSubmitUnavailable, leaving
// the read-only Ping/Status/Relay surface intact (standalone/test path).
//
// WHY THIS IS THE C<->D KEEPER ENTRY: the C-side 0x9999 SubmitSwapIntent writes a
// C->D atomic object + emits IntentSubmitted. Nothing on D consumes that object
// on its own — executeImport runs only over an ImportTx that arrives in a block,
// and the proposer's pending pool was previously unreachable from outside the
// process (CreateHandlers exposed only Ping/Status/Relay). This method is the
// missing seam: an off-chain keeper that watches IntentSubmitted submits the
// ImportTx (consume the C->D object, fund the D order) + the settling
// RelayOrderTx (clob_submit, CollateralRef-bound) here; the proposer then relays
// once (obtainFills) and settles the D->C proceeds, which the C-side Phase-B
// ImportSettlement consumes to emit DEXFill.
type TxSubmitter interface {
SubmitTx(tx []byte) error
}
// Service provides the RPC API for the DEX VM proxy.
type Service struct {
vm VM
// submitter is the OPTIONAL mempool entry. nil on the standalone/test path
// (read-only surface); set to the ChainVM on the node path so SubmitTx works.
submitter TxSubmitter
}
// NewService creates a new API service.
// NewService creates a new API service with the read-only surface
// (Ping/Status/Relay). SubmitTx reports ErrSubmitUnavailable until a submitter is
// wired (see NewServiceWithSubmitter / the node's ChainVM.CreateHandlers).
func NewService(vm VM) *Service {
return &Service{vm: vm}
}
// NewServiceWithSubmitter creates the API service with the tx-submission surface
// enabled. The submitter (the ChainVM wrapper) owns the pending pool + the engine
// channel; this is the seam the C<->D keeper uses to inject ImportTx/RelayOrderTx.
func NewServiceWithSubmitter(vm VM, submitter TxSubmitter) *Service {
return &Service{vm: vm, submitter: submitter}
}
// ============================================
// Health and Status
// ============================================
@@ -124,6 +168,113 @@ func (s *Service) Relay(r *http.Request, args *RelayArgs, reply *RelayReply) err
return nil
}
// ============================================
// Tx submission (the C<->D keeper mempool entry)
// ============================================
// SubmitTxArgs carries a single wire-encoded proxy transaction (hex). The wire is
// the dexvm tx codec: type byte (TxImport/TxExport/TxRelayOrder/…) + JSON body, as
// produced by chains/dexvm/txs.Marshal. The service does NOT parse or validate the
// body — the VM parses + Verify-s it deterministically when it is drawn into a
// block. Submitting a malformed tx is harmless: it fails Verify in ProcessBlock and
// is dropped, never settled.
type SubmitTxArgs struct {
Tx string `json:"tx"` // hex-encoded tx wire bytes
}
// SubmitTxReply returns the submitted tx's id (sha-256 checksum of the wire,
// matching txs.BaseTx.TxID) so the caller can correlate it on-chain.
type SubmitTxReply struct {
TxID string `json:"txID"`
}
// SubmitTx appends a wire-encoded proxy transaction to the pending pool so the
// proposer includes it in the next block. This is the canonical public mempool
// entry and the SEAM the C<->D keeper drives: it submits the ImportTx (consume the
// C->D atomic object the C-side 0x9999 SubmitSwapIntent wrote, funding the D order)
// and the settling RelayOrderTx (clob_submit, CollateralRef-bound) here; the
// proposer relays once (obtainFills) and the D->C proceeds settle back, which the
// C-side Phase-B ImportSettlement consumes to emit DEXFill.
//
// It requires the VM to be bootstrapped (a tx submitted mid-bootstrap would race
// the state machine) and a wired submitter (the node path; ErrSubmitUnavailable on
// the read-only standalone/test surface). The fee/admission gate lives in the
// submitter (ChainVM.SubmitTx), not here — this is a thin transport shim, exactly
// like Relay.
func (s *Service) SubmitTx(_ *http.Request, args *SubmitTxArgs, reply *SubmitTxReply) error {
if !s.vm.IsBootstrapped() {
return ErrNotBootstrapped
}
if s.submitter == nil {
return ErrSubmitUnavailable
}
if args.Tx == "" {
return fmt.Errorf("%w: tx required", ErrInvalidRequest)
}
raw, err := hex.DecodeString(args.Tx)
if err != nil {
return fmt.Errorf("%w: tx must be hex", ErrInvalidRequest)
}
if err := s.submitter.SubmitTx(raw); err != nil {
return err
}
// TxID is the dexvm's canonical tx id: ids.Checksum256 over the wire bytes,
// byte-identical to txs.BaseTx.TxID (stampBase). Reuse it (not a private
// re-hash) so the id the keeper gets back is exactly the one the VM stamps.
reply.TxID = ids.Checksum256(raw).String()
return nil
}
// ============================================
// Settlement coordinate (the keeper's Phase-B input)
// ============================================
// GetSettlementArgs names the collateral ref (the C->D intentID, hex/cb58 ids.ID
// string) whose proceeds D->C object the keeper wants to claim.
type GetSettlementArgs struct {
CollateralRef string `json:"collateralRef"`
}
// GetSettlementReply returns the proceeds object's coordinate. Settled=false means
// the settling block has not been accepted yet (the keeper keeps polling) or the
// settle was a pure refund (no proceeds object to claim). When Settled is true,
// OutputID + Amount are the DS01 Phase-B body inputs (outputID|amount|intentID).
type GetSettlementReply struct {
Settled bool `json:"settled"`
OutputID string `json:"outputID"`
Amount uint64 `json:"amount"`
}
// GetSettlement reports the proceeds D->C object's (outputID, amount) recorded under
// a collateral ref once the dexvm settled a swap with proceeds. This is the SEAM the
// keeper polls after submitting the ImportTx + settling RelayOrderTx: once the
// settling block accepts, settleFromFills records the proceeds export's outputID +
// realized amount here, and the keeper reads them to build the C-side Phase-B
// ImportSettlement (DS01 outputID|amount|intentID) that consumes the object and emits
// DEXFill. It is a read of committed state; it credits nothing.
func (s *Service) GetSettlement(_ *http.Request, args *GetSettlementArgs, reply *GetSettlementReply) error {
if !s.vm.IsBootstrapped() {
return ErrNotBootstrapped
}
if args.CollateralRef == "" {
return fmt.Errorf("%w: collateralRef required", ErrInvalidRequest)
}
ref, err := ids.FromString(args.CollateralRef)
if err != nil {
return fmt.Errorf("%w: collateralRef must be a valid id: %v", ErrInvalidRequest, err)
}
outputID, amount, found, err := s.vm.GetSettlement(ref)
if err != nil {
return err
}
reply.Settled = found
if found {
reply.OutputID = outputID.String()
reply.Amount = amount
}
return nil
}
// reqCtx extracts the request context (or a background context if absent).
func reqCtx(r *http.Request) context.Context {
if r != nil && r.Context() != nil {
+164
View File
@@ -0,0 +1,164 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package api
import (
"context"
"encoding/hex"
"errors"
"testing"
"github.com/luxfi/ids"
)
// stubVM implements api.VM for the SubmitTx tests. bootstrapped toggles the
// IsBootstrapped gate; the relay is never exercised by SubmitTx. settle* configure
// the GetSettlement response.
type stubVM struct {
bootstrapped bool
settleFound bool
settleOut ids.ID
settleAmount uint64
}
func (s *stubVM) IsBootstrapped() bool { return s.bootstrapped }
func (s *stubVM) Relay() Relayer { return stubRelayer{} }
func (s *stubVM) GetSettlement(ids.ID) (ids.ID, uint64, bool, error) {
return s.settleOut, s.settleAmount, s.settleFound, nil
}
type stubRelayer struct{}
func (stubRelayer) Configured() bool { return false }
func (stubRelayer) Relay(context.Context, string, []byte) ([]byte, error) {
return nil, errors.New("unused")
}
// stubSubmitter records the last tx handed to SubmitTx and can be told to fail,
// so the test can assert the service forwards bytes verbatim and propagates errors.
type stubSubmitter struct {
last []byte
err error
}
func (s *stubSubmitter) SubmitTx(tx []byte) error {
s.last = append([]byte(nil), tx...)
return s.err
}
// TestSubmitTx_NoSubmitter proves the read-only surface (NewService, no submitter)
// reports ErrSubmitUnavailable — the standalone/test path keeps Ping/Status/Relay
// but cannot inject txs.
func TestSubmitTx_NoSubmitter(t *testing.T) {
svc := NewService(&stubVM{bootstrapped: true})
err := svc.SubmitTx(nil, &SubmitTxArgs{Tx: "00"}, &SubmitTxReply{})
if !errors.Is(err, ErrSubmitUnavailable) {
t.Fatalf("want ErrSubmitUnavailable, got %v", err)
}
}
// TestSubmitTx_NotBootstrapped proves a submit mid-bootstrap is refused before it
// can race the state machine.
func TestSubmitTx_NotBootstrapped(t *testing.T) {
svc := NewServiceWithSubmitter(&stubVM{bootstrapped: false}, &stubSubmitter{})
err := svc.SubmitTx(nil, &SubmitTxArgs{Tx: "00"}, &SubmitTxReply{})
if !errors.Is(err, ErrNotBootstrapped) {
t.Fatalf("want ErrNotBootstrapped, got %v", err)
}
}
// TestSubmitTx_BadHex proves a non-hex tx is a clean ErrInvalidRequest, never a
// panic or a partial submit.
func TestSubmitTx_BadHex(t *testing.T) {
sub := &stubSubmitter{}
svc := NewServiceWithSubmitter(&stubVM{bootstrapped: true}, sub)
if err := svc.SubmitTx(nil, &SubmitTxArgs{Tx: "zz"}, &SubmitTxReply{}); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("want ErrInvalidRequest, got %v", err)
}
if sub.last != nil {
t.Fatalf("bad-hex tx must not reach the submitter, got %x", sub.last)
}
}
// TestSubmitTx_EmptyTx proves an empty tx is rejected before the submitter.
func TestSubmitTx_EmptyTx(t *testing.T) {
sub := &stubSubmitter{}
svc := NewServiceWithSubmitter(&stubVM{bootstrapped: true}, sub)
if err := svc.SubmitTx(nil, &SubmitTxArgs{Tx: ""}, &SubmitTxReply{}); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("want ErrInvalidRequest, got %v", err)
}
}
// TestSubmitTx_Success proves the happy path: the wire bytes reach the submitter
// VERBATIM and the returned TxID is the canonical ids.Checksum256 of those bytes
// (byte-identical to what the VM stamps as txs.BaseTx.TxID — the keeper's
// correlation handle).
func TestSubmitTx_Success(t *testing.T) {
sub := &stubSubmitter{}
svc := NewServiceWithSubmitter(&stubVM{bootstrapped: true}, sub)
// A representative dexvm wire: type byte (TxRelayOrder=2) + a JSON body. The
// service does not parse it; it only forwards + hashes.
wire := append([]byte{2}, []byte(`{"type":2,"method":"clob_submit"}`)...)
args := &SubmitTxArgs{Tx: hex.EncodeToString(wire)}
reply := &SubmitTxReply{}
if err := svc.SubmitTx(nil, args, reply); err != nil {
t.Fatalf("SubmitTx: %v", err)
}
if string(sub.last) != string(wire) {
t.Fatalf("submitter got %x, want verbatim %x", sub.last, wire)
}
want := ids.Checksum256(wire).String()
if reply.TxID != want {
t.Fatalf("TxID = %s, want %s (ids.Checksum256 of the wire)", reply.TxID, want)
}
}
// TestSubmitTx_SubmitterError proves a submitter rejection (e.g. the fee gate)
// propagates to the caller and is not swallowed into a fake success.
func TestSubmitTx_SubmitterError(t *testing.T) {
sentinel := errors.New("fee gate: zero-fee tx refused")
sub := &stubSubmitter{err: sentinel}
svc := NewServiceWithSubmitter(&stubVM{bootstrapped: true}, sub)
err := svc.SubmitTx(nil, &SubmitTxArgs{Tx: "02"}, &SubmitTxReply{})
if !errors.Is(err, sentinel) {
t.Fatalf("want submitter error propagated, got %v", err)
}
}
// TestGetSettlement_Pending proves the keeper's poll returns Settled=false before the
// settling block accepts (or for a pure-refund settle) — the signal to keep polling.
func TestGetSettlement_Pending(t *testing.T) {
svc := NewService(&stubVM{bootstrapped: true, settleFound: false})
reply := &GetSettlementReply{}
if err := svc.GetSettlement(nil, &GetSettlementArgs{CollateralRef: ids.GenerateTestID().String()}, reply); err != nil {
t.Fatalf("GetSettlement: %v", err)
}
if reply.Settled {
t.Fatalf("want Settled=false (pending), got true")
}
}
// TestGetSettlement_Found proves a settled-with-proceeds ref returns the proceeds
// object's outputID + amount — the DS01 Phase-B body inputs.
func TestGetSettlement_Found(t *testing.T) {
out := ids.GenerateTestID()
svc := NewService(&stubVM{bootstrapped: true, settleFound: true, settleOut: out, settleAmount: 4242})
reply := &GetSettlementReply{}
if err := svc.GetSettlement(nil, &GetSettlementArgs{CollateralRef: ids.GenerateTestID().String()}, reply); err != nil {
t.Fatalf("GetSettlement: %v", err)
}
if !reply.Settled || reply.OutputID != out.String() || reply.Amount != 4242 {
t.Fatalf("got settled=%v out=%s amt=%d, want true %s 4242", reply.Settled, reply.OutputID, reply.Amount, out)
}
}
// TestGetSettlement_BadRef proves a non-id collateralRef is a clean ErrInvalidRequest.
func TestGetSettlement_BadRef(t *testing.T) {
svc := NewService(&stubVM{bootstrapped: true})
if err := svc.GetSettlement(nil, &GetSettlementArgs{CollateralRef: "not-an-id"}, &GetSettlementReply{}); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("want ErrInvalidRequest, got %v", err)
}
}
+26 -3
View File
@@ -12,6 +12,9 @@ import (
"sync"
"time"
"github.com/gorilla/rpc/v2"
rpcjson "github.com/gorilla/rpc/v2/json"
"github.com/luxfi/chains/dexvm/api"
"github.com/luxfi/consensus/engine/dag/vertex"
"github.com/luxfi/ids"
"github.com/luxfi/log"
@@ -179,9 +182,29 @@ func (cvm *ChainVM) NewHTTPHandler(ctx context.Context) (http.Handler, error) {
return mux, nil
}
// CreateHandlers implements the interface expected by chain manager for HTTP registration
func (cvm *ChainVM) CreateHandlers(ctx context.Context) (map[string]http.Handler, error) {
return cvm.inner.CreateHandlers(ctx)
// CreateHandlers implements the interface expected by the chain manager for HTTP
// registration. This is the NODE path (the rpcchainvm harness serves the ChainVM),
// so unlike the inner VM's read-only CreateHandlers it wires the tx-submission
// surface: the dex service is built against an api.TxSubmitter (the ChainVM, which
// owns the pending pool + the engine channel) so dex.submitTx reaches the mempool.
// The inner VM's CreateHandlers stays read-only (Ping/Status/Relay) for the
// standalone/test path that has no pending pool.
//
// This is the seam the C<->D keeper drives: ImportTx + settling RelayOrderTx are
// submitted here; the proposer relays once and settles the D->C proceeds, which the
// C-side Phase-B ImportSettlement consumes to emit DEXFill.
func (cvm *ChainVM) CreateHandlers(_ context.Context) (map[string]http.Handler, error) {
server := rpc.NewServer()
server.RegisterCodec(rpcjson.NewCodec(), "application/json")
server.RegisterCodec(rpcjson.NewCodec(), "application/json;charset=UTF-8")
// Build the service against the inner VM (IsBootstrapped/Relay) PLUS the
// ChainVM as the tx submitter (SubmitTx -> pendingTxs + engine notify).
service := api.NewServiceWithSubmitter(cvm.inner, cvm)
if err := server.RegisterService(service, "dex"); err != nil {
return nil, fmt.Errorf("failed to register DEX service: %w", err)
}
return map[string]http.Handler{"": server}, nil
}
// HealthCheck implements the VM interface
+90
View File
@@ -0,0 +1,90 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package state
import (
"testing"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
)
// newTestState builds a State over in-memory dbs for the settlement record tests.
func newTestState(t *testing.T) *State {
t.Helper()
db := memdb.New()
base := memdb.New()
s := New(db, base)
if err := s.Initialize(); err != nil {
t.Fatalf("Initialize: %v", err)
}
return s
}
// TestSettlement_RoundTrip proves a recorded proceeds coordinate reads back exactly —
// the keeper's dex.getSettlement contract.
func TestSettlement_RoundTrip(t *testing.T) {
s := newTestState(t)
ref := ids.GenerateTestID()
out := ids.GenerateTestID()
if _, _, found, err := s.GetSettlement(ref); err != nil || found {
t.Fatalf("pre-write: want (not found, nil), got found=%v err=%v", found, err)
}
if err := s.PutSettlement(ref, out, 987654); err != nil {
t.Fatalf("PutSettlement: %v", err)
}
gotOut, gotAmt, found, err := s.GetSettlement(ref)
if err != nil {
t.Fatalf("GetSettlement: %v", err)
}
if !found || gotOut != out || gotAmt != 987654 {
t.Fatalf("got found=%v out=%s amt=%d, want true %s 987654", found, gotOut, gotAmt, out)
}
}
// TestSettlement_Isolation proves distinct refs never alias (the record is keyed by
// the full intentID).
func TestSettlement_Isolation(t *testing.T) {
s := newTestState(t)
refA, refB := ids.GenerateTestID(), ids.GenerateTestID()
outA := ids.GenerateTestID()
if err := s.PutSettlement(refA, outA, 10); err != nil {
t.Fatalf("PutSettlement A: %v", err)
}
if _, _, found, _ := s.GetSettlement(refB); found {
t.Fatalf("refB must not resolve to refA's record")
}
gotOut, gotAmt, found, _ := s.GetSettlement(refA)
if !found || gotOut != outA || gotAmt != 10 {
t.Fatalf("refA record corrupted: out=%s amt=%d found=%v", gotOut, gotAmt, found)
}
}
// TestSettlement_DistinctFromEscrow proves the settlement record and the escrow
// record live in disjoint key spaces under the same ref (different prefixes), so a
// swap's escrow (PutEscrow) and its proceeds coordinate (PutSettlement) coexist.
func TestSettlement_DistinctFromEscrow(t *testing.T) {
s := newTestState(t)
ref := ids.GenerateTestID()
owner := ids.GenerateTestShortID()
asset := ids.GenerateTestID()
out := ids.GenerateTestID()
if err := s.PutEscrow(ref, owner, asset, 100); err != nil {
t.Fatalf("PutEscrow: %v", err)
}
if err := s.PutSettlement(ref, out, 60); err != nil {
t.Fatalf("PutSettlement: %v", err)
}
// Both readable under the same ref, independent.
gotOwner, gotAsset, gotLocked, eFound, _ := s.GetEscrow(ref)
if !eFound || gotOwner != owner || gotAsset != asset || gotLocked != 100 {
t.Fatalf("escrow clobbered by settlement write")
}
gotOut, gotAmt, sFound, _ := s.GetSettlement(ref)
if !sFound || gotOut != out || gotAmt != 60 {
t.Fatalf("settlement clobbered by escrow write")
}
}
+66 -5
View File
@@ -46,11 +46,12 @@ var (
// Database prefixes — replay nonces, relay receipts, consumed UTXOs,
// collateral escrow.
prefixNonce = []byte("nonce:")
prefixReceipt = []byte("receipt:")
prefixConsumed = []byte("consumed:")
prefixEscrow = []byte("escrow:")
prefixLastBlock = []byte("lastBlock")
prefixNonce = []byte("nonce:")
prefixReceipt = []byte("receipt:")
prefixConsumed = []byte("consumed:")
prefixEscrow = []byte("escrow:")
prefixSettlement = []byte("settlement:")
prefixLastBlock = []byte("lastBlock")
)
// Receipt records an in-flight ZAP relay: the d-chain operation it triggered,
@@ -262,6 +263,66 @@ func (s *State) ConsumeEscrow(ref ids.ID) error {
return s.db.Delete(escrowKey(ref))
}
// ---------------------------------------------------------------------------
// Settlement record — the D->C proceeds-object COORDINATE, keyed by collateral
// ref (== the C->D intentID). It is a DETERMINISTIC by-product of settleFromFills:
// the proceeds export's outputID (deriveUTXOID(settlementExportTx.ID(), 0)) and the
// realized proceeds amount. Both are pure functions of consensus-agreed inputs
// (block hash + txIndex seed the export identity; the carried fills give the amount),
// so every validator writes the IDENTICAL record into the versiondb at accept and the
// StateRoot stays consensus-safe. It is the seam the off-chain keeper reads (the
// dex.getSettlement RPC) to learn the D->C object's outputID + amount so it can build
// the C-side Phase-B ImportSettlement (DS01 outputID|amount|intentID). It is purely
// INFORMATIONAL — it moves no value (the atomic export object alone does) and is never
// consulted by the deterministic settle/consume path; a keeper that ignores it loses
// only liveness (the locker reclaims after the deadline).
// ---------------------------------------------------------------------------
func settlementKey(ref ids.ID) []byte {
return append(append([]byte{}, prefixSettlement...), ref[:]...)
}
// settlementValueSize is the fixed record width: proceedsOutputID(32) | amount(8).
const settlementValueSize = 32 + 8
// PutSettlement records the proceeds D->C object's (outputID, amount) under the
// collateral ref (the intentID), written in settleFromFills when a swap realizes
// proceeds. Recorded ONLY for the proceeds leg (a pure refund has no D->C proceeds the
// keeper must claim — the refund object is owner-addressed and reclaimed the same way).
// Writing through the versiondb `db` makes it commit atomically with the block, so it
// is visible exactly when the export object becomes claimable.
func (s *State) PutSettlement(ref ids.ID, outputID ids.ID, amount uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
val := make([]byte, settlementValueSize)
copy(val[0:32], outputID[:])
binary.BigEndian.PutUint64(val[32:40], amount)
return s.db.Put(settlementKey(ref), val)
}
// GetSettlement returns the proceeds (outputID, amount) recorded under a collateral
// ref. found is false when no settlement-with-proceeds has been recorded for the ref
// (not yet settled, a pure-refund settle, or an unknown ref) — the keeper then keeps
// polling (or, past the deadline, the taker reclaims). It reads the versiondb `db`, so
// it reflects committed state.
func (s *State) GetSettlement(ref ids.ID) (outputID ids.ID, amount uint64, found bool, err error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, gerr := s.db.Get(settlementKey(ref))
if gerr != nil {
if errors.Is(gerr, database.ErrNotFound) {
return ids.Empty, 0, false, nil
}
return ids.Empty, 0, false, gerr
}
if len(data) < settlementValueSize {
return ids.Empty, 0, false, ErrStateCorrupted
}
copy(outputID[:], data[0:32])
amount = binary.BigEndian.Uint64(data[32:40])
return outputID, amount, true, nil
}
// ---------------------------------------------------------------------------
// Relay receipts — replay-idempotency for in-flight d-chain matches.
//
+33
View File
@@ -1121,6 +1121,25 @@ func (vm *VM) settleFromFills(relaySender ids.ShortID, collateralRef ids.ID, fil
// network-wide. This is the fix for the time.Now() shared-memory-key split.
createdAt := int64(binary.BigEndian.Uint64(blockHash[:8]))
tx := txs.NewSettlementExportTx(taker, txIndex, vm.cChainID(), outs, collateralRef, createdAt)
// Record the proceeds object's COORDINATE for the off-chain keeper (dex.getSettlement).
// When proceeds were realized they are outs[0] (appended first, before the refund leg,
// both > 0 when present so nonZeroOutputs preserves the index), so the proceeds export
// UTXO is deriveUTXOID(tx.ID(), 0) — the SAME key executeExport puts into shared memory
// (it derives elems by output index). The keeper reads this to build the C-side Phase-B
// ImportSettlement (DS01 outputID|amount|intentID). This is a deterministic, purely
// informational by-product: every validator computes the identical tx.ID() (the export
// identity is seeded by the consensus-agreed blockHash+txIndex, never wall-clock), so the
// versiondb write is consensus-safe. A record-write failure must NOT fail the settle
// (value movement is the export's job, not this index's), so it is logged, not returned.
if proceeds > 0 && len(outs) > 0 && outs[0].Amount == proceeds && outs[0].Asset == outAsset {
proceedsOutputID := deriveUTXOID(tx.ID(), 0)
if perr := vm.state.PutSettlement(collateralRef, proceedsOutputID, proceeds); perr != nil && !vm.log.IsZero() {
vm.log.Warn("settle: record proceeds coordinate failed (keeper falls back to deadline reclaim)",
"collateralRef", collateralRef, "error", perr)
}
}
return vm.executeExport(tx, ar)
}
@@ -1347,6 +1366,20 @@ func (vm *VM) GetLastBlockTime() time.Time {
// api.Relayer surface (the proxy's only DEX capability is transport).
func (vm *VM) Relay() api.Relayer { return vm.relay }
// GetSettlement returns the proceeds D->C object's (outputID, amount) recorded under
// a collateral ref (the C->D intentID) once a swap settled with proceeds, for the
// dex.getSettlement RPC the keeper polls to build the C-side Phase-B ImportSettlement.
// found is false until the settling block is accepted (the keeper keeps polling). It is
// a read of committed versiondb state — purely informational, moves no value.
func (vm *VM) GetSettlement(ref ids.ID) (outputID ids.ID, amount uint64, found bool, err error) {
vm.lock.RLock()
defer vm.lock.RUnlock()
if vm.state == nil {
return ids.Empty, 0, false, errVMNotInitialized
}
return vm.state.GetSettlement(ref)
}
// Gossip implements consensuscore.VM. The proxy gossips nothing matcher-related
// (it has no book); it only acknowledges peer messages.
func (vm *VM) Gossip(ctx context.Context, nodeID ids.NodeID, msg []byte) error {