Update project structure and build process

This commit is contained in:
Juan Pablo Civile
2025-05-13 11:10:08 -03:00
parent 124e9fa1bc
commit d9f3e925a4
277 changed files with 15321 additions and 930 deletions

View File

@@ -0,0 +1,540 @@
package electrum
import (
"bufio"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"sort"
"strings"
"time"
"github.com/muun/recovery/utils"
)
const defaultLoggerTag = "Electrum/?"
const connectionTimeout = time.Second * 30
const callTimeout = time.Second * 30
const messageDelim = byte('\n')
const noTimeout = 0
var implsWithBatching = []string{"ElectrumX"}
// Client is a TLS client that implements a subset of the Electrum protocol.
//
// It includes a minimal implementation of a JSON-RPC client, since the one provided by the
// standard library doesn't support features such as batching.
//
// It is absolutely not thread-safe. Every Client should have a single owner.
type Client struct {
Server string
ServerImpl string
ProtoVersion string
nextRequestID int
conn net.Conn
log *utils.Logger
requireTls bool
}
// Request models the structure of all Electrum protocol requests.
type Request struct {
ID int `json:"id"`
Method string `json:"method"`
Params []Param `json:"params"`
}
// ErrorResponse models the structure of a generic error response.
type ErrorResponse struct {
ID int `json:"id"`
Error interface{} `json:"error"` // type varies among Electrum implementations.
}
// ServerVersionResponse models the structure of a `server.version` response.
type ServerVersionResponse struct {
ID int `json:"id"`
Result []string `json:"result"`
}
// ServerFeaturesResponse models the structure of a `server.features` response.
type ServerFeaturesResponse struct {
ID int `json:"id"`
Result ServerFeatures `json:"result"`
}
// ServerPeersResponse models the structure (or lack thereof) of a `server.peers.subscribe` response
type ServerPeersResponse struct {
ID int `json:"id"`
Result []interface{} `json:"result"`
}
// ListUnspentResponse models a `blockchain.scripthash.listunspent` response.
type ListUnspentResponse struct {
ID int `json:"id"`
Result []UnspentRef `json:"result"`
}
// GetTransactionResponse models the structure of a `blockchain.transaction.get` response.
type GetTransactionResponse struct {
ID int `json:"id"`
Result string `json:"result"`
}
// BroadcastResponse models the structure of a `blockchain.transaction.broadcast` response.
type BroadcastResponse struct {
ID int `json:"id"`
Result string `json:"result"`
}
// UnspentRef models an item in the `ListUnspentResponse` results.
type UnspentRef struct {
TxHash string `json:"tx_hash"`
TxPos int `json:"tx_pos"`
Value int64 `json:"value"`
Height int `json:"height"`
}
// ServerFeatures contains the relevant information from `ServerFeatures` results.
type ServerFeatures struct {
ID int `json:"id"`
GenesisHash string `json:"genesis_hash"`
HashFunction string `json:"hash_function"`
ServerVersion string `json:"server_version"`
ProcotolMin string `json:"protocol_min"`
ProtocolMax string `json:"protocol_max"`
Pruning int `json:"pruning"`
}
// Param is a convenience type that models an item in the `Params` array of an Request.
type Param = interface{}
// NewClient creates an initialized Client instance.
func NewClient(requireTls bool) *Client {
return &Client{
log: utils.NewLogger(defaultLoggerTag),
requireTls: requireTls,
}
}
// Connect establishes a TLS connection to an Electrum server.
func (c *Client) Connect(server string) error {
c.Disconnect()
c.log.SetTag("Electrum/" + server)
c.Server = server
c.log.Printf("Connecting")
err := c.establishConnection()
if err != nil {
c.Disconnect()
return c.log.Errorf("Connect failed: %w", err)
}
// Before calling it a day send a test request (trust me), and as we do identify the server:
err = c.identifyServer()
if err != nil {
c.Disconnect()
return c.log.Errorf("Identifying server failed: %w", err)
}
c.log.Printf("Identified as %s (%s)", c.ServerImpl, c.ProtoVersion)
return nil
}
// Disconnect cuts the connection (if connected) to the Electrum server.
func (c *Client) Disconnect() error {
if c.conn == nil {
return nil
}
c.log.Printf("Disconnecting")
err := c.conn.Close()
if err != nil {
return c.log.Errorf("Disconnect failed: %w", err)
}
c.conn = nil
return nil
}
// SupportsBatching returns whether this client can process batch requests.
func (c *Client) SupportsBatching() bool {
for _, implName := range implsWithBatching {
if strings.HasPrefix(c.ServerImpl, implName) {
return true
}
}
return false
}
// ServerVersion calls the `server.version` method and returns the [impl, protocol version] tuple.
func (c *Client) ServerVersion() ([]string, error) {
request := Request{
Method: "server.version",
Params: []Param{},
}
var response ServerVersionResponse
err := c.call(&request, &response, callTimeout)
if err != nil {
return nil, c.log.Errorf("ServerVersion failed: %w", err)
}
return response.Result, nil
}
// ServerFeatures calls the `server.features` method and returns the relevant part of the result.
func (c *Client) ServerFeatures() (*ServerFeatures, error) {
request := Request{
Method: "server.features",
Params: []Param{},
}
var response ServerFeaturesResponse
err := c.call(&request, &response, callTimeout)
if err != nil {
return nil, c.log.Errorf("ServerFeatures failed: %w", err)
}
return &response.Result, nil
}
// ServerPeers calls the `server.peers.subscribe` method and returns a list of server addresses.
func (c *Client) ServerPeers() ([]string, error) {
res, err := c.rawServerPeers()
if err != nil {
return nil, err // note that, besides I/O errors, some servers close the socket on this request
}
var peers []string
for _, entry := range res {
// Get ready for some hot casting action. Not for the faint of heart.
addr := entry.([]interface{})[1].(string)
port := entry.([]interface{})[2].([]interface{})[1].(string)[1:]
peers = append(peers, addr+":"+port)
}
return peers, nil
}
// rawServerPeers calls the `server.peers.subscribe` method and returns this monstrosity:
//
// [ "<ip>", "<domain>", ["<version>", "s<SSL port>", "t<TLS port>"] ]
//
// Ports can be in any order, or absent if the protocol is not supported
func (c *Client) rawServerPeers() ([]interface{}, error) {
request := Request{
Method: "server.peers.subscribe",
Params: []Param{},
}
var response ServerPeersResponse
err := c.call(&request, &response, callTimeout)
if err != nil {
return nil, c.log.Errorf("rawServerPeers failed: %w", err)
}
return response.Result, nil
}
// Broadcast calls the `blockchain.transaction.broadcast` endpoint and returns the transaction hash.
func (c *Client) Broadcast(rawTx string) (string, error) {
request := Request{
Method: "blockchain.transaction.broadcast",
Params: []Param{rawTx},
}
var response BroadcastResponse
err := c.call(&request, &response, callTimeout)
if err != nil {
return "", c.log.Errorf("Broadcast failed: %w", err)
}
return response.Result, nil
}
// GetTransaction calls the `blockchain.transaction.get` endpoint and returns the transaction hex.
func (c *Client) GetTransaction(txID string) (string, error) {
request := Request{
Method: "blockchain.transaction.get",
Params: []Param{txID},
}
var response GetTransactionResponse
err := c.call(&request, &response, callTimeout)
if err != nil {
return "", c.log.Errorf("GetTransaction failed: %w", err)
}
return response.Result, nil
}
// ListUnspent calls `blockchain.scripthash.listunspent` and returns the UTXO results.
func (c *Client) ListUnspent(indexHash string) ([]UnspentRef, error) {
request := Request{
Method: "blockchain.scripthash.listunspent",
Params: []Param{indexHash},
}
var response ListUnspentResponse
err := c.call(&request, &response, callTimeout)
if err != nil {
return nil, c.log.Errorf("ListUnspent failed: %w", err)
}
return response.Result, nil
}
// ListUnspentBatch is like `ListUnspent`, but using batching.
func (c *Client) ListUnspentBatch(indexHashes []string) ([][]UnspentRef, error) {
requests := make([]*Request, len(indexHashes))
method := "blockchain.scripthash.listunspent"
for i, indexHash := range indexHashes {
requests[i] = &Request{
Method: method,
Params: []Param{indexHash},
}
}
var responses []ListUnspentResponse
// Give it a little more time than non-batch calls
timeout := callTimeout * 2
err := c.callBatch(method, requests, &responses, timeout)
if err != nil {
return nil, fmt.Errorf("ListUnspentBatch failed: %w", err)
}
// Don't forget to sort responses:
sort.Slice(responses, func(i, j int) bool {
return responses[i].ID < responses[j].ID
})
// Now we can collect all results:
var unspentRefs [][]UnspentRef
for _, response := range responses {
unspentRefs = append(unspentRefs, response.Result)
}
return unspentRefs, nil
}
func (c *Client) establishConnection() error {
// We first try to connect over TCP+TLS
// If we fail and requireTls is false, we try over TCP
// TODO: check if insecure is necessary
config := &tls.Config{
InsecureSkipVerify: true,
}
dialer := &net.Dialer{
Timeout: connectionTimeout,
}
tlsConn, err := tls.DialWithDialer(dialer, "tcp", c.Server, config)
if err == nil {
c.conn = tlsConn
return nil
}
if c.requireTls {
return err
}
conn, err := net.DialTimeout("tcp", c.Server, connectionTimeout)
if err != nil {
return err
}
c.conn = conn
return nil
}
func (c *Client) identifyServer() error {
serverVersion, err := c.ServerVersion()
if err != nil {
return err
}
c.ServerImpl = serverVersion[0]
c.ProtoVersion = serverVersion[1]
c.log.Printf("Identified %s %s", c.ServerImpl, c.ProtoVersion)
return nil
}
// IsConnected returns whether this client is connected to a server.
// It does not guarantee the next request will succeed.
func (c *Client) IsConnected() bool {
return c.conn != nil
}
// call executes a request with JSON marshalling, and loads the response into a pointer.
func (c *Client) call(request *Request, response interface{}, timeout time.Duration) error {
// Assign a fresh request ID:
request.ID = c.incRequestID()
// Serialize the request:
requestBytes, err := json.Marshal(request)
if err != nil {
return c.log.Errorf("Marshal failed %v: %w", request, err)
}
// Make the call, obtain the serialized response:
responseBytes, err := c.callRaw(request.Method, requestBytes, timeout)
if err != nil {
return c.log.Errorf("Send failed %s: %w", request.Method, err)
}
// Deserialize into an error, to see if there's any:
var maybeErrorResponse ErrorResponse
err = json.Unmarshal(responseBytes, &maybeErrorResponse)
if err != nil {
return c.log.Errorf("Unmarshal of potential error failed: %s %w", request.Method, err)
}
if maybeErrorResponse.Error != nil {
return c.log.Errorf("Electrum error: %v", maybeErrorResponse.Error)
}
// Deserialize the response:
err = json.Unmarshal(responseBytes, response)
if err != nil {
return c.log.Errorf("Unmarshal failed %s: %w", string(responseBytes), err)
}
return nil
}
// call executes a batch request with JSON marshalling, and loads the response into a pointer.
// Response may not match request order, so callers MUST sort them by ID.
func (c *Client) callBatch(
method string, requests []*Request, response interface{}, timeout time.Duration,
) error {
// Assign fresh request IDs:
for _, request := range requests {
request.ID = c.incRequestID()
}
// Serialize the request:
requestBytes, err := json.Marshal(requests)
if err != nil {
return c.log.Errorf("Marshal failed %v: %w", requests, err)
}
// Make the call, obtain the serialized response:
responseBytes, err := c.callRaw(method, requestBytes, timeout)
if err != nil {
return c.log.Errorf("Send failed %s: %w", method, err)
}
// Deserialize into an array of errors, to see if there's any:
var maybeErrorResponses []ErrorResponse
err = json.Unmarshal(responseBytes, &maybeErrorResponses)
if err != nil {
return c.log.Errorf("Unmarshal of potential error failed: %s %w", string(responseBytes), err)
}
// Walk the responses, returning the first error found:
for _, maybeErrorResponse := range maybeErrorResponses {
if maybeErrorResponse.Error != nil {
return c.log.Errorf("Electrum error: %v", maybeErrorResponse.Error)
}
}
// Deserialize the response:
err = json.Unmarshal(responseBytes, response)
if err != nil {
return c.log.Errorf("Unmarshal failed %s: %w", string(responseBytes), err)
}
return nil
}
// callRaw sends a raw request in bytes, and returns a raw response (or an error).
func (c *Client) callRaw(method string, request []byte, timeout time.Duration) ([]byte, error) {
c.log.Printf("Sending %s request", method)
c.log.Tracef("Sending %s body: %s", method, string(request))
if !c.IsConnected() {
return nil, c.log.Errorf("Send failed %s: not connected", method)
}
request = append(request, messageDelim)
start := time.Now()
// SetDeadline is an absolute time based timeout. That is, we set an exact
// time we want it to fail.
var deadline time.Time
if timeout == noTimeout {
// This means no deadline
deadline = time.Time{}
} else {
deadline = start.Add(timeout)
}
err := c.conn.SetDeadline(deadline)
if err != nil {
return nil, c.log.Errorf("Send failed %s: SetDeadline failed", method)
}
_, err = c.conn.Write(request)
if err != nil {
duration := time.Now().Sub(start)
return nil, c.log.Errorf("Send failed %s after %vms: %w", method, duration.Milliseconds(), err)
}
reader := bufio.NewReader(c.conn)
response, err := reader.ReadBytes(messageDelim)
duration := time.Now().Sub(start)
if err != nil {
return nil, c.log.Errorf("Receive failed %s after %vms: %w", method, duration.Milliseconds(), err)
}
c.log.Printf("Received %s after %vms", method, duration.Milliseconds())
c.log.Tracef("Received %s: %s", method, string(response))
return response, nil
}
func (c *Client) incRequestID() int {
c.nextRequestID++
return c.nextRequestID
}
// GetIndexHash returns the script parameter to use with Electrum, given a Bitcoin address.
func GetIndexHash(script []byte) string {
indexHash := sha256.Sum256(script)
reverse(&indexHash)
return hex.EncodeToString(indexHash[:])
}
// reverse the order of the provided byte array, in place.
func reverse(a *[32]byte) {
for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 {
a[i], a[j] = a[j], a[i]
}
}

