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 { 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 { function canFailRequest(method f) returns bool {
return f.selector == sig:markProofAsMissing(Marketplace.SlotId, Periods.Period).selector || 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 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 /// @dev When Host is slashed for missing a proof the slashed amount is reflected in this variable
uint256 currentCollateral; uint256 currentCollateral;
address host; address host; // address used for collateral interactions and identifying hosts
} }
struct ActiveSlot { struct ActiveSlot {
@ -114,6 +114,14 @@ contract Marketplace is Proofs, StateRetrieval, Endian {
emit StorageRequested(id, request.ask, _requestContexts[id].expiresAt); 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( function fillSlot(
RequestId requestId, RequestId requestId,
uint256 slotIndex, 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) { 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]; Slot storage slot = _slots[slotId];
require(slot.host == msg.sender, "Slot filled by other host"); require(slot.host == msg.sender, "Slot filled by other host");
SlotState state = slotState(slotId); SlotState state = slotState(slotId);
require(state != SlotState.Paid, "Already paid"); require(state != SlotState.Paid, "Already paid");
if (state == SlotState.Finished) { if (state == SlotState.Finished) {
_payoutSlot(slot.requestId, slotId); _payoutSlot(slot.requestId, slotId, rewardRecipient, collateralRecipient);
} else if (state == SlotState.Cancelled) { } else if (state == SlotState.Cancelled) {
_payoutCancelledSlot(slot.requestId, slotId); _payoutCancelledSlot(
slot.requestId,
slotId,
rewardRecipient,
collateralRecipient
);
} else if (state == SlotState.Failed) { } else if (state == SlotState.Failed) {
_removeFromMySlots(msg.sender, slotId); _removeFromMySlots(msg.sender, slotId);
} else if (state == SlotState.Filled) { } else if (state == SlotState.Filled) {
// free slot without returning collateral, effectively a 100% slash
_forciblyFreeSlot(slotId); _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 { function _forciblyFreeSlot(SlotId slotId) internal {
Slot storage slot = _slots[slotId]; Slot storage slot = _slots[slotId];
RequestId requestId = slot.requestId; RequestId requestId = slot.requestId;
@ -260,7 +304,9 @@ contract Marketplace is Proofs, StateRetrieval, Endian {
function _payoutSlot( function _payoutSlot(
RequestId requestId, RequestId requestId,
SlotId slotId SlotId slotId,
address rewardRecipient,
address collateralRecipient
) private requestIsKnown(requestId) { ) private requestIsKnown(requestId) {
RequestContext storage context = _requestContexts[requestId]; RequestContext storage context = _requestContexts[requestId];
Request storage request = _requests[requestId]; Request storage request = _requests[requestId];
@ -270,31 +316,62 @@ contract Marketplace is Proofs, StateRetrieval, Endian {
_removeFromMySlots(slot.host, slotId); _removeFromMySlots(slot.host, slotId);
uint256 amount = _requests[requestId].pricePerSlot() + uint256 payoutAmount = _requests[requestId].pricePerSlot();
slot.currentCollateral; uint256 collateralAmount = slot.currentCollateral;
_marketplaceTotals.sent += amount; _marketplaceTotals.sent += (payoutAmount + collateralAmount);
slot.state = SlotState.Paid; 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( function _payoutCancelledSlot(
RequestId requestId, RequestId requestId,
SlotId slotId SlotId slotId,
address rewardRecipient,
address collateralRecipient
) private requestIsKnown(requestId) { ) private requestIsKnown(requestId) {
Slot storage slot = _slots[slotId]; Slot storage slot = _slots[slotId];
_removeFromMySlots(slot.host, slotId); _removeFromMySlots(slot.host, slotId);
uint256 amount = _expiryPayoutAmount(requestId, slot.filledAt) + uint256 payoutAmount = _expiryPayoutAmount(requestId, slot.filledAt);
slot.currentCollateral; uint256 collateralAmount = slot.currentCollateral;
_marketplaceTotals.sent += amount; _marketplaceTotals.sent += (payoutAmount + collateralAmount);
slot.state = SlotState.Paid; 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. * @notice Withdraws remaining storage request funds back to the client that
/// @param requestId the id of the request 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 { 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]; Request storage request = _requests[requestId];
require( require(
block.timestamp > requestExpiry(requestId), block.timestamp > requestExpiry(requestId),
@ -313,7 +390,7 @@ contract Marketplace is Proofs, StateRetrieval, Endian {
uint256 amount = context.expiryFundsWithdraw; uint256 amount = context.expiryFundsWithdraw;
_marketplaceTotals.sent += amount; _marketplaceTotals.sent += amount;
assert(_token.transfer(msg.sender, amount)); assert(_token.transfer(withdrawRecipient, amount));
} }
function getActiveSlot( function getActiveSlot(
@ -356,7 +433,14 @@ contract Marketplace is Proofs, StateRetrieval, Endian {
return _requestContexts[requestId].expiresAt; 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( function _expiryPayoutAmount(
RequestId requestId, RequestId requestId,
uint256 startingTimestamp uint256 startingTimestamp

View File

@ -21,6 +21,7 @@ const {
waitUntilFinished, waitUntilFinished,
waitUntilFailed, waitUntilFailed,
waitUntilSlotFailed, waitUntilSlotFailed,
patchOverloads,
} = require("./marketplace") } = require("./marketplace")
const { price, pricePerSlot } = require("./price") const { price, pricePerSlot } = require("./price")
const { const {
@ -87,7 +88,14 @@ describe("Marketplace", function () {
let marketplace let marketplace
let token let token
let verifier let verifier
let client, host, host1, host2, host3 let client,
clientWithdrawRecipient,
host,
host1,
host2,
host3,
hostRewardRecipient,
hostCollateralRecipient
let request let request
let slot let slot
@ -96,12 +104,28 @@ describe("Marketplace", function () {
beforeEach(async function () { beforeEach(async function () {
await snapshot() await snapshot()
await ensureMinimumBlockHeight(256) await ensureMinimumBlockHeight(256)
;[client, host1, host2, host3] = await ethers.getSigners() ;[
client,
clientWithdrawRecipient,
host1,
host2,
host3,
hostRewardRecipient,
hostCollateralRecipient,
] = await ethers.getSigners()
host = host1 host = host1
const TestToken = await ethers.getContractFactory("TestToken") const TestToken = await ethers.getContractFactory("TestToken")
token = await TestToken.deploy() 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) await token.mint(account.address, ACCOUNT_STARTING_BALANCE)
} }
@ -114,6 +138,7 @@ describe("Marketplace", function () {
token.address, token.address,
verifier.address verifier.address
) )
patchOverloads(marketplace)
request = await exampleRequest() request = await exampleRequest()
request.client = client.address request.client = client.address
@ -131,6 +156,7 @@ describe("Marketplace", function () {
function switchAccount(account) { function switchAccount(account) {
token = token.connect(account) token = token.connect(account)
marketplace = marketplace.connect(account) marketplace = marketplace.connect(account)
patchOverloads(marketplace)
} }
describe("requesting storage", function () { 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 () { it("pays the host when contract was cancelled", async function () {
// Lets advance the time more into the expiry window // Lets advance the time more into the expiry window
const filledAt = (await currentTime()) + Math.floor(request.expiry / 3) 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 () { it("does not pay when the contract hasn't ended", async function () {
await marketplace.fillSlot(slot.request, slot.index, proof) 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)) await marketplace.freeSlot(slotId(slot))
const endBalance = await token.balanceOf(host.address) const endBalanceHost = await token.balanceOf(host.address)
expect(endBalance).to.equal(startBalance) 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 () { 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 () { it("rejects withdraw when request not yet timed out", async function () {
switchAccount(client) switchAccount(client)
await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith( await expect(
"Request not yet timed out" marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address)
) ).to.be.revertedWith("Request not yet timed out")
}) })
it("rejects withdraw when wrong account used", async function () { it("rejects withdraw when wrong account used", async function () {
await waitUntilCancelled(request) await waitUntilCancelled(request)
await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith( await expect(
"Invalid client address" marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address)
) ).to.be.revertedWith("Invalid client address")
}) })
it("rejects withdraw when in wrong state", async function () { it("rejects withdraw when in wrong state", async function () {
@ -610,29 +724,41 @@ describe("Marketplace", function () {
} }
await waitUntilCancelled(request) await waitUntilCancelled(request)
switchAccount(client) switchAccount(client)
await expect(marketplace.withdrawFunds(slot.request)).to.be.revertedWith( await expect(
"Invalid state" marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address)
) ).to.be.revertedWith("Invalid state")
}) })
it("emits event once request is cancelled", async function () { it("emits event once request is cancelled", async function () {
await waitUntilCancelled(request) await waitUntilCancelled(request)
switchAccount(client) switchAccount(client)
await expect(marketplace.withdrawFunds(slot.request)) await expect(
marketplace.withdrawFunds(slot.request, clientWithdrawRecipient.address)
)
.to.emit(marketplace, "RequestCancelled") .to.emit(marketplace, "RequestCancelled")
.withArgs(requestId(request)) .withArgs(requestId(request))
}) })
it("withdraws to the client", async function () { it("withdraws to the client payout address", async function () {
await waitUntilCancelled(request) await waitUntilCancelled(request)
switchAccount(client) switchAccount(client)
const startBalance = await token.balanceOf(client.address) const startBalanceClient = await token.balanceOf(client.address)
await marketplace.withdrawFunds(slot.request) const startBalancePayout = await token.balanceOf(
const endBalance = await token.balanceOf(client.address) clientWithdrawRecipient.address
expect(endBalance - startBalance).to.equal(price(request)) )
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 // Lets advance the time more into the expiry window
const filledAt = (await currentTime()) + Math.floor(request.expiry / 3) const filledAt = (await currentTime()) + Math.floor(request.expiry / 3)
const expiresAt = ( const expiresAt = (
@ -642,14 +768,17 @@ describe("Marketplace", function () {
await advanceTimeToForNextBlock(filledAt) await advanceTimeToForNextBlock(filledAt)
await marketplace.fillSlot(slot.request, slot.index, proof) await marketplace.fillSlot(slot.request, slot.index, proof)
await waitUntilCancelled(request) await waitUntilCancelled(request)
const expectedPartialHostPayout = const expectedPartialhostRewardRecipient =
(expiresAt - filledAt) * request.ask.reward (expiresAt - filledAt) * request.ask.reward
switchAccount(client) switchAccount(client)
await marketplace.withdrawFunds(slot.request) await marketplace.withdrawFunds(
const endBalance = await token.balanceOf(client.address) slot.request,
expect(ACCOUNT_STARTING_BALANCE - endBalance).to.equal( clientWithdrawRecipient.address
expectedPartialHostPayout )
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 () { it("remains 'Cancelled' when client withdraws funds", async function () {
await waitUntilCancelled(request) await waitUntilCancelled(request)
switchAccount(client) switchAccount(client)
await marketplace.withdrawFunds(slot.request) await marketplace.withdrawFunds(
slot.request,
clientWithdrawRecipient.address
)
expect(await marketplace.requestState(slot.request)).to.equal(Cancelled) 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 () { it("removes request from list when funds are withdrawn", async function () {
await marketplace.requestStorage(request) await marketplace.requestStorage(request)
await waitUntilCancelled(request) await waitUntilCancelled(request)
await marketplace.withdrawFunds(requestId(request)) await marketplace.withdrawFunds(
requestId(request),
clientWithdrawRecipient.address
)
expect(await marketplace.myRequests()).to.deep.equal([]) 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 = { module.exports = {
waitUntilCancelled, waitUntilCancelled,
waitUntilStarted, waitUntilStarted,
waitUntilFinished, waitUntilFinished,
waitUntilFailed, waitUntilFailed,
waitUntilSlotFailed, waitUntilSlotFailed,
patchOverloads,
} }