Skip to content

Commit

Permalink
feat: hosts payed by actual time hosting a slot (#160)
Browse files Browse the repository at this point in the history
Co-authored-by: Eric <5089238+emizzle@users.noreply.github.com>
Co-authored-by: r4bbit <445106+0x-r4bbit@users.noreply.github.com>
  • Loading branch information
3 people authored Oct 8, 2024
1 parent f5a54c7 commit 7e6187d
Show file tree
Hide file tree
Showing 7 changed files with 298 additions and 126 deletions.
45 changes: 20 additions & 25 deletions certora/specs/Marketplace.spec
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,22 @@ hook Sstore _requestContexts[KEY MarketplaceHarness.RequestId RequestId].endsAt
| Helper functions |
--------------------------------------------*/

function ensureValidRequestId(Marketplace.RequestId requestId) {
// Without this, the prover will find counter examples with `requestId == 0`,
// which are unlikely in practice as `requestId` is a hash from a request object.
// However, `requestId == 0` enforces `SlotState.Free` in the `fillSlot` function regardless,
// which ultimately results in counter examples where we have a state change
// RequestState.Finished -> RequestState.Started, which is forbidden.
//
// COUNTER EXAMPLE: https://prover.certora.com/output/6199/81939b2b12d74a5cae5e84ceadb901c0?anonymousKey=a4ad6268598a1077ecfce75493b0c0f9bc3b17a0
//
// The `require` below is a hack to ensure we exclude such cases as the code
// reverts in `requestIsKnown()` modifier (simply `require requestId != 0` isn't
// sufficient here)
// require requestId == to_bytes32(0) => currentContract._requests[requestId].client == 0;
require requestId != to_bytes32(0) && currentContract._requests[requestId].client != 0;
}

function canCancelRequest(method f) returns bool {
return f.selector == sig:withdrawFunds(Marketplace.RequestId).selector;
}
Expand Down Expand Up @@ -298,19 +314,7 @@ rule allowedRequestStateChanges(env e, method f) {
// `SlotState.Finished` and `RequestState.New`
requireInvariant finishedSlotAlwaysHasFinishedRequest(e, slotId);

// Without this, the prover will find counter examples with `requestId == 0`,
// which are unlikely in practice as `requestId` is a hash from a request object.
// However, `requestId == 0` enforces `SlotState.Free` in the `fillSlot` function regardless,
// which ultimately results in counter examples where we have a state change
// RequestState.Cancelled -> RequestState.Finished, which is forbidden.
//
// COUNTER EXAMPLE: https://prover.certora.com/output/6199/3a4f410e6367422ba60b218a08c04fae?anonymousKey=0d7003af4ee9bc18c0da0c80a216a6815d397370
//
// The `require` below is a hack to ensure we exclude such cases as the code
// reverts in `requestIsKnown()` modifier (simply `require requestId != 0` isn't
// sufficient here)
require requestId == to_bytes32(0) => currentContract._requests[requestId].client == 0;

ensureValidRequestId(requestId);

Marketplace.RequestState requestStateBefore = currentContract.requestState(e, requestId);

Expand Down Expand Up @@ -387,6 +391,8 @@ rule cancelledRequestsStayCancelled(env e, method f) {
require requestStateBefore == Marketplace.RequestState.Cancelled;
requireInvariant cancelledRequestAlwaysExpired(e, requestId);

ensureValidRequestId(requestId);

f(e, args);
Marketplace.RequestState requestStateAfter = currentContract.requestState(e, requestId);

Expand All @@ -398,18 +404,7 @@ rule finishedRequestsStayFinished(env e, method f) {
calldataarg args;
Marketplace.RequestId requestId;

// Without this, the prover will find counter examples with `requestId == 0`,
// which are unlikely in practice as `requestId` is a hash from a request object.
// However, `requestId == 0` enforces `SlotState.Free` in the `fillSlot` function regardless,
// which ultimately results in counter examples where we have a state change
// RequestState.Finished -> RequestState.Started, which is forbidden.
//
// COUNTER EXAMPLE: https://prover.certora.com/output/6199/81939b2b12d74a5cae5e84ceadb901c0?anonymousKey=a4ad6268598a1077ecfce75493b0c0f9bc3b17a0
//
// The `require` below is a hack to ensure we exclude such cases as the code
// reverts in `requestIsKnown()` modifier (simply `require requestId != 0` isn't
// sufficient here)
require requestId == to_bytes32(0) => currentContract._requests[requestId].client == 0;
ensureValidRequestId(requestId);

Marketplace.RequestState requestStateBefore = currentContract.requestState(e, requestId);
require requestStateBefore == Marketplace.RequestState.Finished;
Expand Down
116 changes: 78 additions & 38 deletions contracts/Marketplace.sol
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,14 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
struct RequestContext {
RequestState state;
uint256 slotsFilled;
/// @notice Tracks how much funds should be returned when Request expires to the Request creator
/// @dev The sum is deducted every time a host fills a Slot by precalculated amount that he should receive if the Request expires
uint256 expiryFundsWithdraw;
/// @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;
uint256 startedAt;
uint256 endsAt;
uint256 expiresAt;
Expand All @@ -41,7 +46,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
SlotState state;
RequestId requestId;
/// @notice Timestamp that signals when slot was filled
/// @dev Used for partial payouts when Requests expires and Hosts are paid out only the time they host the content.
/// @dev Used for calculating payouts as hosts are paid based on time they actually host the content
uint256 filledAt;
uint256 slotIndex;
/// @notice Tracks the current amount of host's collateral that is to be payed out at the end of Slot's lifespan.
Expand Down Expand Up @@ -112,8 +117,8 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {

_addToMyRequests(request.client, id);

uint256 amount = request.price();
_requestContexts[id].expiryFundsWithdraw = amount;
uint256 amount = request.maxPrice();
_requestContexts[id].fundsToReturnToClient = amount;
_marketplaceTotals.received += amount;
_transferFrom(msg.sender, amount);

Expand Down Expand Up @@ -142,6 +147,7 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
Slot storage slot = _slots[slotId];
slot.requestId = requestId;
slot.slotIndex = slotIndex;
RequestContext storage context = _requestContexts[requestId];

require(slotState(slotId) == SlotState.Free, "Slot is not free");

Expand All @@ -151,12 +157,9 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
slot.host = msg.sender;
slot.state = SlotState.Filled;
slot.filledAt = block.timestamp;
RequestContext storage context = _requestContexts[requestId];

context.slotsFilled += 1;
context.expiryFundsWithdraw -= _expiryPayoutAmount(
requestId,
block.timestamp
);
context.fundsToReturnToClient -= _slotPayout(requestId, slot.filledAt);

// Collect collateral
uint256 collateralAmount = request.ask.collateral;
Expand Down Expand Up @@ -288,8 +291,11 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
RequestId requestId = slot.requestId;
RequestContext storage context = _requestContexts[requestId];

_removeFromMySlots(slot.host, slotId);
// 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);

_removeFromMySlots(slot.host, slotId);
uint256 slotIndex = slot.slotIndex;
delete _slots[slotId];
context.slotsFilled -= 1;
Expand All @@ -305,8 +311,6 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
context.state = RequestState.Failed;
context.endsAt = block.timestamp - 1;
emit RequestFailed(requestId);

// TODO: send client remaining funds
}
}

Expand All @@ -319,12 +323,12 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
RequestContext storage context = _requestContexts[requestId];
Request storage request = _requests[requestId];
context.state = RequestState.Finished;
_removeFromMyRequests(request.client, requestId);
Slot storage slot = _slots[slotId];

_removeFromMyRequests(request.client, requestId);
_removeFromMySlots(slot.host, slotId);

uint256 payoutAmount = _requests[requestId].pricePerSlot();
uint256 payoutAmount = _slotPayout(requestId, slot.filledAt);
uint256 collateralAmount = slot.currentCollateral;
_marketplaceTotals.sent += (payoutAmount + collateralAmount);
slot.state = SlotState.Paid;
Expand All @@ -350,7 +354,11 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
Slot storage slot = _slots[slotId];
_removeFromMySlots(slot.host, slotId);

uint256 payoutAmount = _expiryPayoutAmount(requestId, slot.filledAt);
uint256 payoutAmount = _slotPayout(
requestId,
slot.filledAt,
requestExpiry(requestId)
);
uint256 collateralAmount = slot.currentCollateral;
_marketplaceTotals.sent += (payoutAmount + collateralAmount);
slot.state = SlotState.Paid;
Expand All @@ -361,8 +369,8 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
/**
* @notice Withdraws remaining storage request funds back to the client that
deposited them.
* @dev Request must be expired, must be in RequestStat e.New, and the
transaction must originate from the depositer address.
* @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 {
Expand All @@ -381,24 +389,46 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
address withdrawRecipient
) public {
Request storage request = _requests[requestId];
require(
block.timestamp > requestExpiry(requestId),
"Request not yet timed out"
);
require(request.client == msg.sender, "Invalid client address");
RequestContext storage context = _requestContexts[requestId];
require(context.state == RequestState.New, "Invalid state");
RequestState state = requestState(requestId);
require(
state == RequestState.Cancelled ||
state == RequestState.Failed ||
state == RequestState.Finished,
"Invalid state"
);

// Update request state to Cancelled. Handle in the withdraw transaction
// as there needs to be someone to pay for the gas to update the state
context.state = RequestState.Cancelled;
_removeFromMyRequests(request.client, requestId);
// fundsToReturnToClient == 0 is used for "double-spend" protection, once the funds are withdrawn
// then this variable is set to 0.
require(context.fundsToReturnToClient != 0, "Nothing to withdraw");

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

emit RequestCancelled(requestId);
_removeFromMyRequests(request.client, requestId);

uint256 amount = context.expiryFundsWithdraw;
uint256 amount = context.fundsToReturnToClient;
_marketplaceTotals.sent += amount;
assert(_token.transfer(withdrawRecipient, amount));

// We zero out the funds tracking in order to prevent double-spends
context.fundsToReturnToClient = 0;
}

function getActiveSlot(
Expand Down Expand Up @@ -442,24 +472,34 @@ contract Marketplace is SlotReservations, Proofs, StateRetrieval, Endian {
}

/**
* @notice Calculates the amount that should be paid out to a host if a request
* expires based on when the host fills the slot
* @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 _expiryPayoutAmount(
function _slotPayout(
RequestId requestId,
uint256 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,
uint256 startingTimestamp,
uint256 endingTimestamp
) private view returns (uint256) {
Request storage request = _requests[requestId];
require(
startingTimestamp < requestExpiry(requestId),
"Start not before expiry"
);
require(startingTimestamp < endingTimestamp, "Start not before expiry");

return (requestExpiry(requestId) - startingTimestamp) * request.ask.reward;
return (endingTimestamp - startingTimestamp) * request.ask.reward;
}

function getHost(SlotId slotId) public view returns (address) {
Expand Down
10 changes: 2 additions & 8 deletions contracts/Requests.sol
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,7 @@ library Requests {
}
}

function pricePerSlot(
Request memory request
) internal pure returns (uint256) {
return request.ask.duration * request.ask.reward;
}

function price(Request memory request) internal pure returns (uint256) {
return request.ask.slots * pricePerSlot(request);
function maxPrice(Request memory request) internal pure returns (uint256) {
return request.ask.slots * request.ask.duration * request.ask.reward;
}
}
Loading

0 comments on commit 7e6187d

Please sign in to comment.