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/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/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/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 64ba147..a420003 100644 --- a/certora/specs/Marketplace.spec +++ b/certora/specs/Marketplace.spec @@ -3,9 +3,7 @@ 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 (uint64) envfree; + function publicPeriodEnd(Periods.Period) external returns (Marketplace.Timestamp) envfree; function generateSlotId(Marketplace.RequestId, uint64) external returns (Marketplace.SlotId) envfree; } @@ -13,44 +11,12 @@ 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 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 +87,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 +110,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; } @@ -174,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); @@ -198,25 +154,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 +165,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 +211,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 +226,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 +291,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; -} 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); diff --git a/contracts/Configuration.sol b/contracts/Configuration.sol index 69e97b1..10d697a 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 { @@ -19,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/FuzzMarketplace.sol b/contracts/FuzzMarketplace.sol deleted file mode 100644 index 3291e36..0000000 --- a/contracts/FuzzMarketplace.sol +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import "./TestToken.sol"; -import "./Marketplace.sol"; -import "./TestVerifier.sol"; - -contract FuzzMarketplace is Marketplace { - constructor() - Marketplace( - MarketplaceConfig( - CollateralConfig(10, 5, 10, 20), - ProofConfig(10, 5, 64, 67, ""), - SlotReservationsConfig(20), - 60 * 60 * 24 * 30 // 30 days - ), - new TestToken(), - new TestVerifier() - ) - // solhint-disable-next-line no-empty-blocks - { - - } - - // Properties to be tested through fuzzing - - MarketplaceTotals private _lastSeenTotals; - - function neverDecreaseTotals() public { - assert(_marketplaceTotals.received >= _lastSeenTotals.received); - assert(_marketplaceTotals.sent >= _lastSeenTotals.sent); - _lastSeenTotals = _marketplaceTotals; - } - - function neverLoseFunds() public view { - uint256 total = _marketplaceTotals.received - _marketplaceTotals.sent; - assert(token().balanceOf(address(this)) >= total); - } -} diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 8b0a29c..c458d4e 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -2,8 +2,10 @@ 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 "./Vault.sol"; import "./Configuration.sol"; import "./Requests.sol"; import "./Proofs.sol"; @@ -11,11 +13,10 @@ import "./SlotReservations.sol"; 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(); @@ -29,8 +30,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { error Marketplace_InvalidCid(); error Marketplace_SlotNotFree(); error Marketplace_InvalidSlotHost(); - error Marketplace_AlreadyPaid(); - error Marketplace_TransferFailed(); error Marketplace_UnknownRequest(); error Marketplace_InvalidState(); error Marketplace_StartNotBeforeExpiry(); @@ -38,56 +37,40 @@ 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; using EnumerableSet for EnumerableSet.Bytes32Set; 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; + using Collateral for Request; + using Collateral for CollateralConfig; + using Timestamps for Timestamp; + using Tokens for TokensPerSecond; - IERC20 private immutable _token; + Vault private immutable _vault; MarketplaceConfig private _config; mapping(RequestId => Request) private _requests; 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; - uint64 startedAt; - uint64 endsAt; - uint64 expiresAt; + Timestamp endsAt; + Timestamp expiresAt; } 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 - uint64 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 + uint128 currentCollateral; + SlotState state; address host; } @@ -98,22 +81,11 @@ 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_; - - 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(); - } + _vault = vault_; + config.collateral.checkCorrectness(); _config = config; } @@ -122,10 +94,14 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { } function token() public view returns (IERC20) { - return _token; + return _vault.getToken(); } - function currentCollateral(SlotId slotId) public view returns (uint256) { + function vault() public view returns (Vault) { + return _vault; + } + + function currentCollateral(SlotId slotId) public view returns (uint128) { return _slots[slotId].currentCollateral; } @@ -136,12 +112,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) { @@ -150,7 +128,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) { @@ -160,20 +138,33 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { revert Marketplace_DurationExceedsLimit(); } + Timestamp currentTime = Timestamps.currentTime(); + Timestamp expiresAt = currentTime.add(request.expiry); + Timestamp endsAt = currentTime.add(request.ask.duration); + _requests[id] = request; - _requestContexts[id].endsAt = - uint64(block.timestamp) + - request.ask.duration; - _requestContexts[id].expiresAt = uint64(block.timestamp) + request.expiry; + _requestContexts[id] = RequestContext({ + state: RequestState.New, + slotsFilled: 0, + endsAt: endsAt, + expiresAt: expiresAt + }); _addToMyRequests(request.client, id); - uint256 amount = request.maxPrice(); - _requestContexts[id].fundsToReturnToClient = amount; - _marketplaceTotals.received += amount; - _transferFrom(msg.sender, amount); + TokensPerSecond pricePerSecond = request.ask.pricePerSecond(); + uint128 price = pricePerSecond.accumulate(request.ask.duration); - emit StorageRequested(id, request.ask, _requestContexts[id].expiresAt); + FundId fund = id.asFundId(); + 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); } /** @@ -210,30 +201,30 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { } slot.host = msg.sender; - slot.filledAt = uint64(block.timestamp); _startRequiringProofs(slotId); submitProof(slotId, proof); context.slotsFilled += 1; - context.fundsToReturnToClient -= _slotPayout(requestId, slot.filledAt); - // Collect collateral - uint256 collateralAmount; - uint256 collateralPerSlot = request.ask.collateralPerSlot(); + FundId fund = requestId.asFundId(); + AccountId clientAccount = _vault.clientAccount(request.client); + AccountId hostAccount = _vault.hostAccount(slot.host, slotIndex); + TokensPerSecond rate = request.ask.pricePerSlotPerSecond(); + + uint128 collateral = request.ask.collateralPerSlot(); + uint128 designated = _config.collateral.designatedCollateral(collateral); + 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; + uint128 repairReward = _config.collateral.repairReward(collateral); + _vault.transfer(fund, clientAccount, hostAccount, repairReward); } - _transferFrom(msg.sender, collateralAmount); - _marketplaceTotals.received += collateralAmount; - slot.currentCollateral = collateralPerSlot; // Even if he has collateral discounted, he is operating with full collateral + + _transferToVault(slot.host, fund, hostAccount, collateral); + _vault.designate(fund, hostAccount, designated); + _vault.flow(fund, clientAccount, hostAccount, rate); + + slot.currentCollateral = collateral; _addToMySlots(slot.host, slotId); @@ -245,52 +236,40 @@ 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, 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. * @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(); SlotState state = slotState(slotId); - if (state == SlotState.Paid) revert Marketplace_AlreadyPaid(); - - if (state == SlotState.Finished) { - _payoutSlot(slot.requestId, slotId, rewardRecipient, collateralRecipient); - } else if (state == SlotState.Cancelled) { - _payoutCancelledSlot( - slot.requestId, - slotId, - rewardRecipient, - collateralRecipient - ); - } else if (state == SlotState.Failed) { - _removeFromMySlots(msg.sender, slotId); + if ( + state == SlotState.Finished || + state == SlotState.Cancelled || + state == SlotState.Failed + ) { + _payoutSlot(slot.requestId, slotId); } else if (state == SlotState.Filled) { // free slot without returning collateral, effectively a 100% slash _forciblyFreeSlot(slotId); @@ -351,16 +330,16 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { Slot storage slot = _slots[slotId]; Request storage request = _requests[slot.requestId]; - uint256 slashedAmount = (request.ask.collateralPerSlot() * - _config.collateral.slashPercentage) / 100; + uint128 collateral = request.ask.collateralPerSlot(); + uint128 slashedAmount = _config.collateral.slashAmount(collateral); + uint128 validatorReward = _config.collateral.validatorReward(slashedAmount); - uint256 validatorRewardAmount = (slashedAmount * - _config.collateral.validatorRewardPercentage) / 100; - _marketplaceTotals.sent += validatorRewardAmount; - - if (!_token.transfer(msg.sender, validatorRewardAmount)) { - revert Marketplace_TransferFailed(); - } + FundId fund = slot.requestId.asFundId(); + 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); slot.currentCollateral -= slashedAmount; if (missingProofs(slotId) >= _config.collateral.maxNumberOfSlashes) { @@ -370,176 +349,86 @@ 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; 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]; + + TokensPerSecond rate = request.ask.pricePerSlotPerSecond(); + uint128 collateral = request.ask.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); + + // 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); _reservations[slotId].clear(); // We purge all the reservations for the slot slot.state = SlotState.Repair; - slot.filledAt = 0; slot.currentCollateral = 0; slot.host = address(0); context.slotsFilled -= 1; 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); } } - function _payoutSlot( - RequestId requestId, - SlotId slotId, - address rewardRecipient, - address collateralRecipient - ) private requestIsKnown(requestId) { - RequestContext storage context = _requestContexts[requestId]; - Request storage request = _requests[requestId]; - context.state = RequestState.Finished; - Slot storage slot = _slots[slotId]; - - _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; - if (!_token.transfer(rewardRecipient, payoutAmount)) { - revert Marketplace_TransferFailed(); - } - - if (!_token.transfer(collateralRecipient, collateralAmount)) { - revert Marketplace_TransferFailed(); - } - } - /** * @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( + function _payoutSlot( RequestId requestId, - SlotId slotId, - address rewardRecipient, - address collateralRecipient + SlotId slotId ) private requestIsKnown(requestId) { Slot storage slot = _slots[slotId]; + slot.currentCollateral = 0; _removeFromMySlots(slot.host, slotId); - - uint256 payoutAmount = _slotPayout( - requestId, - slot.filledAt, - requestExpiry(requestId) - ); - 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(); - } + FundId fund = requestId.asFundId(); + AccountId account = _vault.hostAccount(slot.host, slot.slotIndex); + _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 - */ - function withdrawFunds(RequestId requestId) public { - withdrawFunds(requestId, msg.sender); + /// Withdraws remaining storage request funds back to the client that + function withdrawFunds(RequestId requestId) public requestIsKnown(requestId) { + FundId fund = requestId.asFundId(); + AccountId account = _vault.clientAccount(msg.sender); + _vault.withdraw(fund, account); + + _removeFromMyRequests(msg.sender, requestId); } - /** - * @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) { - 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(); - } - - // 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(); - - 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; - - if (!_token.transfer(withdrawRecipient, amount)) { - revert Marketplace_TransferFailed(); - } - - // 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( @@ -580,55 +469,18 @@ 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 || state == RequestState.Started) { - return _requestContexts[requestId].endsAt; - } if (state == RequestState.Cancelled) { return _requestContexts[requestId].expiresAt; } - return - uint64(Math.min(_requestContexts[requestId].endsAt, block.timestamp)); + return _requestContexts[requestId].endsAt; } - function requestExpiry(RequestId requestId) public view returns (uint64) { + function requestExpiry(RequestId requestId) public view returns (Timestamp) { 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 startingTimestamp timestamp indicating when a host filled a slot and - * started providing proofs. - */ - function _slotPayout( - RequestId requestId, - uint64 startingTimestamp - ) private view returns (uint256) { - return - _slotPayout( - requestId, - startingTimestamp, - _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 - ) private view returns (uint256) { - Request storage request = _requests[requestId]; - if (startingTimestamp >= endingTimestamp) - revert Marketplace_StartNotBeforeExpiry(); - return - (endingTimestamp - startingTimestamp) * - request.ask.pricePerSlotPerSecond(); - } - function getHost(SlotId slotId) public view returns (address) { return _slots[slotId].host; } @@ -637,15 +489,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 { @@ -661,9 +513,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; } @@ -685,21 +534,9 @@ 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 StorageRequested(RequestId requestId, Ask ask, Timestamp expiry); event RequestFulfilled(RequestId indexed requestId); event RequestFailed(RequestId indexed requestId); 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/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(); diff --git a/contracts/Requests.sol b/contracts/Requests.sol index 0f296be..5bb9a81 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 - uint256 collateralPerByte; // amount of tokens per byte required to be deposited by the hosts in order to fill the slot + TokensPerSecond pricePerBytePerSecond; // amount of tokens paid per second per byte to hosts + 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) - 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 } @@ -40,20 +43,29 @@ 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 } library AskHelpers { - function collateralPerSlot(Ask memory ask) internal pure returns (uint256) { - return ask.collateralPerByte * ask.slotSize; - } + using AskHelpers for Ask; 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); + } + + function pricePerSecond( + Ask memory ask + ) internal pure returns (TokensPerSecond) { + 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; } } @@ -88,11 +100,4 @@ library Requests { result := ids } } - - function maxPrice(Request memory request) internal pure returns (uint256) { - return - request.ask.slots * - request.ask.duration * - request.ask.pricePerSlotPerSecond(); - } } diff --git a/contracts/TestMarketplace.sol b/contracts/TestMarketplace.sol index c18d369..fe7a95f 100644 --- a/contracts/TestMarketplace.sol +++ b/contracts/TestMarketplace.sol @@ -5,20 +5,20 @@ 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, - 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 { - _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/vault/Timestamps.sol b/contracts/Timestamps.sol similarity index 67% rename from contracts/vault/Timestamps.sol rename to contracts/Timestamps.sol index dc63fa4..945eced 100644 --- a/contracts/vault/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, 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.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/contracts/marketplace/Collateral.sol b/contracts/marketplace/Collateral.sol new file mode 100644 index 0000000..5c091a4 --- /dev/null +++ b/contracts/marketplace/Collateral.sol @@ -0,0 +1,63 @@ +// 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 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/contracts/marketplace/VaultHelpers.sol b/contracts/marketplace/VaultHelpers.sol new file mode 100644 index 0000000..be5cb2e --- /dev/null +++ b/contracts/marketplace/VaultHelpers.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import "../Requests.sol"; +import "../Vault.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)); + } +} 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 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/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/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", diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 9904a40..4bb315a 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -22,13 +22,14 @@ const { waitUntilFailed, waitUntilSlotFailed, patchOverloads, + waitUntilSlotFilled, } = require("./marketplace") const { maxPrice, pricePerSlotPerSecond, payoutForDuration, } = require("./price") -const { collateralPerSlot } = require("./collateral") +const { collateralPerSlot, repairReward } = require("./collateral") const { snapshot, revert, @@ -40,10 +41,8 @@ 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, verifier, config + let Marketplace, token, vault, verifier, config beforeEach(async function () { await snapshot() @@ -52,6 +51,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 +70,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 +89,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,16 +100,9 @@ describe("Marketplace", function () { let marketplace 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 @@ -116,40 +111,25 @@ 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") + 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) @@ -294,7 +274,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)) @@ -317,34 +297,20 @@ 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 - ) - - // 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("collects only requested collateral and not more", async function () { + await token.approve(marketplace.address, collateralPerSlot(request) * 2) 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.eq(collateralPerSlot(request)) + }) + + 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 () { @@ -440,17 +406,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) @@ -458,15 +415,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 () { @@ -528,17 +476,8 @@ 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) - }) - - 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 + await expect(await marketplace.requestEnd(requestId(request))).to.equal( + requestTime + request.ask.duration ) }) @@ -608,27 +547,10 @@ 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) + it("updates the slot's current collateral", async function () { 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) + expect(await marketplace.currentCollateral(id)).to.equal(0) }) }) @@ -642,7 +564,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. @@ -660,171 +582,111 @@ 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 ) }) - 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) - 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) await setNextBlockTimestamp(filledAt) await marketplace.fillSlot(slot.request, slot.index, proof) await waitUntilCancelled(marketplace, request) await marketplace.freeSlot(slotId(slot)) + const endBalance = await token.balanceOf(host.address) const expectedPartialPayout = (expiresAt - filledAt) * pricePerSlotPerSecond(request) - const endBalance = await token.balanceOf(host.address) - expect(endBalance - ACCOUNT_STARTING_BALANCE).to.be.equal( - expectedPartialPayout - ) + expect(endBalance - startBalance).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() + 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)) + 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 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 - ) + await marketplace.freeSlot(slotId(slot)) + expect(await marketplace.currentCollateral(slotId(slot))).to.equal(0) + }) - 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)) + 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) - 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 () { + 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("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)) 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 () { @@ -908,16 +770,22 @@ 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( + "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, clientWithdrawRecipient.address) - ).to.be.revertedWith("Marketplace_InvalidClientAddress") + + const startBalanceHost = await token.balanceOf(host.address) + const startBalanceClient = await token.balanceOf(client.address) + await marketplace.withdrawFunds(slot.request) + const endBalanceHost = await token.balanceOf(host.address) + const endBalanceClient = await token.balanceOf(client.address) + + expect(endBalanceHost - startBalanceHost).to.equal(0) + expect(endBalanceClient - startBalanceClient).to.equal(0) }) it("rejects withdraw when in wrong state", async function () { @@ -933,152 +801,121 @@ 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( + "VaultFundNotUnlocked" + ) }) - it("rejects withdraw when already withdrawn", async 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" + ) + }) + + 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) + + 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("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) - await marketplace.withdrawFunds( - slot.request, - clientWithdrawRecipient.address - ) - await expect( - marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address) - ).to.be.revertedWith("Marketplace_NothingToWithdraw") - }) + const startBalance = await token.balanceOf(client.address) + await marketplace.withdrawFunds(slot.request) - it("emits event once request is cancelled", async function () { - await waitUntilCancelled(marketplace, request) - switchAccount(client) - await expect( - marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address) - ) - .to.emit(marketplace, "RequestCancelled") - .withArgs(requestId(request)) - }) + const endBalance = await token.balanceOf(client.address) - it("withdraw rest of funds to the client payout address 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 endBalanceClient = await token.balanceOf(client.address) - const endBalancePayout = await token.balanceOf( - clientWithdrawRecipient.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("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 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 endBalance = await token.balanceOf(client.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 expectedRefund = + (finishedAt - failedAt) * + request.ask.slots * + pricePerSlotPerSecond(request) + expect(endBalance - startBalance).to.be.gte(expectedRefund) }) - 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 = ( - await marketplace.requestExpiry(requestId(request)) - ).toNumber() + const expiresAt = await marketplace.requestExpiry(requestId(request)) await marketplace.reserveSlot(slot.request, slot.index) 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( - 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) - await marketplace.withdrawFunds( - slot.request, - clientWithdrawRecipient.address - ) - const endBalance = await token.balanceOf(clientWithdrawRecipient.address) - expect(endBalance - ACCOUNT_STARTING_BALANCE).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 + 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) + const reward = repairReward(config, collateralPerSlot(request)) + expect(endBalance - startBalance).to.equal( + maxPrice(request) - hostPayouts + refund + reward ) }) }) @@ -1107,10 +944,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) }) @@ -1137,7 +971,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) }) @@ -1157,8 +991,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 () { @@ -1231,13 +1064,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 () { @@ -1387,51 +1213,49 @@ 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 expectedBalance = Math.round( - (collateral * (100 - slashPercentage)) / 100 - ) - - expect( - BigNumber.from(expectedBalance).eq( - await marketplace.getSlotCollateral(id) - ) - ) + 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 () { - 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(validatorRecipient) - - const startBalance = await token.balanceOf(validatorRecipient.address) - - await waitUntilProofIsRequired(id) - let missedPeriod = periodOf(await currentTime()) - await advanceTime(period + 1) + switchAccount(validator) await marketplace.markProofAsMissing(id, missedPeriod) - const endBalance = await token.balanceOf(validatorRecipient.address) - - const collateral = collateralPerSlot(request) - const slashedAmount = (collateral * slashPercentage) / 100 + 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 expectedReward = Math.round( - (slashedAmount * validatorRewardPercentage) / 100 + (slashAmount * validatorRewardPercentage) / 100 ) expect(endBalance.toNumber()).to.equal( @@ -1440,61 +1264,100 @@ 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 + 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("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) - 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 - ) + }) + + it("burns the balance", async 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) + }) + }) + }) + + 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("free slot when minimum reached and resets missed proof counter", async function () { + it("can reserve and fill a freed slot", 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 - ) - 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 + 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 ) - expect(await marketplace.missingProofs(slotId(slot))).to.equal(0) - expect(await marketplace.getSlotCollateral(slotId(slot))).to.be.lte( - minimum + 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" ) }) }) @@ -1522,10 +1385,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([]) }) @@ -1537,16 +1397,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 () { @@ -1620,6 +1470,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)) }) 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 } 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..19be6ee 100644 --- a/test/marketplace.js +++ b/test/marketplace.js @@ -4,28 +4,32 @@ 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) } -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))).toNumber() +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 } @@ -40,7 +44,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) } @@ -50,7 +54,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 +63,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++ @@ -99,6 +103,7 @@ function patchOverloads(contract) { module.exports = { waitUntilCancelled, waitUntilStarted, + waitUntilSlotFilled, waitUntilSlotsFilled, waitUntilFinished, waitUntilFailed, diff --git a/test/requests.js b/test/requests.js index 53a18d7..0088c9f 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, @@ -14,9 +13,8 @@ const SlotState = { Filled: 1, Finished: 2, Failed: 3, - Paid: 4, - Cancelled: 5, - Repair: 6, + Cancelled: 4, + Repair: 5, } function enableRequestAssertions() {