Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .ci-timeout
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
5400
20 changes: 2 additions & 18 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,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

Expand Down Expand Up @@ -107,24 +108,7 @@ jobs:
# ratchet measures a suite the gate never ran: 1248 of 1289 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
Expand Down
126 changes: 126 additions & 0 deletions bin/ci-full.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
#!/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 parallel jobs (ci,
# drift-guards, docker-suites, 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 ../<repo>, 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
# coverage job clones the whole .ci-siblings roster before re-running the unit
# suite, so the roster is what need_sib demands, not just drift-guards' hub.
#
# Database: nothing here reads the venue's CI_DB_* on purpose. The two
# docker-gated tiers bring up their OWN MariaDB inside their compose fixture
# (test/{integration,e2e}/fixtures/docker-compose.test.yml) on ports 13318 and
# 13319, with fixture-local throwaway credentials that the tier's setup.js
# defaults to. Pointing them at a venue database would test the wrong server.
#
# Docker: the docker-suites job runs on a runner that has docker, and both
# tiers are useless without it, so a venue without docker FAILS here rather
# than skipping (a skip is exactly the green-locally / red-on-GitHub hole this
# script exists to close).
#
# Skipped by design: none. Every run-step of every push-triggered workflow is
# transcribed below. The actions-only steps (checkout, setup-node, the npm ci
# install, and the coverage job's sibling-clone loop) have no local twin by
# nature: the venue already ships a checkout, a node, installed modules, and
# the sibling roster, and need_sib proves the last of those.
#
# Out of scope: verify-tag.yml (push on tags v*) and audit.yml
# (schedule + workflow_dispatch + pull_request on manifest paths). Neither
# triggers on a push to develop or master, so neither belongs in a push gate.
#
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
}
need_docker() {
docker info >/dev/null 2>&1 || {
echo "ci:full: VENUE LACKS DOCKER for $1; pin a docker venue with CI_VENUES=..." >&2
exit 1
}
}

need_sib xchain-encoder xchain-documentation xchain-hub xchain-indexer xchain-utxo-tracker

# --- job: ci (XChain-Platform/.github ci-reusable.yml -> npm run ci) -------
run_tier "ci" npm run ci

# --- 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: coin-registry byte-identity" sync_coins_check
run_tier "drift: 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: docker-suites ----------------------------------------------------
# Both tiers own their venue lifecycle inside their npm script (compose up
# --wait, mocha, down -v on any exit), so this transcribes the two run-steps
# and nothing else. The gate is docker itself, checked once before either.
need_docker "docker-suites (test:integration, test:e2e)"
run_tier "docker: integration tier (test:integration)" npm run test:integration
run_tier "docker: end-to-end tier (test:e2e)" npm run test:e2e