View File

@@ -0,0 +1,28 @@
package electrum
// Pool provides a shared pool of Clients that callers can acquire and release, limiting
// the amount of concurrent Clients in active use.
type Pool struct {
nextClient chan *Client
}
// NewPool creates an initialized Pool with a `size` number of clients.
func NewPool(size int, requireTls bool) *Pool {
nextClient := make(chan *Client, size)
for i := 0; i < size; i++ {
nextClient <- NewClient(requireTls)
}
return &Pool{nextClient}
}
// Acquire obtains an unused Client, blocking until one is released.
func (p *Pool) Acquire() <-chan *Client {
return p.nextClient
}
// Release returns a Client to the pool, unblocking the next caller trying to `Acquire()`.
func (p *Pool) Release(client *Client) {
p.nextClient <- client
}

View File

@@ -0,0 +1,105 @@
package electrum
import "sync/atomic"
// ServerProvider manages a rotating server list, from which callers can pull server addresses.
type ServerProvider struct {
nextIndex int32
servers []string
}
// NewServerProvider returns an initialized ServerProvider.
func NewServerProvider(servers []string) *ServerProvider {
return &ServerProvider{
nextIndex: -1,
servers: servers,
}
}
// NextServer returns an address from the rotating list. It's thread-safe.
func (p *ServerProvider) NextServer() string {
index := int(atomic.AddInt32(&p.nextIndex, 1))
return p.servers[index%len(p.servers)]
}
// PublicServers list.
//
// This list was taken from Electrum repositories, keeping TLS servers and excluding onion URIs.
// It was then sorted into sections using the `cmd/survey` program, to prioritize the more reliable
// servers with batch support.
//
// See https://github.com/spesmilo/electrum/blob/master/electrum/servers.json
// See https://github.com/kyuupichan/electrumx/blob/master/electrumx/lib/coins.py
// See `cmd/survey/main.go`
var PublicServers = []string{
// Fast servers with batching
"electrum.coinext.com.br:50002", // impl: ElectrumX 1.14.0, batching: true, ttc: 0.15, speed: 113, from:
"fulcrum.sethforprivacy.com:50002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.55, speed: 96, from:
"mainnet.foundationdevices.com:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.54, speed: 88, from:
"btc.lastingcoin.net:50002", // impl: Fulcrum 1.7.0, batching: true, ttc: 0.74, speed: 73, from:
"vmd71287.contaboserver.net:50002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.60, speed: 70, from:
"de.poiuty.com:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.87, speed: 70, from:
"electrum.jochen-hoenicke.de:50006", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.83, speed: 69, from:
"btc.cr.ypto.tech:50002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.81, speed: 65, from:
"e.keff.org:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.82, speed: 65, from:
"vmd104014.contaboserver.net:50002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.58, speed: 64, from:
"e2.keff.org:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.83, speed: 64, from:
"fulcrum.grey.pw:51002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.81, speed: 63, from:
"fortress.qtornado.com:443", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.84, speed: 62, from:
"f.keff.org:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.89, speed: 62, from:
"2ex.digitaleveryware.com:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.71, speed: 61, from:
"electrum.petrkr.net:50002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.84, speed: 58, from:
"electrum.stippy.com:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.80, speed: 57, from:
"electrum0.snel.it:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.80, speed: 56, from:
"ru.poiuty.com:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.99, speed: 56, from:
"electrum.privateservers.network:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 0.85, speed: 49, from:
"btc.electroncash.dk:60002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.92, speed: 48, from:
"bitcoin.aranguren.org:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 1.19, speed: 48, from:
"electrum.bitcoinserver.nl:50514", // impl: Fulcrum 1.8.1, batching: true, ttc: 0.85, speed: 44, from:
"btc.prompt.cash:61002", // impl: Fulcrum 1.8.1, batching: true, ttc: 1.22, speed: 44, from:
"fulc.bot.nu:50002", // impl: Fulcrum 1.7.0, batching: true, ttc: 1.04, speed: 35, from:
"bolt.schulzemic.net:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.96, speed: 33, from:
"node1.btccuracao.com:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.90, speed: 25, from:
// Other servers
"xtrum.com:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.91, speed: 19, from: fulcrum.sethforprivacy.com:50002
"electrum.bitaroo.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.04, speed: 19, from:
"btce.iiiiiii.biz:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.07, speed: 19, from: electrum.coinext.com.br:50002
"electrum.emzy.de:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.17, speed: 19, from:
"alviss.coinjoined.com:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 1.15, speed: 17, from:
"2AZZARITA.hopto.org:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.81, speed: 16, from: electrum.coinext.com.br:50002
"vmd104012.contaboserver.net:50002", // impl: Fulcrum 1.9.0, batching: true, ttc: 1.25, speed: 16, from:
"electrum.bitcoinlizard.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 4.99, speed: 14, from:
"btc.ocf.sh:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.19, speed: 12, from:
"bitcoins.sk:56002", // impl: ElectrumX 1.14.0, batching: true, ttc: 0.80, speed: 11, from:
"electrum-btc.leblancnet.us:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.04, speed: 11, from:
"helicarrier.bauerj.eu:50002", // impl: ElectrumX 1.10.0, batching: true, ttc: 0.96, speed: 10, from:
"electrum.neocrypto.io:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.69, speed: 7, from:
"caleb.vegas:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.71, speed: 7, from:
"smmalis37.ddns.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.77, speed: 7, from:
"2azzarita.hopto.org:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.78, speed: 7, from: fulcrum.sethforprivacy.com:50002
"electrum.kendigisland.xyz:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.79, speed: 7, from:
"electrum.hsmiths.com:50002", // impl: ElectrumX 1.10.0, batching: true, ttc: 0.90, speed: 7, from:
"vmd63185.contaboserver.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.92, speed: 7, from:
"blkhub.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.94, speed: 7, from:
"electrum.mmitech.info:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.95, speed: 7, from:
"elx.bitske.com:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.17, speed: 7, from:
"bitcoin.lu.ke:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.21, speed: 7, from:
"ex05.axalgo.com:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.21, speed: 7, from:
"walle.dedyn.io:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.23, speed: 7, from:
"eai.coincited.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.34, speed: 6, from:
"2electrumx.hopto.me:56022", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.93, speed: 6, from:
"hodlers.beer:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.03, speed: 6, from:
"kareoke.qoppa.org:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.13, speed: 6, from:
"ASSUREDLY.not.fyi:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.85, speed: 4, from:
"electrumx.alexridevski.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.87, speed: 4, from:
"assuredly.not.fyi:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.90, speed: 4, from:
"ragtor.duckdns.org:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.07, speed: 4, from:
"surely.not.fyi:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.20, speed: 4, from:
"btc.electrum.bitbitnet.net:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 0.71, speed: 3, from:
"gods-of-rock.screaminglemur.net:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 0.92, speed: 3, from:
"SURELY.not.fyi:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.35, speed: 3, from:
"horsey.cryptocowboys.net:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 0.61, speed: 2, from:
"electrumx-btc.cryptonermal.net:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 0.83, speed: 1, from:
"electrum.coineuskal.com:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 1.73, speed: 0, from: electrum.coinext.com.br:50002
}