bitcoind.go raw

   1  package main
   2  
   3  import (
   4  	"bytes"
   5  	"context"
   6  	"encoding/json"
   7  	"fmt"
   8  	"io"
   9  	"net/http"
  10  	"os"
  11  	"os/exec"
  12  	"path/filepath"
  13  	"strings"
  14  	"sync"
  15  	"sync/atomic"
  16  	"time"
  17  
  18  	"next.orly.dev/pkg/lol/chk"
  19  	"next.orly.dev/pkg/lol/log"
  20  )
  21  
  22  // Bitcoind manages a bitcoind child process and provides a JSON-RPC client.
  23  type Bitcoind struct {
  24  	cfg  *Config
  25  	cmd  *exec.Cmd
  26  	mu   sync.Mutex
  27  	done chan struct{}
  28  
  29  	// cached state from last getblockchaininfo call
  30  	lastInfo    atomic.Pointer[BlockchainInfo]
  31  	responsive  atomic.Bool
  32  }
  33  
  34  // BlockchainInfo holds the result of getblockchaininfo.
  35  type BlockchainInfo struct {
  36  	Chain                string  `json:"chain"`
  37  	Blocks               int64   `json:"blocks"`
  38  	Headers              int64   `json:"headers"`
  39  	VerificationProgress float64 `json:"verificationprogress"`
  40  	InitialBlockDownload bool    `json:"initialblockdownload"`
  41  	Pruned               bool    `json:"pruned"`
  42  	PruneHeight          int64   `json:"pruneheight"`
  43  	SizeOnDisk           int64   `json:"size_on_disk"`
  44  }
  45  
  46  // EstimateFeeResult holds the result of estimatesmartfee.
  47  type EstimateFeeResult struct {
  48  	FeeRate float64  `json:"feerate"`
  49  	Errors  []string `json:"errors"`
  50  	Blocks  int      `json:"blocks"`
  51  }
  52  
  53  // rpcResponse is the JSON-RPC response envelope.
  54  type rpcResponse struct {
  55  	Result json.RawMessage `json:"result"`
  56  	Error  *rpcError       `json:"error"`
  57  	ID     int             `json:"id"`
  58  }
  59  
  60  type rpcError struct {
  61  	Code    int    `json:"code"`
  62  	Message string `json:"message"`
  63  }
  64  
  65  func NewBitcoind(cfg *Config) *Bitcoind {
  66  	return &Bitcoind{
  67  		cfg:  cfg,
  68  		done: make(chan struct{}),
  69  	}
  70  }
  71  
  72  // Start launches the bitcoind process and begins polling for readiness.
  73  func (b *Bitcoind) Start(ctx context.Context) error {
  74  	args := b.buildArgs()
  75  	log.I.F("starting bitcoind: %s %s", b.cfg.Binary, strings.Join(args, " "))
  76  
  77  	b.cmd = exec.CommandContext(ctx, b.cfg.Binary, args...)
  78  	b.cmd.Stdout = os.Stdout
  79  	b.cmd.Stderr = os.Stderr
  80  
  81  	if err := b.cmd.Start(); err != nil {
  82  		return fmt.Errorf("failed to start bitcoind: %w", err)
  83  	}
  84  
  85  	log.I.F("bitcoind started with PID %d", b.cmd.Process.Pid)
  86  
  87  	// Monitor process exit
  88  	go func() {
  89  		err := b.cmd.Wait()
  90  		if err != nil {
  91  			log.W.F("bitcoind exited: %v", err)
  92  		} else {
  93  			log.I.F("bitcoind exited cleanly")
  94  		}
  95  		b.responsive.Store(false)
  96  		close(b.done)
  97  	}()
  98  
  99  	// Poll for RPC readiness
 100  	go b.pollLoop(ctx)
 101  
 102  	return nil
 103  }
 104  
 105  // Stop sends SIGINT to bitcoind and waits for clean shutdown.
 106  func (b *Bitcoind) Stop() {
 107  	b.mu.Lock()
 108  	defer b.mu.Unlock()
 109  
 110  	if b.cmd == nil || b.cmd.Process == nil {
 111  		return
 112  	}
 113  
 114  	log.I.F("stopping bitcoind (PID %d)", b.cmd.Process.Pid)
 115  
 116  	// Send SIGINT for graceful shutdown
 117  	if err := b.cmd.Process.Signal(os.Interrupt); err != nil {
 118  		log.W.F("failed to send SIGINT to bitcoind: %v", err)
 119  		return
 120  	}
 121  
 122  	// Wait up to 30 seconds for clean exit
 123  	select {
 124  	case <-b.done:
 125  		log.I.F("bitcoind stopped")
 126  	case <-time.After(30 * time.Second):
 127  		log.W.F("bitcoind did not stop in 30s, sending SIGKILL")
 128  		b.cmd.Process.Kill()
 129  		<-b.done
 130  	}
 131  }
 132  
 133  // IsResponsive returns whether bitcoind is answering RPC calls.
 134  func (b *Bitcoind) IsResponsive() bool {
 135  	return b.responsive.Load()
 136  }
 137  
 138  // IsSynced returns whether bitcoind has finished initial block download.
 139  func (b *Bitcoind) IsSynced() bool {
 140  	info := b.lastInfo.Load()
 141  	return info != nil && !info.InitialBlockDownload
 142  }
 143  
 144  // GetBlockchainInfo returns the last cached blockchain info.
 145  func (b *Bitcoind) GetBlockchainInfo() *BlockchainInfo {
 146  	return b.lastInfo.Load()
 147  }
 148  
 149  // EstimateFee calls estimatesmartfee on bitcoind.
 150  func (b *Bitcoind) EstimateFee(confTarget int) (*EstimateFeeResult, error) {
 151  	raw, err := b.rpcCall("estimatesmartfee", []interface{}{confTarget})
 152  	if err != nil {
 153  		return nil, err
 154  	}
 155  	var result EstimateFeeResult
 156  	if err := json.Unmarshal(raw, &result); err != nil {
 157  		return nil, fmt.Errorf("failed to parse estimatesmartfee response: %w", err)
 158  	}
 159  	return &result, nil
 160  }
 161  
 162  func (b *Bitcoind) buildArgs() []string {
 163  	args := []string{
 164  		fmt.Sprintf("-datadir=%s", b.cfg.DataDir),
 165  		fmt.Sprintf("-rpcport=%d", b.cfg.RPCPort),
 166  		"-server=1",
 167  		"-rpcallowip=127.0.0.1",
 168  		"-rpcbind=127.0.0.1",
 169  	}
 170  
 171  	if b.cfg.PruneSize > 0 {
 172  		args = append(args, fmt.Sprintf("-prune=%d", b.cfg.PruneSize))
 173  	}
 174  
 175  	switch b.cfg.Network {
 176  	case "testnet":
 177  		args = append(args, "-testnet")
 178  	case "signet":
 179  		args = append(args, "-signet")
 180  	case "regtest":
 181  		args = append(args, "-regtest")
 182  	}
 183  
 184  	if b.cfg.RPCUser != "" {
 185  		args = append(args, fmt.Sprintf("-rpcuser=%s", b.cfg.RPCUser))
 186  	}
 187  	if b.cfg.RPCPass != "" {
 188  		args = append(args, fmt.Sprintf("-rpcpassword=%s", b.cfg.RPCPass))
 189  	}
 190  
 191  	return args
 192  }
 193  
 194  // pollLoop continuously polls bitcoind for status.
 195  func (b *Bitcoind) pollLoop(ctx context.Context) {
 196  	ticker := time.NewTicker(5 * time.Second)
 197  	defer ticker.Stop()
 198  
 199  	for {
 200  		select {
 201  		case <-ctx.Done():
 202  			return
 203  		case <-b.done:
 204  			return
 205  		case <-ticker.C:
 206  			b.updateStatus()
 207  		}
 208  	}
 209  }
 210  
 211  func (b *Bitcoind) updateStatus() {
 212  	raw, err := b.rpcCall("getblockchaininfo", nil)
 213  	if err != nil {
 214  		if b.responsive.Load() {
 215  			log.W.F("bitcoind became unresponsive: %v", err)
 216  		}
 217  		b.responsive.Store(false)
 218  		return
 219  	}
 220  
 221  	var info BlockchainInfo
 222  	if err := json.Unmarshal(raw, &info); err != nil {
 223  		log.W.F("failed to parse getblockchaininfo: %v", err)
 224  		return
 225  	}
 226  
 227  	wasResponsive := b.responsive.Load()
 228  	b.responsive.Store(true)
 229  	b.lastInfo.Store(&info)
 230  
 231  	if !wasResponsive {
 232  		log.I.F("bitcoind is responsive (chain=%s blocks=%d headers=%d progress=%.4f)",
 233  			info.Chain, info.Blocks, info.Headers, info.VerificationProgress)
 234  	}
 235  }
 236  
 237  // rpcCall makes a JSON-RPC call to bitcoind.
 238  func (b *Bitcoind) rpcCall(method string, params interface{}) (json.RawMessage, error) {
 239  	if params == nil {
 240  		params = []interface{}{}
 241  	}
 242  
 243  	body, err := json.Marshal(map[string]interface{}{
 244  		"jsonrpc": "1.0",
 245  		"id":      1,
 246  		"method":  method,
 247  		"params":  params,
 248  	})
 249  	if err != nil {
 250  		return nil, fmt.Errorf("failed to marshal RPC request: %w", err)
 251  	}
 252  
 253  	url := fmt.Sprintf("http://127.0.0.1:%d/", b.cfg.RPCPort)
 254  
 255  	req, err := http.NewRequest("POST", url, bytes.NewReader(body))
 256  	if err != nil {
 257  		return nil, err
 258  	}
 259  	req.Header.Set("Content-Type", "application/json")
 260  
 261  	// Use cookie auth if no explicit credentials
 262  	if b.cfg.RPCUser != "" {
 263  		req.SetBasicAuth(b.cfg.RPCUser, b.cfg.RPCPass)
 264  	} else {
 265  		user, pass, err := b.readCookieAuth()
 266  		if err != nil {
 267  			return nil, fmt.Errorf("no RPC credentials and cookie auth failed: %w", err)
 268  		}
 269  		req.SetBasicAuth(user, pass)
 270  	}
 271  
 272  	client := &http.Client{Timeout: 10 * time.Second}
 273  	resp, err := client.Do(req)
 274  	if err != nil {
 275  		return nil, err
 276  	}
 277  	defer resp.Body.Close()
 278  
 279  	respBody, err := io.ReadAll(resp.Body)
 280  	if err != nil {
 281  		return nil, fmt.Errorf("failed to read RPC response: %w", err)
 282  	}
 283  
 284  	var rpcResp rpcResponse
 285  	if err := json.Unmarshal(respBody, &rpcResp); err != nil {
 286  		return nil, fmt.Errorf("failed to parse RPC response: %w", err)
 287  	}
 288  
 289  	if rpcResp.Error != nil {
 290  		return nil, fmt.Errorf("RPC error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message)
 291  	}
 292  
 293  	return rpcResp.Result, nil
 294  }
 295  
 296  // readCookieAuth reads the .cookie file from the bitcoind data directory.
 297  func (b *Bitcoind) readCookieAuth() (user, pass string, err error) {
 298  	cookiePath := filepath.Join(b.cfg.DataDir, ".cookie")
 299  
 300  	// Testnet/signet/regtest use subdirectories
 301  	switch b.cfg.Network {
 302  	case "testnet":
 303  		cookiePath = filepath.Join(b.cfg.DataDir, "testnet3", ".cookie")
 304  	case "signet":
 305  		cookiePath = filepath.Join(b.cfg.DataDir, "signet", ".cookie")
 306  	case "regtest":
 307  		cookiePath = filepath.Join(b.cfg.DataDir, "regtest", ".cookie")
 308  	}
 309  
 310  	data, err := os.ReadFile(cookiePath)
 311  	if err != nil {
 312  		return "", "", fmt.Errorf("failed to read cookie file %s: %w", cookiePath, err)
 313  	}
 314  
 315  	parts := strings.SplitN(strings.TrimSpace(string(data)), ":", 2)
 316  	if len(parts) != 2 {
 317  		return "", "", fmt.Errorf("invalid cookie file format")
 318  	}
 319  
 320  	_ = chk.E // suppress unused import if linter complains
 321  	return parts[0], parts[1], nil
 322  }
 323