diff --git a/contracts/Configuration.sol b/contracts/Configuration.sol index 69e97b1..dd70be4 100644 --- a/contracts/Configuration.sol +++ b/contracts/Configuration.sol @@ -2,12 +2,13 @@ pragma solidity 0.8.28; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "./Timestamps.sol"; struct MarketplaceConfig { CollateralConfig collateral; ProofConfig proofs; SlotReservationsConfig reservations; - uint64 requestDurationLimit; + Duration requestDurationLimit; } struct CollateralConfig { diff --git a/contracts/FuzzMarketplace.sol b/contracts/FuzzMarketplace.sol index c13838c..1520522 100644 --- a/contracts/FuzzMarketplace.sol +++ b/contracts/FuzzMarketplace.sol @@ -13,7 +13,7 @@ contract FuzzMarketplace is Marketplace { CollateralConfig(10, 5, 10, 20), ProofConfig(10, 5, 64, 67, ""), SlotReservationsConfig(20), - 60 * 60 * 24 * 30 // 30 days + Duration.wrap(60 * 60 * 24 * 30) // 30 days ), new Vault(new TestToken()), new TestVerifier() diff --git a/contracts/Marketplace.sol b/contracts/Marketplace.sol index 67563d7..3ba6236 100644 --- a/contracts/Marketplace.sol +++ b/contracts/Marketplace.sol @@ -51,6 +51,8 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { using VaultHelpers for Vault; using VaultHelpers for RequestId; using VaultHelpers for Request; + using Timestamps for Timestamp; + using Tokens for TokensPerSecond; Vault private immutable _vault; MarketplaceConfig private _config; @@ -72,9 +74,9 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { /// that would require all the slots to be filled at the same block as the request was created. uint256 fundsToReturnToClient; uint64 slotsFilled; - uint64 startedAt; - uint64 endsAt; - uint64 expiresAt; + Timestamp startedAt; + Timestamp endsAt; + Timestamp expiresAt; } struct Slot { @@ -83,7 +85,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { /// @notice Timestamp that signals when slot was filled /// @dev Used for calculating payouts as hosts are paid /// based on time they actually host the content - uint64 filledAt; + Timestamp filledAt; uint64 slotIndex; /// @notice Tracks the current amount of host's collateral that is /// to be payed out at the end of Slot's lifespan. @@ -146,12 +148,14 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { if (_requests[id].client != address(0)) { revert Marketplace_RequestAlreadyExists(); } - if (request.expiry == 0 || request.expiry >= request.ask.duration) - revert Marketplace_InvalidExpiry(); + if ( + request.expiry == Duration.wrap(0) || + request.expiry >= request.ask.duration + ) revert Marketplace_InvalidExpiry(); if (request.ask.slots == 0) revert Marketplace_InsufficientSlots(); if (request.ask.maxSlotLoss > request.ask.slots) revert Marketplace_InvalidMaxSlotLoss(); - if (request.ask.duration == 0) { + if (request.ask.duration == Duration.wrap(0)) { revert Marketplace_InsufficientDuration(); } if (request.ask.proofProbability == 0) { @@ -160,7 +164,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { if (request.ask.collateralPerByte == 0) { revert Marketplace_InsufficientCollateral(); } - if (request.ask.pricePerBytePerSecond == 0) { + if (request.ask.pricePerBytePerSecond == TokensPerSecond.wrap(0)) { revert Marketplace_InsufficientReward(); } if (bytes(request.content.cid).length == 0) { @@ -170,15 +174,15 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { revert Marketplace_DurationExceedsLimit(); } + Timestamp currentTime = Timestamps.currentTime(); + _requests[id] = request; - _requestContexts[id].endsAt = - uint64(block.timestamp) + - request.ask.duration; - _requestContexts[id].expiresAt = uint64(block.timestamp) + request.expiry; + _requestContexts[id].endsAt = currentTime.add(request.ask.duration); + _requestContexts[id].expiresAt = currentTime.add(request.expiry); _addToMyRequests(request.client, id); - uint128 amount = uint128(request.maxPrice()); + uint128 amount = request.maxPrice(); _requestContexts[id].fundsToReturnToClient = amount; _marketplaceTotals.received += amount; @@ -186,8 +190,8 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { AccountId account = _vault.clientAccount(request.client); _vault.lock( fund, - Timestamp.wrap(uint40(_requestContexts[id].expiresAt)), - Timestamp.wrap(uint40(_requestContexts[id].endsAt)) + _requestContexts[id].expiresAt, + _requestContexts[id].endsAt ); _transferToVault(request.client, fund, account, amount); @@ -227,8 +231,10 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { revert Marketplace_SlotNotFree(); } + Timestamp currentTime = Timestamps.currentTime(); + slot.host = msg.sender; - slot.filledAt = uint64(block.timestamp); + slot.filledAt = currentTime; _startRequiringProofs(slotId); submitProof(slotId, proof); @@ -253,9 +259,10 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { FundId fund = requestId.asFundId(); AccountId clientAccount = _vault.clientAccount(request.client); AccountId hostAccount = _vault.hostAccount(slot.host, slotIndex); + TokensPerSecond rate = request.ask.pricePerSlotPerSecond(); _transferToVault(slot.host, fund, hostAccount, uint128(collateralAmount)); - _vault.flow(fund, clientAccount, hostAccount, request.slotPrice()); + _vault.flow(fund, clientAccount, hostAccount, rate); _marketplaceTotals.received += collateralAmount; slot.currentCollateral = collateralPerSlot; // Even if he has collateral discounted, he is operating with full collateral @@ -270,8 +277,8 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { context.state == RequestState.New // Only New requests can "start" the requests ) { context.state = RequestState.Started; - context.startedAt = uint64(block.timestamp); - _vault.extendLock(fund, Timestamp.wrap(uint40(context.endsAt))); + context.startedAt = currentTime; + _vault.extendLock(fund, context.endsAt); emit RequestFulfilled(requestId); } } @@ -375,10 +382,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { _marketplaceTotals.sent += validatorRewardAmount; FundId fund = slot.requestId.asFundId(); - AccountId hostAccount = _vault.hostAccount( - slot.host, - slot.slotIndex - ); + AccountId hostAccount = _vault.hostAccount(slot.host, slot.slotIndex); AccountId validatorAccount = _vault.validatorAccount(msg.sender); _vault.transfer( fund, @@ -414,18 +418,17 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { Request storage request = _requests[requestId]; FundId fund = requestId.asFundId(); - AccountId hostAccount = _vault.hostAccount( - slot.host, - slot.slotIndex - ); + AccountId hostAccount = _vault.hostAccount(slot.host, slot.slotIndex); AccountId clientAccount = _vault.clientAccount(request.client); - _vault.flow(fund, hostAccount, clientAccount, request.slotPrice()); + TokensPerSecond rate = request.ask.pricePerSlotPerSecond(); + + _vault.flow(fund, hostAccount, clientAccount, rate); _vault.burnAccount(fund, hostAccount); _removeFromMySlots(slot.host, slotId); _reservations[slotId].clear(); // We purge all the reservations for the slot slot.state = SlotState.Repair; - slot.filledAt = 0; + slot.filledAt = Timestamp.wrap(0); slot.currentCollateral = 0; slot.host = address(0); context.slotsFilled -= 1; @@ -594,7 +597,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { _; } - function requestEnd(RequestId requestId) public view returns (uint64) { + function requestEnd(RequestId requestId) public view returns (Timestamp) { RequestState state = requestState(requestId); if ( state == RequestState.New || @@ -606,11 +609,12 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { if (state == RequestState.Cancelled) { return _requestContexts[requestId].expiresAt; } - return - uint64(Math.min(_requestContexts[requestId].endsAt, block.timestamp)); + Timestamp currentTime = Timestamps.currentTime(); + Timestamp end = _requestContexts[requestId].endsAt; + return Timestamps.earliest(end, currentTime); } - function requestExpiry(RequestId requestId) public view returns (uint64) { + function requestExpiry(RequestId requestId) public view returns (Timestamp) { return _requestContexts[requestId].expiresAt; } @@ -618,33 +622,27 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { * @notice Calculates the amount that should be paid out to a host that successfully finished the request * @param requestId RequestId of the request used to calculate the payout * amount. - * @param startingTimestamp timestamp indicating when a host filled a slot and + * @param start timestamp indicating when a host filled a slot and * started providing proofs. */ function _slotPayout( RequestId requestId, - uint64 startingTimestamp + Timestamp start ) private view returns (uint256) { - return - _slotPayout( - requestId, - startingTimestamp, - _requestContexts[requestId].endsAt - ); + return _slotPayout(requestId, start, _requestContexts[requestId].endsAt); } /// @notice Calculates the amount that should be paid out to a host based on the specified time frame. function _slotPayout( RequestId requestId, - uint64 startingTimestamp, - uint64 endingTimestamp + Timestamp start, + Timestamp end ) private view returns (uint256) { Request storage request = _requests[requestId]; - if (startingTimestamp >= endingTimestamp) + if (end <= start) { revert Marketplace_StartNotBeforeExpiry(); - return - (endingTimestamp - startingTimestamp) * - request.ask.pricePerSlotPerSecond(); + } + return request.ask.pricePerSlotPerSecond().accumulate(start.until(end)); } function getHost(SlotId slotId) public view returns (address) { @@ -655,15 +653,15 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { RequestId requestId ) public view requestIsKnown(requestId) returns (RequestState) { RequestContext storage context = _requestContexts[requestId]; + Timestamp currentTime = Timestamps.currentTime(); if ( context.state == RequestState.New && - uint64(block.timestamp) > requestExpiry(requestId) + requestExpiry(requestId) < currentTime ) { return RequestState.Cancelled; } else if ( (context.state == RequestState.Started || - context.state == RequestState.New) && - uint64(block.timestamp) > context.endsAt + context.state == RequestState.New) && context.endsAt < currentTime ) { return RequestState.Finished; } else { @@ -703,7 +701,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian { (request.ask.proofProbability * (256 - _config.proofs.downtime)) / 256; } - event StorageRequested(RequestId requestId, Ask ask, uint64 expiry); + event StorageRequested(RequestId requestId, Ask ask, Timestamp expiry); event RequestFulfilled(RequestId indexed requestId); event RequestFailed(RequestId indexed requestId); event SlotFilled(RequestId indexed requestId, uint64 slotIndex); diff --git a/contracts/Requests.sol b/contracts/Requests.sol index 0f296be..1bebeb3 100644 --- a/contracts/Requests.sol +++ b/contracts/Requests.sol @@ -1,6 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; +import "./Timestamps.sol"; +import "./Tokens.sol"; + type RequestId is bytes32; type SlotId is bytes32; @@ -8,17 +11,17 @@ struct Request { address client; Ask ask; Content content; - uint64 expiry; // amount of seconds since start of the request at which this request expires + Duration expiry; // amount of seconds since start of the request at which this request expires bytes32 nonce; // random nonce to differentiate between similar requests } struct Ask { uint256 proofProbability; // how often storage proofs are required - uint256 pricePerBytePerSecond; // amount of tokens paid per second per byte to hosts + TokensPerSecond pricePerBytePerSecond; // amount of tokens paid per second per byte to hosts uint256 collateralPerByte; // amount of tokens per byte required to be deposited by the hosts in order to fill the slot uint64 slots; // the number of requested slots uint64 slotSize; // amount of storage per slot (in number of bytes) - uint64 duration; // how long content should be stored (in seconds) + Duration duration; // how long content should be stored (in seconds) uint64 maxSlotLoss; // Max slots that can be lost without data considered to be lost } @@ -52,12 +55,14 @@ library AskHelpers { function pricePerSlotPerSecond( Ask memory ask - ) internal pure returns (uint256) { - return ask.pricePerBytePerSecond * ask.slotSize; + ) internal pure returns (TokensPerSecond) { + uint96 perByte = TokensPerSecond.unwrap(ask.pricePerBytePerSecond); + return TokensPerSecond.wrap(perByte * ask.slotSize); } } library Requests { + using Tokens for TokensPerSecond; using AskHelpers for Ask; function id(Request memory request) internal pure returns (RequestId) { @@ -89,10 +94,10 @@ library Requests { } } - function maxPrice(Request memory request) internal pure returns (uint256) { - return - request.ask.slots * - request.ask.duration * - request.ask.pricePerSlotPerSecond(); + function maxPrice(Request memory request) internal pure returns (uint128) { + uint64 slots = request.ask.slots; + TokensPerSecond rate = request.ask.pricePerSlotPerSecond(); + Duration duration = request.ask.duration; + return slots * rate.accumulate(duration); } } diff --git a/contracts/Timestamps.sol b/contracts/Timestamps.sol index dc63fa4..665eea5 100644 --- a/contracts/Timestamps.sol +++ b/contracts/Timestamps.sol @@ -5,8 +5,6 @@ pragma solidity 0.8.28; /// since 1970). Uses a uint40 to facilitate efficient packing in structs. A /// uint40 allows times to be represented for the coming 30 000 years. type Timestamp is uint40; -/// Represents a duration of time in seconds -type Duration is uint40; using {_timestampEquals as ==} for Timestamp global; using {_timestampNotEqual as !=} for Timestamp global; @@ -29,12 +27,39 @@ function _timestampAtMost(Timestamp a, Timestamp b) pure returns (bool) { return Timestamp.unwrap(a) <= Timestamp.unwrap(b); } +/// Represents a duration of time in seconds +type Duration is uint40; + +using {_durationEquals as ==} for Duration global; +using {_durationGreaterThan as >} for Duration global; +using {_durationAtLeast as >=} for Duration global; + +function _durationEquals(Duration a, Duration b) pure returns (bool) { + return Duration.unwrap(a) == Duration.unwrap(b); +} + +function _durationGreaterThan(Duration a, Duration b) pure returns (bool) { + return Duration.unwrap(a) > Duration.unwrap(b); +} + +function _durationAtLeast(Duration a, Duration b) pure returns (bool) { + return Duration.unwrap(a) >= Duration.unwrap(b); +} + library Timestamps { /// Returns the current block timestamp converted to a Timestamp type function currentTime() internal view returns (Timestamp) { return Timestamp.wrap(uint40(block.timestamp)); } + // Adds a duration to a timestamp + function add( + Timestamp begin, + Duration duration + ) internal pure returns (Timestamp) { + return Timestamp.wrap(Timestamp.unwrap(begin) + Duration.unwrap(duration)); + } + /// Calculates the duration from start until end function until( Timestamp start, @@ -42,4 +67,16 @@ library Timestamps { ) internal pure returns (Duration) { return Duration.wrap(Timestamp.unwrap(end) - Timestamp.unwrap(start)); } + + /// Returns the earliest of the two timestamps + function earliest( + Timestamp a, + Timestamp b + ) internal pure returns (Timestamp) { + if (a <= b) { + return a; + } else { + return b; + } + } } diff --git a/contracts/marketplace/VaultHelpers.sol b/contracts/marketplace/VaultHelpers.sol index 19548cc..0cab7da 100644 --- a/contracts/marketplace/VaultHelpers.sol +++ b/contracts/marketplace/VaultHelpers.sol @@ -43,11 +43,4 @@ library VaultHelpers { function asFundId(RequestId requestId) internal pure returns (FundId) { return FundId.wrap(RequestId.unwrap(requestId)); } - - function slotPrice( - Request memory request - ) internal pure returns (TokensPerSecond) { - uint256 price = request.ask.pricePerBytePerSecond * request.ask.slotSize; - return TokensPerSecond.wrap(uint96(price)); - } } diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index 5677b23..53efb9a 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -507,9 +507,9 @@ describe("Marketplace", function () { it("sets the request end time to now + duration", async function () { await marketplace.reserveSlot(slot.request, slot.index) await marketplace.fillSlot(slot.request, slot.index, proof) - await expect( - (await marketplace.requestEnd(requestId(request))).toNumber() - ).to.equal(requestTime + request.ask.duration) + await expect(await marketplace.requestEnd(requestId(request))).to.equal( + requestTime + request.ask.duration + ) }) it("sets request end time to the past once cancelled", async function () { @@ -639,9 +639,7 @@ describe("Marketplace", function () { it("pays the host when contract was cancelled", async function () { // Lets advance the time more into the expiry window const filledAt = (await currentTime()) + Math.floor(request.expiry / 3) - const expiresAt = ( - await marketplace.requestExpiry(requestId(request)) - ).toNumber() + const expiresAt = await marketplace.requestExpiry(requestId(request)) const startBalance = await token.balanceOf(host.address) await marketplace.reserveSlot(slot.request, slot.index) @@ -856,9 +854,7 @@ describe("Marketplace", function () { it("withdraws to the client for cancelled requests lowered by hosts payout", async function () { // Lets advance the time more into the expiry window const filledAt = (await currentTime()) + Math.floor(request.expiry / 3) - const expiresAt = ( - await marketplace.requestExpiry(requestId(request)) - ).toNumber() + const expiresAt = await marketplace.requestExpiry(requestId(request)) await marketplace.reserveSlot(slot.request, slot.index) await setNextBlockTimestamp(filledAt) diff --git a/test/ids.js b/test/ids.js index 4547565..0587946 100644 --- a/test/ids.js +++ b/test/ids.js @@ -2,10 +2,10 @@ const { ethers } = require("hardhat") const { keccak256, defaultAbiCoder } = ethers.utils function requestId(request) { - const Ask = "tuple(uint256, uint256, uint256, uint64, uint64, uint64, int64)" + const Ask = "tuple(uint256, uint96, uint256, uint64, uint64, uint40, uint64)" const Content = "tuple(bytes, bytes32)" const Request = - "tuple(address, " + Ask + ", " + Content + ", uint64, bytes32)" + "tuple(address, " + Ask + ", " + Content + ", uint40, bytes32)" return keccak256(defaultAbiCoder.encode([Request], requestToArray(request))) } diff --git a/test/marketplace.js b/test/marketplace.js index cff1436..82f6cfc 100644 --- a/test/marketplace.js +++ b/test/marketplace.js @@ -4,7 +4,7 @@ const { payoutForDuration } = require("./price") const { collateralPerSlot } = require("./collateral") async function waitUntilCancelled(contract, request) { - const expiry = (await contract.requestExpiry(requestId(request))).toNumber() + const expiry = await contract.requestExpiry(requestId(request)) // We do +1, because the expiry check in contract is done as `>` and not `>=`. await advanceTimeTo(expiry + 1) } @@ -13,7 +13,7 @@ async function waitUntilSlotsFilled(contract, request, proof, token, slots) { let collateral = collateralPerSlot(request) await token.approve(contract.address, collateral * slots.length) - let requestEnd = (await contract.requestEnd(requestId(request))).toNumber() + let requestEnd = await contract.requestEnd(requestId(request)) const payouts = [] for (let slotIndex of slots) { await contract.reserveSlot(requestId(request), slotIndex) @@ -40,7 +40,7 @@ async function waitUntilStarted(contract, request, proof, token) { } async function waitUntilFinished(contract, requestId) { - const end = (await contract.requestEnd(requestId)).toNumber() + const end = await contract.requestEnd(requestId) // We do +1, because the end check in contract is done as `>` and not `>=`. await advanceTimeTo(end + 1) } diff --git a/test/requests.js b/test/requests.js index 53a18d7..16ad7a3 100644 --- a/test/requests.js +++ b/test/requests.js @@ -1,5 +1,4 @@ const { Assertion } = require("chai") -const { currentTime } = require("./evm") const RequestState = { New: 0,