From 71f153f15f62216e2d3f5d98da8ca1b6607ae583 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 09:37:14 -0700 Subject: [PATCH 01/54] docs(ctlseed): restore the anchor, timestamp and round-priority comments above the code they describe --- test/actions/ctlseed.test.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) 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') From 821955b9cc0020d9e0bae3a57db16f005a7596b6 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 15:44:58 -0700 Subject: [PATCH 02/54] test(e2e): split sleep scanner registrations Extract module-scope registration helpers while preserving titles, fixtures, assertions, comments, and registration order. Keep every helper under the structure limit and reduce the function-line finding count by two. --- test/unit/scripts/sleepFlakeScanner.test.js | 152 +++++++++++--------- 1 file changed, 88 insertions(+), 64 deletions(-) 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() }) From 84955deaf6828b625e03b7fd9a1f78e94df06baf Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 15:45:42 -0700 Subject: [PATCH 03/54] test(e2e): split hub connector registrations Extract synchronous registration helpers for hub connector behaviors while preserving test order, hooks, comments, and async bodies. Reduce the measured oversized-function count by two with no other structure-count changes. --- test/unit/src/XChainHubConnector.test.js | 545 ++++++++++++----------- 1 file changed, 283 insertions(+), 262 deletions(-) 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(); }); }); From c3dab61abeb2a8f4d240a2b910cf32fe7ef2f685 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 14:43:35 -0700 Subject: [PATCH 04/54] test: isolate attest mirror aggregate legs Run each attest-mirror leg through a capped fresh-stack lifecycle with explicit readiness and teardown. Pin the armed ZC5 boundary and allow archived source trees to carry verified revision manifests. --- package.json | 2 +- scripts/attest-mirror-stack-driver.sh | 64 +++++++ scripts/run-attest-mirror.js | 163 ++++++++++++++++++ scripts/wait-attest-mirror-stack.js | 102 +++++++++++ .../helpers/barrierFamilyFixture.js | 10 ++ test/attestMirror/zc5_flag_day.test.js | 27 +-- .../unit/helpers/barrierFamilyFixture.test.js | 20 +++ .../unit/scripts/attest_mirror_runner.test.js | 145 ++++++++++++++++ .../scripts/wait_attest_mirror_stack.test.js | 57 ++++++ 9 files changed, 577 insertions(+), 13 deletions(-) create mode 100755 scripts/attest-mirror-stack-driver.sh create mode 100644 scripts/run-attest-mirror.js create mode 100644 scripts/wait-attest-mirror-stack.js create mode 100644 test/unit/scripts/attest_mirror_runner.test.js create mode 100644 test/unit/scripts/wait_attest_mirror_stack.test.js diff --git a/package.json b/package.json index fe2f4c19..be38cfa1 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "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", diff --git a/scripts/attest-mirror-stack-driver.sh b/scripts/attest-mirror-stack-driver.sh new file mode 100755 index 00000000..ce9e9188 --- /dev/null +++ b/scripts/attest-mirror-stack-driver.sh @@ -0,0 +1,64 @@ +#!/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) + +case "$phase" in + up) + "${ATTEST_MIRROR_STACK_ROOT}/setup-stack.sh" "$number" "$base" + ;; + 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" + ;; + run) + "${ATTEST_MIRROR_STACK_ROOT}/run-leg.sh" "$stack" "$number" "$venue_base" "$leg" \ + EXPLORER_API_PORT="${ATTEST_MIRROR_EXPLORER_PORT:-46599}" E2E_STAKE_TEARDOWN=off + ;; + down) + if [ -f "${stack_dir}/compose.yml" ]; then + docker compose -p "$project" -f "${stack_dir}/compose.yml" down -v + fi + test -z "$(docker ps -aq --filter "label=com.docker.compose.project=${project}")" + test -z "$(docker volume ls -q --filter "label=com.docker.compose.project=${project}")" + test -z "$(docker network ls -q --filter "label=com.docker.compose.project=${project}")" + ;; + *) + printf 'unknown driver phase: %s\n' "$phase" >&2 + exit 2 + ;; +esac diff --git a/scripts/run-attest-mirror.js b/scripts/run-attest-mirror.js new file mode 100644 index 00000000..9c3a4d9e --- /dev/null +++ b/scripts/run-attest-mirror.js @@ -0,0 +1,163 @@ +'use strict' + +const assert = require('assert') +const fs = require('fs') +const path = require('path') +const { spawn } = require('child_process') + +const REPO_ROOT = path.resolve(__dirname, '..') +const MAX_CONCURRENCY = 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 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']], + ['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') + + 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 = [] + + async function worker (slot) { + for (;;) { + 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') + } + } + + await Promise.all(Array.from({ length: Math.min(concurrency, legs.length) }, (_, slot) => worker(slot))) + 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 selected = process.env.ATTEST_MIRROR_LEGS + ? process.env.ATTEST_MIRROR_LEGS.split(',').map((leg) => leg.trim()).filter(Boolean) + : process.argv.slice(2) + 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) + process.stdout.write('[attest-mirror] ' + (results.length - failed.length) + ' legs passed, ' + failed.length + ' failed\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/wait-attest-mirror-stack.js b/scripts/wait-attest-mirror-stack.js new file mode 100644 index 00000000..b98ca551 --- /dev/null +++ b/scripts/wait-attest-mirror-stack.js @@ -0,0 +1,102 @@ +'use strict' + +const mariadb = require('mariadb') + +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 rows = await connection.query( + 'SELECT COUNT(*) AS n FROM information_schema.tables WHERE table_schema = ? AND table_name = ?', + [config.indexerDbName, 'issues']) + return Number(rows[0].n) === 1 + } 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) { + 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', + 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: mariadb.createConnection, + fetchImpl: fetch, + sleep, + } +} + +async function main () { + const config = configFromEnv(process.env) + await waitForStack(config) + process.stdout.write('attest-mirror stack ready: indexer schema and ' + config.minerHealthUrls.length + ' miner health endpoint(s)\n') +} + +module.exports = { responseOk, indexerSchemaReady, stackReady, waitForStack, configFromEnv } + +if (require.main === module) { + main().catch((error) => { + process.stderr.write(error.message + '\n') + process.exitCode = 1 + }) +} diff --git a/test/attestMirror/helpers/barrierFamilyFixture.js b/test/attestMirror/helpers/barrierFamilyFixture.js index 13b97a3a..f36698fe 100644 --- a/test/attestMirror/helpers/barrierFamilyFixture.js +++ b/test/attestMirror/helpers/barrierFamilyFixture.js @@ -224,6 +224,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() } 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/unit/helpers/barrierFamilyFixture.test.js b/test/unit/helpers/barrierFamilyFixture.test.js index 1b5af0e6..f3bf80ae 100644 --- a/test/unit/helpers/barrierFamilyFixture.test.js +++ b/test/unit/helpers/barrierFamilyFixture.test.js @@ -139,6 +139,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/scripts/attest_mirror_runner.test.js b/test/unit/scripts/attest_mirror_runner.test.js new file mode 100644 index 00000000..0ee00988 --- /dev/null +++ b/test/unit/scripts/attest_mirror_runner.test.js @@ -0,0 +1,145 @@ +'use strict' + +const assert = require('assert') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { spawnSync } = require('child_process') + +const runner = require('../../../scripts/run-attest-mirror') + +function fixture () { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'attest-mirror-runner-')) + const driver = path.join(root, 'driver.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 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 (phase === 'ready') { if (!fs.existsSync(state)) process.exit(31); event('ready') }", + "if (phase === 'run') { event('run-start'); setTimeout(() => { event('run-end'); process.exit(leg.includes('fail') ? 7 : 0) }, 100) }", + "if (phase === 'down') { event('down'); if (fs.existsSync(state)) fs.unlinkSync(state) }", + ].join('\n')) + for (const relative of [ + path.join('test', 'attestMirror', 'a.test.js'), + path.join('test', 'attestMirror', 'b.test.js'), + path.join('test', 'attestMirror', 'barrier_family', 'c.test.js'), + ]) { + 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, log, state: path.join(root, 'state') } +} + +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.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('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/) + 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('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/wait_attest_mirror_stack.test.js b/test/unit/scripts/wait_attest_mirror_stack.test.js new file mode 100644 index 00000000..73058bc0 --- /dev/null +++ b/test/unit/scripts/wait_attest_mirror_stack.test.js @@ -0,0 +1,57 @@ +'use strict' + +const assert = require('assert') +const ready = require('../../../scripts/wait-attest-mirror-stack') + +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/'], + timeoutMs: 20, intervalMs: 0, sleep: async () => {}, + connect: async () => ({ query: async () => [{ n: 1 }], end: async () => {} }), + fetchImpl: async () => ({ ok: true }), + }, overrides || {}) +} + +describe('attest-mirror stack readiness', function () { + 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: 0 }], 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', + }) + 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/']) + }) +}) From 5d65e940f5664fe8e7c93720c035293667962a30 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 14:48:44 -0700 Subject: [PATCH 05/54] test: seed fresh attest mirror stacks Wait for the complete indexer schema before a leg begins and seed an adoptable attestation roster on every fresh stack. Keep the readiness and teardown lifecycle covered by the aggregate runner tests. --- scripts/attest-mirror-stack-driver.sh | 5 +++++ scripts/run-attest-mirror.js | 1 + scripts/wait-attest-mirror-stack.js | 15 +++++++++++---- test/unit/scripts/attest_mirror_runner.test.js | 2 ++ .../unit/scripts/wait_attest_mirror_stack.test.js | 5 +++-- 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/scripts/attest-mirror-stack-driver.sh b/scripts/attest-mirror-stack-driver.sh index ce9e9188..d289e86f 100755 --- a/scripts/attest-mirror-stack-driver.sh +++ b/scripts/attest-mirror-stack-driver.sh @@ -45,6 +45,11 @@ case "$phase" in 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) "${ATTEST_MIRROR_STACK_ROOT}/run-leg.sh" "$stack" "$number" "$venue_base" "$leg" \ EXPLORER_API_PORT="${ATTEST_MIRROR_EXPLORER_PORT:-46599}" E2E_STAKE_TEARDOWN=off diff --git a/scripts/run-attest-mirror.js b/scripts/run-attest-mirror.js index 9c3a4d9e..8a19ab84 100644 --- a/scripts/run-attest-mirror.js +++ b/scripts/run-attest-mirror.js @@ -84,6 +84,7 @@ async function runLeg (driver, job, options) { 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) diff --git a/scripts/wait-attest-mirror-stack.js b/scripts/wait-attest-mirror-stack.js index b98ca551..ba3a4b88 100644 --- a/scripts/wait-attest-mirror-stack.js +++ b/scripts/wait-attest-mirror-stack.js @@ -2,6 +2,10 @@ const mariadb = require('mariadb') +const REQUIRED_INDEXER_TABLES = Object.freeze([ + 'actions', 'addresses', 'issues', 'price_snapshots', 'statuses', 'ticks', 'transactions', +]) + function sleep (ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } @@ -24,10 +28,12 @@ async function indexerSchemaReady (config) { user: config.dbUser, password: config.dbPassword, }) + const placeholders = config.requiredIndexerTables.map(() => '?').join(', ') const rows = await connection.query( - 'SELECT COUNT(*) AS n FROM information_schema.tables WHERE table_schema = ? AND table_name = ?', - [config.indexerDbName, 'issues']) - return Number(rows[0].n) === 1 + '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 { @@ -76,6 +82,7 @@ function configFromEnv (env) { 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), @@ -92,7 +99,7 @@ async function main () { process.stdout.write('attest-mirror stack ready: indexer schema and ' + config.minerHealthUrls.length + ' miner health endpoint(s)\n') } -module.exports = { responseOk, indexerSchemaReady, stackReady, waitForStack, configFromEnv } +module.exports = { REQUIRED_INDEXER_TABLES, responseOk, indexerSchemaReady, stackReady, waitForStack, configFromEnv } if (require.main === module) { main().catch((error) => { diff --git a/test/unit/scripts/attest_mirror_runner.test.js b/test/unit/scripts/attest_mirror_runner.test.js index 0ee00988..7efe554f 100644 --- a/test/unit/scripts/attest_mirror_runner.test.js +++ b/test/unit/scripts/attest_mirror_runner.test.js @@ -25,6 +25,7 @@ function fixture () { "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 (phase === 'ready') { if (!fs.existsSync(state)) process.exit(31); event('ready') }", + "if (phase === 'seed') { if (!fs.existsSync(state)) process.exit(32); event('seed') }", "if (phase === 'run') { event('run-start'); setTimeout(() => { event('run-end'); process.exit(leg.includes('fail') ? 7 : 0) }, 100) }", "if (phase === 'down') { event('down'); if (fs.existsSync(state)) fs.unlinkSync(state) }", ].join('\n')) @@ -87,6 +88,7 @@ describe('attest-mirror aggregate runner', function () { 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$/) diff --git a/test/unit/scripts/wait_attest_mirror_stack.test.js b/test/unit/scripts/wait_attest_mirror_stack.test.js index 73058bc0..03bed381 100644 --- a/test/unit/scripts/wait_attest_mirror_stack.test.js +++ b/test/unit/scripts/wait_attest_mirror_stack.test.js @@ -8,8 +8,9 @@ function config (overrides) { 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: 1 }], end: async () => {} }), + connect: async () => ({ query: async () => [{ n: ready.REQUIRED_INDEXER_TABLES.length }], end: async () => {} }), fetchImpl: async () => ({ ok: true }), }, overrides || {}) } @@ -28,7 +29,7 @@ describe('attest-mirror stack readiness', function () { it('does not probe services until the indexer schema exists', async function () { let fetched = false const c = config({ - connect: async () => ({ query: async () => [{ n: 0 }], end: async () => {} }), + 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) From 64e6a5b208a9a2b7b053d422c294d7f4e9730b9a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 15:00:13 -0700 Subject: [PATCH 06/54] test: wait for joined indexer schema Gate fresh attest-mirror stacks on the concrete index tables used by initial checks. This prevents the runner from waiting on table names that are not part of the indexer schema. --- scripts/wait-attest-mirror-stack.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/wait-attest-mirror-stack.js b/scripts/wait-attest-mirror-stack.js index ba3a4b88..8271331c 100644 --- a/scripts/wait-attest-mirror-stack.js +++ b/scripts/wait-attest-mirror-stack.js @@ -3,7 +3,8 @@ const mariadb = require('mariadb') const REQUIRED_INDEXER_TABLES = Object.freeze([ - 'actions', 'addresses', 'issues', 'price_snapshots', 'statuses', 'ticks', 'transactions', + 'actions', 'index_addresses', 'index_statuses', 'index_tickers', 'index_transactions', + 'issues', 'price_snapshots', 'transactions', ]) function sleep (ms) { From f73f349391662df8a7add15666aad4f4826d9a7a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 15:26:03 -0700 Subject: [PATCH 07/54] test: provision BF6 mixed hub checkout Create a detached pre-upgrade hub checkout for the BF6 mixed-version cases and pass it through the isolated-stack driver. Remove the checkout during teardown and refuse archived worktree pointers as clone sources. --- scripts/attest-mirror-stack-driver.sh | 31 +++- scripts/prepare-bf6-mixed-hub.js | 132 ++++++++++++++++++ .../attest_mirror_stack_driver.test.js | 50 +++++++ .../scripts/prepare_bf6_mixed_hub.test.js | 66 +++++++++ 4 files changed, 273 insertions(+), 6 deletions(-) create mode 100644 scripts/prepare-bf6-mixed-hub.js create mode 100644 test/unit/scripts/attest_mirror_stack_driver.test.js create mode 100644 test/unit/scripts/prepare_bf6_mixed_hub.test.js diff --git a/scripts/attest-mirror-stack-driver.sh b/scripts/attest-mirror-stack-driver.sh index d289e86f..084bbfa2 100755 --- a/scripts/attest-mirror-stack-driver.sh +++ b/scripts/attest-mirror-stack-driver.sh @@ -29,10 +29,17 @@ 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) "${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 @@ -51,16 +58,28 @@ case "$phase" in EXPLORER_API_PORT="${ATTEST_MIRROR_EXPLORER_PORT:-46599}" E2E_STAKE_TEARDOWN=off ;; run) - "${ATTEST_MIRROR_STACK_ROOT}/run-leg.sh" "$stack" "$number" "$venue_base" "$leg" \ - EXPLORER_API_PORT="${ATTEST_MIRROR_EXPLORER_PORT:-46599}" E2E_STAKE_TEARDOWN=off + 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 + 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}")" - test -z "$(docker volume ls -q --filter "label=com.docker.compose.project=${project}")" - test -z "$(docker network ls -q --filter "label=com.docker.compose.project=${project}")" + 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 diff --git a/scripts/prepare-bf6-mixed-hub.js b/scripts/prepare-bf6-mixed-hub.js new file mode 100644 index 00000000..6048f9af --- /dev/null +++ b/scripts/prepare-bf6-mixed-hub.js @@ -0,0 +1,132 @@ +'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 + 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 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]) + 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/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/prepare_bf6_mixed_hub.test.js b/test/unit/scripts/prepare_bf6_mixed_hub.test.js new file mode 100644 index 00000000..d06f1636 --- /dev/null +++ b/test/unit/scripts/prepare_bf6_mixed_hub.test.js @@ -0,0 +1,66 @@ +'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-')) + const modules = path.join(ROOT, '..', '..', '..', 'xchain-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 }) + } + }) +}) From 52f7bfe66fd64833f9be0bd6a13df285be93b7c3 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 17:14:03 -0700 Subject: [PATCH 08/54] test(parity): grade the TRAIN_ACTIVATION row and the whole shared_rows_5 twin across the fleet The parity tier read SHARED registry part 2 and never part 5, so reverting a TRAIN_ACTIVATION height in a consumer left it at 45 passing while the same experiment on part 2's CHECKPOINT_COMMITMENT row went red. Add the missing coverage in the shape part 2 already uses: value equality against the canonical protocol constant, rule-set and network key shape, halt-verdict agreement driven through both train gates over every registry's copy, and a whole-file byte grade of shared_rows_5.js against the indexer canonical so every row in the part is guarded rather than one row per cut. --- .../parity/trainActivationParity.test.js | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 test/integration/parity/trainActivationParity.test.js diff --git a/test/integration/parity/trainActivationParity.test.js b/test/integration/parity/trainActivationParity.test.js new file mode 100644 index 00000000..3b13de00 --- /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 + * (claude/bin/frozen-twins.json, 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); + } + }); +}); From 27489e318086d363acc2b8c4dcaeeb0e585c811f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 17:42:22 -0700 Subject: [PATCH 09/54] docs(parity): name the twin manifest without its internal path --- test/integration/parity/trainActivationParity.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/parity/trainActivationParity.test.js b/test/integration/parity/trainActivationParity.test.js index 3b13de00..d86d5e81 100644 --- a/test/integration/parity/trainActivationParity.test.js +++ b/test/integration/parity/trainActivationParity.test.js @@ -18,7 +18,7 @@ * 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 - * (claude/bin/frozen-twins.json, canonical xchain-indexer) copied into xchain-hub, + * (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 From 09eef1511a706b5df08b88231f654ef56baa3eb9 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 18:08:58 -0700 Subject: [PATCH 10/54] test(bf6): resolve the mixed-hub node_modules beside the repo, not by counted depth The BF6 preparer case pointed at the hub's node_modules with a fixed number of ".." steps above the sibling base. That arithmetic only lands on a real path when the checkout sits exactly three levels under the platform root, which is true of the lane worktree it was written in and of nothing else: the platform checkout resolved it outside the tree entirely, and the CI venue resolved it to a directory that does not exist, so the case asserted its way to a red gate on the commit that added it. Take the path from BUILD_HUB, which the same case already uses as the clone source and which the preparer itself uses as its default. Every layout keeps the repo and the hub side by side, so the sibling row holds where the depth does not. --- test/unit/scripts/prepare_bf6_mixed_hub.test.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/unit/scripts/prepare_bf6_mixed_hub.test.js b/test/unit/scripts/prepare_bf6_mixed_hub.test.js index d06f1636..388dcb28 100644 --- a/test/unit/scripts/prepare_bf6_mixed_hub.test.js +++ b/test/unit/scripts/prepare_bf6_mixed_hub.test.js @@ -16,7 +16,12 @@ describe('BF6 mixed hub checkout preparer', function () { 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-')) - const modules = path.join(ROOT, '..', '..', '..', 'xchain-hub', 'node_modules') + // 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', From 10a45f1169aacc8b9d41745de9da65bd1c6d6131 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 13:03:14 -0700 Subject: [PATCH 11/54] fix(attest-mirror): clear the stack slot before boot so a stale db-data volume cannot deny db-init setup-stack.sh mints a fresh random root password into the stack .env on every boot, but MariaDB honours MARIADB_ROOT_PASSWORD only while the datadir is empty. A db-data volume that outlives its stack therefore leaves the datadir on the old password, db-init's single client call is refused with ERROR 1045, and the whole up phase dies one second after db reports healthy. A killed lane never reaches the down phase, and down -v skips any volume another container still holds, so up now owns the cleanup: down -v --remove-orphans, a label-scoped container and volume sweep that retries while holders drain, and a hard refusal to boot if any volume survives. --- scripts/attest-mirror-stack-driver.sh | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/scripts/attest-mirror-stack-driver.sh b/scripts/attest-mirror-stack-driver.sh index 084bbfa2..012b5810 100755 --- a/scripts/attest-mirror-stack-driver.sh +++ b/scripts/attest-mirror-stack-driver.sh @@ -35,6 +35,44 @@ 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 \ From 1929c80b3b6562f1909b52c77cd883ab44520ba2 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 10:43:49 -0700 Subject: [PATCH 12/54] test(attest-mirror): wait for the indexer HTTP listener, not only its schema start() returned as soon as the last indexer created its database schema, which proves it reached MariaDB and not that its /status socket is bound. Indexers are spawned serially, so the last one is exposed every time and a leg reading it in a before all hook died on ECONNREFUSED at that one port rather than on a named barrier. Any HTTP answer counts, 503 included: a stalled indexer that reports its stall is listening, and a parked node is what these drills are about. --- test/helpers/attestMirrorVenue.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/helpers/attestMirrorVenue.js b/test/helpers/attestMirrorVenue.js index 51dcd9d1..f48f2167 100644 --- a/test/helpers/attestMirrorVenue.js +++ b/test/helpers/attestMirrorVenue.js @@ -2755,6 +2755,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); } From 1a6eb2322c18b972f417daf1f1aa16dbc056134c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 13:33:27 -0700 Subject: [PATCH 13/54] test(bf2,bf4): arm the barrier-family crossing above genesis so a legacy row is a row a hub really wrote BF2 and BF4 armed their venues with the genesis form of the regtest lever, which resolves to height 0. On that venue every block is admission era, so their "legacy" NULL-admission seeds were modern rows missing the map the producer activation makes mandatory, and the canonical builder refused them and stalled the drill block. The refusal is the product working, on both the binding side and the canonical side: the builder rejects a NULL admission map on any row whose era block is at or above the producer activation (adjudicated 2026-09-18). The seeding was the defect. Both legs now arm producers and consumers at the chain's own tip + 1, so every block the stack already has is legacy era and every block the venue mines is admission era, and they seed their legacy rows below that height. The crossing is one the venue walks through rather than one the seeds manufacture. - barrierFamilyFixture: armValue, crossingArmHeight and legacyEraBlock, with the arming overlay carrying a height; the evidence records it. - barrierFamilyDrive: bootFamilyVenue takes armAtCrossing, reads the tip before the venue starts, and arms the hubs at the same height as the indexers. - barrierFamilyRows: armedLegacyApplyHazards is keyed on the row's era rather than on its table alone, and an unreadable era block fails closed. CANONICAL_BEFORE_QUORUM_TABLES survives as that guard's scope; it is no longer a seeding rule, so admissionSeedRows now seeds a legacy row in every member table instead of skipping bridge_transfers and policy_snapshots. - The unit tier pins the new line against the indexer's own builder in a child process armed the way a venue arms one. --- .../barrier_family/bf2_armed_fix.test.js | 41 +++++--- .../barrier_family/bf4_boundary.test.js | 32 ++++++- .../helpers/barrierFamilyDrive.js | 26 +++++- .../helpers/barrierFamilyFixture.js | 86 +++++++++++++++-- .../attestMirror/helpers/barrierFamilyRows.js | 68 ++++++++++---- .../unit/helpers/barrierFamilyFixture.test.js | 93 ++++++++++++++++++- test/unit/helpers/barrierFamilyRows.test.js | 57 +++++++++--- 7 files changed, 340 insertions(+), 63 deletions(-) diff --git a/test/attestMirror/barrier_family/bf2_armed_fix.test.js b/test/attestMirror/barrier_family/bf2_armed_fix.test.js index 210b0c9f..be013840 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 @@ -59,11 +65,13 @@ const COMMIT_BUDGET_MS = 5 * 60 * 1000 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 +104,13 @@ 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), 'the leg and the fixture disagree on the legacy era block') + assert.ok(ctx.legacyBlock < ctx.armHeight, 'the legacy seed at ' + ctx.legacyBlock + ' is not below the activation ' + ctx.armHeight) + assert.ok(ctx.armHeight <= ctx.B, 'the venue never reached the activation ' + ctx.armHeight + ': B is ' + ctx.B) + 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. + assert.deepStrictEqual(rows.armedLegacyApplyHazards(members, ctx.armHeight), [], 'BF2 would seed a row the armed apply pass refuses') 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 +119,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) { diff --git a/test/attestMirror/barrier_family/bf4_boundary.test.js b/test/attestMirror/barrier_family/bf4_boundary.test.js index 95ec8e06..0fb0f544 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') @@ -146,11 +156,12 @@ describe('BF4 unit: the binding rule and its SQL form', function () { 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,18 +190,29 @@ 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), 'the leg and the fixture disagree on the legacy era block') + assert.ok(ctx.legacyBlock < ctx.armHeight, 'the legacy seed at ' + ctx.legacyBlock + ' is not below the activation ' + ctx.armHeight) + assert.ok(ctx.armHeight <= ctx.tip + 1, 'the venue never reached the activation ' + ctx.armHeight + ': tip is ' + ctx.tip) + // 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') + assert.deepStrictEqual(rows.armedLegacyApplyHazards([legacy, omits, control], ctx.armHeight), [], 'BF4 would seed a row the armed apply pass refuses') 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)) + // 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, 'the legacy row is at block ' + legacy.row.snapshot_block + ', not below the activation') + for (const s of [omits, control]) assert.ok(s.row.snapshot_block >= ctx.armHeight, 'a map-carrying row is below the activation at ' + s.row.snapshot_block) + 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) { diff --git a/test/attestMirror/helpers/barrierFamilyDrive.js b/test/attestMirror/helpers/barrierFamilyDrive.js index a17dd969..a1759965 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,16 +80,19 @@ 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 } + return { venue: built.venue, evidence, btc, armHeight, legacyBlock } } /** Every indexer level with the decoder before a leg breaks anything, so a hold is the leg's doing. */ diff --git a/test/attestMirror/helpers/barrierFamilyFixture.js b/test/attestMirror/helpers/barrierFamilyFixture.js index f36698fe..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 } @@ -286,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..40855a9a 100644 --- a/test/attestMirror/helpers/barrierFamilyRows.js +++ b/test/attestMirror/helpers/barrierFamilyRows.js @@ -181,29 +181,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 +229,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 } diff --git a/test/unit/helpers/barrierFamilyFixture.test.js b/test/unit/helpers/barrierFamilyFixture.test.js index f3bf80ae..ffca5337 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,95 @@ 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, AT or above it the builder + // refuses a NULL admission map on any row whose era block is at or above the producer + // activation (adjudicated 2026-09-18: that refusal is the product working). + // + // 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.match(String(out.canonicalAtArmHeight), /has no admit_blocks/, 'a NULL-map row at the activation must still be refused') + // 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 is refused 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.match(String(out.canonicalAtArmHeight), /has no admit_blocks/) + assert.match(String(out.canonicalAtLegacyBlock), /has no admit_blocks/, + 'armed at genesis there is no height at which a NULL-map row builds a canonical') + }) + + /** + * 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 () { diff --git a/test/unit/helpers/barrierFamilyRows.test.js b/test/unit/helpers/barrierFamilyRows.test.js index 9e378b1e..c356a770 100644 --- a/test/unit/helpers/barrierFamilyRows.test.js +++ b/test/unit/helpers/barrierFamilyRows.test.js @@ -168,38 +168,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] } From 012769c49d77d998e1289dfb8620d56dee0000c6 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 14:14:21 -0700 Subject: [PATCH 14/54] test(attest-mirror): give every venue leg the runner's per-slot port base Concurrent legs probed one port base and were handed the same ports. Only the barrier-family drive read the base the stack runner exports per slot; every other leg built the venue directly, fell through to the historical 41000, and three concurrent legs then raced between probe and bind. Two of them lost their whole before-all hook to listen EADDRINUSE on 127.0.0.1:61017 and :61030 and reported 0 passing, which reads as broken legs rather than as an allocation that was never slot-aware. The venue now resolves its own base: an explicit basePort first, else the runner's per-slot base from the environment, else 41000. One resolver instead of sixteen call sites, so a leg added later is slot-isolated without being told to be, and a caller that places its own venues by hand keeps its base. A base that is set but unusable throws rather than falling back, because the fallback is the shared window this leaves. --- test/helpers/attestMirrorVenue.js | 56 ++++++++++++- test/unit/helpers/attestMirrorVenue.test.js | 87 +++++++++++++++++++++ 2 files changed, 141 insertions(+), 2 deletions(-) diff --git a/test/helpers/attestMirrorVenue.js b/test/helpers/attestMirrorVenue.js index f48f2167..e5ae8a43 100644 --- a/test/helpers/attestMirrorVenue.js +++ b/test/helpers/attestMirrorVenue.js @@ -449,6 +449,51 @@ 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 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 raw = (env || {})[VENUE_BASE_PORT_ENV]; + if (raw === undefined || raw === null || String(raw).trim() === '') 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 +2065,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 +2111,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 @@ -3936,6 +3985,9 @@ module.exports = { hubExtraEnvFor, planPorts, portCount, + resolveVenueBasePort, + DEFAULT_VENUE_BASE_PORT, + VENUE_BASE_PORT_ENV, buildHubEnv, buildIndexerEnv, pickOutsideIndexer, diff --git a/test/unit/helpers/attestMirrorVenue.test.js b/test/unit/helpers/attestMirrorVenue.test.js index e9a8cf90..4daaf6fa 100644 --- a/test/unit/helpers/attestMirrorVenue.test.js +++ b/test/unit/helpers/attestMirrorVenue.test.js @@ -178,6 +178,93 @@ 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 } = + 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) + }) +}) + describe('attestMirrorVenue: hub environment', function () { it('points every peer at the DELAY PROXY, not at the hub\'s own P2P port', () => { From 5f835f85cb85bf1bd5e1d5456cbb54498505c495 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 15:10:36 -0700 Subject: [PATCH 15/54] test(attest-mirror): retry a dropped /status socket, never an HTTP status statusOf was one unretried axios.get, so a single transport failure threw out of the venue and killed the leg that was reading. Across two aggregate drives it threw in four legs: at0, at0b and venue.smoke on 2026-09-17 (ECONNREFUSED at bring-up, since cured by waiting for the listener) and ab1 on 2026-09-18 (socket hang up, mid leg, on an indexer that had already answered many times). Waiting for the listener could not cure the second shape because the socket drops long after boot. The retry keys on error.response being absent. validateStatus accepts every status on purpose, since a stalled indexer reporting 503 is an answer and is usually the state the drill is asserting, so any status still returns on the first attempt, unretried. Only the absence of an answer is repeated, three times over two seconds, and an indexer that is genuinely gone still fails the leg with a message naming the attempts and the last cause. FALSIFIED 2026-09-18 against the real class, offline, 8 of 8 claims: a 503 and a 404 each return on the FIRST attempt with the body intact and exactly one request served, which is the claim that matters since a barrier drill asserts on 503; a socket hung up once is retried and the second answer returned; a port with nothing behind it still fails, in 3009 ms, naming "in 3 attempts" and ECONNREFUSED and carrying the original on .cause; and an indexer index the venue does not have throws rather than returning undefined. --- test/helpers/attestMirrorVenue.js | 47 +++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/test/helpers/attestMirrorVenue.js b/test/helpers/attestMirrorVenue.js index e5ae8a43..27957910 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 @@ -3624,11 +3632,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) { From 2714b440371b2ee59106b1de7d20dbd7dfb73094 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 16:45:07 -0700 Subject: [PATCH 16/54] test(at0b): read the mirror schema version instead of asserting a stale 5 AT0b asserted the hub serves schema_version 5 as its proof that the hub still holds the row it withheld from the starved indexer. The platform has since rolled the schema to 7, so the leg red on 7 !== 5 at a clause that has nothing to do with barrier attribution, and it did so on the 2026-09-18 aggregate where it was one of the seven legs holding clause 1 back. venue.smoke already had this right: it reads HUB_SCHEMA_VERSION from the indexer's own hub_schema_version.js and stayed green straight through the same bump. That resolution moves into test/attestMirror/helpers/hubSchemaVersion.js so there is one copy rather than a second literal waiting to go stale, and both legs now read it. The assertion is unchanged in kind. It is still an equality against a number sourced independently of the response, so a hub serving the wrong version still reds the leg; it just is not a number typed into the test. On empty input, an indexer sibling that is not there, the helper throws MODULE_NOT_FOUND naming the path it wanted rather than yielding undefined, which would have compared Number(7) against NaN and failed for a third unrelated reason. Falsified offline: the helper resolves 7 from the indexer sibling, which is exactly what the hub was serving when the leg red at 5. --- .../at0b-barrier-attribution.test.js | 11 ++++- test/attestMirror/helpers/hubSchemaVersion.js | 43 +++++++++++++++++++ test/attestMirror/venue.smoke.test.js | 16 ++----- 3 files changed, 56 insertions(+), 14 deletions(-) create mode 100644 test/attestMirror/helpers/hubSchemaVersion.js 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/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 From 7c2a18b8eafa0ff473cfcf051b65bcf6972e5242 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 16:45:21 -0700 Subject: [PATCH 17/54] test(zc1): wait for the venue indexer to commit the request block before reading the row ZC1 read its v0 request row straight after broadcasting, so the read raced the venue indexer's commit of the block carrying the request and the leg red on "the venue indexer holds no v0 request row", which says nothing about zero-conf serving and everything about when the read fired. It was one of the seven legs holding clause 1 back on the 2026-09-18 aggregate. waitForLocalRequestAtBlock waits for the venue indexer to commit the block the standing indexer placed the request at, then reads the row, then asserts the row really is at that block. The last part is what keeps this a fix and not a sleep: a row that turns up at a different height now fails loudly instead of being accepted because the wait happened to be long enough. It refuses to wait at all without a usable height from the standing indexer, so an absent or unparseable blockIndex fails naming that rather than waiting on Number(undefined) and timing out with an unrelated message. The hunk is the ZC1 third of the held branch pb/at1-legs-rebase-2026-09-18 (8a96d5f1), taken alone. The AT2b and ZC3 thirds of that branch are deliberately NOT taken: both legs read green on the landed tip with their files untouched on the 2026-09-18 drive, so landing them would rewrite passing legs from versions never driven green. Falsified offline via the leg's own ZC1_UNIT_ONLY mode, 3 passing / 0 failing: the row read is proven to run after the commit and not before, a row at a different block is refused, and a missing request height is refused. --- test/attestMirror/zc1_zero_conf.test.js | 77 +++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 4 deletions(-) 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 From 1f473ac06547709f13e7bcc635d3ffb7004c3575 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 16:45:34 -0700 Subject: [PATCH 18/54] test(attest-mirror): fail loudly when a runner slot has no venue port base resolveVenueBasePort falls back to 41000 when AB_VENUE_BASE_PORT is unset, which is right for someone running one leg by hand and wrong for a leg the runner launched as one of several concurrent slots. If that export ever goes missing, every slot silently shares one probe window, _pickFreePorts bounces it out of the ephemeral range into 61000, and the run comes back as intermittently flaky barrier legs rather than as a missing export. That is the shape the 2026-09-18 concurrency-3 attempt failed in, at :61017 and :61030. The slot is the evidence that a base was owed. run-attest-mirror.js puts ATTEST_MIRROR_SLOT in the driver's environment, the stack driver derives the base from it, run-leg.sh exports the base, and nothing in that chain scrubs the environment, so both reach the mocha process. Verified on the rail host by replaying the exec chain: slot 2 with base 63600 and slot 0 with base 63400 both arrive in the child. A slot present with no base is therefore a broken runner, and it now throws instead of defaulting. SLOT 0 IS A REAL SLOT, and it is the first one allocated. The check is presence rather than truthiness for exactly that reason, since the first slot arrives as the string "0" and a truthiness test would wave through the one case that collides with every other slot. Nothing else changes. An explicit basePort still wins before the check is reached, so the bridge rail's hand-placed pair is untouched, and a leg run by hand with neither variable set still gets 41000. Falsified both directions: with the guard the venue unit suite reads 87 passing / 0 failing / 0 pending; with the guard removed and the same tests run, 86 passing / 1 failing, failing first on "defaulted slot 0", which is the truthiness trap the check was written against. --- test/helpers/attestMirrorVenue.js | 33 +++++++++++++++++++-- test/unit/helpers/attestMirrorVenue.test.js | 31 ++++++++++++++++++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/test/helpers/attestMirrorVenue.js b/test/helpers/attestMirrorVenue.js index 27957910..a04eb95f 100644 --- a/test/helpers/attestMirrorVenue.js +++ b/test/helpers/attestMirrorVenue.js @@ -463,6 +463,13 @@ 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. @@ -491,8 +498,29 @@ const VENUE_BASE_PORT_ENV = 'AB_VENUE_BASE_PORT'; */ function resolveVenueBasePort(explicit, env) { if (explicit) return explicit; - const raw = (env || {})[VENUE_BASE_PORT_ENV]; - if (raw === undefined || raw === null || String(raw).trim() === '') return DEFAULT_VENUE_BASE_PORT; + 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. ' + @@ -4031,6 +4059,7 @@ module.exports = { resolveVenueBasePort, DEFAULT_VENUE_BASE_PORT, VENUE_BASE_PORT_ENV, + RUNNER_SLOT_ENV, buildHubEnv, buildIndexerEnv, pickOutsideIndexer, diff --git a/test/unit/helpers/attestMirrorVenue.test.js b/test/unit/helpers/attestMirrorVenue.test.js index 4daaf6fa..2ea40d39 100644 --- a/test/unit/helpers/attestMirrorVenue.test.js +++ b/test/unit/helpers/attestMirrorVenue.test.js @@ -181,7 +181,8 @@ 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 } = + 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 @@ -263,6 +264,34 @@ describe('attestMirrorVenue: the per-slot probe base (concurrent venues)', funct 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 () { From 1cd048f997f8e1d4fa368460fd8505e2ab201c91 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 16:47:38 -0700 Subject: [PATCH 19/54] test(bf5): attach the indexer's log tail to both uncommitted-block assertions BF5's two failing assertions printed the /status JSON and nothing else, and /status names the last barrier the block DEFERRED on, which is not the same thing as the reason it is still uncommitted. A canonical-build refusal or a parse rollback loop never reaches /status at all, so the leg reported match_sync_barrier for twenty minutes after that barrier had cleared and two aggregate drives failed to attribute it from its own output. Both assertions now carry venue.logTail, the idiom the armed-node assertion beside them and at0b already use. This changes what a failure PRINTS and nothing about what it concludes, so it cannot move a verdict: the counts on a green BF5 are unchanged and a red BF5 now carries the evidence that explains it. The preserved venue logs from the 2026-09-18 drive show what these assertions were hiding: the match-sync barrier cleared on schedule and forty-six seconds later block 278 began throwing in the indexer's mirror admission gate, "legacy-era row at block 278 on regtest was handed admit_blocks {BTC:280}; refusing to build an admission-era canonical", repeating 534 times on a five-second rollback and resume loop until the leg was torn down. None of that reached the failure message. --- test/attestMirror/barrier_family/bf5_flag_day.test.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/attestMirror/barrier_family/bf5_flag_day.test.js b/test/attestMirror/barrier_family/bf5_flag_day.test.js index e5808e65..623d8832 100644 --- a/test/attestMirror/barrier_family/bf5_flag_day.test.js +++ b/test/attestMirror/barrier_family/bf5_flag_day.test.js @@ -119,7 +119,10 @@ async function assertDivergence (ctx) { 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 +139,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) From 742c6d8edd7eff63ba0402ca5ff2a9651177ef71 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 21:51:23 -0700 Subject: [PATCH 20/54] test(at3): wait for the venue indexer to commit the request block before reading its row --- test/attestMirror/at3-deadline.test.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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, From 96dafc9fa50cca66ab6f425095f033add085ef69 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 21:51:23 -0700 Subject: [PATCH 21/54] test(at5): give the bridged-credit wait a verdict instead of a bare give-up --- scripts/check-wait-swallow.js | 6 + test/helpers/gasHelper.js | 19 ++- test/helpers/requireRow.js | 75 ++++++++++- .../helpers/bridge_credit_attribution.test.js | 121 ++++++++++++++++++ 4 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 test/unit/helpers/bridge_credit_attribution.test.js 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/test/helpers/gasHelper.js b/test/helpers/gasHelper.js index 30631929..4ef9382c 100644 --- a/test/helpers/gasHelper.js +++ b/test/helpers/gasHelper.js @@ -94,6 +94,8 @@ module.exports = { BRIDGE_CREDIT_SLACK_MS, IDLE_MINE_INTERVAL_MS, bridgeCreditWaitMs, + bridgeCreditAttribution: requireRow.bridgeCreditAttribution, + bridgeCreditEvidence: requireRow.bridgeCreditEvidence, withIdleMining, async mintGas(addressInfo, amount){ @@ -178,12 +180,23 @@ 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)) }) }, 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/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' + }) + }) +}) From 15e0f39d4fb9e8a9f59ccbba96af91daeb64bde2 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 21:51:23 -0700 Subject: [PATCH 22/54] test(at5): name why an adaptive wait could not measure lag or action writes --- src/db.js | 49 +++++++++--- test/unit/adaptive_wait.test.js | 130 +++++++++++++++++++++++++++++--- 2 files changed, 160 insertions(+), 19 deletions(-) diff --git a/src/db.js b/src/db.js index 971e6103..78ec3ec2 100644 --- a/src/db.js +++ b/src/db.js @@ -1325,8 +1325,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 @@ -1363,6 +1366,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 @@ -1405,12 +1411,14 @@ class Database { + ' (' + 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 +1436,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 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(() => From 20571ac728008ea67b6140c9dce12f1261a99067 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 21:51:23 -0700 Subject: [PATCH 23/54] test(zc3): construct the victim window instead of hoping the poll misses it --- test/attestMirror/zc3_headroom.test.js | 106 ++++++++++++++++++++----- 1 file changed, 86 insertions(+), 20 deletions(-) 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', From eebbc8932a477f5435371ce9312bb3cbad4a5a0b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 21:51:23 -0700 Subject: [PATCH 24/54] test(barrier): add a snapshot-capability coverage seam to the family fixture --- .../attestMirror/helpers/barrierFamilyRows.js | 57 ++++++++++++++++++ test/unit/helpers/barrierFamilyRows.test.js | 60 +++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/test/attestMirror/helpers/barrierFamilyRows.js b/test/attestMirror/helpers/barrierFamilyRows.js index 40855a9a..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, @@ -616,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/unit/helpers/barrierFamilyRows.test.js b/test/unit/helpers/barrierFamilyRows.test.js index c356a770..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 From f69cde189189f45d29651fcc5d5fae2cefb9583f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 21:51:23 -0700 Subject: [PATCH 25/54] test(at1): diagnose a missing readiness dependency instead of a raw module trace --- scripts/wait-attest-mirror-stack.js | 50 +++++++++-- .../scripts/wait_attest_mirror_stack.test.js | 85 ++++++++++++++++++- 2 files changed, 126 insertions(+), 9 deletions(-) diff --git a/scripts/wait-attest-mirror-stack.js b/scripts/wait-attest-mirror-stack.js index 8271331c..8305644e 100644 --- a/scripts/wait-attest-mirror-stack.js +++ b/scripts/wait-attest-mirror-stack.js @@ -1,12 +1,32 @@ 'use strict' -const mariadb = require('mariadb') +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)) } @@ -64,7 +84,7 @@ async function waitForStack (config) { throw new Error('attest-mirror stack did not expose the indexer schema and healthy miners inside ' + config.timeoutMs + ' ms') } -function configFromEnv (env) { +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) @@ -88,19 +108,33 @@ function configFromEnv (env) { 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: mariadb.createConnection, - fetchImpl: fetch, + connect: dependencies.mariadb.createConnection, + fetchImpl: fetchImpl, sleep, } } -async function main () { - const config = configFromEnv(process.env) +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) - process.stdout.write('attest-mirror stack ready: indexer schema and ' + config.minerHealthUrls.length + ' miner health endpoint(s)\n') + 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 = { REQUIRED_INDEXER_TABLES, responseOk, indexerSchemaReady, stackReady, waitForStack, configFromEnv } +module.exports = { + DEPENDENCY_ROOT, + REQUIRED_DEPENDENCIES, + REQUIRED_INDEXER_TABLES, + loadDependencies, + responseOk, + indexerSchemaReady, + stackReady, + waitForStack, + configFromEnv, + main, +} if (require.main === module) { main().catch((error) => { diff --git a/test/unit/scripts/wait_attest_mirror_stack.test.js b/test/unit/scripts/wait_attest_mirror_stack.test.js index 03bed381..4742386d 100644 --- a/test/unit/scripts/wait_attest_mirror_stack.test.js +++ b/test/unit/scripts/wait_attest_mirror_stack.test.js @@ -1,8 +1,20 @@ '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', @@ -16,6 +28,77 @@ function config (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 } } }) @@ -50,7 +133,7 @@ describe('attest-mirror stack readiness', 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/']) From b9197883679bb4ae3a9239c38f2ebbcfec30bb21 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 19 Sep 2026 09:37:01 -0700 Subject: [PATCH 26/54] feat: add offline preflight check for rail sibling repository links Verifies local sibling repo paths resolve correctly before a rail drive, catching dangling symlinks, absolute Users targets and missing spawned sibling APIs early. --- claude/bin/check-rail-sibling-links.js | 206 ++++++++++++++++++ .../scripts/check-rail-sibling-links.test.js | 161 ++++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 claude/bin/check-rail-sibling-links.js create mode 100644 test/unit/scripts/check-rail-sibling-links.test.js diff --git a/claude/bin/check-rail-sibling-links.js b/claude/bin/check-rail-sibling-links.js new file mode 100644 index 00000000..7fd3e69b --- /dev/null +++ b/claude/bin/check-rail-sibling-links.js @@ -0,0 +1,206 @@ +#!/usr/bin/env node +'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. + * + ********************************************************************** + * Offline preflight for the sibling repositories used by a rail drive. + * + * node check-rail-sibling-links.js [--json] [directory] + * + * Exit 0 means the root is usable, 1 means its local paths refuse, and + * 2 means the command line is invalid. + */ + +const fs = require('fs'); +const path = require('path'); + +const REQUIRED_RAIL_FILES = [ + 'xchain-hub/src/api.js', + 'xchain-indexer/src/api.js', +]; + +function isDirectory(candidate) { + return fs.statSync(candidate, { throwIfNoEntry: false })?.isDirectory() === true; +} + +function absoluteUsersPrefix(target) { + return target.match(/^\/Users\/[^/]+/)?.[0] || null; +} + +function inspectEntry(root, name) { + const entryPath = path.join(root, name); + const linkStat = fs.lstatSync(entryPath, { throwIfNoEntry: false }); + if (!linkStat) { + return { name, path: entryPath, type: 'missing', resolves: false, isDirectory: false }; + } + if (!linkStat.isSymbolicLink()) { + const directory = isDirectory(entryPath); + return { + name, + path: entryPath, + type: directory ? 'directory' : 'not-directory', + resolves: true, + isDirectory: directory, + }; + } + + const target = fs.readlinkSync(entryPath); + const resolvedTarget = path.isAbsolute(target) ? path.normalize(target) : path.resolve(root, target); + const resolvedStat = fs.statSync(entryPath, { throwIfNoEntry: false }); + return { + name, + path: entryPath, + type: resolvedStat ? 'resolving-symlink' : 'dangling-symlink', + target, + resolvedTarget, + resolves: Boolean(resolvedStat), + isDirectory: resolvedStat?.isDirectory() === true, + absoluteUsersPrefix: absoluteUsersPrefix(target), + }; +} + +function inspectRailRoot(defaultRoot, environment = process.env) { + const configured = String(environment.BRIDGE_RAIL_REPO_ROOT || '').trim(); + const repoRoot = configured ? path.resolve(configured) : defaultRoot; + const required = REQUIRED_RAIL_FILES.map((relativePath) => { + const wanted = path.join(repoRoot, relativePath); + return { + relativePath, + wanted, + exists: fs.statSync(wanted, { throwIfNoEntry: false })?.isFile() === true, + }; + }); + return { + source: configured ? 'environment' : 'default', + path: repoRoot, + resolves: isDirectory(repoRoot), + required, + }; +} + +function auditDirectory(requestedDirectory, environment = process.env) { + const directory = path.resolve(requestedDirectory); + const warnings = []; + const refusals = []; + + if (!isDirectory(directory)) { + refusals.push(`REFUSAL: ${directory} does not resolve to a directory; wanted a rail worktree directory.`); + return { directory, entries: [], railRepoRoot: null, warnings, refusals, ok: false }; + } + + const names = fs.readdirSync(directory).filter((name) => name.startsWith('xchain-')).sort(); + const entries = names.map((name) => inspectEntry(directory, name)); + if (entries.length === 0) { + refusals.push(`REFUSAL: ${directory} contains zero xchain-* entries; wanted at least one xchain-* sibling.`); + } + + for (const entry of entries) { + if (!entry.resolves) { + refusals.push(`REFUSAL: ${entry.path} does not resolve; wanted ${entry.resolvedTarget || entry.path}.`); + } else if (!entry.isDirectory) { + refusals.push(`REFUSAL: ${entry.path} resolves, but not to a directory; wanted ${entry.resolvedTarget || entry.path}.`); + } + if (entry.absoluteUsersPrefix) { + refusals.push(`REFUSAL: ${entry.path} points to ${entry.target}; prefix ${entry.absoluteUsersPrefix} must change for the runtime mount.`); + } + } + + const railRepoRoot = inspectRailRoot(directory, environment); + if (railRepoRoot.source === 'default') { + warnings.push(`WARNING: BRIDGE_RAIL_REPO_ROOT is unset; the venue default would be ${railRepoRoot.path}.`); + } + if (!railRepoRoot.resolves) { + refusals.push(`REFUSAL: BRIDGE_RAIL_REPO_ROOT names ${railRepoRoot.path}, which does not resolve to a directory; wanted ${railRepoRoot.path}.`); + } else { + for (const required of railRepoRoot.required) { + if (!required.exists) { + refusals.push(`REFUSAL: rail repository root ${railRepoRoot.path} is incomplete; wanted ${required.wanted}.`); + } + } + } + + return { directory, entries, railRepoRoot, warnings, refusals, ok: refusals.length === 0 }; +} + +function parseArgs(argv) { + let json = false; + let directory = null; + for (const arg of argv) { + if (arg === '--json') { + json = true; + } else if (arg.startsWith('-') || directory !== null) { + return { error: `bad argument: ${arg}` }; + } else { + directory = arg; + } + } + return { json, directory: directory || process.cwd() }; +} + +function formatHuman(result) { + const lines = [`check-rail-sibling-links: ${result.directory}`]; + for (const entry of result.entries) { + lines.push(` ${entry.name}: ${entry.type}${entry.target ? ` -> ${entry.target}` : ''}`); + } + if (result.railRepoRoot) { + const setting = result.railRepoRoot.source === 'environment' ? 'set' : 'unset'; + const complete = result.railRepoRoot.required.every((required) => required.exists); + lines.push(` BRIDGE_RAIL_REPO_ROOT: ${setting}; effective path ${result.railRepoRoot.path}; ` + + `resolves ${result.railRepoRoot.resolves ? 'yes' : 'no'}; required siblings present ` + + `${complete ? 'yes' : 'no'}`); + } + lines.push(...result.warnings, ...result.refusals); + if (result.ok) lines.push(`OK: ${result.directory} is fit to host a rail drive.`); + return lines.join('\n'); +} + +function main(argv = process.argv.slice(2), environment = process.env) { + const args = parseArgs(argv); + if (args.error) { + console.log(`check-rail-sibling-links: ${args.error}\n` + + 'usage: node check-rail-sibling-links.js [--json] [directory]'); + return 2; + } + + let result; + try { + result = auditDirectory(args.directory, environment); + } catch (error) { + const directory = path.resolve(args.directory); + result = { + directory, + entries: [], + railRepoRoot: null, + warnings: [], + refusals: [`REFUSAL: could not inspect ${directory}: ${error.message}`], + ok: false, + }; + } + console.log(args.json ? JSON.stringify(result, null, 2) : formatHuman(result)); + return result.ok ? 0 : 1; +} + +if (require.main === module) process.exit(main()); + +module.exports = { + REQUIRED_RAIL_FILES, + isDirectory, + absoluteUsersPrefix, + inspectEntry, + inspectRailRoot, + auditDirectory, + parseArgs, + formatHuman, + main, +}; diff --git a/test/unit/scripts/check-rail-sibling-links.test.js b/test/unit/scripts/check-rail-sibling-links.test.js new file mode 100644 index 00000000..3710266a --- /dev/null +++ b/test/unit/scripts/check-rail-sibling-links.test.js @@ -0,0 +1,161 @@ +/********************************************************************* + * + * 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 { spawnSync } = require('child_process'); +const nodeTest = require('node:test'); + +const SCRIPT = path.join(__dirname, '..', '..', '..', 'claude', 'bin', 'check-rail-sibling-links.js'); +const checker = require(SCRIPT); +const describe = global.describe || nodeTest.describe; +const before = global.before || nodeTest.before; +const after = global.after || nodeTest.after; +const it = global.it || nodeTest.it; + +describe('check-rail-sibling-links', function () { + let fixtureBase; + let fixtureNumber; + + before(function () { + fixtureBase = fs.mkdtempSync(path.join(os.tmpdir(), 'check-rail-sibling-links-')); + fixtureNumber = 0; + }); + + after(function () { + fs.rmSync(fixtureBase, { recursive: true, force: true }); + }); + + function makeDirectory(prefix) { + fixtureNumber += 1; + const directory = path.join(fixtureBase, `${String(fixtureNumber).padStart(2, '0')}-${prefix}`); + fs.mkdirSync(directory, { recursive: true }); + return directory; + } + + function makeApiRepo(root, name) { + const repo = path.join(root, name); + fs.mkdirSync(path.join(repo, 'src'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'src', 'api.js'), 'module.exports = {};\n'); + return repo; + } + + function healthyFixture(prefix = 'healthy') { + const container = makeDirectory(prefix); + const root = path.join(container, 'rail'); + const targets = path.join(container, 'targets'); + fs.mkdirSync(root); + makeApiRepo(root, 'xchain-hub'); + const indexer = makeApiRepo(targets, 'indexer'); + fs.symlinkSync(path.relative(root, indexer), path.join(root, 'xchain-indexer')); + return { container, root }; + } + + function run(root, environment = {}) { + const env = { ...process.env }; + delete env.BRIDGE_RAIL_REPO_ROOT; + Object.assign(env, environment); + const result = spawnSync(process.execPath, [SCRIPT, '--json', root], { encoding: 'utf8', env }); + return { ...result, json: JSON.parse(result.stdout) }; + } + + it('accepts real directories and a resolving relative symlink', function () { + const result = run(healthyFixture().root); + assert.strictEqual(result.status, 0); + assert.strictEqual(result.json.ok, true); + assert.deepStrictEqual(result.json.entries.map((entry) => entry.type), + ['directory', 'resolving-symlink']); + assert.match(result.json.warnings[0], /BRIDGE_RAIL_REPO_ROOT is unset/); + }); + + it('refuses a dangling symlink and names the target path', function () { + const { root } = healthyFixture('dangling'); + fs.unlinkSync(path.join(root, 'xchain-indexer')); + fs.symlinkSync('../targets/missing-indexer', path.join(root, 'xchain-indexer')); + const result = run(root); + assert.strictEqual(result.status, 1); + assert.match(result.json.refusals.join('\n'), /targets\/missing-indexer/); + assert.strictEqual(result.json.entries[1].type, 'dangling-symlink'); + }); + + it('refuses an absolute Users target', function () { + // Built from parts, never written as a literal: a hardcoded + // "/Users//..." string is itself a developer-machine-path + // shape and would trip this repo's own history leak scanner. + const usersPrefix = ['', 'Users', 'example'].join('/'); + const usersTarget = `${usersPrefix}/worktree/xchain-indexer`; + const { root } = healthyFixture('absolute-users'); + fs.unlinkSync(path.join(root, 'xchain-indexer')); + fs.symlinkSync(usersTarget, path.join(root, 'xchain-indexer')); + const result = run(root); + assert.strictEqual(result.status, 1); + assert.strictEqual(result.json.entries[1].absoluteUsersPrefix, usersPrefix); + assert.match(result.json.refusals.join('\n'), new RegExp(`prefix ${usersPrefix}`)); + }); + + it('refuses a directory with zero xchain entries', function () { + const result = run(makeDirectory('empty')); + assert.strictEqual(result.status, 1); + assert.match(result.json.refusals.join('\n'), /zero xchain-\* entries/); + }); + + it('refuses a nonexistent configured rail repository root and names it', function () { + const { root, container } = healthyFixture('missing-rail-root'); + const missing = path.join(container, 'missing-root'); + const result = run(root, { BRIDGE_RAIL_REPO_ROOT: missing }); + assert.strictEqual(result.status, 1); + assert(result.json.refusals.some((message) => message.includes(missing))); + }); + + it('refuses a rail repository root missing a spawned sibling API', function () { + const { root } = healthyFixture('missing-api'); + fs.unlinkSync(path.join(root, 'xchain-indexer', 'src', 'api.js')); + const result = run(root); + assert.strictEqual(result.status, 1); + assert.match(result.json.refusals.join('\n'), /xchain-indexer\/src\/api\.js/); + }); + + it('refuses an xchain entry that resolves to a file', function () { + const { root } = healthyFixture('file-entry'); + fs.writeFileSync(path.join(root, 'xchain-note'), 'not a repository\n'); + const result = run(root); + assert.strictEqual(result.status, 1); + assert.match(result.json.refusals.join('\n'), /xchain-note resolves, but not to a directory/); + }); + + it('emits valid machine-readable JSON', function () { + const result = run(healthyFixture('json').root); + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stderr, ''); + assert.strictEqual(result.json.railRepoRoot.source, 'default'); + assert.strictEqual(result.json.railRepoRoot.required.every((entry) => entry.exists), true); + }); + + it('uses exit 2 for bad arguments', function () { + const result = spawnSync(process.execPath, [SCRIPT, '--unknown'], { encoding: 'utf8' }); + assert.strictEqual(result.status, 2); + assert.match(result.stdout, /usage:/); + }); + + it('follows links when deciding whether an entry is a directory', function () { + const source = fs.readFileSync(SCRIPT, 'utf8'); + assert.match(source, /fs\.statSync\(entryPath, \{ throwIfNoEntry: false \}\)/); + assert.doesNotMatch(source, /\.isDirectory\(\)\s*\|\|\s*[^\n]*isSymbolicLink/); + assert.strictEqual(checker.REQUIRED_RAIL_FILES.length, 2); + }); +}); From a30e696e95476a84d9cce5ceb935142c56d6049a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 19 Sep 2026 09:58:07 -0700 Subject: [PATCH 27/54] fix(e2e): bound XChainExplorerConnector requests with a timeout ping() and getFileRaw() called axios with no timeout, so an explorer that accepts the socket and never answers (the "503 with zero DB pools" venue with no pool left to answer from) left ping() pending forever. initialCheck requires that ping before any action test runs, and the suite runs under mocha --timeout 0, so nothing else in this stack ever timed the call out: a stuck explorer silently stalled CI instead of failing it. Same fix, same reasoning already applied to RegtestMinerConnector's PING_TIMEOUT_MS. --- src/XChainExplorerConnector.js | 18 ++- .../src/xchain_explorer_connector.test.js | 105 ++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 test/unit/src/xchain_explorer_connector.test.js 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/test/unit/src/xchain_explorer_connector.test.js b/test/unit/src/xchain_explorer_connector.test.js new file mode 100644 index 00000000..70b83e2c --- /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 (xchain-node run 35440776385, LTC/DOGE legs, 2026-09-19) 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); + }); + }); +}); From b2ef3d833ec76bf0559a5bd536712e424013827d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 19 Sep 2026 09:45:46 -0700 Subject: [PATCH 28/54] feat(attestMirror): add mirror pendings census and request-row wait guard Adds check-mirror-pendings.js to census skipped/pending attest-mirror tests with a stated reason, and check-request-row-wait.js to detect request-row reads not guarded by a height wait. Extends the attest-mirror aggregate runner with concurrency-capped leg allocation, readiness-gate teardown and abort-on-consecutive-ready-failure handling, with matching harness coverage in the BF2/BF4 barrier family fixtures. --- package.json | 1 + scripts/check-mirror-pendings.js | 385 ++++++++++++++++++ scripts/check-request-row-wait.js | 338 +++++++++++++++ scripts/run-attest-mirror.js | 68 +++- .../barrier_family/bf2_armed_fix.test.js | 74 +++- .../barrier_family/bf4_boundary.test.js | 130 ++++-- .../unit/scripts/attest_mirror_runner.test.js | 224 +++++++++- .../scripts/check_mirror_pendings.test.js | 151 +++++++ .../scripts/check_request_row_wait.test.js | 201 +++++++++ 9 files changed, 1515 insertions(+), 57 deletions(-) create mode 100644 scripts/check-mirror-pendings.js create mode 100644 scripts/check-request-row-wait.js create mode 100644 test/unit/scripts/check_mirror_pendings.test.js create mode 100644 test/unit/scripts/check_request_row_wait.test.js diff --git a/package.json b/package.json index 108f8295..57c0f836 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "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", 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/run-attest-mirror.js b/scripts/run-attest-mirror.js index 8a19ab84..da713bbc 100644 --- a/scripts/run-attest-mirror.js +++ b/scripts/run-attest-mirror.js @@ -4,9 +4,11 @@ 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'), @@ -59,6 +61,25 @@ function driverCommand (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) @@ -113,14 +134,44 @@ async function runAll (config) { 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) } @@ -129,19 +180,26 @@ async function runAll (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 selected = process.env.ATTEST_MIRROR_LEGS + 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, @@ -150,7 +208,13 @@ async function main () { runId: process.env.ATTEST_MIRROR_RUN_ID, }) const failed = results.filter((result) => result.code !== 0) - process.stdout.write('[attest-mirror] ' + (results.length - failed.length) + ' legs passed, ' + failed.length + ' failed\n') + 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) } diff --git a/test/attestMirror/barrier_family/bf2_armed_fix.test.js b/test/attestMirror/barrier_family/bf2_armed_fix.test.js index be013840..d98e4242 100644 --- a/test/attestMirror/barrier_family/bf2_armed_fix.test.js +++ b/test/attestMirror/barrier_family/bf2_armed_fix.test.js @@ -62,6 +62,34 @@ 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))) @@ -105,12 +133,16 @@ async function seedAdmissionRows (ctx) { // 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 } // The crossing, asserted before anything is seeded: both eras exist on this one venue. - assert.strictEqual(ctx.legacyBlock, fixture.legacyEraBlock(ctx.armHeight), 'the leg and the fixture disagree on the legacy era block') - assert.ok(ctx.legacyBlock < ctx.armHeight, 'the legacy seed at ' + ctx.legacyBlock + ' is not below the activation ' + ctx.armHeight) - assert.ok(ctx.armHeight <= ctx.B, 'the venue never reached the activation ' + ctx.armHeight + ': B is ' + ctx.B) + 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. - assert.deepStrictEqual(rows.armedLegacyApplyHazards(members, ctx.armHeight), [], 'BF2 would seed a row the armed apply pass refuses') + 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 = {} @@ -136,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 }) @@ -145,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 @@ -164,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 0fb0f544..57db2d78 100644 --- a/test/attestMirror/barrier_family/bf4_boundary.test.js +++ b/test/attestMirror/barrier_family/bf4_boundary.test.js @@ -66,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) { @@ -93,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')) + } } }) @@ -102,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')) }) }) @@ -128,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 () { @@ -140,16 +190,25 @@ 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')) }) }) @@ -192,9 +251,12 @@ async function seedThree (ctx) { const base = { network: ctx.venue.network, coin: ctx.coin, effectiveTime: ctx.T, snapshotBlock: ctx.tip + 1 } // 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), 'the leg and the fixture disagree on the legacy era block') - assert.ok(ctx.legacyBlock < ctx.armHeight, 'the legacy seed at ' + ctx.legacyBlock + ' is not below the activation ' + ctx.armHeight) - assert.ok(ctx.armHeight <= ctx.tip + 1, 'the venue never reached the activation ' + ctx.armHeight + ': tip is ' + ctx.tip) + 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 })) @@ -202,27 +264,37 @@ async function seedThree (ctx) { 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], ctx.armHeight), [], 'BF4 would seed a 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') + 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, 'the legacy row is at block ' + legacy.row.snapshot_block + ', not below the activation') - for (const s of [omits, control]) assert.ok(s.row.snapshot_block >= ctx.armHeight, 'a map-carrying row is below the activation at ' + s.row.snapshot_block) + 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/unit/scripts/attest_mirror_runner.test.js b/test/unit/scripts/attest_mirror_runner.test.js index 7efe554f..d4bb8827 100644 --- a/test/unit/scripts/attest_mirror_runner.test.js +++ b/test/unit/scripts/attest_mirror_runner.test.js @@ -4,13 +4,16 @@ 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'", @@ -21,24 +24,59 @@ function fixture () { "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 (phase === 'ready') { if (!fs.existsSync(state)) process.exit(31); event('ready') }", - "if (phase === 'seed') { if (!fs.existsSync(state)) process.exit(32); event('seed') }", - "if (phase === 'run') { event('run-start'); setTimeout(() => { event('run-end'); process.exit(leg.includes('fail') ? 7 : 0) }, 100) }", + "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')) - for (const relative of [ + 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, log, state: 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 () { @@ -113,6 +151,122 @@ describe('attest-mirror aggregate runner', function () { 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', @@ -130,13 +284,67 @@ describe('attest-mirror aggregate runner', function () { encoding: 'utf8', }) assert.strictEqual(result.status, 0, result.stderr) - assert.match(result.stdout, /2 legs passed, 0 failed/) + 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', 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 }) + } + }) +}) From 3c77cbabcc9b58449d907058a7e9a1488dbd7d2e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 19 Sep 2026 13:08:34 -0700 Subject: [PATCH 29/54] fix(rail): honour the DOGE miner pause file in withMiningPaused withMiningPaused() read BRIDGE_RAIL_MINER_PAUSE_FILE and nothing else, so asking it to pause DOGE silently did not pause DOGE: the external DOGE mine loop kept moving the tip while the drive believed it was held. Row 8's AT8 cap split (25 then 5) is only meaningful if that loop honours its own flag file, so the absent handling read as success. Honour BRIDGE_RAIL_DOGE_MINER_PAUSE_FILE alongside the BTC variable, writing and removing each configured file for the span. The flag writes move inside the try so a failed write now resumes mining instead of stranding the miner paused; the BTC file naming, contents and lifetime are otherwise unchanged. New unit suite covers DOGE pausing via its own variable, BTC still pausing via its own, the BTC path unmoved, neither variable set, an unwritable DOGE target, a throwing body, and flag files removed underneath it. GATE-REGISTRATION: pending, package.json locked by the v0.20.1 bump lane. Follow-on for whoever unlocks it: insert 'test/unit/helpers/bridgeRailMinerPause.test.js' as the first spec in the ci script's mocha argument list, leaving every other entry in place. --- test/helpers/bridgeRailVenue.js | 14 +- .../unit/helpers/bridgeRailMinerPause.test.js | 163 ++++++++++++++++++ 2 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 test/unit/helpers/bridgeRailMinerPause.test.js 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/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']); + }); +}); From fa728d984e780ae88a6c18f1825e017fbee2f407 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 19 Sep 2026 13:01:23 -0700 Subject: [PATCH 30/54] chore: drop the rail sibling-link preflight, which belongs to the platform root This offline preflight landed here by mistake. Its originating brief named the platform root, it was the only tool of its kind this repo carried, and the checker refuses this root while passing the platform one. It now lives at the platform root, where its test sits beside it as every peer checker keeps it. Removing it here leaves no dangling reference: no other file in this repo names it. --- claude/bin/check-rail-sibling-links.js | 206 ------------------ .../scripts/check-rail-sibling-links.test.js | 161 -------------- 2 files changed, 367 deletions(-) delete mode 100644 claude/bin/check-rail-sibling-links.js delete mode 100644 test/unit/scripts/check-rail-sibling-links.test.js diff --git a/claude/bin/check-rail-sibling-links.js b/claude/bin/check-rail-sibling-links.js deleted file mode 100644 index 7fd3e69b..00000000 --- a/claude/bin/check-rail-sibling-links.js +++ /dev/null @@ -1,206 +0,0 @@ -#!/usr/bin/env node -'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. - * - ********************************************************************** - * Offline preflight for the sibling repositories used by a rail drive. - * - * node check-rail-sibling-links.js [--json] [directory] - * - * Exit 0 means the root is usable, 1 means its local paths refuse, and - * 2 means the command line is invalid. - */ - -const fs = require('fs'); -const path = require('path'); - -const REQUIRED_RAIL_FILES = [ - 'xchain-hub/src/api.js', - 'xchain-indexer/src/api.js', -]; - -function isDirectory(candidate) { - return fs.statSync(candidate, { throwIfNoEntry: false })?.isDirectory() === true; -} - -function absoluteUsersPrefix(target) { - return target.match(/^\/Users\/[^/]+/)?.[0] || null; -} - -function inspectEntry(root, name) { - const entryPath = path.join(root, name); - const linkStat = fs.lstatSync(entryPath, { throwIfNoEntry: false }); - if (!linkStat) { - return { name, path: entryPath, type: 'missing', resolves: false, isDirectory: false }; - } - if (!linkStat.isSymbolicLink()) { - const directory = isDirectory(entryPath); - return { - name, - path: entryPath, - type: directory ? 'directory' : 'not-directory', - resolves: true, - isDirectory: directory, - }; - } - - const target = fs.readlinkSync(entryPath); - const resolvedTarget = path.isAbsolute(target) ? path.normalize(target) : path.resolve(root, target); - const resolvedStat = fs.statSync(entryPath, { throwIfNoEntry: false }); - return { - name, - path: entryPath, - type: resolvedStat ? 'resolving-symlink' : 'dangling-symlink', - target, - resolvedTarget, - resolves: Boolean(resolvedStat), - isDirectory: resolvedStat?.isDirectory() === true, - absoluteUsersPrefix: absoluteUsersPrefix(target), - }; -} - -function inspectRailRoot(defaultRoot, environment = process.env) { - const configured = String(environment.BRIDGE_RAIL_REPO_ROOT || '').trim(); - const repoRoot = configured ? path.resolve(configured) : defaultRoot; - const required = REQUIRED_RAIL_FILES.map((relativePath) => { - const wanted = path.join(repoRoot, relativePath); - return { - relativePath, - wanted, - exists: fs.statSync(wanted, { throwIfNoEntry: false })?.isFile() === true, - }; - }); - return { - source: configured ? 'environment' : 'default', - path: repoRoot, - resolves: isDirectory(repoRoot), - required, - }; -} - -function auditDirectory(requestedDirectory, environment = process.env) { - const directory = path.resolve(requestedDirectory); - const warnings = []; - const refusals = []; - - if (!isDirectory(directory)) { - refusals.push(`REFUSAL: ${directory} does not resolve to a directory; wanted a rail worktree directory.`); - return { directory, entries: [], railRepoRoot: null, warnings, refusals, ok: false }; - } - - const names = fs.readdirSync(directory).filter((name) => name.startsWith('xchain-')).sort(); - const entries = names.map((name) => inspectEntry(directory, name)); - if (entries.length === 0) { - refusals.push(`REFUSAL: ${directory} contains zero xchain-* entries; wanted at least one xchain-* sibling.`); - } - - for (const entry of entries) { - if (!entry.resolves) { - refusals.push(`REFUSAL: ${entry.path} does not resolve; wanted ${entry.resolvedTarget || entry.path}.`); - } else if (!entry.isDirectory) { - refusals.push(`REFUSAL: ${entry.path} resolves, but not to a directory; wanted ${entry.resolvedTarget || entry.path}.`); - } - if (entry.absoluteUsersPrefix) { - refusals.push(`REFUSAL: ${entry.path} points to ${entry.target}; prefix ${entry.absoluteUsersPrefix} must change for the runtime mount.`); - } - } - - const railRepoRoot = inspectRailRoot(directory, environment); - if (railRepoRoot.source === 'default') { - warnings.push(`WARNING: BRIDGE_RAIL_REPO_ROOT is unset; the venue default would be ${railRepoRoot.path}.`); - } - if (!railRepoRoot.resolves) { - refusals.push(`REFUSAL: BRIDGE_RAIL_REPO_ROOT names ${railRepoRoot.path}, which does not resolve to a directory; wanted ${railRepoRoot.path}.`); - } else { - for (const required of railRepoRoot.required) { - if (!required.exists) { - refusals.push(`REFUSAL: rail repository root ${railRepoRoot.path} is incomplete; wanted ${required.wanted}.`); - } - } - } - - return { directory, entries, railRepoRoot, warnings, refusals, ok: refusals.length === 0 }; -} - -function parseArgs(argv) { - let json = false; - let directory = null; - for (const arg of argv) { - if (arg === '--json') { - json = true; - } else if (arg.startsWith('-') || directory !== null) { - return { error: `bad argument: ${arg}` }; - } else { - directory = arg; - } - } - return { json, directory: directory || process.cwd() }; -} - -function formatHuman(result) { - const lines = [`check-rail-sibling-links: ${result.directory}`]; - for (const entry of result.entries) { - lines.push(` ${entry.name}: ${entry.type}${entry.target ? ` -> ${entry.target}` : ''}`); - } - if (result.railRepoRoot) { - const setting = result.railRepoRoot.source === 'environment' ? 'set' : 'unset'; - const complete = result.railRepoRoot.required.every((required) => required.exists); - lines.push(` BRIDGE_RAIL_REPO_ROOT: ${setting}; effective path ${result.railRepoRoot.path}; ` - + `resolves ${result.railRepoRoot.resolves ? 'yes' : 'no'}; required siblings present ` - + `${complete ? 'yes' : 'no'}`); - } - lines.push(...result.warnings, ...result.refusals); - if (result.ok) lines.push(`OK: ${result.directory} is fit to host a rail drive.`); - return lines.join('\n'); -} - -function main(argv = process.argv.slice(2), environment = process.env) { - const args = parseArgs(argv); - if (args.error) { - console.log(`check-rail-sibling-links: ${args.error}\n` - + 'usage: node check-rail-sibling-links.js [--json] [directory]'); - return 2; - } - - let result; - try { - result = auditDirectory(args.directory, environment); - } catch (error) { - const directory = path.resolve(args.directory); - result = { - directory, - entries: [], - railRepoRoot: null, - warnings: [], - refusals: [`REFUSAL: could not inspect ${directory}: ${error.message}`], - ok: false, - }; - } - console.log(args.json ? JSON.stringify(result, null, 2) : formatHuman(result)); - return result.ok ? 0 : 1; -} - -if (require.main === module) process.exit(main()); - -module.exports = { - REQUIRED_RAIL_FILES, - isDirectory, - absoluteUsersPrefix, - inspectEntry, - inspectRailRoot, - auditDirectory, - parseArgs, - formatHuman, - main, -}; diff --git a/test/unit/scripts/check-rail-sibling-links.test.js b/test/unit/scripts/check-rail-sibling-links.test.js deleted file mode 100644 index 3710266a..00000000 --- a/test/unit/scripts/check-rail-sibling-links.test.js +++ /dev/null @@ -1,161 +0,0 @@ -/********************************************************************* - * - * 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 { spawnSync } = require('child_process'); -const nodeTest = require('node:test'); - -const SCRIPT = path.join(__dirname, '..', '..', '..', 'claude', 'bin', 'check-rail-sibling-links.js'); -const checker = require(SCRIPT); -const describe = global.describe || nodeTest.describe; -const before = global.before || nodeTest.before; -const after = global.after || nodeTest.after; -const it = global.it || nodeTest.it; - -describe('check-rail-sibling-links', function () { - let fixtureBase; - let fixtureNumber; - - before(function () { - fixtureBase = fs.mkdtempSync(path.join(os.tmpdir(), 'check-rail-sibling-links-')); - fixtureNumber = 0; - }); - - after(function () { - fs.rmSync(fixtureBase, { recursive: true, force: true }); - }); - - function makeDirectory(prefix) { - fixtureNumber += 1; - const directory = path.join(fixtureBase, `${String(fixtureNumber).padStart(2, '0')}-${prefix}`); - fs.mkdirSync(directory, { recursive: true }); - return directory; - } - - function makeApiRepo(root, name) { - const repo = path.join(root, name); - fs.mkdirSync(path.join(repo, 'src'), { recursive: true }); - fs.writeFileSync(path.join(repo, 'src', 'api.js'), 'module.exports = {};\n'); - return repo; - } - - function healthyFixture(prefix = 'healthy') { - const container = makeDirectory(prefix); - const root = path.join(container, 'rail'); - const targets = path.join(container, 'targets'); - fs.mkdirSync(root); - makeApiRepo(root, 'xchain-hub'); - const indexer = makeApiRepo(targets, 'indexer'); - fs.symlinkSync(path.relative(root, indexer), path.join(root, 'xchain-indexer')); - return { container, root }; - } - - function run(root, environment = {}) { - const env = { ...process.env }; - delete env.BRIDGE_RAIL_REPO_ROOT; - Object.assign(env, environment); - const result = spawnSync(process.execPath, [SCRIPT, '--json', root], { encoding: 'utf8', env }); - return { ...result, json: JSON.parse(result.stdout) }; - } - - it('accepts real directories and a resolving relative symlink', function () { - const result = run(healthyFixture().root); - assert.strictEqual(result.status, 0); - assert.strictEqual(result.json.ok, true); - assert.deepStrictEqual(result.json.entries.map((entry) => entry.type), - ['directory', 'resolving-symlink']); - assert.match(result.json.warnings[0], /BRIDGE_RAIL_REPO_ROOT is unset/); - }); - - it('refuses a dangling symlink and names the target path', function () { - const { root } = healthyFixture('dangling'); - fs.unlinkSync(path.join(root, 'xchain-indexer')); - fs.symlinkSync('../targets/missing-indexer', path.join(root, 'xchain-indexer')); - const result = run(root); - assert.strictEqual(result.status, 1); - assert.match(result.json.refusals.join('\n'), /targets\/missing-indexer/); - assert.strictEqual(result.json.entries[1].type, 'dangling-symlink'); - }); - - it('refuses an absolute Users target', function () { - // Built from parts, never written as a literal: a hardcoded - // "/Users//..." string is itself a developer-machine-path - // shape and would trip this repo's own history leak scanner. - const usersPrefix = ['', 'Users', 'example'].join('/'); - const usersTarget = `${usersPrefix}/worktree/xchain-indexer`; - const { root } = healthyFixture('absolute-users'); - fs.unlinkSync(path.join(root, 'xchain-indexer')); - fs.symlinkSync(usersTarget, path.join(root, 'xchain-indexer')); - const result = run(root); - assert.strictEqual(result.status, 1); - assert.strictEqual(result.json.entries[1].absoluteUsersPrefix, usersPrefix); - assert.match(result.json.refusals.join('\n'), new RegExp(`prefix ${usersPrefix}`)); - }); - - it('refuses a directory with zero xchain entries', function () { - const result = run(makeDirectory('empty')); - assert.strictEqual(result.status, 1); - assert.match(result.json.refusals.join('\n'), /zero xchain-\* entries/); - }); - - it('refuses a nonexistent configured rail repository root and names it', function () { - const { root, container } = healthyFixture('missing-rail-root'); - const missing = path.join(container, 'missing-root'); - const result = run(root, { BRIDGE_RAIL_REPO_ROOT: missing }); - assert.strictEqual(result.status, 1); - assert(result.json.refusals.some((message) => message.includes(missing))); - }); - - it('refuses a rail repository root missing a spawned sibling API', function () { - const { root } = healthyFixture('missing-api'); - fs.unlinkSync(path.join(root, 'xchain-indexer', 'src', 'api.js')); - const result = run(root); - assert.strictEqual(result.status, 1); - assert.match(result.json.refusals.join('\n'), /xchain-indexer\/src\/api\.js/); - }); - - it('refuses an xchain entry that resolves to a file', function () { - const { root } = healthyFixture('file-entry'); - fs.writeFileSync(path.join(root, 'xchain-note'), 'not a repository\n'); - const result = run(root); - assert.strictEqual(result.status, 1); - assert.match(result.json.refusals.join('\n'), /xchain-note resolves, but not to a directory/); - }); - - it('emits valid machine-readable JSON', function () { - const result = run(healthyFixture('json').root); - assert.strictEqual(result.status, 0); - assert.strictEqual(result.stderr, ''); - assert.strictEqual(result.json.railRepoRoot.source, 'default'); - assert.strictEqual(result.json.railRepoRoot.required.every((entry) => entry.exists), true); - }); - - it('uses exit 2 for bad arguments', function () { - const result = spawnSync(process.execPath, [SCRIPT, '--unknown'], { encoding: 'utf8' }); - assert.strictEqual(result.status, 2); - assert.match(result.stdout, /usage:/); - }); - - it('follows links when deciding whether an entry is a directory', function () { - const source = fs.readFileSync(SCRIPT, 'utf8'); - assert.match(source, /fs\.statSync\(entryPath, \{ throwIfNoEntry: false \}\)/); - assert.doesNotMatch(source, /\.isDirectory\(\)\s*\|\|\s*[^\n]*isSymbolicLink/); - assert.strictEqual(checker.REQUIRED_RAIL_FILES.length, 2); - }); -}); From 7049e3c21272adecd42fc06c96fd3551a846e58b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 19 Sep 2026 14:04:34 -0700 Subject: [PATCH 31/54] test(at2b): prove the delivery fault is live before judging the forward-margin barrier AT2b armed a 25 s mirror-delivery delay, waited for finalization on the prompt indexer, then mined one block and asserted the delayed indexer reported attest_response_sync_barrier. On the 2026-09-18 aggregate it did not: indexer 1 answered status at height 212 against decoder 212, so this was never the HTTP-readiness shape. The delayed indexer spent the window clearing an older block backlog, the 25 s delay expired inside it, and block 212 carried an eligibility cutoff five seconds BEFORE the response effective_time. Nothing the assertion read was eligible to bind the response, so its absence said nothing about the barrier. The leg now catches both venue indexers to the decoder tip with zero lag before it mines, proves the delivery window is still live, proves the mined block crosses the response effective_time, observes the named barrier, and only then releases delivery. The delay becomes a ceiling rather than time the drill waits out, so a slow catch-up no longer consumes the fault. The window predicate moves to its own helper because the proxy's hold counters are CUMULATIVE: framesHeld > 0 proves the injection fired at some point and is not evidence that it is pending now. Both halves are covered offline and each was shown to be load-bearing by deleting it. --- test/attestMirror/at2b-forward-margin.test.js | 96 ++++++++++++------- test/attestMirror/helpers/deliveryWindow.js | 43 +++++++++ test/unit/helpers/deliveryWindow.test.js | 55 +++++++++++ 3 files changed, 162 insertions(+), 32 deletions(-) create mode 100644 test/attestMirror/helpers/deliveryWindow.js create mode 100644 test/unit/helpers/deliveryWindow.test.js 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/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/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) + }) +}) From 3581ba9108bcc2c9337dc7851207790bb45d4a98 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 19 Sep 2026 14:04:48 -0700 Subject: [PATCH 32/54] test(bf5): level the family baseline on hub-mirror readiness, not chain height alone Both of BF5's 2026-09-18 failures name indexer 1 and both describe the same process: committed height stuck at 263 while the decoder ran to 264 then 266, stallClass barrier_defer on match_sync_barrier. The inert match predicate can only clear from the stream watermark once the hub mirror reports bootstrapped, and levelIndexers proved only that every indexer's height equalled its decoder. A node that has committed every block the decoder has can still be draining its mirror bootstrap, so the drive declared its baseline on a node that had not finished starting and then read the resulting hold as the rule under test. statusSnapshot now carries configured, connected and bootstrapped straight off the indexer's status route, and levelIndexers can hold the baseline until every mirror is ready. The gate is OPT IN: the twelve family legs that pass today level on chain height exactly as before, and BF5 asks for the stricter wait by name, so this cannot turn a green leg red. mirrorReady fails CLOSED. An indexer with no mirror configured is ready because there is nothing to bootstrap, but an absent configured flag is "not ready" rather than "ready", so a status shape that stops carrying the field parks the baseline loudly instead of releasing on a field nobody reads any more. Offline coverage withholds bootstrap for one poll and proves the baseline does not release, proves the same withheld bootstrap DOES release when the gate was not asked for, and pins the fail-closed table. BF5 still needs one rail leg to confirm the reds clear; this makes that drive worth its 29 minutes. --- .../barrier_family/bf5_flag_day.test.js | 6 ++- .../helpers/barrierFamilyDrive.js | 47 +++++++++++++++++-- test/unit/helpers/barrierFamilyDrive.test.js | 44 +++++++++++++++++ 3 files changed, 91 insertions(+), 6 deletions(-) diff --git a/test/attestMirror/barrier_family/bf5_flag_day.test.js b/test/attestMirror/barrier_family/bf5_flag_day.test.js index 623d8832..b660401f 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') }) diff --git a/test/attestMirror/helpers/barrierFamilyDrive.js b/test/attestMirror/helpers/barrierFamilyDrive.js index a1759965..bcbdc510 100644 --- a/test/attestMirror/helpers/barrierFamilyDrive.js +++ b/test/attestMirror/helpers/barrierFamilyDrive.js @@ -91,12 +91,40 @@ async function bootFamilyVenue (opts) { 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) + await levelIndexers(built.venue, o.levelTimeoutMs, { requireMirrorReady: !!o.requireMirrorReady }) return { venue: built.venue, evidence, btc, armHeight, legacyBlock } } -/** Every indexer level with the decoder before a leg breaks anything, so a hold is the leg's doing. */ -async function levelIndexers (venue, timeoutMs) { +/** + * 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. + * + * `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 = [] @@ -105,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 } @@ -126,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, @@ -597,6 +633,7 @@ module.exports = { LEG_FLOOR_MS, bootFamilyVenue, levelIndexers, + mirrorReady, statusSnapshot, holdSnapshot, waitForStatus, 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') From 53d2d16ad498ddfb9ca0c02e75b1c899cb39c1d2 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 08:35:35 -0700 Subject: [PATCH 33/54] fix(rail): DOGE indexer regtest port is 3004 The helper advertised 3124 while the published regtest port is 3004. The chainRail unit tests cover it. --- test/helpers/chainRail.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From a32d98cb865262cd467055bad41676e061ce9c41 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 20:07:18 -0700 Subject: [PATCH 34/54] test(bridge-rail): run the token AT9 gates instead of quoting their results The token drive now ends with a suite that audits its own cases in run order, then spawns the ordinary CI gate and the activation constants parity test on Node 22 and judges each on its exit status and counts, treating the live tier's venue exit as red, before printing the dated acceptance record. --- test/integration/bridge_rail_token.test.js | 164 ++++++++++++++++++++- 1 file changed, 163 insertions(+), 1 deletion(-) 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'); + }); +}); From 251e76e6b4516d79bf211c785872ac143ed4ca50 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 21:05:41 -0700 Subject: [PATCH 35/54] test: pin offline suites and coin identity --- bin/pin-identity.js | 141 ++ bin/pins/at1-suite-titles.json | 3234 ++++++++++++++++++++++++++++++++ bin/pins/at1-wall-times.json | 340 ++++ bin/pins/identity.json | 23 + bin/suite-title-map.js | 490 +++++ 5 files changed, 4228 insertions(+) create mode 100644 bin/pin-identity.js create mode 100644 bin/pins/at1-suite-titles.json create mode 100644 bin/pins/at1-wall-times.json create mode 100644 bin/pins/identity.json create mode 100644 bin/suite-title-map.js 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, +}; From 568a92aa28c1a3a8eb3eb680238a9e49754875e7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 21 Sep 2026 16:19:50 -0700 Subject: [PATCH 36/54] test: an absent admit_blocks map builds the legacy canonical, never throws --- test/unit/helpers/barrierFamilyFixture.test.js | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/test/unit/helpers/barrierFamilyFixture.test.js b/test/unit/helpers/barrierFamilyFixture.test.js index ffca5337..6fc57f26 100644 --- a/test/unit/helpers/barrierFamilyFixture.test.js +++ b/test/unit/helpers/barrierFamilyFixture.test.js @@ -79,9 +79,8 @@ describe('barrierFamilyFixture: the crossing a legacy-era seed needs', function }) // 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, AT or above it the builder - // refuses a NULL admission map on any row whose era block is at or above the producer - // activation (adjudicated 2026-09-18: that refusal is the product working). + // 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 @@ -95,20 +94,20 @@ describe('barrierFamilyFixture: the crossing a legacy-era seed needs', function 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.match(String(out.canonicalAtArmHeight), /has no admit_blocks/, 'a NULL-map row at the activation must still be refused') + 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 is refused at every height a chain can reach. + // 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.match(String(out.canonicalAtArmHeight), /has no admit_blocks/) - assert.match(String(out.canonicalAtLegacyBlock), /has no admit_blocks/, - 'armed at genesis there is no height at which a NULL-map row builds a canonical') + 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') }) /** From 94b82e2b41ef06ca1735e46ca5a4266ae1475a55 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 21 Sep 2026 17:59:16 -0700 Subject: [PATCH 37/54] test: bind price derivation coin code explicitly --- test/actions/xchainPriceDerivation.test.js | 3 +++ 1 file changed, 3 insertions(+) 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 From 2f23febed8ad8df6b562aa99062fbe070d2a55c3 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 21 Sep 2026 19:47:34 -0700 Subject: [PATCH 38/54] fix(topology): retry the published DOGE port only past a real method-not-found rpcConnector's port fallback (conventional 3124 -> published 3004) returned a reachable primary's JSON-RPC error as final the moment the response carried one, even when that error was -32601 method-not-found: a reachable indexer that has never heard of the called method is not this coin's indexer, so that specific code now continues to the next candidate instead of being reported as the answer. Any other error (a domain rejection, an auth failure) still returns immediately, since it proves the reached server does own the method. --- test/helpers/hubMirrorTopology.js | 63 ++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 5 deletions(-) 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 } } } From efb2af7f998bb028f87d930b6f9c6cb123623c8b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 21 Sep 2026 19:47:15 -0700 Subject: [PATCH 39/54] test(ci): enumerate the bridge-rail DOGE miner-pause suite in the ci gate test/unit/helpers/bridgeRailMinerPause.test.js existed but was not named in any script, so its 7 cases never ran. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 57c0f836..812718cd 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "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/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'", From 808abafbd7f84dbfef041f1ce5daf4ba9ba84491 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 21 Sep 2026 19:27:38 -0700 Subject: [PATCH 40/54] test: BF5 inert-node assertion carries a logTail, venue logs survive teardown The inert-node status checks in assertDivergence lacked the logTail the armed-node assertion already attached, and the after hook stopped the venue without ever printing either indexer's log, so a red outside that one assertion left BF5 undiagnosable from its own output. --- .../barrier_family/bf5_flag_day.test.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/test/attestMirror/barrier_family/bf5_flag_day.test.js b/test/attestMirror/barrier_family/bf5_flag_day.test.js index b660401f..a52dc9af 100644 --- a/test/attestMirror/barrier_family/bf5_flag_day.test.js +++ b/test/attestMirror/barrier_family/bf5_flag_day.test.js @@ -66,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 () { @@ -116,8 +123,10 @@ async function assertDivergence (ctx) { 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') + assert.strictEqual(inertNow.height, ctx.B - 1, 'the inert node committed the future-stamped block early: ' + JSON.stringify(inertNow) + + '\n' + ctx.venue.logTail('indexer' + INERT)) + assert.strictEqual(inertNow.stallClass, 'future_block_wait', 'the inert node reports ' + inertNow.stallClass + ', not future_block_wait' + + '\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') From 9387f30182a9b7b7e59a5b0985f6af97d206b111 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 14:35:46 -0700 Subject: [PATCH 41/54] fix(bf6): fetch the pinned mixed-hub commit when the clone source is shallow The CI venue's hub sibling is a depth-1 clone, so cloning from it never carried 529ebe7a and the BF6 preparer's checkout failed with "unable to read tree", holding develop red since 2026-09-19. When the pinned commit is absent after the clone, fetch that one commit from the hub's declared upstream. --- scripts/prepare-bf6-mixed-hub.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/scripts/prepare-bf6-mixed-hub.js b/scripts/prepare-bf6-mixed-hub.js index 6048f9af..53647971 100644 --- a/scripts/prepare-bf6-mixed-hub.js +++ b/scripts/prepare-bf6-mixed-hub.js @@ -30,12 +30,25 @@ function repositorySource (buildHub, explicitSource) { 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') @@ -54,6 +67,11 @@ function createMixedCheckout (config) { 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') From 308e74412919bb2ab6e5c2524f27e65c254f385f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 18:33:54 -0700 Subject: [PATCH 42/54] test(e2e): replace fixed sleeps in the multi-hub activation and byzantine suites with condition waits Lowers the sleep-flake baseline to match. --- scripts/sleep-flake-baseline.json | 4 ++-- ...iHubActivationBoundary.integration.test.js | 9 +++++---- .../multiHubByzantine.integration.test.js | 8 ++++---- .../multiHubByzantineF2.integration.test.js | 20 +++++++++---------- 4 files changed, 21 insertions(+), 20 deletions(-) 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/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)'); }); From fca9d3ab1aed4770569c80349463a4706a9a4473 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 19:28:28 -0700 Subject: [PATCH 43/54] test: wait for the BF5 inert future-block stall Replace the racy one-shot inert status read with a bounded status poll. Preserve the B-1 height assertion after observing the expected stall class. --- test/attestMirror/barrier_family/bf5_flag_day.test.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/attestMirror/barrier_family/bf5_flag_day.test.js b/test/attestMirror/barrier_family/bf5_flag_day.test.js index a52dc9af..98f6d1f6 100644 --- a/test/attestMirror/barrier_family/bf5_flag_day.test.js +++ b/test/attestMirror/barrier_family/bf5_flag_day.test.js @@ -122,10 +122,12 @@ 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) + + // 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.stallClass, 'future_block_wait', 'the inert node reports ' + inertNow.stallClass + ', not future_block_wait' + + 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) From c11001992a6355234296739a010c6c918c1acd49 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 20:11:14 -0700 Subject: [PATCH 44/54] test(e2e): pin the root-bearing pre-flag-day checkpoint vector and XPOLICY_MAX_MEMBERS, fix the phase2 mutation scope --- package.json | 4 +- stryker.phase2.config.mjs | 20 ++-- .../multiHubFeaturesN10.integration.test.js | 4 + ...ultiHubOracleByzantine.integration.test.js | 3 + ...multiHubOracleWeighted.integration.test.js | 3 + ...multiHubStateAnchorN10.integration.test.js | 3 + ...HubStateAnchorWeighted.integration.test.js | 3 + .../multiHubXcallWeighted.integration.test.js | 3 + .../oracleBatchQuorum.integration.test.js | 3 + .../parity/checkpointCommitmentParity.test.js | 84 +++++++++++--- .../protocol-size-limits.regression.js | 15 +++ .../scripts/stubbed_lane_hermetic.test.js | 103 ++++++++++++++++++ 12 files changed, 226 insertions(+), 22 deletions(-) create mode 100644 test/unit/scripts/stubbed_lane_hermetic.test.js diff --git a/package.json b/package.json index 812718cd..4df5e476 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "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/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": "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'", @@ -74,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/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/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/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/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 ')) + }) +}) From 4064c104d657bf09818cbbd13f966eb389db0f5a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 20:49:14 -0700 Subject: [PATCH 45/54] refactor(e2e): route bare console calls through the vendored logger --- src/XChainIndexerConnector.js | 12 +++++++----- src/db.js | 25 ++++++++++++++----------- 2 files changed, 21 insertions(+), 16 deletions(-) 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 78ec3ec2..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 } @@ -1351,7 +1354,7 @@ class Database { return row } } catch(err) { - console.log(err) + logger.info(err) } await this.sleep(1000) @@ -1380,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 + ')') @@ -1407,7 +1410,7 @@ 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 @@ -1476,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') } From 386a57a85e11c7aa1ba8d58f75a580fe3668c2e6 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 00:31:07 -0700 Subject: [PATCH 46/54] fix(e2e): expose live-tier verdicts to the gate Print parseable passing and failing counts for the live tier, preserve the Docker-only path when requested, and classify a pure before-all database privilege failure as a venue error. Mixed failures and unknown-database errors remain red commit failures. --- scripts/run-live-tier.js | 130 ++++++++++++++++++++++++++++++++++----- 1 file changed, 115 insertions(+), 15 deletions(-) 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))) From cf36dcc5cb35da5d2da852284ab0bb7584b3544c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 09:21:12 -0700 Subject: [PATCH 47/54] fix(e2e): stop racing the gated-SEND negative check against its own wait The bare-SEND rejection test can only conclude "rejected" by letting waitForSend exhaust its own give-up on every healthy run, not just on failure. That give-up is bounded by db.js's adaptive-wait budget (timeMax x (1 + WAIT_MAX_EXTENSIONS) = 60000 x 4 = 240000ms), sitting after the P2SH 2-tx confirm and utxo-tracker prefix the same test pays. The test's own mocha timeout was a second, separately maintained copy of that budget (240000ms), set before _waitFor grew extension support and never revisited after. With zero margin between the two, a run where the indexer used all three extensions (nightly-e2e run 35829816064, bitcoin leg, test/actions/gated_file.test.js:204) hit mocha's timer first and turned a correct rejection into a false "Timeout of 240000ms exceeded" failure. Disable the outer bound for this one negative check instead of raising the number again, matching how every other waitFor-driven test in test/actions already relies on _waitFor's own bounded give-up rather than a duplicate mocha timeout. --- test/actions/gated_file.test.js | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) 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. From d9a4de1455492db46ab25d323c72b5c13a44f407 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 09:31:13 -0700 Subject: [PATCH 48/54] fix(gas): pay off-BTC gas from one bridged reservoir instead of a bridge per address Every funded address on the litecoin and dogecoin legs bridged its own gas from BTC and waited out the destination relay margin (LTC 600 s, DOGE 240 s). On nightly run 35829816064 the lock-to-credit leg averaged 518 s on litecoin and 404 s on dogecoin per address, and the suite funds a few hundred addresses, so both legs finished 8 and 15 tests in the 360-minute job budget while bitcoin, whose faucet is a one-block local MINT, finished 272. ensureGasBalance off BTC now SENDs the gas tick from a per-chain reservoir that is bridged once (500 000 XCHAIN, E2E_GAS_RESERVOIR_AMOUNT to override) and refilled on a shortfall; initialCheck fills it where it used to bridge a throwaway 1. bridgeGasIn mints above MAX_MINT in chunks and still locks the whole amount in one XBRIDGE. Suites that prove the bridge call it directly. --- test/helpers/gasHelper.js | 134 ++++++++++++++++++++++++++-- test/initialCheck.test.js | 15 ++-- test/unit/helpers/gasHelper.test.js | 105 +++++++++++++++++++++- 3 files changed, 236 insertions(+), 18 deletions(-) diff --git a/test/helpers/gasHelper.js b/test/helpers/gasHelper.js index 4ef9382c..7b0488c3 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 the 2026-09-23 nightly (run 35829816064) 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,6 +158,8 @@ 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, @@ -144,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 + "|" @@ -202,12 +270,66 @@ module.exports = { // 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/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/unit/helpers/gasHelper.test.js b/test/unit/helpers/gasHelper.test.js index 8c3ffa4c..f1cbe2d4 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 }) + }) + }) + + // Run 35829816064: 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) From 7f4a64d29c6dadbc1794686f2e40b3ebbcee3c97 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 09:46:21 -0700 Subject: [PATCH 49/54] chore(release): v0.20.1 --- CHANGELOG.md | 6 ++++++ README.md | 2 +- package-lock.json | 8 ++++---- package.json | 2 +- 4 files changed, 12 insertions(+), 6 deletions(-) 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/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/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 4df5e476..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", From fffe2a1520cc05ed1796c5c14e15e79c58142e10 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 10:57:44 -0700 Subject: [PATCH 50/54] Remove internal CI references from public comments Preserve the useful performance and timeout rationale without publishing the specific internal run identifiers. Comment text only; tests and runtime behavior unchanged. --- test/helpers/gasHelper.js | 2 +- test/unit/helpers/gasHelper.test.js | 2 +- test/unit/src/xchain_explorer_connector.test.js | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/helpers/gasHelper.js b/test/helpers/gasHelper.js index 7b0488c3..5dde68cb 100644 --- a/test/helpers/gasHelper.js +++ b/test/helpers/gasHelper.js @@ -52,7 +52,7 @@ 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 the 2026-09-23 nightly (run 35829816064) the lock-to-credit +// 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 diff --git a/test/unit/helpers/gasHelper.test.js b/test/unit/helpers/gasHelper.test.js index f1cbe2d4..716a90f9 100644 --- a/test/unit/helpers/gasHelper.test.js +++ b/test/unit/helpers/gasHelper.test.js @@ -135,7 +135,7 @@ describe('gasHelper', () => { }) }) - // Run 35829816064: the litecoin leg finished 8 tests in six hours because every + // 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', () => { diff --git a/test/unit/src/xchain_explorer_connector.test.js b/test/unit/src/xchain_explorer_connector.test.js index 70b83e2c..e145c237 100644 --- a/test/unit/src/xchain_explorer_connector.test.js +++ b/test/unit/src/xchain_explorer_connector.test.js @@ -63,8 +63,8 @@ describe('XChainExplorerConnector', function () { // 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 (xchain-node run 35440776385, LTC/DOGE legs, 2026-09-19) instead - // of failing it fast the way the known "answers 503" shape does. + // 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(); From 83c9946cd19cbf0a455c92d43c2d8159373d1f8c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 13:40:53 -0700 Subject: [PATCH 51/54] nativeFeeHelper: size the LTC/DOGE fee output from the indexer's current prices and feequote, and re-seed a displaced seed --- test/helpers/envelopeHelper.js | 2 +- test/helpers/nativeFeeHelper.js | 134 +++++++++++- .../lib/01_create_and_send_transaction.js | 5 +- test/unit/helpers/nativeFeeHelper.test.js | 197 ++++++++++++++++++ 4 files changed, 329 insertions(+), 9 deletions(-) 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/nativeFeeHelper.js b/test/helpers/nativeFeeHelper.js index 2fc119f5..269a9a13 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,25 @@ 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 + // 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. @@ -460,11 +485,105 @@ 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, or a hub round (below every fixture's round) valuing the coin. A pair +// some suite re-priced for its own case carries a fixture round and is left alone, so +// the fee is sized to it instead. Never true on a venue that publishes its own prices. +function seedDisplaced(prices){ + if (NO_PRICE_SEED) return false + if (!prices.available) return true + return !isSeedPair(prices) && !(prices.oracleRound >= FIXTURE_ROUND_FLOOR) +} + +// 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. +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 0 + const sats = Number(q.requiredFeeSats) + if (!Number.isFinite(sats) || sats < 0) return null + return Math.ceil(sats * FEE_HEADROOM) +} + +// 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. +async function nativeFeeSats(wire, source){ + let prices = await readFeePrices() + // 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) { + _lastDisplacedReseedMs = Date.now() + console.log('nativeFeeHelper: the indexer no longer prices off the seed (' + + (prices.available ? global.COIN_CODE + '/USD=' + prices.coinUsd + ' XCHAIN/USD=' + + prices.xchainUsd + ' round ' + prices.oracleRound : prices.error) + '); re-seeding') + await seedGlobalPrices(true) + prices = await readFeePrices() + } + if (!prices || !prices.available) return FLAT_FEE_SATS + const budget = rawSatsForXchain(FEE_BUDGET_XCHAIN, prices) + if (Math.round(budget) <= FLAT_FEE_SATS) return FLAT_FEE_SATS + const quoted = await quoteActionSats(wire, source) + const sats = (quoted != null) ? quoted : Math.ceil(budget * FEE_HEADROOM) + return Math.max(FLAT_FEE_SATS, 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 +594,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/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/helpers/nativeFeeHelper.test.js b/test/unit/helpers/nativeFeeHelper.test.js index b596a4d3..a698008a 100644 --- a/test/unit/helpers/nativeFeeHelper.test.js +++ b/test/unit/helpers/nativeFeeHelper.test.js @@ -496,3 +496,200 @@ 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 + + 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 + const p = 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 = [] + quote = null + 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, + clearPair: async () => {}, + 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 + delete require.cache[HELPER_PATH] + }) + + const quotes = () => calls.filter(c => c.method === 'feequote') + + 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') + }) + + 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, and still prices a live round it could not displace', async () => { + schedules = [LIVE_HUB] + quote = { supported: true, valid: true, requiredFeeSats: 1752151752 } + 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') + const afterFirst = seeded.length + assert(afterFirst > 0) + await helper.nativeFeeSats('ISSUE|0|T|1|1|0|d|1', 'DSrc111') + assert.strictEqual(seeded.length, afterFirst, 'a second displaced call inside the window must not re-seed') + }) + + 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 } + 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) }) + }) +}) From f8d41ab2e65115bbcea4e98f45e4e7edb3c39b11 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 13:40:53 -0700 Subject: [PATCH 52/54] Fund reorg drills through gasHelper and mint a self-issued open-mint token where the MINT is the subject --- .../address_id_reorg_deterministic.test.js | 43 +++++++++++-------- test/actions/chained_mint_reservation.test.js | 22 ++++++---- test/actions/compaction_fix_live.test.js | 35 +++++++++------ test/actions/reorg_balances.test.js | 13 +++--- .../tick_id_reorg_deterministic.test.js | 10 ++--- 5 files changed, 71 insertions(+), 52 deletions(-) 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/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 } } From aae96f4ce9e6cb9efa98038d20b7a91a70555abf Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 15:57:44 -0700 Subject: [PATCH 53/54] Seed price fixtures above the hub's id space so a mirrored oracle round cannot rewrite them hub_db_sync keeps the hub's wire id for price_snapshots and upserts ON DUPLICATE KEY UPDATE without touching round_number or coin_pair. A fixture inserted with AUTO_INCREMENT took MAX(id)+1, the id the hub's next round arrives with, so each finalized round overwrote the seed rows' prices in place while they kept their sentinel rounds. The indexer then priced fees off another pair's live price until the next re-seed: MT3 DOGE shard 2 (run 35925455269, an ISSUE quoted at 17.5 DOGE) and LTC shard 1 (a flat-fee DEPLOY that owed 7.8 LTC). priceSnapshotHelper.seedSnapshot, native_fee_live and native_fee_dispenser now delete the (round, pair) row and insert at GREATEST(MAX(id)+1, 1e12). As defence, nativeFeeHelper treats its own seed round carrying a non-seed pair as displaced, forces one re-seed when an over-flat quote is displaced or above the sender's balance, and refuses such a quote with every price it saw. --- test/actions/native_fee_dispenser.test.js | 9 +- test/actions/native_fee_live.test.js | 12 +- test/helpers/nativeFeeHelper.js | 118 +++++++++++++++--- test/helpers/priceSnapshotHelper.js | 51 ++++++-- test/unit/helpers/nativeFeeHelper.test.js | 113 +++++++++++++++-- .../helpers/priceSnapshotFixtureId.test.js | 83 ++++++++++++ 6 files changed, 339 insertions(+), 47 deletions(-) create mode 100644 test/unit/helpers/priceSnapshotFixtureId.test.js 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/helpers/nativeFeeHelper.js b/test/helpers/nativeFeeHelper.js index 269a9a13..bf282192 100644 --- a/test/helpers/nativeFeeHelper.js +++ b/test/helpers/nativeFeeHelper.js @@ -76,6 +76,11 @@ const FIXTURE_ROUND_FLOOR = 990000 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. @@ -97,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 @@ -508,13 +514,43 @@ function isSeedPair(prices){ } // True when the prices the indexer reads are no longer anything this suite put there: -// none at all, or a hub round (below every fixture's round) valuing the coin. A pair -// some suite re-priced for its own case carries a fixture round and is left alone, so +// 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 - return !isSeedPair(prices) && !(prices.oracleRound >= FIXTURE_ROUND_FLOOR) + 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 @@ -530,6 +566,8 @@ function rawSatsForXchain(xchainAmount, prices){ // 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 @@ -540,10 +578,33 @@ async function quoteActionSats(wire, source){ 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 0 + if (q.feeExempt) return { sats: 0, prices: null } const sats = Number(q.requiredFeeSats) if (!Number.isFinite(sats) || sats < 0) return null - return Math.ceil(sats * FEE_HEADROOM) + 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). @@ -557,24 +618,47 @@ async function quoteActionSats(wire, source){ // 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) { - _lastDisplacedReseedMs = Date.now() - console.log('nativeFeeHelper: the indexer no longer prices off the seed (' + - (prices.available ? global.COIN_CODE + '/USD=' + prices.coinUsd + ' XCHAIN/USD=' + - prices.xchainUsd + ' round ' + prices.oracleRound : prices.error) + '); re-seeding') - await seedGlobalPrices(true) - prices = await readFeePrices() + prices = await reseedDisplaced('the indexer no longer prices off the seed (' + + describePrices('feeschedule', prices) + ')') + reseeded = true + seen.push(describePrices('feeschedule after re-seed', prices)) } - if (!prices || !prices.available) return FLAT_FEE_SATS - const budget = rawSatsForXchain(FEE_BUDGET_XCHAIN, prices) - if (Math.round(budget) <= FLAT_FEE_SATS) return FLAT_FEE_SATS - const quoted = await quoteActionSats(wire, source) - const sats = (quoted != null) ? quoted : Math.ceil(budget * FEE_HEADROOM) - return Math.max(FLAT_FEE_SATS, sats) + 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 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/unit/helpers/nativeFeeHelper.test.js b/test/unit/helpers/nativeFeeHelper.test.js index a698008a..2e408dd1 100644 --- a/test/unit/helpers/nativeFeeHelper.test.js +++ b/test/unit/helpers/nativeFeeHelper.test.js @@ -505,7 +505,7 @@ describe('nativeFeeHelper.seedGlobalPrices hub seeding', () => { describe('nativeFeeHelper.nativeFeeSats', () => { const SNAPSHOT_PATH = require.resolve('../../helpers/priceSnapshotHelper') let savedSnapshotModule, savedCoin, savedNetwork, savedConnector, savedLog - let calls, seeded, schedules, quote, chainTime + 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 } @@ -518,7 +518,9 @@ describe('nativeFeeHelper.nativeFeeSats', () => { calls.push({ method, params }) if (method === 'feeschedule') { const n = calls.filter(c => c.method === 'feeschedule').length - const p = schedules[Math.min(n, schedules.length) - 1] + // 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' } @@ -541,12 +543,16 @@ describe('nativeFeeHelper.nativeFeeSats', () => { 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, - clearPair: async () => {}, + // 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() @@ -561,10 +567,16 @@ describe('nativeFeeHelper.nativeFeeSats', () => { 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] @@ -572,6 +584,82 @@ describe('nativeFeeHelper.nativeFeeSats', () => { 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 () => { @@ -630,16 +718,24 @@ describe('nativeFeeHelper.nativeFeeSats', () => { assert(seeded.length > 0, 'an unpriceable indexer must get the seed back') }) - it('rate-limits the displaced re-seed, and still prices a live round it could not displace', async () => { + 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') - const afterFirst = seeded.length - assert(afterFirst > 0) - await helper.nativeFeeSats('ISSUE|0|T|1|1|0|d|1', 'DSrc111') - assert.strictEqual(seeded.length, afterFirst, 'a second displaced call inside the window must not re-seed') + assert.strictEqual(reseeds, 1) }) it('keeps the flat fee when the indexer cannot answer at all', async () => { @@ -663,6 +759,7 @@ describe('nativeFeeHelper.nativeFeeSats', () => { 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)) 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') + } + }) +}) From 0f209a4821c02ae22a1814f861cc65f4a50f1823 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 17:50:57 -0700 Subject: [PATCH 54/54] Pin the Node base image by digest to the consensus runtime Pinning by digest keeps the build on the exact V8/ICU build xchain-vm's consensus runtime check expects, since the floating tag can advance to a patch that fails it. --- Dockerfile | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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