catchup: per-protocol dispatch for bitcoin, cosmos, and ripple

Extend catchup.sh with protocol-aware branches (bitcoin height-gain,
cosmos CometBFT timestamp via timestamp.sh, ripple validated-ledger
seq) and explicit degradation for closed RPC / unsupported protocols.
Reuse sync-status/check-health idioms for protocol resolution and parsing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-03 03:10:24 +00:00
parent a98ced8af1
commit f8915ed59f
2 changed files with 534 additions and 3 deletions

View File

@@ -32,6 +32,495 @@ s_to_human_readable() {
BASEPATH="$(dirname "$0")"
source $BASEPATH/.env 2>/dev/null || true
compose_base="$1"
seconds_to_measure=${2:-10}
if [ -z "$compose_base" ]; then
echo -e "${RED}Error: compose file path (sans .yml) required${NC}"
exit 1
fi
compose_yml="$BASEPATH/${compose_base}.yml"
if [ ! -f "$compose_yml" ]; then
echo -e "${RED}Error: compose file not found: $compose_yml${NC}"
exit 1
fi
if [ -n "$NO_SSL" ]; then
PROTO="http"
DOMAIN="${DOMAIN:-0.0.0.0}"
else
PROTO="https"
fi
# Resolve traefik RPC path from compose labels (first non-blacklisted path).
compose_rpc_path() {
local pathlist path
pathlist=$(grep -oP "stripprefix\.prefixes.*?/\K[^\"]+" "$compose_yml" 2>/dev/null)
for path in $pathlist; do
echo "$path"
return 0
done
return 1
}
compose_rpc_url() {
local path
path=$(compose_rpc_path) || return 1
echo "$PROTO://$DOMAIN/$path"
}
compose_chain_slug() {
grep -oP '^\s*chain:\s*\K\S+' "$compose_yml" 2>/dev/null | head -1
}
# Registry protocol via x-upstreams chain label; path-substring fallback for legacy composes.
resolve_protocol() {
local chain_slug protocol path
chain_slug=$(compose_chain_slug)
protocol=""
if [ -n "$chain_slug" ]; then
protocol=$($BASEPATH/reference-rpc-endpoint.sh --protocol "$chain_slug" 2>/dev/null) || protocol=""
fi
if [ -z "$protocol" ]; then
path=$(compose_rpc_path 2>/dev/null) || path=""
if echo "$path" | grep -qi "starknet"; then
protocol="starknet"
elif echo "$path" | grep -qi "aztec"; then
protocol="aztec"
elif echo "$path" | grep -qiE "bitcoin|digibyte|dogecoin|dash"; then
protocol="bitcoin"
elif echo "$path" | grep -qi "ripple"; then
protocol="ripple"
elif echo "$path" | grep -qiE "cosmos|babylon|haqq|gaiad|tac"; then
protocol="cosmos"
else
protocol="eth"
fi
fi
echo "$protocol"
}
# Bitcoin-family RPC URL (traefik path) and basic-auth from x-upstreams (fallback username:password).
bitcoin_rpc_url() {
compose_rpc_url
}
bitcoin_rpc_auth() {
# Prefer creds from compose x-upstreams basic-auth; per-chain rpcauth may differ on-host.
local user pass
user=$(grep -A5 'basic-auth:' "$compose_yml" 2>/dev/null | grep -oP '^\s*username:\s*\K\S+' | head -1)
pass=$(grep -A5 'basic-auth:' "$compose_yml" 2>/dev/null | grep -oP '^\s*password:\s*\K\S+' | head -1)
[ -z "$user" ] && user="username"
[ -z "$pass" ] && pass="password"
echo "${user}:${pass}"
}
bitcoin_rpc_call() {
local method="$1"
local params="${2:-[]}"
local url auth
url=$(bitcoin_rpc_url) || return 1
auth=$(bitcoin_rpc_auth)
curl -L --ipv4 -m 5 -s -u "$auth" -X POST -H "Content-Type: application/json" \
--data "{\"jsonrpc\":\"1.0\",\"id\":1,\"method\":\"$method\",\"params\":$params}" "$url"
}
bitcoin_parse_blocks_headers() {
local info="$1"
local blocks headers
blocks=$(echo "$info" | jq -r '.result.blocks // empty' 2>/dev/null)
headers=$(echo "$info" | jq -r '.result.headers // empty' 2>/dev/null)
if [[ ! "$blocks" =~ ^[0-9]+$ ]] || [[ ! "$headers" =~ ^[0-9]+$ ]]; then
return 1
fi
echo "$blocks $headers"
}
bitcoin_reference_height() {
local chain_slug="$1"
local ref_urls ref_url resp height
ref_urls=$($BASEPATH/reference-rpc-endpoint.sh --chain "$chain_slug" 2>/dev/null) || return 1
ref_url=$(echo "$ref_urls" | awk '{print $1}')
[ -z "$ref_url" ] && return 1
resp=$(curl -L --ipv4 -m 5 -s -X POST -H "Content-Type: application/json" \
--data '{"jsonrpc":"1.0","id":1,"method":"getblockcount","params":[]}' "$ref_url")
if [ -z "$resp" ] || echo "$resp" | grep -qi 'Unknown network'; then
return 2
fi
height=$(echo "$resp" | jq -r '.result // empty' 2>/dev/null)
if [[ ! "$height" =~ ^[0-9]+$ ]]; then
return 2
fi
echo "$height"
}
bitcoin_pick_seed_peer() {
local chain_slug="$1" path
path=$(compose_rpc_path 2>/dev/null) || path=""
if echo "$chain_slug $path" | grep -qi "digibyte"; then
echo "seed.digibyte.co:12024"
return 0
fi
if echo "$chain_slug $path" | grep -qi "dogecoin"; then
# TODO: dogecoin wire seeds
return 1
fi
if echo "$chain_slug $path" | grep -qi "dash"; then
# TODO: dash wire seeds
return 1
fi
# Bitcoin mainnet / testnet / cash family default
echo "seed.bitcoin.sipa.be:8333"
}
bitcoin_try_addnode() {
local chain_slug="$1" peer resp
peer=""
local ref_urls ref_url pinfo
ref_urls=$($BASEPATH/reference-rpc-endpoint.sh --chain "$chain_slug" 2>/dev/null) || ref_urls=""
ref_url=$(echo "$ref_urls" | awk '{print $1}')
if [ -n "$ref_url" ]; then
pinfo=$(curl -L --ipv4 -m 5 -s -X POST -H "Content-Type: application/json" \
--data '{"jsonrpc":"1.0","id":1,"method":"getpeerinfo","params":[]}' "$ref_url" 2>/dev/null)
peer=$(echo "$pinfo" | jq -r '.result[0].addr // empty' 2>/dev/null)
fi
if [ -z "$peer" ] || [ "$peer" = "null" ]; then
peer=$(bitcoin_pick_seed_peer "$chain_slug") || return 0
fi
resp=$(bitcoin_rpc_call "addnode" "[\"$peer\", \"onetry\"]")
if echo "$resp" | jq -e '.error == null' >/dev/null 2>&1; then
echo "Issued harmless addnode onetry to $peer"
fi
}
report_block_catchup_rate() {
local gap_change="$1"
local interval="$2"
local block_time_ms="$3"
local gap_remaining="$4"
local label="${5:-blocks}"
local block_time_sec realtime_units effective catchup_rate
block_time_sec=$(echo "scale=6; $block_time_ms / 1000" | bc)
realtime_units=$(echo "scale=6; $interval / $block_time_sec" | bc)
effective=$(echo "scale=6; $realtime_units - $gap_change" | bc)
catchup_rate=$(echo "scale=3; $effective / $realtime_units" | bc)
echo "First measurement: $label behind = $((gap_remaining - gap_change)) $label"
echo "Second measurement: $label behind = $gap_remaining $label"
echo "Gap change in $interval seconds: $gap_change $label"
echo "Calculated catchup rate: ${catchup_rate}x realtime"
if (( $(echo "$catchup_rate < 0" | bc -l) )); then
local falling_behind_rate
falling_behind_rate=$(echo "scale=2; -1 * $catchup_rate" | bc)
echo -e "${RED}Node is FALLING BEHIND by ${falling_behind_rate}x realtime${NC}"
echo -e "${RED}The gap increased by $gap_change $label over $interval seconds${NC}"
return 1
elif (( $(echo "$catchup_rate < 1" | bc -l) )); then
echo -e "${YELLOW}WARNING: Node is processing at ${catchup_rate}x realtime${NC}"
echo -e "${YELLOW}This is too slow - node will never catch up to chain head${NC}"
return 2
else
echo -e "${GREEN}Node is processing at ${catchup_rate}x realtime${NC}"
local time_to_catchup
time_to_catchup=$(echo "scale=0; $gap_remaining * $block_time_sec / ($catchup_rate - 1)" | bc)
echo -e "${GREEN}Estimated time until synced:${NC}"
s_to_human_readable "$time_to_catchup"
return 0
fi
}
bitcoin_catchup() {
local chain_slug="$1"
local interval="$2"
local block_time_ms info1 info2 blocks1 headers1 blocks2 headers2
local ref1 ref2 use_pseudo=false gap1 gap2 gap_change
block_time_ms=$($BASEPATH/reference-rpc-endpoint.sh --block-time-ms "$chain_slug" 2>/dev/null) || block_time_ms="600000"
echo "Checking sync status for $compose_base (bitcoin-family)..."
info1=$(bitcoin_rpc_call "getblockchaininfo")
if [ -z "$info1" ]; then
echo -e "${RED}rpc-not-open (expected mid-sync for stage-sync/bootstrap clients — avalanchego, erigon/reth stage-sync, statesync bootstraps); re-run at chainhead${NC}"
exit 1
fi
read -r blocks1 headers1 < <(bitcoin_parse_blocks_headers "$info1") || {
echo -e "${RED}rpc-not-open (expected mid-sync for stage-sync/bootstrap clients — avalanchego, erigon/reth stage-sync, statesync bootstraps); re-run at chainhead${NC}"
exit 1
}
ref1=$(bitcoin_reference_height "$chain_slug")
local ref_rc1=$?
if [ "$ref_rc1" -eq 2 ]; then
use_pseudo=true
ref1=$headers1
echo "Reference RPC unavailable (Unknown network) — using local headers as pseudo-reference"
elif [ "$ref_rc1" -ne 0 ]; then
use_pseudo=true
ref1=$headers1
echo "Reference RPC unavailable — using local headers as pseudo-reference"
fi
gap1=$((ref1 - blocks1))
echo "Current chain head is $gap1 blocks behind reference"
s_to_human_readable $(echo "scale=0; $gap1 * $block_time_ms / 1000" | bc)
echo "Measuring progress over $interval seconds..."
sleep "$interval"
info2=$(bitcoin_rpc_call "getblockchaininfo")
if [ -z "$info2" ]; then
echo -e "${RED}rpc-not-open (expected mid-sync for stage-sync/bootstrap clients — avalanchego, erigon/reth stage-sync, statesync bootstraps); re-run at chainhead${NC}"
exit 1
fi
read -r blocks2 headers2 < <(bitcoin_parse_blocks_headers "$info2") || {
echo -e "${RED}rpc-not-open (expected mid-sync for stage-sync/bootstrap clients — avalanchego, erigon/reth stage-sync, statesync bootstraps); re-run at chainhead${NC}"
exit 1
}
if $use_pseudo; then
ref2=$headers2
else
ref2=$(bitcoin_reference_height "$chain_slug")
local ref_rc2=$?
if [ "$ref_rc2" -ne 0 ]; then
ref2=$headers2
use_pseudo=true
fi
fi
gap2=$((ref2 - blocks2))
gap_change=$((gap2 - gap1))
report_block_catchup_rate "$gap_change" "$interval" "$block_time_ms" "$gap2" "blocks"
local rc=$?
if [ "$rc" -eq 1 ] && [ -n "$chain_slug" ]; then
bitcoin_try_addnode "$chain_slug"
fi
exit "$rc"
}
ripple_rpc_call() {
local url
url=$(compose_rpc_url) || return 1
curl -L --ipv4 -m 5 -s -X POST -H "Content-Type: application/json" \
--data '{"method":"server_info"}' "$url"
}
ripple_validated_seq() {
local resp="$1" seq
seq=$(echo "$resp" | jq -r '.result.info.validated_ledger.seq // .info.validated_ledger.seq // empty' 2>/dev/null)
if [[ ! "$seq" =~ ^[0-9]+$ ]]; then
return 1
fi
echo "$seq"
}
ripple_reference_seq() {
local chain_slug="$1" ref_urls ref_url resp
ref_urls=$($BASEPATH/reference-rpc-endpoint.sh --chain "$chain_slug" 2>/dev/null) || return 1
ref_url=$(echo "$ref_urls" | awk '{print $1}')
[ -z "$ref_url" ] && return 1
resp=$(curl -L --ipv4 -m 5 -s -X POST -H "Content-Type: application/json" \
--data '{"method":"server_info"}' "$ref_url")
ripple_validated_seq "$resp"
}
ripple_catchup() {
local chain_slug="$1"
local interval="$2"
local block_time_ms resp1 resp2 seq1 seq2 ref1 ref2 gap1 gap2 gap_change
block_time_ms=$($BASEPATH/reference-rpc-endpoint.sh --block-time-ms "$chain_slug" 2>/dev/null) || block_time_ms="4000"
echo "Checking sync status for $compose_base (ripple)..."
resp1=$(ripple_rpc_call)
if [ -z "$resp1" ]; then
echo -e "${RED}rpc-not-open (expected mid-sync for stage-sync/bootstrap clients — avalanchego, erigon/reth stage-sync, statesync bootstraps); re-run at chainhead${NC}"
exit 1
fi
seq1=$(ripple_validated_seq "$resp1") || {
echo -e "${RED}rpc-not-open (expected mid-sync for stage-sync/bootstrap clients — avalanchego, erigon/reth stage-sync, statesync bootstraps); re-run at chainhead${NC}"
exit 1
}
ref1=$(ripple_reference_seq "$chain_slug") || ref1=""
if [ -z "$ref1" ]; then
gap1=0
echo "Reference RPC unavailable — measuring local validated-ledger progress only"
else
gap1=$((ref1 - seq1))
echo "Current chain head is $gap1 ledgers behind reference"
s_to_human_readable $(echo "scale=0; $gap1 * $block_time_ms / 1000" | bc)
fi
echo "Measuring progress over $interval seconds..."
sleep "$interval"
resp2=$(ripple_rpc_call)
if [ -z "$resp2" ]; then
echo -e "${RED}rpc-not-open (expected mid-sync for stage-sync/bootstrap clients — avalanchego, erigon/reth stage-sync, statesync bootstraps); re-run at chainhead${NC}"
exit 1
fi
seq2=$(ripple_validated_seq "$resp2") || {
echo -e "${RED}rpc-not-open (expected mid-sync for stage-sync/bootstrap clients — avalanchego, erigon/reth stage-sync, statesync bootstraps); re-run at chainhead${NC}"
exit 1
}
ref2=$(ripple_reference_seq "$chain_slug") || ref2=""
if [ -z "$ref1" ] || [ -z "$ref2" ]; then
local seq_gain=$((seq2 - seq1))
local realtime_units effective catchup_rate
realtime_units=$(echo "scale=6; $interval * 1000 / $block_time_ms" | bc)
effective=$(echo "scale=6; $seq_gain" | bc)
catchup_rate=$(echo "scale=3; $effective / $realtime_units" | bc)
echo "Validated ledger gain in $interval seconds: $seq_gain ledgers"
echo "Calculated catchup rate: ${catchup_rate}x realtime"
if (( $(echo "$catchup_rate < 1" | bc -l) )); then
if (( $(echo "$catchup_rate < 0" | bc -l) )); then
echo -e "${RED}Node is FALLING BEHIND${NC}"
exit 1
fi
echo -e "${YELLOW}WARNING: Node is processing at ${catchup_rate}x realtime${NC}"
exit 2
fi
echo -e "${GREEN}Node is processing at ${catchup_rate}x realtime${NC}"
exit 0
fi
gap2=$((ref2 - seq2))
gap_change=$((gap2 - gap1))
report_block_catchup_rate "$gap_change" "$interval" "$block_time_ms" "$gap2" "ledgers"
exit $?
}
cosmos_catching_up() {
local url status catching_up
url=$(compose_rpc_url) || return 1
status=$(curl -L --ipv4 -m 5 -s -X POST -H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","id":1,"method":"status"}' "$url")
catching_up=$(echo "$status" | jq -r '.result.sync_info.catching_up // .sync_info.catching_up // empty' 2>/dev/null)
echo "$catching_up"
}
cosmos_catchup() {
local catching_up
echo "Checking sync status for $compose_base (cosmos/cometbft)..."
catching_up=$(cosmos_catching_up)
if [ "$catching_up" = "true" ]; then
echo "CometBFT reports catching_up=true (node is still syncing)"
elif [ -z "$catching_up" ]; then
echo -e "${RED}rpc-not-open (expected mid-sync for stage-sync/bootstrap clients — avalanchego, erigon/reth stage-sync, statesync bootstraps); re-run at chainhead${NC}"
exit 1
fi
# Standard timestamp-lag algorithm via timestamp.sh (cosmos /status source).
echo "Debug: Running ./timestamp.sh $compose_base"
timestamp_output=$(./timestamp.sh "$compose_base" 2>&1)
timestamp_exit_code=$?
echo "Debug: timestamp.sh output: '$timestamp_output'"
echo "Debug: timestamp.sh exit code: $timestamp_exit_code"
latest_block_timestamp_decimal=$(echo "$timestamp_output" | grep -o '[0-9]\+' | head -1)
if [[ $timestamp_exit_code -ne 0 || -z "$latest_block_timestamp_decimal" ]]; then
echo -e "${RED}rpc-not-open (expected mid-sync for stage-sync/bootstrap clients — avalanchego, erigon/reth stage-sync, statesync bootstraps); re-run at chainhead${NC}"
exit 1
fi
current_time=$(date +%s)
time_difference=$((current_time - latest_block_timestamp_decimal))
echo "Current chain head is $time_difference seconds behind real time"
if [ "$catching_up" = "true" ]; then
echo "(catching_up=true)"
fi
s_to_human_readable $time_difference
echo "Measuring progress over $seconds_to_measure seconds..."
sleep $seconds_to_measure
catching_up=$(cosmos_catching_up)
if [ "$catching_up" = "true" ]; then
echo "CometBFT still reports catching_up=true"
fi
echo "Debug: Running ./timestamp.sh $compose_base (second measurement)"
timestamp_output=$(./timestamp.sh "$compose_base" 2>&1)
timestamp_exit_code=$?
echo "Debug: timestamp.sh output (second): '$timestamp_output'"
echo "Debug: timestamp.sh exit code (second): $timestamp_exit_code"
latest_block_timestamp2=$(echo "$timestamp_output" | grep -o '[0-9]\+' | head -1)
if [[ $timestamp_exit_code -ne 0 || -z "$latest_block_timestamp2" ]]; then
echo -e "${RED}rpc-not-open (expected mid-sync for stage-sync/bootstrap clients — avalanchego, erigon/reth stage-sync, statesync bootstraps); re-run at chainhead${NC}"
exit 1
fi
current_time2=$(date +%s)
time_difference2=$((current_time2 - latest_block_timestamp2))
gap_change=$((time_difference2 - time_difference))
effective_catchup_seconds=$((seconds_to_measure - gap_change))
catchup_rate=$(echo "scale=3; $effective_catchup_seconds / $seconds_to_measure" | bc)
echo "First measurement: Time behind = $time_difference seconds"
echo "Second measurement: Time behind = $time_difference2 seconds"
if [ "$catching_up" = "true" ]; then
echo "(catching_up=true)"
fi
echo "Gap change in $seconds_to_measure seconds: $gap_change seconds"
echo "Calculated catchup rate: $catchup_rate× realtime"
if (( $(echo "$catchup_rate < 0" | bc -l) )); then
falling_behind_rate=$(echo "scale=2; -1 * $catchup_rate" | bc)
echo -e "${RED}Node is FALLING BEHIND by $falling_behind_rate× realtime${NC}"
echo -e "${RED}The gap increased by $gap_change seconds over $seconds_to_measure seconds${NC}"
exit 1
elif (( $(echo "$catchup_rate < 1" | bc -l) )); then
echo -e "${YELLOW}WARNING: Node is processing at $catchup_rate× realtime${NC}"
echo -e "${YELLOW}This is too slow - node will never catch up to chain head${NC}"
exit 2
else
echo -e "${GREEN}Node is processing at $catchup_rate× realtime${NC}"
time_to_catchup=$(echo "scale=0; $time_difference2 / ($catchup_rate - 1)" | bc)
echo -e "${GREEN}Estimated time until synced:${NC}"
s_to_human_readable $time_to_catchup
exit 0
fi
}
protocol=$(resolve_protocol)
chain_slug=$(compose_chain_slug)
case "$protocol" in
bitcoin)
bitcoin_catchup "$chain_slug" "$seconds_to_measure"
;;
ripple)
ripple_catchup "$chain_slug" "$seconds_to_measure"
;;
cosmos)
cosmos_catchup
;;
eth|starknet|aztec)
;;
*)
echo "unsupported protocol $protocol for catchup"
exit 1
;;
esac
# --- eth / starknet / aztec: original timestamp-lag path (unchanged) ---
seconds_to_measure=${2:-10}
# First measurement

