marketplace: use Timestamp, Duration and TokensPerSecond types

This commit is contained in:
Mark Spanbroek 2025-02-26 12:26:04 +01:00
parent 284b54e575
commit 83a59d8227
10 changed files with 116 additions and 87 deletions

View File

@ -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 {

View File

@ -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()

View File

@ -50,6 +50,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;
@ -71,9 +73,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 {
@ -82,7 +84,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.
@ -145,12 +147,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) {
@ -159,7 +163,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) {
@ -169,15 +173,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;
@ -185,8 +189,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);
@ -229,8 +233,10 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
_startRequiringProofs(slotId);
submitProof(slotId, proof);
Timestamp currentTime = Timestamps.currentTime();
slot.host = msg.sender;
slot.filledAt = uint64(block.timestamp);
slot.filledAt = currentTime;
context.slotsFilled += 1;
context.fundsToReturnToClient -= _slotPayout(requestId, slot.filledAt);
@ -252,9 +258,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
@ -269,8 +276,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);
}
}
@ -361,10 +368,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,
@ -400,18 +404,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);
delete _reservations[slotId]; // 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;
@ -574,7 +577,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
return _slots[slotId].state == SlotState.Free;
}
function requestEnd(RequestId requestId) public view returns (uint64) {
function requestEnd(RequestId requestId) public view returns (Timestamp) {
RequestState state = requestState(requestId);
if (
state == RequestState.New ||
@ -586,11 +589,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;
}
@ -598,33 +602,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) {
@ -635,15 +633,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 {
@ -681,7 +679,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);

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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));
}
}

View File

@ -499,9 +499,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 () {
@ -605,9 +605,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)
@ -822,9 +820,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)

View File

@ -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)))
}

View File

@ -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)
}

View File

@ -1,5 +1,4 @@
const { Assertion } = require("chai")
const { currentTime } = require("./evm")
const RequestState = {
New: 0,