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...) }