diff --git a/CHANGELOG.md b/CHANGELOG.md index b3cde483..6e5c774a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.26.0] - 2026-10-20 + +### Added +- Exposed the reorg-halt state. + +### Changed +- Gated the P2WSH source walk-back by chain activation. +- Armed exact-integer dispenser expiration on regtest from genesis. +- Pinned the decoder runtime to the Node 22 glibc image. +- Armed DISPENSER_WIDE_EXPIRATION_GATES and EXACT_INTEGER_EXPIRATION_ACTIVATION on testnet at block time 1792605600. + +### Fixed +- Hardened migration guards, duplicate output handling and mempool re-parsing. + ## [0.25.0] - 2026-10-18 ### Changed diff --git a/Dockerfile b/Dockerfile index 5df2b448..b31d29e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ -FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS dependencies +FROM node:22-bookworm-slim@sha256:c3de60bf2f9dd0ac6370e6117950ff62d6e339527e7472301c9c78a017978392 AS dependencies WORKDIR /XChainDecoder COPY ./package.json /XChainDecoder/package.json COPY ./package-lock.json /XChainDecoder/package-lock.json RUN npm ci --omit=dev -FROM node:25.9.0@sha256:78839ac448c23517f8eab2e8f7943d9b4f73979eb7f8bed2c73dbf72ff869e7b AS decoder +FROM node:22-bookworm-slim@sha256:c3de60bf2f9dd0ac6370e6117950ff62d6e339527e7472301c9c78a017978392 AS decoder WORKDIR /XChainDecoder COPY ./package.json /XChainDecoder/package.json COPY ./package-lock.json /XChainDecoder/package-lock.json diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index e9270361..51d612c1 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -6,10 +6,6 @@ "E2E: ACTION Decoding ACTION payload edge cases A4.3:should handle ACTION with special characters in memo", "E2E: ACTION Decoding ACTION payload edge cases A4.4:should handle minimum-length ACTION" ], - "033b7f903629733d": [ - "DISPENSER_PURGE_GRACE_ACTIVATION conformance is value-identical to the canonical map in xchain-documentation", - "DISPENSER_PURGE_GRACE_ACTIVATION conformance keeps mainnet and testnet unarmed while regtest is genesis-active" - ], "0406fb865bdaa059": [ "Database.destructiveAutoStatement() storage engine @regression flags a CREATE TABLE that names any engine other than InnoDB", "Database.destructiveAutoStatement() storage engine @regression flags every ALTER TABLE that names an ENGINE, InnoDB included", @@ -22,17 +18,43 @@ "Database#parseExpectedIndexes() parses a regular CREATE INDEX", "Database#parseExpectedIndexes() returns [] when no CREATE INDEX statements found" ], - "054fdd1916b39a5f": [ - "Database error-path transactionConnection branches createAddress: swallows INSERT error even with active transactionConnection", - "Database error-path transactionConnection branches deleteOpenDispensers: calls endTransaction when transactionConnection is active on generic error", - "Database error-path transactionConnection branches insertBlock: calls endTransaction when transactionConnection is active on generic error", - "Database error-path transactionConnection branches insertDispenser: calls endTransaction when transactionConnection is active on generic error", - "Database error-path transactionConnection branches insertEvent: a transaction-active error frees the transaction lock (no deadlock)", - "Database error-path transactionConnection branches insertEvent: calls endTransaction (rollback + frees lock) when a transaction is active on generic error", - "Database error-path transactionConnection branches insertMempoolTransaction: calls endTransaction when transactionConnection is active on generic error", - "Database error-path transactionConnection branches insertTransaction: calls endTransaction when transactionConnection is active on generic error", - "Database error-path transactionConnection branches insertTransactionOutput: calls endTransaction when transactionConnection is active on generic error", - "Database#ensureMigrationsLedger() calls CREATE TABLE IF NOT EXISTS schema_migrations on the connection" + "05b8d0b9317eedcf": [ + "AuxPoW stripped-block integrity @regression a malformed AuxPoW section is refused, never a block [REGRESSION P1] AuxPoW section with nothing after it: getBlockWithoutAuxPow rejects tagged auxPowParseFailure", + "AuxPoW stripped-block integrity @regression a malformed AuxPoW section is refused, never a block [REGRESSION P1] AuxPoW section with nothing after it: the strip throws instead of returning a block", + "AuxPoW stripped-block integrity @regression a malformed AuxPoW section is refused, never a block [REGRESSION P1] chain branch count lowered by one: getBlockWithoutAuxPow rejects tagged auxPowParseFailure", + "AuxPoW stripped-block integrity @regression a malformed AuxPoW section is refused, never a block [REGRESSION P1] chain branch count lowered by one: the strip throws instead of returning a block", + "AuxPoW stripped-block integrity @regression a malformed AuxPoW section is refused, never a block [REGRESSION P1] chain branch count raised by one: getBlockWithoutAuxPow rejects tagged auxPowParseFailure", + "AuxPoW stripped-block integrity @regression a malformed AuxPoW section is refused, never a block [REGRESSION P1] chain branch count raised by one: the strip throws instead of returning a block", + "AuxPoW stripped-block integrity @regression a malformed AuxPoW section is refused, never a block [REGRESSION P1] coinbase branch count raised by one: getBlockWithoutAuxPow rejects tagged auxPowParseFailure", + "AuxPoW stripped-block integrity @regression a malformed AuxPoW section is refused, never a block [REGRESSION P1] coinbase branch count raised by one: the strip throws instead of returning a block", + "AuxPoW stripped-block integrity @regression a malformed AuxPoW section is refused, never a block [REGRESSION P1] eight zero bytes after the AuxPoW section: getBlockWithoutAuxPow rejects tagged auxPowParseFailure", + "AuxPoW stripped-block integrity @regression a malformed AuxPoW section is refused, never a block [REGRESSION P1] eight zero bytes after the AuxPoW section: the strip throws instead of returning a block", + "AuxPoW stripped-block integrity @regression a malformed AuxPoW section is refused, never a block [REGRESSION P1] one zero byte after the AuxPoW section: getBlockWithoutAuxPow rejects tagged auxPowParseFailure", + "AuxPoW stripped-block integrity @regression a malformed AuxPoW section is refused, never a block [REGRESSION P1] one zero byte after the AuxPoW section: the strip throws instead of returning a block", + "AuxPoW stripped-block integrity @regression a malformed AuxPoW section is refused, never a block [REGRESSION P1] parent header dropped: getBlockWithoutAuxPow rejects tagged auxPowParseFailure", + "AuxPoW stripped-block integrity @regression a malformed AuxPoW section is refused, never a block [REGRESSION P1] parent header dropped: the strip throws instead of returning a block", + "AuxPoW stripped-block integrity @regression a malformed AuxPoW section is refused, never a block [REGRESSION P1] the legacy long-header path refuses a cut that leaves trailing bytes", + "AuxPoW stripped-block integrity @regression a real merge-mined block [REGRESSION P1] decodes the stripped block to the real txids", + "AuxPoW stripped-block integrity @regression a real merge-mined block [REGRESSION P1] strips to the pure block when getblockheader carries the AuxPoW bytes", + "AuxPoW stripped-block integrity @regression a real merge-mined block [REGRESSION P1] strips to the pure block when getblockheader returns the 80-byte header", + "AuxPoW stripped-block integrity @regression a real merge-mined block [REGRESSION P1] the fixture header hashes to the real block hash", + "AuxPoW stripped-block integrity @regression the plain path checks the same three things [REGRESSION P1] bitcoin-mainnet: accepts the real body and keeps the 80-byte header-only parse", + "AuxPoW stripped-block integrity @regression the plain path checks the same three things [REGRESSION P1] bitcoin-mainnet: refuses a merkle root that does not match the transactions", + "AuxPoW stripped-block integrity @regression the plain path checks the same three things [REGRESSION P1] bitcoin-mainnet: refuses a tx count of zero with transactions trailing", + "AuxPoW stripped-block integrity @regression the plain path checks the same three things [REGRESSION P1] bitcoin-mainnet: refuses bytes after the last transaction", + "AuxPoW stripped-block integrity @regression the plain path checks the same three things [REGRESSION P1] dogecoin-mainnet: accepts the real body and keeps the 80-byte header-only parse", + "AuxPoW stripped-block integrity @regression the plain path checks the same three things [REGRESSION P1] dogecoin-mainnet: refuses a merkle root that does not match the transactions", + "AuxPoW stripped-block integrity @regression the plain path checks the same three things [REGRESSION P1] dogecoin-mainnet: refuses a tx count of zero with transactions trailing", + "AuxPoW stripped-block integrity @regression the plain path checks the same three things [REGRESSION P1] dogecoin-mainnet: refuses bytes after the last transaction", + "AuxPoW stripped-block integrity @regression the plain path checks the same three things [REGRESSION P1] litecoin-mainnet: accepts the real body and keeps the 80-byte header-only parse", + "AuxPoW stripped-block integrity @regression the plain path checks the same three things [REGRESSION P1] litecoin-mainnet: refuses a merkle root that does not match the transactions", + "AuxPoW stripped-block integrity @regression the plain path checks the same three things [REGRESSION P1] litecoin-mainnet: refuses a tx count of zero with transactions trailing" + ], + "05c22e4bac27a7bd": [ + "fresh decoder database records its migrations before creating tables leaves the ledger exactly as it was across a second boot", + "fresh decoder database records its migrations before creating tables matches the schema a full migration run produces on an unseeded build", + "fresh decoder database records its migrations before creating tables records every committed migration with the runner checksum and mode, and replays none", + "fresh decoder database records its migrations before creating tables seeds nothing on a database that already holds a declared table" ], "05ef33730e0f85a6": [ "BATCH dispenser registration a dispenser created inside a BATCH registers NOTHING below the gate (the live defect, preserved for replay)", @@ -250,6 +272,14 @@ "Boundary: Multisig Zero-Trim Edge Cases should keep all bytes when no trailing zeros exist", "Boundary: Multisig Zero-Trim Edge Cases should remove single trailing zero from multisig data" ], + "0d97923a8aaa0619": [ + "getRawTransaction wholeHexResult guard (stubbed rpcPost) keeps an empty rpcPost result on the tolerant null path", + "getRawTransaction wholeHexResult guard (stubbed rpcPost) non-vacuity: the scenarios fail when wholeHexResult is removed detects the missing guard: rejects naming the txid when rpcPost never answers whole hex", + "getRawTransaction wholeHexResult guard (stubbed rpcPost) non-vacuity: the scenarios fail when wholeHexResult is removed detects the missing guard: retries past every malformed rpcPost answer to the whole hex that follows", + "getRawTransaction wholeHexResult guard (stubbed rpcPost) passes whole hex through unchanged on the first rpcPost call", + "getRawTransaction wholeHexResult guard (stubbed rpcPost) rejects naming the txid when rpcPost never answers whole hex", + "getRawTransaction wholeHexResult guard (stubbed rpcPost) retries past every malformed rpcPost answer to the whole hex that follows" + ], "0eb0aadd100cef95": [ "XChainDecoder#removeObfuscation() [REGRESSION P0] R-DEC-001: should decrypt a known XCHN payload correctly", "XChainDecoder#removeObfuscation() [REGRESSION P0] R-DEC-003: should decrypt non-XCHN data without error", @@ -279,6 +309,11 @@ "CE-10: Fire-and-Forget DB Call (insertTransactionOutput) insertTransactionOutput should be called with correct parameters", "CE-10: Fire-and-Forget DB Call (insertTransactionOutput) should verify insertTransactionOutput is awaited in source code" ], + "1222f18dd8d3d9ce": [ + "decoder quarantine signal /live reports a quarantine and stays 200, since a restart cannot undo a skip", + "decoder quarantine signal /live reports zero and nulls on a decoder that never skipped a transaction", + "decoder quarantine signal exports the skip count as its own Prometheus counter" + ], "1305399f85b3d667": [ "Fuzz: parseTransaction edge: transaction with no inputs should handle transaction with empty ins array" ], @@ -361,6 +396,10 @@ "Database#stripSqlLineComments() should strip a comment at the end of a line and preserve trailing newline", "Database#stripSqlLineComments() should strip a simple inline comment" ], + "14b08a9944b65284": [ + "DISPENSER_PURGE_GRACE_ACTIVATION conformance is value-identical to the canonical map in xchain-documentation", + "DISPENSER_PURGE_GRACE_ACTIVATION conformance keeps mainnet unarmed and regtest genesis-active while testnet is armable" + ], "154009859620f098": [ "Fuzz: parseTransaction edge: transaction with many outputs should handle transaction with 100 random outputs" ], @@ -480,6 +519,14 @@ "CE-01: Node Unavailability and Recovery should retry getBlockchainInfo on connection failure and recover", "CE-01: Node Unavailability and Recovery should wait when node verification progress is below threshold" ], + "1c8626c6adddaf9b": [ + "Database transaction lock release on failure keeps the lock while discarding a stale connection, so a queued caller stays queued", + "Database transaction lock release on failure lets a retried beginTransaction proceed after a failed one", + "Database transaction lock release on failure releases the lock and the connection when conn.beginTransaction throws", + "Database transaction lock release on failure releases the lock and the connection when rollback throws in endTransaction", + "Database transaction lock release on failure releases the lock when getConnection gives up during beginTransaction", + "Database transaction lock release on failure returns false and frees the lock when commit and the rollback after it both fail" + ], "1e1f291db477634c": [ "dispenser-open gate - optional-tail creates [REGRESSION P1] accepts a create that ends at GET_AMOUNT", "dispenser-open gate - optional-tail creates accepts creates that carry part or all of the optional tail", @@ -569,6 +616,13 @@ "XChainDecoder RPC-lookup + rollback-signal hardening getSourceFromOutput throws a tagged error on an empty RPC result (a confirmed prevout always exists)", "XChainDecoder RPC-lookup + rollback-signal hardening getSourceFromOutput throws a tagged error when the prevout RPC fetch fails, instead of returning null" ], + "214fe5b9e9a614b0": [ + "DB lookup failure inside the block transaction insertBlock rolls back and writes no row when the block hash lookup fails", + "DB lookup failure inside the block transaction insertPubkey keeps swallowing a deterministic content error inside the transaction", + "DB lookup failure inside the block transaction insertTransaction rolls back and writes no row when the source address lookup fails", + "DB lookup failure inside the block transaction retries the block past the parse retry budget instead of quarantining the transaction", + "DB lookup failure inside the block transaction rolls the block back when a dispenser address lookup fails during the collapse" + ], "2165b60bacb53bde": [ "BATCH dispenser registration the DISPENSER prefix carries its delimiter above the gate VALID_ACTION_NAMES holds no other name beginning DISPENSER", "BATCH dispenser registration the DISPENSER prefix carries its delimiter above the gate arms at the same instant as the sub-command walk" @@ -645,12 +699,10 @@ "dispenser protocol time persists raw time while every time-keyed block consumer receives protocol time", "dispenser protocol time retries without writes when the protocol-time history read fails" ], - "257661796fdac2ec": [ - "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] encodeVarintHex is byte-identical in both repos", - "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] every shared function carries a Keep-in-sync comment on both sides", - "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] readVarint is byte-identical in both repos", - "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] skipAuxPow is byte-identical in both repos", - "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] stripAuxPowFromBlockHex is byte-identical in both repos" + "290a48193a5b96df": [ + "collapseDispenserRegistrations below the address-id gate matches the frozen raw-address reference across seeded random batches", + "collapseDispenserRegistrations below the address-id gate matches the frozen raw-address reference on non-list and sparse inputs", + "collapseDispenserRegistrations below the address-id gate matches the frozen raw-address reference on order, ties, oracles and spellings" ], "29f2c0cb6cb80f50": [ "Database#insertEvent() passes code and JSON-stringified data", @@ -659,6 +711,10 @@ "Database#insertEvent() returns true on success", "Database#insertEvent() serialises a BigInt field instead of throwing" ], + "2a151fa9a71c2a05": [ + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides clearReorgHalt refuses a DUPLICATED_TRANSACTION write (errno 1062) without a read-back", + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides clearReorgHalt reports not-cleared when the write lands but the read-back still shows a halt" + ], "2a2b9d37b8ca008a": [ "Fuzz: XChainBlockDecoder bitcoin: blockFromBuffer with random buffers should handle 2000 random buffers", "Fuzz: XChainBlockDecoder bitcoin: mutated valid header should handle 2000 mutated headers", @@ -755,6 +811,11 @@ "DISPENSER wire field offsets pins the offsets the decode path was written against", "DISPENSER wire field offsets reads ORACLE_ADDRESS from the pinned slot and not a neighbouring one" ], + "2d79fc18db20fb8c": [ + "registerDecoderMetrics() REORG_HALT gauges exports a latent halt on a decoder that is still parsing", + "registerDecoderMetrics() REORG_HALT gauges exports a parked halt beside the stale tip it causes", + "registerDecoderMetrics() REORG_HALT gauges omits the halt flag until the first marker probe, then reports a clear decoder as 0" + ], "2e7f48116c3bbab3": [ "Smoke: Database Initialization should create and verify all tables", "Smoke: Database Initialization should create and verify all tables", @@ -850,6 +911,10 @@ "post-commit dispenser purge failure count exports the count as a Prometheus counter", "post-commit dispenser purge failure count leaves the count at zero when the purge succeeds" ], + "34bac8b72829a9ac": [ + "DISPENSER_CANCEL_GRACE_PARENT_FLOOR_ACTIVATION conformance is re-exported by the public constants module", + "DISPENSER_CANCEL_GRACE_PARENT_FLOOR_ACTIVATION conformance keeps mainnet inert and regtest genesis-active while testnet is armable" + ], "358a7cbf07feb7ad": [ "Database#getBlockByIndex() [REGRESSION P0] throws (never returns the missing-row sentinel) after retries on persistent query error" ], @@ -935,6 +1000,13 @@ "db/migration_query_timeout releases an untouched session without changing its timeout", "db/migration_query_timeout releases and destroys the connection when timeout restoration fails" ], + "383edaa2d7dc2c40": [ + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides a clear row holding an already-parsed payload with a valid reason still lifts the halt", + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides a clear row with an unparseable payload still counts as live (fail-closed)", + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides a clear row with no reason or a reason under 8 characters still counts as live (fail-closed)", + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides an audited clear supersedes an unaudited clear row", + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides markReorgHalted writes a REORG_HALT row when the live halt is an unaudited clear row" + ], "3894c155a8954b2b": [ "XChainDecoder#parseTransaction() [REGRESSION P0] R-SCR-001: should decode a DISPENSER payload", "XChainDecoder#parseTransaction() [REGRESSION P0] R-SCR-001: should decode a dynamically built OP_RETURN transaction", @@ -956,6 +1028,14 @@ "Database.verifyTables() file filtering @regression completes without choking on the migrations/ directory (no EISDIR)", "Database.verifyTables() file filtering @regression src/sql contains a non-.sql entry (the migrations dir): the regression trigger" ], + "3b5a32687d9b7288": [ + "Database#deleteAndCompareTxsNotInList() deletes every stored row when the node mempool is empty", + "Database#deleteAndCompareTxsNotInList() drops the temp table and releases the connection even on the happy path", + "Database#deleteAndCompareTxsNotInList() never issues a bare full-table scan of mempool_transactions", + "Database#deleteAndCompareTxsNotInList() rejects on query error, leaving txidList whole and releasing the connection", + "Database#deleteAndCompareTxsNotInList() removes already-stored txids from the list in place (leaving only new arrivals)", + "Database#deleteAndCompareTxsNotInList() removes stored rows not in txidList and returns the delete count" + ], "3cd3e6e6d28f4e3a": [ "E2E: Multi-Block Processing bulk catch-up processing C2.1: decoder should catch up after being stopped and restarted", "E2E: Multi-Block Processing chain reorganization C4.1: should detect and handle a chain reorg", @@ -966,6 +1046,22 @@ "E2E: Multi-Block Processing sequential block processing C1.2: blocks table should have no gaps across sequential blocks", "E2E: Multi-Block Processing sequential block processing C1.3: getDecoderBlockData returns correct data per block" ], + "3e0b1b842f3c3c3c": [ + "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] assertWholeBlock is byte-identical in both repos", + "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] encodeVarintHex is byte-identical in both repos", + "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] every shared function carries a Keep-in-sync comment on both sides", + "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] readVarint is byte-identical in both repos", + "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] skipAuxPow is byte-identical in both repos", + "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] stripAuxPowFromBlockHex is byte-identical in both repos", + "AuxPoW strip parity with xchain-utxo-tracker @regression getBlockWithoutAuxPow error framing (deliberate divergence) does not tag an RPC fault as an AuxPoW parse failure", + "AuxPoW strip parity with xchain-utxo-tracker @regression getBlockWithoutAuxPow error framing (deliberate divergence) tags an untraversable AuxPoW section with auxPowParseFailure", + "AuxPoW strip parity with xchain-utxo-tracker @regression stripAuxPowFromBlockHex behavior agrees with skipAuxPow on where the AuxPoW section ends", + "AuxPoW strip parity with xchain-utxo-tracker @regression stripAuxPowFromBlockHex behavior passes a hex too short to hold a version through unchanged", + "AuxPoW strip parity with xchain-utxo-tracker @regression stripAuxPowFromBlockHex behavior passes a non-AuxPoW block through unchanged", + "AuxPoW strip parity with xchain-utxo-tracker @regression stripAuxPowFromBlockHex behavior strips the AuxPoW section parsed from the block hex when the header is 160 chars", + "AuxPoW strip parity with xchain-utxo-tracker @regression stripAuxPowFromBlockHex behavior takes the legacy length-delta path when getblockheader includes AuxPoW bytes", + "AuxPoW strip parity with xchain-utxo-tracker @regression stripAuxPowFromBlockHex behavior throws when the AuxPoW section cannot be traversed" + ], "3e1e7599837cbf70": [ "ACTION manifest conformance: decoder wireDecoded set @regression ACTION_ALIASES exactly equals the manifest aliases map", "ACTION manifest conformance: decoder wireDecoded set @regression VALID_ACTION_NAMES exactly equals the manifest wireDecoded slice", @@ -993,30 +1089,14 @@ "BlockchainConnector retry exhaustion keeps the cause identity getRawTransaction exhaustion carries the transport code of the last attempt", "BlockchainConnector retry exhaustion keeps the cause identity rpcCallWithTimeoutRetry exhaustion carries ECONNABORTED and keeps its message" ], - "4044805d7869c790": [ - "runMigrations() migration preconditions @regression a file with no precondition entry is never baselined", - "runMigrations() migration preconditions @regression a targeted --file rollout is guarded too, not just the blanket run", - "runMigrations() migration preconditions @regression an unattended startup baselines it before an operator can reach for migrate", - "runMigrations() migration preconditions @regression baselines the DATETIME converter on a BIGINT database instead of destroying it", - "runMigrations() migration preconditions @regression does NOT baseline when the expiration column is missing (half-applied run needs an operator)", - "runMigrations() migration preconditions @regression every precondition entry names a committed migration file", - "runMigrations() migration preconditions @regression still applies the conversion on a legacy DATETIME database", - "runMigrations() migration preconditions @regression the committed file still carries the UNCONDITIONAL conversion the precondition guards" + "403aa81f64e27705": [ + "duplicate transaction insert rolls the block back keeps retrying a persistent duplicate without ever committing or skipping the tx", + "duplicate transaction insert rolls the block back writes no output under a tx_index whose row collided and commits on the clean retry" ], "411a25ffb91c57a8": [ "Security: Connection Handling Transaction lock mechanism [REGRESSION P0] R-SEC-003: should acquire lock on first call", "Security: Connection Handling getConnection retry bound [REGRESSION P0] R-SEC-003: should cap connection retries with a maxAttempts bound" ], - "411f596aab8ee136": [ - "DISPENSER exact integer EXPIRATION activation is public and UNARMED on every network", - "DISPENSER exact integer EXPIRATION activation tests integer value in exact decimal space", - "DISPENSER exact integer EXPIRATION activation uses inclusive block-time activation semantics", - "DISPENSER exact integer EXPIRATION decoding does not mirror a precision-rounded fractional edit above the gate", - "DISPENSER exact integer EXPIRATION decoding preserves precision-rounded create and edit behavior below the gate", - "DISPENSER exact integer EXPIRATION decoding rejects a precision-rounded fractional create above the gate", - "DISPENSER exact integer EXPIRATION valid spellings keeps an omitted create EXPIRATION on the default path above the gate", - "DISPENSER exact integer EXPIRATION valid spellings keeps exactly integral decimal and exponent spellings valid above the gate" - ], "418aa8572442df90": [ "api.js GET /status publishes a lag field computes syncStatus before the res.json() call, not after", "api.js GET /status publishes a lag field publishes lag on the JSON body, matching one of the keys BootstrapHealthGate checks", @@ -1025,12 +1105,43 @@ "41c7af9fde59dd5a": [ "Taproot envelope recognition wire fidelity with the shipped encoder (sibling-gated) an encoder-built signed commit/reveal pair decodes byte-identically" ], + "4223f81e9f105e21": [ + "updateMempool DB isolation a mempool insert failure never rolls back or ends the block transaction", + "updateMempool DB isolation a non-array getrawmempool answer skips the poll instead of reaching the delete", + "updateMempool DB isolation an empty node mempool still reaches the delete, so departed txs are pruned", + "updateMempool DB isolation hands deleteAndCompareTxsNotInList a DEDUPED txid list", + "updateMempool DB isolation hands parseTransaction the mempoolDb handle so pubkey writes stay off the block tx", + "updateMempool DB isolation routes the mempool DELETE and INSERT to mempoolDb, never to the block db", + "updateMempool DB isolation skips the fetch phase when the mempool diff fails in the database", + "updateMempool DB isolation the cycle summary reports the node mempool size, not the post-diff new arrivals" + ], + "43609e549b3dee84": [ + "DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION conformance is byte-equal to the canonical declaration in xchain-documentation", + "DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION conformance is re-exported by the public constants module", + "DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION conformance keeps mainnet inert and regtest genesis-active while testnet is armable" + ], "436372be969d7fb8": [ "Smoke: AES-128-CTR Deobfuscation should decrypt a known fixture XCHN payload", "Smoke: AES-128-CTR Deobfuscation should return null for non-Buffer input", "Smoke: AES-128-CTR Deobfuscation should return null for null input", "Smoke: AES-128-CTR Deobfuscation should round-trip an XCHN payload using fixture txid" ], + "437c4eefedb348ac": [ + "GET /ready answers the REORG_HALT that /live must not gate on answers 200 on /live and /ready for a clean, caught-up decoder", + "GET /ready answers the REORG_HALT that /live must not gate on answers 503 on /ready while the marker stands", + "GET /ready answers the REORG_HALT that /live must not gate on fails closed when the marker has never been read", + "GET /ready answers the REORG_HALT that /live must not gate on keeps /live at 200 on a latent halt and reports it not ready", + "decoderNotReadyReasons() carries every liveness fault as a readiness fault", + "decoderNotReadyReasons() names a frozen node tip, which /live reports but never gates on", + "decoderNotReadyReasons() names a parked halt beside the halt itself" + ], + "44157bc620b75d19": [ + "Database#getConnection() retries on transient failure and succeeds on second attempt", + "Database#getConnection() returns transactionConnection when one is set", + "Database#getConnection() succeeds on first pool.getConnection call", + "Database#getConnection() throws after maxAttempts (30) consecutive failures", + "dropAllTables() executes all DROP TABLE queries without throwing" + ], "447b696129587817": [ "XChainDecoder#parseTransaction() [REGRESSION P0] R-SCR-005: should not drop a 0x00 final ciphertext byte on a full multisig chunk" ], @@ -1150,6 +1261,16 @@ "DISPENSER Integration DISPENSER v0 with valid fields should store DISPENSER data in transactions table", "DISPENSER Integration dispenser output via indexer contract query should include dispenser output fields in getDecoderBlockData" ], + "4eb10f6c301434ed": [ + "migration executable-residue pin @regression every pinned migration on disk still matches its pin", + "migration executable-residue pin @regression pins at least the migrations the rebaseline table names", + "migration executable-residue pin @regression pins each rebaselined file at the hash its rebaseline heals the ledger to", + "migration executable-residue pin: the guard bites @regression a comment-only edit is a content violation unless a rebaseline heals exactly that pair", + "migration executable-residue pin: the guard bites @regression a removed pinned file is missing, and a new unpinned file is free", + "migration executable-residue pin: the guard bites @regression an added statement is a residue violation, even with a rebaseline", + "migration executable-residue pin: the guard bites @regression an unpinned file that sorts before the newest pin is rejected", + "migration executable-residue pin: the guard bites @regression an untouched file passes" + ], "4edb863dbbb574cc": [ "Database.destructiveAutoStatement() quoted comment markers @regression flags a destructive clause hidden between a /* literal and a later */ literal", "Database.destructiveAutoStatement() quoted comment markers @regression flags the same trick through backtick identifiers, one ending in a backslash", @@ -1205,25 +1326,30 @@ "XChainDecoder#findFundingFeeOutputs() should throw a tagged rpcLookupFailure when getRawTransaction returns null (a confirmed funding tx always exists)", "XChainDecoder#findFundingFeeOutputs() should throw a tagged rpcLookupFailure when getRawTransaction throws (fee presence must not depend on RPC health)" ], + "50a477cef0e33403": [ + "DISPENSER exact integer EXPIRATION activation is public, never active on mainnet, dark on testnet, and genesis-active on regtest", + "DISPENSER exact integer EXPIRATION activation tests integer value in exact decimal space", + "DISPENSER exact integer EXPIRATION activation uses inclusive block-time activation semantics", + "DISPENSER exact integer EXPIRATION decoding does not mirror a precision-rounded fractional edit above the gate", + "DISPENSER exact integer EXPIRATION decoding preserves precision-rounded create and edit behavior below the gate", + "DISPENSER exact integer EXPIRATION decoding rejects a precision-rounded fractional create above the gate", + "DISPENSER exact integer EXPIRATION valid spellings keeps an omitted create EXPIRATION on the default path above the gate", + "DISPENSER exact integer EXPIRATION valid spellings keeps exactly integral decimal and exponent spellings valid above the gate" + ], "50b802bfa59d2839": [ "E2E: ACTION Decoding encoding types A2.1:should decode ACTION via direct OP_RETURN", "E2E: ACTION Decoding encoding types A2.2:should decode ACTION via 1-of-3 multisig", "E2E: ACTION Decoding encoding types A2.3:multisig should strip trailing zeros from short payload" ], - "5199ac844e30531f": [ - "mempool admission of an ACTION with no resolvable source blanks an unattributable ACTION to an empty string and keeps the row", - "mempool admission of an ACTION with no resolvable source keeps the ACTION text for a null source when a dispense output makes the tx storable", - "mempool admission of an ACTION with no resolvable source keeps the ACTION text when the source resolves", - "mempool admission of an ACTION with no resolvable source loads the open-dispenser set with the cancelGraceFloor for the network", - "mempool admission of an ACTION with no resolvable source parses against the getAllOpenDispenserAddresses set, so an unattributed ACTION paying an open dispenser keeps its text", - "mempool admission of an ACTION with no resolvable source still runs the cycle against an empty set when getAllOpenDispenserAddresses fails", - "mempool admission of an ACTION with no resolvable source still writes a blanked row for an actionless tx with no source" - ], - "51fbc60e4c509ae1": [ - "Database#hasDispenserTransactions() probes BOTH the top-level and the batch-carried shape", - "Database#hasDispenserTransactions() sees a dispenser opened inside a BATCH", - "Database#hasDispenserTransactions() sees a top-level dispenser", - "Database#hasDispenserTransactions() stays false on a database that never decoded a DISPENSER" + "531b63fa60260bea": [ + "Database.MIGRATION_PRECONDITIONS: widen-ids-to-bigint predicate @regression baselines on the fresh src/sql shape", + "Database.MIGRATION_PRECONDITIONS: widen-ids-to-bigint predicate @regression does NOT baseline on a signed BIGINT", + "Database.MIGRATION_PRECONDITIONS: widen-ids-to-bigint predicate @regression does NOT baseline on an unreadable type or an empty answer", + "Database.MIGRATION_PRECONDITIONS: widen-ids-to-bigint predicate @regression does NOT baseline when a surrogate key lacks AUTO_INCREMENT", + "Database.MIGRATION_PRECONDITIONS: widen-ids-to-bigint predicate @regression does NOT baseline when any one column is absent", + "Database.MIGRATION_PRECONDITIONS: widen-ids-to-bigint predicate @regression does NOT baseline when pubkeys_ibfk_1 is missing", + "Database.MIGRATION_PRECONDITIONS: widen-ids-to-bigint predicate @regression does NOT baseline while pubkeys.address_id or index_addresses.id is still INT", + "Database.MIGRATION_PRECONDITIONS: widen-ids-to-bigint predicate @regression reads information_schema for this database through exactly one placeholder" ], "537ab7708498cb2c": [ "BATCH limit vendoring and cross-repo conformance tier 1: the vendored tables have not drifted from the sibling carries the live weight budget, weight table and activation instants", @@ -1233,55 +1359,6 @@ "BATCH limit vendoring and cross-repo conformance tier 1: the vendored tables have not drifted from the sibling keeps every weight an integer >= 1, which is what makes the count cap a sound pre-filter", "BATCH limit vendoring and cross-repo conformance tier 1: the vendored tables have not drifted from the sibling keeps the ungated and gated caps in the tables they came from" ], - "54639638d1536735": [ - "Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression baselines when both columns already carry utf8mb4", - "Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression does NOT baseline a half-converted pair (one column still lagging)", - "Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression does NOT baseline at the pre-migration shape (both still utf8mb3)", - "Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression does NOT baseline when a charset is unreadable (NULL)", - "Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression does NOT baseline when either column is absent", - "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression baselines at the post-migration shape (tx_hash, no tx_hash_id)", - "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression does NOT baseline an ambiguous shape (both columns present)", - "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression does NOT baseline at the pre-migration shape (tx_hash_id still present)", - "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression does NOT baseline when a column name is unreadable (NULL)", - "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression does NOT baseline when the table or columns are absent", - "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression reads the two column names out of information_schema for this database", - "Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression baselines when the column already holds an uncompressed key (130 chars)", - "Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression baselines when the column is wider than required", - "Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression does NOT baseline at the pre-migration shape (narrow VARCHAR(66))", - "Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression does NOT baseline when the column is absent", - "Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression does NOT baseline when the length is unreadable (NULL)", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-02-widen-ids-to-bigint.sql: carries the deploy-precondition header tag", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-02-widen-ids-to-bigint.sql: is mode=manual (an auto migration cannot be a missing precondition)", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-02-widen-ids-to-bigint.sql: names a real assertion method on Database", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-02-widen-ids-to-bigint.sql: the registered migration exists on disk", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-13-dispensers-expiration-bigint.sql: carries the deploy-precondition header tag", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-13-dispensers-expiration-bigint.sql: is mode=manual (an auto migration cannot be a missing precondition)", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-13-dispensers-expiration-bigint.sql: names a real assertion method on Database", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-13-dispensers-expiration-bigint.sql: the registered migration exists on disk", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-07-24-pubkeys-widen-uncompressed.sql: carries the deploy-precondition header tag", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-07-24-pubkeys-widen-uncompressed.sql: is mode=manual (an auto migration cannot be a missing precondition)", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-07-24-pubkeys-widen-uncompressed.sql: names a real assertion method on Database", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-07-24-pubkeys-widen-uncompressed.sql: the registered migration exists on disk", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-08-10-action-data-utf8mb4.sql: carries the deploy-precondition header tag", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-08-10-action-data-utf8mb4.sql: is mode=manual (an auto migration cannot be a missing precondition)", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-08-10-action-data-utf8mb4.sql: names a real assertion method on Database", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-08-10-action-data-utf8mb4.sql: the registered migration exists on disk", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 every tagged migration file is registered (no tag without an assertion behind it)", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 no mode=auto migration carries the tag", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 registers exactly the four migrations this tree asserts at startup", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 startupAssertedMigrationFile() resolves each registered assertion to its migration filename", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 startupAssertedMigrationFile() throws on an unregistered assertion rather than yielding undefined", - "Database.migrationDeclaresDeployPrecondition @regression @tier1 ignores the token on a comment line that is not the xchain:migration directive", - "Database.migrationDeclaresDeployPrecondition @regression @tier1 ignores the token once the SQL body has started, so prose or a data literal cannot arm it", - "Database.migrationDeclaresDeployPrecondition @regression @tier1 is false for an ordinary tagged migration", - "Database.migrationDeclaresDeployPrecondition @regression @tier1 is false for an untagged file and for empty input", - "Database.migrationDeclaresDeployPrecondition @regression @tier1 reads the tag off the xchain:migration directive line", - "Database.migrationDeclaresDeployPrecondition @regression @tier1 sees the tag through a long license banner (the prologue is unbounded)", - "Database.migrationDeclaresDeployPrecondition @regression @tier1 tolerates spacing around the token", - "startup assertion error text names the registered file @regression @tier1 assertActionDataIsUtf8mb4 names the exact migration file", - "startup assertion error text names the registered file @regression @tier1 assertDispenserExpirationIsBigintUnsigned names the exact migration file", - "startup assertion error text names the registered file @regression @tier1 assertPubkeyColumnIsUncompressedWide names the exact migration file" - ], "5547b4e45f6dd005": [ "XChainDecoder unattributed XChain payload skip counts a payload with no resolvable source toward parse_errors and stores nothing", "XChainDecoder unattributed XChain payload skip leaves parse_errors untouched for a tx with no payload and no source" @@ -1448,13 +1525,6 @@ "BATCH sub-command ACTION-name gate and alias expansion sub-command ACTION names are alias-expanded above the gate reads only OWN properties, so a prototype name is not a table hit", "BATCH sub-command ACTION-name gate and alias expansion sub-command ACTION names are alias-expanded above the gate rewrites the NAME and returns every later byte verbatim" ], - "5bfafba200cdf61c": [ - "XChainDecoder#getSourceFromOutput() should chase P2SH outputs one level deeper", - "XChainDecoder#getSourceFromOutput() should return an address for a valid P2PKH output", - "XChainDecoder#getSourceFromOutput() should return null for OP_RETURN output (no valid address)", - "XChainDecoder#getSourceFromOutput() should return null when output index is out of bounds", - "XChainDecoder#getSourceFromOutput() should throw a tagged rpcLookupFailure when the connector throws (a failed lookup is not a null source)" - ], "5cc2b8143bdd5808": [ "Security: remediated dependency advisories @regression @tier4 ADV-10: the installed mariadb reports a patched runtime version", "Security: remediated dependency advisories @regression @tier4 ADV-1: package.json pins axios at or above 1.18.0", @@ -1483,6 +1553,13 @@ "Security: remediated dependency advisories @regression @tier4 ADV-4: brace-expansion survives the CVE-2026-14257 unbounded-length input", "Security: remediated dependency advisories @regression @tier4 ADV-5: the installed axios reports a patched runtime version" ], + "5d7a78d030197111": [ + "Database#hasDispenserTransactions() probes BOTH the top-level and the batch-carried shape", + "Database#hasDispenserTransactions() sees a dispenser opened after the first sub-command of a BATCH", + "Database#hasDispenserTransactions() sees a dispenser opened inside a BATCH", + "Database#hasDispenserTransactions() sees a top-level dispenser", + "Database#hasDispenserTransactions() stays false on a database that never decoded a DISPENSER" + ], "5ea20e09507c7a24": [ "Taproot envelope recognition constants conformance parity with the canonical xchain-documentation copy ENVELOPE_MAX_PAYLOAD and both activation maps are byte-equal to the canonical copy", "Taproot envelope recognition constants conformance parity with the canonical xchain-documentation copy every adversarial entry is either executed above or mapped to a local test that still exists", @@ -1531,6 +1608,13 @@ "vendored twin parity [REGRESSION P1] src/chain/bufferutils.js vs xchain-utxo-tracker/src/chain/bufferutils.js verifuint is byte-identical in both repos", "vendored twin parity [REGRESSION P1] src/chain/bufferutils.js vs xchain-utxo-tracker/src/chain/bufferutils.js writeUInt64LE is byte-identical in both repos" ], + "6108c204ee3d7617": [ + "XChainDecoder latent REORG_HALT reporting: an empty probe answer keeps a halt read earlier when getReorgHaltMarker resolves null", + "XChainDecoder latent REORG_HALT reporting: an empty probe answer keeps a halt read earlier when getReorgHaltMarker resolves undefined", + "XChainDecoder latent REORG_HALT reporting: an empty probe answer keeps a halt read earlier when the boolean isReorgHalted probe resolves undefined", + "XChainDecoder latent REORG_HALT reporting: an empty probe answer keeps a parked decoder parked when the probe starts answering null", + "XChainDecoder latent REORG_HALT reporting: an empty probe answer leaves a fresh decoder unchecked when getReorgHaltMarker resolves null" + ], "611fbe9fad84305d": [ "Smoke: Module Loading should construct a BlockchainConnector instance", "Smoke: Module Loading should construct an XChainBlockDecoder instance", @@ -1542,15 +1626,6 @@ "Smoke: Module Loading should load XChainDecoder", "Smoke: Module Loading should load util" ], - "6207eebcc7c69c98": [ - "updateMempool DB isolation a mempool insert failure never rolls back or ends the block transaction", - "updateMempool DB isolation a non-array getrawmempool answer skips the poll instead of reaching the delete", - "updateMempool DB isolation an empty node mempool still reaches the delete, so departed txs are pruned", - "updateMempool DB isolation hands deleteAndCompareTxsNotInList a DEDUPED txid list", - "updateMempool DB isolation hands parseTransaction the mempoolDb handle so pubkey writes stay off the block tx", - "updateMempool DB isolation routes the mempool DELETE and INSERT to mempoolDb, never to the block db", - "updateMempool DB isolation the cycle summary reports the node mempool size, not the post-diff new arrivals" - ], "628533306b9d2fb2": [ "Fuzz: XChainBlockDecoder bitcoin: blockFromBuffer with random buffers should handle 100 random buffers", "Fuzz: XChainBlockDecoder bitcoin: mutated valid header should handle 100 mutated headers", @@ -1845,6 +1920,11 @@ "mempool vs confirmed ACTION payload representation conformance stored form is the readable UTF-8 ACTION string, never hex", "mempool vs confirmed ACTION payload representation conformance the rejected-ACTION no-action sentinel is \"\" on both decode paths, never null" ], + "7f4c66806bfe01d8": [ + "decoder mempool rows keyed on the node txid @regression an MWEB-form Litecoin tx [REGRESSION P1] handles a typed malformed-transaction error without aborting the mempool cycle", + "decoder mempool rows keyed on the node txid @regression an MWEB-form Litecoin tx [REGRESSION P1] skips and counts a tx whose parse does not hash to the node txid", + "decoder mempool rows keyed on the node txid @regression an MWEB-form Litecoin tx [REGRESSION P1] stores under the node txid with the witness handed to the parser" + ], "80d233d9d6bb2811": [ "Database#extendOpenDispenserExpirationBySource() extends with GREATEST and never shortens", "Database#extendOpenDispenserExpirationBySource() picks no row: no ORDER BY and no LIMIT, so every open row of the source is covered", @@ -2068,21 +2148,6 @@ "decoder reorg table coverage every src/sql table has a recorded reorg decision", "decoder reorg table coverage reads a non-trivial table list from src/sql" ], - "8d9e5d5d21b31cbc": [ - "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] encodeVarintHex is byte-identical in both repos", - "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] every shared function carries a Keep-in-sync comment on both sides", - "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] readVarint is byte-identical in both repos", - "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] skipAuxPow is byte-identical in both repos", - "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] stripAuxPowFromBlockHex is byte-identical in both repos", - "AuxPoW strip parity with xchain-utxo-tracker @regression getBlockWithoutAuxPow error framing (deliberate divergence) does not tag an RPC fault as an AuxPoW parse failure", - "AuxPoW strip parity with xchain-utxo-tracker @regression getBlockWithoutAuxPow error framing (deliberate divergence) tags an untraversable AuxPoW section with auxPowParseFailure", - "AuxPoW strip parity with xchain-utxo-tracker @regression stripAuxPowFromBlockHex behavior agrees with skipAuxPow on where the AuxPoW section ends", - "AuxPoW strip parity with xchain-utxo-tracker @regression stripAuxPowFromBlockHex behavior passes a hex too short to hold a version through unchanged", - "AuxPoW strip parity with xchain-utxo-tracker @regression stripAuxPowFromBlockHex behavior passes a non-AuxPoW block through unchanged", - "AuxPoW strip parity with xchain-utxo-tracker @regression stripAuxPowFromBlockHex behavior strips the AuxPoW section parsed from the block hex when the header is 160 chars", - "AuxPoW strip parity with xchain-utxo-tracker @regression stripAuxPowFromBlockHex behavior takes the legacy length-delta path when getblockheader includes AuxPoW bytes", - "AuxPoW strip parity with xchain-utxo-tracker @regression stripAuxPowFromBlockHex behavior throws when the AuxPoW section cannot be traversed" - ], "8f6c7971e72c3673": [ "XChainDecoder RPC-lookup + rollback-signal hardening block loop rollback-signal handling aborts and retries the block when insertTransactionOutput signals rollback via false", "XChainDecoder RPC-lookup + rollback-signal hardening block loop rollback-signal handling re-derives tx_index from the DB after a rollback so a retried block matches a clean instance", @@ -2101,6 +2166,19 @@ "Taproot envelope published chunk vectors recognizes and reassembles the published chunk_rebalance vector", "Taproot envelope published chunk vectors recognizes and reassembles the published envelope_chunking vector" ], + "9256abaf91bbe0da": [ + "Database.destructiveAutoStatement() prefix forms @regression catches ALTER IGNORE and CREATE TRIGGER end to end from raw file text", + "Database.destructiveAutoStatement() prefix forms @regression flags CREATE TRIGGER / EVENT / routine / view and any non-table ALTER", + "Database.destructiveAutoStatement() prefix forms @regression flags every ALTER IGNORE TABLE (IGNORE deletes duplicate-key rows)", + "Database.destructiveAutoStatement() prefix forms @regression flags every top-level DROP other than a standalone index or temporary-table drop", + "Database.destructiveAutoStatement() prefix forms @regression runs the ALTER clause checks on ALTER ONLINE TABLE", + "Database.destructiveAutoStatement() prefix forms @regression still allows a standalone DROP INDEX and a DROP TEMPORARY TABLE", + "Database.destructiveAutoStatement() prefix forms @regression still allows additive ALTER ONLINE TABLE and a table merely named ignore", + "Database.destructiveAutoStatement() prefix forms @regression still allows the committed CREATE TABLE / CREATE INDEX forms", + "Database.destructiveAutoStatement() statement-form allow-list @regression flags an unlisted form that follows an allowed one in the same file", + "Database.destructiveAutoStatement() statement-form allow-list @regression flags every statement form outside the allow-list", + "Database.destructiveAutoStatement() statement-form allow-list @regression keeps every allowed form auto-eligible" + ], "9287c127d042af8f": [ "Security: Connection Handling Transaction lock mechanism [REGRESSION P0] R-SEC-003: should acquire lock on first call", "Security: Connection Handling Transaction lock mechanism should initialize lock state correctly", @@ -2156,6 +2234,18 @@ "Database.destructiveAutoStatement() DROP target @regression flags a DROP whose target only starts with a keyword or cannot be read", "Database.destructiveAutoStatement() DROP target @regression keeps bare metadata-only DROP forms auto-eligible" ], + "94f602d41143890d": [ + "a node tip below the stored tip is settled before any rollback [REGRESSION P1] a tip that keeps moving ends the pass with nothing deleted", + "a node tip below the stored tip is settled before any rollback [REGRESSION P1] an unreadable re-read ends the pass with nothing deleted", + "a node tip below the stored tip is settled before any rollback [REGRESSION P1] one stale reply 100 below the real tip deletes nothing and writes no REORG marker", + "a node tip below the stored tip is settled before any rollback [REGRESSION P1] one stale reply 3 below the real tip deletes nothing and writes no REORG marker", + "a settled lower tip rolls back exactly as before [REGRESSION P1] a settled real reorg below a lower tip still walks back to the fork point", + "a settled lower tip rolls back exactly as before [REGRESSION P1] a settled real rollback deletes and marks exactly what it did before the settle", + "an equal-height tip swap is settled before the reconcile [REGRESSION P1] a swap that holds across two readings reconciles as before", + "an equal-height tip swap is settled before the reconcile [REGRESSION P1] one stray hash at the stored height does not start a reconcile", + "the mid-walk tip refresh is settled before it moves the tip down [REGRESSION P1] a settled lower tip mid-walk still drains the blocks above it", + "the mid-walk tip refresh is settled before it moves the tip down [REGRESSION P1] one stale reading during a walk does not turn valid blocks into above-tip deletes" + ], "951b85192ced37b0": [ "getmempool stale reads reports a stale empty fallback when the first read fails without a prior good timestamp", "getmempool stale reads serves the last good snapshot as stale and retries immediately after a failed refresh" @@ -2279,6 +2369,17 @@ "roundtrip conformance fixture: every case reaches the stored record reassembles the envelope payload as the encoder compiled it, chunk boundaries included", "roundtrip conformance fixture: every case reaches the stored record recognizes each envelope as the carrier, with the envelope ceiling" ], + "9bd15abd96c0f43f": [ + "decoder migration runner applies the unique-key witness to auto files @regression refuses an auto INSERT IGNORE while the declared UNIQUE address key is only a plain index", + "decoder migration runner applies the unique-key witness to auto files @regression runs the same file once the UNIQUE address key is live", + "decoder migration unique-key witness @regression finds each key-dependent write and ignores one inside a quoted literal", + "decoder migration unique-key witness @regression keeps every committed auto file to the reviewed key-dependent map", + "decoder migration unique-key witness @regression passes a live unique index with the same ordered column set under another name", + "decoder migration unique-key witness @regression passes a table created earlier in the same file without reading live metadata", + "decoder migration unique-key witness @regression passes a target table that does not exist live without reading its indexes", + "decoder migration unique-key witness @regression refuses a missing declared unique index and names its remedy", + "decoder migration unique-key witness @regression refuses a non-unique live index holding the same name and columns" + ], "9c3f37e4f6fe7093": [ "Database.destructiveAutoStatement() CONVERT TO and MODIFY id @regression flags ALTER TABLE ... CONVERT TO in every spelling", "Database.destructiveAutoStatement() CONVERT TO and MODIFY id @regression flags a MODIFY of id that drops AUTO_INCREMENT", @@ -2290,10 +2391,17 @@ "BATCH sub-command ACTION-name gate and alias expansion the indexer side of the argument, driven not asserted really does reject the EMPTY ACTION name, which is what suppression rests on", "BATCH sub-command ACTION-name gate and alias expansion the indexer side of the argument, driven not asserted rejects an ALIAS name, so expansion must never run below BATCH_SUBACTION_NORMALIZATION" ], - "9cdf9bd6784320ad": [ - "DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION conformance is byte-equal to the canonical declaration in xchain-documentation", - "DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION conformance is re-exported by the public constants module", - "DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION conformance keeps mainnet and testnet inert while regtest is genesis-active" + "9cf7a9676a09ed58": [ + "XChainDecoder.verifyReorg mid-walk tip refresh from a catching-up node accepts the refreshed tip once the node has finished catching up", + "XChainDecoder.verifyReorg mid-walk tip refresh from a catching-up node ignores a refreshed tip while the node reports initial block download", + "XChainDecoder.verifyReorg mid-walk tip refresh from a catching-up node ignores a refreshed tip while verification progress is below the parse threshold", + "XChainDecoder.verifyReorg mid-walk tip refresh from a malformed reply accepts the refreshed tip once a well-formed reply follows the malformed one", + "XChainDecoder.verifyReorg mid-walk tip refresh from a malformed reply ignores a refreshed tip whose reply omits verificationprogress", + "XChainDecoder.verifyReorg mid-walk tip refresh from a malformed reply ignores a refreshed tip whose verificationprogress is not a number", + "XChainDecoder.verifyReorg mid-walk tip regression keeps retrying (does not crash) when the node is fully unreachable", + "XChainDecoder.verifyReorg mid-walk tip regression refreshes nodeTip on out-of-range so a regressed tip self-heals instead of wedging", + "XChainDecoder.verifyReorg mid-walk tip regression refuses a same-tier foreign endpoint tip refresh (genesis pin mismatch) and deletes no local blocks", + "XChainDecoder.verifyReorg mid-walk tip regression still accepts the refreshed tip when block 0 agrees, so the self-heal happy path is unchanged" ], "9d0527ed17a1692f": [ "BATCH dispenser registration two creates on the SAME operating address (the PRIMARY KEY collision) collapses three same-address creates to one row", @@ -2324,6 +2432,13 @@ "decoder migration live-schema MODIFY guard @regression sees a MODIFY behind a block comment and ignores a keyword inside one", "decoder migration runner applies the live-schema guard to auto files @regression refuses an auto MODIFY that would strip a live DEFAULT before running any statement" ], + "a1cc9a9cc14fe706": [ + "block-time gate map assertion accepts an armed testnet block-time instant", + "block-time gate map assertion accepts the shipped unarmed map", + "block-time gate map assertion refuses a height-sized testnet value", + "block-time gate map assertion refuses a nonzero regtest", + "block-time gate map assertion refuses an armed mainnet" + ], "a1f7b506ab91b517": [ "Malformed Data Integration blocks with mixed valid and invalid transactions should store only the valid XCHN transaction from a block", "Malformed Data Integration decoder stability after invalid data should continue processing valid blocks after encountering invalid data", @@ -2527,13 +2642,15 @@ "CE-03: Database Connection Pool Exhaustion getConnection should return transactionConnection when available", "CE-03: Database Connection Pool Exhaustion transaction lock queue should serialize concurrent callers" ], - "ace818c6415f144e": [ - "Database.destructiveAutoStatement() prefix forms @regression catches ALTER IGNORE and CREATE TRIGGER end to end from raw file text", - "Database.destructiveAutoStatement() prefix forms @regression flags CREATE TRIGGER / EVENT / routine / view and any non-table ALTER", - "Database.destructiveAutoStatement() prefix forms @regression flags every ALTER IGNORE TABLE (IGNORE deletes duplicate-key rows)", - "Database.destructiveAutoStatement() prefix forms @regression runs the ALTER clause checks on ALTER ONLINE TABLE", - "Database.destructiveAutoStatement() prefix forms @regression still allows additive ALTER ONLINE TABLE and a table merely named ignore", - "Database.destructiveAutoStatement() prefix forms @regression still allows the committed CREATE TABLE / CREATE INDEX forms" + "afa2fc981402504f": [ + "Litecoin MWEB transaction serialization @regression a standalone tx in its MWEB form [REGRESSION P1] a HogEx with a nonzero locktime gets the txid Litecoin computes", + "Litecoin MWEB transaction serialization @regression a standalone tx in its MWEB form [REGRESSION P1] a plain segwit tx parses exactly as before", + "Litecoin MWEB transaction serialization @regression a standalone tx in its MWEB form [REGRESSION P1] a segwit peg-in with an MWEB part keeps its real txid, witness and locktime", + "Litecoin MWEB transaction serialization @regression a standalone tx in its MWEB form [REGRESSION P1] the real HogEx (null MWEB tag, zero locktime) keeps its txid", + "Litecoin MWEB transaction serialization @regression a standalone tx in its MWEB form [REGRESSION P1] the same tx without its witness (flag 0x08) keeps its real txid", + "Litecoin MWEB transaction serialization @regression the HogEx inside a block [REGRESSION P1] a HogEx with a nonzero locktime gets the txid Litecoin computes", + "Litecoin MWEB transaction serialization @regression the HogEx inside a block [REGRESSION P1] refuses a non-null MWEB part inside a block, whose length nothing records", + "Litecoin MWEB transaction serialization @regression the HogEx inside a block [REGRESSION P1] the real HogEx keeps its txid with the extension block after it" ], "b14912241cdeb24d": [ "dispenser-open gate - optional-tail creates [REGRESSION P1] accepts a create that ends at GET_AMOUNT", @@ -2549,6 +2666,14 @@ "BATCH dispenser registration the DISPENSER prefix carries its delimiter above the gate at the TOP LEVEL, where VALID_ACTION_NAMES already closed it registers nothing for a near-miss name on EITHER side of the gate", "BATCH dispenser registration the DISPENSER prefix carries its delimiter above the gate at the TOP LEVEL, where VALID_ACTION_NAMES already closed it registers nothing for the bare token DISPENSER, on both sides" ], + "b2f89504900651c4": [ + "decoder declared-key drop guard @regression allows a drop the same file re-creates under the same name and kind", + "decoder declared-key drop guard @regression allows a table with no src/sql file and fails closed on a drop it cannot read", + "decoder declared-key drop guard @regression passes every committed mode=auto migration", + "decoder declared-key drop guard @regression refuses a drop of a declared UNIQUE or PRIMARY key that the file never re-creates", + "decoder migration runner applies the declared-key drop guard to auto files @regression refuses an auto drop of the declared UNIQUE address key before running it", + "decoder migration runner applies the declared-key drop guard to auto files @regression runs the same drop when the file re-creates the key" + ], "b3bae8599c655a50": [ "decoder boot consensus-pin verification derives the consensus network from the \"-\" key", "decoder boot consensus-pin verification start() halts fail-closed on a pin mismatch, before any DB work", @@ -2608,15 +2733,6 @@ "graceful shutdown resolveTimeoutMs stays under the 120 s budget xchain-node gives a decoder", "graceful shutdown resolveTimeoutMs stays under the stop budget in xchain-node's own table when that checkout is beside this one" ], - "b7c9218357c1b1f2": [ - "XChainDecoder.verifyReorg mid-walk tip refresh from a catching-up node accepts the refreshed tip once the node has finished catching up", - "XChainDecoder.verifyReorg mid-walk tip refresh from a catching-up node ignores a refreshed tip while the node reports initial block download", - "XChainDecoder.verifyReorg mid-walk tip refresh from a catching-up node ignores a refreshed tip while verification progress is below the parse threshold", - "XChainDecoder.verifyReorg mid-walk tip regression keeps retrying (does not crash) when the node is fully unreachable", - "XChainDecoder.verifyReorg mid-walk tip regression refreshes nodeTip on out-of-range so a regressed tip self-heals instead of wedging", - "XChainDecoder.verifyReorg mid-walk tip regression refuses a same-tier foreign endpoint tip refresh (genesis pin mismatch) and deletes no local blocks", - "XChainDecoder.verifyReorg mid-walk tip regression still accepts the refreshed tip when block 0 agrees, so the self-heal happy path is unchanged" - ], "b890eff5e5d00764": [ "roundtrip conformance fixture: byte-identity to encoder original vendored test/fixtures/roundtrip-conformance.json is byte-identical to the encoder original" ], @@ -2626,6 +2742,18 @@ "CE-09: Unhandled Promise Rejection decoder.start() should throw when table verification fails", "CE-09: Unhandled Promise Rejection health endpoint should report healthy when decoder is running" ], + "b9d84db0edbc4eae": [ + "runMigrations() migration preconditions @regression a file with no precondition entry is never baselined", + "runMigrations() migration preconditions @regression a targeted --file rollout is guarded too, not just the blanket run", + "runMigrations() migration preconditions @regression an unattended startup baselines it before an operator can reach for migrate", + "runMigrations() migration preconditions @regression baselines the DATETIME converter on a BIGINT database instead of destroying it", + "runMigrations() migration preconditions @regression baselines widen-ids at startup on a fresh-shape database", + "runMigrations() migration preconditions @regression does NOT baseline when the expiration column is missing (half-applied run needs an operator)", + "runMigrations() migration preconditions @regression every precondition entry names a committed migration file", + "runMigrations() migration preconditions @regression keeps widen-ids PENDING at startup while a column is still INT", + "runMigrations() migration preconditions @regression still applies the conversion on a legacy DATETIME database", + "runMigrations() migration preconditions @regression the committed file still carries the UNCONDITIONAL conversion the precondition guards" + ], "b9e074be5f4de603": [ "Database#parseExpectedColumns() should detect DEFAULT keyword correctly", "Database#parseExpectedColumns() should handle IF NOT EXISTS in CREATE TABLE", @@ -2655,6 +2783,22 @@ "Security: BlockchainConnector Credential handling [REGRESSION P0] R-SEC-001: should not include credentials in the URL", "Security: BlockchainConnector Protocol handling [REGRESSION P0] R-SEC-001: should preserve https:// when specified" ], + "bc71a27833d1bfb3": [ + "output address derivation conformance bitcoin-mainnet derives the coin prefixes and witness checksum variant", + "output address derivation conformance bitcoin-regtest derives the coin prefixes and witness checksum variant", + "output address derivation conformance bitcoin-testnet derives the coin prefixes and witness checksum variant", + "output address derivation conformance dogecoin-mainnet derives the coin prefixes and witness checksum variant", + "output address derivation conformance dogecoin-regtest derives the coin prefixes and witness checksum variant", + "output address derivation conformance dogecoin-testnet derives the coin prefixes and witness checksum variant", + "output address derivation conformance litecoin-mainnet derives the coin prefixes and witness checksum variant", + "output address derivation conformance litecoin-regtest derives the coin prefixes and witness checksum variant", + "output address derivation conformance litecoin-testnet derives the coin prefixes and witness checksum variant", + "transaction wire parsing conformance bounds-checks every truncation of a fixed MWEB transaction", + "transaction wire parsing conformance matches node txids for legacy, segwit v0, taproot v1, LTC MWEB and DOGE coinbase forms", + "transaction wire parsing conformance rejects an MWEB CompactSize above 2^53 before using it as a length", + "transaction wire parsing conformance rejects truncated, non-hex and trailing bytes with a typed error", + "transaction wire parsing conformance types a truncated transaction inside a block for the block-loop caller" + ], "be256411308fe4e8": [ "compiled-push-size arbiter conformance OP_RETURN_PUSH_OVERHEAD is name-keyed and value-identical equals the vendored canonical protocol constant", "compiled-push-size arbiter conformance OP_RETURN_PUSH_OVERHEAD is name-keyed and value-identical is exactly what compiledPushSize adds above the OP_PUSHDATA1 ceiling", @@ -2790,15 +2934,6 @@ "BATCH dispenser registration batched v2 refill / v1 cancel reaches a create placed AFTER it in the same batch too (hold-open-longer)", "BATCH dispenser registration batched v2 refill / v1 cancel resolves against a dispenser created in the SAME batch" ], - "cd8e0e210f80e0cb": [ - "migration executable-residue pin @regression every pinned migration on disk still matches its pin", - "migration executable-residue pin @regression pins at least the migrations the rebaseline table names", - "migration executable-residue pin @regression pins each rebaselined file at the hash its rebaseline heals the ledger to", - "migration executable-residue pin: the guard bites @regression a comment-only edit is a content violation unless a rebaseline heals exactly that pair", - "migration executable-residue pin: the guard bites @regression a removed pinned file is missing, and a new unpinned file is free", - "migration executable-residue pin: the guard bites @regression an added statement is a residue violation, even with a rebaseline", - "migration executable-residue pin: the guard bites @regression an untouched file passes" - ], "cdab1aa16d30bd48": [ "/live reports the stale tip without gating on it answers 200 on a stale tip but says so in the body", "/live reports the stale tip without gating on it is wired into the real /live handler with the healthy gate untouched", @@ -2825,6 +2960,15 @@ "registerDecoderMetrics() feed-freshness gauges shows a loop that died while caught up, which stalled cannot", "registerDecoderMetrics() feed-freshness gauges surfaces the same counters on getSyncStatus, which /status spreads" ], + "cdee7ff351a1e063": [ + "Database.destructiveAutoStatement() lock-wait head and unplaced MODIFY @regression flags a MODIFY keyword that does not open its alter_spec", + "Database.destructiveAutoStatement() lock-wait head and unplaced MODIFY @regression flags a MODIFY of id that drops AUTO_INCREMENT behind WAIT n, NOWAIT or IF EXISTS", + "Database.destructiveAutoStatement() lock-wait head and unplaced MODIFY @regression keeps lock-wait ALTERs that restate AUTO_INCREMENT or only add auto-eligible", + "migration live-schema MODIFY guard: lock-wait head and unplaced MODIFY @regression does not count a MODIFY inside a quoted literal", + "migration live-schema MODIFY guard: lock-wait head and unplaced MODIFY @regression finds the MODIFY behind WAIT n and NOWAIT", + "migration live-schema MODIFY guard: lock-wait head and unplaced MODIFY @regression refuses a MODIFY it cannot place instead of skipping it", + "migration live-schema MODIFY guard: lock-wait head and unplaced MODIFY @regression refuses a lock-wait MODIFY that strips AUTO_INCREMENT or a DEFAULT" + ], "ce1a39176c1aa135": [ "Security: DISPENSER Field Validation Expiration numeric validation [REGRESSION P1] R-DSP-001: should parse a valid numeric expiration", "Security: DISPENSER Field Validation Field injection resistance [REGRESSION P1] R-DSP-001: should handle extra pipe characters in DISPENSER payload", @@ -2850,17 +2994,23 @@ "applyBufferutilsPatch exported readUInt64LE/writeUInt64LE keep the Number-safe contract", "applyBufferutilsPatch patches the shared bitcoinjs-lib bufferutils module in place" ], - "d029e0b6b043e754": [ - "Database#dropDatabase() executes all DROP TABLE queries without throwing", - "Database#getConnection() retries on transient failure and succeeds on second attempt", - "Database#getConnection() returns transactionConnection when one is set", - "Database#getConnection() succeeds on first pool.getConnection call", - "Database#getConnection() throws after maxAttempts (30) consecutive failures" - ], "d104a881d7ea316d": [ "bufferutils patch twin parity with xchain-utxo-tracker @regression src/chain/apply_bufferutils_patch.js is byte-identical in both repos", "bufferutils patch twin parity with xchain-utxo-tracker @regression src/chain/bufferutils.js is byte-identical in both repos" ], + "d1425c12799087dd": [ + "Database error-path transactionConnection branches createAddress: rethrows an INSERT error inside an active transaction so the block rolls back", + "Database error-path transactionConnection branches createAddress: still swallows an INSERT error with no transaction open (mempool handle)", + "Database error-path transactionConnection branches deleteOpenDispensers: calls endTransaction when transactionConnection is active on generic error", + "Database error-path transactionConnection branches insertBlock: calls endTransaction when transactionConnection is active on generic error", + "Database error-path transactionConnection branches insertDispenser: calls endTransaction when transactionConnection is active on generic error", + "Database error-path transactionConnection branches insertEvent: a transaction-active error frees the transaction lock (no deadlock)", + "Database error-path transactionConnection branches insertEvent: calls endTransaction (rollback + frees lock) when a transaction is active on generic error", + "Database error-path transactionConnection branches insertMempoolTransaction: calls endTransaction when transactionConnection is active on generic error", + "Database error-path transactionConnection branches insertTransaction: calls endTransaction when transactionConnection is active on generic error", + "Database error-path transactionConnection branches insertTransactionOutput: calls endTransaction when transactionConnection is active on generic error", + "Database#ensureMigrationsLedger() calls CREATE TABLE IF NOT EXISTS schema_migrations on the connection" + ], "d18469488f659a45": [ "protocol time newest-first caller contract fetchPreviousBlockTimes returns a height-ordered window bounded by the median span", "protocol time newest-first caller contract returns the available newest-first prefix near genesis" @@ -2929,6 +3079,12 @@ "XChainDecoder.MAX_ACTION_DATA_LENGTH should be exported as a numeric constant", "XChainDecoder.MAX_ACTION_DATA_LENGTH should equal 8192 (protocol canonical value)" ], + "d618afbed6c9aef8": [ + "dispenser cancellation grace parent protocol floor anchors a mempool refresh to the latest stored parent protocol time", + "dispenser cancellation grace parent protocol floor falls back to the block clock when no finite parent clock is available", + "dispenser cancellation grace parent protocol floor keeps the current block MTP window bounded when resolving its parent clock", + "dispenser cancellation grace parent protocol floor uses the earlier parent protocol time only on an active network" + ], "d6270ac20e52b6cb": [ "Security: SQL Parameterization Database name validation [REGRESSION P0] R-SEC-001: should reject a database name with SQL injection characters", "Security: SQL Parameterization deleteAndCompareTxsNotInList parameterization [REGRESSION P0] R-SEC-001: should use parameterized placeholders instead of string concatenation" @@ -3027,14 +3183,6 @@ "clear-reorg-halt CLI reports failure when the clear row does not land", "clear-reorg-halt CLI still clears on the stock strict session" ], - "dc2a6bdefc1386bc": [ - "Database#deleteAndCompareTxsNotInList() deletes every stored row when the node mempool is empty", - "Database#deleteAndCompareTxsNotInList() drops the temp table and releases the connection even on the happy path", - "Database#deleteAndCompareTxsNotInList() never issues a bare full-table scan of mempool_transactions", - "Database#deleteAndCompareTxsNotInList() removes already-stored txids from the list in place (leaving only new arrivals)", - "Database#deleteAndCompareTxsNotInList() removes stored rows not in txidList and returns the delete count", - "Database#deleteAndCompareTxsNotInList() returns transactionsDeleted=0 on query error" - ], "dc378898866ff01a": [ "fixture ports adds CI_PORT_OFFSET to host ports", "fixture ports does not add the offset to an explicit final port override", @@ -3161,6 +3309,15 @@ "BATCH dispenser registration batched v2 refill / v1 cancel captures the oracle fee of a create+refill batch from the CREATE payload", "BATCH dispenser registration batched v2 refill / v1 cancel runs one extend per v2 sub-command and none for other actions" ], + "e593e0d867a3ce4f": [ + "XChainDecoder#getSourceFromOutput() P2WSH walk-back gate does not chase P2WSH-shaped outputs on non-segwit chains after activation", + "XChainDecoder#getSourceFromOutput() P2WSH walk-back gate preserves the P2WSH walk-back below an unarmed non-segwit gate", + "XChainDecoder#getSourceFromOutput() should chase P2SH outputs one level deeper", + "XChainDecoder#getSourceFromOutput() should return an address for a valid P2PKH output", + "XChainDecoder#getSourceFromOutput() should return null for OP_RETURN output (no valid address)", + "XChainDecoder#getSourceFromOutput() should return null when output index is out of bounds", + "XChainDecoder#getSourceFromOutput() should throw a tagged rpcLookupFailure when the connector throws (a failed lookup is not a null source)" + ], "e594278f2f7a35c1": [ "BATCH sub-command ACTION-name gate and alias expansion a provably-rejected sub-command suppresses the whole capture view a sibling empty element does not disturb a TOP-LEVEL action", "BATCH sub-command ACTION-name gate and alias expansion a provably-rejected sub-command suppresses the whole capture view below the gate, where nothing may move a top-level DISPENSER still registers below the gate", @@ -3219,9 +3376,90 @@ "Security: SQL Parameterization Database name validation should reject an empty database name", "Security: SQL Parameterization deleteAndCompareTxsNotInList parameterization [REGRESSION P0] R-SEC-001: should use parameterized placeholders instead of string concatenation" ], + "e9d4bf9e39d8e63a": [ + "Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression baselines when both columns already carry utf8mb4", + "Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression does NOT baseline a half-converted pair (one column still lagging)", + "Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression does NOT baseline at the pre-migration shape (both still utf8mb3)", + "Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression does NOT baseline when a charset is unreadable (NULL)", + "Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression does NOT baseline when either column is absent", + "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression baselines at the post-migration shape (tx_hash, no tx_hash_id)", + "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression does NOT baseline an ambiguous shape (both columns present)", + "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression does NOT baseline at the pre-migration shape (tx_hash_id still present)", + "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression does NOT baseline when a column name is unreadable (NULL)", + "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression does NOT baseline when the table or columns are absent", + "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression reads the two column names out of information_schema for this database", + "Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression baselines when the column already holds an uncompressed key (130 chars)", + "Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression baselines when the column is wider than required", + "Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression does NOT baseline at the pre-migration shape (narrow VARCHAR(66))", + "Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression does NOT baseline when the column is absent", + "Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression does NOT baseline when the length is unreadable (NULL)", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-02-widen-ids-to-bigint.sql: carries a MIGRATION_PRECONDITIONS baseline predicate", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-02-widen-ids-to-bigint.sql: carries the deploy-precondition header tag", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-02-widen-ids-to-bigint.sql: is mode=manual (an auto migration cannot be a missing precondition)", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-02-widen-ids-to-bigint.sql: names a real assertion method on Database", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-02-widen-ids-to-bigint.sql: the registered migration exists on disk", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-13-dispensers-expiration-bigint.sql: carries a MIGRATION_PRECONDITIONS baseline predicate", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-13-dispensers-expiration-bigint.sql: carries the deploy-precondition header tag", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-13-dispensers-expiration-bigint.sql: is mode=manual (an auto migration cannot be a missing precondition)", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-13-dispensers-expiration-bigint.sql: names a real assertion method on Database", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-13-dispensers-expiration-bigint.sql: the registered migration exists on disk", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-07-24-pubkeys-widen-uncompressed.sql: carries a MIGRATION_PRECONDITIONS baseline predicate", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-07-24-pubkeys-widen-uncompressed.sql: carries the deploy-precondition header tag", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-07-24-pubkeys-widen-uncompressed.sql: is mode=manual (an auto migration cannot be a missing precondition)", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-07-24-pubkeys-widen-uncompressed.sql: names a real assertion method on Database", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-07-24-pubkeys-widen-uncompressed.sql: the registered migration exists on disk", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-08-10-action-data-utf8mb4.sql: carries a MIGRATION_PRECONDITIONS baseline predicate", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-08-10-action-data-utf8mb4.sql: carries the deploy-precondition header tag", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-08-10-action-data-utf8mb4.sql: is mode=manual (an auto migration cannot be a missing precondition)", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-08-10-action-data-utf8mb4.sql: names a real assertion method on Database", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-08-10-action-data-utf8mb4.sql: the registered migration exists on disk", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 every tagged migration file is registered (no tag without an assertion behind it)", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 no mode=auto migration carries the tag", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 registers exactly the four migrations this tree asserts at startup", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 startupAssertedMigrationFile() resolves each registered assertion to its migration filename", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 startupAssertedMigrationFile() throws on an unregistered assertion rather than yielding undefined", + "Database.migrationDeclaresDeployPrecondition @regression @tier1 ignores the token on a comment line that is not the xchain:migration directive", + "Database.migrationDeclaresDeployPrecondition @regression @tier1 ignores the token once the SQL body has started, so prose or a data literal cannot arm it", + "Database.migrationDeclaresDeployPrecondition @regression @tier1 is false for an ordinary tagged migration", + "Database.migrationDeclaresDeployPrecondition @regression @tier1 is false for an untagged file and for empty input", + "Database.migrationDeclaresDeployPrecondition @regression @tier1 reads the tag off the xchain:migration directive line", + "Database.migrationDeclaresDeployPrecondition @regression @tier1 sees the tag through a long license banner (the prologue is unbounded)", + "Database.migrationDeclaresDeployPrecondition @regression @tier1 tolerates spacing around the token", + "startup assertion error text names the registered file @regression @tier1 assertActionDataIsUtf8mb4 names the exact migration file", + "startup assertion error text names the registered file @regression @tier1 assertDispenserExpirationIsBigintUnsigned names the exact migration file", + "startup assertion error text names the registered file @regression @tier1 assertPubkeyColumnIsUncompressedWide names the exact migration file" + ], + "eb049ebf43c69706": [ + "ORACLE_FEE_OUTPUT_ACTIVATION conformance is value-identical to the canonical map in xchain-documentation", + "ORACLE_FEE_OUTPUT_ACTIVATION conformance needs no indexer FIX_OUTPUT_FANOUT block-height gate (the decoder gate is time-only)", + "ORACLE_FEE_OUTPUT_ACTIVATION conformance never precedes the indexer FIX_OUTPUT_FANOUT flag-day (capture below it halts blocks)", + "ORACLE_FEE_OUTPUT_ACTIVATION conformance pins the mainnet flag-day and keeps testnet/regtest genesis-on", + "ORACLE_FEE_SET_CAPTURE_ACTIVATION conformance arms mainnet at the base gate instant by the 2026-09-09 ruling", + "ORACLE_FEE_SET_CAPTURE_ACTIVATION conformance carries a block time or null (DISARMED) per network, regtest genesis-on", + "ORACLE_FEE_SET_CAPTURE_ACTIVATION conformance is value-identical to the canonical map in xchain-documentation", + "ORACLE_FEE_SET_CAPTURE_ACTIVATION conformance never precedes the base oracle-fee capture gate on any network" + ], + "eb333e06c5c0a538": [ + "mempool first_seen DATETIME migration on MariaDB baselines a fresh schema and preserves its matching first_seen definition", + "mempool first_seen DATETIME migration on MariaDB leaves both migrated column definitions unchanged on a targeted rerun", + "mempool first_seen DATETIME migration on MariaDB records the migration when it builds the schema from empty, with first_seen already at its migrated definition", + "mempool first_seen DATETIME migration on MariaDB reproduces session-dependent reads on the aged TIMESTAMP columns", + "mempool first_seen DATETIME migration on MariaDB retypes the observation and ledger columns without shifting their values" + ], "eb458618fc930251": [ "XChainDecoder.verifyReorg does not mistake a failed DB read for an exhausted table [REGRESSION P0] retries the walk through failed reads and still rolls every orphan block back" ], + "ebc8dfbf50f8b096": [ + "mempool admission of a gate-skipped ACTION stores an oversized ACTION as a blanked seen-set row, fetched and counted once", + "mempool admission of a gate-skipped ACTION stores an unknown ACTION as a blanked seen-set row, fetched and counted once", + "mempool admission of an ACTION with no resolvable source blanks an unattributable ACTION to an empty string and keeps the row", + "mempool admission of an ACTION with no resolvable source keeps the ACTION text for a null source when a dispense output makes the tx storable", + "mempool admission of an ACTION with no resolvable source keeps the ACTION text when the source resolves", + "mempool admission of an ACTION with no resolvable source loads the open-dispenser set with the cancelGraceFloor for the network", + "mempool admission of an ACTION with no resolvable source parses against the getAllOpenDispenserAddresses set, so an unattributed ACTION paying an open dispenser keeps its text", + "mempool admission of an ACTION with no resolvable source still runs the cycle against an empty set when getAllOpenDispenserAddresses fails", + "mempool admission of an ACTION with no resolvable source still writes a blanked row for an actionless tx with no source" + ], "ed34426952081e37": [ "Fuzz: parseTransaction edge: transaction with no outputs should handle transaction with empty outs array" ], @@ -3250,6 +3488,12 @@ "f0f37f83b5c9b6ee": [ "Fuzz: parseTransaction multisig: all-zero pubkey data should handle multisig where pubkeys are all zeros" ], + "f1f04419f789bac2": [ + "duplicate transaction output insert rolls the block back never commits past a persistent dispense output collision", + "duplicate transaction output insert rolls the block back never commits past a persistent payment output collision", + "duplicate transaction output insert rolls the block back rolls back a dispense output collision and commits on the clean retry", + "duplicate transaction output insert rolls the block back rolls back a payment output collision and commits on the clean retry" + ], "f2206c18335eb69f": [ "BATCH limit vendoring and cross-repo conformance tier 3: driven against the REAL indexer Batch handler agrees with it on every vector, and never suppresses a batch it accepts", "BATCH limit vendoring and cross-repo conformance tier 3: driven against the REAL indexer Batch handler and agrees with the handler ABOVE the shared instant, where both weigh", @@ -3299,6 +3543,14 @@ "Database#getTransactionId() returns null (swallows) on query error", "Database#getTransactionId() returns null when no rows found" ], + "f361361379ed6348": [ + "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] assertWholeBlock is byte-identical in both repos", + "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] encodeVarintHex is byte-identical in both repos", + "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] every shared function carries a Keep-in-sync comment on both sides", + "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] readVarint is byte-identical in both repos", + "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] skipAuxPow is byte-identical in both repos", + "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] stripAuxPowFromBlockHex is byte-identical in both repos" + ], "f3933fa79957c8e8": [ "collapseRegistrationsByKey collapses selected keys while preserving first-seen values and merge rules", "collapseRegistrationsByKey falls back to the raw address when the selected key is null", @@ -3351,21 +3603,6 @@ "E2E: DISPENSER Lifecycle full dispenser flow B1.2: should record transaction_output when payment sent to dispenser address", "E2E: DISPENSER Lifecycle full dispenser flow B1.3: dispenser data should appear in indexer contract query" ], - "f7b308fd7d4c8015": [ - "ORACLE_FEE_OUTPUT_ACTIVATION conformance is value-identical to the canonical map in xchain-documentation", - "ORACLE_FEE_OUTPUT_ACTIVATION conformance never precedes the indexer FIX_OUTPUT_FANOUT flag-day (capture below it halts blocks)", - "ORACLE_FEE_OUTPUT_ACTIVATION conformance pins the mainnet flag-day and keeps testnet/regtest genesis-on", - "ORACLE_FEE_SET_CAPTURE_ACTIVATION conformance arms mainnet at the base gate instant by the 2026-09-09 ruling", - "ORACLE_FEE_SET_CAPTURE_ACTIVATION conformance carries a block time or null (DISARMED) per network, regtest genesis-on", - "ORACLE_FEE_SET_CAPTURE_ACTIVATION conformance is value-identical to the canonical map in xchain-documentation", - "ORACLE_FEE_SET_CAPTURE_ACTIVATION conformance never precedes the base oracle-fee capture gate on any network" - ], - "f826f2a2639c2bf3": [ - "mempool first_seen DATETIME migration on MariaDB baselines a fresh schema and preserves its matching first_seen definition", - "mempool first_seen DATETIME migration on MariaDB leaves both migrated column definitions unchanged on a targeted rerun", - "mempool first_seen DATETIME migration on MariaDB reproduces session-dependent reads on the aged TIMESTAMP columns", - "mempool first_seen DATETIME migration on MariaDB retypes the observation and ledger columns without shifting their values" - ], "f9ea8bf6004987f7": [ "Security: Connection Handling Transaction lock mechanism [REGRESSION P0] R-SEC-003: should acquire lock on first call", "Security: Connection Handling deleteBlockByIndex failure handling [REGRESSION P1] R-BUG-001: a subsequent call does not deadlock after a failure", @@ -3403,15 +3640,15 @@ }, "scripts": { "test": { - "fileCount": 223, - "titleCount": 1990, + "fileCount": 246, + "titleCount": 2165, "files": { "test/unit/action_manifest_conformance.test.js": "3e1e7599837cbf70", "test/unit/alias_expansion_boundary.test.js": "7a5cec648390c3a0", "test/unit/apply_bufferutils_patch.test.js": "d002ffc05ec60ec9", "test/unit/apply_bufferutils_patch_twin_parity.test.js": "d104a881d7ea316d", "test/unit/auxpow_reassembly.test.js": "67e3c12ff1c288e7", - "test/unit/auxpow_strip_parity.test.js": "8d9e5d5d21b31cbc", + "test/unit/auxpow_strip_parity.test.js": "3e0b1b842f3c3c3c", "test/unit/batch_dispenser_address_id_collapse.test.js": "d41d5e457c323cd7", "test/unit/batch_dispenser_registration.test.js": "a87567a9516739f2", "test/unit/batch_dispenser_registration.test/01_a_dispenser_created_inside_a_batch.test.js": "05ef33730e0f85a6", @@ -3427,6 +3664,7 @@ "test/unit/batch_dispenser_registration.test/11_the_dispenser_prefix_carries_its_delimiter_above_the_gate_at_the_top_level.test.js": "b254adfe1875935b", "test/unit/batch_dispenser_registration.test/12_collapse_dispenser_registrations.test.js": "075b788357e290ab", "test/unit/batch_dispenser_registration.test/13_collapse_registrations_by_key.test.js": "f3933fa79957c8e8", + "test/unit/batch_dispenser_registration.test/14_below_gate_collapse_frozen_reference.test.js": "290a48193a5b96df", "test/unit/batch_limits_vendoring.test.js": "537ab7708498cb2c", "test/unit/batch_limits_vendoring.test/tier_2_the_post_flag_rule_set_is_the_only_one_this_decoder_can_ever_see.test.js": "2267962a8018027e", "test/unit/batch_limits_vendoring.test/tier_3_driven_against_the_real_indexer_batch_handler.test.js": "f2206c18335eb69f", @@ -3475,8 +3713,10 @@ "test/unit/db.test/05_database_parse_expected_indexes.test.js": "0421fc1806b28494", "test/unit/db.test/06_database_reconcile_table_indexes.test.js": "646499266a236557", "test/unit/db.test/07_database_alter_table_for_drift_relax.test.js": "65e3e5f5a8f53eb0", + "test/unit/db.test/08_database_transaction_lock_release_on_failure.test.js": "1c8626c6adddaf9b", "test/unit/db/migration_query_timeout.test.js": "376df582d23998da", "test/unit/db_connection_release.test.js": "142291e578f9a313", + "test/unit/db_lookup_failure_rollback.test.js": "214fe5b9e9a614b0", "test/unit/db_ping_probe.test.js": "56fc18cc6fda9009", "test/unit/db_queries.test.js": "f2b4a0f1e0a013b6", "test/unit/db_queries.test/01_has_pubkey.test.js": "254ae1e75c67c363", @@ -3492,26 +3732,32 @@ "test/unit/db_queries.test/11_get_all_open_dispenser_addresses.test.js": "a8fd96d3c6db8dcc", "test/unit/db_queries.test/12_delete_open_dispensers.test.js": "4c9a4aedcd3fdf21", "test/unit/db_queries.test/13_purge_expired_dispensers.test.js": "a5ebca5c681e1fda", - "test/unit/db_queries.test/14_has_dispenser_transactions.test.js": "51fbc60e4c509ae1", - "test/unit/db_queries.test/15_delete_and_compare_txs_not_in_list.test.js": "dc2a6bdefc1386bc", - "test/unit/db_queries.test/16_drop_database.test.js": "d029e0b6b043e754", + "test/unit/db_queries.test/14_has_dispenser_transactions.test.js": "5d7a78d030197111", + "test/unit/db_queries.test/15_delete_and_compare_txs_not_in_list.test.js": "3b5a32687d9b7288", + "test/unit/db_queries.test/16_drop_database.test.js": "44157bc620b75d19", "test/unit/db_queries.test/17_release_connection.test.js": "df70911cc3a76904", "test/unit/db_queries.test/18_end_transaction.test.js": "6e0955a3b780d615", "test/unit/db_queries.test/19_verify_database.test.js": "b9f5fe3686fa1924", - "test/unit/db_queries.test/20_error_path_transaction_connection_branches.test.js": "054fdd1916b39a5f", + "test/unit/db_queries.test/20_error_path_transaction_connection_branches.test.js": "d1425c12799087dd", "test/unit/db_query_helpers.test.js": "6fa21ab5be3abe7a", "test/unit/db_session_timezone_utc.test.js": "4ef43ab7b728601d", "test/unit/decoder_halt_diagnostics.test.js": "a623c806af89bc2c", "test/unit/decoder_live_heartbeat.test.js": "1924e1a0eff49f01", + "test/unit/decoder_quarantine_signal.test.js": "1222f18dd8d3d9ce", + "test/unit/decoder_ready_probe.test/01_ready_route_reports_reorg_halt.test.js": "437c4eefedb348ac", "test/unit/decoder_stress_sweep.test.js": "973dda09a83b0b1a", "test/unit/decoder_tip_stale_surface.test.js": "cdab1aa16d30bd48", - "test/unit/dispenser_address_id_collapse_activation.test.js": "9cdf9bd6784320ad", + "test/unit/decoder_tip_stale_surface.test/01_reorg_halt_gauges.test.js": "2d79fc18db20fb8c", + "test/unit/dispenser_address_id_collapse_activation.test.js": "43609e549b3dee84", "test/unit/dispenser_address_key.test.js": "6579d4ea0926bf01", "test/unit/dispenser_cancel_edit_db.test.js": "80d233d9d6bb2811", "test/unit/dispenser_cancel_grace.test.js": "86ef365e34f8bfd3", "test/unit/dispenser_cancel_grace.test/01_database_get_all_open_dispenser_addresses_grace_floor.test.js": "8c9bfd7c64e31c4f", + "test/unit/dispenser_cancel_grace.test/block_time_gate_map.test.js": "a1cc9a9cc14fe706", "test/unit/dispenser_cancel_grace_activation.test.js": "88ab629ba53398a7", - "test/unit/dispenser_exact_integer_expiration.test.js": "411f596aab8ee136", + "test/unit/dispenser_cancel_grace_parent_floor.test.js": "d618afbed6c9aef8", + "test/unit/dispenser_cancel_grace_parent_floor_activation.test.js": "34bac8b72829a9ac", + "test/unit/dispenser_exact_integer_expiration.test.js": "50a477cef0e33403", "test/unit/dispenser_expiry_realign.test.js": "df84d924c9729396", "test/unit/dispenser_expiry_realign_activation.test.js": "820204db57b071e9", "test/unit/dispenser_extension_undo_reorg.test.js": "ab8fb229aacfa93f", @@ -3528,33 +3774,35 @@ "test/unit/dispenser_protocol_time.test.js": "25603dbc161ae5b3", "test/unit/dispenser_purge_failure_count.test.js": "3219370f321e36fa", "test/unit/dispenser_purge_grace.test.js": "98dd8c6fd3e3decd", - "test/unit/dispenser_purge_grace_activation.test.js": "033b7f903629733d", + "test/unit/dispenser_purge_grace_activation.test.js": "14b08a9944b65284", "test/unit/dispenser_safe_depth.test.js": "48dee2bb9f8949a5", "test/unit/dispenser_wide_expiration.test.js": "ca3928e5b27e5791", + "test/unit/duplicate_transaction_insert_rollback.test.js": "403aa81f64e27705", "test/unit/fee_destination.test.js": "0c8454135e113a90", "test/unit/getmempool_stale_read.test.js": "951b85192ced37b0", "test/unit/jsonrpc_body_guard.test.js": "58706b2a54fa0008", "test/unit/litecoin_block.test.js": "5ecf90a69618e860", "test/unit/mempool_api_surface.test.js": "3e9a24b03ca94279", "test/unit/mempool_first_seen_datetime.test.js": "dde536d960f1a1a5", - "test/unit/mempool_isolation.test.js": "6207eebcc7c69c98", + "test/unit/mempool_isolation.test.js": "4223f81e9f105e21", "test/unit/mempool_payload_representation.test.js": "7f339cce97ef3d87", - "test/unit/mempool_unattributable_action.test.js": "5199ac844e30531f", + "test/unit/mempool_unattributable_action.test.js": "ebc8dfbf50f8b096", "test/unit/migrate.test.js": "826dacd11ea07f42", "test/unit/migrate_status.test.js": "4fd0a29c075465f3", - "test/unit/migration_preconditions.test.js": "54639638d1536735", + "test/unit/migration_preconditions.test.js": "e9d4bf9e39d8e63a", + "test/unit/migration_preconditions.test/01_widen_ids_baseline_predicate.test.js": "531b63fa60260bea", "test/unit/migration_preconditions_bigint.test.js": "47c0720002969b72", "test/unit/migration_runner.test.js": "1fe4c0f9111bdecb", "test/unit/migration_runner.test/01_committed_migrations_declare_intent.test.js": "19e73caaa9b7c73f", "test/unit/migration_runner.test/02_database_migration_checksum_rebaselines.test.js": "35cbf8a92b2e7c36", "test/unit/migration_runner.test/03_run_migrations_checksum_re_bless_path.test.js": "f45010ff7d50d33a", "test/unit/migration_runner.test/04_run_migrations_file_opts_only_scoping.test.js": "9427161b3e973b0e", - "test/unit/migration_runner.test/05_run_migrations_migration_preconditions.test.js": "4044805d7869c790", + "test/unit/migration_runner.test/05_run_migrations_migration_preconditions.test.js": "b9d84db0edbc4eae", "test/unit/migration_runner.test/06_database_split_sql_statements.test.js": "45857eb89566bdb3", "test/unit/migration_runner.test/07_database_schema_contract_guards.test.js": "de71dddc3a70f32b", - "test/unit/migration_runner.test/08_destructive_guard_prefix_forms.test.js": "ace818c6415f144e", + "test/unit/migration_runner.test/08_destructive_guard_prefix_forms.test.js": "9256abaf91bbe0da", "test/unit/migration_runner.test/09_migration_query_timeout.test.js": "97b9a29072a0f010", - "test/unit/migration_runner.test/10_migration_executable_residue_pin.test.js": "cd8e0e210f80e0cb", + "test/unit/migration_runner.test/10_migration_executable_residue_pin.test.js": "4eb10f6c301434ed", "test/unit/migration_runner.test/11_destructive_guard_session_set.test.js": "8b1bac8a4cab2cbd", "test/unit/migration_runner.test/12_destructive_guard_modify_convert.test.js": "9c3f37e4f6fe7093", "test/unit/migration_runner.test/13_live_schema_guard.test.js": "a194c2b2855f5460", @@ -3562,18 +3810,21 @@ "test/unit/migration_runner.test/15_destructive_guard_drop_target.test.js": "94d278f823552144", "test/unit/migration_runner.test/16_destructive_guard_quoted_comments.test.js": "4edb863dbbb574cc", "test/unit/migration_runner.test/17_live_schema_guard_types.test.js": "8a5d5b0d051bc1ea", + "test/unit/migration_runner.test/18_destructive_guard_lock_wait.test.js": "cdee7ff351a1e063", + "test/unit/migration_runner.test/19_key_witness_guard.test.js": "9bd15abd96c0f43f", + "test/unit/migration_runner.test/20_declared_key_drop_guard.test.js": "b2f89504900651c4", "test/unit/migrations_ledger_datetime.test.js": "2a6097257b0a2986", "test/unit/node_catch_up_wait.test.js": "4763aa60db951190", "test/unit/node_catching_up_status.test.js": "d19fabd24013167c", "test/unit/node_reachability_status.test.js": "09f6b21d4c9c4493", "test/unit/node_url_failover.test.js": "df1a7438428d2073", - "test/unit/oracle_fee_output_activation_conformance.test.js": "f7b308fd7d4c8015", + "test/unit/oracle_fee_output_activation_conformance.test.js": "eb049ebf43c69706", "test/unit/parse_loop_quarantine.test.js": "7ab2b61a3082956b", "test/unit/parse_transaction.test.js": "7775a7711b003332", "test/unit/parse_transaction.test/01_parse_transaction_multisig_and_extraction.test.js": "e2dc1e87963a2b20", "test/unit/parse_transaction.test/02_parse_transaction_outputs_and_defaults.test.js": "d9b3257c562c64ff", "test/unit/parse_transaction.test/03_is_future_segwit_script.test.js": "aaf7aa8a7e0a3dd3", - "test/unit/parse_transaction.test/04_get_source_from_output.test.js": "5bfafba200cdf61c", + "test/unit/parse_transaction.test/04_get_source_from_output.test.js": "e593e0d867a3ce4f", "test/unit/parse_transaction.test/05_parse_transaction_p2wsh_per_chain_segwit_gate.test.js": "6a95ee16117e8e38", "test/unit/protocol_constants.test.js": "13584cc3d862a5a2", "test/unit/protocol_time.test.js": "8383b3fc6d715636", @@ -3583,8 +3834,11 @@ "test/unit/remove_obfuscation.test.js": "0eb0aadd100cef95", "test/unit/reorg_depth_across_restart.test.js": "cb25ed0b62ac6428", "test/unit/reorg_halt_clear.test.js": "dbef882bb2e58f54", + "test/unit/reorg_halt_clear.test/01_clear_confirms_on_read_back.test.js": "2a151fa9a71c2a05", + "test/unit/reorg_halt_clear.test/02_unaudited_clear_row_fails_closed.test.js": "383edaa2d7dc2c40", "test/unit/reorg_halt_park.test.js": "b6be0fd71f5537fb", "test/unit/reorg_halt_surface.test.js": "571065a79b6fdaba", + "test/unit/reorg_halt_surface.test/01_empty_probe_answer_keeps_the_halt.test.js": "6108c204ee3d7617", "test/unit/reorg_table_coverage.test.js": "8d1ac41b3c8d3fc3", "test/unit/repo/fixture_ports.test.js": "dc378898866ff01a", "test/unit/roundtrip.test.js": "319d38eebd8ba22a", @@ -3619,16 +3873,22 @@ "test/unit/util_extra.test.js": "3a87e525ae152e17", "test/unit/valid_action_names_flag_day.test.js": "48102ba3278bff5c", "test/unit/verify_reorg_retry.test.js": "fd57b09815088f6a", - "test/unit/verify_reorg_retry.test/01_xchain_decoder_verify_reorg_mid_walk_tip_regression.test.js": "b7c9218357c1b1f2", + "test/unit/verify_reorg_retry.test/01_xchain_decoder_verify_reorg_mid_walk_tip_regression.test.js": "9cf7a9676a09ed58", "test/unit/verify_reorg_retry.test/02_xchain_decoder_verify_reorg_does_not_mistake_a_failed_db_read_for_an_exhausted_table.test.js": "5aede94bd4bed74a", "test/unit/verify_reorg_retry.test/03_reorg_entry_paths_log_at_warn.test.js": "aa473d78908111d8", + "test/unit/verify_reorg_retry.test/04_settle_the_node_tip_before_a_rollback.test.js": "94f602d41143890d", "test/unit/verify_tables_skips_nonsql.test.js": "3abae033a7d98a61", + "test/unit/wire_parse/auxpow_stripped_block_integrity.test.js": "05b8d0b9317eedcf", + "test/unit/wire_parse/mempool_node_txid.test.js": "7f4c66806bfe01d8", + "test/unit/wire_parse/mweb_transaction_serialization.test.js": "afa2fc981402504f", + "test/unit/wire_parse/transaction_parse_conformance.test.js": "bc71a27833d1bfb3", "test/unit/xchain_block_decoder.test.js": "560dcfa0adadeb89", "test/unit/xchain_decoder.test.js": "13e00a456eac7fe0", "test/unit/xchain_decoder.test/01_xchain_decoder_find_funding_fee_outputs.test.js": "50182eb99944526e", "test/unit/xchain_decoder.test/02_xchain_decoder_verify_reorg_edge_cases.test.js": "2f9e2cc055d5ee1e", "test/unit/xchain_decoder.test/03_xchain_decoder_aux_pow_chain_identity_forcing.test.js": "5b918d471e1d540e", - "test/unit/xchain_decoder.test/04_xchain_decoder_max_action_data_length.test.js": "d50352784a92157f" + "test/unit/xchain_decoder.test/04_xchain_decoder_max_action_data_length.test.js": "d50352784a92157f", + "test/unit/xchain_decoder.test/05_xchain_decoder_duplicate_transaction_output_rollback.test.js": "f1f04419f789bac2" } }, "test:bench": { @@ -3792,15 +4052,17 @@ "skipped": "not a mocha command (runs node)" }, "test:integration:mocha": { - "fileCount": 6, - "titleCount": 34, + "fileCount": 8, + "titleCount": 45, "files": { "test/integration/dispensers.test.js": "4ddee0a65a60fc97", + "test/integration/fresh_schema_ledger_seed_real.test.js": "05c22e4bac27a7bd", "test/integration/indexer_contract.test.js": "577e505c41e8c55b", "test/integration/malformed.test.js": "a1f7b506ab91b517", - "test/integration/mempool_first_seen_datetime_real.test.js": "f826f2a2639c2bf3", + "test/integration/mempool_first_seen_datetime_real.test.js": "eb333e06c5c0a538", "test/integration/multisig.test.js": "2ee2031083237da3", - "test/integration/op_return.test.js": "7530468e8b9c8c69" + "test/integration/op_return.test.js": "7530468e8b9c8c69", + "test/integration/raw_transaction_guard.test.js": "0d97923a8aaa0619" } }, "test:integration:up": { @@ -3813,8 +4075,8 @@ "skipped": "not a mocha command (runs stryker)" }, "test:regression": { - "fileCount": 21, - "titleCount": 87, + "fileCount": 25, + "titleCount": 139, "files": { "test/security/action_validation.test.js": "d42bb5a9f837d16a", "test/security/connection_handling.test.js": "f9ea8bf6004987f7", @@ -3823,7 +4085,7 @@ "test/security/dispenser_validation.test.js": "ce1a39176c1aa135", "test/security/error_sanitization.test.js": "2feca699bcf820f3", "test/security/sql_parameterization.test.js": "d6270ac20e52b6cb", - "test/unit/auxpow_strip_parity.test.js": "257661796fdac2ec", + "test/unit/auxpow_strip_parity.test.js": "f361361379ed6348", "test/unit/block_prev_hash_byte_order.test.js": "2cc5bf1987da3a39", "test/unit/boundary/deobfuscation.test.js": "7021d0775728fec9", "test/unit/boundary/dispenser_parsing.test.js": "7b91e388506f917f", @@ -3836,7 +4098,11 @@ "test/unit/parse_transaction.test/01_parse_transaction_multisig_and_extraction.test.js": "447b696129587817", "test/unit/parse_transaction.test/02_parse_transaction_outputs_and_defaults.test.js": "3894c155a8954b2b", "test/unit/remove_obfuscation.test.js": "a02c065b82a3bdf9", - "test/unit/verify_reorg_retry.test/02_xchain_decoder_verify_reorg_does_not_mistake_a_failed_db_read_for_an_exhausted_table.test.js": "eb458618fc930251" + "test/unit/verify_reorg_retry.test/02_xchain_decoder_verify_reorg_does_not_mistake_a_failed_db_read_for_an_exhausted_table.test.js": "eb458618fc930251", + "test/unit/verify_reorg_retry.test/04_settle_the_node_tip_before_a_rollback.test.js": "94f602d41143890d", + "test/unit/wire_parse/auxpow_stripped_block_integrity.test.js": "05b8d0b9317eedcf", + "test/unit/wire_parse/mempool_node_txid.test.js": "7f4c66806bfe01d8", + "test/unit/wire_parse/mweb_transaction_serialization.test.js": "afa2fc981402504f" } }, "test:regression:critical": { @@ -3860,8 +4126,8 @@ } }, "test:regression:full": { - "fileCount": 27, - "titleCount": 127, + "fileCount": 31, + "titleCount": 179, "files": { "test/regression/bugfix.test.js": "6076549f252b92c9", "test/regression/migrate_hints.test.js": "95b9a69144b6d40d", @@ -3872,7 +4138,7 @@ "test/security/dispenser_validation.test.js": "ce1a39176c1aa135", "test/security/error_sanitization.test.js": "2feca699bcf820f3", "test/security/sql_parameterization.test.js": "d6270ac20e52b6cb", - "test/unit/auxpow_strip_parity.test.js": "257661796fdac2ec", + "test/unit/auxpow_strip_parity.test.js": "f361361379ed6348", "test/unit/block_prev_hash_byte_order.test.js": "2cc5bf1987da3a39", "test/unit/blockchain_connector.test.js": "fe5b5679ddb3b515", "test/unit/blockchain_connector.test/01_get_block_without_aux_pow.test.js": "55c6513b14367055", @@ -3889,6 +4155,10 @@ "test/unit/parse_transaction.test/02_parse_transaction_outputs_and_defaults.test.js": "3894c155a8954b2b", "test/unit/remove_obfuscation.test.js": "a02c065b82a3bdf9", "test/unit/verify_reorg_retry.test/02_xchain_decoder_verify_reorg_does_not_mistake_a_failed_db_read_for_an_exhausted_table.test.js": "eb458618fc930251", + "test/unit/verify_reorg_retry.test/04_settle_the_node_tip_before_a_rollback.test.js": "94f602d41143890d", + "test/unit/wire_parse/auxpow_stripped_block_integrity.test.js": "05b8d0b9317eedcf", + "test/unit/wire_parse/mempool_node_txid.test.js": "7f4c66806bfe01d8", + "test/unit/wire_parse/mweb_transaction_serialization.test.js": "afa2fc981402504f", "test/unit/xchain_block_decoder.test.js": "5972469ed3bacacb" } }, @@ -3920,15 +4190,15 @@ } }, "test:unit": { - "fileCount": 223, - "titleCount": 1990, + "fileCount": 246, + "titleCount": 2165, "files": { "test/unit/action_manifest_conformance.test.js": "3e1e7599837cbf70", "test/unit/alias_expansion_boundary.test.js": "7a5cec648390c3a0", "test/unit/apply_bufferutils_patch.test.js": "d002ffc05ec60ec9", "test/unit/apply_bufferutils_patch_twin_parity.test.js": "d104a881d7ea316d", "test/unit/auxpow_reassembly.test.js": "67e3c12ff1c288e7", - "test/unit/auxpow_strip_parity.test.js": "8d9e5d5d21b31cbc", + "test/unit/auxpow_strip_parity.test.js": "3e0b1b842f3c3c3c", "test/unit/batch_dispenser_address_id_collapse.test.js": "d41d5e457c323cd7", "test/unit/batch_dispenser_registration.test.js": "a87567a9516739f2", "test/unit/batch_dispenser_registration.test/01_a_dispenser_created_inside_a_batch.test.js": "05ef33730e0f85a6", @@ -3944,6 +4214,7 @@ "test/unit/batch_dispenser_registration.test/11_the_dispenser_prefix_carries_its_delimiter_above_the_gate_at_the_top_level.test.js": "b254adfe1875935b", "test/unit/batch_dispenser_registration.test/12_collapse_dispenser_registrations.test.js": "075b788357e290ab", "test/unit/batch_dispenser_registration.test/13_collapse_registrations_by_key.test.js": "f3933fa79957c8e8", + "test/unit/batch_dispenser_registration.test/14_below_gate_collapse_frozen_reference.test.js": "290a48193a5b96df", "test/unit/batch_limits_vendoring.test.js": "537ab7708498cb2c", "test/unit/batch_limits_vendoring.test/tier_2_the_post_flag_rule_set_is_the_only_one_this_decoder_can_ever_see.test.js": "2267962a8018027e", "test/unit/batch_limits_vendoring.test/tier_3_driven_against_the_real_indexer_batch_handler.test.js": "f2206c18335eb69f", @@ -3992,8 +4263,10 @@ "test/unit/db.test/05_database_parse_expected_indexes.test.js": "0421fc1806b28494", "test/unit/db.test/06_database_reconcile_table_indexes.test.js": "646499266a236557", "test/unit/db.test/07_database_alter_table_for_drift_relax.test.js": "65e3e5f5a8f53eb0", + "test/unit/db.test/08_database_transaction_lock_release_on_failure.test.js": "1c8626c6adddaf9b", "test/unit/db/migration_query_timeout.test.js": "376df582d23998da", "test/unit/db_connection_release.test.js": "142291e578f9a313", + "test/unit/db_lookup_failure_rollback.test.js": "214fe5b9e9a614b0", "test/unit/db_ping_probe.test.js": "56fc18cc6fda9009", "test/unit/db_queries.test.js": "f2b4a0f1e0a013b6", "test/unit/db_queries.test/01_has_pubkey.test.js": "254ae1e75c67c363", @@ -4009,26 +4282,32 @@ "test/unit/db_queries.test/11_get_all_open_dispenser_addresses.test.js": "a8fd96d3c6db8dcc", "test/unit/db_queries.test/12_delete_open_dispensers.test.js": "4c9a4aedcd3fdf21", "test/unit/db_queries.test/13_purge_expired_dispensers.test.js": "a5ebca5c681e1fda", - "test/unit/db_queries.test/14_has_dispenser_transactions.test.js": "51fbc60e4c509ae1", - "test/unit/db_queries.test/15_delete_and_compare_txs_not_in_list.test.js": "dc2a6bdefc1386bc", - "test/unit/db_queries.test/16_drop_database.test.js": "d029e0b6b043e754", + "test/unit/db_queries.test/14_has_dispenser_transactions.test.js": "5d7a78d030197111", + "test/unit/db_queries.test/15_delete_and_compare_txs_not_in_list.test.js": "3b5a32687d9b7288", + "test/unit/db_queries.test/16_drop_database.test.js": "44157bc620b75d19", "test/unit/db_queries.test/17_release_connection.test.js": "df70911cc3a76904", "test/unit/db_queries.test/18_end_transaction.test.js": "6e0955a3b780d615", "test/unit/db_queries.test/19_verify_database.test.js": "b9f5fe3686fa1924", - "test/unit/db_queries.test/20_error_path_transaction_connection_branches.test.js": "054fdd1916b39a5f", + "test/unit/db_queries.test/20_error_path_transaction_connection_branches.test.js": "d1425c12799087dd", "test/unit/db_query_helpers.test.js": "6fa21ab5be3abe7a", "test/unit/db_session_timezone_utc.test.js": "4ef43ab7b728601d", "test/unit/decoder_halt_diagnostics.test.js": "a623c806af89bc2c", "test/unit/decoder_live_heartbeat.test.js": "1924e1a0eff49f01", + "test/unit/decoder_quarantine_signal.test.js": "1222f18dd8d3d9ce", + "test/unit/decoder_ready_probe.test/01_ready_route_reports_reorg_halt.test.js": "437c4eefedb348ac", "test/unit/decoder_stress_sweep.test.js": "973dda09a83b0b1a", "test/unit/decoder_tip_stale_surface.test.js": "cdab1aa16d30bd48", - "test/unit/dispenser_address_id_collapse_activation.test.js": "9cdf9bd6784320ad", + "test/unit/decoder_tip_stale_surface.test/01_reorg_halt_gauges.test.js": "2d79fc18db20fb8c", + "test/unit/dispenser_address_id_collapse_activation.test.js": "43609e549b3dee84", "test/unit/dispenser_address_key.test.js": "6579d4ea0926bf01", "test/unit/dispenser_cancel_edit_db.test.js": "80d233d9d6bb2811", "test/unit/dispenser_cancel_grace.test.js": "86ef365e34f8bfd3", "test/unit/dispenser_cancel_grace.test/01_database_get_all_open_dispenser_addresses_grace_floor.test.js": "8c9bfd7c64e31c4f", + "test/unit/dispenser_cancel_grace.test/block_time_gate_map.test.js": "a1cc9a9cc14fe706", "test/unit/dispenser_cancel_grace_activation.test.js": "88ab629ba53398a7", - "test/unit/dispenser_exact_integer_expiration.test.js": "411f596aab8ee136", + "test/unit/dispenser_cancel_grace_parent_floor.test.js": "d618afbed6c9aef8", + "test/unit/dispenser_cancel_grace_parent_floor_activation.test.js": "34bac8b72829a9ac", + "test/unit/dispenser_exact_integer_expiration.test.js": "50a477cef0e33403", "test/unit/dispenser_expiry_realign.test.js": "df84d924c9729396", "test/unit/dispenser_expiry_realign_activation.test.js": "820204db57b071e9", "test/unit/dispenser_extension_undo_reorg.test.js": "ab8fb229aacfa93f", @@ -4045,33 +4324,35 @@ "test/unit/dispenser_protocol_time.test.js": "25603dbc161ae5b3", "test/unit/dispenser_purge_failure_count.test.js": "3219370f321e36fa", "test/unit/dispenser_purge_grace.test.js": "98dd8c6fd3e3decd", - "test/unit/dispenser_purge_grace_activation.test.js": "033b7f903629733d", + "test/unit/dispenser_purge_grace_activation.test.js": "14b08a9944b65284", "test/unit/dispenser_safe_depth.test.js": "48dee2bb9f8949a5", "test/unit/dispenser_wide_expiration.test.js": "ca3928e5b27e5791", + "test/unit/duplicate_transaction_insert_rollback.test.js": "403aa81f64e27705", "test/unit/fee_destination.test.js": "0c8454135e113a90", "test/unit/getmempool_stale_read.test.js": "951b85192ced37b0", "test/unit/jsonrpc_body_guard.test.js": "58706b2a54fa0008", "test/unit/litecoin_block.test.js": "5ecf90a69618e860", "test/unit/mempool_api_surface.test.js": "3e9a24b03ca94279", "test/unit/mempool_first_seen_datetime.test.js": "dde536d960f1a1a5", - "test/unit/mempool_isolation.test.js": "6207eebcc7c69c98", + "test/unit/mempool_isolation.test.js": "4223f81e9f105e21", "test/unit/mempool_payload_representation.test.js": "7f339cce97ef3d87", - "test/unit/mempool_unattributable_action.test.js": "5199ac844e30531f", + "test/unit/mempool_unattributable_action.test.js": "ebc8dfbf50f8b096", "test/unit/migrate.test.js": "826dacd11ea07f42", "test/unit/migrate_status.test.js": "4fd0a29c075465f3", - "test/unit/migration_preconditions.test.js": "54639638d1536735", + "test/unit/migration_preconditions.test.js": "e9d4bf9e39d8e63a", + "test/unit/migration_preconditions.test/01_widen_ids_baseline_predicate.test.js": "531b63fa60260bea", "test/unit/migration_preconditions_bigint.test.js": "47c0720002969b72", "test/unit/migration_runner.test.js": "1fe4c0f9111bdecb", "test/unit/migration_runner.test/01_committed_migrations_declare_intent.test.js": "19e73caaa9b7c73f", "test/unit/migration_runner.test/02_database_migration_checksum_rebaselines.test.js": "35cbf8a92b2e7c36", "test/unit/migration_runner.test/03_run_migrations_checksum_re_bless_path.test.js": "f45010ff7d50d33a", "test/unit/migration_runner.test/04_run_migrations_file_opts_only_scoping.test.js": "9427161b3e973b0e", - "test/unit/migration_runner.test/05_run_migrations_migration_preconditions.test.js": "4044805d7869c790", + "test/unit/migration_runner.test/05_run_migrations_migration_preconditions.test.js": "b9d84db0edbc4eae", "test/unit/migration_runner.test/06_database_split_sql_statements.test.js": "45857eb89566bdb3", "test/unit/migration_runner.test/07_database_schema_contract_guards.test.js": "de71dddc3a70f32b", - "test/unit/migration_runner.test/08_destructive_guard_prefix_forms.test.js": "ace818c6415f144e", + "test/unit/migration_runner.test/08_destructive_guard_prefix_forms.test.js": "9256abaf91bbe0da", "test/unit/migration_runner.test/09_migration_query_timeout.test.js": "97b9a29072a0f010", - "test/unit/migration_runner.test/10_migration_executable_residue_pin.test.js": "cd8e0e210f80e0cb", + "test/unit/migration_runner.test/10_migration_executable_residue_pin.test.js": "4eb10f6c301434ed", "test/unit/migration_runner.test/11_destructive_guard_session_set.test.js": "8b1bac8a4cab2cbd", "test/unit/migration_runner.test/12_destructive_guard_modify_convert.test.js": "9c3f37e4f6fe7093", "test/unit/migration_runner.test/13_live_schema_guard.test.js": "a194c2b2855f5460", @@ -4079,18 +4360,21 @@ "test/unit/migration_runner.test/15_destructive_guard_drop_target.test.js": "94d278f823552144", "test/unit/migration_runner.test/16_destructive_guard_quoted_comments.test.js": "4edb863dbbb574cc", "test/unit/migration_runner.test/17_live_schema_guard_types.test.js": "8a5d5b0d051bc1ea", + "test/unit/migration_runner.test/18_destructive_guard_lock_wait.test.js": "cdee7ff351a1e063", + "test/unit/migration_runner.test/19_key_witness_guard.test.js": "9bd15abd96c0f43f", + "test/unit/migration_runner.test/20_declared_key_drop_guard.test.js": "b2f89504900651c4", "test/unit/migrations_ledger_datetime.test.js": "2a6097257b0a2986", "test/unit/node_catch_up_wait.test.js": "4763aa60db951190", "test/unit/node_catching_up_status.test.js": "d19fabd24013167c", "test/unit/node_reachability_status.test.js": "09f6b21d4c9c4493", "test/unit/node_url_failover.test.js": "df1a7438428d2073", - "test/unit/oracle_fee_output_activation_conformance.test.js": "f7b308fd7d4c8015", + "test/unit/oracle_fee_output_activation_conformance.test.js": "eb049ebf43c69706", "test/unit/parse_loop_quarantine.test.js": "7ab2b61a3082956b", "test/unit/parse_transaction.test.js": "7775a7711b003332", "test/unit/parse_transaction.test/01_parse_transaction_multisig_and_extraction.test.js": "e2dc1e87963a2b20", "test/unit/parse_transaction.test/02_parse_transaction_outputs_and_defaults.test.js": "d9b3257c562c64ff", "test/unit/parse_transaction.test/03_is_future_segwit_script.test.js": "aaf7aa8a7e0a3dd3", - "test/unit/parse_transaction.test/04_get_source_from_output.test.js": "5bfafba200cdf61c", + "test/unit/parse_transaction.test/04_get_source_from_output.test.js": "e593e0d867a3ce4f", "test/unit/parse_transaction.test/05_parse_transaction_p2wsh_per_chain_segwit_gate.test.js": "6a95ee16117e8e38", "test/unit/protocol_constants.test.js": "13584cc3d862a5a2", "test/unit/protocol_time.test.js": "8383b3fc6d715636", @@ -4100,8 +4384,11 @@ "test/unit/remove_obfuscation.test.js": "0eb0aadd100cef95", "test/unit/reorg_depth_across_restart.test.js": "cb25ed0b62ac6428", "test/unit/reorg_halt_clear.test.js": "dbef882bb2e58f54", + "test/unit/reorg_halt_clear.test/01_clear_confirms_on_read_back.test.js": "2a151fa9a71c2a05", + "test/unit/reorg_halt_clear.test/02_unaudited_clear_row_fails_closed.test.js": "383edaa2d7dc2c40", "test/unit/reorg_halt_park.test.js": "b6be0fd71f5537fb", "test/unit/reorg_halt_surface.test.js": "571065a79b6fdaba", + "test/unit/reorg_halt_surface.test/01_empty_probe_answer_keeps_the_halt.test.js": "6108c204ee3d7617", "test/unit/reorg_table_coverage.test.js": "8d1ac41b3c8d3fc3", "test/unit/repo/fixture_ports.test.js": "dc378898866ff01a", "test/unit/roundtrip.test.js": "319d38eebd8ba22a", @@ -4136,16 +4423,22 @@ "test/unit/util_extra.test.js": "3a87e525ae152e17", "test/unit/valid_action_names_flag_day.test.js": "48102ba3278bff5c", "test/unit/verify_reorg_retry.test.js": "fd57b09815088f6a", - "test/unit/verify_reorg_retry.test/01_xchain_decoder_verify_reorg_mid_walk_tip_regression.test.js": "b7c9218357c1b1f2", + "test/unit/verify_reorg_retry.test/01_xchain_decoder_verify_reorg_mid_walk_tip_regression.test.js": "9cf7a9676a09ed58", "test/unit/verify_reorg_retry.test/02_xchain_decoder_verify_reorg_does_not_mistake_a_failed_db_read_for_an_exhausted_table.test.js": "5aede94bd4bed74a", "test/unit/verify_reorg_retry.test/03_reorg_entry_paths_log_at_warn.test.js": "aa473d78908111d8", + "test/unit/verify_reorg_retry.test/04_settle_the_node_tip_before_a_rollback.test.js": "94f602d41143890d", "test/unit/verify_tables_skips_nonsql.test.js": "3abae033a7d98a61", + "test/unit/wire_parse/auxpow_stripped_block_integrity.test.js": "05b8d0b9317eedcf", + "test/unit/wire_parse/mempool_node_txid.test.js": "7f4c66806bfe01d8", + "test/unit/wire_parse/mweb_transaction_serialization.test.js": "afa2fc981402504f", + "test/unit/wire_parse/transaction_parse_conformance.test.js": "bc71a27833d1bfb3", "test/unit/xchain_block_decoder.test.js": "560dcfa0adadeb89", "test/unit/xchain_decoder.test.js": "13e00a456eac7fe0", "test/unit/xchain_decoder.test/01_xchain_decoder_find_funding_fee_outputs.test.js": "50182eb99944526e", "test/unit/xchain_decoder.test/02_xchain_decoder_verify_reorg_edge_cases.test.js": "2f9e2cc055d5ee1e", "test/unit/xchain_decoder.test/03_xchain_decoder_aux_pow_chain_identity_forcing.test.js": "5b918d471e1d540e", - "test/unit/xchain_decoder.test/04_xchain_decoder_max_action_data_length.test.js": "d50352784a92157f" + "test/unit/xchain_decoder.test/04_xchain_decoder_max_action_data_length.test.js": "d50352784a92157f", + "test/unit/xchain_decoder.test/05_xchain_decoder_duplicate_transaction_output_rollback.test.js": "f1f04419f789bac2" } } } diff --git a/bin/pins/suite-title-renames.json b/bin/pins/suite-title-renames.json index 1b0ad3f8..1a0be5f2 100644 --- a/bin/pins/suite-title-renames.json +++ b/bin/pins/suite-title-renames.json @@ -5,6 +5,9 @@ "test/unit/db_queries.test/04_delete_block_by_index.test.js": { "Database#deleteBlockByIndex() executes 4 DELETE queries and returns true on success": "Database#deleteBlockByIndex() executes 5 DELETE queries and returns true on success" }, + "test/unit/db_queries.test/15_delete_and_compare_txs_not_in_list.test.js": { + "Database#deleteAndCompareTxsNotInList() returns transactionsDeleted=0 on query error": "Database#deleteAndCompareTxsNotInList() rejects on query error, leaving txidList whole and releasing the connection" + }, "test/unit/db_queries.test/20_error_path_transaction_connection_branches.test.js": { "Database error-path transactionConnection branches createAddress: swallows INSERT error even with active transactionConnection": "Database error-path transactionConnection branches createAddress: rethrows an INSERT error inside an active transaction so the block rolls back" }, diff --git a/package-lock.json b/package-lock.json index 0113830c..51de7f04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-decoder", - "version": "0.25.0", + "version": "0.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-decoder", - "version": "0.25.0", + "version": "0.26.0", "license": "AGPL-3.0-or-later", "dependencies": { "axios": "^1.18.1", diff --git a/package.json b/package.json index 9d8fd06e..3c311d83 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xchain-decoder", "description": "xchain-decoder decodes XChain platform transactions from a given blockchain and populates a database with the decoded data.", - "version": "0.25.0", + "version": "0.26.0", "license": "AGPL-3.0-or-later", "repository": { "type": "git", diff --git a/src/XChainDecoder/constants.js b/src/XChainDecoder/constants.js index ab7fcfa7..d430c55f 100644 --- a/src/XChainDecoder/constants.js +++ b/src/XChainDecoder/constants.js @@ -101,8 +101,9 @@ const SYNCED_THRESHOLD = 3 //Maximum blocks behind to be synced // money-bearing dispenser on the reorged node. Standard networks use 126 and // Litecoin testnet uses 5006. Invariant: SAFE_DEPTH >= matching undo window + // margin. The +6 margin means a small undo-window re-tune -// cannot land exactly at the purge threshold; dispenserSafeDepth.test.js -// enforces the invariant with a conformance read of undo-blocks.js. +// cannot land exactly at the purge threshold; test/unit/dispenser_safe_depth.test.js +// enforces the invariant with a conformance read of DEFAULT_UNDO_BLOCKS in +// xchain-utxo-tracker/src/chain/undo_blocks.js. // Purging deeper is the conservative direction (rows are merely retained longer // before hard-purge; expiry semantics and action evaluation are unchanged). const DISPENSER_EXPIRE_SAFE_DEPTH = 126 // 120 (deepest standard window) + 6 margin diff --git a/src/XChainDecoder/mempool_refresh.js b/src/XChainDecoder/mempool_refresh.js index 020e0ed5..d6b9ea1e 100644 --- a/src/XChainDecoder/mempool_refresh.js +++ b/src/XChainDecoder/mempool_refresh.js @@ -196,7 +196,9 @@ function* storeMempoolTransaction(nodeTxid, nextTxHex, nextTxHexIndex, openDispe // confirmed twin silently mismatches (uuid:26220713). A rejected ACTION // on a money-bearing tx blanks to '' (never SQL NULL) for the same reason. const stored = admitMempoolAction.call(this, parseResult, nextTransactionHash) - if (stored.skip) return false + // Store a gate-skipped tx blanked as its seen-set entry (the block path rejects it + // once; without a row every poll re-fetches it and re-counts its parse error). + const blanked = stored.skip if (!(yield this.mempoolDb.insertMempoolTransaction({ hash: nextTransactionHash, @@ -204,14 +206,14 @@ function* storeMempoolTransaction(nodeTxid, nextTxHex, nextTxHexIndex, openDispe destination: parseResult["destination"], amount: parseResult["amount"], fee: 0, - data: stored.data, - raw_data: stored.rawData + data: blanked ? "" : stored.data, + raw_data: blanked ? null : stored.rawData }))) { yield this.sleep(3000) return false } else { - return !stored.unattributed && (parseResult["data"] != null) && (parseResult["data"].length > 0) + return !blanked && !stored.unattributed && (parseResult["data"] != null) && (parseResult["data"].length > 0) } } diff --git a/src/XChainDecoder/payload_helpers.js b/src/XChainDecoder/payload_helpers.js index 97bdfe8b..c394fd1b 100644 --- a/src/XChainDecoder/payload_helpers.js +++ b/src/XChainDecoder/payload_helpers.js @@ -35,6 +35,13 @@ function nodeStillCatchingUp(info){ return !!info && info["initialblockdownload"] === true } +// Check a getblockchaininfo reply carries numeric `blocks` and `verificationprogress`. +// A trimmed proxy reply makes `undefined < threshold` false and passes the not-synced +// gate, so both node-tip paths (fetchChainTip, readReorgTip) refuse it through this. +function blockchainInfoWellFormed(info){ + return !!info && typeof info["blocks"] === 'number' && typeof info["verificationprogress"] === 'number' +} + // Compiled size of a single script push once bitcoin.script.compile adds its // length prefix: a direct push opcode for <=75 bytes, OP_PUSHDATA1 (+2) for // <=255, or OP_PUSHDATA2 (+3) beyond that. Single source for measuring both @@ -103,4 +110,4 @@ function bigIntBufferutilsActive(bufferutils){ return false } } -module.exports = { nodeStillCatchingUp, compiledPushSize, canonicalizeActionPayload, bigIntBufferutilsActive } +module.exports = { nodeStillCatchingUp, blockchainInfoWellFormed, compiledPushSize, canonicalizeActionPayload, bigIntBufferutilsActive } diff --git a/src/XChainDecoder/reorg_verification.js b/src/XChainDecoder/reorg_verification.js index 42557df9..6078c2ba 100644 --- a/src/XChainDecoder/reorg_verification.js +++ b/src/XChainDecoder/reorg_verification.js @@ -22,7 +22,7 @@ const { format: formatLogLine } = require('node:util') const { chainTierMismatch } = require('../protocol/chain_identity') const { logger, DISPENSER_EXPIRE_SAFE_DEPTH, MIN_VERIFICATION_PROGRESS_TO_PARSE } = require('./constants.js') const { haltReorg } = require('./reorg_halt.js') -const { nodeStillCatchingUp } = require('./payload_helpers.js') +const { nodeStillCatchingUp, blockchainInfoWellFormed } = require('./payload_helpers.js') function safeDepthFor(decoder){ return decoder.dispenserExpireSafeDepth || DISPENSER_EXPIRE_SAFE_DEPTH @@ -175,11 +175,18 @@ function nodeTipStillCatchingUp(info){ } // Read the node tip through the block loop's gates. Answers nodeTip unchanged when the -// node cannot be read or a gate refuses the reply (foreign tier, foreign genesis, a node -// still catching up), so a refused reading is never below the tip the walk already has. +// node cannot be read or a gate refuses the reply (malformed shape, foreign tier, foreign +// genesis, a node still catching up), so a refused reading is never below the tip the walk +// already has. async function readReorgTip(nodeTip){ try { const info = await this.connector.getBlockchainInfo() + // Refuse the same malformed shape fetchChainTip refuses: without a numeric + // verificationprogress the catching-up gate below cannot fire. + if (info && !blockchainInfoWellFormed(info)){ + this.logWarn('reorg: ignoring a malformed tip refresh (missing or non-numeric blocks/verificationprogress)') + return nodeTip + } // Apply the block loop's chain-identity gate here too. This is // the SECOND path a node tip reaches nodeTip, and nodeTip is exactly what // the above-tip branch deletes valid local blocks against, so a foreign @@ -190,7 +197,7 @@ async function readReorgTip(nodeTip){ const reorgChainMismatch = info ? chainTierMismatch(this.consensusNetwork, info["chain"]) : null if (reorgChainMismatch){ this.logError('reorg: ignoring a tip refresh from a foreign endpoint: ' + reorgChainMismatch) - } else if (info && typeof info.blocks === 'number') { + } else if (info) { // Tier agreement is not chain identity: a same-tier foreign node (BTC-mainnet // and DOGE-mainnet both report chain="main") passes the tier gate above, so // re-prove the chain with the genesis pin too, exactly as the block loop does diff --git a/src/XChainDecoder/source_resolution.js b/src/XChainDecoder/source_resolution.js index 5d2f97db..a374b83c 100644 --- a/src/XChainDecoder/source_resolution.js +++ b/src/XChainDecoder/source_resolution.js @@ -23,6 +23,7 @@ const crypto = require('crypto') const bitcoin = require('bitcoinjs-lib') const { format: formatLogLine } = require('node:util') const { logger } = require('./constants.js') +const { P2WSH_WALKBACK_SEGWIT_GATE_ACTIVATION } = require('../protocol/constants/activation.js') function* fetchPrevoutTransaction(txId, outputIndex){ // A prevout lookup that FAILS is not a prevout that does not exist. Swallowing @@ -61,7 +62,7 @@ function* fetchPrevoutTransaction(txId, outputIndex){ return this.xchainBlockDecoder.transactionFromHex(outputRawTransaction) } -function isRevealCarrierScript(script){ +function revealCarrierType(script){ let isP2sh = ( (script.length == 23) //23 bytes for a standard p2sh && (script[0] == 0xa9) //OP_HASH160 @@ -73,7 +74,19 @@ function isRevealCarrierScript(script){ && (script[0] == 0x00) //OP_0 (witness v0) && (script[1] == 0x20) //PUSH 32 bytes ) - return isP2sh || isP2wsh + if (isP2sh) return 'p2sh' + if (isP2wsh) return 'p2wsh' + return null +} + +function p2wshWalkbackEnabled(decoder, blockHeight){ + if (decoder.network.supportsSegwit !== false) return true + const coinMap = P2WSH_WALKBACK_SEGWIT_GATE_ACTIVATION[decoder.coinTick] + const activation = coinMap ? coinMap[decoder.consensusNetwork] : null + const gateActive = typeof activation === 'number' + && typeof blockHeight === 'number' + && blockHeight >= activation + return !gateActive } function* fetchCommitFundingOutput(outputTransaction){ @@ -99,7 +112,7 @@ function* fetchCommitFundingOutput(outputTransaction){ return prevTransaction.outs[prevOutputIndex] } -function* resolveSourceFromOutput(txId, outputIndex, capture){ +function* resolveSourceFromOutput(txId, outputIndex, capture, blockHeight){ let source = null let output = null let outputTransaction = null @@ -122,7 +135,9 @@ function* resolveSourceFromOutput(txId, outputIndex, capture){ //THAT prev output's address (the funder/issuer). Without the P2WSH branch //the source of every P2WSH-encoded action resolved to the bech32 script //address (bcrt1q...), which holds no gas → spurious "insufficient funds (FEE)". - if (isRevealCarrierScript(script)){ + const carrierType = revealCarrierType(script) + if (carrierType === 'p2sh' + || (carrierType === 'p2wsh' && p2wshWalkbackEnabled(this, blockHeight))){ output = yield* fetchCommitFundingOutput.call(this, outputTransaction) } @@ -191,13 +206,13 @@ module.exports = { // a widened return value on purpose: the return contract (a source address or null) // is stubbed and asserted across the suite, and a caller that ignores `capture` // behaves exactly as before. - async getSourceFromOutput(txId, outputIndex, capture = null){ + async getSourceFromOutput(txId, outputIndex, capture = null, blockHeight){ // The lookup steps are generators that yield each node read to this // loop, so the method suspends only while a read is pending, once per // read, and the decode and walk-back after a read run in the same // stretch. An async helper awaited here would add a suspension after // each read settles. - const steps = resolveSourceFromOutput.call(this, txId, outputIndex, capture) + const steps = resolveSourceFromOutput.call(this, txId, outputIndex, capture, blockHeight) let next = steps.next() while (!next.done){ let settled diff --git a/src/XChainDecoder/tip_refresh.js b/src/XChainDecoder/tip_refresh.js index 6bce8f3e..64d7f003 100644 --- a/src/XChainDecoder/tip_refresh.js +++ b/src/XChainDecoder/tip_refresh.js @@ -21,7 +21,7 @@ const { format: formatLogLine } = require('node:util') const { chainTierMismatch, chainFieldMissing, chainGenesisUnpinned } = require('../protocol/chain_identity') const { logger, BLOCKCHAIN_INFO_REFRESH_MS, MIN_VERIFICATION_PROGRESS_TO_PARSE } = require('./constants.js') -const { nodeStillCatchingUp } = require('./payload_helpers.js') +const { nodeStillCatchingUp, blockchainInfoWellFormed } = require('./payload_helpers.js') const { parkOrRethrow } = require('./sync_loop.js') // lastBlockchainInfo is the reply fetchChainTip just stored on the loop, passed in @@ -104,12 +104,10 @@ async function fetchChainTip(loop){ // response or a per-coin getblockchaininfo variant could omit these // fields; without this guard `undefined < 0.99` is false (the // not-synced gate silently passes) and `blocks` becomes undefined - // (every later height comparison quietly goes wrong). Mirror the - // typeof-number discipline verifyReorg's tip refresh already applies - // and treat a malformed result like the RPC-failure branch below. - if (!loop.lastBlockchainInfo - || typeof loop.lastBlockchainInfo["blocks"] !== 'number' - || typeof loop.lastBlockchainInfo["verificationprogress"] !== 'number'){ + // (every later height comparison quietly goes wrong). verifyReorg's + // mid-walk tip refresh applies the same shared check, and a malformed + // result is treated like the RPC-failure branch below. + if (!blockchainInfoWellFormed(loop.lastBlockchainInfo)){ logger.error("Malformed getblockchaininfo response (missing or non-numeric 'blocks'/'verificationprogress'). Trying again...") loop.lastBlockchainInfo = null await this.sleep(3000) diff --git a/src/XChainDecoder/transaction_ingest.js b/src/XChainDecoder/transaction_ingest.js index 24770f5a..da0e1b3a 100644 --- a/src/XChainDecoder/transaction_ingest.js +++ b/src/XChainDecoder/transaction_ingest.js @@ -188,9 +188,17 @@ async function storeDispenseOutput(loop, nextOutput, nextBlockHeight){ logger.error(`insertTransactionOutput (dispense) failed at block ${nextBlockHeight}; block rolled back, retrying`) return 'rollback' } - if (insertResult === this.db.DUPLICATED_TRANSACTION){ - logger.warn(`Duplicate transaction_output on insert (block_index=${nextBlockHeight}, tx_index=${loop.lastProcessedTxIndex}, vout=${nextOutput.vout}); possible stale pre-reorg row not cleaned up by deleteBlockByIndex`) - } + if (insertResult === this.db.DUPLICATED_TRANSACTION) + return rollbackDuplicateOutput.call(this, loop, nextOutput, nextBlockHeight, 'dispense') +} + +// The parser never yields one (tx_index, vout) twice, so a duplicate output is a row this +// tx_index did not write (an orphan the transactions counter cannot see). Keeping it would +// attach another destination/amount to this transaction, so roll back as insertTransactionRow does. +async function rollbackDuplicateOutput(loop, nextOutput, nextBlockHeight, kind){ + logger.error(`insertTransactionOutput (${kind}) hit an existing transaction_outputs row in block ${nextBlockHeight} (tx_index ${loop.lastProcessedTxIndex}, vout ${nextOutput.vout}); possible orphaned row, rolling the block back`) + await this.db.endTransaction() + return 'rollback' } //Store payment outputs the indexer needs to read: @@ -256,9 +264,8 @@ async function capturePaymentOutputs(loop, block, parseResult, nextTransactionHa logger.error(`insertTransactionOutput (payment) failed at block ${nextBlockHeight}; block rolled back, retrying`) return 'rollback' } - if (insertResult === this.db.DUPLICATED_TRANSACTION){ - logger.warn(`Duplicate transaction_output on insert (block_index=${nextBlockHeight}, tx_index=${loop.lastProcessedTxIndex}, vout=${nextOutput.vout}); possible stale pre-reorg row not cleaned up by deleteBlockByIndex`) - } + if (insertResult === this.db.DUPLICATED_TRANSACTION) + return rollbackDuplicateOutput.call(this, loop, nextOutput, nextBlockHeight, 'payment') } } return commands diff --git a/src/XChainDecoder/transaction_parsing.js b/src/XChainDecoder/transaction_parsing.js index 24ddfc51..3233d4c2 100644 --- a/src/XChainDecoder/transaction_parsing.js +++ b/src/XChainDecoder/transaction_parsing.js @@ -152,7 +152,7 @@ function buildParseResult(dataBuffer, compiledDataLength, rawData, source, sourc } } -function* resolveXChainTransaction(transaction, db, nextTxId, firstInputTxId, dispenseOutputs, paymentOutputs, parsed){ +function* resolveXChainTransaction(transaction, db, blockHeight, nextTxId, firstInputTxId, dispenseOutputs, paymentOutputs, parsed){ let { source, dataBuffer, rawData, getSource, p2shFundingTxId, envelopeCarrier, envelopeCommitTransaction } = parsed // compiledDataLength starts as the raw accumulated byte count. // For P2SH/P2WSH/OP_RETURN this equals the compiled push size (the @@ -181,7 +181,7 @@ function* resolveXChainTransaction(transaction, db, nextTxId, firstInputTxId, di if (getSource && (source == null)){ source = envelopeCarrier ? yield this.getEnvelopeSourceFromCommit(envelopeCommitTransaction) - : yield this.getSourceFromOutput(firstInputTxId, transaction.ins[0].index, sourceCommitCapture) + : yield this.getSourceFromOutput(firstInputTxId, transaction.ins[0].index, sourceCommitCapture, blockHeight) } // Read the key from the input that spent the source's UTXO. For an envelope that @@ -239,7 +239,7 @@ function* parseXChainTransaction(transaction, openDispenserAddresses, db, blockH ;({ dataBuffer, p2shFundingTxId, envelopeCarrier, envelopeCommitTransaction } = yield* arbitrateEnvelope.call(this, envelopeActive, envelopeInputs, blockHeight, nextTxId, firstInputTxId, otherCarrierRecognized, { dataBuffer, p2shFundingTxId, envelopeCarrier, envelopeCommitTransaction })) - return yield* resolveXChainTransaction.call(this, transaction, db, nextTxId, firstInputTxId, dispenseOutputs, paymentOutputs, { source, dataBuffer, rawData, getSource, p2shFundingTxId, envelopeCarrier, envelopeCommitTransaction }) + return yield* resolveXChainTransaction.call(this, transaction, db, blockHeight, nextTxId, firstInputTxId, dispenseOutputs, paymentOutputs, { source, dataBuffer, rawData, getSource, p2shFundingTxId, envelopeCarrier, envelopeCommitTransaction }) } function* parseTransactionSteps(transaction, openDispenserAddresses, db, blockHeight){ diff --git a/src/db/database_setup.js b/src/db/database_setup.js index d0205c34..e3316154 100644 --- a/src/db/database_setup.js +++ b/src/db/database_setup.js @@ -13,6 +13,7 @@ **********************************************************************/ const fs = require('fs'); +const crypto = require('crypto'); const util = require('../util') const Database = require('../db.js') const { format: formatLogLine } = require('node:util'); @@ -80,6 +81,12 @@ async function verifyTableFiles(database, files, existing, db){ return { checked, created }; } +// True when no table a src/sql file declares exists yet: a database this release builds from empty. +function isFreshSchema(files, existing){ + const declared = files.filter(f => f.endsWith('.sql')).map(f => f.slice(0, -4)); + return declared.length > 0 && !declared.some(table => existing.has(table)); +} + module.exports = { async verifyDatabase(){ let connectionParams = { @@ -154,6 +161,9 @@ module.exports = { logger.info('Verifying database and tables...'); let result; try { + // A database built from empty takes every table at this release's src/sql shape, which is + // every committed migration's end state, so record them all before any CREATE runs. + if(isFreshSchema(files, existing)) await this.recordFreshSchemaMigrations(db); result = await verifyTableFiles(this, files, existing, db); if(result === null) return false; } finally { @@ -176,6 +186,48 @@ module.exports = { return true; }, + // Record every committed migration as applied on a database verifyTables is about to build + // from empty, so startup never replays one against tables that already hold its end state. + // Same lock and checksum as runMigrations; one transaction, so the ledger is all or nothing. + async recordFreshSchemaMigrations(db){ + const dir = this.sqlPath + '/migrations'; + let files = []; + try { files = fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } + catch(_){ return; } // no migrations dir: nothing to record + if(!files.length) return; + // Ledger DDL commits implicitly, so it runs before the transaction opens. + await this.ensureMigrationsLedger(db); + // Refusing is safe: no declared table exists yet, so a restart seeds cleanly. + const lockName = 'xchain_migrate_' + this.dbName; + const got = await db.query('SELECT GET_LOCK(?, 30) AS l', [lockName]); + if(!got || !got[0] || String(got[0].l) !== '1') + throw new Error('recordFreshSchemaMigrations: could not acquire lock ' + lockName + + ' (another process is seeding or migrating this database). No table has been created ' + + 'yet, so restarting this process seeds the ledger cleanly.'); + try { + await db.query('START TRANSACTION'); + try { + for(const file of files){ + const raw = fs.readFileSync(dir + '/' + file, 'utf8'); + const checksum = crypto.createHash('sha256').update(raw).digest('hex'); + // A row a racing peer already committed is kept as it stands. + await db.query( + 'INSERT INTO schema_migrations (name, checksum, mode, applied_at) VALUES (?, ?, ?, NOW()) ' + + 'ON DUPLICATE KEY UPDATE name = name', + [file, checksum, this.migrationMode(raw)] + ); + } + await db.query('COMMIT'); + } catch(err){ + try { await db.query('ROLLBACK'); } catch(_){} + throw err; + } + } finally { + try { await db.query('SELECT RELEASE_LOCK(?)', [lockName]); } catch(_){} + } + logger.info('Fresh install: recorded ' + files.length + ' migration(s) in schema_migrations before creating tables.'); + }, + // Apply tracked, ordered schema migrations from src/sql/migrations/: the changes the // startup drift reconciler deliberately will not make on its own (data backfills, // destructive index/column changes, dedup-then-unique, type changes). Each file is @@ -332,35 +384,4 @@ module.exports = { } } }, - - //This is only used in tests - async dropDatabase(){ - logger.info("Droping database") - - const dropBlockTable = "DROP TABLE IF EXISTS blocks" - const dropTransactionTable = "DROP TABLE IF EXISTS transactions" - const dropIndexAddressesTable = "DROP TABLE IF EXISTS index_addresses" - const dropIndexTransactionsTable = "DROP TABLE IF EXISTS index_transactions" - const dropEventsTable = "DROP TABLE IF EXISTS events" - const dropTransactionOutputsTable = "DROP TABLE IF EXISTS transaction_outputs" - const dropDispensersTable = "DROP TABLE IF EXISTS dispensers" - const dropMempoolTransactionsTable = "DROP TABLE IF EXISTS mempool_transactions" - const dropPubkeysTable = "DROP TABLE IF EXISTS pubkeys" - - let connection = await this.getConnection() - - // Drop child / referencing tables before their parents. pubkeys carries a - // foreign key onto index_addresses, so it must go before index_addresses - // below or the DROP would fail with a constraint error. - await connection.query(dropTransactionOutputsTable) - await connection.query(dropDispensersTable) - await connection.query(dropMempoolTransactionsTable) - await connection.query(dropPubkeysTable) - await connection.query(dropTransactionTable) - await connection.query(dropBlockTable) - await connection.query(dropIndexAddressesTable) - await connection.query(dropIndexTransactionsTable) - await connection.query(dropEventsTable) - await connection.release() - }, } diff --git a/src/db/dispenser_queries.js b/src/db/dispenser_queries.js index ad2232c6..a3fd0efd 100644 --- a/src/db/dispenser_queries.js +++ b/src/db/dispenser_queries.js @@ -285,15 +285,17 @@ module.exports = { // LIMIT 1 stops at the first hit; a database with none scans the table once, // which is acceptable for a one-off operator command. // - // BOTH arms are load-bearing. A dispenser opened inside a BATCH is stored as - // `BATCH|0|DISPENSER|0|...`, which a top-level `DISPENSER|%` prefix test cannot - // see, and the decoder does register those (the batch sub-command capture gate is - // in force on every network). Over-matching is deliberate and fail-safe: this + // ALL THREE arms are load-bearing. A dispenser opened inside a BATCH is stored as + // `BATCH|0|DISPENSER|0|...` when it is the first sub-command and as + // `BATCH|0|SEND|0|...;DISPENSER|0|...` at any later position (sub-commands are + // ';'-joined), so a top-level `DISPENSER|%` prefix test sees neither, and the + // decoder does register both (the batch sub-command capture gate is in force on + // every network). Over-matching is deliberate and fail-safe: this // probe backs a REFUSAL, so a false positive costs the operator one replica // comparison plus an explicit --force, while a false negative silently certifies // a cleanliness that was never established. Do not narrow it again. async hasDispenserTransactions(){ - const query = `SELECT 1 FROM transactions WHERE data LIKE 'DISPENSER|%' OR data LIKE '%|DISPENSER|%' LIMIT 1;` + const query = `SELECT 1 FROM transactions WHERE data LIKE 'DISPENSER|%' OR data LIKE '%|DISPENSER|%' OR data LIKE '%;DISPENSER|%' LIMIT 1;` let connection = await this.getConnection() const ownLease = (this.transactionConnection == null) try { diff --git a/src/db/migration_checksum_rebaselines.js b/src/db/migration_checksum_rebaselines.js index 848bc266..dbe87e5b 100644 --- a/src/db/migration_checksum_rebaselines.js +++ b/src/db/migration_checksum_rebaselines.js @@ -27,7 +27,8 @@ const Database = require('../db.js') // bottom, and the 8151979 revision of the unique-index one. // Applied fleet-wide through code deploy: both the startup auto-run and // `node src/db/migrate.js` pass through this heal before the mismatch guard, so no -// direct schema_migrations SQL is ever needed. Mirrors xchain-indexer/src/db/index.js. +// direct schema_migrations SQL is ever needed. Mirrors +// xchain-indexer/src/db/migration/checksum_rebaselines.js:MIGRATION_CHECKSUM_REBASELINES. Database.MIGRATION_CHECKSUM_REBASELINES = { // Comment-only edits: 3a1c435 rewrote the validator note into the follower // ordering note (and dropped an em-dash), ec36bd4 added the license header. diff --git a/src/db/migration_guards/applied_ledger.js b/src/db/migration_guards/applied_ledger.js new file mode 100644 index 00000000..b4787655 --- /dev/null +++ b/src/db/migration_guards/applied_ledger.js @@ -0,0 +1,94 @@ +/********************************************************************* + * + * 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 config = require('../../config'); +const Database = require('../../db.js') +const { logger } = require('../constants.js') + +// Reconcile an applied file's checksum with the ledger: a match, a pinned rebaseline, or flagged drift +async function reconcileAppliedMigration(context, file, checksum){ + const recorded = context.appliedByName.get(file); + if(recorded === checksum) return true; + // Deliberate one-off rebaselines: an applied file whose only change + // was a reviewed non-executable edit (e.g. a mode retag) may be + // rebaselined here so fleets that recorded the old checksum heal + // in place instead of failing every operator migrate run forever. + // Both hashes are pinned, so any OTHER edit still trips the guard. + const rebase = Database.MIGRATION_CHECKSUM_REBASELINES[file]; + // `from` is a single hash or a list: the same reviewed edit can + // supersede several historical file revisions, and each DB recorded + // whichever revision it applied first. + const fromList = rebase ? [].concat(rebase.from) : []; + if(rebase && fromList.includes(recorded) && checksum === rebase.to){ + await context.conn.query('UPDATE schema_migrations SET checksum = ? WHERE name = ?', [checksum, file]); + logger.info('runMigrations: rebaselined checksum for ' + file + ' (reviewed retag, executable SQL unchanged).'); + return true; + } + // Migrations are immutable once applied. A changed checksum means + // someone edited an applied file, so the DB is now on a schema that + // diverges from what the committed file describes. + const msg = 'runMigrations: ' + file + ' was already applied but its content CHANGED (checksum mismatch: recorded ' + + recorded + ', current ' + checksum + '). Migrations are immutable once applied.'; + // Operator path (`node src/db/migrate.js`, includeManual) and opt-in strict + // mode fail closed so a diverged schema is caught in CI / by an operator + // instead of silently continuing. Default auto-startup stays non-fatal + // (console.error, not warn) to avoid a surprise fleet-wide boot failure. + // Mirrors xchain-indexer/src/db/database/migration_runner.js:checkAppliedChecksum. + if(context.includeManual || config.MIGRATION_STRICT_CHECKSUM === '1'){ + // Tailor the remedy to which branch actually fired. The operator path + // (includeManual, `node src/db/migrate.js`) ALWAYS fails closed by design, so + // MIGRATION_STRICT_CHECKSUM has no effect there - telling the operator to + // clear it just loops them back to the same error. Only the passive + // startup path opted into strict mode via MIGRATION_STRICT_CHECKSUM=1 can + // actually be downgraded by clearing it. + const hint = context.includeManual + ? ' This operator run always fails closed (MIGRATION_STRICT_CHECKSUM has no' + + ' effect here). Either revert ' + file + ' to the content matching the' + + ' recorded checksum, or - if the edit was reviewed and changed no' + + ' executable SQL - add a pinned Database.MIGRATION_CHECKSUM_REBASELINES' + + ' entry mapping the recorded hash to the current one.' + : ' Review manually (set MIGRATION_STRICT_CHECKSUM=0 / omit to downgrade to a non-fatal log).'; + throw new Error(msg + hint); + } + logger.error(msg + ' Continuing on the diverged schema - review manually.'); + return true; +} + +// Flag an auto file dated before a migration the ledger already applied +function guardMigrationFrontier(context, file, mode){ + // Backdating guard: the runner's dated-prefix check freezes the NAMING + // convention, but nothing stopped a new file from being dated before a + // migration the fleet already applied. Lexical apply order then puts it + // in its date slot on a fresh DB and after the frontier on an aged one, + // diverging the two schemas. `frontier` is the ledger state at run start + // (appliedByName is not written during the loop, and the runner's precondition + // baseline deliberately does not advance it), so files applied or + // baselined by THIS run never move it and a resumed partial run is fine. + // Auto files only - see Database.backdatedFrontierViolation for why a + // deferred mode=manual file cannot be told apart from a backdated one. + // Mirrors xchain-indexer/src/db/database/migration_runner.js:guardBackdatedFrontier. + if(mode !== 'auto') return; + const frontier = Database.backdatedFrontierViolation(file, context.appliedByName.keys()); + if(!frontier) return; + const msg = 'runMigrations: ' + file + ' is dated BEFORE already-applied migration ' + frontier + + ', so it would run in a different position here than on a fresh database and diverge the schema. ' + + 'Rename it with a date after ' + frontier + '.'; + // Same dual-mode contract as the checksum guard above: the operator + // path and opt-in strict mode fail closed, passive startup logs and + // proceeds so a backdated commit cannot black-start the fleet. + if(context.includeManual || config.MIGRATION_STRICT_CHECKSUM === '1') throw new Error(msg); + logger.error(msg + ' Applying it anyway at this position - review manually.'); +} + +module.exports = { reconcileAppliedMigration, guardMigrationFrontier } diff --git a/src/db/migration_guards/declared_key_drop.js b/src/db/migration_guards/declared_key_drop.js new file mode 100644 index 00000000..06198bb0 --- /dev/null +++ b/src/db/migration_guards/declared_key_drop.js @@ -0,0 +1,165 @@ +/********************************************************************* + * + * Copyright © 2025-2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC - https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * XChain Decoder - migration declared-key drop guard + * + * Dropping an index loses no row data, but dropping a key src/sql still declares + * UNIQUE or PRIMARY lets key-dependent writers add duplicate rows until a later boot + * re-adds it. This guard refuses such a drop in a mode=auto file unless the same file + * re-creates the key under the same name and kind. The indexer and decoder copies match. + * + ********************************************************************/ + +'use strict'; + +const { alterSpecs, stripBlockComments } = require('../migration_live_schema_guard.js'); + +const IDENT = '(`[^`]+`|[A-Za-z_$][\\w$]*)'; +const QUALIFIED = '((?:`[^`]+`|[\\w$]+)(?:\\s*\\.\\s*(?:`[^`]+`|[\\w$]+))?)'; +const STANDALONE_DROP = new RegExp('^DROP\\s+(?:(?:ONLINE|OFFLINE)\\s+)?INDEX\\s+(?:IF\\s+EXISTS\\s+)?' + IDENT + '\\s+ON\\s+' + QUALIFIED, 'i'); +const STANDALONE_UNIQUE = new RegExp('^CREATE\\s+UNIQUE\\s+INDEX\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?' + IDENT + '\\s+ON\\s+' + QUALIFIED, 'i'); +const SPEC_DROP_KEY = new RegExp('^DROP\\s+(?:INDEX|KEY|CONSTRAINT)\\s+(?:IF\\s+EXISTS\\s+)?' + IDENT, 'i'); +const UNIQUE_KEY = new RegExp('^(?:CONSTRAINT\\s+' + IDENT + '\\s+)?UNIQUE\\b(?:\\s+(?:INDEX|KEY))?(?:\\s+IF\\s+NOT\\s+EXISTS)?\\s*(?:' + IDENT + '\\s*)?(?:USING\\s+\\w+\\s*)?\\(\\s*' + IDENT, 'i'); +const PRIMARY_KEY = new RegExp('^(?:CONSTRAINT(?:\\s+' + IDENT + ')?\\s+)?PRIMARY\\s+KEY\\b', 'i'); + +// Lower-case, unquoted identifier, and the bare table name of a db-qualified one. +const ident = (name) => String(name).trim().replace(/^`|`$/g, '').toLowerCase(); +const tableOf = (qualified) => ident(String(qualified).split('.').pop()); + +// Blank each quoted string to an empty pair, keeping backtick identifiers, so no value reads as SQL. +const blankStrings = (text) => text.replace(/'(?:[^'\\]|\\.|'')*'|"(?:[^"\\]|\\.|"")*"/g, m => m[0] + m[0]); + +// Statement text the drop and re-create patterns read: comments gone, strings blanked. +const syntax = (stmt) => blankStrings(stripBlockComments(String(stmt))).trim(); + +// The top-level comma-separated parts of a parenthesised list, quote- and paren-aware. +function topLevelParts(body){ + const out = []; + let depth = 0, quote = null, cur = ''; + for(const ch of body){ + if(quote){ cur += ch; if(ch === quote) quote = null; continue; } + if(ch === "'" || ch === '"' || ch === '`'){ quote = ch; cur += ch; continue; } + if(ch === '(') depth++; + else if(ch === ')') depth--; + if(ch === ',' && depth === 0){ out.push(cur.trim()); cur = ''; continue; } + cur += ch; + } + if(cur.trim()) out.push(cur.trim()); + return out; +} + +// The body between the parentheses of `CREATE TABLE (...)`, or null when absent. +function createTableBody(sql, table){ + const head = new RegExp('CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?' + QUALIFIED + '\\s*\\(', 'ig'); + let m; + while((m = head.exec(sql)) !== null){ + if(tableOf(m[1]) !== table) continue; + let depth = 1, i = head.lastIndex; + for(; i < sql.length && depth > 0; i++){ + if(sql[i] === '(') depth++; + else if(sql[i] === ')') depth--; + } + return depth === 0 ? sql.slice(head.lastIndex, i - 1) : null; + } + return null; +} + +// The UNIQUE or PRIMARY key a definition re-creates, as { name, kind }, or null. +function keyDefinition(def){ + const text = def.replace(/^ADD\s+/i, '').trim(); + if(PRIMARY_KEY.test(text)) return { name: 'primary', kind: 'PRIMARY' }; + const u = UNIQUE_KEY.exec(text); + if(u) return { name: ident(u[2] || u[1] || u[3]), kind: 'UNIQUE' }; + return null; +} + +// Every key a table's src/sql text declares UNIQUE or PRIMARY, as a Map of name to kind. +// An unnamed inline UNIQUE takes its first column's name, which is the live name the engine gives it. +function declaredKeys(sql, table){ + const keys = new Map(); + if(!sql) return keys; + const text = blankStrings(stripBlockComments(String(sql).replace(/--[^\n]*/g, ''))); + const body = createTableBody(text, table); + for(const part of body ? topLevelParts(body) : []){ + const key = keyDefinition(part); + if(key){ keys.set(key.name, key.kind); continue; } + if(/^(?:KEY|INDEX|FULLTEXT|SPATIAL|CONSTRAINT|FOREIGN|CHECK)\b/i.test(part)) continue; + const column = /^(`[^`]+`|[\w$]+)/.exec(part); + if(!column) continue; + if(/\bPRIMARY\s+KEY\b/i.test(part)) keys.set('primary', 'PRIMARY'); + else if(/\bUNIQUE\b/i.test(part)) keys.set(ident(column[1]), 'UNIQUE'); + } + for(const stmt of text.split(';')){ + const m = STANDALONE_UNIQUE.exec(stmt.trim()); + if(m && tableOf(m[2]) === table) keys.set(ident(m[1]), 'UNIQUE'); + } + return keys; +} + +// The key drops and UNIQUE/PRIMARY re-creates of one statement, in statement order. +function keyEvents(stmt){ + const text = syntax(stmt); + const drop = STANDALONE_DROP.exec(text); + if(drop) return [{ op: 'drop', table: tableOf(drop[2]), name: ident(drop[1]) }]; + const create = STANDALONE_UNIQUE.exec(text); + if(create) return [{ op: 'add', table: tableOf(create[2]), name: ident(create[1]), kind: 'UNIQUE' }]; + if(/^DROP\s+(?:(?:ONLINE|OFFLINE)\s+)?INDEX\b/i.test(text)) return [{ op: 'unread', text }]; + if(!/^ALTER\b/i.test(text)) return []; + const parsed = alterSpecs(text); + if(!parsed) return /\bDROP\s+(?:INDEX|KEY|PRIMARY|CONSTRAINT)\b/i.test(text) ? [{ op: 'unread', text }] : []; + const table = ident(parsed.table); + const events = []; + for(const raw of parsed.specs){ + const spec = raw.trim(); + if(/^DROP\s+PRIMARY\s+KEY\b/i.test(spec)){ events.push({ op: 'drop', table, name: 'primary' }); continue; } + const dropped = SPEC_DROP_KEY.exec(spec); + if(dropped){ events.push({ op: 'drop', table, name: ident(dropped[1]) }); continue; } + if(/^DROP\s+(?:INDEX|KEY|PRIMARY|CONSTRAINT)\b/i.test(spec)){ events.push({ op: 'unread', text: spec }); continue; } + const added = /^ADD\b/i.test(spec) ? keyDefinition(spec) : null; + if(added) events.push({ op: 'add', table, name: added.name, kind: added.kind }); + } + return events; +} + +// Refuse a mode=auto file that drops a key src/sql still declares UNIQUE or PRIMARY, unless a +// later clause or statement of the same file re-creates it under the same name and kind. +// Names and kinds are compared, never columns: src/sql holds today's declaration, and an older +// pending file was written against an earlier one. sqlFor(table) returns the src/sql text or null. +function assertNoDeclaredKeyDrop(file, statements, sqlFor){ + const declaredByTable = new Map(); + const declared = (table) => { + if(!declaredByTable.has(table)) declaredByTable.set(table, declaredKeys(sqlFor(table), table)); + return declaredByTable.get(table); + }; + const open = new Map(); + for(const stmt of statements || []){ + for(const ev of keyEvents(stmt)){ + if(ev.op === 'unread'){ + throw new Error('runMigrations: ' + file + ' is tagged mode=auto but drops a key this guard cannot read: "' + + ev.text.slice(0, 120) + '". Re-tag the file mode=manual and apply it deliberately.'); + } + const kind = declared(ev.table).get(ev.name); + const key = ev.table + '.' + ev.name; + if(ev.op === 'drop' && kind) open.set(key, { table: ev.table, name: ev.name, kind }); + else if(ev.op === 'add' && open.has(key) && open.get(key).kind === ev.kind) open.delete(key); + } + } + for(const k of open.values()){ + throw new Error('runMigrations: ' + file + ' is tagged mode=auto but drops ' + k.kind + ' key ' + k.name + + ' on ' + k.table + ', which src/sql/' + k.table + '.sql still declares. Re-create it in the same file, ' + + 'update src/sql/' + k.table + '.sql in the same change, or re-tag the file mode=manual and apply it deliberately.'); + } +} + +module.exports = { assertNoDeclaredKeyDrop, declaredKeys, keyEvents }; diff --git a/src/db/migration_guards/destructive_statement_rules.js b/src/db/migration_guards/destructive_statement_rules.js new file mode 100644 index 00000000..ee72bfe5 --- /dev/null +++ b/src/db/migration_guards/destructive_statement_rules.js @@ -0,0 +1,171 @@ +/********************************************************************* + * + * 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 statement rules behind destructiveAutoStatement in ../migration_statements.js. +const { opensBackslashEscape, createNamesNonInnoDb, dropTargetDestroys } = require('../query_helpers.js') +const { alterSpecs, blankLiterals } = require('../migration_live_schema_guard.js') + +// True when a `#` sits outside every quoted span - a line comment +// stripSqlLineComments should already have removed. Quote-aware so a `#` +// inside a string literal or a backtick identifier is not mistaken for one. +// Local rather than a method: runMigrations' callers build partial `this` +// objects, and a second prototype hop would break the guard on those. +function hasUnquotedHash(s){ + let q = null; + for(let i = 0; i < s.length; i++){ + const c = s[i]; + if(q){ + if(opensBackslashEscape(s, i, q)){ i++; continue; } + if(c === q){ + if(s[i + 1] === q){ i++; } + else { q = null; } + } + continue; + } + if(c === "'" || c === '"' || c === '`'){ q = c; continue; } + if(c === '#') return true; + } + return false; +} + +// True for the only session SETs an auto file may carry: SET NAMES and a single UTC time_zone, +// the zone the pool is configured for (timezone 'Z'), so neither can relax a strict-mode check. +// Kept in step with the xchain-indexer classifier. +function autoEligibleSet(stmt){ + if(/^SET\s+NAMES\s+['"]?\w+['"]?(?:\s+COLLATE\s+['"]?\w+['"]?)?$/i.test(stmt)) return true; + return /^SET\s+(?:SESSION\s+|LOCAL\s+|@@SESSION\.|@@LOCAL\.|@@)?time_zone\s*=\s*'\+00:00'$/i.test(stmt); +} + +function isSimpleDestructiveStatement(stmt){ + // Server-side indirection escapes a statement-prefix classifier: a mode=auto + // file can smuggle destructive SQL past every keyword check below via dynamic + // SQL (`SET @s = 'DROP TABLE balances'; PREPARE stmt FROM @s; EXECUTE stmt;`) + // or a `CALL proc()` whose body the scanner cannot see. None of these are used + // by any committed auto migration, so treat them as non-auto-eligible. SET of a + // user variable (`SET @s = ...`) exists to stage dynamic SQL for PREPARE, so + // flag it too. + if(/^PREPARE\b/i.test(stmt)) return true; + if(/^EXECUTE\b/i.test(stmt)) return true; + if(/^CALL\b/i.test(stmt)) return true; + if(/^SET\s+@(?!@)/i.test(stmt)) return true; + // Any other SET is an allow-list, not a deny-list: only SET NAMES and a single + // UTC time_zone stay auto-eligible. A SET sql_mode (or foreign_key_checks, ...) turns + // off the strict-mode backstop, so a later narrowing MODIFY in the same file truncates + // silently instead of failing; and SET STATEMENT v=x FOR hides a statement + // behind the SET keyword from every check below. + if(/^SET\b/i.test(stmt) && !autoEligibleSet(stmt)) return true; + // DROP is an allow-list like CREATE: only a standalone index drop and a session temporary-table + // drop stay auto-eligible, since a sequence, trigger, view, event or routine drop loses state or behaviour. + if(/^DROP\b/i.test(stmt) && !/^DROP\s+(?:INDEX|TEMPORARY\s+TABLE)\b/i.test(stmt)) return true; + // CREATE OR REPLACE TABLE is an atomic DROP TABLE IF EXISTS + CREATE: it destroys + // every existing row. Plain CREATE TABLE / CREATE TABLE IF NOT EXISTS are additive + // and stay unflagged (see the CREATE note below); only the OR REPLACE form loses + // data. DROP TABLE is already flagged, so an author must not be able to slip the + // data-losing idempotent-create variant past the auto guard. + if(/^CREATE\s+OR\s+REPLACE\s+(TEMPORARY\s+)?TABLE\b/i.test(stmt)) return true; + if(/^TRUNCATE\b/i.test(stmt)) return true; + if(/^RENAME\s+TABLE\b/i.test(stmt)) return true; + // Any DELETE removes row data - there is no non-destructive form - so match the + // bare keyword, not `DELETE FROM`. The narrower form let valid-but-non-canonical + // syntax slip the auto guard: `DELETE LOW_PRIORITY FROM`, `DELETE IGNORE FROM`, + // and multi-table `DELETE t1 FROM t1 JOIN t2 ...` all delete rows yet omit an + // immediate FROM. No false positive: a statement starting with DELETE is always DML. + if(/^DELETE\b/i.test(stmt)) return true; + // REPLACE INTO is an atomic DELETE+INSERT on every existing-key row it + // touches - the same data-loss profile as DELETE, with no non-destructive + // form - so match the bare keyword like DELETE above. + if(/^REPLACE\b/i.test(stmt)) return true; + // INSERT ... ON DUPLICATE KEY UPDATE overwrites columns of every existing + // duplicate-key row it touches - the same data-rewrite profile the UPDATE arm + // below hard-blocks, reached from a keyword that arm never sees. Plain INSERT + // stays auto-eligible: with no ON DUPLICATE clause it only adds rows. + if(/^INSERT\b[\s\S]*\bON\s+DUPLICATE\s+KEY\s+UPDATE\b/i.test(stmt)) return true; + // LOAD DATA ... REPLACE INTO TABLE is a DELETE+INSERT on every key collision, + // and the rows come from a file the classifier cannot read, so no form of it + // can be judged safe from the statement text. No committed auto migration + // loads a file; treat the whole form as non-auto-eligible. + return /^LOAD\s+DATA\b/i.test(stmt); +} + +// True for any CREATE other than an InnoDB table or an index. A trigger, event, routine or +// view carries SQL this classifier never reads, and the server runs it later (a trigger on +// every row write), the same blind spot as CALL above. +function createOutsideAllowList(stmt){ + if(!/^CREATE\b/i.test(stmt)) return false; + // Allow the additive forms committed auto migrations use: [TEMPORARY] TABLE and INDEX. + if(/^CREATE\s+(?:TEMPORARY\s+)?TABLE\b/i.test(stmt)) return createNamesNonInnoDb(stmt); + return !/^CREATE\s+(?:(?:UNIQUE|FULLTEXT|SPATIAL)\s+)?INDEX\b/i.test(stmt); +} + +// The closing allow-list: true unless the statement opens with a form an auto file may use. +// Any other form (BINLOG, DO, SELECT, PURGE, RESET, INSTALL, ...) can make the server do +// something this prefix classifier cannot see, the same blind spot as CALL above. +function statementOutsideAllowList(stmt){ + return !/^(?:ALTER\s+(?:ONLINE\s+)?TABLE|CREATE\s+(?:TEMPORARY\s+)?TABLE|CREATE\s+(?:(?:UNIQUE|FULLTEXT|SPATIAL)\s+)?INDEX|DROP\s+(?:INDEX|TEMPORARY\s+TABLE)|INSERT|UPDATE|SET)\b/i.test(stmt); +} + +function isDestructiveAlter(stmt, safeAlterDrop){ + // Partition and tablespace clauses move or discard row data while carrying + // none of the keywords the checks below look for: TRUNCATE PARTITION empties + // a partition, EXCHANGE PARTITION swaps its rows out to another table, + // DISCARD TABLESPACE deletes the table's data file. The additive members of + // the class (ADD PARTITION, IMPORT TABLESPACE) are not separable from the + // destructive ones by prefix, and no committed migration partitions anything, + // so the whole class is non-auto-eligible - re-tag mode=manual to run one. + if(/\bPARTITION(?:ING)?\b/i.test(stmt)) return true; + if(/\bTABLESPACE\b/i.test(stmt)) return true; + // Any ENGINE option rebuilds the whole table by copy, and a non-InnoDB engine loses rows on + // restart (MEMORY) or escapes the reorg rollback (MyISAM, Aria), so it runs manual only. + if(/\bENGINE\b/i.test(stmt)) return true; + // Every DROP inside the ALTER must target a safe (metadata-only) object. + if(dropTargetDestroys(stmt, safeAlterDrop)) return true; + // RENAME TO / RENAME COLUMN / bare RENAME lose the old name; only + // RENAME INDEX/KEY is a metadata-only rename. + if(/\bRENAME\b(?!\s+(INDEX|KEY)\b)/i.test(stmt)) return true; + // CHANGE [COLUMN] renames and retypes in one clause - manual only. + if(/\bCHANGE\b/i.test(stmt)) return true; + // CONVERT TO CHARACTER SET rewrites every text column of the table and can truncate or + // re-encode row data. + if(/\bCONVERT\s+TO\b/i.test(stmt)) return true; + // MODIFY that adds NOT NULL narrows the column domain, except an AUTO_INCREMENT repair + // (definitionally NOT NULL; 2026-06-10-mirror-id-autoincrement-repair.sql). Checked per + // top-level clause, so one AUTO_INCREMENT clause cannot exempt a sibling NOT NULL clause. + let mDepth = 0, mStart = 0; + const mClauses = []; + for(let i=0;i String(name).trim().replace(/^`|`$/g, ''); +const tableName = (qualified, identifiers) => { + const name = unquote(String(qualified).split('.').pop()); + return String(identifiers.get(name) || name).toLowerCase(); +}; + +// Tokenize backtick identifiers first, then blank string literals, so a keyword +// inside a value can never read as a migration operation. +function syntaxText(statement){ + const identifiers = new Map(); + let source = stripBlockComments(String(statement)); + source = source.replace(/`([^`]*)`/g, (_match, name) => { + let token = '$keyWitnessQuoted' + identifiers.size + 'x'; + while(source.includes(token)) token = '$' + token; + identifiers.set(token, name); + return token; + }); + const text = blankLiterals(source).replace(/'(?:[^'\\]|\\.|'')*'|"(?:[^"\\]|\\.|"")*"/g, + value => ' '.repeat(value.length)); + return { text, identifiers }; +} + +// The table a key-dependent write targets, or null for any other statement +function keyDependentTarget(statement){ + const { text, identifiers } = syntaxText(statement); + const insert = INSERT_HEAD.exec(text); + if(insert){ + const modifiers = insert[1] || ''; + if(/\bIGNORE\b/i.test(modifiers) || /\bON\s+DUPLICATE\s+KEY\s+UPDATE\b/i.test(text)) + return tableName(insert[2], identifiers); + } + const replace = REPLACE_HEAD.exec(text); + return replace ? tableName(replace[1], identifiers) : null; +} + +// The table a CREATE TABLE statement makes, or null +function createdTarget(statement){ + const { text, identifiers } = syntaxText(statement); + const found = CREATE_HEAD.exec(text); + return found ? tableName(found[1], identifiers) : null; +} + +// Every distinct table a file's key-dependent writes target, in first-seen order +function keyDependentTargets(statements){ + const out = []; + const seen = new Set(); + for(const statement of statements || []){ + const target = keyDependentTarget(statement); + const key = target && target.toLowerCase(); + if(target && !seen.has(key)){ + seen.add(key); + out.push(target); + } + } + return out; +} + +// The targets that need a live witness: a table the same file created earlier carries its own keys +function targetsNeedingWitness(statements){ + const created = new Set(); + const targets = []; + const seen = new Set(); + for(const statement of statements || []){ + const made = createdTarget(statement); + if(made) created.add(made.toLowerCase()); + const target = keyDependentTarget(statement); + const key = target && target.toLowerCase(); + if(target && !created.has(key) && !seen.has(key)){ + seen.add(key); + targets.push(target); + } + } + return targets; +} + +// The ordered column lists of every live UNIQUE index, as "a,b" strings +function liveUniqueColumnSets(rows){ + const indexes = new Map(); + for(const row of Array.isArray(rows) ? rows : []){ + const name = row.INDEX_NAME == null ? row.index_name : row.INDEX_NAME; + const column = row.COLUMN_NAME == null ? row.column_name : row.COLUMN_NAME; + const nonUnique = row.NON_UNIQUE == null ? row.non_unique : row.NON_UNIQUE; + const seq = row.SEQ_IN_INDEX == null ? row.seq_in_index : row.SEQ_IN_INDEX; + if(name == null || column == null || Number(nonUnique) !== 0) continue; + if(!indexes.has(name)) indexes.set(name, []); + indexes.get(name).push({ seq: Number(seq), column: String(column).toLowerCase() }); + } + return new Set([...indexes.values()].map(columns => columns + .sort((a, b) => a.seq - b.seq).map(part => part.column).join(','))); +} + +// Whether the table exists live; an unreadable answer counts as present, so the key is still checked +async function liveTableExists(conn, dbName, table){ + const rows = await conn.query( + 'SELECT EXISTS(SELECT 1 FROM information_schema.TABLES ' + + 'WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?) AS TABLE_EXISTS', + [dbName, table] + ); + if(!Array.isArray(rows) || !rows.length) return true; + const value = rows[0].TABLE_EXISTS == null ? rows[0].table_exists : rows[0].TABLE_EXISTS; + return value == null || Number(value) !== 0; +} + +// Refuse a key-dependent write whose target lacks a live copy of each UNIQUE index src/sql declares +async function assertUniqueKeyWitness(conn, dbName, file, statements, declaredUniqueIndexes){ + for(const table of targetsNeedingWitness(statements)){ + const declared = (await declaredUniqueIndexes(table) || []).filter(index => index && index.unique); + if(!declared.length) continue; + if(!await liveTableExists(conn, dbName, table)) continue; + const rows = await conn.query( + 'SELECT INDEX_NAME, NON_UNIQUE, COLUMN_NAME, SEQ_IN_INDEX FROM information_schema.STATISTICS ' + + 'WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX', + [dbName, table] + ); + const live = liveUniqueColumnSets(rows); + for(const index of declared){ + const columns = (index.columns || []).map(column => String(column).toLowerCase()); + if(columns.length && live.has(columns.join(','))) continue; + throw new Error('runMigrations: ' + file + ' uses a key-dependent write on ' + table + + ', but declared UNIQUE index ' + index.name + ' (' + columns.join(', ') + ') is not live. ' + + 'The index reconciler runs before migrations at boot and could not add it (duplicate rows, or ' + + 'another index already holds that name); add the key by hand, or in an earlier dated migration.'); + } + } +} + +// The UNIQUE indexes a table's src/sql file declares, or none when the table has no source file +function declaredUniqueIndexes(database, table){ + // Only a plain identifier can name a src/sql file + if(!/^[\w$]+$/.test(table)) return []; + let data; + try { data = fs.readFileSync(database.sqlPath + '/' + table + '.sql', 'utf8'); } + catch(err){ + if(err && err.code === 'ENOENT') return []; + throw err; + } + return database.parseExpectedIndexes(data, table).filter(index => index.unique); +} + +module.exports = { keyDependentTargets, assertUniqueKeyWitness, declaredUniqueIndexes }; diff --git a/src/db/migration_preconditions.js b/src/db/migration_preconditions.js index dcbd04a8..be3c5627 100644 --- a/src/db/migration_preconditions.js +++ b/src/db/migration_preconditions.js @@ -213,7 +213,8 @@ Database.MIGRATION_PRECONDITIONS = { }, }; -// Backdating guard for the auto-apply path, mirroring xchain-indexer/src/db/index.js. Apply +// Backdating guard for the auto-apply path, mirroring +// xchain-indexer/src/db/database/migration_registry.js:backdatedFrontierViolation. Apply // order is lexical, so a migration added with a date EARLIER than one already applied // runs in a different position on a fresh database (in its date slot) than on an aged // one (after the frontier), and the two schemas diverge across the fleet. Given a @@ -279,8 +280,9 @@ Database.DEPLOY_PRECONDITION_TAG = 'deploy-precondition=required'; // // ADDING A STARTUP ASSERTION: register it here and tag its migration file, or the // next fleet deploy discovers the requirement as a crash-loop again. Give the file a -// MIGRATION_PRECONDITIONS baseline too, or a database built from the current src/sql -// never records it and the deploy tool refuses every update. A server-setting +// MIGRATION_PRECONDITIONS baseline too: verifyTables records every committed file only on +// a database it builds from empty, so one built from src/sql by an earlier release never +// records it and the deploy tool refuses every update. A server-setting // assertion with no migration behind it (assertStrictSqlMode) is not registered: the // pool pins what it checks, so it holds by construction and cannot crash-loop a deploy. Database.STARTUP_ASSERTED_MIGRATIONS = [ diff --git a/src/db/migration_statements.js b/src/db/migration_statements.js index 83eb01a8..176a3e34 100644 --- a/src/db/migration_statements.js +++ b/src/db/migration_statements.js @@ -12,150 +12,13 @@ * **********************************************************************/ -const { opensBackslashEscape, createNamesNonInnoDb, dropTargetDestroys } = require('./query_helpers.js') +const { opensBackslashEscape } = require('./query_helpers.js') const { ensureLedgerAppliedAtDatetime } = require('./migration_ledger.js') -const { stripBlockComments, alterSpecs, blankLiterals } = require('./migration_live_schema_guard.js') - -// True when a `#` sits outside every quoted span - a line comment -// stripSqlLineComments should already have removed. Quote-aware so a `#` -// inside a string literal or a backtick identifier is not mistaken for one. -// Local rather than a method: runMigrations' callers build partial `this` -// objects, and a second prototype hop would break the guard on those. -function hasUnquotedHash(s){ - let q = null; - for(let i = 0; i < s.length; i++){ - const c = s[i]; - if(q){ - if(opensBackslashEscape(s, i, q)){ i++; continue; } - if(c === q){ - if(s[i + 1] === q){ i++; } - else { q = null; } - } - continue; - } - if(c === "'" || c === '"' || c === '`'){ q = c; continue; } - if(c === '#') return true; - } - return false; -} - -// True for the only session SETs an auto file may carry: SET NAMES and a single UTC time_zone, -// the zone the pool is configured for (timezone 'Z'), so neither can relax a strict-mode check. -// Kept in step with the xchain-indexer classifier. -function autoEligibleSet(stmt){ - if(/^SET\s+NAMES\s+['"]?\w+['"]?(?:\s+COLLATE\s+['"]?\w+['"]?)?$/i.test(stmt)) return true; - return /^SET\s+(?:SESSION\s+|LOCAL\s+|@@SESSION\.|@@LOCAL\.|@@)?time_zone\s*=\s*'\+00:00'$/i.test(stmt); -} - -function isSimpleDestructiveStatement(stmt){ - // Server-side indirection escapes a statement-prefix classifier: a mode=auto - // file can smuggle destructive SQL past every keyword check below via dynamic - // SQL (`SET @s = 'DROP TABLE balances'; PREPARE stmt FROM @s; EXECUTE stmt;`) - // or a `CALL proc()` whose body the scanner cannot see. None of these are used - // by any committed auto migration, so treat them as non-auto-eligible. SET of a - // user variable (`SET @s = ...`) exists to stage dynamic SQL for PREPARE, so - // flag it too. - if(/^PREPARE\b/i.test(stmt)) return true; - if(/^EXECUTE\b/i.test(stmt)) return true; - if(/^CALL\b/i.test(stmt)) return true; - if(/^SET\s+@(?!@)/i.test(stmt)) return true; - // Any other SET is an allow-list, not a deny-list: only SET NAMES and a single - // UTC time_zone stay auto-eligible. A SET sql_mode (or foreign_key_checks, ...) turns - // off the strict-mode backstop, so a later narrowing MODIFY in the same file truncates - // silently instead of failing; and SET STATEMENT v=x FOR hides a statement - // behind the SET keyword from every check below. - if(/^SET\b/i.test(stmt) && !autoEligibleSet(stmt)) return true; - if(/^DROP\s+(TABLE|DATABASE|SCHEMA)\b/i.test(stmt)) return true; - // CREATE OR REPLACE TABLE is an atomic DROP TABLE IF EXISTS + CREATE: it destroys - // every existing row. Plain CREATE TABLE / CREATE TABLE IF NOT EXISTS are additive - // and stay unflagged (see the CREATE note below); only the OR REPLACE form loses - // data. DROP TABLE is already flagged, so an author must not be able to slip the - // data-losing idempotent-create variant past the auto guard. - if(/^CREATE\s+OR\s+REPLACE\s+(TEMPORARY\s+)?TABLE\b/i.test(stmt)) return true; - if(/^TRUNCATE\b/i.test(stmt)) return true; - if(/^RENAME\s+TABLE\b/i.test(stmt)) return true; - // Any DELETE removes row data - there is no non-destructive form - so match the - // bare keyword, not `DELETE FROM`. The narrower form let valid-but-non-canonical - // syntax slip the auto guard: `DELETE LOW_PRIORITY FROM`, `DELETE IGNORE FROM`, - // and multi-table `DELETE t1 FROM t1 JOIN t2 ...` all delete rows yet omit an - // immediate FROM. No false positive: a statement starting with DELETE is always DML. - if(/^DELETE\b/i.test(stmt)) return true; - // REPLACE INTO is an atomic DELETE+INSERT on every existing-key row it - // touches - the same data-loss profile as DELETE, with no non-destructive - // form - so match the bare keyword like DELETE above. - if(/^REPLACE\b/i.test(stmt)) return true; - // INSERT ... ON DUPLICATE KEY UPDATE overwrites columns of every existing - // duplicate-key row it touches - the same data-rewrite profile the UPDATE arm - // below hard-blocks, reached from a keyword that arm never sees. Plain INSERT - // stays auto-eligible: with no ON DUPLICATE clause it only adds rows. - if(/^INSERT\b[\s\S]*\bON\s+DUPLICATE\s+KEY\s+UPDATE\b/i.test(stmt)) return true; - // LOAD DATA ... REPLACE INTO TABLE is a DELETE+INSERT on every key collision, - // and the rows come from a file the classifier cannot read, so no form of it - // can be judged safe from the statement text. No committed auto migration - // loads a file; treat the whole form as non-auto-eligible. - return /^LOAD\s+DATA\b/i.test(stmt); -} - -// True for any CREATE other than an InnoDB table or an index. A trigger, event, routine or -// view carries SQL this classifier never reads, and the server runs it later (a trigger on -// every row write), the same blind spot as CALL above. -function createOutsideAllowList(stmt){ - if(!/^CREATE\b/i.test(stmt)) return false; - // Allow the additive forms committed auto migrations use: [TEMPORARY] TABLE and INDEX. - if(/^CREATE\s+(?:TEMPORARY\s+)?TABLE\b/i.test(stmt)) return createNamesNonInnoDb(stmt); - return !/^CREATE\s+(?:(?:UNIQUE|FULLTEXT|SPATIAL)\s+)?INDEX\b/i.test(stmt); -} - -function isDestructiveAlter(stmt, safeAlterDrop){ - // Partition and tablespace clauses move or discard row data while carrying - // none of the keywords the checks below look for: TRUNCATE PARTITION empties - // a partition, EXCHANGE PARTITION swaps its rows out to another table, - // DISCARD TABLESPACE deletes the table's data file. The additive members of - // the class (ADD PARTITION, IMPORT TABLESPACE) are not separable from the - // destructive ones by prefix, and no committed migration partitions anything, - // so the whole class is non-auto-eligible - re-tag mode=manual to run one. - if(/\bPARTITION(?:ING)?\b/i.test(stmt)) return true; - if(/\bTABLESPACE\b/i.test(stmt)) return true; - // Any ENGINE option rebuilds the whole table by copy, and a non-InnoDB engine loses rows on - // restart (MEMORY) or escapes the reorg rollback (MyISAM, Aria), so it runs manual only. - if(/\bENGINE\b/i.test(stmt)) return true; - // Every DROP inside the ALTER must target a safe (metadata-only) object. - if(dropTargetDestroys(stmt, safeAlterDrop)) return true; - // RENAME TO / RENAME COLUMN / bare RENAME lose the old name; only - // RENAME INDEX/KEY is a metadata-only rename. - if(/\bRENAME\b(?!\s+(INDEX|KEY)\b)/i.test(stmt)) return true; - // CHANGE [COLUMN] renames and retypes in one clause - manual only. - if(/\bCHANGE\b/i.test(stmt)) return true; - // CONVERT TO CHARACTER SET rewrites every text column of the table and can truncate or - // re-encode row data. - if(/\bCONVERT\s+TO\b/i.test(stmt)) return true; - // MODIFY that adds NOT NULL narrows the column domain, except an AUTO_INCREMENT repair - // (definitionally NOT NULL; 2026-06-10-mirror-id-autoincrement-repair.sql). Checked per - // top-level clause, so one AUTO_INCREMENT clause cannot exempt a sibling NOT NULL clause. - let mDepth = 0, mStart = 0; - const mClauses = []; - for(let i=0;i 160 ? '...' : '') + '". ' + 'Re-tag the file `-- xchain:migration mode=manual` and apply it deliberately via `node src/db/migrate.js`.'); } + // Refuse a drop of a key src/sql still declares UNIQUE or PRIMARY, which verifyTables + // already ran past this boot and so would leave missing until the next one. + assertNoDeclaredKeyDrop(file, statements, table => { + if(!/^[\w$]+$/.test(table)) return null; + try { return fs.readFileSync(database.sqlPath + '/' + table + '.sql', 'utf8'); } + catch(err){ + if(err && err.code === 'ENOENT') return null; + throw err; + } + }); // The text scan cannot see what a MODIFY drops from the live column; this check can. await assertNoLiveColumnLoss(context.conn, file, statements); + // INSERT IGNORE dedupes nothing while a declared UNIQUE key is missing live, so require the key first. + await assertUniqueKeyWitness(context.conn, database.dbName, file, statements, + table => declaredUniqueIndexes(database, table)); } // Lift the pool's 30s statement limit so a table rebuild is not killed half-applied. await context.activateQueryTimeout(); diff --git a/src/db/reorg_halt.js b/src/db/reorg_halt.js index 20955e75..268f03fd 100644 --- a/src/db/reorg_halt.js +++ b/src/db/reorg_halt.js @@ -187,15 +187,17 @@ module.exports = { } }, - // Read the durable halt marker WITH its detail. isReorgHalted() above - // answers the one question verifyReorg asks (may I roll back?) and deliberately - // stays a bare existence probe on the hot reorg path. Operator-facing surfaces - // (health, GET /status, the bootstrap publisher's source gate) need to say WHEN - // the decoder halted and WHY, because a latent marker is otherwise invisible - // until a reorg trips it days later. Returns { halted, at, reason }; `at`/`reason` - // are null when the row exists but its payload is unreadable (an older marker, or - // JSON written by a different revision), which must never turn a real halt into a - // reported non-halt. + // Read the durable halt marker WITH its detail. isReorgHalted() above is the + // boolean view of the same readReorgHaltState() read: the newest REORG_HALT / + // REORG_HALT_CLEARED row by id decides, so a bare `code = 'REORG_HALT'` existence + // check would report a cleared halt as live. Operator-facing surfaces (health, + // GET /status, the bootstrap publisher's source gate) need to say WHEN the decoder + // halted and WHY, because a latent marker is otherwise invisible until a reorg + // trips it days later. Returns readReorgHaltState() without its unaudited_clear + // flag: { halted, id, at, reason, cleared_at, cleared_reason }. A halt row with an + // unreadable payload still reads as halted, with `reason` null and `at` falling + // back to the row time; an unaudited clear reads as halted with the + // unaudited-clear reason. // // `id` is the events row id of the live halt (null when not halted, or when the // id could not be read). It is the identity clear-reorg-halt pins its @@ -231,8 +233,8 @@ module.exports = { // silently resumed over-deep rollback, and every consumer of it (the entry // guard, the health surfaces, the bootstrap gate) reads the ROW, not the ack. async markReorgHalted(reason){ - // An unaudited clear row reads as halted but is no REORG_HALT row, and the - // bootstrap gate counts only those rows, so that halt still gets one written. + // An unaudited clear row reads as halted but is no REORG_HALT row, and older + // bootstrap gates count only those rows, so that halt still gets one written. const live = await this.readReorgHaltState() if (live.halted && !live.unaudited_clear) return true const written = await this.insertEvent('REORG_HALT', { reason: reason, at: new Date().toISOString() }) diff --git a/src/db/table_drift.js b/src/db/table_drift.js index e67ffa5c..e14e639d 100644 --- a/src/db/table_drift.js +++ b/src/db/table_drift.js @@ -78,7 +78,8 @@ module.exports = { // NOT NULL, so a MODIFY ... NULL on one is a silent no-op (PK) or, worse, // silently STRIPS the AUTO_INCREMENT attribute - the mirror-cursor // corruption the indexer hit live on 2026-06-10. Mirrors - // xchain-indexer/src/db/index.js so both reconcilers infer NOT NULL identically. + // xchain-indexer/src/db/database/schema_drift.js:parseExpectedColumns so both + // reconcilers infer NOT NULL identically. const nullable = !/\bNOT\s+NULL\b/i.test(line) && !/\bPRIMARY\s+KEY\b/i.test(line) && !/\bAUTO_INCREMENT\b/i.test(line); const notNull = !nullable; const hasDefault = /\bDEFAULT\b/i.test(line); diff --git a/src/db/transactions.js b/src/db/transactions.js index 07a0a778..cc3f5922 100644 --- a/src/db/transactions.js +++ b/src/db/transactions.js @@ -291,8 +291,10 @@ module.exports = { await seedMempoolSnapshot(connection, TMP, txidList, INSERT_CHUNK) return await reconcileMempoolSnapshot(connection, TMP, txidList) } catch (err) { + // Rethrow so updateMempool skips the cycle (a resolved result would leave the + // whole node mempool in txidList and re-fetch every txid from the node). logger.error(formatLogLine('Error diffing mempool_transactions:', err)); - return { transactionsDeleted: 0 } + throw err } finally { await dropMempoolSnapshot(connection, TMP) if (ownLease) { diff --git a/src/metrics/decoder_metrics.js b/src/metrics/decoder_metrics.js index b30ae4b6..17fff6e8 100644 --- a/src/metrics/decoder_metrics.js +++ b/src/metrics/decoder_metrics.js @@ -40,7 +40,9 @@ const DECODER_GAUGES = [ ['stalled', '1 when the block loop is wedged on one height (/live gates on poll_silent too)'], ['poll_silent', '1 when the block loop has stopped ITERATING; gates /live health beside stalled'], ['last_poll_timestamp_seconds', 'Unix time of the last block-loop iteration, whether or not a block arrived'], - ['last_reorg_depth', 'Blocks rolled back by the most recent reorg'] + ['last_reorg_depth', 'Blocks rolled back by the most recent reorg'], + ['reorg_halted', '1 when a REORG_HALT marker is held (latent or parked); absent until the first marker probe'], + ['reorg_halt_parked', '1 when the parse loop has stopped on a REORG_HALT; node_height_stale also reads 1 while parked'] ]; const DECODER_COUNTERS = [ @@ -99,6 +101,14 @@ function collectDecoderMetrics(decoder, gauges, counters) { // deployed; these read the decoder's own lifetime counters at scrape time. setIf(gauges.last_reorg_depth, decoder.lastReorgDepth); counters.reorgs_total.setMonotonic({}, decoder.reorgCount || 0); + + // Export the REORG_HALT state /live and /ready already carry: a parked loop skips + // the tip refresh, so without these it reads as a stale node tip. The halt flag stays + // absent until a probe has answered, since "never looked" is not "not halted". + if (decoder.reorgHaltCheckedAt > 0 || decoder.reorgHalted === true) { + gauges.reorg_halted.set({}, decoder.reorgHalted === true ? 1 : 0); + } + gauges.reorg_halt_parked.set({}, decoder.reorgHaltParked === true ? 1 : 0); } // Only finite numbers reach the registry: Gauge#set throws on NaN/undefined, diff --git a/src/protocol/constants/activation.js b/src/protocol/constants/activation.js index e35faebd..4984e611 100644 --- a/src/protocol/constants/activation.js +++ b/src/protocol/constants/activation.js @@ -281,13 +281,17 @@ const DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION = { // action the indexer did not accept. // // This recognition change is consensus-affecting because it can remove an address from -// the decoder's payment-output capture set. Every network therefore remains UNARMED -// until an operator audit establishes a safe flag-day and the matching indexer gate is -// armed. null means never active; unknown networks and invalid block times fail closed. +// the decoder's payment-output capture set, so it must open no earlier than the indexer's +// EXACT_INTEGER_WIRE_FIELDS on the same network. Mainnet stays null (never active) until +// an operator audit sets a mainnet flag day. Testnet takes the cut's flag day under the +// operator ruling of 2026-10-17 that every testnet gate arms; 9999999999 holds it dark +// until the cut writes that instant, and the indexer's testnet gate is already armed. +// Regtest is genesis-active, as is the indexer's regtest row, so every regtest matrix +// runs the exact path. Unknown networks and invalid block times fail closed. const EXACT_INTEGER_EXPIRATION_ACTIVATION = { mainnet: null, - testnet: null, - regtest: null, + testnet: 1792605600, + regtest: 0, }; // Enables output capture and open-dispenser registration from BATCH subcommands. @@ -322,6 +326,15 @@ const ENVELOPE_CARRIER_RECOGNITION_ACTIVATION = { DOGE: { mainnet: null, testnet: null, regtest: null }, }; +// Stops walking back through OP_0 <32-byte> outputs on chains without segwit. +// DOGE mainnet and testnet stay unarmed until their histories have been scanned; +// regtest starts at genesis. +const P2WSH_WALKBACK_SEGWIT_GATE_ACTIVATION = { + BTC: { mainnet: 0, testnet: 0, regtest: 0 }, + LTC: { mainnet: 0, testnet: 0, regtest: 0 }, + DOGE: { mainnet: null, testnet: null, regtest: 0 }, +}; + module.exports = { STAKE_WEIGHTED_QUORUM_ACTIVATION, EQUIV_HEADER_ACTIVATION, @@ -343,4 +356,5 @@ module.exports = { BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION, ENVELOPE_RECOGNITION_ACTIVATION, ENVELOPE_CARRIER_RECOGNITION_ACTIVATION, + P2WSH_WALKBACK_SEGWIT_GATE_ACTIVATION, }; diff --git a/src/protocol/constants/dispenser_wide_expiration.js b/src/protocol/constants/dispenser_wide_expiration.js index 25b64713..774d8bf9 100644 --- a/src/protocol/constants/dispenser_wide_expiration.js +++ b/src/protocol/constants/dispenser_wide_expiration.js @@ -20,7 +20,7 @@ // genesis-active so the widened recognition path is continuously exercised. const DISPENSER_WIDE_EXPIRATION_GATES = { mainnet: 9999999999, - testnet: 9999999999, + testnet: 1792605600, regtest: 0, }; diff --git a/test/benchmarks/support/mock_database.js b/test/benchmarks/support/mock_database.js index 62fe2ab6..01e41c79 100644 --- a/test/benchmarks/support/mock_database.js +++ b/test/benchmarks/support/mock_database.js @@ -196,8 +196,6 @@ class MockDatabase { return null } - async dropDatabase() {} - async getConnection() { return { query: async () => [], diff --git a/test/chaos/support/helpers.js b/test/chaos/support/helpers.js index b47b6c59..4c374877 100644 --- a/test/chaos/support/helpers.js +++ b/test/chaos/support/helpers.js @@ -62,7 +62,6 @@ function createMockDatabase(overrides = {}) { isThereADispenserForAddress: sinon.stub().resolves(false), getAllOpenDispenserAddresses: sinon.stub().resolves(new Set()), deleteBlockByIndex: sinon.stub().resolves(true), - dropDatabase: sinon.stub().resolves(), getConnection: sinon.stub().resolves({ query: sinon.stub().resolves([]), release: sinon.stub().resolves(), diff --git a/test/e2e/support/setup.js b/test/e2e/support/setup.js index b9bd6ae6..c4301c35 100644 --- a/test/e2e/support/setup.js +++ b/test/e2e/support/setup.js @@ -46,6 +46,7 @@ const BitcoinCore = require('bitcoin-core') const nodeHelper = require('../../helpers/node_helper') const XChainDecoder = require('../../../src/XChainDecoder') const Database = require('../../../src/db.js') +const { dropAllTables } = require('../../support/drop_tables') const fixturePorts = require('../../../bin/fixture-ports.js') // Fixture venue. Must match fixtures/docker-compose.test.yml. @@ -135,7 +136,7 @@ exports.mochaHooks = { // Prepare DB global.db = new Database(DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD) await global.db.createDatabase() - await global.db.dropDatabase() + await dropAllTables(global.db) await global.db.verifyTables() console.log('[e2e] Funding test wallet') diff --git a/test/integration/fresh_schema_ledger_seed_real.test.js b/test/integration/fresh_schema_ledger_seed_real.test.js new file mode 100644 index 00000000..e9408473 --- /dev/null +++ b/test/integration/fresh_schema_ledger_seed_real.test.js @@ -0,0 +1,136 @@ +/********************************************************************* + * + * 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 crypto = require('crypto') +const fs = require('fs') +const path = require('path') +const mariadb = require('mariadb') +const Database = require('../../src/db.js') +const { dbTarget } = require('./helpers/datetime_readback.js') + +const MIGRATIONS_DIR = path.join(__dirname, '../../src/sql/migrations') +// A prefix keeps a shared venue's databases apart; CI's own fixture needs none. +const PREFIX = process.env.XCHAIN_TEST_DB_NAME_PREFIX || '' +const FRESH_DB = PREFIX + 'xchain_decoder_ledger_seed_fresh' +const AGED_DB = PREFIX + 'xchain_decoder_ledger_seed_aged' +const target = dbTarget() + +function committedMigrations() { + return fs.readdirSync(MIGRATIONS_DIR).filter(f => f.endsWith('.sql')).sort().map(name => { + const raw = fs.readFileSync(path.join(MIGRATIONS_DIR, name), 'utf8') + return { name, checksum: crypto.createHash('sha256').update(raw).digest('hex'), mode: Database.prototype.migrationMode(raw) } + }) +} + +async function withRaw(action) { + const conn = await mariadb.createConnection({ + host: target.host, port: target.port, user: target.user, password: target.password + }) + try { + return await action(conn) + } finally { + await conn.end() + } +} + +async function ledger(name) { + return withRaw(async conn => { + const present = await conn.query( + "SELECT COUNT(*) AS n FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'schema_migrations'", [name]) + if (Number(present[0].n) === 0) return null + const rows = await conn.query('SELECT name, checksum, mode, applied_at FROM `' + name + '`.schema_migrations ORDER BY name') + return rows.map(r => ({ name: r.name, checksum: r.checksum, mode: r.mode, appliedAt: String(r.applied_at) })) + }) +} + +// The live schema minus the ledger. Foreign keys compare by what they bind, not by name: +// MariaDB 12 names an inline key `1` where 10.11 names it pubkeys_ibfk_1. +async function schemaShape(name) { + return withRaw(async conn => { + const tables = await conn.query( + "SELECT TABLE_NAME AS t, ENGINE AS e, TABLE_COLLATION AS co FROM information_schema.TABLES " + + "WHERE TABLE_SCHEMA = ? AND TABLE_NAME <> 'schema_migrations' ORDER BY 1", [name]) + const columns = await conn.query( + 'SELECT TABLE_NAME AS t, COLUMN_NAME AS c, ORDINAL_POSITION AS o, COLUMN_TYPE AS ty, IS_NULLABLE AS n, ' + + 'COLUMN_DEFAULT AS d, EXTRA AS x, CHARACTER_SET_NAME AS cs, COLLATION_NAME AS co FROM information_schema.COLUMNS ' + + "WHERE TABLE_SCHEMA = ? AND TABLE_NAME <> 'schema_migrations' ORDER BY 1, 3", [name]) + const indexes = await conn.query( + 'SELECT TABLE_NAME AS t, INDEX_NAME AS i, NON_UNIQUE AS u, SEQ_IN_INDEX AS s, COLUMN_NAME AS c, SUB_PART AS p ' + + "FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = ? AND TABLE_NAME <> 'schema_migrations' ORDER BY 1, 2, 4", [name]) + const keys = await conn.query( + 'SELECT DISTINCT TABLE_NAME AS t, COLUMN_NAME AS c, REFERENCED_TABLE_NAME AS r, REFERENCED_COLUMN_NAME AS rc ' + + 'FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = ? AND REFERENCED_TABLE_NAME IS NOT NULL ORDER BY 1, 2', [name]) + return JSON.parse(JSON.stringify({ tables, columns, indexes, keys }, (k, v) => (typeof v === 'bigint' ? String(v) : v))) + }) +} + +async function boot(db) { + await db.createDatabase() + assert.strictEqual(await db.verifyTables(), true) + return db.runMigrations() +} + +describe('fresh decoder database records its migrations before creating tables', function () { + this.timeout(180000) + let fresh + let aged + + before(async function () { + await withRaw(async conn => { + await conn.query('DROP DATABASE IF EXISTS `' + FRESH_DB + '`') + await conn.query('DROP DATABASE IF EXISTS `' + AGED_DB + '`') + }) + fresh = new Database(target.host, target.port, FRESH_DB, target.user, target.password) + aged = new Database(target.host, target.port, AGED_DB, target.user, target.password) + }) + + after(async function () { + for (const db of [fresh, aged]) if (db) await db.close() + await withRaw(async conn => { + await conn.query('DROP DATABASE IF EXISTS `' + FRESH_DB + '`') + await conn.query('DROP DATABASE IF EXISTS `' + AGED_DB + '`') + }) + }) + + it('records every committed migration with the runner checksum and mode, and replays none', async function () { + const result = await boot(fresh) + const rows = await ledger(FRESH_DB) + assert.deepStrictEqual(rows.map(({ name, checksum, mode }) => ({ name, checksum, mode })), committedMigrations()) + assert.deepStrictEqual(result.applied, []) + assert.deepStrictEqual(result.pending, []) + assert.deepStrictEqual(result.baselined, []) + }) + + it('leaves the ledger exactly as it was across a second boot', async function () { + const before = await ledger(FRESH_DB) + const result = await boot(fresh) + assert.deepStrictEqual(await ledger(FRESH_DB), before) + assert.deepStrictEqual([result.applied, result.pending, result.baselined], [[], [], []]) + }) + + it('seeds nothing on a database that already holds a declared table', async function () { + await aged.createDatabase() + await aged.createTable('events.sql') + assert.strictEqual(await aged.verifyTables(), true) + assert.strictEqual(await ledger(AGED_DB), null) + }) + + // What makes the seed true: every committed migration, run for real on the unseeded + // database, leaves the schema a fresh build already has. + it('matches the schema a full migration run produces on an unseeded build', async function () { + await aged.runMigrations({ includeManual: true }) + assert.deepStrictEqual(await schemaShape(AGED_DB), await schemaShape(FRESH_DB)) + }) +}) diff --git a/test/integration/mempool_first_seen_datetime_real.test.js b/test/integration/mempool_first_seen_datetime_real.test.js index 9360e1ee..f33ddf30 100644 --- a/test/integration/mempool_first_seen_datetime_real.test.js +++ b/test/integration/mempool_first_seen_datetime_real.test.js @@ -25,9 +25,15 @@ const { const MIGRATION = '2026-09-27-mempool-first-seen-datetime.sql' const OBSERVED_AT = '2026-01-15 12:34:56' -const AGED_DB = 'xchain_decoder_first_seen_datetime_aged' -const FRESH_DB = 'xchain_decoder_first_seen_datetime_fresh' +// A prefix keeps a shared venue's databases apart; CI's own fixture needs none. +const PREFIX = process.env.XCHAIN_TEST_DB_NAME_PREFIX || '' +const AGED_DB = PREFIX + 'xchain_decoder_first_seen_datetime_aged' +const FRESH_DB = PREFIX + 'xchain_decoder_first_seen_datetime_fresh' +const SEEDED_DB = PREFIX + 'xchain_decoder_first_seen_datetime_seeded' const target = dbTarget() +// Both stored times read back six hours early in a -06:00 session, and as observed once retyped +const SHIFTED_SIX_HOURS = { firstSeen: '2026-01-15 06:34:56', appliedAt: '2026-01-15 06:34:56' } +const AS_OBSERVED = { firstSeen: OBSERVED_AT, appliedAt: OBSERVED_AT } function makeDatabase(name) { return new Database(target.host, target.port, name, target.user, target.password) @@ -81,9 +87,15 @@ async function readStoredTimes(name, zone) { })) } +// Build the tables the way a release without the fresh-install ledger seed did: no ledger rows. +async function buildWithoutSeed(db) { + db.recordFreshSchemaMigrations = async () => {} + await db.verifyTables() +} + async function prepareAgedDatabase(db) { await db.createDatabase() - await db.verifyTables() + await buildWithoutSeed(db) await withPoolConnection(db, async conn => { await conn.query(agedModifySql('mempool_transactions', 'first_seen')) await db.ensureMigrationsLedger(conn) @@ -102,6 +114,22 @@ async function prepareAgedDatabase(db) { }) } +// The ledger names recorded for MIGRATION +async function recordedMigrationNames(db) { + const rows = await withPoolConnection(db, conn => conn.query( + 'SELECT name FROM schema_migrations WHERE name = ?', [MIGRATION] + )) + return rows.map(row => row.name) +} + +// Run MIGRATION alone and read the first_seen definition on either side of it +async function targetedFirstSeenRun(db) { + const beforeShape = await readColumnShape(db, 'mempool_transactions', 'first_seen') + const result = await db.runMigrations({ includeManual: true, only: [MIGRATION] }) + const afterShape = await readColumnShape(db, 'mempool_transactions', 'first_seen') + return { result, beforeShape, afterShape } +} + async function dropDatabases(databases) { for (const db of databases) { if (db) await db.close() @@ -115,6 +143,7 @@ async function dropDatabases(databases) { try { await conn.query('DROP DATABASE IF EXISTS `' + AGED_DB + '`') await conn.query('DROP DATABASE IF EXISTS `' + FRESH_DB + '`') + await conn.query('DROP DATABASE IF EXISTS `' + SEEDED_DB + '`') } finally { await conn.end() } @@ -124,20 +153,17 @@ describe('mempool first_seen DATETIME migration on MariaDB', function () { this.timeout(180000) let agedDb let freshDb + let seededDb let migratedShapes before(async function () { agedDb = makeDatabase(AGED_DB) await prepareAgedDatabase(agedDb) }) after(async function () { - await dropDatabases([agedDb, freshDb]) + await dropDatabases([agedDb, freshDb, seededDb]) }) it('reproduces session-dependent reads on the aged TIMESTAMP columns', async function () { - const values = await readStoredTimes(AGED_DB, '-06:00') - assert.deepStrictEqual(values, { - firstSeen: '2026-01-15 06:34:56', - appliedAt: '2026-01-15 06:34:56' - }) + assert.deepStrictEqual(await readStoredTimes(AGED_DB, '-06:00'), SHIFTED_SIX_HOURS) }) it('retypes the observation and ledger columns without shifting their values', async function () { const result = await agedDb.runMigrations({ includeManual: true, only: [MIGRATION] }) @@ -145,10 +171,7 @@ describe('mempool first_seen DATETIME migration on MariaDB', function () { migratedShapes = await readMigrationShapes(agedDb) assert.strictEqual(migratedShapes.firstSeen.DATA_TYPE, 'datetime') assert.strictEqual(migratedShapes.appliedAt.DATA_TYPE, 'datetime') - assert.deepStrictEqual(await readStoredTimes(AGED_DB, '-06:00'), { - firstSeen: OBSERVED_AT, - appliedAt: OBSERVED_AT - }) + assert.deepStrictEqual(await readStoredTimes(AGED_DB, '-06:00'), AS_OBSERVED) }) it('leaves both migrated column definitions unchanged on a targeted rerun', async function () { const beforeShapes = await readMigrationShapes(agedDb) @@ -157,19 +180,27 @@ describe('mempool first_seen DATETIME migration on MariaDB', function () { assert.ok(!result.applied.includes(MIGRATION)) assert.deepStrictEqual(afterShapes, beforeShapes) }) + // A schema an earlier release built from src/sql carries no ledger row, so the precondition baselines it. it('baselines a fresh schema and preserves its matching first_seen definition', async function () { freshDb = makeDatabase(FRESH_DB) await freshDb.createDatabase() - await freshDb.verifyTables() - const beforeShape = await readColumnShape(freshDb, 'mempool_transactions', 'first_seen') - - const result = await freshDb.runMigrations({ includeManual: true, only: [MIGRATION] }) - const afterShape = await readColumnShape(freshDb, 'mempool_transactions', 'first_seen') - const recorded = await withPoolConnection(freshDb, conn => conn.query( - 'SELECT name FROM schema_migrations WHERE name = ?', [MIGRATION] - )) + await buildWithoutSeed(freshDb) + const { result, beforeShape, afterShape } = await targetedFirstSeenRun(freshDb) + const recorded = await recordedMigrationNames(freshDb) assert.ok(result.baselined.includes(MIGRATION)) - assert.deepStrictEqual(recorded.map(row => row.name), [MIGRATION]) + assert.deepStrictEqual(recorded, [MIGRATION]) + assert.deepStrictEqual(afterShape, beforeShape) + assert.deepStrictEqual(afterShape, migratedShapes.firstSeen) + }) + // This release records every committed migration while building from empty, so nothing is left to run. + it('records the migration when it builds the schema from empty, with first_seen already at its migrated definition', async function () { + seededDb = makeDatabase(SEEDED_DB) + await seededDb.createDatabase() + await seededDb.verifyTables() + assert.deepStrictEqual(await recordedMigrationNames(seededDb), [MIGRATION]) + const { result, beforeShape, afterShape } = await targetedFirstSeenRun(seededDb) + assert.ok(!result.applied.includes(MIGRATION)) + assert.ok(!result.baselined.includes(MIGRATION)) assert.deepStrictEqual(afterShape, beforeShape) assert.deepStrictEqual(afterShape, migratedShapes.firstSeen) }) diff --git a/test/integration/support/setup.js b/test/integration/support/setup.js index 37343a0c..2d4c3629 100644 --- a/test/integration/support/setup.js +++ b/test/integration/support/setup.js @@ -41,6 +41,7 @@ const BitcoinCore = require('bitcoin-core') const nodeHelper = require('../../helpers/node_helper') const XChainDecoder = require('../../../src/XChainDecoder') const Database = require('../../../src/db.js') +const { dropAllTables } = require('../../support/drop_tables') const fixturePorts = require('../../../bin/fixture-ports.js') // Fixture venue. Must match fixtures/docker-compose.test.yml. @@ -118,7 +119,7 @@ exports.mochaHooks = { // Prepare DB global.db = new Database(DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD) await global.db.createDatabase() - await global.db.dropDatabase() + await dropAllTables(global.db) await global.db.verifyTables() console.log('[integration] Funding test wallet') diff --git a/test/support/drop_tables.js b/test/support/drop_tables.js new file mode 100644 index 00000000..9b0f608d --- /dev/null +++ b/test/support/drop_tables.js @@ -0,0 +1,43 @@ +// 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. + +// Test-only helper that resets a decoder database between suites. It lives in +// test support so no production code path can drop the schema. + +'use strict'; + +// Child / referencing tables first. pubkeys carries a foreign key onto +// index_addresses, so it must go before index_addresses or the DROP would fail +// with a constraint error. +const DROP_ORDER = [ + 'transaction_outputs', + 'dispensers', + 'mempool_transactions', + 'pubkeys', + 'transactions', + 'blocks', + 'index_addresses', + 'index_transactions', + 'events', +]; + +// Drops every decoder table on one connection taken from `db`. +async function dropAllTables(db) { + const connection = await db.getConnection(); + try { + for (const table of DROP_ORDER) { + await connection.query(`DROP TABLE IF EXISTS ${table}`); + } + } finally { + await connection.release(); + } +} + +module.exports = { DROP_ORDER, dropAllTables }; diff --git a/test/unit/db_queries.test/14_has_dispenser_transactions.test.js b/test/unit/db_queries.test/14_has_dispenser_transactions.test.js index 9d762e73..dd1e16c8 100644 --- a/test/unit/db_queries.test/14_has_dispenser_transactions.test.js +++ b/test/unit/db_queries.test/14_has_dispenser_transactions.test.js @@ -59,6 +59,7 @@ describe('Database#hasDispenserTransactions()', () => { const sql = String(conn.query.firstCall.args[0]); assert.match(sql, /LIKE\s+'DISPENSER\|%'/i, 'must still match a top-level DISPENSER'); assert.match(sql, /LIKE\s+'%\|DISPENSER\|%'/i, 'must also match a BATCH-carried DISPENSER'); + assert.match(sql, /LIKE\s+'%;DISPENSER\|%'/i, 'must also match a DISPENSER after the first BATCH sub-command'); assert.match(sql, /LIMIT 1/i); }); @@ -80,6 +81,14 @@ describe('Database#hasDispenserTransactions()', () => { assert.strictEqual(await db.hasDispenserTransactions(), true); }); + // Sub-commands after the first are joined by ';', so only the leading one follows a '|'. + it('sees a dispenser opened after the first sub-command of a BATCH', async () => { + const db = makeDb(); + const { pool } = withConn(likeConn(['SEND|0|a', 'BATCH|0|SEND|0|X|1;DISPENSER|0|xyz'])); + injectPool(db, pool); + assert.strictEqual(await db.hasDispenserTransactions(), true); + }); + it('sees a top-level dispenser', async () => { const db = makeDb(); const { pool } = withConn(likeConn(['DISPENSER|0|xyz'])); @@ -89,7 +98,7 @@ describe('Database#hasDispenserTransactions()', () => { it('stays false on a database that never decoded a DISPENSER', async () => { const db = makeDb(); - const { pool } = withConn(likeConn(['SEND|0|a', 'BATCH|0|SEND|0|b', 'ISSUANCE|0|c'])); + const { pool } = withConn(likeConn(['SEND|0|a', 'BATCH|0|SEND|0|b', 'BATCH|0|SEND|0|b;ISSUANCE|0|c', 'ISSUANCE|0|c'])); injectPool(db, pool); assert.strictEqual(await db.hasDispenserTransactions(), false); }); diff --git a/test/unit/db_queries.test/15_delete_and_compare_txs_not_in_list.test.js b/test/unit/db_queries.test/15_delete_and_compare_txs_not_in_list.test.js index 47d3d61d..c57945c1 100644 --- a/test/unit/db_queries.test/15_delete_and_compare_txs_not_in_list.test.js +++ b/test/unit/db_queries.test/15_delete_and_compare_txs_not_in_list.test.js @@ -126,13 +126,17 @@ describe('Database#deleteAndCompareTxsNotInList()', () => { describe('Database#deleteAndCompareTxsNotInList()', () => { afterEach(() => sinon.restore()); - it('returns transactionsDeleted=0 on query error', async () => { + // A swallowed failure left txidList holding the whole node mempool, so the caller + // re-fetched every txid; rejecting lets updateMempool skip the cycle instead. + it('rejects on query error, leaving txidList whole and releasing the connection', async () => { const db = makeDb(); const q = sinon.stub().rejects(new Error('db error')); - const { pool } = withConn(q); + const { pool, conn } = withConn(q); injectPool(db, pool); - const r = await db.deleteAndCompareTxsNotInList(['aaaa']); - assert.deepStrictEqual(r, { transactionsDeleted: 0 }); + const list = ['aaaa', 'bbbb']; + await assert.rejects(db.deleteAndCompareTxsNotInList(list), /db error/); + assert.deepStrictEqual(list, ['aaaa', 'bbbb']); + assert.ok(conn.release.calledOnce, 'connection released on the failure path'); }); it('removes already-stored txids from the list in place (leaving only new arrivals)', async () => { diff --git a/test/unit/db_queries.test/16_drop_database.test.js b/test/unit/db_queries.test/16_drop_database.test.js index 6762b639..566f11c6 100644 --- a/test/unit/db_queries.test/16_drop_database.test.js +++ b/test/unit/db_queries.test/16_drop_database.test.js @@ -11,7 +11,7 @@ // Unit tests for Database query methods (no real DB). // Covers: all async query methods, getConnection retry/give-up, // releaseConnection, beginTransaction, endTransaction, commitTransaction, -// verifyDatabase, createDatabase, dropDatabase, and related helpers. +// verifyDatabase, createDatabase, and related helpers. // Uses sinon to inject a fake pool (no proxyquire). 'use strict'; @@ -19,6 +19,7 @@ const assert = require('assert'); const sinon = require('sinon'); const Database = require('../../../src/db.js'); +const { DROP_ORDER, dropAllTables } = require('../../support/drop_tables'); function makeDb(name = 'xchain_btc_mainnet') { @@ -43,7 +44,7 @@ function injectPool(db, pool) { db.pool = pool; } -describe('Database#dropDatabase()', () => { +describe('dropAllTables()', () => { afterEach(() => sinon.restore()); it('executes all DROP TABLE queries without throwing', async () => { @@ -51,9 +52,8 @@ describe('Database#dropDatabase()', () => { const q = sinon.stub().resolves([]); const { pool, conn } = withConn(q); injectPool(db, pool); - await db.dropDatabase(); - // Should have called query at least 9 times (9 tables) - assert.ok(conn.query.callCount >= 9); + await dropAllTables(db); + assert.strictEqual(conn.query.callCount, DROP_ORDER.length); const sqls = conn.query.getCalls().map(c => c.args[0]); assert.ok(sqls.some(s => /DROP TABLE IF EXISTS blocks/i.test(s))); assert.ok(sqls.some(s => /DROP TABLE IF EXISTS transactions/i.test(s))); diff --git a/test/unit/decoder_tip_stale_surface.test/01_reorg_halt_gauges.test.js b/test/unit/decoder_tip_stale_surface.test/01_reorg_halt_gauges.test.js new file mode 100644 index 00000000..a890ccb8 --- /dev/null +++ b/test/unit/decoder_tip_stale_surface.test/01_reorg_halt_gauges.test.js @@ -0,0 +1,80 @@ +/********************************************************************* + * + * 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'; + +// A parked decoder skips the tip refresh, so without the REORG_HALT gauges a scrape +// shows only node_height_stale 1 beside stalled 0, which reads as a node outage. + +const assert = require('assert'); +const XChainDecoder = require('../../../src/XChainDecoder'); +const { registerDecoderMetrics } = require('../../../src/metrics/decoder_metrics'); +const { Registry } = require('../../../src/observability/metrics'); + +// src/XChainDecoder.js BLOCKCHAIN_INFO_REFRESH_MS; stale is > 2x this. +const REFRESH_MS = 30000; + +// A decoder mid-catch-up with a fresh tip, before any marker probe has answered. +function makeRunningDecoder() { + const decoder = new XChainDecoder( + 'bitcoin-regtest', 'h', '0', 'db', 'u', 'p', 'h', '0', 'u', 'p', false, null + ); + decoder.lastProcessedBlockIndex = 100; + decoder.blockchainInfoLastBlock = 150; + decoder.blockchainInfoLastRefreshAt = Date.now(); + decoder.lastAdvanceAt = Date.now(); + return decoder; +} + +describe('registerDecoderMetrics() REORG_HALT gauges', function () { + it('exports a parked halt beside the stale tip it causes', function () { + const registry = new Registry(); + const decoder = makeRunningDecoder(); + decoder.blockchainInfoLastRefreshAt = Date.now() - (3 * REFRESH_MS); + decoder.reorgHalted = true; + decoder.reorgHaltParked = true; + decoder.reorgHaltCheckedAt = Date.now(); + registerDecoderMetrics(registry, decoder); + + const body = registry.render(); + assert.match(body, /^xchain_decoder_reorg_halted 1$/m); + assert.match(body, /^xchain_decoder_reorg_halt_parked 1$/m); + assert.match(body, /^xchain_decoder_node_height_stale 1$/m); + assert.match(body, /^xchain_decoder_stalled 0$/m); + }); + + it('exports a latent halt on a decoder that is still parsing', function () { + const registry = new Registry(); + const decoder = makeRunningDecoder(); + decoder.reorgHalted = true; + decoder.reorgHaltCheckedAt = Date.now(); + registerDecoderMetrics(registry, decoder); + + const body = registry.render(); + assert.match(body, /^xchain_decoder_reorg_halted 1$/m); + assert.match(body, /^xchain_decoder_reorg_halt_parked 0$/m); + }); + + it('omits the halt flag until the first marker probe, then reports a clear decoder as 0', function () { + // "Never looked" is not "not halted": /ready reports reorg_halt_unchecked for it. + const registry = new Registry(); + const decoder = makeRunningDecoder(); + registerDecoderMetrics(registry, decoder); + assert.ok(!/^xchain_decoder_reorg_halted /m.test(registry.render())); + assert.match(registry.render(), /^xchain_decoder_reorg_halt_parked 0$/m); + + decoder.reorgHaltCheckedAt = Date.now(); + assert.match(registry.render(), /^xchain_decoder_reorg_halted 0$/m); + }); +}); diff --git a/test/unit/dispenser_exact_integer_expiration.test.js b/test/unit/dispenser_exact_integer_expiration.test.js index a0165ec9..c2ac21e8 100644 --- a/test/unit/dispenser_exact_integer_expiration.test.js +++ b/test/unit/dispenser_exact_integer_expiration.test.js @@ -30,15 +30,18 @@ function restoreActivationAround() { describe('DISPENSER exact integer EXPIRATION activation', function () { restoreActivationAround() - it('is public and UNARMED on every network', function () { + it('is public, never active on mainnet, dark on testnet, and genesis-active on regtest', function () { assert.deepStrictEqual(activation[NAME], { mainnet: null, - testnet: null, - regtest: null, + testnet: 1792605600, + regtest: 0, }) assert.strictEqual(constants[NAME], activation[NAME]) - for (const network of Object.keys(activation[NAME])) - assert.strictEqual(isExactIntegerExpirationActive(network, 4000000000), false) + assert.strictEqual(isExactIntegerExpirationActive('mainnet', 4000000000), false) + assert.strictEqual(isExactIntegerExpirationActive('testnet', 1792605599), false) + assert.strictEqual(isExactIntegerExpirationActive('testnet', 1792605600), true) + assert.strictEqual(isExactIntegerExpirationActive('regtest', 0), true) + assert.strictEqual(isExactIntegerExpirationActive('regtest', 4000000000), true) assert.strictEqual(isExactIntegerExpirationActive('unknown', 4000000000), false) assert.strictEqual(isExactIntegerExpirationActive('regtest', NaN), false) }) @@ -63,6 +66,7 @@ describe('DISPENSER exact integer EXPIRATION decoding', function () { restoreActivationAround() it('preserves precision-rounded create and edit behavior below the gate', async function () { + activation[NAME].regtest = null const model = new DispenserModel() const createToken = `${T0 + 1000000}.0000000001` const editToken = `${T0 + 2000000}.0000000001` diff --git a/test/unit/dispenser_wide_expiration.test.js b/test/unit/dispenser_wide_expiration.test.js index cfce7bbe..5abee08c 100644 --- a/test/unit/dispenser_wide_expiration.test.js +++ b/test/unit/dispenser_wide_expiration.test.js @@ -61,7 +61,7 @@ describe('DISPENSER wide expiration', function () { it('pins the activation map and public constants export', function () { assert.deepStrictEqual(DISPENSER_WIDE_EXPIRATION_ACTIVATION, { mainnet: 9999999999, - testnet: 9999999999, + testnet: 1792605600, regtest: 0, }) assert.strictEqual( diff --git a/test/unit/mempool_isolation.test.js b/test/unit/mempool_isolation.test.js index eda0d65f..c2916016 100644 --- a/test/unit/mempool_isolation.test.js +++ b/test/unit/mempool_isolation.test.js @@ -161,4 +161,20 @@ describe('updateMempool DB isolation', function () { assert.match(summary, /3 in mempool/, 'the summary must report the size the node reported') assert.match(summary, /0 new/, 'the post-diff length is the new-arrival count and must be labelled so') }) + + // Drives the REAL Database diff over a failing connection: a swallowed failure left the + // whole node mempool in the list and the batch phase re-fetched every txid each poll. + it('skips the fetch phase when the mempool diff fails in the database', async () => { + const Database = require('../../src/db.js') + const { decoder } = buildDecoder(true) + const realDb = new Database('127.0.0.1', 3306, 'xchain_btc_regtest', 'u', 'p') + const conn = { query: async () => { throw new Error('CREATE TEMPORARY TABLE denied') }, release: async () => {} } + realDb.pool = { getConnection: async () => conn } + decoder.mempoolDb.deleteAndCompareTxsNotInList = (list) => realDb.deleteAndCompareTxsNotInList(list) + let fetched = 0 + decoder.connector.getRawTransactions = async (chunk) => { fetched += chunk.length; return [] } + await assert.rejects(decoder.updateMempool(), /CREATE TEMPORARY TABLE denied/) + assert.strictEqual(fetched, 0, 'a failed diff must not fall through to getRawTransactions') + assert.strictEqual(decoder.mempoolBusy, false, 'the next poll must be free to retry the diff') + }) }) diff --git a/test/unit/mempool_unattributable_action.test.js b/test/unit/mempool_unattributable_action.test.js index a814797d..7f5ca436 100644 --- a/test/unit/mempool_unattributable_action.test.js +++ b/test/unit/mempool_unattributable_action.test.js @@ -106,3 +106,56 @@ describe('mempool admission of an ACTION with no resolvable source', function () assert.strictEqual(inserted[0].data, 'COINPAY|0|abc|def') }) }) + +// The block path sees a gate-skipped tx (oversized or unknown ACTION, no outputs) once. +// The mempool path must too: a stateful seen-set fake runs two polls over the same txid. +describe('mempool admission of a gate-skipped ACTION', function () { + this.timeout(0) + + async function runTwoMempoolCycles(parseResult) { + const decoder = new XChainDecoder( + 'bitcoin-regtest', 'h', '0', 'db', 'u', 'p', 'h', '0', 'u', 'p', false, null + ) + const stored = new Map() + let fetched = 0 + decoder.mempoolDb = { + getAllOpenDispenserAddresses: async () => new Set(), + // Mirrors the real diff: txids already stored leave the list in place. + deleteAndCompareTxsNotInList: async (list) => { + const remaining = list.filter((h) => !stored.has(h)) + list.length = 0 + remaining.forEach((h) => list.push(h)) + return { transactionsDeleted: 0 } + }, + insertMempoolTransaction: async (row) => { stored.set(row.hash, row); return true }, + } + decoder.db = {} + decoder.connector = { + getRawMempool: async () => ['txid1'], + getRawTransactions: async (chunk) => { fetched += chunk.length; return chunk.map(() => 'hexdata') }, + } + decoder.xchainBlockDecoder = { transactionFromHex: () => ({ ins: [{}], getId: () => 'txid1' }) } + decoder.parseTransaction = async () => parseResult + await decoder.updateMempool() + await decoder.updateMempool() + return { decoder, stored, fetched } + } + + it('stores an unknown ACTION as a blanked seen-set row, fetched and counted once', async () => { + const unknown = Buffer.from('NOTANACTION|0|x') + const { decoder, stored, fetched } = await runTwoMempoolCycles( + actionResult('src', { data: unknown, compiledDataLength: unknown.length + 1 })) + assert.strictEqual(fetched, 1, 'the second poll must not re-fetch a tx the first one rejected') + assert.strictEqual(decoder.parseErrors, 1, 'parse_errors counts the rejection once, as the block path does') + assert.strictEqual(stored.get('txid1').data, '', 'a rejected ACTION must not be published as pending') + assert.strictEqual(stored.get('txid1').raw_data, null) + }) + + it('stores an oversized ACTION as a blanked seen-set row, fetched and counted once', async () => { + const { decoder, stored, fetched } = await runTwoMempoolCycles( + actionResult('src', { compiledDataLength: 1000000 })) + assert.strictEqual(fetched, 1, 'the second poll must not re-fetch a tx the first one rejected') + assert.strictEqual(decoder.parseErrors, 1, 'parse_errors counts the rejection once, as the block path does') + assert.strictEqual(stored.get('txid1').data, '') + }) +}) diff --git a/test/unit/migration_runner.test/08_destructive_guard_prefix_forms.test.js b/test/unit/migration_runner.test/08_destructive_guard_prefix_forms.test.js index 544e78a0..03f5afb3 100644 --- a/test/unit/migration_runner.test/08_destructive_guard_prefix_forms.test.js +++ b/test/unit/migration_runner.test/08_destructive_guard_prefix_forms.test.js @@ -18,6 +18,10 @@ * Covers the statement heads a plain `ALTER TABLE` anchor misses: MariaDB's * ALTER [ONLINE] [IGNORE] TABLE, non-table ALTERs, and CREATE of a trigger, * event, routine or view, whose bodies the prefix classifier cannot read. + * Also covers the closing statement-form allow-list: a mode=auto statement must + * open with ALTER [ONLINE] TABLE, CREATE [TEMPORARY] TABLE, CREATE + * [UNIQUE|FULLTEXT|SPATIAL] INDEX, DROP INDEX, DROP TEMPORARY TABLE, INSERT, + * UPDATE or SET. * ********************************************************************/ @@ -30,6 +34,11 @@ const scanOf = Database.prototype.destructiveAutoStatement.bind(Database.protot const splitOf = (raw) => Database.prototype.splitSqlStatements.call(Database.prototype, raw); const scanSql = (sql) => scanOf(splitOf(sql)); +// Top-level DROPs of an object other than an index or a temporary table +const NON_INDEX_DROPS = ['DROP SEQUENCE IF EXISTS s;', 'DROP TRIGGER trg;', 'DROP TRIGGER IF EXISTS trg;', + 'DROP VIEW v;', 'DROP EVENT e;', 'DROP PROCEDURE p;', 'DROP FUNCTION f;', 'DROP PACKAGE BODY pk;', + 'DROP USER u;', 'DROP ROLE r;', 'DROP SERVER s;', 'drop\n trigger trg;', '/* note */ DROP VIEW v;']; + describe('Database.destructiveAutoStatement() prefix forms @regression', function () { it('runs the ALTER clause checks on ALTER ONLINE TABLE', function () { assert.ok(scanSql('ALTER ONLINE TABLE t DROP COLUMN c;')); @@ -70,8 +79,18 @@ describe('Database.destructiveAutoStatement() prefix forms @regression', functio assert.strictEqual(scanSql('CREATE UNIQUE INDEX u ON t (a);'), null); }); + it('flags every top-level DROP other than a standalone index or temporary-table drop', function () { + for (const sql of NON_INDEX_DROPS) assert.ok(scanSql(sql), sql); + }); + + it('still allows a standalone DROP INDEX and a DROP TEMPORARY TABLE', function () { + assert.strictEqual(scanSql('DROP INDEX IF EXISTS address ON index_addresses;'), null); + assert.strictEqual(scanSql('DROP INDEX i ON t;'), null); + assert.strictEqual(scanSql('DROP TEMPORARY TABLE IF EXISTS _dup_index_addresses;'), null); + }); + it('catches ALTER IGNORE and CREATE TRIGGER end to end from raw file text', function () { - for (const body of ['ALTER IGNORE TABLE transactions DROP COLUMN raw;', + for (const body of ['ALTER IGNORE TABLE transactions DROP COLUMN raw;', 'DROP TRIGGER trg;', 'CREATE TRIGGER purge AFTER INSERT ON blocks FOR EACH ROW DELETE FROM transactions;']) { const raw = '-- xchain:migration mode=auto\n' + body + '\n'; assert.strictEqual(modeOf(raw), 'auto'); @@ -79,3 +98,38 @@ describe('Database.destructiveAutoStatement() prefix forms @regression', functio } }); }); + +// Statement forms outside the closing allow-list; each can make the server act unseen +const UNLISTED_FORMS = ["BINLOG 'AAAA'", 'DO purge_stale_rows()', 'SELECT purge_stale_rows()', + 'WITH x AS (SELECT 1) SELECT * FROM x', '(SELECT 1)', 'PURGE BINARY LOGS BEFORE NOW()', 'RESET MASTER', + "INSTALL SONAME 'ha_example'", 'UNINSTALL PLUGIN example', 'FLUSH TABLES', 'KILL 1', + "GRANT ALL ON *.* TO 'u'@'%'", "REVOKE ALL ON *.* FROM 'u'@'%'", 'HANDLER transactions OPEN', + 'LOCK TABLES transactions WRITE', 'ANALYZE TABLE transactions', 'OPTIMIZE TABLE transactions', + 'BEGIN NOT ATOMIC DELETE FROM transactions; END', 'XA START 1']; + +// One example of every form the closing allow-list admits +const ALLOWED_FORMS = ['ALTER TABLE transactions ADD COLUMN note TEXT NULL', + 'ALTER ONLINE TABLE transactions ADD INDEX note_idx (note)', 'CREATE TABLE IF NOT EXISTS notes (id INT)', + 'CREATE TEMPORARY TABLE scratch (id INT)', 'CREATE INDEX note_idx ON notes (id)', + 'CREATE UNIQUE INDEX note_uq ON notes (id)', 'DROP INDEX IF EXISTS note_idx ON notes', + 'DROP TEMPORARY TABLE scratch', "INSERT INTO notes (body) VALUES ('a')", + 'UPDATE notes SET id = (SELECT MAX(id) + 1 FROM notes) WHERE id = 0', "SET time_zone = '+00:00'"]; + +describe('Database.destructiveAutoStatement() statement-form allow-list @regression', function () { + it('flags every statement form outside the allow-list', function () { + for (const stmt of UNLISTED_FORMS) { + assert.strictEqual(scanOf([stmt]), stmt, stmt + ' must not be auto-eligible'); + } + }); + + it('keeps every allowed form auto-eligible', function () { + for (const stmt of ALLOWED_FORMS) { + assert.strictEqual(scanOf([stmt]), null, stmt + ' must stay auto-eligible'); + } + }); + + it('flags an unlisted form that follows an allowed one in the same file', function () { + assert.strictEqual(scanOf(['ALTER TABLE notes ADD COLUMN x INT NULL', 'DO purge_stale_rows()']), + 'DO purge_stale_rows()'); + }); +}); diff --git a/test/unit/migration_runner.test/19_key_witness_guard.test.js b/test/unit/migration_runner.test/19_key_witness_guard.test.js new file mode 100644 index 00000000..9c59877e --- /dev/null +++ b/test/unit/migration_runner.test/19_key_witness_guard.test.js @@ -0,0 +1,149 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * Unique-key witness: an auto migration whose INSERT IGNORE, ON DUPLICATE KEY + * UPDATE or REPLACE relies on a declared UNIQUE index that is not live is + * refused before it runs. + * + ********************************************************************/ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const Database = require('../../../src/db'); +const { keyDependentTargets, assertUniqueKeyWitness } = require('../../../src/db/migration_guards/key_witness.js'); + +const MIGRATIONS = path.join(__dirname, '../../../src/sql/migrations'); +// Reviewed map of committed auto files to the tables their key-dependent writes target +const PINNED = {}; + +const declared = [{ name: 'uq_pair', unique: true, columns: ['a', 'b'] }]; +const write = ['INSERT IGNORE INTO target (a, b) VALUES (1, 2)']; +const row = (name, nonUnique, column, seq) => ({ + INDEX_NAME: name, NON_UNIQUE: nonUnique, COLUMN_NAME: column, SEQ_IN_INDEX: seq, +}); +const exists = (value) => ({ TABLE_EXISTS: value ? 1 : 0 }); + +// Every committed auto file's key-dependent write targets, keyed by file name +function committedKeyDependentTargets() { + const found = {}; + for(const file of fs.readdirSync(MIGRATIONS).filter(name => name.endsWith('.sql')).sort()){ + const raw = fs.readFileSync(path.join(MIGRATIONS, file), 'utf8'); + if(Database.prototype.migrationMode(raw) !== 'auto') continue; + const targets = keyDependentTargets(Database.prototype.splitSqlStatements.call(Database.prototype, raw)); + if(targets.length) found[file] = targets; + } + return found; +} + +describe('decoder migration unique-key witness @regression', function () { + it('finds each key-dependent write and ignores one inside a quoted literal', function () { + assert.deepStrictEqual(keyDependentTargets([ + 'INSERT IGNORE INTO index_addresses (address) VALUES (1)', + 'INSERT INTO `PUBKEYS` (a) VALUES (1) ON DUPLICATE KEY UPDATE a=1', + 'REPLACE INTO db.blocks (a) VALUES (1)', + 'INSERT INTO events (code) VALUES (1)', + "UPDATE x SET note='INSERT IGNORE INTO y'", + ]), ['index_addresses', 'pubkeys', 'blocks']); + }); + + it('refuses a missing declared unique index and names its remedy', async function () { + const conn = { query: async () => [] }; + await assert.rejects(() => assertUniqueKeyWitness(conn, 'db', 'f.sql', write, () => declared), + /f\.sql[\s\S]*target[\s\S]*uq_pair[\s\S]*a, b[\s\S]*add the key by hand[\s\S]*earlier dated migration/); + }); + + it('passes a live unique index with the same ordered column set under another name', async function () { + let reads = 0; + const conn = { query: async sql => { + if(/information_schema\.TABLES/.test(sql)) return [exists(true)]; + reads++; + return [row('live_pair', 0, 'b', 2), row('live_pair', 0, 'a', 1)]; + } }; + await assert.doesNotReject(() => assertUniqueKeyWitness(conn, 'db', 'f.sql', write.concat(write), () => declared)); + assert.strictEqual(reads, 1, 'one target table must have one STATISTICS read'); + }); + + it('refuses a non-unique live index holding the same name and columns', async function () { + const conn = { query: async sql => /information_schema\.TABLES/.test(sql) ? [exists(true)] : + [row('uq_pair', 1, 'a', 1), row('uq_pair', 1, 'b', 2)] }; + await assert.rejects(() => assertUniqueKeyWitness(conn, 'db', 'f.sql', write, () => declared), /uq_pair/); + }); + + it('passes a target table that does not exist live without reading its indexes', async function () { + let indexReads = 0; + const conn = { query: async sql => { + if(/information_schema\.TABLES/.test(sql)) return [exists(false)]; + indexReads++; + return []; + } }; + await assert.doesNotReject(() => assertUniqueKeyWitness(conn, 'db', 'f.sql', write, () => declared)); + assert.strictEqual(indexReads, 0); + }); + + it('passes a table created earlier in the same file without reading live metadata', async function () { + let reads = 0; + const conn = { query: async () => { reads++; return []; } }; + await assert.doesNotReject(() => assertUniqueKeyWitness(conn, 'db', 'f.sql', [ + 'CREATE TABLE target (a INT, b INT, UNIQUE KEY uq_pair (a, b))', + write[0], + ], () => declared)); + assert.strictEqual(reads, 0); + }); + + it('keeps every committed auto file to the reviewed key-dependent map', function () { + assert.deepStrictEqual(committedKeyDependentTargets(), PINNED); + }); +}); + +describe('decoder migration runner applies the unique-key witness to auto files @regression', function () { + // A runner over a temp src/sql holding index_addresses.sql and one auto migration + function makeDb(liveUnique) { + const executed = []; + const conn = { + async query(sql) { + if (/GET_LOCK|RELEASE_LOCK|^SET SESSION|CREATE TABLE|schema_migrations/.test(sql)) return /GET_LOCK/.test(sql) ? [{ l: '1' }] : []; + if (/@@SESSION\.sql_mode/.test(sql)) return [{ mode: 'STRICT_TRANS_TABLES' }]; + if (/information_schema\.TABLES/.test(sql)) return [exists(true)]; + if (/information_schema\.STATISTICS/.test(sql)) return [row('address', liveUnique ? 0 : 1, 'address', 1)]; + executed.push(sql); + return []; + }, + async release() {}, + }; + const db = Object.create(Database.prototype); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'decoder-key-witness-')); + fs.mkdirSync(path.join(root, 'migrations')); + fs.writeFileSync(path.join(root, 'index_addresses.sql'), + 'CREATE TABLE index_addresses (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, address VARCHAR(120) NOT NULL);\n' + + 'CREATE UNIQUE INDEX address ON index_addresses (address);\n'); + fs.writeFileSync(path.join(root, 'migrations', '2099-01-01-t.sql'), + '-- xchain:migration mode=auto\nINSERT IGNORE INTO index_addresses (address) SELECT source FROM mempool_transactions;\n'); + Object.assign(db, { sqlPath: root, dbName: 'fake_db', getConnection: async () => conn, ensureMigrationsLedger: async () => {} }); + return { db, executed }; + } + + it('refuses an auto INSERT IGNORE while the declared UNIQUE address key is only a plain index', async function () { + const { db, executed } = makeDb(false); + await assert.rejects(() => db.runMigrations({}), /key-dependent write on index_addresses[\s\S]*address \(address\) is not live/); + assert.ok(!executed.some((s) => /^INSERT IGNORE/i.test(s)), 'the INSERT IGNORE never ran'); + }); + + it('runs the same file once the UNIQUE address key is live', async function () { + const { db, executed } = makeDb(true); + await db.runMigrations({}); + assert.ok(executed.some((s) => /^INSERT IGNORE INTO index_addresses/i.test(s)), 'the INSERT IGNORE ran'); + }); +}); diff --git a/test/unit/migration_runner.test/20_declared_key_drop_guard.test.js b/test/unit/migration_runner.test/20_declared_key_drop_guard.test.js new file mode 100644 index 00000000..cc2fbe32 --- /dev/null +++ b/test/unit/migration_runner.test/20_declared_key_drop_guard.test.js @@ -0,0 +1,112 @@ +'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. + * + ********************************************************************** + * Schema migration runner: the declared-key drop guard of the auto-apply path. + * + * verifyTables runs before runMigrations, so a mode=auto file that drops a key + * src/sql still declares UNIQUE or PRIMARY would leave the table without it for the + * whole process. The guard refuses that drop unless the same file re-creates the key. + * + ********************************************************************/ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const Database = require('../../../src/db'); +const { assertNoDeclaredKeyDrop } = require('../../../src/db/migration_guards/declared_key_drop.js'); + +const SQL_DIR = path.join(__dirname, '../../../src/sql'); +const MIG_DIR = path.join(SQL_DIR, 'migrations'); +const splitOf = (raw) => Database.prototype.splitSqlStatements.call(Database.prototype, raw); +const sqlFor = (table) => { + try { return fs.readFileSync(path.join(SQL_DIR, table + '.sql'), 'utf8'); } + catch (err) { if (err.code === 'ENOENT') return null; throw err; } +}; +const guard = (sql) => assertNoDeclaredKeyDrop('t.sql', splitOf(sql), sqlFor); + +describe('decoder declared-key drop guard @regression', function () { + it('refuses a drop of a declared UNIQUE or PRIMARY key that the file never re-creates', function () { + for (const sql of [ + 'DROP INDEX address ON index_addresses;', + 'ALTER TABLE index_addresses DROP KEY address;', + 'ALTER TABLE `index_transactions` DROP INDEX IF EXISTS `hash`;', + 'ALTER TABLE index_transactions DROP PRIMARY KEY;', + 'ALTER TABLE index_addresses DROP INDEX address, ADD INDEX address (address);', + ]) { + assert.throws(() => guard(sql), /drops (UNIQUE|PRIMARY) key (address|hash|primary) on index_(addresses|transactions)/, sql); + } + }); + + it('allows a drop the same file re-creates under the same name and kind', function () { + for (const sql of [ + 'ALTER TABLE index_transactions DROP PRIMARY KEY, ADD PRIMARY KEY (id);', + 'ALTER TABLE index_addresses DROP INDEX IF EXISTS address;\nCREATE UNIQUE INDEX IF NOT EXISTS address ON index_addresses (address);', + ]) { + assert.doesNotThrow(() => guard(sql), sql); + } + }); + + it('allows a table with no src/sql file and fails closed on a drop it cannot read', function () { + assert.doesNotThrow(() => guard('ALTER TABLE no_such_source_table DROP PRIMARY KEY;')); + assert.throws(() => guard('DROP INDEX ON index_addresses;'), /drops a key this guard cannot read/); + }); + + it('passes every committed mode=auto migration', function () { + for (const file of fs.readdirSync(MIG_DIR).filter((name) => name.endsWith('.sql')).sort()) { + const raw = fs.readFileSync(path.join(MIG_DIR, file), 'utf8'); + if (Database.prototype.migrationMode(raw) !== 'auto') continue; + assert.doesNotThrow(() => assertNoDeclaredKeyDrop(file, splitOf(raw), sqlFor), file); + } + }); +}); + +describe('decoder migration runner applies the declared-key drop guard to auto files @regression', function () { + // A runner over a temp src/sql holding index_addresses.sql and one auto migration + function makeDb(body) { + const executed = []; + const conn = { + async query(sql) { + if (/GET_LOCK|RELEASE_LOCK|^SET SESSION|CREATE TABLE|schema_migrations/.test(sql)) return /GET_LOCK/.test(sql) ? [{ l: '1' }] : []; + if (/@@SESSION\.sql_mode/.test(sql)) return [{ mode: 'STRICT_TRANS_TABLES' }]; + executed.push(sql); + return []; + }, + async release() {}, + }; + const db = Object.create(Database.prototype); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'decoder-key-drop-')); + fs.mkdirSync(path.join(root, 'migrations')); + fs.writeFileSync(path.join(root, 'index_addresses.sql'), + 'CREATE TABLE index_addresses (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, address VARCHAR(120) NOT NULL);\n' + + 'CREATE UNIQUE INDEX address ON index_addresses (address);\n'); + fs.writeFileSync(path.join(root, 'migrations', '2099-01-01-t.sql'), '-- xchain:migration mode=auto\n' + body); + Object.assign(db, { sqlPath: root, dbName: 'fake_db', getConnection: async () => conn, ensureMigrationsLedger: async () => {} }); + return { db, executed }; + } + + it('refuses an auto drop of the declared UNIQUE address key before running it', async function () { + const { db, executed } = makeDb('DROP INDEX address ON index_addresses;\n'); + await assert.rejects(() => db.runMigrations({}), /drops UNIQUE key address on index_addresses/); + assert.ok(!executed.some((s) => /^DROP INDEX/i.test(s)), 'the DROP INDEX never ran'); + }); + + it('runs the same drop when the file re-creates the key', async function () { + const { db, executed } = makeDb('DROP INDEX address ON index_addresses;\nCREATE UNIQUE INDEX address ON index_addresses (address);\n'); + await db.runMigrations({}); + assert.ok(executed.some((s) => /^DROP INDEX address/i.test(s)), 'the DROP INDEX ran'); + }); +}); diff --git a/test/unit/oracle_fee_output_activation_conformance.test.js b/test/unit/oracle_fee_output_activation_conformance.test.js index 2203bb12..33f62214 100644 --- a/test/unit/oracle_fee_output_activation_conformance.test.js +++ b/test/unit/oracle_fee_output_activation_conformance.test.js @@ -23,8 +23,9 @@ // Three tiers, so a one-sided edit fails somewhere no matter which checkout is present: // 1. PIN - the vendored value equals the pinned flag-day instant, in this repo alone. // 2. DOCS - it equals the canonical map in xchain-documentation/protocol/constants.js. -// 3. INDEXER - it is >= the indexer's FIX_OUTPUT_FANOUT mainnet block-time, so capture can -// never begin in a block the indexer would halt on. +// 3. INDEXER - on every network it is >= the indexer's FIX_OUTPUT_FANOUT block-time, and that +// row carries no height gate, so capture can never begin in a block the +// indexer would halt on. // Tiers 2 and 3 skip when the sibling checkout is absent (standalone deploy); set // XCHAIN_REQUIRE_SIBLINGS=1 in CI so a missing sibling hard-fails instead of green-by-skip. @@ -101,18 +102,49 @@ describe('ORACLE_FEE_OUTPUT_ACTIVATION conformance', function () { // regtest_block), written either as the historical addChange(...) call or as the // array literal the registry parts hold; both spellings are accepted so the guard // reads the same row through either layout. - const src = indexerChangesCorpus(); - const m = /(?:addChange\(|\[)\s*'FIX_OUTPUT_FANOUT'\s*,\s*'[^']*'\s*,\s*(\d+)\s*,/.exec(src); - assert.ok(m, 'FIX_OUTPUT_FANOUT must be registered in xchain-indexer/src/protocol_changes.js ' + - 'or one of its registry parts under src/protocol_changes/'); - const fanoutMainnetTime = parseInt(m[1], 10); - assert.ok(ORACLE_FEE_OUTPUT_ACTIVATION.mainnet >= fanoutMainnetTime, - 'oracle-fee capture (' + ORACLE_FEE_OUTPUT_ACTIVATION.mainnet + ') must not begin before ' + - 'FIX_OUTPUT_FANOUT (' + fanoutMainnetTime + '): a second stored output below that ' + - 'flag-day is a consensus-critical fan-out fault that halts the block'); + const row = fixOutputFanoutRow(); + for (const network of ['mainnet', 'testnet', 'regtest']) { + const fanoutTime = row[network + '_time']; + assert.ok(ORACLE_FEE_OUTPUT_ACTIVATION[network] >= fanoutTime, + network + ' oracle-fee capture (' + ORACLE_FEE_OUTPUT_ACTIVATION[network] + ') must not ' + + 'begin before FIX_OUTPUT_FANOUT (' + fanoutTime + '): a second stored output below that ' + + 'flag-day is a consensus-critical fan-out fault that halts the block'); + } + }); + + it('needs no indexer FIX_OUTPUT_FANOUT block-height gate (the decoder gate is time-only)', function () { + if (!siblingOrSkip(this, INDEXER_CHANGES)) return; + // isEnabled ANDs the time and height gates, so a non-zero *_block holds the collapse + // off past its time gate, where this time-only capture would already halt blocks. + const row = fixOutputFanoutRow(); + for (const network of ['mainnet', 'testnet', 'regtest']) { + assert.strictEqual(row[network + '_block'], 0, + 'FIX_OUTPUT_FANOUT ' + network + '_block is ' + row[network + '_block'] + ': the ' + + 'decoder oracle-fee gate cannot mirror a height gate, so arm it by time only'); + } }); }); +// The FIX_OUTPUT_FANOUT arming row, all six gate columns, read from indexer source. +// A non-integer column (a named constant, a thunk) fails here rather than reading as 0. +function fixOutputFanoutRow(){ + const src = indexerChangesCorpus(); + const head = /(?:addChange\(|\[)\s*'FIX_OUTPUT_FANOUT'\s*,\s*'[^']*'\s*,([^\])]*)[\])]/.exec(src); + assert.ok(head, 'FIX_OUTPUT_FANOUT must be registered in xchain-indexer/src/protocol_changes.js ' + + 'or one of its registry parts under src/protocol_changes/'); + const cols = head[1].split(',').map(s => s.trim()); + const names = ['mainnet_time', 'testnet_time', 'regtest_time', 'mainnet_block', 'testnet_block', 'regtest_block']; + assert.strictEqual(cols.length, names.length, + 'FIX_OUTPUT_FANOUT row must carry exactly six gate columns, read: ' + head[1]); + const row = {}; + names.forEach((name, i) => { + assert.ok(/^\d+$/.test(cols[i]), + 'FIX_OUTPUT_FANOUT ' + name + ' must be an integer literal for this guard to read it, got: ' + cols[i]); + row[name] = parseInt(cols[i], 10); + }); + return row; +} + // ORACLE_FEE_SET_CAPTURE_ACTIVATION drift guard. // // The second gate widens a v2 refill's oracle-fee capture from ONE resolved address to diff --git a/test/unit/parse_transaction.test/04_get_source_from_output.test.js b/test/unit/parse_transaction.test/04_get_source_from_output.test.js index 38856aab..23ff2bc0 100644 --- a/test/unit/parse_transaction.test/04_get_source_from_output.test.js +++ b/test/unit/parse_transaction.test/04_get_source_from_output.test.js @@ -116,4 +116,43 @@ describe('XChainDecoder#getSourceFromOutput()', () => { // Should have chased one level assert.ok(decoder.connector.getRawTransaction.calledTwice) }) + +}) + +describe('XChainDecoder#getSourceFromOutput() P2WSH walk-back gate', () => { + let decoder + + afterEach(() => { + sinon.restore() + }) + + it('does not chase P2WSH-shaped outputs on non-segwit chains after activation', async () => { + decoder = new XChainDecoder( + 'dogecoin-regtest', null, null, null, null, null, + '127.0.0.1', 18443, 'rpc', 'rpc', false + ) + const outerTx = bitcoin.Transaction.fromHex(TX_HEX.opReturn) + outerTx.outs[1].script = Buffer.from('0020' + 'aa'.repeat(32), 'hex') + decoder.connector = { getRawTransaction: sinon.stub().resolves(outerTx.toHex()) } + + await decoder.getSourceFromOutput('sometxid', 1, null, 0) + + assert.strictEqual(decoder.connector.getRawTransaction.callCount, 1) + }) + + it('preserves the P2WSH walk-back below an unarmed non-segwit gate', async () => { + decoder = new XChainDecoder( + 'dogecoin-mainnet', null, null, null, null, null, + '127.0.0.1', 22555, 'rpc', 'rpc', false + ) + const outerTx = bitcoin.Transaction.fromHex(TX_HEX.opReturn) + outerTx.outs[1].script = Buffer.from('0020' + 'aa'.repeat(32), 'hex') + decoder.connector = { getRawTransaction: sinon.stub() } + decoder.connector.getRawTransaction.onFirstCall().resolves(outerTx.toHex()) + decoder.connector.getRawTransaction.onSecondCall().resolves(TX_HEX.opReturn) + + await decoder.getSourceFromOutput('sometxid', 1, null, 9999999) + + assert.strictEqual(decoder.connector.getRawTransaction.callCount, 2) + }) }) diff --git a/test/unit/verify_reorg_retry.test/01_xchain_decoder_verify_reorg_mid_walk_tip_regression.test.js b/test/unit/verify_reorg_retry.test/01_xchain_decoder_verify_reorg_mid_walk_tip_regression.test.js index a3baa086..31717bcf 100644 --- a/test/unit/verify_reorg_retry.test/01_xchain_decoder_verify_reorg_mid_walk_tip_regression.test.js +++ b/test/unit/verify_reorg_retry.test/01_xchain_decoder_verify_reorg_mid_walk_tip_regression.test.js @@ -55,7 +55,7 @@ describe('XChainDecoder.verifyReorg mid-walk tip regression', function () { if (h > REGRESSED_TIP) throw new Error('Block height out of range') return 'match' + h }, - getBlockchainInfo: async () => ({ blocks: REGRESSED_TIP }) + getBlockchainInfo: async () => ({ blocks: REGRESSED_TIP, verificationprogress: 1 }) } decoder.db = { getLastBlockIndex: async () => top, @@ -106,7 +106,7 @@ describe('XChainDecoder.verifyReorg mid-walk tip regression', function () { if (h > 102) throw new Error('Block height out of range') return 'match' + h }, - getBlockchainInfo: async () => ({ blocks: 102, chain: 'main' }) + getBlockchainInfo: async () => ({ blocks: 102, chain: 'main', verificationprogress: 1 }) } decoder.db = { getLastBlockIndex: async () => top, @@ -152,7 +152,7 @@ describe('XChainDecoder.verifyReorg mid-walk tip regression', function () { if (h > REGRESSED_TIP) throw new Error('Block height out of range') return 'match' + h }, - getBlockchainInfo: async () => ({ blocks: REGRESSED_TIP, chain: 'main' }) + getBlockchainInfo: async () => ({ blocks: REGRESSED_TIP, chain: 'main', verificationprogress: 1 }) } decoder.db = { getLastBlockIndex: async () => top, @@ -263,3 +263,29 @@ describe('XChainDecoder.verifyReorg mid-walk tip refresh from a catching-up node assert.deepStrictEqual(state.deleted, [105, 104, 103], 'the wait resolves once the node is synced') }) }) + +describe('XChainDecoder.verifyReorg mid-walk tip refresh from a malformed reply', function () { + this.timeout(0) + + it('ignores a refreshed tip whose reply omits verificationprogress', async function () { + const trimmed = { blocks: 102, chain: 'main' } + const { decoder, state } = catchingUpWalk([trimmed], 3) + assert.strictEqual(await decoder.verifyReorg(105), true) + assert.deepStrictEqual(state.deleted, [], 'a reply the block loop refuses must not drive above-tip deletes') + }) + + it('ignores a refreshed tip whose verificationprogress is not a number', async function () { + const stringly = { blocks: 102, chain: 'main', initialblockdownload: false, verificationprogress: '1' } + const { decoder, state } = catchingUpWalk([stringly], 3) + assert.strictEqual(await decoder.verifyReorg(105), true) + assert.deepStrictEqual(state.deleted, [], 'a non-numeric progress is a malformed reply, not a synced node') + }) + + it('accepts the refreshed tip once a well-formed reply follows the malformed one', async function () { + const trimmed = { blocks: 102, chain: 'main' } + const synced = { blocks: 102, chain: 'main', initialblockdownload: false, verificationprogress: 1 } + const { decoder, state } = catchingUpWalk([trimmed, trimmed, synced]) + assert.strictEqual(await decoder.verifyReorg(105), true) + assert.deepStrictEqual(state.deleted, [105, 104, 103], 'the walk resumes on the first well-formed reading') + }) +}) diff --git a/test/unit/xchain_decoder.test/05_xchain_decoder_duplicate_transaction_output_rollback.test.js b/test/unit/xchain_decoder.test/05_xchain_decoder_duplicate_transaction_output_rollback.test.js new file mode 100644 index 00000000..83d8e77b --- /dev/null +++ b/test/unit/xchain_decoder.test/05_xchain_decoder_duplicate_transaction_output_rollback.test.js @@ -0,0 +1,100 @@ +// 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 XChainDecoder = require('../../../src/XChainDecoder') + +// Passes of one block before a test calls the loop wedged and stops it. +const PASS_LIMIT = 8 + +function fakeTx(id) { + return { getId: () => id, outs: [] } +} + +// Drive the real block loop at height 0 with `output` deciding each insertTransactionOutput result. +function buildDecoder(output, parsed) { + const decoder = new XChainDecoder('bitcoin-regtest', 'h', '0', 'db', 'u', 'p', 'h', '0', 'u', 'p', false, null) + decoder.startBlockIndex = 0 + decoder.sleep = async () => {} + const calls = { passes: 0, wedged: false, commit: 0, endTransaction: 0, outputs: 0, insertEvent: [] } + decoder.connector = { + getBlockchainInfo: async () => ({ verificationprogress: 1, blocks: 0 }), + getBlockHash: async () => 'blockA', + getBlock: async () => '' + } + decoder.db = { + createDatabase: async () => true, verifyDatabase: async () => true, + verifyTables: async () => true, runMigrations: async () => ({ applied: [], pending: [] }), + getLastBlockIndex: async () => -1, getLastTxIndex: async () => 0, + beginTransaction: async () => {}, + endTransaction: async () => { calls.endTransaction++ }, + commitTransaction: async () => { calls.commit++; decoder.stopFlag = true; return true }, + deleteOpenDispensers: async () => true, purgeExpiredDispensers: async () => true, + getAllOpenDispenserAddresses: async () => new Set(), + insertEvent: async (code, data) => { calls.insertEvent.push({ code, data }); return true }, + insertBlock: async () => true, + insertTransaction: async () => true, + insertTransactionOutput: async () => { calls.outputs++; return output(calls.outputs) }, + DUPLICATED_TRANSACTION: 1, POISON_ROW: 2 + } + decoder.xchainBlockDecoder = { + blockFromHex: () => { + calls.passes++ + if (calls.passes > PASS_LIMIT){ calls.wedged = true; decoder.stopFlag = true } + return { prevHash: Buffer.alloc(32), timestamp: 1700000000, transactions: [fakeTx('cafe01')] } + } + } + decoder.parseTransaction = async () => Object.assign({ + data: Buffer.from('SEND|x'), compiledDataLength: 6, rawData: null, + source: 'someaddr', destination: null, amount: 0 + }, parsed) + return { decoder, calls } +} + +// The payment output is captured because it pays the protocol fee destination. +const PATHS = [ + { kind: 'dispense', feeDestination: null, parsed: { + dispenseOutputs: [{ vout: 0, destinationAddress: 'destaddr', amount: 1n }], paymentOutputs: [] } }, + { kind: 'payment', feeDestination: 'feeaddr', parsed: { + dispenseOutputs: [], paymentOutputs: [{ vout: 1, destinationAddress: 'feeaddr', amount: 2n }] } }, +] + +function build(path, output) { + const built = buildDecoder(output, path.parsed) + if (path.feeDestination) built.decoder.feeDestination = path.feeDestination + return built +} + +// A duplicate key on a transaction_outputs row rolls the block back, as it does on the parent row. +describe('duplicate transaction output insert rolls the block back', function () { + this.timeout(0) + + for (const p of PATHS) { + it('rolls back a ' + p.kind + ' output collision and commits on the clean retry', async function () { + const { decoder, calls } = build(p, n => (n === 1 ? 1 : true)) + await decoder.start() + assert.strictEqual(calls.wedged, false, 'the block must not wedge') + assert.strictEqual(calls.outputs, 2, 'the output insert is retried after the duplicate') + assert.strictEqual(calls.endTransaction, 1, 'the open block transaction is ended on the duplicate') + assert.strictEqual(calls.commit, 1, 'the block commits on the retry') + assert.deepStrictEqual(calls.insertEvent, [], 'a duplicate is never quarantined with PARSE_ERROR') + }) + + it('never commits past a persistent ' + p.kind + ' output collision', async function () { + const { decoder, calls } = build(p, () => 1) + await decoder.start() + assert.strictEqual(calls.wedged, true, 'a persistent duplicate stalls the block') + assert.strictEqual(calls.commit, 0, 'nothing commits past the collision') + assert.deepStrictEqual(calls.insertEvent, [], 'the tx is never skipped with PARSE_ERROR') + }) + } +})