# --- 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)"
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"coverage:check": "c8 --check-coverage --lines 87.8 --statements 87.8 --branches 85.4 --functions 77.2 --reporter=text-summary --include 'src/**/*.js' mocha --timeout 5000 --require ./test/unit/setup.js 'test/unit/**/*.test.js' --exit",
"test:smoke": "mocha --timeout 5000 --require ./test/unit/setup.js 'test/smoke/**/*.smoke.js'",
"test:unit": "mocha --timeout 5000 --require ./test/unit/setup.js 'test/unit/**/*.test.js'",
"test:boundary": "mocha --timeout 5000 --require ./test/unit/setup.js 'test/unit/boundary/**/*.boundary.test.js'",
"ci": "npm run ci:unit && npm run ci:security && npm run ci:smoke && npm run ci:regression && npm run ci:chaos && npm run ci:fuzz",
"ci:unit": "mocha --timeout 5000 --exit --require ./test/unit/setup.js 'test/unit/**/*.test.js'",
"ci:security": "mocha --timeout 10000 --exit --require ./test/security/setup.js 'test/security/**/*.security.test.js'",
Expand Down Expand Up @@ -74,7 +75,8 @@
"test:regression:critical": "mocha --timeout 5000 --require ./test/unit/setup.js --grep '\\[REGRESSION P0\\]' 'test/unit/**/*.test.js' 'test/security/**/*.security.test.js'",
"test:regression:full": "mocha --timeout 10000 --require ./test/unit/setup.js --grep '\\[REGRESSION P[0123]\\]' 'test/unit/**/*.test.js' 'test/security/**/*.security.test.js' 'test/regression/**/*.test.js'",
"test:mutation": "stryker run test/mutation/stryker.config.mjs",
"test:mutation:phase2": "stryker run test/mutation/stryker.phase2.config.mjs"
"test:mutation:phase2": "stryker run test/mutation/stryker.phase2.config.mjs",
"ci:full": "bash bin/ci-full.sh"
},
"devDependencies": {
"@stryker-mutator/core": "^9.6.1",
Expand Down
7 changes: 6 additions & 1 deletion src/CryptoNetworks.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,12 @@ class CryptoNetworks {
return coins.getCoinConfig(p.tick, p.net).chainGenesisHash || null;
}

// Indexing start height (not part of any consensus hash). Unknown/regtest -> 0.
// Indexing start height, and a CONSENSUS input: coins/index.js folds firstBlock into
// consensusSubset() (REGENERATED 2026-08-06, see coins/consensus_pin.js), because the
// decoder reads it as the chain's start height, so it decides which block the action
// history begins at. A node bundling a higher value skips the actions below it and
// replays a different history while its pin verifies clean, so this is never a
// locally-tunable operational value. Unknown/regtest -> 0.
static getFirstBlock(networkName){
const p = parseNetworkName(networkName);
return p ? coins.getCoinConfig(p.tick, p.net).firstBlock : 0;
Expand Down
42 changes: 36 additions & 6 deletions src/XChainDecoder.js
Original file line number Diff line number Diff line change
Expand Up @@ -1811,7 +1811,19 @@ class XChainDecoder {
if (reorgChainMismatch){
this.logError('reorg: ignoring a tip refresh from a foreign endpoint: ' + reorgChainMismatch)
} else if (info && typeof info.blocks === 'number') {
nodeTip = info.blocks
// Tier agreement is not chain identity: a same-tier foreign node (BTC-mainnet
// and DOGE-mainnet both report chain="main") passes the tier gate above, so
// re-prove the chain with the genesis pin too, exactly as the block loop does
// before it trusts a refreshed tip. verifyChainGenesis() never throws and returns
// null when unpinned/unreadable/agreeing, so on anything but a PROVEN mismatch the
// tip advances as before; a proven mismatch keeps the call-time tip and falls
// through to sleep-and-retry (the recoverable direction).
const reorgGenesisMismatch = await this.verifyChainGenesis()
if (reorgGenesisMismatch){
this.logError('reorg: ignoring a tip refresh from a foreign endpoint: ' + reorgGenesisMismatch)
} else {
nodeTip = info.blocks
}
}
} catch (refreshErr) { /* node unreachable; retry with the existing tip */ }
await this.sleep(3000)
Expand Down Expand Up @@ -2913,10 +2925,24 @@ class XChainDecoder {
// block loop, which then retries the same deterministic tx
// forever - or truncates under a lax one, leaving the decoder
// holding a dispenser the indexer never registered.
// Number.isInteger already excludes NaN and Infinity, so it
// Number.isSafeInteger already excludes NaN and Infinity, so it
// subsumes the isNaN test it replaces; the default expiration is
// integral by construction (block timestamp + whole days).
if (!Number.isInteger(expiration) || expiration < 0 || expiration > 4294967295) {
//
// SAFE integer, not merely integer, and no u32 ceiling. The old
// `expiration > 4294967295` reject was recognition drift: the
// indexer escrows any non-negative integer EXPIRATION into its own
// BIGINT UNSIGNED column, so a dispenser opened past year 2106 (or
// spelled 9999999999 for "never") stayed open and escrowed there
// while the decoder skipped registration, and a later coin payment
// to it was never flagged as a dispense. Number.isSafeInteger is
// the bound that actually holds: at or below it Number() round-trips
// the payload token exactly, so the decoder stores the same value
// the indexer does, and it stays far inside BIGINT UNSIGNED.
// Dropping the ceiling outright would NOT be safe - Number.isInteger
// is true for 1e300, which overflows the column and wedges the block
// loop on the same deterministic tx forever.
if (!Number.isSafeInteger(expiration) || expiration < 0) {
this.parseErrors++
console.error(`Skipping dispenser in tx ${nextTransactionHash}: invalid expiration value '${decodedDataSplit[14]}'`)
} else if (this.dispenserOpensForThisChain(giveCoin, getCoin)){
Expand Down Expand Up @@ -3040,9 +3066,13 @@ class XChainDecoder {
// path writes through extendOpenDispenserExpirationBySource
// into the same BIGINT UNSIGNED column, and the indexer
// rejects a fractional edit EXPIRATION with the identical
// isInteger test.
if (Number.isInteger(newExpiration) && newExpiration >= 0 &&
newExpiration <= 4294967295 && newExpiration > block.timestamp){
// isInteger test, and the same SAFE-integer ceiling rather than
// a u32 one (see the create guard: a u32 reject here would
// silently decline to mirror an extend the indexer accepted,
// closing the decoder's row early on a dispenser that is still
// open and escrowed).
if (Number.isSafeInteger(newExpiration) && newExpiration >= 0 &&
newExpiration > block.timestamp){
// nextBlockHeight lets the mirror also clear a soft-expiry
// THIS block stamped: deleteOpenDispensers ran before this
// loop, so without it the `IS NULL` filter silently skipped
Expand Down
Loading
Loading