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
+12 -1
View File
@@ -18,6 +18,7 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/kamadopool/kamado-api/internal/accelerator"
"github.com/kamadopool/kamado-api/internal/bitcoind" "github.com/kamadopool/kamado-api/internal/bitcoind"
"github.com/kamadopool/kamado-api/internal/ckpool" "github.com/kamadopool/kamado-api/internal/ckpool"
"github.com/kamadopool/kamado-api/internal/config" "github.com/kamadopool/kamado-api/internal/config"
@@ -65,10 +66,16 @@ func main() {
agg.Store = blockStore agg.Store = blockStore
agg.MempoolBaseURL = cfg.MempoolBaseURL agg.MempoolBaseURL = cfg.MempoolBaseURL
// Transaction accelerator (prioritisetransaction).
var accSvc *accelerator.Service
if blockStore != nil {
accSvc = accelerator.NewService(rpc, blockStore, log)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
api := httpapi.New(agg, log) api := httpapi.New(agg, accSvc, log)
// Wire snapshot refreshes into the WebSocket hub so subscribers get // Wire snapshot refreshes into the WebSocket hub so subscribers get
// real-time updates without polling. // real-time updates without polling.
@@ -130,6 +137,10 @@ func main() {
go agg.IngestAttemptEvents(ctx, tailer.Attempts) go agg.IngestAttemptEvents(ctx, tailer.Attempts)
go agg.IngestLatencyEvents(ctx, tailer.Latencies) go agg.IngestLatencyEvents(ctx, tailer.Latencies)
if accSvc != nil {
go accSvc.Cleanup(ctx)
}
srv := &http.Server{ srv := &http.Server{
Addr: cfg.ListenAddr, Addr: cfg.ListenAddr,
Handler: api.Handler(), Handler: api.Handler(),
+236
View File
@@ -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)
}
}
}
}
+70
View File
@@ -276,3 +276,73 @@ func (c *RPC) GetBlockTemplate(ctx context.Context) (*BlockTemplate, error) {
} }
return &out, nil 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()
}
+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" "net/http"
"strings" "strings"
"github.com/kamadopool/kamado-api/internal/accelerator"
"github.com/kamadopool/kamado-api/internal/state" "github.com/kamadopool/kamado-api/internal/state"
"github.com/kamadopool/kamado-api/internal/webui" "github.com/kamadopool/kamado-api/internal/webui"
) )
type Server struct { type Server struct {
Agg *state.Aggregator Agg *state.Aggregator
Acc *accelerator.Service
Hub *Hub Hub *Hub
Log *slog.Logger Log *slog.Logger
} }
func New(agg *state.Aggregator, log *slog.Logger) *Server { func New(agg *state.Aggregator, acc *accelerator.Service, log *slog.Logger) *Server {
return &Server{Agg: agg, Hub: NewHub(), Log: log} return &Server{Agg: agg, Acc: acc, Hub: NewHub(), Log: log}
} }
// Handler returns an http.Handler with all kamado routes mounted under // 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/ws", s.handleWS)
mux.HandleFunc("GET /api/admin/debug-blocks", s.debugBlocks) mux.HandleFunc("GET /api/admin/debug-blocks", s.debugBlocks)
mux.HandleFunc("POST /api/admin/reset-latency", s.resetLatency) 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())) mux.Handle("/", spaHandler(webui.FS()))
return mux return mux
} }
+79
View File
@@ -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
}
+4
View File
@@ -109,6 +109,10 @@ func Open(path string) (*BlockStore, error) {
_ = db.Close() _ = db.Close()
return nil, err return nil, err
} }
if err := s.migrateAccelerator(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("store: accelerator schema: %w", err)
}
return s, nil return s, nil
} }
+3
View File
@@ -11,6 +11,7 @@
import BestShares from "./lib/BestShares.svelte"; import BestShares from "./lib/BestShares.svelte";
import UserDetailPage from "./lib/UserDetailPage.svelte"; import UserDetailPage from "./lib/UserDetailPage.svelte";
import WorkerDetailPage from "./lib/WorkerDetailPage.svelte"; import WorkerDetailPage from "./lib/WorkerDetailPage.svelte";
import AcceleratorPage from "./lib/AcceleratorPage.svelte";
import SharesBar from "./lib/SharesBar.svelte"; import SharesBar from "./lib/SharesBar.svelte";
import BlockFoundAnimation from "./lib/BlockFoundAnimation.svelte"; import BlockFoundAnimation from "./lib/BlockFoundAnimation.svelte";
@@ -34,6 +35,8 @@
<div class="stat-sub bad">{snap.error}</div> <div class="stat-sub bad">{snap.error}</div>
{/if} {/if}
</div> </div>
{:else if selection.page === "accelerator"}
<AcceleratorPage />
{:else if selection.worker} {:else if selection.worker}
<WorkerDetailPage /> <WorkerDetailPage />
{:else if selection.user} {:else if selection.user}
+650
View File
@@ -0,0 +1,650 @@
<script lang="ts">
import { clearSelection } from "../stores/selection.svelte";
import { snap } from "../stores/snapshot.svelte";
import { formatHashrate } from "../format";
type BoostedTx = {
txid: string;
original_feerate: number;
boosted_feerate: number;
fee_delta: number;
vsize: number;
boosted_at: number;
};
let txid = $state("");
let feerateInput = $state("");
let loading = $state(false);
let error = $state("");
let success = $state("");
let feeLostSats = $state(0);
let boostedList = $state<BoostedTx[]>([]);
let loadingMax = $state(false);
const feerate = $derived(parseFloat(feerateInput) || 0);
const validTxid = $derived(/^[0-9a-fA-F]{64}$/.test(txid.trim()));
const overCap = $derived(feerate > 2000);
const explorerBase = $derived(
snap.data?.mempool_base_url || "https://mempool.space"
);
const poolHashrate = $derived(snap.data?.hashrate_hs_5m ?? 0);
const networkHashrate = $derived(snap.data?.network_hashrate_hs ?? 0);
const poolShare = $derived(
networkHashrate > 0 ? poolHashrate / networkHashrate : 0
);
async function fetchList() {
try {
const res = await fetch("/api/accelerate/list");
if (res.ok) {
boostedList = await res.json();
}
} catch { /* ignore */ }
}
async function handleBoost() {
if (!validTxid || feerate <= 0 || overCap) return;
loading = true;
error = "";
success = "";
feeLostSats = 0;
try {
const res = await fetch("/api/accelerate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ txid: txid.trim().toLowerCase(), fee_rate_satvb: feerate }),
});
const data = await res.json();
if (!res.ok) {
error = data.error || `HTTP ${res.status}`;
} else {
feeLostSats = data.fee_lost_sats ?? 0;
const from = data.original_feerate.toFixed(1);
const to = data.boosted_feerate.toFixed(1);
if (data.fee_lost_error) {
success = `Boosted from ${from} to ${to} sat/vB. Revenue impact could not be measured: ${data.fee_lost_error}`;
} 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.`;
}
txid = "";
feerateInput = "";
fetchList();
}
} catch (e) {
error = (e as Error).message;
}
loading = false;
}
async function handleMaxPriority() {
loadingMax = true;
error = "";
try {
const res = await fetch("/api/accelerate/max", { method: "POST" });
const data = await res.json();
if (!res.ok) {
error = data.error || `HTTP ${res.status}`;
} else {
feerateInput = data.max_feerate_satvb.toFixed(1);
}
} catch (e) {
error = (e as Error).message;
}
loadingMax = false;
}
async function handleCancel(cancelTxid: string) {
error = "";
try {
const res = await fetch("/api/accelerate/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ txid: cancelTxid }),
});
const data = await res.json();
if (!res.ok) {
error = data.error || `HTTP ${res.status}`;
} else {
boostedList = boostedList.filter(t => t.txid !== cancelTxid);
}
} catch (e) {
error = (e as Error).message;
}
}
function fmtAgo(unix: number): string {
const diff = Math.floor(Date.now() / 1000 - unix);
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
function onKey(ev: KeyboardEvent): void {
if (ev.key === "Escape") clearSelection();
}
$effect(() => { fetchList(); });
$effect(() => {
const id = setInterval(fetchList, 15000);
return () => clearInterval(id);
});
</script>
<svelte:window onkeydown={onKey} />
<section class="page">
<nav class="crumbs">
<button type="button" class="back" onclick={clearSelection}>
&larr; Back to dashboard
</button>
</nav>
<header class="accel-header">
<div class="flame-border" aria-hidden="true"></div>
<div class="header-content">
<h2 class="title">
<svg class="rocket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
<path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/>
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>
</svg>
Transaction Accelerator
</h2>
<p class="explainer">
Boost a transaction's priority in your next block template using
<code>prioritisetransaction</code>. When your pool finds a block,
the boosted transaction will be included regardless of its real feerate.
</p>
<div class="info-box">
<p>
<strong>How it works:</strong> The boosted transaction takes the place of
the lowest-feerate transaction at the bottom of the block template. If the
mempool isn't full (all transactions already fit), there is <em>zero cost</em>.
</p>
<p>
<strong>Revenue impact:</strong> 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.
</p>
<p>
Your pool: {formatHashrate(poolHashrate)} /
{formatHashrate(networkHashrate)} network
({poolShare > 0 ? `${(poolShare * 100).toFixed(6)}%` : "—"} of blocks).
</p>
</div>
</div>
</header>
<section class="card boost-form">
<h3>Boost a Transaction</h3>
<div class="field">
<label for="txid-input">Transaction ID</label>
<input
id="txid-input"
type="text"
class="mono"
placeholder="64-character hex txid"
bind:value={txid}
maxlength={64}
spellcheck={false}
/>
{#if txid && !validTxid}
<span class="field-error">Must be exactly 64 hex characters</span>
{/if}
</div>
<div class="field">
<label for="feerate-input">Target feerate (sat/vB) — max 2,000</label>
<div class="feerate-row">
<input
id="feerate-input"
type="number"
min="1"
max="2000"
step="0.1"
placeholder="e.g. 50"
bind:value={feerateInput}
/>
<button
type="button"
class="max-btn"
onclick={handleMaxPriority}
disabled={loadingMax}
title="Set feerate to 2x the highest in mempool (capped at 2000)"
>
{#if loadingMax}
<span class="spinner small"></span> Loading...
{:else}
Prioritize above all
{/if}
</button>
</div>
{#if overCap}
<span class="field-error">Cannot exceed 2,000 sat/vB</span>
{/if}
</div>
<button
type="button"
class="boost-btn"
onclick={handleBoost}
disabled={loading || !validTxid || feerate <= 0 || overCap}
>
{#if loading}
<span class="spinner"></span> Boosting...
{:else}
Boost Transaction
{/if}
</button>
{#if error}
<div class="msg error">
<span>{error}</span>
<button type="button" class="dismiss" onclick={() => error = ""}>&times;</button>
</div>
{/if}
{#if success}
<div class="msg success">
<span>{success}</span>
<button type="button" class="dismiss" onclick={() => success = ""}>&times;</button>
</div>
{/if}
</section>
<section class="card">
<h3>Boosted Transactions {#if boostedList.length > 0}<span class="count">{boostedList.length}</span>{/if}</h3>
{#if boostedList.length === 0}
<div class="empty">No transactions currently boosted. They are automatically removed when confirmed or dropped from the mempool.</div>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Transaction</th>
<th class="num">Original</th>
<th class="num">Boosted to</th>
<th class="num">Delta</th>
<th class="num">vsize</th>
<th>When</th>
<th></th>
</tr>
</thead>
<tbody>
{#each boostedList as tx (tx.txid)}
<tr>
<td>
<a
class="txid-link mono"
href="{explorerBase}/tx/{tx.txid}"
target="_blank"
rel="noopener noreferrer"
title={tx.txid}
>{tx.txid.slice(0, 10)}...{tx.txid.slice(-10)}</a>
</td>
<td class="num">{tx.original_feerate.toFixed(1)}<span class="unit"> sat/vB</span></td>
<td class="num boost-rate">{tx.boosted_feerate.toFixed(1)}<span class="unit"> sat/vB</span></td>
<td class="num delta">{tx.fee_delta.toLocaleString()}<span class="unit"> sats</span></td>
<td class="num">{tx.vsize.toLocaleString()}</td>
<td>{fmtAgo(tx.boosted_at)}</td>
<td>
<button
type="button"
class="cancel-btn"
onclick={() => handleCancel(tx.txid)}
title="Reverse the priority boost"
>Cancel</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
</section>
<style>
.page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.crumbs {
margin-bottom: 0.25rem;
}
.back {
font: inherit;
font-size: 1rem;
color: var(--fg-dim);
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5em 1em;
cursor: pointer;
}
.back:hover {
color: var(--fg);
border-color: var(--accent);
background: var(--bg-hover);
}
/* ── Header with animated flame border ── */
.accel-header {
position: relative;
border-radius: 14px;
padding: 2px;
background: linear-gradient(135deg, #ff6a00, #ff2d2d, #ff6a00, #ffb347);
background-size: 300% 300%;
animation: flame-gradient 4s ease infinite;
}
@keyframes flame-gradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.header-content {
background: var(--bg-card);
border-radius: 12px;
padding: 2rem;
}
.title {
display: flex;
align-items: center;
gap: 0.5em;
margin: 0 0 1rem;
font-size: 1.8rem;
font-weight: 700;
}
.rocket {
width: 1.3em;
height: 1.3em;
color: var(--accent);
}
.explainer {
margin: 0 0 1.25rem;
color: var(--fg-dim);
line-height: 1.6;
font-size: 1.1rem;
}
.explainer code {
background: var(--bg-alt);
padding: 0.15em 0.4em;
border-radius: 4px;
font-size: 0.9em;
color: var(--fg);
}
.info-box {
background: rgba(255, 180, 71, 0.06);
border: 1px solid rgba(255, 180, 71, 0.2);
border-radius: 10px;
padding: 1.25rem 1.5rem;
font-size: 1rem;
line-height: 1.6;
color: var(--fg-dim);
}
.info-box p {
margin: 0 0 0.75rem;
}
.info-box p:last-child {
margin-bottom: 0;
}
.info-box strong {
color: var(--fg);
}
.info-box em {
color: var(--good);
font-style: normal;
font-weight: 600;
}
/* ── Boost form ── */
.boost-form {
padding: 2rem;
}
.boost-form h3 {
margin: 0 0 1.5rem;
font-size: 1.3rem;
font-weight: 700;
}
.field {
margin-bottom: 1.5rem;
}
.field label {
display: block;
font-size: 0.95rem;
font-weight: 500;
color: var(--fg-dim);
margin-bottom: 0.5em;
}
.field input {
width: 100%;
padding: 0.75em 1em;
font: inherit;
font-size: 1.1rem;
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--fg);
outline: none;
transition: border-color 0.15s;
}
.field input:focus {
border-color: var(--accent);
}
.field-error {
display: block;
color: var(--bad);
font-size: 0.9rem;
margin-top: 0.4em;
}
.feerate-row {
display: flex;
gap: 0.75rem;
align-items: stretch;
}
.feerate-row input {
flex: 1;
}
.max-btn {
display: inline-flex;
align-items: center;
gap: 0.5em;
font: inherit;
font-size: 0.95rem;
font-weight: 600;
white-space: nowrap;
background: linear-gradient(135deg, #1a0a00, #2d1400);
border: 1px solid rgba(255, 122, 58, 0.4);
border-radius: 8px;
padding: 0.6em 1.25em;
color: var(--accent);
cursor: pointer;
transition: box-shadow 0.2s, border-color 0.2s;
}
.max-btn:hover:not(:disabled) {
border-color: var(--accent);
box-shadow: 0 0 14px rgba(255, 122, 58, 0.35);
}
.max-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.boost-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5em;
width: 100%;
padding: 0.9em;
font: inherit;
font-size: 1.2rem;
font-weight: 700;
background: linear-gradient(135deg, #ff6a00, #ff2d2d);
border: none;
border-radius: 10px;
color: #fff;
cursor: pointer;
transition: box-shadow 0.2s, transform 0.15s, opacity 0.2s;
}
.boost-btn:hover:not(:disabled) {
box-shadow: 0 4px 24px rgba(255, 106, 0, 0.45), 0 0 50px rgba(255, 45, 45, 0.15);
transform: translateY(-1px);
}
.boost-btn:active:not(:disabled) {
transform: translateY(0);
}
.boost-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.spinner {
display: inline-block;
width: 1.1em;
height: 1.1em;
border: 2.5px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
.spinner.small {
width: 0.9em;
height: 0.9em;
border-width: 2px;
border-color: rgba(255, 122, 58, 0.3);
border-top-color: var(--accent);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Messages ── */
.msg {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-top: 1.25rem;
padding: 1rem 1.25rem;
border-radius: 8px;
font-size: 1.05rem;
line-height: 1.5;
}
.msg span {
flex: 1;
}
.msg.error {
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
color: var(--bad);
}
.msg.success {
background: rgba(92, 224, 168, 0.08);
border: 1px solid rgba(92, 224, 168, 0.3);
color: var(--good);
}
.dismiss {
flex-shrink: 0;
background: none;
border: none;
color: inherit;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
opacity: 0.6;
padding: 0 0.2em;
transition: opacity 0.15s;
}
.dismiss:hover {
opacity: 1;
}
/* ── Table ── */
h3 {
margin: 0 0 1.25rem;
font-size: 1.2rem;
font-weight: 700;
}
.count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.6em;
height: 1.6em;
font-size: 0.65em;
font-weight: 700;
background: var(--accent);
color: #000;
border-radius: 999px;
margin-left: 0.4em;
vertical-align: middle;
}
.empty {
color: var(--fg-dim);
padding: 1.25rem 0;
font-size: 1.05rem;
}
.table-wrap {
overflow-x: auto;
font-size: 1.05rem;
}
.txid-link {
color: var(--accent);
text-decoration: none;
font-size: 1rem;
border-bottom: 1px dashed transparent;
}
.txid-link:hover {
color: #ff9a5f;
border-bottom-color: var(--accent);
}
.boost-rate {
color: var(--accent);
font-weight: 600;
}
.delta {
color: var(--bad);
}
.unit {
color: var(--fg-dim);
font-size: 0.85em;
font-weight: 400;
}
.cancel-btn {
font: inherit;
font-size: 0.9rem;
font-weight: 600;
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.35em 0.8em;
color: var(--fg-dim);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.cancel-btn:hover {
color: var(--bad);
border-color: var(--bad);
}
@media (max-width: 600px) {
.header-content {
padding: 1.25rem;
}
.boost-form {
padding: 1.25rem;
}
.title {
font-size: 1.4rem;
}
.feerate-row {
flex-direction: column;
}
}
</style>
+46
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { snap } from "../stores/snapshot.svelte"; import { snap } from "../stores/snapshot.svelte";
import { selectAccelerator } from "../stores/selection.svelte";
const statusClass = $derived.by(() => { const statusClass = $derived.by(() => {
if (snap.status === "open") return "good"; if (snap.status === "open") return "good";
@@ -20,6 +21,19 @@
<div class="brand"> <div class="brand">
<span class="logo">&#x1F525;</span> <span class="logo">&#x1F525;</span>
<span class="name">Kamado Pool</span> <span class="name">Kamado Pool</span>
<button
type="button"
class="accel-btn"
onclick={selectAccelerator}
title="Transaction Accelerator"
>
<svg class="rocket-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
<path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/>
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>
</svg>
</button>
</div> </div>
<div class="meta" title="WebSocket status: {statusLabel}"> <div class="meta" title="WebSocket status: {statusLabel}">
<span class="ws-dot {statusClass}" aria-hidden="true"></span> <span class="ws-dot {statusClass}" aria-hidden="true"></span>
@@ -46,6 +60,38 @@
.logo { .logo {
font-size: 1.4rem; font-size: 1.4rem;
} }
.accel-btn {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.45em;
cursor: pointer;
color: var(--fg-dim);
transition: color 0.2s, border-color 0.2s, box-shadow 0.2s, transform 0.2s;
animation: jiggle 10s ease-in-out infinite;
}
.accel-btn:hover {
color: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 12px rgba(255, 122, 58, 0.5);
transform: translateY(-2px) scale(1.05);
animation: none;
}
@keyframes jiggle {
0%, 94%, 100% { transform: rotate(0deg) scale(1); }
95% { transform: rotate(-12deg) scale(1.1); }
96.5% { transform: rotate(10deg) scale(1.1); }
97.5% { transform: rotate(-8deg) scale(1.05); }
98.5% { transform: rotate(6deg) scale(1.05); }
99.5% { transform: rotate(-3deg) scale(1); }
}
.rocket-icon {
width: 1.4em;
height: 1.4em;
}
.meta { .meta {
display: flex; display: flex;
align-items: center; align-items: center;
+16 -16
View File
@@ -77,7 +77,7 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 10px;
background: var(--bg-card); background: var(--bg-card);
padding: 0.85rem 1.25rem; padding: 1.1rem 1.5rem;
} }
.content { .content {
position: relative; position: relative;
@@ -85,29 +85,29 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 1.5rem; gap: 2rem;
} }
.title { .title {
margin: 0; margin: 0;
font-size: 0.9rem; font-size: 1.1rem;
font-weight: 600; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.05em;
color: var(--fg); color: var(--fg);
} }
.stats { .stats {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1.5rem; gap: 2rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.group { .group {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 0.4em; gap: 0.5em;
} }
.group-label { .group-label {
font-size: 0.75rem; font-size: 0.85rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: var(--fg-dim); color: var(--fg-dim);
@@ -115,31 +115,31 @@
} }
.accepted { .accepted {
color: var(--good); color: var(--good);
font-weight: 600; font-weight: 700;
font-size: 1.1rem; font-size: 1.4rem;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.rejected { .rejected {
color: var(--bad); color: var(--bad);
font-weight: 600; font-weight: 700;
font-size: 1.1rem; font-size: 1.4rem;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.sep { .sep {
color: var(--fg-dim); color: var(--fg-dim);
font-size: 0.9rem; font-size: 1.1rem;
} }
.pct { .pct {
font-size: 1.1rem; font-size: 1.05rem;
color: var(--fg-dim); color: var(--fg-dim);
margin-left: 0.3em; margin-left: 0.4em;
} }
.pct.warn { .pct.warn {
color: var(--bad); color: var(--bad);
} }
.divider { .divider {
width: 1px; width: 1px;
height: 1.6rem; height: 2rem;
background: var(--border); background: var(--border);
} }
+17 -6
View File
@@ -3,6 +3,7 @@
// #/ -> dashboard // #/ -> dashboard
// #/user/<address> -> per-user page // #/user/<address> -> per-user page
// #/worker/<workername> -> per-worker page // #/worker/<workername> -> per-worker page
// #/accelerator -> transaction accelerator
// //
// selectUser / selectWorker / clearSelection just mutate the hash; // selectUser / selectWorker / clearSelection just mutate the hash;
// a hashchange listener reads it back into reactive state so any // a hashchange listener reads it back into reactive state so any
@@ -10,29 +11,34 @@
const USER_PREFIX = "#/user/"; const USER_PREFIX = "#/user/";
const WORKER_PREFIX = "#/worker/"; const WORKER_PREFIX = "#/worker/";
const ACCELERATOR_HASH = "#/accelerator";
type Selection = { user: string | null; worker: string | null }; type Selection = { user: string | null; worker: string | null; page: string | null };
export const selection = $state<Selection>(readHash()); export const selection = $state<Selection>(readHash());
function readHash(): Selection { function readHash(): Selection {
if (typeof window === "undefined") return { user: null, worker: null }; if (typeof window === "undefined") return { user: null, worker: null, page: null };
const h = window.location.hash; const h = window.location.hash;
if (h === ACCELERATOR_HASH) {
return { user: null, worker: null, page: "accelerator" };
}
if (h.startsWith(WORKER_PREFIX)) { if (h.startsWith(WORKER_PREFIX)) {
const w = decodeURIComponent(h.slice(WORKER_PREFIX.length)); const w = decodeURIComponent(h.slice(WORKER_PREFIX.length));
return { user: null, worker: w || null }; return { user: null, worker: w || null, page: null };
} }
if (h.startsWith(USER_PREFIX)) { if (h.startsWith(USER_PREFIX)) {
const u = decodeURIComponent(h.slice(USER_PREFIX.length)); const u = decodeURIComponent(h.slice(USER_PREFIX.length));
return { user: u || null, worker: null }; return { user: u || null, worker: null, page: null };
} }
return { user: null, worker: null }; return { user: null, worker: null, page: null };
} }
function syncFromHash(): void { function syncFromHash(): void {
const parsed = readHash(); const parsed = readHash();
selection.user = parsed.user; selection.user = parsed.user;
selection.worker = parsed.worker; selection.worker = parsed.worker;
selection.page = parsed.page;
} }
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@@ -47,11 +53,16 @@ export function selectWorker(workername: string): void {
window.location.hash = WORKER_PREFIX + encodeURIComponent(workername); window.location.hash = WORKER_PREFIX + encodeURIComponent(workername);
} }
export function selectAccelerator(): void {
window.location.hash = ACCELERATOR_HASH;
}
export function clearSelection(): void { export function clearSelection(): void {
if ( if (
window.history.length > 1 && window.history.length > 1 &&
(window.location.hash.startsWith(USER_PREFIX) || (window.location.hash.startsWith(USER_PREFIX) ||
window.location.hash.startsWith(WORKER_PREFIX)) window.location.hash.startsWith(WORKER_PREFIX) ||
window.location.hash === ACCELERATOR_HASH)
) { ) {
window.history.back(); window.history.back();
} else { } else {