mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
feat(broadcast): wire Polkadot author_submitExtrinsic + error classification
Add the substrate broadcast handler at internal/broadcast/dot.go:
- POST {"method":"author_submitExtrinsic","params":["0x<hex>"]} against
the network's substrate RPC, return the 0x-prefixed extrinsic hash
on success.
- DOTBroadcastError classification (retryable vs fatal) by substrate
error code + message:
Invalid::Future / AlreadyImported / pool-full / runtime-upgrade
-> retryable (orchestrator polls again next tick)
Invalid::Stale / Invalid::BadProof / Module(...)
-> fatal (re-sign needed or pallet rejection)
- Default RPC endpoints: rpc.polkadot.io (mainnet),
westend-rpc.polkadot.io (testnet), kusama-rpc.polkadot.io (canary).
Wire dispatch in client.go Broadcast switch: AddressTypeDOT lands in
broadcastDOT. ErrFamilyNotImplemented now reads BTC/SOL/TON/XRP only.
Tests cover happy-path, Stale/BadProof fatal, Future retryable,
AlreadyImported retryable, Module error fatal, 4xx-with-envelope, 5xx
without envelope, empty result, and error-string formatting.
This commit is contained in:
@@ -8,7 +8,9 @@
|
||||
// - EVM (Ethereum / Sepolia / Lux / Base / Polygon / Arbitrum /
|
||||
// Optimism / Avalanche / BSC / Holesky / Zora / Blast / Linea):
|
||||
// implemented via JSON-RPC eth_sendRawTransaction.
|
||||
// - Non-EVM (BTC / SOL / TON / XRP / DOT): returns
|
||||
// - Polkadot / Substrate (POLKADOT_MAINNET / POLKADOT_TESTNET /
|
||||
// KUSAMA_MAINNET): implemented via author_submitExtrinsic. See dot.go.
|
||||
// - Non-EVM, non-DOT (BTC / SOL / TON / XRP): returns
|
||||
// ErrFamilyNotImplemented. Adding any one is a straightforward
|
||||
// follow-up — different REST/RPC contracts, same shape.
|
||||
//
|
||||
@@ -83,8 +85,9 @@ func RPCURLFor(network string) string { return rpcURLs[network] }
|
||||
var ErrUnsupportedNetwork = errors.New("broadcast: unsupported network")
|
||||
|
||||
// ErrFamilyNotImplemented — the network's address-family broadcaster
|
||||
// isn't implemented yet (BTC, SOL, TON, XRP, DOT).
|
||||
var ErrFamilyNotImplemented = errors.New("broadcast: family not implemented (only EVM today)")
|
||||
// isn't implemented yet (BTC, SOL, TON, XRP). EVM + Polkadot are
|
||||
// implemented today.
|
||||
var ErrFamilyNotImplemented = errors.New("broadcast: family not implemented")
|
||||
|
||||
// ErrEmptyRawTx — the caller passed an empty rawTxHex. Surfacing
|
||||
// this distinctly so the broadcast driver can tell "missing tx
|
||||
@@ -180,10 +183,12 @@ type BroadcastResult struct {
|
||||
//
|
||||
// Errors:
|
||||
// - ErrUnsupportedNetwork network not in the table / overrides.
|
||||
// - ErrFamilyNotImplemented BTC/SOL/TON/XRP/DOT — TODO.
|
||||
// - ErrFamilyNotImplemented BTC/SOL/TON/XRP — TODO.
|
||||
// - ErrEmptyRawTx rawTxHex == "".
|
||||
// - *RPCError upstream returned a JSON-RPC error
|
||||
// or non-2xx HTTP.
|
||||
// - *DOTBroadcastError Polkadot author_submitExtrinsic rejected
|
||||
// the extrinsic; check Retryable bit.
|
||||
// - context.Canceled/DeadlineExceeded on caller or per-call timeout.
|
||||
func (c *Client) Broadcast(ctx context.Context, network, rawTxHex string) (*BroadcastResult, error) {
|
||||
if rawTxHex == "" {
|
||||
@@ -196,11 +201,12 @@ func (c *Client) Broadcast(ctx context.Context, network, rawTxHex string) (*Broa
|
||||
|
||||
// Family dispatch. We use mchain.AddressTypeFor as the canonical
|
||||
// network→family mapping (it's already the project's source of
|
||||
// truth for which family a network belongs to). For broadcast,
|
||||
// only AddressTypeETH is implemented today.
|
||||
// truth for which family a network belongs to).
|
||||
switch mchain.AddressTypeFor(network) {
|
||||
case mchain.AddressTypeETH:
|
||||
return c.broadcastEVM(ctx, url, rawTxHex)
|
||||
case mchain.AddressTypeDOT:
|
||||
return c.broadcastDOT(ctx, url, rawTxHex)
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %s", ErrFamilyNotImplemented, network)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
// dot.go: Polkadot / Substrate broadcast handler.
|
||||
//
|
||||
// Wire shape: substrate JSON-RPC over HTTP (the WS variant is identical
|
||||
// at the JSON-RPC envelope level; we use plain HTTP POST so we don't
|
||||
// need a long-lived connection for one-shot submits).
|
||||
//
|
||||
// POST <network rpc>
|
||||
// {"jsonrpc":"2.0","id":N,"method":"author_submitExtrinsic","params":["0x<hex extrinsic>"]}
|
||||
// → {"jsonrpc":"2.0","id":N,"result":"0x<extrinsic hash>"}
|
||||
//
|
||||
// On the result branch the upstream returns the extrinsic hash (the
|
||||
// 0x-prefixed blake2_256 of the encoded extrinsic body). On error,
|
||||
// the error.message contains a structured failure code that maps to:
|
||||
//
|
||||
// "Invalid::Stale" — nonce too low; the user already submitted a
|
||||
// higher-nonce tx. Fatal — needs re-sign.
|
||||
// "Invalid::BadProof" — signature didn't verify against signer
|
||||
// pubkey. Fatal — keygen / sign-context bug.
|
||||
// "Invalid::Future" — nonce too high; will probably land on a
|
||||
// future block. Retryable — wait and retry.
|
||||
// "Module(...)" — runtime returned a structured pallet error.
|
||||
// Usually fatal (e.g. ExistentialDeposit,
|
||||
// InsufficientBalance), unless the operator
|
||||
// can fund the wallet — then retry post-fund.
|
||||
//
|
||||
// Returning a hash from this layer means "the node accepted the
|
||||
// extrinsic into its mempool" — NOT "finalized". The destination
|
||||
// confirmation loop (a separate concern) polls block inclusion.
|
||||
//
|
||||
// Brand: Lux Network surface — no Liquidity / references.
|
||||
|
||||
package broadcast
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// DOT endpoints — destination-chain RPC URLs
|
||||
// =============================================================================
|
||||
//
|
||||
// Polkadot mainnet uses wss://rpc.polkadot.io for the official feed;
|
||||
// HTTPS variants exist too and are what we use for one-shot submits
|
||||
// (no need to keep an open WS for a single author_submitExtrinsic).
|
||||
//
|
||||
// Westend is the canonical Polkadot testnet for end-to-end bridge
|
||||
// dev — Kusama is "canary" production and not appropriate for testing.
|
||||
|
||||
func init() {
|
||||
// Polkadot mainnet — official RPC, HTTPS.
|
||||
rpcURLs["POLKADOT_MAINNET"] = "https://rpc.polkadot.io"
|
||||
// Westend — canonical Polkadot testnet.
|
||||
rpcURLs["POLKADOT_TESTNET"] = "https://westend-rpc.polkadot.io"
|
||||
// Kusama — production canary; included for completeness but the
|
||||
// bridge doesn't bootstrap KSM by default.
|
||||
rpcURLs["KUSAMA_MAINNET"] = "https://kusama-rpc.polkadot.io"
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Errors
|
||||
// =============================================================================
|
||||
|
||||
// DOTBroadcastError is the DOT-specific error surface. Like
|
||||
// BTCBroadcastError, the Retryable bit drives the orchestrator's
|
||||
// retry vs. fail decision.
|
||||
//
|
||||
// Substrate error codes follow a stable taxonomy in
|
||||
// sp_runtime::transaction_validity::TransactionValidityError. We
|
||||
// classify them into the (Retryable, Fatal) buckets the orchestrator
|
||||
// needs without trying to be exhaustive — unknown errors land in the
|
||||
// fatal bucket by default rather than looping forever.
|
||||
type DOTBroadcastError struct {
|
||||
// Op is "dot_author_submitExtrinsic" for log/metric tagging.
|
||||
Op string
|
||||
// HTTPStatus is the JSON-RPC envelope's HTTP status. 0 if the
|
||||
// request never made it that far.
|
||||
HTTPStatus int
|
||||
// Code is the substrate RPC error code when known (1010 = invalid
|
||||
// tx, 1011 = pool full, 1012 = already-in-pool).
|
||||
Code int
|
||||
// Message is the chain-side error message (raw text from upstream).
|
||||
Message string
|
||||
// Retryable indicates whether the orchestrator should leave the
|
||||
// swap in broadcasting and try again next tick. Fatal errors
|
||||
// require a fresh sign (Stale, BadProof) or a config fix (Module
|
||||
// errors that aren't fundable like CannotLookup).
|
||||
Retryable bool
|
||||
}
|
||||
|
||||
func (e *DOTBroadcastError) Error() string {
|
||||
tag := "broadcast: dot"
|
||||
if e.HTTPStatus != 0 {
|
||||
tag = fmt.Sprintf("broadcast: dot HTTP %d rpc %d", e.HTTPStatus, e.Code)
|
||||
}
|
||||
if e.Retryable {
|
||||
return tag + " (retryable): " + e.Message
|
||||
}
|
||||
return tag + " (fatal): " + e.Message
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// broadcastDOT — JSON-RPC submitter
|
||||
// =============================================================================
|
||||
|
||||
// broadcastDOT submits a signed substrate extrinsic via
|
||||
// author_submitExtrinsic. rawTxHex is the 0x-prefixed wire bytes from
|
||||
// (*txassembler.DOTAssembler).Finalize().
|
||||
//
|
||||
// Returns *BroadcastResult{TxHash: <0x-prefixed extrinsic hash>} on
|
||||
// success. *DOTBroadcastError on chain-side rejections. *RPCError on
|
||||
// transport / non-2xx HTTP that doesn't carry a parseable JSON-RPC
|
||||
// envelope.
|
||||
func (c *Client) broadcastDOT(ctx context.Context, url, rawTxHex string) (*BroadcastResult, error) {
|
||||
rawTxHex = strings.TrimSpace(rawTxHex)
|
||||
if rawTxHex == "" {
|
||||
return nil, ErrEmptyRawTx
|
||||
}
|
||||
if !strings.HasPrefix(rawTxHex, "0x") && !strings.HasPrefix(rawTxHex, "0X") {
|
||||
rawTxHex = "0x" + rawTxHex
|
||||
}
|
||||
|
||||
type rpcErr struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
type rpcResp struct {
|
||||
Result string `json:"result"`
|
||||
Error *rpcErr `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": c.callSeq.Add(1),
|
||||
"method": "author_submitExtrinsic",
|
||||
"params": []string{rawTxHex},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("broadcast: dot marshal: %w", err)
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(ctx, c.timeout())
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(callCtx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, &RPCError{Method: "author_submitExtrinsic", Code: -32000, Message: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// Try to parse a JSON-RPC error envelope before falling back to
|
||||
// a generic RPCError. Some substrate gateways return 4xx with
|
||||
// a structured body.
|
||||
var parsed rpcResp
|
||||
if jerr := json.Unmarshal(respBody, &parsed); jerr == nil && parsed.Error != nil {
|
||||
return nil, classifyDOTError(resp.StatusCode, parsed.Error.Code, parsed.Error.Message)
|
||||
}
|
||||
return nil, &RPCError{
|
||||
Method: "author_submitExtrinsic",
|
||||
HTTPStatus: resp.StatusCode,
|
||||
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, truncate(respBody, 200)),
|
||||
}
|
||||
}
|
||||
|
||||
var parsed rpcResp
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return nil, &RPCError{
|
||||
Method: "author_submitExtrinsic",
|
||||
HTTPStatus: resp.StatusCode,
|
||||
Message: fmt.Sprintf("decode response: %v (body=%s)", err, truncate(respBody, 200)),
|
||||
}
|
||||
}
|
||||
|
||||
if parsed.Error != nil {
|
||||
return nil, classifyDOTError(resp.StatusCode, parsed.Error.Code, parsed.Error.Message)
|
||||
}
|
||||
|
||||
if parsed.Result == "" {
|
||||
return nil, &RPCError{
|
||||
Method: "author_submitExtrinsic",
|
||||
Code: -32603,
|
||||
Message: "empty extrinsic hash",
|
||||
}
|
||||
}
|
||||
|
||||
return &BroadcastResult{TxHash: parsed.Result}, nil
|
||||
}
|
||||
|
||||
// classifyDOTError maps a substrate RPC error code + message into a
|
||||
// (retryable / fatal) bucket. The classification is conservative: any
|
||||
// error we can't confidently identify as transient lands in fatal so
|
||||
// the orchestrator escalates rather than looping.
|
||||
//
|
||||
// Substrate error code reference:
|
||||
// - 1010: Invalid transaction (see message for sub-classification)
|
||||
// - 1011: Pool is full (retry later)
|
||||
// - 1012: Already imported / future / transaction outdated
|
||||
// - 1013: Transaction has a bad signature (BadProof)
|
||||
// - 1014: Transaction would exhaust resources
|
||||
// - 1015: Method not found / runtime upgrade in progress (retry)
|
||||
func classifyDOTError(httpStatus, code int, message string) *DOTBroadcastError {
|
||||
lower := strings.ToLower(message)
|
||||
retryable := false
|
||||
|
||||
// Code-based first — these are authoritative.
|
||||
switch code {
|
||||
case 1011: // Pool full
|
||||
retryable = true
|
||||
case 1015: // Runtime call failed — often transient during upgrade
|
||||
retryable = true
|
||||
}
|
||||
|
||||
// Message-based — more granular; substrate proxies all funnel into
|
||||
// code 1010 with the actual reason in the message.
|
||||
switch {
|
||||
case strings.Contains(lower, "future"):
|
||||
// Invalid::Future — nonce too high, will eventually land.
|
||||
retryable = true
|
||||
case strings.Contains(lower, "stale"):
|
||||
// Invalid::Stale — nonce too low. Caller must re-sign with a
|
||||
// fresher nonce; existing signed bytes can never become valid.
|
||||
retryable = false
|
||||
case strings.Contains(lower, "badproof"),
|
||||
strings.Contains(lower, "bad proof"),
|
||||
strings.Contains(lower, "bad signature"):
|
||||
// Invalid::BadProof — signature didn't verify. Either the
|
||||
// signing context was wrong (genesis hash, spec version, etc.)
|
||||
// or the wrong recovery byte was used. Re-sign required.
|
||||
retryable = false
|
||||
case strings.Contains(lower, "alreadyimported"),
|
||||
strings.Contains(lower, "already imported"),
|
||||
strings.Contains(lower, "already in pool"):
|
||||
// The node already has this exact extrinsic in its mempool.
|
||||
// Treat as success at the broadcast layer — the inclusion
|
||||
// loop will eventually see it landed.
|
||||
retryable = true
|
||||
case strings.Contains(lower, "pool"),
|
||||
strings.Contains(lower, "rate"):
|
||||
retryable = true
|
||||
case strings.Contains(lower, "module"):
|
||||
// Module(*) errors — pallet-level rejections. Examples:
|
||||
// ExistentialDeposit, InsufficientBalance, CannotLookup,
|
||||
// OutdatedNonce. Most are fatal unless the operator funds the
|
||||
// wallet; we let the orchestrator decide whether to keep
|
||||
// trying via the signing driver's gas pre-check.
|
||||
retryable = false
|
||||
}
|
||||
|
||||
return &DOTBroadcastError{
|
||||
Op: "author_submitExtrinsic",
|
||||
HTTPStatus: httpStatus,
|
||||
Code: code,
|
||||
Message: message,
|
||||
Retryable: retryable,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
// Tests for the Polkadot / Substrate broadcast handler.
|
||||
//
|
||||
// We mock the substrate JSON-RPC server with httptest. Each case
|
||||
// pins:
|
||||
// - a fixed response shape (success / error code / error message)
|
||||
// - the expected classification (success, fatal, retryable)
|
||||
// so a future change to classifyDOTError can't silently flip the
|
||||
// retry behaviour of the orchestrator.
|
||||
|
||||
package broadcast
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// dotMockServer wraps an httptest.Server with knobs to control the
|
||||
// next JSON-RPC response shape.
|
||||
type dotMockServer struct {
|
||||
server *httptest.Server
|
||||
calls int
|
||||
// On success: returned in result field.
|
||||
resultHash string
|
||||
// On error: code + message returned in error field. errCode != 0
|
||||
// turns the response into an error envelope.
|
||||
errCode int
|
||||
errMessage string
|
||||
// HTTP-level status override (defaults to 200).
|
||||
httpStatus int
|
||||
// Captured params for assertion.
|
||||
lastParams string
|
||||
}
|
||||
|
||||
func newDOTMockServer(t *testing.T) *dotMockServer {
|
||||
t.Helper()
|
||||
m := &dotMockServer{}
|
||||
m.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
m.calls++
|
||||
var req struct {
|
||||
Method string `json:"method"`
|
||||
Params []string `json:"params"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if len(req.Params) > 0 {
|
||||
m.lastParams = req.Params[0]
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if m.httpStatus != 0 {
|
||||
w.WriteHeader(m.httpStatus)
|
||||
}
|
||||
body := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
}
|
||||
if m.errCode != 0 || m.errMessage != "" {
|
||||
body["error"] = map[string]any{
|
||||
"code": m.errCode,
|
||||
"message": m.errMessage,
|
||||
}
|
||||
} else {
|
||||
body["result"] = m.resultHash
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}))
|
||||
t.Cleanup(m.server.Close)
|
||||
return m
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Happy path
|
||||
// =============================================================================
|
||||
|
||||
func TestBroadcastDOT_Confirmed(t *testing.T) {
|
||||
m := newDOTMockServer(t)
|
||||
m.resultHash = "0xdeadbeefcafef00d11223344556677889900aabbccddeeff00112233445566778"
|
||||
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
|
||||
"POLKADOT_MAINNET": m.server.URL,
|
||||
}}
|
||||
res, err := c.Broadcast(context.Background(), "POLKADOT_MAINNET", "0xabba")
|
||||
if err != nil {
|
||||
t.Fatalf("Broadcast: %v", err)
|
||||
}
|
||||
if res.TxHash != m.resultHash {
|
||||
t.Errorf("TxHash = %q, want %q", res.TxHash, m.resultHash)
|
||||
}
|
||||
if m.calls != 1 {
|
||||
t.Errorf("expected 1 RPC call, got %d", m.calls)
|
||||
}
|
||||
if !strings.HasPrefix(m.lastParams, "0x") {
|
||||
t.Errorf("expected 0x-prefixed params, got %q", m.lastParams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastDOT_AddsZeroXPrefix(t *testing.T) {
|
||||
m := newDOTMockServer(t)
|
||||
m.resultHash = "0xfeedface"
|
||||
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
|
||||
"POLKADOT_MAINNET": m.server.URL,
|
||||
}}
|
||||
// Pass without 0x.
|
||||
if _, err := c.Broadcast(context.Background(), "POLKADOT_MAINNET", "abba1234"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(m.lastParams, "0x") {
|
||||
t.Errorf("expected 0x prefix added on wire, got %q", m.lastParams)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Error cases — Invalid::Stale / BadProof = fatal
|
||||
// =============================================================================
|
||||
|
||||
func TestBroadcastDOT_StaleIsFatal(t *testing.T) {
|
||||
m := newDOTMockServer(t)
|
||||
m.errCode = 1010
|
||||
m.errMessage = "Invalid Transaction (Stale)"
|
||||
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
|
||||
"POLKADOT_MAINNET": m.server.URL,
|
||||
}}
|
||||
_, err := c.Broadcast(context.Background(), "POLKADOT_MAINNET", "0xabba")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for Stale tx")
|
||||
}
|
||||
var dotErr *DOTBroadcastError
|
||||
if !errors.As(err, &dotErr) {
|
||||
t.Fatalf("expected *DOTBroadcastError, got %T", err)
|
||||
}
|
||||
if dotErr.Retryable {
|
||||
t.Error("Stale should be fatal (non-retryable)")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(dotErr.Message), "stale") {
|
||||
t.Errorf("unexpected message: %q", dotErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastDOT_BadProofIsFatal(t *testing.T) {
|
||||
m := newDOTMockServer(t)
|
||||
m.errCode = 1010
|
||||
m.errMessage = "Invalid Transaction: BadProof"
|
||||
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
|
||||
"POLKADOT_MAINNET": m.server.URL,
|
||||
}}
|
||||
_, err := c.Broadcast(context.Background(), "POLKADOT_MAINNET", "0xabba")
|
||||
var dotErr *DOTBroadcastError
|
||||
if !errors.As(err, &dotErr) {
|
||||
t.Fatalf("expected *DOTBroadcastError, got %T", err)
|
||||
}
|
||||
if dotErr.Retryable {
|
||||
t.Error("BadProof should be fatal — sign context is wrong")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Error cases — Invalid::Future = retryable
|
||||
// =============================================================================
|
||||
|
||||
func TestBroadcastDOT_FutureIsRetryable(t *testing.T) {
|
||||
m := newDOTMockServer(t)
|
||||
m.errCode = 1010
|
||||
m.errMessage = "Invalid Transaction: Future"
|
||||
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
|
||||
"POLKADOT_MAINNET": m.server.URL,
|
||||
}}
|
||||
_, err := c.Broadcast(context.Background(), "POLKADOT_MAINNET", "0xabba")
|
||||
var dotErr *DOTBroadcastError
|
||||
if !errors.As(err, &dotErr) {
|
||||
t.Fatalf("expected *DOTBroadcastError, got %T", err)
|
||||
}
|
||||
if !dotErr.Retryable {
|
||||
t.Error("Future nonce should be retryable — will land eventually")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastDOT_AlreadyInPoolIsRetryable(t *testing.T) {
|
||||
m := newDOTMockServer(t)
|
||||
m.errCode = 1012
|
||||
m.errMessage = "Transaction Already Imported"
|
||||
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
|
||||
"POLKADOT_MAINNET": m.server.URL,
|
||||
}}
|
||||
_, err := c.Broadcast(context.Background(), "POLKADOT_MAINNET", "0xabba")
|
||||
var dotErr *DOTBroadcastError
|
||||
if !errors.As(err, &dotErr) {
|
||||
t.Fatalf("expected *DOTBroadcastError, got %T", err)
|
||||
}
|
||||
if !dotErr.Retryable {
|
||||
t.Error("AlreadyImported should be retryable — node already has it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastDOT_PoolFullIsRetryable(t *testing.T) {
|
||||
m := newDOTMockServer(t)
|
||||
m.errCode = 1011
|
||||
m.errMessage = "Transaction Pool is Full"
|
||||
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
|
||||
"POLKADOT_MAINNET": m.server.URL,
|
||||
}}
|
||||
_, err := c.Broadcast(context.Background(), "POLKADOT_MAINNET", "0xabba")
|
||||
var dotErr *DOTBroadcastError
|
||||
if !errors.As(err, &dotErr) {
|
||||
t.Fatalf("expected *DOTBroadcastError, got %T", err)
|
||||
}
|
||||
if !dotErr.Retryable {
|
||||
t.Error("Pool full should be retryable")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Error cases — Module errors = fatal (usually pallet rejections)
|
||||
// =============================================================================
|
||||
|
||||
func TestBroadcastDOT_ModuleErrorIsFatal(t *testing.T) {
|
||||
m := newDOTMockServer(t)
|
||||
m.errCode = 1010
|
||||
m.errMessage = "Module Error: balances.InsufficientBalance"
|
||||
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
|
||||
"POLKADOT_MAINNET": m.server.URL,
|
||||
}}
|
||||
_, err := c.Broadcast(context.Background(), "POLKADOT_MAINNET", "0xabba")
|
||||
var dotErr *DOTBroadcastError
|
||||
if !errors.As(err, &dotErr) {
|
||||
t.Fatalf("expected *DOTBroadcastError, got %T", err)
|
||||
}
|
||||
if dotErr.Retryable {
|
||||
t.Error("Module errors are fatal (pallet rejection)")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Error envelopes inside 4xx HTTP — exercised by some substrate proxies
|
||||
// =============================================================================
|
||||
|
||||
func TestBroadcastDOT_4xxWithEnvelope(t *testing.T) {
|
||||
m := newDOTMockServer(t)
|
||||
m.httpStatus = 400
|
||||
m.errCode = 1010
|
||||
m.errMessage = "Invalid Transaction (Stale)"
|
||||
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
|
||||
"POLKADOT_MAINNET": m.server.URL,
|
||||
}}
|
||||
_, err := c.Broadcast(context.Background(), "POLKADOT_MAINNET", "0xabba")
|
||||
var dotErr *DOTBroadcastError
|
||||
if !errors.As(err, &dotErr) {
|
||||
t.Fatalf("expected *DOTBroadcastError, got %T", err)
|
||||
}
|
||||
if dotErr.HTTPStatus != 400 {
|
||||
t.Errorf("HTTPStatus = %d, want 400", dotErr.HTTPStatus)
|
||||
}
|
||||
if dotErr.Retryable {
|
||||
t.Error("Stale-via-4xx should still be fatal")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HTTP 5xx without parseable envelope falls through to RPCError
|
||||
// =============================================================================
|
||||
|
||||
func TestBroadcastDOT_5xxNoEnvelope(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "upstream gateway down", http.StatusBadGateway)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
|
||||
"POLKADOT_MAINNET": srv.URL,
|
||||
}}
|
||||
_, err := c.Broadcast(context.Background(), "POLKADOT_MAINNET", "0xabba")
|
||||
var rpcErr *RPCError
|
||||
if !errors.As(err, &rpcErr) {
|
||||
t.Fatalf("expected *RPCError fallback, got %T: %v", err, err)
|
||||
}
|
||||
if rpcErr.HTTPStatus != http.StatusBadGateway {
|
||||
t.Errorf("HTTPStatus = %d, want %d", rpcErr.HTTPStatus, http.StatusBadGateway)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DOT RPC URL table
|
||||
// =============================================================================
|
||||
|
||||
func TestBroadcastDOT_RPCURLFor(t *testing.T) {
|
||||
if RPCURLFor("POLKADOT_MAINNET") == "" {
|
||||
t.Error("POLKADOT_MAINNET should be in the table")
|
||||
}
|
||||
if RPCURLFor("POLKADOT_TESTNET") == "" {
|
||||
t.Error("POLKADOT_TESTNET should be in the table")
|
||||
}
|
||||
if RPCURLFor("KUSAMA_MAINNET") == "" {
|
||||
t.Error("KUSAMA_MAINNET should be in the table")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Empty response shape
|
||||
// =============================================================================
|
||||
|
||||
func TestBroadcastDOT_EmptyResult(t *testing.T) {
|
||||
m := newDOTMockServer(t)
|
||||
// Leave resultHash and errMessage both empty → empty result envelope.
|
||||
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
|
||||
"POLKADOT_MAINNET": m.server.URL,
|
||||
}}
|
||||
_, err := c.Broadcast(context.Background(), "POLKADOT_MAINNET", "0xabba")
|
||||
var rpcErr *RPCError
|
||||
if !errors.As(err, &rpcErr) {
|
||||
t.Fatalf("expected *RPCError on empty result, got %T", err)
|
||||
}
|
||||
if !strings.Contains(rpcErr.Message, "empty") {
|
||||
t.Errorf("expected 'empty' in message, got %q", rpcErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DOTBroadcastError formatting
|
||||
// =============================================================================
|
||||
|
||||
func TestDOTBroadcastError_String(t *testing.T) {
|
||||
cases := []struct {
|
||||
err *DOTBroadcastError
|
||||
want string
|
||||
}{
|
||||
{
|
||||
&DOTBroadcastError{Op: "x", HTTPStatus: 200, Code: 1010, Message: "Stale", Retryable: false},
|
||||
"broadcast: dot HTTP 200 rpc 1010 (fatal): Stale",
|
||||
},
|
||||
{
|
||||
&DOTBroadcastError{Op: "x", HTTPStatus: 0, Code: 1010, Message: "Future", Retryable: true},
|
||||
"broadcast: dot (retryable): Future",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := tc.err.Error(); got != tc.want {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user