feat: adds an optional `payoutAddress` to allow payouts to be paid to separate address (#144)

* initial commit for splitting payouts

Collateral goes to slot's host address, while reward payouts go to the slot's host payoutAddress

* Add fillSlot overload to make payoutAddress "optional"

* add tests for payoutAddress

* add doc to patchFillSlotOverloads

* formatting

* remove optional payoutAddress parameter

* Move payoutAddress to freeSlot

- remove payoutAddress parameter from `fillSlot`
- remove `payoutAddress` from slot struct and storage
- add payoutAddress parameter to `freeSlot`, preventing the need for storage

* formatting

* update certora spec to match updated function signature

* Add withdrawAddress to withdrawFunds

- prevent erc20 msg.sender blacklisting

* Update tests for paying out to withdrawAddress

* formatting

* Add collateralRecipient

* refactor: change withdrawFunds and freeSlot overloads

- `withdrawFunds` now has an option withdrawRecipient parameter
- `freeSlot` now has two optional parameters: rewardRecipient, and collateralRecipient. Both or none must be specified.

* update certora spec for new sigs
This commit is contained in:
Eric 2024-08-19 09:09:48 +02:00 committed by GitHub
parent 29f39d52c7
commit 73a2ca0bd3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 299 additions and 49 deletions

View File

@ -111,12 +111,12 @@ function canStartRequest(method f) returns bool {
}
function canFinishRequest(method f) returns bool {
return f.selector == sig:freeSlot(Marketplace.SlotId).selector;
return f.selector == sig:freeSlot(Marketplace.SlotId, address, address).selector;
}
function canFailRequest(method f) returns bool {
return f.selector == sig:markProofAsMissing(Marketplace.SlotId, Periods.Period).selector ||
f.selector == sig:freeSlot(Marketplace.SlotId).selector;
f.selector == sig:freeSlot(Marketplace.SlotId, address, address).selector;
}
/*--------------------------------------------

View File

@ -46,7 +46,7 @@ contract Marketplace is Proofs, StateRetrieval, Endian {
/// @dev When Slot is filled, the collateral is collected in amount of request.ask.collateral
/// @dev When Host is slashed for missing a proof the slashed amount is reflected in this variable
uint256 currentCollateral;
address host;
address host; // address used for collateral interactions and identifying hosts
}
struct ActiveSlot {
@ -114,6 +114,14 @@ contract Marketplace is Proofs, StateRetrieval, Endian {
emit StorageRequested(id, request.ask, _requestContexts[id].expiresAt);
}
/**
* @notice Fills a slot. Reverts if an invalid proof of the slot data is
provided.
* @param requestId RequestId identifying the request containing the slot to
fill.
* @param slotIndex Index of the slot in the request.
* @param proof Groth16 proof procing possession of the slot data.
*/
function fillSlot(
RequestId requestId,
uint256 slotIndex,
@ -158,19 +166,48 @@ contract Marketplace is Proofs, StateRetrieval, Endian {
}
}
/**
* @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.
*/
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];
require(slot.host == msg.sender, "Slot filled by other host");
SlotState state = slotState(slotId);
require(state != SlotState.Paid, "Already paid");
if (state == SlotState.Finished) {
_payoutSlot(slot.requestId, slotId);
_payoutSlot(slot.requestId, slotId, rewardRecipient, collateralRecipient);
} else if (state == SlotState.Cancelled) {
_payoutCancelledSlot(slot.requestId, slotId);
_payoutCancelledSlot(
slot.requestId,
slotId,
rewardRecipient,
collateralRecipient
);
} else if (state == SlotState.Failed) {
_removeFromMySlots(msg.sender, slotId);
} else if (state == SlotState.Filled) {
// free slot without returning collateral, effectively a 100% slash
_forciblyFreeSlot(slotId);
}
}
@ -231,6 +268,13 @@ contract Marketplace is 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.
*/
function _forciblyFreeSlot(SlotId slotId) internal {
Slot storage slot = _slots[slotId];
RequestId requestId = slot.requestId;
@ -260,7 +304,9 @@ contract Marketplace is Proofs, StateRetrieval, Endian {
function _payoutSlot(
RequestId requestId,
SlotId slotId
SlotId slotId,
address rewardRecipient,
address collateralRecipient
) private requestIsKnown(requestId) {
RequestContext storage context = _requestContexts[requestId];
Request storage request = _requests[requestId];
@ -270,31 +316,62 @@ contract Marketplace is Proofs, StateRetrieval, Endian {
_removeFromMySlots(slot.host, slotId);
uint256 amount = _requests[requestId].pricePerSlot() +
slot.currentCollateral;
_marketplaceTotals.sent += amount;
uint256 payoutAmount = _requests[requestId].pricePerSlot();
uint256 collateralAmount = slot.currentCollateral;
_marketplaceTotals.sent += (payoutAmount + collateralAmount);
slot.state = SlotState.Paid;
assert(_token.transfer(slot.host, amount));
assert(_token.transfer(rewardRecipient, payoutAmount));
assert(_token.transfer(collateralRecipient, collateralAmount));
}
/**
* @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(
RequestId requestId,
SlotId slotId
SlotId slotId,
address rewardRecipient,
address collateralRecipient
) private requestIsKnown(requestId) {
Slot storage slot = _slots[slotId];
_removeFromMySlots(slot.host, slotId);
uint256 amount = _expiryPayoutAmount(requestId, slot.filledAt) +
slot.currentCollateral;
_marketplaceTotals.sent += amount;
uint256 payoutAmount = _expiryPayoutAmount(requestId, slot.filledAt);
uint256 collateralAmount = slot.currentCollateral;
_marketplaceTotals.sent += (payoutAmount + collateralAmount);
slot.state = SlotState.Paid;
assert(_token.transfer(slot.host, amount));
assert(_token.transfer(rewardRecipient, payoutAmount));
assert(_token.transfer(collateralRecipient, collateralAmount));
}
/// @notice Withdraws storage request funds back to the client that deposited them.
/// @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
/**
* @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.
* @param requestId the id of the request
*/
function withdrawFunds(RequestId requestId) public {
withdrawFunds(requestId, msg.sender);
}
/**
* @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 {
Request storage request = _requests[requestId];
require(
block.timestamp > requestExpiry(requestId),
@ -313,7 +390,7 @@ contract Marketplace is Proofs, StateRetrieval, Endian {
uint256 amount = context.expiryFundsWithdraw;
_marketplaceTotals.sent += amount;
assert(_token.transfer(msg.sender, amount));
assert(_token.transfer(withdrawRecipient, amount));
}
function getActiveSlot(
@ -356,7 +433,14 @@ contract Marketplace is Proofs, StateRetrieval, Endian {
return _requestContexts[requestId].expiresAt;
}
/// @notice Calculates the amount that should be payed 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 if a request
* expires based on when the host fills the slot
* @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(
RequestId requestId,
uint256 startingTimestamp

View File

@ -21,6 +21,7 @@ const {
waitUntilFinished,
waitUntilFailed,
waitUntilSlotFailed,
patchOverloads,
} = require("./marketplace")
const { price, pricePerSlot } = require("./price")
const {
@ -87,7 +88,14 @@ describe("Marketplace", function () {
let marketplace
let token
let verifier
let client, host, host1, host2, host3
let client,
clientWithdrawRecipient,
host,
host1,
host2,
host3,
hostRewardRecipient,
hostCollateralRecipient
let request
let slot
@ -96,12 +104,28 @@ describe("Marketplace", function () {
beforeEach(async function () {
await snapshot()
await ensureMinimumBlockHeight(256)
;[client, host1, host2, host3] = await ethers.getSigners()
;[
client,
clientWithdrawRecipient,
host1,
host2,
host3,
hostRewardRecipient,
hostCollateralRecipient,
] = await ethers.getSigners()
host = host1
const TestToken = await ethers.getContractFactory("TestToken")
token = await TestToken.deploy()
for (let account of [client, host1, host2, host3]) {
for (let account of [
client,
clientWithdrawRecipient,
host1,
host2,
host3,
hostRewardRecipient,
hostCollateralRecipient,
]) {
await token.mint(account.address, ACCOUNT_STARTING_BALANCE)
}
@ -114,6 +138,7 @@ describe("Marketplace", function () {
token.address,
verifier.address
)
patchOverloads(marketplace)
request = await exampleRequest()
request.client = client.address
@ -131,6 +156,7 @@ describe("Marketplace", function () {
function switchAccount(account) {
token = token.connect(account)
marketplace = marketplace.connect(account)
patchOverloads(marketplace)
}
describe("requesting storage", function () {
@ -481,6 +507,37 @@ describe("Marketplace", function () {
)
})
it("pays to host reward address when contract has finished and returns collateral to host collateral address", 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
)
expect(endBalanceHost).to.equal(startBalanceHost)
expect(endBalanceCollateral - startBalanceCollateral).to.equal(
request.ask.collateral
)
expect(endBalanceReward - startBalanceReward).to.equal(
pricePerSlot(request)
)
})
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)
@ -500,12 +557,69 @@ describe("Marketplace", function () {
)
})
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()
await advanceTimeToForNextBlock(filledAt)
await marketplace.fillSlot(slot.request, slot.index, proof)
await waitUntilCancelled(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 expectedPartialPayout = (expiresAt - filledAt) * request.ask.reward
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(
request.ask.collateral
)
})
it("does not pay when the contract hasn't ended", async function () {
await marketplace.fillSlot(slot.request, slot.index, proof)
const startBalance = await token.balanceOf(host.address)
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))
const endBalance = await token.balanceOf(host.address)
expect(endBalance).to.equal(startBalance)
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)
})
it("can only be done once", async function () {
@ -586,16 +700,16 @@ describe("Marketplace", function () {
it("rejects withdraw when request not yet timed out", async function () {
switchAccount(client)
await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith(
"Request not yet timed out"
)
await expect(
marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address)
).to.be.revertedWith("Request not yet timed out")
})
it("rejects withdraw when wrong account used", async function () {
await waitUntilCancelled(request)
await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith(
"Invalid client address"
)
await expect(
marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address)
).to.be.revertedWith("Invalid client address")
})
it("rejects withdraw when in wrong state", async function () {
@ -610,29 +724,41 @@ describe("Marketplace", function () {
}
await waitUntilCancelled(request)
switchAccount(client)
await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith(
"Invalid state"
)
await expect(
marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address)
).to.be.revertedWith("Invalid state")
})
it("emits event once request is cancelled", async function () {
await waitUntilCancelled(request)
switchAccount(client)
await expect(marketplace.withdrawFunds(slot.request))
await expect(
marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address)
)
.to.emit(marketplace, "RequestCancelled")
.withArgs(requestId(request))
})
it("withdraws to the client", async function () {
it("withdraws to the client payout address", async function () {
await waitUntilCancelled(request)
switchAccount(client)
const startBalance = await token.balanceOf(client.address)
await marketplace.withdrawFunds(slot.request)
const endBalance = await token.balanceOf(client.address)
expect(endBalance - startBalance).to.equal(price(request))
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(price(request))
})
it("withdraws to the client for cancelled requests lowered by hosts payout", async function () {
it("withdraws to the client payout address 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 = (
@ -642,14 +768,17 @@ describe("Marketplace", function () {
await advanceTimeToForNextBlock(filledAt)
await marketplace.fillSlot(slot.request, slot.index, proof)
await waitUntilCancelled(request)
const expectedPartialHostPayout =
const expectedPartialhostRewardRecipient =
(expiresAt - filledAt) * request.ask.reward
switchAccount(client)
await marketplace.withdrawFunds(slot.request)
const endBalance = await token.balanceOf(client.address)
expect(ACCOUNT_STARTING_BALANCE - endBalance).to.equal(
expectedPartialHostPayout
await marketplace.withdrawFunds(
slot.request,
clientWithdrawRecipient.address
)
const endBalance = await token.balanceOf(clientWithdrawRecipient.address)
expect(endBalance - ACCOUNT_STARTING_BALANCE).to.equal(
price(request) - expectedPartialhostRewardRecipient
)
})
})
@ -678,7 +807,10 @@ describe("Marketplace", function () {
it("remains 'Cancelled' when client withdraws funds", async function () {
await waitUntilCancelled(request)
switchAccount(client)
await marketplace.withdrawFunds(slot.request)
await marketplace.withdrawFunds(
slot.request,
clientWithdrawRecipient.address
)
expect(await marketplace.requestState(slot.request)).to.equal(Cancelled)
})
@ -1031,7 +1163,10 @@ describe("Marketplace", function () {
it("removes request from list when funds are withdrawn", async function () {
await marketplace.requestStorage(request)
await waitUntilCancelled(request)
await marketplace.withdrawFunds(requestId(request))
await marketplace.withdrawFunds(
requestId(request),
clientWithdrawRecipient.address
)
expect(await marketplace.myRequests()).to.deep.equal([])
})

View File

@ -49,10 +49,41 @@ async function waitUntilSlotFailed(contract, request, slot) {
}
}
function patchOverloads(contract) {
contract.freeSlot = async (slotId, rewardRecipient, collateralRecipient) => {
const logicalXor = (a, b) => (a || b) && !(a && b)
if (logicalXor(rewardRecipient, collateralRecipient)) {
// XOR, if exactly one is truthy
throw new Error(
"Invalid freeSlot overload, you must specify both `rewardRecipient` and `collateralRecipient` or neither."
)
}
if (!rewardRecipient && !collateralRecipient) {
// calls `freeSlot` overload without `rewardRecipient` and `collateralRecipient`
const fn = contract["freeSlot(bytes32)"]
return await fn(slotId)
}
const fn = contract["freeSlot(bytes32,address,address)"]
return await fn(slotId, rewardRecipient, collateralRecipient)
}
contract.withdrawFunds = async (requestId, withdrawRecipient) => {
if (!withdrawRecipient) {
// calls `withdrawFunds` overload without `withdrawRecipient`
const fn = contract["withdrawFunds(bytes32)"]
return await fn(requestId)
}
const fn = contract["withdrawFunds(bytes32,address)"]
return await fn(requestId, withdrawRecipient)
}
}
module.exports = {
waitUntilCancelled,
waitUntilStarted,
waitUntilFinished,
waitUntilFailed,
waitUntilSlotFailed,
patchOverloads,
}