From 341b303789a895fff13eb362b41fa9a226ec0cbc Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Mon, 24 Feb 2025 16:32:13 +0100 Subject: [PATCH 01/38] marketplace: use SafeERC20 for transfers --- contracts/Marketplace.sol | 40 ++++++++++----------------------------- 1 file changed, 10 insertions(+), 30 deletions(-) diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 8b0a29c..c159c0d 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/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/utils/math/Math.sol"; import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import "./Configuration.sol"; @@ -30,7 +31,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { error Marketplace_SlotNotFree(); error Marketplace_InvalidSlotHost(); error Marketplace_AlreadyPaid(); - error Marketplace_TransferFailed(); error Marketplace_UnknownRequest(); error Marketplace_InvalidState(); error Marketplace_StartNotBeforeExpiry(); @@ -41,6 +41,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { error Marketplace_NothingToWithdraw(); error Marketplace_DurationExceedsLimit(); + using SafeERC20 for IERC20; using EnumerableSet for EnumerableSet.Bytes32Set; using EnumerableSet for EnumerableSet.AddressSet; using Requests for Request; @@ -171,7 +172,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 amount = request.maxPrice(); _requestContexts[id].fundsToReturnToClient = amount; _marketplaceTotals.received += amount; - _transferFrom(msg.sender, amount); + _token.safeTransferFrom(msg.sender, address(this), amount); emit StorageRequested(id, request.ask, _requestContexts[id].expiresAt); } @@ -231,7 +232,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { } else { collateralAmount = collateralPerSlot; } - _transferFrom(msg.sender, collateralAmount); + _token.safeTransferFrom(msg.sender, address(this), collateralAmount); _marketplaceTotals.received += collateralAmount; slot.currentCollateral = collateralPerSlot; // Even if he has collateral discounted, he is operating with full collateral @@ -357,10 +358,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 validatorRewardAmount = (slashedAmount * _config.collateral.validatorRewardPercentage) / 100; _marketplaceTotals.sent += validatorRewardAmount; - - if (!_token.transfer(msg.sender, validatorRewardAmount)) { - revert Marketplace_TransferFailed(); - } + _token.safeTransfer(msg.sender, validatorRewardAmount); slot.currentCollateral -= slashedAmount; if (missingProofs(slotId) >= _config.collateral.maxNumberOfSlashes) { @@ -426,13 +424,8 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 collateralAmount = slot.currentCollateral; _marketplaceTotals.sent += (payoutAmount + collateralAmount); slot.state = SlotState.Paid; - if (!_token.transfer(rewardRecipient, payoutAmount)) { - revert Marketplace_TransferFailed(); - } - - if (!_token.transfer(collateralRecipient, collateralAmount)) { - revert Marketplace_TransferFailed(); - } + _token.safeTransfer(rewardRecipient, payoutAmount); + _token.safeTransfer(collateralRecipient, collateralAmount); } /** @@ -461,13 +454,8 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 collateralAmount = slot.currentCollateral; _marketplaceTotals.sent += (payoutAmount + collateralAmount); slot.state = SlotState.Paid; - if (!_token.transfer(rewardRecipient, payoutAmount)) { - revert Marketplace_TransferFailed(); - } - - if (!_token.transfer(collateralRecipient, collateralAmount)) { - revert Marketplace_TransferFailed(); - } + _token.safeTransfer(rewardRecipient, payoutAmount); + _token.safeTransfer(collateralRecipient, collateralAmount); } /** @@ -534,9 +522,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 amount = context.fundsToReturnToClient; _marketplaceTotals.sent += amount; - if (!_token.transfer(withdrawRecipient, amount)) { - revert Marketplace_TransferFailed(); - } + _token.safeTransfer(withdrawRecipient, amount); // We zero out the funds tracking in order to prevent double-spends context.fundsToReturnToClient = 0; @@ -685,12 +671,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { (request.ask.proofProbability * (256 - _config.proofs.downtime)) / 256; } - function _transferFrom(address sender, uint256 amount) internal { - address receiver = address(this); - if (!_token.transferFrom(sender, receiver, amount)) - revert Marketplace_TransferFailed(); - } - event StorageRequested(RequestId requestId, Ask ask, uint64 expiry); event RequestFulfilled(RequestId indexed requestId); event RequestFailed(RequestId indexed requestId); From aee61bdb453cbf2eabb40ae16d8475c7b6049344 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Mon, 24 Feb 2025 17:11:00 +0100 Subject: [PATCH 02/38] marketplace: deploy vault and set it in the marketplace --- contracts/FuzzMarketplace.sol | 8 +++----- contracts/Marketplace.sol | 29 +++++++++++++++++------------ contracts/TestMarketplace.sol | 4 ++-- contracts/Vault.sol | 4 ++++ deploy/marketplace.js | 10 +++++----- deploy/vault.js | 13 +++++++++++++ test/Marketplace.test.js | 15 +++++++++++---- 7 files changed, 55 insertions(+), 28 deletions(-) create mode 100644 deploy/vault.js diff --git a/contracts/FuzzMarketplace.sol b/contracts/FuzzMarketplace.sol index 3291e36..c13838c 100644 --- a/contracts/FuzzMarketplace.sol +++ b/contracts/FuzzMarketplace.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.28; import "./TestToken.sol"; import "./Marketplace.sol"; +import "./Vault.sol"; import "./TestVerifier.sol"; contract FuzzMarketplace is Marketplace { @@ -14,13 +15,10 @@ contract FuzzMarketplace is Marketplace { SlotReservationsConfig(20), 60 * 60 * 24 * 30 // 30 days ), - new TestToken(), + new Vault(new TestToken()), new TestVerifier() ) - // solhint-disable-next-line no-empty-blocks - { - - } + {} // Properties to be tested through fuzzing diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index c159c0d..650db34 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -5,6 +5,7 @@ import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/utils/math/Math.sol"; import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import "./Vault.sol"; import "./Configuration.sol"; import "./Requests.sol"; import "./Proofs.sol"; @@ -47,7 +48,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { using Requests for Request; using AskHelpers for Ask; - IERC20 private immutable _token; + Vault private immutable _vault; MarketplaceConfig private _config; mapping(RequestId => Request) private _requests; @@ -99,10 +100,10 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { constructor( MarketplaceConfig memory config, - IERC20 token_, + Vault vault_, IGroth16Verifier verifier ) SlotReservations(config.reservations) Proofs(config.proofs, verifier) { - _token = token_; + _vault = vault_; if (config.collateral.repairRewardPercentage > 100) revert Marketplace_RepairRewardPercentageTooHigh(); @@ -123,7 +124,11 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { } function token() public view returns (IERC20) { - return _token; + return _vault.getToken(); + } + + function vault() public view returns (Vault) { + return _vault; } function currentCollateral(SlotId slotId) public view returns (uint256) { @@ -172,7 +177,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 amount = request.maxPrice(); _requestContexts[id].fundsToReturnToClient = amount; _marketplaceTotals.received += amount; - _token.safeTransferFrom(msg.sender, address(this), amount); + token().safeTransferFrom(msg.sender, address(this), amount); emit StorageRequested(id, request.ask, _requestContexts[id].expiresAt); } @@ -232,7 +237,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { } else { collateralAmount = collateralPerSlot; } - _token.safeTransferFrom(msg.sender, address(this), collateralAmount); + token().safeTransferFrom(msg.sender, address(this), collateralAmount); _marketplaceTotals.received += collateralAmount; slot.currentCollateral = collateralPerSlot; // Even if he has collateral discounted, he is operating with full collateral @@ -358,7 +363,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 validatorRewardAmount = (slashedAmount * _config.collateral.validatorRewardPercentage) / 100; _marketplaceTotals.sent += validatorRewardAmount; - _token.safeTransfer(msg.sender, validatorRewardAmount); + token().safeTransfer(msg.sender, validatorRewardAmount); slot.currentCollateral -= slashedAmount; if (missingProofs(slotId) >= _config.collateral.maxNumberOfSlashes) { @@ -424,8 +429,8 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 collateralAmount = slot.currentCollateral; _marketplaceTotals.sent += (payoutAmount + collateralAmount); slot.state = SlotState.Paid; - _token.safeTransfer(rewardRecipient, payoutAmount); - _token.safeTransfer(collateralRecipient, collateralAmount); + token().safeTransfer(rewardRecipient, payoutAmount); + token().safeTransfer(collateralRecipient, collateralAmount); } /** @@ -454,8 +459,8 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 collateralAmount = slot.currentCollateral; _marketplaceTotals.sent += (payoutAmount + collateralAmount); slot.state = SlotState.Paid; - _token.safeTransfer(rewardRecipient, payoutAmount); - _token.safeTransfer(collateralRecipient, collateralAmount); + token().safeTransfer(rewardRecipient, payoutAmount); + token().safeTransfer(collateralRecipient, collateralAmount); } /** @@ -522,7 +527,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 amount = context.fundsToReturnToClient; _marketplaceTotals.sent += amount; - _token.safeTransfer(withdrawRecipient, amount); + token().safeTransfer(withdrawRecipient, amount); // We zero out the funds tracking in order to prevent double-spends context.fundsToReturnToClient = 0; diff --git a/contracts/TestMarketplace.sol b/contracts/TestMarketplace.sol index c18d369..cb840a0 100644 --- a/contracts/TestMarketplace.sol +++ b/contracts/TestMarketplace.sol @@ -7,10 +7,10 @@ import "./Marketplace.sol"; contract TestMarketplace is Marketplace { constructor( MarketplaceConfig memory config, - IERC20 token, + Vault vault, IGroth16Verifier verifier ) - Marketplace(config, token, verifier) // solhint-disable-next-line no-empty-blocks + Marketplace(config, vault, verifier) {} function forciblyFreeSlot(SlotId slotId) public { diff --git a/contracts/Vault.sol b/contracts/Vault.sol index 63667b8..8433a08 100644 --- a/contracts/Vault.sol +++ b/contracts/Vault.sol @@ -50,6 +50,10 @@ import "./vault/VaultBase.sol"; contract Vault is VaultBase, Pausable, Ownable { constructor(IERC20 token) VaultBase(token) Ownable(msg.sender) {} + function getToken() public view returns (IERC20) { + return _token; + } + /// Creates an account id that encodes the address of the account holder, and /// a discriminator. The discriminator can be used to create different /// accounts within a fund that all belong to the same account holder. diff --git a/deploy/marketplace.js b/deploy/marketplace.js index d1cb51d..4ace5da 100644 --- a/deploy/marketplace.js +++ b/deploy/marketplace.js @@ -9,12 +9,12 @@ async function mine256blocks({ network, ethers }) { // deploys a marketplace with a real Groth16 verifier async function deployMarketplace({ deployments, getNamedAccounts }) { - const token = await deployments.get("TestToken") + const vault = await deployments.get("Vault") const verifier = await deployments.get("Groth16Verifier") const zkeyHash = loadZkeyHash(network.name) let configuration = loadConfiguration(network.name) configuration.proofs.zkeyHash = zkeyHash - const args = [configuration, token.address, verifier.address] + const args = [configuration, vault.address, verifier.address] const { deployer: from } = await getNamedAccounts() const marketplace = await deployments.deploy("Marketplace", { args, from }) console.log("Deployed Marketplace with Groth16 Verifier at:") @@ -29,12 +29,12 @@ async function deployTestMarketplace({ getNamedAccounts, }) { if (network.tags.local) { - const token = await deployments.get("TestToken") + const vault = await deployments.get("Vault") const verifier = await deployments.get("TestVerifier") const zkeyHash = loadZkeyHash(network.name) let configuration = loadConfiguration(network.name) configuration.proofs.zkeyHash = zkeyHash - const args = [configuration, token.address, verifier.address] + const args = [configuration, vault.address, verifier.address] const { deployer: from } = await getNamedAccounts() const marketplace = await deployments.deploy("Marketplace", { args, from }) console.log("Deployed Marketplace with Test Verifier at:") @@ -50,4 +50,4 @@ module.exports = async (environment) => { } module.exports.tags = ["Marketplace"] -module.exports.dependencies = ["TestToken", "Verifier"] +module.exports.dependencies = ["Vault", "Verifier"] diff --git a/deploy/vault.js b/deploy/vault.js new file mode 100644 index 0000000..fed6c46 --- /dev/null +++ b/deploy/vault.js @@ -0,0 +1,13 @@ +async function deployVault({ deployments, getNamedAccounts }) { + const token = await deployments.get("TestToken") + const args = [token.address] + const { deployer: from } = await getNamedAccounts() + await deployments.deploy("Vault", { args, from }) +} + +module.exports = async (environment) => { + await deployVault(environment) +} + +module.exports.tags = ["Vault"] +module.exports.dependencies = ["TestToken"] diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 9904a40..62364a1 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -43,7 +43,7 @@ const { arrayify } = require("ethers/lib/utils") const ACCOUNT_STARTING_BALANCE = 1_000_000_000_000_000 describe("Marketplace constructor", function () { - let Marketplace, token, verifier, config + let Marketplace, token, vault, verifier, config beforeEach(async function () { await snapshot() @@ -52,6 +52,9 @@ describe("Marketplace constructor", function () { const TestToken = await ethers.getContractFactory("TestToken") token = await TestToken.deploy() + const Vault = await ethers.getContractFactory("Vault") + vault = await Vault.deploy(token.address) + const TestVerifier = await ethers.getContractFactory("TestVerifier") verifier = await TestVerifier.deploy() @@ -68,7 +71,7 @@ describe("Marketplace constructor", function () { config.collateral[property] = 101 await expect( - Marketplace.deploy(config, token.address, verifier.address) + Marketplace.deploy(config, vault.address, verifier.address) ).to.be.revertedWith(expectedError) }) } @@ -87,7 +90,7 @@ describe("Marketplace constructor", function () { config.collateral.maxNumberOfSlashes = 101 await expect( - Marketplace.deploy(config, token.address, verifier.address) + Marketplace.deploy(config, vault.address, verifier.address) ).to.be.revertedWith("Marketplace_MaximumSlashingTooHigh") }) }) @@ -98,6 +101,7 @@ describe("Marketplace", function () { let marketplace let token + let vault let verifier let client, clientWithdrawRecipient, @@ -143,13 +147,16 @@ describe("Marketplace", function () { await token.mint(account.address, ACCOUNT_STARTING_BALANCE) } + const Vault = await ethers.getContractFactory("Vault") + vault = await Vault.deploy(token.address) + const TestVerifier = await ethers.getContractFactory("TestVerifier") verifier = await TestVerifier.deploy() const Marketplace = await ethers.getContractFactory("TestMarketplace") marketplace = await Marketplace.deploy( config, - token.address, + vault.address, verifier.address ) patchOverloads(marketplace) From 6ebed47327032b1c7b87f22b5734a84ac0f0e339 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Tue, 25 Feb 2025 11:03:06 +0100 Subject: [PATCH 03/38] marketplace: remove support for changing payout addresses --- contracts/Marketplace.sol | 61 +------ contracts/TestMarketplace.sol | 4 +- test/Marketplace.test.js | 311 ++++++---------------------------- 3 files changed, 66 insertions(+), 310 deletions(-) diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 650db34..d8b373d 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -261,25 +261,9 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { finished or cancelled requests to the host that has filled the slot. * @param slotId id of the slot to free * @dev The host that filled the slot must have initiated the transaction - (msg.sender). This overload allows `rewardRecipient` and - `collateralRecipient` to be optional. + (msg.sender). */ function freeSlot(SlotId slotId) public slotIsNotFree(slotId) { - return freeSlot(slotId, msg.sender, msg.sender); - } - - /** - * @notice Frees a slot, paying out rewards and returning collateral for - finished or cancelled requests. - * @param slotId id of the slot to free - * @param rewardRecipient address to send rewards to - * @param collateralRecipient address to refund collateral to - */ - function freeSlot( - SlotId slotId, - address rewardRecipient, - address collateralRecipient - ) public slotIsNotFree(slotId) { Slot storage slot = _slots[slotId]; if (slot.host != msg.sender) revert Marketplace_InvalidSlotHost(); @@ -287,14 +271,9 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { if (state == SlotState.Paid) revert Marketplace_AlreadyPaid(); if (state == SlotState.Finished) { - _payoutSlot(slot.requestId, slotId, rewardRecipient, collateralRecipient); + _payoutSlot(slot.requestId, slotId); } else if (state == SlotState.Cancelled) { - _payoutCancelledSlot( - slot.requestId, - slotId, - rewardRecipient, - collateralRecipient - ); + _payoutCancelledSlot(slot.requestId, slotId); } else if (state == SlotState.Failed) { _removeFromMySlots(msg.sender, slotId); } else if (state == SlotState.Filled) { @@ -413,9 +392,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { function _payoutSlot( RequestId requestId, - SlotId slotId, - address rewardRecipient, - address collateralRecipient + SlotId slotId ) private requestIsKnown(requestId) { RequestContext storage context = _requestContexts[requestId]; Request storage request = _requests[requestId]; @@ -429,24 +406,19 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 collateralAmount = slot.currentCollateral; _marketplaceTotals.sent += (payoutAmount + collateralAmount); slot.state = SlotState.Paid; - token().safeTransfer(rewardRecipient, payoutAmount); - token().safeTransfer(collateralRecipient, collateralAmount); + token().safeTransfer(slot.host, payoutAmount + collateralAmount); } /** * @notice Pays out a host for duration of time that the slot was filled, and returns the collateral. - * @dev The payouts are sent to the rewardRecipient, and collateral is returned - to the host address. * @param requestId RequestId of the request that contains the slot to be paid out. * @param slotId SlotId of the slot to be paid out. */ function _payoutCancelledSlot( RequestId requestId, - SlotId slotId, - address rewardRecipient, - address collateralRecipient + SlotId slotId ) private requestIsKnown(requestId) { Slot storage slot = _slots[slotId]; _removeFromMySlots(slot.host, slotId); @@ -459,8 +431,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 collateralAmount = slot.currentCollateral; _marketplaceTotals.sent += (payoutAmount + collateralAmount); slot.state = SlotState.Paid; - token().safeTransfer(rewardRecipient, payoutAmount); - token().safeTransfer(collateralRecipient, collateralAmount); + token().safeTransfer(slot.host, payoutAmount + collateralAmount); } /** @@ -470,21 +441,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { transaction must originate from the depositor address. * @param requestId the id of the request */ - function withdrawFunds(RequestId requestId) public { - withdrawFunds(requestId, msg.sender); - } - - /** - * @notice Withdraws storage request funds to the provided address. - * @dev Request must be expired, must be in RequestState.New, and the - transaction must originate from the depositer address. - * @param requestId the id of the request - * @param withdrawRecipient address to return the remaining funds to - */ - function withdrawFunds( - RequestId requestId, - address withdrawRecipient - ) public requestIsKnown(requestId) { + function withdrawFunds(RequestId requestId) public requestIsKnown(requestId) { Request storage request = _requests[requestId]; RequestContext storage context = _requestContexts[requestId]; @@ -527,7 +484,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 amount = context.fundsToReturnToClient; _marketplaceTotals.sent += amount; - token().safeTransfer(withdrawRecipient, amount); + token().safeTransfer(request.client, amount); // We zero out the funds tracking in order to prevent double-spends context.fundsToReturnToClient = 0; diff --git a/contracts/TestMarketplace.sol b/contracts/TestMarketplace.sol index cb840a0..c13d7a1 100644 --- a/contracts/TestMarketplace.sol +++ b/contracts/TestMarketplace.sol @@ -9,9 +9,7 @@ contract TestMarketplace is Marketplace { MarketplaceConfig memory config, Vault vault, IGroth16Verifier verifier - ) - Marketplace(config, vault, verifier) - {} + ) Marketplace(config, vault, verifier) {} function forciblyFreeSlot(SlotId slotId) public { _forciblyFreeSlot(slotId); diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 62364a1..5ead160 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -23,11 +23,7 @@ const { waitUntilSlotFailed, patchOverloads, } = require("./marketplace") -const { - maxPrice, - pricePerSlotPerSecond, - payoutForDuration, -} = require("./price") +const { maxPrice, pricePerSlotPerSecond } = require("./price") const { collateralPerSlot } = require("./collateral") const { snapshot, @@ -40,8 +36,6 @@ const { } = require("./evm") const { arrayify } = require("ethers/lib/utils") -const ACCOUNT_STARTING_BALANCE = 1_000_000_000_000_000 - describe("Marketplace constructor", function () { let Marketplace, token, vault, verifier, config @@ -103,15 +97,7 @@ describe("Marketplace", function () { let token let vault let verifier - let client, - clientWithdrawRecipient, - host, - host1, - host2, - host3, - hostRewardRecipient, - hostCollateralRecipient, - validatorRecipient + let client, host, host1, host2, host3, validator let request let slot @@ -120,31 +106,13 @@ describe("Marketplace", function () { beforeEach(async function () { await snapshot() await ensureMinimumBlockHeight(256) - ;[ - client, - clientWithdrawRecipient, - host1, - host2, - host3, - hostRewardRecipient, - hostCollateralRecipient, - validatorRecipient, - ] = await ethers.getSigners() + ;[client, host1, host2, host3, validator] = await ethers.getSigners() host = host1 const TestToken = await ethers.getContractFactory("TestToken") token = await TestToken.deploy() - for (let account of [ - client, - clientWithdrawRecipient, - host1, - host2, - host3, - hostRewardRecipient, - hostCollateralRecipient, - validatorRecipient, - ]) { - await token.mint(account.address, ACCOUNT_STARTING_BALANCE) + for (let account of [client, host1, host2, host3, validator]) { + await token.mint(account.address, 1_000_000_000_000_000) } const Vault = await ethers.getContractFactory("Vault") @@ -674,61 +642,6 @@ describe("Marketplace", function () { ) }) - it("returns collateral to host collateral address if specified", async function () { - await waitUntilStarted(marketplace, request, proof, token) - await waitUntilFinished(marketplace, requestId(request)) - - const startBalanceHost = await token.balanceOf(host.address) - const startBalanceCollateral = await token.balanceOf( - hostCollateralRecipient.address - ) - - const collateralToBeReturned = await marketplace.currentCollateral( - slotId(slot) - ) - - await marketplace.freeSlot( - slotId(slot), - hostRewardRecipient.address, - hostCollateralRecipient.address - ) - - const endBalanceCollateral = await token.balanceOf( - hostCollateralRecipient.address - ) - - const endBalanceHost = await token.balanceOf(host.address) - expect(endBalanceHost).to.equal(startBalanceHost) - expect(endBalanceCollateral - startBalanceCollateral).to.equal( - collateralPerSlot(request) - ) - expect(collateralToBeReturned).to.equal(collateralPerSlot(request)) - }) - - it("pays reward to host reward address if specified", async function () { - await waitUntilStarted(marketplace, request, proof, token) - await waitUntilFinished(marketplace, requestId(request)) - - const startBalanceHost = await token.balanceOf(host.address) - const startBalanceReward = await token.balanceOf( - hostRewardRecipient.address - ) - - await marketplace.freeSlot( - slotId(slot), - hostRewardRecipient.address, - hostCollateralRecipient.address - ) - - const endBalanceHost = await token.balanceOf(host.address) - const endBalanceReward = await token.balanceOf( - hostRewardRecipient.address - ) - - expect(endBalanceHost).to.equal(startBalanceHost) - expect(endBalanceReward - startBalanceReward).to.gt(0) - }) - it("pays the host when contract was cancelled", async function () { // Lets advance the time more into the expiry window const filledAt = (await currentTime()) + Math.floor(request.expiry / 3) @@ -736,93 +649,26 @@ describe("Marketplace", function () { await marketplace.requestExpiry(requestId(request)) ).toNumber() + const startBalance = await token.balanceOf(host.address) await marketplace.reserveSlot(slot.request, slot.index) await setNextBlockTimestamp(filledAt) await marketplace.fillSlot(slot.request, slot.index, proof) await waitUntilCancelled(marketplace, request) await marketplace.freeSlot(slotId(slot)) - - const expectedPartialPayout = - (expiresAt - filledAt) * pricePerSlotPerSecond(request) const endBalance = await token.balanceOf(host.address) - expect(endBalance - ACCOUNT_STARTING_BALANCE).to.be.equal( - expectedPartialPayout - ) - }) - - it("pays to host reward address when contract was cancelled, and returns collateral to host address", async function () { - // Lets advance the time more into the expiry window - const filledAt = (await currentTime()) + Math.floor(request.expiry / 3) - const expiresAt = ( - await marketplace.requestExpiry(requestId(request)) - ).toNumber() - - await marketplace.reserveSlot(slot.request, slot.index) - await setNextBlockTimestamp(filledAt) - await marketplace.fillSlot(slot.request, slot.index, proof) - await waitUntilCancelled(marketplace, request) - const startBalanceHost = await token.balanceOf(host.address) - const startBalanceReward = await token.balanceOf( - hostRewardRecipient.address - ) - const startBalanceCollateral = await token.balanceOf( - hostCollateralRecipient.address - ) - - const collateralToBeReturned = await marketplace.currentCollateral( - slotId(slot) - ) - - await marketplace.freeSlot( - slotId(slot), - hostRewardRecipient.address, - hostCollateralRecipient.address - ) const expectedPartialPayout = (expiresAt - filledAt) * pricePerSlotPerSecond(request) - - const endBalanceReward = await token.balanceOf( - hostRewardRecipient.address - ) - expect(endBalanceReward - startBalanceReward).to.be.equal( - expectedPartialPayout - ) - - const endBalanceHost = await token.balanceOf(host.address) - expect(endBalanceHost).to.be.equal(startBalanceHost) - - const endBalanceCollateral = await token.balanceOf( - hostCollateralRecipient.address - ) - expect(endBalanceCollateral - startBalanceCollateral).to.be.equal( - collateralPerSlot(request) - ) - - expect(collateralToBeReturned).to.be.equal(collateralPerSlot(request)) + expect(endBalance - startBalance).to.be.equal(expectedPartialPayout) }) it("does not pay when the contract hasn't ended", async function () { await marketplace.reserveSlot(slot.request, slot.index) await marketplace.fillSlot(slot.request, slot.index, proof) - const startBalanceHost = await token.balanceOf(host.address) - const startBalanceReward = await token.balanceOf( - hostRewardRecipient.address - ) - const startBalanceCollateral = await token.balanceOf( - hostCollateralRecipient.address - ) + const startBalance = await token.balanceOf(host.address) await marketplace.freeSlot(slotId(slot)) - const endBalanceHost = await token.balanceOf(host.address) - const endBalanceReward = await token.balanceOf( - hostRewardRecipient.address - ) - const endBalanceCollateral = await token.balanceOf( - hostCollateralRecipient.address - ) - expect(endBalanceHost).to.equal(startBalanceHost) - expect(endBalanceReward).to.equal(startBalanceReward) - expect(endBalanceCollateral).to.equal(startBalanceCollateral) + const endBalance = await token.balanceOf(host.address) + expect(endBalance).to.equal(startBalance) }) it("can only be done once", async function () { @@ -915,16 +761,16 @@ describe("Marketplace", function () { it("rejects withdraw when request not yet timed out", async function () { switchAccount(client) - await expect( - marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address) - ).to.be.revertedWith("Marketplace_InvalidState") + await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith( + "Marketplace_InvalidState" + ) }) it("rejects withdraw when wrong account used", async function () { await waitUntilCancelled(marketplace, request) - await expect( - marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address) - ).to.be.revertedWith("Marketplace_InvalidClientAddress") + await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith( + "Marketplace_InvalidClientAddress" + ) }) it("rejects withdraw when in wrong state", async function () { @@ -940,9 +786,9 @@ describe("Marketplace", function () { } await waitUntilCancelled(marketplace, request) switchAccount(client) - await expect( - marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address) - ).to.be.revertedWith("Marketplace_InvalidState") + await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith( + "Marketplace_InvalidState" + ) }) it("rejects withdraw when already withdrawn", async function () { @@ -950,99 +796,64 @@ describe("Marketplace", function () { await waitUntilFinished(marketplace, requestId(request)) switchAccount(client) - await marketplace.withdrawFunds( - slot.request, - clientWithdrawRecipient.address + await marketplace.withdrawFunds(slot.request) + await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith( + "Marketplace_NothingToWithdraw" ) - await expect( - marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address) - ).to.be.revertedWith("Marketplace_NothingToWithdraw") }) it("emits event once request is cancelled", async function () { await waitUntilCancelled(marketplace, request) switchAccount(client) - await expect( - marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address) - ) + await expect(marketplace.withdrawFunds(slot.request)) .to.emit(marketplace, "RequestCancelled") .withArgs(requestId(request)) }) - it("withdraw rest of funds to the client payout address for finished requests", async function () { + it("withdraw rest of funds to the client for finished requests", async function () { await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) switchAccount(client) - const startBalanceClient = await token.balanceOf(client.address) - const startBalancePayout = await token.balanceOf( - clientWithdrawRecipient.address - ) - await marketplace.withdrawFunds( - slot.request, - clientWithdrawRecipient.address - ) + const startBalance = await token.balanceOf(client.address) + await marketplace.withdrawFunds(slot.request) - const endBalanceClient = await token.balanceOf(client.address) - const endBalancePayout = await token.balanceOf( - clientWithdrawRecipient.address - ) + const endBalance = await token.balanceOf(client.address) - expect(endBalanceClient).to.equal(startBalanceClient) // As all the request's slots will get filled and request will start and successfully finishes, // then the upper bound to how much the client gets returned is the cumulative reward for all the // slots for expiry window. This limit is "inclusive" because it is possible that all slots are filled // at the time of expiry and hence the user would get the full "expiry window" reward back. - expect(endBalancePayout - startBalancePayout).to.be.gt(0) - expect(endBalancePayout - startBalancePayout).to.be.lte( + expect(endBalance - startBalance).to.be.gt(0) + expect(endBalance - startBalance).to.be.lte( request.expiry * pricePerSlotPerSecond(request) ) }) - it("withdraws to the client payout address when request is cancelled", async function () { + it("withdraws to the client when request is cancelled", async function () { await waitUntilCancelled(marketplace, request) switchAccount(client) - const startBalanceClient = await token.balanceOf(client.address) - const startBalancePayout = await token.balanceOf( - clientWithdrawRecipient.address - ) - await marketplace.withdrawFunds( - slot.request, - clientWithdrawRecipient.address - ) - const endBalanceClient = await token.balanceOf(client.address) - const endBalancePayout = await token.balanceOf( - clientWithdrawRecipient.address - ) - expect(endBalanceClient).to.equal(startBalanceClient) - expect(endBalancePayout - startBalancePayout).to.equal(maxPrice(request)) + const startBalance = await token.balanceOf(client.address) + await marketplace.withdrawFunds(slot.request) + const endBalance = await token.balanceOf(client.address) + expect(endBalance - startBalance).to.equal(maxPrice(request)) }) - it("withdraws full price for failed requests to the client payout address", async function () { + it("withdraws full price for failed requests to the client", async function () { await waitUntilStarted(marketplace, request, proof, token) await waitUntilFailed(marketplace, request) switchAccount(client) - const startBalanceClient = await token.balanceOf(client.address) - const startBalancePayout = await token.balanceOf( - clientWithdrawRecipient.address - ) - await marketplace.withdrawFunds( - slot.request, - clientWithdrawRecipient.address - ) + const startBalance = await token.balanceOf(client.address) + await marketplace.withdrawFunds(slot.request) - const endBalanceClient = await token.balanceOf(client.address) - const endBalancePayout = await token.balanceOf( - clientWithdrawRecipient.address - ) + const endBalance = await token.balanceOf(client.address) - expect(endBalanceClient).to.equal(startBalanceClient) - expect(endBalancePayout - startBalancePayout).to.equal(maxPrice(request)) + expect(endBalance - startBalance).to.equal(maxPrice(request)) }) - it("withdraws to the client payout address for cancelled requests lowered by hosts payout", async function () { + it("withdraws to the client for cancelled requests lowered by hosts payout", async function () { // Lets advance the time more into the expiry window const filledAt = (await currentTime()) + Math.floor(request.expiry / 3) const expiresAt = ( @@ -1053,21 +864,19 @@ describe("Marketplace", function () { await setNextBlockTimestamp(filledAt) await marketplace.fillSlot(slot.request, slot.index, proof) await waitUntilCancelled(marketplace, request) - const expectedPartialhostRewardRecipient = + const expectedPartialHostReward = (expiresAt - filledAt) * pricePerSlotPerSecond(request) switchAccount(client) - await marketplace.withdrawFunds( - slot.request, - clientWithdrawRecipient.address - ) - const endBalance = await token.balanceOf(clientWithdrawRecipient.address) - expect(endBalance - ACCOUNT_STARTING_BALANCE).to.equal( - maxPrice(request) - expectedPartialhostRewardRecipient + const startBalance = await token.balanceOf(client.address) + await marketplace.withdrawFunds(slot.request) + const endBalance = await token.balanceOf(client.address) + expect(endBalance - startBalance).to.equal( + maxPrice(request) - expectedPartialHostReward ) }) - it("when slot is freed and not repaired, client will get refunded the freed slot's funds", async function () { + it("refunds the client when slot is freed and not repaired", async function () { const payouts = await waitUntilStarted(marketplace, request, proof, token) await expect(marketplace.freeSlot(slotId(slot))).to.emit( @@ -1077,12 +886,10 @@ describe("Marketplace", function () { await waitUntilFinished(marketplace, requestId(request)) switchAccount(client) - await marketplace.withdrawFunds( - slot.request, - clientWithdrawRecipient.address - ) - const endBalance = await token.balanceOf(clientWithdrawRecipient.address) - expect(endBalance - ACCOUNT_STARTING_BALANCE).to.equal( + const startBalance = await token.balanceOf(client.address) + await marketplace.withdrawFunds(slot.request) + const endBalance = await token.balanceOf(client.address) + expect(endBalance - startBalance).to.equal( maxPrice(request) - payouts.reduce((a, b) => a + b, 0) + // This is the amount that user gets refunded for filling period in expiry window payouts[slot.index] // This is the refunded amount for the freed slot @@ -1114,10 +921,7 @@ describe("Marketplace", function () { it("remains 'Cancelled' when client withdraws funds", async function () { await waitUntilCancelled(marketplace, request) switchAccount(client) - await marketplace.withdrawFunds( - slot.request, - clientWithdrawRecipient.address - ) + await marketplace.withdrawFunds(slot.request) expect(await marketplace.requestState(slot.request)).to.equal(Cancelled) }) @@ -1423,16 +1227,16 @@ describe("Marketplace", function () { await marketplace.reserveSlot(slot.request, slot.index) await marketplace.fillSlot(slot.request, slot.index, proof) - switchAccount(validatorRecipient) + switchAccount(validator) - const startBalance = await token.balanceOf(validatorRecipient.address) + const startBalance = await token.balanceOf(validator.address) await waitUntilProofIsRequired(id) let missedPeriod = periodOf(await currentTime()) await advanceTime(period + 1) await marketplace.markProofAsMissing(id, missedPeriod) - const endBalance = await token.balanceOf(validatorRecipient.address) + const endBalance = await token.balanceOf(validator.address) const collateral = collateralPerSlot(request) const slashedAmount = (collateral * slashPercentage) / 100 @@ -1529,10 +1333,7 @@ describe("Marketplace", function () { it("removes request from list when funds are withdrawn", async function () { await marketplace.requestStorage(request) await waitUntilCancelled(marketplace, request) - await marketplace.withdrawFunds( - requestId(request), - clientWithdrawRecipient.address - ) + await marketplace.withdrawFunds(requestId(request)) expect(await marketplace.myRequests()).to.deep.equal([]) }) From 0910c83428229b3ac8123aa0f55af74ace688dc0 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Tue, 25 Feb 2025 15:53:01 +0100 Subject: [PATCH 04/38] marketplace: use vault in marketplace --- contracts/Marketplace.sol | 90 +++++++++++++++++++++++--- contracts/marketplace/VaultHelpers.sol | 53 +++++++++++++++ contracts/vault/VaultBase.sol | 5 +- test/Marketplace.test.js | 51 ++++++++------- 4 files changed, 162 insertions(+), 37 deletions(-) create mode 100644 contracts/marketplace/VaultHelpers.sol diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index d8b373d..67563d7 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -13,6 +13,7 @@ import "./SlotReservations.sol"; import "./StateRetrieval.sol"; import "./Endian.sol"; import "./Groth16.sol"; +import "./marketplace/VaultHelpers.sol"; contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { error Marketplace_RepairRewardPercentageTooHigh(); @@ -47,6 +48,9 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { using EnumerableSet for EnumerableSet.AddressSet; using Requests for Request; using AskHelpers for Ask; + using VaultHelpers for Vault; + using VaultHelpers for RequestId; + using VaultHelpers for Request; Vault private immutable _vault; MarketplaceConfig private _config; @@ -174,10 +178,18 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { _addToMyRequests(request.client, id); - uint256 amount = request.maxPrice(); + uint128 amount = uint128(request.maxPrice()); _requestContexts[id].fundsToReturnToClient = amount; _marketplaceTotals.received += amount; - token().safeTransferFrom(msg.sender, address(this), amount); + + FundId fund = id.asFundId(); + AccountId account = _vault.clientAccount(request.client); + _vault.lock( + fund, + Timestamp.wrap(uint40(_requestContexts[id].expiresAt)), + Timestamp.wrap(uint40(_requestContexts[id].endsAt)) + ); + _transferToVault(request.client, fund, account, amount); emit StorageRequested(id, request.ask, _requestContexts[id].expiresAt); } @@ -237,7 +249,14 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { } else { collateralAmount = collateralPerSlot; } - token().safeTransferFrom(msg.sender, address(this), collateralAmount); + + FundId fund = requestId.asFundId(); + AccountId clientAccount = _vault.clientAccount(request.client); + AccountId hostAccount = _vault.hostAccount(slot.host, slotIndex); + + _transferToVault(slot.host, fund, hostAccount, uint128(collateralAmount)); + _vault.flow(fund, clientAccount, hostAccount, request.slotPrice()); + _marketplaceTotals.received += collateralAmount; slot.currentCollateral = collateralPerSlot; // Even if he has collateral discounted, he is operating with full collateral @@ -252,10 +271,22 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { ) { context.state = RequestState.Started; context.startedAt = uint64(block.timestamp); + _vault.extendLock(fund, Timestamp.wrap(uint40(context.endsAt))); emit RequestFulfilled(requestId); } } + function _transferToVault( + address from, + FundId fund, + AccountId account, + uint128 amount + ) private { + _vault.getToken().safeTransferFrom(from, address(this), amount); + _vault.getToken().approve(address(_vault), amount); + _vault.deposit(fund, account, amount); + } + /** * @notice Frees a slot, paying out rewards and returning collateral for finished or cancelled requests to the host that has filled the slot. @@ -342,7 +373,19 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 validatorRewardAmount = (slashedAmount * _config.collateral.validatorRewardPercentage) / 100; _marketplaceTotals.sent += validatorRewardAmount; - token().safeTransfer(msg.sender, validatorRewardAmount); + + FundId fund = slot.requestId.asFundId(); + AccountId hostAccount = _vault.hostAccount( + slot.host, + slot.slotIndex + ); + AccountId validatorAccount = _vault.validatorAccount(msg.sender); + _vault.transfer( + fund, + hostAccount, + validatorAccount, + uint128(validatorRewardAmount) + ); slot.currentCollateral -= slashedAmount; if (missingProofs(slotId) >= _config.collateral.maxNumberOfSlashes) { @@ -368,6 +411,17 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { // we keep correctly the track of the funds that needs to be returned at the end. context.fundsToReturnToClient += _slotPayout(requestId, slot.filledAt); + Request storage request = _requests[requestId]; + + FundId fund = requestId.asFundId(); + AccountId hostAccount = _vault.hostAccount( + slot.host, + slot.slotIndex + ); + AccountId clientAccount = _vault.clientAccount(request.client); + _vault.flow(fund, hostAccount, clientAccount, request.slotPrice()); + _vault.burnAccount(fund, hostAccount); + _removeFromMySlots(slot.host, slotId); _reservations[slotId].clear(); // We purge all the reservations for the slot slot.state = SlotState.Repair; @@ -378,14 +432,14 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { emit SlotFreed(requestId, slot.slotIndex); _resetMissingProofs(slotId); - Request storage request = _requests[requestId]; uint256 slotsLost = request.ask.slots - context.slotsFilled; if ( slotsLost > request.ask.maxSlotLoss && context.state == RequestState.Started ) { context.state = RequestState.Failed; - context.endsAt = uint64(block.timestamp) - 1; + _vault.freezeFund(fund); + emit RequestFailed(requestId); } } @@ -406,7 +460,9 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 collateralAmount = slot.currentCollateral; _marketplaceTotals.sent += (payoutAmount + collateralAmount); slot.state = SlotState.Paid; - token().safeTransfer(slot.host, payoutAmount + collateralAmount); + FundId fund = requestId.asFundId(); + AccountId account = _vault.hostAccount(slot.host, slot.slotIndex); + _vault.withdraw(fund, account); } /** @@ -431,7 +487,9 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 collateralAmount = slot.currentCollateral; _marketplaceTotals.sent += (payoutAmount + collateralAmount); slot.state = SlotState.Paid; - token().safeTransfer(slot.host, payoutAmount + collateralAmount); + FundId fund = requestId.asFundId(); + AccountId account = _vault.hostAccount(slot.host, slot.slotIndex); + _vault.withdraw(fund, account); } /** @@ -484,12 +542,20 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint256 amount = context.fundsToReturnToClient; _marketplaceTotals.sent += amount; - token().safeTransfer(request.client, amount); + FundId fund = requestId.asFundId(); + AccountId account = _vault.clientAccount(request.client); + _vault.withdraw(fund, account); // We zero out the funds tracking in order to prevent double-spends context.fundsToReturnToClient = 0; } + function withdrawByValidator(RequestId requestId) public { + FundId fund = requestId.asFundId(); + AccountId account = _vault.validatorAccount(msg.sender); + _vault.withdraw(fund, account); + } + function getActiveSlot( SlotId slotId ) public view returns (ActiveSlot memory) { @@ -530,7 +596,11 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { function requestEnd(RequestId requestId) public view returns (uint64) { RequestState state = requestState(requestId); - if (state == RequestState.New || state == RequestState.Started) { + if ( + state == RequestState.New || + state == RequestState.Started || + state == RequestState.Failed + ) { return _requestContexts[requestId].endsAt; } if (state == RequestState.Cancelled) { diff --git a/contracts/marketplace/VaultHelpers.sol b/contracts/marketplace/VaultHelpers.sol new file mode 100644 index 0000000..19548cc --- /dev/null +++ b/contracts/marketplace/VaultHelpers.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import "../Requests.sol"; +import "../Vault.sol"; + +import "hardhat/console.sol"; + +library VaultHelpers { + enum VaultRole { + client, + host, + validator + } + + function clientAccount( + Vault vault, + address client + ) internal pure returns (AccountId) { + bytes12 discriminator = bytes12(bytes1(uint8(VaultRole.client))); + return vault.encodeAccountId(client, discriminator); + } + + function hostAccount( + Vault vault, + address host, + uint64 slotIndex + ) internal pure returns (AccountId) { + bytes12 role = bytes12(bytes1(uint8(VaultRole.host))); + bytes12 index = bytes12(uint96(slotIndex)); + bytes12 discriminator = role | index; + return vault.encodeAccountId(host, discriminator); + } + + function validatorAccount( + Vault vault, + address validator + ) internal pure returns (AccountId) { + bytes12 discriminator = bytes12(bytes1(uint8(VaultRole.validator))); + return vault.encodeAccountId(validator, discriminator); + } + + function asFundId(RequestId requestId) internal pure returns (FundId) { + return FundId.wrap(RequestId.unwrap(requestId)); + } + + function slotPrice( + Request memory request + ) internal pure returns (TokensPerSecond) { + uint256 price = request.ask.pricePerBytePerSecond * request.ask.slotSize; + return TokensPerSecond.wrap(uint96(price)); + } +} diff --git a/contracts/vault/VaultBase.sol b/contracts/vault/VaultBase.sol index 9e9622f..be21481 100644 --- a/contracts/vault/VaultBase.sol +++ b/contracts/vault/VaultBase.sol @@ -6,6 +6,9 @@ import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "./Accounts.sol"; import "./Funds.sol"; +/// Unique identifier for a fund, chosen by the controller +type FundId is bytes32; + /// Records account balances and token flows. Accounts are separated into funds. /// Funds are kept separate between controllers. /// @@ -46,8 +49,6 @@ abstract contract VaultBase { /// Represents a smart contract that can redistribute and burn tokens in funds type Controller is address; - /// Unique identifier for a fund, chosen by the controller - type FundId is bytes32; /// Each controller has its own set of funds mapping(Controller => mapping(FundId => Fund)) private _funds; diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 5ead160..5677b23 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -23,7 +23,11 @@ const { waitUntilSlotFailed, patchOverloads, } = require("./marketplace") -const { maxPrice, pricePerSlotPerSecond } = require("./price") +const { + maxPrice, + pricePerSlotPerSecond, + payoutForDuration, +} = require("./price") const { collateralPerSlot } = require("./collateral") const { snapshot, @@ -508,15 +512,6 @@ describe("Marketplace", function () { ).to.equal(requestTime + request.ask.duration) }) - it("sets request end time to the past once failed", async function () { - await waitUntilStarted(marketplace, request, proof, token) - await waitUntilFailed(marketplace, request) - const now = await currentTime() - await expect(await marketplace.requestEnd(requestId(request))).to.be.eq( - now - 1 - ) - }) - it("sets request end time to the past once cancelled", async function () { await marketplace.reserveSlot(slot.request, slot.index) await marketplace.fillSlot(slot.request, slot.index, proof) @@ -617,7 +612,7 @@ describe("Marketplace", function () { await token.approve(marketplace.address, collateral) }) - it("finished request pays out reward based on time hosted", async function () { + it("pays out finished request based on time hosted", async function () { // We are advancing the time because most of the slots will be filled somewhere // in the "expiry window" and not at its beginning. This is more "real" setup // and demonstrates the partial payout feature better. @@ -635,7 +630,6 @@ describe("Marketplace", function () { await marketplace.freeSlot(slotId(slot)) const endBalanceHost = await token.balanceOf(host.address) - expect(expectedPayouts[slot.index]).to.be.lt(maxPrice(request)) const collateral = collateralPerSlot(request) expect(endBalanceHost - startBalanceHost).to.equal( expectedPayouts[slot.index] + collateral @@ -839,18 +833,24 @@ describe("Marketplace", function () { expect(endBalance - startBalance).to.equal(maxPrice(request)) }) - it("withdraws full price for failed requests to the client", async function () { + it("refunds the client for the remaining time when request fails", async function () { await waitUntilStarted(marketplace, request, proof, token) await waitUntilFailed(marketplace, request) + const failedAt = await currentTime() + await waitUntilFinished(marketplace, requestId(request)) + const finishedAt = await currentTime() switchAccount(client) const startBalance = await token.balanceOf(client.address) await marketplace.withdrawFunds(slot.request) - const endBalance = await token.balanceOf(client.address) - expect(endBalance - startBalance).to.equal(maxPrice(request)) + const expectedRefund = + (finishedAt - failedAt) * + request.ask.slots * + pricePerSlotPerSecond(request) + expect(endBalance - startBalance).to.be.gte(expectedRefund) }) it("withdraws to the client for cancelled requests lowered by hosts payout", async function () { @@ -878,21 +878,21 @@ describe("Marketplace", function () { it("refunds the client when slot is freed and not repaired", async function () { const payouts = await waitUntilStarted(marketplace, request, proof, token) - - await expect(marketplace.freeSlot(slotId(slot))).to.emit( - marketplace, - "SlotFreed" - ) + await advanceTime(10) + await marketplace.freeSlot(slotId(slot)) + const freedAt = await currentTime() + const requestEnd = await marketplace.requestEnd(requestId(request)) await waitUntilFinished(marketplace, requestId(request)) switchAccount(client) const startBalance = await token.balanceOf(client.address) await marketplace.withdrawFunds(slot.request) const endBalance = await token.balanceOf(client.address) + + const hostPayouts = payouts.reduce((a, b) => a + b, 0) + const refund = payoutForDuration(request, freedAt, requestEnd) expect(endBalance - startBalance).to.equal( - maxPrice(request) - - payouts.reduce((a, b) => a + b, 0) + // This is the amount that user gets refunded for filling period in expiry window - payouts[slot.index] // This is the refunded amount for the freed slot + maxPrice(request) - hostPayouts + refund ) }) }) @@ -1229,13 +1229,14 @@ describe("Marketplace", function () { switchAccount(validator) - const startBalance = await token.balanceOf(validator.address) - await waitUntilProofIsRequired(id) let missedPeriod = periodOf(await currentTime()) await advanceTime(period + 1) await marketplace.markProofAsMissing(id, missedPeriod) + const startBalance = await token.balanceOf(validator.address) + await waitUntilFinished(marketplace, slot.request) + await marketplace.withdrawByValidator(slot.request) const endBalance = await token.balanceOf(validator.address) const collateral = collateralPerSlot(request) From ccf91075bfc8c54d141783366f4fbbac84e3e15c Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Wed, 26 Feb 2025 10:36:04 +0100 Subject: [PATCH 05/38] vault: move Timestamp and TokensPerSecond libraries one level up --- contracts/{vault => }/Timestamps.sol | 0 contracts/{vault/TokenFlows.sol => Tokens.sol} | 2 +- contracts/vault/Accounts.sol | 6 +++--- contracts/vault/Funds.sol | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename contracts/{vault => }/Timestamps.sol (100%) rename contracts/{vault/TokenFlows.sol => Tokens.sol} (98%) diff --git a/contracts/vault/Timestamps.sol b/contracts/Timestamps.sol similarity index 100% rename from contracts/vault/Timestamps.sol rename to contracts/Timestamps.sol diff --git a/contracts/vault/TokenFlows.sol b/contracts/Tokens.sol similarity index 98% rename from contracts/vault/TokenFlows.sol rename to contracts/Tokens.sol index d62c38d..83d9553 100644 --- a/contracts/vault/TokenFlows.sol +++ b/contracts/Tokens.sol @@ -43,7 +43,7 @@ function _tokensPerSecondAtMost( return TokensPerSecond.unwrap(a) <= TokensPerSecond.unwrap(b); } -library TokenFlows { +library Tokens { /// Calculates how many tokens are accumulated when a token flow is maintained /// for a duration of time. function accumulate( diff --git a/contracts/vault/Accounts.sol b/contracts/vault/Accounts.sol index 24aff9f..3066e7d 100644 --- a/contracts/vault/Accounts.sol +++ b/contracts/vault/Accounts.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; -import "./TokenFlows.sol"; -import "./Timestamps.sol"; +import "../Tokens.sol"; +import "../Timestamps.sol"; /// Used to identify an account. The first 20 bytes consist of the address of /// the account holder, and the last 12 bytes consist of a discriminator value. @@ -38,7 +38,7 @@ struct Flow { library Accounts { using Accounts for Account; - using TokenFlows for TokensPerSecond; + using Tokens for TokensPerSecond; using Timestamps for Timestamp; /// Creates an account id from the account holder address and a discriminator. diff --git a/contracts/vault/Funds.sol b/contracts/vault/Funds.sol index 8b69c35..471c2d9 100644 --- a/contracts/vault/Funds.sol +++ b/contracts/vault/Funds.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; -import "./Timestamps.sol"; +import "../Timestamps.sol"; struct Fund { /// The time-lock unlocks at this time From 4f45856a5e625eadf3c32e56e7316bd084606d46 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Wed, 26 Feb 2025 12:26:04 +0100 Subject: [PATCH 06/38] marketplace: use Timestamp, Duration and TokensPerSecond types --- contracts/Configuration.sol | 3 +- contracts/FuzzMarketplace.sol | 2 +- contracts/Marketplace.sol | 100 ++++++++++++------------- contracts/Requests.sol | 25 ++++--- contracts/Timestamps.sol | 41 +++++++++- contracts/marketplace/VaultHelpers.sol | 7 -- test/Marketplace.test.js | 14 ++-- test/ids.js | 4 +- test/marketplace.js | 6 +- test/requests.js | 1 - 10 files changed, 116 insertions(+), 87 deletions(-) diff --git a/contracts/Configuration.sol b/contracts/Configuration.sol index 69e97b1..dd70be4 100644 --- a/contracts/Configuration.sol +++ b/contracts/Configuration.sol @@ -2,12 +2,13 @@ pragma solidity 0.8.28; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "./Timestamps.sol"; struct MarketplaceConfig { CollateralConfig collateral; ProofConfig proofs; SlotReservationsConfig reservations; - uint64 requestDurationLimit; + Duration requestDurationLimit; } struct CollateralConfig { diff --git a/contracts/FuzzMarketplace.sol b/contracts/FuzzMarketplace.sol index c13838c..1520522 100644 --- a/contracts/FuzzMarketplace.sol +++ b/contracts/FuzzMarketplace.sol @@ -13,7 +13,7 @@ contract FuzzMarketplace is Marketplace { CollateralConfig(10, 5, 10, 20), ProofConfig(10, 5, 64, 67, ""), SlotReservationsConfig(20), - 60 * 60 * 24 * 30 // 30 days + Duration.wrap(60 * 60 * 24 * 30) // 30 days ), new Vault(new TestToken()), new TestVerifier() diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 67563d7..3ba6236 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -51,6 +51,8 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { using VaultHelpers for Vault; using VaultHelpers for RequestId; using VaultHelpers for Request; + using Timestamps for Timestamp; + using Tokens for TokensPerSecond; Vault private immutable _vault; MarketplaceConfig private _config; @@ -72,9 +74,9 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { /// that would require all the slots to be filled at the same block as the request was created. uint256 fundsToReturnToClient; uint64 slotsFilled; - uint64 startedAt; - uint64 endsAt; - uint64 expiresAt; + Timestamp startedAt; + Timestamp endsAt; + Timestamp expiresAt; } struct Slot { @@ -83,7 +85,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { /// @notice Timestamp that signals when slot was filled /// @dev Used for calculating payouts as hosts are paid /// based on time they actually host the content - uint64 filledAt; + Timestamp filledAt; uint64 slotIndex; /// @notice Tracks the current amount of host's collateral that is /// to be payed out at the end of Slot's lifespan. @@ -146,12 +148,14 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { if (_requests[id].client != address(0)) { revert Marketplace_RequestAlreadyExists(); } - if (request.expiry == 0 || request.expiry >= request.ask.duration) - revert Marketplace_InvalidExpiry(); + if ( + request.expiry == Duration.wrap(0) || + request.expiry >= request.ask.duration + ) revert Marketplace_InvalidExpiry(); if (request.ask.slots == 0) revert Marketplace_InsufficientSlots(); if (request.ask.maxSlotLoss > request.ask.slots) revert Marketplace_InvalidMaxSlotLoss(); - if (request.ask.duration == 0) { + if (request.ask.duration == Duration.wrap(0)) { revert Marketplace_InsufficientDuration(); } if (request.ask.proofProbability == 0) { @@ -160,7 +164,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { if (request.ask.collateralPerByte == 0) { revert Marketplace_InsufficientCollateral(); } - if (request.ask.pricePerBytePerSecond == 0) { + if (request.ask.pricePerBytePerSecond == TokensPerSecond.wrap(0)) { revert Marketplace_InsufficientReward(); } if (bytes(request.content.cid).length == 0) { @@ -170,15 +174,15 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { revert Marketplace_DurationExceedsLimit(); } + Timestamp currentTime = Timestamps.currentTime(); + _requests[id] = request; - _requestContexts[id].endsAt = - uint64(block.timestamp) + - request.ask.duration; - _requestContexts[id].expiresAt = uint64(block.timestamp) + request.expiry; + _requestContexts[id].endsAt = currentTime.add(request.ask.duration); + _requestContexts[id].expiresAt = currentTime.add(request.expiry); _addToMyRequests(request.client, id); - uint128 amount = uint128(request.maxPrice()); + uint128 amount = request.maxPrice(); _requestContexts[id].fundsToReturnToClient = amount; _marketplaceTotals.received += amount; @@ -186,8 +190,8 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { AccountId account = _vault.clientAccount(request.client); _vault.lock( fund, - Timestamp.wrap(uint40(_requestContexts[id].expiresAt)), - Timestamp.wrap(uint40(_requestContexts[id].endsAt)) + _requestContexts[id].expiresAt, + _requestContexts[id].endsAt ); _transferToVault(request.client, fund, account, amount); @@ -227,8 +231,10 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { revert Marketplace_SlotNotFree(); } + Timestamp currentTime = Timestamps.currentTime(); + slot.host = msg.sender; - slot.filledAt = uint64(block.timestamp); + slot.filledAt = currentTime; _startRequiringProofs(slotId); submitProof(slotId, proof); @@ -253,9 +259,10 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { FundId fund = requestId.asFundId(); AccountId clientAccount = _vault.clientAccount(request.client); AccountId hostAccount = _vault.hostAccount(slot.host, slotIndex); + TokensPerSecond rate = request.ask.pricePerSlotPerSecond(); _transferToVault(slot.host, fund, hostAccount, uint128(collateralAmount)); - _vault.flow(fund, clientAccount, hostAccount, request.slotPrice()); + _vault.flow(fund, clientAccount, hostAccount, rate); _marketplaceTotals.received += collateralAmount; slot.currentCollateral = collateralPerSlot; // Even if he has collateral discounted, he is operating with full collateral @@ -270,8 +277,8 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { context.state == RequestState.New // Only New requests can "start" the requests ) { context.state = RequestState.Started; - context.startedAt = uint64(block.timestamp); - _vault.extendLock(fund, Timestamp.wrap(uint40(context.endsAt))); + context.startedAt = currentTime; + _vault.extendLock(fund, context.endsAt); emit RequestFulfilled(requestId); } } @@ -375,10 +382,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { _marketplaceTotals.sent += validatorRewardAmount; FundId fund = slot.requestId.asFundId(); - AccountId hostAccount = _vault.hostAccount( - slot.host, - slot.slotIndex - ); + AccountId hostAccount = _vault.hostAccount(slot.host, slot.slotIndex); AccountId validatorAccount = _vault.validatorAccount(msg.sender); _vault.transfer( fund, @@ -414,18 +418,17 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { Request storage request = _requests[requestId]; FundId fund = requestId.asFundId(); - AccountId hostAccount = _vault.hostAccount( - slot.host, - slot.slotIndex - ); + AccountId hostAccount = _vault.hostAccount(slot.host, slot.slotIndex); AccountId clientAccount = _vault.clientAccount(request.client); - _vault.flow(fund, hostAccount, clientAccount, request.slotPrice()); + TokensPerSecond rate = request.ask.pricePerSlotPerSecond(); + + _vault.flow(fund, hostAccount, clientAccount, rate); _vault.burnAccount(fund, hostAccount); _removeFromMySlots(slot.host, slotId); _reservations[slotId].clear(); // We purge all the reservations for the slot slot.state = SlotState.Repair; - slot.filledAt = 0; + slot.filledAt = Timestamp.wrap(0); slot.currentCollateral = 0; slot.host = address(0); context.slotsFilled -= 1; @@ -594,7 +597,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { _; } - function requestEnd(RequestId requestId) public view returns (uint64) { + function requestEnd(RequestId requestId) public view returns (Timestamp) { RequestState state = requestState(requestId); if ( state == RequestState.New || @@ -606,11 +609,12 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { if (state == RequestState.Cancelled) { return _requestContexts[requestId].expiresAt; } - return - uint64(Math.min(_requestContexts[requestId].endsAt, block.timestamp)); + Timestamp currentTime = Timestamps.currentTime(); + Timestamp end = _requestContexts[requestId].endsAt; + return Timestamps.earliest(end, currentTime); } - function requestExpiry(RequestId requestId) public view returns (uint64) { + function requestExpiry(RequestId requestId) public view returns (Timestamp) { return _requestContexts[requestId].expiresAt; } @@ -618,33 +622,27 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { * @notice Calculates the amount that should be paid out to a host that successfully finished the request * @param requestId RequestId of the request used to calculate the payout * amount. - * @param startingTimestamp timestamp indicating when a host filled a slot and + * @param start timestamp indicating when a host filled a slot and * started providing proofs. */ function _slotPayout( RequestId requestId, - uint64 startingTimestamp + Timestamp start ) private view returns (uint256) { - return - _slotPayout( - requestId, - startingTimestamp, - _requestContexts[requestId].endsAt - ); + return _slotPayout(requestId, start, _requestContexts[requestId].endsAt); } /// @notice Calculates the amount that should be paid out to a host based on the specified time frame. function _slotPayout( RequestId requestId, - uint64 startingTimestamp, - uint64 endingTimestamp + Timestamp start, + Timestamp end ) private view returns (uint256) { Request storage request = _requests[requestId]; - if (startingTimestamp >= endingTimestamp) + if (end <= start) { revert Marketplace_StartNotBeforeExpiry(); - return - (endingTimestamp - startingTimestamp) * - request.ask.pricePerSlotPerSecond(); + } + return request.ask.pricePerSlotPerSecond().accumulate(start.until(end)); } function getHost(SlotId slotId) public view returns (address) { @@ -655,15 +653,15 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { RequestId requestId ) public view requestIsKnown(requestId) returns (RequestState) { RequestContext storage context = _requestContexts[requestId]; + Timestamp currentTime = Timestamps.currentTime(); if ( context.state == RequestState.New && - uint64(block.timestamp) > requestExpiry(requestId) + requestExpiry(requestId) < currentTime ) { return RequestState.Cancelled; } else if ( (context.state == RequestState.Started || - context.state == RequestState.New) && - uint64(block.timestamp) > context.endsAt + context.state == RequestState.New) && context.endsAt < currentTime ) { return RequestState.Finished; } else { @@ -703,7 +701,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { (request.ask.proofProbability * (256 - _config.proofs.downtime)) / 256; } - event StorageRequested(RequestId requestId, Ask ask, uint64 expiry); + event StorageRequested(RequestId requestId, Ask ask, Timestamp expiry); event RequestFulfilled(RequestId indexed requestId); event RequestFailed(RequestId indexed requestId); event SlotFilled(RequestId indexed requestId, uint64 slotIndex); diff --git a/contracts/Requests.sol b/contracts/Requests.sol index 0f296be..1bebeb3 100644 --- a/contracts/Requests.sol +++ b/contracts/Requests.sol @@ -1,6 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; +import "./Timestamps.sol"; +import "./Tokens.sol"; + type RequestId is bytes32; type SlotId is bytes32; @@ -8,17 +11,17 @@ struct Request { address client; Ask ask; Content content; - uint64 expiry; // amount of seconds since start of the request at which this request expires + Duration expiry; // amount of seconds since start of the request at which this request expires bytes32 nonce; // random nonce to differentiate between similar requests } struct Ask { uint256 proofProbability; // how often storage proofs are required - uint256 pricePerBytePerSecond; // amount of tokens paid per second per byte to hosts + TokensPerSecond pricePerBytePerSecond; // amount of tokens paid per second per byte to hosts uint256 collateralPerByte; // amount of tokens per byte required to be deposited by the hosts in order to fill the slot uint64 slots; // the number of requested slots uint64 slotSize; // amount of storage per slot (in number of bytes) - uint64 duration; // how long content should be stored (in seconds) + Duration duration; // how long content should be stored (in seconds) uint64 maxSlotLoss; // Max slots that can be lost without data considered to be lost } @@ -52,12 +55,14 @@ library AskHelpers { function pricePerSlotPerSecond( Ask memory ask - ) internal pure returns (uint256) { - return ask.pricePerBytePerSecond * ask.slotSize; + ) internal pure returns (TokensPerSecond) { + uint96 perByte = TokensPerSecond.unwrap(ask.pricePerBytePerSecond); + return TokensPerSecond.wrap(perByte * ask.slotSize); } } library Requests { + using Tokens for TokensPerSecond; using AskHelpers for Ask; function id(Request memory request) internal pure returns (RequestId) { @@ -89,10 +94,10 @@ library Requests { } } - function maxPrice(Request memory request) internal pure returns (uint256) { - return - request.ask.slots * - request.ask.duration * - request.ask.pricePerSlotPerSecond(); + function maxPrice(Request memory request) internal pure returns (uint128) { + uint64 slots = request.ask.slots; + TokensPerSecond rate = request.ask.pricePerSlotPerSecond(); + Duration duration = request.ask.duration; + return slots * rate.accumulate(duration); } } diff --git a/contracts/Timestamps.sol b/contracts/Timestamps.sol index dc63fa4..665eea5 100644 --- a/contracts/Timestamps.sol +++ b/contracts/Timestamps.sol @@ -5,8 +5,6 @@ pragma solidity 0.8.28; /// since 1970). Uses a uint40 to facilitate efficient packing in structs. A /// uint40 allows times to be represented for the coming 30 000 years. type Timestamp is uint40; -/// Represents a duration of time in seconds -type Duration is uint40; using {_timestampEquals as ==} for Timestamp global; using {_timestampNotEqual as !=} for Timestamp global; @@ -29,12 +27,39 @@ function _timestampAtMost(Timestamp a, Timestamp b) pure returns (bool) { return Timestamp.unwrap(a) <= Timestamp.unwrap(b); } +/// Represents a duration of time in seconds +type Duration is uint40; + +using {_durationEquals as ==} for Duration global; +using {_durationGreaterThan as >} for Duration global; +using {_durationAtLeast as >=} for Duration global; + +function _durationEquals(Duration a, Duration b) pure returns (bool) { + return Duration.unwrap(a) == Duration.unwrap(b); +} + +function _durationGreaterThan(Duration a, Duration b) pure returns (bool) { + return Duration.unwrap(a) > Duration.unwrap(b); +} + +function _durationAtLeast(Duration a, Duration b) pure returns (bool) { + return Duration.unwrap(a) >= Duration.unwrap(b); +} + library Timestamps { /// Returns the current block timestamp converted to a Timestamp type function currentTime() internal view returns (Timestamp) { return Timestamp.wrap(uint40(block.timestamp)); } + // Adds a duration to a timestamp + function add( + Timestamp begin, + Duration duration + ) internal pure returns (Timestamp) { + return Timestamp.wrap(Timestamp.unwrap(begin) + Duration.unwrap(duration)); + } + /// Calculates the duration from start until end function until( Timestamp start, @@ -42,4 +67,16 @@ library Timestamps { ) internal pure returns (Duration) { return Duration.wrap(Timestamp.unwrap(end) - Timestamp.unwrap(start)); } + + /// Returns the earliest of the two timestamps + function earliest( + Timestamp a, + Timestamp b + ) internal pure returns (Timestamp) { + if (a <= b) { + return a; + } else { + return b; + } + } } diff --git a/contracts/marketplace/VaultHelpers.sol b/contracts/marketplace/VaultHelpers.sol index 19548cc..0cab7da 100644 --- a/contracts/marketplace/VaultHelpers.sol +++ b/contracts/marketplace/VaultHelpers.sol @@ -43,11 +43,4 @@ library VaultHelpers { function asFundId(RequestId requestId) internal pure returns (FundId) { return FundId.wrap(RequestId.unwrap(requestId)); } - - function slotPrice( - Request memory request - ) internal pure returns (TokensPerSecond) { - uint256 price = request.ask.pricePerBytePerSecond * request.ask.slotSize; - return TokensPerSecond.wrap(uint96(price)); - } } diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 5677b23..53efb9a 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -507,9 +507,9 @@ describe("Marketplace", function () { it("sets the request end time to now + duration", async function () { await marketplace.reserveSlot(slot.request, slot.index) await marketplace.fillSlot(slot.request, slot.index, proof) - await expect( - (await marketplace.requestEnd(requestId(request))).toNumber() - ).to.equal(requestTime + request.ask.duration) + await expect(await marketplace.requestEnd(requestId(request))).to.equal( + requestTime + request.ask.duration + ) }) it("sets request end time to the past once cancelled", async function () { @@ -639,9 +639,7 @@ describe("Marketplace", function () { it("pays the host when contract was cancelled", async function () { // Lets advance the time more into the expiry window const filledAt = (await currentTime()) + Math.floor(request.expiry / 3) - const expiresAt = ( - await marketplace.requestExpiry(requestId(request)) - ).toNumber() + const expiresAt = await marketplace.requestExpiry(requestId(request)) const startBalance = await token.balanceOf(host.address) await marketplace.reserveSlot(slot.request, slot.index) @@ -856,9 +854,7 @@ describe("Marketplace", function () { it("withdraws to the client for cancelled requests lowered by hosts payout", async function () { // Lets advance the time more into the expiry window const filledAt = (await currentTime()) + Math.floor(request.expiry / 3) - const expiresAt = ( - await marketplace.requestExpiry(requestId(request)) - ).toNumber() + const expiresAt = await marketplace.requestExpiry(requestId(request)) await marketplace.reserveSlot(slot.request, slot.index) await setNextBlockTimestamp(filledAt) diff --git a/test/ids.js b/test/ids.js index 4547565..0587946 100644 --- a/test/ids.js +++ b/test/ids.js @@ -2,10 +2,10 @@ const { ethers } = require("hardhat") const { keccak256, defaultAbiCoder } = ethers.utils function requestId(request) { - const Ask = "tuple(uint256, uint256, uint256, uint64, uint64, uint64, int64)" + const Ask = "tuple(uint256, uint96, uint256, uint64, uint64, uint40, uint64)" const Content = "tuple(bytes, bytes32)" const Request = - "tuple(address, " + Ask + ", " + Content + ", uint64, bytes32)" + "tuple(address, " + Ask + ", " + Content + ", uint40, bytes32)" return keccak256(defaultAbiCoder.encode([Request], requestToArray(request))) } diff --git a/test/marketplace.js b/test/marketplace.js index cff1436..82f6cfc 100644 --- a/test/marketplace.js +++ b/test/marketplace.js @@ -4,7 +4,7 @@ const { payoutForDuration } = require("./price") const { collateralPerSlot } = require("./collateral") async function waitUntilCancelled(contract, request) { - const expiry = (await contract.requestExpiry(requestId(request))).toNumber() + const expiry = await contract.requestExpiry(requestId(request)) // We do +1, because the expiry check in contract is done as `>` and not `>=`. await advanceTimeTo(expiry + 1) } @@ -13,7 +13,7 @@ async function waitUntilSlotsFilled(contract, request, proof, token, slots) { let collateral = collateralPerSlot(request) await token.approve(contract.address, collateral * slots.length) - let requestEnd = (await contract.requestEnd(requestId(request))).toNumber() + let requestEnd = await contract.requestEnd(requestId(request)) const payouts = [] for (let slotIndex of slots) { await contract.reserveSlot(requestId(request), slotIndex) @@ -40,7 +40,7 @@ async function waitUntilStarted(contract, request, proof, token) { } async function waitUntilFinished(contract, requestId) { - const end = (await contract.requestEnd(requestId)).toNumber() + const end = await contract.requestEnd(requestId) // We do +1, because the end check in contract is done as `>` and not `>=`. await advanceTimeTo(end + 1) } diff --git a/test/requests.js b/test/requests.js index 53a18d7..16ad7a3 100644 --- a/test/requests.js +++ b/test/requests.js @@ -1,5 +1,4 @@ const { Assertion } = require("chai") -const { currentTime } = require("./evm") const RequestState = { New: 0, From 761fbd4f84ed5dac5d0d21c2d7b48393c2713cf2 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Wed, 26 Feb 2025 14:03:02 +0100 Subject: [PATCH 07/38] marketplace: collateral is uint128 Vault stores balances as uint128 --- contracts/Marketplace.sol | 12 ++++++------ contracts/Requests.sol | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 3ba6236..3ff1590 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -243,8 +243,8 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { context.fundsToReturnToClient -= _slotPayout(requestId, slot.filledAt); // Collect collateral - uint256 collateralAmount; - uint256 collateralPerSlot = request.ask.collateralPerSlot(); + uint128 collateralAmount; + uint128 collateralPerSlot = request.ask.collateralPerSlot(); if (slotState(slotId) == SlotState.Repair) { // Host is repairing a slot and is entitled for repair reward, so he gets "discounted collateral" // in this way he gets "physically" the reward at the end of the request when the full amount of collateral @@ -261,7 +261,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { AccountId hostAccount = _vault.hostAccount(slot.host, slotIndex); TokensPerSecond rate = request.ask.pricePerSlotPerSecond(); - _transferToVault(slot.host, fund, hostAccount, uint128(collateralAmount)); + _transferToVault(slot.host, fund, hostAccount, collateralAmount); _vault.flow(fund, clientAccount, hostAccount, rate); _marketplaceTotals.received += collateralAmount; @@ -374,10 +374,10 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { Slot storage slot = _slots[slotId]; Request storage request = _requests[slot.requestId]; - uint256 slashedAmount = (request.ask.collateralPerSlot() * + uint128 slashedAmount = (request.ask.collateralPerSlot() * _config.collateral.slashPercentage) / 100; - uint256 validatorRewardAmount = (slashedAmount * + uint128 validatorRewardAmount = (slashedAmount * _config.collateral.validatorRewardPercentage) / 100; _marketplaceTotals.sent += validatorRewardAmount; @@ -388,7 +388,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { fund, hostAccount, validatorAccount, - uint128(validatorRewardAmount) + validatorRewardAmount ); slot.currentCollateral -= slashedAmount; diff --git a/contracts/Requests.sol b/contracts/Requests.sol index 1bebeb3..c6cc99b 100644 --- a/contracts/Requests.sol +++ b/contracts/Requests.sol @@ -18,7 +18,7 @@ struct Request { struct Ask { uint256 proofProbability; // how often storage proofs are required TokensPerSecond pricePerBytePerSecond; // amount of tokens paid per second per byte to hosts - uint256 collateralPerByte; // amount of tokens per byte required to be deposited by the hosts in order to fill the slot + uint128 collateralPerByte; // amount of tokens per byte required to be deposited by the hosts in order to fill the slot uint64 slots; // the number of requested slots uint64 slotSize; // amount of storage per slot (in number of bytes) Duration duration; // how long content should be stored (in seconds) @@ -49,7 +49,7 @@ enum SlotState { } library AskHelpers { - function collateralPerSlot(Ask memory ask) internal pure returns (uint256) { + function collateralPerSlot(Ask memory ask) internal pure returns (uint128) { return ask.collateralPerByte * ask.slotSize; } From 15c58e1a8120231d06fc2d887b5eeb803bf24a87 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Wed, 26 Feb 2025 14:13:05 +0100 Subject: [PATCH 08/38] marketplace: remove fuzzing replaced by formal verification with certora --- .github/workflows/ci.yml | 5 ----- Readme.md | 4 ---- contracts/FuzzMarketplace.sol | 37 ----------------------------------- fuzzing/corpus/.gitignore | 3 --- fuzzing/corpus/.keep | 0 fuzzing/echidna.yaml | 11 ----------- fuzzing/fuzz.sh | 33 ------------------------------- package.json | 1 - 8 files changed, 94 deletions(-) delete mode 100644 contracts/FuzzMarketplace.sol delete mode 100644 fuzzing/corpus/.gitignore delete mode 100644 fuzzing/corpus/.keep delete mode 100644 fuzzing/echidna.yaml delete mode 100755 fuzzing/fuzz.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7bf34e..935e74b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,11 +32,6 @@ jobs: node-version: 18.15 - 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/Readme.md b/Readme.md index c5e8872..2c0efb6 100644 --- a/Readme.md +++ b/Readme.md @@ -14,10 +14,6 @@ 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 diff --git a/contracts/FuzzMarketplace.sol b/contracts/FuzzMarketplace.sol deleted file mode 100644 index 1520522..0000000 --- a/contracts/FuzzMarketplace.sol +++ /dev/null @@ -1,37 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import "./TestToken.sol"; -import "./Marketplace.sol"; -import "./Vault.sol"; -import "./TestVerifier.sol"; - -contract FuzzMarketplace is Marketplace { - constructor() - Marketplace( - MarketplaceConfig( - CollateralConfig(10, 5, 10, 20), - ProofConfig(10, 5, 64, 67, ""), - SlotReservationsConfig(20), - Duration.wrap(60 * 60 * 24 * 30) // 30 days - ), - new Vault(new TestToken()), - new TestVerifier() - ) - {} - - // 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/fuzzing/corpus/.gitignore b/fuzzing/corpus/.gitignore deleted file mode 100644 index 1c0813e..0000000 --- a/fuzzing/corpus/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -* -!.gitignore -!.keep diff --git a/fuzzing/corpus/.keep b/fuzzing/corpus/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/fuzzing/echidna.yaml b/fuzzing/echidna.yaml deleted file mode 100644 index feb64ee..0000000 --- a/fuzzing/echidna.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# configure Echidna fuzzing tests - -testMode: "assertion" # check that solidity asserts are never triggered -allContracts: true # allow calls to e.g. TestToken in test scenarios -format: "text" # disable interactive ui - -# For a longer test run, consider these options: -# timeout: 3600 # limit test run to one hour -# testLimit: 100000000000 # do not limit the amount of test sequences -# stopOnFail: true # stop on first failure -# workers: 8 # use more cpu cores diff --git a/fuzzing/fuzz.sh b/fuzzing/fuzz.sh deleted file mode 100755 index a869e18..0000000 --- a/fuzzing/fuzz.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -set -e - -root=$(cd $(dirname "$0")/.. && pwd) -arch=$(arch) - -if command -v echidna; then - fuzz () { - echidna ${root} \ - --config ${root}/fuzzing/echidna.yaml \ - --corpus-dir ${root}/fuzzing/corpus \ - --crytic-args --ignore-compile \ - --contract $1 - } -elif [ "${arch}" = "x86_64" ]; then - fuzz () { - docker run \ - --rm \ - -v ${root}:/src ghcr.io/crytic/echidna/echidna \ - bash -c \ - "cd /src && echidna . \ - --config fuzzing/echidna.yaml \ - --corpus-dir fuzzing/corpus \ - --crytic-args --ignore-compile \ - --contract $1" - } -else - echo "Error: echidna not found, and the docker image does not support ${arch}" - echo "Please install echidna: https://github.com/crytic/echidna#installation" - exit 1 -fi - -fuzz FuzzMarketplace diff --git a/package.json b/package.json index cb36df8..c2948fc 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "license": "MIT", "scripts": { "test": "npm run lint && hardhat test", - "fuzz": "hardhat compile && fuzzing/fuzz.sh", "start": "hardhat node --export deployment-localhost.json", "compile": "hardhat compile", "format": "prettier --write contracts/*.sol contracts/**/*.sol test/**/*.js", From 9570404fba927a30a8d0c46bb7989b6735302db7 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Wed, 26 Feb 2025 14:27:30 +0100 Subject: [PATCH 09/38] marketplace: remove accounting that is now done by vault --- contracts/Marketplace.sol | 66 ++------------------------------------- test/Marketplace.test.js | 12 ++++--- 2 files changed, 9 insertions(+), 69 deletions(-) diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 3ff1590..1ba8124 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -40,7 +40,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { error Marketplace_ProofNotSubmittedByHost(); error Marketplace_SlotIsFree(); error Marketplace_ReservationRequired(); - error Marketplace_NothingToWithdraw(); error Marketplace_DurationExceedsLimit(); using SafeERC20 for IERC20; @@ -61,18 +60,8 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { mapping(RequestId => RequestContext) internal _requestContexts; mapping(SlotId => Slot) internal _slots; - MarketplaceTotals internal _marketplaceTotals; - struct RequestContext { RequestState state; - /// @notice Tracks how much funds should be returned to the client as not all funds might be used for hosting the request - /// @dev The sum starts with the full reward amount for the request and is reduced every time a host fills a slot. - /// The reduction is calculated from the duration of time between the slot being filled and the request's end. - /// This is the amount that will be paid out to the host when the request successfully finishes. - /// @dev fundsToReturnToClient == 0 is used to signal that after request is terminated all the remaining funds were withdrawn. - /// This is possible, because technically it is not possible for this variable to reach 0 in "natural" way as - /// that would require all the slots to be filled at the same block as the request was created. - uint256 fundsToReturnToClient; uint64 slotsFilled; Timestamp startedAt; Timestamp endsAt; @@ -182,10 +171,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { _addToMyRequests(request.client, id); - uint128 amount = request.maxPrice(); - _requestContexts[id].fundsToReturnToClient = amount; - _marketplaceTotals.received += amount; - FundId fund = id.asFundId(); AccountId account = _vault.clientAccount(request.client); _vault.lock( @@ -193,7 +178,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { _requestContexts[id].expiresAt, _requestContexts[id].endsAt ); - _transferToVault(request.client, fund, account, amount); + _transferToVault(request.client, fund, account, request.maxPrice()); emit StorageRequested(id, request.ask, _requestContexts[id].expiresAt); } @@ -240,7 +225,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { submitProof(slotId, proof); context.slotsFilled += 1; - context.fundsToReturnToClient -= _slotPayout(requestId, slot.filledAt); // Collect collateral uint128 collateralAmount; @@ -264,7 +248,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { _transferToVault(slot.host, fund, hostAccount, collateralAmount); _vault.flow(fund, clientAccount, hostAccount, rate); - _marketplaceTotals.received += collateralAmount; slot.currentCollateral = collateralPerSlot; // Even if he has collateral discounted, he is operating with full collateral _addToMySlots(slot.host, slotId); @@ -379,7 +362,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint128 validatorRewardAmount = (slashedAmount * _config.collateral.validatorRewardPercentage) / 100; - _marketplaceTotals.sent += validatorRewardAmount; FundId fund = slot.requestId.asFundId(); AccountId hostAccount = _vault.hostAccount(slot.host, slot.slotIndex); @@ -411,10 +393,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { RequestId requestId = slot.requestId; RequestContext storage context = _requestContexts[requestId]; - // We need to refund the amount of payout of the current node to the `fundsToReturnToClient` so - // we keep correctly the track of the funds that needs to be returned at the end. - context.fundsToReturnToClient += _slotPayout(requestId, slot.filledAt); - Request storage request = _requests[requestId]; FundId fund = requestId.asFundId(); @@ -459,9 +437,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { _removeFromMyRequests(request.client, requestId); _removeFromMySlots(slot.host, slotId); - uint256 payoutAmount = _slotPayout(requestId, slot.filledAt); - uint256 collateralAmount = slot.currentCollateral; - _marketplaceTotals.sent += (payoutAmount + collateralAmount); slot.state = SlotState.Paid; FundId fund = requestId.asFundId(); AccountId account = _vault.hostAccount(slot.host, slot.slotIndex); @@ -481,14 +456,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { ) private requestIsKnown(requestId) { Slot storage slot = _slots[slotId]; _removeFromMySlots(slot.host, slotId); - - uint256 payoutAmount = _slotPayout( - requestId, - slot.filledAt, - requestExpiry(requestId) - ); - uint256 collateralAmount = slot.currentCollateral; - _marketplaceTotals.sent += (payoutAmount + collateralAmount); slot.state = SlotState.Paid; FundId fund = requestId.asFundId(); AccountId account = _vault.hostAccount(slot.host, slot.slotIndex); @@ -517,40 +484,16 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { revert Marketplace_InvalidState(); } - // fundsToReturnToClient == 0 is used for "double-spend" protection, once the funds are withdrawn - // then this variable is set to 0. - if (context.fundsToReturnToClient == 0) - revert Marketplace_NothingToWithdraw(); - + context.state = state; if (state == RequestState.Cancelled) { - context.state = RequestState.Cancelled; emit RequestCancelled(requestId); - - // `fundsToReturnToClient` currently tracks funds to be returned for requests that successfully finish. - // When requests are cancelled, funds earmarked for payment for the duration - // between request expiry and request end (for every slot that was filled), should be returned to the client. - // Update `fundsToReturnToClient` to reflect this. - context.fundsToReturnToClient += - context.slotsFilled * - _slotPayout(requestId, requestExpiry(requestId)); - } else if (state == RequestState.Failed) { - // For Failed requests the client is refunded whole amount. - context.fundsToReturnToClient = request.maxPrice(); - } else { - context.state = RequestState.Finished; } _removeFromMyRequests(request.client, requestId); - uint256 amount = context.fundsToReturnToClient; - _marketplaceTotals.sent += amount; - FundId fund = requestId.asFundId(); AccountId account = _vault.clientAccount(request.client); _vault.withdraw(fund, account); - - // We zero out the funds tracking in order to prevent double-spends - context.fundsToReturnToClient = 0; } function withdrawByValidator(RequestId requestId) public { @@ -707,9 +650,4 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { event SlotFilled(RequestId indexed requestId, uint64 slotIndex); event SlotFreed(RequestId indexed requestId, uint64 slotIndex); event RequestCancelled(RequestId indexed requestId); - - struct MarketplaceTotals { - uint256 received; - uint256 sent; - } } diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 53efb9a..8962ef8 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -783,15 +783,17 @@ describe("Marketplace", function () { ) }) - it("rejects withdraw when already withdrawn", async function () { + it("does not withdraw more than once", async function () { await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) - switchAccount(client) await marketplace.withdrawFunds(slot.request) - await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith( - "Marketplace_NothingToWithdraw" - ) + + const startBalance = await token.balanceOf(client.address) + await marketplace.withdrawFunds(slot.request) + const endBalance = await token.balanceOf(client.address) + + expect(endBalance - startBalance).to.equal(0) }) it("emits event once request is cancelled", async function () { From 3ea02914fa5928ef373609c7b0074713f13607a9 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Wed, 26 Feb 2025 15:00:13 +0100 Subject: [PATCH 10/38] marketplace: simplify withdrawing by client - removes RequestCancelled event, which was not great anyway because it is not emitted at the moment that the request is cancelled --- contracts/Marketplace.sol | 34 ++++------------------------------ test/Marketplace.test.js | 23 +++++++++-------------- 2 files changed, 13 insertions(+), 44 deletions(-) diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 1ba8124..8bb6f5e 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -462,38 +462,13 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { _vault.withdraw(fund, account); } - /** - * @notice Withdraws remaining storage request funds back to the client that - deposited them. - * @dev Request must be cancelled, failed or finished, and the - transaction must originate from the depositor address. - * @param requestId the id of the request - */ + /// Withdraws remaining storage request funds back to the client that function withdrawFunds(RequestId requestId) public requestIsKnown(requestId) { - Request storage request = _requests[requestId]; - RequestContext storage context = _requestContexts[requestId]; - - if (request.client != msg.sender) revert Marketplace_InvalidClientAddress(); - - RequestState state = requestState(requestId); - if ( - state != RequestState.Cancelled && - state != RequestState.Failed && - state != RequestState.Finished - ) { - revert Marketplace_InvalidState(); - } - - context.state = state; - if (state == RequestState.Cancelled) { - emit RequestCancelled(requestId); - } - - _removeFromMyRequests(request.client, requestId); - FundId fund = requestId.asFundId(); - AccountId account = _vault.clientAccount(request.client); + AccountId account = _vault.clientAccount(msg.sender); _vault.withdraw(fund, account); + + _removeFromMyRequests(msg.sender, requestId); } function withdrawByValidator(RequestId requestId) public { @@ -649,5 +624,4 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { event RequestFailed(RequestId indexed requestId); event SlotFilled(RequestId indexed requestId, uint64 slotIndex); event SlotFreed(RequestId indexed requestId, uint64 slotIndex); - event RequestCancelled(RequestId indexed requestId); } diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 8962ef8..6434b9b 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -754,15 +754,18 @@ describe("Marketplace", function () { it("rejects withdraw when request not yet timed out", async function () { switchAccount(client) await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith( - "Marketplace_InvalidState" + "VaultFundNotUnlocked" ) }) - it("rejects withdraw when wrong account used", async function () { + it("withdraws nothing when wrong account used", async function () { await waitUntilCancelled(marketplace, request) - await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith( - "Marketplace_InvalidClientAddress" - ) + + const startBalance = await token.balanceOf(host.address) + await marketplace.withdrawFunds(slot.request) + const endBalance = await token.balanceOf(host.address) + + expect(endBalance - startBalance).to.equal(0) }) it("rejects withdraw when in wrong state", async function () { @@ -779,7 +782,7 @@ describe("Marketplace", function () { await waitUntilCancelled(marketplace, request) switchAccount(client) await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith( - "Marketplace_InvalidState" + "VaultFundNotUnlocked" ) }) @@ -796,14 +799,6 @@ describe("Marketplace", function () { expect(endBalance - startBalance).to.equal(0) }) - it("emits event once request is cancelled", async function () { - await waitUntilCancelled(marketplace, request) - switchAccount(client) - await expect(marketplace.withdrawFunds(slot.request)) - .to.emit(marketplace, "RequestCancelled") - .withArgs(requestId(request)) - }) - it("withdraw rest of funds to the client for finished requests", async function () { await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) From c626372d55ee66a91894076e2e0bd5335f7cc9e9 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Thu, 27 Feb 2025 08:23:56 +0100 Subject: [PATCH 11/38] marketplace: burn tokens in vault when slashing - move all collateral calculatons to separate library --- contracts/Marketplace.sol | 72 +++++----------------- contracts/Requests.sol | 4 -- contracts/TestMarketplace.sol | 10 +++- contracts/marketplace/Collateral.sol | 69 +++++++++++++++++++++ test/Marketplace.test.js | 90 +++++++++------------------- 5 files changed, 121 insertions(+), 124 deletions(-) create mode 100644 contracts/marketplace/Collateral.sol diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 8bb6f5e..fd305a4 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -14,11 +14,9 @@ import "./StateRetrieval.sol"; import "./Endian.sol"; import "./Groth16.sol"; import "./marketplace/VaultHelpers.sol"; +import "./marketplace/Collateral.sol"; contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { - error Marketplace_RepairRewardPercentageTooHigh(); - error Marketplace_SlashPercentageTooHigh(); - error Marketplace_MaximumSlashingTooHigh(); error Marketplace_InvalidExpiry(); error Marketplace_InvalidMaxSlotLoss(); error Marketplace_InsufficientSlots(); @@ -50,6 +48,8 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { using VaultHelpers for Vault; using VaultHelpers for RequestId; using VaultHelpers for Request; + using Collateral for Request; + using Collateral for CollateralConfig; using Timestamps for Timestamp; using Tokens for TokensPerSecond; @@ -76,14 +76,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { /// based on time they actually host the content Timestamp filledAt; uint64 slotIndex; - /// @notice Tracks the current amount of host's collateral that is - /// to be payed out at the end of Slot's lifespan. - /// @dev When Slot is filled, the collateral is collected in amount - /// of request.ask.collateralPerByte * request.ask.slotSize - /// (== request.ask.collateralPerSlot() when using the AskHelpers library) - /// @dev When Host is slashed for missing a proof the slashed amount is - /// reflected in this variable - uint256 currentCollateral; /// @notice address used for collateral interactions and identifying hosts address host; } @@ -99,18 +91,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { IGroth16Verifier verifier ) SlotReservations(config.reservations) Proofs(config.proofs, verifier) { _vault = vault_; - - if (config.collateral.repairRewardPercentage > 100) - revert Marketplace_RepairRewardPercentageTooHigh(); - if (config.collateral.slashPercentage > 100) - revert Marketplace_SlashPercentageTooHigh(); - - if ( - config.collateral.maxNumberOfSlashes * config.collateral.slashPercentage > - 100 - ) { - revert Marketplace_MaximumSlashingTooHigh(); - } + config.collateral.checkCorrectness(); _config = config; } @@ -126,10 +107,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { return _vault; } - function currentCollateral(SlotId slotId) public view returns (uint256) { - return _slots[slotId].currentCollateral; - } - function requestStorage(Request calldata request) public { RequestId id = request.id(); @@ -227,17 +204,15 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { context.slotsFilled += 1; // Collect collateral - uint128 collateralAmount; - uint128 collateralPerSlot = request.ask.collateralPerSlot(); + uint128 collateralAmount = request.collateralPerSlot(); + uint128 designatedAmount = _config.collateral.designatedCollateral( + collateralAmount + ); if (slotState(slotId) == SlotState.Repair) { // Host is repairing a slot and is entitled for repair reward, so he gets "discounted collateral" // in this way he gets "physically" the reward at the end of the request when the full amount of collateral // is returned to him. - collateralAmount = - collateralPerSlot - - ((collateralPerSlot * _config.collateral.repairRewardPercentage) / 100); - } else { - collateralAmount = collateralPerSlot; + collateralAmount -= _config.collateral.repairReward(collateralAmount); } FundId fund = requestId.asFundId(); @@ -246,10 +221,9 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { TokensPerSecond rate = request.ask.pricePerSlotPerSecond(); _transferToVault(slot.host, fund, hostAccount, collateralAmount); + _vault.designate(fund, hostAccount, designatedAmount); _vault.flow(fund, clientAccount, hostAccount, rate); - slot.currentCollateral = collateralPerSlot; // Even if he has collateral discounted, he is operating with full collateral - _addToMySlots(slot.host, slotId); slot.state = SlotState.Filled; @@ -357,23 +331,16 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { Slot storage slot = _slots[slotId]; Request storage request = _requests[slot.requestId]; - uint128 slashedAmount = (request.ask.collateralPerSlot() * - _config.collateral.slashPercentage) / 100; - - uint128 validatorRewardAmount = (slashedAmount * - _config.collateral.validatorRewardPercentage) / 100; + uint128 collateral = request.collateralPerSlot(); + uint128 slashedAmount = _config.collateral.slashAmount(collateral); + uint128 validatorReward = _config.collateral.validatorReward(slashedAmount); FundId fund = slot.requestId.asFundId(); AccountId hostAccount = _vault.hostAccount(slot.host, slot.slotIndex); AccountId validatorAccount = _vault.validatorAccount(msg.sender); - _vault.transfer( - fund, - hostAccount, - validatorAccount, - validatorRewardAmount - ); + _vault.transfer(fund, hostAccount, validatorAccount, validatorReward); + _vault.burnDesignated(fund, hostAccount, slashedAmount - validatorReward); - slot.currentCollateral -= slashedAmount; if (missingProofs(slotId) >= _config.collateral.maxNumberOfSlashes) { // When the number of slashings is at or above the allowed amount, // free the slot. @@ -381,13 +348,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { } } - /** - * @notice Abandons the slot without returning collateral, effectively slashing the - entire collateral. - * @param slotId SlotId of the slot to free. - * @dev _slots[slotId] is deleted, resetting _slots[slotId].currentCollateral - to 0. - */ + /// Abandons the slot, burns all associated tokens function _forciblyFreeSlot(SlotId slotId) internal { Slot storage slot = _slots[slotId]; RequestId requestId = slot.requestId; @@ -407,7 +368,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { _reservations[slotId].clear(); // We purge all the reservations for the slot slot.state = SlotState.Repair; slot.filledAt = Timestamp.wrap(0); - slot.currentCollateral = 0; slot.host = address(0); context.slotsFilled -= 1; emit SlotFreed(requestId, slot.slotIndex); diff --git a/contracts/Requests.sol b/contracts/Requests.sol index c6cc99b..d3f7e85 100644 --- a/contracts/Requests.sol +++ b/contracts/Requests.sol @@ -49,10 +49,6 @@ enum SlotState { } library AskHelpers { - function collateralPerSlot(Ask memory ask) internal pure returns (uint128) { - return ask.collateralPerByte * ask.slotSize; - } - function pricePerSlotPerSecond( Ask memory ask ) internal pure returns (TokensPerSecond) { diff --git a/contracts/TestMarketplace.sol b/contracts/TestMarketplace.sol index c13d7a1..864fe6d 100644 --- a/contracts/TestMarketplace.sol +++ b/contracts/TestMarketplace.sol @@ -5,6 +5,9 @@ import "./Marketplace.sol"; // exposes internal functions of Marketplace for testing contract TestMarketplace is Marketplace { + using VaultHelpers for RequestId; + using VaultHelpers for Vault; + constructor( MarketplaceConfig memory config, Vault vault, @@ -15,8 +18,11 @@ contract TestMarketplace is Marketplace { _forciblyFreeSlot(slotId); } - function getSlotCollateral(SlotId slotId) public view returns (uint256) { - return _slots[slotId].currentCollateral; + function getSlotBalance(SlotId slotId) public view returns (uint256) { + Slot storage slot = _slots[slotId]; + FundId fund = slot.requestId.asFundId(); + AccountId account = vault().hostAccount(slot.host, slot.slotIndex); + return vault().getBalance(fund, account); } function challengeToFieldElement( diff --git a/contracts/marketplace/Collateral.sol b/contracts/marketplace/Collateral.sol new file mode 100644 index 0000000..f6e4956 --- /dev/null +++ b/contracts/marketplace/Collateral.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import "../Configuration.sol"; +import "../Requests.sol"; + +library Collateral { + using Collateral for Request; + using Collateral for CollateralConfig; + + function checkCorrectness( + CollateralConfig memory configuration + ) internal pure { + require( + configuration.repairRewardPercentage <= 100, + Marketplace_RepairRewardPercentageTooHigh() + ); + require( + configuration.slashPercentage <= 100, + Marketplace_SlashPercentageTooHigh() + ); + require( + configuration.maxNumberOfSlashes * configuration.slashPercentage <= 100, + Marketplace_MaximumSlashingTooHigh() + ); + } + + function collateralPerSlot( + Request storage request + ) internal view returns (uint128) { + return request.ask.collateralPerByte * request.ask.slotSize; + } + + function slashAmount( + CollateralConfig storage configuration, + uint128 collateral + ) internal view returns (uint128) { + return (collateral * configuration.slashPercentage) / 100; + } + + function repairReward( + CollateralConfig storage configuration, + uint128 collateral + ) internal view returns (uint128) { + return (collateral * configuration.repairRewardPercentage) / 100; + } + + function validatorReward( + CollateralConfig storage configuration, + uint128 slashed + ) internal view returns (uint128) { + return (slashed * configuration.validatorRewardPercentage) / 100; + } + + function designatedCollateral( + CollateralConfig storage configuration, + uint128 collateral + ) internal view returns (uint128) { + uint8 slashes = configuration.maxNumberOfSlashes; + uint128 slashing = configuration.slashAmount(collateral); + uint128 validation = slashes * configuration.validatorReward(slashing); + uint128 repair = configuration.repairReward(collateral); + return collateral - validation - repair; + } + + error Marketplace_RepairRewardPercentageTooHigh(); + error Marketplace_SlashPercentageTooHigh(); + error Marketplace_MaximumSlashingTooHigh(); +} diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 6434b9b..27b9840 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -1200,18 +1200,16 @@ describe("Marketplace", function () { await waitUntilProofIsRequired(id) let missedPeriod = periodOf(await currentTime()) await advanceTime(period + 1) + + const startBalance = await marketplace.getSlotBalance(id) + await setNextBlockTimestamp(await currentTime()) await marketplace.markProofAsMissing(id, missedPeriod) + const endBalance = await marketplace.getSlotBalance(id) const collateral = collateralPerSlot(request) - const expectedBalance = Math.round( - (collateral * (100 - slashPercentage)) / 100 - ) + const expectedSlash = Math.round((collateral * slashPercentage) / 100) - expect( - BigNumber.from(expectedBalance).eq( - await marketplace.getSlotCollateral(id) - ) - ) + expect(endBalance).to.equal(startBalance - expectedSlash) }) it("rewards validator when marking proof as missing", async function () { @@ -1245,62 +1243,30 @@ describe("Marketplace", function () { }) }) - it("frees slot when collateral slashed below minimum threshold", async function () { - const collateral = collateralPerSlot(request) - const minimum = - collateral - - (collateral * - config.collateral.maxNumberOfSlashes * - config.collateral.slashPercentage) / - 100 - await waitUntilStarted(marketplace, request, proof, token) - while ((await marketplace.slotState(slotId(slot))) === SlotState.Filled) { - expect(await marketplace.getSlotCollateral(slotId(slot))).to.be.gt( - minimum - ) - await waitUntilProofIsRequired(slotId(slot)) - const missedPeriod = periodOf(await currentTime()) - await advanceTime(period + 1) - await marketplace.markProofAsMissing(slotId(slot), missedPeriod) - } - expect(await marketplace.slotState(slotId(slot))).to.equal( - SlotState.Repair - ) - expect(await marketplace.getSlotCollateral(slotId(slot))).to.be.lte( - minimum - ) - }) + describe("when slashing the maximum number of times", function () { + beforeEach(async function () { + await waitUntilStarted(marketplace, request, proof, token) + for (let i = 0; i < config.collateral.maxNumberOfSlashes; i++) { + await waitUntilProofIsRequired(slotId(slot)) + const missedPeriod = periodOf(await currentTime()) + await advanceTime(period + 1) + await marketplace.markProofAsMissing(slotId(slot), missedPeriod) + } + }) - it("free slot when minimum reached and resets missed proof counter", async function () { - const collateral = collateralPerSlot(request) - const minimum = - collateral - - (collateral * - config.collateral.maxNumberOfSlashes * - config.collateral.slashPercentage) / - 100 - await waitUntilStarted(marketplace, request, proof, token) - let missedProofs = 0 - while ((await marketplace.slotState(slotId(slot))) === SlotState.Filled) { - expect(await marketplace.getSlotCollateral(slotId(slot))).to.be.gt( - minimum + it("sets the state to 'repair'", async function () { + expect(await marketplace.slotState(slotId(slot))).to.equal( + SlotState.Repair ) - await waitUntilProofIsRequired(slotId(slot)) - const missedPeriod = periodOf(await currentTime()) - await advanceTime(period + 1) - expect(await marketplace.missingProofs(slotId(slot))).to.equal( - missedProofs - ) - await marketplace.markProofAsMissing(slotId(slot), missedPeriod) - missedProofs += 1 - } - expect(await marketplace.slotState(slotId(slot))).to.equal( - SlotState.Repair - ) - expect(await marketplace.missingProofs(slotId(slot))).to.equal(0) - expect(await marketplace.getSlotCollateral(slotId(slot))).to.be.lte( - minimum - ) + }) + + it("burns the balance", async function () { + expect(await marketplace.getSlotBalance(slotId(slot))).to.equal(0) + }) + + it("resets missed proof counter", async function () { + expect(await marketplace.missingProofs(slotId(slot))).to.equal(0) + }) }) }) From 5e8031eda5b3847f66e3c8ed392afd9b72499163 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Thu, 27 Feb 2025 08:37:23 +0100 Subject: [PATCH 12/38] marketplace: remove accounting that is now done by vault --- contracts/Marketplace.sol | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index fd305a4..bd93d58 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -63,7 +63,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { struct RequestContext { RequestState state; uint64 slotsFilled; - Timestamp startedAt; Timestamp endsAt; Timestamp expiresAt; } @@ -71,12 +70,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { struct Slot { SlotState state; RequestId requestId; - /// @notice Timestamp that signals when slot was filled - /// @dev Used for calculating payouts as hosts are paid - /// based on time they actually host the content - Timestamp filledAt; uint64 slotIndex; - /// @notice address used for collateral interactions and identifying hosts address host; } @@ -193,10 +187,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { revert Marketplace_SlotNotFree(); } - Timestamp currentTime = Timestamps.currentTime(); - slot.host = msg.sender; - slot.filledAt = currentTime; _startRequiringProofs(slotId); submitProof(slotId, proof); @@ -234,7 +225,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { context.state == RequestState.New // Only New requests can "start" the requests ) { context.state = RequestState.Started; - context.startedAt = currentTime; _vault.extendLock(fund, context.endsAt); emit RequestFulfilled(requestId); } @@ -367,7 +357,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { _removeFromMySlots(slot.host, slotId); _reservations[slotId].clear(); // We purge all the reservations for the slot slot.state = SlotState.Repair; - slot.filledAt = Timestamp.wrap(0); slot.host = address(0); context.slotsFilled -= 1; emit SlotFreed(requestId, slot.slotIndex); From 5c9910d29d4a8b1969684782391910d1c8e03fbe Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Thu, 27 Feb 2025 08:42:19 +0100 Subject: [PATCH 13/38] marketplace: optimize storage reads and writes --- contracts/Marketplace.sol | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index bd93d58..de97458 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -135,23 +135,25 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { } Timestamp currentTime = Timestamps.currentTime(); + Timestamp expiresAt = currentTime.add(request.expiry); + Timestamp endsAt = currentTime.add(request.ask.duration); _requests[id] = request; - _requestContexts[id].endsAt = currentTime.add(request.ask.duration); - _requestContexts[id].expiresAt = currentTime.add(request.expiry); + _requestContexts[id] = RequestContext({ + state: RequestState.New, + slotsFilled: 0, + endsAt: endsAt, + expiresAt: expiresAt + }); _addToMyRequests(request.client, id); FundId fund = id.asFundId(); AccountId account = _vault.clientAccount(request.client); - _vault.lock( - fund, - _requestContexts[id].expiresAt, - _requestContexts[id].endsAt - ); + _vault.lock(fund, expiresAt, endsAt); _transferToVault(request.client, fund, account, request.maxPrice()); - emit StorageRequested(id, request.ask, _requestContexts[id].expiresAt); + emit StorageRequested(id, request.ask, expiresAt); } /** From b6f5d65630cb63b30bb1d93bcfadcd7a83b1445d Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Thu, 27 Feb 2025 09:31:51 +0100 Subject: [PATCH 14/38] marketplace: transfer repair reward in vault --- contracts/Marketplace.sol | 39 +++++++++++++----------- contracts/Requests.sol | 14 ++++----- test/Marketplace.test.js | 63 ++++++++++++++++++++++----------------- test/collateral.js | 7 ++++- 4 files changed, 70 insertions(+), 53 deletions(-) diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index de97458..2e670f4 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -148,10 +148,14 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { _addToMyRequests(request.client, id); + TokensPerSecond pricePerSecond = request.ask.pricePerSecond(); + uint128 price = pricePerSecond.accumulate(request.ask.duration); + FundId fund = id.asFundId(); AccountId account = _vault.clientAccount(request.client); _vault.lock(fund, expiresAt, endsAt); - _transferToVault(request.client, fund, account, request.maxPrice()); + _transferToVault(request.client, fund, account, price); + _vault.flow(fund, account, account, pricePerSecond); emit StorageRequested(id, request.ask, expiresAt); } @@ -196,25 +200,23 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { context.slotsFilled += 1; - // Collect collateral - uint128 collateralAmount = request.collateralPerSlot(); - uint128 designatedAmount = _config.collateral.designatedCollateral( - collateralAmount - ); - if (slotState(slotId) == SlotState.Repair) { - // Host is repairing a slot and is entitled for repair reward, so he gets "discounted collateral" - // in this way he gets "physically" the reward at the end of the request when the full amount of collateral - // is returned to him. - collateralAmount -= _config.collateral.repairReward(collateralAmount); - } - FundId fund = requestId.asFundId(); AccountId clientAccount = _vault.clientAccount(request.client); AccountId hostAccount = _vault.hostAccount(slot.host, slotIndex); TokensPerSecond rate = request.ask.pricePerSlotPerSecond(); - _transferToVault(slot.host, fund, hostAccount, collateralAmount); - _vault.designate(fund, hostAccount, designatedAmount); + uint128 collateral = request.collateralPerSlot(); + uint128 designated = _config.collateral.designatedCollateral(collateral); + + if (slotState(slotId) == SlotState.Repair) { + // host gets a discount on its collateral, paid for by the repair reward + uint128 repairReward = _config.collateral.repairReward(collateral); + _vault.transfer(fund, clientAccount, hostAccount, repairReward); + collateral -= repairReward; + } + + _transferToVault(slot.host, fund, hostAccount, collateral); + _vault.designate(fund, hostAccount, designated); _vault.flow(fund, clientAccount, hostAccount, rate); _addToMySlots(slot.host, slotId); @@ -348,12 +350,15 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { Request storage request = _requests[requestId]; + TokensPerSecond rate = request.ask.pricePerSlotPerSecond(); + uint128 collateral = request.collateralPerSlot(); + uint128 repairReward = _config.collateral.repairReward(collateral); + FundId fund = requestId.asFundId(); AccountId hostAccount = _vault.hostAccount(slot.host, slot.slotIndex); AccountId clientAccount = _vault.clientAccount(request.client); - TokensPerSecond rate = request.ask.pricePerSlotPerSecond(); - _vault.flow(fund, hostAccount, clientAccount, rate); + _vault.transfer(fund, hostAccount, clientAccount, repairReward); _vault.burnAccount(fund, hostAccount); _removeFromMySlots(slot.host, slotId); diff --git a/contracts/Requests.sol b/contracts/Requests.sol index d3f7e85..b347e9b 100644 --- a/contracts/Requests.sol +++ b/contracts/Requests.sol @@ -49,12 +49,19 @@ enum SlotState { } library AskHelpers { + using AskHelpers for Ask; + function pricePerSlotPerSecond( Ask memory ask ) internal pure returns (TokensPerSecond) { uint96 perByte = TokensPerSecond.unwrap(ask.pricePerBytePerSecond); return TokensPerSecond.wrap(perByte * ask.slotSize); } + + function pricePerSecond(Ask memory ask) internal pure returns (TokensPerSecond) { + uint96 perSlot = TokensPerSecond.unwrap(ask.pricePerSlotPerSecond()); + return TokensPerSecond.wrap(perSlot * ask.slots); + } } library Requests { @@ -89,11 +96,4 @@ library Requests { result := ids } } - - function maxPrice(Request memory request) internal pure returns (uint128) { - uint64 slots = request.ask.slots; - TokensPerSecond rate = request.ask.pricePerSlotPerSecond(); - Duration duration = request.ask.duration; - return slots * rate.accumulate(duration); - } } diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 27b9840..7592285 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -28,7 +28,7 @@ const { pricePerSlotPerSecond, payoutForDuration, } = require("./price") -const { collateralPerSlot } = require("./collateral") +const { collateralPerSlot, repairReward } = require("./collateral") const { snapshot, revert, @@ -296,34 +296,40 @@ describe("Marketplace", function () { expect(await marketplace.getHost(slotId(slot))).to.equal(host.address) }) - it("gives discount on the collateral for repaired slot", async function () { - await marketplace.reserveSlot(slot.request, slot.index) - await marketplace.fillSlot(slot.request, slot.index, proof) - await marketplace.freeSlot(slotId(slot)) - expect(await marketplace.slotState(slotId(slot))).to.equal( - SlotState.Repair - ) + describe("when repairing a slot", function () { + beforeEach(async function () { + await marketplace.reserveSlot(slot.request, slot.index) + await marketplace.fillSlot(slot.request, slot.index, proof) + await advanceTime(config.proofs.period + 1) + await marketplace.freeSlot(slotId(slot)) + }) - // We need to advance the time to next period, because filling slot - // must not be done in the same period as for that period there was already proof - // submitted with the previous `fillSlot` and the transaction would revert with "Proof already submitted". - await advanceTime(config.proofs.period + 1) + it("gives the host a discount on the collateral", async function () { + const collateral = collateralPerSlot(request) + const reward = repairReward(config, collateral) + const discountedCollateral = collateral - reward + await token.approve(marketplace.address, discountedCollateral) + await marketplace.reserveSlot(slot.request, slot.index) + const startBalance = await token.balanceOf(host.address) + await marketplace.fillSlot(slot.request, slot.index, proof) + const endBalance = await token.balanceOf(host.address) - const startBalance = await token.balanceOf(host.address) - const collateral = collateralPerSlot(request) - const discountedCollateral = - collateral - - Math.round( - (collateral * config.collateral.repairRewardPercentage) / 100 - ) - await token.approve(marketplace.address, discountedCollateral) - await marketplace.reserveSlot(slot.request, slot.index) - await marketplace.fillSlot(slot.request, slot.index, proof) - const endBalance = await token.balanceOf(host.address) - expect(startBalance - endBalance).to.equal(discountedCollateral) - expect(await marketplace.slotState(slotId(slot))).to.equal( - SlotState.Filled - ) + expect(startBalance - endBalance).to.equal(discountedCollateral) + }) + + it("tops up the host collateral with the repair reward", async function () { + const collateral = collateralPerSlot(request) + const reward = repairReward(config, collateral) + const discountedCollateral = collateral - reward + await token.approve(marketplace.address, discountedCollateral) + await marketplace.reserveSlot(slot.request, slot.index) + + const startBalance = await marketplace.getSlotBalance(slotId(slot)) + await marketplace.fillSlot(slot.request, slot.index, proof) + const endBalance = await marketplace.getSlotBalance(slotId(slot)) + + expect(endBalance - startBalance).to.equal(collateral) + }) }) it("fails to retrieve a request of an empty slot", async function () { @@ -884,8 +890,9 @@ describe("Marketplace", function () { const hostPayouts = payouts.reduce((a, b) => a + b, 0) const refund = payoutForDuration(request, freedAt, requestEnd) + const reward = repairReward(config, collateralPerSlot(request)) expect(endBalance - startBalance).to.equal( - maxPrice(request) - hostPayouts + refund + maxPrice(request) - hostPayouts + refund + reward ) }) }) diff --git a/test/collateral.js b/test/collateral.js index ce60bf1..99b0500 100644 --- a/test/collateral.js +++ b/test/collateral.js @@ -2,4 +2,9 @@ function collateralPerSlot(request) { return request.ask.collateralPerByte * request.ask.slotSize } -module.exports = { collateralPerSlot } +function repairReward(configuration, collateral) { + const percentage = configuration.collateral.repairRewardPercentage + return Math.round((collateral * percentage) / 100) +} + +module.exports = { collateralPerSlot, repairReward } From 17646f15b93c42622bc9a3870a6b85790ea5ab13 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Thu, 27 Feb 2025 09:39:13 +0100 Subject: [PATCH 15/38] marketplace: designate validator rewards so that they can no longer be transfered within the vault --- contracts/Marketplace.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 2e670f4..a640578 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -333,6 +333,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { AccountId hostAccount = _vault.hostAccount(slot.host, slot.slotIndex); AccountId validatorAccount = _vault.validatorAccount(msg.sender); _vault.transfer(fund, hostAccount, validatorAccount, validatorReward); + _vault.designate(fund, validatorAccount, validatorReward); _vault.burnDesignated(fund, hostAccount, slashedAmount - validatorReward); if (missingProofs(slotId) >= _config.collateral.maxNumberOfSlashes) { From e60ff3620205979fcb6e163f101152f6a6d514a3 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Thu, 27 Feb 2025 09:44:01 +0100 Subject: [PATCH 16/38] marketplace: formatting --- contracts/Requests.sol | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/contracts/Requests.sol b/contracts/Requests.sol index b347e9b..3a3af68 100644 --- a/contracts/Requests.sol +++ b/contracts/Requests.sol @@ -58,7 +58,9 @@ library AskHelpers { return TokensPerSecond.wrap(perByte * ask.slotSize); } - function pricePerSecond(Ask memory ask) internal pure returns (TokensPerSecond) { + function pricePerSecond( + Ask memory ask + ) internal pure returns (TokensPerSecond) { uint96 perSlot = TokensPerSecond.unwrap(ask.pricePerSlotPerSecond()); return TokensPerSecond.wrap(perSlot * ask.slots); } From 06a9e417b2ecc81a55f25f844f9688b622fa2e16 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Thu, 27 Feb 2025 09:57:33 +0100 Subject: [PATCH 17/38] marketplace: remove accounting that is now done by vault --- contracts/Marketplace.sol | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index a640578..8300814 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -493,33 +493,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { return _requestContexts[requestId].expiresAt; } - /** - * @notice Calculates the amount that should be paid out to a host that successfully finished the request - * @param requestId RequestId of the request used to calculate the payout - * amount. - * @param start timestamp indicating when a host filled a slot and - * started providing proofs. - */ - function _slotPayout( - RequestId requestId, - Timestamp start - ) private view returns (uint256) { - return _slotPayout(requestId, start, _requestContexts[requestId].endsAt); - } - - /// @notice Calculates the amount that should be paid out to a host based on the specified time frame. - function _slotPayout( - RequestId requestId, - Timestamp start, - Timestamp end - ) private view returns (uint256) { - Request storage request = _requests[requestId]; - if (end <= start) { - revert Marketplace_StartNotBeforeExpiry(); - } - return request.ask.pricePerSlotPerSecond().accumulate(start.until(end)); - } - function getHost(SlotId slotId) public view returns (address) { return _slots[slotId].host; } From 5fb63c493987cb42c4a6fac3fe154303a6076246 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Thu, 27 Feb 2025 10:01:45 +0100 Subject: [PATCH 18/38] marketplace: cleanup --- contracts/Requests.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/contracts/Requests.sol b/contracts/Requests.sol index 3a3af68..da13d6e 100644 --- a/contracts/Requests.sol +++ b/contracts/Requests.sol @@ -67,7 +67,6 @@ library AskHelpers { } library Requests { - using Tokens for TokensPerSecond; using AskHelpers for Ask; function id(Request memory request) internal pure returns (RequestId) { From 468bc2e8333e4104830004f7d0060da88fa2cb80 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Thu, 27 Feb 2025 10:45:39 +0100 Subject: [PATCH 19/38] marketplace: remove 'Paid' state This state is no longer necessary, vault ensures that payouts happen only once. Hosts could bypass this state anyway by withdrawing from the vault directly. --- contracts/Marketplace.sol | 8 -------- contracts/Requests.sol | 1 - test/Marketplace.test.js | 19 ++++++------------- test/requests.js | 5 ++--- 4 files changed, 8 insertions(+), 25 deletions(-) diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 8300814..cee8247 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -30,7 +30,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { error Marketplace_InvalidCid(); error Marketplace_SlotNotFree(); error Marketplace_InvalidSlotHost(); - error Marketplace_AlreadyPaid(); error Marketplace_UnknownRequest(); error Marketplace_InvalidState(); error Marketplace_StartNotBeforeExpiry(); @@ -257,8 +256,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { if (slot.host != msg.sender) revert Marketplace_InvalidSlotHost(); SlotState state = slotState(slotId); - if (state == SlotState.Paid) revert Marketplace_AlreadyPaid(); - if (state == SlotState.Finished) { _payoutSlot(slot.requestId, slotId); } else if (state == SlotState.Cancelled) { @@ -394,7 +391,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { _removeFromMyRequests(request.client, requestId); _removeFromMySlots(slot.host, slotId); - slot.state = SlotState.Paid; FundId fund = requestId.asFundId(); AccountId account = _vault.hostAccount(slot.host, slot.slotIndex); _vault.withdraw(fund, account); @@ -413,7 +409,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { ) private requestIsKnown(requestId) { Slot storage slot = _slots[slotId]; _removeFromMySlots(slot.host, slotId); - slot.state = SlotState.Paid; FundId fund = requestId.asFundId(); AccountId account = _vault.hostAccount(slot.host, slot.slotIndex); _vault.withdraw(fund, account); @@ -525,9 +520,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { return SlotState.Free; } RequestState reqState = requestState(slot.requestId); - if (slot.state == SlotState.Paid) { - return SlotState.Paid; - } if (reqState == RequestState.Cancelled) { return SlotState.Cancelled; } diff --git a/contracts/Requests.sol b/contracts/Requests.sol index da13d6e..0895b1f 100644 --- a/contracts/Requests.sol +++ b/contracts/Requests.sol @@ -43,7 +43,6 @@ enum SlotState { Filled, // host has filled slot Finished, // successfully completed Failed, // the request has failed - Paid, // host has been paid Cancelled, // when request was cancelled then slot is cancelled as well Repair // when slot slot was forcible freed (host was kicked out from hosting the slot because of too many missed proofs) and needs to be repaired } diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 7592285..21fc08d 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -669,13 +669,14 @@ describe("Marketplace", function () { expect(endBalance).to.equal(startBalance) }) - it("can only be done once", async function () { + it("pays only once", async function () { await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) await marketplace.freeSlot(slotId(slot)) - await expect(marketplace.freeSlot(slotId(slot))).to.be.revertedWith( - "Marketplace_AlreadyPaid" - ) + const startBalance = await token.balanceOf(host.address) + await marketplace.freeSlot(slotId(slot)) + const endBalance = await token.balanceOf(host.address) + expect(endBalance).to.equal(startBalance) }) it("cannot be filled again", async function () { @@ -968,8 +969,7 @@ describe("Marketplace", function () { }) describe("slot state", function () { - const { Free, Filled, Finished, Failed, Paid, Cancelled, Repair } = - SlotState + const { Free, Filled, Finished, Failed, Cancelled, Repair } = SlotState let period, periodEnd beforeEach(async function () { @@ -1042,13 +1042,6 @@ describe("Marketplace", function () { await waitUntilSlotFailed(marketplace, request, slot) expect(await marketplace.slotState(slotId(slot))).to.equal(Failed) }) - - it("changes to 'Paid' when host has been paid", async function () { - await waitUntilStarted(marketplace, request, proof, token) - await waitUntilFinished(marketplace, slot.request) - await marketplace.freeSlot(slotId(slot)) - expect(await marketplace.slotState(slotId(slot))).to.equal(Paid) - }) }) describe("slot probability", function () { diff --git a/test/requests.js b/test/requests.js index 16ad7a3..0088c9f 100644 --- a/test/requests.js +++ b/test/requests.js @@ -13,9 +13,8 @@ const SlotState = { Filled: 1, Finished: 2, Failed: 3, - Paid: 4, - Cancelled: 5, - Repair: 6, + Cancelled: 4, + Repair: 5, } function enableRequestAssertions() { From 52cf22789ca84246b60c946a8c1c462f8b5e93d0 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Thu, 6 Mar 2025 15:14:15 +0100 Subject: [PATCH 20/38] proofs: use Timestamp instead of uint64 --- certora/specs/Marketplace.spec | 2 +- contracts/Configuration.sol | 4 ++-- contracts/Periods.sol | 28 ++++++++++++++++++---------- contracts/Proofs.sol | 15 +++++++++------ 4 files changed, 30 insertions(+), 19 deletions(-) diff --git a/certora/specs/Marketplace.spec b/certora/specs/Marketplace.spec index 64ba147..48f3564 100644 --- a/certora/specs/Marketplace.spec +++ b/certora/specs/Marketplace.spec @@ -5,7 +5,7 @@ using ERC20A as Token; methods { function Token.balanceOf(address) external returns (uint256) envfree; function Token.totalSupply() external returns (uint256) envfree; - function publicPeriodEnd(Periods.Period) external returns (uint64) envfree; + function publicPeriodEnd(Periods.Period) external returns (Marketplace.Timestamp) envfree; function generateSlotId(Marketplace.RequestId, uint64) external returns (Marketplace.SlotId) envfree; } diff --git a/contracts/Configuration.sol b/contracts/Configuration.sol index dd70be4..10d697a 100644 --- a/contracts/Configuration.sol +++ b/contracts/Configuration.sol @@ -20,8 +20,8 @@ struct CollateralConfig { } struct ProofConfig { - uint64 period; // proofs requirements are calculated per period (in seconds) - uint64 timeout; // mark proofs as missing before the timeout (in seconds) + Duration period; // proofs requirements are calculated per period (in seconds) + Duration timeout; // mark proofs as missing before the timeout (in seconds) uint8 downtime; // ignore this much recent blocks for proof requirements // Ensures the pointer does not remain in downtime for many consecutive // periods. For each period increase, move the pointer `pointerProduct` diff --git a/contracts/Periods.sol b/contracts/Periods.sol index 5e18ee1..189fb02 100644 --- a/contracts/Periods.sol +++ b/contracts/Periods.sol @@ -1,37 +1,45 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; +import "./Timestamps.sol"; + contract Periods { error Periods_InvalidSecondsPerPeriod(); - type Period is uint64; + type Period is uint40; - uint64 internal immutable _secondsPerPeriod; + Duration internal immutable _secondsPerPeriod; - constructor(uint64 secondsPerPeriod) { - if (secondsPerPeriod == 0) { + constructor(Duration secondsPerPeriod) { + if (secondsPerPeriod == Duration.wrap(0)) { revert Periods_InvalidSecondsPerPeriod(); } _secondsPerPeriod = secondsPerPeriod; } - function _periodOf(uint64 timestamp) internal view returns (Period) { - return Period.wrap(timestamp / _secondsPerPeriod); + function _periodOf(Timestamp timestamp) internal view returns (Period) { + return + Period.wrap( + Timestamp.unwrap(timestamp) / Duration.unwrap(_secondsPerPeriod) + ); } function _blockPeriod() internal view returns (Period) { - return _periodOf(uint64(block.timestamp)); + return _periodOf(Timestamps.currentTime()); } function _nextPeriod(Period period) internal pure returns (Period) { return Period.wrap(Period.unwrap(period) + 1); } - function _periodStart(Period period) internal view returns (uint64) { - return Period.unwrap(period) * _secondsPerPeriod; + function _periodStart(Period period) internal view returns (Timestamp) { + return + Timestamp.wrap( + Period.unwrap(period) * Duration.unwrap(_secondsPerPeriod) + ); } - function _periodEnd(Period period) internal view returns (uint64) { + function _periodEnd(Period period) internal view returns (Timestamp) { return _periodStart(_nextPeriod(period)); } diff --git a/contracts/Proofs.sol b/contracts/Proofs.sol index 1dfc7ce..1037de4 100644 --- a/contracts/Proofs.sol +++ b/contracts/Proofs.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.28; import "./Configuration.sol"; import "./Requests.sol"; +import "./Timestamps.sol"; import "./Periods.sol"; import "./Groth16.sol"; @@ -11,6 +12,8 @@ import "./Groth16.sol"; * @notice Abstract contract that handles proofs tracking, validation and reporting functionality */ abstract contract Proofs is Periods { + using Timestamps for Timestamp; + error Proofs_InsufficientBlockHeight(); error Proofs_InvalidProof(); error Proofs_ProofAlreadySubmitted(); @@ -39,7 +42,7 @@ abstract contract Proofs is Periods { _verifier = verifier; } - mapping(SlotId => uint64) private _slotStarts; + mapping(SlotId => Timestamp) private _slotStarts; mapping(SlotId => uint64) private _missed; mapping(SlotId => mapping(Period => bool)) private _received; mapping(SlotId => mapping(Period => bool)) private _missing; @@ -73,7 +76,7 @@ abstract contract Proofs is Periods { * and saves the required probability. */ function _startRequiringProofs(SlotId id) internal { - _slotStarts[id] = uint64(block.timestamp); + _slotStarts[id] = Timestamps.currentTime(); } /** @@ -234,10 +237,10 @@ abstract contract Proofs is Periods { SlotId id, Period missedPeriod ) internal view { - uint256 end = _periodEnd(missedPeriod); - if (end >= block.timestamp) revert Proofs_PeriodNotEnded(); - if (block.timestamp >= end + _config.timeout) - revert Proofs_ValidationTimedOut(); + Timestamp end = _periodEnd(missedPeriod); + Timestamp current = Timestamps.currentTime(); + if (current <= end) revert Proofs_PeriodNotEnded(); + if (end.add(_config.timeout) <= current) revert Proofs_ValidationTimedOut(); if (_received[id][missedPeriod]) revert Proofs_ProofNotMissing(); if (!_isProofRequired(id, missedPeriod)) revert Proofs_ProofNotRequired(); if (_missing[id][missedPeriod]) revert Proofs_ProofAlreadyMarkedMissing(); From 2d21d65624c4e1c0cc9af8db282967313fbda762 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Thu, 6 Mar 2025 10:21:06 +0100 Subject: [PATCH 21/38] certora: update marketplace spec now that we have vault - changes to marketplace constructor - we no longer have _marketplaceTotals - timestamps have their own type now - freeSlot no longer takes payout addresses - slot state 'Paid' no longer exists - freeSlot can be invoked more than once now - a failed request no longer ends immediately --- certora/confs/Marketplace.conf | 6 +- certora/harness/MarketplaceHarness.sol | 9 +- certora/specs/Marketplace.spec | 115 +++---------------------- 3 files changed, 18 insertions(+), 112 deletions(-) diff --git a/certora/confs/Marketplace.conf b/certora/confs/Marketplace.conf index 2dc6fe4..395ebae 100644 --- a/certora/confs/Marketplace.conf +++ b/certora/confs/Marketplace.conf @@ -2,12 +2,14 @@ "files": [ "certora/harness/MarketplaceHarness.sol", "contracts/Marketplace.sol", + "contracts/Vault.sol", "contracts/Groth16Verifier.sol", "certora/helpers/ERC20A.sol", ], "parametric_contracts": ["MarketplaceHarness"], "link" : [ - "MarketplaceHarness:_token=ERC20A", + "Vault:_token=ERC20A", + "MarketplaceHarness:_vault=Vault", "MarketplaceHarness:_verifier=Groth16Verifier" ], "msg": "Verifying MarketplaceHarness", @@ -18,5 +20,3 @@ "optimistic_hashing": true, "hashing_length_bound": "512", } - - diff --git a/certora/harness/MarketplaceHarness.sol b/certora/harness/MarketplaceHarness.sol index c3119c9..fddac3e 100644 --- a/certora/harness/MarketplaceHarness.sol +++ b/certora/harness/MarketplaceHarness.sol @@ -2,19 +2,20 @@ pragma solidity ^0.8.28; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Vault} from "../../contracts/Vault.sol"; import {IGroth16Verifier} from "../../contracts/Groth16.sol"; import {MarketplaceConfig} from "../../contracts/Configuration.sol"; import {Marketplace} from "../../contracts/Marketplace.sol"; import {RequestId, SlotId} from "../../contracts/Requests.sol"; import {Requests} from "../../contracts/Requests.sol"; +import {Timestamp} from "../../contracts/Timestamps.sol"; contract MarketplaceHarness is Marketplace { - constructor(MarketplaceConfig memory config, IERC20 token, IGroth16Verifier verifier) - Marketplace(config, token, verifier) + constructor(MarketplaceConfig memory config, Vault vault, IGroth16Verifier verifier) + Marketplace(config, vault, verifier) {} - function publicPeriodEnd(Period period) public view returns (uint64) { + function publicPeriodEnd(Period period) public view returns (Timestamp) { return _periodEnd(period); } diff --git a/certora/specs/Marketplace.spec b/certora/specs/Marketplace.spec index 48f3564..e849a64 100644 --- a/certora/specs/Marketplace.spec +++ b/certora/specs/Marketplace.spec @@ -25,32 +25,12 @@ hook Sstore Token._balances[KEY address addr] uint256 newValue (uint256 oldValue sumOfBalances = sumOfBalances - oldValue + newValue; } -ghost mathint totalReceived; - -hook Sload uint256 defaultValue currentContract._marketplaceTotals.received { - require totalReceived >= to_mathint(defaultValue); -} - -hook Sstore currentContract._marketplaceTotals.received uint256 defaultValue (uint256 defaultValue_old) { - totalReceived = totalReceived + defaultValue - defaultValue_old; -} - -ghost mathint totalSent; - -hook Sload uint256 defaultValue currentContract._marketplaceTotals.sent { - require totalSent >= to_mathint(defaultValue); -} - -hook Sstore currentContract._marketplaceTotals.sent uint256 defaultValue (uint256 defaultValue_old) { - totalSent = totalSent + defaultValue - defaultValue_old; -} - -ghost uint64 lastBlockTimestampGhost; +ghost Marketplace.Timestamp lastBlockTimestampGhost; hook TIMESTAMP uint v { - require v < max_uint64; - require lastBlockTimestampGhost <= assert_uint64(v); - lastBlockTimestampGhost = assert_uint64(v); + require v < max_uint40; + require lastBlockTimestampGhost <= assert_uint40(v); + lastBlockTimestampGhost = assert_uint40(v); } ghost mapping(MarketplaceHarness.SlotId => mapping(Periods.Period => bool)) _missingMirror { @@ -121,13 +101,13 @@ hook Sstore _requestContexts[KEY MarketplaceHarness.RequestId RequestId].slotsFi slotsFilledGhost[RequestId] = defaultValue; } -ghost mapping(MarketplaceHarness.RequestId => uint64) endsAtGhost; +ghost mapping(MarketplaceHarness.RequestId => Marketplace.Timestamp) endsAtGhost; -hook Sload uint64 defaultValue _requestContexts[KEY MarketplaceHarness.RequestId RequestId].endsAt { +hook Sload Marketplace.Timestamp defaultValue _requestContexts[KEY MarketplaceHarness.RequestId RequestId].endsAt { require endsAtGhost[RequestId] == defaultValue; } -hook Sstore _requestContexts[KEY MarketplaceHarness.RequestId RequestId].endsAt uint64 defaultValue { +hook Sstore _requestContexts[KEY MarketplaceHarness.RequestId RequestId].endsAt Marketplace.Timestamp defaultValue { endsAtGhost[RequestId] = defaultValue; } @@ -144,18 +124,11 @@ function canStartRequest(method f) returns bool { } function canFinishRequest(method f) returns bool { - return f.selector == sig:freeSlot(Marketplace.SlotId, address, address).selector || - f.selector == sig:freeSlot(Marketplace.SlotId).selector; + return f.selector == sig:freeSlot(Marketplace.SlotId).selector; } function canFailRequest(method f) returns bool { return f.selector == sig:markProofAsMissing(Marketplace.SlotId, Periods.Period).selector || - f.selector == sig:freeSlot(Marketplace.SlotId, address, address).selector || - f.selector == sig:freeSlot(Marketplace.SlotId).selector; -} - -function canMakeSlotPaid(method f) returns bool { - return f.selector == sig:freeSlot(Marketplace.SlotId, address, address).selector || f.selector == sig:freeSlot(Marketplace.SlotId).selector; } @@ -198,25 +171,6 @@ invariant cancelledRequestAlwaysExpired(env e, Marketplace.RequestId requestId) currentContract.requestState(e, requestId) == Marketplace.RequestState.Cancelled => currentContract.requestExpiry(e, requestId) < lastBlockTimestampGhost; -// STATUS - verified -// failed request is always ended -// https://prover.certora.com/output/6199/3c5e57311e474f26aa7d9e9481c5880a?anonymousKey=36e39932ee488bb35fe23e38d8d4091190e047af -invariant failedRequestAlwaysEnded(env e, Marketplace.RequestId requestId) - currentContract.requestState(e, requestId) == Marketplace.RequestState.Failed => - endsAtGhost[requestId] < lastBlockTimestampGhost; - -// STATUS - verified -// paid slot always has finished or cancelled request -// https://prover.certora.com/output/6199/d0e165ed5d594f9fb477602af06cfeb1?anonymousKey=01ffaad46027786c38d78e5a41c03ce002032200 -invariant paidSlotAlwaysHasFinishedOrCancelledRequest(env e, Marketplace.SlotId slotId) - currentContract.slotState(e, slotId) == Marketplace.SlotState.Paid => - currentContract.requestState(e, slotIdToRequestId[slotId]) == Marketplace.RequestState.Finished || currentContract.requestState(e, slotIdToRequestId[slotId]) == Marketplace.RequestState.Cancelled - { preserved { - requireInvariant cancelledSlotAlwaysHasCancelledRequest(e, slotId); - } - } - - /*-------------------------------------------- | Properties | --------------------------------------------*/ @@ -228,35 +182,11 @@ rule sanity(env e, method f) { satisfy true; } -rule totalReceivedCannotDecrease(env e, method f) { - mathint total_before = totalReceived; - - calldataarg args; - f(e, args); - - mathint total_after = totalReceived; - - assert total_after >= total_before; -} - -rule totalSentCannotDecrease(env e, method f) { - mathint total_before = totalSent; - - calldataarg args; - f(e, args); - - mathint total_after = totalSent; - - assert total_after >= total_before; -} - // https://prover.certora.com/output/6199/0b56a7cdb3f9466db08f2a4677eddaac?anonymousKey=351ce9d5561f6c2aff1b38942e307735428bb83f rule slotIsFailedOrFreeIfRequestHasFailed(env e, method f) { calldataarg args; Marketplace.SlotId slotId; - requireInvariant paidSlotAlwaysHasFinishedOrCancelledRequest(e, slotId); - Marketplace.RequestState requestStateBefore = currentContract.requestState(e, slotIdToRequestId[slotId]); f(e, args); Marketplace.RequestState requestAfter = currentContract.requestState(e, slotIdToRequestId[slotId]); @@ -298,9 +228,6 @@ rule functionsCausingSlotStateChanges(env e, method f) { f(e, args); Marketplace.SlotState slotStateAfter = currentContract.slotState(e, slotId); - // SlotState.Finished -> SlotState.Paid - assert slotStateBefore == Marketplace.SlotState.Finished && slotStateAfter == Marketplace.SlotState.Paid => canMakeSlotPaid(f); - // SlotState.Free -> SlotState.Filled assert (slotStateBefore == Marketplace.SlotState.Free || slotStateBefore == Marketplace.SlotState.Repair) && slotStateAfter == Marketplace.SlotState.Filled => canFillSlot(f); } @@ -316,15 +243,11 @@ rule allowedSlotStateChanges(env e, method f) { f(e, args); Marketplace.SlotState slotStateAfter = currentContract.slotState(e, slotId); - // Cannot change from Paid - assert slotStateBefore == Marketplace.SlotState.Paid => slotStateAfter == Marketplace.SlotState.Paid; - - // SlotState.Cancelled -> SlotState.Cancelled || SlotState.Failed || Finished || Paid + // SlotState.Cancelled -> SlotState.Cancelled || SlotState.Failed || Finished assert slotStateBefore == Marketplace.SlotState.Cancelled => ( slotStateAfter == Marketplace.SlotState.Cancelled || slotStateAfter == Marketplace.SlotState.Failed || - slotStateAfter == Marketplace.SlotState.Finished || - slotStateAfter == Marketplace.SlotState.Paid + slotStateAfter == Marketplace.SlotState.Finished ); // SlotState.Filled only from Free or Repair @@ -385,21 +308,3 @@ rule slotStateChangesOnlyOncePerFunctionCall(env e, method f) { assert slotStateChangesCountAfter <= slotStateChangesCountBefore + 1; } - -rule slotCanBeFreedAndPaidOnce { - env e; - Marketplace.SlotId slotId; - address rewardRecipient; - address collateralRecipient; - - Marketplace.SlotState slotStateBefore = currentContract.slotState(e, slotId); - require slotStateBefore != Marketplace.SlotState.Paid; - freeSlot(e, slotId, rewardRecipient, collateralRecipient); - - Marketplace.SlotState slotStateAfter = currentContract.slotState(e, slotId); - require slotStateAfter == Marketplace.SlotState.Paid; - - freeSlot@withrevert(e, slotId, rewardRecipient, collateralRecipient); - - assert lastReverted; -} From 51862f67e995310cb4ba8b6a382896be537ca7e2 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Thu, 6 Mar 2025 10:22:46 +0100 Subject: [PATCH 22/38] certora: remove check on ERC20 token No need to test the token itself --- certora/specs/Marketplace.spec | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/certora/specs/Marketplace.spec b/certora/specs/Marketplace.spec index e849a64..a420003 100644 --- a/certora/specs/Marketplace.spec +++ b/certora/specs/Marketplace.spec @@ -3,8 +3,6 @@ import "./shared.spec"; using ERC20A as Token; methods { - function Token.balanceOf(address) external returns (uint256) envfree; - function Token.totalSupply() external returns (uint256) envfree; function publicPeriodEnd(Periods.Period) external returns (Marketplace.Timestamp) envfree; function generateSlotId(Marketplace.RequestId, uint64) external returns (Marketplace.SlotId) envfree; } @@ -13,18 +11,6 @@ methods { | Ghosts and hooks | --------------------------------------------*/ -ghost mathint sumOfBalances { - init_state axiom sumOfBalances == 0; -} - -hook Sload uint256 balance Token._balances[KEY address addr] { - require sumOfBalances >= to_mathint(balance); -} - -hook Sstore Token._balances[KEY address addr] uint256 newValue (uint256 oldValue) { - sumOfBalances = sumOfBalances - oldValue + newValue; -} - ghost Marketplace.Timestamp lastBlockTimestampGhost; hook TIMESTAMP uint v { @@ -147,9 +133,6 @@ function slotAttributesAreConsistent(env e, Marketplace.SlotId slotId) { | Invariants | --------------------------------------------*/ -invariant totalSupplyIsSumOfBalances() - to_mathint(Token.totalSupply()) == sumOfBalances; - invariant requestStartedWhenSlotsFilled(env e, Marketplace.RequestId requestId, Marketplace.SlotId slotId) currentContract.requestState(e, requestId) == Marketplace.RequestState.Started => to_mathint(currentContract.getRequest(e, requestId).ask.slots) - slotsFilledGhost[requestId] <= to_mathint(currentContract.getRequest(e, requestId).ask.maxSlotLoss); From e4348de891f06e1c50a0cb9eb06da138ba98e1d3 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Thu, 6 Mar 2025 14:17:47 +0100 Subject: [PATCH 23/38] certora: update state changes spec now that we have vault --- certora/confs/StateChanges.conf | 4 +++- certora/specs/StateChanges.spec | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/certora/confs/StateChanges.conf b/certora/confs/StateChanges.conf index 11c5536..5bd250a 100644 --- a/certora/confs/StateChanges.conf +++ b/certora/confs/StateChanges.conf @@ -2,12 +2,14 @@ "files": [ "certora/harness/MarketplaceHarness.sol", "contracts/Marketplace.sol", + "contracts/Vault.sol", "contracts/Groth16Verifier.sol", "certora/helpers/ERC20A.sol", ], "parametric_contracts": ["MarketplaceHarness"], "link" : [ - "MarketplaceHarness:_token=ERC20A", + "Vault:_token=ERC20A", + "MarketplaceHarness:_vault=Vault", "MarketplaceHarness:_verifier=Groth16Verifier" ], "msg": "Verifying StateChanges", diff --git a/certora/specs/StateChanges.spec b/certora/specs/StateChanges.spec index 82cb5d9..830c3ef 100644 --- a/certora/specs/StateChanges.spec +++ b/certora/specs/StateChanges.spec @@ -20,7 +20,7 @@ rule allowedRequestStateChanges(env e, method f) { // we need to check for `freeSlot(slotId)` here to ensure it's being called with // the slotId we're interested in and not any other slotId (that may not pass the // required invariants) - if (f.selector == sig:freeSlot(Marketplace.SlotId).selector || f.selector == sig:freeSlot(Marketplace.SlotId, address, address).selector) { + if (f.selector == sig:freeSlot(Marketplace.SlotId).selector) { freeSlot(e, slotId); } else { f(e, args); From 6bd414471431daad5f1fe0039458ad018295cd63 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Wed, 12 Mar 2025 10:52:11 +0100 Subject: [PATCH 24/38] marketplace: repair reward is paid out at the end It is no longer a discount on the collateral, to simplify reasoning about collateral in the sales module of the codex node --- contracts/Marketplace.sol | 2 -- test/Marketplace.test.js | 18 ++---------------- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index cee8247..ceaf09d 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -208,10 +208,8 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { uint128 designated = _config.collateral.designatedCollateral(collateral); if (slotState(slotId) == SlotState.Repair) { - // host gets a discount on its collateral, paid for by the repair reward uint128 repairReward = _config.collateral.repairReward(collateral); _vault.transfer(fund, clientAccount, hostAccount, repairReward); - collateral -= repairReward; } _transferToVault(slot.host, fund, hostAccount, collateral); diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 21fc08d..4f394bf 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -304,31 +304,17 @@ describe("Marketplace", function () { await marketplace.freeSlot(slotId(slot)) }) - it("gives the host a discount on the collateral", async function () { - const collateral = collateralPerSlot(request) - const reward = repairReward(config, collateral) - const discountedCollateral = collateral - reward - await token.approve(marketplace.address, discountedCollateral) - await marketplace.reserveSlot(slot.request, slot.index) - const startBalance = await token.balanceOf(host.address) - await marketplace.fillSlot(slot.request, slot.index, proof) - const endBalance = await token.balanceOf(host.address) - - expect(startBalance - endBalance).to.equal(discountedCollateral) - }) - it("tops up the host collateral with the repair reward", async function () { const collateral = collateralPerSlot(request) const reward = repairReward(config, collateral) - const discountedCollateral = collateral - reward - await token.approve(marketplace.address, discountedCollateral) + await token.approve(marketplace.address, collateral) await marketplace.reserveSlot(slot.request, slot.index) const startBalance = await marketplace.getSlotBalance(slotId(slot)) await marketplace.fillSlot(slot.request, slot.index, proof) const endBalance = await marketplace.getSlotBalance(slotId(slot)) - expect(endBalance - startBalance).to.equal(collateral) + expect(endBalance - startBalance).to.equal(collateral + reward) }) }) From 6971766b627ca4c1a639e433f43df4fcb15f9747 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Tue, 25 Mar 2025 09:51:53 +0100 Subject: [PATCH 25/38] marketplace: cleanup tests --- test/Marketplace.test.js | 68 +++++++++++++++------------------------- 1 file changed, 26 insertions(+), 42 deletions(-) diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 4f394bf..fac7f66 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -273,7 +273,7 @@ describe("Marketplace", function () { }) }) - describe("filling a slot with collateral", function () { + describe("filling a slot", function () { beforeEach(async function () { switchAccount(client) await token.approve(marketplace.address, maxPrice(request)) @@ -296,6 +296,15 @@ describe("Marketplace", function () { expect(await marketplace.getHost(slotId(slot))).to.equal(host.address) }) + it("collects only requested collateral and not more", async function () { + await token.approve(marketplace.address, collateralPerSlot(request) * 2) + const startBalance = await token.balanceOf(host.address) + await marketplace.reserveSlot(slot.request, slot.index) + await marketplace.fillSlot(slot.request, slot.index, proof) + const endBalance = await token.balanceOf(host.address) + expect(startBalance - endBalance).to.eq(collateralPerSlot(request)) + }) + describe("when repairing a slot", function () { beforeEach(async function () { await marketplace.reserveSlot(slot.request, slot.index) @@ -411,17 +420,8 @@ describe("Marketplace", function () { marketplace.fillSlot(slot.request, slot.index, proof) ).to.be.revertedWith("Marketplace_ReservationRequired") }) - }) - describe("filling slot without collateral", function () { - beforeEach(async function () { - switchAccount(client) - await token.approve(marketplace.address, maxPrice(request)) - await marketplace.requestStorage(request) - switchAccount(host) - }) - - it("is rejected when approved collateral is insufficient", async function () { + it("fails when approved collateral is insufficient", async function () { let insufficient = collateralPerSlot(request) - 1 await token.approve(marketplace.address, insufficient) await marketplace.reserveSlot(slot.request, slot.index) @@ -429,15 +429,6 @@ describe("Marketplace", function () { marketplace.fillSlot(slot.request, slot.index, proof) ).to.be.revertedWith("ERC20InsufficientAllowance") }) - - it("collects only requested collateral and not more", async function () { - await token.approve(marketplace.address, collateralPerSlot(request) * 2) - const startBalance = await token.balanceOf(host.address) - await marketplace.reserveSlot(slot.request, slot.index) - await marketplace.fillSlot(slot.request, slot.index, proof) - const endBalance = await token.balanceOf(host.address) - expect(startBalance - endBalance).to.eq(collateralPerSlot(request)) - }) }) describe("submitting proofs when slot is filled", function () { @@ -1177,38 +1168,34 @@ describe("Marketplace", function () { }) describe("slashing when missing proofs", function () { - it("reduces collateral when a proof is missing", async function () { - const id = slotId(slot) - const { slashPercentage } = config.collateral + const { slashPercentage, validatorRewardPercentage } = config.collateral + let id + let missedPeriod + let collateral + let slashAmount + + beforeEach(async function () { + collateral = collateralPerSlot(request) + slashAmount = Math.round((collateral * slashPercentage) / 100) + id = slotId(slot) await marketplace.reserveSlot(slot.request, slot.index) await marketplace.fillSlot(slot.request, slot.index, proof) - await waitUntilProofIsRequired(id) - let missedPeriod = periodOf(await currentTime()) + missedPeriod = periodOf(await currentTime()) await advanceTime(period + 1) + }) + it("reduces balance when a proof is missing", async function () { const startBalance = await marketplace.getSlotBalance(id) await setNextBlockTimestamp(await currentTime()) await marketplace.markProofAsMissing(id, missedPeriod) const endBalance = await marketplace.getSlotBalance(id) + expect(endBalance).to.equal(startBalance - slashAmount) - const collateral = collateralPerSlot(request) - const expectedSlash = Math.round((collateral * slashPercentage) / 100) - - expect(endBalance).to.equal(startBalance - expectedSlash) }) it("rewards validator when marking proof as missing", async function () { - const id = slotId(slot) - const { slashPercentage, validatorRewardPercentage } = config.collateral - await marketplace.reserveSlot(slot.request, slot.index) - await marketplace.fillSlot(slot.request, slot.index, proof) - switchAccount(validator) - - await waitUntilProofIsRequired(id) - let missedPeriod = periodOf(await currentTime()) - await advanceTime(period + 1) await marketplace.markProofAsMissing(id, missedPeriod) const startBalance = await token.balanceOf(validator.address) @@ -1216,11 +1203,8 @@ describe("Marketplace", function () { await marketplace.withdrawByValidator(slot.request) const endBalance = await token.balanceOf(validator.address) - const collateral = collateralPerSlot(request) - const slashedAmount = (collateral * slashPercentage) / 100 - const expectedReward = Math.round( - (slashedAmount * validatorRewardPercentage) / 100 + (slashAmount * validatorRewardPercentage) / 100 ) expect(endBalance.toNumber()).to.equal( From 7a566182d5facb53fb2bdc4d6a3f8e5d990abe1e Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Tue, 25 Mar 2025 09:51:28 +0100 Subject: [PATCH 26/38] marketplace: re-instate currentCollateral() So that a storage provider can know how much collateral is returned when it calls freeSlot() --- contracts/Marketplace.sol | 13 ++++++++++++- test/Marketplace.test.js | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index ceaf09d..4f3fe34 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -67,9 +67,10 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { } struct Slot { - SlotState state; RequestId requestId; uint64 slotIndex; + uint128 currentCollateral; + SlotState state; address host; } @@ -100,6 +101,10 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { return _vault; } + function currentCollateral(SlotId slotId) public view returns (uint128) { + return _slots[slotId].currentCollateral; + } + function requestStorage(Request calldata request) public { RequestId id = request.id(); @@ -216,6 +221,8 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { _vault.designate(fund, hostAccount, designated); _vault.flow(fund, clientAccount, hostAccount, rate); + slot.currentCollateral = collateral; + _addToMySlots(slot.host, slotId); slot.state = SlotState.Filled; @@ -331,6 +338,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { _vault.designate(fund, validatorAccount, validatorReward); _vault.burnDesignated(fund, hostAccount, slashedAmount - validatorReward); + slot.currentCollateral -= slashedAmount; if (missingProofs(slotId) >= _config.collateral.maxNumberOfSlashes) { // When the number of slashings is at or above the allowed amount, // free the slot. @@ -360,6 +368,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { _removeFromMySlots(slot.host, slotId); _reservations[slotId].clear(); // We purge all the reservations for the slot slot.state = SlotState.Repair; + slot.currentCollateral = 0; slot.host = address(0); context.slotsFilled -= 1; emit SlotFreed(requestId, slot.slotIndex); @@ -385,6 +394,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { Request storage request = _requests[requestId]; context.state = RequestState.Finished; Slot storage slot = _slots[slotId]; + slot.currentCollateral = 0; _removeFromMyRequests(request.client, requestId); _removeFromMySlots(slot.host, slotId); @@ -406,6 +416,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { SlotId slotId ) private requestIsKnown(requestId) { Slot storage slot = _slots[slotId]; + slot.currentCollateral = 0; _removeFromMySlots(slot.host, slotId); FundId fund = requestId.asFundId(); AccountId account = _vault.hostAccount(slot.host, slot.slotIndex); diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index fac7f66..77595a9 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -327,6 +327,13 @@ describe("Marketplace", function () { }) }) + it("updates the slot's current collateral", async function () { + await marketplace.reserveSlot(slot.request, slot.index) + await marketplace.fillSlot(slot.request, slot.index, proof) + const collateral = await marketplace.currentCollateral(slotId(slot)) + expect(collateral).to.equal(collateralPerSlot(request)) + }) + it("fails to retrieve a request of an empty slot", async function () { expect(marketplace.getActiveSlot(slotId(slot))).to.be.revertedWith( "Marketplace_SlotIsFree" @@ -583,6 +590,12 @@ describe("Marketplace", function () { await token.approve(marketplace.address, collateral) await marketplace.fillSlot(slot.request, slot.index, proof) }) + + it("updates the slot's current collateral", async function () { + await waitUntilStarted(marketplace, request, proof, token) + await marketplace.freeSlot(id) + expect(await marketplace.currentCollateral(id)).to.equal(0) + }) }) describe("paying out a slot", function () { @@ -637,6 +650,21 @@ describe("Marketplace", function () { expect(endBalance - startBalance).to.be.equal(expectedPartialPayout) }) + it("updates the collateral when freeing a finished slot", async function () { + await waitUntilStarted(marketplace, request, proof, token) + await waitUntilFinished(marketplace, requestId(request)) + await marketplace.freeSlot(slotId(slot)) + expect(await marketplace.currentCollateral(slotId(slot))).to.equal(0) + }) + + it("updates the collateral when freeing a cancelled slot", async function () { + await marketplace.reserveSlot(slot.request, slot.index) + await marketplace.fillSlot(slot.request, slot.index, proof) + await waitUntilCancelled(marketplace, request) + await marketplace.freeSlot(slotId(slot)) + expect(await marketplace.currentCollateral(slotId(slot))).to.equal(0) + }) + it("does not pay when the contract hasn't ended", async function () { await marketplace.reserveSlot(slot.request, slot.index) await marketplace.fillSlot(slot.request, slot.index, proof) @@ -1191,7 +1219,13 @@ describe("Marketplace", function () { await marketplace.markProofAsMissing(id, missedPeriod) const endBalance = await marketplace.getSlotBalance(id) expect(endBalance).to.equal(startBalance - slashAmount) + }) + it("updates the slot's current collateral", async function () { + await setNextBlockTimestamp(await currentTime()) + await marketplace.markProofAsMissing(id, missedPeriod) + const currentCollateral = await marketplace.currentCollateral(id) + expect(currentCollateral).to.equal(collateral - slashAmount) }) it("rewards validator when marking proof as missing", async function () { @@ -1234,6 +1268,11 @@ describe("Marketplace", function () { expect(await marketplace.getSlotBalance(slotId(slot))).to.equal(0) }) + it("updates the slot's current collateral", async function () { + const collateral = await marketplace.currentCollateral(slotId(slot)) + expect(collateral).to.equal(0) + }) + it("resets missed proof counter", async function () { expect(await marketplace.missingProofs(slotId(slot))).to.equal(0) }) From 9603025202cbe8a6f2cec2aea1e7a9764dc38746 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Wed, 16 Apr 2025 13:50:46 +0200 Subject: [PATCH 27/38] marketplace: simplify requestEnd() --- contracts/Marketplace.sol | 11 +---------- contracts/Timestamps.sol | 12 ------------ 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 4f3fe34..1f84b30 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -478,19 +478,10 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { function requestEnd(RequestId requestId) public view returns (Timestamp) { RequestState state = requestState(requestId); - if ( - state == RequestState.New || - state == RequestState.Started || - state == RequestState.Failed - ) { - return _requestContexts[requestId].endsAt; - } if (state == RequestState.Cancelled) { return _requestContexts[requestId].expiresAt; } - Timestamp currentTime = Timestamps.currentTime(); - Timestamp end = _requestContexts[requestId].endsAt; - return Timestamps.earliest(end, currentTime); + return _requestContexts[requestId].endsAt; } function requestExpiry(RequestId requestId) public view returns (Timestamp) { diff --git a/contracts/Timestamps.sol b/contracts/Timestamps.sol index 665eea5..945eced 100644 --- a/contracts/Timestamps.sol +++ b/contracts/Timestamps.sol @@ -67,16 +67,4 @@ library Timestamps { ) internal pure returns (Duration) { return Duration.wrap(Timestamp.unwrap(end) - Timestamp.unwrap(start)); } - - /// Returns the earliest of the two timestamps - function earliest( - Timestamp a, - Timestamp b - ) internal pure returns (Timestamp) { - if (a <= b) { - return a; - } else { - return b; - } - } } From 47f3c1e36d442e30146dc59903a364677054b585 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Tue, 27 May 2025 10:44:34 +0200 Subject: [PATCH 28/38] marketplace: keep request in myRequests when freeing slot Because a client might still need to call withdraw() --- contracts/Marketplace.sol | 27 +++++---------------------- test/Marketplace.test.js | 10 ---------- 2 files changed, 5 insertions(+), 32 deletions(-) diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 1f84b30..d00cf7c 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -261,10 +261,11 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { if (slot.host != msg.sender) revert Marketplace_InvalidSlotHost(); SlotState state = slotState(slotId); - if (state == SlotState.Finished) { + if ( + state == SlotState.Finished || + state == SlotState.Cancelled + ) { _payoutSlot(slot.requestId, slotId); - } else if (state == SlotState.Cancelled) { - _payoutCancelledSlot(slot.requestId, slotId); } else if (state == SlotState.Failed) { _removeFromMySlots(msg.sender, slotId); } else if (state == SlotState.Filled) { @@ -386,24 +387,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { } } - function _payoutSlot( - RequestId requestId, - SlotId slotId - ) private requestIsKnown(requestId) { - RequestContext storage context = _requestContexts[requestId]; - Request storage request = _requests[requestId]; - context.state = RequestState.Finished; - Slot storage slot = _slots[slotId]; - slot.currentCollateral = 0; - - _removeFromMyRequests(request.client, requestId); - _removeFromMySlots(slot.host, slotId); - - FundId fund = requestId.asFundId(); - AccountId account = _vault.hostAccount(slot.host, slot.slotIndex); - _vault.withdraw(fund, account); - } - /** * @notice Pays out a host for duration of time that the slot was filled, and returns the collateral. @@ -411,7 +394,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { out. * @param slotId SlotId of the slot to be paid out. */ - function _payoutCancelledSlot( + function _payoutSlot( RequestId requestId, SlotId slotId ) private requestIsKnown(requestId) { diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 77595a9..597a4da 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -1314,16 +1314,6 @@ describe("Marketplace", function () { switchAccount(client) expect(await marketplace.myRequests()).to.deep.equal([requestId(request)]) }) - - it("removes request from list when request finishes", async function () { - await marketplace.requestStorage(request) - switchAccount(host) - await waitUntilStarted(marketplace, request, proof, token) - await waitUntilFinished(marketplace, requestId(request)) - await marketplace.freeSlot(slotId(slot)) - switchAccount(client) - expect(await marketplace.myRequests()).to.deep.equal([]) - }) }) describe("list of active slots", function () { From 2cd45214cf88e3c4ea21a162bd332dbdf74536c1 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Tue, 27 May 2025 10:52:21 +0200 Subject: [PATCH 29/38] marketplace: no longer expose forciblyFreeSlot in tests reason: freeSlot() will call forciblyFreeSlot when contract is not finished, so we can use that instead --- contracts/TestMarketplace.sol | 4 ---- test/Marketplace.test.js | 2 +- test/marketplace.js | 4 ++-- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/contracts/TestMarketplace.sol b/contracts/TestMarketplace.sol index 864fe6d..fe7a95f 100644 --- a/contracts/TestMarketplace.sol +++ b/contracts/TestMarketplace.sol @@ -14,10 +14,6 @@ contract TestMarketplace is Marketplace { IGroth16Verifier verifier ) Marketplace(config, vault, verifier) {} - function forciblyFreeSlot(SlotId slotId) public { - _forciblyFreeSlot(slotId); - } - function getSlotBalance(SlotId slotId) public view returns (uint256) { Slot storage slot = _slots[slotId]; FundId fund = slot.requestId.asFundId(); diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 597a4da..d72f549 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -954,7 +954,7 @@ describe("Marketplace", function () { for (let i = 0; i <= request.ask.maxSlotLoss; i++) { slot.index = i let id = slotId(slot) - await marketplace.forciblyFreeSlot(id) + await marketplace.freeSlot(id) } expect(await marketplace.requestState(slot.request)).to.equal(New) }) diff --git a/test/marketplace.js b/test/marketplace.js index 82f6cfc..9cf0041 100644 --- a/test/marketplace.js +++ b/test/marketplace.js @@ -50,7 +50,7 @@ async function waitUntilFailed(contract, request) { for (let i = 0; i <= request.ask.maxSlotLoss; i++) { slot.index = i let id = slotId(slot) - await contract.forciblyFreeSlot(id) + await contract.freeSlot(id) } } @@ -59,7 +59,7 @@ async function waitUntilSlotFailed(contract, request, slot) { let freed = 0 while (freed <= request.ask.maxSlotLoss) { if (index !== slot.index) { - await contract.forciblyFreeSlot(slotId({ ...slot, index })) + await contract.freeSlot(slotId({ ...slot, index })) freed++ } index++ From 724344cb5eb99d176b69f1c46d8c36ce3ec6c462 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Tue, 27 May 2025 11:02:11 +0200 Subject: [PATCH 30/38] marketplace: allow slot payout for failed requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reason: hosts are paid for the time that they hosted the slot up until the time that the request failed Co-Authored-By: Adam Uhlíř --- contracts/Marketplace.sol | 5 ++--- test/Marketplace.test.js | 46 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index d00cf7c..89d4137 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -263,11 +263,10 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { SlotState state = slotState(slotId); if ( state == SlotState.Finished || - state == SlotState.Cancelled + state == SlotState.Cancelled || + state == SlotState.Failed ) { _payoutSlot(slot.requestId, slotId); - } else if (state == SlotState.Failed) { - _removeFromMySlots(msg.sender, slotId); } else if (state == SlotState.Filled) { // free slot without returning collateral, effectively a 100% slash _forciblyFreeSlot(slotId); diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index d72f549..da5613e 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -650,6 +650,27 @@ describe("Marketplace", function () { expect(endBalance - startBalance).to.be.equal(expectedPartialPayout) }) + it("pays the host when contract fails and then finishes", async function () { + await advanceTime(10) + await waitUntilStarted(marketplace, request, proof, token) + const filledAt = await currentTime() + + await advanceTime(10) + await waitUntilSlotFailed(marketplace, request, slot) + const failedAt = await currentTime() + + await advanceTime(10) + await waitUntilFinished(marketplace, requestId(request)) + + const startBalance = await token.balanceOf(host.address) + await marketplace.freeSlot(slotId(slot)) + const endBalance = await token.balanceOf(host.address) + + const payout = (failedAt - filledAt) * pricePerSlotPerSecond(request) + const collateral = collateralPerSlot(request) + expect(endBalance - startBalance).to.equal(payout + collateral) + }) + it("updates the collateral when freeing a finished slot", async function () { await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) @@ -665,6 +686,14 @@ describe("Marketplace", function () { expect(await marketplace.currentCollateral(slotId(slot))).to.equal(0) }) + it("updates the collateral when freeing a failed slot", async function () { + await waitUntilStarted(marketplace, request, proof, token) + await waitUntilSlotFailed(marketplace, request, slot) + await waitUntilFinished(marketplace, requestId(request)) + await marketplace.freeSlot(slotId(slot)) + expect(await marketplace.currentCollateral(slotId(slot))).to.equal(0) + }) + it("does not pay when the contract hasn't ended", async function () { await marketplace.reserveSlot(slot.request, slot.index) await marketplace.fillSlot(slot.request, slot.index, proof) @@ -674,6 +703,14 @@ describe("Marketplace", function () { expect(endBalance).to.equal(startBalance) }) + it("does not pay for a failed slot when the contract hasn't ended", async function () { + await waitUntilStarted(marketplace, request, proof, token) + await waitUntilSlotFailed(marketplace, request, slot) + await expect(marketplace.freeSlot(slotId(slot))).to.be.revertedWith( + "VaultFundNotUnlocked" + ) + }) + it("pays only once", async function () { await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) @@ -798,6 +835,14 @@ describe("Marketplace", function () { ) }) + it("rejects withdraw for failed request before request end", async function () { + await waitUntilStarted(marketplace, request, proof, token) + await waitUntilFailed(marketplace, request) + await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith( + "VaultFundNotUnlocked" + ) + }) + it("does not withdraw more than once", async function () { await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) @@ -1387,6 +1432,7 @@ describe("Marketplace", function () { it("removes slot when failed slot is freed", async function () { await waitUntilStarted(marketplace, request, proof, token) await waitUntilSlotFailed(marketplace, request, slot) + await waitUntilFinished(marketplace, requestId(request)) await marketplace.freeSlot(slotId(slot)) expect(await marketplace.mySlots()).to.not.contain(slotId(slot)) }) From 1fb3d2130b4589b890ded3479538cc28b7f6d66a Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Tue, 27 May 2025 11:38:22 +0200 Subject: [PATCH 31/38] marketplace: cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Adam Uhlíř --- contracts/marketplace/VaultHelpers.sol | 2 -- 1 file changed, 2 deletions(-) diff --git a/contracts/marketplace/VaultHelpers.sol b/contracts/marketplace/VaultHelpers.sol index 0cab7da..be5cb2e 100644 --- a/contracts/marketplace/VaultHelpers.sol +++ b/contracts/marketplace/VaultHelpers.sol @@ -4,8 +4,6 @@ pragma solidity 0.8.28; import "../Requests.sol"; import "../Vault.sol"; -import "hardhat/console.sol"; - library VaultHelpers { enum VaultRole { client, From bdee8de9ccc9d784b0b1c7ae77c17247a92d44fb Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Tue, 27 May 2025 12:46:23 +0200 Subject: [PATCH 32/38] marketplace: move collateralPerSlot() to Requests.sol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reason: the function only depends on the request, and it is similar to the pricePerSlotPerSecond function Co-Authored-By: Adam Uhlíř --- contracts/Marketplace.sol | 6 +++--- contracts/Requests.sol | 4 ++++ contracts/marketplace/Collateral.sol | 6 ------ 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 89d4137..c90b4cc 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -209,7 +209,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { AccountId hostAccount = _vault.hostAccount(slot.host, slotIndex); TokensPerSecond rate = request.ask.pricePerSlotPerSecond(); - uint128 collateral = request.collateralPerSlot(); + uint128 collateral = request.ask.collateralPerSlot(); uint128 designated = _config.collateral.designatedCollateral(collateral); if (slotState(slotId) == SlotState.Repair) { @@ -327,7 +327,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { Slot storage slot = _slots[slotId]; Request storage request = _requests[slot.requestId]; - uint128 collateral = request.collateralPerSlot(); + uint128 collateral = request.ask.collateralPerSlot(); uint128 slashedAmount = _config.collateral.slashAmount(collateral); uint128 validatorReward = _config.collateral.validatorReward(slashedAmount); @@ -355,7 +355,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { Request storage request = _requests[requestId]; TokensPerSecond rate = request.ask.pricePerSlotPerSecond(); - uint128 collateral = request.collateralPerSlot(); + uint128 collateral = request.ask.collateralPerSlot(); uint128 repairReward = _config.collateral.repairReward(collateral); FundId fund = requestId.asFundId(); diff --git a/contracts/Requests.sol b/contracts/Requests.sol index 0895b1f..5bb9a81 100644 --- a/contracts/Requests.sol +++ b/contracts/Requests.sol @@ -63,6 +63,10 @@ library AskHelpers { uint96 perSlot = TokensPerSecond.unwrap(ask.pricePerSlotPerSecond()); return TokensPerSecond.wrap(perSlot * ask.slots); } + + function collateralPerSlot(Ask memory ask) internal pure returns (uint128) { + return ask.collateralPerByte * ask.slotSize; + } } library Requests { diff --git a/contracts/marketplace/Collateral.sol b/contracts/marketplace/Collateral.sol index f6e4956..5c091a4 100644 --- a/contracts/marketplace/Collateral.sol +++ b/contracts/marketplace/Collateral.sol @@ -25,12 +25,6 @@ library Collateral { ); } - function collateralPerSlot( - Request storage request - ) internal view returns (uint128) { - return request.ask.collateralPerByte * request.ask.slotSize; - } - function slashAmount( CollateralConfig storage configuration, uint128 collateral From 895e36fbba5ce20a127a5a760ac6e08d9a3982c8 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Tue, 27 May 2025 12:52:52 +0200 Subject: [PATCH 33/38] marketplace: clarify why we flow funds from client to itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Adam Uhlíř --- contracts/Marketplace.sol | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index c90b4cc..8b61a60 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -159,6 +159,9 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { AccountId account = _vault.clientAccount(request.client); _vault.lock(fund, expiresAt, endsAt); _transferToVault(request.client, fund, account, price); + + // start flow from client to itself, to make sure that funds that are not + // paid to hosts will slowly become designated to the client _vault.flow(fund, account, account, pricePerSecond); emit StorageRequested(id, request.ask, expiresAt); From 075d97e5da43f3c44eb6c09e34d8ad5ce52d7746 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Tue, 27 May 2025 13:07:10 +0200 Subject: [PATCH 34/38] marketplace: clarify why we flow & transfer funds before burning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Adam Uhlíř --- contracts/Marketplace.sol | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 8b61a60..c458d4e 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -364,8 +364,16 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { FundId fund = requestId.asFundId(); AccountId hostAccount = _vault.hostAccount(slot.host, slot.slotIndex); AccountId clientAccount = _vault.clientAccount(request.client); + + // ensure that nothing is flowing into the account anymore by reversing the + // incoming flow from the client _vault.flow(fund, hostAccount, clientAccount, rate); + + // temporarily transfer repair reward for the slot to the client until a + // host repairs the slot _vault.transfer(fund, hostAccount, clientAccount, repairReward); + + // burn the rest of the funds in the account _vault.burnAccount(fund, hostAccount); _removeFromMySlots(slot.host, slotId); From cd8b6572f3a114a6fcb003161c660bf594691541 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Wed, 11 Jun 2025 17:00:24 +0200 Subject: [PATCH 35/38] marketplace: fix test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Adam Uhlíř --- test/Marketplace.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index da5613e..8a9ca49 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -838,6 +838,7 @@ describe("Marketplace", function () { it("rejects withdraw for failed request before request end", async function () { await waitUntilStarted(marketplace, request, proof, token) await waitUntilFailed(marketplace, request) + switchAccount(client) await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith( "VaultFundNotUnlocked" ) From 44109410613204f68e1645fcf4f374b3a886cff8 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Wed, 11 Jun 2025 17:00:52 +0200 Subject: [PATCH 36/38] marketplace: add test for hosts that makes request fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Adam Uhlíř --- test/Marketplace.test.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 8a9ca49..dac82ed 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -711,6 +711,18 @@ describe("Marketplace", function () { ) }) + it("does not pay host that made the request fail", async function () { + await waitUntilStarted(marketplace, request, proof, token) + for (let i = 0; i <= request.ask.maxSlotLoss; i++) { + slot.index = i + await marketplace.freeSlot(slotId(slot)) + } + await waitUntilFinished(marketplace, requestId(request)) + await expect(marketplace.freeSlot(slotId(slot))).to.be.revertedWith( + "Marketplace_InvalidSlotHost" + ) + }) + it("pays only once", async function () { await waitUntilStarted(marketplace, request, proof, token) await waitUntilFinished(marketplace, requestId(request)) From d7554ce0d2d4e6210055294b7b69bd3a4e764cb9 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Thu, 12 Jun 2025 08:05:24 +0200 Subject: [PATCH 37/38] marketplace: improve withdraw test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Adam Uhlíř --- test/Marketplace.test.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index dac82ed..57032d7 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -822,11 +822,14 @@ describe("Marketplace", function () { it("withdraws nothing when wrong account used", async function () { await waitUntilCancelled(marketplace, request) - const startBalance = await token.balanceOf(host.address) + const startBalanceHost = await token.balanceOf(host.address) + const startBalanceClient = await token.balanceOf(client.address) await marketplace.withdrawFunds(slot.request) - const endBalance = await token.balanceOf(host.address) + const endBalanceHost = await token.balanceOf(host.address) + const endBalanceClient = await token.balanceOf(client.address) - expect(endBalance - startBalance).to.equal(0) + expect(endBalanceHost - startBalanceHost).to.equal(0) + expect(endBalanceClient - startBalanceClient).to.equal(0) }) it("rejects withdraw when in wrong state", async function () { From b5ab3869b949f651a3ed9aeb914feed7bd0dfd47 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Thu, 12 Jun 2025 15:50:09 +0200 Subject: [PATCH 38/38] marketplace: better tests for repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Adam Uhlíř --- test/Marketplace.test.js | 112 +++++++++++++++++++++++---------------- test/marketplace.js | 25 +++++---- 2 files changed, 82 insertions(+), 55 deletions(-) diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 57032d7..4bb315a 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -22,6 +22,7 @@ const { waitUntilFailed, waitUntilSlotFailed, patchOverloads, + waitUntilSlotFilled, } = require("./marketplace") const { maxPrice, @@ -305,28 +306,6 @@ describe("Marketplace", function () { expect(startBalance - endBalance).to.eq(collateralPerSlot(request)) }) - describe("when repairing a slot", function () { - beforeEach(async function () { - await marketplace.reserveSlot(slot.request, slot.index) - await marketplace.fillSlot(slot.request, slot.index, proof) - await advanceTime(config.proofs.period + 1) - await marketplace.freeSlot(slotId(slot)) - }) - - it("tops up the host collateral with the repair reward", async function () { - const collateral = collateralPerSlot(request) - const reward = repairReward(config, collateral) - await token.approve(marketplace.address, collateral) - await marketplace.reserveSlot(slot.request, slot.index) - - const startBalance = await marketplace.getSlotBalance(slotId(slot)) - await marketplace.fillSlot(slot.request, slot.index, proof) - const endBalance = await marketplace.getSlotBalance(slotId(slot)) - - expect(endBalance - startBalance).to.equal(collateral + reward) - }) - }) - it("updates the slot's current collateral", async function () { await marketplace.reserveSlot(slot.request, slot.index) await marketplace.fillSlot(slot.request, slot.index, proof) @@ -568,29 +547,6 @@ describe("Marketplace", function () { .withArgs(slot.request, slot.index) }) - it("can reserve and fill a freed slot", async function () { - // Make a reservation from another host - switchAccount(host2) - collateral = collateralPerSlot(request) - await token.approve(marketplace.address, collateral) - await marketplace.reserveSlot(slot.request, slot.index) - - // Switch host and free the slot - switchAccount(host) - await waitUntilStarted(marketplace, request, proof, token) - await marketplace.freeSlot(id) - - // At this point, the slot should be freed and in a repair state. - // Another host should be able to make a reservation for this - // slot and fill it. - switchAccount(host2) - await marketplace.reserveSlot(slot.request, slot.index) - let currPeriod = periodOf(await currentTime()) - await advanceTimeTo(periodEnd(currPeriod) + 1) - await token.approve(marketplace.address, collateral) - await marketplace.fillSlot(slot.request, slot.index, proof) - }) - it("updates the slot's current collateral", async function () { await waitUntilStarted(marketplace, request, proof, token) await marketplace.freeSlot(id) @@ -1340,6 +1296,72 @@ describe("Marketplace", function () { }) }) + describe("repairing a slot", function () { + beforeEach(async function () { + switchAccount(client) + await token.approve(marketplace.address, maxPrice(request)) + await marketplace.requestStorage(request) + switchAccount(host) + await waitUntilStarted(marketplace, request, proof, token) + await advanceTime(config.proofs.period + 1) + await marketplace.freeSlot(slotId(slot)) + switchAccount(host2) + }) + + it("can reserve and fill a freed slot", async function () { + const collateral = collateralPerSlot(request) + await token.approve(marketplace.address, collateral) + await marketplace.reserveSlot(slot.request, slot.index) + await token.approve(marketplace.address, collateral) + await marketplace.fillSlot(slot.request, slot.index, proof) + }) + + it("tops up the host collateral with the repair reward", async function () { + const collateral = collateralPerSlot(request) + const reward = repairReward(config, collateral) + await token.approve(marketplace.address, collateral) + await marketplace.reserveSlot(slot.request, slot.index) + + const startBalance = await marketplace.getSlotBalance(slotId(slot)) + await marketplace.fillSlot(slot.request, slot.index, proof) + const endBalance = await marketplace.getSlotBalance(slotId(slot)) + + expect(endBalance - startBalance).to.equal(collateral + reward) + }) + + it("pays the host that repaired when the request finishes", async function () { + const collateral = collateralPerSlot(request) + const reward = repairReward(config, collateral) + const payout = await waitUntilSlotFilled( + marketplace, + request, + proof, + token, + slot.index + ) + await waitUntilFinished(marketplace, slot.request) + + const startBalance = await token.balanceOf(host2.address) + await marketplace.freeSlot(slotId(slot)) + const endBalance = await token.balanceOf(host2.address) + + expect(endBalance - startBalance).to.equal(collateral + reward + payout) + }) + + it("burns payment and collateral of the host that failed", async function () { + const collateral = collateralPerSlot(request) + await token.approve(marketplace.address, collateral) + await marketplace.reserveSlot(slot.request, slot.index) + await marketplace.fillSlot(slot.request, slot.index, proof) + await waitUntilFinished(marketplace, slot.request) + + switchAccount(host) + await expect(marketplace.freeSlot(slotId(slot))).to.be.revertedWith( + "InvalidSlotHost" + ) + }) + }) + describe("list of active requests", function () { beforeEach(async function () { switchAccount(host) diff --git a/test/marketplace.js b/test/marketplace.js index 9cf0041..19be6ee 100644 --- a/test/marketplace.js +++ b/test/marketplace.js @@ -9,23 +9,27 @@ async function waitUntilCancelled(contract, request) { await advanceTimeTo(expiry + 1) } -async function waitUntilSlotsFilled(contract, request, proof, token, slots) { +async function waitUntilSlotFilled(contract, request, proof, token, slotIndex) { let collateral = collateralPerSlot(request) - await token.approve(contract.address, collateral * slots.length) + await token.approve(contract.address, collateral) + await contract.reserveSlot(requestId(request), slotIndex) + await contract.fillSlot(requestId(request), slotIndex, proof) + const start = await currentTime() + const end = await contract.requestEnd(requestId(request)) + return payoutForDuration(request, start, end) +} - let requestEnd = await contract.requestEnd(requestId(request)) +async function waitUntilSlotsFilled(contract, request, proof, token, slots) { const payouts = [] for (let slotIndex of slots) { - await contract.reserveSlot(requestId(request), slotIndex) - await contract.fillSlot(requestId(request), slotIndex, proof) - - payouts[slotIndex] = payoutForDuration( + payouts[slotIndex] = await waitUntilSlotFilled( + contract, request, - await currentTime(), - requestEnd + proof, + token, + slotIndex ) } - return payouts } @@ -99,6 +103,7 @@ function patchOverloads(contract) { module.exports = { waitUntilCancelled, waitUntilStarted, + waitUntilSlotFilled, waitUntilSlotsFilled, waitUntilFinished, waitUntilFailed,