View File

@@ -16,7 +16,15 @@ else
PROTO="https"
fi
pathlist=$(cat $BASEPATH/$1.yml | grep -oP "stripprefix\.prefixes.*?/\K[^\"]+")
compose_yml="$BASEPATH/$1.yml"
pathlist=$(cat "$compose_yml" | grep -oP "stripprefix\.prefixes.*?/\K[^\"]+")
# Resolve protocol from x-upstreams chain label (reuse sync-status / catchup idiom).
chain_slug=$(grep -oP '^\s*chain:\s*\K\S+' "$compose_yml" 2>/dev/null | head -1)
protocol=""
if [ -n "$chain_slug" ]; then
protocol=$($BASEPATH/reference-rpc-endpoint.sh --protocol "$chain_slug" 2>/dev/null) || protocol=""
fi
for path in $pathlist; do
include=true
@@ -30,6 +38,40 @@ for path in $pathlist; do
RPC_URL="$PROTO://$DOMAIN/$path"
response_file=$(mktemp)
path_protocol="$protocol"
if [ -z "$path_protocol" ]; then
if echo "$path" | grep -qi "starknet"; then
path_protocol="starknet"
elif echo "$path" | grep -qi "aztec"; then
path_protocol="aztec"
elif echo "$path" | grep -qiE "cosmos|babylon|haqq|gaiad|tac"; then
path_protocol="cosmos"
else
path_protocol="eth"
fi
fi
if [ "$path_protocol" = "cosmos" ]; then
http_status_code=$(curl -L --ipv4 -m 1 -s -X POST -w "%{http_code}" -o "$response_file" \
-H "Content-Type: application/json" --data '{"jsonrpc":"2.0","id":1,"method":"status"}' "$RPC_URL")
if [ $? -eq 0 ] && [[ $http_status_code -eq 200 ]]; then
response=$(cat "$response_file")
latest_block_time=$(echo "$response" | jq -r '.result.sync_info.latest_block_time // .sync_info.latest_block_time' 2>/dev/null)
rm -f "$response_file"
if [ -z "$latest_block_time" ] || [ "$latest_block_time" = "null" ]; then
exit 1
fi
latest_block_timestamp_decimal=$(date -d "$latest_block_time" +%s 2>/dev/null)
if [ -z "$latest_block_timestamp_decimal" ]; then
exit 1
fi
echo "$latest_block_timestamp_decimal"
exit 0
fi
rm -f "$response_file"
exit 1
fi
# Detect Starknet vs Ethereum vs Aztec based on path
if echo "$path" | grep -qi "starknet"; then
rpc_method='{"jsonrpc":"2.0","method":"starknet_getBlockWithTxHashes","params":["latest"],"id":1}'