diff --git a/api/internal/accelerator/accelerator.go b/api/internal/accelerator/accelerator.go index 982aca5..032cccb 100644 --- a/api/internal/accelerator/accelerator.go +++ b/api/internal/accelerator/accelerator.go @@ -43,6 +43,54 @@ type AccelerateResult struct { 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 { @@ -68,38 +116,26 @@ func (s *Service) Accelerate(ctx context.Context, txid string, targetFeerateVB f targetFeerateVB, modifiedFeeSats/float64(entry.Vsize)) } - // Snapshot coinbasevalue BEFORE the boost to measure actual impact. - var coinbaseBefore int64 + // 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 - tplBefore, err := s.RPC.GetBlockTemplate(ctx) + tpl, 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) + tplErr = fmt.Sprintf("getblocktemplate: %s", bitcoind.RPCErrorMessage(err)) + s.Log.Warn("accelerate: getblocktemplate failed", "err", err) } else { - coinbaseBefore = tplBefore.CoinbaseValue + 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) } - // 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, diff --git a/api/internal/bitcoind/rpc.go b/api/internal/bitcoind/rpc.go index de5042e..ccd7d61 100644 --- a/api/internal/bitcoind/rpc.go +++ b/api/internal/bitcoind/rpc.go @@ -273,11 +273,19 @@ func (c *RPC) GetNetworkHashPS(ctx context.Context, blocks, height int) (float64 return out, nil } +// TemplateTx is one transaction inside a getblocktemplate result. +type TemplateTx struct { + Txid string `json:"txid"` + Fee int64 `json:"fee"` // satoshis + Weight int64 `json:"weight"` // weight units +} + // BlockTemplate is the subset of getblocktemplate we care about: the // coinbase value (subsidy + fees) and height of the next block. type BlockTemplate struct { - CoinbaseValue int64 `json:"coinbasevalue"` // satoshis - Height int64 `json:"height"` + CoinbaseValue int64 `json:"coinbasevalue"` // satoshis + Height int64 `json:"height"` + Transactions []TemplateTx `json:"transactions"` } // GetBlockTemplate fetches the next block template with segwit rules. diff --git a/ui/src/lib/AcceleratorPage.svelte b/ui/src/lib/AcceleratorPage.svelte index 362950a..fdf9892 100644 --- a/ui/src/lib/AcceleratorPage.svelte +++ b/ui/src/lib/AcceleratorPage.svelte @@ -69,7 +69,7 @@ } else if (feeLostSats > 0) { success = `Boosted from ${from} to ${to} sat/vB. Revenue impact: -${feeLostSats.toLocaleString()} sats (${(feeLostSats / 1e8).toFixed(8)} BTC) per block mined.`; } else { - success = `Boosted from ${from} to ${to} sat/vB. No revenue impact — the displaced transaction's fee was equal or lower.`; + success = `Boosted from ${from} to ${to} sat/vB. No revenue impact — the transaction was already in the template or the mempool fits in one block.`; } txid = ""; feerateInput = ""; @@ -169,9 +169,9 @@ mempool isn't full (all transactions already fit), there is zero cost.

- Revenue impact: After boosting, the server compares the block template's - coinbase value before and after. The difference (if any) is the actual fee revenue - lost per block mined. This is shown after each boost. + Revenue impact: The server inspects the current block template to find + the marginal transaction — the lowest fee-rate tx that would be displaced by the + boosted one. Its fee is the revenue you sacrifice per block mined, shown after each boost.

Your pool: {formatHashrate(poolHashrate)} /