Skip to content

Commit 3699566

Browse files
authored
Merge pull request #4077 from stacks-network/feat/fee-estimate-side-car
[Node] side-car script to get sat/vbyte fee estimations
2 parents f6d59f8 + 0224b1a commit 3699566

File tree

1 file changed

+256
-0
lines changed

1 file changed

+256
-0
lines changed

contrib/side-cars/fee-estimate.sh

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
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

Comments
 (0)