muun-recovery/survey/survey.go

342 lines
7.7 KiB
Go

package survey
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"sort"
"strings"
"sync"
"time"
"github.com/muun/recovery/electrum"
)
type Survey struct {
config *Config
tasks chan *surveyTask
taskWg sync.WaitGroup
results chan *Result
visited map[string]bool
}
type Config struct {
InitialServers []string
Workers int
SpeedTestDuration time.Duration
SpeedTestBatchSize int
}
type Result struct {
Server string
FromPeer string
IsWorthy bool
Err error
Impl string
Version string
TimeToConnect time.Duration
Speed int
BatchSupport bool
peers []string
}
type surveyTask struct {
server string
fromPeer string
}
// Values to check whether we're in the same chain (in a previous version, SV servers snuck in)
var mainnetSomeTx = "1712426823cc94935287a6834f7982723fbb5c808cbe00ec2cf3f582582be4c5"
var mainnetGenesisHash = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
func NewSurvey(config *Config) *Survey {
return &Survey{
config: config,
tasks: make(chan *surveyTask),
results: make(chan *Result),
visited: make(map[string]bool),
}
}
func (s *Survey) Run() []*Result {
// Add initial tasks:
for _, server := range s.config.InitialServers {
s.addTask(server, "")
}
// Start collecting results in background:
results := []*Result{}
go s.startCollect(&results)
// Launch workers to process tasks and send back results:
for i := 0; i < s.config.Workers; i++ {
go s.startWorker()
}
// Wait until there's no tasks left, and signal everyone to stop:
s.taskWg.Wait()
close(s.tasks)
close(s.results)
sort.Slice(results, func(i, j int) bool {
return results[i].IsBetterThan(results[j])
})
return results
}
func (s *Survey) addTask(server string, fromPeer string) {
task := &surveyTask{server, fromPeer}
if _, ok := s.visited[task.server]; ok {
return
}
s.visited[task.server] = true
s.taskWg.Add(1)
go func() { s.tasks <- task }() // scheduling tasks is non-blocking for users of the type
}
func (s *Survey) notifyResult(result *Result) {
s.results <- result
s.taskWg.Done()
}
func (s *Survey) startCollect(resultsRef *[]*Result) {
for result := range s.results {
*resultsRef = append(*resultsRef, result)
}
}
func (s *Survey) startWorker() {
for task := range s.tasks {
log("• %s\n", task.server)
result := s.processTask(task)
if result.Err != nil {
log("✕ %s\n", task.server)
} else {
log("✓ %s\n", task.server)
}
s.notifyResult(result)
}
}
func (s *Survey) processTask(task *surveyTask) *Result {
// We're going to perform a number of tests an measurements:
//
// 1. How much time does it take to establish a connection?
// 2. Does the server support batching?
// 3. Is the server willing to share its peers? If so, crawl.
// 4. How many requests can the server handle in a given time interval?
// 5. Did the server fail at any point during testing?
//
// Each test can result in a closed socket (since Electrum communicates errors by slapping you
// in the face with no explanation), so we'll be connecting separately for each attempt.
//
// When a testing method returns an error, it means the server failed completely and we couldn't
// obtain meaningful results (while some internal errors in a test are expected and handled).
impl, version, timeToConnect, err := testConnection(task)
if err != nil {
return &Result{Server: task.server, Err: err}
}
isBitcoinMainnet, err := testBitcoinMainnet(task)
if err != nil || !isBitcoinMainnet {
return &Result{Server: task.server, Err: fmt.Errorf("not on Bitcoin mainnet: %w", err)}
}
batchSupport, err := testBatchSupport(task)
if err != nil {
return &Result{Server: task.server, Err: err}
}
speed, err := s.measureSpeed(task)
if err != nil {
return &Result{Server: task.server, Err: err}
}
peers, err := getPeers(task)
if err != nil {
return &Result{Server: task.server, Err: err}
}
for _, peer := range peers {
if strings.Contains(peer, ".onion:") {
continue
}
s.addTask(peer, task.server)
}
isWorthy := err == nil &&
batchSupport &&
timeToConnect.Seconds() < 5.0 &&
speed >= int(s.config.SpeedTestDuration.Seconds())
return &Result{
IsWorthy: isWorthy,
Server: task.server,
FromPeer: task.fromPeer,
Impl: impl,
Version: version,
TimeToConnect: timeToConnect,
BatchSupport: batchSupport,
Speed: speed,
peers: peers,
}
}
// testConnection returns the server implementation, protocol version and time to connect
func testConnection(task *surveyTask) (string, string, time.Duration, error) {
client := electrum.NewClient()
start := time.Now()
err := client.Connect(task.server)
if err != nil {
return "", "", 0, err
}
return client.ServerImpl, client.ProtoVersion, time.Since(start), nil
}
// testsBlockchain returns whether this server is operating on Bitcoin mainnet
func testBitcoinMainnet(task *surveyTask) (bool, error) {
client := electrum.NewClient()
err := client.Connect(task.server)
if err != nil {
return false, err
}
features, err := client.ServerFeatures()
if err != nil || features.GenesisHash != mainnetGenesisHash {
return false, err
}
_, err = client.GetTransaction(mainnetSomeTx)
if err != nil {
return false, err
}
return true, nil
}
// testBatchSupport returns whether the server successfully responded to a batched request
func testBatchSupport(task *surveyTask) (bool, error) {
client := electrum.NewClient()
err := client.Connect(task.server)
if err != nil {
return false, err
}
_, err = client.ListUnspentBatch(createFakeHashes(2))
if err != nil {
return false, nil // an error here suggests lack of support for this call
}
return true, nil
}
// measureSpeed returns the amount of successful ListUnspentBatch calls in SPEED_TEST_DURATION
// seconds. It assumes batch support was verified beforehand.
func (s *Survey) measureSpeed(task *surveyTask) (int, error) {
client := electrum.NewClient()
err := client.Connect(task.server)
if err != nil {
return 0, err
}
start := time.Now()
responseCount := 0
for time.Since(start) < s.config.SpeedTestDuration {
fakeHashes := createFakeHashes(s.config.SpeedTestBatchSize)
_, err := client.ListUnspentBatch(fakeHashes) // TODO: is the faking affecting the result?
if err != nil {
return 0, err
}
responseCount++
}
return responseCount - 1, nil // the last one was over the time limit
}
// getPeers returns the list of peers from a server, or empty if it doesn't responds to the request
func getPeers(task *surveyTask) ([]string, error) {
client := electrum.NewClient()
err := client.Connect(task.server)
if err != nil {
return nil, err
}
peers, err := client.ServerPeers()
if err != nil {
return []string{}, nil // an error here suggests lack of support for this call
}
return peers, nil
}
func (r *Result) IsBetterThan(other *Result) bool {
if r.Err != nil {
return false
}
if other.Err != nil {
return true
}
if r.IsWorthy != other.IsWorthy {
return r.IsWorthy
}
if r.BatchSupport != other.BatchSupport {
return r.BatchSupport
}
if r.Speed != other.Speed {
return (r.Speed > other.Speed)
}
return (r.TimeToConnect < other.TimeToConnect)
}
func (r *Result) String() string {
return fmt.Sprintf(
"%s, %s, %s, %v, %v, %d, %v",
r.Server,
r.Impl,
r.Version,
r.BatchSupport,
r.TimeToConnect.Seconds(),
r.Speed,
r.Err,
)
}
func createFakeHashes(count int) []string {
randomBuffer := make([]byte, 32)
fakeHashes := make([]string, count)
for i := 0; i < count; i++ {
rand.Read(randomBuffer)
fakeHashes[i] = hex.EncodeToString(randomBuffer)
}
return fakeHashes
}
func log(msg string, args ...interface{}) {
fmt.Fprintf(os.Stderr, msg, args...)
}