diff --git a/contracts/Proofs.sol b/contracts/Proofs.sol index 5c7ff95..df8120e 100644 --- a/contracts/Proofs.sol +++ b/contracts/Proofs.sol @@ -13,6 +13,7 @@ contract Proofs { mapping(bytes32 => bool) private ids; mapping(bytes32 => uint256) private starts; mapping(bytes32 => uint256) private ends; + mapping(bytes32 => uint256) private probabilities; mapping(bytes32 => uint256) private markers; mapping(bytes32 => uint256) private missed; mapping(bytes32 => mapping(uint256 => bool)) private received; @@ -34,55 +35,95 @@ contract Proofs { return missed[id]; } - function _expectProofs(bytes32 id, uint256 duration) internal { + function periodOf(uint256 timestamp) private view returns (uint256) { + return timestamp / period; + } + + function currentPeriod() private view returns (uint256) { + return periodOf(block.timestamp); + } + + function _expectProofs( + bytes32 id, + uint256 probability, + uint256 duration + ) internal { require(!ids[id], "Proof id already in use"); ids[id] = true; - starts[id] = block.number; - ends[id] = block.number + duration + 2 * timeout; + starts[id] = block.timestamp; + ends[id] = block.timestamp + duration; + probabilities[id] = probability; markers[id] = uint256(blockhash(block.number - 1)) % period; } - // Check whether a proof is required at the time of the block with the - // specified block number. A proof has to be submitted within the proof - // timeout for it to be valid. Whether a proof is required is determined - // randomly, but on average it is once every proof period. - function _isProofRequired(bytes32 id, uint256 blocknumber) + function _getChallenges(bytes32 id, uint256 proofperiod) + internal + view + returns (Challenge memory challenge1, Challenge memory challenge2) + { + if ( + proofperiod <= periodOf(starts[id]) || proofperiod >= periodOf(ends[id]) + ) { + bytes32 nullChallenge; + return (Challenge(false, nullChallenge), Challenge(false, nullChallenge)); + } + + uint256 blocknumber = block.number % 256; + uint256 periodnumber = proofperiod % 256; + uint256 idoffset = uint256(id) % 256; + + uint256 pointer1 = (blocknumber + periodnumber + idoffset) % 256; + uint256 pointer2 = (blocknumber + periodnumber + idoffset + 128) % 256; + + bytes32 blockhash1 = blockhash(block.number - 1 - pointer1); + bytes32 blockhash2 = blockhash(block.number - 1 - pointer2); + + assert(uint256(blockhash1) != 0); + assert(uint256(blockhash2) != 0); + + challenge1.challenge = keccak256(abi.encode(blockhash1)); + challenge2.challenge = keccak256(abi.encode(blockhash2)); + + challenge1.isProofRequired = + uint256(challenge1.challenge) % probabilities[id] == 0; + challenge2.isProofRequired = + uint256(challenge2.challenge) % probabilities[id] == 0; + } + + function _isProofRequired(bytes32 id, uint256 proofPeriod) internal view returns (bool) { - if (blocknumber < starts[id] || blocknumber >= ends[id]) { - return false; - } - bytes32 hash = blockhash(blocknumber - 1); - return hash != 0 && uint256(hash) % period == markers[id]; + Challenge memory challenge1; + Challenge memory challenge2; + (challenge1, challenge2) = _getChallenges(id, proofPeriod); + return challenge1.isProofRequired && challenge2.isProofRequired; } - function _isProofTimedOut(uint256 blocknumber) internal view returns (bool) { - return block.number >= blocknumber + timeout; + function _isProofRequired(bytes32 id) internal view returns (bool) { + return _isProofRequired(id, currentPeriod()); } - function _submitProof( - bytes32 id, - uint256 blocknumber, - bool proof - ) internal { + function _submitProof(bytes32 id, bool proof) internal { require(proof, "Invalid proof"); // TODO: replace bool by actual proof - require( - _isProofRequired(id, blocknumber), - "No proof required for this block" - ); - require(!_isProofTimedOut(blocknumber), "Proof not allowed after timeout"); - require(!received[id][blocknumber], "Proof already submitted"); - received[id][blocknumber] = true; + require(!received[id][currentPeriod()], "Proof already submitted"); + received[id][currentPeriod()] = true; } - function _markProofAsMissing(bytes32 id, uint256 blocknumber) internal { - require(_isProofTimedOut(blocknumber), "Proof has not timed out yet"); - require(!received[id][blocknumber], "Proof was submitted, not missing"); - require(_isProofRequired(id, blocknumber), "Proof was not required"); - require(!missing[id][blocknumber], "Proof already marked as missing"); - missing[id][blocknumber] = true; + function _markProofAsMissing(bytes32 id, uint256 missedPeriod) internal { + uint256 periodEnd = (missedPeriod + 1) * period; + require(periodEnd < block.timestamp, "Period has not ended yet"); + require(block.timestamp < periodEnd + timeout, "Validation timed out"); + require(!received[id][missedPeriod], "Proof was submitted, not missing"); + require(_isProofRequired(id, missedPeriod), "Proof was not required"); + require(!missing[id][missedPeriod], "Proof already marked as missing"); + missing[id][missedPeriod] = true; missed[id] += 1; } + + struct Challenge { + bool isProofRequired; + bytes32 challenge; + } } diff --git a/contracts/Storage.sol b/contracts/Storage.sol index 3ac8ec1..de2a0fd 100644 --- a/contracts/Storage.sol +++ b/contracts/Storage.sol @@ -29,11 +29,11 @@ contract Storage is Collateral, Marketplace, Proofs { Offer storage offer = _offer(id); require(msg.sender == offer.host, "Only host can call this function"); Request storage request = _request(offer.requestId); - _expectProofs(id, request.duration); + _expectProofs(id, request.proofProbability, request.duration); } function finishContract(bytes32 id) public { - require(block.number > proofEnd(id), "Contract has not ended yet"); + require(block.timestamp > proofEnd(id), "Contract has not ended yet"); require(!finished[id], "Contract already finished"); finished[id] = true; Offer storage offer = _offer(id); @@ -56,28 +56,16 @@ contract Storage is Collateral, Marketplace, Proofs { return _missed(contractId); } - function isProofRequired(bytes32 contractId, uint256 blocknumber) - public - view - returns (bool) - { - return _isProofRequired(contractId, blocknumber); + function isProofRequired(bytes32 contractId) public view returns (bool) { + return _isProofRequired(contractId); } - function isProofTimedOut(uint256 blocknumber) public view returns (bool) { - return _isProofTimedOut(blocknumber); + function submitProof(bytes32 contractId, bool proof) public { + _submitProof(contractId, proof); } - function submitProof( - bytes32 contractId, - uint256 blocknumber, - bool proof - ) public { - _submitProof(contractId, blocknumber, proof); - } - - function markProofAsMissing(bytes32 contractId, uint256 blocknumber) public { - _markProofAsMissing(contractId, blocknumber); + function markProofAsMissing(bytes32 contractId, uint256 period) public { + _markProofAsMissing(contractId, period); if (_missed(contractId) % slashMisses == 0) { Offer storage offer = _offer(contractId); _slash(offer.host, slashPercentage); diff --git a/contracts/TestProofs.sol b/contracts/TestProofs.sol index 2641131..650004f 100644 --- a/contracts/TestProofs.sol +++ b/contracts/TestProofs.sol @@ -28,27 +28,23 @@ contract TestProofs is Proofs { return _missed(id); } - function expectProofs(bytes32 id, uint256 _duration) public { - _expectProofs(id, _duration); - } - - function isProofRequired(bytes32 id, uint256 blocknumber) - public - view - returns (bool) - { - return _isProofRequired(id, blocknumber); - } - - function submitProof( + function expectProofs( bytes32 id, - uint256 blocknumber, - bool proof + uint256 _probability, + uint256 _duration ) public { - _submitProof(id, blocknumber, proof); + _expectProofs(id, _probability, _duration); } - function markProofAsMissing(bytes32 id, uint256 blocknumber) public { - _markProofAsMissing(id, blocknumber); + function isProofRequired(bytes32 id) public view returns (bool) { + return _isProofRequired(id); + } + + function submitProof(bytes32 id, bool proof) public { + _submitProof(id, proof); + } + + function markProofAsMissing(bytes32 id, uint256 _period) public { + _markProofAsMissing(id, _period); } } diff --git a/test/Proofs.test.js b/test/Proofs.test.js index 5124894..24a0fb8 100644 --- a/test/Proofs.test.js +++ b/test/Proofs.test.js @@ -1,190 +1,202 @@ const { expect } = require("chai") const { ethers } = require("hardhat") const { mineBlock, minedBlockNumber } = require("./evm") +const { + snapshot, + revert, + currentTime, + advanceTime, + advanceTimeTo, +} = require("./evm") describe("Proofs", function () { const id = ethers.utils.randomBytes(32) const period = 10 const timeout = 5 - const duration = 50 + const duration = 1000 + const probability = 2 // require a proof roughly once every 2² periods let proofs + async function ensureEnoughBlockHistory() { + while ((await minedBlockNumber()) < 256) { + await mineBlock() + } + } + beforeEach(async function () { + await snapshot() + await ensureEnoughBlockHistory() const Proofs = await ethers.getContractFactory("TestProofs") proofs = await Proofs.deploy(period, timeout) }) - it("calculates an end time based on duration and timeout", async function () { - await proofs.expectProofs(id, duration) - let start = await minedBlockNumber() - let end = start + duration + 2 * timeout - expect(await proofs.end(id)).to.equal(end) + afterEach(async function () { + await revert() + }) + + it("calculates an end time based on duration", async function () { + await proofs.expectProofs(id, probability, duration) + let end = (await currentTime()) + duration + expect((await proofs.end(id)).toNumber()).to.be.closeTo(end, 1) }) it("does not allow ids to be reused", async function () { - await proofs.expectProofs(id, duration) - await expect(proofs.expectProofs(id, duration)).to.be.revertedWith( - "Proof id already in use" - ) + await proofs.expectProofs(id, probability, duration) + await expect( + proofs.expectProofs(id, probability, duration) + ).to.be.revertedWith("Proof id already in use") }) - it("requires on average a proof every period", async function () { - let blocks = 600 + it("requires proofs with an agreed upon probability", async function () { + const duration = 100_000 + await proofs.expectProofs(id, probability, duration) let amount = 0 - await proofs.expectProofs(id, blocks) - for (let i = 0; i < blocks; i++) { - await mineBlock() - if (await proofs.isProofRequired(id, await minedBlockNumber())) { + for (let i = 0; i < 100; i++) { + if (await proofs.isProofRequired(id)) { amount += 1 } + await advanceTime(period) } - let average = blocks / amount - expect(average).to.be.closeTo(period, period / 2) + let expected = 100 / probability ** 2 + expect(amount).to.be.closeTo(expected, expected / 2) }) - it("requires no proof before start time", async function () { - for (let i = 0; i < 4 * period; i++) { - mineBlock() + it("requires no proofs in the start period", async function () { + const startPeriod = Math.floor((await currentTime()) / period) + const probability = 1 + await proofs.expectProofs(id, probability, duration) + while (Math.floor((await currentTime()) / period) == startPeriod) { + expect(await proofs.isProofRequired(id)).to.be.false + await advanceTime(1) } - await proofs.expectProofs(id, duration) - let start = await minedBlockNumber() - for (let i = 1; i < 4 * period; i++) { - expect(await proofs.isProofRequired(id, start - i)).to.be.false + }) + + it("requires no proofs in the end period", async function () { + const probability = 1 + await proofs.expectProofs(id, probability, duration) + await advanceTime(duration) + expect(await proofs.isProofRequired(id)).to.be.false + }) + + it("requires no proofs after the end time", async function () { + const probability = 1 + await proofs.expectProofs(id, probability, duration) + await advanceTime(duration + timeout) + expect(await proofs.isProofRequired(id)).to.be.false + }) + + it("requires proofs for different ids at different times", async function () { + let id1 = ethers.utils.randomBytes(32) + let id2 = ethers.utils.randomBytes(32) + let id3 = ethers.utils.randomBytes(32) + for (let id of [id1, id2, id3]) { + await proofs.expectProofs(id, probability, duration) + } + let req1, req2, req3 + while (req1 === req2 && req2 === req3) { + req1 = await proofs.isProofRequired(id1) + req2 = await proofs.isProofRequired(id2) + req3 = await proofs.isProofRequired(id3) + await advanceTime(period) } }) describe("when proofs are required", async function () { beforeEach(async function () { - await proofs.expectProofs(id, duration) + await proofs.expectProofs(id, probability, duration) }) - async function mineUntilProofIsRequired(id) { - while (!(await proofs.isProofRequired(id, await minedBlockNumber()))) { - mineBlock() + async function waitUntilProofIsRequired(id) { + while (!(await proofs.isProofRequired(id))) { + await advanceTime(period) } } - async function mineUntilProofTimeout() { - for (let i = 0; i < timeout; i++) { - mineBlock() - } + function periodOf(timestamp) { + return Math.floor(timestamp / period) } - async function mineUntilEnd() { - const end = await proofs.end(id) - while ((await minedBlockNumber()) < end) { - mineBlock() - } + function periodStart(p) { + return period * p } - it("requires no proof for blocks that are unavailable", async function () { - await mineUntilProofIsRequired(id) - let blocknumber = await minedBlockNumber() - for (let i = 0; i < 256; i++) { - // only last 256 blocks available in solidity - mineBlock() - } - expect(await proofs.isProofRequired(id, blocknumber)).to.be.false - }) - - it("requires no proof after end time", async function () { - await mineUntilEnd() - for (let i = 0; i < 4 * period; i++) { - const blocknumber = await minedBlockNumber() - expect(await proofs.isProofRequired(id, blocknumber)).to.be.false - mineBlock() - } - }) + function periodEnd(p) { + return periodStart(p + 1) + } it("submits a correct proof", async function () { - await mineUntilProofIsRequired(id) - let blocknumber = await minedBlockNumber() - await proofs.submitProof(id, blocknumber, true) + await proofs.submitProof(id, true) }) it("fails proof submission when proof is incorrect", async function () { - await mineUntilProofIsRequired(id) - let blocknumber = await minedBlockNumber() - await expect( - proofs.submitProof(id, blocknumber, false) - ).to.be.revertedWith("Invalid proof") - }) - - it("fails proof submission when proof was not required", async function () { - while (await proofs.isProofRequired(id, await minedBlockNumber())) { - await mineBlock() - } - let blocknumber = await minedBlockNumber() - await expect( - proofs.submitProof(id, blocknumber, true) - ).to.be.revertedWith("No proof required") - }) - - it("fails proof submission when proof is too late", async function () { - await mineUntilProofIsRequired(id) - let blocknumber = await minedBlockNumber() - await mineUntilProofTimeout() - await expect( - proofs.submitProof(id, blocknumber, true) - ).to.be.revertedWith("Proof not allowed after timeout") + await expect(proofs.submitProof(id, false)).to.be.revertedWith( + "Invalid proof" + ) }) it("fails proof submission when already submitted", async function () { - await mineUntilProofIsRequired(id) - let blocknumber = await minedBlockNumber() - await proofs.submitProof(id, blocknumber, true) - await expect( - proofs.submitProof(id, blocknumber, true) - ).to.be.revertedWith("Proof already submitted") + await advanceTimeTo(periodEnd(periodOf(await currentTime()))) + await proofs.submitProof(id, true) + await expect(proofs.submitProof(id, true)).to.be.revertedWith( + "Proof already submitted" + ) }) it("marks a proof as missing", async function () { expect(await proofs.missed(id)).to.equal(0) - await mineUntilProofIsRequired(id) - let blocknumber = await minedBlockNumber() - await mineUntilProofTimeout() - await proofs.markProofAsMissing(id, blocknumber) + await waitUntilProofIsRequired(id) + let missedPeriod = periodOf(await currentTime()) + await advanceTimeTo(periodEnd(missedPeriod)) + await proofs.markProofAsMissing(id, missedPeriod) expect(await proofs.missed(id)).to.equal(1) }) - it("does not mark a proof as missing before timeout", async function () { - await mineUntilProofIsRequired(id) - let blocknumber = await minedBlockNumber() - await mineBlock() + it("does not mark a proof as missing before period end", async function () { + await waitUntilProofIsRequired(id) + let currentPeriod = periodOf(await currentTime()) await expect( - proofs.markProofAsMissing(id, blocknumber) - ).to.be.revertedWith("Proof has not timed out yet") + proofs.markProofAsMissing(id, currentPeriod) + ).to.be.revertedWith("Period has not ended yet") + }) + + it("does not mark a proof as missing after timeout", async function () { + await waitUntilProofIsRequired(id) + let currentPeriod = periodOf(await currentTime()) + await advanceTimeTo(periodEnd(currentPeriod) + timeout) + await expect( + proofs.markProofAsMissing(id, currentPeriod) + ).to.be.revertedWith("Validation timed out") }) it("does not mark a submitted proof as missing", async function () { - await mineUntilProofIsRequired(id) - let blocknumber = await minedBlockNumber() - await proofs.submitProof(id, blocknumber, true) - await mineUntilProofTimeout() + await waitUntilProofIsRequired(id) + let submittedPeriod = periodOf(await currentTime()) + await proofs.submitProof(id, true) + await advanceTimeTo(periodEnd(submittedPeriod)) await expect( - proofs.markProofAsMissing(id, blocknumber) + proofs.markProofAsMissing(id, submittedPeriod) ).to.be.revertedWith("Proof was submitted, not missing") }) it("does not mark proof as missing when not required", async function () { - while (await proofs.isProofRequired(id, await minedBlockNumber())) { - mineBlock() + while (await proofs.isProofRequired(id)) { + await advanceTime(period) } - let blocknumber = await minedBlockNumber() - await mineUntilProofTimeout() + let currentPeriod = periodOf(await currentTime()) + await advanceTimeTo(periodEnd(currentPeriod)) await expect( - proofs.markProofAsMissing(id, blocknumber) + proofs.markProofAsMissing(id, currentPeriod) ).to.be.revertedWith("Proof was not required") }) it("does not mark proof as missing twice", async function () { - await mineUntilProofIsRequired(id) - let blocknumber = await minedBlockNumber() - await mineUntilProofTimeout() - await proofs.markProofAsMissing(id, blocknumber) + await waitUntilProofIsRequired(id) + let missedPeriod = periodOf(await currentTime()) + await advanceTimeTo(periodEnd(missedPeriod)) + await proofs.markProofAsMissing(id, missedPeriod) await expect( - proofs.markProofAsMissing(id, blocknumber) + proofs.markProofAsMissing(id, missedPeriod) ).to.be.revertedWith("Proof already marked as missing") }) }) diff --git a/test/Storage.test.js b/test/Storage.test.js index 7e21e63..b4bbd08 100644 --- a/test/Storage.test.js +++ b/test/Storage.test.js @@ -1,7 +1,13 @@ const { expect } = require("chai") const { ethers, deployments } = require("hardhat") const { exampleRequest, exampleOffer } = require("./examples") -const { mineBlock, minedBlockNumber } = require("./evm") +const { + mineBlock, + minedBlockNumber, + advanceTime, + advanceTimeTo, + currentTime, +} = require("./evm") const { requestId, offerId } = require("./ids") describe("Storage", function () { @@ -17,6 +23,12 @@ describe("Storage", function () { storage = storage.connect(account) } + async function ensureEnoughBlockHistory() { + while ((await minedBlockNumber()) < 256) { + await mineBlock() + } + } + beforeEach(async function () { ;[client, host] = await ethers.getSigners() @@ -48,6 +60,8 @@ describe("Storage", function () { switchAccount(client) await storage.selectOffer(offerId(offer)) id = offerId(offer) + + await ensureEnoughBlockHistory() }) describe("starting the contract", function () { @@ -77,11 +91,9 @@ describe("Storage", function () { await storage.startContract(id) }) - async function mineUntilEnd() { - const end = await storage.proofEnd(id) - while ((await minedBlockNumber()) < end) { - await mineBlock() - } + async function waitUntilEnd() { + const end = (await storage.proofEnd(id)).toNumber() + await advanceTimeTo(end) } // it("unlocks the host collateral", async function () { @@ -91,7 +103,7 @@ describe("Storage", function () { // }) it("pays the host", async function () { - await mineUntilEnd() + await waitUntilEnd() const startBalance = await token.balanceOf(host.address) await storage.finishContract(id) const endBalance = await token.balanceOf(host.address) @@ -105,7 +117,7 @@ describe("Storage", function () { }) it("can only be done once", async function () { - await mineUntilEnd() + await waitUntilEnd() await storage.finishContract(id) await expect(storage.finishContract(id)).to.be.revertedWith( "Contract already finished" @@ -114,20 +126,34 @@ describe("Storage", function () { }) describe("slashing when missing proofs", function () { - beforeEach(function () { + let period + + beforeEach(async function () { switchAccount(host) + period = (await storage.proofPeriod()).toNumber() }) + function periodOf(timestamp) { + return Math.floor(timestamp / period) + } + + function periodStart(p) { + return period * p + } + + function periodEnd(p) { + return periodStart(p + 1) + } + async function ensureProofIsMissing() { - while (!(await storage.isProofRequired(id, await minedBlockNumber()))) { - mineBlock() + let currentPeriod = periodOf(await currentTime()) + await advanceTimeTo(periodEnd(currentPeriod)) + while (!(await storage.isProofRequired(id))) { + await advanceTime(period) } - const blocknumber = await minedBlockNumber() - const timeout = await storage.proofTimeout() - for (let i = 0; i < timeout; i++) { - mineBlock() - } - await storage.markProofAsMissing(id, blocknumber) + let missedPeriod = periodOf(await currentTime()) + await advanceTime(period) + await storage.markProofAsMissing(id, missedPeriod) } it("reduces collateral when too many proofs are missing", async function () { diff --git a/test/evm.js b/test/evm.js index c9588d4..e92090a 100644 --- a/test/evm.js +++ b/test/evm.js @@ -32,6 +32,13 @@ async function advanceTime(seconds) { await mineBlock() } +async function advanceTimeTo(timestamp) { + if ((await currentTime()) !== timestamp) { + ethers.provider.send("evm_setNextBlockTimestamp", [timestamp]) + await mineBlock() + } +} + module.exports = { snapshot, revert, @@ -39,4 +46,5 @@ module.exports = { minedBlockNumber, currentTime, advanceTime, + advanceTimeTo, } diff --git a/test/examples.js b/test/examples.js index e0e3710..a1e3c06 100644 --- a/test/examples.js +++ b/test/examples.js @@ -4,10 +4,10 @@ const { sha256, hexlify, randomBytes } = ethers.utils const exampleRequest = () => ({ client: hexlify(randomBytes(20)), - duration: 150, // 150 blocks ≈ half an hour + duration: hours(10), size: 1 * 1024 * 1024 * 1024, // 1 Gigabyte contentHash: sha256("0xdeadbeef"), - proofProbability: 5, // require a proof roughly once every 5^2 periods + proofProbability: 2, // require a proof roughly once every 2² periods maxPrice: 84, expiry: now() + hours(1), nonce: hexlify(randomBytes(32)),