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