Release v0.15.0 - xchain-hub - #17
Merged
Merged
Conversation
…e and warn before it halts rounds
… presence over RPC
… the capability config
…ping it with self_sync
…d pin the floor in the dependency gate
…t stored ones through diagnostics
…gate include_secrets on its own key tier
…table Two additions, both inert until the height is armed. attest_response_mirror_activation.js is the value-identical twin of the indexer's copy and of the canonical map in the documentation repo. The null sentinel needs its explicit test: `req >= null` coerces to `req >= 0` and would arm the mirror on every block of a network nobody ratified. attestation_responses.sql is the table a finalized response is written to instead of being broadcast. It is insert-only and hub-authored, and every hub that holds the artifact writes its own row, so the id is hub-local and the consumer strips it on apply. The network column is what lets a mirror scope its cursor and purge rows a previous hub served. The mirror is transport, never authority: the consumer re-verifies the signatures against the responsible set it resolves from its own request row.
… above the height Three pieces of the response mirror, all inert until a height is armed. attest_response_canonical.js is the twin of the indexer's copy and holds the one thing both sides must agree on byte for byte. The legacy canonical is five fields concatenated with no separator, which was safe only because the free-form meta field was last and nothing followed it. Appending the effective time is exactly what that accident was protecting: concatenated bare, meta "X" with 1234 and meta "X1" with 234 produce identical bytes, so one honest quorum's signature would validate against two different effective times and the producer would get to pick which. A separator plus a canonical integer spelling requirement makes the encoding injective again. The publisher now declines a request whose response rides the mirror. The gate is a per-request early return rather than a conditional listener, because the listener is process-global; returning early skips the queue append, the broadcast, the spend reservation and the durable intent at once. The on-disk sweep is independent, so entries queued before a flag day still drain. The schema version and the shared-gate registry move with them. The registry row changes the rules digest that rides the heartbeat, so upgraded hubs will report a mismatch against un-upgraded peers for the length of a deploy wave.
A snapshot route for the bootstrap and a max_ids entry for the live stream. The route lists its columns explicitly rather than selecting everything, because a mirror bootstrapped over REST and one streamed over the websocket must deliver the same column set: a wildcard drifts the moment the hub gains a column the broadcaster does not send. The ready frame's max_ids entry is what lets a consumer run its gap catch-up for the table at all. Leaving it out fails silently, with no error and no log line, so it is asserted against a real frame rather than a mock. The count is unfiltered: unlike the cross-chain tables, nothing here is ever retracted, so a filtered maximum would understate.
Above the activation height the responsible set signs the time its answer becomes effective, so the block every indexer fires the callback at is a function of signed data rather than of whichever hub happened to write the row. Below the height the canonical is unchanged, byte for byte, and a captured literal from before this change pins that. Two things the design needed that were not obvious until it was built. The time has to ride the proposal, not only the prepare, because every responsible hub establishes its own winner independently. If each kept its own stamp, every hub would sign a canonical no peer could rebuild and an honest round that agrees on the body would still run to timeout. Each hub therefore settles on the elected leader's stamp, which it already holds. With the leader's proposal missing they fall back to their own and the round stalls and retries, which is the liveness profile a missing leader already has, and a stall rather than a divergence. The candidate key had to gain the stamp for the same reason: two corroborators that stamped different times signed different canonicals, and merging them would have carried a non-verifying signature into the winner's set and inflated the count. The follower checks the spelling before the bounds, deliberately. A time spelled with a leading zero is numerically in range, so a bounds-first order would admit it, collect an honest quorum, and produce a row whose canonical nobody can rebuild. The margin is 120 seconds, not the relay's 2400: a relayed row has to land on another chain before its effective time, and nothing here lands anywhere, so this only has to beat gossip, stream lag and clock skew. It is overridable on regtest alone, because regtest blocks are stamped at about now and the acceptance tests would otherwise be undrivable.
It was read by indexing process.env through a constant that holds that exact string, which bought nothing and hid the variable from the environment-variable coverage gate. That gate scans for literal reads, so the knob was operator-settable, documented nowhere, and visible to the gate only as the anonymous computed-read count going up. Spelled out, it is scannable and now has a row in the hub's configuration table. The constant stays for the config-table lookup and the messages, where it is a real deduplication.
…e slot Leader election is a function of the BTC height, so a tip that stops advancing pins the slot to one constant and every other hub returns from the not-my-slot branch forever with no stall recorded. Consecutive not-my-slot ticks at the same height now report as a cadence stall.
…ironment Regtest ships inert and arms from XC_ROLLCALL_REGTEST_ACTIVATION, so a single-coin venue with no DOGE peer is not wedged at its first epoch close. Mainnet and testnet stay literal and are unreachable from the environment.
…he body before anyone signs it The request:finalized listener materializes a mirror-era response into attestation_responses, selects it back for the autoincrement the consumer pages on, and broadcasts the stored row. A legacy-era round (null effective time) is refused outright rather than coerced to the epoch. The 8189-byte body cap is now enforced where a signature can be produced: the leader before proposing, and followers on both PROPOSE and PREPARE, measured on the decoded body rather than the assembled wire.
… before writing A hub that newly writes a mirror row broadcasts it once as an ATTEST_RESULT envelope. A receiving hub resolves the request from its own indexer, checks the responsible-set signatures against its own capability snapshot at the buried request height, and only then inserts and streams the row. An unknown request parks for one cycle and is retried once; duplicates are no-ops and are never re-gossiped, so five hubs converge without a storm.
The mirror table gains a nullable batch_action_index column, the one field set after insert: when the ATTEST v5 batch carrying a row lands on DOGE the hub records that batch action on the row and re-broadcasts it, and the explorer's batch link reads it downstream. The mirrored column list, the snapshot route and the insert carry it; the gossip wire deliberately does not, because the value is set hours after finalization and reaches every hub through the chain-to-hub push rather than through a peer's claim.
…d accept landed batches back An hourly window of mirrored responses is signed by the attestation set over the batch canonical, encoded with the shared wire codec (vendored byte-identical from the indexer) and published on the Dogecoin rail as a head plus continuations, with its own buffer, dead-letter path and durable markers so a restart never pays twice and an empty window still lands a zero-row head that proves coverage. A hash-order election over the batch key picks one publisher per window with rank-staggered fallback. The receive method accepts a batch the Dogecoin indexer parsed: it re-verifies the batch quorum, inserts any rows this hub lacks, records the landing batch action on each row once, and re-broadcasts so every mirror carries the link. The method is documented in the RPC spec and listed for the feed.
…y a window defers Window membership read the hub's own wall clock, which the schema declares may differ between hubs, so two hubs could partition a boundary row differently and cost the window its quorum. A skew band papered over that. The effective time is inside the signed canonical, so every hub holding a row agrees on it byte for byte, and the band is gone with it: a disagreement about membership is now always a real one. The signed field also gives the window its own completeness deadline, because a row is written a full forward margin before the window holding it can close. The publisher needs a Bitcoin chain tip for its anchor and deferred every window in silence without one. It now names the cause once per distinct failure, points at the tip push that fixes it, and says coverage is missing meanwhile. The batch rail's configuration is documented, and the doc coverage gate now scans the attestation prefixes so the next knob cannot arrive undocumented.
Closing a hub stopped none of the five attestation engines it started, so a hub closed and reopened inside one process kept every previous lifetime listening and one finalized response fanned out to all of them, each copy racing for the same spend reservation. Close now stops them all. That alone was not enough: the publisher subscribed with an anonymous closure it kept no reference to, so its own subscription was structurally unremovable and the listener count never returned to zero. It now stores the handler and detaches it on stop, the way the spot checker already did. A new guard asserts the two eras partition every finalized request: exactly one of the publisher and the mirror serves each one, so a request served by neither can no longer lose its response in silence.
…now use The snapshot routes stopped handing the payload to res.json when they took over serializing a BIGINT column themselves, and ship the finished string through res.type().send() instead. This suite's response double implements only status() and json(), so type() was undefined, the success path threw, and the route's own catch answered 500: the case asserting a valid since_id is accepted read that as the route rejecting its input. The double now carries type() and send() as well, so it covers the same slice of the express response the routes actually call. The case had been masked rather than passing. The security tier runs behind the unit tier in one && chain, so the unit failures that came with the serialization change stopped it from running at all.
RollcallRound re-emits a stored signature after a restart without re-signing, and the loader took any line for the epoch regardless of which identity wrote it. A log carrying another hub's line therefore made the restarted hub broadcast a signature no peer could verify under its pubkey, record nothing of its own, and read as absent while it believed it had signed. Both loaders now keep only records naming this hub's pubkey; spend records gain the field, and a record predating it is treated as this hub's own.
…poch A hub broadcasts its signature once, when it signs, and never again, and _onSign dropped any signature for an epoch whose round was not open yet. The hub that signed last therefore lost every earlier signer for good: elected leader, it published a partial set for the whole accept window and left the rest to sweepers and self-publishes. Early gossip is now held per epoch, unverified, and judged by the ordinary rule when the round opens; the holding area covers a few epochs ahead and drops anything at or behind an open round.
… cadence config One wave of the review round on the xchain-platform board. Every change was re-derived from the code rather than applied from the finding recommended option, and each carries a control that reproduces the original failure. Review findings: 6425 6426 6427 6428 6438 6440 6465 6466 6489 6490 6491 6515
…header
The review round corrected this shared module's header in the indexer copy
(#6494): it claimed the indexer flips the signed shape on this anchor, when the
indexer has no call site at all. The file is vendored in five repos under a
byte-identity gate that permits only the "live in xchain-{...}" line to differ,
so the correction has to land in all five or the gate reds.
Mechanical sync from the indexer copy, each repo keeping its own live-in line.
…letters EncoderClient's default 30s axios timeout had zero margin over the encoder's own upstream RPC budget, whose uncached worst case is three 30s hops, so a healthy broadcast could time out client-side and be recorded as an ambiguous send. The budget is now derived rather than guessed; the recommended 90000 was rejected because it merely equals that worst case rather than exceeding it. Also: SlashDetector silently discarded an explicit SLASH_DEVIATION_THRESHOLD=0 override (0 is falsy, so the coercion chain fell back to the 0.05 default), and the judge_equivalence_threshold governance knob was defined but consumed by nothing. One rationale comment in SlashDetector.js is corrected rather than added: a NaN deviation band does NOT disable slashing as previously written. Executed, exceedsBand returns true for everything, so a NaN band would slash the entire honest federation, which is why the finiteness guard belongs there. Review round 7 findings #6860, #6862, #6871, #6881, #6852. Also carries review round 6's hub work.
isResponseMirrorActive returned false for two conditions that mean different things. A null threshold is the deliberate unratified sentinel: the operator has not armed that network and the legacy on-chain path is correct. An absent entry is a misconfiguration, and it disables the mirror totally and silently, so every request is answered on the legacy path and every delivered mirror row is declined with no log line anywhere. Downstream that is indistinguishable from a mirror that never delivered at all. It is reachable because the two sides of the applier's join take the network string from different places, and nothing in the code forces them to agree, so whichever side is wrong the only symptom is silence. Only the absent-entry case warns, once per distinct string, because this sits on a per-request per-block path. Return values are untouched, so the era decision and every signature over it are byte-identical.
…s capability snapshot mirrored at its anchor Two defects the regtest ladder's batch drill found, neither reachable before a window carrying a real response reached the co-sign step. - Mirror row: AttestationConsensus hands the finalize list over in the order signatures ARRIVED (a Map walked in insertion order), which differs hub to hub for one quorum, and the batch co-sign compares signer_pubkeys and signatures byte for byte across hubs. Every window with a real response was refused `differs on signer_pubkeys` while all four responsible hubs held the same three signers. The row now sorts the pairs by pubkey; the pairing is kept, and the indexer verifies the pairs as a set, so nothing downstream reads the order. - Batch anchor: an off-BTC verifier judges a v5 head against capability_snapshots at exactly the batch anchor, mirrored from the hub it follows, and nobody wrote those rows: the publisher resolved the set from the BTC indexer and kept it in memory, and the relay's writer had no caller. Every v5 head on DOGE read `invalid: insufficient signer stake`. The publisher now persists the attestation set at its anchor on the leader after the election resolves and on every follower after its own capability check, the PRICE batch's contract: INSERT IGNORE on the natural key, truncated sets never mirrored, rows broadcast, once per anchor per process. Driven: a window of two responses co-signed 3 of 5, broadcast as a v5 head plus a v6 continuation, and judged valid on an indexer that mirrors the federation.
Review-round fixes, scoped by pathspec: a concurrent lane's oracle and consensus work is live in this same tree and is deliberately NOT in this commit. Money-path effectors: a failed send consumes no budget while a POSSIBLY-PAID one does, so the attestation publisher and the idempotent broadcast helper now commit on an ambiguous send instead of releasing, matching the four exemplars in this repo that already did. Roll-call reserves one guard token per CHUNK rather than once per batch, since the pair list splits at 41 and each chunk is its own transaction and its own fee, and it marks delivery per chunk so a failing second chunk cannot re-broadcast the first on every tick. Checkpoints: a same-sequence fence refuses a conflicting write instead of letting an INSERT IGNORE drop it, which also stops a phantom resync that dumped every mirror subscriber and emitted a finalized event for a row the hub does not hold. The Byzantine half is left open: proving its liveness needs a live regtest venue. The LLM path classifies on HTTP status rather than JSON body shape, so a vendor failure stops being booked as a decided judgement, and only the resolved credential reaches the spawned child. Suite: 5370 passing, 0 failing.
The tests for this landed without the implementation, so the suite has been red since: three cases in test/unit/OraclePublisher.test.js assert that a pre-send failure requeues, and nothing tagged pre-send failures. _defaultBroadcast now splits into _buildSignedTx (fetch, build, sign) and the broadcast call. Every failure in the build-and-sign stage is tagged oraclePreSend, because that stage definitively cannot have sent anything, and the dead-letter branch excludes it. Only the broadcast call keeps ambiguity classification, where a timeout or mid-flight reset really can mean the DOGE node accepted the transaction and a blind retry would spend a second fee and double-anchor the round. Without the tag the shared classifier decides: it answers "ambiguous" for any error it does not recognise, which is right for a broadcaster it knows nothing about and wrong for a stage it knows cannot send. A get_utxos timeout was therefore dead-lettered, permanently removing a never-broadcast round from automatic retry. Same convention as AttestationRelay's _relayPreSend. Verified: test/unit/OraclePublisher.test.js 102 passing, 0 failing. The control is the gate's own run of the committed tree, where exactly these three failed.
Nothing bound a hub to the body it had already signed at a given sequence, so a second request for the same (chain, network, checkpoint_seq) carrying a different canonical was signed as readily as the first. Two conflicting signatures over one sequence are equivocation, and the co-signers cannot tell which body the hub meant. The engine now remembers the canonical it signed per sequence and refuses a second signature over a different one, while a repeat of the SAME canonical still returns its signature so an ordinary retry is unaffected.
…-signed The back-fill trusted the member ids it was handed rather than the ones in the archive it had actually decompressed and co-signed, so a request could name members the signed body never contained and have them written as finalized. The sign path now records the member ids of the archive it verified (matches, calls, rewards), and the back-fill writes only those. A member outside the co-signed body is refused instead of trusted.
…success The durable at-most-once marker keyed only the request, so a first attempt that failed for a retryable reason claimed the key and the successful response that followed was discarded as a duplicate. Rather than widening the primary key, which would auto-apply a key change on already-deployed hubs, the row gains two nullable columns recording which outcomes have been sent. Identity moves INSIDE the row: the marker still admits one send per outcome, a retryable failure no longer consumes the slot a success needs, and existing rows read correctly with both columns null.
…pts writes _loadState's header states that all of its rules are fail-closed, and the corrupt-file rule beside it seeds the window CONSUMED because a broken store must never read as a green light. The absent-file rule did not follow it: it returned an empty window, which is right for a genuine first run and wrong for a store that cannot be written at all. Those two raise the same ENOENT. On a read-only disk _persist() lands no byte, so the file never appears, every restart reads ENOENT and takes a full fresh allowance, and the per-window ceiling is unbounded across restarts. An absent file now seeds CONSUMED unless the store actually accepts writes. The probe is permission-only, creating and writing nothing, so the write path keeps its single call site; it walks to the nearest existing ancestor because _persist() mkdirs the tree it needs, so a missing directory under a writable parent is still a first run. This is the half that is decidable without a policy call. Whether reserve() itself must refuse when _persist() fails is a separate liveness trade-off, since failing closed there can halt every hub effector on a read-only disk, and it is not changed here. Control: the new test is 2 passing / 1 failing against the previous guard and 3 passing / 0 failing with this one, and the two cases that must NOT change (a genuine first run, and an absent directory under a writable parent) pass both ways. All spend-guard suites together: 51 passing, 0 failing.
…cannot pin the socket _post cleared its abort timer when the response HEADERS arrived and never released res.body, so a collector that answers 200 and then stalls held the socket open with nothing bounding it. Measured against a stalling collector, the socket was still open at three seconds and the batch was counted as shipped. That is worse than a leak, because the timeout was configured and did nothing: at a 400ms ship timeout the timer cleared at 22ms, so the stall outlived its only bound and the shipper reported success. The body is now cancelled inside the same then, within the abort timer's window, rather than after finally has cleared it. The stream is cancelled and never read, and a cancel on an already-errored body is swallowed. Every existing test injected a transport and bypassed the real fetch path, which is why this survived. The new case drives _post itself. Canonical copy. The vendored consumers are regenerated from it by the sync script and are never hand-edited.
…ss as fresh _resolveBtcLatestBlock fell back to a direct getlatestblock call when no tip push was available, and took whatever height came back. The pushed-tip path is age-gated; the direct path was not, so an indexer stuck at a stale height served that height and the hub treated it as current. The direct path is now dated against the newest block_time the hub holds, and a height it dates as frozen is refused rather than served. A rejected pushed tip is kept for exactly that purpose: it is still the only block_time available, so it remains useful as a reference even when it is too old to serve. Both paths failing returns null, unchanged.
…cepts attestation_responses.response_payload and meta hold provider bytes, and the on-chain twins they stand in for are utf8mb4. These sat on the table's utf8mb3 tail, so a 4-byte character failed the mirror INSERT with errno 1366 under STRICT_TRANS_TABLES: a body the ATTEST v1 path would have carried never reached any indexer, and the request it answered expired unresolved. Both columns now declare utf8mb4. The definition edit alone reaches only fresh installs, because the drift reconciler adds a missing column and never restates an existing one, so a charset migration runs for deployments that already have the table.
…ready emits PRICE v0 ingest accepted unbounded-precision price strings that the v1 lane and every v0 producer reject, so a value no producer emits could enter through the older path and reach consumers that assume the bounded form. Ingest now applies the same scale on both lanes. Gated, because refusing a value the chain currently accepts moves consensus. Mainnet ships UNARMED on the house sentinel and naming the activation instant is a separate operator act; testnet and regtest run from genesis, so both sides of the gate are exercised and historical replay stays byte-identical. The activation is duplicated across the two services and the copies are byte-identical, so a partial landing cannot arm one side alone.
…oduces One request can finalize more than once. Every responsible hub runs its own agreement, the leader slot follows the chain tip that hub polled, and two hubs polling different tips finalize under different leaders whose signed stamps differ. Both rows carry an honest quorum over their own canonical, yet the mirror keyed rows on the request alone, so the second variant was a silent duplicate on some hubs and the first on others, and no window carrying such a request could ever reach batch quorum. Row identity is now the request plus its signed effective_time. The already- held check, the batch-link stamp, the re-broadcast read and the select-back are keyed on that triple, the startup key widen rebuilds the unique index on an existing database, and the batch co-sign compares windows on the same identity with the window read ordered by the stamp last so the signed bytes agree on every hub. The indexer binds the smaller stamp, which it already did.
A round with no deterministic price capability snapshot is now skipped and recorded as skipped, rather than sized from this hub's live validator set, which peers do not share and cannot reproduce. Sizing quorum from a local set is a consensus input that differs per hub, so the round it produced was unverifiable by design. Carried with it: - The validator normalization both consensus paths perform moves into one helper and preserves `truncated`, so a truncated snapshot stays marked as truncated through the carry instead of reading as a complete set. - The attestation finalizer re-checks its own pending entry before acting, so a second finalize for the same request cannot act on a superseded entry. - The price sources become a declarative table carrying each source's key requirement and pair coverage, in place of the per-source branching. - A fee config row that is inert against a pinned value warns once per coin/network/param instead of on every read, and the config lookup it needs is a single per-module query. - The stake gauges reset before they are set, so a removed series does not linger at its last value. Tests: new deterministic-snapshot gate and snapshot-truncation-carry suites, plus boundary coverage for the fee quote and the trimmed median, a constants conformance suite, and hub metrics coverage.
Operator ruling 2026-09-07: the point of this train is to exercise the response mirror and roll call on testnet, and a train that ships them dark there is not worth cutting. The mirror is armed at block 151324, the chain tip when the ruling was made, so it is active the moment a node updates rather than waiting on a future height. Roll call needed no change: it was already armed at 151200, which the chain passed some time ago. Mainnet stays unratified for both, so its behaviour is byte for byte unchanged. On testnet this changes state derived from existing bytes, so the changelogs now carry an Activation section saying so, and every hub and the indexers following it must update together rather than one at a time. The activation map is mirrored in five places and all five move together: both service copies, the documented canonical, the vendored copy the test helper reads, and the assertion that used testnet as its example of an unratified network, which it no longer is.
…network Two suites used testnet as their example of a network with no ratified height, which stopped being true when this train armed it. Both drove a request far above any plausible height and asserted the legacy path still ran, so arming testnet turned them red for the right reason. Mainnet is the unratified example in both files already, so the testnet cases are repointed at what they can still prove: below the activation height the legacy path owns the response. That keeps the coercion guard the originals existed for, since a null read as zero would arm every block.
jdogresorg
force-pushed
the
release/v0.15.0
branch
from
September 7, 2026 05:03
1cca544 to
674aa6d
Compare
The shared workflow pins every sibling at develop for any pull request, and this train's activation arming lives only on the release branches, so consensus_rules_digest compared an armed activation map here against an indexer still carrying the unarmed one and reported a gate disagreement that exists nowhere outside CI. The venue gate never saw it because it ships each sibling at its release branch already. The coverage ratchet re-runs the same unit suite, so it needs the same expression or it measures a suite whose cross-repo guards resolve differently from the ones the gate ran. xchain-indexer already passes both; this is the hub adopting them.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Cuts xchain-hub for the v0.15.0 release train. 58 commits from master.
Added
getattestationresponsiblesetanswers which validators a request drew.Fixed
getallconfigsredacts secret-bearing params;include_secretsneeds its own key tier.hub_urlstays on the pushed checkpoint block instead of being dropped withself_sync.