feat: refunding has been working

This commit is contained in:
venuswhispers
2026-05-28 00:10:03 +02:00
parent a35e775173
commit 21020685d9
16 changed files with 1411 additions and 7 deletions
BIN
View File
Binary file not shown.
+54 -3
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"strings"
"sync"
"sync/atomic"
"time"
@@ -224,9 +225,23 @@ func (d *BroadcastDriver) broadcastOne(ctx context.Context, sw *Swap) {
"err", err,
)
}
// Leave at SwapStatusBroadcasting so the next tick retries.
// The destination RPC's idempotency (nonce-already-used,
// duplicate-tx-rejected) means re-pushing is safe.
// Surface the error so the SDK/UI can show the user what's
// blocking (e.g. "insufficient funds for gas" — the release
// address needs LUX). The swap stays at SwapStatusBroadcasting
// so the next tick retries; once the user funds the address
// the next attempt succeeds and LastError is cleared below.
//
// LastErrorAt stamps the FIRST tick where LastError went
// non-empty since last clear. The refund driver uses this to
// decide when "stuck at broadcasting" has lasted long enough
// to warrant sweeping the deposit back to the original sender.
_, _ = d.store.Patch(ctx, sw.ID, func(s *Swap) {
humanized := humanizeBroadcastErr(err)
if s.LastError == "" {
s.LastErrorAt = time.Now().UTC()
}
s.LastError = humanized
})
return
}
@@ -236,6 +251,8 @@ func (d *BroadcastDriver) broadcastOne(ctx context.Context, sw *Swap) {
}
s.DestTxHash = res.TxHash
s.Status = SwapStatusCompleted
s.LastError = "" // clear: terminal success.
s.LastErrorAt = time.Time{}
})
if err != nil {
d.failures.Add(1)
@@ -257,5 +274,39 @@ func (d *BroadcastDriver) broadcastOne(ctx context.Context, sw *Swap) {
}
}
// humanizeBroadcastErr normalizes the underlying broadcast error into
// a one-line surface for the SDK + UI. Goals:
// - Common chain-side rejections get a short human label so the UI
// can render "Insufficient funds in release address" instead of
// pasting the full geth error string.
// - HTTP / gateway flakes (502 from krakend, network drops) get a
// generic "Destination RPC unreachable" so users don't see scary
// internals for what's just a transient retry.
// - Anything we don't recognize is forwarded verbatim (truncated)
// so operator triage isn't blind.
func humanizeBroadcastErr(err error) string {
if err == nil {
return ""
}
msg := err.Error()
low := strings.ToLower(msg)
switch {
case strings.Contains(low, "insufficient funds"):
return "Insufficient funds in release address — fund the MPC address with destination-chain gas tokens"
case strings.Contains(low, "nonce too low"):
return "Nonce stale — release address already has a tx with this nonce; bridge will catch up"
case strings.Contains(low, "invalid sender"):
return "Destination chain rejected the signature (invalid sender) — possible MPC pubkey mismatch"
case strings.Contains(low, "http 502"), strings.Contains(low, "http 503"), strings.Contains(low, "http 504"):
return "Destination RPC unreachable (gateway error) — retrying"
case strings.Contains(low, "context deadline exceeded"), strings.Contains(low, "timeout"):
return "Destination RPC timed out — retrying"
}
if len(msg) > 160 {
return msg[:160] + "…"
}
return msg
}
// Compile-time check: *broadcast.Client satisfies Broadcaster.
var _ Broadcaster = (*broadcast.Client)(nil)
+75
View File
@@ -159,6 +159,81 @@ func TestBroadcast_LeavesAtBroadcastingOnFailure(t *testing.T) {
}
}
// TestBroadcast_SurfacesLastErrorOnInsufficientFunds pins the UX
// contract: when the destination chain rejects with "insufficient
// funds for gas * price + value", the swap stays at broadcasting
// (retryable — user just needs to fund the release address) AND
// LastError gets a clear human label so the SPA / UI can stop
// spinning blindly and tell the user what's wrong.
func TestBroadcast_SurfacesLastErrorOnInsufficientFunds(t *testing.T) {
store := NewInMemoryStore()
bc := newFakeBroadcaster()
sw := seedBroadcastingSwap(t, store, "LUX_TESTNET", "0xrawtx")
bc.failFor("LUX_TESTNET", "0xrawtx",
errors.New("eth_sendRawTransaction rpc -32000: insufficient funds for gas * price + value: balance 0, tx cost 1525000000021000, overshot 1525000000021000"))
d := NewBroadcastDriver(store, bc, time.Hour, nil)
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusBroadcasting {
t.Errorf("status should remain broadcasting (recoverable), got %q", got.Status)
}
if got.LastError == "" {
t.Fatal("expected LastError to be populated on insufficient-funds failure")
}
if !strings.Contains(strings.ToLower(got.LastError), "insufficient funds") {
t.Errorf("LastError should label the cause as insufficient funds; got %q", got.LastError)
}
// Make sure we did NOT pass through the raw geth string — that's
// internal noise; the SDK + UI render the human label.
if strings.Contains(got.LastError, "tx cost 1525000000021000") {
t.Errorf("LastError should be humanized, not the raw geth message; got %q", got.LastError)
}
}
// TestBroadcast_ClearsLastErrorOnSuccess pins that after a previously-
// failing swap finally lands, the UI no longer shows the stale error.
func TestBroadcast_ClearsLastErrorOnSuccess(t *testing.T) {
store := NewInMemoryStore()
bc := newFakeBroadcaster()
sw := seedBroadcastingSwap(t, store, "LUX_TESTNET", "0xrawtx")
// Seed a prior LastError as though the previous tick failed.
_, _ = store.Patch(t.Context(), sw.ID, func(s *Swap) {
s.LastError = "Insufficient funds in release address — fund the MPC address"
})
bc.okFor("LUX_TESTNET", "0xrawtx", "0xok")
d := NewBroadcastDriver(store, bc, time.Hour, nil)
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusCompleted {
t.Fatalf("status should be completed, got %q", got.Status)
}
if got.LastError != "" {
t.Errorf("LastError must be cleared on success; got %q", got.LastError)
}
}
// TestBroadcast_HumanizesGatewayFlake confirms a 502 from the krakend
// gateway becomes a generic transient-RPC label, not the raw HTTP
// status string. Users shouldn't see internals for what's just retry.
func TestBroadcast_HumanizesGatewayFlake(t *testing.T) {
store := NewInMemoryStore()
bc := newFakeBroadcaster()
sw := seedBroadcastingSwap(t, store, "LUX_TESTNET", "0xrawtx")
bc.failFor("LUX_TESTNET", "0xrawtx", errors.New("HTTP 502: gateway error"))
d := NewBroadcastDriver(store, bc, time.Hour, nil)
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if !strings.Contains(strings.ToLower(got.LastError), "unreachable") {
t.Errorf("502 should humanize to 'unreachable / retrying'; got %q", got.LastError)
}
}
func TestBroadcast_OnlyTouchesBroadcasting(t *testing.T) {
store := NewInMemoryStore()
bc := newFakeBroadcaster()
+5
View File
@@ -253,6 +253,11 @@ func (w *DepositWatcher) checkOne(ctx context.Context, sw *Swap) {
}
s.Status = SwapStatusBridgeTransferPending
s.DepositedAmount = sw.Amount
// Clear any error left from a previous (admin-reset) attempt
// so the UI doesn't display a stale "Insufficient funds" while
// the new signing leg is in progress.
s.LastError = ""
s.LastErrorAt = time.Time{}
})
if err != nil {
if w.logger != nil {
+44
View File
@@ -120,6 +120,12 @@ func main() {
"disable the background broadcast driver. Swaps in broadcasting will then stall — useful when destination chains are unreachable.")
broadcastTimeout := flag.Duration("broadcast-timeout", broadcast.DefaultTimeout,
"per-request timeout for destination-chain RPC broadcasts (eth_sendRawTransaction etc.).")
refundInterval := flag.Duration("refund-interval", DefaultRefundInterval,
"poll cadence for the refund driver (background loop that sweeps a stuck deposit back to the original sender when destination broadcast can't land).")
refundAfter := flag.Duration("refund-after", DefaultRefundAfter,
"elapsed-since-last-error window before the refund driver auto-reverts a swap stuck at broadcasting with insufficient-funds errors. Default 90s — long enough for an operator to drip LUX to the release address via lux-faucet.sh.")
disableRefundDriver := flag.Bool("disable-refund-driver", false,
"disable the background refund driver entirely. Swaps stuck at broadcasting will then never auto-revert — useful when the source chain is unreachable or operators want manual control.")
staticDir := flag.String("static", envOr("BRIDGE_STATIC_DIR", ""), "override embedded SPA from disk")
dataDir := flag.String("data-dir", envOr("BRIDGE_DATA_DIR", ""),
"persistent data directory for the swap store (zapdb). When empty, swaps are stored in-process and lost on restart — only use the in-memory mode for tests + first deploys. In prod, mount a PersistentVolume and point this at it (e.g. /var/lib/lux-bridge).")
@@ -419,6 +425,40 @@ func main() {
}
defer bcastCancel()
// Refund driver: sweeps a stuck deposit back to the original
// sender on the SOURCE chain when the destination broadcast leg
// has been blocked by "insufficient funds" for longer than
// --refund-after. Reuses the same MPC signer (same threshold key
// signs both legs), the same assembler instance (Networks map
// covers both source + destination), and the same broadcast
// client (rpcURLs covers both directions).
var refundDriver *RefundDriver
refundCtx, refundCancel := context.WithCancel(context.Background())
if !*disableRefundDriver && mchainClient != nil {
refundDriver = NewRefundDriver(
swapStore,
mchainClient,
bcastClient,
asm,
*refundInterval,
*refundAfter,
overrides,
logger,
)
go func() {
_ = refundDriver.Run(refundCtx)
}()
logger.Info("refund driver started",
"interval", *refundInterval,
"refund_after", *refundAfter,
)
} else if *disableRefundDriver {
logger.Info("refund driver disabled by --disable-refund-driver")
} else {
logger.Info("refund driver skipped (no MPC client configured)")
}
defer refundCancel()
app := zip.New(zip.Config{
AppName: "lux-bridge",
DisableStartupMessage: true,
@@ -437,6 +477,7 @@ func main() {
"deposit_watcher": watcher != nil && watcher.Running(),
"signing_driver": signer != nil && signer.Running(),
"broadcast_driver": bcastDriver != nil && bcastDriver.Running(),
"refund_driver": refundDriver != nil && refundDriver.Running(),
"profile": profile.Name,
"post_quantum_end_to_end": profile.IsPostQuantumEndToEnd(),
}
@@ -449,6 +490,9 @@ func main() {
if bcastDriver != nil {
body["broadcast_stats"] = bcastDriver.Stats()
}
if refundDriver != nil {
body["refund_stats"] = refundDriver.Stats()
}
return c.JSON(200, body)
})
+497
View File
@@ -0,0 +1,497 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
luxlog "github.com/luxfi/log"
"github.com/luxfi/bridge/internal/broadcast"
"github.com/luxfi/bridge/internal/depositcheck"
"github.com/luxfi/bridge/internal/mchain"
"github.com/luxfi/bridge/internal/txassembler"
)
// refund_driver.go: background goroutine that sweeps a swap's
// source-chain deposit back to the original sender when the
// destination broadcast leg has been stuck "insufficient funds" for
// too long.
//
// Pipeline position (off the main happy path):
//
// bridge_transfer_pending_broadcasting ── BroadcastDriver retrying ──┐
// │
// (LastError contains "insufficient funds" for > RefundAfter) │
// ▼
// refunding ── RefundDriver (this file) ──┐
// ▼
// refunded (RefundTxHash populated; user got source-chain funds back)
//
// Decision policy in shouldRefund(). Execution policy in refundOne():
// 1. Patch status SwapStatusBroadcasting → SwapStatusRefunding so a
// restart-mid-refund doesn't double-spend.
// 2. Read the source-chain balance at the MPC deposit address.
// 3. Build an EIP-155 refund tx FROM the MPC deposit address TO
// the original sender for (balance − gas) on the source chain.
// 4. Have the MPC quorum sign the sighash. Same key signs the
// destination release AND the source refund — same eth_address
// on every EVM chain.
// 5. Broadcast on the source chain.
// 6. Patch status SwapStatusRefunding → SwapStatusRefunded +
// RefundTxHash. Terminal.
//
// On any mid-flow failure, status rolls back to SwapStatusBroadcasting
// so the broadcast driver or refund driver can retry on the next tick.
// RefundDriver polls broadcasting swaps that have been blocked by
// insufficient funds for too long and sweeps the deposit back.
// Concurrency-safe; one instance per bridge process.
type RefundDriver struct {
store SwapStore
signer MPCSigner
bcaster Broadcaster
assembler *txassembler.Assembler
interval time.Duration
logger luxlog.Logger
// rpcOverrides shadows depositcheck's RPC URL table when querying
// the source-chain balance of the MPC deposit address. Operators
// set this via --source-rpc-overrides (same flag main.go reuses
// across the deposit watcher + broadcast client + assembler).
rpcOverrides map[string]string
// httpClient is reused across balance queries; populated to the
// configured timeout. Zero ⇒ http.DefaultClient.
httpClient *http.Client
// refundAfter is the grace window between LastErrorAt and now that
// must elapse before the driver considers a swap unrecoverable.
// 90s default — gives the user time to pre-fund via the faucet
// helper before the bridge auto-reverts.
refundAfter time.Duration
// perSignTimeout caps each MPC sign call (mirrors signing_driver).
perSignTimeout time.Duration
// perBroadcastTimeout caps each source-chain broadcast.
perBroadcastTimeout time.Duration
// perBalanceTimeout caps each eth_getBalance call.
perBalanceTimeout time.Duration
running atomic.Bool
ticks atomic.Uint64
candidates atomic.Uint64
successes atomic.Uint64
failures atomic.Uint64
listErrors atomic.Uint64
stopOnce sync.Once
cancelRunning context.CancelFunc
}
// DefaultRefundInterval is the tick cadence for the refund driver.
// 15s is slow enough to not race the broadcast driver's 5s retries —
// the broadcast driver wins if a late funding tx lands.
const DefaultRefundInterval = 15 * time.Second
// DefaultRefundAfter is the elapsed-since-LastErrorAt threshold that
// triggers a refund. 90s lets the user run /root/bin/lux-faucet.sh
// before the bridge gives up.
const DefaultRefundAfter = 90 * time.Second
// DefaultRefundPerSignTimeout caps each MPC sign call.
const DefaultRefundPerSignTimeout = 75 * time.Second
// DefaultRefundPerBroadcastTimeout caps each source-chain broadcast.
const DefaultRefundPerBroadcastTimeout = 15 * time.Second
// DefaultRefundPerBalanceTimeout caps eth_getBalance.
const DefaultRefundPerBalanceTimeout = 8 * time.Second
// NewRefundDriver builds a driver with sensible defaults.
func NewRefundDriver(
store SwapStore,
signer MPCSigner,
bcaster Broadcaster,
assembler *txassembler.Assembler,
interval, refundAfter time.Duration,
rpcOverrides map[string]string,
logger luxlog.Logger,
) *RefundDriver {
if interval <= 0 {
interval = DefaultRefundInterval
}
if refundAfter <= 0 {
refundAfter = DefaultRefundAfter
}
return &RefundDriver{
store: store,
signer: signer,
bcaster: bcaster,
assembler: assembler,
interval: interval,
refundAfter: refundAfter,
rpcOverrides: rpcOverrides,
httpClient: &http.Client{Timeout: DefaultRefundPerBalanceTimeout},
perSignTimeout: DefaultRefundPerSignTimeout,
perBroadcastTimeout: DefaultRefundPerBroadcastTimeout,
perBalanceTimeout: DefaultRefundPerBalanceTimeout,
logger: logger,
}
}
// Running reports whether the driver loop is active.
func (d *RefundDriver) Running() bool { return d.running.Load() }
// RefundDriverStats is a point-in-time view of the driver's counters.
type RefundDriverStats struct {
Ticks uint64 `json:"ticks"`
Candidates uint64 `json:"candidates"`
Successes uint64 `json:"successes"`
Failures uint64 `json:"failures"`
ListErrors uint64 `json:"list_errors"`
}
// Stats returns a point-in-time snapshot.
func (d *RefundDriver) Stats() RefundDriverStats {
return RefundDriverStats{
Ticks: d.ticks.Load(),
Candidates: d.candidates.Load(),
Successes: d.successes.Load(),
Failures: d.failures.Load(),
ListErrors: d.listErrors.Load(),
}
}
// Run blocks until ctx is cancelled.
func (d *RefundDriver) Run(ctx context.Context) error {
if !d.running.CompareAndSwap(false, true) {
return nil
}
defer d.running.Store(false)
ctx, cancel := context.WithCancel(ctx)
d.cancelRunning = cancel
defer cancel()
if d.logger != nil {
d.logger.Info("refund driver started",
"interval", d.interval,
"refund_after", d.refundAfter,
)
}
d.tick(ctx)
ticker := time.NewTicker(d.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
if d.logger != nil {
d.logger.Info("refund driver stopped",
"reason", ctx.Err(),
"stats", d.Stats(),
)
}
return ctx.Err()
case <-ticker.C:
d.tick(ctx)
}
}
}
// Stop signals shutdown. Idempotent.
func (d *RefundDriver) Stop() {
d.stopOnce.Do(func() {
if d.cancelRunning != nil {
d.cancelRunning()
}
})
}
// Tick runs a single iteration. Exported for tests.
func (d *RefundDriver) Tick(ctx context.Context) { d.tick(ctx) }
func (d *RefundDriver) tick(ctx context.Context) {
d.ticks.Add(1)
swaps, err := d.store.List(ctx, SwapFilter{Status: SwapStatusBroadcasting})
if err != nil {
d.listErrors.Add(1)
if d.logger != nil {
d.logger.Warn("refund driver: list broadcasting swaps", "err", err)
}
return
}
now := time.Now().UTC()
for _, sw := range swaps {
if ctx.Err() != nil {
return
}
if !d.shouldRefund(sw, now) {
continue
}
d.candidates.Add(1)
d.refundOne(ctx, sw)
}
}
// shouldRefund encodes the trigger policy: a swap is a refund
// candidate iff it has been stuck at broadcasting with an
// insufficient-funds error for longer than the configured window.
func (d *RefundDriver) shouldRefund(sw *Swap, now time.Time) bool {
if sw.Status != SwapStatusBroadcasting {
return false
}
if sw.LastError == "" || sw.LastErrorAt.IsZero() {
return false
}
if !strings.Contains(strings.ToLower(sw.LastError), "insufficient funds") {
return false
}
if now.Sub(sw.LastErrorAt) < d.refundAfter {
return false
}
if sw.Sender == "" {
// Nothing to refund TO. The original sender wasn't captured
// on swap creation. Operator must trigger a manual sweep.
return false
}
if sw.DepositAddress == "" {
return false
}
return true
}
// refundOne is the per-swap refund flow. Atomic-ish: claims the swap
// via a status patch first so a parallel driver doesn't double-fire.
func (d *RefundDriver) refundOne(ctx context.Context, sw *Swap) {
walletID := extractWalletID(sw.DepositAddress)
depositAddr := extractDepositAddress(sw.DepositAddress)
if walletID == "" || depositAddr == "" {
return
}
// Claim the swap. If a parallel tick / restart raced us, give up.
claimed, err := d.store.Patch(ctx, sw.ID, func(s *Swap) {
if s.Status != SwapStatusBroadcasting {
return
}
s.Status = SwapStatusRefunding
})
if err != nil || claimed == nil || claimed.Status != SwapStatusRefunding {
return
}
if d.logger != nil {
d.logger.Info("refund triggered",
"swap_id", sw.ID,
"deposit_addr", depositAddr,
"sender", sw.Sender,
"elapsed_since_last_error", time.Since(sw.LastErrorAt),
)
}
// Step 1 — get source-chain balance at the deposit address.
balanceCtx, cancelBal := context.WithTimeout(ctx, d.perBalanceTimeout)
balance, err := d.fetchBalance(balanceCtx, sw.SourceNetwork, depositAddr)
cancelBal()
if err != nil {
d.rollback(ctx, sw.ID, fmt.Errorf("fetch balance: %w", err))
return
}
// Step 2 — compute refund value = balance - (gasLimit * gasPrice).
// We let the assembler tell us the gas price (it queries the
// source-chain RPC). 21000 gas covers a pure native transfer.
gasPrice, err := d.assembler.Provider.SuggestGasPrice(ctx, sw.SourceNetwork)
if err != nil {
d.rollback(ctx, sw.ID, fmt.Errorf("gas price: %w", err))
return
}
gasCost := new(big.Int).Mul(gasPrice, big.NewInt(21000))
refundValue := new(big.Int).Sub(balance, gasCost)
if refundValue.Sign() <= 0 {
// Balance can't cover gas. Nothing to refund. Skip — leave
// status at refunding so an operator can see it stuck and
// triage manually (vs silently rolling back, which would
// re-fire on the next tick).
_, _ = d.store.Patch(ctx, sw.ID, func(s *Swap) {
s.LastError = fmt.Sprintf("Refund impossible: deposit balance %s wei < gas cost %s wei",
balance.String(), gasCost.String())
})
d.failures.Add(1)
if d.logger != nil {
d.logger.Warn("refund impossible: balance < gas",
"swap_id", sw.ID,
"balance_wei", balance.String(),
"gas_cost_wei", gasCost.String(),
)
}
return
}
// Step 3 — build the unsigned refund tx via the source-chain
// network config the assembler already knows about.
unsigned, err := d.assembler.PreSignNativeTransfer(ctx, sw.SourceNetwork, depositAddr, sw.Sender, refundValue)
if err != nil {
d.rollback(ctx, sw.ID, fmt.Errorf("preSign refund: %w", err))
return
}
// Step 4 — MPC sign the sighash.
sigCtx, cancelSig := context.WithTimeout(ctx, d.perSignTimeout)
res, err := d.signer.SignForWallet(sigCtx, walletID, fmt.Sprintf("0x%x", unsigned.SigHash))
cancelSig()
if err != nil {
d.rollback(ctx, sw.ID, fmt.Errorf("MPC sign refund: %w", err))
return
}
// Step 5 — finalize the signed raw tx.
r, s, v, err := txassembler.ParseRSV(res.Signature)
if err != nil {
d.rollback(ctx, sw.ID, fmt.Errorf("parse refund signature: %w", err))
return
}
rawTx, err := d.assembler.Finalize(unsigned, r, s, v)
if err != nil {
d.rollback(ctx, sw.ID, fmt.Errorf("finalize refund: %w", err))
return
}
// Step 6 — broadcast on the source chain.
pushCtx, cancelPush := context.WithTimeout(ctx, d.perBroadcastTimeout)
bres, err := d.bcaster.Broadcast(pushCtx, sw.SourceNetwork, rawTx)
cancelPush()
if err != nil {
d.rollback(ctx, sw.ID, fmt.Errorf("broadcast refund: %w", err))
return
}
// Step 7 — mark refunded.
_, _ = d.store.Patch(ctx, sw.ID, func(s *Swap) {
s.Status = SwapStatusRefunded
s.RefundTxHash = bres.TxHash
s.LastError = ""
s.LastErrorAt = time.Time{}
})
d.successes.Add(1)
if d.logger != nil {
d.logger.Info("refund completed",
"swap_id", sw.ID,
"refund_tx_hash", bres.TxHash,
"refund_value_wei", refundValue.String(),
)
}
}
// fetchBalance queries eth_getBalance on the source chain's JSON-RPC
// endpoint. Uses the same URL resolution policy as the broadcast
// client + assembler: overrides shadow the depositcheck table.
func (d *RefundDriver) fetchBalance(ctx context.Context, network, addr string) (*big.Int, error) {
url := d.rpcOverrides[network]
if url == "" {
url = depositcheck.RPCURLFor(network)
}
if url == "" {
return nil, fmt.Errorf("no RPC URL configured for network %s", network)
}
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_getBalance",
"params": []any{addr, "latest"},
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := d.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("eth_getBalance HTTP %d: %s", resp.StatusCode, truncRefund(respBody, 200))
}
var parsed struct {
Result string `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
if err := json.Unmarshal(respBody, &parsed); err != nil {
return nil, fmt.Errorf("decode balance: %w", err)
}
if parsed.Error != nil {
return nil, fmt.Errorf("eth_getBalance rpc %d: %s", parsed.Error.Code, parsed.Error.Message)
}
hex := strings.TrimPrefix(strings.TrimPrefix(parsed.Result, "0x"), "0X")
if hex == "" {
return big.NewInt(0), nil
}
n, ok := new(big.Int).SetString(hex, 16)
if !ok {
return nil, fmt.Errorf("balance: invalid hex %q", parsed.Result)
}
return n, nil
}
// rollback returns the swap to SwapStatusBroadcasting so a future
// tick (refund OR broadcast — broadcast wins if user funded the
// release address in the meantime) can try again.
func (d *RefundDriver) rollback(ctx context.Context, id string, cause error) {
d.failures.Add(1)
if d.logger != nil {
d.logger.Warn("refund failed — rolling back to broadcasting",
"swap_id", id,
"err", cause,
)
}
_, _ = d.store.Patch(ctx, id, func(s *Swap) {
if s.Status != SwapStatusRefunding {
return
}
s.Status = SwapStatusBroadcasting
// Surface the refund failure on LastError so operators can
// see why the auto-revert didn't land. Doesn't reset
// LastErrorAt — keeps next-tick eligibility window unchanged.
s.LastError = "Refund attempt failed (retrying): " + truncRefundErr(cause, 120)
})
}
func truncRefund(b []byte, n int) string {
if len(b) <= n {
return string(b)
}
return string(b[:n]) + "…"
}
func truncRefundErr(err error, n int) string {
if err == nil {
return ""
}
s := err.Error()
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// Compile-time interface guards (also serve as imports anchors).
var _ MPCSigner = (*mchain.Client)(nil)
var _ Broadcaster = (*broadcast.Client)(nil)
var _ = errors.New
+330
View File
@@ -0,0 +1,330 @@
package main
import (
"context"
"encoding/json"
"errors"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/luxfi/bridge/internal/broadcast"
"github.com/luxfi/bridge/internal/mchain"
"github.com/luxfi/bridge/internal/txassembler"
)
// =============================================================================
// Helpers — fakes for the refund driver dependencies
// =============================================================================
// rdFakeSigner is a programmable MPCSigner for refund driver tests.
type rdFakeSigner struct {
sig string
err error
}
func (f *rdFakeSigner) SignForWallet(_ context.Context, _, _ string) (*mchain.SignResult, error) {
if f.err != nil {
return nil, f.err
}
return &mchain.SignResult{Signature: f.sig, SessionID: "rd-test-session"}, nil
}
// rdFakeBroadcaster is a programmable Broadcaster.
type rdFakeBroadcaster struct {
hash string
err error
calls atomic.Uint64
last atomic.Value // last (network, rawTx)
}
func (f *rdFakeBroadcaster) Broadcast(_ context.Context, network, rawTxHex string) (*broadcast.BroadcastResult, error) {
f.calls.Add(1)
f.last.Store(network + "|" + rawTxHex)
if f.err != nil {
return nil, f.err
}
return &broadcast.BroadcastResult{TxHash: f.hash}, nil
}
// fakeBalanceRPC is an httptest server that responds to a single
// eth_getBalance call with a fixed hex value. Used to verify the
// refund driver queries source-chain balance correctly.
func fakeBalanceRPC(t *testing.T, balanceHex string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Method string `json:"method"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": 1,
"result": balanceHex,
})
}))
t.Cleanup(srv.Close)
return srv
}
// newRefundRig assembles everything the refund driver needs against
// in-process fakes. Static provider supplies nonce + gas price so the
// assembler doesn't hit the network for the PreSign leg.
func newRefundRig(t *testing.T, balanceHex string) (*RefundDriver, *InMemoryStore, *rdFakeBroadcaster) {
t.Helper()
store := NewInMemoryStore()
signer := &rdFakeSigner{
// Valid 65-byte sig (deterministic test value). The assembler
// canonicalizes high-s to low-s in ParseRSV.
sig: "1111111111111111111111111111111111111111111111111111111111111111" +
"2222222222222222222222222222222222222222222222222222222222222222" +
"00",
}
bc := &rdFakeBroadcaster{hash: "0xrefundtxhash"}
provider := &txassembler.StaticProvider{
GasPrice: map[string]*big.Int{
"ETHEREUM_SEPOLIA": big.NewInt(25_000_000_000), // 25 gwei
},
}
asm := txassembler.New(provider)
asm.SetNetwork("ETHEREUM_SEPOLIA", txassembler.PerNetwork{
ChainID: big.NewInt(11155111),
NativeDecimals: 18,
})
balanceSrv := fakeBalanceRPC(t, balanceHex)
d := NewRefundDriver(store, signer, bc, asm,
time.Hour, 60*time.Second,
map[string]string{"ETHEREUM_SEPOLIA": balanceSrv.URL},
nil)
// Shrink internal timeouts so tests don't wait.
d.perBalanceTimeout = 2 * time.Second
d.perSignTimeout = 2 * time.Second
d.perBroadcastTimeout = 2 * time.Second
return d, store, bc
}
// seedBlockedSwap creates a swap stuck at broadcasting with an
// "insufficient funds" LastError set elapsedAgo in the past. opts
// override individual fields when needed.
func seedBlockedSwap(t *testing.T, store *InMemoryStore, elapsedAgo time.Duration, mutate func(*Swap)) *Swap {
t.Helper()
sw := &Swap{
Status: SwapStatusBroadcasting,
SourceNetwork: "ETHEREUM_SEPOLIA",
DestinationNetwork: "LUX_TESTNET",
Sender: "0xa28fae14eb42e7a5c36ad2d774a2b7eb293c4473",
DepositAddress: "wallet-x###0x9d6afe4e71184d8bd2972fc5a8b63ca257fb7383",
LastError: "Insufficient funds in release address — fund the MPC address with destination-chain gas tokens",
LastErrorAt: time.Now().UTC().Add(-elapsedAgo),
DestRawTx: "0xrawtx",
}
if mutate != nil {
mutate(sw)
}
if err := store.Create(t.Context(), sw); err != nil {
t.Fatalf("seed: %v", err)
}
return sw
}
// =============================================================================
// Trigger-policy tests (shouldRefund pure function)
// =============================================================================
func TestRefund_DoesNothingBeforeTimeout(t *testing.T) {
d, store, bc := newRefundRig(t, "0x0")
sw := seedBlockedSwap(t, store, 5*time.Second, nil) // well under 60s window
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusBroadcasting {
t.Errorf("status changed prematurely: %q", got.Status)
}
if bc.calls.Load() != 0 {
t.Errorf("broadcaster called before timeout elapsed")
}
if d.Stats().Candidates != 0 {
t.Errorf("expected 0 candidates, got %d", d.Stats().Candidates)
}
}
func TestRefund_DoesNothingWithoutInsufficientFundsError(t *testing.T) {
d, store, bc := newRefundRig(t, "0x0")
sw := seedBlockedSwap(t, store, 5*time.Minute, func(s *Swap) {
// Different transient error class — gateway flake, not "insufficient funds".
s.LastError = "Destination RPC unreachable — retrying"
})
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusBroadcasting {
t.Errorf("non-funds error should not trigger refund: %q", got.Status)
}
if bc.calls.Load() != 0 {
t.Errorf("broadcaster called for wrong error class")
}
}
func TestRefund_DoesNothingWithoutSender(t *testing.T) {
d, store, _ := newRefundRig(t, "0x0")
sw := seedBlockedSwap(t, store, 5*time.Minute, func(s *Swap) {
s.Sender = ""
})
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusBroadcasting {
t.Errorf("missing sender should leave status untouched: %q", got.Status)
}
}
// =============================================================================
// Happy path
// =============================================================================
func TestRefund_HappyPath(t *testing.T) {
// 1 ETH balance at the deposit address: more than enough to cover
// 21000 * 25 gwei = 525_000 gwei = 5.25e14 wei of gas.
d, store, bc := newRefundRig(t, "0xDE0B6B3A7640000") // 1 ETH
sw := seedBlockedSwap(t, store, 2*time.Minute, nil)
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusRefunded {
t.Fatalf("status = %q, want refunded", got.Status)
}
if got.RefundTxHash != "0xrefundtxhash" {
t.Errorf("RefundTxHash = %q, want 0xrefundtxhash", got.RefundTxHash)
}
if got.LastError != "" || !got.LastErrorAt.IsZero() {
t.Errorf("LastError fields should clear on refund success; got %q at %v", got.LastError, got.LastErrorAt)
}
if bc.calls.Load() != 1 {
t.Errorf("expected 1 source-chain broadcast, got %d", bc.calls.Load())
}
// The broadcaster was asked to push on the SOURCE network.
last := bc.last.Load().(string)
if !strings.HasPrefix(last, "ETHEREUM_SEPOLIA|") {
t.Errorf("broadcast on wrong network: %q", last)
}
if d.Stats().Successes != 1 {
t.Errorf("expected 1 success, got %+v", d.Stats())
}
}
// =============================================================================
// Failure-mode tests
// =============================================================================
func TestRefund_BalanceBelowGasCostKeepsStuck(t *testing.T) {
// 1 wei balance — way below the 21000 * 25e9 = 5.25e14 wei gas cost.
d, store, bc := newRefundRig(t, "0x1")
sw := seedBlockedSwap(t, store, 2*time.Minute, nil)
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusRefunding {
t.Errorf("balance-below-gas swap should stay refunding (operator triage); got %q", got.Status)
}
if !strings.Contains(strings.ToLower(got.LastError), "refund impossible") {
t.Errorf("expected LastError to explain refund impossibility; got %q", got.LastError)
}
if bc.calls.Load() != 0 {
t.Errorf("broadcaster should NOT be called when refund value is negative")
}
if d.Stats().Failures != 1 {
t.Errorf("expected 1 failure, got %+v", d.Stats())
}
}
func TestRefund_RollsBackOnBroadcastFailure(t *testing.T) {
d, store, bc := newRefundRig(t, "0xDE0B6B3A7640000")
bc.err = errors.New("source RPC returned 500")
sw := seedBlockedSwap(t, store, 2*time.Minute, nil)
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusBroadcasting {
t.Errorf("broadcast failure should roll back to broadcasting; got %q", got.Status)
}
if !strings.Contains(strings.ToLower(got.LastError), "refund attempt failed") {
t.Errorf("LastError should explain refund retry; got %q", got.LastError)
}
// LastErrorAt must NOT have been reset — rollback preserves the
// original "stuck since" stamp so retry eligibility is unchanged.
if got.LastErrorAt.IsZero() {
t.Error("LastErrorAt should be preserved across rollback")
}
if d.Stats().Failures != 1 {
t.Errorf("expected 1 failure, got %+v", d.Stats())
}
}
func TestRefund_RollsBackOnSignFailure(t *testing.T) {
d, store, bc := newRefundRig(t, "0xDE0B6B3A7640000")
d.signer = &rdFakeSigner{err: errors.New("MPC cluster timeout")}
sw := seedBlockedSwap(t, store, 2*time.Minute, nil)
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusBroadcasting {
t.Errorf("MPC failure should roll back to broadcasting; got %q", got.Status)
}
if bc.calls.Load() != 0 {
t.Errorf("broadcaster should NOT be called when sign fails")
}
}
// =============================================================================
// Concurrency: claim/rollback semantics
// =============================================================================
func TestRefund_ClaimsAtomically_AvoidsDoubleFire(t *testing.T) {
// Two ticks back-to-back; second should observe SwapStatusRefunding
// (already claimed) and skip. End state: refunded, broadcaster
// called exactly once.
d, store, bc := newRefundRig(t, "0xDE0B6B3A7640000")
sw := seedBlockedSwap(t, store, 2*time.Minute, nil)
// First tick → refund happens.
d.Tick(t.Context())
// Second tick → swap is now SwapStatusRefunded; not eligible.
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusRefunded {
t.Errorf("status = %q, want refunded", got.Status)
}
if bc.calls.Load() != 1 {
t.Errorf("broadcaster called %d times; want 1", bc.calls.Load())
}
}
// =============================================================================
// Network-stub double-checks (the JSON-RPC balance helper)
// =============================================================================
func TestRefund_ParsesBalanceHexCorrectly(t *testing.T) {
d, _, _ := newRefundRig(t, "0xabc123")
bal, err := d.fetchBalance(t.Context(), "ETHEREUM_SEPOLIA",
"0x9d6afe4e71184d8bd2972fc5a8b63ca257fb7383")
if err != nil {
t.Fatalf("fetchBalance: %v", err)
}
if bal.Cmp(big.NewInt(0xabc123)) != 0 {
t.Errorf("balance = %s, want 0xabc123 (%d)", bal.String(), 0xabc123)
}
}
+8 -1
View File
@@ -275,13 +275,14 @@ func (d *SigningDriver) signOne(ctx context.Context, sw *Swap) {
"err", aerr,
)
}
// Roll back — the swap is still depositd-confirmed; the
// Roll back — the swap is still deposit-confirmed; the
// next tick will retry. (Often this means a transient
// RPC failure querying nonce / gas price.)
_, _ = d.store.Patch(ctx, sw.ID, func(s *Swap) {
if s.Status == SwapStatusSigning {
s.Status = SwapStatusBridgeTransferPending
}
s.LastError = "Destination RPC unreachable while building tx — retrying"
})
return
}
@@ -310,6 +311,7 @@ func (d *SigningDriver) signOne(ctx context.Context, sw *Swap) {
if s.Status == SwapStatusSigning {
s.Status = SwapStatusBridgeTransferPending
}
s.LastError = "MPC signing ceremony failed — retrying"
})
return
}
@@ -364,6 +366,11 @@ func (d *SigningDriver) signOne(ctx context.Context, sw *Swap) {
s.DestRawTx = destRawTx
}
s.Status = SwapStatusBroadcasting
// Clear any prior transient error — we're past the signing
// stage. The broadcast driver will set a fresh LastError if
// the destination chain rejects the raw tx.
s.LastError = ""
s.LastErrorAt = time.Time{}
})
if err != nil {
if d.logger != nil {
+34 -1
View File
@@ -42,7 +42,14 @@ const (
SwapStatusBroadcasting SwapStatus = "bridge_transfer_pending_broadcasting"
// Final settlement: funds arrived at destination address.
SwapStatusCompleted SwapStatus = "completed"
// Terminal failure (timeout, refund, etc.).
// Destination broadcast couldn't land (e.g. insufficient funds at the
// MPC release address) and a timeout elapsed; the refund driver is
// sweeping the source-chain deposit back to the original sender.
SwapStatusRefunding SwapStatus = "refunding"
// Refund tx confirmed on the source chain — sender got their funds
// back minus source-chain gas. Terminal state.
SwapStatusRefunded SwapStatus = "refunded"
// Terminal failure (refund attempted but unrecoverable, etc.).
SwapStatusFailed SwapStatus = "failed"
// User-cancelled before deposit.
SwapStatusCancelled SwapStatus = "cancelled"
@@ -102,6 +109,32 @@ type Swap struct {
// Destination-side broadcast — tx hash on the destination chain.
DestTxHash string `json:"dest_tx_hash,omitempty"`
// LastError holds the most-recent transient error from the signing
// or broadcast driver. The drivers keep retrying (the swap is
// recoverable — usually a flaky RPC or a release-address that
// needs funding), but exposing the error lets the SDK and UI tell
// the user WHY progress has stalled instead of letting them stare
// at a "broadcasting" spinner for the full 5-minute SPA timeout.
//
// Drivers MUST clear this field on success (set to ""). Treat it
// as transient state, not historical log — once the swap reaches
// `completed`, the field should be empty.
LastError string `json:"last_error,omitempty"`
// LastErrorAt is when LastError last transitioned from empty to set.
// The refund driver uses elapsed time since this stamp to decide
// when a stalled-at-broadcasting swap should be reverted (i.e. the
// destination chain has been rejecting "insufficient funds" for
// long enough that the user clearly isn't going to fund the
// release address). Zero value = no error active.
LastErrorAt time.Time `json:"last_error_at,omitempty"`
// RefundTxHash is the source-chain transaction hash of the refund
// sweep (from MPC deposit address back to Sender) once the refund
// driver successfully broadcasts. Populated together with
// Status=SwapStatusRefunded.
RefundTxHash string `json:"refund_tx_hash,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+11
View File
@@ -67,6 +67,15 @@ type serverSwap struct {
// and verifying ECDSA recovery against the expected sender when
// a destination chain rejects with "invalid sender".
DestRawTx string `json:"dest_raw_tx,omitempty"`
// LastError is the most-recent transient driver error. The swap
// is still progressing (the drivers retry); the UI uses this to
// tell the user what's blocking — e.g. "insufficient funds for
// gas" before they sit through a 5-minute spinner.
LastError string `json:"last_error,omitempty"`
// RefundTxHash is the source-chain tx hash of the refund sweep
// once the refund driver lands it. Populated together with
// status=refunded.
RefundTxHash string `json:"refund_tx_hash,omitempty"`
}
// createSwapReq mirrors the POST body sent by the TS SDK
@@ -286,6 +295,8 @@ func swapToServerShape(sw *Swap) serverSwap {
SourceTxHash: sw.SourceTxHash,
DestTxHash: sw.DestTxHash,
DestRawTx: sw.DestRawTx,
LastError: sw.LastError,
RefundTxHash: sw.RefundTxHash,
}
}
+80
View File
@@ -305,6 +305,86 @@ func (a *Assembler) PreSign(ctx context.Context, in SwapIntent) (*Unsigned, erro
return unsigned, nil
}
// PreSignNativeTransfer is the low-level sibling of PreSign for raw
// native-token transfers expressed in wei rather than human-readable
// amounts. Used by the refund driver to sweep a deposit address back
// to its original sender, where the value is computed as
// "balance − gas" — a quantity only meaningful in base units, never
// floats.
//
// Behaviour vs PreSign:
// - Skips the Tokens / BridgeContract / ERC-20 routing entirely.
// Native transfer only: `to` is the recipient, `data` empty,
// `value` the supplied wei amount.
// - Looks up the network's PerNetwork config to source ChainID +
// gas defaults; falls back to 21000 gas / SuggestGasPrice when
// the per-network values are unset.
// - Returns an Unsigned ready for MPC signing → Finalize, same as
// PreSign — the refund flow reuses the same signer + assembler
// instances as the destination-release flow.
func (a *Assembler) PreSignNativeTransfer(
ctx context.Context,
network, fromAddress, toAddress string,
valueWei *big.Int,
) (*Unsigned, error) {
cfg, ok := a.Networks[network]
if !ok {
return nil, fmt.Errorf("txassembler: no config for network %s", network)
}
if cfg.ChainID == nil || cfg.ChainID.Sign() <= 0 {
return nil, fmt.Errorf("txassembler: ChainID required for %s", network)
}
if valueWei == nil || valueWei.Sign() < 0 {
return nil, fmt.Errorf("txassembler: valueWei must be non-negative; got %v", valueWei)
}
to, err := parseAddress(toAddress)
if err != nil {
return nil, fmt.Errorf("toAddress: %w", err)
}
nonce, err := a.Provider.PendingNonce(ctx, network, fromAddress)
if err != nil {
return nil, fmt.Errorf("PendingNonce: %w", err)
}
var gasPrice *big.Int
if cfg.DefaultGasPriceWei != nil && cfg.DefaultGasPriceWei.Sign() > 0 {
gasPrice = new(big.Int).Set(cfg.DefaultGasPriceWei)
} else {
gasPrice, err = a.Provider.SuggestGasPrice(ctx, network)
if err != nil {
return nil, fmt.Errorf("SuggestGasPrice: %w", err)
}
}
gasLimit := cfg.DefaultGasLimit
if gasLimit == 0 {
gasLimit = 21000 // pure native transfer.
}
unsigned := &Unsigned{
Network: network,
ChainID: new(big.Int).Set(cfg.ChainID),
Nonce: nonce,
GasPrice: gasPrice,
GasLimit: gasLimit,
To: to,
Value: new(big.Int).Set(valueWei),
Data: nil,
}
encoded := rlpList(
encodeUint(new(big.Int).SetUint64(unsigned.Nonce)),
encodeUint(unsigned.GasPrice),
encodeUint(new(big.Int).SetUint64(unsigned.GasLimit)),
encodeBytes(unsigned.To),
encodeUint(unsigned.Value),
encodeBytes(unsigned.Data),
encodeUint(unsigned.ChainID),
encodeUint(nil),
encodeUint(nil),
)
unsigned.SigHash = keccak256(encoded)
return unsigned, nil
}
// Finalize takes the MPC signature (r, s, recoveryID) and produces
// the wire-ready RLP-encoded signed transaction as 0x-prefixed hex.
//
@@ -444,6 +444,131 @@ describe('useTransfers', () => {
})
})
// ───────────────────────────────────────────────────────────────────────
// Server-side LastError propagation
// ───────────────────────────────────────────────────────────────────────
//
// The bridge backend writes a human-readable transient error to
// `last_error` on the swap record whenever the broadcast or signing
// leg fails recoverably (insufficient funds, RPC gateway flake,
// etc.). The SPA polls /api/swaps/:id, mirrors that field to
// Transfer.lastError, and TransferStatus renders it as a warning
// banner — distinct from `Transfer.error` (terminal failure).
describe('refund phase propagation', () => {
it('maps server refunding/refunded statuses to TransferPhase + surfaces refund_tx_hash', async () => {
let serverState = {
status: 'bridge_transfer_pending_broadcasting' as string,
refund_tx_hash: undefined as string | undefined,
}
global.fetch = fetchMatcher([
[/\/api\/swaps$/, { data: { id: 'swap_refunded', status: 'pending' } }],
[
/\/api\/swaps\//,
() => ({
data: {
id: 'swap_refunded',
status: serverState.status,
...(serverState.refund_tx_hash
? { refund_tx_hash: serverState.refund_tx_hash }
: {}),
},
}),
],
])
const { result } = renderHook(() => useTransfers())
await act(async () => {
await result.current.initiate({
fromChainId: 'lux:96369',
toChainId: 'evm:1',
fromAssetId: 'lux:96369:LUX',
toAssetId: 'evm:1:USDC',
inAmount: 1,
outAmount: 0.99,
})
})
// Step 1 — server transitions to refunding.
serverState = { status: 'refunding', refund_tx_hash: undefined }
await waitFor(
() => {
expect(result.current.active?.phase).toBe('refunding')
},
{ timeout: 5_000 },
)
// Step 2 — server lands the refund + reports the source-chain tx hash.
serverState = { status: 'refunded', refund_tx_hash: '0xrefundhash123' }
await waitFor(
() => {
expect(result.current.active?.phase).toBe('refunded')
},
{ timeout: 5_000 },
)
expect(result.current.active?.refundTxHash).toBe('0xrefundhash123')
// 'refunded' is terminal — poll loop stops. The SPA's 5min
// timeout should NOT fire (no patch to 'failed'). The phase
// staying at 'refunded' after a beat is a regression guard:
// if subscribe() kept polling and the next response was missing
// refund_tx_hash, refundTxHash would clear here.
await new Promise((r) => setTimeout(r, 200))
expect(result.current.active?.phase).toBe('refunded')
expect(result.current.active?.refundTxHash).toBe('0xrefundhash123')
})
})
describe('server-side last_error propagation', () => {
it('mirrors last_error from poll into Transfer.lastError', async () => {
let lastErr: string | null =
'Insufficient funds in release address — fund the MPC address with destination-chain gas tokens'
global.fetch = fetchMatcher([
[/\/api\/swaps$/, { data: { id: 'swap_lerr', status: 'pending' } }],
[
/\/api\/swaps\//,
() => ({
data: {
id: 'swap_lerr',
status: 'bridge_transfer_pending_broadcasting',
...(lastErr ? { last_error: lastErr } : {}),
},
}),
],
])
const { result } = renderHook(() => useTransfers())
await act(async () => {
await result.current.initiate({
fromChainId: 'lux:96369',
toChainId: 'evm:1',
fromAssetId: 'lux:96369:LUX',
toAssetId: 'evm:1:USDC',
inAmount: 1,
outAmount: 0.99,
})
})
await waitFor(
() => {
expect(result.current.active?.lastError).toMatch(/insufficient funds/i)
},
{ timeout: 5_000 },
)
// Phase stays in broadcasting — last_error is NOT terminal.
expect(result.current.active?.phase).toBe('broadcasting')
expect(result.current.active?.error).toBeUndefined()
// Server clears its last_error on the next tick (issue resolved
// → user funded address → broadcast accepted). The local
// Transfer.lastError should clear too.
lastErr = null
await waitFor(
() => {
expect(result.current.active?.lastError).toBeUndefined()
},
{ timeout: 5_000 },
)
})
})
it('clear() removes all transfers and aborts polling', async () => {
global.fetch = fetchMatcher([
[/\/api\/swaps$/, { data: { id: 'swap_y', status: 'pending' } }],
@@ -30,6 +30,8 @@ const phaseLabel: Record<TransferPhase, string> = {
signing: 'Signing (MPC threshold)',
broadcasting: 'Broadcasting',
completed: 'Completed',
refunding: 'Refunding',
refunded: 'Refunded',
failed: 'Failed',
}
@@ -38,6 +40,12 @@ const phaseColor: Record<TransferPhase, string> = {
signing: 'var(--bridge-accent)',
broadcasting: 'var(--bridge-accent-hover)',
completed: 'var(--bridge-success)',
// Refund phases sit between "still working" and "done" — render in a
// warning hue so users can tell they got their funds back but the
// intended swap didn't complete. Distinct from completed (green) and
// failed (red).
refunding: 'var(--bridge-warning, #b45309)',
refunded: 'var(--bridge-warning, #b45309)',
failed: 'var(--bridge-danger)',
}
@@ -302,6 +310,71 @@ export const TransferStatus: FC<TransferStatusProps> = ({ transfers }) => {
</div>
) : null}
{/*
Transient retryable error from the bridge — shown only
while the swap is still progressing. Distinct visually
from `t.error` (terminal failure, red): rendered as a
warning banner so the user knows what to fix (e.g.
"Insufficient funds in release address") without
thinking the swap has actually died.
*/}
{t.lastError && t.phase !== 'failed' && t.phase !== 'completed' && t.phase !== 'refunded' ? (
<div
style={{
fontSize: 11,
padding: '6px 8px',
marginTop: 4,
borderRadius: 4,
color: 'var(--bridge-warning, #b45309)',
background: 'var(--bridge-warning-bg, rgba(245, 158, 11, 0.08))',
border: '1px solid var(--bridge-warning-border, rgba(245, 158, 11, 0.25))',
}}
>
⏳ {t.lastError} — bridge is retrying
</div>
) : null}
{/*
Refund-state banner. While refunding: tell the user the
bridge is sweeping their deposit back. After refunded:
surface the source-chain tx hash so they can verify
receipt on the explorer. Distinct from the standard
lastError banner because this is a final-disposition
state rather than transient retry signal.
*/}
{t.phase === 'refunding' ? (
<div
style={{
fontSize: 11,
padding: '6px 8px',
marginTop: 4,
borderRadius: 4,
color: 'var(--bridge-warning, #b45309)',
background: 'var(--bridge-warning-bg, rgba(245, 158, 11, 0.08))',
border: '1px solid var(--bridge-warning-border, rgba(245, 158, 11, 0.25))',
}}
>
↩ Auto-reverting: destination release couldn't land, sweeping deposit back to sender
</div>
) : null}
{t.phase === 'refunded' && t.refundTxHash ? (
<div
style={{
fontSize: 11,
padding: '6px 8px',
marginTop: 4,
borderRadius: 4,
color: 'var(--bridge-warning, #b45309)',
background: 'var(--bridge-warning-bg, rgba(245, 158, 11, 0.08))',
border: '1px solid var(--bridge-warning-border, rgba(245, 158, 11, 0.25))',
}}
>
↩ Refunded — source funds returned to sender · tx{' '}
{shortAddress(t.refundTxHash, 6, 4)}
</div>
) : null}
{t.error ? (
<div style={{ fontSize: 11, color: 'var(--bridge-danger)' }}>
{t.error}
+50 -1
View File
@@ -47,6 +47,9 @@ export type TransferPhase =
| 'signing' // MPC threshold ceremony in progress
| 'broadcasting' // dest tx broadcast, waiting for finality
| 'completed' // dest finality reached
| 'refunding' // dest broadcast couldn't land (insufficient funds, etc.)
// — refund driver is sweeping deposit back to sender
| 'refunded' // refund landed; sender got source-chain funds back
| 'failed' // any leg failed; .error has the reason
export interface Transfer {
@@ -71,7 +74,29 @@ export interface Transfer {
* the deposit is detected on-chain.
*/
depositAddress?: string
/**
* Terminal failure reason — set together with `phase: 'failed'`.
* Distinct from `lastError`: `error` means the swap is dead, `lastError`
* means the swap is still retrying but currently blocked.
*/
error?: string
/**
* Most-recent transient driver error from the bridge — typically a
* human-readable label like "Insufficient funds in release address".
* The swap is still progressing; the bridge will retry. The UI
* surfaces this so the user knows what's blocking before the SPA's
* 5-minute deadline elapses. Cleared automatically when the server's
* `last_error` field disappears (i.e. the underlying issue
* resolved — usually because the user funded the address).
*/
lastError?: string
/**
* Source-chain transaction hash of the refund sweep, populated by
* the bridge's refund driver once it lands. Together with
* phase === 'refunded' this lets the UI deep-link to the explorer
* tx where the user received their source-chain funds back.
*/
refundTxHash?: string
}
export interface TransferState {
@@ -120,6 +145,10 @@ const POLL_DEADLINE_MS = 5 * 60_000 // 5 minutes
function statusToPhase(status: string | undefined): TransferPhase {
if (!status) return 'pending'
const s = status.toLowerCase()
// Refund phases first — "refunded" must beat "completed"'s loose
// heuristic; "refunding" must beat broadcasting/transfer.
if (s === 'refunded') return 'refunded'
if (s.startsWith('refund')) return 'refunding'
if (s.includes('complete') || s.includes('payout')) return 'completed'
if (s.includes('fail') || s.includes('expire') || s.includes('error')) return 'failed'
if (s.includes('sign') || s.includes('mpc')) return 'signing'
@@ -225,6 +254,23 @@ export function useTransfers(): TransferState {
patch(transferId, { depositAddress: serverSwap.deposit_address })
}
// Mirror server-side LastError to the local Transfer.lastError so
// the UI can render the underlying blocker (e.g. "Insufficient
// funds in release address") instead of waiting on the 5-minute
// SPA deadline. Empty/missing on the wire → clear locally so the
// banner disappears as soon as the bridge's next retry succeeds.
const wireLastErr =
typeof serverSwap.last_error === 'string' ? serverSwap.last_error : ''
patch(transferId, { lastError: wireLastErr || undefined })
// Refund tx hash appears together with phase=refunded — the
// refund driver lands the source-chain sweep, then patches
// both fields atomically server-side. Mirror it so the UI can
// link to the explorer.
if (typeof serverSwap.refund_tx_hash === 'string' && serverSwap.refund_tx_hash) {
patch(transferId, { refundTxHash: serverSwap.refund_tx_hash })
}
// Kick off MPC session on first entry into the signing phase.
// `publicUrl` is required for the native threshold sign; tenants
// running pure-external custody (utila/fireblocks only) skip this
@@ -270,7 +316,10 @@ export function useTransfers(): TransferState {
}
}
if (phase === 'completed' || phase === 'failed') {
// Terminal phases — stop polling. `refunded` is terminal too:
// the bridge has swept the source-chain deposit back to the
// sender and the swap will never advance further.
if (phase === 'completed' || phase === 'failed' || phase === 'refunded') {
return
}
+20
View File
@@ -255,6 +255,26 @@ export interface ServerSwap {
* server's wire format.
*/
deposit_address?: string
/** Source-chain tx hash, populated by the deposit watcher when found. */
source_tx_hash?: string
/** Destination-chain tx hash, populated by the broadcast driver on completion. */
dest_tx_hash?: string
/**
* Most-recent transient error from the bridge's signing or broadcast
* driver. Cleared on advance to a new phase / success. The bridge
* keeps retrying — surface this so the UI can tell the user WHY a
* swap is stalled (e.g. "Insufficient funds in release address")
* instead of letting them stare at a spinner until the 5-min UI
* timeout fires.
*/
last_error?: string
/**
* Source-chain transaction hash of the auto-refund sweep, populated
* by the bridge's refund driver when a stuck-broadcasting swap is
* reverted (e.g. release address never got funded). Set together
* with status === 'refunded'.
*/
refund_tx_hash?: string
[k: string]: unknown
}
+5 -1
View File
@@ -81,5 +81,9 @@ export function saveTransfers(
* polling on it). Terminal phases never need polling.
*/
export function isActive(t: Transfer): boolean {
return t.phase !== 'completed' && t.phase !== 'failed'
return (
t.phase !== 'completed' &&
t.phase !== 'failed' &&
t.phase !== 'refunded'
)
}