Skip to content
324 changes: 198 additions & 126 deletions src/Multiverse.sol

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions src/QueryFeeController.sol
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,15 @@ contract QueryFeeController is IQueryFeeController {
}

/* ============================================ CONSTRUCTOR/SETTER =========================================== */
constructor() {
/**
* @notice Seeds the genesis universe's fee state.
* @param genesisUniverseId The genesis universe id — the Zoltar universe id the Multiverse is
* deployed with, since Lituus universe ids mirror Zoltar universe ids.
*/
constructor(uint248 genesisUniverseId) {
DEPLOYER = msg.sender;

FeeState storage feeState = feeStates[0];
FeeState storage feeState = feeStates[genesisUniverseId];
feeState.baseFee = uint128(INITIAL_BASE_FEE);
feeState.timeFeeLastChanged = uint48(block.timestamp);
}
Expand Down
10 changes: 5 additions & 5 deletions src/mock/MockZoltar.sol
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ contract MockZoltar is IZoltar {
}

/// @notice Stubbed implementation of `IZoltar.universes`.
/// @dev `forkTime` is `block.timestamp` for the genesis universe (id 0) and `0`
/// for any other universe. The remaining fields are zero / the shared REP
/// token. Lets tests instantiate the mock against the full interface.
function universes(uint248 universeId) external view returns (Universe memory u) {
u.forkTime = universeId == 0 ? block.timestamp : 0;
/// @dev `forkTime` is always `0` (not forking), so resolution tests don't trigger fork
/// mirroring. The remaining fields are zero / the shared REP token. Lets tests
/// instantiate the mock against the full interface.
function universes(uint248) external view returns (Universe memory u) {
u.forkTime = 0;
u.forkQuestionId = 0;
u.forkingOutcomeIndex = 0;
u.reputationToken = repToken;
Expand Down
97 changes: 97 additions & 0 deletions test/fuzz/Multiverse.fuzz.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.35;

import { Test } from "forge-std/Test.sol";

import { Multiverse } from "src/Multiverse.sol";
import { ILituusRep } from "src/interfaces/ILituusRep.sol";
import { IReputationToken } from "src/interfaces/IReputationToken.sol";
import { MockERC20 } from "src/mock/MockERC20.sol";
import { MockZoltar } from "src/mock/MockZoltar.sol";
import { MockZoltarQuestionData } from "src/mock/MockZoltarQuestionData.sol";
import { MockQueryFeeController } from "src/mock/MockQueryFeeController.sol";

/// @notice Property-based tests for createQuery. The fuzzer throws random inputs at the assumptions.
contract MultiverseFuzzTest is Test {
// Nonzero on purpose: catches code paths that wrongly assume the genesis universe lives at id 0.
uint248 internal constant GENESIS_UID = 42;
uint256 internal constant DEFAULT_FEE = 1 ether;
uint256 internal constant USER_REP_BALANCE = 1000 ether;

MockERC20 internal underlying;
MockZoltarQuestionData internal zoltarQuestionData;
MockZoltar internal zoltar;
MockQueryFeeController internal feeCtl;
Multiverse internal multiverse;
ILituusRep internal genesisRep;

address internal user = makeAddr("user");

function setUp() public {
underlying = new MockERC20("Underlying", "U");
zoltarQuestionData = new MockZoltarQuestionData();
zoltar = new MockZoltar(IReputationToken(address(underlying)), zoltarQuestionData);
feeCtl = new MockQueryFeeController(DEFAULT_FEE);
multiverse = new Multiverse(zoltar, GENESIS_UID, feeCtl);

(ILituusRep repToken,,,,,,,,,,,,,) = multiverse.universes(GENESIS_UID);
genesisRep = repToken;

underlying.mint(user, USER_REP_BALANCE);
vm.startPrank(user);
underlying.approve(address(genesisRep), type(uint256).max);
multiverse.wrap(GENESIS_UID, USER_REP_BALANCE);
genesisRep.approve(address(multiverse), type(uint256).max);
vm.stopPrank();
}

/// @dev Property: any outcome count in [MIN_OUTCOMES, MAX_OUTCOMES] is stored as given.
function testFuzz_CreateQuery_ValidOutcomes(uint8 n) public {
n = uint8(bound(uint256(n), multiverse.MIN_OUTCOMES(), multiverse.MAX_OUTCOMES()));

vm.prank(user);
multiverse.createQuery(GENESIS_UID, "q", n);

(uint8 numberOfOutcomes,,,) = multiverse.queries(0);
assertEq(numberOfOutcomes, n);
assertEq(multiverse.queryCount(), 1);
}

/// @dev Property: outcome counts below MIN_OUTCOMES always revert.
function testFuzz_CreateQuery_RevertsLowOutcomes(uint8 n) public {
n = uint8(bound(uint256(n), 0, uint256(multiverse.MIN_OUTCOMES()) - 1));

vm.prank(user);
vm.expectRevert(Multiverse.InvalidNumberOfOutcomes.selector);
multiverse.createQuery(GENESIS_UID, "q", n);
}

/// @dev Property: outcome counts above MAX_OUTCOMES always revert.
/// This test is added for clarity but has only one input value (255) that is above MAX_OUTCOMES.
/// It might become useful if MAX_OUTCOMES is ever changed to a lower value.
function testFuzz_CreateQuery_RevertsHighOutcomes(uint8 n) public {
n = uint8(bound(uint256(n), uint256(multiverse.MAX_OUTCOMES()) + 1, 255));

vm.prank(user);
vm.expectRevert(Multiverse.InvalidNumberOfOutcomes.selector);
multiverse.createQuery(GENESIS_UID, "q", n);
}

/// @dev Property: the exact fee reported by the controller is charged and stored.
function testFuzz_CreateQuery_VaryingFee(uint256 fee) public {
// Fees at or above half the fork threshold are rejected by createQuery (FeeAboveForkThreshold),
// matching the stake clamp in _requiredStakeAmountAndForkThreshold, so the valid range tops
// out just below that.
fee = bound(fee, 1, zoltar.getForkThreshold(GENESIS_UID) / 2 - 1);
feeCtl.setFee(fee);
uint256 userBalanceBefore = genesisRep.balanceOf(user);

vm.prank(user);
multiverse.createQuery(GENESIS_UID, "q", 3);

(,, uint256 storedFee,) = multiverse.queries(0);
assertEq(storedFee, fee);
assertEq(genesisRep.balanceOf(address(multiverse)), fee);
assertEq(genesisRep.balanceOf(user), userBalanceBefore - fee);
}
}
90 changes: 90 additions & 0 deletions test/invariant/Multiverse.invariant.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.35;

import { Test, console } from "forge-std/Test.sol";

import { Multiverse } from "src/Multiverse.sol";
import { ILituusRep } from "src/interfaces/ILituusRep.sol";
import { IReputationToken } from "src/interfaces/IReputationToken.sol";
import { MockERC20 } from "src/mock/MockERC20.sol";
import { MockZoltar } from "src/mock/MockZoltar.sol";
import { MockZoltarQuestionData } from "src/mock/MockZoltarQuestionData.sol";
import { MockQueryFeeController } from "src/mock/MockQueryFeeController.sol";
import { MultiverseHandler } from "./handlers/MultiverseHandler.sol";

/// @notice Stateful fuzzing of createQuery. Random sequences of handler calls must never
/// violate the global invariants below.
contract MultiverseInvariantTest is Test {
// Nonzero on purpose: catches code paths that wrongly assume the genesis universe lives at id 0.
uint248 internal constant GENESIS_UID = 42;
uint256 internal constant DEFAULT_FEE = 1 ether;
// Large REP balance for the handler so cumulative fees never exhaust it during a run.
uint256 internal constant HANDLER_REP_BALANCE = 1e40;

MockERC20 internal underlying;
MockZoltarQuestionData internal zoltarQuestionData;
MockZoltar internal zoltar;
MockQueryFeeController internal feeCtl;
Multiverse internal multiverse;
ILituusRep internal genesisRep;
MultiverseHandler internal handler;

function setUp() public {
underlying = new MockERC20("Underlying", "U");
zoltarQuestionData = new MockZoltarQuestionData();
zoltar = new MockZoltar(IReputationToken(address(underlying)), zoltarQuestionData);
feeCtl = new MockQueryFeeController(DEFAULT_FEE);
multiverse = new Multiverse(zoltar, GENESIS_UID, feeCtl);

(ILituusRep repToken,,,,,,,,,,,,,) = multiverse.universes(GENESIS_UID);
genesisRep = repToken;

handler = new MultiverseHandler(multiverse, feeCtl, genesisRep, GENESIS_UID);

// Fund the handler with REP and approve the multiverse to pull query fees.
underlying.mint(address(handler), HANDLER_REP_BALANCE);
vm.startPrank(address(handler));
underlying.approve(address(genesisRep), type(uint256).max);
multiverse.wrap(GENESIS_UID, HANDLER_REP_BALANCE);
genesisRep.approve(address(multiverse), type(uint256).max);
vm.stopPrank();

// Direct the fuzzer at the handler only, keeps inputs bounded.
targetContract(address(handler));
}

/*//////////////////////////////////////////////////////////////
INVARIANTS
//////////////////////////////////////////////////////////////*/

/// @dev queryCount equals the number of successful createQuery calls.
function invariant_QueryCountMatchesGhost() public view {
assertEq(multiverse.queryCount(), handler.ghostQueriesCreated());
}

/// @dev The REP held by the multiverse equals the sum of every fee charged.
function invariant_RepBalanceMatchesFees() public view {
assertEq(genesisRep.balanceOf(address(multiverse)), handler.ghostTotalFees());
}

/// @dev Every created query has a valid outcome count and origin universe.
function invariant_QueryRecordsWellFormed() public view {
uint256 count = multiverse.queryCount();
for (uint256 i = 0; i < count; ++i) {
(uint8 numberOfOutcomes, uint248 originUniverse,,) = multiverse.queries(i);
assertGe(numberOfOutcomes, multiverse.MIN_OUTCOMES());
assertLe(numberOfOutcomes, multiverse.MAX_OUTCOMES());
assertEq(originUniverse, GENESIS_UID);
}
}

/*//////////////////////////////////////////////////////////////
CALL SUMMARY
//////////////////////////////////////////////////////////////*/

/// @dev Diagnostic-only, prints handler call counts. Run with `-vv`.
function invariant_CallSummary() public view {
console.log("queriesCreated :", handler.ghostQueriesCreated());
console.log("totalFees :", handler.ghostTotalFees());
}
}
48 changes: 48 additions & 0 deletions test/invariant/handlers/MultiverseHandler.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.35;

import { CommonBase } from "forge-std/Base.sol";
import { StdUtils } from "forge-std/StdUtils.sol";
import { StdCheats } from "forge-std/StdCheats.sol";

import { Multiverse } from "src/Multiverse.sol";
import { ILituusRep } from "src/interfaces/ILituusRep.sol";
import { MockQueryFeeController } from "src/mock/MockQueryFeeController.sol";

/// @notice Handler for stateful fuzzing of createQuery. Wraps calls with bounded inputs.
/// @dev The invariant runner picks random functions from this contract. The handler holds
/// REP and is the query creator; ghost variables track the expected on-chain state.
contract MultiverseHandler is CommonBase, StdCheats, StdUtils {
// Per-call fee ceiling, small relative to the handler's REP balance so cumulative fees
// over an entire run never exhaust it.
uint256 internal constant MAX_FEE = 1e24;

Multiverse public immutable MULTIVERSE;
MockQueryFeeController public immutable FEE_CTL;
ILituusRep public immutable REP;
uint248 public immutable GENESIS_UID;

// Ghost variables, observable from invariants.
uint256 public ghostQueriesCreated;
uint256 public ghostTotalFees;

constructor(Multiverse multiverse_, MockQueryFeeController feeCtl_, ILituusRep rep_, uint248 genesisUid_) {
MULTIVERSE = multiverse_;
FEE_CTL = feeCtl_;
REP = rep_;
GENESIS_UID = genesisUid_;
}

/// @notice Create a query with a valid, bounded outcome count and fee.
function createQuery(uint256 outcomeSeed, uint256 feeSeed) external {
uint8 outcomes = uint8(bound(outcomeSeed, MULTIVERSE.MIN_OUTCOMES(), MULTIVERSE.MAX_OUTCOMES()));
uint256 fee = bound(feeSeed, 0, MAX_FEE);

FEE_CTL.setFee(fee);
uint256 balanceBefore = REP.balanceOf(address(this));
MULTIVERSE.createQuery(GENESIS_UID, "q", outcomes);

++ghostQueriesCreated;
ghostTotalFees += balanceBefore - REP.balanceOf(address(this));
}
}
89 changes: 89 additions & 0 deletions test/unit/Multiverse.constructor.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.35;

import { Multiverse } from "src/Multiverse.sol";
import { LituusRep } from "src/LituusRep.sol";
import { ILituusRep } from "src/interfaces/ILituusRep.sol";
import { IZoltar } from "src/interfaces/IZoltar.sol";
import { IQueryFeeController } from "src/interfaces/IQueryFeeController.sol";
import { MultiverseFixtures } from "./Multiverse.fixtures.sol";

contract MultiverseConstructorTest is MultiverseFixtures {
function test_RevertWhen_ZoltarIsZero() public {
vm.expectRevert(Multiverse.ZeroAddress.selector);
new Multiverse(IZoltar(address(0)), GENESIS_UID, feeCtl);
}

function test_RevertWhen_QueryFeeControllerIsZero() public {
vm.expectRevert(Multiverse.ZeroAddress.selector);
new Multiverse(zoltar, GENESIS_UID, IQueryFeeController(address(0)));
}

function test_Constructor_SetsImmutables() public {
uint256 deployTime = START_TIME + 5 days;
vm.warp(deployTime);
Multiverse newMultiverse = new Multiverse(zoltar, GENESIS_UID, feeCtl);

assertEq(address(newMultiverse.ZOLTAR()), address(zoltar));
assertEq(address(newMultiverse.QUERY_FEE_CONTROLLER()), address(feeCtl));
assertEq(newMultiverse.GENESIS_TIMESTAMP(), deployTime);
}

function test_Constructor_InitializesGenesisUniverse() public {
// supplyBeforeFork is captured at deploy time from the theoretical supply. Deploy a
// new instance so the captured value equals the current live reading.
uint256 expectedSupply = zoltar.getUniverseTheoreticalSupply(GENESIS_UID);
Multiverse newMultiverse = new Multiverse(zoltar, GENESIS_UID, feeCtl);

(
ILituusRep repToken,
Multiverse.UniverseState universeState,
uint48 forkTime,
uint16 forkDepth,
bool isCanonical,
uint248 parent,
uint248 favoriteChild,
uint248 heir,
bytes32 history,
uint256 forkQuery,
uint256 supplyBeforeFork,
address queryTokenizer,
uint8 forkOutcome,
bool isLituusFork
) = newMultiverse.universes(GENESIS_UID);

assertTrue(address(repToken) != address(0));
assertEq(uint8(universeState), uint8(Multiverse.UniverseState.Active));
assertEq(forkTime, uint48(block.timestamp));
assertEq(forkDepth, 0);
assertTrue(isCanonical);
assertEq(parent, 0);
assertEq(favoriteChild, 0);
assertEq(heir, 0);
assertEq(history, bytes32(0));
assertEq(forkQuery, 0);
assertEq(supplyBeforeFork, expectedSupply);
assertEq(queryTokenizer, address(0));
assertEq(forkOutcome, 0);
assertFalse(isLituusFork);
}

function test_Constructor_DeploysRepToken() public view {
LituusRep rep = LituusRep(address(genesisRep));
assertEq(rep.name(), "Lituus Reputation Token");
assertEq(rep.symbol(), "REP0");
assertEq(rep.owner(), address(multiverse));
assertEq(address(rep.UNDERLYING_TOKEN()), address(underlying));
}

function test_Constructor_Constants() public view {
assertEq(multiverse.MAX_OUTCOMES(), 254);
assertEq(multiverse.MIN_OUTCOMES(), 2);
assertEq(multiverse.MAX_FORK_OUTCOMES(), 2);
assertEq(multiverse.UNRESOLVED(), 0);
assertEq(multiverse.INVALID(), 255);
assertEq(multiverse.THREE_DAYS(), 3 days);
assertEq(multiverse.ONE_DAY(), 1 days);
assertEq(multiverse.BURN_DIVIDER(), 5);
}
}
Loading