diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..723ef36 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/LICENSE b/LICENSE index c09285b..c1964d7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,18 +1,24 @@ -MIT License +The MIT License (MIT) -Copyright (c) 2025 steven +Copyright (c) 2025 Steven Polley +Copyright (c) 2022 Roman Maklakov +Copyright (c) 2019 Ian DescĂ´teaux +Copyright (c) 2015 Tristan Rice -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and -associated documentation files (the "Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the -following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial -portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT -LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO -EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 42c93ea..68e3447 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,71 @@ -# go-electrum +# go-electrum +A pure Go [Electrum](https://electrum.org/) bitcoin library supporting the latest [ElectrumX](https://github.com/spesmilo/electrumx) protocol versions. +This makes it easy to write cryptocurrencies based services in a trustless fashion using Go without having to run a full node. + +![go-electrum](https://code.stevenpolley.net/steven/go-electrum/raw/branch/master/media/logo.png) + + + +## Usage +See [example/](https://code.stevenpolley.net/steven/go-electrum/src/branch/master/example) for more. + +### electrum +```bash +$ go get code.stevenpolley.net/steven/go-electrum +``` + +```go +package main + +import ( + "context" + "log" + "time" + + "code.stevenpolley.net/steven/go-electrum/electrum" +) + +func main() { + // Establishing a new SSL connection to an ElectrumX server + client := electrum.NewClient() + if err := client.ConnectTCP(context.TODO(), "satoshi.stevenpolley.net:50002"); err != nil { + log.Fatal(err) + } + ctx := context.TODO() + // Making sure connection is not closed with timed "client.ping" call + go func() { + for { + if err := client.Ping(ctx); err != nil { + log.Fatal(err) + } + time.Sleep(60 * time.Second) + } + }() + + // Making sure we declare to the server what protocol we want to use + if _, _, err := client.ServerVersion(ctx); err != nil { + log.Fatal(err) + } + + // Asking the server for the balance of address bc1qng5cyhc06q9pnnldv2jxw7ga7hz5q5g0c30gr4 + // We must use scripthash of the address now as explained in ElectrumX docs + scripthash, _ := electrum.AddressToElectrumScriptHash("bc1qng5cyhc06q9pnnldv2jxw7ga7hz5q5g0c30gr4") + balance, err := client.GetBalance(ctx, scripthash) + if err != nil { + log.Fatal(err) + } + log.Printf("Address confirmed balance: %+v", balance.Confirmed) + log.Printf("Address unconfirmed balance: %+v", balance.Unconfirmed) +} +``` + +# License +go-electrum is licensed under the MIT license. See LICENSE file for more details. + +Copyright (c) 2025 Steven Polley +Copyright (c) 2022 Roman Maklakov +Copyright (c) 2019 Ian DescĂ´teaux +Copyright (c) 2015 Tristan Rice + +Based on Tristan Rice [go-electrum](https://github.com/d4l3k/go-electrum) unmaintained library. diff --git a/electrum/address.go b/electrum/address.go new file mode 100644 index 0000000..a4253c9 --- /dev/null +++ b/electrum/address.go @@ -0,0 +1,31 @@ +package electrum + +import ( + "crypto/sha256" + "encoding/hex" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" +) + +// AddressToElectrumScriptHash converts valid bitcoin address to electrum scriptHash sha256 encoded, reversed and encoded in hex +// https://electrumx.readthedocs.io/en/latest/protocol-basics.html#script-hashes +func AddressToElectrumScriptHash(addressStr string) (string, error) { + address, err := btcutil.DecodeAddress(addressStr, &chaincfg.MainNetParams) + if err != nil { + return "", err + } + script, err := txscript.PayToAddrScript(address) + if err != nil { + return "", err + } + + hashSum := sha256.Sum256(script) + + for i, j := 0, len(hashSum)-1; i < j; i, j = i+1, j-1 { + hashSum[i], hashSum[j] = hashSum[j], hashSum[i] + } + + return hex.EncodeToString(hashSum[:]), nil +} diff --git a/electrum/address_test.go b/electrum/address_test.go new file mode 100644 index 0000000..faba113 --- /dev/null +++ b/electrum/address_test.go @@ -0,0 +1,29 @@ +package electrum + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +func TestAddressToElectrumScriptHash(t *testing.T) { + tests := []struct { + address string + wantScriptHash string + }{ + { + address: "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + wantScriptHash: "8b01df4e368ea28f8dc0423bcf7a4923e3a12d307c875e47a0cfbf90b5c39161", + }, + { + address: "34xp4vRoCGJym3xR7yCVPFHoCNxv4Twseo", + wantScriptHash: "2375f2bbf7815e3cdc835074b052d65c9b2f101bab28d37250cc96b2ed9a6809", + }, + } + + for _, tc := range tests { + scriptHash, err := AddressToElectrumScriptHash(tc.address) + require.NoError(t, err) + assert.Equal(t, scriptHash, tc.wantScriptHash) + } +} diff --git a/electrum/block.go b/electrum/block.go new file mode 100644 index 0000000..5111d8b --- /dev/null +++ b/electrum/block.go @@ -0,0 +1,91 @@ +package electrum + +import ( + "context" + "errors" +) + +var ( + // ErrCheckpointHeight is thrown if the checkpoint height is smaller than the block height. + ErrCheckpointHeight = errors.New("checkpoint height must be greater than or equal to block height") +) + +// GetBlockHeaderResp represents the response to GetBlockHeader(). +type GetBlockHeaderResp struct { + Result *GetBlockHeaderResult `json:"result"` +} + +// GetBlockHeaderResult represents the content of the result field in the response to GetBlockHeader(). +type GetBlockHeaderResult struct { + Branch []string `json:"branch"` + Header string `json:"header"` + Root string `json:"root"` +} + +// GetBlockHeader returns the block header at a specific height. +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-block-header +func (s *Client) GetBlockHeader(ctx context.Context, height uint32, checkpointHeight ...uint32) (*GetBlockHeaderResult, error) { + if checkpointHeight != nil && checkpointHeight[0] != 0 { + if height > checkpointHeight[0] { + return nil, ErrCheckpointHeight + } + + var resp GetBlockHeaderResp + err := s.request(ctx, "blockchain.block.header", []interface{}{height, checkpointHeight[0]}, &resp) + + return resp.Result, err + } + + var resp basicResp + err := s.request(ctx, "blockchain.block.header", []interface{}{height, 0}, &resp) + if err != nil { + return nil, err + } + + result := &GetBlockHeaderResult{ + Branch: nil, + Header: resp.Result, + Root: "", + } + + return result, err +} + +// GetBlockHeadersResp represents the response to GetBlockHeaders(). +type GetBlockHeadersResp struct { + Result *GetBlockHeadersResult `json:"result"` +} + +// GetBlockHeadersResult represents the content of the result field in the response to GetBlockHeaders(). +type GetBlockHeadersResult struct { + Count uint32 `json:"count"` + Headers string `json:"hex"` + Max uint32 `json:"max"` + Branch []string `json:"branch,omitempty"` + Root string `json:"root,omitempty"` +} + +// GetBlockHeaders return a concatenated chunk of block headers. +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-block-headers +func (s *Client) GetBlockHeaders(ctx context.Context, startHeight, count uint32, + checkpointHeight ...uint32) (*GetBlockHeadersResult, error) { + + var resp GetBlockHeadersResp + var err error + + if checkpointHeight != nil && checkpointHeight[0] != 0 { + if (startHeight + (count - 1)) > checkpointHeight[0] { + return nil, ErrCheckpointHeight + } + + err = s.request(ctx, "blockchain.block.headers", []interface{}{startHeight, count, checkpointHeight[0]}, &resp) + } else { + err = s.request(ctx, "blockchain.block.headers", []interface{}{startHeight, count, 0}, &resp) + } + + if err != nil { + return nil, err + } + + return resp.Result, err +} diff --git a/electrum/misc.go b/electrum/misc.go new file mode 100644 index 0000000..cca0543 --- /dev/null +++ b/electrum/misc.go @@ -0,0 +1,64 @@ +package electrum + +import "context" + +type basicResp struct { + Result string `json:"result"` +} + +// GetFeeResp represents the response to GetFee(). +type GetFeeResp struct { + Result float32 `json:"result"` +} + +// GetFee returns the estimated transaction fee per kilobytes for a transaction +// to be confirmed within a target number of blocks. +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-estimatefee +func (s *Client) GetFee(ctx context.Context, target uint32) (float32, error) { + var resp GetFeeResp + + err := s.request(ctx, "blockchain.estimatefee", []interface{}{target}, &resp) + if err != nil { + return -1, err + } + + return resp.Result, err +} + +// GetRelayFee returns the minimum fee a transaction must pay to be accepted into the +// remote server memory pool. +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-relayfee +func (s *Client) GetRelayFee(ctx context.Context) (float32, error) { + var resp GetFeeResp + + err := s.request(ctx, "blockchain.relayfee", []interface{}{}, &resp) + if err != nil { + return -1, err + } + + return resp.Result, err +} + +// GetFeeHistogramResp represents the response to GetFee(). +type getFeeHistogramResp struct { + Result [][2]uint64 `json:"result"` +} + +// GetFeeHistogram returns a histogram of the fee rates paid by transactions in the +// memory pool, weighted by transacation size. +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#mempool-get-fee-histogram +func (s *Client) GetFeeHistogram(ctx context.Context) (map[uint32]uint64, error) { + var resp getFeeHistogramResp + + err := s.request(ctx, "mempool.get_fee_histogram", []interface{}{}, &resp) + if err != nil { + return nil, err + } + + feeMap := make(map[uint32]uint64) + for i := 0; i < len(resp.Result); i++ { + feeMap[uint32(resp.Result[i][0])] = resp.Result[i][1] + } + + return feeMap, err +} diff --git a/electrum/network.go b/electrum/network.go new file mode 100644 index 0000000..41825f9 --- /dev/null +++ b/electrum/network.go @@ -0,0 +1,278 @@ +package electrum + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "log" + "sync" + "sync/atomic" +) + +const ( + // ClientVersion identifies the client version/name to the remote server + ClientVersion = "go-electrum1.1" + + // ProtocolVersion identifies the support protocol version to the remote server + ProtocolVersion = "1.4" + + nl = byte('\n') +) + +var ( + // DebugMode provides debug output on communications with the remote server if enabled. + DebugMode bool + + // ErrServerConnected throws an error if remote server is already connected. + ErrServerConnected = errors.New("server is already connected") + + // ErrServerShutdown throws an error if remote server has shutdown. + ErrServerShutdown = errors.New("server has shutdown") + + // ErrTimeout throws an error if request has timed out + ErrTimeout = errors.New("request timeout") + + // ErrNotImplemented throws an error if this RPC call has not been implemented yet. + ErrNotImplemented = errors.New("RPC call is not implemented") + + // ErrDeprecated throws an error if this RPC call is deprecated. + ErrDeprecated = errors.New("RPC call has been deprecated") +) + +// Transport provides interface to server transport. +type Transport interface { + SendMessage([]byte) error + Responses() <-chan []byte + Errors() <-chan error + Close() error +} + +type container struct { + content []byte + err error +} + +// Client stores information about the remote server. +type Client struct { + transport Transport + + handlers map[uint64]chan *container + handlersLock sync.RWMutex + + pushHandlers map[string][]chan *container + pushHandlersLock sync.RWMutex + + Error chan error + quit chan struct{} + + nextID uint64 +} + +// NewClientTCP initialize a new client for remote server and connects to the remote server using TCP +func NewClientTCP(ctx context.Context, addr string) (*Client, error) { + transport, err := NewTCPTransport(ctx, addr) + if err != nil { + return nil, err + } + + c := &Client{ + handlers: make(map[uint64]chan *container), + pushHandlers: make(map[string][]chan *container), + + Error: make(chan error), + quit: make(chan struct{}), + } + + c.transport = transport + go c.listen() + + return c, nil +} + +// NewClientSSL initialize a new client for remote server and connects to the remote server using SSL +func NewClientSSL(ctx context.Context, addr string, config *tls.Config) (*Client, error) { + transport, err := NewSSLTransport(ctx, addr, config) + if err != nil { + return nil, err + } + + c := &Client{ + handlers: make(map[uint64]chan *container), + pushHandlers: make(map[string][]chan *container), + + Error: make(chan error), + quit: make(chan struct{}), + } + + c.transport = transport + go c.listen() + + return c, nil +} + +type apiErr struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e *apiErr) Error() string { + return fmt.Sprintf("errNo: %d, errMsg: %s", e.Code, e.Message) +} + +type response struct { + ID uint64 `json:"id"` + Method string `json:"method"` + Error string `json:"error"` +} + +func (s *Client) listen() { + for { + if s.IsShutdown() { + break + } + if s.transport == nil { + break + } + select { + case <-s.quit: + return + case err := <-s.transport.Errors(): + s.Error <- err + s.Shutdown() + case bytes := <-s.transport.Responses(): + result := &container{ + content: bytes, + } + + msg := &response{} + err := json.Unmarshal(bytes, msg) + if err != nil { + if DebugMode { + log.Printf("Unmarshal received message failed: %v", err) + } + result.err = fmt.Errorf("Unmarshal received message failed: %v", err) + } else if msg.Error != "" { + result.err = errors.New(msg.Error) + } + + if len(msg.Method) > 0 { + s.pushHandlersLock.RLock() + handlers := s.pushHandlers[msg.Method] + s.pushHandlersLock.RUnlock() + + for _, handler := range handlers { + select { + case handler <- result: + default: + } + } + } + + s.handlersLock.RLock() + c, ok := s.handlers[msg.ID] + s.handlersLock.RUnlock() + + if ok { + // TODO: very rare case. fix this memory leak, when nobody will read channel (in case of error) + c <- result + } + } + } +} + +func (s *Client) listenPush(method string) <-chan *container { + c := make(chan *container, 1) + s.pushHandlersLock.Lock() + s.pushHandlers[method] = append(s.pushHandlers[method], c) + s.pushHandlersLock.Unlock() + + return c +} + +type request struct { + ID uint64 `json:"id"` + Method string `json:"method"` + Params []interface{} `json:"params"` +} + +func (s *Client) request(ctx context.Context, method string, params []interface{}, v interface{}) error { + select { + case <-s.quit: + return ErrServerShutdown + default: + } + + msg := request{ + ID: atomic.AddUint64(&s.nextID, 1), + Method: method, + Params: params, + } + + bytes, err := json.Marshal(msg) + if err != nil { + return err + } + + bytes = append(bytes, nl) + + err = s.transport.SendMessage(bytes) + if err != nil { + s.Shutdown() + return err + } + + c := make(chan *container, 1) + + s.handlersLock.Lock() + s.handlers[msg.ID] = c + s.handlersLock.Unlock() + + defer func() { + s.handlersLock.Lock() + delete(s.handlers, msg.ID) + s.handlersLock.Unlock() + }() + + var resp *container + select { + case resp = <-c: + case <-ctx.Done(): + return ErrTimeout + } + + if resp.err != nil { + return resp.err + } + + if v != nil { + err = json.Unmarshal(resp.content, v) + if err != nil { + return err + } + } + + return nil +} + +func (s *Client) Shutdown() { + if !s.IsShutdown() { + close(s.quit) + } + if s.transport != nil { + _ = s.transport.Close() + } + s.transport = nil + s.handlers = nil + s.pushHandlers = nil +} + +func (s *Client) IsShutdown() bool { + select { + case <-s.quit: + return true + default: + } + return false +} diff --git a/electrum/scripthash.go b/electrum/scripthash.go new file mode 100644 index 0000000..bfa810a --- /dev/null +++ b/electrum/scripthash.go @@ -0,0 +1,89 @@ +package electrum + +import "context" + +// GetBalanceResp represents the response to GetBalance(). +type GetBalanceResp struct { + Result GetBalanceResult `json:"result"` +} + +// GetBalanceResult represents the content of the result field in the response to GetBalance(). +type GetBalanceResult struct { + Confirmed float64 `json:"confirmed"` + Unconfirmed float64 `json:"unconfirmed"` +} + +// GetBalance returns the confirmed and unconfirmed balance for a scripthash. +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-scripthash-get-balance +func (s *Client) GetBalance(ctx context.Context, scripthash string) (GetBalanceResult, error) { + var resp GetBalanceResp + + err := s.request(ctx, "blockchain.scripthash.get_balance", []interface{}{scripthash}, &resp) + if err != nil { + return GetBalanceResult{}, err + } + + return resp.Result, err +} + +// GetMempoolResp represents the response to GetHistory() and GetMempool(). +type GetMempoolResp struct { + Result []*GetMempoolResult `json:"result"` +} + +// GetMempoolResult represents the content of the result field in the response +// to GetHistory() and GetMempool(). +type GetMempoolResult struct { + Hash string `json:"tx_hash"` + Height int32 `json:"height"` + Fee uint32 `json:"fee,omitempty"` +} + +// GetHistory returns the confirmed and unconfirmed history for a scripthash. +func (s *Client) GetHistory(ctx context.Context, scripthash string) ([]*GetMempoolResult, error) { + var resp GetMempoolResp + + err := s.request(ctx, "blockchain.scripthash.get_history", []interface{}{scripthash}, &resp) + if err != nil { + return nil, err + } + + return resp.Result, err +} + +// GetMempool returns the unconfirmed transacations of a scripthash. +func (s *Client) GetMempool(ctx context.Context, scripthash string) ([]*GetMempoolResult, error) { + var resp GetMempoolResp + + err := s.request(ctx, "blockchain.scripthash.get_mempool", []interface{}{scripthash}, &resp) + if err != nil { + return nil, err + } + + return resp.Result, err +} + +// ListUnspentResp represents the response to ListUnspent() +type ListUnspentResp struct { + Result []*ListUnspentResult `json:"result"` +} + +// ListUnspentResult represents the content of the result field in the response to ListUnspent() +type ListUnspentResult struct { + Height uint32 `json:"height"` + Position uint32 `json:"tx_pos"` + Hash string `json:"tx_hash"` + Value uint64 `json:"value"` +} + +// ListUnspent returns an ordered list of UTXOs for a scripthash. +func (s *Client) ListUnspent(ctx context.Context, scripthash string) ([]*ListUnspentResult, error) { + var resp ListUnspentResp + + err := s.request(ctx, "blockchain.scripthash.listunspent", []interface{}{scripthash}, &resp) + if err != nil { + return nil, err + } + + return resp.Result, err +} diff --git a/electrum/server.go b/electrum/server.go new file mode 100644 index 0000000..ac30619 --- /dev/null +++ b/electrum/server.go @@ -0,0 +1,109 @@ +package electrum + +import "context" + +// Ping send a ping to the target server to ensure it is responding and +// keeping the session alive. +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#server-ping +func (s *Client) Ping(ctx context.Context) error { + err := s.request(ctx, "server.ping", []interface{}{}, nil) + + return err +} + +// ServerAddPeer adds your new server into the remote server own peers list. +// This should not be used if you are a client. +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#server-add-peer +func (s *Client) ServerAddPeer(ctx context.Context, features *ServerFeaturesResult) error { + var resp basicResp + + err := s.request(ctx, "server.add_peer", []interface{}{features}, &resp) + + return err +} + +// ServerBanner returns the banner for this remote server. +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#server-banner +func (s *Client) ServerBanner(ctx context.Context) (string, error) { + var resp basicResp + + err := s.request(ctx, "server.banner", []interface{}{}, &resp) + + return resp.Result, err +} + +// ServerDonation returns the donation address for this remote server +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#server-donation-address +func (s *Client) ServerDonation(ctx context.Context) (string, error) { + var resp basicResp + + err := s.request(ctx, "server.donation_address", []interface{}{}, &resp) + + return resp.Result, err +} + +type host struct { + TCPPort uint16 `json:"tcp_port,omitempty"` + SSLPort uint16 `json:"ssl_port,omitempty"` +} + +// ServerFeaturesResp represent the response to GetFeatures(). +type ServerFeaturesResp struct { + Result *ServerFeaturesResult `json:"result"` +} + +// ServerFeaturesResult represent the data sent or receive in RPC call "server.features" and +// "server.add_peer". +type ServerFeaturesResult struct { + GenesisHash string `json:"genesis_hash"` + Hosts map[string]host `json:"hosts"` + ProtocolMax string `json:"protocol_max"` + ProtocolMin string `json:"protocol_min"` + Pruning bool `json:"pruning,omitempty"` + ServerVersion string `json:"server_version"` + HashFunction string `json:"hash_function"` +} + +// ServerFeatures returns a list of features and services supported by the remote server. +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#server-features +func (s *Client) ServerFeatures(ctx context.Context) (*ServerFeaturesResult, error) { + var resp ServerFeaturesResp + + err := s.request(ctx, "server.features", []interface{}{}, &resp) + + return resp.Result, err +} + +// ServerPeers returns a list of peers this remote server is aware of. +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#server-peers-subscribe +func (s *Client) ServerPeers(ctx context.Context) (interface{}, error) { + resp := &struct { + Result [][]interface{} `json:"result"` + }{} + err := s.request(ctx, "server.peers.subscribe", []interface{}{}, &resp) + + return resp.Result, err +} + +// ServerVersionResp represent the response to ServerVersion(). +type ServerVersionResp struct { + Result [2]string `json:"result"` +} + +// ServerVersion identify the client to the server, and negotiate the protocol version. +// This call must be sent first, or the server will default to an older protocol version. +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#server-version +func (s *Client) ServerVersion(ctx context.Context) (serverVer, protocolVer string, err error) { + var resp ServerVersionResp + + err = s.request(ctx, "server.version", []interface{}{ClientVersion, ProtocolVersion}, &resp) + if err != nil { + serverVer = "" + protocolVer = "" + } else { + serverVer = resp.Result[0] + protocolVer = resp.Result[1] + } + + return +} diff --git a/electrum/subscribe.go b/electrum/subscribe.go new file mode 100644 index 0000000..5185d30 --- /dev/null +++ b/electrum/subscribe.go @@ -0,0 +1,250 @@ +package electrum + +import ( + "context" + "encoding/json" + "errors" + "sync" +) + +// SubscribeHeadersResp represent the response to SubscribeHeaders(). +type SubscribeHeadersResp struct { + Result *SubscribeHeadersResult `json:"result"` +} + +// SubscribeHeadersNotif represent the notification to SubscribeHeaders(). +type SubscribeHeadersNotif struct { + Params []*SubscribeHeadersResult `json:"params"` +} + +// SubscribeHeadersResult represents the content of the result field in the response to SubscribeHeaders(). +type SubscribeHeadersResult struct { + Height int32 `json:"height,omitempty"` + Hex string `json:"hex"` +} + +// SubscribeHeaders subscribes to receive block headers notifications when new blocks are found. +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-headers-subscribe +func (s *Client) SubscribeHeaders(ctx context.Context) (<-chan *SubscribeHeadersResult, error) { + var resp SubscribeHeadersResp + + err := s.request(ctx, "blockchain.headers.subscribe", []interface{}{}, &resp) + if err != nil { + return nil, err + } + + respChan := make(chan *SubscribeHeadersResult, 1) + respChan <- resp.Result + + go func() { + for msg := range s.listenPush("blockchain.headers.subscribe") { + if msg.err != nil { + return + } + + var resp SubscribeHeadersNotif + + err := json.Unmarshal(msg.content, &resp) + if err != nil { + return + } + + for _, param := range resp.Params { + respChan <- param + } + } + }() + + return respChan, nil +} + +// ScripthashSubscription ... +type ScripthashSubscription struct { + server *Client + notifChan chan *SubscribeNotif + + subscribedSH []string + scripthashMap map[string]string + + lock sync.RWMutex +} + +// SubscribeNotif represent the notification to SubscribeScripthash() and SubscribeMasternode(). +type SubscribeNotif struct { + Params [2]string `json:"params"` +} + +// SubscribeScripthash ... +func (s *Client) SubscribeScripthash() (*ScripthashSubscription, <-chan *SubscribeNotif) { + sub := &ScripthashSubscription{ + server: s, + notifChan: make(chan *SubscribeNotif, 1), + scripthashMap: make(map[string]string), + } + + go func() { + for msg := range s.listenPush("blockchain.scripthash.subscribe") { + if msg.err != nil { + return + } + + var resp SubscribeNotif + + err := json.Unmarshal(msg.content, &resp) + if err != nil { + return + } + + sub.lock.Lock() + for _, a := range sub.subscribedSH { + if a == resp.Params[0] { + sub.notifChan <- &resp + break + } + } + sub.lock.Unlock() + } + }() + + return sub, sub.notifChan +} + +// Add ... +func (sub *ScripthashSubscription) Add(ctx context.Context, scripthash string, address ...string) error { + var resp basicResp + + err := sub.server.request(ctx, "blockchain.scripthash.subscribe", []interface{}{scripthash}, &resp) + if err != nil { + return err + } + + if len(resp.Result) > 0 { + sub.notifChan <- &SubscribeNotif{[2]string{scripthash, resp.Result}} + } + + sub.lock.Lock() + sub.subscribedSH = append(sub.subscribedSH[:], scripthash) + if len(address) > 0 { + sub.scripthashMap[scripthash] = address[0] + } + sub.lock.Unlock() + + return nil +} + +// GetAddress ... +func (sub *ScripthashSubscription) GetAddress(scripthash string) (string, error) { + address, ok := sub.scripthashMap[scripthash] + if ok { + return address, nil + } + + return "", errors.New("scripthash not found in map") +} + +// GetScripthash ... +func (sub *ScripthashSubscription) GetScripthash(address string) (string, error) { + var found bool + var scripthash string + + for k, v := range sub.scripthashMap { + if v == address { + scripthash = k + found = true + } + } + + if found { + return scripthash, nil + } + + return "", errors.New("address not found in map") +} + +// GetChannel ... +func (sub *ScripthashSubscription) GetChannel() <-chan *SubscribeNotif { + return sub.notifChan +} + +// Remove ... +func (sub *ScripthashSubscription) Remove(scripthash string) error { + for i, v := range sub.subscribedSH { + if v == scripthash { + sub.lock.Lock() + sub.subscribedSH = append(sub.subscribedSH[:i], sub.subscribedSH[i+1:]...) + sub.lock.Unlock() + return nil + } + } + + return errors.New("scripthash not found") +} + +// RemoveAddress ... +func (sub *ScripthashSubscription) RemoveAddress(address string) error { + scripthash, err := sub.GetScripthash(address) + if err != nil { + return err + } + + for i, v := range sub.subscribedSH { + if v == scripthash { + sub.lock.Lock() + sub.subscribedSH = append(sub.subscribedSH[:i], sub.subscribedSH[i+1:]...) + delete(sub.scripthashMap, scripthash) + sub.lock.Unlock() + return nil + } + } + + return errors.New("scripthash not found") +} + +// Resubscribe ... +func (sub *ScripthashSubscription) Resubscribe(ctx context.Context) error { + for _, v := range sub.subscribedSH { + err := sub.Add(ctx, v) + if err != nil { + return err + } + } + + return nil +} + +// SubscribeMasternode subscribes to receive notifications when a masternode status changes. +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-headers-subscribe +func (s *Client) SubscribeMasternode(ctx context.Context, collateral string) (<-chan string, error) { + var resp basicResp + + err := s.request(ctx, "blockchain.masternode.subscribe", []interface{}{collateral}, &resp) + if err != nil { + return nil, err + } + + respChan := make(chan string, 1) + if len(resp.Result) > 0 { + respChan <- resp.Result + } + + go func() { + for msg := range s.listenPush("blockchain.masternode.subscribe") { + if msg.err != nil { + return + } + + var resp SubscribeNotif + + err := json.Unmarshal(msg.content, &resp) + if err != nil { + return + } + + for _, param := range resp.Params { + respChan <- param + } + } + }() + + return respChan, nil +} diff --git a/electrum/transaction.go b/electrum/transaction.go new file mode 100644 index 0000000..77cf184 --- /dev/null +++ b/electrum/transaction.go @@ -0,0 +1,157 @@ +package electrum + +import "context" + +// BroadcastTransaction sends a raw transaction to the remote server to +// be broadcasted on the server network. +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-transaction-broadcast +func (s *Client) BroadcastTransaction(ctx context.Context, rawTx string) (string, error) { + resp := &basicResp{} + err := s.request(ctx, "blockchain.transaction.broadcast", []interface{}{rawTx}, &resp) + if err != nil { + return "", err + } + + return resp.Result, nil +} + +// GetTransactionResp represents the response to GetTransaction(). +type GetTransactionResp struct { + Result *GetTransactionResult `json:"result"` +} + +// GetTransactionResult represents the content of the result field in the response to GetTransaction(). +type GetTransactionResult struct { + Blockhash string `json:"blockhash"` + Blocktime uint64 `json:"blocktime"` + Confirmations int32 `json:"confirmations"` + Hash string `json:"hash"` + Hex string `json:"hex"` + Locktime uint32 `json:"locktime"` + Size uint32 `json:"size"` + Time uint64 `json:"time"` + Version uint32 `json:"version"` + Vin []Vin `json:"vin"` + Vout []Vout `json:"vout"` + Merkle GetMerkleProofResult `json:"merkle,omitempty"` // For protocol v1.5 and up. +} + +// Vin represents the input side of a transaction. +type Vin struct { + Coinbase string `json:"coinbase"` + ScriptSig *ScriptSig `json:"scriptsig"` + Sequence uint32 `json:"sequence"` + TxID string `json:"txid"` + Vout uint32 `json:"vout"` +} + +// ScriptSig represents the signature script for that transaction input. +type ScriptSig struct { + Asm string `json:"asm"` + Hex string `json:"hex"` +} + +// Vout represents the output side of a transaction. +type Vout struct { + N uint32 `json:"n"` + ScriptPubkey ScriptPubkey `json:"scriptpubkey"` + Value float64 `json:"value"` +} + +// ScriptPubkey represents the script of that transaction output. +type ScriptPubkey struct { + Addresses []string `json:"addresses,omitempty"` + Asm string `json:"asm"` + Hex string `json:"hex,omitempty"` + ReqSigs uint32 `json:"reqsigs,omitempty"` + Type string `json:"type"` +} + +// GetTransaction gets the detailed information for a transaction. +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-transaction-get +func (s *Client) GetTransaction(ctx context.Context, txHash string) (*GetTransactionResult, error) { + var resp GetTransactionResp + + err := s.request(ctx, "blockchain.transaction.get", []interface{}{txHash, true}, &resp) + if err != nil { + return nil, err + } + + return resp.Result, nil +} + +// GetRawTransaction gets a raw encoded transaction. +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-transaction-get +func (s *Client) GetRawTransaction(ctx context.Context, txHash string) (string, error) { + var resp basicResp + + err := s.request(ctx, "blockchain.transaction.get", []interface{}{txHash, false}, &resp) + if err != nil { + return "", err + } + + return resp.Result, nil +} + +// GetMerkleProofResp represents the response to GetMerkleProof(). +type GetMerkleProofResp struct { + Result *GetMerkleProofResult `json:"result"` +} + +// GetMerkleProofResult represents the content of the result field in the response to GetMerkleProof(). +type GetMerkleProofResult struct { + Merkle []string `json:"merkle"` + Height uint32 `json:"block_height"` + Position uint32 `json:"pos"` +} + +// GetMerkleProof returns the merkle proof for a confirmed transaction. +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-transaction-get-merkle +func (s *Client) GetMerkleProof(ctx context.Context, txHash string, height uint32) (*GetMerkleProofResult, error) { + var resp GetMerkleProofResp + + err := s.request(ctx, "blockchain.transaction.get_merkle", []interface{}{txHash, height}, &resp) + if err != nil { + return nil, err + } + + return resp.Result, err +} + +// GetHashFromPosition returns the transaction hash for a specific position in a block. +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-transaction-id-from-pos +func (s *Client) GetHashFromPosition(ctx context.Context, height, position uint32) (string, error) { + var resp basicResp + + err := s.request(ctx, "blockchain.transaction.id_from_pos", []interface{}{height, position, false}, &resp) + if err != nil { + return "", err + } + + return resp.Result, err +} + +// GetMerkleProofFromPosResp represents the response to GetMerkleProofFromPosition(). +type GetMerkleProofFromPosResp struct { + Result *GetMerkleProofFromPosResult `json:"result"` +} + +// GetMerkleProofFromPosResult represents the content of the result field in the response +// to GetMerkleProofFromPosition(). +type GetMerkleProofFromPosResult struct { + Hash string `json:"tx_hash"` + Merkle []string `json:"merkle"` +} + +// GetMerkleProofFromPosition returns the merkle proof for a specific position in a block. +// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-transaction-id-from-pos +func (s *Client) GetMerkleProofFromPosition(ctx context.Context, height, position uint32) (*GetMerkleProofFromPosResult, error) { + var resp GetMerkleProofFromPosResp + + err := s.request(ctx, "blockchain.transaction.id_from_pos", []interface{}{height, position, true}, &resp) + if err != nil { + return nil, err + } + + return resp.Result, err +} diff --git a/electrum/transport.go b/electrum/transport.go new file mode 100644 index 0000000..78df46b --- /dev/null +++ b/electrum/transport.go @@ -0,0 +1,101 @@ +package electrum + +import ( + "bufio" + "context" + "crypto/tls" + "log" + "net" + "time" +) + +// TCPTransport store information about the TCP transport. +type TCPTransport struct { + conn net.Conn + responses chan []byte + errors chan error +} + +// NewTCPTransport opens a new TCP connection to the remote server. +func NewTCPTransport(ctx context.Context, addr string) (*TCPTransport, error) { + var d net.Dialer + + conn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return nil, err + } + + tcp := &TCPTransport{ + conn: conn, + responses: make(chan []byte), + errors: make(chan error), + } + + go tcp.listen() + + return tcp, nil +} + +// NewSSLTransport opens a new SSL connection to the remote server. +func NewSSLTransport(ctx context.Context, addr string, config *tls.Config) (*TCPTransport, error) { + dialer := tls.Dialer{ + NetDialer: &net.Dialer{}, + Config: config, + } + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return nil, err + } + + tcp := &TCPTransport{ + conn: conn, + responses: make(chan []byte), + errors: make(chan error), + } + + go tcp.listen() + + return tcp, nil +} + +func (t *TCPTransport) listen() { + defer t.conn.Close() + reader := bufio.NewReader(t.conn) + + for { + line, err := reader.ReadBytes(nl) + if err != nil { + t.errors <- err + break + } + if DebugMode { + log.Printf("%s [debug] %s -> %s", time.Now().Format("2006-01-02 15:04:05"), t.conn.RemoteAddr(), line) + } + + t.responses <- line + } +} + +// SendMessage sends a message to the remote server through the TCP transport. +func (t *TCPTransport) SendMessage(body []byte) error { + if DebugMode { + log.Printf("%s [debug] %s <- %s", time.Now().Format("2006-01-02 15:04:05"), t.conn.RemoteAddr(), body) + } + + _, err := t.conn.Write(body) + return err +} + +// Responses returns chan to TCP transport responses. +func (t *TCPTransport) Responses() <-chan []byte { + return t.responses +} + +// Errors returns chan to TCP transport errors. +func (t *TCPTransport) Errors() <-chan error { + return t.errors +} + +func (t *TCPTransport) Close() error { + return t.conn.Close() +} diff --git a/example/singleserver.go b/example/singleserver.go new file mode 100644 index 0000000..f028e8a --- /dev/null +++ b/example/singleserver.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + "log" + "time" + + "code.stevenpolley.net/steven/go-electrum/electrum" +) + +func main() { + client, err := electrum.NewClientTCP(context.Background(), "bch.imaginary.cash:50001") + + if err != nil { + log.Fatal(err) + } + + serverVer, protocolVer, err := client.ServerVersion(context.Background()) + if err != nil { + log.Fatal(err) + } + log.Printf("Server version: %s [Protocol %s]", serverVer, protocolVer) + + go func() { + for { + if err := client.Ping(context.Background()); err != nil { + log.Fatal(err) + } + time.Sleep(60 * time.Second) + } + }() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4faaeb3 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module code.stevenpolley.net/steven/go-electrum + +go 1.24.6 + +require ( + github.com/btcsuite/btcd v0.24.2 + github.com/btcsuite/btcd/btcutil v1.1.6 + github.com/checksum0/go-electrum v0.0.0-20220912200153-b862ac442cf9 + github.com/stretchr/testify v1.10.0 +) + +require ( + github.com/btcsuite/btcd/btcec/v2 v2.3.5 // indirect + github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect + github.com/btcsuite/btclog v1.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/sys v0.35.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..82cd2e0 --- /dev/null +++ b/go.sum @@ -0,0 +1,127 @@ +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= +github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= +github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY= +github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg= +github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= +github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= +github.com/btcsuite/btcd/btcec/v2 v2.3.5 h1:dpAlnAwmT1yIBm3exhT1/8iUSD98RDJM5vqJVQDQLiU= +github.com/btcsuite/btcd/btcec/v2 v2.3.5/go.mod h1:m22FrOAiuxl/tht9wIqAoGHcbnCCaPWyauO8y2LGGtQ= +github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= +github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= +github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= +github.com/btcsuite/btcd/btcutil v1.1.6 h1:zFL2+c3Lb9gEgqKNzowKUPQNb8jV7v5Oaodi/AYFd6c= +github.com/btcsuite/btcd/btcutil v1.1.6/go.mod h1:9dFymx8HpuLqBnsPELrImQeTQfKBQqzqGbbV3jK55aE= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btclog v1.0.0 h1:sEkpKJMmfGiyZjADwEIgB1NSwMyfdD1FB8v6+w1T0Ns= +github.com/btcsuite/btclog v1.0.0/go.mod h1:w7xnGOhwT3lmrS4H3b/D1XAXxvh+tbhUm8xeHN2y3TQ= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/checksum0/go-electrum v0.0.0-20220912200153-b862ac442cf9 h1:PEkrrCdN0F0wgeof+V8dwMabAYccVBgJfqysVdlT51U= +github.com/checksum0/go-electrum v0.0.0-20220912200153-b862ac442cf9/go.mod h1:EjLxYzaf/28gOdSRlifeLfjoOA6aUjtJZhwaZPnjL9c= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/media/logo.png b/media/logo.png new file mode 100644 index 0000000..bbdd128 Binary files /dev/null and b/media/logo.png differ