package scanner import ( "fmt" "time" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcutil" "github.com/muun/libwallet" "github.com/muun/recovery/electrum" ) // scanTask encapsulates a parallelizable Scanner unit of work. type scanTask struct { servers *ServerProvider client *electrum.Client addresses []libwallet.MuunAddress timeout time.Duration exit chan struct{} } // scanTaskResult contains a summary of the execution of a task. type scanTaskResult struct { Task *scanTask Utxos []*Utxo Err error } // Execute obtains the Utxo set for the Task address, implementing a retry strategy. func (t *scanTask) Execute() *scanTaskResult { results := make(chan *scanTaskResult) timeout := time.After(t.timeout) // Keep the last error around, in case we reach the timeout and want to know the reason: var lastError error for { // Attempt to run the task: go t.tryExecuteAsync(results) // Wait until a result is sent, the timeout is reached or the task canceled, capturing errors // errors along the way: select { case <-t.exit: return t.exitResult() // stop retrying when we get the done signal case result := <-results: if result.Err == nil { return result // we're done! nice work everyone. } lastError = result.Err // keep retrying when an attempt fails case <-timeout: return t.errorResult(fmt.Errorf("Task timed out. Last error: %w", lastError)) // stop on timeout } } } func (t *scanTask) tryExecuteAsync(results chan *scanTaskResult) { // Errors will almost certainly arise from Electrum server failures, which are extremely // common. Unreachable IPs, dropped connections, sudden EOFs, etc. We'll run this task, assuming // the servers are at fault when something fails, disconnecting and cycling them as we retry. result := t.tryExecute() if result.Err != nil { t.client.Disconnect() } results <- result } func (t *scanTask) tryExecute() *scanTaskResult { // If our client is not connected, make an attempt to connect to a server: if !t.client.IsConnected() { err := t.client.Connect(t.servers.NextServer()) if err != nil { return t.errorResult(err) } } // Prepare the output scripts for all given addresses: outputScripts, err := getOutputScripts(t.addresses) if err != nil { return t.errorResult(err) } // Prepare the index hashes that Electrum requires to list outputs: indexHashes, err := getIndexHashes(outputScripts) if err != nil { return t.errorResult(err) } // Call Electrum to get the unspent output list, grouped by index for each address: var unspentRefGroups [][]electrum.UnspentRef if t.client.SupportsBatching() { unspentRefGroups, err = t.listUnspentWithBatching(indexHashes) } else { unspentRefGroups, err = t.listUnspentWithoutBatching(indexHashes) } if err != nil { return t.errorResult(err) } // Compile the results into a list of `Utxos`: var utxos []*Utxo for i, unspentRefGroup := range unspentRefGroups { for _, unspentRef := range unspentRefGroup { newUtxo := &Utxo{ TxID: unspentRef.TxHash, OutputIndex: unspentRef.TxPos, Amount: unspentRef.Value, Script: outputScripts[i], Address: t.addresses[i], } utxos = append(utxos, newUtxo) } } return t.successResult(utxos) } func (t *scanTask) listUnspentWithBatching(indexHashes []string) ([][]electrum.UnspentRef, error) { unspentRefGroups, err := t.client.ListUnspentBatch(indexHashes) if err != nil { return nil, fmt.Errorf("Listing with batching failed: %w", err) } return unspentRefGroups, nil } func (t *scanTask) listUnspentWithoutBatching(indexHashes []string) ([][]electrum.UnspentRef, error) { var unspentRefGroups [][]electrum.UnspentRef for _, indexHash := range indexHashes { newGroup, err := t.client.ListUnspent(indexHash) if err != nil { return nil, fmt.Errorf("Listing without batching failed: %w", err) } unspentRefGroups = append(unspentRefGroups, newGroup) } return unspentRefGroups, nil } func (t *scanTask) errorResult(err error) *scanTaskResult { return &scanTaskResult{Task: t, Err: err} } func (t *scanTask) successResult(utxos []*Utxo) *scanTaskResult { return &scanTaskResult{Task: t, Utxos: utxos} } func (t *scanTask) exitResult() *scanTaskResult { return &scanTaskResult{Task: t} } // getIndexHashes calculates all the Electrum index hashes for a list of output scripts. func getIndexHashes(outputScripts [][]byte) ([]string, error) { indexHashes := make([]string, len(outputScripts)) for i, outputScript := range outputScripts { indexHashes[i] = electrum.GetIndexHash(outputScript) } return indexHashes, nil } // getOutputScripts creates all the scripts that send to an list of Bitcoin address. func getOutputScripts(addresses []libwallet.MuunAddress) ([][]byte, error) { outputScripts := make([][]byte, len(addresses)) for i, address := range addresses { rawAddress := address.Address() decodedAddress, err := btcutil.DecodeAddress(rawAddress, &chaincfg.MainNetParams) if err != nil { return nil, fmt.Errorf("Failed to decode address %s: %w", rawAddress, err) } outputScript, err := txscript.PayToAddrScript(decodedAddress) if err != nil { return nil, fmt.Errorf("Failed to craft script for %s: %w", rawAddress, err) } outputScripts[i] = outputScript } return outputScripts, nil }