Files
zeekay 2dd8da86db feat(xrp): native inbound deposit detection + wire XRP into mainnet/testnet config
XRP was the one of {BTC,SOL,TON,XRP,ETH} with a genuine in-repo gap: outbound
(release pool + assembler + rippled submit) was complete, but inbound deposit
detection was a stub (ErrUnsupportedNetwork) and XRP was absent from all config.

- depositcheck.checkXRP: rippled account_info against the last *validated* ledger;
  balance read as decimal drops (1 XRP = 1e6 drops), compared via the shared
  compareBalance(decimals=6). An unfunded account (actNotFound) maps to the
  (false,nil) 'no deposit yet' contract, not an error. strict:true so account is
  read as an address. Same canonical rippled endpoints as the outbound broadcaster.
- networks.{mainnet,testnet}.yaml: XRP_{MAINNET,TESTNET} network + XRP token,
  deposit+withdrawal enabled. XRP settles on secp256k1 (same curve as the live
  ETH/BTC pipeline) so it has no mpcd FROST/Ed25519 gap like SOL/TON; r-address
  derives from the ecdsa_pub_key mpcd already returns. Empty release wallets fail
  payouts safe until an operator funds them (>=10 XRP reserve + fee).
- 7 new XRP deposit tests (funded/boundary/insufficient/actNotFound x2/rpc-error/
  malformed); retire the obsolete TestCheck_XRP_NotImplemented. depositcheck 36/36.
2026-06-26 08:44:12 -07:00

754 lines
24 KiB
Go

