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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,3 +276,73 @@ func (c *RPC) GetBlockTemplate(ctx context.Context) (*BlockTemplate, error) {
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ---- mempool / priority methods ----
|
||||
|
||||
// MempoolEntryFees mirrors the "fees" object inside getmempoolentry.
|
||||
type MempoolEntryFees struct {
|
||||
Base float64 `json:"base"`
|
||||
Modified float64 `json:"modified"`
|
||||
Ancestor float64 `json:"ancestor"`
|
||||
Descendant float64 `json:"descendant"`
|
||||
}
|
||||
|
||||
// MempoolEntry is the result of getmempoolentry <txid>.
|
||||
type MempoolEntry struct {
|
||||
Vsize int64 `json:"vsize"`
|
||||
Fees MempoolEntryFees `json:"fees"`
|
||||
}
|
||||
|
||||
// GetMempoolEntry returns the mempool entry for a transaction.
|
||||
// Returns rpcError code -5 if the tx is not in the mempool.
|
||||
func (c *RPC) GetMempoolEntry(ctx context.Context, txid string) (*MempoolEntry, error) {
|
||||
var out MempoolEntry
|
||||
if err := c.Call(ctx, "getmempoolentry", []any{txid}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// PrioritiseTransaction adjusts a transaction's apparent fee for block
|
||||
// template selection. feeDelta is in satoshis (positive = boost,
|
||||
// negative = de-prioritise).
|
||||
func (c *RPC) PrioritiseTransaction(ctx context.Context, txid string, feeDelta int64) error {
|
||||
var ok bool
|
||||
if err := c.Call(ctx, "prioritisetransaction", []any{txid, 0, feeDelta}, &ok); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RawMempoolEntry is the verbose form of a mempool entry from getrawmempool true.
|
||||
type RawMempoolEntry struct {
|
||||
Vsize int64 `json:"vsize"`
|
||||
Fees MempoolEntryFees `json:"fees"`
|
||||
}
|
||||
|
||||
// GetRawMempoolVerbose returns all mempool transactions with their details.
|
||||
func (c *RPC) GetRawMempoolVerbose(ctx context.Context) (map[string]RawMempoolEntry, error) {
|
||||
var out map[string]RawMempoolEntry
|
||||
if err := c.Call(ctx, "getrawmempool", []any{true}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IsRPCError checks if err is an rpcError with a specific code.
|
||||
func IsRPCError(err error, code int) bool {
|
||||
var rerr *rpcError
|
||||
if errors.As(err, &rerr) {
|
||||
return rerr.Code == code
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RPCErrorMessage extracts the message from an rpcError, or the error string.
|
||||
func RPCErrorMessage(err error) string {
|
||||
var rerr *rpcError
|
||||
if errors.As(err, &rerr) {
|
||||
return rerr.Message
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package store
|
||||
|
||||
// BoostedTx represents a transaction whose priority has been adjusted
|
||||
// via bitcoind's prioritisetransaction RPC.
|
||||
type BoostedTx struct {
|
||||
Txid string `json:"txid"`
|
||||
OriginalFeerate float64 `json:"original_feerate"` // sat/vB before boost
|
||||
BoostedFeerate float64 `json:"boosted_feerate"` // sat/vB after boost
|
||||
FeeDelta int64 `json:"fee_delta"` // sats applied
|
||||
Vsize int64 `json:"vsize"`
|
||||
BoostedAt int64 `json:"boosted_at"` // unix timestamp
|
||||
}
|
||||
|
||||
const acceleratorSchema = `
|
||||
CREATE TABLE IF NOT EXISTS boosted_txs (
|
||||
txid TEXT PRIMARY KEY,
|
||||
original_feerate REAL NOT NULL,
|
||||
boosted_feerate REAL NOT NULL,
|
||||
fee_delta INTEGER NOT NULL,
|
||||
vsize INTEGER NOT NULL,
|
||||
boosted_at INTEGER NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
// migrateAccelerator ensures the boosted_txs table exists.
|
||||
func (s *BlockStore) migrateAccelerator() error {
|
||||
_, err := s.db.Exec(acceleratorSchema)
|
||||
return err
|
||||
}
|
||||
|
||||
// InsertBoostedTx records a newly boosted transaction.
|
||||
func (s *BlockStore) InsertBoostedTx(tx BoostedTx) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT OR REPLACE INTO boosted_txs(txid, original_feerate, boosted_feerate, fee_delta, vsize, boosted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
tx.Txid, tx.OriginalFeerate, tx.BoostedFeerate, tx.FeeDelta, tx.Vsize, tx.BoostedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveBoostedTx deletes a boosted tx record (confirmed, dropped, or cancelled).
|
||||
func (s *BlockStore) RemoveBoostedTx(txid string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM boosted_txs WHERE txid = ?`, txid)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListBoostedTxs returns all currently boosted transactions, newest first.
|
||||
func (s *BlockStore) ListBoostedTxs() ([]BoostedTx, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT txid, original_feerate, boosted_feerate, fee_delta, vsize, boosted_at
|
||||
FROM boosted_txs ORDER BY boosted_at DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []BoostedTx
|
||||
for rows.Next() {
|
||||
var tx BoostedTx
|
||||
if err := rows.Scan(&tx.Txid, &tx.OriginalFeerate, &tx.BoostedFeerate, &tx.FeeDelta, &tx.Vsize, &tx.BoostedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, tx)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetBoostedTx returns a single boosted tx by txid, or nil if not found.
|
||||
func (s *BlockStore) GetBoostedTx(txid string) (*BoostedTx, error) {
|
||||
var tx BoostedTx
|
||||
err := s.db.QueryRow(
|
||||
`SELECT txid, original_feerate, boosted_feerate, fee_delta, vsize, boosted_at
|
||||
FROM boosted_txs WHERE txid = ?`, txid,
|
||||
).Scan(&tx.Txid, &tx.OriginalFeerate, &tx.BoostedFeerate, &tx.FeeDelta, &tx.Vsize, &tx.BoostedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tx, nil
|
||||
}
|
||||
@@ -109,6 +109,10 @@ func Open(path string) (*BlockStore, error) {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := s.migrateAccelerator(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("store: accelerator schema: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user