diff --git a/.env.example b/.env.example
index 142f76350..c682f598f 100644
--- a/.env.example
+++ b/.env.example
@@ -19,18 +19,21 @@ HUB_PORT=10000
# startup. ALWAYS set a strong random value in production. See
# CONFIGURATION.md "Silent-failure variables".
HUB_API_KEY=your-strong-api-key-here
-# Optional: requests per minute per client (default 100). Does NOT cover the
+# Optional: requests per minute per client without a hub key (default 100, on
+# every role). A JSON-RPC batch costs one token per call. Does NOT cover the
# mirror-bootstrap routes, which carry their own budget below.
# HUB_RATE_LIMIT_RPM=100
+# Optional: requests per minute per client presenting a configured hub key in
+# x-api-key (default 60000): the fleet's own indexer push and replay traffic.
+# HUB_AUTH_RATE_LIMIT_RPM=60000
# Optional: requests per minute per client on /hub-db/snapshot/* (default 600).
# A mirroring indexer bootstrapping from id 0 spends about 30 page reads per
# table set, in a burst, and it must not compete with ordinary polling for the
# budget above; that is what wedged the testnet mirrors on 2026-09-16.
# HUB_SNAPSHOT_RATE_LIMIT_RPM=600
-# Optional: maximum JSON-RPC calls in one batch array (default 20). The limit
-# above charges a whole batch ONE token while the router dispatches every
-# element concurrently, so without this cap one request fans out into hundreds
-# of handlers on the shared DB pool. Over the cap the hub answers 400 / -32600.
+# Optional: maximum JSON-RPC calls in one batch array (default 20). The router
+# dispatches every element concurrently, so without this cap one request fans
+# out into hundreds of handlers on the shared DB pool. Over the cap the hub answers 400 / -32600.
# HUB_MAX_RPC_BATCH=20
# Optional: allowed CORS origin(s) (default disabled). `*` allows any origin;
# otherwise give one origin, or a comma-separated allowlist matched per-origin.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 316d4fdbf..7ae746333 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.20.1] - 2026-09-23
+
+### Fixed
+- Re-slid BTC/DOGE testnet activations with LTC inert; canonical admission is non-throwing, and INERT legacy-signature mismatches stay on the invalid-row path.
+
+
+### Changed
+- The attestation batch publisher's `quarantinedWindows` statistic now counts only quarantines inside the current catch-up horizon. Dashboards keyed to the previous all-history count may see a one-time decrease after upgrade.
+
## [0.20.0] - 2026-09-17
### Changed
diff --git a/CONFIGURATION.md b/CONFIGURATION.md
index bd4299e7d..107afc99b 100644
--- a/CONFIGURATION.md
+++ b/CONFIGURATION.md
@@ -84,10 +84,11 @@ signatures.
| `HUB_API_KEY` | **Prod** | _empty_ | API key required on write requests and WS upgrades when set. **Empty means the hub refuses to boot unless `HUB_ALLOW_UNAUTHENTICATED=true` - see above.** |
| `HUB_ALLOW_UNAUTHENTICATED` | No | _unset_ | Declares that this hub knowingly runs with an unauthenticated write surface. Only consulted when `HUB_API_KEY` is empty. |
| `HUB_CONSENSUS_INPUT_ALERT_AFTER` | No | `3` | Consecutive consensus-input fetch failures before the hub alerts and `/health` reports degraded. |
-| `HUB_RATE_LIMIT_RPM` | No | `100` | Requests per minute per client IP, on every route EXCEPT `/hub-db/snapshot/*` (see below). Over the limit the hub answers `429` with a JSON-RPC error body (code `-32029`) naming the limit, the window and the wait, alongside `Retry-After` and `RateLimit-*` headers. |
+| `HUB_RATE_LIMIT_RPM` | No | `100` | The PUBLIC budget: requests per minute per client IP for callers without a hub key, on every route EXCEPT `/hub-db/snapshot/*` (see below), on every role including a validator hub. A JSON-RPC batch costs one token per call, not one per HTTP request. Over the limit the hub answers `429` with a JSON-RPC error body (code `-32029`) naming the limit, the window and the wait, alongside `Retry-After` and `RateLimit-*` headers. |
+| `HUB_AUTH_RATE_LIMIT_RPM` | No | `60000` | The AUTHENTICATED budget: requests per minute per client IP for callers whose `x-api-key` matches `HUB_API_KEY`, `HUB_REORG_API_KEY` or `HUB_CONFIG_SECRETS_API_KEY`, metered in its own bucket so the fleet's indexer push and replay traffic keeps the fleet value without lifting the throttle for keyless callers. A wrong key is metered as public. Batches are charged per call here too. Never defaults below `HUB_RATE_LIMIT_RPM`; an unparseable or non-positive value keeps the default. A keyless hub has no authenticated tier. |
| `HUB_SNAPSHOT_RATE_LIMIT_RPM` | No | `600` | Requests per minute per client IP on the mirror-bootstrap routes `/hub-db/snapshot/*`, metered in their OWN bucket so a bootstrapping indexer and ordinary polling cannot starve each other. Sized to the measured drain: about 30 page reads for one mirror's full ten-table bootstrap, about 96 for three mirrors behind one address, retried every 30s while any mirror is still draining. Over the limit the hub answers `429` in the `{ "error": ... }` shape these REST routes already use. An unparseable or non-positive value keeps the default. |
| `HUB_RATE_LIMIT_EXEMPT_LOCAL` | No | `true` | Exempts loopback and private-range (RFC1918 / IPv6 ULA / link-local) callers from the per-IP limit, so a node's own indexer can rebuild price history from the chain at the shipped default instead of needing the limit raised by hand. Keyed on the post-`trust proxy` client IP, so a public caller behind a private-IP proxy is still limited. Set `false` to enforce the cap on every caller. |
-| `HUB_MAX_RPC_BATCH` | No | `20` | Maximum JSON-RPC calls in one batch array. The router dispatches every element concurrently while the rate limit above charges the whole batch one token, so an uncapped batch amplifies one request into hundreds of DB-touching handlers. Over the cap the hub answers `400` with JSON-RPC error `-32600`. An unparseable or non-positive value keeps the default. |
+| `HUB_MAX_RPC_BATCH` | No | `20` | Maximum JSON-RPC calls in one batch array. The router dispatches every element concurrently, so an uncapped batch amplifies one request into hundreds of DB-touching handlers; the rate limits above charge each element, and this caps the count outright. Over the cap the hub answers `400` with JSON-RPC error `-32600`. An unparseable or non-positive value keeps the default. |
| `CORS_ORIGIN` | No | `false` | Allowed CORS origin(s); unset disables cross-origin requests. `*` allows any origin, or give one origin or a comma-separated allowlist matched per-origin (browser wallet shells each send a different origin). A stray `*` inside a list is not a wildcard, so the grant fails closed. |
## Database (MariaDB)
diff --git a/Dockerfile b/Dockerfile
index 0085dec2d..4cafe3c81 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,8 +1,8 @@
-# Pinned to node:22-bookworm, the tag .nvmrc and the sibling service images
-# already declare. `node:latest` floats: a rebuild silently moves the runtime
-# off the declared Node 22, so the image and the repo's pin drift apart with
-# no signal anywhere.
-FROM node:22-bookworm
+# Pinned by digest to the node:22.23.2-bookworm build whose V8/ICU match
+# xchain-vm's consensus runtime pin: the floating node:22-bookworm tag moved
+# to a Node patch that fails that check, so a tag alone can silently drift
+# the image off the runtime the fleet requires.
+FROM node:22.23.2-bookworm@sha256:dd5847a04b0deee391fa145f1f4c6d214196668b6bcc7988ebed67249f226844
RUN mkdir /XChainHub/
COPY ./package.json /XChainHub/package.json
diff --git a/README.md b/README.md
index 6ab05c0dc..f728667f6 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
# XChain Platform Hub
-
+
diff --git a/bin/ci-full.sh b/bin/ci-full.sh
index 3de3902d6..ceac44307 100755
--- a/bin/ci-full.sh
+++ b/bin/ci-full.sh
@@ -74,7 +74,36 @@ SELF="$(pwd)"
SIB="$(cd .. && pwd)"
FAILED=""
+# >>> ci-tier (generated block; re-run the tier wirer to update) >>>
+# Tier classes. A push grades the FAST tier only: the unit job, the pin and
+# drift guards, and the structure and hygiene checks the hook runs before it
+# dispatches. The tiers named below (coverage re-runs, perf scenarios) are
+# skipped when the gate sets CI_TIER=fast, and each skip is recorded so the
+# closing verdict can never claim a green it did not earn. Nothing stops
+# being graded: a scheduled sweep re-runs this same script with CI_TIER=full
+# on every repo every three hours and before any release or deploy, and a
+# red there is tracked down and fixed first. CI_TIER is unset for a hand
+# run, so a bare `npm run ci:full` still runs every tier as it always did.
+CI_TIER_FULL_ONLY=(
+ "perf (test:perf)"
+ "coverage ratchet (coverage:check)"
+)
+DEFERRED=""
+ci_tier_deferred() {
+ [ "${CI_TIER:-full}" = "fast" ] || return 1
+ local t
+ for t in ${CI_TIER_FULL_ONLY[@]+"${CI_TIER_FULL_ONLY[@]}"}; do
+ if [ "$t" = "$1" ]; then
+ DEFERRED="$DEFERRED [$1]"
+ echo; echo "ci:full ===== $1 DEFERRED (CI_TIER=fast, runs in the full sweep) ====="
+ return 0
+ fi
+ done
+ return 1
+}
+# <<< ci-tier <<<
run_tier() {
+ ci_tier_deferred "$1" && return 0 # ci-tier guard (generated)
local name="$1"; shift
echo; echo "ci:full ===== $name ====="
if "$@"; then
@@ -122,7 +151,7 @@ run_tier "measurement tools (bin/test)" \
npx mocha 'bin/test/**/*.test.js' --no-config --timeout 120000 --recursive --exit
# --- job: ci (XChain-Platform/.github ci-reusable.yml -> npm run ci) -------
-run_tier "ci" npm run ci
+run_tier "ci" env XCHAIN_REQUIRE_SIBLINGS=1 npm run ci
# --- job: perf -------------------------------------------------------------
# The workflow gives this job its own MariaDB service container; here the DB is
@@ -159,11 +188,24 @@ run_tier "drift: vendored observability shim vs canonical (six consumers)" \
npm run check:observability-sync
# --- job: coverage ---------------------------------------------------------
-run_tier "coverage ratchet (coverage:check)" npm run coverage:check
+run_tier "coverage ratchet (coverage:check)" \
+ env XCHAIN_REQUIRE_SIBLINGS=1 npm run coverage:check
echo
+# >>> ci-tier summary (generated) >>>
+echo "ci:full: tier class ${CI_TIER:-full}"
+if [ -n "${DEFERRED:-}" ]; then
+ echo "ci:full: DEFERRED to the full sweep:$DEFERRED"
+fi
+# <<< ci-tier summary <<<
if [ -n "$FAILED" ]; then
echo "ci:full: RED tiers:$FAILED"
exit 1
fi
-echo "ci:full: all tiers green (same set GitHub CI runs)"
+# >>> ci-tier verdict (generated) >>>
+if [ "${CI_TIER:-full}" = "fast" ]; then
+ echo "ci:full: all FAST tiers green; the DEFERRED tiers above were NOT graded here"
+else
+ echo "ci:full: all tiers green (same set GitHub CI runs)"
+fi
+# <<< ci-tier verdict <<<
diff --git a/bin/consensus-identity.js b/bin/consensus-identity.js
index a68c8bb9a..60f3865bf 100644
--- a/bin/consensus-identity.js
+++ b/bin/consensus-identity.js
@@ -11,7 +11,7 @@
*
**********************************************************************
*
- * The four numbers that say whether this build still applies the same rules as
+ * The five numbers that say whether this build still applies the same rules as
* the one before it. A restructure may move any file it likes; it may not move
* one of these.
*
@@ -21,9 +21,9 @@
* "same rules?".
* gates_field_hash sha256 of the GATES field this hub signs into a
* ROLLCALL v1 epoch, which is
- * knownGateKeys().join(','). Built at
- * RollcallRound.js:313 and hashed at
- * rollcall_canonical.js:49.
+ * knownGateKeys().join(','). Built by gatesFor()
+ * in rollcall/round.js and hashed by gatesHash()
+ * in rollcall/rollcall_canonical.js.
* coin_consensus_pins the coin registry's consensus hash for every
* (coin, network) PAIR, not one number per network.
* hub_schema_version the mirror-row schema every reader checks for
@@ -34,30 +34,32 @@
* not the tree: the pin's own guard measures the tree.
*
* WHY THE GATES MAP IS PINNED AND NOT JUST THE DIGEST, and this is the whole
- * reason this script exists rather than a one-line hash. consensus_rules_digest
- * resolves each carrier with `require('./' + mod + '.js')` in a try/catch that
- * substitutes the ABSENT sentinel, so moving every gate module into a
- * subdirectory produces a perfectly well-formed digest over a map in which every
- * single gate reads ``. Meanwhile the SIGNED wire field is
- * knownGateKeys().join(','), which is built from the same key list and is
- * therefore IDENTICAL before and after the move. A hub in that state publishes
- * the same GATES string as every peer over a rules set it has entirely lost, and
- * no wire field names the difference. So the pin carries the map, and
- * --assert-no-absent turns "the digest changed" into "these gates stopped
- * resolving", which is the sentence an operator can act on.
+ * reason this script exists rather than a one-line hash. Two digests that differ
+ * say nothing about what to fix; the map turns "the digest changed" into "these
+ * gates changed", which is the sentence an operator can act on, and it is what
+ * makes an environment-shifted digest diagnosable in one read (see ONE OF THE
+ * FIVE below). The SIGNED wire field is knownGateKeys().join(','), built from
+ * the key list alone, so it reads the same whether or not a carrier resolves.
+ * That is why consensus_rules_digest takes each gate from the activation
+ * registry, or a function-valued one from src/consensus/gates/_gate.js,
+ * and THROWS naming the key when neither is there: this script then prints no
+ * pin and exits 2 with that one line, so a moved carrier cannot hide inside a
+ * well-formed digest. `` survives as the sentinel that module's
+ * diffGates uses for a key a peer's map lacks, not as a value this build emits.
*
- * WHY THE COIN PIN IS PER PAIR. coins/index.js:338 is consensusHash(tick,
- * network), and coins/consensus_pin.js has CONSENSUS_CONFIG_PIN.mainnet null,
- * so mainnet verification is a deliberate skip and only the non-mainnet pairs
- * are actually pinned today. One number per network would average over three
- * chains and hide a single moved chain; the pair map cannot.
+ * WHY THE COIN PIN IS PER PAIR. The registry hash is consensusHash(tick,
+ * network) in coins/index.js, and coins/consensus_pin.js has
+ * CONSENSUS_CONFIG_PIN.mainnet null, so mainnet verification is a deliberate
+ * skip and only the non-mainnet pairs are actually pinned today. One number per
+ * network would average over three chains and hide a single moved chain; the
+ * pair map cannot.
*
- * WHY NO LEDGER READ. The indexer's copy of this tool takes a fourth reading
+ * WHY NO LEDGER READ. The indexer's copy of this tool takes a fifth reading
* from its blocks table. The hub has no chain of its own to read: it is the
* config oracle, so its identity is entirely code-derived and this script opens
* no socket, reads no .env and needs no database.
*
- * ONE OF THE FOUR IS NOT PURE, AND IT MATTERS FOR ANY PIN. The rules digest
+ * ONE OF THE FIVE IS NOT PURE, AND IT MATTERS FOR ANY PIN. The rules digest
* hashes gate VALUES, and a regtest venue arms some gates from its own
* environment rather than from a committed height, so the same build reports one
* digest in a bare checkout and another inside a configured container. Two
@@ -70,9 +72,10 @@
* node bin/consensus-identity.js --json the full pin on stdout
* node bin/consensus-identity.js --out write the pin as JSON
* node bin/consensus-identity.js --root measure another checkout
- * node bin/consensus-identity.js --assert-no-absent exit 1 if any gate is
- * unresolved (the check a
- * move has to survive)
+ * node bin/consensus-identity.js --assert-no-absent exit 1 if any gate reads
+ * ``; a lost carrier
+ * is the exit-2 refusal
+ * above, flag or no flag
* node bin/consensus-identity.js --compare diff a tree against a pin,
* exit 1 on any difference
*
@@ -91,6 +94,16 @@ let REPO_ROOT = path.resolve(__dirname, '..');
const ABSENT = '';
+// The row a function-valued shared gate prints: present, body not digested (see SHARED_GATES).
+const FUNCTION_GATE = '';
+
+// Name every gate row, since a function canonicalizes to undefined and JSON would drop it.
+function projectGates(gates) {
+ const out = {};
+ for (const key of Object.keys(gates)) out[key] = gates[key] === undefined ? FUNCTION_GATE : gates[key];
+ return out;
+}
+
/**
* Measure `dir` instead of the checkout this script lives in.
* @param {string} dir a hub checkout
@@ -105,11 +118,6 @@ function loadFromRepo(rel) {
return require(path.join(REPO_ROOT, rel));
}
-/** Key-sorted JSON, so the hash of a map does not depend on insertion order. */
-function canonicalJson(value) {
- return JSON.stringify(value, Object.keys(value).sort());
-}
-
/**
* The coin registry's consensus hash for every (coin, network) pair, plus which
* pairs carry an armed CONSENSUS_CONFIG_PIN to be checked against.
@@ -138,33 +146,36 @@ function coinConsensusPins() {
}
/**
- * The four values, all of them derived from the source tree alone.
+ * The five values, all of them derived from the source tree alone.
* @returns {object}
*/
function codeIdentity() {
const rulesModule = loadFromRepo('src/consensus_rules_digest.js');
const { HUB_SCHEMA_VERSION } = loadFromRepo('src/hub_schema_version.js');
const logicPin = loadFromRepo('bin/lib/carrier_logic_pin.js');
+ // The measured checkout's registry, whose exported canonicaliser consensusHash uses.
+ const coins = loadFromRepo('src/coins/index.js');
const rules = rulesModule.computeConsensusRulesDigest();
+ const gates = projectGates(rules.gates);
// The GATES field verbatim, because the hash alone cannot be checked by hand
// against a wire capture and this is the string the hub signs.
const gatesField = rulesModule.knownGateKeys().join(',');
const coinPins = coinConsensusPins();
- const absentGates = Object.keys(rules.gates).filter((k) => rules.gates[k] === ABSENT).sort();
+ const absentGates = Object.keys(gates).filter((k) => gates[k] === ABSENT).sort();
return {
consensus_rules_digest: rules.digest,
// The gate-by-gate preimage. See WHY THE GATES MAP IS PINNED: without it
- // a digest match cannot tell "unchanged" from "all absent on both sides".
- consensus_rules_gates: rules.gates,
- gate_key_count: Object.keys(rules.gates).length,
+ // a digest mismatch cannot be traced to the gate that moved.
+ consensus_rules_gates: gates,
+ gate_key_count: Object.keys(gates).length,
absent_gates: absentGates,
gates_field: gatesField,
gates_field_hash: crypto.createHash('sha256').update(gatesField, 'utf8').digest('hex'),
coin_consensus_pins: coinPins.pairs,
- coin_consensus_pin_hash: crypto.createHash('sha256').update(canonicalJson(coinPins.pairs)).digest('hex'),
+ coin_consensus_pin_hash: crypto.createHash('sha256').update(coins.canonicalJson(coinPins.pairs)).digest('hex'),
coin_pin_armed_networks: coinPins.pinnedNetworks,
coin_pin_skipped_networks: coinPins.unpinnedNetworks,
hub_schema_version: HUB_SCHEMA_VERSION,
@@ -219,12 +230,24 @@ function compare(pin, fresh) {
function parseArgs(argv) {
const opts = { json: false, assertNoAbsent: false };
for (let i = 0; i < argv.length; i += 1) {
- if (argv[i] === '--json') opts.json = true;
- else if (argv[i] === '--assert-no-absent') opts.assertNoAbsent = true;
- else if (argv[i] === '--root') { opts.root = path.resolve(argv[i + 1]); i += 1; }
- else if (argv[i] === '--out') { opts.out = path.resolve(argv[i + 1]); i += 1; }
- else if (argv[i] === '--compare') { opts.compare = path.resolve(argv[i + 1]); i += 1; }
- else if (argv[i] === '--help' || argv[i] === '-h') opts.help = true;
+ const arg = argv[i];
+ // Refuse a missing value or a following flag: consumed as a path, `--out
+ // --assert-no-absent` would drop the assertion and exit 0.
+ const takeValue = () => {
+ const value = argv[i + 1];
+ if (value === undefined || value.startsWith('-')) throw new Error(`${arg} requires a value`);
+ i += 1;
+ return value;
+ };
+ if (arg === '--json') opts.json = true;
+ else if (arg === '--assert-no-absent') opts.assertNoAbsent = true;
+ else if (arg === '--root') opts.root = path.resolve(takeValue());
+ else if (arg === '--out') opts.out = path.resolve(takeValue());
+ else if (arg === '--compare') opts.compare = path.resolve(takeValue());
+ else if (arg === '--help' || arg === '-h') opts.help = true;
+ // A misspelt flag would otherwise run as the default reading and exit 0,
+ // which a caller checking --assert-no-absent would take as a pass.
+ else throw new Error(`unknown flag ${argv[i]}`);
}
return opts;
}
@@ -294,7 +317,6 @@ module.exports = {
codeIdentity,
coinConsensusPins,
compare,
- canonicalJson,
setRepoRoot,
// The measured checkout, as a call rather than a binding: a consumer that
// captured the value at require time would keep reading the default after
diff --git a/bin/pins/at1-consensus-identity.json b/bin/pins/at1-consensus-identity.json
index 2a65ea62d..9c5d5dc22 100644
--- a/bin/pins/at1-consensus-identity.json
+++ b/bin/pins/at1-consensus-identity.json
@@ -1,5 +1,5 @@
{
- "consensus_rules_digest": "ee6476f2445fe454b6b262e6f9dbea1b922bd31ba7eec55a6c7b30dbb513cf5e",
+ "consensus_rules_digest": "f69861138e062d4feb1dc7f9975fd28284a1f297e7e8375e6d870c020f3d97fd",
"consensus_rules_gates": {
"anchor_reward_activation.ANCHOR_REWARD_ACTIVATION": "{\"mainnet\":961000,\"regtest\":0,\"testnet\":0}",
"anchor_reward_activation.ARCHIVE_REWARD_ACTIVATION": "{\"mainnet\":963000,\"regtest\":0,\"testnet\":0}",
@@ -21,20 +21,25 @@
"attest_responsible_widening_activation.ATTEST_RESPONSIBLE_WIDENING_V2": "{\"headroom\":1,\"maxSlots\":2,\"startOffset\":0}",
"rollcall_gates_activation.ROLLCALL_GATES_ACTIVATION": "{\"mainnet\":null,\"regtest\":null,\"testnet\":152208}",
"xchain_bridge_activation.XCHAIN_BRIDGE_ACTIVATION": "{\"BTC:mainnet\":9999999999,\"BTC:testnet\":152929,\"DOGE:mainnet\":9999999999,\"DOGE:testnet\":67902062,\"LTC:mainnet\":9999999999,\"LTC:testnet\":4887898,\"mainnet\":9999999999,\"regtest\":0,\"testnet\":9999999999}",
- "mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":153222,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67911796,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":4891504}",
- "mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":153266,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67912575,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":4891766}",
+ "mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":154234,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67936053,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":null}",
+ "mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":154291,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67936888,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":null}",
"mirror_admission_activation.ADMIT_MARGIN_BLOCKS": "{\"anchor_reward_attestations\":144,\"attestation_responses\":1,\"default\":4,\"oracle_prices\":1}",
"mirror_admission_activation.ADMIT_MIN_FUTURE_BLOCKS": "1",
"mirror_admission_activation.ADMIT_MAX_FUTURE_BLOCKS": "{\"BTC\":6,\"DOGE\":60,\"LTC\":24,\"default\":6}",
- "anchor_reward_activation.ANCHOR_ATTEST_BARRIER_ACTIVATION": "{\"mainnet\":null,\"regtest\":null,\"testnet\":153266}",
+ "anchor_reward_activation.ANCHOR_ATTEST_BARRIER_ACTIVATION": "{\"mainnet\":null,\"regtest\":null,\"testnet\":154291}",
"anchor_reward_activation.ANCHOR_ATTEST_ARRIVAL_MARGIN_S": "64800",
"mirror_admission_activation.CHAIN_CODE_RE": "{}",
- "mirror_admission_activation.CANONICAL_HEIGHT_RE": "{}"
+ "mirror_admission_activation.CANONICAL_HEIGHT_RE": "{}",
+ "mirror_admission_activation.encodeAdmitBlocks": "",
+ "mirror_admission_activation.decodeAdmitBlocks": "",
+ "mirror_admission_activation.isAdmissionEra": "",
+ "mirror_admission_activation.admissionCanonicalField": "",
+ "token_bridge_activation.TOKEN_BRIDGE_ACTIVATION": "{\"mainnet\":9999999999,\"regtest\":0,\"testnet\":9999999999}"
},
- "gate_key_count": 33,
+ "gate_key_count": 34,
"absent_gates": [],
- "gates_field": "anchor_reward_activation.ANCHOR_ATTEST_ARRIVAL_MARGIN_S,anchor_reward_activation.ANCHOR_ATTEST_BARRIER_ACTIVATION,anchor_reward_activation.ANCHOR_REWARD_ACTIVATION,anchor_reward_activation.ANCHOR_REWARD_DERIVE_ACTIVATION,anchor_reward_activation.ARCHIVE_REWARD_ACTIVATION,attest_relay_activation.ATTEST_RELAY_ACTIVATION,attest_response_mirror_activation.ATTEST_RESPONSE_MIRROR_ACTIVATION,attest_responsible_widening_activation.ATTEST_RESPONSIBLE_WIDENING,attest_responsible_widening_activation.ATTEST_RESPONSIBLE_WIDENING_ACTIVATION,attest_responsible_widening_activation.ATTEST_RESPONSIBLE_WIDENING_V2,attest_zero_conf_activation.ATTEST_ZERO_CONF_ACTIVATION,checkpoint_commitment_activation.CHECKPOINT_COMMITMENT_ACTIVATION,cross_chain_royalty_activation.CROSS_CHAIN_ROYALTY_ACTIVATION,equivocation_header.EQUIV_HEADER_ACTIVATION,mirror_admission_activation.ADMIT_MARGIN_BLOCKS,mirror_admission_activation.ADMIT_MAX_FUTURE_BLOCKS,mirror_admission_activation.ADMIT_MIN_FUTURE_BLOCKS,mirror_admission_activation.CANONICAL_HEIGHT_RE,mirror_admission_activation.CHAIN_CODE_RE,mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION,mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION,mirror_admission_activation.admissionCanonicalField,mirror_admission_activation.decodeAdmitBlocks,mirror_admission_activation.encodeAdmitBlocks,mirror_admission_activation.isAdmissionEra,price_pair_activation.PRICE_PAIR_WIDEN_ACTIVATION,price_sig_tally_activation.PRICE_SIG_TALLY_ACTIVATION,retraction_signing_activation.RETRACTION_SIGNING_ACTIVATION,rollcall_activation.ROLLCALL_ACTIVATION,rollcall_gates_activation.ROLLCALL_GATES_ACTIVATION,snapshot_reorg_buffer.SNAPSHOT_BURIAL_ACTIVATION,stake_weighted_quorum.STAKE_WEIGHTED_QUORUM_ACTIVATION,xchain_bridge_activation.XCHAIN_BRIDGE_ACTIVATION",
- "gates_field_hash": "f19f21f028823fa1aa784e4220724ea13dabd02a1c7d7bb963bc72c8ec4d85b5",
+ "gates_field": "anchor_reward_activation.ANCHOR_ATTEST_ARRIVAL_MARGIN_S,anchor_reward_activation.ANCHOR_ATTEST_BARRIER_ACTIVATION,anchor_reward_activation.ANCHOR_REWARD_ACTIVATION,anchor_reward_activation.ANCHOR_REWARD_DERIVE_ACTIVATION,anchor_reward_activation.ARCHIVE_REWARD_ACTIVATION,attest_relay_activation.ATTEST_RELAY_ACTIVATION,attest_response_mirror_activation.ATTEST_RESPONSE_MIRROR_ACTIVATION,attest_responsible_widening_activation.ATTEST_RESPONSIBLE_WIDENING,attest_responsible_widening_activation.ATTEST_RESPONSIBLE_WIDENING_ACTIVATION,attest_responsible_widening_activation.ATTEST_RESPONSIBLE_WIDENING_V2,attest_zero_conf_activation.ATTEST_ZERO_CONF_ACTIVATION,checkpoint_commitment_activation.CHECKPOINT_COMMITMENT_ACTIVATION,cross_chain_royalty_activation.CROSS_CHAIN_ROYALTY_ACTIVATION,equivocation_header.EQUIV_HEADER_ACTIVATION,mirror_admission_activation.ADMIT_MARGIN_BLOCKS,mirror_admission_activation.ADMIT_MAX_FUTURE_BLOCKS,mirror_admission_activation.ADMIT_MIN_FUTURE_BLOCKS,mirror_admission_activation.CANONICAL_HEIGHT_RE,mirror_admission_activation.CHAIN_CODE_RE,mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION,mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION,mirror_admission_activation.admissionCanonicalField,mirror_admission_activation.decodeAdmitBlocks,mirror_admission_activation.encodeAdmitBlocks,mirror_admission_activation.isAdmissionEra,price_pair_activation.PRICE_PAIR_WIDEN_ACTIVATION,price_sig_tally_activation.PRICE_SIG_TALLY_ACTIVATION,retraction_signing_activation.RETRACTION_SIGNING_ACTIVATION,rollcall_activation.ROLLCALL_ACTIVATION,rollcall_gates_activation.ROLLCALL_GATES_ACTIVATION,snapshot_reorg_buffer.SNAPSHOT_BURIAL_ACTIVATION,stake_weighted_quorum.STAKE_WEIGHTED_QUORUM_ACTIVATION,token_bridge_activation.TOKEN_BRIDGE_ACTIVATION,xchain_bridge_activation.XCHAIN_BRIDGE_ACTIVATION",
+ "gates_field_hash": "709a898fb424966c6189d9de1938c03f7a78443f944030e33d451edbc63ad814",
"coin_consensus_pins": {
"BTC:mainnet": "242cf3cfcb4ff494d43b3d39775938f822f03a45326beff3b125ca102648e6ad",
"LTC:mainnet": "5fd9f1dda65b55faf6a5e507a4a81bd2a5ba6813d8a3ed1f04cd27b99fec03c3",
@@ -55,5 +60,5 @@
"mainnet"
],
"hub_schema_version": 7,
- "carrier_logic_digest": "48f3d471e54ceb2082861bdcd6ece30d68d42375feb18153cf8f9bcda4ee165d"
+ "carrier_logic_digest": "ca77303a657414d3a75f587e152e7fad6ca3dd94f15ffc9c32f40c24d945761c"
}
diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json
index a77aaa7ca..eacc25681 100644
--- a/bin/pins/at1-suite-titles.json
+++ b/bin/pins/at1-suite-titles.json
@@ -1363,15 +1363,6 @@
"e5a52ef95e935f53002f3f6a19037a22b54ca1b84cb9992030d82bdf00cf0325",
"6c5adea75dbab254c4617ef8432e0f54b654bea928e43045848c57f1270cc4bf"
],
- "247daec60db604ec": [
- "790d33b706a4d861b11e2babc8fc013869a72cc0e2257fdb5e2853e50f224ff5",
- "ea5bb9ecbea9d4ee6103ebc21439ec93cc5931488bf8391ebe09261f876d4c36",
- "96c6aaf15072d51c011cb1eb3fb40130f4f000fa83f22e8cf4e655e49919e006",
- "5fa294212ce85473365e4c7083a7aa4c2cf756fec55a1c21d29208fd0f5a514f",
- "94bdf42183dea70f4ed78a4177f0435ac64c6c0a339f962829e4716e93854385",
- "8a953a352a75b6d0fed2c5aa4c08d93aa0d5299d2d4376b1dd5b2ff80bb4840b",
- "f1cf5f9d20412c1e1bde8fa62b397e7dd5963f779a8f26fdbb5d7e66109b9db4"
- ],
"24a2552ee5e2627c": [
"581e0eb1c6087dd597caa1b626ee3d2bf4f31759c7cef2abc2b7babedb0acec5",
"a34547f2ee4f1fb534ae4928d97c8e7cf426898ab6208f4a35a213d6951896ac",
@@ -8804,6 +8795,15 @@
"7af9fce537c9dfea9527bbe2fe3b89d9df8045444e7ea77b3fe233185da29754",
"f88a9439387d34c4125d9a15306290a0fcefad9404606798986ce2cca98f62b5"
],
+ "f7bbb4514212d6f3": [
+ "d49bfd35be6446b731f4e829348fbf91507e2c0de88534b534dec19a1551e4de",
+ "e37529ab23c20969256b4ce679cd69ee8e2e310e6e99c20add14fd6cd4af6c1d",
+ "9dfe8307d9051964bfd7040786fa1f64d6d3b89f6d331a469e5846d9d9f58d25",
+ "2ef917540e197747b07cc70e835949934359b23af1880a834166a2253643898b",
+ "b57c69ad8ddaa61ec0cd5eae6968fcbf9347c996bb6e0abfff38dbb165d96c3d",
+ "3f3e628ad8fe6308403564408b2f72ad22552cf296384aef39110dff2458bb3a",
+ "6a8d80e71f9b5b27d793b946d8004ec5549023915a9c4e55e08880d2550b4c1d"
+ ],
"f848101de0f26a45": [
"aa72839c5ce957458accfdd1ed69a78aae4bdc722a874c6da9640fa6c01699f2",
"b50da41333f64911824a0cf1e23253031fa64ff4fade89d3defb67032f9e7708",
@@ -9527,7 +9527,7 @@
"test/unit/oracle/price/price_capability_derivation_rows.test.js": "35eb18802fe53bd9",
"test/unit/oracle/price/price_capability_derivation_topology.test.js": "a525401f113821d1",
"test/unit/oracle/price/price_v0_canonical_admission.test.js": "c6ad10cbe91a98b9",
- "test/unit/oracle/price/price_v2_payload_twin_parity.test.js": "247daec60db604ec",
+ "test/unit/oracle/price/price_v0_batch_canonical_parity.test.js": "f7bbb4514212d6f3",
"test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js": "09a26ce652e78b52",
"test/unit/oracle/price/push_price_batch.test.js": "a1d7bb616c893e9e",
"test/unit/oracle/publisher/batch/oracle_publisher_batch_01_the_separate_durable_buffer_file.test.js": "91662f70f43b9692",
@@ -10201,7 +10201,7 @@
"test/unit/oracle/price/price_capability_derivation_rows.test.js": "35eb18802fe53bd9",
"test/unit/oracle/price/price_capability_derivation_topology.test.js": "a525401f113821d1",
"test/unit/oracle/price/price_v0_canonical_admission.test.js": "c6ad10cbe91a98b9",
- "test/unit/oracle/price/price_v2_payload_twin_parity.test.js": "247daec60db604ec",
+ "test/unit/oracle/price/price_v0_batch_canonical_parity.test.js": "f7bbb4514212d6f3",
"test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js": "09a26ce652e78b52",
"test/unit/oracle/price/push_price_batch.test.js": "a1d7bb616c893e9e",
"test/unit/oracle/publisher/batch/oracle_publisher_batch_01_the_separate_durable_buffer_file.test.js": "91662f70f43b9692",
@@ -10903,7 +10903,7 @@
"test/unit/oracle/price/price_capability_derivation_rows.test.js": "35eb18802fe53bd9",
"test/unit/oracle/price/price_capability_derivation_topology.test.js": "a525401f113821d1",
"test/unit/oracle/price/price_v0_canonical_admission.test.js": "c6ad10cbe91a98b9",
- "test/unit/oracle/price/price_v2_payload_twin_parity.test.js": "247daec60db604ec",
+ "test/unit/oracle/price/price_v0_batch_canonical_parity.test.js": "f7bbb4514212d6f3",
"test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js": "09a26ce652e78b52",
"test/unit/oracle/price/push_price_batch.test.js": "a1d7bb616c893e9e",
"test/unit/oracle/publisher/batch/oracle_publisher_batch_01_the_separate_durable_buffer_file.test.js": "91662f70f43b9692",
diff --git a/bin/pins/carrier-logic.json b/bin/pins/carrier-logic.json
index 912e932ee..63189f5e2 100644
--- a/bin/pins/carrier-logic.json
+++ b/bin/pins/carrier-logic.json
@@ -16,7 +16,9 @@
"attest_responsible_widening_activation": {
"path": "src/consensus/gates/attest_responsible_widening_gate.js",
"hash": "5e389fa47f0c26faed5e3c6e817c30602c9e8ad2f51aece8dfadf0b2c09c19ad",
- "twins": []
+ "twins": [
+ "xchain-indexer"
+ ]
},
"attest_zero_conf_activation": {
"path": "src/attestation/attest_zero_conf_gate.js",
@@ -25,7 +27,7 @@
},
"consensus_rules_digest": {
"path": "src/consensus_rules_digest.js",
- "hash": "26894199bd6651dbedcc962825eb63aea91b0a5e7d1d8d3e28eaddf73ce69415",
+ "hash": "90d7040d61391bb2a42ed6ebcf8ad250914a4e81f1bab11ec98420877bae66a9",
"twins": [
"xchain-indexer"
]
@@ -33,7 +35,10 @@
"equivocation_header": {
"path": "src/consensus/equivocation_header.js",
"hash": "74a151e91bb72581fce3efe097452afa10603417d20eb008e58d1833f4a889f0",
- "twins": []
+ "twins": [
+ "xchain-indexer",
+ "xchain-sync"
+ ]
},
"lib/fullnode_activation": {
"path": "src/lib/fullnode_activation.js",
@@ -42,8 +47,10 @@
},
"mirror_admission_activation": {
"path": "src/consensus/gates/mirror_admission_gate.js",
- "hash": "ff9dacf3d0abc1037361c4265f5a324d1bc0965b078e528df55da4320f981a85",
- "twins": []
+ "hash": "d59a0c44b7d0dc1dbc5f1530d67e012a7dac33f1824960565a9788b75cff6a25",
+ "twins": [
+ "xchain-indexer"
+ ]
},
"oracle_clamp_reference_activation": {
"path": "src/oracle_clamp_reference_activation.js",
@@ -60,17 +67,23 @@
"price_scale_activation": {
"path": "src/consensus/gates/price_scale_gate.js",
"hash": "4789fa01a078a0287fe711e0ebaea253e7ebf9a39190e60de451db4fed0c0436",
- "twins": []
+ "twins": [
+ "xchain-indexer"
+ ]
},
"rollcall_activation": {
"path": "src/consensus/gates/rollcall_gate.js",
"hash": "edd542e9a143c33539433d9f6f5d0b11989402908cb464a25cc4c5fe017edc4b",
- "twins": []
+ "twins": [
+ "xchain-indexer"
+ ]
},
"rollcall_gates_activation": {
"path": "src/consensus/gates/rollcall_gates_gate.js",
"hash": "fdefddad0db37e81f8c7301a7763ecd5b4fade21c2c6ef1b6cc28211c2bc6e81",
- "twins": []
+ "twins": [
+ "xchain-indexer"
+ ]
},
"snapshot_reorg_buffer": {
"path": "src/consensus/snapshot_reorg_buffer.js",
@@ -82,7 +95,10 @@
"stake_weighted_quorum": {
"path": "src/consensus/stake_weighted_quorum.js",
"hash": "afaee5777e8d909eace7a7106b294797014f74929fc4302c50a2ec31ac77a847",
- "twins": []
+ "twins": [
+ "xchain-indexer",
+ "xchain-sync"
+ ]
},
"xchain_price_activation": {
"path": "src/xchain_price_activation.js",
@@ -630,6 +646,20 @@
"to": "26894199bd6651dbedcc962825eb63aea91b0a5e7d1d8d3e28eaddf73ce69415",
"reason": "W5 consolidation (row 21)",
"date": "2026-09-16"
+ },
+ {
+ "id": "mirror_admission_activation",
+ "from": "ff9dacf3d0abc1037361c4265f5a324d1bc0965b078e528df55da4320f981a85",
+ "to": "d59a0c44b7d0dc1dbc5f1530d67e012a7dac33f1824960565a9788b75cff6a25",
+ "reason": "canonical admission boundary fix, operator ruling (a) 2026-09-19",
+ "date": "2026-09-19"
+ },
+ {
+ "id": "consensus_rules_digest",
+ "from": "26894199bd6651dbedcc962825eb63aea91b0a5e7d1d8d3e28eaddf73ce69415",
+ "to": "90d7040d61391bb2a42ed6ebcf8ad250914a4e81f1bab11ec98420877bae66a9",
+ "reason": "SHARED_GATES appends token_bridge_activation.TOKEN_BRIDGE_ACTIVATION",
+ "date": "2026-09-23"
}
]
}
diff --git a/bin/test/consensus_identity.test.js b/bin/test/consensus_identity.test.js
index 3b67fd23f..e46bbcbba 100644
--- a/bin/test/consensus_identity.test.js
+++ b/bin/test/consensus_identity.test.js
@@ -10,7 +10,7 @@
*
**********************************************************************
*
- * The four identity values, and the one failure they exist to catch.
+ * The five identity values, and the one failure they exist to catch.
*
* The hashes themselves are not asserted against literals: a literal here is a
* second copy of the pin under bin/pins/, and the day the two disagree the one
@@ -146,6 +146,33 @@ describe('bin/consensus-identity.js', function () {
});
});
+// A function canonicalizes to undefined, which JSON drops and a comparer reads as equal.
+describe('bin/consensus-identity.js', function () {
+ this.timeout(60000);
+
+ before(() => { value = identity.codeIdentity(); });
+
+ describe('function-valued gate rows', () => {
+ const FUNCTION_KEYS = ['encodeAdmitBlocks', 'decodeAdmitBlocks', 'isAdmissionEra', 'admissionCanonicalField']
+ .map((name) => `mirror_admission_activation.${name}`);
+
+ it('serializes one row per gate key, naming a function gate by presence', () => {
+ const printed = JSON.parse(JSON.stringify(value));
+ assert.strictEqual(Object.keys(printed.consensus_rules_gates).length, printed.gate_key_count,
+ 'the printed map must hold every gate the count names');
+ for (const key of FUNCTION_KEYS) assert.strictEqual(printed.consensus_rules_gates[key], '', key);
+ });
+
+ it('reports a pin that lacks a function gate row as a difference', () => {
+ const pin = JSON.parse(JSON.stringify(value));
+ delete pin.consensus_rules_gates[FUNCTION_KEYS[0]];
+ const differences = identity.compare(pin, value);
+ assert.ok(differences.some((d) => d.kind === 'gate_added' && d.field === FUNCTION_KEYS[0]),
+ 'a row the pin lacks must not read as unchanged');
+ });
+ });
+});
+
describe('bin/consensus-identity.js', function () {
this.timeout(60000);
diff --git a/package-lock.json b/package-lock.json
index e988f9e80..9ecd9f604 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "xchain-hub",
- "version": "0.20.0",
+ "version": "0.20.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xchain-hub",
- "version": "0.20.0",
+ "version": "0.20.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"acorn": "8.18.0",
@@ -703,6 +703,30 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/@eslint/eslintrc/node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@eslint/js": {
"version": "9.39.5",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz",
@@ -2536,6 +2560,30 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/eslint/node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/eslint/node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
@@ -2784,6 +2832,13 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
},
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
@@ -4377,6 +4432,16 @@
"resolve": "^1.11.1"
}
},
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/pure-rand": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
@@ -5219,6 +5284,16 @@
"browserslist": ">= 4.21.0"
}
},
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
diff --git a/package.json b/package.json
index f8e75526c..371513ba0 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "xchain-hub",
"description": "Config oracle and cross-chain coordinator for the XChain Platform. Stores service configs in MariaDB and serves them via JSON-RPC.",
- "version": "0.20.0",
+ "version": "0.20.1",
"license": "AGPL-3.0-or-later",
"repository": {
"type": "git",
@@ -74,7 +74,13 @@
"ajv": "^8.17.2",
"decimal.js": "10.4.3",
"js-yaml": "^4.3.1",
- "form-data": "^4.0.6"
+ "form-data": "^4.0.6",
+ "eslint": {
+ "ajv": "^6.14.0"
+ },
+ "@eslint/eslintrc": {
+ "ajv": "^6.14.0"
+ }
},
"engines": {
"node": ">=22.12.0"
diff --git a/src/anchor/checkpoint_engine/canonical_forms.js b/src/anchor/checkpoint_engine/canonical_forms.js
index 1171677ca..13f1e07e4 100644
--- a/src/anchor/checkpoint_engine/canonical_forms.js
+++ b/src/anchor/checkpoint_engine/canonical_forms.js
@@ -43,11 +43,18 @@ const forms = {
String(cp.checkpoint_seq), String(cp.snapshot_block)].join('|');
},
+ // Is CHECKPOINT_COMMITMENT active at this checkpoint's own network and snapshot_block?
+ // The one reader of the flag-day key, shared by the suffix, the rootless guard and
+ // the ANCHOR bundle selector's floor.
+ isCheckpointCommitmentActive(cp){
+ return gateRegistry.activeAt(CHECKPOINT_COMMITMENT_KEY, cp.network, null, cp.snapshot_block, null);
+ },
+
// The SPV Phase 2 (spec §6.1) root suffix appended to the checkpoint-family
// canonical at/above the CHECKPOINT_COMMITMENT flag-day. Kept as one helper so
// the hub / SDK / indexer-anchor / explorer all build byte-identical bytes.
checkpointRootSuffix(cp){
- if(!gateRegistry.activeAt(CHECKPOINT_COMMITMENT_KEY, cp.network, null, cp.snapshot_block, null)) return '';
+ if(!forms.isCheckpointCommitmentActive(cp)) return '';
// Append only when the roots are actually present. Post-flag-day the engine
// refuses to sign a checkpoint that lacks them (runRound throws), so for every
// REAL post-flag-day checkpoint this is always true and the suffix is byte-
@@ -106,7 +113,7 @@ const forms = {
// be enforced on one path and quietly skipped on the others again.
isRootless(cp){
if(!cp) return false;
- if(!gateRegistry.activeAt(CHECKPOINT_COMMITMENT_KEY, cp.network, null, cp.snapshot_block, null)) return false;
+ if(!forms.isCheckpointCommitmentActive(cp)) return false;
return (!cp.state_root || !cp.block_merkle_root ||
cp.state_root_version == null || cp.block_merkle_version == null);
},
diff --git a/src/anchor/publisher/archive/rows.js b/src/anchor/publisher/archive/rows.js
index f47825a3b..ccfa727d0 100644
--- a/src/anchor/publisher/archive/rows.js
+++ b/src/anchor/publisher/archive/rows.js
@@ -141,18 +141,18 @@ module.exports = {
for(let m of matchIds){
await this.db.updateCrossChainMatchByMatchIdAndBatchSeq(batchSeq, m.status, txid, m.match_id);
}
- // Re-emit the stamped rows on the hub-DB mirror feed: anchor_txid is the one
- // back-filled column the mirror twins carry, and without a re-broadcast a
- // long-running streamed mirror keeps NULL forever while a later REST bootstrap
- // serves the stamp (divergent mirrors). Retracted rows stay out of the feed
- // (the stream already deleted them on mirrors); old sync clients INSERT IGNORE
- // the re-delivery, so this is backward-compatible.
+ // Emit a metadata-only anchor stamp on the hub-DB mirror feed. A full-row
+ // re-broadcast can be refused after the admission watermark abandons the
+ // original XDEX round, while bypassing that refusal for a full row could
+ // insert a consensus row below a certified height. The narrow event lets a
+ // mirror update an existing row only. Retracted rows stay out of the feed
+ // because the stream already deleted them on mirrors.
if(txid && matchIds.length && this.hub && this.hub.hubDbBroadcaster){
try {
let ids = matchIds.map(m => m.match_id);
let rows = await this.db.findLiveCrossChainMatchesByMatchIds(ids);
for(let row of rows)
- this.hub.hubDbBroadcaster.broadcastRow({ table: 'cross_chain_matches', row: row });
+ this.hub.hubDbBroadcaster.broadcastMatchAnchorStamp(row.match_id, row.anchor_txid);
} catch(e){
logger.warn(nodeUtil.format('StateAnchorPublisher: anchor-stamp re-broadcast failed (mirrors converge on next bootstrap):', e.message));
}
diff --git a/src/anchor/publisher/archive/verify.js b/src/anchor/publisher/archive/verify.js
index 58530004b..4e82863de 100644
--- a/src/anchor/publisher/archive/verify.js
+++ b/src/anchor/publisher/archive/verify.js
@@ -27,6 +27,7 @@ const { resolveQuorumNetwork } = require('../../quorum_network.js');
const swq = require('../../../consensus/stake_weighted_quorum.js');
const ar = require('../../../consensus/gates/anchor_reward_gate.js');
const ark = require('../../anchor_reward_key.js');
+const { legacyAnchorRewardAmount } = require('../../reward_tracker.js');
const { getLogger } = require('../../../observability');
const logger = getLogger();
@@ -217,7 +218,7 @@ module.exports = {
? ar.ANCHOR_REWARD_AMOUNT
: isDerivedArchive
? ar.ARCHIVE_REWARD_AMOUNT
- : (this.hub.rewardTracker ? parseFloat(this.hub.rewardTracker.anchorReward).toFixed(8) : null);
+ : (this.hub.rewardTracker ? legacyAnchorRewardAmount(this.hub.rewardTracker.anchorReward) : null);
return expectedAmount;
},
diff --git a/src/anchor/publisher/bundle.js b/src/anchor/publisher/bundle.js
index e979293d2..76e643295 100644
--- a/src/anchor/publisher/bundle.js
+++ b/src/anchor/publisher/bundle.js
@@ -22,6 +22,7 @@
'use strict';
const ar = require('../../consensus/gates/anchor_reward_gate.js');
+const checkpointForms = require('../checkpoint_engine/canonical_forms.js');
const { ANCHOR_BUNDLE_MAX_BYTES } = require('./constants.js');
const { getLogger } = require('../../observability');
const logger = getLogger();
@@ -100,6 +101,13 @@ module.exports = {
this.anchorEveryNCheckpoints);
},
+ // Is CHECKPOINT_COMMITMENT active at this section's OWN network and snapshot_block?
+ // The bundle selector's floor reads it here, so a test harness can override the
+ // gate on one publisher instance without touching the shared registry.
+ sectionCommitmentActive(row){
+ return checkpointForms.isCheckpointCommitmentActive(row);
+ },
+
// Group the result set into ONE bundle per network. A chain absent from a
// group is NOT an anomaly (D4): under the daily cadence the normal case is a
// chain whose newest eligible seq is already anchored.
@@ -107,10 +115,7 @@ module.exports = {
let byNetwork = new Map();
for(let row of (rows || [])){
// D8: the bundle is root-bearing by construction, so a row with no
- // light-client roots cannot ride one. Below CHECKPOINT_COMMITMENT_ACTIVATION
- // (regtest 0, testnet 146000, mainnet 961000) no federation cutting
- // checkpoints today produces such a row, so this is a loud skip rather than
- // a rootless fallback wire.
+ // light-client roots cannot ride one; a loud skip, never a rootless wire.
if(row.state_root == null || row.block_merkle_root == null ||
row.state_root_version == null || row.block_merkle_version == null){
logger.warn('StateAnchorPublisher: checkpoint ' + row.chain + '/' + row.network + ' @ ' +
@@ -118,6 +123,17 @@ module.exports = {
'skipped, an ANCHOR v0 section is root-bearing by construction');
continue;
}
+ // Skip a root-bearing row below CHECKPOINT_COMMITMENT_ACTIVATION at its own
+ // snapshot_block (roots arm earlier, at STATE_COMMITMENT): its signatures cover
+ // the rootless canonical while the indexer rebuilds every v0 section WITH the
+ // root suffix, so it can never verify and would fail the whole bundle.
+ if(!this.sectionCommitmentActive(row)){
+ logger.warn('StateAnchorPublisher: checkpoint ' + row.chain + '/' + row.network + ' @ ' +
+ row.block_index + ' (seq ' + row.checkpoint_seq + ', snapshot_block ' +
+ row.snapshot_block + ') sits below CHECKPOINT_COMMITMENT; skipped, its ' +
+ 'signatures cover the rootless canonical an ANCHOR v0 section cannot carry');
+ continue;
+ }
let net = String(row.network);
if(!byNetwork.has(net)) byNetwork.set(net, []);
byNetwork.get(net).push(row);
diff --git a/src/anchor/publisher/options.js b/src/anchor/publisher/options.js
index 9df447dab..5c41b392b 100644
--- a/src/anchor/publisher/options.js
+++ b/src/anchor/publisher/options.js
@@ -25,6 +25,7 @@
const EncoderClient = require('../../peers/encoder_client.js');
const SpendGuard = require('../../lib/spend_guard.js');
const { resolveCheckpointIntervalBlocks } = require('../checkpoint_cadence.js');
+const { DEFAULT_ANCHOR_ROUND_TIMEOUT_MS } = require('../../constants.js');
const hubConfig = require('../../config');
module.exports = {
@@ -72,7 +73,7 @@ module.exports = {
this.batchSize = parseInt(hubConfig.ANCHOR_MATCH_BATCH_SIZE || cfg.ANCHOR_MATCH_BATCH_SIZE || '200');
this.maxBatch = parseInt(hubConfig.ANCHOR_MAX_BATCH || cfg.ANCHOR_MAX_BATCH || '1000');
this.chunkMaxBytes = parseInt(hubConfig.ANCHOR_CHUNK_MAX_BYTES || cfg.ANCHOR_CHUNK_MAX_BYTES || '6000');
- this.roundTimeoutMs = parseInt(hubConfig.ANCHOR_ROUND_TIMEOUT_MS || cfg.ANCHOR_ROUND_TIMEOUT_MS || '120000');
+ this.roundTimeoutMs = parseInt(hubConfig.ANCHOR_ROUND_TIMEOUT_MS || cfg.ANCHOR_ROUND_TIMEOUT_MS || DEFAULT_ANCHOR_ROUND_TIMEOUT_MS);
this.chunkRetryDelayMs = parseInt(hubConfig.ANCHOR_CHUNK_RETRY_MS || cfg.ANCHOR_CHUNK_RETRY_MS || '2500');
},
diff --git a/src/anchor/publisher/state.js b/src/anchor/publisher/state.js
index 96b02a1a7..be4d33b42 100644
--- a/src/anchor/publisher/state.js
+++ b/src/anchor/publisher/state.js
@@ -25,6 +25,27 @@
const coins = require('../../coins');
const hubConfig = require('../../config');
const { DEFAULT_ANCHOR_MARKER_RETENTION_MS } = require('./constants.js');
+const { getLogger } = require('../../observability');
+const logger = getLogger();
+
+// The anchor-attest arrival budget counts both the durable-intent hold and the
+// deferred announcement/reward-attestation queue. Its measured 18 h envelope
+// allocates 6 h to each TTL, so the shared default is also the maximum. Keeping
+// the clamp at the two config reads prevents an operator override from silently
+// moving the height watermark's worst-case trail past that fixed budget.
+const MAX_ANCHOR_ATTEST_TTL_MS = 6 * 60 * 60 * 1000;
+const ANCHOR_ATTEST_TTL_CLAMP_REASON = 'ANCHOR_ATTEST_WRITE_LAG_ENVELOPE';
+
+function anchorAttestTtlMs(raw, name) {
+ let ttlMs = parseInt(raw, 10);
+ if(ttlMs > MAX_ANCHOR_ATTEST_TTL_MS){
+ logger.warn('config: ' + name + '=' + ttlMs + 'ms exceeds the ' +
+ MAX_ANCHOR_ATTEST_TTL_MS + 'ms anchor-attest write-lag allocation; ' +
+ 'clamping at boot (reason=' + ANCHOR_ATTEST_TTL_CLAMP_REASON + ').');
+ return MAX_ANCHOR_ATTEST_TTL_MS;
+ }
+ return ttlMs;
+}
module.exports = {
@@ -220,7 +241,10 @@ module.exports = {
// announcement queues.
this._deferredRewardAttest = new Map();
this.announceRetryMs = parseInt(hubConfig.ANCHOR_ANNOUNCE_RETRY_MS || cfg.ANCHOR_ANNOUNCE_RETRY_MS || '300000'); // 5 min
- this.announceRetryTtlMs = parseInt(hubConfig.ANCHOR_ANNOUNCE_RETRY_TTL_MS || cfg.ANCHOR_ANNOUNCE_RETRY_TTL_MS || '21600000'); // 6 h, ~6x the 60-conf DOGE window
+ this.announceRetryTtlMs = anchorAttestTtlMs(
+ hubConfig.ANCHOR_ANNOUNCE_RETRY_TTL_MS ||
+ cfg.ANCHOR_ANNOUNCE_RETRY_TTL_MS || String(MAX_ANCHOR_ATTEST_TTL_MS),
+ 'ANCHOR_ANNOUNCE_RETRY_TTL_MS'); // 6 h, ~6x the 60-conf DOGE window
this.announceQueueMax = parseInt(hubConfig.ANCHOR_ANNOUNCE_QUEUE_MAX || cfg.ANCHOR_ANNOUNCE_QUEUE_MAX || '500');
this._deferTimer = null;
this._rankWakeTimer = null; // failover wake, see rankWakeMs
@@ -232,7 +256,10 @@ module.exports = {
// same reasoning as announceRetryTtlMs above: ~6x the 60-conf DOGE window, past
// which a send that never relayed is not coming back and holding the row costs
// more than re-broadcasting it.
- this.anchorIntentTtlMs = parseInt(hubConfig.ANCHOR_INTENT_TTL_MS || cfg.ANCHOR_INTENT_TTL_MS || '21600000'); // 6 h
+ this.anchorIntentTtlMs = anchorAttestTtlMs(
+ hubConfig.ANCHOR_INTENT_TTL_MS ||
+ cfg.ANCHOR_INTENT_TTL_MS || String(MAX_ANCHOR_ATTEST_TTL_MS),
+ 'ANCHOR_INTENT_TTL_MS'); // 6 h
// Retention window for the two durable anchor marker tables. Both appended one
// row per DOGE-spending broadcast and never removed one, so they grew for the
// life of the deployment while their oracle_published_rounds sibling was swept.
diff --git a/src/anchor/reward_tracker.js b/src/anchor/reward_tracker.js
index dea5bcaa7..ffb4ac807 100644
--- a/src/anchor/reward_tracker.js
+++ b/src/anchor/reward_tracker.js
@@ -29,6 +29,12 @@ const nodeUtil = require('node:util');
const { getLogger } = require('../observability');
const logger = getLogger();
+// Render the operator-tunable (below-flag-day) anchor reward on the 8-decimal grid.
+// bcmath, never parseFloat: a prefix-numeric knob ('10abc') renders '0.00000000', not 10.
+function legacyAnchorRewardAmount(knob) {
+ return bcmath.bcformat(knob, 8);
+}
+
class RewardTracker {
constructor(hub) {
@@ -149,9 +155,8 @@ class RewardTracker {
let lcPubkey = pubkey.toLowerCase();
// The frozen consensus amount at/above its flag-day, else the operator-tunable one.
- let amount = this.resolveAnchorRewardAmount(rewardType, blockIndex, rewardNetwork);
- if (!Number.isFinite(amount) || amount <= 0) return;
- let amountStr = amount.toFixed(8);
+ let amountStr = this.resolveAnchorRewardAmount(rewardType, blockIndex, rewardNetwork);
+ if (!bcmath.bcgt(amountStr, '0')) return;
// Qualify the logical anchor by the archive leg's snapshot block. round_number is
// MATCH_BATCH_SEQ for anchor_archive, a dense counter a wipe-and-replay rebase
@@ -193,9 +198,9 @@ class RewardTracker {
// chain already determines.
}
- // The anchor reward amount to record, as a parsed float (NaN when the configured
- // legacy amount does not parse). Pure: no DB access, so the locked body keeps its
- // statement order.
+ // The anchor reward amount to record, as a decimal string ('0.00000000' when the
+ // configured legacy amount is not numeric). Pure: no DB access, so the locked body
+ // keeps its statement order.
resolveAnchorRewardAmount(rewardType, blockIndex, rewardNetwork) {
// At/above the anchor-reward flag-day the per-chain reward is DERIVED on-chain
// from the ANCHOR v4/v5 publisher attestation, and every indexer credits the FROZEN
@@ -229,8 +234,10 @@ class RewardTracker {
ar.isAnchorRewardActive(Number(blockIndex), network);
let isDerivedArchive = String(rewardType) === 'anchor_archive' &&
ar.isArchiveRewardActive(Number(blockIndex), network);
- return parseFloat((isDerivedChain || isDerivedBundle) ? ar.ANCHOR_REWARD_AMOUNT
- : isDerivedArchive ? ar.ARCHIVE_REWARD_AMOUNT : this.anchorReward);
+ // Frozen constants verbatim (byte-identical to what the indexer credits and signs).
+ if (isDerivedChain || isDerivedBundle) return String(ar.ANCHOR_REWARD_AMOUNT);
+ if (isDerivedArchive) return String(ar.ARCHIVE_REWARD_AMOUNT);
+ return legacyAnchorRewardAmount(this.anchorReward);
}
// Resolve the staking source address that owns a signing pubkey at a block,
@@ -294,4 +301,6 @@ class RewardTracker {
}
}
+RewardTracker.legacyAnchorRewardAmount = legacyAnchorRewardAmount;
+
module.exports = RewardTracker;
diff --git a/src/api.js b/src/api.js
index fd3d0a64b..db1f4ecbb 100644
--- a/src/api.js
+++ b/src/api.js
@@ -69,6 +69,7 @@ const geoip = require('geoip-lite'); // self-contained country/region DB;
// identically, or a consumer that switches between the two feeds sees the same value
// change JS type mid-stream. Importing is what makes that identity structural.
const { bigIntReplacer } = require('./lib/bigint_replacer.js');
+const { positiveIntConfig } = require('./lib/config_int.js');
const { parseCorsOrigin } = require('./api/cors_origin.js');
const { parseExemptLocal } = require('./api/rate_limit_policy.js');
const { resolveMaxBatch, makeRpcBatchGuard } = require('./peers/rpc_batch_guard.js'); // JSON-RPC batch cardinality cap
@@ -99,13 +100,12 @@ const HUB_API_KEY = hubConfig.HUB_API_KEY || '';
// ConfigService sets this var for a managed deploy that has no key in its host
// env, so keyless stays possible but is always a stated choice, never a default.
const HUB_ALLOW_UNAUTHENTICATED = (hubConfig.HUB_ALLOW_UNAUTHENTICATED || '').toLowerCase() === 'true';
+// The PUBLIC budget on every role; key-holding callers (HUB_AUTH_RATE_LIMIT_RPM) have their own.
const HUB_RATE_LIMIT_RPM = parseInt(hubConfig.HUB_RATE_LIMIT_RPM) || 100;
-// Loopback and private-range callers skip the per-IP cap by default. The
-// caller this protects is the node's OWN indexer replaying a batch-bearing chain: it
-// pushes one pushpricebatch per batch block as fast as it reads blocks, blows 100/min
-// in seconds, and without this exemption needs HUB_RATE_LIMIT_RPM=60000 set by hand before
-// recovery runs at all. Keyed on req.ip (post-trust-proxy), so a public client arriving through a
-// private-IP reverse proxy is still throttled; see src/api/rate_limit_policy.js.
+// Loopback and private-range callers skip the per-IP cap by default: the node's
+// OWN indexer replaying a batch-bearing chain pushes one pushpricebatch per batch
+// block as fast as it reads them. Keyed on req.ip (post-trust-proxy), so a public
+// client through a private-IP reverse proxy is still throttled; see rate_limit_policy.js.
// Set HUB_RATE_LIMIT_EXEMPT_LOCAL=false to cap every caller including those.
const HUB_RATE_LIMIT_EXEMPT_LOCAL = parseExemptLocal(hubConfig.HUB_RATE_LIMIT_EXEMPT_LOCAL);
// A comma-separated ALLOWLIST, not a single origin: the hub is called
@@ -248,8 +248,11 @@ const p2pConfig = P2P_VALIDATOR_ADDR ? {
XCHAIN_PRICE_MIN_BTC_VOLUME: hubConfig.XCHAIN_PRICE_MIN_BTC_VOLUME || '',
ORACLE_EPOCH_START: parseInt(hubConfig.ORACLE_EPOCH_START),
- ORACLE_ROUND_INTERVAL: parseInt(hubConfig.ORACLE_ROUND_INTERVAL) || DEFAULT_ORACLE_ROUND_INTERVAL_MS,
- ORACLE_SUBMISSION_WINDOW: parseInt(hubConfig.ORACLE_SUBMISSION_WINDOW) || DEFAULT_ORACLE_SUBMISSION_WINDOW_MS,
+ // Positive-only: the interval divides elapsed time into federation round numbers.
+ ORACLE_ROUND_INTERVAL: positiveIntConfig(hubConfig.ORACLE_ROUND_INTERVAL,
+ DEFAULT_ORACLE_ROUND_INTERVAL_MS, 'ORACLE_ROUND_INTERVAL'),
+ ORACLE_SUBMISSION_WINDOW: positiveIntConfig(hubConfig.ORACLE_SUBMISSION_WINDOW,
+ DEFAULT_ORACLE_SUBMISSION_WINDOW_MS, 'ORACLE_SUBMISSION_WINDOW'),
// Per-round cap on collected peer submissions (anti-flood, OracleRound.js).
// Passed through UNPARSED for the same reason as the retention knob below:
// OracleRound.js owns the parse, the range check and the 200 default, so a
@@ -371,12 +374,11 @@ async function startApi(){
mountRegistryRoutes(app, ctx);
// Bound JSON-RPC batch cardinality (src/peers/rpc_batch_guard.js). The router below runs
- // Promise.all over every element of a batch array while the per-IP rate limiter at
- // the top of this stack charges the whole batch ONE token, so a single ~100 KB body
- // fans out into ~1,400 concurrent handlers on the shared DB pool. Mounted here, in
- // front of the router rather than globally, so it governs the dispatcher that
- // amplifies and cannot reject a REST route's array body; the limiter has already
- // charged its token by this point, so an oversize batch is never free.
+ // Promise.all over every element of a batch array, so a single ~100 KB body fans out
+ // into ~1,400 concurrent handlers on the shared DB pool. The limiter at the top of
+ // this stack already charges a batch one token per call; this caps it outright.
+ // Mounted in front of the router rather than globally, so it governs the dispatcher
+ // that amplifies and cannot reject a REST route's array body.
// Default 20, matching encoder/decoder/utxo-tracker. No hub caller batches at all
// (every connector sends one call object), so the cap breaks no existing client.
app.use(makeRpcBatchGuard(resolveMaxBatch(hubConfig.HUB_MAX_RPC_BATCH, 20)));
diff --git a/src/api/middleware.js b/src/api/middleware.js
index d0d34ee56..5c20b39da 100644
--- a/src/api/middleware.js
+++ b/src/api/middleware.js
@@ -15,16 +15,18 @@
* XChain Hub - the express middleware stack ahead of every route.
*
* Order is the auth boundary and is kept exactly: trust proxy, helmet, the JSON
- * body parser, CORS, the two per-IP rate limits, the observability surface and
+ * body parser, CORS, the three per-IP rate limits, the observability surface and
* hub gauges, then the x-api-key tier gate and the public-port allowlist.
*
********************************************************************/
// The per-IP cap answers in JSON-RPC and stands down for the hub's own
// stack, so chain-only price recovery works at shipped defaults. The mirror
-// bootstrap is metered separately; see installRateLimits below.
+// bootstrap and key-holding callers are metered separately; see installRateLimits below.
const { buildRateLimitOptions, isSnapshotRequest, parseSnapshotRpm,
SNAPSHOT_PATH_PREFIX } = require('./rate_limit_policy.js');
+const { authenticatedCaller, batchSurcharge, parseAuthRpm, rateLimitKey } = require('./rate_limit_tiers.js');
+const FixedWindowStore = require('./rate_limit_store.js');
const { installObservability } = require('../observability'); // default-off /metrics + structured log shim
const { installHubOracleMetrics, installHubStakeShareMetrics } = require('./hub_metrics'); // item a98d6746: oracle-round heartbeat gauges; stake-share margin gauges
const { authGate, feedPortAllowlist } = require('./auth_gate');
@@ -83,30 +85,39 @@ function throttleReporter(logger, envName) {
};
}
-// TWO per-IP buckets, because the hub serves two workloads with different shapes and one
-// shared budget let them starve each other. The general bucket meters the JSON-RPC surface
-// and everything else at HUB_RATE_LIMIT_RPM (100). The snapshot bucket meters the mirror
-// bootstrap alone at HUB_SNAPSHOT_RATE_LIMIT_RPM (600), which is sized to the measured
-// drain: on the 2026-09-16 fleet roll three indexers re-draining from id 0 behind one
-// public address spent the shared 100 req/min that fleet polling was already using, 429ed
-// part way, retried every 30s and stayed wedged until the limit was raised by hand on five
-// hubs. Splitting the budgets is what makes that recovery run at shipped defaults WITHOUT
-// taking the throttle off the public reads; src/api/rate_limit_policy.js has the numbers.
+// THREE per-IP buckets, because the hub serves workloads with different shapes and one
+// shared budget let them starve or cover for each other. The public bucket meters the
+// JSON-RPC surface and everything else at HUB_RATE_LIMIT_RPM (100 on every role). The
+// authenticated bucket meters callers presenting a configured hub key at
+// HUB_AUTH_RATE_LIMIT_RPM (60000): the fleet's own indexers and replay traffic, which the
+// 60000 validator default of 08c756e5 was for, without handing that budget to keyless
+// callers; src/api/rate_limit_tiers.js has the history. The snapshot bucket meters the
+// mirror bootstrap alone at HUB_SNAPSHOT_RATE_LIMIT_RPM (600), sized to the drain that
+// wedged the 2026-09-16 fleet roll; src/api/rate_limit_policy.js has the numbers.
//
-// The general limiter is installed FIRST and skips the snapshot family, so a drain charges
-// exactly one bucket rather than both. The options for both (the 429 body, the
+// The two JSON-RPC tiers skip the snapshot family and each other, so a request charges
+// exactly one bucket. Each is followed by its batch surcharge, so a batch costs one token
+// per call rather than one per HTTP request. The options (the 429 body, the
// loopback/private exemption) live in the policy module so they are unit-testable; api.js
// self-starts on require, so nothing declared inline here could ever be asserted against.
function installRateLimits(app, ctx) {
const { hubConfig, logger, rateLimit, HUB_RATE_LIMIT_RPM, HUB_RATE_LIMIT_EXEMPT_LOCAL } = ctx;
const snapshotRpm = parseSnapshotRpm(hubConfig.HUB_SNAPSHOT_RATE_LIMIT_RPM);
- app.use(rateLimit(buildRateLimitOptions({
+ const authRpm = parseAuthRpm(hubConfig.HUB_AUTH_RATE_LIMIT_RPM, HUB_RATE_LIMIT_RPM);
+ const isAuthenticated = authenticatedCaller(ctx);
+ installRpcTier(app, rateLimit, {
rpm: HUB_RATE_LIMIT_RPM,
- windowMs: 60 * 1000,
exemptLocal: HUB_RATE_LIMIT_EXEMPT_LOCAL,
- skipPath: isSnapshotRequest,
+ skipPath: (req) => isSnapshotRequest(req) || isAuthenticated(req),
onLimited: throttleReporter(logger, 'HUB_RATE_LIMIT_RPM')
- })));
+ });
+ installRpcTier(app, rateLimit, {
+ rpm: authRpm,
+ exemptLocal: HUB_RATE_LIMIT_EXEMPT_LOCAL,
+ skipPath: (req) => isSnapshotRequest(req) || !isAuthenticated(req),
+ envName: 'HUB_AUTH_RATE_LIMIT_RPM',
+ onLimited: throttleReporter(logger, 'HUB_AUTH_RATE_LIMIT_RPM')
+ });
// Answers in the REST `{ error }` shape its routes already use for 401 and 500, not in
// a JSON-RPC envelope: nothing on this path speaks JSON-RPC.
app.use(SNAPSHOT_PATH_PREFIX, rateLimit(buildRateLimitOptions({
@@ -118,12 +129,24 @@ function installRateLimits(app, ctx) {
onLimited: throttleReporter(logger, 'HUB_SNAPSHOT_RATE_LIMIT_RPM')
})));
logger.info('Hub API rate limit: ' + HUB_RATE_LIMIT_RPM + ' req/min per IP, ' +
- snapshotRpm + ' req/min per IP on ' + SNAPSHOT_PATH_PREFIX +
+ authRpm + ' req/min per IP with a hub key, ' +
+ snapshotRpm + ' req/min per IP on ' + SNAPSHOT_PATH_PREFIX + ', batches charged per call' +
(HUB_RATE_LIMIT_EXEMPT_LOCAL
? ' (loopback and private-range callers exempt; HUB_RATE_LIMIT_EXEMPT_LOCAL=false to enforce)'
: ' (enforced for every caller, including loopback and private-range)'));
}
+// One JSON-RPC tier: its limiter over a store the surcharge can charge in bulk, then the
+// surcharge built from the very same options.
+function installRpcTier(app, rateLimit, tier) {
+ const opts = Object.assign(buildRateLimitOptions(Object.assign({ windowMs: 60 * 1000 }, tier)), {
+ store: new FixedWindowStore(),
+ keyGenerator: rateLimitKey
+ });
+ app.use(rateLimit(opts));
+ app.use(batchSurcharge(opts));
+}
+
function installMetrics(app, ctx) {
const { hub, hubConfig } = ctx;
// Prometheus /metrics plus a structured log shim, both DEFAULT OFF.
diff --git a/src/api/rate_limit_store.js b/src/api/rate_limit_store.js
new file mode 100644
index 000000000..7a602c83a
--- /dev/null
+++ b/src/api/rate_limit_store.js
@@ -0,0 +1,93 @@
+/*********************************************************************
+ *
+ * Copyright © 2025–2026 Dankest, LLC
+ * Based on XChain Platform by Dankest, LLC – https://dankest.llc
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * This file is part of XChain Platform. Licensed under the GNU Affero
+ * General Public License v3.0 or later; see LICENSE.md. A commercial
+ * license (without AGPL source-disclosure terms) is available -
+ * contact legal@dankest.llc.
+ *
+ **********************************************************************
+ *
+ * XChain Hub - a fixed-window hit store for express-rate-limit that can
+ * charge more than one hit per request.
+ *
+ * express-rate-limit charges one hit per HTTP request, while the JSON-RPC
+ * router dispatches every element of a batch array. The batch surcharge in
+ * src/api/rate_limit_tiers.js charges the remaining elements through
+ * incrementBy, in O(1) whatever the batch length, which the library's own
+ * MemoryStore cannot do without one awaited increment per element.
+ *
+ * No timer: expired windows are swept lazily, at most once per window, on the
+ * next charge, so a hub that stops taking traffic holds no live handle.
+ *
+ ********************************************************************/
+
+'use strict';
+
+class FixedWindowStore {
+ constructor() {
+ this.windowMs = 60 * 1000;
+ this.windows = new Map();
+ this.nextSweepAt = 0;
+ // Hits live in this process only; express-rate-limit reads this flag.
+ this.localKeys = true;
+ }
+
+ // Called by express-rate-limit with the limiter's resolved options.
+ init(options) {
+ if (options && Number.isFinite(options.windowMs) && options.windowMs > 0) this.windowMs = options.windowMs;
+ }
+
+ // The key's current window, or null once it has expired.
+ currentWindow(key, now) {
+ const entry = this.windows.get(key);
+ if (!entry) return null;
+ if (entry.resetAt <= now) { this.windows.delete(key); return null; }
+ return entry;
+ }
+
+ sweep(now) {
+ if (now < this.nextSweepAt) return;
+ for (const [key, entry] of this.windows) if (entry.resetAt <= now) this.windows.delete(key);
+ this.nextSweepAt = now + this.windowMs;
+ }
+
+ // Charge `hits` against the key's window. Synchronous so a surcharge can decide
+ // before any other request interleaves.
+ incrementBy(key, hits) {
+ const now = Date.now();
+ this.sweep(now);
+ let entry = this.currentWindow(key, now);
+ if (!entry) { entry = { hits: 0, resetAt: now + this.windowMs }; this.windows.set(key, entry); }
+ entry.hits += Math.max(0, Math.floor(hits));
+ return { totalHits: entry.hits, resetTime: new Date(entry.resetAt) };
+ }
+
+ async increment(key) {
+ return this.incrementBy(key, 1);
+ }
+
+ async get(key) {
+ const entry = this.currentWindow(key, Date.now());
+ return entry ? { totalHits: entry.hits, resetTime: new Date(entry.resetAt) } : undefined;
+ }
+
+ async decrement(key) {
+ const entry = this.currentWindow(key, Date.now());
+ if (entry && entry.hits > 0) entry.hits -= 1;
+ }
+
+ async resetKey(key) {
+ this.windows.delete(key);
+ }
+
+ async resetAll() {
+ this.windows.clear();
+ }
+}
+
+module.exports = FixedWindowStore;
diff --git a/src/api/rate_limit_tiers.js b/src/api/rate_limit_tiers.js
new file mode 100644
index 000000000..aa7f96a44
--- /dev/null
+++ b/src/api/rate_limit_tiers.js
@@ -0,0 +1,114 @@
+/*********************************************************************
+ *
+ * Copyright © 2025–2026 Dankest, LLC
+ * Based on XChain Platform by Dankest, LLC – https://dankest.llc
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * This file is part of XChain Platform. Licensed under the GNU Affero
+ * General Public License v3.0 or later; see LICENSE.md. A commercial
+ * license (without AGPL source-disclosure terms) is available -
+ * contact legal@dankest.llc.
+ *
+ **********************************************************************
+ *
+ * XChain Hub - the public and authenticated JSON-RPC rate-limit tiers, and
+ * the batch surcharge that charges one token per call.
+ *
+ * Why two tiers. 08c756e5 made a validator hub default HUB_RATE_LIMIT_RPM to
+ * 60000, the value five testnet validator composes had carried by hand since
+ * the 2026-09-16 roll wedged every mirror bootstrap at 100 req/min. That raise
+ * lifted the throttle for EVERY caller, and the public surface includes
+ * `health`, which fans out to three indexer reads per call: one keyless client
+ * could drive about 1.2M health handlers a minute through 20-call batches.
+ * The traffic the raise was for is the fleet's own (indexer push rails and
+ * replay, and the mirror drain, which has had its own bucket since 6b45132b),
+ * and on a keyed hub that traffic carries a hub key. So the key, not the role,
+ * picks the budget: a request presenting a configured key is metered at
+ * HUB_AUTH_RATE_LIMIT_RPM (default 60000), everything else at
+ * HUB_RATE_LIMIT_RPM (default 100) on every role. A wrong key lands in the
+ * public tier, so a guessed header buys nothing.
+ *
+ * Why a surcharge. express-rate-limit charges one hit per HTTP request, and
+ * the router runs every element of a batch array, so a 20-call batch cost one
+ * token. The surcharge runs right after each tier's limiter and charges the
+ * other n - 1 calls to the same key in the same store.
+ *
+ ********************************************************************/
+
+'use strict';
+
+const crypto = require('crypto');
+const { ipKeyGenerator } = require('express-rate-limit');
+
+// The authenticated tier's default: the fleet value 08c756e5 shipped, kept for the
+// key-holding callers it was measured against.
+const DEFAULT_AUTH_RPM = 60000;
+
+// An unset, unparseable or non-positive value keeps the default, and the default is
+// never below the public limit: presenting a key must not cost a caller budget.
+function parseAuthRpm(raw, publicRpm) {
+ const value = parseInt(raw, 10);
+ if (Number.isFinite(value) && value > 0) return value;
+ const floor = Number.isFinite(publicRpm) && publicRpm > 0 ? publicRpm : 0;
+ return Math.max(DEFAULT_AUTH_RPM, floor);
+}
+
+function timingEqual(provided, expected) {
+ const a = Buffer.from(String(provided));
+ const b = Buffer.from(String(expected));
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
+}
+
+// A predicate over requests: true when x-api-key equals any key this hub has
+// configured. A keyless hub has none, so every caller is public there.
+function authenticatedCaller(ctx) {
+ const keys = [ctx.HUB_API_KEY, ctx.HUB_REORG_API_KEY, ctx.HUB_CONFIG_SECRETS_API_KEY]
+ .filter((key) => typeof key === 'string' && key.length > 0);
+ return function isAuthenticated(req) {
+ if (keys.length === 0) return false;
+ const provided = req && req.headers && req.headers['x-api-key'];
+ if (typeof provided !== 'string' || provided.length === 0) return false;
+ return keys.some((key) => timingEqual(provided, key));
+ };
+}
+
+// Tokens a request costs: one per element of a JSON-RPC batch array, one otherwise.
+// An empty array still reaches the router, so it still costs its one token.
+function batchCost(req) {
+ const body = req && req.body;
+ return Array.isArray(body) ? Math.max(1, body.length) : 1;
+}
+
+// The per-IP key, with IPv6 collapsed to its /56 exactly as the library's default does,
+// so one host cannot rotate through its own subnet to reset the count.
+function rateLimitKey(req) {
+ return ipKeyGenerator(String((req && req.ip) || ''));
+}
+
+// Charges the rest of a batch against the tier whose limiter just passed the request.
+// `opts` is that limiter's own options object, so skip, key, store, limit and the 429
+// body can never disagree with the limiter it follows.
+function batchSurcharge(opts) {
+ return function rpcBatchSurcharge(req, res, next) {
+ const extra = batchCost(req) - 1;
+ if (extra <= 0 || opts.skip(req)) return next();
+ let totalHits;
+ try {
+ totalHits = opts.store.incrementBy(opts.keyGenerator(req), extra).totalHits;
+ } catch (err) {
+ return next(err);
+ }
+ if (totalHits > opts.limit) return opts.handler(req, res);
+ return next();
+ };
+}
+
+module.exports = {
+ DEFAULT_AUTH_RPM,
+ authenticatedCaller,
+ batchCost,
+ batchSurcharge,
+ parseAuthRpm,
+ rateLimitKey
+};
diff --git a/src/api/rpc/cross_chain.js b/src/api/rpc/cross_chain.js
index 7d2364fea..948693d4e 100644
--- a/src/api/rpc/cross_chain.js
+++ b/src/api/rpc/cross_chain.js
@@ -45,29 +45,31 @@ function crossChainCallRpc(ctx) {
// {call_id, dispatch, result}; getxcall is a shorter alias (mirrors the
// explorer's getXcall naming). Read-only, public read tier.
async getcrosschaincall({call_id}){
- if(!call_id) return {error: "call_id is required"};
- if(!hub.crossChainCalls) return {error: "cross-chain call engine not active"};
- try {
- let call = await hub.getCrossChainCall(call_id);
- return call || {error: "cross-chain call not found"};
- } catch (err) {
- return {error: "error fetching cross-chain call"};
- }
+ return readCrossChainCall(hub, call_id);
},
async getxcall({call_id}){
- if(!call_id) return {error: "call_id is required"};
- if(!hub.crossChainCalls) return {error: "cross-chain call engine not active"};
- try {
- let call = await hub.getCrossChainCall(call_id);
- return call || {error: "cross-chain call not found"};
- } catch (err) {
- return {error: "error fetching cross-chain call"};
- }
+ return readCrossChainCall(hub, call_id);
},
};
}
+// Resolve one XCALL lifecycle for both read aliases. A call_id is a 64-char hex
+// sha256 (the dispatch path refuses any other shape), so refuse junk before the read.
+async function readCrossChainCall(hub, call_id){
+ if(!call_id) return {error: "call_id is required"};
+ if(typeof call_id !== 'string') return {error: "call_id must be a 64-character hex string"};
+ let callId = call_id.trim().toLowerCase();
+ if(!/^[0-9a-f]{64}$/.test(callId)) return {error: "call_id must be a 64-character hex string"};
+ if(!hub.crossChainCalls) return {error: "cross-chain call engine not active"};
+ try {
+ let call = await hub.getCrossChainCall(callId);
+ return call || {error: "cross-chain call not found"};
+ } catch (err) {
+ return {error: "error fetching cross-chain call"};
+ }
+}
+
function callListSwapReadsRpc(ctx) {
const { hub } = ctx;
return {
diff --git a/src/api/rpc/feed.js b/src/api/rpc/feed.js
index 21672a179..1488cbe47 100644
--- a/src/api/rpc/feed.js
+++ b/src/api/rpc/feed.js
@@ -65,6 +65,17 @@ function chainTipFeedRpc(ctx) {
chainId = chain_id;
}
try {
+ // hub.network is THIS hub's own configured network, never the caller's
+ // param: a testnet/mainnet hub must not purge over a caller claiming
+ // network: 'regtest' twice with different chain_ids.
+ if (coin === 'BTC' && network === 'regtest' && hub.network === 'regtest' && chainId !== undefined) {
+ let storedTip = await hub.db.getChainTip(coin, network);
+ if (storedTip && storedTip.chainId && storedTip.chainId !== chainId) {
+ await hub.db.deleteCrossChainMatchesByNetwork(network);
+ await hub.db.deleteCrossChainCallsByNetwork(network);
+ await hub.db.deleteAllCapabilitySnapshots();
+ }
+ }
await hub.db.setChainTip(coin, network, height, time, chainId);
return {status: "success"};
} catch (err) {
diff --git a/src/api/rpc/health_probes.js b/src/api/rpc/health_probes.js
new file mode 100644
index 000000000..11f98c305
--- /dev/null
+++ b/src/api/rpc/health_probes.js
@@ -0,0 +1,142 @@
+/*********************************************************************
+ *
+ * Copyright © 2025–2026 Dankest, LLC
+ * Based on XChain Platform by Dankest, LLC – https://dankest.llc
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * This file is part of XChain Platform. Licensed under the GNU Affero
+ * General Public License v3.0 or later; see LICENSE.md. A commercial
+ * license (without AGPL source-disclosure terms) is available -
+ * contact legal@dankest.llc.
+ *
+ **********************************************************************
+ *
+ * XChain Hub - bounded probes behind ping and health: a deadline that
+ * cleans up after itself, and the admission-tip probe with single-flight
+ * and a short cache.
+ *
+ * `health` is on the public tier, and every call used to read all three
+ * admission tips from the indexers, so the upstream cost of a health flood
+ * was three indexer calls per call. The probe below shares one upstream read
+ * among every concurrent caller and reuses its result for
+ * ADMISSION_PROBE_TTL_MS, so a flood costs at most three indexer calls per
+ * TTL window whatever its size. The TTL is far inside the six-block stall
+ * window the admission gate dates tips against, so the report never trails
+ * the producer's own view by more than a probe interval.
+ *
+ ********************************************************************/
+
+'use strict';
+
+const {
+ ADMIT_COLUMN_CHAINS,
+ ADMIT_MARGIN_BLOCKS,
+ isMirrorAdmissionProducerActive
+} = require('../../consensus/gates/mirror_admission_gate.js');
+
+const ADMISSION_PROBE_TTL_MS = 1500;
+
+// Race `work` against a deadline and clear the deadline's timer however the race ends,
+// so a fast completion leaves no live handle behind. `work` is a promise, or a function
+// handed an AbortSignal that fires at the deadline, so an HTTP read can be cancelled
+// instead of left running after its caller has given up on it.
+function raceTimeout(work, ms) {
+ const controller = new AbortController();
+ let timer = null;
+ const deadline = new Promise((_, reject) => {
+ timer = setTimeout(() => { controller.abort(); reject(new Error('timeout')); }, ms);
+ });
+ let pending;
+ try { pending = Promise.resolve(typeof work === 'function' ? work(controller.signal) : work); }
+ catch (err) { pending = Promise.reject(err); }
+ return Promise.race([pending, deadline]).finally(() => clearTimeout(timer));
+}
+
+// The admission-tip probe for one hub. Returns an async function answering the health
+// report's `admission_tips` section, or null where the hub runs no producer.
+function makeAdmissionTipProbe(hub, p2pConfig, timeoutMs, opts) {
+ opts = opts || {};
+ const ttlMs = Number.isFinite(opts.ttlMs) && opts.ttlMs >= 0 ? opts.ttlMs : ADMISSION_PROBE_TTL_MS;
+ const clock = typeof opts.now === 'function' ? opts.now : Date.now;
+ let cached = null;
+ let inflight = null;
+
+ // A timed-out or failed read is cached like a good one: the refusal it produces is
+ // the honest report, and re-reading on every call is the amplification this bounds.
+ function readTips() {
+ if (cached && clock() - cached.at < ttlMs) return Promise.resolve(cached.resolved);
+ if (inflight) return inflight;
+ inflight = raceTimeout((signal) => hub.resolveAdmissionTips(ADMIT_COLUMN_CHAINS.slice(), { signal }), timeoutMs)
+ .then((resolved) => resolved || {}, () => ({}))
+ .then((resolved) => { cached = { resolved, at: clock() }; return resolved; })
+ .finally(() => { inflight = null; });
+ return inflight;
+ }
+
+ return async function probeAdmissionTips() {
+ if (!p2pConfig || !hub || typeof hub.resolveAdmissionTips !== 'function') return null;
+ return admissionReport(hub, await readTips());
+ };
+}
+
+// The per-chain report from one resolver answer. A stale resolver result is null; the
+// last observation is retained only as diagnosis and is never promoted back into a
+// usable admission height. Built per call, so ages stay current under the cache.
+function admissionReport(hub, resolved) {
+ let chains = {};
+ let missing = [];
+ for (let chain of ADMIT_COLUMN_CHAINS) {
+ let tip = usableAdmissionHeight(resolved[chain]);
+ let previous = lastAdmissionTip(hub, chain);
+ let stale = false;
+ if (tip === null && previous !== null) {
+ stale = typeof hub.admissionTipFresh === 'function'
+ ? !hub.admissionTipFresh(chain, previous.height)
+ : true;
+ }
+ if (tip === null) missing.push(chain);
+ chains[chain] = {
+ height: tip === null && previous !== null ? previous.height : tip,
+ observed_at_ms: previous === null ? null : previous.at_ms,
+ age_s: previous === null || previous.at_ms === null
+ ? null : Math.max(0, Math.floor((Date.now() - previous.at_ms) / 1000)),
+ fresh: tip !== null,
+ stale,
+ reason: tip !== null ? null : (stale
+ ? 'admission tip for ' + chain + ' is stale'
+ : 'no fresh admission tip for ' + chain)
+ };
+ }
+
+ // Signed admission rows use their own BTC anchor for the producer flag day.
+ // A stale last observation is sufficient to establish that the node has crossed
+ // the activation, but is diagnostic only and remains unusable above.
+ let btcHeight = chains.BTC && chains.BTC.height;
+ let producerActive = usableAdmissionHeight(btcHeight) !== null &&
+ isMirrorAdmissionProducerActive('BTC', hub.network, btcHeight);
+ return {
+ producer_active: producerActive,
+ healthy: !producerActive || missing.length === 0,
+ reason: producerActive && missing.length > 0
+ ? 'no fresh admission tip for ' + missing.join(', ') + '; refusing to finalize admission-era rows'
+ : null,
+ admit_margin_blocks: ADMIT_MARGIN_BLOCKS,
+ chains
+ };
+}
+
+function usableAdmissionHeight(value) {
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
+}
+
+function lastAdmissionTip(hub, chain) {
+ let seen = hub && hub._admissionTipSeen;
+ if (!seen || typeof seen.get !== 'function') return null;
+ let value = seen.get(chain);
+ if (!value) return null;
+ let height = usableAdmissionHeight(value.height);
+ return height === null ? null : { height, at_ms: Number(value.atMs) || null };
+}
+
+module.exports = { ADMISSION_PROBE_TTL_MS, makeAdmissionTipProbe, raceTimeout };
diff --git a/src/api/rpc/system.js b/src/api/rpc/system.js
index 126813b26..a87e3e302 100644
--- a/src/api/rpc/system.js
+++ b/src/api/rpc/system.js
@@ -23,6 +23,7 @@
const configRedaction = require('../config_redaction.js');
const { DEFAULT_ORACLE_ROUND_INTERVAL_MS } = require('../../constants');
+const { makeAdmissionTipProbe, raceTimeout } = require('./health_probes.js');
const { validateChain } = require('../validate');
function buildSystemRpc(ctx) {
@@ -34,10 +35,7 @@ function systemReads(ctx) {
return {
async ping(params, {res}) {
try {
- await Promise.race([
- hub.db.getDatabaseLivenessProbe(),
- new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), DB_PROBE_TIMEOUT_MS))
- ]);
+ await raceTimeout(hub.db.getDatabaseLivenessProbe(), DB_PROBE_TIMEOUT_MS);
return {status: "success", db: true};
} catch (err) {
res.status(503);
@@ -47,6 +45,7 @@ function systemReads(ctx) {
async updateconfig({config}){
try {
+ hub.learnNetworkFromConfig(config);
await hub.addParametersFromJson(config);
return {status: "success"};
} catch (err) {
@@ -151,6 +150,8 @@ function configsRpc(ctx) {
function healthRpc(ctx) {
const { hub, hubConfig, p2pConfig, configFetchCounters, DB_PROBE_TIMEOUT_MS } = ctx;
+ // One probe per controller, so concurrent health calls share its upstream read.
+ const probeAdmissionTips = makeAdmissionTipProbe(hub, p2pConfig, DB_PROBE_TIMEOUT_MS);
return {
// Like ping, but also reports the DB circuit-breaker state. The breaker
// trips open after repeated connection failures and rejects queries during
@@ -184,11 +185,23 @@ function healthRpc(ctx) {
? hub.capabilitySnapshot.monitor.snapshot() : null;
if (consensusInput && consensusInput.alerting) healthy = false;
+ // Admission-height production has a hard per-chain precondition: above
+ // its activation the hub cannot finalize a mirrored row unless every
+ // chain in that row's read set has a fresh decoder tip. Probe the full
+ // federation set here so an operator sees the dependency before a round
+ // pays for it. The resolver applies the same freshness gate as the
+ // producer and returns null rather than guessing a height. Single-flight
+ // and cached briefly (health_probes.js), so a health flood cannot
+ // multiply into indexer reads.
+ let admissionTips = await probeAdmissionTips();
+ if (admissionTips && !admissionTips.healthy) healthy = false;
+
let published = publisherStats(hub);
if(!healthy) res.status(503);
return healthBody(hub, configFetchCounters, published, {
- healthy, dbOk, dbCircuit, oracleAgeS, oracleStale, oracleThresholdS, consensusInput
+ healthy, dbOk, dbCircuit, oracleAgeS, oracleStale, oracleThresholdS,
+ consensusInput, admissionTips
});
},
};
@@ -198,10 +211,7 @@ function healthRpc(ctx) {
async function probeDatabase(hub, DB_PROBE_TIMEOUT_MS) {
let dbOk = false;
try {
- await Promise.race([
- hub.db.getDatabaseLivenessProbe(),
- new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), DB_PROBE_TIMEOUT_MS))
- ]);
+ await raceTimeout(hub.db.getDatabaseLivenessProbe(), DB_PROBE_TIMEOUT_MS);
dbOk = true;
} catch (err) {
dbOk = false;
@@ -222,10 +232,7 @@ async function probeOracleFreshness(hub, hubConfig, dbOk, p2pConfig, DB_PROBE_TI
// slow-start environments via ORACLE_STALENESS_THRESHOLD_S.
oracleThresholdS = parseInt(hubConfig.ORACLE_STALENESS_THRESHOLD_S)
|| Math.round((roundIntervalMs * 2) / 1000);
- let rows = await Promise.race([
- hub.db.getPriceSnapshotsFinalizedAgeSeconds(),
- new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), DB_PROBE_TIMEOUT_MS))
- ]);
+ let rows = await raceTimeout(hub.db.getPriceSnapshotsFinalizedAgeSeconds(), DB_PROBE_TIMEOUT_MS);
// age_s is null when no round has ever finalized (fresh node);
// treat that as not-stale so a slow first round doesn't 503.
if (rows && rows.length && rows[0].age_s != null) {
@@ -255,7 +262,8 @@ function publisherStats(hub) {
// The /health response body; every section past config_fetch is telemetry only.
function healthBody(hub, configFetchCounters, { anchorStats, attestStats, relayStats },
- { healthy, dbOk, dbCircuit, oracleAgeS, oracleStale, oracleThresholdS, consensusInput }) {
+ { healthy, dbOk, dbCircuit, oracleAgeS, oracleStale, oracleThresholdS,
+ consensusInput, admissionTips }) {
let healthResult = {
status: healthy ? "healthy" : "degraded",
db: dbOk,
@@ -269,6 +277,7 @@ function healthBody(hub, configFetchCounters, { anchorStats, attestStats, relayS
}
};
if (consensusInput) healthResult.consensus_input = consensusInput;
+ if (admissionTips) healthResult.admission_tips = admissionTips;
if (anchorStats) healthResult.anchor = anchorStats;
if (attestStats) healthResult.attest = attestStats;
// Telemetry only, never a 503: a relay that is disabled, or holding
diff --git a/src/attestation/batch_publisher.js b/src/attestation/batch_publisher.js
index b556e693f..b6495b7d1 100644
--- a/src/attestation/batch_publisher.js
+++ b/src/attestation/batch_publisher.js
@@ -283,11 +283,22 @@ class AttestationBatchPublisher {
}
getStats(){
+ let currentWindow = this.windowStartFor(this.nowSeconds());
+ let quarantineFloor = this.catchupFloorWindow(currentWindow);
+ let quarantinedWindows = 0;
+ for(let start of this._quarantined){
+ let windowStart = Number(start);
+ if(Number.isFinite(windowStart) && windowStart >= quarantineFloor &&
+ windowStart < currentWindow) quarantinedWindows++;
+ }
return Object.assign({}, this.stats, {
windowSeconds: this.windowS,
enabled: this.enabled,
armed: this.isArmedNetwork(),
- quarantinedWindows: this._quarantined.size,
+ // The number of quarantines a sweep can still reach, not every quarantine
+ // retained by this process. A dashboard that counts whole retained history
+ // will read a lower number here, by design.
+ quarantinedWindows: quarantinedWindows,
// Windows an earlier sweep walked past that are being caught up now. A count
// that keeps rising is a publish path failing, not a busy federation.
coverageGapWindows: this._coverageGaps.size,
diff --git a/src/attestation/batch_publisher/broadcast.js b/src/attestation/batch_publisher/broadcast.js
index 01b8d9fb4..16d1b12be 100644
--- a/src/attestation/batch_publisher/broadcast.js
+++ b/src/attestation/batch_publisher/broadcast.js
@@ -219,6 +219,23 @@ module.exports = {
// The default pipeline: the same encoder, address and wallet hook the PRICE rail
// uses, because there is one operator wallet. Only the payload differs.
async defaultBroadcast(payload){
+ let psbtResult;
+ try {
+ psbtResult = await this.buildBatchPsbt(payload);
+ } catch(e){
+ // Tag a build failure neverSent: only broadcast_tx below puts a transaction on
+ // a wire, and the encoder answers a refused create_tx as an HTTP 200 RPC error.
+ if(e && typeof e === 'object') e.neverSent = true;
+ throw e;
+ }
+
+ let txHex = await this.walletSignFn(psbtResult.psbt);
+ if(!txHex || typeof txHex !== 'string') throw new Error('wallet sign hook returned invalid tx hex');
+ return (await this.encoder.broadcastTx(txHex)) || { txid: null };
+ },
+
+ // Select inputs and build the unsigned single-transaction PSBT; nothing here sends.
+ async buildBatchPsbt(payload){
if(!this.encoder) throw new Error('no encoder configured (set DOGE_ENCODER_URL)');
if(!this.walletSignFn) throw new Error('no wallet sign hook configured (call setWalletSignHook)');
if(!this.dogeAddress) throw new Error('no DOGE_ADDRESS configured');
@@ -242,10 +259,7 @@ module.exports = {
// Refuse phase one of a two-transaction encoding: this pipeline has no reveal,
// so broadcasting it would publish an undecodable batch and strand the value.
assertSingleTxEncoding(psbtResult, 'AttestationBatchPublisher');
-
- let txHex = await this.walletSignFn(psbtResult.psbt);
- if(!txHex || typeof txHex !== 'string') throw new Error('wallet sign hook returned invalid tx hex');
- return (await this.encoder.broadcastTx(txHex)) || { txid: null };
+ return psbtResult;
}
};
diff --git a/src/attestation/batch_publisher/markers.js b/src/attestation/batch_publisher/markers.js
index ffcf32ff0..581a366d1 100644
--- a/src/attestation/batch_publisher/markers.js
+++ b/src/attestation/batch_publisher/markers.js
@@ -48,7 +48,10 @@ module.exports = {
let db = this.hubDb();
if(!db || typeof db.doQuery !== 'function') return null;
let rows = await db.findAttestPublishedBatchesByNetwork(this.network, windowStart);
- return (rows && rows.length) ? rows[0] : null;
+ let marker = (rows && rows.length) ? rows[0] : null;
+ // `tracking` records when this hub began owing coverage; it is not an outcome
+ // for that window. Returning it as absent keeps the ordinary retry path live.
+ return marker && String(marker.status) === 'tracking' ? null : marker;
},
// The oldest window a marker can still matter for: pendingWindows builds candidates
@@ -66,38 +69,56 @@ module.exports = {
async hydrateMarkers(nowSec){
let db = this.hubDb();
if(!db || typeof db.doQuery !== 'function') return;
- let rows = await db.findAttestPublishedBatchesByNetworkAndStatus(this.network, 'intent');
let current = this.windowStartFor(Number.isFinite(nowSec) ? Number(nowSec) : this.nowSeconds());
let floor = this.catchupFloorWindow(current);
+ let rows = await db.findAttestPublishedBatchesByNetworkAndStatusSince(
+ this.network, 'intent', floor);
+ let agedRows = await db.getAttestPublishedBatchesCountByNetworkAndStatusBefore(
+ this.network, 'intent', floor);
+ let agedSummary = (agedRows && agedRows[0]) || {};
+ if(!Number.isFinite(Number(agedSummary.count))){
+ let oldest = Number(agedSummary.oldest);
+ agedSummary.count = agedSummary.oldest !== null &&
+ agedSummary.oldest !== undefined && Number.isFinite(oldest) && oldest < floor ? 1 : 0;
+ }
- let live = [], aged = [];
+ let live = [];
for(let r of (rows || [])){
let start = Number(r.window_start);
// A row whose window_start does not read as a number cannot be matched against
// any window the sweep proposes, so it is neither quarantined nor counted.
if(!Number.isFinite(start)) continue;
- (start >= floor ? live : aged).push(start);
+ if(start >= floor) live.push(start);
}
live.sort((a, b) => a - b);
for(let start of live) this._quarantined.add(start);
- this.reportHydratedMarkers(live, aged, current);
+ this.reportHydratedMarkers(live, agedSummary, current);
+
+ // Absence on its own cannot separate "the publisher has never run" from "the
+ // publisher ran but produced no outcome". Mark the in-progress window as the
+ // durable coverage floor so a later table read can tell them apart. The insert
+ // is deliberately a no-op on an existing terminal row.
+ if(live.length === 0 && (Number(agedSummary.count) || 0) === 0)
+ await db.setAttestPublishedBatchByNetwork(
+ this.network, current, this.windowEndFor(current), null, 0, 'tracking');
},
// Two kinds of marker at two severities. A window inside the horizon is an action item
// and names its age, because a count with no age leaves a four-day-old marker reading
// like a fresh one; a window below it is a record and must not read as an alarm.
- reportHydratedMarkers(live, aged, currentWindow){
+ reportHydratedMarkers(live, agedSummary, currentWindow){
if(live.length > 0)
logger.error('AttestationBatchPublisher: ' + live.length + ' window(s) carry a ' +
'publish-intent marker with no outcome; they are NOT re-published automatically. ' +
'Operator: verify each on chain and replay by hand if absent. Windows: ' +
live.map(s => s + ' (' + this.markerAge(s, currentWindow) + ')').join(', ') + '.');
- if(aged.length > 0)
- logger.info('AttestationBatchPublisher: ' + aged.length + ' publish-intent marker(s) lie below ' +
+ let agedCount = Number(agedSummary.count) || 0;
+ if(agedCount > 0)
+ logger.info('AttestationBatchPublisher: ' + agedCount + ' publish-intent marker(s) lie below ' +
'the ' + MAX_CATCHUP_WINDOWS + '-window catch-up horizon and are NOT an action item: no ' +
'sweep can propose those windows again. Oldest ' +
- this.markerAge(Math.min.apply(null, aged), currentWindow) + ', newest ' +
- this.markerAge(Math.max.apply(null, aged), currentWindow) + '.');
+ this.markerAge(agedSummary.oldest, currentWindow) + ', newest ' +
+ this.markerAge(agedSummary.newest, currentWindow) + '.');
},
// A marker's age in the two units that decide what to do with it: windows closed
@@ -109,11 +130,15 @@ module.exports = {
return windows + ' window(s) / ' + formatAge(secs) + ' old';
},
- // Idempotent: an existing row for the window is left exactly as it is, so a replay
- // can never downgrade a `sent` or `landed` marker back to an intent.
+ // A tracking row becomes the pre-send intent; an existing outcome row is left
+ // exactly as it is, so a replay cannot downgrade `sent` or `landed` to intent.
async recordIntent(window, batchKey){
let db = this.hubDb();
if(!db || typeof db.doQuery !== 'function') return;
+ // A tracking row licenses no spend and must become the real pre-send marker.
+ // Delete it under a status guard before the insert. A concurrent landed row is
+ // untouched, and the intent insert's duplicate-key no-op preserves it.
+ await db.deleteAttestPublishedBatch(this.network, window.window_start, 'tracking');
await db.setAttestPublishedBatchByNetwork(this.network, window.window_start, window.window_end, batchKey, window.row_count, 'intent');
},
diff --git a/src/attestation/batch_publisher/signing.js b/src/attestation/batch_publisher/signing.js
index 004bb8027..04136e8be 100644
--- a/src/attestation/batch_publisher/signing.js
+++ b/src/attestation/batch_publisher/signing.js
@@ -24,6 +24,10 @@
const swq = require('../../consensus/stake_weighted_quorum.js');
const abw = require('../../lib/attest_batch_wire.js');
+// The era predicate the wire takes to pick a batch's row field set from its own signed
+// anchor: admit_block_btc is signed only at or above the BTC mirror-admission producer
+// activation, so a batch below it carries the bytes every indexer already verifies.
+const { isAdmissionEra } = require('../../consensus/gates/mirror_admission_gate.js');
const ValidatorIdentity = require('../../validators/identity.js');
const { bftQuorumOrSingle } = require('../../lib/bft_quorum.js');
const { XATTESTB_SIGN_REQ, XATTESTB_SIGN, ANCHOR_MAX_LAG_BLOCKS } = require('./constants.js');
@@ -48,7 +52,7 @@ module.exports = {
row_count: window.row_count,
btc_block_height: window.btc_block_height,
rows: this.wireRows(window.rows)
- });
+ }, isAdmissionEra);
let set = await this.resolveAttestationSet(window.btc_block_height);
if(!set) return empty;
@@ -234,7 +238,7 @@ module.exports = {
row_count: d.rows.length,
btc_block_height: anchor,
rows: d.rows
- });
+ }, isAdmissionEra);
pm.broadcast(XATTESTB_SIGN, {
network: this.network,
window_start: windowStart,
diff --git a/src/attestation/batch_publisher/window.js b/src/attestation/batch_publisher/window.js
index 2108c8605..872eb73c7 100644
--- a/src/attestation/batch_publisher/window.js
+++ b/src/attestation/batch_publisher/window.js
@@ -24,6 +24,8 @@
'use strict';
const abw = require('../../lib/attest_batch_wire.js');
+// Picks the row field set the encoded body carries from the batch's own anchor (signing.js).
+const { isAdmissionEra } = require('../../consensus/gates/mirror_admission_gate.js');
const { MAX_CATCHUP_WINDOWS } = require('./constants.js');
const { getLogger } = require('../../observability');
const logger = getLogger();
@@ -199,7 +201,7 @@ module.exports = {
}
window.sigs = signed.sigs;
- let encoded = abw.encodeAttestBatch(window);
+ let encoded = abw.encodeAttestBatch(window, isAdmissionEra);
if(!encoded.ok){
this.deadLetter({ window_start: windowStart, window_end: windowEnd,
row_count: rows.length, reason: encoded.reason },
@@ -211,7 +213,42 @@ module.exports = {
return false;
}
- return await this.broadcastWindow(window, batchKey, encoded);
+ // Claim at broadcastWindow's existing pre-send intent point. The unique
+ // (network, window_start) marker is this hub's own exclusivity guard: only the
+ // INSERT that creates it may send. Each validator runs its own hub against its
+ // own DB, so this never arbitrates between validators; it is what keeps two
+ // overlapping sweep attempts on the SAME hub (a retry racing the original, or a
+ // restart racing an in-flight run) from both broadcasting the same window. A
+ // marker read followed by a send is not sufficient, because both attempts can
+ // complete that read before either one sends.
+ let claimed = Object.create(this);
+ claimed.recordIntent = async (candidate, key) => {
+ if(!(await this.claimWindowForBroadcast(candidate, key))){
+ let error = new Error('window already has a durable publication claim');
+ error.code = 'ATTEST_BATCH_WINDOW_CLAIMED';
+ throw error;
+ }
+ };
+ return await this.broadcastWindow.call(claimed, window, batchKey, encoded);
+ },
+
+ // Atomically acquire the durable pre-send marker. setAttestPublishedBatchByNetwork
+ // uses INSERT IGNORE, so affectedRows separates the sole creator of this hub's
+ // marker row from every other attempt on it. This runs inside broadcastWindow after
+ // its pipeline, balance and spend reservations have passed, preserving the rule
+ // that a window unable to attempt a send leaves no intent marker.
+ async claimWindowForBroadcast(window, batchKey){
+ let db = this.hubDb();
+ if(!db || typeof db.doQuery !== 'function')
+ throw new Error('no hub DB for durable batch-window claim');
+
+ // A tracking row is a coverage floor, not a publication claim. The guarded
+ // delete cannot remove an intent or outcome installed by a competing path.
+ await db.deleteAttestPublishedBatch(this.network, window.window_start, 'tracking');
+ let result = await db.setAttestPublishedBatchByNetwork(
+ this.network, window.window_start, window.window_end,
+ batchKey, window.row_count, 'intent');
+ return !!(result && Number(result.affectedRows) === 1);
},
// ------------------------------------------------------------ the mirror read
@@ -254,6 +291,10 @@ module.exports = {
response_hash: String(r.response_hash).toLowerCase(),
meta: r.meta == null ? '' : String(r.meta),
effective_time: intOrNull(r.effective_time),
+ // Carried so an admission-era batch signs the row's real admission height; a
+ // row read without it would sign null and rebuild on chain as a legacy row.
+ // Below the activation the wire drops the field, so this changes no bytes there.
+ admit_block_btc: intOrNull(r.admit_block_btc),
signer_pubkeys: String(r.signer_pubkeys == null ? '[]' : r.signer_pubkeys),
signatures: String(r.signatures == null ? '[]' : r.signatures),
widen: intOrNull(r.widen) || 0
diff --git a/src/attestation/consensus/propose.js b/src/attestation/consensus/propose.js
index 41efec303..f327fbc9f 100644
--- a/src/attestation/consensus/propose.js
+++ b/src/attestation/consensus/propose.js
@@ -299,6 +299,17 @@ module.exports = {
let d = envelope.data;
if(!d || !d.requestId) return null;
let rid = String(d.requestId).toLowerCase();
+ // This is also the site a federation-wide re-serve trigger would have to
+ // change: every responsible hub would need to un-finalize `rid` from
+ // purely local state first, or a re-serving hub's own PROPOSE is dropped
+ // right here by its peers. D2 ruled (2026-09-22) that trigger stays
+ // deferred, the case being rare and bounded by deadline expiry plus
+ // refund. It does not need to cover ring eviction: the tombstone
+ // re-propose path already covers ring eviction on its own - once `rid`
+ // falls off `finalized`'s ring in round_records.js, this check reads
+ // false and lets the round reopen, with notePrematureEviction above
+ // logging the premature-eviction proof. Do not add an un-finalize path
+ // here without a new ruling.
if(this.finalized.has(rid)) return null;
let pending = this.pending.get(rid);
if(!pending){
diff --git a/src/attestation/consensus/round_records.js b/src/attestation/consensus/round_records.js
index bf9ff7964..0a1c24d7d 100644
--- a/src/attestation/consensus/round_records.js
+++ b/src/attestation/consensus/round_records.js
@@ -95,6 +95,15 @@ module.exports = {
return this.finalized.has(String(rid).toLowerCase());
},
+ // `finalized` has no un-finalize path, by design: D2 ruled (2026-09-22) that
+ // a federation-wide re-serve trigger stays deferred, the case being rare and
+ // bounded by deadline expiry plus refund. Ring eviction is not part of that
+ // deferred case - the tombstone re-propose path already covers ring eviction:
+ // once `rid` falls off this ring, isFinalized(rid) reads false on its own and
+ // propose.js's inbound gate lets the round reopen, with notePrematureEviction
+ // there logging the premature-eviction proof from `_finalizedEvicted` below.
+ // Do not build a federation-wide un-finalize path here without a new ruling.
+ //
// Record a finalized request ID, evicting the oldest once the ring-buffer
// cap (`finalizedMax`) is reached. Keeps `finalized` bounded while
// preserving Set semantics for the duplicate-finalization guards.
diff --git a/src/attestation/response_mirror/batch.js b/src/attestation/response_mirror/batch.js
index 2cbab0a57..7e2763ff0 100644
--- a/src/attestation/response_mirror/batch.js
+++ b/src/attestation/response_mirror/batch.js
@@ -24,6 +24,8 @@
'use strict';
const abw = require('../../lib/attest_batch_wire.js');
+// Picks the row field set the batch was signed over from its own anchor (batch_publisher/signing.js).
+const { isAdmissionEra } = require('../../consensus/gates/mirror_admission_gate.js');
const swq = require('../../consensus/stake_weighted_quorum.js');
const ValidatorIdentity = require('../../validators/identity.js');
const { bftQuorumOrSingle } = require('../../lib/bft_quorum.js');
@@ -249,7 +251,7 @@ module.exports = {
row_count: Number(batchData.row_count),
btc_block_height: anchor,
rows: batchData.rows
- });
+ }, isAdmissionEra);
let qualified = new Set(snapshot.validators.map(v => String(v.pubkey).toLowerCase()));
let seen = new Set(), verified = [];
diff --git a/src/config.js b/src/config.js
index 62b55015e..21a501e00 100644
--- a/src/config.js
+++ b/src/config.js
@@ -136,6 +136,7 @@ const config = {
get HUB_ADMISSION_RELAY() { return process.env.HUB_ADMISSION_RELAY; },
get HUB_ALLOW_UNAUTHENTICATED() { return process.env.HUB_ALLOW_UNAUTHENTICATED; },
get HUB_API_KEY() { return process.env.HUB_API_KEY; },
+ get HUB_AUTH_RATE_LIMIT_RPM() { return process.env.HUB_AUTH_RATE_LIMIT_RPM; },
get HUB_CAPABILITY_CONFIG() { return process.env.HUB_CAPABILITY_CONFIG; },
get HUB_CONFIG_SECRETS_API_KEY() { return process.env.HUB_CONFIG_SECRETS_API_KEY; },
get HUB_CONSENSUS_INPUT_ALERT_AFTER() { return process.env.HUB_CONSENSUS_INPUT_ALERT_AFTER; },
@@ -252,6 +253,7 @@ const config = {
get XCHAIN_ATTEST_FINALIZED_MAX() { return process.env.XCHAIN_ATTEST_FINALIZED_MAX; },
get XCHAIN_ATTEST_STORE_RETRIES() { return process.env.XCHAIN_ATTEST_STORE_RETRIES; },
get XCHAIN_ATTEST_STORE_RETRY_MS() { return process.env.XCHAIN_ATTEST_STORE_RETRY_MS; },
+ get XCHAIN_HUB_ORACLE_TICK_MIRRORS_WIDENED() { return process.env.XCHAIN_HUB_ORACLE_TICK_MIRRORS_WIDENED; },
get XCHAIN_HUB_SKIP_FULLNODE_ASSERT() { return process.env.XCHAIN_HUB_SKIP_FULLNODE_ASSERT; },
get XCHAIN_HUB_SKIP_MIN_STAKE_ASSERT() { return process.env.XCHAIN_HUB_SKIP_MIN_STAKE_ASSERT; },
get XCHAIN_HUB_SKIP_REORG_BUFFER_ASSERT() { return process.env.XCHAIN_HUB_SKIP_REORG_BUFFER_ASSERT; },
diff --git a/src/consensus/equivocation_header.js b/src/consensus/equivocation_header.js
index 48e11991e..c043bdec8 100644
--- a/src/consensus/equivocation_header.js
+++ b/src/consensus/equivocation_header.js
@@ -18,11 +18,15 @@
* EQUIV|||||
*
* The canonical source of record is
- * xchain-documentation/protocol/reference-impl/equivocation_header.js; it is
- * vendored BYTE-IDENTICALLY into xchain-hub, xchain-indexer, xchain-explorer,
- * xchain-sdk and xchain-sync. Every PBFT/consensus engine prefixes its canonical
- * through here, the settlement gates (cross_settle, xexec, xcall, anchor, price,
- * attest) and the recovery verifier re-derive it to re-verify quorum signatures,
+ * xchain-indexer/src/consensus/equivocation_header.js; it is vendored
+ * BYTE-IDENTICALLY into xchain-hub, xchain-explorer, xchain-sdk, xchain-sync and
+ * xchain-documentation/protocol/reference-impl/consensus/equivocation_header.js.
+ * Edit the indexer copy only and re-run reconcile-twins.sh to re-vendor every
+ * other copy; never edit a vendored copy.
+ *
+ * Every PBFT/consensus engine prefixes its canonical through here, the settlement
+ * gates (cross_settle, xexec, xcall, anchor, price, attest) and the recovery
+ * verifier re-derive it to re-verify quorum signatures,
* and the SLASH v0 action verifies equivocation proofs against it. Adding ``
* makes equivocation provable WITHOUT false-positiving honest view changes (which
* re-sign different content for the same round under a different view). Equivocation
@@ -38,8 +42,9 @@
* the same anchor. The cross-service conformance suite
* (ConsensusPrimitiveConformance.test.js, driven by
* xchain-documentation/protocol/test-vectors) runs in every repo and asserts BOTH the
- * behavior (canonical vectors) AND byte-identity of the local copy to this canonical
- * source, so any unmirrored edit fails CI everywhere (a divergence forks the chain).
+ * behavior (canonical vectors) AND byte-identity of the local copy to the
+ * xchain-documentation copy, so any unmirrored edit fails CI everywhere (a
+ * divergence forks the chain).
*
********************************************************************/
diff --git a/src/consensus/gate_registry/shared_rows_1.js b/src/consensus/gate_registry/shared_rows_1.js
index bf311be06..568d8ff57 100644
--- a/src/consensus/gate_registry/shared_rows_1.js
+++ b/src/consensus/gate_registry/shared_rows_1.js
@@ -295,11 +295,11 @@ addGate('anchor_reward_activation.ANCHOR_ATTEST_ARRIVAL_MARGIN_S', 'constant', 6
// that window.
addGate('anchor_reward_activation.ANCHOR_ATTEST_BARRIER_ACTIVATION', 'height', {
mainnet: null, // INERT under the 2026-08-29 mainnet write hold
- // SIZED 2026-09-16 20:41Z, on the BTC clock because this member is BTC-only: the same
- // instant as the family's BTC CONSUMER height, so the one member that keeps BOTH
- // certificates gains them together rather than carrying a lone extra rule for 6 h.
- // Above the same roll and the same epoch close; the canon carries the measurement.
- testnet: 153266,
+ // SIZED 2026-09-16 20:41Z, RE-SLID 2026-09-19 and 2026-09-23, on the BTC clock because this
+ // member is BTC-only: the same instant as the family's BTC CONSUMER height, so the one
+ // member that keeps BOTH certificates gains them together rather than carrying a lone extra
+ // rule for 6 h. The canon carries the measurement.
+ testnet: 154291,
regtest: UNPINNED, // shares the family's arming seam so one venue lever arms both
});
diff --git a/src/consensus/gate_registry/shared_rows_2.js b/src/consensus/gate_registry/shared_rows_2.js
index c7fd765da..0063a1656 100644
--- a/src/consensus/gate_registry/shared_rows_2.js
+++ b/src/consensus/gate_registry/shared_rows_2.js
@@ -216,9 +216,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', {
'BTC:mainnet': null,
'LTC:mainnet': null,
'DOGE:mainnet': null,
- 'BTC:testnet': 153222, // SIZED 2026-09-16 20:41Z: epoch close 153,216 + 6 buried; tip 152,756 + 466 at 498.7 s/blk, about 64.5 h
- 'LTC:testnet': 4891504, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 4,889,190 + 2314 at 82.5 s/blk
- 'DOGE:testnet': 67911796, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 67,904,912 + 6884 at 27.7 s/blk
+ 'BTC:testnet': 154234, // RE-SLID 2026-09-23: train 154,074 + 160 blocks (17 h at the 383.04 s/blk bound, 25.6 h at the 575.89 s/blk 84 h trailing mean), the v0.20.1 patch reslide
+ 'LTC:testnet': null, // disabled for v0.20.1, 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train
+ 'DOGE:testnet': 67936053, // RE-SLID 2026-09-23: tip 67,924,397 at 17:48Z + 11656 blocks (83.8 h at 25.89 s/blk, the 84 h trailing mean, the same instant as the BTC producer), the v0.20.1 patch reslide
'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration
'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration
'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration
@@ -228,9 +228,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei
'BTC:mainnet': null,
'LTC:mainnet': null,
'DOGE:mainnet': null,
- 'BTC:testnet': 153266, // its producer + 44 blocks, about 6 h: strictly above, never equal
- 'LTC:testnet': 4891766, // its producer + 262 blocks, about 6 h at 82.5 s/blk
- 'DOGE:testnet': 67912575, // its producer + 779 blocks, about 6 h at 27.7 s/blk
+ 'BTC:testnet': 154291, // RE-SLID 2026-09-23: its producer + 57 blocks (6 h at the 383.04 s/blk bound, 9.1 h at the 575.89 s/blk 84 h trailing mean), strictly above, never equal
+ 'LTC:testnet': null, // disabled for v0.20.1, 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train
+ 'DOGE:testnet': 67936888, // RE-SLID 2026-09-23: its producer + 835 blocks (6 h at 25.89 s/blk, the 84 h trailing mean), strictly above, never equal
'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration
'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration
'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration
diff --git a/src/consensus/gate_registry/shared_rows_5.js b/src/consensus/gate_registry/shared_rows_5.js
index cbf289761..fde6a96c7 100644
--- a/src/consensus/gate_registry/shared_rows_5.js
+++ b/src/consensus/gate_registry/shared_rows_5.js
@@ -108,9 +108,16 @@ addGate('train_activation.TRAIN_ACTIVATION', 'ruleset', {
// the LTC leg of this family two days off its BTC counterpart a day after the first cut.
// That lead is the rolling-upgrade window the fleet roll must finish inside (24x the 90
// minute roll budget), and every testnet mirror-admission height sits above it on the same
- // BTC clock (the BTC producer at 153,222 is 106 blocks and about 17.0 h further up), so a
- // node lacking this rule set halts before it can grade an admission-stamped row.
- '0.20.0': { mainnet: 9999999999, testnet: 153116, regtest: 0 },
+ // BTC clock, so a node lacking this rule set halts before it can grade an admission-stamped
+ // row.
+ // RE-SLID 2026-09-23 for the v0.20.1 patch train, after the live tips overran the
+ // 2026-09-19 slide before the freeze: margin is 40 h to the nearest armed height, converted
+ // at each coin's fastest defensible cadence. Chain_tip TBTC 153,698 at 2026-09-23T15:55Z
+ // + 376 blocks, ceil(40 h / 383.04 s per block, the least-squares bound). The
+ // mirror-admission family below re-slides onto the same instant plus its own 17 h and 6 h
+ // offsets. LTC:testnet mirror admission ships disabled on this train and is
+ // untouched by this reslide; it arms on a later train.
+ '0.20.0': { mainnet: 9999999999, testnet: 154074, regtest: 0 },
});
// xchain_bridge_activation
diff --git a/src/consensus/gates/mirror_admission_gate.js b/src/consensus/gates/mirror_admission_gate.js
index 179bfb19c..74029a896 100644
--- a/src/consensus/gates/mirror_admission_gate.js
+++ b/src/consensus/gates/mirror_admission_gate.js
@@ -3,17 +3,21 @@
const { get, copy, activeAt } = require('../gate_registry');
/*
- * mirror_admission_activation.js - admission by height for the mirror barrier family.
+ * mirror_admission_gate.js - admission by height for the mirror barrier family.
*
- * BYTE-IDENTICAL TWIN. This file exists at xchain-indexer/src/, xchain-hub/src/ and, once
- * vendored, xchain-explorer/src/. The three copies are held identical by
- * bin/sync-hub-mirror-client.sh --check (a cmp -s byte compare, not a digest) and the
- * exported constants are held value-identical to xchain-documentation/protocol/constants.js
- * by the activation-constants parity suite. A one-sided edit forks consensus at the boundary.
+ * BYTE-IDENTICAL TWIN. The canonical copy is xchain-indexer/src/; xchain-hub/src/ and
+ * xchain-explorer/src/ carry it byte for byte, so edit the indexer copy and copy it outward.
+ * reconcile-twins.sh --check grades both pairs. The explorer pair is also held by
+ * xchain-indexer/bin/sync-hub-mirror-client.sh --check (a cmp -s byte compare, not a digest),
+ * which never reads the hub copy; the hub pair is also held by the byte compare in the
+ * activation-constants parity suite, which skips without a sibling checkout unless
+ * XCHAIN_REQUIRE_SIBLINGS=1. That suite also holds the exported constants value-identical to
+ * xchain-documentation/protocol/constants.js. A one-sided edit forks consensus at the boundary.
*
- * ZERO REQUIRES, DELIBERATELY. This module is a CLIENT_FILES entry: it is vendored into the
- * explorer beside hub_db_sync.js, which can carry no dependency the explorer does not have.
- * price_batching_floor_activation.js is the precedent. The alternative considered and
+ * ONE REQUIRE, DELIBERATELY. This module is a DEP_FILES entry of sync-hub-mirror-client.sh: it
+ * is vendored into the explorer beside hub_db_sync.js, which can carry no dependency the
+ * explorer does not have, so it requires only ../gate_registry, which every consumer carries
+ * as its own. price_batching_floor_gate.js is the precedent. The alternative considered and
* rejected was threading an admission bound through eleven predicate signatures, their
* waiters and every call site.
*
@@ -364,20 +368,19 @@ function isAdmissionEra(network, eraBlock){
}
/**
- * The canonical tail for a row's admission map: '' below the activation, and '|' plus the
- * encoded map at or above it.
+ * The canonical tail for a row's admission map: '' unless both the row is at or above the
+ * activation and the map is present, and '|' plus the encoded map when both are true.
*
- * REFUSES IN BOTH DIRECTIONS, exactly as AttestationConsensus._buildCanonical does for
- * the mirror era. Building a legacy canonical for a modern row strands the row (its
- * signatures reproduce over bytes no verifier rebuilds); building a modern canonical for
- * a legacy row forks a from-genesis replay. Neither can be recovered from downstream, so
- * both throw where the caller that got it wrong is still on the stack.
+ * A version seam can expose either an admission map to an inert node or a legacy row to an
+ * armed node. Both cases take the legacy byte path so mixed versions can bind the row and
+ * let quorum verification decide whether its signatures match. Only an armed node with a
+ * present map emits admission bytes.
*
* No per-rail canonical VERSION field is minted for this, and none exists anywhere in the
* tree: this height-gated era check IS the versioning, and a version integer would
* duplicate the gate while giving a Byzantine leader a second field to disagree about.
*
- * @param {string} label the builder's canonical tag, for the refusal message
+ * @param {string} label the builder's canonical tag, retained for the stable caller API
* @param {string} network the row's network, half the activation key
* @param {number} eraBlock the ROW's own BTC block
* @param {object|null} map the row's admission map, or null for a legacy row
@@ -390,22 +393,16 @@ function admissionCanonicalField(label, network, eraBlock, map){
/**
* The admission map as a canonical VALUE rather than a pipe-appended tail: null below the
- * activation, the encoded map at or above it, with the same two refusals as the field
- * form. This is the spelling a JSON-shaped canonical (the PRICE batch) carries under its
- * own key, where a '|' tail would be a byte inside a string rather than a delimiter. One
- * era gate for both spellings, so the two carriers can never disagree about which era a
- * row is in.
+ * activation or when the map is absent, and the encoded map when the era is armed and the
+ * map is present. This is the spelling a JSON-shaped canonical (the PRICE batch) carries
+ * under its own key, where a '|' tail would be a byte inside a string rather than a
+ * delimiter. One era gate for both spellings keeps their boundary semantics identical.
*/
function admissionCanonicalValue(label, network, eraBlock, map){
let era = isAdmissionEra(network, eraBlock);
let has = (map !== null && map !== undefined);
- if(era && !has)
- throw new Error(label + ': admission-era row at block ' + String(eraBlock) + ' on ' + String(network) +
- ' has no admit_blocks; refusing to build a legacy canonical');
- if(!era && has)
- throw new Error(label + ': legacy-era row at block ' + String(eraBlock) + ' on ' + String(network) +
- ' was handed admit_blocks ' + JSON.stringify(map) + '; refusing to build an admission-era canonical');
if(!era) return null;
+ if(!has) return null;
return encodeAdmitBlocks(map);
}
diff --git a/src/consensus/gates/rollcall_gates_gate.js b/src/consensus/gates/rollcall_gates_gate.js
index cc2d272e5..ad55613a9 100644
--- a/src/consensus/gates/rollcall_gates_gate.js
+++ b/src/consensus/gates/rollcall_gates_gate.js
@@ -42,10 +42,10 @@
* arms the ROLLCALL rail opts in here separately, so a rail-armed venue can
* still drive the below-height (v0) behaviour as its control.
*
- * LOCAL COPY of the canonical map in xchain-documentation/protocol/constants.js
- * and the value twin of xchain-hub/src/rollcall_gates_activation.js. The
- * mainnet and testnet heights are parity-tested; regtest is env-derived and is
- * not.
+ * LOCAL COPY of the canonical map in xchain-documentation/protocol/constants.js;
+ * the twin lives at the same path (src/consensus/gates/rollcall_gates_gate.js)
+ * in the hub and the indexer and is kept BYTE-IDENTICAL. The mainnet and
+ * testnet heights are parity-tested; regtest is env-derived and is not.
*
********************************************************************/
diff --git a/src/consensus/snapshot_reorg_buffer.js b/src/consensus/snapshot_reorg_buffer.js
index 28ea9dad3..0f0003870 100644
--- a/src/consensus/snapshot_reorg_buffer.js
+++ b/src/consensus/snapshot_reorg_buffer.js
@@ -62,11 +62,14 @@
* difference must not be "corrected" without its own flag-day.
*
* The canonical source of record is
- * xchain-documentation/protocol/reference-impl/snapshot_reorg_buffer.js; it is
- * vendored BYTE-IDENTICALLY into xchain-hub, xchain-indexer and xchain-sdk. The
- * cross-service conformance suite (ConsensusPrimitiveConformance.test.js) runs in
- * every one of those repos and asserts byte-identity of the local copy to this
- * source, so an unmirrored edit fails CI everywhere.
+ * xchain-indexer/src/consensus/snapshot_reorg_buffer.js; it is vendored
+ * BYTE-IDENTICALLY into xchain-hub, xchain-sdk and
+ * xchain-documentation/protocol/reference-impl/consensus/snapshot_reorg_buffer.js.
+ * Edit the indexer copy only and re-run reconcile-twins.sh to re-vendor every
+ * other copy; never edit a vendored copy. The cross-service conformance suite
+ * (ConsensusPrimitiveConformance.test.js) runs in every one of those repos and
+ * asserts byte-identity of the local copy to the xchain-documentation copy, so
+ * an unmirrored edit fails CI everywhere.
*
********************************************************************/
@@ -74,8 +77,23 @@
const { get, copy, activeAt } = require('./gate_registry');
+// The reorg-depth buffer every party in a federation must resolve capability
+// snapshots at. 6 = the BTC confirmation depth the platform already treats as
+// buried (XCHAIN_CONFIRMATIONS_BTC). CONSENSUS-CRITICAL: the hub subtracts this
+// before every snapshot lookup and refuses to boot on mainnet/testnet when a local
+// override diverges (CapabilitySnapshot._resolveReorgBuffer), so a verifier that
+// buries by a different depth resolves a different set than the signer.
const CANONICAL_REORG_BUFFER = copy('snapshot_reorg_buffer.CANONICAL_REORG_BUFFER');
+// Per-network activation height (LOCAL COPY of the canonical map maintained
+// upstream, kept equal by the cross-service regression suite). Keyed on the
+// BTC-anchored declared snapshot_block.
+//
+// ARMED at genesis on mainnet, testnet and regtest per the gate table: every
+// network resolves through the buried height from block 0. Arming changes
+// acceptance itself, so a one-sided or partially-rolled-out arm would fork the
+// fleet rather than fix it; the gate table records the per-network ruling and
+// the evidence for arming each network at genesis instead of a later height.
const SNAPSHOT_BURIAL_ACTIVATION = copy('snapshot_reorg_buffer.SNAPSHOT_BURIAL_ACTIVATION');
// Whether a verifier must bury the declared snapshot_block before re-deriving the
diff --git a/src/consensus/stake_weighted_quorum.js b/src/consensus/stake_weighted_quorum.js
index 76fdd4ed2..116655343 100644
--- a/src/consensus/stake_weighted_quorum.js
+++ b/src/consensus/stake_weighted_quorum.js
@@ -14,17 +14,20 @@
*
* THE single, CONSENSUS-CRITICAL implementation of the stake-weighted quorum
* predicate. The canonical source of record is
- * xchain-documentation/protocol/reference-impl/stake_weighted_quorum.js; it is
- * vendored BYTE-IDENTICALLY into xchain-hub, xchain-indexer, xchain-explorer,
- * xchain-sdk and xchain-sync. Every PBFT tally engine, every settlement gate
- * (cross_settle, xexec, xcall, anchor, price, attest), the recovery verifier and
- * the client/explorer checkpoint verifiers resolve through this predicate so they
- * can never drift; a divergence forks the chain.
+ * xchain-indexer/src/consensus/stake_weighted_quorum.js; it is vendored
+ * BYTE-IDENTICALLY into xchain-hub, xchain-explorer, xchain-sdk, xchain-sync and
+ * xchain-documentation/protocol/reference-impl/consensus/stake_weighted_quorum.js.
+ * Edit the indexer copy only and re-run reconcile-twins.sh to re-vendor every
+ * other copy; never edit a vendored copy. Every PBFT tally engine, every
+ * settlement gate (cross_settle, xexec, xcall, anchor, price, attest), the
+ * recovery verifier and the client/explorer checkpoint verifiers resolve through
+ * this predicate so they can never drift; a divergence forks the chain.
*
* The cross-service conformance suite (ConsensusPrimitiveConformance.test.js,
* driven by xchain-documentation/protocol/test-vectors) runs in every repo and
* asserts BOTH the behavior (canonical vectors) AND byte-identity of the local
- * copy to this canonical source, so any unmirrored edit fails CI everywhere.
+ * copy to the xchain-documentation copy, so any unmirrored edit fails CI
+ * everywhere.
*
* Self-contained on mathjs bignumber (exact, never a JS double): the predicate is
* a pure function with no injected utility instance, so it is identical in every
diff --git a/src/consensus_rules_digest.js b/src/consensus_rules_digest.js
index 91b6bf6d1..693438118 100644
--- a/src/consensus_rules_digest.js
+++ b/src/consensus_rules_digest.js
@@ -77,9 +77,10 @@ const SHARED_GATES = [
['snapshot_reorg_buffer', ['SNAPSHOT_BURIAL_ACTIVATION']],
['stake_weighted_quorum', ['STAKE_WEIGHTED_QUORUM_ACTIVATION']],
['attest_responsible_widening_activation', ['ATTEST_RESPONSIBLE_WIDENING_ACTIVATION', 'ATTEST_RESPONSIBLE_WIDENING']],
- // Unratified on mainnet and testnet (both null): this row moves the digest for a
- // gate that decides nothing yet, so an upgraded hub reports a rules mismatch
- // against un-upgraded peers during the deploy wave, not a divergent ledger.
+ // Armed on testnet (height 151324, 2026-09-07) and at genesis on regtest; still
+ // null (unratified) on mainnet. This row moves the digest for a gate that is live
+ // on testnet, so an upgraded hub reports a rules mismatch against un-upgraded
+ // testnet peers during the deploy wave, not a divergent ledger.
['attest_response_mirror_activation', ['ATTEST_RESPONSE_MIRROR_ACTIVATION']],
// The zero-confirmation flip (one height for serve-at-tip, the stage-2 ladder and the
// applier fall-through) and the stage-2 ladder constants it selects. The V2 constants
@@ -117,7 +118,11 @@ const SHARED_GATES = [
// a digest row. A SECOND entry for the module at the END rather than names added to its
// entry above, because an insertion mid-list shifts the preimage of every gate after it.
['mirror_admission_activation', ['CHAIN_CODE_RE', 'CANONICAL_HEIGHT_RE', 'encodeAdmitBlocks',
- 'decodeAdmitBlocks', 'isAdmissionEra', 'admissionCanonicalField']]
+ 'decodeAdmitBlocks', 'isAdmissionEra', 'admissionCanonicalField']],
+ // The token leg of the bridge: the hub gates every token leg on it and the indexer gates
+ // XBRIDGE v3/v4 and ISSUE format 7 on it, so a train that sizes it on one side only must
+ // report a rules mismatch. Appended at the END for the preimage-ordering reason above.
+ ['token_bridge_activation', ['TOKEN_BRIDGE_ACTIVATION']]
];
// A per-network height at or above this value is a far-future placeholder, not an
diff --git a/src/constants.js b/src/constants.js
index 6713d86a1..b2319ab76 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -226,6 +226,13 @@ const MAX_SOURCE_ADDRESS_LENGTH = 100;
// timeout, a different engine's lifecycle that must not be tied to this one.
const DEFAULT_ATTESTATION_ROUND_TIMEOUT_MS = 120000;
+// Default XDEX and ANCHOR PBFT round timeouts, one per rail for the same reason:
+// each engine and the mirror admission watermark (peers/hub_db) size from these,
+// so a one-sided raise cannot shift the trail bound the hub publishes. Separate
+// names on purpose: equal today, but each engine's lifecycle is its own.
+const DEFAULT_XDEX_ROUND_TIMEOUT_MS = 120000;
+const DEFAULT_ANCHOR_ROUND_TIMEOUT_MS = 120000;
+
// The ATTEST PBFT state machines this build actually implements, and therefore the
// admission allowlist for a block-anchored provider consensus_strategy.
// AttestationConsensus dispatches on the pinned strategy by POSITIVE equality against
@@ -243,6 +250,7 @@ const SUPPORTED_CONSENSUS_STRATEGIES = Object.freeze(['byte_equality', 'judge_mo
module.exports = { PRICE_MAX, ORACLE_DEVIATION_THRESHOLD, ORACLE_MAX_CHANGE_PER_ROUND, XCALL_MAX_HOPS,
DEFAULT_ORACLE_ROUND_INTERVAL_MS, DEFAULT_ORACLE_SUBMISSION_WINDOW_MS,
DEFAULT_ATTESTATION_ROUND_TIMEOUT_MS, SUPPORTED_CONSENSUS_STRATEGIES,
+ DEFAULT_XDEX_ROUND_TIMEOUT_MS, DEFAULT_ANCHOR_ROUND_TIMEOUT_MS,
PRICE_V1_COINS, PRICE_V1_FIATS, MAX_TICK_LENGTH, MAX_MEMO_LENGTH,
MAX_SOURCE_ADDRESS_LENGTH, DERIVED_PAIRS,
XCHAIN_PRICE_WINDOW_BLOCKS, XCHAIN_PRICE_CONFIRMATION_BUFFER,
diff --git a/src/cross_chain/dex_consensus.js b/src/cross_chain/dex_consensus.js
index 766db9bd8..45e6d709d 100644
--- a/src/cross_chain/dex_consensus.js
+++ b/src/cross_chain/dex_consensus.js
@@ -45,6 +45,8 @@
const EventEmitter = require('events');
const { positiveIntConfig } = require('../lib/config_int.js');
+const { DEFAULT_XDEX_ROUND_TIMEOUT_MS } = require('../constants.js');
+const hubConfig = require('../config');
const ah = require('../lib/admission_height.js');
const { getLogger } = require('../observability');
const logger = getLogger();
@@ -61,8 +63,6 @@ const XDEX_MATCH_VIEW_CHANGE = 'XDEX_MATCH_VIEW_CHANGE';
const XDEX_MATCH_NEW_VIEW = 'XDEX_MATCH_NEW_VIEW';
const XDEX_MATCH_FINAL_SYNC = 'XDEX_MATCH_FINAL_SYNC';
-const DEFAULT_ROUND_TIMEOUT_MS = 120000; // 2 minutes per match round before view-change
-
class CrossChainDexConsensus extends EventEmitter {
// engine: the CrossChainDexEngine. Used for canonicalMatch (the signable
@@ -114,13 +114,19 @@ class CrossChainDexConsensus extends EventEmitter {
this.initEarlyBuffer();
this._messageHandler = null;
- this.roundTimeoutMs = parseInt(this.config.XDEX_ROUND_TIMEOUT_MS) || DEFAULT_ROUND_TIMEOUT_MS;
+ // Resolve env first, then p2pConfig, as the mirror admission watermark does: its
+ // xdex trail is this engine's terminal bound, so both must read one value.
+ this.roundTimeoutMs = positiveIntConfig(
+ hubConfig.XDEX_ROUND_TIMEOUT_MS || this.config.XDEX_ROUND_TIMEOUT_MS,
+ DEFAULT_XDEX_ROUND_TIMEOUT_MS, 'XDEX_ROUND_TIMEOUT_MS');
// A round that keeps view-changing without ever finalizing (sustained
// message loss, e.g. P2P rate-limit drops during a burst of concurrent
// rounds) must not leak in `pending` forever: past this lifetime it is
// abandoned so the engine can re-propose a fresh round once the storm
// clears. Default = several view-change cycles.
- this.roundMaxLifetimeMs = parseInt(this.config.XDEX_ROUND_MAX_LIFETIME_MS) || (this.roundTimeoutMs * 4);
+ this.roundMaxLifetimeMs = positiveIntConfig(
+ hubConfig.XDEX_ROUND_MAX_LIFETIME_MS || this.config.XDEX_ROUND_MAX_LIFETIME_MS,
+ this.roundTimeoutMs * 4, 'XDEX_ROUND_MAX_LIFETIME_MS');
}
async start(){
diff --git a/src/cross_chain/pbft/votes.js b/src/cross_chain/pbft/votes.js
index da387721f..e3d2f8d81 100644
--- a/src/cross_chain/pbft/votes.js
+++ b/src/cross_chain/pbft/votes.js
@@ -33,29 +33,28 @@ module.exports = {
if(!proposal) return;
let { rid, pending, view, row, canonical } = proposal;
- // INDEPENDENT confirmation: re-derive + validate against our own view of
- // the underlying data. This (not byte-equality with our locally pre-built
- // row) is the gate against a Byzantine leader.
- let ok = false;
- try { ok = await this.engine.validateProposedMatch(row); }
- catch(e){ ok = false; }
- if(!ok){
- logger.warn('CrossChainDexConsensus: PROPOSE ' + rid.substring(0,16) + '... failed local validation; not signing');
- return;
- }
+ const validateAndBound = async () => {
+ // INDEPENDENT confirmation: re-derive + validate against our own view of
+ // the underlying data. This (not byte-equality with our locally pre-built
+ // row) is the gate against a Byzantine leader.
+ let ok = false;
+ try { ok = await this.engine.validateProposedMatch(row); }
+ catch(e){ ok = false; }
+ if(!ok){
+ logger.warn('CrossChainDexConsensus: PROPOSE ' + rid.substring(0,16) + '... failed local validation; not signing');
+ return false;
+ }
- // The per-chain follower bound on the row's ADMISSION MAP (C38, BF6), applied here
- // because this is the one PROPOSE handler every engine on this consensus shares.
- // An engine opts in by answering admissionScope(row) with the row's table and read
- // set; one that does not is on the legacy effective_time rule and is unchanged.
- //
- // Deliberately a SECOND application for CrossChainCallEngine, which also holds the
- // bound inside its own validateProposedMatch. The engine gate binds every caller of
- // validateProposedMatch (its own tests, the e2e legs, any future caller) and this
- // one binds every engine on the shared path, so neither can be removed by work on
- // the other. The cost is one getlatestblock per reading chain on a path that
- // already makes at least two indexer round trips per proposal.
- if(!(await this.admissionBoundHolds(row, rid))) return;
+ // The per-chain follower bound on the row's ADMISSION MAP (C38, BF6), applied
+ // here because this is the one PROPOSE handler every engine shares. XCALL also
+ // applies the bound inside validateProposedMatch, so the proposal-scoped memo
+ // makes both gates reuse one tip promise per reading chain.
+ return this.admissionBoundHolds(row, rid);
+ };
+ const checked = this.hub && typeof this.hub.withAdmissionTipMemo === 'function'
+ ? await this.hub.withAdmissionTipMemo(validateAndBound)
+ : await validateAndBound();
+ if(!checked) return;
// Every await of the round sits in this function, so adopting the leader's row and
// writing the leader's and our own signature are one synchronous step.
diff --git a/src/db/attestation.js b/src/db/attestation.js
index d840553a0..054b0cb51 100644
--- a/src/db/attestation.js
+++ b/src/db/attestation.js
@@ -116,6 +116,17 @@ module.exports = {
return this.doQuery('SELECT window_start FROM attest_published_batches WHERE network = ? AND status = ?', [network, status]);
},
+ // Reads rows from attest_published_batches at or above a window floor.
+ async findAttestPublishedBatchesByNetworkAndStatusSince(network, status, sinceWindowStart) {
+ return this.doQuery('SELECT window_start FROM attest_published_batches WHERE network = ? AND status = ? AND window_start >= ?', [network, status, sinceWindowStart]);
+ },
+
+ // Summarizes attest_published_batches below a window floor without returning
+ // the historical rows. MIN and MAX preserve the age range in the operator log.
+ async getAttestPublishedBatchesCountByNetworkAndStatusBefore(network, status, beforeWindowStart) {
+ return this.doQuery('SELECT MIN(window_start) AS oldest, MAX(window_start) AS newest, COUNT(*) AS count FROM attest_published_batches WHERE network = ? AND status = ? AND window_start < ?', [network, status, beforeWindowStart]);
+ },
+
// Reads rows from attest_published_requests.
// Moved here from src/attestation/publisher.js:616.
async findAttestPublishedRequestsByRequestId(rid) {
@@ -168,10 +179,18 @@ module.exports = {
return this.doQuery('SELECT MAX(id) AS max_id FROM attestation_responses');
},
- // Inserts or updates a row in attest_published_batches.
- // Moved here from src/attestation/batch_publisher.js:1310.
+ // Inserts a row in attest_published_batches; a no-op on an existing (network,
+ // window_start) row of any status, like an ON DUPLICATE KEY UPDATE no-op,
+ // but with an affectedRows split that stays exclusive. INSERT IGNORE
+ // reports 0 for a duplicate and 1 only for the row it actually created,
+ // regardless of the driver's foundRows setting; an ON DUPLICATE KEY UPDATE
+ // no-op does not have that property, because the hub pool
+ // (src/db/index.js) leaves mariadb's default foundRows:true in effect, under
+ // which a duplicate's matched-but-unchanged row also reports affectedRows 1.
+ // claimWindowForBroadcast in batch_publisher/window.js reads that split to tell
+ // the sole creator of an intent row from every other attempt on it.
async setAttestPublishedBatchByNetwork(network, window_start, window_end, batchKey, row_count, status) {
- return this.doQuery('INSERT INTO attest_published_batches (network, window_start, window_end, batch_key, row_count, status) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE window_start = window_start', [network, window_start, window_end, batchKey, row_count, status]);
+ return this.doQuery('INSERT IGNORE INTO attest_published_batches (network, window_start, window_end, batch_key, row_count, status) VALUES (?, ?, ?, ?, ?, ?)', [network, window_start, window_end, batchKey, row_count, status]);
},
// Inserts or updates a row in attest_published_batches.
diff --git a/src/db/capability_snapshots.js b/src/db/capability_snapshots.js
index d77b7ff14..0420e67e1 100644
--- a/src/db/capability_snapshots.js
+++ b/src/db/capability_snapshots.js
@@ -31,6 +31,11 @@ const TABLE = 'capability_snapshots';
const COLUMNS = '(snapshot_block, capability, signing_pubkey, amount, source, btc_chain_id)';
module.exports = {
+ // The table has no network column, so a regtest chain-instance change clears it whole.
+ async deleteAllCapabilitySnapshots() {
+ return this.doQuery('DELETE FROM capability_snapshots');
+ },
+
// Reads rows from capability_snapshots.
// Moved here from src/api.js:2116.
async findCapabilitySnapshotsById(since, limit) {
diff --git a/src/db/cross_chain.js b/src/db/cross_chain.js
index 4b2f897c0..4dfaed40e 100644
--- a/src/db/cross_chain.js
+++ b/src/db/cross_chain.js
@@ -88,6 +88,16 @@ const CROSS_CHAIN_MATCH_COLUMNS = ['match_id','snapshot_block','network',
'admit_block_btc','admit_block_ltc','admit_block_doge'];
module.exports = {
+ // Deletes rows tied to one network after a regtest chain instance changes.
+ async deleteCrossChainMatchesByNetwork(network) {
+ return this.doQuery('DELETE FROM cross_chain_matches WHERE network = ?', [network]);
+ },
+
+ // Deletes rows tied to one network after a regtest chain instance changes.
+ async deleteCrossChainCallsByNetwork(network) {
+ return this.doQuery('DELETE FROM cross_chain_calls WHERE network = ?', [network]);
+ },
+
// Reads rows from cross_chain_calls.
// Moved here from src/anchor/publisher.js:2491.
async findCrossChainCallsByBatchSeq(maxBatch) {
diff --git a/src/db/schema/columns.js b/src/db/schema/columns.js
index a2a8fe736..e162384e5 100644
--- a/src/db/schema/columns.js
+++ b/src/db/schema/columns.js
@@ -28,10 +28,67 @@
const nodeUtil = require('node:util');
const ark = require('../../anchor/anchor_reward_key.js');
const { getLogger } = require('../../observability');
+const hubConfig = require('../../config');
const logger = getLogger();
+// The width oracle_prices.tick widens to, and the restated column. Literal rather than
+// MAX_TICK_LENGTH: a raised gate constant must not widen the hub past its mirrors unasked.
+const ORACLE_TICK_WIDTH = 250;
+const ORACLE_TICK_DEF = 'VARCHAR(250) NOT NULL';
+
module.exports = {
+ // Widen oracle_prices.tick to the PRICE v1 ingest bound, once the MIRRORS have.
+ //
+ // A mirror still at VARCHAR(50) under STRICT_TRANS_TABLES refuses the first longer tick
+ // the hub serves and wedges its oracle barrier on every re-page, and the hub cannot see
+ // its mirrors. So the order is operator-attested: widen every indexer and explorer mirror
+ // first, then set XCHAIN_HUB_ORACLE_TICK_MIRRORS_WIDENED=1. Until then this logs and
+ // skips, and each boot asks again; the narrow column keeps a long tick off the wire.
+ async migrateOracleTickWidth(){
+ if(hubConfig.XCHAIN_HUB_ORACLE_TICK_MIRRORS_WIDENED !== '1'){
+ logger.warn('Migration deferred: oracle_prices.tick stays narrower than ' + ORACLE_TICK_WIDTH +
+ ' until every mirror is widened (indexer migration 2026-09-22-oracle-prices-widen-tick.sql, ' +
+ 'explorer hub-mirror reconciler). Then set XCHAIN_HUB_ORACLE_TICK_MIRRORS_WIDENED=1 and restart.');
+ return false;
+ }
+ return this.migrateColumnLength('oracle_prices', 'tick', ORACLE_TICK_WIDTH, ORACLE_TICK_DEF);
+ },
+
+
+ // Widen a character column's length in place. Idempotent: reads the live
+ // CHARACTER_MAXIMUM_LENGTH and no-ops once it is at or above `targetLength`, so a fresh
+ // install and an already-migrated node both skip it. migrateColumnType cannot do this:
+ // it compares DATA_TYPE, and varchar to varchar reads as already converted.
+ //
+ // WIDENING ONLY: `columnDef` restates the whole column and must match the definition
+ // file, and a narrower live column is the only case that issues the MODIFY.
+ async migrateColumnLength(table, column, targetLength, columnDef){
+ let db = await this.getConnection();
+ try {
+ let rows = await db.query(
+ "SELECT CHARACTER_MAXIMUM_LENGTH AS len FROM information_schema.columns " +
+ "WHERE table_schema = ? AND table_name = ? AND column_name = ?",
+ [this.dbName, table, column]
+ );
+ if(!rows || !rows[0]) return false; // table/column not present yet; CREATE TABLE covers it
+ let live = Number(rows[0].len);
+ if(rows[0].len == null || Number.isNaN(live) || live >= targetLength) return false;
+ await db.query('ALTER TABLE `' + table + '` MODIFY `' + column + '` ' + columnDef);
+ logger.info('Migration: widened ' + table + '.' + column + ' ' + live + ' -> ' + targetLength);
+ return true;
+ } catch(e){
+ // Swallowed loudly, as migrateColumnCharset is: a throw takes the boot down.
+ logger.error(nodeUtil.format('MIGRATION FAILED: ' + table + '.' + column + ' is still narrower than ' +
+ targetLength + ', so a longer value errors or truncates on INSERT. Run by hand: ' +
+ 'ALTER TABLE `' + table + '` MODIFY `' + column + '` ' + columnDef, e));
+ return false;
+ } finally {
+ await db.release();
+ }
+ },
+
+
// Add the per-chain admission height columns to every mirrored table that carries one.
//
diff --git a/src/db/schema/migrations.js b/src/db/schema/migrations.js
index c69585588..625cdf04c 100644
--- a/src/db/schema/migrations.js
+++ b/src/db/schema/migrations.js
@@ -131,7 +131,8 @@ module.exports = {
},
// The column-level steps: the two DATETIME conversions, the two charset
- // widens, the price fence re-key, and the mirror admission columns.
+ // widens, the price fence re-key, the mirror admission columns, and the gated
+ // oracle_prices.tick length widen.
async runColumnAndFenceMigrations(){
// #4315: governance_proposals.voting_start/voting_end shipped as TIMESTAMP, which
// MariaDB bounds to the signed 32-bit epoch (2038-01-19 03:14:07 UTC). Both hold a
@@ -164,6 +165,18 @@ module.exports = {
// xchain-hub/migrations/ are applied by hand, so a migration copied from the
// indexer's style would sit there and never run on a single deployed hub.
await this.migrateAdmissionColumns();
+ // oracle_prices.tick to the 250 the PRICE v1 ingest gate admits, gated on the mirrors
+ // having widened first (see migrateOracleTickWidth for the order and the flag).
+ await this.migrateOracleTickWidth();
+ // The price_snapshots finalized-frontier indexes (see the KEY comments in
+ // src/sql/price_snapshots.sql). Every mirror reader polls MAX(reference_block) and
+ // MAX(block_timestamp) under status = 'finalized' against the hub's table, and the
+ // hub's status RPC polls MAX(created_at) the same way; with only idx_status each is a
+ // full scan on a table that grows a row per pair per round. Appended LAST so the
+ // pass stays a prefix-extension of what every deployed hub already ran.
+ await this.migrateIndex('price_snapshots', 'idx_status_block_round', '(status, reference_block, round_number)');
+ await this.migrateIndex('price_snapshots', 'idx_status_timestamp_round', '(status, block_timestamp, round_number)');
+ await this.migrateIndex('price_snapshots', 'idx_status_created', '(status, created_at)');
}
};
diff --git a/src/hub/chain_tips.js b/src/hub/chain_tips.js
index 08a014501..5147d18b5 100644
--- a/src/hub/chain_tips.js
+++ b/src/hub/chain_tips.js
@@ -26,9 +26,14 @@ const { blockIntervalS } = require('../lib/relay_margin.js');
const { DEFAULT_ORACLE_ROUND_INTERVAL_MS } = require('../constants.js');
const hubConfig = require('../config');
const nodeUtil = require('node:util');
+const { AsyncLocalStorage } = require('node:async_hooks');
const { getLogger } = require('../observability');
const logger = getLogger();
+// One store per asynchronous proposal flow. The WeakMap keeps separate hub instances
+// isolated even when tests or embedded callers deliberately use more than one in a scope.
+const admissionTipMemo = new AsyncLocalStorage();
+
class ChainTips {
// Resolve the latest BTC block index: first hub.db.getChainTip, populated by the
@@ -159,7 +164,16 @@ class ChainTips {
// fast chains. Overridable per deployment, never per call.
static get ADMISSION_TIP_STALL_BLOCKS(){ return 6; }
- async resolveAdmissionTip(coin){
+ // Share admission-tip reads only within one proposal. Nested users join the current
+ // scope, while the next top-level proposal starts with an empty memo.
+ withAdmissionTipMemo(fn){
+ if(admissionTipMemo.getStore()) return fn();
+ return admissionTipMemo.run(new WeakMap(), fn);
+ }
+
+ // opts.signal, when given, cancels the indexer read (the health probe's deadline).
+ async resolveAdmissionTip(coin, opts){
+ let signal = opts && opts.signal;
let c = admissionHeight.normalizeChain(coin);
if(c === null){
logger.warn('XChainHub: admission tip requested for unusable chain ' + JSON.stringify(String(coin)));
@@ -176,9 +190,11 @@ class ChainTips {
let res = await axiosFor(this).post(url, {
jsonrpc: '2.0', id: Date.now(),
method: 'getlatestblock', params: {}
- }, { timeout: 5000 });
+ }, signal ? { timeout: 5000, signal } : { timeout: 5000 });
result = res && res.data && res.data.result;
} catch (err) {
+ // A read the caller cancelled is the caller's deadline, not an indexer fault.
+ if(signal && signal.aborted) return null;
logger.error(nodeUtil.format('XChainHub: failed to read the ' + c + ' admission tip from its indexer:', err.message));
return null;
}
@@ -245,7 +261,10 @@ class ChainTips {
// Every admission tip a row's read set needs, read in parallel. A chain whose tip is
// refused comes back null rather than missing, so the caller's refusal names it.
- async resolveAdmissionTips(chains){
+ // opts.signal cancels the reads, but only outside a proposal memo: a memoized read
+ // is shared with the rest of the proposal, and one caller's deadline must not
+ // cancel it for the others.
+ async resolveAdmissionTips(chains, opts){
let out = {};
let want = [];
for(let raw of (chains || [])){
@@ -253,7 +272,18 @@ class ChainTips {
if(c === null){ out[String(raw)] = null; continue; }
if(want.indexOf(c) === -1) want.push(c);
}
- let tips = await Promise.all(want.map((c) => this.resolveAdmissionTip(c).catch(() => null)));
+ let scope = admissionTipMemo.getStore();
+ let memo = null;
+ if(scope){
+ memo = scope.get(this);
+ if(!memo){ memo = new Map(); scope.set(this, memo); }
+ }
+ let tips = await Promise.all(want.map((c) => {
+ if(!memo) return this.resolveAdmissionTip(c, opts).catch(() => null);
+ if(!memo.has(c))
+ memo.set(c, Promise.resolve().then(() => this.resolveAdmissionTip(c)).catch(() => null));
+ return memo.get(c);
+ }));
want.forEach((c, i) => { out[c] = tips[i]; });
return out;
}
diff --git a/src/hub/config_params.js b/src/hub/config_params.js
index efc252b53..03c56202f 100644
--- a/src/hub/config_params.js
+++ b/src/hub/config_params.js
@@ -42,6 +42,11 @@ const logger = getLogger();
const PARAMETER_LIST = ["host", "port", "service_port", "db_host", "db_port", "name", "user", "pass", "self_sync", "hub_url"];
const OPERATIONAL_PARAMS = new Set(["GAS_PRICE", "ACTIVATION_DELAY_BLOCKS", "EXPIRATION_FEE_PER_DAY"]);
const JSON_BLOB_PARAMS = new Set(["GAS_SCHEDULE", "STAKING"]);
+const HUB_NETWORKS = new Set(["mainnet", "testnet", "regtest"]);
+
+function isConfigObject(value) {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
// The string-valued parameters of one (coin, network, module): the transport
// keys every module carries, then the operational ones.
@@ -119,6 +124,27 @@ function collectJsonBlobRows(rows, moduleLevel, nextCoin, nextNetwork, nextModul
class ConfigParams {
+ // Infer only when every object-valued network slot names the same recognized
+ // network. Preserve explicit settings and refuse ambiguous input.
+ learnNetworkFromConfig(json){
+ if(this.network) return this.network;
+ if(!isConfigObject(json)) return this.network;
+
+ let learnedNetwork = null;
+ for(const coinLevel of Object.values(json)){
+ if(!isConfigObject(coinLevel)) continue;
+ for(const [network, networkLevel] of Object.entries(coinLevel)){
+ if(!isConfigObject(networkLevel)) continue;
+ if(!HUB_NETWORKS.has(network)) return this.network;
+ if(learnedNetwork !== null && learnedNetwork !== network) return this.network;
+ learnedNetwork = network;
+ }
+ }
+
+ if(learnedNetwork !== null) this.network = learnedNetwork;
+ return this.network;
+ }
+
async addParametersFromJson(json){
if(this.consensus){
await this.consensus.propose(json);
diff --git a/src/lib/attest_batch_wire.js b/src/lib/attest_batch_wire.js
index aadfea8ab..e74f0c1af 100644
--- a/src/lib/attest_batch_wire.js
+++ b/src/lib/attest_batch_wire.js
@@ -40,6 +40,14 @@
* refusal shape, codecs and signed canonical in primitives.js, the two wire
* parsers in parse.js, and chunk coverage plus reassembly in reassemble.js.
*
+ * TWO ROW FIELD SETS, ONE CHOICE. admit_block_btc joined the rows at the BTC
+ * mirror-admission producer activation, so a batch whose signed anchor is below it
+ * carries the legacy set and one at or above it carries the admission set. Every
+ * entry point that signs, encodes or checks a body takes the caller's era predicate
+ * (mirror_admission_gate.js isAdmissionEra) and resolves the set through the one
+ * attestBatchRowFields call, so the canonical a quorum signs and the presence check
+ * a replaying node applies can never disagree about a batch.
+ *
* THE CAPS ARE CONSENSUS. The compressed bytes are the action body, so a size
* or row-count breach must be invalid on every node or the fleet forks on the
* first hostile batch. The inflate is bounded through zlib's maxOutputLength so
@@ -65,11 +73,12 @@ const zlib = require('zlib');
const {
ATTEST_BATCH_HEAD_VERSION, ATTEST_BATCH_CONTINUATION_VERSION, ATTEST_BATCH_WIRE_MAX_BYTES,
ATTEST_BATCH_MAX_INFLATED_BYTES, ATTEST_BATCH_MAX_ROWS, ATTEST_BATCH_MAX_CHUNKS,
- ATTEST_BATCH_MAX_INFLATE_RATIO, ATTEST_BATCH_ROW_FIELDS, FAIL
+ ATTEST_BATCH_MAX_INFLATE_RATIO, ATTEST_BATCH_LEGACY_ROW_FIELDS, ATTEST_BATCH_ADMISSION_ROW_FIELDS,
+ ATTEST_BATCH_ROW_FIELDS, FAIL
} = require('./attest_batch_wire/constants.js');
const {
- fail, decodeCanonicalBase64, crc32Hex, computeBatchKey,
- buildAttestBatchCanonical, buildAttestBatchBody
+ fail, decodeCanonicalBase64, crc32Hex, computeBatchKey, attestBatchRowFields,
+ requireAdmissionEra, buildAttestBatchCanonical, buildAttestBatchBody
} = require('./attest_batch_wire/primitives.js');
const { parseAttestBatchHead, parseAttestBatchContinuation } = require('./attest_batch_wire/parse.js');
const { attestChunkCoverage, reassembleAttestBatch } = require('./attest_batch_wire/reassemble.js');
@@ -105,18 +114,21 @@ function continuationPrefix(batchKey, chunkIndex, totalChunks, crc){
*
* @param {{network:string, window_start:number, window_end:number, row_count:number,
* btc_block_height:number, rows:Object[], sigs:{pubkey:string,sig:string}[]}} window
+ * @param {function(string, number): boolean} admissionEra the repo's isAdmissionEra, which
+ * picks the row field set the body carries (see attestBatchRowFields)
* @returns {{ok:true, batchKey:string, batchCrc32:string, totalChunks:number,
* wires:string[], body:string, inflatedBytes:number, compressedBytes:number}
* |{ok:false, reason:string, status:string, detail:*}}
*/
-function encodeAttestBatch(window){
+function encodeAttestBatch(window, admissionEra){
+ requireAdmissionEra(admissionEra);
if(!window || typeof window !== 'object') return fail(FAIL.STRUCTURE, 'window is not an object');
const rows = Array.isArray(window.rows) ? window.rows : null;
if(rows === null) return fail(FAIL.STRUCTURE, 'rows is not an array');
if(rows.length > ATTEST_BATCH_MAX_ROWS) return fail(FAIL.ROW_COUNT, rows.length);
if(Number(window.row_count) !== rows.length) return fail(FAIL.ROW_COUNT, 'row_count does not match rows.length');
- const body = buildAttestBatchBody(window);
+ const body = buildAttestBatchBody(window, admissionEra);
const bodyBytes = Buffer.from(body, 'utf8');
if(bodyBytes.length > ATTEST_BATCH_MAX_INFLATED_BYTES) return fail(FAIL.OVERSIZE, bodyBytes.length);
@@ -164,11 +176,14 @@ module.exports = {
ATTEST_BATCH_MAX_ROWS,
ATTEST_BATCH_MAX_CHUNKS,
ATTEST_BATCH_MAX_INFLATE_RATIO,
+ ATTEST_BATCH_LEGACY_ROW_FIELDS,
+ ATTEST_BATCH_ADMISSION_ROW_FIELDS,
ATTEST_BATCH_ROW_FIELDS,
ATTEST_BATCH_FAIL_REASONS: FAIL,
ATTEST_BATCH_HEAD_FORMAT: HEAD_FORMAT,
ATTEST_BATCH_CONTINUATION_FORMAT: CONTINUATION_FORMAT,
computeBatchKey,
+ attestBatchRowFields,
buildAttestBatchCanonical,
buildAttestBatchBody,
encodeAttestBatch,
diff --git a/src/lib/attest_batch_wire/constants.js b/src/lib/attest_batch_wire/constants.js
index 8fb6364df..68bfdb31f 100644
--- a/src/lib/attest_batch_wire/constants.js
+++ b/src/lib/attest_batch_wire/constants.js
@@ -94,17 +94,48 @@ const ATTEST_BATCH_MAX_CHUNKS = 256;
const ATTEST_BATCH_MAX_INFLATE_RATIO = 150;
/**
- * The per-row fields the batch carries, in canonical order: every
+ * The per-row fields a LEGACY batch carries, in canonical order: every
* `attestation_responses` column except `id` (hub-local paging cursor),
- * `finalized_at` (hub wall clock, audit only) and `batch_action_index` (set by
- * the batch landing itself, so a batch cannot carry its own).
+ * `finalized_at` (hub wall clock, audit only), `batch_action_index` (set by
+ * the batch landing itself, so a batch cannot carry its own) and
+ * `admit_block_btc` (see the admission set below).
*
* Carrying `signatures` and `signer_pubkeys` is what makes a batch-fed node's
* `attests` rows byte-identical to a mirror-fed node's: the per-row responsible
* -set signatures ride the chain, not just the batch quorum's.
+ *
+ * This is the set every batch on chain below the mirror-admission producer
+ * activation was signed over and is still judged by, so it is frozen: adding a
+ * field here rewrites the signed bytes and the presence check of history.
+ * @type {string[]}
+ */
+const ATTEST_BATCH_LEGACY_ROW_FIELDS = Object.freeze([
+ 'network',
+ 'request_id',
+ 'request_action_index',
+ 'request_block_index',
+ 'provider_id',
+ 'status',
+ 'response_payload',
+ 'response_hash',
+ 'meta',
+ 'effective_time',
+ 'signer_pubkeys',
+ 'signatures',
+ 'widen'
+]);
+
+/**
+ * The per-row fields an ADMISSION-ERA batch carries: the legacy set plus the
+ * row's signed BTC admission height, placed after `effective_time`. A batch is in
+ * the admission era when its own signed `btc_block_height` is at or above the
+ * BTC mirror-admission producer activation for its network, the same flag day
+ * that arms the admit_block_* columns. primitives.js attestBatchRowFields is the
+ * one place that choice is made, and both the signed canonical and the
+ * reassembly presence check read it there.
* @type {string[]}
*/
-const ATTEST_BATCH_ROW_FIELDS = [
+const ATTEST_BATCH_ADMISSION_ROW_FIELDS = Object.freeze([
'network',
'request_id',
'request_action_index',
@@ -115,10 +146,20 @@ const ATTEST_BATCH_ROW_FIELDS = [
'response_hash',
'meta',
'effective_time',
+ 'admit_block_btc',
'signer_pubkeys',
'signatures',
'widen'
-];
+]);
+
+/**
+ * Every field either era's batch can carry: the admission set, which is a
+ * superset of the legacy one. For reading and projecting rows (a SELECT list, a
+ * row copy) and never for signing or checking a batch, which must take the
+ * era's own set from attestBatchRowFields.
+ * @type {string[]}
+ */
+const ATTEST_BATCH_ROW_FIELDS = ATTEST_BATCH_ADMISSION_ROW_FIELDS;
/**
* Failure reasons. STABLE STRINGS: they reach the chain inside the action's
@@ -161,6 +202,8 @@ module.exports = {
ATTEST_BATCH_MAX_ROWS,
ATTEST_BATCH_MAX_CHUNKS,
ATTEST_BATCH_MAX_INFLATE_RATIO,
+ ATTEST_BATCH_LEGACY_ROW_FIELDS,
+ ATTEST_BATCH_ADMISSION_ROW_FIELDS,
ATTEST_BATCH_ROW_FIELDS,
FAIL,
CANONICAL_BASE64,
diff --git a/src/lib/attest_batch_wire/primitives.js b/src/lib/attest_batch_wire/primitives.js
index feb02dccb..ce2a5e706 100644
--- a/src/lib/attest_batch_wire/primitives.js
+++ b/src/lib/attest_batch_wire/primitives.js
@@ -30,7 +30,9 @@
const crypto = require('crypto');
const zlib = require('zlib');
-const { ATTEST_BATCH_ROW_FIELDS, CANONICAL_BASE64 } = require('./constants.js');
+const {
+ ATTEST_BATCH_LEGACY_ROW_FIELDS, ATTEST_BATCH_ADMISSION_ROW_FIELDS, CANONICAL_BASE64
+} = require('./constants.js');
/**
* A structured refusal. Callers record the action invalid on it and must never
@@ -108,18 +110,59 @@ function computeBatchKey(window){
return crypto.createHash('sha256').update(preimage, 'utf8').digest('hex');
}
-// Rows reduced to the carried field set, in ATTEST_BATCH_ROW_FIELDS order. A row
-// object built ad hoc in two places drifts, and a drifted row changes the signed
+/**
+ * The row field set a batch is signed over and checked against, chosen by the
+ * batch's OWN signed anchor: the admission set when `admissionEra(network,
+ * btcBlockHeight)` is true, the legacy set otherwise.
+ *
+ * The era predicate is a parameter because this directory may require nothing
+ * outside itself (the twins sit at different depths), so each repo passes its own
+ * mirror_admission_gate.js isAdmissionEra, the BTC producer activation keyed by
+ * network. It is keyed on the batch anchor and never on the reading node's height,
+ * so a batch's field set is fixed the moment it is signed: a node replaying from
+ * genesis, a node mid-roll and the publisher all pick the same set for the same
+ * bytes. The height handed over is Number() of the value the header signs, which
+ * is the value a verifier holds after checkBodyAgainstHead, so the two sides
+ * evaluate the gate on one number.
+ *
+ * A missing predicate throws rather than defaulting: either default is a fork,
+ * one against history and one against the flag day.
+ * @param {string} network the batch's network
+ * @param {number|string} btcBlockHeight the batch's signed BTC anchor
+ * @param {function(string, number): boolean} admissionEra the repo's isAdmissionEra
+ * @returns {string[]} ATTEST_BATCH_ADMISSION_ROW_FIELDS or ATTEST_BATCH_LEGACY_ROW_FIELDS
+ */
+function attestBatchRowFields(network, btcBlockHeight, admissionEra){
+ requireAdmissionEra(admissionEra);
+ return admissionEra(String(network), Number(btcBlockHeight)) === true
+ ? ATTEST_BATCH_ADMISSION_ROW_FIELDS
+ : ATTEST_BATCH_LEGACY_ROW_FIELDS;
+}
+
+// Refuse a call that brought no era predicate, before any byte is judged, so the
+// omission fails the same way whatever the input is.
+function requireAdmissionEra(admissionEra){
+ if(typeof admissionEra !== 'function')
+ throw new TypeError('attest_batch_wire: an admission-era predicate (mirror_admission_gate ' +
+ 'isAdmissionEra) is required to choose the batch row field set');
+}
+
+// Rows reduced to the era's carried field set, in that set's order. A row object
+// built ad hoc in two places drifts, and a drifted row changes the signed
// canonical, so every serializer below goes through here.
-function normalizeBatchRows(window){
+function normalizeBatchRows(window, fields){
return (window.rows || []).map((r) => {
const out = {};
- for(const f of ATTEST_BATCH_ROW_FIELDS)
+ for(const f of fields)
out[f] = (r[f] === undefined) ? null : r[f];
return out;
});
}
+function windowRowFields(window, admissionEra){
+ return attestBatchRowFields(window.network, window.btc_block_height, admissionEra);
+}
+
function batchHeaderObject(window){
return {
network: String(window.network),
@@ -137,12 +180,17 @@ function batchHeaderObject(window){
* two sides agree without the compressed or chunked bytes ever entering the
* preimage. Insertion order IS the field order in JSON.stringify, so the object
* literal above is the canonical's definition; never reorder it.
+ *
+ * The row fields are the era's (attestBatchRowFields): below the admission
+ * activation `admit_block_btc` is neither signed nor carried, so every batch
+ * already on chain keeps the bytes its quorum signed.
* @param {Object} window
+ * @param {function(string, number): boolean} admissionEra the repo's isAdmissionEra
* @returns {string}
*/
-function buildAttestBatchCanonical(window){
+function buildAttestBatchCanonical(window, admissionEra){
const header = batchHeaderObject(window);
- header.rows = normalizeBatchRows(window);
+ header.rows = normalizeBatchRows(window, windowRowFields(window, admissionEra));
return JSON.stringify(header);
}
@@ -151,11 +199,12 @@ function buildAttestBatchCanonical(window){
* signature set that covers it. This is the exact string both sides deflate and
* inflate.
* @param {Object} window the window to serialize
+ * @param {function(string, number): boolean} admissionEra the repo's isAdmissionEra
* @returns {string}
*/
-function buildAttestBatchBody(window){
+function buildAttestBatchBody(window, admissionEra){
const body = batchHeaderObject(window);
- body.rows = normalizeBatchRows(window);
+ body.rows = normalizeBatchRows(window, windowRowFields(window, admissionEra));
body.sigs = (window.sigs || []).map((s) => ({
pubkey: String(s.pubkey || '').toLowerCase(),
sig: String(s.sig || '').toLowerCase()
@@ -165,6 +214,8 @@ function buildAttestBatchBody(window){
module.exports = {
fail,
+ attestBatchRowFields,
+ requireAdmissionEra,
decodeCanonicalBase64,
crc32Hex,
computeBatchKey,
diff --git a/src/lib/attest_batch_wire/reassemble.js b/src/lib/attest_batch_wire/reassemble.js
index 04d664fc3..66d840742 100644
--- a/src/lib/attest_batch_wire/reassemble.js
+++ b/src/lib/attest_batch_wire/reassemble.js
@@ -33,9 +33,11 @@ const zlib = require('zlib');
const {
ATTEST_BATCH_MAX_INFLATED_BYTES, ATTEST_BATCH_MAX_ROWS, ATTEST_BATCH_MAX_INFLATE_RATIO,
- ATTEST_BATCH_ROW_FIELDS, FAIL, HEX64
+ FAIL, HEX64
} = require('./constants.js');
-const { fail, decodeCanonicalBase64, crc32Hex } = require('./primitives.js');
+const {
+ fail, decodeCanonicalBase64, crc32Hex, attestBatchRowFields, requireAdmissionEra
+} = require('./primitives.js');
/**
* Order a chunk set into slots 1..totalChunks-1, or null when coverage is
@@ -130,7 +132,12 @@ function decodeBatchBody(head, inflated){
// Step 4: the body must describe the batch the wire header declared, and every
// row and signature entry must carry its full field set. Returns null when it does.
-function checkBodyAgainstHead(head, parsed){
+//
+// "Full" is the ERA's set, from the same attestBatchRowFields call the signed
+// canonical makes, keyed on the head's anchor, which the body has just been held
+// equal to. A batch below the admission activation is complete without
+// admit_block_btc, and one at or above it is not.
+function checkBodyAgainstHead(head, parsed, admissionEra){
// The header the wire declares and the body the quorum signed must describe
// one batch. A disagreement is refused rather than resolved in either
// direction: the header keys the gates, the body carries the rows, and
@@ -143,9 +150,10 @@ function checkBodyAgainstHead(head, parsed){
return fail(FAIL.STRUCTURE, 'body header does not match the wire header');
if(Number(parsed.row_count) !== parsed.rows.length) return fail(FAIL.ROW_COUNT, 'row_count does not match rows.length');
+ const fields = attestBatchRowFields(head.network, head.btcBlockHeight, admissionEra);
for(const r of parsed.rows){
if(!r || typeof r !== 'object' || Array.isArray(r)) return fail(FAIL.ROW_FIELD, 'row is not an object');
- for(const f of ATTEST_BATCH_ROW_FIELDS)
+ for(const f of fields)
if(!(f in r)) return fail(FAIL.ROW_FIELD, f);
}
for(const s of parsed.sigs){
@@ -166,10 +174,13 @@ function checkBodyAgainstHead(head, parsed){
*
* @param {Object} head the parseAttestBatchHead result for this batch
* @param {{chunk_index:number, chunk_b64:string, action_index:(number|undefined)}[]} chunks
+ * @param {function(string, number): boolean} admissionEra the repo's isAdmissionEra, which
+ * picks the row field set the body must carry (see attestBatchRowFields)
* @returns {{ok:true, batch:Object, inflatedBytes:number, compressedBytes:number}
* |{ok:false, reason:string, status:string, detail:*}}
*/
-function reassembleAttestBatch(head, chunks){
+function reassembleAttestBatch(head, chunks, admissionEra){
+ requireAdmissionEra(admissionEra);
if(!head || head.ok !== true) return fail(FAIL.STRUCTURE, 'head is not a parsed batch head');
const joined = concatenateBatchChunks(head, chunks);
@@ -181,7 +192,7 @@ function reassembleAttestBatch(head, chunks){
const decoded = decodeBatchBody(head, body.inflated);
if(decoded.ok !== true) return decoded;
- const mismatch = checkBodyAgainstHead(head, decoded.parsed);
+ const mismatch = checkBodyAgainstHead(head, decoded.parsed, admissionEra);
if(mismatch !== null) return mismatch;
return {
diff --git a/src/lib/idempotent_broadcast.js b/src/lib/idempotent_broadcast.js
index 14bd483b5..ec515167f 100644
--- a/src/lib/idempotent_broadcast.js
+++ b/src/lib/idempotent_broadcast.js
@@ -36,11 +36,14 @@
*
* isNeverSentError(e)
* The strict half of the definitive class: not merely "retrying is safe" but
- * "no byte of this transaction left the process". Two shapes prove it, and they
- * are the two isAmbiguousSendError already reads: a sub-500 HTTP response (the
- * encoder refused the call before it ever built or forwarded a transaction, for
- * example insufficient funds or an unconfirmed-change refusal) and a
- * never-connected transport code. A NAMED node rejection is definitive but NOT
+ * "no byte of this transaction left the process". Three shapes prove it, and
+ * isAmbiguousSendError reads all three as definitive: a `neverSent` tag, which a
+ * pipeline sets on a failure raised before its send step (the encoder answers a
+ * refused create_tx, such as insufficient funds or an unconfirmed-change refusal,
+ * as an HTTP 200 JSON-RPC error, so only the pipeline knows nothing was sent); a
+ * sub-500 HTTP response (the encoder's auth, batch-size and rate-limit gates
+ * refusing the call before processing); and a never-connected transport code.
+ * A NAMED node rejection is definitive but NOT
* never-sent, because the transaction reached the node to be rejected; callers
* that need "nothing was spent and nothing is in flight" must not read it as
* such. Used by callers that hold a durable publish-intent marker and want to
@@ -93,6 +96,8 @@ function isAmbiguousSendError(e){
// definition and the caller must dead-letter rather than rebuild. The hook sets the
// flag; nothing else in the hub does.
if (e.fundsCommitted) return true;
+ // A pipeline tags neverSent only on a failure before its send step, so a retry cannot double-spend.
+ if (e.neverSent === true) return false;
let message = String(e.message || '');
// A definitive rejection and an ambiguous transport failure share the SAME
// 'Encoder RPC error' envelope, so this case is read before the prefix is trusted.
@@ -114,10 +119,11 @@ function isAmbiguousSendError(e){
// named rejection is the node's own verdict on a transaction it received.
function isNeverSentError(e){
if (!e) return false;
- // Outranks both shapes below for the same reason it outranks every rule in
+ // Outranks every shape below for the same reason it outranks every rule in
// isAmbiguousSendError: a multi-phase signer hook can carry a funded phase-one
// transaction on chain and still surface a 4xx or an ECONNREFUSED on its reveal.
if (e.fundsCommitted) return false;
+ if (e.neverSent === true) return true; // failed before the send step
if (e.response && Number(e.response.status) < 500) return true; // refused before processing
let code = String(e.code || '');
return code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'EAI_AGAIN'; // never connected
diff --git a/src/observability/index.js b/src/observability/index.js
index f07981c48..26b8de5b9 100644
--- a/src/observability/index.js
+++ b/src/observability/index.js
@@ -78,6 +78,17 @@ let _shipperAttached = false;
// (` warn [svc] warn [svc] msg`).
let _sink = null;
+// routeLabel's unmatched-path fallback (below) hands out one label per
+// distinct first path segment, and that segment is chosen by whoever sends
+// the request. Without a cap, a flood of distinct unmatched segments buys one
+// series per request against the SHARED per-metric budget every route draws
+// from (Registry maxSeries, metrics.js), and once that budget is spent the
+// service's own routes can no longer register a series either. Past
+// UNMATCHED_ROUTE_LABEL_CAP distinct segments, every further one collapses
+// onto a single overflow label instead of buying its own series.
+const UNMATCHED_ROUTE_LABEL_CAP = 20;
+const _unmatchedRouteLabels = new Set();
+
const CONSOLE_METHODS = { log: 'info', info: 'info', warn: 'warn', error: 'error', debug: 'debug' };
function toBool(v, fallback = false) {
@@ -110,7 +121,10 @@ function timingSafeEqual(a, b) {
// Path label for HTTP metrics. Express route patterns ("/hub-db/snapshot/:t")
// are already low-cardinality; a raw URL is not, so anything without a matched
// route falls back to its first path segment. This is the difference between a
-// dozen series and one per block height.
+// dozen series and one per block height. The first segment is still whatever
+// the requester sent, so past UNMATCHED_ROUTE_LABEL_CAP distinct segments seen
+// (above), later ones share a fixed overflow label instead of each buying a
+// new series.
function routeLabel(req) {
if (req.route && req.route.path) {
const base = req.baseUrl || '';
@@ -119,7 +133,14 @@ function routeLabel(req) {
}
const raw = (req.originalUrl || req.url || '/').split('?')[0];
const seg = raw.split('/').filter(Boolean)[0];
- return seg ? `/${seg}` : '/';
+ if (!seg) return '/';
+ const label = `/${seg}`;
+ if (_unmatchedRouteLabels.has(label)) return label;
+ if (_unmatchedRouteLabels.size < UNMATCHED_ROUTE_LABEL_CAP) {
+ _unmatchedRouteLabels.add(label);
+ return label;
+ }
+ return '/_unmatched';
}
// Express dispatches its router stack in registration order, so a timing
@@ -394,6 +415,7 @@ function _resetObservability() {
_logger = null;
_registry = null;
_shipperAttached = false;
+ _unmatchedRouteLabels.clear();
}
module.exports = {
diff --git a/src/oracle/consensus/follower_round.js b/src/oracle/consensus/follower_round.js
index 3a022a589..17c4b41ed 100644
--- a/src/oracle/consensus/follower_round.js
+++ b/src/oracle/consensus/follower_round.js
@@ -153,7 +153,7 @@ function prepareOnPropose(envelope, proposal, proposedAdmit) {
// Bind the leader's admission map (or refuse the round), open the pending round and co-sign it.
async function bindAndCoSign(envelope, proposal, locked) {
- let { round, admitBlocks } = proposal;
+ let { round, digest, admitBlocks } = proposal;
let { blockHeight } = locked;
let proposedAdmit = null;
{
@@ -170,6 +170,15 @@ async function bindAndCoSign(envelope, proposal, locked) {
}
}
+ // An exact repeat of a round already open (same digest, same admission map):
+ // keep the first round's votes and timer, and skip re-opening and re-broadcasting.
+ let pending = this.pendingRounds.get(round);
+ if (pending && pending.digest === digest &&
+ this.spellAdmit(pending.admitBlocks) === this.spellAdmit(proposedAdmit)) {
+ logger.info('Oracle: ignoring duplicate PROPOSE for round ' + round + ' from ' + envelope.sender);
+ return;
+ }
+
openFollowerRound.call(this, proposal, locked, proposedAdmit);
prepareOnPropose.call(this, envelope, proposal, proposedAdmit);
}
diff --git a/src/oracle/price_aggregator/derived_capabilities.js b/src/oracle/price_aggregator/derived_capabilities.js
index 97173dcf6..5444502f1 100644
--- a/src/oracle/price_aggregator/derived_capabilities.js
+++ b/src/oracle/price_aggregator/derived_capabilities.js
@@ -20,6 +20,9 @@
*
********************************************************************/
+const hubConfig = require('../../config.js');
+const { positiveIntConfig } = require('../../lib/config_int.js');
+
// ── Derived capability snapshots ────────────────────────────────────────────
// Every capability the consensus path persists into capability_snapshots, and so
// every capability a chain-only hub must derive for itself. Row 45 built this pass
@@ -82,7 +85,11 @@ const PRICE_CAP_DERIVE_INTERVAL_S = 60;
// widen this budget. The pass walks newest-first and covers ALL capabilities at a height
// before it steps back one, so the tip (what a node following the chain needs next) is
// fully covered first and the backfill trails behind it over the following ticks.
-const PRICE_CAP_DERIVE_MAX_PER_TICK = 64;
+// HUB_PRICE_CAPABILITY_DERIVE_MAX_PER_TICK accepts a positive integer RPC-unit budget;
+// an unset or invalid value retains the 64-unit default.
+const PRICE_CAP_DERIVE_MAX_PER_TICK = positiveIntConfig(
+ hubConfig.env().HUB_PRICE_CAPABILITY_DERIVE_MAX_PER_TICK, 64,
+ 'HUB_PRICE_CAPABILITY_DERIVE_MAX_PER_TICK');
module.exports = {
DERIVED_CAPABILITIES, CAPABILITY_CONSENSUS_ENGINES,
diff --git a/src/oracle/publisher/rotation.js b/src/oracle/publisher/rotation.js
index f32413d5e..cb5788148 100644
--- a/src/oracle/publisher/rotation.js
+++ b/src/oracle/publisher/rotation.js
@@ -23,6 +23,7 @@
const nodeUtil = require('node:util');
const { getLogger } = require('../../observability');
+const swq = require('../../consensus/stake_weighted_quorum.js');
const logger = getLogger();
module.exports = {
@@ -67,12 +68,17 @@ module.exports = {
// Primary: deterministic on-chain snapshot at blockIndex
if (this.hub.capabilitySnapshot && blockIndex !== undefined && blockIndex !== null) {
try {
- let snapshot = await this.hub.capabilitySnapshot.getSnapshot('oracle_publish', blockIndex);
+ let snapshot = await this.oraclePublishSnapshot(blockIndex);
if (snapshot && Array.isArray(snapshot.validators)) {
- this._snapshotDark = false;
- return snapshot.validators
- .map(v => String(v.pubkey).toLowerCase())
- .sort();
+ // Dedupe: weighted snapshots carry one row per (source, pubkey), and rank must see each key once.
+ let pubkeys = [...new Set(snapshot.validators.map(v => String(v.pubkey).toLowerCase()))].sort();
+ if (pubkeys.length > 0) {
+ this._snapshotDark = false;
+ return pubkeys;
+ }
+ // An empty set elects no leader on any hub, so log it as loudly as the null path.
+ this.logSnapshotDark('resolved to an empty member set at block ' + blockIndex, null);
+ return [];
}
// Snapshot resolved to null. This is the dominant dark path:
// CapabilitySnapshot.getSnapshot returns null (does NOT throw) when the
@@ -99,6 +105,17 @@ module.exports = {
return [];
},
+ // Resolve the block-pinned oracle_publish membership the leader quorum judges by:
+ // the source-keyed weight snapshot at/above STAKE_WEIGHTED_QUORUM, the count
+ // snapshot below it, twin of StateAnchorPublisher.oraclePublishSnapshot. Gated on
+ // the DEPLOYMENT network only, never a wire-supplied one.
+ // DEPLOY NOTE: upgrade fleet-wide at once; where the two sets differ, mixed hubs rank differently.
+ oraclePublishSnapshot(blockIndex) {
+ return swq.isStakeWeightedQuorumActive(Number(blockIndex), this.network)
+ ? this.hub.capabilitySnapshot.getWeightSnapshot('oracle_publish', blockIndex)
+ : this.hub.capabilitySnapshot.getSnapshot('oracle_publish', blockIndex);
+ },
+
// Fallback: build a single-validator signature locally if the round event didn't carry any.
// Only used in degenerate cases; normal operation collects sigs from consensus prepare/commit.
buildLocalSigOnly(event) {
diff --git a/src/oracle/xchain_price_source.js b/src/oracle/xchain_price_source.js
index c8c9819c8..168b0dfea 100644
--- a/src/oracle/xchain_price_source.js
+++ b/src/oracle/xchain_price_source.js
@@ -223,14 +223,17 @@ class XchainPriceSource {
}
// Final ingestion bound, applied to every value this file emits whether derived or
- // carried forward. The API sources bound their values the same way for the same
- // reason: garbage must never enter a round, and a carried-forward value read out
- // of a database is no more trusted than a fetched one.
+ // carried forward: garbage must never enter a round, and a carried-forward value read
+ // out of a database is no more trusted than a fetched one. Compare doubles, not exact
+ // decimals, so this is the same predicate the API sources, co-sign gate, aggregation
+ // and ingest apply; an exact bound admits the sub-ulp band below PRICE_MAX that
+ // parseFloat rounds onto the ceiling, and a pair every gate then refuses costs the round.
entry(price, meta) {
let value;
try {
value = bcmath.bcformat(price, 8);
- if (!bcmath.bcgt(value, '0') || !bcmath.bclt(value, String(PRICE_MAX))) {
+ let val = parseFloat(value);
+ if (!(Number.isFinite(val) && val > 0 && val < PRICE_MAX)) {
logger.warn('XchainPriceSource: abstaining from ' + XCHAIN_PAIR + ' - computed value ' +
price + ' failed the ingestion bound (0 < value < ' + PRICE_MAX + ')');
return null;
diff --git a/src/peers/hub_db/admission_height_watermark.js b/src/peers/hub_db/admission_height_watermark.js
index e9614eed8..65dbb61b2 100644
--- a/src/peers/hub_db/admission_height_watermark.js
+++ b/src/peers/hub_db/admission_height_watermark.js
@@ -23,6 +23,8 @@
const { positiveIntConfig } = require('../../lib/config_int.js');
const { ADMIT_COLUMN_CHAINS, normalizeChain, rowAdmitBlocks } = require('../../lib/admission_height.js');
const { admitMarginBlocks } = require('../../consensus/gates/mirror_admission_gate.js');
+const { DEFAULT_ORACLE_ROUND_INTERVAL_MS, DEFAULT_XDEX_ROUND_TIMEOUT_MS,
+ DEFAULT_ATTESTATION_ROUND_TIMEOUT_MS, DEFAULT_ANCHOR_ROUND_TIMEOUT_MS } = require('../../constants.js');
const hubConfig = require('../../config');
// ---------------------------------------------------------------------------
@@ -105,23 +107,24 @@ class AdmissionHeightWatermark {
// round timeout, because a view change re-arms the timeout on a round that is still
// open (CrossChainDexConsensus.js:134, :140).
let xdexTimeout = positiveIntConfig(
- hubConfig.XDEX_ROUND_TIMEOUT_MS || this.config.XDEX_ROUND_TIMEOUT_MS, 120000, 'XDEX_ROUND_TIMEOUT_MS');
+ hubConfig.XDEX_ROUND_TIMEOUT_MS || this.config.XDEX_ROUND_TIMEOUT_MS,
+ DEFAULT_XDEX_ROUND_TIMEOUT_MS, 'XDEX_ROUND_TIMEOUT_MS');
this.roundWindows = {
xdex: positiveIntConfig(
hubConfig.XDEX_ROUND_MAX_LIFETIME_MS || this.config.XDEX_ROUND_MAX_LIFETIME_MS,
xdexTimeout * 4, 'XDEX_ROUND_MAX_LIFETIME_MS'),
attest: positiveIntConfig(
hubConfig.ATTESTATION_ROUND_TIMEOUT_MS || this.config.ATTESTATION_ROUND_TIMEOUT_MS,
- 120000, 'ATTESTATION_ROUND_TIMEOUT_MS'),
+ DEFAULT_ATTESTATION_ROUND_TIMEOUT_MS, 'ATTESTATION_ROUND_TIMEOUT_MS'),
anchor: positiveIntConfig(
hubConfig.ANCHOR_ROUND_TIMEOUT_MS || this.config.ANCHOR_ROUND_TIMEOUT_MS,
- 120000, 'ANCHOR_ROUND_TIMEOUT_MS'),
+ DEFAULT_ANCHOR_ROUND_TIMEOUT_MS, 'ANCHOR_ROUND_TIMEOUT_MS'),
// A price round's terminal bound is its own cadence: the next round opens only
// once this one is finalized or skipped, so the interval is what bounds how long
// a round can hold the watermark.
price: positiveIntConfig(
hubConfig.ORACLE_ROUND_INTERVAL || this.config.ORACLE_ROUND_INTERVAL,
- 600000, 'ORACLE_ROUND_INTERVAL'),
+ DEFAULT_ORACLE_ROUND_INTERVAL_MS, 'ORACLE_ROUND_INTERVAL'),
// oracle_prices has no consensus round at all: the rows are the hub's own ingest
// of an on-chain PRICE v1 transaction, so the bound is how long an ingest may
// trail the chain it reads. Its own knob, because nothing else sizes it.
diff --git a/src/peers/hub_db_broadcaster.js b/src/peers/hub_db_broadcaster.js
index 0a0348694..1716c8b4d 100644
--- a/src/peers/hub_db_broadcaster.js
+++ b/src/peers/hub_db_broadcaster.js
@@ -234,6 +234,31 @@ class HubDbBroadcaster {
}
}
+ // Deliver the post-archive anchor_txid without replaying the consensus row.
+ // The mirror applies this frame as UPDATE-only, so a row that was not admitted
+ // before the height watermark passed remains absent and must come from REST
+ // bootstrap. This path is intentionally outside isLateFinalization because it
+ // cannot add a match or alter signed match content.
+ broadcastMatchAnchorStamp(matchId, anchorTxid) {
+ if (this.subscribers.size === 0) return;
+ if (typeof matchId !== 'string' || matchId.length === 0 ||
+ typeof anchorTxid !== 'string' || anchorTxid.length === 0) return;
+ let message;
+ try {
+ message = JSON.stringify({
+ type: 'row:anchor-stamped',
+ table: 'cross_chain_matches',
+ match_id: matchId,
+ anchor_txid: anchorTxid,
+ schema_version: HUB_SCHEMA_VERSION
+ });
+ } catch (e) {
+ logger.error(nodeUtil.format('HubDbBroadcaster: anchor-stamp serialization error:', e));
+ return;
+ }
+ for (let ws of this.subscribers) this.send(ws, message);
+ }
+
// Broadcast a reorg retraction to all subscribers so they prune their local
// price-table copies. event: { table, source_chain, from_action_index, to_action_index?, retraction_generation? }
// to_action_index is included only for a CLOSED-range (deferred) retraction so subscribers
diff --git a/src/peers/rpc_batch_guard.js b/src/peers/rpc_batch_guard.js
index 64b5e9c5f..788a60f4b 100644
--- a/src/peers/rpc_batch_guard.js
+++ b/src/peers/rpc_batch_guard.js
@@ -15,12 +15,13 @@
* XChain Hub - JSON-RPC batch-size cap
*
* express-json-rpc-router dispatches EVERY element of a batch array
- * concurrently, while the per-IP express-rate-limit in front of it charges one
- * token per HTTP REQUEST regardless of array length. So one ~100 KB body of
- * ~1,400 seventy-byte call objects amplifies into ~1,400 concurrent handlers,
- * each drawing on the shared MariaDB pool, for the price of a single token -
- * and on a keyless deploy (no HUB_API_KEY) the amplified set includes the
- * push* write rails. Cap the CARDINALITY before dispatch.
+ * concurrently. The per-IP limiter in front of it once charged one token per
+ * HTTP REQUEST, so one ~100 KB body of ~1,400 seventy-byte call objects
+ * amplified into ~1,400 concurrent handlers, each drawing on the shared
+ * MariaDB pool, for a single token - and on a keyless deploy (no HUB_API_KEY)
+ * the amplified set includes the push* write rails. The limiter now charges
+ * per call (src/api/rate_limit_tiers.js); this still caps the CARDINALITY
+ * before dispatch, so no single request can fan out that far.
*
* Lives in its own module rather than inline in api.js because the hub's whole
* middleware stack is built inside async function startApi(), which exports
diff --git a/src/sql/oracle_prices.sql b/src/sql/oracle_prices.sql
index e071e12b9..be838ef28 100644
--- a/src/sql/oracle_prices.sql
+++ b/src/sql/oracle_prices.sql
@@ -3,7 +3,7 @@ CREATE TABLE oracle_prices (
source_address VARCHAR(100) NOT NULL, -- oracle operator's address (the SOURCE of the PRICE v1 tx)
source_chain VARCHAR(10) NOT NULL, -- chain on which the PRICE v1 tx was published
coin VARCHAR(10) NOT NULL, -- which chain's token this oracle is for (BTC/LTC/DOGE)
- tick VARCHAR(50) NOT NULL, -- token name (e.g. PEPECASH)
+ tick VARCHAR(250) NOT NULL, -- token name (e.g. PEPECASH); PRICE v1 admits up to MAX_TICK_LENGTH
fiat VARCHAR(10) NOT NULL, -- fiat currency code (e.g. USD, JPY)
value VARCHAR(250) NOT NULL, -- price value as decimal string
fee VARCHAR(250), -- oracle usage fee as decimal (e.g. 0.01 = 1%)
diff --git a/src/sql/price_snapshots.sql b/src/sql/price_snapshots.sql
index d7a8c12f9..327e5d13f 100644
--- a/src/sql/price_snapshots.sql
+++ b/src/sql/price_snapshots.sql
@@ -27,6 +27,17 @@ CREATE TABLE price_snapshots (
KEY idx_pair_block (coin_pair, reference_block),
KEY idx_pair_timestamp (coin_pair, block_timestamp),
KEY idx_status (status),
+ -- The finalized-frontier reads lead on status and take MAX() of one column, so each
+ -- needs an index that carries that column under status or MariaDB scans every row
+ -- (measured 2026-09-23 on the regtest rail: 1.17M rows, 1.2 to 1.4 s per read, four
+ -- readers polling, the hub's database at 60 to 80 percent CPU). The first two are
+ -- byte-equal to the indexer and explorer mirror twins (their hub_db_sync price
+ -- barrier runs both MAX() reads against THIS table over HUB_DB_*); the third serves
+ -- the hub's own finalized-age read (getPriceSnapshotsFinalizedAgeSeconds). Existing
+ -- tables get them from runMigrations (alterTableForDrift never touches indexes).
+ KEY idx_status_block_round (status, reference_block, round_number),
+ KEY idx_status_timestamp_round (status, block_timestamp, round_number),
+ KEY idx_status_created (status, created_at),
KEY idx_source_chain (source_chain),
-- The landing stamp is written by round_number and read by (coin_pair, landing
-- clock, round_number) on the indexer side; keep the hub copy indexed the same way
diff --git a/src/validators/governance/bounds.js b/src/validators/governance/bounds.js
index 63f23fba5..9061c6f2d 100644
--- a/src/validators/governance/bounds.js
+++ b/src/validators/governance/bounds.js
@@ -29,6 +29,12 @@ const { ORACLE_DEVIATION_THRESHOLD } = require('../../constants.js');
const { SLASHING_PARAMS, MAX_INCREASE, MAX_DECREASE, MAX_SLASH_INCREASE, MAX_SLASH_DECREASE,
parseDecimalParts, toScaledBigInt } = require('./rules.js');
+// Bound a proposed value echoed into an error, since the inbound path logs the message.
+function echoValue(v) {
+ let s = String(v);
+ return s.length > 64 ? s.slice(0, 64) + '...' : s;
+}
+
module.exports = {
validateChangeBounds(parameter, currentValue, proposedValue) {
@@ -38,24 +44,27 @@ module.exports = {
this.validateSlashBandFloor(parameter, proposedValue);
},
- // Absolute floor under the slash band, mirroring the guard SlashDetector's
- // constructor already hard-enforces (SlashDetector.js): a slash band tighter than
- // the federation-uniform oracle co-sign band would slash submissions inside the
- // band the federation just co-signed. validateChangeRatio caps only the SIZE of a
- // change, so from the 0.05 default a -20% proposal (0.04) cleared every gate,
- // and applying the approved value then bricked the hub at its next restart.
+ // Absolute floor under the slash band, mirroring resolveDeviationThreshold in
+ // slash_detector/options.js: a slash band tighter than the federation-uniform
+ // oracle co-sign band would slash submissions inside the band the federation just
+ // co-signed, and a non-finite band slashes every submission. That guard reads the
+ // value on presence and throws on a non-finite band and on anything below the
+ // oracle band, an explicit 0 included, so approving either would brick the hub at
+ // its next restart; the floor refuses the same values.
// Sits outside validateChangeRatio's numeric early-returns so a non-numeric or
- // zero CURRENT value cannot skip it. A proposed 0 is refused here although
- // SlashDetector's `parseFloat(...) || DEFAULT` would fall back to the band: the
- // ratio bound already refuses it, and refusing is the safe direction.
+ // zero CURRENT value cannot skip it.
// DEPLOY NOTE: same mixed-version caveat as the follower-path bounds re-check in
- // handlePropose - fixed hubs drop a sub-floor Byzantine proposal that unfixed
- // hubs still record. Honest proposals always clear the floor, so honest traffic
- // never diverges.
+ // handlePropose - fixed hubs drop a sub-floor or non-decimal Byzantine proposal
+ // that unfixed hubs still record. Honest proposals are plain decimals that clear
+ // the floor, so honest traffic never diverges.
validateSlashBandFloor(parameter, proposedValue) {
if (parameter !== 'SLASH_DEVIATION_THRESHOLD') return;
let band = parseFloat(proposedValue);
- if (!Number.isFinite(band) || band >= ORACLE_DEVIATION_THRESHOLD) return;
+ if (!Number.isFinite(band)) {
+ throw new Error('SLASH_DEVIATION_THRESHOLD (' + echoValue(proposedValue) + ') is not a ' +
+ 'valid number, and SlashDetector refuses to construct on it.');
+ }
+ if (band >= ORACLE_DEVIATION_THRESHOLD) return;
throw new Error('SLASH_DEVIATION_THRESHOLD (' + band + ') is below the federation-uniform ' +
'ORACLE_DEVIATION_THRESHOLD (' + ORACLE_DEVIATION_THRESHOLD + '): this would slash ' +
'submissions inside the co-signed band, and SlashDetector refuses to construct on it.');
@@ -65,6 +74,12 @@ module.exports = {
// Only validate numeric parameters
let cur = parseDecimalParts(currentValue);
let prop = parseDecimalParts(proposedValue);
+ let isSlashParam = SLASHING_PARAMS.includes(parameter);
+ // Refuse a non-decimal slash value, or exponent and junk-suffix forms skip the bound.
+ if (isSlashParam && !prop) {
+ throw new Error(parameter + ' (' + echoValue(proposedValue) + ') must be a plain decimal: ' +
+ 'no exponent notation, trailing text, NaN or Infinity.');
+ }
if (!cur || !prop) return;
let scale = Math.max(cur.frac.length, prop.frac.length);
@@ -72,7 +87,6 @@ module.exports = {
let P = toScaledBigInt(prop, scale);
if (C === 0n) return;
- let isSlashParam = SLASHING_PARAMS.includes(parameter);
let maxIncrease = isSlashParam ? MAX_SLASH_INCREASE : MAX_INCREASE;
let maxDecrease = isSlashParam ? MAX_SLASH_DECREASE : MAX_DECREASE;
diff --git a/src/validators/governance/propose_inbound.js b/src/validators/governance/propose_inbound.js
index a5819df8f..1f21364d5 100644
--- a/src/validators/governance/propose_inbound.js
+++ b/src/validators/governance/propose_inbound.js
@@ -189,8 +189,9 @@ module.exports = {
// propose(); without this, every hub records and votes on an out-of-bounds
// change. Drop it (never record it) so the whole federation ignores it,
// matching the MIN_STAKE and block-anchor drops above. validateChangeBounds
- // is a no-op for non-numeric parameters, so only numeric out-of-bounds
- // proposals are affected.
+ // drops a non-decimal value for a slashing parameter and skips one for any
+ // other parameter, so only numeric out-of-bounds proposals and non-decimal
+ // slashing proposals are affected.
//
// DEPLOY NOTE: enforce fleet-wide in one coordinated upgrade. During a
// mixed-version window, fixed hubs drop an out-of-bounds Byzantine proposal
diff --git a/test/fixtures/attest_batch_v0190_replay.json b/test/fixtures/attest_batch_v0190_replay.json
new file mode 100644
index 000000000..47e11e1cc
--- /dev/null
+++ b/test/fixtures/attest_batch_v0190_replay.json
@@ -0,0 +1,10 @@
+{
+ "note": "An ATTEST v5 batch head as a v0.19.0 hub built and signed it: encoded and signed by the v0.19.0 tag's src/actions/attest/attest_batch_wire.js (byte twin of the hub copy), on testnet, anchored at BTC 151400, after the batch rail armed (151324) and below the mirror admission producer activation. Its rows carry no admit_block_btc, as no batch on chain before that activation does. The signer is a fixed test key derived from a sha256 seed, so the fixture is reproducible. Regenerate only from a tag whose wire predates the admission field.",
+ "generated_with": "v0.19.0:src/actions/attest/attest_batch_wire.js",
+ "network": "testnet",
+ "btc_block_height": 151400,
+ "batch_key": "7fff8ea39b4aec2c7896104e788e698800849d3002066012fad3d780238e9596",
+ "signer_pubkey": "c3d7ee883b2925605fe6408a5e24f061f4ab7594bbafd54d07aeedc8d4130d02",
+ "signed_canonical": "{\"network\":\"testnet\",\"window_start\":1789203600,\"window_end\":1789207200,\"row_count\":2,\"btc_block_height\":151400,\"rows\":[{\"network\":\"testnet\",\"request_id\":\"de188666ba195196ceb08114ca5ff1132e9a1a2575c7f14d74a1a35e04246500\",\"request_action_index\":4400,\"request_block_index\":151380,\"provider_id\":\"http_get\",\"status\":\"ok\",\"response_payload\":\"{\\\"ok\\\":true,\\\"n\\\":0}\",\"response_hash\":\"4d28f9d296c5af5c7eeb93836dbd355000609fce440f4d4a3661069aeb319857\",\"meta\":\"200\",\"effective_time\":1789203600,\"signer_pubkeys\":\"[\\\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\\\"]\",\"signatures\":\"[{\\\"pubkey\\\":\\\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\\\",\\\"sig\\\":\\\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\\\"}]\",\"widen\":0},{\"network\":\"testnet\",\"request_id\":\"dafa727ebea92905b6c81dd47f488cf6d90ab1b744dde2254cb2c6adb912ba88\",\"request_action_index\":4401,\"request_block_index\":151381,\"provider_id\":\"http_get\",\"status\":\"ok\",\"response_payload\":\"{\\\"ok\\\":true,\\\"n\\\":1}\",\"response_hash\":\"27e1acbfee8890972e9a9edc748155a6c715a61f4c47c8f1bfc8be31795505f8\",\"meta\":\"200\",\"effective_time\":1789203660,\"signer_pubkeys\":\"[\\\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\\\"]\",\"signatures\":\"[{\\\"pubkey\\\":\\\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\\\",\\\"sig\\\":\\\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\\\"}]\",\"widen\":0}]}",
+ "wire": "ATTEST|5|7fff8ea39b4aec2c7896104e788e698800849d3002066012fad3d780238e9596|testnet|1789203600|1789207200|2|151400|22d8d6e8|1|7VTLjtxGDPwXnufQ7HfrV6yFwCbZO8LY0kTq8cZY7L8bmlkHDmAYBuIgl/BIEsViV7FfYdH+sm4XGKDr3hftcIKXeZH1Zdo7bR0GTLlY46Ixf1V0kW/5ZI/8tr5MvN6WDoM9Qe081Y8rX6azzs/nAyOgf+/bYfjw+sOxm/5x071Ps8AAophzjLESloAlslaTET1TaA3RWS2EZEMKnBp6SZ6QXFDjrY/BmO/wiPu8LtO8iP4Jg38Qea89aL6XMKDL5gTXbf08i24PJufer9PzneHeqd92GGC93PH367rsOl3py8eVjt7XEdbLCEPfbnoaYRlhMG/ft55pP8MAXmxuRWyJHKgFTqq1uOyiVHEhGGOiKY3Ve9O8eHIxoomFtDosOSQ4wSftBAPY+6ramnKfP+vU50/6d832+XnRbbre6kW/HOQ/jFD/YYzwBA9k6rdN76ivIzxmjDD8jgmn8RhwB+P/OEZ4e7rfhehyKHr6FQNTo2STVqViiwk1ckYRn5rPmVuUYqhiTd6LqLXBc7UcSWpBWynnnxkYf2Zg/M0Gxh8Z2CZF4tpUcy6mpOMciwonnzEEipwwUMTm2SfODWvjXNVhKiGY0PKvGjj+b+B/w8BP990fX/FjZxiAnaRDT1dtsSGa0DR6kymo9c0cYlJNofhaqUnwYhKpCmfx6IwY+3jQQ1KLEQnFZS4p52bEFcPsqsmmFIfZs3PBco6peZt8jVZMsBxTtQ6pNMNFBVurpI6DaKkpOI+imsRxIYM+t+qLj6V6y9hYrMUUlbGiYXh7evsK"
+}
diff --git a/test/helpers/anchor_mesh.js b/test/helpers/anchor_mesh.js
index 765f2664b..39c22a13f 100644
--- a/test/helpers/anchor_mesh.js
+++ b/test/helpers/anchor_mesh.js
@@ -201,6 +201,13 @@ function applyStakeWeights(bus, recordNetwork) {
}
}
+// Arm CHECKPOINT_COMMITMENT for the bundle selector's floor on THIS mesh's publishers
+// only, as an own-property override of sectionCommitmentActive that stopMeshes deletes.
+// The shared gate registry is never touched, so no other case or canonical sees it.
+function armCommitmentFloor(bus) {
+ for (let nd of bus.nodes) nd.pub.sectionCommitmentActive = () => true;
+}
+
// n publishers over a shared gossip bus. Every node shares identical DB
// contents unless opts.mutate(self) tweaks its copy (divergence tests).
function buildMesh(n, opts) {
@@ -222,6 +229,8 @@ function buildMesh(n, opts) {
// snapshot-block-gated rule - EQUIV, checkpoint-commitment, royalty, the anchor/
// archive reward flag-days - also activates at >=961000 on mainnet, so a block-100
// mainnet record sits on the fully-legacy, headerless path the count assertions expect).
+ // The bundle selector skips a section below CHECKPOINT_COMMITMENT, so a count-path
+ // case that needs its v0 bundle also passes checkpointCommitment:true (armCommitmentFloor).
let recordNetwork = opts.network || CP_ROW.network;
bus.network = recordNetwork;
let identities = [];
@@ -249,6 +258,7 @@ function buildMesh(n, opts) {
bus.nodes.push(self);
}
if (opts.stakeWeighted) applyStakeWeights(bus, recordNetwork);
+ if (opts.checkpointCommitment) armCommitmentFloor(bus);
buses.push(bus);
return bus;
}
@@ -301,9 +311,15 @@ function rewardRow(pk, over) {
}, over || {});
}
-// Stop every publisher on every mesh built since the last call, then forget them.
+// Stop every publisher on every mesh built since the last call, drop any
+// commitment-floor override so the prototype's gate read is back, then forget them.
async function stopMeshes() {
- for (let bus of buses) { for (let nd of bus.nodes) await nd.pub.stop(); }
+ for (let bus of buses) {
+ for (let nd of bus.nodes) {
+ await nd.pub.stop();
+ delete nd.pub.sectionCommitmentActive;
+ }
+ }
buses = [];
}
diff --git a/test/helpers/armAdmission.js b/test/helpers/armAdmission.js
new file mode 100644
index 000000000..146dd2d90
--- /dev/null
+++ b/test/helpers/armAdmission.js
@@ -0,0 +1,27 @@
+'use strict';
+
+// Load a set of modules under an admission activation without leaking either the
+// require-cache entries or the environment override into the rest of the test run.
+// moduleIds are require.resolve() results from the caller so relative paths keep the
+// caller's resolution context. Loaded exports remain usable after their cache entries
+// are restored because they retain the dependencies they closed over at load time.
+function armAdmission(height, moduleIds, load) {
+ const paths = [...new Set(moduleIds)];
+ const saved = paths.map(path => [path, require.cache[path]]);
+ const savedEnv = process.env.XC_MIRROR_ADMISSION_ACTIVATION;
+
+ try {
+ for (const path of paths) delete require.cache[path];
+ process.env.XC_MIRROR_ADMISSION_ACTIVATION = String(height);
+ return load();
+ } finally {
+ for (const [path, mod] of saved) {
+ if (mod === undefined) delete require.cache[path];
+ else require.cache[path] = mod;
+ }
+ if (savedEnv === undefined) delete process.env.XC_MIRROR_ADMISSION_ACTIVATION;
+ else process.env.XC_MIRROR_ADMISSION_ACTIVATION = savedEnv;
+ }
+}
+
+module.exports = armAdmission;
diff --git a/test/unit/activations/anchor_reward_activation_parity.test.js b/test/unit/activations/anchor_reward_activation_parity.test.js
index d91fd8e7d..649b0f802 100644
--- a/test/unit/activations/anchor_reward_activation_parity.test.js
+++ b/test/unit/activations/anchor_reward_activation_parity.test.js
@@ -174,10 +174,10 @@ it('holds an INERT network inert at height 0 and at a huge height', function ()
function registerBarrierCutTests() {
it('arms the testnet barrier horizon exactly at its sized height, never a block early', function () {
expect(local.ANCHOR_ATTEST_BARRIER_ACTIVATION.testnet,
- 'not vacuous: the sized testnet height this case is about must still be in the map').to.equal(153266);
- expect(local.isAnchorAttestBarrierHorizonActive('testnet', 153265),
+ 'not vacuous: the sized testnet height this case is about must still be in the map').to.equal(154291);
+ expect(local.isAnchorAttestBarrierHorizonActive('testnet', 154290),
'the horizon armed a block BELOW its sized height').to.equal(false);
- expect(local.isAnchorAttestBarrierHorizonActive('testnet', 153266),
+ expect(local.isAnchorAttestBarrierHorizonActive('testnet', 154291),
'the horizon is not armed AT its own sized height; the comparison must be >=, not >').to.equal(true);
// The same `0 >= threshold` direction the inert case guards, but on a LIVE height: a
// guard keyed on null alone would arm genesis here and no inert case would catch it.
diff --git a/test/unit/anchor/publisher/admission_ttl_clamp.test.js b/test/unit/anchor/publisher/admission_ttl_clamp.test.js
new file mode 100644
index 000000000..e70720fdf
--- /dev/null
+++ b/test/unit/anchor/publisher/admission_ttl_clamp.test.js
@@ -0,0 +1,35 @@
+'use strict';
+
+// Copyright (c) 2025-2026 Dankest, LLC
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+const { expect } = require('chai');
+const StateAnchorPublisher = require('../../../../src/anchor/publisher');
+const {
+ ANCHOR_ATTEST_ARRIVAL_MARGIN_S
+} = require('../../../../src/consensus/gates/anchor_reward_gate.js');
+
+const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
+const CHECKPOINT_AGE_MS = 6 * 10 * 60 * 1000;
+const RAW_STAMP_SKEW_MS = 2 * 60 * 60 * 1000;
+
+describe('anchor publisher admission TTL clamps', function () {
+ it('does not let raised TTLs push the anchor-attest trail past its arrival budget', function () {
+ const raisedTtlMs = 24 * 60 * 60 * 1000;
+ const pub = new StateAnchorPublisher({
+ db: {},
+ p2pConfig: {
+ DOGE_ADDRESS: 'Dpub1',
+ ANCHOR_ANNOUNCE_RETRY_TTL_MS: String(raisedTtlMs),
+ ANCHOR_INTENT_TTL_MS: String(raisedTtlMs)
+ }
+ });
+
+ expect(pub.announceRetryTtlMs).to.equal(SIX_HOURS_MS);
+ expect(pub.anchorIntentTtlMs).to.equal(SIX_HOURS_MS);
+
+ const worstCaseTrailMs = CHECKPOINT_AGE_MS + pub.anchorIntentTtlMs +
+ pub.announceRetryTtlMs + RAW_STAMP_SKEW_MS;
+ expect(worstCaseTrailMs).to.be.at.most(ANCHOR_ATTEST_ARRIVAL_MARGIN_S * 1000);
+ });
+});
diff --git a/test/unit/anchor/publisher/state_anchor_publisher.test.js b/test/unit/anchor/publisher/state_anchor_publisher.test.js
index e52c8a7b8..2526939a8 100644
--- a/test/unit/anchor/publisher/state_anchor_publisher.test.js
+++ b/test/unit/anchor/publisher/state_anchor_publisher.test.js
@@ -53,7 +53,8 @@ function registerBundleWireCases() {
it('single-node: flush publishes v0 + v1, archive round-trips, batch back-filled', async function () {
// Count-path archive mechanics: a mainnet record below the 961000 SWQ activation
// takes the legacy count snapshot the getSnapshot stub serves (weighted has its own suite).
- let bus = buildMesh(1, { network: 'mainnet' });
+ // checkpointCommitment arms the bundle floor so the block-100 section still rides.
+ let bus = buildMesh(1, { network: 'mainnet', checkpointCommitment: true });
let nd = bus.nodes[0];
await startAll(bus);
await nd.pub.flush();
@@ -158,7 +159,8 @@ function registerArchiveTxidCases() {
// broadcast success ({ txid: null }) must NOT dequeue the rows with their final
// status (which would strand them in an unrecoverable hole) and must NOT credit
// the anchor_archive reward for an anchor that never landed on-chain.
- let bus = buildMesh(1, { network: 'mainnet' }); // count-path archive mechanics (SWQ off below 961000)
+ // Count-path archive mechanics (SWQ off below 961000); the armed bundle floor lets the v0 ride.
+ let bus = buildMesh(1, { network: 'mainnet', checkpointCommitment: true });
let nd = bus.nodes[0];
// The v0 bundle still gets a txid; only the v1 archive broadcast returns none.
nd.pub.setBroadcastHook(async (payload) => {
diff --git a/test/unit/anchor/publisher/state_anchor_publisher_bundle_election.test.js b/test/unit/anchor/publisher/state_anchor_publisher_bundle_election.test.js
index e272d864d..244bb03e2 100644
--- a/test/unit/anchor/publisher/state_anchor_publisher_bundle_election.test.js
+++ b/test/unit/anchor/publisher/state_anchor_publisher_bundle_election.test.js
@@ -36,7 +36,8 @@ function registerMeshElectionCase() {
it('N=4: per-row v0 election + archive leader; v1 carries 2f+1 sigs; back-fill propagates', async function () {
// Count-path: mainnet record below the 961000 SWQ activation, so the archive
// quorum stays legacy 2f+1 (the weighted path has its own dedicated suite below).
- let bus = buildMesh(4, { btcBlock: 101, network: 'mainnet' });
+ // checkpointCommitment arms the bundle floor so the block-100 section still rides.
+ let bus = buildMesh(4, { btcBlock: 101, network: 'mainnet', checkpointCommitment: true });
await startAll(bus);
let v0Pub = v0Order(bus)[0]; // elected for the BTC checkpoint
let leader = archiveLeader(bus); // elected archive leader
diff --git a/test/unit/anchor/publisher/state_anchor_publisher_commitment_floor.test.js b/test/unit/anchor/publisher/state_anchor_publisher_commitment_floor.test.js
new file mode 100644
index 000000000..13f2dbf08
--- /dev/null
+++ b/test/unit/anchor/publisher/state_anchor_publisher_commitment_floor.test.js
@@ -0,0 +1,93 @@
+'use strict';
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+//
+// The ANCHOR v0 bundle selector's CHECKPOINT_COMMITMENT floor: a root-bearing row
+// below the flag-day at its own snapshot_block never rides a bundle, because the
+// hub signs it rootless while the indexer rebuilds every v0 section with the root
+// suffix. Built on testnet, whose flag-day (146000) sits above genesis; regtest
+// arms it at 0, where the below-gate case cannot exist.
+
+const { expect } = require('chai');
+const StateAnchorPublisher = require('../../../../src/anchor/publisher');
+const StateCheckpointEngine = require('../../../../src/anchor/checkpoint_engine');
+const { CP_ROW, buildMesh, stopMeshes } = require('../../../helpers/anchor_mesh.js');
+
+const BELOW = 145500;
+const AT = 146000;
+
+function testnetRow(chain, snapshotBlock, over) {
+ return Object.assign({}, CP_ROW, { chain: chain, network: 'testnet', snapshot_block: snapshotBlock,
+ checkpoint_seq: snapshotBlock, anchor_txid: null }, over || {});
+}
+
+function group(rows) {
+ let pub = new StateAnchorPublisher({ db: {}, p2pConfig: {} });
+ return pub.groupSectionsByNetwork(rows);
+}
+
+describe('StateAnchorPublisher bundle selector CHECKPOINT_COMMITMENT floor', function () {
+ registerFloorCases();
+ registerCanonicalParityCase();
+ registerMeshOverrideScopeCase();
+});
+
+// The floor itself: below-gate dropped, at-gate kept, the rootless skip unchanged.
+function registerFloorCases() {
+ it('drops a root-bearing testnet row at 145500 and keeps the same row at 146000', function () {
+ expect(group([testnetRow('BTC', BELOW)]).size, 'below the flag-day: no bundle').to.equal(0);
+ let kept = group([testnetRow('BTC', AT)]);
+ expect(kept.size).to.equal(1);
+ expect(kept.get('testnet').map(r => r.snapshot_block)).to.deep.equal([AT]);
+ });
+
+ it('drops only the below-gate section when a bundle mixes both', function () {
+ let out = group([testnetRow('BTC', AT), testnetRow('LTC', BELOW), testnetRow('DOGE', AT)]);
+ expect(out.get('testnet').map(r => r.chain)).to.deep.equal(['BTC', 'DOGE']);
+ });
+
+ it('still drops a root-null row at/above the flag-day (the rootless skip is kept)', function () {
+ expect(group([testnetRow('BTC', AT, { state_root: null })]).size).to.equal(0);
+ });
+
+ it('keeps regtest rows, whose flag-day is genesis', function () {
+ let out = group([Object.assign({}, CP_ROW, { anchor_txid: null })]);
+ expect(out.get('regtest').length).to.equal(1);
+ });
+}
+
+// The floor sits exactly where the hub's signed canonical loses its root suffix.
+function registerCanonicalParityCase() {
+ it('drops a row exactly when the hub canonical omits the root suffix', function () {
+ let rootTail = '|' + CP_ROW.state_root + '|1|' + CP_ROW.block_merkle_root + '|1';
+ let below = testnetRow('BTC', BELOW), at = testnetRow('BTC', AT);
+ expect(StateCheckpointEngine.canonicalCheckpoint(below).endsWith(rootTail), 'below: signed rootless').to.equal(false);
+ expect(StateCheckpointEngine.canonicalCheckpoint(at).endsWith(rootTail), 'at: signed with roots').to.equal(true);
+ expect(StateCheckpointEngine.isCheckpointCommitmentActive(below)).to.equal(false);
+ expect(StateCheckpointEngine.isCheckpointCommitmentActive(at)).to.equal(true);
+ });
+}
+
+// The mesh harness override is per-instance and gone once the mesh stops.
+function registerMeshOverrideScopeCase() {
+ afterEach(stopMeshes);
+
+ it('arms the floor only on the mesh that asked, and stopMeshes restores the gate read', async function () {
+ let armed = buildMesh(1, { network: 'mainnet', checkpointCommitment: true }).nodes[0].pub;
+ let plain = buildMesh(1, { network: 'mainnet' }).nodes[0].pub;
+ let row = Object.assign({}, CP_ROW, { network: 'mainnet', anchor_txid: null });
+ expect(armed.groupSectionsByNetwork([row]).size, 'armed mesh keeps the block-100 row').to.equal(1);
+ expect(plain.groupSectionsByNetwork([row]).size, 'an unarmed mesh applies the real gate').to.equal(0);
+ await stopMeshes();
+ expect(Object.prototype.hasOwnProperty.call(armed, 'sectionCommitmentActive')).to.equal(false);
+ expect(armed.groupSectionsByNetwork([row]).size, 'after stop the real gate applies').to.equal(0);
+ });
+}
diff --git a/test/unit/anchor/publisher/state_anchor_publisher_finalized_binding.test.js b/test/unit/anchor/publisher/state_anchor_publisher_finalized_binding.test.js
index 126ba5ef8..3fbac09ab 100644
--- a/test/unit/anchor/publisher/state_anchor_publisher_finalized_binding.test.js
+++ b/test/unit/anchor/publisher/state_anchor_publisher_finalized_binding.test.js
@@ -17,6 +17,8 @@
const { expect } = require('chai');
const { CP_ROW, matchRow, buildMesh, registerMeshHooks } = require('../../../helpers/anchor_mesh.js');
+const HubDbBroadcaster = require('../../../../src/peers/hub_db_broadcaster.js');
+const { admitMarginBlocks } = require('../../../../src/consensus/gates/mirror_admission_gate.js');
describe('StateAnchorPublisher', function () {
registerMeshHooks();
@@ -26,6 +28,7 @@ describe('StateAnchorPublisher', function () {
registerRewardListGateCases();
registerBackfillStampCase();
registerBackfillBroadcastCases();
+ registerLateWindowStampCase();
});
// ── XANC-ELECTED-FORGE-1 (archive half): bind the v1 archive txid ────────────
@@ -187,27 +190,29 @@ function registerBackfillBroadcastCases() {
let bus = buildMesh(1, { matches: [matchRow('m1'), matchRow('m2', 'retracted')] });
let nd = bus.nodes[0];
let broadcast = [];
- nd.pub.hub.hubDbBroadcaster = { broadcastRow: (ev) => broadcast.push(ev) };
+ nd.pub.hub.hubDbBroadcaster = {
+ broadcastMatchAnchorStamp: (matchId, anchorTxid) => broadcast.push({ matchId, anchorTxid })
+ };
await nd.pub.backfillBatch(0,
[{ match_id: 'm1', status: 'finalized' }, { match_id: 'm2', status: 'retracted' }],
'dogetx_rebroadcast', [], []);
- // Only the non-retracted row re-emits (the stream already deleted retracted
- // rows on mirrors; re-inserting one would diverge them), and it carries the
- // freshly stamped anchor_txid plus the full signed content.
+ // Only the non-retracted row emits a metadata-only update. A full-row replay
+ // would either be refused after the admission window or admit a missing row
+ // below the certified watermark.
expect(broadcast.length, 'exactly one re-broadcast').to.equal(1);
- expect(broadcast[0].table).to.equal('cross_chain_matches');
- expect(broadcast[0].row.match_id).to.equal('m1');
- expect(broadcast[0].row.anchor_txid).to.equal('dogetx_rebroadcast');
- expect(broadcast[0].row.a_amount, 'full row, not a partial patch').to.equal('1000');
+ expect(broadcast[0].matchId).to.equal('m1');
+ expect(broadcast[0].anchorTxid).to.equal('dogetx_rebroadcast');
});
it('backfillBatch does NOT re-broadcast on a null txid or without a broadcaster', async function () {
let bus = buildMesh(1);
let nd = bus.nodes[0];
let broadcast = [];
- nd.pub.hub.hubDbBroadcaster = { broadcastRow: (ev) => broadcast.push(ev) };
+ nd.pub.hub.hubDbBroadcaster = {
+ broadcastMatchAnchorStamp: (matchId, anchorTxid) => broadcast.push({ matchId, anchorTxid })
+ };
// Null txid = the archive never landed on-chain (rows stay pending); there is
// no stamp to propagate, so the feed stays quiet.
@@ -221,3 +226,35 @@ function registerBackfillBroadcastCases() {
expect(nd.db.matches.find(m => m.match_id === 'm1').anchor_txid, 'back-fill still applied').to.equal('dogetx_x');
});
}
+
+// A stamp raised after the admission window still reaches the mirror, because it
+// rides the metadata-only frame rather than a full-row admission.
+function registerLateWindowStampCase() {
+ it('backfillBatch delivers an anchor stamp after the match admission window closes', async function () {
+ let margin = admitMarginBlocks('cross_chain_matches');
+ let lateMatch = Object.assign(matchRow('m1'), { admit_block_btc: 899999 + margin });
+ let bus = buildMesh(1, { matches: [lateMatch] });
+ let nd = bus.nodes[0];
+ let broadcaster = new HubDbBroadcaster({}, nd.db);
+ broadcaster.admissionWatermark.observeTip('BTC', 900000, Date.now() - 600000);
+ let sent = [];
+ broadcaster.subscribers.add({
+ readyState: 1, bufferedAmount: 0, _hubBuffered: 0,
+ send: (raw) => sent.push(JSON.parse(raw)), close: () => {}
+ });
+ nd.pub.hub.hubDbBroadcaster = broadcaster;
+
+ try {
+ await nd.pub.backfillBatch(0, [{ match_id: 'm1', status: 'finalized' }],
+ 'dogetx_after_window', [], []);
+ expect(sent.length, 'the streaming mirror receives the late anchor stamp').to.equal(1);
+ expect(sent[0]).to.deep.include({
+ type: 'row:anchor-stamped', table: 'cross_chain_matches',
+ match_id: 'm1', anchor_txid: 'dogetx_after_window'
+ });
+ expect(sent[0]).to.not.have.property('row');
+ } finally {
+ broadcaster.stop();
+ }
+ });
+}
diff --git a/test/unit/anchor/publisher/state_anchor_publisher_flush_retry.test.js b/test/unit/anchor/publisher/state_anchor_publisher_flush_retry.test.js
index 88e0190bc..a3c4ba01e 100644
--- a/test/unit/anchor/publisher/state_anchor_publisher_flush_retry.test.js
+++ b/test/unit/anchor/publisher/state_anchor_publisher_flush_retry.test.js
@@ -35,7 +35,8 @@ function registerChunkingAndSummaryCases() {
it('oversized archive splits into v1 + v2 chunks that reassemble byte-identically', async function () {
let many = [];
for (let i = 0; i < 40; i++) many.push(matchRow('m' + String(i).padStart(3, '0')));
- let bus = buildMesh(1, { network: 'mainnet', matches: many, cfg: { ANCHOR_CHUNK_MAX_BYTES: '500' } }); // count-path chunking mechanics (SWQ off below 961000)
+ // Count-path chunking mechanics (SWQ off below 961000); the armed bundle floor lets the v0 ride.
+ let bus = buildMesh(1, { network: 'mainnet', checkpointCommitment: true, matches: many, cfg: { ANCHOR_CHUNK_MAX_BYTES: '500' } });
let nd = bus.nodes[0];
await startAll(bus);
await nd.pub.flush();
diff --git a/test/unit/anchor/reward_tracker_amount_string.test.js b/test/unit/anchor/reward_tracker_amount_string.test.js
new file mode 100644
index 000000000..a7e9ccdd8
--- /dev/null
+++ b/test/unit/anchor/reward_tracker_amount_string.test.js
@@ -0,0 +1,72 @@
+'use strict';
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+// The anchor reward amount is a decimal string end to end: the recorded row carries a
+// frozen consensus constant byte-for-byte (the indexer credits and signs it verbatim),
+// and the archive co-sign expectation agrees with the recorder on both branches.
+//
+// The frozen constant is respelled to '12.5' here on purpose: '10.00000000' survives a
+// float round-trip unchanged, so with the shipped spelling these assertions prove nothing.
+
+const { expect } = require('chai');
+const ar = require('../../../src/consensus/gates/anchor_reward_gate.js');
+const RewardTracker = require('../../../src/anchor/reward_tracker');
+const StateAnchorPublisher = require('../../../src/anchor/publisher');
+const { createMockHub } = require('../../helpers/mockHub');
+
+const PK = 'ab'.repeat(32);
+const NETWORK = 'testnet'; // every anchor-reward flag-day is 0 on testnet
+const BLOCK = 970000;
+
+function expectedOnArchive(rewardType, knob) {
+ const self = { hub: { rewardTracker: { anchorReward: knob } } };
+ return StateAnchorPublisher.prototype.expectedArchivedRewardAmount.call(
+ self, { reward_type: rewardType, block_index: BLOCK }, NETWORK);
+}
+
+async function recordedAmount(rewardType, knob, network) {
+ const rt = new RewardTracker(createMockHub({ p2pConfig: { ANCHOR_REWARD_PER_PUBLISH: knob } }));
+ await rt.recordAnchorReward(rewardType, 5, PK, BLOCK, network);
+ const insert = rt.db.doQuery.getCalls().find(c => String(c.args[0]).includes('INSERT IGNORE INTO validator_rewards'));
+ return insert ? insert.args[1][3] : null;
+}
+
+describe('RewardTracker anchor reward amount (decimal string)', function () {
+ let savedAnchor, savedArchive;
+ beforeEach(function () {
+ savedAnchor = ar.ANCHOR_REWARD_AMOUNT;
+ savedArchive = ar.ARCHIVE_REWARD_AMOUNT;
+ ar.ANCHOR_REWARD_AMOUNT = '12.5';
+ ar.ARCHIVE_REWARD_AMOUNT = '12.5';
+ });
+ afterEach(function () {
+ ar.ANCHOR_REWARD_AMOUNT = savedAnchor;
+ ar.ARCHIVE_REWARD_AMOUNT = savedArchive;
+ });
+
+ for (const rewardType of ['anchor_BTC', 'anchor_bundle', 'anchor_archive']) {
+ it(`records the frozen constant verbatim for a derived ${rewardType} reward`, async function () {
+ expect(await recordedAmount(rewardType, '7.5', NETWORK)).to.equal('12.5');
+ expect(expectedOnArchive(rewardType, '7.5'), 'co-sign expectation').to.equal('12.5');
+ });
+ }
+
+ it('renders the legacy knob on the 8-decimal grid, identically on both sides', async function () {
+ expect(await recordedAmount('anchor_BTC', '2.5', '')).to.equal('2.50000000');
+ expect(RewardTracker.legacyAnchorRewardAmount('2.5')).to.equal('2.50000000');
+ });
+
+ it('records nothing for a prefix-numeric knob, and co-signs no legacy amount for it', async function () {
+ expect(await recordedAmount('anchor_BTC', '10abc', '')).to.equal(null);
+ expect(RewardTracker.legacyAnchorRewardAmount('10abc')).to.equal('0.00000000');
+ });
+});
diff --git a/test/unit/api/health_probes.test.js b/test/unit/api/health_probes.test.js
new file mode 100644
index 000000000..93f32d12b
--- /dev/null
+++ b/test/unit/api/health_probes.test.js
@@ -0,0 +1,191 @@
+/*********************************************************************
+ *
+ * Copyright © 2025–2026 Dankest, LLC
+ * Based on XChain Platform by Dankest, LLC – https://dankest.llc
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * This file is part of XChain Platform. Licensed under the GNU Affero
+ * General Public License v3.0 or later; see LICENSE.md. A commercial
+ * license (without AGPL source-disclosure terms) is available -
+ * contact legal@dankest.llc.
+ *
+ **********************************************************************/
+
+'use strict';
+
+/**
+ * The probes behind `health` (src/api/rpc/health_probes.js).
+ *
+ * The defect these guard: every health call read all three admission tips from the
+ * indexers, and each Promise.race deadline left its timer running after a fast answer
+ * and its axios read running after a timeout. One upstream read is now shared by every
+ * concurrent caller and cached briefly, every deadline is cleared, and a timed-out read
+ * is cancelled. The upstream count is measured on a real indexer-shaped endpoint.
+ */
+
+const { expect } = require('chai');
+const sinon = require('sinon');
+const http = require('http');
+const XChainHub = require('../../../src/XChainHub.js');
+const { buildSystemRpc } = require('../../../src/api/rpc/system.js');
+const { ADMISSION_PROBE_TTL_MS, makeAdmissionTipProbe, raceTimeout } = require('../../../src/api/rpc/health_probes.js');
+const { ADMIT_COLUMN_CHAINS } = require('../../../src/consensus/gates/mirror_admission_gate.js');
+
+let server = null;
+let url = '';
+let upstream = 0;
+let closedEarly = 0;
+
+// An indexer that answers getlatestblock after `delayMs`, or never when delayMs is null.
+function startIndexer(delayMs) {
+ upstream = 0;
+ closedEarly = 0;
+ server = http.createServer((req, res) => {
+ upstream++;
+ res.on('close', () => { if (!res.writableEnded) closedEarly++; });
+ req.resume();
+ if (delayMs === null) return;
+ setTimeout(() => {
+ res.writeHead(200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { block_index: 100, decoder_block: 120 } }));
+ }, delayMs);
+ });
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
+ url = 'http://127.0.0.1:' + server.address().port + '/';
+ resolve();
+ }));
+}
+
+function stopIndexer() {
+ return new Promise((resolve) => {
+ if (!server) return resolve();
+ server.closeAllConnections();
+ server.close(() => { server = null; resolve(); });
+ });
+}
+
+// The real resolver methods on a hub that reads its indexer URL and nothing else.
+function indexerBackedHub() {
+ return {
+ network: 'regtest',
+ _admissionTipSeen: new Map(),
+ resolveIndexerUrl: async () => url,
+ resolveAdmissionTips: XChainHub.prototype.resolveAdmissionTips,
+ resolveAdmissionTip: XChainHub.prototype.resolveAdmissionTip,
+ admissionTipFresh: XChainHub.prototype.admissionTipFresh,
+ db: {
+ circuitState: 'closed',
+ getDatabaseLivenessProbe: async () => true,
+ getPriceSnapshotsFinalizedAgeSeconds: async () => []
+ }
+ };
+}
+
+function healthRpcFor(hub, timeoutMs) {
+ return buildSystemRpc({
+ hub, hubConfig: {}, p2pConfig: {}, configFetchCounters: { served: 0, errors: 0 },
+ DB_PROBE_TIMEOUT_MS: timeoutMs || 2000, COIN_CONSENSUS_HASHES: {}
+ });
+}
+
+const RES = { status() { return this; } };
+
+function concurrentHealth(rpc, n) {
+ return Promise.all(Array.from({ length: n }, () => rpc.health({}, { res: RES })));
+}
+
+function singleFlightSuite() {
+ afterEach(stopIndexer);
+
+ it('collapses N concurrent health calls into exactly one read per admission chain', async function () {
+ expect(ADMIT_COLUMN_CHAINS).to.have.length(3);
+ await startIndexer(30);
+ const rpc = healthRpcFor(indexerBackedHub());
+ const bodies = await concurrentHealth(rpc, 50);
+ expect(upstream).to.equal(3);
+ for (const body of bodies) expect(body.admission_tips.chains.BTC.height).to.equal(120);
+ // Still inside the TTL: a second flood reuses the same answer.
+ await concurrentHealth(rpc, 50);
+ expect(upstream).to.equal(3);
+ });
+
+ it('reads again once the cache has aged past its TTL', async function () {
+ let t = 0;
+ const hub = { resolveAdmissionTips: sinon.stub().resolves({ BTC: 1, LTC: 2, DOGE: 3 }), _admissionTipSeen: new Map() };
+ const probe = makeAdmissionTipProbe(hub, {}, 2000, { ttlMs: 1000, now: () => t });
+ await Promise.all([probe(), probe(), probe()]);
+ expect(hub.resolveAdmissionTips.callCount).to.equal(1);
+ t = 999;
+ await probe();
+ expect(hub.resolveAdmissionTips.callCount).to.equal(1);
+ t = 1001;
+ await probe();
+ expect(hub.resolveAdmissionTips.callCount).to.equal(2);
+ expect(ADMISSION_PROBE_TTL_MS).to.be.within(1000, 2000);
+ });
+
+ it('cancels the indexer reads when the probe deadline fires', async function () {
+ await startIndexer(null);
+ const rpc = healthRpcFor(indexerBackedHub(), 50);
+ const [body] = await concurrentHealth(rpc, 5);
+ expect(upstream).to.equal(3);
+ expect(body.admission_tips.chains.BTC.fresh).to.equal(false);
+ // axios' own timeout is 5000ms; a close inside 1s is the abort, not that.
+ const deadline = Date.now() + 1000;
+ while (closedEarly < 3 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 10));
+ expect(closedEarly).to.equal(3);
+ });
+
+ it('answers null where the hub runs no admission producer', async function () {
+ expect(await makeAdmissionTipProbe({}, {}, 2000)()).to.equal(null);
+ expect(await makeAdmissionTipProbe({ resolveAdmissionTips() {} }, null, 2000)()).to.equal(null);
+ });
+}
+
+function timerSuite() {
+ let clock;
+ beforeEach(() => { clock = sinon.useFakeTimers(); });
+ afterEach(() => clock.restore());
+
+ it('clears the deadline when the work completes first', async function () {
+ expect(await raceTimeout(Promise.resolve('done'), 2000)).to.equal('done');
+ expect(clock.countTimers()).to.equal(0);
+ let err = null;
+ await raceTimeout(Promise.reject(new Error('boom')), 2000).catch((e) => { err = e; });
+ expect(err.message).to.equal('boom');
+ expect(clock.countTimers()).to.equal(0);
+ });
+
+ it('aborts the work and rejects when the deadline fires', async function () {
+ let signal = null;
+ const race = raceTimeout((s) => { signal = s; return new Promise(() => {}); }, 2000);
+ const outcome = race.catch((e) => e);
+ await clock.tickAsync(2000);
+ expect((await outcome).message).to.equal('timeout');
+ expect(signal.aborted).to.equal(true);
+ expect(clock.countTimers()).to.equal(0);
+ });
+
+ it('rejects a synchronously throwing work without leaving its timer', async function () {
+ const err = await raceTimeout(() => { throw new Error('sync'); }, 2000).catch((e) => e);
+ expect(err.message).to.equal('sync');
+ expect(clock.countTimers()).to.equal(0);
+ });
+
+ it('leaves no live timer after health and ping answer', async function () {
+ const hub = indexerBackedHub();
+ hub.resolveAdmissionTips = async () => ({ BTC: 5, LTC: 6, DOGE: 7 });
+ const rpc = healthRpcFor(hub);
+ const body = await rpc.health({}, { res: RES });
+ expect(body.status).to.equal('healthy');
+ expect(await rpc.ping({}, { res: RES })).to.deep.equal({ status: 'success', db: true });
+ expect(clock.countTimers()).to.equal(0);
+ });
+}
+
+describe('hub health probes', function () {
+ this.timeout(10000);
+ describe('admission-tip single flight', singleFlightSuite);
+ describe('deadlines', timerSuite);
+});
diff --git a/test/unit/api/p2p_config_oracle_cadence.test.js b/test/unit/api/p2p_config_oracle_cadence.test.js
new file mode 100644
index 000000000..0d9e6b6ff
--- /dev/null
+++ b/test/unit/api/p2p_config_oracle_cadence.test.js
@@ -0,0 +1,109 @@
+'use strict';
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+// The oracle cadence knobs as src/api.js puts them on the p2pConfig a validator hub is
+// built with. The interval divides elapsed time into federation round numbers, so a
+// non-positive value must degrade to the shared default rather than reach OracleRound.
+
+const sinon = require('sinon');
+const { expect } = require('chai');
+const proxyquire = require('proxyquire').noPreserveCache();
+
+const { waitUntil } = require('../../helpers/waitUntil');
+const { DB_METHODS } = require('../../helpers/mockHub');
+const { DEFAULT_ORACLE_ROUND_INTERVAL_MS, DEFAULT_ORACLE_SUBMISSION_WINDOW_MS } = require('../../../src/constants.js');
+
+const ENV_KEYS = ['HUB_API_KEY', 'HUB_REORG_API_KEY', 'HUB_SENSITIVE_READ_AUTH', 'HUB_ALLOW_UNAUTHENTICATED',
+ 'HUB_DB_HOST', 'HUB_DB_PORT', 'HUB_DB_NAME', 'HUB_DB_USER', 'HUB_DB_PASS', 'HUB_PORT',
+ 'P2P_VALIDATOR_ADDR', 'HUB_NETWORK', 'ORACLE_EPOCH_START',
+ 'ORACLE_ROUND_INTERVAL', 'ORACLE_SUBMISSION_WINDOW'];
+
+function makeHub() {
+ const noop = async () => {};
+ return {
+ db: { ...DB_METHODS, doQuery: sinon.stub().resolves([]), circuitState: 'closed' },
+ capabilitySnapshot: null, stateAnchorPublisher: null, attestationPublisher: null,
+ start: noop, startP2P: noop, startConsensus: noop, startOracle: noop, startCrossChain: noop,
+ startReorgHandler: noop, startGovernance: noop, startAttestation: noop, startCapabilities: noop,
+ on: () => {}
+ };
+}
+
+function apiStubs(captured) {
+ const app = { use: sinon.stub(), get: sinon.stub(), post: sinon.stub(), set: sinon.stub(),
+ listen: sinon.stub().callsFake((p, h, cb) => { if (cb) cb(); }) };
+ const express = sinon.stub().returns(app);
+ express.json = sinon.stub().returns(function expressJson() {});
+ const server = { listen: sinon.stub().callsFake((p, h, cb) => { if (cb) cb(); }), on: sinon.stub() };
+ return {
+ 'dotenv': { config: sinon.stub() },
+ 'express': express,
+ 'helmet': sinon.stub().returns(function helmetMw() {}),
+ 'cors': sinon.stub().returns(function corsMw() {}),
+ 'express-rate-limit': sinon.stub().returns(function rateLimitMw() {}),
+ 'express-json-rpc-router': (opts) => { captured.methods = opts.methods; return function routerMw() {}; },
+ 'http': { createServer: sinon.stub().returns(server) },
+ 'ws': { Server: sinon.stub().returns({ on: sinon.stub() }) },
+ 'geoip-lite': { lookup: sinon.stub().returns(null) },
+ './XChainHub': function () { captured.p2pConfig = arguments[5]; return makeHub(); }
+ };
+}
+
+// Boot src/api.js in validator mode and return the p2pConfig it hands the hub.
+async function bootP2pConfig(env) {
+ const captured = { p2pConfig: undefined, methods: null, exits: [] };
+ const saved = {};
+ for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
+ Object.assign(process.env, {
+ HUB_DB_HOST: 'localhost', HUB_DB_PORT: '3306', HUB_DB_NAME: 'testdb', HUB_DB_USER: 'root',
+ HUB_DB_PASS: 'pass', HUB_PORT: '9998', HUB_API_KEY: 'k', P2P_VALIDATOR_ADDR: 'bc1qexample',
+ HUB_NETWORK: 'regtest', ORACLE_EPOCH_START: '1700000000000'
+ }, env);
+ const exitStub = sinon.stub(process, 'exit').callsFake((code) => { captured.exits.push(code); });
+ try {
+ proxyquire('../../../src/api', apiStubs(captured));
+ await waitUntil(() => captured.methods || captured.exits.length > 0,
+ { label: 'api.js boot to serve or refuse' });
+ } finally {
+ exitStub.restore();
+ for (const [k, v] of Object.entries(saved)) {
+ if (v === undefined) delete process.env[k];
+ else process.env[k] = v;
+ }
+ }
+ expect(captured.exits, 'validator boot refused').to.deep.equal([]);
+ return captured.p2pConfig;
+}
+
+describe('src/api.js p2pConfig oracle cadence', function () {
+ // The first proxyquire of api.js loads its whole require graph.
+ this.timeout(20000);
+ afterEach(function () { sinon.restore(); });
+
+ it('carries a positive operator interval and window through unchanged', async function () {
+ const cfg = await bootP2pConfig({ ORACLE_ROUND_INTERVAL: '60000', ORACLE_SUBMISSION_WINDOW: '20000' });
+ expect(cfg.ORACLE_ROUND_INTERVAL).to.equal(60000);
+ expect(cfg.ORACLE_SUBMISSION_WINDOW).to.equal(20000);
+ });
+
+ it('lands on the shared defaults when both knobs are unset', async function () {
+ const cfg = await bootP2pConfig({});
+ expect(cfg.ORACLE_ROUND_INTERVAL).to.equal(DEFAULT_ORACLE_ROUND_INTERVAL_MS);
+ expect(cfg.ORACLE_SUBMISSION_WINDOW).to.equal(DEFAULT_ORACLE_SUBMISSION_WINDOW_MS);
+ });
+
+ it('degrades a negative interval and window to the shared defaults', async function () {
+ const cfg = await bootP2pConfig({ ORACLE_ROUND_INTERVAL: '-600000', ORACLE_SUBMISSION_WINDOW: '-1' });
+ expect(cfg.ORACLE_ROUND_INTERVAL).to.equal(DEFAULT_ORACLE_ROUND_INTERVAL_MS);
+ expect(cfg.ORACLE_SUBMISSION_WINDOW).to.equal(DEFAULT_ORACLE_SUBMISSION_WINDOW_MS);
+ });
+});
diff --git a/test/unit/api/rate_limit_policy.test.js b/test/unit/api/rate_limit_policy.test.js
index becdbfe58..49381f382 100644
--- a/test/unit/api/rate_limit_policy.test.js
+++ b/test/unit/api/rate_limit_policy.test.js
@@ -296,7 +296,7 @@ const { buildRateLimitOptions, isLocalCaller, normalizeIp, parseExemptLocal,
// api.js self-starts on require, so asserting its wiring requires booting it under proxyquire
// and reading the options handed to express-rate-limit. This keeps the check on the actual
// application boundary where environment policy becomes middleware configuration.
- async function bootApiCapturingLimiter(env) {
+ async function bootApiCapturingLimiters(env) {
const mockApp = {
use: sinon.stub(), get: sinon.stub(), post: sinon.stub(), set: sinon.stub(),
listen: sinon.stub().callsFake((p, h, cb) => { if (cb) cb(); })
@@ -308,9 +308,10 @@ const { buildRateLimitOptions, isLocalCaller, normalizeIp, parseExemptLocal,
const mockHub = new Proxy({}, {
get: (t, p) => { if (!(p in t)) t[p] = sinon.stub().callsFake(async () => ({})); return t[p]; }
});
- const keys = ['HUB_API_KEY', 'HUB_ALLOW_UNAUTHENTICATED', 'HUB_RATE_LIMIT_RPM',
+ const keys = ['HUB_API_KEY', 'HUB_ALLOW_UNAUTHENTICATED', 'HUB_RATE_LIMIT_RPM', 'HUB_AUTH_RATE_LIMIT_RPM',
'HUB_RATE_LIMIT_EXEMPT_LOCAL', 'HUB_DB_HOST', 'HUB_DB_PORT', 'HUB_DB_NAME',
- 'HUB_DB_USER', 'HUB_DB_PASS', 'HUB_PORT'];
+ 'HUB_DB_USER', 'HUB_DB_PASS', 'HUB_PORT',
+ 'P2P_VALIDATOR_ADDR', 'ORACLE_EPOCH_START', 'HUB_NETWORK'];
const saved = {};
for (const k of keys) { saved[k] = process.env[k]; delete process.env[k]; }
Object.assign(process.env, {
@@ -331,16 +332,17 @@ const { buildRateLimitOptions, isLocalCaller, normalizeIp, parseExemptLocal,
'geoip-lite': { lookup: sinon.stub().returns(null) },
'./XChainHub': function () { return mockHub; }
});
+ // The async boot reads HUB_AUTH_RATE_LIMIT_RPM, so keep the env until it has.
+ await waitUntil(() => rateLimitStub.callCount >= 3, { label: 'api.js boot to install the rate limiters' });
} finally {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k]; else process.env[k] = v;
}
}
- await waitUntil(() => rateLimitStub.called, { label: 'api.js boot to install the rate limiter' });
- return rateLimitStub.firstCall.args[0];
+ return rateLimitStub.args.map((args) => args[0]);
}
async function installsThePolicyOptionsExemptionOnTest35() {
- const opts = await bootApiCapturingLimiter({});
+ const [opts] = await bootApiCapturingLimiters({});
expect(opts.limit).to.equal(100);
expect(opts.windowMs).to.equal(60000);
expect(opts.skip({ ip: '172.17.0.4' })).to.equal(true);
@@ -348,16 +350,37 @@ const { buildRateLimitOptions, isLocalCaller, normalizeIp, parseExemptLocal,
expect(typeof opts.handler).to.equal('function');
}
async function honoursHubRateLimitRpmAndTest36() {
- const opts = await bootApiCapturingLimiter({
+ const [opts] = await bootApiCapturingLimiters({
HUB_RATE_LIMIT_RPM: '4200', HUB_RATE_LIMIT_EXEMPT_LOCAL: 'false'
});
expect(opts.limit).to.equal(4200);
expect(opts.skip({ ip: '172.17.0.4' })).to.equal(false);
}
+ function validatorEnv(extra) {
+ return Object.assign({ P2P_VALIDATOR_ADDR: 'validator-1', ORACLE_EPOCH_START: '1700000000000',
+ HUB_NETWORK: 'regtest' }, extra);
+ }
+ // The 60000 fleet value lives in the key-holders' tier; the keyless surface stays at 100.
+ async function aValidatorHubDefaultsToTheFleetTest37() {
+ const [publicTier, authTier] = await bootApiCapturingLimiters(validatorEnv({}));
+ expect(publicTier.limit).to.equal(100);
+ expect(authTier.limit).to.equal(60000);
+ expect(authTier.skip({ ip: '203.0.113.7', originalUrl: '/', headers: { 'x-api-key': 'test-hub-key' } })).to.equal(false);
+ expect(publicTier.skip({ ip: '203.0.113.7', originalUrl: '/', headers: { 'x-api-key': 'test-hub-key' } })).to.equal(true);
+ expect(publicTier.skip({ ip: '203.0.113.7', originalUrl: '/', headers: {} })).to.equal(false);
+ }
+ async function anExplicitHubRateLimitRpmStillWinsOnATest38() {
+ const [publicTier, authTier] = await bootApiCapturingLimiters(validatorEnv({
+ HUB_RATE_LIMIT_RPM: '4200', HUB_AUTH_RATE_LIMIT_RPM: '9000' }));
+ expect(publicTier.limit).to.equal(4200);
+ expect(authTier.limit).to.equal(9000);
+ }
function apiJsWiringSuite34() {
this.timeout(10000);
it('installs the policy options, exemption on, at the shipped default of 100', installsThePolicyOptionsExemptionOnTest35);
it('honours HUB_RATE_LIMIT_RPM and HUB_RATE_LIMIT_EXEMPT_LOCAL=false', honoursHubRateLimitRpmAndTest36);
+ it('a validator hub keeps 100 public and gives key-holders the 60000 fleet tier', aValidatorHubDefaultsToTheFleetTest37);
+ it('explicit HUB_RATE_LIMIT_RPM and HUB_AUTH_RATE_LIMIT_RPM win on a validator hub', anExplicitHubRateLimitRpmStillWinsOnATest38);
}
registerapiJsWiring33 = function registerSuite() {
describe('api.js wiring', apiJsWiringSuite34);
diff --git a/test/unit/api/rate_limit_tiers.test.js b/test/unit/api/rate_limit_tiers.test.js
new file mode 100644
index 000000000..9d127066b
--- /dev/null
+++ b/test/unit/api/rate_limit_tiers.test.js
@@ -0,0 +1,237 @@
+/*********************************************************************
+ *
+ * Copyright © 2025–2026 Dankest, LLC
+ * Based on XChain Platform by Dankest, LLC – https://dankest.llc
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * This file is part of XChain Platform. Licensed under the GNU Affero
+ * General Public License v3.0 or later; see LICENSE.md. A commercial
+ * license (without AGPL source-disclosure terms) is available -
+ * contact legal@dankest.llc.
+ *
+ **********************************************************************/
+
+'use strict';
+
+/**
+ * The public and authenticated JSON-RPC tiers, and the batch surcharge.
+ *
+ * The defect these guard: a validator hub shipped HUB_RATE_LIMIT_RPM=60000 for every
+ * caller, and a 20-call batch cost one token, so one keyless client could drive about
+ * 1.2M `health` handlers a minute. Driven over a socket against the real
+ * installRateLimits stack, as rate_limit_snapshot_route.test.js drives the drain.
+ */
+
+const { expect } = require('chai');
+const express = require('express');
+const rateLimit = require('express-rate-limit');
+const http = require('http');
+const sinon = require('sinon');
+const { installRateLimits } = require('../../../src/api/middleware.js');
+const { DEFAULT_AUTH_RPM, authenticatedCaller, batchCost, parseAuthRpm } = require('../../../src/api/rate_limit_tiers.js');
+const FixedWindowStore = require('../../../src/api/rate_limit_store.js');
+
+const PUBLIC_IP = '203.0.113.7';
+const HUB_KEY = 'hub-key-for-tests';
+
+let server = null;
+let port = 0;
+let handled = 0;
+
+function boot(opts) {
+ opts = opts || {};
+ handled = 0;
+ const app = express();
+ app.set('trust proxy', 'loopback');
+ app.use(express.json());
+ installRateLimits(app, {
+ hubConfig: { HUB_AUTH_RATE_LIMIT_RPM: opts.authRpm },
+ logger: { info() {}, warn() {} },
+ rateLimit,
+ HUB_API_KEY: opts.keyless ? '' : HUB_KEY,
+ HUB_RATE_LIMIT_RPM: opts.rpm === undefined ? 100 : opts.rpm,
+ HUB_RATE_LIMIT_EXEMPT_LOCAL: true
+ });
+ app.post('/', (req, res) => {
+ const calls = Array.isArray(req.body) ? req.body : [req.body];
+ handled += calls.length;
+ res.json(calls.map((c) => ({ jsonrpc: '2.0', id: c.id, result: 'ok' })));
+ });
+ return new Promise((resolve) => {
+ server = app.listen(0, '127.0.0.1', () => { port = server.address().port; resolve(); });
+ });
+}
+
+function shutdown() {
+ return new Promise((resolve) => {
+ if (!server) return resolve();
+ server.close(() => { server = null; resolve(); });
+ });
+}
+
+function post(payload, headers) {
+ const body = JSON.stringify(payload);
+ return new Promise((resolve, reject) => {
+ const req = http.request({
+ hostname: '127.0.0.1', port, path: '/', method: 'POST',
+ headers: Object.assign({
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(body),
+ 'X-Forwarded-For': PUBLIC_IP
+ }, headers || {})
+ }, (res) => {
+ let data = '';
+ res.on('data', (chunk) => { data += chunk; });
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }));
+ });
+ req.on('error', reject);
+ req.write(body);
+ req.end();
+ });
+}
+
+function healthCall(id) {
+ return { jsonrpc: '2.0', id, method: 'health', params: {} };
+}
+
+function batchOf(n) {
+ return Array.from({ length: n }, (_, i) => healthCall(i + 1));
+}
+
+const WITH_KEY = { 'x-api-key': HUB_KEY };
+
+function batchChargingSuite() {
+ it('charges a batch one token per call', async function () {
+ await boot({ rpm: 40 });
+ expect((await post(batchOf(20))).status).to.equal(200);
+ expect((await post(batchOf(20))).status).to.equal(200);
+ const over = await post(healthCall(99));
+ expect(over.status).to.equal(429);
+ expect(JSON.parse(over.body).error.code).to.equal(-32029);
+ expect(handled).to.equal(40);
+ });
+
+ it('refuses a batch whose calls exceed what is left, before any call runs', async function () {
+ await boot({ rpm: 10 });
+ const res = await post(batchOf(20));
+ expect(res.status).to.equal(429);
+ expect(handled).to.equal(0);
+ });
+
+ it('still charges a single call exactly one token', async function () {
+ await boot({ rpm: 5 });
+ for (let i = 1; i <= 5; i++) expect((await post(healthCall(i))).status, 'call ' + i).to.equal(200);
+ expect((await post(healthCall(6))).status).to.equal(429);
+ });
+
+ it('charges an authenticated batch per call in the authenticated tier', async function () {
+ await boot({ rpm: 5, authRpm: 30 });
+ expect((await post(batchOf(20), WITH_KEY)).status).to.equal(200);
+ expect((await post(batchOf(10), WITH_KEY)).status).to.equal(200);
+ expect((await post(healthCall(1), WITH_KEY)).status).to.equal(429);
+ });
+
+ it('counts tokens by call: batchCost', function () {
+ expect(batchCost({ body: batchOf(20) })).to.equal(20);
+ expect(batchCost({ body: [] })).to.equal(1);
+ expect(batchCost({ body: healthCall(1) })).to.equal(1);
+ expect(batchCost({})).to.equal(1);
+ expect(batchCost(undefined)).to.equal(1);
+ });
+}
+
+function bucketsSuite() {
+ it('keeps the public tier conservative while a key-holder on the same IP carries on', async function () {
+ await boot({ rpm: 3, authRpm: 50 });
+ for (let i = 1; i <= 3; i++) expect((await post(healthCall(i))).status).to.equal(200);
+ const refused = await post(healthCall(4));
+ expect(refused.status).to.equal(429);
+ expect(JSON.parse(refused.body).error.data.env).to.equal('HUB_RATE_LIMIT_RPM');
+ for (let i = 1; i <= 40; i++) expect((await post(healthCall(i), WITH_KEY)).status, 'keyed ' + i).to.equal(200);
+ const keyed = await post(healthCall(41), WITH_KEY);
+ expect(keyed.headers['ratelimit-limit']).to.equal('50');
+ });
+
+ it('a key-holder spending its tier leaves the public tier untouched', async function () {
+ await boot({ rpm: 3, authRpm: 4 });
+ for (let i = 1; i <= 4; i++) expect((await post(healthCall(i), WITH_KEY)).status).to.equal(200);
+ const refused = await post(healthCall(5), WITH_KEY);
+ expect(refused.status).to.equal(429);
+ expect(JSON.parse(refused.body).error.data.env).to.equal('HUB_AUTH_RATE_LIMIT_RPM');
+ expect((await post(healthCall(6))).status).to.equal(200);
+ });
+
+ it('a wrong key buys nothing: it is metered as public', async function () {
+ await boot({ rpm: 2, authRpm: 50 });
+ const wrong = { 'x-api-key': 'not-the-key' };
+ expect((await post(healthCall(1), wrong)).status).to.equal(200);
+ expect((await post(healthCall(2), wrong)).status).to.equal(200);
+ expect((await post(healthCall(3), wrong)).status).to.equal(429);
+ });
+
+ it('a keyless hub has no authenticated tier at all', async function () {
+ await boot({ rpm: 2, authRpm: 50, keyless: true });
+ expect((await post(healthCall(1), WITH_KEY)).status).to.equal(200);
+ expect((await post(healthCall(2), WITH_KEY)).status).to.equal(200);
+ expect((await post(healthCall(3), WITH_KEY)).status).to.equal(429);
+ });
+
+ it('recognises every configured hub key and nothing else', function () {
+ const isAuthenticated = authenticatedCaller({
+ HUB_API_KEY: 'bulk', HUB_REORG_API_KEY: 'reorg', HUB_CONFIG_SECRETS_API_KEY: ''
+ });
+ expect(isAuthenticated({ headers: { 'x-api-key': 'bulk' } })).to.equal(true);
+ expect(isAuthenticated({ headers: { 'x-api-key': 'reorg' } })).to.equal(true);
+ expect(isAuthenticated({ headers: { 'x-api-key': '' } })).to.equal(false);
+ expect(isAuthenticated({ headers: { 'x-api-key': 'bulkx' } })).to.equal(false);
+ expect(isAuthenticated({ headers: {} })).to.equal(false);
+ expect(isAuthenticated(undefined)).to.equal(false);
+ expect(authenticatedCaller({})({ headers: { 'x-api-key': '' } })).to.equal(false);
+ });
+
+ it('defaults the authenticated tier to the fleet value, never below the public one', function () {
+ expect(DEFAULT_AUTH_RPM).to.equal(60000);
+ expect(parseAuthRpm(undefined, 100)).to.equal(60000);
+ expect(parseAuthRpm('abc', 100)).to.equal(60000);
+ expect(parseAuthRpm('0', 100)).to.equal(60000);
+ expect(parseAuthRpm(undefined, 90000)).to.equal(90000);
+ expect(parseAuthRpm('5000', 100)).to.equal(5000);
+ });
+}
+
+// Date is faked so window expiry is driven, never waited for.
+function storeSuite() {
+ let clock;
+ beforeEach(() => { clock = sinon.useFakeTimers(); });
+ afterEach(() => clock.restore());
+
+ it('charges in bulk and resets when the window expires', async function () {
+ const store = new FixedWindowStore();
+ store.init({ windowMs: 50 });
+ expect(store.incrementBy('k', 19).totalHits).to.equal(19);
+ expect((await store.increment('k')).totalHits).to.equal(20);
+ await store.decrement('k');
+ expect((await store.get('k')).totalHits).to.equal(19);
+ clock.tick(51);
+ expect(await store.get('k')).to.equal(undefined);
+ expect(store.incrementBy('k', 1).totalHits).to.equal(1);
+ });
+
+ it('sweeps expired keys so the map cannot grow without bound', function () {
+ const store = new FixedWindowStore();
+ store.init({ windowMs: 20 });
+ for (let i = 0; i < 50; i++) store.incrementBy('ip' + i, 1);
+ clock.tick(21);
+ store.incrementBy('fresh', 1);
+ expect(store.windows.size).to.equal(1);
+ });
+}
+
+describe('hub JSON-RPC rate-limit tiers', function () {
+ this.timeout(10000);
+ afterEach(shutdown);
+ describe('batch charging', batchChargingSuite);
+ describe('public and authenticated buckets', bucketsSuite);
+ describe('FixedWindowStore', storeSuite);
+});
diff --git a/test/unit/api/rpc_cross_chain_call_id.test.js b/test/unit/api/rpc_cross_chain_call_id.test.js
new file mode 100644
index 000000000..ca2efc2c5
--- /dev/null
+++ b/test/unit/api/rpc_cross_chain_call_id.test.js
@@ -0,0 +1,67 @@
+'use strict';
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+//
+// getcrosschaincall and its getxcall alias: a call_id is a 64-char hex sha256, so a
+// present but malformed one is refused in the envelope before any read, and a
+// well-formed one reaches the hub normalized.
+
+const sinon = require('sinon');
+const { expect } = require('chai');
+const { buildCrossChainRpc } = require('../../../src/api/rpc/cross_chain.js');
+
+const CALL_ID = 'ab'.repeat(32);
+const METHODS = ['getcrosschaincall', 'getxcall'];
+
+function makeRpc() {
+ let hub = {
+ crossChainCalls: {},
+ getCrossChainCall: sinon.stub().callsFake(async (id) => (id === CALL_ID ? { call_id: id } : null)),
+ };
+ return { hub, rpc: buildCrossChainRpc({ hub }) };
+}
+
+function registerShapeTests() {
+ it('refuses a non-string, a short or non-hex id and a megabyte of junk without reading', async function () {
+ for (const m of METHODS) {
+ let { hub, rpc } = makeRpc();
+ for (const bad of [{}, [], 123, 'ab'.repeat(31), 'zz'.repeat(32), 'x'.repeat(1 << 20)]) {
+ let res = await rpc[m]({ call_id: bad });
+ expect(res.error, m).to.equal('call_id must be a 64-character hex string');
+ }
+ expect(hub.getCrossChainCall.called, m).to.equal(false);
+ }
+ });
+
+ it('keeps the missing-value message', async function () {
+ for (const m of METHODS) {
+ let { rpc } = makeRpc();
+ expect((await rpc[m]({})).error, m).to.equal('call_id is required');
+ expect((await rpc[m]({ call_id: '' })).error, m).to.equal('call_id is required');
+ }
+ });
+
+ it('resolves a well-formed id, upper-case and padded included, as its normalized form', async function () {
+ for (const m of METHODS) {
+ let { hub, rpc } = makeRpc();
+ expect(await rpc[m]({ call_id: CALL_ID })).to.deep.equal({ call_id: CALL_ID });
+ expect(await rpc[m]({ call_id: ' ' + CALL_ID.toUpperCase() + ' ' })).to.deep.equal({ call_id: CALL_ID });
+ expect(hub.getCrossChainCall.alwaysCalledWith(CALL_ID), m).to.equal(true);
+ }
+ });
+
+ it('still reports an unknown well-formed id as not found', async function () {
+ let { rpc } = makeRpc();
+ expect((await rpc.getxcall({ call_id: 'cd'.repeat(32) })).error).to.equal('cross-chain call not found');
+ });
+}
+
+describe('JSON-RPC cross-chain call reads: call_id shape', registerShapeTests);
diff --git a/test/unit/api/sensitive_read_auth.test.js b/test/unit/api/sensitive_read_auth.test.js
index 73ceb06ab..33aea4fb0 100644
--- a/test/unit/api/sensitive_read_auth.test.js
+++ b/test/unit/api/sensitive_read_auth.test.js
@@ -108,7 +108,9 @@ function drive(mw, methodOrMethods, apiKey, params) {
}
function findAuthMiddleware(useCalls) {
- const candidates = useCalls.filter((fn) => typeof fn === 'function' && fn.length >= 3);
+ // The rate-limit batch surcharge also passes a single call through, so it is named out.
+ const candidates = useCalls.filter((fn) => typeof fn === 'function' && fn.length >= 3 &&
+ fn.name !== 'rpcBatchSurcharge');
let authMw = null;
for (const fn of candidates) {
try {
diff --git a/test/unit/api/system_updateconfig_network.test.js b/test/unit/api/system_updateconfig_network.test.js
new file mode 100644
index 000000000..322b17c72
--- /dev/null
+++ b/test/unit/api/system_updateconfig_network.test.js
@@ -0,0 +1,143 @@
+'use strict';
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+const sinon = require('sinon');
+const { expect } = require('chai');
+const ConfigParams = require('../../../src/hub/config_params.js');
+const { buildSystemRpc } = require('../../../src/api/rpc/system.js');
+
+function configFor(network, coin) {
+ return {
+ [coin || 'bitcoin']: {
+ [network]: {}
+ }
+ };
+}
+
+function emptyHub() {
+ const hub = new ConfigParams();
+ hub.network = '';
+ return hub;
+}
+
+function restoreSinon() {
+ sinon.restore();
+}
+
+function testSharedNetworkAdoption() {
+ const hub = emptyHub();
+ const config = Object.assign(
+ configFor('testnet', 'bitcoin'),
+ configFor('testnet', 'dogecoin'),
+ configFor('testnet', 'litecoin')
+ );
+
+ expect(hub.learnNetworkFromConfig(config)).to.equal('testnet');
+ expect(hub.network).to.equal('testnet');
+}
+
+function testEveryRecognizedNetwork() {
+ for(const network of ['mainnet', 'testnet', 'regtest']){
+ const hub = emptyHub();
+ hub.learnNetworkFromConfig(configFor(network));
+ expect(hub.network).to.equal(network);
+ }
+}
+
+function testNonObjectMetadata() {
+ const hub = emptyHub();
+ const config = configFor('regtest');
+ config.bitcoin.label = 'local development';
+ config.bitcoin.disabled = false;
+ config.bitcoin.optional = null;
+
+ hub.learnNetworkFromConfig(config);
+ expect(hub.network).to.equal('regtest');
+}
+
+function testEmptyOrNonObjectConfig() {
+ for(const config of [{}, null, [], 'regtest']){
+ const hub = emptyHub();
+ hub.learnNetworkFromConfig(config);
+ expect(hub.network).to.equal('');
+ }
+}
+
+function testUnknownNetworkRefusal() {
+ const hub = emptyHub();
+ hub.learnNetworkFromConfig(configFor('signet'));
+ expect(hub.network).to.equal('');
+}
+
+function testRecognizedAndUnknownRefusal() {
+ const hub = emptyHub();
+ const config = Object.assign(
+ configFor('mainnet', 'bitcoin'),
+ configFor('production', 'dogecoin')
+ );
+
+ hub.learnNetworkFromConfig(config);
+ expect(hub.network).to.equal('');
+}
+
+function testMixedNetworkRefusal() {
+ const hub = emptyHub();
+ const config = Object.assign(
+ configFor('mainnet', 'bitcoin'),
+ configFor('testnet', 'dogecoin')
+ );
+
+ hub.learnNetworkFromConfig(config);
+ expect(hub.network).to.equal('');
+}
+
+function testConfiguredNetworkPreservation() {
+ const hub = emptyHub();
+ hub.network = 'mainnet';
+
+ expect(hub.learnNetworkFromConfig(configFor('regtest'))).to.equal('mainnet');
+ expect(hub.network).to.equal('mainnet');
+}
+
+async function testLearnBeforeProposal() {
+ const hub = emptyHub();
+ hub.consensus = {
+ propose: sinon.stub().callsFake(async function assertLearnedNetwork() {
+ expect(hub.network).to.equal('regtest');
+ })
+ };
+ const learn = sinon.spy(hub, 'learnNetworkFromConfig');
+ const add = sinon.spy(hub, 'addParametersFromJson');
+ const config = configFor('regtest');
+
+ expect(await buildSystemRpc({ hub }).updateconfig({ config }))
+ .to.deep.equal({ status: 'success' });
+ expect(learn.calledOnceWithExactly(config)).to.equal(true);
+ expect(add.calledOnceWithExactly(config)).to.equal(true);
+ expect(learn.calledBefore(add)).to.equal(true);
+ expect(hub.consensus.propose.calledOnceWithExactly(config)).to.equal(true);
+}
+
+function registerNetworkLearningTests() {
+ afterEach(restoreSinon);
+ it('adopts a recognized network shared by every coin', testSharedNetworkAdoption);
+ it('can adopt each recognized network', testEveryRecognizedNetwork);
+ it('ignores non-object metadata when object slots agree', testNonObjectMetadata);
+ it('leaves the network empty for empty or non-object config', testEmptyOrNonObjectConfig);
+ it('refuses an unknown object-valued network key', testUnknownNetworkRefusal);
+ it('refuses a recognized network paired with an unknown network', testRecognizedAndUnknownRefusal);
+ it('refuses mixed recognized networks', testMixedNetworkRefusal);
+ it('preserves an already configured network', testConfiguredNetworkPreservation);
+ it('learns the network before proposing the config', testLearnBeforeProposal);
+}
+
+describe('system updateconfig network learning', registerNetworkLearningTests);
diff --git a/test/unit/attestation/publisher/batch/attest_batch_wire_era.test.js b/test/unit/attestation/publisher/batch/attest_batch_wire_era.test.js
new file mode 100644
index 000000000..08ac677f1
--- /dev/null
+++ b/test/unit/attestation/publisher/batch/attest_batch_wire_era.test.js
@@ -0,0 +1,113 @@
+/*********************************************************************
+ *
+ * Copyright © 2025–2026 Dankest, LLC
+ * Based on XChain Platform by Dankest, LLC – https://dankest.llc
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * This file is part of XChain Platform. Licensed under the GNU Affero
+ * General Public License v3.0 or later; see LICENSE.md. A commercial
+ * license (without AGPL source-disclosure terms) is available -
+ * contact legal@dankest.llc.
+ *
+ **********************************************************************
+ * test/unit/attestation/publisher/batch/attest_batch_wire_era.test.js
+ *
+ * The hub half of the ATTEST batch admission-era rule: this hub SIGNS the batch, so
+ * below the BTC mirror-admission producer activation it must sign the legacy bytes
+ * every indexer on the fleet rebuilds (v0.19.0 included), and at or above it the bytes
+ * that carry each row's real admit_block_btc. And this hub RE-VERIFIES a batch an
+ * indexer pushes back from chain, so a batch a v0.19.0 hub signed has to verify here.
+ * Driven on the shipped testnet height with the real gate; the indexer twin of this
+ * rule is xchain-indexer test/unit/actions/attest/attest_batch_wire_era.test.js.
+ ********************************************************************/
+
+'use strict';
+
+const { expect } = require('chai');
+
+const abw = require('../../../../../src/lib/attest_batch_wire.js');
+const adm = require('../../../../../src/consensus/gates/mirror_admission_gate.js');
+const signing = require('../../../../../src/attestation/batch_publisher/signing.js');
+const windows = require('../../../../../src/attestation/batch_publisher/window.js');
+const ValidatorIdentity = require('../../../../../src/validators/identity.js');
+const HISTORY = require('../../../../fixtures/attest_batch_v0190_replay.json');
+
+const { isAdmissionEra } = adm;
+const NETWORK = 'testnet';
+const ADMIT_AT = adm.MIRROR_ADMISSION_ACTIVATION['BTC:' + NETWORK];
+
+// A stored attestation_responses row as the driver hands it back, admission height and all.
+function storedRow(i, admitBlock){
+ return {
+ network: NETWORK, request_id: String(i).repeat(64).slice(0, 64), request_action_index: '4400',
+ request_block_index: String(ADMIT_AT - 10 + i), provider_id: 'http_get', status: 'ok',
+ response_payload: '{"ok":true}', response_hash: 'cd'.repeat(32), meta: '200',
+ effective_time: String(1789203600 + i), admit_block_btc: admitBlock,
+ signer_pubkeys: '[]', signatures: '[]', widen: '0', batch_action_index: null, finalized_at: 1
+ };
+}
+
+// The leader's own signing round on a one-member set: the single-member path returns
+// this hub's signature over exactly the canonical it built.
+async function leaderSignature(anchor, rows){
+ const identity = new ValidatorIdentity(ValidatorIdentity.generate().privkeyHex);
+ const me = identity.getPubkeyHex().toLowerCase();
+ const ctx = Object.assign({}, windows, signing, {
+ identity, stats: { signRounds: 0, signQuorums: 0 },
+ hubPeerManager: () => null,
+ resolveAttestationSet: async () => [{ pubkey: me, source: 'S', weight: '1' }]
+ });
+ const window = { network: NETWORK, window_start: 1789203600, window_end: 1789207200,
+ row_count: rows.length, btc_block_height: anchor, rows: rows.map(r => ctx.normalizeRow(r)) };
+ const signed = await ctx.collectBatchSignatures(window, 'k');
+ expect(signed.met).to.equal(true);
+ return { window, identity, sig: signed.sigs[0].sig };
+}
+
+// The canonical an indexer that has never heard of admit_block_btc rebuilds: the legacy
+// field order, spelled here from the frozen list rather than from the builder under test.
+function legacyCanonical(window){
+ return JSON.stringify({
+ network: window.network, window_start: window.window_start, window_end: window.window_end,
+ row_count: window.row_count, btc_block_height: window.btc_block_height,
+ rows: window.rows.map(r => Object.fromEntries(abw.ATTEST_BATCH_LEGACY_ROW_FIELDS.map(f => [f, r[f] === undefined ? null : r[f]])))
+ });
+}
+
+describe('ATTEST batch wire: the hub signs the era of the batch anchor @regression', function () {
+
+ it('normalizes the stored admission height onto the row, as an integer or null', function () {
+ expect(windows.normalizeRow(storedRow(1, '154300')).admit_block_btc).to.equal(154300);
+ expect(windows.normalizeRow(storedRow(1, null)).admit_block_btc).to.equal(null);
+ });
+
+ it('below the activation the leader signs the legacy bytes, even for a row holding a height', async function () {
+ const { window, identity, sig } = await leaderSignature(ADMIT_AT - 1, [storedRow(1, null), storedRow(2, ADMIT_AT)]);
+ expect(ValidatorIdentity.verify(legacyCanonical(window), sig, identity.getPubkeyHex())).to.equal(true);
+ expect(abw.buildAttestBatchBody(window, isAdmissionEra)).to.not.include('admit_block_btc');
+ });
+
+ it('at the activation the leader signs each row\'s real admission height', async function () {
+ const { window, identity, sig } = await leaderSignature(ADMIT_AT, [storedRow(1, ADMIT_AT), storedRow(2, null)]);
+ const canonical = abw.buildAttestBatchCanonical(window, isAdmissionEra);
+ expect(JSON.parse(canonical).rows.map(r => r.admit_block_btc)).to.deep.equal([ADMIT_AT, null]);
+ expect(ValidatorIdentity.verify(canonical, sig, identity.getPubkeyHex())).to.equal(true);
+ expect(ValidatorIdentity.verify(legacyCanonical(window), sig, identity.getPubkeyHex())).to.equal(false);
+ });
+
+ it('a batch a v0.19.0 hub signed before the activation re-verifies here from its own rows', function () {
+ expect(HISTORY.btc_block_height).to.be.below(ADMIT_AT);
+ const head = abw.parseAttestBatchHead(HISTORY.wire.split('|').slice(1));
+ const back = abw.reassembleAttestBatch(head, [], isAdmissionEra);
+ expect(back.ok, back.status).to.equal(true);
+ // The pushed payload carries the reassembled rows verbatim, which is what
+ // response_mirror/batch.js rebuilds the canonical from.
+ const canonical = abw.buildAttestBatchCanonical({
+ network: back.batch.network, window_start: back.batch.window_start, window_end: back.batch.window_end,
+ row_count: back.batch.row_count, btc_block_height: head.btcBlockHeight, rows: back.batch.rows
+ }, isAdmissionEra);
+ expect(canonical).to.equal(HISTORY.signed_canonical);
+ expect(ValidatorIdentity.verify(canonical, back.batch.sigs[0].sig, HISTORY.signer_pubkey)).to.equal(true);
+ });
+});
diff --git a/test/unit/attestation/publisher/batch/attest_batch_wire_parity.test.js b/test/unit/attestation/publisher/batch/attest_batch_wire_parity.test.js
index c3eb6e079..3f9e2ebc0 100644
--- a/test/unit/attestation/publisher/batch/attest_batch_wire_parity.test.js
+++ b/test/unit/attestation/publisher/batch/attest_batch_wire_parity.test.js
@@ -36,6 +36,7 @@ const fs = require('fs');
const path = require('path');
const local = require('../../../../../src/lib/attest_batch_wire.js');
+const { isAdmissionEra } = require('../../../../../src/consensus/gates/mirror_admission_gate.js');
// Sibling checkout, resolved the way every other twin test resolves it: an explicit
// env path for CI (actions/checkout cannot write above the workspace), falling back
@@ -123,10 +124,10 @@ describe('ATTEST v5/v6 batch wire: hub twin @regression', function () {
it('produces identical wires and an identical canonical from identical input', function () {
const twin = require(TWIN_PATH);
- expect(local.buildAttestBatchCanonical(window()))
- .to.equal(twin.buildAttestBatchCanonical(window()));
- const mine = local.encodeAttestBatch(window());
- const theirs = twin.encodeAttestBatch(window());
+ expect(local.buildAttestBatchCanonical(window(), isAdmissionEra))
+ .to.equal(twin.buildAttestBatchCanonical(window(), isAdmissionEra));
+ const mine = local.encodeAttestBatch(window(), isAdmissionEra);
+ const theirs = twin.encodeAttestBatch(window(), isAdmissionEra);
expect(mine.ok && theirs.ok).to.equal(true);
expect(mine.wires).to.deep.equal(theirs.wires);
expect(mine.batchKey).to.equal(theirs.batchKey);
@@ -149,16 +150,16 @@ describe('ATTEST v5/v6 batch wire: hub twin @regression', function () {
});
it('round-trips a window the hub built back through the parser', function () {
- const encoded = local.encodeAttestBatch(window());
+ const encoded = local.encodeAttestBatch(window(), isAdmissionEra);
expect(encoded.ok).to.equal(true);
const params = encoded.wires[0].split('|').slice(1);
const head = local.parseAttestBatchHead(params);
expect(head.ok, head.status).to.equal(true);
- const back = local.reassembleAttestBatch(head, []);
+ const back = local.reassembleAttestBatch(head, [], isAdmissionEra);
expect(back.ok, back.status).to.equal(true);
expect(back.batch.rows.length).to.equal(2);
- expect(local.buildAttestBatchCanonical(back.batch))
- .to.equal(local.buildAttestBatchCanonical(window()));
+ expect(local.buildAttestBatchCanonical(back.batch, isAdmissionEra))
+ .to.equal(local.buildAttestBatchCanonical(window(), isAdmissionEra));
});
});
});
diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher.test.js
index aa3ea78d1..6f5c95a59 100644
--- a/test/unit/attestation/publisher/batch/attestation_batch_publisher.test.js
+++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher.test.js
@@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){
let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1]));
return found ? [Object.assign({}, found)] : [];
}
- if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
+ if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args);
throw new Error('unexpected statement: ' + sql);
}
diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_000_the_window.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_000_the_window.test.js
index 33c238afd..c39e572a4 100644
--- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_000_the_window.test.js
+++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_000_the_window.test.js
@@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){
let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1]));
return found ? [Object.assign({}, found)] : [];
}
- if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
+ if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args);
throw new Error('unexpected statement: ' + sql);
}
diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_001_resolvefloorwindow.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_001_resolvefloorwindow.test.js
index edbf14744..4302c547b 100644
--- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_001_resolvefloorwindow.test.js
+++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_001_resolvefloorwindow.test.js
@@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){
let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1]));
return found ? [Object.assign({}, found)] : [];
}
- if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
+ if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args);
throw new Error('unexpected statement: ' + sql);
}
diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_002_publishing_a_window.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_002_publishing_a_window.test.js
index 5d1574c23..9c120d227 100644
--- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_002_publishing_a_window.test.js
+++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_002_publishing_a_window.test.js
@@ -37,6 +37,7 @@ const { expect } = require('chai');
const AttestationBatchPublisher = require('../../../../../src/attestation/batch_publisher.js');
const ValidatorIdentity = require('../../../../../src/validators/identity.js');
const abw = require('../../../../../src/lib/attest_batch_wire.js');
+const { isAdmissionEra } = require('../../../../../src/consensus/gates/mirror_admission_gate.js');
const { isNeverSentError, isAmbiguousSendError } = require('../../../../../src/lib/idempotent_broadcast.js');
const { DB_METHODS } = require('../../../../helpers/mockHub.js');
@@ -124,7 +125,7 @@ function doDbQuery(responses, markers, sql, args){
let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1]));
return found ? [Object.assign({}, found)] : [];
}
- if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
+ if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args);
throw new Error('unexpected statement: ' + sql);
}
@@ -278,7 +279,7 @@ describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); aft
let head = decodeHead(p.wires[0]);
expect(head.rowCount).to.equal(2);
- let body = abw.reassembleAttestBatch(head, []);
+ let body = abw.reassembleAttestBatch(head, [], isAdmissionEra);
expect(body.ok, body.status).to.equal(true);
expect(body.batch.rows.map(r => r.request_block_index)).to.deep.equal([120, 121]);
// The audit column is hub wall clock and two hubs disagree on it, so it must
diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js
index 7b4790ea4..37d7477fc 100644
--- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js
+++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js
@@ -38,6 +38,7 @@ const AttestationBatchPublisher = require('../../../../../src/attestation/batch_
const ValidatorIdentity = require('../../../../../src/validators/identity.js');
const abw = require('../../../../../src/lib/attest_batch_wire.js');
const { isNeverSentError, isAmbiguousSendError } = require('../../../../../src/lib/idempotent_broadcast.js');
+const { MAX_CATCHUP_WINDOWS } = require('../../../../../src/attestation/batch_publisher/constants.js');
const { DB_METHODS } = require('../../../../helpers/mockHub.js');
const WINDOW_S = 10; // regtest override; the whole suite closes windows in seconds
@@ -109,6 +110,15 @@ function updateMarker(markers, args){
function doDbQuery(responses, markers, sql, args){
if(/FROM attestation_responses/i.test(sql)) return selectResponses(responses, sql, args);
if(/^DELETE FROM attest_published_batches/i.test(sql)) return deleteMarker(markers, args);
+ if(/SELECT MIN\(window_start\).*window_start < \?/i.test(sql)){
+ let [network, status, before] = args;
+ let starts = markers.filter(m => m.network === network && m.status === status &&
+ Number(m.window_start) < Number(before))
+ .map(m => Number(m.window_start));
+ return [{ oldest: starts.length ? Math.min.apply(null, starts) : null,
+ newest: starts.length ? Math.max.apply(null, starts) : null,
+ count: starts.length }];
+ }
// The floor read. BOTH aggregates are answered from the same row set, so a
// publisher that went back to flooring on the newest marker reads a real
// value here rather than an undefined the test would silently coerce.
@@ -117,14 +127,20 @@ function doDbQuery(responses, markers, sql, args){
let newest = markers.reduce((m, r) => Math.max(m, Number(r.window_start)), 0);
return [{ oldest: Number.isFinite(oldest) ? oldest : null, newest: newest || null }];
}
+ if(/SELECT window_start FROM attest_published_batches.*window_start >= \?/i.test(sql)){
+ return markers.filter(m => m.network === args[0] && m.status === args[1] &&
+ Number(m.window_start) >= Number(args[2]))
+ .map(m => ({ window_start: m.window_start }));
+ }
if(/SELECT window_start FROM attest_published_batches/i.test(sql)){
- return markers.filter(m => m.status === args[1]).map(m => ({ window_start: m.window_start }));
+ return markers.filter(m => m.network === args[0] && m.status === args[1])
+ .map(m => ({ window_start: m.window_start }));
}
if(/FROM attest_published_batches WHERE network = \? AND window_start = \?/i.test(sql)){
let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1]));
return found ? [Object.assign({}, found)] : [];
}
- if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
+ if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args);
throw new Error('unexpected statement: ' + sql);
}
@@ -297,11 +313,6 @@ describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); aft
// ------------------------------------------------------------ publishing
-
-
- // A federation that shares one Bitcoin indexer has one hub with a chain_tips
- // row and N-1 without (testnet 2026-09-07: four of five validators). Every
- // one of them polls that indexer for requests, and the poll reports the tip,
// so a hub with no pushed row anchors on the tip its own round observed.
describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('publishing a window', function () { it('anchors on the tip the attestation poll observed when no chain tip was pushed', async function () {
let hub = makeHub({ dir: dir });
diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_004_a_head_refused_before_it_was.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_004_a_head_refused_before_it_was.test.js
index 096c41dff..860a36955 100644
--- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_004_a_head_refused_before_it_was.test.js
+++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_004_a_head_refused_before_it_was.test.js
@@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){
let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1]));
return found ? [Object.assign({}, found)] : [];
}
- if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
+ if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args);
throw new Error('unexpected statement: ' + sql);
}
diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_005_a_head_refused_before_it_was.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_005_a_head_refused_before_it_was.test.js
index 0ea21175e..f42a028bf 100644
--- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_005_a_head_refused_before_it_was.test.js
+++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_005_a_head_refused_before_it_was.test.js
@@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){
let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1]));
return found ? [Object.assign({}, found)] : [];
}
- if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
+ if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args);
throw new Error('unexpected statement: ' + sql);
}
diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_006_a_head_refused_before_it_was.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_006_a_head_refused_before_it_was.test.js
index de7e8c7ba..e07746d27 100644
--- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_006_a_head_refused_before_it_was.test.js
+++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_006_a_head_refused_before_it_was.test.js
@@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){
let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1]));
return found ? [Object.assign({}, found)] : [];
}
- if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
+ if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args);
throw new Error('unexpected statement: ' + sql);
}
diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_007_window_membership.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_007_window_membership.test.js
index dd7efe8d4..6e7c09dea 100644
--- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_007_window_membership.test.js
+++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_007_window_membership.test.js
@@ -37,6 +37,7 @@ const { expect } = require('chai');
const AttestationBatchPublisher = require('../../../../../src/attestation/batch_publisher.js');
const ValidatorIdentity = require('../../../../../src/validators/identity.js');
const abw = require('../../../../../src/lib/attest_batch_wire.js');
+const { isAdmissionEra } = require('../../../../../src/consensus/gates/mirror_admission_gate.js');
const { isNeverSentError, isAmbiguousSendError } = require('../../../../../src/lib/idempotent_broadcast.js');
const { DB_METHODS } = require('../../../../helpers/mockHub.js');
@@ -124,7 +125,7 @@ function doDbQuery(responses, markers, sql, args){
let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1]));
return found ? [Object.assign({}, found)] : [];
}
- if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
+ if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args);
throw new Error('unexpected statement: ' + sql);
}
@@ -315,7 +316,7 @@ describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); aft
let head = decodeHead(p.wires[0]);
expect(head.windowEnd).to.equal(now);
expect(head.rowCount, 'window_end is exclusive').to.equal(1);
- let body = abw.reassembleAttestBatch(head, []);
+ let body = abw.reassembleAttestBatch(head, [], isAdmissionEra);
expect(body.batch.rows[0].request_id).to.equal(first.request_id);
// The boundary row is not dropped: it rides the NEXT window, exactly once.
@@ -323,6 +324,6 @@ describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); aft
let next = decodeHead(p.wires[1]);
expect(next.windowStart).to.equal(now);
expect(next.rowCount).to.equal(1);
- expect(abw.reassembleAttestBatch(next, []).batch.rows[0].request_id).to.equal(edge.request_id);
+ expect(abw.reassembleAttestBatch(next, [], isAdmissionEra).batch.rows[0].request_id).to.equal(edge.request_id);
}); }); });
}
diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_008_the_batch_quorum.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_008_the_batch_quorum.test.js
index 741dd38c7..b4c88a43a 100644
--- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_008_the_batch_quorum.test.js
+++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_008_the_batch_quorum.test.js
@@ -37,6 +37,7 @@ const { expect } = require('chai');
const AttestationBatchPublisher = require('../../../../../src/attestation/batch_publisher.js');
const ValidatorIdentity = require('../../../../../src/validators/identity.js');
const abw = require('../../../../../src/lib/attest_batch_wire.js');
+const { isAdmissionEra } = require('../../../../../src/consensus/gates/mirror_admission_gate.js');
const { isNeverSentError, isAmbiguousSendError } = require('../../../../../src/lib/idempotent_broadcast.js');
const { DB_METHODS } = require('../../../../helpers/mockHub.js');
@@ -124,7 +125,7 @@ function doDbQuery(responses, markers, sql, args){
let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1]));
return found ? [Object.assign({}, found)] : [];
}
- if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
+ if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args);
throw new Error('unexpected statement: ' + sql);
}
@@ -255,7 +256,7 @@ const hookAt10827 = function () {
network: data.network, window_start: data.window_start,
window_end: data.window_end, row_count: data.row_count,
btc_block_height: data.btc_block_height, rows: data.rows
- });
+ }, isAdmissionEra);
for (let f of followers) {
// The follower's own payload shape, window bounds included: a
// co-signature that did not name its window would be counted into
@@ -315,7 +316,7 @@ describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); aft
// The proposal the round timed out on and the one it published from are the
// same window bytes.
expect(JSON.parse(firstProposal).window_start).to.equal(start);
- let body = abw.reassembleAttestBatch(head, []);
+ let body = abw.reassembleAttestBatch(head, [], isAdmissionEra);
expect(body.ok, body.status).to.equal(true);
expect(body.batch.sigs.length).to.be.at.least(2);
}); }); });
@@ -343,8 +344,8 @@ describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); aft
pubkey: followers[0].getPubkeyHex().toLowerCase(), sig: 'ab'.repeat(64) }
});
- let body = abw.reassembleAttestBatch(decodeHead(p.wires[0]), []);
- let canonical = abw.buildAttestBatchCanonical(body.batch);
+ let body = abw.reassembleAttestBatch(decodeHead(p.wires[0]), [], isAdmissionEra);
+ let canonical = abw.buildAttestBatchCanonical(body.batch, isAdmissionEra);
let qualified = new Set(hub._snapshot.validators.map(v => v.pubkey));
for (let s of body.batch.sigs) {
expect(qualified.has(s.pubkey), 'signer ' + s.pubkey.substring(0, 8) + ' is not in the set').to.equal(true);
diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_009_the_batch_quorum.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_009_the_batch_quorum.test.js
index 1143e27bf..291b68c16 100644
--- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_009_the_batch_quorum.test.js
+++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_009_the_batch_quorum.test.js
@@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){
let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1]));
return found ? [Object.assign({}, found)] : [];
}
- if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
+ if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args);
throw new Error('unexpected statement: ' + sql);
}
diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_010_the_publisher_election.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_010_the_publisher_election.test.js
index 9880a678a..ff02a86d1 100644
--- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_010_the_publisher_election.test.js
+++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_010_the_publisher_election.test.js
@@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){
let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1]));
return found ? [Object.assign({}, found)] : [];
}
- if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
+ if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args);
throw new Error('unexpected statement: ' + sql);
}
diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_011_the_landed_marker.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_011_the_landed_marker.test.js
index 88141edb3..7527cdd69 100644
--- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_011_the_landed_marker.test.js
+++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_011_the_landed_marker.test.js
@@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){
let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1]));
return found ? [Object.assign({}, found)] : [];
}
- if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
+ if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args);
throw new Error('unexpected statement: ' + sql);
}
@@ -242,6 +242,51 @@ const hookAt10827 = function () {
};
// ------------------------------------------------------------ landing
+describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('the landed marker', function () { it('records the window from which an otherwise empty marker history is tracking coverage', async function () {
+ let hub = makeHub({ dir: dir });
+ let p = makePublisher(hub);
+ let now = 200 * WINDOW_S;
+
+ await p.hydrateMarkers(now);
+
+ expect(hub.db.markers).to.deep.equal([{
+ network: 'regtest', window_start: now, window_end: now + WINDOW_S,
+ batch_key: null, row_count: 0, status: 'tracking'
+ }]);
+ expect(await p.getMarker(now),
+ 'tracking is evidence of participation, not a completed window').to.equal(null);
+ }); }); });
+
+describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('the landed marker', function () { it('turns the coverage-tracking row into a landed outcome', async function () {
+ let hub = makeHub({ dir: dir });
+ let p = makePublisher(hub);
+ let now = 200 * WINDOW_S;
+
+ await p.hydrateMarkers(now);
+ await p.recordLandedWindow(now, now + WINDOW_S, 'dogetxid', 0);
+
+ expect(hub.db.markers).to.have.length(1);
+ expect(hub.db.marker(now)).to.include({
+ window_start: now, window_end: now + WINDOW_S,
+ row_count: 0, status: 'landed'
+ });
+ }); }); });
+
+describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('the landed marker', function () { it('turns the coverage-tracking row into a publish intent before sending', async function () {
+ let hub = makeHub({ dir: dir });
+ let p = makePublisher(hub);
+ let now = 200 * WINDOW_S;
+ let window = { window_start: now, window_end: now + WINDOW_S, row_count: 2 };
+
+ await p.hydrateMarkers(now);
+ await p.recordIntent(window, 'batchkey');
+
+ expect(hub.db.markers).to.deep.equal([{
+ network: 'regtest', window_start: now, window_end: now + WINDOW_S,
+ batch_key: 'batchkey', row_count: 2, status: 'intent'
+ }]);
+ }); }); });
+
describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('the landed marker', function () { it('stops a window the federation has already landed from being published', async function () {
let hub = makeHub({ dir: dir });
let p = makePublisher(hub);
diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_013_the_catchup_horizon.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_013_the_catchup_horizon.test.js
new file mode 100644
index 000000000..947b43128
--- /dev/null
+++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_013_the_catchup_horizon.test.js
@@ -0,0 +1,301 @@
+/*********************************************************************
+ *
+ * Copyright © 2025–2026 Dankest, LLC
+ * Based on XChain Platform by Dankest, LLC – https://dankest.llc
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * This file is part of XChain Platform. Licensed under the GNU Affero
+ * General Public License v3.0 or later; see LICENSE.md. A commercial
+ * license (without AGPL source-disclosure terms) is available -
+ * contact legal@dankest.llc.
+ *
+ **********************************************************************
+ *
+ * XChain Hub - AttestationBatchPublisher unit tests (the ATTEST response-mirror
+ * design, §6.2).
+ *
+ * The cases here are the ones a reading of the diff cannot settle: that the window
+ * is on the unix hour and not on the process's own start, that an EMPTY window still
+ * publishes a coverage head, that an over-cap window dead-letters loudly rather than
+ * truncating itself, that a restart cannot pay for a window twice, and that a window
+ * whose quorum was unavailable is retried with the SAME bytes rather than a new
+ * proposal. The DB is a small in-memory pair of tables rather than call-counting
+ * stubs, because "the second publisher saw the first one's marker" is exactly the
+ * assertion a canned stub cannot fail.
+ *
+ ********************************************************************/
+
+'use strict';
+
+const os = require('os');
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { expect } = require('chai');
+
+const AttestationBatchPublisher = require('../../../../../src/attestation/batch_publisher.js');
+const ValidatorIdentity = require('../../../../../src/validators/identity.js');
+const abw = require('../../../../../src/lib/attest_batch_wire.js');
+const { isNeverSentError, isAmbiguousSendError } = require('../../../../../src/lib/idempotent_broadcast.js');
+const { MAX_CATCHUP_WINDOWS } = require('../../../../../src/attestation/batch_publisher/constants.js');
+const { DB_METHODS } = require('../../../../helpers/mockHub.js');
+
+const WINDOW_S = 10; // regtest override; the whole suite closes windows in seconds
+const ANCHOR = 941234;
+
+// ---------------------------------------------------------------------------
+// Two in-memory tables with just enough SQL surface for the publisher's reads and
+// writes, plus the chain-tip accessor it resolves its anchor from.
+// ---------------------------------------------------------------------------
+function selectResponses(responses, sql, args){
+ // Membership is read off the column the statement actually names, so a
+ // publisher that went back to the audit column selects nothing here.
+ let column = /WHERE network = \? AND ([a-z_]+) >= \?/i.exec(sql)[1];
+ let [network, from, to, limit] = args;
+ return responses
+ .filter(r => r.network === network && r[column] >= from && r[column] < to)
+ .sort((a, b) => (a.request_block_index - b.request_block_index) ||
+ (a.request_action_index - b.request_action_index) ||
+ (a.request_id < b.request_id ? -1 : 1))
+ .slice(0, limit)
+ .map(r => Object.assign({}, r));
+}
+
+function deleteMarker(markers, args){
+ // Read BEFORE the marker SELECT below, whose predicate this statement shares:
+ // matched the other way round, a withdraw would silently read as a lookup and
+ // the row it was meant to remove would survive.
+ let [network, windowStart, fromStatus] = args;
+ // The status predicate is honoured, because the whole safety argument for
+ // the withdraw is that it can only remove an intent-only row: a fake that
+ // deleted unconditionally could not fail the case that matters.
+ let idx = markers.findIndex(m => m.network === network &&
+ Number(m.window_start) === Number(windowStart) &&
+ m.status === fromStatus);
+ if(idx < 0) return { affectedRows: 0 };
+ markers.splice(idx, 1);
+ return { affectedRows: 1 };
+}
+
+function insertMarker(markers, sql, args){
+ let cols = sql.substring(sql.indexOf('(') + 1, sql.indexOf(')')).split(',').map(s => s.trim());
+ let row = {};
+ cols.forEach((c, i) => { row[c] = args[i]; });
+ let existing = markers.find(m => Number(m.window_start) === Number(row.window_start));
+ if(existing){
+ // Only the landed/dead-letter statements carry an updating clause; the
+ // intent statement's is a deliberate no-op, and the fake honours that
+ // difference so a replay cannot downgrade a marker here either.
+ if(/status = VALUES\(status\)/i.test(sql)){
+ existing.status = row.status;
+ existing.row_count = row.row_count;
+ }
+ return { affectedRows: 0 };
+ }
+ markers.push(row);
+ return { affectedRows: 1 };
+}
+
+function updateMarker(markers, args){
+ let [status, txid, rowCount, network, windowStart, fromStatus] = args;
+ let found = markers.find(m => m.network === network &&
+ Number(m.window_start) === Number(windowStart) &&
+ m.status === fromStatus);
+ if(!found) return { affectedRows: 0 };
+ Object.assign(found, { status, txid, row_count: rowCount });
+ return { affectedRows: 1 };
+}
+
+function doDbQuery(responses, markers, sql, args){
+ if(/FROM attestation_responses/i.test(sql)) return selectResponses(responses, sql, args);
+ if(/^DELETE FROM attest_published_batches/i.test(sql)) return deleteMarker(markers, args);
+ if(/SELECT MIN\(window_start\).*window_start < \?/i.test(sql)){
+ let [network, status, before] = args;
+ let starts = markers.filter(m => m.network === network && m.status === status &&
+ Number(m.window_start) < Number(before))
+ .map(m => Number(m.window_start));
+ return [{ oldest: starts.length ? Math.min.apply(null, starts) : null,
+ newest: starts.length ? Math.max.apply(null, starts) : null,
+ count: starts.length }];
+ }
+ // The floor read. BOTH aggregates are answered from the same row set, so a
+ // publisher that went back to flooring on the newest marker reads a real
+ // value here rather than an undefined the test would silently coerce.
+ if(/SELECT MIN\(window_start\)/i.test(sql)){
+ let oldest = markers.reduce((m, r) => Math.min(m, Number(r.window_start)), Infinity);
+ let newest = markers.reduce((m, r) => Math.max(m, Number(r.window_start)), 0);
+ return [{ oldest: Number.isFinite(oldest) ? oldest : null, newest: newest || null }];
+ }
+ if(/SELECT window_start FROM attest_published_batches.*window_start >= \?/i.test(sql)){
+ return markers.filter(m => m.network === args[0] && m.status === args[1] &&
+ Number(m.window_start) >= Number(args[2]))
+ .map(m => ({ window_start: m.window_start }));
+ }
+ if(/SELECT window_start FROM attest_published_batches/i.test(sql)){
+ return markers.filter(m => m.network === args[0] && m.status === args[1])
+ .map(m => ({ window_start: m.window_start }));
+ }
+ if(/FROM attest_published_batches WHERE network = \? AND window_start = \?/i.test(sql)){
+ let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1]));
+ return found ? [Object.assign({}, found)] : [];
+ }
+ if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
+ if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args);
+ throw new Error('unexpected statement: ' + sql);
+}
+
+function makeDb(){
+ let responses = [];
+ let markers = [];
+ let tip = { blockHeight: ANCHOR, blockTime: 1 };
+ return { ...DB_METHODS,
+ responses, markers,
+ setTip(h){ tip = (h === null) ? null : { blockHeight: h, blockTime: 1 }; },
+ marker(windowStart){ return markers.find(m => Number(m.window_start) === Number(windowStart)) || null; },
+ async getChainTip(){ return tip; },
+ async doQuery(sql, args){
+ return doDbQuery(responses, markers, sql, args);
+ }
+ };
+}
+
+// `effective_time` is what places a row in a window, so every case sets it. The
+// default `finalized_at` is deliberately absurd: it belongs to no window any test
+// publishes, so a publisher that partitioned on the audit column would find an empty
+// window everywhere in this file rather than a subtly different one.
+function makeRow(overrides){
+ let rid = crypto.randomBytes(32).toString('hex');
+ return Object.assign({
+ network: 'regtest',
+ request_id: rid,
+ request_action_index: 4400,
+ request_block_index: 120,
+ provider_id: 'http_get',
+ status: 'ok',
+ response_payload: '{"ok":true}',
+ response_hash: crypto.createHash('sha256').update('body').digest('hex'),
+ meta: '200',
+ effective_time: 1780000120,
+ signer_pubkeys: '[]',
+ signatures: '[]',
+ widen: 0,
+ finalized_at: 7
+ }, overrides || {});
+}
+
+// A capability snapshot of `n` validators, the first of which is this hub. Weights
+// are deliberately uneven so a two-of-three signer set clears the strict 2/3 stake
+// bar, which equal weights would sit exactly on and fail.
+function makeSnapshot(privkeys){
+ let weights = ['100', '100', '10', '10', '10'];
+ return {
+ validators: privkeys.map((pk, i) => ({
+ pubkey: new ValidatorIdentity(pk).getPubkeyHex().toLowerCase(),
+ weight: weights[i] || '10',
+ amount: weights[i] || '10',
+ source: 'src' + i
+ })),
+ count: privkeys.length
+ };
+}
+
+function makeHub(opts){
+ opts = opts || {};
+ let db = opts.db || makeDb();
+ let ids = opts.identities || [ValidatorIdentity.generate()];
+ let identity = new ValidatorIdentity(ids[0].privkeyHex);
+ let snapshot = makeSnapshot(ids.map(i => i.privkeyHex));
+ return {
+ network: opts.network || 'regtest',
+ db: db,
+ p2pConfig: Object.assign({
+ ATTEST_BATCH_WINDOW_S_OVERRIDE: String(WINDOW_S),
+ ATTEST_BATCH_BUFFER_PATH: path.join(opts.dir, 'attest-batch-buffer.jsonl'),
+ ATTEST_BATCH_SPEND_STATE_PATH: path.join(opts.dir, 'spend-state.json'),
+ ORACLE_BATCH_SIGN_TIMEOUT_MS: '40'
+ }, opts.cfg || {}),
+ getIdentity: () => identity,
+ capabilitySnapshot: opts.capabilitySnapshot || {
+ async getWeightSnapshot(){ return snapshot; },
+ async getSnapshot(){ return snapshot; }
+ },
+ peerManager: opts.peerManager || null,
+ _identity: identity,
+ _snapshot: snapshot
+ };
+}
+
+// A publisher wired to a capturing broadcaster. Returns both so a test can read the
+// wires that actually went out.
+function makePublisher(hub){
+ let p = new AttestationBatchPublisher(hub);
+ let sent = [];
+ p.setBroadcastHook(async (payload) => {
+ sent.push(payload);
+ return { txid: 'tx' + sent.length };
+ });
+ p.wires = sent;
+ return p;
+}
+
+function decodeHead(wire){
+ let params = wire.split('|').slice(1);
+ let head = abw.parseAttestBatchHead(params);
+ if(!head.ok) throw new Error('head did not parse: ' + head.status);
+ return head;
+}
+
+{
+let dir;
+
+const hookAt10719 = function () {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'attest-batch-'));
+ };
+
+const hookAt10827 = function () {
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* best effort */ }
+ };
+
+
+ // The set is deliberately report-once process memory, so entries remain after
+ // their windows age out. The public statistic is narrower: it counts only the
+ // quarantines a bounded sweep can still reach.
+describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('publishing a window', function () { it('drops quarantines from the statistic when they leave the catch-up horizon', async function () {
+ let hub = makeHub({ dir: dir });
+ let now = 200 * WINDOW_S;
+ let oldestReachable = now - MAX_CATCHUP_WINDOWS * WINDOW_S;
+ let newestReachable = now - WINDOW_S;
+ hub.db.markers.push(
+ { network: 'regtest', window_start: oldestReachable, status: 'intent' },
+ { network: 'regtest', window_start: newestReachable, status: 'intent' }
+ );
+ let p = makePublisher(hub);
+ p.nowSeconds = () => now;
+
+ let realError = console.error;
+ try {
+ console.error = () => {};
+ await p.hydrateMarkers();
+ } finally {
+ console.error = realError;
+ }
+
+ expect(p._quarantined.size, 'both reachable markers stay in report-once memory').to.equal(2);
+ expect(p.getStats().quarantinedWindows,
+ 'the boundary window is still inside the horizon').to.equal(2);
+
+ p.nowSeconds = () => now + WINDOW_S;
+ expect(p._quarantined.size, 'aging does not erase report-once memory').to.equal(2);
+ expect(p.getStats().quarantinedWindows,
+ 'the marker below the moving horizon is not actionable').to.equal(1);
+ }); }); });
+
+// ------------------------------------------------------------ publishing
+
+
+
+ // A federation that shares one Bitcoin indexer has one hub with a chain_tips
+ // row and N-1 without (testnet 2026-09-07: four of five validators). Every
+ // one of them polls that indexer for requests, and the poll reports the tip,
+}
diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_014_one_broadcast_per_window.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_014_one_broadcast_per_window.test.js
new file mode 100644
index 000000000..75f7a497e
--- /dev/null
+++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_014_one_broadcast_per_window.test.js
@@ -0,0 +1,197 @@
+/*********************************************************************
+ *
+ * Copyright © 2025–2026 Dankest, LLC
+ * Based on XChain Platform by Dankest, LLC - https://dankest.llc
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * This file is part of XChain Platform. Licensed under the GNU Affero
+ * General Public License v3.0 or later; see LICENSE.md. A commercial
+ * license (without AGPL source-disclosure terms) is available -
+ * contact legal@dankest.llc.
+ *
+ **********************************************************************
+ *
+ * XChain Hub - one durable claim and one broadcast per ATTEST batch window.
+ *
+ ********************************************************************/
+
+'use strict';
+
+const os = require('os');
+const fs = require('fs');
+const path = require('path');
+const { expect } = require('chai');
+
+const AttestationBatchPublisher = require('../../../../../src/attestation/batch_publisher.js');
+const ValidatorIdentity = require('../../../../../src/validators/identity.js');
+const abw = require('../../../../../src/lib/attest_batch_wire.js');
+const { DB_METHODS } = require('../../../../helpers/mockHub.js');
+
+const WINDOW_S = 10;
+const ANCHOR = 941234;
+
+// A shared durable marker table. INSERT executes synchronously before the returned
+// promise resolves, matching the unique-key decision made by MariaDB.
+function makeDb(){
+ let markers = [];
+ let insertAttempts = 0;
+ return { ...DB_METHODS,
+ markers,
+ get insertAttempts(){ return insertAttempts; },
+ marker(windowStart){
+ return markers.find(m => Number(m.window_start) === Number(windowStart)) || null;
+ },
+ async getChainTip(){ return { blockHeight: ANCHOR, blockTime: 1 }; },
+ async doQuery(sql, args){
+ if(/^DELETE FROM attest_published_batches/i.test(sql)){
+ let [network, windowStart, fromStatus] = args;
+ let i = markers.findIndex(m => m.network === network &&
+ Number(m.window_start) === Number(windowStart) && m.status === fromStatus);
+ if(i < 0) return { affectedRows: 0 };
+ markers.splice(i, 1);
+ return { affectedRows: 1 };
+ }
+ if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)){
+ insertAttempts++;
+ let [network, windowStart, windowEnd, batchKey, rowCount, status] = args;
+ let existing = markers.find(m => m.network === network &&
+ Number(m.window_start) === Number(windowStart));
+ if(existing) return { affectedRows: 0 };
+ markers.push({ network, window_start: windowStart, window_end: windowEnd,
+ batch_key: batchKey, row_count: rowCount, status, txid: null });
+ return { affectedRows: 1 };
+ }
+ if(/^UPDATE attest_published_batches SET status/i.test(sql)){
+ let [status, txid, rowCount, network, windowStart, fromStatus] = args;
+ let marker = markers.find(m => m.network === network &&
+ Number(m.window_start) === Number(windowStart) && m.status === fromStatus);
+ if(!marker) return { affectedRows: 0 };
+ Object.assign(marker, { status, txid, row_count: rowCount });
+ return { affectedRows: 1 };
+ }
+ if(/FROM attest_published_batches WHERE network = \? AND window_start = \?/i.test(sql)){
+ let marker = markers.find(m => m.network === args[0] &&
+ Number(m.window_start) === Number(args[1]));
+ return marker ? [Object.assign({}, marker)] : [];
+ }
+ throw new Error('unexpected statement: ' + sql);
+ }
+ };
+}
+
+function makeHub(db, identity, dir, index){
+ let pubkey = identity.getPubkeyHex().toLowerCase();
+ let snapshot = {
+ validators: [{ pubkey, weight: '100', amount: '100', source: 'validator-' + index }],
+ count: 1
+ };
+ return {
+ network: 'regtest',
+ db,
+ p2pConfig: {
+ ATTEST_BATCH_WINDOW_S_OVERRIDE: String(WINDOW_S),
+ ATTEST_BATCH_BUFFER_PATH: path.join(dir, 'buffer-' + index + '.jsonl'),
+ ATTEST_BATCH_SPEND_STATE_PATH: path.join(dir, 'spend-' + index + '.json'),
+ ORACLE_BATCH_SIGN_TIMEOUT_MS: '40'
+ },
+ getIdentity: () => identity,
+ capabilitySnapshot: {
+ async getWeightSnapshot(){ return snapshot; },
+ async getSnapshot(){ return snapshot; }
+ },
+ peerManager: null
+ };
+}
+
+function makePublisher(hub){
+ let publisher = new AttestationBatchPublisher(hub);
+ let wires = [];
+ publisher.setBroadcastHook(async payload => {
+ wires.push(payload);
+ return { txid: 'tx-' + wires.length };
+ });
+ publisher.wires = wires;
+ return publisher;
+}
+
+function emptyWindow(){
+ let windowStart = 199 * WINDOW_S;
+ return {
+ network: 'regtest',
+ window_start: windowStart,
+ window_end: windowStart + WINDOW_S,
+ row_count: 0,
+ btc_block_height: ANCHOR,
+ rows: []
+ };
+}
+
+// Hold every publisher after signing so they reach the durable claim as peers,
+// rather than letting the first finish before the others start.
+function holdAtClaim(publishers){
+ let arrived = 0;
+ let release;
+ let gate = new Promise(resolve => { release = resolve; });
+ for(let publisher of publishers){
+ let collect = publisher.collectBatchSignatures.bind(publisher);
+ publisher.collectBatchSignatures = async (window, batchKey) => {
+ let signed = await collect(window, batchKey);
+ arrived++;
+ if(arrived === publishers.length) release();
+ await gate;
+ return signed;
+ };
+ }
+}
+
+function makeThreePublishers(db, dir){
+ return [1, 2, 3].map(index => {
+ let identity = new ValidatorIdentity(ValidatorIdentity.generate().privkeyHex);
+ return makePublisher(makeHub(db, identity, dir, index));
+ });
+}
+
+describe('AttestationBatchPublisher', function () {
+ let dir;
+
+ beforeEach(function () {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'attest-one-window-'));
+ });
+
+ afterEach(function () {
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* best effort */ }
+ });
+
+ describe('one broadcast per window', function () {
+ it('lets one of three validator publishers claim and broadcast the shared window', async function () {
+ let db = makeDb();
+ let publishers = makeThreePublishers(db, dir);
+ expect(new Set(publishers.map(p => p.identity.getPubkeyHex())).size).to.equal(3);
+ holdAtClaim(publishers);
+
+ let window = emptyWindow();
+ let batchKey = abw.computeBatchKey(window);
+ let results = await Promise.all(publishers.map(p =>
+ p.signAndBroadcastWindow(Object.assign({}, window, { rows: [] }), batchKey)));
+
+ expect(results.filter(Boolean).length).to.equal(1);
+ expect(publishers.reduce((n, p) => n + p.wires.length, 0)).to.equal(1);
+ expect(db.insertAttempts).to.equal(3);
+ expect(db.markers).to.have.length(1);
+ expect(db.marker(window.window_start).status).to.equal('sent');
+ });
+
+ it('does not claim a window when the broadcast pipeline cannot attempt a send', async function () {
+ let db = makeDb();
+ let identity = new ValidatorIdentity(ValidatorIdentity.generate().privkeyHex);
+ let publisher = new AttestationBatchPublisher(makeHub(db, identity, dir, 1));
+ let window = emptyWindow();
+
+ expect(await publisher.signAndBroadcastWindow(
+ window, abw.computeBatchKey(window))).to.equal(false);
+ expect(db.insertAttempts).to.equal(0);
+ expect(db.marker(window.window_start)).to.equal(null);
+ });
+ });
+});
diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_015_a_head_refused_by_create_tx.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_015_a_head_refused_by_create_tx.test.js
new file mode 100644
index 000000000..1c430d96e
--- /dev/null
+++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_015_a_head_refused_by_create_tx.test.js
@@ -0,0 +1,339 @@
+/*********************************************************************
+ *
+ * Copyright © 2025–2026 Dankest, LLC
+ * Based on XChain Platform by Dankest, LLC – https://dankest.llc
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * This file is part of XChain Platform. Licensed under the GNU Affero
+ * General Public License v3.0 or later; see LICENSE.md. A commercial
+ * license (without AGPL source-disclosure terms) is available -
+ * contact legal@dankest.llc.
+ *
+ **********************************************************************
+ *
+ * XChain Hub - AttestationBatchPublisher unit tests: a head the encoder refused at
+ * create_tx. The encoder answers that refusal as an HTTP 200 JSON-RPC error, so no
+ * status or transport code proves it unsent; the default pipeline's own neverSent tag
+ * on a build-stage failure is what lets the window withdraw its marker and rebuild.
+ *
+ ********************************************************************/
+
+'use strict';
+
+const os = require('os');
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { expect } = require('chai');
+
+const AttestationBatchPublisher = require('../../../../../src/attestation/batch_publisher.js');
+const ValidatorIdentity = require('../../../../../src/validators/identity.js');
+const abw = require('../../../../../src/lib/attest_batch_wire.js');
+const { isNeverSentError, isAmbiguousSendError } = require('../../../../../src/lib/idempotent_broadcast.js');
+const { DB_METHODS } = require('../../../../helpers/mockHub.js');
+
+const WINDOW_S = 10; // regtest override; the whole suite closes windows in seconds
+const ANCHOR = 941234;
+
+// ---------------------------------------------------------------------------
+// Two in-memory tables with just enough SQL surface for the publisher's reads and
+// writes, plus the chain-tip accessor it resolves its anchor from.
+// ---------------------------------------------------------------------------
+function selectResponses(responses, sql, args){
+ // Membership is read off the column the statement actually names, so a
+ // publisher that went back to the audit column selects nothing here.
+ let column = /WHERE network = \? AND ([a-z_]+) >= \?/i.exec(sql)[1];
+ let [network, from, to, limit] = args;
+ return responses
+ .filter(r => r.network === network && r[column] >= from && r[column] < to)
+ .sort((a, b) => (a.request_block_index - b.request_block_index) ||
+ (a.request_action_index - b.request_action_index) ||
+ (a.request_id < b.request_id ? -1 : 1))
+ .slice(0, limit)
+ .map(r => Object.assign({}, r));
+}
+
+function deleteMarker(markers, args){
+ // Read BEFORE the marker SELECT below, whose predicate this statement shares:
+ // matched the other way round, a withdraw would silently read as a lookup and
+ // the row it was meant to remove would survive.
+ let [network, windowStart, fromStatus] = args;
+ // The status predicate is honoured, because the whole safety argument for
+ // the withdraw is that it can only remove an intent-only row: a fake that
+ // deleted unconditionally could not fail the case that matters.
+ let idx = markers.findIndex(m => m.network === network &&
+ Number(m.window_start) === Number(windowStart) &&
+ m.status === fromStatus);
+ if(idx < 0) return { affectedRows: 0 };
+ markers.splice(idx, 1);
+ return { affectedRows: 1 };
+}
+
+function insertMarker(markers, sql, args){
+ let cols = sql.substring(sql.indexOf('(') + 1, sql.indexOf(')')).split(',').map(s => s.trim());
+ let row = {};
+ cols.forEach((c, i) => { row[c] = args[i]; });
+ let existing = markers.find(m => Number(m.window_start) === Number(row.window_start));
+ if(existing){
+ // Only the landed/dead-letter statements carry an updating clause; the
+ // intent statement's is a deliberate no-op, and the fake honours that
+ // difference so a replay cannot downgrade a marker here either.
+ if(/status = VALUES\(status\)/i.test(sql)){
+ existing.status = row.status;
+ existing.row_count = row.row_count;
+ }
+ return { affectedRows: 0 };
+ }
+ markers.push(row);
+ return { affectedRows: 1 };
+}
+
+function updateMarker(markers, args){
+ let [status, txid, rowCount, network, windowStart, fromStatus] = args;
+ let found = markers.find(m => m.network === network &&
+ Number(m.window_start) === Number(windowStart) &&
+ m.status === fromStatus);
+ if(!found) return { affectedRows: 0 };
+ Object.assign(found, { status, txid, row_count: rowCount });
+ return { affectedRows: 1 };
+}
+
+function doDbQuery(responses, markers, sql, args){
+ if(/FROM attestation_responses/i.test(sql)) return selectResponses(responses, sql, args);
+ if(/^DELETE FROM attest_published_batches/i.test(sql)) return deleteMarker(markers, args);
+ // The floor read. BOTH aggregates are answered from the same row set, so a
+ // publisher that went back to flooring on the newest marker reads a real
+ // value here rather than an undefined the test would silently coerce.
+ if(/SELECT MIN\(window_start\)/i.test(sql)){
+ let oldest = markers.reduce((m, r) => Math.min(m, Number(r.window_start)), Infinity);
+ let newest = markers.reduce((m, r) => Math.max(m, Number(r.window_start)), 0);
+ return [{ oldest: Number.isFinite(oldest) ? oldest : null, newest: newest || null }];
+ }
+ if(/SELECT window_start FROM attest_published_batches/i.test(sql)){
+ return markers.filter(m => m.status === args[1]).map(m => ({ window_start: m.window_start }));
+ }
+ if(/FROM attest_published_batches WHERE network = \? AND window_start = \?/i.test(sql)){
+ let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1]));
+ return found ? [Object.assign({}, found)] : [];
+ }
+ if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args);
+ if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args);
+ throw new Error('unexpected statement: ' + sql);
+}
+
+function makeDb(){
+ let responses = [];
+ let markers = [];
+ let tip = { blockHeight: ANCHOR, blockTime: 1 };
+ return { ...DB_METHODS,
+ responses, markers,
+ setTip(h){ tip = (h === null) ? null : { blockHeight: h, blockTime: 1 }; },
+ marker(windowStart){ return markers.find(m => Number(m.window_start) === Number(windowStart)) || null; },
+ async getChainTip(){ return tip; },
+ async doQuery(sql, args){
+ return doDbQuery(responses, markers, sql, args);
+ }
+ };
+}
+
+// `effective_time` is what places a row in a window, so every case sets it. The
+// default `finalized_at` is deliberately absurd: it belongs to no window any test
+// publishes, so a publisher that partitioned on the audit column would find an empty
+// window everywhere in this file rather than a subtly different one.
+function makeRow(overrides){
+ let rid = crypto.randomBytes(32).toString('hex');
+ return Object.assign({
+ network: 'regtest',
+ request_id: rid,
+ request_action_index: 4400,
+ request_block_index: 120,
+ provider_id: 'http_get',
+ status: 'ok',
+ response_payload: '{"ok":true}',
+ response_hash: crypto.createHash('sha256').update('body').digest('hex'),
+ meta: '200',
+ effective_time: 1780000120,
+ signer_pubkeys: '[]',
+ signatures: '[]',
+ widen: 0,
+ finalized_at: 7
+ }, overrides || {});
+}
+
+// A capability snapshot of `n` validators, the first of which is this hub. Weights
+// are deliberately uneven so a two-of-three signer set clears the strict 2/3 stake
+// bar, which equal weights would sit exactly on and fail.
+function makeSnapshot(privkeys){
+ let weights = ['100', '100', '10', '10', '10'];
+ return {
+ validators: privkeys.map((pk, i) => ({
+ pubkey: new ValidatorIdentity(pk).getPubkeyHex().toLowerCase(),
+ weight: weights[i] || '10',
+ amount: weights[i] || '10',
+ source: 'src' + i
+ })),
+ count: privkeys.length
+ };
+}
+
+function makeHub(opts){
+ opts = opts || {};
+ let db = opts.db || makeDb();
+ let ids = opts.identities || [ValidatorIdentity.generate()];
+ let identity = new ValidatorIdentity(ids[0].privkeyHex);
+ let snapshot = makeSnapshot(ids.map(i => i.privkeyHex));
+ return {
+ network: opts.network || 'regtest',
+ db: db,
+ p2pConfig: Object.assign({
+ ATTEST_BATCH_WINDOW_S_OVERRIDE: String(WINDOW_S),
+ ATTEST_BATCH_BUFFER_PATH: path.join(opts.dir, 'attest-batch-buffer.jsonl'),
+ ATTEST_BATCH_SPEND_STATE_PATH: path.join(opts.dir, 'spend-state.json'),
+ ORACLE_BATCH_SIGN_TIMEOUT_MS: '40'
+ }, opts.cfg || {}),
+ getIdentity: () => identity,
+ capabilitySnapshot: opts.capabilitySnapshot || {
+ async getWeightSnapshot(){ return snapshot; },
+ async getSnapshot(){ return snapshot; }
+ },
+ peerManager: opts.peerManager || null,
+ _identity: identity,
+ _snapshot: snapshot
+ };
+}
+
+function decodeHead(wire){
+ let params = wire.split('|').slice(1);
+ let head = abw.parseAttestBatchHead(params);
+ if(!head.ok) throw new Error('head did not parse: ' + head.status);
+ return head;
+}
+
+// The encoder refusing create_tx the way it really does: HTTP 200, a JSON-RPC error
+// body, code -32010 and a machine-readable reason, rethrown by EncoderClient with no
+// `.response` and no `.code`.
+function createTxRefusal(reason){
+ return Object.assign(new Error('Encoder RPC error: Insufficient funds'),
+ { rpcCode: -32010, rpcData: { reason: reason || 'INSUFFICIENT_FUNDS' } });
+}
+
+// An encoder stub whose create_tx answers from `script` in order, then builds; every
+// broadcast_tx succeeds and is recorded.
+function makeEncoder(script){
+ let enc = { createCalls: 0, sent: [] };
+ enc.getUtxos = async () => [{ txid: 'aa'.repeat(32), vout: 0, value: 100000000, confirmations: 6 }];
+ enc.createTx = async (params) => {
+ let e = script[enc.createCalls++];
+ if(e) throw e;
+ return { psbt: 'psbt:' + params.data };
+ };
+ enc.broadcastTx = async (txHex) => { enc.sent.push(txHex); return { txid: 'tx' + enc.sent.length }; };
+ return enc;
+}
+
+// A publisher on its real default pipeline, with the encoder and wallet hook stubbed.
+function makeDefaultPipelinePublisher(hub, encoder){
+ let p = new AttestationBatchPublisher(hub);
+ p.encoder = encoder;
+ p.dogeAddress = 'DTestAddressXXXXXXXXXXXXXXXXXXXXXX';
+ p.setWalletSignHook(async (psbt) => 'signed:' + psbt);
+ return p;
+}
+
+let dir;
+
+function registerClassifierTests() {
+ it('reads a neverSent tag as never sent and not ambiguous', function () {
+ let e = Object.assign(createTxRefusal(), { neverSent: true });
+ expect(isNeverSentError(e)).to.equal(true);
+ expect(isAmbiguousSendError(e)).to.equal(false);
+ let timeout = Object.assign(new Error('timeout of 120000ms exceeded'), { code: 'ECONNABORTED', neverSent: true });
+ expect(isNeverSentError(timeout)).to.equal(true);
+ expect(isAmbiguousSendError(timeout)).to.equal(false);
+ });
+
+ it('lets fundsCommitted outrank a neverSent tag in both predicates', function () {
+ let e = Object.assign(createTxRefusal(), { neverSent: true, fundsCommitted: true });
+ expect(isNeverSentError(e)).to.equal(false);
+ expect(isAmbiguousSendError(e)).to.equal(true);
+ });
+
+ it('does not read an untagged create_tx refusal as never sent', function () {
+ expect(isNeverSentError(createTxRefusal())).to.equal(false);
+ expect(isNeverSentError(Object.assign(createTxRefusal(), { neverSent: 'true' }))).to.equal(false);
+ });
+}
+
+function registerPipelineTaggingTests() {
+ it('tags a create_tx refusal, a get_utxos timeout and a missing PSBT, but never a broadcast failure', async function () {
+ let hub = makeHub({ dir: dir });
+ let p = makeDefaultPipelinePublisher(hub, makeEncoder([createTxRefusal()]));
+ let err = await p.defaultBroadcast('P').then(() => null, (e) => e);
+ expect(err.neverSent).to.equal(true);
+ expect(err.rpcCode).to.equal(-32010);
+
+ p.encoder.getUtxos = async () => { throw Object.assign(new Error('timeout'), { code: 'ECONNABORTED' }); };
+ expect((await p.defaultBroadcast('P').then(() => null, (e) => e)).neverSent).to.equal(true);
+
+ p = makeDefaultPipelinePublisher(hub, makeEncoder([]));
+ p.encoder.createTx = async () => ({});
+ expect((await p.defaultBroadcast('P').then(() => null, (e) => e)).neverSent).to.equal(true);
+
+ p = makeDefaultPipelinePublisher(hub, makeEncoder([]));
+ p.encoder.broadcastTx = async () => { throw new Error('socket hang up'); };
+ let sendErr = await p.defaultBroadcast('P').then(() => null, (e) => e);
+ expect(sendErr.neverSent).to.equal(undefined);
+ expect(isAmbiguousSendError(sendErr)).to.equal(true);
+ });
+}
+
+function registerWindowTests() {
+ it('withdraws the intent marker on a create_tx head refusal and lands the window next cycle', async function () {
+ let hub = makeHub({ dir: dir });
+ let now = 200 * WINDOW_S;
+ let start = now - WINDOW_S;
+ hub.db.responses.push(makeRow({ effective_time: start + 1 }));
+ let enc = makeEncoder([createTxRefusal()]);
+ let p = makeDefaultPipelinePublisher(hub, enc);
+ p._floorWindow = start;
+
+ await p.sweep(now);
+ expect(enc.sent.length, 'the refused head never went out').to.equal(0);
+ expect(hub.db.marker(start), 'no intent marker may strand an unsent window').to.equal(null);
+ expect(p.stats.windowsRefusalRetried).to.equal(1);
+ expect(p.spendGuard.spentInWindow()).to.equal(0);
+
+ let result = await p.sweep(now);
+ expect(result.published, 'the rebuilt window must publish').to.equal(1);
+ expect(enc.sent.length).to.equal(1);
+ expect(decodeHead(enc.sent[0].replace(/^signed:psbt:/, '')).windowStart).to.equal(start);
+ expect(hub.db.marker(start).status).to.equal('sent');
+ });
+
+ it('still quarantines once create_tx refusals reach the attempt bound', async function () {
+ let hub = makeHub({ dir: dir, cfg: { ATTEST_BATCH_MAX_REFUSAL_ATTEMPTS: '2' } });
+ let now = 200 * WINDOW_S;
+ let start = now - WINDOW_S;
+ hub.db.responses.push(makeRow({ effective_time: start + 1 }));
+ let enc = makeEncoder([createTxRefusal(), createTxRefusal(), createTxRefusal(), createTxRefusal()]);
+ let p = makeDefaultPipelinePublisher(hub, enc);
+ p._floorWindow = start;
+
+ for (let i = 0; i < 4; i++) await p.sweep(now);
+ expect(enc.sent.length).to.equal(0);
+ expect(p.stats.windowsRefusalRetried).to.equal(1);
+ expect(p.stats.windowsQuarantined).to.equal(1);
+ });
+}
+
+describe('AttestationBatchPublisher: a head refused at create_tx', function () {
+ beforeEach(function () { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'attest-batch-')); });
+ afterEach(function () {
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* best effort */ }
+ });
+
+ describe('the classifier', registerClassifierTests);
+ describe('the default pipeline', registerPipelineTaggingTests);
+ describe('the window', registerWindowTests);
+});
diff --git a/test/unit/attestation/response_mirror/attest_batch_receive.test.js b/test/unit/attestation/response_mirror/attest_batch_receive.test.js
index b79ac6ecd..07f524bbe 100644
--- a/test/unit/attestation/response_mirror/attest_batch_receive.test.js
+++ b/test/unit/attestation/response_mirror/attest_batch_receive.test.js
@@ -35,6 +35,7 @@ const { expect } = require('chai');
const AttestationResponseMirror = require('../../../../src/attestation/response_mirror.js');
const ValidatorIdentity = require('../../../../src/validators/identity.js');
const abw = require('../../../../src/lib/attest_batch_wire.js');
+const { isAdmissionEra } = require('../../../../src/consensus/gates/mirror_admission_gate.js');
const { DB_METHODS } = require('../../../helpers/mockHub.js');
const ANCHOR = 941234;
@@ -106,7 +107,7 @@ function makeBatch(rows, signers, overrides){
network: 'regtest', window_start: WINDOW_START, window_end: WINDOW_END,
row_count: rows.length, btc_block_height: ANCHOR, rows: rows
}, overrides || {});
- let canonical = abw.buildAttestBatchCanonical(header);
+ let canonical = abw.buildAttestBatchCanonical(header, isAdmissionEra);
return Object.assign({}, header, {
sigs: signers.map(s => ({ pubkey: s.getPubkeyHex().toLowerCase(), sig: s.sign(canonical) })),
action_index: ACTION_INDEX, block_index: 700123, block_time: 1780004000, push_generation: 3
diff --git a/test/unit/attestation/response_mirror/attestation_response_mirror_batch_retraction.test.js b/test/unit/attestation/response_mirror/attestation_response_mirror_batch_retraction.test.js
index 97e93f39b..d8be0d857 100644
--- a/test/unit/attestation/response_mirror/attestation_response_mirror_batch_retraction.test.js
+++ b/test/unit/attestation/response_mirror/attestation_response_mirror_batch_retraction.test.js
@@ -34,6 +34,7 @@ const { expect } = require('chai');
const AttestationResponseMirror = require('../../../../src/attestation/response_mirror.js');
const ValidatorIdentity = require('../../../../src/validators/identity.js');
const abw = require('../../../../src/lib/attest_batch_wire.js');
+const { isAdmissionEra } = require('../../../../src/consensus/gates/mirror_admission_gate.js');
const { DB_METHODS } = require('../../../helpers/mockHub.js');
const ANCHOR = 941234;
@@ -123,7 +124,7 @@ function makeBatch(rows, signers, overrides){
network: NETWORK, window_start: WINDOW_START, window_end: WINDOW_END,
row_count: rows.length, btc_block_height: ANCHOR, rows: rows
}, overrides || {});
- let canonical = abw.buildAttestBatchCanonical(header);
+ let canonical = abw.buildAttestBatchCanonical(header, isAdmissionEra);
return Object.assign({}, header, {
sigs: signers.map(s => ({ pubkey: s.getPubkeyHex().toLowerCase(), sig: s.sign(canonical) })),
action_index: ACTION_INDEX, block_index: 700123, block_time: 1780004000, push_generation: 3
diff --git a/test/unit/attestation/round/attestation_round_006_computeresponsibleset_canonical_vector_conformance_regression.test.js b/test/unit/attestation/round/attestation_round_006_computeresponsibleset_canonical_vector_conformance_regression.test.js
index 46678e6a6..0873e40bb 100644
--- a/test/unit/attestation/round/attestation_round_006_computeresponsibleset_canonical_vector_conformance_regression.test.js
+++ b/test/unit/attestation/round/attestation_round_006_computeresponsibleset_canonical_vector_conformance_regression.test.js
@@ -135,7 +135,8 @@ describe('AttestationRound', function () { beforeEach(hookAt3853); afterEach(hoo
let hub = makeHub();
let ar = new AttestationRound(hub, makeProviderRegistry());
let got = ar
- .computeResponsibleSet(c.validators, c.requestId, c.redundancy, c.weighted, c.minStake)
+ .computeResponsibleSet(c.validators, c.requestId,
+ c.redundancy - (c.widen ?? 0), c.weighted, c.minStake, c.widen)
.map(v => v.pubkey);
expect(got).to.deep.equal(c.expected);
});
diff --git a/test/unit/attestation/round/attestation_round_007_computeresponsible_over_the_same_vectors.test.js b/test/unit/attestation/round/attestation_round_007_computeresponsible_over_the_same_vectors.test.js
index 68a5ee865..abf04c3d7 100644
--- a/test/unit/attestation/round/attestation_round_007_computeresponsible_over_the_same_vectors.test.js
+++ b/test/unit/attestation/round/attestation_round_007_computeresponsible_over_the_same_vectors.test.js
@@ -159,7 +159,8 @@ describe('AttestationRound', function () { beforeEach(hookAt3853); afterEach(hoo
'attest-vec-' + process.pid + '-' + Math.floor(Math.random() * 1e9) + '.jsonl');
// Block 100: below the mainnet SWQ anchor (961000) and above the
// regtest one (0), so the vector's `weighted` flag alone picks the branch.
- let got = await pub.computeResponsible(c.requestId, 100, c.redundancy, 'http_get');
+ let got = await pub.computeResponsible(c.requestId, 100,
+ c.redundancy - (c.widen ?? 0), 'http_get', c.widen);
expect(got).to.deep.equal(c.expected.length ? c.expected : null);
});
}); }); }); });
diff --git a/test/unit/consensus/consensus_primitive_conformance.test.js b/test/unit/consensus/consensus_primitive_conformance.test.js
index 7e79f7404..1df591c13 100644
--- a/test/unit/consensus/consensus_primitive_conformance.test.js
+++ b/test/unit/consensus/consensus_primitive_conformance.test.js
@@ -14,11 +14,13 @@
// predicate and the equivocation-header builder are CONSENSUS-CRITICAL and
// vendored byte-identically into five services (xchain-hub, xchain-indexer,
// xchain-explorer, xchain-sdk, xchain-sync); a divergence in their logic forks
-// the chain. The canonical source of record lives in xchain-documentation
-// (protocol/reference-impl + protocol/test-vectors). This guard runs in every
+// the chain. Their canonical source of record is xchain-indexer/src/consensus,
+// which reconcile-twins.sh vendors into every other copy, including
+// xchain-documentation/protocol/reference-impl/consensus; the canonical vectors
+// live in xchain-documentation/protocol/test-vectors. This guard runs in every
// repo and asserts BOTH:
// 1. BEHAVIOR - the local copy matches the canonical vectors.
-// 2. IDENTITY - the local copy is byte-identical to the canonical source.
+// 2. IDENTITY - the local copy is byte-identical to the xchain-documentation copy.
// (1) catches a logic change that happens to pass the local unit suite; (2)
// catches ANY edit to one copy that was not propagated to the others. When the
// sibling xchain-documentation repo is not checked out (standalone deploy), skip
@@ -119,7 +121,7 @@ describe('consensus-primitive conformance: byte-identity to canonical source @re
const canon = fs.readFileSync(canonPath, 'utf8');
assert.strictEqual(local, canon,
'this repo\'s consensus/' + f + ' has drifted from the canonical source; ' +
- 'edit xchain-documentation/protocol/reference-impl/consensus/' + f + ' and re-vendor every copy.');
+ 'edit xchain-indexer/src/consensus/' + f + ' and re-run reconcile-twins.sh to re-vendor every copy.');
});
});
});
diff --git a/test/unit/consensus/consensus_rules_digest_active_gates.test.js b/test/unit/consensus/consensus_rules_digest_active_gates.test.js
index 604ca394c..78769a885 100644
--- a/test/unit/consensus/consensus_rules_digest_active_gates.test.js
+++ b/test/unit/consensus/consensus_rules_digest_active_gates.test.js
@@ -36,9 +36,9 @@ function stubRegistryRow(key, table) {
// The zero-confirmation flip's three appended SHARED_GATES rows (§8), plus the two
// helpers a ROLLCALL v1 publisher and the rules-aware capability set filter both read.
function registerGateInventoryTest() {
- it('is sorted, has 33 entries, and contains the gates the last three trains append', function () {
+ it('is sorted, has 34 entries, and contains the gates the last three trains append', function () {
const keys = crd.knownGateKeys();
- expect(keys).to.have.lengthOf(33, 'SHARED_GATES total entry count moved; re-derive this floor before changing it');
+ expect(keys).to.have.lengthOf(34, 'SHARED_GATES total entry count moved; re-derive this floor before changing it');
expect(keys).to.deep.equal([...keys].sort());
expect(keys).to.include.members([
'attest_zero_conf_activation.ATTEST_ZERO_CONF_ACTIVATION',
@@ -62,7 +62,9 @@ function registerGateInventoryTest() {
'mirror_admission_activation.encodeAdmitBlocks',
'mirror_admission_activation.decodeAdmitBlocks',
'mirror_admission_activation.isAdmissionEra',
- 'mirror_admission_activation.admissionCanonicalField'
+ 'mirror_admission_activation.admissionCanonicalField',
+ // The token leg gate this hub loads per bridge leg.
+ 'token_bridge_activation.TOKEN_BRIDGE_ACTIVATION'
]);
});
}
@@ -80,7 +82,7 @@ function registerPinnedDigestTest() {
// the environment: a venue process launched armed has a different, equally correct digest,
// and a pin that moved with a drill lever would be a test of the launcher. The pinned value
// is the fleet's: every shipped process reads the unarmed maps.
- it('digests to the pinned value, which moved when the v0.19.0 cut armed the bridge on testnet (and again at the 16:33Z ladder re-cut)', function () {
+ it('digests to the pinned value, which moved when the v0.19.0 cut armed the bridge on testnet (and again at the 16:33Z ladder re-cut, and again when LTC:testnet mirror admission shipped null under dq4 (a))', function () {
// Every gate module that still has a logic file, not just the admission one: the
// family's arming lever is shared, so the anchor-attest gate resolves from the same
// variable and a cached copy of it would keep a drill's heights in the digest after
@@ -96,7 +98,7 @@ function registerPinnedDigestTest() {
for (const [p] of saved) delete require.cache[p];
const fresh = require('../../../src/consensus_rules_digest.js');
expect(fresh.computeConsensusRulesDigest().digest)
- .to.equal('ee6476f2445fe454b6b262e6f9dbea1b922bd31ba7eec55a6c7b30dbb513cf5e',
+ .to.equal('f69861138e062d4feb1dc7f9975fd28284a1f297e7e8375e6d870c020f3d97fd',
'the consensus rules digest moved; a gate was added, removed, reordered or re-armed');
} finally {
for (const [p, mod] of saved) { if (mod === undefined) delete require.cache[p]; else require.cache[p] = mod; }
@@ -129,8 +131,10 @@ function registerGateOrderingTest() {
expect(mods.slice(0, PRE_EXISTING.length),
'a SHARED_GATES entry was inserted mid-list; that reorders the preimage of every gate after it')
.to.deep.equal(PRE_EXISTING);
- expect(mods.slice(PRE_EXISTING.length), 'the family must be the LAST three entries, the encoder registration last of all')
- .to.deep.equal(['mirror_admission_activation', 'anchor_reward_activation', 'mirror_admission_activation']);
+ expect(mods.slice(PRE_EXISTING.length),
+ 'the family must follow the bridge gate, the encoder registration last, then the token leg gate')
+ .to.deep.equal(['mirror_admission_activation', 'anchor_reward_activation', 'mirror_admission_activation',
+ 'token_bridge_activation']);
});
}
diff --git a/test/unit/consensus/consensus_rules_digest_registry_loader.test.js b/test/unit/consensus/consensus_rules_digest_registry_loader.test.js
index 83a86d21f..d76fb3d76 100644
--- a/test/unit/consensus/consensus_rules_digest_registry_loader.test.js
+++ b/test/unit/consensus/consensus_rules_digest_registry_loader.test.js
@@ -65,8 +65,8 @@ describe('consensus_rules_digest: values by registry key', function () {
expect(gates[key], key).to.equal(crd.canonical(registry.get(key)));
}
}
- expect(rows, 'the digest rows the registry supplies').to.equal(29);
- expect(Object.keys(gates).length, 'every SHARED_GATES name is a digest key').to.equal(33);
+ expect(rows, 'the digest rows the registry supplies').to.equal(30);
+ expect(Object.keys(gates).length, 'every SHARED_GATES name is a digest key').to.equal(34);
});
it('reads the four function-valued names from their carrier, canonicalising to no value, as before', function () {
@@ -115,6 +115,6 @@ describe('consensus_rules_digest: a registry miss', function () {
it('leaves the shipped module untouched: the digest still reads the pinned value afterwards', function () {
expect(crd.computeConsensusRulesDigest().gates[KEY]).to.equal(crd.canonical(registry.get(KEY)));
expect(crd.computeConsensusRulesDigest().gates[RETIRED_KEY]).to.equal(crd.canonical(registry.get(RETIRED_KEY)));
- expect(crd.knownGateKeys().length).to.equal(33);
+ expect(crd.knownGateKeys().length).to.equal(34);
});
});
diff --git a/test/unit/consensus/equivocation_header.test.js b/test/unit/consensus/equivocation_header.test.js
index f605d48ad..7f89b31bd 100644
--- a/test/unit/consensus/equivocation_header.test.js
+++ b/test/unit/consensus/equivocation_header.test.js
@@ -9,7 +9,25 @@
// General Public License v3.0 or later; see LICENSE.md.
const { expect } = require('chai');
+const path = require('node:path');
const eq = require('../../../src/consensus/equivocation_header.js');
+const REPO_ROOT = path.join(__dirname, '..', '..', '..');
+const SIBLING_ROOT = process.env.XCHAIN_SIBLING_ROOT || path.join(REPO_ROOT, '..');
+const REQUIRE_SIBLINGS = process.env.XCHAIN_REQUIRE_SIBLINGS === '1';
+
+function loadSibling(relativePath) {
+ const absolutePath = path.join(SIBLING_ROOT, relativePath);
+ try {
+ return require(absolutePath);
+ } catch (error) {
+ if (REQUIRE_SIBLINGS) {
+ throw new Error(`XCHAIN_REQUIRE_SIBLINGS=1 but the equivocation-header sibling is unloadable at ${absolutePath}`, {
+ cause: error
+ });
+ }
+ return null;
+ }
+}
// CONSENSUS-CRITICAL: the EQUIV header (WI-2 bump 2) is prefixed onto every signed
// consensus canonical at/above the flag-day. The indexer keeps a byte-equivalent
@@ -68,23 +86,22 @@ function registerCrossServiceParityTests() {
// by the dedicated consensus-primitive conformance gate, so the skip is not a false
// green on this fork-class invariant.
describe('cross-service activation parity', function () {
+ this.timeout(20000);
it('hub activation map == canonical constants.js', function () {
- let canonical;
- try { canonical = require('../../../../xchain-documentation/protocol/constants.js').EQUIV_HEADER_ACTIVATION; }
- catch (e) { return this.skip(); }
+ const constants = loadSibling('xchain-documentation/protocol/constants.js');
+ if (!constants) return this.skip();
+ const canonical = constants.EQUIV_HEADER_ACTIVATION;
expect(eq.EQUIV_HEADER_ACTIVATION).to.deep.equal(canonical);
});
it('all 5 copies == hub (map + tags + builder bytes)', function () {
// hub + indexer (server consensus) + sdk + explorer (client checkpoint
// verifiers). A drift in ANY copy flips the header on different blocks → fork.
- let copies;
- try {
- copies = {
- indexer: require('../../../../xchain-indexer/src/equivocation_header.js'),
- sdk: require('../../../../xchain-sdk/src/equivocation_header.js'),
- explorer: require('../../../../xchain-explorer/src/equivocation_header.js'),
- };
- } catch (e) { return this.skip(); }
+ const copies = {
+ indexer: loadSibling('xchain-indexer/src/consensus/equivocation_header.js'),
+ sdk: loadSibling('xchain-sdk/src/consensus/equivocation_header.js'),
+ explorer: loadSibling('xchain-explorer/src/consensus/equivocation_header.js'),
+ };
+ if (Object.values(copies).includes(null)) return this.skip();
const ref = eq.buildEquivCanonical('XDEX', 'mid', 2, 'XMATCH|mid|x');
for(const [name, copy] of Object.entries(copies)){
expect(copy.EQUIV_HEADER_ACTIVATION, name + ' activation map').to.deep.equal(eq.EQUIV_HEADER_ACTIVATION);
diff --git a/test/unit/consensus/gate_registry.test.js b/test/unit/consensus/gate_registry.test.js
index f0810a810..10acb9334 100644
--- a/test/unit/consensus/gate_registry.test.js
+++ b/test/unit/consensus/gate_registry.test.js
@@ -302,7 +302,7 @@ describe('src/consensus/gate_registry.js: regtest arming from the environment',
bootedWith('XC_MIRROR_ADMISSION_ACTIVATION', '150', (fresh) => {
const producer = fresh.get(ADMISSION);
for (const k of ['BTC:regtest', 'LTC:regtest', 'DOGE:regtest']) expect(producer[k], k).to.equal(150);
- expect(producer['BTC:testnet']).to.equal(153222);
+ expect(producer['BTC:testnet']).to.equal(154234);
expect(fresh.copy(BARRIER).regtest).to.equal(150);
expect(fresh.get(ROLLCALL).regtest, 'the roll-call lever is a different variable').to.equal(null);
});
diff --git a/test/unit/cross_chain/chain_tip_chain_id_purge.test.js b/test/unit/cross_chain/chain_tip_chain_id_purge.test.js
new file mode 100644
index 000000000..c09704da1
--- /dev/null
+++ b/test/unit/cross_chain/chain_tip_chain_id_purge.test.js
@@ -0,0 +1,131 @@
+'use strict';
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+const sinon = require('sinon');
+const { expect } = require('chai');
+const { buildFeedRpc } = require('../../../src/api/rpc/feed');
+const crossChainDb = require('../../../src/db/cross_chain');
+const capabilityDb = require('../../../src/db/capability_snapshots');
+
+const OLD_ID = '01'.repeat(32);
+const NEW_ID = '02'.repeat(32);
+
+function makeDb(storedId = OLD_ID) {
+ let order = [];
+ return {
+ order,
+ getChainTip: sinon.stub().callsFake(async () => {
+ order.push('get');
+ return { chainId: storedId };
+ }),
+ deleteCrossChainMatchesByNetwork: sinon.stub().callsFake(async () => { order.push('matches'); }),
+ deleteCrossChainCallsByNetwork: sinon.stub().callsFake(async () => { order.push('calls'); }),
+ deleteAllCapabilitySnapshots: sinon.stub().callsFake(async () => { order.push('snapshots'); }),
+ setChainTip: sinon.stub().callsFake(async () => { order.push('set'); })
+ };
+}
+
+async function push(db, overrides = {}, hubOverrides = {}) {
+ const params = {
+ coin: 'BTC', network: 'regtest', block_height: 42,
+ block_time: 1700000000, chain_id: NEW_ID,
+ ...overrides
+ };
+ return buildFeedRpc({ hub: { db, network: 'regtest', ...hubOverrides } }).pushchaintip(params);
+}
+
+function registerScopedSqlTest() {
+ it('uses scoped SQL for cross-chain rows and an unfiltered snapshot delete', async function () {
+ const doQuery = sinon.stub().resolves();
+ await crossChainDb.deleteCrossChainMatchesByNetwork.call({ doQuery }, 'regtest');
+ await crossChainDb.deleteCrossChainCallsByNetwork.call({ doQuery }, 'regtest');
+ await capabilityDb.deleteAllCapabilitySnapshots.call({ doQuery });
+
+ expect(doQuery.getCall(0).args).to.deep.equal([
+ 'DELETE FROM cross_chain_matches WHERE network = ?', ['regtest']
+ ]);
+ expect(doQuery.getCall(1).args).to.deep.equal([
+ 'DELETE FROM cross_chain_calls WHERE network = ?', ['regtest']
+ ]);
+ expect(doQuery.getCall(2).args).to.deep.equal(['DELETE FROM capability_snapshots']);
+ });
+}
+
+function registerPurgeAndFailureTests() {
+ it('purges all three tables before replacing a changed BTC regtest tip', async function () {
+ const db = makeDb();
+ expect(await push(db)).to.deep.equal({ status: 'success' });
+ expect(db.order).to.deep.equal(['get', 'matches', 'calls', 'snapshots', 'set']);
+ expect(db.deleteCrossChainMatchesByNetwork.calledWithExactly('regtest')).to.equal(true);
+ expect(db.deleteCrossChainCallsByNetwork.calledWithExactly('regtest')).to.equal(true);
+ expect(db.deleteAllCapabilitySnapshots.calledOnceWithExactly()).to.equal(true);
+ });
+
+ for (const failedMethod of [
+ 'getChainTip',
+ 'deleteCrossChainMatchesByNetwork',
+ 'deleteCrossChainCallsByNetwork',
+ 'deleteAllCapabilitySnapshots'
+ ]) {
+ it('does not update the tip when ' + failedMethod + ' fails', async function () {
+ const db = makeDb();
+ db[failedMethod].rejects(new Error(failedMethod + ' failed'));
+ const result = await push(db);
+ expect(result).to.deep.equal({ error: failedMethod + ' failed' });
+ expect(db.setChainTip.called).to.equal(false);
+ });
+ }
+}
+
+function registerNoOpTests() {
+ const noOps = [
+ ['no stored identity', null, {}],
+ ['an unchanged identity', NEW_ID, {}],
+ ['a non-BTC tip', OLD_ID, { coin: 'DOGE' }],
+ ['a non-regtest tip', OLD_ID, { network: 'mainnet' }]
+ ];
+ for (const [label, storedId, overrides] of noOps) {
+ it('does not purge for ' + label, async function () {
+ const db = makeDb(storedId);
+ expect(await push(db, overrides)).to.deep.equal({ status: 'success' });
+ expect(db.deleteCrossChainMatchesByNetwork.called).to.equal(false);
+ expect(db.deleteCrossChainCallsByNetwork.called).to.equal(false);
+ expect(db.deleteAllCapabilitySnapshots.called).to.equal(false);
+ expect(db.setChainTip.calledOnce).to.equal(true);
+ });
+ }
+}
+
+// The purge must gate on THIS hub's own configured network, never the caller's
+// param alone: a caller (an indexer relaying a pushed tip) claiming network:
+// 'regtest' twice with different chain_ids is not evidence of a regtest
+// re-genesis when the hub answering the call is a testnet or mainnet hub.
+function registerHubNetworkGuardTests() {
+ for (const hubNetwork of ['testnet', 'mainnet']) {
+ it('does not purge when the caller sends regtest but the hub is ' + hubNetwork, async function () {
+ const db = makeDb(OLD_ID);
+ expect(await push(db, {}, { network: hubNetwork })).to.deep.equal({ status: 'success' });
+ expect(db.deleteCrossChainMatchesByNetwork.called).to.equal(false);
+ expect(db.deleteCrossChainCallsByNetwork.called).to.equal(false);
+ expect(db.deleteAllCapabilitySnapshots.called).to.equal(false);
+ expect(db.setChainTip.calledOnce).to.equal(true);
+ });
+ }
+
+ it('still purges for a regtest hub, unaffected by the added hub.network guard', async function () {
+ const db = makeDb(OLD_ID);
+ expect(await push(db, {}, { network: 'regtest' })).to.deep.equal({ status: 'success' });
+ expect(db.order).to.deep.equal(['get', 'matches', 'calls', 'snapshots', 'set']);
+ });
+}
+
+describe('regtest chain-tip identity purge', function () {
+ registerScopedSqlTest();
+ registerPurgeAndFailureTests();
+ registerNoOpTests();
+ registerHubNetworkGuardTests();
+});
diff --git a/test/unit/cross_chain/dex/cross_chain_dex_consensus_round_config.test.js b/test/unit/cross_chain/dex/cross_chain_dex_consensus_round_config.test.js
new file mode 100644
index 000000000..1b7c8173b
--- /dev/null
+++ b/test/unit/cross_chain/dex/cross_chain_dex_consensus_round_config.test.js
@@ -0,0 +1,113 @@
+'use strict';
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+// The XDEX round timeout and max lifetime, resolved by the engine and by the mirror
+// admission watermark. The watermark's xdex trail is the engine's terminal bound, so
+// both must resolve the same value from the same sources in the same order.
+
+const sinon = require('sinon');
+const { expect } = require('chai');
+const proxyquire = require('proxyquire');
+
+const CrossChainDexConsensus = require('../../../../src/cross_chain/dex_consensus.js');
+const { AdmissionHeightWatermark } = require('../../../../src/peers/hub_db/admission_height_watermark.js');
+const { DEFAULT_XDEX_ROUND_TIMEOUT_MS } = require('../../../../src/constants.js');
+
+const ENV_KEYS = ['XDEX_ROUND_TIMEOUT_MS', 'XDEX_ROUND_MAX_LIFETIME_MS'];
+
+// Run fn with exactly `env` set for the two round knobs, restoring the caller's values.
+function withEnv(env, fn) {
+ const saved = {};
+ for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
+ Object.assign(process.env, env || {});
+ try { return fn(); }
+ finally { for (const k of ENV_KEYS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } }
+}
+
+function makeConsensus(p2pConfig) {
+ return new CrossChainDexConsensus({ hub: { p2pConfig: p2pConfig || {} }, peerManager: null, identity: null });
+}
+
+function registerSourceOrderTests() {
+ it('lands on the shared default with nothing configured', function () {
+ let c = withEnv({}, () => makeConsensus());
+ expect(c.roundTimeoutMs).to.equal(DEFAULT_XDEX_ROUND_TIMEOUT_MS);
+ expect(c.roundMaxLifetimeMs).to.equal(DEFAULT_XDEX_ROUND_TIMEOUT_MS * 4);
+ });
+
+ it('honours the environment, which p2pConfig never carries on a real hub', function () {
+ let c = withEnv({ XDEX_ROUND_TIMEOUT_MS: '30000' }, () => makeConsensus());
+ expect(c.roundTimeoutMs).to.equal(30000);
+ expect(c.roundMaxLifetimeMs).to.equal(120000);
+ });
+
+ it('lets the environment win over a different p2pConfig value for both knobs', function () {
+ let c = withEnv({ XDEX_ROUND_TIMEOUT_MS: '5000', XDEX_ROUND_MAX_LIFETIME_MS: '20000' },
+ () => makeConsensus({ XDEX_ROUND_TIMEOUT_MS: 90000, XDEX_ROUND_MAX_LIFETIME_MS: 900000 }));
+ expect(c.roundTimeoutMs).to.equal(5000);
+ expect(c.roundMaxLifetimeMs).to.equal(20000);
+ });
+
+ it('still honours p2pConfig when the environment is unset', function () {
+ let c = withEnv({}, () => makeConsensus({ XDEX_ROUND_TIMEOUT_MS: 90000, XDEX_ROUND_MAX_LIFETIME_MS: 900000 }));
+ expect(c.roundTimeoutMs).to.equal(90000);
+ expect(c.roundMaxLifetimeMs).to.equal(900000);
+ });
+}
+
+function registerRejectionTests() {
+ it('falls back to the default on a negative timeout instead of accepting it', function () {
+ let fromP2p = withEnv({}, () => makeConsensus({ XDEX_ROUND_TIMEOUT_MS: '-1' }));
+ expect(fromP2p.roundTimeoutMs).to.equal(DEFAULT_XDEX_ROUND_TIMEOUT_MS);
+ expect(fromP2p.roundMaxLifetimeMs).to.equal(DEFAULT_XDEX_ROUND_TIMEOUT_MS * 4);
+ let fromEnv = withEnv({ XDEX_ROUND_TIMEOUT_MS: '-1' }, () => makeConsensus());
+ expect(fromEnv.roundTimeoutMs).to.equal(DEFAULT_XDEX_ROUND_TIMEOUT_MS);
+ });
+
+ it('falls back to the derived lifetime on a negative p2pConfig lifetime', function () {
+ let c = withEnv({}, () => makeConsensus({ XDEX_ROUND_TIMEOUT_MS: 10000, XDEX_ROUND_MAX_LIFETIME_MS: -5 }));
+ expect(c.roundMaxLifetimeMs).to.equal(40000);
+ });
+
+ it('follows a retuned shared default rather than a local copy of it', function () {
+ const Stubbed = proxyquire('../../../../src/cross_chain/dex_consensus.js', {
+ '../constants.js': { DEFAULT_XDEX_ROUND_TIMEOUT_MS: 77777 },
+ });
+ let c = withEnv({}, () => new Stubbed({ hub: { p2pConfig: {} }, peerManager: null, identity: null }));
+ expect(c.roundTimeoutMs).to.equal(77777);
+ });
+}
+
+function registerWatermarkAgreementTests() {
+ it('sizes the watermark xdex trail to the engine lifetime for an env-only timeout', function () {
+ let both = withEnv({ XDEX_ROUND_TIMEOUT_MS: '30000' },
+ () => ({ engine: makeConsensus(), watermark: new AdmissionHeightWatermark({}) }));
+ expect(both.watermark.roundWindows.xdex).to.equal(both.engine.roundMaxLifetimeMs);
+ expect(both.engine.roundMaxLifetimeMs).to.equal(120000);
+ });
+
+ it('agrees with the watermark when env and p2pConfig disagree', function () {
+ const p2p = { XDEX_ROUND_TIMEOUT_MS: 90000, XDEX_ROUND_MAX_LIFETIME_MS: 900000 };
+ let both = withEnv({ XDEX_ROUND_TIMEOUT_MS: '5000', XDEX_ROUND_MAX_LIFETIME_MS: '20000' },
+ () => ({ engine: makeConsensus(p2p), watermark: new AdmissionHeightWatermark(p2p) }));
+ expect(both.watermark.roundWindows.xdex).to.equal(both.engine.roundMaxLifetimeMs);
+ });
+}
+
+describe('CrossChainDexConsensus round timeout and lifetime config', function () {
+ beforeEach(function () { sinon.stub(console, 'warn'); });
+ afterEach(function () { sinon.restore(); });
+
+ registerSourceOrderTests();
+ registerRejectionTests();
+ registerWatermarkAgreementTests();
+});
diff --git a/test/unit/db/db_attest_batch_claim_exclusive.test.js b/test/unit/db/db_attest_batch_claim_exclusive.test.js
new file mode 100644
index 000000000..aa2a3fc79
--- /dev/null
+++ b/test/unit/db/db_attest_batch_claim_exclusive.test.js
@@ -0,0 +1,216 @@
+'use strict';
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+//
+// The ATTEST batch-window durable claim (attest_published_batches, keyed on
+// (network, window_start)) is what stops two overlapping sweeps on the SAME hub
+// from both broadcasting a batch for one window and spending DOGE twice.
+// claimWindowForBroadcast (src/attestation/batch_publisher/window.js) trusts
+// affectedRows === 1 to mean "this call created the row, so this call may send".
+//
+// That trust was misplaced against the statement setAttestPublishedBatchByNetwork
+// used to issue: `INSERT ... ON DUPLICATE KEY UPDATE window_start = window_start`.
+// MySQL/MariaDB report affectedRows 0 for a duplicate whose ON DUPLICATE KEY UPDATE
+// leaves every column unchanged - UNLESS the connection negotiated the
+// CLIENT_FOUND_ROWS capability, in which case a matched-but-unchanged row reports
+// 1 instead of 0. The hub's pool (src/db/index.js's connectionPoolParams) never
+// sets `foundRows`, and the mariadb 3.5.3 driver defaults an unset `foundRows` to
+// true (lib/config/connection-options.js: `this.foundRows = opts.foundRows ===
+// undefined || Boolean(opts.foundRows)`), so every hub connection runs with
+// CLIENT_FOUND_ROWS on. Under that default, a duplicate claim attempt reported
+// affectedRows 1 exactly like the real creator, and claimWindowForBroadcast could
+// not tell them apart: two overlapping attempts on the same window both won.
+//
+// The fix makes setAttestPublishedBatchByNetwork issue `INSERT IGNORE` instead.
+// INSERT IGNORE never runs an UPDATE clause, so the CLIENT_FOUND_ROWS capability
+// (which only changes how a matched UPDATE is counted) has nothing to act on: a
+// duplicate is always 0 and a creation is always 1, with or without foundRows.
+//
+// The first block below models the historical statement directly, against the
+// documented MariaDB affectedRows rule, to show the vulnerability was real. The
+// second block drives the actual current src/db/attestation.js and
+// src/attestation/batch_publisher/window.js code and proves the claim it issues
+// today is exclusive under the same foundRows: true the hub pool actually runs
+// with.
+
+const sinon = require('sinon');
+const { expect } = require('chai');
+const proxyquire = require('proxyquire');
+
+const windowMixin = require('../../../src/attestation/batch_publisher/window.js');
+
+// The exact statement setAttestPublishedBatchByNetwork issued before this fix.
+// Kept here, not in production code, purely to model what it did under
+// foundRows: true.
+const OLD_CLAIM_SQL = 'INSERT INTO attest_published_batches ' +
+ '(network, window_start, window_end, batch_key, row_count, status) ' +
+ 'VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE window_start = window_start';
+
+// A one-row-per-key model of attest_published_batches, answering exactly the
+// statements setAttestPublishedBatchByNetwork and its DELETE guard issue, with
+// MariaDB's own affectedRows rules for each form:
+//
+// INSERT ... ON DUPLICATE KEY UPDATE = (the pre-fix statement)
+// new row -> 1
+// duplicate, foundRows false -> 0 (the row's values did not change)
+// duplicate, foundRows true -> 1 (CLIENT_FOUND_ROWS counts the matched row)
+//
+// INSERT IGNORE (the current statement)
+// new row -> 1
+// duplicate -> 0, always: IGNORE never runs an UPDATE, so CLIENT_FOUND_ROWS
+// (which only changes how a matched UPDATE is counted) does not
+// apply to it.
+function makeClaimModel(foundRows) {
+ const table = new Map();
+ const key = (network, windowStart) => network + '|' + windowStart;
+
+ async function query(sql, args) {
+ const text = String(sql);
+
+ if (/^INSERT IGNORE INTO attest_published_batches/i.test(text)) {
+ const [network, windowStart, windowEnd, batchKey, rowCount, status] = args;
+ const k = key(network, windowStart);
+ if (table.has(k)) return { affectedRows: 0 };
+ table.set(k, { network, windowStart, windowEnd, batchKey, rowCount, status });
+ return { affectedRows: 1 };
+ }
+
+ if (text === OLD_CLAIM_SQL) {
+ const [network, windowStart, windowEnd, batchKey, rowCount, status] = args;
+ const k = key(network, windowStart);
+ if (table.has(k)) return { affectedRows: foundRows ? 1 : 0 };
+ table.set(k, { network, windowStart, windowEnd, batchKey, rowCount, status });
+ return { affectedRows: 1 };
+ }
+
+ if (/^DELETE FROM attest_published_batches/i.test(text)) {
+ const [network, windowStart, statusGuard] = args;
+ const k = key(network, windowStart);
+ const row = table.get(k);
+ if (!row || row.status !== statusGuard) return { affectedRows: 0 };
+ table.delete(k);
+ return { affectedRows: 1 };
+ }
+
+ throw new Error('unexpected statement in claim model: ' + text);
+ }
+
+ return { table, query };
+}
+
+// A real Database instance (src/db, with the attestation.js mixin installed),
+// mariadb and fs fully stubbed, its connection routed through one claim model so
+// setAttestPublishedBatchByNetwork's actual SQL text is what gets exercised.
+function makeDb(foundRows) {
+ const model = makeClaimModel(foundRows);
+ const mockConn = {
+ query: sinon.stub().callsFake(model.query),
+ release: sinon.stub().resolves(),
+ end: sinon.stub().resolves()
+ };
+ const mockPool = {
+ getConnection: sinon.stub().resolves(mockConn),
+ end: sinon.stub().resolves()
+ };
+ const Database = proxyquire('../../../src/db', {
+ mariadb: { createPool: sinon.stub().returns(mockPool), createConnection: sinon.stub().resolves(mockConn) },
+ fs: { readdirSync: sinon.stub().returns([]), readFileSync: sinon.stub().returns('') }
+ });
+ const db = new Database('localhost', 3306, 'test_db', 'user', 'pass');
+ return { db, model };
+}
+
+function registerOldStatementModelTests() {
+ describe('the pre-fix statement, modeled directly against MariaDB\'s affectedRows rule', function () {
+ it('reports affectedRows 1 for the real creator', async function () {
+ const model = makeClaimModel(true);
+ const result = await model.query(OLD_CLAIM_SQL,
+ ['regtest', 1000, 1010, 'key-a', 0, 'intent']);
+ expect(result.affectedRows).to.equal(1);
+ });
+
+ it('under foundRows: true, ALSO reports affectedRows 1 for a duplicate: two contenders both win', async function () {
+ const model = makeClaimModel(true);
+ const first = await model.query(OLD_CLAIM_SQL, ['regtest', 1000, 1010, 'key-a', 0, 'intent']);
+ const second = await model.query(OLD_CLAIM_SQL, ['regtest', 1000, 1010, 'key-a', 0, 'intent']);
+ expect(first.affectedRows).to.equal(1);
+ expect(second.affectedRows, 'a duplicate claim must not read as a win').to.equal(1);
+ });
+
+ it('under foundRows: false, a duplicate correctly reports affectedRows 0 - the pool never gets this default', async function () {
+ const model = makeClaimModel(false);
+ await model.query(OLD_CLAIM_SQL, ['regtest', 1000, 1010, 'key-a', 0, 'intent']);
+ const second = await model.query(OLD_CLAIM_SQL, ['regtest', 1000, 1010, 'key-a', 0, 'intent']);
+ expect(second.affectedRows).to.equal(0);
+ });
+ });
+}
+
+function registerCurrentStatementTests() {
+ describe('setAttestPublishedBatchByNetwork today, run against a connection with foundRows: true', function () {
+ it('issues INSERT IGNORE, with no ON DUPLICATE KEY UPDATE clause', async function () {
+ const { db } = makeDb(true);
+ await db.setAttestPublishedBatchByNetwork('regtest', 2000, 2010, 'key-b', 0, 'intent');
+ const calledSql = (await db.getConnection()).query.getCall(0).args[0];
+ expect(calledSql).to.match(/^INSERT IGNORE INTO attest_published_batches/i);
+ expect(calledSql).to.not.match(/ON DUPLICATE KEY UPDATE/i);
+ });
+
+ it('reports affectedRows 1 for the creating call and 0 for every later duplicate', async function () {
+ const { db } = makeDb(true);
+ const first = await db.setAttestPublishedBatchByNetwork('regtest', 3000, 3010, 'key-c', 0, 'intent');
+ const second = await db.setAttestPublishedBatchByNetwork('regtest', 3000, 3010, 'key-c', 0, 'intent');
+ const third = await db.setAttestPublishedBatchByNetwork('regtest', 3000, 3010, 'key-c', 0, 'intent');
+ expect(Number(first.affectedRows)).to.equal(1);
+ expect(Number(second.affectedRows)).to.equal(0);
+ expect(Number(third.affectedRows)).to.equal(0);
+ });
+ });
+}
+
+// claimWindowForBroadcast is a mixin method, installed on
+// AttestationBatchPublisher.prototype in production; called here directly against
+// a minimal context so the assertion is about the SQL/affectedRows contract, not
+// the surrounding publisher machinery already covered by
+// attestation_batch_publisher_014_one_broadcast_per_window.test.js.
+function makeClaimCtx(db) {
+ return { hubDb: () => db, network: 'regtest' };
+}
+
+function registerClaimWindowForBroadcastTests() {
+ describe('claimWindowForBroadcast (src/attestation/batch_publisher/window.js), end to end', function () {
+ it('lets exactly one of two identical claim attempts win', async function () {
+ const { db } = makeDb(true);
+ const window = { window_start: 4000, window_end: 4010, row_count: 0 };
+
+ const firstWon = await windowMixin.claimWindowForBroadcast.call(makeClaimCtx(db), window, 'key-d');
+ const secondWon = await windowMixin.claimWindowForBroadcast.call(makeClaimCtx(db), window, 'key-d');
+
+ expect(firstWon, 'the first attempt must create the marker and win').to.equal(true);
+ expect(secondWon, 'a second attempt on the same window must not also win').to.equal(false);
+ });
+
+ it('lets a later window claim independently once the earlier one is settled', async function () {
+ const { db } = makeDb(true);
+ const windowA = { window_start: 5000, window_end: 5010, row_count: 0 };
+ const windowB = { window_start: 5010, window_end: 5020, row_count: 0 };
+
+ expect(await windowMixin.claimWindowForBroadcast.call(makeClaimCtx(db), windowA, 'key-e')).to.equal(true);
+ expect(await windowMixin.claimWindowForBroadcast.call(makeClaimCtx(db), windowB, 'key-f')).to.equal(true);
+ });
+ });
+}
+
+describe('ATTEST batch-window claim: exclusivity under the hub pool\'s foundRows default', function () {
+ registerOldStatementModelTests();
+ registerCurrentStatementTests();
+ registerClaimWindowForBroadcastTests();
+});
diff --git a/test/unit/db/db_coverage.test.js b/test/unit/db/db_coverage.test.js
index 806e90cc3..af22d6568 100644
--- a/test/unit/db/db_coverage.test.js
+++ b/test/unit/db/db_coverage.test.js
@@ -282,6 +282,13 @@ function registerRunMigrationSequenceTests() {
expect(mig.calledWith('oracle_submissions', 'uq_submission')).to.be.true;
expect(mig.calledWith('validator_rewards', 'uq_reward')).to.be.true;
expect(idx.calledWith('validator_rewards', 'idx_batch_seq', '(batch_seq)')).to.be.true;
+ // The three price_snapshots frontier indexes: names and column lists byte-equal
+ // to the KEY lines in src/sql/price_snapshots.sql, the first two also to the
+ // indexer and explorer mirror twins, so a hub that predates them converges on the
+ // same index set a fresh install gets.
+ expect(idx.calledWith('price_snapshots', 'idx_status_block_round', '(status, reference_block, round_number)')).to.be.true;
+ expect(idx.calledWith('price_snapshots', 'idx_status_timestamp_round', '(status, block_timestamp, round_number)')).to.be.true;
+ expect(idx.calledWith('price_snapshots', 'idx_status_created', '(status, created_at)')).to.be.true;
const enCall = en.getCall(0);
expect(enCall.args[0]).to.equal('validator_capabilities');
expect(enCall.args[1]).to.equal('capability');
diff --git a/test/unit/db/db_oracle_tick_width.test.js b/test/unit/db/db_oracle_tick_width.test.js
new file mode 100644
index 000000000..8cebbb6c0
--- /dev/null
+++ b/test/unit/db/db_oracle_tick_width.test.js
@@ -0,0 +1,147 @@
+'use strict';
+
+// Copyright © 2025-2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC - https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+//
+// oracle_prices must store every field the PRICE v1 ingest gate admits, and a
+// deployed hub widens tick only after its mirrors have. Three arms:
+// DEFINITION - the column widths in src/sql/oracle_prices.sql cover the gate bounds.
+// GATE - without XCHAIN_HUB_ORACLE_TICK_MIRRORS_WIDENED=1 the widen never runs.
+// HELPER - migrateColumnLength widens a narrower live column and nothing else.
+
+const fs = require('fs');
+const path = require('path');
+const sinon = require('sinon');
+const { expect } = require('chai');
+const proxyquire = require('proxyquire');
+
+const { MAX_TICK_LENGTH, MAX_MEMO_LENGTH, MAX_SOURCE_ADDRESS_LENGTH } = require('../../../src/constants.js');
+
+const DEF_PATH = path.join(__dirname, '..', '..', '..', 'src', 'sql', 'oracle_prices.sql');
+const FLAG = 'XCHAIN_HUB_ORACLE_TICK_MIRRORS_WIDENED';
+
+// A Database over stubbed mariadb + fs, matching the charset migration suite.
+function makeDb() {
+ const mockConn = { query: sinon.stub().resolves([]), release: sinon.stub().resolves(), end: sinon.stub().resolves() };
+ const mockPool = { getConnection: sinon.stub().resolves(mockConn), end: sinon.stub().resolves() };
+ const Database = proxyquire('../../../src/db', {
+ mariadb: { createPool: sinon.stub().returns(mockPool), createConnection: sinon.stub().resolves(mockConn) },
+ fs: { readdirSync: sinon.stub().returns([]), readFileSync: sinon.stub().returns('') },
+ path: require('path')
+ });
+ return { db: new Database('localhost', 3306, 'test_db', 'user', 'pass'), mockConn };
+}
+
+// The declared spec of one column ("VARCHAR(250) NOT NULL"), comment and comma stripped.
+function declaredSpec(column) {
+ const line = fs.readFileSync(DEF_PATH, 'utf8').split('\n').find(l => new RegExp('^\\s*' + column + '\\s').test(l));
+ expect(line, 'oracle_prices.' + column + ' is no longer declared').to.exist;
+ return line.replace(/--.*$/, '').replace(/,\s*$/, '').trim().replace(new RegExp('^' + column + '\\s+'), '').replace(/\s+/g, ' ');
+}
+
+function declaredWidth(column) {
+ return Number(/VARCHAR\((\d+)\)/i.exec(declaredSpec(column))[1]);
+}
+
+function registerDefinitionSuite() {
+ describe('the definition (what a fresh hub gets from verifyTables)', function () {
+ it('tick holds every tick the ingest gate admits', function () {
+ expect(declaredWidth('tick')).to.be.at.least(MAX_TICK_LENGTH);
+ });
+ it('source_address and memo hold their gate bounds too', function () {
+ expect(declaredWidth('source_address')).to.be.at.least(MAX_SOURCE_ADDRESS_LENGTH);
+ expect(declaredWidth('memo')).to.be.at.least(MAX_MEMO_LENGTH);
+ });
+ });
+}
+
+function registerGateSuite() {
+ describe('migrateOracleTickWidth (the mirrors-first gate)', function () {
+ afterEach(() => { delete process.env[FLAG]; });
+
+ it('skips and warns while the operator has not attested the mirrors', async function () {
+ const { db, mockConn } = makeDb();
+ const widen = sinon.spy(db, 'migrateColumnLength');
+ expect(await db.migrateOracleTickWidth()).to.equal(false);
+ expect(widen.called).to.equal(false);
+ expect(mockConn.query.called).to.equal(false);
+ expect(console.warn.calledWithMatch(new RegExp(FLAG))).to.equal(true);
+ });
+
+ it('does not treat any value but 1 as the attestation', async function () {
+ process.env[FLAG] = 'true';
+ const { db } = makeDb();
+ const widen = sinon.stub(db, 'migrateColumnLength').resolves(true);
+ await db.migrateOracleTickWidth();
+ expect(widen.called).to.equal(false);
+ });
+
+ it('widens to the definition spec once the flag is 1', async function () {
+ process.env[FLAG] = '1';
+ const { db } = makeDb();
+ const widen = sinon.stub(db, 'migrateColumnLength').resolves(true);
+ await db.migrateOracleTickWidth();
+ expect(widen.calledOnce).to.equal(true);
+ expect(widen.firstCall.args).to.deep.equal(['oracle_prices', 'tick', declaredWidth('tick'), declaredSpec('tick')]);
+ });
+
+ it('runMigrations reaches the gate on every boot', async function () {
+ const { db } = makeDb();
+ for (const m of ['migrateUniqueKey', 'migrateIndex', 'migrateEnumColumn', 'migrateColumnType', 'migrateColumnCharset'])
+ sinon.stub(db, m).resolves();
+ const gate = sinon.stub(db, 'migrateOracleTickWidth').resolves(false);
+ await db.runMigrations();
+ expect(gate.calledOnce).to.equal(true);
+ });
+ });
+}
+
+function registerHelperSuite() {
+ describe('migrateColumnLength', function () {
+ it('issues the MODIFY when the live column is narrower', async function () {
+ const { db, mockConn } = makeDb();
+ mockConn.query.onCall(0).resolves([{ len: 50 }]).onCall(1).resolves([]);
+ expect(await db.migrateColumnLength('oracle_prices', 'tick', 250, 'VARCHAR(250) NOT NULL')).to.equal(true);
+ expect(mockConn.query.getCall(1).args[0]).to.equal('ALTER TABLE `oracle_prices` MODIFY `tick` VARCHAR(250) NOT NULL');
+ expect(mockConn.release.called).to.equal(true);
+ });
+
+ it('no-ops at or above the target, when absent, and on an unreadable length', async function () {
+ for (const answer of [[{ len: 250 }], [{ len: 300 }], [], [{ len: null }]]) {
+ const { db, mockConn } = makeDb();
+ mockConn.query.onCall(0).resolves(answer);
+ expect(await db.migrateColumnLength('oracle_prices', 'tick', 250, 'VARCHAR(250) NOT NULL')).to.equal(false);
+ expect(mockConn.query.callCount, JSON.stringify(answer)).to.equal(1);
+ }
+ });
+
+ it('logs the hand-run statement and releases rather than taking the boot down', async function () {
+ const { db, mockConn } = makeDb();
+ mockConn.query.onCall(0).resolves([{ len: 50 }]).onCall(1).rejects(new Error('errno 1071'));
+ expect(await db.migrateColumnLength('oracle_prices', 'tick', 250, 'VARCHAR(250) NOT NULL')).to.equal(false);
+ expect(console.error.calledWithMatch(/MIGRATION FAILED: oracle_prices\.tick/)).to.equal(true);
+ expect(console.error.calledWithMatch(/ALTER TABLE `oracle_prices` MODIFY `tick` VARCHAR\(250\) NOT NULL/)).to.equal(true);
+ expect(mockConn.release.called).to.equal(true);
+ });
+ });
+}
+
+describe('oracle_prices stores every PRICE v1 field the ingest gate admits', function () {
+ beforeEach(function () {
+ sinon.stub(console, 'log');
+ sinon.stub(console, 'warn');
+ sinon.stub(console, 'error');
+ });
+ afterEach(() => sinon.restore());
+
+ registerDefinitionSuite();
+ registerGateSuite();
+ registerHelperSuite();
+});
diff --git a/test/unit/oracle/consensus/oracle_consensus_follower_round_duplicate_propose.test.js b/test/unit/oracle/consensus/oracle_consensus_follower_round_duplicate_propose.test.js
new file mode 100644
index 000000000..219532689
--- /dev/null
+++ b/test/unit/oracle/consensus/oracle_consensus_follower_round_duplicate_propose.test.js
@@ -0,0 +1,113 @@
+'use strict';
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+const sinon = require('sinon');
+const { expect } = require('chai');
+const OracleConsensus = require('../../../../src/oracle/consensus');
+const { createMockHub } = require('../../../helpers/mockHub');
+const { VALIDATORS_3, makeCapabilitySnapshotStub } = require('../../../helpers/fixtures');
+
+const ROUND = 300;
+const BLOCK_HEIGHT = 1000;
+const PRICES = [{ coinPair: 'BTC/USD', price: '100.00000000' }];
+
+function createHarness() {
+ let leader = VALIDATORS_3[0];
+ let follower = VALIDATORS_3[1];
+ let thirdParty = VALIDATORS_3[2];
+ let clock = sinon.useFakeTimers();
+ let hub = createMockHub({ validatorAddr: follower.addr });
+ hub.resolveBtcLatestBlock = sinon.stub().resolves(BLOCK_HEIGHT);
+ hub.capabilitySnapshot = makeCapabilitySnapshotStub(VALIDATORS_3);
+ let oracleRound = { getSubmissions: sinon.stub().returns(new Map()) };
+ let oc = new OracleConsensus(hub, oracleRound);
+ oc.setValidatorSet(VALIDATORS_3);
+ oc._lastFinalizedPrices = new Map([['BTC/USD', '100.00000000']]);
+ let digest = oc.digest(ROUND, PRICES);
+ return { clock, digest, leader, thirdParty, oc, pm: hub._peerManager };
+}
+
+function proposeEnvelope(harness) {
+ return {
+ sender: harness.leader.addr,
+ sig_pubkey: harness.leader.pubkey,
+ data: {
+ round: ROUND,
+ prices: PRICES,
+ digest: harness.digest,
+ btcBlockHeight: BLOCK_HEIGHT,
+ btcBlockTime: 1700000000
+ }
+ };
+}
+
+function prepareEnvelope(harness) {
+ return {
+ type: 'ORACLE_PREPARE',
+ sender: harness.thirdParty.addr,
+ sig_pubkey: harness.thirdParty.pubkey,
+ data: { round: ROUND, digest: harness.digest }
+ };
+}
+
+describe('OracleConsensus: duplicate follower PROPOSE', function () {
+ let harness;
+
+ beforeEach(function () {
+ harness = createHarness();
+ });
+
+ afterEach(function () {
+ harness.oc.stop();
+ harness.clock.restore();
+ sinon.restore();
+ });
+
+ it('keeps the first round, its votes, and its timers', async function () {
+ let { oc, pm, clock, thirdParty } = harness;
+ await oc.handlePropose(proposeEnvelope(harness));
+ let firstPending = oc.pendingRounds.get(ROUND);
+ expect(firstPending, 'first follower round must exist').to.exist;
+
+ oc.handlePrepare(prepareEnvelope(harness));
+ await clock.tickAsync(0);
+ expect(firstPending.prepares.has(thirdParty.pubkey)).to.be.true;
+ let broadcastsBeforeDuplicate = pm.broadcast.callCount;
+ let timersBeforeDuplicate = clock.countTimers();
+
+ await oc.handlePropose(proposeEnvelope(harness));
+ await clock.tickAsync(0);
+
+ expect(oc.pendingRounds.size).to.equal(1);
+ expect(oc.pendingRounds.get(ROUND)).to.equal(firstPending);
+ expect(firstPending.prepares.has(thirdParty.pubkey)).to.be.true;
+ expect(pm.broadcast.callCount).to.equal(broadcastsBeforeDuplicate);
+ expect(clock.countTimers()).to.equal(timersBeforeDuplicate);
+ });
+
+ it('does not replace the first round for a conflicting proposal', async function () {
+ let { oc, clock, digest } = harness;
+ await oc.handlePropose(proposeEnvelope(harness));
+ let firstPending = oc.pendingRounds.get(ROUND);
+ let timersBeforeConflict = clock.countTimers();
+ let conflicting = proposeEnvelope(harness);
+ conflicting.data.prices = [{ coinPair: 'BTC/USD', price: '999.00000000' }];
+ conflicting.data.digest = oc.digest(ROUND, conflicting.data.prices);
+
+ await oc.handlePropose(conflicting);
+
+ expect(oc.pendingRounds.size).to.equal(1);
+ expect(oc.pendingRounds.get(ROUND)).to.equal(firstPending);
+ expect(firstPending.digest).to.equal(digest);
+ expect(clock.countTimers()).to.equal(timersBeforeConflict);
+ });
+});
diff --git a/test/unit/oracle/price/aggregator/price_aggregator_batch.test.js b/test/unit/oracle/price/aggregator/price_aggregator_batch.test.js
index e9a6adda5..fb9de29f0 100644
--- a/test/unit/oracle/price/aggregator/price_aggregator_batch.test.js
+++ b/test/unit/oracle/price/aggregator/price_aggregator_batch.test.js
@@ -13,9 +13,10 @@
// PRICE v0 (batch) ingest: PriceAggregator.receiveValidatedBatch, spec
// spec section 5.7, decisions D8, D13, D14, D23, D28.
//
-// The canonical payload itself is pinned elsewhere (priceV2PayloadTwinParity.test.js
-// asserts buildPriceBatchPayload is byte-identical to the indexer and OracleConsensus
-// twins), so these tests sign whatever that builder emits and pin what INGEST does
+// The canonical payload itself is pinned elsewhere (price_v0_batch_canonical_parity.test.js
+// asserts all three buildPriceBatchPayload implementations are byte-identical, including
+// the OracleConsensus and PriceAggregator production twins), so these tests sign whatever
+// that builder emits and pin what INGEST does
// with a batch: per-round dedupe, column semantics, the block_time-keyed pair flag
// day, the WS mirror re-emit, the reorg fence and the publisher marker clear.
diff --git a/test/unit/oracle/price/aggregator/price_aggregator_batch_activation.test.js b/test/unit/oracle/price/aggregator/price_aggregator_batch_activation.test.js
index 86385fdc7..824427b76 100644
--- a/test/unit/oracle/price/aggregator/price_aggregator_batch_activation.test.js
+++ b/test/unit/oracle/price/aggregator/price_aggregator_batch_activation.test.js
@@ -13,9 +13,10 @@
// PRICE v0 (batch) ingest: PriceAggregator.receiveValidatedBatch, spec
// spec section 5.7, decisions D8, D13, D14, D23, D28.
//
-// The canonical payload itself is pinned elsewhere (priceV2PayloadTwinParity.test.js
-// asserts buildPriceBatchPayload is byte-identical to the indexer and OracleConsensus
-// twins), so these tests sign whatever that builder emits and pin what INGEST does
+// The canonical payload itself is pinned elsewhere (price_v0_batch_canonical_parity.test.js
+// asserts all three buildPriceBatchPayload implementations are byte-identical, including
+// the OracleConsensus and PriceAggregator production twins), so these tests sign whatever
+// that builder emits and pin what INGEST does
// with a batch: per-round dedupe, column semantics, the block_time-keyed pair flag
// day, the WS mirror re-emit, the reorg fence and the publisher marker clear.
diff --git a/test/unit/oracle/price/aggregator/price_aggregator_batch_boundaries.test.js b/test/unit/oracle/price/aggregator/price_aggregator_batch_boundaries.test.js
index 1a92a8c43..efeedcdb7 100644
--- a/test/unit/oracle/price/aggregator/price_aggregator_batch_boundaries.test.js
+++ b/test/unit/oracle/price/aggregator/price_aggregator_batch_boundaries.test.js
@@ -13,9 +13,10 @@
// PRICE v0 (batch) ingest: PriceAggregator.receiveValidatedBatch, spec
// spec section 5.7, decisions D8, D13, D14, D23, D28.
//
-// The canonical payload itself is pinned elsewhere (priceV2PayloadTwinParity.test.js
-// asserts buildPriceBatchPayload is byte-identical to the indexer and OracleConsensus
-// twins), so these tests sign whatever that builder emits and pin what INGEST does
+// The canonical payload itself is pinned elsewhere (price_v0_batch_canonical_parity.test.js
+// asserts all three buildPriceBatchPayload implementations are byte-identical, including
+// the OracleConsensus and PriceAggregator production twins), so these tests sign whatever
+// that builder emits and pin what INGEST does
// with a batch: per-round dedupe, column semantics, the block_time-keyed pair flag
// day, the WS mirror re-emit, the reorg fence and the publisher marker clear.
diff --git a/test/unit/oracle/price/aggregator/price_aggregator_batch_publisher.test.js b/test/unit/oracle/price/aggregator/price_aggregator_batch_publisher.test.js
index 479fb2294..379843a50 100644
--- a/test/unit/oracle/price/aggregator/price_aggregator_batch_publisher.test.js
+++ b/test/unit/oracle/price/aggregator/price_aggregator_batch_publisher.test.js
@@ -13,9 +13,10 @@
// PRICE v0 (batch) ingest: PriceAggregator.receiveValidatedBatch, spec
// spec section 5.7, decisions D8, D13, D14, D23, D28.
//
-// The canonical payload itself is pinned elsewhere (priceV2PayloadTwinParity.test.js
-// asserts buildPriceBatchPayload is byte-identical to the indexer and OracleConsensus
-// twins), so these tests sign whatever that builder emits and pin what INGEST does
+// The canonical payload itself is pinned elsewhere (price_v0_batch_canonical_parity.test.js
+// asserts all three buildPriceBatchPayload implementations are byte-identical, including
+// the OracleConsensus and PriceAggregator production twins), so these tests sign whatever
+// that builder emits and pin what INGEST does
// with a batch: per-round dedupe, column semantics, the block_time-keyed pair flag
// day, the WS mirror re-emit, the reorg fence and the publisher marker clear.
diff --git a/test/unit/oracle/price/aggregator/price_aggregator_batch_retraction.test.js b/test/unit/oracle/price/aggregator/price_aggregator_batch_retraction.test.js
index e1530436d..16b71462e 100644
--- a/test/unit/oracle/price/aggregator/price_aggregator_batch_retraction.test.js
+++ b/test/unit/oracle/price/aggregator/price_aggregator_batch_retraction.test.js
@@ -13,9 +13,10 @@
// PRICE v0 (batch) ingest: PriceAggregator.receiveValidatedBatch, spec
// spec section 5.7, decisions D8, D13, D14, D23, D28.
//
-// The canonical payload itself is pinned elsewhere (priceV2PayloadTwinParity.test.js
-// asserts buildPriceBatchPayload is byte-identical to the indexer and OracleConsensus
-// twins), so these tests sign whatever that builder emits and pin what INGEST does
+// The canonical payload itself is pinned elsewhere (price_v0_batch_canonical_parity.test.js
+// asserts all three buildPriceBatchPayload implementations are byte-identical, including
+// the OracleConsensus and PriceAggregator production twins), so these tests sign whatever
+// that builder emits and pin what INGEST does
// with a batch: per-round dedupe, column semantics, the block_time-keyed pair flag
// day, the WS mirror re-emit, the reorg fence and the publisher marker clear.
diff --git a/test/unit/oracle/price/aggregator/price_aggregator_batch_snapshots.test.js b/test/unit/oracle/price/aggregator/price_aggregator_batch_snapshots.test.js
index 3a781cd0e..cc8052aa6 100644
--- a/test/unit/oracle/price/aggregator/price_aggregator_batch_snapshots.test.js
+++ b/test/unit/oracle/price/aggregator/price_aggregator_batch_snapshots.test.js
@@ -13,9 +13,10 @@
// PRICE v0 (batch) ingest: PriceAggregator.receiveValidatedBatch, spec
// spec section 5.7, decisions D8, D13, D14, D23, D28.
//
-// The canonical payload itself is pinned elsewhere (priceV2PayloadTwinParity.test.js
-// asserts buildPriceBatchPayload is byte-identical to the indexer and OracleConsensus
-// twins), so these tests sign whatever that builder emits and pin what INGEST does
+// The canonical payload itself is pinned elsewhere (price_v0_batch_canonical_parity.test.js
+// asserts all three buildPriceBatchPayload implementations are byte-identical, including
+// the OracleConsensus and PriceAggregator production twins), so these tests sign whatever
+// that builder emits and pin what INGEST does
// with a batch: per-round dedupe, column semantics, the block_time-keyed pair flag
// day, the WS mirror re-emit, the reorg fence and the publisher marker clear.
diff --git a/test/unit/oracle/price/aggregator/price_aggregator_batch_validation.test.js b/test/unit/oracle/price/aggregator/price_aggregator_batch_validation.test.js
index f067e5141..13b97b5c0 100644
--- a/test/unit/oracle/price/aggregator/price_aggregator_batch_validation.test.js
+++ b/test/unit/oracle/price/aggregator/price_aggregator_batch_validation.test.js
@@ -13,9 +13,10 @@
// PRICE v0 (batch) ingest: PriceAggregator.receiveValidatedBatch, spec
// spec section 5.7, decisions D8, D13, D14, D23, D28.
//
-// The canonical payload itself is pinned elsewhere (priceV2PayloadTwinParity.test.js
-// asserts buildPriceBatchPayload is byte-identical to the indexer and OracleConsensus
-// twins), so these tests sign whatever that builder emits and pin what INGEST does
+// The canonical payload itself is pinned elsewhere (price_v0_batch_canonical_parity.test.js
+// asserts all three buildPriceBatchPayload implementations are byte-identical, including
+// the OracleConsensus and PriceAggregator production twins), so these tests sign whatever
+// that builder emits and pin what INGEST does
// with a batch: per-round dedupe, column semantics, the block_time-keyed pair flag
// day, the WS mirror re-emit, the reorg fence and the publisher marker clear.
diff --git a/test/unit/oracle/price/price_admission_wire_carrier.test.js b/test/unit/oracle/price/price_admission_wire_carrier.test.js
index ed8bdd4a1..11afccc0f 100644
--- a/test/unit/oracle/price/price_admission_wire_carrier.test.js
+++ b/test/unit/oracle/price/price_admission_wire_carrier.test.js
@@ -282,13 +282,16 @@ function registerEndToEndThroughThe3Tests4() {
expect(result.accepted).to.equal(true, 'reason: ' + result.reason);
});
- it('REFUSES the identical round when the RPC drops the map', async function () {
+ it('still does not accept the identical round when the RPC drops the map', async function () {
+ // The round is rebuilt over legacy bytes instead of being refused at the builder,
+ // so it fails on its signatures. Not accepted either way, which is the property
+ // the carrier exists to hold.
const controller = await bootApi({ priceAggregator: agg });
const params = signedRound(MAP);
delete params.admit_blocks; // the pre-row behaviour, exactly
const result = await controller.pushpriceround(params);
expect(result.accepted).to.equal(false);
- expect(result.reason).to.match(/refusing to build a legacy canonical/);
+ expect(result.reason).to.not.match(/refusing to build a legacy canonical/);
});
it('refuses a map edited in flight, so the carrier cannot be used to rewrite one', async function () {
diff --git a/test/unit/oracle/price/price_admission_wire_carrier_batch.test.js b/test/unit/oracle/price/price_admission_wire_carrier_batch.test.js
index 1dce7543c..513ad5947 100644
--- a/test/unit/oracle/price/price_admission_wire_carrier_batch.test.js
+++ b/test/unit/oracle/price/price_admission_wire_carrier_batch.test.js
@@ -245,18 +245,22 @@ function registerThePriceBatchRailCarries2Tests1() {
expect(result.rejected).to.equal(2);
});
- it('refuses an era batch whose rounds carry NO map, whole, storing nothing', async function () {
+ it('takes the legacy byte path for an era batch whose rounds carry NO map, storing nothing', async function () {
+ // A version seam is not a fault. The batch is rebuilt over legacy bytes and quorum
+ // verification decides it, so it is refused on its signatures rather than on an era
+ // complaint. Nothing lands either way, which is the property that still matters.
const result = await aggOn(NETWORK).receiveValidatedBatch('BTC', batchOn(ADMIT_AT + 1));
expect(result.accepted).to.equal(false);
- expect(result.reason).to.match(/admission map does not match the round's era/);
+ expect(result.reason).to.not.match(/admission map does not match the round's era/);
expect(result.stored).to.equal(0);
- expect(result.rejected).to.equal(2);
});
- it('refuses a LEGACY batch that was handed a map, the other direction', async function () {
+ it('takes the legacy byte path for a LEGACY batch that was handed a map, the other direction', async function () {
+ // An activation-inert consumer treats admit_blocks as absent even when a newer
+ // producer supplied it, so the map is ignored and the signatures decide.
const result = await aggOn(NETWORK).receiveValidatedBatch('BTC', batchOn(LEGACY_AT, [MAP5, MAP6]));
expect(result.accepted).to.equal(false);
- expect(result.reason).to.match(/admission map does not match the round's era/);
+ expect(result.reason).to.not.match(/admission map does not match the round's era/);
});
it('refuses a map the encoder cannot spell rather than throwing on it', async function () {
diff --git a/test/unit/oracle/price/price_admission_wire_carrier_producer.test.js b/test/unit/oracle/price/price_admission_wire_carrier_producer.test.js
index 244081221..b7e345c65 100644
--- a/test/unit/oracle/price/price_admission_wire_carrier_producer.test.js
+++ b/test/unit/oracle/price/price_admission_wire_carrier_producer.test.js
@@ -251,9 +251,11 @@ function registerTheBatchProducerCarriesEach2Tests1() {
expect(legacy.join('|')).to.not.match(/BTC:/);
});
- it('refuses to emit a wire for an era round with no map, so nothing its verifiers would refuse is ever spent on', function () {
- expect(() => pub.buildPriceBatchBody(5, 6, ADMIT_AT + 1, rounds(ADMIT_AT), SIGS))
- .to.throw(/has no admit_blocks; refusing to build a legacy canonical/);
+ it('emits a LEGACY wire for an era round with no map, leaving the verifiers to decide it', function () {
+ // An armed producer with no map in hand spells the legacy bytes rather than refusing,
+ // so a mixed-version fleet can still bind the round.
+ const body = pub.buildPriceBatchBody(5, 6, ADMIT_AT + 1, rounds(ADMIT_AT), SIGS);
+ expect(body).to.not.match(/BTC:/);
});
it('splits a window at the activation, so no batch straddles it', function () {
diff --git a/test/unit/oracle/price/price_v2_payload_twin_parity.test.js b/test/unit/oracle/price/price_v0_batch_canonical_parity.test.js
similarity index 84%
rename from test/unit/oracle/price/price_v2_payload_twin_parity.test.js
rename to test/unit/oracle/price/price_v0_batch_canonical_parity.test.js
index 30f8a37c1..0c4540e14 100644
--- a/test/unit/oracle/price/price_v2_payload_twin_parity.test.js
+++ b/test/unit/oracle/price/price_v0_batch_canonical_parity.test.js
@@ -11,15 +11,16 @@
* contact legal@dankest.llc.
*
**********************************************************************
- * priceV2PayloadTwinParity: the PRICE v0 canonical is written three times, and the
- * hub owns two of them. OracleConsensus.buildPriceBatchPayload SIGNS; anything
+ * priceV0BatchCanonicalParity: the PRICE v0 canonical is written by the two hub
+ * production twins and an indexer verifier. OracleConsensus.buildPriceBatchPayload
+ * SIGNS; anything
* PriceAggregator.buildPriceBatchPayload or xchain-indexer ed25519.buildPriceBatchPayload
* builds differently is a batch the federation cannot verify, so the price rail stops
* and the native-fee / XCHAIN-USD path stops with it. This suite asserts byte equality
* on a batch built to exercise every normalization the builders own (round order, pair
* order, integer spelling, the coinPair/pair spelling split).
*
- * The indexer twin is resolved by monorepo-relative path: a standalone hub checkout
+ * The indexer verifier is resolved by monorepo-relative path: a standalone hub checkout
* skips that comparison (unless XCHAIN_REQUIRE_SIBLINGS=1) and still compares the hub's
* own two copies against each other.
*
@@ -82,7 +83,7 @@ function hubTwins() {
};
}
-function loadIndexerTwin(ctx) {
+function loadIndexerVerifier(ctx) {
try { return require('../../../../../xchain-indexer/src/consensus/ed25519.js'); }
catch (e) {
if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1')
@@ -145,12 +146,12 @@ function loadIndexerTwin(ctx) {
assert.strictEqual(eq.isEquivHeaderActive(1, 'mainnet'), false, 'the gate v0 would have failed here');
}
- let registeragainstTheIndexerVerifierTwin6;
+ let registerAgainstTheIndexerVerifier6;
{
- function allThreeTwinsEmitTheIdenticalTest8() {
- let ed25519 = loadIndexerTwin(this);
+ function allThreeBuildersEmitTheIdenticalTest8() {
+ let ed25519 = loadIndexerVerifier(this);
if (!ed25519) return;
let fromIndexer = ed25519.buildPriceBatchPayload(FIRST, LAST, ANCHOR, batch());
@@ -160,8 +161,8 @@ function loadIndexerTwin(ctx) {
'PriceAggregator (hub ingest verifier) diverged from the indexer verifier');
}
- function allThreeNormalizeCallerOrderingToTest9() {
- let ed25519 = loadIndexerTwin(this);
+ function allThreeBuildersNormalizeCallerOrderingToTest9() {
+ let ed25519 = loadIndexerVerifier(this);
if (!ed25519) return;
let expected = ed25519.buildPriceBatchPayload(FIRST, LAST, ANCHOR, batch());
@@ -174,39 +175,39 @@ function loadIndexerTwin(ctx) {
}
}
- function allThreeSpellCoinpairAndPairTest10() {
- let ed25519 = loadIndexerTwin(this);
+ function allThreeBuildersSpellCoinpairAndPairTest10() {
+ let ed25519 = loadIndexerVerifier(this);
if (!ed25519) return;
let rounds = [{ round: 7, timestamp: 100, btcBlockHeight: 5, pairs: [{ coinPair: 'BTC/USD', price: '1' }] }];
- let twin = [{ round: 7, timestamp: 100, btcBlockHeight: 5, pairs: [{ pair: 'BTC/USD', price: 1 }] }];
+ let alternate = [{ round: 7, timestamp: 100, btcBlockHeight: 5, pairs: [{ pair: 'BTC/USD', price: 1 }] }];
let expected = ed25519.buildPriceBatchPayload(7, 7, 5, rounds);
- assert.strictEqual(ed25519.buildPriceBatchPayload(7, 7, 5, twin), expected, 'indexer verifier');
- assert.strictEqual(hub.producer.buildPriceBatchPayload(7, 7, 5, twin), expected, 'hub producer');
+ assert.strictEqual(ed25519.buildPriceBatchPayload(7, 7, 5, alternate), expected, 'indexer verifier');
+ assert.strictEqual(hub.producer.buildPriceBatchPayload(7, 7, 5, alternate), expected, 'hub producer');
assert.strictEqual(hub.ingest.buildPriceBatchPayload(7, 7, 5, rounds), expected, 'hub ingest');
}
- function againstTheIndexerVerifierTwinSuite7() {
- it('all three twins emit the identical canonical for one batch', allThreeTwinsEmitTheIdenticalTest8);
- it('all three normalize caller ordering to the same bytes', allThreeNormalizeCallerOrderingToTest9);
- it('all three spell coinPair and pair to the same bytes', allThreeSpellCoinpairAndPairTest10);
+ function againstTheIndexerVerifierSuite7() {
+ it('all three builders emit the identical canonical for one batch', allThreeBuildersEmitTheIdenticalTest8);
+ it('all three builders normalize caller ordering to the same bytes', allThreeBuildersNormalizeCallerOrderingToTest9);
+ it('all three builders spell coinPair and pair to the same bytes', allThreeBuildersSpellCoinpairAndPairTest10);
}
- registeragainstTheIndexerVerifierTwin6 = function registerSuite() {
- describe('against the indexer verifier twin', againstTheIndexerVerifierTwinSuite7);
+ registerAgainstTheIndexerVerifier6 = function registerSuite() {
+ describe('against the indexer verifier', againstTheIndexerVerifierSuite7);
};
}
- function priceV0CanonicalThreeWayTwinSuite1() {
+ function priceV0BatchCanonicalParitySuite1() {
before(function () { hub = hubTwins(); });
it('the producer emits the pinned key order, ascending rounds and sorted pairs', theProducerEmitsThePinnedKeyTest2);
it('the hub twins agree with each other, byte for byte', theHubTwinsAgreeWithEachTest3);
it('the hub twins are caller-order independent', theHubTwinsAreCallerOrderTest4);
it('the hub twins wrap in the EQUIV header unconditionally, with no activation gate', theHubTwinsWrapInTheTest5);
- registeragainstTheIndexerVerifierTwin6();
+ registerAgainstTheIndexerVerifier6();
}
- describe('PRICE v0 canonical: three-way twin parity', priceV0CanonicalThreeWayTwinSuite1);
+ describe('PRICE v0 batch canonical parity', priceV0BatchCanonicalParitySuite1);
}
diff --git a/test/unit/oracle/price/price_v0_canonical_admission.test.js b/test/unit/oracle/price/price_v0_canonical_admission.test.js
index 782937cd1..e14d0db26 100644
--- a/test/unit/oracle/price/price_v0_canonical_admission.test.js
+++ b/test/unit/oracle/price/price_v0_canonical_admission.test.js
@@ -36,6 +36,7 @@ const crypto = require('crypto');
const sinon = require('sinon');
const { expect } = require('chai');
const { createMockHub } = require('../../../helpers/mockHub');
+const armAdmission = require('../../../helpers/armAdmission');
const eq = require('../../../../src/consensus/equivocation_header.js');
// The regtest producer activation this suite arms, keyed on the ROUND's own BTC anchor. One
// armed process therefore drives both eras: a round below this height is a pre-activation round and
@@ -81,43 +82,26 @@ function armTwins() {
if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1')
throw new Error('PRICE v0 admission parity cannot run: xchain-indexer sibling missing (' + e.message + ')');
}
- const paths = hubPaths.concat(indexerPaths || []);
- const saved = paths.map(p => [p, require.cache[p]]);
- const savedEnv = process.env.XC_MIRROR_ADMISSION_ACTIVATION;
- for (const p of paths) delete require.cache[p];
- process.env.XC_MIRROR_ADMISSION_ACTIVATION = String(ADMIT_AT);
-
- const OracleConsensus = require('../../../../src/oracle/consensus.js');
- const PriceAggregator = require('../../../../src/oracle/price_aggregator.js');
- const act = require('../../../../src/consensus/gates/mirror_admission_gate.js');
- const indexer = indexerPaths ? require('../../../../../xchain-indexer/src/consensus/ed25519.js') : null;
-
- // Put the process back exactly as it was found. The instances built below keep the
- // armed modules they closed over, so the rest of the run still sees the inert tree it
- // was written against: arming is scoped to this file and not to the process.
- function restore() {
- for (const [p, mod] of saved) {
- if (mod === undefined) delete require.cache[p]; else require.cache[p] = mod;
- }
- if (savedEnv === undefined) delete process.env.XC_MIRROR_ADMISSION_ACTIVATION;
- else process.env.XC_MIRROR_ADMISSION_ACTIVATION = savedEnv;
- }
-
- function hubOn(network) { return { db: null, network: network, getPeerManager: () => ({}) }; }
-
- return {
- act: act,
- indexer: indexer,
- producer: new OracleConsensus(hubOn(NETWORK), {}),
- ingest: new PriceAggregator(hubOn(NETWORK)),
- // Second pair on an INERT network, to drive the era key: a mainnet round is a pre-activation
- // round at every height, including heights far above the armed regtest threshold.
- inertProducer: new OracleConsensus(hubOn('mainnet'), {}),
- inertIngest: new PriceAggregator(hubOn('mainnet')),
- OracleConsensus: OracleConsensus,
- PriceAggregator: PriceAggregator,
- restore: restore
- };
+ return armAdmission(ADMIT_AT, hubPaths.concat(indexerPaths || []), () => {
+ const OracleConsensus = require('../../../../src/oracle/consensus.js');
+ const PriceAggregator = require('../../../../src/oracle/price_aggregator.js');
+ const act = require('../../../../src/consensus/gates/mirror_admission_gate.js');
+ const indexer = indexerPaths ? require('../../../../../xchain-indexer/src/consensus/ed25519.js') : null;
+ function hubOn(network) { return { db: null, network: network, getPeerManager: () => ({}) }; }
+
+ return {
+ act: act,
+ indexer: indexer,
+ producer: new OracleConsensus(hubOn(NETWORK), {}),
+ ingest: new PriceAggregator(hubOn(NETWORK)),
+ // Second pair on an INERT network, to drive the era key: a mainnet round is a pre-activation
+ // round at every height, including heights far above the armed regtest threshold.
+ inertProducer: new OracleConsensus(hubOn('mainnet'), {}),
+ inertIngest: new PriceAggregator(hubOn('mainnet')),
+ OracleConsensus: OracleConsensus,
+ PriceAggregator: PriceAggregator
+ };
+ });
}
// The three builders on one round. `network` picks which pair of hub instances answers,
@@ -173,16 +157,16 @@ function builtBy(height, map, network) {
expect(b.producer.endsWith('}')).to.equal(true);
}
- function allThreeRefuseAMapOnTest7() {
- const b = three(LEGACY_AT, admitMap());
- for (const [name, build] of [['producer', b.producer], ['ingest', b.ingest], ['indexer', b.indexer]])
- expect(build, name).to.throw(/refusing to build an admission-era canonical/);
+ function allThreeIgnoreAMapOnTest7() {
+ // An inert consumer treats a supplied map as absent, so the legacy bytes stand.
+ const plain = builtBy(LEGACY_AT, undefined), b = builtBy(LEGACY_AT, admitMap());
+ for (const k of ['producer', 'ingest', 'indexer']) assert.strictEqual(b[k], plain[k], k);
}
function belowTheActivationTheLegacyBytesSuite4() {
it('all three emit the identical canonical, with no admission field', allThreeEmitTheIdenticalCanonicalTest5);
it('a mainnet round is legacy at a height far above the armed regtest one', aMainnetRoundIsLegacyAtTest6);
- it('all three REFUSE a map on a legacy round rather than signing bytes no era reads', allThreeRefuseAMapOnTest7);
+ it('all three IGNORE a map on a legacy round and sign the untouched legacy bytes', allThreeIgnoreAMapOnTest7);
}
registerbelowTheActivationTheLegacyBytes3 = function registerSuite() {
@@ -245,12 +229,13 @@ function builtBy(height, map, network) {
assert.strictEqual(b.indexer, b.producer);
}
- function allThreeRefuseARoundWithTest14() {
- for (const missing of [null, undefined]) {
- const b = three(ADMIT_AT, missing);
- for (const [name, build] of [['producer', b.producer], ['ingest', b.ingest], ['indexer', b.indexer]])
- expect(build, name + ' with ' + String(missing)).to.throw(/refusing to build a legacy canonical/);
- }
+ function allThreeUseLegacyBytesWhenTest14() {
+ // Armed with no map in hand a consumer binds the legacy bytes rather than refusing.
+ // The with-map build is compared alongside: equal spellings would pass vacuously.
+ const withMap = builtBy(ADMIT_AT, admitMap());
+ for (const b of [builtBy(ADMIT_AT, null), builtBy(ADMIT_AT, undefined)])
+ for (const k of ['producer', 'ingest', 'indexer'])
+ assert.strictEqual(b[k].endsWith('}') && b[k] !== withMap[k], true, k + ' did not use legacy bytes');
}
function allThreeRefuseAMapTheTest15() {
@@ -267,7 +252,7 @@ function builtBy(height, map, network) {
it('appends the field after the JSON body and inside the EQUIV wrapper', appendsTheFieldAfterTheJsonTest11);
it('the map insertion order never reaches the bytes, on any of the three', theMapInsertionOrderNeverReachesTest12);
it('one changed height changes the signed bytes on all three, and they still agree', oneChangedHeightChangesTheSignedTest13);
- it('all three REFUSE a round with no map, rather than signing legacy bytes above the era', allThreeRefuseARoundWithTest14);
+ it('all three sign the legacy bytes for a round with no map above the era', allThreeUseLegacyBytesWhenTest14);
it('all three refuse a map the encoding cannot spell injectively', allThreeRefuseAMapTheTest15);
}
@@ -326,12 +311,13 @@ function builtBy(height, map, network) {
expect(result.reason).to.match(/admission map unusable/);
}
- async function rejectsAnAdmissionEraRoundThatTest20() {
+ async function doesNotAcceptAnAdmissionEraRoundTest20() {
+ // Rebuilt over legacy bytes rather than refused at the builder: signatures decide.
const r = round();
delete r.admit_blocks;
const result = await agg.receiveValidatedRound('BTC', r);
expect(result.accepted).to.equal(false);
- expect(result.reason).to.match(/refusing to build a legacy canonical/);
+ expect(result.reason).to.not.match(/refusing to build a legacy canonical/);
}
async function refusesTheSignaturesWhenThePushedTest21() {
@@ -374,7 +360,7 @@ function builtBy(height, map, network) {
afterEach(function () { sinon.restore(); });
it('accepts a round whose signatures cover the admission-era canonical', acceptsARoundWhoseSignaturesCoverTest18);
it('rejects, and does not throw, when the pushed map is unspellable', rejectsAndDoesNotThrowWhenTest19);
- it('rejects an admission-era round that carries no map at all', rejectsAnAdmissionEraRoundThatTest20);
+ it('does not accept an admission-era round that carries no map at all', doesNotAcceptAnAdmissionEraRoundTest20);
it('refuses the signatures when the pushed map is not the one that was signed', refusesTheSignaturesWhenThePushedTest21);
}
@@ -386,7 +372,7 @@ function builtBy(height, map, network) {
function priceV0CanonicalTheAdmissionFieldSuite1() {
before(function () { armed = armTwins(); });
- after(function () { if (armed) armed.restore(); armed = null; });
+ after(function () { armed = null; });
it('is ARMED for this suite, so neither era case is vacuous', isArmedForThisSuiteSoTest2);
registerbelowTheActivationTheLegacyBytes3();
registeratAndAboveTheActivationThe8();
diff --git a/test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js b/test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js
index 92e6aab0a..10f6ed50d 100644
--- a/test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js
+++ b/test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js
@@ -33,6 +33,7 @@
const assert = require('assert');
const OracleConsensus = require('../../../../src/oracle/consensus.js');
const PriceAggregator = require('../../../../src/oracle/price_aggregator.js');
+const armAdmission = require('../../../helpers/armAdmission');
const eq = require('../../../../src/consensus/equivocation_header.js');
const ANCHOR = 912345; // equals the last round's own anchor, per the wire format
@@ -145,37 +146,19 @@ function loadIndexerTwin(ctx) {
throw new Error('PRICE v0 single-round parity cannot run: xchain-indexer sibling missing (' + e.message + ')');
}
- const paths = hubPaths.concat(indexerPaths || []);
- const saved = paths.map(p => [p, require.cache[p]]);
- const savedEnv = process.env.XC_MIRROR_ADMISSION_ACTIVATION;
- for (const p of paths) delete require.cache[p];
- process.env.XC_MIRROR_ADMISSION_ACTIVATION = String(ADMIT_AT);
-
- const ArmedOracleConsensus = require('../../../../src/oracle/consensus.js');
- const ArmedPriceAggregator = require('../../../../src/oracle/price_aggregator.js');
- const act = require('../../../../src/consensus/gates/mirror_admission_gate.js');
- const indexer = indexerPaths ? require('../../../../../xchain-indexer/src/consensus/ed25519.js') : null;
-
- // Put the process back exactly as it was found. The instances built below keep the
- // armed modules they closed over, so the batch describe above and every other file in
- // the run still see the inert tree they were written against: the arming is scoped to
- // this describe and never to the process.
- function restore() {
- for (const [p, mod] of saved) {
- if (mod === undefined) delete require.cache[p]; else require.cache[p] = mod;
- }
- if (savedEnv === undefined) delete process.env.XC_MIRROR_ADMISSION_ACTIVATION;
- else process.env.XC_MIRROR_ADMISSION_ACTIVATION = savedEnv;
- }
-
- const stubHub = { db: null, network: NETWORK, getPeerManager: () => ({}) };
- return {
- act: act,
- indexer: indexer,
- producer: new ArmedOracleConsensus(stubHub, {}),
- ingest: new ArmedPriceAggregator(stubHub),
- restore: restore
- };
+ return armAdmission(ADMIT_AT, hubPaths.concat(indexerPaths || []), () => {
+ const ArmedOracleConsensus = require('../../../../src/oracle/consensus.js');
+ const ArmedPriceAggregator = require('../../../../src/oracle/price_aggregator.js');
+ const act = require('../../../../src/consensus/gates/mirror_admission_gate.js');
+ const indexer = indexerPaths ? require('../../../../../xchain-indexer/src/consensus/ed25519.js') : null;
+ const stubHub = { db: null, network: NETWORK, getPeerManager: () => ({}) };
+ return {
+ act: act,
+ indexer: indexer,
+ producer: new ArmedOracleConsensus(stubHub, {}),
+ ingest: new ArmedPriceAggregator(stubHub)
+ };
+ });
}
let armed = null;
@@ -247,15 +230,17 @@ function loadIndexerTwin(ctx) {
assert.strictEqual(armed.indexer.buildPriceBatchPayload(FIRST, LAST, LEGACY_AT, legacy, NETWORK), fromProducer);
}
- function everyTwinRefusesInBothDirectionsTest17() {
+ function everyTwinUsesLegacyBytesTest17() {
+ // Both directions of the version seam take the legacy byte path. An armed twin with
+ // no map emits no admission field, and an inert twin handed one ignores it.
let twins = [['producer', (r, a) => armed.producer.buildPriceBatchPayload(FIRST, LAST, a, r)],
['ingest', (r, a) => armed.ingest.buildPriceBatchPayload(FIRST, LAST, a, r)]];
if (armed.indexer) twins.push(['indexer', (r, a) => armed.indexer.buildPriceBatchPayload(FIRST, LAST, a, r, NETWORK)]);
for (const [name, build] of twins) {
- assert.throws(() => build(rounds(ADMIT_AT), ADMIT_AT + 1), /has no admit_blocks; refusing to build a legacy canonical/,
- name + ' built legacy bytes for an era round');
- assert.throws(() => build(rounds(LEGACY_AT - 1, MAPS), LEGACY_AT), /was handed admit_blocks .*; refusing to build an admission-era canonical/,
- name + ' built era bytes for a legacy round');
+ assert.strictEqual(/admit/.test(build(rounds(ADMIT_AT), ADMIT_AT + 1)), false,
+ name + ' emitted admission bytes for an era round with no map');
+ assert.strictEqual(/admit/.test(build(rounds(LEGACY_AT - 1, MAPS), LEGACY_AT)), false,
+ name + ' emitted admission bytes for a legacy round handed a map');
}
}
@@ -271,7 +256,7 @@ function loadIndexerTwin(ctx) {
function theBatchCanonicalCarriesOneAdmissionSuite14() {
it('the hub twins agree, and the indexer verifier with them, in the admission era', theHubTwinsAgreeAndTheTest15);
it('below the activation the bytes are the pre-admission form exactly, on all twins', belowTheActivationTheBytesAreTest16);
- it('every twin refuses in both directions: an era round with no map, a legacy round with one', everyTwinRefusesInBothDirectionsTest17);
+ it('every twin uses legacy bytes in both directions: an era round with no map, a legacy round with one', everyTwinUsesLegacyBytesTest17);
it('the map is keyed on EACH round\'s own anchor: a straddling window carries it on the era round only', theMapIsKeyedOnEachTest18);
}
@@ -283,7 +268,7 @@ function loadIndexerTwin(ctx) {
function priceV0SingleRoundCanonicalThreeSuite11() {
before(function () { armed = armTwins(); });
- after(function () { if (armed) armed.restore(); armed = null; });
+ after(function () { armed = null; });
it('is ARMED, so neither era block below is the other one in disguise', isArmedSoNeitherEraBlockTest12);
for (const era of [
{ name: 'below the activation, where the round is legacy', height: LEGACY_AT, map: () => undefined, tail: null },
diff --git a/test/unit/oracle/price/push_price_batch.test.js b/test/unit/oracle/price/push_price_batch.test.js
index 8ee8f1314..ac6ac770d 100644
--- a/test/unit/oracle/price/push_price_batch.test.js
+++ b/test/unit/oracle/price/push_price_batch.test.js
@@ -111,7 +111,9 @@ function driveMiddleware(mw, methodOrMethods, apiKey) {
return { nexted, res };
}
function findAuthMiddleware(useCalls) {
- const candidates = useCalls.filter((fn) => typeof fn === 'function' && fn.length >= 3);
+ // The rate-limit batch surcharge also passes a single call through, so it is named out.
+ const candidates = useCalls.filter((fn) => typeof fn === 'function' && fn.length >= 3 &&
+ fn.name !== 'rpcBatchSurcharge');
for (const fn of candidates) {
try {
const probe = driveMiddleware(fn, 'updateconfig', 'wrong-key-probe');
diff --git a/test/unit/oracle/publisher/batch/oracle_publisher_batch.test.js b/test/unit/oracle/publisher/batch/oracle_publisher_batch.test.js
index f51fa7954..077b48d31 100644
--- a/test/unit/oracle/publisher/batch/oracle_publisher_batch.test.js
+++ b/test/unit/oracle/publisher/batch/oracle_publisher_batch.test.js
@@ -120,15 +120,18 @@ function makeSigner(opts) {
}
// Capability snapshot stand-in. `oracle_publish` drives leader election, `price`
-// only sizes the pre-signing estimate.
+// only sizes the pre-signing estimate. The weight snapshot serves the same members,
+// since election reads it at and above the stake-weighted quorum activation.
function makeSnapshot(publishers, priceSet) {
+ let resolve = async (capability) => {
+ if (capability === 'oracle_publish') {
+ return { validators: (publishers || [ME]).map(p => ({ pubkey: p })) };
+ }
+ return { validators: (priceSet || publishers || [ME]).map(p => ({ pubkey: p })) };
+ };
return {
- getSnapshot: sinon.stub().callsFake(async (capability) => {
- if (capability === 'oracle_publish') {
- return { validators: (publishers || [ME]).map(p => ({ pubkey: p })) };
- }
- return { validators: (priceSet || publishers || [ME]).map(p => ({ pubkey: p })) };
- })
+ getSnapshot: sinon.stub().callsFake(resolve),
+ getWeightSnapshot: sinon.stub().callsFake(resolve),
};
}
diff --git a/test/unit/oracle/publisher/batch/oracle_publisher_batch_16_publisher_role_and_publish_history.test.js b/test/unit/oracle/publisher/batch/oracle_publisher_batch_16_publisher_role_and_publish_history.test.js
index d3a058dc1..72dbc32c8 100644
--- a/test/unit/oracle/publisher/batch/oracle_publisher_batch_16_publisher_role_and_publish_history.test.js
+++ b/test/unit/oracle/publisher/batch/oracle_publisher_batch_16_publisher_role_and_publish_history.test.js
@@ -83,7 +83,8 @@ const testCase3 = async function () {
};
const testCase4 = async function () {
- let h = makePublisher({ capabilitySnapshot: { getSnapshot: sinon.stub().resolves(null) } });
+ let h = makePublisher({ capabilitySnapshot: {
+ getSnapshot: sinon.stub().resolves(null), getWeightSnapshot: sinon.stub().resolves(null) } });
await h.p.start();
for (let r = 0; r < 12; r++) h.p._buffer.set(r, bufferedFixture(r));
@@ -97,11 +98,12 @@ const testCase4 = async function () {
const testCase5 = async function () {
let dark = false;
+ let resolve = async () => (dark ? null : { validators: [{ pubkey: ME }] });
let h = makePublisher({
signerOpts: { met: false },
capabilitySnapshot: {
- getSnapshot: sinon.stub().callsFake(async () => (
- dark ? null : { validators: [{ pubkey: ME }] }))
+ getSnapshot: sinon.stub().callsFake(resolve),
+ getWeightSnapshot: sinon.stub().callsFake(resolve),
}
});
await h.p.start();
@@ -118,11 +120,12 @@ const testCase5 = async function () {
const testCase6 = async function () {
let members = [ME, PEER1];
+ let resolve = async () => ({ validators: members.map(p => ({ pubkey: p })) });
let h = makePublisher({
signerOpts: { met: false },
capabilitySnapshot: {
- getSnapshot: sinon.stub().callsFake(async () => (
- { validators: members.map(p => ({ pubkey: p })) }))
+ getSnapshot: sinon.stub().callsFake(resolve),
+ getWeightSnapshot: sinon.stub().callsFake(resolve),
}
});
await h.p.start();
diff --git a/test/unit/oracle/publisher/oracle_publisher_confirmation_05_core.test.js b/test/unit/oracle/publisher/oracle_publisher_confirmation_05_core.test.js
index bc53a73d5..e6f66db16 100644
--- a/test/unit/oracle/publisher/oracle_publisher_confirmation_05_core.test.js
+++ b/test/unit/oracle/publisher/oracle_publisher_confirmation_05_core.test.js
@@ -1,22 +1,6 @@
'use strict';
-const {
- fs,
- os,
- path,
- sinon,
- expect,
- waitUntil,
- ME,
- ADDR,
- utxo,
- makeEncoder,
- queueEntry,
- makePublisher,
- seedQueue,
- readJsonl,
- cleanupPublisherConfirmation
-} = require('./oracle_publisher_confirmation.test.js');
+const { expect } = require('./oracle_publisher_confirmation.test.js');
const OraclePublisher = require('../../../../src/oracle/publisher.js');
@@ -30,17 +14,15 @@ function pub(cfg) {
}
const testCase1 = function () {
- if (pub().allowUnconfirmedInputs !== false) throw new Error('default must be false');
+ expect(pub().allowUnconfirmedInputs, 'default must be false').to.equal(false);
};
const testCase2 = function () {
- if (pub({ ORACLE_PUBLISH_ALLOW_UNCONFIRMED_INPUTS: 'true' }).allowUnconfirmedInputs !== true) {
- throw new Error('explicit true must opt in');
- }
+ expect(pub({ ORACLE_PUBLISH_ALLOW_UNCONFIRMED_INPUTS: 'true' }).allowUnconfirmedInputs,
+ 'explicit true must opt in').to.equal(true);
for (const v of ['false', '1', 'yes', '', 'TRUE']) {
- if (pub({ ORACLE_PUBLISH_ALLOW_UNCONFIRMED_INPUTS: v }).allowUnconfirmedInputs !== false) {
- throw new Error('only the exact string true opts in, got ' + v);
- }
+ expect(pub({ ORACLE_PUBLISH_ALLOW_UNCONFIRMED_INPUTS: v }).allowUnconfirmedInputs,
+ 'only the exact string true opts in, got ' + v).to.equal(false);
}
};
diff --git a/test/unit/oracle/publisher/oracle_publisher_rotation_membership.test.js b/test/unit/oracle/publisher/oracle_publisher_rotation_membership.test.js
new file mode 100644
index 000000000..e5f0a772b
--- /dev/null
+++ b/test/unit/oracle/publisher/oracle_publisher_rotation_membership.test.js
@@ -0,0 +1,126 @@
+'use strict';
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+//
+// The PRICE publish rotation's oracle_publish membership: the weight snapshot at and
+// above the STAKE_WEIGHTED_QUORUM activation, the count snapshot below it, each key
+// ranked once, and an empty set logged as loudly as an unresolved one.
+
+const sinon = require('sinon');
+const { expect } = require('chai');
+const proxyquire = require('proxyquire');
+
+const A = 'aa'.repeat(32);
+const B = 'bb'.repeat(32);
+const C = 'cc'.repeat(32);
+
+let OraclePublisher;
+
+function loadModule() {
+ let fsMock = {
+ mkdirSync: sinon.stub(), existsSync: sinon.stub().returns(true), writeFileSync: sinon.stub(),
+ openSync: sinon.stub().returns(99), writeSync: sinon.stub(), fsyncSync: sinon.stub(),
+ closeSync: sinon.stub(), readFileSync: sinon.stub().returns(''),
+ };
+ OraclePublisher = proxyquire('../../../../src/oracle/publisher', {
+ fs: fsMock,
+ '../peers/encoder_client': function () { return null; },
+ });
+}
+
+function makeSnapshots(count, weight) {
+ return {
+ getSnapshot: sinon.stub().resolves(count),
+ getWeightSnapshot: sinon.stub().resolves(weight),
+ };
+}
+
+function makePublisher(network, capSS) {
+ return new OraclePublisher({
+ network: network,
+ p2pConfig: {},
+ getIdentity: sinon.stub().returns({ getPubkeyHex: sinon.stub().returns(A), sign: sinon.stub() }),
+ capabilityRegistry: null,
+ capabilitySnapshot: capSS,
+ oracleConsensus: null,
+ });
+}
+
+function registerGateTests() {
+ it('reads the count snapshot below the mainnet activation height', async function () {
+ let capSS = makeSnapshots({ validators: [{ pubkey: A }] }, { validators: [{ pubkey: B }] });
+ let keys = await makePublisher('mainnet', capSS).getActiveOraclePublishPubkeys(960999);
+ expect(keys).to.deep.equal([A]);
+ expect(capSS.getWeightSnapshot.called).to.equal(false);
+ });
+
+ it('reads the weight snapshot at the mainnet activation height', async function () {
+ let capSS = makeSnapshots({ validators: [{ pubkey: A }] }, { validators: [{ pubkey: B }] });
+ let keys = await makePublisher('mainnet', capSS).getActiveOraclePublishPubkeys(961000);
+ expect(keys).to.deep.equal([B]);
+ expect(capSS.getSnapshot.called).to.equal(false);
+ });
+
+ it('reads the weight snapshot at any height on testnet and regtest', async function () {
+ for (const network of ['testnet', 'regtest']) {
+ let capSS = makeSnapshots({ validators: [{ pubkey: A }] }, { validators: [{ pubkey: B }] });
+ expect(await makePublisher(network, capSS).getActiveOraclePublishPubkeys(1), network).to.deep.equal([B]);
+ }
+ });
+
+ it('keeps the count snapshot on an unscoped hub', async function () {
+ let capSS = makeSnapshots({ validators: [{ pubkey: A }] }, { validators: [{ pubkey: B }] });
+ expect(await makePublisher('', capSS).getActiveOraclePublishPubkeys(990000)).to.deep.equal([A]);
+ });
+}
+
+function registerShapeTests() {
+ it('ranks each key once when a weighted snapshot carries several sources for it', async function () {
+ let rows = [{ pubkey: C, source: 's1' }, { pubkey: A.toUpperCase(), source: 's1' },
+ { pubkey: C, source: 's2' }, { pubkey: A, source: 's3' }];
+ let pub = makePublisher('regtest', makeSnapshots(null, { validators: rows }));
+ expect(await pub.getActiveOraclePublishPubkeys(5)).to.deep.equal([A, C]);
+ expect(await pub.getMyRank(5)).to.equal(0);
+ expect(await pub.getActiveOraclePublishCount(5)).to.equal(2);
+ });
+
+ it('logs an empty member set once per dark spell and fails closed', async function () {
+ let warn = sinon.stub(console, 'warn');
+ let capSS = makeSnapshots(null, { validators: [] });
+ let pub = makePublisher('regtest', capSS);
+ expect(await pub.getActiveOraclePublishPubkeys(5)).to.deep.equal([]);
+ expect(await pub.getActiveOraclePublishPubkeys(5)).to.deep.equal([]);
+ expect(warn.callCount).to.equal(1);
+ expect(warn.firstCall.args.join(' ')).to.match(/empty member set/);
+ capSS.getWeightSnapshot.resolves({ validators: [{ pubkey: B }] });
+ expect(await pub.getActiveOraclePublishPubkeys(5)).to.deep.equal([B]);
+ capSS.getWeightSnapshot.resolves({ validators: [] });
+ await pub.getActiveOraclePublishPubkeys(5);
+ expect(warn.callCount).to.equal(2);
+ });
+
+ it('still fails closed and logs on a null or throwing weight snapshot', async function () {
+ let warn = sinon.stub(console, 'warn');
+ let pub = makePublisher('regtest', makeSnapshots(null, null));
+ expect(await pub.getActiveOraclePublishPubkeys(5)).to.deep.equal([]);
+ let pub2 = makePublisher('regtest', { getWeightSnapshot: sinon.stub().rejects(new Error('indexer down')) });
+ expect(await pub2.getActiveOraclePublishPubkeys(5)).to.deep.equal([]);
+ expect(warn.callCount).to.equal(2);
+ });
+}
+
+describe('OraclePublisher rotation membership', function () {
+ beforeEach(loadModule);
+ afterEach(function () { sinon.restore(); });
+
+ describe('the stake-weighted quorum gate', registerGateTests);
+ describe('the resolved set', registerShapeTests);
+});
diff --git a/test/unit/oracle/xchain_price_source_overrides.test.js b/test/unit/oracle/xchain_price_source_overrides.test.js
index 1db17869b..37ba3b082 100644
--- a/test/unit/oracle/xchain_price_source_overrides.test.js
+++ b/test/unit/oracle/xchain_price_source_overrides.test.js
@@ -149,6 +149,16 @@ xchainPriceSourceTests('ingestion bounds', function () {
expect(out.meta.carriedFrom).to.equal('bootstrap');
});
+ it('abstains in the sub-ulp band the downstream gates round onto PRICE_MAX', function () {
+ // Exact math admits this 8dp value; parseFloat rounds it to PRICE_MAX, so every
+ // co-sign and ingest gate refuses it. The producer must refuse it first.
+ const band = String(PRICE_MAX - 1) + '.99999999';
+ expect(parseFloat(band), 'the band value rounds onto the ceiling').to.equal(PRICE_MAX);
+ const { src } = makeSource({}, FINALIZED_BTC);
+ expect(src.entry(band, {})).to.equal(null);
+ expect(src.entry('0.05000000', {}).price).to.equal('0.05000000');
+ });
+
it('publishes at 8dp like every other pair', async function () {
const { src } = makeSource({}, FINALIZED_BTC);
expect((await src.derive(CTX)).price).to.match(/^\d+\.\d{8}$/);
diff --git a/test/unit/peers/manager/peer_manager_listener_ceiling.test.js b/test/unit/peers/manager/peer_manager_listener_ceiling.test.js
index f04966a5e..814e6f585 100644
--- a/test/unit/peers/manager/peer_manager_listener_ceiling.test.js
+++ b/test/unit/peers/manager/peer_manager_listener_ceiling.test.js
@@ -170,6 +170,8 @@ async function warningsDuring(fn) {
}
describe('PeerManager: message listener ceiling', function () {
+ // Allow for a cold read of every src/ file on a loaded CI venue; it runs in under a second warm.
+ this.timeout(30000);
it('the declared subscriber roster covers exactly the modules the sources register', function () {
const fromSource = subscribersFromSource();
diff --git a/test/unit/providers/llm/dockerfile_llm_provider_cli.test.js b/test/unit/providers/llm/dockerfile_llm_provider_cli.test.js
index 88d8b48a7..486897a18 100644
--- a/test/unit/providers/llm/dockerfile_llm_provider_cli.test.js
+++ b/test/unit/providers/llm/dockerfile_llm_provider_cli.test.js
@@ -51,7 +51,7 @@ describe('Dockerfile: the llm provider CLI is in the image', () => {
// Coverage floor: if the file stops looking like the hub's Dockerfile at all,
// every assertion below could pass vacuously against an empty read.
it('reads a Dockerfile that still builds the hub', () => {
- expect(dockerfile).to.match(/^FROM node:22-bookworm$/m);
+ expect(dockerfile).to.match(/^FROM node:22\.23\.2-bookworm@sha256:[0-9a-f]{64}$/m);
expect(dockerfile).to.include('/XChainHub/src');
});
diff --git a/test/unit/repo_guards/carrier_logic_pin.test.js b/test/unit/repo_guards/carrier_logic_pin.test.js
index cbcfd93bd..87f95eba9 100644
--- a/test/unit/repo_guards/carrier_logic_pin.test.js
+++ b/test/unit/repo_guards/carrier_logic_pin.test.js
@@ -42,9 +42,13 @@ function siblingWith(repo, rel) {
return fs.existsSync(path.join(dir, rel)) ? dir : null;
}
-/** Skip or fail on a missing sibling, by the environment's rule. */
-function missingSibling(test, repo) {
- if (REQUIRE_SIBLINGS) assert.fail(`${repo} is not checked out beside this repo and XCHAIN_REQUIRE_SIBLINGS=1`);
+/** Skip or fail once for every missing sibling, by the environment's rule; call it after the assertions (skip throws). */
+function missingSiblings(test, repos) {
+ const names = [...new Set(repos)].sort();
+ if (!names.length) return;
+ if (REQUIRE_SIBLINGS) {
+ assert.fail(`${names.join(', ')} ${names.length > 1 ? 'are' : 'is'} not checked out beside this repo and XCHAIN_REQUIRE_SIBLINGS=1`);
+ }
test.skip();
}
@@ -70,15 +74,17 @@ describe('bin/pins/carrier-logic.json: the carrier logic pin', function () {
it('(c) every twin id hashes the same in the sibling pin', function () {
const mismatches = [];
+ const missing = [];
for (const id of Object.keys(pin.entries)) {
for (const repo of pin.entries[id].twins || []) {
const dir = siblingWith(repo, pinModule.PIN_REL);
- if (!dir) { missingSibling(this, repo); continue; }
+ if (!dir) { missing.push(repo); continue; }
const theirs = (pinModule.readPin(dir).entries[id] || {}).hash;
if (theirs !== pin.entries[id].hash) mismatches.push(`${id}: ${repo} pins ${theirs}, this repo pins ${pin.entries[id].hash}`);
}
}
assert.deepStrictEqual(mismatches, [], 'a logic change to a twin re-pins every copy in one change set');
+ missingSiblings(this, missing);
});
});
@@ -105,10 +111,11 @@ describe('bin/pins/carrier-logic.json: the carrier logic pin', function () {
it('(e) every twin file\'s bytes equal every sibling copy', function () {
assert.ok(pinModule.MODULE_TWIN_FILES.length >= 2, 'the module and its ops half are both twins');
+ const missing = [];
for (const repo of pinModule.MODULE_TWINS) {
if (repo === pinModule.repoName(REPO_ROOT)) continue;
const dir = siblingWith(repo, pinModule.MODULE_REL);
- if (!dir) { missingSibling(this, repo); continue; }
+ if (!dir) { missing.push(repo); continue; }
for (const rel of pinModule.MODULE_TWIN_FILES) {
const theirs = path.join(dir, rel);
assert.ok(fs.existsSync(theirs), `${rel} is missing from the ${repo} copy: cp it there`);
@@ -116,6 +123,7 @@ describe('bin/pins/carrier-logic.json: the carrier logic pin', function () {
`${rel} differs from the ${repo} copy: edit one and cp it to the others`);
}
}
+ missingSiblings(this, missing);
});
it('(g) the membership rule and the pin name the same files, both ways', () => {
diff --git a/test/unit/repo_guards/consensus_identity_cli.test.js b/test/unit/repo_guards/consensus_identity_cli.test.js
index 6a9aecdca..6def49eb1 100644
--- a/test/unit/repo_guards/consensus_identity_cli.test.js
+++ b/test/unit/repo_guards/consensus_identity_cli.test.js
@@ -18,6 +18,7 @@
// test is what Node does with an uncaught throw, which an in-process stub cannot show.
const { expect } = require('chai');
+const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
@@ -45,4 +46,27 @@ describe('bin/consensus-identity.js: top-level error handling', function () {
// one-liner carries none, so this is the line that tells the two apart.
expect(res.stderr).to.not.match(/^\s+at /m);
});
+
+ it('exits 2 naming an unknown flag instead of ignoring it', function () {
+ const res = run(['--assert-no-absnet']);
+ expect(res.status).to.equal(2);
+ expect(res.stderr).to.match(/^consensus-identity: unknown flag --assert-no-absnet/);
+ expect(res.stdout).to.equal('');
+ });
+
+ it('exits 2 naming a value flag whose value is missing', function () {
+ const res = run(['--out']);
+ expect(res.status).to.equal(2);
+ expect(res.stderr).to.equal('consensus-identity: --out requires a value\n');
+ expect(res.stdout).to.equal('');
+ });
+
+ it('refuses a flag in the value position instead of consuming it as the path', function () {
+ // Consumed as a path, the flag would write a file of that name and drop the assertion.
+ const res = run(['--out', '--assert-no-absent']);
+ expect(res.status).to.equal(2);
+ expect(res.stderr).to.equal('consensus-identity: --out requires a value\n');
+ expect(res.stdout).to.equal('');
+ expect(fs.existsSync(path.join(REPO, '--assert-no-absent'))).to.equal(false);
+ });
});
diff --git a/test/unit/repo_guards/observability_sync_gate.test.js b/test/unit/repo_guards/observability_sync_gate.test.js
index bcf0490f2..98d44a62b 100644
--- a/test/unit/repo_guards/observability_sync_gate.test.js
+++ b/test/unit/repo_guards/observability_sync_gate.test.js
@@ -53,6 +53,13 @@ const SIBLING_ROOT = process.env.XCHAIN_SIBLING_ROOT || path.join(REPO_ROOT, '..
// restated, so adding a consumer or a file there cannot leave this suite
// asserting against a stale copy of the truth.
const SCRIPT_TEXT = fs.readFileSync(SCRIPT, 'utf8');
+const REQUIRE_SIBLINGS = process.env.XCHAIN_REQUIRE_SIBLINGS === '1';
+const PORTED_SUITES = new Map([
+ ['xchain-encoder', 'test/unit/observability.test.js'],
+ ['xchain-utxo-tracker', 'test/unit/observability.test.js'],
+ ['xchain-explorer', 'test/unit/lib/observability.test.js'],
+ ['xchain-sync', 'test/unit/observability.test.js']
+]);
function bashArray(name) {
const match = SCRIPT_TEXT.match(new RegExp(`^${name}=\\((.*)\\)$`, 'm'));
return match ? match[1].split(/\s+/).filter(Boolean) : [];
@@ -195,17 +202,20 @@ function registerObservabilityShimTheVendoredCopyParityGateSuite1Part3() {
}
function registerObservabilityShimTheVendoredCopyParityGateSuite1Part4() {
describe('the ported suite headers describe the mechanism that exists', function () {
- // Skips per consumer when the sibling is absent, the same way every other
+ // Skips per ported suite when the sibling is absent, the same way every other
// cross-repo guard here does; test/unit/repo_guards/sibling_coverage.test.js is what
- // reports which of them could not run.
- //
- // It reports an absent CHECKOUT, not an absent SUITE, so a consumer that
- // vendors the code and never ported the suite skips here and is reported
- // nowhere. Read a green run as "every ported suite named the gate".
- for (const consumer of CONSUMERS) {
+ // reports which of them could not run. Strict mode makes a missing ported
+ // suite a failure rather than a pending test.
+ for (const [consumer, relativeSuite] of PORTED_SUITES) {
it(`${consumer}: its ported observability suite names the real gate`, function () {
- const suite = path.join(SIBLING_ROOT, consumer, 'test', 'unit', 'observability.test.js');
- if (!fs.existsSync(suite)) return this.skip();
+ expect(CONSUMERS, `${consumer} has a ported suite but is not a sync consumer`).to.contain(consumer);
+ const suite = path.join(SIBLING_ROOT, consumer, relativeSuite);
+ if (!fs.existsSync(suite)) {
+ if (REQUIRE_SIBLINGS) {
+ throw new Error(`XCHAIN_REQUIRE_SIBLINGS=1 but the ported observability suite is missing at ${suite}`);
+ }
+ return this.skip();
+ }
const header = fs.readFileSync(suite, 'utf8').split('\nconst ')[0];
expect(header, 'the header must name the script that enforces parity').to.contain('sync-observability.sh');
expect(header, 'a header claiming a check "in CI" without naming it is what drifted').to.not.match(/parity is gated by a\s*\n\/\/ check across the vendored copies in CI/);
diff --git a/test/unit/shared/admission/admission_height.test.js b/test/unit/shared/admission/admission_height.test.js
index 9940b7a6e..cbb1167f4 100644
--- a/test/unit/shared/admission/admission_height.test.js
+++ b/test/unit/shared/admission/admission_height.test.js
@@ -353,11 +353,11 @@ describe('admission_height: the era gate, INERT', () => {
expect(armed.ah.admissionCanonicalField('XTEST', REGTEST, 1000, null)).to.equal('');
});
- it('refuses to build an ADMISSION canonical for a legacy-era row', () => {
- expect(() => armed.ah.admissionCanonicalField('XTEST', 'mainnet', 1000, { BTC: 1004 }))
- .to.throw(/refusing to build an admission-era canonical/);
- expect(() => armed.ah.admissionCanonicalField('XTEST', REGTEST, 1000, { BTC: 1004 }))
- .to.throw(/refusing to build an admission-era canonical/);
+ it('builds NO field for a legacy-era row that was handed a map, ignoring it', () => {
+ // An activation-inert consumer treats admit_blocks as absent even when a newer producer
+ // supplied it, so the legacy bytes stay byte for byte what every peer rebuilds.
+ expect(armed.ah.admissionCanonicalField('XTEST', 'mainnet', 1000, { BTC: 1004 })).to.equal('');
+ expect(armed.ah.admissionCanonicalField('XTEST', REGTEST, 1000, { BTC: 1004 })).to.equal('');
});
});
@@ -378,13 +378,11 @@ describe('admission_height: the era gate, ARMED at a regtest height', () => {
expect(armed.ah.isAdmissionEra('testnet', ERA_AT)).to.equal(false);
});
- it('refuses to build a LEGACY canonical for an admission-era row', () => {
- // The branch under test is the refusal, and it is the one that strands a row: a
- // modern row signed over pre-admission bytes reproduces for no verifier in the fleet.
- expect(() => armed.ah.admissionCanonicalField('XTEST', REGTEST, ERA_AT, null))
- .to.throw(/refusing to build a legacy canonical/);
- expect(() => armed.ah.admissionCanonicalField('XTEST', REGTEST, ERA_AT, undefined))
- .to.throw(/refusing to build a legacy canonical/);
+ it('builds NO field for an admission-era row with no map, taking the legacy path', () => {
+ // Only an armed consumer WITH a present map emits admission bytes. Armed with nothing
+ // in hand it spells the legacy bytes and lets quorum verification decide.
+ expect(armed.ah.admissionCanonicalField('XTEST', REGTEST, ERA_AT, null)).to.equal('');
+ expect(armed.ah.admissionCanonicalField('XTEST', REGTEST, ERA_AT, undefined)).to.equal('');
});
it('appends the field after a single pipe when the era is active', () => {
diff --git a/test/unit/shared/admission/admission_height_runtime.test.js b/test/unit/shared/admission/admission_height_runtime.test.js
index e968e3816..2e0920bf7 100644
--- a/test/unit/shared/admission/admission_height_runtime.test.js
+++ b/test/unit/shared/admission/admission_height_runtime.test.js
@@ -162,9 +162,10 @@ describe('at and above the activation', () => {
before(() => { armed = withAdmissionActivation(ERA_AT); });
after(() => { armed.restore(); });
- it('the match builder refuses to build LEGACY bytes for an admission-era row', () => {
- expect(() => armed.DEX.call({}, matchRow(), 0))
- .to.throw(/CrossChainDex.*refusing to build a legacy canonical/);
+ it('the match builder builds LEGACY bytes for an admission-era row with no map', () => {
+ // Armed with no map in hand, the builder spells the legacy bytes.
+ let raw = armed.DEX.call({}, matchRow(), 0);
+ expect(raw).to.not.match(/BTC:/);
});
it('an admission-era match canonical ends with the ASCII-ordered map', () => {
@@ -211,14 +212,16 @@ describe('below the activation', () => {
expect(raw.split('|').pop()).to.equal('0'); // b_filled_before, the old last field
});
- it('the match builder refuses an admission map on a legacy-era row', () => {
- expect(() => inert.DEX.call({}, matchRow({ network: 'mainnet', admit_blocks: { BTC: 1004, DOGE: 2004 } }), 0))
- .to.throw(/CrossChainDex.*refusing to build an admission-era canonical/);
+ it('the match builder ignores an admission map on a legacy-era row', () => {
+ // Inert means absent, whatever a newer producer put on the row.
+ let raw = inert.DEX.call({}, matchRow({ network: 'mainnet', admit_blocks: { BTC: 1004, DOGE: 2004 } }), 0);
+ expect(raw).to.not.match(/BTC:/);
});
- it('the policy builder refuses an admission map on a legacy-era row', () => {
- expect(() => inert.BRIDGE.call({}, policyRow({ network: 'mainnet', admit_block_btc: 1004 }), 0))
- .to.throw(/CrossChainPolicy.*refusing to build an admission-era canonical/);
+ it('the policy builder ignores an admission map on a legacy-era row', () => {
+ // The policy carrier reads the same era gate as the match carrier.
+ let raw = inert.BRIDGE.call({}, policyRow({ network: 'mainnet', admit_block_btc: 1004 }), 0);
+ expect(raw).to.not.match(/BTC:/);
});
});
}
diff --git a/test/unit/shared/admission/admission_watermark_price_default.test.js b/test/unit/shared/admission/admission_watermark_price_default.test.js
new file mode 100644
index 000000000..36dbee10f
--- /dev/null
+++ b/test/unit/shared/admission/admission_watermark_price_default.test.js
@@ -0,0 +1,55 @@
+'use strict';
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+//
+// The admission watermark's price rail window: the configured oracle round cadence
+// when set, and the one shared DEFAULT_ORACLE_ROUND_INTERVAL_MS every other oracle
+// cadence fallback reads when it is not.
+
+const { expect } = require('chai');
+const proxyquire = require('proxyquire');
+
+const WATERMARK_PATH = '../../../../src/peers/hub_db/admission_height_watermark.js';
+const { AdmissionHeightWatermark } = require(WATERMARK_PATH);
+const { DEFAULT_ORACLE_ROUND_INTERVAL_MS } = require('../../../../src/constants.js');
+
+const ENV_KEYS = ['ORACLE_ROUND_INTERVAL', 'ADMISSION_ORACLE_INGEST_WINDOW_MS'];
+
+// Build with the oracle knobs absent from the environment, so config alone decides.
+function withCleanEnv(fn) {
+ const saved = {};
+ for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
+ try { return fn(); }
+ finally { for (const k of ENV_KEYS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } }
+}
+
+describe('admission height watermark: the price rail window default', function () {
+
+ it('sizes the price window from a configured ORACLE_ROUND_INTERVAL', function () {
+ let w = withCleanEnv(() => new AdmissionHeightWatermark({ ORACLE_ROUND_INTERVAL: 200000 }));
+ expect(w.roundTerminalMs('price_snapshots')).to.equal(200000);
+ });
+
+ it('falls back to the shared oracle round default when ORACLE_ROUND_INTERVAL is unset', function () {
+ let w = withCleanEnv(() => new AdmissionHeightWatermark({}));
+ expect(w.roundTerminalMs('price_snapshots')).to.equal(DEFAULT_ORACLE_ROUND_INTERVAL_MS);
+ });
+
+ it('follows a retuned shared default rather than a local copy of it', function () {
+ // A distinct stand-in default proves the fallback reads constants.js, not a literal.
+ const { AdmissionHeightWatermark: Stubbed } = proxyquire(WATERMARK_PATH, {
+ '../../constants.js': { DEFAULT_ORACLE_ROUND_INTERVAL_MS: 123457 },
+ });
+ let w = withCleanEnv(() => new Stubbed({ ADMISSION_ORACLE_INGEST_WINDOW_MS: 200000 }));
+ expect(w.roundTerminalMs('price_snapshots')).to.equal(123457);
+ expect(w.roundTerminalMs('oracle_prices')).to.equal(200000);
+ });
+});
diff --git a/test/unit/shared/admission/admission_watermark_round_defaults.test.js b/test/unit/shared/admission/admission_watermark_round_defaults.test.js
new file mode 100644
index 000000000..a5ad67e5c
--- /dev/null
+++ b/test/unit/shared/admission/admission_watermark_round_defaults.test.js
@@ -0,0 +1,65 @@
+'use strict';
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+// The admission watermark's round-timeout fallbacks for the xdex, attest and anchor
+// rails. Each is the published trail bound for its rail, so each must read the one
+// shared definition its engine reads, never a local copy of the number.
+
+const { expect } = require('chai');
+const proxyquire = require('proxyquire');
+
+const WATERMARK_PATH = '../../../../src/peers/hub_db/admission_height_watermark.js';
+const { AdmissionHeightWatermark } = require(WATERMARK_PATH);
+const constants = require('../../../../src/constants.js');
+
+const ENV_KEYS = ['XDEX_ROUND_TIMEOUT_MS', 'XDEX_ROUND_MAX_LIFETIME_MS', 'ATTESTATION_ROUND_TIMEOUT_MS',
+ 'ANCHOR_ROUND_TIMEOUT_MS'];
+
+// Build with none of the round knobs set anywhere, so every rail takes its default.
+function withCleanEnv(fn) {
+ const saved = {};
+ for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
+ try { return fn(); }
+ finally { for (const k of ENV_KEYS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } }
+}
+
+describe('admission height watermark: round-timeout defaults bind to the shared constants', function () {
+
+ it('sizes each rail from its shared default with nothing configured', function () {
+ let w = withCleanEnv(() => new AdmissionHeightWatermark({}));
+ expect(w.roundWindows.xdex).to.equal(constants.DEFAULT_XDEX_ROUND_TIMEOUT_MS * 4);
+ expect(w.roundWindows.attest).to.equal(constants.DEFAULT_ATTESTATION_ROUND_TIMEOUT_MS);
+ expect(w.roundWindows.anchor).to.equal(constants.DEFAULT_ANCHOR_ROUND_TIMEOUT_MS);
+ });
+
+ it('follows a retuned shared default on every rail rather than a local copy', function () {
+ // Distinct stand-ins per rail prove each fallback reads its own constant.
+ const { AdmissionHeightWatermark: Stubbed } = proxyquire(WATERMARK_PATH, {
+ '../../constants.js': {
+ DEFAULT_XDEX_ROUND_TIMEOUT_MS: 11111,
+ DEFAULT_ATTESTATION_ROUND_TIMEOUT_MS: 22222,
+ DEFAULT_ANCHOR_ROUND_TIMEOUT_MS: 33333,
+ },
+ });
+ let w = withCleanEnv(() => new Stubbed({}));
+ expect(w.roundWindows.xdex).to.equal(11111 * 4);
+ expect(w.roundWindows.attest).to.equal(22222);
+ expect(w.roundWindows.anchor).to.equal(33333);
+ });
+
+ it('matches the anchor engine default read from the same constant', function () {
+ const StateAnchorPublisher = require('../../../../src/anchor/publisher.js');
+ let pub = withCleanEnv(() => new StateAnchorPublisher({ db: {}, p2pConfig: { DOGE_ADDRESS: 'Dpub1' } }));
+ let w = withCleanEnv(() => new AdmissionHeightWatermark({}));
+ expect(pub.roundTimeoutMs).to.equal(w.roundWindows.anchor);
+ });
+});
diff --git a/test/unit/shared/admission/follower_admission_bound.test.js b/test/unit/shared/admission/follower_admission_bound.test.js
index 2d64c6a77..3956ddfcd 100644
--- a/test/unit/shared/admission/follower_admission_bound.test.js
+++ b/test/unit/shared/admission/follower_admission_bound.test.js
@@ -13,12 +13,9 @@
**********************************************************************
* followerAdmissionBound: the CALL SITES of the per-chain follower bound (BF6, C38).
*
- * admissionHeight.test.js drives checkAdmitBlocks as a function. This file drives the two
- * places a live hub actually reaches it, because a bound with no call site refuses nothing:
- *
- * 1. CrossChainCallEngine.validateProposedMatch, the XCALL engine's own follower gate;
- * 2. CrossChainDexConsensus.handlePropose, the ONE proposal handler every engine on that
- * consensus shares, for any engine that declares an admissionScope.
+ * admissionHeight.test.js drives checkAdmitBlocks as a function. This file drives
+ * CrossChainCallEngine.validateProposedMatch, the XCALL engine's own follower gate.
+ * The shared consensus call site is covered in follower_admission_bound_consensus.test.js.
*
* Both must refuse in the same directions, and both must resolve the FOLLOWER'S OWN tips
* rather than trusting the map the leader sent. The refusals driven here are the ones BF6
@@ -39,6 +36,7 @@ const { expect } = require('chai');
const sinon = require('sinon');
const crypto = require('crypto');
const ValidatorIdentity = require('../../../../src/validators/identity.js');
+const armAdmission = require('../../../helpers/armAdmission');
const sha256 = (s) => crypto.createHash('sha256').update(String(s), 'utf8').digest('hex');
@@ -60,34 +58,11 @@ const ARMED_MODULES = [
'../../../../src/cross_chain/call_engine.js'
];
-// Purge, arm, re-require, and hand back a restore() that puts the process back byte-exact.
-// The objects built from the armed modules keep them by closure, so the rest of the run
-// still sees the inert tree it was written against.
-function armAdmission() {
- const paths = ARMED_MODULES.map(m => require.resolve(m));
- const saved = paths.map(p => [p, require.cache[p]]);
- const savedEnv = process.env.XC_MIRROR_ADMISSION_ACTIVATION;
- for (const p of paths) delete require.cache[p];
- process.env.XC_MIRROR_ADMISSION_ACTIVATION = String(ADMIT_AT);
-
- const ah = require('../../../../src/lib/admission_height.js');
- const CrossChainCallEngine = require('../../../../src/cross_chain/call_engine.js');
-
- function restore() {
- for (const [p, mod] of saved) {
- if (mod === undefined) delete require.cache[p]; else require.cache[p] = mod;
- }
- if (savedEnv === undefined) delete process.env.XC_MIRROR_ADMISSION_ACTIVATION;
- else process.env.XC_MIRROR_ADMISSION_ACTIVATION = savedEnv;
- }
- return { ah, CrossChainCallEngine, restore };
-}
-
// ─────────────────────────────────────────────────────────────────────────────
// 1. CrossChainCallEngine.validateProposedMatch
// ─────────────────────────────────────────────────────────────────────────────
-let armedAh, CallEngine, restore;
+let armedAh, CallEngine;
let tipCalls;
// tips: chain code -> the height this hub's own indexer reports, or null for a chain
@@ -269,12 +244,13 @@ function registerLegacyCallAdmissionTest() {
describe('follower admission bound: CrossChainCallEngine.validateProposedMatch', function () {
before(function () {
- const armed = armAdmission();
+ const armed = armAdmission(ADMIT_AT, ARMED_MODULES.map(m => require.resolve(m)), () => ({
+ ah: require('../../../../src/lib/admission_height.js'),
+ CrossChainCallEngine: require('../../../../src/cross_chain/call_engine.js')
+ }));
armedAh = armed.ah;
CallEngine = armed.CrossChainCallEngine;
- restore = armed.restore;
});
- after(function () { restore(); });
afterEach(function () { sinon.restore(); });
registerCallAdmissionAcceptTests();
diff --git a/test/unit/shared/admission/follower_admission_bound_consensus.test.js b/test/unit/shared/admission/follower_admission_bound_consensus.test.js
index c8d97efa4..16b73f91c 100644
--- a/test/unit/shared/admission/follower_admission_bound_consensus.test.js
+++ b/test/unit/shared/admission/follower_admission_bound_consensus.test.js
@@ -11,82 +11,34 @@
* contact legal@dankest.llc.
*
**********************************************************************
- * followerAdmissionBound: the CALL SITES of the per-chain follower bound (BF6, C38).
+ * followerAdmissionBound: the shared consensus call site of the per-chain follower
+ * bound (BF6, C38).
*
- * admissionHeight.test.js drives checkAdmitBlocks as a function. This file drives the two
- * places a live hub actually reaches it, because a bound with no call site refuses nothing:
- *
- * 1. CrossChainCallEngine.validateProposedMatch, the XCALL engine's own follower gate;
- * 2. CrossChainDexConsensus.handlePropose, the ONE proposal handler every engine on that
- * consensus shares, for any engine that declares an admissionScope.
+ * admissionHeight.test.js drives checkAdmitBlocks as a function. This file drives
+ * CrossChainDexConsensus.handlePropose, the one proposal handler every engine on that
+ * consensus shares, for any engine that declares an admissionScope.
*
* Both must refuse in the same directions, and both must resolve the FOLLOWER'S OWN tips
* rather than trusting the map the leader sent. The refusals driven here are the ones BF6
* names: a map that omits a reading chain, a height past the chain's own forward window, a
* height at or behind our tip, and no usable own tip at all (a dead indexer, a frozen
* decoder, or a hub with no resolver) which is a REFUSAL and never a pass.
- *
- * THE SUITE ARMS ITSELF, in the shape priceV0CanonicalAdmission.test.js established: the
- * activation resolves at module load, so the engine half purges the twin, the admission seam
- * and both classes from the require cache, sets the regtest height, re-requires, and restores
- * every entry and the variable afterwards. A default run that only drove the inert tree would
- * report the whole file as vacuous green, since below the activation there is no map to bound.
********************************************************************/
'use strict';
const { expect } = require('chai');
-const sinon = require('sinon');
const crypto = require('crypto');
const ValidatorIdentity = require('../../../../src/validators/identity.js');
+const ChainTips = require('../../../../src/hub/chain_tips.js');
const sha256 = (s) => crypto.createHash('sha256').update(String(s), 'utf8').digest('hex');
-// The regtest producer activation this suite arms. Rows below it are legacy rows in the
-// same armed process, which is how the "no map, no tip read" case below is driven.
-const ADMIT_AT = 1000;
const ERA_BLOCK = 2000;
-const LEGACY_BLK = 150;
const OWN_BTC = 900000;
const OWN_DOGE = 5000000;
-const CALL_ID = 'c'.repeat(64);
-
-const ARMED_MODULES = [
- '../../../../src/consensus/gates/mirror_admission_gate.js',
- '../../../../src/lib/admission_height.js',
- '../../../../src/cross_chain/dex_consensus.js',
- '../../../../src/cross_chain/call_engine.js'
-];
-
-// Purge, arm, re-require, and hand back a restore() that puts the process back byte-exact.
-// The objects built from the armed modules keep them by closure, so the rest of the run
-// still sees the inert tree it was written against.
-function armAdmission() {
- const paths = ARMED_MODULES.map(m => require.resolve(m));
- const saved = paths.map(p => [p, require.cache[p]]);
- const savedEnv = process.env.XC_MIRROR_ADMISSION_ACTIVATION;
- for (const p of paths) delete require.cache[p];
- process.env.XC_MIRROR_ADMISSION_ACTIVATION = String(ADMIT_AT);
-
- const ah = require('../../../../src/lib/admission_height.js');
- const CrossChainCallEngine = require('../../../../src/cross_chain/call_engine.js');
-
- function restore() {
- for (const [p, mod] of saved) {
- if (mod === undefined) delete require.cache[p]; else require.cache[p] = mod;
- }
- if (savedEnv === undefined) delete process.env.XC_MIRROR_ADMISSION_ACTIVATION;
- else process.env.XC_MIRROR_ADMISSION_ACTIVATION = savedEnv;
- }
- return { ah, CrossChainCallEngine, restore };
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// 1. CrossChainCallEngine.validateProposedMatch
-// ─────────────────────────────────────────────────────────────────────────────
-
// No arming here on purpose: the consensus never asks the era gate. It asks the ENGINE
// for a scope, which is the only thing that can know the row's table and read set, and
// then bounds whatever map the row carries. Driving it with a stub engine is what proves
@@ -112,12 +64,12 @@ const ah = require('../../../../src/lib/admission_height.js'
// A round id whose leader is the OTHER identity, so handlePropose runs for real
// instead of the follower being its own leader.
- function ridLedBy(pub) {
+ function ridLedBy(pub, excluded) {
const sorted = VALIDATORS.map(v => v.pubkey).sort();
for (let n = 0; n < 512; n++) {
const rid = sha256('round-' + n).slice(0, 64);
const mInt = parseInt(rid.slice(0, 8), 16) || 0;
- if (sorted[mInt % sorted.length] === pub) return rid;
+ if (rid !== excluded && sorted[mInt % sorted.length] === pub) return rid;
}
throw new Error('no round id in the search space is led by the requested validator');
}
@@ -145,7 +97,11 @@ const ah = require('../../../../src/lib/admission_height.js'
capSnapshot: null,
canonicalMatch: canonicalMatch,
persistCapabilitySnapshot: async () => {},
- validateProposedMatch: async () => true
+ validateProposedMatch: async () => {
+ if(opts.validationReadSet)
+ await engine.hub.resolveAdmissionTips(opts.validationReadSet);
+ return true;
+ }
};
if (opts.scope !== false) {
engine.admissionScope = opts.scope || ((row) => ({
@@ -154,18 +110,19 @@ const ah = require('../../../../src/lib/admission_height.js'
}));
}
if (opts.tips !== undefined) {
- engine.hub.resolveAdmissionTips = async (chains) => {
- let out = {};
- for (const c of chains) out[c] = Object.prototype.hasOwnProperty.call(opts.tips, c) ? opts.tips[c] : null;
- return out;
+ engine.hub.resolveAdmissionTip = async (chain) => {
+ if(opts.tipReads) opts.tipReads.push(chain);
+ return Object.prototype.hasOwnProperty.call(opts.tips, chain) ? opts.tips[chain] : null;
};
+ engine.hub.resolveAdmissionTips = ChainTips.prototype.resolveAdmissionTips;
+ engine.hub.withAdmissionTipMemo = ChainTips.prototype.withAdmissionTipMemo;
}
return new CrossChainDexConsensus(engine);
}
// Seed the round, hand the follower a validly signed leader PROPOSE, and report whether
// it took the leader's signature (which is what "signed and moved to PREPARE" means).
- async function offerPropose(consensus, row, rid) {
+ async function offerPropose(consensus, row, rid, stop) {
await consensus.propose(rid, { row, snapshot: { validators: VALIDATORS, count: 2 } });
const canonical = canonicalMatch(row);
await consensus.handlePropose({
@@ -175,11 +132,27 @@ const ah = require('../../../../src/lib/admission_height.js'
const pending = consensus.pending.get(rid);
const took = !!(pending && pending.signatures.has(LEADER_PUB));
if (pending && pending.timer) clearTimeout(pending.timer);
- await consensus.stop();
+ if(stop !== false) await consensus.stop();
return took;
}
function registerConsensusAdmissionBoundTests() {
+ it('reads each chain once inside one proposal and reads both afresh for the next proposal', async function () {
+ const reads = [];
+ const firstRid = ridLedBy(LEADER_PUB);
+ const secondRid = ridLedBy(LEADER_PUB, firstRid);
+ const c = makeFollower({
+ tips: { BTC: OWN_BTC, DOGE: OWN_DOGE },
+ validationReadSet: ['BTC', 'DOGE'],
+ tipReads: reads
+ });
+
+ expect(await offerPropose(c, matchRow(firstRid, honestColumns()), firstRid, false)).to.equal(true);
+ expect(reads).to.deep.equal(['BTC', 'DOGE']);
+ expect(await offerPropose(c, matchRow(secondRid, honestColumns()), secondRid)).to.equal(true);
+ expect(reads).to.deep.equal(['BTC', 'DOGE', 'BTC', 'DOGE']);
+ });
+
it('signs a proposal whose map holds against this follower\'s own tips', async function () {
const rid = ridLedBy(LEADER_PUB);
const c = makeFollower({ tips: { BTC: OWN_BTC, DOGE: OWN_DOGE } });
diff --git a/test/unit/validators/governance/governance_slash_value_shape.test.js b/test/unit/validators/governance/governance_slash_value_shape.test.js
new file mode 100644
index 000000000..d941dc47a
--- /dev/null
+++ b/test/unit/validators/governance/governance_slash_value_shape.test.js
@@ -0,0 +1,85 @@
+'use strict';
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+//
+// Slashing-parameter value shape: the change bounds refuse a non-decimal value, so an
+// exponent or junk-suffix form cannot skip the ratio bound and a non-finite band never
+// reaches a SlashDetector that refuses to construct on it.
+
+const sinon = require('sinon');
+const { expect } = require('chai');
+const Governance = require('../../../../src/validators/governance');
+const { resolveDeviationThreshold } = require('../../../../src/validators/slash_detector/options.js');
+const { createMockHub } = require('../../../helpers/mockHub');
+const { VALIDATORS_3 } = require('../../../helpers/fixtures');
+
+const NON_DECIMAL_BANDS = ['abc', 'NaN', 'Infinity', '-Infinity', '', '5e-1', '5E-2', '0.9abc', '0x1'];
+
+let gov;
+
+function registerLocalGateTests() {
+ it('refuses a non-decimal SLASH_DEVIATION_THRESHOLD proposal', function () {
+ for (const v of NON_DECIMAL_BANDS)
+ expect(() => gov.validateChangeBounds('SLASH_DEVIATION_THRESHOLD', '0.05', v), v).to.throw(/plain decimal/);
+ });
+
+ it('refuses an exponent or junk-suffix SLASH_MISSED_ROUNDS_THRESHOLD proposal', function () {
+ for (const v of ['3e1', '30abc'])
+ expect(() => gov.validateChangeBounds('SLASH_MISSED_ROUNDS_THRESHOLD', '30', v), v).to.throw(/plain decimal/);
+ });
+
+ it('refuses a non-finite band at the floor when called on its own', function () {
+ for (const v of ['abc', 'NaN', 'Infinity'])
+ expect(() => gov.validateSlashBandFloor('SLASH_DEVIATION_THRESHOLD', v), v).to.throw(/not a valid number/);
+ });
+
+ it('bounds the echoed value in the refusal', function () {
+ let junk = 'x'.repeat(10000);
+ expect(() => gov.validateChangeBounds('SLASH_DEVIATION_THRESHOLD', '0.05', junk))
+ .to.throw().with.property('message').that.has.length.below(300);
+ });
+
+ it('still skips a non-decimal value for a non-slashing parameter', function () {
+ expect(() => gov.validateChangeBounds('P', '0.05', '5e-1')).to.not.throw();
+ expect(() => gov.validateChangeBounds('P', 'abc', 'def')).to.not.throw();
+ });
+}
+
+function registerFollowerAndConsumerTests() {
+ it('drops an inbound exponent-form band and keeps an in-bounds plain decimal', function () {
+ expect(gov.inboundProposalBoundsHold('p1', 'SLASH_DEVIATION_THRESHOLD', '0.05', '5e-1')).to.equal(false);
+ expect(gov.inboundProposalBoundsHold('p1', 'SLASH_DEVIATION_THRESHOLD', '0.05', '0.0625')).to.equal(true);
+ });
+
+ it('accepts only bands SlashDetector also constructs on', function () {
+ for (const v of ['0.05', '0.0625', '0.06', 'abc', 'Infinity', 'NaN', '0.04', '0', '5e-1', '0.9abc']) {
+ let accepted = true;
+ try { gov.validateChangeBounds('SLASH_DEVIATION_THRESHOLD', '0.05', v); } catch (e) { accepted = false; }
+ if (accepted) expect(() => resolveDeviationThreshold({ SLASH_DEVIATION_THRESHOLD: v }), v).to.not.throw();
+ }
+ });
+}
+
+describe('Governance: slashing-parameter value shape', function () {
+ beforeEach(function () {
+ let hub = createMockHub();
+ hub._identity.getPubkeyHex.returns(VALIDATORS_3[0].pubkey);
+ gov = new Governance(hub);
+ gov.setValidatorSet(VALIDATORS_3);
+ });
+ afterEach(function () {
+ if (gov._tallyTimer) clearInterval(gov._tallyTimer);
+ sinon.restore();
+ });
+
+ describe('the local change bounds', registerLocalGateTests);
+ describe('the follower re-check and the consumer', registerFollowerAndConsumerTests);
+});