From 7b00a1faa357850efcc4f5b17dfeb431db2b5e9d Mon Sep 17 00:00:00 2001 From: Jude Nelson Date: Mon, 20 Nov 2023 12:20:49 -0500 Subject: [PATCH 1/3] feat: side-car to watch and report estimated sats/vbyte --- contrib/side-cars/fee-estimate.sh | 211 ++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100755 contrib/side-cars/fee-estimate.sh diff --git a/contrib/side-cars/fee-estimate.sh b/contrib/side-cars/fee-estimate.sh new file mode 100755 index 000000000..1682c3fe0 --- /dev/null +++ b/contrib/side-cars/fee-estimate.sh @@ -0,0 +1,211 @@ +#!/bin/bash + +#################################### +# Usage +# +# $ ./fee-estimate.sh +# 161 +# +# $ ./fee-estimate.sh test; echo $? +# 0 +#################################### + +set -uoe pipefail + +function exit_error() { + echo >&2 "$@" + exit 1 +} + +#################################### +# Dependencies +#################################### +# Dependencies +for cmd in curl jq bc sed date grep; do + command -v "$cmd" >/dev/null 2>&1 || exit_error "Command not found: '$cmd'" +done + + +#################################### +# Functions +#################################### + +# Convert a fee/kb to fee/vbyte. +# If there's a fractional part of the fee/kb (i.e. if it's not divisible by 1000), +# then round up. +# Arguments: +# $1 -- the fee per kb +# Stdout: the satoshis per vbyte, as an integer +# Stderr: none +# Return: +# 0 on success +# nonzero on error +function fee_per_kb_to_fee_per_vbyte() { + local fee_per_kb="$1" + local fee_per_vbyte_float= + local fee_per_vbyte_ipart= + local fee_per_vbyte_fpart= + local fee_per_vbyte= + + # must be an integer + if ! [[ "$fee_per_kb" =~ ^[0-9]+$ ]]; then + exit_error "Did not receive a fee/kb from $fee_endpoint, but got '$fee_per_kb'" + fi + + # NOTE: round up -- get the fractional part, and if it's anything other than 000, then add 1 + fee_per_vbyte_float="$(echo "scale=3; $fee_per_kb / 1000" | bc)" + fee_per_vbyte_ipart="$(echo "$fee_per_vbyte_float" | sed -r 's/^([0-9]*)\..+$/\1/g')" + fee_per_vbyte_fpart="$(echo "$fee_per_vbyte_float" | sed -r -e 's/.+\.([0-9]+)$/\1/g' -e 's/0//g')" + fee_per_vbyte="$fee_per_vbyte_ipart" + if [ -n "$fee_per_vbyte_fpart" ]; then + fee_per_vbyte="$((fee_per_vbyte + 1))" + fi + + echo "$fee_per_vbyte" + return 0 +} + +# Determine satoshis per vbyte +# Arguments: none +# Stdout: the satoshis per vbyte, as an integer +# Stderr: none +# Return: +# 0 on success +# nonzero on error +function get_sats_per_vbyte() { + local fee_endpoint="https://api.blockcypher.com/v1/btc/main" + local fee_per_kb= + + fee_per_kb="$(curl -sL "$fee_endpoint" | jq -r '.high_fee_per_kb')" + fee_per_kb_to_fee_per_vbyte "$fee_per_kb" + return 0 +} + +# Update the fee rate in the config file. +# Arguments: +# $1 -- path to the config file +# $2 -- new fee to write +# Stdout: (none) +# Stderr: (none) +# Returns: +# 0 on success +# nonzero on error +function update_fee() { + local config_path="$1" + local fee="$2" + sed -i -r "s/satoshis_per_byte[ \t]+=.*$/satoshis_per_byte = ${fee}/g" "$config_path" + return 0 +} + +# Poll fees every so often, and update a config file. +# Runs indefinitely. +# If the fee estimator endpoint cannot be reached, then the file is not modified. +# Arguments: +# $1 -- path to file to watch +# $2 -- interval at which to poll, in seconds +# Stdout: (none) +# Stderr: (none) +# Returns: (none) +function watch_fees() { + local config_path="$1" + local interval="$2" + + local fee= + local rc= + + while true; do + # allow poll command to fail without killing the script + set +e + fee="$(get_sats_per_vbyte)" + rc="$?" + set -e + + if [ $rc -ne 0 ]; then + echo >&2 "WARN[$(date +%s)]: failed to poll fees" + else + update_fee "$config_path" "$fee" + fi + sleep "$interval" + done +} + +# Unit tests +function unit_test() { + local test_config="/tmp/test-miner-config-$$.toml" + if [ "$(fee_per_kb_to_fee_per_vbyte 1000)" != "1" ]; then + exit_error "failed -- 1000 sats/kbyte != 1 sats/vbyte" + fi + + if [ "$(fee_per_kb_to_fee_per_vbyte 1001)" != "2" ]; then + exit_error "failed -- 1001 sats/vbyte != 2 sats/vbyte" + fi + + if [ "$(fee_per_kb_to_fee_per_vbyte 999)" != "1" ]; then + exit_error "failed -- 999 sats/vbyte != 1 sats/vbyte" + fi + + echo "satoshis_per_byte = 123" > "$test_config" + update_fee "$test_config" "456" + if ! grep 'satoshis_per_byte = 456' >/dev/null "$test_config"; then + exit_error "failed -- did not update satoshis_per_byte" + fi + + echo "" > "$test_config" + update_fee "$test_config" "456" + if grep "satoshis_per_byte" "$test_config" >/dev/null; then + exit_error "failed -- updated satoshis_per_byte in a config file without it" + fi + + rm "$test_config" + return 0 +} + +#################################### +# Entry point +#################################### + +# Main body +# Arguments +# $1: mode of operation. Can be "test" or empty +# Stdout: the fee rate, in sats/vbte +# Stderr: None +# Return: (no return) +function main() { + local mode="$1" + local config_path= + local interval= + + case "$mode" in + "test") + # run unit tests + echo "Run unit tests" + unit_test + exit 0 + ;; + "watch") + # watch and update the file + if (( $# < 3 )); then + exit_error "Usage: $0 watch /path/to/miner.toml interval_in_seconds" + fi + + config_path="$2" + interval="$3" + + watch_fees "$config_path" "$interval" + ;; + + "") + # one-shot + get_sats_per_vbyte + ;; + esac + exit 0 +} + +if (( $# > 0 )); then + # got arguments + main "$@" +else + # no arguments + main "" +fi From 29e31cd43a531e4d9464a030c49a43a6f5a013e4 Mon Sep 17 00:00:00 2001 From: Jude Nelson Date: Mon, 20 Nov 2023 12:30:57 -0500 Subject: [PATCH 2/3] chore: more usage docs --- contrib/side-cars/fee-estimate.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contrib/side-cars/fee-estimate.sh b/contrib/side-cars/fee-estimate.sh index 1682c3fe0..672f237df 100755 --- a/contrib/side-cars/fee-estimate.sh +++ b/contrib/side-cars/fee-estimate.sh @@ -3,9 +3,14 @@ #################################### # Usage # +# $ # one-shot fee-rate calculation # $ ./fee-estimate.sh # 161 # +# $ # Check fees every 5 seconds and update `satoshis_per_byte` in `/path/to/miner.toml` +# $ ./fee-estimate.sh watch /path/to/miner.toml 5 +# +# $ # Run unit tests and report result (0 means success) # $ ./fee-estimate.sh test; echo $? # 0 #################################### From 0224b1a1dadbab0c49469cc8ca45afae0dfed909 Mon Sep 17 00:00:00 2001 From: Jude Nelson Date: Mon, 20 Nov 2023 15:42:58 -0500 Subject: [PATCH 3/3] chore: address PR feedback -- log HTTP errors and check that the config file exists --- contrib/side-cars/fee-estimate.sh | 50 +++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/contrib/side-cars/fee-estimate.sh b/contrib/side-cars/fee-estimate.sh index 672f237df..e7810b488 100755 --- a/contrib/side-cars/fee-estimate.sh +++ b/contrib/side-cars/fee-estimate.sh @@ -25,8 +25,7 @@ function exit_error() { #################################### # Dependencies #################################### -# Dependencies -for cmd in curl jq bc sed date grep; do +for cmd in curl jq bc sed date grep head tail; do command -v "$cmd" >/dev/null 2>&1 || exit_error "Command not found: '$cmd'" done @@ -54,7 +53,7 @@ function fee_per_kb_to_fee_per_vbyte() { # must be an integer if ! [[ "$fee_per_kb" =~ ^[0-9]+$ ]]; then - exit_error "Did not receive a fee/kb from $fee_endpoint, but got '$fee_per_kb'" + return 1 fi # NOTE: round up -- get the fractional part, and if it's anything other than 000, then add 1 @@ -70,6 +69,41 @@ function fee_per_kb_to_fee_per_vbyte() { return 0 } +# Query the endpoint and log HTTP errors gracefully +# Arguments: +# $1 endpoint to query +# Stdout: the HTTP response body +# Stderr: an error message, if we failed to query +# Return: +# 0 on success +# nonzero on error +function query_fee_endpoint() { + local fee_endpoint="$1" + local response= + local http_status_code= + + response="$(curl -sL -w "\n%{http_code}" "$fee_endpoint" || true)"; + http_status_code="$(echo "$response" | tail -n 1)"; + case $http_status_code in + 200) + ;; + 429) + echo >&2 "WARN[$(date +%s)]: 429 Rate-Limited retreiving ${fee_endpoint}" + return 1 + ;; + 404) + echo >&2 "WARN[$(date +%s)]: 404 Not Found retrieving ${fee_endpoint}" + return 1 + ;; + **) + echo >&2 "WARN[$(date +%s)]: ${http_status_code} Error retrieving ${fee_endpoint}" + return 1 + ;; + esac + echo "$response" | head -n -1 + return 0 +} + # Determine satoshis per vbyte # Arguments: none # Stdout: the satoshis per vbyte, as an integer @@ -81,8 +115,10 @@ function get_sats_per_vbyte() { local fee_endpoint="https://api.blockcypher.com/v1/btc/main" local fee_per_kb= - fee_per_kb="$(curl -sL "$fee_endpoint" | jq -r '.high_fee_per_kb')" - fee_per_kb_to_fee_per_vbyte "$fee_per_kb" + fee_per_kb="$(query_fee_endpoint "$fee_endpoint" | jq -r '.high_fee_per_kb')" + if ! fee_per_kb_to_fee_per_vbyte "$fee_per_kb"; then + return 1 + fi return 0 } @@ -196,6 +232,10 @@ function main() { config_path="$2" interval="$3" + if ! [ -f "$config_path" ]; then + exit_error "No such config file: ${config_path}" + fi + watch_fees "$config_path" "$interval" ;;