From 17cb41726b90149c78d713a2f84f5b71b4f94562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Uhl=C3=AD=C5=99?= Date: Thu, 19 Jun 2025 13:04:24 +0200 Subject: [PATCH 1/4] feat: upgradable Marketplace contract --- .github/workflows/ci.yml | 1 + Readme.md | 3 + contracts/FuzzMarketplace.sol | 39 ------- contracts/Marketplace.sol | 11 +- contracts/Periods.sol | 8 +- contracts/Proofs.sol | 8 +- contracts/Proxies.sol | 6 + contracts/SlotReservations.sol | 6 +- contracts/TestMarketplace.sol | 8 -- contracts/TestPeriods.sol | 13 +++ contracts/TestProofs.sol | 5 +- contracts/TestSlotReservations.sol | 8 +- ignition/modules/marketplace.js | 151 ++++++++++++++++++++++---- ignition/modules/periods.js | 3 +- ignition/modules/proofs.js | 3 +- ignition/modules/slot-reservations.js | 7 +- package-lock.json | 2 +- package.json | 6 +- test/Marketplace.test.js | 6 +- 19 files changed, 198 insertions(+), 96 deletions(-) delete mode 100644 contracts/FuzzMarketplace.sol create mode 100644 contracts/Proxies.sol create mode 100644 contracts/TestPeriods.sol diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c1c012..65250b3 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: diff --git a/Readme.md b/Readme.md index 6b93f5f..7466796 100644 --- a/Readme.md +++ b/Readme.md @@ -43,6 +43,9 @@ To reuse a previously deployed `Token` contract, define the environment variable 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. Running the prover 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..8a09004 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,13 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint64 slotIndex; } - constructor( + 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/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.js b/ignition/modules/marketplace.js index e0798eb..b0fc732 100644 --- a/ignition/modules/marketplace.js +++ b/ignition/modules/marketplace.js @@ -1,4 +1,4 @@ -const { buildModule } = require("@nomicfoundation/hardhat-ignition/modules") +const { buildModule } = require('@nomicfoundation/hardhat-ignition/modules') const { loadZkeyHash } = require("../../verifier/verifier.js") const { loadConfiguration } = require("../../configuration/configuration.js") const TokenModule = require("./token.js") @@ -11,33 +11,146 @@ function getDefaultConfig() { 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()) + 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()) + 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 153f9a1..9c472f7 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -62,7 +62,7 @@ describe("Marketplace constructor", function () { const promise = ignition.deploy(MarketplaceModule, { parameters: { - Marketplace: { + TestProxy: { configuration: config, }, }, @@ -90,7 +90,7 @@ describe("Marketplace constructor", function () { const promise = ignition.deploy(MarketplaceModule, { parameters: { - Marketplace: { + TestProxy: { configuration: config, }, }, @@ -143,7 +143,7 @@ describe("Marketplace", function () { MarketplaceModule, { parameters: { - Marketplace: { + TestProxy: { configuration: config, }, }, From 5717ff071336befba4b8c7b80758588e93e4fb6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Uhl=C3=AD=C5=99?= Date: Thu, 26 Jun 2025 09:58:38 +0200 Subject: [PATCH 2/4] docs: upgrade documentation and tweaks --- License.md => LICENSE.md | 0 Readme.md => README.md | 148 ++++++++++++++++++++++----------------- 2 files changed, 85 insertions(+), 63 deletions(-) rename License.md => LICENSE.md (100%) rename Readme.md => README.md (50%) 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 similarity index 50% rename from Readme.md rename to README.md index 7466796..78380c0 100644 --- a/Readme.md +++ b/README.md @@ -1,13 +1,10 @@ -Codex Contracts -================ +# Codex Marketplace 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. +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 -------- +## Running To run the tests, execute the following commands: @@ -22,8 +19,22 @@ To start a local Ethereum node with the contracts deployed, execute: npm start -Deployment ----------- +### 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`: @@ -31,9 +42,10 @@ To deploy the marketplace, you need to specify the network using `--network MY_N 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): +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 @@ -46,26 +58,53 @@ 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. +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 ------------------- +## Smart contracts overview -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 +This contract suite deploys two smart contracts: -``` -$ pip install certora-cli -``` +1. `Marketplace` smart contract +2. `Vault` smart contract -Once that is done the `certoraRun` command can be used to send CVL specs to the prover. +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. -You can run Certora's specs with the provided `npm` script: +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. - npm run verify +### 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. -Overview --------- +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. + +## 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 @@ -91,16 +130,18 @@ When all goes well, the client and hosts perform the following steps: | | | <-- 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 +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 ---------- +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 @@ -116,8 +157,7 @@ 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 ----------- +### 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 @@ -126,8 +166,7 @@ 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 ------- +### 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 @@ -145,29 +184,12 @@ 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 ----------- +## 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 +* [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) From 03b52273560dc3c912fedbb7844cb13f8809a8e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Uhl=C3=AD=C5=99?= Date: Thu, 26 Jun 2025 09:59:05 +0200 Subject: [PATCH 3/4] test: upgrade tests --- .github/workflows/ci.yml | 5 - certora/harness/MarketplaceHarness.sol | 4 - configuration/configuration.js | 14 ++- contracts/Marketplace.sol | 7 ++ contracts/TestMarketplaceUpgrade.sol | 12 +++ ignition/modules/marketplace-test-upgrade.js | 36 +++++++ ignition/modules/marketplace.js | 13 +-- test/Marketplace.test.js | 106 ++++++++++++++++++- 8 files changed, 175 insertions(+), 22 deletions(-) create mode 100644 contracts/TestMarketplaceUpgrade.sol create mode 100644 ignition/modules/marketplace-test-upgrade.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65250b3..21a78ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,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/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/Marketplace.sol b/contracts/Marketplace.sol index 8a09004..93c0398 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -97,6 +97,13 @@ contract Marketplace is Initializable, SlotReservations, Proofs, StateRetrieval, uint64 slotIndex; } + 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_, 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/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 b0fc732..397acad 100644 --- a/ignition/modules/marketplace.js +++ b/ignition/modules/marketplace.js @@ -1,15 +1,8 @@ const { buildModule } = require('@nomicfoundation/hardhat-ignition/modules') -const { loadZkeyHash } = require("../../verifier/verifier.js") -const { loadConfiguration } = require("../../configuration/configuration.js") +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 that deploy the Marketplace logic @@ -59,7 +52,7 @@ const proxyModule = buildModule('Proxy', (m) => { const { marketplace } = m.useModule(marketplaceLogicModule) const { token } = m.useModule(TokenModule) const { verifier } = m.useModule(VerifierModule) - const configuration = m.getParameter("configuration", getDefaultConfig()) + const configuration = m.getParameter("configuration", getDefaultConfig(hre.network.name)) const encodedMarketplaceInitializerCall = m.encodeFunctionCall( marketplace, "initialize", @@ -110,7 +103,7 @@ const testProxyModule = buildModule('TestProxy', (m) => { const { testMarketplace } = m.useModule(marketplaceLogicModule) const { token } = m.useModule(TokenModule) const { testVerifier } = m.useModule(VerifierModule) - const configuration = m.getParameter("configuration", getDefaultConfig()) + const configuration = m.getParameter("configuration", getDefaultConfig(hre.network.name)) const encodedMarketplaceInitializerCall = m.encodeFunctionCall( testMarketplace, "initialize", diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 9c472f7..6237f20 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 @@ -86,8 +87,6 @@ describe("Marketplace constructor", function () { config.collateral.slashPercentage = 1 config.collateral.maxNumberOfSlashes = 101 - const expectedError = "Marketplace_MaximumSlashingTooHigh" - const promise = ignition.deploy(MarketplaceModule, { parameters: { TestProxy: { @@ -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() @@ -144,6 +245,7 @@ describe("Marketplace", function () { { parameters: { TestProxy: { + // TestMarketplace initialization is done in the TestProxy module configuration: config, }, }, From 64174d8ce666e33d21e17acea76b2b66e0f1b6c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Uhl=C3=AD=C5=99?= Date: Tue, 1 Jul 2025 14:40:59 +0200 Subject: [PATCH 4/4] docs: safe multisig upgrade documentation --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 78380c0..0aa9a39 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,17 @@ Once the new feature upgrade is planned, the first step when drafting this new v 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