diff --git a/.ci-siblings b/.ci-siblings index 92bb3473..695b5433 100644 --- a/.ci-siblings +++ b/.ci-siblings @@ -5,13 +5,16 @@ # resolves with existsSync, calls this.skip(), and the gate still prints PASS, # so the run reports green having exercised none of them. Measured 2026-08-12, # that gap was ~36 guards (a partial CI checkout ran 3444 passing / 40 pending -# where a full local checkout runs 3515 / 4). See test/unit/sibling_coverage.test.js, -# which reports the surface this file leaves uncovered; the same gap applies -# to a sibling that is declared but missing. +# where a full local checkout runs 3515 / 4). See +# test/unit/tooling/sibling_coverage.test.js, which reports the surface this file +# leaves uncovered; the same gap applies to a sibling that is declared but missing. # -# ci-dispatch.sh ships each of these at its origin/master (falling back to local -# HEAD) and REFUSES the push if one is not a checkout beside this repo, so a -# declared sibling can never be silently absent. +# ci-dispatch.sh, the shared pre-push dispatcher (it lives in the CI tooling on +# the pushing host, outside this repo: there is no bin/ci-dispatch.sh here and +# never was), ships each of these at the pushed branch, falling back through +# origin/master, origin/main, origin/HEAD, then local HEAD. It REFUSES the push +# if one is not a checkout beside this repo, so a declared sibling can never be +# silently absent. # Drift gate (§8.5) over the mapped indexer handlers. xchain-indexer diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed0dd38e..e10a2232 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: uses: XChain-Platform/.github/.github/workflows/ci-reusable.yml@6f4d39ae85787fc31e90a31588d87610a2c33103 # pin: XChain-Platform/.github @ master 2026-08-14; bump deliberately with: # A hotfix or release PR compares against siblings at the same branch, not develop. - siblings-ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || '' }} + siblings-ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || github.base_ref || '' }} # Opt into the perf gate: the SDK's perf suite (Validator throughput) needs # no infra, so it runs cleanly as a post-gate step. First adopter of run-perf. run-perf: true @@ -48,7 +48,7 @@ jobs: # canonical sibling is read at the sibling release branch, because develop # keeps moving under the frozen train (the indexer handler map this gate # grades is re-pinned at every indexer develop landing). - ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || (github.ref == 'refs/heads/master' && 'master' || 'develop') }} + ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || github.base_ref || (github.ref == 'refs/heads/master' && 'master' || 'develop') }} ssh-key: ${{ secrets.XCHAIN_HUB_DEPLOY_KEY }} path: xchain-hub @@ -83,7 +83,7 @@ jobs: # canonical sibling is read at the sibling release branch, because develop # keeps moving under the frozen train (the indexer handler map this gate # grades is re-pinned at every indexer develop landing). - ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || (github.ref == 'refs/heads/master' && 'master' || 'develop') }} + ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || github.base_ref || (github.ref == 'refs/heads/master' && 'master' || 'develop') }} ssh-key: ${{ secrets.XCHAIN_EXPLORER_DEPLOY_KEY }} path: xchain-explorer @@ -117,7 +117,7 @@ jobs: # canonical sibling is read at the sibling release branch, because develop # keeps moving under the frozen train (the indexer handler map this gate # grades is re-pinned at every indexer develop landing). - ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || (github.ref == 'refs/heads/master' && 'master' || 'develop') }} + ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || github.base_ref || (github.ref == 'refs/heads/master' && 'master' || 'develop') }} ssh-key: ${{ secrets.XCHAIN_INDEXER_DEPLOY_KEY }} path: xchain-indexer @@ -150,7 +150,7 @@ jobs: - name: Check out declared sibling repositories uses: XChain-Platform/.github/actions/checkout-siblings@master # one definition for every call site; see the action for why this tracks master with: - ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || '' }} + ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || github.base_ref || '' }} - name: Use Node.js 22 uses: actions/setup-node@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..5ed8a68b --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,67 @@ +name: Publish to npm + +on: + push: + tags: + - "v*" + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Check out declared sibling repositories + uses: XChain-Platform/.github/actions/checkout-siblings@master + with: + ref: master + + - name: Use Node.js 22 + uses: actions/setup-node@v6 + with: + node-version: "22.22.3" + registry-url: "https://registry.npmjs.org" + package-manager-cache: false + + - name: Install an OIDC-capable npm CLI + run: npm install --global "npm@^11.5.1" + + - name: Verify release versions + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + node <<'NODE' + const packages = [ + require('./package.json'), + require('./mcp/package.json'), + ]; + const expectedVersion = process.env.RELEASE_TAG.replace(/^v/, ''); + + for (const pkg of packages) { + if (pkg.version !== expectedVersion) { + throw new Error(`${pkg.name} is ${pkg.version}, expected ${expectedVersion}`); + } + } + NODE + + - name: Install dependencies + run: npm ci + + - name: Test SDK + env: + XCHAIN_REQUIRE_SIBLINGS: "1" + run: npm run ci + + - name: Test MCP package + run: npm --prefix mcp run ci + + - name: Publish SDK + run: npm publish --access public + + - name: Publish MCP server + working-directory: mcp + run: npm publish --access public diff --git a/CHANGELOG.md b/CHANGELOG.md index c2800f80..daa18846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.20.1] - 2026-09-23 + +### Fixed +- Re-slid BTC and DOGE testnet activations with LTC inert, added exact BET and Coinpay arithmetic, and emitted TIS v1.1.1 NFT documents. + + ## [0.20.0] - 2026-09-17 ### Added diff --git a/bin/check-preflight-drift.js b/bin/check-preflight-drift.js index ddf5c33d..8246c4a4 100755 --- a/bin/check-preflight-drift.js +++ b/bin/check-preflight-drift.js @@ -71,7 +71,8 @@ const { indexerRootCandidates, describeIndexerCandidates, resolveIndexerRoot } = /* The table rows, each tagged 'file', 'directory' or 'malformed' by the shape of the * handler path it names. A directory row carries a trailing slash and is hashed over every - * file in the directory, so a handler split into parts cannot leave them unhashed. */ + * file in the directory. A file row also covers a same-named directory beside its entry, + * so either supported handler split shape includes every part in its pin. */ function parseMap(mapPath) { return handlerDirs.parseMapRows(fs.readFileSync(mapPath, 'utf8')); } diff --git a/bin/ci-full.sh b/bin/ci-full.sh index b2670b2b..845d9aad 100755 --- a/bin/ci-full.sh +++ b/bin/ci-full.sh @@ -52,7 +52,36 @@ SELF="$(pwd)" SIB="$(cd .. && pwd)" FAILED="" +# >>> ci-tier (generated block; re-run the tier wirer to update) >>> +# Tier classes. A push grades the FAST tier only: the unit job, the pin and +# drift guards, and the structure and hygiene checks the hook runs before it +# dispatches. The tiers named below (coverage re-runs, perf scenarios) are +# skipped when the gate sets CI_TIER=fast, and each skip is recorded so the +# closing verdict can never claim a green it did not earn. Nothing stops +# being graded: a scheduled sweep re-runs this same script with CI_TIER=full +# on every repo every three hours and before any release or deploy, and a +# red there is tracked down and fixed first. CI_TIER is unset for a hand +# run, so a bare `npm run ci:full` still runs every tier as it always did. +CI_TIER_FULL_ONLY=( + "perf: hot-path throughput (test:performance)" + "coverage ratchet (coverage:check)" +) +DEFERRED="" +ci_tier_deferred() { + [ "${CI_TIER:-full}" = "fast" ] || return 1 + local t + for t in ${CI_TIER_FULL_ONLY[@]+"${CI_TIER_FULL_ONLY[@]}"}; do + if [ "$t" = "$1" ]; then + DEFERRED="$DEFERRED [$1]" + echo; echo "ci:full ===== $1 DEFERRED (CI_TIER=fast, runs in the full sweep) =====" + return 0 + fi + done + return 1 +} +# <<< ci-tier <<< run_tier() { + ci_tier_deferred "$1" && return 0 # ci-tier guard (generated) local name="$1"; shift echo; echo "ci:full ===== $name =====" if "$@"; then @@ -119,8 +148,20 @@ run_tier "drift: pre-flight <-> handler gate" \ run_tier "coverage ratchet (coverage:check)" npm run coverage:check echo +# >>> ci-tier summary (generated) >>> +echo "ci:full: tier class ${CI_TIER:-full}" +if [ -n "${DEFERRED:-}" ]; then + echo "ci:full: DEFERRED to the full sweep:$DEFERRED" +fi +# <<< ci-tier summary <<< if [ -n "$FAILED" ]; then echo "ci:full: RED tiers:$FAILED" exit 1 fi -echo "ci:full: all tiers green (same set GitHub CI runs)" +# >>> ci-tier verdict (generated) >>> +if [ "${CI_TIER:-full}" = "fast" ]; then + echo "ci:full: all FAST tiers green; the DEFERRED tiers above were NOT graded here" +else + echo "ci:full: all tiers green (same set GitHub CI runs)" +fi +# <<< ci-tier verdict <<< diff --git a/bin/pins/identity.json b/bin/pins/identity.json index fc171208..2db97bac 100644 --- a/bin/pins/identity.json +++ b/bin/pins/identity.json @@ -13,7 +13,7 @@ "src/checkpoint.js": "0b0e80f1372ef59fb6c8e7ec00a1f66d5e88ddca0a12584cadd087c1db595117", "src/contract/abi-core.js": "8222b193b5e65da7130af0bb589ff38d069f3a088f0d9f901826ffb18a3e9294", "test/fixtures/roundtrip-conformance.json": "d9963ac7c35bdcfab99595fec040d7791d73119cb62fef20db136f8ea56236ed", - "test/fixtures/action-manifest.json": "93ac85b4d76f078951a2e95eb3ca303f39fb9f18d0fb91b1ae16693716bef72f", + "test/fixtures/action-manifest.json": "05813d667976ec49bdeb001fc7f5aea41fce4f343a37576a2716d176b334a823", "test/unit/xcall-constants-cross-repo.test.js": "baa3bd32e8626a6b177c92f001455bf6922e4ec85ddad46456f5e8a6a903df96", "test/fixtures/gate-min-amount-vectors.json": "414a68df798c8eb24532abc4e0275326cc138019541a7cd4fb3b0221eef2b3c2" }, diff --git a/bin/preflight_handler_dirs.js b/bin/preflight_handler_dirs.js index ea939b6c..1a7a4e77 100755 --- a/bin/preflight_handler_dirs.js +++ b/bin/preflight_handler_dirs.js @@ -19,7 +19,9 @@ * * - a map row may name a DIRECTORY (`src/actions//`, trailing slash), hashed over * every file in it, recursively, so editing, adding or removing any part moves the pin; - * - the fee walk reads every source file of a directory handler, not only index.js; + * - a FILE row also includes a same-named companion directory, since another valid split + * leaves `.js` as the entry and moves its imported logic into `/`; + * - the fee walk reads every source file owned by either split shape, not only its entry; * - a fee-quote literal is found exactly once across the indexer's src/, so moving it * out of the loader into a part neither loses it nor lets a second copy go unseen. * @@ -111,14 +113,44 @@ function hashDirectory(dirAbs) { return { digest: sha256(manifest), parts }; } +// A flat entry may keep its original path while its implementation moves into a directory +// beside it. In that shape the row still names the entry, but its pin must commit to both +// the entry and every companion part. Names are relative to src/actions/ so the manifest +// distinguishes the entry from its tree and detects part renames as well as byte changes. +function hashFileWithCompanionParts(fileAbs) { + const partsDir = fileAbs.replace(/\.js$/, ''); + let partsStat; + try { + partsStat = fs.lstatSync(partsDir); + } catch (e) { + if (e && e.code === 'ENOENT') return { digest: sha256(fs.readFileSync(fileAbs)) }; + throw e; + } + if (partsStat.isSymbolicLink()) { + throw new Error(`drift-gate: companion ${partsDir} is a symbolic link; handler parts must be in a plain directory.`); + } + if (!partsStat.isDirectory()) return { digest: sha256(fs.readFileSync(fileAbs)) }; + + const entryName = path.basename(fileAbs); + const partsName = path.basename(partsDir); + const parts = [{ name: entryName, sha256: sha256(fs.readFileSync(fileAbs)) }]; + for (const name of listParts(partsDir)) { + parts.push({ name: `${partsName}/${name}`, sha256: sha256(fs.readFileSync(path.join(partsDir, name))) }); + } + parts.sort((a, b) => Buffer.compare(Buffer.from(a.name, 'utf8'), Buffer.from(b.name, 'utf8'))); + const manifest = parts.map((p) => `${p.sha256} ${p.name}\n`).join(''); + return { digest: sha256(manifest), parts }; +} + function isDirectory(abs) { try { return fs.lstatSync(abs).isDirectory(); } catch (e) { return false; } } // Hash one mapped row against the checkout. Returns { actual, parts? } or { problem }. -// A flat row is hashed as the file's bytes, which is what every pin in the table already -// records, so directory support leaves the shipped map valid with no re-pin. +// A flat row with no companion directory is hashed as the file's bytes, preserving legacy +// pins. When a companion directory exists, the entry and its parts form one manifest and +// the row needs the deliberate re-pin that closes the old blind spot. // The problems a directory split introduces are named one by one below rather than folded // into a hash mismatch, because each has its own fix. @@ -129,7 +161,14 @@ function hashMappedRow(indexerRoot, row) { return { problem: `${row.handler}: not a mappable handler path; a row names src/actions/.js or the directory src/actions//` }; } if (row.kind === 'file') { - if (fs.existsSync(abs)) return { actual: sha256(fs.readFileSync(abs)) }; + if (fs.existsSync(abs)) { + try { + const { digest, parts } = hashFileWithCompanionParts(abs); + return { actual: digest, parts }; + } catch (e) { + return { problem: `${row.handler}: ${e && e.message ? e.message : String(e)}` }; + } + } // A flat row whose handler became a directory: the fix is a re-pin as a directory row. const dir = abs.replace(/\.js$/, ''); if (isDirectory(dir)) { @@ -144,7 +183,7 @@ function hashMappedRow(indexerRoot, row) { // require('./') resolves a flat .js ahead of /index.js, so with both // present the directory this would hash is not the code that runs. if (fs.existsSync(flat)) { - return { problem: `${row.handler}: a flat ${row.handler.replace(/\/$/, '.js')} beside it is what require() resolves first, so the directory is not the code that runs` }; + return { problem: `${row.handler}: a flat ${row.handler.replace(/\/$/, '.js')} beside it is what require() resolves first; map that entry file so its pin includes this companion parts directory` }; } try { const { digest, parts } = hashDirectory(abs); @@ -181,8 +220,8 @@ function stripCommentsAndStrings(src) { } // The handlers the fee walk reads, each with every source file that belongs to it. A -// handler is either src/actions/.js or, since the M3 feature-directory pass, -// src/actions//index.js. +// handler is src/actions/.js, optionally with parts in src/actions//, or a +// directory handler whose entry is src/actions//index.js. // A flat readdir of *.js alone silently drops every directory handler, and a dropped // handler reads as "charges no fee" rather than as a broken walk, so both shapes are @@ -196,11 +235,18 @@ function stripCommentsAndStrings(src) { // as a flat readdir drops a directory. function feeWalkHandlers(actionsDir, entries) { const handlers = []; + const flatNames = new Set(entries + .filter((e) => e.isFile() && e.name.endsWith('.js') && e.name !== 'index.js') + .map((e) => path.basename(e.name, '.js'))); for (const e of entries) { if (e.isDirectory()) { const dirAbs = path.join(actionsDir, e.name); const files = listParts(dirAbs, { allowEmpty: true }).filter((n) => JS_SOURCE.test(n)).map((n) => path.join(dirAbs, n)); - if (fs.existsSync(path.join(dirAbs, 'index.js'))) { + // A same-named flat entry owns this parts tree. It is added with the entry in + // the file branch below, including any index.js the tree happens to contain. + if (flatNames.has(e.name)) { + continue; + } else if (fs.existsSync(path.join(dirAbs, 'index.js'))) { handlers.push({ action: e.name, files }); // A directory with no index.js is not a handler, but if one of its files charges // a fee the walk cannot say for which action, so that fails CLOSED here rather @@ -210,7 +256,23 @@ function feeWalkHandlers(actionsDir, entries) { + 'calls createFeesObject. The fee walk cannot name the action that charges; fix the layout or the walk.'); } } else if (e.isFile() && e.name.endsWith('.js') && e.name !== 'index.js') { - handlers.push({ action: path.basename(e.name, '.js'), files: [path.join(actionsDir, e.name)] }); + const action = path.basename(e.name, '.js'); + const entry = path.join(actionsDir, e.name); + const partsDir = path.join(actionsDir, action); + const files = [entry]; + let partsStat; + try { partsStat = fs.lstatSync(partsDir); } catch (err) { + if (!err || err.code !== 'ENOENT') throw err; + } + if (partsStat && partsStat.isSymbolicLink()) { + throw new Error(`drift-gate: companion src/actions/${action}/ is a symbolic link; handler parts must be in a plain directory.`); + } + if (partsStat && partsStat.isDirectory()) { + files.push(...listParts(partsDir, { allowEmpty: true }) + .filter((n) => JS_SOURCE.test(n)) + .map((n) => path.join(partsDir, n))); + } + handlers.push({ action, files }); } } return handlers; @@ -290,6 +352,6 @@ function main(argv) { if (require.main === module) process.exit(main(process.argv.slice(2))); module.exports = { - parseMapRows, listParts, hashDirectory, hashMappedRow, compareRows, formatDrift, + parseMapRows, listParts, hashDirectory, hashFileWithCompanionParts, hashMappedRow, compareRows, formatDrift, stripCommentsAndStrings, feeWalkHandlers, declarationPattern, indexerLiteralReader, main, }; diff --git a/index.d.ts b/index.d.ts index 693f97f0..2c11b2a2 100644 --- a/index.d.ts +++ b/index.d.ts @@ -954,7 +954,7 @@ export declare class ContractUtils { /* * NFT helpers: pure builders for the NFT pattern (ISSUE with DECIMALS=0 + - * LOCK_MAX_SUPPLY=1). No network. Spec: protocol/NFT_Standard.md + * LOCK_MAX_SUPPLY=1). No network. Spec: protocol/nft-standard.md */ export interface NftUniqueParams { tick: string; description?: string; transfer?: string; memo?: string; } @@ -2110,7 +2110,7 @@ export declare class XChainSDK { /** Distribute a dividend to all holders of a token */ distributeDividend(wif: string, dividendParams: DividendParams | ActionParams, opts?: Partial): Promise; - /** Pure NFT param builders + classifier (no network). Spec: protocol/NFT_Standard.md */ + /** Pure NFT param builders + classifier (no network). Spec: protocol/nft-standard.md */ readonly nft: NftHelpers; /** Issue a unique 1-of-1 NFT, fully minted to the issuer */ diff --git a/mcp/package.json b/mcp/package.json index 0f5a6aa9..cc53ac20 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,7 +1,7 @@ { "name": "xchain-mcp", "description": "Model Context Protocol (MCP) stdio server for the XChain Platform: read chain/token/balance data on BTC, LTC and DOGE (mainnet/testnet/regtest), and optionally submit policy-gated actions, backed by the XChain SDK.", - "version": "0.20.0", + "version": "0.20.1", "comment:homepage": "The npm package page links wherever this points, and it points at the platform site.", "homepage": "https://xchain.io/", "main": "server.js", @@ -11,7 +11,7 @@ "license": "AGPL-3.0-or-later", "repository": { "type": "git", - "url": "https://github.com/XChain-Platform/xchain-sdk.git", + "url": "git+https://github.com/XChain-Platform/xchain-sdk.git", "directory": "mcp" }, "comment:files": "Publish allowlist: the server is two files. README/LICENSE/package.json ship automatically.", @@ -28,12 +28,16 @@ "dogecoin" ], "dependencies": { - "@dankest-llc/xchain-sdk": "^0.20.0", + "@dankest-llc/xchain-sdk": "^0.20.1", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.4.3" }, "scripts": { - "start": "node cli.js" + "start": "node cli.js", + "test": "node --test test/x402_fail_closed.test.js", + "comment:ci:pack": "Publish-shape check: packs both packages and requires the MCP server via its published load path. Needs registry access to resolve the SDK's own dependency tree into a clean install root, so it is not part of `test`.", + "ci:pack": "node --test test/packed_install.test.js", + "ci": "npm test && npm run ci:pack" }, "engines": { "node": ">=22.0.0" diff --git a/mcp/test/packed_install.test.js b/mcp/test/packed_install.test.js new file mode 100644 index 00000000..90929b53 --- /dev/null +++ b/mcp/test/packed_install.test.js @@ -0,0 +1,90 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * + * Publish-shape check: pack both the SDK and the MCP package the way + * `npm publish` would, install the tarballs into a scratch project with no + * repo-relative escape hatch, and require the MCP package the way an + * installer would - 'xchain-mcp/server.js' - not the in-repo relative path + * mcp/server.js also supports for its own dev loop. + * + ********************************************************************/ + +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const test = require('node:test'); + +const repoRoot = path.resolve(__dirname, '..', '..'); +const mcpRoot = path.join(repoRoot, 'mcp'); + +function npm(args, options = {}) { + const npmCli = process.env.npm_execpath; + const command = npmCli ? process.execPath : 'npm'; + const commandArgs = npmCli ? [npmCli, ...args] : args; + return execFileSync(command, commandArgs, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + ...options, + }); +} + +function pack(packageRoot, destination) { + const result = JSON.parse(npm([ + 'pack', + '--json', + '--pack-destination', destination, + ], { + cwd: packageRoot, + env: { ...process.env, npm_config_cache: path.join(destination, '.npm-cache') }, + })); + assert.equal(result.length, 1); + return path.join(destination, result[0].filename); +} + +test('packed SDK and MCP packages load xchain-mcp/server.js after install', () => { + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xchain-mcp-pack-')); + try { + const sdkTarball = pack(repoRoot, installRoot); + const mcpTarball = pack(mcpRoot, installRoot); + npm([ + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + sdkTarball, + mcpTarball, + ], { + cwd: installRoot, + env: { ...process.env, npm_config_cache: path.join(installRoot, '.npm-cache') }, + }); + + const loaded = execFileSync(process.execPath, ['-e', [ + "const server = require('xchain-mcp/server.js');", + "const sdk = require('@dankest-llc/xchain-sdk/package.json');", + "if (typeof server.buildServer !== 'function') process.exit(2);", + 'process.stdout.write(sdk.version);', + ].join('')], { + cwd: installRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + assert.equal(loaded, require('../../package.json').version); + } finally { + fs.rmSync(installRoot, { recursive: true, force: true }); + } +}); diff --git a/mcp/test/x402_fail_closed.test.js b/mcp/test/x402_fail_closed.test.js new file mode 100644 index 00000000..a2f8f944 --- /dev/null +++ b/mcp/test/x402_fail_closed.test.js @@ -0,0 +1,85 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * + * X402Gateway.guard()/middleware() fail-closed path: a throwing verify() + * must answer 500 (or 503 for X402_STATE_CORRUPT), return false, and never + * invoke `next`. Lives under mcp/ (a separately published, siblings-free + * package) so it needs only node:test, no mocha/sinon devDependency. + * + ********************************************************************/ + +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { X402Gateway, X402_VERSION } = require('../../src/utils/x402.js'); + +function response() { + return { + headers: {}, + statusCode: 200, + body: null, + setHeader(name, value) { this.headers[name] = value; }, + end(body) { this.body = body; }, + }; +} + +function paymentRequest() { + const proof = Buffer.from(JSON.stringify({ + x402Version: X402_VERSION, + scheme: 'xchain-send', + coin: 'TDOGE', + })).toString('base64url'); + return { headers: { 'x-payment': proof }, url: '/paid' }; +} + +function throwingGateway(code) { + const gateway = new X402Gateway({ + coin: 'TDOGE', + explorer: {}, + requireSignature: false, + send: { tick: 'TOK', amount: '1', payTo: 'gate' }, + invoiceStore: {}, + }); + gateway.verify = async () => { + const error = new Error('verification failed'); + if (code) error.code = code; + throw error; + }; + return gateway; +} + +for (const scenario of [ + { code: undefined, status: 500, responseCode: 'X402_ERROR' }, + { code: 'X402_STATE_CORRUPT', status: 503, responseCode: 'X402_STATE_CORRUPT' }, +]) { + test(`x402 middleware fails closed with ${scenario.status} when verify throws`, async () => { + const res = response(); + let nextCalls = 0; + const paid = await throwingGateway(scenario.code).middleware()( + paymentRequest(), + res, + () => { nextCalls += 1; }, + ); + + assert.equal(paid, false); + assert.equal(res.statusCode, scenario.status); + assert.equal(res.headers['Content-Type'], 'application/json'); + assert.deepEqual(JSON.parse(res.body), { + error: 'payment verification error', + code: scenario.responseCode, + }); + assert.equal(nextCalls, 0); + }); +} diff --git a/package-lock.json b/package-lock.json index dc8b1ce7..fd4b15a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dankest-llc/xchain-sdk", - "version": "0.20.0", + "version": "0.20.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dankest-llc/xchain-sdk", - "version": "0.20.0", + "version": "0.20.1", "license": "AGPL-3.0-or-later", "dependencies": { "@bitcoinerlab/secp256k1": "^1.2.0", diff --git a/package.json b/package.json index 299adaf9..4f011972 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@dankest-llc/xchain-sdk", "description": "xchain-sdk generates XChain Platform ACTION commands and encodes them into unsigned blockchain transactions as well as pulls data from the XChain APIs such as balances and transaction history.", - "version": "0.20.0", + "version": "0.20.1", "comment:homepage": "The npm package page links wherever this points, and it points at the platform site (xchain.io's deep-link backlinks 301 away to tokenscan.io, so operator-controlled touchpoints are what still build its authority).", "homepage": "https://xchain.io/", "publishConfig": { @@ -25,7 +25,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/XChain-Platform/xchain-sdk.git" + "url": "git+https://github.com/XChain-Platform/xchain-sdk.git" }, "dependencies": { "@bitcoinerlab/secp256k1": "^1.2.0", diff --git a/src/XChainSDK.js b/src/XChainSDK.js index 1156fc16..4b319ea6 100644 --- a/src/XChainSDK.js +++ b/src/XChainSDK.js @@ -86,9 +86,9 @@ function initializeNetworkUtilities(sdk, network) { sdk.auth = new AuthUtils(network); sdk.messaging = new MessagingUtils(network); sdk.gatedFile = new GatedFileUtils(); - // FILE payload compression. Stateless, no network: - // deflate-raw compress/inflate with the fail-closed, ratio-bounded - // read path every serve layer shares. + // FILE payload compression is stateless and needs no network. Every serve + // layer shares its fail-closed, ratio-bounded deflate-raw read path so a + // compressed payload cannot bypass the same expansion limit elsewhere. sdk.compression = new CompressionUtils(); } @@ -98,7 +98,7 @@ function initializeActionHelpers(sdk) { // LOCK_MAX_SUPPLY=1), collection child params, content-attach (LINK) params, // and the canonical isNft() classifier. No network. Submit-flow recipes that // compose these into live actions live on sdk.workflows (issueNft, etc.). - // Spec: protocol/NFT_Standard.md. + // Spec: protocol/nft-standard.md. sdk.nft = new NftHelpers(); // Project registry helpers: pure builders for owner-attested official-token // rosters (TICK-type LIST + LINK to the project's ISSUE). No network. diff --git a/src/XChainSDK/client_lifecycle.js b/src/XChainSDK/client_lifecycle.js index 7e95eeb5..b445dbcc 100644 --- a/src/XChainSDK/client_lifecycle.js +++ b/src/XChainSDK/client_lifecycle.js @@ -137,7 +137,7 @@ module.exports = { discover() { if (this._discovering) return this._discovering; this._discovering = (async () => { - await this.hub.getAllConfig(); + await this.hub.getDiscoveryConfig(); this.applyEndpoints(); this.startPollingOnce(); })(); diff --git a/src/actions/betting/payout_projection.js b/src/actions/betting/payout_projection.js index 58001f60..0800aeda 100644 --- a/src/actions/betting/payout_projection.js +++ b/src/actions/betting/payout_projection.js @@ -152,8 +152,11 @@ module.exports = { `${ctx}: the market's life must be a positive number of seconds (got ${secs})`, { field: 'durationSeconds', value: secs }); - // Half-up, matching bcdiv(seconds, 86400, 0) on-chain. - const days = mathjs.floor(mathjs.add(mathjs.divide(bn(secs), bn(86400)), bn(0.5))); + // Half-up twice, as the chain does: bcsub(until, blockTime, 0) rounds the + // seconds, then bcdiv(seconds, 86400, 0) rounds the days. Native .floor(), + // never mathjs.floor (its relTol rounds a near-integer quotient up). + const whole = bn(secs).plus(0.5).floor(); + const days = whole.div(86400).plus(0.5).floor(); const freeBn = bn(Number(freeDays)); const billable = days.gt(freeBn) ? mathjs.subtract(days, freeBn) : bn(0); const fee = mathjs.multiply(mathjs.multiply(billable, bn(Number(perDay))), bn(String(gasPrice))); diff --git a/src/actions/messaging/key_derivation.js b/src/actions/messaging/key_derivation.js index 8d46756c..3c013c78 100644 --- a/src/actions/messaging/key_derivation.js +++ b/src/actions/messaging/key_derivation.js @@ -46,8 +46,9 @@ module.exports = { // v1 KDF: HKDF-SHA256 over the raw ECDH product with a fixed protocol salt // and a per-method `info` label. The differing `info` per method is what - // guarantees cross-method domain separation: a key that leaks from one - // method cannot be replayed to read messages protected by the other. + // guarantees cross-method domain separation. The same ECDH product yields + // different method keys, so leaking one cannot expose messages protected + // by the other. hkdfFromEcdh(privateKey, publicKey, info) { let raw = this.ecdhProduct(privateKey, publicKey); return hkdfSha256(raw, HKDF_SALT, info, HKDF_KEY_LEN); diff --git a/src/actions/nft.js b/src/actions/nft.js index c2a97afd..0b958aa8 100644 --- a/src/actions/nft.js +++ b/src/actions/nft.js @@ -16,7 +16,7 @@ * * Non-fungible tokens on XChain are a composition of existing * primitives, not a special token type (see - * xchain-documentation/protocol/NFT_Standard.md). There is no NFT + * xchain-documentation/protocol/nft-standard.md). There is no NFT * action, flag, or wire format. An "NFT" is an ISSUE with DECIMALS=0 * and LOCK_MAX_SUPPLY=1. These helpers are the guided builders that * encode that rule in one place so every client mints NFTs correctly, @@ -25,6 +25,12 @@ * ********************************************************************/ +// Display roles an images[] entry may carry (TIS images.type enum). +const TIS_IMAGE_ROLES = ['icon', 'standard', 'large', 'hires']; + +// Match a type/subtype MIME string, which TIS forbids in images[].type. +const MIME_SHAPE = /^[\w.+-]+\/[\w.+-]+$/; + class NftHelpers { // Build ISSUE params for a unique 1-of-1, fully minted to the issuer. @@ -109,47 +115,77 @@ class NftHelpers { }; } - // Build a minimal TIS v1.0.0 document for an NFT-pattern token - // (Token_Information_Standard.md). Intended for the fully on-chain + // Build a minimal TIS v1.1.1 document for an NFT-pattern token + // (token-information-standard.md). Intended for the fully on-chain // authoring path: upload the returned JSON as a FILE action // (TYPE application/json) and set the token's DESCRIPTION to // "action:" of that upload (the On-Chain Format). // // tick - the token's TICK (required) - // name - display name (optional) + // name - display name (required by the TIS schema) // description - prose description (optional) // imageActionIndex - on-chain artwork FILE to reference via data_ref (optional) // imageCoin - base coin ticker (BTC/LTC/DOGE) when the artwork FILE // lives on a SIBLING chain (optional; omitted = same // chain as the token; network tier is implied) // imageUrl - off-chain artwork URL (optional; data_ref preferred when both) - // imageType - artwork MIME type (optional) + // imageRole - TIS display role for images[].type: icon, standard, + // large or hires (optional; default standard) + // imageType - deprecated alias for imageRole; a MIME value is ignored, + // since the media type comes from the FILE action's TYPE // imageName - artwork filename (optional) // // Returns { doc, json }: the document object and its serialized form. - tisDocument({ tick, name, description, imageActionIndex, imageCoin, imageUrl, imageType, imageName } = {}) { + tisDocument({ tick, name, description, imageActionIndex, imageCoin, imageUrl, imageRole, imageType, imageName } = {}) { if (!tick) throw new Error('nft.tisDocument: tick is required'); + NftHelpers.assertTisName(name); + let role = NftHelpers.tisImageRole(imageRole, imageType); let doc = { tick: String(tick).toUpperCase(), + name: String(name), // Declare collectible intent so clients distinguish an NFT from a // currency that shares the same field values (TIS, NFT Usage). categories: [{ type: 'main', data: 'NFT' }] }; - if (name) doc.name = String(name); if (description) doc.description = String(description); if (imageActionIndex !== undefined && imageActionIndex !== null || imageUrl) { let entry = {}; if (imageActionIndex !== undefined && imageActionIndex !== null) entry.data_ref = 'action:' + (imageCoin ? String(imageCoin).toUpperCase() + ':' : '') + String(imageActionIndex); if (imageUrl) entry.data = String(imageUrl); - if (imageType) entry.type = String(imageType); + entry.type = role; if (imageName) entry.name = String(imageName); doc.images = [entry]; } return { doc, json: JSON.stringify(doc) }; } - // Canonical NFT classification (NFT_Standard.md): a token follows the NFT + // Refuse a TIS document without a display name (schema-required; never invented). + static assertTisName(name) { + if (name === undefined || name === null || !String(name).trim()) + throw new Error('nft.tisDocument: name is required'); + } + + // Resolve the images[] display role (TIS v1.1.1 images.type enum). + static tisImageRole(imageRole, imageType) { + if (imageRole !== undefined && imageRole !== null && imageRole !== '') { + let role = String(imageRole); + if (!TIS_IMAGE_ROLES.includes(role)) + throw new Error('nft.tisDocument: imageRole must be one of ' + TIS_IMAGE_ROLES.join(', ') + + '; the media type comes from the referenced FILE action TYPE'); + return role; + } + if (imageType !== undefined && imageType !== null && imageType !== '') { + let legacy = String(imageType); + if (TIS_IMAGE_ROLES.includes(legacy)) return legacy; + if (!MIME_SHAPE.test(legacy)) + throw new Error('nft.tisDocument: imageType must be one of ' + TIS_IMAGE_ROLES.join(', ') + + '; the media type comes from the referenced FILE action TYPE'); + } + return 'standard'; + } + + // Canonical NFT classification (nft-standard.md): a token follows the NFT // pattern when it is indivisible (DECIMALS=0) and permanently supply-capped // (LOCK_MAX_SUPPLY=1). Accepts a token-info object using either UPPER_SNAKE // (explorer/indexer shape) or camelCase keys. diff --git a/src/actions/workflows/bridge.js b/src/actions/workflows/bridge.js index 8f54adbc..0d49614c 100644 --- a/src/actions/workflows/bridge.js +++ b/src/actions/workflows/bridge.js @@ -24,6 +24,23 @@ const Utility = require('../../utils/utility.js'); const coins = require('../../coins'); const Config = require('../../config.js'); +// Submit a bridge recipe, routing each half of the caller's opts to the layer +// that reads it. WalletSession.submit takes encoder options and submission +// options as SEPARATE arguments, so a recipe that hands its whole opts object +// to the third slot silently drops a feePerKb or an unconfirmed-input request: +// the encoder never sees them and the fee is whatever the default says. The +// `encoder` sub-object is lifted out and passed as the encoder argument; a +// caller who supplies no `encoder` key gets `{}` there, which is exactly the +// literal every recipe passed before, so nothing existing changes behaviour. +function submitBridge(workflows, wif, actionData, opts) { + let submitOpts = { ...opts }; + let encoderOpts = submitOpts.encoder || {}; + delete submitOpts.encoder; + + let session = workflows.sdk.session(wif, submitOpts); + return session.submit(actionData, encoderOpts, submitOpts); +} + module.exports = { // Open a market and immediately place the oracle's own opening context in a // single recipe is deliberately NOT offered: the oracle may not bet on its @@ -80,29 +97,32 @@ module.exports = { // // wif - WIF private key of the holder // params - { destCoin, destAddress, amount, memo } - // opts - submit options (network, waitForIndexer, timeout, ...) + // opts - submit options (network, waitForIndexer, timeout, maxFeeSats, ...) + // plus encoder: { feePerKb, unconfirmed, ... } // // Returns: async bridgeLock(wif, params, opts = {}) { let fields = this.bridgeParams(params); this.assertBridgeAddress('DEST_ADDRESS', fields.DEST_ADDRESS, fields.DEST_COIN, this.bridgeNetwork(opts)); - let session = this.sdk.session(wif, opts); - return session.submit({ action: 'XBRIDGE', params: Utility.withForcedVersion('0', fields) }, {}, opts); + return submitBridge(this, wif, + { action: 'XBRIDGE', params: Utility.withForcedVersion('0', fields) }, opts); }, // Burn bridged XCHAIN off BTC to release the BTC escrow (XBRIDGE v1, never on BTC). // // params - { btcAddress, amount, memo } + // opts - as bridgeLock, including encoder: { feePerKb, unconfirmed, ... } async bridgeBurn(wif, params, opts = {}) { let fields = this.bridgeParams(params); this.assertBridgeAddress('BTC_ADDRESS', fields.BTC_ADDRESS, 'BTC', this.bridgeNetwork(opts)); - let session = this.sdk.session(wif, opts); - return session.submit({ action: 'XBRIDGE', params: Utility.withForcedVersion('1', fields) }, {}, opts); + return submitBridge(this, wif, + { action: 'XBRIDGE', params: Utility.withForcedVersion('1', fields) }, opts); }, // Lock a general token on its origin chain (XBRIDGE v3). // // params - { tick, destCoin, destAddress, amount, memo } + // opts - as bridgeLock, including encoder: { feePerKb, unconfirmed, ... } // // TICK is the NATIVE name on this chain. A rooted name (BTC.FUFU) is the // bridged copy and is refused here rather than on chain, because v3 of a @@ -117,13 +137,14 @@ module.exports = { if (tick.indexOf('.') !== -1) throw new Error('XBRIDGE v3 cannot bridge "' + tick + '": a dotted tick is a subasset or a bridged copy, and neither is bridgeable in this milestone'); this.assertBridgeAddress('DEST_ADDRESS', fields.DEST_ADDRESS, fields.DEST_COIN, this.bridgeNetwork(opts)); - let session = this.sdk.session(wif, opts); - return session.submit({ action: 'XBRIDGE', params: Utility.withForcedVersion('3', fields) }, {}, opts); + return submitBridge(this, wif, + { action: 'XBRIDGE', params: Utility.withForcedVersion('3', fields) }, opts); }, // Burn a bridged token row back to its origin chain (XBRIDGE v4). // // params - { tick, originAddress, amount, memo } + // opts - as bridgeLock, including encoder: { feePerKb, unconfirmed, ... } // // The origin chain is the tick's own root (BTC.FUFU burns back to BTC), so // ORIGIN_ADDRESS is validated against THAT chain and never against the chain @@ -135,13 +156,14 @@ module.exports = { if (!parsed) throw new Error('XBRIDGE v4 needs a bridged tick of the form .; got "' + String(fields.TICK) + '"'); this.assertBridgeAddress('ORIGIN_ADDRESS', fields.ORIGIN_ADDRESS, parsed.origin, this.bridgeNetwork(opts)); - let session = this.sdk.session(wif, opts); - return session.submit({ action: 'XBRIDGE', params: Utility.withForcedVersion('4', fields) }, {}, opts); + return submitBridge(this, wif, + { action: 'XBRIDGE', params: Utility.withForcedVersion('4', fields) }, opts); }, // Set a token's bridgeability (ISSUE format 7). Owner only, no issuance fee. // // params - { tick, bridgeChains, minDepth, lockBridge, memo } + // opts - as bridgeLock, including encoder: { feePerKb, unconfirmed, ... } // // bridgeChains is a comma list of destination coins, or the '-' sentinel for // none. An EMPTY field means UNCHANGED on chain, so an empty string is refused @@ -159,8 +181,8 @@ module.exports = { throw new Error('ISSUE v7 BRIDGE_CHAINS names an unsupported coin: ' + chain); fields.BRIDGE_CHAINS = chains.map(c => c.toUpperCase()).join(','); } - let session = this.sdk.session(wif, opts); - return session.submit({ action: 'ISSUE', params: Utility.withForcedVersion('7', fields) }, {}, opts); + return submitBridge(this, wif, + { action: 'ISSUE', params: Utility.withForcedVersion('7', fields) }, opts); }, // Throw unless the chunked deploy's Phase-2 assembler fits the compiled-action diff --git a/src/actions/workflows/content_votes_markets.js b/src/actions/workflows/content_votes_markets.js index 6a3690b7..38c71e0d 100644 --- a/src/actions/workflows/content_votes_markets.js +++ b/src/actions/workflows/content_votes_markets.js @@ -77,15 +77,23 @@ module.exports = { // file: { name, type, title?, memo?, rawData }, // FILE upload // memo?, // LINK memo // tis?: { // OPTIONAL: also author the on-chain TIS - // tick, // document (Token_Information_Standard.md - // name?, description? // On-Chain Format) and point the token's - // } // DESCRIPTION at it via ISSUE v1 + // tick, name, // document (token-information-standard.md + // description?, // On-Chain Format) and point the token's + // imageRole? // DESCRIPTION at it via ISSUE v1; imageRole + // } // is the TIS display role (default standard) // } // Requires indexer confirmation (waitForIndexer) so each leg's ACTION_INDEX // is resolvable for the next. // // Returns: { file, link, tisFile?, describe? } (each a ) async attachContent(wif, params, opts = {}) { + // Validate the TIS request before any leg broadcasts, so a refusal strands nothing on-chain. + if (params.tis) this.sdk.nft.tisDocument({ + tick: params.tis.tick, + name: params.tis.name, + description: params.tis.description, + imageRole: params.tis.imageRole + }); let session = this.sdk.session(wif, opts); return this.withPartial({ file: null, link: null }, async (p) => { p.file = await session.file({ @@ -115,7 +123,7 @@ module.exports = { name: params.tis.name, description: params.tis.description, imageActionIndex: fileActionIndex, - imageType: params.file.type, + imageRole: params.tis.imageRole, imageName: params.file.name }); p.tisFile = await session.file({ diff --git a/src/api/index.js b/src/api/index.js index 496aed1b..3b9d3dba 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -31,7 +31,8 @@ const { parseCorsOrigin } = require('../utils/cors_origin.js'); const Config = require('../config.js'); // Request guards live in their own module so the shipped middleware has exactly // one implementation: the guard tests mount src/utils/api_guards.js directly and -// pin, by reading this file, that createApp wires the same functions in order. +// pin, from the router stack of the app createApp builds, that it wires the same +// functions in order. const { parseWholeNumber, resolveMaxBatch, diff --git a/src/carrier/verify_carrier_scripts.js b/src/carrier/verify_carrier_scripts.js index f6f91ad9..d1506972 100644 --- a/src/carrier/verify_carrier_scripts.js +++ b/src/carrier/verify_carrier_scripts.js @@ -17,9 +17,9 @@ // cannot: its payload lives in redeem scripts that exist only inside the // encoder, and the commit outputs are just hashes of them. So the one // encoding that carries the LARGEST payloads was also the one the wallet -// had to take on trust: a compromised encoder could have swapped the payload -// and nothing on the signing side would have noticed. That gap is what this -// file closes. +// had to take on trust: a compromised encoder could swap the payload while +// leaving the signer to approve only its opaque hash. Checking both the script +// commitment and its content closes that gap on the signing side. // // The encoder now returns those scripts (`carrierScripts`). Verification is // therefore a check, not a re-derivation - deliberately, because a second diff --git a/src/clients/explorer/activity_reads.js b/src/clients/explorer/activity_reads.js index 83be3413..50aa6ebb 100644 --- a/src/clients/explorer/activity_reads.js +++ b/src/clients/explorer/activity_reads.js @@ -26,7 +26,7 @@ module.exports = { }, // Coin-prefix for a sibling chain at THIS client's network tier: - // RBTC client + 'DOGE' → 'RDOGE' (Token_Information_Standard.md: + // RBTC client + 'DOGE' → 'RDOGE' (token-information-standard.md: // cross-chain action refs carry the base ticker; the tier is implied). siblingCoin(baseCoin) { if (!baseCoin) return this.coin; diff --git a/src/clients/hub.js b/src/clients/hub.js index 28c64f61..c791fdb9 100644 --- a/src/clients/hub.js +++ b/src/clients/hub.js @@ -192,7 +192,7 @@ class HubConnector { if (this._pollTimer) return; this._pollTimer = setInterval(async () => { try { - await this.getAllConfig(); + await this.getDiscoveryConfig(); if (callback) callback(this.configs); } catch (err) { // Silently continue: hub unavailability during polling is non-fatal diff --git a/src/clients/hub/config_sync.js b/src/clients/hub/config_sync.js index db035db2..046bb147 100644 --- a/src/clients/hub/config_sync.js +++ b/src/clients/hub/config_sync.js @@ -31,6 +31,41 @@ const { const { getLogger } = require('../../observability/logger.js'); const log = getLogger('xchain-sdk:hub'); +const CHAIN_REGISTRY_PATH = '/api/v1/chain-registry'; + +function normalizeServiceDefaults(descriptor, service) { + let defaults = descriptor[service]; + if (!defaults || typeof defaults !== 'object' || + typeof defaults.defaultUrl !== 'string' || !defaults.defaultUrl) + throw new Error('chain registry descriptor ' + descriptor.id + ' has no ' + service + ' default URL'); + + let normalized = { host: defaults.defaultUrl }; + if (defaults.defaultPort !== undefined && defaults.defaultPort !== null) + normalized.port = defaults.defaultPort; + return normalized; +} + +function normalizeDiscoveryDescriptors(descriptors) { + if (!Array.isArray(descriptors)) throw new Error('chain registry response has no descriptors array'); + + let configs = {}; + for (const descriptor of descriptors) { + if (!descriptor || typeof descriptor !== 'object' || + typeof descriptor.coin !== 'string' || !descriptor.coin || + typeof descriptor.networkKind !== 'string' || !descriptor.networkKind) + throw new Error('chain registry contains an invalid descriptor'); + + let coin = descriptor.coin; + let network = descriptor.networkKind; + if (!configs[coin]) configs[coin] = {}; + configs[coin][network] = { + 'xchain-explorer': normalizeServiceDefaults(descriptor, 'explorer'), + 'xchain-encoder': normalizeServiceDefaults(descriptor, 'encoder') + }; + } + return configs; +} + async function fetchConfigResult(connector, url, cursorValid, sinceCursor, headers) { let result = await connector.postGetAllConfigs(url, sinceCursor, headers); if (result === undefined) return undefined; @@ -71,6 +106,44 @@ async function fetchConfigResult(connector, url, cursorValid, sinceCursor, heade } module.exports = { + // Public SDKs do not have the key required by getallconfigs. Discover from + // the public chain registry in that case, while keyed mesh clients retain + // the full JSON-RPC config path and its incremental polling behavior. + async getDiscoveryConfig() { + if (this.apiKey) return this.getAllConfig(); + + let lastError = null; + for (let i = 0; i < this.urls.length; i++) { + let idx = (this._lastGoodIdx + i) % this.urls.length; + let url = this.urls[idx]; + try { + // Hub URLs can carry a coin routing path such as /TBTC. The + // registry is mounted once at the origin, outside that route. + let registryUrl = new URL(CHAIN_REGISTRY_PATH, url).toString(); + let response = await axios.get(registryUrl, { + timeout: this.timeout, + ...agentOptsFor(registryUrl, this._pool) + }); + let configs = normalizeDiscoveryDescriptors(response.data && response.data.descriptors); + this._lastGoodIdx = idx; + this.configs = configs; + this.lastSeq = 0; + this.lastWatermark = 0; + this._watermarkEndpointIdx = null; + this.lastFetch = Date.now(); + return this.configs; + } catch (err) { + lastError = err; + } + } + + throw new SDKHubError( + 'HUB_UNAVAILABLE', + 'Failed to fetch discovery config from hub (tried ' + this.urls.length + ' endpoint(s)): ' + (lastError ? lastError.message : 'no result'), + { urls: this.urls, error: lastError ? lastError.message : 'no result' } + ); + }, + // Fetch all configs from the hub via JSON-RPC (tries each endpoint in order) async getAllConfig() { let lastError = null; diff --git a/src/config.js b/src/config.js index d1bba682..b4fe0350 100644 --- a/src/config.js +++ b/src/config.js @@ -43,8 +43,8 @@ const NUMBER_FIELDS = [ 'COOLDOWN_BLOCKS', 'DECIMALS', // DEPOSIT / GAS_ESCROW are the VOTE v0 poll-creator XCHAIN escrow - // amounts; without canonicalization String(0.00000001) puts "1e-8" - // on the wire. + // amounts. Canonicalization keeps String(0.00000001) from putting "1e-8" + // on the wire, where a numerically equal value has a different spelling. 'DEPOSIT', 'DISPENSER_ACTION_INDEX', 'EDIT', diff --git a/src/consensus/equivocation_header.js b/src/consensus/equivocation_header.js index 48e11991..c043bdec 100644 --- a/src/consensus/equivocation_header.js +++ b/src/consensus/equivocation_header.js @@ -18,11 +18,15 @@ * EQUIV||||| * * The canonical source of record is - * xchain-documentation/protocol/reference-impl/equivocation_header.js; it is - * vendored BYTE-IDENTICALLY into xchain-hub, xchain-indexer, xchain-explorer, - * xchain-sdk and xchain-sync. Every PBFT/consensus engine prefixes its canonical - * through here, the settlement gates (cross_settle, xexec, xcall, anchor, price, - * attest) and the recovery verifier re-derive it to re-verify quorum signatures, + * xchain-indexer/src/consensus/equivocation_header.js; it is vendored + * BYTE-IDENTICALLY into xchain-hub, xchain-explorer, xchain-sdk, xchain-sync and + * xchain-documentation/protocol/reference-impl/consensus/equivocation_header.js. + * Edit the indexer copy only and re-run reconcile-twins.sh to re-vendor every + * other copy; never edit a vendored copy. + * + * Every PBFT/consensus engine prefixes its canonical through here, the settlement + * gates (cross_settle, xexec, xcall, anchor, price, attest) and the recovery + * verifier re-derive it to re-verify quorum signatures, * and the SLASH v0 action verifies equivocation proofs against it. Adding `` * makes equivocation provable WITHOUT false-positiving honest view changes (which * re-sign different content for the same round under a different view). Equivocation @@ -38,8 +42,9 @@ * the same anchor. The cross-service conformance suite * (ConsensusPrimitiveConformance.test.js, driven by * xchain-documentation/protocol/test-vectors) runs in every repo and asserts BOTH the - * behavior (canonical vectors) AND byte-identity of the local copy to this canonical - * source, so any unmirrored edit fails CI everywhere (a divergence forks the chain). + * behavior (canonical vectors) AND byte-identity of the local copy to the + * xchain-documentation copy, so any unmirrored edit fails CI everywhere (a + * divergence forks the chain). * ********************************************************************/ diff --git a/src/consensus/gate_registry/shared_rows_1.js b/src/consensus/gate_registry/shared_rows_1.js index bf311be0..568d8ff5 100644 --- a/src/consensus/gate_registry/shared_rows_1.js +++ b/src/consensus/gate_registry/shared_rows_1.js @@ -295,11 +295,11 @@ addGate('anchor_reward_activation.ANCHOR_ATTEST_ARRIVAL_MARGIN_S', 'constant', 6 // that window. addGate('anchor_reward_activation.ANCHOR_ATTEST_BARRIER_ACTIVATION', 'height', { mainnet: null, // INERT under the 2026-08-29 mainnet write hold - // SIZED 2026-09-16 20:41Z, on the BTC clock because this member is BTC-only: the same - // instant as the family's BTC CONSUMER height, so the one member that keeps BOTH - // certificates gains them together rather than carrying a lone extra rule for 6 h. - // Above the same roll and the same epoch close; the canon carries the measurement. - testnet: 153266, + // SIZED 2026-09-16 20:41Z, RE-SLID 2026-09-19 and 2026-09-23, on the BTC clock because this + // member is BTC-only: the same instant as the family's BTC CONSUMER height, so the one + // member that keeps BOTH certificates gains them together rather than carrying a lone extra + // rule for 6 h. The canon carries the measurement. + testnet: 154291, regtest: UNPINNED, // shares the family's arming seam so one venue lever arms both }); diff --git a/src/consensus/gate_registry/shared_rows_2.js b/src/consensus/gate_registry/shared_rows_2.js index c7fd765d..0063a165 100644 --- a/src/consensus/gate_registry/shared_rows_2.js +++ b/src/consensus/gate_registry/shared_rows_2.js @@ -216,9 +216,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', { 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, - 'BTC:testnet': 153222, // SIZED 2026-09-16 20:41Z: epoch close 153,216 + 6 buried; tip 152,756 + 466 at 498.7 s/blk, about 64.5 h - 'LTC:testnet': 4891504, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 4,889,190 + 2314 at 82.5 s/blk - 'DOGE:testnet': 67911796, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 67,904,912 + 6884 at 27.7 s/blk + 'BTC:testnet': 154234, // RE-SLID 2026-09-23: train 154,074 + 160 blocks (17 h at the 383.04 s/blk bound, 25.6 h at the 575.89 s/blk 84 h trailing mean), the v0.20.1 patch reslide + 'LTC:testnet': null, // disabled for v0.20.1, 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train + 'DOGE:testnet': 67936053, // RE-SLID 2026-09-23: tip 67,924,397 at 17:48Z + 11656 blocks (83.8 h at 25.89 s/blk, the 84 h trailing mean, the same instant as the BTC producer), the v0.20.1 patch reslide 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration @@ -228,9 +228,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, - 'BTC:testnet': 153266, // its producer + 44 blocks, about 6 h: strictly above, never equal - 'LTC:testnet': 4891766, // its producer + 262 blocks, about 6 h at 82.5 s/blk - 'DOGE:testnet': 67912575, // its producer + 779 blocks, about 6 h at 27.7 s/blk + 'BTC:testnet': 154291, // RE-SLID 2026-09-23: its producer + 57 blocks (6 h at the 383.04 s/blk bound, 9.1 h at the 575.89 s/blk 84 h trailing mean), strictly above, never equal + 'LTC:testnet': null, // disabled for v0.20.1, 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train + 'DOGE:testnet': 67936888, // RE-SLID 2026-09-23: its producer + 835 blocks (6 h at 25.89 s/blk, the 84 h trailing mean), strictly above, never equal 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration diff --git a/src/consensus/gate_registry/shared_rows_5.js b/src/consensus/gate_registry/shared_rows_5.js index cbf28976..fde6a96c 100644 --- a/src/consensus/gate_registry/shared_rows_5.js +++ b/src/consensus/gate_registry/shared_rows_5.js @@ -108,9 +108,16 @@ addGate('train_activation.TRAIN_ACTIVATION', 'ruleset', { // the LTC leg of this family two days off its BTC counterpart a day after the first cut. // That lead is the rolling-upgrade window the fleet roll must finish inside (24x the 90 // minute roll budget), and every testnet mirror-admission height sits above it on the same - // BTC clock (the BTC producer at 153,222 is 106 blocks and about 17.0 h further up), so a - // node lacking this rule set halts before it can grade an admission-stamped row. - '0.20.0': { mainnet: 9999999999, testnet: 153116, regtest: 0 }, + // BTC clock, so a node lacking this rule set halts before it can grade an admission-stamped + // row. + // RE-SLID 2026-09-23 for the v0.20.1 patch train, after the live tips overran the + // 2026-09-19 slide before the freeze: margin is 40 h to the nearest armed height, converted + // at each coin's fastest defensible cadence. Chain_tip TBTC 153,698 at 2026-09-23T15:55Z + // + 376 blocks, ceil(40 h / 383.04 s per block, the least-squares bound). The + // mirror-admission family below re-slides onto the same instant plus its own 17 h and 6 h + // offsets. LTC:testnet mirror admission ships disabled on this train and is + // untouched by this reslide; it arms on a later train. + '0.20.0': { mainnet: 9999999999, testnet: 154074, regtest: 0 }, }); // xchain_bridge_activation diff --git a/src/consensus/snapshot_reorg_buffer.js b/src/consensus/snapshot_reorg_buffer.js index 28ea9dad..0f000387 100644 --- a/src/consensus/snapshot_reorg_buffer.js +++ b/src/consensus/snapshot_reorg_buffer.js @@ -62,11 +62,14 @@ * difference must not be "corrected" without its own flag-day. * * The canonical source of record is - * xchain-documentation/protocol/reference-impl/snapshot_reorg_buffer.js; it is - * vendored BYTE-IDENTICALLY into xchain-hub, xchain-indexer and xchain-sdk. The - * cross-service conformance suite (ConsensusPrimitiveConformance.test.js) runs in - * every one of those repos and asserts byte-identity of the local copy to this - * source, so an unmirrored edit fails CI everywhere. + * xchain-indexer/src/consensus/snapshot_reorg_buffer.js; it is vendored + * BYTE-IDENTICALLY into xchain-hub, xchain-sdk and + * xchain-documentation/protocol/reference-impl/consensus/snapshot_reorg_buffer.js. + * Edit the indexer copy only and re-run reconcile-twins.sh to re-vendor every + * other copy; never edit a vendored copy. The cross-service conformance suite + * (ConsensusPrimitiveConformance.test.js) runs in every one of those repos and + * asserts byte-identity of the local copy to the xchain-documentation copy, so + * an unmirrored edit fails CI everywhere. * ********************************************************************/ @@ -74,8 +77,23 @@ const { get, copy, activeAt } = require('./gate_registry'); +// The reorg-depth buffer every party in a federation must resolve capability +// snapshots at. 6 = the BTC confirmation depth the platform already treats as +// buried (XCHAIN_CONFIRMATIONS_BTC). CONSENSUS-CRITICAL: the hub subtracts this +// before every snapshot lookup and refuses to boot on mainnet/testnet when a local +// override diverges (CapabilitySnapshot._resolveReorgBuffer), so a verifier that +// buries by a different depth resolves a different set than the signer. const CANONICAL_REORG_BUFFER = copy('snapshot_reorg_buffer.CANONICAL_REORG_BUFFER'); +// Per-network activation height (LOCAL COPY of the canonical map maintained +// upstream, kept equal by the cross-service regression suite). Keyed on the +// BTC-anchored declared snapshot_block. +// +// ARMED at genesis on mainnet, testnet and regtest per the gate table: every +// network resolves through the buried height from block 0. Arming changes +// acceptance itself, so a one-sided or partially-rolled-out arm would fork the +// fleet rather than fix it; the gate table records the per-network ruling and +// the evidence for arming each network at genesis instead of a later height. const SNAPSHOT_BURIAL_ACTIVATION = copy('snapshot_reorg_buffer.SNAPSHOT_BURIAL_ACTIVATION'); // Whether a verifier must bury the declared snapshot_block before re-deriving the diff --git a/src/consensus/stake_weighted_quorum.js b/src/consensus/stake_weighted_quorum.js index 76fdd4ed..11665534 100644 --- a/src/consensus/stake_weighted_quorum.js +++ b/src/consensus/stake_weighted_quorum.js @@ -14,17 +14,20 @@ * * THE single, CONSENSUS-CRITICAL implementation of the stake-weighted quorum * predicate. The canonical source of record is - * xchain-documentation/protocol/reference-impl/stake_weighted_quorum.js; it is - * vendored BYTE-IDENTICALLY into xchain-hub, xchain-indexer, xchain-explorer, - * xchain-sdk and xchain-sync. Every PBFT tally engine, every settlement gate - * (cross_settle, xexec, xcall, anchor, price, attest), the recovery verifier and - * the client/explorer checkpoint verifiers resolve through this predicate so they - * can never drift; a divergence forks the chain. + * xchain-indexer/src/consensus/stake_weighted_quorum.js; it is vendored + * BYTE-IDENTICALLY into xchain-hub, xchain-explorer, xchain-sdk, xchain-sync and + * xchain-documentation/protocol/reference-impl/consensus/stake_weighted_quorum.js. + * Edit the indexer copy only and re-run reconcile-twins.sh to re-vendor every + * other copy; never edit a vendored copy. Every PBFT tally engine, every + * settlement gate (cross_settle, xexec, xcall, anchor, price, attest), the + * recovery verifier and the client/explorer checkpoint verifiers resolve through + * this predicate so they can never drift; a divergence forks the chain. * * The cross-service conformance suite (ConsensusPrimitiveConformance.test.js, * driven by xchain-documentation/protocol/test-vectors) runs in every repo and * asserts BOTH the behavior (canonical vectors) AND byte-identity of the local - * copy to this canonical source, so any unmirrored edit fails CI everywhere. + * copy to the xchain-documentation copy, so any unmirrored edit fails CI + * everywhere. * * Self-contained on mathjs bignumber (exact, never a JS double): the predicate is * a pure function with no injected utility instance, so it is identical in every diff --git a/src/contract/abi-core.js b/src/contract/abi-core.js index 32582d9a..b0eaa14c 100644 --- a/src/contract/abi-core.js +++ b/src/contract/abi-core.js @@ -14,14 +14,14 @@ /** * Contract-ABI extraction core (spec: xchain-documentation/protocol/ - * Contract_ABI.md), shared by the explorer's contract introspection and the + * contract-abi.md), shared by the explorer's contract introspection and the * SDK's ContractUtils.parseAbi. The abi block is display metadata self-declared * by the contract author: never validated against the code, never consensus. * Extraction is fail-closed: a dynamic or structurally wrong abi/version/ * methods yields null, while a malformed SINGLE method entry drops only that * method so one typo does not blank the whole ABI. * - * CANONICAL COPY: xchain-explorer/src/abi-core.js. The SDK vendors it + * CANONICAL COPY: xchain-explorer/src/contract/abi_core.js. The SDK vendors it * byte-identically at xchain-sdk/src/contract/abi-core.js (it cannot depend on * the explorer). Edit the canonical file and run * xchain-explorer/bin/sync-abi-core.sh; drift fails CI (the SDK's diff --git a/src/contract/utils.js b/src/contract/utils.js index 8682b9e5..1944b8e2 100644 --- a/src/contract/utils.js +++ b/src/contract/utils.js @@ -171,13 +171,13 @@ class ContractUtils { } // Extract the optional self-declared `abi` metadata block from contract - // source (spec: xchain-documentation/protocol/Contract_ABI.md). Fail-closed: + // source (spec: xchain-documentation/protocol/contract-abi.md). Fail-closed: // a dynamic or structurally wrong abi/version/methods returns null, while a // malformed SINGLE method entry drops only that method. The abi is display // metadata only, never validated against the code and never consensus. // - // Delegates to the vendored ./contract/abi-core.js (canonical copy: - // xchain-explorer/src/abi-core.js; byte-identity enforced by the + // Delegates to the vendored ./abi-core.js (canonical copy: + // xchain-explorer/src/contract/abi_core.js; byte-identity enforced by the // abi-core-drift unit test and the root bin/ci-all.sh guard). // Returns { version, methods } | null. Never throws. parseAbi(sourceCode) { diff --git a/src/cosigner/agent_session.js b/src/cosigner/agent_session.js index f67f8f82..4458b0bf 100644 --- a/src/cosigner/agent_session.js +++ b/src/cosigner/agent_session.js @@ -66,12 +66,11 @@ function validatePolicy(policy) { if (policy.idempotencyHours !== undefined && (!Number.isFinite(policy.idempotencyHours) || policy.idempotencyHours <= 0)) throw new SDKPolicyError('POLICY_INVALID', 'idempotencyHours must be a positive number'); - // Require at least one enforceable ceiling, BREAKING for a policy that ships - // an action allowlist with no monetary ceiling: such a policy bounds nothing, - // so an automated agent can execute an allowed value-moving action without - // limit. Running without a ceiling is an explicit, auditable opt-in rather - // than the state a caller reaches by omission. Same shape as the X402Client - // fail-closed default: the default is safe, unbounded is a word someone types. + // Require at least one enforceable ceiling. An action allowlist controls what + // an agent may do, not how much value it may move, so an allowlist without a + // monetary ceiling leaves every permitted action unbounded. Running that way + // is an explicit, auditable opt-in rather than a state reached by omission, + // matching the X402Client fail-closed default. // // The cap tables are tested for an ENFORCEABLE entry, not for truthiness: // `maxPerAction: {}`, `{ SEND: {} }` and `maxPerWindow.perTick: {}` are all diff --git a/src/cosigner/recovery.js b/src/cosigner/recovery.js index d0eb9bc0..d7824145 100644 --- a/src/cosigner/recovery.js +++ b/src/cosigner/recovery.js @@ -37,8 +37,9 @@ const ecc = require('@bitcoinerlab/secp256k1'); const { secp256k1 } = require('@noble/curves/secp256k1'); const MuSig2 = require('./musig2.js'); const { exactU64 } = require('./co_signer.js'); -// Teach bitcoinjs to serialize a satoshi value above 2^53. Idempotent via -// the module cache; without it a BigInt output value throws at write time. +// Teach bitcoinjs to serialize satoshi values above 2^53 without narrowing +// them through Number. The module cache makes the patch idempotent; without +// it, an exact BigInt output value throws when the transaction is written. require('../utils/apply_bufferutils_patch'); bitcoin.initEccLib(ecc); diff --git a/src/decoder/index.js b/src/decoder/index.js index 1dcc2aba..2394cf93 100644 --- a/src/decoder/index.js +++ b/src/decoder/index.js @@ -14,8 +14,8 @@ * * XChain Platform SDK - decoder (public barrel) * - * First-class decode library (spec §3). Two pure layers plus the PSBT - * primitive: + * The public decode library keeps parsing separate from human-readable + * description, then exposes the same parser through the PSBT primitive: * * parse(input, opts) action string -> ParsedAction * describe(parsed, ctx) ParsedAction -> {summary, details, warnings} diff --git a/src/preflight/INDEXER-MAP.md b/src/preflight/INDEXER-MAP.md index 051efff7..8526f86b 100644 --- a/src/preflight/INDEXER-MAP.md +++ b/src/preflight/INDEXER-MAP.md @@ -72,7 +72,8 @@ commit, not a re-pin back. **Pins taken at indexer commit:** `4fd091c7` -(Re-anchored 2026-09-16, eighth pass, by the activation-registry (W5) landing review below. +(Re-anchored 2026-09-16, eighth pass, for the activation-registry (W5) landing; the read is +logged in the 2026-09-22 seventh- and eighth-pass entry of the review log below. `4fd091c7` is the indexer tip that carries the W5 consolidation: the last thirteen predicate-only activation modules retired and the fourteen logic-bearing gate modules moved under `src/consensus/gates/`. One mapped directory drifted on a real byte change to a pre-existing @@ -85,8 +86,9 @@ Registry build; no activation window moves), so no client-visible validity logic `checks/` module needs an update - only the `issue/` hash below. The other ten rows are byte-identical at `c5f9ba85` and at `4fd091c7`. `c5f9ba85` stays reachable.) -(Re-anchored 2026-09-15, seventh pass, by the activation-registry (W4) landing review below. -`c5f9ba85` is the indexer tip that carries the W4 migration retiring five per-flag activation +(Re-anchored 2026-09-15, seventh pass, for the activation-registry (W4) landing; the read is +logged in the 2026-09-22 seventh- and eighth-pass entry of the review log below. +`c5f9ba85` is the indexer tip that carries the W4 migration retiring ten per-flag activation modules in favor of the shared registry aliased at `src/consensus/gate_registry.js` (itself `require('../protocol_changes')`): `consolidation_leg_amount_activation.js`, `gated_handoff_ref_activation.js`, `tick_namespace_activation.js`, `dispenser_freshness_activation.js`, @@ -389,7 +391,7 @@ behind by a move is a finding instead of the value that happens to be read. | `checks/send.js` (SEND) | `src/actions/send/` | `fb94d4c1808146cb427a0a02f8e277620f2f02a6fadb1b42d5b7f57c1812dfe4` | | `checks/send.js` (DESTROY) | `src/actions/destroy/` | `6f15d0f22e60fe328ff12188f519281b88aa61114ef1163b07df3bbba63d2d8f` | | `checks/mint.js` | `src/actions/mint/` | `7c8992a06f9143b876c5eb7bc554dbbe5b2ca61507c44e822b5491b8571d1c06` | -| `checks/issue.js` | `src/actions/issue/` | `9ff550c0f8105c557064f4168931d5d9e4761bab3527fa91b658054ed36ad78d` | +| `checks/issue.js` | `src/actions/issue/` | `b76a638d9c1906d95989052f07d3459902014db1bad5858fed398936b2827e6a` | | `checks/dispenser.js` (open/edit/close) | `src/actions/dispenser/` | `7f65cdaa58d433d3415f43f7b5997ae028baa00b6ba47fe38b11af392fcc04b9` | | `checks/dispenser.js` (DISPENSE) | `src/actions/dispense/` | `66b5a180f0829cbfc25a1c1e7b8b698a376c89c0ced5e5237e4cbb5059243311` | | `checks/trading.js` (ORDER) | `src/actions/order/` | `644dfe6951e78b653e185bb78201bfaeadc7b04eaab5cb7c1f4ffb2682b6cc79` | @@ -406,6 +408,55 @@ logic) are intentionally NOT mapped: there is nothing to drift from. A hash refresh is only honest if someone actually read the diff. What was read, and what it changed on the client side, goes here. +### 2026-09-22 (seventh and eighth passes, logged) - the activation-registry W4 and W5 re-anchors, read after the fact + +The two re-anchors of 2026-09-15 (seventh pass, `4d5e2d0a` to `c5f9ba85`) and 2026-09-16 +(eighth pass, `c5f9ba85` to `4fd091c7`) were written up only in their anchor notes above, +which pointed at a log entry that did not exist. This entry is that read, done on 2026-09-22 +against the committed trees, not a copy of the notes. + +Pins, each hashed from `git archive src/actions` by +`node bin/preflight_handler_dirs.js src/actions//` (never by hand), for all eleven +rows at all three commits. At `c5f9ba85` five rows moved: `send` `caf45dac` to `fb94d4c1`, +`destroy` `4fcdfcde` to `6f15d0f2`, `issue` `e75a3af1` to `848d23eb`, `dispenser` `fa6a3c6a` +to `7f65cdaa`, `dispense` `7a31e158` to `66b5a180`. At `4fd091c7` only `issue` moved, `848d23eb` +to `9ff550c0`. The other six rows are byte-identical across all three commits. Range read: +`git -C ../xchain-indexer log --name-status 4d5e2d0a..4fd091c7 -- src/actions/`, which reaches +the mapped directories through exactly two commits, `a55d59f4` (W4) and `5b84ef90` (W5), and the +full diffs `4d5e2d0a..c5f9ba85` and `c5f9ba85..4fd091c7` over those directories. + +**What moved: how each gate is reached, not what it decides.** Every non-comment change in the +ten W4 handler files and in `issue/wire.js` under W5 replaces a require of a per-flag +`src/_activation.js` module and its own `isActive(...)` call with +`gateRegistry.activeAt('_activation.', network, coin, blockIndex, blockTime)`, +passing the height or the time in the slot the retired predicate read it from and keeping the +coin for the two DISPENSER freshness gates. The only other non-comment change is `dispense/` +gaining `dispense_payment_tally_scale_gate.js`, the old shared +`src/dispense_payment_tally_scale_activation.js` moved into the directory, with its two +requirers (`dispense/context.js`, `dispense/pricing.js`) repointed at it. The W4 anchor note above says the migration retired five modules and +then names ten; ten is the count. + +Machine-verified. With both trees loaded against the indexer's own dependencies, each of the +eleven retired predicates (the nine W4 flags the mapped handlers read, and W5's +`isTokenBridgeActive` and `isTokenPolicyInheritanceActive`) was compared with its registry row +at every network (mainnet, testnet, regtest and an unknown name), every coin the freshness +gates key on, and at 0, 1, one below, at and one above each declared threshold: 820 points, +no disagreement. The same grid with the registry read one unit later disagrees at the +thresholds, so the comparison can fail. The moved tally-scale gate exports the same names and +constants, and its two functions agree with the retired copy at 784 points. + +**Direction: NEITHER, no admission boundary moves.** No threshold, fee, field, format version, +error string or activation window changed. NO CLIENT CHECK MOVES. + +### 2026-09-22 - two comment citations in `issue/` name the controller-bound-tokens spec by its current path + +The `issue` pin moves from `9ff550c0` to `b76a638d`, hashed from the committed indexer tree at +`f0c21e31`. The whole diff of `src/actions/issue/` since the `4fd091c7` anchor is two comment +lines, in `controller_binding.js` and `index.js`, that replace the retired +`Controller_Bound_Tokens.md` filename with `xchain-documentation/protocol/controller-bound-tokens.md`. + +**Direction: NEITHER, no admission boundary moves.** No code line changed. NO CLIENT CHECK MOVES. + ### 2026-09-15 (sixth pass) - `reservedRoots.js` becomes `reserved_roots.js`, one require line in `issue/` follows it Baseline pin was the `issue` directory digest `41f20369` at `0e53e6c7`; the new pin is diff --git a/src/preflight/checks/issue.js b/src/preflight/checks/issue.js index 06c568ff..9c6ec2b7 100644 --- a/src/preflight/checks/issue.js +++ b/src/preflight/checks/issue.js @@ -215,7 +215,8 @@ function checkBridgeOptIn(ctx, tick, token) { } } -// MAX_SUPPLY fractional precision, measured at the decimals CONSENSUS uses. +// Measure MAX_SUPPLY fractional precision with the decimals consensus uses, +// so pre-flight cannot approve a spelling the chain will reject after fees. // // The static validator range-checks `split('.')[0]` and throws the fraction away, so // MAX_SUPPLY=1.5 with DECIMALS=0 cleared the SDK and was then refused on-chain as diff --git a/src/preflight/checks/send.js b/src/preflight/checks/send.js index 04ae8441..c1708669 100644 --- a/src/preflight/checks/send.js +++ b/src/preflight/checks/send.js @@ -51,17 +51,19 @@ * into a total that passes: two 0.5 legs of a 0-decimals token used to * merge to '1' and settle. * - * It is declared, not mirrored, and the distinction is load-bearing. - * Predicting it needs the tick's DECIMALS *and* the activation state at - * the block that will carry this action, which pre-flight cannot read. - * The 2026-09-09 ruling armed it at genesis on EVERY network - the - * authority map reads `mainnet: 0` alongside testnet and regtest - * (registry row `consolidation_leg_amount_activation.CONSOLIDATION_LEG_AMOUNT_ACTIVATION` - * in xchain-indexer/src/protocol_changes/gates_1.js) - so the - * old reason for declaring it, that an unconditional error would reject - * on mainnet what mainnet still accepts, no longer holds. Promoting it - * to a mirrored error is a client-behaviour change with its own blast - * radius and is not made here; until then it stays declared. + * It is now MIRRORED, not merely declared. Predicting it needs the + * tick's DECIMALS and the activation state at the block that will carry + * this action; pre-flight can read the former (the token row) and, for + * this one gate, does not need the latter at all. The 2026-09-09 ruling + * armed consolidation_leg_amount_activation.CONSOLIDATION_LEG_AMOUNT_ACTIVATION + * (xchain-indexer/src/protocol_changes/gates_1.js) at genesis - height 0 - + * on EVERY network, mainnet included, so any including block, whose + * height is never negative, always satisfies it. The old reason this + * stayed declared, that an unconditional error would reject on mainnet + * what mainnet still accepts, no longer holds, so checkLegAmountFormat + * below raises AMOUNT_FORMAT_INVALID per leg once the tick's decimals + * are known, and the declaration that follows narrows to the residual + * case: a leg whose tick lookup fails. * * DESTROY has two BRIDGE supply-path refusals (the base bridge spec), * both UNCONDITIONAL on every plane and every height: the gas tick @@ -81,6 +83,7 @@ const numeric = require('../numeric.js'); const { ALLOWED_COINS } = require('../../coins/index.js'); const { GAS_TICK } = require('../../protocol/constants.js'); const { planeFromCoin } = require('./issue.js'); +const { tokenField } = require('./mint.js'); // Net multi-leg TICK/AMOUNT pairs into per-tick totals. SEND v1 // repeats AMOUNT under one TICK; v2/v3 repeat TICK too. @@ -132,18 +135,54 @@ async function checkBalanceCovers(ctx, verb) { } } -// Name the leg-amount consolidation rule as unverified. Deciding it needs the -// tick's DECIMALS and the activation state at the including block, so it is -// declared here rather than predicted (see the header on the false-block risk). +// LEG_AMOUNT_CONSOLIDATION mirror (see the header): consolidation_leg_amount_activation +// .CONSOLIDATION_LEG_AMOUNT_ACTIVATION is armed at genesis on every network, so a leg +// whose RAW amount does not fit its tick's DECIMALS is judged here per LEG, not on the +// perTickTotals() merge above, which is exactly the grouping the flag-day takes out of +// play for a badly-formatted leg. AMOUNT_FORMAT_INVALID is 'local' in TIER2_ERROR_CAPABLE +// (mint.js and issue.js already certify the same vendored rule the same way), so a +// resolvable decimals mismatch is a non-overridable error, never a warning. +async function checkLegAmountFormat(ctx, verb) { + const ticks = [].concat(ctx.params.TICK || []); + const amounts = [].concat(ctx.params.AMOUNT || []); + const n = Math.max(ticks.length, amounts.length); + ctx.markRun(FINDING_CODES.AMOUNT_FORMAT_INVALID); + for (let i = 0; i < n; i++) { + const tick = String(ticks[Math.min(i, ticks.length - 1)] || ''); + const amount = amounts[i] !== undefined ? String(amounts[i]) : ''; + if (!tick || amount === '') continue; + const token = await ctx.token(tick); + if (token === undefined) { + ctx.addUnverified(FINDING_CODES.AMOUNT_FORMAT_INVALID, `token lookup unavailable for ${tick}`); + continue; + } + if (token === null) continue; // nonexistent tick: TOKEN_NOT_FOUND owns this leg + const decimals = tokenField(token, ['supply.decimals', 'info.decimals', 'decimals', 'DECIMALS']); + if (decimals === null || decimals === undefined || decimals === '') continue; + if (!numeric.isValidAmountFormat(decimals, amount)) { + ctx.addFinding(FINDING_CODES.AMOUNT_FORMAT_INVALID, 'error', + `${verb} leg amount ${amount} for ${tick} is not a valid amount at ${decimals} decimals; ` + + 'above the leg-amount-consolidation activation (armed on every network since genesis) ' + + 'the indexer holds this leg out of the merge and rejects it on its own.', + { tick, amount, decimals }); + } + } +} + +// The residual, still-declared half of the rule: whether a leg's amount fits its +// tick's decimals when the tick's row cannot be read at all. checkLegAmountFormat +// above decides every leg whose token lookup succeeds. function declareLegAmountRule(ctx, verb) { ctx.addUnverified('LEG_AMOUNT_CONSOLIDATION', `above its flag-day, a ${verb.toLowerCase()} leg whose amount does not fit its tick's decimals is ` + 'rejected on its own instead of merging into a sibling leg; the rule is armed on every network, ' - + 'mainnet included, and the activation state of the including block is server-side only'); + + 'mainnet included, so this SDK raises AMOUNT_FORMAT_INVALID for it directly once the tick\'s ' + + 'decimals are known, and can only decide this when a tick\'s row is unreadable'); } async function checkSend(ctx) { await checkBalanceCovers(ctx, 'Send'); + await checkLegAmountFormat(ctx, 'Send'); declareLegAmountRule(ctx, 'Send'); ctx.addUnverified('SEND_RESTRICTIONS', 'sleep state, allow/block lists, controller-guard outcome, and the conditional gated-key handoff ' @@ -204,6 +243,7 @@ function checkBridgeSupplyPath(ctx) { async function checkDestroy(ctx) { await checkBalanceCovers(ctx, 'Destroy'); + await checkLegAmountFormat(ctx, 'Destroy'); declareLegAmountRule(ctx, 'Destroy'); checkBridgeSupplyPath(ctx); ctx.addUnverified('DESTROY_RESTRICTIONS', diff --git a/src/protocol/batch_limits/coinpay_output_plan.js b/src/protocol/batch_limits/coinpay_output_plan.js index 0828b14c..3bf8135a 100644 --- a/src/protocol/batch_limits/coinpay_output_plan.js +++ b/src/protocol/batch_limits/coinpay_output_plan.js @@ -138,7 +138,9 @@ function checkCoinpayOutputPlan(obligations, outputs) { } const rawAvailable = (output.amount === undefined || output.amount === null) ? '0' : output.amount; const available = mathjs.bignumber(String(rawAvailable)); - if (mathjs.smaller(available, total)) { + // Compare with decimal.js's exact .lt, NOT mathjs.smaller: its relTol band + // would read a one-satoshi shortfall on a large total as fully paid. + if (available.lt(total)) { violations.push({ payee, owed: mathjs.format(total, { notation: 'fixed' }), diff --git a/src/protocol/light_client/anchored_checkpoint.js b/src/protocol/light_client/anchored_checkpoint.js index 5291cf30..eaf32017 100644 --- a/src/protocol/light_client/anchored_checkpoint.js +++ b/src/protocol/light_client/anchored_checkpoint.js @@ -76,9 +76,10 @@ const DEFAULT_ANCHOR_MIN_DEPTH = 60; // passed to parseAnchorV0 or accepted by the fetchAnchoredCheckpoint filter. // // ACTIVATION: an ANCHOR of any version mined below ANCHOR_ACTIVATION[network] -// (protocol/constants.js; { mainnet: 0, testnet: 67858600, regtest: 0 }) is -// invalid on the wire and never reaches this parser; at/above it only 0/1/2 -// exist. The SDK trusts the indexer/explorer to have already applied that gate. +// (protocol/constants.js, which carries the per-network heights and why the +// mainnet one sits above the chain tip) is invalid on the wire and never +// reaches this parser; at/above it only 0/1/2 exist. The SDK trusts the +// indexer/explorer to have already applied that gate. // // Sections are variable-width (their signature lists differ), so the bundle is // walked with a cursor rather than read at the fixed offsets the retired v3/v5 diff --git a/src/protocol/light_client/fetch_helpers.js b/src/protocol/light_client/fetch_helpers.js index a41051ba..a557719a 100644 --- a/src/protocol/light_client/fetch_helpers.js +++ b/src/protocol/light_client/fetch_helpers.js @@ -89,15 +89,64 @@ function lowerHex(x){ return String(x == null ? '' : x).toLowerCase(); } // the next major version, so new callers should always pass it. Only the fields // present are compared, each as a string, so a numeric contract_index and its // decimal spelling agree. +// +// undefined and null are NOT equivalent. undefined means the caller left the +// field out of `expected` (nothing to bind), so it is skipped exactly like a +// field that was never a key of the object. An explicit null on an identity +// field is never a legitimate "don't care": a caller only produces one via a +// bug (an unresolved lookup, a bad default), and a `continue` past it +// would bind nothing, i.e. verify a proof for a different identity than the +// caller thought it asked for. So it throws instead of skipping; every caller +// of expectedMismatch (proof_checks.js) runs inside a try/catch that turns a +// thrown error into an unverified/refused result, so this fails closed rather +// than passing with the field unchecked. function expectedMismatch(expected, actual){ if (!expected) return null; for (const field of Object.keys(expected)){ const want = expected[field]; - if (want === undefined || want === null) continue; + if (want === undefined) continue; + if (want === null) throw new TypeError('expectedMismatch: expected.' + field + ' is null, refusing to bind an unchecked identity field'); if (String(want) !== String(actual[field])) return field; } return null; } + +// Runnable unit case for the null-refusal fix above: +// node src/protocol/light_client/fetch_helpers.js +// Exits 0 and prints "ok" lines on success, exits 1 on any failure. Kept in +// this file because it is the only writable surface for this row. +if (require.main === module){ + let failures = 0; + function check(name, fn){ + try { fn(); console.log('ok - ' + name); } + catch (e){ failures++; console.error('not ok - ' + name + ': ' + (e && e.message)); } + } + check('a null expected field throws instead of being skipped (was: continue, bound nothing)', () => { + let threw = null; + try { expectedMismatch({ address: null }, { address: 'bc1qsomeoneelse' }); } + catch (e){ threw = e; } + if (!threw) throw new Error('expectedMismatch did not throw for a null expected.address'); + if (!(threw instanceof TypeError)) throw new Error('expected a TypeError, got ' + threw); + }); + check('an undefined expected field is still skipped (back-compat, unchanged)', () => { + const result = expectedMismatch({ address: undefined, tick: 'DOGE' }, { address: 'bc1qanything', tick: 'DOGE' }); + if (result !== null) throw new Error('expected no mismatch for an undefined field, got ' + result); + }); + check('a genuine mismatch on a present field is still reported', () => { + const result = expectedMismatch({ tick: 'DOGE' }, { tick: 'BTC' }); + if (result !== 'tick') throw new Error('expected mismatched field "tick", got ' + result); + }); + check('a fully matching expected object still verifies clean', () => { + const result = expectedMismatch({ tick: 'DOGE', address: 'bc1qsame' }, { tick: 'DOGE', address: 'bc1qsame' }); + if (result !== null) throw new Error('expected no mismatch, got ' + result); + }); + if (failures > 0){ + console.error(failures + ' of 4 unit case(s) failed'); + process.exit(1); + } + console.log('all 4 fetch_helpers.js unit cases passed'); +} + function unverified(reason){ return { verified: false, amount: null, reason: reason }; } // ── Validator-set proof + forward-following (spec §7, Phase 5) ──────────────── // The keystone of a self-verifying client: instead of trusting an explorer for the diff --git a/src/utils/action_waiter.js b/src/utils/action_waiter.js index 498410e9..060b8cfe 100644 --- a/src/utils/action_waiter.js +++ b/src/utils/action_waiter.js @@ -47,7 +47,9 @@ let warnedUnknownStatus = false; class ActionWaiter { - // Keep encoding on the shared SDK while waiting on an optional target explorer. + // Keep encoding on the shared SDK while an optional target explorer watches + // the venue that actually indexes the transaction. A discovered shared + // explorer may describe another stack and can never confirm this venue's tx. constructor(sdk, opts = {}) { this.sdk = sdk; this.explorer = buildExplorer(sdk, opts); diff --git a/src/utils/apply_bufferutils_patch.js b/src/utils/apply_bufferutils_patch.js index 10345e2d..8691acd0 100644 --- a/src/utils/apply_bufferutils_patch.js +++ b/src/utils/apply_bufferutils_patch.js @@ -29,7 +29,8 @@ * a Number whenever the value is exactly representable and a BigInt only * above 2^53-1, so existing Number-based callers see identical behavior for * every value they could already handle. The READ-SIDE copies - * (xchain-decoder and xchain-utxo-tracker, src/apply_bufferutils_patch.js) + * (xchain-decoder/src/chain/apply_bufferutils_patch.js and + * xchain-utxo-tracker/src/chain/apply_bufferutils_patch.js) * lift the same 2^53 wall for block decode but deliberately implement a * DIFFERENT contract: BufferReader.readUInt64 always returns a BigInt and * the module-level readUInt64LE/writeUInt64LE helpers keep the stock 2^53-1 @@ -37,8 +38,9 @@ * * Re-sync seam (uuid:311476c7). Everything below this banner is pinned * byte-identical to the encoder copy's body by - * xchain-encoder/test/unit/apply_bufferutils_patch.test.js. Only the two headers - * differ, because each names the other and its own read-side relationship. + * xchain-encoder/test/unit/build/apply_bufferutils_patch.test.js. Only the two + * headers differ, because each names the other and its own read-side + * relationship. * Convergence runs SDK-ward: change this copy first, then copy its body to the * encoder, or the twin guard goes red. * diff --git a/src/utils/x402.js b/src/utils/x402.js index 48d4de5a..89641d31 100644 --- a/src/utils/x402.js +++ b/src/utils/x402.js @@ -206,6 +206,7 @@ class X402Gateway { return async (req, res, next) => { const paid = await this.guard(req, res); if (paid && next) next(); + return paid; }; } diff --git a/test/fixtures/action-manifest.json b/test/fixtures/action-manifest.json index 43a87125..a8343544 100644 --- a/test/fixtures/action-manifest.json +++ b/test/fixtures/action-manifest.json @@ -2,8 +2,8 @@ "$schema_note": "Authoritative registry of every XChain protocol ACTION and which repos must wire it. Single source of truth for the cross-repo action lockstep. Adding an action = add one entry here, re-vendor the copies, and the per-repo ActionManifestConformance guards force every repo to wire it (or fail CI). Generated from live wiring at HEAD; it codifies what IS, not an aspiration.", "authority": "xchain-documentation/protocol/action-manifest.json is authoritative. xchain-{decoder,encoder,indexer,sdk,wallet,explorer}/test/fixtures/action-manifest.json vendor byte-identical copies so each repo CI asserts its slice without a sibling checkout. Keep all copies identical.", "flags": { - "wireDecoded": "top-level ACTION-encoded on-chain tx the DECODER must decode (xchain-decoder VALID_ACTION_NAMES); also enforced pre-broadcast by the ENCODER's own VALID_ACTION_NAMES/ACTION_ALIASES gate (xchain-encoder src/validator.js)", - "indexerHandled": "the INDEXER dispatches a handler for it (xchain-indexer src/actions.js action== switch)", + "wireDecoded": "top-level ACTION-encoded on-chain tx the DECODER must decode (xchain-decoder VALID_ACTION_NAMES); also enforced pre-broadcast by the ENCODER's own VALID_ACTION_NAMES/ACTION_ALIASES gate (xchain-encoder src/common/validator/constants.js, applied in src/common/validator/action_data_checks.js)", + "indexerHandled": "the INDEXER dispatches a handler for it (xchain-indexer src/actions/actions_class/dispatch.js action== switch)", "userEncodable": "the SDK can author it (xchain-sdk Formats keys)", "userEncodableVersions": "REQUIRED on every userEncodable action, forbidden on the rest: the exact list of FORMAT versions a user may author (xchain-sdk Formats[ACTION] keys). Present because userEncodable alone is action-level, so a version the indexer accepts only when it synthesizes it could be added to the SDK Formats without any guard noticing. Each entry is audited against the indexer handler's own this.formats map plus its system-only gates, so a version listed here is one a user-broadcast tx can legitimately carry.", "explorerRender": "the EXPLORER renders it (xchain-explorer getActionData)", diff --git a/test/helpers/indexer_action_handler.js b/test/helpers/indexer_action_handler.js new file mode 100644 index 00000000..ffeb0a7a --- /dev/null +++ b/test/helpers/indexer_action_handler.js @@ -0,0 +1,48 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const { resolveIndexerRoot } = require('../../bin/preflight_indexer_root.js'); +const { listParts, parseMapRows } = require('../../bin/preflight_handler_dirs.js'); + +const MAP_PATH = path.join(__dirname, '..', '..', 'src', 'preflight', 'INDEXER-MAP.md'); + +function actionName(handler) { + const trimmed = handler.replace(/\/$/, ''); + return path.basename(trimmed, path.extname(trimmed)).toLowerCase(); +} + +function mappedActionRow(action) { + const wanted = String(action).toLowerCase(); + const rows = parseMapRows(fs.readFileSync(MAP_PATH, 'utf8')) + .filter((row) => actionName(row.handler) === wanted); + if (rows.length !== 1) + throw new Error(`expected one mapped indexer handler for ${action}, found ${rows.length}`); + return rows[0]; +} + +function resolveIndexerAction(action) { + const root = resolveIndexerRoot(); + if (!root) return null; + + const row = mappedActionRow(action); + const mappedPath = path.join(root, row.handler); + const entry = row.kind === 'directory' ? path.join(mappedPath, 'index.js') : mappedPath; + if (!fs.existsSync(entry)) + throw new Error(`mapped indexer handler entry is missing for ${action}: ${entry}`); + + const sources = row.kind === 'directory' + ? listParts(mappedPath).filter((name) => /\.(?:c|m)?js$/.test(name)) + .map((name) => path.join(mappedPath, name)) + : [mappedPath]; + return { root, entry, sources }; +} + +function loadIndexerAction(action) { + const resolved = resolveIndexerAction(action); + if (!resolved) return null; + return { ...resolved, Handler: require(resolved.entry) }; +} + +module.exports = { mappedActionRow, resolveIndexerAction, loadIndexerAction }; diff --git a/test/helpers/sibling_checkout.js b/test/helpers/sibling_checkout.js new file mode 100644 index 00000000..afcbac81 --- /dev/null +++ b/test/helpers/sibling_checkout.js @@ -0,0 +1,123 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * May a cross-repo guard trust the sibling file it is about to read? + * + * The guards in this tree reach a sibling repo as `../../../xchain-`, + * which is only a statement about the directory layout, not about which + * commit of the sibling is sitting there. Two layouts give an honest answer: + * a main checkout beside its sibling main checkouts (a developer's tree), and + * a CI venue, which clones every declared sibling as a real directory beside + * the repo under test. A third layout gives a dishonest one: a linked worktree + * cut under a lane directory whose `xchain-` entries are SYMLINKS into + * the platform's main checkouts. There the path resolves into a peer + * session's live working tree, uncommitted edits and all, and a guard reads + * green against state no commit holds. On 2026-09-14 a cross-lineage + * falsification returned a false pass for exactly that reason. + * + * So a sibling is refused when it is absent, and also when this checkout is a + * linked worktree and the sibling entry beside it is a symlink whose target is + * a main checkout (its `.git` is a directory). A symlink into another linked + * worktree is allowed: that is a deliberately cut tree at a known ref. + * + * The verdict never decides soft versus strict on its own. Guards skip on a + * refusal in soft mode and fail naming the reason when the run declared its + * siblings supplied with XCHAIN_REQUIRE_SIBLINGS=1, which is what skipOrFail() + * does. Guards keep their own path literals, so every census that greps for + * `xchain-/...` still sees what each guard reads. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const OWN_ROOT = path.resolve(__dirname, '..', '..'); + +/** True when the sibling checkouts were declared supplied for this run. */ +function siblingsRequired(env = process.env) { + return env.XCHAIN_REQUIRE_SIBLINGS === '1'; +} + +/** A linked worktree carries a `.git` FILE pointing at its common dir. */ +function isLinkedWorktree(root) { + try { return fs.lstatSync(path.join(root, '.git')).isFile(); } + catch (e) { return false; } +} + +/** The nearest ancestor of `dir` (inclusive) that holds a `.git` entry, or null. */ +function checkoutRootOf(dir) { + for (let d = dir; ; d = path.dirname(d)) { + if (fs.existsSync(path.join(d, '.git'))) return d; + if (path.dirname(d) === d) return null; + } +} + +/** + * Judge one sibling path. + * + * @param {string} fromDir the directory the guard's literal is relative to (its __dirname) + * @param {string} target the guard's own path literal, relative or absolute + * @param {object} [opts] + * @param {string} [opts.ownRoot] the checkout the guard runs in; defaults to this repo + * @returns {{usable: boolean, path: string, reason: string|null}} + */ +function siblingCheckout(fromDir, target, opts = {}) { + const ownRoot = opts.ownRoot || OWN_ROOT; + const abs = path.resolve(fromDir, target); + + if (!fs.existsSync(abs)) + return { usable: false, path: abs, reason: 'sibling path absent: ' + abs }; + + // Only the entry directly beside this checkout can be the lane symlink. A + // path that does not pass through that parent is not a sibling reference. + const parent = path.dirname(ownRoot); + const rel = path.relative(parent, abs); + if (rel.startsWith('..') || path.isAbsolute(rel)) + return { usable: true, path: abs, reason: null }; + const entry = path.join(parent, rel.split(path.sep)[0]); + + if (!isLinkedWorktree(ownRoot) || !fs.lstatSync(entry).isSymbolicLink()) + return { usable: true, path: abs, reason: null }; + + const real = fs.realpathSync(entry); + const targetRoot = checkoutRootOf(real); + const targetIsMain = targetRoot !== null + && fs.lstatSync(path.join(targetRoot, '.git')).isDirectory(); + if (targetIsMain) + return { + usable: false, path: abs, + reason: 'sibling ' + path.basename(entry) + ' resolves through a symlink into the live main checkout ' + + targetRoot + ', which no commit pins; cut a real sibling worktree to test against it', + }; + return { usable: true, path: abs, reason: null }; +} + +/** + * Act on a refusal inside a mocha test or hook: fail when siblings were + * declared supplied, otherwise skip. Returns false so a caller can write + * `if (!verdict.usable) return skipOrFail(this, verdict, 'what this guard checks');`. + * + * @param {object} ctx the mocha `this` + * @param {{usable: boolean, reason: string|null}} verdict from siblingCheckout() + * @param {string} what one clause naming the guard, for the failure message + */ +function skipOrFail(ctx, verdict, what) { + if (verdict.usable) return true; + if (siblingsRequired()) + throw new Error('XCHAIN_REQUIRE_SIBLINGS=1 but ' + what + ' cannot run: ' + verdict.reason); + ctx.skip(); + return false; +} + +module.exports = { siblingCheckout, skipOrFail, siblingsRequired, isLinkedWorktree }; diff --git a/test/unit/actions/betting.test/06_bet_client_surfaces.test.js b/test/unit/actions/betting.test/06_bet_client_surfaces.test.js index cedaa346..e539da32 100644 --- a/test/unit/actions/betting.test/06_bet_client_surfaces.test.js +++ b/test/unit/actions/betting.test/06_bet_client_surfaces.test.js @@ -146,6 +146,22 @@ describe('BET client surfaces', function () { }); +describe('projectFeedCreateFee fractional seconds', function () { + + it('rounds the seconds first, as the chain does, then the days', function () { + const b = new BettingHelpers(); + // 7819200s is exactly 90.5 days. The chain rounds seconds to an integer + // (bcsub at 0 dp) before counting days, so anything that rounds to it is + // charged; anything that rounds below it is free. + const edge = 7819200; + expect(b.projectFeedCreateFee({ durationSeconds: edge - 0.4 }).days, 'rounds up to the edge').to.equal(91); + expect(b.projectFeedCreateFee({ durationSeconds: edge - 1e-8 }).days, 'inside relTol of the edge').to.equal(91); + expect(b.projectFeedCreateFee({ durationSeconds: edge - 0.6 }).days, 'rounds down off the edge').to.equal(90); + expect(b.projectFeedCreateFee({ durationSeconds: edge - 0.6 }).free).to.equal(true); + }); + +}); + describe('BET client surfaces', function () { describe('projectFeedCreateFee (decision F duration pricing)', function () { diff --git a/test/unit/actions/bridge_sdk.test.js b/test/unit/actions/bridge_sdk.test.js index ec9cea20..d2115129 100644 --- a/test/unit/actions/bridge_sdk.test.js +++ b/test/unit/actions/bridge_sdk.test.js @@ -65,7 +65,10 @@ function stubWorkflows(network){ options: { network: network }, util: new Utility(), session: () => ({ - submit: async (actionData) => { calls.push(actionData); return { submitted: true }; } + submit: async (actionData, encoderOpts, submitOpts) => { + calls.push({ actionData, encoderOpts, submitOpts }); + return { submitted: true }; + } }) }; return { flows: new Workflows(sdk), calls: calls }; @@ -231,9 +234,9 @@ describe('bridge: workflow recipes', () => { let { flows, calls } = stubWorkflows('regtest'); await flows.bridgeLock('wif', { destCoin: 'BTC', destAddress: ADDR.BTC_REG_P2PKH, amount: '5', memo: 'go' }); assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0].action, 'XBRIDGE'); - assert.strictEqual(calls[0].params.VERSION, '0'); - assert.strictEqual(calls[0].params.DEST_ADDRESS, ADDR.BTC_REG_P2PKH); + assert.strictEqual(calls[0].actionData.action, 'XBRIDGE'); + assert.strictEqual(calls[0].actionData.params.VERSION, '0'); + assert.strictEqual(calls[0].actionData.params.DEST_ADDRESS, ADDR.BTC_REG_P2PKH); }); it('bridgeLock refuses a destination that is not valid on the destination chain', async () => { @@ -247,7 +250,7 @@ describe('bridge: workflow recipes', () => { it('bridgeBurn validates against BTC whatever chain it is broadcast on, and pins v1', async () => { let { flows, calls } = stubWorkflows('regtest'); await flows.bridgeBurn('wif', { btcAddress: ADDR.BTC_REG_P2PKH, amount: '2' }); - assert.strictEqual(calls[0].params.VERSION, '1'); + assert.strictEqual(calls[0].actionData.params.VERSION, '1'); await assert.rejects( () => flows.bridgeBurn('wif', { btcAddress: ADDR.DOGE_MAIN_P2PKH, amount: '2' }), /BTC_ADDRESS .* not a valid BTC regtest address/); @@ -256,7 +259,7 @@ describe('bridge: workflow recipes', () => { it('bridgeTokenLock pins v3 and refuses a dotted or missing tick', async () => { let { flows, calls } = stubWorkflows('regtest'); await flows.bridgeTokenLock('wif', { tick: 'FUFU', destCoin: 'BTC', destAddress: ADDR.BTC_REG_P2PKH, amount: '5' }); - assert.strictEqual(calls[0].params.VERSION, '3'); + assert.strictEqual(calls[0].actionData.params.VERSION, '3'); await assert.rejects(() => flows.bridgeTokenLock('wif', { tick: 'BTC.FUFU', destCoin: 'BTC', destAddress: ADDR.BTC_REG_P2PKH, amount: '5' }), /dotted tick/); await assert.rejects(() => flows.bridgeTokenLock('wif', { destCoin: 'BTC', destAddress: ADDR.BTC_REG_P2PKH, amount: '5' }), /requires a TICK/); }); @@ -264,7 +267,7 @@ describe('bridge: workflow recipes', () => { it('bridgeTokenBurn reads the origin chain out of the tick', async () => { let { flows, calls } = stubWorkflows('regtest'); await flows.bridgeTokenBurn('wif', { tick: 'BTC.FUFU', originAddress: ADDR.BTC_REG_P2PKH, amount: '2' }); - assert.strictEqual(calls[0].params.VERSION, '4'); + assert.strictEqual(calls[0].actionData.params.VERSION, '4'); // A BTC-shaped address is right for BTC.FUFU and wrong for DOGE.WOW: the // chain the address is checked against comes from the tick, not the caller. await assert.rejects( @@ -277,14 +280,65 @@ describe('bridge: workflow recipes', () => { }); +describe('bridge: workflow encoder options', () => { + + // A recipe that swallowed opts.encoder produced a transaction at the default + // fee rate no matter what the caller asked for, which is why the credited-lock + // recipe could not be driven at a chosen feePerKb. + it('forwards encoder and submission controls to their intended layers for every recipe', async () => { + let cases = [ + ['bridgeLock', { destCoin: 'BTC', destAddress: ADDR.BTC_REG_P2PKH, amount: '5' }], + ['bridgeBurn', { btcAddress: ADDR.BTC_REG_P2PKH, amount: '2' }], + ['bridgeTokenLock', { tick: 'FUFU', destCoin: 'BTC', destAddress: ADDR.BTC_REG_P2PKH, amount: '5' }], + ['bridgeTokenBurn', { tick: 'BTC.FUFU', originAddress: ADDR.BTC_REG_P2PKH, amount: '2' }], + ['setTokenBridgeability', { tick: 'FUFU', bridgeChains: 'DOGE' }] + ]; + + for (let [method, params] of cases) { + let { flows, calls } = stubWorkflows('regtest'); + await flows[method]('wif', params, { + network: 'regtest', + encoder: { feePerKb: 2000, unconfirmed: false }, + waitForIndexer: false, + maxFeeSats: 12000 + }); + + assert.strictEqual(calls.length, 1, method + ' must submit exactly once'); + assert.deepStrictEqual(calls[0].encoderOpts, + { feePerKb: 2000, unconfirmed: false }, method + ' encoder options'); + assert.deepStrictEqual(calls[0].submitOpts, { + network: 'regtest', + waitForIndexer: false, + maxFeeSats: 12000 + }, method + ' submission options'); + } + }); + + // The backward-compatibility case: an opts with no `encoder` key must reach + // the encoder as the same empty object every recipe passed before, and the + // encoder-shaped keys a caller misplaced at the top level stay where they are + // rather than being promoted into encoder input behind their back. + it('does not treat an encoder control placed only in submission options as encoder input', async () => { + let { flows, calls } = stubWorkflows('regtest'); + await flows.bridgeLock('wif', + { destCoin: 'BTC', destAddress: ADDR.BTC_REG_P2PKH, amount: '5' }, + { feePerKb: 9000, unconfirmed: true, maxFeeSats: 12000 }); + + assert.deepStrictEqual(calls[0].encoderOpts, {}); + assert.deepStrictEqual(calls[0].submitOpts, + { feePerKb: 9000, unconfirmed: true, maxFeeSats: 12000 }); + }); + +}); + describe('bridge: workflow recipes', () => { it('setTokenBridgeability pins ISSUE v7, upper-cases the chain list and refuses an unknown coin', async () => { let { flows, calls } = stubWorkflows('regtest'); await flows.setTokenBridgeability('wif', { tick: 'FUFU', bridgeChains: 'doge, ltc', minDepth: '3' }); - assert.strictEqual(calls[0].action, 'ISSUE'); - assert.strictEqual(calls[0].params.VERSION, '7'); - assert.strictEqual(calls[0].params.BRIDGE_CHAINS, 'DOGE,LTC'); + assert.strictEqual(calls[0].actionData.action, 'ISSUE'); + assert.strictEqual(calls[0].actionData.params.VERSION, '7'); + assert.strictEqual(calls[0].actionData.params.BRIDGE_CHAINS, 'DOGE,LTC'); await assert.rejects(() => flows.setTokenBridgeability('wif', { tick: 'FUFU', bridgeChains: 'XMR' }), /unsupported coin/); // '' means UNCHANGED on chain, so it can never be the way to clear a list. await assert.rejects(() => flows.setTokenBridgeability('wif', { tick: 'FUFU', bridgeChains: '' }), /comma list of coins or the "-" sentinel/); @@ -293,7 +347,7 @@ describe('bridge: workflow recipes', () => { it('setTokenBridgeability passes the "-" sentinel through untouched', async () => { let { flows, calls } = stubWorkflows('regtest'); await flows.setTokenBridgeability('wif', { tick: 'FUFU', bridgeChains: '-' }); - assert.strictEqual(calls[0].params.BRIDGE_CHAINS, '-'); + assert.strictEqual(calls[0].actionData.params.BRIDGE_CHAINS, '-'); }); it('refuses a caller-supplied VERSION that disagrees with the recipe', async () => { diff --git a/test/unit/actions/issue_format7_pre_change_pin.test.js b/test/unit/actions/issue_format7_pre_change_pin.test.js index 7bf0bee8..9b6276c1 100644 --- a/test/unit/actions/issue_format7_pre_change_pin.test.js +++ b/test/unit/actions/issue_format7_pre_change_pin.test.js @@ -38,8 +38,8 @@ ********************************************************************/ const { expect } = require('chai'); -const fs = require('fs'); const path = require('path'); +const { loadIndexerAction } = require('../../helpers/indexer_action_handler.js'); const config = require('../../../src/config.js'); const Utility = require('../../../src/utils/utility.js'); @@ -112,19 +112,6 @@ function makeActions() { // Same sibling resolution the golden suite uses: the unit tier checks out only // this repo, so the parser half degrades to a skip rather than a false red. -function resolveIndexerRoot() { - const candidates = [ - process.env.XCHAIN_INDEXER_PATH, - path.join(__dirname, '../..', '..', '..', 'xchain-indexer'), - ].filter(Boolean); - for (const root of candidates) { - if (fs.existsSync(path.join(root, 'src', 'utility.js')) && - fs.existsSync(path.join(root, 'src', 'actions', 'issue.js'))) - return root; - } - return null; -} - describe('ISSUE format 7 is purely additive (pre-change pin)', function () { describe('the SDK ISSUE format table for versions 0 to 6 is byte-identical to its pre-format-7 text', function () { @@ -158,18 +145,18 @@ describe('ISSUE format 7 is purely additive (pre-change pin)', function () { describe('ISSUE format 7 is purely additive (pre-change pin)', function () { describe('the sibling indexer parses an old-version ISSUE to the same values it always did', function () { - const indexerRoot = resolveIndexerRoot(); + const indexerAction = loadIndexerAction('issue'); let parse = null; before(function () { // Cross-repo require of the sibling handler and utility; synchronous, // so mocha's timer could only mis-attribute a slow load, never stop it. this.timeout(0); - if (!indexerRoot) { this.skip(); return; } + if (!indexerAction) { this.skip(); return; } + const { root: indexerRoot, Handler: Issue } = indexerAction; process.env.INDEXER_COIN = process.env.INDEXER_COIN || 'BTC'; process.env.INDEXER_NETWORK = process.env.INDEXER_NETWORK || 'regtest'; const IdxUtility = require(path.join(indexerRoot, 'src', 'utility.js')); - const Issue = require(path.join(indexerRoot, 'src', 'actions', 'issue.js')); const formats = new Issue({ config: {}, decoderDb: null, indexerDb: null, util: null, mapper: null }).formats; const util = new IdxUtility(); parse = function (wire) { diff --git a/test/unit/actions/nft.test.js b/test/unit/actions/nft.test.js index cd12bdfe..58528ae4 100644 --- a/test/unit/actions/nft.test.js +++ b/test/unit/actions/nft.test.js @@ -115,25 +115,27 @@ describe('NftHelpers', function () { expect(() => nft.collectionItem({ parent: 'X' })).to.throw(/name is required/); }); }); +}); +describe('NftHelpers', function () { + beforeEach(resetNftHelpers); describe('tisDocument()', function () { it('builds a minimal NFT-intent doc with an on-chain data_ref', function () { const { doc, json } = nft.tisDocument({ tick: 'art1', name: 'Art One', description: 'First print', - imageActionIndex: 1234, imageType: 'image/png', imageName: 'art.png', + imageActionIndex: 1234, imageRole: 'large', imageName: 'art.png', }); expect(doc.tick).to.equal('ART1'); expect(doc.name).to.equal('Art One'); expect(doc.categories).to.deep.equal([{ type: 'main', data: 'NFT' }]); expect(doc.images).to.deep.equal([{ - data_ref: 'action:1234', type: 'image/png', name: 'art.png', + data_ref: 'action:1234', type: 'large', name: 'art.png', }]); expect(JSON.parse(json)).to.deep.equal(doc); }); it('supports an off-chain image URL and omits empty fields', function () { - const { doc } = nft.tisDocument({ tick: 'X', imageUrl: 'https://a/b.png' }); - expect(doc.images).to.deep.equal([{ data: 'https://a/b.png' }]); - expect(doc).to.not.have.property('name'); + const { doc } = nft.tisDocument({ tick: 'X', name: 'X', imageUrl: 'https://a/b.png' }); + expect(doc.images).to.deep.equal([{ data: 'https://a/b.png', type: 'standard' }]); expect(doc).to.not.have.property('description'); }); it('omits images entirely when no artwork given', function () { @@ -141,12 +143,32 @@ describe('NftHelpers', function () { expect(doc).to.not.have.property('images'); }); it('builds a cross-chain data_ref when imageCoin is given', function () { - const { doc } = nft.tisDocument({ tick: 'X', imageActionIndex: 55, imageCoin: 'doge' }); + const { doc } = nft.tisDocument({ tick: 'X', name: 'X', imageActionIndex: 55, imageCoin: 'doge' }); expect(doc.images[0].data_ref).to.equal('action:DOGE:55'); }); it('requires tick', function () { expect(() => nft.tisDocument({})).to.throw(/tick is required/); }); + it('requires a non-blank name rather than inventing one', function () { + expect(() => nft.tisDocument({ tick: 'X', imageUrl: 'https://a/b.png' })).to.throw(/name is required/); + expect(() => nft.tisDocument({ tick: 'X', name: ' ' })).to.throw(/name is required/); + }); + it('defaults every image entry to the standard display role', function () { + const { doc } = nft.tisDocument({ tick: 'X', name: 'X', imageActionIndex: 7 }); + expect(doc.images).to.deep.equal([{ data_ref: 'action:7', type: 'standard' }]); + }); + it('never writes a MIME type into images[].type', function () { + const { doc } = nft.tisDocument({ tick: 'X', name: 'X', imageActionIndex: 7, imageType: 'image/png' }); + expect(doc.images[0].type).to.equal('standard'); + expect(nft.tisDocument({ tick: 'X', name: 'X', imageActionIndex: 7, imageType: 'hires' }).doc.images[0].type) + .to.equal('hires'); + }); + it('rejects a role outside icon, standard, large, hires', function () { + expect(() => nft.tisDocument({ tick: 'X', name: 'X', imageActionIndex: 7, imageRole: 'image/png' })) + .to.throw(/imageRole must be one of icon, standard, large, hires/); + expect(() => nft.tisDocument({ tick: 'X', name: 'X', imageActionIndex: 7, imageType: 'thumbnail' })) + .to.throw(/imageType must be one of/); + }); }); }); diff --git a/test/unit/actions/workflows.test/04_attach_content.test.js b/test/unit/actions/workflows.test/04_attach_content.test.js index 73487e09..509a1be5 100644 --- a/test/unit/actions/workflows.test/04_attach_content.test.js +++ b/test/unit/actions/workflows.test/04_attach_content.test.js @@ -85,6 +85,9 @@ describe('Workflows', function () { //...whose images[] data_ref points at the artwork upload (action 101) assert.strictEqual(doc.images[0].data_ref, 'action:101'); assert.strictEqual(doc.tick, 'ART1'); + //...carrying the schema-required name and a display role, never the FILE's MIME type + assert.strictEqual(doc.name, 'Art One'); + assert.strictEqual(doc.images[0].type, 'standard'); //...and ISSUE v1 points the token's DESCRIPTION at the doc (action 102) assert.strictEqual(calls.issues.length, 1); assert.deepStrictEqual(calls.issues[0], { @@ -93,5 +96,17 @@ describe('Workflows', function () { assert.strictEqual(out.tisFile.txid, 'file_tx2'); assert.strictEqual(out.describe.txid, 'describe_tx'); }); + it('with tis but no name: refuses before any leg broadcasts', async function () { + const calls = { files: [], links: [], issues: [] }; + const wf = new Workflows(makeAttachSdk(calls)); + await assert.rejects(wf.attachContent(FAKE_WIF, { + coin: 'BTC', issueActionIndex: 7, + file: { name: 'a.png', type: 'image/png', rawData: 'x' }, + tis: { tick: 'art1' }, + }), /name is required/); + assert.strictEqual(calls.files.length, 0); + assert.strictEqual(calls.links.length, 0); + assert.strictEqual(calls.issues.length, 0); + }); }); }); diff --git a/test/unit/api/api_auth_gate.test.js b/test/unit/api/api_auth_gate.test.js index 8dc5d6e0..a1b29cd9 100644 --- a/test/unit/api/api_auth_gate.test.js +++ b/test/unit/api/api_auth_gate.test.js @@ -36,6 +36,7 @@ const http = require('http'); const express = require('express'); const bodyParser = require('body-parser'); const { authGateMiddleware } = require('../../../src/utils/api_guards.js'); +const { mountIndexes, DISPATCH_NAME } = require('./helpers/mount_order.js'); const KEY = 'correct-horse-battery-staple'; function buildApp(SDK_API_KEY) { @@ -268,12 +269,9 @@ describe('API bearer-token auth gate', function () { }); it('src/api.js mounts the shipped gate before the jsonRouter mount', () => { - const src = fs.readFileSync(path.join(__dirname, '../../../src/api/index.js'), 'utf8'); - const gateIdx = src.indexOf('app.use(authGateMiddleware('); - const routerIdx = src.indexOf('jsonRouter('); - assert.notStrictEqual(gateIdx, -1, 'auth gate not mounted in src/api.js'); - assert.notStrictEqual(routerIdx, -1, 'jsonRouter mount missing from src/api.js'); - assert.ok(gateIdx < routerIdx, 'auth gate must be registered before the jsonRouter mount'); + // Read from the BUILT app, so a mount moved into a helper is placed by its call site. + const at = mountIndexes(assert, ['authGate', DISPATCH_NAME]); + assert.ok(at.authGate < at[DISPATCH_NAME], 'auth gate must be registered before the jsonRouter mount'); }); it('keeps the timing-safe compare, in the guard module the gate now lives in', () => { diff --git a/test/unit/api/api_batch_cap.test.js b/test/unit/api/api_batch_cap.test.js index 4a0eb777..89c5cdd8 100644 --- a/test/unit/api/api_batch_cap.test.js +++ b/test/unit/api/api_batch_cap.test.js @@ -22,7 +22,7 @@ * These tests mount the SHIPPED middleware from src/utils/api_guards.js (the * same function src/api/index.js mounts) rather than a copy of it, so the cap * is driven alone, without the SDK or the other guards in front of it, and a - * source check pins that api.js still mounts it ahead of the auth gate and the + * built-app check pins that api.js mounts it ahead of the auth gate and the * router. (src/api/index.js used to start a live server at require time; it now * exports createApp and startApi and listens only as the CLI entry.) * @@ -37,6 +37,7 @@ const http = require('http'); const express = require('express'); const bodyParser = require('body-parser'); const { batchCapMiddleware, resolveMaxBatch } = require('../../../src/utils/api_guards.js'); +const { mountIndexes, DISPATCH_NAME } = require('./helpers/mount_order.js'); function buildApp(maxBatch) { const app = express(); @@ -119,18 +120,11 @@ describe('API JSON-RPC batch fan-out cap', function () { }); it('src/api.js mounts the batch cap BEFORE the auth gate and the jsonRouter mount', () => { - const src = fs.readFileSync(path.join(__dirname, '../../../src/api/index.js'), 'utf8'); - const capIdx = src.indexOf('app.use(batchCapMiddleware('); - // Anchored on the MOUNT, not on a compare inside the gate body: the gate - // now lives in src/utils/api_guards.js, and an anchor that can go missing makes - // every ordering assertion below it argue from -1. - const authIdx = src.indexOf('app.use(authGateMiddleware('); - const routerIdx = src.indexOf('jsonRouter('); - assert.notStrictEqual(capIdx, -1, 'batch cap not mounted in src/api.js'); - assert.notStrictEqual(authIdx, -1, 'auth gate missing from src/api.js'); - assert.notStrictEqual(routerIdx, -1, 'jsonRouter mount missing from src/api.js'); - assert.ok(capIdx < authIdx, 'the cap must run before the auth gate'); - assert.ok(capIdx < routerIdx, 'the cap must run before the router dispatches'); + // Read from the BUILT app: the cap mounts inside configureBaseMiddleware, so + // its runtime position is set by that helper's call site, not by its text. + const at = mountIndexes(assert, ['batchCap', 'authGate', DISPATCH_NAME]); + assert.ok(at.batchCap < at.authGate, 'the cap must run before the auth gate'); + assert.ok(at.batchCap < at[DISPATCH_NAME], 'the cap must run before the router dispatches'); }); it('keeps ONE implementation of the cap: api.js holds no inline copy', () => { diff --git a/test/unit/api/api_rate_limit.test.js b/test/unit/api/api_rate_limit.test.js index 4f3f2358..939a0687 100644 --- a/test/unit/api/api_rate_limit.test.js +++ b/test/unit/api/api_rate_limit.test.js @@ -21,7 +21,7 @@ * These tests mount the SHIPPED limiter from src/utils/api_guards.js (the same * function src/api/index.js mounts) rather than a copy of it, so the limiter is * driven alone, without the SDK or the other guards in front of it, and a - * source check pins that api.js still mounts it ahead of the auth gate and the + * built-app check pins that api.js mounts it ahead of the auth gate and the * router. (src/api/index.js used to start a live server at require time; it now * exports createApp and startApi and listens only as the CLI entry.) * @@ -41,6 +41,7 @@ const { resolveRateWindowMs } = require('../../../src/utils/api_guards.js'); const { waitFor } = require('../../helpers/wait.js'); +const { mountIndexes, DISPATCH_NAME } = require('./helpers/mount_order.js'); // Tests that exercise per-credential bucketing pass the credentials the app // would accept (src/api/index.js passes a safeTokenEqual-against-SDK_API_KEY @@ -232,18 +233,10 @@ describe('API request-rate limit', function () { this.timeout(10000); it('src/api.js mounts the limiter BEFORE the auth gate and the jsonRouter mount', () => { - const src = fs.readFileSync(path.join(__dirname, '../../../src/api/index.js'), 'utf8'); - const rateIdx = src.indexOf('app.use(rateLimitMiddleware('); - // Anchored on the MOUNT, not on a compare inside the gate body: the gate - // now lives in src/utils/api_guards.js, and an anchor that can go missing makes - // every ordering assertion below it argue from -1. - const authIdx = src.indexOf('app.use(authGateMiddleware('); - const routerIdx = src.indexOf('jsonRouter('); - assert.notStrictEqual(rateIdx, -1, 'rate limiter not mounted in src/api.js'); - assert.notStrictEqual(authIdx, -1, 'auth gate not mounted in src/api.js'); - assert.notStrictEqual(routerIdx, -1, 'jsonRouter mount missing from src/api.js'); - assert.ok(rateIdx < authIdx, 'the limiter must run before the auth gate so anonymous floods are bounded'); - assert.ok(rateIdx < routerIdx, 'the limiter must run before the router'); + // Read from the BUILT app, so a mount moved into a helper is placed by its call site. + const at = mountIndexes(assert, ['rateLimit', 'authGate', DISPATCH_NAME]); + assert.ok(at.rateLimit < at.authGate, 'the limiter must run before the auth gate so anonymous floods are bounded'); + assert.ok(at.rateLimit < at[DISPATCH_NAME], 'the limiter must run before the router'); }); it('keeps ONE implementation of the limiter: api.js holds no inline copy', () => { diff --git a/test/unit/api/helpers/mount_order.js b/test/unit/api/helpers/mount_order.js new file mode 100644 index 00000000..6dffcfe4 --- /dev/null +++ b/test/unit/api/helpers/mount_order.js @@ -0,0 +1,81 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * XChain SDK tests - runtime middleware mount order + * + * Reads the handler names of the app createApp() builds, in the order Express + * runs them. Guard-order tests assert on this rather than on byte offsets in + * src/api/index.js, because a guard mounted inside a helper runs where the + * helper is CALLED, which the source text does not show. + * + * The module runs in a CHILD process with a fixed environment and a neutral + * cwd, as api_listener.test.js does: it reads settings at require time and + * dotenv.config() would otherwise load a repo-root .env into the mocha process. + * + ********************************************************************/ + +'use strict'; + +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const MODULE = path.join(__dirname, '../../../../src/api/index.js'); + +// Name the JSON-RPC dispatch layer: express-json-rpc-router returns an anonymous handler. +const DISPATCH_NAME = 'jsonRpcDispatch'; + +const STACK_SCRIPT = [ + "const Module = require('module');", + 'const realLoad = Module._load;', + 'Module._load = function (request) {', + ' const loaded = realLoad.apply(this, arguments);', + " if (request !== 'express-json-rpc-router') return loaded;", + ' return function () {', + ' const handler = loaded.apply(this, arguments);', + ' return { ' + DISPATCH_NAME + '(req, res, next) { return handler(req, res, next); } }.' + DISPATCH_NAME + ';', + ' };', + '};', + 'const { createApp } = require(' + JSON.stringify(MODULE) + ');', + 'const stack = createApp({}).router.stack;', + "process.stdout.write(JSON.stringify(stack.map(l => (l.handle && l.handle.name) || '')));" +].join('\n'); + +// Return createApp's layer handler names in runtime order ('' for an anonymous layer). +function builtMountOrder() { + const env = { + PATH: process.env.PATH, + HOME: process.env.HOME, + NETWORK: 'bitcoin-regtest', + SDK_API_KEY: 'mount-order-test-key' + }; + const out = execFileSync(process.execPath, ['-e', STACK_SCRIPT], + { env, cwd: os.tmpdir(), timeout: 8000, encoding: 'utf8' }); + return JSON.parse(out); +} + +// Return the runtime index of each named layer, asserting every one is mounted exactly once. +function mountIndexes(assert, names) { + const order = builtMountOrder(); + const found = {}; + for (const name of names) { + const first = order.indexOf(name); + assert.notStrictEqual(first, -1, name + ' is not mounted on the built app: ' + JSON.stringify(order)); + assert.strictEqual(order.lastIndexOf(name), first, name + ' is mounted twice: ' + JSON.stringify(order)); + found[name] = first; + } + return found; +} + +module.exports = { builtMountOrder, mountIndexes, DISPATCH_NAME }; diff --git a/test/unit/clients/hub.test/06_public_discovery.test.js b/test/unit/clients/hub.test/06_public_discovery.test.js new file mode 100644 index 00000000..d5f86df2 --- /dev/null +++ b/test/unit/clients/hub.test/06_public_discovery.test.js @@ -0,0 +1,147 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +'use strict'; + +const assert = require('assert'); +const nock = require('nock'); +const HubConnector = require('../../../../src/clients/hub.js'); +const XChainSDK = require('../../../../src/XChainSDK.js'); + +const HUB_BASE = 'http://localhost:10000'; +const REGISTRY_PATH = '/api/v1/chain-registry'; +let savedHubApiKey; + +function descriptor(overrides = {}) { + return Object.assign({ + id: 'bitcoin-mainnet', + coin: 'bitcoin', + networkKind: 'mainnet', + explorer: { defaultUrl: 'https://discovered-explorer.test', defaultPort: 8443 }, + encoder: { defaultUrl: 'https://discovered-encoder.test/BTC', defaultPort: 9443 } + }, overrides); +} + +function isolateHubApiKey() { + beforeEach(function saveHubApiKey() { + savedHubApiKey = process.env.HUB_API_KEY; + delete process.env.HUB_API_KEY; + }); + + afterEach(function restoreHubApiKey() { + nock.cleanAll(); + if (savedHubApiKey === undefined) delete process.env.HUB_API_KEY; + else process.env.HUB_API_KEY = savedHubApiKey; + }); +} + +function publicSdkDiscoveryTests() { + isolateHubApiKey(); + it('uses public GET discovery, updates clients and getHubConfig(), then polls the same public route', async function () { + let gets = 0; + let sawPoll; + let polled = new Promise(resolve => { sawPoll = resolve; }); + nock(HUB_BASE) + .get(REGISTRY_PATH) + .twice() + .reply(200, () => { + gets++; + if (gets === 2) sawPoll(); + return { schema_version: 1, descriptors: [descriptor()] }; + }); + let forbiddenPost = nock(HUB_BASE) + .post('/BTC', body => body && body.method === 'getallconfigs') + .reply(500, { error: 'public clients must not call getallconfigs' }); + + let sdk = new XChainSDK({ + network: 'bitcoin-mainnet', + hubUrl: HUB_BASE + '/BTC', + hubPollInterval: 10, + retry: false + }); + + try { + await sdk.discover(); + assert.strictEqual(sdk.explorer.baseUrl, 'https://discovered-explorer.test'); + assert.strictEqual(sdk.explorer.port, 8443); + assert.strictEqual(sdk.encoder.baseUrl, 'https://discovered-encoder.test/BTC'); + assert.strictEqual(sdk.encoder.port, 9443); + assert.deepStrictEqual(sdk.getHubConfig(), { + bitcoin: { + mainnet: { + 'xchain-explorer': { host: 'https://discovered-explorer.test', port: 8443 }, + 'xchain-encoder': { host: 'https://discovered-encoder.test/BTC', port: 9443 } + } + } + }); + assert.strictEqual(sdk._polling, true); + assert.ok(sdk.hub._pollTimer, 'discovery must reach polling'); + await Promise.race([ + polled, + new Promise((resolve, reject) => setTimeout(() => reject(new Error('poll did not use public discovery')), 500)) + ]); + assert.strictEqual(gets, 2); + assert.strictEqual(forbiddenPost.isDone(), false, 'keyless discovery must never POST getallconfigs'); + } finally { + sdk.stop(); + } + }); +} + +function publicHubFailoverTests() { + isolateHubApiKey(); + it('fails over between hub origins and reports HUB_UNAVAILABLE when all public origins fail', async function () { + nock(HUB_BASE).get(REGISTRY_PATH).replyWithError('first hub unavailable'); + nock('http://hub2.test:8001').get(REGISTRY_PATH) + .reply(200, { descriptors: [descriptor()] }); + + let hub = new HubConnector({ + hubValidators: [HUB_BASE + '/BTC', 'http://hub2.test:8001/TBTC'] + }); + let configs = await hub.getDiscoveryConfig(); + assert.ok(configs.bitcoin.mainnet); + assert.strictEqual(hub._lastGoodIdx, 1); + + nock('http://bad-one.test').get(REGISTRY_PATH).replyWithError('offline'); + nock('http://bad-two.test').get(REGISTRY_PATH).reply(200, { descriptors: null }); + let unavailable = new HubConnector({ + hubValidators: ['http://bad-one.test/BTC', 'http://bad-two.test/TBTC'] + }); + await assert.rejects( + unavailable.getDiscoveryConfig(), + err => err.name === 'SDKHubError' && err.code === 'HUB_UNAVAILABLE' && /2 endpoint/.test(err.message) + ); + }); +} + +function keyedHubDiscoveryTests() { + isolateHubApiKey(); + it('retains keyed getallconfigs JSON-RPC discovery', async function () { + let publicGet = nock(HUB_BASE).get(REGISTRY_PATH) + .reply(500, { error: 'keyed clients should retain JSON-RPC' }); + let rpc = nock(HUB_BASE, { reqheaders: { 'x-api-key': 'operator-key' } }) + .post('/', body => body.method === 'getallconfigs' && body.params.since_updated_at === 0) + .reply(200, { + jsonrpc: '2.0', + result: { bitcoin: { mainnet: { 'xchain-encoder': { host: 'mesh-encoder.test' } } } }, + id: 1 + }); + + let hub = new HubConnector({ hubUrl: HUB_BASE, hubApiKey: 'operator-key' }); + let configs = await hub.getDiscoveryConfig(); + assert.strictEqual(configs.bitcoin.mainnet['xchain-encoder'].host, 'mesh-encoder.test'); + assert.strictEqual(rpc.isDone(), true); + assert.strictEqual(publicGet.isDone(), false); + }); +} + +describe('public hub discovery', publicSdkDiscoveryTests); +describe('public hub discovery', publicHubFailoverTests); +describe('public hub discovery', keyedHubDiscoveryTests); diff --git a/test/unit/coins/derivation.test.js b/test/unit/coins/derivation.test.js index 5da838a7..1f594c69 100644 --- a/test/unit/coins/derivation.test.js +++ b/test/unit/coins/derivation.test.js @@ -38,9 +38,10 @@ const REGISTERED = [...ALLOWED_COINS].sort(); // coin family -> wallet descriptor file whose p2pkh path (m/44'/N') carries the // authoritative coin type on the other side of the contract. +const SIBLING_ROOT = process.env.XCHAIN_SIBLING_ROOT || path.join(__dirname, '../..', '..', '..'); const WALLET_DESCRIPTORS_DIR = path.join( - __dirname, '../..', '..', '..', - 'xchain-wallet', 'packages', 'core', 'src', 'registry', 'descriptors' + process.env.XCHAIN_WALLET_DIR || path.join(SIBLING_ROOT, 'xchain-wallet'), + 'packages', 'core', 'src', 'registry', 'descriptors' ); const DESCRIPTOR_FILE = { BTC: 'bitcoin.js', LTC: 'litecoin.js', DOGE: 'dogecoin.js' }; diff --git a/test/unit/consensus/consensus_primitive_conformance.test.js b/test/unit/consensus/consensus_primitive_conformance.test.js index 28739588..7539eb2c 100644 --- a/test/unit/consensus/consensus_primitive_conformance.test.js +++ b/test/unit/consensus/consensus_primitive_conformance.test.js @@ -18,11 +18,13 @@ // predicate and the equivocation-header builder are CONSENSUS-CRITICAL and // vendored byte-identically into five services (xchain-hub, xchain-indexer, // xchain-explorer, xchain-sdk, xchain-sync); a divergence in their logic forks -// the chain. The canonical source of record lives in xchain-documentation -// (protocol/reference-impl + protocol/test-vectors). This guard runs in every +// the chain. Their canonical source of record is xchain-indexer/src/consensus, +// which reconcile-twins.sh vendors into every other copy, including +// xchain-documentation/protocol/reference-impl/consensus; the canonical vectors +// live in xchain-documentation/protocol/test-vectors. This guard runs in every // repo and asserts BOTH: // 1. BEHAVIOR - the local copy matches the canonical vectors. -// 2. IDENTITY - the local copy is byte-identical to the canonical source. +// 2. IDENTITY - the local copy is byte-identical to the xchain-documentation copy. // (1) catches a logic change that happens to pass the local unit suite; (2) // catches ANY edit to one copy that was not propagated to the others. When the // sibling xchain-documentation repo is not checked out (standalone deploy), skip @@ -117,7 +119,22 @@ describe('consensus-primitive conformance: byte-identity to canonical source @re const canon = fs.readFileSync(path.join(CANON_DIR, 'consensus', f), 'utf8'); assert.strictEqual(local, canon, 'this repo\'s consensus/' + f + ' has drifted from the canonical source; ' + - 'edit xchain-documentation/protocol/reference-impl/consensus/' + f + ' and re-vendor all five copies.'); + 'edit xchain-indexer/src/consensus/' + f + ' and re-run reconcile-twins.sh to re-vendor every copy.'); + }); + }); + + // The activation-registry parts are byte twins of the canonical gate_registry/ + // copies; the per-repo entry gate_registry.js is not a twin and stays out. + ['core.js', 'shared_rows.js', 'regtest_env.js', 'shared_rows_1.js', 'shared_rows_2.js', + 'shared_rows_3.js', 'shared_rows_4.js', 'shared_rows_5.js'].forEach(function(f){ + it('gate_registry/' + f + ' is byte-identical to xchain-documentation/protocol/reference-impl', function(){ + const rel = path.join('consensus', 'gate_registry', f); + const local = fs.readFileSync(path.join(LOCAL_DIR, rel), 'utf8'); + const canon = fs.readFileSync(path.join(CANON_DIR, rel), 'utf8'); + assert.strictEqual(local, canon, + 'this repo\'s consensus/gate_registry/' + f + ' has drifted from the canonical source; ' + + 'edit xchain-documentation/protocol/reference-impl/consensus/gate_registry/' + f + + ' and re-vendor every consumer copy, never the vendored copy alone.'); }); }); }); diff --git a/test/unit/consensus/state_subtree_constants.test.js b/test/unit/consensus/state_subtree_constants.test.js index c11cb5d5..94a3dbae 100644 --- a/test/unit/consensus/state_subtree_constants.test.js +++ b/test/unit/consensus/state_subtree_constants.test.js @@ -54,10 +54,12 @@ const M = require('../../../src/merkle.js'); // pair below is a raw byte compare. const GATE_FILE = 'src/consensus/gates/state_subtree_gate.js'; const SELF = path.resolve(__dirname, '../../..', GATE_FILE); +const SIBLING_ROOT = process.env.XCHAIN_SIBLING_ROOT || path.resolve(__dirname, '../../../..'); const SIBLINGS = { - 'xchain-indexer': path.resolve(__dirname, '../../../..', 'xchain-indexer', GATE_FILE), - 'xchain-sync': path.resolve(__dirname, '../../../..', 'xchain-sync', GATE_FILE), - 'xchain-explorer': path.resolve(__dirname, '../../../..', 'xchain-explorer', GATE_FILE) + 'xchain-indexer': path.resolve(process.env.XCHAIN_INDEXER_PATH || process.env.XCHAIN_INDEXER_DIR + || path.join(SIBLING_ROOT, 'xchain-indexer'), GATE_FILE), + 'xchain-sync': path.resolve(process.env.XCHAIN_SYNC_DIR || path.join(SIBLING_ROOT, 'xchain-sync'), GATE_FILE), + 'xchain-explorer': path.resolve(process.env.XCHAIN_EXPLORER_DIR || path.join(SIBLING_ROOT, 'xchain-explorer'), GATE_FILE) }; const SIBLING_REQUIRED = process.env.XCHAIN_REQUIRE_SIBLINGS === '1'; diff --git a/test/unit/contract/contract_parity.test.js b/test/unit/contract/contract_parity.test.js index d9dad444..727ed2e6 100644 --- a/test/unit/contract/contract_parity.test.js +++ b/test/unit/contract/contract_parity.test.js @@ -33,8 +33,9 @@ const XChainSDK = require('../../../src/XChainSDK.js'); const ContractUtils = require('../../../src/contract/utils.js'); const VENDORED_DIR = path.join(__dirname, '../..', '..', 'src', 'contract'); -const VM_SRC_DIR = path.join(__dirname, '../..', '..', '..', 'xchain-vm', 'src'); -const CONTRACTS_DIR = path.join(__dirname, '../..', '..', '..', 'xchain-contracts'); +const SIBLING_ROOT = process.env.XCHAIN_SIBLING_ROOT || path.join(__dirname, '../..', '..', '..'); +const VM_SRC_DIR = path.join(process.env.XCHAIN_VM_DIR || path.join(SIBLING_ROOT, 'xchain-vm'), 'src'); +const CONTRACTS_DIR = process.env.XCHAIN_CONTRACTS_DIR || path.join(SIBLING_ROOT, 'xchain-contracts'); // stripped_globals.js is in the vendor set because it is the ONE // definition of the sandbox's stripped-global names, required by lint_core.js // here and by sandbox.js / toolkit/authoring.js in xchain-vm. It is @@ -182,7 +183,7 @@ describe('contract-lint parity + drift', function () { it('the four templates emit zero Move-2 findings (low false-positive)', function () { const fs = require('fs'); - const dir = path.join(__dirname, '../..', '..', '..', 'xchain-contracts'); + const dir = CONTRACTS_DIR; if (!requireSibling(this, dir)) return; for (const name of ['escrow', 'vesting', 'crowdsale', 'amm']) { const f = path.join(dir, name, name + '.js'); diff --git a/test/unit/contract/contracts_parse_abi.test.js b/test/unit/contract/contracts_parse_abi.test.js index ce91ef65..e41001f1 100644 --- a/test/unit/contract/contracts_parse_abi.test.js +++ b/test/unit/contract/contracts_parse_abi.test.js @@ -8,7 +8,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -// ContractUtils.parseAbi (spec: xchain-documentation/protocol/Contract_ABI.md). +// ContractUtils.parseAbi (spec: xchain-documentation/protocol/contract-abi.md). // The fixture strings are duplicated verbatim from the explorer suite // (xchain-explorer/test/unit/contract_introspect.test.js) so the two // hand-synced parser copies are tested against identical inputs. diff --git a/test/unit/contract/template_parity.test.js b/test/unit/contract/template_parity.test.js index b85ea527..1fe1e018 100644 --- a/test/unit/contract/template_parity.test.js +++ b/test/unit/contract/template_parity.test.js @@ -14,7 +14,8 @@ // generated by scripts/sync-templates.js). They MUST stay byte-identical to the // canonical xchain-contracts repo, or authors scaffold stale code. The drift guard // (sha256 of the base64-decoded embed vs the canonical file) catches a stale embed. -// Skipped when the sibling xchain-contracts checkout is absent. +// Skipped when the sibling xchain-contracts checkout is absent, and a hard failure +// instead under XCHAIN_REQUIRE_SIBLINGS=1. const assert = require('assert'); const crypto = require('crypto'); @@ -24,8 +25,20 @@ const path = require('path'); const XChainSDK = require('../../../src/XChainSDK.js'); const EMBEDDED = require('../../../src/contract/templates.js'); -const CONTRACTS_DIR = path.join(__dirname, '../..', '..', '..', 'xchain-contracts'); -const haveContracts = fs.existsSync(CONTRACTS_DIR); +const SIBLING_ROOT = process.env.XCHAIN_SIBLING_ROOT || path.join(__dirname, '../..', '..', '..'); +const CONTRACTS_DIR = process.env.XCHAIN_CONTRACTS_DIR || path.join(SIBLING_ROOT, 'xchain-contracts'); + +// Throw where the sibling is required, skip elsewhere, so the drift guard can +// never green-by-skip in the job that checks xchain-contracts out. +const SIBLING_REQUIRED = process.env.XCHAIN_REQUIRE_SIBLINGS === '1'; +function requireContracts(ctx) { + if (fs.existsSync(CONTRACTS_DIR)) return true; + if (SIBLING_REQUIRED) + throw new Error('template drift guard cannot run: required sibling missing at ' + CONTRACTS_DIR + + ' (the job setting XCHAIN_REQUIRE_SIBLINGS=1 must check out xchain-contracts)'); + ctx.skip(); + return false; +} function sha256(buf) { return crypto.createHash('sha256').update(buf).digest('hex'); } function canonicalTemplatePath(name) { return path.join(CONTRACTS_DIR, name, name + '.js'); } @@ -39,7 +52,7 @@ describe('scaffold sources: drift + verdict', function () { describe('drift guard (embedded === canonical xchain-contracts)', function () { for (const name of Object.keys(EMBEDDED.templates)) { it('template ' + name + ' matches xchain-contracts/' + name + '/' + name + '.js', function () { - if (!haveContracts) return this.skip(); + if (!requireContracts(this)) return; const embedded = Buffer.from(EMBEDDED.templates[name], 'base64'); const canonical = fs.readFileSync(canonicalTemplatePath(name)); assert.strictEqual(sha256(embedded), sha256(canonical), @@ -48,7 +61,7 @@ describe('scaffold sources: drift + verdict', function () { } for (const name of Object.keys(EMBEDDED.patterns)) { it('pattern ' + name + ' matches xchain-contracts/patterns/' + name + '.js', function () { - if (!haveContracts) return this.skip(); + if (!requireContracts(this)) return; const embedded = Buffer.from(EMBEDDED.patterns[name], 'base64'); const canonical = fs.readFileSync(canonicalPatternPath(name)); assert.strictEqual(sha256(embedded), sha256(canonical), diff --git a/test/unit/decoder/batch_grammar.test.js b/test/unit/decoder/batch_grammar.test.js index c20bf9e5..eba7a1e1 100644 --- a/test/unit/decoder/batch_grammar.test.js +++ b/test/unit/decoder/batch_grammar.test.js @@ -9,6 +9,7 @@ // findings. const { expect } = require('chai'); +const { resolveIndexerAction } = require('../../helpers/indexer_action_handler.js'); const { parse, BATCH_ACTION_LIMITS, @@ -155,14 +156,11 @@ describe('decoder.parse - BATCH sub-grammar', function () { describe('sibling conformance vs xchain-indexer batch.js', function () { const fs = require('fs'); - const path = require('path'); - const INDEXER = process.env.XCHAIN_INDEXER_PATH || - path.join(__dirname, '..', '..', '..', '..', 'xchain-indexer'); - const BATCH_SRC = path.join(INDEXER, 'src', 'actions', 'batch.js'); - before(function () { if (!fs.existsSync(BATCH_SRC)) this.skip(); }); + const batchHandler = resolveIndexerAction('batch'); + before(function () { if (!batchHandler) this.skip(); }); it('actionLimits values are byte-equal to the indexer source', function () { - const src = fs.readFileSync(BATCH_SRC, 'utf8'); + const src = batchHandler.sources.map((file) => fs.readFileSync(file, 'utf8')).join('\n'); for (const [action, limit] of Object.entries(BATCH_ACTION_LIMITS)) { // A gated cap lives in `gatedActionLimits` on purpose: writing it // into `actionLimits` would have applied it retroactively, so @@ -182,7 +180,7 @@ describe('decoder.parse - BATCH sub-grammar', function () { // what makes the merge in batch_limits.js a mirror rather than a // guess. `gatedActionLimits` capitalizes its A, so the pattern reads // only the ungated table by construction. - const src = fs.readFileSync(BATCH_SRC, 'utf8'); + const src = batchHandler.sources.map((file) => fs.readFileSync(file, 'utf8')).join('\n'); expect(/actionLimits\['DEPLOY'\]/.test(src)).to.equal(false); }); }); diff --git a/test/unit/protocol/action_manifest_conformance.test.js b/test/unit/protocol/action_manifest_conformance.test.js index b37c72dd..7de2515a 100644 --- a/test/unit/protocol/action_manifest_conformance.test.js +++ b/test/unit/protocol/action_manifest_conformance.test.js @@ -31,6 +31,7 @@ const path = require('path'); const VENDORED = path.join(__dirname, '../..', 'fixtures', 'action-manifest.json'); const MANIFEST = JSON.parse(fs.readFileSync(VENDORED, 'utf8')); const Formats = require('../../../src/protocol/formats.js'); +const { siblingCheckout, skipOrFail } = require('../../helpers/sibling_checkout.js'); const EDIT_HINT = 'Edit xchain-documentation/protocol/action-manifest.json, re-vendor with ' + 'bin/sync-action-manifest.sh, or change src/protocol/formats.js.'; @@ -109,7 +110,8 @@ describe('ACTION manifest conformance: sdk userEncodable set @regression', funct describe('byte-identity to canonical manifest', function () { const DOCS = process.env.XCHAIN_DOCS_DIR || path.join(__dirname, '../..', '..', '..', 'xchain-documentation'); const CANON = path.join(DOCS, 'protocol', 'action-manifest.json'); - before(function () { if (!fs.existsSync(CANON)) { if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') throw new Error('XCHAIN_REQUIRE_SIBLINGS=1 but canonical action-manifest.json not found at ' + CANON); this.skip(); } }); + // Refuses an absent docs checkout and a lane symlink into a live main checkout alike. + before(function () { const docs = siblingCheckout(__dirname, CANON); if (!docs.usable) skipOrFail(this, docs, 'the canonical action-manifest.json byte-identity guard'); }); it('vendored test/fixtures/action-manifest.json is byte-identical to canonical', function () { assert.strictEqual(fs.readFileSync(VENDORED, 'utf8'), fs.readFileSync(CANON, 'utf8'), 'vendored action-manifest.json drifted from canonical; edit ' + @@ -161,12 +163,10 @@ function registerIndexerHandlerAuditTests() { const INDEXER = process.env.XCHAIN_INDEXER_DIR || path.join(__dirname, '../..', '..', '..', 'xchain-indexer'); const ACTIONS_DIR = path.join(INDEXER, 'src', 'actions'); + // Refuses an absent indexer checkout and a lane symlink into a live main checkout alike. before(function () { - if (!fs.existsSync(ACTIONS_DIR)) { - if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') - throw new Error('XCHAIN_REQUIRE_SIBLINGS=1 but indexer handlers not found at ' + ACTIONS_DIR); - this.skip(); - } + const indexer = siblingCheckout(__dirname, ACTIONS_DIR); + if (!indexer.usable) skipOrFail(this, indexer, 'the indexer-handler audit'); }); // The indexer loader accepts flat handler modules and feature directories diff --git a/test/unit/protocol/batch_builder_cost_weight.test.js b/test/unit/protocol/batch_builder_cost_weight.test.js index 5e61f099..e9b5cb2b 100644 --- a/test/unit/protocol/batch_builder_cost_weight.test.js +++ b/test/unit/protocol/batch_builder_cost_weight.test.js @@ -38,8 +38,8 @@ ********************************************************************/ const { expect } = require('chai'); -const fs = require('fs'); const path = require('path'); +const { loadIndexerAction } = require('../../helpers/indexer_action_handler.js'); const XChainSDK = require('../../../src/XChainSDK.js'); const { @@ -96,16 +96,14 @@ const VECTORS = [ ]; function loadBatchHandler() { - const roots = [process.env.XCHAIN_INDEXER_PATH, - path.join(__dirname, '../..', '..', '..', 'xchain-indexer')].filter(Boolean); - const root = roots.find((r) => fs.existsSync(path.join(r, 'src', 'actions', 'batch.js'))); - if (!root) return null; + const resolved = loadIndexerAction('batch'); + if (!resolved) return null; + const { root, Handler: Batch } = resolved; process.env.INDEXER_COIN = process.env.INDEXER_COIN || 'BTC'; process.env.INDEXER_NETWORK = process.env.INDEXER_NETWORK || 'regtest'; - let Batch, IdxUtility, IdxConfig, ProtocolChanges; + let IdxUtility, IdxConfig, ProtocolChanges; try { - Batch = require(path.join(root, 'src', 'actions', 'batch.js')); IdxUtility = require(path.join(root, 'src', 'utility.js')); IdxConfig = require(path.join(root, 'src', 'config.js')); ProtocolChanges = require(path.join(root, 'src', 'protocol_changes.js')); diff --git a/test/unit/protocol/batch_coinpay_output_plan.test.js b/test/unit/protocol/batch_coinpay_output_plan.test.js index ddccc58b..1f7edddb 100644 --- a/test/unit/protocol/batch_coinpay_output_plan.test.js +++ b/test/unit/protocol/batch_coinpay_output_plan.test.js @@ -206,3 +206,42 @@ describe(COINPAY_OUTPUT_PLAN_TITLE, function(){ }); }); + +describe(COINPAY_OUTPUT_PLAN_TITLE, function(){ + + describe('checkCoinpayOutputPlan exact comparison', function(){ + + it('FAILS an output one satoshi short of a large total, inside the mathjs tolerance band', function(){ + const result = checkCoinpayOutputPlan( + [{ payee: 'sellerA', amount: '100000.00000000' }], + [{ address: 'sellerA', amount: '99999.99999999' }] + ); + expect(result.ok).to.equal(false); + expect(result.violations).to.deep.equal([{ + payee: 'sellerA', + owed: '100000', + available: '99999.99999999', + reason: 'INSUFFICIENT', + }]); + }); + + it('FAILS an output one satoshi short of a small total', function(){ + const result = checkCoinpayOutputPlan( + [{ payee: 'sellerA', amount: '1.00000000' }], + [{ address: 'sellerA', amount: '0.99999999' }] + ); + expect(result.ok).to.equal(false); + expect(result.violations[0].reason).to.equal('INSUFFICIENT'); + }); + + it('PASSES an output exactly equal to a large total', function(){ + const result = checkCoinpayOutputPlan( + [{ payee: 'sellerA', amount: '100000.00000000' }], + [{ address: 'sellerA', amount: '100000' }] + ); + expect(result.ok).to.equal(true); + }); + + }); + +}); diff --git a/test/unit/protocol/batch_limits_conformance.test/02_arbiter_half_sibling_xchain_indexer_checkout.test.js b/test/unit/protocol/batch_limits_conformance.test/02_arbiter_half_sibling_xchain_indexer_checkout.test.js index 71445636..2811699e 100644 --- a/test/unit/protocol/batch_limits_conformance.test/02_arbiter_half_sibling_xchain_indexer_checkout.test.js +++ b/test/unit/protocol/batch_limits_conformance.test/02_arbiter_half_sibling_xchain_indexer_checkout.test.js @@ -38,8 +38,8 @@ ********************************************************************/ const { expect } = require('chai'); -const fs = require('fs'); const path = require('path'); +const { loadIndexerAction } = require('../../../helpers/indexer_action_handler.js'); const { BATCH_ACTION_LIMITS, @@ -72,16 +72,15 @@ const TICKER_IDS = new Map([ const EXISTING_TICKER_IDS = new Set(TICKER_IDS.values()); function loadIndexer(context) { - const roots = [process.env.XCHAIN_INDEXER_PATH, - path.join(__dirname, '../..', '..', '..', '..', 'xchain-indexer')].filter(Boolean); - const root = roots.find((r) => fs.existsSync(path.join(r, 'src', 'actions', 'batch.js'))); - if (!root) return context.skip(); + const resolved = loadIndexerAction('batch'); + if (!resolved) return context.skip(); + const { root, Handler: Batch } = resolved; process.env.INDEXER_COIN = process.env.INDEXER_COIN || 'BTC'; process.env.INDEXER_NETWORK = process.env.INDEXER_NETWORK || 'regtest'; try { return { - Batch: require(path.join(root, 'src', 'actions', 'batch.js')), + Batch, IdxUtility: require(path.join(root, 'src', 'utility.js')), IdxConfig: require(path.join(root, 'src', 'config.js')), ProtocolChanges: require(path.join(root, 'src', 'protocol_changes.js')), diff --git a/test/unit/protocol/batch_limits_conformance.test/04_weight_conformance_arbiter_half.test.js b/test/unit/protocol/batch_limits_conformance.test/04_weight_conformance_arbiter_half.test.js index 3801ce99..a2d575b6 100644 --- a/test/unit/protocol/batch_limits_conformance.test/04_weight_conformance_arbiter_half.test.js +++ b/test/unit/protocol/batch_limits_conformance.test/04_weight_conformance_arbiter_half.test.js @@ -38,8 +38,8 @@ ********************************************************************/ const { expect } = require('chai'); -const fs = require('fs'); const path = require('path'); +const { loadIndexerAction } = require('../../../helpers/indexer_action_handler.js'); const { BATCH_COMMAND_WEIGHTS, @@ -54,16 +54,15 @@ const { } = require('./fixtures/weight_vectors.js'); function loadIndexer(context) { - const roots = [process.env.XCHAIN_INDEXER_PATH, - path.join(__dirname, '../..', '..', '..', '..', 'xchain-indexer')].filter(Boolean); - const root = roots.find((r) => fs.existsSync(path.join(r, 'src', 'actions', 'batch.js'))); - if (!root) return context.skip(); + const resolved = loadIndexerAction('batch'); + if (!resolved) return context.skip(); + const { root, Handler: Batch } = resolved; process.env.INDEXER_COIN = process.env.INDEXER_COIN || 'BTC'; process.env.INDEXER_NETWORK = process.env.INDEXER_NETWORK || 'regtest'; try { return { - Batch: require(path.join(root, 'src', 'actions', 'batch.js')), + Batch, IdxUtility: require(path.join(root, 'src', 'utility.js')), IdxConfig: require(path.join(root, 'src', 'config.js')), ProtocolChanges: require(path.join(root, 'src', 'protocol_changes.js')), diff --git a/test/unit/protocol/light.test.js b/test/unit/protocol/light.test.js index 762f35d6..0f8fb5ff 100644 --- a/test/unit/protocol/light.test.js +++ b/test/unit/protocol/light.test.js @@ -240,11 +240,23 @@ describe('SPV Phase 4: sdk.light pure verifiers', function () { it('the identity binding is OPT-IN and ignores absent fields, so existing callers are unaffected', function () { const { proof, stateRoot } = buildBalanceProof(ADDR_A, TICK, '5'); - for (const expected of [undefined, null, {}, { address: undefined }, { tick: null }]) { + for (const expected of [undefined, null, {}, { address: undefined }]) { assert.strictEqual(light.verifyBalanceProof(proof, stateRoot, CHAIN, NET, expected).verified, true, 'expected=' + JSON.stringify(expected) + ' must not change the verdict'); } }); + + // A null identity FIELD is not an absent field: only a caller bug produces + // one, and skipping it would verify a proof for an identity the caller never + // named. It fails closed rather than binding nothing. + it('a null identity field is refused, never skipped', function () { + const { proof, stateRoot } = buildBalanceProof(ADDR_A, TICK, '5'); + for (const expected of [{ tick: null }, { address: null, tick: TICK }]) { + const r = light.verifyBalanceProof(proof, stateRoot, CHAIN, NET, expected); + assert.strictEqual(r.verified, false, 'expected=' + JSON.stringify(expected) + ' must not verify'); + assert.ok(/^VERIFY_ERROR:/.test(r.reason) && /null/.test(r.reason), 'reason names the null field: ' + r.reason); + } + }); }); describe('SPV Phase 4: sdk.light pure verifiers', function () { diff --git a/test/unit/protocol/validator.test/06_validator_file_gate_min_amount_pc_29.test.js b/test/unit/protocol/validator.test/06_validator_file_gate_min_amount_pc_29.test.js index ca1e7b91..653a3a2d 100644 --- a/test/unit/protocol/validator.test/06_validator_file_gate_min_amount_pc_29.test.js +++ b/test/unit/protocol/validator.test/06_validator_file_gate_min_amount_pc_29.test.js @@ -71,14 +71,16 @@ describe('Validator: FILE GATE_MIN_AMOUNT (PC-29)', function () { // Cross-repo byte identity. Skips (rather than fails) when a sibling checkout // is absent, matching the repo's other sibling-conformance tests; CI sets // XCHAIN_REQUIRE_SIBLINGS=1 so a missing sibling hard-fails there. + const SIBLING_ROOT = process.env.XCHAIN_SIBLING_ROOT || path.join(__dirname, '../../../../..'); const SIBLINGS = [ - ['xchain-indexer', 'test/fixtures/gate-min-amount-vectors.json'], - ['xchain-wallet', 'test/fixtures/gate-min-amount-vectors.json'] + ['xchain-indexer', process.env.XCHAIN_INDEXER_PATH || process.env.XCHAIN_INDEXER_DIR, + 'test/fixtures/gate-min-amount-vectors.json'], + ['xchain-wallet', process.env.XCHAIN_WALLET_DIR, 'test/fixtures/gate-min-amount-vectors.json'] ]; const canonical = fs.readFileSync(FIXTURE); - SIBLINGS.forEach(([repo, rel]) => { + SIBLINGS.forEach(([repo, root, rel]) => { it(`the ${repo} copy is byte-identical`, function () { - const p = path.join(__dirname, '../../../../..', repo, rel); + const p = path.join(root || path.join(SIBLING_ROOT, repo), rel); if (!fs.existsSync(p)) { if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') throw new Error('sibling ' + repo + ' fixture missing: ' + p); diff --git a/test/unit/sdk/prototype_method_descriptors.test/fixtures/descriptors_clients.js b/test/unit/sdk/prototype_method_descriptors.test/fixtures/descriptors_clients.js index fbf146f1..7332a8e8 100644 --- a/test/unit/sdk/prototype_method_descriptors.test/fixtures/descriptors_clients.js +++ b/test/unit/sdk/prototype_method_descriptors.test/fixtures/descriptors_clients.js @@ -40,6 +40,9 @@ module.exports = { ['constructor', false, true, true, 'function', 0], ['extractServiceEndpoints', false, true, true, 'function', 1], ['getAllConfig', false, true, true, 'function', 0], + // Added by 43c77f6 (fix(hub): use public registry for keyless discovery), + // after this split: the keyless/public discovery entry point. + ['getDiscoveryConfig', false, true, true, 'function', 0], ['getCapabilityThresholds', false, true, true, 'function', 0], ['ping', false, true, true, 'function', 0], ['startPolling', false, true, true, 'function', 1], diff --git a/test/unit/sdk/sdk_config.test.js b/test/unit/sdk/sdk_config.test.js index 8670cc24..615de993 100644 --- a/test/unit/sdk/sdk_config.test.js +++ b/test/unit/sdk/sdk_config.test.js @@ -98,9 +98,12 @@ describe('XChainSDK config resolution', function () { describe('lazy hub overlay (ensureReady)', function () { // Build an SDK whose hub returns the given endpoints, with polling stubbed. - function withHub(endpoints, getAllConfig) { + // discover() reaches the hub through getDiscoveryConfig() (the keyless/ + // public path added by 43c77f6), not getAllConfig(), so that is the + // method to stub here; otherwise these calls quietly race the real network. + function withHub(endpoints, getDiscoveryConfig) { const sdk = new XChainSDK({ network: 'bitcoin-mainnet' }); - sdk.hub.getAllConfig = getAllConfig || (async () => ({})); + sdk.hub.getDiscoveryConfig = getDiscoveryConfig || (async () => ({})); sdk.hub.extractServiceEndpoints = () => endpoints; sdk.hub.startPolling = () => {}; return sdk; @@ -151,7 +154,7 @@ describe('XChainSDK config resolution', function () { describe('downgrade guard', function () { function applyHub(opts, endpoints) { const sdk = new XChainSDK(Object.assign({ network: 'bitcoin-mainnet' }, opts)); - sdk.hub.getAllConfig = async () => ({}); + sdk.hub.getDiscoveryConfig = async () => ({}); sdk.hub.extractServiceEndpoints = () => endpoints; sdk.hub.startPolling = () => {}; return sdk.ensureReady().then(() => sdk); diff --git a/test/unit/sdk/xchain_sdk.test.js b/test/unit/sdk/xchain_sdk.test.js index 168bd164..cedee017 100644 --- a/test/unit/sdk/xchain_sdk.test.js +++ b/test/unit/sdk/xchain_sdk.test.js @@ -163,20 +163,22 @@ describe('XChainSDK', function () { it('calls discover when hub is configured', async function () { const sdk = makeSDK(); - // Create a fake hub + // Create a fake hub. discover() reaches the hub through + // getDiscoveryConfig() (the keyless/public entry point added by + // 43c77f6), not getAllConfig(), so the fake must expose that. sdk.hub = { - getAllConfig: sinon.stub().resolves({}), + getDiscoveryConfig: sinon.stub().resolves({}), extractServiceEndpoints: sinon.stub().returns({}), startPolling: sinon.stub() }; await sdk.init(); - expect(sdk.hub.getAllConfig.called).to.be.true; + expect(sdk.hub.getDiscoveryConfig.called).to.be.true; }); it('warns but does not throw when hub fails and clients exist', async function () { const sdk = makeSDK(); sdk.hub = { - getAllConfig: sinon.stub().rejects(new Error('hub down')), + getDiscoveryConfig: sinon.stub().rejects(new Error('hub down')), extractServiceEndpoints: sinon.stub().returns({}), startPolling: sinon.stub() }; @@ -190,7 +192,7 @@ describe('XChainSDK', function () { network: 'bitcoin-mainnet', hubUrl: 'http://localhost:8001' }); - sdk.hub.getAllConfig = sinon.stub().rejects(new Error('hub down')); + sdk.hub.getDiscoveryConfig = sinon.stub().rejects(new Error('hub down')); sdk.hub.extractServiceEndpoints = sinon.stub().returns({}); sdk.hub.startPolling = sinon.stub(); sdk.explorer = null; diff --git a/test/unit/sdk/xchain_sdk.test/08_apply_endpoints.test.js b/test/unit/sdk/xchain_sdk.test/08_apply_endpoints.test.js index c880d655..d718d53b 100644 --- a/test/unit/sdk/xchain_sdk.test/08_apply_endpoints.test.js +++ b/test/unit/sdk/xchain_sdk.test/08_apply_endpoints.test.js @@ -101,7 +101,10 @@ describe('XChainSDK', function () { network: 'bitcoin-regtest', hubUrl: 'http://localhost:8001' }); - sdk.hub.getAllConfig = sinon.stub().resolves({}); + // ensureReady() drives discover(), which calls hub.getDiscoveryConfig() + // (the keyless/public path), not getAllConfig(); stub the method that + // is actually on the call path. + sdk.hub.getDiscoveryConfig = sinon.stub().resolves({}); sdk.hub.extractServiceEndpoints = sinon.stub().returns({ encoderUrl: 'http://encoder.test', encoderPort: 3000 @@ -157,7 +160,9 @@ describe('XChainSDK', function () { it('logs warn only once per service even if called multiple times', async function () { const sdk = new XChainSDK({ network: 'bitcoin-mainnet' }); - sdk.hub.getAllConfig = sinon.stub().resolves({}); + // Stub the method ensureReady()'s discover() actually calls, so this + // stays hermetic instead of quietly racing a real network fetch. + sdk.hub.getDiscoveryConfig = sinon.stub().resolves({}); sdk.hub.extractServiceEndpoints = sinon.stub().returns({ explorerUrl: 'http://insecure.internal', explorerPort: 18080 diff --git a/test/unit/tooling/sibling_coverage.test.js b/test/unit/tooling/sibling_coverage.test.js index dd4a6be2..2bfda851 100644 --- a/test/unit/tooling/sibling_coverage.test.js +++ b/test/unit/tooling/sibling_coverage.test.js @@ -36,8 +36,8 @@ const REPO_ROOT = path.join(__dirname, '../..', '..'); const SIBLING_ROOT = process.env.XCHAIN_SIBLING_ROOT || path.join(REPO_ROOT, '..'); const STRICT = process.env.XCHAIN_REQUIRE_SIBLINGS === '1'; -// Each entry names a sibling checkout, the env overrides the existing suites -// already read for it (first one set wins, so this resolves exactly as they do), +// Each entry names a sibling checkout, the env overrides its guards read for it +// (first one set wins; every name must have a process.env reader, asserted below), // a path that must exist inside it to count as a real checkout rather than an // empty directory, and the guard family that goes quiet when it is missing. const SIBLINGS = [ @@ -54,7 +54,8 @@ const SIBLINGS = [ guards: 'abi-core drift and the typed explorer-route contract' }, { repo: 'xchain-documentation', envs: ['XCHAIN_DOCS_DIR', 'XCHAIN_DOCUMENTATION_DIR'], marker: 'protocol', - guards: 'consensus reference-impl parity (quorum, equivocation, reorg buffer) and the canonical action map' }, + guards: 'consensus reference-impl parity (quorum, equivocation, reorg buffer, gate_registry parts) ' + + 'and the canonical action map' }, { repo: 'xchain-encoder', envs: ['XCHAIN_ENCODER_DIR'], marker: 'src', guards: 'the vendored roundtrip-conformance fixture' }, @@ -86,6 +87,35 @@ function resolve(entry) { return { dir: path.join(SIBLING_ROOT, entry.repo), via: 'sibling root' }; } +// Walk the guard sources for a process.env reader of every roster override; what +// stays in `pending` has no reader outside this file. +function scanForEnvReaders() { + const pending = new Map(); + for (const entry of SIBLINGS) for (const key of entry.envs) pending.set('process.env.' + key, `${key} (${entry.repo})`); + let scanned = 0; + const UNIT = path.join(REPO_ROOT, 'test', 'unit'); + function walk(dir) { + if (!pending.size || !fs.existsSync(dir)) return; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + if (!pending.size) return; + const p = path.join(dir, e.name); + if (e.isDirectory()) { + if (!['node_modules', 'fixtures', 'pins'].includes(e.name) && p !== UNIT) walk(p); + continue; + } + if (!/\.(js|sh)$/.test(e.name) || p === __filename) continue; + const text = fs.readFileSync(p, 'utf8'); + scanned++; + for (const needle of [...pending.keys()]) if (text.includes(needle)) pending.delete(needle); + } + } + // Guards live mostly under test/unit, so read it first and stop once every name is found. + walk(UNIT); + walk(path.join(REPO_ROOT, 'test')); + walk(path.join(REPO_ROOT, 'bin')); + return { scanned, pending }; +} + // Present means "a real checkout", not merely "a directory exists": an empty // placeholder would resolve and then every guard inside it would still skip, // which is the exact silence this file exists to break. @@ -120,6 +150,16 @@ describe('cross-repo sibling coverage (what this run could NOT verify)', functio } }); + it('advertises only env overrides that some guard actually reads', function () { + // An override no guard reads makes this roster report a sibling resolvable + // while the guard it names resolves elsewhere and skips. + this.timeout(60000); // a full tree read on a loaded venue outruns the 5s default + const { scanned, pending } = scanForEnvReaders(); + expect(scanned, 'no guard sources found to scan').to.be.greaterThan(0); + expect([...pending.values()], 'roster env overrides with no process.env reader outside this file') + .to.deep.equal([]); + }); + it('resolves every sibling checkout the cross-repo guards depend on', function () { if (!absent.length) return; const detail = absent.map(r => `${r.repo} (expected ${r.dir}, via ${r.via}; silences ${r.guards})`).join('; ');