// Package accelerator manages transaction priority boosting via // bitcoind's prioritisetransaction RPC. package accelerator import ( "context" "database/sql" "errors" "fmt" "log/slog" "math" "time" "github.com/kamadopool/kamado-api/internal/bitcoind" "github.com/kamadopool/kamado-api/internal/store" ) // Service manages the lifecycle of boosted transactions. type Service struct { RPC *bitcoind.RPC Store *store.BlockStore Log *slog.Logger } func NewService(rpc *bitcoind.RPC, st *store.BlockStore, log *slog.Logger) *Service { return &Service{RPC: rpc, Store: st, Log: log} } // MaxFeerateVB is the hard cap to prevent accidental extreme boosts. const MaxFeerateVB = 2000.0 // AccelerateResult is returned on a successful boost. type AccelerateResult struct { Txid string `json:"txid"` OriginalFeerate float64 `json:"original_feerate"` BoostedFeerate float64 `json:"boosted_feerate"` FeeDelta int64 `json:"fee_delta"` Vsize int64 `json:"vsize"` // Actual revenue impact: coinbasevalue_before - coinbasevalue_after. // Zero means the mempool isn't full (no tx was displaced). // Negative means getblocktemplate failed (couldn't measure). FeeLostSats int64 `json:"fee_lost_sats"` FeeLostError string `json:"fee_lost_error,omitempty"` } // Accelerate boosts a transaction to the target feerate (sat/vB). func (s *Service) Accelerate(ctx context.Context, txid string, targetFeerateVB float64) (*AccelerateResult, error) { if targetFeerateVB > MaxFeerateVB { return nil, fmt.Errorf("target feerate %.1f exceeds maximum %d sat/vB", targetFeerateVB, int(MaxFeerateVB)) } entry, err := s.RPC.GetMempoolEntry(ctx, txid) if err != nil { return nil, fmt.Errorf("getmempoolentry: %w", err) } // Use base fee (real, not priority-adjusted) to compute current feerate. baseFeeSats := entry.Fees.Base * 1e8 currentFeerate := baseFeeSats / float64(entry.Vsize) // Delta is relative to the modified fee (which includes any prior boosts). modifiedFeeSats := entry.Fees.Modified * 1e8 targetFeeSats := targetFeerateVB * float64(entry.Vsize) delta := int64(math.Ceil(targetFeeSats - modifiedFeeSats)) if delta <= 0 { return nil, fmt.Errorf("target feerate %.2f sat/vB is not higher than current effective %.2f sat/vB", targetFeerateVB, modifiedFeeSats/float64(entry.Vsize)) } // Snapshot coinbasevalue BEFORE the boost to measure actual impact. var coinbaseBefore int64 var tplErr string tplBefore, err := s.RPC.GetBlockTemplate(ctx) if err != nil { tplErr = fmt.Sprintf("getblocktemplate (before): %s", bitcoind.RPCErrorMessage(err)) s.Log.Warn("accelerate: getblocktemplate failed (before)", "err", err) } else { coinbaseBefore = tplBefore.CoinbaseValue } if err := s.RPC.PrioritiseTransaction(ctx, txid, delta); err != nil { return nil, fmt.Errorf("prioritisetransaction: %w", err) } // Measure coinbasevalue AFTER — the difference is the real fee lost. var feeLost int64 if coinbaseBefore > 0 { tplAfter, err := s.RPC.GetBlockTemplate(ctx) if err != nil { tplErr = fmt.Sprintf("getblocktemplate (after): %s", bitcoind.RPCErrorMessage(err)) s.Log.Warn("accelerate: getblocktemplate failed (after)", "err", err) } else { feeLost = coinbaseBefore - tplAfter.CoinbaseValue if feeLost < 0 { feeLost = 0 // template improved (new tx arrived between calls) } s.Log.Info("accelerate: fee impact measured", "before", coinbaseBefore, "after", tplAfter.CoinbaseValue, "lost", feeLost) } } rec := store.BoostedTx{ Txid: txid, OriginalFeerate: currentFeerate, BoostedFeerate: targetFeerateVB, FeeDelta: delta, Vsize: entry.Vsize, BoostedAt: time.Now().Unix(), } if s.Store != nil { if err := s.Store.InsertBoostedTx(rec); err != nil { s.Log.Warn("failed to persist boosted tx", "txid", txid, "err", err) } } s.Log.Info("tx accelerated", "txid", txid, "delta_sats", delta, "original_rate", currentFeerate, "target_rate", targetFeerateVB, "fee_lost_sats", feeLost) return &AccelerateResult{ Txid: txid, OriginalFeerate: currentFeerate, BoostedFeerate: targetFeerateVB, FeeDelta: delta, Vsize: entry.Vsize, FeeLostSats: feeLost, FeeLostError: tplErr, }, nil } // Cancel reverses a previously applied priority boost. func (s *Service) Cancel(ctx context.Context, txid string) error { if s.Store == nil { return fmt.Errorf("no store available") } rec, err := s.Store.GetBoostedTx(txid) if err != nil { if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("transaction %s is not in the boosted list", txid) } return err } // Reverse the delta. if err := s.RPC.PrioritiseTransaction(ctx, txid, -rec.FeeDelta); err != nil { // If tx is no longer in mempool, the reversal is a no-op at the // bitcoind level but we still remove our record. if !bitcoind.IsRPCError(err, -5) { return fmt.Errorf("prioritisetransaction (cancel): %w", err) } } if err := s.Store.RemoveBoostedTx(txid); err != nil { return fmt.Errorf("remove record: %w", err) } s.Log.Info("tx acceleration cancelled", "txid", txid, "reversed_delta", rec.FeeDelta) return nil } // MaxFeerate returns the highest real feerate (ignoring priority // adjustments) in the mempool, doubled, capped at MaxFeerateVB. // Uses fees.base so previously-boosted txs don't skew the result. func (s *Service) MaxFeerate(ctx context.Context) (float64, error) { pool, err := s.RPC.GetRawMempoolVerbose(ctx) if err != nil { return 0, fmt.Errorf("getrawmempool: %w", err) } var maxRate float64 for _, entry := range pool { if entry.Vsize <= 0 { continue } // Use base fee (actual fee paid), not modified (includes priority boosts). rate := (entry.Fees.Base * 1e8) / float64(entry.Vsize) if rate > maxRate { maxRate = rate } } if maxRate == 0 { return 100, nil // sensible default if mempool is empty } result := math.Ceil(maxRate * 2) if result > MaxFeerateVB { result = MaxFeerateVB } return result, nil } // List returns all currently tracked boosted transactions. func (s *Service) List() ([]store.BoostedTx, error) { if s.Store == nil { return nil, nil } return s.Store.ListBoostedTxs() } // Cleanup periodically checks if boosted txs have left the mempool // (confirmed or evicted) and removes them from tracking. Run as a // background goroutine. func (s *Service) Cleanup(ctx context.Context) { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: s.cleanupOnce(ctx) } } } func (s *Service) cleanupOnce(ctx context.Context) { if s.Store == nil { return } txs, err := s.Store.ListBoostedTxs() if err != nil { s.Log.Warn("accelerator cleanup: list failed", "err", err) return } for _, tx := range txs { _, err := s.RPC.GetMempoolEntry(ctx, tx.Txid) if err != nil && bitcoind.IsRPCError(err, -5) { // Tx no longer in mempool — confirmed or dropped. if err := s.Store.RemoveBoostedTx(tx.Txid); err != nil { s.Log.Warn("accelerator cleanup: remove failed", "txid", tx.Txid, "err", err) } else { s.Log.Info("accelerator: tx left mempool, removed from tracking", "txid", tx.Txid) } } } }