Files
satoshi 83d70b2636 Fix accelerator revenue impact always showing zero
The old approach compared coinbasevalue from two getblocktemplate
calls, but new mempool arrivals between calls masked the displacement
loss. Now uses a single template snapshot to find the marginal
(lowest fee-rate) transaction that would be displaced and reports
its fee as the revenue cost.
2026-05-27 14:23:05 +03:00

273 lines
8.1 KiB
Go

// 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"`
}
// marginalFeeLost estimates the fee revenue lost by inserting a boosted
// transaction into the template. It finds the lowest fee-rate transaction
// in the current template (the one that would be displaced) and returns
// the difference: displaced_fee - boosted_tx_real_fee. If the boosted tx
// is already in the template or the template has room, returns 0.
func marginalFeeLost(tpl *bitcoind.BlockTemplate, txid string, txVsize int64) int64 {
if len(tpl.Transactions) == 0 {
return 0
}
// Check if the tx is already in the template (nothing displaced).
for _, tx := range tpl.Transactions {
if tx.Txid == txid {
return 0
}
}
// Find the marginal transaction: lowest fee-rate in the template.
var marginal *bitcoind.TemplateTx
var marginalRate float64 = math.MaxFloat64
for i := range tpl.Transactions {
tx := &tpl.Transactions[i]
// Weight to vsize: ceil(weight/4)
vsize := (tx.Weight + 3) / 4
if vsize <= 0 {
continue
}
rate := float64(tx.Fee) / float64(vsize)
if rate < marginalRate {
marginalRate = rate
marginal = tx
}
}
if marginal == nil {
return 0
}
// The displaced tx's fee is the revenue we lose. We don't add the
// boosted tx's real fee back because the pool never had it — the tx
// wasn't in the template before the boost.
lost := marginal.Fee
if lost < 0 {
lost = 0
}
return lost
}
// 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))
}
// Estimate revenue impact from the pre-boost template. The boosted tx
// will displace the marginal (lowest fee-rate) transaction in the
// template. We compute this from the single template snapshot to avoid
// a race with new mempool arrivals between two getblocktemplate calls.
var feeLost int64
var tplErr string
tpl, err := s.RPC.GetBlockTemplate(ctx)
if err != nil {
tplErr = fmt.Sprintf("getblocktemplate: %s", bitcoind.RPCErrorMessage(err))
s.Log.Warn("accelerate: getblocktemplate failed", "err", err)
} else {
feeLost = marginalFeeLost(tpl, txid, entry.Vsize)
s.Log.Info("accelerate: fee impact estimated",
"marginal_fee_lost", feeLost, "boosted_tx_fee", int64(baseFeeSats))
}
if err := s.RPC.PrioritiseTransaction(ctx, txid, delta); err != nil {
return nil, fmt.Errorf("prioritisetransaction: %w", err)
}
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)
}
}
}
}