diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c1c012..21a78ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - run: npm install + - run: npm run lint - run: npm run format:check test: @@ -30,11 +31,6 @@ jobs: node-version: 22 - run: npm install - run: npm test - - uses: actions/cache@v4 - with: - path: fuzzing/corpus - key: fuzzing - - run: npm run fuzz verify: runs-on: ubuntu-latest diff --git a/License.md b/LICENSE.md similarity index 100% rename from License.md rename to LICENSE.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..0aa9a39 --- /dev/null +++ b/README.md @@ -0,0 +1,206 @@ +# Codex Marketplace Contracts + +An implementation of the smart contracts that underlay the Codex +storage network. Its goal is to facilitate storage marketplace +for the Codex's persistance layer. + +## Running + +To run the tests, execute the following commands: + + npm install + npm test + +You can also run fuzzing tests (using [Echidna][echidna]) on the contracts: + + npm run fuzz + +To start a local Ethereum node with the contracts deployed, execute: + + npm start + +### Running the prover + +To run the formal verification rules using Certora, first, make sure you have Java (JDK >= 11.0) installed on your +machine, and then install the Certora CLI + +``` +$ pip install certora-cli +``` + +Once that is done the `certoraRun` command can be used to send CVL specs to the prover. + +You can run Certora's specs with the provided `npm` script: + + npm run verify + +## Deployment + +To deploy the marketplace, you need to specify the network using `--network MY_NETWORK`: + +```bash +npm run deploy -- --network localhost +``` + +Hardhat uses [reconciliation](https://hardhat.org/ignition/docs/advanced/reconciliation) to recover from +errors or resume a previous deployment. In our case, we will likely redeploy a new contract every time, +so we will need +to [clear the previous deployment](https://hardhat.org/ignition/docs/guides/modifications#clearing-an-existing-deployment-with-reset): + +```bash +npm run deploy -- --network testnet --reset +``` + +To reuse a previously deployed `Token` contract, define the environment variable `TOKEN_ADDRESS`. +The deployment script will use `contractAt` from Hardhat Ignition to retrieve the existing contract +instead of deploying a new one. + +When deploying to other network then Hardhat localhost's, you have to specify the Proxy's owner address +using the env. variable `PROXY_ADMIN_ADDRESS`. This account then can perform upgrades to the contract. + +The deployment files are kept under version +control [as recommended by Hardhat](https://hardhat.org/ignition/docs/advanced/versioning), except the build files, +which are 18 MB. + +## Smart contracts overview + +This contract suite deploys two smart contracts: + +1. `Marketplace` smart contract +2. `Vault` smart contract + +The `Marketplace` smart contract implements the storage marketplace logic. Its internal logic is divided into +multiple abstract subcontracts that focus on specific pieces like `Periods`, `Proofs`, `SlotReservations`, and so on, +which are all bundled at the top level of the `Marketplace` contract itself. + +The `Vault` smart contract is a specialized contract designed to safely keep users' funds. It is utilized by the +`Marketplace` contract to delegate all funds' safe-keeping to it. There are several mechanisms in the `Vault` contract +that should prevent a complete "grab & run" of all the funds in case an exploit is found in the `Marketplace` smart +contract. + +### Upgradability + +The `Marketplace` contract employs the contract's upgradability pattern using [ +`TransparentUpgradeableProxy`](https://docs.openzeppelin.com/contracts/5.x/api/proxy#TransparentUpgradeableProxy), which +allows replacing the underlying implementation while preserving the contract address and its storage. The upgrade can be +performed only by the account that is specified during the initial deployment through the `PROXY_ADMIN_ADDRESS` +environment variable. This capability is dedicated to emergency upgrades, as described in +our [Codex Contract Deployment, Upgrades and Security](https://github.com/codex-storage/codex-research/blob/master/design/contract-deployment.md) +document. + +The steps to perform an emergency upgrade are: + +1. Create a new `Marketplace` contract that will incorporate the changes. Name it, for example, `MarketplaceV2`. + - The original `Marketplace` and its abstract subcontracts should not be edited once deployed. + - If you need to make changes in one of the abstract subcontracts, also create a new version copy like `PeriodsV2`. + - **Do not modify any storage variables in the contract!** The upgrade mechanism is not designed for this. +2. Create a new Ignition deployment script that will perform the upgrade. + - Take inspiration from the `marketplace-test-upgrade` module, which performs the upgrade in our test suite. + - The upgrading transaction needs to originate from the account that was specified as `PROXY_ADMIN_ADDRESS` in the + initial deployment. +3. Deploy the upgrade with `hardhat ignition deploy --network `. + +Once the new feature upgrade is planned, the first step when drafting this new version is to reconcile all +the upgrade's changes (if there were any) back into the `Marketplace` contract and any modified subcontract +on the new feature branch. + +#### Safe Multisig Upgrade + +When the `Proxy`'s owner is set to Safe's Multisig Wallet, the upgrade process needs to be modified. The upgrade +deployment module cannot create the `upgradeAndCall` call directly; hence, the deploy module needs only to deploy the +upgraded implementation logic. + +Then, the `upgradeAndCall` call needs to be created through the Safe Wallet UI using the "Transaction Builder," where it +is recommended to input the `ProxyAdmin` ABI, which helps to easily create the `upgradeAndCall` call. The `proxy` +parameter is the Marketplace's Proxy address, the `implementation` parameter is the address of the new upgraded +implementation, and the `data` can be left empty (`0x`). + +## Marketplace overview + +The Codex storage network depends on hosts offering storage to clients of the +network. The smart contracts in this repository handle interactions between +client and hosts as they negotiate and fulfill a contract to store data for a +certain amount of time. + +When all goes well, the client and hosts perform the following steps: + + Client Host Marketplace Contract + | | | + | | + | --------------- request (1) -------------> | + | | + | ----- data (2) ---> | | + | | | + | ----- fill (3) ----> | + | | + | ---- proof (4) ----> | + | | + | ---- proof (4) ----> | + | | + | ---- proof (4) ----> | + | | + | <-- payment (5) ---- | + +1. Client submits a request for storage, containing the size of the data that + it wants to store and the length of time it wants to store it +2. Client makes the data available to hosts +3. Hosts submit storage proofs to fill slots in the contract +4. While the storage contract is active, host prove that they are still + storing the data by responding to frequent random challenges +5. At the end of the contract the hosts are paid + +For full overview +see [Codex Marketplace specification](https://github.com/codex-storage/codex-spec/blob/master/specs/marketplace.md). + +### Storage Contracts + +A storage contract contains of a number of slots. Each of these slots represents +an agreement with a storage host to store a part of the data. Hosts that want to +offer storage can fill a slot in the contract. + +A contract can be negotiated through requests. A request contains the size of +the data, the length of time during which it needs to be stored, and a number of +slots. It also contains the reward that a client is willing to pay and proof +requirements such as how often a proof will need to be submitted by hosts. A +random nonce is included to ensure uniqueness among similar requests. + +When a new storage contract is created the client immediately pays the entire +price of the contract. The payment is only released to the host upon successful +completion of the contract. + +### Collateral + +To motivate a host to remain honest, it must put up some collateral before it is +allowed to participate in storage contracts. The collateral may not be withdrawn +as long as a host is participating in an active storage contract. + +Should a host be misbehaving, then its collateral may be reduced by a certain +percentage (slashed). + +### Proofs + +Hosts are required to submit frequent proofs while a contract is active. These +proofs ensure with a high probability that hosts are still holding on to the +data that they were entrusted with. + +To ensure that hosts are not able to predict and precalculate proofs, these +proofs are based on a random challenge. Currently we use ethereum block hashes +to determine two things: 1) whether or not a proof is required at this point in +time, and 2) the random challenge for the proof. Although hosts will not be able +to predict the exact times at which proofs are required, the frequency of proofs +averages out to a value that was set by the client in the request for storage. + +Hosts have a small period of time in which they are expected to submit a proof. +When that time has expired without seeing a proof, validators are able to point +out the lack of proof. If a host misses too many proofs, it results into a +slashing of its collateral. + +## References + +* [A marketplace for storage + durability](https://github.com/codex-storage/codex-research/blob/master/design/marketplace.md) + (design document) +* [Timing of Storage + Proofs](https://github.com/codex-storage/codex-research/blob/master/design/storage-proof-timing.md) + (design document) +* [Codex Marketplace spec](https://github.com/codex-storage/codex-spec/blob/master/specs/marketplace.md) diff --git a/Readme.md b/Readme.md deleted file mode 100644 index 6b93f5f..0000000 --- a/Readme.md +++ /dev/null @@ -1,170 +0,0 @@ -Codex Contracts -================ - -An experimental implementation of the smart contracts that underlay the Codex -storage network. Its goal is to experiment with the rules around the bidding -process, the storage contracts, the storage proofs and the host collateral. -Neither completeness nor correctness are guaranteed at this moment in time. - -Running -------- - -To run the tests, execute the following commands: - - npm install - npm test - -You can also run fuzzing tests (using [Echidna][echidna]) on the contracts: - - npm run fuzz - -To start a local Ethereum node with the contracts deployed, execute: - - npm start - -Deployment ----------- - -To deploy the marketplace, you need to specify the network using `--network MY_NETWORK`: - -```bash -npm run deploy -- --network localhost -``` - -Hardhat uses [reconciliation](https://hardhat.org/ignition/docs/advanced/reconciliation) to recover from -errors or resume a previous deployment. In our case, we will likely redeploy a new contract every time, -so we will need to [clear the previous deployment](https://hardhat.org/ignition/docs/guides/modifications#clearing-an-existing-deployment-with-reset): - -```bash -npm run deploy -- --network testnet --reset -``` - -To reuse a previously deployed `Token` contract, define the environment variable `TOKEN_ADDRESS`. -The deployment script will use `contractAt` from Hardhat Ignition to retrieve the existing contract -instead of deploying a new one. - -The deployment files are kept under version control [as recommended by Hardhat](https://hardhat.org/ignition/docs/advanced/versioning), except the build files, which are 18 MB. - -Running the prover ------------------- - -To run the formal verification rules using Certora, first, make sure you have Java (JDK >= 11.0) installed on your machine, and then install the Certora CLI - -``` -$ pip install certora-cli -``` - -Once that is done the `certoraRun` command can be used to send CVL specs to the prover. - -You can run Certora's specs with the provided `npm` script: - - npm run verify - - -Overview --------- - -The Codex storage network depends on hosts offering storage to clients of the -network. The smart contracts in this repository handle interactions between -client and hosts as they negotiate and fulfill a contract to store data for a -certain amount of time. - -When all goes well, the client and hosts perform the following steps: - - Client Host Marketplace Contract - | | | - | | - | --------------- request (1) -------------> | - | | - | ----- data (2) ---> | | - | | | - | ----- fill (3) ----> | - | | - | ---- proof (4) ----> | - | | - | ---- proof (4) ----> | - | | - | ---- proof (4) ----> | - | | - | <-- payment (5) ---- | - - 1. Client submits a request for storage, containing the size of the data that - it wants to store and the length of time it wants to store it - 2. Client makes the data available to hosts - 3. Hosts submit storage proofs to fill slots in the contract - 4. While the storage contract is active, host prove that they are still - storing the data by responding to frequent random challenges - 5. At the end of the contract the hosts are paid - -Contracts ---------- - -A storage contract contains of a number of slots. Each of these slots represents -an agreement with a storage host to store a part of the data. Hosts that want to -offer storage can fill a slot in the contract. - -A contract can be negotiated through requests. A request contains the size of -the data, the length of time during which it needs to be stored, and a number of -slots. It also contains the reward that a client is willing to pay and proof -requirements such as how often a proof will need to be submitted by hosts. A -random nonce is included to ensure uniqueness among similar requests. - -When a new storage contract is created the client immediately pays the entire -price of the contract. The payment is only released to the host upon successful -completion of the contract. - -Collateral ----------- - -To motivate a host to remain honest, it must put up some collateral before it is -allowed to participate in storage contracts. The collateral may not be withdrawn -as long as a host is participating in an active storage contract. - -Should a host be misbehaving, then its collateral may be reduced by a certain -percentage (slashed). - -Proofs ------- - -Hosts are required to submit frequent proofs while a contract is active. These -proofs ensure with a high probability that hosts are still holding on to the -data that they were entrusted with. - -To ensure that hosts are not able to predict and precalculate proofs, these -proofs are based on a random challenge. Currently we use ethereum block hashes -to determine two things: 1) whether or not a proof is required at this point in -time, and 2) the random challenge for the proof. Although hosts will not be able -to predict the exact times at which proofs are required, the frequency of proofs -averages out to a value that was set by the client in the request for storage. - -Hosts have a small period of time in which they are expected to submit a proof. -When that time has expired without seeing a proof, validators are able to point -out the lack of proof. If a host misses too many proofs, it results into a -slashing of its collateral. - -References ----------- - - * [A marketplace for storage - durability](https://github.com/codex-storage/codex-research/blob/master/design/marketplace.md) - (design document) - * [Timing of Storage - Proofs](https://github.com/codex-storage/codex-research/blob/master/design/storage-proof-timing.md) - (design document) - -To Do ------ - - * Contract repair - - Allow another host to take over a slot in the contract when the original - host missed too many proofs. - - * Reward validators - - A validator that points out missed proofs should be compensated for its - vigilance and for the gas costs of invoking the smart contract. - - * Analysis and optimization of gas usage - -[echidna]: https://github.com/crytic/echidna diff --git a/certora/harness/MarketplaceHarness.sol b/certora/harness/MarketplaceHarness.sol index c3119c9..072e43e 100644 --- a/certora/harness/MarketplaceHarness.sol +++ b/certora/harness/MarketplaceHarness.sol @@ -10,10 +10,6 @@ import {RequestId, SlotId} from "../../contracts/Requests.sol"; import {Requests} from "../../contracts/Requests.sol"; contract MarketplaceHarness is Marketplace { - constructor(MarketplaceConfig memory config, IERC20 token, IGroth16Verifier verifier) - Marketplace(config, token, verifier) - {} - function publicPeriodEnd(Period period) public view returns (uint64) { return _periodEnd(period); } diff --git a/configuration/configuration.js b/configuration/configuration.js index 2d0ec3d..bb45d3d 100644 --- a/configuration/configuration.js +++ b/configuration/configuration.js @@ -1,4 +1,5 @@ const fs = require("fs") +const { loadZkeyHash } = require("../verifier/verifier.js") const BASE_PATH = __dirname + "/networks" @@ -23,6 +24,17 @@ const DEFAULT_CONFIGURATION = { requestDurationLimit: 60*60*24*30 // 30 days } +function getDefaultConfig(networkName) { + if (networkName === undefined) { + throw new TypeError("Network name needs to be specified!") + } + + const zkeyHash = loadZkeyHash(networkName) + const config = loadConfiguration(networkName) + config.proofs.zkeyHash = zkeyHash + return config +} + function loadConfiguration(name) { const path = `${BASE_PATH}/${name}/configuration.js` if (fs.existsSync(path)) { @@ -32,4 +44,4 @@ function loadConfiguration(name) { } } -module.exports = { loadConfiguration } +module.exports = { loadConfiguration, getDefaultConfig } diff --git a/contracts/FuzzMarketplace.sol b/contracts/FuzzMarketplace.sol deleted file mode 100644 index 3291e36..0000000 --- a/contracts/FuzzMarketplace.sol +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import "./TestToken.sol"; -import "./Marketplace.sol"; -import "./TestVerifier.sol"; - -contract FuzzMarketplace is Marketplace { - constructor() - Marketplace( - MarketplaceConfig( - CollateralConfig(10, 5, 10, 20), - ProofConfig(10, 5, 64, 67, ""), - SlotReservationsConfig(20), - 60 * 60 * 24 * 30 // 30 days - ), - new TestToken(), - new TestVerifier() - ) - // solhint-disable-next-line no-empty-blocks - { - - } - - // Properties to be tested through fuzzing - - MarketplaceTotals private _lastSeenTotals; - - function neverDecreaseTotals() public { - assert(_marketplaceTotals.received >= _lastSeenTotals.received); - assert(_marketplaceTotals.sent >= _lastSeenTotals.sent); - _lastSeenTotals = _marketplaceTotals; - } - - function neverLoseFunds() public view { - uint256 total = _marketplaceTotals.received - _marketplaceTotals.sent; - assert(token().balanceOf(address(this)) >= total); - } -} diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 8b0a29c..93c0398 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.28; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/proxy/utils/Initializable.sol"; import "@openzeppelin/contracts/utils/math/Math.sol"; import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import "./Configuration.sol"; @@ -12,7 +13,7 @@ import "./StateRetrieval.sol"; import "./Endian.sol"; import "./Groth16.sol"; -contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { +contract Marketplace is Initializable, SlotReservations, Proofs, StateRetrieval, Endian { error Marketplace_RepairRewardPercentageTooHigh(); error Marketplace_SlashPercentageTooHigh(); error Marketplace_MaximumSlashingTooHigh(); @@ -46,7 +47,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { using Requests for Request; using AskHelpers for Ask; - IERC20 private immutable _token; + IERC20 private _token; MarketplaceConfig private _config; mapping(RequestId => Request) private _requests; @@ -96,11 +97,20 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint64 slotIndex; } - constructor( + constructor() { + // In case that the contract would get deployed without initialization + // this prevents attackers to call the initializations themselves with + // potentially malicious initialization values. + _disableInitializers(); + } + + function initialize ( MarketplaceConfig memory config, IERC20 token_, IGroth16Verifier verifier - ) SlotReservations(config.reservations) Proofs(config.proofs, verifier) { + ) public initializer { + _initializeSlotReservations(config.reservations); + _initializeProofs(config.proofs, verifier); _token = token_; if (config.collateral.repairRewardPercentage > 100) diff --git a/contracts/Periods.sol b/contracts/Periods.sol index 5e18ee1..08a9353 100644 --- a/contracts/Periods.sol +++ b/contracts/Periods.sol @@ -1,14 +1,16 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; -contract Periods { +import "@openzeppelin/contracts/proxy/utils/Initializable.sol"; + +contract Periods is Initializable { error Periods_InvalidSecondsPerPeriod(); type Period is uint64; - uint64 internal immutable _secondsPerPeriod; + uint64 internal _secondsPerPeriod; - constructor(uint64 secondsPerPeriod) { + function _initializePeriods(uint64 secondsPerPeriod) internal onlyInitializing { if (secondsPerPeriod == 0) { revert Periods_InvalidSecondsPerPeriod(); } diff --git a/contracts/Proofs.sol b/contracts/Proofs.sol index 1dfc7ce..ba9df9e 100644 --- a/contracts/Proofs.sol +++ b/contracts/Proofs.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; +import "@openzeppelin/contracts/proxy/utils/Initializable.sol"; + import "./Configuration.sol"; import "./Requests.sol"; import "./Periods.sol"; @@ -26,15 +28,17 @@ abstract contract Proofs is Periods { /** * Creation of the contract requires at least 256 mined blocks! * @param config Proving configuration + * @param verifier Proof verifier */ - constructor( + function _initializeProofs( ProofConfig memory config, IGroth16Verifier verifier - ) Periods(config.period) { + ) internal onlyInitializing { if (block.number <= 256) { revert Proofs_InsufficientBlockHeight(); } + _initializePeriods(config.period); _config = config; _verifier = verifier; } diff --git a/contracts/Proxies.sol b/contracts/Proxies.sol new file mode 100644 index 0000000..6560c68 --- /dev/null +++ b/contracts/Proxies.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + + +import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; \ No newline at end of file diff --git a/contracts/SlotReservations.sol b/contracts/SlotReservations.sol index 8faa87b..bc7c392 100644 --- a/contracts/SlotReservations.sol +++ b/contracts/SlotReservations.sol @@ -1,18 +1,20 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; +import "@openzeppelin/contracts/proxy/utils/Initializable.sol"; import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + import "./Requests.sol"; import "./Configuration.sol"; -abstract contract SlotReservations { +abstract contract SlotReservations is Initializable { using EnumerableSet for EnumerableSet.AddressSet; error SlotReservations_ReservationNotAllowed(); mapping(SlotId => EnumerableSet.AddressSet) internal _reservations; SlotReservationsConfig private _config; - constructor(SlotReservationsConfig memory config) { + function _initializeSlotReservations(SlotReservationsConfig memory config) internal onlyInitializing { _config = config; } diff --git a/contracts/TestMarketplace.sol b/contracts/TestMarketplace.sol index c18d369..815d8ea 100644 --- a/contracts/TestMarketplace.sol +++ b/contracts/TestMarketplace.sol @@ -5,14 +5,6 @@ import "./Marketplace.sol"; // exposes internal functions of Marketplace for testing contract TestMarketplace is Marketplace { - constructor( - MarketplaceConfig memory config, - IERC20 token, - IGroth16Verifier verifier - ) - Marketplace(config, token, verifier) // solhint-disable-next-line no-empty-blocks - {} - function forciblyFreeSlot(SlotId slotId) public { _forciblyFreeSlot(slotId); } diff --git a/contracts/TestMarketplaceUpgrade.sol b/contracts/TestMarketplaceUpgrade.sol new file mode 100644 index 0000000..cc23e03 --- /dev/null +++ b/contracts/TestMarketplaceUpgrade.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "./Marketplace.sol"; + +// exposes internal functions of Marketplace for testing +contract TestMarketplaceUpgraded is Marketplace { + + function newShinyMethod() public pure returns (uint256) { + return 42; + } +} diff --git a/contracts/TestPeriods.sol b/contracts/TestPeriods.sol new file mode 100644 index 0000000..d21ff17 --- /dev/null +++ b/contracts/TestPeriods.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "./Periods.sol"; + +contract TestPeriods is Periods { + + function initialize ( + uint64 secondsPerPeriod + ) public initializer { + _initializePeriods(secondsPerPeriod); + } +} diff --git a/contracts/TestProofs.sol b/contracts/TestProofs.sol index 4d49aad..bca6057 100644 --- a/contracts/TestProofs.sol +++ b/contracts/TestProofs.sol @@ -13,11 +13,12 @@ contract TestProofs is Proofs { // private to internal, which may cause problems in the Marketplace contract. ProofConfig private _proofConfig; - constructor( + function initialize ( ProofConfig memory config, IGroth16Verifier verifier - ) Proofs(config, verifier) { + ) public initializer { _proofConfig = config; + _initializeProofs(config, verifier); } function slotState(SlotId slotId) public view override returns (SlotState) { diff --git a/contracts/TestSlotReservations.sol b/contracts/TestSlotReservations.sol index 3793223..f72a1fd 100644 --- a/contracts/TestSlotReservations.sol +++ b/contracts/TestSlotReservations.sol @@ -8,9 +8,11 @@ contract TestSlotReservations is SlotReservations { mapping(SlotId => SlotState) private _states; - // solhint-disable-next-line no-empty-blocks - constructor(SlotReservationsConfig memory config) SlotReservations(config) {} - + function initialize ( + SlotReservationsConfig memory config + ) public initializer { + _initializeSlotReservations(config); + } function contains(SlotId slotId, address host) public view returns (bool) { return _reservations[slotId].contains(host); } diff --git a/ignition/modules/marketplace-test-upgrade.js b/ignition/modules/marketplace-test-upgrade.js new file mode 100644 index 0000000..3ff47c9 --- /dev/null +++ b/ignition/modules/marketplace-test-upgrade.js @@ -0,0 +1,36 @@ +const { buildModule } = require('@nomicfoundation/hardhat-ignition/modules') +const MarketplaceModule = require("./marketplace.js") + +/** + * This module upgrades the Marketplace's Proxy with a new implementation. + * It deploys the new Marketplace contract and then calls the Proxy with + * the `upgradeAndCall` function, which swaps the implementations. + */ +const upgradeModule = buildModule('UpgradeProxyImplementation', (m) => { + const config = hre.network.config + + if (!(config && config.tags && config.tags.includes("local"))) { + throw new Error("Module is not meant for real deployments!") + } + + const proxyAdminOwner = m.getAccount(9) + const marketplaceUpgraded = m.contract("TestMarketplaceUpgraded", []) + const {proxyAdmin, proxy, token} = m.useModule(MarketplaceModule); + + m.call(proxyAdmin, "upgradeAndCall", [proxy, marketplaceUpgraded, "0x"], { + from: proxyAdminOwner, + }); + + return { proxyAdmin, proxy, token }; +}) + +/** + * The main module that represents the upgraded Marketplace contract. + */ +module.exports = buildModule('MarketplaceUpgraded', (m) => { + const { proxy, proxyAdmin, token } = m.useModule(upgradeModule) + + const marketplace = m.contractAt('TestMarketplaceUpgraded', proxy) + + return { marketplace, proxy, proxyAdmin, token } +}) diff --git a/ignition/modules/marketplace.js b/ignition/modules/marketplace.js index e0798eb..397acad 100644 --- a/ignition/modules/marketplace.js +++ b/ignition/modules/marketplace.js @@ -1,43 +1,149 @@ -const { buildModule } = require("@nomicfoundation/hardhat-ignition/modules") -const { loadZkeyHash } = require("../../verifier/verifier.js") -const { loadConfiguration } = require("../../configuration/configuration.js") +const { buildModule } = require('@nomicfoundation/hardhat-ignition/modules') +const { getDefaultConfig } = require("../../configuration/configuration.js") const TokenModule = require("./token.js") const VerifierModule = require("./verifier.js") -function getDefaultConfig() { - const zkeyHash = loadZkeyHash(hre.network.name) - const config = loadConfiguration(hre.network.name) - config.proofs.zkeyHash = zkeyHash - return config -} -module.exports = buildModule("Marketplace", (m) => { - const { token } = m.useModule(TokenModule) - const { verifier } = m.useModule(VerifierModule) - const configuration = m.getParameter("configuration", getDefaultConfig()) - - const marketplace = m.contract( - "Marketplace", - [configuration, token, verifier], - {}, - ) +/** + * Module that deploy the Marketplace logic + */ +const marketplaceLogicModule = buildModule("MarketplaceLogic", (m) => { + const marketplace = m.contract("Marketplace", []) let testMarketplace const config = hre.network.config if (config && config.tags && config.tags.includes("local")) { - const { testVerifier } = m.useModule(VerifierModule) - - testMarketplace = m.contract( - "TestMarketplace", - [configuration, token, testVerifier], - {}, - ) + testMarketplace = m.contract("TestMarketplace", []) } return { marketplace, testMarketplace, - token, } }) + + +/** + * This module deploy Proxy with Marketplace contract used as implementation + * and it initializes the Marketplace in the Proxy context. + */ +const proxyModule = buildModule('Proxy', (m) => { + const deployer = m.getAccount(0) + const config = hre.network.config + + + // This address is the owner of the ProxyAdmin contract, + // so it will be the only account that can upgrade the proxy when needed. + let proxyAdminOwner + + if (config && config.tags && config.tags.includes("local")) { + // The Proxy Admin is not allowed to make "forwarded" calls through Proxy, + // it can only upgrade it, hence this account must not be used for example in tests. + proxyAdminOwner = process.env.PROXY_ADMIN_ADDRESS || m.getAccount(9) + } else { + if (!process.env.PROXY_ADMIN_ADDRESS) { + throw new Error("In non-Hardhat network you need to specify PROXY_ADMIN_ADDRESS env. variable") + } + + proxyAdminOwner = process.env.PROXY_ADMIN_ADDRESS + } + + const { marketplace } = m.useModule(marketplaceLogicModule) + const { token } = m.useModule(TokenModule) + const { verifier } = m.useModule(VerifierModule) + const configuration = m.getParameter("configuration", getDefaultConfig(hre.network.name)) + const encodedMarketplaceInitializerCall = m.encodeFunctionCall( + marketplace, + "initialize", + [configuration, token, verifier] + ); + + // The TransparentUpgradeableProxy contract creates the ProxyAdmin within its constructor. + const proxy = m.contract( + 'TransparentUpgradeableProxy', + [ + marketplace, + proxyAdminOwner, + encodedMarketplaceInitializerCall, + ], + { from: deployer }, + ) + + + // We need to get the address of the ProxyAdmin contract that was created by the TransparentUpgradeableProxy + // so that we can use it to upgrade the proxy later. + const proxyAdminAddress = m.readEventArgument( + proxy, + 'AdminChanged', + 'newAdmin' + ) + + // Here we use m.contractAt(...) to create a contract instance for the ProxyAdmin that we can interact with later to upgrade the proxy. + const proxyAdmin = m.contractAt('ProxyAdmin', proxyAdminAddress) + + + return { proxyAdmin, proxy, token } +}) + +/** + * This module deploy Proxy with TestMarketplace contract used for testing purposes. + */ +const testProxyModule = buildModule('TestProxy', (m) => { + const deployer = m.getAccount(0) + const config = hre.network.config + + // We allow testing contract only in local/Hardhat network + if (!(config && config.tags && config.tags.includes("local"))) { + return { testProxy: undefined } + } + + + let proxyAdminOwner = process.env.PROXY_ADMIN_ADDRESS || m.getAccount(9) + const { testMarketplace } = m.useModule(marketplaceLogicModule) + const { token } = m.useModule(TokenModule) + const { testVerifier } = m.useModule(VerifierModule) + const configuration = m.getParameter("configuration", getDefaultConfig(hre.network.name)) + const encodedMarketplaceInitializerCall = m.encodeFunctionCall( + testMarketplace, + "initialize", + [configuration, token, testVerifier] + ); + + const testProxy = m.contract( + 'TransparentUpgradeableProxy', + [ + testMarketplace, + proxyAdminOwner, + encodedMarketplaceInitializerCall, + ], + { from: deployer }, + ) + + return { testProxy } +}) + +/** + * The main module that represents Marketplace contract. + * Underneath there is the deployed Proxy contract with Markeplace's logic used as proxy's implementation + * and initilized proxy's context with Marketplace's configuration. + */ +module.exports = buildModule('Marketplace', (m) => { + const { proxy, proxyAdmin, token } = m.useModule(proxyModule) + const config = hre.network.config + + // We use the Proxy contract as it would be Marketplace contract + const marketplace = m.contractAt('Marketplace', proxy) + + // We allow testing contract only in local/Hardhat network + if (config && config.tags && config.tags.includes("local")) { + const { testProxy } = m.useModule(testProxyModule) + const testMarketplace = m.contractAt('TestMarketplace', testProxy) + + return { marketplace, proxy, proxyAdmin, testMarketplace, token } + } + + // Return the contract instance, along with the original proxy and proxyAdmin contracts + // so that they can be used by other modules, or in tests and scripts. + return { marketplace, proxy, proxyAdmin, token } +}) diff --git a/ignition/modules/periods.js b/ignition/modules/periods.js index c3a352a..c2a3b96 100644 --- a/ignition/modules/periods.js +++ b/ignition/modules/periods.js @@ -2,8 +2,9 @@ const { buildModule } = require("@nomicfoundation/hardhat-ignition/modules") module.exports = buildModule("Periods", (m) => { const secondsPerPeriod = m.getParameter("secondsPerPeriod", 0) + const periods = m.contract("TestPeriods", []) - const periods = m.contract("Periods", [secondsPerPeriod], {}) + m.call(periods, "initialize", [secondsPerPeriod]); return { periods } }) diff --git a/ignition/modules/proofs.js b/ignition/modules/proofs.js index bc71762..570f663 100644 --- a/ignition/modules/proofs.js +++ b/ignition/modules/proofs.js @@ -5,7 +5,8 @@ module.exports = buildModule("Proofs", (m) => { const { verifier } = m.useModule(VerifierModule) const configuration = m.getParameter("configuration", null) - const testProofs = m.contract("TestProofs", [configuration, verifier], {}) + const testProofs = m.contract("TestProofs", []) + m.call(testProofs, "initialize", [configuration, verifier]) return { testProofs } }) diff --git a/ignition/modules/slot-reservations.js b/ignition/modules/slot-reservations.js index d795381..64989f2 100644 --- a/ignition/modules/slot-reservations.js +++ b/ignition/modules/slot-reservations.js @@ -2,12 +2,9 @@ const { buildModule } = require("@nomicfoundation/hardhat-ignition/modules") module.exports = buildModule("SlotReservations", (m) => { const configuration = m.getParameter("configuration", null) + const testSlotReservations = m.contract("TestSlotReservations", []) - const testSlotReservations = m.contract( - "TestSlotReservations", - [configuration], - {}, - ) + m.call(testSlotReservations, "initialize", [configuration]); return { testSlotReservations } }) diff --git a/package-lock.json b/package-lock.json index da57f6a..669ed22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "license": "MIT", "devDependencies": { "@nomicfoundation/hardhat-chai-matchers": "^2.0.8", - "@nomicfoundation/hardhat-ignition-ethers": "^0.15.11", + "@nomicfoundation/hardhat-ignition-ethers": "^0.15.12", "@nomicfoundation/hardhat-toolbox": "^5.0.0", "@openzeppelin/contracts": "^5.3.0", "@stdlib/stats-binomial-test": "^0.2.2", diff --git a/package.json b/package.json index 8d972d4..8d88f5c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "codex-contracts-eth", "license": "MIT", "scripts": { - "test": "npm run lint && hardhat test", + "test": "hardhat test", "fuzz": "hardhat compile && fuzzing/fuzz.sh", "start": "concurrently --names \"hardhat,deployment\" --prefix \"[{time} {name}]\" \"hardhat node\" \"sleep 2 && npm run mine && npm run deploy -- --network localhost\"", "compile": "hardhat compile", @@ -18,10 +18,10 @@ "gas:report": "REPORT_GAS=true hardhat test" }, "devDependencies": { - "@openzeppelin/contracts": "^5.3.0", "@nomicfoundation/hardhat-chai-matchers": "^2.0.8", - "@nomicfoundation/hardhat-ignition-ethers": "^0.15.11", + "@nomicfoundation/hardhat-ignition-ethers": "^0.15.12", "@nomicfoundation/hardhat-toolbox": "^5.0.0", + "@openzeppelin/contracts": "^5.3.0", "@stdlib/stats-binomial-test": "^0.2.2", "chai": "^4.5.0", "ethers": "6.14.4", diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index ee3311a..40e9433 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -41,6 +41,7 @@ const { } = require("./evm") const { getBytes } = require("ethers") const MarketplaceModule = require("../ignition/modules/marketplace") +const MarketplaceUpgradeModule = require("../ignition/modules/marketplace-test-upgrade") const { assertDeploymentRejectedWithCustomError } = require("./helpers") const ACCOUNT_STARTING_BALANCE = 1_000_000_000_000_000n @@ -62,7 +63,7 @@ describe("Marketplace constructor", function () { const promise = ignition.deploy(MarketplaceModule, { parameters: { - Marketplace: { + TestProxy: { configuration: config, }, }, @@ -86,11 +87,9 @@ describe("Marketplace constructor", function () { config.collateral.slashPercentage = 1 config.collateral.maxNumberOfSlashes = 101 - const expectedError = "Marketplace_MaximumSlashingTooHigh" - const promise = ignition.deploy(MarketplaceModule, { parameters: { - Marketplace: { + TestProxy: { configuration: config, }, }, @@ -103,6 +102,108 @@ describe("Marketplace constructor", function () { }) }) +// Currently, Hardhat Ignition does not track deployments in the Hardhat network, +// so when Hardhat runs tests on its network, Ignition will deploy new contracts +// every time it is deploying something. This prevents proper testing of the upgradability +// because a new Proxy will be deployed on the "upgrade deployment," and it won't reuse the +// original one. +// This can be tested manually by running `hardhat node` and then `hardhat test --network localhost`. +// But even then, the test "should share the same storage" is having issues, which I suspect +// are related to different network usage. +// Blocked until this issue is resolved: https://github.com/NomicFoundation/hardhat/issues/6927 +describe.skip("Marketplace upgrades", function () { + const config = exampleConfiguration() + let originalMarketplace, proxy, token, request, client + enableRequestAssertions() + + beforeEach(async function () { + await snapshot() + await ensureMinimumBlockHeight(256) + const { + marketplace, + proxy: deployedProxy, + token: token_, + } = await ignition.deploy(MarketplaceModule, { + deploymentId: "test", + parameters: { + TestProxy: { + configuration: config, + }, + }, + }) + + proxy = deployedProxy + originalMarketplace = marketplace + token = token_ + + await ensureMinimumBlockHeight(256) + ;[client] = await ethers.getSigners() + + request = await exampleRequest() + request.client = client.address + + token = token.connect(client) + originalMarketplace = marketplace.connect(client) + }) + + afterEach(async function () { + await revert() + }) + + it("should get upgraded", async () => { + expect(() => originalMarketplace.newShinyMethod()).to.throw(TypeError) + + const { marketplace: upgradedMarketplace, proxy: upgradedProxy } = + await ignition.deploy(MarketplaceUpgradeModule, { + deploymentId: "test", + parameters: { + TestProxy: { + configuration: config, + }, + }, + }) + expect(await upgradedMarketplace.newShinyMethod()).to.equal(42) + expect(await originalMarketplace.getAddress()).to.equal( + await upgradedMarketplace.getAddress(), + ) + expect(await originalMarketplace.configuration()).to.deep.equal( + await upgradedMarketplace.configuration(), + ) + }) + + it("should share the same storage", async () => { + // Old implementation not supporting the shiny method + expect(() => originalMarketplace.newShinyMethod()).to.throw(TypeError) + + // We create a Request on the old implementation + await token.approve( + await originalMarketplace.getAddress(), + maxPrice(request), + ) + const now = await currentTime() + await setNextBlockTimestamp(now) + const expectedExpiry = now + request.expiry + await expect(originalMarketplace.requestStorage(request)) + .to.emit(originalMarketplace, "StorageRequested") + .withArgs(requestId(request), askToArray(request.ask), expectedExpiry) + + // Assert that the data is there + expect( + await originalMarketplace.getRequest(requestId(request)), + ).to.be.request(request) + + // Upgrade Marketplace + const { marketplace: upgradedMarketplace, token: _token } = + await ignition.deploy(MarketplaceUpgradeModule) + expect(await upgradedMarketplace.newShinyMethod()).to.equal(42) + + // Assert that the data is there + expect( + await upgradedMarketplace.getRequest(requestId(request)), + ).to.be.request(request) + }) +}) + describe("Marketplace", function () { const proof = exampleProof() const config = exampleConfiguration() @@ -143,7 +244,8 @@ describe("Marketplace", function () { MarketplaceModule, { parameters: { - Marketplace: { + TestProxy: { + // TestMarketplace initialization is done in the TestProxy module configuration: config, }, },