From 076d93faf1b8c5ef7fcce190cc0f251eeb780434 Mon Sep 17 00:00:00 2001 From: Tanya Bushenyova Date: Fri, 29 May 2026 14:59:41 +0300 Subject: [PATCH 1/8] Add multiverse draft --- src/Multiverse.sol | 82 +++++++++++++++++++++++++++++++++++ src/interfaces/ILituusRep.sol | 9 ++++ src/interfaces/IZoltar.sol | 6 +++ 3 files changed, 97 insertions(+) create mode 100644 src/Multiverse.sol create mode 100644 src/interfaces/ILituusRep.sol create mode 100644 src/interfaces/IZoltar.sol diff --git a/src/Multiverse.sol b/src/Multiverse.sol new file mode 100644 index 0000000..f009069 --- /dev/null +++ b/src/Multiverse.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.35; + +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { IZoltar } from "./interfaces/IZoltar.sol"; +import { ILituusRep } from "./interfaces/ILituusRep.sol"; + +contract Multiverse { + + using SafeERC20 for IERC20; + + struct Stake + { + address owner; + uint48 claim; + uint48 time; + uint256 amount; + } + + struct Query + { + uint48 createTime; + uint16 numberOfOutcomes; + uint64 originUniverse; + uint256 fee; + string question; + bytes32[] resolvedUniverses; + } + + struct Outcome + { + uint16 outcome; + uint256 totalStake; + Stake[] stake; + } + + struct Universe + { + ILituusRep repToken; + uint8 forkState; + uint64 parent; + uint64 favoriteChild; + uint64 heir; + bytes32[] history; + uint256 forkQuery; + uint256 supplyBeforeFork; + address queryTokenizer; + } + + mapping(uint248 => Universe) public universes; + mapping(uint256 => Query) public queries; + mapping(uint248 => mapping(uint256 => Outcome)) outcomes; // universeId => queryId => Outcome + + uint256 public queryCount; + + IZoltar public immutable ZOLTAR; + + error ZeroAddress(); + + constructor(IZoltar _zoltar) { + ZOLTAR = _zoltar; + if (address(ZOLTAR) == address(0)) revert ZeroAddress(); + + // TODO: Get the rep token address from the genesis universe in Zoltar + // Deploy a Lituus REP token that wraps the Zoltar REP token + // ILituusRep repToken = new LituusRep(address(0)); // Pass address(0) for now, will set the correct address after deployment + + Universe memory genesisUniverse; + genesisUniverse.favoriteChild = 0; + genesisUniverse.parent = 0; + // TODO genesisUniverse.repToken = repToken; + genesisUniverse.forkState = 0; + genesisUniverse.heir = 0; + genesisUniverse.history = new bytes32[](0); + genesisUniverse.forkQuery = 0; + genesisUniverse.supplyBeforeFork = genesisUniverse.repToken.totalSupply(); + genesisUniverse.queryTokenizer = address(0); + universes[0] = genesisUniverse; + } +} diff --git a/src/interfaces/ILituusRep.sol b/src/interfaces/ILituusRep.sol new file mode 100644 index 0000000..ccd42c0 --- /dev/null +++ b/src/interfaces/ILituusRep.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.35; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface ILituusRep is IERC20 { + function wrap(uint256 amount) external; + function unwrap(uint256 amount) external; +} \ No newline at end of file diff --git a/src/interfaces/IZoltar.sol b/src/interfaces/IZoltar.sol new file mode 100644 index 0000000..e99afd3 --- /dev/null +++ b/src/interfaces/IZoltar.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.35; + +interface IZoltar { + function getChildUniverseId(uint248 universeId, uint256 outcomeIndex) external pure returns (uint248); +} \ No newline at end of file From ae22796d7f30ea35d0409176989ea0c2137b2e6f Mon Sep 17 00:00:00 2001 From: Tanya Bushenyova Date: Sun, 31 May 2026 23:36:53 +0300 Subject: [PATCH 2/8] Add interfaces and constructor --- src/LituusRep.sol | 39 +++++++++++++++++++++++++++++ src/Multiverse.sol | 26 ++++++++++++++----- src/interfaces/ILituusRep.sol | 4 +-- src/interfaces/IReputationToken.sol | 9 +++++++ src/interfaces/IZoltar.sol | 13 ++++++++++ 5 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 src/LituusRep.sol create mode 100644 src/interfaces/IReputationToken.sol diff --git a/src/LituusRep.sol b/src/LituusRep.sol new file mode 100644 index 0000000..2802bc5 --- /dev/null +++ b/src/LituusRep.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.35; + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ERC20Burnable } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +import { ILituusRep } from "./interfaces/ILituusRep.sol"; + +contract LituusRep is ERC20, ERC20Burnable, Ownable, ILituusRep { + + using SafeERC20 for IERC20; + + IERC20 public immutable UNDERLYING_TOKEN; + + constructor(address owner, address underlyingToken, string memory name, string memory symbol) + ERC20(name, symbol) + Ownable(owner) { + UNDERLYING_TOKEN = IERC20(underlyingToken); + } + + function mint(address to, uint256 amount) public onlyOwner { + _mint(to, amount); + } + + function wrap(address sender, uint256 amount) public onlyOwner { + UNDERLYING_TOKEN.safeTransferFrom(sender, address(this), amount); + _mint(sender, amount); + } + + function unwrap(address sender, uint256 amount) public onlyOwner { + _burn(sender, amount); + UNDERLYING_TOKEN.safeTransfer(sender, amount); + } + + // TODO: permit? +} \ No newline at end of file diff --git a/src/Multiverse.sol b/src/Multiverse.sol index f009069..c1848ae 100644 --- a/src/Multiverse.sol +++ b/src/Multiverse.sol @@ -6,6 +6,8 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { IZoltar } from "./interfaces/IZoltar.sol"; import { ILituusRep } from "./interfaces/ILituusRep.sol"; +import { LituusRep } from "./LituusRep.sol"; +import { IReputationToken } from "./interfaces/IReputationToken.sol"; contract Multiverse { @@ -59,24 +61,36 @@ contract Multiverse { error ZeroAddress(); - constructor(IZoltar _zoltar) { + constructor(IZoltar _zoltar, uint248 _initialZoltarUniverseId) { ZOLTAR = _zoltar; if (address(ZOLTAR) == address(0)) revert ZeroAddress(); - // TODO: Get the rep token address from the genesis universe in Zoltar - // Deploy a Lituus REP token that wraps the Zoltar REP token - // ILituusRep repToken = new LituusRep(address(0)); // Pass address(0) for now, will set the correct address after deployment + // get the rep token address from the initial universe in Zoltar + IReputationToken initialZoltarRepToken = ZOLTAR.getRepToken(_initialZoltarUniverseId); + // deploy a Lituus REP token that wraps the Zoltar REP token + // token symbol will use universe.history as a suffix. Genesis universe will have symbol "REP0" + ILituusRep repToken = new LituusRep(address(this), address(initialZoltarRepToken), "Lituus Reputation Token", "REP0"); Universe memory genesisUniverse; genesisUniverse.favoriteChild = 0; genesisUniverse.parent = 0; - // TODO genesisUniverse.repToken = repToken; + genesisUniverse.repToken = repToken; genesisUniverse.forkState = 0; genesisUniverse.heir = 0; genesisUniverse.history = new bytes32[](0); genesisUniverse.forkQuery = 0; - genesisUniverse.supplyBeforeFork = genesisUniverse.repToken.totalSupply(); + genesisUniverse.supplyBeforeFork = repToken.totalSupply(); // TODO: what should it be? genesisUniverse.queryTokenizer = address(0); universes[0] = genesisUniverse; } + + function wrap(uint248 universeId, uint256 amount) external { + // TODO: check universe status + universes[universeId].repToken.wrap(msg.sender, amount); + } + + function unwrap(uint248 universeId, uint256 amount) external { + // TODO: check universe status + universes[universeId].repToken.unwrap(msg.sender, amount); + } } diff --git a/src/interfaces/ILituusRep.sol b/src/interfaces/ILituusRep.sol index ccd42c0..9bd764a 100644 --- a/src/interfaces/ILituusRep.sol +++ b/src/interfaces/ILituusRep.sol @@ -4,6 +4,6 @@ pragma solidity ^0.8.35; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; interface ILituusRep is IERC20 { - function wrap(uint256 amount) external; - function unwrap(uint256 amount) external; + function wrap(address sender, uint256 amount) external; + function unwrap(address sender, uint256 amount) external; } \ No newline at end of file diff --git a/src/interfaces/IReputationToken.sol b/src/interfaces/IReputationToken.sol new file mode 100644 index 0000000..923b3b2 --- /dev/null +++ b/src/interfaces/IReputationToken.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.35; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface IReputationToken is IERC20 { + function mint(address account, uint256 value) external; + function burn(address account, uint256 value) external; +} \ No newline at end of file diff --git a/src/interfaces/IZoltar.sol b/src/interfaces/IZoltar.sol index e99afd3..6a25053 100644 --- a/src/interfaces/IZoltar.sol +++ b/src/interfaces/IZoltar.sol @@ -1,6 +1,19 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.35; +import { IReputationToken } from "./IReputationToken.sol"; + interface IZoltar { + struct Universe { + uint256 forkTime; + uint256 forkQuestionId; + uint256 forkingOutcomeIndex; + + IReputationToken reputationToken; + uint248 parentUniverseId; + } + function getChildUniverseId(uint248 universeId, uint256 outcomeIndex) external pure returns (uint248); + + function getRepToken(uint248 universeId) external view returns (IReputationToken); } \ No newline at end of file From 3743e9d9353d3f4b74a17bdbd922143463aa6519 Mon Sep 17 00:00:00 2001 From: Tanya Bushenyova Date: Mon, 1 Jun 2026 21:24:40 +0300 Subject: [PATCH 3/8] Add enum for the universe state --- src/Multiverse.sol | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/Multiverse.sol b/src/Multiverse.sol index c1848ae..3b5ebc5 100644 --- a/src/Multiverse.sol +++ b/src/Multiverse.sol @@ -13,6 +13,22 @@ contract Multiverse { using SafeERC20 for IERC20; + uint public constant MAX_OUTCOMES = 255; //number of outcomes for a query + uint public constant MAX_FORK_OUTCOMES = 2; //number of outcomes for a forking query + uint public constant UNRESOLVED = MAX_OUTCOMES; //the starting value for outcome is UNRESOLVED. Outcome 0 is the first, outcome (MAX_OUTCOMES-1) is the last. + uint public constant NO_REPORT = MAX_OUTCOMES; //the starting value for lastReport is NO_REPORT. + + enum ForkState { + NotForking, // 0 - default; universe is operating normally + AwaitingChildren, // 1 - system frozen, waiting for forkUniverse() to be called + InitialMigration, // 2 - forking in progress; REP holders migrate to child universes + SupplyRestoration1, // 3 - SR attempt 1 + SupplyRestoration2, // 4 - SR attempt 2 + SupplyRestoration3, // 5 - SR attempt 3 + PostFork, // 6 - fork finalized + Forming // 7 - child universe still being formed + } + struct Stake { address owner; @@ -41,7 +57,7 @@ contract Multiverse { struct Universe { ILituusRep repToken; - uint8 forkState; + ForkState forkState; uint64 parent; uint64 favoriteChild; uint64 heir; @@ -69,13 +85,14 @@ contract Multiverse { IReputationToken initialZoltarRepToken = ZOLTAR.getRepToken(_initialZoltarUniverseId); // deploy a Lituus REP token that wraps the Zoltar REP token // token symbol will use universe.history as a suffix. Genesis universe will have symbol "REP0" + // TODO: Discuss the format of the suffix if the forks are for binary queries. ILituusRep repToken = new LituusRep(address(this), address(initialZoltarRepToken), "Lituus Reputation Token", "REP0"); Universe memory genesisUniverse; genesisUniverse.favoriteChild = 0; genesisUniverse.parent = 0; genesisUniverse.repToken = repToken; - genesisUniverse.forkState = 0; + genesisUniverse.forkState = ForkState.NotForking; genesisUniverse.heir = 0; genesisUniverse.history = new bytes32[](0); genesisUniverse.forkQuery = 0; From 5c4d179c2beceef366abd7f3051890a5883cb0fc Mon Sep 17 00:00:00 2001 From: Tanya Bushenyova Date: Mon, 1 Jun 2026 21:37:25 +0300 Subject: [PATCH 4/8] Formatting --- foundry.toml | 2 +- src/LituusRep.sol | 6 ++-- src/Multiverse.sol | 45 ++++++++++++++--------------- src/interfaces/ILituusRep.sol | 2 +- src/interfaces/IReputationToken.sol | 4 +-- src/interfaces/IZoltar.sol | 14 ++++----- 6 files changed, 35 insertions(+), 38 deletions(-) diff --git a/foundry.toml b/foundry.toml index 051938a..23c0c49 100644 --- a/foundry.toml +++ b/foundry.toml @@ -35,7 +35,7 @@ line_length = 120 tab_width = 4 bracket_spacing = true int_types = "long" -multiline_func_header = "all" +multiline_func_header = "attributes_first" quote_style = "double" number_underscore = "thousands" wrap_comments = true diff --git a/src/LituusRep.sol b/src/LituusRep.sol index 2802bc5..1796ab7 100644 --- a/src/LituusRep.sol +++ b/src/LituusRep.sol @@ -10,14 +10,14 @@ import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { ILituusRep } from "./interfaces/ILituusRep.sol"; contract LituusRep is ERC20, ERC20Burnable, Ownable, ILituusRep { - using SafeERC20 for IERC20; IERC20 public immutable UNDERLYING_TOKEN; constructor(address owner, address underlyingToken, string memory name, string memory symbol) ERC20(name, symbol) - Ownable(owner) { + Ownable(owner) + { UNDERLYING_TOKEN = IERC20(underlyingToken); } @@ -36,4 +36,4 @@ contract LituusRep is ERC20, ERC20Burnable, Ownable, ILituusRep { } // TODO: permit? -} \ No newline at end of file +} diff --git a/src/Multiverse.sol b/src/Multiverse.sol index 3b5ebc5..8e7816e 100644 --- a/src/Multiverse.sol +++ b/src/Multiverse.sol @@ -10,35 +10,33 @@ import { LituusRep } from "./LituusRep.sol"; import { IReputationToken } from "./interfaces/IReputationToken.sol"; contract Multiverse { - using SafeERC20 for IERC20; - uint public constant MAX_OUTCOMES = 255; //number of outcomes for a query - uint public constant MAX_FORK_OUTCOMES = 2; //number of outcomes for a forking query - uint public constant UNRESOLVED = MAX_OUTCOMES; //the starting value for outcome is UNRESOLVED. Outcome 0 is the first, outcome (MAX_OUTCOMES-1) is the last. - uint public constant NO_REPORT = MAX_OUTCOMES; //the starting value for lastReport is NO_REPORT. + uint256 public constant MAX_OUTCOMES = 255; //number of outcomes for a query + uint256 public constant MAX_FORK_OUTCOMES = 2; //number of outcomes for a forking query + uint256 public constant UNRESOLVED = MAX_OUTCOMES; //the starting value for outcome is UNRESOLVED. Outcome 0 is the + // first, outcome (MAX_OUTCOMES-1) is the last. + uint256 public constant NO_REPORT = MAX_OUTCOMES; //the starting value for lastReport is NO_REPORT. enum ForkState { - NotForking, // 0 - default; universe is operating normally - AwaitingChildren, // 1 - system frozen, waiting for forkUniverse() to be called - InitialMigration, // 2 - forking in progress; REP holders migrate to child universes + NotForking, // 0 - default; universe is operating normally + AwaitingChildren, // 1 - system frozen, waiting for forkUniverse() to be called + InitialMigration, // 2 - forking in progress; REP holders migrate to child universes SupplyRestoration1, // 3 - SR attempt 1 SupplyRestoration2, // 4 - SR attempt 2 SupplyRestoration3, // 5 - SR attempt 3 - PostFork, // 6 - fork finalized - Forming // 7 - child universe still being formed + PostFork, // 6 - fork finalized + Forming // 7 - child universe still being formed } - struct Stake - { - address owner; - uint48 claim; - uint48 time; - uint256 amount; - } + struct Stake { + address owner; + uint48 claim; + uint48 time; + uint256 amount; + } - struct Query - { + struct Query { uint48 createTime; uint16 numberOfOutcomes; uint64 originUniverse; @@ -47,15 +45,13 @@ contract Multiverse { bytes32[] resolvedUniverses; } - struct Outcome - { + struct Outcome { uint16 outcome; uint256 totalStake; Stake[] stake; } - struct Universe - { + struct Universe { ILituusRep repToken; ForkState forkState; uint64 parent; @@ -86,7 +82,8 @@ contract Multiverse { // deploy a Lituus REP token that wraps the Zoltar REP token // token symbol will use universe.history as a suffix. Genesis universe will have symbol "REP0" // TODO: Discuss the format of the suffix if the forks are for binary queries. - ILituusRep repToken = new LituusRep(address(this), address(initialZoltarRepToken), "Lituus Reputation Token", "REP0"); + ILituusRep repToken = + new LituusRep(address(this), address(initialZoltarRepToken), "Lituus Reputation Token", "REP0"); Universe memory genesisUniverse; genesisUniverse.favoriteChild = 0; diff --git a/src/interfaces/ILituusRep.sol b/src/interfaces/ILituusRep.sol index 9bd764a..c97f01a 100644 --- a/src/interfaces/ILituusRep.sol +++ b/src/interfaces/ILituusRep.sol @@ -6,4 +6,4 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; interface ILituusRep is IERC20 { function wrap(address sender, uint256 amount) external; function unwrap(address sender, uint256 amount) external; -} \ No newline at end of file +} diff --git a/src/interfaces/IReputationToken.sol b/src/interfaces/IReputationToken.sol index 923b3b2..361d129 100644 --- a/src/interfaces/IReputationToken.sol +++ b/src/interfaces/IReputationToken.sol @@ -5,5 +5,5 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; interface IReputationToken is IERC20 { function mint(address account, uint256 value) external; - function burn(address account, uint256 value) external; -} \ No newline at end of file + function burn(address account, uint256 value) external; +} diff --git a/src/interfaces/IZoltar.sol b/src/interfaces/IZoltar.sol index 6a25053..3e1e34d 100644 --- a/src/interfaces/IZoltar.sol +++ b/src/interfaces/IZoltar.sol @@ -5,15 +5,15 @@ import { IReputationToken } from "./IReputationToken.sol"; interface IZoltar { struct Universe { - uint256 forkTime; - uint256 forkQuestionId; - uint256 forkingOutcomeIndex; + uint256 forkTime; + uint256 forkQuestionId; + uint256 forkingOutcomeIndex; - IReputationToken reputationToken; - uint248 parentUniverseId; - } + IReputationToken reputationToken; + uint248 parentUniverseId; + } function getChildUniverseId(uint248 universeId, uint256 outcomeIndex) external pure returns (uint248); function getRepToken(uint248 universeId) external view returns (IReputationToken); -} \ No newline at end of file +} From 7e20b986429cb992d37e02b8a226fc6961fa5a0f Mon Sep 17 00:00:00 2001 From: Tanya Bushenyova Date: Mon, 1 Jun 2026 22:19:11 +0300 Subject: [PATCH 5/8] Add a workflow --- .github/workflows/foundry-ci.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/foundry-ci.yml diff --git a/.github/workflows/foundry-ci.yml b/.github/workflows/foundry-ci.yml new file mode 100644 index 0000000..b708d6c --- /dev/null +++ b/.github/workflows/foundry-ci.yml @@ -0,0 +1,28 @@ +name: foundry-ci + +on: + push: + +jobs: + foundry-ci: + name: Forge fmt & test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: stable + + - name: Run Forge Build + run: forge build + + - name: Forge format check + run: forge fmt --check + + - name: Run Forge Test + run: forge test From 69fec8da862ac0f8997e7a101ca06c90a2639d2c Mon Sep 17 00:00:00 2001 From: Tanya Bushenyova Date: Mon, 1 Jun 2026 22:21:43 +0300 Subject: [PATCH 6/8] Update the workflow --- .github/workflows/foundry-ci.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/foundry-ci.yml b/.github/workflows/foundry-ci.yml index b708d6c..a9f3c86 100644 --- a/.github/workflows/foundry-ci.yml +++ b/.github/workflows/foundry-ci.yml @@ -1,11 +1,12 @@ name: foundry-ci on: - push: + pull_request: + branches: [ main ] jobs: foundry-ci: - name: Forge fmt & test + name: Forge Format Check and Test runs-on: ubuntu-latest steps: - name: Checkout @@ -21,7 +22,7 @@ jobs: - name: Run Forge Build run: forge build - - name: Forge format check + - name: Forge Format Check run: forge fmt --check - name: Run Forge Test From cd8b165355b39578ecb3bcd4339932499ece589f Mon Sep 17 00:00:00 2001 From: Tanya Bushenyova Date: Tue, 2 Jun 2026 15:54:13 +0300 Subject: [PATCH 7/8] Add createQuery --- src/LituusRep.sol | 3 +- src/Multiverse.sol | 98 +++++++-- src/interfaces/IQueryFeeController.sol | 6 + src/mock/MockERC20.sol | 18 ++ src/mock/MockQueryFeeController.sol | 20 ++ src/mock/MockZoltar.sol | 21 ++ test/unit/Multiverse.t.sol | 291 +++++++++++++++++++++++++ 7 files changed, 441 insertions(+), 16 deletions(-) create mode 100644 src/interfaces/IQueryFeeController.sol create mode 100644 src/mock/MockERC20.sol create mode 100644 src/mock/MockQueryFeeController.sol create mode 100644 src/mock/MockZoltar.sol create mode 100644 test/unit/Multiverse.t.sol diff --git a/src/LituusRep.sol b/src/LituusRep.sol index 1796ab7..a174824 100644 --- a/src/LituusRep.sol +++ b/src/LituusRep.sol @@ -4,12 +4,11 @@ pragma solidity ^0.8.35; import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { ERC20Burnable } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { ILituusRep } from "./interfaces/ILituusRep.sol"; -contract LituusRep is ERC20, ERC20Burnable, Ownable, ILituusRep { +contract LituusRep is ERC20, Ownable, ILituusRep { using SafeERC20 for IERC20; IERC20 public immutable UNDERLYING_TOKEN; diff --git a/src/Multiverse.sol b/src/Multiverse.sol index 8e7816e..43b87c6 100644 --- a/src/Multiverse.sol +++ b/src/Multiverse.sol @@ -8,15 +8,18 @@ import { IZoltar } from "./interfaces/IZoltar.sol"; import { ILituusRep } from "./interfaces/ILituusRep.sol"; import { LituusRep } from "./LituusRep.sol"; import { IReputationToken } from "./interfaces/IReputationToken.sol"; +import { IQueryFeeController } from "./interfaces/IQueryFeeController.sol"; contract Multiverse { using SafeERC20 for IERC20; + using SafeERC20 for ILituusRep; - uint256 public constant MAX_OUTCOMES = 255; //number of outcomes for a query - uint256 public constant MAX_FORK_OUTCOMES = 2; //number of outcomes for a forking query - uint256 public constant UNRESOLVED = MAX_OUTCOMES; //the starting value for outcome is UNRESOLVED. Outcome 0 is the - // first, outcome (MAX_OUTCOMES-1) is the last. - uint256 public constant NO_REPORT = MAX_OUTCOMES; //the starting value for lastReport is NO_REPORT. + uint16 public constant MAX_OUTCOMES = 253; //number of outcomes for a query + uint16 public constant MAX_FORK_OUTCOMES = 2; //number of outcomes for a forking query + uint16 public constant UNRESOLVED = 255; // Not reported in time. + uint16 public constant INVALID = 254; // an invalid outcome value used for reporting an invalid fork outcome during + // fork resolution. It is outside the valid outcome range [0, MAX_OUTCOMES-1] + uint16 public constant NO_REPORT = 0; // the starting value for outcome is NO_REPORT. enum ForkState { NotForking, // 0 - default; universe is operating normally @@ -39,7 +42,7 @@ contract Multiverse { struct Query { uint48 createTime; uint16 numberOfOutcomes; - uint64 originUniverse; + uint248 originUniverse; uint256 fee; string question; bytes32[] resolvedUniverses; @@ -54,10 +57,10 @@ contract Multiverse { struct Universe { ILituusRep repToken; ForkState forkState; - uint64 parent; - uint64 favoriteChild; - uint64 heir; - bytes32[] history; + uint248 parent; + uint248 favoriteChild; + uint248 heir; + bytes32 history; uint256 forkQuery; uint256 supplyBeforeFork; address queryTokenizer; @@ -71,11 +74,20 @@ contract Multiverse { IZoltar public immutable ZOLTAR; + event QueryCreated(uint256 indexed queryId, uint248 indexed universeId, string question, uint16 numberOfOutcomes); + error ZeroAddress(); + error InvalidUniverse(); + error UniverseForking(); + error InvalidNumberOfOutcomes(); + + IQueryFeeController public immutable QUERY_FEE_CONTROLLER; - constructor(IZoltar _zoltar, uint248 _initialZoltarUniverseId) { + constructor(IZoltar _zoltar, uint248 _initialZoltarUniverseId, IQueryFeeController _queryFeeController) { ZOLTAR = _zoltar; if (address(ZOLTAR) == address(0)) revert ZeroAddress(); + QUERY_FEE_CONTROLLER = _queryFeeController; + if (address(QUERY_FEE_CONTROLLER) == address(0)) revert ZeroAddress(); // get the rep token address from the initial universe in Zoltar IReputationToken initialZoltarRepToken = ZOLTAR.getRepToken(_initialZoltarUniverseId); @@ -85,17 +97,16 @@ contract Multiverse { ILituusRep repToken = new LituusRep(address(this), address(initialZoltarRepToken), "Lituus Reputation Token", "REP0"); - Universe memory genesisUniverse; + Universe storage genesisUniverse = universes[0]; genesisUniverse.favoriteChild = 0; genesisUniverse.parent = 0; genesisUniverse.repToken = repToken; genesisUniverse.forkState = ForkState.NotForking; genesisUniverse.heir = 0; - genesisUniverse.history = new bytes32[](0); + genesisUniverse.history = 0; genesisUniverse.forkQuery = 0; genesisUniverse.supplyBeforeFork = repToken.totalSupply(); // TODO: what should it be? genesisUniverse.queryTokenizer = address(0); - universes[0] = genesisUniverse; } function wrap(uint248 universeId, uint256 amount) external { @@ -107,4 +118,63 @@ contract Multiverse { // TODO: check universe status universes[universeId].repToken.unwrap(msg.sender, amount); } + + // Main functions + + function createQuery(uint248 universeId, string calldata question, uint16 numberOfOutcomes) external { + Universe storage universe = universes[universeId]; + ILituusRep repToken = universe.repToken; + if (address(repToken) == address(0)) revert InvalidUniverse(); + // Forward the transaction to the heir + uint248 heirId = universe.heir; + if (heirId != universeId) { + universe = universes[heirId]; + repToken = universe.repToken; + if (address(repToken) == address(0)) revert InvalidUniverse(); + } + // TODO: double check the allowed states + // Query creation is allowed during a fork in child universes + // but not in the parent universe that is forking. + ForkState forkState = universe.forkState; + if ( + forkState == ForkState.InitialMigration || forkState == ForkState.SupplyRestoration1 + || forkState == ForkState.SupplyRestoration2 || forkState == ForkState.SupplyRestoration3 + || forkState == ForkState.PostFork + ) { + revert UniverseForking(); + } + // Validate the question and number of outcomes + if (numberOfOutcomes <= 2) revert InvalidNumberOfOutcomes(); + if (numberOfOutcomes > MAX_OUTCOMES) revert InvalidNumberOfOutcomes(); + + // Get the fee amount from the query fee controller + uint256 fee = QUERY_FEE_CONTROLLER.getQueryFee(universeId); + // Transfer the query fee amount of REP token + // TODO: permit? permit2? + repToken.safeTransferFrom(msg.sender, address(this), fee); + // Create a global query record + + Query storage query = queries[queryCount]; + query.createTime = uint48(block.timestamp); + query.numberOfOutcomes = numberOfOutcomes; + query.originUniverse = universeId; + query.fee = fee; + query.question = question; + + // A universe-specific outcome record will start with NO_REPORT + // The record will be populated when the first report comes in + + // Emit an event + emit QueryCreated(queryCount, universeId, question, numberOfOutcomes); + + queryCount++; + } + + function getOutcome(uint248 universeId, uint256 queryId) external view returns (uint16) { + return outcomes[universeId][queryId].outcome; + } + + function getOutcomeData(uint248 universeId, uint256 queryId) external view returns (Outcome memory) { + return outcomes[universeId][queryId]; + } } diff --git a/src/interfaces/IQueryFeeController.sol b/src/interfaces/IQueryFeeController.sol new file mode 100644 index 0000000..ba0ec7d --- /dev/null +++ b/src/interfaces/IQueryFeeController.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.35; + +interface IQueryFeeController { + function getQueryFee(uint248 universeId) external view returns (uint256); +} diff --git a/src/mock/MockERC20.sol b/src/mock/MockERC20.sol new file mode 100644 index 0000000..b92284a --- /dev/null +++ b/src/mock/MockERC20.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.35; + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import { IReputationToken } from "../interfaces/IReputationToken.sol"; + +contract MockERC20 is ERC20, IReputationToken { + constructor(string memory _name, string memory _symbol) ERC20(_name, _symbol) { } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function burn(address from, uint256 amount) external { + _burn(from, amount); + } +} diff --git a/src/mock/MockQueryFeeController.sol b/src/mock/MockQueryFeeController.sol new file mode 100644 index 0000000..d4e44de --- /dev/null +++ b/src/mock/MockQueryFeeController.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.35; + +import { IQueryFeeController } from "../interfaces/IQueryFeeController.sol"; + +contract MockQueryFeeController is IQueryFeeController { + uint256 public fee; + + constructor(uint256 _fee) { + fee = _fee; + } + + function setFee(uint256 _fee) external { + fee = _fee; + } + + function getQueryFee(uint248) external view returns (uint256) { + return fee; + } +} diff --git a/src/mock/MockZoltar.sol b/src/mock/MockZoltar.sol new file mode 100644 index 0000000..bc5451f --- /dev/null +++ b/src/mock/MockZoltar.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.35; + +import { IZoltar } from "../interfaces/IZoltar.sol"; +import { IReputationToken } from "../interfaces/IReputationToken.sol"; + +contract MockZoltar is IZoltar { + IReputationToken public repToken; + + constructor(IReputationToken repToken_) { + repToken = repToken_; + } + + function getChildUniverseId(uint248 universeId, uint256) external pure returns (uint248) { + return universeId; + } + + function getRepToken(uint248) external view returns (IReputationToken) { + return repToken; + } +} diff --git a/test/unit/Multiverse.t.sol b/test/unit/Multiverse.t.sol new file mode 100644 index 0000000..b4eec0c --- /dev/null +++ b/test/unit/Multiverse.t.sol @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.35; + +import { Test } from "forge-std/Test.sol"; + +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 { IReputationToken } from "src/interfaces/IReputationToken.sol"; +import { IQueryFeeController } from "src/interfaces/IQueryFeeController.sol"; +import { MockERC20 } from "src/mock/MockERC20.sol"; +import { MockZoltar } from "src/mock/MockZoltar.sol"; +import { MockQueryFeeController } from "src/mock/MockQueryFeeController.sol"; + +contract MultiverseUnitTest is Test { + uint248 internal constant GENESIS_UID = 0; + uint256 internal constant DEFAULT_FEE = 1 ether; + uint256 internal constant USER_REP_BALANCE = 1000 ether; + + MockERC20 internal underlying; + MockZoltar internal zoltar; + MockQueryFeeController internal feeCtl; + Multiverse internal multiverse; + ILituusRep internal genesisRep; + + address internal user = makeAddr("user"); + address internal attacker = makeAddr("attacker"); + + event QueryCreated(uint256 indexed queryId, uint248 indexed universeId, string question, uint16 numberOfOutcomes); + + function setUp() public { + underlying = new MockERC20("Underlying", "U"); + zoltar = new MockZoltar(IReputationToken(address(underlying))); + 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(); + } + + /*////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + + 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_GenesisUniverseInitialized() public view { + ( + ILituusRep repToken, + Multiverse.ForkState forkState, + uint248 parent, + uint248 favoriteChild, + uint248 heir, + bytes32 history, + uint256 forkQuery, + uint256 supplyBeforeFork, + address queryTokenizer + ) = multiverse.universes(GENESIS_UID); + + assertTrue(address(repToken) != address(0)); + assertEq(uint8(forkState), uint8(Multiverse.ForkState.NotForking)); + assertEq(parent, 0); + assertEq(favoriteChild, 0); + assertEq(heir, 0); + assertEq(history, bytes32(0)); + assertEq(forkQuery, 0); + assertEq(supplyBeforeFork, 0); + assertEq(queryTokenizer, address(0)); + } + + function test_GenesisRepTokenWrapsZoltarRep() public view { + assertEq(address(LituusRep(address(genesisRep)).UNDERLYING_TOKEN()), address(underlying)); + } + + function test_ImmutablesStored() public view { + assertEq(address(multiverse.ZOLTAR()), address(zoltar)); + assertEq(address(multiverse.QUERY_FEE_CONTROLLER()), address(feeCtl)); + } + + /*////////////////////////////////////////////////////////////// + WRAP + //////////////////////////////////////////////////////////////*/ + + function test_Wrap_TransfersUnderlyingAndMintsLituusRep() public { + uint256 amount = 5 ether; + underlying.mint(user, amount); + + uint256 underlyingBefore = underlying.balanceOf(user); + uint256 repBefore = genesisRep.balanceOf(user); + + vm.prank(user); + multiverse.wrap(GENESIS_UID, amount); + + assertEq(underlying.balanceOf(user), underlyingBefore - amount); + assertEq(genesisRep.balanceOf(user), repBefore + amount); + } + + function test_RevertWhen_WrapOnUnknownUniverse() public { + vm.expectRevert(); + vm.prank(user); + multiverse.wrap(99, 1 ether); + } + + /*////////////////////////////////////////////////////////////// + UNWRAP + //////////////////////////////////////////////////////////////*/ + + function test_Unwrap_BurnsLituusRepAndReturnsUnderlying() public { + uint256 amount = 5 ether; + + uint256 underlyingBefore = underlying.balanceOf(user); + uint256 repBefore = genesisRep.balanceOf(user); + + vm.prank(user); + multiverse.unwrap(GENESIS_UID, amount); + + assertEq(underlying.balanceOf(user), underlyingBefore + amount); + assertEq(genesisRep.balanceOf(user), repBefore - amount); + } + + function test_RevertWhen_UnwrapOnUnknownUniverse() public { + vm.expectRevert(); + vm.prank(user); + multiverse.unwrap(99, 1 ether); + } + + /*////////////////////////////////////////////////////////////// + CREATE QUERY: SUCCESS + //////////////////////////////////////////////////////////////*/ + + function test_CreateQuery_GenesisUniverse_Succeeds() public { + uint256 multiverseRepBefore = genesisRep.balanceOf(address(multiverse)); + uint256 userRepBefore = genesisRep.balanceOf(user); + + vm.prank(user); + multiverse.createQuery(GENESIS_UID, "Q?", 3); + + assertEq(multiverse.queryCount(), 1); + + (uint48 createTime, uint16 numberOfOutcomes, uint248 originUniverse, uint256 fee) = _readQueryHeader(0); + assertEq(createTime, uint48(block.timestamp)); + assertEq(numberOfOutcomes, 3); + assertEq(originUniverse, GENESIS_UID); + assertEq(fee, DEFAULT_FEE); + + assertEq(multiverse.getOutcome(GENESIS_UID, 0), multiverse.NO_REPORT()); + assertEq(multiverse.getOutcomeData(GENESIS_UID, 0).totalStake, 0); + + assertEq(genesisRep.balanceOf(address(multiverse)), multiverseRepBefore + DEFAULT_FEE); + assertEq(genesisRep.balanceOf(user), userRepBefore - DEFAULT_FEE); + } + + function test_CreateQuery_EmitsEvent() public { + vm.expectEmit({ checkTopic1: true, checkTopic2: true, checkTopic3: false, checkData: true }); + emit QueryCreated(0, GENESIS_UID, "Q?", 3); + + vm.prank(user); + multiverse.createQuery(GENESIS_UID, "Q?", 3); + } + + function test_CreateQuery_QueryCountIncrements() public { + vm.startPrank(user); + multiverse.createQuery(GENESIS_UID, "Q1?", 3); + multiverse.createQuery(GENESIS_UID, "Q2?", 3); + vm.stopPrank(); + + assertEq(multiverse.queryCount(), 2); + } + + function test_CreateQuery_BoundaryOutcomes_3() public { + vm.prank(user); + multiverse.createQuery(GENESIS_UID, "Q?", 3); + + (, uint16 numberOfOutcomes,,) = _readQueryHeader(0); + assertEq(numberOfOutcomes, 3); + } + + function test_CreateQuery_BoundaryOutcomes_253() public { + vm.prank(user); + multiverse.createQuery(GENESIS_UID, "Q?", 253); + + (, uint16 numberOfOutcomes,,) = _readQueryHeader(0); + assertEq(numberOfOutcomes, 253); + } + + function test_CreateQuery_FeeZero_Succeeds() public { + feeCtl.setFee(0); + uint256 multiverseRepBefore = genesisRep.balanceOf(address(multiverse)); + + vm.prank(user); + multiverse.createQuery(GENESIS_UID, "Q?", 3); + + (,,, uint256 fee) = _readQueryHeader(0); + assertEq(fee, 0); + assertEq(genesisRep.balanceOf(address(multiverse)), multiverseRepBefore); + } + + function test_CreateQuery_FeeReadFromController() public { + feeCtl.setFee(42); + uint256 multiverseRepBefore = genesisRep.balanceOf(address(multiverse)); + uint256 userRepBefore = genesisRep.balanceOf(user); + + vm.prank(user); + multiverse.createQuery(GENESIS_UID, "Q?", 3); + + (,,, uint256 fee) = _readQueryHeader(0); + assertEq(fee, 42); + assertEq(genesisRep.balanceOf(address(multiverse)), multiverseRepBefore + 42); + assertEq(genesisRep.balanceOf(user), userRepBefore - 42); + } + + /*////////////////////////////////////////////////////////////// + CREATE QUERY: REVERTS + //////////////////////////////////////////////////////////////*/ + + function test_RevertWhen_UniverseDoesNotExist() public { + vm.expectRevert(Multiverse.InvalidUniverse.selector); + vm.prank(user); + multiverse.createQuery(99, "Q?", 3); + } + + function test_RevertWhen_NumberOfOutcomesIsZero() public { + vm.expectRevert(Multiverse.InvalidNumberOfOutcomes.selector); + vm.prank(user); + multiverse.createQuery(GENESIS_UID, "Q?", 0); + } + + function test_RevertWhen_NumberOfOutcomesIsOne() public { + vm.expectRevert(Multiverse.InvalidNumberOfOutcomes.selector); + vm.prank(user); + multiverse.createQuery(GENESIS_UID, "Q?", 1); + } + + function test_RevertWhen_NumberOfOutcomesIsTwo() public { + vm.expectRevert(Multiverse.InvalidNumberOfOutcomes.selector); + vm.prank(user); + multiverse.createQuery(GENESIS_UID, "Q?", 2); + } + + function test_RevertWhen_NumberOfOutcomesExceedsMax() public { + vm.expectRevert(Multiverse.InvalidNumberOfOutcomes.selector); + vm.prank(user); + multiverse.createQuery(GENESIS_UID, "Q?", 254); + } + + function test_RevertWhen_UserHasInsufficientRepBalance() public { + vm.prank(attacker); + genesisRep.approve(address(multiverse), type(uint256).max); + + vm.expectRevert(); + vm.prank(attacker); + multiverse.createQuery(GENESIS_UID, "Q?", 3); + } + + function test_RevertWhen_UserDidNotApproveMultiverse() public { + vm.prank(user); + genesisRep.approve(address(multiverse), 0); + + vm.expectRevert(); + vm.prank(user); + multiverse.createQuery(GENESIS_UID, "Q?", 3); + } + + /*////////////////////////////////////////////////////////////// + HELPERS + //////////////////////////////////////////////////////////////*/ + + function _readQueryHeader(uint256 queryId) + internal + view + returns (uint48 createTime, uint16 numberOfOutcomes, uint248 originUniverse, uint256 fee) + { + (createTime, numberOfOutcomes, originUniverse, fee,) = multiverse.queries(queryId); + } +} From 89a14a1348bbdd80c7dab26c7e47d61695287f28 Mon Sep 17 00:00:00 2001 From: Tanya Bushenyova Date: Wed, 3 Jun 2026 13:26:52 +0300 Subject: [PATCH 8/8] Change data types --- src/Multiverse.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Multiverse.sol b/src/Multiverse.sol index 43b87c6..5988267 100644 --- a/src/Multiverse.sol +++ b/src/Multiverse.sol @@ -45,11 +45,11 @@ contract Multiverse { uint248 originUniverse; uint256 fee; string question; - bytes32[] resolvedUniverses; + uint248[] resolvedUniverses; } struct Outcome { - uint16 outcome; + uint8 outcome; uint256 totalStake; Stake[] stake; }