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
+106
View File
@@ -0,0 +1,106 @@
package httpapi
import (
"encoding/json"
"net/http"
"regexp"
"github.com/kamadopool/kamado-api/internal/bitcoind"
"github.com/kamadopool/kamado-api/internal/store"
)
var txidRE = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
type accelerateReq struct {
Txid string `json:"txid"`
FeerateVB float64 `json:"fee_rate_satvb"`
}
type cancelReq struct {
Txid string `json:"txid"`
}
func (s *Server) accelerate(w http.ResponseWriter, r *http.Request) {
if s.Acc == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "accelerator not available (no store)"})
return
}
var req accelerateReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON: " + err.Error()})
return
}
if !txidRE.MatchString(req.Txid) {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid txid: must be 64 hex characters"})
return
}
if req.FeerateVB <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "fee_rate_satvb must be positive"})
return
}
if req.FeerateVB > 2000 {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "fee_rate_satvb cannot exceed 2000 sat/vB"})
return
}
result, err := s.Acc.Accelerate(r.Context(), req.Txid, req.FeerateVB)
if err != nil {
status := http.StatusInternalServerError
if bitcoind.IsRPCError(err, -5) {
status = http.StatusNotFound
}
writeJSON(w, status, map[string]any{"error": bitcoind.RPCErrorMessage(err)})
return
}
writeJSON(w, http.StatusOK, result)
}
func (s *Server) accelerateCancel(w http.ResponseWriter, r *http.Request) {
if s.Acc == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "accelerator not available"})
return
}
var req cancelReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON: " + err.Error()})
return
}
if !txidRE.MatchString(req.Txid) {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid txid"})
return
}
if err := s.Acc.Cancel(r.Context(), req.Txid); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) accelerateMax(w http.ResponseWriter, r *http.Request) {
if s.Acc == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "accelerator not available"})
return
}
rate, err := s.Acc.MaxFeerate(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"max_feerate_satvb": rate})
}
func (s *Server) accelerateList(w http.ResponseWriter, r *http.Request) {
if s.Acc == nil {
writeJSON(w, http.StatusOK, []any{})
return
}
txs, err := s.Acc.List()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
if txs == nil {
txs = []store.BoostedTx{}
}
writeJSON(w, http.StatusOK, txs)
}
+8 -2
View File
@@ -10,18 +10,20 @@ import (
"net/http"
"strings"
"github.com/kamadopool/kamado-api/internal/accelerator"
"github.com/kamadopool/kamado-api/internal/state"
"github.com/kamadopool/kamado-api/internal/webui"
)
type Server struct {
Agg *state.Aggregator
Acc *accelerator.Service
Hub *Hub
Log *slog.Logger
}
func New(agg *state.Aggregator, log *slog.Logger) *Server {
return &Server{Agg: agg, Hub: NewHub(), Log: log}
func New(agg *state.Aggregator, acc *accelerator.Service, log *slog.Logger) *Server {
return &Server{Agg: agg, Acc: acc, Hub: NewHub(), Log: log}
}
// Handler returns an http.Handler with all kamado routes mounted under
@@ -39,6 +41,10 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/ws", s.handleWS)
mux.HandleFunc("GET /api/admin/debug-blocks", s.debugBlocks)
mux.HandleFunc("POST /api/admin/reset-latency", s.resetLatency)
mux.HandleFunc("POST /api/accelerate", s.accelerate)
mux.HandleFunc("POST /api/accelerate/cancel", s.accelerateCancel)
mux.HandleFunc("POST /api/accelerate/max", s.accelerateMax)
mux.HandleFunc("GET /api/accelerate/list", s.accelerateList)
mux.Handle("/", spaHandler(webui.FS()))
return mux
}