feat: hosts payed by actual time hosting a slot (#160)

Co-authored-by: Eric <5089238+emizzle@users.noreply.github.com>
Co-authored-by: r4bbit <445106+0x-r4bbit@users.noreply.github.com>
This commit is contained in:
Adam Uhlíř 2024-10-08 09:38:19 +02:00 committed by GitHub
parent f5a54c7ed4
commit 7e6187d4b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 299 additions and 127 deletions

View File

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

View File

@ -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;
@ -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.
@ -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);
@ -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");
@ -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;
@ -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;
@ -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
}
}
@ -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;
@ -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;
@ -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 {
@ -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"
);
// 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;
}
// 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);
emit RequestCancelled(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(
@ -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) {
Request storage request = _requests[requestId];
require(
startingTimestamp < requestExpiry(requestId),
"Start not before expiry"
);
return
_slotPayout(
requestId,
startingTimestamp,
_requestContexts[requestId].endsAt
);
}
return (requestExpiry(requestId) - startingTimestamp) * request.ask.reward;
/// @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 < endingTimestamp, "Start not before expiry");
return (endingTimestamp - startingTimestamp) * request.ask.reward;
}
function getHost(SlotId slotId) public view returns (address) {

View File

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

View File

@ -23,7 +23,7 @@ const {
waitUntilSlotFailed,
patchOverloads,
} = require("./marketplace")
const { price, pricePerSlot } = require("./price")
const { maxPrice, payoutForDuration } = require("./price")
const {
snapshot,
revert,
@ -165,7 +165,7 @@ describe("Marketplace", function () {
})
it("emits event when storage is requested", async function () {
await token.approve(marketplace.address, price(request))
await token.approve(marketplace.address, maxPrice(request))
// We +1 second to the expiry because the time will advance with the mined transaction for requestStorage because of Hardhat
const expectedExpiry = (await currentTime()) + request.expiry + 1
@ -175,7 +175,7 @@ describe("Marketplace", function () {
})
it("allows retrieval of request details", async function () {
await token.approve(marketplace.address, price(request))
await token.approve(marketplace.address, maxPrice(request))
await marketplace.requestStorage(request)
const id = requestId(request)
expect(await marketplace.getRequest(id)).to.be.request(request)
@ -183,14 +183,14 @@ describe("Marketplace", function () {
it("rejects request with invalid client address", async function () {
let invalid = { ...request, client: host.address }
await token.approve(marketplace.address, price(invalid))
await token.approve(marketplace.address, maxPrice(invalid))
await expect(marketplace.requestStorage(invalid)).to.be.revertedWith(
"Invalid client address"
)
})
it("rejects request with insufficient payment", async function () {
let insufficient = price(request) - 1
let insufficient = maxPrice(request) - 1
await token.approve(marketplace.address, insufficient)
await expect(marketplace.requestStorage(request)).to.be.revertedWith(
"ERC20: insufficient allowance"
@ -198,7 +198,7 @@ describe("Marketplace", function () {
})
it("rejects request when expiry out of bounds", async function () {
await token.approve(marketplace.address, price(request))
await token.approve(marketplace.address, maxPrice(request))
request.expiry = request.ask.duration + 1
await expect(marketplace.requestStorage(request)).to.be.revertedWith(
@ -226,7 +226,7 @@ describe("Marketplace", function () {
})
it("rejects resubmission of request", async function () {
await token.approve(marketplace.address, price(request) * 2)
await token.approve(marketplace.address, maxPrice(request) * 2)
await marketplace.requestStorage(request)
await expect(marketplace.requestStorage(request)).to.be.revertedWith(
"Request already exists"
@ -237,7 +237,7 @@ describe("Marketplace", function () {
describe("filling a slot with collateral", function () {
beforeEach(async function () {
switchAccount(client)
await token.approve(marketplace.address, price(request))
await token.approve(marketplace.address, maxPrice(request))
await marketplace.requestStorage(request)
switchAccount(host)
await token.approve(marketplace.address, request.ask.collateral)
@ -296,7 +296,7 @@ describe("Marketplace", function () {
it("is rejected when request is cancelled", async function () {
switchAccount(client)
let expired = { ...request, expiry: hours(1) + 1 }
await token.approve(marketplace.address, price(request))
await token.approve(marketplace.address, maxPrice(request))
await marketplace.requestStorage(expired)
await waitUntilCancelled(expired)
switchAccount(host)
@ -335,7 +335,7 @@ describe("Marketplace", function () {
marketplace.address,
request.ask.collateral * lastSlot
)
await token.approve(marketplace.address, price(request) * lastSlot)
await token.approve(marketplace.address, maxPrice(request) * lastSlot)
for (let i = 0; i <= lastSlot; i++) {
await marketplace.reserveSlot(slot.request, i)
await marketplace.fillSlot(slot.request, i, proof)
@ -355,7 +355,7 @@ describe("Marketplace", function () {
describe("filling slot without collateral", function () {
beforeEach(async function () {
switchAccount(client)
await token.approve(marketplace.address, price(request))
await token.approve(marketplace.address, maxPrice(request))
await marketplace.requestStorage(request)
switchAccount(host)
})
@ -382,7 +382,7 @@ describe("Marketplace", function () {
describe("submitting proofs when slot is filled", function () {
beforeEach(async function () {
switchAccount(client)
await token.approve(marketplace.address, price(request))
await token.approve(marketplace.address, maxPrice(request))
await marketplace.requestStorage(request)
switchAccount(host)
await token.approve(marketplace.address, request.ask.collateral)
@ -419,7 +419,7 @@ describe("Marketplace", function () {
var requestTime
beforeEach(async function () {
switchAccount(client)
await token.approve(marketplace.address, price(request))
await token.approve(marketplace.address, maxPrice(request))
await marketplace.requestStorage(request)
requestTime = await currentTime()
switchAccount(host)
@ -477,7 +477,7 @@ describe("Marketplace", function () {
id = slotId(slot)
switchAccount(client)
await token.approve(marketplace.address, price(request))
await token.approve(marketplace.address, maxPrice(request))
await marketplace.requestStorage(request)
switchAccount(host)
await token.approve(marketplace.address, request.ask.collateral)
@ -515,52 +515,84 @@ describe("Marketplace", function () {
describe("paying out a slot", function () {
beforeEach(async function () {
switchAccount(client)
await token.approve(marketplace.address, price(request))
await token.approve(marketplace.address, maxPrice(request))
await marketplace.requestStorage(request)
switchAccount(host)
await token.approve(marketplace.address, request.ask.collateral)
})
it("pays the host when contract has finished and returns collateral", async function () {
await waitUntilStarted(marketplace, request, proof, token)
it("finished request pays out reward 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.
await advanceTimeForNextBlock(request.expiry / 2)
const expectedPayouts = await waitUntilStarted(
marketplace,
request,
proof,
token
)
await waitUntilFinished(marketplace, requestId(request))
const startBalance = await token.balanceOf(host.address)
const startBalanceHost = await token.balanceOf(host.address)
await marketplace.freeSlot(slotId(slot))
const endBalance = await token.balanceOf(host.address)
expect(endBalance - startBalance).to.equal(
pricePerSlot(request) + request.ask.collateral
const endBalanceHost = await token.balanceOf(host.address)
expect(expectedPayouts[slot.index]).to.be.lt(maxPrice(request))
expect(endBalanceHost - startBalanceHost).to.equal(
expectedPayouts[slot.index] + request.ask.collateral
)
})
it("pays to host reward address when contract has finished and returns collateral to host collateral address", async function () {
it("returns collateral to host collateral address if specified", async function () {
await waitUntilStarted(marketplace, request, proof, token)
await waitUntilFinished(marketplace, requestId(request))
const startBalanceHost = await token.balanceOf(host.address)
const startBalanceReward = await token.balanceOf(
hostRewardRecipient.address
)
const startBalanceCollateral = await token.balanceOf(
hostCollateralRecipient.address
)
await marketplace.freeSlot(
slotId(slot),
hostRewardRecipient.address,
hostCollateralRecipient.address
)
const endBalanceHost = await token.balanceOf(host.address)
const endBalanceReward = await token.balanceOf(
hostRewardRecipient.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(
request.ask.collateral
)
expect(endBalanceReward - startBalanceReward).to.equal(
pricePerSlot(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 () {
@ -671,7 +703,7 @@ describe("Marketplace", function () {
describe("fulfilling a request", function () {
beforeEach(async function () {
switchAccount(client)
await token.approve(marketplace.address, price(request))
await token.approve(marketplace.address, maxPrice(request))
await marketplace.requestStorage(request)
switchAccount(host)
await token.approve(marketplace.address, request.ask.collateral)
@ -724,7 +756,7 @@ describe("Marketplace", function () {
describe("withdrawing funds", function () {
beforeEach(async function () {
switchAccount(client)
await token.approve(marketplace.address, price(request))
await token.approve(marketplace.address, maxPrice(request))
await marketplace.requestStorage(request)
switchAccount(host)
await token.approve(marketplace.address, request.ask.collateral)
@ -734,7 +766,7 @@ describe("Marketplace", function () {
switchAccount(client)
await expect(
marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address)
).to.be.revertedWith("Request not yet timed out")
).to.be.revertedWith("Invalid state")
})
it("rejects withdraw when wrong account used", async function () {
@ -762,6 +794,20 @@ describe("Marketplace", function () {
).to.be.revertedWith("Invalid state")
})
it("rejects withdraw when already withdrawn", 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("Nothing to withdraw")
})
it("emits event once request is cancelled", async function () {
await waitUntilCancelled(request)
switchAccount(client)
@ -772,7 +818,37 @@ describe("Marketplace", function () {
.withArgs(requestId(request))
})
it("withdraws to the client payout address", async function () {
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(
request.expiry * request.ask.reward
)
})
it("withdraws to the client payout address when request is cancelled", async function () {
await waitUntilCancelled(request)
switchAccount(client)
const startBalanceClient = await token.balanceOf(client.address)
@ -788,7 +864,31 @@ describe("Marketplace", function () {
clientWithdrawRecipient.address
)
expect(endBalanceClient).to.equal(startBalanceClient)
expect(endBalancePayout - startBalancePayout).to.equal(price(request))
expect(endBalancePayout - startBalancePayout).to.equal(maxPrice(request))
})
it("withdraws full price for failed requests to the client payout address", async function () {
await waitUntilStarted(marketplace, request, proof, token)
await waitUntilFailed(marketplace, request)
switchAccount(client)
const startBalanceClient = await token.balanceOf(client.address)
const startBalancePayout = await token.balanceOf(
clientWithdrawRecipient.address
)
await marketplace.withdrawFunds(
slot.request,
clientWithdrawRecipient.address
)
const 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))
})
it("withdraws to the client payout address for cancelled requests lowered by hosts payout", async function () {
@ -812,7 +912,29 @@ describe("Marketplace", function () {
)
const endBalance = await token.balanceOf(clientWithdrawRecipient.address)
expect(endBalance - ACCOUNT_STARTING_BALANCE).to.equal(
price(request) - expectedPartialhostRewardRecipient
maxPrice(request) - expectedPartialhostRewardRecipient
)
})
it("when slot is freed and not repaired, client will get refunded the freed slot's funds", async function () {
const payouts = await waitUntilStarted(marketplace, request, proof, token)
await expect(marketplace.freeSlot(slotId(slot))).to.emit(
marketplace,
"SlotFreed"
)
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
)
})
})
@ -822,7 +944,7 @@ describe("Marketplace", function () {
beforeEach(async function () {
switchAccount(client)
await token.approve(marketplace.address, price(request))
await token.approve(marketplace.address, maxPrice(request))
await marketplace.requestStorage(request)
switchAccount(host)
await token.approve(marketplace.address, request.ask.collateral)
@ -901,7 +1023,7 @@ describe("Marketplace", function () {
;({ periodOf, periodEnd } = periodic(period))
switchAccount(client)
await token.approve(marketplace.address, price(request))
await token.approve(marketplace.address, maxPrice(request))
await marketplace.requestStorage(request)
switchAccount(host)
await token.approve(marketplace.address, request.ask.collateral)
@ -988,7 +1110,7 @@ describe("Marketplace", function () {
;({ periodOf, periodEnd } = periodic(period))
switchAccount(client)
await token.approve(marketplace.address, price(request))
await token.approve(marketplace.address, maxPrice(request))
await marketplace.requestStorage(request)
switchAccount(host)
await token.approve(marketplace.address, request.ask.collateral)
@ -1079,7 +1201,7 @@ describe("Marketplace", function () {
;({ periodOf, periodEnd } = periodic(period))
switchAccount(client)
await token.approve(marketplace.address, price(request))
await token.approve(marketplace.address, maxPrice(request))
await marketplace.requestStorage(request)
switchAccount(host)
await token.approve(marketplace.address, request.ask.collateral)
@ -1190,7 +1312,7 @@ describe("Marketplace", function () {
switchAccount(host)
await token.approve(marketplace.address, request.ask.collateral)
switchAccount(client)
await token.approve(marketplace.address, price(request))
await token.approve(marketplace.address, maxPrice(request))
})
it("adds request to list when requesting storage", async function () {
@ -1239,7 +1361,7 @@ describe("Marketplace", function () {
describe("list of active slots", function () {
beforeEach(async function () {
switchAccount(client)
await token.approve(marketplace.address, price(request))
await token.approve(marketplace.address, maxPrice(request))
await marketplace.requestStorage(request)
switchAccount(host)
await token.approve(marketplace.address, request.ask.collateral)

View File

@ -1,6 +1,6 @@
const { advanceTimeToForNextBlock, currentTime } = require("./evm")
const { slotId, requestId } = require("./ids")
const { price } = require("./price")
const { maxPrice, payoutForDuration } = require("./price")
/**
* @dev This will not advance the time right on the "expiry threshold" but will most probably "overshoot it"
@ -14,13 +14,33 @@ async function waitUntilCancelled(request) {
await advanceTimeToForNextBlock((await currentTime()) + request.expiry + 1)
}
async function waitUntilStarted(contract, request, proof, token) {
await token.approve(contract.address, price(request) * request.ask.slots)
async function waitUntilSlotsFilled(contract, request, proof, token, slots) {
await token.approve(contract.address, request.ask.collateral * slots.length)
for (let i = 0; i < request.ask.slots; i++) {
await contract.reserveSlot(requestId(request), i)
await contract.fillSlot(requestId(request), i, proof)
let requestEnd = (await contract.requestEnd(requestId(request))).toNumber()
const payouts = []
for (let slotIndex of slots) {
await contract.reserveSlot(requestId(request), slotIndex)
await contract.fillSlot(requestId(request), slotIndex, proof)
payouts[slotIndex] = payoutForDuration(
request,
await currentTime(),
requestEnd
)
}
return payouts
}
async function waitUntilStarted(contract, request, proof, token) {
return waitUntilSlotsFilled(
contract,
request,
proof,
token,
Array.from({ length: request.ask.slots }, (_, i) => i)
)
}
async function waitUntilFinished(contract, requestId) {
@ -83,6 +103,7 @@ function patchOverloads(contract) {
module.exports = {
waitUntilCancelled,
waitUntilStarted,
waitUntilSlotsFilled,
waitUntilFinished,
waitUntilFailed,
waitUntilSlotFailed,

View File

@ -1,9 +1,9 @@
function price(request) {
return request.ask.slots * pricePerSlot(request)
function maxPrice(request) {
return request.ask.slots * request.ask.duration * request.ask.reward
}
function pricePerSlot(request) {
return request.ask.duration * request.ask.reward
function payoutForDuration(request, start, end) {
return (end - start) * request.ask.reward
}
module.exports = { price, pricePerSlot }
module.exports = { maxPrice, payoutForDuration }

View File

@ -18,7 +18,7 @@ const SlotState = {
Cancelled: 5,
}
const enableRequestAssertions = function () {
function enableRequestAssertions() {
// language chain method
Assertion.addMethod("request", function (request) {
var actual = this._obj