From c45a3b7dd160047785ac4ee49be7c5e4e7bb3566 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 15 Aug 2026 12:48:54 -0700 Subject: [PATCH 1/9] chore(github): add the pull request template with the CLA checkbox The contributor agreement says the template carries an agreement checkbox; no template existed in any repo, so the sentence described something that was not there. --- .github/pull_request_template.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..6241305 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,17 @@ + + +## What this changes + + + +## How it was verified + + + +## Contributor License Agreement + +- [ ] I have read and agree to the [Contributor License Agreement](https://docs.xchain.io/legal/cla). + +The CLA Assistant bot checks this automatically and records your signature +against your GitHub account, once, covering all XChain Platform repositories. +Its `license/cla` check is the record that counts; this box is a reminder. From ae52b500efefb203f1bdc00aa0330bcb9cf36a90 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 15 Aug 2026 19:54:28 -0700 Subject: [PATCH 2/9] ci: pin sibling checkouts to the branch under test With no ref, actions/checkout fetches the sibling repo's DEFAULT branch, which D7 (2026-08-14) keeps at master (released code). Develop CI was therefore comparing develop code against master siblings, which held only while master stayed leveled with develop; the LIST-memo lane diverging develop across xchain-indexer/sdk/explorer turned every cross-repo gate red (drift-guards both directions, explorer schema canary). Same idiom as the .ci-siblings SIBLINGS_REF blocks; the venue push gate already ships siblings at the pushed branch (XC-1494). XC-1519. --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f93fa40..c5dcb7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,6 +168,7 @@ jobs: uses: actions/checkout@v4 with: repository: XChain-Platform/xchain-hub + ref: ${{ github.ref == 'refs/heads/master' && 'master' || 'develop' }} ssh-key: ${{ secrets.XCHAIN_HUB_DEPLOY_KEY }} path: xchain-hub From 6f66e33ebd4dac5b0db53c1cf607464988f6306f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 15 Aug 2026 20:20:41 -0700 Subject: [PATCH 3/9] ci: give the push gate the full GitHub job set (bin/ci-full.sh) The pre-push venue gate ran one of the several jobs ci.yml fans out on GitHub, so a push could gate green locally and go red upstream on a job the gate never ran (2026-08-15: that happened on three repos at once). bin/ci-full.sh transcribes every push-triggered job's run-steps in job order, fails loud on a missing sibling or a missing runtime rather than skipping, and reports every red tier the way GitHub reports every red job. When ci.yml gains or changes a job, ci-full.sh changes in the same commit. XC-1520. --- .ci-timeout | 1 + bin/ci-full.sh | 156 +++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 +- 3 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 .ci-timeout create mode 100755 bin/ci-full.sh diff --git a/.ci-timeout b/.ci-timeout new file mode 100644 index 0000000..4dc663e --- /dev/null +++ b/.ci-timeout @@ -0,0 +1 @@ +3600 diff --git a/bin/ci-full.sh b/bin/ci-full.sh new file mode 100755 index 0000000..68b0426 --- /dev/null +++ b/bin/ci-full.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +#********************************************************************* +# +# 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. +# +#********************************************************************* + +# +# bin/ci-full.sh: run EVERY tier this repo's GitHub CI runs, in one process. +# +# .github/workflows/ci.yml fans this repo out as four jobs (ci, e2e, +# drift-guards, coverage). The pre-push venue gate used to run only +# `npm run ci`, so a push could gate green locally and then go red on GitHub +# on a job the gate never ran (2026-08-15: exactly that, on three repos at +# once). This script IS the local twin of the workflow: every job's run +# steps, transcribed, in job order. When ci.yml gains or changes a job, +# change this script in the same commit. +# +# Layout: siblings resolve at ../, which is both the platform monorepo +# layout and the venue gate's work/ layout (.ci-siblings ships them there). A +# sibling a GitHub job checks out is REQUIRED here: missing means fail loud, +# never skip, because GitHub will run the step this gate would be skipping. +# +# The e2e job spins up its OWN throwaway MariaDB pair (source-db + replica-db, +# test/e2e/docker-compose.e2e.yml) rather than a venue-provided database, +# matching the two GitHub Actions service containers exactly (same image, +# ports, and fixture credentials). It needs Docker; a docker-less venue fails +# this script loud rather than silently skip the tier GitHub actually runs. +# +# SKIPPED-BY-DESIGN: none. Every real test/build step ci.yml runs is +# transcribed below (checkout/setup-node/npm-ci/cache steps are GitHub-only +# bookkeeping and need no transcription). +# +# All tiers run even after one fails (GitHub reports every red job, so this +# reports every red tier); the exit code is red if any tier was. +# +set -uo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." +SELF="$(pwd)" +SIB="$(cd .. && pwd)" + +FAILED="" +run_tier() { + local name="$1"; shift + echo; echo "ci:full ===== $name =====" + if "$@"; then + echo "ci:full ----- $name PASS" + else + FAILED="$FAILED [$name]" + echo "ci:full ----- $name FAIL" + fi +} +need_sib() { + local s + for s in "$@"; do + if [ ! -d "$SIB/$s" ]; then + echo "ci:full: MISSING SIBLING $SIB/$s" >&2 + echo "ci:full: GitHub CI checks this sibling out and runs steps against it," >&2 + echo "ci:full: so skipping here would gate green on a subset. Declare it in" >&2 + echo "ci:full: .ci-siblings (venue) or clone it beside this repo (hand run)." >&2 + exit 1 + fi + done +} + +export XCHAIN_INDEXER_SQL_PATH="${XCHAIN_INDEXER_SQL_PATH:-$SIB/xchain-indexer/src/sql}" +export XCHAIN_DECODER_SQL_PATH="${XCHAIN_DECODER_SQL_PATH:-$SIB/xchain-decoder/src/sql}" + +need_sib xchain-indexer xchain-decoder xchain-hub + +# The e2e job (below) needs Docker for its two service containers; guard once, +# up front, so a docker-less venue fails loud instead of every DB-backed tier +# failing separately with a confusing connection-refused error. +docker info >/dev/null 2>&1 || { + echo "ci:full: VENUE LACKS DOCKER for e2e job (source-db/replica-db service" >&2 + echo "ci:full: containers, e2e tier, integration tier); pin a docker venue" >&2 + echo "ci:full: with CI_VENUES=..." >&2 + exit 1 +} + +# --- job: ci (XChain-Platform/.github ci-reusable.yml -> npm run ci) ------- +run_tier "ci" npm run ci + +# --- job: e2e ---------------------------------------------------------------- +# GitHub stands up source-db (:23306) and replica-db (:23307) as service +# containers before any step runs; test/e2e/docker-compose.e2e.yml is the +# same pair (same image, ports, MARIADB_USER/PASSWORD), and every e2e/testDb +# helper already defaults to those ports and credentials, so no env override +# is needed once the stack is up. +E2E_COMPOSE="test/e2e/docker-compose.e2e.yml" +e2e_compose_down() { + docker compose -f "$E2E_COMPOSE" down -v >/dev/null 2>&1 +} +trap e2e_compose_down EXIT +run_tier "e2e: bring up service containers (source-db, replica-db)" \ + docker compose -f "$E2E_COMPOSE" up -d --wait + +# Cross-repo consensus drift guards (rollback-coverage and friends) live in +# the unit tier but the shared `ci` job never checks out a sibling, so they +# silently skip there. Run them HERE, where xchain-indexer and xchain-decoder +# ARE checked out, with XCHAIN_REQUIRE_SIBLINGS=1 so a missing sibling +# hard-fails instead of green-by-skip. Pure source comparisons (no DB). +run_tier "e2e: cross-repo consensus drift guards" \ + env XCHAIN_REQUIRE_SIBLINGS=1 \ + npx mocha --timeout 10000 \ + test/unit/rollback-coverage.test.js \ + test/unit/blockhash-conformance-twin.test.js \ + test/unit/protocolAddressRoles.twin.test.js \ + test/unit/stakesValidatorSetParity.test.js \ + test/unit/generatedColumns.test.js + +run_tier "e2e: e2e tier (test:e2e:ci)" npm run test:e2e:ci + +# Independent of the e2e tier above (own DBs, own schema seed); run even if +# the e2e tier failed, so a flake there can't mask the integration result. +# Reuses source-db (:23306) with the admin credentials, not the e2e +# xchain-node user, matching the workflow step exactly. +run_tier "e2e: integration tier (green suites, test:integration:ci)" \ + env TEST_DB_HOST=127.0.0.1 TEST_DB_PORT=23306 TEST_DB_USER=root TEST_DB_PASS=test \ + npm run test:integration:ci + +run_tier "e2e: tear down service containers" e2e_compose_down +trap - EXIT + +# --- job: drift-guards ------------------------------------------------------- +# Run FROM the parent so sync-coins.sh sees the canonical + vendored pair the +# way the workflow lays them out (hub checkout beside this repo's checkout). +sync_coins_check() { (cd "$SIB" && "xchain-hub/bin/sync-coins.sh" --check --only "$(basename "$SELF")"); } +run_tier "drift-guards: coin-registry byte-identity" sync_coins_check +run_tier "drift-guards: coin consensus-pin conformance" node -e ' + const coins = require("./src/coins"); + for (const net of ["testnet", "regtest"]) { + const res = coins.verifyConsensusPin(net); + if (res && res.skipped) throw new Error("consensus pin unexpectedly unarmed for " + net); + } + console.log("consensus pin conformance OK (testnet, regtest)"); +' + +# --- job: coverage ----------------------------------------------------------- +run_tier "coverage ratchet (coverage:check)" npm run coverage:check + +echo +if [ -n "$FAILED" ]; then + echo "ci:full: RED tiers:$FAILED" + exit 1 +fi +echo "ci:full: all tiers green (same set GitHub CI runs)" diff --git a/package.json b/package.json index 8c4534f..fb8f196 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,8 @@ "test:mutate": "npx stryker run test/mutation/stryker.config.json", "test:mutate:quick": "npx stryker run test/mutation/stryker.quick.config.json", "test:mutate:check": "npx stryker run test/mutation/stryker.config.json --incremental", - "test:boundary": "mocha --timeout 5000 --recursive 'test/boundary/**/*.boundary.test.js'" + "test:boundary": "mocha --timeout 5000 --recursive 'test/boundary/**/*.boundary.test.js'", + "ci:full": "bash bin/ci-full.sh" }, "overrides": { "brace-expansion": "^5.0.9", From faf1391c3a829358f61674566d6ed055d089dd80 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 15 Aug 2026 20:21:28 -0700 Subject: [PATCH 4/9] test(quality): wait on the condition, not the clock, in 7 suites [XC-1521] The AML gap board flagged 16 files here for the sleep-flake rubric check. Seven carried a real fixed settle and are converted onto the repo's existing waitFor helper, waiting on the post-condition the following assertions read: a socket reaching OPEN before a single-shot broadcast that a still-handshaking subscriber would miss permanently, and replica row counts after a poll. The other nine are left alone deliberately and are not defects: a sleep whose following assertion says nothing happened is the test itself, and converting it to a condition poll returns immediately and proves nothing. server-websocket's isolation case now waits for both frames bitcoin is owed rather than guessing 200ms, which is a strictly stronger window for the litecoin-got-nothing assertion that follows it. Unit suite green at 1748 passing; the e2e and integration suites need a live stack this venue has not got, so their behaviour is unverified here. --- test/e2e/api.test.js | 8 ++++++-- test/e2e/decoder-lifecycle.test.js | 7 +++++-- test/e2e/multi-chain.test.js | 11 ++++++++--- test/integration/client-live-sync.test.js | 7 +++++-- test/integration/lifecycle.test.js | 4 +++- test/integration/server-websocket.test.js | 7 ++++--- test/perf/scenarios/08-bootstrap-stampede.test.js | 14 +++++++++++--- 7 files changed, 42 insertions(+), 16 deletions(-) diff --git a/test/e2e/api.test.js b/test/e2e/api.test.js index 341f189..9fee567 100644 --- a/test/e2e/api.test.js +++ b/test/e2e/api.test.js @@ -127,7 +127,9 @@ describe('E2E: API Correctness', function() { messages.push(JSON.parse(data.toString())); }); - await new Promise(r => setTimeout(r, 1000)); + // Poll the socket to OPEN. Each block is broadcast exactly once, so a + // subscription still handshaking when the poll fires misses the events. + await waitFor(() => ws.readyState === WebSocket.OPEN, 10000); await fixtures.seedBlocks(sourceDb, 6, 10); @@ -178,7 +180,9 @@ describe('E2E: API Correctness', function() { ws.on('message', (data) => { messages.push(JSON.parse(data.toString())); }); - await new Promise(r => setTimeout(r, 1000)); + // Poll the socket to OPEN. The reorg is broadcast exactly once, so a + // subscription still handshaking when the poll fires misses it. + await waitFor(() => ws.readyState === WebSocket.OPEN, 10000); await fixtures.deleteBlocksFrom(sourceDb, 8); await server.poll(); diff --git a/test/e2e/decoder-lifecycle.test.js b/test/e2e/decoder-lifecycle.test.js index 43c3b3f..be7b351 100644 --- a/test/e2e/decoder-lifecycle.test.js +++ b/test/e2e/decoder-lifecycle.test.js @@ -434,7 +434,9 @@ describe('E2E: Decoder DB Lifecycle', function() { client = makeClient(); await client.bootstrap(); await client.connectLive(); - await new Promise(r => setTimeout(r, 500)); + // Poll the client's socket to OPEN before seeding: blocks broadcast + // while the handshake is still in flight are never re-sent. + await waitFor(() => client.sync.wsConns[0] && client.sync.wsConns[0].readyState === WebSocket.OPEN, 10000); await decoderFixtures.seedDecoderBlocks(sourceDb, 6, 8); @@ -464,7 +466,8 @@ describe('E2E: Decoder DB Lifecycle', function() { ws.on('message', (data) => { try { messages.push(JSON.parse(data.toString())); } catch(e){} }); - await new Promise(r => setTimeout(r, 500)); + // Poll the socket to OPEN: block 3 is broadcast exactly once. + await waitFor(() => ws.readyState === WebSocket.OPEN, 10000); await decoderFixtures.seedDecoderBlocks(sourceDb, 3, 3); diff --git a/test/e2e/multi-chain.test.js b/test/e2e/multi-chain.test.js index 360ba35..6588863 100644 --- a/test/e2e/multi-chain.test.js +++ b/test/e2e/multi-chain.test.js @@ -178,13 +178,16 @@ describe('E2E: Multi-Chain Synchronization', function() { ltcMessages.push(JSON.parse(data.toString())); }); - await new Promise(r => setTimeout(r, 1000)); + // Poll both sockets to OPEN: the poll below broadcasts each block once, + // so a subscription still handshaking misses the events outright. + await waitFor(() => btcWs.readyState === WebSocket.OPEN && ltcWs.readyState === WebSocket.OPEN, 10000); await fixtures.seedBlocks(sourceDb, 6, 8); btcPoller.lastPolledBlock = 5; await btcPoller._poll(); - await new Promise(r => setTimeout(r, 1000)); + // Wait on the delivery this test is about, not on a fixed window. + await waitFor(() => btcMessages.filter(m => m.type === 'block').length >= 3, 10000); let btcBlockEvents = btcMessages.filter(m => m.type === 'block'); assert.ok(btcBlockEvents.length >= 3, 'Bitcoin should have received 3 block events, got ' + btcBlockEvents.length); @@ -212,7 +215,9 @@ describe('E2E: Multi-Chain Synchronization', function() { ltcMessages.push(JSON.parse(data.toString())); }); - await new Promise(r => setTimeout(r, 500)); + // Poll the socket to OPEN, so the isolation check below cannot pass + // merely because litecoin was not subscribed yet. + await waitFor(() => ltcWs.readyState === WebSocket.OPEN, 10000); await fixtures.deleteBlocksFrom(sourceDb, 8); btcPoller.lastPolledBlock = 10; diff --git a/test/integration/client-live-sync.test.js b/test/integration/client-live-sync.test.js index 003d38d..6c68297 100644 --- a/test/integration/client-live-sync.test.js +++ b/test/integration/client-live-sync.test.js @@ -143,7 +143,9 @@ describe('Integration: Client Live Sync', function() { cs.lastHashes = await replicaDb.getBlockHashRow(5); cs._connectWebSockets(); - await new Promise(r => setTimeout(r, 500)); + // Poll the socket to OPEN: the poll below broadcasts block 6 once, so a + // subscription still handshaking would never see it. + await waitFor(() => cs.wsConns[0] && cs.wsConns[0].readyState === WebSocket.OPEN); await fixtures.seedBlocks(sourceDb, 6, 6); poller.lastPolledBlock = 5; @@ -181,7 +183,8 @@ describe('Integration: Client Live Sync', function() { cs.lastAppliedBlock = 5; cs.lastHashes = await replicaDb.getBlockHashRow(5); cs._connectWebSockets(); - await new Promise(r => setTimeout(r, 500)); + // Poll the socket to OPEN: blocks 6-10 are broadcast once each. + await waitFor(() => cs.wsConns[0] && cs.wsConns[0].readyState === WebSocket.OPEN); await fixtures.seedBlocks(sourceDb, 6, 10); poller.lastPolledBlock = 5; diff --git a/test/integration/lifecycle.test.js b/test/integration/lifecycle.test.js index 3938fcd..d7ed2f1 100644 --- a/test/integration/lifecycle.test.js +++ b/test/integration/lifecycle.test.js @@ -145,7 +145,9 @@ describe('Integration: Full Lifecycle', function() { assert.strictEqual(replicaBlocks, 10); cs._connectWebSockets(); - await new Promise(r => setTimeout(r, 500)); + // Poll the socket to OPEN: blocks 11-15 are broadcast once each, so a + // subscription still handshaking would never see them. + await waitFor(() => cs.wsConns[0] && cs.wsConns[0].readyState === WebSocket.OPEN); await fixtures.seedBlocks(sourceDb, 11, 15); poller.lastPolledBlock = 10; diff --git a/test/integration/server-websocket.test.js b/test/integration/server-websocket.test.js index 51e2c0f..acc026e 100644 --- a/test/integration/server-websocket.test.js +++ b/test/integration/server-websocket.test.js @@ -199,9 +199,10 @@ describe('Integration: WebSocket Broadcasting', function() { // Broadcast to bitcoin only broadcaster.broadcast('bitcoin', 'mainnet', { type: 'block', block_index: 99 }); - await waitForMessages(btcConn.messages, 1); - // btc got initial status; filter for block - await new Promise(resolve => setTimeout(resolve, 200)); + // btc gets two frames: the initial status on connect plus the block + // broadcast above. Waiting for both is the real post-condition; a + // fixed settle here just guesses at delivery latency. + await waitForMessages(btcConn.messages, 2); let btcBlock = btcConn.messages.find(m => m.type === 'block'); assert.ok(btcBlock); diff --git a/test/perf/scenarios/08-bootstrap-stampede.test.js b/test/perf/scenarios/08-bootstrap-stampede.test.js index f3d54e6..00452c9 100644 --- a/test/perf/scenarios/08-bootstrap-stampede.test.js +++ b/test/perf/scenarios/08-bootstrap-stampede.test.js @@ -51,6 +51,7 @@ const { bootEnvironment, teardownEnvironment, resetAll, const ReportGenerator = require('../setup/report-generator'); const SnapshotBuilder = require('../../../src/SnapshotBuilder'); const poolSizing = require('../../../src/poolSizing'); +const { waitFor } = require('../../e2e/helpers/waitFor'); // Explicit agent: the whole test rests on N requests being in flight AT ONCE. // Whatever the ambient default maxSockets is, this pins it above the largest @@ -249,9 +250,16 @@ describe('08 Bootstrap Stampede (N-concurrent-bootstrap load)', function () { await Promise.all([...stampede, liveLoop]); const stampedeMs = Date.now() - startedAt; - // Give the broadcaster a moment to flush the last frames before judging - // what the follower missed. - await new Promise(r => setTimeout(r, 1500)); + // Drain the frames the follower is still owed, bounded, instead of guessing + // a flush window. A frame that arrives late is then judged by the + // broadcast-gap budget below rather than silently counted as a miss; on + // timeout the completeness assertion reports exactly what never arrived. + try { + await waitFor(() => { + const seen = new Set(received.map(r => r.blockIndex)); + return liveBlocks.every(h => seen.has(h)); + }, 15000, 100); + } catch (e) { /* reported by the completeness assertion below */ } try { follower.close(); } catch (e) { /* already closed */ } const accepted = snapshotResults.filter(r => r.status === 200); From 3bda3acaa79fa364d5ef2a16a5d8ed2f2516957c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 16 Aug 2026 09:25:30 -0700 Subject: [PATCH 5/9] fix(sync): AML review round findings (3 files) Findings adjudicated in the 2026-08-16 xchain-platform review round. Every claim was re-checked against current code and every remedy was re-derived rather than applied from the finding's recommended option; each verdict then passed an adversarial verify stage before booking. Findings: #5073 tableLifecycle.js state_tree_nodes note is stale: the "awaited" mark-and-sweep pruner already shipped in retention.js #5101 sync_meta content-parity digest hashes node-local id/logged_at columns, guaranteeing a false TABLE_CONTENT_PARITY mismatch Report: claude/reports/2026-08-16_review-round-xchain-platform.md --- src/sql/state_tree_nodes.sql | 8 +++++--- src/tableLifecycle.js | 13 +++++++++++-- test/unit/tableContentParity.test.js | 15 +++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/sql/state_tree_nodes.sql b/src/sql/state_tree_nodes.sql index 5f2768c..9907ac9 100644 --- a/src/sql/state_tree_nodes.sql +++ b/src/sql/state_tree_nodes.sql @@ -18,14 +18,16 @@ CREATE TABLE state_tree_nodes ( left_hash CHAR(64) NOT NULL, -- the two child hashes (a child may be an EMPTY constant) right_hash CHAR(64) NOT NULL, UNIQUE KEY uq_node_hash (node_hash), - KEY idx_left_hash (left_hash), -- reachability sweep for the (deferred) pruner + KEY idx_left_hash (left_hash), -- reachability sweep for the opt-in pruner KEY idx_right_hash (right_hash) -- Content-addressed, copy-on-write SMT node store for the light-client state -- commitment (SPV spec §4.1/§4.3). Holds INTERNAL nodes only (depth 0..255); -- a value leaf (depth 256) is never its own row, it lives as a child hash of -- its depth-255 parent. Empty subtrees (the EMPTY[h] constants) are never -- stored. Append-only during forward processing (INSERT IGNORE; identical - -- subtrees dedupe by hash). Reorg leaves orphaned nodes here that a later - -- mark-and-sweep prunes; rollback only drops the state_tree_roots pointers. + -- subtrees dedupe by hash). Reorg leaves orphaned nodes here that the opt-in + -- mark-and-sweep pruner in the indexer's retention.js reclaims, gated by + -- STATE_ROOT_RETENTION_BLOCKS plus STATE_NODE_RECLAIM; rollback only drops + -- the state_tree_roots pointers. -- Never written by hub_db_sync. ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; diff --git a/src/tableLifecycle.js b/src/tableLifecycle.js index 0b5d6d4..5438460 100644 --- a/src/tableLifecycle.js +++ b/src/tableLifecycle.js @@ -365,7 +365,7 @@ const TABLES = [ note: 'Hub-mirrored, append-only, never retracted: written only after the XANCPUB quorum resolves for a FINALIZED checkpoint, so there is no un-finalize to retract. The derived validator_rewards row (block_index = snapshot_block) rolls back normally as a dataTable and re-derives idempotently on replay; a DOGE reorg cannot un-quorum an already-attested publish.' }, { table: 'state_tree_nodes', owner: 'indexer', replication: 'snapshot', rollback: 'exempt', replicaRollback: 'exempt', hashed: { classes: ['state_commitment'], note: 'Content-addressed SMT node store; nodes are keyed by their own hash.' }, - note: 'Copy-on-write: a node surviving a reorg is harmless (re-apply INSERT-IGNOREs the same hashes) and the surviving fork-point root in state_tree_roots anchors the correct tree. Orphans await a mark-and-sweep pruner; per-block deletion is impossible (no block_index, nodes shared across blocks).' }, + note: 'Copy-on-write: a node surviving a reorg is harmless (re-apply INSERT-IGNOREs the same hashes) and the surviving fork-point root in state_tree_roots anchors the correct tree. Orphans are reclaimed by the indexer\'s opt-in mark-and-sweep pruner (retention.js computeReachable/reclaimOrphanNodes, off unless STATE_ROOT_RETENTION_BLOCKS is positive AND STATE_NODE_RECLAIM is set, and serialized against block processing via runExclusive so a forward insert cannot re-reference a node between the mark and the delete); per-block deletion is impossible (no block_index, nodes shared across blocks).' }, // ── Inert append-only lookups ────────────────────────────────────── // Id-keyed dedup lookups. Orphaned rows are harmless because block @@ -478,10 +478,19 @@ const CONTENT_PARITY_CARVE_OUTS = Object.freeze([ // the applier strips before insert (localSurrogateIdTables), so the two sides // legitimately disagree on it, and `contract_state.state_key_bin` is a // database-GENERATED column the applier never names (generatedColumns.js). -// Hashing either would turn a by-design difference into a permanent alarm. +// `sync_meta.id` and `sync_meta.logged_at` are the same class: ServerPoller +// builds the streamed sync_meta row by hand from the block hashes and omits +// both, so the follower auto-assigns its own id and stamps its own insert +// wall-clock, and the two id counters drift further apart after any reorg +// because both sides delete and re-insert while InnoDB never reuses ids. The +// replicated columns block_index/block_time/ledger_hash/actions_hash/ +// contract_hash stay in the preimage, so parity still covers every column the +// two sides must agree on. +// Hashing any of these would turn a by-design difference into a permanent alarm. const CONTENT_PARITY_EXCLUDED_COLUMNS = Object.freeze({ blocks: Object.freeze(['id']), contract_state: Object.freeze(['state_key_bin']), + sync_meta: Object.freeze(['id', 'logged_at']), }); // ── Derivation helpers ────────────────────────────────────────────────── diff --git a/test/unit/tableContentParity.test.js b/test/unit/tableContentParity.test.js index 006e633..99b73e3 100644 --- a/test/unit/tableContentParity.test.js +++ b/test/unit/tableContentParity.test.js @@ -225,6 +225,21 @@ describe('Advisory table-content parity', function(){ hasherFor({}).contentDigest('blocks', [{ id: 41, block_index: 100, block_hash: 'bb' }])); }); + it('the node-local sync_meta.id/logged_at columns are excluded, so they cannot false-alarm', function(){ + // ServerPoller builds the streamed sync_meta row by hand from the block + // hashes and omits id/logged_at, so the follower auto-assigns its own id + // and stamps its own insert wall-clock on EVERY live-applied block. Left + // in the preimage those two columns guarantee a mismatch at equal counts. + let source = [{ id: 7, block_index: 100, block_time: 1700, ledger_hash: 'aa', actions_hash: 'bb', contract_hash: 'cc', logged_at: '2026-08-16T00:00:00Z' }]; + let replica = [{ id: 41, block_index: 100, block_time: 1700, ledger_hash: 'aa', actions_hash: 'bb', contract_hash: 'cc', logged_at: '2026-08-16T09:31:02Z' }]; + assert.strictEqual(hasherFor({}).contentDigest('sync_meta', source), + hasherFor({}).contentDigest('sync_meta', replica)); + // ...but the replicated hash columns still have to match. + let forged = [Object.assign({}, replica[0], { ledger_hash: 'zz' })]; + assert.notStrictEqual(hasherFor({}).contentDigest('sync_meta', source), + hasherFor({}).contentDigest('sync_meta', forged)); + }); + it('the generated contract_state.state_key_bin column is excluded', function(){ // The applier never names a generated column; the database computes it. let a = [{ block_index: 5, state_key: 'k', state_key_bin: 'k', value: '1' }]; From 0e8b8a2ec6e516e942f11a998e7d9490cec44066 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 17 Aug 2026 09:42:18 -0700 Subject: [PATCH 6/9] ci: use the shared checkout-siblings composite action Replaces the inline sibling-checkout script with the composite action published in the organization .github repo, so one definition serves every call site instead of ten copies that had drifted apart. --- .github/workflows/ci.yml | 41 ++-------------------------------------- 1 file changed, 2 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5dcb7e..231c651 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,27 +74,7 @@ jobs: # deploy keys this used to need are gone. Roster = .ci-siblings, the same # declaration the e2e venue reads. - name: Check out declared sibling repositories - env: - SIBLINGS_REF: ${{ github.ref == 'refs/heads/master' && 'master' || 'develop' }} - run: | - set -euo pipefail - repos=$(sed 's/#.*//' .ci-siblings | tr -d '\r' | awk 'NF') - cd "$GITHUB_WORKSPACE/.." - for repo in $repos; do - rm -rf "$repo" - url="https://github.com/${{ github.repository_owner }}/$repo.git" - git clone --quiet --depth 1 --branch "$SIBLINGS_REF" "$url" "$repo" 2>/dev/null \ - || git clone --quiet --depth 1 "$url" "$repo" - echo "sibling $repo @ $(git -C "$repo" rev-parse --abbrev-ref HEAD) $(git -C "$repo" rev-parse --short HEAD)" - # Two guards require the indexer's modules, not just its files, so a - # source-only checkout fails on the sibling's own dependencies - # (Cannot find module 'mathjs'). Runtime deps only, never fatal. - if [ -f "$repo/package.json" ]; then - ( cd "$repo" && npm ci --omit=dev --ignore-scripts --no-audit --no-fund >/dev/null 2>&1 ) \ - || ( cd "$repo" && npm install --omit=dev --ignore-scripts --no-audit --no-fund >/dev/null 2>&1 ) \ - || echo "sibling $repo: dependency install failed, guards needing its modules will say so" - fi - done + uses: XChain-Platform/.github/actions/checkout-siblings@master # one definition for every call site; see the action for why this tracks master - name: Use Node.js 22 uses: actions/setup-node@v4 @@ -210,24 +190,7 @@ jobs: # ratchet measures a suite the gate never ran: 1668 of 1745 unit tests ran without them. # Same roster (.ci-siblings) and same layout the shared workflow uses. - name: Check out declared sibling repositories - env: - SIBLINGS_REF: ${{ github.ref == 'refs/heads/master' && 'master' || 'develop' }} - run: | - set -euo pipefail - repos=$(sed 's/#.*//' .ci-siblings | tr -d '\r' | awk 'NF') - cd "$GITHUB_WORKSPACE/.." - for repo in $repos; do - rm -rf "$repo" - url="https://github.com/${{ github.repository_owner }}/$repo.git" - git clone --quiet --depth 1 --branch "$SIBLINGS_REF" "$url" "$repo" 2>/dev/null \ - || git clone --quiet --depth 1 "$url" "$repo" - echo "sibling $repo @ $(git -C "$repo" rev-parse --abbrev-ref HEAD) $(git -C "$repo" rev-parse --short HEAD)" - if [ -f "$repo/package.json" ]; then - ( cd "$repo" && npm ci --omit=dev --ignore-scripts --no-audit --no-fund >/dev/null 2>&1 ) \ - || ( cd "$repo" && npm install --omit=dev --ignore-scripts --no-audit --no-fund >/dev/null 2>&1 ) \ - || echo "sibling $repo: dependency install failed" - fi - done + uses: XChain-Platform/.github/actions/checkout-siblings@master # one definition for every call site; see the action for why this tracks master - name: Use Node.js 22 uses: actions/setup-node@v4 From f2056a337bd6128254451f1da8e7a3efffffd4fa Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 17 Aug 2026 12:38:52 -0700 Subject: [PATCH 7/9] fix(sync): key the invalid_archive replication class on the populated chunk height [XC-1509] --- src/stateHash.js | 80 +++++++++++++++++++++++++++-- src/updatedRows.js | 19 +++++-- test/unit/rollback-coverage.test.js | 12 ++++- 3 files changed, 101 insertions(+), 10 deletions(-) diff --git a/src/stateHash.js b/src/stateHash.js index 1bbd60d..fd99546 100644 --- a/src/stateHash.js +++ b/src/stateHash.js @@ -29,7 +29,8 @@ * - v0 request_status flips (attests/xcalls) * - cooldown-maturity status flips (unstakes/contract_unstakes) * - backdated cooldown refund credits (capability GAS + contract own-tick) - * - invalid_archive stamp on anchor_actions v1 parent rows (CRC-failed chunked batches) + * - invalid_archive stamp on anchor_actions archive-head parent rows (CRC-failed + * chunked batches; version set and completing-chunk height key both flag-day gated) * - VOTE poll finalization flips on surviving polls rows (flag-day gated per chain) * - tokens.supply refreshes on surviving token rows (flag-day gated per chain; the * hash twin of the updated_rows tokens-supply replication class) @@ -293,6 +294,69 @@ function isArchiveInvalidStateHashActive(blockIndex, network, coin){ return b >= threshold; } +// ── invalid_archive chunk-height key repair state-hash flag-day ─────────────── +// Class 6 scopes the invalid_archive stamp to the block the COMPLETING v2 chunk +// landed in. It has always keyed that scope on `c.block_index`, and that column +// is NEVER populated on a v2 row: `block_index` carries BLOCK_INDEX_CHECKPOINTED +// (the checkpointed height on the OTHER chain, see anchor_actions.sql), which is +// assigned only in anchor.js `_parseCheckpoint`; `_parseContinuation` never sets +// it, and db.js binds the column NULL when the key is absent. `NULL BETWEEN x AND +// y` is never true, so the class has selected ZERO rows on every node since it +// landed, on every network. The completing chunk's real height is +// `block_index_doge` (the DOGE block the ANCHOR action landed in, NOT NULL by +// schema), the same height the class is being scoped to, and the same distinction +// anchor.js `_archiveAuthorScope` already draws. +// +// Repairing the key CHANGES THE PREIMAGE the moment a stamped batch exists: a +// node on the repaired predicate hashes the parent row, a node on the broken one +// hashes nothing, and the fleet halts at that block. So the repair is a flag day +// like every other class-shape change here, gated per chain on the chain's OWN +// local block_index, DEFAULT INERT: below the threshold the query keeps the +// broken `c.block_index` key and the preimage stays byte-identical to what every +// deployed node computes today. +// +// Every mainnet AND testnet key is an INERT placeholder on purpose. This repair +// and the head-side archive reassembly gate in actions/anchor.js are both +// preimage-moving and ride ONE flag-day train, so neither height is chosen alone; +// pin them together at ratification. Deploy order at that ratification is not +// free either: xchain-sync updatedRows.js carries the SAME broken key on the +// replication side (fixed there un-gated, since shipping a row is not a preimage) +// and must be live FIRST, or a follower is asked to hash a stamped parent row it +// was never sent. Testnet is NOT armed at genesis the way the v6-coverage gate +// above was: that ruling was made one day after the testnet re-genesis, and +// testnet has run for a week since, so a height of 0 here would be retroactive +// rather than a flag day. regtest is armed at 0 so fresh regtest stacks exercise +// the repaired class end to end. No STATE_HASH_VERSION bump: a block is +// unambiguously pre- or post-activation. Keep byte-identical to the +// xchain-sync twin. +const ARCHIVE_INVALID_HEIGHT_KEY_ACTIVATION = { + 'BTC:mainnet': 999999999, // INERT placeholder; pinned with the sibling call at ratification + 'LTC:mainnet': 999999999, // INERT placeholder + 'DOGE:mainnet': 999999999, // INERT placeholder (DOGE is the anchor chain; arm first here) + 'BTC:testnet': 999999999, // INERT placeholder + 'LTC:testnet': 999999999, // INERT placeholder + 'DOGE:testnet': 999999999, // INERT placeholder + regtest: 0, // armed from genesis: fresh regtest stacks exercise the repaired class end to end +}; + +// The column class 6 scopes the completing v2 chunk by, as a SQL fragment. Broken +// legacy key below the flag day, repaired key at/after it. Exported so the twin +// repos and the drift guards can assert on ONE definition rather than a literal. +const ARCHIVE_CHUNK_HEIGHT_COL = 'c.block_index_doge'; +const ARCHIVE_CHUNK_HEIGHT_COL_LEGACY = 'c.block_index'; + +// Whether class 6 scopes the completing v2 chunk by the repaired +// `block_index_doge` key at `blockIndex` on `network` for `coin`. Below the +// threshold / unknown network -> off (safe; the class keeps the legacy +// `block_index` key, so the preimage is byte-identical to the pre-repair shape). +function isArchiveInvalidHeightKeyActive(blockIndex, network, coin){ + let b = parseInt(blockIndex); + if(!Number.isFinite(b)) return false; + let threshold = _activationThreshold(ARCHIVE_INVALID_HEIGHT_KEY_ACTIVATION, network, coin); + if(threshold === undefined) return false; + return b >= threshold; +} + const DEACTIVATION_TABLES = ['stakes', 'delegations', 'contract_stakes', 'contract_delegations']; const SLASH_SPECS = [ { table: 'stakes', debits: 'capability_slash_debits', target: 'stakes' }, @@ -411,7 +475,15 @@ async function buildStateHashData(db, blockIndex, opts){ // Version predicate GATED: legacy v1-only below the // ARCHIVE_INVALID_STATE_HASH activation, the full ARCHIVE_HEAD_VERSIONS set // at/after it, so the pre-flag preimage stays byte-identical. + // Chunk-height key ALSO GATED, on its own separate flag day: the legacy + // `c.block_index` key is NEVER populated on a v2 continuation row, so this + // class matched nothing on every node from the day it landed. At/after + // ARCHIVE_INVALID_HEIGHT_KEY it uses `c.block_index_doge`, the height the + // completing chunk actually landed at. See the constant for why the two + // gates are separate and why repairing it is preimage-moving. let archiveInvalidActive = isArchiveInvalidStateHashActive(B, network, coin); + let chunkHeightCol = isArchiveInvalidHeightKeyActive(B, network, coin) + ? ARCHIVE_CHUNK_HEIGHT_COL : ARCHIVE_CHUNK_HEIGHT_COL_LEGACY; let anchor_invalid = []; try { anchor_invalid = await db.doQuery( @@ -420,7 +492,7 @@ async function buildStateHashData(db, blockIndex, opts){ "JOIN index_statuses s ON s.id = p.status_id AND s.status = 'invalid_archive' " + "JOIN index_statuses cs ON cs.id = c.status_id AND cs.status = 'valid' " + "WHERE p.version " + (archiveInvalidActive ? ARCHIVE_HEAD_VERSIONS_SQL : "= 1") + - " AND c.block_index BETWEEN ? AND ? " + + " AND " + chunkHeightCol + " BETWEEN ? AND ? " + "ORDER BY p.action_index ASC", [B, B]); } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; /* table/columns may not exist on older schemas */ } @@ -570,4 +642,6 @@ module.exports = { buildStateHashData, STATE_HASH_VERSION, TOKEN_SUPPLY_STATE_HASH_ACTIVATION, isTokenSupplyStateHashActive, BET_STATUS_STATE_HASH_ACTIVATION, isBetStatusStateHashActive, ARCHIVE_HEAD_VERSIONS, ARCHIVE_HEAD_VERSIONS_SQL, - ARCHIVE_INVALID_STATE_HASH_ACTIVATION, isArchiveInvalidStateHashActive }; + ARCHIVE_INVALID_STATE_HASH_ACTIVATION, isArchiveInvalidStateHashActive, + ARCHIVE_INVALID_HEIGHT_KEY_ACTIVATION, isArchiveInvalidHeightKeyActive, + ARCHIVE_CHUNK_HEIGHT_COL, ARCHIVE_CHUNK_HEIGHT_COL_LEGACY }; diff --git a/src/updatedRows.js b/src/updatedRows.js index 51b1754..e5810fb 100644 --- a/src/updatedRows.js +++ b/src/updatedRows.js @@ -75,7 +75,7 @@ * ********************************************************************/ -const { ARCHIVE_HEAD_VERSIONS_SQL } = require('./stateHash'); +const { ARCHIVE_HEAD_VERSIONS_SQL, ARCHIVE_CHUNK_HEIGHT_COL } = require('./stateHash'); // Tables carrying the deactivation_block stamp (value-threshold detection). const DEACTIVATION_TABLES = ['stakes', 'delegations', 'contract_stakes', 'contract_delegations']; @@ -277,16 +277,25 @@ async function collectUpdatedRows(db, fromBlock, toBlock, activationDelay, conn) // fails CRC, the parent is stamped 'invalid_archive' in place. Chunking spans // blocks by design, so the parent's action_index is in an earlier block and the // action-scoped stream carries the chunk row but not the parent's flipped status. - // The self-join keyed on the completing chunk's block_index mirrors - // ClientRollback's reverse 'unverified' reset predicate. Table may not exist on - // schemas without ANCHOR support. + // The self-join keyed on the completing chunk's height mirrors ClientRollback's + // reverse 'unverified' reset predicate. Table may not exist on schemas without + // ANCHOR support. + // HEIGHT KEY: `block_index_doge` (shared ARCHIVE_CHUNK_HEIGHT_COL), the DOGE + // block the completing chunk landed in. This class used to key on + // `c.block_index`, which a v2 continuation row NEVER populates (it carries the + // CHECKPOINTED height and only anchor.js `_parseCheckpoint` assigns it), so + // `NULL BETWEEN from AND to` was never true and the class shipped ZERO rows: a + // follower never received the stamped parent at all. UN-GATED, unlike the + // state-hash twin of this class: shipping the row is not a hash preimage, and + // this fix must be live BEFORE the state-hash flag day or the follower halts on + // a parent row it was never sent. try { let anchorRows = await db.doQuery( "SELECT DISTINCT p.* FROM anchor_actions p " + "JOIN anchor_actions c ON c.version = 2 AND c.match_batch_seq = p.match_batch_seq " + "JOIN index_statuses ps ON ps.id = p.status_id AND ps.status = 'invalid_archive' " + "JOIN index_statuses cs ON cs.id = c.status_id AND cs.status = 'valid' " + - "WHERE p.version " + ARCHIVE_HEAD_VERSIONS_SQL + " AND c.block_index BETWEEN ? AND ?", + "WHERE p.version " + ARCHIVE_HEAD_VERSIONS_SQL + " AND " + ARCHIVE_CHUNK_HEIGHT_COL + " BETWEEN ? AND ?", [from, to], conn); add('anchor_actions', anchorRows); } catch(e){ diff --git a/test/unit/rollback-coverage.test.js b/test/unit/rollback-coverage.test.js index 7cda296..667dfaf 100644 --- a/test/unit/rollback-coverage.test.js +++ b/test/unit/rollback-coverage.test.js @@ -635,8 +635,16 @@ describe('Rollback coverage guard @regression', function(){ const fs = require('fs'), pathMod = require('path'); const norm = s => s.replace(/[`"']/g, ' ').replace(/\s+\+\s+/g, ' ').replace(/\s+/g, ' '); const ur = norm(fs.readFileSync(pathMod.resolve(__dirname, '../../src/updatedRows.js'), 'utf8')); - assertLocal.ok(/WHERE p\.version ARCHIVE_HEAD_VERSIONS_SQL AND c\.block_index BETWEEN \? AND \?/.test(ur), - 'updatedRows.js anchor class must select archive-head parents via ARCHIVE_HEAD_VERSIONS_SQL'); + assertLocal.ok(/WHERE p\.version ARCHIVE_HEAD_VERSIONS_SQL AND ARCHIVE_CHUNK_HEIGHT_COL BETWEEN \? AND \?/.test(ur), + 'updatedRows.js anchor class must select archive-head parents via ARCHIVE_HEAD_VERSIONS_SQL, ' + + 'scoped by the shared ARCHIVE_CHUNK_HEIGHT_COL'); + // The completing chunk's height key is the shared constant, never a literal + // `c.block_index`: that column is NULL on every v2 continuation row, so the + // class shipped ZERO rows and a follower never received the stamped parent. + assertLocal.strictEqual(sh.ARCHIVE_CHUNK_HEIGHT_COL, 'c.block_index_doge', + 'ARCHIVE_CHUNK_HEIGHT_COL must be c.block_index_doge (block_index is NULL on v2 chunks)'); + assertLocal.ok(!/AND c\.block_index BETWEEN/.test(ur), + 'updatedRows.js must not regress to the never-populated c.block_index key'); // Twin-parity: the indexer stateHash.js copy (when the sibling checkout exists) // must carry the identical constant, or the two repos disagree on the parent set. const indexerPath = indexerFile('src/stateHash.js'); From 2c20e562c3ac311e95cd4dbbae517229a1b18520 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 17 Aug 2026 12:39:34 -0700 Subject: [PATCH 8/9] test(security): raise the brace-expansion and js-yaml advisory floors [XC-1518] --- .../configuration/dependency-advisories.test.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/test/unit/security/configuration/dependency-advisories.test.js b/test/unit/security/configuration/dependency-advisories.test.js index 383fe0d..1c5cee8 100644 --- a/test/unit/security/configuration/dependency-advisories.test.js +++ b/test/unit/security/configuration/dependency-advisories.test.js @@ -44,8 +44,11 @@ describe('Security: remediated dependency advisories @regression @tier4', functi // number of results but not their total length, so a few KB of chained brace // groups exhausts the heap and kills the process with an uncatchable OOM. // Affects <=5.0.7 across every release line, and no 1.x/2.x/3.x/4.x carries - // the patch, so every entry has to move to 5.0.8. Reaches this tree dev-only - // through minimatch (mocha, glob, stryker, test-exclude). + // the patch, so every entry has to move onto the 5.x line. Reaches this tree + // dev-only through minimatch (mocha, glob, stryker, test-exclude). The first + // patch, 5.0.8, capped the result count but still materialised the whole + // expansion before truncating it, so the same input exhausted the heap one + // step later; 5.0.9 is the floor that actually holds. // // Everything below this point is the second wave: HIGH advisories no // lockfile splice could reach, because the safe version was a real upgrade @@ -59,8 +62,10 @@ describe('Security: remediated dependency advisories @regression @tier4', functi // the rest of this list axios is a direct runtime dependency of most // services, so it is pinned in dependencies rather than through overrides. // - // js-yaml <4.3.0: merge-key ("<<") chains expand quadratically, so a small + // js-yaml <4.3.1: merge-key ("<<") chains expand quadratically, so a small // document forces unbounded CPU. Dev-only here, via mocha's config loader. + // 4.3.0 fixed the plain-mapping case and left the same blowup reachable + // through an !!omap, so the floor is 4.3.1 rather than 4.3.0. // // serialize-javascript <=7.0.4: RCE via RegExp.flags and // Date.prototype.toISOString, plus CPU exhaustion on crafted array-likes. @@ -86,14 +91,14 @@ describe('Security: remediated dependency advisories @regression @tier4', functi // rate-limit bucket than its canonical address. const advisories = [ { name: 'fast-uri', minSafe: [3, 1, 5], majorSeries: 3 }, - { name: 'brace-expansion', minSafe: [5, 0, 8], majorSeries: 5 }, + { name: 'brace-expansion', minSafe: [5, 0, 9], majorSeries: 5 }, // Coupled to the entry above: brace-expansion 5.x dropped its CommonJS // default export, so only minimatch >=10 (named `import { expand }`) // can consume it. Pinning minimatch here keeps the pair from drifting // apart into a tree that installs but throws on first glob match. { name: 'minimatch', minSafe: [10, 2, 5], majorSeries: 10 }, { name: 'axios', minSafe: [1, 18, 0], majorSeries: 1 }, - { name: 'js-yaml', minSafe: [4, 3, 0], majorSeries: 4 }, + { name: 'js-yaml', minSafe: [4, 3, 1], majorSeries: 4 }, { name: 'serialize-javascript', minSafe: [7, 0, 5], majorSeries: 7 }, { name: 'shell-quote', minSafe: [1, 9, 0], majorSeries: 1 }, { name: 'form-data', minSafe: [4, 0, 6], majorSeries: 4 }, From 3b2491b2975674bc468d89266048eda19b79addb Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 17 Aug 2026 15:40:47 -0700 Subject: [PATCH 9/9] chore: scrub internal work-tracking references from comments and test labels --- src/TransparencyLog.js | 2 +- src/balance-helpers.js | 2 +- test/unit/TransparencyLog.retention.test.js | 2 +- test/unit/balance-helpers.test.js | 11 +++++------ 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/TransparencyLog.js b/src/TransparencyLog.js index 8281f80..c0d0321 100644 --- a/src/TransparencyLog.js +++ b/src/TransparencyLog.js @@ -69,7 +69,7 @@ class TransparencyLog { } } - // OPT-IN retention for the per-block sync_meta rows (XC-1363). DEFAULT OFF: with + // OPT-IN retention for the per-block sync_meta rows. DEFAULT OFF: with // SYNC_META_RETENTION_BLOCKS unset (or 0) this is a no-op and the log keeps full // history, which stays the shipped behaviour. Mirrors the indexer's // retention.pruneStateRoots (xchain-indexer/src/retention.js): a positive window diff --git a/src/balance-helpers.js b/src/balance-helpers.js index 7b5582e..ddeb9a1 100644 --- a/src/balance-helpers.js +++ b/src/balance-helpers.js @@ -69,7 +69,7 @@ function minimalSupply(sumExpr, decimals) { // // Each ledger row is summed at the EXACT ledger scale (DECIMAL(60,18)) and the TOTAL // is rounded ONCE to the token's own decimals, mirroring the indexer's exact-ledger -// rule (XC-1459, xchain-indexer/src/ledger_amount_precision_activation.js). The +// rule (xchain-indexer/src/ledger_amount_precision_activation.js). The // former shape cast each ROW to DECIMAL(60,d) first, which agreed with the indexer // only while every stored amount already sat on the token's grid; once the indexer // stores fee amounts finer than the tick (0.5 XCHAIN against a decimals=0 gas tick), diff --git a/test/unit/TransparencyLog.retention.test.js b/test/unit/TransparencyLog.retention.test.js index 14cec39..4b5c658 100644 --- a/test/unit/TransparencyLog.retention.test.js +++ b/test/unit/TransparencyLog.retention.test.js @@ -8,7 +8,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -// Opt-in sync_meta retention (XC-1363). The default posture is UNCHANGED: with no +// Opt-in sync_meta retention. The default posture is UNCHANGED: with no // SYNC_META_RETENTION_BLOCKS the log keeps full history and pruneSyncMeta deletes // nothing, so every historical inclusion proof stays serveable. When an operator // does arm a window, the prune must land on a COMMITTED epoch boundary, because a diff --git a/test/unit/balance-helpers.test.js b/test/unit/balance-helpers.test.js index cd0d7fd..ae8d9fb 100644 --- a/test/unit/balance-helpers.test.js +++ b/test/unit/balance-helpers.test.js @@ -132,11 +132,10 @@ describe('balance-helpers @money @regression', function () { assert.deepStrictEqual(updateArgs.sort(), [[0], [8]]); }); - // XC-1459: rows are summed EXACTLY (DECIMAL(60,18)) and the TOTAL is rounded once - // to the token's own scale. Per-ROW rounding agreed with the source indexer only - // while every stored amount already sat on the token's grid; the exact-ledger - // flag-day lets a fee amount be finer than the tick, and per-row rounding then - // inflates a rebuilt supply by up to one unit per row. + // Rows are summed EXACTLY (DECIMAL(60,18)) and the TOTAL is rounded once + // to the token's own scale: per-ROW rounding agreed with the indexer only + // while every amount sat on the token's grid, and inflates supply once + // fee amounts go finer than the tick. it('sums (credits - debits) + escrows at the EXACT scale and rounds ONCE at the token scale', async function () { const db = precisionDb([{ decimals: 8 }]); await recomputeTokenSupplies(db); @@ -149,7 +148,7 @@ describe('balance-helpers @money @regression', function () { assert.ok(/CAST\(SUM\(amt\) AS DECIMAL\(60,8\)\)/i.test(sql), 'the TOTAL is rounded once at the token scale, so it stays byte-identical to the source supply'); assert.ok(!/CAST\(amount AS DECIMAL\(60,8\)\)/i.test(sql), - 'must NOT round each ROW to the token scale (that is the XC-1459 overcharge shape)'); + 'must NOT round each ROW to the token scale (the per-row rounding overcharge shape)'); assert.ok(!/DECIMAL\(65,18\)/i.test(sql), 'must NOT use the fixed 65,18 scale (byte-identity needs the token scale)'); assert.ok(/GROUP BY tick_id/i.test(sql)); });