[marketplace] remove `offer`, `select` and `startContract`

Contract is started when first proof is submitted.
This commit is contained in:
Mark Spanbroek 2022-06-13 12:17:37 +02:00 committed by markspanbroek
parent f3b969fd7c
commit 8d7b7aed1d
6 changed files with 32 additions and 338 deletions

View File

@ -10,7 +10,6 @@ contract Marketplace is Collateral, Proofs {
MarketplaceFunds private funds; MarketplaceFunds private funds;
mapping(bytes32 => Request) private requests; mapping(bytes32 => Request) private requests;
mapping(bytes32 => RequestState) private requestState; mapping(bytes32 => RequestState) private requestState;
mapping(bytes32 => Offer) private offers;
constructor( constructor(
IERC20 _token, IERC20 _token,
@ -51,7 +50,7 @@ contract Marketplace is Collateral, Proofs {
marketplaceInvariant marketplaceInvariant
{ {
RequestState storage state = requestState[requestId]; RequestState storage state = requestState[requestId];
require(!state.fulfilled, "Request already fulfilled"); require(state.host == address(0), "Request already fulfilled");
Request storage request = requests[requestId]; Request storage request = requests[requestId];
require(request.client != address(0), "Unknown request"); require(request.client != address(0), "Unknown request");
@ -67,67 +66,18 @@ contract Marketplace is Collateral, Proofs {
); );
_submitProof(requestId, proof); _submitProof(requestId, proof);
state.fulfilled = true; state.host = msg.sender;
emit RequestFulfilled(requestId); emit RequestFulfilled(requestId);
} }
function offerStorage(Offer calldata offer) public marketplaceInvariant { function _host(bytes32 requestId) internal view returns (address) {
require(offer.host == msg.sender, "Invalid host address"); return requestState[requestId].host;
require(balanceOf(msg.sender) >= collateral, "Insufficient collateral");
Request storage request = requests[offer.requestId];
require(request.client != address(0), "Unknown request");
require(request.expiry > block.timestamp, "Request expired");
require(offer.price <= request.ask.maxPrice, "Price too high");
bytes32 id = keccak256(abi.encode(offer));
require(offers[id].host == address(0), "Offer already exists");
offers[id] = offer;
_lock(msg.sender, offer.requestId);
emit StorageOffered(id, offer, offer.requestId);
}
function selectOffer(bytes32 id) public marketplaceInvariant {
Offer storage offer = offers[id];
require(offer.host != address(0), "Unknown offer");
require(offer.expiry > block.timestamp, "Offer expired");
Request storage request = requests[offer.requestId];
require(request.client == msg.sender, "Only client can select offer");
RequestState storage state = requestState[offer.requestId];
require(state.selectedOffer == bytes32(0), "Offer already selected");
state.selectedOffer = id;
_createLock(id, offer.expiry);
_lock(offer.host, id);
_unlock(offer.requestId);
uint256 difference = request.ask.maxPrice - offer.price;
funds.sent += difference;
funds.balance -= difference;
token.transfer(request.client, difference);
emit OfferSelected(id, offer.requestId);
} }
function _request(bytes32 id) internal view returns (Request storage) { function _request(bytes32 id) internal view returns (Request storage) {
return requests[id]; return requests[id];
} }
function _offer(bytes32 id) internal view returns (Offer storage) {
return offers[id];
}
function _selectedOffer(bytes32 requestId) internal view returns (bytes32) {
return requestState[requestId].selectedOffer;
}
function proofPeriod() public view returns (uint256) { function proofPeriod() public view returns (uint256) {
return _period(); return _period();
} }
@ -174,21 +124,11 @@ contract Marketplace is Collateral, Proofs {
} }
struct RequestState { struct RequestState {
bool fulfilled;
bytes32 selectedOffer;
}
struct Offer {
address host; address host;
bytes32 requestId;
uint256 price;
uint256 expiry;
} }
event StorageRequested(bytes32 requestId, Ask ask); event StorageRequested(bytes32 requestId, Ask ask);
event RequestFulfilled(bytes32 indexed requestId); event RequestFulfilled(bytes32 indexed requestId);
event StorageOffered(bytes32 offerId, Offer offer, bytes32 indexed requestId);
event OfferSelected(bytes32 offerId, bytes32 indexed requestId);
modifier marketplaceInvariant() { modifier marketplaceInvariant() {
MarketplaceFunds memory oldFunds = funds; MarketplaceFunds memory oldFunds = funds;

View File

@ -10,7 +10,7 @@ contract Storage is Collateral, Marketplace {
uint256 public slashMisses; uint256 public slashMisses;
uint256 public slashPercentage; uint256 public slashPercentage;
mapping(bytes32 => ContractState) private contractState; mapping(bytes32 => bool) private contractFinished;
constructor( constructor(
IERC20 token, IERC20 token,
@ -38,25 +38,15 @@ contract Storage is Collateral, Marketplace {
return _request(id); return _request(id);
} }
function getOffer(bytes32 id) public view returns (Offer memory) {
return _offer(id);
}
function startContract(bytes32 id) public {
Offer storage offer = _offer(id);
require(msg.sender == offer.host, "Only host can call this function");
require(_selectedOffer(offer.requestId) == id, "Offer was not selected");
contractState[id] = ContractState.started;
Request storage request = _request(offer.requestId);
_expectProofs(id, request.ask.proofProbability, request.ask.duration);
}
function finishContract(bytes32 id) public { function finishContract(bytes32 id) public {
require(contractState[id] == ContractState.started, "Contract not started"); require(_host(id) != address(0), "Contract not started");
require(!contractFinished[id], "Contract already finished");
require(block.timestamp > proofEnd(id), "Contract has not ended yet"); require(block.timestamp > proofEnd(id), "Contract has not ended yet");
contractState[id] = ContractState.finished; contractFinished[id] = true;
Offer storage offer = _offer(id); require(
require(token.transfer(offer.host, offer.price), "Payment failed"); token.transfer(_host(id), _request(id).ask.maxPrice),
"Payment failed"
);
} }
function missingProofs(bytes32 contractId) public view returns (uint256) { function missingProofs(bytes32 contractId) public view returns (uint256) {
@ -86,14 +76,7 @@ contract Storage is Collateral, Marketplace {
function markProofAsMissing(bytes32 contractId, uint256 period) public { function markProofAsMissing(bytes32 contractId, uint256 period) public {
_markProofAsMissing(contractId, period); _markProofAsMissing(contractId, period);
if (_missed(contractId) % slashMisses == 0) { if (_missed(contractId) % slashMisses == 0) {
Offer storage offer = _offer(contractId); _slash(_host(contractId), slashPercentage);
_slash(offer.host, slashPercentage);
} }
} }
enum ContractState {
none,
started,
finished
}
} }

View File

@ -1,10 +1,10 @@
const { ethers } = require("hardhat") const { ethers } = require("hardhat")
const { hexlify, randomBytes } = ethers.utils const { hexlify, randomBytes } = ethers.utils
const { expect } = require("chai") const { expect } = require("chai")
const { exampleRequest, exampleOffer } = require("./examples") const { exampleRequest } = require("./examples")
const { snapshot, revert, ensureMinimumBlockHeight } = require("./evm") const { snapshot, revert, ensureMinimumBlockHeight } = require("./evm")
const { now, hours } = require("./time") const { now, hours } = require("./time")
const { requestId, offerId, offerToArray, askToArray } = require("./ids") const { requestId, askToArray } = require("./ids")
describe("Marketplace", function () { describe("Marketplace", function () {
const collateral = 100 const collateral = 100
@ -15,7 +15,7 @@ describe("Marketplace", function () {
let marketplace let marketplace
let token let token
let client, host, host1, host2, host3 let client, host, host1, host2, host3
let request, offer let request
beforeEach(async function () { beforeEach(async function () {
await snapshot() await snapshot()
@ -40,10 +40,6 @@ describe("Marketplace", function () {
request = exampleRequest() request = exampleRequest()
request.client = client.address request.client = client.address
offer = exampleOffer()
offer.host = host.address
offer.requestId = requestId(request)
}) })
afterEach(async function () { afterEach(async function () {
@ -162,153 +158,4 @@ describe("Marketplace", function () {
).to.be.revertedWith("Request expired") ).to.be.revertedWith("Request expired")
}) })
}) })
describe("offering storage", function () {
beforeEach(async function () {
switchAccount(client)
await token.approve(marketplace.address, request.ask.maxPrice)
await marketplace.requestStorage(request)
switchAccount(host)
await token.approve(marketplace.address, collateral)
await marketplace.deposit(collateral)
})
it("emits event when storage is offered", async function () {
await expect(marketplace.offerStorage(offer))
.to.emit(marketplace, "StorageOffered")
.withArgs(offerId(offer), offerToArray(offer), requestId(request))
})
it("locks collateral of host", async function () {
await marketplace.offerStorage(offer)
await expect(marketplace.withdraw()).to.be.revertedWith("Account locked")
})
it("rejects offer with invalid host address", async function () {
let invalid = { ...offer, host: client.address }
await expect(marketplace.offerStorage(invalid)).to.be.revertedWith(
"Invalid host address"
)
})
it("rejects offer for unknown request", async function () {
let unknown = exampleRequest()
let invalid = { ...offer, requestId: requestId(unknown) }
await expect(marketplace.offerStorage(invalid)).to.be.revertedWith(
"Unknown request"
)
})
it("rejects offer for expired request", async function () {
switchAccount(client)
let expired = { ...request, expiry: now() - hours(1) }
await token.approve(marketplace.address, request.ask.maxPrice)
await marketplace.requestStorage(expired)
switchAccount(host)
let invalid = { ...offer, requestId: requestId(expired) }
await expect(marketplace.offerStorage(invalid)).to.be.revertedWith(
"Request expired"
)
})
it("rejects an offer that exceeds the maximum price", async function () {
let invalid = { ...offer, price: request.ask.maxPrice + 1 }
await expect(marketplace.offerStorage(invalid)).to.be.revertedWith(
"Price too high"
)
})
it("rejects resubmission of offer", async function () {
await marketplace.offerStorage(offer)
await expect(marketplace.offerStorage(offer)).to.be.revertedWith(
"Offer already exists"
)
})
it("rejects offer with insufficient collateral", async function () {
let insufficient = collateral - 1
await marketplace.withdraw()
await token.approve(marketplace.address, insufficient)
await marketplace.deposit(insufficient)
await expect(marketplace.offerStorage(offer)).to.be.revertedWith(
"Insufficient collateral"
)
})
})
describe("selecting an offer", function () {
beforeEach(async function () {
switchAccount(client)
await token.approve(marketplace.address, request.ask.maxPrice)
await marketplace.requestStorage(request)
for (host of [host1, host2, host3]) {
switchAccount(host)
let hostOffer = { ...offer, host: host.address }
await token.approve(marketplace.address, collateral)
await marketplace.deposit(collateral)
await marketplace.offerStorage(hostOffer)
}
switchAccount(client)
})
it("emits event when offer is selected", async function () {
await expect(marketplace.selectOffer(offerId(offer)))
.to.emit(marketplace, "OfferSelected")
.withArgs(offerId(offer), requestId(request))
})
it("returns price difference to client", async function () {
let difference = request.ask.maxPrice - offer.price
let before = await token.balanceOf(client.address)
await marketplace.selectOffer(offerId(offer))
let after = await token.balanceOf(client.address)
expect(after - before).to.equal(difference)
})
it("unlocks collateral of hosts that weren't chosen", async function () {
await marketplace.selectOffer(offerId(offer))
switchAccount(host2)
await expect(marketplace.withdraw()).not.to.be.reverted
switchAccount(host3)
await expect(marketplace.withdraw()).not.to.be.reverted
})
it("locks collateral of host that was chosen", async function () {
await marketplace.selectOffer(offerId(offer))
switchAccount(host1)
await expect(marketplace.withdraw()).to.be.revertedWith("Account locked")
})
it("rejects selection of unknown offer", async function () {
let unknown = exampleOffer()
await expect(
marketplace.selectOffer(offerId(unknown))
).to.be.revertedWith("Unknown offer")
})
it("rejects selection of expired offer", async function () {
let expired = { ...offer, expiry: now() - hours(1) }
switchAccount(host1)
await marketplace.offerStorage(expired)
switchAccount(client)
await expect(
marketplace.selectOffer(offerId(expired))
).to.be.revertedWith("Offer expired")
})
it("rejects reselection of offer", async function () {
let secondOffer = { ...offer, host: host2.address }
await marketplace.selectOffer(offerId(offer))
await expect(
marketplace.selectOffer(offerId(secondOffer))
).to.be.revertedWith("Offer already selected")
})
it("rejects selection by anyone other than the client", async function () {
switchAccount(host1)
await expect(marketplace.selectOffer(offerId(offer))).to.be.revertedWith(
"Only client can select offer"
)
})
})
}) })

View File

@ -1,15 +1,18 @@
const { expect } = require("chai") const { expect } = require("chai")
const { ethers, deployments } = require("hardhat") const { ethers, deployments } = require("hardhat")
const { exampleRequest, exampleOffer } = require("./examples") const { hexlify, randomBytes } = ethers.utils
const { exampleRequest } = require("./examples")
const { advanceTime, advanceTimeTo, currentTime } = require("./evm") const { advanceTime, advanceTimeTo, currentTime } = require("./evm")
const { requestId, offerId } = require("./ids") const { requestId } = require("./ids")
const { periodic } = require("./time") const { periodic } = require("./time")
describe("Storage", function () { describe("Storage", function () {
const proof = hexlify(randomBytes(42))
let storage let storage
let token let token
let client, host let client, host
let request, offer let request
let collateralAmount, slashMisses, slashPercentage let collateralAmount, slashMisses, slashPercentage
let id let id
@ -34,10 +37,7 @@ describe("Storage", function () {
request = exampleRequest() request = exampleRequest()
request.client = client.address request.client = client.address
id = requestId(request)
offer = exampleOffer()
offer.host = host.address
offer.requestId = requestId(request)
switchAccount(client) switchAccount(client)
await token.approve(storage.address, request.ask.maxPrice) await token.approve(storage.address, request.ask.maxPrice)
@ -45,10 +45,6 @@ describe("Storage", function () {
switchAccount(host) switchAccount(host)
await token.approve(storage.address, collateralAmount) await token.approve(storage.address, collateralAmount)
await storage.deposit(collateralAmount) await storage.deposit(collateralAmount)
await storage.offerStorage(offer)
switchAccount(client)
await storage.selectOffer(offerId(offer))
id = offerId(offer)
}) })
it("can retrieve storage requests", async function () { it("can retrieve storage requests", async function () {
@ -59,69 +55,26 @@ describe("Storage", function () {
expect(retrieved.nonce).to.equal(request.nonce) expect(retrieved.nonce).to.equal(request.nonce)
}) })
it("can retrieve storage offers", async function () {
const id = offerId(offer)
const retrieved = await storage.getOffer(id)
expect(retrieved.requestId).to.equal(offer.requestId)
expect(retrieved.host).to.equal(offer.host)
expect(retrieved.price).to.equal(offer.price)
expect(retrieved.expiry).to.equal(offer.expiry)
})
describe("starting the contract", function () {
it("starts requiring storage proofs", async function () {
switchAccount(host)
await storage.startContract(id)
expect(await storage.proofEnd(id)).to.be.gt(0)
})
it("can only be done by the host", async function () {
switchAccount(client)
await expect(storage.startContract(id)).to.be.revertedWith(
"Only host can call this function"
)
})
it("can only be done once", async function () {
switchAccount(host)
await storage.startContract(id)
await expect(storage.startContract(id)).to.be.reverted
})
it("can only be done for a selected offer", async function () {
switchAccount(host)
const differentOffer = { ...offer, price: offer.price * 2 }
await storage.offerStorage(differentOffer)
await expect(
storage.startContract(offerId(differentOffer))
).to.be.revertedWith("Offer was not selected")
})
})
describe("finishing the contract", function () { describe("finishing the contract", function () {
beforeEach(async function () {
switchAccount(host)
})
async function waitUntilEnd() { async function waitUntilEnd() {
const end = (await storage.proofEnd(id)).toNumber() const end = (await storage.proofEnd(id)).toNumber()
await advanceTimeTo(end) await advanceTimeTo(end)
} }
it("unlocks the host collateral", async function () { it("unlocks the host collateral", async function () {
await storage.startContract(id) await storage.fulfillRequest(requestId(request), proof)
await waitUntilEnd() await waitUntilEnd()
await storage.finishContract(id) await storage.finishContract(id)
await expect(storage.withdraw()).not.to.be.reverted await expect(storage.withdraw()).not.to.be.reverted
}) })
it("pays the host", async function () { it("pays the host", async function () {
await storage.startContract(id) await storage.fulfillRequest(requestId(request), proof)
await waitUntilEnd() await waitUntilEnd()
const startBalance = await token.balanceOf(host.address) const startBalance = await token.balanceOf(host.address)
await storage.finishContract(id) await storage.finishContract(id)
const endBalance = await token.balanceOf(host.address) const endBalance = await token.balanceOf(host.address)
expect(endBalance - startBalance).to.equal(offer.price) expect(endBalance - startBalance).to.equal(request.ask.maxPrice)
}) })
it("is only allowed when the contract has started", async function () { it("is only allowed when the contract has started", async function () {
@ -131,24 +84,24 @@ describe("Storage", function () {
}) })
it("is only allowed when end time has passed", async function () { it("is only allowed when end time has passed", async function () {
await storage.startContract(id) await storage.fulfillRequest(requestId(request), proof)
await expect(storage.finishContract(id)).to.be.revertedWith( await expect(storage.finishContract(id)).to.be.revertedWith(
"Contract has not ended yet" "Contract has not ended yet"
) )
}) })
it("can only be done once", async function () { it("can only be done once", async function () {
await storage.startContract(id) await storage.fulfillRequest(requestId(request), proof)
await waitUntilEnd() await waitUntilEnd()
await storage.finishContract(id) await storage.finishContract(id)
await expect(storage.finishContract(id)).to.be.reverted await expect(storage.finishContract(id)).to.be.reverted
}) })
it("can not be restarted", async function () { it("can not be restarted", async function () {
await storage.startContract(id) await storage.fulfillRequest(requestId(request), proof)
await waitUntilEnd() await waitUntilEnd()
await storage.finishContract(id) await storage.finishContract(id)
await expect(storage.startContract(id)).to.be.reverted await expect(storage.fulfillRequest(id, proof)).to.be.reverted
}) })
}) })
@ -156,7 +109,6 @@ describe("Storage", function () {
let period, periodOf, periodEnd let period, periodOf, periodEnd
beforeEach(async function () { beforeEach(async function () {
switchAccount(host)
period = (await storage.proofPeriod()).toNumber() period = (await storage.proofPeriod()).toNumber()
;({ periodOf, periodEnd } = periodic(period)) ;({ periodOf, periodEnd } = periodic(period))
}) })
@ -174,7 +126,7 @@ describe("Storage", function () {
} }
it("reduces collateral when too many proofs are missing", async function () { it("reduces collateral when too many proofs are missing", async function () {
await storage.connect(host).startContract(id) await storage.fulfillRequest(requestId(request), proof)
for (let i = 0; i < slashMisses; i++) { for (let i = 0; i < slashMisses; i++) {
await waitUntilProofIsRequired() await waitUntilProofIsRequired()
let missedPeriod = periodOf(await currentTime()) let missedPeriod = periodOf(await currentTime())
@ -187,7 +139,6 @@ describe("Storage", function () {
}) })
}) })
// TODO: failure to start contract burns host and client
// TODO: implement checking of actual proofs of storage, instead of dummy bool // TODO: implement checking of actual proofs of storage, instead of dummy bool
// TODO: allow other host to take over contract when too many missed proofs // TODO: allow other host to take over contract when too many missed proofs
// TODO: small partial payouts when proofs are being submitted // TODO: small partial payouts when proofs are being submitted

View File

@ -27,21 +27,9 @@ const exampleRequest = () => ({
nonce: hexlify(randomBytes(32)), nonce: hexlify(randomBytes(32)),
}) })
const exampleBid = () => ({
price: 42,
bidExpiry: now() + hours(1),
})
const exampleOffer = () => ({
requestId: hexlify(randomBytes(32)),
host: hexlify(randomBytes(20)),
price: 42,
expiry: now() + hours(1),
})
const exampleLock = () => ({ const exampleLock = () => ({
id: hexlify(randomBytes(32)), id: hexlify(randomBytes(32)),
expiry: now() + hours(1), expiry: now() + hours(1),
}) })
module.exports = { exampleRequest, exampleOffer, exampleBid, exampleLock } module.exports = { exampleRequest, exampleLock }

View File

@ -11,15 +11,6 @@ function requestId(request) {
return keccak256(defaultAbiCoder.encode([Request], requestToArray(request))) return keccak256(defaultAbiCoder.encode([Request], requestToArray(request)))
} }
function offerId(offer) {
return keccak256(
defaultAbiCoder.encode(
["address", "bytes32", "uint256", "uint256"],
offerToArray(offer)
)
)
}
function askToArray(ask) { function askToArray(ask) {
return [ask.size, ask.duration, ask.proofProbability, ask.maxPrice] return [ask.size, ask.duration, ask.proofProbability, ask.maxPrice]
} }
@ -48,14 +39,8 @@ function requestToArray(request) {
] ]
} }
function offerToArray(offer) {
return [offer.host, offer.requestId, offer.price, offer.expiry]
}
module.exports = { module.exports = {
requestId, requestId,
offerId,
requestToArray, requestToArray,
askToArray, askToArray,
offerToArray,
} }