From 6450ad08dbe1c7a76aed5d526dad980819b64a89 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 3 Oct 2026 15:26:29 -0700 Subject: [PATCH 1/7] Add two-hub regtest failover venue Provide independent federated hubs, seeded BTC/LTC/DOGE indexers, and a hub-B-pinned BTC control. Document the service contract used by the failover drill. --- .github/hub-failover/README.md | 33 ++ .github/hub-failover/compose.yml | 637 +++++++++++++++++++++++++++++++ 2 files changed, 670 insertions(+) create mode 100644 .github/hub-failover/README.md create mode 100644 .github/hub-failover/compose.yml diff --git a/.github/hub-failover/README.md b/.github/hub-failover/README.md new file mode 100644 index 00000000..c43bf0ec --- /dev/null +++ b/.github/hub-failover/README.md @@ -0,0 +1,33 @@ +# Two-hub regtest failover venue + +This Compose venue gives the HF-15b drill two federated hubs with independent databases, three seed-driven indexers, and a BTC control indexer pinned to hub B. The BTC, LTC, and DOGE indexers all use `http://hub-a:10000,http://hub-b:10000` as `HUB_SEED_URLS`. `btc-indexer-control` has no seed list and uses `HUB_API_URL=http://hub-b:10000`. + +Both hubs use the same `HUB_FEED_API_KEY`, `HUB_API_KEY`, and `HUB_REORG_API_KEY`. Their API and P2P endpoints, signing identities, peer seeds, and databases remain distinct. The one-shot `hub-federation-init` service registers both deterministic fixture signing identities in both hubs before any indexer starts. Fixture defaults are supplied for every interpolation so `docker compose config` works without a credential file. A CI caller can override the keys, database password, RPC credentials, seed list, and host ports through its environment. + +Start the venue from the `xchain-node` repository: + +```sh +docker compose -p hub-failover -f .github/hub-failover/compose.yml up -d --wait +``` + +HF-15b drives these service names: + +| Role | Compose service | Default host port | +| --- | --- | ---: | +| Hub A API | `hub-a` | 64230 | +| Hub A P2P/feed | `hub-a` | 64231 | +| Hub B API | `hub-b` | 64232 | +| Hub B P2P/feed | `hub-b` | 64233 | +| Seed-driven BTC indexer | `btc-indexer` | 64205 | +| Hub-B-pinned BTC control | `btc-indexer-control` | 64207 | +| Seed-driven LTC indexer | `ltc-indexer` | 64214 | +| Seed-driven DOGE indexer | `doge-indexer` | 64224 | +| BTC, LTC, DOGE miners | `btc-miner`, `ltc-miner`, `doge-miner` | 64206, 64215, 64225 | + +The drill records each seed-driven indexer's followed hub, stops that service with `docker compose stop hub-a` or `docker compose stop hub-b`, verifies movement and continued block progress, and restarts it with `docker compose start `. It compares the moved `btc-indexer` with `btc-indexer-control` only while hub B remains available. Hub data is separate in `XChain_Hub_A` and `XChain_Hub_B`, so stopping and restarting a hub exercises peer catch-up rather than a shared database. + +Stop the venue while preserving data with: + +```sh +docker compose -p hub-failover -f .github/hub-failover/compose.yml down +``` diff --git a/.github/hub-failover/compose.yml b/.github/hub-failover/compose.yml new file mode 100644 index 00000000..74f93d68 --- /dev/null +++ b/.github/hub-failover/compose.yml @@ -0,0 +1,637 @@ +x-indexer-environment: &indexer-environment + INDEXER_NETWORK: regtest + INDEXER_API_PORT: 3004 + INDEXER_DB_HOST: db + INDEXER_DB_PORT: 3306 + INDEXER_DB_USER: root + INDEXER_DB_PASS: ${DB_PASSWORD:-hub-failover-db} + DECODER_DB_HOST: db + DECODER_DB_PORT: 3306 + DECODER_DB_USER: root + DECODER_DB_PASS: ${DB_PASSWORD:-hub-failover-db} + HUB_API_KEY: ${HUB_API_KEY:-hub-failover-bulk} + HUB_FEED_API_KEY: ${HUB_FEED_API_KEY:-hub-failover-feed} + HUB_REORG_API_KEY: ${HUB_REORG_API_KEY:-hub-failover-reorg} + HUB_DB_HOST: db + HUB_DB_PORT: 3306 + HUB_DB_USER: root + HUB_DB_PASS: ${DB_PASSWORD:-hub-failover-db} + HUB_DB_SYNC_ENABLED: "true" + INDEXER_ALLOW_UNAUTHENTICATED: "true" + HUB_SYNC_PRICE_GRACE_S: 0 + HUB_SYNC_ORACLE_GRACE_S: 0 + HUB_SYNC_ATTEST_RESPONSE_GRACE_S: 0 + HUB_SYNC_MATCH_GRACE_S: 0 + HUB_SYNC_CALL_GRACE_S: 0 + HUB_SYNC_ANCHOR_ATTEST_GRACE_S: 0 + +x-failover-environment: &failover-environment + HUB_SEED_URLS: ${HUB_SEED_URLS:-http://hub-a:10000,http://hub-b:10000} + HUB_CONFIG_URL: http://hub-a:10000 + +x-hub-environment: &hub-environment + HUB_HOST: 0.0.0.0 + HUB_PORT: 10000 + HUB_DB_HOST: db + HUB_DB_PORT: 3306 + HUB_DB_USER: root + HUB_DB_SECRET: ${DB_PASSWORD:-hub-failover-db} + HUB_API_KEY: ${HUB_API_KEY:-hub-failover-bulk} + HUB_FEED_API_KEY: ${HUB_FEED_API_KEY:-hub-failover-feed} + HUB_REORG_API_KEY: ${HUB_REORG_API_KEY:-hub-failover-reorg} + HUB_NETWORK: regtest + HUB_P2P_FEED_ENABLED: "true" + P2P_HOST: 0.0.0.0 + P2P_PORT: 10001 + REQUIRE_SIGNATURES: "true" + ORACLE_EPOCH_START: 1704067200000 + MIN_VALIDATORS: 2 + ORACLE_MIN_SUBMISSIONS: 2 + XCHAIN_HUB_SKIP_MIN_STAKE_ASSERT: 1 + BTC_INDEXER_API_URL: http://btc-indexer:3004 + BTC_INDEXER_URL: http://btc-indexer:3004 + LTC_INDEXER_URL: http://ltc-indexer:3004 + DOGE_INDEXER_API_URL: http://doge-indexer:3004 + DOGE_INDEXER_URL: http://doge-indexer:3004 + +x-service-healthcheck: &service-healthcheck + interval: 10s + timeout: 5s + retries: 90 + start_period: 120s + +x-node-healthcheck: &node-healthcheck + interval: 5s + timeout: 5s + retries: 60 + start_period: 20s + +services: + db: + image: xchain-node-database + command: ["--max-connections=1000", "--innodb-flush-log-at-trx-commit=2", "--innodb-doublewrite=0", "--innodb-buffer-pool-size=1G"] + environment: + MARIADB_ROOT_PASSWORD: ${DB_PASSWORD:-hub-failover-db} + MARIADB_ROOT_HOST: "%" + ports: + - "127.0.0.1:${DB_HOST_PORT:-64200}:3306" + volumes: + - db-data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "healthcheck.sh --connect --innodb_initialized"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 20s + restart: unless-stopped + + db-init: + image: xchain-node-database + depends_on: + db: + condition: service_healthy + environment: + MARIADB_ROOT_PASSWORD: ${DB_PASSWORD:-hub-failover-db} + entrypoint: ["/bin/sh", "-ec"] + command: + - | + mariadb -h db -uroot -p"$${MARIADB_ROOT_PASSWORD}" -e "CREATE DATABASE IF NOT EXISTS XChain_Hub_A; CREATE DATABASE IF NOT EXISTS XChain_Hub_B; CREATE DATABASE IF NOT EXISTS XChain_BTC_Regtest_Decoder; CREATE DATABASE IF NOT EXISTS XChain_BTC_Regtest_Indexer; CREATE DATABASE IF NOT EXISTS XChain_BTC_Regtest_Control_Indexer; CREATE DATABASE IF NOT EXISTS XChain_LTC_Regtest_Decoder; CREATE DATABASE IF NOT EXISTS XChain_LTC_Regtest_Indexer; CREATE DATABASE IF NOT EXISTS XChain_DOGE_Regtest_Decoder; CREATE DATABASE IF NOT EXISTS XChain_DOGE_Regtest_Indexer;" + restart: "no" + + hub-a: + image: xchain-node-xchain-hub + depends_on: + db-init: + condition: service_completed_successfully + environment: + <<: *hub-environment + HUB_DB_NAME: XChain_Hub_A + HUB_PUBLIC_API_URL: http://hub-a:10000 + P2P_VALIDATOR_ADDR: ws://hub-a:10001 + SEED_NODES: ws://hub-b:10001 + SIGNING_PRIVKEY_HEX: 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f + ports: + - "127.0.0.1:${HUB_A_HOST_PORT:-64230}:10000" + - "127.0.0.1:${HUB_A_P2P_HOST_PORT:-64231}:10001" + healthcheck: + <<: *service-healthcheck + test: ["CMD-SHELL", "wget -qO- --post-data='{\"jsonrpc\":\"2.0\",\"method\":\"health\",\"id\":1}' --header='Content-Type: application/json' http://127.0.0.1:10000/ >/dev/null 2>&1"] + restart: unless-stopped + + hub-b: + image: xchain-node-xchain-hub + depends_on: + db-init: + condition: service_completed_successfully + environment: + <<: *hub-environment + HUB_DB_NAME: XChain_Hub_B + HUB_PUBLIC_API_URL: http://hub-b:10000 + P2P_VALIDATOR_ADDR: ws://hub-b:10001 + SEED_NODES: ws://hub-a:10001 + SIGNING_PRIVKEY_HEX: 202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f + ports: + - "127.0.0.1:${HUB_B_HOST_PORT:-64232}:10000" + - "127.0.0.1:${HUB_B_P2P_HOST_PORT:-64233}:10001" + healthcheck: + <<: *service-healthcheck + test: ["CMD-SHELL", "wget -qO- --post-data='{\"jsonrpc\":\"2.0\",\"method\":\"health\",\"id\":1}' --header='Content-Type: application/json' http://127.0.0.1:10000/ >/dev/null 2>&1"] + restart: unless-stopped + + hub-federation-init: + image: xchain-node-xchain-hub + depends_on: + hub-a: + condition: service_healthy + hub-b: + condition: service_healthy + environment: + HUB_API_KEY: ${HUB_API_KEY:-hub-failover-bulk} + entrypoint: ["/bin/sh", "-ec"] + command: + - | + for hub in hub-a hub-b; do + for registration in \ + '{"jsonrpc":"2.0","id":1,"method":"registervalidator","params":{"signing_pubkey":"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8","addr":"ws://hub-a:10001"}}' \ + '{"jsonrpc":"2.0","id":2,"method":"registervalidator","params":{"signing_pubkey":"29acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd7","addr":"ws://hub-b:10001"}}'; do + wget -qO- --header="x-api-key: $${HUB_API_KEY}" --header="Content-Type: application/json" --post-data="$${registration}" "http://$${hub}:10000/" | grep -q '"status":"success"' + done + done + restart: "no" + + btc-node: + image: xchain-node-bitcoin-regtest-node + environment: + NODE_USER: ${RPC_USER:-hub-failover} + NODE_PASSWORD: ${RPC_PASSWORD:-hub-failover-rpc} + entrypoint: ["/bin/sh", "-ec"] + command: + - | + umask 077 + sed -e "s|^rpcuser=.*|rpcuser=$${NODE_USER}|" -e "s|^rpcpassword=.*|rpcpassword=$${NODE_PASSWORD}|" /etc/bitcoin/bitcoin.conf > /run/bitcoin.conf + exec bitcoind -conf=/run/bitcoin.conf -datadir=/root/.bitcoin/ + tmpfs: + - /run + ports: + - "127.0.0.1:${BTC_NODE_HOST_PORT:-64201}:18444" + volumes: + - btc-node-data:/root/.bitcoin + healthcheck: + <<: *node-healthcheck + test: ["CMD-SHELL", "bitcoin-cli -conf=/run/bitcoin.conf -datadir=/root/.bitcoin/ getblockchaininfo >/dev/null 2>&1"] + restart: unless-stopped + + btc-miner: + image: xchain-node-bitcoin-regtest-xchain-regtest-miner + depends_on: + btc-node: + condition: service_healthy + environment: + NETWORK: bitcoin-regtest + NODE_URL: btc-node + NODE_PORT: 18444 + NODE_USER: ${RPC_USER:-hub-failover} + NODE_PASSWORD: ${RPC_PASSWORD:-hub-failover-rpc} + REGTEST_MINER_API_PORT: 3005 + ports: + - "127.0.0.1:${BTC_MINER_HOST_PORT:-64206}:3005" + healthcheck: + <<: *service-healthcheck + test: ["CMD-SHELL", "node -e \"const h=require('http');const q=h.request({host:'127.0.0.1',port:3005,method:'POST',headers:{'content-type':'application/json'}},r=>process.exit(r.statusCode===200?0:1));q.on('error',()=>process.exit(1));q.end(JSON.stringify({jsonrpc:'2.0',method:'health',id:1}))\""] + restart: unless-stopped + + btc-tracker: + image: xchain-node-bitcoin-regtest-xchain-utxo-tracker + depends_on: + btc-node: + condition: service_healthy + environment: + COIN: bitcoin + NETWORK: bitcoin-regtest + NODE_URL: btc-node + NODE_PORT: 18444 + NODE_USER: ${RPC_USER:-hub-failover} + NODE_PASSWORD: ${RPC_PASSWORD:-hub-failover-rpc} + UTXO_TRACKER_API_PORT: 3001 + CORS_ORIGIN: "*" + ports: + - "127.0.0.1:${BTC_TRACKER_HOST_PORT:-64202}:3001" + volumes: + - btc-tracker-data:/data/xchain-utxo-tracker + healthcheck: + <<: *service-healthcheck + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/status >/dev/null 2>&1"] + restart: unless-stopped + + btc-decoder: + image: xchain-node-bitcoin-regtest-xchain-decoder + depends_on: + db-init: + condition: service_completed_successfully + btc-node: + condition: service_healthy + environment: + COIN: bitcoin + NETWORK: bitcoin-regtest + NODE_URL: btc-node + NODE_PORT: 18444 + NODE_USER: ${RPC_USER:-hub-failover} + NODE_PASSWORD: ${RPC_PASSWORD:-hub-failover-rpc} + DECODER_DB_HOST: db + DECODER_DB_PORT: 3306 + DECODER_DB_NAME: XChain_BTC_Regtest_Decoder + DECODER_DB_USER: root + DECODER_DB_PASS: ${DB_PASSWORD:-hub-failover-db} + DECODER_API_PORT: 3002 + ports: + - "127.0.0.1:${BTC_DECODER_HOST_PORT:-64203}:3002" + healthcheck: + <<: *service-healthcheck + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3002/live >/dev/null 2>&1"] + restart: unless-stopped + + btc-encoder: + image: xchain-node-bitcoin-regtest-xchain-encoder + depends_on: + btc-node: + condition: service_healthy + btc-tracker: + condition: service_healthy + environment: + NETWORK: bitcoin-regtest + NODE_URL: btc-node + NODE_PORT: 18444 + NODE_USER: ${RPC_USER:-hub-failover} + NODE_PASSWORD: ${RPC_PASSWORD:-hub-failover-rpc} + UTXO_TRACKER_URL: btc-tracker + UTXO_TRACKER_API_PORT: 3001 + ENCODER_API_PORT: 3003 + ENCODER_RATE_LIMIT_RPM: 99999 + CORS_ORIGIN: "*" + ports: + - "127.0.0.1:${BTC_ENCODER_HOST_PORT:-64204}:3003" + healthcheck: + <<: *service-healthcheck + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3003/status >/dev/null 2>&1"] + restart: unless-stopped + + btc-indexer: + image: xchain-node-bitcoin-regtest-xchain-indexer + depends_on: + db-init: + condition: service_completed_successfully + btc-decoder: + condition: service_healthy + btc-tracker: + condition: service_healthy + hub-a: + condition: service_healthy + hub-b: + condition: service_healthy + hub-federation-init: + condition: service_completed_successfully + environment: + <<: [*indexer-environment, *failover-environment] + INDEXER_COIN: BTC + INDEXER_DB_NAME: XChain_BTC_Regtest_Indexer + DECODER_DB_NAME: XChain_BTC_Regtest_Decoder + UTXO_TRACKER_URL: btc-tracker + UTXO_TRACKER_API_PORT: 3001 + HUB_DB_NAME: XChain_BTC_Regtest_Indexer + ports: + - "127.0.0.1:${BTC_INDEXER_HOST_PORT:-64205}:3004" + healthcheck: + <<: *service-healthcheck + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3004/status >/dev/null 2>&1"] + restart: unless-stopped + + btc-indexer-control: + image: xchain-node-bitcoin-regtest-xchain-indexer + depends_on: + db-init: + condition: service_completed_successfully + btc-decoder: + condition: service_healthy + btc-tracker: + condition: service_healthy + hub-b: + condition: service_healthy + hub-federation-init: + condition: service_completed_successfully + environment: + <<: *indexer-environment + INDEXER_COIN: BTC + INDEXER_DB_NAME: XChain_BTC_Regtest_Control_Indexer + DECODER_DB_NAME: XChain_BTC_Regtest_Decoder + UTXO_TRACKER_URL: btc-tracker + UTXO_TRACKER_API_PORT: 3001 + HUB_API_URL: http://hub-b:10000 + HUB_CONFIG_URL: http://hub-b:10000 + HUB_DB_NAME: XChain_BTC_Regtest_Control_Indexer + ports: + - "127.0.0.1:${BTC_CONTROL_INDEXER_HOST_PORT:-64207}:3004" + healthcheck: + <<: *service-healthcheck + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3004/status >/dev/null 2>&1"] + restart: unless-stopped + + ltc-node: + image: xchain-node-litecoin-regtest-node + environment: + NODE_USER: ${RPC_USER:-hub-failover} + NODE_PASSWORD: ${RPC_PASSWORD:-hub-failover-rpc} + entrypoint: ["/bin/sh", "-ec"] + command: + - | + umask 077 + sed -e "s|^rpcuser=.*|rpcuser=$${NODE_USER}|" -e "s|^rpcpassword=.*|rpcpassword=$${NODE_PASSWORD}|" /etc/litecoin/litecoin.conf > /run/litecoin.conf + exec litecoind -conf=/run/litecoin.conf -datadir=/root/.litecoin/ + tmpfs: + - /run + ports: + - "127.0.0.1:${LTC_NODE_HOST_PORT:-64210}:18444" + volumes: + - ltc-node-data:/root/.litecoin + healthcheck: + <<: *node-healthcheck + test: ["CMD-SHELL", "litecoin-cli -conf=/run/litecoin.conf -datadir=/root/.litecoin/ getblockchaininfo >/dev/null 2>&1"] + restart: unless-stopped + + ltc-miner: + image: xchain-node-litecoin-regtest-xchain-regtest-miner + depends_on: + ltc-node: + condition: service_healthy + environment: + NETWORK: litecoin-regtest + NODE_URL: ltc-node + NODE_PORT: 18444 + NODE_USER: ${RPC_USER:-hub-failover} + NODE_PASSWORD: ${RPC_PASSWORD:-hub-failover-rpc} + REGTEST_MINER_API_PORT: 3005 + ports: + - "127.0.0.1:${LTC_MINER_HOST_PORT:-64215}:3005" + healthcheck: + <<: *service-healthcheck + test: ["CMD-SHELL", "node -e \"const h=require('http');const q=h.request({host:'127.0.0.1',port:3005,method:'POST',headers:{'content-type':'application/json'}},r=>process.exit(r.statusCode===200?0:1));q.on('error',()=>process.exit(1));q.end(JSON.stringify({jsonrpc:'2.0',method:'health',id:1}))\""] + restart: unless-stopped + + ltc-tracker: + image: xchain-node-litecoin-regtest-xchain-utxo-tracker + depends_on: + ltc-node: + condition: service_healthy + environment: + COIN: litecoin + NETWORK: litecoin-regtest + NODE_URL: ltc-node + NODE_PORT: 18444 + NODE_USER: ${RPC_USER:-hub-failover} + NODE_PASSWORD: ${RPC_PASSWORD:-hub-failover-rpc} + UTXO_TRACKER_API_PORT: 3001 + CORS_ORIGIN: "*" + ports: + - "127.0.0.1:${LTC_TRACKER_HOST_PORT:-64211}:3001" + volumes: + - ltc-tracker-data:/data/xchain-utxo-tracker + healthcheck: + <<: *service-healthcheck + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/status >/dev/null 2>&1"] + restart: unless-stopped + + ltc-decoder: + image: xchain-node-litecoin-regtest-xchain-decoder + depends_on: + db-init: + condition: service_completed_successfully + ltc-node: + condition: service_healthy + environment: + COIN: litecoin + NETWORK: litecoin-regtest + NODE_URL: ltc-node + NODE_PORT: 18444 + NODE_USER: ${RPC_USER:-hub-failover} + NODE_PASSWORD: ${RPC_PASSWORD:-hub-failover-rpc} + DECODER_DB_HOST: db + DECODER_DB_PORT: 3306 + DECODER_DB_NAME: XChain_LTC_Regtest_Decoder + DECODER_DB_USER: root + DECODER_DB_PASS: ${DB_PASSWORD:-hub-failover-db} + DECODER_API_PORT: 3002 + ports: + - "127.0.0.1:${LTC_DECODER_HOST_PORT:-64212}:3002" + healthcheck: + <<: *service-healthcheck + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3002/live >/dev/null 2>&1"] + restart: unless-stopped + + ltc-encoder: + image: xchain-node-litecoin-regtest-xchain-encoder + depends_on: + ltc-node: + condition: service_healthy + ltc-tracker: + condition: service_healthy + environment: + NETWORK: litecoin-regtest + NODE_URL: ltc-node + NODE_PORT: 18444 + NODE_USER: ${RPC_USER:-hub-failover} + NODE_PASSWORD: ${RPC_PASSWORD:-hub-failover-rpc} + UTXO_TRACKER_URL: ltc-tracker + UTXO_TRACKER_API_PORT: 3001 + ENCODER_API_PORT: 3003 + ENCODER_RATE_LIMIT_RPM: 99999 + CORS_ORIGIN: "*" + ports: + - "127.0.0.1:${LTC_ENCODER_HOST_PORT:-64213}:3003" + healthcheck: + <<: *service-healthcheck + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3003/status >/dev/null 2>&1"] + restart: unless-stopped + + ltc-indexer: + image: xchain-node-litecoin-regtest-xchain-indexer + depends_on: + db-init: + condition: service_completed_successfully + ltc-decoder: + condition: service_healthy + ltc-tracker: + condition: service_healthy + hub-a: + condition: service_healthy + hub-b: + condition: service_healthy + hub-federation-init: + condition: service_completed_successfully + environment: + <<: [*indexer-environment, *failover-environment] + INDEXER_COIN: LTC + INDEXER_DB_NAME: XChain_LTC_Regtest_Indexer + DECODER_DB_NAME: XChain_LTC_Regtest_Decoder + UTXO_TRACKER_URL: ltc-tracker + UTXO_TRACKER_API_PORT: 3001 + HUB_DB_NAME: XChain_LTC_Regtest_Indexer + ports: + - "127.0.0.1:${LTC_INDEXER_HOST_PORT:-64214}:3004" + healthcheck: + <<: *service-healthcheck + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3004/status >/dev/null 2>&1"] + restart: unless-stopped + + doge-node: + image: xchain-node-dogecoin-regtest-node + environment: + NODE_USER: ${RPC_USER:-hub-failover} + NODE_PASSWORD: ${RPC_PASSWORD:-hub-failover-rpc} + entrypoint: ["/bin/sh", "-ec"] + command: + - | + umask 077 + sed -e "s|^rpcuser=.*|rpcuser=$${NODE_USER}|" -e "s|^rpcpassword=.*|rpcpassword=$${NODE_PASSWORD}|" /etc/dogecoin/dogecoin.conf > /run/dogecoin.conf + exec dogecoind -conf=/run/dogecoin.conf -datadir=/root/.dogecoin/ + tmpfs: + - /run + ports: + - "127.0.0.1:${DOGE_NODE_HOST_PORT:-64220}:18444" + volumes: + - doge-node-data:/root/.dogecoin + healthcheck: + <<: *node-healthcheck + test: ["CMD-SHELL", "dogecoin-cli -conf=/run/dogecoin.conf -datadir=/root/.dogecoin/ getblockchaininfo >/dev/null 2>&1"] + restart: unless-stopped + + doge-miner: + image: xchain-node-dogecoin-regtest-xchain-regtest-miner + depends_on: + doge-node: + condition: service_healthy + environment: + NETWORK: dogecoin-regtest + NODE_URL: doge-node + NODE_PORT: 18444 + NODE_USER: ${RPC_USER:-hub-failover} + NODE_PASSWORD: ${RPC_PASSWORD:-hub-failover-rpc} + REGTEST_MINER_API_PORT: 3005 + ports: + - "127.0.0.1:${DOGE_MINER_HOST_PORT:-64225}:3005" + healthcheck: + <<: *service-healthcheck + test: ["CMD-SHELL", "node -e \"const h=require('http');const q=h.request({host:'127.0.0.1',port:3005,method:'POST',headers:{'content-type':'application/json'}},r=>process.exit(r.statusCode===200?0:1));q.on('error',()=>process.exit(1));q.end(JSON.stringify({jsonrpc:'2.0',method:'health',id:1}))\""] + restart: unless-stopped + + doge-tracker: + image: xchain-node-dogecoin-regtest-xchain-utxo-tracker + depends_on: + doge-node: + condition: service_healthy + environment: + COIN: dogecoin + NETWORK: dogecoin-regtest + NODE_URL: doge-node + NODE_PORT: 18444 + NODE_USER: ${RPC_USER:-hub-failover} + NODE_PASSWORD: ${RPC_PASSWORD:-hub-failover-rpc} + UTXO_TRACKER_API_PORT: 3001 + CORS_ORIGIN: "*" + ports: + - "127.0.0.1:${DOGE_TRACKER_HOST_PORT:-64221}:3001" + volumes: + - doge-tracker-data:/data/xchain-utxo-tracker + healthcheck: + <<: *service-healthcheck + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/status >/dev/null 2>&1"] + restart: unless-stopped + + doge-decoder: + image: xchain-node-dogecoin-regtest-xchain-decoder + depends_on: + db-init: + condition: service_completed_successfully + doge-node: + condition: service_healthy + environment: + COIN: dogecoin + NETWORK: dogecoin-regtest + NODE_URL: doge-node + NODE_PORT: 18444 + NODE_USER: ${RPC_USER:-hub-failover} + NODE_PASSWORD: ${RPC_PASSWORD:-hub-failover-rpc} + DECODER_DB_HOST: db + DECODER_DB_PORT: 3306 + DECODER_DB_NAME: XChain_DOGE_Regtest_Decoder + DECODER_DB_USER: root + DECODER_DB_PASS: ${DB_PASSWORD:-hub-failover-db} + DECODER_API_PORT: 3002 + ports: + - "127.0.0.1:${DOGE_DECODER_HOST_PORT:-64222}:3002" + healthcheck: + <<: *service-healthcheck + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3002/live >/dev/null 2>&1"] + restart: unless-stopped + + doge-encoder: + image: xchain-node-dogecoin-regtest-xchain-encoder + depends_on: + doge-node: + condition: service_healthy + doge-tracker: + condition: service_healthy + environment: + NETWORK: dogecoin-regtest + NODE_URL: doge-node + NODE_PORT: 18444 + NODE_USER: ${RPC_USER:-hub-failover} + NODE_PASSWORD: ${RPC_PASSWORD:-hub-failover-rpc} + UTXO_TRACKER_URL: doge-tracker + UTXO_TRACKER_API_PORT: 3001 + ENCODER_API_PORT: 3003 + ENCODER_RATE_LIMIT_RPM: 99999 + CORS_ORIGIN: "*" + ports: + - "127.0.0.1:${DOGE_ENCODER_HOST_PORT:-64223}:3003" + healthcheck: + <<: *service-healthcheck + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3003/status >/dev/null 2>&1"] + restart: unless-stopped + + doge-indexer: + image: xchain-node-dogecoin-regtest-xchain-indexer + depends_on: + db-init: + condition: service_completed_successfully + doge-decoder: + condition: service_healthy + doge-tracker: + condition: service_healthy + hub-a: + condition: service_healthy + hub-b: + condition: service_healthy + hub-federation-init: + condition: service_completed_successfully + environment: + <<: [*indexer-environment, *failover-environment] + INDEXER_COIN: DOGE + INDEXER_DB_NAME: XChain_DOGE_Regtest_Indexer + DECODER_DB_NAME: XChain_DOGE_Regtest_Decoder + UTXO_TRACKER_URL: doge-tracker + UTXO_TRACKER_API_PORT: 3001 + HUB_DB_NAME: XChain_DOGE_Regtest_Indexer + ports: + - "127.0.0.1:${DOGE_INDEXER_HOST_PORT:-64224}:3004" + healthcheck: + <<: *service-healthcheck + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3004/status >/dev/null 2>&1"] + restart: unless-stopped + +volumes: + db-data: + btc-node-data: + btc-tracker-data: + ltc-node-data: + ltc-tracker-data: + doge-node-data: + doge-tracker-data: From b9acf1f79414367e6097a15e7be6d6eb13cb274c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 3 Oct 2026 15:55:35 -0700 Subject: [PATCH 2/7] Configure hub failover discovery Pass seed URLs and feed credentials into managed indexers and explorer checkpoints while preserving the private config route. Advertise public hub URLs and cover pinned and failover modes with unit tests. --- src/config/index.js | 3 + src/services/config_service/database.js | 1 + src/services/config_service/defaults.js | 30 ++++- src/services/config_service/services.js | 7 ++ src/services/hub_service/hub_module_config.js | 19 ++- test/unit/config_service.test.js | 1 + .../config_service.test/hub_failover.test.js | 114 ++++++++++++++++++ test/unit/hub_service.test.js | 2 + test/unit/hub_service_failover.test.js | 61 ++++++++++ 9 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 test/unit/config_service.test/hub_failover.test.js create mode 100644 test/unit/hub_service_failover.test.js diff --git a/src/config/index.js b/src/config/index.js index 7028545c..372a1248 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -205,8 +205,11 @@ module.exports = { get GITHUB_TOKEN() { return process.env.GITHUB_TOKEN }, get HUB_API_KEY() { return process.env.HUB_API_KEY }, get HUB_API_URL() { return process.env.HUB_API_URL }, + get HUB_FEED_API_KEY() { return process.env.HUB_FEED_API_KEY }, get HUB_NETWORK() { return process.env.HUB_NETWORK }, get HUB_PORT() { return process.env.HUB_PORT }, + get HUB_PUBLIC_API_URL() { return process.env.HUB_PUBLIC_API_URL }, + get HUB_SEED_URLS() { return process.env.HUB_SEED_URLS }, get INDEXER_API_KEY() { return process.env.INDEXER_API_KEY }, get LEVELDB_CACHE_BYTES() { return process.env.LEVELDB_CACHE_BYTES }, get LEVELDB_WRITE_BUFFER_BYTES() { return process.env.LEVELDB_WRITE_BUFFER_BYTES }, diff --git a/src/services/config_service/database.js b/src/services/config_service/database.js index b1fb6ba5..398c02cd 100644 --- a/src/services/config_service/database.js +++ b/src/services/config_service/database.js @@ -120,6 +120,7 @@ function setIndexerHubPassword(defaultConfig, defaultValues, module) { } function mergeDefaults(defaultConfig, defaultValues) { + if (defaultValues.HUB_SEED_URLS) delete defaultConfig.HUB_API_URL for (const key in defaultValues) { if (!(key in defaultConfig)) defaultConfig[key] = defaultValues[key] } diff --git a/src/services/config_service/defaults.js b/src/services/config_service/defaults.js index d8de5b37..2ebcc565 100644 --- a/src/services/config_service/defaults.js +++ b/src/services/config_service/defaults.js @@ -276,15 +276,36 @@ function configureSharedBeforeHubKey(defaultValues) { // hand to a remote address; a container-local // default that overrode it would point a working production // explorer at a hostname that does not resolve. +function configureExplorerHubFeed(defaultValues, module) { + if (module !== EXPLORER_MODULE_NAME || config.EXPLORER_CHECKPOINT_SELF_SYNC === undefined || + config.EXPLORER_CHECKPOINT_SELF_SYNC === "") return + if (config.HUB_SEED_URLS !== undefined && config.HUB_SEED_URLS !== "") { + defaultValues.HUB_SEED_URLS = config.HUB_SEED_URLS + } else { + defaultValues.HUB_API_URL = config.HUB_API_URL || + ("http://" + getDockerContainerImageName(HUB_MODULE_NAME, "", "") + ":" + defaultValues.HUB_PORT) + } + if (config.HUB_FEED_API_KEY !== undefined && config.HUB_FEED_API_KEY !== "") { + defaultValues.HUB_FEED_API_KEY = config.HUB_FEED_API_KEY + } +} + +function configureManagedHubDiscovery(defaultValues, module) { + if (module !== HUB_MODULE_NAME) return + if (config.HUB_FEED_API_KEY !== undefined && config.HUB_FEED_API_KEY !== "") { + defaultValues.HUB_FEED_API_KEY = config.HUB_FEED_API_KEY + } + if (config.HUB_PUBLIC_API_URL !== undefined && config.HUB_PUBLIC_API_URL !== "") { + defaultValues.HUB_PUBLIC_API_URL = config.HUB_PUBLIC_API_URL + } +} + function configureSharedAfterHubKey(defaultValues, module) { if (config.CORS_ORIGIN !== undefined && config.CORS_ORIGIN !== "") { defaultValues.CORS_ORIGIN = config.CORS_ORIGIN } + configureExplorerHubFeed(defaultValues, module) if (module === EXPLORER_MODULE_NAME) { - if (config.EXPLORER_CHECKPOINT_SELF_SYNC !== undefined && config.EXPLORER_CHECKPOINT_SELF_SYNC !== "") { - defaultValues.HUB_API_URL = config.HUB_API_URL || - ("http://" + getDockerContainerImageName(HUB_MODULE_NAME, "", "") + ":" + defaultValues.HUB_PORT) - } if (config.EXPLORER_VM_QUERY_ENABLED !== undefined && config.EXPLORER_VM_QUERY_ENABLED !== "") { defaultValues.EXPLORER_VM_QUERY_ENABLED = config.EXPLORER_VM_QUERY_ENABLED } @@ -331,6 +352,7 @@ function configureSharedAfterHubKey(defaultValues, module) { } } } + configureManagedHubDiscovery(defaultValues, module) } module.exports = { diff --git a/src/services/config_service/services.js b/src/services/config_service/services.js index 13331821..932037c8 100644 --- a/src/services/config_service/services.js +++ b/src/services/config_service/services.js @@ -229,6 +229,13 @@ function configureIndexerBeforeHubKey(defaultValues, module, network) { if (config.HUB_API_KEY !== undefined && config.HUB_API_KEY !== "") { defaultValues.HUB_API_KEY = config.HUB_API_KEY } + if (config.HUB_FEED_API_KEY !== undefined && config.HUB_FEED_API_KEY !== "") { + defaultValues.HUB_FEED_API_KEY = config.HUB_FEED_API_KEY + } + if (config.HUB_SEED_URLS !== undefined && config.HUB_SEED_URLS !== "") { + defaultValues.HUB_SEED_URLS = config.HUB_SEED_URLS + delete defaultValues.HUB_API_URL + } } } diff --git a/src/services/hub_service/hub_module_config.js b/src/services/hub_service/hub_module_config.js index c509bbdd..da23d968 100644 --- a/src/services/hub_service/hub_module_config.js +++ b/src/services/hub_service/hub_module_config.js @@ -75,10 +75,7 @@ function buildHubModuleConfig(nextModule, defaultConfigCoinNetwork, ctx) { // install/recreate time from the same host env var, and the explorer falls back // to it for hand-written config.json deployments. function buildCheckpointConfig(defaultConfigCoinNetwork) { - return { - hub_url: config.HUB_API_URL || - ("http://" + getDockerContainerImageName(HUB_MODULE_NAME, "", "") + ":" + - defaultConfigCoinNetwork.HUB_PORT), + const checkpointConfig = { db_host: defaultConfigCoinNetwork.INDEXER_DB_HOST, db_port: defaultConfigCoinNetwork.INDEXER_DB_PORT, user: defaultConfigCoinNetwork.INDEXER_DB_USER, @@ -90,6 +87,17 @@ function buildCheckpointConfig(defaultConfigCoinNetwork) { name: defaultConfigCoinNetwork.INDEXER_DB_NAME + '_HubMirror', self_sync: true } + if (config.HUB_SEED_URLS !== undefined && config.HUB_SEED_URLS !== '') { + checkpointConfig.hub_seed_urls = config.HUB_SEED_URLS + } else { + checkpointConfig.hub_url = config.HUB_API_URL || + ("http://" + getDockerContainerImageName(HUB_MODULE_NAME, "", "") + ":" + + defaultConfigCoinNetwork.HUB_PORT) + } + if (config.HUB_FEED_API_KEY !== undefined && config.HUB_FEED_API_KEY !== '') { + checkpointConfig.hub_feed_api_key = config.HUB_FEED_API_KEY + } + return checkpointConfig } // Is the self-synced checkpoint mirror opted in for THIS deployment? @@ -126,7 +134,8 @@ async function isCheckpointSelfSyncEnabled(deps = {}) { if (!containerEnv) return false return (containerEnv.EXPLORER_CHECKPOINT_SELF_SYNC !== undefined && containerEnv.EXPLORER_CHECKPOINT_SELF_SYNC !== "") || - (containerEnv.HUB_API_URL !== undefined && containerEnv.HUB_API_URL !== "") + (containerEnv.HUB_API_URL !== undefined && containerEnv.HUB_API_URL !== "") || + (containerEnv.HUB_SEED_URLS !== undefined && containerEnv.HUB_SEED_URLS !== "") } module.exports = { buildHubModuleConfig, buildCheckpointConfig, isCheckpointSelfSyncEnabled } diff --git a/test/unit/config_service.test.js b/test/unit/config_service.test.js index ec13eeeb..062ea63e 100644 --- a/test/unit/config_service.test.js +++ b/test/unit/config_service.test.js @@ -333,3 +333,4 @@ require('./config_service.test/shared_service_guards.test') require('./config_service.test/ensure_hub_api_key.test') require('./config_service.test/filter_command_parameters.test') require('./config_service.test/resolve_args.test') +require('./config_service.test/hub_failover.test') diff --git a/test/unit/config_service.test/hub_failover.test.js b/test/unit/config_service.test/hub_failover.test.js new file mode 100644 index 00000000..663e516f --- /dev/null +++ b/test/unit/config_service.test/hub_failover.test.js @@ -0,0 +1,114 @@ +'use strict' + +const { expect } = require('chai') +const { + HUB_MODULE_NAME, + EXPLORER_MODULE_NAME, + makeServiceWithConfig +} = require('./helpers.test') + +describe('ConfigService hub failover environment', function () { + const envNames = [ + 'EXPLORER_CHECKPOINT_SELF_SYNC', + 'HUB_API_URL', + 'HUB_FEED_API_KEY', + 'HUB_PUBLIC_API_URL', + 'HUB_SEED_URLS' + ] + let savedEnv + + beforeEach(function () { + savedEnv = Object.fromEntries(envNames.map(name => [name, process.env[name]])) + for (const name of envNames) delete process.env[name] + }) + + afterEach(function () { + for (const [name, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[name] + else process.env[name] = value + } + }) + + it('writes seed and feed credentials to every indexer without a pinned feed URL', async function () { + process.env.HUB_SEED_URLS = 'default,http://seed.example:10002' + process.env.HUB_FEED_API_KEY = 'feed-key-fixture' + const config = await makeServiceWithConfig('').getDefaultConfig( + 'xchain-indexer', 'bitcoin', 'testnet') + + expect(config.HUB_SEED_URLS).to.equal('default,http://seed.example:10002') + expect(config.HUB_FEED_API_KEY).to.equal('feed-key-fixture') + expect(config).not.to.have.property('HUB_API_URL') + expect(config.HUB_CONFIG_URL).to.equal( + 'http://' + config.HUB_API_HOST + ':' + config.HUB_PORT) + }) + + it('removes an old per-coin pinned feed URL when host seed mode is set', async function () { + process.env.HUB_SEED_URLS = 'default' + const config = await makeServiceWithConfig('HUB_API_URL=http://old-feed.example:10002\n') + .getDefaultConfig('xchain-indexer', 'litecoin', 'testnet') + + expect(config.HUB_SEED_URLS).to.equal('default') + expect(config).not.to.have.property('HUB_API_URL') + expect(config.HUB_CONFIG_URL).to.equal('http://xchain-node-xchain-hub:10000') + }) + + it('keeps the pinned default when seed mode is unset', async function () { + const config = await makeServiceWithConfig('').getDefaultConfig( + 'xchain-indexer', 'dogecoin', 'mainnet') + + expect(config.HUB_API_URL).to.equal('http://xchain-node-xchain-hub:10000') + expect(config).not.to.have.property('HUB_SEED_URLS') + expect(config).not.to.have.property('HUB_FEED_API_KEY') + }) + + it('does not write indexer-only discovery settings to sibling coin services', async function () { + process.env.HUB_SEED_URLS = 'default' + process.env.HUB_FEED_API_KEY = 'feed-key-fixture' + const config = await makeServiceWithConfig('').getDefaultConfig( + 'xchain-decoder', 'bitcoin', 'testnet') + + expect(config.HUB_API_URL).to.equal('http://xchain-node-xchain-hub:10000') + expect(config).not.to.have.property('HUB_SEED_URLS') + expect(config).not.to.have.property('HUB_FEED_API_KEY') + }) + + it('writes seed and feed credentials to a self-syncing explorer without a pinned feed URL', async function () { + process.env.EXPLORER_CHECKPOINT_SELF_SYNC = '1' + process.env.HUB_SEED_URLS = 'default' + process.env.HUB_FEED_API_KEY = 'explorer-feed-key-fixture' + const config = await makeServiceWithConfig('').getDefaultConfig( + EXPLORER_MODULE_NAME, null, null) + + expect(config.HUB_SEED_URLS).to.equal('default') + expect(config.HUB_FEED_API_KEY).to.equal('explorer-feed-key-fixture') + expect(config).not.to.have.property('HUB_API_URL') + }) + + it('writes the feed key and public API URL to a managed hub', async function () { + process.env.HUB_FEED_API_KEY = 'hub-feed-key-fixture' + process.env.HUB_PUBLIC_API_URL = 'https://public-hub.example' + const service = makeServiceWithConfig('') + const hubConfig = await service.getDefaultConfig(HUB_MODULE_NAME, null, null) + const explorerConfig = await service.getDefaultConfig(EXPLORER_MODULE_NAME, null, null) + + expect(hubConfig.HUB_FEED_API_KEY).to.equal('hub-feed-key-fixture') + expect(hubConfig.HUB_PUBLIC_API_URL).to.equal('https://public-hub.example') + expect(explorerConfig).not.to.have.property('HUB_FEED_API_KEY') + expect(explorerConfig).not.to.have.property('HUB_PUBLIC_API_URL') + }) + + it('treats empty failover environment values as unset', async function () { + process.env.HUB_SEED_URLS = '' + process.env.HUB_FEED_API_KEY = '' + process.env.HUB_PUBLIC_API_URL = '' + const service = makeServiceWithConfig('') + const indexerConfig = await service.getDefaultConfig( + 'xchain-indexer', 'bitcoin', 'testnet') + const hubConfig = await service.getDefaultConfig(HUB_MODULE_NAME, null, null) + + expect(indexerConfig.HUB_API_URL).to.equal('http://xchain-node-xchain-hub:10000') + expect(indexerConfig).not.to.have.property('HUB_SEED_URLS') + expect(indexerConfig).not.to.have.property('HUB_FEED_API_KEY') + expect(hubConfig).not.to.have.property('HUB_PUBLIC_API_URL') + }) +}) diff --git a/test/unit/hub_service.test.js b/test/unit/hub_service.test.js index e788431c..727c05e2 100644 --- a/test/unit/hub_service.test.js +++ b/test/unit/hub_service.test.js @@ -375,3 +375,5 @@ describe('HubService.isCheckpointSelfSyncEnabled', function () { expect(enabled).to.be.false }) }) + +require('./hub_service_failover.test') diff --git a/test/unit/hub_service_failover.test.js b/test/unit/hub_service_failover.test.js new file mode 100644 index 00000000..35f5910b --- /dev/null +++ b/test/unit/hub_service_failover.test.js @@ -0,0 +1,61 @@ +'use strict' + +const { expect } = require('chai') +const { + buildCheckpointConfig, + isCheckpointSelfSyncEnabled +} = require('../../src/services/hub_service/hub_module_config') + +const COIN_CONFIG = { + INDEXER_DB_HOST: 'mariadb', + INDEXER_DB_PORT: 3306, + INDEXER_DB_USER: 'xchain_indexer_bitcoin_regtest', + INDEXER_DB_PASS: 'secret', + INDEXER_DB_NAME: 'XChain_BTC_regtest', + HUB_PORT: 10000 +} + +describe('HubService failover checkpoint config', function () { + const envNames = ['HUB_API_URL', 'HUB_FEED_API_KEY', 'HUB_SEED_URLS'] + let savedEnv + + beforeEach(function () { + savedEnv = Object.fromEntries(envNames.map(name => [name, process.env[name]])) + for (const name of envNames) delete process.env[name] + }) + + afterEach(function () { + for (const [name, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[name] + else process.env[name] = value + } + }) + + it('writes seeds and the feed key without a pinned hub URL', function () { + process.env.HUB_API_URL = 'http://old-hub.internal:10002' + process.env.HUB_SEED_URLS = 'default,http://seed.internal:10002' + process.env.HUB_FEED_API_KEY = 'feed-key-fixture' + const config = buildCheckpointConfig(COIN_CONFIG) + + expect(config.hub_seed_urls).to.equal('default,http://seed.internal:10002') + expect(config.hub_feed_api_key).to.equal('feed-key-fixture') + expect(config).not.to.have.property('hub_url') + }) + + it('writes the feed key alongside a pinned endpoint when seeds are unset', function () { + process.env.HUB_FEED_API_KEY = 'feed-key-fixture' + const config = buildCheckpointConfig(COIN_CONFIG) + + expect(config.hub_feed_api_key).to.equal('feed-key-fixture') + expect(config.hub_url).to.equal('http://xchain-node-xchain-hub:10000') + expect(config).not.to.have.property('hub_seed_urls') + }) + + it('stays opted in when the explorer container remembers seed mode', async function () { + const enabled = await isCheckpointSelfSyncEnabled({ + env: {}, + readContainerEnv: async () => ({ HUB_SEED_URLS: 'default' }) + }) + expect(enabled).to.be.true + }) +}) From 34e068e323e17d81c6067e855951326a3c86c45e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 3 Oct 2026 16:13:06 -0700 Subject: [PATCH 3/7] Split hub failover test suites Keep each test callback within the repository structure limits. --- test/unit/config_service.test/hub_failover.test.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/unit/config_service.test/hub_failover.test.js b/test/unit/config_service.test/hub_failover.test.js index 663e516f..3c6f2195 100644 --- a/test/unit/config_service.test/hub_failover.test.js +++ b/test/unit/config_service.test/hub_failover.test.js @@ -7,7 +7,7 @@ const { makeServiceWithConfig } = require('./helpers.test') -describe('ConfigService hub failover environment', function () { +function registerEnvCleanup() { const envNames = [ 'EXPLORER_CHECKPOINT_SELF_SYNC', 'HUB_API_URL', @@ -28,6 +28,10 @@ describe('ConfigService hub failover environment', function () { else process.env[name] = value } }) +} + +describe('ConfigService indexer hub failover environment', function () { + registerEnvCleanup() it('writes seed and feed credentials to every indexer without a pinned feed URL', async function () { process.env.HUB_SEED_URLS = 'default,http://seed.example:10002' @@ -71,6 +75,10 @@ describe('ConfigService hub failover environment', function () { expect(config).not.to.have.property('HUB_SEED_URLS') expect(config).not.to.have.property('HUB_FEED_API_KEY') }) +}) + +describe('ConfigService managed hub failover environment', function () { + registerEnvCleanup() it('writes seed and feed credentials to a self-syncing explorer without a pinned feed URL', async function () { process.env.EXPLORER_CHECKPOINT_SELF_SYNC = '1' From 31024a256e0723b14d5e5b432de95419de83fbd2 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 3 Oct 2026 16:30:17 -0700 Subject: [PATCH 4/7] Limit feed credentials to failover clients Keep managed hub configuration limited to its public API URL. Cover the recipient boundary by asserting the feed credential is absent from the hub module. --- src/services/config_service/defaults.js | 3 --- test/unit/config_service.test/hub_failover.test.js | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/services/config_service/defaults.js b/src/services/config_service/defaults.js index 2ebcc565..8a5e8b5d 100644 --- a/src/services/config_service/defaults.js +++ b/src/services/config_service/defaults.js @@ -292,9 +292,6 @@ function configureExplorerHubFeed(defaultValues, module) { function configureManagedHubDiscovery(defaultValues, module) { if (module !== HUB_MODULE_NAME) return - if (config.HUB_FEED_API_KEY !== undefined && config.HUB_FEED_API_KEY !== "") { - defaultValues.HUB_FEED_API_KEY = config.HUB_FEED_API_KEY - } if (config.HUB_PUBLIC_API_URL !== undefined && config.HUB_PUBLIC_API_URL !== "") { defaultValues.HUB_PUBLIC_API_URL = config.HUB_PUBLIC_API_URL } diff --git a/test/unit/config_service.test/hub_failover.test.js b/test/unit/config_service.test/hub_failover.test.js index 3c6f2195..2ee15423 100644 --- a/test/unit/config_service.test/hub_failover.test.js +++ b/test/unit/config_service.test/hub_failover.test.js @@ -92,15 +92,15 @@ describe('ConfigService managed hub failover environment', function () { expect(config).not.to.have.property('HUB_API_URL') }) - it('writes the feed key and public API URL to a managed hub', async function () { + it('writes only the public API URL to a managed hub', async function () { process.env.HUB_FEED_API_KEY = 'hub-feed-key-fixture' process.env.HUB_PUBLIC_API_URL = 'https://public-hub.example' const service = makeServiceWithConfig('') const hubConfig = await service.getDefaultConfig(HUB_MODULE_NAME, null, null) const explorerConfig = await service.getDefaultConfig(EXPLORER_MODULE_NAME, null, null) - expect(hubConfig.HUB_FEED_API_KEY).to.equal('hub-feed-key-fixture') expect(hubConfig.HUB_PUBLIC_API_URL).to.equal('https://public-hub.example') + expect(hubConfig).not.to.have.property('HUB_FEED_API_KEY') expect(explorerConfig).not.to.have.property('HUB_FEED_API_KEY') expect(explorerConfig).not.to.have.property('HUB_PUBLIC_API_URL') }) From 6eba7d79d49a23d1793ed2f5eb0da6b6ff707e70 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 3 Oct 2026 20:51:25 -0700 Subject: [PATCH 5/7] Gate failover venue on indexer readiness Require all four indexer healthchecks to wait for migrated and bootstrapped mirrors. Document the readiness signal used by compose up --wait. --- .github/hub-failover/README.md | 2 ++ .github/hub-failover/compose.yml | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/hub-failover/README.md b/.github/hub-failover/README.md index c43bf0ec..74fa9ec2 100644 --- a/.github/hub-failover/README.md +++ b/.github/hub-failover/README.md @@ -10,6 +10,8 @@ Start the venue from the `xchain-node` repository: docker compose -p hub-failover -f .github/hub-failover/compose.yml up -d --wait ``` +The indexer healthchecks require `/status` to report `bootstrapped: true`, so `--wait` returns only after all four indexers have migrated their databases and bootstrapped their mirrors. + HF-15b drives these service names: | Role | Compose service | Default host port | diff --git a/.github/hub-failover/compose.yml b/.github/hub-failover/compose.yml index 74f93d68..283192d2 100644 --- a/.github/hub-failover/compose.yml +++ b/.github/hub-failover/compose.yml @@ -302,7 +302,7 @@ services: - "127.0.0.1:${BTC_INDEXER_HOST_PORT:-64205}:3004" healthcheck: <<: *service-healthcheck - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3004/status >/dev/null 2>&1"] + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3004/status 2>/dev/null | grep -Eq '\"bootstrapped\"[[:space:]]*:[[:space:]]*true'"] restart: unless-stopped btc-indexer-control: @@ -332,7 +332,7 @@ services: - "127.0.0.1:${BTC_CONTROL_INDEXER_HOST_PORT:-64207}:3004" healthcheck: <<: *service-healthcheck - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3004/status >/dev/null 2>&1"] + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3004/status 2>/dev/null | grep -Eq '\"bootstrapped\"[[:space:]]*:[[:space:]]*true'"] restart: unless-stopped ltc-node: @@ -478,7 +478,7 @@ services: - "127.0.0.1:${LTC_INDEXER_HOST_PORT:-64214}:3004" healthcheck: <<: *service-healthcheck - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3004/status >/dev/null 2>&1"] + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3004/status 2>/dev/null | grep -Eq '\"bootstrapped\"[[:space:]]*:[[:space:]]*true'"] restart: unless-stopped doge-node: @@ -624,7 +624,7 @@ services: - "127.0.0.1:${DOGE_INDEXER_HOST_PORT:-64224}:3004" healthcheck: <<: *service-healthcheck - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3004/status >/dev/null 2>&1"] + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3004/status 2>/dev/null | grep -Eq '\"bootstrapped\"[[:space:]]*:[[:space:]]*true'"] restart: unless-stopped volumes: From 4badd0f13e0df4bca07646d51f0368cc92bab640 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 4 Oct 2026 03:28:19 -0700 Subject: [PATCH 6/7] chore(release): v0.22.0 Arms ORACLE_ROUND_TIME_ACTIVATION on testnet at BTC 155158, LTC 4907593 and DOGE 67966647, and bumps the version to 0.22.0. --- CHANGELOG.md | 9 +++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 706e49b2..f62ed82d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.22.0] - 2026-10-04 + +### Added +- Added a two-hub regtest venue for indexer failover drills. + +### Changed +- Configured HUB_SEED_URLS and HUB_FEED_API_KEY for indexers, explorers, and managed hubs. +- Limited feed credentials to failover clients and gated the failover venue on indexer readiness. + ## [0.21.3] - 2026-10-03 ### Added diff --git a/package-lock.json b/package-lock.json index 2b2c396b..b01c466f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-node", - "version": "0.21.3", + "version": "0.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-node", - "version": "0.21.3", + "version": "0.22.0", "license": "AGPL-3.0-or-later", "dependencies": { "@dankest-llc/xchain-sdk": "^0.18.0", diff --git a/package.json b/package.json index 5819aa10..cc0fe95f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xchain-node", - "version": "0.21.3", + "version": "0.22.0", "description": "xchain-node allows users to install, configure and run XChain platform nodes.", "license": "AGPL-3.0-or-later", "repository": { From d0ff0aba8c5c273f1be283d7374359914d76a722 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 4 Oct 2026 06:29:37 -0700 Subject: [PATCH 7/7] Release v0.22.0 manifest Generated by bin/write-release-manifest.js from the five verified sibling origin tags. --- src/release-manifest.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/release-manifest.json b/src/release-manifest.json index 8ae75a6a..e221e3c4 100644 --- a/src/release-manifest.json +++ b/src/release-manifest.json @@ -1,9 +1,9 @@ { "_comment": [ - "Pinned component set for XChain Platform v0.21.3.", + "Pinned component set for XChain Platform v0.22.0.", "Generated by bin/write-release-manifest.js from the ACTUAL tagged master merge commits.", "xchain-node is the carrier and is not listed: checking out its tag IS this manifest.", - "A train tags only the repos it touches. This one moves xchain-vm, xchain-indexer, xchain-hub, xchain-sync, xchain-explorer and xchain-sdk.", + "A train tags only the repos it touches. This one moves xchain-indexer, xchain-hub, xchain-explorer and xchain-sdk.", "Every other component is unchanged and keeps the tag it already carries, which is", "what section 4 means by a version being the platform version at which a component", "last changed. A gap is unchanged, not skipped.", @@ -12,8 +12,8 @@ "repo's origin/master, and is a GPG-signed annotated tag verifying against the", "platform release key (fingerprint 1DA7C4896F56EA22CF491EDF4361611A82F90B70)." ], - "platform_version": "0.21.3", - "released": "2026-10-03", + "platform_version": "0.22.0", + "released": "2026-10-04", "components": { "xchain-vm": { "tag": "v0.21.3", @@ -24,12 +24,12 @@ "commit": "cc2c0802f1674e5aee397b0b9c4c4fce5dc2e551" }, "xchain-indexer": { - "tag": "v0.21.3", - "commit": "6e28b030ffa09601c8851d2b5dc0938230077083" + "tag": "v0.22.0", + "commit": "d670628fee21a6135b143f7a9dc3d2d27170311f" }, "xchain-hub": { - "tag": "v0.21.3", - "commit": "06212b5923af4a18c793d5be217f489c4e739c9f" + "tag": "v0.22.0", + "commit": "fdd04a502157787c90f318cd49c7aa7fea3754ff" }, "xchain-sync": { "tag": "v0.21.3", @@ -44,12 +44,12 @@ "commit": "8b2092686b6d5eb26117df5625a6899839d3caf4" }, "xchain-explorer": { - "tag": "v0.21.3", - "commit": "db698605ed8420f8e61239abc422af825001a500" + "tag": "v0.22.0", + "commit": "b019947e4d2544d44f628829d196b7b4848c0851" }, "xchain-sdk": { - "tag": "v0.21.3", - "commit": "da0e03ddf0546f029809657ca412c953878a14b3" + "tag": "v0.22.0", + "commit": "406db31e45d82337a551ec479e106e8f4bc13a98" }, "xchain-e2e-test": { "tag": "v0.21.0",