mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
feat: refunding has been working
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user