diff --git a/service/popm/popm.go b/service/popm/popm.go index df42e512..c1a40dab 100644 --- a/service/popm/popm.go +++ b/service/popm/popm.go @@ -109,7 +109,8 @@ type Miner struct { holdoffTimeout time.Duration requestTimeout time.Duration - cfg *Config + cfg *Config + txFee atomic.Uint32 btcChainParams *btcchaincfg.Params btcPrivateKey *dcrsecp256k1.PrivateKey @@ -146,6 +147,7 @@ func NewMiner(cfg *Config) (*Miner, error) { mineNowCh: make(chan struct{}, 1), l2Keystones: make(map[string]L2KeystoneProcessingContainer, l2KeystonesMaxSize), } + m.SetFee(cfg.StaticFee) switch strings.ToLower(cfg.BTCChainName) { case "mainnet": @@ -167,6 +169,24 @@ func NewMiner(cfg *Config) (*Miner, error) { return m, nil } +// Fee returns the current fee in sats/vB used by the PoP Miner when +// creating PoP transactions. +func (m *Miner) Fee() uint { + return uint(m.txFee.Load()) +} + +// SetFee sets the fee in sats/vB used by the PoP Miner when creating +// PoP transactions. +func (m *Miner) SetFee(fee uint) { + switch { + case fee < 0: + fee = 1 + case fee > 1<<32-1: + fee = 1<<32 - 1 + } + m.txFee.Store(uint32(fee)) +} + func (m *Miner) bitcoinBalance(ctx context.Context, scriptHash []byte) (uint64, int64, error) { br := &bfgapi.BitcoinBalanceRequest{ ScriptHash: scriptHash, @@ -355,7 +375,7 @@ func (m *Miner) mineKeystone(ctx context.Context, ks *hemi.L2Keystone) error { // Estimate BTC fees. txLen := 285 // XXX: for now all transactions are the same size - feePerKB := 1024 * m.cfg.StaticFee + feePerKB := 1024 * m.Fee() feeAmount := (int64(txLen) * int64(feePerKB)) / 1024 // Retrieve the current balance for the miner. diff --git a/service/popm/popm_test.go b/service/popm/popm_test.go index 4672e796..617e6125 100644 --- a/service/popm/popm_test.go +++ b/service/popm/popm_test.go @@ -105,6 +105,29 @@ func TestNewMiner(t *testing.T) { } } +func TestFees(t *testing.T) { + cfg := NewDefaultConfig() + cfg.StaticFee = 5 + cfg.BTCPrivateKey = "ebaaedce6af48a03bbfd25e8cd0364140ebaaedce6af48a03bbfd25e8cd03641" + + m, err := NewMiner(cfg) + if err != nil { + t.Fatalf("Failed to create new miner: %v", err) + } + + // Make sure that the static fee is used. + if m.Fee() != cfg.StaticFee { + t.Errorf("got fee %d, want %d", m.Fee(), cfg.StaticFee) + } + + // Make sure the fee can be changed with SetFee. + const newFee = 8 + m.SetFee(newFee) + if m.Fee() != newFee { + t.Errorf("got fee %d, want %d", m.Fee(), newFee) + } +} + // TestProcessReceivedKeystones ensures that we store the latest keystone // correctly as well as data stored in slices within the struct func TestProcessReceivedKeystones(t *testing.T) { diff --git a/web/packages/pop-miner/src/types.ts b/web/packages/pop-miner/src/types.ts index d26ea7de..38528e55 100644 --- a/web/packages/pop-miner/src/types.ts +++ b/web/packages/pop-miner/src/types.ts @@ -331,6 +331,17 @@ export declare function removeEventListener( listener: EventListener, ): Promise; +/** + * The type of recommended fee to use when doing automatic fees using + * the mempool.space REST API. + */ +export type RecommendedFeeType = + | 'fastest' + | 'halfHour' + | 'hour' + | 'economy' + | 'minimum'; + /** * @see startPoPMiner */ @@ -359,7 +370,22 @@ export type MinerStartArgs = { logLevel?: string; /** - * The number of stats/vB the PoP Miner will pay for fees. + * Whether to do automatic fee estimation using the mempool.space API. + * Defaults to true. If disabled, then only the staticFee will be used. + * + * When the value is true, the 'economy' recommended fee type will be used. + */ + automaticFees?: boolean | RecommendedFeeType; + + /** + * The duration between refreshing recommended fee data from the + * mempool.space API, in seconds. Defaults to 300 seconds (5 minutes). + */ + automaticFeeRefreshSeconds?: number; + + /** + * The number of sats/vB the PoP Miner will pay for fees. If automaticFees + * is enabled, then this will be used as a fallback fee value. */ staticFee: number; }; @@ -392,6 +418,11 @@ export type MinerStatusResult = { * Whether the PoP miner is currently connected to a BFG server. */ readonly connected: boolean; + + /** + * The current fee used by the PoP miner for PoP transactions, in sats/vB. + */ + readonly fee: number; }; /** diff --git a/web/popminer/api.go b/web/popminer/api.go index ef835543..1e60cca5 100644 --- a/web/popminer/api.go +++ b/web/popminer/api.go @@ -7,6 +7,8 @@ package main import ( + "fmt" + "strings" "syscall/js" "github.com/hemilabs/heminetwork/service/popm" @@ -140,6 +142,36 @@ type BitcoinAddressToScriptHashResult struct { ScriptHash string `json:"scriptHash"` } +// RecommendedFeeType is the type of recommended fee to use when doing automatic +// fees using the mempool.space REST API. +type RecommendedFeeType string + +const ( + RecommendedFeeTypeFastest RecommendedFeeType = "fastest" + RecommendedFeeTypeHalfHour RecommendedFeeType = "halfHour" + RecommendedFeeTypeHour RecommendedFeeType = "hour" + RecommendedFeeTypeEconomy RecommendedFeeType = "economy" + RecommendedFeeTypeMinimum RecommendedFeeType = "minimum" +) + +// ParseRecommendedFeeType parses the given string as a RecommendedFeeType. +func ParseRecommendedFeeType(s string) (RecommendedFeeType, error) { + switch strings.ToLower(s) { + case "fastest": + return RecommendedFeeTypeFastest, nil + case "halfHour": + return RecommendedFeeTypeHalfHour, nil + case "hour": + return RecommendedFeeTypeHour, nil + case "economy": + return RecommendedFeeTypeEconomy, nil + case "minimum": + return RecommendedFeeTypeMinimum, nil + default: + return "", fmt.Errorf("unknown recommended fee type: %q", s) + } +} + // MinerStatusResult contains information about the status of the PoP miner. // Returned by MethodMinerStatus. type MinerStatusResult struct { @@ -149,6 +181,10 @@ type MinerStatusResult struct { // Connecting is whether the PoP miner is currently connected to a BFG // server. Connected bool `json:"connected"` + + // Fee is the current fee used by the PoP miner for PoP transactions, + // in sats/vB. + Fee uint `json:"fee"` } // PingResult contains information when pinging the BFG server. diff --git a/web/popminer/dispatch.go b/web/popminer/dispatch.go index 0fae5f9f..c3845b6f 100644 --- a/web/popminer/dispatch.go +++ b/web/popminer/dispatch.go @@ -12,6 +12,7 @@ import ( "encoding/hex" "errors" "fmt" + "net/http" "runtime/debug" "syscall/js" "time" @@ -342,26 +343,13 @@ func startPoPMiner(_ js.Value, args []js.Value) (any, error) { return nil, errors.New("miner already started") } - cfg, err := createMinerConfig(args[0]) + m, autoFees, err := newMiner(args[0]) if err != nil { return nil, err } - miner, err := popm.NewMiner(cfg) - if err != nil { - return nil, fmt.Errorf("create miner: %w", err) - } - // Add WebAssembly miner event handler - miner.RegisterEventHandler(svc.handleMinerEvent) - - ctx, cancel := context.WithCancel(context.Background()) - m := &Miner{ - ctx: ctx, - cancel: cancel, - Miner: miner, - errCh: make(chan error, 1), - } + m.RegisterEventHandler(svc.handleMinerEvent) svc.miner = m // run in background @@ -390,11 +378,25 @@ func startPoPMiner(_ js.Value, args []js.Value) (any, error) { svc.dispatchEvent(EventTypeMinerStop, EventMinerStop{}) }() + if autoFees.enabled { + // Automatic fees are enabled, run the goroutine to retrieve the fees + // at the refresh interval. + m.wg.Add(1) + go m.automaticFees(autoFees.feeType, autoFees.refreshInterval) + } + return js.Null(), nil } -// createMinerConfig creates a [popm.Config] from the given JavaScript object. -func createMinerConfig(config js.Value) (*popm.Config, error) { +type automaticFeeOptions struct { + enabled bool + feeType RecommendedFeeType + refreshInterval time.Duration +} + +// newMiner creates a [popm.Miner] using config options from the given +// JavaScript object. +func newMiner(config js.Value) (*Miner, *automaticFeeOptions, error) { cfg := popm.NewDefaultConfig() cfg.BTCPrivateKey = config.Get("privateKey").String() cfg.StaticFee = uint(config.Get("staticFee").Int()) @@ -405,7 +407,7 @@ func createMinerConfig(config js.Value) (*popm.Config, error) { cfg.LogLevel = "popm=ERROR" } if err := loggo.ConfigureLoggers(cfg.LogLevel); err != nil { - return nil, errorWithCode(ErrorCodeInvalidValue, + return nil, nil, errorWithCode(ErrorCodeInvalidValue, fmt.Errorf("configure logger: %w", err)) } @@ -413,13 +415,47 @@ func createMinerConfig(config js.Value) (*popm.Config, error) { network := config.Get("network").String() netOpts, ok := networks[network] if !ok { - return nil, errorWithCode(ErrorCodeInvalidValue, + return nil, nil, errorWithCode(ErrorCodeInvalidValue, fmt.Errorf("unknown network: %s", network)) } cfg.BFGWSURL = netOpts.bfgURL cfg.BTCChainName = netOpts.btcChainName - return cfg, nil + // Automatic fee options + autoFeeConfig := config.Get("automaticFees") + autoFees := &automaticFeeOptions{ + enabled: autoFeeConfig.Truthy(), + feeType: RecommendedFeeTypeEconomy, + refreshInterval: 5 * time.Minute, + } + if autoFeeConfig.Type() == js.TypeString { + // automaticFees is a string, parse the selected recommended fee type. + feeType, err := ParseRecommendedFeeType(autoFeeConfig.String()) + if err != nil { + return nil, nil, errorWithCode(ErrorCodeInvalidValue, err) + } + autoFees.feeType = feeType + } + if rf := config.Get("automaticFeeRefreshSeconds"); rf.Truthy() { + autoFees.refreshInterval = time.Duration(rf.Int()) * time.Second + } + + // Create new miner + miner, err := popm.NewMiner(cfg) + if err != nil { + return nil, nil, fmt.Errorf("create miner: %w", err) + } + + m := &Miner{ + Miner: miner, + errCh: make(chan error, 1), + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + mempoolSpaceURL: netOpts.mempoolSpaceURL, + } + m.ctx, m.cancel = context.WithCancel(context.Background()) + return m, autoFees, nil } func stopPopMiner(_ js.Value, _ []js.Value) (any, error) { @@ -453,6 +489,7 @@ func minerStatus(_ js.Value, _ []js.Value) (any, error) { if err == nil { status.Running = true status.Connected = miner.Connected() + status.Fee = miner.Fee() } return status, nil diff --git a/web/popminer/fee.go b/web/popminer/fee.go new file mode 100644 index 00000000..797cbebc --- /dev/null +++ b/web/popminer/fee.go @@ -0,0 +1,123 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +//go:build js && wasm + +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +const mempoolRecommendedFees = "/api/v1/fees/recommended" + +// recommendedFees is the result from the mempool.space API. +type recommendedFees struct { + FastestFee uint `json:"fastestFee"` + HalfHourFee uint `json:"halfHourFee"` + HourFee uint `json:"hourFee"` + EconomyFee uint `json:"economyFee"` + MinimumFee uint `json:"minimumFee"` +} + +// pick returns the recommended fee value for the specified type. +func (r recommendedFees) pick(f RecommendedFeeType) uint { + switch f { + case RecommendedFeeTypeFastest: + return r.FastestFee + case RecommendedFeeTypeHalfHour: + return r.HalfHourFee + case RecommendedFeeTypeHour: + return r.HourFee + case RecommendedFeeTypeEconomy: + return r.EconomyFee + case RecommendedFeeTypeMinimum: + return r.MinimumFee + default: + panic("bug: unknown recommended fee type") + } +} + +// automaticFees runs a loop to refresh the fee used by the PoP Miner using +// the recommended fee of the specified type from the mempool.space REST API. +func (m *Miner) automaticFees(fee RecommendedFeeType, refresh time.Duration) { + log.Tracef("automaticFees") + defer log.Tracef("automaticFees exit") + defer m.wg.Done() + + if m.mempoolSpaceURL == "" { + // Not supported for this network. + return + } + + for { + m.updateFee(m.ctx, fee) + + select { + case <-m.ctx.Done(): + return + case <-time.After(refresh): + } + } +} + +// updateFee requests the recommended fees from mempool.space and updates the +// fee being used by the PoP Miner. +func (m *Miner) updateFee(ctx context.Context, fee RecommendedFeeType) { + ctx, cancel := context.WithTimeout(m.ctx, 5*time.Second) + defer cancel() + + // Retrieve recommended fees from mempool.space. + fees, err := m.getRecommendedFees(ctx) + if err != nil { + log.Warningf("Failed to fetch recommended fees: %v", err) + return + } + + // Update fee used by the miner. + m.SetFee(fees.pick(fee)) +} + +// getRecommendedFees requests the recommended fees from the mempool.space +// REST API. +func (m *Miner) getRecommendedFees(ctx context.Context) (*recommendedFees, error) { + log.Debugf("Requesting recommended fees from mempool.space...") + + // Join API path. + apiPath, err := url.JoinPath(m.mempoolSpaceURL, mempoolRecommendedFees) + if err != nil { + return nil, fmt.Errorf("join mempool.space URL path: %w", err) + } + + // Create HTTP GET request. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiPath, nil) + if err != nil { + return nil, fmt.Errorf("create recommended fees request: %w", err) + } + req.Header.Add("Accept", "application/json") + + // Make HTTP request. + res, err := m.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request recommended fees: %w", err) + } + defer res.Body.Close() + + // Make sure status code is 200. + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("request recommended fees: %s", res.Status) + } + + // Decode response. + var fees recommendedFees + if err = json.NewDecoder(res.Body).Decode(&fees); err != nil { + return nil, fmt.Errorf("decode recommended fees response: %w", err) + } + return &fees, nil +} diff --git a/web/popminer/network.go b/web/popminer/network.go index 71dbddd7..e0b1992e 100644 --- a/web/popminer/network.go +++ b/web/popminer/network.go @@ -15,24 +15,32 @@ const ( rpcHemiMainnet = "wss://rpc.hemi.network" bfgRoute = "/v1/ws/public" + + mempoolSpaceURL = "https://mempool.space" ) type networkOptions struct { bfgURL string btcChainName string + + // mempoolSpaceURL is the base URL for mempool.space for this network. + mempoolSpaceURL string } var networks = map[string]networkOptions{ "testnet": { - bfgURL: rpcHemiTestnet + bfgRoute, - btcChainName: btcChainTestnet3, + bfgURL: rpcHemiTestnet + bfgRoute, + btcChainName: btcChainTestnet3, + mempoolSpaceURL: mempoolSpaceURL + "/testnet", }, "devnet": { - bfgURL: rpcHemiDevnet + bfgRoute, - btcChainName: btcChainTestnet3, + bfgURL: rpcHemiDevnet + bfgRoute, + btcChainName: btcChainTestnet3, + mempoolSpaceURL: "", }, "mainnet": { - bfgURL: rpcHemiMainnet + bfgRoute, - btcChainName: btcChainMainnet, + bfgURL: rpcHemiMainnet + bfgRoute, + btcChainName: btcChainMainnet, + mempoolSpaceURL: mempoolSpaceURL, }, } diff --git a/web/popminer/popminer.go b/web/popminer/popminer.go index 87ccc6f5..514e24bc 100644 --- a/web/popminer/popminer.go +++ b/web/popminer/popminer.go @@ -9,6 +9,7 @@ package main import ( "context" "errors" + "net/http" "os" "path/filepath" "sync" @@ -92,6 +93,14 @@ type Miner struct { cancel context.CancelFunc *popm.Miner + // httpClient is the HTTP client used for accessing the mempool.space API + // if automaticFees is enabled. + httpClient *http.Client + + // mempoolSpaceURL is the base URL for mempool.space, for the current + // network. + mempoolSpaceURL string + errCh chan error wg sync.WaitGroup } diff --git a/web/www/index.html b/web/www/index.html index 75d891eb..2eb02de9 100644 --- a/web/www/index.html +++ b/web/www/index.html @@ -62,6 +62,14 @@
+ + +
+ + +

diff --git a/web/www/index.js b/web/www/index.js index 7c903aac..b9dc392f 100644 --- a/web/www/index.js +++ b/web/www/index.js @@ -103,11 +103,17 @@ const StartPopMinerShow = document.querySelector('.StartPopMinerShow'); async function StartPopMiner() { try { + let automaticFees = StartPopMinerAutomaticFeesInput.value; + if (automaticFees === 'false' || automaticFees === 'true') { + automaticFees = automaticFees === 'true'; + } const result = await dispatch({ method: 'startPoPMiner', network: StartPopMinerNetworkInput.value, logLevel: StartPopMinerLogLevelInput.value, privateKey: StartPopMinerPrivateKeyInput.value, + automaticFees: automaticFees, + automaticFeeRefreshSeconds: Number(StartPopMinerAutomaticFeeRefreshInput.value), staticFee: Number(StartPopMinerStaticFeeInput.value), }); StartPopMinerShow.innerText = JSON.stringify(result, null, 2);