dexvm: expose SubmitTx RPC (the C<->D keeper mempool seam)

The dexvm is a stateless atomic ZAP proxy whose ImportTx/RelayOrderTx/ExportTx
are deterministic block inputs, drawn from the proposer's pending pool. That pool
(ChainVM.SubmitTx -> pendingTxs + engine notify) was unreachable from outside the
process: CreateHandlers exposed only Ping/Status/Relay, so an off-chain keeper had
no way to inject the ImportTx that consumes the C->D atomic object the C-side
0x9999 SubmitSwapIntent writes, nor the settling RelayOrderTx. The full two-phase
seam therefore could not land a DEXFill.

This adds a dex.submitTx JSON-RPC method (api.Service.SubmitTx) that forwards
wire-encoded tx bytes to an api.TxSubmitter and returns the canonical
ids.Checksum256 tx id. The submitter is wired ONLY on the node path
(ChainVM.CreateHandlers builds the service with the ChainVM as submitter); the
inner VM's read-only CreateHandlers (standalone/test) reports ErrSubmitUnavailable.
Bootstrap-gated; admission/fee gate stays in ChainVM.SubmitTx. 6 unit tests cover
unavailable/not-bootstrapped/bad-hex/empty/success(verbatim+TxID)/submitter-error.
This commit is contained in:
2026-06-22 01:23:00 -07:00
parent d0124f77f8
commit 0d8a06af7d
3 changed files with 245 additions and 4 deletions
+97 -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
@@ -36,16 +39,52 @@ type VM interface {
Relay() Relayer
}
// 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 +163,63 @@ 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
}
// reqCtx extracts the request context (or a background context if absent).
func reqCtx(r *http.Request) context.Context {
if r != nil && r.Context() != nil {
+122
View File
@@ -0,0 +1,122 @@
// 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.
type stubVM struct {
bootstrapped bool
}
func (s *stubVM) IsBootstrapped() bool { return s.bootstrapped }
func (s *stubVM) Relay() Relayer { return stubRelayer{} }
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)
}
}
+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