// Tests for the deposit-check client. Each chain family gets a
// dedicated httptest.Server mock that matches the upstream's wire
// format (eth_getBalance, Blockstream `/address/{addr}`, Solana
// `getBalance`, TON Center `getAddressBalance`). The package's
// RPCURLOverrides hook lets us point the Client at the mocks without
// touching the package-level RPC_URLs map.
package depositcheck
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/luxfi/bridge/internal/tokens"
)
// =============================================================================
// EVM
// =============================================================================
// ethBalanceServer returns the given balance (in wei) as a 0x-prefixed
// hex string from any eth_getBalance call.
func ethBalanceServer(t *testing.T, balanceWei string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
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": balanceWei,
})
}))
t.Cleanup(srv.Close)
return srv
}
func TestCheck_EVM_Confirmed(t *testing.T) {
// 0xDE0B6B3A7640000 = 10^18 = 1.0 ETH
srv := ethBalanceServer(t, "0xDE0B6B3A7640000")
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
"ETHEREUM_SEPOLIA": srv.URL,
}}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "ETHEREUM_SEPOLIA",
Address: "0xabc",
Asset: "ETH",
RequiredAmount: 0.5,
})
if err != nil {
t.Fatalf("err: %v", err)
}
if !ok {
t.Fatal("expected confirmed (1 ETH >= 0.5 required)")
}
}
func TestCheck_EVM_Insufficient(t *testing.T) {
// 0x2386F26FC10000 = 10^16 = 0.01 ETH
srv := ethBalanceServer(t, "0x2386F26FC10000")
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
"ETHEREUM_SEPOLIA": srv.URL,
}}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "ETHEREUM_SEPOLIA",
Address: "0xabc",
Asset: "ETH",
RequiredAmount: 0.5,
})
if err != nil {
t.Fatalf("err: %v", err)
}
if ok {
t.Fatal("expected NOT confirmed (0.01 ETH < 0.5 required)")
}
}
func TestCheck_EVM_ZeroBalance(t *testing.T) {
srv := ethBalanceServer(t, "0x0")
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
"ETHEREUM_SEPOLIA": srv.URL,
}}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "ETHEREUM_SEPOLIA",
RequiredAmount: 0.01,
})
if err != nil || ok {
t.Fatalf("expected (false, nil) for 0 wei; got (%v, %v)", ok, err)
}
}
func TestCheck_EVM_RPCErrorEnvelope(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0", "id": 1,
"error": map[string]any{"code": -32000, "message": "node overloaded"},
})
}))
t.Cleanup(srv.Close)
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
"ETHEREUM_SEPOLIA": srv.URL,
}}
_, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "ETHEREUM_SEPOLIA",
RequiredAmount: 0.01,
})
if err == nil || !strings.Contains(err.Error(), "node overloaded") {
t.Fatalf("expected upstream error to surface, got %v", err)
}
}
func TestCheck_EVM_HTTPNon200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "rate limited", http.StatusTooManyRequests)
}))
t.Cleanup(srv.Close)
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
"ETHEREUM_SEPOLIA": srv.URL,
}}
_, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "ETHEREUM_SEPOLIA",
RequiredAmount: 0.01,
})
if err == nil || !strings.Contains(err.Error(), "HTTP 429") {
t.Fatalf("expected HTTP 429 surface, got %v", err)
}
}
func TestCheck_EVM_LargeBalancePrecision(t *testing.T) {
// 10 ETH = 10 * 1e18 wei = 0x8AC7230489E80000
srv := ethBalanceServer(t, "0x8AC7230489E80000")
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
"LUX_TESTNET": srv.URL,
}}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "LUX_TESTNET",
RequiredAmount: 9.99,
})
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("expected confirmed: 10 ETH >= 9.99")
}
}
// =============================================================================
// EVM — ERC-20 (eth_call balanceOf)
// =============================================================================
// erc20BalanceServer returns the configured balance from any
// eth_call. It records the call params so we can verify the data
// payload was assembled correctly.
type erc20BalanceServer struct {
server *httptest.Server
balance string // 0x-prefixed hex of the 32-byte balance
lastData string
lastTo string
calls int
}
func newERC20BalanceServer(t *testing.T, balance string) *erc20BalanceServer {
t.Helper()
s := &erc20BalanceServer{balance: balance}
s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.calls++
body, _ := io.ReadAll(r.Body)
var req struct {
Method string `json:"method"`
Params []any `json:"params"`
}
_ = json.Unmarshal(body, &req)
if req.Method != "eth_call" {
t.Errorf("expected eth_call, got %q", req.Method)
}
if len(req.Params) > 0 {
if obj, ok := req.Params[0].(map[string]any); ok {
s.lastTo, _ = obj["to"].(string)
s.lastData, _ = obj["data"].(string)
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0", "id": 1, "result": s.balance,
})
}))
t.Cleanup(s.server.Close)
return s
}
func TestCheck_EVM_ERC20_Confirmed(t *testing.T) {
// 100 USDC = 100 * 10^6 = 100,000,000 base units = 0x5F5E100.
// 32-byte word: pad to 64 hex chars: 000…000005F5E100
balance := "0x" + strings.Repeat("0", 56) + "05f5e100"
srv := newERC20BalanceServer(t, balance)
reg := tokens.NewRegistry()
usdcContract := "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"
_ = reg.Register(tokens.Info{
Network: "ETHEREUM_SEPOLIA",
Asset: "USDC",
Contract: usdcContract,
Decimals: 6,
})
c := &Client{
Timeout: time.Second,
RPCURLOverrides: map[string]string{"ETHEREUM_SEPOLIA": srv.server.URL},
Tokens: reg,
}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "ETHEREUM_SEPOLIA",
Address: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
Asset: "USDC",
RequiredAmount: 50,
})
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("expected confirmed (100 USDC ≥ 50 required)")
}
// Verify the wire: `to` is the USDC contract, NOT the holder.
if !strings.EqualFold(srv.lastTo, usdcContract) {
t.Errorf("to = %q, want USDC contract %q", srv.lastTo, usdcContract)
}
// Data must start with the balanceOf selector (0x70a08231).
if !strings.HasPrefix(strings.ToLower(srv.lastData), "0x70a08231") {
t.Errorf("data should start with balanceOf selector 0x70a08231, got %s", srv.lastData)
}
// Padded holder address: 0x70a08231 + 12 zero bytes + holder = 68 chars hex + 2 prefix.
if len(srv.lastData) != 2+(4+32)*2 {
t.Errorf("data length wrong: %d", len(srv.lastData))
}
// Last 40 hex chars should be the holder address (lowercase).
wantHolder := strings.ToLower("a28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473")
if !strings.HasSuffix(strings.ToLower(srv.lastData), wantHolder) {
t.Errorf("holder mismatch in calldata: got %s, want suffix %s", srv.lastData, wantHolder)
}
}
func TestCheck_EVM_ERC20_Insufficient(t *testing.T) {
// 25 USDC = 25 * 10^6 = 25,000,000 base units
balance := "0x" + strings.Repeat("0", 56) + "017d7840"
srv := newERC20BalanceServer(t, balance)
reg := tokens.NewRegistry()
_ = reg.Register(tokens.Info{Network: "ETHEREUM_SEPOLIA", Asset: "USDC", Contract: "0x1c7D", Decimals: 6})
c := &Client{
Timeout: time.Second,
RPCURLOverrides: map[string]string{"ETHEREUM_SEPOLIA": srv.server.URL},
Tokens: reg,
}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "ETHEREUM_SEPOLIA",
Address: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
Asset: "USDC",
RequiredAmount: 100,
})
if err != nil || ok {
t.Fatalf("expected (false, nil) for 25 USDC < 100 required; got (%v, %v)", ok, err)
}
}
func TestCheck_EVM_ERC20_RPCError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0", "id": 1,
"error": map[string]any{"code": -32000, "message": "execution reverted"},
})
}))
t.Cleanup(srv.Close)
reg := tokens.NewRegistry()
_ = reg.Register(tokens.Info{Network: "ETHEREUM_SEPOLIA", Asset: "USDC", Contract: "0x1c7D", Decimals: 6})
c := &Client{
Timeout: time.Second,
RPCURLOverrides: map[string]string{"ETHEREUM_SEPOLIA": srv.URL},
Tokens: reg,
}
_, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "ETHEREUM_SEPOLIA",
Address: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
Asset: "USDC",
RequiredAmount: 1,
})
if err == nil || !strings.Contains(err.Error(), "execution reverted") {
t.Errorf("expected upstream error to surface, got %v", err)
}
}
func TestCheck_EVM_NativeWithRegistry_UsesAssetDecimals(t *testing.T) {
// Native ETH with the registry attached: should STILL use
// eth_getBalance (Contract is empty in the registry), and the
// asset's Decimals=18 means standard wei → ether scaling.
srv := ethBalanceServer(t, "0xDE0B6B3A7640000") // 1 ETH
reg := tokens.NewRegistry()
_ = reg.Register(tokens.Info{Network: "ETHEREUM_SEPOLIA", Asset: "ETH", Decimals: 18})
c := &Client{
Timeout: time.Second,
RPCURLOverrides: map[string]string{"ETHEREUM_SEPOLIA": srv.URL},
Tokens: reg,
}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "ETHEREUM_SEPOLIA",
Address: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
Asset: "ETH",
RequiredAmount: 0.5,
})
if err != nil || !ok {
t.Fatalf("expected confirmed; got (%v, %v)", ok, err)
}
}
func TestCheck_EVM_UnknownAsset_FallsBackToNative(t *testing.T) {
// Registry has no entry for "WEIRDO" → falls back to native path
// with 18 decimals (backward compat).
srv := ethBalanceServer(t, "0xDE0B6B3A7640000") // 1 ETH
reg := tokens.NewRegistry() // empty
c := &Client{
Timeout: time.Second,
RPCURLOverrides: map[string]string{"ETHEREUM_SEPOLIA": srv.URL},
Tokens: reg,
}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "ETHEREUM_SEPOLIA",
Address: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
Asset: "WEIRDO", // not in registry
RequiredAmount: 0.5,
})
if err != nil || !ok {
t.Fatalf("unknown asset should fall back to native path; got (%v, %v)", ok, err)
}
}
func TestCheck_EVM_NoRegistry_BackwardCompat(t *testing.T) {
// Client.Tokens == nil → all EVM checks use the legacy native path.
// This proves callers who haven't migrated still get the old behavior.
srv := ethBalanceServer(t, "0xDE0B6B3A7640000")
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{"ETHEREUM_SEPOLIA": srv.URL}}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "ETHEREUM_SEPOLIA",
Asset: "USDC", // would be ERC-20 with registry
RequiredAmount: 0.5,
})
if err != nil || !ok {
t.Fatalf("no-registry path should use native; got (%v, %v)", ok, err)
}
}
func TestCompareBalance(t *testing.T) {
cases := []struct {
name string
bal int64
decimals int
req float64
want bool
}{
{"USDC 100 ≥ 50", 100_000_000, 6, 50, true},
{"USDC 25 < 50", 25_000_000, 6, 50, false},
{"1 ETH ≥ 0.5", 1_000_000_000_000_000_000, 18, 0.5, true},
{"1 ETH ≥ 1.0", 1_000_000_000_000_000_000, 18, 1.0, true},
{"0.1 ETH < 0.5", 100_000_000_000_000_000, 18, 0.5, false},
{"required 0", 1, 18, 0, true},
{"zero balance", 0, 18, 0.001, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := compareBalance(intBig(tc.bal), tc.decimals, tc.req)
if got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}
func intBig(n int64) *big.Int { return big.NewInt(n) }
// =============================================================================
// Bitcoin
// =============================================================================
func TestCheck_BTC_Confirmed(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/address/") {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"chain_stats": map[string]any{
"funded_txo_sum": 200_000, // 0.002 BTC
"spent_txo_sum": 50_000, // 0.0005 BTC spent
},
})
// Net: 150_000 sats = 0.0015 BTC
}))
t.Cleanup(srv.Close)
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
"BITCOIN_TESTNET": srv.URL,
}}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "BITCOIN_TESTNET",
Address: "tb1qabc",
Asset: "BTC",
RequiredAmount: 0.001,
})
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("expected confirmed: 0.0015 BTC >= 0.001")
}
}
func TestCheck_BTC_Insufficient(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"chain_stats": map[string]any{
"funded_txo_sum": 50_000,
"spent_txo_sum": 0,
},
})
}))
t.Cleanup(srv.Close)
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
"BITCOIN_TESTNET": srv.URL,
}}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "BITCOIN_TESTNET",
RequiredAmount: 0.001,
})
if err != nil || ok {
t.Fatalf("expected (false, nil); got (%v, %v)", ok, err)
}
}
func TestCheck_BTC_FullySpent_NetZero(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"chain_stats": map[string]any{
"funded_txo_sum": 100_000,
"spent_txo_sum": 100_000,
},
})
}))
t.Cleanup(srv.Close)
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
"BITCOIN_TESTNET": srv.URL,
}}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "BITCOIN_TESTNET",
RequiredAmount: 0.0001,
})
if err != nil || ok {
t.Fatalf("expected (false, nil) net-zero; got (%v, %v)", ok, err)
}
}
// =============================================================================
// Solana
// =============================================================================
func TestCheck_SOL_Confirmed(t *testing.T) {
// 1_500_000_000 lamports = 1.5 SOL
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0", "id": 1,
"result": map[string]any{"value": 1_500_000_000, "context": map[string]any{"slot": 1}},
})
}))
t.Cleanup(srv.Close)
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
"SOLANA_DEVNET": srv.URL,
}}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "SOLANA_DEVNET",
Address: "SoLaNa",
Asset: "SOL",
RequiredAmount: 1.0,
})
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("expected confirmed: 1.5 SOL >= 1.0")
}
}
func TestCheck_SOL_Insufficient(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0", "id": 1,
"result": map[string]any{"value": 100_000_000},
})
}))
t.Cleanup(srv.Close)
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
"SOLANA_DEVNET": srv.URL,
}}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "SOLANA_DEVNET",
RequiredAmount: 1.0,
})
if err != nil || ok {
t.Fatalf("expected (false, nil), got (%v, %v)", ok, err)
}
}
func TestCheck_SOL_RPCError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0", "id": 1,
"error": map[string]any{"code": -32602, "message": "invalid address"},
})
}))
t.Cleanup(srv.Close)
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
"SOLANA_DEVNET": srv.URL,
}}
_, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "SOLANA_DEVNET",
RequiredAmount: 1.0,
})
if err == nil || !strings.Contains(err.Error(), "invalid address") {
t.Fatalf("expected upstream error to surface, got %v", err)
}
}
// =============================================================================
// TON
// =============================================================================
func TestCheck_TON_Confirmed(t *testing.T) {
// 5_000_000_000 nanoTON = 5 TON
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/getAddressBalance") {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"result": "5000000000", // TON Center returns string
})
}))
t.Cleanup(srv.Close)
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
"TON_TESTNET": srv.URL,
}}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "TON_TESTNET",
Address: "tonAddr",
Asset: "TON",
RequiredAmount: 4.5,
})
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("expected confirmed: 5 TON >= 4.5")
}
}
func TestCheck_TON_NumericResult(t *testing.T) {
// Some shims/forks return number instead of string — make sure we
// don't blow up on the loosely-typed shape.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"result": 5_000_000_000.0,
})
}))
t.Cleanup(srv.Close)
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
"TON_TESTNET": srv.URL,
}}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "TON_TESTNET",
RequiredAmount: 1,
})
if err != nil || !ok {
t.Fatalf("expected confirmed, got (%v, %v)", ok, err)
}
}
func TestCheck_TON_BadResultType(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"result": map[string]any{"weird": "shape"},
})
}))
t.Cleanup(srv.Close)
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
"TON_TESTNET": srv.URL,
}}
_, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "TON_TESTNET",
RequiredAmount: 1,
})
if err == nil || !strings.Contains(err.Error(), "unexpected TON balance type") {
t.Fatalf("expected unexpected-type error, got %v", err)
}
}
// =============================================================================
// Unsupported
// =============================================================================
func TestCheck_UnsupportedNetwork(t *testing.T) {
c := &Client{Timeout: time.Second}
_, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "MARS_MAINNET",
RequiredAmount: 1,
})
if !errors.Is(err, ErrUnsupportedNetwork) {
t.Fatalf("expected ErrUnsupportedNetwork, got %v", err)
}
}
func TestCheck_Substrate_NotImplemented(t *testing.T) {
c := &Client{Timeout: time.Second}
_, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "POLKADOT_MAINNET",
RequiredAmount: 1,
})
if !errors.Is(err, ErrSubstrateNotImplemented) {
t.Fatalf("expected ErrSubstrateNotImplemented, got %v", err)
}
}
// XRP deposit detection (rippled account_info) is now implemented; its
// tests live in xrp_test.go. The former "not implemented" assertion was
// retired when checkXRP landed.
// =============================================================================
// Context + timeout
// =============================================================================
func TestCheck_ContextCanceled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-time.After(2 * time.Second):
}
}))
t.Cleanup(srv.Close)
c := &Client{Timeout: 5 * time.Second, RPCURLOverrides: map[string]string{
"ETHEREUM_SEPOLIA": srv.URL,
}}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := c.Check(ctx, CheckParams{
NetworkInternalName: "ETHEREUM_SEPOLIA",
RequiredAmount: 1,
})
if err == nil {
t.Fatal("expected cancellation error, got nil")
}
}
func TestCheck_TimeoutFires(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-time.After(2 * time.Second):
}
}))
t.Cleanup(srv.Close)
c := &Client{Timeout: 200 * time.Millisecond, RPCURLOverrides: map[string]string{
"BITCOIN_TESTNET": srv.URL,
}}
start := time.Now()
_, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "BITCOIN_TESTNET",
RequiredAmount: 1,
})
if err == nil || time.Since(start) > time.Second {
t.Fatalf("expected fast timeout, err=%v dur=%v", err, time.Since(start))
}
}
// =============================================================================
// RPC URL lookup
// =============================================================================
func TestRPCURLFor(t *testing.T) {
if got := RPCURLFor("ETHEREUM_SEPOLIA"); !strings.Contains(got, "sepolia") {
t.Errorf("ETHEREUM_SEPOLIA → %q, want a sepolia URL", got)
}
if got := RPCURLFor("BITCOIN_MAINNET"); !strings.Contains(got, "blockstream") {
t.Errorf("BITCOIN_MAINNET → %q, want blockstream", got)
}
if got := RPCURLFor("UNKNOWN_NETWORK"); got != "" {
t.Errorf("UNKNOWN_NETWORK → %q, want empty", got)
}
}
func TestRPCURLOverride_TakesPrecedence(t *testing.T) {
// Override should beat the package-level default.
srv := ethBalanceServer(t, "0x0")
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{
"ETHEREUM_MAINNET": srv.URL,
}}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "ETHEREUM_MAINNET",
RequiredAmount: 0.01,
})
if err != nil || ok {
t.Fatalf("expected override to route to mock; ok=%v err=%v", ok, err)
}
}
// Smoke test for the truncate helper (used in error messages).
func TestTruncate(t *testing.T) {
short := truncate([]byte("hi"), 10)
if short != "hi" {
t.Errorf("short = %q", short)
}
long := truncate([]byte("abcdefghijklmnop"), 5)
if !strings.HasPrefix(long, "abcde") || !strings.HasSuffix(long, "…") {
t.Errorf("long = %q", long)
}
}
// Reference assertion so the unused-import linter doesn't trip if
// fmt only appears via error formatting (kept for parity with client.go).
var _ = fmt.Sprintf