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:
@@ -18,6 +18,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/kamadopool/kamado-api/internal/accelerator"
|
||||
"github.com/kamadopool/kamado-api/internal/bitcoind"
|
||||
"github.com/kamadopool/kamado-api/internal/ckpool"
|
||||
"github.com/kamadopool/kamado-api/internal/config"
|
||||
@@ -65,10 +66,16 @@ func main() {
|
||||
agg.Store = blockStore
|
||||
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)
|
||||
defer stop()
|
||||
|
||||
api := httpapi.New(agg, log)
|
||||
api := httpapi.New(agg, accSvc, log)
|
||||
|
||||
// Wire snapshot refreshes into the WebSocket hub so subscribers get
|
||||
// real-time updates without polling.
|
||||
@@ -130,6 +137,10 @@ func main() {
|
||||
go agg.IngestAttemptEvents(ctx, tailer.Attempts)
|
||||
go agg.IngestLatencyEvents(ctx, tailer.Latencies)
|
||||
|
||||
if accSvc != nil {
|
||||
go accSvc.Cleanup(ctx)
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: api.Handler(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import BestShares from "./lib/BestShares.svelte";
|
||||
import UserDetailPage from "./lib/UserDetailPage.svelte";
|
||||
import WorkerDetailPage from "./lib/WorkerDetailPage.svelte";
|
||||
import AcceleratorPage from "./lib/AcceleratorPage.svelte";
|
||||
import SharesBar from "./lib/SharesBar.svelte";
|
||||
import BlockFoundAnimation from "./lib/BlockFoundAnimation.svelte";
|
||||
|
||||
@@ -34,6 +35,8 @@
|
||||
<div class="stat-sub bad">{snap.error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if selection.page === "accelerator"}
|
||||
<AcceleratorPage />
|
||||
{:else if selection.worker}
|
||||
<WorkerDetailPage />
|
||||
{:else if selection.user}
|
||||
|
||||
@@ -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}>
|
||||
← 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 = ""}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if success}
|
||||
<div class="msg success">
|
||||
<span>{success}</span>
|
||||
<button type="button" class="dismiss" onclick={() => success = ""}>×</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>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { snap } from "../stores/snapshot.svelte";
|
||||
import { selectAccelerator } from "../stores/selection.svelte";
|
||||
|
||||
const statusClass = $derived.by(() => {
|
||||
if (snap.status === "open") return "good";
|
||||
@@ -20,6 +21,19 @@
|
||||
<div class="brand">
|
||||
<span class="logo">🔥</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 class="meta" title="WebSocket status: {statusLabel}">
|
||||
<span class="ws-dot {statusClass}" aria-hidden="true"></span>
|
||||
@@ -46,6 +60,38 @@
|
||||
.logo {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+16
-16
@@ -77,7 +77,7 @@
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
padding: 0.85rem 1.25rem;
|
||||
padding: 1.1rem 1.5rem;
|
||||
}
|
||||
.content {
|
||||
position: relative;
|
||||
@@ -85,29 +85,29 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
gap: 2rem;
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--fg);
|
||||
}
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.group {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4em;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.group-label {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--fg-dim);
|
||||
@@ -115,31 +115,31 @@
|
||||
}
|
||||
.accepted {
|
||||
color: var(--good);
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.4rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rejected {
|
||||
color: var(--bad);
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.4rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.sep {
|
||||
color: var(--fg-dim);
|
||||
font-size: 0.9rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.pct {
|
||||
font-size: 1.1rem;
|
||||
font-size: 1.05rem;
|
||||
color: var(--fg-dim);
|
||||
margin-left: 0.3em;
|
||||
margin-left: 0.4em;
|
||||
}
|
||||
.pct.warn {
|
||||
color: var(--bad);
|
||||
}
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 1.6rem;
|
||||
height: 2rem;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// #/ -> dashboard
|
||||
// #/user/<address> -> per-user page
|
||||
// #/worker/<workername> -> per-worker page
|
||||
// #/accelerator -> transaction accelerator
|
||||
//
|
||||
// selectUser / selectWorker / clearSelection just mutate the hash;
|
||||
// a hashchange listener reads it back into reactive state so any
|
||||
@@ -10,29 +11,34 @@
|
||||
|
||||
const USER_PREFIX = "#/user/";
|
||||
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());
|
||||
|
||||
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;
|
||||
if (h === ACCELERATOR_HASH) {
|
||||
return { user: null, worker: null, page: "accelerator" };
|
||||
}
|
||||
if (h.startsWith(WORKER_PREFIX)) {
|
||||
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)) {
|
||||
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 {
|
||||
const parsed = readHash();
|
||||
selection.user = parsed.user;
|
||||
selection.worker = parsed.worker;
|
||||
selection.page = parsed.page;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -47,11 +53,16 @@ export function selectWorker(workername: string): void {
|
||||
window.location.hash = WORKER_PREFIX + encodeURIComponent(workername);
|
||||
}
|
||||
|
||||
export function selectAccelerator(): void {
|
||||
window.location.hash = ACCELERATOR_HASH;
|
||||
}
|
||||
|
||||
export function clearSelection(): void {
|
||||
if (
|
||||
window.history.length > 1 &&
|
||||
(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();
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user