diff --git a/CHANGELOG.md b/CHANGELOG.md index c0a6c9d7..01f2199f 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 + +### Changed +- Expanded activation, mixed-hub, bridge-rail, and attest-mirror coverage with condition-based waits and clearer failure diagnostics. + + ## [0.20.0] - 2026-09-17 ### Added diff --git a/Dockerfile b/Dockerfile index 047a1d2d..5b04ba04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,9 @@ -# Pin Node 22 (bookworm, not alpine): the platform requires Node 22 exactly. -# node:latest resolves to Node 24+, which can't build isolated-vm, and alpine's -# musl breaks native addon builds. Matches xchain-indexer's base image. -FROM node:22-bookworm +# Pin Node 22 (bookworm, not alpine) by digest: the platform requires Node 22 +# exactly, and this digest's V8/ICU build matches xchain-vm's consensus runtime +# pin, which a floating tag can drift past and fail. node:latest resolves to +# Node 24+, which can't build isolated-vm, and alpine's musl breaks native +# addon builds. Matches xchain-indexer's base image. +FROM node:22.23.2-bookworm@sha256:dd5847a04b0deee391fa145f1f4c6d214196668b6bcc7988ebed67249f226844 RUN mkdir /XChainE2ETest/ # xchain-hub is staged into the build context by xchain-node's install diff --git a/README.md b/README.md index aec083b8..3afc990c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # XChain Platform End-to-End Test Suite

- Version + Version Tests Node License diff --git a/bin/pin-identity.js b/bin/pin-identity.js new file mode 100644 index 00000000..97eaa29b --- /dev/null +++ b/bin/pin-identity.js @@ -0,0 +1,141 @@ +#!/usr/bin/env node +/********************************************************************* + * + * Copyright © 2025-2026 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. + * + ********************************************************************** + * + * Pins the byte identity of the coin registry and every vendored twin. + * + * USAGE + * node bin/pin-identity.js + * node bin/pin-identity.js --out + * node bin/pin-identity.js --compare + * + ********************************************************************/ + +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const SUITE_TITLE_PIN = 'bin/pins/at1-suite-titles.json'; +const COIN_FILES = [ + 'src/coins/BTC.js', + 'src/coins/DOGE.js', + 'src/coins/LTC.js', + 'src/coins/consensus_pin.js', + 'src/coins/index.js', +]; +const VENDORED_TWIN_FILES = COIN_FILES.slice(); + +function sha256File(relativePath) { + const bytes = fs.readFileSync(path.join(REPO_ROOT, relativePath)); + return crypto.createHash('sha256').update(bytes).digest('hex'); +} + +function hashFiles(files) { + const hashes = {}; + for (const relativePath of files.slice().sort()) hashes[relativePath] = sha256File(relativePath); + return hashes; +} + +function buildIdentity() { + const coinsDirectory = path.join(REPO_ROOT, 'src', 'coins'); + const coinsPresent = fs.existsSync(coinsDirectory) && fs.statSync(coinsDirectory).isDirectory(); + const suiteTitlePin = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, SUITE_TITLE_PIN), 'utf8')); + const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')); + const expectedScripts = Object.keys(pkg.scripts || {}).filter((name) => name.startsWith('test')); + const { validatePin } = require('./suite-title-map'); + const suiteProblems = validatePin(suiteTitlePin, expectedScripts); + if (suiteProblems.length) throw new Error(`invalid suite-title pin: ${suiteProblems.join('; ')}`); + return { + algorithm: 'sha256', + coins: coinsPresent ? hashFiles(COIN_FILES) : { none: 'repository has no src/coins directory' }, + vendoredTwins: VENDORED_TWIN_FILES.length + ? hashFiles(VENDORED_TWIN_FILES) + : { none: 'repository carries no vendored twin files' }, + suiteTitleMap: { + path: SUITE_TITLE_PIN, + sha256: sha256File(SUITE_TITLE_PIN), + scripts: Object.keys(suiteTitlePin.scripts).length, + titleSets: Object.keys(suiteTitlePin.titleSets).length, + }, + }; +} + +function compare(pin, fresh) { + const differences = []; + for (const section of ['algorithm', 'coins', 'vendoredTwins', 'suiteTitleMap']) { + if (JSON.stringify(pin[section]) !== JSON.stringify(fresh[section])) differences.push(section); + } + for (const section of Object.keys(pin)) { + if (!Object.prototype.hasOwnProperty.call(fresh, section)) differences.push(section); + } + for (const section of Object.keys(fresh)) { + if (!Object.prototype.hasOwnProperty.call(pin, section)) differences.push(section); + } + return Array.from(new Set(differences)).sort(); +} + +function parseArgs(argv) { + const options = {}; + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === '--out') { + options.out = path.resolve(argv[i + 1]); + i += 1; + } else if (argv[i] === '--compare') { + options.compare = path.resolve(argv[i + 1]); + i += 1; + } else if (argv[i] === '--help' || argv[i] === '-h') { + options.help = true; + } else { + throw new Error(`unknown argument: ${argv[i]}`); + } + } + return options; +} + +function serialize(identity) { + return `${JSON.stringify(identity, null, 2)}\n`; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(fs.readFileSync(__filename, 'utf8').split('*/')[0]); + return; + } + + const identity = buildIdentity(); + if (options.compare) { + const pin = JSON.parse(fs.readFileSync(options.compare, 'utf8')); + const differences = compare(pin, identity); + if (!differences.length) { + console.log(`identity holds against ${path.relative(REPO_ROOT, options.compare)}`); + return; + } + console.error(`identity mismatch in: ${differences.join(', ')}`); + process.exitCode = 1; + return; + } + + const text = serialize(identity); + if (options.out) { + fs.mkdirSync(path.dirname(options.out), { recursive: true }); + fs.writeFileSync(options.out, text); + console.log(`written to ${path.relative(REPO_ROOT, options.out)}`); + return; + } + process.stdout.write(text); +} + +if (require.main === module) main(); + +module.exports = { buildIdentity, compare, hashFiles, serialize }; diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json new file mode 100644 index 00000000..2ef185ef --- /dev/null +++ b/bin/pins/at1-suite-titles.json @@ -0,0 +1,3234 @@ +{ + "titleEncoding": "sha256", + "titleSets": { + "016f5b2846038a24": [ + "40ba5787b2348e07dbba12066fa25f3a8796ba0c6aa8e517d349842dc3de6d60", + "6affdc5b3197e170153b19075f6e3577794d0342ef4b0dd002ed50b8e5fa3da1", + "511fe4f89403b16b62a35220090adc11f75f6d8cca5205b51cbc1fca0b3dc7f6", + "7cdb27f638a58e399c29b031a5e81fd3e8cad9d168eadc2b37b9de5fb7469d4d", + "98d7fa70c338cc14cd6ad51e25399a9828dae311a34af86d1fbc6a9ca3fde720", + "a4690acb58f85d31517bce07d7821ea392614f5088fad5f8ed4ad866e97bac1e" + ], + "038b8d2e4c1f4ff4": [ + "66a5c7ead6fa943664f9cc2edb345b872f3ee35010a736f001f53fba53712e74", + "6b818886aebdbbd8ddee856340e7c9e1818da86409c7a1687b826936cdd53a1a", + "c3b64e5f49236a1bacd8832b02601d31074e7a7e8c6d7102a338b85cdce197f6", + "43102ff7d03161824447eb32abfe44c186f8b592b0629f5fed5616a23529d8e8", + "fff6d85ff5eb528716edb628be8a599f720674dfb01233090d159bc126d428f3", + "a8569854c762fdc841b4d134d6105d2b23c78bf7a63f2740636f0a1a21179cb5", + "cec07d8a2ead9edb740e1dd31a25b67f18cdfbc41316022a15b59226b2cc8528", + "7b9433bebd09accedf2f2d2a7463325e136601ca6b66181ef677a83962ce9131" + ], + "0435717b2b0530cd": [ + "3fc489dfe99f78417e3f5edcf23d3e76b5ca61d18853e8a5231dd6db3706d65b", + "d92b52e05f34a7243ef6238838e30fccd0dd3f3620af45db4720c1c1ce202fa3", + "3b9f3233e86ad7e443dbb6118fb11a20e8bc318db9ebc906052bf3b6a61c8f9a", + "a886281832ae5eca700737acde93903745020d41379a95c0c736a0f343975449", + "3eb6412860fb8d3020c35da57ebe7cc785e060a5cdcf23a81dc636920299ea13" + ], + "0614ca99b9ebc5e4": [ + "5b3714645a26595bd75185116d1cf33e46aa62d16af73aa55020da26bfb554d3", + "ca8321b8f75a9a41e88c4ed1d3305192803f3f8a84e3dd1fd71a358c9976274f", + "554215ca2afff5a8247be66f49bfeff42f1c580887ea8805657e41ba2f92d75f", + "3bec73dc171944d20686c04e82cf9bea335ffe60d0ce4cdea7b58d27a86e5b12", + "b15c8cab67572b5a7d03b2aa8b2bee53741982c010ed002320d6cf45fe8bd77f", + "42fb58dae4a31ebfb6e2d806b7dc3edd7290199747c92596a37a82d82141e942", + "19a2b8668a99d3d3e6141da014a0c7b78282f3e68db2c90e032e615db232f2c1", + "50ea8d83359a55569b57030d7c8f7f3a441efc69231d4e171bdca0fb96a9840c", + "a4c3f5f07048ee012b880fc59eb8ffe92fc377684bc05712d14cceaa2b4feafd", + "8ddb9ef62c596d8513a3625047a38f74d166f2550876e64e1b0424a260881edb", + "efad39b98409f18b78149bcb0a285b58bd3ae82e44b2d526928e1c2b8d39967d", + "79af9f99d17b326a67c881e1cd81d5da87707e118f281e220a6fe9ab2b082789", + "1e1c3050f29fd055520026fc799da060700cff586c7bd94a96253ca25d85ae02", + "bb48b8dfcb0ee04da9c77793fc41528f90ab6b90ccdf1f29fe21baa638170328", + "1a4c1e14d1406661baa620bec044b3505baba21da7f4275cf7febc5e84bb0292", + "b56ae75d49c68d8bd57862e6e83ce29d1bd20282924277565637da6fb9a3b759", + "4b17a633bfdeda299ec8b40ea7f0ede65fc89c17c0c518ba50ff5e532a616d22", + "71565e7e5acfe8ff38ae2c8755f3c16ffe100c44dd9a77627992968012166c3f", + "60ee3a6774baffb0ebd6b120d5d76a188c402f13ffb9fc3ec20d01398cc0d62b", + "f4db5327c7fcb8e4fe5074507766d064a9384b8963fce952793863c5e366516e", + "9e1e37aaed86005227a561c7fbc3138eb7dc519d1480fbc6aa572bc85dc1fd97", + "3d1740242c696e607bf34d33466674b0da0d434f92a7a2063b2412f0ac09735a", + "63ea3b073f964200311f53b20d7f13ac456dc7f6130af06fa200eba0a4bef739", + "f47a2534b97ae656f6cdbfd62957ed2dfcc870d11160763f8cefe02b0230ef14", + "e2096ba60a54196f4f58aa8eea6e1f905ce13ab8e89c41628473928f41bc31a4", + "f12812662c162aacdfcd9600372b9f32ac625886b341005301da0e3de9c1c802", + "dde71fd3730f067fe2f09a3c5aa50637280d5f4928b0af707997b9c72e544314", + "e0a771171adad5d09da0f37dd169946b3d03566d03c8ecee04e7c6a8f25f3673", + "646af1f8b8c4594ee08828f6259af3af0b257d10eb5a1d91947fac6f525a25d4", + "a345ffe1607dbe0905d65d46fa654bbce113a156d18ef32eaf3b24a483657a74", + "33698e263900627e4178366fb55fa43acb7ba119366d2949b854c6cc625af0c2", + "b603b23ca892bfbf804f78a38f6d0326197a1aef31f970ca6249ee25bbe093eb", + "58db36fe6356d76580a09ccbc3d9cbfe3341982f051f05a20ec41ceb771f4570", + "50d83185a672026310100cb1fce9f9936089b0df084173f63fd67bbaf97cfd7a", + "6231c27b726caec657647c0722768773b3e11be7173cc5e35ad9c6cf4f3084ec", + "e7cf7095df6aea5399479abcad2ff8902a2f05b28c11a637b147fc44a75686f5" + ], + "0739e2cf1ce30670": [ + "4d07eb23168c2776dcff48f257b09add7673ea8801d2d76b5734ef6d1cf418c0", + "7fdd70be4f8f959eae8af0d438ea95d9734a9adf879c3758c40f1d85a6fefded", + "b27267bd7d0b82f589c3097e3677549d68f4797f5b3d809dbbd737956b252abc", + "ca554f432da77c62662c6c52284c40f28370bd9634dcec8c5fb408fbedf548ed", + "f3282dc829d152e7422334bf9307b3b460c4b13e0b41d116566ca6253ee62c97", + "6a4a3995d5d77dcb7340becd7d68cc0eb1e237bb2f7974927ee2ebcc35ce1823", + "17d6a3c90f2067a3ce025de9e329d9f8ad4099cd8e14796c9aaf027423762238", + "c702abc6cd6f88f3ca98a392334e80158c79e32e30906c8fcc65be3d34c62cfc", + "a5727c787f194e9a7c10bcd553b3d617ac093cd7a27681d8fc68b1c38ad945f9", + "db41004e14f0bc8c453a4435a69659f9b62d74926f8ef21cd74dad046a562731", + "814d13b4172afeaa3ea3caa3b985a789b3533d7739fcdf686cac4ebdad4ad251", + "bfbe9174cdd7850524274e26cb3de2ff1d97f2c4edb68ee517cedb218a1d8016", + "65599573c7bc256a846f79a564ca7b3752ccda7f8ded82a9f36085a7edec8239" + ], + "0746a74a6397853f": [ + "a65f2a151377399ac0f9b434289a72ce3d2c13274128a056ebf652467c4ec304", + "5869dd1d5500a1531e4fce8c44ab7b87ff456e246e1b4ccdd06de7a587809170", + "8ea56d88187acaae495e6d717b62cf7f0e334a8ab53b4f1b5099761b0f4fea8b", + "f7574ef8971248256594b82cd3b964948a42210de6263aab7e962c35276d849a", + "f9533197d311008f0df5d8709211e226cc736fa37f67cb4b7901d7498a693707", + "0b3eeef995d3ae14405b35836fe35e9cc350d375b812517e23af1282d1429c12" + ], + "09e725b3700796af": [ + "ce7256a83677e552f73555bfef4e780cbd32d5791c8dc17f8d8b8072db3b4791", + "0d7cced96d7f70bac36c00cfe69f5f2bdc6772b18574fa032f3b4e96d50bc197", + "187a3d5e4d6ee01376f52f893a2c7e5a6234bcfc4f87956e69a8b0ece5187604", + "0fd9ddea8329503c61e8e443523e3111e05694d243dde0d05e2c2309bc0c558c", + "99428b9e725dc596d942769d8cdccfe233f4f8f30ea6d3d6ca56b658f41b7d28", + "dc92932381695cb9ee90296ee4b8f9b19870d8b345a7dafd2b91bb2eb323ac15", + "dbfb9a11972301c91127e4b7044253f9f662cc3da9f94c5b0ba99dd7074b3def", + "f8c563b82b0075a71ee039db6f7c909d0bb90ff886c15d06248b1d9c692acf9d", + "79a0880c205b03597295d640bcb1a31efc2c758b7781edb12726ea6a4f34ab7f", + "b791a4e26effdc5ae7c67f33fe0f2f91a5679cd451ea3345a67a9a56b0c3798a", + "b642cfe2c244c0afab87401b2a4c21b093bc87b1c966dc1046371154b57344b3", + "ed5f859e0051a9fdb2efc1d2d9a4628ca3ab0382fc94853975878028421aaada", + "896c4da99ba2fcf75a8b928be3134f7e3006cd7b34b8092a5d445b392267436e", + "49d7e0764d21e4169bac1d07565b8154ed3735d9b63ca411ee783c6f87affcde", + "d87c3a1b94546be23b0374282dd39ae014d7628bbd31d76ebdae769881859721", + "1940be464f3563700d126217b37cd4e96d2e075e49266560abf931034862b965", + "95781f05285cd9fde511410c4e17ec0237241606e3358b0c50a51b30f1143969", + "bce72dc4609e593b7ff2790b869bd712623dcffc4f1a89371208df36e9d6753d", + "01a74bb1f3057c9213514324f2f455cefa97ed954b037452af02b5575adbd9e8", + "a42d58912bddceafe3ff218d20188f5e23a1dab934744c242ecf657a0122fc06", + "6d8da6c0a27b1712edc7de325632e10dc399cb906a847d189e77f7bdd19586b5", + "eebcc14df0706c07c49d677f2ed66adebd2ebc4bc01c6c08d68217736ce9ec1e", + "84727d4eea4e6d3ff312e90ceccc92d2358a0d7c1673d91588060b47703aa3f0", + "308551eb940fb4bf39ca90379853c87c40196edee9791a519e16ed1090c85b5c", + "e89de1b7006b6342f758e6a9f5c20af7b22c10e4bf076be25dbd16947d86a881", + "d55548a0e742dbfab4ed28577d7ba80870bbc7b580a48a654f2022efba1df9fa" + ], + "0a9caf6dc040aee5": [ + "941ecdc8171d45f5e9444f197bb8974d86ed96413364279fdf4a17a5cc4d96e5", + "0b60dd3c8939e482589eef75dcc44efe82ead7a30a7af17091fc7bf0796b807d", + "38514e7a1369bf1f858eabfa130d01c171fa4f45b0a0b007af999a68f6fc0541", + "1cb350fdb0e5affb32af12135ae31f8980cd740ac850e6926a34469c151dad1e", + "71428081db634b795747fb3a1753abcadf8c8b5b06c72a8b4b5d889ac1a290ef", + "f28003c91013d786b0a140dea3d072cca2679082cc0de4870b7112767fc821c9", + "c6c0da6e5492b994d9ea42eadec9d34372592cef9c6ad05af0d806c549c213e8", + "2b67796a58171f9ff2ad96aed4d5b6bc09949bda689cfb5f7ff0adefb8706724", + "70c82245f857cb18df562915625bf3d7bae39b531f4a719b485619a1fd4780b5", + "7fe6abeeeb5fbc22c3afcae5d7eca4d5947eac42a3927024c843ab45a4cc7c5a", + "f1d3355749abe79817a88776fac3ed750e30222994537a4fdbe46cfa477b6942", + "186d093c4233a929edb452bb142dbfdb998a11d71ee7851ff2418e9804f4a6f3", + "9d6c63da5341b239f6f8f78dcf0c70caf8f89f7859d45a616fcbbd9c08ac4129", + "cb0d997a0a14229b695821a3f5bced0bb4edd4aa90c489b2d1d0a05b30e6282e", + "1bcc5ba055744688ad867f877e1a9e6fa7a8aa7254ef1206b67ec61a3330dcf1", + "0295c33544a8f949524943081ba49b7a6eff532fb0bd59bf2b1961ef1acaa8e3", + "852204e8a26bca262e10c9e39f96f81ddfe7209e530d7aef44e6a8e29bf4d099", + "6f79788dc5c0d0ce0f2f73ebe8d93e8ebe393b36e874ca3a601e964f1852b20e", + "61798e40ba7d4c1d959fce1bdff9e7b5baa90c48f33b0a2c2632a6d3ef3b01e7", + "6f9eceb1dd3b203e46780c4c4cbf1001e9c59af98bba3d19a01a7e8d17f21a57", + "43ce01a760a3d8153244aefbfd3c5f0c848055f19b3178dbdf5a6eb8aa215c66", + "1d66884e30ae185dad8bcd98e493189ebe51c8e4299fe3129352c762cec85b5a", + "6f6dd177b7864dc6842ff4c1237bfedacf0e3d0b8b85c9d410d206f0436276a1", + "f2876a0b64f6def6273cf6806394528118492ad659ae8dc1ef6ebdbcc5cd0383", + "c7a9b6675395cfb3aad8030bc318ba007211ca32c393571d3d32799f9e10bea3", + "97941a4cabc916c63f8fc9169d0c45d0bed5673be04424eca09fce244a6bb950", + "c0ed7b1d51e207d5a1232da0ec44e382e5be60feb166a7035bfd10e4edcfc483", + "7bb0ef4e383a69eb7d2e597fc317dbfc66e890731177b3db2481586b8c37e3df", + "e5c8f526047fb87d7465c004b4c6a65a346019dc1301f414141cb6e9cb4ad8d2", + "0b6b235e5f225a403b578ac327099f37c6ca88d0d2e408ef3e12cc802e2b0bbc", + "0509fae01de8276b6edf72e3595af0648dfafb099093ca204cfcc2a06dca2dcf", + "e594c8632d8fcdd4932da7563e213ffab7b6de9786efeb76495070753cce1e1a", + "4ad229482f588be13311e7b5969edd4f13ab7c60e7be1456f6c5b2caa0c0e18c", + "729317279449143777a20cb6f6c5d1b8c97bce99e8ce5e140e449c09ea8e311c", + "4a1bfcebb3ee2cc1c6947bc5da8936588dfa4919566ab49657177ac690550e04", + "0739aece004678abbbc5f767f364a8356a602795dfe6fbe86632d729aedb9183" + ], + "0c1dd56a6d76e703": [ + "5c4bd9a35970dccc02be8878f390baac01dc031deda59ea9e36105d2f5cb9db8", + "06dcd4883e210b5121a6f3e2d14c2241f89dcf4502deb1b4a983e184e6cd95f4", + "43ac566ec871739b58f26abe4d6d28cdb4d5b6b6079861592b0915ded9c41b1f", + "214616901fd2f12c6e1082fd3b910e97114809075108e23756df0cc64f68663b", + "acfa6c68e9214826032bdd565fefe1e875be8f061bf2299a3c218eeba5a06d0f" + ], + "0caaab991148ee3a": [ + "8be1abb3383f954a600c9fe88aabe96e64d9d27f7f7a5fecc09677a5beef1669", + "a1bc05aa25b9c4068ea41af962bc63b198a9939748ad718ad6d324334c4b49e7", + "a5ee60ace63334453aef70dc260041667e3cbe1d78c6f8da8b07c156a1e5de8b", + "059655358f005c7c716ecb469d1c9e2725a90e903d2c6dd990074dedd5760c9f", + "0ca8146fb2a63dea3e3183021e872e0ffeab20e983942fc48c59b410b8471730", + "b68e6a38ebbad6b72baa3c1a16ec7bf630f9669f3c011bbdedc9ec3bde97372c", + "5e0845f002d09247b9f64d7cedb660eb6d4fe3d9813845a76bd49a58b2c5189f", + "a862abccc2747aa2c68741fb35610a74e46a739fc4aa1ad5f936e4666649d36a", + "86402279c7aa2baedf1e1260f6b1d7a7e900c99b9263a62583ec600ec96f0b56", + "88a13e9857b9a4b568a3a838364ea7308b421f6f20989278ddb7a8171032998a", + "65fadff4e068e55607e2d553e6882ea6eeee0752eea2b1bf59881d8924827410", + "a2f9594b1a6a5cabf1b2b20dcb8cfa210e5453c3b39855f5de9d3a0c71134544", + "4229e578cfbf2bcc122db4717396d3bac0650b20700b1dc79542eada8aaf3896", + "5f374272592f2c43fcab3d852f8275bbc840bf8a0a112e07518148fb8419db9d", + "761460c07098b210d2178daae5f2ec233ecd90d8556f4ce3e5697e3bab2edb26", + "1200796646897c3de8d04c0e6278fea37bf636c4df964d2c291e18cbdced512c" + ], + "1355d6612df217eb": [ + "3c7d45f2968dc7320eaa4fd8c8a5cf726fe751f053f335bac2394d2153469240", + "dabade238ffb83e72b01efe0b59fef7ad94aef9bc84feb3311efd04a898b3552", + "fc3e423b8ff8c846cc671c25d2f44bea3f162bdcf4834d28d383492bcee8bbe2", + "6c88e91a9dba87e12a2d61541c76cdcc5565d5c8ccceb8d28a4c1520749ff3bd", + "c81c33ffb17f7828652428eb6f8b0102eb8a7dd8c3376dfd01d4d98b51fcaa8b", + "9e683ee2e5fd8cf6e63bfa01b55095056b8b0c4df455a17e07dfcc86f411561e", + "68be7832f1c9ff5f39548b5e3f3ab127e4d7f4635e05e63525f147b794cb6d46", + "656da253bfbe452d3ccd2b9faff6cf830f2bd8597f26fe25c4541aa3bf1a603c" + ], + "14076a4fd268af82": [ + "84fc0d7e6a2ceceba9b35a746261eb6893770566b23fb8ce303caa2749213662", + "c837137bc6ac879bfd980c31598512a32cbc048f46479a912190ac0b8107117e", + "fb87e5f4b6aeb66a541d99ab72073ed8661910eb9413763b8252e207cb2b9896", + "8e6cff7865c8a54f93c616cc4d0f84bd5491a74c4241cd1512393ef1c8469a6b", + "a3a31e96f022b96f32e5dc8f62ad21bf2530914e941233a46443bb3706125d2f", + "7cef51857e92595ff94153940625a9d92d9fffea7024e3d98e49a589624d92e9" + ], + "166aa79083232e28": [ + "ea845298c00af38e267b2a37c1c4a1c4b92957013a411718829431b6b9cd4e32", + "1c65689bd34230536165afeff16c5fc5f9fa8e875319ea09a0b7162738e535b2", + "b48025cc5070b00eb5f7af85a6c044b3a5b3c2d42ada66fbab1d8894f9d1157d", + "a9aecb0641204771abfbc535fa3eeb9c45903234d6bb0e759b0837338e55cb7e", + "eae8a6fe34caead973c03660ccbd8fc7c2a203d8ca789773675adc1bc5b8b3a1", + "80c88cf970dc3c6ae20de4f3e211f0539025309cae5cbe1211811be31d817673", + "1077b57b02d481fb80002593f70f167a9446cec42eded8cd98f3fdcfee679e5b", + "af236057557a9a3ce70bcedcc023696cdf2d01d9f00509d22fa12b522ffdcb23", + "8dfce6f1ed1aa923dc3eec0ba9f45fc82c0832d8c53e786c0b5193fdc35e8285", + "c6b87de960726972d7e2735706d1dac066aea02b978b9ece6893c50b6dbd4ec6", + "d3c5807950b66ccc116a1e90872e7e764554e064812502a39947a3aeba9dba26", + "91aef4db20cf252316a986c1f1dc15ad065e86aa785bb749f132b8ac84612a53", + "a3b05920ae50499d9666adcf364965a858e65405db816ec2519b299f5f528004", + "372f33f58af93caa8f3e2626edcb326d365bdb63f595eabdbc15f77323ef0d72", + "752e377ec71c5e223f725d570e3ebfcb4ca483ec9df2846a7606bdd766d4e714", + "6fb6578d9980092b1f1a277b878901f98fb44d17f2249210a4e25d8bd0543de4", + "7d96ae739ccb10130adeb371f6d68cd62ea4b552113f0f9afba42a613503e43d" + ], + "16911dd1f12b2180": [ + "fb3b11f71152df73d431ef17ba66f011fd7fefda478a34e7f601170a5b340a10", + "dedf9ed9f2087128ec23d7755fefed8cf9907058c3005ba9da2d96588f98735f", + "d2cecf8f127ba1298d3a63c658611336da7460390db482a6e3fd1c38c023808b", + "561c31c8d1b65991bc5251620a08ab6e80e5c2d22dc2066efefb0d25dd4a9044", + "1e6a62f8b03ed4010526712dba1575ba089fdd5a264ca82403f87ba998797a63", + "63a92d2e72a7576b619541b8f9d0fc9de78d753b59d9b03e511a0a2c2a164db6", + "d776c732ffcc053385a98e748f074e6c8eba4f2a161820a6b0e2c86562ee34a4", + "0d469a8b093cbf92108e316bac1ffef3d8db53813a9525e8591a773a83d4d0fc", + "490074a42c9d9175cee29167467e01074776042eaa25fc4af2790355fdb90874", + "6e564bce8d339c5ba48db52929455ad6497fe3b818f5ccce9525a6bfc3659459", + "0b917e3a848cdeb4dcabf54bcc35834a7799b3ca1e71a03b5d0a0635df7ce80d", + "f3d05caae853c0eb83c750e17002f77cd0eed3d8a4eb86d40770d9813abe3013", + "b3081471b2444ddfa3c7a148a796b8aeb98bb7356b400d76aa437fef605187e6", + "9811f7342b5db4613c51e13449aa596691d035a0fa8323ff1a1647834ed96926" + ], + "180c7e3c86a4f5ca": [ + "592c6bec99e2e98fff8485b2667750200aa86974282d575029778cb10d2aadf8", + "b77f924e46cd8761128c3943f6880b92b665a85e344c805c98d474f6888f6c23", + "bd459aa407cc00a4d2e16074d0de80e658eac26db1531610bc7b9a9e7418a137", + "cac69f11612b87b5698a899684263d6bc6f4c676d06c0c1b7124b7427f1e90e8", + "5ca2945f6cc474a1cecb405375e1f001cfb5c20365cdc01f875d844755e5626a", + "250ecd9a3dfddaee6ff40447d371697d7373c512556974501091ab15dd33fd08", + "dda8af67ef318d9a4cb73a9bd775d479e829ac34cf6d5d449ae58328f6d2562f" + ], + "193916cd8d823305": [ + "9a8d63e16b6ff700358e2039c850f74a1114890f3c7b6274890fb68de511efd1", + "78d4df45aab6a7b8854d6bb609d647382a20dcf2b3d5bd0e9a0ec2b15db6a105", + "84373cc805b164dc1bee1d318fc0e1041c96e60862d34c2e6a713886702232b3", + "d63b5727ebac60782ed6ce221dd645ead265252c164aec0ac10392a7484f500b", + "30661e74fe92b05161bc4b8249b22601ab5641d035b5930717cb8a8c8a5bd0bb", + "712a25d10550e5d594c62595a6636e6caad2c40a0f19f14479829919fd7fb02d", + "4e986d07707eba765b39174d99e65f003581206191741a8c445c09290f6d5980", + "0a963a366c177f63e86dc48108a60805f8b954858ddf7886f757517a4f19e37a", + "df59c9c369bc32b919319773d0422994847e1bfd38139d841db0c4ff6640839e", + "1797a4ed5207f974b8d4c869cb71d71e9528766a296ec7f2a820285a657f1751", + "1a72db3678e1e44b047b17221ef02d310ede4f84162833278ccbc9f6238c442c", + "c4c2f6986c00585d9585b71cbe018b94a2145410dcb9e52411263e4e55e27584" + ], + "1a9b6f6e59abb83e": [ + "96f3641784da736a0c3fd8d6d3f6ca42205d4ab180bb7a7138eaf3d458add865", + "cbf0cff88af1b94700d14c14920b47795cf39586bb1e987be39e85fe5311f084", + "b7f35374ccab0a7ac203c47e57bcfddd9cbd64452e0410d910bc76fd76aeb46a", + "fdd0556eed1a97d0fc5a8a84c1753dfb0b3f9f27b4550e7fd3f2e8dd924d8bda", + "9821a45dac09799d7ad5fb92ab5399868af382f5ed3291fc6ef45d15da766dac", + "4649f7d6de7797933e83494c6964446c248fc5b46162d4c736066228f636b79e", + "0dd86fb1b6a7950c902afe421f0fd3e8267c566fd7aa81fdd0c440e304fa88a8", + "6b2409ddf5eadf68a49d719e30ca6f6ed99c9bc3d37c8112b32b73e64ec33d92", + "e97622daee7b90d1be6b161c3e11a6a8e4f4cc41d55fb68b2d83eab6c619b42e", + "d30d002993bf6fcc07ce30bc43419335bdf69d76ad5114c3e23863ba86983d15", + "fee8866a76878e2044d727f46d660a9bedb8240208715fffd5be3d88755cdd30", + "91092edfc96ddc19f11c91ede5334ad359a52f8df1644656b2657adc2453705e", + "bdcb4b5f41dc2ca9b36b969f268d19ab94cd61a2ee9322262c441ef1fce5687d", + "f29548dcf3b2a40e22b08a381c890c04825b5aaf62f9dd8b0d887a789827065c", + "139c1650b5fdd4be7894abfddb7abea6dda312004e972c5870ffba9483896c2d", + "4e7283ecea695dfb1fa59d17f9bea6b75d8cbb1b9506db5832736a11b7b7916f", + "e353a9f9ac861d6695589b84db3e744c66b24b53e4ccaff7fbfb839ceb3e7b26", + "38e0b2f035afd811b16f7c683e212ffec79195c1a0788d2a948089337d478c56", + "1d92c56af01ba9c4c911c5688a588afdd8060b7a15950d79571d24532713ca74", + "9bc1c04205c2a9d5ca2795836d8821c275c24ad994e4da9adb70509401858a72", + "9f237c0c58e7cb7346b501d3f202f71fe661dcec18d701cb7fe7b4642dfee380", + "b1c1f3c44f5a1ed30b5920052b4606cb896c49d5c6f4b607b4718d857935f484", + "c6a437f021cb840b602816bab20613689bcae531826ffb0ade710225b8f59e89" + ], + "1bc1583df3d438fc": [ + "a982aa6a0c70119b25a18b49e38ca1ee7c1164503ad87100288116c3d7446c7c", + "6c6e8617bf03fde4c8e739157fcd89458c7300b2ad63d7e2a04c878f9cf9477d", + "7780ca2dc3f2d4c1be16a64e6b0f058aca4eeb5a139f1c052fc9cfb1e6430929", + "29d4002f10e622350bbdf98841fce8817702739f15b7722862360b1a76c69199", + "84f19bcc7dc8fab4e643497bf2587838ee7bc0ccc2d760db73274d83d5a4de5b", + "d666b23eb11d3ed0222ffa1b3c47ba82d46b4e2f8a841845f89eaaa77fc584e2", + "032ac3c385568d3a14c3e304377b47165ab75f75170afd4bc1e423ff49198a36", + "512db93e6714dceb88b95d35c6361cefcd945669b42c61c37a74e883438fcfd3", + "8c5a2048a1b89c725777d813a6ae1b0faa00f0e2e466d14ab8d0369fc84575f1", + "7beae50c6c9808ce849eefe069b63b690d59f4ec099aca1cd9292d72dc63a441", + "a37989e73ef6bf4d15b6659b1c7cc6a6f9880437e7d46b64ca8071b56e47b9ed", + "724baf774dd9e3a25eb111d1b73d92300e5dfc18e9d7656b766f11d15a3d3a95", + "abaeb3656c78140fe18fbe2b31b9524fb86bbd85fd209f34f7ed7f6da2a0bf0b", + "db43865a7ad2fe859b21a584dacd5b683734120af55a1eab0f24f46815f7c571", + "917a2822fb1860eab44b2654b117116f1ff987f25373b4912399224edaf808df", + "91e47e0ed2e3478161dab1d5d0c67ecb919e1ea55891b563be82c92ecbbb1915", + "089e22c5ee0f0412f03b8d33eb6844663b1692226837e04b7c6871a0a76b6230", + "b498f551acfa587ee70a4c662ae1988050106c148a0e3b97cf7522f0fb0169d9", + "f88c871c0eda400342d7e37886c7f400527e3f297d94523b2363319f57eef192", + "98badcf81dc6d597c48d664b275fba9c1ca568d54d72fbaf80bd33597ea50c72", + "3b9c60422e1fd3ad1ac56c2f4e6fa3daf635e309118b3774c413fb859a2ec017", + "d168e726c85b5a82f8a3726228fd97e01bd770471536b51ee13ccaf9d704a468", + "76dc0f7b7e849228d07b7e028f194b53517fe18d755c6bac6d995a0f7970ff00", + "509a33059b8cc355cfe3ac6bd3f26ecaf16955e7ec9865edacd4008877a7d4d5", + "c1d05008f5fb42d012371ce18d3aac0ab3fd81e054af391c4b3deb787a1b9ed4", + "77845d2f35b9d2a0fa344d0402e0546206791817aadaf9534d0a4784868c9698", + "ba3e72eb58eada4d473118689ebbb91a2caa60cd52be0450c17fd3b08cda10a4", + "c098025c140ed390d377479ab3b65564ee36751e20adad35ff1be4920425934c", + "42594db3d65757154e1d2f04e78acdd7690bfc22f3ef3dd0e3bdc023c52535b0", + "38c63294dbc263292362d6daae7183b03ae325d6c0a975c7b67d4a819688e021", + "925ddab5bf32327a92824c93ddda8cb216487734959ac0cb6dd589b937095735", + "7fc22341ba8767ce2501d4e0f07e8982744777685aa2fbbe1801bb832b441400", + "93d91033b65b6aa2cfd7ae83c9089be96b33961df89fe48c20f3f792da215cb9", + "3975e7eb22ee721ab1574518c3e9dfda6ccf32fb77a065e5c05bba730f3c2e87", + "da4318551f45112baa5240dc9c6d5e1bcd719ffe47243d819646bd8620292ef8", + "e3a6b8a26c41814e6880fbf084a346826f9a3603bb35ec6f354a96a7a6556398", + "b184466df55e9ac9554ce472932a3b47b953946066f940c3bd46bbffdd0aada0", + "fff843af5dd6454f0a89cb5e473ae26f82fe678ca69c460de2b9a496d7a09b5e", + "1dc27d90d6bff891267b42e9984c6b5b7a9147bfe45653104a96243b0d293762", + "a33f70847f77b5daa4d8e66468fc3473a4d30769051a25edde13aea6d2b152c8", + "2c0e5eb8e3ecb1190d1db282d0d89f12f1b8c1231841aface2909b4f437f1e0d", + "5a607eb06bfe234fcaf6ef05a8a870082fe45f8914f80ee66a8667d6a413e6ca", + "8feda2326a97dc997a025a1916578bdffa149eff183ee1a854fa4917fcc46ef3" + ], + "1c51cd59548a6fe2": [ + "fff9d0ce613ce13a72420ea955f99d3caa9f17f71c8ee5cd5be242327c2e9bb8", + "b56ea6b6cf3b8b2b2cbcae84e57be84572d471a6dacbd2d8bd39beb743e6a548", + "e35a20e8c846a1943169282d5e27de242d00bcd45c4885d83a570d779e4d681f", + "8ae7f0f672739dd145a1dc848eb1fde5b98f2cfe1037140e20a4529125d8b826", + "55346b7b5b6c6704f754e748343f747a8d42a51911785dd68639f46db700af02", + "f540d0aaca04471e6c10e0d6ec145096c7855371007f27409bac0fcc236d1bcf", + "aec5f14820850003b97770ddce7b2d21fa774df058da5d575cdbc6dcb23c6b9f", + "c3d5f24d7745b315e663010a979bc1ebe4f47127a3f52ecfced5b36be618c141", + "d2728bb49d7654778cc61bdad4164bf91699ccd2a364c39c0e06b73d192b5460", + "2bcdd83c2350ec67e32ffe99f1ff92ccb3198fa4b4b2cd4ca09400a9c63febdc", + "d61b50dd092d27ee46f37548162b52cb7f906649c4ae4a590aeb5d96ee64e927", + "f003e5c1b853794bb10150f8084d4f03fdfb546506560cc2248b23b920a39aab", + "a4e98789a542f42f94d1bfec14017ea1fef2cd7c68cf8d52434d9019f976a170", + "45dce42f5ee385d2e7f7b3c3ce48dbd5c3608021890faa6d5f8b80e234da3c67", + "3f65fe7de91620487d1114a11443bfbec2139125b64a63117c92bf13b2f5fd04", + "815a00952a394cfce4b1164fca603a1eac2b0686cf7ed1c4d88d9ef2a07c5767", + "a1aca9dabe3bbb061083d701708f7bfe87beba2458f3eef46fb1bc7b67e1d23f", + "09e4cfd22d4e28f2bb3214c5533111608dc52a7acb332ec9436914f25a0eb383", + "f3be27c2419962098b8da4435dadaaf1d9df441882e8bc6004213f96330b9af9", + "c14692ce749d1bad4770c4579f454550774e96dc0250cee9fa3031910bf95b40", + "b007a538e199e33836229bdfadf79e39ddfec9ec361d85b0031850a5bb28a0bb", + "614185093e5e4f40aeb151800632f5f7c9652237f10866aadd415e63ddf91216", + "c588893660649fe515b9c81e25d1170d0b3236478b959975f13bab2b608a4f83", + "cb30eb737c50adf2bb430a4bdeec7180dda2266f5642a586f24dee0be82200b0", + "521655938379a30d5eb314d2f355343bf613745e33d55543f1bd8d127464d230", + "b03fe10d2bf66eb15d7c8d3dfbb3871876236d0dce796e08361c21ffd970f1c0", + "6b3253a7f7b70aa12bb511eb4db7f0327026edea2f4ac1646d503256f9c674b2", + "e03b0b99908baab28241e28b1c8a44b11131666af8b03555f4c3ab0520e43db6", + "ba3418c543f888695f930fb796aca2fca1dd3ba46243f6b109dddf513b89d424", + "e7aa1f0567596d034c0ab6e298962db83c3f04865c4febb150a9736d38038a11", + "322277cf6f747c7197b1abb7e5af7e17b042c2f547484030c21d77805aa1afa1", + "44a0e7dddebe4e0310cd503f582e188da79e059f42e76f0319f35b24736583d8", + "f53955fb4dedfb2c8aafed0e0f37e13010792ac674255b99e3c72612ba1dd74f", + "9ce4864ba20c8a2aed2ec603646534c7d7184cac33fccaf7297c0bf93feadda3", + "f6ce36e7c9693ea22cf985618d2f159480c0d3066eab777959c92c4571882bff", + "1f2f3010aa2d0f3f6c433180c41e5b70416307a2cae61697f3a7fed9afd61219", + "e2695d49bf884fa14a5d7af6a93184f2cb82b608e6af0c3fdf9f879c1fc7511b", + "fc71afd3ebb0faa19bca1a2183cb128f5b770b1945bb80732de1a5068cc9afe7", + "743bce76c6de7e73beb4b85d9a8dff46094b1247667be9fd51339cc001388e77", + "091bf66a792cd493ada7c2a14a102750ff7ae783c23e1de25e3a882c9ee8b1ce", + "f4da242f7f4b88080852196ade1b5893e1bc624541eea223a13753359514c800", + "bc08223cc4dee73c20d13a24fae4f50a33c8e08d91c6b674da9d29ed4b64e40b" + ], + "1e287ecb2731014e": [ + "170ac3e34fb53cbdcc00138cd1392173c7c3f41f119eb64678f1a2f48b5fdb02", + "659ef9d4fc9babd05874b2b990038b0bbeda1e65216c09e9e738f208a8158918" + ], + "1ed51828ad4dcf06": [ + "ec5e2092aee3170d0d331912ecb7db262430972021d53b9b75762eb086a8274d", + "22bab8b37b0dafb095aa82b88ee9108a7cd97b1cf73931efa4ff20cafaccdb31", + "7f117f5d9677208c7a3dda6fb5dfeb32ff553b78235d1b3eb3d61bbca9553a75", + "51251a0438e992eb715682b551da4bdeece506870bd93ac5ffc0b5a758ca7d45", + "8c7bfc3850c373e35a7287220ce6e58e71287da18b9bc166a788b3ab93c4a062", + "471dcfdbb4b9fb023a066c3eb48725b316113f4c787216c60a138cebf54ebff7" + ], + "1f55a4be46b26fa4": [ + "963d27c28209da497b3a1db742f442927733d05c399a8a4a977b19e3e3df5dab", + "6f6601a60d08fdbce641af801312c988fe2621eb5bb695eead79c30374b514fa", + "215ad324758f35f683d5524cfd78bda48aa126d219e053e0a1f6196c177ad25d", + "4e7ffee0e6a0d3e0bc0063bef789d33ea813952ecf0db5c47d8b870dad845e9c", + "34d6cc005eea7ab3fd6c9c309b322bfd1f6f3f8e2f9ea15a0a99dbfe4334dc67", + "e57543fa027a8d6478612a2d497a9dfa5186fb78166b1a4ed631553529c27c49", + "c83ef0483edcca4fbf82d303077eab7911e14eec7ade5d1b48e085c841dae071", + "a9aeb8b54aa6ee4ba243b7734b5c467d2309f4e4027b9719e2f38661b7a3a2ff", + "6f73f67b1a91e3f4b8de2f0081ccbaeb742011a61e4c67dedc2b7f02babde532", + "c28dfa2951e638913ed53a868d55473f5663f4e485846971178a00c18ccf4db7", + "94d629864f834b08ff84b9821dc434408a0fc39d1430b0635faba7525b4b6067", + "0337dd9effef41900554b57af6a61bfabeb92ee69eadab153da70ea9e771e1b6", + "938d56df6a5550660f41bd3319b5a50d112f0456f1ff2a7650adb2ba2317014e", + "955453c22f3814a7b39570d0e831638a5cb17546b5ffb088fdbec81edc26b52b", + "bb1d07dd95c2f41aa2908cbd28b15e6411fde69a8dbd0d8133a22a31343f078a", + "0d427376909c37f1516e0a69736844b10ff9dc0cd8f5c7ee05a163054549ec33", + "9d5378124f7b023b7a92e1a9764f25fafe8649a69faceccfaf239b2d10d2c674", + "37c2fa27222d9a5966472feef54214aa4870becaeab9df098e438fc6947b3984", + "d02c86068723997350e7e99cc765d0df6a6f5c285355944640710c29331d92fe", + "def3c96af6f969f527bbca7c389f2ca8147e0c4d561ad0550d60e3580216fc3b", + "c92c635a1eb41c32640f3d9b0162261a27a8de7e5f2ecee74c2de33585bf596b", + "99698d66741fb9d7cd72a36f5b5d48842e5e8da044f656088e1b914b1f60661e", + "bbc4060abacdd28a1b2b51ce657b0ef328155545243fb40e2ec454b8a252b44c", + "fbf3dd271cb915b349a85f81df6978f8e1796bbcc38a8e2ed026b5fd732b262f", + "1e1758fa504a3e6011926aed34758f7e9f85a3f140da4300133a02da0d7a7f4e", + "85e1124c5371ce80307956f30618cae72062522116154428c41a36f604d00ab3", + "7f8e35be1dfed1f47a64beb5aa7dece724f86e44bf556a5e1e76a5b2eb4c4ed0", + "e2e5e98a257b2c703bc0ff158578009ada449053d8884dc97f36bba780b59265", + "8b8a01fa1ee1f1ef132ccf3d102cf2db51e34037e283fbeff687f368f739705b", + "d909f5aee55c3e6d364b7914c3e8ec31523fb5259ebee1f93a0ff095887d8567", + "64d0e54ebddb7cbcefe1b939783717db5ee296aade5adb667223d7a21145aaf0", + "da3a8f50a588c513d6370dc529713cd574ee0d05ebcb18fd68cca279d0d52e41", + "87ccfc63a163f22940a4290eddfae3ab66cfe2e05133c566396a2a89404e42e3", + "10b34c191ef16ebb439b226812c780f453d1071de15d975b1c34d75b5228eb30", + "8012480ec4fb5fb9fcb2ece9d2363fc100f3bc9364a9555ed2bc98eb6c30cdbb", + "3d2a02e91c0e6484efb7dd587bb03bcb6ee1f31fe18371368c6a5e5b4410b1b5", + "4706e8d81cb587e0fae0a40b7f0daf74dd7f2855499742a36d30bf04824e290b", + "fbc0d77250dd018e1efd8c010f227c326fe5a4427db47b234e964908786f2395", + "9edd974b8192b6ecc15d9e62220aa0ee007654f79c8b79bf2720afb2f468825d", + "3ce3435da28323cdd3deaaa8bfacb18d25007ff3f69b86cbc0a2fe14d77258cd", + "ec6bc4854824b7e9ff55756dd13d6980d30a04b1751d8687d0a0e81083b1eb70", + "dcaff9f91f295580537d03d8e38439d6a97d2abd9c5b8ed51b326704ce0ef28e", + "da8ea512f7c4acef092c2d447520fa20a40ed0a2380414c24ad357c314fd9de7", + "6b11325959280ca39143b5924b216822efeb336ac3e0b12483b6e0445ca5e4f3", + "df47970289f4dff3cab4b2972234b601394590d449a3ae4ce7350ccfa7165458", + "8cf30e30b61076f264f6dd766d2fbcb62af5f1491bce5a7101c787c10c3fa01f", + "61ce103bcb3f7d9c2f8b3623030865be4926370d210d1fe197a9c75d59af458e", + "3bb69f2ee671f92b81bf2cf4ecbdccbd69c25ca1338bc26913b05565b6e049b7", + "4168938a94880aa840f95936f9e6f83274eb8e0b483f454a6624ccf55f7200e7", + "6a8ffd52a398ea919606cce4031a31d51e425af5ead2d818ea47971ec3f4c710", + "d2b34f7bdc19b80ddb182692d3df8cc0134d4259a10fd94a906f8934e19f1fda", + "284b24117034e55ec8f6c8e529364cc6f22f119e2e49754ac57a8b6f811cb41a", + "433bfd17e18fa99c1c0518a31ff834518646000ed34272ccae557b1e31700992", + "70ed609e26cf4bd78aa37920fe358c3cb972bdbff39e0124453aabc0d9f561a6", + "01fd3bbf3e57107cac5d836f07e41dc92f40851a3da8e120e27ed34e78eedc69", + "da16101dd2f84f43c227cc5a2e26bc3c5d295c635c568d8ca38a9c4da4087183", + "755866b0acfb071a371baa47ffada6f29d174781c5d8b791764bf75d95e40b0a", + "bc26d55149d6439f9aaca6c130aa01c0ffd8fc692efd6e3020a499644bf34d86", + "567d52b243eca9930f3e0c54aedaab6e9620c3bfc5969e5c6027d0646611168d", + "92cd60cb8c5a888921816ed0758d48c187089acde25413a6d8659ea1a80b9e59", + "2689c77ef8271c4847356c0208339f7a90d1248fbadd60da24114cc32f3b1149", + "aca53fbf9494050eecd758620e2f1234bff166a84a2aaa37b70158499a4b884a", + "391bae26cc19a51e642d8419eb5021caba3bc4650e024fd4f03b68fee9f661b8", + "f7bfbe9506bc9bf01707cb33c4052c715e74970ac13736328f66d7553fd6c4ad", + "9c227e2148c449d0a4cdf65da567ba53ff1ae0f31d4284b3d7a33218d741bf88", + "58d38f19767c77d4db8645c97d93f4c350f708f2cde7aa80007dad7cf72b576a", + "d69b84bc1367278afd42c5add3c5356a87ffbff0ebc0c0e0ba183a93aba59eac", + "d41e5c84210899bc63df7281ac30edab81a5cb3dadcb4434d6c8e98d2cefa358", + "0f224810129a667dbcef5341b88f0b59998d918d57739d48b5162e15913773c5", + "73c99d042e20f94a2483ab13a4edc2205d11b1605db0d040b444e9c0638a9db0", + "b59c567066bfdd0ea49e24643e52d10c1750149e1837b3c04cc1ecc4e9ab55ec", + "1af90ea331b71c5a2c7bdb7766a1d01d689edb06b22841f4dd94a9316ce60e79", + "8190617d9514f5961a7cdc75889b369f8d290ff1e3a49ac745adbf819d517f77", + "f6512e6082dcfadd494b3a444228c529c8e66a85ddddac4e293facc2ecc2a7b5", + "b8a0eb5eddc7d909371991dc43b9a10f7a6f14d63a49af489edcf1d136ab24e5", + "da4c926b0a47a45195aac39d200a84a356cdf83c2492e509b437fd88c7c39a15", + "833e8054cb6d24192783777a6df6cad42715f530955203b351e9fff7f940decf", + "b69094867cf9b4ab192e1b7870d346e95a62762a46a1832b7f6c3c9cb7ff42cc", + "444340526593bbcb51a1077cb18bb9ef15796641740b36164d47d8e6f48a14a1", + "6fb5242408d288d29b3530d0f577d15f7bf37d9b652cb4b153a6c3f2e87021f0", + "dc68bc4c18ed44c70fcca95e3d2049dc95897dc8f34c1fb3b8a5bc45fb78287b", + "55997da32e2760c5fe17ef8f1104cf51722d65cbe9bad002cc0febe8922a17e6", + "813f90cea59bb0e2d0304ecd0699421ad78870705685b7478c61767d2fe93b74", + "c16046c39ad60bcc7fe94a8caf3c980d018818be09da04a1d247ac75457e1286", + "89ccb6fc9f11c6d8e365a4437cf6a9e0985db52fe89646a4ccf1131cad6623f3", + "2142660526bb28435aa95b186c0ca6bd01bf33bcbd2e25350a388a016353dd2e", + "d59bb24665987a34d483b35976211747ca1b834fdbd6555c211a3964469f782a", + "44e8b52b1c9513d698dbfed6c895be9881704403878f71a86dc06db0aad9f0c1", + "bb21f907d3c66476f23a2599c4db5a86c3de4f1cb002b3200d39966fb4f3bd02", + "65541f3f8f1e8dd18aa09ac2837dd4b29360f4e3f86bdbd9ff9cb0e5c812d5b4", + "b646ab047d68c3c136ee605a5c2c51b11cc0e8ae3929e4141e85b1edb0ef8b3f", + "fb605ade5baea3c7771735da17e573d4b14fa240c3389bb47eaf277cdbfb6be9", + "5e3046c3303afd700451d982b1769f1669f66b935da5dc08819f8d3281626bd0", + "39f299bd4cd011739f72adf3b59e9e98dff42661c78730d976987f96695dd89d", + "89ed5b933cca15748a41212c619b6c79dbb8e2c8d1711135adfc360b0ebf4e00", + "1e10e9f671f36410ec2fb28c6a6e627bb0e35fec20a6fb88c0aa40df1b98e6b4", + "6b39df7804237ad93ef281acbfc6da778a8583c1d0dde3e019760ee2dff48974", + "7ba65838b1d8a6644c894c752d9b11043f4f6805b0402dc1e63577980700938b", + "5f4b087b24f6c4e5c90822be413b2f8b4b0ea7257374f04bcab9ef041a5aa4d7", + "c529608265dbfdb87d9f0e7b67abf23d6f5363dd7967ec54697048a1e4e2418f", + "9860a92da6e4c3a1a434ed99170caf756ba2e1643a73e1602e2ab5a4db982ed4", + "37fc60e226f879658356d8ae8af4c5a9aa3dcaace95ff2983b4a807a40289070", + "d0dc0dd6d156d4c864e54cf7902464f1f6dd1f4a9f611f51d959ee15949bd9ba", + "26925e34d61ff17b458d5fa0927d63da67337ef28e5f63d7d0a4216410358475", + "8ef612bb9bd5b42cb2627b12e214b6195f75d1b758c335a3aed8f1db81d4383e", + "25cc4c7f3ea409eccfb8c5cc9ae2f54421a4354ff0b91d65077fa53897ca6497", + "071fc94070849c18fd814ebb94d5702fec227cf35c1ae276375860d231a3e331", + "290b6ae4624bf54f2ed18e56789530429b8488252d67512f82f0f3041b3e92af", + "565df7bae297c93e1cc0c68ced0d159800de1eba0086b2ab463718d15ebfb0be", + "8be8788ed0c7807255c56a70f7b996ae2688afc3f26c21771a572fb02f38ae43", + "20bda98f97098338a260463450740637c5564a83db808b17006817c17697979b", + "5c4299fad5ff25da157118946ada292fcedc9346bbd7d139dfe50d70066634a8", + "e30919b01d0e6ebcccba471177fc6a14e03a6bc5f9bf9cf57bc44ec51d15b674" + ], + "209e4312e414d195": [ + "dc60a3ec9270a791d317c59a3246225075947cd1926dcc3693fcd49ea70620bc", + "397797f0c9e138fd6a24ce0903a4931962484cb041401bb8995f12ce2220638f", + "ec0fb2dcec3298b539d9d2becbab657daddd883587dd623a6f4c586ddf95aa9b", + "611a9c82f7f293ad645ec66aa723a212f7fb19d90ed946461bc779877e2d7efd", + "b0b2223d74875915ac054d0689db6508817edd7448000216727be7cfe810e059", + "8cb686b8b7674661cf21e29f272440e4146a69685f124f94d555c17be6c2b983", + "3aa057542c914502f11370ccb774c74111b6ac137660a28ee63b9eaf2766ff2e", + "44cdc3a70d9f07dff0bc3ccd7288a49632232bb281201549bf39ba4dd557eb4f", + "c8a72b8cd922f1017f416cb27701f351ff13ab0259e657f293eb98af0cdda775", + "c8d6bb51ac0cc00ac7eea65c44c08df92c8f2378683927b339fddaf1a2087cae", + "76c4038b5bcee932789830993ef657950aa899d10a4d128687b1f036fbf29935", + "acd295f72267d07c0c0fe19318b1a758541045a5b08c45b6853276b38f9d5135", + "1446d79720918193dae99dcdcea6af5b6dcf9a2802472c5daada4bdd1095920d", + "af550482ee69e243230e0dc4a0fafd5647115035398a084c231b54853529ea2c", + "277f5e8a83c078c9fd2fd9e82a145718bf10a9c95ce14b9af715c61895b502bf", + "2d89c5e00bd2b8dd68cb1e1323a82c86b4550b02f30f62464b8c94c52b300e95", + "f8cf2fd22df8b771f62622b7f1d89f4e88e49dafffcc4e5c6670c21106a28e90", + "c06eb30bc3018fe930c6315f32da0c0b1e138867e5e4946e68ee88ab7a5ccfde", + "06584fa3d103a5d9108b01279c5a11e385eab1f8375c2ef454f6e1b01fe58e21", + "284491c176c1faccb9d721d6e1119bf5b6f4d9a8a7844f4e7442c56b08538f68", + "f7d4c6792373cb9afcb56572bbd62ae7f6da259e946f1953ac2f1794d0b6fd62", + "be3521dc0a05bd7f3087815032905f00daeb079255b17dcc42a0724c5b1af4f9", + "c51cd467c719f08ec09846258a18eb339f2fc54d71b5e543fb74664fdbee2581", + "96ed65bbd10a3ec7db58be4a681fa95f285e8aecfb0361a069f67ca97ddd4f45", + "9b79c11d9be375ec5e653e8d7614244e3b661a97be11ee2522d938a5f0142e9a", + "21b0a279cc2694be686dd66d9b58bebec894d908fce0dde8b375550020085e37", + "701599b2de5bfc1e212a95f6a0815828e23ee1120f95ed3ea057c3eae2961370", + "d028da4c15615d40c61f5e366b3d9638602c743507e7ec2a0a6395bbffa0e23d", + "be8198db2860290eec787d6d8b5902088decb069298e499d4bfe6b8fe81c4945", + "1c899671b81f9f3b90c9b28e2dd40900fbcf1db72c0562d3e0bf65fce345a6c6", + "37723755a2a4fdb104e8878600d139346f3dd198014e4b5bd492a19a00fdee09", + "335ee7e402dc0700b6ae28a0f7e129b543c032dc4c2f5908112db1d6d4a2ccac", + "2552552589a81f536d9eb236f97b80a8b6facdd44970a4a6ca6c9de76770ac45", + "fa3ee9ae51335fe33c883cf543c558599d2b15c0e882618932d1cb0b8b311eaa", + "085d14057beb0dd2737ace0bc598b968d048a0dda44b3c324879d05d0da1c42f", + "8db0eca4f4903ecd6f7d447ccd86bc73c42772a18b49ddc28cf1b4b4a9fe7755", + "13c8e9f00dbf76fa0f7a38df4490e505698a1351feccd0eef750589ae0f7d333", + "a40922ed564c33d374e478f10dd26426c9aa4eda80b45ab86f6c550c1c89c6eb", + "6ceb8e5df94d19618f76a78be18714b3140afb45bda7369ce5fd7a5e79512c29", + "1f1049599a8611bca5672f643d0473d4f85ccb5f17afd8c89287a1de9948f344", + "06247da5957c227fb6e5dc859e74ce1ebeb2df867355bf7e36eb53eda6d78e2e", + "3f9e6208be9c86425b8109d07fa4a6d6c6a41995911365cb4f7e602b3b493b78", + "a7ac5d415b0cdfc500d9d4c6cb5202c5bab2687b7855a56a4c8090cd2ad38db6", + "88075e1150cb98b3e0adeb18f71ca4e7d8858a2862a56c78429950f70211e0ad", + "fc2bef8bff080a850002eb1ca17de45657ff4e9bd92315e343ccc240950bc10e", + "931fc57100b65471ad6d1dccf1d6cf519420c4d1b13347538374c7032a652d21", + "92e18c89a5ee93b9ecf7000d03dd38daec593af1193087c60fc01ec67f1d8566", + "94412de858aec7e68131a411115f7e920c4e25a9e4a3038aa7433f40ce4aa04a", + "5c762ff7199a35c8bb1c7704f2b26c9490a93c38a027c2893f9a3239e0f63433", + "7247eaa78ba4814dcec0c8cb39c358e8a362231786db8c9a532934872db087d2", + "a27b37648ab79781fade4b2040414255f53a3874580ed7bd1becc8dbef11f3be", + "d469da5c371d4be0f12bebde2404f9b090dbc64b73c70cf380bf56cc94d64306", + "3d30a0309c6a4aa31a4c1db4464b1850edc7f65a123ba29d8dd8e6403d083337", + "f025ce7f10f1cad42d7746f61986b8d7994da5d96e888723681f91f2aecbdec2", + "8452103634599ff2fcbc79cccb5d4566c5be498ccb4bd191eae9f180959b6ce1", + "4ebef6e54e570df5ed5e76f1929f2c20e9291df1358685860e466687a7dfa1a6", + "def5f38b0761e7a809ca1826751fb6dc588832388ff1a37f940230ad953e8e81", + "9cf7e5b0d2c11a0230c255b45a16dd7c59d83295568a8a74dd3764d0ca712651", + "ae2ed139c203c0403f1b2d54269e12075fc8f255f001d7741c1e88d219ce4d8e", + "cc41d090c015745f2583577bea01ce2b269c2ec24fecf425006f42508ed6cfdf", + "8d5c77b50f920e22cff14e5d469bbe58ba7f0a12a77be093d2c868d2748602b2", + "7cec25dbd0e5079e366a5878ac867520e765072a74230d2ac6cc696abc9c8c0a", + "4bcac8c24a670e1142ff51fce8a84f867608dfa091af59b5d0463fcfd551f3b6", + "598ca4da42ff9b0925476f4172015414ea91ef9e1ef15c90fb47f280ff57731c", + "ebbf4ebfcb3d568da7270b8f077eb7501534b92d1d1043d8ba43c9cf3b9c49fd", + "e692014404c4beaccf51fe6d805db7156e6eee7b3913ac9ae4d4bc99b4b41a1c", + "fdc835fc1a7423f74d9b3b8762ec8df0658b65386306b61f062a33ef440612c8", + "cd1c2f2f3e37f4c1b9fc872c3065182a84baba667d4e0e543276827e464bcc0b", + "b372c027467ac71ef7ea5c5bc5e7977dcd269c52a6be1e61edc007132c6524a5", + "28c8c9d8fcfbdc9139cb8e6603df9ab43175a4c0e436162943ae1f09c36995a0", + "cdf6110d8b1407219e1a88eb460eebe7e8dd496398791026109851891839e37a", + "2db4efa5b647df833d930b81efea3ecdbe9911e2f0af126933647e192d38008d", + "87c3c0e1b2d7e5afc9cdf407034cbd6ceaedfa752027e92e522433803b0d0850", + "acebf5f7cb88fe3d41c6adef8466d2155feafae92c41bcb25541c027813f7d30", + "226351415e3f267ce22034e2d05e34d67e2f920ea44eed2ce72d86936a26bb32", + "1c64a53d5e260001ee989b3226831a8787dc03ba2b8fd4f20f265a5b1d5f9c2e", + "ebf3069e19585294f9849ad2d23950f4099784d3488a50dc1ce1b4e23ae55ede", + "8752635d0ea4f95c24784344ca06400e4a7610e1f9d0265ce69116fe262a7ea9", + "917fc6ae8338e6db58a157db5902f50650168e3c954dad686b17dd5753148469", + "ee785260a86c10431a36fcbe5958449593489b9fcf9b0ce941c042e9472f95d2", + "457a20d4815aa94a9636d0a4e23136c686c184386e11831264dff8dde175c184", + "f0fd6f8708b538a186348b5eeff47c5d67f1e106c1aa9e9feac423558fefa8ba", + "b67b1a16b914ae8eca2bf84b4d40e99bfbb81c246e6519fa2a4de47194d307c5", + "781eb8eaeb4464ee99e58d1c948d14c63919b04d68a58cf92214d9dcb92b0f40", + "0109d1d3671f1c99ee5bc4ba124ef1c55bc0bd67b77466047a7f935988de3e6f", + "a1c1d8d9f1ae8859f3d47c879dcda9390a3cf17f154e1e7a4813de5646161b67", + "1ce1a0568c149e4b8af405b00648405d3271b7920710484d5a9bd658beb81810", + "9ef1849b22ec206931180aba6fd3b6e45c1244d9bfa6997149e857ae3edc3d64", + "ec897e07b0f21b87d21e5f552efd6768fb7724dc60dfafa9d8fd822cb6dfa675", + "55b9a1e61b64db34b02735bd8f8e480902f2ac255b0e2dd704eec61064d3c18f", + "28393061edcce39e63e3122da1d200fa44fa8afc123cc908869822314ef5bf4b", + "989f8aa09e7ea4c35ac1c92815df784318f11cf50def64e942cf4113ac4c88d7", + "02e3a9a36b1e767c6bfbb0b17c73d7b4e08c04a9af234ea4249a56ce1b1082c5", + "de06a4a9631d91247fa14cb3cabe9e3aef25d430cdc989e432da068a2e610c62", + "96897b9efd261b5f06681d6accf38b5783eae450dcc0aa09ee9bc19f72693f06", + "398fe59ff8f9a2587d0c6faa35c4d2c2c3d5c3db43e6428588bb9cbb9e044446", + "66c99f4b9dd2fd1ed8f59e85e2683786f57955e31e574ced6c7d59106cb6c9c1", + "4f8eaf4cd71085e4dbad9b2cf21ffb22014285bdc9ccf657312ce9fe75989eca", + "733fe88720d0e872116891414b47c12b4eac213be0304290e5e20d04b2b34b5b", + "5c68666868c4061ac0f26af7e4043ff38d9896ed595f67cef098e97cc32060ad", + "fd291aac94e0b36a45169f9504467fd453cc690ad78ffffabdb4c8495e0686cb", + "ab5f58cf23bff29a53c5ed3056012076d43249adf1953eba00f8a11e81e15549", + "b5a600459d427098b9e58fbabadedb2a9af5f23f1892e01736aa2e61b8644278", + "7ecd2434e793a413356755bd5a913e7fce13f9aaebfa7af641626f73264bb6d1", + "ef76dcbd54c15aef0b4b5c1ca8de94b0bf09d389a556418b90170f16de9ac3a1", + "cc8aa57e865808510666cb727b2196606a62c03c6697ae3813efcd7ff42bccfc", + "47709f5c41bb5f7b74a834c7123d54c550af1ee0eaf90c032bcd6eb36bb2413f", + "087f7880d78db2f5157ab98010452436b3a84013fb36094b403243770ee1230f", + "50855a2adcdbe5a870d050ceee7fc99c56905700aa3a732cda7243486f9a4630", + "0a112c31fa0aa284a99f6d68914fdd6d9771f6540cccb0aa13f3812930e2adbe", + "7de87445ee1a29ecf61fafaa37d9d03f4389f577cce897b7ecbb178b992feffc", + "b174fc13b62687420931a862a86f68377c4d8dac50a587aa915c470d7a737312", + "37aa42345a6e3c82ec2924750a1289349d1855ae045419fd0c9d91b678554dbf", + "c9aec7772d4221c49ed150ca6c9a10c7ee55a47dfb6cf43010eaab8875667501", + "d9683663ddc02849d8ca80e09aaf8c273a60c0e9c45c3dff18eef4c8f68ac68d", + "1a9ccf416665fa44ca5259dd3372baa5677ff0ec0d402035348944ced2c6c094", + "2db8cac10adb9f9f158c7f79562db4b9d95835a8b0f5ee8f8435179da6624f17", + "ca9ab2caa3281d9a6f84fef0494d2d79f9783d6e8bd4df1773b4f256a2e19a6c", + "f6e5f6a2d3e9f2db941d91f2c0198997a832633c605d4f7dc6d85c7258ea5d29", + "75d18f0e241ad5bbc03b067444f484930307aeb979810ba947d2fff7810bbed0", + "748b6296f2a7d5c06aef35a1a335295cc36b79084b595f573df3d444fe233a66", + "3e973309b66fbd214bac9de7c4e225065e5c899e68e0a1293f6cda593b05f937", + "5a5e54bedaad88c91720bce482b45bf1d7240adff2659750dbdcdba3e1d7f489", + "aca90b101abae7ff583a2d355ff973783ea8dc16bcc7dfc2fe4bd435023f7fd6", + "0e239006c02459a990460013d7135e3ea8e7bc99271a5e64a2f37d32126b864d", + "03f2e93c8b4c96c09428bc26461a4856ad8a4f8efcdb54cf199e95c1163b2560", + "1849ea0f0793cbc871c81d1c8d6630f6e726461d13671f6be6fe612a9b9aa57c", + "9185bad36d9b09d4c5283c2a38d30fb9f684f63423d45204d13c72b668cf1a94", + "c940cd0aeb7772087b37c0d525036c3bb85243bc651206396e7d6171a3a546ea", + "c6f60249e4a2e5b56d14770627bea6026a3ecaf781fe5b8e339da6401ab4e8b0", + "d9497d80024f3f0bf1c8cd992ba63d4da6b453f7d198155dc1cbaa81bb93db5c", + "224e674088ff4e5c83a9ed29dc7dd23f57c8b5bc3e3a8f8dd45644215fbc2b4c", + "40192c80a55a69171a4e48bc730b0284b8c43740948c2276f61faf819b1d7db4", + "7e0a63a748588b5e8769f4f0cfdb8100e5bf8225f8777ad40c55ca226ecc4eec", + "9e0bf4baceff4067649fb2cb29d2d74a1739de9a704d7579ad2257eb3cff95a6", + "0e4671478159626c9d6e1b7d94ed28554494e4aa55887ec04bd75a083bb29e2c", + "455cc57af3bcf58c0c324f684c4ec279092beb9a1524ca71fdd81f5a1430373c", + "a8da92e2bc7c04aef39c98f7bcab39564d9d5d9dccfac90c453785f00d71b6ea", + "63b93fc3624c9b3ab8a55f3a684920e0a6a5ca1c3bbc36e3c2913b1eddecba38", + "3c34e6e31af686a65d6c8e842db2ac3f0ec0314f14bc859e9e2a309e3bd8b272", + "f9e4a79dd50cf0973b6d8c97353666583a4d9279c0131ba67bf29bad9fe09b41", + "45e9ca140868b481821eb9f5dfbd605932f932da6c48ae0c1911f53a73aca0fe", + "a79516838d8d6181de6d9590b795ad0c5f263235c9db04e0842aa0dad86f9305", + "734d7c4de32c057d0671a1cc86bdb003d09505b4b46c46459bd5e82230bf33af" + ], + "20a3db1f3fffc8f1": [ + "f5a532d6925d20ec42f9ebfc55f4667a90ab74eee10ba1582756894d42bfe016", + "a30043f120f5f6b7c0c2c8c196443faeae76e5f7eaa7d3bc5c971c6ee2b52d24", + "bfef6b5f330a0baf8789ac6605d56699caeffa5563ad96267d8d96270e74b488", + "c58f2917fdf75c0ab2e26485753984bc94d280fea3eb250c909558e0835f505f", + "c58448c67008f89690bdf0e3dd7ac3510667785acb8ea84dfd811217ac122bbf", + "2e4d81ad185db37b16e171496033619484d7fc12ff395d67966adfe4607b7086", + "c8612384a782033010756a6e02c989b3f573a0cac3ad6ef5a955c0d4c18eb97a", + "9a73c91dffafe8156135e46924b0320328c8693fa7b114272062ee7c8a636527", + "941a2ccd720cf07fe452ffcf2b05e79e9235844bca02c242b33274b815fa392e", + "6affe817f82bd769561f61f65aa1485996103ecb28b15d6aee915e2ccaa225ca", + "663bf8bf8dc31b28fa4cbee4bd4f884bf8c4bb543098878b70fbe36059087319", + "54f3bd74f2fdc0582e8b31896113711b1e84efc2a8c6c2c01fda02b6a8b8eff7", + "420e4dad94667a36551f3b950b3cd76c33016e65b1f8985fcbcbbfc9f96c0722", + "2cae3422ebe20f4f178c3c6c13a09ed543a8b24abd9a5398c64e61492639c93e", + "d381d332e6396ff97e34c2242ceb53909c3228513b212aefa1c3d284493aa584", + "6046a2485b5af934f69a1fbede4876f2d0703ef927f4ae69139cd1afd2c7de8b", + "38bbcbfbc41e25c00aac21767af1da9007a0ca682818571ad845a71b18ff5d38", + "d578fc309a957af708cd98c8e9888444e92d452102d3425ac093f89c8d1f5d0d" + ], + "23acb049b6a89464": [ + "c96848eee11ea4905f85401bd5c14fa5ead7eb35d360f24c225ce4dae454aa41", + "db5c3620c9e90134f23668ec26743f250803eb7233c9e958672be996452fc6cd", + "a824dab81b5f287ab121ce611a73012a150efb67b2b68577b5112885db303cb1", + "80ece1963125dfab564336f6a2741fee3dd26959d7eb67da4e0760208e5ea028", + "f0ffe11a22b259d6fb38a948831145e22f95a8374c714c1e382a87d4c3818731", + "048ef82b5351728c010b31ebbaf58fbd655866e7e697790eee96f818a350c4c1", + "365a51f453b769124cc97cae76cdb45cdbc61ed415ea8e96bb043bba7ddb3014" + ], + "2442df610ebb6911": [ + "6a97a637e7c60e76869cecfd792f36e4dee6df6ea6d9893d87478f64129f3795", + "ae118456ff8019a9daea92d4b2eb6c7990a57dbe2d29241d89c626f3aa18cc4e", + "862ebe3c071af16bbdae61fdeef726495e3a4cead17ac729e68da3356c01645d", + "d042ee42d9a5633828709ad2a1812c3c701a94404707da03bffd8f0322bcde07", + "576f475b125152a07a851cf3d4d40339b3c30e6ffa95b70687af33692c05bcc8", + "06d4e17e1f8422886e5d462bc3523a83f37da367006d5dd566b06eff7cc7478a", + "2eb50c48c4f32f012945cb5df8ae5b223a672ab8cfbf4d48ce9806913c7bac62", + "ccb7c9a25bb5388bffb8e33dfb291c4f29c0138d0ccc97b416a8af9aedcd14e2", + "73f55a399e0710d202942bfed67539baf8bec07725fe8ffd5fabffecd27dc8c6", + "49312db1b3972a5ae31ab0afae1881a2c144624c31bca89c5b4716c0ddb9aea7", + "79395d420de7f1a9e09b7f4c83220f9a39cb2d4f614b2e72d5e0694c84b833c5", + "c19de4e14b7bf63ce143cb5925350d51987c0f55e65d3f48aac09a969023a95c" + ], + "263745e4962da7e8": [ + "ece526c34c3d0c0b52931972cd20ffc638102cb9bbdc21ef2c53ab3f0e4cada4", + "75cd40252b78c242ce5af28a42b632ec3513ac698d4658fcc9a004bf692bd7ef", + "fd66564953d8c20208c045ec7a64b6707d9e385b80022d6e2ddc0822ae14cd8e", + "7877b8e38c50ba6ccb79198e6b9bb96b63403d0c4d26901dafa182a928a3f293", + "ccacb967f625373f005a6664a9529bf4dd67f978799b5fb57ba4490c6343fe82", + "95526e347c520e5cda1527bc79c9b31bb20b5fdc909d028427c235a9d273e7ae", + "c10718f96b927ad45bd2aac2b4786d02712e9e7b26163bbfc2e46b340aa62e8a", + "825fd60502544c8f220c90c14700ae8432daab9f617fef69031d2e4d9de6b060", + "407b61624cab9055cba881fe5db823e334ca72bb1cabbbcab4e93eab624720d3", + "a983823edf4d4047412b75778e82b8895b32cad90200353ae60ce920ab2a363b" + ], + "27acbc623681ea65": [ + "5a65ff4ee1bb490747d5098b214f0688e34139e4b14701a1ecf59cc38ef34daf", + "c2e6ab390d810be816970b1fd8aed0f57857387a85c9d99077fcd8fc985495be", + "32711fc898dbb0ce4bb012fc569801c758c9d3f61dd594a7b48aee9383960602", + "051b74d2653b7a46b289458b59c7b37f88f79ee35f683da7305a27117da945ff", + "81d3cb1b0e54188af7e90c84d964db3032eddb5c83401564e35836a2a51195b3", + "42699f528b8df59c51f3d1f2f318d4d67d8526f32e570ece35d32a6353ce8a23" + ], + "2857931af5513c04": [ + "192079350d2bf6b92e8a4c3997f27763fdb3019a24cc11ba906240b315a66ec6", + "b20bf81098ed88d45e7d25464740ad3f1f405df57409d6d784e0c70beee74908", + "2d0ff899b4901aae4895261c3ed5a1cebbefd4bee41a20ed8bc4b6bf3b9072d5", + "7ccc704137befda4ae1b2f5eafbeaaf07e47fe39049213be64fda54266c8af20", + "6e12d4aa32a516457ace5eccc13b9cac268983febdc55ec779aa89708fa00138", + "ab1c553ec6cfc4d3922ab17e7ed83bfbd0534090f00a03f085e7e3a36bfd3e5e", + "32daef7eb9768ce4b9b17f3e55c3db5e5bf83c0a7f5f53f70d04a015a7dc2c5e", + "a928ca40846e9fa0b60f9d64b857c2d44e33e79b401cb7447d62e4a3a9aa2a38", + "5d3aef764619e1313b2a7037ef9a1426d565db40cd30a54cabbcd0afed919197", + "2710a529157fe849b48f7bbfcba27be209935b60a45ca80a031fa7ea900da226", + "4579d3521a0c351cfd7fc8ee3a28a6217ebfa915dade2d3c7cf4918744fdfef8", + "468ff03bef7a9af23a89ba9bbfe56fbac2daf0419d1ea1eef91717dd28d9f39d", + "099a706efe31ff1f5852c934390cb13ee7da7e586b42ec844d2dbba4cb3eb5bf", + "224557e7a352a288e11bf5035d0738d4caf7d7bc6ca7d35439e4d7194fd52e19", + "9d4ed809fa31a20c372fc4a6f83030d47417c454de1ad34133c225e0acc002cb", + "26644daf32ca5026cd3d55ae1ed3fc3115fbe2d0eb179e3dd84aa0b2b2d8dfca", + "8f86b0e2b558296153596d152e3069d80776c71bc67052062528d813db366217", + "114ead2cadd0a517a27b47a1175b794a97de28dfa0bc38cebb3c6f792cb6fb16", + "b46751d263bfc7cd650ff98a922ffafa3f7136f1474f93b9a7d706cd61eaa86a", + "e14695bf90529f0b6f0abb681f7619709626aaea4136899b6e455c6c49d1dfb9", + "e9c51c6a79595801fd45aee4f47e07189f5aef0d63e821161eaeba0adb17ff8f", + "e79dde57c22b49b0a399570633c6f7acfc455aaa74738fc7974fecdcc8f1e98f", + "35f008ca5c4829dce7cc58fb9a3de3e07f046f71f9a508a3f3cb3469bc38a359", + "7b2ba37bd6a18f468bd2575c827e77cb51d3b588871b40283f1df05353f56dcc", + "326eb085539fed0322aa0d1b8f26689e344c7aad4fdf6b91a2eca98dd9cdb232", + "6e44e13740241319d66a728f0a7df69d8bca88ce75414fefcac529146c3a83c5", + "12250c6d644ff716bd058ce583fc9faf465fc6f786d99a2a73b20391e075b673", + "970818ddad162f3448275f57d3d027e5d00a7403e7a177984819e881974119e3", + "0b5ee9805eb727820704bda027b9f62c3e4e9d95dff1cbaf2e711d63f5052d79" + ], + "286a3bf25e4f29cb": [ + "56809c251a9f177e3dabcec8dde4f067a939f3634e13172ebbe2cb9ef2f1013c", + "477497d985a4a875851be785bd854ccfca409e20e13d49f8091acc86152a923e", + "503b360072a1dd4c6e2c2a8def1b33ec26e140f2e9caddf8bfa0af1338259c43", + "e3ab8ac03174d3c30c46792a35e5146b6c817df1dd7c4f072de04e36b06729e1", + "ff3215a346e9e402b0a33194cb771be08baef8dc2d827f71cadf02a3025b24ff", + "6af951066ba1085816700a8f679e158527bb90115b67069166c9b0b82ea926a0", + "d5adb18d8710eb0788c149f76d8aae2a7884ccc1fbf46e231da016a365b96358", + "18e26b92afe992a45703eb38b8dc665b1637eb1e02bfc7facb5a7831056081a1", + "c52ad8cd857f13b8bedc26d5c72eb389c7366fd6fc9f65e5cb96e1d9503bc8cf", + "f88fd299cf9acb875ce9b9e0dade11e2bdca59fab6fd0cbcd1ece551fd71a5e3" + ], + "314a51b634b5b81f": [ + "7a6feea54e6dfa96b44b03af9b76a12ae2ae9ff3ab583dbb6d612150da9a0d0d", + "17e618a6bcf06b963de09348d76e1354d81d86a202aae37bbbe6dc8fff9e778a", + "8690e9f523acd569cc491039a0b1d386289bfffbfa7c031f019e3e25bc5745eb", + "5d10505798f9a43ae0b0f438f846c2f79faadc50d8d5d69a1673da0c43496fb4", + "1f5e3dd68a1bbe042b3e1ceec8d97eaee64bb030441ecfc3aa8e479df5011831", + "423ee9d49147d62d91a6b0588061b71d8da970c65313daf51ce798f7f6c28804", + "637535e0cf157dcceff78b97a4c1e086afda7daa7b49a69b0755cf963dcbfc53", + "ff5b8a046f2909dfddd1486a76d56e6bc742f3645ba9dd0700d844ba3958a141", + "faa4f2f9bd6d0e86f0bf801a657d55abe94e22be398f2110537403d7c540ea42", + "7b03dc707cfe0cffd4aa6312952e01ac23b42a9197c58137bc89b50e226f0c0d", + "cde0d116e250da7b70c87f6ff7bfada2fb56acd9c9eafc470ae0c16f1a4031fe", + "198e1a9003f672fca6f01bb4c4aad3fe7e7ec52f21b95fff94d2767331c4115c", + "6ef0b319887e083249f53c52dac13c114aa4b8aa5105b34af0dacae5ac9f8e92", + "e46d406f54f3383013a2822412641a3b0c77083bf4211437645aa92d40c74960", + "7e69c02c6a2217e9700c64e94b0053d47c2ddbc3049f41b33f05ebd3e8c8d50b", + "ebef621211f16e53e9c369d1f429aa927949391e3b6a37c9a1fa9a5a1d45fee4", + "e36bc57f1e99697fc55d8d65a991398f88e21e21efd6b363dce0302323e2ac7d", + "411a90a7789e6f9df776e45b182babef87f63fef1ce08e5f768fb4c0bac2376a", + "b1222738e911b38cfdc037cb5d1cdcb8fdfe64280fa9bfacfa75cc9e3b18b461", + "17780d86c731dabbda24f5f156eff09cfb154c9b19f51f3611670560f4f3041e", + "a7c31362ddf3a1a9a284b24d47191b2d0066edaa6835bdb150b8c32d0dda0bcd", + "2a1b79496cbb5a6a5b022ba479b5b0082056c367c34753efd30388608ea154b5", + "82382db6942e874a2bad1e8a6c895d424c6638664409c602ac55c47ee84ee5c0", + "fa756e39871aec9ecc51387d8c3338ff62922d14101afb590b9cba1b08fa554a", + "0e16e3d17621602f1ba694339e565faceb0248f50e8fdf002f73ee24382c6612", + "d7b0e81dd1c57ff82ac1a5a4495b30a282274dc5f37986334d225885fbf8601e", + "bcf18fb9ada9f383ffd8b79a5d9a788a27415ebbede9b65de8b0f44b475015de", + "63526d3686361bfaef2e4093858e8dc575924c0f521bc7d0ee68d119d72f9688", + "65823529d76fc90e7968df4af1ce151e2f7615dec557505c3d66dbf81ea7cbdc", + "e2251b753da2403af540c458dbedc18a5752788a5c64f53fdb16da620336ba96", + "5cd18f3975935173bb9f7d0270054ba3c0ceb245949b83823b8d51097863dd00", + "89110a949d18c9c52c61b86c2317be1b79e2d2402414ecb2c0995a364000ac90", + "6de318c6591b718cca529e2148b9ac8ceec0cd00a3b19cac95d28cb9a50407f2", + "6f1925295ff0e67aaaea4c9f518f9107d35748723ae86aa8d900cac4b3edb83f", + "abda770f4460ed5047773ac0331be42a16f7198e3c11db4da8c891c184662d14", + "28d792ccb907af51acaae8f84529d1bb19cae5b7dcd7104fa3e57c480c43b3d0", + "c84a020c2017453c9de2de319c4c0c09a6142ce129c85ac7f400ce379b69f831", + "0affa930250b8b77d2160f820057d91208105d6ce79f97aef49c7d35567798b7", + "ee6c62d4cd10271f7ac2efafb82f8b2b469ebadfe8bf332068ade56f508404da", + "209b524ec7193acf862ab7758aae91a94bb1fe80b7c62ffe436b8082aee7c5eb", + "c3fb89c9eb1129ab6c0f5e70a28c93e2c665906ccb97f488f5d716549a7dd374", + "0cc801c39a70028f89a5e5f1c125da07dedc0a03689c35873de4cb9b01fe2999", + "6cb1769dc097b90599a6d7a1395d47039bd96acd39f5b609b7e189281ff68772", + "c1939974d2f717de261462d94b4d7d1125630da8951bbe67e517141f8012a9a9", + "e002f2210cbc2e69a022743f12f66f6472bc1b6c604ef8c77af0436785459b81", + "99b6617b8113fa2d33ef0fba975270317edad01b4602b6203f86a03c6dc0541c", + "4d30b7e17548b502f5615c0d892dcd8f4f8c2fe5e3fcd546ee8ac81c8e3284c5", + "d2586c948630ed244d1685759d39e3406a27865406ad5d41d5f8101aa33bb310", + "a294b4962a9f29df7fc2ce2bb5860345245dac123ca8dd88016aee1cac7cb94c", + "c55a1afcbb2dbec7ec37f321816b5b352a965b96623d9c7a0dc0652f0505bbc2", + "5a8a47f169b65a54c6e7204cc2b555c51d6824d684fdbc2e0859e25a56b7982a", + "36e44dd45c81c7e2dd41405062db074d588e2dd15dfa19097f755de0bd62785a", + "834bdba33985d1ae48f1c3291c82782bb00acfc619a14f9cd724e6153f6b57ee", + "2e91e5bf2e6755e6b210fdc73a51457d2097c9d063b6260221c33dd17bd893ed", + "093f08f6a6bf1082b642107424a5d232bd2911973b40eeb6be68365064c2c770", + "bcdc64f42453fbddb614f293863cba4150a30d230f160b3ae1ebaee0f112d1f7", + "ef79a0021ec8d53ea4f05498a74545fbd2f47c92d187810a5014cce4758a0421", + "1b2b1c7016908631eec83fd1ae06f4361ceac83aaae07daaf5faaaf2017bf6b2" + ], + "35e1c559c597c6c1": [ + "3a4253d80975fc74c5520bf9a5f42a99ad4e407b1e892b5d80b824aaf61aa792", + "518beaede0006776060a2e120cfe1931f2093f4ecb677fe4f4d36b29fb600de9", + "415d153d8f78858fbd0c5fabb4b73e189b3db9eb200e928a99966ac1849138b4", + "38ee27ce52eca5305861293154e905d3ea8f62011cf9cccf338dce4d6b9d6cd7", + "d3f62522076baa57ad9503e668c39687ea19d049a068f92d57a8ac8c46298f69", + "ff99f4fa5071ba4883b31f48397efd1a4f2517eb5265ac4453894f18f92b4808", + "5589ef65e42dcf3e2ba3a8dda9da14c8f290b8e021e9112b5553855925d5f635", + "4465b6284ebe02c5fd348d7314d9c95748aed31cb3d04b273bb7c94e5cdb1a52", + "6952c6660a79ab92cc38007936221757d929ae9e64d8cc13e17579b08f47103f", + "64dd148a4a53243cc94d0491ff6581ecd544c4851f9fa68ea0f749c8734a65de", + "ec573c5cbaa450a4c12ac8045bb398155e5f1df3083d06f116092006be1217f3", + "3710ff9ab536ed422379a2514755dbcf8415b62943c41c14ad52205df2c4b4dc", + "c62b5bca4c38a1e4831176897fc179a09e050a1d5df33c65ec9c2883b64be92a", + "2b5971472534ffd100e3a9e4819f50117b4f02651ff91494e6f21e39dd038af1", + "779fbebacce2898b645d3613843a1cd22a6ef13cf337d443c9436368db3e2283", + "da10ad93cda65efec04d45da6af5ccc012709c1496e904abd7ecb4c019203685", + "2d0339ebc1cbd47230f0a5fcf63d0bb834e163c25280f353bc79ecc8b84b33ff", + "0a349cd011d0c2321e31fe15b7efd1d5adceeb85ab498fc7ef7e65fcb75b006c", + "fb7d91933f440c0aef13ed41dafc43b912104ba244c6cce540a772f928aeb5bf", + "3e31eab4cde8e80b2fec9b53e3f144b3617ce0c23b0023a2264a1b2b78872395", + "93124b54b05c9d57afa60bdc8095408b657e8abb9efd1ca6a7e286f04bc33deb", + "cd187fd65a87e226a8807c1ccb5f696ea14fa02d3fecd80622d8a068cb4b00a8", + "c6f0d64e96f55d43a3b2a3b1c01a1cb98f3001e5213af2d5272c06c4043a0524", + "ddb9f7ec7902bb014fe609462bcb4d2e0cf727087b00de786ba5534f3aa874ec", + "e04e88f5ee5ebc40fba6106473fe7a608bc21c6ee30d03a73ed18779034e46c3", + "9770214f7a83423515223ecd703a122b97c189d769ebd3ff5b92ff80fb58a4d1", + "af30b6d8925a440faa02a733c1102a5497c057f60ee3fbb3a782a1bbabf2261e" + ], + "38974d9d48c965f2": [ + "fbaa8e30b943e66e3a362afc9bd8a1e4391dd250607faab16768b01f0dbb461c", + "b0db7fed597dadb2ad9a8ddedc636f5d96b55be41b983fba8c1426f17d0a29de", + "5a9f68f5c09494acf71093e415a4f141b9030c735c079c08eb5e86d857734232", + "f3169b6b5637a7a260488985d1b303f8d7236d0a653223215a74bc3917f970e5", + "aa545cadf440613a2d42cb02a5546b4a27d332c998bffd244db88a79f9e16d8a", + "c0d33255e69021961fb94f944465410df341a0ced3b63ecb323d2f02d8720995", + "cbb87bd79aae8ecb1a6a1297dbedd7cbc68b4cf0d473a13898cda593dc265a21", + "f646c690b8956b8dd4f5d770cdb5460751d46669cfb8d88a7b20040e264e23f1", + "d2611c6ce63d0ddd4a69f0bd912bfc577884686e8d6d1bbfbb75c1ffb63c42ff", + "3b7c93329944781289b1f135dea82bcdf068ab33bab0fc482b3ff1f58fac37c5", + "b74fb90320f15bd4c1f61c4dac82bd109366e43215c3e068d4e453dc17d5a85b" + ], + "38b3b8e5d603ac82": [ + "5af22aa713474c443d61dc4486908657ef9805b5784f7fcfab494ed0c7b92d81", + "2a8899c069352438938d2d46ac7ace46f7a2a6f0244f989563f0d8e760ce1875", + "b7b6091db0df2f615f2e089fe540561062422b1e2e0b78485a470a94db12d158", + "4862f00a8ed38ef7b03e605ee8da5c09e4e66c4c684152c310c97c554b74fcdf", + "68e5682bb2d0ef9fe9587f1a0ed9c5c69c279ef66c290ad79c5d723b01b2a442", + "6945bdb98419477e8043b534f06e8bd613db0213dc9a65504ade42b4c1e69baa", + "eb38c8f60c34b14e6f1f3b4e0f98c873906495a285d3562d650f155501c11de5", + "ec5dfb1076419bdc8ebd64cc96aea1c66cc0b893b28da844ea3a642fff7a0dfe", + "235a4bd061f7ccc61ee1f3473cd6a735c36cee6b70a9ea568f36eb7d7609c8fe", + "e24855984054dc88c7e841af9ed4e93062f41890596cbfa83982e6a0d23e5624", + "e6488a07d1a169b16a7d6d6e732b007bb75789e12ef4a41c0972314e4a5029e5", + "3e04b70e898c2ab49421db801755755d28f625c13f429f852aa63ae4a443704c", + "1becf4aa1688a4cac7f1633cdac3f7e91cd887f2e2dbf629431a34fd9cfc288f", + "10ed157c435e1a382f7792d881001ba8369fb4e1c7b2ae94f9d83af85fd910ca" + ], + "39178eed4d197640": [ + "89504afb4963727d0bb8ec965fc436116232c46d7b1042152cb8f60a9be052f7", + "438c14b38325b0fa7aedbb97d7a487c9dd560963c976ae07fb900568e1ae78a2", + "179faa5bf0bafa7acec3f4721c0a6ecf678efda4dc03650b730260dec24d5356", + "f8a77fc69ffb231294db1f90c079af247e0d445423af115a842f62abfbaf55b6", + "bfecad7214f17b3680578441db48114adc491734db4a994021304a01401cba99", + "f39d6f138bd20479177aea2d482a16ee564a75844edf23d52497b93d233f118d" + ], + "3a5bd5071c5ba86b": [ + "958cc9b61492c7e4ed6f654f9b83f93af0c2d727a88454559b75ef2006fd9d23", + "c8fb6851c76c5d4f894bca53882bc3eb11b6e4ab3668a66dba1ada7de3557cb4", + "a55cccd5d54eb2f4f4dcf3062a4aa5c71bf03778f276f4730567bf0e518cee11", + "38f0b81ccca4f8253dbc26514c8fd84a2b70f6b93a80b0d414fa7140c4ad3aed", + "170057bc648459eb243d6118f273e910a7e4de74bc72010c728d487fff3dd598", + "eec8499108edc54c9fbcaece32e75fdc7d71df8ea4bfdf80dd4309617845f12c", + "02e87d669a01bbb68c41870d9aa05ecc3d7a5998a829450d0c20fc53a43e0c2b", + "e000026568737bede1e28f6681c82b99e1eca02fda3192c2660a22cc2298d972", + "f06e877fb530af353d1fb86fbbf32e7583d35c518aa6415d60a1ec34ca10a4ef", + "f602ce324e3d7d2aec5bbce97f3b11da54a06a19d7639d4eb49145cd45cbbff9", + "deef47ec7167de27eb07dfc6e9a8453ee1a2905b94edfecf91a47ed87befc40c", + "741e356623802165c16d49765ad0f0a8513836322845f6a3724209d5e865f7b1", + "0838a79a74422096bf3a2c642c0ed8d861dd18512011724137b9d9044a5b940d", + "92f2a6e9413babf1319b5204e3a0dea376b593849f9ad1c1129a07d68a74269b", + "1b76b91e7138cdfb1eaed6425c2c7696dcb7c4f98175d3e123b8ca41f5e62093", + "5c150f04cc99622ae6c908ab16a0b277b25aa136c973972a0bb407eb61e64086", + "66f15ea04882c4543742c62512eab21725a78788f54f83d4bf2f67b93587af83", + "ef0cd8d28540b5c182f8bbbbaee6ddb9a0356daa236c1f6ce66926c43b202af2", + "96e97f47482c850f8a2acd34d12b58a6ea81f3900d19d33608260c77dbf9f5d9", + "689cb36096a02dc7b7e5b6fcccc06c2e308fd9fbdaea5222dee2291f73cb13c0", + "1307d4fc937f9244ff9897e64708ae1c64935e6ae236e4d4cc6ee9d67675c16f", + "4f3aa4ae5a3d54acdd0c3cce772d73b354f6363a1706dfba84e6018d6f8286b1", + "ece73acbbc7eca53ec52ddf2405aef822d2e6a38b966e23d6ca3b5dc774a922f", + "1b0007876beb4d4843d01e4528c40bc977c923402d42ef007d710a4122a6e3ff", + "1ec7097f0df55ba89afee37459da3dd8961c46a03b074a2777eb4043ca8dccf5", + "839d9c0654a4aa2e67f36d6fc6e07c1ab94815005e61ae824ab3a47ffc631213", + "67d690e7b32c364637358cdf1255ce746d7b64c7267468eb3055d93579cfe094", + "119a96ba722165582f38f76e6759e42b8ff3fe21ecfa38a55e00cd32390d2e61", + "c25152fdf69e643002cb982186ed9a7ba4a03dba216da42910ff026aa168834f", + "1dc90ff1bdb5e79e8ac7e96738c9b856bf311118efa3115e7e596c915c06c83e", + "65a31cab5ce11777e8943481d6c7fafba5f0224990f9299db73b73d2e7b707e6", + "978609c4b460dc1b6e988c27052493ddf7b5bc98a20a20881dc7318d5d48dc59" + ], + "3ae1c3d954d314e7": [ + "43552b01f434b636b06afaa4e34a3403e4471db3f830b6c5153378c035f3d7ea", + "c1a5a097673ceac743d6bfc7a2166164eff46653a9bc2f59ca2f25f892dc9ef2", + "03a0dcb3feee7dbad84a67efac72da691919dadedaae596557fcf6bf27aab656", + "b0d928e6b9abdd7a1993e4598e0d516e50aa638429aa7a34bad4836050602361", + "7fbe4250d8f081443053e6486fe02fa05cb09cea58b2dea0f9a4e477f1f75012", + "240d3ef3ac49ba9d796941120cd7949dd302fa8985b656aad4ffb7e48c2a19e1" + ], + "3b239498ce428382": [ + "ff7239f8014e78bf39f68d40584e44747e947be412ee491297da648e16fdd95d", + "358c8f59d5b6485aca19fdbffe4e6f966b8c4f1439456a97856809dcf3f8f4f7", + "de9ee5c97e8097ff307f6d4c3d80be579806caf60e50074a8441ce4fd100a70f", + "5bba56267a132561030fb11bc3394336e3d53f3b66a755d7f64dd225e723e2f6", + "fb62f8ac02b7914982adc02055b437af63b38ba8e232e88799fd11a87d43ba48", + "c0593ee3b90c0e138209dfe1febe272ed622a1e6c08a38c5b684126cf662aa3f", + "d53f8fc1dff997047aa2c059a7e892ee2aa317f4c92a93aea63cdfcbc93071b8", + "40bebfd88b3b69952858ee33eace2a0d6f92aa86a056de69a0b5c68cdfe07a2a", + "5bde897ee45f2f001053c66b37722ccdad5cd584e88e18866f55b9a3fc1935a8", + "f8c7d5646873e2c4ef11cdc78b02285764a0574405fc8a96f813943638282c69", + "1c5d8546d92d21acc02d55515c6f23103e1cbe8542662f51bf3d8633b0c67323", + "09b3f901690702468e8ebb1d0e531f8eec05ab557818a6fd66278bb8c78d8553", + "b2cdd6288c5e5f6467edd30037f981b92bec3bcd06a238e4a92884f7ae42e667", + "f613d7b0421b5593aaacd93f1c119620b088566c0e73d8b48d94da092871b57d" + ], + "3f7c6acbb9de4c16": [ + "5b754b7b76a3511140d7d0adf72f31cf86c5ab9ce5784c609772bfa5497901f1", + "04af426ec7fd41818611ec1bc1c4d5e46ba23426b4beae76b5b3b37487675760", + "cb280c489522a8849914761be3ef6a82fcec35bc7c97481e4abb30c477580fd4", + "a79d33c8bc153449a3c079cd429b40f81ca894aad3a207b0b4e48064c5a858a8", + "e0854b9366527ea06c863f9976aabfbc14bc8d597fa9d3451729c8d9b8063e5e" + ], + "426c208ff2814bfa": [ + "ccf8a6154b4a213dc7fadb17ca97d656542e49378b39f7dcf1f3630f2bc19923", + "eaee78b6d52e58b8c19e7ac1ce7fa74dc6490ff45509d02c97e711aec2193626", + "f7be86d1a88de20d575bbbadd8cdb939e55695b5dc1bd645bbc050a7253bd616", + "57a6c2ea63826f0ba5d004b2674251218595bc2d6baf46e5d8613e5689997c42", + "8fde9509945e9ad13e15d9f566f8f852adff6150fa0d153a2a6bb193fd9742ab", + "c7b98980402ae25af3ff21068847910b449e8538214dc28e1d4f8993a3db8fe5", + "4d62f509d2a311a2c0a7ef9a3ca321101cf48905b4bcf26f91b4d9e0e6c06ded", + "398a3c736123d835c00adb5ee692a7e3edb8204b582f34b65b87b723a74bb64c", + "53edffa7bcc778dd8ba23aba1536d01aa86aedec9288d80075200d7b921efbde", + "20cf8a8988ca83a85f3b1302720c10817c1c3284af98d6d110bbea2821d7895b", + "c1e4bbecf0d076c5ed6f57c9ba94d9e4bf281aa4e70e7df5d35354310fad4500" + ], + "44c2420dbbc388f2": [ + "90a85b8dadbae427d9ff3a3bac7d287ec088aeaf53dc6ea51c072a33b978c116", + "85cbfeb409c00f3abdb2a370644194cf3dca4a0a5e4bec07bb7c0bd0c8246b25", + "9a5d6c1568695cf2b6aac61272ba4bf734546d47a8170e03ebc77a30e70dae2d", + "f36efdfc437d63a2d33ee8e726b38a85e0160fd7bc082317a4936bc8d4f48bd7", + "a4f8b2ab825b672c16611af4db4d11cc3ecc0a3fe833ea474f0061626fb49412", + "3ce2cb38b620cbc9748514616647a306f32ef86a66c81d40f43b528eb2b52b32", + "1cc75a864156da4dcfb32b6556e5c394d283bd9a9327fd7c2fc244f079e7c4f4" + ], + "45177e61109dcb64": [ + "ad8354059018350fe91fd9be0e251fea41ea85101cdbfc9097e33ab5efa381b3", + "7d6760fef086743dc4e505227c02fb28cea993a2ede028bb266fea412387206d", + "1166b09bb11f98232c512289fdfae358d1d8b89ae44f15d5dae2c1ac4d312f60", + "593004fd69c410e7b6d3195ba89394d55fe96d18ef09460f97c925bd2a4be254", + "73bc3b3fb5cda501001477d86d5c08c49a53d34be1b050b33416b63cf2c0d230", + "ed3a6bc9c54a53307511e3ba6e018f7a706f102a550e88ca6e3b7a030b8076b0", + "d4b39f1372477d1310a02e4784fd7bde0af1ccec9e42b234a7002beca30f9cea" + ], + "473820d8285cb3c1": [ + "70a213028fe1355f8dfa37ee51a7ccdde6d7a808c7dbfd37e54c0a7be1d65162", + "9ec015b786d211a9addb755c07e0272d81163aa3d5d51267dc114611e14f2875", + "904145dab0ec3bf62c8da5c46a652c33bd68ac81ca9f2733efbb3aad30586a9c", + "256089b9df7754aa21ee38269e6b65438b9105597f0602b8b9ad76793b8dcdf7", + "677e8b3ed8c64840b0d58295dd1b91de50e59e9a431e32458c64c0f1d118faac" + ], + "4b784fc283799ebe": [ + "11abb98fda2e228b8b89c2366a0e3275e3dda2c0172629f0ee9d11e755fb04ff", + "f04621e31a5868da1e56842a0810e394b88ed7075f81fd2b830ed96c1ab2fc37", + "6cf4fc59e278d45d83374643ae2ccf93e9525edb9079ec54ae8d5f9be92d0f20", + "9a308ef8a1b54b5a0b11332c2d19e616c590bc6689629f88a46de87f97def2e8", + "807c9141ad0939c5cab2434f2bb2a1f27cfd635760a4e73bbfaf349554a6dda1", + "6948fb863132b8e5dda9f9b1d447accb5e83966e8345a2b0c5964db42c14e302", + "d4c92363a42105cc7b611129d27588829c6b4fcb3993620a9d5d98cf4ab99d57", + "47c73a4459d7b8dd9f6d8bd563c439adcffc0be36f80942e6d87b1281b72793b" + ], + "4b8f756a8633228c": [ + "6c44caac5d044d7575d925e8d1ac34f53e2c1e42ae887746bb9502d4dd089326", + "ae47c1ab9aec7c1c8ba2d0abe33c14872de0648ea0873dbb4c65f6c7167606cf", + "eac74cd72d4f43dfbcde81723ad322d41957e9139315a796e95321f11ad089be", + "5c89125d6cf4349d212c5b49e54ba63dc613968ac2508ff44652c3395db013c6", + "d17cda5ed85e38882975baedf67cea9220e38d4f9f9223f995c894c432e79793", + "9db5ddf34f1018da95bf6d3e5c4f7728f65c1e73f21d67991050df5c45a4329a", + "6f8689421f14af1a76783d6c343917ebf849fdc2646c22cd48302deca9754bf1", + "50173b463b29ad4479e0dc101f4296c0242ade58bf528541345b53fbd1266a2c", + "003dd700374a436984fad63452f13e62fc37b527eb8875ba2f310e8e0874936b", + "3205641ae021b8952548ae2f2ad044e8ed28e9321e1f6090989e2f70efa3370f", + "e2ad82ed0a4e0b3f0f18ce48b4f310d80804e47bf73485420ab328cbb7183f3b", + "c2c992d2074644aea8e7604f18fd2c9863b5b45721ccb395f9a8159d9774932d", + "c428e9bac1ba94e5ef864d2d68fd5ae92597752b4c7a7b1299d828e2496f26ad", + "b87bf6a20eeb9b0369dc18b3ea6b99ef4274f837b2f946b6d2cccea36aee70e9", + "89946db3d67ae9ad583d22f9f543ea3b75499cdc35c8d65aa945151d53af8617", + "00110934fec1d3f16089acbf16c9e4f5d38c1ec7dff9662ba8ccd3261cf7c26e", + "a3d108a397d6261dad582b01059acf9a8bbe815cec602cc306111480bba838d5", + "e892a0031d08430018acdd5283d0f33af5f46bf31f172bad567d337d70cc3de7", + "816ee06fca402af7d77f2f6dc51e8b3521dc88c488fc9db4dc67e0211b47a0b6", + "841e9e4d87ad3a8ad8c79091553335055a7d5f34476fde6faf523690868f77d6", + "ad686f43394d0291ca0aaa760f36c7bbb1d5912f4fd258e157cf7be77056a3ff", + "b1c6b04038e1b238d4bcf786d96edaaa2a40cf885bfa15b8757e04c571cf025a", + "4eacf6456522392c683793f2bc2426ba728a5a5131f7f340d56b7624e0606be6", + "5f929f1122f133fb3d0b1b9cf9c26bb88d72c41c8613c177f023390942a9d555", + "861768e0c8af43d292fc3ad2bf9c43208d363063da0c9f6805b4eea69bbefdce", + "e0c0ab69c0d6f401aa9269231850c8f485ea38eeb740435009cbe22a7f85aa69", + "681716563711ea49c2c90316f8e4e8c40ad801d07a23fd6656f8cd231fc7e40d", + "e43c39c85817ad0d20f5aecc6c6f0ef9d31b6f66f601ad839493d1a237b69921", + "137531654f8f0c0b77d2b04caff5f4bc222563c3a543d91b014fe7b2b363e568", + "e7c24e20454acb8a3844b13ce16036bc13b6d93a0c6551e3842eeb6d69b56eca", + "f3912339dbbe367277583070fb34d34afd0c3719fa9c62c715c7f94187889869", + "9d2bb71fc7dd026c6a94214d2c66a7f388504859e6c94916f8e3e293de4f16a1", + "897f45c63e2f02dae3945b37e5212f4c7fb31304a394abe17befde3f526f512b", + "7f43fd4c19a2e5716d794bb0f6dcc04effb90b1482a443e2e966bc3a1a2bc5b8", + "77d804c2c0d239c1ea8a17c0eba4e8a0c83bce9f8cf4bcca484e8f2af401e5e1", + "c59e68e99eac7d5bbf98133e96421dd65c75ded906d5acb75758df3bdea8087c", + "74fbd67f5366a8f8d95a4b20bd47ddb5e833173c73b6a44df4a04236d7538457" + ], + "4ef7880e9d34b168": [ + "cb3a9d08acb416fb45740b672b93a2c84732d7049c21b81bb72c7c072833c517", + "56aafc554501957fd22d35204a66b808d0aa1fb302f5df8abeaa66f898f55a97" + ], + "4fc64f2fb69c53dc": [ + "d690e01384e0f3675f5fe58a1e468cd295d5f3b10e916ed6b2e3e363bf69ac9d", + "06e2f6d464690b80a32e7f3d85e9a1d0884c8a19ffc199ea3dcd71f80b34a139", + "3b2cbc522aacd9c4ac7c533b03368d62ffb745d2df5e273bcc5053e576f2663c", + "05a886876d1f4141e6bebf15e17b4efafa7be1f6c84f0f18afdfbad12900ac04", + "8b1caa534fc638fd2d07ea569a1051bff1d7d4e21d9d5e1552e03095ed33b558", + "8c7926d1dd99f198cbd6f0e3918fb5d57728b9b55471849df74ded6982a5ebcd", + "fb40845ec518f1a25354070df65c0972239c642e0b7f0b124347beb194c2807e", + "70c4b4eab8942be0b104c9dc522ba7959020db54a92706d8899384d296ee4b99" + ], + "51bffca6740ee9db": [ + "54259fac2c724cff15402c526356865d47f686ef3e23c7a484ef0b239db72a3f", + "cb68db7956bea945672ac62d03672b441c32895f0098ee9afe59da3bad55bd99", + "40e5851d05cf52402cb30df3f9e889f12857e783511d39d942b9c98815c12c0f", + "4f9ecd8efb3b4b5ff31bd58027b9f7c9e6fb1a2de0303d223fd9430270d9d17d", + "6361ef986100436b8f345bf8960bd8aa0de90e7b21aa57ebc812be207211b2b8", + "dba8f10c66a4da3a25c51b82a5e37128c9b01f227a67a0415d3aa31eab33aca1", + "f889bf9da26c5929433bb756d13fc5325dbbe9eb555ef13baa0dac7a88836360", + "812aba8b651ee58bfdf96f06337a854dfb6b7ce47935dcbcf2b68110d0208777", + "070c41a9493ab7f77a1e59e9517849d8945758f13dfc90d7e0e21ce837bbb0d2", + "f863072cf14fa4627261e65730e57c5bf5d6f196c91b091c39a45ce5cfee48a3", + "9be307d36b9734300c446a63ace69c31322bf11447531b7cdc9e3f7e27b2cd67", + "e70d9ab3f89d4619a6539f50c6d0d4856bb7c5460fe324905f49199add06e33c", + "99c008ca489f2a7c4e30c80d2e2bf19e8457c5467079a7dbaf30435c408dd288", + "a0f929e68dfcd4ba01c600bf60a31670144740a6918baa8822e5e3cdd9c41824", + "cf8d9618e8348f2c07e2f5acdea5d683c8ff5df4696d766557efef5c7be71e30", + "ce6062a65b545b8b432bd2bf80110ebed884dccee6a711725925de0648eef7ba", + "4b8a1fdb7d03bbcd4458ece7e34e11c73fa13cfb8c1bdd0c9774858a520d26d3", + "4917e94af0fac88ba5794bb8c0094a01a59cca053a74c19736f8718c0ec9c715", + "8b23f4fddc666064e952f4c8ce9c5cb9b8363cab2d66357b1e6913278b9a25f2", + "4149a64133d266b3ca48dfef57b681823dd41f510317704bbe53be5d4f8f6aa0", + "1ef613c5d49867586ba0c5c688f30f086381308f5acf5cad0ffac10dee3dc28b", + "58a92cabda40be3050d91d53f143e01e45559042f2a8117a984f6779ec2d9d5c", + "2476122ec1ae838e6d70eae8dc9d2bc119546e2973d5ec0492097c9e3f39c89a", + "781bb3404e5c403bb8fd73aa2095e77f8c71be1c8bca70bb749297242b97f9ea", + "4b3f009778690e1d2364e586058992d2d905b4fb03aea939f70230bce48375a7", + "8c86394c5210e7a387cb4f4a6881222a6a1ba2cf7ded33cbd53923b861b2bf06", + "62bc2e55b5a0a185dade5d004f3af80a406d128a19880bae3b48b6326da01ced", + "839ae95be5cc267bffc1d4009c3b7eae8704a5de7293ee80df173a8906a75e62", + "2cbbc7f6012d11ee66a98bb3bc2906b3414ad61fe136a57abb4f87a54b511afa", + "f6c236c82997ae79d70ad902e1ae2534b032b8c613c57ff40e15110b26bb5769", + "6b61b6c077be9c69632033b93d5ca5b59c7c3dea4def78870dd766fc746df8de", + "cb97aff59cba5b6c2d8acab35536efb1bfcdbd6fb1edfd8dcfa3458926ae9641", + "81394c6fcbaf6e5e663b8da522504541aa996269e241614913587cc80fd04134", + "d917dacc16bda99399419edf8dc60eea0ec961cdf1691e68384ed0eae64ec7e4", + "553b572828f863b2291314d0ca7528c4ec45050384bdc1a96d29442ea1199876", + "54cdc3a8c36982b8efa9fcd78987a0ae51c69eaa268ab4b2ae8a0dca6fa99143", + "5b36b562ac70856a366505193f259e7ec09602f8595941fdbfcf292c6ee89abe", + "d1e41e6f13fce1e2969e6e61b30be4356f3f631bdc1fb6ada83dd3c2065551f8", + "dd96811e39716ff42fb842f00b9e0c4ba5b16ad198f942a36d45893357e14641", + "6eb10c673b477d4c94dc145a8a6e6af6053de012ff336f5426fb7fad21da2174", + "02fde34cfbce956b9ce296f6ac7a53fd898da14ad383fe5ba860c44185e54baa", + "92d93199ec3147c9503f387c0d5895970d2baa7608b85c377d65f59b1d8b6af6", + "9d4760cad4e503741185240e058b5951a99ceeeb72724229b77b3f6eae91a71a", + "0f14cae2a79a9a29c123cfc22511f2d44abfbed4d21d3c23bbb59e6de7374baa", + "a1e7b82b9b1005c6394864550b958d438d263b522683d7c864447bae0ed84186", + "120a4bf517d9a98181cccb3d2ee6a3c5a420480919820c042e71d8419b37e123", + "42cdd6c04efa2db0580e3d63e528cddbee48a5cfa5c52034d34941e22fc57a13", + "c76340a0385f77cc34e94070603f591dfeeadad808a90f91bb0a2884e9018cd3", + "5465d08b1d3b53c4f566eb14bfe2dc7aea2f41f0ef04127d4751c888c97924d8", + "371f08c92c72808bc2aaee88f9b1e1309c1df091b1118b4b36271ff65e1807a5", + "892e9da068419a676f4abd91fcc0496c3479926b57c973fdced61eead8c01924" + ], + "540d99311338c4c1": [ + "6f6dd533c68367d6d3bf5a0d589f2cec1cfa1e27ec685dac428573ab1ad6ed63", + "fcf846b22370be272763dc507a1087f414ea6946083208f6e6ecc18afd2d83f6", + "c4fca00856ceb40f6f52636cf324ed8e31d4f24daf331ae9da3936cc31ce26a0", + "b25bfa40e774fc868e398fb151bf38fee2fd1e2261af242726e2bd143e3e01e9", + "769e522b925feaa3a921fb99bdfa323be01b73953bc750c9949771e44aaa5f4b", + "c7669a846db575e00d33a2c559ea06989e924797ac97ff4d645230eaae7b1767", + "1d2415b987a4253b91f3e1b1e9e6b45e8ff53f1420d6a65c3ecf29cf024e323a", + "8758ee55237d80b4c99dea7b58d49b895ad4dd03687bb861cc22bb2619f3a774", + "589127d8e03e4d4d6c1bf7d0e60a1802157a0fba7fe824f0b4eab656a9fbc6e4", + "8c5bff8570d74032989e3bcfae4c65799d95bb9adc2e5896ecba7c558b5cba29", + "09e9730f028b5e75bf95c34f1c6cf7b1d8b6e1e001008e97e68b5492fb60d19b", + "f5505d82035fc1a9ea86cb76a9929956db2ed0eafd099f927dcdc7f6ba26aafd", + "af8be5e738a7bfe808c7721a4c1454ba8d8671565f9459c8c5f3f51e003da4c4", + "b3815c3838d87110fe34ff3960ff35cb03b9ae3ada15b8b25e240d42be3cd8b6", + "500a5719ff22460602c37675b482aa926f05d7fd406b7ebc0f02d68bb3bd5e60", + "d2fe4b2aa22fc4742f0ef415833cd05c63b04e2a6d1ce257f3f2671da957e646" + ], + "5525d69c94ddce61": [ + "13711fc773f09bdaa34b6cb7595b7ff05eadd4baddea9424a04bb8de20883534", + "9bccc7e8d7ffd65bdf02a1d636df96d47f44021d1e3d730837914af21fed2a08", + "85bae98c5132d0aba687791193851864c00028cbfc8ee276e1086b4cd4032d48" + ], + "57e1e8657a2e07b0": [ + "c062d3cc9d9dbf49c5811961312404b903ec495e3b712f3b5babdeb4a3026a5a", + "161a9c835f112589938968c0a447b3bc1c4900014e145e5d665565912662e65f", + "8269ac70b254ce4cc4d14b89697ee0cc4508f60c0e9d09643e4600e4d05554f4", + "9bbc5b19d5fd3f60f6d8f99c6202de2f7a2de3c116f1d785eba41dc16a675fdb", + "3b214988c534e9f8fdf8b3a7f3698f225a8aee87dd7de601930dc2e31a313188", + "91bfcef3ebb5f89e3d3e013d57610e7ff0cab06c1f9901cb888c2f272ed6ea2c", + "d0638fdc4d6a6c0672b969edc1bb6f71fee5edfa3989d0acdf2dfec7ce160623", + "c29b2699ec714e95ffd704fae7533a0ece4422a34e0b455d5c743792762e1d66", + "fab8743d623ab670b14b8cd2729c6f6b79b1fc03c8005fa629ef63153ee09ae7", + "b792da84b06d0ba8b89f4253e8a43847e65b6ce65eff9da5ab2c1518fd79d0e6", + "76a98cec0e90a4c0ec158c09639edf7aae96ca32be6366dfa3abcbb2167c06a1", + "75e0d4ca82c294761565a7adc1cfb0cfb0277abd7215e1e8be956b58b5b3636e", + "d1874b1e75541cf3bd59a69099308bdf7a85949c1e3d87e7319b14df1ac7862a", + "75369fe7a4aba3b25ac7327091f3daa69c31f59421294291ccd66204149aafda", + "bc2fb5a1dc5c659d1ec330f63f0e5dba7cb1e83ac20b0a154dd10c143778fcb6", + "5e4de5958b513765f75cec3c6b397f7ee0c710f5fe46077ebc6d23b2c7c0cbd1", + "7d2679a11a9ba64983c707db2c1fa0537a1b61db38d67ff8c2984483fb04992b", + "30c29362cbad4bfa0f28a1e0eb2beed7c40e6733326794fbab8961062f8b8cc4" + ], + "57fc7ee37107dee4": [ + "4d29c7eb155e8edad0e71c54b949d4116547eaf6cd9dc2091765a94f4954b30b", + "45adf3165da4ce99a2bc1b410cec01ab803d6b17433579bd4fac658578cef51f", + "75bf39f6475cff07def9f8c537a2ff899d51bf59ff87b6a0fa9a3fd5b5fbbf46", + "ee55986ffd437bb5112d096212b626793d64767bfea8ad541c50082ebf8b8d39", + "35a4f929b73c2f8c1e0b5119adcfb2a60d5ee6f67764db56ccbf5e3874d94890", + "4791b8382a3a0a2b1e61ea78b18f7e80b636a8f1a68776064d1a6c5365d0105b", + "c3b2de28bf9933f2f8939d921b57f14e3a0602198ab94915b7bdc41ec986d8f9", + "b9ddccd523f6be5398f946275ea7081ae4ad81ae0cbbddec50a8be2cd4539310", + "1dfafb54161aa0f703497f95baee24c1b0685d3e864a31a29999719b73654b75", + "0c2c6e56d579dfe3ee4184eff2585f22fdc3a56dce3a897776e61985a0a0a1bc", + "44f1e5412660ac16500ff41a57bed6a9167499d998eb390b15a82482ad250e70" + ], + "59323bf0d8e4807f": [ + "a840508dbd005cc6a491f546ba0594ec67713c900ffb9d3074a110d2d563468c", + "4a55a4f4c47012b287c0483f1537ddc61b385fd154f92d75f419e49f3f6802cf", + "b6bcca72cfba5a9746169b8c38f07271dfb501776d1a7f1771a0704deafee0d3", + "74b02db740508c6f9252f5875a7c596ef68ec43da646fa5cfda9327a6b3ab9a4", + "0269035312b374580ef4d93095ace677c7621158f52544b12b20c88c647d7061", + "1633f15d3054daa6701013f8be97cd77e395727a6a6a62d1cbe5107a61d5145e", + "00e7af67926785ce083e3d1ebb13d26abae66de60bf86628f13361716f6d5924", + "8b23b40ccf6d906dc4ed584fa3d4b31ec6aa9e60daf24194eb015c2e9265de42", + "aa6427d38ac63fc4529b38ea4cb46e96dcbc14522c010974e69aa06538d57b4c", + "b905a1185b3c329c5d561cc51845197a718e773ffb8e6179377c0dfd00f17afc", + "ad56fc0aa812203773c37bbd7baeba8490e028858049846cc4aba3cce2b4d124", + "17491e0943a3d94352ea87363e4c2fe04786ac9117f462d18b0b9ce687cf871a", + "6ac95aad1315672258146491d094a8873959065d149f7737b794d649d835378a", + "41360453031a532faadf3de9149c3d47b1f563a4216d6975491192a630cec800", + "b69a045b3814286290873bd552d5a48f66da6ace79acbf8cb5e48cfc38b8efc0", + "a8da78e02ec4d0a04ff51e7ac39ecc043e895e1aec1067e46d84429463fa7052", + "a8a2664a7a60a221309b9787a1c4ff291e4c71c28d0f4f826f46ddf55f7e750f", + "e3cbe15c6424395c479016760dc402aa8297a08af7300305fc97f8cc60cb1f27" + ], + "59bdb6a0868e291e": [ + "c3a81badf7fb32a5442535bfaff7c0ef3a495b2d19977f5bc289c93ddaf2be83", + "6467b6ffe594c0e8278f3ab70beff738e8471722bec745609ea15ea04cc4cbe3", + "274401175ec6b1c39bf5af22ed1697903316a1fc5bb4504d0cc6cc4e157327fb", + "e116517a0fdeaade7f5926d10845bf5e743629231b468832f8fe9d0d35dbde60", + "f07e68fdd68bf64df00ec003327ae07b0e8330d3b45208000d1161e8421fce1e" + ], + "5cc2b8143bdd5808": [ + "64590a6bef320dec9288dc07b6e7363a8e2faa3864e9c7dbd2c0b2697d29c97e", + "f65edb9eb7d8afc0db0143e3800051563388847a989c15332436ff726056abb1", + "25ef7f98ed690e6fe8364fd671e0a441df595c29713a73127d82f6460130c0a1", + "369bc9126919f382dcaa05a42ddb8b3f8a71270bc61efe6f5b796f84423af969", + "0d6b9ad976452ccdfa7a466e2bac8103b2e98905d1921dec173ee6df300a1020", + "a554875cbe3cd8b07e5f7d261a8ec448e40c30f7b397dd6e0cb0fd1404747522", + "029ad6c95b039266af6769c8107ba2df2ec6cd665c2828a0ce0b0932a3e0c9f3", + "5d9977d459f06ed173c9ea1574b4f5533fd3ca60610c2cd2b9d4dfb3b42f1a83", + "496167c2cb56e206d557688a844027b055964012abf66e1bb84c4334fdf43805", + "0e72deb6094a2c8a905c8c3840249cb8449a2ae36782a9ca8df300bcd7803538", + "84f001025f3aff47da6c7298f4bd9a016f5080a0881d2dcdd713bfdbeca37d02", + "203f073e7b0a62f7e2855238404a039f367148bbad7eddfc1f601c51aff3391e", + "a33d29a59624003c7845045c55ea623f859842d9397a919b537b60b9e0864e51", + "3a22d528db23e0e57962153ac51ef6ed269a6da23c590ef42bdad191dbb1c7ff", + "4bed2ef6d2c9083d996e4551323907613821671ec09f7c975c9ae8836014f224", + "151a1038bb9ff27be28b799f340a48aa83a301e28e2dc3adc451e43a99770786", + "095cebf39d418b5a1e4a1d9e2c7dd157f6683a70ee826f5a4ea89f17031fe1bc", + "8df6714aaa6f3f7182eecb9b4cd21da06437db46c47e8cb628dec3050c79315c", + "cb2a613d75a0ec323cae00040578f873a506bacb8444306036790d6d15bb15dc", + "6cbf1ba71a4c787e7d88f2e1928513c1f63473d5ec354a16710f3dd405a35d75", + "d600cbd37a5f3bd44c044d191f237f438b55d4040dbf9e68111e094d9fb6f501", + "880e10f2fefadff007deda057a5987c122fb94bc4892d1808fe45ef15862035e", + "55907cd9008b6f65e8bdba5f50c2c5851f7d169ea17b00b2379814eab7d68648", + "ecee04aaaef7936d53bd81d6c35e5710ae86bd414220c040c0c14a88d7552321", + "b816f82ab2f52df487046912ab32325250678a10614b9c00ffdd84acc819769b", + "a55150bc8b369db6ff65ac6e1cc4111cd9e2c5a2d45ccfbc152538741c46377f" + ], + "5cdc4b7c63d2fcab": [ + "62e525754aaeb3c756b77d2d34ec7143f392219eb8e83741b342f72e957c023e", + "a55a8c692ac9c589159dfcbc764ee63b4cf5ba901d9068499020bd5947d38975", + "ef94d04346c5f81ed2e7c4a12f93c94ca59a81686fe51a4ace0f98cad6f6ec52", + "e2d00501379b4be4261154bc367916a1469ca1f690a4ce7cf0a8bae0158c1db2", + "5e85cfe253a469a773cf48a48ae28b7149d071323f3adca3e38b568efa9a6a17", + "3adae87b2eada464888d36587ef5cd7c1f3e8272b69607bcb892cb6e7bd2d333", + "084a7a983b55210745faec964dc9e91c00536ba96d6056ca6f8c8530940cf62c", + "6337373a6fccf7fb0a5045cad4914b238cee472d617f2c13e7befcfdf387410e", + "f3e1e75519a3a4c97438c1bf4fff3f0733096ac9e6596c5a5de76dfeececb20e" + ], + "5d08969aaed0af20": [ + "a134ef611398759c9e4b37e72a086cf5aea418a48ae3c612258859a89a5f4e22", + "79b4b3a5f1c5704181a62fcef74b920cc3d3d0e83b12f5c4810b4f0a24da31d0", + "cbd5ae604739b561e5e4b9b2351bd8b2471d3e71a82accaebbc0d006175a3021", + "e13c1e8effb186f967ff89aac930942de02bd4c0d7bb04c242ad4440bce68e33", + "c7f784440e4ccab4aead69697d30735b043e9074703bf9cfa1e291dba791d5ad" + ], + "5f86f4e60022308a": [ + "4f092af059feb184ec80b13341df2575a1fcd87dbb8a1d3ef4a035cf0c5d1052", + "17d5ffb33c99e86a744b24bdb8b3e4d9886bc14f0c65c8705137d3ba17b39148", + "5fb936b86bb82e82e8a64d58129c13548e74a743f627298c34afbbd00121dcbe", + "d6dc861943a8172043ee1cd7d993b2c13d9b467e8ed8bdb6ecd6b170d006aff8", + "038a812e37ad7eb185dace791a55a63e65351cca58544278aaff828f698f92a4", + "b11ad11add7811688f52d7a04648db46db8443ca031778bb4d16dfc2da37cb93", + "1a370a5ff4bc34a40250cacaef02f42662b3d06d91e195ee9d73dbb49e1465b9", + "bb7b2cd433a1b6cce2d17f16f270d203bb10e65fe369ebc02b61ccc308e0751c", + "6c3a358f735703069bc10127ba9bca560d7fc60e32a322e4ce47641c1678bbb6", + "3d2839152e553d19fed624ab472cbc13b893ea81b67590a300963bb8f1855a61", + "d82660abafa7ff1cdaed8f268c528384952d4aac038761cb16113e16c88f5966", + "6f13bb277c2925d2e2ba2484c347f3d88198ca0186683a97cad2d93f897ca798" + ], + "62d413d922c4051e": [ + "3d47182fb42fec5ccb4822316e078c8fbf0f97c95f28f85cdcfa21bbdcdff421", + "bb2e4a1c1a426320214b33e0f25173ecff38301c07d5818653580ff335e814ef", + "495fc9ac950a000930eef328320b55da2f98703fe986191878629c0360f7a5f5", + "1892635ede2127d2ad73c52ec381db91326c8eb6a705e4e2ecb9bea1e46fa3b7" + ], + "63382a678c25705e": [ + "7849373f60464be7bd41ef4dce0e55e591bc93c57a7199b176798e8c266d370e", + "1ccdac84e18cdeec050a33c4a0c6aa5635c984b91aaddb024b1476556f7066ed", + "94003d1672ef108ef56a6bbcde5a3088031586d564ebf09c09cb173dc42aa477" + ], + "6426fafb3b3b1641": [ + "d5d40973ee3b378f7bd515d1c91cfa714c8df55c723aa0ea51f4564745d17c65", + "8b3c4eed829f80de1fba9b8d827a74bd2beaf4789fb887f30d15bb20a8485df8", + "ae8e8071a95dceadc7e1be15ab03f58060da3a3c06be6039625359ba0b71e81e", + "ad719bf212b78b3ff43867b66fb49d929c4e733ac50e74df7f502f0c21b58ecc", + "39f82ed980af4cfd5a10af50a3873e3bb6839a6c555274441e3600161308f851", + "f49050aec8ffdf0b8c147dd941f15272ad4bad998b22dfb211470327a59500fe", + "e001670f3acd953a62db57ddf329a2c0ca6efced9f9927730c2a182f5fe1f349", + "c77d1338e858948d8d5abe515c39c38a21dfead0f0604f60609936cce0d396f2", + "00b72e470367f5e2b2aaeede02f32577c3759b9dcb4bb5dad75a968747c57082" + ], + "64b8ffa12f55c1f0": [ + "c76a30bca5b388b88e90e08859b201b0e29ba51a84dc221ba0c27fafeb241500", + "7154a58796173f9f2fbdcb005efd8b0cd5084a932ac696f26e32a7b8d9583a18", + "0dff79d3eb5b6567fe49564683272ee8c1bfd0ca00e31284cb2962843b14390a", + "fbb784ee8f47f9ed1661b38fbc1b89704fab945ad122fe1122f14e06038244b4", + "2a704dc2398cbfce715b87c69180ed50737773da0a2e96bcaa8a036aa76bf743", + "44e8e428ca9b39adac752d2a0cd30fe7be80513d0d8c1ef6de074ba1ee2134d6", + "344f7a2d9bac7d0bc4571c0ab7bd2062b4332be33e5e5586696fab1023dacdd3", + "3f06d759abf58da6af1d9af3804b8a55800021d3a6f9277d17fe0673bae0af3f", + "58304dddfdd939a7df95f3dfb692525a9d5a697edcf7a88446f0279ceee9f3b6", + "822d0d1c0f7f0f76328e6a0ef5f0b9e5f5a3eb15bb93ec84786b6e164e9ba26c", + "46b282044505ccdd800dfabd08486522b5f3366686ab2474a6f674d0b20bd041", + "0a330134a86aec4800f6e7a5a119990a03562a96b3581ca02989f9c3f792eb9a", + "e834d077e2b86dc386a260d508fd63a931b708c89b7b1e09ace3d4dd93daa812", + "4329a0d42441b2ec7abd9bd7f87a6df72d2ea04bf848a66dc722a7094c6cd1ca", + "a4b88f3029c52ed2eecd351107b5f2046c9d45c091b245f408ad38be7a635cb0", + "770eaf0b6c4c32c4988e1608552c67f80d5bff63d72e4c41a5c149a91616fe01", + "c31475dfc650264729106d935caf0776fee5d9de1bc8efa1a341c9c328270d4c", + "42da5e3f9d64367bec9b73950cd25c44deadd341bc696d2c9f3283e7ff92add9" + ], + "671ceaebe86a269a": [ + "35d643e44d66cd39e8e2d6741fde9db1173610759ff59cd9783e7ac9265f33dd", + "aa8c8479c8dd3d706257afb34b6a37b7bfcc1a24567ede4c31027488caed7fd5", + "451ff539a8530699d05f4a83f43e24be2df2e96a53fa77fc30c380544353261e", + "6ea200c2e60437426f993844b4b2ae201764b1db6b1a82c7907b9888a24f4826", + "69676705550edc78ecd49056858465328790bcffdc29e66cc978dd87dead0bc8", + "4143599bb445f2cd8fc1d752da6ec46592db101027b841fbc096e38fbb56b643", + "0a902269031c34b45e7c1bf5c5e14a7d1dc4067b52d979b875780fabed1eb6df" + ], + "6a314765e623ea65": [ + "fc3e1f99a114223c46e2198f4cf931ea33a43dad579e46b86fb633ee78c8448a", + "b2f4cb0462092b3b0a608bf6339ec381c74252f38ef80fb13cca3ef037a64435", + "b1e98426bda3292b2cd4bc7550581120f2201b4be43b1799236720518fdc1334", + "3361e049af6c9cb2d253fca13498f0515f6e894f6ad4846c2f0dc851ecd7d49d", + "712f6dee275b5d9eb2465aa2532c8dca946c30136cd3657bfacb3dcce7cd0fed", + "1aaa9031403dc85ba306c27b31c8a9b935cf2ef1034ee30c905845175812609e", + "4bb90f8956bc3e35b888003ef081465d4e7967447ec76f5ba924264162fc8a9f", + "54f470ddc259e7b0030b3f449f07df67da4682afaba65873c252b779d6330c1d", + "5541a5fad60f023c333ef2e975db8e563770b6ac18cc35a26a6f92e7d9e5db7d", + "2334a6435d7186f9b7e6e37d3d3be4498a61111d1a7358faecea0b5afbe39fb7", + "78acab867f1b63e1bce7a10a7047d837f3b61906f21bc8253ba6dfb9b6ecc61c", + "21bfc4640a9bab8fcc220ce3284161f94fed398a66e02f475213941d13f7e6af", + "4c2fc5f09c6f4ac6b7f2d4265c451648dc699f804bc6d40553c61c6f38ef9e23", + "e94ef0bce9300421b586c4e8a6cbe519af0dd13024f43932f2c131189ee3d069", + "c8bf86ccce2286e9a48440cac00889d62f82af658b196593a9834cfe90119b43", + "36008106660e6c4962361452ddf4610bf2672a6f2bac0eb805bf42660cdbfb23", + "53a2e72131e606fa98c31545840844a3d0f48fd72873bb03a761e5ca6b41e8f6", + "78244d801d230afb9f02f5198c2b1b160517c4a53e29c492b1105f56bb4c0f8d", + "f02383f4c0cf5391d12d051728347e72ef8a5b809dca5797085079a1afb8e79c", + "932295c61dbea9d7b31f3a30d4b57bcf4516b5295465c4cfc6457dc0a5fbe84b", + "71dfcb8708b75352a8b273fd9f0569b47595ba7d3b5119f564d84fc1c06ed314", + "d7c655d40ff4511a02f992d35bf98a61dddb8aaed7ec60fc135435ac78e82773" + ], + "6c9c65aa8ba701b7": [ + "f3877ba01f0df709385d440dfd1fa67d230a6ecaa61ed5c6931aa27d1751a7bf", + "bade4cc05c04c398e625424e9ce927128650b2b4f6550029ce224df4c2d9243e", + "80559467fec5f17134dd295f0401b4c344088031528ed79cff193ffa665854cf", + "d0a615ee556e6af8ccbd7cc4ed4dbcf6aca2c44d8bcd984dcba4d574d542628a", + "c3fa4b652fd039a168b6a93d158744f7ec6e81f9993c262e9d4e4be48f6a1cf6", + "bfe8840ae12c3e9385b8040ba61e942b23b5d14853e446a34e30a0d87082c3a5", + "ea38f4409aac8f9e2169b5ff420c739bdeb47992de2c57f38b3f950908e2df2c", + "cc1765537e5c16d398da19d7dd11e93e710ab5bca5575a816a74e47929aa7352", + "f22ced6b0f6eb02a46220d530ae4394add28578acbec07990df50268d93adac9", + "25c2111229ac0657de7f7d45df6b5313343fa5c6faeb9e1db4cd7df5e827e7a0", + "1e156e035c6a89901c8fdbac925ba103c582e07158cb705eb8a133587c5a062d", + "10526d22f827802e6c32e70d2837ec5d89bdb7b0ed1f9245ec7823f2bed6bb13", + "de2da578ce0c0e48856343a257ecb7ab04177a5bbdbb47ff8b7c1e28a4697001", + "fe064e83412c055d02de40006520db07666b68cceab8f00c71b1ec349ada57af", + "71c8a1c4b88b22cff59884d43969ed622c12a2a14e51cfe00ed311061690bff4", + "984ddc282a80530bf536776c7372ea599069d0984685ac608f953728321693bb", + "9a3c2cad87f5e19f7f996315d25b451892cc4e13a14471c6864c83190bb57a48" + ], + "72dcd716d91b3056": [ + "f8da3610542939b5f0ab5922858202579da79e2b4788954b6cb345d687a169c4", + "b75ac3e0d105bdc23dd8dfccb705a4a90fdea906b29b96ee9daa17bbd7fa6712", + "a8025943d567a6cc7b32776f1214ce5f92c6dcbdd90d5ba93dc1cd0264d9a8ba", + "fe5a07473fb0b7a1ea1a3e56690bc32d9462a9ebf1392c61154b3c42cd63c1a1", + "b0894bfcbda16b134dddeee62f161f0488510c250d8d9a1ab9edec4ece6a3bee", + "73817610da30bf5699cd9b3831b261ea2994fa4e306ffce038b23b49d4c5dc09", + "9e242645b46aa9c98cc04494688e47e4dd0d092ef7ebc71bb3099f43d730b1b0", + "7549c6c9f6320be4ed7158fe24744199f709051d5b8d8a9f4f6f9e7baeba6f19", + "dd99d810692f7d446e6d6220c7237af70c527bf0d6d873602e1970972d056822", + "dbae65fdc988f77862102e8ea89f4d3ebd9da80bf0a347190ae023d894ed920e", + "747cfb422336bbaac00f45634af567bcf7bf44ae6d06bee6230486c6b921915a", + "71ec4ff83119ffa821e4b288c65a464ee3206334b1bf18745223783acfefb85f", + "3e37fef8a76d50730677a131532661a9f0ac099d9381256f70df5d72db55364b", + "ba66d265f5ed7a452e8c41913ed0bb56f02181af31f1d3771d1722d34a53253a", + "e674d7ea9ca8054867acbbc7f13ddf338c6df95b0fc22067203575c481345221", + "c4a184149d31ed9d7aa0fe5d54e1e7a0d54427363fcfff78a061040fdcd8c0e0", + "e78ca9ae7135a5012c01d32fb61702d0daa9eb7c4a9572fc2cdcf6f3413f5860", + "9360376d1e6033fcd6db94b240d84a8db3faad8299e5f2acc0fd1cc196170eb8", + "056ed2b749aac6a0c4a30231194f894be408b550f4c4a6d91fa94cfda47ac09f", + "56bb572bdcdc01a6fe5389637d4ce733a431f8f936500c7bb60f31ccc12bf527", + "ba9a916f0a46a2850d29175795792fc4ba3a6302d93e3c30d89001104a5ac9c4", + "c35ebd6656b54d96d2a8111e2a6910d44468a707326ded0849033c4b2b73ba0e", + "7df679489d7c120c18cb6601428da0588ba68f9da9c4f7229438f7b101fd1768", + "d67d1b30e9789b04962c73a896eb2dee25e153777485326d983fa658b3a3e2ea" + ], + "7728cf9a5cb6bb89": [ + "7292e144b494e6ec96f692577884d8124828bca588a09cc1efc82ccff19103e1", + "67cd2fa1ae83d248068e2ea6494f4971696f4c012e7ec3772793e5fef7f7962c", + "777f1ae2d828bfa820b1f2c1b4e6b7d36b329e586bdf351f8fc16b1bf4bf433e", + "995bc83c5da32d9e08b534b2e9cad5579bab1d69f9dc19845fca3c4bcd8b23e5", + "53617b2fddef0466cc47c5c5c70d3da4e452f56077f4faaa42ef1a86ec179604", + "8a2db56f30c4159397ea6c115f66dbacd6b1d23d708d968603a3a861dd1e56d8", + "602b347ec46beef507437199b4d4abb12ca0dfd357a41d10d0fc4a812eb06089", + "181cfc38959dd90544d71cdc9fe9f6b9f8197e183088fd8f4c58067d46c7ff16", + "87e82c3c477d517791a9e2078531e5abe37d9bafd6bcb4048a98f47239dfafc1", + "3f6513e8b04d00ec77e89f53277639ea7b0a41be0999f3540f6729ff55ba11f3", + "d877c876955dd3c262bc80e6744855d9b9aea85802cb54413bf1905523e54686", + "d988e76a6f18c144a7390824e448634af24caac550dc861abd144c40c65e873d" + ], + "7a424d79955da9a6": [ + "3232704f640e727cebb629554e0bdbd9bdf57d352f4e65ea726ed683d90ffc73", + "699627a7dcd66596eaef6838fa414edaf66db85999f62943cc71e00b580dfa1d", + "6712f6829cd85e4dfd5d198e3740c4612094d2a6078428d55ad71b9cea0481d5", + "d303beab77ba7cc79dedbeb5a7f00b44feec0f36430a11752433d18cee964196", + "fb244314e5c1322c956a06eb15ae0daf9e86ed25a89d13e596518d9dd2a3d7d4", + "f9c7c67827437a4bdec9406c541684b8883f8ff28978a626e948201af87b23d1" + ], + "7bf83adbc320d05e": [ + "84e04d699e653af663d607484f02cc53a7f4c0e56c15c91c78f6e0a42ada29a1", + "551918a9bf06fa7a04ac0bf4f3bd7e07703d34fb247d55aa25bd8b6cbea9d3aa", + "891b6cec0676398e2af4612c7d192cc179f3872d8d81de66e50df75b5077282f", + "4b46581fd019633deb4ee403383ec088ca672dc8486551ad7bf5092e259aa059", + "bb7f61f2d20803332fe2b3dfd13d1f95d23af9c4590e93a2ec99cffa96613fbd", + "f2178cbf08b9a81243c7d7c1ac424789a37a5b2ca0ff70e63dd5dc8b2ca87848", + "c0f218cdf5db0ef63cb33b910f8f55994196259e328a3b6b641ce250f99c6b57", + "a86acdeca348aa4a5c2dfebf6b2dd858ddab25643504f37f565d6b1694bda2a2", + "bf62c844b490f0c75f2a8e3b9277f1306041fab55ac7e44922424c6e843c1fcc", + "2460259518946229b26e7248a03f80d3bec1506998a671627a8662a8a1bbaec1" + ], + "7c35a9e28fad2cb4": [ + "139e57171de15beca26928dc5e13f827245f1a9a6ec34a771af1da231b90b422", + "bed53fc3acbe864c300efbf7f0d1460831a341f52d95731d9335febbb4fd8106", + "23f549981654f3bcf54f8ed77bab04e164dd9de213b2c207d835c4f9f6a31ee5", + "ab92dd438f6e1e644196b6c229c92db017d85fac0193fe92b32d500368fa061e", + "5fac9be302e8e7681297101b45b5461b94a1840e11f294962e918a4b308713a5", + "f012cf2bb93d7cb4b7ec1ef569029f4b0a377c1c36408cc019ad2821a4d11723", + "5e258a453e01e7ab270c2a63dd9c0b1d93c224737700676cc93d880adc0c748f" + ], + "7c4ac020d539fc2c": [ + "225f566168df53eaa9d1c0032ce8df34f25fe55751ec9f4dda4046a362c32d04", + "6489c4294cbb64ab4934917d53411410338e8a0c54cfad60e27102dd3456a97b", + "33ee4e5d2fd2fe045878dba8be811a0818557fdc577f08fa2c31f805755f1ce6" + ], + "7c717548147468f5": [ + "7c717548147468f55c7988a631c924c6f855d501a222ae202c43cf9f55b9ba12" + ], + "7d83087e7be513c4": [ + "5436f2cb5f1cd5c3a78bdc0869dc4b3cb93c8d3eb6c5b3cede3ffefcc89c661c", + "cc8a5f2309ddaec4ecaaa7890e0e4d246173ddb039cc340d7a25589277cf9ff0", + "d97c1473a251b1a9c746b4117eaa8683798a2b65fae7dc1b542dc097f4631d72", + "44e5b30acf9155c1f8e0c0fa4f31ccd0326299c104cb4b85f39d860a1c0eb2f6", + "2e7c3c80daa6f2119da14e801ce9b9ebe19ff747b2e3554e82051d0c9b28a1e6", + "79414d4073b2e0aee2d8e81f81acee71db47059268f6a9aca702cf1f28986118", + "eb64fbd007693162d2f5ff40809d9b7f47b2a3f85d7eb08bd0e226bae3974d82", + "b0eb1c1c1334af447188c7026e0714ae701f5fbd9035d8f70a3549c4febc6de5", + "101c20730583da0bca81cc6f826a71614e52d94a306217b55e1322c3acff3770", + "b6b47f5d78a7a380234546ac05b405b20d5f36da29af5c7c786485eb96fc84f6", + "6c0db615468c5ecbbe62113cf5606adcd284678d28498e8a43a4db229cd760b5", + "1f6c35a51c2ca9a8dc50cc4640da53877b71a6427ec3034ac77059ed1ecb4fd5", + "29289b7e0ffeafa3d63d650b38758829716342c68afb99171bbe9755a489eb8a", + "5dbdee75d0061c7be104209f006edec3dc823cf54c682b15a88caa35c13f6b56", + "a9e1aa3168c20b7632af2e01218ccd92511a2b64df9f83ff9a456518346b1fe5", + "4ca858fb33f3d08565d8ebaf53cb6d037d380cda6d9534f4f962e0e35eba4f7a", + "47ddc0a1abcdb01c3a337b5ae565029e1fe128c5afe6e6945f22198d3d55a13b", + "4a957847a655a571dc33d622e9d4eaa6b704879d822b7839d8722b5dea9aba40", + "4ee80090b0ee73911637651c10616a1b87dfeafadede388ada7314bfec2a1348", + "f50d07b0e5168d46a19ae5e403ccaef247f56971d3accdd994127c98309e9cbd", + "b1445235fdc99ea34ba423475eb88709a669ff156d3ed180cf0adae6bab4df47" + ], + "7dba094d40eea46d": [ + "40d0b90a1693ec31f2f7cfde6361965487140e7434402817ea10c6d7fb97ae76", + "6fb364fd10ff4b620a3bbf52385c46dbe9be9c4b94e41ed35372f3701e562a57", + "25c1b3cfa195db258257b28016e0c4513b614d3a022672bb986f74a9f34dd3da", + "2fc0e9e5bbadd32e9ea4367b2fca66a915ad58b9da02e95b1ab7e86a559503a9", + "5131973d084dcc4124233db0eb30a88412783086a7447d11ad1e9611cda1261e", + "a5808bd4e80dec1950025006732d22c46c635f6a44ebaf30488f394156846293", + "371c7585b0b7f50f67a378c84ff8f06517b3f49f9dcdeb807f5adb4b831f1e30" + ], + "841ab8c8862e4663": [ + "643c0df0fb60426a51ddb0a829d6c7de5749f528afd90629e2de870077eed0d4", + "6182a6d1da823ba2d7a32c41c2e699bb21d50c4c71e1f142dea2fc3dae516fb2", + "a77c530361206aadc4d1c4d023ad7bda4f9e07189e6975ce96ea4618c1a4df1f", + "962f1c289f44776d713aef25d39b0ef6e9b8e197f1cad1b0a3118a5c2baa7197", + "e3df34e9da3602a192ee1a221a5ea1e716888538e83ac7b278181181551c663d", + "060da8dbcff999c2b5d4e77f2a9e1137ec9eaaf9f11796b654649370defbadaa", + "ff8141d4d14d106f6da0c24fa5d6257caee8c4a32be22dda57d7db4bccfe7277", + "abc2c6c891607e0632c2adef26d6f2b9901d8c1234cab1aad0f47a821b9959d2", + "1a35e396faa57dd865b3ba9f199caae9ef76f6fde22950c622d7018521961594", + "a4637728d066f0afc7908f1a3b8c5d43e73342356653062a063c37b7d9590bf6", + "86326fea7877ea8d0d1a99294a883d4caa3050558efb2b631466b1a1446ad426", + "b3df850e0927c304e343d932d378bb16a46194371c4960131e70a2df47ce99e3", + "15d56404eac1b4ed0aae382b01d3f282e5be8173f4ebdc6709805f1841ff176d", + "7af3e8996b44e1eadc882aa3e4960c3855f63b6e43c50a061cc3b8df396d09ce", + "e709d6def522a6fdd12f2ca83bb1e3a81b1f42bc1bf94fb147dcca10c7ccf6ac", + "55ccd81e9a7e13b6a1b67fd5d38bb69d5401f12f037ba00e5bb87766a1496056", + "8fd6ac7c5f961af503d0d6096f0efbec1c8106e62649ae9be050cf888e1ae23f", + "751cadb8d6cf5b7aaa52693923381dab0a8158aaacad96f3dffef119dd793186", + "3504917df5c0567964f21f73d1cef13166ecc32cfde7d9147ac5010b46c8815f", + "0e1872c9cfbc30aa8f5fa1fc6f72071d79160e54bb1884c92b91654a7d878d0c", + "2be0d20f755b4ac91a53267b002dcc3814f83f88614e7b296d8e87efcf419f03", + "ddad163c0d8e98864985c1f3b2965ca2c411a5784bc99e70bb34af36298cef93", + "25bf69573f438f913e70db28ec416861c7e28b025b8eea07cb27c3b9079497d7", + "f2a42a102f514b5ff4446545e0d5123787f04cc2814b8f0382fd9798f728ac8f", + "71d54c0f7faf4f62c6456fb9a40d454f03d7d6c6e204e28b501b454e38e2c2b8", + "6c31cdaaec6c2758a95083ffcb2c9a3f5abcca15f4a091e57b2061871b72fe3f", + "8e1cb4a34089d25467934e72974b0576931f1ef62271791129f41c12b11f5101", + "6495140fc6505855de706607378ea98bce1ff3bdb88ea429e6fa14b6f76fe8fc" + ], + "8a2b9d75522a798e": [ + "1f3f8d6e4bb7cf2a6087cff454b4795dc87b29c7f3c17292105064623c91606b", + "2625076933950b859cb4c7dfe9ac2c39cfc9b94f3cc84ecf2a237e4d81e566b1", + "e64a9a42492ad979ab0c4d213c25731edebf0c50e18e4e0b9bf86dfca3243495", + "13eabf6d97eba285e6ff8dc3501a68c4c196d3d19b20ba99bfbe73874be43754" + ], + "8bb189e21576dc44": [ + "d76cde0540e75ccf02b3efacc5423c651ec850b78b75e6f8d9ea43686f011818", + "12d115ae590e6f7c6eea612bfc8a58cd79aca7327916cb084fceb2a6111cc8b7", + "8dbbec78a695516fbf63522b8169d5e2565997a1008ec56dd7f71de6792d7d8f" + ], + "8c8b5aed583e6ffb": [ + "a115bfc7c14831b467ab23525e565f4e4d8af34cf898c1ca9f3ca395d41e748a", + "55eecdc5236f126ca2d53a0d6ec5ea14a405474554300e2bc7cdbe03e3a74ca4", + "38c49930d7d1a7a6a68fefb463260f0c4ba55cd0efdb9363af7edae61da586fa", + "4454893230fcd56234bdc4b45d3e3cba0ca93707d9a83a6ec6084739eefc5860", + "5161c7f78db543ae3c0d2831834dc338f04ba6f63e9c09e4fd613c650c8ad40f", + "4e06a45645546d2cb446071dee3c5112e02c096c3424a0e424e70bb5aa2e59b2", + "7d2fa0d1a71e8786029af14fe1fbe7a88abe3befba9d685f8e264f9c0c672c63", + "c1007a49eeb2737c018d29c86efb15e041f96d360fea75b73c9d4ed4ed704752", + "391f90c2bcd41eda807381c275e6751674fd7db0256e52510194ca5f038c4cb7", + "1197ee3cafb96a6620c7cee4ceafe42357e8d00f475f39b307918ffafdcb710d", + "7f4232dc6665bc87ec89b62e3f09c721f65c491ed463b3dca44229205752535d", + "0bdd500bba867074f3172b2fc6009be1b623b1da21541ef861b1effec1207e0a", + "d53718bf29d8f30aaeacfda0e42406fb86a31239a32b704e516e3e8b1cac062b", + "07d4bced5309b2800c00f4fb42e94f87315e8d9f9d935099635049b0a4a31fce", + "ee15e878ac28a2c3f552fe2aea1056bc1349e16b97714a044028f354a155dff2", + "f3b85ea65d2ed277344afe0e0120ed19e51d3751867ca92df31e79aefa02a48c", + "595f4ec361537fdf4ea8e6b76026da3011802bf8b598d4d7f03f0dcaeb444d33", + "ca01009049094acb6a45fb5336a0f795a67150eef056f726699a32d3597c34f4", + "e0e540a7253868ccc5d98e040652ed4c61e1e997bcee284724df7fd1bc529c18", + "04371088cca238f7d37807d724a44f45d235dc7815319b4871b95600752d6fff", + "6d21553296222f2055c2c2c490d79297e7f0922266ab287f9789c20b2693bdef" + ], + "8df09eacc218fa15": [ + "7fb97ecd34e94682138dcc69842d034c91e3c999df196c02ffbc6dea47ef0e34", + "c2807c4cf0c5d5b7493a67c5fb2dafd5be848d19b18d56cb411940097328fa25", + "3504626cfc4df1e670881494d0bf2c000e6f55a776eea24c5fea89bf8b8fb7d6", + "33094621001bc92218dafc6e06ed43263b167c9bc0c544e632bd3105f14e0663", + "6b489a1992804b3c0fe740bf3cbf93e273836c37041ad04d4292a9b00f6fd2db", + "5fdf23989ba14a48b17fca4c4d9b32f72c1065cb9d823252e708d9f0426ce1fe", + "3c91825c38b0c1ec9fb5cf4dcb6a9759cb2c1228ebc0a9cb3f16d1c71dfc6142", + "fa9547771ad2d5e7859534ac9142736636685711e5d1cbd7b75e3d7c92aee56a", + "ebd46de8f3fee617cb64eeecb50967d6c1ad8efb798623ab453b2b0503ceee33", + "29308bf755e7b05878ee06a8884cc6e2d61a086c25a73bbee8a7c731a6695672", + "d4987e2e954ac9d1d65f144a2c85503d756d18f404ab7d7478a4f3a6f86babe2", + "65e6f74cf47637b89e3aa37c9e9b3da3ea8510b64b8d1c1c89aa1bebd1b8d998", + "3b9985822f3ad1748609226958b7ab44e801adc2251d3175e62a60125ace1fd8", + "e1b1c0f25d23133f200f287bd14f0491a0f07ce5618835ab256da97dfcf83ea6", + "9353e2e1a03e8ce3049a2c218c6c02d36a59a8f013c991e88486ec6cb83f0132", + "6f4578eca9b0269478fe75e421dda08ccca64a093a554e4bbac02fef49360d5b", + "bbe18b56ce3fa9613bebc53e2406e9baf2d3a1edf48b1b7251caed8dc87ba797", + "afe1faaa72bde6166268d39d076fd05d2121413843a22c7df0be4959c4565593", + "1f37e01c90d55d9064f67289fdc5d8f6270bab0c2495a80b276070fa3544ee6b", + "5a64c3da27b319485704f6a950e6236cffbdd4d365f8c88541afbc10dac37ad7", + "6a13d16f965ac197ce79aa87e494d52aeb8fd9808e1d136f5d475489fb90764d", + "71d24504618bf86d8ad650c06e9e4e0b471c32d77306408cb8cdf171c7dde3de", + "8f3996fba44cefba1c98300496e386cc8e23a41aa2060084e7c74dd39ec76c49", + "54d8deae6aea94130f6c11a7217997b002646f9a251dd1f68ce794dfa8af4f7f" + ], + "8e022b537e71204c": [ + "7eef1f846bb2fd8b143e7953e22c6f7e160b4703f164fec149ef7db03a3343d2", + "bf20998bec8717552190a3893f55c658323ca477c4fde9b0938bc2720b796e29", + "a589d6048057118a806b08aa2716ef27225b025d890b168febb79fdf7b9bc417", + "94e8fe20fada725c3a886392654bed50e7a2e511d329f5faa0174be1ef3c6a0e", + "b104cb5663681dcc51a98b9052e8782bd96ee20352949e6e965c7001a2fa7fa8" + ], + "8eeb1c83c0b2ad8f": [ + "1a89cc38308ab5229e0f59c4fac55709df28de89468933659d574f34d3ccdc94", + "b24df9a1aa406f233c5b9e7ffbabfa242c16400a12579f16158b7eb6c8d30fbc", + "56835e16ae012aaeeb5315107480edba33518e13cf1bffbaac11553fa37bef6b", + "1e2ab3a5b943c785b4722c17b5582a3bc7acdd19b1752863a3dffa6bac58f93e", + "44fed1ab842875cf11ade05bd95f6738c2d01562c07a6c8da3fe9bc5df78e919" + ], + "90ba2ac26915e2d5": [ + "0d0baf73194dfda84538fe0819e82f1887bfeda06f973217630c4740b38599e5", + "b1154527d2b5a7bd3bf33523f376efdcdff08831da2ffdb198fc65742bbf7c30", + "740d453b95216bcdacdfa9203a64914f8a15c5061fd3200cb78a2236483f055e", + "f9718d36e2dbcbed1a9923ff14f70153e08b382a616588b22e8962f0927f8092", + "3348bbd9c5fc607583500ec239b52c0373cdacd52a991b1997f9fb53facd6859" + ], + "91644fb551269942": [ + "0fc093bfa72d806ebdee0fadb06ff9fa0517d1647a941a881cf3c64f57050402", + "ef8593aeaf972ad0fdb5c412cef26e85c69054b45e1ef0e9af2267bd42c51326", + "6776a07f7f74a68a8f91420963750bc77437a80437d7777c6a4fbdadb7b634f6", + "fa25aaca3d15ab67934e6be9a9d5c1da115c6524ceea795add8b2450066e9b38", + "d07bac16d817a2a3dac4022cab21cacff6ade08765f20cc82583118106d10085", + "f79f4243b5fac5be52f4dfd6edf190de0e0c8558c347ec0f2b12a09d9f5774fc" + ], + "937bd84108209f4c": [ + "bf22961b31fb4d81425b99ea46b42a69fb089b27f02f81b5cab5311681125ad3", + "3bc74b0a1bf548a574f9427dc0f68c7b769522750978f11def52dcde7344bc62", + "e85d6811269683c2c827a9b1e03d083090cd5fe001a39f302ebbeade6a446f6a", + "b737626d2248fb8b4d2053c0d6483b593ac2c02953f74000b3cb0307bf411752", + "53c10c42f63414220cafdc394d934f743925ce12450cc555110a01145619a8cc", + "172cd1e150545a479fa6b3b081f5804f5ff0fd64ade461ce3937d9360f8c37a1", + "e7c1b04d77fef4ea4f2f21ef7a6e3c6a606f00d7da4cd4951bb3353445a6227b" + ], + "940d00f2cbc41741": [ + "9b58e3d0be3550cdc7c4e9b5057044ac944403f8a5f6244d82bcdd08e3905544", + "d140a2d817a41c0bd84b493b80937c6797a90fc069c718eff91da34221cf4465", + "01474e3a43c897e6c18cb61a24f9b276a3892fefc84bd74b27f59dc27972788d", + "8512a5d9085f73faf9705148f54a8da4d7b5cb2638f544db05a9e7ba278edb26", + "411e8ae8d52356cf080796b8c6eff98b758316b2848e3b659c336365a3e3d142", + "0528062fc798ae7a841cb8c98bf3914a14cc806311ba8856557c7f584ecd2caf", + "28513aeda1af4e482f17fb96b83a392c94751184b5828a88ca327f4fc36aa301", + "4e84ef9d0597df591805c1262c8d8c942d7e53d3df5ec82f6cf13c1964c921dd" + ], + "95e4e3814ab62e04": [ + "89fbb7133df1f943b98fe23dad1544d43dbd9f2280570c837df7c2da7e05b3eb", + "21861deb108a798e2de75a9b77b9435cc555daa5fec47a532715f482897260cb", + "e3c59ba04ed84fbbde5ddff60bb9d842813f56dfafc5561a68b74a798cec34b8" + ], + "97622a515a4cba5a": [ + "31ec8898fe2fa1759289f07fd9ac8b6ad424f7a7a1aaed10f3bd56d8c79641aa", + "c307b85f0f7129c1184b5b70831581de1a6059e2ff69082c4f5c4d32c5f77623", + "d5ca81065930c0b1bfd386e3cac4237a72bfebafce57ef2fce16e9f579ac1230", + "ff6576be788462a532400a5330751d0993d00495a6d543002e4c64863fe5e97e", + "8abed2dcbc194a38401bfd57a4359c09150fe7e31bbf8aab4f68c1334cce0bc2" + ], + "97e8d5878cd67efe": [ + "b4d1f47ae164907743c245dc72b125b4c0ba7a6bd59c64c7eddc2da41685b97a", + "306f3f0e448fe692ea424d4abcfde4fbb305bda3915a32aa495fdc61c98a95e5", + "fd29480c2973baefdfc9108ed4ca2c5b409381111b74154db9c64dee98de3771", + "30b476e0c57829c1eef443db07092749d3faf1d496afa8860049ee235df3d21d", + "b75d0361310b9eb5076537ad736c82ea18c638e044523feb90ddea37f629d159", + "bff9a31f5cf8c3234ec73ba8de7487b02a31ffa4dd062b7208fdbb4fefce1583", + "7cffe58fd041f473179ab3756403b6aa9bec1357b209f57c530aaabb57714160", + "b5f848813681cc77f0b0884da2103f9a1c47238d537d2ed43757d051edfc304e", + "25acebe3f613e60a7b1cbbdf82962cecbeb7e3f0c632edbf4791c5d2c6ff0e91", + "35878e543b1140d444228d14484455a05ad60fa9dff5ea0ceda017acce873cd5", + "d25af7277b711903a503c8a12e71ea9f53425c9e00a06db63c6a97923d7c2cc9", + "87a977c800b32f47b01d771b9ccca5f057f9ea6d253baab7fcae419d8feebd30", + "7943452d9947aba3c0b46256f1004b9a271a0353e34024a1575d1972543f0cb2", + "e477e0f9f7fe3fdcfed5614415b1a7e46d308910f22f12b752cf4ebb0c8fd4d9", + "154f75917529dec1333d2e32585497dfb8e761923d36fdfb594199b1e1ae2584", + "2dc4238af63f927ef7b9cf8d1beac1c8baa14fc429d3edc99f203e64c3304441", + "12808a8f8b8b4a8dadbd320b70466323e6aee4fb712b986b8b3892eed0984630", + "e42bbd3b9a52f0c3b588c4366f1a62af3260758194030fbd4ea20e6ffb657346", + "70b6318dbd5f1907cc1c7f728874137507befccbbd47c2fe1303b1815aa3dd45" + ], + "97fb04bf0f0c2530": [ + "7d695a840d93df1b52da4a39a3ea1d1c3ca5c368cec47508e5fca5629925d5db", + "491c9b1435a3c605c0a356c72adcbb63f57325f1fabfb07d0d62cb21815ef13d", + "90dfb8094b3b335802dd4c593a3573ea974e4dc934173c294feab98671437eb7", + "a70911c36e3b852dfe44eda452e574242ca500a1213bd553383b6bfa937b4ad7", + "7a2b0b04be07e8c7142b93d59e55c2dc81ad4a40865033c0bbd86bc85a7a96b1" + ], + "9eaf774059da7414": [ + "1475c8c03e04e44dc6821e2771feaa902da4d6ef6457a14152ffe61f5e5b6c61", + "43689ad6c68a7b8a89a5b9a13c755b5ff7bb31d6fd01204ff42af26fba739592", + "955b9e4395d659de51c73a668ed0ec3f5ac914e2b0a3a80b64140b78023fac23" + ], + "a010df99b689ca28": [ + "7ee96c892233464fbc29108f0635bab6cd2a15f86bffaee1bc8f82dfa107b498", + "5ddf763b1ba4ab6249a4ff15b8cb21e42f8212217b6c7debe66557f467e5f7c8", + "97987e146a009ad535984ae9a0f0974c907ba3deafbf6586b0951f28e52eee3d", + "c17326b99fdde126ea84a3b572469bcd4eeb236a67ab0d674ec43d2073b9a9b0", + "0cc0dd33a9372a223cfeec8d71751159afe873ae513a7a4f9d2b1429eb1897a5", + "6983dc987dd61481a304514720a32ff98959c9c5b5599120395912c2a16896ef", + "42e4b2944d74daf9796d17e95178de383c4a5cf83981b991beaffc37c55fa986", + "2cfe8320d7b5da9a78eaa7b71306a79102b80367e4f0714b902efd56cc5928c8", + "6cf1f02d7dbf759b7f131969cd848e57559c1624efb84ae41b3cf630f58bdb99", + "b1ac50f50b7f044166413eb2624364c28088cdae08fb74c06db6d76a91d518c1", + "0a065d7363913f3b342008b08436875e069250c5a22e4f2c1547e4c71fe25af9", + "6867b95018637372e8c87d4560ecfa471d6997e405f7139834b6a14ce50027c8", + "287d07e47dfa9b7aff25a10d9da0ca54ce29c0e9760ef3ebf5f139f119c75c51", + "3ec4a7c4121f77e371dfaa68f53afec4412f5f984d00071f563dfe900e15c2ae", + "847e3c82d2be4cfeecd623bc364fd48257716ab6ee9f7fe5494684c1c4f61647", + "d55d3c8eb505dc4c6270d2e755d497baf51c145aba8488800ba9d673f840d35c", + "450b535bbf613b5d9a15aa6d288ec10bebb6ee41e0ca78b06604ccc7cb8e082b", + "c9908b64343e2acaf4c4513933e7d8e408bb01c0fa37343e367e6f356d47b0f2", + "a1fb096b9860dd63a15f29e2d765f1e46df40b4897c8e19699db8f2b484fca70", + "c5a31b8e5d119144d9092979e76a871c348591908425c167a05217b70e726d70", + "c9101d90dfdb6fdbc1e85e9a0a5bdd6f26a70edf4929c4327906bcc0ef9ba4e3", + "dd045443aac60f851434240939c06fcdef5ee6b234fc832eb1e5a7fabaf24a83", + "6a20ec078b1bcfd207a58dc40ac1a40bf7d7d226d4c55ad9eb70de7764a27882", + "bd99dfe7ebb51471940b4c33ca9a0edd2e6cfb43fbadc09a7bf8fed8497c2924", + "cb0647d8d1c1ac2fc135aa573d6cf0c01a31e2c42d75003f994c91e7f1bb1208" + ], + "a3fa5837de772b59": [ + "d002b86380e1999ed3e0b909356967c0d7bb17cc3c899610598b71500de6ab5a", + "723eb5221a10e9820ce7943a2442b14741369e4d4d2c7df506d49d3c59cf4a9f", + "5b453d71a3d8e0a89162af3ef9f5f72d4af1720c0766022dd5108ec05cccf070", + "61800dc92599ebdfa2b708189284e5f88485bf09c85d59cf14e355070a576227", + "9730955f70e1860987861f64d09218f048fde20878953b09f2036430655ff905", + "98ddcebb852026ab549b570617c81d0a60551e8e80f5b4fee242b79507dfee4a", + "4f1dd9f98f4b74402ae88eaf9561ba7552d144e9b9b34f455879038477d6839d", + "c055504d8c6cd3ec6d97754f0bce7c9fbdfb02c5371ee8ce4839332605c39ade", + "1d10e17508ee06ca8283f42689b9820db89cec2ae59edd8a3f5741dcb41c8060", + "dfb65962b248c681a989b7ea874a247f0598408254a4f8099e610194332d3da7", + "45d0de29667e5899c036b1c17453c6b7fc1415ec132798b18e710cb28adaddf6", + "9828b63997a3866a44e9bff18c023a20fbf22959fc94a96b04f96367f630ba86", + "26b9ee7645c93686da481a79044a1914486b3999ed41c577c6519f4d9ac0d66d", + "cee492277a61e4ef70fbb32ac8ff963660c6758b07301a38cd3780d0f82a0f9b", + "5f4a93dac4b3da04b07a12992400b2687b8c042fe648d30c18fbf284022aee6e", + "6036819ff36c821b4f69385b467a22611bbafa800c0f939ecf958e29bfdf9ab9", + "5ac62dd11ba4b9eb0de10c14ab0dd76c2eb1a5a28302bb22ecc84667aca1c850", + "86488a89cb821d825e17a790e1dfaa353751552d39a83de80dbdc46a7df5bd0c", + "cc28e01aaf0ea9f0702777ec9113a22f8bc21a5ccfeb2b1513672c4ba11e77ee", + "eb655fc0c75cb00f710e2e000190d7fb9f3b213df3ca12df3fc0f0d923a5085c", + "2e767568709d1672ed5e6ff7f5abc33c7def436f04a83c3088c974ec96204f66" + ], + "a4ebe90fd24baf2b": [ + "039626809f6f029cdd135c2214e9d1549b4f27ddfd4339901a2ebd898de356ea", + "97e68c4c264e1a9c5b22ee6d6a753a27aa8317026f4cb63f18a2281af220f36c", + "2b891a0f242601ce79ebaf20ec04087c89d572329f982a491ef92bff0fa5eeb3", + "d6617ab4c732cf815ccb0259bc7acef44a91d3b1b72d6791a3635589a94c2768", + "0159f84d06986fd61a5fda90b78b981e76289166025b8d3a6b3999dadc219eaa", + "1e64889c9bca59d99e18804770f664c08a9205d773022ae569849c623d038214", + "3fce2adb6008d205805301f1fdad531e878771079e39a9ae151ef65f71bba745", + "2769d17adec3401688d34bed90d2f13c70070b2f7ebb4efc7eb4d3f0d69b7376", + "135fee18e11a9febbeb9fa6b1a31433b06fe577888578662ea4fb6403af6b0b5", + "dd85c00d0cb8cb48dbf8fa489599f41fff015162438414114fcfdfe51eae2f48", + "ae43ec630f9c2cac1c73a3756c1d8729a9b50ef01509e615de050edf3b1266fc" + ], + "a69710b2a2ac0051": [ + "61354c2d5b52c0410be5bda66a151b0a01d216b13022f30a50f6a426de8467b1", + "31e8ec9ac116ae52ac47a6418bb281c0647546a856cf92be813a295a3aa7f724", + "0cc819a538e19d6e81620b2274ac6bfc0f86ecc360541930f2367c45abd00781", + "775e2a25d3396077f854e2092373413d3d0702eb7133f9675333b37da470a5c1", + "3619d0c732bf13d612d054034ac4229b5ca07ee4d9d4c9ffa2f9ef4003132f4e", + "54e69bd4fcb23a1ae6d3a5fb35a9ea61d2fa9ef072f1ec7f68ca77e8907812ce", + "89bde7fcfe88208cb42cd96248871a3ff31e4712f8aa9ebd56ba9205ac49c0e5", + "ab2938f009c7ba37e91b26f9f675d42d0a2c2fd8b973387e8b7ba477d184dafc", + "a1a063b45dda7208e8698f1d337254792e3c3f5b520de370b0fae64d45667fc4", + "6a36cc53fe886a9cb6bbaf68d70a07b384de044c325c4622499997381e28dda1", + "e5df9495348b6aaab1cb2209b15572ed5a294c78cc4a179824f1bc9762e40549" + ], + "a760972a33410b64": [ + "9171cfcb2972932f37b7f3dd3f3641b941507ab9f34625659c697d9346293af8", + "cd8cb1b9aa6a34b528f0ab656b4d97327358fcf822036c2d0c318aebd9daeefa", + "5e00c412406c99a89879a176ad8263b4046b6ccfb50ba542b519fd9e64260e11", + "262e40b59e6f4f440896ef56e357eedbe6605100e939008ea510ea4de63542a2", + "1a8ffc1c7cea7bbe7d2d4dcf38fbcf72f7410c22992535b4d0b838792c7d0691", + "db6a8c1597f606481b168bc1a2b626df0236ab3bafadfdb37c79c201f38996e2", + "05f5604d62959a5545e007e5a0cfea30b6fc8ed7c71726a1f83943c59540f2af", + "0c0a6cfe162212cd220e3afc68c8eb9a7211bcb9bee4aabaeb4ae800947a046d", + "e2c26d45ecf118adab40802be0dcdf459109ee29676a7789343dfd5ee8ddb06e", + "317bd9577e5927cbc6955803bcb72a087a6dbaa026043bdf248b9172fa9aa9e4", + "7b2424befbf3540d2fd0281a0fd2fb6b52db4c0e71cb5f389dca213579e41550", + "fced4a5b6df789ba5763959570e5d1726ad169c484bab4f9c6c1f17306f38106", + "605087c605a979df21f3d089bf442fbf2620fc53e87d5d59a6a7ded63e99b376", + "1183759040ba7160336c169b14f72721203c52cccdfd1ed59eba09c7e141f95d", + "771bb3ec711811193310daf5c1e21dc8416882b17fed287d7436108c0cf30502", + "8f8cd3d74884d83b6dcec0229238dd407605dcdd4e955dd2758dba6226d6855c", + "a717a337a8fc3cf6be96eeba8b01d19ab20af008c0b074e042bd17dd976deaef", + "747f24434c1448e520e1f5ecc22f9aa6e13eaf9454787d317274e6c8ec1f0e33", + "c8b8669ba7d8aa4b9cc33770116627e4a66381644c7b9534f13cac61a3421080", + "ab04419cb621db9f7fad9c9fe0c8fd03d78961b8a4a2ec0f6139379ff8aabdb5", + "7d1056461d3cd1bbcbea127958e317e8f094e6ac795117ca5ca11ed73eb0dd38", + "6f5a8f417bfc5981d5643ffa9e50a5739655a0cd9f2ae642d9f7b260cf0c1724", + "2879cc429b82bbd77f22725e960065eb768f47b602487a29cc76a2ca5383eba1", + "b0a0df33636acb0ddecc49b55fb69b7d023d00553c8f165a364462a83308e909", + "4d6f2545477f5a28af8453de6febe923cae41f157e9ec8f2e517adb7184d150b", + "d28b153ae864f5b88e366bf6a3baca5ea13ccb97b1a00a5315f0b8b156d426da", + "86c6428cab4dfc59b68f2a067d14dada28212fec85ec06cc6631b95737f5dc17", + "a7d2f5e51dba59feb35f25eb5493809b1058788d92ffe65905588b7c0c4d6bd1", + "884aa3247f18e056d853252bc238723cdf397dfbec2cd07887046743f6b446e6", + "de7e7cb3310266b6eb31f2b9f9be53f58e3715ba87295ecd998ec757f4819a47", + "c0f24cb6beec591226d8dae68e978616c4ab0cad5939b64dfe982ea3aef0b272", + "c6c805e884c03f0625aa8f11cde06c9c660d12ef0da2e83a4a1468e6181068ab", + "51fee3879b5205fd7c9e3245a2f560dfe1d9e06a764e2afc0d4fa3594126d089", + "3a4a932caa4c048f1a882143ac84f12b46e801adb3e922610ba38998095ba8e1", + "3808790662ffbe98e6a0cdc5d699ce992781d0b025c8ac88032d0fe392964150", + "32b91c91360fca5e3b04067497362d883642c50b1954b6a4248da97cb1d63d27", + "a7d3d8b66d9b79706609bd2d886b5f1c435a66a3c95f6734c138194e8ecb5af7", + "d0eb709b16a47593070b6614b02911ea0dc743fddf7d76fc63cc74c730b3be94", + "cfe767c239e6aa06890c4fd33a82062409a6cdf396bda9b6e68ac41c75fd21d6", + "fdda6a541a03f8dc417dcc55ef229697cd4cc3cdd41a8c0ba6d2856ebfab66d7", + "e58a32d7f33fb1e64979e649b1d2882234fa2fd6198fddf1a6756ac64fca86e0", + "500b25a8068b0107d4a50edf4bbc907696c6028328643268e674dfd1ab555f1e", + "0d9daaa44579363f886d4d8407add1f690b09c8f294d6fb6015045ce9a870ad4", + "a4fd30083e3afb4a6f77e3381adff741058e57e4427dae0d25cd993717f5ec84", + "994e1d3d2960bd6d801190059fd202983f6c8dffacf61cfe19f75177a9d44333", + "af41d891d55d477e32c534927dafb6e32fc6ccbb5e463fbc0e74037a0dc9cf2f", + "729cedeeb03c95240584e5f6c9ab0c35b2ae7f824b01485044aaced8da66d30c", + "f04273c691e95bec86f9edcd1138550cbea1070df102e968823afa7242133279", + "7e0bdd3c779c9477e4c7bc27fe75887b0c1e9923174f56513535466e6909f04e", + "5bab3e001433766061e55358ddc4e7d16e4af02b89f062541ba8264d516e955d", + "9468e287d8a503a11ee16489833d310a773f99fb05ee57d8fd991527ef7417fe", + "b12f8f3c30cf2baf1a406f5dcf4d545374b1369253890c351b92e5f6bdaabb88", + "67321f5832850d966b48deb5e6af1dfc5917db844ca7624c99d0f5a8110aa97f", + "7cdf77fe17110c8b98192ea69113d9248ae8a4732d76dfd587825fe148e8b5e2", + "a948e2e79a060218414b9fc7952192156e48afabaefe444cfa64c9a2f3be33f7", + "05982adf250663b6fc88f8f8606269998394dc9d231c0ff6406521b0a1a990e5", + "3cc989e23920c5204c3efce224cdad329831dfb1593c166f0925bea5a6173081", + "bc07a0956c5de2afe5268f121ac8dfbe1f6ed27b2ed714c87bdb2ddd63a649cb", + "c314e9f1d507d4fbbbdda7c079b46da2cfeaa18b4598b95dd8d3015a831a49ac", + "bc64483724e6f855b2e9fee8d5a7ba02f37d47f0d0a593648451f5cf9969feca", + "c1823b3875fa0e35ad391296ec17aa89d6f066dbaf9fc3d52f3a535e5c09859c", + "2875852169cd73093de01494a7309c134c10115da03ef7e06c6714d99d7023b1", + "633449497fb66ac29ee31bbd9fe8dcc9588e3cc4fb02c4d05e30ef824fc32099", + "3858db0619b42314c9dd5f9e4134084f838141b780b467af1839c0b750e18445", + "3f3a4497ff8e40a136fc03d38ec7a46a9215135dc5fc7e447fcb61e5bc33d0ce", + "dfce264146ba58ff5a72133617c35ada318ab3c43dca70c79b7a9015e4829bdc", + "5e64c7c938abd55ce51ee4f471ea270d0ddb0cf7e5a372177a76a1b04fde5e44", + "0c6305dc6fe12991ccb453a26edffbd7d4932dc19e2c5d9cf0b5183fa3cbe302", + "5ea460223ceeb85bb0972f3de3c211c519a43db5fc33e8a13bb94bb481739f68", + "bb21799649093ea111a34ccfc2ae4b2727a71207d9dbe3c8f506d140d82ec255", + "9e1c506cc11e062e3e243265906b5c0d641de830a8423877dd309ccb9399688e", + "6c7f0b6c056e87b4a45c8036a8e962d0cb4ce3bdc77c0085c83213936cb5ec65", + "a03e7b32870da021fdd6571056a3bab3c0ec5358d157db6e7c0439691a7744f8", + "e1086ca4ecdd5e72f2171a713cf1fc67835baccefebd2263072dcdce0ec33fef", + "01e459914eeddc53ed35576e62387a5c053885ed0331059bc4effab2c23fafe6", + "21ea47259ae01492d95eb5a236c45a9444d30d377ccd6d7cf14c7e3b0bd4c0e2", + "cdd92429aef47be13a9f5f8eac5e36c04fa0199ee32192d9fa92484f7f987304", + "4b2d209923f600c0a70fdfb95358d2bdcbf0df7d0ad770419c06f7231787652a", + "6cc5f6c065a0ec421347bca9606297c2a48885ae719a8289f72a883ec7dcb9a9", + "1d0f373fe0bddd28cf06ae0de0f1b03e3abe4a07171bbef33f9ca24f294a718c", + "c14c34a50baf239ac9627b603aeef96d75f3529687b939c55e83b4ddabc27b9c", + "921d2d3975b974de1ca7038b061a9cc3bc4d70d96fb027659433f97d53795bc5", + "5e9477034ca54e062408e50c8c58b72e20669768125bca541460c009daa471b1", + "e0de13406fdf60885c4bd2c51472cf5cd575a28ac0a9f6f8d3fa005d5c338886", + "c375baf248c12888cf6b308d2eda1cde06749dab09e57146a38a7aaecaa2edd7", + "cc1891e306c34d0be8a462a5ebf7701be27ebbbb6e1627896cb95f566f55935f", + "9c1c6096929a6700f4a2d40c765649f082a2fb2998d64f66b2109dfa7316cc4b", + "a53e42a8b306cc2706e343741eeb6dd995e9c1387aa7cbd8347f737fe29eef81", + "2a228b63a4af3a7b0549b5c90a4b9b1851b9d5e2d5cb3d7fae14c268914a1b6c", + "255e38f1d54da18d9320b00cd97ee4d498d688480c476bb6a71c6840cb3978f8", + "5eac98dc4e5fa375e89cda4da84c1b0a94e74daffd209b90f344e77a06586185", + "b93df119c318050a7468761c1bb9667861b54d019c3a7f2cd75627089679e3fb", + "be09f950c3d70e8f6a6093e442640e4b6f9a61c2b82ce38d87256e08744b57a5", + "35804d46bc6eb0a319bcb74b3dbe46da8cd3016db93d483d2d7a67ff61166108", + "19cd21f815516327e0d1f30956b749092b3dcab30946a24f32af15fc6e602609", + "ce5ba43344a114dd2ae8bfbb2bd14cca9b54906f893283c4bd7d3e016b556b60", + "72e694a06c37a6e51e1744b205a76ae0077f58158c2dab80288ac1b14a242b2f" + ], + "a7f8e944f21dfc97": [ + "bde2e1384d1fd09c5793e6c51e50c606c5fe411b50272e5c48adfd80e7a05243", + "475dedaadfae3f1b2f412cd62086f89dc7a273b82828417a466ed2711f3adfed", + "6f1f001bcf4e3ec02410f20a1545fd528e8bcde73bb086d776a7db2302b84a4f", + "2b7abf955768fe61f7fdac129bb47e4f154c830e4ebd3e8e3c90e860eefed7d3", + "b0df8c5514960c703665fbe171423095d1d3814a8e194ba39eb9a6fd52678869", + "36de8acab6c4a6e3222f1902cca5ae60ef738f514c3679ea5de5fa553dfaaa83", + "1ccade0f5de4675d1f81ce5a96f1a83b0dd8cf9754d6eaec02b0ba24a50c5635", + "ee95a335f1c19247eebd31e850f6482d2d43e1a0372dd5f209f1086a74bfde5f", + "f312650e10ac69b511558ef10ed573c4eb8c6cca4a1657b78f49d3b0f793b60f", + "594bf47fd268fd9b9d66fa02df8269e439d93262f74adb57fcbc4c96ac6df227", + "b4bc8111b12f9e560f26f5dc0d6946c86436b3bf1e198a0e45e3dd9522e07bd9", + "1af873240a1d9624e264b01d3438691011c610efe8d6fc9b007de551a791a753", + "d6f9c97e8c361435e7b4e59ae623e953d685a610d1c54c932d40e3c23199b960" + ], + "a9b194a368d8fdd4": [ + "6adde4e704d2d2bf44f4ab047686cdca7d91bbad3931f0069192af52c23c376e", + "2f9d72c8d064e905eb9e2226cb96a89fad600b9653c0cfd7d4ac25f8df971e1c", + "d545757bba3dcc3221e3212e60626a40c6fbe9c9b0939d1cc66e11ff1304c293", + "b1ae3c0476d62ed3a7da9b444f9c227be16d38d4cd36a2962e0e80d517af8510", + "7b2b34b96cad4c2889f62d02c40fd3fc29702a52147caa2431a3a4918a00d195", + "67f34922341892cfabeeb63a75ae25ea752181756858f490c95cf06b53b14a15", + "9e9b4095cd92c8172ece27b4fec5b95e29c148da01a8bdd37a87a704c4e30cd0", + "fa03c2c9ed04335a54df2bdb8045b6242f21e2038e23ca9f9cf14bafa8d540ed", + "cb8129e24e8da602016a5f03266f0b092b589015ce08a4ac533e1c1acbf5e4b4", + "7a22255ef5018890b9faf008d2a67e345d86e6ecd6a139c145a38803bea6fd03" + ], + "a9cf6b2aa2f631ce": [ + "58e4a9ef081aba01b2f105747588bfb0c5e431b227c57593499d23628364d9ec", + "5aa7be67373a10c0fd4186a17a765d8c05092133b51b9d8ea87fe4122ddb63a3", + "7d29d6da9f40f0d80960790ce195a4daa46559f146a7d75d68c46b33fa6d7112", + "ec71ccb356c73f4264be1399243818fb66ed07885bcfd18291d473955122936a", + "26963d12c129b7689a0415a1bee89d70e8128479ae967ff6b7138787a3c03b6d", + "078e1158048660cf977dba68e780e5131ff17fe0dccea293278ea38430c66436", + "b6448db06ad4fbd8ac640dfd1327ca22ee758357ecfc67400224b743479cfbaa", + "9c65f0a3b0dc293214cc0b8a7d047a73bd9a6fc675d860453b83d6125ade75a2", + "c04611e4f4bcfb422bf4f5580ab8de8a83197d38cca13cb6538e2d0aefc517fa", + "6e17d659362269976ed24fe852542e2b9ad6e3f33641f6860b484f85646edcd1", + "c1db98e4d61bf120bb59a4c93da6657183230bd3bb1a328863c132f75828b64e" + ], + "aa436be15dac70dc": [ + "6932ec7c1b9c642957ed34b32854d418ec7f21d6ca70807879ad61b6e0662514", + "d6bca883492d99845e5b2de2e56157b4d6dc6cf897806ab5dfdc20d10b9f9db6", + "c8d20b69c68e92b63f7aaf4c549998ad115d91bc499ebabbdbcae9f7f334b33b", + "24d61469ed9b8f9ec8f4dc8a93360a94f6009c7a92bd4b47fab0ce944dc293dd" + ], + "ab9f1afc95331739": [ + "501581659abe2646ef28afeb924e2b3eca80aaafe5f0a97337c061365b558558", + "861e0b2f9d05baeb65ae24c20c1fc89d6788b900b482a8fc1ba965db3628a8f3", + "17c5032d3e4b416559647b4c9101b4a315ae2aa6db391ba36069e314fcf98c46", + "44db300b50cd4eae334bc7e03a1064a3f1aa78e42006c50c7972b4e9ec53e82a", + "1369c577212b47c9c1302fdaab9985ec0c0056267de0dd8227865ddde4791b85", + "74a090f9fd12f4dd1fd3c4144bfb92b9519b740031126a38f125b7b46eab48bc", + "5b30819a5e26146c2198cbccffbd5a105515d30c2d767a3def79ab1995cab5c8", + "696cd9456cfe2311e415c3fea7e79c3b4ab75de711781a815bfbee3d178d2e1a", + "7ce4447ba45753bd51784d4a551759a9e371c88ac4f17555c007bf8aa1a70745", + "e6aa0ab9e9cd50609b19b1b45ee4fa38090360bb523da63688cbf171e7f05d53" + ], + "abce93d888c40198": [ + "23375d32259c3b2081ca48c5941f667a99ff2c8de24bbf1024cd43782b565d50", + "7cddddf56c4569fa3f3d4c8baa138b352ff4b5907f2f76964043b75ecf675809", + "8c5a5e0cd5d3850ba2ba9fb037cc358fc654b2ce14084e541d37bfb7c77b8a24", + "9e068edfc7277163fffa96047de841e365c139ce88fa0798db5331fede255437", + "36326a7a0b85443c0bd84fcf5ea267b60994bd69362dd6968aa07e45a8e02589", + "55b06398cb167eb06ed0ebfbdbb7f5df7d004b67575cb11987eee53b72b77689", + "fbd312bb24a29794ee68714b671b47b9096b819b70f4b29c8d0b88d4240fee13", + "36ba1e7865cc3a6486e5f110f0b12e80386932a3eba5ff7ff2d2814f07ed9509", + "c778d8b3bfd6d5f5feec684bb0f90fc6fb12352ca6e0b5828d337148f144a3da", + "3af88698dadc2d3e55571d777216e90314a0f2555834ab905514ced4db65f906" + ], + "ac955d8e961425c6": [ + "c94dff4684c030bffbb8d30985a224084a24d090712df7828f7a0726e39ac2c2", + "dba9b9a4cc4f99a3518a0897b304c1f8fa71bd08194e261be556e4ad7456de99", + "4c3d1d1e69e86f78b99ac19650414793c6a21a39355f86d8212b72590605b88c", + "87d80890513e80c30fab598a039b585b94cedcd951a359b5a3952fb560dc6a23", + "ff9476677671f913f8fdee3ba36a5fe6e56427ef67faac33396b11e016a648fe", + "eb7e615ed2a73356b1313e43ffc9a8d8fde2a0e75d28d7d43700cf5059051d3e", + "daab6f87e9bdd2bb28d8132947384f7f7c3a6776fc5c1bd16f144cf765c23c90", + "8cadc3bf8e8f03f95561d4657ee395f16230610e3ee387accfca36918313469b", + "4d71606c5a1e2d2a2f52ae876897b33330357bcf7caba6e66bbb72410ad9d7bd", + "18c4733f1503c5c8a55eed6cabcb6883ab5ff5607a5c5f18f8338d0a33191fe3" + ], + "ad13aa7faa100196": [ + "4b84742a4300b6a96bfa9c56013b63172e7ae159551fb1aa003313a29fdd261d", + "d81a2d50d041a26087b11e61c6ec4ef1e0ae63b464da6e08dd704f603868c554", + "4edbceb6030ced74ae99678555949a4958313814170d27940ff78eb68b186088", + "65e6bbdcaa1bde3eccd3e8b41b52c32909c386597f8ca0fa30b47bc934e9eec9", + "4ca423a4c2b4d0731d436deaa8d4295830a467f7b87b6ce7c2b7824be542ba25", + "0ed2dd9d20ef6ecd22a09fbc1adb68592ef365ee2d123f76b8da6782338f9115" + ], + "b3ae9805497c1ccc": [ + "69b86f811643ed6d305ef81821f4f7e972b2f28be165e378313aafe5dcf43b10", + "5250683cac09c888c901ce1447fa9c11fc887c62942ec77f2a245b240801680d", + "8329ce348c676465fef21f6eee536e8851a5fa0ba1d22cce6debbdb42337f5cd", + "bd7dca8eecb0802671ee65ca194e604431c14f289642686e2efb69b1c9d77846", + "151e9e2739b31dbb11071535abc1f7b7f8eac80510f59bd5e26ab67d02ea8a18", + "a992faaed233f73122440d1eaef64b869d8789e8a12f31d4be8c06b833916581", + "6d127b3ef67b3599946907593961d2ced80508925e0d3e48aa7b98e0b9a7fdf1", + "662446963a8b79d8c0c5d6d01649a0af2ea8278da249bc5969d7dadb28015d5e", + "9a37a6c7eb2361d2217c8e947b0a76b8743a0ac57cee295616ec0eac2dee5354", + "93e35b9fb3dfc20dda0819c5e8926d3034d9e7d1a165418b962a03b457a427d8", + "c2b3d0726ea2c40abf256b5ce49eaab917962e813ff243caac1d339701307996", + "56a88827d6b59f3f23409a6f3dfca7c219622a3cd8304d8a9c6bad932bd21bde", + "b0d14e9de11c4e48e0377f1ad22570722f4c2f17f0825a1c8c94aee5a55fb0c4", + "8cac8503a3bd9ab0b71c7f0cdec7b09da551dd8087ea4de50a8924ef27f4d0e7", + "45b9e145d4887520e7ec98db8d432f82397b82110172660ca1126ce0a10571cf", + "114f7fc01f99c48578854266da94c5a5e45b29d00b45b9a7f9e4a11102a7cd88", + "83999d0e310d69e7d0d88d9334658d2ef5efe9aa6aee75adc0dddfd1dd973185", + "24250cb1d2de001abb2714a0581f9d722dbb88147ccf73e6f0e11be7db3131a2", + "f8f077604ecb7cdf98d8c67a7ebe414fc26384b0d8c71ed64ee07912fc2ea74e", + "e1cb62205835dd256b4508786efa2f8bb785d6ef9cad3d0c71d5f715c94d1d44", + "a4a9a6a087b2a798ecbfd532bf56a98519c8bbd0f49ded21b83fd7088f1be3b2", + "f0133dc8535cc402bfcef8198ccb4304f7c48823f064ccc35ad4d3289abd169b", + "acefcd2b7e2545a920c11ae4016d55d4d1c9bbf14ed820944d01fcc0fc86086c", + "b14f4dc76589c417531c6174c04a9ba16cd3047192947bf9073b937cdd476117", + "4c581dfa6e1dc11e222e013ef9356c8ce176c30c85a77da17b3c222e727a7bc4", + "7fcef560ad5568788fe008ea4247084933c75154cb8ee31a335f65f3b850f414", + "a05185a7b3e47f59860a43307c49424f5636502c66ef8401d453e78e63d52212", + "77c352506e11a517199da67d7642520d2641b711ca468863d03dc994612128b5", + "1ad5d205852dc18cc1a7ca222661245d0ae07a1d158e1ad83904e94c94856314", + "010d1465f2811b24a7b7150df242bfe8afe083515c53af8f9146d734b1d45755", + "eaf0bfec4c00883f992d508721a0461fd17e2beb01bb1fd0e5182264f3db0d43", + "9467a48082934dcc1f48b6eebddc52d4688e60501209a30ce2b63d4d839ca89c", + "adb696c6b5a0e39dd9f7028946ed23f7eeb5b8fca57df73f6d59311be5713070", + "36fa1351baef15652ca011a20de9e1e6529da1c7ef5b72636688f1efe1ebe1b5" + ], + "b559b20801a83936": [ + "fd10cbbd8722c7a432f2289da788067fe29a85d23f6adfd5632c47920b01564c", + "b9993397ebdfdc256be93c157a3012807f0167972bb0d4397dfa4a9ff2c7f64d", + "d0de24aa15f2e447fcddd045226dcc4b63d57d744056831464238590073c2bae", + "926589f8704345ded0c8560928c5ed5548f43977b5aa821a60d0b2d4141896cd", + "7ff0042ca5e8c21a3b2cde060b464a220513e9116a127941c0c1f635625bee5e" + ], + "b7b57b8f1422e65f": [ + "9420d5e757e4cbd07ea9e7d051997be8cecf4d21c45fbd486cdc0da99659cad0", + "93b848c5324b7babcbe884f4e9f7df6fa4d760945c567a5c5e158fe4b93b6ca1" + ], + "b7c26853f8d96da8": [ + "a43a1ec0090eebd636adfd6169edd6369cd07c7256e916e1d1742cf0629311e7", + "90ad27d260ca5e813ca1dff76a410487c1fef17521125cbd68879224178defba", + "e961b754b9a01c1e8b91fe9cc9dc25869c1dcc8f4b2d63ed3ffa5e62db8fdf7c", + "c4092dbe45428a9d117b37c0a16b07e3c24352ed29ef254ca99a4176af2c404e", + "d6bf95627f6debadf91d1e3a077410c4efe2d17c107020f26c460c011a317beb", + "a9ef9f5c7a6440c1fd2ca5d9e91304b54afc0a65a3b994d6b7196c844e5e8017", + "6f3df3635d0d6dbe12118281d250369209e1324dfe3b89d2f1b405a74f14f7ba", + "f500e151b6dedac7f719887e4ee19bcc44abd9f56802184c279576fd4305043c", + "4a8b86975c6c8d94c7e4a8a5e7c3cde52beb5007bee97f3ff37c986c736f2d23", + "be863df26e016a6b42723eba1e201823a1e9dcc60698cc5ffa3a087740bd6132", + "afed9be9290a4f6f2e1bbb125eec34bb2c1444ef6e107c1276da8925ab7d4877", + "ee663a5e3de6818a7a7573b58ce808848b29cd6b62451287099da95ee6e819f6", + "f92608e1613514f7bc3f485835ef82035356e94eca0a1621ed42ffda72ef55ee", + "fafe532925777b6a72681173985e6e7a2f2da87b05d5140254d21fc0e9a43de9", + "f0c484448cc5a89f68be4422c51421f9c00be3ac1cbdafd3cb605ec11d0f0a72", + "96637500220477fcaddcb3f25fab28259128ef5a8725c8e60b92fdb6f9552e91", + "fdc9d67ebbc96defa4ce5e45fbae5b77297c7ddaa27c127234f72f531b8f7a4c", + "a5ad21a9927de366c15af91e7e1d770c3a4319dd9749d0a8c005ab10890c630d", + "12fa34bb330d28e01275165ce60fb4de860244c090362abbfd292675d400a91b", + "597f6141a8bd55eeec3b6c55eacbe1463032013403e926741278872ebbdeb744", + "e72014686d6403aa7e2971392c6194da81b2b044daafa330d9fd87d392919430" + ], + "b803cd5f3246785e": [ + "29841d65b935a1bb11eb7bc59f294319ca04179600d6fac1e7419fdbf52044d4", + "4fc08d8c55d7a16de51da2e52d5786d3b61c3f3f0ec81b617b9f2dc5f9819769", + "2336bfea9606baf44208443ef752be86b3f198a398d300180cd1bad03251149c", + "c309c958f9d55e8771cf6fdd03fc14e8941555f452001e504e0937fed57528d6", + "d32cbf81d1d9c496a398344bf1985a97c46d102a2b2cda0ca141b24eb70be723", + "911d96581ef85de9a644fa1d3007267a1029974210b8ed12f08145beb1ff6dc1", + "a7b16a6ab61aa04f4e94b18d3478e64a7b9fd3dd2c3c662bb5e81b871c949d3b", + "cbfdd59246f7c57650384470bab6c19c54f99288fcdfd9d843af9d274dd2a0d2", + "332881d4ae3edb77ef8a668d01eab5a99a44527bba6666ff7d7c9c36ee9e2ff8", + "fb9dbe99e216091fb98c73c289bf10b2d852e7894450cf0f4be90466ac8ca24b", + "96e442e3a6f8482ae604ae807ff4bde90b363a1323528bc8d58e0057dc7cbbb4", + "d73d1cfa06f01d52fdf4ab76e1669938907ac236515428c20582a3843f56458b", + "fe54cf4d09061d656b2a18bf2a4b72f561ab7a77a84e94d5d1efad3b269efe62", + "9cc0b2aa99db0d613ad744529c8a17804b9bd668d75eee9b9f1e8a9149fe270b", + "12470b179accedd2072c1c7ff23d48ba0a8ac5f147938c11331d3aa5c9d4dfc8", + "d0c6d903705fde8ef1766c635c3978837cbb0ea480aea572afab8c0063fbe9c3" + ], + "b8de3438882c53e2": [ + "4917f61ab2199fcc13d9f65ffb642e59f69bd046b92926fb0d1dcd5328f3942c", + "b8716d34caa44aa1d625e54b91268db8519aa93d9f04d8145c3b51fd65592971", + "df27ecb7e1786d326f8c589311fab12f6f2de9e8ffae0ebf27249e3960023187", + "6576ef8bcd00cd6b7a4e8b5697b0ba0598084af621edf894bb285b5a025951ce", + "9a1dd548ccbcec3cf9d418701e65e96c662507048673463bcd380bbb7d9a9541", + "24b17c838df32bf3c2065c5bb886ce7fad402954ec1960bb0d78f991602585ac", + "d047f0eb87f00301bdec42f4af450851c94322eb143ea8121e309f329cfe2e58", + "c02587330a56baa464599bf03b7342beb9d79d21a1887b832b8ba45b2ada293c", + "cf7b7982fbfc6e73f7b74ad1ae55947a446f989d3c701c4d7ed50f4a5fa7558c", + "dc3b75315e5a73b8168bf7b03b248f1dc32052225fcf3750e968a1a9b51b4c43", + "f85c2ad7972b9f34c24527ba66726675f705e2ddc4b26473ec7886d3f543839b", + "8ce252e4e0f6591f5fc39d90127ae164ca7744c593619a42401623fae7a150ff", + "952ec931b4268318ae1128336fb43f5d117044bf95ad76f1fa812c1a70664da8", + "8b886eb6f31b892b7ec6f1121f0e0066e789ebc3b6389a48eacbcfca11570267", + "87c9ed47d10404687338952a4bb538b57643e2690a7ba06659ded1f3cd3e82a8", + "277b10e364ac7abcd06cb1765b5873c29cb5d500893168de1f04707846ff2b8a", + "d3b91b93233dcbcb8db3f1a8783562c781ecfee6a5cb0c6450fa460b475aec04", + "2c50a2c4141a83dc83db94d914347aec6dc96fc27dbb77f8b75568bc7a9c2560", + "7fa71b8f1f104469bf08c3a2c66ef02d736fe759b7227e6161f7d8e5cc70d24c" + ], + "b8e7fa5d163a013b": [ + "8fb97956568c861d8923b779abe4503ae9df279e1dc3fd5f74726a9c35b2ea61", + "adbca57294b796de344c06647c1a2b13010b25487fc5f00d9947787e094768e6", + "c166946c49e8c7cc846ccf3d3f36965474ae5355d20ff1826f573ed05cb05647", + "b154e33ed2fb8140a5ef793bb0e5bbdc078df8330136318b06da32570752ac6e", + "4aee6ec0fa282ba04982a4bb77d123c83eb304969140603a9e96d4eae64cd86c", + "27ef852e2d27a88cfd9e78a3d19528178c54133a0ef5a618339b25009afdd38d", + "6084c6cbcadb2fdb0eba16a3871d2f30bb670cb566e451e50c78f18af7137bec", + "56cd1ccf779f8a8ffde2386da4094fdc3747237fef276c7b62a90fb650cd9380", + "5fe0da43ce77fc85f6cb1947c4dd8dc23f20b5ad7cd8d36a1dd608f92bda7cc8", + "35d7dfb2efc33c144acdc25d2a06bf83385594c8af95cbd47f55971798dc6106" + ], + "b901ac2617e4ab15": [ + "cbb4922cb65c76a4b588c709b1935976de00fb6dc913893af8ea1984b2181180", + "217ad8200c8f0c43e18566fc1340a020b82cef8e0b829af099fe4fded7e3a82f", + "9c284fdd87a1e537f8bbac79de6bd297d2c5370fc988c26510df4f7c92ebded8", + "b7db458dd3cc44f7ddabb72176a8176281b080cc9d76d6be06ab1d3f79f50572", + "14fedc01635e84e1d1a50b501f68e1e8635a9fa296353ec5905c747fde6dd0fc", + "38e54de2771bd2c649cb156801c189f80fb86c9e9ee55b3ced1606423e4f8261", + "8cdb4564707d0d33d1f323701cecc5583afa34515957fff53485ebff255260f5", + "63874f45dc498b1ca436342cff7116518819139380e6d318181a526ea38ee793", + "c328c62e2300876b478586e33173eb56b9081192510250babceac8d6a5cd0f45", + "97777ac1850f51bb60cdf6c0cb4a049093c5006816c089b3888242eac43a59fc", + "b84efaf58f17e3936b37bda4cc6dd995715fe4b7f34955d07b5b6a5ef923afdd", + "592d16678d9f17398f5d2e7a26b0d1fe584108f12ff69d078ad65214d025eff7", + "29a234f089f3d467fb822233301ffa4d91949cf769e247a1d9a59f8648b476af", + "0f8267feb55640007e1d1c5fc29c5c08e77886cac4437486601d935e5946ded8", + "2ece86cfffba23244cb520f4fbda43a4d16831fdacf780be718a3642072a793a", + "c4ddab2adb4eaf562f5a043a7b1b4c1b1f511de7b308d12497b6a99d4ef11075", + "11c97b810c3d1d92719420eff2ab527de6d410d9826f73a32c248c1e489a299b", + "d59ef9193c4340f8531647036935e51d31ffd75f177f92e67a4f288c9708d4e5", + "08f59b6b5f809d7f77365739ab4ab6e8a4b5ae7a6662448fbf9710520afe0aeb", + "5e63fd66787b102952ad2fc87e944e21cc5b4041e8f215fa3baad327b8226b43" + ], + "b9f408c847a7b574": [ + "798a96d597d594de44a223f6ab3f762d19669c669f2c6ea1caf11c8a405cd813", + "fb3ef3fa94c00be99b6cce009ea7351f3a5fce6c241267ea37a4f3c25569ebad", + "5ab4946562f7ad44ea5d374f854da2fa08d7a2fc16d80ed2be29b97cbebdbf12", + "03d211b543e8ae938331b26658a1a5d6f5767caaeeae1d97c99721ed58f400bc" + ], + "bbb6b28f02d490a6": [ + "b451a75bdd65b059ec69fb7a3bb924daad347d7de5e35ce46d3aa0b9c04903b3", + "46728ea1734e87b78c473e8de5086fe7b33be5e97a2f7006d5f273d5e6758e00", + "73b0c4fac39af4e7a75363ba143eeae18c92f95fcf7bc67c32f9e31adc162c75", + "87421af8d4a7a8afecfdaffd26796a0dbe335333426366dac3eba1ada5b9b9da", + "fb2d75134a7adecd2333cf7fca96ecdd19007a3071bb0c8a3faa41bf360cba0f", + "f49b1b74d034603f7e4fe4ba3266b0d14c403db37d498b4f0e56450506327180", + "6b498a998fd3843191408d0f6775329bf4af84c3c0a7a733443210da1df9b8aa", + "28a973f3af08c9d7270c4f3b850bc319026d6528deaebd70a3b2bc226644d41c", + "58485fe82cac4c942f15193ac73d061b5bc5997803eef9a5643312aba8de2112" + ], + "bcfa921243320e56": [ + "5cb77fdf56381b43caf35b2c378209e03c11329194da391f18d501324c009ad6", + "4929d42e3e6909f24651888cc0934716a717d8548929625c44f7e97c63a67a4e", + "c43f8526a6629f4c11e1eb4d82c861038bbd71441fc2ed8c6c0d7d5c44d88dd5", + "71b98a4fb2e672e9ae558c79122d5419c1e78d293661cc003ae8ea3bfd13f016" + ], + "be69c99eefb720ee": [ + "7229104ba4071f70fc4be854422d1ef70811fef6bf4a5f983795bc3c6220fa7d", + "47bd1cad55e58768439c9ec4d5426eb38d195e814c9d0d0bd22041ec69c3dbbe", + "56ddf8dd12817381e86d6582d618bc4d7611be843960d5c54d721cb40608f1d6", + "8e46b9cd7255fdc75823e10904900915b351238a0cc3cdf1a4f8aea2ad433af8", + "8980358c7ce14ec6baf27371a29fe610595dfc5df01bc7a90cd67f7a931cf38d", + "0d11e0740c45194d91d9ef0129e98183d35a8148445972539b0ed5447ca5ea3e", + "22ad22772404d3b1f37058a0b31752e5fb6d73f3f26fc98e4692f3087d6348b4", + "253f12b184aa57fe01eda4a0b3829d4130673a18d1d0b60c91ac050384c5fe80", + "eac2ac16d49b89c3e53a4b6421f1015d32a3b6a32d5a0d99733a6a757d585562", + "19f4e314b0c4ec02d2cae92c604cad78aafc78a2addfca86811165c1273ae515", + "9107ed456abcc2018f3d8259ef030fd9dcc00a7cef8ef64b5f43ffe76b68dc48", + "915e13e83b51c87cb72154ce8dca2a8fe8b138a4c0aa18bb1c78b1508cb50b79" + ], + "be7b89a560ce199f": [ + "97f59ca3121760634f04653bc96736396667aff22f41bf9fe6288620e9d70646", + "94412ee64cb355d11982be09c348e0740f8e914c6c8da31e4c1e501b057a5e83", + "6436ac88ce2c5025cc4812c7dcb06646204bbeece5a4e85f96edb7eb12c1f618", + "4bf84c4da4fe29b7c1330e171d1dd6136f85ed907c657acfd9f3db1cba09299e", + "cb25cdd7a36e44326249c22b95a21477f56647548ae79ef58abe4b42fd7eb072", + "9c8b615f939e1f782001cd6417a2051a9cf6ae0d125b1274f2ecdf764f221892" + ], + "bf1f5b80ecdbc709": [ + "f2b3b9401f5141d57cfff1b9fb4a8a5dca380eb41c71a920c0f5b5b9c1862055", + "1cbbe542e7e8c7c7258b15a8aee33dca4fc35aaed504a0da4088fbdcf06419cd", + "0e582cb412fb685e1498972eb731e91986f6b83f98105dc0d0e0d672ebc5b5e1", + "67838365767da7d85d967519b80d5bf3928940d72090391911a1c0d19f5992a5", + "475a4a07a3cf809deaca3b61d51ba001a7f6661a0ce2a8178c86ec9206ba4f42", + "e518c022ffc9cb546c0c05bed167f2ccd75ba951bfd5ec820f8003b49886cfa6", + "a634c8761741cd11ff76c776f32bec4b77f8afa06431cb5cdf3be17a3c144426", + "b73b485cfcdea51957f173a80ee1ab508615ffb3be8b13b455f6d4d1d4534997", + "7045dd063e50ef2d12bc4559d9eaa6becf818e34bd31f32549f909ca81355e91", + "364e8134c9e8aafb4f2c668dad6bf1763a8deb1d1e2965cfa0d1df9f14c290dd", + "6aa81e765d90d376259bc244f395c341108a2df7d2ac934693f083689785b922", + "a868a9b0ad264b7c0cfcb88f32e25c3d0ae9e08cd4c399340b2f1d3c1568a035", + "51ce0379cbea20aeaf853475419ea7d68097418f6d672025426d7e0cffe749e9", + "c8d8b3172fe06a62da79e5cdd472a10bca55bc465e931c29dbad481e1f962dd7", + "a47525f663e3bddb41b9645d03657bf1227a0ca65c68e6dc18784e109ce11455", + "86d7c06aecacd06ef6928f5418e8521fd1d693bfac6eb0c74034a30bf179ff7c", + "f13c9a31656ee37bd509abed9c448e66dd0197bc2a8b45a37ca4a9dc839af823", + "9439afcf5ed298fc3faf9a17472e51f06d018e400fa89e6e8cdb814db52d499f", + "bf21f1bef4e634eb8d6b01cf74d93adcfb26d98bd6b841cf73eefc300e16e279", + "415d831ba9c8ac57d8200c3a461e94e18e606a6f3e9df98b7dc21ca647e976e5", + "ce4357086d70d982d315a2c20d37575d5c5b0a26d3de22f5f5866ef9b79e2790", + "62d3e745383dec532ba49136dba887f0b57b50e949d09a92ad0c8b78c98bff0c", + "5b593da640c6826949eee9b9cf8a17dd2aa418fe41562311a681c1d17abb533c", + "e1b32db2bc6f571a90eb864eb5da7e8fc0a3a1fbe24b8ebdcfbc8fba3f3b06c9", + "22fc4da9f574771488517f641f30026d839cfff17c63fa4492516e9df3be32c2", + "beb8d2e155bb04ef440d00c646d182789c8c230b8df859ac0c643561fbc9f60d", + "0c9a39b9616c53506fcc9ccc94c68b6faaf521b9770547573d0eb330d1eb7655", + "1d1e4b3e3f600260fe80bf3d2298ce84c325063c732c5d995e2d70ffa91bbbf9", + "3a1e192a2808cdbbc09e6f79a090269b36dfa299bdccb839b9520a513dd3c4c0", + "3fa0a6ad47105eb20a922e312c0905f6344d3acf7cf4b4e0881419b8e0b6505d", + "515d562bfa7020ed8fdeb9a9aa178d7e26ce748e4f123140ba17b9f86cba1182", + "e5869f4113c9624e54a1c04769087fc7681d1e8f2a034cb341973a28eeb01d49", + "51856d170ef5dec55753c870a259bdb6f338c32ec4b952ffca8013885213205a" + ], + "bffb9837c17e4639": [ + "70c2ddb44fa0d600c3cbab1e562bebc305acf635b7116b73ab819fb1ef0ea73b", + "2143b5e9d85990d53c31f927544fa1cd12721c233b61e75bd3ae5c7b4573a4c9", + "1dc2ded7c9e694af8f3adaa7eb85c9c196cc702a42064b30ce24882db992ee52", + "6158bd7a5e144fe8583b62514ed185175da6ed92bac4f3e310d8c0ed0f4e950e", + "115d3bef71ce9cb3ff9fe58633aa186e1af5948890782781287bffd8d9be2ff0", + "d311427fa72cb29ec39dada5fe18f7c6784b383d6dead72c408329203616d67a", + "b7ad68dfd4d083d2d8cf367330344c30081b073fa13e06cdc26f81396e3635eb" + ], + "c761e8dfc24dfda9": [ + "8bb054a2f3b0b3da303020b86fed953026acffef489847c5c37a23cb16a0d980", + "c3e98b0c716deb5ec920112d2395879c18a9e158340ddf5f1c7adc49cdfaf3f3", + "a31b83ff013856928a95c51cb9ec439af7ceec213e30d6db16508e7317f320a9", + "113cf750d8be8b5cb9cb39ce03c7ecad22f5a1a78819007bdac94f1b01c06c96", + "22a8e9be8f5ebb3141718c33fe6e4aab458ddf547f0ab42434032a9c5d79f39e", + "2ae9dbd8e6b9383f77e1be3ffd64032c68a9e5a3db022bd14290e3cab1f07b6e", + "29c5f3da8de7d1fcb8e8d46a3a695b136eefbf2c4786311bc2ad9ec954c8a682", + "8ad6c5eccc1862d0212a6d35e0869513c07e2c9d4dc40b9c98a3599fa146fe02", + "b1d0214691a2160838881f1d364170bcb8b4851a544c8e142048891d8f86a6dc", + "e57eade38a62386e10371188f48262518cba6b3bcac9a4b60ca0d91a49acd7f0", + "e3281781996b79ee5d087b182aaf44523bcd073b68b7697eb1f34bc153939ace", + "f9505cc125e7074a29b9814f6eb2a8cad00a6b9304794765e1ee936de2852e7a", + "181b99f5b0189c3ce1d2e8dc196d01c52342747e91a34c0bcce3968f399d470a", + "ab49a3d0e016feb16bc7cd458ac038a505bf3c3b6e20221275f429c25e713b2f" + ], + "c95cbba1c2509797": [ + "aaeb7836d37140607b5f3cec60bb35c43bf92e6c6c1f6fa4eb9bb37fc4654697", + "b137c052c0029e471d5c5d136f4da1f3b4797b93d7d0a41b6bf46a3bb0fa462a", + "1fc6b54106385714c0d547c3d9d190dfc9bd599bea5fda01d3a3dd7c44fa8bf7", + "77b451cd70dbbb40fa317628205981440d994177b52f93b65243bb51aa4da617", + "4430dc2e9477a6b8117670f29842aef3f076484496295e28ed2631963d6f7b41", + "3cf3c17ef2b7c0ec17a331d855e44782346bd89809a2092dbfff35dd20f52c02", + "bed52eb80cd62a22f2028967442ef9a168022b8d94a152897bdcde50118271d0", + "0e4b1e8ba317d5563bf7f2d978288265256d60c99b3044f93de63cf4297131c6", + "7ba9d57874e80e10f9f067538925faa4d9b3ac874ef229c6f06412cccb3e1e6b", + "dd0cc5e5902362027cd8534c702212ebbdb506f2125681c87a8c98f9890b3db1", + "847ab7369f73fadfd72325c7dc0ff83be80ade240b91efb78297d7b0717fa517", + "72b32d1d9ae8d1e59fcd6f22dee03a15ca16c70ab31c9a2b8faca0f17ea01103", + "5d07ce2093c6df7d4d15cfd0b9eced1ae874ad8d84bfa45c74529f4d42155ecf", + "40e1fae8a48cb393bf63d3ca77a666268cc6ea4a10719d8b71e0cb650c61d0ed", + "5bd868aec90a93e2a1c087808e0462db2c0e1b27557c9ff4e51ecc8622dca677", + "109ce8fc33a9895777da8c0a86cb373196b367c1ceaf2c0471197260a985c29b" + ], + "c9b467218c41f779": [ + "1bb63df985fe0931bb63a82b11f3da88e398e6a6e33c8413be0a25737e54d1c3", + "c330ddb9fc758a2a66b039d3d69a5993ac86aea3877cbf53a5b233c57f92d837", + "7fa193b0fa6789ae39beb4b5b3c247fc79fab74de951b223d805920966706443", + "3e27198a7d6515c84cce87d69dad09b74a9b0ab23e1b2f80c05860671241156d", + "d8d624e6f15a3d18ef6378aeadff718fc3ef66848d8287c1a187798fb3488d59", + "5cb318816534252e7c9b92c435b4996db4c16a436a1366ab594e2a130dc28a8b", + "0ce388ded87cb9a44500b8a9195cfa76ca5b9bb4567175b448c2a53455167f4c", + "5d688c1c5f7e67a9a5581d09485cd26c3d843e1c3ba041d786503f187814db8b", + "762d7494cb3d99467e2cc408d967a9aa07d6ddee839d89f03b8574145f49dc66", + "23653fe20209b82d14803ee71b189b57fdc34df4dc4c71fe00125e379a985e84", + "e81472b623f756e53b667d33bdc50b847b16087274ef4bb2972834d881990279", + "19315d0c939bee7d604c7fd6e0eca9ac27792f328cd6e7fa903b4f1f35485718", + "db314c833c0b77849014cb8e4d57af821622a2639df2ad6ed9ea2094feb0529b", + "84d6fed3c7c562384f796bd4102a4cfe2ff9234d7e27ac980a79f7f2d5683c8b", + "b6fb500a76a32cbc1c0004ebcd7678cbeede6eda6da489f430afbe72b716b9eb", + "e428892c2dd923b230b4e32e313a8872443e11008ed709741dc807e4b5140738", + "9ad3b9ff0cf5b882e6e37c371942d76056b00c80d85cd9413952594e57bc2e5d", + "affde8f7751eb5b98a60a352736b60e7a3b60b47e31dbf1f529fe0dfa4cc6242", + "67dc1e58bd67815c85725d813e592bc4816e59905a6b07dc610fb6088622edef", + "7644a08298a3a35133446e2be6e945f96148dfb00b2eb1b39175a94b265383bf", + "57f3d7f31f6d002022e11caaa7c3386dfe0abbd75a743a1e252d82d3962cfc5a", + "4f9adb0dc040f070129a9bffe1729ef7527cc8c5614d0b272129d5647753dfe6", + "37ac4fcfe05d5ccf9c460efb8d44816a260fbc8bf920d39e4793b58fb12cf383", + "201e669b543f1b61d53adccf30354867668931a7fd8bcc53248f4a4c181896a9", + "c7e29c8d05fb9638e355154461cd9f1f68f7abdb64c91aa8315765f8f0c5031e", + "fb36870bb34994a5c7dd5ea2ed73c402c89c6b08bc77267d5c36888c941ff25c", + "26daad0cfddd55752e4e80a39427cdbd48e8bce8442f4bff1c0f4b9830dcbfab", + "2b99e403d1cd8ac01b48750717517c3c7890a081176a7d0307e77b7bc1320c47", + "39c81796834ae8f4b2857bee3d268aee12ddf1802a68e6c00265bcd05b9e578c", + "611b604f0fb9ec788084a51c06636488cea01a075bc86aa1f5bf4e6876e2790b", + "4bc464e368e85847fe6df752f8fa9008b9b839e4e4aa239c332436d49ea351f0", + "e4cd3123b22530448fe84e562ad4f86350b70c92e06c42dddbf39f42a490f9ab", + "53e6f0aa8fe10a48ef967aa2f491321c7e5ef2ee4d5cd75b8bd5e38662ab5fcf", + "48879949d7f732d63e3b10e9f5ac788529b8c6ccc18c2da9c9f6a9b529bb81da", + "8e0248ab6e667c3213ec73246daca9ffe472558162b23222207dc14b7604b7f4", + "0446fee246d40e9f2794eb3386ac2c02d7b3e450c735f9449f56df743fe8bf08", + "759036954a8c82eff1f3aa521c0a4ed521e1038e03066c3c55f51f0032a38a81", + "2a23e1cf71feb991be6fd7f1895c42e45524cd6db0a2f20c077632cddaef3e7b", + "cf6767d1a6c2a7142af2b72160c2bcc1a10920b0ea93efb6ad8f50ba47ab1404", + "de39149c3b10f2c90b2e603039f9442241854bc0a2a2fc0ab1cc4540e2fa57f1", + "37d72196866d73605c23cb53cfa79b7ea0668b7df7d38d8ee19848a2584c7f72", + "8234d1800f6716e211e3e272b450617511dd519d3154a1a4422df53aeac0e4c8", + "d51b6ab69ec98735635457b23ad67d3f4705318becfb3f5e5d9ad9641fe7f37a", + "5d7b4ee203d7e5a35c4e4b201b36693996fc7033dd0d460aa3e7a2fd259d83bf", + "97363de3f8ee40f7887e859abfc1007064f9b2f7bfbb6cb2924960d7b8f4e6f1", + "bdc5faf3b1b0bb0c21d7afe63abe02db5dfc93ed8267102fa30d489e3bb360aa", + "17e5bc72e11d2589dff4526310a5123c321c0521af136c40b2d3ce7df3f0579a", + "4b26ebda72456f7f060aa9606f6ab1615e1b507daaa5b224d15c89fd0242aae1", + "0f0601e0a0ddad9e4dd8cf775c78303ad49ffbed033449f170a37817215a6a09", + "aa0a9ffff907f24568a9b0d7de73ffb597d6691fdfc7000823223ef65dec233d", + "e009ac674a977f23c266a03ed104bd2b861acdaecb0f685d3439c633f5042f1d", + "2699cdf97adb21e10d79c0a116c054b9e2a1d24edceaad8c3af92678be805697", + "f7b0ca2d6cf43d0e5d8ec207940dc37394d6c64e5f86042518a0aa206e6cb0b7", + "93984ef38b2a1af156d2b53c75f2d5281dc23fcb5143f4eeca9fa4f4ce779c60", + "331d2013388176b34c8fa71921632f3baca32106499b81d2bab79a9ebad7e115", + "0d88d41d26aa14ab5015ca55ad8b8a2b1dda059c7702e780695a9f69feb0c33c", + "b401a800a3f8280a253cdc303221d06eae47500ac67d1f7567d8ad7112c457c2", + "4aa605672e97b5ca9234f623e182cc92f7f0d9605dd73dc3f8fba127ae6cc57c", + "b70bde78a96f9e0f7a27051fac915b82c82ada9a17c6d508e30fc32c80e00997", + "8f915b61287a5983e3e91bf2706e50413b83fe69b1ec5d31da70580138e9313d", + "4f000a84330d55eae6c062f7aa43a589a3e9eaa514984212157ef7cbf5cc2308", + "728f0e735bac0a0c54443b06831945d1e705c6831d25be4fa02916bd69d15e11", + "1d772939d265703184701a9028f12215f2f292ffa9e3c2c5f79f49b3319842d0", + "8222446cbcaebb7d1ed87463b6bf4a421371de559e5ad0b2e9c869c585349337", + "89ed9f7b3bcab7f8e198a91f10e35b5fbc22755a9218e32f9709e0ed7039ac0b", + "382e51898a5f3dd9ea54af4ccacb7d07dfc8539872ef573f0a335883a48922ae", + "379e6a1cd0029d12e61d2982238d198dc5fccce95fd682efb7c3047f011ee095", + "1c411b35911f0258d10a6a6a51daffa1e963f3f8a590a159a0bef4ea2d9e5b8f", + "233dcb713596c8b48367da4fa0114ab4f853d23fa89891df2c8ff241e94981c4", + "0829b83c7348df7ae765f8a5d493aeecef4543c1612e1c6d7dc0b37cd1254146", + "351511f8bac4ad4cad6ba10b84a33abc55e979de59ae2d58ccdf4ca91a990e7d", + "01e188da4bce7b5493d6c86ab0ceba4a39a8a6537cb948d3b1f2d1c162051e89", + "a8b07eb94c0df74ac8bd20feaeca0ce72bdceb522e9dedafed2c8c430de73853", + "4af05b20d7efbacabba09e34e2deea0a1ef4d976bfb73bf5ccc7428f3d582abb", + "d1bffb368419d9e4d789953043e68168932de1473961c050b3e37c3ced03f9ad", + "b7c23e2ecf869e99c4b2d69d593e5e2987df20f471e0c1624ab2769fd2b6806e", + "19f2af466e364f71de5ea82872987155105a103a2c1e34d557e3f7ceb1eb9f9b", + "28a5843c1164388a65786bc83e68af7953b967af37f2ba56040ff2e6f22ada4d", + "db907148f1ce91c342c98bc6d897de4b22ad23f2a0108310634f95b4891224e2", + "b5dfbd6ad91d3b4c7f7c08b40e4d0ae3d1e08358c268848fe820b64023a60019", + "4ac73f45c05428d9a31157b9f915ee58ca7b2712513d70fd33714926e1f3ac83", + "f65d9ab840790b9c2d6a5d7d950949b00c09f8e10d318d91da1fb14fd20f476e", + "5c5af00b9e9f67c853ae7654035e11588b4aece038c1212de9705b978470e97b", + "7ff857b63f60605bd37a675386ededd40b91184574689ed4af5906ac8962d188", + "97a735208d30f17a232a1a8986a70fa00ecf13bcb8d6e3753a87b9a81e3ac90e", + "a4fafd9aa59bc26d6837a55b69a0f0aa293ea04a65e1216d046f399d4d0589cd", + "bcffb431acca9a146c8f63d6860643696683945212965d3feabbc1d01459c2cd" + ], + "c9b9a5b9ea085713": [ + "c9b9a5b9ea085713470b9e17970a3458a69e8ecc7ffee1e449d939eb4ea41108" + ], + "ca34598768545fbb": [ + "11550b429b8637a5b1ae6ab461ebf5bb18c37187c046d7088496408625e46100", + "1521ac83c399275a6d8b5608b7f852b3b30506e53f371da36f97987e9cdbffcc", + "0ab3f6e1a18f1f6e53a1ca0d1942e20fb348512cc45eb43c67f0d01803d24365", + "9a4b4125c83e6cbc285bb78fb32a380cf10b605d313b86054a523624635c9ae0", + "1c7094f9ac6a656b1866db62ed64899667cf2c7c819e12a05073cb1ffd20e2bb", + "20740fcee4736782755fd03ca9a9ae4a45b453087b82874b975f4908a671d502", + "e2f9daf6e27a40de62636576f98f3ce8251031df66544c59547f5c88bf45559b", + "ae2bafe32c740fe4e6583fbf0132dfa5aa6296a6a612f9cb3a7a168896e081d7", + "f950aa2b5c4ba45c343a8fc85b1e3201df48a836923b7b146a725e0d1fcda67e", + "699b443dd05c15f990d5bfbce19ba9c03e26220d0b204fabfe18cb9e23c5698a", + "8e4d2ab4ff83c614a5f71389a3af05e1b0f13bbc29e8efa0745a220a6796013d", + "0bba1d932ac7a9740a926795ef864106795d41cd0590407d8c1f544093b243b1", + "4eaba28f769966c8628b10dc5474e285b0c7ec3dc60507775cb7c38ace2f535c", + "e8203acbea4590ba21ca716d9c05fa2fbd58f34fc3da463a85057a953279b008", + "a5d3391a531022355920a9a023b6924b3bf991b9a7a48e54960dbc490a9a94fb", + "6a78386b1f6d10241fbd7fef61765a9ba40a69c6369e4563663b53651f8abdc9", + "a36aff58d38cd80b97293a615890c9076d83875d1cd70ba771acebae3529e9da", + "be832b76a1e5da87430f4c015876b306136d6518771ed906b7bc52ac7db7581e", + "86b02bd3d6723e91d1842b77cc6e5329c6a52964234cc7b79986acd86e08dd53", + "2d1905d2107b5487ccc48e6d0d99faa082937d68b5d5054426a62dc72950b758", + "a90a6618d76be01f938b160e707b3f7ec95eb244083d50f5d94d8750d9ed9e4e", + "fc777e5a2613903a6b18ea7ef9844077cd42710d370a8d4951bdeb3c0cb6ff85", + "d3416d7135c4f11c9db5374740c252e4213529a58200a67af5817a899746bf9c", + "d09b484ec48cc62e84ad4e9cbcc078577437f2e89253cd862e783802c871b47e", + "1e96c116d6f79466e8318bada326867233d9de3c52eef1a2a31ea1cf453d0279", + "85a5f72b86995eaaba739fca222fd0a7a2788337a92a88d558052bbd346c8fab" + ], + "cb81b4f72eed0dbf": [ + "aee629252143891ebc04b5080ccac5950294aa85be99c8995e8652868b8bfb13", + "02cd53867cfa44660434522e5cc62027ef87a379e962cdc691c5e875338ebfea", + "2ce455f6e75139324f09f7e17013c0684073f1a91df26a36aa81e95a4193011e", + "472937f74a5110c5d5c31ec871349aa1a35c4f6ad5747bf8271320a16f2a4986", + "b5b35ba785004cad981be95c807644b6298664d0626c924efc032861d6059d05", + "2ecff83587b9ff04abd0ccf20ec3470d17faeba4169bc452375947076973b71d", + "40a087a7d73c30420dd05e91390481bc2ff40e76c064a20717ebdf379bd96542", + "ca6eca84df5534ed2460f82a27bb96c497d4fccc2f0355049596e888a5e8c052", + "c64dcab99700e5b3c97d37068cfd24ec905bdd3b2ade1b21e88a9a60a26f71ca", + "c6680680e00099731fd1f6440108792e4a793f0fd96c54c690924fe118438ff5", + "5182ec7fe5e457ef0754e7b5113277982aa3ec40c9e0ddb4c70f0486aa91167d", + "30c795cd671feaaba48023c0a48471ead1436f9ba6618b6e112b749d8919f815", + "1156fc269eb01348f76cb2cddf212966f074199604159156b59740defcbafdf4", + "dc09d77e51e27a7ece969100a27b2926c369af6a8c3017d20e9db38c70491906", + "0922b47668042e2ecacc115d7507a061b7eb27f356db8fcb30be80d5e1125fb2", + "011b9d46ccae974254d7f3064d93c8902e201b43bc3a95d1af30f7ee5f0a1d46", + "1f2de40f3bdc9948a24eb72a967a756cd66cba16cd578f834d41d7a7e98d9e65", + "84c2b952b2a41b9816e0f48ff950180c6ae739edef69e700e1b5c5f30e488331", + "0976124aefe1da49c95bcd9a3edbf714352ae98ffd787bf4396cd3a0304c6a99", + "853670e2b15cd1d7566446403e1182a9c2300f26482c42ef298b77a163baa589", + "13de5d4ebd653289836837e117dd633223d841e175b37089f1b30e5ffa602710", + "87dd75d2c6938eea61630b4198b8a622f60e2f885a8c1fdfd410f577baf38aca", + "1d1b0bf23d83b90cd538a9da3ffe0fe6923c334770a99b2dcacd28e804c1240d", + "1d3ba54f5c72c504de53e3d4035a141d60db3447a5d22c23ac24918f9344f4f0" + ], + "cfac3debaad59af7": [ + "eb371bd49cd9309cfd8249fe42b2583f24deb19457164eb9b01e93476ad7bdd9", + "d4f33ae1b7107eb2a21659b4a542a696db4cdc0fa4503dad17d45ee5e9c91bec", + "5604ec2d77829127b443ed3508ef6dd75de76ce5c57f839fc50bf6cec2c74c30", + "5a84fac898134980aef46bdec9a0a415e73d5c13fd24ed82c9f12a4090edd978", + "84648edf18d2444c0084a4044556ea856cdede234f1c77186e9f5f21ba0c94b0", + "4b97bfa89507688fb36f55a053dc466df19986af44de90865578c015815c64be", + "90600a116ee267f9065d6b40812f16ebb5ad02c75136e9afbc08f66859356fee", + "693fc583231cacce3c45b85fe2f812f297c3aea75d4d8c8df17eb4c725511274", + "79f472abc3bb7b07b0c14c84c649b4da639b05b61f26cb589f21eee06acbe6fa", + "f7c9779a86a5ea6e10d40d92b9f6a5024047b1dd12af437ee4ef387e85115f8e", + "c96bd45b8c6a494ff304e6718c86e5373e350569471bec95074a5e0e353b06a7", + "b9f25f08651101ef6f9dfaeca5a22ebe15c2919c383afc42bbe19aa8c48c6f9d", + "f043c4758ad4634eb8cd2a412672737bb3f1b90c074b4be21ae6665cfc978ebf", + "716b2bb3be0a8a5856866cf7c546a699a75d8ce56ade1fa5dde8c86190088d9a", + "ebf4e085833570a0304bc490ece16b4e528698e0988ec7f2e54407783ddfa987" + ], + "d22b7ae29f618575": [ + "baabf549857d6edb571f0ff719f2a6d7f519361f2f54ee2b41871a5a8825716c", + "013e2ba49f2dbcaceb80f8ee3ebb373a86ca4729356e4db7c6df013111e689a4", + "2d2b4d2f48287afa97b5506ee80cadf290bef8219bf5ee161d43eb66feb05a93", + "bc79632c91ab1bb0b42766579094e1617274a87311ad43a4872fe5aab52cacd5", + "38832aec930b6be44f1eb058354c3f632063f282f95ac9a06d107c5d4d819ef5", + "c539ed31b48c58fc007709b1013d490aa9a1d6a8b69a4bd2a561ddeac83898e5" + ], + "d8d1e52267ea606f": [ + "5a38e5a07763add39daf79ec7b9f537a48c3c5d0287d8dd01747724fde8bcc62", + "60b162af0fa916e0fc51a059a74cede213ea31032f4d9ada988e0fcecdfbb1b9", + "3761dbfeef1ffb0515f436fef50ae6c297dd0dc732919e75c6aa2898174a5f4d" + ], + "d99deae25d296efa": [ + "2b3d68ad61a2befcfbab155fe25ee5109359894cf1af987d4b1de4c1fd7c490d", + "e7e663436c39108ba5901c0d82c3e77ae1d5e00825d88e8bcd93ecb46bdf3da1", + "6011619bd88bdd5ee9027cd539ba1b498ce8100d59dab7ae540688f78d0c258e", + "9a40025c636bb87df4c3d45bd4019260cb698e42e819d28facb2135f3ecd6703", + "d54ad01cec92518f45c69b1474dec6e34db2d9f05674a9faf8bfdf0b1c8dd0a3", + "6922e4f3871ead1cf811b45930dd9d79731cfc90fa768a5eb78f6b4d1aa69fd9", + "e4488cf0f16ebc203b6458471944fb73b477f9d720f0bdf4109c5a2627097833", + "4266215a8cda59451a7e1b2a0ed811b86142f1a3a5fcc6f4b8a4de56e1d7c601", + "3a2cee6652adf18483b0eeef46c69cfeab64d0f003aa3fec4c1710171bfd3008", + "4ddd82a44c4102eaf95bb07e15f673d9cf1bc2578b209183f5498526d2eda6f3", + "c687c753f406930a604127f981a6980e193ed259b29b9d92187bb286c1a92104", + "9945eaceaebf352e40cf5f030068e0f0eafc314c276a2efa1fed2abbc141e659", + "3fd846bd9e2489185e07698bfb30de34ad5d5b89e3b02c18b57e6e71c90bfed8", + "446665eccc8b57b628e577ffd8c469c07c6da6442459e3f05b07e3e7e5ade9a4", + "b0bad4c481f88be9b71283e11ad14ab50fa41405894a73cbc0d4299aef264517" + ], + "da3c5c8b692d1c83": [ + "77388716773d8653e41a4adba9640be34e1c747974a272d859d76e2840852bfe", + "00ec0f1e2d1694f6f762462ab95b306f3f76fe4e3571cd2d7d97e20c12f2b9a9", + "f2f73433f213f25b194c4e6b7f0094dcf80584ad88119ea46df110139eb1b230", + "43c3ed97de48d0750407597b0494139a952ca995b5c4981a621042b281f9b70a", + "3dbfc82f95e218b91ec19991a7fe2396989d63339f78b457e02295d0e3ba9bbe", + "a54d3a0a876609a8bd5dc44859eec2752d56b9face092a68953c2670b355ee45", + "9892e98b44c677919f94cc7fbbf98ff818751cc1ca429c7385ee0de35ba6c0c2", + "c3a591774671908d04643d48b37d9dd6b4a0c0fc10e6d2d3d465317dcd51f9ae", + "73da40903e250be1f311bb955b01a0f371c9873b9538c0ef414eb494179e09c5", + "758db8d65e54130b3b093168c6137ef009a8a855d53caade579d519251cf4bbf", + "e528f27ed5e778cef0e7e7b9f6c952426c827ce9baa7cf8492429529bd65c905" + ], + "da61811452bc755c": [ + "c65658b38e72af10e9dc5d2cb0fcf70bd81ab4fb7ee3ee103c10a3d8db5945fb", + "0fff64fbbf60bf82155d53fe840e6d24847905e563fd687acf8731a56dc7aae5", + "45656e7608e88aa22df3c887000b279cd4ffa48c5c4059aa69a5d4fd9845b43e", + "2ff00d0e3bf4dca4220f9054d3286904b4ffdcb5850943ccbb03897189ecc9cf", + "9f806e3134c0cb7919f1103ebd87fa480817e5b930cbfd092b8f03f29cce1f8e", + "24973f645ba5dcc66f676c867bcd54ea10ce2b7b04a0902aa2aef6774887ad70", + "230bb82e5c2d86aaca1acb17acc41e2ce757a1ec0e9abc355cd7cd2c214124d9", + "eee29e1fd8b64e37c110cf2cdc727d815f24a756bb328e9e3f857ed0820c1452", + "bb6a14003637f7f73027569c4855e1dee89dd4b1c4b1e8c6cb9aaebdf3159c72", + "b2d499a9c8518854834dc7e9685804a0eb97ea864773308ed9bb84ae9b2372e9", + "9d460429bba2ff38165591631d06bbc46ea1d08f886ff0de6ed480afb99be2c1", + "31ca9ffd2c7dc4fc5b0b13dbae41a0a0bb76769496fa3f13806e7d55583217b8", + "f400b83c7e6bb9145bc894eea3bf62011e5feb3ad0327e15f1b9bf0800a76efb" + ], + "de902594699a4005": [ + "dd3f55424245f19fd602708547065f1155d8403bf822df1c7d937b7deccba500", + "c957f3de19cc9b97a8ec86f39ad4a8e7cb0a04fbd90bcc0ef637fdd9504727e2", + "dfb73d0bb3dca3afabf30509e79a8fd45a833af6b563284b2a59fec256de8fd9", + "54a34de9f525d56a6690725b2f4f993796e6f462114881a3e9a2e2787c47fef7", + "908afedd8ae3d9730b118adcace8acbb5f80f7d5738dd654056b2cb99d8a2115", + "cfebd13aa320c2604f240c11e1ef17f41bf5f0dafcd5ab2790051a5917a86590", + "9ba729ca89cb7facda5481351d2ff0d86541244f0e6d64e65651a5a3e6c552e1", + "ae9c59ea52ecb332e9fe677efa796c4052ce55496e08084075cf3109157edb47" + ], + "dea555703614a199": [ + "701a592516a9f2324944e55ef4739345079ee69db3eec9620aa2d90c7986cc0a", + "c3b892dac96f8d1e11c04ec4134dc46831b5de4e2f8f7ec307961d7b40e9007a", + "d369c761d22a5170d324dacba5fdc7c77cf106faafad2127a5641071a1ddba2b", + "e80f314e39769c98ed4a926dd410d7c048fe782a4efb168093a42de68f2e69cd", + "7a21ab72a89be1c279cf88902683a125fbe43ac401a92e445f4affa8f681020d", + "733585a6efb5cf7cccae190f6711d1626484da31ac06fe9318083048f4ae4109", + "d707f6bd72967ac392cf398a4997aeed90a0470d2b7417bd2d0ecbbe09b22c78", + "fb788569975218a0a062e6f5d25f58ad1e579d81c16b03e39996649eae3d082e", + "582fb860c9be184075af391c9d5b353a8b9cb72c1e07497733a055f8e9a86a92", + "1dd94378df593d76837d029629774a05c326d574a2e269f08c0fbe03681b15a5", + "d82519de30bc71dfb3903a5cf5ff2cb5fbe92ec16b8c476b76135f5f02bbb615", + "1aea653d4f2253c752b0dca24c351f644ed482e00580b6177c13c021650a057e", + "95a485c8cad069fe42d47a3d49b87d250f043ed75c03ad040d771d6f29a6271b", + "e8d68538c4d4edbced36594fa45250265c1f8fd4670949455496407df044db0c", + "1c61caf7e45f3b6c183048367c579429cd492fccb5af6164f96920a1e18c2a8f", + "8014574448fb6bd3e98b34fa05d07f6e82f9c55bc373cb34801b9d4bf8ddb019", + "657e816262f0c3f6b934fe9abd09a8ab2ce82ad1f062d89b73fdf946d1002014", + "98209a1c081956a281f5a78c1d97c55c84d20090383f5fca6197a8390cc4a38a", + "eb5bbf5d76499d9bd63374fe9eec1a2d63db77a3502c3dda11ccb8b96aaa2ce5", + "071c7799c3323b7416f5730a8fee0cb969177c81bc58ca0d1c5c0a9e78282eb8", + "894c0007dfaed1e977948a131553bb2a32d0cb7029790af369061e3c415aa301", + "35f2377ed44a1183bd5e89c5653af2ce52c43ce583144fb4c31fccafcd539bf8", + "cc576ce02162f113f5a33b78f04c873935c61d63ae43c60f71b69a759bb9802d", + "4455c71413d0d293d96494fb0d6458f1f7b8ef47f1961d62b02a1ed15de089b5", + "a4677a195d290c98bc01534bb04e116cfcc66c3f5028cac2d6ea1b5b89e19742", + "56b5c6970affe438a9c798aa66926d5dc8d6fb5f8b794e100fc10c2853ef2dd5", + "107f7e6b719f71bf6d1b9e349c5a007e861ea79a72727030a04802bfb4102072", + "65428eb4e302d03e9690ec27963d18495c3dbbb00e4491de29565c5db8f1204d", + "e626f99e7d45d7ec9ab7b39e79468173f6c8ac1f0e9db68495743ff59c1cf815", + "d7a0416d65c9685c05281f7572e31c5a6589856853cb0a43395f1101fb9efe74", + "c3039057a95a5fdac8ccd3993c7ff606bfe9009f224b204e78a113ae7228fe25", + "73bf6736496eb62cce02e840f0a45a4a85be716bff41b8f0f6ca92d988399424", + "143a09e18dd6e80e7a36387dbd5bbc7cf2455e5403691b7bd345ca970943d1e4", + "18d79f85bdc5c2dd1a17146ac6a20f380721c8485b4ccef11d200326e3b2bc24", + "396b8c47003241282d07134099ba50c5e4c44045c92710e212c68dcc6f2f6754", + "dd1f9e0bd92ae3652fa2c09294b1ba15cfb3bd0b35096a07910867a98eb0695c", + "1f53b86c3cc6a5f84507d1945777bd284d8afc5931ee9acf744190e460ddb9cb", + "b569ccf9b85e05b9cd97c35cda5fbdbcc789140377dc07d3ab943afb72c8d28d", + "d79d3d79822cfa5a3676cd0af42fa8de1380feb3d9781a0d19268b80a483b493", + "fc12f4c129481e7cb5001ec4fdc0af858ec7652d8588618650604705b7399eef", + "5781d548096edd091877b38eaad7aed47972b06c1a7c96343d2182296fc9e5f8", + "6a39359c1736487d3ce4d2ec471d237ab461fc6563430dfefbf9e83e8321d068", + "1666aa680d84b8dcd042a7401760fc004c32ed31b1710924566a7cc87d958dfa", + "d27c3bdf49a14aa5732a4dbdf8e3c185997d8bed6c10b1eeb861f69c73744124", + "af1fc405f06a28aafbf10db07a2833e26c3c0647820a842fb473e69f9e5a404d", + "9d461417935b2157b9d95d161b4cc71b1b64cc6549f39685ed813e77d91b2797", + "6a27a6bb90b15cd511530acbd7a3d8d9bf8ac0624430e5792716bd688fd65066" + ], + "df46e6b82cd9ea68": [ + "f9f0759dec03b561a6149fddf4c9b227b9d2cfb14e8e222f2a5c45d5ee900434", + "d245f837bb2b6856d359c5b0abecf61da29a8ff05a8d142f3c99fd45aa9141a6", + "cc6622bf53424da2bc317080fe2ac0c8d00bacfcee7940b40030049a4593bbb0", + "91f0570c5de853f1451f2fc0699098f250145c1b8cc4cbfeac6c0ce6346aa304", + "576875c754f0e2988f347b3966dbc9e9a3669a2d9cabdaa9e6fcd5aaa1a80b44", + "389efde5f5f307e9d296a413e4e91f438b14d5d0153f02e543c6900f55cf55e9", + "d3b2230f6349b2b1a4caf2d2531690319d8ec4e571180974eb10119d22bc9809", + "a9b12810bede62c73f840ee9c46f907f2af846121a52389326d40e4c96da545f", + "46958be7104b0e69de863ecc9d7fdb1c478761b647b6beb79adc30add55ad145", + "cf9db1706e8fcad3fd6e698086b524391aefd7da8ade8e9f7f65bb9864304e12", + "4f2b69a4fb17a15c058abe38fbf72800aca7f98ab1d01829622f7244ad821c8c", + "9931321528a50c1a6af73f1c60a00b775da5b0afe21c2524dd151c79917e9086" + ], + "e035a7a88d487a4c": [ + "c0ea7309ab568eaac0b018ea86afefa4504c99c2cd152a6d710d7a72b5b50acb", + "cdb82baef7bd59c3c6c969996be73749fb9ed710e4d9d6a0451457c2a6c25f17" + ], + "e102844ed928d4be": [ + "086bd1e46312e23d3289e4a1637e6e9f51613a7f78ac940717210c4a9fda07c5", + "0e83110821529eb261e03cba4ae11e2a7f8d5f52e9ceeea20bf44c900c16a06f", + "d197767065bf45dde71579640ac61182757e6a30920c98b3a16b54e3bd7ddd84", + "3ac963bbd3e36f1d72a52ba57bc82b234b5618e63e405d5c622afa7b77a06237", + "512a1ad38c151136799001dd84918ca52fe3a778744c4386b2c6ebd6ae2e0a9c", + "cba6aed218389a81ddee60daafde67ec26888f61c46482a306ad6325385c910d", + "65cb4cb89c214afeb6332fe93eb09f6c41636bb26aeafe4b2c8b428ad197b554", + "d98dfac99214685a02864ee611055fbf756065f6299c27cd8768151bf06d5c66", + "326e5de810c66fc2b98dd734cc364776a94eb43512dcd6905905ae24e9673a7d", + "b46376d8988f02b1eff7fadb13ab9c9fe8b57cc9da4859ce707a8084f98527d5", + "1d9f8a2fdbe99e465f3bb753fdefaf6221505bfaa452da90d714bd556f8e83a1", + "bb3ba3ecd3669c13d64c34e404de801f160786d7c1e313a9e292924c098ca0b7", + "72c56fceb3d3c9d78c1492bdfbc02cfdb1f8757ae790e38728e396cf0847518c", + "c68fc9fd4d21661e655c27b375b333daf2a4a0bef4e74304968aa75718bd152e" + ], + "e718e78fcdcf1cea": [ + "5c6225ca6e2bb318d6f3888387578ae155defdfafd52cd7dd5667ca5de9c398b", + "eb941b4b7912d66af3206986cfc1b595ef03d94bd2adbe6e0962d05947047316", + "2e5f6c614e43e98237e3d8ac977276a63258eb1b303507caa3c7a39ad43f3288", + "b56eb0341dc17d8a7b90e74442311219f483315d1936c25b46405808c1b00382", + "93f07f92f528ad214ba3dd50c4ac843b2a2a60bb030090fe7219c4ec0e0fde1d", + "ee5614bb0e0630a25869fefd058cb25e1ce27d6d582c7913d5628a9666a020c5", + "665fc5dc7bbfa2bbcf3155d9ee09ff78ded3ecb6934002e90cadb4af2c4ee733", + "e1ca9cd10cfa3eafb588d0dad1fd4b22199e24f8042a57d2b22fba3cfe207220", + "b5f1a36902362858a159a4c3d2e4346ddc97c930c48bc5f84f4d83a071e055c4", + "800c019ca8b774359b15cec586a1cb2b07627a76013e05c9579d0ef6844d0599", + "751557eed3ee36b0c926ea140fd7070b6f999a6221b5307d7cfc1b95da410609", + "d4fcfbad222aa37b2f2b83b4c2fb07e26521143efbd220399ea6c448d0875ac7", + "80390ce315f1b07c5b8f9b0408245c79e6c7fd1a49518af88d47b4437f8a8f4b", + "b821cbe36b86e764b576e7bb16339fc270b8c04f9bf1e17109c30a64861c9336", + "cfb75521efb6f7647b2a096e94d25728d4cd5c42d3934a1cc382e79162390415", + "c5ce60358cdfed20c8593a30710f915bfdea18259747fc57fe9e69c96cf2f8ad", + "ea5c3e31ecab46bc854358133263cbf93d8fa6fb78344e6573d3dd4b5abd1530", + "10c490ae56b461626a7fdb04770c4468ca0d464e1083f754bfb42baff6936bbc", + "fd79a057213fb6a3b7480f825c092b7e12063bf0a0c42992d23b1f36ef6520d8", + "cbeccb5253d6e91332ca6b4cd240e73fbec599ebf22b9a6fa7efd35522abf5d5", + "dca5c798041849688afa50d20cdb99e4fa7487ea0b8fd7ce5f763bb3f25c9db4", + "10016e09b96d6c3b15614b27e2a9cd18c786c16ea4bb65c01df7743b03431206", + "5d0933701ca3f01586071e798e867d8cdd3cf43ed97640ddb9b0a4b7b56b357b", + "9f17bab5613f8464696ccc6e4bb8512399cd431741f075d273fa19ce3707df8d", + "853d33ac11390da804ff156696511c6b5d680258a7608186e99c96b8d35f389d", + "38417412accce9903d01322beb70466991454841531a1da6167c4a32bfe27560", + "84a5c81806c130763ca2fa13617d95464ed8fe3ea3bfffb4d0435c93e7fa3a4c", + "34f339567875ccb4d7c204f408392c71c3f99a375cda7f7f7a3580cdc64acb44", + "1c7ee2ea259c013219b660b9903d0a00d27e1b818ce3112d258bad2b12e2951f", + "d979934c6bba12545fad4f020b81b68fa867eb8adaf9dc2eef9ab2ae558bf9f2", + "9131b81866ee825857e4e20b92ce1964951a77b9da63837bcc08a258caab7890", + "ffff9e8130f7bb2da238fa8e11abd686ac43b000d6040b4cc8cd3d2fa43caffe", + "575e67963d5ab5227526a66b6d5233ec7efa34edfca821d16440d30302f1e58d", + "500251038a7f736388421ea4dd58ebf3fed568899a4dc980a4680b338d7a6ef1" + ], + "ebbb43951613f2e7": [ + "f3877fb279bcd8cbe73439cf4f9fa4b02909aa0b4aa451c12669f7ad47048d44", + "f323e6bb2b8ce4243a5bd1acb62ce228fbf79fb25c4d915f70a6ae0395d78793", + "ff0c256c56b2d4fed512f7bd5426521ccc2ec184748089ef9fa890d6d5800bbc", + "a45d8a671b10caa76e625c1528d46d8d92c8c1f536895a6fb3d84e7cb8b232c8", + "74adce20fee41cc7152341590ad4c7161b8444d4abfa9cfa4d130d99b0476234", + "970801aba8bd58396f79a7dd3c69bfee59cb8089b91af7c5e2c07936cb3872db", + "d21fec95dfae13b47f94bf9829a3f3fd704ebebfaeb2edff938f751e57a8fe81", + "6c2404c5dc4397007863a38abcedeffa0579b9c635e5ef090f36f5cbc8cf96b5", + "647654ba45574b215eebbed2758f99ab1ae58145fed0e3a4798951061d408619", + "f4ad3871e9200696d7cd25495b1e17b725688f4e7937c98a1e33d87a9ae73b7c", + "916dab8f542b9f735f60df3bf3064e4c18a87ccac5f1dd27d8191e5341ee10ff", + "54065c3fa8060963021b760c8f475ed97f6ae40cb966a04eca7e324d8e6125bb", + "e7a4cbccfd02ff88a12921e6a4939c49cb820528ce682bdc47b5d16b7429f9bf", + "62d2d6ba0f8232d1412b89a23bdfc256b234bd0b54d10c951d80cf40cdc1b229", + "8541ad6bd7dbc405c5464a4415f103ab834d8428a52c664dc9ff5c79ba830f94", + "56510fd9d67e61eee1f66b7176ae269ed0b02f19df2f6eefa9f08a06911d8058", + "314d331eb3b34d971e1b482bfea06047c5d009baadf6b5ab6250a72497b083a0", + "ff45aac4968683d432978042a010e4ced566adb14c38e37feadefa4850e77de1", + "b1bc2a94014cd05fcfe6e016bb225f88ed20ed6d310a5fba782bb0465c5672d2", + "c15ad51ef75643c1dd0bd2289cec7d3eb9d81613a429d477458d70f3e8597798", + "eefbd328b51ecf37a23695eac5f75839ccb043e83698252810b2a07f5a7a6d1b", + "36bfc940ec63fecafade089e3b3319e7659a0bc3d7ce05364919648afa161843", + "01c4b3d0f7a76bfebaac4c3ac76ad45ad8c7eeda57818337c083bcfd3b4b6d3f", + "16adb9ec9462fb1b9b08efa7bfc8694a185916078be23b8f87409bf8f1dd5108", + "0f6c083b76617f3ccecc5d24840d6dfa71c3f7e01c8da76e53c89cd563795091", + "2235089978693f8478a5cc067fa03a72503126408ed4aaa5e86fba46bb1491bf", + "e1063daa7568653e7485597fd0f9948bc8b426331e40f0d0ee72a6d6b550d243", + "31f6623cfa302439ac9af7033a0e6dffd09ed1be94065e895784881cce2d2650", + "c003ba67a130e837d21ccbb3e51c2efaba9934441607c2396a109d78dbc552a0", + "c9c90b92b5cf28275b0fbe5072be61c2dcf82cbf7e3947d57965b91898b5b968", + "57e0b44aa3bef0ea1f658c217d5c232b9cb92b022e0243746816465833862626", + "5230d69bc3c845f8d062c923aeaf40f2a8aa400f4c71f55288e620f66040e48d", + "3fcbd53c20ae8c8050c7f8e77a3c859a6a08d9bf71e0082ebc5dd093769ade1f", + "3a6f37888fac8ca1d117c38c960dc592d637b4e67a2c255589fdbbd1b385994b", + "a71ff27ea56456fe514749e5d26f2254fe572e8b3d5f0d5f74951e900b4aceec", + "927beb40ecc189532f3a6ad3b74a49dc16e5edb32fea2dff9aa1bf7ba72fe3db", + "2b2148527f29b95e870fd2d4e2d3a132ba5fb2946dd63fddd0d2cd0686df8822", + "073cb65b021ca5d08c2b55fa921b4d64e6686f2f0981ec5f64046b98531c6930", + "e7f330283acedf2276633148a9340cb48d2d0d554f9fe688fe524ab37d674b02" + ], + "ee6cb3e80366841c": [ + "61b63fab035d412ac36f05933961cab81c509816dd9b5e77220a2f5d8973f3fd", + "870720850b1a63611fb532ff1786f13e8bd355e541e698218986665fdd0a9da6", + "ab72d288ceae7f009473123f1e134a447e69366b0c13d9413ae10734c1ec47fa", + "595be402d0b0333b4bd64b8b27bed7fba735907fb06142cf36ca56f928e1d388", + "72dd1c9721852acba0bb8cda2fe493db50ec0243ba200b08a996ad932ccb5167", + "4a68e7fc9e0aff5f048a4fe96882f0b4dee9e9307590f7a2cc1de0b46360c9c3", + "28321ea278334040ead895035460e81d067f81b1c99aab5ea167fbaab004a101", + "0d6b741eff74cc5ece2f0db7eb78248b8881c66059240496a2a3f532701ce1cf", + "d531ba98a30135a6c23a315086f09bf20481372a0ab0261cd54b32a4a3908cab", + "bbbb27c3d639565dd685cc195093e20f8f478fac9909594bb43bc70b5e2be43a", + "6b47265060c6fa97e71ff34f46d8bddd1306464cf7a7f03b7861d8f1a8cee577", + "3ea5b6114908da5bb1a6b4dae725561de790c69d4e3d6d1566e14173126a47c3", + "f1ff552bb835d2ed0e82f335b688308ac408e6d057d0efd220d559a9b2ab3679", + "00023e4bcd7fec5593d72601cb2634111731c77474bbce0ca45c52022d03b165", + "02917bde8402eac14a493bd3cf1099ad8a870ba70cb5b33506b6742e02a88b24", + "31ccc59ec53fe75bb57c37bc8494554220bea50de4aabe5c6fceaa8ce3888e18", + "875dd05ed7e2bf1e6728dc66af53aa757677aeb985e0e8e8b8a227ce4212e224", + "4d708e150d972f5f47f8fff2ae3eff9c988ed9bf6bfb33526e8c35157f6426a9", + "81b968cf796e9edfa4a28bba65eeb1a47e457a8686ac5847ef1a3c33626f1250", + "fbe5073b203f055cda066324c6e5100c064fc0e2e43320ee01362cd017d5c7bb", + "89941fd1a25dd61388ca5fae3aa78b4e6a8cc52ef340b67f2fb1868033a73f46", + "0014b25ef7b6604a5032c5db110cde93ff67cb4ad0a21d2d25d06926b60375f4", + "4da48e030f07c390c99df2f8ef3f70a6b757201557d13fd0bf94398025b071fd", + "f9f7369552c7b9acbcdd4ddfbaac5b6e50911c8dc55809c2d6f37fb60175dcd0", + "1f5e7608835d43cb3b4a8ac57848763841766b32f5f6848a70f8d1d06ae7de3f", + "bdcaf352202c2cdc569c18e8265f01bb6de6d8213da9b8fff0b570bbe4782a9f", + "59e4a872b5cba52c9df47397a676effe6a98897003795fc68c542faa14a71472", + "f6ca32f6fb42cb63be97aac1e3acd266359d0f4b1fef435c4f3d49beaa83f217" + ], + "f2820cbdf76cc38f": [ + "988f54d67daaccfe9c0505a5cb8ae9edaaa54a9b912b29888b49889be0405580", + "452f93f607f6069915ccff764cd1b6f69019b2f9421affb3fbe3c249caa70f00", + "ab2f4e731f7467b365b9affb31c6562f42ab342479c6425feda3f75f81ffeb22", + "bc1db1845db50cbb6f9b3a607e7cc0c1f6617c9cd985992a1dd91f397f2609e7", + "4f0d6cf20f38e5f0eaf1221ca2ea80e22173c659d694103b0e98c9273148fcb5", + "3f401f92f62d174cbb6a0eab8c48d28fef4a5869c9df00193f5dc35ded18b634", + "9ccbeca7e32efd5ea919c25f9c9b0915537e0d4a6f54db599c4f30b55a70cbb3", + "366f89fc54c498296bf7254ab0045130a8e53ce7432c58b17736e8f0c49b425c", + "b25cdc025f6532efbe69f986fb4434a5888756d73ad9d9cb03f35befbb5ba1a6", + "7f01a61661044d58f854ca1af131b67e389b6d2b44f7eea78881344011a1b0b6", + "3f482986a20d0e92bb84568535676288e259340d34dd4f26dc57562ce9eef189", + "2f552a2fa3ec5709c05aec21773c64e723e80db2f9f81e9864592651aebbfe35", + "cf888f083bc9b0ee7f0a1a50d878dec6b3fa0a7d3f70df9c1937a66136906f92", + "e139856edd10d95afa156087eef7bb42a009ad4ded239552692c2d1a588139c1", + "09cd3b083110c55bc1c0f725aaf26cb2c9b2d77c08dda6d960e98377fa2f66a7", + "4f07cebb97b5bbf26b4d0f49f59742eb3c0a8255537e35f640e2cfed81f9d8ab", + "c384512b556ee689983a727215a202efbbc130347488ad95bade9cadec4f33c8", + "8f05e246164061d1ef9b092cae0c3c672f0843853ee9a7492c6e337e1f0f8307", + "60f76e96434e61d666144089664f85c7e3afeffc1f8e6281e4fc78455efafaee" + ], + "f2e10276766b1866": [ + "51a124b452983bae4c7d25f0045c357dff97ff0ca73dd5c203e61472be3814fa", + "81e892047f8c320bb62c7e2d8c57b27c4b4cb814715b7adc4b95ed60eb1bae2e", + "3b13fb6ee9465d1f3c676031084953ef96ddc31cde55f4e03140c1cea597754f", + "9110b00d35630ee32861f5bb8fa9efe4056722729664ea5ff2b585f4aa0e297e", + "751db9ab3daafaf09648b5d3dfaf84aaba0ea1d6f86a41018779b9a59defbc43", + "347503c34d885729a55ea9518dfaeaa46b6c0300cb82cff29ae8a29d05cb17c4", + "65e890dc302fb80c946328b3cc206c40e3551d625222ce5de83ab5f0b7c8606f", + "88d775b6bf57609ac0f815c331fa9bdb7cd2840e5cd65f4c46493f0a98420731", + "83b9c37e7bb1da515a60f42967f9b2eda787fbfc68136e022c2e5fdf1bcc4491", + "81521dc35781c0e7d328b16e4dcb1ac59c059358e95f2e7deadb8621f0b9e393", + "b2f72c44fa2b0e88a5d19268da2b2332d40059843b41946ad5cfc5f2cd1b281a", + "bc418eed174cb39310cb35c54c3d19701464d725dfbb7f6de50b3d8c81f5f8c6", + "707e297294637785f2f87ed70cef6d3ea0a9b3d3f1d208ced2ae66014da3d7f4", + "4f01c03b6321a249be141c86a7cbd407173eb869f92c920600c3619f7228b547", + "d0594ed0a3a5da54ca3eaab6ee41481aac3eea9f14cfd4d5dfbefe1ce59a6a5c", + "e9b489568d0e10d0e98188afaa28c74c751e4defe52919c60df765459a1c9cc2", + "18c1f633055ab0778d7829433e93236cf2b4fc6e53cc98e00170c0e6419df3e7", + "e39e46a5a09992915bc555a737bd24b37005bd6ad78e2cb6117130b0199766ad", + "62aaf62ff346ed083f9f82866d76e83fb1c57344ee82227340c4be86d190e110", + "2c8c3062b4f73e546d163185527cb702d5e74a2c0466b54123423a94fa503a15", + "51f55a47eeac590d50c87ba669c769aad37e922d562f019bb7d429c1208235c8", + "f1fa44def1986d0cb563765e5f02e6b88c2133108ca84905671836dc2b181cc2", + "c88c2637d0fffa3872dd073a5e4cdb34e0605df54002c7f0ea03b47365d3bc6e", + "2e7ed449f221146998386279e8b372999dc6663e1f01413cd02f86e732b6eeb1", + "499a0b5af3eebb79555369cbbf788817708ee737381a65b9d190a4d0be5f95b3", + "3b8bc4ab3ef27739a88f2aa1afb361e47989d303db95f5717a48763f3f0dea6b" + ], + "f5e2e0f585e7643f": [ + "f0f0a2ef9318fd4146b82a883b675d6c0340c7518cdb9c8b2afc7ff6e6edb3f7", + "76c6df182ae6c6a454fb79052445cd2eead8373a716d93b6539a79c4b780ef1c", + "870e776dd93f812c553f6694f3abc6328cf606675fb072fc66b0bcdc8a5ccbaf", + "beb27c634ad198a471f11793f14fbfa76360422327b54965b66400de377859f2", + "8e47c789b380e3008e822be85e68f3601081e393dd04ab908d574570c7ed0efd", + "ef929817307091d396903e04379a74764d218130e2a6ab7cb55e106d8901b146", + "c8fc623c5d31a77cf6ced928d33088043a4a54df19c997f41f5caa8e07570391" + ], + "f61ad5cff4224918": [ + "e386c15c16e052e4afe6ae79cea354b406191b24f4a45d1e1b91a520e24a2a4c", + "aa231c33180bdb250728bf72a81ae0b380d80720eef0073969bfdd63fe7a8980", + "0ce5e19feb68886fa51f2fa3e08ea9c6e63a3d0e8cea645bbbeaca3bf8036485", + "1eed95f401db3a8b63994353183be622e4b6f65c866820fc371142ac7f4d423a", + "5e49da1eb0b7d33bc45914ded2406482e71a38b64c4f0da9a4c00dbcbc589b05", + "fcb357b80c002feed6535c0698317877c6d368842a99fbc7589a5233027b7b23", + "b9263791a815606ed12bcf899c2849c3274d065d7a7f7d4f69013f45d18562e6", + "650c9e89d1ae7d6d6d6562f337d93635e3d944513478754d426200627e323f96", + "c1cf5809573f33f1cf900ca0b292b1f0f9104cfc95be3e13ee10a2df292bca6e", + "733a6be9e19da24879337034119513f152e26287c683d6d61236254fb6076e14", + "b48b1a0abe8d6a3a00ab3947c0f5f1ee0b893d31f303a57c07837dd86ee3d244", + "bb4bdabe825a80472fbad5b31143a744b98dbe05748714a99be77cd2dd1d2357", + "cbe417865128c499549127889a335f1d06ff2532423dc8872b40b4ca64f6fc3a", + "2f1aca35deeaa030fce3ccd9ff8debaad220e19bcb4601be96b3c3358c8acecd", + "d193c3a2515c56a3afb1c713d6e8208198de23e7dcf5ca73a5963e63fe5d1f19", + "6e269c589226253ad961b7f1dc3b1942bdecab45226cd3605efdb04cbbbc0555", + "b02b0b69fe406afaa10b518d9d8f7f52af81770d372083d07c232d7872eafb5d", + "504eabfd14b03a836509a0f20235daf34b766aee105befb2b016273ffac026be", + "de90164afb8aa5296b367387e842ede3a5c0a56284665d25964f4b6719bfaca6", + "caa0a79fa3fa3797cdea50c5099bc876eec28f4f3dc4eecd4919b1de3845598a", + "506ff7c31e5090c66afebff2b9ad9bb3dd96c5a722cd1bc32b336c42e7952362", + "9a9bbcc91cb23cfb8b40b3d7d3500f29795403a5313f88adfc1060b840a609f8", + "c5539610e4c5bde69e57ad230bd322d887ad7a07d8ed4d4239204f8b73f0a950", + "5afc3082a8709a0a57a56022f761e5d509c10ec1314af16f7e0a59a9d29cfa95", + "3293cf80a0640060d21d481be9c2216cf549ca64148ee7c1bdd3091467222a18", + "28b3b83af5c6b5ae5f182f6690e4893df1bf1cd30de15b02c3050927c9545fd2", + "ab5115a2871f86c0716451ed8d4457b79d47ac65b0ddec0677fb4018f2733ead", + "67acc7c749b63b93d53181c1d3b7011aa9591ad289fb7849266bc3e56f1f0123", + "7f902b93bd07dcb7c0a2515a99406c3511f9c5443c1d32737733d505c0ec317c", + "cba6941b5cb6eacfdcd6355859c7c644158c5127cc75f3af0a7ba24ff538f1e8", + "560041492eb69f32cbee9e149d6ca62fa616fdc8cf234e7bd1059ae40070de47", + "17e8722316b3524963dcda429460731396a3ae8f6a1c2d8af167c4afc2b0c950", + "fcf9c1f0f2b918bac70d50832af6bbde0665fa73c74a8ea70f485ddaa3de8526", + "2f1229da0af32303b354e16abcdcf85f829f1087d36994f665d11d0d1e59090b", + "cc11819cae4d0743a0425186b9ae5311376456f0e6708879739e848ce7f1d75c", + "d66ef1dcb014e499e29d8fb7dfb320447dd6dbd1333024f1cf20c6ca58b7d0ba", + "2c5812e4cea122a792783bf61cf2f6c5a6c48c4a5fd860d6476466b220ec4926", + "0eae941037e4b4633b9ac67afce6518b8bda761543c92be46bf565f9f065a1f0", + "3608d6252ffed11e9013cef1ad4d31a91c1d5b8245f71dd6ba39e84e468690c1", + "2a9a8b4bd7223992b13c5e987efc057ae070368383645144ccd3c3cf48421ad7", + "fc694dea1bc249e09141dbb3db6d2b0ef7be091bdd1cf3633238655ce0bde0df", + "27f310a8f869a8d2779165fdc0cbe448b9571d63862addb84ff176256db61c46", + "7611b59db91ee581928ae15064cb2111f99b4533f9c9f81ba135589837e7a2af", + "03045487406a1594a7a339eb82c0d22e95074f9de2a779759de9a83b57b3a1f3", + "2b43524cff365b3de526604b9457abd4d3f022da2698007c5b39c1933b867883", + "ceb945b011e0121d7254b8368ed4e26cf6d714e0a9e2b42f0eaa66eddc53f009", + "06693aa892f1501026c72562727d82f0c21a913a4a766d822dc03d66132e3a12", + "373c5e943cf9104970ee9a7e4f6eac51c469f02efa648eb9b1b7f6cb39f471e0", + "5e71e4648a9515d92b1d940e6e2b5baa20acdc1f94928d42a6a9e1dc02c73d97", + "96c4e95e37412fdf03fbbe234dfb915ac07b6baf0f4829a7cbf3c4bcdd7b16cd", + "e90bbd0ce9b2584cea6aafd5ac04d761182678f6ebd40f20e9157a839c8f8cf3", + "9a9a5ffb7d53d856a16a10a42fe8ff56004cf8394067532127c2dc413fc359df", + "1d141deac17b004cfa8ef8513a1483d19d1e703ca4a2a44abc210f99edb96fff", + "f03d1c02d0bc1f6978a6350a043518daa06cb89f5cd31fd8f1145b66cc6278d3", + "3a90b0692bc4e6fb368eb7251d8e09d3c050f03ddd52f8a000e6bd6173f41e02", + "7fa503f71e3c0127175cfd2199bad5426f2f9c000283f22e4427163de8c576e6", + "8491e8eaf8de962eb39a2ccc69d9128e91009520354a4ae622cab46b6974b79f", + "44cfe9dc05a143437c49f9494c439db7f001bc7a269a7e58adce0d58a7b1bdf5", + "64b8cda44fced5e59d8e28c57fcf2247e40f80b7e278afe6c1baddc45b2abda1", + "d10dcb81239c8cee608f72e496777a7842589001b3290d6cbab95b9109cbbcc2", + "a312e4a1cb67031dd3cddb49411abf8a5b4ca6dcdf970daad5df5a664e1b1994", + "21a2ba6daefca8796649dd0397517bafe29a27f5904b636037e608af702fea6e", + "d58304bddc963db0d99baea8ebf466f354e17d2ea49ca1449f225821b69ed1f8", + "624870cb2698f60e2cd692bdff3f79c384fa39623a917d2a8a8e54cf98d9fe2f", + "5f1bf3778d04e229394ba3fb67ddb455edfee612b0ec58dd59b9cac82ae0993a", + "27c769eb4c03ba42ac8a9d9e9a228a571852c03c1a2f80d37dc9a64b3abfac99", + "15242eba8c3bd5613ba47840401cf234bbbdab18d3d85baa867a9db3611a321f", + "cf0fbf76f387b35e6efd9686a0e470d2ee97755e2328b318d3b9dd9bef80ecfe", + "18840a0191d7afc0cc84275e30979dd16b0eb02478f7e7f34ee82b8a6af53366", + "45c143883dda01cb392f191aca8220931d34f9e9de5a51353e9c58db02953f12", + "76b47ac9d91d12466b0484746f24ca1fbd386c8b6ea9a60ee2d7bf3e26bc82d6", + "8404ebd69b71355afa83b88c9e8263fd9c49e84bfac6e2d8fef17b650e5534e9", + "2c6aa5b9b1e339cdd9f22e383f21d0d9a29d392c26feffc7700f4563506a88b8", + "d7e77264816c674e55d9a48d5c019bb03c0f8f7b934bf83c133a04a2df4a33a0", + "bc3e8ba75f027ad586507765313f1d3fe679dc783d7fd63a8bd43940b4aa9018", + "37f4105fbff663a3017f49ebb4073b465ca84b8e33dd078d6d696ba23117aaa2", + "0d7ac15c8954e35a21dff4931ea2e7b4d304f42ded9019c0ef41ea823693f4ab", + "ca7bc3ad18e45b57b4307229ed7192f17baa7f1ead214156f80886989160ba55", + "8c5f14b0ecb189bc6c24b20f505b954d9166dd111d2e45d89ecf9822c5fa88de", + "6d2e7ef0a41c55e997620861783cb1a1951bf312bf249d47db3c7833a1aad591", + "f857fd1d457be8a87c892a4d2f6429ef9ae6a53454c23faa79d123bbd41d39b0", + "71100352651d34da65ca100f287570ebd74720fcd5d2bc29960c802ceb4fc50c", + "11c6174305efe722c4b34965adac095b39dc54d4c9351bad066ea4544afb22d1", + "24052053721bbe905f75732e0b0cbb3359b5248573c0a9e0d152f4f8077ac8fc", + "1eb78d293fdd983cb141662900813c99491b463261ff68595d36a757f1a4b030", + "f0e9429c359af9d52bb7c8837888b9a731ce6dadf0494a10245fefaea9664afb", + "e610d02ad89bb222b27560295b93bebd7b4d39edf6af89df650d40e135ea2667", + "37ecc8ceb6e0caa5ed69663ec434fd66460cc9a33b193fbb3c2d3548fa720b9c", + "e74377d2f5a06fc739f41358747055ae664142b971a7b86c301e7db313eefce8", + "1914d98ef9f3d232024bf47c3d2b3738e5a1788228fc783d14948418ed223ba3", + "678111bd5e7218bc5c8496d7843fa99e9530242e6147777167f62c5fdf03364a", + "fb95afafba6d132298f95e50a0042b9b13a682c1e992529f5915a30d61845430", + "e085f5053d0a6f651daa2e4242c508c65ada66921e0bf35f50d50929ec02941a", + "8cedaece227c0de22722523765523c6f28f0f08fa5069189510dcca7b2cd471c", + "559ccfc4ee063c96d7c9c88924fe5562b93ed9fc2860729aa2a53ebf2a12e413", + "cde288b63aded9e58bb46c04990b4ddeca531ace1310c1e5a1a248da40caba8b", + "348cd0aaceddb79064d5c6c2997153af626cf9acf56aca5cb5da3879869a36fe", + "8c90a0c5472f70c5daa7e99704d1d8a249b8587981ce9c601df6fa2791b15c1f", + "26cf45dd148b6f5c84ae1069a4fc4a99e38ff2bd9390e1b553aae10770c73268", + "9f4690e6caf30385cd28f4bd89cedd35cb1d2ce3014ba2b0cd458a1719b3173e", + "ac4c3d8e273bd0dc2e9188f1824eb1027f0d5107d347ab16a8b29daf5ef81da7", + "032e94deefdd5a1866b6565bd6f7a3f8a8bc10b2a95501e636ac8ca1d6e38004", + "bfad2a0380ead3e3bd3153e0f24ae892c0d3d7ce6997313dceefb376c7479077", + "d046d98f4652572bdc7f1884abfc37ed862052fd8e911b71570ce8d44ec85ad8", + "99cbca7b075e5e8d7733391efe1b99edb975b659c8dac6a6906429373d659b25", + "d62ab30cd4be6d93e1f63fa5da8428d27eed74606a914855b64ca82358738d4c", + "231d30f6c48efa7e6ce5933671763bcb0cc68d3a3625f69afd14063cbaad7320", + "3529a06e429e850b91d52644efc3fdfba5fc7009c8ead167caa70263cfdd50fd" + ], + "f6241e8d0941418d": [ + "3a41368947de2b62920101b0669bf3285ce13dba95e9abd59bce1ab0d2230d54", + "05713e07c7f75271ab6fe9880aab8e9eee052db0d30c8c327322e75f2a42ec97", + "496709e839b4bb023b02164ddbabe0fef602c33faaa6dd1b394b7a541ec2efa0", + "14c91981e291e8fadc746241c6490d06533875cbfb5778d97f931676be57c277" + ], + "f634cd8e1616e3f8": [ + "44f735a445f4cde1326a48a6c7265ed73fc9e1daff80b70c662514e815df3ba6", + "9fa3abc35ba965353165a87f50389e07b7c636c7b29ff92391671e8ae6de8e32", + "2e51ee612536784b675d432dc440718530f9719980801f15e6f678430a6f12aa", + "fd6673d62ac0ff9ffbda16f7f7cba247aca38b365511b2659d09b403838926b9", + "27d60b5ac75eb16c7b11a079cbdb05441fd97bb8bcbb0d2f33c45518924ae457", + "7d86dc629bccf50d1b12e9f468f94ba4ecf89b449257a35b703ff58b33ece942", + "9a3d3676238a170732157aa1ee0ea2965fb394a7c5e523014b69a61eb60b1a62", + "0f9d8cd04d577b460a44e46d35132b4a8deee02027092bc7f9572590f9773eb8", + "3fd211f8760e082b8a5f36641fad6e10854fa6404b4b43950db64c6c0233ebf4", + "72d6e5c26893cf88ff224676c18261aa0dc46a86858813c90481d08afdb287d2" + ], + "f6392964bad3fc09": [ + "182a1f582e287d3e50af0a43465886feed5be6cf4b481f472f249abd6d77fe21", + "6db9e26300e5e545b212c19ebeb0f8a95a764cb0d73008d896f5e08cd3c207f2", + "476fc2cfcd4994ba4afae4e5bbc67ef15c145bb02e1a14ffbceb94523217f9ea", + "cdf482cabfbce8cbfc0ece4ee49f1c7e79dc338e65f225ec4d04495daa80d438", + "698461f39803b8f9ef220546a8644a2c08b17352fa2fe5115e7ea76a2a9f4566", + "7d88f0de8d627d40122651fcc5c214118f443200a50532e55eccc1315076d9ea", + "f6844e9cccd8caa05f40ae37d09c767595f1c8d5174a3c29c26b815812f3390d", + "56e4e52ccad5783bf2b4dd20be81507c0d60fa12e808781858ff65a847e70bc4", + "c968f861b1902c40546b6bbac0815a577593435c454527c46bd71556d21e6d43", + "d1214234f9d7e0f647fea0b63be8610a6fc5133e4f99d7c412918147143ec824", + "68c110c41fdc36717421fbd25b6637c342c88c62ac7b5062ebe058573835f8a6", + "0d073c50f776bd006a08d8a9493bd506952f0e951c12e79a7b688852d7854297", + "9af77ee77c4ace011daf19582f95d1a1dec5ca43fdbee5ae36d360a5ee95902f", + "1fcae3aa28c6195d2bed0cb1ce0496ce0887265f12d73afb84bdf2936576d2ca", + "44e1200c88fc75868cc182d00c97a129d159549a7ce1f113a695d0933ec47d65", + "de331264db830be9b1cccfe6ecf58b0b3f5031c5fbceb68b8b33d10a7e07d50a", + "4a5c170ad80c93d6cb07bfab3c513406cfa49155eb992808784b24cb3c9185dd", + "0d92cce5680dac24347fcba20cf47a0191418d88646b7084e9607963a032be80", + "7f2276eddcff82b7a67c3c1c80a612beffe1336c011c9dde70c1442a7e513195", + "471a1c3f643d994c6b280127951f1f75f5ac44d509f95b037e5cdeeb87dcfaec", + "ca264a40ebee7f4fe802dc9e33243e306f08fc7e271484de12730b6eded16d1f" + ], + "f68dc5b85dcaeb30": [ + "f9231db34a8632aa14b03e8decd72c58eda349d44225dece0b6b0e5eea3d489a", + "83d315679eff11a160320b6327318775a9f43437340e5102fd189cad50cc11cf", + "35c2852de1a32a0a6c5a1b6375ef9f0a070a6995f8f0c68a4d772ca6632f62b6", + "d58c8e40beb9096ed35b74e290b45eef64a880323fab236a65a06dbcf8de7ec9", + "3eb731d3763f3cb6da475673404a579e280c89800ea38951240444b8c1b7439a", + "a61e3f926f837ea3e498424c2732cd79dd3f1a178c2a135fcc75c7973a94cb64" + ], + "f838c0c446c2368e": [ + "14292fe69d7585739fca12ef7ccea836a8990b920862d010e288faebcc318499", + "6ef2600de674d70c6df4a02e8eae96399139f3965486774f54c61d29d6356974" + ], + "f9669558ff20c77e": [ + "0376795ef25fae250468e2b95fb34e179eccadf63f218bbb33adf8fa5e363d72", + "074c20c162446cc011c368c73059f5bffb67d76996a5ce057bee1e29b9c71739", + "d8e2bb825881d4e60c86232f9c757e86813a495609d810f1e6500fed1154d8df" + ], + "fa6d820f587d2f8f": [ + "6eeaefc0116d2200dedb55d74471aa6a54d78e34e9d7a47500df9127f116a839", + "912a6674ebc1fd94eea6eacbd165ae90cd34e857975b2c4736e823cc582e8aab", + "c003a2ac3511ee4a18f9cca274be1c67a481b1b445267dd053001f2bdd345ae2", + "05903d849da28a500fe6dafb4cca8cbcb24fcb85e3e7f0c4e7d1047d3c4d46c1", + "cce533fbcfb60f2cde346a6e2aaa4255994048003d16ca7b5f926eabb0674fca", + "88ef1e6705d4eb1c994c4c1fd509dd3f6ad849c23e42896b684043385a01d8ad", + "9b9e28eb760eb7960a01f2353746732875c76f8dad281bb9bf0cda243609a4bc", + "d286ab12155ac9ea520c4335f97e9a0acaa133c7d40b5710c2126056f38cba71", + "6ceb2870046f7ee3132968665a54ad811e72c20ad0bbd74cd8333e92218e3edf", + "ddfe9e0ea8df5c96c1f6e1ae859c4c99c4fbf71d40b6c2823dd8bda7487821dd", + "55c775acf875d5338084741ed48c5c5438960339289335aa155554160018bbea", + "f28a49c5bc3c3b864f606263bc43b14cece31f84cca7d0dee883e0d792f7c2e7", + "598c220510d21e641e5fd8daa87cb9d8506819116fecc4360d910d317c5a6ee7" + ], + "fcc4f78d20cceec8": [ + "47b05614e2c4538c910aa9a4441898ecdb75ff1059d7cd29c7dcf1f281dff748", + "12833fb5360d4353db2c2e038e7aae52c81d9f44bc56a4f605af758975118bc7", + "83d99a804e7c76652be212d3de4cbb29d40579433155243d53474b1448aa2796" + ] + }, + "scripts": { + "test": { + "notRun": "drives a live rail" + }, + "test:attest-mirror": { + "notRun": "drives a live rail" + }, + "test:attest-relay-loop": { + "notRun": "drives a live rail" + }, + "test:attestation:llm": { + "notRun": "drives a live rail" + }, + "test:auctions": { + "notRun": "drives a live rail" + }, + "test:boundary": { + "fileCount": 7, + "titleCount": 145, + "files": { + "test/boundary/connectionPool.boundary.js": "cfac3debaad59af7", + "test/boundary/connectors.boundary.js": "35e1c559c597c6c1", + "test/boundary/errorPropagation.boundary.js": "97e8d5878cd67efe", + "test/boundary/globalState.boundary.js": "bf1f5b80ecdbc709", + "test/boundary/identifiers.boundary.js": "b803cd5f3246785e", + "test/boundary/polling.boundary.js": "6c9c65aa8ba701b7", + "test/boundary/whereClause.boundary.js": "20a3db1f3fffc8f1" + } + }, + "test:bridge-rail": { + "notRun": "drives a live rail" + }, + "test:chaos": { + "error": "Mocha exited 1 with 1 failure(s)", + "fileCount": 12, + "titleCount": 80, + "files": { + "test/chaos/bad-psbt.chaos.js": "8eeb1c83c0b2ad8f", + "test/chaos/connector-timeout.chaos.js": "4b784fc283799ebe", + "test/chaos/db-disconnect.chaos.js": "940d00f2cbc41741", + "test/chaos/gas-bootstrap.chaos.js": "8a2b9d75522a798e", + "test/chaos/hub-failure.chaos.js": "426c208ff2814bfa", + "test/chaos/partial-config.chaos.js": "5f86f4e60022308a", + "test/chaos/pool-exhaustion.chaos.js": "3f7c6acbb9de4c16", + "test/chaos/reporter-write.chaos.js": "f68dc5b85dcaeb30", + "test/chaos/teardown-failure.chaos.js": "3ae1c3d954d314e7", + "test/chaos/unhandled-rejection.chaos.js": "95e4e3814ab62e04", + "test/chaos/utxo-race.chaos.js": "0435717b2b0530cd", + "test/chaos/wallet-race.chaos.js": "7c35a9e28fad2cb4" + } + }, + "test:chaos:quick": { + "fileCount": 4, + "titleCount": 23, + "files": { + "test/chaos/connector-timeout.chaos.js": "4b784fc283799ebe", + "test/chaos/gas-bootstrap.chaos.js": "8a2b9d75522a798e", + "test/chaos/pool-exhaustion.chaos.js": "3f7c6acbb9de4c16", + "test/chaos/teardown-failure.chaos.js": "3ae1c3d954d314e7" + } + }, + "test:codec": { + "error": "Mocha exited 1 with 1 failure(s)", + "fileCount": 2, + "titleCount": 19, + "files": { + "test/codec/field-roundtrip.test.js": "5cdc4b7c63d2fcab", + "test/codec/roundtrip.test.js": "abce93d888c40198" + } + }, + "test:compaction": { + "notRun": "drives a live rail" + }, + "test:contracts": { + "notRun": "drives a live rail" + }, + "test:cpbridge": { + "notRun": "drives a live rail" + }, + "test:e2e": { + "notRun": "drives a live rail" + }, + "test:envelope": { + "notRun": "drives a live rail" + }, + "test:federation": { + "notRun": "drives a live rail" + }, + "test:federation:all": { + "notRun": "drives a live rail" + }, + "test:federation:anchor-acceptance": { + "notRun": "drives a live rail" + }, + "test:federation:anchor-election": { + "notRun": "drives a live rail" + }, + "test:federation:dex": { + "notRun": "drives a live rail" + }, + "test:federation:llm:multi": { + "notRun": "drives a live rail" + }, + "test:federation:llm:outage": { + "notRun": "drives a live rail" + }, + "test:federation:nodeproof": { + "notRun": "drives a live rail" + }, + "test:federation:signer-wiring": { + "notRun": "drives a live rail" + }, + "test:fuzz": { + "fileCount": 5, + "titleCount": 170, + "files": { + "test/fuzz/action-messages.fuzz.js": "8df09eacc218fa15", + "test/fuzz/config-parsing.fuzz.js": "e102844ed928d4be", + "test/fuzz/connectors.fuzz.js": "3b239498ce428382", + "test/fuzz/crypto-inputs.fuzz.js": "f634cd8e1616e3f8", + "test/fuzz/db-filters.fuzz.js": "f61ad5cff4224918" + } + }, + "test:fuzz:quick": { + "fileCount": 5, + "titleCount": 170, + "files": { + "test/fuzz/action-messages.fuzz.js": "8df09eacc218fa15", + "test/fuzz/config-parsing.fuzz.js": "e102844ed928d4be", + "test/fuzz/connectors.fuzz.js": "3b239498ce428382", + "test/fuzz/crypto-inputs.fuzz.js": "f634cd8e1616e3f8", + "test/fuzz/db-filters.fuzz.js": "f61ad5cff4224918" + } + }, + "test:integration": { + "notRun": "drives a live rail" + }, + "test:integration:live": { + "notRun": "drives a live rail" + }, + "test:integration:stubbed": { + "notRun": "drives a live rail" + }, + "test:mutate": { + "notRun": "not a Mocha command (runs npx)" + }, + "test:mutate:dry-run": { + "notRun": "not a Mocha command (runs npx)" + }, + "test:mutate:integration": { + "notRun": "not a Mocha command (runs npx)" + }, + "test:parity": { + "notRun": "drives a live rail" + }, + "test:perf": { + "notRun": "drives a live rail" + }, + "test:perf:actions": { + "notRun": "drives a live rail" + }, + "test:perf:budget": { + "notRun": "drives a live rail" + }, + "test:perf:e2e": { + "notRun": "drives a live rail" + }, + "test:regression": { + "error": "Mocha exited 9 with 9 failure(s)", + "fileCount": 10, + "titleCount": 178, + "files": { + "test/regression/actions.regression.js": "a9b194a368d8fdd4", + "test/regression/bootstrap.regression.js": "da3c5c8b692d1c83", + "test/regression/config-fuzz-perf.regression.js": "b901ac2617e4ab15", + "test/regression/connectors.regression.js": "0614ca99b9ebc5e4", + "test/regression/crypto.regression.js": "df46e6b82cd9ea68", + "test/regression/database.regression.js": "d99deae25d296efa", + "test/regression/protocol-size-limits.regression.js": "51bffca6740ee9db", + "test/regression/teardown-errors.regression.js": "263745e4962da7e8", + "test/regression/transaction.regression.js": "ab9f1afc95331739", + "test/regression/xcallDeliveryLag.regression.js": "5525d69c94ddce61" + } + }, + "test:regression:p0": { + "error": "Mocha exited 9 with 9 failure(s)", + "fileCount": 7, + "titleCount": 138, + "files": { + "test/regression/bootstrap.regression.js": "da3c5c8b692d1c83", + "test/regression/connectors.regression.js": "0614ca99b9ebc5e4", + "test/regression/crypto.regression.js": "df46e6b82cd9ea68", + "test/regression/database.regression.js": "d99deae25d296efa", + "test/regression/protocol-size-limits.regression.js": "51bffca6740ee9db", + "test/regression/transaction.regression.js": "ab9f1afc95331739", + "test/regression/xcallDeliveryLag.regression.js": "5525d69c94ddce61" + } + }, + "test:regression:p0p1": { + "error": "Mocha exited 9 with 9 failure(s)", + "fileCount": 9, + "titleCount": 158, + "files": { + "test/regression/actions.regression.js": "a9b194a368d8fdd4", + "test/regression/bootstrap.regression.js": "da3c5c8b692d1c83", + "test/regression/connectors.regression.js": "0614ca99b9ebc5e4", + "test/regression/crypto.regression.js": "df46e6b82cd9ea68", + "test/regression/database.regression.js": "d99deae25d296efa", + "test/regression/protocol-size-limits.regression.js": "51bffca6740ee9db", + "test/regression/teardown-errors.regression.js": "263745e4962da7e8", + "test/regression/transaction.regression.js": "ab9f1afc95331739", + "test/regression/xcallDeliveryLag.regression.js": "5525d69c94ddce61" + } + }, + "test:rollcall": { + "notRun": "drives a live rail" + }, + "test:sdk": { + "notRun": "drives a live rail" + }, + "test:sdk:attestation": { + "notRun": "drives a live rail" + }, + "test:sdk:bet": { + "notRun": "drives a live rail" + }, + "test:sdk:bet-expiry": { + "notRun": "drives a live rail" + }, + "test:sdk:bet-latch": { + "notRun": "drives a live rail" + }, + "test:sdk:bet-parity": { + "notRun": "drives a live rail" + }, + "test:sdk:bet-reorg": { + "notRun": "drives a live rail" + }, + "test:sdk:bet-ws": { + "notRun": "drives a live rail" + }, + "test:sdk:chunked": { + "notRun": "drives a live rail" + }, + "test:sdk:chunked-clients": { + "notRun": "drives a live rail" + }, + "test:sdk:chunked-deferred": { + "notRun": "drives a live rail" + }, + "test:sdk:chunked-reorg": { + "notRun": "drives a live rail" + }, + "test:sdk:decoder": { + "notRun": "drives a live rail" + }, + "test:sdk:dex-reorg": { + "notRun": "drives a live rail" + }, + "test:sdk:mcp": { + "notRun": "drives a live rail" + }, + "test:sdk:multileg": { + "notRun": "drives a live rail" + }, + "test:sdk:tickerid": { + "notRun": "drives a live rail" + }, + "test:sdk:vote": { + "notRun": "drives a live rail" + }, + "test:sdk:vote-reorg": { + "notRun": "drives a live rail" + }, + "test:sdk:x402": { + "notRun": "drives a live rail" + }, + "test:sdk:xcall-reorg": { + "notRun": "drives a live rail" + }, + "test:security": { + "notRun": "drives a live rail" + }, + "test:smoke": { + "notRun": "drives a live rail" + }, + "test:unit": { + "error": "Mocha exited 2 with 2 failure(s)", + "fileCount": 114, + "titleCount": 1926, + "files": { + "test/unit/adaptive_wait.test.js": "4b8f756a8633228c", + "test/unit/anchor_version_helper.test.js": "7d83087e7be513c4", + "test/unit/attest_legacy_response_path.test.js": "44c2420dbbc388f2", + "test/unit/ci/ci_sibling_ref_expression.test.js": "0a9caf6dc040aee5", + "test/unit/coins_conformance.test.js": "2442df610ebb6911", + "test/unit/crypto_helper.test.js": "59323bf0d8e4807f", + "test/unit/fiat_dispenser_pair_isolation.test.js": "39178eed4d197640", + "test/unit/helpers/addressHelper.test.js": "b7b57b8f1422e65f", + "test/unit/helpers/airdropHelper.test.js": "4fc64f2fb69c53dc", + "test/unit/helpers/attestMirrorVenue.test.js": "c9b467218c41f779", + "test/unit/helpers/attestationVenueGuard.test.js": "166aa79083232e28", + "test/unit/helpers/barrierFamilyDrive.test.js": "ca34598768545fbb", + "test/unit/helpers/barrierFamilyFixture.test.js": "1a9b6f6e59abb83e", + "test/unit/helpers/barrierFamilyRows.test.js": "ebbb43951613f2e7", + "test/unit/helpers/batchHelper.test.js": "63382a678c25705e", + "test/unit/helpers/bridgeRailMinerPause.test.js": "45177e61109dcb64", + "test/unit/helpers/bridgeRailVenue.test.js": "a760972a33410b64", + "test/unit/helpers/bridge_credit_attribution.test.js": "671ceaebe86a269a", + "test/unit/helpers/bridge_rail_topology.test.js": "38b3b8e5d603ac82", + "test/unit/helpers/broadcastHelper.test.js": "97622a515a4cba5a", + "test/unit/helpers/byzantineFaultsArchive.test.js": "97fb04bf0f0c2530", + "test/unit/helpers/callbackHelper.test.js": "f838c0c446c2368e", + "test/unit/helpers/chainRail.test/fee_destination.test.js": "5d08969aaed0af20", + "test/unit/helpers/coinpayHelper.test.js": "8bb189e21576dc44", + "test/unit/helpers/consensusWait.test.js": "57e1e8657a2e07b0", + "test/unit/helpers/deliveryWindow.test.js": "7a424d79955da9a6", + "test/unit/helpers/destroyHelper.test.js": "bffb9837c17e4639", + "test/unit/helpers/dispenserHelper.test.js": "7dba094d40eea46d", + "test/unit/helpers/dividendHelper.test.js": "1e287ecb2731014e", + "test/unit/helpers/dogeSetupPriceSeed.test.js": "193916cd8d823305", + "test/unit/helpers/fileHelper.test.js": "f9669558ff20c77e", + "test/unit/helpers/gasHelper.test.js": "f6392964bad3fc09", + "test/unit/helpers/hubDbMirrorProxy.test.js": "a010df99b689ca28", + "test/unit/helpers/hubMirrorTopology.test.js": "dea555703614a199", + "test/unit/helpers/issueHelper.test.js": "a7f8e944f21dfc97", + "test/unit/helpers/linkHelper.test.js": "4ef7880e9d34b168", + "test/unit/helpers/listHelper.test.js": "038b8d2e4c1f4ff4", + "test/unit/helpers/messageHelper.test.js": "8e022b537e71204c", + "test/unit/helpers/mintHelper.test.js": "7c4ac020d539fc2c", + "test/unit/helpers/mirrorDrillFixture.test.js": "1bc1583df3d438fc", + "test/unit/helpers/mirrorDrillWaits.test.js": "314a51b634b5b81f", + "test/unit/helpers/multiValidatorHubDefaults.test.js": "473820d8285cb3c1", + "test/unit/helpers/multiValidatorHubPorts.test.js": "90ba2ac26915e2d5", + "test/unit/helpers/multiValidatorHubSubsystems.test.js": "be7b89a560ce199f", + "test/unit/helpers/nativeFeeHelper.test.js": "a3fa5837de772b59", + "test/unit/helpers/orderHelper.test.js": "27acbc623681ea65", + "test/unit/helpers/priceSeedAnchor.test.js": "937bd84108209f4c", + "test/unit/helpers/priceSeedRouting.test.js": "a9cf6b2aa2f631ce", + "test/unit/helpers/rollcallFederationPrecondition.test.js": "c761e8dfc24dfda9", + "test/unit/helpers/rollcallHelper.wire-and-shadow.test.js": "180c7e3c86a4f5ca", + "test/unit/helpers/seedSentinelClear.test.js": "ad13aa7faa100196", + "test/unit/helpers/seedSentinelCoverage.test.js": "b559b20801a83936", + "test/unit/helpers/sendHelper.test.js": "1355d6612df217eb", + "test/unit/helpers/sleepHelper.test.js": "62d413d922c4051e", + "test/unit/helpers/stakeHelper.test.js": "f2820cbdf76cc38f", + "test/unit/helpers/stakeTeardown.test.js": "3a5bd5071c5ba86b", + "test/unit/helpers/swapHelper.test.js": "14076a4fd268af82", + "test/unit/helpers/sweepHelper.test.js": "b9f408c847a7b574", + "test/unit/helpers/vmHelper.test.js": "38974d9d48c965f2", + "test/unit/helpers/waitSwallow.test.js": "0caaab991148ee3a", + "test/unit/hub_connector_consensus_hash.test.js": "0c1dd56a6d76e703", + "test/unit/initialCheck.test.js": "a4ebe90fd24baf2b", + "test/unit/oracleBatchBarrierEscape.test.js": "f2e10276766b1866", + "test/unit/oracle_batch_barrier_testnet_alignment.test.js": "09e725b3700796af", + "test/unit/oracle_batch_barrier_testnet_observe.test.js": "841ab8c8862e4663", + "test/unit/oracle_batch_replay_live_chain.test.js": "e718e78fcdcf1cea", + "test/unit/parity_ltc_pin.test.js": "fcc4f78d20cceec8", + "test/unit/parity_order_expire.test.js": "91644fb551269942", + "test/unit/scripts/action_suite_count.test.js": "be69c99eefb720ee", + "test/unit/scripts/attest_mirror_runner.test.js": "c95cbba1c2509797", + "test/unit/scripts/attest_mirror_stack_driver.test.js": "7c717548147468f5", + "test/unit/scripts/check_mirror_pendings.test.js": "7728cf9a5cb6bb89", + "test/unit/scripts/check_request_row_wait.test.js": "da61811452bc755c", + "test/unit/scripts/clear_seed_sentinels_script.test.js": "fa6d820f587d2f8f", + "test/unit/scripts/live_tier_roster.test.js": "cb81b4f72eed0dbf", + "test/unit/scripts/mutationReport.test.js": "16911dd1f12b2180", + "test/unit/scripts/prepare_bf6_mixed_hub.test.js": "9eaf774059da7414", + "test/unit/scripts/sleepFlakeScanner.test.js": "540d99311338c4c1", + "test/unit/scripts/stake_teardown_gate.test.js": "6426fafb3b3b1641", + "test/unit/scripts/wait_attest_mirror_stack.test.js": "23acb049b6a89464", + "test/unit/sdk/template_deploy_plan.test.js": "bcfa921243320e56", + "test/unit/security/configuration/dependency_advisories.test.js": "5cc2b8143bdd5808", + "test/unit/security/configuration/history_path_leak.test.js": "6a314765e623ea65", + "test/unit/security/configuration/production_audit_advisories.test.js": "ac955d8e961425c6", + "test/unit/security/configuration/sdk_transitive_advisories.test.js": "286a3bf25e4f29cb", + "test/unit/security/configuration/sibling-tree-advisories.test.js": "1ed51828ad4dcf06", + "test/unit/seed_contract_resume_by_code_hash.test.js": "7bf83adbc320d05e", + "test/unit/seed_contract_stale_state_file.test.js": "f5e2e0f585e7643f", + "test/unit/seed_contract_state_helpers.test.js": "bbb6b28f02d490a6", + "test/unit/seed_escrow_state_helpers.test.js": "b8de3438882c53e2", + "test/unit/seed_execute_has_no_gas_limit.test.js": "f6241e8d0941418d", + "test/unit/seed_explorer_document_shape.test.js": "8c8b5aed583e6ffb", + "test/unit/seed_fill_resumes_from_chain.test.js": "016f5b2846038a24", + "test/unit/seed_submit_timeout_recheck.test.js": "64b8ffa12f55c1f0", + "test/unit/sibling_coverage.test.js": "d8d1e52267ea606f", + "test/unit/spvSeedContract.test.js": "de902594699a4005", + "test/unit/src/CryptoNetworks.test.js": "1c51cd59548a6fe2", + "test/unit/src/XChainHubConnector.test.js": "2857931af5513c04", + "test/unit/src/XChainIndexerConnector.test.js": "0739e2cf1ce30670", + "test/unit/src/blockchain_connector.test.js": "b3ae9805497c1ccc", + "test/unit/src/config.test.js": "209e4312e414d195", + "test/unit/src/db.test.js": "1f55a4be46b26fa4", + "test/unit/src/db_driver_binding.test.js": "e035a7a88d487a4c", + "test/unit/src/regtest_miner_connector.test.js": "ee6cb3e80366841c", + "test/unit/src/regtest_miner_idle_mine.test.js": "aa436be15dac70dc", + "test/unit/src/xchain_encoder_connector.test.js": "b7c26853f8d96da8", + "test/unit/src/xchain_explorer_connector.test.js": "0746a74a6397853f", + "test/unit/src/xchain_utxo_tracker_connector.test.js": "72dcd716d91b3056", + "test/unit/suite_parses.test.js": "c9b9a5b9ea085713", + "test/unit/test_file_reachability.test.js": "57fc7ee37107dee4", + "test/unit/tools/reseedAttestationRoster.test.js": "b8e7fa5d163a013b", + "test/unit/transaction_helper.test.js": "a69710b2a2ac0051", + "test/unit/vm_false_green.test.js": "d22b7ae29f618575", + "test/unit/xchain_price_seed_guard.test.js": "59bdb6a0868e291e" + } + }, + "test:vm": { + "notRun": "drives a live rail" + } + } +} diff --git a/bin/pins/at1-wall-times.json b/bin/pins/at1-wall-times.json new file mode 100644 index 00000000..bda83334 --- /dev/null +++ b/bin/pins/at1-wall-times.json @@ -0,0 +1,340 @@ +{ + "timingMethod": "UTC timestamps captured around each Mocha process", + "node": "v22.22.3", + "scripts": { + "test": { + "command": "npm run test", + "notRun": "drives a live rail" + }, + "test:attest-mirror": { + "command": "npm run test:attest-mirror", + "notRun": "drives a live rail" + }, + "test:attest-relay-loop": { + "command": "npm run test:attest-relay-loop", + "notRun": "drives a live rail" + }, + "test:attestation:llm": { + "command": "npm run test:attestation:llm", + "notRun": "drives a live rail" + }, + "test:auctions": { + "command": "npm run test:auctions", + "notRun": "drives a live rail" + }, + "test:boundary": { + "command": "npm run test:boundary", + "startedAt": "2026-09-21T03:44:15.536Z", + "finishedAt": "2026-09-21T03:44:17.698Z", + "wallTimeSeconds": 2.162, + "files": 7, + "titles": 145, + "passing": 145, + "pending": 0, + "failing": 0 + }, + "test:bridge-rail": { + "command": "npm run test:bridge-rail", + "notRun": "drives a live rail" + }, + "test:chaos": { + "command": "npm run test:chaos", + "startedAt": "2026-09-21T03:44:17.701Z", + "finishedAt": "2026-09-21T03:44:18.916Z", + "wallTimeSeconds": 1.215, + "files": 12, + "titles": 80, + "passing": 79, + "pending": 0, + "failing": 1 + }, + "test:chaos:quick": { + "command": "npm run test:chaos:quick", + "startedAt": "2026-09-21T03:44:18.917Z", + "finishedAt": "2026-09-21T03:44:19.731Z", + "wallTimeSeconds": 0.814, + "files": 4, + "titles": 23, + "passing": 23, + "pending": 0, + "failing": 0 + }, + "test:codec": { + "command": "npm run test:codec", + "startedAt": "2026-09-21T03:44:19.731Z", + "finishedAt": "2026-09-21T03:44:20.713Z", + "wallTimeSeconds": 0.982, + "files": 2, + "titles": 19, + "passing": 18, + "pending": 0, + "failing": 1 + }, + "test:compaction": { + "command": "npm run test:compaction", + "notRun": "drives a live rail" + }, + "test:contracts": { + "command": "npm run test:contracts", + "notRun": "drives a live rail" + }, + "test:cpbridge": { + "command": "npm run test:cpbridge", + "notRun": "drives a live rail" + }, + "test:e2e": { + "command": "npm run test:e2e", + "notRun": "drives a live rail" + }, + "test:envelope": { + "command": "npm run test:envelope", + "notRun": "drives a live rail" + }, + "test:federation": { + "command": "npm run test:federation", + "notRun": "drives a live rail" + }, + "test:federation:all": { + "command": "npm run test:federation:all", + "notRun": "drives a live rail" + }, + "test:federation:anchor-acceptance": { + "command": "npm run test:federation:anchor-acceptance", + "notRun": "drives a live rail" + }, + "test:federation:anchor-election": { + "command": "npm run test:federation:anchor-election", + "notRun": "drives a live rail" + }, + "test:federation:dex": { + "command": "npm run test:federation:dex", + "notRun": "drives a live rail" + }, + "test:federation:llm:multi": { + "command": "npm run test:federation:llm:multi", + "notRun": "drives a live rail" + }, + "test:federation:llm:outage": { + "command": "npm run test:federation:llm:outage", + "notRun": "drives a live rail" + }, + "test:federation:nodeproof": { + "command": "npm run test:federation:nodeproof", + "notRun": "drives a live rail" + }, + "test:federation:signer-wiring": { + "command": "npm run test:federation:signer-wiring", + "notRun": "drives a live rail" + }, + "test:fuzz": { + "command": "npm run test:fuzz", + "startedAt": "2026-09-21T03:44:20.713Z", + "finishedAt": "2026-09-21T03:45:13.347Z", + "wallTimeSeconds": 52.634, + "files": 5, + "titles": 170, + "passing": 170, + "pending": 0, + "failing": 0 + }, + "test:fuzz:quick": { + "command": "npm run test:fuzz:quick", + "startedAt": "2026-09-21T03:45:13.348Z", + "finishedAt": "2026-09-21T03:45:55.411Z", + "wallTimeSeconds": 42.063, + "files": 5, + "titles": 170, + "passing": 170, + "pending": 0, + "failing": 0 + }, + "test:integration": { + "command": "npm run test:integration", + "notRun": "drives a live rail" + }, + "test:integration:live": { + "command": "npm run test:integration:live", + "notRun": "drives a live rail" + }, + "test:integration:stubbed": { + "command": "npm run test:integration:stubbed", + "notRun": "drives a live rail" + }, + "test:mutate": { + "command": "npm run test:mutate", + "notRun": "not a Mocha command (runs npx)" + }, + "test:mutate:dry-run": { + "command": "npm run test:mutate:dry-run", + "notRun": "not a Mocha command (runs npx)" + }, + "test:mutate:integration": { + "command": "npm run test:mutate:integration", + "notRun": "not a Mocha command (runs npx)" + }, + "test:parity": { + "command": "npm run test:parity", + "notRun": "drives a live rail" + }, + "test:perf": { + "command": "npm run test:perf", + "notRun": "drives a live rail" + }, + "test:perf:actions": { + "command": "npm run test:perf:actions", + "notRun": "drives a live rail" + }, + "test:perf:budget": { + "command": "npm run test:perf:budget", + "notRun": "drives a live rail" + }, + "test:perf:e2e": { + "command": "npm run test:perf:e2e", + "notRun": "drives a live rail" + }, + "test:regression": { + "command": "npm run test:regression", + "startedAt": "2026-09-21T03:45:55.412Z", + "finishedAt": "2026-09-21T03:45:59.554Z", + "wallTimeSeconds": 4.142, + "files": 10, + "titles": 178, + "passing": 169, + "pending": 0, + "failing": 9 + }, + "test:regression:p0": { + "command": "npm run test:regression:p0", + "startedAt": "2026-09-21T03:45:59.557Z", + "finishedAt": "2026-09-21T03:46:01.660Z", + "wallTimeSeconds": 2.103, + "files": 7, + "titles": 138, + "passing": 129, + "pending": 0, + "failing": 9 + }, + "test:regression:p0p1": { + "command": "npm run test:regression:p0p1", + "startedAt": "2026-09-21T03:46:01.664Z", + "finishedAt": "2026-09-21T03:46:03.786Z", + "wallTimeSeconds": 2.122, + "files": 9, + "titles": 158, + "passing": 149, + "pending": 0, + "failing": 9 + }, + "test:rollcall": { + "command": "npm run test:rollcall", + "notRun": "drives a live rail" + }, + "test:sdk": { + "command": "npm run test:sdk", + "notRun": "drives a live rail" + }, + "test:sdk:attestation": { + "command": "npm run test:sdk:attestation", + "notRun": "drives a live rail" + }, + "test:sdk:bet": { + "command": "npm run test:sdk:bet", + "notRun": "drives a live rail" + }, + "test:sdk:bet-expiry": { + "command": "npm run test:sdk:bet-expiry", + "notRun": "drives a live rail" + }, + "test:sdk:bet-latch": { + "command": "npm run test:sdk:bet-latch", + "notRun": "drives a live rail" + }, + "test:sdk:bet-parity": { + "command": "npm run test:sdk:bet-parity", + "notRun": "drives a live rail" + }, + "test:sdk:bet-reorg": { + "command": "npm run test:sdk:bet-reorg", + "notRun": "drives a live rail" + }, + "test:sdk:bet-ws": { + "command": "npm run test:sdk:bet-ws", + "notRun": "drives a live rail" + }, + "test:sdk:chunked": { + "command": "npm run test:sdk:chunked", + "notRun": "drives a live rail" + }, + "test:sdk:chunked-clients": { + "command": "npm run test:sdk:chunked-clients", + "notRun": "drives a live rail" + }, + "test:sdk:chunked-deferred": { + "command": "npm run test:sdk:chunked-deferred", + "notRun": "drives a live rail" + }, + "test:sdk:chunked-reorg": { + "command": "npm run test:sdk:chunked-reorg", + "notRun": "drives a live rail" + }, + "test:sdk:decoder": { + "command": "npm run test:sdk:decoder", + "notRun": "drives a live rail" + }, + "test:sdk:dex-reorg": { + "command": "npm run test:sdk:dex-reorg", + "notRun": "drives a live rail" + }, + "test:sdk:mcp": { + "command": "npm run test:sdk:mcp", + "notRun": "drives a live rail" + }, + "test:sdk:multileg": { + "command": "npm run test:sdk:multileg", + "notRun": "drives a live rail" + }, + "test:sdk:tickerid": { + "command": "npm run test:sdk:tickerid", + "notRun": "drives a live rail" + }, + "test:sdk:vote": { + "command": "npm run test:sdk:vote", + "notRun": "drives a live rail" + }, + "test:sdk:vote-reorg": { + "command": "npm run test:sdk:vote-reorg", + "notRun": "drives a live rail" + }, + "test:sdk:x402": { + "command": "npm run test:sdk:x402", + "notRun": "drives a live rail" + }, + "test:sdk:xcall-reorg": { + "command": "npm run test:sdk:xcall-reorg", + "notRun": "drives a live rail" + }, + "test:security": { + "command": "npm run test:security", + "notRun": "drives a live rail" + }, + "test:smoke": { + "command": "npm run test:smoke", + "notRun": "drives a live rail" + }, + "test:unit": { + "command": "npm run test:unit", + "startedAt": "2026-09-21T03:46:03.789Z", + "finishedAt": "2026-09-21T03:46:42.384Z", + "wallTimeSeconds": 38.595, + "files": 114, + "titles": 1926, + "passing": 1912, + "pending": 12, + "failing": 2 + }, + "test:vm": { + "command": "npm run test:vm", + "notRun": "drives a live rail" + } + } +} diff --git a/bin/pins/identity.json b/bin/pins/identity.json new file mode 100644 index 00000000..c87df8cf --- /dev/null +++ b/bin/pins/identity.json @@ -0,0 +1,23 @@ +{ + "algorithm": "sha256", + "coins": { + "src/coins/BTC.js": "900d82359d27269ebb775a207e84cac7ac0702f57f07c0239d5406f2a0ec6c90", + "src/coins/DOGE.js": "a0952d619edec50c09d0cbac90023cba2e8e75f0fa98b650e2ee1f4eadd7540b", + "src/coins/LTC.js": "c227025a7b1e8d70f5165f6065cd2894161abd4966814c8f7b0f462e036474c9", + "src/coins/consensus_pin.js": "f41142b6b3c9e3f1c1d491b9737fee5e6fd988d700bd96f0d200f7bd0b301ae7", + "src/coins/index.js": "dd350bc0ec999849fe302ac4381f37b7be3eaa866f019a35cbcceebb4d5ebd4b" + }, + "vendoredTwins": { + "src/coins/BTC.js": "900d82359d27269ebb775a207e84cac7ac0702f57f07c0239d5406f2a0ec6c90", + "src/coins/DOGE.js": "a0952d619edec50c09d0cbac90023cba2e8e75f0fa98b650e2ee1f4eadd7540b", + "src/coins/LTC.js": "c227025a7b1e8d70f5165f6065cd2894161abd4966814c8f7b0f462e036474c9", + "src/coins/consensus_pin.js": "f41142b6b3c9e3f1c1d491b9737fee5e6fd988d700bd96f0d200f7bd0b301ae7", + "src/coins/index.js": "dd350bc0ec999849fe302ac4381f37b7be3eaa866f019a35cbcceebb4d5ebd4b" + }, + "suiteTitleMap": { + "path": "bin/pins/at1-suite-titles.json", + "sha256": "885c6b14e29770e1ac1b48585ea4332d97d1c224a5f50be1c383b777bdea1bfe", + "scripts": 66, + "titleSets": 150 + } +} diff --git a/bin/suite-title-map.js b/bin/suite-title-map.js new file mode 100644 index 00000000..4052e1a6 --- /dev/null +++ b/bin/suite-title-map.js @@ -0,0 +1,490 @@ +#!/usr/bin/env node +/********************************************************************* + * + * Copyright © 2025-2026 Dankest, LLC + * Based on XChain Platform by 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. + * + ********************************************************************** + * + * Records the set of full Mocha titles collected by each npm test script. + * Live-rail and non-Mocha scripts stay visible with a not-run reason. Eligible + * scripts run with their original arguments plus Mocha's JSON reporter. + * + * Titles are stored as SHA-256 digests. The invariant is set membership, and + * digests keep product names in test prose out of the committed public pin. + * Wall times and result counts are written separately from the stable map. + * + * USAGE + * node bin/suite-title-map.js + * node bin/suite-title-map.js --json + * node bin/suite-title-map.js --out + * node bin/suite-title-map.js --timings + * node bin/suite-title-map.js --script test:unit + * node bin/suite-title-map.js --compare [--rename-map ] + * + ********************************************************************/ + +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const MOCHA_BIN = process.env.MOCHA_BIN + ? path.resolve(process.env.MOCHA_BIN) + : path.join(REPO_ROOT, 'node_modules', '.bin', 'mocha'); +const LIVE_RAIL_WITHOUT_INITIAL_CHECK = new Set([ + 'test:attest-mirror', + 'test:federation:dex', + 'test:integration', + 'test:integration:live', + 'test:integration:stubbed', + 'test:parity', +]); + +function splitCommand(script) { + const tokens = []; + let current = ''; + let quote = null; + let started = false; + let quoted = false; + const push = () => { + tokens.push({ value: current, quoted }); + current = ''; + started = false; + quoted = false; + }; + for (const ch of script) { + if (quote) { + if (ch === quote) quote = null; + else current += ch; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + started = true; + quoted = true; + continue; + } + if (/\s/.test(ch)) { + if (started || current) push(); + continue; + } + current += ch; + started = true; + } + if (started || current) push(); + return tokens; +} + +const SHELL_OPERATOR = /^(?:&&|\|\||[|;]|[<>]+)$/; + +function mochaArgsFor(script) { + const tokens = splitCommand(script); + if (tokens.some((token) => !token.quoted && SHELL_OPERATOR.test(token.value))) { + return { notRun: 'shell composition is not a single Mocha invocation' }; + } + const env = {}; + let i = 0; + while (i < tokens.length && !tokens[i].quoted && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i].value)) { + const equal = tokens[i].value.indexOf('='); + env[tokens[i].value.slice(0, equal)] = tokens[i].value.slice(equal + 1); + i += 1; + } + if (!tokens[i] || tokens[i].value !== 'mocha') { + return { notRun: `not a Mocha command (runs ${tokens[i] ? tokens[i].value : 'nothing'})` }; + } + return { args: tokens.slice(i + 1).map((token) => token.value), env }; +} + +function notRunReason(name, script) { + if (script.includes('./test/initialCheck.test.js') || LIVE_RAIL_WITHOUT_INITIAL_CHECK.has(name)) { + return 'drives a live rail'; + } + return null; +} + +function collect(scriptName, script) { + const policyReason = notRunReason(scriptName, script); + if (policyReason) return { notRun: policyReason }; + const parsed = mochaArgsFor(script); + if (parsed.notRun) return { notRun: parsed.notRun }; + + const startedAt = new Date(); + const result = spawnSync(MOCHA_BIN, [...parsed.args, '--reporter', 'json'], { + cwd: REPO_ROOT, + env: { ...process.env, ...parsed.env }, + maxBuffer: 256 * 1024 * 1024, + encoding: 'utf8', + }); + const finishedAt = new Date(); + const timing = { + command: `npm run ${scriptName}`, + startedAt: startedAt.toISOString(), + finishedAt: finishedAt.toISOString(), + wallTimeSeconds: Number(((finishedAt - startedAt) / 1000).toFixed(3)), + }; + if (result.error) return { error: result.error.message, timing }; + + let report; + try { + const start = result.stdout.indexOf('{\n "stats"'); + report = JSON.parse(start === -1 ? result.stdout : result.stdout.slice(start)); + } catch (error) { + return { + error: `unparseable Mocha JSON (exit ${result.status}): ${result.stderr.slice(0, 400)}`, + timing, + }; + } + + const files = {}; + for (const test of report.tests || []) { + const relative = test.file ? path.relative(REPO_ROOT, test.file) : '(no file)'; + if (!files[relative]) files[relative] = []; + files[relative].push(test.fullTitle); + } + const sorted = {}; + let titleCount = 0; + for (const relative of Object.keys(files).sort()) { + sorted[relative] = files[relative].slice().sort(); + titleCount += sorted[relative].length; + } + const counts = { + passCount: (report.passes || []).length, + pendingCount: (report.pending || []).length, + failureCount: (report.failures || []).length, + }; + if (result.status !== 0) { + return { + error: `Mocha exited ${result.status} with ${counts.failureCount} failure(s)`, + fileCount: Object.keys(sorted).length, + titleCount, + files: sorted, + ...counts, + timing, + }; + } + return { + fileCount: Object.keys(sorted).length, + titleCount, + files: sorted, + ...counts, + timing, + }; +} + +function setKey(titles) { + return crypto.createHash('sha256').update(titles.join('\n')).digest('hex').slice(0, 16); +} + +function buildMap(only) { + const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')); + const names = Object.keys(pkg.scripts || {}).filter((name) => name.startsWith('test')).sort(); + const titleSets = {}; + const scripts = {}; + for (const name of names) { + if (only && name !== only) continue; + const result = collect(name, pkg.scripts[name]); + if (result.files) { + const files = {}; + for (const relative of Object.keys(result.files)) { + const key = setKey(result.files[relative]); + titleSets[key] = result.files[relative]; + files[relative] = key; + } + result.files = files; + } + scripts[name] = result; + } + const sortedSets = {}; + for (const key of Object.keys(titleSets).sort()) sortedSets[key] = titleSets[key]; + return { titleSets: sortedSets, scripts }; +} + +function titleDigest(title) { + return crypto.createHash('sha256').update(title).digest('hex'); +} + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function validatePin(pin, expectedScripts) { + const problems = []; + if (!isRecord(pin)) return ['top level must be an object']; + if (pin.titleEncoding !== 'sha256') problems.push('titleEncoding must be sha256'); + if (!isRecord(pin.titleSets) || Object.keys(pin.titleSets).length === 0) { + problems.push('titleSets must be a nonempty object'); + } + if (!isRecord(pin.scripts) || Object.keys(pin.scripts).length === 0) { + problems.push('scripts must be a nonempty object'); + } + if (problems.length) return problems; + + const referencedSets = new Set(); + for (const [key, titles] of Object.entries(pin.titleSets)) { + if (!/^[a-f0-9]{16}$/.test(key)) problems.push(`invalid title-set key: ${key}`); + if (!Array.isArray(titles) || titles.length === 0) { + problems.push(`title set ${key} must be a nonempty array`); + continue; + } + if (titles.some((title) => typeof title !== 'string' || !/^[a-f0-9]{64}$/.test(title))) { + problems.push(`title set ${key} contains a non-sha256 title`); + } + if (new Set(titles).size !== titles.length) problems.push(`title set ${key} contains duplicates`); + } + + let runnableScripts = 0; + for (const [name, entry] of Object.entries(pin.scripts)) { + if (!isRecord(entry)) { + problems.push(`${name} must be an object`); + continue; + } + const hasFiles = isRecord(entry.files); + const hasNotRun = typeof entry.notRun === 'string' && entry.notRun.length > 0; + if (Number(hasFiles) + Number(hasNotRun) !== 1) { + problems.push(`${name} must contain exactly one of files or notRun`); + continue; + } + if (hasNotRun) { + if (Object.keys(entry).length !== 1) problems.push(`${name} has fields beside notRun`); + continue; + } + + runnableScripts += 1; + const fileNames = Object.keys(entry.files); + let titleCount = 0; + for (const [relative, key] of Object.entries(entry.files)) { + if (typeof key !== 'string' || !Object.prototype.hasOwnProperty.call(pin.titleSets, key)) { + problems.push(`${name} references missing title set ${String(key)} for ${relative}`); + continue; + } + referencedSets.add(key); + titleCount += pin.titleSets[key].length; + } + if (entry.fileCount !== fileNames.length) problems.push(`${name} fileCount does not match files`); + if (entry.titleCount !== titleCount) problems.push(`${name} titleCount does not match title sets`); + const fields = Object.keys(entry).sort().join(','); + const expectedFields = typeof entry.error === 'string' + ? 'error,fileCount,files,titleCount' + : 'fileCount,files,titleCount'; + if (fields !== expectedFields) problems.push(`${name} has unexpected fields`); + } + if (runnableScripts === 0) problems.push('scripts has no collected test suite'); + for (const key of Object.keys(pin.titleSets)) { + if (!referencedSets.has(key)) problems.push(`unreferenced title set: ${key}`); + } + + if (expectedScripts) { + const pinnedNames = Object.keys(pin.scripts).sort(); + const expectedNames = expectedScripts.slice().sort(); + if (JSON.stringify(pinnedNames) !== JSON.stringify(expectedNames)) { + problems.push('scripts do not match package.json test scripts'); + } + } + return problems; +} + +function withoutMeasurements(map) { + const scripts = {}; + for (const name of Object.keys(map.scripts)) { + const { + timing, passCount, pendingCount, failureCount, ...stable + } = map.scripts[name]; + scripts[name] = stable; + } + return { titleSets: map.titleSets, scripts }; +} + +function toPin(map) { + if (map.titleEncoding === 'sha256') return map; + const titleSets = {}; + for (const key of Object.keys(map.titleSets)) titleSets[key] = map.titleSets[key].map(titleDigest); + return { titleEncoding: 'sha256', titleSets, scripts: map.scripts }; +} + +function timingRecord(map) { + const scripts = {}; + for (const name of Object.keys(map.scripts)) { + const entry = map.scripts[name]; + scripts[name] = entry.timing ? { + ...entry.timing, + files: entry.fileCount, + titles: entry.titleCount, + passing: entry.passCount, + pending: entry.pendingCount, + failing: entry.failureCount, + } : { command: `npm run ${name}`, notRun: entry.notRun || entry.error || 'not run' }; + } + return { + timingMethod: 'UTC timestamps captured around each Mocha process', + node: process.version, + scripts, + }; +} + +function expand(map, scriptName) { + const script = map.scripts[scriptName]; + if (!script || !script.files) return null; + const expanded = {}; + for (const relative of Object.keys(script.files).sort()) { + expanded[relative] = map.titleSets[script.files[relative]] || []; + } + return expanded; +} + +function titleIndex(map, titles) { + const hashed = map.titleEncoding === 'sha256'; + return new Map(titles.map((title) => [hashed ? title : titleDigest(title), title])); +} + +function compare(pin, fresh, renames, only) { + const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')); + const expectedScripts = Object.keys(pkg.scripts || {}).filter((name) => name.startsWith('test')); + const differences = validatePin(pin, only ? undefined : expectedScripts) + .map((detail) => ({ script: only || 'pin', kind: 'invalid_pin', detail })); + if (differences.length) return differences; + const names = Array.from(new Set(Object.keys(pin.scripts).concat(Object.keys(fresh.scripts)))) + .filter((name) => !only || name === only) + .sort(); + for (const name of names) { + const before = expand(pin, name); + const after = expand(fresh, name); + if (!before && !after) { + if (JSON.stringify(pin.scripts[name]) !== JSON.stringify(fresh.scripts[name])) { + differences.push({ script: name, kind: 'not_run', detail: 'not-run reason changed' }); + } + continue; + } + if (!before || !after) { + differences.push({ script: name, kind: 'script', detail: before ? 'script removed' : 'script added' }); + continue; + } + const mapped = {}; + for (const relative of Object.keys(before)) mapped[renames[relative] || relative] = before[relative]; + const files = Array.from(new Set(Object.keys(mapped).concat(Object.keys(after)))).sort(); + for (const relative of files) { + if (!mapped[relative]) { + differences.push({ script: name, kind: 'file_added', file: relative }); + continue; + } + if (!after[relative]) { + differences.push({ script: name, kind: 'file_dropped', file: relative }); + continue; + } + const beforeTitles = titleIndex(pin, mapped[relative]); + const afterTitles = titleIndex(fresh, after[relative]); + for (const [digest, title] of beforeTitles) { + if (!afterTitles.has(digest)) differences.push({ script: name, kind: 'title_dropped', file: relative, title }); + } + for (const [digest, title] of afterTitles) { + if (!beforeTitles.has(digest)) differences.push({ script: name, kind: 'title_added', file: relative, title }); + } + } + } + return differences; +} + +function parseArgs(argv) { + const options = {}; + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === '--json') options.json = true; + else if (argv[i] === '--out') { options.out = path.resolve(argv[i + 1]); i += 1; } + else if (argv[i] === '--timings') { options.timings = path.resolve(argv[i + 1]); i += 1; } + else if (argv[i] === '--script') { options.script = argv[i + 1]; i += 1; } + else if (argv[i] === '--compare') { options.compare = path.resolve(argv[i + 1]); i += 1; } + else if (argv[i] === '--rename-map') { options.renameMap = path.resolve(argv[i + 1]); i += 1; } + else if (argv[i] === '--help' || argv[i] === '-h') options.help = true; + else throw new Error(`unknown argument: ${argv[i]}`); + } + return options; +} + +function serialize(value) { + return `${JSON.stringify(value, null, 2).replace(/[\u007f-\uffff]/g, + (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`)}\n`; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(fs.readFileSync(__filename, 'utf8').split('*/')[0]); + return; + } + const measured = buildMap(options.script); + const stable = withoutMeasurements(measured); + const pin = toPin(stable); + + if (options.timings) { + fs.mkdirSync(path.dirname(options.timings), { recursive: true }); + fs.writeFileSync(options.timings, serialize(timingRecord(measured))); + } + if (options.compare) { + const expected = JSON.parse(fs.readFileSync(options.compare, 'utf8')); + const renames = options.renameMap ? JSON.parse(fs.readFileSync(options.renameMap, 'utf8')) : {}; + const differences = compare(expected, stable, renames, options.script); + if (!differences.length) { + console.log(`suite identity holds against ${path.relative(REPO_ROOT, options.compare)}`); + return; + } + console.log(`${differences.length} difference(s) against ${path.relative(REPO_ROOT, options.compare)}:`); + for (const difference of differences.slice(0, 200)) { + console.log(` [${difference.script}] ${difference.kind} ${difference.file || ''}` + + `${difference.title ? ` :: ${difference.title}` : ` ${difference.detail || ''}`}`); + } + if (differences.length > 200) console.log(` ... and ${differences.length - 200} more`); + process.exitCode = 1; + return; + } + + if (options.out) { + fs.mkdirSync(path.dirname(options.out), { recursive: true }); + fs.writeFileSync(options.out, serialize(pin)); + } + if (options.json) { + process.stdout.write(serialize(pin)); + return; + } + let failed = 0; + for (const name of Object.keys(measured.scripts)) { + const result = measured.scripts[name]; + if (result.notRun) { + console.log(`${name.padEnd(32)} not run: ${result.notRun}`); + continue; + } + if (result.error) { + console.log(`${name.padEnd(32)} ERROR: ${result.error}`); + failed += 1; + continue; + } + console.log(`${name.padEnd(32)} ${String(result.fileCount).padStart(4)} files ` + + `${String(result.titleCount).padStart(5)} titles ${String(result.passCount).padStart(5)} passing`); + } + if (options.out) console.log(`\nwritten to ${path.relative(REPO_ROOT, options.out)}`); + if (options.timings) console.log(`timings written to ${path.relative(REPO_ROOT, options.timings)}`); + if (failed) process.exitCode = 1; +} + +if (require.main === module) main(); + +module.exports = { + buildMap, + collect, + compare, + expand, + mochaArgsFor, + notRunReason, + serialize, + splitCommand, + timingRecord, + toPin, + validatePin, + withoutMeasurements, +}; diff --git a/package-lock.json b/package-lock.json index 1045b1de..8fe6254d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-e2e-test", - "version": "0.20.0", + "version": "0.20.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-e2e-test", - "version": "0.20.0", + "version": "0.20.1", "license": "AGPL-3.0-or-later", "dependencies": { "axios": "^1.18.1", @@ -8916,7 +8916,7 @@ } }, "xchain-hub": { - "version": "0.20.0", + "version": "0.20.1", "license": "AGPL-3.0-or-later", "dependencies": { "acorn": "8.18.0", @@ -8974,7 +8974,7 @@ }, "xchain-sdk": { "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 0b4be882..7d103032 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xchain-e2e-test", "description": "xchain-e2e-test is an end-to-end testing suite for the XChain platform to ensure data integrity", - "version": "0.20.0", + "version": "0.20.1", "license": "AGPL-3.0-or-later", "repository": { "type": "git", @@ -25,15 +25,16 @@ "venue:age-rollcall-absences": "E2E_REQUIRE_FEDERATION=1 mocha --timeout 0 --exit --require ./test/initialCheck.test.js test/tools/rollcall_age_absences.test.js", "venue:clear-rollcall-outsiders": "E2E_STAKE_TEARDOWN=off E2E_REQUIRE_FEDERATION=1 XC_ROLLCALL_CLEAR_OUTSIDERS=1 mocha --timeout 0 --exit --require ./test/initialCheck.test.js test/tools/rollcallClearOutsiders.test.js", "venue:seed-attestation": "E2E_STAKE_TEARDOWN=off mocha --timeout 0 --exit --require ./test/initialCheck.test.js test/tools/reseedAttestationRoster.test.js test/tools/reseedAttestationRoster.test/01_seed_the_attestation_roster_on_a_reset_chain.test.js", - "test:attest-mirror": "mocha --timeout 0 --exit --require ./test/initialCheck.test.js 'test/attestMirror/*.test.js' 'test/attestMirror/barrier_family/*.test.js'", + "test:attest-mirror": "node scripts/run-attest-mirror.js 'test/attestMirror/*.test.js' 'test/attestMirror/barrier_family/*.test.js'", "test:bridge-rail": "mocha --timeout 0 --exit --require ./test/initialCheck.test.js 'test/integration/bridge_rail_*.test.js'", "lint:sleep-flake": "node scripts/check-sleep-flake.js", "lint:wait-swallow": "node scripts/check-wait-swallow.js", "lint:stake-teardown": "node scripts/check-stake-teardown.js", + "lint:mirror-pendings": "node scripts/check-mirror-pendings.js", "count:actions": "node scripts/count-action-suites.js", "stage:siblings": "node scripts/stage-siblings.js", "stage:siblings:check": "node scripts/stage-siblings.js --check", - "ci": "npm run lint:sleep-flake && npm run lint:wait-swallow && npm run lint:stake-teardown && mocha --no-config --timeout 30000 --exit 'test/unit/coins_conformance.test.js' 'test/unit/sibling_coverage.test.js' 'test/unit/vm_false_green.test.js' 'test/unit/suite_parses.test.js' 'test/unit/sdk/template_deploy_plan.test.js' 'test/unit/src/CryptoNetworks.test.js' 'test/unit/scripts/live_tier_roster.test.js' 'test/unit/scripts/action_suite_count.test.js' 'test/unit/scripts/sleepFlakeScanner.test.js' 'test/unit/helpers/multiValidatorHubPorts.test.js' 'test/unit/helpers/consensusWait.test.js' 'test/unit/helpers/attestationVenueGuard.test.js' 'test/unit/helpers/stakeHelper.test.js' 'test/unit/helpers/waitSwallow.test.js' 'test/unit/helpers/stakeTeardown.test.js' 'test/unit/scripts/stake_teardown_gate.test.js' 'test/unit/security/configuration/dependency_advisories.test.js' 'test/unit/security/configuration/sibling-tree-advisories.test.js' 'test/unit/security/configuration/sdk_transitive_advisories.test.js' 'test/unit/security/configuration/production_audit_advisories.test.js' 'test/unit/security/configuration/history_path_leak.test.js' 'test/unit/oracle_batch_replay_live_chain.test.js' 'test/unit/oracle_batch_barrier_testnet_observe.test.js' 'test/integration/parity/**/*.test.js' && npm run ci:live", + "ci": "npm run lint:sleep-flake && npm run lint:wait-swallow && npm run lint:stake-teardown && mocha --no-config --timeout 30000 --exit 'test/unit/coins_conformance.test.js' 'test/unit/sibling_coverage.test.js' 'test/unit/vm_false_green.test.js' 'test/unit/suite_parses.test.js' 'test/unit/sdk/template_deploy_plan.test.js' 'test/unit/src/CryptoNetworks.test.js' 'test/unit/scripts/live_tier_roster.test.js' 'test/unit/scripts/stubbed_lane_hermetic.test.js' 'test/unit/scripts/action_suite_count.test.js' 'test/unit/scripts/sleepFlakeScanner.test.js' 'test/unit/helpers/multiValidatorHubPorts.test.js' 'test/unit/helpers/consensusWait.test.js' 'test/unit/helpers/attestationVenueGuard.test.js' 'test/unit/helpers/stakeHelper.test.js' 'test/unit/helpers/waitSwallow.test.js' 'test/unit/helpers/stakeTeardown.test.js' 'test/unit/helpers/bridgeRailMinerPause.test.js' 'test/unit/scripts/stake_teardown_gate.test.js' 'test/unit/security/configuration/dependency_advisories.test.js' 'test/unit/security/configuration/sibling-tree-advisories.test.js' 'test/unit/security/configuration/sdk_transitive_advisories.test.js' 'test/unit/security/configuration/production_audit_advisories.test.js' 'test/unit/security/configuration/history_path_leak.test.js' 'test/unit/oracle_batch_replay_live_chain.test.js' 'test/unit/oracle_batch_barrier_testnet_observe.test.js' 'test/integration/parity/**/*.test.js' && npm run ci:live", "ci:live": "node scripts/run-live-tier.js", "ci:full": "bash bin/ci-full.sh", "test": "mocha --timeout 0 --exit --require ./test/initialCheck.test.js 'test/actions/**/*.test.js'", @@ -73,7 +74,7 @@ "test:smoke": "mocha --bail --timeout 30000 --exit --require ./test/initialCheck.test.js 'test/smoke/**/*.smoke.js'", "test:unit": "mocha 'test/unit/**/*.test.js'", "test:integration": "npm run test:integration:stubbed && npm run test:integration:live", - "test:integration:stubbed": "mocha --timeout 0 --exit 'test/integration/*/**/*.test.js'", + "test:integration:stubbed": "mocha --timeout 0 --exit 'test/integration/*/**/*.test.js' --ignore 'test/integration/*.test/**'", "test:integration:live": "mocha --timeout 0 --exit 'test/integration/*.integration.test.js'", "test:e2e": "mocha --timeout 0 --exit --require ./test/initialCheck.test.js 'test/e2e/**/*.e2e.js'", "test:boundary": "mocha 'test/boundary/**/*.boundary.js'", diff --git a/scripts/attest-mirror-stack-driver.sh b/scripts/attest-mirror-stack-driver.sh new file mode 100755 index 00000000..012b5810 --- /dev/null +++ b/scripts/attest-mirror-stack-driver.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -eu + +phase=$1 +shift +stack= +slot= +leg= +while [ "$#" -gt 0 ]; do + case "$1" in + --stack) stack=$2; shift 2 ;; + --slot) slot=$2; shift 2 ;; + --leg) leg=$2; shift 2 ;; + --require) shift 2 ;; + *) printf 'unknown driver argument: %s\n' "$1" >&2; exit 2 ;; + esac +done + +: "${ATTEST_MIRROR_STACK_ROOT:?ATTEST_MIRROR_STACK_ROOT is required}" +: "${stack:?--stack is required}" +: "${slot:?--slot is required}" +: "${leg:?--leg is required}" + +prefix=${ATTEST_MIRROR_STACK_PREFIX:-xca7} +number=$((slot + 1)) +project=${prefix}${number} +base=$(( ${ATTEST_MIRROR_PORT_BASE:-62100} + (slot * 100) )) +venue_base=$(( ${ATTEST_MIRROR_VENUE_PORT_BASE:-63400} + (slot * 100) )) +stack_dir=${ATTEST_MIRROR_STACK_ROOT}/${project} +node_bin=${ATTEST_MIRROR_NODE_BIN:-node} +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +build_hub=${repo_root}/../xchain-hub +mixed_root=${stack_dir}/bf6-mixed/${stack} +bf6_leg=test/attestMirror/barrier_family/bf6_producer_follower_parity.test.js + +case "$phase" in + up) + # Clear the slot before filling it. setup-stack.sh mints a fresh random root + # password into the stack's .env on every boot, but MariaDB honours + # MARIADB_ROOT_PASSWORD only while the datadir is empty. So a db-data volume that + # outlives its stack leaves the datadir on the OLD password, db-init's one client + # call is refused with ERROR 1045, and the whole `up` dies. Two things leave one + # behind: a lane killed before it reaches the down phase (a machine reboot did + # exactly this on 2026-09-18), and a `down -v` that skips a volume another + # container still holds ("Resource is still in use"). Booting over a survivor is + # not recoverable downstream, so `up` owns the cleanup rather than trusting the + # previous `down` to have finished. + if [ -f "${stack_dir}/compose.yml" ]; then + docker compose -p "$project" -f "${stack_dir}/compose.yml" down -v --remove-orphans >&2 || true + fi + # `down -v` is best effort, so sweep by project label and retry: a volume is + # released only once the last container holding it is actually gone. + attempt=0 + while [ "$attempt" -lt 10 ]; do + leftover_containers=$(docker ps -aq --filter "label=com.docker.compose.project=${project}") + if [ -n "$leftover_containers" ]; then + # Unquoted on purpose: this is a whitespace-separated id list, and the + # -n guard above is what keeps the command from running with no argument. + docker rm -f $leftover_containers >&2 || true + fi + leftover_volumes=$(docker volume ls -q --filter "label=com.docker.compose.project=${project}") + if [ -z "$leftover_volumes" ]; then + break + fi + docker volume rm $leftover_volumes >&2 || true + attempt=$((attempt + 1)) + sleep 2 + done + # Fail loudly rather than boot onto a stale datadir: a silent pass here becomes an + # ERROR 1045 forty seconds later that reads like a rail capacity problem. + leftover_volumes=$(docker volume ls -q --filter "label=com.docker.compose.project=${project}") + if [ -n "$leftover_volumes" ]; then + printf 'stack slot %s still holds volumes after cleanup; refusing to boot over them\n' "$project" >&2 + exit 1 + fi + "${ATTEST_MIRROR_STACK_ROOT}/setup-stack.sh" "$number" "$base" + if [ "$leg" = "$bf6_leg" ]; then + "$node_bin" "${repo_root}/scripts/prepare-bf6-mixed-hub.js" create \ + --workspace "$stack_dir" --stack "$stack" --build-hub "$build_hub" + fi + ;; + ready) + set -a + . "${stack_dir}/.env" + set +a + export BTC_INDEXER_API_PORT=$INDEXER_HOST_PORT + export BTC_INDEXER_DB_NAME=XChain_BTC_Regtest_Indexer + export BTC_REGTEST_MINER_API_PORT=$MINER_HOST_PORT + export LTC_REGTEST_MINER_API_PORT=$LTC_MINER_HOST_PORT + export DOGE_REGTEST_MINER_API_PORT=$DOGE_MINER_HOST_PORT + "$node_bin" "${repo_root}/scripts/wait-attest-mirror-stack.js" + ;; + seed) + "${ATTEST_MIRROR_STACK_ROOT}/run-leg.sh" "${stack}-seed" "$number" "$venue_base" \ + test/tools/reseedAttestationRoster.test.js \ + EXPLORER_API_PORT="${ATTEST_MIRROR_EXPLORER_PORT:-46599}" E2E_STAKE_TEARDOWN=off + ;; + run) + if [ "$leg" = "$bf6_leg" ]; then + "${ATTEST_MIRROR_STACK_ROOT}/run-leg.sh" "$stack" "$number" "$venue_base" "$leg" \ + EXPLORER_API_PORT="${ATTEST_MIRROR_EXPLORER_PORT:-46599}" E2E_STAKE_TEARDOWN=off \ + BF6_MIXED_HUB_ROOT="$mixed_root" + else + "${ATTEST_MIRROR_STACK_ROOT}/run-leg.sh" "$stack" "$number" "$venue_base" "$leg" \ + EXPLORER_API_PORT="${ATTEST_MIRROR_EXPLORER_PORT:-46599}" E2E_STAKE_TEARDOWN=off + fi + ;; + down) + down_status=0 + if [ -f "${stack_dir}/compose.yml" ]; then + docker compose -p "$project" -f "${stack_dir}/compose.yml" down -v || down_status=$? + fi + if [ "$leg" = "$bf6_leg" ]; then + "$node_bin" "${repo_root}/scripts/prepare-bf6-mixed-hub.js" remove \ + --workspace "$stack_dir" --stack "$stack" || down_status=$? + fi + test -z "$(docker ps -aq --filter "label=com.docker.compose.project=${project}")" || down_status=$? + test -z "$(docker volume ls -q --filter "label=com.docker.compose.project=${project}")" || down_status=$? + test -z "$(docker network ls -q --filter "label=com.docker.compose.project=${project}")" || down_status=$? + exit "$down_status" + ;; + *) + printf 'unknown driver phase: %s\n' "$phase" >&2 + exit 2 + ;; +esac diff --git a/scripts/check-mirror-pendings.js b/scripts/check-mirror-pendings.js new file mode 100644 index 00000000..640470c9 --- /dev/null +++ b/scripts/check-mirror-pendings.js @@ -0,0 +1,385 @@ +#!/usr/bin/env node + +'use strict' + +const fs = require('fs') +const path = require('path') + +const ROOT = path.resolve(__dirname, '..') +const DEFAULT_INPUTS = [path.join(ROOT, 'test', 'attestMirror')] +const TEST_FILE = /\.test\.js$/ +const MOCHA_CALL = /\b(describe|context|suite|it|test|before|beforeEach|setup)(\.skip)?\s*\(/g +const HOOKS = new Set(['before', 'beforeEach', 'setup']) +const SUITES = new Set(['describe', 'context', 'suite']) +const TESTS = new Set(['it', 'test']) + +function maskNonCode (source) { + const out = source.split('') + const blank = (from, to) => { + for (let i = from; i < to && i < out.length; i++) { + if (out[i] !== '\n') out[i] = ' ' + } + } + let i = 0 + while (i < source.length) { + const c = source[i] + const next = source[i + 1] + if (c === '/' && next === '/') { + const end = source.indexOf('\n', i) + const stop = end === -1 ? source.length : end + blank(i, stop) + i = stop + continue + } + if (c === '/' && next === '*') { + const end = source.indexOf('*/', i + 2) + const stop = end === -1 ? source.length : end + 2 + blank(i, stop) + i = stop + continue + } + if (c === '"' || c === "'" || c === '`') { + const quote = c + let end = i + 1 + while (end < source.length) { + if (source[end] === '\\') { + end += 2 + continue + } + if (source[end] === quote) { + end++ + break + } + end++ + } + blank(i, end) + i = end + continue + } + if (c === '/') { + let previous = i - 1 + while (previous >= 0 && /\s/.test(out[previous])) previous-- + const prior = previous < 0 ? '' : out[previous] + const word = source.slice(Math.max(0, previous - 9), previous + 1) + const canStartRegex = previous < 0 || '(,=:[!&|?{};+-*%^~'.includes(prior) || + /\b(?:return|typeof|case|in|of|do|else|yield|await)$/.test(word) + if (canStartRegex) { + let end = i + 1 + let inClass = false + let closed = false + while (end < source.length && source[end] !== '\n') { + if (source[end] === '\\') { + end += 2 + continue + } + if (source[end] === '[') inClass = true + else if (source[end] === ']') inClass = false + else if (source[end] === '/' && !inClass) { + end++ + while (/[a-z]/i.test(source[end] || '')) end++ + closed = true + break + } + end++ + } + if (closed) { + blank(i, end) + i = end + continue + } + } + } + i++ + } + return out.join('') +} + +function closeBrace (masked, open) { + let depth = 0 + for (let i = open; i < masked.length; i++) { + if (masked[i] === '{') depth++ + else if (masked[i] === '}') { + depth-- + if (depth === 0) return i + } + } + return masked.length +} + +function lineAt (source, offset) { + return source.slice(0, offset).split('\n').length +} + +function stringAt (source, offset) { + let i = offset + while (/\s/.test(source[i] || '')) i++ + const quote = source[i] + if (quote !== "'" && quote !== '"' && quote !== '`') return null + let value = '' + for (i++; i < source.length; i++) { + if (source[i] === '\\') { + if (i + 1 < source.length) value += source[++i] + continue + } + if (source[i] === quote) return value + value += source[i] + } + return null +} + +function findCalls (source) { + const masked = maskNonCode(source) + const calls = [] + MOCHA_CALL.lastIndex = 0 + let match + while ((match = MOCHA_CALL.exec(masked)) !== null) { + const openParen = masked.indexOf('(', match.index) + const tail = masked.slice(openParen + 1) + const callback = /(?:async\s+)?(?:function(?:\s+[\w$]+)?\s*\([^)]*\)|\([^)]*\)\s*=>|[\w$]+\s*=>)\s*\{/.exec(tail) + if (!callback) continue + const bodyOpen = openParen + 1 + callback.index + callback[0].lastIndexOf('{') + const bodyClose = closeBrace(masked, bodyOpen) + calls.push({ + kind: match[1], + skipped: Boolean(match[2]), + offset: match.index, + line: lineAt(source, match.index), + title: stringAt(source, openParen + 1), + bodyOpen, + bodyClose, + }) + } + return { calls, masked } +} + +function parentSuite (call, calls) { + return calls + .filter((candidate) => SUITES.has(candidate.kind) && candidate.bodyOpen < call.offset && candidate.bodyClose > call.offset) + .sort((a, b) => b.bodyOpen - a.bodyOpen)[0] || null +} + +function attachedComment (source, line) { + const lines = source.split('\n') + let index = line - 2 + if (index < 0 || /^\s*$/.test(lines[index])) return '' + const collected = [] + if (/^\s*\/\//.test(lines[index])) { + while (index >= 0 && /^\s*\/\//.test(lines[index])) collected.unshift(lines[index--]) + } else if (/\*\/\s*$/.test(lines[index])) { + while (index >= 0) { + collected.unshift(lines[index]) + if (/\/\*/.test(lines[index--])) break + } + } else { + return '' + } + return collected.join(' ') + .replace(/\/\*+|\*\//g, ' ') + .replace(/(^|\s)(?:\/\/|\*)\s?/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function reasonFrom (title, comment) { + const parenthetical = /\(([^()]*(?:driven|needs?|not\s+drivable|because|reason|see\s+comment)[^()]*)\)/i.exec(title || '') + if (parenthetical) return parenthetical[1].trim() + if (/\b(?:because|needs?|not\s+drivable|only\s+when|unless|until|driven\s+in)\b/i.test(title || '')) { + return title.trim() + } + return comment && comment.length >= 8 ? comment : '' +} + +function availabilityGuard (source, call, offset) { + const lead = source.slice(Math.max(call.bodyOpen, offset - 600), offset) + const unavailableVenue = /if\s*\(\s*!\s*up\s*\)/.test(lead) && /unavailable/.test(lead) + const unavailableRail = /SKIPPED[\s\S]*(?:unreachable|cannot be driven)/.test(lead) + return unavailableVenue || unavailableRail +} + +function parseSource (source, file) { + const { calls, masked } = findCalls(source) + const pending = [] + + for (const call of calls) { + if (!TESTS.has(call.kind) || !call.skipped) continue + const suite = parentSuite(call, calls) + const title = call.title || '' + const comment = attachedComment(source, call.line) + pending.push({ + file, + line: call.line, + title: suite && suite.title ? suite.title + ' > ' + title : title, + claim: title, + comment, + reason: reasonFrom(title, comment), + crossrefs: crossrefsFrom(title + ' ' + comment), + kind: 'declared', + }) + } + + const skipCall = /\bthis\s*\.\s*skip\s*\(\s*\)/g + let match + while ((match = skipCall.exec(masked)) !== null) { + const containers = calls + .filter((call) => call.bodyOpen < match.index && call.bodyClose > match.index) + .sort((a, b) => b.bodyOpen - a.bodyOpen) + const hook = containers.find((call) => HOOKS.has(call.kind)) + if (!hook || availabilityGuard(source, hook, match.index)) continue + const suite = parentSuite(hook, calls) + const suiteTitle = (suite && suite.title) || '' + const title = suiteTitle + ' [' + hook.kind + ' hook]' + const comment = attachedComment(source, lineAt(source, match.index)) + pending.push({ + file, + line: lineAt(source, match.index), + title, + claim: suiteTitle, + comment, + reason: reasonFrom(suiteTitle, comment), + crossrefs: crossrefsFrom(suiteTitle + ' ' + comment), + kind: 'hook', + }) + } + + return { pending: pending.sort((a, b) => a.line - b.line), calls } +} + +function crossrefsFrom (text) { + const found = [] + const matcher = /\b(?:driven|covered)\s+in\s+`?([a-z]+\d+[a-z]?)\b/ig + let match + while ((match = matcher.exec(text)) !== null) found.push(match[1].toLowerCase()) + return [...new Set(found)] +} + +function walkTests (directory, output) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name) + if (entry.isDirectory()) walkTests(absolute, output) + else if (TEST_FILE.test(entry.name)) output.push(absolute) + } +} + +function collectFiles (inputs) { + const files = [] + const missing = [] + for (const input of inputs) { + const absolute = path.resolve(input) + if (!fs.existsSync(absolute)) { + missing.push(input) + continue + } + const stat = fs.statSync(absolute) + if (stat.isDirectory()) walkTests(absolute, files) + else if (stat.isFile()) files.push(absolute) + } + return { files: [...new Set(files)].sort(), missing } +} + +const STOP_WORDS = new Set([ + 'a', 'an', 'and', 'at', 'by', 'comment', 'does', 'driven', 'for', 'in', 'is', 'it', 'leg', + 'names', 'on', 'see', 'specifically', 'test', 'that', 'the', 'this', 'to', 'when', 'with', +]) + +function claimWords (text) { + return [...new Set(String(text).toLowerCase().match(/[a-z][a-z0-9_]+/g) || [])] + .filter((word) => !STOP_WORDS.has(word) && !/^at\d+[a-z]?$/.test(word)) +} + +function titleMatchesClaim (claim, title) { + const wanted = claimWords(claim) + const offered = new Set(claimWords(title)) + if (wanted.some((word) => word.includes('_') && offered.has(word))) return true + const overlap = wanted.filter((word) => offered.has(word)).length + return wanted.length > 0 && overlap >= Math.min(2, wanted.length) +} + +function resolveCrossrefs (pending, parsedFiles) { + const checks = [] + for (const item of pending) { + for (const name of item.crossrefs) { + const targets = [...parsedFiles.entries()].filter(([file]) => { + const base = path.basename(file, '.test.js').toLowerCase() + return base === name || base.startsWith(name + '-') || base.startsWith(name + '_') + }) + if (!targets.length) { + checks.push({ item, name, ok: false, why: 'leg file does not exist' }) + continue + } + const matching = targets.flatMap(([, parsed]) => parsed.calls) + .filter((call) => TESTS.has(call.kind) && !call.skipped && call.title) + .find((call) => { + const suite = parentSuite(call, targets.find(([, value]) => value.calls.includes(call))[1].calls) + const fullTitle = (suite && suite.title ? suite.title + ' ' : '') + call.title + return titleMatchesClaim(item.claim, fullTitle) + }) + checks.push({ + item, + name, + ok: Boolean(matching), + why: matching ? '' : 'leg has no matching active case title', + }) + } + } + return checks +} + +function scanPaths (inputs) { + const selected = collectFiles(inputs) + const parsedFiles = new Map() + const pending = [] + for (const absolute of selected.files) { + const shown = path.relative(process.cwd(), absolute).split(path.sep).join('/') + const parsed = parseSource(fs.readFileSync(absolute, 'utf8'), shown) + parsedFiles.set(absolute, parsed) + pending.push(...parsed.pending) + } + const crossrefs = resolveCrossrefs(pending, parsedFiles) + return { files: selected.files, missing: selected.missing, pending, crossrefs } +} + +function main () { + const inputs = process.argv.slice(2) + const result = scanPaths(inputs.length ? inputs : DEFAULT_INPUTS) + + for (const input of result.missing) { + console.error('mirror-pendings: input path does not exist: ' + input) + } + if (result.files.length === 0) { + console.error('mirror-pendings: read zero files from input') + return 1 + } + + for (const item of result.pending) { + console.log('PENDING ' + item.file + ':' + item.line + + ' | title: ' + item.title + + ' | reason: ' + (item.reason || '')) + } + + const unreasoned = result.pending.filter((item) => !item.reason) + const unresolved = result.crossrefs.filter((check) => !check.ok) + console.log('mirror-pendings: read ' + result.files.length + ' file(s); found ' + result.pending.length + + ' pending; cross-references ' + result.crossrefs.length + '; unreasoned ' + unreasoned.length + '.') + + if (result.pending.length === 0) { + console.error('mirror-pendings: read ' + result.files.length + ' file(s) but found zero pending tests') + } + for (const item of unreasoned) { + console.error('mirror-pendings: unreasoned pending: ' + item.file + ':' + item.line + ' ' + item.title) + } + for (const check of unresolved) { + console.error('mirror-pendings: unresolved cross-reference ' + check.name + ' at ' + + check.item.file + ':' + check.item.line + ': ' + check.why) + } + + return result.missing.length || result.pending.length === 0 || unreasoned.length || unresolved.length ? 1 : 0 +} + +module.exports = { + collectFiles, + parseSource, + scanPaths, + titleMatchesClaim, +} + +if (require.main === module) process.exit(main()) diff --git a/scripts/check-request-row-wait.js b/scripts/check-request-row-wait.js new file mode 100644 index 00000000..aed9af17 --- /dev/null +++ b/scripts/check-request-row-wait.js @@ -0,0 +1,338 @@ +#!/usr/bin/env node +/********************************************************************* + * + * 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. + * + ********************************************************************** + * + * Request-row commit-barrier gate. + * + * A finalized mirror row does not prove that a venue indexer has committed + * the source block that emitted the request. Before reading a request row, a + * test must wait for that indexer to commit a request-related height. The wait + * must dominate the read on the same control-flow path. + * + * A deliberately early read must explain itself on the call line or in the + * contiguous comment block above it: + * + * // request-row-wait-ok: absence is the assertion in this poll. + * + * Usage: node scripts/check-request-row-wait.js [--list] [path ...] + * + ********************************************************************/ + +'use strict' + +const fs = require('fs') +const path = require('path') + +const ROOT = path.join(__dirname, '..') +const DEFAULT_PATH = path.join(ROOT, 'test', 'attestMirror') +const OPT_OUT = /\/\/\s*request-row-wait-ok:\s*\S/ + +function slash (value) { return value.split(path.sep).join('/') } + +// Blank comments and literals without changing offsets. The scanner only +// needs identifiers, punctuation and braces; keeping offsets stable preserves +// exact source lines in the report. +function blankNonCode (src) { + const out = src.split('') + const blank = (from, to) => { + for (let i = from; i < to && i < out.length; i++) if (out[i] !== '\n') out[i] = ' ' + } + let i = 0 + while (i < src.length) { + const c = src[i] + const d = src[i + 1] + if (c === '/' && d === '/') { + const end = src.indexOf('\n', i) + const stop = end === -1 ? src.length : end + blank(i, stop); i = stop; continue + } + if (c === '/' && d === '*') { + const end = src.indexOf('*/', i + 2) + const stop = end === -1 ? src.length : end + 2 + blank(i, stop); i = stop; continue + } + if (c === '"' || c === "'" || c === '`') { + let j = i + 1 + while (j < src.length) { + if (src[j] === '\\') { j += 2; continue } + if (src[j] === c) break + j++ + } + blank(i, Math.min(j + 1, src.length)); i = j + 1; continue + } + i++ + } + return out.join('') +} + +function lineAt (src, offset) { + let line = 1 + for (let i = 0; i < offset; i++) if (src[i] === '\n') line++ + return line +} + +function splitArgs (code, open) { + const args = [] + let from = open + 1 + let depth = 1 + for (let i = open + 1; i < code.length; i++) { + const c = code[i] + if (c === '(' || c === '[' || c === '{') depth++ + else if (c === ')' || c === ']' || c === '}') { + depth-- + if (depth === 0) { + args.push(code.slice(from, i).trim()) + return { args, end: i } + } + } else if (c === ',' && depth === 1) { + args.push(code.slice(from, i).trim()) + from = i + 1 + } + } + return null +} + +function aliasesFor (code, canonical) { + const names = new Set([canonical]) + const re = new RegExp('(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*[^;\\n]*\\b' + canonical + '\\b', 'g') + let match + while ((match = re.exec(code))) names.add(match[1]) + return names +} + +function callsFor (code, names) { + const escaped = [...names].map((n) => n.replace(/[$]/g, '\\$&')).join('|') + const re = new RegExp('\\b(' + escaped + ')\\s*\\(', 'g') + const calls = [] + let match + while ((match = re.exec(code))) { + const prefix = code.slice(Math.max(0, match.index - 30), match.index) + if (/function\s*$/.test(prefix)) continue + const open = code.indexOf('(', match.index + match[1].length) + const parsed = splitArgs(code, open) + if (!parsed) continue + calls.push({ name: match[1], offset: match.index, end: parsed.end, args: parsed.args }) + re.lastIndex = parsed.end + 1 + } + return calls +} + +function blockKind (code, open) { + const prefix = code.slice(Math.max(0, open - 300), open) + if (/\b(?:for|while)\s*\([^{}]*\)\s*$/.test(prefix) || /\bdo\s*$/.test(prefix)) return 'loop' + if (/\b(?:if|else|switch|catch|try|finally)\b[^{}]*$/.test(prefix)) return 'branch' + if (/(?:=>|\bfunction\b)[^{}]*$/.test(prefix)) return 'function' + return 'block' +} + +function blocksOf (code) { + const blocks = [] + const stack = [] + for (let i = 0; i < code.length; i++) { + if (code[i] === '{') { + const block = { id: blocks.length, open: i, close: code.length, kind: blockKind(code, i) } + blocks.push(block) + stack.push(block) + } else if (code[i] === '}' && stack.length) { + stack.pop().close = i + } + } + return blocks +} + +function ancestry (blocks, offset) { + return blocks.filter((b) => b.open < offset && offset < b.close) +} + +function assignmentsOf (code) { + const assignments = [] + const re = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;\n]+)/g + let match + while ((match = re.exec(code))) assignments.push({ name: match[1], rhs: match[2].trim(), offset: match.index }) + return assignments +} + +function unwrap (expr) { + let value = expr.trim() + let changed = true + while (changed) { + changed = false + const wrapper = /^(?:Number|String)\s*\((.*)\)$/.exec(value) + if (wrapper) { value = wrapper[1].trim(); changed = true; continue } + const guarded = /^(?:[A-Za-z_$][\w$]*\s*&&\s*)?([A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*)$/.exec(value) + if (guarded) value = guarded[1] + } + return value +} + +function resolveExpr (expr, assignments, before, seen) { + const trail = seen || new Set() + let value = unwrap(expr) + if (!/^[A-Za-z_$][\w$]*$/.test(value) || trail.has(value)) return value + const prior = assignments.filter((a) => a.name === value && a.offset < before).pop() + if (!prior) return value + trail.add(value) + return resolveExpr(prior.rhs, assignments, prior.offset, trail) +} + +function rootAndProperty (expr) { + const members = [...expr.matchAll(/\b([A-Za-z_$][\w$]*)\.(blockIndex|requestBlock|deadlineBlock|appliedBlock|requestId)\b/g)] + return members.length ? { root: members[0][1], property: members[0][2] } : null +} + +function requestRelatedHeight (heightArg, requestArg, assignments, before) { + const height = resolveExpr(heightArg, assignments, before) + const request = resolveExpr(requestArg, assignments, before) + const hMember = rootAndProperty(height) + const rMember = rootAndProperty(request) + if (hMember && rMember && hMember.root === rMember.root && rMember.property === 'requestId') return true + if (/^(?:blockIndex|requestBlock)$/.test(height) && /^(?:requestId|id)$/.test(request)) return true + return false +} + +function sameIndexer (wait, read, src) { + const left = (wait.args[1] || '').replace(/\s+/g, '') + const right = (read.args[1] || '').replace(/\s+/g, '') + if (left === right) return true + const lineStart = src.lastIndexOf('\n', wait.offset) + 1 + const line = src.slice(lineStart, src.indexOf('\n', wait.offset) === -1 ? src.length : src.indexOf('\n', wait.offset)) + return /for\s*\(\s*const\s+ix\s+of\s+venue\.indexers\s*\)/.test(line) && left === 'ix.index' +} + +function dominates (wait, read) { + if (wait.offset >= read.offset) return false + const readIds = new Set(read.ancestry.map((b) => b.id)) + return wait.ancestry.every((b) => readIds.has(b.id)) +} + +function markerFor (lines, lineNumber) { + const own = lines[lineNumber - 1] || '' + if (OPT_OUT.test(own)) return own.trim() + for (let i = lineNumber - 2; i >= 0 && /^\s*\/\//.test(lines[i]); i--) { + if (OPT_OUT.test(lines[i])) return lines[i].trim() + } + return null +} + +function scanSource (src, rel) { + const code = blankNonCode(src) + const lines = src.split('\n') + const blocks = blocksOf(code) + const assignments = assignmentsOf(code) + const readAliases = aliasesFor(code, 'readRequestRow') + const waitAliases = aliasesFor(code, 'waitForHeightWithClear') + const waits = callsFor(code, waitAliases).filter((c) => c.args.length >= 3).map((call) => ({ + ...call, + line: lineAt(src, call.offset), + ancestry: ancestry(blocks, call.offset), + })) + const reads = callsFor(code, readAliases).filter((c) => c.args.length >= 3).map((call) => ({ + ...call, + file: rel, + line: lineAt(src, call.offset), + identifier: (call.args[2] || '').replace(/\s+/g, ' ').trim(), + ancestry: ancestry(blocks, call.offset), + })) + + return reads.map((read) => { + const marker = markerFor(lines, read.line) + if (marker) return { ...read, status: 'exception', reason: marker } + const guard = waits.filter((wait) => dominates(wait, read) && sameIndexer(wait, read, src) && + requestRelatedHeight(wait.args[2], read.args[2], assignments, read.offset)).pop() + if (!guard) return { ...read, status: 'unguarded', reason: 'no dominating request-related indexer-height wait' } + const readLoops = read.ancestry.filter((b) => b.kind === 'loop') + const waitLoops = new Set(guard.ancestry.filter((b) => b.kind === 'loop').map((b) => b.id)) + const loopDominance = readLoops.some((b) => !waitLoops.has(b.id)) + return { + ...read, + status: 'guarded', + reason: 'height wait at ' + rel + ':' + guard.line + + (loopDominance ? ' dominates the enclosing loop' : ' dominates this read') + + ' and targets ' + guard.args[2].replace(/\s+/g, ' ').trim(), + } + }) +} + +function collectFiles (inputs) { + const files = [] + const missing = [] + const walk = (entry) => { + if (!fs.existsSync(entry)) { missing.push(entry); return } + const stat = fs.statSync(entry) + if (stat.isDirectory()) { + for (const child of fs.readdirSync(entry, { withFileTypes: true })) { + if (child.isDirectory()) walk(path.join(entry, child.name)) + else if (child.isFile() && child.name.endsWith('.js')) files.push(path.join(entry, child.name)) + } + } else if (stat.isFile() && entry.endsWith('.js')) files.push(entry) + } + inputs.forEach((entry) => walk(path.resolve(entry))) + return { files: [...new Set(files)].sort(), missing } +} + +function scanPaths (inputs) { + const collected = collectFiles(inputs) + const calls = [] + for (const file of collected.files) { + const rel = slash(path.relative(ROOT, file)) + calls.push(...scanSource(fs.readFileSync(file, 'utf8'), rel)) + } + return { + files: collected.files, + missing: collected.missing, + calls, + guarded: calls.filter((c) => c.status === 'guarded'), + unguarded: calls.filter((c) => c.status === 'unguarded'), + exceptions: calls.filter((c) => c.status === 'exception'), + } +} + +function printResult (result) { + console.log('request-row-wait: files-read ' + result.files.length + ', call-sites-read ' + result.calls.length) + for (const hit of result.calls) { + console.log(hit.status.toUpperCase() + ' ' + hit.file + ':' + hit.line + + ' identifier=' + hit.identifier + ' - ' + hit.reason) + } + console.log('request-row-wait: guarded ' + result.guarded.length + ', unguarded ' + + result.unguarded.length + ', acknowledged-exceptions ' + result.exceptions.length) +} + +function main (argv) { + const args = argv.filter((arg) => arg !== '--list') + const inputs = args.length ? args : [DEFAULT_PATH] + const result = scanPaths(inputs) + if (result.missing.length) { + result.missing.forEach((entry) => console.error('request-row-wait: input path does not exist: ' + entry)) + return 2 + } + if (!result.files.length) { + console.error('request-row-wait: refused: read zero files from the supplied input') + return 2 + } + if (!result.calls.length) { + console.error('request-row-wait: refused: read ' + result.files.length + + ' file(s) but found zero request-row reads') + return 2 + } + printResult(result) + if (result.unguarded.length) { + console.error('request-row-wait: every unguarded read needs a request-block commit wait or an explained marker') + return 1 + } + return 0 +} + +module.exports = { blankNonCode, collectFiles, scanPaths, scanSource } + +if (require.main === module) process.exit(main(process.argv.slice(2))) diff --git a/scripts/check-wait-swallow.js b/scripts/check-wait-swallow.js index 9ae521d8..12185607 100644 --- a/scripts/check-wait-swallow.js +++ b/scripts/check-wait-swallow.js @@ -29,6 +29,8 @@ * * - wrap the wait in requireRow() from test/helpers/requireRow.js (the * fixture-builder shape: fail at the wait, naming the row that never came) + * - hand it to requireRow.withProbe(row, ...), the same helper's companion, + * which throws on a falsy row and folds a give-up diagnosis into the message * - guard it with an explicit `if (!row) throw` * - assert on the row (`assert(row, ...)`, `expect(row)...`) before using it * - hand it straight back (`return await db.waitForX(...)`) or test it inline @@ -107,8 +109,12 @@ function guardedByThrow(lines, from, name){ const esc = name.replace(/[$]/g, '\\$') const nullTest = new RegExp('if\\s*\\(\\s*!\\s*' + esc + '\\b') const asserted = new RegExp('(?:assert|expect)[\\w.]*\\([^;]*\\b' + esc + '\\b') + // requireRow.withProbe(row, ...) throws on a falsy row exactly as requireRow + // does, so a wait handed to it fails loud rather than storing a null. + const probed = new RegExp('requireRow\\.withProbe\\s*\\(\\s*' + esc + '\\b') for (let i = from + 1; i < lines.length; i++){ if (asserted.test(lines[i])) return true + if (probed.test(lines[i])) return true if (i > from + 1 && METHOD_HEAD.test(lines[i]) && !NOT_A_HEAD.test(lines[i])) break if (!nullTest.test(lines[i])) continue // The guard's block: this line plus the few that close it. diff --git a/scripts/prepare-bf6-mixed-hub.js b/scripts/prepare-bf6-mixed-hub.js new file mode 100644 index 00000000..53647971 --- /dev/null +++ b/scripts/prepare-bf6-mixed-hub.js @@ -0,0 +1,150 @@ +'use strict' + +const assert = require('assert') +const fs = require('fs') +const path = require('path') +const { execFileSync } = require('child_process') + +const MIXED_HUB_REVISION = '529ebe7aedb19ed150aedc294369da78f0280892' +const MARKER = '.bf6-mixed-checkout.json' + +function git (args, options) { + return execFileSync('git', args, Object.assign({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, options || {})).trim() +} + +function isCheckout (repo, allowGitFile) { + const dotGit = path.join(repo, '.git') + if (!fs.existsSync(dotGit)) return false + if (!fs.statSync(dotGit).isDirectory() && !allowGitFile) return false + try { + return git(['-C', repo, 'rev-parse', '--is-inside-work-tree']) === 'true' + } catch (_) { + return false + } +} + +function repositorySource (buildHub, explicitSource) { + if (explicitSource) { + const source = path.resolve(explicitSource) + assert.ok(isCheckout(source, true), source + ' is not a real git checkout') + return source + } + if (isCheckout(buildHub)) return buildHub + return upstreamRepository(buildHub) +} + +function upstreamRepository (buildHub) { + const pkg = JSON.parse(fs.readFileSync(path.join(buildHub, 'package.json'), 'utf8')) + const repository = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository && pkg.repository.url + assert.ok(repository, buildHub + '/package.json has no repository source') + return repository +} + +function hasCommit (repo, revision) { + try { + git(['-C', repo, 'cat-file', '-e', revision + '^{commit}']) + return true + } catch (_) { + return false + } +} + +function targetPaths (workspace, stack) { + assert.match(stack, /^[a-zA-Z0-9_-]+$/, 'unsafe attest-mirror stack id') + const parent = path.resolve(workspace, 'bf6-mixed') + const root = path.join(parent, stack) + return { parent, root, hub: path.join(root, 'xchain-hub'), marker: path.join(root, MARKER) } +} + +function createMixedCheckout (config) { + const paths = targetPaths(config.workspace, config.stack) + assert.ok(!fs.existsSync(paths.root), paths.root + ' already exists; the previous BF6 checkout was not removed') + const buildHub = path.resolve(config.buildHub) + const modules = path.resolve(config.modules || path.join(buildHub, 'node_modules')) + assert.ok(fs.existsSync(modules), modules + ' does not exist; the mixed hub needs installed dependencies') + const source = repositorySource(buildHub, config.source) + + fs.mkdirSync(paths.parent, { recursive: true }) + try { + git(['clone', '--no-checkout', '--quiet', source, paths.hub]) + // A shallow source (the CI venue's depth-1 sibling clone) lacks the pinned + // commit, so fetch that one commit from the hub's declared upstream. + if (!hasCommit(paths.hub, MIXED_HUB_REVISION)) { + git(['-C', paths.hub, 'fetch', '--quiet', '--depth', '1', upstreamRepository(buildHub), MIXED_HUB_REVISION]) + } + git(['-C', paths.hub, 'checkout', '--detach', '--quiet', MIXED_HUB_REVISION]) + const head = git(['-C', paths.hub, 'rev-parse', 'HEAD']) + assert.strictEqual(head, MIXED_HUB_REVISION, 'mixed hub checkout resolved to the wrong revision') + fs.symlinkSync(fs.realpathSync(modules), path.join(paths.hub, 'node_modules'), 'dir') + fs.writeFileSync(paths.marker, JSON.stringify({ version: 1, stack: config.stack, revision: head }) + '\n') + return paths.root + } catch (error) { + if (fs.existsSync(paths.root)) fs.rmSync(paths.root, { recursive: true, force: true }) + throw error + } +} + +function removeMixedCheckout (config) { + const paths = targetPaths(config.workspace, config.stack) + if (!fs.existsSync(paths.root)) return false + assert.ok(fs.existsSync(paths.marker), paths.root + ' has no BF6 checkout marker; refusing to remove it') + const marker = JSON.parse(fs.readFileSync(paths.marker, 'utf8')) + assert.strictEqual(marker.stack, config.stack, paths.marker + ' belongs to a different stack') + assert.strictEqual(marker.revision, MIXED_HUB_REVISION, paths.marker + ' names an unexpected revision') + fs.rmSync(paths.root, { recursive: true }) + return true +} + +function parseArgs (argv) { + const args = argv.slice() + const action = args.shift() + const values = {} + while (args.length > 0) { + const key = args.shift() + assert.ok(/^--[a-z-]+$/.test(key) && args.length > 0, 'bad BF6 checkout argument ' + key) + values[key.slice(2)] = args.shift() + } + assert.ok(action === 'create' || action === 'remove', 'BF6 checkout action must be create or remove') + assert.ok(values.workspace, '--workspace is required') + assert.ok(values.stack, '--stack is required') + if (action === 'create') assert.ok(values['build-hub'], '--build-hub is required') + return { + action, + config: { + workspace: values.workspace, + stack: values.stack, + buildHub: values['build-hub'], + source: process.env.ATTEST_MIRROR_HUB_GIT_SOURCE, + modules: process.env.ATTEST_MIRROR_HUB_NODE_MODULES, + }, + } +} + +function main () { + const parsed = parseArgs(process.argv.slice(2)) + if (parsed.action === 'create') { + process.stdout.write(createMixedCheckout(parsed.config) + '\n') + } else { + removeMixedCheckout(parsed.config) + } +} + +module.exports = { + MIXED_HUB_REVISION, + MARKER, + isCheckout, + repositorySource, + targetPaths, + createMixedCheckout, + removeMixedCheckout, + parseArgs, +} + +if (require.main === module) { + try { + main() + } catch (error) { + process.stderr.write(error.message + '\n') + process.exitCode = 1 + } +} diff --git a/scripts/run-attest-mirror.js b/scripts/run-attest-mirror.js new file mode 100644 index 00000000..da713bbc --- /dev/null +++ b/scripts/run-attest-mirror.js @@ -0,0 +1,228 @@ +'use strict' + +const assert = require('assert') +const fs = require('fs') +const path = require('path') +const { spawn } = require('child_process') +const { createRequire } = require('module') + +const REPO_ROOT = path.resolve(__dirname, '..') +const MAX_CONCURRENCY = 3 +const ENV_FAILURE_ABORT_THRESHOLD = 3 +const LEG_DIRS = [ + path.join('test', 'attestMirror'), + path.join('test', 'attestMirror', 'barrier_family'), +] + +function listLegs (root, requested) { + if (requested.length > 0) { + const legs = [] + for (const value of requested) { + const relative = path.normalize(value) + assert.ok(!path.isAbsolute(relative) && !relative.startsWith('..' + path.sep), + 'attest-mirror leg must be inside the repository: ' + value) + if (path.basename(relative) === '*.test.js') { + const dir = path.dirname(relative) + assert.ok(LEG_DIRS.includes(dir), 'attest-mirror glob is outside the leg directories: ' + value) + for (const name of fs.readdirSync(path.join(root, dir))) { + if (name.endsWith('.test.js')) legs.push(path.join(dir, name)) + } + continue + } + const absolute = path.join(root, relative) + assert.ok(LEG_DIRS.includes(path.dirname(relative)), + 'attest-mirror leg is outside the leg directories: ' + value) + assert.ok(fs.statSync(absolute).isFile() && relative.endsWith('.test.js'), + 'attest-mirror leg is not a test file: ' + value) + legs.push(relative) + } + return [...new Set(legs)].sort() + } + + const legs = [] + for (const dir of LEG_DIRS) { + for (const name of fs.readdirSync(path.join(root, dir))) { + if (name.endsWith('.test.js')) legs.push(path.join(dir, name)) + } + } + return legs.sort() +} + +function concurrencyFrom (raw) { + const value = raw === undefined || raw === '' ? MAX_CONCURRENCY : Number(raw) + assert.ok(Number.isInteger(value) && value >= 1 && value <= MAX_CONCURRENCY, + 'ATTEST_MIRROR_CONCURRENCY must be an integer from 1 to ' + MAX_CONCURRENCY) + return value +} + +function driverCommand (driver, args) { + return path.extname(driver) === '.js' + ? { command: process.execPath, args: [driver, ...args] } + : { command: driver, args } +} + +function preflightReadinessHelper (driver) { + const helper = path.join(path.dirname(driver), 'wait-attest-mirror-stack.js') + assert.ok(fs.existsSync(helper) && fs.statSync(helper).isFile(), + 'attest-mirror readiness helper does not exist: ' + helper) + const source = fs.readFileSync(helper, 'utf8') + const requireFromHelper = createRequire(helper) + const dependencies = new Set() + const requirePattern = /\brequire\s*\(\s*(['"])([^'"]+)\1\s*\)/g + for (const match of source.matchAll(requirePattern)) dependencies.add(match[2]) + for (const dependency of dependencies) { + try { + requireFromHelper.resolve(dependency) + } catch (error) { + throw new Error('attest-mirror readiness preflight could not resolve module "' + dependency + + '" from ' + path.dirname(helper), { cause: error }) + } + } +} + +function runDriver (driver, phase, job, extraArgs, options) { + const args = [phase, '--stack', job.stack, '--slot', String(job.slot), '--leg', job.leg, ...(extraArgs || [])] + const invocation = driverCommand(driver, args) + const spawnFn = (options && options.spawn) || spawn + const child = spawnFn(invocation.command, invocation.args, { + cwd: (options && options.root) || REPO_ROOT, + env: Object.assign({}, process.env, { + ATTEST_MIRROR_STACK_ID: job.stack, + ATTEST_MIRROR_SLOT: String(job.slot), + ATTEST_MIRROR_LEG: job.leg, + }), + stdio: 'inherit', + }) + return new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => resolve(signal ? 128 : (code === null ? 1 : code))) + }) +} + +async function runLeg (driver, job, options) { + let failure = null + try { + for (const step of [ + ['up', []], + ['ready', ['--require', 'indexer-schema', '--require', 'miner-health']], + ['seed', []], + ['run', []], + ]) { + const code = await runDriver(driver, step[0], job, step[1], options) + if (code !== 0) { + failure = { phase: step[0], code } + break + } + } + } catch (error) { + failure = { phase: 'spawn', code: 1, error } + } finally { + try { + const code = await runDriver(driver, 'down', job, [], options) + if (code !== 0 && failure === null) failure = { phase: 'down', code } + } catch (error) { + if (failure === null) failure = { phase: 'down', code: 1, error } + } + } + return Object.assign({ leg: job.leg, stack: job.stack }, failure || { phase: null, code: 0 }) +} + +async function runAll (config) { + const root = config.root || REPO_ROOT + const legs = listLegs(root, config.legs || []) + const concurrency = concurrencyFrom(config.concurrency) + const driver = path.resolve(root, config.driver) + assert.ok(fs.existsSync(driver), 'ATTEST_MIRROR_STACK_DRIVER does not exist: ' + driver) + assert.ok(legs.length > 0, 'no attest-mirror legs selected') + preflightReadinessHelper(driver) + + const runId = String(config.runId || (Date.now().toString(36) + '-' + process.pid)) + assert.ok(/^[a-zA-Z0-9_-]+$/.test(runId), 'ATTEST_MIRROR_RUN_ID contains unsafe characters') + let next = 0 + const results = [] + let completedThrough = -1 + let streakPhase = null + let streakLegs = [] + let abort = null + + function observeCompletedLegs () { + if (abort) return + while (results[completedThrough + 1]) { + const result = results[++completedThrough] + if (result.code !== 0 && result.phase !== 'run') { + if (result.phase !== streakPhase) { + streakPhase = result.phase + streakLegs = [] + } + streakLegs.push(result.leg) + if (streakLegs.length >= ENV_FAILURE_ABORT_THRESHOLD) { + abort = { + phase: streakPhase, + legs: streakLegs.slice(-ENV_FAILURE_ABORT_THRESHOLD), + } + return + } + } else { + streakPhase = null + streakLegs = [] + } + } + } + + async function worker (slot) { + for (;;) { + if (abort) return + const index = next++ + if (index >= legs.length) return + const job = { leg: legs[index], slot, stack: 'am-' + runId + '-' + String(index + 1) } + process.stdout.write('[attest-mirror] allocate ' + job.stack + ' slot=' + slot + ' leg=' + job.leg + '\n') + const result = await runLeg(driver, job, config) + results[index] = result + process.stdout.write('[attest-mirror] release ' + job.stack + ' exit=' + result.code + + (result.phase ? ' phase=' + result.phase : '') + '\n') + observeCompletedLegs() + } + } + + await Promise.all(Array.from({ length: Math.min(concurrency, legs.length) }, (_, slot) => worker(slot))) + if (abort) { + throw new Error('attest-mirror abort after ' + ENV_FAILURE_ABORT_THRESHOLD + ' consecutive ' + abort.phase + + ' failures: ' + abort.legs.join(', ')) + } + return results +} + +async function main () { + const driver = process.env.ATTEST_MIRROR_STACK_DRIVER + assert.ok(driver, 'ATTEST_MIRROR_STACK_DRIVER must name the isolated-stack lifecycle driver') + const selectedFromEnv = process.env.ATTEST_MIRROR_LEGS !== undefined + const selected = selectedFromEnv + ? process.env.ATTEST_MIRROR_LEGS.split(',').map((leg) => leg.trim()).filter(Boolean) + : process.argv.slice(2) + if (selectedFromEnv) assert.ok(selected.length > 0, 'no attest-mirror legs selected') + const results = await runAll({ + root: REPO_ROOT, + driver, + legs: selected, + concurrency: process.env.ATTEST_MIRROR_CONCURRENCY, + runId: process.env.ATTEST_MIRROR_RUN_ID, + }) + const failed = results.filter((result) => result.code !== 0) + const failuresByPhase = new Map() + for (const result of failed) failuresByPhase.set(result.phase, (failuresByPhase.get(result.phase) || 0) + 1) + const phaseSummary = failuresByPhase.size === 0 + ? 'none' + : [...failuresByPhase].map(([phase, count]) => phase + '=' + count).join(', ') + process.stdout.write('[attest-mirror] ' + (results.length - failed.length) + ' legs passed, ' + failed.length + + ' failed; failures by phase: ' + phaseSummary + '\n') + if (failed.length > 0) process.exitCode = Math.min(failed.length, 255) +} + +module.exports = { MAX_CONCURRENCY, LEG_DIRS, listLegs, concurrencyFrom, driverCommand, runDriver, runLeg, runAll } + +if (require.main === module) { + main().catch((error) => { + process.stderr.write('[attest-mirror] ' + error.stack + '\n') + process.exitCode = 1 + }) +} diff --git a/scripts/run-live-tier.js b/scripts/run-live-tier.js index bc83937e..0fd08946 100644 --- a/scripts/run-live-tier.js +++ b/scripts/run-live-tier.js @@ -61,6 +61,8 @@ * Usage: * node scripts/run-live-tier.js run the lane * node scripts/run-live-tier.js --list print the roster and exit + * node scripts/run-live-tier.js --force-docker + * ignore HUB_DB_* and require the disposable Docker database path * * Exit: 0 green; 1 red (roster drift, a failing case, or a suite that ran * nothing); 95 when the HOST cannot run the tier truthfully, which the shared @@ -153,6 +155,7 @@ function suitesExcluded(roster) { function tallyByFile(report, repoRoot = REPO_ROOT) { const tally = new Map() const bump = (file, field) => { + if (!file) return const rel = path.relative(repoRoot, file).split(path.sep).join('/') if (!tally.has(rel)) tally.set(rel, { passing: 0, failing: 0, pending: 0 }) tally.get(rel)[field]++ @@ -199,6 +202,53 @@ function formatTally(expected, tally) { return lines } +function mochaSummary(report) { + const stats = report.stats || {} + const count = (field, entries) => Number.isFinite(stats[field]) + ? stats[field] + : (report[entries] || []).length + const lines = [' ' + count('passes', 'passes') + ' passing'] + const pending = count('pending', 'pending') + if (pending) lines.push(' ' + pending + ' pending') + lines.push(' ' + count('failures', 'failures') + ' failing') + return lines +} + +const DB_PRIVILEGE_CODES = new Set([ + 'ER_ACCESS_DENIED_ERROR', + 'ER_DBACCESS_DENIED_ERROR', + 'ER_TABLEACCESS_DENIED_ERROR', + 'ER_COLUMNACCESS_DENIED_ERROR', + 'ER_PROCACCESS_DENIED_ERROR', + 'ER_SPECIFIC_ACCESS_DENIED_ERROR' +]) +const DB_PRIVILEGE_ERRNOS = new Set([1044, 1045, 1142, 1143, 1227, 1370]) + +function isDbPrivilegeError(err) { + if (!err) return false + let node = err + for (let depth = 0; node && depth < 5; depth++) { + if (DB_PRIVILEGE_CODES.has(node.code)) return true + if (node.errno !== undefined && DB_PRIVILEGE_ERRNOS.has(Number(node.errno))) return true + node = node.cause + } + const detail = [err.code, err.errno, err.sqlState, err.message, err.stack] + .filter(v => v !== undefined && v !== null).join('\n') + return /\bER_(?:ACCESS_DENIED|DBACCESS_DENIED|TABLEACCESS_DENIED|COLUMNACCESS_DENIED|PROCACCESS_DENIED|SPECIFIC_ACCESS_DENIED)_ERROR\b/i.test(detail) + || /\baccess denied for user\b/i.test(detail) + || /\b(?:create|drop|alter|select|insert|update|delete|execute) command denied to user\b/i.test(detail) +} + +function isBeforeAllHookFailure(failure) { + const hook = [failure && failure.title, failure && failure.fullTitle] + .filter(Boolean).join(' ') + return /\bbefore(?:[ -])?all\b.*\bhook\b|\bhook\b.*\bbefore(?:[ -])?all\b/i.test(hook) +} + +function isDbPrivilegeBeforeAllFailure(failure) { + return isBeforeAllHookFailure(failure) && isDbPrivilegeError(failure.err) +} + // ---- the lane ------------------------------------------------------------ // Can this host run the tier at all? The suites obtain a database one of two @@ -212,15 +262,42 @@ function formatTally(expected, tally) { // with no container runtime says nothing about the code. Reporting it as a red // commit is the misattribution the exit contract exists to prevent. const VENUE_EXIT = 95 +const HUB_DB_KEYS = ['HUB_DB_HOST', 'HUB_DB_PORT', 'HUB_DB_NAME', 'HUB_DB_USER', 'HUB_DB_PASS'] + +function classifyReport(report, problems) { + const failures = report.failures || [] + const venueFailures = failures.filter(isDbPrivilegeBeforeAllFailure) + const hasCommitFailure = failures.some(f => !isDbPrivilegeBeforeAllFailure(f)) + return { + venueFailures, + exitCode: hasCommitFailure + ? 1 + : (venueFailures.length ? VENUE_EXIT : (problems.length ? 1 : 0)) + } +} + +function optionForcesDocker(options) { + return options === true || !!(options && options.forceDocker) +} -function liveTierBlocker(env = process.env) { - if (env.HUB_DB_USER && env.HUB_DB_PASS) return null +function liveTierBlocker(env = process.env, options = {}) { + const forceDocker = optionForcesDocker(options) + if (!forceDocker && env.HUB_DB_USER && env.HUB_DB_PASS) return null const probe = spawnSync('docker', ['version', '--format', '{{.Server.Version}}'], { stdio: 'ignore' }) if (probe.error || probe.status !== 0) - return 'no container runtime and no HUB_DB_USER/HUB_DB_PASS, so every live suite would skip itself' + return forceDocker + ? 'Docker was required but no container runtime is available' + : 'no container runtime and no HUB_DB_USER/HUB_DB_PASS, so every live suite would skip itself' return null } +function mochaEnvironment(env = process.env, options = {}) { + const childEnv = { ...env } + if (optionForcesDocker(options)) + for (const key of HUB_DB_KEYS) delete childEnv[key] + return childEnv +} + // Remove disposable-MariaDB containers a previous run left behind. // // test/helpers/disposableHubDb.js binds a FIXED host port (13307) and names the @@ -255,7 +332,7 @@ function reapStaleFixtureContainers(now = Date.now()) { return stale.length } -function runMocha(files, roster) { +function runMocha(files, roster, options = {}) { const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'xc714-live-')), 'report.json') const bin = path.join(REPO_ROOT, 'node_modules', '.bin', 'mocha') const args = [ @@ -269,7 +346,11 @@ function runMocha(files, roster) { '--reporter-option', 'output=' + out, ...files ] - const res = spawnSync(bin, args, { cwd: REPO_ROOT, stdio: 'inherit' }) + const res = spawnSync(bin, args, { + cwd: REPO_ROOT, + stdio: 'inherit', + env: mochaEnvironment(process.env, options) + }) return { out, status: res.status, error: res.error } } @@ -301,19 +382,23 @@ function main(argv) { return 1 } - const blocker = liveTierBlocker() + const forceDocker = argv.includes('--force-docker') || argv.includes('--forceDocker') + const blocker = liveTierBlocker(process.env, { forceDocker }) if (blocker) { console.error('live tier: this host cannot run the tier truthfully: ' + blocker + '.') - console.error('live tier: the commit was NOT evaluated. Run the lane on a venue with Docker,') - console.error(' or export HUB_DB_HOST/PORT/USER/PASS for a database it may create schemas in.') + console.error('live tier: the commit was NOT evaluated. Run the lane on a venue with Docker' + + (forceDocker ? '.' : ',')) + if (!forceDocker) + console.error(' or export HUB_DB_HOST/PORT/USER/PASS for a database it may create schemas in.') return VENUE_EXIT } const reaped = reapStaleFixtureContainers() if (reaped) console.log('live tier: removed ' + reaped + ' stale disposable-MariaDB container(s) from an earlier run') - console.log('live tier: running ' + expected.length + ' suite(s) on ' + os.hostname()) - const { out, status, error } = runMocha(expected, roster) + console.log('live tier: running ' + expected.length + ' suite(s) on ' + os.hostname() + + (forceDocker ? ' (--force-docker)' : '')) + const { out, status, error } = runMocha(expected, roster, { forceDocker }) if (error) { console.error('live tier: could not start mocha: ' + error.message) return 1 @@ -333,6 +418,10 @@ function main(argv) { const tally = tallyByFile(report) const problems = classify(expected, tally) + const outcome = classifyReport(report, problems) + + console.log('') + for (const line of mochaSummary(report)) console.log(line) console.log('\nlive tier: per-suite tally') for (const line of formatTally(expected, tally)) console.log(line) @@ -343,22 +432,33 @@ function main(argv) { for (const e of excluded) console.log(' ' + e.file.replace('test/integration/', '') + ': ' + e.why) } - if (problems.length) { + if (outcome.exitCode === VENUE_EXIT) { + console.error('\nlive tier: VENUE FAILURE') + for (const f of outcome.venueFailures) + console.error(' ' + (f.file ? path.relative(REPO_ROOT, f.file) : '') + + ' :: ' + f.fullTitle + '\n ' + + String((f.err && f.err.message) || '').split('\n')[0]) + console.error('live tier: the commit was NOT evaluated because every failure was a before-all') + console.error(' database privilege failure.\n') + return VENUE_EXIT + } + + if (outcome.exitCode !== 0) { console.error('\nlive tier: FAILED') for (const p of problems) console.error(' ' + p.file + ': ' + p.detail) for (const f of report.failures || []) - console.error(' ' + path.relative(REPO_ROOT, f.file) + ' :: ' + f.fullTitle + console.error(' ' + (f.file ? path.relative(REPO_ROOT, f.file) : '') + ' :: ' + f.fullTitle + '\n ' + String((f.err && f.err.message) || '').split('\n')[0]) console.error('') return 1 } - console.log('\nlive tier: GREEN (' + (report.stats && report.stats.passes) + ' passing in ' - + expected.length + ' suite(s))\n') + console.log('\nlive tier: GREEN (' + expected.length + ' suite(s))\n') return 0 } module.exports = { discoverSuites, readRoster, auditRoster, suitesToRun, suitesExcluded, tallyByFile, classify, - liveTierBlocker, VENUE_EXIT, ROSTER_FILE, LIVE_DIR } + mochaSummary, isDbPrivilegeError, isBeforeAllHookFailure, isDbPrivilegeBeforeAllFailure, classifyReport, + liveTierBlocker, mochaEnvironment, VENUE_EXIT, ROSTER_FILE, LIVE_DIR } if (require.main === module) process.exit(main(process.argv.slice(2))) diff --git a/scripts/sleep-flake-baseline.json b/scripts/sleep-flake-baseline.json index 52eab2ae..bf6339ef 100644 --- a/scripts/sleep-flake-baseline.json +++ b/scripts/sleep-flake-baseline.json @@ -1,5 +1,5 @@ { "check": "fixed-settle sleep call sites under test/", - "note": "Ratchet only. Lower this when waits are converted to condition waits; never raise it. Raised once, 26 to 38, when the detector was widened to read the inline `await new Promise(r => setTimeout(r, n))` form alongside `await sleep(n)`: that rise was the gate seeing a shape it had been blind to, not new debt. Lowered once, 38 to 31, by the C-51a8ad sweep: seven settles across controllerPolicy, vmContractCustody and oracleBatchTamper became condition waits on consensusWait `waitUntil` and indexerWait `waitForTxIndexed`. The sites left in those files, and the two 15s settles in oracleBatchReplay, are deliberate observation windows for an ABSENCE (a throttle that must not fire twice, a quorum that must not form, a ghost row that must not appear, a snapshot a chain-only node must not invent): the elapsed time IS the claim there, and converting one to a condition wait would make its assertion vacuous. The oracleBatchReplay pair was converted once and reverted, because a poll that returns the instant a >= floor is met closes the window the file's later `extra.length === 0` assertions observe.", - "count": 31 + "note": "Ratchet only. Lower this when waits are converted to condition waits; never raise it. Raised once, 26 to 38, when the detector was widened to read the inline `await new Promise(r => setTimeout(r, n))` form alongside `await sleep(n)`: that rise was the gate seeing a shape it had been blind to, not new debt. Lowered once, 38 to 31, by the C-51a8ad sweep: seven settles across controllerPolicy, vmContractCustody and oracleBatchTamper became condition waits on consensusWait `waitUntil` and indexerWait `waitForTxIndexed`. The sites left in those files, and the two 15s settles in oracleBatchReplay, are deliberate observation windows for an ABSENCE (a throttle that must not fire twice, a quorum that must not form, a ghost row that must not appear, a snapshot a chain-only node must not invent): the elapsed time IS the claim there, and converting one to a condition wait would make its assertion vacuous. The oracleBatchReplay pair was converted once and reverted, because a poll that returns the instant a >= floor is met closes the window the file's later `extra.length === 0` assertions observe. Lowered 31 to 27 when the four PBFT never-apply windows in multiHubActivationBoundary, multiHubByzantine and multiHubByzantineF2 moved onto consensusWait `assertNeverApplied`: the window keeps its fixed length but is polled, so a hub that applies fails at that instant, and each keeps its final direct read so a failing hub DB still reads red.", + "count": 27 } diff --git a/scripts/wait-attest-mirror-stack.js b/scripts/wait-attest-mirror-stack.js new file mode 100644 index 00000000..8305644e --- /dev/null +++ b/scripts/wait-attest-mirror-stack.js @@ -0,0 +1,144 @@ +'use strict' + +const path = require('path') + +const DEPENDENCY_ROOT = path.resolve(__dirname, '..') +const REQUIRED_DEPENDENCIES = Object.freeze(['mariadb']) + +const REQUIRED_INDEXER_TABLES = Object.freeze([ + 'actions', 'index_addresses', 'index_statuses', 'index_tickers', 'index_transactions', + 'issues', 'price_snapshots', 'transactions', +]) + +function loadDependencies (resolveDependency, loadDependency) { + const resolve = resolveDependency || require.resolve + const load = loadDependency || require + const dependencies = {} + for (const name of REQUIRED_DEPENDENCIES) { + try { + const resolved = resolve(name, { paths: [DEPENDENCY_ROOT] }) + dependencies[name] = load(resolved) + } catch (error) { + if (!error || error.code !== 'MODULE_NOT_FOUND') throw error + throw new Error('Missing dependency "' + name + '" in ' + DEPENDENCY_ROOT + + '; install dependencies in that tree or point the driver at the staged tree that has them.') + } + } + return dependencies +} + +function sleep (ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function responseOk (url, init, fetchImpl) { + try { + const response = await fetchImpl(url, init) + return !!response && response.ok + } catch (_) { + return false + } +} + +async function indexerSchemaReady (config) { + let connection = null + try { + connection = await config.connect({ + host: config.dbHost, + port: config.dbPort, + user: config.dbUser, + password: config.dbPassword, + }) + const placeholders = config.requiredIndexerTables.map(() => '?').join(', ') + const rows = await connection.query( + 'SELECT COUNT(DISTINCT table_name) AS n FROM information_schema.tables ' + + 'WHERE table_schema = ? AND table_name IN (' + placeholders + ')', + [config.indexerDbName, ...config.requiredIndexerTables]) + return Number(rows[0].n) === config.requiredIndexerTables.length + } catch (_) { + return false + } finally { + if (connection) await connection.end().catch(() => {}) + } +} + +async function stackReady (config) { + if (!await indexerSchemaReady(config)) return false + if (!await responseOk(config.indexerStatusUrl, undefined, config.fetchImpl)) return false + for (const url of config.minerHealthUrls) { + if (!await responseOk(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'health', id: 1 }), + }, config.fetchImpl)) return false + } + return true +} + +async function waitForStack (config) { + const deadline = Date.now() + config.timeoutMs + do { + if (await stackReady(config)) return + await config.sleep(config.intervalMs) + } while (Date.now() <= deadline) + throw new Error('attest-mirror stack did not expose the indexer schema and healthy miners inside ' + config.timeoutMs + ' ms') +} + +function configFromEnv (env, dependencies, fetchImpl) { + const host = env.ATTEST_MIRROR_STACK_HOST || '127.0.0.1' + const minerPorts = [env.BTC_REGTEST_MINER_API_PORT || env.REGTEST_MINER_API_PORT || env.MINER_HOST_PORT, + env.LTC_REGTEST_MINER_API_PORT, env.DOGE_REGTEST_MINER_API_PORT].filter(Boolean) + const required = { + dbPort: env.DB_HOST_PORT || env.DATABASE_PORT, + dbPassword: env.DB_PASSWORD || env.INDEXER_DB_PASS, + indexerPort: env.BTC_INDEXER_API_PORT || env.INDEXER_API_PORT || env.INDEXER_HOST_PORT, + } + for (const [key, value] of Object.entries(required)) { + if (!value) throw new Error('missing stack readiness value ' + key) + } + if (minerPorts.length === 0) throw new Error('missing stack readiness value minerPort') + return { + dbHost: host, + dbPort: Number(required.dbPort), + dbUser: env.INDEXER_DB_USER || 'root', + dbPassword: required.dbPassword, + indexerDbName: env.BTC_INDEXER_DB_NAME || env.INDEXER_DB_NAME || 'XChain_BTC_Regtest_Indexer', + requiredIndexerTables: REQUIRED_INDEXER_TABLES, + indexerStatusUrl: 'http://' + host + ':' + required.indexerPort + '/status', + minerHealthUrls: minerPorts.map((port) => 'http://' + host + ':' + port + '/'), + timeoutMs: Number(env.ATTEST_MIRROR_READY_TIMEOUT_MS || 300000), + intervalMs: Number(env.ATTEST_MIRROR_READY_INTERVAL_MS || 3000), + connect: dependencies.mariadb.createConnection, + fetchImpl: fetchImpl, + sleep, + } +} + +async function main (runtime) { + const context = runtime || {} + const dependencies = loadDependencies(context.resolveDependency, context.loadDependency) + const config = configFromEnv(context.env || process.env, dependencies, context.fetchImpl || fetch) + await waitForStack(config) + const stdout = context.stdout || process.stdout + stdout.write('attest-mirror stack ready: indexer schema and ' + config.minerHealthUrls.length + ' miner health endpoint(s)\n') +} + +module.exports = { + DEPENDENCY_ROOT, + REQUIRED_DEPENDENCIES, + REQUIRED_INDEXER_TABLES, + loadDependencies, + responseOk, + indexerSchemaReady, + stackReady, + waitForStack, + configFromEnv, + main, +} + +if (require.main === module) { + main().catch((error) => { + process.stderr.write(error.message + '\n') + process.exitCode = 1 + }) +} diff --git a/src/XChainExplorerConnector.js b/src/XChainExplorerConnector.js index 5bd759c9..946ab920 100644 --- a/src/XChainExplorerConnector.js +++ b/src/XChainExplorerConnector.js @@ -22,6 +22,19 @@ const axios = require('axios'); const { getLogger } = require('./lib/logger'); const logger = getLogger(); +// Per-request cap, same reasoning and same fix as RegtestMinerConnector's +// PING_TIMEOUT_MS: axios defaults to no timeout, so an explorer that accepts +// the socket and never answers (the "503 with zero DB pools" venue left with +// no pool to even answer FROM) left ping() pending forever. initialCheck.test.js +// requires this ping before any action test runs, and the suite runs under +// `mocha --timeout 0`, so nothing else in this stack would ever time the call +// out: a stuck explorer silently stalled the whole CI job instead of failing +// it. getFileRaw carries the same cap because waitForServedFile's own polling +// deadline (envelopeHelper.js) is defeated if a single GET inside the loop can +// hang past it. +const PING_TIMEOUT_MS = 5000; +const FILE_TIMEOUT_MS = 15000; + class XChainExplorerConnector { constructor(url, port) { this.url = "http://"+url+":"+port @@ -41,7 +54,7 @@ class XChainExplorerConnector { var response = null try { - response = await axios.post(this.url, data) + response = await axios.post(this.url, data, { timeout: PING_TIMEOUT_MS }) } catch (err) { logger.info(err) return false @@ -67,7 +80,8 @@ class XChainExplorerConnector { const url = this.url+"/"+coinPrefix+"/api/file/"+actionIndex+"/raw" const response = await axios.get(url, { responseType: 'arraybuffer', - validateStatus: () => true + validateStatus: () => true, + timeout: FILE_TIMEOUT_MS }) return { status: response.status, diff --git a/src/XChainIndexerConnector.js b/src/XChainIndexerConnector.js index d0f1a7d3..ebc840a1 100644 --- a/src/XChainIndexerConnector.js +++ b/src/XChainIndexerConnector.js @@ -19,6 +19,8 @@ ********************************************************************/ const axios = require('axios'); +const { getLogger } = require('./lib/logger'); +const logger = getLogger(); // What the SERVICE said, when it said anything at all. // The indexer refuses a gated call with a non-2xx status whose body is still a @@ -60,8 +62,8 @@ class XChainIndexerConnector { // A probe keeps its boolean sentinel: callers poll on false. Name the // refusal in the log so a gated port is not read as a dead one. const refusal = serviceRefusal(err) - if(refusal) console.warn('Indexer ' + this.url + ' refused ping: ' + refusal) - else console.log(err) + if(refusal) logger.warn('Indexer ' + this.url + ' refused ping: ' + refusal) + else logger.info(err) return false } @@ -89,8 +91,8 @@ class XChainIndexerConnector { // polls it in a loop and must ride out a restarting indexer's non-2xx // window rather than abort the run. Name the refusal instead. const refusal = serviceRefusal(err) - if(refusal) console.warn('Indexer ' + this.url + ' refused health: ' + refusal) - else console.log(err) + if(refusal) logger.warn('Indexer ' + this.url + ' refused health: ' + refusal) + else logger.info(err) return null } @@ -137,7 +139,7 @@ class XChainIndexerConnector { // assertion that fired instead never named authentication. const refusal = serviceRefusal(err) if(refusal) throw new Error('Indexer refused ' + method + ': ' + refusal) - console.log(err) + logger.info(err) return null } if (response.data && response.data.error) { diff --git a/src/db.js b/src/db.js index 971e6103..ad21fe29 100644 --- a/src/db.js +++ b/src/db.js @@ -30,6 +30,9 @@ // order-independent: it only has to happen before the first `new Database()`. function mariadbDriver(){ return require('mariadb'); } +const { getLogger } = require('./lib/logger'); +const logger = getLogger(); + // Parses an integer-valued tunable from an env var, falling back to `def` // only when the var is unset/empty/non-numeric. Plain `parseInt(x) || def` // swallows an explicit "0" (0 is falsy), which silently reinstates a wait @@ -164,7 +167,7 @@ class Database { const fatal = this._isFatalConnectError(e); const spent = attempts >= this.CONNECT_MAX_ATTEMPTS || Date.now() >= deadline; if (fatal || spent) break; - console.log("Can't connect to mariadb at " + this._target() + logger.info("Can't connect to mariadb at " + this._target() + " (attempt " + attempts + "/" + this.CONNECT_MAX_ATTEMPTS + "): " + this._errText(lastError) + ". Trying again..."); await this.sleep(this.CONNECT_RETRY_MS); @@ -842,11 +845,11 @@ class Database { if (itemsClone.length == 0){ return listRow } else { - console.log("ERROR! List items don't match with the items in the database") + logger.info("ERROR! List items don't match with the items in the database") return null } } else { - console.log("ERROR! List items don't have the same length as the items in the database") + logger.info("ERROR! List items don't have the same length as the items in the database") return null } } catch (err) { @@ -874,11 +877,11 @@ class Database { if (rows.length > 0){ listType = parseInt(rows[0]["type"]) } else { - console.log("ERROR! Couldn't get the type of a list") - return null + logger.info("ERROR! Couldn't get the type of a list") + return null } } catch (err) { - console.log(err) + logger.info(err) return null } finally { await connection.release() @@ -938,7 +941,7 @@ class Database { } } - console.log("ERROR: there is no list with action index "+listActionIndex) + logger.info("ERROR: there is no list with action index "+listActionIndex) return null } @@ -1325,8 +1328,11 @@ class Database { // Retained for the give-up report below: the last answers the probe gave // (null means it could not answer) and whether it was ever consulted. let lastLag = null + let lagReason = null let probes = 0 let writeMark = null // highest action_index the indexer has written + let lastWrites = null + let writeReason = null let writeMoved = null // when that mark last advanced // Only waits that were meant to be long can be extended. Short waits are // callers polling for something that should be immediate, and probing on @@ -1348,7 +1354,7 @@ class Database { return row } } catch(err) { - console.log(err) + logger.info(err) } await this.sleep(1000) @@ -1363,6 +1369,9 @@ class Database { probes++ nextProbeAt = Date.now() + probeEvery lastLag = progress.lag + lagReason = progress.lagReason + lastWrites = progress.writes + writeReason = progress.writesReason if (progress.writes !== null){ if (writeMark !== null && progress.writes > writeMark) writeMoved = Date.now() if (writeMark === null || progress.writes > writeMark) writeMark = progress.writes @@ -1374,7 +1383,7 @@ class Database { if (behind || writing){ extensions++ deadline = Date.now() + timeMax - console.log(label + ': ' + logger.info(label + ': ' + (behind ? 'indexer is ' + lastLag + ' blocks behind the chain tip' : 'indexer is still writing action rows (index ' + writeMark + ')') @@ -1401,16 +1410,18 @@ class Database { // and the write signal says whether rows were still landing at give-up time, // which separates "the stack is busy and we ran out of budget" from "the // stack was idle and the row is genuinely absent". - console.log(label + ': GAVE UP after ' + (Date.now() - startMs) + 'ms' + logger.info(label + ': GAVE UP after ' + (Date.now() - startMs) + 'ms' + ' (' + polls + ' polls, ' + extensions + '/' + this.WAIT_MAX_EXTENSIONS + ' extensions, ' + 'timeMax ' + timeMax + 'ms, ' + (!eligible - ? 'not eligible for extension' + ? 'last indexer lag unavailable (probe was never run because the wait was not eligible for extension)' + + ', action writes unavailable (probe was never run because the wait was not eligible for extension)' : probes === 0 - ? 'extension never probed' - : 'last indexer lag ' + (lastLag === null ? 'unknown (probe failed)' : lastLag + ' blocks') - + ', action writes ' + (writeMark === null - ? 'unknown' + ? 'last indexer lag unavailable (probe was never run before give-up)' + + ', action writes unavailable (probe was never run before give-up)' + : 'last indexer lag ' + (lastLag === null ? 'unavailable (' + lagReason + ')' : lastLag + ' blocks') + + ', action writes ' + (lastWrites === null + ? 'unavailable (' + writeReason + ')' : writeMoved === null ? 'idle at index ' + writeMark : 'last advanced ' + (Date.now() - writeMoved) + 'ms ago (index ' + writeMark + ')')) @@ -1428,14 +1439,37 @@ class Database { // signal so the fixed deadline stands: without progress, waiting longer is // indistinguishable from hanging. async _pipelineProgress(){ - if (!this.pool) return { lag: null, writes: null } + if (!this.pool) return { + lag: null, lagReason: 'no database pool wired', + writes: null, writesReason: 'no database pool wired' + } // The probe is an optimisation, never a reason to block. If it cannot answer // promptly the fixed deadline stands, so a probe that hangs costs one timeout // rather than stalling the suite. Learned the hard way: the first cut called // this.getConnection(), which retries until a connection appears. - const blind = { lag: null, writes: null } - const capped = new Promise(resolve => setTimeout(() => resolve(blind), this.WAIT_LAG_PROBE_MS)) - return Promise.race([this._probePipeline().catch(err => { this._warnProbeFailed(err); return blind }), capped]) + const unavailable = reason => ({ + lag: null, lagReason: reason, + writes: null, writesReason: reason + }) + const capped = new Promise(resolve => setTimeout(() => + resolve(unavailable('probe timed out')), this.WAIT_LAG_PROBE_MS)) + const measured = this._probePipeline().then(progress => ({ + lag: progress.lag, + lagReason: progress.lag === null + ? (!global.nodeConnector || typeof global.nodeConnector.getBlockCount !== 'function' + ? 'no node connector wired' + : 'blocks table held no rows') + : null, + writes: progress.writes, + writesReason: progress.writes === null ? 'actions table held no rows' : null + })).catch(err => { + const message = err && typeof err.message === 'string' && err.message + ? err.message + : 'no error message' + this._warnProbeFailed({ message }) + return unavailable('probe failed: ' + message) + }) + return Promise.race([measured, capped]) } // A probe that can never answer degrades every wait back to a fixed deadline @@ -1445,7 +1479,7 @@ class Database { _warnProbeFailed(err){ if (this._probeWarned) return this._probeWarned = true - console.log('_pipelineProgress: probe unavailable (' + (err && err.message ? err.message : err) + '); ' + logger.info('_pipelineProgress: probe unavailable (' + (err && err.message ? err.message : err) + '); ' + 'waits fall back to the fixed deadline') } diff --git a/stryker.phase2.config.mjs b/stryker.phase2.config.mjs index 76d4c890..71b641f6 100644 --- a/stryker.phase2.config.mjs +++ b/stryker.phase2.config.mjs @@ -18,11 +18,14 @@ * against mock fixtures and catch mutations unit tests alone may miss. That is * the same selection package.json declares as `test:integration:stubbed`. * - * The top-level test/integration/*.integration.test.js live suites are - * deliberately OUT. They provision a Docker MariaDB (test/helpers/disposableHubDb.js) - * and return null without Docker, so including them made this phase's score - * host-dependent: a Docker-less host silently skips 34 of the 56 files and still - * reports a number. Docker also does not survive mutation well here, since + * The live suites are deliberately OUT: the top-level test/integration/*.test.js + * roots AND the same-named test/integration/.test/ directories holding + * their split parts (the `ignore` below). They provision a Docker MariaDB + * (test/helpers/disposableHubDb.js) or a regtest rail and skip themselves when + * it is absent, so including any of them makes this phase's score + * host-dependent: a Docker-less host silently skips those files and still + * reports a number. A split part runs in its root's lane, so excluding it here + * drops it from no lane. Docker also does not survive mutation well here, since * disposableHubDb.js is itself in the `mutate` list above, so a mutant of the * teardown path leaks containers; and `timeoutMS` below is far under live * bring-up, which would score live mutants as timeouts rather than survivors. @@ -44,10 +47,13 @@ export default { mochaOptions: { spec: [ 'test/unit/**/*.test.js', - // Stubbed integration only. `test/integration/**` also matches the 34 - // top-level live suites; the one-directory-deep form is the stubbed lane. + // Stubbed integration only: the one-directory-deep form skips the top-level + // live roots, and `ignore` skips their split-part directories. 'test/integration/*/**/*.test.js', ], + // Same selection as package.json `test:integration:stubbed`, pinned by + // test/unit/scripts/stubbed_lane_hermetic.test.js. + ignore: ['test/integration/*.test/**'], }, coverageAnalysis: 'perTest', timeoutMS: 60000, diff --git a/test/actions/address_id_reorg_deterministic.test.js b/test/actions/address_id_reorg_deterministic.test.js index 7ad85edc..65e160da 100644 --- a/test/actions/address_id_reorg_deterministic.test.js +++ b/test/actions/address_id_reorg_deterministic.test.js @@ -11,10 +11,10 @@ const assert = require('assert') const cryptoHelper = require('../cryptoHelper') const mintHelper = require('../helpers/mintHelper') +const issueHelper = require('../helpers/issueHelper') +const gasHelper = require('../helpers/gasHelper') const transactionHelper = require('../transactionHelper') -const GAS_TICK = 'XCHAIN' - /** * Phase 5 ACCEPTANCE GATE for the deterministic index-id fix - ADDRESS twin of * tickIdReorgDeterministic.test.js (plan: address `^id` compaction + deterministic @@ -30,14 +30,14 @@ const GAS_TICK = 'XCHAIN' * fix, an orphaned new-address id is reclaimed and a later DIFFERENT address reuses * it, matching a fresh canonical-only node. * - * 1. MINT XCHAIN from issuerA to a FRESH addrA -> addrA takes id = idA (its - * block_index stamped). issuerA already has an id (surviving self-mint), so the + * 1. MINT the drill token from issuerA to a FRESH addrA -> addrA takes id = idA (its + * block_index stamped). issuerA already has an id (surviving gas top-up), so the * MINT introduces ONLY addrA. * 2. Orphan the block that introduced addrA (invalidateblock + a longer EMPTY * competing chain). * 3. After rollback: addrA's credit is gone AND the index_addresses row idA->addrA * is DELETED (not resurrected by updateBalances during rollback). - * 4. On the canonical chain, MINT XCHAIN from issuerB to a DIFFERENT fresh addrB. + * 4. On the canonical chain, MINT the drill token from issuerB to a DIFFERENT fresh addrB. * addrB must take id = idA (reclaimed) - what a fresh canonical-only node * assigns - so a wire `^idA` resolves to addrB everywhere. No divergence. * @@ -51,6 +51,10 @@ const GAS_TICK = 'XCHAIN' * * generateBlock(addr, [...]) is a Bitcoin Core 0.19 RPC; Dogecoin Core 1.14.x lacks * it, so this runs on BTC/LTC regtest only. + * + * The minted token is one issuerA ISSUEs in the fixture with open minting (any address + * may MINT it), not XCHAIN: off BTC XCHAIN exists only by bridging, so a MINT of it is + * invalid (MINT_START_BLOCK) and would introduce no address at all. */ const ADDRESS_REORG_TITLE = 'Address ^id reorg DETERMINISTIC (Phase 5 gate): orphaned index_addresses id is reclaimed, ^id no longer forks' @@ -86,11 +90,16 @@ async function createAddressFixture() { const addrA = (await cryptoHelper.getNewAddress('addriddet-destA', COIN, NETWORK, null, 'legacy', 0)).address const addrB = (await cryptoHelper.getNewAddress('addriddet-destB', COIN, NETWORK, null, 'legacy', 0)).address - // Pre-reorg, surviving: each issuer (source) gets its index_addresses id via a - // self-mint, so the destination MINTs below introduce ONLY the fresh address. - await mintHelper.sendMintV0(issuerA, GAS_TICK, 10) - await mintHelper.sendMintV0(issuerB, GAS_TICK, 10) - return { issuerA, issuerB, addrA, addrB } + // Pre-reorg, surviving: each issuer (source) gets its index_addresses id from a gas + // top-up (it is the recipient of the MINT or reservoir SEND), so the destination MINTs + // below introduce ONLY the fresh address. The drill token is issued here too, with no + // MINT_SUPPLY, so its ticker exists before the reorg and credits nobody yet. + await gasHelper.ensureGasBalance(issuerA, 10) + await gasHelper.ensureGasBalance(issuerB, 10) + const suffix = issuerA['address'].substring(issuerA['address'].length - 6).toUpperCase().replace(/[^A-Z0-9]/g, 'X') + const tick = 'ADR' + suffix + await issueHelper.sendIssueV0(issuerA, tick, 1000000, 1000, 0, 'address id reorg drill token', 0) + return { issuerA, issuerB, addrA, addrB, tick } } async function reorgContext(addrABlock) { @@ -116,13 +125,13 @@ async function waitForAddressCreditRemoval(addrA) { return addrAGone } -async function mintCanonicalAddress(issuerB, addrB, miner) { - // 4. MINT XCHAIN from issuerB -> the DIFFERENT fresh addrB, WITHOUT re-mining the +async function mintCanonicalAddress(issuerB, addrB, miner, tick) { + // 4. MINT the drill token from issuerB -> the DIFFERENT fresh addrB, WITHOUT re-mining the // orphaned MINT. Auto-mining stays paused: broadcast from issuerB (no mempool // conflict with issuerA's orphaned tx) and mine ONLY that tx, so the orphaned // MINT stays unmined and idA is available for addrB. Message mirrors // mintHelper.sendMintV0 (MINT v0, amount 5, empty memo). - const mintBMessage = 'MINT|0|' + GAS_TICK + '|5|' + addrB + '|' + const mintBMessage = 'MINT|0|' + tick + '|5|' + addrB + '|' const mintBTxid = await transactionHelper.createAndSendTransaction(issuerB, mintBMessage) await nodeConnector.generateBlock(miner, [mintBTxid]) @@ -143,13 +152,13 @@ async function reclaimOrphanedAddressId() { const cols = await q("SHOW COLUMNS FROM index_addresses LIKE 'block_index'") assert.strictEqual(cols.length, 1, 'index_addresses.block_index must exist (deterministic-id migration applied)') - const { issuerA, issuerB, addrA, addrB } = await createAddressFixture() + const { issuerA, issuerB, addrA, addrB, tick } = await createAddressFixture() assert(await addrIdByName(issuerA['address']), 'issuerA has an index_addresses id pre-reorg') assert(await addrIdByName(issuerB['address']), 'issuerB has an index_addresses id pre-reorg') - // 1. MINT XCHAIN from issuerA -> the FRESH addrA. addrA takes the next dense + // 1. MINT the drill token from issuerA -> the FRESH addrA. addrA takes the next dense // index_addresses id (idA), stamped with the block_index of this MINT. - await mintHelper.sendMintV0(issuerA, GAS_TICK, 5, addrA) + await mintHelper.sendMintV0(issuerA, tick, 5, addrA) const idA = await addrIdByName(addrA) assert(idA, 'addrA resolved an index_addresses id pre-reorg') assert.strictEqual(await addrById(idA), addrA, 'idA maps to addrA pre-reorg') @@ -179,7 +188,7 @@ async function reclaimOrphanedAddressId() { assert.strictEqual(await addrById(idA), null, 'index_addresses id ' + idA + ' must be reclaimed (deleted) after the orphaning reorg') - const idB = await mintCanonicalAddress(issuerB, addrB, miner) + const idB = await mintCanonicalAddress(issuerB, addrB, miner, tick) assert(idB, 'addrB resolved an index_addresses id on the canonical chain') // THE FIX (part 2): no divergence. addrB (a DIFFERENT address than the orphaned diff --git a/test/actions/chained_mint_reservation.test.js b/test/actions/chained_mint_reservation.test.js index 8118c8b8..3ad0f412 100644 --- a/test/actions/chained_mint_reservation.test.js +++ b/test/actions/chained_mint_reservation.test.js @@ -28,6 +28,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const cryptoHelper = require('../cryptoHelper') const mintHelper = require('../helpers/mintHelper') +const issueHelper = require('../helpers/issueHelper') const GAS_TICK = "XCHAIN" const MINT_AMOUNT = 100 @@ -35,16 +36,19 @@ const CHAIN_LENGTH = 3 describe('MINT chaining', () => { it('three chained MINTs from one address are three distinct txids and three balance increments', async () => { - // seedGas=false: the gas seed is itself a MINT, and it would put a - // credit on the address before the chain under test starts. Starting at - // zero makes each increment below attributable to one of these three. - // XCHAIN is an open mint on the test networks, so a zero-gas address can - // still mint it. + // The chained token is one the sender issues itself with open minting and no + // MINT_SUPPLY, not XCHAIN: off BTC XCHAIN exists only by bridging, so a MINT of + // it is invalid (MINT_START_BLOCK). A fresh token also opens at a zero balance + // whatever gas the funding helper seeds, so each increment below is + // attributable to one of these three. The ISSUE precedes the chain, so it is + // not one of the chained sends. const sender = await cryptoHelper.getNewFundedAddress( - 'XC1796.CHAIN', COIN, NETWORK, null, 'legacy', 0, 1, false + 'XC1796.CHAIN', COIN, NETWORK, null, 'legacy', 0, 1 ) + const tick = 'MCH' + sender.address.substring(sender.address.length - 8).toUpperCase().replace(/[^A-Z0-9]/g, 'X') + await issueHelper.sendIssueV0(sender, tick, 1000000, MINT_AMOUNT, 0, 'mint chaining drill token', 0) - const opening = await indexerDatabase.getBalance({ address: sender.address, tick: GAS_TICK }) + const opening = await indexerDatabase.getBalance({ address: sender.address, tick: tick }) assert.strictEqual(opening, "0", 'the drill needs a zero opening balance to attribute each increment') const txids = [] @@ -55,13 +59,13 @@ describe('MINT chaining', () => { // row AND the credit row, so each send starts from a view that has // the previous spend in it - which is exactly the chained-send shape, // and the one the encoder has to keep distinct. - const result = await mintHelper.sendMintV0(sender, GAS_TICK, MINT_AMOUNT, sender.address, 'mint chain ' + i) + const result = await mintHelper.sendMintV0(sender, tick, MINT_AMOUNT, sender.address, 'mint chain ' + i) assert(result.mint, 'MINT ' + i + ' should exist in the DB') assert(result.credit, 'MINT ' + i + ' credit should exist in the DB') txids.push(result.txHash) - const settled = await indexerDatabase.getBalance({ address: sender.address, tick: GAS_TICK }) + const settled = await indexerDatabase.getBalance({ address: sender.address, tick: tick }) assert(settled != null, 'balance read after MINT ' + i + ' failed') balances.push(BigInt(settled)) } diff --git a/test/actions/compaction_fix_live.test.js b/test/actions/compaction_fix_live.test.js index 5f98a2fd..4c93b6c3 100644 --- a/test/actions/compaction_fix_live.test.js +++ b/test/actions/compaction_fix_live.test.js @@ -21,24 +21,29 @@ // would emit it. // // Proves on a live regtest chain: -// 1. control: MINT XCHAIN to B's FULL address -> valid + B credited (also +// 1. control: MINT to B's FULL address -> valid + B credited (also // indexes B so it gets an index id N). -// 2. FIX: MINT XCHAIN to ^N -> valid, and the credit lands on the +// 2. FIX: MINT to ^N -> valid, and the credit lands on the // RESOLVED address B (checkMint/checkCredit join index_addresses by id, // so a match on B.address proves ^N resolved to B). // 3. dangling ^ with no row -> invalid: DESTINATION (format) (the fix // leaves a non-resolvable ref unchanged so the format check rejects it; // no silent drop). // +// The minted token is one the source ISSUEs in the before hook with open minting, +// not XCHAIN: off BTC XCHAIN exists only by bridging, so every MINT of it is invalid +// (MINT_START_BLOCK) whatever its destination, and case 3 would pass for the wrong +// reason. Case 3 therefore also pins that the rejection names the DESTINATION. +// // Run: ~/action-run-btc.sh test/actions/compaction_fix_live.test.js const assert = require('assert') const cryptoHelper = require('../cryptoHelper') const transactionHelper = require('../transactionHelper') +const issueHelper = require('../helpers/issueHelper') const COIN = global.COIN || process.env.COIN || 'bitcoin' const NETWORK = global.NETWORK || process.env.NETWORK || 'regtest' -const GAS = 'XCHAIN' async function addressId(address){ const conn = await global.indexerDatabase.pool.getConnection() @@ -67,24 +72,27 @@ async function mintStatus(txHash){ describe('[live] indexer ^ address resolution fix (resolveAddressRef)', function () { this.timeout(0) - let src, dest, destId + let src, dest, destId, tick before(async function () { // Funded source (native + seeded gas). Fresh dest address (no funding // needed; it gets indexed the moment it receives the control MINT). src = await cryptoHelper.getNewFundedAddress('COMPACTFIX.SRC', COIN, NETWORK, null, 'legacy', 0, 1) dest = await cryptoHelper.getNewAddress('COMPACTFIX.DEST', COIN, NETWORK, null, 'legacy', 1) + // Open minting, no MINT_SUPPLY: only the MINTs below ever credit it. + tick = 'CFX' + src.address.substring(src.address.length - 8).toUpperCase().replace(/[^A-Z0-9]/g, 'X') + await issueHelper.sendIssueV0(src, tick, 1000000, 1000, 0, 'compaction fix drill token', 0) console.log(' [live] src=' + src.address + ' dest=' + dest.address) }) - it('control: MINT XCHAIN to the FULL destination address is valid and credits it (indexes dest)', async function () { - const msg = 'MINT|0|' + GAS + '|7|' + dest.address + '|' + it('control: MINT to the FULL destination address is valid and credits it (indexes dest)', async function () { + const msg = 'MINT|0|' + tick + '|7|' + dest.address + '|' const txHash = await transactionHelper.createAndSendTransaction(src, msg) try { await global.regtestMinerConnector.generateBlocks(1) } catch (e) {} - const mint = await global.indexerDatabase.waitForMint({ txHash, tick: GAS, destination: dest.address, status: 'valid' }) + const mint = await global.indexerDatabase.waitForMint({ txHash, tick: tick, destination: dest.address, status: 'valid' }) assert.ok(mint, 'control MINT should be indexed valid with destination=dest') - const credit = await global.indexerDatabase.waitForCredit({ txHash, tick: GAS, address: dest.address }) + const credit = await global.indexerDatabase.waitForCredit({ txHash, tick: tick, address: dest.address }) assert.ok(credit, 'control MINT should credit dest') destId = await addressId(dest.address) @@ -92,25 +100,25 @@ describe('[live] indexer ^ address resolution fix (resolveAddressRef)', func assert.ok(destId && /^[0-9]+$/.test(destId), 'dest must now have a numeric index id') }) - it('FIX: MINT XCHAIN to ^N is valid and the credit lands on the RESOLVED address (dest)', async function () { + it('FIX: MINT to ^N is valid and the credit lands on the RESOLVED address (dest)', async function () { const ref = '^' + destId - const msg = 'MINT|0|' + GAS + '|11|' + ref + '|' + const msg = 'MINT|0|' + tick + '|11|' + ref + '|' console.log(' [live] minting to compacted ref ' + ref) const txHash = await transactionHelper.createAndSendTransaction(src, msg) try { await global.regtestMinerConnector.generateBlocks(1) } catch (e) {} // destination joins index_addresses by the stored id; a match on dest.address // proves ^N resolved to dest (pre-fix this row would be invalid format). - const mint = await global.indexerDatabase.waitForMint({ txHash, tick: GAS, destination: dest.address, status: 'valid' }) + const mint = await global.indexerDatabase.waitForMint({ txHash, tick: tick, destination: dest.address, status: 'valid' }) assert.ok(mint, '^N MINT must be indexed VALID with destination resolved to dest (the fix)') - const credit = await global.indexerDatabase.waitForCredit({ txHash, tick: GAS, address: dest.address }) + const credit = await global.indexerDatabase.waitForCredit({ txHash, tick: tick, address: dest.address }) assert.ok(credit, 'the ^N MINT credit must land on the resolved address dest') console.log(' [live] ^N resolved + credited dest, amount=' + (credit.amount)) }) it('dangling ^ (no such row) is rejected as invalid format (no silent drop)', async function () { - const msg = 'MINT|0|' + GAS + '|3|^999999999|' + const msg = 'MINT|0|' + tick + '|3|^999999999|' const txHash = await transactionHelper.createAndSendTransaction(src, msg) try { await global.regtestMinerConnector.generateBlocks(1) } catch (e) {} @@ -122,5 +130,6 @@ describe('[live] indexer ^ address resolution fix (resolveAddressRef)', func } console.log(' [live] dangling-ref MINT status=' + status) assert.ok(status && /^invalid/.test(status), 'dangling ^ MINT must be invalid, got: ' + status) + assert.ok(/DESTINATION/.test(status), 'the dangling ^ MINT must be rejected for its DESTINATION, got: ' + status) }) }) diff --git a/test/actions/ctlseed.test.js b/test/actions/ctlseed.test.js index 6fca489f..912bb68a 100644 --- a/test/actions/ctlseed.test.js +++ b/test/actions/ctlseed.test.js @@ -25,12 +25,18 @@ * contract DEPLOY indexes "invalid: no current oracle price for {COIN}/USD". * (Known harness flake.) * - * Anchoring: priceSnapshotHelper.usableSeedAnchors(), which bounds every - * seeded anchor at or before the chain tip. On every chain but BTC, - * getLatestPrice SELECTS with `block_timestamp <= `, so a - * future-dated row is invisible rather than merely stale; headroom comes - * from re-seeding as the chain advances, which is what nativeFeeHelper does - * per SEED_REFRESH_MS. + * Anchoring: priceSnapshotHelper.usableSeedAnchors() returns the chain-time + * anchor and adds a wall-time anchor when the chain trails wall clock. Fee + * validation on every chain except BTC selects only snapshots with a + * `block_timestamp` at or before the processed block time. A snapshot dated + * after that block is invisible to the lookup, even while the table contains + * a finalized row for the pair. The chain-time anchor remains readable at the + * current tip. The wall-time anchor covers later blocks stamped near wall clock + * after an idle chain resumes. Seeds use ascending round numbers in anchor + * order, giving the wall-time row selection priority when both timestamps are + * eligible while allowing the chain-time row to match when the wall-time row + * is beyond the processed block. Continued freshness depends on periodic + * seeding as the chain advances, and SEED_REFRESH_MS limits refresh frequency. ********************************************************************/ const assert = require('assert') diff --git a/test/actions/gated_file.test.js b/test/actions/gated_file.test.js index bec53f84..e6710f49 100644 --- a/test/actions/gated_file.test.js +++ b/test/actions/gated_file.test.js @@ -198,16 +198,31 @@ describe('FILE: token-gated content', function () { }) describe('FILE: token-gated content', function () { - this.timeout(240000) + // No finite outer bound (timeout = 0): the negative check below requires + // its own internal wait budget to complete and prove rejection. + this.timeout(0) before(setupGatedActors) it('rejects bare SEND of the gated token with no sibling MESSAGE', async function () { - // The encoder picks the P2SH 2-tx path for this SEND (gated-token - // wire size pushes past OP_RETURN), so we wait for two confirms - // not one. Worst case: 2 x 60s tx confirm + 20s tracker + 30s - // waitForSend is about 170s. 180s was right at the boundary and flaked. - // 240s gives a real margin for retries + load. - this.timeout(240000) + // This is a negative check: it can only conclude "rejected" by + // waitForSend running all the way to its own give-up, on every + // healthy run, not just on failure. That give-up is already bounded + // by Database's adaptive-wait budget (base timeMax x (1 + + // WAIT_MAX_EXTENSIONS) = 60000 x 4 = 240000ms today), which sits + // AFTER the P2SH 2-tx confirm + utxo-tracker prefix this SEND also + // pays. A finite mocha timeout here is a second, independently + // maintained copy of that same budget: it drifted out of sync when + // db.js's _waitFor grew extension support (WAIT_MAX_EXTENSIONS, + // added later) without this test's timeout being revisited, so the + // two bounds collided with no margin between them - mocha fired + // first here, on a run where the indexer's real lag used all three + // extensions, converting a correct rejection into a false failure. + // Bumping the number again just rebuilds the same race one level + // up; every other test in this suite driven by these waitFor* + // polls (test/actions/*.test.js) instead disables the outer bound + // and trusts _waitFor's own give-up as the one source of truth for + // how long a poll may run, so this test follows that convention. + this.timeout(0) // sendHelper.sendSendV0 doesn't throw on a timeout. waitForSend // resolves to null. So we check the resolved value: if `send` is // non-null AND status='valid', the gate isn't being enforced. diff --git a/test/actions/native_fee_dispenser.test.js b/test/actions/native_fee_dispenser.test.js index 16f6edf5..59592784 100644 --- a/test/actions/native_fee_dispenser.test.js +++ b/test/actions/native_fee_dispenser.test.js @@ -15,6 +15,7 @@ const issueHelper = require('../helpers/issueHelper') const gasHelper = require('../helpers/gasHelper') const nativeFeeHelper = require('../helpers/nativeFeeHelper') const { BOOTSTRAP_XCHAIN_USD, NO_PRICE_SEED } = require('../helpers/xchainPriceConstants') +const { FIXTURE_ID_FLOOR, FIXTURE_INSERT_SQL } = require('../helpers/priceSnapshotHelper') // Live-stack proof of native-coin USD-pegged fee payment, using the DISPENSER expiration fee // (which, unlike the ISSUE issuance fee, is NOT gated behind the mainnet activation height @@ -42,11 +43,9 @@ async function seedPrice(coinPair, price, referenceBlock, roundNumber){ let rows = await q("SELECT block_time FROM blocks ORDER BY block_index DESC LIMIT 1") let nowSec = Math.floor(Date.now() / 1000) let chainNow = rows.length ? Math.max(Number(rows[0].block_time), nowSec) : nowSec - await q(`INSERT INTO price_snapshots - (round_number, coin_pair, price, reference_block, reference_chain, - block_timestamp, validator_count, consensus_round, consensus_proof, status) - VALUES (?, ?, ?, ?, 'BTC', ?, 1, 1, '[]', 'finalized')`, - [roundNumber, coinPair, price, referenceBlock, chainNow - 60]) + // Above the hub's id space, or the next mirrored round overwrites this row's + // price in place (see priceSnapshotHelper FIXTURE_ID_FLOOR). + await q(FIXTURE_INSERT_SQL, [FIXTURE_ID_FLOOR, roundNumber, coinPair, price, referenceBlock, chainNow - 60]) } async function feeAndActions(txHash){ diff --git a/test/actions/native_fee_live.test.js b/test/actions/native_fee_live.test.js index baed87d6..a43ed8c8 100644 --- a/test/actions/native_fee_live.test.js +++ b/test/actions/native_fee_live.test.js @@ -13,6 +13,7 @@ const cryptoHelper = require('../cryptoHelper') const transactionHelper = require('../transactionHelper') const nativeFeeHelper = require('../helpers/nativeFeeHelper') const { BOOTSTRAP_XCHAIN_USD, NO_PRICE_SEED } = require('../helpers/xchainPriceConstants') +const { FIXTURE_ID_FLOOR, FIXTURE_INSERT_SQL } = require('../helpers/priceSnapshotHelper') // Live-stack proof of native-coin USD-pegged fee payment. // @@ -48,13 +49,10 @@ async function seedPrice(coinPair, price, referenceBlock, roundNumber){ let rows = await conn.query("SELECT block_time FROM blocks ORDER BY block_index DESC LIMIT 1") let nowSec = Math.floor(Date.now() / 1000) let chainNow = rows.length ? Math.max(Number(rows[0].block_time), nowSec) : nowSec - await conn.query( - `INSERT INTO price_snapshots - (round_number, coin_pair, price, reference_block, reference_chain, - block_timestamp, validator_count, consensus_round, consensus_proof, status) - VALUES (?, ?, ?, ?, 'BTC', ?, 1, 1, '[]', 'finalized')`, - [roundNumber, coinPair, price, referenceBlock, chainNow - 60] - ) + // Above the hub's id space, or the next mirrored round overwrites this row's + // price in place (see priceSnapshotHelper FIXTURE_ID_FLOOR). + await conn.query(FIXTURE_INSERT_SQL, + [FIXTURE_ID_FLOOR, roundNumber, coinPair, price, referenceBlock, chainNow - 60]) } finally { await conn.release() } } diff --git a/test/actions/reorg_balances.test.js b/test/actions/reorg_balances.test.js index 849eb077..4590221f 100644 --- a/test/actions/reorg_balances.test.js +++ b/test/actions/reorg_balances.test.js @@ -12,12 +12,7 @@ const assert = require('assert') const cryptoHelper = require('../cryptoHelper') const issueHelper = require('../helpers/issueHelper') const sendHelper = require('../helpers/sendHelper') -const mintHelper = require('../helpers/mintHelper') - -// GAS token. Issuing a new token charges an ISSUANCE_FEE payable in GAS (XCHAIN); -// on testnet/regtest XCHAIN is an open faucet (anyone MINTs it; no owner check, -// no fee), so a fresh issuer grabs gas via a MINT before it can ISSUE. -const GAS_TICK = 'XCHAIN' +const gasHelper = require('../helpers/gasHelper') async function q(sql, params) { const conn = await indexerDatabase.getConnection() @@ -51,8 +46,10 @@ async function createMoneyReorgFixture() { const tick = 'MRG' + sender['address'].substring(sender['address'].length - 8) // Grab GAS first: the sender needs an XCHAIN balance to pay the ISSUANCE_FEE. - // XCHAIN is an open faucet on testnet/regtest: anyone MINTs it (no owner check, no fee). - await mintHelper.sendMintV0(sender, GAS_TICK, 10) + // gasHelper mints it on BTC and sends it from the bridged reservoir elsewhere, + // where XCHAIN exists only by bridging and a local MINT of it is invalid + // (MINT_START_BLOCK). + await gasHelper.ensureGasBalance(sender, 10) // ISSUE in an early block: mintSupply credited to the sender. const MINT = 100 diff --git a/test/actions/tick_id_reorg_deterministic.test.js b/test/actions/tick_id_reorg_deterministic.test.js index 77d16e6a..cd53365e 100644 --- a/test/actions/tick_id_reorg_deterministic.test.js +++ b/test/actions/tick_id_reorg_deterministic.test.js @@ -11,11 +11,9 @@ const assert = require('assert') const cryptoHelper = require('../cryptoHelper') const issueHelper = require('../helpers/issueHelper') -const mintHelper = require('../helpers/mintHelper') +const gasHelper = require('../helpers/gasHelper') const transactionHelper = require('../transactionHelper') -const GAS_TICK = 'XCHAIN' - async function q(sql, params) { const conn = await indexerDatabase.getConnection() try { return await conn.query(sql, params) } @@ -55,8 +53,10 @@ async function prepareTickIdIssuers() { const TOKENA = 'TDA' + suffix const TOKENB = 'TDB' + suffix - await mintHelper.sendMintV0(issuerA, GAS_TICK, 10) - await mintHelper.sendMintV0(issuerB, GAS_TICK, 10) + // Gas for each ISSUE: minted on BTC, sent from the bridged reservoir elsewhere, where + // a local MINT of XCHAIN is invalid (MINT_START_BLOCK). + await gasHelper.ensureGasBalance(issuerA, 10) + await gasHelper.ensureGasBalance(issuerB, 10) return { issuerA, issuerB, TOKENA, TOKENB } } diff --git a/test/actions/xchainPriceDerivation.test.js b/test/actions/xchainPriceDerivation.test.js index 59540c02..314f5270 100644 --- a/test/actions/xchainPriceDerivation.test.js +++ b/test/actions/xchainPriceDerivation.test.js @@ -31,8 +31,11 @@ const gasHelper = require('../helpers/gasHelper') const sendHelper = require('../helpers/sendHelper') const issueHelper = require('../helpers/issueHelper') const stakeHelper = require('../helpers/stakeHelper') +const { captureCurrentRail } = require('../helpers/chainRail') const { NO_PRICE_SEED } = require('../helpers/xchainPriceConstants') +const { code: COIN_CODE } = captureCurrentRail() + // The derivation lives in xchain-indexer and is vendored byte-identically into // xchain-hub. Require it from the sibling checkout rather than copying it here: // a local copy in the e2e repo would be a THIRD implementation to drift, and the diff --git a/test/attestMirror/at0b-barrier-attribution.test.js b/test/attestMirror/at0b-barrier-attribution.test.js index 3321a720..6c50d877 100644 --- a/test/attestMirror/at0b-barrier-attribution.test.js +++ b/test/attestMirror/at0b-barrier-attribution.test.js @@ -62,6 +62,7 @@ dotenv.config() const { AttestMirrorVenue } = require('../helpers/attestMirrorVenue') const { createRail } = require('../helpers/chainRail') const { until, untilOrClearDogeStall, diffStateHashes, venueTipProbe } = require('./mirrorDrillWaits') +const { HUB_SCHEMA_VERSION } = require('./helpers/hubSchemaVersion') const XChainIndexerConnector = require('../../src/XChainIndexerConnector.js') // The observable this file exists to pin. @@ -182,8 +183,14 @@ describe('AT0 last clause: the mirror stall is attest_response_sync_barrier by n assert.ok(snap && !snap.error, 'hub ' + followed + ' would not serve its own snapshot route, so the withhold cannot be ' + 'distinguished from a hub that is down: ' + JSON.stringify(snap)) - assert.strictEqual(Number(snap.schema_version), 5, - 'the hub is serving schema_version ' + snap.schema_version + ' rather than 5') + // Against the indexer's own constant, never a literal. This clause only needs + // the hub to be serving the version the mirror consumer demands; a literal here + // goes stale at the next schema bump and reds the leg for a reason that has + // nothing to do with barrier attribution, which is what it did at v5 against a + // hub serving v7. + assert.strictEqual(Number(snap.schema_version), Number(HUB_SCHEMA_VERSION), + 'the hub is serving schema_version ' + snap.schema_version + + ' but the mirror consumer demands ' + HUB_SCHEMA_VERSION) // THE PEER IS UNAFFECTED. Same chain, same federation, graces at zero, no // withhold: it must get past the height the starved one is holding at. diff --git a/test/attestMirror/at2b-forward-margin.test.js b/test/attestMirror/at2b-forward-margin.test.js index 11d8bd9a..241c6e12 100644 --- a/test/attestMirror/at2b-forward-margin.test.js +++ b/test/attestMirror/at2b-forward-margin.test.js @@ -45,13 +45,13 @@ * a wait instead of a divergence, and it is what makes the wait attributable to * THIS barrier by name. * - * So the drill arms a delivery delay LONGER than the forward margin, which is - * exactly the fault the margin is supposed to absorb and, on its own, does not; and - * it runs the affected indexer with a grace longer than the delay. The claim then - * has two halves that must both hold: the barrier reports itself while it waits, - * and when the block finally lands, the response bound at the SAME block on both - * nodes. The second half is what "no block is processed early" means in terms a - * ledger can be checked against. + * So the drill arms a delivery window LONGER than the forward margin, which is + * exactly the fault the margin is supposed to absorb and, on its own, does not. It + * runs the affected indexer with enough grace to expose the named barrier, then + * releases delivery inside that grace. The claim has two halves that must both + * hold: the barrier reports itself while it waits, and when the block finally lands, + * the response bound at the SAME block on both nodes. The second half is what "no + * block is processed early" means in terms a ledger can be checked against. * * WHAT IS NOT DRIVEN HERE, deliberately: the spec's falsification, lowering the * margin below the injected delay. Its expected outcome is a real divergence @@ -80,6 +80,7 @@ const { settleOrReport, widenArithmetic, } = require('./mirrorDrillWaits') +const { delayedDeliveryWindow } = require('./helpers/deliveryWindow') const vmHelper = require('../helpers/vmHelper') const XChainIndexerConnector = require('../../src/XChainIndexerConnector.js') @@ -96,18 +97,20 @@ const BURIAL_BLOCKS = 6 // exceed it without the drill waiting out the frozen 120. const FORWARD_S = 5 -// Delivery delay, comfortably PAST the forward margin: by the time the row reaches -// this indexer its signed effective time is long gone, so the margin has already -// failed to cover it and only the barrier can. -const DELIVERY_DELAY_MS = 25_000 - -// The grace on the affected indexer, above the delay so the wait can absorb it. -// WELL above the barrier's per-attempt timeout too (HUB_PRICE_SYNC_TIMEOUT_MS, -// 60 s), because that timeout is the ONLY thing that sets `stallReason`: a wait -// that resolves inside one attempt parks the node silently. At 70 s the reason -// was readable for ten seconds per block, and on 2026-09-05 the drill missed it -// entirely (indexer 1 had deferred 6761 by name at 15:58:47, before the poll -// began). At 100 s a fresh block parks the node for forty readable seconds. +// The configured delay is a safety ceiling, not time the drill waits out. The row +// is released as soon as the named barrier is observable. Keeping this window long +// enough for the delayed indexer to reach the decoder tip prevents its existing +// block backlog from consuming the fault before the binding block is mined. +const DELIVERY_DELAY_MS = 10 * 60 * 1000 + +// The grace on the affected indexer is above the barrier's per-attempt timeout +// (HUB_PRICE_SYNC_TIMEOUT_MS, 60 s), because that timeout is the ONLY thing that +// sets `stallReason`: a wait that resolves inside one attempt parks the node +// silently. At 70 s the reason was readable for ten seconds per block, and on +// 2026-09-05 the drill missed it entirely (indexer 1 had deferred 6761 by name at +// 15:58:47, before the poll began). At 100 s a fresh block parks the node for +// forty readable seconds. Delivery is released inside that grace after the named +// status is observed. const BARRIER_GRACE_S = 100 // The indexer whose delivery is delayed. Its peer keeps every grace at zero and an @@ -115,6 +118,7 @@ const BARRIER_GRACE_S = 100 const DELAYED = 1 const PROMPT = 0 + const CONTRACT_CODE = ` module.exports = { meta: { name: 'Mirror Margin Asker', description: 'Requests an attestation used to measure the mirror forward margin.', version: '1.0.0' }, @@ -143,6 +147,7 @@ describe('AT2 second clause: delivery past the forward margin holds the barrier let testServer = null let testUrl = null let contract = null + let delayArmedAtMs = null async function statusOf (i) { const s = await venue.statusOf(i) @@ -195,6 +200,7 @@ describe('AT2 second clause: delivery past the forward margin holds the barrier it('waits on the named barrier and still binds at the same block on both nodes', async function () { // Armed BEFORE the request, so the row is delayed from the moment it exists // rather than after this indexer has already seen it. + delayArmedAtMs = Date.now() venue.delayMirrorTable(DELAYED, MIRROR_TABLE, DELIVERY_DELAY_MS) console.log('AT2b: delaying ' + MIRROR_TABLE + ' by ' + DELIVERY_DELAY_MS + 'ms on indexer ' + DELAYED + ' mirror edge; the forward margin is ' + FORWARD_S + @@ -263,10 +269,34 @@ describe('AT2 second clause: delivery past the forward margin holds the barrier console.log('AT2b: the unfiltered indexer holds the row; effective_time ' + prompt.rows[0].effective_time) - // THE HOLD, BY NAME. A fresh block is what arms the barrier, and while the - // delayed row is in flight the affected indexer must report THIS barrier and - // not a neighbouring one. + // Remove the replay backlog before mining the binding block. Without this + // synchronization the configured delay can expire while the delayed indexer + // is still clearing older blocks, so the assertion observes neither the + // injected fault nor the block whose binding rule is under test. + await waitForVenueIndexersAtTip(venue, { maxLag: 0 }) + const beforeMineStats = venue.mirrorProxyStats(DELAYED) + const delayWindow = delayedDeliveryWindow( + delayArmedAtMs, Date.now(), DELIVERY_DELAY_MS, beforeMineStats) + assert.ok(delayWindow.ok, + 'the delayed indexer reached the tip outside an active delivery window. ' + + 'Elapsed ' + delayWindow.elapsedMs + 'ms; proxy ' + JSON.stringify(beforeMineStats)) + + // THE HOLD, BY NAME. A fresh block is what arms the barrier. The row is known + // to be in flight when this block is mined, so the named status is evidence + // about this delivery rather than an older block waiting out the same grace. + const conn0 = new XChainIndexerConnector('127.0.0.1', venue.indexers[PROMPT].apiPort, null) await regtestMinerConnector.generateBlocks(1) + const crossing = await until(async () => { + const tip = await conn0.call('getblockhashes', {}) + const blockTime = Number(tip && tip.block_time) + return { + ok: Number.isFinite(blockTime) && blockTime >= Number(prompt.rows[0].effective_time), + tip: tip, + } + }, 2 * 60 * 1000) + assert.ok(crossing.ok, + 'the mined block did not cross response effective_time ' + prompt.rows[0].effective_time + + ', so it cannot exercise the response barrier: ' + JSON.stringify(crossing.tip)) const held = await until(async () => { const a = await statusOf(DELAYED) return { ok: a.reason === BARRIER_REASON && PARKED_CLASSES.includes(a.klass), a: a } @@ -287,14 +317,18 @@ describe('AT2 second clause: delivery past the forward margin holds the barrier console.log('AT2b: proxy held ' + stats.framesHeld + ' stream frame(s) and ' + stats.snapshotRowsHeld + ' snapshot row(s)') + // The configured delay is deliberately generous enough to survive a slow + // catch-up. Release once the barrier has named itself so the rest of the leg + // measures recovery instead of waiting out unused fault-injection time. + venue.releaseMirrorTable(DELAYED, MIRROR_TABLE) + // NO BLOCK PROCESSED EARLY, expressed as the thing a ledger can be checked // against: both nodes bound the response at the SAME block, so the wait // absorbed the delay instead of the delayed node skipping past it and binding // later. - // MINES WHILE WAITING, as AT1 does: the applier runs inside the block loop, - // and the one block mined above may well be stamped before the effective - // time. The claim rests on the BARRIER holding the delayed node, not on the - // chain standing still, so blocks under this wait do not weaken it. + // MINES WHILE WAITING, as AT1 does: the applier runs inside the block loop. + // The crossing assertion above proves the first block is eligible to bind; + // later blocks only keep recovery moving after delivery is released. const applied = await waitForAppliedEverywhere(venue, requestId, 20 * 60 * 1000, { mineWhileWaiting: { perPoll: 1, maxBlocks: widenArithmetic(DEADLINE_BLOCKS).safeCap } }) const diffs = diffRows(applied[PROMPT], applied[DELAYED], APPLIED_FIELDS) @@ -304,11 +338,10 @@ describe('AT2 second clause: delivery past the forward margin holds the barrier 'block was processed before the row arrived, and the two nodes fired the callback at ' + 'different heights.') const at = Number(applied[PROMPT].block_index) - console.log('AT2b: both nodes bound at block ' + at + ' despite a ' + DELIVERY_DELAY_MS + - 'ms delivery delay against a ' + FORWARD_S + 's margin') + console.log('AT2b: both nodes bound at block ' + at + ' while delivery was held past a ' + + FORWARD_S + 's margin (configured delay ceiling ' + DELIVERY_DELAY_MS + 'ms)') // And the ledger agrees, not merely the row. - const conn0 = new XChainIndexerConnector('127.0.0.1', venue.indexers[PROMPT].apiPort, null) const conn1 = new XChainIndexerConnector('127.0.0.1', venue.indexers[DELAYED].apiPort, null) const h0 = await conn0.call('getblockhashes', { block_index: at }) const h1 = await conn1.call('getblockhashes', { block_index: at }) @@ -322,9 +355,8 @@ describe('AT2 second clause: delivery past the forward margin holds the barrier }) it('clears the barrier once the delay is released', async function () { - // The wait must be a wait rather than a wedge: with the filter gone the node - // stops reporting the barrier and keeps up. - venue.releaseMirrorTable(DELAYED, MIRROR_TABLE) + // The wait must be a wait rather than a wedge: after the release above the + // node stops reporting the barrier and keeps up. await regtestMinerConnector.generateBlocks(2) await settleOrReport('at2b') diff --git a/test/attestMirror/at3-deadline.test.js b/test/attestMirror/at3-deadline.test.js index 64595b95..2027be05 100644 --- a/test/attestMirror/at3-deadline.test.js +++ b/test/attestMirror/at3-deadline.test.js @@ -198,8 +198,13 @@ describe('AT3: the deadline decides whether a mirrored response ever binds', fun assert.strictEqual(String(rows[0].status), 'ok', tag + ': the round did not produce an ok response, so the deadline is not what is under test') + await waitForHeightWithClear(venue, 0, request.blockIndex) const local = await readRequestRow(venue, 0, requestId) - assert.ok(local, tag + ': the venue indexer holds no v0 request row for ' + requestId) + assert.ok(local, tag + ': the venue indexer holds no v0 request row for ' + requestId + + ' after committing request block ' + request.blockIndex) + assert.strictEqual(Number(local.block_index), Number(request.blockIndex), + tag + ': the venue request row landed at block ' + local.block_index + + ' but the standing indexer placed it at ' + request.blockIndex) const effectiveTime = Number(rows[0].effective_time) assert.ok(Number.isFinite(effectiveTime) && effectiveTime > 0, diff --git a/test/attestMirror/barrier_family/bf2_armed_fix.test.js b/test/attestMirror/barrier_family/bf2_armed_fix.test.js index 210b0c9f..d98e4242 100644 --- a/test/attestMirror/barrier_family/bf2_armed_fix.test.js +++ b/test/attestMirror/barrier_family/bf2_armed_fix.test.js @@ -18,16 +18,22 @@ * * THE BINDING ASSERTION IS A ROW SET, NOT A HASH. Above the activation a row binds * at a different block by design, so equality with BF1's `state_hash` could only - * hold vacuously. The leg seeds one row admitted AT B and one admitted past B per - * member table, plus a legacy NULL row in the tables whose armed apply pass never - * reaches that row's canonical, and compares the set the armed node's mirror reads - * at B (the section 5.5 rule in its IS NULL OR SQL form) against the fixture's + * hold vacuously. The leg seeds one row admitted AT B, one admitted past B and one + * LEGACY NULL row per member table, and compares the set the armed node's mirror + * reads at B (the section 5.5 rule in its IS NULL OR SQL form) against the fixture's * independently computed expected set over the hub's own rows. * - * NO LEGACY ROW IN bridge_transfers OR policy_snapshots. The venue arms at height 0, - * so every block is admission era and no hub produces a NULL-map row; those two apply - * passes build the canonical before reading the capability set and throw on one, - * stalling the drill block (rail 2026-09-17, block 104). The legacy rule is BF4's. + * A LEGACY ROW IN EVERY MEMBER TABLE, BECAUSE THE VENUE ARMS AT A CROSSING. This leg + * used to skip bridge_transfers and policy_snapshots: armed at height 0 every block is + * admission era, so a NULL-map seed was a modern row missing its mandatory map, those + * two apply passes build the canonical before reading the capability set, and the + * builder's refusal stalled the drill block (rail 2026-09-17, block 104). The refusal + * was the product working (adjudicated 2026-09-18): the canonical builder refuses a NULL + * admission map on any row whose era block is at or above the producer activation. The + * leg now arms producers and consumers at the chain's tip + 1 and seeds its legacy rows + * below that height, + * where a NULL map is what a pre-crossing hub really wrote and the builder returns an + * empty canonical tail, so every member table carries one. The legacy rule is BF4's. * * WHAT THIS DOES NOT PROVE. The seeded rows carry no verifiable signature, so no * pass APPLIES them to the ledger; the read set is the mirror's, which is the rule @@ -56,14 +62,44 @@ const HEIGHT_TABLES = TABLES.concat(['anchor_reward_attestations']) // attest rail's height watermark window plus one barrier attempt. const COMMIT_BUDGET_MS = 5 * 60 * 1000 +function renderEvidence (value) { + try { + const rendered = JSON.stringify(value, (key, item) => typeof item === 'bigint' ? item.toString() : item) + return rendered === undefined ? String(value) : rendered + } catch (error) { + return '[unprintable: ' + (error && error.message ? error.message : String(error)) + ']' + } +} + +function assertionMessage (expected, found, next) { + return 'expected ' + expected + '; found ' + renderEvidence(found) + '; inspect ' + next +} + +function deferralNote (venue, indexer) { + const which = 'indexer' + indexer + try { + const tail = String(venue.logTail(which) || '') + if (!tail || /\blast 0 line\(s\) from\b/.test(tail)) { + return 'log tail unavailable: ' + which + ' has not written any lines yet' + } + const lines = tail.split(/\r?\n/).filter((line) => /Deferring block /.test(line)) + if (lines.length) return 'latest deferral from ' + which + ': ' + lines[lines.length - 1].trim() + return 'deferral evidence unavailable: no matching line in the available ' + which + ' log tail' + } catch (error) { + return 'log tail unavailable: ' + (error && error.message ? error.message : String(error)) + } +} + describe('BF2: the fix, armed, the identical block on the identical mirror state', function () { this.timeout(Math.max(drive.LEG_FLOOR_MS, fixture.legTimeoutMs(600))) - const ctx = { venue: null, btc: null, coin: 'BTC', B: null, block: null, seen: [], seeded: {}, expected: {}, read: {} } + const ctx = { venue: null, btc: null, coin: 'BTC', B: null, block: null, armHeight: null, legacyBlock: null, + seen: [], seeded: {}, expected: {}, read: {} } before(async function () { - const up = await drive.bootFamilyVenue({ label: 'bf2', repoRoot: BUILD_ROOT, armed: [ARMED, PEER], armHubs: true }) + const up = await drive.bootFamilyVenue({ label: 'bf2', repoRoot: BUILD_ROOT, armed: [ARMED, PEER], armHubs: true, armAtCrossing: true }) Object.assign(ctx, up, { coin: up.evidence.coinCode }) + console.log('BF2 armed at ' + ctx.armHeight + ', legacy era block ' + ctx.legacyBlock) }) after(async function () { @@ -96,9 +132,17 @@ async function seedAdmissionRows (ctx) { const now = Math.floor(Date.now() / 1000) // Snapshot at B, not the reached tip: a fresh node's stake re-derivation rejects a synthetic capability row at a reached height. const base = { network: ctx.venue.network, coin: ctx.coin, effectiveTime: now, snapshotBlock: ctx.B } - const members = rows.admissionSeedRows(TABLES, base, ctx.B, tip) - // Refuse before seeding: a NULL-map row the armed apply canonicalizes stalls the drill block, not this case. - assert.deepStrictEqual(rows.armedLegacyApplyHazards(members), [], 'BF2 would seed a legacy row the armed apply pass refuses') + // The crossing, asserted before anything is seeded: both eras exist on this one venue. + assert.strictEqual(ctx.legacyBlock, fixture.legacyEraBlock(ctx.armHeight), + assertionMessage('legacyBlock=' + fixture.legacyEraBlock(ctx.armHeight), 'legacyBlock=' + ctx.legacyBlock, 'the BF2 activation setup')) + assert.ok(ctx.legacyBlock < ctx.armHeight, + assertionMessage('legacyBlock below armHeight', { legacyBlock: ctx.legacyBlock, armHeight: ctx.armHeight }, 'the BF2 legacy seed era')) + assert.ok(ctx.armHeight <= ctx.B, + assertionMessage('armHeight at or below B', { armHeight: ctx.armHeight, B: ctx.B }, 'the BF2 crossing height')) + const members = rows.admissionSeedRows(TABLES, base, ctx.B, tip, ctx.legacyBlock) + // Refuse before seeding: a NULL-map row at or above the activation stalls the drill block, not this case. + const hazards = rows.armedLegacyApplyHazards(members, ctx.armHeight) + assert.deepStrictEqual(hazards, [], assertionMessage('no armed legacy apply hazards', hazards, 'the BF2 member seeds')) const seeds = [rows.inertRow('capability_snapshots', Object.assign({ tag: 'bf2|snap|' + tip }, base))].concat(members) await drive.seedMirrors(ctx.venue, seeds) ctx.seeded = {} @@ -107,7 +151,8 @@ async function seedAdmissionRows (ctx) { await drive.waitForMirrorRows(ctx.venue, ARMED, t, ctx.seeded[t]) } await drive.waitForMirrorRows(ctx.venue, ARMED, 'capability_snapshots', 1) - console.log('BF2 seeded ' + JSON.stringify(ctx.seeded) + ' rows per member table against B=' + ctx.B) + console.log('BF2 seeded ' + JSON.stringify(ctx.seeded) + ' rows per member table against B=' + ctx.B + + ', activation ' + ctx.armHeight + ', legacy era block ' + ctx.legacyBlock) } async function mineAndWatch (ctx) { @@ -123,8 +168,10 @@ async function mineAndWatch (ctx) { const wall = Math.floor(Date.now() / 1000) // Resumes the miner: the hold ends with the drill block. ctx.block = await drive.mineStamped(ctx.btc, wall + drive.STAMP_AHEAD_S) - assert.strictEqual(ctx.block.height, ctx.B, 'the drill block landed at ' + ctx.block.height + ', not the seeded B=' + ctx.B) - assert.ok(ctx.block.blockTime >= wall + drive.STAMP_AHEAD_S - 1, 'the block is stamped ' + ctx.block.blockTime + ', not +' + drive.STAMP_AHEAD_S) + assert.strictEqual(ctx.block.height, ctx.B, + assertionMessage('drill block height B=' + ctx.B, 'block height=' + ctx.block.height, 'the BF2 mined block')) + assert.ok(ctx.block.blockTime >= wall + drive.STAMP_AHEAD_S - 1, + assertionMessage('blockTime at least ' + (wall + drive.STAMP_AHEAD_S - 1), 'blockTime=' + ctx.block.blockTime, 'the BF2 block stamp')) const started = Date.now() const got = await drive.waitForStatus(ctx.venue, ARMED, (s) => { if (s.height === ctx.B - 1 && s.stallReason) ctx.seen.push({ at: Date.now() - started, reason: s.stallReason, stallClass: s.stallClass }) @@ -132,13 +179,15 @@ async function mineAndWatch (ctx) { }, COMMIT_BUDGET_MS) const heights = got.s && got.s.heights console.log('BF2 armed node: ' + JSON.stringify(got.s) + '; reasons seen while on B-1: ' + JSON.stringify(ctx.seen)) - assert.ok(got.ok, 'the armed node did not commit the +' + drive.STAMP_AHEAD_S + ' block inside ' + COMMIT_BUDGET_MS + - ' ms: ' + JSON.stringify(got.s) + '\n' + ctx.venue.logTail('indexer' + ARMED)) + assert.ok(got.ok, assertionMessage('armed node commit of block ' + ctx.B + ' inside ' + COMMIT_BUDGET_MS + ' ms', + { ok: got.ok, status: got.s }, 'the armed indexer evidence: ' + deferralNote(ctx.venue, ARMED))) const barriers = ctx.seen.filter((x) => /_barrier$/.test(String(x.reason))) - assert.deepStrictEqual(barriers, [], 'a barrier reason appeared for block ' + ctx.B + ' on the armed node: ' + JSON.stringify(barriers)) - assert.strictEqual(got.s.stallClass, 'none', 'stallClass after the commit is ' + got.s.stallClass) - assert.ok(heights && Object.keys(heights).length > 0, 'the hub published no heights map; the consumer bound on nothing') - assert.ok(Date.now() - started < drive.STAMP_AHEAD_S * 1000, 'the commit waited out the stamp') + assert.deepStrictEqual(barriers, [], assertionMessage('no barrier reasons for block ' + ctx.B, barriers, 'the BF2 status samples')) + assert.strictEqual(got.s.stallClass, 'none', assertionMessage('stallClass="none" after commit', got.s.stallClass, 'the BF2 final status')) + assert.ok(heights && Object.keys(heights).length > 0, + assertionMessage('a non-empty hub heights map', heights, 'the BF2 final status hubMirror.heights')) + assert.ok(Date.now() - started < drive.STAMP_AHEAD_S * 1000, + assertionMessage('commit latency below ' + (drive.STAMP_AHEAD_S * 1000) + ' ms', (Date.now() - started) + ' ms', 'the BF2 commit timer')) } // The expected set is computed by the FIXTURE over the hub's rows (independent of the @@ -151,16 +200,18 @@ async function assertReadSets (ctx) { const hubRows = await drive.hubRows(ctx.venue, hub, t) ctx.expected[t] = fixture.admittedRowSet(hubRows, t, ctx.coin, ctx.B, ctx.block.blockTime, (r) => String(r[keyCol])) ctx.read[t] = await drive.mirrorReadableSet(ctx.venue, ARMED, t, ctx.coin, ctx.B, ctx.block.blockTime) - assert.deepStrictEqual(ctx.read[t], ctx.expected[t], t + ': the mirror reads ' + JSON.stringify(ctx.read[t]) + - ' at B=' + ctx.B + ' while the hub rows admit ' + JSON.stringify(ctx.expected[t])) + assert.deepStrictEqual(ctx.read[t], ctx.expected[t], + assertionMessage(t + ' mirror read set ' + renderEvidence(ctx.expected[t]), ctx.read[t], 'the BF2 ' + t + ' rows at B=' + ctx.B)) // The past-B row is the one seeded row not admitted at B. const want = ctx.seeded[t] - 1 - assert.strictEqual(ctx.expected[t].length, want, t + ': expected the at-B row' + (want > 1 ? ' and the legacy row' : '') + - ', got ' + ctx.expected[t].length + ' of ' + hubRows.length + ' hub rows') + assert.strictEqual(ctx.expected[t].length, want, + assertionMessage(t + ' admitted row count=' + want, ctx.expected[t].length, 'the BF2 ' + t + ' hub rows, total=' + hubRows.length)) const col = fixture.admissionColumn(t, ctx.coin) const past = hubRows.filter((r) => Number(r[col]) === ctx.B + 3).map((r) => String(r[keyCol])) - assert.strictEqual(past.length, 1, t + ': the past-B row is missing on the hub') - assert.ok(!ctx.read[t].includes(past[0]), t + ': the past-B row was read at B') + assert.strictEqual(past.length, 1, + assertionMessage(t + ' past-B hub row count=1', past.length, 'the BF2 ' + t + ' hub rows')) + assert.ok(!ctx.read[t].includes(past[0]), + assertionMessage(t + ' past-B key excluded at B=' + ctx.B, ctx.read[t], 'the BF2 ' + t + ' mirror read set')) } console.log('BF2 read sets at B=' + ctx.B + ': ' + JSON.stringify(ctx.read)) } diff --git a/test/attestMirror/barrier_family/bf4_boundary.test.js b/test/attestMirror/barrier_family/bf4_boundary.test.js index 95ec8e06..57db2d78 100644 --- a/test/attestMirror/barrier_family/bf4_boundary.test.js +++ b/test/attestMirror/barrier_family/bf4_boundary.test.js @@ -33,6 +33,16 @@ * asserted at the block the legacy rule selects: excluded at a block stamped * before their effective_time, included at the next block stamped past it, while * a control row admitted by height binds at the first of the two. + * + * THE VENUE ARMS AT A CROSSING, NOT AT GENESIS. "Armed" resolves to height 0, and on + * that venue no block sits below the activation, so a NULL-map seed is not a legacy + * row at all: it is a modern row missing its mandatory map, which no hub produces and + * which the canonical builder refuses by design: it rejects a NULL admission map on any + * row whose era block is at or above the producer activation (adjudicated 2026-09-18, the + * product is correct on both sides and they agree; the seed was the defect). The leg + * arms producers and consumers at the chain's tip + 1 and seeds its legacy row below + * that height, so the row is one a pre-crossing hub really wrote and the crossing is + * one this venue walked through rather than one the seeding manufactured. ********************************************************************/ const assert = require('assert') @@ -56,6 +66,34 @@ const TABLE = 'cross_chain_matches' const B = 812000 const T = 1788494058 +function renderEvidence (value) { + try { + const rendered = JSON.stringify(value, (key, item) => typeof item === 'bigint' ? item.toString() : item) + return rendered === undefined ? String(value) : rendered + } catch (error) { + return '[unprintable: ' + (error && error.message ? error.message : String(error)) + ']' + } +} + +function assertionMessage (expected, found, next) { + return 'expected ' + expected + '; found ' + renderEvidence(found) + '; inspect ' + next +} + +function deferralNote (venue, indexer) { + const which = 'indexer' + indexer + try { + const tail = String(venue.logTail(which) || '') + if (!tail || /\blast 0 line\(s\) from\b/.test(tail)) { + return 'log tail unavailable: ' + which + ' has not written any lines yet' + } + const lines = tail.split(/\r?\n/).filter((line) => /Deferring block /.test(line)) + if (lines.length) return 'latest deferral from ' + which + ': ' + lines[lines.length - 1].trim() + return 'deferral evidence unavailable: no matching line in the available ' + which + ' log tail' + } catch (error) { + return 'log tail unavailable: ' + (error && error.message ? error.message : String(error)) + } +} + // A stub of the mirror client with the fields the height comparison reads, below or // above the activation, publishing `heights`. function mirrorStub (heights, active) { @@ -83,8 +121,13 @@ describe('BF4 unit: below the activation every predicate is today\'s form, above ] for (const [heights, want, why] of cases) { const stub = mirrorStub(heights, true) - assert.strictEqual(stub.heightSatisfied(TABLE, B), want, why) - if (!want) assert.strictEqual(stub._heightShortfalls[TABLE + '|BTC'], target, why + ': the shortfall names the line') + const satisfied = stub.heightSatisfied(TABLE, B) + assert.strictEqual(satisfied, want, + assertionMessage(why + ' to produce heightSatisfied=' + want, satisfied, 'the BF4 height matrix case')) + if (!want) { + assert.strictEqual(stub._heightShortfalls[TABLE + '|BTC'], target, + assertionMessage(why + ' shortfall=' + target, stub._heightShortfalls[TABLE + '|BTC'], 'the BF4 shortfall map')) + } } }) @@ -92,16 +135,26 @@ describe('BF4 unit: below the activation every predicate is today\'s form, above for (const heights of [undefined, {}, { [TABLE]: { BTC: B - 1 } }, { [TABLE]: { BTC: 0 } }]) { const inert = mirrorStub(heights, false) // Legacy: content escape (max effective_time >= t(B)), else watermark >= t(B) + grace. - assert.strictEqual(members.matchSyncSatisfied.call(inert, T - 1, B), true, 'content escape at t(B) = max effective_time + 1') - assert.strictEqual(members.matchSyncSatisfied.call(inert, T + 1, B), false, 'watermark T < t(B) + 120 with no content escape') + const escapedByContent = members.matchSyncSatisfied.call(inert, T - 1, B) + assert.strictEqual(escapedByContent, true, + assertionMessage('content escape=true at t(B)=max effective_time+1', escapedByContent, 'the BF4 legacy member predicate')) + const beforeGrace = members.matchSyncSatisfied.call(inert, T + 1, B) + assert.strictEqual(beforeGrace, false, + assertionMessage('legacy member=false before watermark grace', beforeGrace, 'the BF4 legacy member predicate')) inert.streamWatermark = T + 121 - assert.strictEqual(members.matchSyncSatisfied.call(inert, T + 1, B), true, 'watermark past t(B) + grace') + const afterGrace = members.matchSyncSatisfied.call(inert, T + 1, B) + assert.strictEqual(afterGrace, true, + assertionMessage('legacy member=true after watermark grace', afterGrace, 'the BF4 legacy member predicate')) inert.matchSyncTimestamp = null - assert.strictEqual(members.matchSyncSatisfied.call(inert, T + 9999, B), true, 'the empty-mirror escape survives') + const emptyMirror = members.matchSyncSatisfied.call(inert, T + 9999, B) + assert.strictEqual(emptyMirror, true, + assertionMessage('empty-mirror escape=true', emptyMirror, 'the BF4 legacy member predicate')) } const armed = mirrorStub({ [TABLE]: { BTC: B - 5 } }, true) armed.streamWatermark = T + 99999 - assert.strictEqual(members.matchSyncSatisfied.call(armed, T + 1, B), false, 'armed: the clock cannot open a height-keyed member') + const armedSatisfied = members.matchSyncSatisfied.call(armed, T + 1, B) + assert.strictEqual(armedSatisfied, false, + assertionMessage('armed height-keyed member=false below its height line', armedSatisfied, 'the BF4 armed member predicate')) }) }) @@ -118,11 +171,18 @@ describe('BF4 unit: the binding rule and its SQL form', function () { const row = { [fixture.admissionColumn(TABLE, 'BTC')]: admit, effective_time: et } const ours = fixture.rowAdmittedAt(row, fixture.admissionColumn(TABLE, 'BTC'), height, bt) const theirs = gate.isRowReadableAt(admit, height, et, bt) - assert.strictEqual(ours, theirs, 'diverged on ' + JSON.stringify({ admit, height, et, bt })) + assert.strictEqual(ours, theirs, + assertionMessage('fixture result=' + theirs, ours, 'the BF4 binding matrix case ' + renderEvidence({ admit, height, et, bt }))) } - assert.strictEqual(gate.isRowReadableAt(null, B, T, T), true, 'legacy NULL binds by effective_time <= t(B)') - assert.strictEqual(gate.isRowReadableAt(undefined, B, T, T), true, 'a map that omits this chain binds by effective_time') - assert.strictEqual(gate.isRowReadableAt(B + 1, B, T - 9999, T), false, 'an admission height past B never binds early') + const legacyReadable = gate.isRowReadableAt(null, B, T, T) + assert.strictEqual(legacyReadable, true, + assertionMessage('legacy NULL readable=true at effective_time <= t(B)', legacyReadable, 'the BF4 binding boundary')) + const omittedReadable = gate.isRowReadableAt(undefined, B, T, T) + assert.strictEqual(omittedReadable, true, + assertionMessage('chain-omitting map readable=true by effective_time', omittedReadable, 'the BF4 binding boundary')) + const futureReadable = gate.isRowReadableAt(B + 1, B, T - 9999, T) + assert.strictEqual(futureReadable, false, + assertionMessage('future admission height readable=false at B', futureReadable, 'the BF4 binding boundary')) }) it('unit: the SQL form is the IS NULL OR shape in the bind clause, the snapshot scope and the fixture query', function () { @@ -130,27 +190,37 @@ describe('BF4 unit: the binding rule and its SQL form', function () { const stub = { mirrorAdmissionActiveAt: () => true, admitColumn: () => col } const clause = mirrorReads.mirrorBindClause.call(stub, T, B, null, null) const shape = '((' + col + ' IS NULL AND effective_time <= ?) OR (' + col + ' IS NOT NULL AND ' + col + ' <= ?))' - assert.strictEqual(clause.sql, shape, 'the bind clause is not the C33 form') - assert.deepStrictEqual(clause.args, [T, B]) + assert.strictEqual(clause.sql, shape, + assertionMessage('the C33 bind clause SQL', clause.sql, 'the BF4 mirror bind clause')) + assert.deepStrictEqual(clause.args, [T, B], + assertionMessage('bind arguments [T, B]', clause.args, 'the BF4 mirror bind clause arguments')) const inert = mirrorReads.mirrorBindClause.call({ mirrorAdmissionActiveAt: () => false, admitColumn: () => col }, T, B, null, null) - assert.deepStrictEqual(inert, { sql: 'effective_time <= ?', args: [T] }, 'below the activation the clause is today\'s bytes') + assert.deepStrictEqual(inert, { sql: 'effective_time <= ?', args: [T] }, + assertionMessage('the legacy effective_time bind clause', inert, 'the BF4 below-activation SQL')) const q = rows.readableRowsSql(TABLE, 'BTC', B, T) - assert.ok(q.sql.includes('`' + col + '` IS NULL AND effective_time <= ?') && q.sql.includes('`' + col + '` IS NOT NULL AND `' + col + '` <= ?'), - 'the fixture query is not the IS NULL OR shape: ' + q.sql) - assert.ok(!/\bWHERE[^(]*`admit_block_[a-z]+` <= \?/.test(q.sql), 'a bare comparison on the nullable column silently drops legacy rows') + const hasNullOrShape = q.sql.includes('`' + col + '` IS NULL AND effective_time <= ?') && + q.sql.includes('`' + col + '` IS NOT NULL AND `' + col + '` <= ?') + assert.ok(hasNullOrShape, + assertionMessage('fixture SQL with both IS NULL and IS NOT NULL arms', q.sql, 'the BF4 readableRowsSql query')) + const hasBareComparison = /\bWHERE[^(]*`admit_block_[a-z]+` <= \?/.test(q.sql) + assert.ok(!hasBareComparison, + assertionMessage('no bare nullable admission-height comparison', { hasBareComparison, sql: q.sql }, 'the BF4 readableRowsSql WHERE clause')) const snapshotSrc = require('fs').readFileSync(path.join(INDEXER, 'hub', 'hub_db_sync', 'barriers', 'snapshot.js'), 'utf8') - assert.ok(snapshotSrc.includes("' IS NULL AND ' + alias + '.effective_time <= ?) OR ('"), 'the snapshot scope lost the IS NULL arm') + const snapshotHasNullArm = snapshotSrc.includes("' IS NULL AND ' + alias + '.effective_time <= ?) OR ('") + assert.ok(snapshotHasNullArm, + assertionMessage('snapshot scope with the IS NULL arm', snapshotHasNullArm, 'the BF4 snapshot barrier source')) }) }) describe('BF4 live: above the activation a NULL row and a chain-omitting row bind by the legacy rule', function () { this.timeout(drive.LEG_FLOOR_MS) - const ctx = { venue: null, btc: null, coin: 'BTC', T: null, tip: null, keys: {}, blocks: [] } + const ctx = { venue: null, btc: null, coin: 'BTC', T: null, tip: null, armHeight: null, legacyBlock: null, keys: {}, blocks: [] } before(async function () { - const up = await drive.bootFamilyVenue({ label: 'bf4', repoRoot: BUILD_ROOT, armed: [ARMED, 1], armHubs: true }) + const up = await drive.bootFamilyVenue({ label: 'bf4', repoRoot: BUILD_ROOT, armed: [ARMED, 1], armHubs: true, armAtCrossing: true }) Object.assign(ctx, up, { coin: up.evidence.coinCode }) + console.log('BF4 armed at ' + ctx.armHeight + ', legacy era block ' + ctx.legacyBlock) }) after(async function () { @@ -179,28 +249,52 @@ async function seedThree (ctx) { ctx.T = Math.max(Math.floor(Date.now() / 1000), floor.floor + 60) + 120 // Snapshot at the next height, not the reached tip: a fresh node's stake re-derivation rejects a synthetic capability row at a reached height. const base = { network: ctx.venue.network, coin: ctx.coin, effectiveTime: ctx.T, snapshotBlock: ctx.tip + 1 } - const legacy = rows.inertRow(TABLE, Object.assign({ tag: 'bf4|legacy|' + ctx.tip }, base)) + // The crossing, asserted before anything is seeded: the legacy block is a real block BELOW the + // activation and the drill blocks land at or above it, so both eras exist on this one venue. + assert.strictEqual(ctx.legacyBlock, fixture.legacyEraBlock(ctx.armHeight), + assertionMessage('legacyBlock=' + fixture.legacyEraBlock(ctx.armHeight), 'legacyBlock=' + ctx.legacyBlock, 'the BF4 activation setup')) + assert.ok(ctx.legacyBlock < ctx.armHeight, + assertionMessage('legacyBlock below armHeight', { legacyBlock: ctx.legacyBlock, armHeight: ctx.armHeight }, 'the BF4 legacy seed era')) + assert.ok(ctx.armHeight <= ctx.tip + 1, + assertionMessage('armHeight at or below next tip', { armHeight: ctx.armHeight, nextTip: ctx.tip + 1 }, 'the BF4 crossing height')) + // The legacy row is the one seed in the pre-activation era: its map is NULL because the hub that + // wrote it did not stamp maps yet, which is the row C33 and BF4's "Above" half are about. + const legacy = rows.inertRow(TABLE, Object.assign({ tag: 'bf4|legacy|' + ctx.tip }, base, { snapshotBlock: ctx.legacyBlock })) const omits = rows.inertRow(TABLE, Object.assign({ tag: 'bf4|omits|' + ctx.tip, admitBlocks: { LTC: 5 } }, base)) const control = rows.inertRow(TABLE, Object.assign({ tag: 'bf4|control|' + ctx.tip, admitBlocks: { BTC: ctx.tip + 1 }, effectiveTime: ctx.T + 9999 }, base)) // The legacy row lives in cross_chain_matches, whose armed apply reads the capability set before the // canonical; the guard fails this case, not the drill block, if it is ever moved to bridge or policy. - assert.deepStrictEqual(rows.armedLegacyApplyHazards([legacy, omits, control]), [], 'BF4 would seed a legacy row the armed apply pass refuses') + const hazards = rows.armedLegacyApplyHazards([legacy, omits, control], ctx.armHeight) + assert.deepStrictEqual(hazards, [], assertionMessage('no armed legacy apply hazards', hazards, 'the BF4 seed rows')) await drive.seedMirrors(ctx.venue, [rows.inertRow('capability_snapshots', Object.assign({ tag: 'bf4|snap|' + ctx.tip }, base)), legacy, omits, control]) await drive.waitForMirrorRows(ctx.venue, ARMED, TABLE, 3) ctx.keys = { legacy: legacy.row.match_id, omits: omits.row.match_id, control: control.row.match_id } - assert.strictEqual(omits.row[fixture.admissionColumn(TABLE, 'BTC')], null, 'the omitting row must carry NULL for this chain') - assert.strictEqual(omits.row[fixture.admissionColumn(TABLE, 'LTC')], 5, 'the omitting row must carry a height for the other chain') - console.log('BF4 seeded at T=' + ctx.T + ' against tip ' + ctx.tip + ': ' + JSON.stringify(ctx.keys)) + assert.strictEqual(omits.row[fixture.admissionColumn(TABLE, 'BTC')], null, + assertionMessage('BTC admission height=NULL', omits.row[fixture.admissionColumn(TABLE, 'BTC')], 'the BF4 chain-omitting seed')) + assert.strictEqual(omits.row[fixture.admissionColumn(TABLE, 'LTC')], 5, + assertionMessage('LTC admission height=5', omits.row[fixture.admissionColumn(TABLE, 'LTC')], 'the BF4 chain-omitting seed')) + // Each seed in the era it is meant to be in, read off the row rather than off the spec. + assert.ok(legacy.row.snapshot_block < ctx.armHeight, + assertionMessage('legacy snapshot_block below armHeight', { snapshotBlock: legacy.row.snapshot_block, armHeight: ctx.armHeight }, 'the BF4 legacy seed')) + for (const s of [omits, control]) { + assert.ok(s.row.snapshot_block >= ctx.armHeight, + assertionMessage('map-carrying snapshot_block at or above armHeight', { snapshotBlock: s.row.snapshot_block, armHeight: ctx.armHeight }, 'the BF4 admission-era seeds')) + } + console.log('BF4 seeded at T=' + ctx.T + ' against tip ' + ctx.tip + ', activation ' + ctx.armHeight + + ', legacy era block ' + ctx.legacyBlock + ': ' + JSON.stringify(ctx.keys)) } async function expectReadSet (ctx, block, want) { const got = await drive.waitCommitted(ctx.venue, ARMED, block.height, 10 * 60 * 1000) - assert.ok(got.ok, 'the armed node did not commit block ' + block.height + ': ' + JSON.stringify(got.s) + '\n' + ctx.venue.logTail('indexer' + ARMED)) + assert.ok(got.ok, assertionMessage('armed node commit of block ' + block.height + ' inside 600000 ms', + { ok: got.ok, status: got.s }, 'the armed indexer evidence: ' + deferralNote(ctx.venue, ARMED))) const read = await drive.mirrorReadableSet(ctx.venue, ARMED, TABLE, ctx.coin, block.height, block.blockTime) const hubRows = await drive.hubRows(ctx.venue, ctx.venue.indexers[ARMED].followsHub, TABLE) const expected = fixture.admittedRowSet(hubRows, TABLE, ctx.coin, block.height, block.blockTime, (r) => String(r.match_id)) ctx.blocks.push({ block, read, expected }) console.log('BF4 block ' + block.height + ' stamped ' + block.blockTime + ': read ' + JSON.stringify(read)) - assert.deepStrictEqual(read, want, 'at block ' + block.height + ' (t=' + block.blockTime + ', T=' + ctx.T + ') the mirror reads ' + JSON.stringify(read)) - assert.deepStrictEqual(expected, want, 'the fixture computes ' + JSON.stringify(expected) + ' from the hub rows') + assert.deepStrictEqual(read, want, + assertionMessage('mirror read set ' + renderEvidence(want), read, 'the BF4 mirror rows at block ' + block.height + ', t=' + block.blockTime + ', T=' + ctx.T)) + assert.deepStrictEqual(expected, want, + assertionMessage('fixture admitted set ' + renderEvidence(want), expected, 'the BF4 hub rows at block ' + block.height)) } diff --git a/test/attestMirror/barrier_family/bf5_flag_day.test.js b/test/attestMirror/barrier_family/bf5_flag_day.test.js index e5808e65..98f6d1f6 100644 --- a/test/attestMirror/barrier_family/bf5_flag_day.test.js +++ b/test/attestMirror/barrier_family/bf5_flag_day.test.js @@ -54,7 +54,11 @@ describe('BF5: the flag day, one venue, two rules, one row bound at two differen const ctx = { venue: null, btc: null, coin: 'BTC', B: null, key: null, blocks: {}, committedAt: {}, inertSeen: [] } before(async function () { - const up = await drive.bootFamilyVenue({ label: 'bf5', repoRoot: BUILD_ROOT, armed: [ARMED] }) + // requireMirrorReady: BF5 judges the INERT node by the match barrier, which can only clear + // once its hub mirror is bootstrapped. Levelling on chain height alone let the 2026-09-18 + // drive start with indexer 1 still draining its bootstrap, and both of BF5's reds were that + // one unready node rather than the rule under test. + const up = await drive.bootFamilyVenue({ label: 'bf5', repoRoot: BUILD_ROOT, armed: [ARMED], requireMirrorReady: true }) Object.assign(ctx, up, { coin: up.evidence.coinCode }) assert.deepStrictEqual(Object.keys(ctx.venue.indexerEnv).map(Number), [ARMED], 'the overlay must arm indexer 0 alone') }) @@ -62,7 +66,14 @@ describe('BF5: the flag day, one venue, two rules, one row bound at two differen after(async function () { // A case that failed while the chain was held must not leave the miner paused. await drive.releaseChain(ctx.btc) - if (ctx.venue) await ctx.venue.stop() + if (ctx.venue) { + // Printed unconditionally, before stop() kills both indexer processes and removes + // the venue's working directory: a red that never touched the one assertion above + // that carries a logTail must not lose the only copy of either node's log. + console.log('BF5 indexer ' + ARMED + ' log tail at teardown:\n' + ctx.venue.logTail('indexer' + ARMED)) + console.log('BF5 indexer ' + INERT + ' log tail at teardown:\n' + ctx.venue.logTail('indexer' + INERT)) + await ctx.venue.stop() + } }) it('seeds the one row whose two rules select different blocks', async function () { @@ -111,15 +122,22 @@ async function assertDivergence (ctx) { ctx.committedAt[ARMED] = Date.now() - started assert.ok(armed.ok, 'the armed node did not commit B inside five minutes: ' + JSON.stringify(armed.s) + '\n' + ctx.venue.logTail('indexer' + ARMED)) assert.ok(armed.s.heights && Object.keys(armed.s.heights).length > 0, 'the hub published no heights map; arming bound on nothing') - const inertNow = await drive.statusSnapshot(ctx.venue, INERT) - assert.strictEqual(inertNow.height, ctx.B - 1, 'the inert node committed the future-stamped block early: ' + JSON.stringify(inertNow)) - assert.strictEqual(inertNow.stallClass, 'future_block_wait', 'the inert node reports ' + inertNow.stallClass + ', not future_block_wait') + // A one-shot statusSnapshot read here races indexer 188b08af's per-pass stall reset: the + // node can report between passes with the prior stallClass already cleared. Poll instead. + const inertNow = await drive.waitForStatus(ctx.venue, INERT, (s) => s.stallClass === 'future_block_wait', 90 * 1000) + assert.ok(inertNow.ok, 'the inert node never reported future_block_wait: ' + JSON.stringify(inertNow.s) + + '\n' + ctx.venue.logTail('indexer' + INERT)) + assert.strictEqual(inertNow.s.height, ctx.B - 1, 'the inert node committed the future-stamped block early: ' + JSON.stringify(inertNow.s) + + '\n' + ctx.venue.logTail('indexer' + INERT)) const t = ctx.blocks.B.blockTime const armedSet = await drive.mirrorReadableSet(ctx.venue, ARMED, TABLE, ctx.coin, ctx.B, t) assert.deepStrictEqual(armedSet, [], 'the armed node reads the row at B, but its admission height is B + 2') const inertGot = await drive.waitCommitted(ctx.venue, INERT, ctx.B, (AHEAD_S + 600) * 1000) ctx.committedAt[INERT] = Date.now() - started - assert.ok(inertGot.ok, 'the inert node never committed B: ' + JSON.stringify(inertGot.s)) + // The status JSON names the last barrier the block DEFERRED on, which is not the same as the + // reason it is still uncommitted: a canonical-build refusal or a parse rollback never reaches + // /status at all. The inert node's own log is the only carrier of that, so it rides the message. + assert.ok(inertGot.ok, 'the inert node never committed B: ' + JSON.stringify(inertGot.s) + '\n' + ctx.venue.logTail('indexer' + INERT)) // The inert node's read at B is the LEGACY rule alone: effective_time <= t(B). const inertRows = await queryDb(ctx.venue, ctx.venue.indexers[INERT].mirrorDbName, 'SELECT match_id AS k FROM `' + TABLE + "` WHERE status = 'finalized' AND effective_time <= ? ORDER BY match_id ASC", [t]) @@ -136,7 +154,7 @@ async function assertConvergence (ctx) { assert.strictEqual(ctx.blocks.B2.height, ctx.B + 2) for (const i of [ARMED, INERT]) { const got = await drive.waitCommitted(ctx.venue, i, ctx.B + 2, 10 * 60 * 1000) - assert.ok(got.ok, 'indexer ' + i + ' never committed B + 2: ' + JSON.stringify(got.s)) + assert.ok(got.ok, 'indexer ' + i + ' never committed B + 2: ' + JSON.stringify(got.s) + '\n' + ctx.venue.logTail('indexer' + i)) } const t2 = ctx.blocks.B2.blockTime const armedSet = await drive.mirrorReadableSet(ctx.venue, ARMED, TABLE, ctx.coin, ctx.B + 2, t2) diff --git a/test/attestMirror/helpers/barrierFamilyDrive.js b/test/attestMirror/helpers/barrierFamilyDrive.js index a17dd969..bcbdc510 100644 --- a/test/attestMirror/helpers/barrierFamilyDrive.js +++ b/test/attestMirror/helpers/barrierFamilyDrive.js @@ -49,12 +49,25 @@ const STAMP_AHEAD_S = Number(process.env.BF_STAMP_AHEAD_S || 7200) * the fixture; `opts.armHubs` arms every hub child with the same lever so the producers * stamp admission maps (the venue's `hubExtraEnv` seam). * - * @returns {Promise<{venue: object, evidence: object, btc: object}>} + * `opts.armAtCrossing` arms at the chain's own tip + 1 instead of at genesis, and returns + * that height beside `legacyBlock`, the last block below it. A leg that seeds a legacy-era + * row needs this: armed at genesis there is no block below the activation, so a NULL-map + * seed is a modern row missing its map and the canonical builder refuses it by design + * (barrierFamilyFixture, the crossing note). The height is read from the rail BEFORE the + * venue starts, so every block the venue goes on to mine is admission era. + * + * @returns {Promise<{venue: object, evidence: object, btc: object, armHeight: number|null, legacyBlock: number|null}>} */ async function bootFamilyVenue (opts) { const o = opts || {} assert.ok(o.repoRoot, 'bootFamilyVenue: repoRoot is required (B4: the evidence names the tree that ran)') const btc = await createRail('bitcoin', 'regtest') + let armHeight = o.armHeight === undefined ? null : o.armHeight + if (o.armAtCrossing) { + assert.strictEqual(armHeight, null, 'bootFamilyVenue: armAtCrossing derives the height; do not also pass armHeight') + armHeight = fixture.crossingArmHeight(Number(await btc.globals.nodeConnector.getBlockCount())) + } + const legacyBlock = armHeight === null ? null : fixture.legacyEraBlock(armHeight) const venueOpts = Object.assign({}, o.venue || {}) // Port seam for concurrent stacks on one host: the rail runner gives each leg its own base (rail 2026-09-17, four stacks at once). if (process.env.AB_VENUE_BASE_PORT) venueOpts.basePort = Number(process.env.AB_VENUE_BASE_PORT) @@ -67,20 +80,51 @@ async function bootFamilyVenue (opts) { }, venueOpts.hubExtraEnv || {}) if (o.indexerGraces) venueOpts.indexerGraces = o.indexerGraces if (o.indexerExtraEnv) venueOpts.indexerExtraEnv = Object.assign({}, venueOpts.indexerExtraEnv || {}, o.indexerExtraEnv) - if (o.armHubs) venueOpts.hubExtraEnv = Object.assign({}, venueOpts.hubExtraEnv || {}, { [fixture.ARM_ENV]: fixture.ARM_VALUE }) + // The hubs take the SAME height as the indexers: one value arms producer and consumer + // (protocol_changes/shared_rows.js REGTEST_ARMING), so a hub armed at genesis beside an + // indexer armed at a crossing would stamp maps the consumer reads under the other rule. + if (o.armHubs) venueOpts.hubExtraEnv = Object.assign({}, venueOpts.hubExtraEnv || {}, { [fixture.ARM_ENV]: fixture.armValue(armHeight) }) const built = fixture.buildFamilyVenue({ - repoRoot: o.repoRoot, armed: o.armed || [], hubRepoRoots: o.hubRepoRoots, label: o.label, venue: venueOpts, + repoRoot: o.repoRoot, armed: o.armed || [], armHeight, hubRepoRoots: o.hubRepoRoots, label: o.label, venue: venueOpts, }) - const evidence = Object.assign(built.evidence, { armHubs: !!o.armHubs, stampAheadS: STAMP_AHEAD_S }) + const evidence = Object.assign(built.evidence, { armHubs: !!o.armHubs, stampAheadS: STAMP_AHEAD_S, legacyBlock }) console.log('BF EVIDENCE ' + JSON.stringify(evidence)) const up = await built.venue.start() assert.ok(up, 'FAILED DRIVE (not a skip): the family venue did not come up: ' + String(built.venue.unavailable)) - await levelIndexers(built.venue, o.levelTimeoutMs) - return { venue: built.venue, evidence, btc } + await levelIndexers(built.venue, o.levelTimeoutMs, { requireMirrorReady: !!o.requireMirrorReady }) + return { venue: built.venue, evidence, btc, armHeight, legacyBlock } +} + +/** + * Is this indexer's hub mirror far enough along to judge a barrier, from one status snapshot? + * + * Chain level is not mirror readiness. An indexer can commit every block the decoder has while + * its hub mirror is still draining its bootstrap, and a match barrier that clears only once + * `bootstrapped` is true then reads as a product hold on a node that was merely still starting + * (BF5, 2026-09-18: indexer 1 sat at 263 against decoder 266 in `barrier_defer`). + * + * An indexer with NO mirror configured is ready by definition, because there is nothing to + * bootstrap. Anything else fails closed: an absent `configured` flag is "not ready", never + * "ready", so a status shape that stops carrying the field parks the baseline loudly instead of + * releasing it on a field nobody is reading any more. + */ +function mirrorReady (s) { + if (s.mirrorConfigured === false) return { ok: true, why: 'no hub mirror configured' } + if (s.mirrorConfigured !== true) return { ok: false, why: 'hubMirror.configured absent from /status' } + if (s.mirrorConnected !== true) return { ok: false, why: 'hub mirror not connected' } + if (s.mirrorBootstrapped !== true) return { ok: false, why: 'hub mirror not bootstrapped' } + return { ok: true, why: 'connected and bootstrapped' } } -/** Every indexer level with the decoder before a leg breaks anything, so a hold is the leg's doing. */ -async function levelIndexers (venue, timeoutMs) { +/** + * Every indexer level with the decoder before a leg breaks anything, so a hold is the leg's doing. + * + * `opts.requireMirrorReady` additionally holds the baseline until every indexer's hub mirror is + * connected and bootstrapped. It is OPT IN: the legs that pass today level on chain height alone + * and keep doing so, so a leg that wants the stricter gate asks for it by name. + */ +async function levelIndexers (venue, timeoutMs, opts) { + const o = opts || {} const idx = venue.indexers.map((ix) => ix.index) const level = await untilOrClearDogeStall(async () => { const all = [] @@ -89,10 +133,13 @@ async function levelIndexers (venue, timeoutMs) { for (const i of idx) { all.push(await statusSnapshot(venue, i).catch((e) => ({ height: null, decoder: null, unreachable: String(e && e.message) }))) } - return { ok: all.every((s) => s.height !== null && s.decoder !== null && s.height === s.decoder), all } + const chainLevel = all.every((s) => s.height !== null && s.decoder !== null && s.height === s.decoder) + const mirrorHeld = o.requireMirrorReady ? all.filter((s) => !mirrorReady(s).ok) : [] + return { ok: chainLevel && mirrorHeld.length === 0, all, mirrorHeld: mirrorHeld.length } }, { timeoutMs: timeoutMs || LEVEL_TIMEOUT_MS, tipProbe: venueTipProbe(venue, idx[idx.length - 1]) }) assert.ok(level.ok, 'the venue indexers never caught the chain before the drill: ' + JSON.stringify(level.all)) - console.log('BF baseline: every indexer committed block ' + level.all[0].height) + console.log('BF baseline: every indexer committed block ' + level.all[0].height + + (o.requireMirrorReady ? '; hub mirrors ' + level.all.map((s) => mirrorReady(s).why).join(', ') : '')) return level.all } @@ -110,6 +157,11 @@ async function statusSnapshot (venue, i) { inFlight: b.inFlightBlock !== undefined && b.inFlightBlock !== null ? Number(b.inFlightBlock) : null, decoder: b.decoderBlock !== undefined ? Number(b.decoderBlock) : null, // /status carries the mirror under `hubMirror`; the fixture's snapshot reads `mirror`. + // `configured`/`connected`/`bootstrapped` come straight off the indexer's own status route + // (xchain-indexer src/api/status_route.js) so a readiness verdict never has to be inferred. + mirrorConfigured: mirror.configured, + mirrorConnected: mirror.connected, + mirrorBootstrapped: mirror.bootstrapped, heights: mirror.heights, heightShortfalls: mirror.heightShortfalls, heightsFrozenMs: mirror.heightsFrozenMs, @@ -581,6 +633,7 @@ module.exports = { LEG_FLOOR_MS, bootFamilyVenue, levelIndexers, + mirrorReady, statusSnapshot, holdSnapshot, waitForStatus, diff --git a/test/attestMirror/helpers/barrierFamilyFixture.js b/test/attestMirror/helpers/barrierFamilyFixture.js index 13b97a3a..cfd48e18 100644 --- a/test/attestMirror/helpers/barrierFamilyFixture.js +++ b/test/attestMirror/helpers/barrierFamilyFixture.js @@ -26,7 +26,9 @@ * defaulted from whatever tree this file happens to live in; * - the arming lever is the indexer's own regtest resolver, read from the * indexer's registry rather than spelled here, so a renamed env key reddens - * the fixture instead of silently leaving every "armed" indexer inert. + * the fixture instead of silently leaving every "armed" indexer inert. It + * arms at genesis or at a named HEIGHT, and a leg that asserts anything about + * a legacy-era row must use the height form (see the crossing note below). * * VENUE TRAPS, encoded once (family frontier, "Venue traps the legs must encode"): * the oracle quote lives QUOTE_LIFETIME_S and a leg that outlives it misreads a @@ -59,6 +61,70 @@ const gate = require('../../../../xchain-indexer/src/consensus/gates/mirror_admi const ARM_ENV = gate.MIRROR_ADMISSION_REGTEST_ENV const ARM_VALUE = 'armed' +/** + * THE CROSSING FORM OF THE SAME LEVER, and why a leg needs it. + * + * `ARM_VALUE` resolves to MIRROR_ADMISSION_REGTEST_ARMED_HEIGHT, which is 0, so on that + * venue EVERY height is admission era and no block below the activation exists. A leg + * that seeds a "legacy" row there is not seeding a legacy row: it is seeding a modern row + * missing its mandatory map, which is a row no hub produces and which the canonical + * builder refuses by design: `admissionCanonicalValue` rejects a NULL admission map on any + * row whose era block is at or above the producer activation (adjudicated 2026-09-18). + * The refusal is the product working; the seed was the defect. + * + * The resolver also takes a HEIGHT (xchain-indexer protocol_changes/regtest_env.js + * `regtestHeight`), and the same value arms the producer and the consumer. A leg that + * needs a real flag day passes the chain's own tip + 1: every block already on the chain + * is then legacy era, every block the venue goes on to mine is admission era, and the + * crossing the legs assert about is one the venue actually walks through rather than one + * the seeds manufacture. + */ + +/** + * The env VALUE that arms a child at `armHeight`: the canonical decimal spelling the + * indexer's regtest resolver accepts, or `ARM_VALUE` when no height is named. + * + * @param {number} [armHeight] a non-negative safe integer, or null/undefined for genesis + * @returns {string} + */ +function armValue (armHeight) { + if (armHeight === null || armHeight === undefined) return ARM_VALUE + if (!Number.isSafeInteger(armHeight) || armHeight < 0) throw new Error('barrierFamilyFixture: bad arm height ' + armHeight) + return String(armHeight) +} + +/** + * The activation height a crossing leg arms at, given the chain tip the venue was built + * on: the FIRST block the venue itself mines. Below it sits the chain the stack already + * has, which is where a legacy-era row can honestly live; at and above it sits everything + * the venue produces, which is admission era and carries a map. + * + * @param {number} railTip the chain's height when the venue is built + * @returns {number} + */ +function crossingArmHeight (railTip) { + if (typeof railTip !== 'number' || !Number.isSafeInteger(railTip) || railTip < 0) { + throw new Error('barrierFamilyFixture: bad rail tip ' + JSON.stringify(railTip)) + } + return railTip + 1 +} + +/** + * The era block a LEGACY seed carries under `armHeight`: the last block below the + * activation, which is a block the chain has actually reached. Never negative, so a + * venue armed at genesis has no legacy era and says so rather than seeding block -1. + * + * @param {number} armHeight + * @returns {number} + */ +function legacyEraBlock (armHeight) { + if (typeof armHeight !== 'number' || !Number.isSafeInteger(armHeight) || armHeight < 1) { + throw new Error('barrierFamilyFixture: no legacy era below arm height ' + JSON.stringify(armHeight) + + '; arm above genesis (crossingArmHeight) before seeding a legacy row') + } + return armHeight - 1 +} + // The nine watermark-keyed reasons in BLOCK-LOOP order, which is the order BF1's // enumeration must observe as each member's grace is raised in turn. `/status` is // the only source of the order at run time; this is the expected sequence, and the @@ -104,17 +170,19 @@ const ADMISSION_TABLES = Object.freeze({ /** * The per-index env overlay that arms the named indexers and leaves the rest inert. * `{ armed: [0] }` gives `{ 0: { [ARM_ENV]: 'armed' } }` and nothing for indexer 1, - * which is BF5's whole lever: one venue, two rules. + * which is BF5's whole lever: one venue, two rules. `armHeight` arms at that height + * instead of at genesis, which is what a crossing leg needs (see `crossingArmHeight`). * - * @param {{armed?: number[]}} spec + * @param {{armed?: number[], armHeight?: number}} spec * @returns {object} an `opts.indexerEnv` value */ function armingOverlay (spec) { const armed = (spec && Array.isArray(spec.armed)) ? spec.armed : [] + const value = armValue(spec ? spec.armHeight : undefined) const out = {} for (const i of armed) { if (!Number.isInteger(i) || i < 0) throw new Error('barrierFamilyFixture: bad indexer index ' + i) - out[i] = { [ARM_ENV]: ARM_VALUE } + out[i] = { [ARM_ENV]: value } } return out } @@ -224,6 +292,16 @@ function reseedQuoteBefore (startedAtMs, nowMs, nextS) { /** The commit a checkout (worktree or main) is at, read-only. */ function headShaOf (repoDir) { + const manifestPath = process.env.XCHAIN_SOURCE_REVISION_FILE || + path.join(path.dirname(repoDir), '.xchain-source-revisions.json') + if (fs.existsSync(manifestPath)) { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + const revisions = manifest && manifest.repositories + const revision = revisions && revisions[path.basename(repoDir)] + assert.ok(/^[0-9a-f]{40}$/.test(String(revision || '')), + manifestPath + ' has no pinned 40-hex revision for ' + path.basename(repoDir)) + return revision + } return execFileSync('git', ['-C', repoDir, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() } @@ -276,16 +354,22 @@ function buildFamilyVenue (opts) { const venueOpts = Object.assign({}, o.venue || {}, { label: o.label || 'bf', repoRoot: root, - indexerEnv: mergeIndexerEnv((o.venue && o.venue.indexerEnv) || {}, armingOverlay({ armed: o.armed || [] })), + indexerEnv: mergeIndexerEnv((o.venue && o.venue.indexerEnv) || {}, + armingOverlay({ armed: o.armed || [], armHeight: o.armHeight })), }) if (o.hubRepoRoots) venueOpts.hubRepoRoots = o.hubRepoRoots const venue = new AttestMirrorVenue(venueOpts) - return { venue, evidence: evidenceRecord(venue, { armed: o.armed || [], coinCode: coinCode(venue.coin) }) } + return { venue, evidence: evidenceRecord(venue, { + armed: o.armed || [], armHeight: o.armHeight === undefined ? null : o.armHeight, coinCode: coinCode(venue.coin), + }) } } module.exports = { ARM_ENV, ARM_VALUE, + armValue, + crossingArmHeight, + legacyEraBlock, FAMILY_REASONS_LOOP_ORDER, QUOTE_LIFETIME_S, RESEED_MARGIN_S, diff --git a/test/attestMirror/helpers/barrierFamilyRows.js b/test/attestMirror/helpers/barrierFamilyRows.js index 7237a3c9..311ab46f 100644 --- a/test/attestMirror/helpers/barrierFamilyRows.js +++ b/test/attestMirror/helpers/barrierFamilyRows.js @@ -144,6 +144,60 @@ function snapshotRow (s, tag) { } } +// The two seed tables selected by snapshotSyncSatisfied. The helper below is an +// explicit fixture seam: a withholding leg can omit the call and keep the barrier red. +const SNAPSHOT_JOIN_TABLES = Object.freeze(['cross_chain_matches', 'cross_chain_calls']) + +function joinedSnapshotBlocks (seeds) { + const blocks = new Set() + for (const seed of seeds || []) { + if (!seed || !SNAPSHOT_JOIN_TABLES.includes(seed.table)) continue + const raw = seed.row && seed.row.snapshot_block + const block = Number(raw) + if (raw === null || raw === undefined || raw === '' || !Number.isSafeInteger(block) || block < 0) { + throw new Error('barrierFamilyRows: joined seed has no valid snapshot_block') + } + blocks.add(block) + } + return Array.from(blocks).sort((a, b) => a - b) +} + +/** + * One deterministic cross_chain capability snapshot for every distinct block named by + * a match or call seed. Blocks are derived only from the seeds, never from caller input. + * + * @param {Array<{table: string, row: object, key: string[]}>} seeds inertRow results + * @returns {Array<{table: string, row: object, key: string[]}>} + */ +function requiredCapabilitySnapshots (seeds) { + return joinedSnapshotBlocks(seeds).map((block) => ({ + table: 'capability_snapshots', + row: snapshotRow({ snapshotBlock: block }, 'required|cross_chain|' + block), + key: NATURAL_KEYS.capability_snapshots, + })) +} + +/** + * Whether a complete seed set carries every snapshot required by its match and call + * rows. No joined rows is an explicit refusal, not a vacuous success. + * + * @returns {{satisfied: boolean, missingBlocks: number[], refusal: string|null}} + */ +function snapshotCapabilityCoverage (seeds) { + const required = joinedSnapshotBlocks(seeds) + if (required.length === 0) { + return { satisfied: false, missingBlocks: [], refusal: 'no rows from snapshot barrier join tables' } + } + const present = new Set() + for (const seed of seeds || []) { + if (!seed || seed.table !== 'capability_snapshots' || !seed.row || seed.row.capability !== 'cross_chain') continue + const block = Number(seed.row.snapshot_block) + if (Number.isSafeInteger(block) && block >= 0) present.add(block) + } + const missingBlocks = required.filter((block) => !present.has(block)) + return { satisfied: missingBlocks.length === 0, missingBlocks, refusal: null } +} + const ROW_BUILDERS = Object.freeze({ cross_chain_matches: matchRow, cross_chain_calls: callRow, bridge_transfers: bridgeRow, policy_snapshots: policyRow, attestation_responses: attestRow, oracle_prices: oracleRow, @@ -181,29 +235,47 @@ function familySeedRows (spec) { } /* - * WHERE AN ARMED VENUE CANNOT TAKE A LEGACY ROW. The regtest arming lever resolves to height 0 - * (xchain-indexer mirror_admission_gate.js resolveMirrorAdmissionRegtest), so every snapshot_block is - * admission era and a NULL admission map is a row no hub produces. The canonical builders refuse it - * ("admission-era row at block N ... has no admit_blocks", admissionCanonicalValue), and that refusal - * is a thrown error that stalls the block wherever the apply pass reaches the canonical for a due row. + * WHERE AN ARMED VENUE CANNOT TAKE A NULL-MAP ROW. A row whose era block is at or above the + * producer activation and whose every admission column is NULL is a row no hub produces. The + * canonical builders refuse it ("admission-era row at block N ... has no admit_blocks", + * admissionCanonicalValue), and that refusal is a thrown error that stalls the block wherever the + * apply pass reaches the canonical for a due row. * * These two tables reach it unconditionally: bridge_settle/transfer.js guardQuorumAndEscrow and * bridge_settle/policy.js guardQuorumAndCopy build the canonical as verifyQuorum's ARGUMENT, before the * capability set is read (rail 2026-09-17: BF2 stalled at block 104 on its legacy bridge row). The match * and call passes read the capability set first and defer while it is empty (cross_settle/quorum.js, - * xexec/dispatch_quorum.js), and the attest pass selects mirror rows only for a pending request, so an - * inert legacy row in those tables is never canonicalized on these venues. + * xexec/dispatch_quorum.js), and the attest pass selects mirror rows only for a pending request, so a + * NULL-map row in those tables is never canonicalized on these venues. + * + * WHAT CHANGED, and why the table list is no longer a seeding rule. While the legs armed at genesis + * this list doubled as a workaround: BF2 simply did not seed a legacy row in these two tables, because + * on a venue armed at height 0 every era block is admission era and a "legacy" seed was really a modern + * row missing its map (adjudicated 2026-09-18: the product is correct, the seed was the defect). The + * legs now arm at a crossing and seed their legacy rows BELOW it, where the builder returns an empty + * canonical and no pass throws, so every member table takes a legacy row. The list survives as the + * SCOPE of the guard below, which is now keyed on the era rather than on the table alone. */ const CANONICAL_BEFORE_QUORUM_TABLES = Object.freeze(['bridge_transfers', 'policy_snapshots']) +// The column each table's era is fixed by, the one the canonical builder keys on. Only the tables +// the guard scopes over are listed: a table added here without an era column is a defect, not a pass. +const ERA_BLOCK_COLUMN = Object.freeze({ bridge_transfers: 'snapshot_block', policy_snapshots: 'snapshot_block' }) + /** * The seeds an armed venue would stall on: finalized rows in a CANONICAL_BEFORE_QUORUM table whose - * every admission column is NULL. A map naming any chain builds its canonical and is not a hazard. + * every admission column is NULL and whose ERA BLOCK is at or above the activation. A map naming any + * chain builds its canonical and is not a hazard; nor is a NULL-map row below the activation, which is + * an honest legacy row and builds an empty canonical tail. * * @param {Array<{table: string, row: object, key: string[]}>} seeds inertRow results + * @param {number} [armHeight] the producer activation; omitted means the genesis form, where every + * era block is admission era and every NULL-map seed is a hazard * @returns {string[]} one 'table|key' per offending seed; empty when the set is safe to seed armed */ -function armedLegacyApplyHazards (seeds) { +function armedLegacyApplyHazards (seeds, armHeight) { + const activation = (armHeight === undefined || armHeight === null) ? 0 : Number(armHeight) + if (!Number.isSafeInteger(activation) || activation < 0) throw new Error('barrierFamilyRows: bad arm height ' + armHeight) const out = [] for (const s of seeds || []) { if (!s || !CANONICAL_BEFORE_QUORUM_TABLES.includes(s.table)) continue @@ -211,27 +283,39 @@ function armedLegacyApplyHazards (seeds) { // A retracted row is outside every apply select (status = 'finalized'), so it never reaches a canonical. if (r.status !== 'finalized') continue const cols = Object.keys(admissionColumns(s.table, null)) - if (cols.every((c) => r[c] === null || r[c] === undefined)) out.push(s.table + '|' + (s.key || []).map((k) => r[k]).join(',')) + if (!cols.every((c) => r[c] === null || r[c] === undefined)) continue + // An unreadable era block fails CLOSED: the builder reads it as pre-activation, but a seed that + // cannot say which era it is in is a harness defect and the leg should hear about it here. + // NULL and the empty string are the trap, since Number() reads both as block 0. + const raw = r[ERA_BLOCK_COLUMN[s.table]] + const era = (raw === null || raw === undefined || raw === '') ? Number.NaN : Number(raw) + if (Number.isSafeInteger(era) && era < activation) continue + out.push(s.table + '|' + (s.key || []).map((k) => r[k]).join(',')) } return out } /** - * BF2's member rows against drill height B: per table one row admitted AT B and one PAST B, plus a - * legacy NULL row only where the armed apply pass cannot reach its canonical (see above). The legacy - * rule itself is BF4's to prove; BF2's spec asserts the set with admit_blocks[BTC] <= B. + * BF2's member rows against drill height B: per table one row admitted AT B, one PAST B, and one + * LEGACY row seeded at `legacyBlock`, a block below the activation, where a NULL admission map is + * what a pre-crossing hub really wrote. The legacy rule itself is BF4's to prove; BF2's spec asserts + * the set with admit_blocks[BTC] <= B. * - * @param {string[]} tables the member tables - * @param {object} base inertRow spec without tag or admitBlocks - * @param {number} B the drill height - * @param {string} tagTail distinguishes re-runs (the leg passes the held tip) + * @param {string[]} tables the member tables + * @param {object} base inertRow spec without tag or admitBlocks + * @param {number} B the drill height + * @param {string} tagTail distinguishes re-runs (the leg passes the held tip) + * @param {number} legacyBlock the legacy seed's era block (barrierFamilyFixture.legacyEraBlock) */ -function admissionSeedRows (tables, base, B, tagTail) { +function admissionSeedRows (tables, base, B, tagTail, legacyBlock) { + if (!Number.isSafeInteger(Number(legacyBlock)) || Number(legacyBlock) < 0) { + throw new Error('barrierFamilyRows: admissionSeedRows needs a legacyBlock below the activation, got ' + legacyBlock) + } const seeds = [] for (const t of tables) { seeds.push(inertRow(t, Object.assign({}, base, { tag: 'bf2|at|' + t + '|' + tagTail, admitBlocks: { BTC: B } }))) seeds.push(inertRow(t, Object.assign({}, base, { tag: 'bf2|past|' + t + '|' + tagTail, admitBlocks: { BTC: B + 3 } }))) - if (!CANONICAL_BEFORE_QUORUM_TABLES.includes(t)) seeds.push(inertRow(t, Object.assign({}, base, { tag: 'bf2|legacy|' + t + '|' + tagTail }))) + seeds.push(inertRow(t, Object.assign({}, base, { tag: 'bf2|legacy|' + t + '|' + tagTail, snapshotBlock: Number(legacyBlock) }))) } return seeds } @@ -586,9 +670,12 @@ function replayWitnessCommand (o) { module.exports = { NATURAL_KEYS, INERT_SIGNATURES, + SNAPSHOT_JOIN_TABLES, COIN_SCOPE, finalizedClause, inertRow, + requiredCapabilitySnapshots, + snapshotCapabilityCoverage, familySeedRows, admissionColumns, CANONICAL_BEFORE_QUORUM_TABLES, diff --git a/test/attestMirror/helpers/deliveryWindow.js b/test/attestMirror/helpers/deliveryWindow.js new file mode 100644 index 00000000..99270b3a --- /dev/null +++ b/test/attestMirror/helpers/deliveryWindow.js @@ -0,0 +1,43 @@ +'use strict' + +/********************************************************************* + * Copyright © 2025-2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC - https://dankest.llc + * SPDX-License-Identifier: AGPL-3.0-or-later + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + ********************************************************************** + * Was a mirror-delivery fault still live when a leg mined the block it judges? + * + * A leg that injects a delivery delay and then waits for a barrier is only measuring + * that barrier while the injection is BOTH fired and unexpired. AT2b went red on + * 2026-09-18 with neither condition checked: the delayed indexer spent the window + * clearing an older block backlog, the configured delay ran out, and the assertion + * then read a node that had nothing left to wait for. + * + * The proxy's hold counters are CUMULATIVE, so `framesHeld > 0` proves the injection + * fired at some point and says nothing about now. The elapsed clock is what carries + * "now", and the two are only evidence together. + ********************************************************************/ + +/** + * @param {number} armedAtMs wall clock when the delay was armed + * @param {number} nowMs wall clock at the read + * @param {number} delayMs the configured delay ceiling + * @param {object} stats the mirror proxy's cumulative hold counters + * @returns {{ok: boolean, elapsedMs: number, injectionFired: boolean}} + */ +function delayedDeliveryWindow (armedAtMs, nowMs, delayMs, stats) { + const elapsedMs = Number(nowMs) - Number(armedAtMs) + const injectionFired = Number(stats && stats.framesHeld) > 0 || + Number(stats && stats.snapshotRowsHeld) > 0 + return { + ok: injectionFired && Number.isFinite(elapsedMs) && elapsedMs >= 0 && elapsedMs < Number(delayMs), + elapsedMs: elapsedMs, + injectionFired: injectionFired, + } +} + +module.exports = { delayedDeliveryWindow } diff --git a/test/attestMirror/helpers/hubSchemaVersion.js b/test/attestMirror/helpers/hubSchemaVersion.js new file mode 100644 index 00000000..33005b33 --- /dev/null +++ b/test/attestMirror/helpers/hubSchemaVersion.js @@ -0,0 +1,43 @@ +'use strict' + +/********************************************************************* + * Copyright © 2025-2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC - https://dankest.llc + * SPDX-License-Identifier: AGPL-3.0-or-later + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + ********************************************************************** + * The mirror schema version, read from the indexer that consumes the mirror + * rather than typed into a leg. + * + * WHY THIS IS NOT A LITERAL. A hub serving a schema version the mirror consumer + * does not demand parks the WHOLE mirror rather than one table, so every leg that + * reads a snapshot route wants the same number, and that number moves with the + * schema. A leg carrying its own copy goes stale silently at the next bump and + * then fails for a reason that has nothing to do with what it is testing: the + * 2026-09-18 aggregate lost `at0b` to a hardcoded 5 against a hub serving 7, + * while `venue.smoke`, which already read the indexer's copy, stayed green + * through the same bump. + * + * WHAT IT READS. The indexer's own `hub_schema_version.js`, resolved beside the + * e2e repo. The indexer renamed the file to snake_case, so the hyphenated name an + * older indexer origin carries is tried too and the first that exists wins; when + * neither exists the snake_case path is returned so the require throws naming the + * path it wanted rather than a bare undefined. + ********************************************************************/ + +const fs = require('fs') +const path = require('path') + +const CANDIDATE_NAMES = ['hub_schema_version.js', 'hub-schema-version.js'] + +const HUB_SCHEMA_VERSION_FILE = CANDIDATE_NAMES + .map((name) => path.join(__dirname, '..', '..', '..', '..', 'xchain-indexer', 'src', 'hub', name)) + .find((p) => fs.existsSync(p)) || + path.join(__dirname, '..', '..', '..', '..', 'xchain-indexer', 'src', 'hub', 'hub_schema_version.js') + +const HUB_SCHEMA_VERSION = require(HUB_SCHEMA_VERSION_FILE).HUB_SCHEMA_VERSION + +module.exports = { HUB_SCHEMA_VERSION, HUB_SCHEMA_VERSION_FILE } diff --git a/test/attestMirror/venue.smoke.test.js b/test/attestMirror/venue.smoke.test.js index 7a8fd288..5bd9d11a 100644 --- a/test/attestMirror/venue.smoke.test.js +++ b/test/attestMirror/venue.smoke.test.js @@ -40,23 +40,15 @@ ********************************************************************/ const assert = require('assert') -const fs = require('fs') -const path = require('path') const dotenv = require('dotenv') dotenv.config() const { AttestMirrorVenue, DEFAULT_HUB_COUNT, DEFAULT_INDEXER_COUNT } = require('../helpers/attestMirrorVenue') -// The version the mirror consumer demands. Read from the indexer's own copy -// rather than typed here: the number moves with the schema, and a literal would -// have to be remembered at every bump while the code that matters would not. -// The indexer renamed the file to snake_case; the hyphenated name is what an indexer -// origin from before that rename carries, so the first of the two that exists is read. -const HUB_SCHEMA_VERSION_FILE = ['hub_schema_version.js', 'hub-schema-version.js'] - .map((name) => path.join(__dirname, '..', '..', '..', 'xchain-indexer', 'src', 'hub', name)) - .find((p) => fs.existsSync(p)) || - path.join(__dirname, '..', '..', '..', 'xchain-indexer', 'src', 'hub', 'hub_schema_version.js') -const HUB_SCHEMA_VERSION = require(HUB_SCHEMA_VERSION_FILE).HUB_SCHEMA_VERSION +// The version the mirror consumer demands. Read from the indexer's own copy rather +// than typed here, and now shared with the other legs that assert it, so one bump +// moves every leg at once. The resolution itself lives in the helper. +const { HUB_SCHEMA_VERSION } = require('./helpers/hubSchemaVersion') describe('attest-response mirror venue: it stands up', function () { // Five hub processes and two indexers, each bootstrapping a schema on an diff --git a/test/attestMirror/zc1_zero_conf.test.js b/test/attestMirror/zc1_zero_conf.test.js index 8a3792be..d40ecf62 100644 --- a/test/attestMirror/zc1_zero_conf.test.js +++ b/test/attestMirror/zc1_zero_conf.test.js @@ -51,7 +51,8 @@ const assert = require('assert') const dotenv = require('dotenv') -dotenv.config() +const UNIT_ONLY = process.env.ZC1_UNIT_ONLY === '1' +if (!UNIT_ONLY) dotenv.config() const { AttestMirrorVenue } = require('../helpers/attestMirrorVenue') const { @@ -110,6 +111,30 @@ module.exports = { function sleep (ms) { return new Promise((r) => setTimeout(r, ms)) } +async function waitForLocalRequestAtBlock (venue, indexerIndex, request, deps) { + const d = deps || {} + const waitForHeight = d.waitForHeight || waitForHeightWithClear + const readRow = d.readRow || readRequestRow + const log = d.log || console.log + const requestId = String(request && request.requestId) + const rawBlockIndex = request && request.blockIndex + const blockIndex = Number(rawBlockIndex) + assert.ok(rawBlockIndex !== null && rawBlockIndex !== undefined && + Number.isSafeInteger(blockIndex) && blockIndex >= 0, + 'the standing indexer returned no usable block height for request ' + requestId) + + const committed = await waitForHeight(venue, indexerIndex, blockIndex) + log('ZC1 BARRIER: request ' + requestId.slice(0, 12) + ' mined at ' + blockIndex + + ', venue indexer ' + indexerIndex + ' committed ' + (committed && committed.at)) + const local = await readRow(venue, indexerIndex, requestId) + assert.ok(local, 'the venue indexer holds no v0 request row for ' + requestId + + ' after committing request block ' + blockIndex) + assert.strictEqual(Number(local.block_index), blockIndex, + 'the venue request row landed at block ' + local.block_index + + ' but the standing indexer placed it at ' + blockIndex) + return local +} + /** * Run `work` and mine AT MOST ONE block, and only once the mempool has something. * @@ -137,7 +162,52 @@ async function mineOneBlockFor (work) { try { return await p } finally { await miner; } } -describe('ZC1: a request mined at N is served at tip N and binds at N+1', function () { +if (UNIT_ONLY) { + describe('ZC1 request-block barrier', function () { + it('waits for the venue commit before reading the request row', async function () { + const venue = { label: 'unit' } + const request = { requestId: 'abc123', blockIndex: 157 } + let committed = false + let evidence = null + const local = await waitForLocalRequestAtBlock(venue, 0, request, { + waitForHeight: async (gotVenue, gotIndex, gotHeight) => { + assert.strictEqual(gotVenue, venue) + assert.strictEqual(gotIndex, 0) + assert.strictEqual(gotHeight, 157) + await Promise.resolve() + committed = true + return { at: gotHeight } + }, + readRow: async (gotVenue, gotIndex, gotRequestId) => { + assert.ok(committed, 'the request row read ran before the venue committed its block') + assert.strictEqual(gotVenue, venue) + assert.strictEqual(gotIndex, 0) + assert.strictEqual(gotRequestId, 'abc123') + return { block_index: 157 } + }, + log: (line) => { evidence = line }, + }) + assert.strictEqual(local.block_index, 157) + assert.strictEqual(evidence, + 'ZC1 BARRIER: request abc123 mined at 157, venue indexer 0 committed 157') + }) + + it('refuses a row assigned to a different block', async function () { + await assert.rejects(() => waitForLocalRequestAtBlock({}, 0, + { requestId: 'abc123', blockIndex: 157 }, { + waitForHeight: async () => {}, + readRow: async () => ({ block_index: 158 }), + log: () => {}, + }), /venue request row landed at block 158 but the standing indexer placed it at 157/) + }) + + it('refuses to wait without a usable request height', async function () { + await assert.rejects(() => waitForLocalRequestAtBlock({}, 0, + { requestId: 'abc123', blockIndex: null }), + /standing indexer returned no usable block height/) + }) + }) +} else describe('ZC1: a request mined at N is served at tip N and binds at N+1', function () { // Five hub processes, two indexers replaying from genesis, then one round. this.timeout(60 * 60 * 1000) @@ -207,8 +277,7 @@ describe('ZC1: a request mined at N is served at tip N and binds at N+1', functi const requestId = request.requestId await settleOrReport('zc1') - const local = await readRequestRow(venue, 0, requestId) - assert.ok(local, 'the venue indexer holds no v0 request row for ' + requestId) + const local = await waitForLocalRequestAtBlock(venue, 0, request) const N = Number(local.block_index) // THE PRECONDITION THE WHOLE CASE RESTS ON. The miner is paused precisely diff --git a/test/attestMirror/zc3_headroom.test.js b/test/attestMirror/zc3_headroom.test.js index e403971d..456629b7 100644 --- a/test/attestMirror/zc3_headroom.test.js +++ b/test/attestMirror/zc3_headroom.test.js @@ -79,6 +79,11 @@ const FIXED_BODY = '{"score":23,"meta":"zc3-headroom"}' // Long enough to read the draw and stop a hub before any hub's first poll fires. const POLL_MS = 20000 +// A poll can land in the interval between request visibility and stopHub. A fresh +// request changes both the draw and which hub occupies rank 1, so a bounded retry +// turns that race into one discarded request without turning the leg into a hang. +const VICTIM_WINDOW_ATTEMPTS = 4 + const FORWARD_S = 3 // Sixty blocks gives a first ladder segment of twenty (span 60, V2 maxSlots 2, so @@ -153,7 +158,7 @@ async function issueRequest() { await clearBeforeBroadcast() const exec = await mineWhile(() => vmHelper.sendExecuteV0( contract.owner, contract.contractIndex, 'ask', ['http_get', testUrl])) - return { exec, sinceAction } + return { exec, sinceAction, requestVisibleAtMs: Date.now() } } async function readDraw(sinceAction) { @@ -193,7 +198,7 @@ function selectVictim(draw) { return { victimKey, victimHub } } -async function stopVictim(victimHub, victimKey, requestId) { +async function stopVictim(victimHub, victimKey, requestId, requestVisibleAtMs) { await venue.stopHub(victimHub) stoppedHub = victimHub console.log('ZC3: stopped hub ' + victimHub + ' (' + victimKey.slice(0, 16) + '...), rank 1 of the draw') @@ -203,8 +208,40 @@ async function stopVictim(victimHub, victimKey, requestId) { // round that four members ran and three finished, which is a different claim. // The round-start line is written once per started round on a responsible hub, // so its absence on the victim is the evidence. - return venue.logTail('hub' + victimHub) - .includes('AttestationRound: starting ' + requestId.slice(0, 16) + '...') + const tail = venue.logTail('hub' + victimHub) + const tailAvailable = typeof tail === 'string' && tail.trim().length > 0 && + !/^\s*last 0 line\(s\) from /.test(tail) + const marker = 'AttestationRound: starting ' + requestId.slice(0, 16) + '...' + const roundStartLine = tailAvailable + ? tail.split('\n').find((line) => line.includes(marker)) || null + : null + const roundStartAtMs = roundStartLine ? Date.parse(roundStartLine.trim().split(/\s+/, 1)[0]) : NaN + const gapMs = Number.isFinite(roundStartAtMs) ? roundStartAtMs - requestVisibleAtMs : null + + // An unavailable tail cannot prove absence. Treat it as a violated window so + // the retry either obtains observable evidence or exhausts loudly. + return { + victimStarted: !tailAvailable || roundStartLine !== null, + tailAvailable, + roundStartLine, + gapMs, + } +} + +function victimAttemptEvidence(attempt, requestId, victimHub, requestVisibleAtMs, observation) { + const prefix = 'attempt ' + attempt + ' request ' + requestId.slice(0, 16) + + '... hub ' + victimHub + ': ' + if (!observation.tailAvailable) { + return prefix + 'log tail unavailable; request observed at ' + + new Date(requestVisibleAtMs).toISOString() + ', round-start gap not measurable' + } + if (!observation.roundStartLine) { + return prefix + 'no round-start line; request observed at ' + + new Date(requestVisibleAtMs).toISOString() + } + return prefix + 'round-start gap ' + observation.gapMs + 'ms (request observed ' + + new Date(requestVisibleAtMs).toISOString() + ', round started ' + + new Date(requestVisibleAtMs + observation.gapMs).toISOString() + ')' } async function waitForSignedResult(requestId, draw) { @@ -271,23 +308,52 @@ describe('ZC3: a drawn member that cannot sign is covered by the headroom slot', after(teardownZc3) it('finalizes with exactly redundancy signatures inside the first ladder segment', async function () { - const { exec, sinceAction } = await issueRequest() - assert.strictEqual(exec.execution.status, 'valid', - 'the EXECUTE that emits the request came back ' + exec.execution.status) - const { requestId, draw, drawFrom } = await readDraw(sinceAction) - assert.ok(draw, 'no venue hub could resolve the responsible set for ' + requestId + - ' while it was pending\n' + allHubTails(venue)) - console.log('ZC3: hub ' + drawFrom + ' drew ' + jsonSafe(draw.responsible.map((p) => p.slice(0, 16))) + ' (redundancy ' + draw.redundancy + ', widen ' + draw.widen + ') for request ' + requestId.slice(0, 12)) - assert.strictEqual(draw.widen, 1, - 'the draw carries widen ' + draw.widen + ' rather than 1. At the request\'s own block ' + 'the V2 ladder returns its headroom slot and nothing more, so 0 means the stage-2 early ' + 'return is not armed and 2 or more means blocks went by before the draw was read.') - assert.strictEqual(draw.responsible.length, REDUNDANCY + 1, - 'the responsible set holds ' + draw.responsible.length + ' member(s) rather than ' + (REDUNDANCY + 1) + ' (redundancy ' + REDUNDANCY + ' plus one headroom slot): ' + jsonSafe(draw.responsible.map((p) => p.slice(0, 16)))) - const { victimKey, victimHub } = selectVictim(draw) - assert.ok(victimHub >= 0, - 'the drawn member at rank 1 (' + victimKey.slice(0, 16) + '...) belongs to no venue hub. ' + 'The venue adopts the roster precisely so every drawn key has a live hub here.') - const victimStarted = await stopVictim(victimHub, victimKey, requestId) + let requestId = null + let draw = null + let victimKey = null + let victimHub = null + let victimStarted = true + const victimAttempts = [] + + for (let attempt = 1; attempt <= VICTIM_WINDOW_ATTEMPTS; attempt++) { + const { exec, sinceAction, requestVisibleAtMs } = await issueRequest() + assert.strictEqual(exec.execution.status, 'valid', + 'the EXECUTE that emits the request came back ' + exec.execution.status) + const read = await readDraw(sinceAction) + requestId = read.requestId + draw = read.draw + assert.ok(draw, 'no venue hub could resolve the responsible set for ' + requestId + + ' while it was pending\n' + allHubTails(venue)) + console.log('ZC3: hub ' + read.drawFrom + ' drew ' + jsonSafe(draw.responsible.map((p) => p.slice(0, 16))) + ' (redundancy ' + draw.redundancy + ', widen ' + draw.widen + ') for request ' + requestId.slice(0, 12)) + assert.strictEqual(draw.widen, 1, + 'the draw carries widen ' + draw.widen + ' rather than 1. At the request\'s own block ' + 'the V2 ladder returns its headroom slot and nothing more, so 0 means the stage-2 early ' + 'return is not armed and 2 or more means blocks went by before the draw was read.') + assert.strictEqual(draw.responsible.length, REDUNDANCY + 1, + 'the responsible set holds ' + draw.responsible.length + ' member(s) rather than ' + (REDUNDANCY + 1) + ' (redundancy ' + REDUNDANCY + ' plus one headroom slot): ' + jsonSafe(draw.responsible.map((p) => p.slice(0, 16)))) + const selected = selectVictim(draw) + victimKey = selected.victimKey + victimHub = selected.victimHub + assert.ok(victimHub >= 0, + 'the drawn member at rank 1 (' + victimKey.slice(0, 16) + '...) belongs to no venue hub. ' + 'The venue adopts the roster precisely so every drawn key has a live hub here.') + const observation = await stopVictim( + victimHub, victimKey, requestId, requestVisibleAtMs) + victimStarted = observation.victimStarted + victimAttempts.push(victimAttemptEvidence( + attempt, requestId, victimHub, requestVisibleAtMs, observation)) + if (!victimStarted) break + if (attempt < VICTIM_WINDOW_ATTEMPTS) { + await venue.startHub(victimHub) + stoppedHub = null + console.log('ZC3: discarded request ' + requestId.slice(0, 12) + + ' after its victim started; hub ' + victimHub + + ' is back for fresh attempt ' + (attempt + 1)) + } + } + assert.strictEqual(victimStarted, false, - 'hub ' + victimHub + ' had already started a round for ' + requestId.slice(0, 16) + '... before it was stopped, so it is not a member that never signed and ZC3 is measuring ' + 'something else. Its poll fired inside the window; re-run (the poll interval is the ' + 'window, see the header).\n' + venue.logTail('hub' + victimHub)) + 'ZC3 exhausted ' + VICTIM_WINDOW_ATTEMPTS + ' fresh request attempts without constructing ' + + 'a victim window at poll interval ' + POLL_MS + 'ms. A selected victim had already ' + + 'started its request, or its log tail was unavailable, so it cannot prove a member that ' + + 'never signed. ' + victimAttempts.join('; ') + '\n' + venue.logTail('hub' + victimHub)) const { mirrorRows, signers, headroomKey, strays } = await waitForSignedResult(requestId, draw) for (const [i, row] of mirrorRows.entries()) { assert.strictEqual(String(row.status), 'ok', diff --git a/test/attestMirror/zc5_flag_day.test.js b/test/attestMirror/zc5_flag_day.test.js index 690ddb12..635bbaeb 100644 --- a/test/attestMirror/zc5_flag_day.test.js +++ b/test/attestMirror/zc5_flag_day.test.js @@ -26,10 +26,10 @@ * exist below it, exactly as AT6's own skipped case measures for the mirror * activation. The below-height behaviour is therefore asserted at a SYNTHETIC * height on `testnet`, which is the one network where the widening map is armed - * (150780), the mirror map is armed (151324) and the zero-conf map is still null: - * the only combination that reaches the old branches with the mirror-era gate - * satisfied. If the operator arms testnet, these cases stop testing what they say - * they test, so the fixture assumption is asserted first rather than assumed. + * (150780), the mirror map is armed (151324) and the zero-conf map arms at 151800: + * the only interval that reaches the old branches with the mirror-era gate satisfied. + * The fixture pins that activation and keeps every synthetic height below it, so a + * later registry change fails before these cases can assert the wrong era. * * THE ABOVE-HEIGHT HALF IS ON THE VENUE, on the at6 shape (D79): a request served * at zero confirmations, bound, then rolled back and re-applied, with the signed @@ -80,10 +80,11 @@ const XChainIndexerConnector = require('../../src/XChainIndexerConnector.js') // --------------------------------------------------------------------------- // A testnet request block: above the widening height (150780) and above the mirror -// height (151324), with the zero-conf map still null. Any height in that band works; -// these three pin one request's whole window. +// height (151324), but below the zero-conf height (151800). These values pin one +// request's whole window inside that interval. const T_REQ_BLOCK = 151400 const T_DEADLINE = 151500 +const TESTNET_ZERO_CONF_HEIGHT = 151800 // The regtest counterparts, where every request is above the zero-conf height because // the map is armed at genesis. Present so every below-height assertion has an @@ -120,17 +121,19 @@ describe('ZC5 below the height: the flag day leaves every rule where it was', fu Utility = require('../../../xchain-indexer/src/utility.js') }) - it('holds the fixture assumption: testnet is armed for mirror and widening, not zero-conf', + it('holds the fixture assumption: the synthetic request stays below testnet zero-conf activation', function () { // THE ASSUMPTION EVERY CASE BELOW RESTS ON. If the operator arms testnet (the // frontier's own row 19), these cases would silently start testing the // above-height branch while still claiming to test the below-height one, // which is the most dangerous direction for a flag-day test to fail in. - assert.strictEqual(zc.ATTEST_ZERO_CONF_ACTIVATION.testnet, null, - 'ATTEST_ZERO_CONF_ACTIVATION.testnet is no longer null (' + - zc.ATTEST_ZERO_CONF_ACTIVATION.testnet + '), so testnet is not a below-height venue any ' + - 'more and this whole describe block is asserting the wrong branch. Move these cases to ' + - 'a network that is still null, or retire them with the height.') + assert.strictEqual(zc.ATTEST_ZERO_CONF_ACTIVATION.testnet, TESTNET_ZERO_CONF_HEIGHT, + 'ATTEST_ZERO_CONF_ACTIVATION.testnet moved from the pinned height ' + + TESTNET_ZERO_CONF_HEIGHT + ' to ' + zc.ATTEST_ZERO_CONF_ACTIVATION.testnet + + '; resize the synthetic below-height window before trusting these cases') + assert.ok(T_DEADLINE < TESTNET_ZERO_CONF_HEIGHT, + 'the synthetic request window reaches testnet zero-conf activation at ' + + TESTNET_ZERO_CONF_HEIGHT + '; these cases no longer exercise the below-height branch') assert.strictEqual(zc.isZeroConfActive(T_REQ_BLOCK, 'testnet'), false) assert.ok(Number.isInteger(wid.ATTEST_RESPONSIBLE_WIDENING_ACTIVATION.testnet) && T_REQ_BLOCK >= wid.ATTEST_RESPONSIBLE_WIDENING_ACTIVATION.testnet, diff --git a/test/helpers/attestMirrorVenue.js b/test/helpers/attestMirrorVenue.js index 51dcd9d1..a04eb95f 100644 --- a/test/helpers/attestMirrorVenue.js +++ b/test/helpers/attestMirrorVenue.js @@ -144,6 +144,14 @@ const DEFAULT_BATCH_WINDOW_S = 30; // this: the invariant governs the venue's own resting knobs. const GOSSIP_HOP_BUDGET_S = 2; +// How many times `statusOf` re-reads `/status` when the read fails WITHOUT an HTTP +// status, and how long it waits between those reads. Three attempts over two seconds +// covers a socket dropped under load without hiding a listener that is genuinely gone: +// an indexer that is down stays down and still fails the leg, about two seconds in, +// while a single dropped connection costs one retry rather than the whole leg. +const STATUS_TRANSPORT_ATTEMPTS = 3; +const STATUS_TRANSPORT_RETRY_MS = 1000; + // The two window keyings this venue knows how to reason about, named so a verdict cannot // be spelled two ways in the helper and its guard. `effective_time` is the signed field // every hub reads identically; `finalized_at` is the per-hub wall clock older hubs keyed @@ -449,6 +457,79 @@ function planPorts(ports, hubCount, indexerCount) { }; } +/** Where the venue probes from when nothing names a base. */ +const DEFAULT_VENUE_BASE_PORT = 41000; + +/** The per-slot probe base the stack runner exports for the leg it launches. */ +const VENUE_BASE_PORT_ENV = 'AB_VENUE_BASE_PORT'; + +/** + * The runner's own slot number, which is how a leg knows it is one of several running + * side by side. `run-attest-mirror.js` puts it in the driver's environment and nothing + * down the chain scrubs it, so it arrives in the mocha process alongside the base. + */ +const RUNNER_SLOT_ENV = 'ATTEST_MIRROR_SLOT'; + +/** + * The port probe base for ONE venue, which is `planPorts`' guarantee carried one + * level out, from inside a venue to between concurrent venues. + * + * `planPorts` makes two roles of one venue unable to share a port by partitioning a + * single probe. Nothing made two VENUES unable to share one, and they run side by + * side: `pickFreePorts` probes and returns, and no child binds until seconds later, + * so two processes probing the same base are handed the same ports and the second + * child to listen dies. Measured on a three-way concurrent run, where two legs lost + * their whole `before all` hook to `listen EADDRINUSE 127.0.0.1:61017` and `:61030` + * and reported 0 passing, which reads as two broken legs rather than as one + * allocation that was never slot-aware. + * + * The runner already derives a disjoint window per concurrency slot and exports it. + * Reading it HERE is what covers every leg: a leg that constructs the venue directly + * and names no base is the common case, and a fix at the call sites would cover only + * the legs that exist today. An explicit `basePort` still wins, so a caller that + * places its own venues by hand (the bridge rail's two) is unchanged. + * + * A base that is set but unusable throws rather than falling back, because the + * fallback is the shared window this exists to leave. + * + * @param {number} [explicit] `opts.basePort`, the caller's own choice + * @param {object} [env] environment to read, normally `process.env` + * @returns {number} the base to probe upward from + */ +function resolveVenueBasePort(explicit, env) { + if (explicit) return explicit; + const source = env || {}; + const raw = source[VENUE_BASE_PORT_ENV]; + if (raw === undefined || raw === null || String(raw).trim() === '') { + // UNDER THE RUNNER, AN ABSENT BASE IS A FAILURE AND NOT A DEFAULT. A leg that is + // one of several concurrent slots and has no base of its own would fall back to + // the shared window, every slot would probe the same ports, and the whole run + // would come back as intermittently flaky barrier legs rather than as a missing + // export. That is the failure this refuses to report quietly: the slot is the + // evidence that a base was owed, so its presence without one throws. + // + // SLOT 0 IS A REAL SLOT. The check is presence, never truthiness, because the + // first slot arrives as the string "0" and a truthiness test would wave through + // exactly the case that collides with everything else. + const slot = source[RUNNER_SLOT_ENV]; + if (slot !== undefined && slot !== null && String(slot).trim() !== '') { + throw new Error('attestMirrorVenue: ' + RUNNER_SLOT_ENV + '=' + slot + ' names a concurrency ' + + 'slot but ' + VENUE_BASE_PORT_ENV + ' is unset, so this leg has no window of its own. ' + + 'Falling back to ' + DEFAULT_VENUE_BASE_PORT + ' would put every concurrent slot back ' + + 'in one shared window and surface as flaky legs rather than as the missing export. ' + + 'The stack driver derives the base per slot and run-leg.sh exports it.'); + } + return DEFAULT_VENUE_BASE_PORT; + } + const base = Number(raw); + if (!Number.isInteger(base) || base < 1024 || base > 65000) { + throw new Error('attestMirrorVenue: ' + VENUE_BASE_PORT_ENV + '=' + raw + ' is not a usable probe base. ' + + 'The stack runner exports the slot\'s base as an integer between 1024 and 65000; ' + + 'falling back to ' + DEFAULT_VENUE_BASE_PORT + ' would put this leg back in the shared window.'); + } + return base; +} + /** * Refuse a knob combination that makes an acceptance test flaky rather than failing. * @@ -2020,7 +2101,8 @@ class AttestMirrorVenue { * keypairs are generated and no request will ever select * these hubs. * @param opts.coin/network the chain the indexers index (default bitcoin/regtest) - * @param opts.basePort port probe base (default 41000) + * @param opts.basePort port probe base; unset, the runner's per-slot base from + * the environment, else 41000 (see resolveVenueBasePort) * @param opts.hubDb an already-started disposableHubDb handle to share * @param opts.repoRoot monorepo root the hub and indexer children are spawned * from; defaults to XCHAIN_VENUE_REPO_ROOT, else the @@ -2065,7 +2147,10 @@ class AttestMirrorVenue { this.configSecretsRedacted = null; if (this.attachHubs) this.hubCount = this.attachHubs.length; this.network = opts.network || 'regtest'; - this.basePort = opts.basePort || 41000; + // Named base first, else the runner's per-slot window, else the historical + // 41000. See resolveVenueBasePort for why the environment is read here and + // not at the legs. + this.basePort = resolveVenueBasePort(opts.basePort, process.env); this.repoRoot = resolveRepoRoot(opts.repoRoot, process.env); // `{1: '/path/to/other/root'}`: the per-HUB code root, resolved and checked here // so a bad root refuses at construction rather than as a boot timeout. Every @@ -2755,6 +2840,32 @@ class AttestMirrorVenue { throw new Error('attestMirrorVenue[' + this.label + ']: indexer ' + i + ' never created its schema in ' + ix.indexerDbName + ' within ' + up.waitedMs + 'ms.\n' + this._tail('indexer' + i)); } + + // The schema wait proves the indexer reached its DATABASE, not that its HTTP + // listener is bound, and those are minutes apart on a cold genesis replay. Every + // read a drill takes goes through `//status`, so without this wait `start()` returns + // over a socket that is still refusing and the leg dies in its `before all` hook + // with ECONNREFUSED rather than a named barrier. Indexers are spawned SERIALLY, so + // the last one is the exposed one every time: it is the one whose schema ends the + // wait above with nothing behind it to cover its listener, which is why the refusal + // lands on the same port on every stack rather than looking like a flake. + // + // ANY answer counts, 503 included. A stalled indexer that reports its stall is + // listening, and a barrier drill's whole subject is a node parked on a stall: a wait + // for HTTP 200 here would hang exactly the legs this is meant to let run. + const answering = await waitFor(async () => { + if (ix.proc.exitCode !== null) return { ok: false, dead: true }; + try { + const res = await axios.get(ix.apiUrl + '/status', { timeout: 5_000, validateStatus: () => true }); + return { ok: true, httpStatus: res.status }; + } catch (_) { return { ok: false }; } + }, { timeoutMs: BOOT_WAIT_MS, intervalMs: 1000 }); + if (!answering.ok) { + throw new Error('attestMirrorVenue[' + this.label + ']: indexer ' + i + ' never answered /status on ' + + ix.apiUrl + ' within ' + answering.waitedMs + 'ms' + + (answering.last && answering.last.dead ? ' (the process exited)' : '') + + '.\n' + this._tail('indexer' + i)); + } ix.connector = new XChainIndexerConnector('127.0.0.1', ix.apiPort, null); } @@ -3549,11 +3660,46 @@ class AttestMirrorVenue { // The indexer's `/status`, parsed. Carries `stallReason`, `stallClass`, // `stallClearsAt`, `degraded` and the block counters: the anti-wedge leg reads // `attest_response_sync_barrier` out of `stallReason` here. + // + // RETRIES A DROPPED SOCKET, AND NOTHING ELSE. `validateStatus` accepts every HTTP + // status on purpose, because a stalled indexer reporting 503 is an ANSWER and is + // frequently the very state a barrier drill is asserting. A transport failure is the + // absence of an answer: the socket hung up, the connection reset, the read timed out + // before any response line arrived. Those two are not the same event and only the + // second is safe to repeat, so the retry keys on `error.response` being absent and a + // status of any kind returns on the first attempt, unretried and unmodified. + // + // WHY IT EXISTS. This was one unretried `axios.get`, so a single dropped socket + // anywhere in a leg threw straight out of the venue and killed the leg in whatever + // `before all` hook or poll loop happened to be reading. Measured across two full + // aggregate drives, `statusOf` threw in four legs: at0, at0b and venue.smoke on the + // 2026-09-17 drive (ECONNREFUSED, the indexer's listener not yet bound, since fixed + // upstream by waiting for it) and ab1 on the 2026-09-18 drive (socket hang up, mid + // leg, on an indexer that was up and had answered many times already). Waiting for + // the listener at bring-up cured the first shape and could not cure the second. async statusOf(indexerIndex) { const ix = this.indexers[indexerIndex]; if (!ix) throw new Error('attestMirrorVenue: no indexer ' + indexerIndex); - const res = await axios.get(ix.apiUrl + '/status', { timeout: 10_000, validateStatus: () => true }); - return { httpStatus: res.status, body: res.data }; + let lastError = null; + for (let attempt = 0; attempt < STATUS_TRANSPORT_ATTEMPTS; attempt++) { + try { + const res = await axios.get(ix.apiUrl + '/status', + { timeout: 10_000, validateStatus: () => true }); + return { httpStatus: res.status, body: res.data }; + } catch (error) { + // A response of ANY status is an answer and must not be retried; without + // one this is a transport failure and repeating it is safe. + if (error && error.response) throw error; + lastError = error; + await sleep(STATUS_TRANSPORT_RETRY_MS); + } + } + const reason = lastError && lastError.message ? lastError.message : String(lastError); + const wrapped = new Error('attestMirrorVenue[' + this.label + ']: indexer ' + indexerIndex + + ' did not answer /status on ' + ix.apiUrl + ' in ' + STATUS_TRANSPORT_ATTEMPTS + + ' attempts, the last failing before any HTTP status arrived: ' + reason); + wrapped.cause = lastError; + throw wrapped; } async stallClassOf(indexerIndex) { @@ -3910,6 +4056,10 @@ module.exports = { hubExtraEnvFor, planPorts, portCount, + resolveVenueBasePort, + DEFAULT_VENUE_BASE_PORT, + VENUE_BASE_PORT_ENV, + RUNNER_SLOT_ENV, buildHubEnv, buildIndexerEnv, pickOutsideIndexer, diff --git a/test/helpers/bridgeRailVenue.js b/test/helpers/bridgeRailVenue.js index f05dba92..216380b7 100644 --- a/test/helpers/bridgeRailVenue.js +++ b/test/helpers/bridgeRailVenue.js @@ -2680,10 +2680,10 @@ function hubRelayMarginFloorS(chain) { * exactly the span that does not need it removes the drift without touching the window. * * The miner's own pause only stops its auto-mine loop: `generate_blocks` is exposed with no - * pause gate, so a drive that mines BTC through an external loop keeps moving the tip. That - * loop honours a flag file instead: when `BRIDGE_RAIL_MINER_PAUSE_FILE` names one, it is - * created for the span and removed in the same finally, and the loop skips its call while - * the file exists. + * pause gate, so a drive that mines through external loops keeps moving the tips. Those + * loops honour flag files instead: `BRIDGE_RAIL_MINER_PAUSE_FILE` controls the BTC loop and + * `BRIDGE_RAIL_DOGE_MINER_PAUSE_FILE` controls the DOGE loop. Each configured file exists + * for the span and is removed in the same finally, so the corresponding loop skips its call. * * @param {{pauseMining: function, resumeMining: function}} miner the regtest miner connector * @param {function(): Promise<*>} fn @@ -2695,13 +2695,15 @@ async function withMiningPaused(miner, fn, opts) { 'withMiningPaused: needs a connector with pauseMining()/resumeMining()'); const pauseFile = (opts && opts.pauseFile !== undefined) ? opts.pauseFile : (process.env.BRIDGE_RAIL_MINER_PAUSE_FILE || ''); + const dogePauseFile = process.env.BRIDGE_RAIL_DOGE_MINER_PAUSE_FILE || ''; + const pauseFiles = [pauseFile, dogePauseFile].filter(Boolean); const fs = require('fs'); await miner.pauseMining(); - if (pauseFile) fs.writeFileSync(pauseFile, String(process.pid) + '\n'); try { + for (const file of pauseFiles) fs.writeFileSync(file, String(process.pid) + '\n'); return await fn(); } finally { - if (pauseFile) { try { fs.unlinkSync(pauseFile); } catch (e) { /* already gone */ } } + for (const file of pauseFiles) { try { fs.unlinkSync(file); } catch (e) { /* already gone */ } } await miner.resumeMining(); } } diff --git a/test/helpers/chainRail.js b/test/helpers/chainRail.js index 358aa0b8..2a4b6364 100644 --- a/test/helpers/chainRail.js +++ b/test/helpers/chainRail.js @@ -69,7 +69,7 @@ const COIN_CODE_MAP = { bitcoin: 'BTC', litecoin: 'LTC', dogecoin: 'DOGE' }; const DEFAULT_PORTS = { BTC: { node: 3020, tracker: 3021, decoder: 3022, encoder: 3023, indexer: 3024, miner: 3025 }, LTC: { node: 3220, tracker: 3221, decoder: 3222, encoder: 3223, indexer: 3224, miner: 3225 }, - DOGE: { node: 3120, tracker: 3121, decoder: 3122, encoder: 3123, indexer: 3124, miner: 3125 }, + DOGE: { node: 3120, tracker: 3121, decoder: 3122, encoder: 3123, indexer: 3004, miner: 3125 }, }; // Globals a rail owns. Restoring exactly this list is what makes withRail safe diff --git a/test/helpers/envelopeHelper.js b/test/helpers/envelopeHelper.js index 9ba2ed72..9d752f46 100644 --- a/test/helpers/envelopeHelper.js +++ b/test/helpers/envelopeHelper.js @@ -124,7 +124,7 @@ module.exports = { const nativeFeeHelper = require('./nativeFeeHelper') let outputs = customOutputs if (outputs === null){ - const feeOutput = await nativeFeeHelper.getNativeFeeOutput() + const feeOutput = await nativeFeeHelper.getNativeFeeOutput(action, addressInfo && addressInfo["address"]) outputs = feeOutput ? [feeOutput] : [] } diff --git a/test/helpers/gasHelper.js b/test/helpers/gasHelper.js index 30631929..5dde68cb 100644 --- a/test/helpers/gasHelper.js +++ b/test/helpers/gasHelper.js @@ -10,6 +10,7 @@ const mintHelper = require('./mintHelper') const issueHelper = require('./issueHelper') +const sendHelper = require('./sendHelper') const chainRail = require('./chainRail') const requireRow = require('./requireRow') const transactionHelper = require('../transactionHelper') @@ -40,6 +41,70 @@ const { relayMarginFloorS } = loadRelayMargin() const GAS_TICK = "XCHAIN" +// The per-transaction MAX_MINT the e2e ISSUE of the gas tick sets on BTC (the ISSUE +// below and initialCheck's), so a bridge larger than one MINT is minted in chunks. +const GAS_MAX_MINT = 100000 + +// XCHAIN carries 8 decimals off BTC; amounts are compared in these base units so a +// display string such as '0.5' cannot be rounded into a false "enough". +const GAS_UNIT_SCALE = 10n ** 8n + +// Bridged gas held on a non-BTC chain for the rest of this process, handed out by a +// local SEND. Bridging once per funded address cost a full relay margin each time +// (LTC 600 s, DOGE 240 s, plus the BTC MINT and lock), and nearly every funded +// address takes gas: on a representative nightly run the lock-to-credit +// leg alone averaged 518 s on litecoin and 404 s on dogecoin, per address, where +// bitcoin's faucet is a one-block local MINT. A SEND from the reservoir is one block. +// Sized to cover a full action suite in one bridge: the 111 literal call sites ask +// for about 364 000 XCHAIN, plus 100 per funded address (261 on the bitcoin leg of +// that run). A shortfall refills rather than fails. +const RESERVOIR_BRIDGE_AMOUNT = parseReservoirAmount(process.env.E2E_GAS_RESERVOIR_AMOUNT, 500000) + +// Native coin the reservoir address is funded with for its SEND fees. Each SEND +// spends the 50 000-sat native fee output plus relay fee, so this covers thousands. +const RESERVOIR_NATIVE_FUND = 10 + +// Charged against the reservoir per SEND on top of the amount, so a gas-schedule +// fee on the SEND itself can never drain it below what the accounting believes. +const RESERVOIR_FEE_HEADROOM = 1n * GAS_UNIT_SCALE + +// One reservoir per chain and network, so a test that swaps rails never spends +// another chain's reservoir. Values: { addressInfo, remaining } (base units). +const reservoirs = new Map() + +function parseReservoirAmount(raw, def){ + if (raw === undefined || raw === '') return def + const n = Number(raw) + if (!Number.isInteger(n) || n <= 0) + throw new Error('gasHelper: E2E_GAS_RESERVOIR_AMOUNT must be a positive whole number of XCHAIN, got ' + raw) + return n +} + +function gasUnits(amount){ + const m = String(amount).trim().match(/^(\d+)(?:\.(\d{1,8}))?$/) + if (!m) throw new Error('gasHelper: not a gas amount: ' + amount) + return BigInt(m[1]) * GAS_UNIT_SCALE + BigInt((m[2] || '').padEnd(8, '0')) +} + +// The MINT amounts that add up to `amount`, each within GAS_MAX_MINT. An amount +// that fits one MINT is returned untouched, so its wire string is the caller's. +function mintChunks(amount){ + if (gasUnits(amount) <= BigInt(GAS_MAX_MINT) * GAS_UNIT_SCALE) return [amount] + // BTC's gas tick has 0 decimals, so anything minted there is whole. + let left = BigInt(String(amount)) + const chunks = [] + while (left > 0n) { + const chunk = left < BigInt(GAS_MAX_MINT) ? left : BigInt(GAS_MAX_MINT) + chunks.push(String(chunk)) + left -= chunk + } + return chunks +} + +function reservoirKey(){ + return global.COIN_CODE + ':' + global.NETWORK +} + // Slack on top of the relay margin for everything between the lock confirming and // the credit row: the engine's 15 s poll, the single-validator PBFT round, the // mirror ingest, and the destination's protocol clock (median-time-past off @@ -93,7 +158,11 @@ async function withIdleMining(miners, fn, intervalMs = IDLE_MINE_INTERVAL_MS){ module.exports = { BRIDGE_CREDIT_SLACK_MS, IDLE_MINE_INTERVAL_MS, + GAS_MAX_MINT, + RESERVOIR_BRIDGE_AMOUNT, bridgeCreditWaitMs, + bridgeCreditAttribution: requireRow.bridgeCreditAttribution, + bridgeCreditEvidence: requireRow.bridgeCreditEvidence, withIdleMining, async mintGas(addressInfo, amount){ @@ -142,14 +211,15 @@ module.exports = { await issueHelper.sendIssueV0( issuerInfo, GAS_TICK, 100000000, // MAX_SUPPLY - 100000, // MAX_MINT (per tx): high for e2e; real faucet may cap tighter + GAS_MAX_MINT, // MAX_MINT (per tx): high for e2e; real faucet may cap tighter 0, // decimals "XChain GAS Token", 0 // MINT_SUPPLY: faucet, no pre-minted supply ) } - await mintHelper.sendMintV0(issuerInfo, GAS_TICK, amount, issuerAddress, "") + for (const chunk of mintChunks(amount)) + await mintHelper.sendMintV0(issuerInfo, GAS_TICK, chunk, issuerAddress, "") let lockMessage = "XBRIDGE|0|" + destCoin + "|" + destAddress + "|" + amount + "|" @@ -178,23 +248,88 @@ module.exports = { console.log("Waiting for the bridged " + GAS_TICK + " credit on " + destCoin + " (up to " + Math.round(waitMs / 1000) + " s: relay margin " + relayMarginFloorS(destCoin) + " s plus slack)...") - return requireRow(await indexerDatabase.waitForCredit({ + const credit = await indexerDatabase.waitForCredit({ address: destAddress, tick: GAS_TICK, amount: amount - }, waitMs), "bridgeGasIn: the bridged " + GAS_TICK + " credit of " + amount + " to " - + destAddress + " on " + destCoin + " never landed (lock tx " + lockTxHash + ")") + }, waitMs) + const failure = "bridgeGasIn: the bridged " + GAS_TICK + " credit of " + amount + " to " + + destAddress + " on " + destCoin + " never landed (lock tx " + lockTxHash + ")" + const expected = { + lockTxHash, + destCoin, + destAddress, + tick: GAS_TICK, + amount + } + return await requireRow.withProbe(credit, failure, + () => requireRow.bridgeCreditAttribution(indexerDatabase, expected), + requireRow.bridgeCreditEvidence(expected)) }) }, // Fresh mnemonics per run mean addresses start at zero balance, so no need to // diff against current balance for idempotency in the e2e context. BTC keeps - // the local open-mint faucet; every other chain routes through the bridge - // (bridgeGasIn above), because xchain-bridge.md's supply-path closure (D62) - // closes the local ISSUE/open-MINT path everywhere else. + // the local open-mint faucet; every other chain is paid from the bridged + // reservoir (sendFromGasReservoir below), because xchain-bridge.md's + // supply-path closure (D62) closes the local ISSUE/open-MINT path everywhere + // else. A suite that must prove the bridge itself calls bridgeGasIn directly. async ensureGasBalance(addressInfo, amount){ if (global.COIN_CODE === 'BTC') return await this.mintGas(addressInfo, amount) - return await this.bridgeGasIn(addressInfo, amount) + return await this.sendFromGasReservoir(addressInfo, amount) + }, + + // Make sure this chain's reservoir exists and holds at least `neededUnits` + // (base units), bridging a refill when it does not. The first call on a fresh + // venue is also what creates the gas token row off BTC (section 9), which is + // why initialCheck's bootstrap calls this directly. + async fillGasReservoir(neededUnits = 1n){ + const key = reservoirKey() + let reservoir = reservoirs.get(key) + if (!reservoir) { + const addressInfo = await cryptoHelper.getNewFundedAddress( + "GAS.RESERVOIR", global.COIN, global.NETWORK, null, "legacy", 0, RESERVOIR_NATIVE_FUND, false + ) + reservoir = { addressInfo, remaining: 0n } + reservoirs.set(key, reservoir) + } + if (reservoir.remaining >= neededUnits) return reservoir + + // Whole XCHAIN, because the BTC side mints with 0 decimals. + const shortfall = (neededUnits - reservoir.remaining + GAS_UNIT_SCALE - 1n) / GAS_UNIT_SCALE + const bridgeAmount = String(shortfall > BigInt(RESERVOIR_BRIDGE_AMOUNT) ? shortfall : BigInt(RESERVOIR_BRIDGE_AMOUNT)) + console.log("Filling the " + key + " gas reservoir " + reservoir.addressInfo["address"] + + " with " + bridgeAmount + " " + GAS_TICK + " over the bridge...") + try { + await this.bridgeGasIn(reservoir.addressInfo, bridgeAmount) + } catch (err) { + // A half-landed refill leaves the balance unknown; start the next call clean. + reservoirs.delete(key) + throw err + } + reservoir.remaining += gasUnits(bridgeAmount) + return reservoir + }, + + async sendFromGasReservoir(addressInfo, amount){ + const cost = gasUnits(amount) + RESERVOIR_FEE_HEADROOM + const reservoir = await this.fillGasReservoir(cost) + try { + const result = await sendHelper.sendSendV0(reservoir.addressInfo, GAS_TICK, amount, addressInfo["address"], "") + reservoir.remaining -= cost + return result + } catch (err) { + // A SEND that did not land valid means the accounting no longer matches the + // ledger (a reorg, a fee this file does not know about): rebuild next time + // rather than keep spending from a balance nobody has checked. + reservoirs.delete(reservoirKey()) + throw err + } + }, + + // Unit tests only: forget every reservoir so each case starts from none. + _resetGasReservoirs(){ + reservoirs.clear() } } diff --git a/test/helpers/hubMirrorTopology.js b/test/helpers/hubMirrorTopology.js index d3b9c921..766fa5c3 100644 --- a/test/helpers/hubMirrorTopology.js +++ b/test/helpers/hubMirrorTopology.js @@ -471,18 +471,71 @@ async function resolveDriverPriceTarget(opts){ return Object.assign({}, envTarget, { source: 'env model (HUB_DB_NAME)' }) } +// JSON-RPC 'Method not found' (see xchain-indexer's express-json-rpc-router). An +// indexer that answers this for a method every current build serves is not the +// coin's real indexer at all: on the regtest venue behind this driver, the DOGE +// indexer publishes on 3004 while every caller here still dials the conventional +// 3124, and whatever answers there instead - measured 2026-09-08 as the reachable +// BTC indexer sharing the docker network - has never heard of DOGE's method set +// and refuses it exactly this way rather than by refusing the connection. Only +// this code means 'wrong server, keep looking'; any other error (a domain +// rejection, an auth failure) comes from the right server and is the final answer. +const RPC_METHOD_NOT_FOUND = -32601 + +// Candidate JSON-RPC bases to try for the coin indexer, most-explicit (the +// caller's own URL) first. A caller on the conventional 3124 gets 3004 added as +// a fallback, since that is the one venue-specific port swap known to matter; +// every other URL resolves to itself alone. +function indexerRpcUrls(url){ + let primary = String(url || '').replace(/\/+$/, '') + if (!primary) return [] + let out = [primary] + try { + let parsed = new URL(primary) + if (parsed.port === '3124'){ + parsed.port = '3004' + let fallback = parsed.toString().replace(/\/+$/, '') + if (fallback !== primary) out.push(fallback) + } + } catch (e){ + // Let axios report the original malformed URL with its normal diagnostic. + } + return out +} + // A connector shaped like XChainIndexerConnector for a driver that has only a URL. // Returns null rather than throwing on a missing URL, so a driver that cannot reach // its indexer keeps the env model instead of failing to start. function rpcConnector(url){ if (!url) return null let axios = require('axios') + let urls = indexerRpcUrls(url) return { call: async (method, params) => { - let res = await axios.post(url.replace(/\/+$/, '') + '/api', - { jsonrpc: '2.0', method: method, params: params || {}, id: 1 }, { timeout: 8000 }) - if (res.data && res.data.error) - return { error: (res.data.error.message || JSON.stringify(res.data.error)) } - return res.data ? res.data.result : null + let lastError = null + for (let i = 0; i < urls.length; i++){ + let base = urls[i] + let isLast = i === urls.length - 1 + try { + let res = await axios.post(base.replace(/\/+$/, '') + '/api', + { jsonrpc: '2.0', method: method, params: params || {}, id: 1 }, { timeout: 8000 }) + if (res.data && res.data.error){ + // A reachable indexer that simply does not know this method is not + // this coin's indexer; try the next candidate rather than reporting + // its refusal as the answer. Any other error came from a server that + // DOES know the method, which proves it is the right one. + if (res.data.error.code === RPC_METHOD_NOT_FOUND && !isLast) continue + return { error: (res.data.error.message || JSON.stringify(res.data.error)) } + } + return res.data ? res.data.result : null + } catch (e){ + lastError = e + // An HTTP response proves the configured endpoint is live. Its error + // belongs to that service and must not be hidden by trying another port. + if (e && e.response) throw e + if (isLast) throw e + } + } + throw lastError } } } diff --git a/test/helpers/nativeFeeHelper.js b/test/helpers/nativeFeeHelper.js index 2fc119f5..bf282192 100644 --- a/test/helpers/nativeFeeHelper.js +++ b/test/helpers/nativeFeeHelper.js @@ -19,10 +19,16 @@ // is never created, and the suite hangs on the resulting TICK-unknown cascade. // // validateNativeCoinFee (utility.js) enforces only a LOWER bound: it rejects -// when paidAmount < 0.95 * oracle-expected and never rejects overpayment, and -// this suite seeds (and therefore controls) the oracle prices. So a single flat -// fee output that comfortably clears the min for every action is sufficient; no -// per-action fee computation is needed. BTC is left untouched (gas mode). +// when paidAmount < 0.95 * oracle-expected and never rejects overpayment. At the +// seeded prices a flat FLAT_FEE_SATS output clears the min for every action, so +// that is still what is paid there. But the seed is not the only thing that sets +// the prices the indexer values a fee against: a suite that re-prices a pair for +// its own case, and a hub_db_sync reconcile that deletes the seed rows and leaves +// the hub's live rounds in force, both move them, and the flat output was then +// rejected on every ISSUE/DEPLOY/EXECUTE (nightly run 35899520268, all ten LTC and +// DOGE shards). So the output is sized at send time from the indexer's own +// feeschedule prices and, where those make the flat output too small, its feequote +// for this very action (nativeFeeSats). BTC is left untouched (gas mode). const priceSnapshotHelper = require('./priceSnapshotHelper') const topology = require('./hubMirrorTopology') @@ -51,6 +57,30 @@ const COIN_USD = '100000.00000000' // fundings cryptoHelper uses. const FLAT_FEE_SATS = 50000 +// The XCHAIN cost FLAT_FEE_SATS covers at the seeded pair (0.0005 coin * 100000 / 2). +// Re-valued at the indexer's current prices it is the fallback size for an action the +// feequote cannot price, so the fallback always buys the same XCHAIN as the flat fee. +const FEE_BUDGET_XCHAIN = 25 + +// Headroom over the indexer's own expected fee. The band's floor is 0.95x expected, so +// 1.10x survives a price move of about 14% between the quote and the action's block. +const FEE_HEADROOM = 1.10 + +// Every price fixture in this repo writes a round at or above this (the same floor +// xchainPriceDerivation asserts against); a hub's round counter is a small integer that +// needs decades to reach it. An indexer pricing off a round below it is therefore pricing +// off the hub's live rounds, which means the seed rows are gone from what it reads. +const FIXTURE_ROUND_FLOOR = 990000 + +// At most one displaced-seed re-seed (see nativeFeeSats) per this many milliseconds. +const DISPLACED_RESEED_MIN_MS = 15000 +let _lastDisplacedReseedMs = 0 + +// What a sender can pay when its balance cannot be read: cryptoHelper's default funding +// is one coin, which is exactly the 100000000-sat input the encoder refused to stretch +// over a live-priced ISSUE in MT3 run 35925455269. +const DEFAULT_FUNDED_SATS = 100000000 + // Re-seed at most this often. Must stay well under ORACLE_MAX_PRICE_AGE_SECONDS // (1800s): a >30-minute run would otherwise let the seeded snapshot age out and // the validator would reject every fee as stale. Re-anchored to the chain clock. @@ -72,6 +102,7 @@ const XCHAIN_ROUND = 888100001 const COIN_ROUND = 888100002 const XCHAIN_ROUND_NOW = 888100011 const COIN_ROUND_NOW = 888100012 +const OWN_SEED_ROUNDS = new Set([XCHAIN_ROUND, COIN_ROUND, XCHAIN_ROUND_NOW, COIN_ROUND_NOW]) // Re-seed once the CHAIN clock has moved this far past the last seed's anchor, // independent of the wall-clock throttle above. A clock-drill family (BET, and @@ -460,11 +491,183 @@ async function discoverFeeMode(){ return _feeMode } +// The two prices the indexer values a native fee against right now, from its own +// feeschedule (the same getFeeOraclePrices read validateNativeCoinFee makes, anchored on +// the tip block). Returns { available, xchainUsd, coinUsd, oracleRound, error }, or null +// when the indexer cannot answer, in which case the caller keeps the flat fee. +async function readFeePrices(){ + const c = global.indexerConnector + if (!c || typeof c.call !== 'function') return null + let sched = null + try { sched = await c.call('feeschedule', {}) } catch (e) { return null } + if (!sched || !sched.prices) return null + const p = sched.prices + if (!p.available) return { available: false, error: p.error || 'unavailable' } + const xchainUsd = Number(p.xchainUsd) + const coinUsd = Number(p.coinUsd) + if (!(xchainUsd > 0) || !(coinUsd > 0)) return { available: false, error: 'non-positive price' } + return { available: true, xchainUsd, coinUsd, oracleRound: Number(p.oracleRound) } +} + +function isSeedPair(prices){ + return prices.xchainUsd === Number(XCHAIN_USD) && prices.coinUsd === Number(COIN_USD) +} + +// True when the prices the indexer reads are no longer anything this suite put there: +// none at all, a hub round (below every fixture's round) valuing the coin, or one of +// THIS helper's own rounds carrying a pair it never wrote. A pair some other suite +// re-priced for its own case carries that suite's fixture round and is left alone, so +// the fee is sized to it instead. Never true on a venue that publishes its own prices. +// +// The own-round case is what MT3 run 35925455269 hit. A mirrored hub round landed on the +// seed rows' primary keys and rewrote their prices in place (see priceSnapshotHelper +// FIXTURE_ID_FLOOR), so the coin row still reported round 888100012, which cleared the +// fixture floor, and the displaced check never fired. Only the coin row's round is +// reported, so a moved XCHAIN/USD leg under a seed-round coin row reads the same way. +function seedDisplaced(prices){ + if (NO_PRICE_SEED) return false + if (!prices.available) return true + if (isSeedPair(prices)) return false + return !(prices.oracleRound >= FIXTURE_ROUND_FLOOR) || OWN_SEED_ROUNDS.has(prices.oracleRound) +} + +// What the sender can pay, in satoshis: its UTXO total from the tracker, or the default +// one-coin funding when it cannot be read (no sender named, no tracker, nothing indexed). +async function fundedSats(source){ + const t = global.utxoTrackerConnector + if (source && t && typeof t.getUtxosFromAddress === 'function') { + try { + const snap = await t.getUtxosFromAddress(source) + const utxos = (snap && Array.isArray(snap.utxos)) ? snap.utxos : [] + const total = utxos.reduce((sum, u) => sum + (Number(u && u.value) || 0), 0) + if (total > 0) return total + } catch (e) { /* fall through to the default funding */ } + } + return DEFAULT_FUNDED_SATS +} + +function describePrices(label, p){ + if (!p) return label + ' unreadable' + if (p.available === false) return label + ' unavailable (' + (p.error || 'no price') + ')' + return label + ' ' + global.COIN_CODE + '/USD=' + p.coinUsd + ' XCHAIN/USD=' + p.xchainUsd + + ' round ' + p.oracleRound +} + +// Satoshis for `xchainAmount` of protocol fee at `prices`, before any headroom. Multiplied +// out before the one division so the seeded pair comes out exact (25 * 2 * 1e8 / 1e5). +function rawSatsForXchain(xchainAmount, prices){ + return Number(xchainAmount) * prices.xchainUsd * 1e8 / prices.coinUsd +} + +// The indexer's own expected fee for this exact action, in satoshis with FEE_HEADROOM, +// or null when it will not price it. feequote dry-runs the real handler against current +// state and values its staged XCHAIN fee at the prices the chain will use. It answers +// no fee for an action it judges invalid (a negative test's deliberately bad action), +// for a controller-bound one, for BATCH/XEXEC, or while the indexer is busy on a block; +// connector.call throws on that answer's error envelope, and every one of those cases +// falls back to the priced budget. +// Returns { sats, prices } where `prices` is the pair the quote itself was valued at (the +// feequote answer carries it), or null when there is no quote. +async function quoteActionSats(wire, source){ + const c = global.indexerConnector + if (typeof wire !== 'string' || !source || !c || typeof c.call !== 'function') return null + const cut = wire.indexOf('|') + if (cut <= 0) return null + let q = null + try { + q = await c.call('feequote', { action: wire.slice(0, cut), params: wire.slice(cut + 1), source: source }) + } catch (e) { return null } + if (!q || q.valid === false) return null + if (q.feeExempt) return { sats: 0, prices: null } + const sats = Number(q.requiredFeeSats) + if (!Number.isFinite(sats) || sats < 0) return null + const prices = (q.coinUsdPrice != null && q.xchainUsdPrice != null) + ? { available: true, xchainUsd: Number(q.xchainUsdPrice), coinUsd: Number(q.coinUsdPrice), + oracleRound: Number(q.oracleRound) } + : null + return { sats: Math.ceil(sats * FEE_HEADROOM), prices } +} + +// One sizing pass at `prices` (the feeschedule view): { sats, quotePrices }. `sats` is the +// raw size before the flat floor, so a caller can tell a pass that needs no more than the +// flat fee from one that does. +async function sizeAt(prices, wire, source){ + if (!prices || !prices.available) return { sats: FLAT_FEE_SATS, quotePrices: null } + const budget = rawSatsForXchain(FEE_BUDGET_XCHAIN, prices) + if (Math.round(budget) <= FLAT_FEE_SATS) return { sats: FLAT_FEE_SATS, quotePrices: null } + const quoted = await quoteActionSats(wire, source) + if (quoted) return { sats: quoted.sats, quotePrices: quoted.prices } + return { sats: Math.ceil(budget * FEE_HEADROOM), quotePrices: null } +} + +async function reseedDisplaced(why){ + _lastDisplacedReseedMs = Date.now() + console.log('nativeFeeHelper: ' + why + '; re-seeding') + await seedGlobalPrices(true) + return readFeePrices() +} + +// Size the native fee output for one action tx (`wire`, from `source`; both optional). +// At the seeded pair, or any pair at which FLAT_FEE_SATS still buys FEE_BUDGET_XCHAIN, +// the flat fee is paid exactly as before and no quote is made, so a suite that runs on +// the seed sees no change. Past that the flat fee is too small for some actions: the +// action's own feequote sizes it, and where there is none the budget is re-priced. The +// flat fee stays the floor either way, since overpaying is never rejected. +// +// Before sizing, a seed that has vanished from the indexer's view is put back (see +// seedDisplaced). Paying the live hub price is not an option there: at a real +// DOGE/USD one ISSUE costs about 17 DOGE, several times what a test address is funded +// with, so the build would fail instead of the validation. +// +// A size above the flat fee gets one more look. A suite's own re-price that the sender +// can pay is paid as quoted. A size off the seed that is displaced (in the feeschedule +// view or the pair the quote was valued at) or that the sender cannot pay gets ONE +// forced re-seed, ignoring the rate limit, and is sized again. A size still above the +// sender's balance is refused with every price seen, rather than handed to the encoder +// to fail as "insufficient funds" with no word about why the fee was that large. +async function nativeFeeSats(wire, source){ + let prices = await readFeePrices() + const seen = [describePrices('feeschedule', prices)] + let reseeded = false + // Rate-limited: where even a fresh seed stays invisible (warnIfSeedInvisible names + // that case), re-seeding before every action would only repeat the warning. + if (prices && seedDisplaced(prices) && (Date.now() - _lastDisplacedReseedMs) >= DISPLACED_RESEED_MIN_MS) { + prices = await reseedDisplaced('the indexer no longer prices off the seed (' + + describePrices('feeschedule', prices) + ')') + reseeded = true + seen.push(describePrices('feeschedule after re-seed', prices)) + } + let sized = await sizeAt(prices, wire, source) + if (sized.sats <= FLAT_FEE_SATS) return FLAT_FEE_SATS + if (sized.quotePrices) seen.push(describePrices('feequote', sized.quotePrices)) + + const funded = await fundedSats(source) + const offSeed = !isSeedPair(prices) || !!(sized.quotePrices && !isSeedPair(sized.quotePrices)) + const displaced = seedDisplaced(prices) || !!(sized.quotePrices && seedDisplaced(sized.quotePrices)) + if (!reseeded && !NO_PRICE_SEED && offSeed && (displaced || sized.sats > funded)) { + prices = await reseedDisplaced('a ' + sized.sats + '-sat fee for ' + (wire ? wire.split('|')[0] : 'this action') + + ' is priced off the seed (' + seen.join('; ') + ')') + seen.push(describePrices('feeschedule after re-seed', prices)) + sized = await sizeAt(prices, wire, source) + if (sized.sats <= FLAT_FEE_SATS) return FLAT_FEE_SATS + if (sized.quotePrices) seen.push(describePrices('feequote after re-seed', sized.quotePrices)) + } + if (sized.sats > funded) + throw new Error('nativeFeeHelper: refusing a ' + sized.sats + '-sat native fee output for ' + + (wire ? wire.split('|')[0] : 'this action') + (source ? ' from ' + source : '') + + ': the sender holds ' + funded + ' sats. Prices seen: ' + seen.join('; ') + + '. The seed is XCHAIN/USD=' + XCHAIN_USD + ' ' + global.COIN_CODE + '/USD=' + COIN_USD + + ', at which this fee would be the flat ' + FLAT_FEE_SATS + ' sats.') + return Math.max(FLAT_FEE_SATS, sized.sats) +} + // The native fee output to attach to an action tx, or null to skip (gas-mode // chains where native fees are disabled). Throws loudly when native fees ARE // enabled but no destination is resolvable. Throwing loudly is far better than // a silent skip that hangs the suite. Refreshes prices first so a long run never ages out. -async function getNativeFeeOutput(){ +// `wire` and `source` (the action string and its sender) let the fee be quoted for this +// action; a caller without them still gets an output priced at the current rates. +async function getNativeFeeOutput(wire, source){ // Refresh oracle prices for EVERY chain first (throttled). Gas-mode BTC // returns null just below, but its contract actions still need a fresh // BTC/USD for USD-pegged fee validation, so the seed must run BEFORE the @@ -475,8 +678,9 @@ async function getNativeFeeOutput(){ if (!mode.destination) throw new Error('native fee enabled on ' + global.COIN_CODE + ' but no FEE_DESTINATION resolvable (set FEE_DESTINATION or check indexer feeschedule)') - return { address: mode.destination, value: FLAT_FEE_SATS } + return { address: mode.destination, value: await nativeFeeSats(wire, source) } } module.exports = { resolveFeeDestination, discoverFeeMode, seedGlobalPrices, getNativeFeeOutput, - warnIfSeedInvisible, hubSeedTarget, lastSeedReport, FLAT_FEE_SATS } + nativeFeeSats, warnIfSeedInvisible, hubSeedTarget, lastSeedReport, FLAT_FEE_SATS, + FEE_BUDGET_XCHAIN, FEE_HEADROOM } diff --git a/test/helpers/priceSnapshotHelper.js b/test/helpers/priceSnapshotHelper.js index a72df510..822d3d07 100644 --- a/test/helpers/priceSnapshotHelper.js +++ b/test/helpers/priceSnapshotHelper.js @@ -57,9 +57,45 @@ function seedsThroughMirror(){ return !topology.sameTarget(resolveParams(), indexerReadParams()) } +// Fixture rows take their PRIMARY KEY from here up, never from AUTO_INCREMENT. +// +// On an indexer with hub_db_sync, price_snapshots is a mirror that keeps the HUB's +// row ids (row_apply strips the wire id only for capability_snapshots and +// attestation_responses), and its upsert is ON DUPLICATE KEY UPDATE with round_number +// and coin_pair left out of the update. A fixture inserted with AUTO_INCREMENT takes +// MAX(id)+1, which is exactly the id the hub's next finalized round arrives with. That +// row then lands on the fixture's primary key and rewrites its price, block_timestamp +// and reference_block in place while the fixture keeps its sentinel round and its +// pair: the round still wins every getLatestPrice read, now carrying some other +// pair's live price. That was MT3 run 35925455269 (a DOGE ISSUE quoted at 17.5 DOGE) +// and the LTC DEPLOY that owed 7.8 LTC, each seconds after a hub round finalized, and +// it lasted until the next re-seed cleared the pair. +// +// A hub writes about 37 rows per ten-minute round, so 1e12 is out of reach for the +// life of any venue. price_snapshots is a FULL_REPAGE mirror table, whose cursor never +// reads the local MAX(id), so ids above the hub's ceiling do not move the mirror's +// drain or its rebuilt-hub fence. +const FIXTURE_ID_FLOOR = 1000000000000 + +// Insert one finalized fixture row at the next id at or above FIXTURE_ID_FLOOR. +// Arguments: [FIXTURE_ID_FLOOR, round_number, coin_pair, price, reference_block, +// block_timestamp]. There is no upsert: a row already at this (round_number, coin_pair) +// may sit on a hub-reachable id from before this floor existed, so callers delete it first. +const FIXTURE_INSERT_SQL = `INSERT INTO price_snapshots + (id, round_number, coin_pair, price, reference_block, reference_chain, + block_timestamp, validator_count, consensus_round, consensus_proof, status) + SELECT GREATEST(COALESCE(MAX(id), 0) + 1, ?), ?, ?, ?, ?, 'BTC', ?, 1, 1, '[]', 'finalized' + FROM price_snapshots` + +const FIXTURE_DELETE_SQL = 'DELETE FROM price_snapshots WHERE round_number = ? AND coin_pair = ?' + module.exports = { seedsThroughMirror, + FIXTURE_ID_FLOOR, + FIXTURE_INSERT_SQL, + FIXTURE_DELETE_SQL, + // Exposed for tests: which database this helper writes to and which one // settlement reads. They must be equal in every topology (see resolveParams). seedTarget: resolveParams, @@ -260,16 +296,11 @@ module.exports = { let params = resolveParams() let conn = await mariadb.createConnection(params) try { - let query = `INSERT INTO price_snapshots - (round_number, coin_pair, price, reference_block, reference_chain, - block_timestamp, validator_count, consensus_round, consensus_proof, status) - VALUES (?, ?, ?, ?, 'BTC', ?, 1, 1, '[]', 'finalized') - ON DUPLICATE KEY UPDATE - price = VALUES(price), - reference_block = VALUES(reference_block), - block_timestamp = VALUES(block_timestamp), - status = 'finalized'` - await conn.query(query, [roundNumber, coinPair, price, referenceBlock || 0, blockTimestamp]) + // Delete, then insert above the hub's id space (see FIXTURE_ID_FLOOR). This + // replaces an upsert, so a re-seed of the same round still replaces its row. + await conn.query(FIXTURE_DELETE_SQL, [roundNumber, coinPair]) + await conn.query(FIXTURE_INSERT_SQL, + [FIXTURE_ID_FLOOR, roundNumber, coinPair, price, referenceBlock || 0, blockTimestamp]) } finally { await conn.end().catch(() => {}) } diff --git a/test/helpers/requireRow.js b/test/helpers/requireRow.js index 9c19cc0a..a80343bc 100644 --- a/test/helpers/requireRow.js +++ b/test/helpers/requireRow.js @@ -22,9 +22,82 @@ // The thrown message points at the GAVE UP line the poll already printed, // because that line is what separates "the row is absent" from "the row // landed with another status". -module.exports = function requireRow(row, what){ +function requireRow(row, what){ if (row) return row throw new Error(what + ' never landed; the GAVE UP line above, from the ' + 'matching check* poll, says whether the row is absent or landed with ' + 'another status - read the indexer verdict for this tx') } + +// Keep the two-argument contract above synchronous and unchanged. Callers that +// opt into a give-up probe use this companion: a diagnostic failure is folded +// into the original failure instead of replacing it. +requireRow.withProbe = async function withProbe(row, what, probe, probeContext){ + if (row) return row + + let verdict + try { + verdict = await probe() + } catch (err) { + verdict = 'UNREACHABLE: ' + probeContext + '; query failed: ' + safeErrorText(err) + } + + throw new Error(what + ' never landed; indexer verdict: ' + verdict) +} + +requireRow.bridgeCreditEvidence = function bridgeCreditEvidence( + {lockTxHash, destCoin, destAddress, tick, amount} +){ + return 'lock tx ' + lockTxHash + ', destination ' + destCoin + ', address ' + + destAddress + ', tick ' + tick + ', expected amount ' + amount +} + +// Read once after waitForCredit gives up, without its amount filter. That makes +// an existing row with the wrong amount visible instead of looking absent. +// Credit rows are valid ledger effects by definition; older database accessors +// do not project a status column, so an omitted status is reported as valid. +requireRow.bridgeCreditAttribution = async function bridgeCreditAttribution(database, expected){ + const evidence = requireRow.bridgeCreditEvidence(expected) + const row = await database.checkCredit({ + address: expected.destAddress, + tick: expected.tick + }) + + if (!row) return 'ABSENT: ' + evidence + '; destination credit row not found' + + const foundStatus = row.status == null ? 'valid' : String(row.status) + const foundAmount = row.amount == null ? 'unknown' : String(row.amount) + if (foundStatus !== 'valid' || !sameDecimalAmount(foundAmount, expected.amount)) { + return 'WRONG STATUS: ' + evidence + '; found status ' + foundStatus + + ', amount ' + foundAmount + '; expected status valid, amount ' + expected.amount + } + + return 'WRONG STATUS: ' + evidence + '; the row appeared only after the wait gave up; ' + + 'found status valid, amount ' + foundAmount + '; expected status valid, amount ' + + expected.amount +} + +function sameDecimalAmount(left, right){ + return canonicalDecimal(left) === canonicalDecimal(right) +} + +function canonicalDecimal(value){ + const text = String(value).trim() + const match = /^([+-]?)(\d+)(?:\.(\d*))?$/.exec(text) + if (!match) return text + const whole = match[2].replace(/^0+(?=\d)/, '') + const fraction = (match[3] || '').replace(/0+$/, '') + const zero = whole === '0' && fraction === '' + return (match[1] === '-' && !zero ? '-' : '') + whole + (fraction ? '.' + fraction : '') +} + +// Error messages are useful evidence, but connection errors can include a DSN +// or key/value configuration. Preserve the cause while removing secret values. +function safeErrorText(err){ + return String(err && err.message ? err.message : err) + .replace(/([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)[^@\s/]+@/gi, '$1[REDACTED]@') + .replace(/(\b[\w.-]*(?:pass(?:word)?|pwd|secret|token)[\w.-]*\b\s*[:=]\s*)([^\s,;]+)/gi, + '$1[REDACTED]') +} + +module.exports = requireRow diff --git a/test/initialCheck.test.js b/test/initialCheck.test.js index 08b3f455..f47aa66d 100644 --- a/test/initialCheck.test.js +++ b/test/initialCheck.test.js @@ -382,14 +382,13 @@ exports.mochaHooks = { 0 // MINT_SUPPLY: faucet, no pre-minted supply ) } else { - // gasHelper.ensureGasBalance mints the GAS tick on BTC and locks it - // across with an XBRIDGE v0 (xchain-bridge.md section 4); the tiny - // throwaway amount only needs to be enough to trip the lazy, - // idempotent token-row creation on THIS chain (section 9) - its - // parameters are byte-identical to injectGasToken regardless of the - // amount bridged. - let gasAddressInfo = await cryptoHelper.getNewFundedAddress("GAS.TOKEN", COIN, NETWORK, null, "legacy", 0, 1, false) - await gasHelper.ensureGasBalance(gasAddressInfo, 1) + // Fill this run's gas reservoir: gasHelper mints the GAS tick on BTC + // and locks it across with an XBRIDGE v0 (xchain-bridge.md section 4), + // and that first in-leg trips the lazy, idempotent token-row creation + // on THIS chain (section 9), whose parameters are byte-identical to + // injectGasToken regardless of the amount bridged. Every later + // ensureGasBalance is then a local SEND from the reservoir. + await gasHelper.fillGasReservoir() } console.log("GAS token ("+GAS_TICK+") created successfully") }) diff --git a/test/integration/bridge_rail_token.test.js b/test/integration/bridge_rail_token.test.js index 58562810..da6371ec 100644 --- a/test/integration/bridge_rail_token.test.js +++ b/test/integration/bridge_rail_token.test.js @@ -14,7 +14,8 @@ * * THE TOKEN BRIDGE ACCEPTANCE DRIVE: token AT1 to AT8, over the base drive's venue shape (a harness federation of the seated roster * keys, a venue BTC clone, a venue DOGE indexer REPLAYED from genesis under this tree's - * bridge code). AT9 is the gate run plus the activation parity test and is not a drive. + * bridge code). AT9 is the gate run plus the activation parity test and is not a drive: + * this file runs both as child processes after the drive (see the AT9 section at its foot). * * ── ONE SUITE IN TWO PLACES ──────────────────────────────────────────────────────── * This root holds the venue bring-up, the T0 precondition and the arming case; the legs @@ -60,6 +61,16 @@ * flag state implies: refused with the milestone-1 strings below it, applied above it, * with the format 6 controller bind refused either way. Both readings are journaled. * + * ── AT9 IS RUN, NOT QUOTED ───────────────────────────────────────────────────────── + * The last suite in the run is a plain `describe`, not a drive part, so it runs after the + * venue is torn down and needs no federation. It audits the drive's own cases (every + * drivable case passed, in the order above), then spawns, on this process's Node 22 with + * an allowlisted environment: this repository's ordinary CI gate (`npm run ci`, which + * ends in the live tier) and xchain-indexer's activation constants parity test out of + * the pinned root with XCHAIN_REQUIRE_SIBLINGS=1. Each is judged on its exit status and + * its own counts. The live tier's 95 (the host cannot run it) is a RED verdict here: a + * gate that did not run proves nothing. The last case prints the dated record. + * * ── EVERY DOGE-SIDE READ IS ON THE VENUE LEDGER, ASSERT BY IDENTITY ───────────────── * As the base drive: the standing DOGE indexer parsed a different history, and a count * over a set this small passes against broken code half the time. @@ -71,6 +82,9 @@ 'use strict'; +const path = require('path'); +const { spawnSync } = require('child_process'); +const { journalCase } = require('../helpers/bridgeRailVenue'); const { assert, GAS_TICK, @@ -80,6 +94,7 @@ const { chainHalves, needsFederation, bridgeRailSuite, + TOKEN_DRIVE, } = require('./bridge_rail_token.test/support'); const T0 = 'token T0: the destination ledger before any token leg'; @@ -128,3 +143,150 @@ bridgeRailSuite(T0, function () { state.evidence.xchainBaseline = state.baseline; }); }); + +// ── AT9: THE GATES, RUN ─────────────────────────────────────────────────────────── +const E2E_ROOT = path.resolve(__dirname, '..', '..'); +// The indexer the venue spawned is the one whose constants are judged, so the parity test +// runs out of the same pinned root; a hand run falls back to the sibling layout. +const PLATFORM_ROOT = process.env.BRIDGE_RAIL_REPO_ROOT || path.resolve(E2E_ROOT, '..'); +const INDEXER_ROOT = path.join(PLATFORM_ROOT, 'xchain-indexer'); +const PARITY_FILES = [ + 'test/unit/activations/activation_constants_parity.test.js', + 'test/unit/activations/activation_constants_parity.test/height_ordering.test.js', +]; +const PARITY_ORDERING_TITLE = 'holds TOKEN_BRIDGE_ACTIVATION >= XCHAIN_BRIDGE_ACTIVATION for every chain key'; +const VENUE_EXIT = 95; +const GATE_TIMEOUT_MS = 4 * 60 * 60 * 1000; +// The two cases a leg skips on purpose because regtest arms their flag at height 0, so no +// block below it exists to drive; each names the indexer unit coverage that carries it. +const NOT_DRIVABLE_ON_REGTEST = new Set([ + 'token AT4: an ISSUE|7 below TOKEN_BRIDGE_ACTIVATION is invalid: VERSION (unknown)', + 'token AT6 (R8): a ^id edit of a pre-flag three-character row applies', +]); +// The part order the header documents, one label per part, consecutive repeats collapsed. +const DRIVE_ORDER = ['T0', 'AT8', 'AT1', 'AT2', 'AT3', 'AT4', 'AT5', 'AT6', 'AT7', 'AT8']; +// The child sees the host basics and nothing the drive carries: no federation secret, no +// NODE_PATH, no pinned database, exactly as the workflow gives the gate a clean runner. +const GATE_ENV_KEYS = ['HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LC_ALL', 'TMPDIR', 'TERM', + 'DOCKER_HOST', 'XDG_RUNTIME_DIR']; + +const at9 = { record: null, drive: null, ordinaryCi: null, parity: null }; + +function gateEnv(extra) { + const env = {}; + for (const key of GATE_ENV_KEYS) if (process.env[key] !== undefined) env[key] = process.env[key]; + // This process's own node first, so `npm` and every `node` it forks are the same Node 22. + env.PATH = path.dirname(process.execPath) + path.delimiter + (process.env.PATH || ''); + return Object.assign(env, extra || {}); +} + +function run(cmd, args, cwd, env) { + const res = spawnSync(cmd, args, { cwd, env, encoding: 'utf8', maxBuffer: 512 * 1024 * 1024, + timeout: GATE_TIMEOUT_MS }); + const out = String(res.stdout || '') + String(res.stderr || ''); + return { status: res.status, signal: res.signal, error: res.error ? String(res.error.message) : null, + out, tail: out.split('\n').slice(-60).join('\n') }; +} + +function headOf(dir) { + const res = run('git', ['-C', dir, 'rev-parse', 'HEAD'], dir, gateEnv()); + return res.status === 0 ? res.out.trim() : null; +} + +// Every mocha summary line in a gate's output, so the record carries what actually ran. +function tallies(out) { + const plain = out.replace(/\u001b\[[0-9;]*m/g, ''); + const count = (re) => [...plain.matchAll(re)].reduce((n, m) => n + Number(m[1]), 0); + return { passing: count(/^\s*(\d+) passing/gm), failing: count(/^\s*(\d+) failing/gm), + pending: count(/^\s*(\d+) pending/gm) }; +} + +function driveTests(root) { + const outer = root.suites.find((s) => s.title === TOKEN_DRIVE.outerTitle); + assert.ok(outer, 'the token drive registered no outer suite: the legs were not in this run'); + const tests = []; + (function walk(suite) { + for (const t of suite.tests) tests.push(t); + for (const s of suite.suites) walk(s); + })(outer); + return { outer, tests }; +} + +describe('token AT9: the gates, and the dated acceptance record', function () { + before(function () { + const major = Number(process.versions.node.split('.')[0]); + assert.strictEqual(major, 22, 'AT9 is judged on Node 22 and this process is ' + process.version); + const child = run('node', ['-p', 'process.versions.node'], E2E_ROOT, gateEnv()); + assert.ok(child.status === 0 && /^22\./.test(child.out.trim()), + 'the gate child does not resolve Node 22 first on PATH: ' + child.tail); + at9.record = { date: new Date().toISOString().slice(0, 10), node: process.version, + platformRoot: PLATFORM_ROOT, e2eHead: headOf(E2E_ROOT), indexerHead: headOf(INDEXER_ROOT) }; + }); + + it('the drive ran AT1 to AT8 in this file\'s order and every drivable case passed', function () { + const { outer, tests } = driveTests(this.test.parent.parent); + assert.strictEqual(state.blocked, null, 'the drive never ran its federated cases: ' + state.blocked); + const labels = []; + for (const suite of outer.suites) { + const m = /token (T0|AT\d)/.exec(suite.title); + assert.ok(m, 'a drive part carries no token AT label: ' + suite.title); + if (labels[labels.length - 1] !== m[1]) labels.push(m[1]); + } + assert.deepStrictEqual(labels, DRIVE_ORDER, 'the drive parts did not run in the documented order'); + const verdicts = tests.map((t) => ({ title: t.title, state: t.state || 'unfinished' })); + const wrong = verdicts.filter((v) => v.state !== (NOT_DRIVABLE_ON_REGTEST.has(v.title) ? 'pending' : 'passed')); + assert.deepStrictEqual(wrong, [], 'drive cases that did not end as required: ' + JSON.stringify(wrong)); + for (const title of NOT_DRIVABLE_ON_REGTEST) + assert.ok(verdicts.some((v) => v.title === title), 'the declared not-drivable case is gone: ' + title); + at9.drive = { order: labels, passed: verdicts.filter((v) => v.state === 'passed').length, + notDrivableOnRegtest: [...NOT_DRIVABLE_ON_REGTEST], cases: verdicts }; + }); + + it('token AT9: this repository\'s ordinary CI gate exits 0 on Node 22, live tier included', function () { + this.timeout(0); + const res = run('npm', ['run', 'ci'], E2E_ROOT, gateEnv()); + const counts = tallies(res.out); + at9.ordinaryCi = { command: 'npm run ci', cwd: E2E_ROOT, exit: res.status, signal: res.signal, counts }; + assert.notStrictEqual(res.status, VENUE_EXIT, 'the live tier could not run on this host (exit ' + + VENUE_EXIT + ', the venue code). That is a gate that did not run, not a green one:\n' + res.tail); + assert.strictEqual(res.status, 0, 'the ordinary CI gate is red (exit ' + res.status + + (res.signal ? ', signal ' + res.signal : '') + (res.error ? ', ' + res.error : '') + '):\n' + res.tail); + assert.ok(counts.passing > 0, 'the ordinary CI gate exited 0 having run no mocha case:\n' + res.tail); + assert.strictEqual(counts.failing, 0, 'the ordinary CI gate reported failing cases:\n' + res.tail); + }); + + it('token AT9: the activation constants parity test passes with the TOKEN_BRIDGE >= XCHAIN_BRIDGE ordering', function () { + this.timeout(0); + const mocha = path.join(INDEXER_ROOT, 'node_modules', 'mocha', 'bin', 'mocha.js'); + const report = path.join(require('os').tmpdir(), 'at9-parity-' + process.pid + '-' + Date.now() + '.json'); + const res = run(process.execPath, [mocha, '--no-config', '--no-package', '--require', './test/helpers/setup.js', + '--timeout', '30000', '--exit', '--reporter', 'json', '--reporter-option', 'output=' + report, + ...PARITY_FILES], INDEXER_ROOT, gateEnv({ XCHAIN_REQUIRE_SIBLINGS: '1' })); + let json = null; + try { json = JSON.parse(require('fs').readFileSync(report, 'utf8')); } catch (e) { json = null; } + try { require('fs').unlinkSync(report); } catch (e) { /* never written */ } + assert.ok(json && json.stats, 'the parity run wrote no mocha report (exit ' + res.status + '):\n' + res.tail); + const ordering = json.passes.filter((t) => t.title === PARITY_ORDERING_TITLE).length; + at9.parity = { files: PARITY_FILES, cwd: INDEXER_ROOT, exit: res.status, + counts: { passing: json.stats.passes, failing: json.stats.failures, pending: json.stats.pending }, + orderingCasesPassed: ordering }; + assert.strictEqual(res.status, 0, 'the parity test is red (exit ' + res.status + '):\n' + res.tail); + assert.strictEqual(json.stats.failures, 0, 'parity failures: ' + JSON.stringify(json.failures.map((t) => t.fullTitle))); + assert.strictEqual(json.stats.pending, 0, 'parity cases skipped, so they compared nothing: ' + + JSON.stringify(json.pending.map((t) => t.fullTitle))); + assert.ok(json.stats.passes > 0, 'the parity run passed no case'); + assert.ok(ordering >= 1, 'the parity run never passed "' + PARITY_ORDERING_TITLE + '"'); + }); + + it('prints the dated acceptance record', function () { + assert.ok(at9.record, 'AT9 took no record header'); + const record = Object.assign({ rail: 'BTC/DOGE regtest', drive: at9.drive, + ordinaryCi: at9.ordinaryCi, activationConstantsParity: at9.parity }, at9.record); + console.log('\n=== token bridge acceptance record ' + record.date + ' ===\n' + + JSON.stringify(record, null, 2) + '\n'); + journalCase({ suite: TOKEN_DRIVE.journalSuite, title: '=== acceptance record ===', state: 'evidence', + evidence: record }); + for (const part of ['drive', 'ordinaryCi', 'activationConstantsParity']) + assert.ok(record[part], 'the record has no ' + part + ' reading: its case failed before measuring'); + }); +}); diff --git a/test/integration/multiHubActivationBoundary.integration.test.js b/test/integration/multiHubActivationBoundary.integration.test.js index 71c2d95f..d4f6c46f 100644 --- a/test/integration/multiHubActivationBoundary.integration.test.js +++ b/test/integration/multiHubActivationBoundary.integration.test.js @@ -59,7 +59,7 @@ dotenv.config(); const assert = require('assert'); const { MultiValidatorHub, loadHubModule } = require('../helpers/multiValidatorHubHelper'); const { startDisposableHubDb } = require('../helpers/disposableHubDb'); -const { waitForMesh, waitForConfigEverywhere } = require('../helpers/consensusWait'); +const { waitForMesh, waitForConfigEverywhere, assertNeverApplied } = require('../helpers/consensusWait'); // Load swq through the SAME resolver the hub harness uses, so this module // instance is the one consensus/pbft.js reads and a runtime override reaches it. const swq = loadHubModule('src/consensus/stake_weighted_quorum.js'); @@ -81,8 +81,6 @@ const APPLY_WAIT_MS = 60_000; // COMMIT propagation + follower applyConfig // fixed observation window rather than a deadline. const STALL_WAIT_MS = 6000; // long enough to confirm a round does NOT finalize -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - function findLeader(mvh) { return mvh.hubs.find((h) => { const l = h.consensus.getLeader(h.consensus.seq + 1); @@ -236,8 +234,11 @@ describe('MultiValidatorHub: STAKE_WEIGHTED_QUORUM activation boundary (WI-1 Sui // Stake-minority → never reaches weighted quorum → the propose promise // rejects on timeout; fire-and-forget, assert no-apply on every hub. leader.addParametersFromJson(config).catch(() => {}); - await sleep(STALL_WAIT_MS); + await assertNeverApplied(mvh.hubs, + { coin: COIN, network: NET, module: MODULE, key: 'GAS_PRICE', value: VALUE }, + { windowMs: STALL_WAIT_MS }); + // Re-read without the watcher's swallowed read errors, so a dead hub DB fails loud. for (let i = 0; i < mvh.hubs.length; i++) { const cfg = await mvh.hubs[i].db.getConfig(COIN, NET, MODULE); assert.notStrictEqual(cfg && cfg.GAS_PRICE, VALUE, diff --git a/test/integration/multiHubByzantine.integration.test.js b/test/integration/multiHubByzantine.integration.test.js index d81df6b5..756d120e 100644 --- a/test/integration/multiHubByzantine.integration.test.js +++ b/test/integration/multiHubByzantine.integration.test.js @@ -40,7 +40,7 @@ const { startDisposableHubDb } = require('../helpers/disposableHubDb'); const { seedStakeSnapshot } = require('../helpers/seededStakeSnapshot'); const { forceCountModeQuorum } = require('../helpers/forceCountModeQuorum'); const { silenceValidator, forgedPrePrepare } = require('../helpers/byzantineFaults'); -const { waitForMesh, waitForConfigEverywhere } = require('../helpers/consensusWait'); +const { waitForMesh, waitForConfigEverywhere, assertNeverApplied } = require('../helpers/consensusWait'); const COUNT = 4; // quorum 3 → tolerates exactly 1 byzantine/silent fault // Deadlines, not settles: the mesh and the honest hubs' applied rows are both @@ -49,8 +49,6 @@ const COUNT = 4; // quorum 3 → tolerates exactly 1 byzantine/sil const PEER_WAIT_MS = 60_000; const APPLY_WAIT_MS = 60_000; -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - // The leader for the next sequence (all hubs agree on the same sorted set + seq). function findLeader(mvh) { return mvh.hubs.find((h) => { @@ -136,7 +134,9 @@ describe('MultiValidatorHub: byzantine fault tolerance (L5)', function () { assert.ok(!target.consensus.pendingProposals.has(seq), 'forged PRE_PREPARE created a pending proposal (digest check failed)'); - await sleep(500); + await assertNeverApplied([target], + { coin: COIN, network: NET, module: MODULE, key: 'GAS_PRICE', value: forgedValue }, + { windowMs: 500 }); const after = await target.db.getConfig(COIN, NET, MODULE); assert.notStrictEqual(after.GAS_PRICE, forgedValue, 'forged config was applied (safety violated)'); diff --git a/test/integration/multiHubByzantineF2.integration.test.js b/test/integration/multiHubByzantineF2.integration.test.js index c08d043f..ecd6be0f 100644 --- a/test/integration/multiHubByzantineF2.integration.test.js +++ b/test/integration/multiHubByzantineF2.integration.test.js @@ -50,9 +50,7 @@ const { startDisposableHubDb } = require('../helpers/disposableHubDb'); const { seedStakeSnapshot } = require('../helpers/seededStakeSnapshot'); const { forceCountModeQuorum } = require('../helpers/forceCountModeQuorum'); const { silenceValidator, forgedPrePrepare } = require('../helpers/byzantineFaults'); -const { waitForMesh, waitForConfigEverywhere } = require('../helpers/consensusWait'); - -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const { waitForMesh, waitForConfigEverywhere, assertNeverApplied } = require('../helpers/consensusWait'); // The leader for the next sequence (all hubs agree on the same sorted set + seq). function findLeader(mvh) { @@ -193,13 +191,13 @@ function byzantineScaleSuite({ count, quorum, faults, basePort, peerWaitMs }) { const restores = victims.map((v) => silenceValidator(v)); try { // Quorum is unreachable, so addParametersFromJson never resolves on a - // COMMIT quorum. Bound the wait and assert the change applied NOWHERE. - await Promise.race([ - leader.addParametersFromJson(config).catch(() => {}), - sleep(STALL_WAIT_MS) - ]); - await sleep(1000); + // COMMIT quorum. Fire and forget, then watch that the change applies NOWHERE. + leader.addParametersFromJson(config).catch(() => {}); + await assertNeverApplied(mvh.hubs, + { coin: COIN, network: NET, module: MODULE, key: 'GAS_PRICE', value: VALUE }, + { windowMs: STALL_WAIT_MS + 1000 }); + // Re-read without the watcher's swallowed read errors, so a dead hub DB fails loud. for (let i = 0; i < mvh.hubs.length; i++) { const cfg = await mvh.hubs[i].db.getConfig(COIN, NET, MODULE); assert.notStrictEqual(cfg.GAS_PRICE, VALUE, @@ -227,7 +225,9 @@ function byzantineScaleSuite({ count, quorum, faults, basePort, peerWaitMs }) { assert.ok(!target.consensus.pendingProposals.has(seq), 'forged PRE_PREPARE created a pending proposal (digest check failed)'); - await sleep(500); + await assertNeverApplied([target], + { coin: COIN, network: NET, module: MODULE, key: 'GAS_PRICE', value: forgedValue }, + { windowMs: 500 }); const after = await target.db.getConfig(COIN, NET, MODULE); assert.notStrictEqual(after.GAS_PRICE, forgedValue, 'forged config was applied (safety violated)'); }); diff --git a/test/integration/multiHubFeaturesN10.integration.test.js b/test/integration/multiHubFeaturesN10.integration.test.js index faaa8712..2de507cc 100644 --- a/test/integration/multiHubFeaturesN10.integration.test.js +++ b/test/integration/multiHubFeaturesN10.integration.test.js @@ -243,3 +243,7 @@ describe('MultiValidatorHub: per-feature weighted quorum at N=10 (C.2)', functio }); }); + +// Run the split parts in this suite's lane; the stubbed lane ignores their directory. +require('./multiHubFeaturesN10.integration.test/02_cross_chain_dex_match.test'); +require('./multiHubFeaturesN10.integration.test/03_xcall_dispatch_relay.test'); diff --git a/test/integration/multiHubOracleByzantine.integration.test.js b/test/integration/multiHubOracleByzantine.integration.test.js index 60746741..d56a33c6 100644 --- a/test/integration/multiHubOracleByzantine.integration.test.js +++ b/test/integration/multiHubOracleByzantine.integration.test.js @@ -186,3 +186,6 @@ describe('MultiValidatorHub: oracle-PBFT byzantine fault tolerance (C.2)', funct }); }); + +// Run the split parts in this suite's lane; the stubbed lane ignores their directory. +require('./multiHubOracleByzantine.integration.test/02_safety_two_validators_down.test'); diff --git a/test/integration/multiHubOracleWeighted.integration.test.js b/test/integration/multiHubOracleWeighted.integration.test.js index c66ac6d9..3b643fda 100644 --- a/test/integration/multiHubOracleWeighted.integration.test.js +++ b/test/integration/multiHubOracleWeighted.integration.test.js @@ -164,3 +164,6 @@ describe('MultiValidatorHub: STAKE_WEIGHTED_QUORUM price PBFT round (WI-1 Suite }); }); + +// Run the split parts in this suite's lane; the stubbed lane ignores their directory. +require('./multiHubOracleWeighted.integration.test/02_healthy_weighted_federation.test'); diff --git a/test/integration/multiHubStateAnchorN10.integration.test.js b/test/integration/multiHubStateAnchorN10.integration.test.js index f04a7a71..ad70dc93 100644 --- a/test/integration/multiHubStateAnchorN10.integration.test.js +++ b/test/integration/multiHubStateAnchorN10.integration.test.js @@ -158,3 +158,6 @@ describe('MultiValidatorHub: state-checkpoint signing at N=10 (C.2 matrix cell)' }); }); }); + +// Run the split parts in this suite's lane; the stubbed lane ignores their directory. +require('./multiHubStateAnchorN10.integration.test/02_six_of_ten_boundary.test'); diff --git a/test/integration/multiHubStateAnchorWeighted.integration.test.js b/test/integration/multiHubStateAnchorWeighted.integration.test.js index 2b9a4ba8..5f4b2c6d 100644 --- a/test/integration/multiHubStateAnchorWeighted.integration.test.js +++ b/test/integration/multiHubStateAnchorWeighted.integration.test.js @@ -158,3 +158,6 @@ describe('MultiValidatorHub: STAKE_WEIGHTED_QUORUM oracle_publish checkpoint (WI }); }); + +// Run the split parts in this suite's lane; the stubbed lane ignores their directory. +require('./multiHubStateAnchorWeighted.integration.test/02_healthy_weighted_federation.test'); diff --git a/test/integration/multiHubXcallWeighted.integration.test.js b/test/integration/multiHubXcallWeighted.integration.test.js index 44747b61..61ee243a 100644 --- a/test/integration/multiHubXcallWeighted.integration.test.js +++ b/test/integration/multiHubXcallWeighted.integration.test.js @@ -171,3 +171,6 @@ describe('MultiValidatorHub: STAKE_WEIGHTED_QUORUM XCALL dispatch relay (C.2)', }); }); + +// Run the split parts in this suite's lane; the stubbed lane ignores their directory. +require('./multiHubXcallWeighted.integration.test/02_healthy_weighted_federation.test'); diff --git a/test/integration/oracleBatchQuorum.integration.test.js b/test/integration/oracleBatchQuorum.integration.test.js index a8956f67..43bdc5cb 100644 --- a/test/integration/oracleBatchQuorum.integration.test.js +++ b/test/integration/oracleBatchQuorum.integration.test.js @@ -381,3 +381,6 @@ registerOracleTest('the leader ran a signing round, it EXPIRED SHORT, and batchS 'a silenced peer co-signed anyway, so the federation was never below signing quorum and the ' + 'timeout this drill observed was caused by something else'); }); + +// Run the split parts after this entry provides their shared setup; the stubbed lane ignores their directory. +require('./oracleBatchQuorum.integration.test/01_with_quorum_restored_the_same_window_published.test'); diff --git a/test/integration/parity/checkpointCommitmentParity.test.js b/test/integration/parity/checkpointCommitmentParity.test.js index 32917755..01ef344b 100644 --- a/test/integration/parity/checkpointCommitmentParity.test.js +++ b/test/integration/parity/checkpointCommitmentParity.test.js @@ -13,8 +13,10 @@ * Phase 2 makes the quorum-signed checkpoint canonical (and every ANCHOR v0 bundle * SECTION) additively commit the light-client roots `STATE_ROOT|STATE_ROOT_VERSION| * BLOCK_MERKLE_ROOT|BLOCK_MERKLE_VERSION`, gated on the BTC `snapshot_block` by the - * CHECKPOINT_COMMITMENT flag-day. The signed string is built INLINE in four places - * (hub engine, SDK verifier, indexer ANCHOR verifier, explorer verify endpoint) plus + * CHECKPOINT_COMMITMENT flag-day. The signed string is built INLINE in six places + * (hub engine, SDK verifier, sync follower, explorer verify endpoint, indexer ANCHOR + * v0 section verifier, indexer bridge-proof mirrored-row verifier); the first four + * gate the root suffix and the two indexer copies append it unconditionally. Plus * the activation map is one registry row (checkpoint_commitment_activation * .CHECKPOINT_COMMITMENT_ACTIVATION) carried by the byte-twin registry parts of FIVE * services (hub, indexer, sdk, explorer, and xchain-sync, which reads it at @@ -30,13 +32,16 @@ * agrees on the verdict. * 2. The post-flag-day checkpoint canonical (with the root suffix) is byte-identical * across the hub engine, the SDK verifier, and the indexer's v0 SECTION verifier. - * 3. The pre-flag-day canonical (no suffix) is likewise byte-identical, and the - * suffix is genuinely absent. + * 3. The rootless pre-flag-day canonical is likewise byte-identical on the shared + * ten-field base, and the suffix is genuinely absent. * 4. A null-root row (legacy / pre-Phase-1) stays on the rootless canonical even * post-flag-day (the presence-aware gate), so old signatures still verify. + * 5. All six builders agree on a post-flag-day root-bearing checkpoint, and on a + * root-bearing checkpoint BELOW the flag day the gated four omit the suffix while + * the two indexer copies append it: the one quadrant where they differ, pinned. * - * The explorer's inline copy is exercised against the SDK in the explorer's own unit - * suite (explorer_checkpoints.test.js); here we cover the three callable builders. + * The archive family (indexer ANCHOR v1, bin/recovery.js wrapperCanonical, the hub's + * archiveCanonical) is pinned in the indexer's recovery_wrapper_canonical_parity suite. * * Spec: SPV light-client spec s6; Phase 2 handover. ********************************************************************/ @@ -68,6 +73,9 @@ const syncCkpt = require(path.join(ROOT, 'xchain-sync', REGISTRY_ENTRY)); const StateCheckpointEngine = require(path.join(ROOT, 'xchain-hub/src/anchor/checkpoint_engine.js')); const sdkCheckpoint = require(path.join(ROOT, 'xchain-sdk/src/checkpoint.js')); const Anchor = require(path.join(ROOT, 'xchain-indexer/src/actions/anchor/index.js')); +const syncCheckpoint = require(path.join(ROOT, 'xchain-sync/src/checkpoint.js')); +const explorerProofs = require(path.join(ROOT, 'xchain-explorer/src/explorer/proofs.js')); +const checkpointSource = require(path.join(ROOT, 'xchain-indexer/src/consensus/bridge_proof_client/checkpoint_source.js')); // The indexer ANCHOR canonical is a plain method that reads only its `d` argument // (no `this`), so invoke it directly off the prototype. `d` is ONE v0 bundle section, @@ -93,8 +101,9 @@ function fixtures(net, snapshotBlock, withRoots) { }; const d = { // A v0 SECTION is root-bearing by construction: the publisher skips a null-root - // row rather than emitting a rootless section (D8), so the rootless shape has no - // FORMAT on the checkpoint leg at all and exercises _canonical's shared base. + // row rather than emitting a rootless section (D8). The rootless `d` is therefore + // not a real section shape, carries no FORMAT, and reaches only canonical's shared + // ten-field base, never the v0 branch. FORMAT: withRoots ? 0 : undefined, CHAIN: 'BTC', NETWORK: net, BLOCK_INDEX_CHECKPOINTED: 500, BLOCK_HASH: 'c0'.repeat(32), LEDGER_HASH: 'a1'.repeat(32), ACTIONS_HASH: 'b2'.repeat(32), CONTRACT_HASH: 'c3'.repeat(32), @@ -143,11 +152,12 @@ describe('SPV Phase 2: CHECKPOINT_COMMITMENT cross-service parity', function () 'post-flag-day canonical must commit the root suffix; got ' + hubC); }); - it('pre-flag-day: hub == SDK == indexer canonical, and the root suffix is absent', function () { - // mainnet flag-day is the far-future placeholder, so snapshot_block 1000 is inactive - // and the row carries null roots. No v0 section is ever cut here (a rootless row is - // skipped, D8), so the indexer side is canonical's shared rootless base, which the - // archive leg still signs and which the v0 branch extends. + it('pre-flag-day rootless row: hub == SDK == indexer shared base, and the root suffix is absent', function () { + // snapshot_block 1000 sits below the mainnet flag day and the row carries null + // roots. No v0 section is ever cut here (a rootless row is skipped, D8), so the + // indexer side is canonical's shared rootless base, which the archive leg still + // signs and which the v0 branch extends. The root-bearing pre-flag-day quadrant + // is pinned separately below. const { cp, d, STATE_ROOT, BLOCK_MERKLE } = fixtures('mainnet', 1000, false); const hubC = StateCheckpointEngine.canonicalCheckpoint(cp); const sdkC = sdkCheckpoint.canonicalCheckpoint(cp); @@ -220,3 +230,51 @@ describe('SPV Phase 2: CHECKPOINT_COMMITMENT cross-service parity', function () 'a null-root and a rooted checkpoint must not share a canonical'); }); }); + +// The four builders that gate the root suffix on CHECKPOINT_COMMITMENT, over the row shape. +function gatedCanonicals(cp) { + return { + hub: StateCheckpointEngine.canonicalCheckpoint(cp), + sdk: sdkCheckpoint.canonicalCheckpoint(cp), + sync: syncCheckpoint.canonicalCheckpoint(cp), + explorer: explorerProofs.canonicalCheckpointString(cp) + }; +} + +// The two indexer builders that append the root suffix unconditionally. +function unconditionalCanonicals(cp, d) { + return { + anchorSection: anchorSectionCanonical(d), + mirroredRow: checkpointSource.checkpointCanonical(cp) + }; +} + +describe('XCHECKPOINT canonical: all six checkpoint-family builders', function () { + + it('post-flag-day, roots present: all six emit one byte string', function () { + const { cp, d } = fixtures('regtest', 100, true); + const all = Object.assign(gatedCanonicals(cp), unconditionalCanonicals(cp, d)); + for (const [name, s] of Object.entries(all)) + assert.strictEqual(s, all.hub, name + ' drifted from the hub on a post-flag-day root-bearing checkpoint'); + }); + + it('pre-flag-day, roots present: the gated four omit the suffix and both indexer copies append it', function () { + // The one quadrant where the builders disagree, pinned as the contract. The hub signs + // the rootless form here, so an indexer rebuild carrying the suffix fails every + // signature: a v0 section fails closed, and a mirrored row never hands the escrow + // check roots no quorum signed. Gating either indexer copy turns that into adopting + // unsigned roots, which is what this case refuses. + const { cp, d, STATE_ROOT, BLOCK_MERKLE } = fixtures('mainnet', 1000, true); + assert.strictEqual(idxCkpt.activeAt(CKPT_KEY, 'mainnet', null, 1000, null), false, + 'premise: mainnet@1000 must sit below CHECKPOINT_COMMITMENT or this case pins nothing'); + const gated = gatedCanonicals(cp); + assert.ok(gated.hub.startsWith('XCHECKPOINT|'), 'premise: no EQUIV wrap at mainnet@1000'); + for (const [name, s] of Object.entries(gated)) { + assert.strictEqual(s, gated.hub, name + ' drifted from the hub below the flag day'); + assert.ok(!s.includes(STATE_ROOT) && !s.includes(BLOCK_MERKLE), name + ' carries a root below the flag day'); + } + const suffixed = gated.hub + '|' + STATE_ROOT + '|1|' + BLOCK_MERKLE + '|1'; + for (const [name, s] of Object.entries(unconditionalCanonicals(cp, d))) + assert.strictEqual(s, suffixed, name + ' must append the root suffix unconditionally below the flag day'); + }); +}); diff --git a/test/integration/parity/trainActivationParity.test.js b/test/integration/parity/trainActivationParity.test.js new file mode 100644 index 00000000..d86d5e81 --- /dev/null +++ b/test/integration/parity/trainActivationParity.test.js @@ -0,0 +1,186 @@ +/********************************************************************* + * + * Copyright (c) 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. + * + * Cross-service TRAIN_ACTIVATION parity, and the byte twin that carries it. + * + * TRAIN_ACTIVATION is the rolling-upgrade boundary: keyed by platform version, then + * network, to a BTC height at which the fleet switches rule sets. A node that does + * not implement the rule set the map requires at the height it has reached HALTS + * (train_gate.evaluateTrainActivation), so a single height disagreeing between two + * services splits the fleet at a boundary neither operator chose: one half grades the + * new rules, the other halts, and the halting half is the one whose copy is stale. + * + * The row lives in the SHARED registry block part 5, which is a registered byte twin + * (registered in the twin manifest, canonical xchain-indexer) copied into xchain-hub, + * xchain-sync, xchain-explorer, xchain-sdk and the xchain-documentation reference + * implementation. Until this file existed nothing in the parity tier read part 5 at + * all: reverting a TRAIN_ACTIVATION testnet height in one consumer left the tier at + * 45 passing, while the same experiment on part 2's CHECKPOINT_COMMITMENT row went + * red. That gap is what this file closes, in the shape the part 2 coverage already + * uses (value equality against the canonical SoT, key-set shape, verdict agreement) + * plus a whole-file byte grade so EVERY row in part 5 is guarded, not just this one. + * + * Only xchain-indexer and xchain-sync carry train_gate.js; hub, sdk and explorer carry + * the row and no resolver. The verdict case therefore drives BOTH resolvers over EVERY + * repo's copy of the map, which is the pairing that actually matters: the resolver is + * itself a twin, so what can disagree is the map each service feeds it. + ********************************************************************/ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '../../../..'); + +const protocolConstants = require(path.join(ROOT, 'xchain-documentation/protocol/constants.js')); + +const TRAIN_KEY = 'train_activation.TRAIN_ACTIVATION'; +const REGISTRY_ENTRY = 'src/consensus/gate_registry.js'; + +const hubTrain = require(path.join(ROOT, 'xchain-hub', REGISTRY_ENTRY)); +const idxTrain = require(path.join(ROOT, 'xchain-indexer', REGISTRY_ENTRY)); +const sdkTrain = require(path.join(ROOT, 'xchain-sdk', REGISTRY_ENTRY)); +const expTrain = require(path.join(ROOT, 'xchain-explorer', REGISTRY_ENTRY)); +const syncTrain = require(path.join(ROOT, 'xchain-sync', REGISTRY_ENTRY)); + +const REGISTRIES = [ + ['hub', hubTrain], + ['indexer', idxTrain], + ['sdk', sdkTrain], + ['explorer', expTrain], + ['sync', syncTrain] +]; + +// The two train gates on the fleet. Each is a twin of the other; each takes the map as +// an argument, so either can be driven over any service's copy of the row. +const RESOLVERS = [ + ['indexer', require(path.join(ROOT, 'xchain-indexer/src/consensus/gates/train_gate.js'))], + ['sync', require(path.join(ROOT, 'xchain-sync/src/consensus/gates/train_gate.js'))] +]; + +// The registry part that carries the row. The indexer is canonical under +// src/protocol_changes/; every consumer keeps the same bytes under its own +// gate_registry/ directory. Two candidate spellings are read for the canonical so the +// guard survives a layout move, and a checkout with neither FAILS naming both rather +// than skipping, because a skipped twin guard is how a copy drifts without a red run. +const PART = 'shared_rows_5.js'; +const CANONICAL_CANDIDATES = [ + 'xchain-indexer/src/protocol_changes/' + PART, + 'xchain-indexer/src/consensus/gate_registry/' + PART +]; +const COPY_PATHS = [ + ['hub', 'xchain-hub/src/consensus/gate_registry/' + PART], + ['sync', 'xchain-sync/src/consensus/gate_registry/' + PART], + ['explorer', 'xchain-explorer/src/consensus/gate_registry/' + PART], + ['sdk', 'xchain-sdk/src/consensus/gate_registry/' + PART], + ['documentation', 'xchain-documentation/protocol/reference-impl/consensus/gate_registry/' + PART] +]; + +// Probe heights derived FROM the canonical map rather than written here, so a row added +// at a later cut is probed at its own boundary with no edit to this file: every height +// in the map is probed one below, at, and one above, which is where an off-by-one +// divergence between two copies shows up as a different rule set. +function probeHeights(map) { + const out = new Set([0, 1, 999999999999]); + for (const perNetwork of Object.values(map)) { + for (const h of Object.values(perNetwork)) { + if (typeof h !== 'number') continue; + out.add(Math.max(0, h - 1)); + out.add(h); + out.add(h + 1); + } + } + return [...out].sort((a, b) => a - b); +} + +describe('TRAIN_ACTIVATION cross-service parity (SHARED registry part 5)', function () { + + it('the TRAIN_ACTIVATION row is value-equal across all five local registries and the canonical SoT', function () { + const canonical = protocolConstants.TRAIN_ACTIVATION; + assert.ok(canonical, 'documentation/protocol/constants.js must export TRAIN_ACTIVATION'); + for (const [name, mod] of REGISTRIES) { + assert.deepStrictEqual(mod.copy(TRAIN_KEY), canonical, + name + ' TRAIN_ACTIVATION drifted from the canonical protocol constant'); + } + }); + + it('every registry names the same rule-set versions, and the same networks within each version', function () { + // The value case above holds the heights; this pins the SHAPE. A rule set present + // in one copy and absent in another resolves to a DIFFERENT active rule set on the + // same chain even when every shared height reads equal, and a network slot added on + // one side only leaves that network resolving to nothing on the other. + const canonical = protocolConstants.TRAIN_ACTIVATION; + const versions = Object.keys(canonical).sort(); + for (const [name, mod] of REGISTRIES) { + const row = mod.copy(TRAIN_KEY); + assert.deepStrictEqual(Object.keys(row).sort(), versions, + name + ' TRAIN_ACTIVATION names a different rule-set version set from the canonical map'); + for (const version of versions) { + assert.deepStrictEqual(Object.keys(row[version]).sort(), Object.keys(canonical[version]).sort(), + name + ' TRAIN_ACTIVATION rule set ' + version + ' names a different network set from the canonical map'); + } + } + }); + + it('both train gates return the same halt verdict for every copy of the map', function () { + // This is the behaviour the row exists to drive. evaluateTrainActivation halts a + // node whose map does not CONTAIN the rule set the signed release manifest requires + // (implementedRuleSets is read off the map, so the map is the record of what the + // binary can apply), and otherwise clears it. A rule set present in one service's + // copy and missing from another therefore splits the fleet at that manifest: half + // clears and grades the new rules, half halts. Drive both twins of the gate over + // every service's copy at every rule set the canon names, at its own boundary and + // one block either side, and require one status everywhere. + const canonical = protocolConstants.TRAIN_ACTIVATION; + const networks = [...new Set(Object.values(canonical).flatMap((v) => Object.keys(v)))].sort(); + for (const version of Object.keys(canonical)) { + const required = { ruleSetVersion: version, heights: canonical[version] }; + for (const network of networks) { + const at = canonical[version][network]; + for (const height of [0, at - 1, at, at + 1, 999999999999]) { + const expected = RESOLVERS[0][1].evaluateTrainActivation( + { network, height, required, activation: canonical }).status; + for (const [resolverName, gate] of RESOLVERS) { + for (const [name, mod] of REGISTRIES) { + const got = gate.evaluateTrainActivation( + { network, height, required, activation: mod.copy(TRAIN_KEY) }).status; + assert.strictEqual(got, expected, + 'train-gate verdict disagreement for rule set ' + version + ' on ' + + network + '@' + height + ': the ' + resolverName + ' gate over the ' + + name + ' TRAIN_ACTIVATION map says ' + JSON.stringify(got) + + ', the canonical map says ' + JSON.stringify(expected)); + } + } + } + } + } + }); + + it('shared_rows_5.js is byte-identical across the indexer canonical and all five copies', function () { + // The cases above grade ONE row. This grades the whole part, which is what + // frozen-twins.json actually registers: every other row in part 5 + // (TOKEN_POLICY_INHERITANCE_ACTIVATION, XCHAIN_BRIDGE_ACTIVATION) is a + // consensus boundary too, and a value-level case per row would be added a cut + // late every time. The consumers replace nothing: the require line, the header + // and the SHARED-GATES block are the same bytes in all six checkouts. + const canonRel = CANONICAL_CANDIDATES.find((p) => fs.existsSync(path.join(ROOT, p))); + assert.ok(canonRel, 'the canonical ' + PART + ' resolved at neither ' + + CANONICAL_CANDIDATES.map((p) => path.join(ROOT, p)).join(' nor ')); + const canon = fs.readFileSync(path.join(ROOT, canonRel), 'utf8'); + for (const [name, rel] of COPY_PATHS) { + const abs = path.join(ROOT, rel); + assert.ok(fs.existsSync(abs), name + ' is registered as a ' + PART + ' twin but ' + abs + ' does not exist'); + assert.strictEqual(fs.readFileSync(abs, 'utf8'), canon, + rel + ' drifted from the canonical ' + canonRel); + } + }); +}); diff --git a/test/regression/protocol-size-limits.regression.js b/test/regression/protocol-size-limits.regression.js index 7043bc76..b2a25fe7 100644 --- a/test/regression/protocol-size-limits.regression.js +++ b/test/regression/protocol-size-limits.regression.js @@ -742,6 +742,21 @@ describe('Protocol size-limit drift guard', () => { assertVendored('CROSS_SETTLE_MAX_PER_BLOCK', ['xchain-indexer']) }) + // Pin the inherited-policy membership ceiling on both halves of the rule: the + // indexer refuses a format-7 opt-in over it, the hub declines to sign a snapshot + // over it from a bare literal of its own. A one-sided edit lets the hub sign a + // snapshot the indexer's opt-in would refuse, or strand one it would accept. + it('[regression:p0] XPOLICY_MAX_MEMBERS === canonical across hub bridge constants + indexer vendored copy', () => { + const hubBridge = require('../../../xchain-hub/src/cross_chain/bridge/constants.js') + // A dropped export on every side would compare undefined to undefined and pass. + assert.ok(Number.isFinite(protocol.XPOLICY_MAX_MEMBERS), + 'XPOLICY_MAX_MEMBERS is not a finite value on the canonical protocol constants module') + assert.strictEqual(hubBridge.XPOLICY_MAX_MEMBERS, protocol.XPOLICY_MAX_MEMBERS, + 'hub cross_chain/bridge/constants.js XPOLICY_MAX_MEMBERS drifted from the canonical protocol constant; ' + + 'the hub would sign policy snapshots the indexer sizes differently') + assertVendored('XPOLICY_MAX_MEMBERS', ['xchain-indexer']) + }) + // Bind the ROLLCALL eviction scalars to canonical (the hub signs, the indexer judges, // each from its own bare literal in src/consensus/gates/rollcall_gate.js). Neither repo compares // its copy to canonical: the hub pins 2 / 4 / 0 as literals of its own and the indexer diff --git a/test/transactionHelper/lib/01_create_and_send_transaction.js b/test/transactionHelper/lib/01_create_and_send_transaction.js index 538fb2e8..d80d8b59 100644 --- a/test/transactionHelper/lib/01_create_and_send_transaction.js +++ b/test/transactionHelper/lib/01_create_and_send_transaction.js @@ -67,8 +67,11 @@ module.exports = { // exactly what hung the LTC/DOGE suite. Dedup against the discovered // destination so callers that supply their own fee output (e.g. // nativeFeeLive/nativeFeeDispenser) aren't double-charged. + // The action string and its sender let the helper size the output from the + // indexer's quote for this action rather than a fixed amount. const nativeFeeHelper = require('../../helpers/nativeFeeHelper') - const feeOutput = await nativeFeeHelper.getNativeFeeOutput() + const feeOutput = await nativeFeeHelper.getNativeFeeOutput( + typeof data === 'string' ? data : null, addressInfo && addressInfo["address"]) if (feeOutput) { const alreadyHasFee = outputs.some(o => o && o.address === feeOutput.address) if (!alreadyHasFee) { diff --git a/test/unit/adaptive_wait.test.js b/test/unit/adaptive_wait.test.js index 4a34bb0b..e0199547 100644 --- a/test/unit/adaptive_wait.test.js +++ b/test/unit/adaptive_wait.test.js @@ -32,16 +32,23 @@ const Database = require(process.env.XC701_DB_SRC || '../../src/db.js') const TIMEMAX = 60 const TICK = 15 -function makeDb({ lag = null, writes = null, maxExtensions = 3, probeEvery } = {}) { +function makeDb({ lag = null, writes = null, lagReason, writesReason, + maxExtensions = 3, probeEvery } = {}) { const db = Object.create(Database.prototype) db.sleep = () => new Promise(r => setTimeout(r, TICK)) db._recordPerfPoll = () => {} db.lagCalls = 0 db._pipelineProgress = async () => { db.lagCalls++ + const lagValue = typeof lag === 'function' ? lag(db.lagCalls) : lag + const writeValue = typeof writes === 'function' ? writes(db.lagCalls) : writes return { - lag: typeof lag === 'function' ? lag(db.lagCalls) : lag, - writes: typeof writes === 'function' ? writes(db.lagCalls) : writes + lag: lagValue, + lagReason: lagValue === null ? (lagReason || 'probe failed: test probe unavailable') : null, + writes: writeValue, + writesReason: writeValue === null + ? (writesReason || 'probe failed: test probe unavailable') + : null } } db.WAIT_MAX_EXTENSIONS = maxExtensions @@ -297,10 +304,20 @@ describe('adaptive wait deadline', function () { return db } + it('names a missing database pool for both unavailable signals', async () => { + const db = Object.create(Database.prototype) + assert.deepStrictEqual(await db._pipelineProgress(), { + lag: null, lagReason: 'no database pool wired', + writes: null, writesReason: 'no database pool wired' + }) + }) + it('reports lag and the action-write mark from one round trip', async () => { const db = dbWithRows([{ tip: 4970, writes: 88123 }]) await withConnector({ getBlockCount: async () => 5000 }, async () => { - assert.deepStrictEqual(await db._pipelineProgress(), { lag: 30, writes: 88123 }) + assert.deepStrictEqual(await db._pipelineProgress(), { + lag: 30, lagReason: null, writes: 88123, writesReason: null + }) }) assert.strictEqual(db.queries.length, 1, 'sampling twice as often must not cost twice as many round trips') @@ -311,14 +328,20 @@ describe('adaptive wait deadline', function () { // need the chain: it is exactly the case lag cannot cover. const db = dbWithRows([{ tip: 4970, writes: 5 }]) await withConnector(undefined, async () => { - assert.deepStrictEqual(await db._pipelineProgress(), { lag: null, writes: 5 }) + assert.deepStrictEqual(await db._pipelineProgress(), { + lag: null, lagReason: 'no node connector wired', + writes: 5, writesReason: null + }) }) }) it('reports nulls, not NaN, on an empty database', async () => { const db = dbWithRows([{ tip: null, writes: null }]) await withConnector({ getBlockCount: async () => 5000 }, async () => { - assert.deepStrictEqual(await db._pipelineProgress(), { lag: null, writes: null }) + assert.deepStrictEqual(await db._pipelineProgress(), { + lag: null, lagReason: 'blocks table held no rows', + writes: null, writesReason: 'actions table held no rows' + }) }) }) @@ -327,7 +350,10 @@ describe('adaptive wait deadline', function () { db.WAIT_LAG_PROBE_MS = 500 db.pool = { getConnection: async () => { throw new Error('pool exhausted') } } await withConnector({ getBlockCount: async () => 5000 }, async () => { - assert.deepStrictEqual(await db._pipelineProgress(), { lag: null, writes: null }) + assert.deepStrictEqual(await db._pipelineProgress(), { + lag: null, lagReason: 'probe failed: pool exhausted', + writes: null, writesReason: 'probe failed: pool exhausted' + }) }) }) @@ -359,7 +385,10 @@ describe('adaptive wait deadline', function () { db.pool = { getConnection: () => new Promise(() => {}) } await withConnector({ getBlockCount: async () => 5000 }, async () => { const started = Date.now() - assert.deepStrictEqual(await db._pipelineProgress(), { lag: null, writes: null }) + assert.deepStrictEqual(await db._pipelineProgress(), { + lag: null, lagReason: 'probe timed out', + writes: null, writesReason: 'probe timed out' + }) assert(Date.now() - started < 1000, 'the probe must be time-capped') }) }) @@ -379,6 +408,14 @@ describe('adaptive wait deadline', function () { return Promise.resolve(fn()).finally(() => { console.log = orig }).then(() => lines) } + async function gaveUpFor(progress, configure){ + const db = makeDb() + db._pipelineProgress = async () => progress + if (configure) configure(db) + const lines = await captureLog(() => db._waitFor(checkThing, {}, TIMEMAX)) + return lines.find(l => l.includes('GAVE UP')) + } + it('reports zero lag distinctly, which is the case the current signal cannot see', async () => { const db = makeDb({ lag: 0 }) const lines = await captureLog(() => db._waitFor(checkThing, {}, TIMEMAX)) @@ -390,6 +427,14 @@ describe('adaptive wait deadline', function () { 'zero lag must be stated, not implied by silence: ' + gaveUp) }) + it('preserves polls, extensions and timeMax in their established order', async () => { + const gaveUp = await gaveUpFor({ + lag: 0, lagReason: null, writes: 0, writesReason: null + }) + assert(/GAVE UP after \d+ms \(\d+ polls, 0\/3 extensions, timeMax 60ms, /.test(gaveUp), + 'the stable diagnostic fields changed shape or order: ' + gaveUp) + }) + it('reports whether action rows were still landing when it gave up', async () => { // Separates "the stack was busy and we ran out of budget" from "the stack // was idle and the row is genuinely absent", which read identically before. @@ -414,7 +459,64 @@ describe('adaptive wait deadline', function () { const db = makeDb({ lag: null }) const lines = await captureLog(() => db._waitFor(checkThing, {}, TIMEMAX)) const gaveUp = lines.find(l => l.includes('GAVE UP')) - assert(/probe failed/.test(gaveUp), 'an unanswerable probe must say so: ' + gaveUp) + assert(/probe failed: test probe unavailable/.test(gaveUp), + 'an unanswerable probe must say so: ' + gaveUp) + }) + + it('names a missing database pool for each signal', async () => { + const gaveUp = await gaveUpFor({ + lag: null, lagReason: 'no database pool wired', + writes: null, writesReason: 'no database pool wired' + }) + assert(/last indexer lag unavailable \(no database pool wired\)/.test(gaveUp), gaveUp) + assert(/action writes unavailable \(no database pool wired\)/.test(gaveUp), gaveUp) + }) + + it('names a missing node connector without hiding a readable action mark', async () => { + const gaveUp = await gaveUpFor({ + lag: null, lagReason: 'no node connector wired', + writes: 0, writesReason: null + }) + assert(/last indexer lag unavailable \(no node connector wired\)/.test(gaveUp), gaveUp) + assert(/action writes idle at index 0/.test(gaveUp), gaveUp) + }) + + it('names each empty table independently', async () => { + const gaveUp = await gaveUpFor({ + lag: null, lagReason: 'blocks table held no rows', + writes: null, writesReason: 'actions table held no rows' + }) + assert(/last indexer lag unavailable \(blocks table held no rows\)/.test(gaveUp), gaveUp) + assert(/action writes unavailable \(actions table held no rows\)/.test(gaveUp), gaveUp) + }) + + it('names a timed-out probe for each signal', async () => { + const gaveUp = await gaveUpFor({ + lag: null, lagReason: 'probe timed out', + writes: null, writesReason: 'probe timed out' + }) + assert(/last indexer lag unavailable \(probe timed out\)/.test(gaveUp), gaveUp) + assert(/action writes unavailable \(probe timed out\)/.test(gaveUp), gaveUp) + }) + + it('reduces a thrown probe value to its message without logging the object', async () => { + const db = makeDb() + db._pipelineProgress = Database.prototype._pipelineProgress + db.pool = { getConnection: async () => { + throw { message: 'safe probe failure', toString: () => 'connection object with secret' } + } } + const saved = global.nodeConnector + global.nodeConnector = { getBlockCount: async () => 1 } + let lines + try { + lines = await captureLog(() => db._waitFor(checkThing, {}, TIMEMAX)) + } finally { + if (saved === undefined) delete global.nodeConnector; else global.nodeConnector = saved + } + const gaveUp = lines.find(l => l.includes('GAVE UP')) + assert(/probe failed: safe probe failure/.test(gaveUp), gaveUp) + assert(!lines.join('\n').includes('connection object with secret'), + 'the thrown object reached the log: ' + JSON.stringify(lines)) }) it('says when the wait was too short to ever qualify for an extension', async () => { @@ -422,10 +524,18 @@ describe('adaptive wait deadline', function () { db.WAIT_MIN_FOR_EXTENSION = 10000 // far above TIMEMAX const lines = await captureLog(() => db._waitFor(checkThing, {}, TIMEMAX)) const gaveUp = lines.find(l => l.includes('GAVE UP')) - assert(/not eligible for extension/.test(gaveUp), + assert(/probe was never run because the wait was not eligible for extension/.test(gaveUp), 'an ineligible wait must not be reported as a zero-lag one: ' + gaveUp) }) + it('says when an eligible wait exhausted its budget before any probe ran', async () => { + const db = makeDb({ maxExtensions: 0 }) + const lines = await captureLog(() => db._waitFor(checkThing, {}, TIMEMAX)) + const gaveUp = lines.find(l => l.includes('GAVE UP')) + assert(/probe was never run before give-up/.test(gaveUp), + 'an unrun eligible probe needs its own reason: ' + gaveUp) + }) + it('stays silent on the success path, so the log only grows when something went wrong', async () => { const db = makeDb({ lag: 0 }) const lines = await captureLog(() => diff --git a/test/unit/helpers/attestMirrorVenue.test.js b/test/unit/helpers/attestMirrorVenue.test.js index e9a8cf90..2ea40d39 100644 --- a/test/unit/helpers/attestMirrorVenue.test.js +++ b/test/unit/helpers/attestMirrorVenue.test.js @@ -178,6 +178,122 @@ describe('attestMirrorVenue: port planning', function () { }) }) +describe('attestMirrorVenue: the per-slot probe base (concurrent venues)', function () { + this.timeout(20000) + + const { AttestMirrorVenue, resolveVenueBasePort, DEFAULT_VENUE_BASE_PORT, VENUE_BASE_PORT_ENV, + RUNNER_SLOT_ENV } = + require('../../helpers/attestMirrorVenue') + + // The three bases a three-slot run derives, 100 apart, which is the layout the + // venue has to land inside for the slots to stay out of each other's way. + const SLOT_BASES = [63400, 63500, 63600] + const SLOT_WIDTH = 100 + + let saved + beforeEach(() => { saved = process.env[VENUE_BASE_PORT_ENV]; delete process.env[VENUE_BASE_PORT_ENV] }) + afterEach(() => { + if (saved === undefined) delete process.env[VENUE_BASE_PORT_ENV] + else process.env[VENUE_BASE_PORT_ENV] = saved + }) + + // A venue's whole port footprint: the children's planned ports plus the mirror + // proxies, i.e. the same one-probe-sliced-twice pool start() takes. + async function footprint (venue) { + const planned = portCount(venue.hubCount, venue.indexerCount) + const pool = await pickFreePorts(planned + venue.indexerCount, venue.basePort) + const plan = planPorts(pool.slice(0, planned), venue.hubCount, venue.indexerCount) + return [].concat(plan.hubApi, plan.p2p, plan.p2pProxy, plan.indexerApi, pool.slice(planned)) + } + + function venueAt (base, label) { + if (base === null) delete process.env[VENUE_BASE_PORT_ENV] + else process.env[VENUE_BASE_PORT_ENV] = String(base) + return new AttestMirrorVenue({ label: label }) + } + + it('hands two venues with no slot base the SAME ports, which is the collision itself', async () => { + // The defect, reproduced without a rail: a probe reports what is free, it binds + // nothing, so a second venue probing the same base is told the same ports are + // free and both plan onto them. Concurrently, whichever child listens second + // dies in its `before all` hook on EADDRINUSE. + const a = venueAt(null, 'slotless1') + const b = venueAt(null, 'slotless2') + assert.strictEqual(a.basePort, DEFAULT_VENUE_BASE_PORT) + assert.strictEqual(b.basePort, DEFAULT_VENUE_BASE_PORT) + const [pa, pb] = [await footprint(a), await footprint(b)] + const shared = pa.filter(p => pb.includes(p)) + assert.strictEqual(pa[0], pb[0], 'two venues probing one base were handed different first ports') + assert.strictEqual(shared.length, pa.length, + 'expected both shared-base venues to plan the same ' + pa.length + ' ports, shared ' + shared.length) + }) + + it('keeps three slot venues in three disjoint windows', async () => { + const venues = SLOT_BASES.map((base, i) => venueAt(base, 'slot' + i)) + venues.forEach((v, i) => assert.strictEqual(v.basePort, SLOT_BASES[i])) + const prints = [] + for (const v of venues) prints.push(await footprint(v)) + for (let i = 0; i < prints.length; i++) { + const lo = SLOT_BASES[i] + for (const p of prints[i]) + assert.ok(p >= lo && p < lo + SLOT_WIDTH, 'slot ' + i + ' planned ' + p + ' outside its window at ' + lo) + for (let j = i + 1; j < prints.length; j++) { + const shared = prints[i].filter(p => prints[j].includes(p)) + assert.deepStrictEqual(shared, [], 'slots ' + i + ' and ' + j + ' share ports: ' + shared.join(',')) + } + } + }) + + it('reads the slot base for a venue that names none, which is every leg that builds one directly', () => { + assert.strictEqual(resolveVenueBasePort(undefined, {}), DEFAULT_VENUE_BASE_PORT) + assert.strictEqual(resolveVenueBasePort(undefined, { [VENUE_BASE_PORT_ENV]: '63500' }), 63500) + assert.strictEqual(resolveVenueBasePort(undefined, { [VENUE_BASE_PORT_ENV]: '' }), DEFAULT_VENUE_BASE_PORT) + assert.strictEqual(resolveVenueBasePort(undefined, undefined), DEFAULT_VENUE_BASE_PORT) + }) + + it('lets a caller that places its own venues keep its base', () => { + // The bridge rail hands its two venues bases 200 apart on purpose; the slot + // base must not move them, or the pair would land on top of each other. + assert.strictEqual(resolveVenueBasePort(43000, { [VENUE_BASE_PORT_ENV]: '63400' }), 43000) + process.env[VENUE_BASE_PORT_ENV] = '63400' + assert.strictEqual(new AttestMirrorVenue({ label: 'explicit', basePort: 43200 }).basePort, 43200) + }) + + it('refuses a slot base it cannot use rather than falling back into the shared window', () => { + for (const bad of ['sixty-three thousand', '0', '80', '65001', '63400.5']) + assert.throws(() => resolveVenueBasePort(undefined, { [VENUE_BASE_PORT_ENV]: bad }), + /is not a usable probe base/, 'accepted ' + bad) + }) + + // A leg under the runner is owed a window of its own, and the slot is the evidence + // that it was owed one. Without this the export going missing would not fail: every + // slot would quietly share 41000 and the run would come back as flaky barrier legs. + it('refuses to default the base for a leg the runner gave a slot', () => { + for (const slot of ['0', '1', '2']) + assert.throws(() => resolveVenueBasePort(undefined, { [RUNNER_SLOT_ENV]: slot }), + /names a concurrency slot but AB_VENUE_BASE_PORT is unset/, 'defaulted slot ' + slot) + // Slot 0 is a real slot and the first one allocated. Asserted on its own because a + // presence check written as a truthiness check waves through exactly this case. + assert.throws(() => resolveVenueBasePort(undefined, { [RUNNER_SLOT_ENV]: '0' }), + /names a concurrency slot/) + // An empty or absent slot is NOT the runner, so the standalone default stands. + for (const empty of ['', ' ', undefined, null]) + assert.strictEqual(resolveVenueBasePort(undefined, { [RUNNER_SLOT_ENV]: empty }), + DEFAULT_VENUE_BASE_PORT, 'threw for slot ' + JSON.stringify(empty)) + }) + + it('resolves normally when the runner supplies both the slot and the base', () => { + for (const [slot, base] of [['0', '63400'], ['1', '63500'], ['2', '63600']]) + assert.strictEqual( + resolveVenueBasePort(undefined, { [RUNNER_SLOT_ENV]: slot, [VENUE_BASE_PORT_ENV]: base }), + Number(base)) + // An explicit base still wins over both, so the bridge rail's hand-placed pair + // is not touched by a slot it happens to inherit. + assert.strictEqual( + resolveVenueBasePort(43000, { [RUNNER_SLOT_ENV]: '1', [VENUE_BASE_PORT_ENV]: '63500' }), 43000) + }) +}) + describe('attestMirrorVenue: hub environment', function () { it('points every peer at the DELAY PROXY, not at the hub\'s own P2P port', () => { diff --git a/test/unit/helpers/barrierFamilyDrive.test.js b/test/unit/helpers/barrierFamilyDrive.test.js index a7152b7e..14700c96 100644 --- a/test/unit/helpers/barrierFamilyDrive.test.js +++ b/test/unit/helpers/barrierFamilyDrive.test.js @@ -274,6 +274,50 @@ describe('barrierFamilyDrive: an indexer whose API is not listening yet is not l }) }) +describe('barrierFamilyDrive: a level chain is not a ready hub mirror', function () { + /** A venue whose indexers are always level, and whose mirror flags come from `plan` per read. */ + function mirrorVenue (plan) { + const reads = [] + const venue = { + indexers: [{ index: 0 }, { index: 1 }], + statusOf: async (i) => { + const turn = reads.filter((r) => r === i).length + reads.push(i) + const hubMirror = plan(i, turn) + return { httpStatus: 200, body: { indexerBlock: 100, decoderBlock: 100, hubMirror } } + }, + } + return { venue, reads } + } + + it('does not release the baseline on a level chain whose mirror has not bootstrapped', async function () { + this.timeout(10000) + const ready = { configured: true, connected: true, bootstrapped: true } + const { venue, reads } = mirrorVenue((i, turn) => (i === 1 && turn < 1 + ? { configured: true, connected: true, bootstrapped: false } + : ready)) + const all = await drive.levelIndexers(venue, 8000, { requireMirrorReady: true }) + assert.ok(reads.filter((r) => r === 1).length > 1, + 'indexer 1 was read once, so the withheld bootstrap never held anything') + assert.deepStrictEqual(all.map((s) => s.mirrorBootstrapped), [true, true]) + }) + + it('releases on the same withheld bootstrap when the gate was not asked for', async function () { + this.timeout(10000) + const { venue } = mirrorVenue(() => ({ configured: true, connected: true, bootstrapped: false })) + const all = await drive.levelIndexers(venue, 8000) + assert.deepStrictEqual(all.map((s) => s.height), [100, 100]) + }) + + it('treats an indexer with no mirror configured as ready, and an absent flag as not ready', function () { + assert.strictEqual(drive.mirrorReady({ mirrorConfigured: false }).ok, true) + assert.strictEqual(drive.mirrorReady({}).ok, false) + assert.strictEqual(drive.mirrorReady({ mirrorConfigured: true, mirrorConnected: false, mirrorBootstrapped: true }).ok, false) + assert.strictEqual(drive.mirrorReady({ mirrorConfigured: true, mirrorConnected: true, mirrorBootstrapped: false }).ok, false) + assert.strictEqual(drive.mirrorReady({ mirrorConfigured: true, mirrorConnected: true, mirrorBootstrapped: true }).ok, true) + }) +}) + describe('barrierFamilyDrive: the AT4 corpus coordinates and the VM link a copied tree breaks', function () { const fs = require('fs') const os = require('os') diff --git a/test/unit/helpers/barrierFamilyFixture.test.js b/test/unit/helpers/barrierFamilyFixture.test.js index 1b5af0e6..6fc57f26 100644 --- a/test/unit/helpers/barrierFamilyFixture.test.js +++ b/test/unit/helpers/barrierFamilyFixture.test.js @@ -22,10 +22,12 @@ const assert = require('assert') const fs = require('fs') const os = require('os') const path = require('path') +const { execFileSync } = require('child_process') const F = require('../../attestMirror/helpers/barrierFamilyFixture') const { resolveRepoRoot } = require('../../helpers/attestMirrorVenue') -const gate = require('../../../../xchain-indexer/src/consensus/gates/mirror_admission_gate.js') +const GATE_PATH = require.resolve('../../../../xchain-indexer/src/consensus/gates/mirror_admission_gate.js') +const gate = require(GATE_PATH) const ROOT = resolveRepoRoot(undefined, {}) @@ -43,6 +45,94 @@ describe('barrierFamilyFixture: the arming lever', function () { assert.strictEqual(gate.resolveMirrorAdmissionRegtest({}), null, 'no key must read inert') assert.throws(() => F.armingOverlay({ armed: [-1] }), /bad indexer index/) }) + + it('arms at a HEIGHT when one is named, in the spelling the indexer\'s own resolver reads back', () => { + assert.deepStrictEqual(F.armingOverlay({ armed: [0, 1], armHeight: 812000 }), + { 0: { [F.ARM_ENV]: '812000' }, 1: { [F.ARM_ENV]: '812000' } }) + assert.strictEqual(gate.resolveMirrorAdmissionRegtest({ [F.ARM_ENV]: F.armValue(812000) }), 812000, + 'the height value does not arm the resolver at that height') + assert.strictEqual(F.armValue(undefined), F.ARM_VALUE, 'no height keeps the genesis form') + assert.strictEqual(F.armValue(null), F.ARM_VALUE) + assert.strictEqual(gate.resolveMirrorAdmissionRegtest({ [F.ARM_ENV]: F.armValue(0) }), + gate.MIRROR_ADMISSION_REGTEST_ARMED_HEIGHT, 'height 0 is the genesis height spelled out') + assert.throws(() => F.armValue(-1), /bad arm height/) + assert.throws(() => F.armValue(1.5), /bad arm height/) + assert.throws(() => F.armValue('812000'), /bad arm height/) + }) +}) + +describe('barrierFamilyFixture: the crossing a legacy-era seed needs', function () { + it('puts the activation at the first block the venue mines, so the chain it was built on is legacy era', () => { + assert.strictEqual(F.crossingArmHeight(811999), 812000) + assert.strictEqual(F.legacyEraBlock(F.crossingArmHeight(811999)), 811999, 'the legacy block is the tip the venue was built on') + assert.strictEqual(F.crossingArmHeight(0), 1, 'a chain at genesis still yields one legacy block: block 0') + assert.strictEqual(F.legacyEraBlock(1), 0) + assert.throws(() => F.crossingArmHeight(-1), /bad rail tip/) + assert.throws(() => F.crossingArmHeight('811999'), /bad rail tip/) + }) + + it('refuses to name a legacy block under the GENESIS arming, because there is none', () => { + assert.strictEqual(gate.MIRROR_ADMISSION_REGTEST_ARMED_HEIGHT, 0, + 'the genesis arming moved; the crossing rule below is keyed on it being 0') + assert.throws(() => F.legacyEraBlock(gate.MIRROR_ADMISSION_REGTEST_ARMED_HEIGHT), /no legacy era below arm height 0/) + assert.throws(() => F.legacyEraBlock(null), /no legacy era below arm height/) + }) + + // The whole point of the crossing, measured against the indexer's own builder rather than argued: + // BELOW the activation a NULL-map row builds an empty canonical tail, and AT or above it the + // builder still yields the legacy canonical: an absent map never throws in either era. + // + // In a CHILD process, because the arming is a child's environment: the gate reads its activation + // tables once at require time, so a venue arms a child at spawn and this case must do the same or + // it would only be testing this process's own already-resolved tables. + it('is the line the canonical builder itself draws for a NULL-map row', () => { + const ARM = 812000 + const out = armedGateProbe(F.armValue(ARM), ARM, F.legacyEraBlock(ARM)) + assert.strictEqual(out.resolved, ARM, 'the height value did not arm the child') + assert.strictEqual(out.producerActivation, ARM, 'the producer activation is not the armed height') + assert.strictEqual(out.consumerActivation, ARM, 'one value arms producer and consumer, or the eras split') + assert.strictEqual(out.eraAtLegacyBlock, false, 'the legacy block must be below the producer activation') + assert.strictEqual(out.eraAtArmHeight, true, 'the activation height itself is admission era') + assert.strictEqual(out.canonicalAtLegacyBlock, null, 'a NULL-map row below the activation must build an empty canonical tail') + assert.strictEqual(out.canonicalAtArmHeight, null, 'a NULL-map row at the activation must build the legacy canonical, not throw') + // The binding rule is untouched by the crossing: a NULL column binds by effective_time at every height. + assert.strictEqual(out.readableAbove, true, 'a NULL admission column must bind by effective_time above the activation') + }) + + // Armed at GENESIS, which is what every leg did before the crossing: there is no block below the + // activation, so the very same NULL-map row builds the legacy canonical at every height a chain can reach. + it('has no such line when the child is armed at genesis, which is why the seed had to move', () => { + const out = armedGateProbe(F.ARM_VALUE, 0, 0) + assert.strictEqual(out.resolved, 0) + assert.strictEqual(out.eraAtArmHeight, true, 'block 0 is already admission era') + assert.strictEqual(out.canonicalAtArmHeight, null) + assert.strictEqual(out.canonicalAtLegacyBlock, null, + 'armed at genesis a NULL-map row still builds the legacy canonical and never throws') + }) + + /** + * The indexer gate's answers under one arming value, read out of a child process whose + * environment carries it. A thrown canonical comes back as its message, never as a pass. + */ + function armedGateProbe (armValue, armHeight, legacyBlock) { + const script = [ + 'const g = require(' + JSON.stringify(GATE_PATH) + ');', + 'const N = "regtest";', + 'const can = (b) => { try { return g.admissionCanonicalValue("cross_chain_matches", N, b, null) } catch (e) { return String(e.message) } };', + 'process.stdout.write(JSON.stringify({', + ' resolved: g.resolveMirrorAdmissionRegtest(process.env),', + ' producerActivation: g.MIRROR_ADMISSION_ACTIVATION["BTC:regtest"],', + ' consumerActivation: g.MIRROR_ADMISSION_CONSUMER_ACTIVATION["BTC:regtest"],', + ' eraAtLegacyBlock: g.isMirrorAdmissionProducerActive("BTC", N, ' + Number(legacyBlock) + '),', + ' eraAtArmHeight: g.isMirrorAdmissionProducerActive("BTC", N, ' + Number(armHeight) + '),', + ' canonicalAtLegacyBlock: can(' + Number(legacyBlock) + '),', + ' canonicalAtArmHeight: can(' + Number(armHeight) + '),', + ' readableAbove: g.isRowReadableAt(null, ' + (Number(armHeight) + 50) + ', 1788494058, 1788494058),', + '}))', + ].join('\n') + const env = Object.assign({}, process.env, { [F.ARM_ENV]: armValue }) + return JSON.parse(execFileSync(process.execPath, ['-e', script], { encoding: 'utf8', env })) + } }) describe('barrierFamilyFixture: the family in loop order', function () { @@ -139,6 +229,26 @@ describe('barrierFamilyFixture: the BF3 pin and the leg sizing', function () { }) describe('barrierFamilyFixture: the build root and its evidence (B4, B11)', function () { + it('uses a pinned archive revision without following a copied .git pointer', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'barrier-source-revision-')) + const repo = path.join(root, 'xchain-hub') + fs.mkdirSync(repo) + fs.writeFileSync(path.join(repo, '.git'), 'gitdir: /unreachable/copied-worktree-pointer\n') + const revision = 'a'.repeat(40) + fs.writeFileSync(path.join(root, '.xchain-source-revisions.json'), JSON.stringify({ + version: 1, repositories: { 'xchain-hub': revision }, + })) + assert.strictEqual(F.headShaOf(repo), revision) + }) + + it('refuses an archive manifest that does not pin the requested repository', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'barrier-source-revision-')) + const repo = path.join(root, 'xchain-hub') + fs.mkdirSync(repo) + fs.writeFileSync(path.join(root, '.xchain-source-revisions.json'), JSON.stringify({ version: 1, repositories: {} })) + assert.throws(() => F.headShaOf(repo), /no pinned 40-hex revision for xchain-hub/) + }) + it('refuses to build a venue without an explicit build root', () => { assert.throws(() => F.buildFamilyVenue({}), /repoRoot is required/) assert.throws(() => F.buildFamilyVenue({ repoRoot: os.tmpdir() }), /has no xchain-hub\/src\/api\.js/) diff --git a/test/unit/helpers/barrierFamilyRows.test.js b/test/unit/helpers/barrierFamilyRows.test.js index 9e378b1e..d6f63ead 100644 --- a/test/unit/helpers/barrierFamilyRows.test.js +++ b/test/unit/helpers/barrierFamilyRows.test.js @@ -76,6 +76,66 @@ describe('barrierFamilyRows: inert rows', () => { }) }) +describe('barrierFamilyRows: capability snapshots required by seeded rows', () => { + const seed = (table, block, tag) => rows.inertRow(table, Object.assign({}, SPEC, { snapshotBlock: block, tag })) + + it('emits exactly one snapshot for seeds at one joined block', () => { + const seeds = [seed('cross_chain_matches', 100, 'm'), seed('cross_chain_calls', 100, 'c')] + const got = rows.requiredCapabilitySnapshots(seeds) + assert.strictEqual(got.length, 1) + assert.strictEqual(got[0].row.snapshot_block, 100) + }) + + it('covers the BF4 shape with one snapshot per legacy, boundary and post-boundary block', () => { + const seeds = [ + seed('cross_chain_matches', 99, 'legacy'), + seed('cross_chain_matches', 100, 'boundary'), + seed('cross_chain_calls', 103, 'post'), + ] + assert.deepStrictEqual(rows.requiredCapabilitySnapshots(seeds).map((s) => s.row.snapshot_block), [99, 100, 103]) + }) + + it('collapses duplicate blocks and is deterministic for upsert-safe re-runs', () => { + const seeds = [seed('cross_chain_matches', 200, 'm1'), seed('cross_chain_matches', 200, 'm2')] + const first = rows.requiredCapabilitySnapshots(seeds) + const second = rows.requiredCapabilitySnapshots(seeds.slice().reverse()) + assert.strictEqual(first.length, 1) + assert.deepStrictEqual(first, second) + }) + + it('emits the predicate capability and the declared capability snapshot natural key', () => { + const got = rows.requiredCapabilitySnapshots([seed('cross_chain_calls', 300, 'c')])[0] + assert.strictEqual(got.row.capability, 'cross_chain') + assert.strictEqual(got.table, 'capability_snapshots') + assert.deepStrictEqual(got.key, rows.NATURAL_KEYS.capability_snapshots) + for (const column of got.key) assert.notStrictEqual(got.row[column], undefined, 'missing natural key column ' + column) + }) + + it('reports false and names every joined block missing a required snapshot', () => { + const joined = [seed('cross_chain_matches', 400, 'm'), seed('cross_chain_calls', 401, 'c')] + const snapshots = rows.requiredCapabilitySnapshots(joined).filter((s) => s.row.snapshot_block === 400) + assert.deepStrictEqual(rows.snapshotCapabilityCoverage(joined.concat(snapshots)), { + satisfied: false, missingBlocks: [401], refusal: null, + }) + }) + + it('refuses an empty seed set instead of reporting the invariant satisfied', () => { + assert.deepStrictEqual(rows.snapshotCapabilityCoverage([]), { + satisfied: false, missingBlocks: [], refusal: 'no rows from snapshot barrier join tables', + }) + }) + + it('ignores tables outside the predicate and distinguishes absence from full coverage', () => { + const unrelated = [seed('bridge_transfers', 500, 'bridge')] + assert.deepStrictEqual(rows.requiredCapabilitySnapshots(unrelated), []) + assert.strictEqual(rows.snapshotCapabilityCoverage(unrelated).refusal, 'no rows from snapshot barrier join tables') + const joined = [seed('cross_chain_matches', 500, 'match')] + assert.deepStrictEqual(rows.snapshotCapabilityCoverage(joined.concat(rows.requiredCapabilitySnapshots(joined))), { + satisfied: true, missingBlocks: [], refusal: null, + }) + }) +}) + describe('barrierFamilyRows: BF1 judges each walker observation against its own deadline', () => { // The rail's walker block (2026-09-17, v020-final-bf1.log): stamped 1789635655, ladder step 90. const STAMP = 1789635655 @@ -168,38 +228,69 @@ describe('barrierFamilyRows: the heights a drill block needs before it is mined' }) }) -describe('barrierFamilyRows: no legacy row where a height-0 armed apply pass canonicalizes it', () => { +describe('barrierFamilyRows: a NULL-map row is a hazard only in the ADMISSION era', () => { const B = 104 + // The activation, chosen so SPEC's own block is admission era and one block below it is not. + const ARM = SPEC.snapshotBlock + const LEGACY = fixture.legacyEraBlock(ARM) const MEMBERS = ['cross_chain_matches', 'cross_chain_calls', 'bridge_transfers', 'policy_snapshots', 'attestation_responses'] const legacy = (t) => rows.inertRow(t, Object.assign({}, SPEC, { tag: 'legacy|' + t })) + const preCrossing = (t) => rows.inertRow(t, Object.assign({}, SPEC, { tag: 'legacy|' + t, snapshotBlock: LEGACY })) - it('flags a finalized NULL-map row in bridge_transfers and policy_snapshots, and only there', () => { - const hazards = rows.armedLegacyApplyHazards(MEMBERS.map(legacy)) + it('flags a finalized NULL-map row at or above the activation in bridge_transfers and policy_snapshots, and only there', () => { + const hazards = rows.armedLegacyApplyHazards(MEMBERS.map(legacy), ARM) assert.deepStrictEqual(hazards.map((h) => h.split('|')[0]), ['bridge_transfers', 'policy_snapshots']) assert.strictEqual(hazards[0], 'bridge_transfers|' + legacy('bridge_transfers').row.transfer_id, 'the hazard names the row key') - }) - - it('passes a row whose map names any chain, and a legacy row no apply select reads', () => { + // No height is the genesis form: every era block is admission era, which is what the legs armed at 0 saw. + assert.deepStrictEqual(rows.armedLegacyApplyHazards(MEMBERS.map(preCrossing)).map((h) => h.split('|')[0]), + ['bridge_transfers', 'policy_snapshots'], 'armed at genesis there is no era below the activation') + assert.throws(() => rows.armedLegacyApplyHazards([], -1), /bad arm height/) + }) + + it('passes the SAME row seeded BELOW the activation, which is the row a pre-crossing hub really wrote', () => { + assert.deepStrictEqual(rows.armedLegacyApplyHazards(MEMBERS.map(preCrossing), ARM), []) + // The boundary itself: a row AT the activation is admission era and still a hazard. + const at = rows.inertRow('bridge_transfers', Object.assign({}, SPEC, { tag: 'edge', snapshotBlock: ARM })) + assert.strictEqual(rows.armedLegacyApplyHazards([at], ARM).length, 1, 'a row at the activation is admission era') + const below = rows.inertRow('bridge_transfers', Object.assign({}, SPEC, { tag: 'edge', snapshotBlock: ARM - 1 })) + assert.deepStrictEqual(rows.armedLegacyApplyHazards([below], ARM), []) + // An unreadable era block fails CLOSED rather than passing as pre-activation. + const noEra = rows.inertRow('bridge_transfers', Object.assign({}, SPEC, { tag: 'edge' })) + noEra.row = Object.assign({}, noEra.row, { snapshot_block: null }) + assert.strictEqual(rows.armedLegacyApplyHazards([noEra], ARM).length, 1, 'a seed with no readable era must be flagged') + }) + + it('passes a row whose map names any chain, and a retracted row no apply select reads', () => { const atB = rows.inertRow('bridge_transfers', Object.assign({}, SPEC, { tag: 'at', admitBlocks: { BTC: B } })) const omitsBtc = rows.inertRow('policy_snapshots', Object.assign({}, SPEC, { tag: 'omits', admitBlocks: { LTC: 5 } })) const retracted = legacy('bridge_transfers') retracted.row = Object.assign({}, retracted.row, { status: 'retracted' }) - assert.deepStrictEqual(rows.armedLegacyApplyHazards([atB, omitsBtc, retracted]), []) + assert.deepStrictEqual(rows.armedLegacyApplyHazards([atB, omitsBtc, retracted], ARM), []) }) - it('BF2\'s seed plan carries at-B and past-B rows everywhere, legacy rows only where the apply cannot reach them', () => { - const seeds = rows.admissionSeedRows(MEMBERS, SPEC, B, 103) - assert.deepStrictEqual(rows.armedLegacyApplyHazards(seeds), [], 'the BF2 plan seeds a row the armed node stalls on') + it('BF2\'s seed plan carries at-B, past-B and a legacy row in EVERY member table, the legacy one below the activation', () => { + const seeds = rows.admissionSeedRows(MEMBERS, SPEC, B, 103, LEGACY) + assert.deepStrictEqual(rows.armedLegacyApplyHazards(seeds, ARM), [], 'the BF2 plan seeds a row the armed node stalls on') const count = (t) => seeds.filter((s) => s.table === t).length - assert.deepStrictEqual(MEMBERS.map(count), [3, 3, 2, 2, 3]) + assert.deepStrictEqual(MEMBERS.map(count), [3, 3, 3, 3, 3]) for (const t of MEMBERS) { const col = fixture.admissionColumn(t, 'BTC') - const heights = seeds.filter((s) => s.table === t).map((s) => s.row[col]) + const seeded = seeds.filter((s) => s.table === t) + const heights = seeded.map((s) => s.row[col]) assert.ok(heights.includes(B) && heights.includes(B + 3), t + ': the at-B and past-B rows are both present') + assert.strictEqual(heights.filter((h) => h === null).length, 1, t + ': exactly one legacy row') + // The legacy seed sits below the activation and the map-carrying seeds sit at or above it. + for (const s of seeded) { + if (!('snapshot_block' in s.row)) continue + const era = s.row[col] === null ? LEGACY : SPEC.snapshotBlock + assert.strictEqual(s.row.snapshot_block, era, t + ': a seed is in the wrong era') + } // The fixture's expected set at B is the seeded count minus the past-B row, which is what BF2 asserts. - const admitted = fixture.admittedRowSet(seeds.filter((s) => s.table === t).map((s) => s.row), t, 'BTC', B, SPEC.effectiveTime, (r) => String(r[s0(t)])) + const admitted = fixture.admittedRowSet(seeded.map((s) => s.row), t, 'BTC', B, SPEC.effectiveTime, (r) => String(r[s0(t)])) assert.strictEqual(admitted.length, count(t) - 1, t + ': admitted at B') } + assert.throws(() => rows.admissionSeedRows(MEMBERS, SPEC, B, 103), /needs a legacyBlock/) + assert.throws(() => rows.admissionSeedRows(MEMBERS, SPEC, B, 103, -1), /needs a legacyBlock/) }) function s0 (t) { return rows.NATURAL_KEYS[t][0] } diff --git a/test/unit/helpers/bridgeRailMinerPause.test.js b/test/unit/helpers/bridgeRailMinerPause.test.js new file mode 100644 index 00000000..50fe1b18 --- /dev/null +++ b/test/unit/helpers/bridgeRailMinerPause.test.js @@ -0,0 +1,163 @@ +/********************************************************************* + * + * 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 fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { withMiningPaused } = require('../../helpers/bridgeRailVenue'); + +const BTC_ENV = 'BRIDGE_RAIL_MINER_PAUSE_FILE'; +const DOGE_ENV = 'BRIDGE_RAIL_DOGE_MINER_PAUSE_FILE'; + +describe('bridgeRailVenue: miner pause files', function () { + let tempDir; + let priorBtc; + let priorDoge; + + beforeEach(function () { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bridge-rail-miner-pause-')); + priorBtc = process.env[BTC_ENV]; + priorDoge = process.env[DOGE_ENV]; + delete process.env[BTC_ENV]; + delete process.env[DOGE_ENV]; + }); + + afterEach(function () { + if (priorBtc === undefined) delete process.env[BTC_ENV]; + else process.env[BTC_ENV] = priorBtc; + if (priorDoge === undefined) delete process.env[DOGE_ENV]; + else process.env[DOGE_ENV] = priorDoge; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function minerStub(events) { + return { + async pauseMining() { events.push('pause'); }, + async resumeMining() { events.push('resume'); }, + }; + } + + it('creates and removes only the BTC file when only its variable is set', async function () { + const btc = path.join(tempDir, 'btc.pause'); + const doge = path.join(tempDir, 'doge.pause'); + const events = []; + process.env[BTC_ENV] = btc; + + await withMiningPaused(minerStub(events), async () => { + assert.strictEqual(fs.existsSync(btc), true); + assert.strictEqual(fs.existsSync(doge), false); + events.push('fn'); + }); + + assert.strictEqual(fs.existsSync(btc), false); + assert.deepStrictEqual(events, ['pause', 'fn', 'resume']); + }); + + it('creates and removes only the DOGE file when only its variable is set', async function () { + const btc = path.join(tempDir, 'btc.pause'); + const doge = path.join(tempDir, 'doge.pause'); + process.env[DOGE_ENV] = doge; + + await withMiningPaused(minerStub([]), async () => { + assert.strictEqual(fs.existsSync(doge), true); + assert.strictEqual(fs.existsSync(btc), false); + }); + + assert.strictEqual(fs.existsSync(doge), false); + }); + + it('creates and removes both independently configured files', async function () { + const btc = path.join(tempDir, 'btc.pause'); + const doge = path.join(tempDir, 'doge.pause'); + process.env[BTC_ENV] = btc; + process.env[DOGE_ENV] = doge; + + await withMiningPaused(minerStub([]), async () => { + assert.strictEqual(fs.existsSync(btc), true); + assert.strictEqual(fs.existsSync(doge), true); + }); + + assert.strictEqual(fs.existsSync(btc), false); + assert.strictEqual(fs.existsSync(doge), false); + }); + + it('writes no file but still pauses and resumes when neither variable is set', async function () { + const events = []; + + const result = await withMiningPaused(minerStub(events), async () => { + events.push('fn'); + assert.deepStrictEqual(fs.readdirSync(tempDir), []); + return 'done'; + }); + + assert.strictEqual(result, 'done'); + assert.deepStrictEqual(events, ['pause', 'fn', 'resume']); + assert.deepStrictEqual(fs.readdirSync(tempDir), []); + }); + + it('removes every created file and resumes when fn throws', async function () { + const btc = path.join(tempDir, 'btc.pause'); + const doge = path.join(tempDir, 'doge.pause'); + const events = []; + process.env[BTC_ENV] = btc; + process.env[DOGE_ENV] = doge; + + await assert.rejects( + withMiningPaused(minerStub(events), async () => { + assert.strictEqual(fs.existsSync(btc), true); + assert.strictEqual(fs.existsSync(doge), true); + events.push('fn'); + throw new Error('case failed'); + }), + /case failed/); + + assert.strictEqual(fs.existsSync(btc), false); + assert.strictEqual(fs.existsSync(doge), false); + assert.deepStrictEqual(events, ['pause', 'fn', 'resume']); + }); + + it('surfaces a write failure, cleans earlier files, and resumes', async function () { + const btc = path.join(tempDir, 'btc.pause'); + const missingDirDoge = path.join(tempDir, 'missing', 'doge.pause'); + const events = []; + process.env[BTC_ENV] = btc; + process.env[DOGE_ENV] = missingDirDoge; + + await assert.rejects( + withMiningPaused(minerStub(events), async () => events.push('fn')), + /ENOENT/); + + assert.strictEqual(fs.existsSync(btc), false); + assert.deepStrictEqual(events, ['pause', 'resume']); + }); + + it('swallows already-removed flag files and still resumes', async function () { + const btc = path.join(tempDir, 'btc.pause'); + const doge = path.join(tempDir, 'doge.pause'); + const events = []; + process.env[BTC_ENV] = btc; + process.env[DOGE_ENV] = doge; + + await withMiningPaused(minerStub(events), async () => { + fs.unlinkSync(btc); + fs.unlinkSync(doge); + events.push('fn'); + }); + + assert.deepStrictEqual(events, ['pause', 'fn', 'resume']); + }); +}); diff --git a/test/unit/helpers/bridge_credit_attribution.test.js b/test/unit/helpers/bridge_credit_attribution.test.js new file mode 100644 index 00000000..21f3210b --- /dev/null +++ b/test/unit/helpers/bridge_credit_attribution.test.js @@ -0,0 +1,121 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +const assert = require('assert') +const requireRow = require('../../helpers/requireRow') + +const expected = { + lockTxHash: '943e12baba913660f8a64a368ed563f914bda8ebe1d4db6e5adbda3f61b60cb6', + destCoin: 'DOGE', + destAddress: 'mosGAKW3KXbLsDVh5WSw6DAbcf6DzzcKu9', + tick: 'XCHAIN', + amount: '100' +} +const originalFailure = 'bridgeGasIn: destination credit wait gave up' + +async function runGiveUp(checkCredit){ + const database = { checkCredit } + return await requireRow.withProbe(null, originalFailure, + () => requireRow.bridgeCreditAttribution(database, expected), + requireRow.bridgeCreditEvidence(expected)) +} + +async function rejectionFrom(fn){ + try { + await fn() + } catch (err) { + return err + } + assert.fail('expected the give-up path to reject') +} + +describe('AT5 bridge credit attribution', function(){ + it('reports ABSENT with the lock txid when no destination row exists', async function(){ + const err = await rejectionFrom(() => runGiveUp(async () => null)) + + assert.match(err.message, /indexer verdict: ABSENT/) + assert.match(err.message, new RegExp(expected.lockTxHash)) + assert.match(err.message, /destination DOGE/) + assert.match(err.message, /address mosGAKW3/) + assert.match(err.message, /tick XCHAIN/) + assert.match(err.message, /expected amount 100/) + }) + + it('reports WRONG STATUS with the found and expected statuses', async function(){ + const err = await rejectionFrom(() => runGiveUp(async () => ({ + status: 'invalid: escrow proof', + amount: '100' + }))) + + assert.match(err.message, /indexer verdict: WRONG STATUS/) + assert.match(err.message, /found status invalid: escrow proof/) + assert.match(err.message, /expected status valid/) + assert.match(err.message, /amount 100/) + }) + + it('reports a valid row with the wrong amount instead of calling it ABSENT', async function(){ + const err = await rejectionFrom(() => runGiveUp(async () => ({ + status: 'valid', + amount: '99.00000000' + }))) + + assert.match(err.message, /indexer verdict: WRONG STATUS/) + assert.match(err.message, /found status valid, amount 99\.00000000/) + assert.match(err.message, /expected status valid, amount 100/) + assert.doesNotMatch(err.message, /indexer verdict: ABSENT/) + }) + + it('reports a sanitized UNREACHABLE cause without replacing the give-up failure', async function(){ + const err = await rejectionFrom(() => runGiveUp(async () => { + throw new Error('credits table does not exist; DOGE_INDEXER_DB_PASS=hunter2') + })) + + assert.match(err.message, /^bridgeGasIn: destination credit wait gave up never landed/) + assert.match(err.message, /indexer verdict: UNREACHABLE/) + assert.match(err.message, /credits table does not exist/) + assert.match(err.message, /DOGE_INDEXER_DB_PASS=\[REDACTED\]/) + assert.doesNotMatch(err.message, /hunter2/) + assert.match(err.message, new RegExp(expected.lockTxHash)) + }) + + it('returns a truthy wait result without running the probe', async function(){ + const row = { action_index: 42, amount: '100.00000000' } + let calls = 0 + const database = { + checkCredit: async () => { + calls++ + throw new Error('must not run') + } + } + + const result = await requireRow.withProbe(row, originalFailure, + () => requireRow.bridgeCreditAttribution(database, expected), + requireRow.bridgeCreditEvidence(expected)) + + assert.strictEqual(result, row) + assert.strictEqual(calls, 0) + }) + + it('treats equivalent decimal spellings as the expected amount', async function(){ + const verdict = await requireRow.bridgeCreditAttribution({ + checkCredit: async () => ({ amount: '100.00000000' }) + }, expected) + + assert.match(verdict, /row appeared only after the wait gave up/) + assert.match(verdict, /found status valid, amount 100\.00000000/) + }) + + it('preserves the two-argument requireRow contract', function(){ + const row = { id: 7 } + assert.strictEqual(requireRow(row, 'unused'), row) + assert.throws(() => requireRow(null, 'missing fixture'), { + message: 'missing fixture never landed; the GAVE UP line above, from the matching ' + + 'check* poll, says whether the row is absent or landed with another status ' + + '- read the indexer verdict for this tx' + }) + }) +}) diff --git a/test/unit/helpers/deliveryWindow.test.js b/test/unit/helpers/deliveryWindow.test.js new file mode 100644 index 00000000..ed5651fc --- /dev/null +++ b/test/unit/helpers/deliveryWindow.test.js @@ -0,0 +1,55 @@ +'use strict' + +/********************************************************************* + * Copyright © 2025-2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC - https://dankest.llc + * SPDX-License-Identifier: AGPL-3.0-or-later + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + ********************************************************************** + * The two halves of AT2b's delivery-window precondition, each proven to be + * load-bearing on its own: a cumulative hold counter without a live clock, and a + * live clock without a hold, must both read NOT ACTIVE. + ********************************************************************/ + +const assert = require('assert') + +const { delayedDeliveryWindow } = require('../../attestMirror/helpers/deliveryWindow') + +describe('deliveryWindow: an injected mirror delay is only evidence while it is live', function () { + it('rejects a window before the proxy has held anything', function () { + const out = delayedDeliveryWindow(1000, 2000, 5000, { framesHeld: 0, snapshotRowsHeld: 0 }) + assert.strictEqual(out.ok, false) + assert.strictEqual(out.injectionFired, false) + }) + + it('accepts a held row while the configured delay is active', function () { + const out = delayedDeliveryWindow(1000, 5999, 5000, { framesHeld: 1, snapshotRowsHeld: 0 }) + assert.strictEqual(out.ok, true) + assert.strictEqual(out.elapsedMs, 4999) + }) + + it('rejects a cumulative hold count after the delay has expired', function () { + const out = delayedDeliveryWindow(1000, 6000, 5000, { framesHeld: 1, snapshotRowsHeld: 0 }) + assert.strictEqual(out.ok, false) + assert.strictEqual(out.injectionFired, true) + }) + + it('counts a held snapshot row as a fired injection, not only a stream frame', function () { + const out = delayedDeliveryWindow(1000, 2000, 5000, { framesHeld: 0, snapshotRowsHeld: 3 }) + assert.strictEqual(out.ok, true) + assert.strictEqual(out.injectionFired, true) + }) + + it('rejects absent stats rather than reading a missing counter as a hold', function () { + assert.strictEqual(delayedDeliveryWindow(1000, 2000, 5000, null).ok, false) + assert.strictEqual(delayedDeliveryWindow(1000, 2000, 5000, {}).ok, false) + }) + + it('rejects an unusable clock instead of letting NaN arithmetic pass the window', function () { + const out = delayedDeliveryWindow(undefined, 2000, 5000, { framesHeld: 1 }) + assert.strictEqual(out.ok, false) + }) +}) diff --git a/test/unit/helpers/gasHelper.test.js b/test/unit/helpers/gasHelper.test.js index 8c3ffa4c..716a90f9 100644 --- a/test/unit/helpers/gasHelper.test.js +++ b/test/unit/helpers/gasHelper.test.js @@ -16,6 +16,7 @@ const transactionHelper = require('../../transactionHelper') const cryptoHelper = require('../../cryptoHelper') const mintHelper = require('../../helpers/mintHelper') const issueHelper = require('../../helpers/issueHelper') +const sendHelper = require('../../helpers/sendHelper') const chainRail = require('../../helpers/chainRail') const helper = require('../../helpers/gasHelper') const path = require('path') @@ -122,13 +123,96 @@ describe('gasHelper', () => { assert.strictEqual(mintStub.firstCall.args[2], '1000') }) - it('routes to bridgeGasIn on every other chain', async () => { + it('routes to the bridged reservoir on every other chain, never a per-address bridge', async () => { global.COIN_CODE = 'DOGE' - const bridgeStub = sinon.stub(helper, 'bridgeGasIn').resolves({ bridged: true }) + const reservoirStub = sinon.stub(helper, 'sendFromGasReservoir').resolves({ sent: true }) + const bridgeStub = sinon.stub(helper, 'bridgeGasIn') const result = await helper.ensureGasBalance(addressInfo, '500') - assert(bridgeStub.calledOnceWith(addressInfo, '500')) + assert(reservoirStub.calledOnceWith(addressInfo, '500')) + assert(bridgeStub.notCalled) assert(mintStub.notCalled) - assert.deepStrictEqual(result, { bridged: true }) + assert.deepStrictEqual(result, { sent: true }) + }) + }) + + // A representative nightly run: the litecoin leg finished 8 tests in six hours because every + // funded address paid a full relay margin for its own bridge. The reservoir + // bridges once per chain per process and pays every later address by SEND. + describe('gas reservoir', () => { + let bridgeStub, sendStub, fundStub + + beforeEach(() => { + helper._resetGasReservoirs() + global.COIN = 'litecoin' + global.COIN_CODE = 'LTC' + global.NETWORK = 'regtest' + bridgeStub = sinon.stub(helper, 'bridgeGasIn').resolves({ id: 9 }) + sendStub = sinon.stub(sendHelper, 'sendSendV0').resolves({ txHash: 'send1' }) + let n = 0 + fundStub = sinon.stub(cryptoHelper, 'getNewFundedAddress').callsFake(async () => ({ address: 'Lres' + (++n) })) + }) + + afterEach(() => helper._resetGasReservoirs()) + + it('bridges the reservoir amount once, then pays each address with a local SEND of the gas tick', async () => { + await helper.ensureGasBalance({ address: 'Lrecv1' }, 100) + await helper.ensureGasBalance({ address: 'Lrecv2' }, '20000') + + assert.strictEqual(fundStub.callCount, 1, 'one reservoir address per chain') + const [label, coin, network, , , , native, seedGas] = fundStub.firstCall.args + assert.deepStrictEqual([label, coin, network, seedGas], ['GAS.RESERVOIR', 'litecoin', 'regtest', false]) + assert.ok(native > 1, 'the reservoir needs native coin for many SEND fees') + + assert.strictEqual(bridgeStub.callCount, 1) + assert.deepStrictEqual(bridgeStub.firstCall.args, [{ address: 'Lres1' }, String(helper.RESERVOIR_BRIDGE_AMOUNT)]) + + assert.strictEqual(sendStub.callCount, 2) + assert.deepStrictEqual(sendStub.firstCall.args, [{ address: 'Lres1' }, 'XCHAIN', 100, 'Lrecv1', '']) + assert.deepStrictEqual(sendStub.secondCall.args, [{ address: 'Lres1' }, 'XCHAIN', '20000', 'Lrecv2', '']) + }) + + it('refills over the bridge when the next request would overdraw it, and bridges a request bigger than the default whole', async () => { + const big = helper.RESERVOIR_BRIDGE_AMOUNT * 2 + await helper.ensureGasBalance({ address: 'Lrecv1' }, String(big)) + // the fee headroom rounds the shortfall up by one whole XCHAIN + assert.strictEqual(bridgeStub.firstCall.args[1], String(big + 1)) + + await helper.ensureGasBalance({ address: 'Lrecv2' }, '100') + assert.strictEqual(bridgeStub.callCount, 2, 'drained by the first SEND, so the second refills') + assert.strictEqual(bridgeStub.secondCall.args[1], String(helper.RESERVOIR_BRIDGE_AMOUNT)) + assert.strictEqual(fundStub.callCount, 1, 'a refill reuses the same reservoir address') + }) + + it('keeps one reservoir per chain, so a DOGE caller never spends the LTC one', async () => { + await helper.ensureGasBalance({ address: 'Lrecv1' }, 100) + global.COIN = 'dogecoin' + global.COIN_CODE = 'DOGE' + await helper.ensureGasBalance({ address: 'Drecv1' }, 100) + assert.strictEqual(fundStub.callCount, 2) + assert.strictEqual(bridgeStub.callCount, 2) + assert.deepStrictEqual(sendStub.secondCall.args[0], { address: 'Lres2' }) + }) + + it('rebuilds from scratch after a SEND that did not land, instead of trusting the old balance', async () => { + sendStub.onFirstCall().rejects(new Error('sendSendV0: SEND 100 XCHAIN never valid')) + await assert.rejects(helper.ensureGasBalance({ address: 'Lrecv1' }, 100), /never valid/) + await helper.ensureGasBalance({ address: 'Lrecv2' }, 100) + assert.strictEqual(fundStub.callCount, 2) + assert.strictEqual(bridgeStub.callCount, 2) + }) + + it('rebuilds after a refill that failed, since its credit may or may not have landed', async () => { + bridgeStub.onFirstCall().rejects(new Error('never landed')) + await assert.rejects(helper.fillGasReservoir(), /never landed/) + await helper.fillGasReservoir() + assert.strictEqual(fundStub.callCount, 2) + }) + + it('fillGasReservoir alone bridges once and SENDs nothing (the initialCheck bootstrap)', async () => { + await helper.fillGasReservoir() + await helper.fillGasReservoir() + assert.strictEqual(bridgeStub.callCount, 1) + assert(sendStub.notCalled) }) }) @@ -251,6 +335,19 @@ describe('gasHelper', () => { assert.deepStrictEqual(result, { id: 500 }) }) + it('mints an amount above MAX_MINT in MAX_MINT chunks on BTC, then locks the whole amount in ONE XBRIDGE', async () => { + const amount = String(helper.GAS_MAX_MINT * 2 + 12345) + await helper.bridgeGasIn({ address: 'DDestBig' }, amount) + const messages = sendTxStub.getCalls().map(c => c.args[1]) + assert.deepStrictEqual(messages, [ + 'MINT|0|XCHAIN|' + helper.GAS_MAX_MINT + '|btcIssuer1|', + 'MINT|0|XCHAIN|' + helper.GAS_MAX_MINT + '|btcIssuer1|', + 'MINT|0|XCHAIN|12345|btcIssuer1|', + 'XBRIDGE|0|DOGE|DDestBig|' + amount + '|', + ]) + assert.deepStrictEqual(destDb.waitForCredit.firstCall.args[0], { address: 'DDestBig', tick: 'XCHAIN', amount }) + }) + it('ISSUEs the GAS tick on BTC first when it does not exist there yet, with the real 24-field wire string', async () => { btcDb.checkIssue.resolves(null) diff --git a/test/unit/helpers/nativeFeeHelper.test.js b/test/unit/helpers/nativeFeeHelper.test.js index b596a4d3..2e408dd1 100644 --- a/test/unit/helpers/nativeFeeHelper.test.js +++ b/test/unit/helpers/nativeFeeHelper.test.js @@ -496,3 +496,297 @@ describe('nativeFeeHelper.seedGlobalPrices hub seeding', () => { assert(/hub_db=XChain_Hub \(replay-safe\)/.test(log), log) }) }) + +// Native fee sizing at send time (nightly run 35899520268). The flat output was the +// whole fee model, and it held only while the indexer priced off the seed: a suite +// re-pricing a pair for its own case, or a mirror reconcile that deleted the seed and +// left the hub's live rounds in force, both raised the expected fee past it, and every +// LTC/DOGE shard went red on `insufficient native coin fee (paid: 0.00050000, ...)`. +describe('nativeFeeHelper.nativeFeeSats', () => { + const SNAPSHOT_PATH = require.resolve('../../helpers/priceSnapshotHelper') + let savedSnapshotModule, savedCoin, savedNetwork, savedConnector, savedLog + let calls, seeded, schedules, quote, chainTime, reseeds, savedTracker + + const SEED = { xchainUsd: '2.00000000', coinUsd: '100000.00000000', oracleRound: 888100012 } + const REPRICED = { xchainUsd: '2.00000000', coinUsd: '1000.00000000', oracleRound: 997710002 } + const LIVE_HUB = { xchainUsd: '2.00000000', coinUsd: '0.11414000', oracleRound: 12 } + + // `schedules` is the price view each successive feeschedule call answers with (the + // last one repeats); `quote` is what feequote answers, or an Error to throw. + function connector(){ + return { call: async (method, params) => { + calls.push({ method, params }) + if (method === 'feeschedule') { + const n = calls.filter(c => c.method === 'feeschedule').length + // A function is a stateful view: it sees how many re-seeds have happened. + const p = (typeof schedules === 'function') ? schedules(reseeds) + : schedules[Math.min(n, schedules.length) - 1] + return { + nativeFeeEnabled: true, feeDestination: 'DFeeDest111', + prices: p ? Object.assign({ available: true }, p) : { available: false, error: 'no current oracle price for DOGE/USD' } + } + } + if (method === 'feequote') { + if (quote instanceof Error) throw quote + return quote + } + throw new Error('unexpected method ' + method) + }} + } + + beforeEach(() => { + savedSnapshotModule = require.cache[SNAPSHOT_PATH] + savedCoin = global.COIN_CODE + savedNetwork = global.NETWORK + savedConnector = global.indexerConnector + global.COIN_CODE = 'DOGE' + global.NETWORK = 'regtest' + calls = [] + seeded = [] + reseeds = 0 + quote = null + savedTracker = global.utxoTrackerConnector + delete global.utxoTrackerConnector + chainTime = Math.floor(Date.now() / 1000) + 60 + require.cache[SNAPSHOT_PATH] = { id: SNAPSHOT_PATH, filename: SNAPSHOT_PATH, loaded: true, exports: { + isAvailable: async () => true, + latestBlockTime: async () => chainTime, + // One seedGlobalPrices clears XCHAIN/USD exactly once, so this counts re-seeds. + clearPair: async (pair) => { if (pair === 'XCHAIN/USD') reseeds++ }, + seedSnapshot: async (row) => { seeded.push(row) } + }} + global.indexerConnector = connector() + savedLog = console.log + console.log = () => {} + }) + + afterEach(() => { + console.log = savedLog + if (savedSnapshotModule) require.cache[SNAPSHOT_PATH] = savedSnapshotModule + else delete require.cache[SNAPSHOT_PATH] + global.COIN_CODE = savedCoin + global.NETWORK = savedNetwork + global.indexerConnector = savedConnector + if (savedTracker === undefined) delete global.utxoTrackerConnector + else global.utxoTrackerConnector = savedTracker + delete require.cache[HELPER_PATH] + }) + + const quotes = () => calls.filter(c => c.method === 'feequote') + // A sender the tracker reports as holding `sats`. + const fundSender = (sats) => { + global.utxoTrackerConnector = { getUtxosFromAddress: async () => ({ utxos: [{ value: sats }] }) } + } + + it('pays the flat fee at the seeded pair and asks for no quote', async () => { + schedules = [SEED] + const helper = freshHelper() + assert.strictEqual(await helper.nativeFeeSats('ISSUE|0|TICK|1000|100|0|d|10', 'DSrc111'), helper.FLAT_FEE_SATS) + assert.strictEqual(quotes().length, 0, 'the seed path must stay as cheap as it was') + assert.strictEqual(seeded.length, 0, 'a visible seed must not be re-seeded') + assert.strictEqual(reseeds, 0) + }) + + it('stays flat on a steady seed across many actions, with no re-seed and no quote', async () => { + schedules = [SEED] + const helper = freshHelper() + for (let i = 0; i < 5; i++) + assert.strictEqual(await helper.nativeFeeSats('ISSUE|0|T' + i + '|1|1|0|d|1', 'DSrc111'), helper.FLAT_FEE_SATS) + assert.strictEqual(reseeds, 0) + assert.strictEqual(quotes().length, 0) + }) + + // MT3 run 35925455269 (DOGE shard 2). A hub round finalized seconds after a seed, and the + // mirror wrote its rows onto the seed rows' primary keys: the coin row kept the seed's own + // round 888100012 but now carried a live price. That round cleared the fixture floor, so + // the old check read it as a suite's re-price, never re-seeded, and quoted 17.5 DOGE plus + // headroom against a 1-DOGE sender on all eight controller_policy cases. + it('re-seeds once when an oracle-round price sits under the seed\'s own round, then pays flat', async () => { + const OVERWRITTEN = { xchainUsd: '2.00000000', coinUsd: '0.11414000', oracleRound: 888100012 } + schedules = (n) => (n === 0 ? OVERWRITTEN : SEED) + quote = { supported: true, valid: true, requiredFeeSats: 1751730805 } + const helper = freshHelper() + assert.strictEqual(await helper.nativeFeeSats('ISSUE|0|TICK|1000|100|0|d|10', 'DSrc111'), helper.FLAT_FEE_SATS) + assert.strictEqual(reseeds, 1, 'exactly one re-seed') + assert.strictEqual(quotes().length, 0, 'back on the seed, the flat fee needs no quote') + }) + + it('forces one re-seed when a fixture-round quote exceeds what the sender holds, then pays flat', async () => { + // A round above the floor that no helper here owns: the displaced check lets it + // stand, but a fee the sender cannot pay is never a suite's intended re-price. + const UNKNOWN_FIXTURE = { xchainUsd: '2.00000000', coinUsd: '0.11414000', oracleRound: 990500 } + schedules = (n) => (n === 0 ? UNKNOWN_FIXTURE : SEED) + quote = { supported: true, valid: true, requiredFeeSats: 1751730805, + coinUsdPrice: '0.11414000', xchainUsdPrice: '2.00000000', oracleRound: 990500 } + const helper = freshHelper() + assert.strictEqual(await helper.nativeFeeSats('ISSUE|0|TICK|1000|100|0|d|10', 'DSrc111'), helper.FLAT_FEE_SATS) + assert.strictEqual(reseeds, 1, 'one forced re-seed, then the flat fee at the seed') + assert.strictEqual(quotes().length, 1) + }) + + it('forces the re-seed even inside the displaced rate-limit window', async () => { + const OVERWRITTEN = { xchainUsd: '2.00000000', coinUsd: '0.11414000', oracleRound: 888100012 } + let flip = 0 + schedules = (n) => (n === flip ? OVERWRITTEN : SEED) + const helper = freshHelper() + await helper.nativeFeeSats('SEND|0|T|1|x|', 'DSrc111') // takes the rate-limited re-seed + assert.strictEqual(reseeds, 1) + flip = 1 // the next round overwrites again + quote = { supported: true, valid: true, requiredFeeSats: 1751730805 } + assert.strictEqual(await helper.nativeFeeSats('ISSUE|0|T|1|1|0|d|1', 'DSrc111'), helper.FLAT_FEE_SATS) + assert.strictEqual(reseeds, 2, 'an over-flat displaced quote re-seeds regardless of the window') + }) + + it('refuses loudly, naming the prices it saw, when the fee stays above the funded balance', async () => { + const OVERWRITTEN = { xchainUsd: '2.00000000', coinUsd: '0.11414000', oracleRound: 888100012 } + schedules = [OVERWRITTEN] + quote = { supported: true, valid: true, requiredFeeSats: 1751730805, + coinUsdPrice: '0.11414000', xchainUsdPrice: '2.00000000', oracleRound: 888100012 } + const helper = freshHelper() + await assert.rejects(helper.nativeFeeSats('ISSUE|0|T|1|1|0|d|1', 'DSrc111'), (err) => { + assert.match(err.message, /refusing a 1926903886-sat native fee output for ISSUE from DSrc111/) + assert.match(err.message, /holds 100000000 sats/) + assert.match(err.message, /feeschedule DOGE\/USD=0\.11414 XCHAIN\/USD=2 round 888100012/) + assert.match(err.message, /feequote DOGE\/USD=0\.11414 XCHAIN\/USD=2 round 888100012/) + assert.match(err.message, /The seed is XCHAIN\/USD=2\.00000000 DOGE\/USD=100000\.00000000/) + return true + }) + assert.strictEqual(reseeds, 1, 'one re-seed before refusing, not one per sizing pass') + }) + + it('refuses against the tracker\'s balance for the sender when it can read one', async () => { + schedules = [REPRICED] + quote = { supported: true, valid: true, requiredFeeSats: 200000 } + fundSender(100000) + const helper = freshHelper() + await assert.rejects(helper.nativeFeeSats('ISSUE|0|T|1|1|0|d|1', 'DSrc111'), /holds 100000 sats/) + }) + + it('sizes the fee to the action\'s own quote, with headroom, under a re-priced fixture', async () => { + schedules = [REPRICED] + quote = { supported: true, valid: true, requiredFeeSats: 200000 } // 1 XCHAIN at DOGE/USD=1000 + const helper = freshHelper() + const sats = await helper.nativeFeeSats('ISSUE|0|TICK|1000|100|0|d|10', 'DSrc111') + assert.strictEqual(sats, Math.ceil(200000 * helper.FEE_HEADROOM)) + // The expected fee here is 0.002 DOGE, and the flat 0.0005 was what the indexer + // rejected; the paid output has to clear the band's 0.95x floor with room to spare. + assert(sats * 0.95 > 200000, 'the output must clear the band floor with headroom, got ' + sats) + assert.deepStrictEqual(quotes()[0].params, + { action: 'ISSUE', params: '0|TICK|1000|100|0|d|10', source: 'DSrc111' }, + 'the quote must be for this exact action and sender') + assert.strictEqual(seeded.length, 0, 'a fixture round is a suite\'s own re-price; it must not be clobbered') + }) + + it('never pays less than the flat floor, even when the quote is smaller', async () => { + schedules = [REPRICED] + quote = { supported: true, valid: true, requiredFeeSats: 1000 } + const helper = freshHelper() + assert.strictEqual(await helper.nativeFeeSats('SEND|0|T|1|x|', 'DSrc111'), helper.FLAT_FEE_SATS) + }) + + it('falls back to the re-priced budget when the indexer will not quote the action', async () => { + schedules = [REPRICED] + quote = new Error('invalid: TICK (already exists)') // the connector throws on the error envelope + const helper = freshHelper() + const sats = await helper.nativeFeeSats('ISSUE|0|TICK|1000|100|0|d|10', 'DSrc111') + // 25 XCHAIN at $2 / $1000 = 0.05 DOGE, plus headroom. + assert.strictEqual(sats, Math.ceil(5000000 * helper.FEE_HEADROOM)) + }) + + it('prices by budget when no action string is given (the SDK and multisig lanes)', async () => { + schedules = [REPRICED] + const helper = freshHelper() + assert.strictEqual(await helper.nativeFeeSats(), Math.ceil(5000000 * helper.FEE_HEADROOM)) + assert.strictEqual(quotes().length, 0) + }) + + it('re-seeds when the indexer has fallen back to a live hub round, then pays the flat fee', async () => { + // Before the fix: one ISSUE at the live DOGE/USD expected ~17.5 DOGE against a + // 1-DOGE funded address, so no correct fee could even be built. + schedules = [LIVE_HUB, SEED] + const helper = freshHelper() + assert.strictEqual(await helper.nativeFeeSats('ISSUE|0|TICK|1000|100|0|d|10', 'DSrc111'), helper.FLAT_FEE_SATS) + assert(seeded.some(r => r.coinPair === 'DOGE/USD' && r.price === '100000.00000000'), + 'the seed must be written back: ' + JSON.stringify(seeded)) + assert.strictEqual(quotes().length, 0) + }) + + it('re-seeds when the indexer has no usable price at all', async () => { + schedules = [null, SEED] + const helper = freshHelper() + assert.strictEqual(await helper.nativeFeeSats('ISSUE|0|T|1|1|0|d|1', 'DSrc111'), helper.FLAT_FEE_SATS) + assert(seeded.length > 0, 'an unpriceable indexer must get the seed back') + }) + + it('rate-limits the displaced re-seed for an action that stays at the flat fee', async () => { + schedules = [LIVE_HUB] + quote = { supported: true, valid: true, feeExempt: true, requiredFeeSats: 0 } + const helper = freshHelper() + assert.strictEqual(await helper.nativeFeeSats('SEND|0|T|1|x|', 'DSrc111'), helper.FLAT_FEE_SATS) + assert.strictEqual(reseeds, 1) + await helper.nativeFeeSats('SEND|0|T|1|x|', 'DSrc111') + assert.strictEqual(reseeds, 1, 'a second displaced call inside the window must not re-seed') + }) + + it('pays an unmovable live round the sender can afford, after its one re-seed', async () => { + schedules = [LIVE_HUB] + quote = { supported: true, valid: true, requiredFeeSats: 1752151752 } + fundSender(10000000000) + const helper = freshHelper() + assert.strictEqual(await helper.nativeFeeSats('ISSUE|0|T|1|1|0|d|1', 'DSrc111'), + Math.ceil(1752151752 * helper.FEE_HEADROOM), 'an unmovable live price is still paid correctly') + assert.strictEqual(reseeds, 1) + }) + + it('keeps the flat fee when the indexer cannot answer at all', async () => { + global.indexerConnector = { call: async () => { throw new Error('ECONNREFUSED') } } + const helper = freshHelper() + assert.strictEqual(await helper.nativeFeeSats('ISSUE|0|T|1|1|0|d|1', 'DSrc111'), helper.FLAT_FEE_SATS) + }) + + it('pays the flat floor for a fee-exempt action', async () => { + schedules = [REPRICED] + quote = { supported: true, valid: true, feeExempt: true, requiredFeeSats: 0 } + const helper = freshHelper() + assert.strictEqual(await helper.nativeFeeSats('DISPENSE|0|x', 'DSrc111'), helper.FLAT_FEE_SATS) + }) + + it('never re-seeds on a venue that publishes its own prices (XCHAIN_E2E_NO_PRICE_SEED=1)', async () => { + const CONSTANTS_PATH = require.resolve('../../helpers/xchainPriceConstants') + const savedFlag = process.env.XCHAIN_E2E_NO_PRICE_SEED + process.env.XCHAIN_E2E_NO_PRICE_SEED = '1' + delete require.cache[CONSTANTS_PATH] + try { + schedules = [LIVE_HUB] + quote = { supported: true, valid: true, requiredFeeSats: 1752151752 } + fundSender(10000000000) + const helper = freshHelper() + assert.strictEqual(await helper.nativeFeeSats('ISSUE|0|T|1|1|0|d|1', 'DSrc111'), + Math.ceil(1752151752 * helper.FEE_HEADROOM)) + assert.strictEqual(seeded.length, 0, 'the live rounds ARE the prices on such a venue') + } finally { + if (savedFlag === undefined) delete process.env.XCHAIN_E2E_NO_PRICE_SEED + else process.env.XCHAIN_E2E_NO_PRICE_SEED = savedFlag + delete require.cache[CONSTANTS_PATH] + } + }) + + it('getNativeFeeOutput stays null on gas-mode BTC and reads no prices for sizing', async () => { + global.COIN_CODE = 'BTC' + global.indexerConnector = { call: async (method) => { + calls.push({ method }) + return { nativeFeeEnabled: false, feeDestination: null, prices: { available: false } } + }} + const helper = freshHelper() + assert.strictEqual(await helper.getNativeFeeOutput('ISSUE|0|T|1|1|0|d|1', 'bSrc'), null) + assert.strictEqual(quotes().length, 0) + }) + + it('getNativeFeeOutput carries the sized value to the discovered destination', async () => { + schedules = [REPRICED] + quote = { supported: true, valid: true, requiredFeeSats: 200000 } + const helper = freshHelper() + const out = await helper.getNativeFeeOutput('ISSUE|0|TICK|1000|100|0|d|10', 'DSrc111') + assert.deepStrictEqual(out, { address: 'DFeeDest111', value: Math.ceil(200000 * helper.FEE_HEADROOM) }) + }) +}) diff --git a/test/unit/helpers/priceSnapshotFixtureId.test.js b/test/unit/helpers/priceSnapshotFixtureId.test.js new file mode 100644 index 00000000..b29c9d3b --- /dev/null +++ b/test/unit/helpers/priceSnapshotFixtureId.test.js @@ -0,0 +1,83 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Fixture price rows must never sit on an id the hub's next round arrives with. The +// indexer's hub_db_sync keeps the hub's wire id for price_snapshots and upserts ON +// DUPLICATE KEY UPDATE, so a fixture that took MAX(id)+1 had its price rewritten in +// place by the next mirrored round while it kept its sentinel round (MT3 run +// 35925455269 on DOGE, and the LTC DEPLOY that owed 7.8 LTC). + +const assert = require('assert') +const fs = require('fs') +const path = require('path') +const proxyquire = require('proxyquire') + +function loadHelper(){ + const seen = [] + const helper = proxyquire('../../helpers/priceSnapshotHelper', { + mariadb: { + createConnection: async () => ({ + query: async (sql, args) => { seen.push({ sql: String(sql), args }); return { affectedRows: 1 } }, + end: async () => {}, + }), + '@noCallThru': true, + }, + }) + return { helper, seen } +} + +describe('priceSnapshotHelper fixture ids', () => { + let savedIndexerDb + beforeEach(() => { + savedIndexerDb = global.indexerDatabase + global.indexerDatabase = { host: 'mariadb', port: 3306, dbName: 'XChain_DOGE_Regtest_Indexer', user: 'idx', pass: 'p' } + }) + afterEach(() => { global.indexerDatabase = savedIndexerDb }) + + it('inserts at an explicit id at or above the floor, never by AUTO_INCREMENT', async () => { + const { helper, seen } = loadHelper() + await helper.seedSnapshot({ coinPair: 'DOGE/USD', price: '100000.00000000', blockTimestamp: 1790201516, roundNumber: 888100002 }) + const insert = seen.find(q => /^INSERT/i.test(q.sql.trim())) + assert.ok(insert, 'seedSnapshot must insert: ' + JSON.stringify(seen)) + assert.match(insert.sql, /\(id, round_number, coin_pair,/) + assert.match(insert.sql, /GREATEST\(COALESCE\(MAX\(id\), 0\) \+ 1, \?\)/) + assert.strictEqual(insert.args[0], helper.FIXTURE_ID_FLOOR) + assert.deepStrictEqual(insert.args.slice(1), [888100002, 'DOGE/USD', '100000.00000000', 0, 1790201516]) + assert.ok(!/ON DUPLICATE KEY/i.test(insert.sql), + 'an upsert would keep an old row on its hub-reachable id') + }) + + it('replaces an existing row for the same round and pair by deleting it first', async () => { + const { helper, seen } = loadHelper() + await helper.seedSnapshot({ coinPair: 'DOGE/USD', price: '100000.00000000', blockTimestamp: 1, roundNumber: 888100012, referenceBlock: 7 }) + const insertAt = seen.findIndex(q => /^INSERT/i.test(q.sql.trim())) + const deleteAt = seen.findIndex(q => /^DELETE/i.test(q.sql.trim())) + assert.ok(deleteAt >= 0 && deleteAt < insertAt, 'the delete must precede the insert') + assert.deepStrictEqual(seen[deleteAt].args, [888100012, 'DOGE/USD']) + assert.strictEqual(seen[insertAt].args[4], 7, 'referenceBlock is carried through') + }) + + it('puts the floor far above any id a hub reaches', () => { + const { helper } = loadHelper() + // About 37 rows per ten-minute round: a century of rounds is ~2e8 ids. + assert.ok(helper.FIXTURE_ID_FLOOR >= 1e12) + assert.ok(Number.isSafeInteger(helper.FIXTURE_ID_FLOOR + 1e9), 'ids must stay exact in JS') + }) + + it('is the insert every direct indexer-side fixture writer uses', () => { + for (const rel of ['../../actions/native_fee_live.test.js', '../../actions/native_fee_dispenser.test.js']) { + const src = fs.readFileSync(path.join(__dirname, rel), 'utf8') + assert.ok(/FIXTURE_INSERT_SQL/.test(src), rel + ' must insert through FIXTURE_INSERT_SQL') + assert.ok(!/INSERT INTO price_snapshots/.test(src), rel + ' must not carry its own AUTO_INCREMENT insert') + } + }) +}) diff --git a/test/unit/scripts/attest_mirror_runner.test.js b/test/unit/scripts/attest_mirror_runner.test.js new file mode 100644 index 00000000..d4bb8827 --- /dev/null +++ b/test/unit/scripts/attest_mirror_runner.test.js @@ -0,0 +1,355 @@ +'use strict' + +const assert = require('assert') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { EventEmitter } = require('events') +const { spawnSync } = require('child_process') + +const runner = require('../../../scripts/run-attest-mirror') +const ABORT_THRESHOLD = 3 + +function fixture () { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'attest-mirror-runner-')) + const driver = path.join(root, 'driver.js') + const helper = path.join(root, 'wait-attest-mirror-stack.js') + const log = path.join(root, 'events.log') + fs.writeFileSync(driver, [ + "'use strict'", + "const fs = require('fs')", + "const path = require('path')", + "const args = process.argv.slice(2)", + "const phase = args.shift()", + "const value = (key) => args[args.indexOf(key) + 1]", + "const stack = value('--stack')", + "const leg = value('--leg')", + "const failures = JSON.parse(process.env.FAKE_FAILURES || '{}')", + "const failAt = failures[leg]", + "const state = path.join(process.env.FAKE_STATE, stack)", + "const event = (name) => fs.appendFileSync(process.env.FAKE_LOG, name + ' ' + stack + ' ' + leg + ' ' + args.join(' ') + '\\n')", + "if (phase === 'up') { fs.writeFileSync(state, leg); event('up'); if (failAt === phase) process.exit(11) }", + "if (phase === 'ready') { if (!fs.existsSync(state)) process.exit(31); event('ready'); if (failAt === phase) process.exit(12) }", + "if (phase === 'seed') { if (!fs.existsSync(state)) process.exit(32); event('seed'); if (failAt === phase) process.exit(13) }", + "if (phase === 'run') { event('run-start'); setTimeout(() => { event('run-end'); process.exit(failAt === phase || leg.includes('fail') ? 7 : 0) }, 100) }", + "if (phase === 'down') { event('down'); if (fs.existsSync(state)) fs.unlinkSync(state) }", + ].join('\n')) + fs.writeFileSync(helper, "'use strict'\nrequire('path')\n") + const legs = [ + path.join('test', 'attestMirror', 'a.test.js'), + path.join('test', 'attestMirror', 'b.test.js'), + path.join('test', 'attestMirror', 'barrier_family', 'c.test.js'), + ] + for (const relative of legs) { + fs.mkdirSync(path.dirname(path.join(root, relative)), { recursive: true }) + fs.writeFileSync(path.join(root, relative), '') + } + fs.mkdirSync(path.join(root, 'state')) + return { root, driver, helper, legs, log, state: path.join(root, 'state') } +} + +function fakeSpawn (failurePhase, events) { + return function (_command, args) { + const driverOffset = args[0].endsWith('.js') ? 1 : 0 + const phase = args[driverOffset] + const phaseArgs = args.slice(driverOffset + 1) + const value = (key) => phaseArgs[phaseArgs.indexOf(key) + 1] + const leg = value('--leg') + events.push({ phase, leg, stack: value('--stack') }) + const child = new EventEmitter() + setImmediate(() => child.emit('exit', failurePhase(phase, leg) ? 7 : 0, null)) + return child + } +} + +async function captureStdout (action) { + const originalWrite = process.stdout.write + let output = '' + process.stdout.write = function (chunk) { + output += String(chunk) + return true + } + try { + return { value: await action(), output } + } catch (error) { + error.capturedStdout = output + throw error + } finally { + process.stdout.write = originalWrite + } +} + +describe('attest-mirror aggregate runner', function () { + let savedLog = null + let savedState = null + + beforeEach(function () { + savedLog = process.env.FAKE_LOG + savedState = process.env.FAKE_STATE + }) + + afterEach(function () { + if (savedLog === undefined) delete process.env.FAKE_LOG + else process.env.FAKE_LOG = savedLog + if (savedState === undefined) delete process.env.FAKE_STATE + else process.env.FAKE_STATE = savedState + }) + + it('caps concurrency at three and rejects oversubscription', function () { + assert.strictEqual(runner.concurrencyFrom(undefined), 3) + assert.strictEqual(runner.concurrencyFrom('1'), 1) + assert.throws(() => runner.concurrencyFrom('4'), /integer from 1 to 3/) + assert.throws(() => runner.concurrencyFrom('0'), /integer from 1 to 3/) + }) + + it('expands the two package-script globs into the complete sorted leg list', function () { + const f = fixture() + assert.deepStrictEqual(runner.listLegs(f.root, [ + 'test/attestMirror/*.test.js', 'test/attestMirror/barrier_family/*.test.js', + ]), [ + 'test/attestMirror/a.test.js', + 'test/attestMirror/b.test.js', + 'test/attestMirror/barrier_family/c.test.js', + ]) + }) + + it('allocates a unique stack per leg, waits for both readiness gates and tears every stack down', async function () { + const f = fixture() + process.env.FAKE_LOG = f.log + process.env.FAKE_STATE = f.state + const legs = runner.listLegs(f.root, []) + const results = await runner.runAll({ + root: f.root, driver: f.driver, legs, concurrency: '2', runId: 'two-leg-proof', + }) + assert.deepStrictEqual(results.map((result) => result.code), [0, 0, 0]) + const lines = fs.readFileSync(f.log, 'utf8').trim().split('\n') + const stacks = new Set(lines.filter((line) => line.startsWith('up ')).map((line) => line.split(' ')[1])) + assert.strictEqual(stacks.size, 3, 'each leg must receive a different stack') + assert.strictEqual(lines.filter((line) => line.startsWith('down ')).length, 3) + assert.strictEqual(lines.filter((line) => line.startsWith('seed ')).length, 3) + assert.deepStrictEqual(fs.readdirSync(f.state), [], 'all stack markers must be removed') + for (const line of lines.filter((entry) => entry.startsWith('ready '))) { + assert.match(line, /--require indexer-schema --require miner-health$/) + } + const firstEnd = lines.findIndex((line) => line.startsWith('run-end ')) + assert.strictEqual(lines.slice(0, firstEnd).filter((line) => line.startsWith('run-start ')).length, 2, + 'two workers should run while the third waits for a slot') + }) + + it('tears a stack down after its leg fails', async function () { + const f = fixture() + const failedLeg = path.join('test', 'attestMirror', 'fail.test.js') + fs.writeFileSync(path.join(f.root, failedLeg), '') + process.env.FAKE_LOG = f.log + process.env.FAKE_STATE = f.state + const results = await runner.runAll({ + root: f.root, driver: f.driver, legs: [failedLeg], concurrency: '1', runId: 'failure-proof', + }) + assert.strictEqual(results[0].phase, 'run') + assert.strictEqual(results[0].code, 7) + assert.deepStrictEqual(fs.readdirSync(f.state), []) + assert.match(fs.readFileSync(f.log, 'utf8'), /run-end .*\ndown /) + }) + + it('refuses a missing readiness dependency before allocating a stack', async function () { + const f = fixture() + fs.writeFileSync(f.helper, "'use strict'\nrequire('module-that-is-deliberately-absent')\n") + let error = null + try { + await captureStdout(() => runner.runAll({ + root: f.root, driver: f.driver, legs: [f.legs[0]], concurrency: '1', runId: 'preflight-failure', + spawn: fakeSpawn(() => false, []), + })) + } catch (caught) { + error = caught + } + assert.ok(error) + assert.match(error.message, /module-that-is-deliberately-absent/) + assert.match(error.message, new RegExp(f.root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + assert.doesNotMatch(error.capturedStdout, /\[attest-mirror\] allocate/) + }) + + it('refuses when the readiness helper is absent', async function () { + const f = fixture() + fs.unlinkSync(f.helper) + await assert.rejects(runner.runAll({ + root: f.root, driver: f.driver, legs: [f.legs[0]], concurrency: '1', runId: 'missing-helper', + spawn: fakeSpawn(() => false, []), + }), /readiness helper does not exist/) + }) + + it('passes preflight silently on a healthy run', async function () { + const f = fixture() + const events = [] + const captured = await captureStdout(() => runner.runAll({ + root: f.root, driver: f.driver, legs: [f.legs[0]], concurrency: '1', runId: 'silent-preflight', + spawn: fakeSpawn(() => false, events), + })) + assert.strictEqual(captured.value[0].code, 0) + assert.strictEqual(captured.output, + '[attest-mirror] allocate am-silent-preflight-1 slot=0 leg=' + f.legs[0] + '\n' + + '[attest-mirror] release am-silent-preflight-1 exit=0\n') + }) + + it('aborts after consecutive ready failures and names the phase and legs', async function () { + const f = fixture() + const unallocated = [ + path.join('test', 'attestMirror', 'd.test.js'), + path.join('test', 'attestMirror', 'e.test.js'), + ] + for (const leg of unallocated) fs.writeFileSync(path.join(f.root, leg), '') + const legs = [...f.legs, ...unallocated] + const events = [] + let error = null + try { + await runner.runAll({ + root: f.root, driver: f.driver, legs, concurrency: '1', runId: 'ready-abort', + spawn: fakeSpawn((phase) => phase === 'ready', events), + }) + } catch (caught) { + error = caught + } + assert.ok(error) + assert.match(error.message, new RegExp('abort after ' + ABORT_THRESHOLD + ' consecutive ready failures')) + for (const leg of f.legs) assert.match(error.message, new RegExp(leg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + assert.strictEqual(events.filter((event) => event.phase === 'up').length, ABORT_THRESHOLD) + for (const leg of unallocated) assert.doesNotMatch(error.message, new RegExp(leg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + }) + + it('does not abort on consecutive run failures and runs every leg', async function () { + const f = fixture() + const events = [] + const results = await runner.runAll({ + root: f.root, driver: f.driver, legs: f.legs, concurrency: '2', runId: 'ordinary-red', + spawn: fakeSpawn((phase) => phase === 'run', events), + }) + assert.strictEqual(results.length, f.legs.length) + assert.deepStrictEqual(results.map((result) => result.phase), f.legs.map(() => 'run')) + assert.strictEqual(events.filter((event) => event.phase === 'run').length, f.legs.length) + }) + + it('tears down every allocated stack when the environment threshold aborts', async function () { + const f = fixture() + const events = [] + await assert.rejects(runner.runAll({ + root: f.root, driver: f.driver, legs: f.legs, concurrency: '3', runId: 'abort-teardown', + spawn: fakeSpawn((phase) => phase === 'ready', events), + }), /consecutive ready failures/) + const allocated = events.filter((event) => event.phase === 'up').map((event) => event.stack).sort() + const tornDown = events.filter((event) => event.phase === 'down').map((event) => event.stack).sort() + assert.deepStrictEqual(tornDown, allocated) + }) + + it('keeps refusing a zero-leg selection', async function () { + const f = fixture() + for (const leg of f.legs) fs.unlinkSync(path.join(f.root, leg)) + await assert.rejects(runner.runAll({ + root: f.root, driver: f.driver, legs: [], concurrency: '1', runId: 'zero-legs', + spawn: fakeSpawn(() => false, []), + }), /no attest-mirror legs selected/) + }) + + it('refuses an explicit empty CLI selection before allocating a stack', function () { + const f = fixture() + const result = spawnSync(process.execPath, [path.join(__dirname, '..', '..', '..', 'scripts', 'run-attest-mirror.js')], { + cwd: f.root, + env: Object.assign({}, process.env, { + ATTEST_MIRROR_STACK_DRIVER: f.driver, + ATTEST_MIRROR_LEGS: ' , ', + FAKE_LOG: f.log, + FAKE_STATE: f.state, + }), + encoding: 'utf8', + }) + assert.strictEqual(result.status, 1) + assert.match(result.stderr, /no attest-mirror legs selected/) + assert.deepStrictEqual(fs.readdirSync(f.state), []) + assert.strictEqual(fs.existsSync(f.log), false) + }) + + it('runs a two-leg aggregate through the CLI and releases both stacks', function () { + const f = fixture() + const selected = ['test/attestMirror/at0-anti-wedge.test.js', + 'test/attestMirror/barrier_family/ab4_below_activation.test.js'] + const result = spawnSync(process.execPath, [path.join(__dirname, '..', '..', '..', 'scripts', 'run-attest-mirror.js')], { + cwd: f.root, + env: Object.assign({}, process.env, { + ATTEST_MIRROR_STACK_DRIVER: f.driver, + ATTEST_MIRROR_CONCURRENCY: '2', + ATTEST_MIRROR_RUN_ID: 'cli-proof', + ATTEST_MIRROR_LEGS: selected.join(','), + FAKE_LOG: f.log, + FAKE_STATE: f.state, + }), + encoding: 'utf8', + }) + assert.strictEqual(result.status, 0, result.stderr) + assert.match(result.stdout, /2 legs passed, 0 failed; failures by phase: none/) + assert.deepStrictEqual(fs.readdirSync(f.state), []) + const lines = fs.readFileSync(f.log, 'utf8').trim().split('\n') + assert.strictEqual(lines.filter((line) => line.startsWith('up ')).length, 2) + assert.strictEqual(lines.filter((line) => line.startsWith('down ')).length, 2) + }) + + it('summarizes a mixed run by failure phase', function () { + const f = fixture() + const selected = [ + 'test/attestMirror/at0-anti-wedge.test.js', + 'test/attestMirror/at1_mirror_finalize.test.js', + 'test/attestMirror/at2-dissemination-determinism.test.js', + ] + const failures = { + [selected[0]]: 'up', + [selected[1]]: 'seed', + [selected[2]]: 'run', + } + const result = spawnSync(process.execPath, [path.join(__dirname, '..', '..', '..', 'scripts', 'run-attest-mirror.js')], { + cwd: f.root, + env: Object.assign({}, process.env, { + ATTEST_MIRROR_STACK_DRIVER: f.driver, + ATTEST_MIRROR_CONCURRENCY: '1', + ATTEST_MIRROR_RUN_ID: 'summary-proof', + ATTEST_MIRROR_LEGS: selected.join(','), + FAKE_FAILURES: JSON.stringify(failures), + FAKE_LOG: f.log, + FAKE_STATE: f.state, + }), + encoding: 'utf8', + }) + assert.strictEqual(result.status, 3, result.stderr) + assert.match(result.stdout, + /0 legs passed, 3 failed; failures by phase: up=1, seed=1, run=1/) + }) + + it('keeps the failed-leg exit code when every failure is at run', function () { + const f = fixture() + const selected = [ + 'test/attestMirror/at0-anti-wedge.test.js', + 'test/attestMirror/at1_mirror_finalize.test.js', + ] + const failures = Object.fromEntries(selected.map((leg) => [leg, 'run'])) + const result = spawnSync(process.execPath, [path.join(__dirname, '..', '..', '..', 'scripts', 'run-attest-mirror.js')], { + cwd: f.root, + env: Object.assign({}, process.env, { + ATTEST_MIRROR_STACK_DRIVER: f.driver, + ATTEST_MIRROR_CONCURRENCY: '2', + ATTEST_MIRROR_RUN_ID: 'exit-proof', + ATTEST_MIRROR_LEGS: selected.join(','), + FAKE_FAILURES: JSON.stringify(failures), + FAKE_LOG: f.log, + FAKE_STATE: f.state, + }), + encoding: 'utf8', + }) + assert.strictEqual(result.status, selected.length, result.stderr) + assert.match(result.stdout, /0 legs passed, 2 failed; failures by phase: run=2/) + }) + + it('requires a lifecycle driver at the npm entry point', function () { + const result = spawnSync(process.execPath, [path.join(__dirname, '..', '..', '..', 'scripts', 'run-attest-mirror.js')], { + cwd: path.join(__dirname, '..', '..', '..'), env: Object.assign({}, process.env, { ATTEST_MIRROR_STACK_DRIVER: '' }), encoding: 'utf8', + }) + assert.strictEqual(result.status, 1) + assert.match(result.stderr, /ATTEST_MIRROR_STACK_DRIVER/) + }) +}) diff --git a/test/unit/scripts/attest_mirror_stack_driver.test.js b/test/unit/scripts/attest_mirror_stack_driver.test.js new file mode 100644 index 00000000..4b16a4b5 --- /dev/null +++ b/test/unit/scripts/attest_mirror_stack_driver.test.js @@ -0,0 +1,50 @@ +'use strict' + +const assert = require('assert') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { spawnSync } = require('child_process') + +const ROOT = path.resolve(__dirname, '..', '..', '..') +const DRIVER = path.join(ROOT, 'scripts', 'attest-mirror-stack-driver.sh') +const BF6 = 'test/attestMirror/barrier_family/bf6_producer_follower_parity.test.js' + +function fixture () { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'attest-mirror-stack-driver-')) + const log = path.join(root, 'run.log') + const runLeg = path.join(root, 'run-leg.sh') + fs.writeFileSync(runLeg, '#!/bin/sh\nprintf "%s\\n" "$*" > "$FAKE_DRIVER_LOG"\n') + fs.chmodSync(runLeg, 0o755) + return { root, log } +} + +function runPhase (f, leg) { + return spawnSync(DRIVER, ['run', '--stack', 'am-proof-1', '--slot', '0', '--leg', leg], { + cwd: ROOT, + env: Object.assign({}, process.env, { + ATTEST_MIRROR_STACK_ROOT: f.root, + FAKE_DRIVER_LOG: f.log, + }), + encoding: 'utf8', + }) +} + +describe('attest-mirror concrete stack driver', function () { + it('passes the prepared real-checkout root only to BF6', function () { + const f = fixture() + try { + const bf6 = runPhase(f, BF6) + assert.strictEqual(bf6.status, 0, bf6.stderr) + const bf6Args = fs.readFileSync(f.log, 'utf8') + assert.match(bf6Args, /BF6_MIXED_HUB_ROOT=/) + assert.match(bf6Args, /xca71\/bf6-mixed\/am-proof-1/) + + const other = runPhase(f, 'test/attestMirror/zc5_flag_day.test.js') + assert.strictEqual(other.status, 0, other.stderr) + assert.doesNotMatch(fs.readFileSync(f.log, 'utf8'), /BF6_MIXED_HUB_ROOT=/) + } finally { + fs.rmSync(f.root, { recursive: true, force: true }) + } + }) +}) diff --git a/test/unit/scripts/check_mirror_pendings.test.js b/test/unit/scripts/check_mirror_pendings.test.js new file mode 100644 index 00000000..43b40636 --- /dev/null +++ b/test/unit/scripts/check_mirror_pendings.test.js @@ -0,0 +1,151 @@ +'use strict' + +const assert = require('assert') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { spawnSync } = require('child_process') + +const checker = require('../../../scripts/check-mirror-pendings') +const checkerPath = path.resolve(__dirname, '../../../scripts/check-mirror-pendings.js') + +function fixture (files) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mirror-pendings-')) + for (const [relative, source] of Object.entries(files || {})) { + const absolute = path.join(root, relative) + fs.mkdirSync(path.dirname(absolute), { recursive: true }) + fs.writeFileSync(absolute, source) + } + return root +} + +function run (input) { + return spawnSync(process.execPath, [checkerPath, input], { encoding: 'utf8' }) +} + +describe('check-mirror-pendings', function () { + it('reports and accepts an it.skip with a stated reason', function () { + const root = fixture({ + 'at1-example.test.js': "describe('AT1', () => { it.skip('covers the slow edge (needs 257 rounds)', () => {}) })", + }) + const result = run(root) + assert.strictEqual(result.status, 0, result.stderr) + assert.match(result.stdout, /PENDING .*at1-example\.test\.js:1/) + assert.match(result.stdout, /reason: needs 257 rounds/) + }) + + it('refuses and names an it.skip with no reason', function () { + const root = fixture({ + 'at1-example.test.js': "describe('AT1', () => { it.skip('quietly absent claim', () => {}) })", + }) + const result = run(root) + assert.strictEqual(result.status, 1) + assert.match(result.stderr, /unreasoned pending: .*quietly absent claim/) + }) + + it('finds a this.skip in a lifecycle hook', function () { + const source = [ + "describe('seeder runs only when the roster is absent', function () {", + ' before(function () {', + ' this.skip()', + ' })', + " it('seeds the roster', () => {})", + '})', + ].join('\n') + const parsed = checker.parseSource(source, 'at4.test.js') + assert.strictEqual(parsed.pending.length, 1) + assert.strictEqual(parsed.pending[0].line, 3) + assert.match(parsed.pending[0].title, /before hook/) + }) + + it('resolves a named leg whose active case title covers the deferred claim', function () { + const root = fixture({ + 'at2.test.js': "describe('AT2', () => { it.skip('holds delivery past the forward margin (DRIVEN in at2b)', () => {}) })", + 'at2b-forward-margin.test.js': "describe('delivery past the forward margin', () => { it('holds the barrier', () => {}) })", + }) + const result = run(root) + assert.strictEqual(result.status, 0, result.stderr) + assert.match(result.stdout, /cross-references 1/) + }) + + it('refuses a pending that names a leg which does not exist', function () { + const root = fixture({ + 'at2.test.js': "describe('AT2', () => { it.skip('holds delivery past the margin (DRIVEN in at9b)', () => {}) })", + }) + const result = run(root) + assert.strictEqual(result.status, 1) + assert.match(result.stderr, /unresolved cross-reference at9b.*leg file does not exist/) + }) + + it('refuses a named leg whose active cases do not cover the claim', function () { + const root = fixture({ + 'at2.test.js': "describe('AT2', () => { it.skip('holds delivery past the margin (DRIVEN in at2b)', () => {}) })", + 'at2b-other.test.js': "describe('AT2b', () => { it('checks an unrelated property', () => {}) })", + }) + const result = run(root) + assert.strictEqual(result.status, 1) + assert.match(result.stderr, /at2b.*no matching active case title/) + }) + + it('refuses an empty directory as zero files read', function () { + const result = run(fixture()) + assert.strictEqual(result.status, 1) + assert.match(result.stderr, /read zero files from input/) + }) + + it('distinguishes a missing input path from an empty directory', function () { + const root = fixture() + const result = run(path.join(root, 'does-not-exist')) + assert.strictEqual(result.status, 1) + assert.match(result.stderr, /input path does not exist/) + assert.match(result.stderr, /read zero files from input/) + }) + + it('refuses a file with no pending tests after reporting one file read', function () { + const root = fixture({ 'clean.test.js': "describe('clean', () => { it('runs', () => {}) })" }) + const result = run(path.join(root, 'clean.test.js')) + assert.strictEqual(result.status, 1) + assert.match(result.stderr, /read 1 file\(s\) but found zero pending tests/) + }) + + it('prints file, line, title and reason on every census line', function () { + const root = fixture({ + 'at5.test.js': [ + "describe('AT5', function () {", + " it.skip('dead-letters the window (needs 257 rounds)', function () {})", + '})', + ].join('\n'), + }) + const result = run(root) + assert.strictEqual(result.status, 0, result.stderr) + assert.match(result.stdout, /PENDING .*at5\.test\.js:2 \| title: AT5 > dead-letters the window \(needs 257 rounds\) \| reason: needs 257 rounds/) + }) + + it('uses a directly attached comment as the stated reason', function () { + const root = fixture({ + 'at6.test.js': [ + "describe('AT6', function () {", + ' // Regtest is armed at height zero, so no below-height request can exist.', + " it.skip('serves a below-height request', function () {})", + '})', + ].join('\n'), + }) + const result = run(root) + assert.strictEqual(result.status, 0, result.stderr) + assert.match(result.stdout, /reason: Regtest is armed at height zero/) + }) + + it('does not count the standard unavailable-venue hook as a pending claim', function () { + const source = [ + "describe('venue leg', function () {", + ' before(function () {', + ' if (!up) {', + " console.log('SKIPPED: ' + venue.unavailable)", + ' this.skip()', + ' }', + ' })', + '})', + ].join('\n') + assert.deepStrictEqual(checker.parseSource(source, 'at0.test.js').pending, []) + }) +}) diff --git a/test/unit/scripts/check_request_row_wait.test.js b/test/unit/scripts/check_request_row_wait.test.js new file mode 100644 index 00000000..dfb94cc3 --- /dev/null +++ b/test/unit/scripts/check_request_row_wait.test.js @@ -0,0 +1,201 @@ +'use strict' + +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************/ + +const assert = require('assert') +const cp = require('child_process') +const fs = require('fs') +const os = require('os') +const path = require('path') + +const checker = require('../../../scripts/check-request-row-wait') +const script = path.join(__dirname, '..', '..', '..', 'scripts', 'check-request-row-wait.js') + +function scan (lines) { + return checker.scanSource(lines.join('\n'), 'fixture.test.js') +} + +function run (args) { + return cp.spawnSync(process.execPath, [script].concat(args), { encoding: 'utf8' }) +} + +describe('check-request-row-wait classifications', function () { + it('recognises a height wait on the emitted request block as guarded', function () { + const hits = scan([ + 'async function check(request) {', + ' const requestId = request.requestId', + ' const requestBlock = Number(request.blockIndex)', + ' await waitForHeightWithClear(venue, 0, requestBlock)', + ' return readRequestRow(venue, 0, requestId)', + '}', + ]) + assert.strictEqual(hits.length, 1) + assert.strictEqual(hits[0].status, 'guarded') + assert.strictEqual(hits[0].identifier, 'requestId') + }) + + it('names an unguarded read and its exact line', function () { + const hits = scan([ + 'async function check(requestId) {', + ' await waitForMirrorRowEverywhere(venue, requestId)', + ' return readRequestRow(venue, 0, requestId)', + '}', + ]) + assert.strictEqual(hits[0].status, 'unguarded') + assert.strictEqual(hits[0].file, 'fixture.test.js') + assert.strictEqual(hits[0].line, 3) + }) + + it('does not credit a wait on a different request height', function () { + const hits = scan([ + 'async function check(request, otherRequest) {', + ' await waitForHeightWithClear(venue, 0, otherRequest.blockIndex)', + ' return readRequestRow(venue, 0, request.requestId)', + '}', + ]) + assert.strictEqual(hits[0].status, 'unguarded') + }) + + it('credits a dominating wait outside a loop and explains the loop path', function () { + const hits = scan([ + 'async function check(request) {', + ' await waitForHeightWithClear(venue, 0, request.blockIndex)', + ' for (const sample of samples) {', + ' sample.row = await readRequestRow(venue, 0, request.requestId)', + ' }', + '}', + ]) + assert.strictEqual(hits[0].status, 'guarded') + assert.match(hits[0].reason, /dominates the enclosing loop/) + }) + + it('does not let a conditional wait guard a read after the branch', function () { + const hits = scan([ + 'async function check(request, shouldWait) {', + ' if (shouldWait) {', + ' await waitForHeightWithClear(venue, 0, request.blockIndex)', + ' }', + ' return readRequestRow(venue, 0, request.requestId)', + '}', + ]) + assert.strictEqual(hits[0].status, 'unguarded') + }) + + it('reports an annotated deliberate exception instead of calling it clean', function () { + const hits = scan([ + 'async function check(requestId) {', + ' // request-row-wait-ok: absence is the assertion in this poll.', + ' return readRequestRow(venue, 0, requestId)', + '}', + ]) + assert.strictEqual(hits[0].status, 'exception') + assert.match(hits[0].reason, /absence is the assertion/) + }) +}) + +describe('check-request-row-wait calibration fixtures', function () { + it('recognises the ZC1 injected-reader shape as guarded', function () { + const hits = scan([ + 'async function waitForLocalRequestAtBlock(venue, indexerIndex, request, deps) {', + ' const d = deps || {}', + ' const waitForHeight = d.waitForHeight || waitForHeightWithClear', + ' const readRow = d.readRow || readRequestRow', + ' const requestId = String(request && request.requestId)', + ' const rawBlockIndex = request && request.blockIndex', + ' const blockIndex = Number(rawBlockIndex)', + ' await waitForHeight(venue, indexerIndex, blockIndex)', + ' return readRow(venue, indexerIndex, requestId)', + '}', + ]) + assert.strictEqual(hits.length, 1) + assert.strictEqual(hits[0].status, 'guarded') + }) + + it('recognises the AT3 mirror-row-only shape as unguarded', function () { + const hits = scan([ + 'async function driveToMirrorRow(requestId) {', + ' await waitForMirrorRowEverywhere(venue, requestId)', + ' const local = await readRequestRow(venue, 0, requestId)', + ' return local', + '}', + ]) + assert.strictEqual(hits.length, 1) + assert.strictEqual(hits[0].status, 'unguarded') + }) +}) + +describe('check-request-row-wait input refusal', function () { + it('exits one and names an unguarded request-row read', function () { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'request-row-wait-bad-')) + const file = path.join(dir, 'bad.js') + try { + fs.writeFileSync(file, 'async function check(requestId) {\n' + + ' return readRequestRow(venue, 0, requestId)\n}\n') + const result = run([file]) + assert.strictEqual(result.status, 1) + assert.match(result.stdout, /UNGUARDED .*bad\.js:2 identifier=requestId/) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + it('counts an explained exception without treating it as unguarded', function () { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'request-row-wait-exception-')) + const file = path.join(dir, 'exception.js') + try { + fs.writeFileSync(file, 'async function check(requestId) {\n' + + ' // request-row-wait-ok: absence is deliberately observed here.\n' + + ' return readRequestRow(venue, 0, requestId)\n}\n') + const result = run([file]) + assert.strictEqual(result.status, 0) + assert.match(result.stdout, /acknowledged-exceptions 1/) + assert.match(result.stdout, /EXCEPTION .*exception\.js:3 identifier=requestId/) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + it('exits nonzero and says zero files were read for an empty directory', function () { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'request-row-wait-empty-')) + try { + const result = run([dir]) + assert.strictEqual(result.status, 2) + assert.match(result.stderr, /read zero files/) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + it('distinguishes a missing path from an empty directory', function () { + const missing = path.join(os.tmpdir(), 'request-row-wait-missing-' + process.pid) + const result = run([missing]) + assert.strictEqual(result.status, 2) + assert.match(result.stderr, /input path does not exist/) + assert.doesNotMatch(result.stderr, /read zero files/) + }) + + it('refuses a file that was read but contains no request-row calls', function () { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'request-row-wait-none-')) + const file = path.join(dir, 'none.js') + try { + fs.writeFileSync(file, 'module.exports = true\n') + const result = run([file]) + assert.strictEqual(result.status, 2) + assert.match(result.stderr, /read 1 file\(s\) but found zero request-row reads/) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/test/unit/scripts/prepare_bf6_mixed_hub.test.js b/test/unit/scripts/prepare_bf6_mixed_hub.test.js new file mode 100644 index 00000000..388dcb28 --- /dev/null +++ b/test/unit/scripts/prepare_bf6_mixed_hub.test.js @@ -0,0 +1,71 @@ +'use strict' + +const assert = require('assert') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { execFileSync } = require('child_process') + +const prep = require('../../../scripts/prepare-bf6-mixed-hub') + +const ROOT = path.resolve(__dirname, '..', '..', '..', '..') +const BUILD_HUB = path.join(ROOT, 'xchain-hub') + +describe('BF6 mixed hub checkout preparer', function () { + this.timeout(60 * 1000) + + it('creates a detached real checkout at the pinned older upgrade state and removes it', function () { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'bf6-mixed-checkout-')) + // Beside the hub this test already clones from, never a counted number of + // levels above it. The sibling row is what every layout agrees on; absolute + // depth is not, so counting levels resolved to a real path in one lane + // worktree and to a nonexistent directory on the CI venue, which is how this + // case passed where it was written and failed the gate. + const modules = path.join(BUILD_HUB, 'node_modules') + const config = { + workspace, + stack: 'am-unit-1', + buildHub: BUILD_HUB, + source: BUILD_HUB, + modules, + } + let mixedRoot = null + try { + mixedRoot = prep.createMixedCheckout(config) + const hub = path.join(mixedRoot, 'xchain-hub') + assert.strictEqual(execFileSync('git', ['-C', hub, 'rev-parse', '--is-inside-work-tree'], { encoding: 'utf8' }).trim(), 'true') + assert.strictEqual(execFileSync('git', ['-C', hub, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), prep.MIXED_HUB_REVISION) + assert.strictEqual(require(path.join(hub, 'src', 'hub_schema_version.js')).HUB_SCHEMA_VERSION, 6) + assert.strictEqual(fs.lstatSync(path.join(hub, 'node_modules')).isSymbolicLink(), true) + assert.strictEqual(prep.removeMixedCheckout(config), true) + assert.strictEqual(fs.existsSync(mixedRoot), false) + mixedRoot = null + } finally { + if (mixedRoot && fs.existsSync(mixedRoot)) prep.removeMixedCheckout(config) + fs.rmSync(workspace, { recursive: true, force: true }) + } + }) + + it('refuses an unmarked directory instead of deleting an unknown tree', function () { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'bf6-mixed-checkout-')) + const paths = prep.targetPaths(workspace, 'am-unit-2') + fs.mkdirSync(paths.root, { recursive: true }) + try { + assert.throws(() => prep.removeMixedCheckout({ workspace, stack: 'am-unit-2' }), /no BF6 checkout marker/) + assert.strictEqual(fs.existsSync(paths.root), true) + } finally { + fs.rmSync(workspace, { recursive: true, force: true }) + } + }) + + it('does not follow an archived worktree pointer when selecting the clone source', function () { + const buildHub = fs.mkdtempSync(path.join(os.tmpdir(), 'bf6-archived-hub-')) + fs.writeFileSync(path.join(buildHub, '.git'), 'gitdir: /unreachable/copied-worktree-pointer\n') + fs.writeFileSync(path.join(buildHub, 'package.json'), JSON.stringify({ repository: 'upstream-source' })) + try { + assert.strictEqual(prep.repositorySource(buildHub), 'upstream-source') + } finally { + fs.rmSync(buildHub, { recursive: true, force: true }) + } + }) +}) diff --git a/test/unit/scripts/sleepFlakeScanner.test.js b/test/unit/scripts/sleepFlakeScanner.test.js index ed7e2da6..ea420881 100644 --- a/test/unit/scripts/sleepFlakeScanner.test.js +++ b/test/unit/scripts/sleepFlakeScanner.test.js @@ -36,8 +36,7 @@ const scanner = require('../../../scripts/check-sleep-flake') const linesOf = (src) => scanner.scanSource(src, 'fixture.js').hits.map((h) => h.line) const countOf = (src) => scanner.scanSource(src, 'fixture.js').hits.length -describe('check-sleep-flake scanner', function () { - +function registerFixedSettleTests() { describe('counts a fixed settle', function () { it('counts the helper form', function () { @@ -69,7 +68,9 @@ describe('check-sleep-flake scanner', function () { ].join('\n')), 2) }) }) +} +function registerPollIntervalTests() { describe('does not count a poll interval', function () { it('skips the inline form as the last step of a re-checking loop', function () { @@ -91,7 +92,9 @@ describe('check-sleep-flake scanner', function () { ].join('\n')), 0) }) }) +} +function registerNonWaitTests() { describe('does not count things that are not waits on a duration', function () { it('skips a sleep helper definition', function () { @@ -120,7 +123,9 @@ describe('check-sleep-flake scanner', function () { ].join('\n')), 0) }) }) +} +function registerOffsetHandlingTests() { describe('reads each hit at its own offset', function () { it('counts both waits when two share a line', function () { @@ -142,76 +147,86 @@ describe('check-sleep-flake scanner', function () { ].join('\n')), [2]) }) }) +} + +function registerLoopSettleTests() { + it('counts a settle inside a data-iteration loop', function () { + assert.deepStrictEqual(linesOf([ + 'async function t() {', + ' for (const c of cases) {', + ' await send(c)', + ' await sleep(5000)', + ' }', + '}', + ].join('\n')), [4]) + }) + it('counts the settle that FOLLOWS a brace-less poll loop on one line', function () { + // The poll interval is the loop body and ends at its own `;`; the + // second wait is a separate statement and separate debt. + assert.deepStrictEqual(linesOf([ + 'async function t() {', + ' while (!ready) await sleep(100); await sleep(5000)', + '}', + ].join('\n')), [2]) + }) + + it('counts a settle in a callback that merely sits in a loop body', function () { + // Crossing into a function body ends the exemption: the callback does + // not run as an iteration of the loop enclosing its definition. + assert.deepStrictEqual(linesOf([ + 'async function t() {', + ' while (!done) {', + ' if (await check()) break', + ' register(async () => { await sleep(3000) })', + ' await sleep(100)', + ' }', + '}', + ].join('\n')), [4]) + }) + + it('counts a `while (true)` body that can never leave', function () { + assert.strictEqual(countOf([ + 'async function t() {', + ' while (true) { await sleep(1000) }', + '}', + ].join('\n')), 1) + }) +} + +function registerLoopPollTests() { + it('still exempts the real poll shapes', function () { + const poll = (src) => countOf(src.join('\n')) + assert.strictEqual(poll([ + 'async function t() {', + ' while (true) { await sleep(1000); if (await check()) break }', + '}', + ]), 0, 'while (true) with an exit is a poll') + assert.strictEqual(poll([ + 'async function t() {', + ' for (let i = 0; i < 20; i++) { if (await ok()) break; await sleep(500) }', + '}', + ]), 0, 'bounded retry loop with an exit is a poll') + assert.strictEqual(poll([ + 'async function t() {', + ' do { await sleep(500) } while (!(await ok()))', + '}', + ]), 0, 'do/while reads its test from the tail') + }) +} + +function registerLoopClassificationTests() { describe('only a loop that re-checks a condition exempts its waits', function () { // A "lexically inside any loop" exemption is not the rule the header // states. Each case below counts 0 under that looser rule and each is a // plain fixed settle: the loop re-checks nothing, or the wait is not part // of the loop at all. - - it('counts a settle inside a data-iteration loop', function () { - assert.deepStrictEqual(linesOf([ - 'async function t() {', - ' for (const c of cases) {', - ' await send(c)', - ' await sleep(5000)', - ' }', - '}', - ].join('\n')), [4]) - }) - - it('counts the settle that FOLLOWS a brace-less poll loop on one line', function () { - // The poll interval is the loop body and ends at its own `;`; the - // second wait is a separate statement and separate debt. - assert.deepStrictEqual(linesOf([ - 'async function t() {', - ' while (!ready) await sleep(100); await sleep(5000)', - '}', - ].join('\n')), [2]) - }) - - it('counts a settle in a callback that merely sits in a loop body', function () { - // Crossing into a function body ends the exemption: the callback does - // not run as an iteration of the loop enclosing its definition. - assert.deepStrictEqual(linesOf([ - 'async function t() {', - ' while (!done) {', - ' if (await check()) break', - ' register(async () => { await sleep(3000) })', - ' await sleep(100)', - ' }', - '}', - ].join('\n')), [4]) - }) - - it('counts a `while (true)` body that can never leave', function () { - assert.strictEqual(countOf([ - 'async function t() {', - ' while (true) { await sleep(1000) }', - '}', - ].join('\n')), 1) - }) - - it('still exempts the real poll shapes', function () { - const poll = (src) => countOf(src.join('\n')) - assert.strictEqual(poll([ - 'async function t() {', - ' while (true) { await sleep(1000); if (await check()) break }', - '}', - ]), 0, 'while (true) with an exit is a poll') - assert.strictEqual(poll([ - 'async function t() {', - ' for (let i = 0; i < 20; i++) { if (await ok()) break; await sleep(500) }', - '}', - ]), 0, 'bounded retry loop with an exit is a poll') - assert.strictEqual(poll([ - 'async function t() {', - ' do { await sleep(500) } while (!(await ok()))', - '}', - ]), 0, 'do/while reads its test from the tail') - }) + registerLoopSettleTests() + registerLoopPollTests() }) +} +function registerRatchetTests() { describe('the ratchet gate', function () { it('counts every site in a file whose braces do not balance', function () { @@ -227,4 +242,13 @@ describe('check-sleep-flake scanner', function () { assert.strictEqual(res.hits.length, 1) }) }) +} + +describe('check-sleep-flake scanner', function () { + registerFixedSettleTests() + registerPollIntervalTests() + registerNonWaitTests() + registerOffsetHandlingTests() + registerLoopClassificationTests() + registerRatchetTests() }) diff --git a/test/unit/scripts/stubbed_lane_hermetic.test.js b/test/unit/scripts/stubbed_lane_hermetic.test.js new file mode 100644 index 00000000..757b0a4d --- /dev/null +++ b/test/unit/scripts/stubbed_lane_hermetic.test.js @@ -0,0 +1,103 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// The stubbed integration lane (package.json `test:integration:stubbed`, and the +// phase-2 mutation spec built on it) must stay hermetic: a live suite in it skips +// itself on a host without Docker and the lane still reports a number. A live +// suite split into a same-named `.test/` directory lands one directory deep, +// exactly where the stubbed glob looks, so these guards resolve the real selection +// with mocha's own file collector and check it by location and by content. They +// also pin the other half of that exclusion: every split part of a live root is +// reached from its root, or excluding its directory drops it from every lane. + +const assert = require('assert') +const fs = require('fs') +const path = require('path') +const { pathToFileURL } = require('url') +const collectFiles = require('mocha/lib/cli/collect-files') + +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..') +const INTEGRATION_DIR = path.join(REPO_ROOT, 'test', 'integration') +const LIVE_MARKERS = /require\([^)]*\b(disposableHubDb|multiValidatorHubHelper)\b/ + +// Resolve a mocha spec/ignore selection to sorted repo-relative paths. +function resolveSelection(spec, ignore) { + const out = collectFiles({ spec, ignore, extension: ['js'], file: [], recursive: false, sort: false }) + const files = Array.isArray(out) ? out : out.files + return files.map(f => path.relative(REPO_ROOT, f).split(path.sep).join('/')).sort() +} + +// Read the spec and --ignore globs out of the npm stubbed script. +function stubbedScriptSelection() { + const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')) + const script = pkg.scripts['test:integration:stubbed'] + const ignore = [...script.matchAll(/--ignore\s+'([^']+)'/g)].map(m => m[1]) + const spec = [...script.matchAll(/'([^']+)'/g)].map(m => m[1]).filter(g => !ignore.includes(g)) + return resolveSelection(spec, ignore) +} + +// Local relative requires of one file, resolved to absolute .js paths. +function localRequires(file) { + const src = fs.readFileSync(file, 'utf8') + return [...src.matchAll(/require\((['"])(\.{1,2}\/[^'"]+)\1\)/g)] + .map(m => path.resolve(path.dirname(file), m[2])) + .map(p => (p.endsWith('.js') ? p : p + '.js')) + .filter(p => fs.existsSync(p)) +} + +// Every file reachable from `entry` through local relative requires. +function reachableFrom(entry) { + const seen = new Set([entry]) + const queue = [entry] + while (queue.length) { + for (const next of localRequires(queue.shift())) { + if (!seen.has(next)) { seen.add(next); queue.push(next) } + } + } + return seen +} + +describe('stubbed integration lane stays hermetic', () => { + + it('selects no split-part directory and no file that requires a live helper', () => { + const files = stubbedScriptSelection() + assert.ok(files.length > 0, 'the stubbed lane resolved to no files, so it proves nothing') + const splitParts = files.filter(f => /^test\/integration\/[^/]+\.test\//.test(f)) + assert.deepStrictEqual(splitParts, [], + 'the stubbed lane selects split parts of live suites:\n ' + splitParts.join('\n ')) + const live = files.filter(f => LIVE_MARKERS.test(fs.readFileSync(path.join(REPO_ROOT, f), 'utf8'))) + assert.deepStrictEqual(live, [], + 'the stubbed lane selects files that provision a live database or hub mesh:\n ' + live.join('\n ')) + }) + + it('phase-2 mutation spec resolves to the same integration files as the npm script', async () => { + const config = (await import(pathToFileURL(path.join(REPO_ROOT, 'stryker.phase2.config.mjs')).href)).default + const { spec, ignore = [] } = config.mochaOptions + const integration = resolveSelection(spec, ignore).filter(f => f.startsWith('test/integration/')) + assert.deepStrictEqual(integration, stubbedScriptSelection(), + 'stryker.phase2.config.mjs and package.json test:integration:stubbed select different integration files') + }) + + it('every split part of a live root is reached from that root', () => { + const orphans = [] + for (const dir of fs.readdirSync(INTEGRATION_DIR).filter(d => d.endsWith('.integration.test'))) { + const root = path.join(INTEGRATION_DIR, dir + '.js') + const reached = fs.existsSync(root) ? reachableFrom(root) : new Set() + for (const part of fs.readdirSync(path.join(INTEGRATION_DIR, dir)).filter(f => f.endsWith('.test.js'))) { + if (!reached.has(path.join(INTEGRATION_DIR, dir, part))) orphans.push(dir + '/' + part) + } + } + assert.deepStrictEqual(orphans, [], + 'split parts that no root requires run in no lane once the stubbed lane ignores them:\n ' + + orphans.join('\n ')) + }) +}) diff --git a/test/unit/scripts/wait_attest_mirror_stack.test.js b/test/unit/scripts/wait_attest_mirror_stack.test.js new file mode 100644 index 00000000..4742386d --- /dev/null +++ b/test/unit/scripts/wait_attest_mirror_stack.test.js @@ -0,0 +1,141 @@ +'use strict' + +const assert = require('assert') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { spawnSync } = require('child_process') +const ready = require('../../../scripts/wait-attest-mirror-stack') + +const HELPER = path.join(__dirname, '..', '..', '..', 'scripts', 'wait-attest-mirror-stack.js') +const fakeMariadb = { + createConnection: async () => ({ + query: async () => [{ n: ready.REQUIRED_INDEXER_TABLES.length }], + end: async () => {}, + }), +} + +function config (overrides) { + return Object.assign({ + dbHost: '127.0.0.1', dbPort: 1, dbUser: 'root', dbPassword: 'secret', + indexerDbName: 'indexer', indexerStatusUrl: 'http://indexer/status', + minerHealthUrls: ['http://miner-a/', 'http://miner-b/'], + requiredIndexerTables: ready.REQUIRED_INDEXER_TABLES, + timeoutMs: 20, intervalMs: 0, sleep: async () => {}, + connect: async () => ({ query: async () => [{ n: ready.REQUIRED_INDEXER_TABLES.length }], end: async () => {} }), + fetchImpl: async () => ({ ok: true }), + }, overrides || {}) +} + +describe('attest-mirror stack readiness', function () { + let fixtureRoot + + beforeEach(function () { + fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'attest-mirror-ready-')) + fs.mkdirSync(path.join(fixtureRoot, 'scripts')) + fs.copyFileSync(HELPER, path.join(fixtureRoot, 'scripts', 'wait-attest-mirror-stack.js')) + }) + + afterEach(function () { + fs.rmSync(fixtureRoot, { recursive: true, force: true }) + }) + + function runFixture (env) { + return spawnSync(process.execPath, [path.join(fixtureRoot, 'scripts', 'wait-attest-mirror-stack.js')], { + cwd: fixtureRoot, + env: Object.assign({}, env), + encoding: 'utf8', + }) + } + + function installFakeMariadb () { + const moduleDir = path.join(fixtureRoot, 'node_modules', 'mariadb') + fs.mkdirSync(moduleDir, { recursive: true }) + fs.writeFileSync(path.join(moduleDir, 'index.js'), [ + "'use strict'", + 'global.fetch = async () => ({ ok: true })', + 'module.exports = {', + ' createConnection: async () => ({', + ' query: async () => [{ n: 8 }],', + ' end: async () => {},', + ' }),', + '}', + '', + ].join('\n')) + } + + it('diagnoses a missing dependency in one line and exits non-zero without exposing credential values', function () { + const credentialCanary = ['credential', 'canary', String(Date.now())].join('-') + const result = runFixture({ + DB_PASSWORD: credentialCanary, + SERVICE_PASS: credentialCanary, + SERVICE_SECRET: credentialCanary, + }) + const expected = 'Missing dependency "mariadb" in ' + fs.realpathSync(fixtureRoot) + + '; install dependencies in that tree or point the driver at the staged tree that has them.\n' + assert.strictEqual(result.status, 1) + assert.strictEqual(result.stdout, '') + assert.strictEqual(result.stderr, expected) + assert.strictEqual(result.stderr.split('\n').filter(Boolean).length, 1) + assert.ok(!result.stderr.includes(credentialCanary)) + }) + + it('adds no diagnostic output and follows the ready path when dependencies resolve', function () { + installFakeMariadb() + const result = runFixture({ + DB_HOST_PORT: '62000', DB_PASSWORD: 'runtime-only', INDEXER_HOST_PORT: '62005', + MINER_HOST_PORT: '62006', LTC_REGTEST_MINER_API_PORT: '62015', DOGE_REGTEST_MINER_API_PORT: '62021', + }) + assert.strictEqual(result.status, 0) + assert.strictEqual(result.stderr, '') + assert.strictEqual(result.stdout, 'attest-mirror stack ready: indexer schema and 3 miner health endpoint(s)\n') + }) + + it('does not declare readiness when stack configuration is absent', function () { + installFakeMariadb() + const result = runFixture({}) + assert.strictEqual(result.status, 1) + assert.strictEqual(result.stdout, '') + assert.strictEqual(result.stderr, 'missing stack readiness value dbPort\n') + }) + + it('requires the issues schema, indexer status and every miner health endpoint', async function () { + const calls = [] + const c = config({ fetchImpl: async (url, init) => { calls.push({ url, init }); return { ok: true } } }) + assert.strictEqual(await ready.stackReady(c), true) + assert.deepStrictEqual(calls.map((call) => call.url), + ['http://indexer/status', 'http://miner-a/', 'http://miner-b/']) + assert.strictEqual(calls[1].init.method, 'POST') + assert.match(calls[1].init.body, /"method":"health"/) + }) + + it('does not probe services until the indexer schema exists', async function () { + let fetched = false + const c = config({ + connect: async () => ({ query: async () => [{ n: ready.REQUIRED_INDEXER_TABLES.length - 1 }], end: async () => {} }), + fetchImpl: async () => { fetched = true; return { ok: true } }, + }) + assert.strictEqual(await ready.stackReady(c), false) + assert.strictEqual(fetched, false) + }) + + it('waits through a miner race and completes only after health is green', async function () { + let minerReads = 0 + const c = config({ fetchImpl: async (url) => { + if (url.includes('miner')) minerReads++ + return { ok: !url.includes('miner') || minerReads > 1 } + } }) + await ready.waitForStack(c) + assert.ok(minerReads >= 2) + }) + + it('reads all three miner ports from a three-coin stack environment', function () { + const c = ready.configFromEnv({ + DB_HOST_PORT: '62000', DB_PASSWORD: 'secret', INDEXER_HOST_PORT: '62005', + MINER_HOST_PORT: '62006', LTC_REGTEST_MINER_API_PORT: '62015', DOGE_REGTEST_MINER_API_PORT: '62021', + }, { mariadb: fakeMariadb }, async () => ({ ok: true })) + assert.strictEqual(c.indexerStatusUrl, 'http://127.0.0.1:62005/status') + assert.deepStrictEqual(c.minerHealthUrls, + ['http://127.0.0.1:62006/', 'http://127.0.0.1:62015/', 'http://127.0.0.1:62021/']) + }) +}) diff --git a/test/unit/src/XChainHubConnector.test.js b/test/unit/src/XChainHubConnector.test.js index b506fa17..899baee6 100644 --- a/test/unit/src/XChainHubConnector.test.js +++ b/test/unit/src/XChainHubConnector.test.js @@ -16,6 +16,8 @@ const axios = require('axios'); const XChainHubConnector = require('../../../src/XChainHubConnector'); +let axiosPostStub; + // Build an Axios-style error for a non-2xx response that still carries a valid // JSON-RPC body, e.g. the hub's HTTP 503 "degraded" health response when its DB // pool is down. Axios attaches the full response to the thrown error as err.response. @@ -28,298 +30,317 @@ function degraded503Error(body) { return err; } -describe('XChainHubConnector', function () { +function registerCallBehaviorTests() { + it('returns result when axios succeeds', async function () { + const conn = new XChainHubConnector(['http://hub1:10000']); + axiosPostStub.resolves({ data: { result: 'pong' } }); + + const result = await conn._call({ method: 'ping' }); + assert.strictEqual(result, 'pong'); + }); - let axiosPostStub; + it('returns null when axios throws a generic error', async function () { + const conn = new XChainHubConnector(['http://hub1:10000']); + axiosPostStub.rejects(new Error('ECONNREFUSED')); - beforeEach(function () { - axiosPostStub = sinon.stub(axios, 'post'); + const result = await conn._call({ method: 'test' }); + assert.strictEqual(result, null); }); - afterEach(function () { - sinon.restore(); - // Clean any env vars set during tests - delete process.env.HUB_VALIDATORS; - delete process.env.HUB_URL; - delete process.env.HUB_API_HOST; - delete process.env.HUB_PORT; + it('returns the result even when it is falsy (0, false, "")', async function () { + // The source checks `response.data.result !== undefined`. Falsy truths count + // and must not be discarded. + const conn = new XChainHubConnector(['http://hub1:10000']); + axiosPostStub.resolves({ data: { result: false } }); + + const result = await conn._call({ method: 'test' }); + assert.strictEqual(result, false); }); - describe('_call', function () { - it('returns result when axios succeeds', async function () { - const conn = new XChainHubConnector(['http://hub1:10000']); - axiosPostStub.resolves({ data: { result: 'pong' } }); + it('surfaces the JSON-RPC body of a 503 "degraded" response instead of discarding it', async function () { + const conn = new XChainHubConnector(['http://hub1:10000']); + axiosPostStub.rejects(degraded503Error()); - const result = await conn._call({ method: 'ping' }); - assert.strictEqual(result, 'pong'); - }); + const result = await conn._call({ method: 'ping' }); + // The reachable-but-degraded body is returned, not null. + assert.deepStrictEqual(result, { status: 'degraded', db: false }); + }); - it('returns null when axios throws a generic error', async function () { - const conn = new XChainHubConnector(['http://hub1:10000']); - axiosPostStub.rejects(new Error('ECONNREFUSED')); + it('prefers a healthy endpoint over a degraded one when both are present', async function () { + const conn = new XChainHubConnector(['http://hub1:10000', 'http://hub2:10000']); + axiosPostStub + .onFirstCall().rejects(degraded503Error()) + .onSecondCall().resolves({ data: { result: 'pong' } }); - const result = await conn._call({ method: 'test' }); - assert.strictEqual(result, null); - }); + const result = await conn._call({ method: 'ping' }); + assert.strictEqual(result, 'pong'); + assert.strictEqual(axiosPostStub.callCount, 2); + }); +} - it('returns the result even when it is falsy (0, false, "")', async function () { - // The source checks `response.data.result !== undefined`. Falsy truths count - // and must not be discarded. - const conn = new XChainHubConnector(['http://hub1:10000']); - axiosPostStub.resolves({ data: { result: false } }); +function registerPingBehaviorTests() { + it('returns true when _call returns a non-null value', async function () { + const conn = new XChainHubConnector(['http://hub1:10000']); + sinon.stub(conn, '_call').resolves('pong'); - const result = await conn._call({ method: 'test' }); - assert.strictEqual(result, false); - }); + const result = await conn.ping(); + assert.strictEqual(result, true); + }); - it('surfaces the JSON-RPC body of a 503 "degraded" response instead of discarding it', async function () { - const conn = new XChainHubConnector(['http://hub1:10000']); - axiosPostStub.rejects(degraded503Error()); + it('returns false when _call returns null', async function () { + const conn = new XChainHubConnector(['http://hub1:10000']); + sinon.stub(conn, '_call').resolves(null); - const result = await conn._call({ method: 'ping' }); - // The reachable-but-degraded body is returned, not null. - assert.deepStrictEqual(result, { status: 'degraded', db: false }); - }); + const result = await conn.ping(); + assert.strictEqual(result, false); + }); - it('prefers a healthy endpoint over a degraded one when both are present', async function () { - const conn = new XChainHubConnector(['http://hub1:10000', 'http://hub2:10000']); - axiosPostStub - .onFirstCall().rejects(degraded503Error()) - .onSecondCall().resolves({ data: { result: 'pong' } }); + it('returns true (reachable) for a degraded hub rather than masking it as down', async function () { + // A live hub with a dead DB pool must NOT read the same as a crashed one. + const conn = new XChainHubConnector(['http://hub1:10000']); + sinon.stub(conn, '_call').resolves({ status: 'degraded', db: false }); - const result = await conn._call({ method: 'ping' }); - assert.strictEqual(result, 'pong'); - assert.strictEqual(axiosPostStub.callCount, 2); - }); + const result = await conn.ping(); + assert.strictEqual(result, true); }); - describe('ping', function () { - it('returns true when _call returns a non-null value', async function () { - const conn = new XChainHubConnector(['http://hub1:10000']); - sinon.stub(conn, '_call').resolves('pong'); + it('sends JSON-RPC ping payload', async function () { + const conn = new XChainHubConnector(['http://hub1:10000']); + const callStub = sinon.stub(conn, '_call').resolves('pong'); - const result = await conn.ping(); - assert.strictEqual(result, true); - }); + await conn.ping(); - it('returns false when _call returns null', async function () { - const conn = new XChainHubConnector(['http://hub1:10000']); - sinon.stub(conn, '_call').resolves(null); + const [data] = callStub.firstCall.args; + assert.strictEqual(data.jsonrpc, '2.0'); + assert.strictEqual(data.method, 'ping'); + assert.strictEqual(data.id, 1); + }); +} - const result = await conn.ping(); - assert.strictEqual(result, false); - }); +function registerCredentialTierRequestTests() { + // The hub withholds every password unless the call asks for the credential + // tier, substituting the literal '[redacted]'. Asking with an empty params + // array hands the harness that sentinel wherever a node or indexer database + // password belongs, so the ask itself is the contract under test. + it('asks for the credential tier, not a bare params array', async function () { + const conn = new XChainHubConnector(['http://hub1:10000']); + const callStub = sinon.stub(conn, '_call').resolves({ bitcoin: {} }); - it('returns true (reachable) for a degraded hub rather than masking it as down', async function () { - // A live hub with a dead DB pool must NOT read the same as a crashed one. - const conn = new XChainHubConnector(['http://hub1:10000']); - sinon.stub(conn, '_call').resolves({ status: 'degraded', db: false }); + await conn.getAllConfig(); - const result = await conn.ping(); - assert.strictEqual(result, true); - }); + const [data] = callStub.firstCall.args; + assert.strictEqual(data.method, 'getallconfigs'); + assert.deepStrictEqual(data.params, { include_secrets: true }); + }); - it('sends JSON-RPC ping payload', async function () { - const conn = new XChainHubConnector(['http://hub1:10000']); - const callStub = sinon.stub(conn, '_call').resolves('pong'); + it('falls back to the bare params array when the tiered ask yields nothing', async function () { + // A hub that denies the tier, or predates it, must still serve + // coordinates; only the credentials are lost. + const conn = new XChainHubConnector(['http://hub1:10000']); + const callStub = sinon.stub(conn, '_call'); + // An endpoint answered and refused, which is what a hub denying the + // credential tier looks like; that is the only state worth a retry. + callStub.onFirstCall().callsFake(async () => { conn.lastHttpRefusal = true; return null; }); + callStub.onSecondCall().resolves({ bitcoin: { regtest: {} } }); + + const result = await conn.getAllConfig(); + + assert.deepStrictEqual(callStub.secondCall.args[0].params, []); + assert.deepStrictEqual(result, { bitcoin: { regtest: {} } }); + assert.strictEqual(conn.lastConfigSecretsRedacted, true); + }); - await conn.ping(); + it('does not retry when nothing answered, so the endpoint attempt count holds', async function () { + // test/boundary/globalState.boundary.js counts the attempts a + // dead-hub run makes; a blind retry doubles them. + const conn = new XChainHubConnector(['http://hub1:10000']); + const callStub = sinon.stub(conn, '_call').resolves(null); - const [data] = callStub.firstCall.args; - assert.strictEqual(data.jsonrpc, '2.0'); - assert.strictEqual(data.method, 'ping'); - assert.strictEqual(data.id, 1); - }); + assert.strictEqual(await conn.getAllConfig(), null); + assert.strictEqual(callStub.callCount, 1); }); +} - describe('getAllConfig', function () { - // The hub withholds every password unless the call asks for the credential - // tier, substituting the literal '[redacted]'. Asking with an empty params - // array hands the harness that sentinel wherever a node or indexer database - // password belongs, so the ask itself is the contract under test. - it('asks for the credential tier, not a bare params array', async function () { - const conn = new XChainHubConnector(['http://hub1:10000']); - const callStub = sinon.stub(conn, '_call').resolves({ bitcoin: {} }); - - await conn.getAllConfig(); - - const [data] = callStub.firstCall.args; - assert.strictEqual(data.method, 'getallconfigs'); - assert.deepStrictEqual(data.params, { include_secrets: true }); - }); - - it('falls back to the bare params array when the tiered ask yields nothing', async function () { - // A hub that denies the tier, or predates it, must still serve - // coordinates; only the credentials are lost. - const conn = new XChainHubConnector(['http://hub1:10000']); - const callStub = sinon.stub(conn, '_call'); - // An endpoint answered and refused, which is what a hub denying the - // credential tier looks like; that is the only state worth a retry. - callStub.onFirstCall().callsFake(async () => { conn.lastHttpRefusal = true; return null; }); - callStub.onSecondCall().resolves({ bitcoin: { regtest: {} } }); - - const result = await conn.getAllConfig(); - - assert.deepStrictEqual(callStub.secondCall.args[0].params, []); - assert.deepStrictEqual(result, { bitcoin: { regtest: {} } }); - assert.strictEqual(conn.lastConfigSecretsRedacted, true); - }); - - it('does not retry when nothing answered, so the endpoint attempt count holds', async function () { - // test/boundary/globalState.boundary.js counts the attempts a - // dead-hub run makes; a blind retry doubles them. - const conn = new XChainHubConnector(['http://hub1:10000']); - const callStub = sinon.stub(conn, '_call').resolves(null); - - assert.strictEqual(await conn.getAllConfig(), null); - assert.strictEqual(callStub.callCount, 1); - }); - - it('records that secrets were served when the envelope says so', async function () { - const conn = new XChainHubConnector(['http://hub1:10000']); - const configs = { bitcoin: { regtest: { node: { pass: 'rpcpass' } } } }; - sinon.stub(conn, '_call').resolves({ configs, seq: 7, watermark: 7, secrets_redacted: false }); - - await conn.getAllConfig(); - assert.strictEqual(conn.lastConfigSecretsRedacted, false); - }); - - it('treats an envelope with no secrets_redacted flag as redacted', async function () { - const conn = new XChainHubConnector(['http://hub1:10000']); - sinon.stub(conn, '_call').resolves({ configs: { bitcoin: {} }, seq: 1, watermark: 1 }); - - await conn.getAllConfig(); - assert.strictEqual(conn.lastConfigSecretsRedacted, true); - }); - - it('sends the credential-tier key on a secrets ask and the bulk key otherwise', async function () { - const conn = new XChainHubConnector(['http://hub1:10000']); - process.env.HUB_API_KEY = 'bulk-key'; - process.env.HUB_CONFIG_SECRETS_API_KEY = 'secrets-key'; - try { - axiosPostStub.resolves({ data: { result: { bitcoin: {} } } }); - - await conn._call({ method: 'getallconfigs', params: { include_secrets: true }, id: 1 }); - assert.strictEqual(axiosPostStub.firstCall.args[2].headers['x-api-key'], 'secrets-key'); - - await conn._call({ method: 'ping', params: [], id: 1 }); - assert.strictEqual(axiosPostStub.secondCall.args[2].headers['x-api-key'], 'bulk-key'); - } finally { - delete process.env.HUB_API_KEY; - delete process.env.HUB_CONFIG_SECRETS_API_KEY; - } - }); - - it('falls back to the bulk key for a secrets ask when no tier key is set', async function () { - const conn = new XChainHubConnector(['http://hub1:10000']); - process.env.HUB_API_KEY = 'bulk-key'; +function registerCredentialTierConfigurationTests() { + it('records that secrets were served when the envelope says so', async function () { + const conn = new XChainHubConnector(['http://hub1:10000']); + const configs = { bitcoin: { regtest: { node: { pass: 'rpcpass' } } } }; + sinon.stub(conn, '_call').resolves({ configs, seq: 7, watermark: 7, secrets_redacted: false }); + + await conn.getAllConfig(); + assert.strictEqual(conn.lastConfigSecretsRedacted, false); + }); + + it('treats an envelope with no secrets_redacted flag as redacted', async function () { + const conn = new XChainHubConnector(['http://hub1:10000']); + sinon.stub(conn, '_call').resolves({ configs: { bitcoin: {} }, seq: 1, watermark: 1 }); + + await conn.getAllConfig(); + assert.strictEqual(conn.lastConfigSecretsRedacted, true); + }); + + it('sends the credential-tier key on a secrets ask and the bulk key otherwise', async function () { + const conn = new XChainHubConnector(['http://hub1:10000']); + process.env.HUB_API_KEY = 'bulk-key'; + process.env.HUB_CONFIG_SECRETS_API_KEY = 'secrets-key'; + try { + axiosPostStub.resolves({ data: { result: { bitcoin: {} } } }); + + await conn._call({ method: 'getallconfigs', params: { include_secrets: true }, id: 1 }); + assert.strictEqual(axiosPostStub.firstCall.args[2].headers['x-api-key'], 'secrets-key'); + + await conn._call({ method: 'ping', params: [], id: 1 }); + assert.strictEqual(axiosPostStub.secondCall.args[2].headers['x-api-key'], 'bulk-key'); + } finally { + delete process.env.HUB_API_KEY; delete process.env.HUB_CONFIG_SECRETS_API_KEY; - try { - axiosPostStub.resolves({ data: { result: { bitcoin: {} } } }); - await conn._call({ method: 'getallconfigs', params: { include_secrets: true }, id: 1 }); - assert.strictEqual(axiosPostStub.firstCall.args[2].headers['x-api-key'], 'bulk-key'); - } finally { - delete process.env.HUB_API_KEY; - } - }); - - it('refuses the redaction sentinel where a credential is expected', function () { - assert.strictEqual(XChainHubConnector.REDACTED, '[redacted]'); - assert.strictEqual( - XChainHubConnector.assertUnredactedCredential('realpass', 'the node RPC password'), - 'realpass'); - assert.throws( - () => XChainHubConnector.assertUnredactedCredential('[redacted]', 'the node RPC password'), - /HUB_CONFIG_SECRETS_API_KEY/); - }); - - it('returns whatever _call returns', async function () { - const conn = new XChainHubConnector(['http://hub1:10000']); - const configs = { bitcoin: { regtest: {} } }; - sinon.stub(conn, '_call').resolves(configs); - - const result = await conn.getAllConfig(); - assert.deepStrictEqual(result, configs); - }); - - it('unwraps the { configs, seq, watermark } envelope to the flat tree', async function () { - const conn = new XChainHubConnector(['http://hub1:10000']); - const configs = { bitcoin: { regtest: { node: { server_port: 18443 } } } }; - sinon.stub(conn, '_call').resolves({ configs, seq: 7, watermark: 7 }); - - const result = await conn.getAllConfig(); - assert.deepStrictEqual(result, configs); - }); - - it('returns null when _call returns null', async function () { - const conn = new XChainHubConnector(['http://hub1:10000']); - sinon.stub(conn, '_call').resolves(null); - - const result = await conn.getAllConfig(); - assert.strictEqual(result, null); - }); - - it('returns null (not the degraded body) when the hub reports degraded', async function () { - // A {status:"degraded"} body is not a config tree. It must not be - // returned as one, or the caller would index into it as config. - const conn = new XChainHubConnector(['http://hub1:10000']); - sinon.stub(conn, '_call').resolves({ status: 'degraded', db: false }); - - const result = await conn.getAllConfig(); - assert.strictEqual(result, null); - }); - - it('returns null when the hub returns a {error} config body', async function () { - const conn = new XChainHubConnector(['http://hub1:10000']); - sinon.stub(conn, '_call').resolves({ error: 'there was an error trying to get all configs' }); - - const result = await conn.getAllConfig(); - assert.strictEqual(result, null); - }); + } + }); + + it('falls back to the bulk key for a secrets ask when no tier key is set', async function () { + const conn = new XChainHubConnector(['http://hub1:10000']); + process.env.HUB_API_KEY = 'bulk-key'; + delete process.env.HUB_CONFIG_SECRETS_API_KEY; + try { + axiosPostStub.resolves({ data: { result: { bitcoin: {} } } }); + await conn._call({ method: 'getallconfigs', params: { include_secrets: true }, id: 1 }); + assert.strictEqual(axiosPostStub.firstCall.args[2].headers['x-api-key'], 'bulk-key'); + } finally { + delete process.env.HUB_API_KEY; + } + }); + + it('refuses the redaction sentinel where a credential is expected', function () { + assert.strictEqual(XChainHubConnector.REDACTED, '[redacted]'); + assert.strictEqual( + XChainHubConnector.assertUnredactedCredential('realpass', 'the node RPC password'), + 'realpass'); + assert.throws( + () => XChainHubConnector.assertUnredactedCredential('[redacted]', 'the node RPC password'), + /HUB_CONFIG_SECRETS_API_KEY/); + }); +} + +function registerResponseShapeHandlingTests() { + it('returns whatever _call returns', async function () { + const conn = new XChainHubConnector(['http://hub1:10000']); + const configs = { bitcoin: { regtest: {} } }; + sinon.stub(conn, '_call').resolves(configs); + + const result = await conn.getAllConfig(); + assert.deepStrictEqual(result, configs); + }); + + it('unwraps the { configs, seq, watermark } envelope to the flat tree', async function () { + const conn = new XChainHubConnector(['http://hub1:10000']); + const configs = { bitcoin: { regtest: { node: { server_port: 18443 } } } }; + sinon.stub(conn, '_call').resolves({ configs, seq: 7, watermark: 7 }); + + const result = await conn.getAllConfig(); + assert.deepStrictEqual(result, configs); + }); + + it('returns null when _call returns null', async function () { + const conn = new XChainHubConnector(['http://hub1:10000']); + sinon.stub(conn, '_call').resolves(null); + + const result = await conn.getAllConfig(); + assert.strictEqual(result, null); + }); + + it('returns null (not the degraded body) when the hub reports degraded', async function () { + // A {status:"degraded"} body is not a config tree. It must not be + // returned as one, or the caller would index into it as config. + const conn = new XChainHubConnector(['http://hub1:10000']); + sinon.stub(conn, '_call').resolves({ status: 'degraded', db: false }); + + const result = await conn.getAllConfig(); + assert.strictEqual(result, null); + }); + + it('returns null when the hub returns a {error} config body', async function () { + const conn = new XChainHubConnector(['http://hub1:10000']); + sinon.stub(conn, '_call').resolves({ error: 'there was an error trying to get all configs' }); + + const result = await conn.getAllConfig(); + assert.strictEqual(result, null); + }); +} + +function registerEndpointParsingTests() { + it('splits HUB_VALIDATORS by comma and returns an array of URLs', function () { + process.env.HUB_VALIDATORS = 'http://hub1:10000,http://hub2:10000'; + const result = XChainHubConnector.parseEndpoints(); + assert.deepStrictEqual(result, ['http://hub1:10000', 'http://hub2:10000']); + }); + + it('trims whitespace from each entry in HUB_VALIDATORS', function () { + process.env.HUB_VALIDATORS = ' hub1:10000 , hub2:10000 '; + const result = XChainHubConnector.parseEndpoints(); + assert.deepStrictEqual(result, ['http://hub1:10000', 'http://hub2:10000']); + }); + + it('does not prepend http:// when entry already starts with http', function () { + process.env.HUB_VALIDATORS = 'http://hub1:10000'; + const result = XChainHubConnector.parseEndpoints(); + assert.strictEqual(result[0], 'http://hub1:10000'); + }); + + it('filters out empty entries in HUB_VALIDATORS', function () { + process.env.HUB_VALIDATORS = 'hub1:10000,,hub2:10000'; + const result = XChainHubConnector.parseEndpoints(); + assert.strictEqual(result.length, 2); + }); + + it('falls back to HUB_URL + HUB_PORT when HUB_VALIDATORS is absent', function () { + process.env.HUB_URL = 'myhubhost'; + process.env.HUB_PORT = '20000'; + const result = XChainHubConnector.parseEndpoints(); + assert.deepStrictEqual(result, ['http://myhubhost:20000']); + }); + + it('falls back to HUB_API_HOST when HUB_URL is absent', function () { + process.env.HUB_API_HOST = 'apihubhost'; + process.env.HUB_PORT = '30000'; + const result = XChainHubConnector.parseEndpoints(); + assert.deepStrictEqual(result, ['http://apihubhost:30000']); + }); + + it('defaults to localhost:10000 when no env vars are set', function () { + const result = XChainHubConnector.parseEndpoints(); + assert.deepStrictEqual(result, ['http://localhost:10000']); + }); +} + +describe('XChainHubConnector', function () { + beforeEach(function () { + axiosPostStub = sinon.stub(axios, 'post'); + }); + + afterEach(function () { + sinon.restore(); + // Clean any env vars set during tests + delete process.env.HUB_VALIDATORS; + delete process.env.HUB_URL; + delete process.env.HUB_API_HOST; + delete process.env.HUB_PORT; + }); + + describe('_call', function () { + registerCallBehaviorTests(); + }); + + describe('ping', function () { + registerPingBehaviorTests(); + }); + + describe('getAllConfig', function () { + registerCredentialTierRequestTests(); + registerCredentialTierConfigurationTests(); + registerResponseShapeHandlingTests(); }); describe('parseEndpoints (static)', function () { - it('splits HUB_VALIDATORS by comma and returns an array of URLs', function () { - process.env.HUB_VALIDATORS = 'http://hub1:10000,http://hub2:10000'; - const result = XChainHubConnector.parseEndpoints(); - assert.deepStrictEqual(result, ['http://hub1:10000', 'http://hub2:10000']); - }); - - it('trims whitespace from each entry in HUB_VALIDATORS', function () { - process.env.HUB_VALIDATORS = ' hub1:10000 , hub2:10000 '; - const result = XChainHubConnector.parseEndpoints(); - assert.deepStrictEqual(result, ['http://hub1:10000', 'http://hub2:10000']); - }); - - it('does not prepend http:// when entry already starts with http', function () { - process.env.HUB_VALIDATORS = 'http://hub1:10000'; - const result = XChainHubConnector.parseEndpoints(); - assert.strictEqual(result[0], 'http://hub1:10000'); - }); - - it('filters out empty entries in HUB_VALIDATORS', function () { - process.env.HUB_VALIDATORS = 'hub1:10000,,hub2:10000'; - const result = XChainHubConnector.parseEndpoints(); - assert.strictEqual(result.length, 2); - }); - - it('falls back to HUB_URL + HUB_PORT when HUB_VALIDATORS is absent', function () { - process.env.HUB_URL = 'myhubhost'; - process.env.HUB_PORT = '20000'; - const result = XChainHubConnector.parseEndpoints(); - assert.deepStrictEqual(result, ['http://myhubhost:20000']); - }); - - it('falls back to HUB_API_HOST when HUB_URL is absent', function () { - process.env.HUB_API_HOST = 'apihubhost'; - process.env.HUB_PORT = '30000'; - const result = XChainHubConnector.parseEndpoints(); - assert.deepStrictEqual(result, ['http://apihubhost:30000']); - }); - - it('defaults to localhost:10000 when no env vars are set', function () { - const result = XChainHubConnector.parseEndpoints(); - assert.deepStrictEqual(result, ['http://localhost:10000']); - }); + registerEndpointParsingTests(); }); }); diff --git a/test/unit/src/xchain_explorer_connector.test.js b/test/unit/src/xchain_explorer_connector.test.js new file mode 100644 index 00000000..e145c237 --- /dev/null +++ b/test/unit/src/xchain_explorer_connector.test.js @@ -0,0 +1,105 @@ +'use strict'; + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert'); +const sinon = require('sinon'); +const axios = require('axios'); + +const XChainExplorerConnector = require('../../../src/XChainExplorerConnector'); + +const URL = 'localhost'; +const PORT = 18080; + +let connector; +let axiosPostStub; +let axiosGetStub; + +function setupConnector() { + axiosPostStub = sinon.stub(axios, 'post'); + axiosGetStub = sinon.stub(axios, 'get'); + connector = new XChainExplorerConnector(URL, PORT); +} + +function teardownConnector() { + sinon.restore(); +} + +describe('XChainExplorerConnector', function () { + beforeEach(setupConnector); + afterEach(teardownConnector); + describe('constructor', function () { + it('builds the URL as http://{url}:{port}', function () { + assert.strictEqual(connector.url, `http://${URL}:${PORT}`); + }); + }); +}); + +describe('XChainExplorerConnector', function () { + beforeEach(setupConnector); + afterEach(teardownConnector); + describe('ping', function () { + it('returns true when response.data.result is present', async function () { + axiosPostStub.resolves({ data: { result: 'pong' } }); + assert.strictEqual(await connector.ping(), true); + }); + + it('returns false when axios throws', async function () { + axiosPostStub.rejects(new Error('connection refused')); + assert.strictEqual(await connector.ping(), false); + }); + + // Without a per-request cap, an explorer that accepts the socket and + // never answers (the "503 with zero DB pools" venue left with no pool + // to answer FROM) left this pending forever. initialCheck.test.js + // requires this ping before any action test runs, and the suite runs + // under `mocha --timeout 0`, so nothing else in this stack would ever + // time the call out: a stuck explorer silently stalled the whole CI + // job (observed on the LTC/DOGE legs) instead of failing it fast the + // way the known "answers 503" shape does. + it('bounds the readiness probe with a request timeout', async function () { + axiosPostStub.resolves({ data: { result: 'pong' } }); + await connector.ping(); + const cfg = axiosPostStub.firstCall.args[2]; + assert.strictEqual(typeof cfg.timeout, 'number'); + assert.ok(cfg.timeout > 0); + }); + }); +}); + +describe('XChainExplorerConnector', function () { + beforeEach(setupConnector); + afterEach(teardownConnector); + describe('getFileRaw', function () { + it('returns status, headers and body from the response', async function () { + axiosGetStub.resolves({ + status: 200, + headers: { 'x-xchain-stored-form': 'raw' }, + data: Buffer.from('abc'), + }); + const result = await connector.getFileRaw('btc', 5); + assert.strictEqual(result.status, 200); + assert.deepStrictEqual(result.headers, { 'x-xchain-stored-form': 'raw' }); + assert.ok(result.body.equals(Buffer.from('abc'))); + }); + + // waitForServedFile (envelopeHelper.js) polls this against its own + // bounded deadline; that deadline is defeated if a single GET inside + // the loop can itself hang past it with no cap of its own. + it('bounds the file fetch with a request timeout', async function () { + axiosGetStub.resolves({ status: 200, headers: {}, data: Buffer.alloc(0) }); + await connector.getFileRaw('btc', 5); + const cfg = axiosGetStub.firstCall.args[1]; + assert.strictEqual(typeof cfg.timeout, 'number'); + assert.ok(cfg.timeout > 0); + }); + }); +});