Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
123 changes: 113 additions & 10 deletions src/Multiverse.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -78,10 +79,20 @@ 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;
// 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;
// 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;
Expand Down Expand Up @@ -159,7 +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, 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);
Expand All @@ -182,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();
Expand Down Expand Up @@ -217,14 +230,19 @@ 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();

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();
Expand Down Expand Up @@ -270,8 +288,7 @@ contract Multiverse is ReentrancyGuard {
}

function resolve(uint248 universeId, uint256 queryId) external nonReentrant {
(uint248 activeUniverseId, Universe storage universe, ILituusRep repToken) =
_getActiveUniverseAndRepToken(universeId);
(uint248 activeUniverseId, Universe storage activeUniverse,) = _getActiveUniverseAndRepToken(universeId);

Query storage query = queries[queryId];
if (query.numberOfOutcomes == 0) revert InvalidQuery();
Expand All @@ -282,8 +299,13 @@ 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;
_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) {
Expand All @@ -292,6 +314,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;
_recordResolvedUniverse(queryId, activeUniverseId, activeUniverse.isCanonical);
emit QueryResolved(msg.sender, activeUniverseId, queryId, outcome);
// TODO: Payouts
} else {
Expand Down Expand Up @@ -465,9 +488,73 @@ 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;

Comment thread
viatrix marked this conversation as resolved.
// 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);
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);
Comment thread
viatrix marked this conversation as resolved.
}

/**
* @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. 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;
}

/**
* @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) {
Expand Down Expand Up @@ -507,10 +594,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();
Expand All @@ -524,6 +618,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;
Expand All @@ -541,6 +643,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.
Expand Down
107 changes: 107 additions & 0 deletions src/libraries/LibHistory.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// 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 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
* 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. 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.
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 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} 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.
* @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)
{
// 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();
}
// Only two branches are valid for the binary fork: 0 or 1.
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}.
* @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) {
if ((ancestorForkDepth > MAX_FORK_DEPTH) || (descendantForkDepth > MAX_FORK_DEPTH)) {
revert ForkDepthOverflow();
}
// 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.
// 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));
}
}
Loading