From 8122904e6e448d04a5f05d9d20f867477785aaeb Mon Sep 17 00:00:00 2001 From: Tanya Bushenyova Date: Tue, 30 Jun 2026 15:55:57 +0300 Subject: [PATCH 1/5] Add history bitmap and outcome lookup --- foundry.toml | 1 + src/Multiverse.sol | 95 ++++++++++++++- src/libraries/LibHistory.sol | 122 +++++++++++++++++++ test/unit/LibHistory.t.sol | 219 +++++++++++++++++++++++++++++++++++ 4 files changed, 433 insertions(+), 4 deletions(-) create mode 100644 src/libraries/LibHistory.sol create mode 100644 test/unit/LibHistory.t.sol diff --git a/foundry.toml b/foundry.toml index f84ae83..2c501a3 100644 --- a/foundry.toml +++ b/foundry.toml @@ -15,6 +15,7 @@ cbor_metadata = false gas_reports = ["*"] fs_permissions = [{ access = "read-write", path = "./broadcast" }] ignored_warnings_from = ["lib"] # ignoring warnings from external packages +allow_internal_expect_revert = true fuzz = { runs = 256 } invariant = { runs = 256, depth = 15, fail_on_revert = false, call_override = false } diff --git a/src/Multiverse.sol b/src/Multiverse.sol index 3f13838..4b1ea82 100644 --- a/src/Multiverse.sol +++ b/src/Multiverse.sol @@ -10,6 +10,7 @@ import { ILituusRep } from "./interfaces/ILituusRep.sol"; import { LituusRep } from "./LituusRep.sol"; import { IReputationToken } from "./interfaces/IReputationToken.sol"; import { IQueryFeeController } from "./interfaces/IQueryFeeController.sol"; +import { LibHistory } from "./libraries/LibHistory.sol"; // TODO: check zoltar forks @@ -78,10 +79,17 @@ contract Multiverse is ReentrancyGuard { ForkState forkState; // TODO: populate the forkTime uint48 forkTime; + // The depth of the universe in the fork tree + // Genesis universe has depth 0, its children have depth 1, etc. Max 256. + uint16 forkDepth; uint248 parent; uint248 favoriteChild; uint248 heir; // TODO: populate the history + // History format: + // Genesis universe has history 0, depth 0. + // First children have history 0b00 and 0b01, depth 1, + // second children of the second child 0b010 and 0b011, depth 2, etc. bytes32 history; uint256 forkQuery; uint256 supplyBeforeFork; @@ -159,7 +167,9 @@ contract Multiverse is ReentrancyGuard { genesisUniverse.forkState = ForkState.NotForking; genesisUniverse.forkTime = uint48(block.timestamp); genesisUniverse.heir = 0; + // Genesis is the root of the fork tree: an empty inheritance path. genesisUniverse.history = 0; + genesisUniverse.forkDepth = 0; genesisUniverse.forkQuery = 0; // TODO: fill in the correct supply genesisUniverse.supplyBeforeFork = ZOLTAR.getUniverseTheoreticalSupply(_initialZoltarUniverseId); @@ -225,6 +235,12 @@ contract Multiverse is ReentrancyGuard { QueryResolution storage resolution = queryResolutions[activeUniverseId][queryId]; if (resolution.outcome != UNRESOLVED) revert QueryAlreadyResolved(); + // A query resolved in an ancestor universe is inherited by this lineage (via getOutcome), so it + // cannot be reported on again here. The ancestor set is fixed per lineage, so this only needs to + // be checked on the first report; later stakes in the same escalation are already covered. + if (resolution.stakes.length == 0 && _findAncestorResolution(activeUniverseId, queryId) != UNRESOLVED) { + revert QueryAlreadyResolved(); + } // Outcome should be between 1 and numberOfOutcomes unless the query should be reported as INVALID if (outcome == UNRESOLVED) revert InvalidOutcome(); @@ -282,8 +298,15 @@ contract Multiverse is ReentrancyGuard { uint48 queryCreateTime = _getAndUpdateQueryCreateTime(activeUniverseId, queryId); if (resolution.stakes.length == 0 && queryCreateTime + THREE_DAYS < block.timestamp) { + // No report ever landed here, so the ancestor check was never run by report(): a query + // resolved in an ancestor is inherited by this lineage (via getOutcome) and must not be + // resolved again. The stakes branch below is already covered by report()'s first-stake check. + if (_findAncestorResolution(activeUniverseId, queryId) != UNRESOLVED) revert QueryAlreadyResolved(); // If the report period has passed and the query was not reported on then resolve the query as INVALID resolution.outcome = INVALID; + // Record this universe as one where the query is resolved, so heir universes can find it + // as an ancestor resolution via getOutcome(). + query.resolvedUniverses.push(activeUniverseId); emit QueryResolved(msg.sender, activeUniverseId, queryId, INVALID); // TODO: Payout to the resolver, another clock auction } else if (resolution.stakes.length > 0) { @@ -292,6 +315,9 @@ contract Multiverse is ReentrancyGuard { // TODO: Unless the query is 1 step from fork threshold, then we should wait for the fork to finish uint8 outcome = _calculateOutcomeAndEscalationPayoffs(activeUniverseId, queryId); resolution.outcome = outcome; + // Record this universe as one where the query is resolved, so heir universes can find it + // as an ancestor resolution via getOutcome(). + query.resolvedUniverses.push(activeUniverseId); emit QueryResolved(msg.sender, activeUniverseId, queryId, outcome); // TODO: Payouts } else { @@ -465,9 +491,54 @@ contract Multiverse is ReentrancyGuard { } /* ========================================= PUBLIC VIEW FUNCTIONS =========================================== */ + /** + * @notice Returns the outcome of a query as seen from a given universe. + * @dev If the query is resolved in this universe, that outcome is returned directly. Otherwise the + * query's resolutions are scanned for one recorded in an ancestor of this universe (a prefix + * match on the inheritance path). A query is resolved at most once along any single lineage, + * so the first ancestor match is the applicable resolution. Returns UNRESOLVED if neither this + * universe nor any ancestor has resolved the query. + * @param universeId The universe to read the outcome from. + * @param queryId The query to read. + * @return The resolved outcome, or UNRESOLVED if none applies to this universe. + */ function getOutcome(uint248 universeId, uint256 queryId) external view returns (uint8) { - // TODO: outcome lookup in ancestor universes if unresolved in the current universe - return queryResolutions[universeId][queryId].outcome; + // Fast path: resolved in this universe. + uint8 localOutcome = queryResolutions[universeId][queryId].outcome; + if (localOutcome != UNRESOLVED) return localOutcome; + + // Forward to the heir if this universe has forked, so we read from the active universe where + // report()/resolve() record resolutions. Reverts only if the universe does not exist; outcomes + // remain readable while the universe is forking (no fork-state check, unlike report/resolve). + (uint248 activeUniverseId,) = _getActiveUniverse(universeId); + + // Otherwise inherit the resolution from an ancestor universe, if any. + return _findAncestorResolution(activeUniverseId, queryId); + } + + /** + * @notice Returns the outcome of the first ancestor of `universeId` that has resolved `queryId`. + * @dev Scans the query's recorded resolution universes and prefix-matches their inheritance path + * against `universeId`'s path. Does NOT consider a resolution in `universeId` itself — callers + * handle the local resolution separately. A query is resolved at most once along any single + * lineage, so the first ancestor match is authoritative. Returns UNRESOLVED if no ancestor + * has resolved the query. + */ + function _findAncestorResolution(uint248 universeId, uint256 queryId) internal view returns (uint8) { + Universe storage universe = universes[universeId]; + bytes32 history = universe.history; + uint16 forkDepth = universe.forkDepth; + + uint248[] storage resolvedUniverses = queries[queryId].resolvedUniverses; + uint256 length = resolvedUniverses.length; + for (uint256 i = 0; i < length; i++) { + uint248 resolvedUniverseId = resolvedUniverses[i]; + Universe storage candidate = universes[resolvedUniverseId]; + if (LibHistory.isAncestor(candidate.history, candidate.forkDepth, history, forkDepth)) { + return queryResolutions[resolvedUniverseId][queryId].outcome; + } + } + return UNRESOLVED; } function _requiredStakeAmount(uint248 universeId, uint256 queryId) public view returns (uint256) { @@ -507,10 +578,17 @@ contract Multiverse is ReentrancyGuard { } /* =========================================== INTERNAL HELPERS ============================================== */ - function _getActiveUniverseAndRepToken(uint248 universeId) + /** + * @notice Resolves a universe id to the active universe, forwarding to the heir if it has forked. + * @dev Reverts with InvalidUniverse only if the universe (or its heir) does not exist (repToken == 0). + * Does NOT check the fork state, so callers that must operate only on an active/forming universe + * (report/resolve) layer that check on top; read-only callers (getOutcome) can use this directly + * to stay readable while a universe is forking. + */ + function _getActiveUniverse(uint248 universeId) internal view - returns (uint248 activeUniverseId, Universe storage universe, ILituusRep repToken) + returns (uint248 activeUniverseId, Universe storage universe) { universe = universes[universeId]; if (address(universe.repToken) == address(0)) revert InvalidUniverse(); @@ -524,6 +602,14 @@ contract Multiverse is ReentrancyGuard { } else { activeUniverseId = universeId; } + } + + function _getActiveUniverseAndRepToken(uint248 universeId) + internal + view + returns (uint248 activeUniverseId, Universe storage universe, ILituusRep repToken) + { + (activeUniverseId, universe) = _getActiveUniverse(universeId); // Sanity check: if the universe is not active or forming, // then the reporting should have been forwarded to the heir. ForkState forkState = universe.forkState; @@ -541,6 +627,7 @@ contract Multiverse is ReentrancyGuard { if (resolution.queryCreateTime != 0) { return resolution.queryCreateTime; } else { + // TODO: check query flow after forks // If the queryCreateTime is not set, it means the query was not created in this universe but was reported // on in this universe. In this case, we should use the forkTime of the universe as the queryCreateTime, // since the query becomes reportable in this universe after the fork. diff --git a/src/libraries/LibHistory.sol b/src/libraries/LibHistory.sol new file mode 100644 index 0000000..48a32c8 --- /dev/null +++ b/src/libraries/LibHistory.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.35; + +/** + * @title LibHistory + * @notice Encodes and queries a universe's path of inheritance through the fork tree as a bitmap, + * enabling O(1) ancestor checks via prefix matching. + * @dev Encoding is MSB-first / left-aligned. The genesis universe has `history == 0` and + * `forkDepth == 0`. Each binary fork appends one bit (the branch taken, 0 or 1) to the + * path; the bit for fork level `i` (0-based) lives at bit `255 - i`, so the oldest fork is + * the most-significant bit (i=0 -> bit 255, i=255 -> bit 0). A path of depth `d` therefore + * occupies the top `d` bits and the remaining `256 - d` low bits MUST be zero. + * + * `forkDepth` is required to disambiguate paths: a branch-0 fork sets no bit, so a history + * alone cannot distinguish e.g. "0" (depth 1) from genesis (depth 0) — the depth carries + * that information. Ancestry is "A is an ancestor-or-equal of B if A's path is a prefix of + * B's path", which reduces to comparing the top `A.forkDepth` bits. + * + * All functions are `pure` and operate only on their arguments. Malformed inputs (a history + * with bits set below its depth, or a depth above {MAX_FORK_DEPTH}) revert rather than + * returning a value: these are sanity checks that should never trigger for histories built + * by the system through {appendHistory}. + */ +library LibHistory { + /// @notice Maximum fork depth. A path can hold at most 256 single-bit fork branches in a bytes32. + uint256 public constant MAX_FORK_DEPTH = 256; + + /// @notice Thrown when a fork depth exceeds {MAX_FORK_DEPTH} (or would on append). + error ForkDepthOverflow(); + /// @notice Thrown when a fork outcome index is not a valid binary branch (must be 0 or 1). + error InvalidForkOutcomeIndex(); + /// @notice Thrown when a history has bits set below its declared depth (history/depth mismatch). + error HistoryAndDepthMismatch(); + + /** + * @notice Appends a fork branch to a universe's path, producing the child universe's history. + * @dev Reverts with {ForkDepthOverflow} if the parent is at or above {MAX_FORK_DEPTH}, + * {HistoryAndDepthMismatch} if `history` has bits set below `forkDepth`, and + * {InvalidForkOutcomeIndex} if `forkOutcomeIndex > 1`. The new branch bit is placed at + * bit `256 - newForkDepth` (MSB-first); a branch of 0 leaves the bitmap unchanged and is + * distinguished only by the incremented depth. + * @param history The parent universe's history bitmap. + * @param forkDepth The parent universe's fork depth (number of branches in `history`). + * @param forkOutcomeIndex The branch taken by the child: 0 or 1. + * @return newHistory The child universe's history bitmap. + * @return newForkDepth The child universe's fork depth (`forkDepth + 1`). + */ + function appendHistory(bytes32 history, uint16 forkDepth, uint8 forkOutcomeIndex) + internal + pure + returns (bytes32 newHistory, uint16 newForkDepth) + { + if ((forkDepth >= MAX_FORK_DEPTH)) { + revert ForkDepthOverflow(); + } + if (!isEmptyAfterDepth(history, forkDepth)) { + revert HistoryAndDepthMismatch(); + } + if (forkOutcomeIndex > 1) { + revert InvalidForkOutcomeIndex(); + } + newForkDepth = forkDepth + 1; + newHistory = history | bytes32(uint256(forkOutcomeIndex) << (MAX_FORK_DEPTH - newForkDepth)); + } + + /** + * @notice Returns whether one universe is an ancestor of (or equal to) another, i.e. whether the + * ancestor's path is a prefix of the descendant's path. + * @dev O(1): compares the top `ancestorForkDepth` bits of both histories. The genesis universe + * (depth 0) is an ancestor of every universe. Reverts with {ForkDepthOverflow} if either + * depth exceeds {MAX_FORK_DEPTH}, or {HistoryAndDepthMismatch} if either history has bits + * set below its declared depth. + * @param ancestorHistory The candidate ancestor's history bitmap. + * @param ancestorForkDepth The candidate ancestor's fork depth. + * @param descendantHistory The candidate descendant's history bitmap. + * @param descendantForkDepth The candidate descendant's fork depth. + * @return True if the ancestor's path is a prefix of the descendant's path (equal paths included). + */ + function isAncestor( + bytes32 ancestorHistory, + uint16 ancestorForkDepth, + bytes32 descendantHistory, + uint16 descendantForkDepth + ) internal pure returns (bool) { + // Checks for malformed histories revert because that's a sanity check, + // it should not happen in practice if the histories are generated by the system. + if ((ancestorForkDepth > MAX_FORK_DEPTH) || (descendantForkDepth > MAX_FORK_DEPTH)) { + revert ForkDepthOverflow(); + } + if (!isEmptyAfterDepth(ancestorHistory, ancestorForkDepth)) { + revert HistoryAndDepthMismatch(); + } + if (!isEmptyAfterDepth(descendantHistory, descendantForkDepth)) { + revert HistoryAndDepthMismatch(); + } + // A deeper path can never be a prefix of a shallower one. + if (ancestorForkDepth > descendantForkDepth) { + return false; + } + // Compare the top `ancestorForkDepth` bits. At depth 0 the mask is 0 (shift by 256), + // so genesis (history 0) matches every descendant. + uint256 shift = MAX_FORK_DEPTH - ancestorForkDepth; + uint256 mask = uint256(type(uint256).max) << shift; + return ancestorHistory == (descendantHistory & bytes32(mask)); + } + + /** + * @notice Returns whether a history has no bits set below its declared depth, i.e. only the top + * `depth` bits are used as the path. + * @dev This is the well-formedness invariant for a (history, depth) pair. At `depth == 0` it + * requires `history == 0`; at `depth == MAX_FORK_DEPTH` the whole word is path so it is + * always true (the mask shifts to 0). + * The check that depth does not exceed {MAX_FORK_DEPTH} is left to the caller. + * @param history The history bitmap to check. + * @param depth The declared fork depth (number of leading path bits). + * @return True if all bits below the top `depth` bits are zero. + */ + function isEmptyAfterDepth(bytes32 history, uint16 depth) internal pure returns (bool) { + uint256 mask = uint256(type(uint256).max) >> depth; + return (history & bytes32(mask)) == 0; + } +} diff --git a/test/unit/LibHistory.t.sol b/test/unit/LibHistory.t.sol new file mode 100644 index 0000000..e07e98d --- /dev/null +++ b/test/unit/LibHistory.t.sol @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.35; + +import { Test } from "forge-std/Test.sol"; +import { LibHistory } from "src/libraries/LibHistory.sol"; +import "forge-std/console.sol"; + +// Test for the LibHistory library (path-of-inheritance bitmap). +// +// Encoding: MSB-first / left-aligned single-bit path. +// + +contract LibHistoryTest is Test { + /* ----------------------------------- appendBit ---------------------------------- */ + + /// genesis (history 0, depth 0) -> child on branch 0 and branch 1 produce distinct (history, depth). + function test_appendHistory_genesisChildren_distinct() public { + (bytes32 h0, uint16 d0) = LibHistory.appendHistory(bytes32(0), 0, 0); + (bytes32 h1, uint16 d1) = LibHistory.appendHistory(bytes32(0), 0, 1); + assertEq(d0, 1); + assertEq(d1, 1); + assertEq(h0, 0x0000000000000000000000000000000000000000000000000000000000000000); // 0.... + assertEq(h1, 0x8000000000000000000000000000000000000000000000000000000000000000); // 1.... + console.logBytes(abi.encodePacked(h0)); + console.logBytes(abi.encodePacked(h1)); + assertTrue(h0 != h1); + } + + /// chained appends 0->1->1->0->1->0 build the path "011010" at the top 6 bits + function test_appendHistory_chainedPath() public { + // build path b0..b5 = 0,1,1,0,1,0 ; expect history top byte == 0x68, depth == 6, + // and (history >> (256 - 6)) == 0b011010 (== 26). + bytes32 history = bytes32(0); // genesis + uint16 depth = 0; // genesis + (history, depth) = LibHistory.appendHistory(history, depth, 0); // 0 + (history, depth) = LibHistory.appendHistory(history, depth, 1); // 01 + (history, depth) = LibHistory.appendHistory(history, depth, 1); // 011 + (history, depth) = LibHistory.appendHistory(history, depth, 0); // 0110 + (history, depth) = LibHistory.appendHistory(history, depth, 1); // 01101 + (history, depth) = LibHistory.appendHistory(history, depth, 0); // 011010 + assertEq(depth, 6); + console.logBytes(abi.encodePacked(history)); + assertEq(history, 0x6800000000000000000000000000000000000000000000000000000000000000); // 011010..... + } + + /// appends history when the MAX_FORK_DEPTH is not exceeded. + function test_appendHistory_atMaxDepth() public { + bytes32 history = bytes32(0); + uint16 depth = 255; + (history, depth) = LibHistory.appendHistory(history, depth, 0); + assertEq(depth, 256); + } + + /// reverts when parentDepth >= MAX_FORK_DEPTH (256). + function test_appendHistory_revertsAtMaxDepth() public { + vm.expectRevert(LibHistory.ForkDepthOverflow.selector); + LibHistory.appendHistory(bytes32(0), 256, 0); + } + + /// reverts for a non-binary branch value (b > 1). + function test_appendHistory_revertsOnInvalidBranch() public { + vm.expectRevert(LibHistory.InvalidForkOutcomeIndex.selector); + LibHistory.appendHistory(bytes32(0), 0, 2); + } + + // reverts if the history is not empty after the given depth. + function test_appendHistory_revertsOnNonEmptyAfterDepth() public { + bytes32 history = 0xF000000000000000000000000000000000000000000000000000000000000000; // 111100.... + uint16 depth = 3; + vm.expectRevert(LibHistory.HistoryAndDepthMismatch.selector); + LibHistory.appendHistory(history, depth, 0); + depth = 4; // correct depth + LibHistory.appendHistory(history, depth, 0); + } + + /* ----------------------------------- isAncestor --------------------------------- */ + + /// genesis (depth 0) is an ancestor of every universe, including itself. + function test_isAncestor_genesisIsAncestorOfAll() public { + bytes32 anyHistory = 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef; + uint16 anyDepth = 256; + assertTrue(LibHistory.isAncestor(bytes32(0), 0, anyHistory, anyDepth)); + bytes32 otherHistory = 0x1200000000000000000000000000000000000000000000000000000000000000; + uint16 otherDepth = 8; + assertTrue(LibHistory.isAncestor(bytes32(0), 0, otherHistory, otherDepth)); + // Check genesis is ancestor of itself + assertTrue(LibHistory.isAncestor(bytes32(0), 0, bytes32(0), 0)); + } + + /// "0110" is an ancestor of "011010", but "0111" is not. + function test_isAncestor_prefixMatchAndMismatch() public { + // anc0110 (depth 4), desc011010 (depth 6) -> true + bytes32 ancestorHistory = 0x6000000000000000000000000000000000000000000000000000000000000000; // 0110.... + uint16 ancestorDepth = 4; + bytes32 descendantHistory = 0x6800000000000000000000000000000000000000000000000000000000000000; // 011010... + uint16 descendantDepth = 6; + assertTrue(LibHistory.isAncestor(ancestorHistory, ancestorDepth, descendantHistory, descendantDepth)); + + // anc0111 (depth 4), desc011010 (depth 6) -> false + ancestorHistory = 0x7000000000000000000000000000000000000000000000000000000000000000; // 0111.... + ancestorDepth = 4; + descendantHistory = 0x6800000000000000000000000000000000000000000000000000000000000000; // 011010... + descendantDepth = 6; + assertFalse(LibHistory.isAncestor(ancestorHistory, ancestorDepth, descendantHistory, descendantDepth)); + } + + /// equal paths are ancestor-or-equal (true). + function test_isAncestor_equalPathsAreAncestors() public { + bytes32 history = 0x6000000000000000000000000000000000000000000000000000000000000000; // 0110.... + uint16 depth = 4; + assertTrue(LibHistory.isAncestor(history, depth, history, depth)); + } + + /// a sibling is not an ancestor (false). + function test_isAncestor_siblingIsNotAncestor() public { + bytes32 ancestorHistory = 0x6800000000000000000000000000000000000000000000000000000000000000; // 0110.... + uint16 ancestorDepth = 6; + bytes32 descendantHistory = 0x6C00000000000000000000000000000000000000000000000000000000000000; // 0110.... + uint16 descendantDepth = 6; + assertFalse(LibHistory.isAncestor(ancestorHistory, ancestorDepth, descendantHistory, descendantDepth)); + } + + /// a deeper "ancestor" than the descendant can never be an ancestor (false). + function test_isAncestor_deeperThanDescendantIsFalse() public { + bytes32 ancestorHistory = 0x6000000000000000000000000000000000000000000000000000000000000000; // 0110.... + uint16 ancestorDepth = 6; + bytes32 descendantHistory = 0x6000000000000000000000000000000000000000000000000000000000000000; // 0110.... + uint16 descendantDepth = 4; + assertFalse(LibHistory.isAncestor(ancestorHistory, ancestorDepth, descendantHistory, descendantDepth)); + } + + /// boundary: ancestorDepth == 256 (full word) compares the entire path without reverting. + function test_isAncestor_depth256Boundary() public { + bytes32 ancestorHistory = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF; // 256 bits + uint16 ancestorDepth = 256; + bytes32 descendantHistory = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF; // 256 bits + uint16 descendantDepth = 256; + assertTrue(LibHistory.isAncestor(ancestorHistory, ancestorDepth, descendantHistory, descendantDepth)); + } + + /// all-zero-branch universes are distinguished only by depth: "00" is an ancestor of "00000", + /// the reverse is not, and "000" is not an ancestor of the same-depth "001". + function test_isAncestor_allZeroBranchesDisambiguatedByDepth() public { + // "00" (depth 2) is an ancestor of "00000" (depth 5) + assertTrue(LibHistory.isAncestor(bytes32(0), 2, bytes32(0), 5)); + // the deeper all-zero path is not an ancestor of the shorter one + assertFalse(LibHistory.isAncestor(bytes32(0), 5, bytes32(0), 2)); + // "000" is not an ancestor of the same-depth sibling "001" + bytes32 oneAtThird = 0x2000000000000000000000000000000000000000000000000000000000000000; // 001.... + assertFalse(LibHistory.isAncestor(bytes32(0), 3, oneAtThird, 3)); + } + + /// reverts when the ancestor history has bits set below its depth (malformed: history/depth mismatch). + function test_isAncestor_revertsOnMalformedAncestor() public { + // depth 0 must mean an all-zero history; a non-zero genesis history is malformed. + vm.expectRevert(LibHistory.HistoryAndDepthMismatch.selector); + LibHistory.isAncestor( + 0x8000000000000000000000000000000000000000000000000000000000000000, 0, bytes32(0), 5 + ); + // a stray bit below the claimed depth is also malformed. + bytes32 strayBelowDepth = 0x6100000000000000000000000000000000000000000000000000000000000000; // 0110...1... + vm.expectRevert(LibHistory.HistoryAndDepthMismatch.selector); + LibHistory.isAncestor( + strayBelowDepth, 4, 0x6100000000000000000000000000000000000000000000000000000000000000, 8 + ); + } + + /// reverts when the descendant history has bits set below its depth (malformed: history/depth mismatch). + function test_isAncestor_revertsOnMalformedDescendant() public { + bytes32 ancestorHistory = 0x6000000000000000000000000000000000000000000000000000000000000000; // 0110.... + // top 6 bits are a clean "011010" but a stray low bit makes depth 6 inconsistent. + bytes32 strayLowBit = 0x6800000000000000000000000000000000000000000000000000000000000001; + vm.expectRevert(LibHistory.HistoryAndDepthMismatch.selector); + LibHistory.isAncestor(ancestorHistory, 4, strayLowBit, 6); + } + + /* --------------------------------- isEmptyAfterDepth ---------------------------- */ + + function test_isEmptyAfterDepth() public { + // depth 0 requires an entirely empty history. + assertTrue(LibHistory.isEmptyAfterDepth(bytes32(0), 0)); + assertFalse( + LibHistory.isEmptyAfterDepth(0x8000000000000000000000000000000000000000000000000000000000000000, 0) + ); + // a single top bit is consumed by depth 1, leaving the rest empty. + assertTrue( + LibHistory.isEmptyAfterDepth(0x8000000000000000000000000000000000000000000000000000000000000000, 1) + ); + // "011010" is clean at depth 6 but a stray low bit is not. + assertTrue( + LibHistory.isEmptyAfterDepth(0x6800000000000000000000000000000000000000000000000000000000000000, 6) + ); + assertFalse( + LibHistory.isEmptyAfterDepth(0x6800000000000000000000000000000000000000000000000000000000000001, 6) + ); + // at full depth the whole word is path, so any history is "empty after". + assertTrue( + LibHistory.isEmptyAfterDepth(bytes32(type(uint256).max), 256) + ); + } + + /* ------------------------------------- fuzz ------------------------------------- */ + + /// a freshly-appended child always has its parent as an ancestor. + function testFuzz_appendThenAncestor(bytes32 parentHistory, uint16 parentDepth, bool branch) public { + parentDepth = uint16(bound(parentDepth, 0, 255)); + parentHistory = _clearHistoryAfterDepth(parentHistory, parentDepth); + console.logBytes(abi.encodePacked(parentHistory)); + (bytes32 childHistory, uint16 childDepth) = + LibHistory.appendHistory(parentHistory, parentDepth, branch ? 1 : 0); + assertTrue(LibHistory.isAncestor(parentHistory, parentDepth, childHistory, childDepth)); + } + + function _clearHistoryAfterDepth(bytes32 history, uint16 depth) internal pure returns (bytes32) { + uint256 shift = 256 - depth; + uint256 mask = uint256(type(uint256).max) << shift; + return history & bytes32(mask); + } +} From b731ffcda5479ea835dd405209f60e0cdb1bd955 Mon Sep 17 00:00:00 2001 From: Tanya Bushenyova Date: Wed, 1 Jul 2026 13:20:29 +0300 Subject: [PATCH 2/5] Add isCanonical --- src/Multiverse.sol | 32 ++++++++++++++++++++++++-------- src/libraries/LibHistory.sol | 11 ++++++----- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/Multiverse.sol b/src/Multiverse.sol index 4b1ea82..7712513 100644 --- a/src/Multiverse.sol +++ b/src/Multiverse.sol @@ -82,6 +82,9 @@ contract Multiverse is ReentrancyGuard { // The depth of the universe in the fork tree // Genesis universe has depth 0, its children have depth 1, etc. Max 256. uint16 forkDepth; + // Whether this universe lies on the canonical timeline (the genesis -> favoriteChild -> ... chain). + // Genesis is canonical; on a fork, only the designated favoriteChild inherits the parent's flag. + bool isCanonical; uint248 parent; uint248 favoriteChild; uint248 heir; @@ -167,9 +170,10 @@ contract Multiverse is ReentrancyGuard { genesisUniverse.forkState = ForkState.NotForking; genesisUniverse.forkTime = uint48(block.timestamp); genesisUniverse.heir = 0; - // Genesis is the root of the fork tree: an empty inheritance path. + // Genesis is the root of the fork tree: an empty inheritance path, and the root of the canonical timeline. genesisUniverse.history = 0; genesisUniverse.forkDepth = 0; + genesisUniverse.isCanonical = true; genesisUniverse.forkQuery = 0; // TODO: fill in the correct supply genesisUniverse.supplyBeforeFork = ZOLTAR.getUniverseTheoreticalSupply(_initialZoltarUniverseId); @@ -286,7 +290,7 @@ contract Multiverse is ReentrancyGuard { } function resolve(uint248 universeId, uint256 queryId) external nonReentrant { - (uint248 activeUniverseId, Universe storage universe, ILituusRep repToken) = + (uint248 activeUniverseId, Universe storage activeUniverse, ILituusRep repToken) = _getActiveUniverseAndRepToken(universeId); Query storage query = queries[queryId]; @@ -304,9 +308,7 @@ contract Multiverse is ReentrancyGuard { if (_findAncestorResolution(activeUniverseId, queryId) != UNRESOLVED) revert QueryAlreadyResolved(); // If the report period has passed and the query was not reported on then resolve the query as INVALID resolution.outcome = INVALID; - // Record this universe as one where the query is resolved, so heir universes can find it - // as an ancestor resolution via getOutcome(). - query.resolvedUniverses.push(activeUniverseId); + _recordResolvedUniverse(queryId, activeUniverseId, activeUniverse.isCanonical); emit QueryResolved(msg.sender, activeUniverseId, queryId, INVALID); // TODO: Payout to the resolver, another clock auction } else if (resolution.stakes.length > 0) { @@ -315,9 +317,7 @@ contract Multiverse is ReentrancyGuard { // TODO: Unless the query is 1 step from fork threshold, then we should wait for the fork to finish uint8 outcome = _calculateOutcomeAndEscalationPayoffs(activeUniverseId, queryId); resolution.outcome = outcome; - // Record this universe as one where the query is resolved, so heir universes can find it - // as an ancestor resolution via getOutcome(). - query.resolvedUniverses.push(activeUniverseId); + _recordResolvedUniverse(queryId, activeUniverseId, activeUniverse.isCanonical); emit QueryResolved(msg.sender, activeUniverseId, queryId, outcome); // TODO: Payouts } else { @@ -541,6 +541,22 @@ contract Multiverse is ReentrancyGuard { return UNRESOLVED; } + /** + * @notice Records `universeId` as a universe where `queryId` is resolved. + * @dev Keeps a canonical universe's entry at index 0 so `_findAncestorResolution` finds the canonical + * resolution first (the common-case read). Safe because the canonical chain is a single linear + * path, so at most one canonical universe ever resolves a given query. Saves gas during lookups. + */ + function _recordResolvedUniverse(uint256 queryId, uint248 universeId, bool isCanonical) internal { + uint248[] storage resolvedUniverses = queries[queryId].resolvedUniverses; + if (isCanonical && resolvedUniverses.length > 0) { + resolvedUniverses.push(resolvedUniverses[0]); // move current head to the tail + resolvedUniverses[0] = universeId; // canonical entry takes index 0 + } else { + resolvedUniverses.push(universeId); + } + } + function _requiredStakeAmount(uint248 universeId, uint256 queryId) public view returns (uint256) { QueryResolution storage resolution = queryResolutions[universeId][queryId]; uint256 numberOfStakes = resolution.stakes.length; diff --git a/src/libraries/LibHistory.sol b/src/libraries/LibHistory.sol index 48a32c8..e8ca925 100644 --- a/src/libraries/LibHistory.sol +++ b/src/libraries/LibHistory.sol @@ -7,9 +7,10 @@ pragma solidity ^0.8.35; * enabling O(1) ancestor checks via prefix matching. * @dev Encoding is MSB-first / left-aligned. The genesis universe has `history == 0` and * `forkDepth == 0`. Each binary fork appends one bit (the branch taken, 0 or 1) to the - * path; the bit for fork level `i` (0-based) lives at bit `255 - i`, so the oldest fork is - * the most-significant bit (i=0 -> bit 255, i=255 -> bit 0). A path of depth `d` therefore - * occupies the top `d` bits and the remaining `256 - d` low bits MUST be zero. + * path; the bit for fork depth `i` (1-based because depth 0 is the genesis) lives at bit + * `256 - i`, so the oldest fork is the most-significant bit (i=1 -> bit 255, i=256 -> bit 0). + * A path of depth `d` therefore occupies the top `d` bits and the remaining `256 - d` + * low bits MUST be zero. * * `forkDepth` is required to disambiguate paths: a branch-0 fork sets no bit, so a history * alone cannot distinguish e.g. "0" (depth 1) from genesis (depth 0) — the depth carries @@ -108,8 +109,8 @@ library LibHistory { * @notice Returns whether a history has no bits set below its declared depth, i.e. only the top * `depth` bits are used as the path. * @dev This is the well-formedness invariant for a (history, depth) pair. At `depth == 0` it - * requires `history == 0`; at `depth == MAX_FORK_DEPTH` the whole word is path so it is - * always true (the mask shifts to 0). + * requires `history == 0`; at `depth == MAX_FORK_DEPTH` the whole bytes32 value is path + * so it is always true (the mask shifts to 0). * The check that depth does not exceed {MAX_FORK_DEPTH} is left to the caller. * @param history The history bitmap to check. * @param depth The declared fork depth (number of leading path bits). From 75ab5583030a6e9b1e559d3b4065da7afb76f1a9 Mon Sep 17 00:00:00 2001 From: Tanya Bushenyova Date: Wed, 1 Jul 2026 14:09:16 +0300 Subject: [PATCH 3/5] Fixes after review --- src/Multiverse.sol | 10 ++++++---- test/unit/LibHistory.t.sol | 38 +++++++++----------------------------- 2 files changed, 15 insertions(+), 33 deletions(-) diff --git a/src/Multiverse.sol b/src/Multiverse.sol index 7712513..0e19f92 100644 --- a/src/Multiverse.sol +++ b/src/Multiverse.sol @@ -290,8 +290,7 @@ contract Multiverse is ReentrancyGuard { } function resolve(uint248 universeId, uint256 queryId) external nonReentrant { - (uint248 activeUniverseId, Universe storage activeUniverse, ILituusRep repToken) = - _getActiveUniverseAndRepToken(universeId); + (uint248 activeUniverseId, Universe storage activeUniverse,) = _getActiveUniverseAndRepToken(universeId); Query storage query = queries[queryId]; if (query.numberOfOutcomes == 0) revert InvalidQuery(); @@ -511,6 +510,10 @@ contract Multiverse is ReentrancyGuard { // report()/resolve() record resolutions. Reverts only if the universe does not exist; outcomes // remain readable while the universe is forking (no fork-state check, unlike report/resolve). (uint248 activeUniverseId,) = _getActiveUniverse(universeId); + if (activeUniverseId != universeId) { + uint8 heirOutcome = queryResolutions[activeUniverseId][queryId].outcome; + if (heirOutcome != UNRESOLVED) return heirOutcome; + } // Otherwise inherit the resolution from an ancestor universe, if any. return _findAncestorResolution(activeUniverseId, queryId); @@ -519,8 +522,7 @@ contract Multiverse is ReentrancyGuard { /** * @notice Returns the outcome of the first ancestor of `universeId` that has resolved `queryId`. * @dev Scans the query's recorded resolution universes and prefix-matches their inheritance path - * against `universeId`'s path. Does NOT consider a resolution in `universeId` itself — callers - * handle the local resolution separately. A query is resolved at most once along any single + * against `universeId`'s path. A query is resolved at most once along any single * lineage, so the first ancestor match is authoritative. Returns UNRESOLVED if no ancestor * has resolved the query. */ diff --git a/test/unit/LibHistory.t.sol b/test/unit/LibHistory.t.sol index e07e98d..206c32b 100644 --- a/test/unit/LibHistory.t.sol +++ b/test/unit/LibHistory.t.sol @@ -3,7 +3,6 @@ pragma solidity ^0.8.35; import { Test } from "forge-std/Test.sol"; import { LibHistory } from "src/libraries/LibHistory.sol"; -import "forge-std/console.sol"; // Test for the LibHistory library (path-of-inheritance bitmap). // @@ -11,7 +10,7 @@ import "forge-std/console.sol"; // contract LibHistoryTest is Test { - /* ----------------------------------- appendBit ---------------------------------- */ + /* ----------------------------------- appendHistory ---------------------------------- */ /// genesis (history 0, depth 0) -> child on branch 0 and branch 1 produce distinct (history, depth). function test_appendHistory_genesisChildren_distinct() public { @@ -21,8 +20,6 @@ contract LibHistoryTest is Test { assertEq(d1, 1); assertEq(h0, 0x0000000000000000000000000000000000000000000000000000000000000000); // 0.... assertEq(h1, 0x8000000000000000000000000000000000000000000000000000000000000000); // 1.... - console.logBytes(abi.encodePacked(h0)); - console.logBytes(abi.encodePacked(h1)); assertTrue(h0 != h1); } @@ -39,7 +36,6 @@ contract LibHistoryTest is Test { (history, depth) = LibHistory.appendHistory(history, depth, 1); // 01101 (history, depth) = LibHistory.appendHistory(history, depth, 0); // 011010 assertEq(depth, 6); - console.logBytes(abi.encodePacked(history)); assertEq(history, 0x6800000000000000000000000000000000000000000000000000000000000000); // 011010..... } @@ -154,15 +150,11 @@ contract LibHistoryTest is Test { function test_isAncestor_revertsOnMalformedAncestor() public { // depth 0 must mean an all-zero history; a non-zero genesis history is malformed. vm.expectRevert(LibHistory.HistoryAndDepthMismatch.selector); - LibHistory.isAncestor( - 0x8000000000000000000000000000000000000000000000000000000000000000, 0, bytes32(0), 5 - ); + LibHistory.isAncestor(0x8000000000000000000000000000000000000000000000000000000000000000, 0, bytes32(0), 5); // a stray bit below the claimed depth is also malformed. bytes32 strayBelowDepth = 0x6100000000000000000000000000000000000000000000000000000000000000; // 0110...1... vm.expectRevert(LibHistory.HistoryAndDepthMismatch.selector); - LibHistory.isAncestor( - strayBelowDepth, 4, 0x6100000000000000000000000000000000000000000000000000000000000000, 8 - ); + LibHistory.isAncestor(strayBelowDepth, 4, 0x6100000000000000000000000000000000000000000000000000000000000000, 8); } /// reverts when the descendant history has bits set below its depth (malformed: history/depth mismatch). @@ -179,24 +171,14 @@ contract LibHistoryTest is Test { function test_isEmptyAfterDepth() public { // depth 0 requires an entirely empty history. assertTrue(LibHistory.isEmptyAfterDepth(bytes32(0), 0)); - assertFalse( - LibHistory.isEmptyAfterDepth(0x8000000000000000000000000000000000000000000000000000000000000000, 0) - ); + assertFalse(LibHistory.isEmptyAfterDepth(0x8000000000000000000000000000000000000000000000000000000000000000, 0)); // a single top bit is consumed by depth 1, leaving the rest empty. - assertTrue( - LibHistory.isEmptyAfterDepth(0x8000000000000000000000000000000000000000000000000000000000000000, 1) - ); + assertTrue(LibHistory.isEmptyAfterDepth(0x8000000000000000000000000000000000000000000000000000000000000000, 1)); // "011010" is clean at depth 6 but a stray low bit is not. - assertTrue( - LibHistory.isEmptyAfterDepth(0x6800000000000000000000000000000000000000000000000000000000000000, 6) - ); - assertFalse( - LibHistory.isEmptyAfterDepth(0x6800000000000000000000000000000000000000000000000000000000000001, 6) - ); + assertTrue(LibHistory.isEmptyAfterDepth(0x6800000000000000000000000000000000000000000000000000000000000000, 6)); + assertFalse(LibHistory.isEmptyAfterDepth(0x6800000000000000000000000000000000000000000000000000000000000001, 6)); // at full depth the whole word is path, so any history is "empty after". - assertTrue( - LibHistory.isEmptyAfterDepth(bytes32(type(uint256).max), 256) - ); + assertTrue(LibHistory.isEmptyAfterDepth(bytes32(type(uint256).max), 256)); } /* ------------------------------------- fuzz ------------------------------------- */ @@ -205,9 +187,7 @@ contract LibHistoryTest is Test { function testFuzz_appendThenAncestor(bytes32 parentHistory, uint16 parentDepth, bool branch) public { parentDepth = uint16(bound(parentDepth, 0, 255)); parentHistory = _clearHistoryAfterDepth(parentHistory, parentDepth); - console.logBytes(abi.encodePacked(parentHistory)); - (bytes32 childHistory, uint16 childDepth) = - LibHistory.appendHistory(parentHistory, parentDepth, branch ? 1 : 0); + (bytes32 childHistory, uint16 childDepth) = LibHistory.appendHistory(parentHistory, parentDepth, branch ? 1 : 0); assertTrue(LibHistory.isAncestor(parentHistory, parentDepth, childHistory, childDepth)); } From c29aba76a83b43e58c0dfb7c2e3fb8d6ae8360f1 Mon Sep 17 00:00:00 2001 From: Tanya Bushenyova Date: Wed, 1 Jul 2026 22:11:07 +0300 Subject: [PATCH 4/5] Add comments --- src/libraries/LibHistory.sol | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/libraries/LibHistory.sol b/src/libraries/LibHistory.sol index e8ca925..6d185cd 100644 --- a/src/libraries/LibHistory.sol +++ b/src/libraries/LibHistory.sol @@ -51,12 +51,15 @@ library LibHistory { pure returns (bytes32 newHistory, uint16 newForkDepth) { + // Provided fork depth should be strictly less than MAX_FORK_DEPTH, because the new depth will be forkDepth + 1. if ((forkDepth >= MAX_FORK_DEPTH)) { revert ForkDepthOverflow(); } + // Check that the history is well-formed: all bits below the top `forkDepth` bits must be zero. if (!isEmptyAfterDepth(history, forkDepth)) { revert HistoryAndDepthMismatch(); } + // Only two branches are valid for the binary fork: 0 or 1. if (forkOutcomeIndex > 1) { revert InvalidForkOutcomeIndex(); } @@ -100,8 +103,17 @@ library LibHistory { } // Compare the top `ancestorForkDepth` bits. At depth 0 the mask is 0 (shift by 256), // so genesis (history 0) matches every descendant. + // The shift is the number of low bits to clear, so the mask is all 1s in the top `ancestorForkDepth` bits and + // 0s below. uint256 shift = MAX_FORK_DEPTH - ancestorForkDepth; + // The mask is 1s in the top `ancestorForkDepth` bits and 0s below (to get the prefix for comparison). uint256 mask = uint256(type(uint256).max) << shift; + // ANDing the mask with the descendant history clears all bits below that depth. + // Compare the masked descendant history to the ancestor history: + // if they are equal, the ancestor's path is a prefix of the descendant's path. + // Example: ancestor depth 3, history 010... (top 3 bits), descendant depth 5, history 01010... (top 5 bits). + // shift = 256 - 3 = 253, mask = 111000...0 (top 3 bits are set to 1, the rest are set to 0) + // descendantHistory & mask = 01010... & 11100.... = 01000..., this prefix equals ancestorHistory. return ancestorHistory == (descendantHistory & bytes32(mask)); } @@ -117,7 +129,15 @@ library LibHistory { * @return True if all bits below the top `depth` bits are zero. */ function isEmptyAfterDepth(bytes32 history, uint16 depth) internal pure returns (bool) { + // The mask is 0s in the top `depth` bits and 1s below (to check that all lower bits are zero). + // Example: depth 3, mask = 000111...1 (top 3 bits are set to 0, the rest are set to 1) uint256 mask = uint256(type(uint256).max) >> depth; + // ANDing the mask with the history clears all bits above that depth. + // If the result is 0, then all bits below that depth are zero, so the history is well-formed. + // Example (positive): history = 01000..., depth is 3, mask = 000111...1, history & mask = 00000..., which is + // zero, so the history is well-formed. + // Example (negative): history = 01010..., depth is 3, mask = 000111...1, + // history & mask = 00010..., which is not zero, so the history is malformed. return (history & bytes32(mask)) == 0; } } From d6d0cd1a0b4a18ca40d014a65a6b7fbbaac63f96 Mon Sep 17 00:00:00 2001 From: Tanya Bushenyova Date: Thu, 2 Jul 2026 12:15:20 +0300 Subject: [PATCH 5/5] Remove validation for empty history after depth --- src/Multiverse.sol | 6 ++-- src/libraries/LibHistory.sol | 62 +++++++-------------------------- test/unit/LibHistory.t.sol | 67 ++++++------------------------------ 3 files changed, 26 insertions(+), 109 deletions(-) diff --git a/src/Multiverse.sol b/src/Multiverse.sol index 0e19f92..40748a7 100644 --- a/src/Multiverse.sol +++ b/src/Multiverse.sol @@ -196,8 +196,7 @@ contract Multiverse is ReentrancyGuard { /* ============================================= QUERY FUNCTIONS ============================================= */ function createQuery(uint248 universeId, string calldata question, uint8 numberOfOutcomes) external nonReentrant { - (uint248 activeUniverseId, Universe storage universe, ILituusRep repToken) = - _getActiveUniverseAndRepToken(universeId); + (uint248 activeUniverseId,, ILituusRep repToken) = _getActiveUniverseAndRepToken(universeId); // Validate the question and number of outcomes if (numberOfOutcomes <= 2) revert InvalidNumberOfOutcomes(); @@ -231,8 +230,7 @@ contract Multiverse is ReentrancyGuard { function report(uint248 universeId, uint256 queryId, uint8 outcome) external nonReentrant { // Check all conditions (universe exists, query exists, outcome is valid, report is within time, etc.) - (uint248 activeUniverseId, Universe storage universe, ILituusRep repToken) = - _getActiveUniverseAndRepToken(universeId); + (uint248 activeUniverseId,, ILituusRep repToken) = _getActiveUniverseAndRepToken(universeId); Query storage query = queries[queryId]; if (query.numberOfOutcomes == 0) revert InvalidQuery(); diff --git a/src/libraries/LibHistory.sol b/src/libraries/LibHistory.sol index 6d185cd..5e9f978 100644 --- a/src/libraries/LibHistory.sol +++ b/src/libraries/LibHistory.sol @@ -17,10 +17,14 @@ pragma solidity ^0.8.35; * that information. Ancestry is "A is an ancestor-or-equal of B if A's path is a prefix of * B's path", which reduces to comparing the top `A.forkDepth` bits. * - * All functions are `pure` and operate only on their arguments. Malformed inputs (a history - * with bits set below its depth, or a depth above {MAX_FORK_DEPTH}) revert rather than - * returning a value: these are sanity checks that should never trigger for histories built - * by the system through {appendHistory}. + * All functions are `pure` and operate only on their arguments. Inputs with a depth above + * {MAX_FORK_DEPTH}) revert rather than returning a value: this is a sanity check that + * should never trigger for histories built by the system through {appendHistory}. + * + * Bits below the declared depth MUST be zero, but this is not enforced by the library: it is the caller's + * responsibility to ensure that the history is well-formed. The library does not have a validation + * for this because of gas optimization. It is intended to be used by the Lituus Multiverse contract, + * which maintains a consistent structure of universe histories. */ library LibHistory { /// @notice Maximum fork depth. A path can hold at most 256 single-bit fork branches in a bytes32. @@ -30,16 +34,13 @@ library LibHistory { error ForkDepthOverflow(); /// @notice Thrown when a fork outcome index is not a valid binary branch (must be 0 or 1). error InvalidForkOutcomeIndex(); - /// @notice Thrown when a history has bits set below its declared depth (history/depth mismatch). - error HistoryAndDepthMismatch(); /** * @notice Appends a fork branch to a universe's path, producing the child universe's history. - * @dev Reverts with {ForkDepthOverflow} if the parent is at or above {MAX_FORK_DEPTH}, - * {HistoryAndDepthMismatch} if `history` has bits set below `forkDepth`, and - * {InvalidForkOutcomeIndex} if `forkOutcomeIndex > 1`. The new branch bit is placed at - * bit `256 - newForkDepth` (MSB-first); a branch of 0 leaves the bitmap unchanged and is - * distinguished only by the incremented depth. + * @dev Reverts with {ForkDepthOverflow} if the parent is at or above {MAX_FORK_DEPTH} and it's + * impossible to append a value, and {InvalidForkOutcomeIndex} if `forkOutcomeIndex > 1`. + * The new branch bit is placed at bit `256 - newForkDepth` (MSB-first); a branch of 0 + * leaves the bitmap unchanged and is distinguished only by the incremented depth. * @param history The parent universe's history bitmap. * @param forkDepth The parent universe's fork depth (number of branches in `history`). * @param forkOutcomeIndex The branch taken by the child: 0 or 1. @@ -55,10 +56,6 @@ library LibHistory { if ((forkDepth >= MAX_FORK_DEPTH)) { revert ForkDepthOverflow(); } - // Check that the history is well-formed: all bits below the top `forkDepth` bits must be zero. - if (!isEmptyAfterDepth(history, forkDepth)) { - revert HistoryAndDepthMismatch(); - } // Only two branches are valid for the binary fork: 0 or 1. if (forkOutcomeIndex > 1) { revert InvalidForkOutcomeIndex(); @@ -72,8 +69,7 @@ library LibHistory { * ancestor's path is a prefix of the descendant's path. * @dev O(1): compares the top `ancestorForkDepth` bits of both histories. The genesis universe * (depth 0) is an ancestor of every universe. Reverts with {ForkDepthOverflow} if either - * depth exceeds {MAX_FORK_DEPTH}, or {HistoryAndDepthMismatch} if either history has bits - * set below its declared depth. + * depth exceeds {MAX_FORK_DEPTH}. * @param ancestorHistory The candidate ancestor's history bitmap. * @param ancestorForkDepth The candidate ancestor's fork depth. * @param descendantHistory The candidate descendant's history bitmap. @@ -86,17 +82,9 @@ library LibHistory { bytes32 descendantHistory, uint16 descendantForkDepth ) internal pure returns (bool) { - // Checks for malformed histories revert because that's a sanity check, - // it should not happen in practice if the histories are generated by the system. if ((ancestorForkDepth > MAX_FORK_DEPTH) || (descendantForkDepth > MAX_FORK_DEPTH)) { revert ForkDepthOverflow(); } - if (!isEmptyAfterDepth(ancestorHistory, ancestorForkDepth)) { - revert HistoryAndDepthMismatch(); - } - if (!isEmptyAfterDepth(descendantHistory, descendantForkDepth)) { - revert HistoryAndDepthMismatch(); - } // A deeper path can never be a prefix of a shallower one. if (ancestorForkDepth > descendantForkDepth) { return false; @@ -116,28 +104,4 @@ library LibHistory { // descendantHistory & mask = 01010... & 11100.... = 01000..., this prefix equals ancestorHistory. return ancestorHistory == (descendantHistory & bytes32(mask)); } - - /** - * @notice Returns whether a history has no bits set below its declared depth, i.e. only the top - * `depth` bits are used as the path. - * @dev This is the well-formedness invariant for a (history, depth) pair. At `depth == 0` it - * requires `history == 0`; at `depth == MAX_FORK_DEPTH` the whole bytes32 value is path - * so it is always true (the mask shifts to 0). - * The check that depth does not exceed {MAX_FORK_DEPTH} is left to the caller. - * @param history The history bitmap to check. - * @param depth The declared fork depth (number of leading path bits). - * @return True if all bits below the top `depth` bits are zero. - */ - function isEmptyAfterDepth(bytes32 history, uint16 depth) internal pure returns (bool) { - // The mask is 0s in the top `depth` bits and 1s below (to check that all lower bits are zero). - // Example: depth 3, mask = 000111...1 (top 3 bits are set to 0, the rest are set to 1) - uint256 mask = uint256(type(uint256).max) >> depth; - // ANDing the mask with the history clears all bits above that depth. - // If the result is 0, then all bits below that depth are zero, so the history is well-formed. - // Example (positive): history = 01000..., depth is 3, mask = 000111...1, history & mask = 00000..., which is - // zero, so the history is well-formed. - // Example (negative): history = 01010..., depth is 3, mask = 000111...1, - // history & mask = 00010..., which is not zero, so the history is malformed. - return (history & bytes32(mask)) == 0; - } } diff --git a/test/unit/LibHistory.t.sol b/test/unit/LibHistory.t.sol index 206c32b..1e07635 100644 --- a/test/unit/LibHistory.t.sol +++ b/test/unit/LibHistory.t.sol @@ -13,7 +13,7 @@ contract LibHistoryTest is Test { /* ----------------------------------- appendHistory ---------------------------------- */ /// genesis (history 0, depth 0) -> child on branch 0 and branch 1 produce distinct (history, depth). - function test_appendHistory_genesisChildren_distinct() public { + function test_appendHistory_genesisChildren_distinct() public pure { (bytes32 h0, uint16 d0) = LibHistory.appendHistory(bytes32(0), 0, 0); (bytes32 h1, uint16 d1) = LibHistory.appendHistory(bytes32(0), 0, 1); assertEq(d0, 1); @@ -24,7 +24,7 @@ contract LibHistoryTest is Test { } /// chained appends 0->1->1->0->1->0 build the path "011010" at the top 6 bits - function test_appendHistory_chainedPath() public { + function test_appendHistory_chainedPath() public pure { // build path b0..b5 = 0,1,1,0,1,0 ; expect history top byte == 0x68, depth == 6, // and (history >> (256 - 6)) == 0b011010 (== 26). bytes32 history = bytes32(0); // genesis @@ -40,7 +40,7 @@ contract LibHistoryTest is Test { } /// appends history when the MAX_FORK_DEPTH is not exceeded. - function test_appendHistory_atMaxDepth() public { + function test_appendHistory_atMaxDepth() public pure { bytes32 history = bytes32(0); uint16 depth = 255; (history, depth) = LibHistory.appendHistory(history, depth, 0); @@ -59,20 +59,10 @@ contract LibHistoryTest is Test { LibHistory.appendHistory(bytes32(0), 0, 2); } - // reverts if the history is not empty after the given depth. - function test_appendHistory_revertsOnNonEmptyAfterDepth() public { - bytes32 history = 0xF000000000000000000000000000000000000000000000000000000000000000; // 111100.... - uint16 depth = 3; - vm.expectRevert(LibHistory.HistoryAndDepthMismatch.selector); - LibHistory.appendHistory(history, depth, 0); - depth = 4; // correct depth - LibHistory.appendHistory(history, depth, 0); - } - /* ----------------------------------- isAncestor --------------------------------- */ /// genesis (depth 0) is an ancestor of every universe, including itself. - function test_isAncestor_genesisIsAncestorOfAll() public { + function test_isAncestor_genesisIsAncestorOfAll() public pure { bytes32 anyHistory = 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef; uint16 anyDepth = 256; assertTrue(LibHistory.isAncestor(bytes32(0), 0, anyHistory, anyDepth)); @@ -84,7 +74,7 @@ contract LibHistoryTest is Test { } /// "0110" is an ancestor of "011010", but "0111" is not. - function test_isAncestor_prefixMatchAndMismatch() public { + function test_isAncestor_prefixMatchAndMismatch() public pure { // anc0110 (depth 4), desc011010 (depth 6) -> true bytes32 ancestorHistory = 0x6000000000000000000000000000000000000000000000000000000000000000; // 0110.... uint16 ancestorDepth = 4; @@ -101,14 +91,14 @@ contract LibHistoryTest is Test { } /// equal paths are ancestor-or-equal (true). - function test_isAncestor_equalPathsAreAncestors() public { + function test_isAncestor_equalPathsAreAncestors() public pure { bytes32 history = 0x6000000000000000000000000000000000000000000000000000000000000000; // 0110.... uint16 depth = 4; assertTrue(LibHistory.isAncestor(history, depth, history, depth)); } /// a sibling is not an ancestor (false). - function test_isAncestor_siblingIsNotAncestor() public { + function test_isAncestor_siblingIsNotAncestor() public pure { bytes32 ancestorHistory = 0x6800000000000000000000000000000000000000000000000000000000000000; // 0110.... uint16 ancestorDepth = 6; bytes32 descendantHistory = 0x6C00000000000000000000000000000000000000000000000000000000000000; // 0110.... @@ -117,7 +107,7 @@ contract LibHistoryTest is Test { } /// a deeper "ancestor" than the descendant can never be an ancestor (false). - function test_isAncestor_deeperThanDescendantIsFalse() public { + function test_isAncestor_deeperThanDescendantIsFalse() public pure { bytes32 ancestorHistory = 0x6000000000000000000000000000000000000000000000000000000000000000; // 0110.... uint16 ancestorDepth = 6; bytes32 descendantHistory = 0x6000000000000000000000000000000000000000000000000000000000000000; // 0110.... @@ -126,7 +116,7 @@ contract LibHistoryTest is Test { } /// boundary: ancestorDepth == 256 (full word) compares the entire path without reverting. - function test_isAncestor_depth256Boundary() public { + function test_isAncestor_depth256Boundary() public pure { bytes32 ancestorHistory = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF; // 256 bits uint16 ancestorDepth = 256; bytes32 descendantHistory = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF; // 256 bits @@ -136,7 +126,7 @@ contract LibHistoryTest is Test { /// all-zero-branch universes are distinguished only by depth: "00" is an ancestor of "00000", /// the reverse is not, and "000" is not an ancestor of the same-depth "001". - function test_isAncestor_allZeroBranchesDisambiguatedByDepth() public { + function test_isAncestor_allZeroBranchesDisambiguatedByDepth() public pure { // "00" (depth 2) is an ancestor of "00000" (depth 5) assertTrue(LibHistory.isAncestor(bytes32(0), 2, bytes32(0), 5)); // the deeper all-zero path is not an ancestor of the shorter one @@ -146,45 +136,10 @@ contract LibHistoryTest is Test { assertFalse(LibHistory.isAncestor(bytes32(0), 3, oneAtThird, 3)); } - /// reverts when the ancestor history has bits set below its depth (malformed: history/depth mismatch). - function test_isAncestor_revertsOnMalformedAncestor() public { - // depth 0 must mean an all-zero history; a non-zero genesis history is malformed. - vm.expectRevert(LibHistory.HistoryAndDepthMismatch.selector); - LibHistory.isAncestor(0x8000000000000000000000000000000000000000000000000000000000000000, 0, bytes32(0), 5); - // a stray bit below the claimed depth is also malformed. - bytes32 strayBelowDepth = 0x6100000000000000000000000000000000000000000000000000000000000000; // 0110...1... - vm.expectRevert(LibHistory.HistoryAndDepthMismatch.selector); - LibHistory.isAncestor(strayBelowDepth, 4, 0x6100000000000000000000000000000000000000000000000000000000000000, 8); - } - - /// reverts when the descendant history has bits set below its depth (malformed: history/depth mismatch). - function test_isAncestor_revertsOnMalformedDescendant() public { - bytes32 ancestorHistory = 0x6000000000000000000000000000000000000000000000000000000000000000; // 0110.... - // top 6 bits are a clean "011010" but a stray low bit makes depth 6 inconsistent. - bytes32 strayLowBit = 0x6800000000000000000000000000000000000000000000000000000000000001; - vm.expectRevert(LibHistory.HistoryAndDepthMismatch.selector); - LibHistory.isAncestor(ancestorHistory, 4, strayLowBit, 6); - } - - /* --------------------------------- isEmptyAfterDepth ---------------------------- */ - - function test_isEmptyAfterDepth() public { - // depth 0 requires an entirely empty history. - assertTrue(LibHistory.isEmptyAfterDepth(bytes32(0), 0)); - assertFalse(LibHistory.isEmptyAfterDepth(0x8000000000000000000000000000000000000000000000000000000000000000, 0)); - // a single top bit is consumed by depth 1, leaving the rest empty. - assertTrue(LibHistory.isEmptyAfterDepth(0x8000000000000000000000000000000000000000000000000000000000000000, 1)); - // "011010" is clean at depth 6 but a stray low bit is not. - assertTrue(LibHistory.isEmptyAfterDepth(0x6800000000000000000000000000000000000000000000000000000000000000, 6)); - assertFalse(LibHistory.isEmptyAfterDepth(0x6800000000000000000000000000000000000000000000000000000000000001, 6)); - // at full depth the whole word is path, so any history is "empty after". - assertTrue(LibHistory.isEmptyAfterDepth(bytes32(type(uint256).max), 256)); - } - /* ------------------------------------- fuzz ------------------------------------- */ /// a freshly-appended child always has its parent as an ancestor. - function testFuzz_appendThenAncestor(bytes32 parentHistory, uint16 parentDepth, bool branch) public { + function testFuzz_appendThenAncestor(bytes32 parentHistory, uint16 parentDepth, bool branch) public pure { parentDepth = uint16(bound(parentDepth, 0, 255)); parentHistory = _clearHistoryAfterDepth(parentHistory, parentDepth); (bytes32 childHistory, uint16 childDepth) = LibHistory.appendHistory(parentHistory, parentDepth, branch ? 1 : 0);