Add transaction accelerator (prioritisetransaction UI + API)

Full-stack feature for boosting transactions via bitcoind's
prioritisetransaction RPC:

Backend:
- New accelerator package with Accelerate, Cancel, MaxFeerate, List,
  and background Cleanup goroutine (removes confirmed/dropped txs)
- RPC wrappers: GetMempoolEntry, PrioritiseTransaction,
  GetRawMempoolVerbose, IsRPCError helpers
- SQLite boosted_txs table for persistence across restarts
- Revenue impact measured via getblocktemplate before/after comparison
- Hard cap at 2000 sat/vB; MaxFeerate uses fees.base (not modified)
  to ignore our own prior priority adjustments

Frontend:
- Rocket icon in header with 10s jiggle animation
- AcceleratorPage with flame-gradient border, txid input, feerate
  input, "Prioritize above all" button with spinner, boost list with
  cancel buttons, and dismissible error/success messages
- Hash-based routing (#/accelerator)
- SharesBar font size bump
This commit is contained in:
satoshi
2026-05-11 03:54:35 +03:00
parent 72890d900b
commit dc7da6ab19
12 changed files with 1247 additions and 25 deletions
+236
View File
@@ -0,0 +1,236 @@
// 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)
}
}
}
}