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.
273 lines
8.1 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|