mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
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:
+152
-1
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user