|
| 1 | +#!/bin/bash |
| 2 | + |
| 3 | +#################################### |
| 4 | +# Usage |
| 5 | +# |
| 6 | +# $ # one-shot fee-rate calculation |
| 7 | +# $ ./fee-estimate.sh |
| 8 | +# 161 |
| 9 | +# |
| 10 | +# $ # Check fees every 5 seconds and update `satoshis_per_byte` in `/path/to/miner.toml` |
| 11 | +# $ ./fee-estimate.sh watch /path/to/miner.toml 5 |
| 12 | +# |
| 13 | +# $ # Run unit tests and report result (0 means success) |
| 14 | +# $ ./fee-estimate.sh test; echo $? |
| 15 | +# 0 |
| 16 | +#################################### |
| 17 | + |
| 18 | +set -uoe pipefail |
| 19 | + |
| 20 | +function exit_error() { |
| 21 | + echo >&2 "$@" |
| 22 | + exit 1 |
| 23 | +} |
| 24 | + |
| 25 | +#################################### |
| 26 | +# Dependencies |
| 27 | +#################################### |
| 28 | +for cmd in curl jq bc sed date grep head tail; do |
| 29 | + command -v "$cmd" >/dev/null 2>&1 || exit_error "Command not found: '$cmd'" |
| 30 | +done |
| 31 | + |
| 32 | + |
| 33 | +#################################### |
| 34 | +# Functions |
| 35 | +#################################### |
| 36 | + |
| 37 | +# Convert a fee/kb to fee/vbyte. |
| 38 | +# If there's a fractional part of the fee/kb (i.e. if it's not divisible by 1000), |
| 39 | +# then round up. |
| 40 | +# Arguments: |
| 41 | +# $1 -- the fee per kb |
| 42 | +# Stdout: the satoshis per vbyte, as an integer |
| 43 | +# Stderr: none |
| 44 | +# Return: |
| 45 | +# 0 on success |
| 46 | +# nonzero on error |
| 47 | +function fee_per_kb_to_fee_per_vbyte() { |
| 48 | + local fee_per_kb="$1" |
| 49 | + local fee_per_vbyte_float= |
| 50 | + local fee_per_vbyte_ipart= |
| 51 | + local fee_per_vbyte_fpart= |
| 52 | + local fee_per_vbyte= |
| 53 | + |
| 54 | + # must be an integer |
| 55 | + if ! [[ "$fee_per_kb" =~ ^[0-9]+$ ]]; then |
| 56 | + return 1 |
| 57 | + fi |
| 58 | + |
| 59 | + # NOTE: round up -- get the fractional part, and if it's anything other than 000, then add 1 |
| 60 | + fee_per_vbyte_float="$(echo "scale=3; $fee_per_kb / 1000" | bc)" |
| 61 | + fee_per_vbyte_ipart="$(echo "$fee_per_vbyte_float" | sed -r 's/^([0-9]*)\..+$/\1/g')" |
| 62 | + fee_per_vbyte_fpart="$(echo "$fee_per_vbyte_float" | sed -r -e 's/.+\.([0-9]+)$/\1/g' -e 's/0//g')" |
| 63 | + fee_per_vbyte="$fee_per_vbyte_ipart" |
| 64 | + if [ -n "$fee_per_vbyte_fpart" ]; then |
| 65 | + fee_per_vbyte="$((fee_per_vbyte + 1))" |
| 66 | + fi |
| 67 | + |
| 68 | + echo "$fee_per_vbyte" |
| 69 | + return 0 |
| 70 | +} |
| 71 | + |
| 72 | +# Query the endpoint and log HTTP errors gracefully |
| 73 | +# Arguments: |
| 74 | +# $1 endpoint to query |
| 75 | +# Stdout: the HTTP response body |
| 76 | +# Stderr: an error message, if we failed to query |
| 77 | +# Return: |
| 78 | +# 0 on success |
| 79 | +# nonzero on error |
| 80 | +function query_fee_endpoint() { |
| 81 | + local fee_endpoint="$1" |
| 82 | + local response= |
| 83 | + local http_status_code= |
| 84 | + |
| 85 | + response="$(curl -sL -w "\n%{http_code}" "$fee_endpoint" || true)"; |
| 86 | + http_status_code="$(echo "$response" | tail -n 1)"; |
| 87 | + case $http_status_code in |
| 88 | + 200) |
| 89 | + ;; |
| 90 | + 429) |
| 91 | + echo >&2 "WARN[$(date +%s)]: 429 Rate-Limited retreiving ${fee_endpoint}" |
| 92 | + return 1 |
| 93 | + ;; |
| 94 | + 404) |
| 95 | + echo >&2 "WARN[$(date +%s)]: 404 Not Found retrieving ${fee_endpoint}" |
| 96 | + return 1 |
| 97 | + ;; |
| 98 | + **) |
| 99 | + echo >&2 "WARN[$(date +%s)]: ${http_status_code} Error retrieving ${fee_endpoint}" |
| 100 | + return 1 |
| 101 | + ;; |
| 102 | + esac |
| 103 | + echo "$response" | head -n -1 |
| 104 | + return 0 |
| 105 | +} |
| 106 | + |
| 107 | +# Determine satoshis per vbyte |
| 108 | +# Arguments: none |
| 109 | +# Stdout: the satoshis per vbyte, as an integer |
| 110 | +# Stderr: none |
| 111 | +# Return: |
| 112 | +# 0 on success |
| 113 | +# nonzero on error |
| 114 | +function get_sats_per_vbyte() { |
| 115 | + local fee_endpoint="https://api.blockcypher.com/v1/btc/main" |
| 116 | + local fee_per_kb= |
| 117 | + |
| 118 | + fee_per_kb="$(query_fee_endpoint "$fee_endpoint" | jq -r '.high_fee_per_kb')" |
| 119 | + if ! fee_per_kb_to_fee_per_vbyte "$fee_per_kb"; then |
| 120 | + return 1 |
| 121 | + fi |
| 122 | + return 0 |
| 123 | +} |
| 124 | + |
| 125 | +# Update the fee rate in the config file. |
| 126 | +# Arguments: |
| 127 | +# $1 -- path to the config file |
| 128 | +# $2 -- new fee to write |
| 129 | +# Stdout: (none) |
| 130 | +# Stderr: (none) |
| 131 | +# Returns: |
| 132 | +# 0 on success |
| 133 | +# nonzero on error |
| 134 | +function update_fee() { |
| 135 | + local config_path="$1" |
| 136 | + local fee="$2" |
| 137 | + sed -i -r "s/satoshis_per_byte[ \t]+=.*$/satoshis_per_byte = ${fee}/g" "$config_path" |
| 138 | + return 0 |
| 139 | +} |
| 140 | + |
| 141 | +# Poll fees every so often, and update a config file. |
| 142 | +# Runs indefinitely. |
| 143 | +# If the fee estimator endpoint cannot be reached, then the file is not modified. |
| 144 | +# Arguments: |
| 145 | +# $1 -- path to file to watch |
| 146 | +# $2 -- interval at which to poll, in seconds |
| 147 | +# Stdout: (none) |
| 148 | +# Stderr: (none) |
| 149 | +# Returns: (none) |
| 150 | +function watch_fees() { |
| 151 | + local config_path="$1" |
| 152 | + local interval="$2" |
| 153 | + |
| 154 | + local fee= |
| 155 | + local rc= |
| 156 | + |
| 157 | + while true; do |
| 158 | + # allow poll command to fail without killing the script |
| 159 | + set +e |
| 160 | + fee="$(get_sats_per_vbyte)" |
| 161 | + rc="$?" |
| 162 | + set -e |
| 163 | + |
| 164 | + if [ $rc -ne 0 ]; then |
| 165 | + echo >&2 "WARN[$(date +%s)]: failed to poll fees" |
| 166 | + else |
| 167 | + update_fee "$config_path" "$fee" |
| 168 | + fi |
| 169 | + sleep "$interval" |
| 170 | + done |
| 171 | +} |
| 172 | + |
| 173 | +# Unit tests |
| 174 | +function unit_test() { |
| 175 | + local test_config="/tmp/test-miner-config-$$.toml" |
| 176 | + if [ "$(fee_per_kb_to_fee_per_vbyte 1000)" != "1" ]; then |
| 177 | + exit_error "failed -- 1000 sats/kbyte != 1 sats/vbyte" |
| 178 | + fi |
| 179 | + |
| 180 | + if [ "$(fee_per_kb_to_fee_per_vbyte 1001)" != "2" ]; then |
| 181 | + exit_error "failed -- 1001 sats/vbyte != 2 sats/vbyte" |
| 182 | + fi |
| 183 | + |
| 184 | + if [ "$(fee_per_kb_to_fee_per_vbyte 999)" != "1" ]; then |
| 185 | + exit_error "failed -- 999 sats/vbyte != 1 sats/vbyte" |
| 186 | + fi |
| 187 | + |
| 188 | + echo "satoshis_per_byte = 123" > "$test_config" |
| 189 | + update_fee "$test_config" "456" |
| 190 | + if ! grep 'satoshis_per_byte = 456' >/dev/null "$test_config"; then |
| 191 | + exit_error "failed -- did not update satoshis_per_byte" |
| 192 | + fi |
| 193 | + |
| 194 | + echo "" > "$test_config" |
| 195 | + update_fee "$test_config" "456" |
| 196 | + if grep "satoshis_per_byte" "$test_config" >/dev/null; then |
| 197 | + exit_error "failed -- updated satoshis_per_byte in a config file without it" |
| 198 | + fi |
| 199 | + |
| 200 | + rm "$test_config" |
| 201 | + return 0 |
| 202 | +} |
| 203 | + |
| 204 | +#################################### |
| 205 | +# Entry point |
| 206 | +#################################### |
| 207 | + |
| 208 | +# Main body |
| 209 | +# Arguments |
| 210 | +# $1: mode of operation. Can be "test" or empty |
| 211 | +# Stdout: the fee rate, in sats/vbte |
| 212 | +# Stderr: None |
| 213 | +# Return: (no return) |
| 214 | +function main() { |
| 215 | + local mode="$1" |
| 216 | + local config_path= |
| 217 | + local interval= |
| 218 | + |
| 219 | + case "$mode" in |
| 220 | + "test") |
| 221 | + # run unit tests |
| 222 | + echo "Run unit tests" |
| 223 | + unit_test |
| 224 | + exit 0 |
| 225 | + ;; |
| 226 | + "watch") |
| 227 | + # watch and update the file |
| 228 | + if (( $# < 3 )); then |
| 229 | + exit_error "Usage: $0 watch /path/to/miner.toml interval_in_seconds" |
| 230 | + fi |
| 231 | + |
| 232 | + config_path="$2" |
| 233 | + interval="$3" |
| 234 | + |
| 235 | + if ! [ -f "$config_path" ]; then |
| 236 | + exit_error "No such config file: ${config_path}" |
| 237 | + fi |
| 238 | + |
| 239 | + watch_fees "$config_path" "$interval" |
| 240 | + ;; |
| 241 | + |
| 242 | + "") |
| 243 | + # one-shot |
| 244 | + get_sats_per_vbyte |
| 245 | + ;; |
| 246 | + esac |
| 247 | + exit 0 |
| 248 | +} |
| 249 | + |
| 250 | +if (( $# > 0 )); then |
| 251 | + # got arguments |
| 252 | + main "$@" |
| 253 | +else |
| 254 | + # no arguments |
| 255 | + main "" |
| 256 | +fi |
0 commit comments