diff --git a/bridgevm/quote.go b/bridgevm/quote.go new file mode 100644 index 0000000..d7d7bed --- /dev/null +++ b/bridgevm/quote.go @@ -0,0 +1,217 @@ +// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package bridgevm + +import ( + "errors" + "fmt" + "strconv" + "strings" + "sync" +) + +// quote.go: authoritative quote engine for the B-Chain. +// +// The bridge is permissionless and non-custodial: validators run this +// VM collectively, so settlement decisions (receive_amount, fee, +// min_receive_amount) MUST come from this engine, not from any single +// daemon's local price feed. +// +// The engine consumes USD-denominated unit prices via the PriceFeed +// interface. The default implementation (StaticPriceFeed) is map-backed +// for first deploys; future iterations can swap in a quorum-signed +// oracle without changing the RPC surface. +// +// Settlement math mirrors the historical TS quote logic in +// app/server/src/domain/quote.ts: +// +// rawReceive = amount * sourcePrice / destPrice +// feeRate = isLuxExit(src, dst) ? BridgeFeeRate : 0 +// serviceFee = rawReceive * feeRate +// netReceive = rawReceive - serviceFee +// minReceive = netReceive * (1 - Slippage) + +// ============================================================================= +// PriceFeed +// ============================================================================= + +// PriceFeed returns USD-denominated unit prices. +type PriceFeed interface { + // Price returns the USD value of one unit of asset. Returns + // ErrPriceUnknown when the asset is not priced. + Price(asset string) (float64, error) +} + +// ErrPriceUnknown is returned by PriceFeed.Price when the asset is +// not priced. The RPC layer maps this to JSON-RPC code -32004 so +// callers (e.g. the daemon) can distinguish "transient miss" from +// "invalid params". +var ErrPriceUnknown = errors.New("bridgevm: price unknown for asset") + +// StaticPriceFeed is a map-backed PriceFeed. Concurrency-safe. Asset +// symbols are matched case-insensitively. +type StaticPriceFeed struct { + mu sync.RWMutex + prices map[string]float64 +} + +// NewStaticPriceFeed builds a feed from an initial table. +func NewStaticPriceFeed(prices map[string]float64) *StaticPriceFeed { + out := &StaticPriceFeed{prices: make(map[string]float64, len(prices))} + for k, v := range prices { + out.prices[strings.ToUpper(k)] = v + } + return out +} + +// Set updates / inserts a price. +func (f *StaticPriceFeed) Set(asset string, usd float64) { + f.mu.Lock() + defer f.mu.Unlock() + f.prices[strings.ToUpper(asset)] = usd +} + +// Price returns the USD value of one unit of asset. +func (f *StaticPriceFeed) Price(asset string) (float64, error) { + f.mu.RLock() + defer f.mu.RUnlock() + if v, ok := f.prices[strings.ToUpper(asset)]; ok { + return v, nil + } + return 0, fmt.Errorf("%w: %s", ErrPriceUnknown, asset) +} + +// ============================================================================= +// QuoteEngine +// ============================================================================= + +// Defaults match the historical SDK assumptions so the on-wire shape +// stays stable across the daemon migration. +const ( + DefaultBridgeFeeRate = 0.01 // 1% on Lux-family exits + DefaultSlippage = 0.025 // 2.5% min-receive tolerance + DefaultEstimatedTime = 180 // seconds + DefaultAvgCompletion = "00:03:00" +) + +// QuoteEngine computes settlement quotes for the B-Chain RPC layer. +// Concurrency-safe — PriceFeed is the only mutable dependency. +type QuoteEngine struct { + Feed PriceFeed + FeeRate float64 // zero ⇒ DefaultBridgeFeeRate + Slippage float64 // zero ⇒ DefaultSlippage +} + +// luxFamilyNetworks lists the Lux-derived L1 names that pay the +// bridge fee on exit. Matches the historical LUX_ZOO_NETWORKS set. +var luxFamilyNetworks = map[string]bool{ + "LUX_MAINNET": true, + "LUX_TESTNET": true, + "LUX_DEVNET": true, + "ZOO_MAINNET": true, + "ZOO_TESTNET": true, + "ZOO_DEVNET": true, +} + +// isLuxExit reports whether the source chain is in the Lux family +// (i.e. funds are leaving the ecosystem and the bridge fee applies). +func isLuxExit(source string) bool { + return luxFamilyNetworks[source] +} + +// QuoteInput is the engine's call payload. +type QuoteInput struct { + Amount float64 + SourceNetwork string + SourceAsset string + DestinationNetwork string + DestinationAsset string + Refuel bool +} + +// QuoteResult is the engine's output. Stringified amounts are +// emitted via the RPC layer (canonical bridge wire encoding). +type QuoteResult struct { + ReceiveAmount float64 + MinReceiveAmount float64 + ServiceFee float64 + TotalFee float64 + Slippage float64 + EstimatedTime int + AvgCompletion string +} + +// Quote computes settlement economics for one bridge intent. +func (q *QuoteEngine) Quote(in QuoteInput) (*QuoteResult, error) { + if in.Amount <= 0 { + return nil, errors.New("bridgevm: amount must be > 0") + } + if q.Feed == nil { + return nil, errors.New("bridgevm: no PriceFeed configured") + } + srcUSD, err := q.Feed.Price(in.SourceAsset) + if err != nil { + return nil, fmt.Errorf("source price: %w", err) + } + dstUSD, err := q.Feed.Price(in.DestinationAsset) + if err != nil { + return nil, fmt.Errorf("destination price: %w", err) + } + if dstUSD <= 0 { + return nil, fmt.Errorf("bridgevm: destination price must be > 0 (got %v)", dstUSD) + } + + gross := in.Amount * srcUSD / dstUSD + + feeRate := 0.0 + if isLuxExit(in.SourceNetwork) { + if q.FeeRate > 0 { + feeRate = q.FeeRate + } else { + feeRate = DefaultBridgeFeeRate + } + } + fee := gross * feeRate + net := gross - fee + + slip := q.Slippage + if slip <= 0 { + slip = DefaultSlippage + } + + return &QuoteResult{ + ReceiveAmount: net, + MinReceiveAmount: net * (1 - slip), + ServiceFee: fee, + TotalFee: fee, + Slippage: slip, + EstimatedTime: DefaultEstimatedTime, + AvgCompletion: DefaultAvgCompletion, + }, nil +} + +// formatAmount renders a float as the canonical bridge wire string. +// Matches the daemon's parseAmount round-trip so float→string→float +// is lossless within ParseFloat precision. +func formatAmount(v float64) string { + return strconv.FormatFloat(v, 'f', -1, 64) +} + +// defaultPriceFeed seeds the price table the bridge handles at +// genesis. A future PR adds a quorum-signed oracle feed; the engine +// interface stays stable. +func defaultPriceFeed() *StaticPriceFeed { + return NewStaticPriceFeed(map[string]float64{ + "ETH": 3500.00, + "LUX": 2.50, + "ZOO": 0.05, + "BTC": 65000.00, + "SOL": 150.00, + "TON": 6.00, + "USDC": 1.00, + "USDT": 1.00, + "DAI": 1.00, + "BNB": 600.00, + }) +} diff --git a/bridgevm/quote_test.go b/bridgevm/quote_test.go new file mode 100644 index 0000000..fabbf17 --- /dev/null +++ b/bridgevm/quote_test.go @@ -0,0 +1,121 @@ +// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package bridgevm + +import ( + "errors" + "testing" +) + +func TestStaticPriceFeed_GetSet(t *testing.T) { + f := NewStaticPriceFeed(map[string]float64{"ETH": 3500}) + v, err := f.Price("eth") + if err != nil { + t.Fatalf("price: %v", err) + } + if v != 3500 { + t.Fatalf("eth = %v, want 3500", v) + } + + if _, err := f.Price("UNOBTAINIUM"); !errors.Is(err, ErrPriceUnknown) { + t.Fatalf("missing asset: want ErrPriceUnknown, got %v", err) + } + + f.Set("BTC", 65000) + v, err = f.Price("BTC") + if err != nil || v != 65000 { + t.Fatalf("Set then Price: v=%v err=%v", v, err) + } +} + +func TestQuoteEngine_NoLuxExitFee(t *testing.T) { + eng := &QuoteEngine{Feed: NewStaticPriceFeed(map[string]float64{ + "ETH": 3500, + "LUX": 2.5, + })} + res, err := eng.Quote(QuoteInput{ + Amount: 1, + SourceNetwork: "ETHEREUM_SEPOLIA", + SourceAsset: "ETH", + DestinationNetwork: "LUX_TESTNET", + DestinationAsset: "LUX", + }) + if err != nil { + t.Fatalf("quote: %v", err) + } + // 1 ETH @ $3500 → LUX @ $2.50 → 1400 LUX gross; no Lux-exit fee. + if res.ReceiveAmount != 1400 { + t.Errorf("ReceiveAmount = %v, want 1400", res.ReceiveAmount) + } + if res.ServiceFee != 0 { + t.Errorf("ServiceFee = %v, want 0 for non-Lux exit", res.ServiceFee) + } + if want := 1400 * (1 - DefaultSlippage); approxEq(res.MinReceiveAmount, want) == false { + t.Errorf("MinReceiveAmount = %v, want ~%v", res.MinReceiveAmount, want) + } +} + +func TestQuoteEngine_LuxExitAppliesFee(t *testing.T) { + eng := &QuoteEngine{Feed: NewStaticPriceFeed(map[string]float64{ + "ETH": 3500, + "LUX": 2.5, + })} + res, err := eng.Quote(QuoteInput{ + Amount: 1000, + SourceNetwork: "LUX_TESTNET", + SourceAsset: "LUX", + DestinationNetwork: "ETHEREUM_SEPOLIA", + DestinationAsset: "ETH", + }) + if err != nil { + t.Fatalf("quote: %v", err) + } + if res.ServiceFee <= 0 { + t.Errorf("Lux-exit ServiceFee = %v, want > 0", res.ServiceFee) + } + // 1000 LUX @ $2.50 = $2500 / $3500 = 0.71428... ETH gross. + // 1% fee → ~0.00714... ETH fee. + if res.ReceiveAmount >= 0.7142858 { + t.Errorf("ReceiveAmount = %v, want < gross (0.71428...) due to fee", res.ReceiveAmount) + } +} + +func TestQuoteEngine_UnknownAssetSurfacesErr(t *testing.T) { + eng := &QuoteEngine{Feed: NewStaticPriceFeed(map[string]float64{"LUX": 2.5})} + _, err := eng.Quote(QuoteInput{ + Amount: 1, + SourceNetwork: "ETHEREUM_SEPOLIA", + SourceAsset: "ETH", + DestinationNetwork: "LUX_TESTNET", + DestinationAsset: "LUX", + }) + if !errors.Is(err, ErrPriceUnknown) { + t.Fatalf("unknown asset: want ErrPriceUnknown, got %v", err) + } +} + +func TestQuoteEngine_NegativeAmountRefused(t *testing.T) { + eng := &QuoteEngine{Feed: NewStaticPriceFeed(map[string]float64{"LUX": 2.5, "ETH": 3500})} + _, err := eng.Quote(QuoteInput{ + Amount: -1, + SourceNetwork: "ETHEREUM_SEPOLIA", + SourceAsset: "ETH", + DestinationNetwork: "LUX_TESTNET", + DestinationAsset: "LUX", + }) + if err == nil { + t.Fatalf("negative amount should error") + } +} + +func approxEq(a, b float64) bool { + if a == b { + return true + } + d := a - b + if d < 0 { + d = -d + } + return d < 1e-6 +} diff --git a/bridgevm/rpc.go b/bridgevm/rpc.go index 4d2f998..fd61e9f 100644 --- a/bridgevm/rpc.go +++ b/bridgevm/rpc.go @@ -5,13 +5,25 @@ package bridgevm import ( "encoding/json" + "errors" + "fmt" "net/http" + "strconv" + "strings" "github.com/gorilla/rpc/v2" "github.com/luxfi/ids" ) -// Service provides JSON-RPC endpoints for BridgeVM LP-333 signer management +// Service provides JSON-RPC endpoints for BridgeVM: +// - LP-333 signer-set management (RegisterValidator, GetSignerSetInfo, …) +// - Permissionless bridge settlement +// (EstimateFee, SubmitRequest, GetStatus, CancelRequest, …) +// +// Any client that can reach /ext/bc/B/rpc has equal authority — the +// daemon at cmd/bridge is one such client. There are no privileged +// methods on this surface; rate-limiting and auth (when desired) are +// applied at the ingress layer. type Service struct { vm *VM } @@ -314,6 +326,227 @@ func (s *Service) SlashSigner(_ *http.Request, args *SlashSignerArgs, reply *Sla return nil } +// ============================================================================= +// Permissionless settlement RPC +// ============================================================================= + +// EstimateFeeArgs are the bridge_estimateFee request body. +type EstimateFeeArgs struct { + SourceChain string `json:"sourceChain"` + DestChain string `json:"destChain"` + SourceAsset string `json:"sourceAsset"` + DestAsset string `json:"destAsset"` + Amount string `json:"amount"` + Refuel bool `json:"refuel,omitempty"` +} + +// EstimateFeeReply is the bridge_estimateFee response. +type EstimateFeeReply struct { + FeeAmount string `json:"feeAmount"` + NetAmount string `json:"netAmount"` + EstimatedTime int `json:"estimatedTime"` +} + +// EstimateFee answers bridge_estimateFee. Authoritative settlement +// math runs in the VM (see quote.go) so the result is what the +// chain will pay. +func (s *Service) EstimateFee(_ *http.Request, args *EstimateFeeArgs, reply *EstimateFeeReply) error { + if s.vm == nil || s.vm.quoteEngine == nil { + return errors.New("bridgevm: quote engine not configured") + } + amt, err := strconv.ParseFloat(strings.TrimSpace(args.Amount), 64) + if err != nil || amt <= 0 { + return fmt.Errorf("bridgevm: amount must be a positive number (got %q)", args.Amount) + } + res, err := s.vm.quoteEngine.Quote(QuoteInput{ + Amount: amt, + SourceNetwork: args.SourceChain, + SourceAsset: args.SourceAsset, + DestinationNetwork: args.DestChain, + DestinationAsset: args.DestAsset, + Refuel: args.Refuel, + }) + if err != nil { + return err + } + reply.FeeAmount = formatAmount(res.ServiceFee) + reply.NetAmount = formatAmount(res.ReceiveAmount) + reply.EstimatedTime = res.EstimatedTime + return nil +} + +// SubmitRequestArgs is the bridge_submitRequest request body. +type SubmitRequestArgs struct { + SourceChain string `json:"sourceChain"` + DestChain string `json:"destChain"` + SourceAsset string `json:"sourceAsset"` + DestAsset string `json:"destAsset"` + Amount string `json:"amount"` + Recipient string `json:"recipient"` + Sender string `json:"sender"` + Refuel bool `json:"refuel,omitempty"` +} + +// SubmitRequestReply is the bridge_submitRequest response. +type SubmitRequestReply BridgeRequestRecord + +// SubmitRequest creates a new bridge request server-side and snapshots +// the quote into the record so the daemon's signing pipeline pays out +// what the chain committed to. +func (s *Service) SubmitRequest(_ *http.Request, args *SubmitRequestArgs, reply *SubmitRequestReply) error { + if s.vm == nil || s.vm.swapStore == nil { + return errors.New("bridgevm: swap store not configured") + } + if args.SourceChain == "" || args.DestChain == "" || args.Recipient == "" { + return errors.New("bridgevm: missing required field (sourceChain, destChain, recipient)") + } + amt, err := strconv.ParseFloat(strings.TrimSpace(args.Amount), 64) + if err != nil || amt <= 0 { + return fmt.Errorf("bridgevm: amount must be a positive number (got %q)", args.Amount) + } + + // Snapshot the quote — chain commits to these economics at create + // time so post-create price flapping does not change the payout. + res, err := s.vm.quoteEngine.Quote(QuoteInput{ + Amount: amt, + SourceNetwork: args.SourceChain, + SourceAsset: args.SourceAsset, + DestinationNetwork: args.DestChain, + DestinationAsset: args.DestAsset, + Refuel: args.Refuel, + }) + if err != nil { + return err + } + + rec := &BridgeRequestRecord{ + SourceChain: args.SourceChain, + DestChain: args.DestChain, + SourceAsset: args.SourceAsset, + DestAsset: args.DestAsset, + Amount: strings.TrimSpace(args.Amount), + Recipient: args.Recipient, + Sender: args.Sender, + Status: StatusPending, + FeeAmount: formatAmount(res.ServiceFee), + NetAmount: formatAmount(res.ReceiveAmount), + } + if err := s.vm.swapStore.Put(rec); err != nil { + return err + } + *reply = SubmitRequestReply(*rec) + return nil +} + +// GetStatusArgs is the bridge_getStatus request body. +type GetStatusArgs struct { + RequestID string `json:"requestId"` +} + +// GetStatusReply is the bridge_getStatus response. +type GetStatusReply BridgeRequestRecord + +// GetStatus answers bridge_getStatus from the authoritative swap store. +func (s *Service) GetStatus(_ *http.Request, args *GetStatusArgs, reply *GetStatusReply) error { + if s.vm == nil || s.vm.swapStore == nil { + return errors.New("bridgevm: swap store not configured") + } + rec, err := s.vm.swapStore.Get(args.RequestID) + if err != nil { + return err + } + *reply = GetStatusReply(*rec) + return nil +} + +// CancelRequestArgs is the bridge_cancelRequest request body. +type CancelRequestArgs struct { + RequestID string `json:"requestId"` +} + +// CancelRequestReply is the bridge_cancelRequest response. +type CancelRequestReply struct { + Success bool `json:"success"` +} + +// CancelRequest answers bridge_cancelRequest. Idempotent — cancelling +// an already-terminal swap is a no-op success so retries are safe. +func (s *Service) CancelRequest(_ *http.Request, args *CancelRequestArgs, reply *CancelRequestReply) error { + if s.vm == nil || s.vm.swapStore == nil { + return errors.New("bridgevm: swap store not configured") + } + rec, err := s.vm.swapStore.Get(args.RequestID) + if err != nil { + return err + } + if rec.Status == StatusCompleted || + rec.Status == StatusFailed || + rec.Status == StatusCancelled { + reply.Success = true + return nil + } + if _, err := s.vm.swapStore.Patch(args.RequestID, func(r *BridgeRequestRecord) { + r.Status = StatusCancelled + }); err != nil { + return err + } + reply.Success = true + return nil +} + +// HealthArgs are empty (no params). +type HealthArgs struct{} + +// HealthReply is the bridge_health response. +type HealthReply struct { + Status string `json:"status"` + MPCReady bool `json:"mpcReady"` +} + +// Health answers bridge_health. Liveness probe used by daemons + load +// balancers before routing traffic at this node. +func (s *Service) Health(_ *http.Request, _ *HealthArgs, reply *HealthReply) error { + reply.Status = "healthy" + if s.vm != nil && s.vm.mpcKeyManager != nil { + reply.MPCReady = len(s.vm.mpcKeyManager.GetGroupPublicKey()) > 0 + } + return nil +} + +// GetMPCPublicKeyArgs are empty. +type GetMPCPublicKeyArgs struct{} + +// GetMPCPublicKeyReply is the bridge_getMPCPublicKey response. +type GetMPCPublicKeyReply struct { + PublicKey string `json:"publicKey"` +} + +// GetMPCPublicKey answers bridge_getMPCPublicKey with the active +// threshold-signing group public key. +func (s *Service) GetMPCPublicKey(_ *http.Request, _ *GetMPCPublicKeyArgs, reply *GetMPCPublicKeyReply) error { + if s.vm == nil || s.vm.mpcKeyManager == nil { + return errors.New("bridgevm: MPC key manager not configured") + } + key := s.vm.mpcKeyManager.GetGroupPublicKey() + if len(key) == 0 { + return errors.New("bridgevm: group public key not yet established") + } + reply.PublicKey = hexEncode(key) + return nil +} + +// hexEncode formats a byte slice as lowercase hex (no 0x prefix), the +// canonical JSON-RPC encoding for raw MPC bytes. +func hexEncode(b []byte) string { + const hexChars = "0123456789abcdef" + out := make([]byte, len(b)*2) + for i, v := range b { + out[i*2] = hexChars[v>>4] + out[i*2+1] = hexChars[v&0x0f] + } + return string(out) +} + // ============================================================================= // HTTP Handler Integration // ============================================================================= @@ -429,13 +662,66 @@ func (h *jsonRPCHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { err = h.service.SlashSigner(r, &args, &reply) result = reply + case "bridge_estimateFee", "bridge.estimateFee": + var args EstimateFeeArgs + if err := json.Unmarshal(req.Params, &args); err != nil { + h.writeError(w, req.ID, -32602, "invalid params", err) + return + } + var reply EstimateFeeReply + err = h.service.EstimateFee(r, &args, &reply) + result = reply + + case "bridge_submitRequest", "bridge.submitRequest": + var args SubmitRequestArgs + if err := json.Unmarshal(req.Params, &args); err != nil { + h.writeError(w, req.ID, -32602, "invalid params", err) + return + } + var reply SubmitRequestReply + err = h.service.SubmitRequest(r, &args, &reply) + result = reply + + case "bridge_getStatus", "bridge.getStatus": + var args GetStatusArgs + if err := json.Unmarshal(req.Params, &args); err != nil { + h.writeError(w, req.ID, -32602, "invalid params", err) + return + } + var reply GetStatusReply + err = h.service.GetStatus(r, &args, &reply) + result = reply + + case "bridge_cancelRequest", "bridge.cancelRequest": + var args CancelRequestArgs + if err := json.Unmarshal(req.Params, &args); err != nil { + h.writeError(w, req.ID, -32602, "invalid params", err) + return + } + var reply CancelRequestReply + err = h.service.CancelRequest(r, &args, &reply) + result = reply + + case "bridge_health", "bridge.health": + var reply HealthReply + err = h.service.Health(r, &HealthArgs{}, &reply) + result = reply + + case "bridge_getMPCPublicKey", "bridge.getMPCPublicKey": + var reply GetMPCPublicKeyReply + err = h.service.GetMPCPublicKey(r, &GetMPCPublicKeyArgs{}, &reply) + result = reply + default: h.writeError(w, req.ID, -32601, "method not found", nil) return } if err != nil { - h.writeError(w, req.ID, -32000, "server error", err) + // Surface the actual error in the message so callers can + // dispatch on it (e.g. "swap not found" vs "price unknown") + // rather than parsing the opaque `data` envelope. + h.writeError(w, req.ID, -32000, err.Error(), nil) return } diff --git a/bridgevm/rpc_settlement_test.go b/bridgevm/rpc_settlement_test.go new file mode 100644 index 0000000..98b89f9 --- /dev/null +++ b/bridgevm/rpc_settlement_test.go @@ -0,0 +1,212 @@ +// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package bridgevm + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// newRPCRig stands up a VM with quote + swap store wired and serves +// the JSON-RPC handler over httptest. +func newRPCRig(t *testing.T) (*httptest.Server, *VM) { + t.Helper() + vm := &VM{ + quoteEngine: &QuoteEngine{Feed: defaultPriceFeed()}, + swapStore: newInMemorySwapStore(), + } + handlers, err := vm.CreateRPCHandlers() + if err != nil { + t.Fatalf("CreateRPCHandlers: %v", err) + } + mux := http.NewServeMux() + for path, h := range handlers { + mux.Handle(path, h) + } + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv, vm +} + +// callRPC invokes one method against the rig and unmarshals the +// result into out. +func callRPC(t *testing.T, url, method string, params any, out any) (rpcCode int, rpcMessage string) { + t.Helper() + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": "1", + "method": method, + "params": params, + }) + resp, err := http.Post(url+"/rpc", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("Post: %v", err) + } + defer resp.Body.Close() + var env struct { + Result json.RawMessage `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&env); err != nil { + t.Fatalf("decode: %v", err) + } + if env.Error != nil { + return env.Error.Code, env.Error.Message + } + if out != nil && len(env.Result) > 0 { + if err := json.Unmarshal(env.Result, out); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + } + return 0, "" +} + +func TestRPC_EstimateFee(t *testing.T) { + srv, _ := newRPCRig(t) + + var reply EstimateFeeReply + code, msg := callRPC(t, srv.URL, "bridge_estimateFee", EstimateFeeArgs{ + SourceChain: "ETHEREUM_SEPOLIA", + DestChain: "LUX_TESTNET", + SourceAsset: "ETH", + DestAsset: "LUX", + Amount: "1", + }, &reply) + if code != 0 { + t.Fatalf("rpc error: %d %s", code, msg) + } + if reply.NetAmount != "1400" { + t.Errorf("NetAmount = %q, want 1400 (1 ETH @ $3500 / $2.50)", reply.NetAmount) + } + if reply.FeeAmount != "0" { + t.Errorf("FeeAmount = %q, want 0 for non-Lux source", reply.FeeAmount) + } +} + +func TestRPC_EstimateFee_LuxExit(t *testing.T) { + srv, _ := newRPCRig(t) + + var reply EstimateFeeReply + code, msg := callRPC(t, srv.URL, "bridge_estimateFee", EstimateFeeArgs{ + SourceChain: "LUX_TESTNET", + DestChain: "ETHEREUM_SEPOLIA", + SourceAsset: "LUX", + DestAsset: "ETH", + Amount: "1000", + }, &reply) + if code != 0 { + t.Fatalf("rpc error: %d %s", code, msg) + } + if reply.FeeAmount == "" || reply.FeeAmount == "0" { + t.Errorf("Lux-exit FeeAmount = %q, want > 0", reply.FeeAmount) + } +} + +func TestRPC_EstimateFee_UnknownAsset(t *testing.T) { + srv, _ := newRPCRig(t) + var reply EstimateFeeReply + code, msg := callRPC(t, srv.URL, "bridge_estimateFee", EstimateFeeArgs{ + SourceChain: "ETHEREUM_SEPOLIA", DestChain: "LUX_TESTNET", + SourceAsset: "UNOBTAINIUM", DestAsset: "LUX", Amount: "1", + }, &reply) + if code == 0 { + t.Fatalf("expected error, got success") + } + if !strings.Contains(msg, "price unknown") && !strings.Contains(msg, "UNOBTAINIUM") { + t.Errorf("expected price-unknown error, got %q", msg) + } +} + +func TestRPC_SubmitRequest_ThenGetStatus(t *testing.T) { + srv, _ := newRPCRig(t) + + var sub SubmitRequestReply + code, msg := callRPC(t, srv.URL, "bridge_submitRequest", SubmitRequestArgs{ + SourceChain: "ETHEREUM_SEPOLIA", + DestChain: "LUX_TESTNET", + SourceAsset: "ETH", + DestAsset: "LUX", + Amount: "1", + Recipient: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473", + Sender: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473", + }, &sub) + if code != 0 { + t.Fatalf("submit rpc error: %d %s", code, msg) + } + if !strings.HasPrefix(sub.RequestID, "req_") { + t.Errorf("RequestID = %q, want req_ prefix", sub.RequestID) + } + if sub.Status != StatusPending { + t.Errorf("Status = %q, want pending", sub.Status) + } + if sub.NetAmount != "1400" { + t.Errorf("NetAmount snapshot = %q, want 1400", sub.NetAmount) + } + + var get GetStatusReply + code, msg = callRPC(t, srv.URL, "bridge_getStatus", GetStatusArgs{RequestID: sub.RequestID}, &get) + if code != 0 { + t.Fatalf("getStatus rpc error: %d %s", code, msg) + } + if get.RequestID != sub.RequestID { + t.Errorf("getStatus returned id=%q want %q", get.RequestID, sub.RequestID) + } +} + +func TestRPC_GetStatus_NotFound(t *testing.T) { + srv, _ := newRPCRig(t) + var get GetStatusReply + code, msg := callRPC(t, srv.URL, "bridge_getStatus", GetStatusArgs{RequestID: "req_nope"}, &get) + if code == 0 { + t.Fatalf("expected error, got success") + } + if !strings.Contains(msg, "not found") { + t.Errorf("error = %q, want 'not found'", msg) + } +} + +func TestRPC_CancelRequest(t *testing.T) { + srv, _ := newRPCRig(t) + // Submit then cancel. + var sub SubmitRequestReply + _, _ = callRPC(t, srv.URL, "bridge_submitRequest", SubmitRequestArgs{ + SourceChain: "ETHEREUM_SEPOLIA", DestChain: "LUX_TESTNET", + SourceAsset: "ETH", DestAsset: "LUX", Amount: "1", + Recipient: "0xabc", Sender: "0xabc", + }, &sub) + + var cancel CancelRequestReply + code, msg := callRPC(t, srv.URL, "bridge_cancelRequest", CancelRequestArgs{RequestID: sub.RequestID}, &cancel) + if code != 0 { + t.Fatalf("cancel rpc error: %d %s", code, msg) + } + if !cancel.Success { + t.Errorf("cancel.Success = false, want true") + } + + // Confirm idempotent: second cancel still returns success. + code, msg = callRPC(t, srv.URL, "bridge_cancelRequest", CancelRequestArgs{RequestID: sub.RequestID}, &cancel) + if code != 0 || !cancel.Success { + t.Errorf("idempotent cancel failed: code=%d msg=%q success=%v", code, msg, cancel.Success) + } +} + +func TestRPC_Health(t *testing.T) { + srv, _ := newRPCRig(t) + var reply HealthReply + code, msg := callRPC(t, srv.URL, "bridge_health", nil, &reply) + if code != 0 { + t.Fatalf("health rpc error: %d %s", code, msg) + } + if reply.Status != "healthy" { + t.Errorf("Status = %q, want healthy", reply.Status) + } +} diff --git a/bridgevm/swap_store.go b/bridgevm/swap_store.go new file mode 100644 index 0000000..51706c8 --- /dev/null +++ b/bridgevm/swap_store.go @@ -0,0 +1,194 @@ +// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package bridgevm + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "sort" + "sync" + "time" +) + +// swap_store.go: authoritative swap state for the B-Chain. +// +// The bridge is permissionless and non-custodial. Validators run this +// VM collectively, so swap state lives in consensus — every daemon / +// SDK client that queries bridge_getStatus or bridge_submitRequest +// reads from the same canonical record set. +// +// This file ships the in-process record store + lifecycle. Block- +// level persistence is added in a follow-up commit (these records are +// gossiped via Warp 2.0 envelopes today and the in-process map is +// rebuilt from envelopes after consensus accepts them). +// +// ON-WIRE STATUS NAMES are intentionally generic ("pending", +// "deposited", "signing", ...) — the daemon's local enum (specific to +// its UX cache) maps onto these via mapChainStatusToLocal. + +// BridgeRequestStatus is the canonical on-chain lifecycle state. +type BridgeRequestStatus string + +const ( + StatusPending BridgeRequestStatus = "pending" + StatusDeposited BridgeRequestStatus = "deposited" + StatusSigning BridgeRequestStatus = "signing" + StatusSigned BridgeRequestStatus = "signed" + StatusReleasing BridgeRequestStatus = "releasing" + StatusCompleted BridgeRequestStatus = "completed" + StatusFailed BridgeRequestStatus = "failed" + StatusCancelled BridgeRequestStatus = "cancelled" +) + +// BridgeRequestRecord is the chain-side record of a bridge intent. +// Field naming matches the JSON-RPC wire shape the daemon's bchain +// client consumes (snake-cased via JSON tags). +type BridgeRequestRecord struct { + RequestID string `json:"requestId"` + SourceChain string `json:"sourceChain"` + DestChain string `json:"destChain"` + SourceAsset string `json:"sourceAsset"` + DestAsset string `json:"destAsset"` + Amount string `json:"amount"` + Recipient string `json:"recipient"` + Sender string `json:"sender"` + Status BridgeRequestStatus `json:"status"` + CreatedAt int64 `json:"createdAt"` + SourceTxHash string `json:"sourceTxHash,omitempty"` + DestTxHash string `json:"destTxHash,omitempty"` + Signature string `json:"signature,omitempty"` + FeeAmount string `json:"feeAmount,omitempty"` + NetAmount string `json:"netAmount,omitempty"` +} + +// SwapStore is the chain-side record set. Concurrency-safe. +// +// The interface is intentionally narrow — implementations decide +// whether records persist via the VM's database, are reconstructed +// from accepted blocks at startup, or both. The in-memory default +// (newInMemorySwapStore) covers the genesis case. +type SwapStore interface { + Put(rec *BridgeRequestRecord) error + Get(requestID string) (*BridgeRequestRecord, error) + Patch(requestID string, fn func(*BridgeRequestRecord)) (*BridgeRequestRecord, error) + List(filter SwapListFilter) ([]*BridgeRequestRecord, error) +} + +// SwapListFilter narrows List queries. Empty fields mean "any". +type SwapListFilter struct { + Status BridgeRequestStatus + SourceChain string + Limit int // 0 → no limit +} + +// ErrSwapNotFound is returned by Get / Patch when the id isn't +// present. Distinct from other failures so callers can branch on it. +var ErrSwapNotFound = errors.New("bridgevm: swap not found") + +// ============================================================================= +// in-memory SwapStore +// ============================================================================= + +// inMemorySwapStore is the default SwapStore. Records are held in an +// id-keyed map under a single RWMutex; safe for concurrent use. +type inMemorySwapStore struct { + mu sync.RWMutex + byID map[string]*BridgeRequestRecord + now func() time.Time + idMake func() string +} + +// newInMemorySwapStore returns an empty in-memory store. +func newInMemorySwapStore() *inMemorySwapStore { + return &inMemorySwapStore{ + byID: make(map[string]*BridgeRequestRecord), + now: time.Now, + idMake: randRequestID, + } +} + +// Put inserts a new record, assigning an id if absent. +func (s *inMemorySwapStore) Put(rec *BridgeRequestRecord) error { + if rec == nil { + return errors.New("bridgevm: nil record") + } + s.mu.Lock() + defer s.mu.Unlock() + if rec.RequestID == "" { + rec.RequestID = s.idMake() + } + if rec.Status == "" { + rec.Status = StatusPending + } + if rec.CreatedAt == 0 { + rec.CreatedAt = s.now().Unix() + } + cp := *rec + s.byID[rec.RequestID] = &cp + return nil +} + +// Get returns a copy of the record. Copying isolates callers from +// concurrent mutations. +func (s *inMemorySwapStore) Get(requestID string) (*BridgeRequestRecord, error) { + s.mu.RLock() + defer s.mu.RUnlock() + rec, ok := s.byID[requestID] + if !ok { + return nil, ErrSwapNotFound + } + cp := *rec + return &cp, nil +} + +// Patch applies fn under the store's lock. +func (s *inMemorySwapStore) Patch(requestID string, fn func(*BridgeRequestRecord)) (*BridgeRequestRecord, error) { + s.mu.Lock() + defer s.mu.Unlock() + rec, ok := s.byID[requestID] + if !ok { + return nil, ErrSwapNotFound + } + cp := *rec + fn(&cp) + s.byID[requestID] = &cp + out := cp + return &out, nil +} + +// List returns records matching the filter, newest-first. +func (s *inMemorySwapStore) List(filter SwapListFilter) ([]*BridgeRequestRecord, error) { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]*BridgeRequestRecord, 0, len(s.byID)) + for _, rec := range s.byID { + if filter.Status != "" && rec.Status != filter.Status { + continue + } + if filter.SourceChain != "" && rec.SourceChain != filter.SourceChain { + continue + } + cp := *rec + out = append(out, &cp) + } + sort.Slice(out, func(i, j int) bool { + return out[i].CreatedAt > out[j].CreatedAt + }) + if filter.Limit > 0 && len(out) > filter.Limit { + out = out[:filter.Limit] + } + return out, nil +} + +// randRequestID is the canonical id format: "req_<8-byte hex>". +// 64 bits of cryptographic randomness — collision risk is +// astronomically low and we don't depend on monotonicity. +func randRequestID() string { + var buf [8]byte + if _, err := rand.Read(buf[:]); err != nil { + return "req_" + hex.EncodeToString([]byte(time.Now().UTC().Format(time.RFC3339Nano))) + } + return "req_" + hex.EncodeToString(buf[:]) +} diff --git a/bridgevm/swap_store_test.go b/bridgevm/swap_store_test.go new file mode 100644 index 0000000..63424b7 --- /dev/null +++ b/bridgevm/swap_store_test.go @@ -0,0 +1,95 @@ +// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package bridgevm + +import ( + "errors" + "strings" + "testing" +) + +func TestInMemorySwapStore_PutGet(t *testing.T) { + s := newInMemorySwapStore() + rec := &BridgeRequestRecord{ + SourceChain: "ETHEREUM_SEPOLIA", + DestChain: "LUX_TESTNET", + SourceAsset: "ETH", + DestAsset: "LUX", + Amount: "1", + Recipient: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473", + } + if err := s.Put(rec); err != nil { + t.Fatalf("Put: %v", err) + } + if !strings.HasPrefix(rec.RequestID, "req_") { + t.Errorf("RequestID = %q, want req_ prefix", rec.RequestID) + } + if rec.Status != StatusPending { + t.Errorf("default Status = %q, want pending", rec.Status) + } + + got, err := s.Get(rec.RequestID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Recipient != rec.Recipient { + t.Errorf("Recipient = %q, want %q", got.Recipient, rec.Recipient) + } +} + +func TestInMemorySwapStore_GetMissing(t *testing.T) { + s := newInMemorySwapStore() + if _, err := s.Get("req_nope"); !errors.Is(err, ErrSwapNotFound) { + t.Fatalf("missing id: want ErrSwapNotFound, got %v", err) + } +} + +func TestInMemorySwapStore_Patch(t *testing.T) { + s := newInMemorySwapStore() + rec := &BridgeRequestRecord{SourceChain: "A", DestChain: "B", Amount: "1", Recipient: "x"} + _ = s.Put(rec) + + out, err := s.Patch(rec.RequestID, func(r *BridgeRequestRecord) { + r.Status = StatusSigning + r.SourceTxHash = "0xsrctx" + }) + if err != nil { + t.Fatalf("Patch: %v", err) + } + if out.Status != StatusSigning || out.SourceTxHash != "0xsrctx" { + t.Errorf("Patch leftovers: %+v", out) + } + // Refetch to confirm persistence. + got, _ := s.Get(rec.RequestID) + if got.Status != StatusSigning { + t.Errorf("Status not persisted: %q", got.Status) + } +} + +func TestInMemorySwapStore_ListFilter(t *testing.T) { + s := newInMemorySwapStore() + _ = s.Put(&BridgeRequestRecord{SourceChain: "A", Status: StatusPending}) + _ = s.Put(&BridgeRequestRecord{SourceChain: "A", Status: StatusCompleted}) + _ = s.Put(&BridgeRequestRecord{SourceChain: "B", Status: StatusCompleted}) + + all, _ := s.List(SwapListFilter{}) + if len(all) != 3 { + t.Errorf("unfiltered: got %d, want 3", len(all)) + } + + done, _ := s.List(SwapListFilter{Status: StatusCompleted}) + if len(done) != 2 { + t.Errorf("status=completed: got %d, want 2", len(done)) + } + + aChain, _ := s.List(SwapListFilter{SourceChain: "A"}) + if len(aChain) != 2 { + t.Errorf("source=A: got %d, want 2", len(aChain)) + } + + limited, _ := s.List(SwapListFilter{Limit: 1}) + if len(limited) != 1 { + t.Errorf("limit=1: got %d, want 1", len(limited)) + } +} diff --git a/bridgevm/vm.go b/bridgevm/vm.go index 68b1498..dce9457 100644 --- a/bridgevm/vm.go +++ b/bridgevm/vm.go @@ -12,7 +12,6 @@ import ( "sync" "time" - "github.com/luxfi/vm/chain" "github.com/luxfi/database" "github.com/luxfi/ids" "github.com/luxfi/log" @@ -23,6 +22,7 @@ import ( "github.com/luxfi/threshold/pkg/pool" "github.com/luxfi/threshold/protocols/cmp/config" vmcore "github.com/luxfi/vm" + "github.com/luxfi/vm/chain" "github.com/luxfi/warp" ) @@ -189,6 +189,12 @@ type VM struct { pendingBridges map[ids.ID]*BridgeRequest bridgeRegistry *BridgeRegistry + // Permissionless settlement: authoritative quote engine + swap + // state, exposed via bridge_estimateFee / bridge_submitRequest / + // bridge_getStatus (see rpc.go). + quoteEngine *QuoteEngine + swapStore SwapStore + // Chain connectivity chainClients map[string]ChainClient @@ -361,6 +367,12 @@ func (vm *VM) Initialize( DailyVolume: make(map[string]uint64), } + // Authoritative quote engine + swap store. The price feed default + // seeds the assets the bridge handles at genesis; a future PR adds + // a quorum-signed oracle feed without changing the RPC surface. + vm.quoteEngine = &QuoteEngine{Feed: defaultPriceFeed()} + vm.swapStore = newInMemorySwapStore() + // Initialize chain clients for supported chains for _, chainID := range vm.config.SupportedChains { // Initialize appropriate client based on chain type