add new bitcoin electrum SPV provider
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
91
providers/bitcoinElectrum/bitcoin.go
Normal file
91
providers/bitcoinElectrum/bitcoin.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package bitcoinElectrum
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/btcutil/hdkeychain"
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
"github.com/btcsuite/btcd/txscript"
|
||||
)
|
||||
|
||||
// Derives a P2WPKH address at a specific index from a branch key.
|
||||
func deriveAddress(branchKey *hdkeychain.ExtendedKey, index uint32) (btcutil.Address, error) {
|
||||
childKey, err := branchKey.Derive(index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pubKey, err := childKey.ECPubKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Note: For zpub/vpub, use NewAddressWitnessPubKeyHash.
|
||||
// For ypub/upub, use NewAddressScriptHash with a P2WPKH script.
|
||||
// For xpub/tpub, use NewAddressPubKeyHash.
|
||||
return btcutil.NewAddressWitnessPubKeyHash(btcutil.Hash160(pubKey.SerializeCompressed()), &chaincfg.MainNetParams)
|
||||
}
|
||||
|
||||
// Converts a btcutil.Address to an Electrum scripthash.
|
||||
func addressToScriptHash(address btcutil.Address) (string, error) {
|
||||
script, err := txscript.PayToAddrScript(address)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := sha256.Sum256(script)
|
||||
// Reverse byte order for Electrum protocol
|
||||
for i, j := 0, len(hash)-1; i < j; i, j = i+1, j-1 {
|
||||
hash[i], hash[j] = hash[j], hash[i]
|
||||
}
|
||||
return hex.EncodeToString(hash[:]), nil
|
||||
}
|
||||
|
||||
// Scans a derivation branch (m/0 or m/1) for balances until the gap limit is reached.
|
||||
func scanBranch(masterKey *hdkeychain.ExtendedKey, branch uint32, gapLimit int, spvServer string) int64 {
|
||||
log.Printf("Scanning branch m/%d/k...", branch)
|
||||
|
||||
branchKey, err := masterKey.Derive(branch)
|
||||
if err != nil {
|
||||
log.Printf("Error deriving branch %d: %v", branch, err)
|
||||
return 0
|
||||
}
|
||||
|
||||
var branchTotalBalance int64
|
||||
unusedAddressCount := 0
|
||||
|
||||
for i := uint32(0); ; i++ {
|
||||
if unusedAddressCount >= gapLimit {
|
||||
log.Printf("Gap limit of %d reached on branch m/%d. Stopping scan.", gapLimit, branch)
|
||||
break
|
||||
}
|
||||
|
||||
// 3. Derive child address
|
||||
address, err := deriveAddress(branchKey, i)
|
||||
if err != nil {
|
||||
log.Printf("Could not derive address at index %d on branch %d: %v", i, branch, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 4. Get balance from Electrum server
|
||||
balance, err := getAddressBalance(spvServer, address)
|
||||
if err != nil {
|
||||
log.Printf("Error getting balance for %s: %v", address.EncodeAddress(), err)
|
||||
// On error, we can't know if it's used, so we continue scanning.
|
||||
unusedAddressCount = 0
|
||||
continue
|
||||
}
|
||||
|
||||
total := balance.Result.Confirmed + balance.Result.Unconfirmed
|
||||
if total > 0 {
|
||||
log.Printf("Found balance for m/%d/%d (%s): %.8f BTC", branch, i, address.EncodeAddress(), btcutil.Amount(total).ToBTC())
|
||||
atomic.AddInt64(&branchTotalBalance, total)
|
||||
unusedAddressCount = 0 // Reset gap counter on finding a used address
|
||||
} else {
|
||||
log.Printf("No balance for m/%d/%d (%s)", branch, i, address.EncodeAddress())
|
||||
unusedAddressCount++
|
||||
}
|
||||
}
|
||||
return branchTotalBalance
|
||||
}
|
63
providers/bitcoinElectrum/fiat.go
Normal file
63
providers/bitcoinElectrum/fiat.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package bitcoinElectrum
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const fiatConvertURL = "https://api.coingecko.com/api/v3/coins/bitcoin?localization=false&tickers=false&market_data=true&community_data=false&developer_data=false&sparkline=false"
|
||||
|
||||
type coinGeckoResponse struct {
|
||||
MarketData struct {
|
||||
CurrentPrice struct {
|
||||
CAD int `json:"cad"`
|
||||
} `json:"current_price"`
|
||||
} `json:"market_data"`
|
||||
}
|
||||
|
||||
func convertBTCToCAD(amount int, coinGeckoApiKey string) (int, error) {
|
||||
coinGeckoData := &coinGeckoResponse{}
|
||||
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("%s&x-cg-demo-api-key=%s", fiatConvertURL, coinGeckoApiKey), nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create new GET request: %v", err)
|
||||
}
|
||||
|
||||
client := http.Client{Transport: &http.Transport{ResponseHeaderTimeout: 15 * time.Second}}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("http GET request failed: %v", err)
|
||||
}
|
||||
|
||||
err = processResponse(res, coinGeckoData)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to process response: %v", err)
|
||||
}
|
||||
|
||||
return (amount * int(coinGeckoData.MarketData.CurrentPrice.CAD*1000)) / 100000000, nil // one BTC = one hundred million satoshi's
|
||||
}
|
||||
|
||||
// processResponse takes the body of an HTTP response, and either returns
|
||||
// the error code, or unmarshalls the JSON response, extracts
|
||||
// rate limit info, and places it into the object
|
||||
// output parameter. This function closes the response body after reading it.
|
||||
func processResponse(res *http.Response, out interface{}) error {
|
||||
body, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body: %v", err)
|
||||
}
|
||||
|
||||
if res.StatusCode != 200 && res.StatusCode != 201 {
|
||||
return fmt.Errorf("unexpected http status code '%d': %v", res.StatusCode, err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, out)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal response body: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
62
providers/bitcoinElectrum/providerImpl.go
Normal file
62
providers/bitcoinElectrum/providerImpl.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package bitcoinElectrum
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/hdkeychain"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
masterKey *hdkeychain.ExtendedKey
|
||||
ynabAccountID string // YNAB account ID this provider updates - all bitcoin addresses are summed up and mapped to this YNAB account
|
||||
spvServer string
|
||||
gapLimit int
|
||||
coinGeckoApiKey string
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string {
|
||||
return "Bitcoin - Electrum SPV"
|
||||
}
|
||||
|
||||
func (p *Provider) Configure() error {
|
||||
var err error
|
||||
zpub := os.Getenv("bitcoin_zpub")
|
||||
p.ynabAccountID = os.Getenv("bitcoin_ynab_account")
|
||||
p.spvServer = os.Getenv("bitcoin_spv_server")
|
||||
p.coinGeckoApiKey = os.Getenv("bitcoin_coingecko_api_key")
|
||||
p.gapLimit = 20
|
||||
if zpub == "" || p.ynabAccountID == "" || p.spvServer == "" || p.coinGeckoApiKey == "" {
|
||||
return fmt.Errorf("this account provider is not configured")
|
||||
}
|
||||
|
||||
p.masterKey, err = hdkeychain.NewKeyFromString(zpub)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to obtain master key from zpub: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Returns slices of account balances and mapped YNAB account IDs, along with an error
|
||||
func (p *Provider) GetBalances() ([]int, []string, error) {
|
||||
var totalSats int64
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// branch 0 = regular
|
||||
// branch 1 = change addresses
|
||||
for _, branch := range []uint32{0, 1} {
|
||||
wg.Add(1)
|
||||
go func(branch uint32) {
|
||||
defer wg.Done()
|
||||
branchSats := scanBranch(p.masterKey, branch, p.gapLimit, p.spvServer)
|
||||
atomic.AddInt64(&branchSats, totalSats)
|
||||
}(branch)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return nil, nil, nil
|
||||
}
|
76
providers/bitcoinElectrum/spv.go
Normal file
76
providers/bitcoinElectrum/spv.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package bitcoinElectrum
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
)
|
||||
|
||||
// --- Electrum JSON-RPC Structures ---
|
||||
type ElectrumRequest struct {
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params []interface{} `json:"params"`
|
||||
}
|
||||
|
||||
type ElectrumBalanceResponse struct {
|
||||
Result struct {
|
||||
Confirmed int64 `json:"confirmed"`
|
||||
Unconfirmed int64 `json:"unconfirmed"`
|
||||
} `json:"result"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// Connects to Electrum and queries the balance for a single address.
|
||||
func getAddressBalance(electrumServer string, address btcutil.Address) (*ElectrumBalanceResponse, error) {
|
||||
scripthash, err := addressToScriptHash(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not create scripthash: %w", err)
|
||||
}
|
||||
|
||||
// Connect to the server with SSL/TLS
|
||||
conf := &tls.Config{InsecureSkipVerify: true} // Use InsecureSkipVerify for public servers, but be aware of MITM risks
|
||||
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", electrumServer, conf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not connect to electrum server: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Prepare the JSON-RPC request
|
||||
request := ElectrumRequest{
|
||||
ID: 1,
|
||||
Method: "blockchain.scripthash.get_balance",
|
||||
Params: []interface{}{scripthash},
|
||||
}
|
||||
requestBytes, _ := json.Marshal(request)
|
||||
|
||||
// Send the request (with a newline delimiter)
|
||||
_, err = fmt.Fprintf(conn, "%s\n", requestBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
|
||||
// Read the response
|
||||
reader := bufio.NewReader(conn)
|
||||
responseLine, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var response ElectrumBalanceResponse
|
||||
if err := json.Unmarshal([]byte(responseLine), &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
if response.Error != nil {
|
||||
return nil, fmt.Errorf("electrum server error: %s", response.Error.Message)
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
Reference in New Issue
Block a user