diff --git a/packages/evm-contracts/contracts/DuelOutcomeOracle.sol b/packages/evm-contracts/contracts/DuelOutcomeOracle.sol index c494ec93..23bfad57 100644 --- a/packages/evm-contracts/contracts/DuelOutcomeOracle.sol +++ b/packages/evm-contracts/contracts/DuelOutcomeOracle.sol @@ -290,7 +290,7 @@ contract DuelOutcomeOracle is AccessControl { if (duel.status != DuelStatus.LOCKED) revert DuelNotLocked(); if (block.timestamp < duel.betCloseTs) revert BettingWindowActive(); if (winner != Side.A && winner != Side.B) revert InvalidWinner(); - if (duelEndTs < duel.betCloseTs) revert InvalidDuelEnd(); + _validateResultTime(duel, duelEndTs); id = proposalId(duelKey, resultHash, replayHash); if (proposals[id].exists) revert ProposalExists(); @@ -345,7 +345,7 @@ contract DuelOutcomeOracle is AccessControl { _requireSettleable(duel); if (duel.status != DuelStatus.CHALLENGED) revert NotChallenged(); if (winner != Side.A && winner != Side.B) revert InvalidWinner(); - if (duelEndTs < duel.betCloseTs) revert InvalidDuelEnd(); + _validateResultTime(duel, duelEndTs); delete proposals[duel.activeProposalId]; @@ -407,4 +407,10 @@ contract DuelOutcomeOracle is AccessControl { if (duel.status == DuelStatus.RESOLVED) revert DuelAlreadyResolved(); if (duel.status == DuelStatus.CANCELLED) revert DuelAlreadyCancelled(); } + + function _validateResultTime(DuelState storage duel, uint64 duelEndTs) private view { + if (duelEndTs < duel.betCloseTs) revert InvalidDuelEnd(); + if (duelEndTs < duel.duelStartTs) revert InvalidDuelEnd(); + if (duelEndTs > block.timestamp) revert InvalidDuelEnd(); + } } diff --git a/packages/evm-contracts/scripts/simulate-adversarial-localnet.ts b/packages/evm-contracts/scripts/simulate-adversarial-localnet.ts index 8b78472f..0d8df2c9 100644 --- a/packages/evm-contracts/scripts/simulate-adversarial-localnet.ts +++ b/packages/evm-contracts/scripts/simulate-adversarial-localnet.ts @@ -415,9 +415,16 @@ async function runArbitrageScenario( throw new Error(`arb bot stake is not discounted: ${combinedStake.toString()}`); } - const latestBlock = await fixture.provider.getBlock("latest"); - const now = BigInt(latestBlock?.timestamp ?? Math.floor(Date.now() / 1000)); await lockMarket(fixture, duel, "arbitrage"); + const storedDuel = await fixture.oracle.getDuel(duel); + await fixture.provider.send("evm_setNextBlockTimestamp", [ + Number(storedDuel.duelStartTs + 1n), + ]); + await fixture.provider.send("evm_mine", []); + const resultBlock = await fixture.provider.getBlock("latest"); + const duelEndTs = BigInt( + resultBlock?.timestamp ?? Number(storedDuel.duelStartTs + 1n), + ); await ( await fixture.oracle.connect(fixture.reporter).proposeResult( duel, @@ -425,7 +432,7 @@ async function runArbitrageScenario( 77, ethers.keccak256(ethers.toUtf8Bytes("arb-replay")), ethers.keccak256(ethers.toUtf8Bytes("arb-result")), - now + 180n, + duelEndTs, "arb-resolved", ) ).wait(); diff --git a/packages/evm-contracts/scripts/simulate-localnet.ts b/packages/evm-contracts/scripts/simulate-localnet.ts index 07396df9..183b1ca4 100644 --- a/packages/evm-contracts/scripts/simulate-localnet.ts +++ b/packages/evm-contracts/scripts/simulate-localnet.ts @@ -116,13 +116,17 @@ async function main() { "localnet-simulation-locked", DUEL_STATUS_LOCKED, ); + await ethers.provider.send("evm_setNextBlockTimestamp", [Number(now + 121n)]); + await ethers.provider.send("evm_mine", []); + const resultBlock = await ethers.provider.getBlock("latest"); + const duelEndTs = BigInt(resultBlock?.timestamp ?? Number(now + 121n)); await oracle.connect(reporter).proposeResult( duel, SIDE_A, 42, ethers.keccak256(ethers.toUtf8Bytes("replay")), ethers.keccak256(ethers.toUtf8Bytes("result")), - now + 180n, + duelEndTs, "resolved", ); await ethers.provider.send("evm_increaseTime", [3600]); diff --git a/packages/evm-contracts/test/ExploitSuite.t.sol b/packages/evm-contracts/test/ExploitSuite.t.sol index 6739ff48..41616d12 100644 --- a/packages/evm-contracts/test/ExploitSuite.t.sol +++ b/packages/evm-contracts/test/ExploitSuite.t.sol @@ -113,13 +113,14 @@ contract ExploitSuiteTest is Test { "valid", DuelOutcomeOracle.DuelStatus.LOCKED ); + vm.warp(nowTs + 150); oracle.proposeResult( duelKey, DuelOutcomeOracle.Side.A, 123, bytes32(0), bytes32(0), - uint64(block.timestamp + 200), + nowTs + 150, "proposed" ); vm.stopPrank(); @@ -159,6 +160,7 @@ contract ExploitSuiteTest is Test { DuelOutcomeOracle.DuelStatus.LOCKED ); + vm.warp(nowTs + 150); vm.prank(reporter); oracle.proposeResult( duelKey, @@ -166,7 +168,7 @@ contract ExploitSuiteTest is Test { 123, bytes32(0), bytes32(0), - uint64(block.timestamp + 200), + nowTs + 150, "proposed" ); @@ -300,7 +302,7 @@ contract ExploitSuiteTest is Test { DuelOutcomeOracle.DuelStatus.LOCKED ); - vm.warp(nowTs + 101); + vm.warp(nowTs + 150); vm.prank(reporter); oracle.proposeResult( duelKey, @@ -308,7 +310,7 @@ contract ExploitSuiteTest is Test { 1, bytes32(0), bytes32(0), - uint64(block.timestamp + 50), + nowTs + 150, "proposed" ); @@ -384,6 +386,7 @@ contract ExploitSuiteTest is Test { vm.prank(alice); clob.cancelOrder(duelKey, 0, 1); + vm.warp(nowTs + 150); vm.prank(reporter); oracle.proposeResult( duelKey, @@ -391,7 +394,7 @@ contract ExploitSuiteTest is Test { 1, bytes32(0), bytes32(0), - uint64(block.timestamp + 150), + nowTs + 150, "proposed" ); @@ -446,6 +449,7 @@ contract ExploitSuiteTest is Test { DuelOutcomeOracle.DuelStatus.LOCKED ); + vm.warp(nowTs + 150); vm.prank(reporter); oracle.proposeResult( duelKey, @@ -453,7 +457,7 @@ contract ExploitSuiteTest is Test { 1, bytes32(0), bytes32(0), - uint64(block.timestamp + 50), + nowTs + 150, "proposed" ); @@ -514,8 +518,9 @@ contract ExploitSuiteTest is Test { nowTs, nowTs + 100, nowTs + 150, "locked", DuelOutcomeOracle.DuelStatus.LOCKED ); + vm.warp(nowTs + 150); vm.prank(reporter); - oracle.proposeResult(duelKey, DuelOutcomeOracle.Side.B, 1, bytes32(0), bytes32(0), uint64(block.timestamp + 50), "p"); + oracle.proposeResult(duelKey, DuelOutcomeOracle.Side.B, 1, bytes32(0), bytes32(0), nowTs + 150, "p"); vm.warp(block.timestamp + disputeWindow + 1); vm.prank(finalizer); oracle.finalizeResult(duelKey, "resolved"); diff --git a/packages/evm-contracts/test/GoldClobSettlement.t.sol b/packages/evm-contracts/test/GoldClobSettlement.t.sol index 8a901d10..8cb1a18e 100644 --- a/packages/evm-contracts/test/GoldClobSettlement.t.sol +++ b/packages/evm-contracts/test/GoldClobSettlement.t.sol @@ -213,6 +213,8 @@ contract GoldClobSettlementTest is Test { function _resolveDuel(bytes32 duel, DuelOutcomeOracle.Side winner) private { _lockDuel(duel); + DuelOutcomeOracle.DuelState memory d = oracle.getDuel(duel); + if (block.timestamp < d.duelStartTs) vm.warp(d.duelStartTs); vm.prank(reporter); oracle.proposeResult( @@ -221,7 +223,7 @@ contract GoldClobSettlementTest is Test { 42, _hashLabel("replay"), _hashLabel("result"), - uint64(block.timestamp + 180), + d.duelStartTs, "resolved" ); vm.warp(block.timestamp + 3_600); diff --git a/packages/evm-contracts/test/GoldClobStress.t.sol b/packages/evm-contracts/test/GoldClobStress.t.sol index 99b46ef4..52c87a90 100644 --- a/packages/evm-contracts/test/GoldClobStress.t.sol +++ b/packages/evm-contracts/test/GoldClobStress.t.sol @@ -69,9 +69,10 @@ contract GoldClobStressTest is Test { vm.prank(reporter); oracle.upsertDuel(duel, d.participantAHash, d.participantBHash, d.betOpenTs, d.betCloseTs, d.duelStartTs, "", DuelOutcomeOracle.DuelStatus.LOCKED); + if (block.timestamp < d.duelStartTs) vm.warp(d.duelStartTs); vm.prank(reporter); oracle.proposeResult(duel, winner, 42, keccak256("r"), keccak256("h"), - uint64(block.timestamp + 100), ""); + d.duelStartTs, ""); vm.warp(block.timestamp + 3_601); vm.prank(finalizer); oracle.finalizeResult(duel, ""); diff --git a/packages/evm-contracts/test/OracleFinality.t.sol b/packages/evm-contracts/test/OracleFinality.t.sol index 46aec193..ebf2d15a 100644 --- a/packages/evm-contracts/test/OracleFinality.t.sol +++ b/packages/evm-contracts/test/OracleFinality.t.sol @@ -61,8 +61,8 @@ contract OracleFinalityTest is Test { bytes32 resultHash = keccak256(abi.encode("result", key)); bytes32 replayHash = keccak256(abi.encode("replay", key)); - // Warp past betCloseTs (2_000) so proposing is allowed - if (block.timestamp < 2_001) vm.warp(2_001); + // Warp past duelStartTs so a result can exist. + if (block.timestamp < 4_000) vm.warp(4_000); vm.prank(reporter); oracle.proposeResult(key, DuelOutcomeOracle.Side.A, 42, replayHash, resultHash, 4_000, ""); @@ -111,8 +111,8 @@ contract OracleFinalityTest is Test { bytes32 rh = keccak256("r"); bytes32 rp = keccak256("p"); - // Warp past betCloseTs (2_000) so proposing is allowed - vm.warp(2_001); + // Warp past duelStartTs so a result can exist. + vm.warp(4_000); vm.prank(reporter); o.proposeResult(key, DuelOutcomeOracle.Side.A, 1, rp, rh, 4_000, ""); @@ -202,7 +202,7 @@ contract OracleFinalityTest is Test { function test_noWinnerInProposed() public { bytes32 key = _createLockedDuel(302); - vm.warp(2_001); + vm.warp(4_000); vm.prank(reporter); oracle.proposeResult(key, DuelOutcomeOracle.Side.B, 99, keccak256("rp"), keccak256("rh"), 4_000, ""); @@ -212,7 +212,7 @@ contract OracleFinalityTest is Test { function test_noWinnerInChallenged() public { bytes32 key = _createLockedDuel(303); - vm.warp(2_001); + vm.warp(4_000); vm.prank(reporter); oracle.proposeResult(key, DuelOutcomeOracle.Side.A, 1, keccak256("rp2"), keccak256("rh2"), 4_000, ""); @@ -222,6 +222,58 @@ contract OracleFinalityTest is Test { assertEq(uint8(d.winner), 0); } + function test_proposeRejectsResultBeforeDuelStart() public { + bytes32 key = _createLockedDuel(305); + vm.warp(3_000); + + vm.prank(reporter); + vm.expectRevert(DuelOutcomeOracle.InvalidDuelEnd.selector); + oracle.proposeResult(key, DuelOutcomeOracle.Side.A, 1, + keccak256("early-replay"), keccak256("early-result"), 2_000, ""); + } + + function test_proposeRejectsFutureResultEnd() public { + bytes32 key = _createLockedDuel(306); + vm.warp(3_000); + + vm.prank(reporter); + vm.expectRevert(DuelOutcomeOracle.InvalidDuelEnd.selector); + oracle.proposeResult(key, DuelOutcomeOracle.Side.A, 1, + keccak256("future-replay"), keccak256("future-result"), 3_001, ""); + } + + function test_reproposeRejectsResultBeforeDuelStart() public { + bytes32 key = _createLockedDuel(307); + vm.warp(4_000); + + vm.prank(reporter); + oracle.proposeResult(key, DuelOutcomeOracle.Side.A, 1, + keccak256("replay"), keccak256("result"), 4_000, ""); + vm.prank(challenger); + oracle.challengeResult(key, ""); + + vm.prank(reporter); + vm.expectRevert(DuelOutcomeOracle.InvalidDuelEnd.selector); + oracle.reproposeResult(key, DuelOutcomeOracle.Side.A, 2, + keccak256("early-replay-2"), keccak256("early-result-2"), 2_000, ""); + } + + function test_reproposeRejectsFutureResultEnd() public { + bytes32 key = _createLockedDuel(308); + vm.warp(4_000); + + vm.prank(reporter); + oracle.proposeResult(key, DuelOutcomeOracle.Side.A, 1, + keccak256("replay-future"), keccak256("result-future"), 4_000, ""); + vm.prank(challenger); + oracle.challengeResult(key, ""); + + vm.prank(reporter); + vm.expectRevert(DuelOutcomeOracle.InvalidDuelEnd.selector); + oracle.reproposeResult(key, DuelOutcomeOracle.Side.A, 2, + keccak256("future-replay-2"), keccak256("future-result-2"), 4_001, ""); + } + function test_noWinnerInCancelled() public { bytes32 key = _createLockedDuel(304); vm.prank(pauser); @@ -286,7 +338,7 @@ contract OracleFinalityTest is Test { function test_onlyReporterCanPropose() public { bytes32 key = _createLockedDuel(501); - vm.warp(2_001); + vm.warp(4_000); vm.prank(other); vm.expectRevert(); oracle.proposeResult(key, DuelOutcomeOracle.Side.A, 1, @@ -295,7 +347,7 @@ contract OracleFinalityTest is Test { function test_onlyFinalizerCanFinalize() public { bytes32 key = _createLockedDuel(502); - vm.warp(2_001); + vm.warp(4_000); vm.prank(reporter); oracle.proposeResult(key, DuelOutcomeOracle.Side.A, 1, keccak256("r3"), keccak256("h3"), 4_000, ""); @@ -308,7 +360,7 @@ contract OracleFinalityTest is Test { function test_onlyChallengerCanChallenge() public { bytes32 key = _createLockedDuel(503); - vm.warp(2_001); + vm.warp(4_000); vm.prank(reporter); oracle.proposeResult(key, DuelOutcomeOracle.Side.B, 1, keccak256("r4"), keccak256("h4"), 4_000, ""); diff --git a/packages/evm-contracts/test/adversarial/PmPerpsAdversarial.t.sol b/packages/evm-contracts/test/adversarial/PmPerpsAdversarial.t.sol index f06e325c..3184f72f 100644 --- a/packages/evm-contracts/test/adversarial/PmPerpsAdversarial.t.sol +++ b/packages/evm-contracts/test/adversarial/PmPerpsAdversarial.t.sol @@ -269,11 +269,12 @@ contract PmPerpsAdversarialTest is Test { DuelOutcomeOracle.DuelStatus.LOCKED ); + vm.warp(setupDuelStart); vm.prank(reporter); duelOracle.proposeResult( duelKey, DuelOutcomeOracle.Side(1), 42, keccak256("r"), keccak256("h"), - uint64(block.timestamp), "" + setupDuelStart, "" ); vm.warp(block.timestamp + 4000); diff --git a/packages/evm-contracts/test/fuzz/GoldClobFuzz.t.sol b/packages/evm-contracts/test/fuzz/GoldClobFuzz.t.sol index 8c8c8a3f..95d596df 100644 --- a/packages/evm-contracts/test/fuzz/GoldClobFuzz.t.sol +++ b/packages/evm-contracts/test/fuzz/GoldClobFuzz.t.sol @@ -244,6 +244,8 @@ contract GoldClobFuzzTest is Test { function _resolveDuel(bytes32 duel, DuelOutcomeOracle.Side winner) private { _lockDuel(duel); + DuelOutcomeOracle.DuelState memory d = oracle.getDuel(duel); + if (block.timestamp < d.duelStartTs) vm.warp(d.duelStartTs); vm.prank(reporter); oracle.proposeResult( @@ -252,7 +254,7 @@ contract GoldClobFuzzTest is Test { 42, _hashLabel("replay"), _hashLabel("result"), - uint64(block.timestamp + 180), + d.duelStartTs, "resolved" ); vm.warp(block.timestamp + 3_600); diff --git a/packages/evm-contracts/test/integration/FullLifecycle.t.sol b/packages/evm-contracts/test/integration/FullLifecycle.t.sol index 0afa080b..82b86270 100644 --- a/packages/evm-contracts/test/integration/FullLifecycle.t.sol +++ b/packages/evm-contracts/test/integration/FullLifecycle.t.sol @@ -129,9 +129,10 @@ contract FullLifecycleTest is Test { d.betOpenTs, d.betCloseTs, d.duelStartTs, "", DuelOutcomeOracle.DuelStatus.LOCKED); + if (block.timestamp < d.duelStartTs) vm.warp(d.duelStartTs); vm.prank(reporter); duelOracle.proposeResult(duel, DuelOutcomeOracle.Side.A, 42, - keccak256("r"), keccak256("h"), uint64(block.timestamp + 100), ""); + keccak256("r"), keccak256("h"), d.duelStartTs, ""); vm.warp(block.timestamp + 3601); vm.prank(finalizer); @@ -259,10 +260,11 @@ contract FullLifecycleTest is Test { duelOracle.upsertDuel(duels[i], d.participantAHash, d.participantBHash, d.betOpenTs, d.betCloseTs, d.duelStartTs, "", DuelOutcomeOracle.DuelStatus.LOCKED); + if (block.timestamp < d.duelStartTs) vm.warp(d.duelStartTs); vm.prank(reporter); duelOracle.proposeResult(duels[i], DuelOutcomeOracle.Side.A, 42, keccak256(abi.encodePacked("r", i)), keccak256(abi.encodePacked("h", i)), - uint64(block.timestamp + 100), ""); + d.duelStartTs, ""); vm.warp(block.timestamp + 3601); vm.prank(finalizer); duelOracle.finalizeResult(duels[i], ""); @@ -318,9 +320,10 @@ contract FullLifecycleTest is Test { duelOracle.upsertDuel(duel, d.participantAHash, d.participantBHash, d.betOpenTs, d.betCloseTs, d.duelStartTs, "", DuelOutcomeOracle.DuelStatus.LOCKED); + if (block.timestamp < d.duelStartTs) vm.warp(d.duelStartTs); vm.prank(reporter); duelOracle.proposeResult(duel, DuelOutcomeOracle.Side.A, 42, - keccak256("r"), keccak256("h"), uint64(block.timestamp + 100), ""); + keccak256("r"), keccak256("h"), d.duelStartTs, ""); vm.warp(block.timestamp + 3601); vm.prank(finalizer); duelOracle.finalizeResult(duel, ""); diff --git a/packages/market-maker-bot/src/runtime-smoke.ts b/packages/market-maker-bot/src/runtime-smoke.ts index c33489f2..f76069b4 100644 --- a/packages/market-maker-bot/src/runtime-smoke.ts +++ b/packages/market-maker-bot/src/runtime-smoke.ts @@ -370,15 +370,21 @@ async function main() { { nonce: await nextNonce(reporter.address) }, ); const lockedDuel = await oracle.getDuel(duel); + const duelStartTs = BigInt(lockedDuel.duelStartTs); + const preResultBlock = await provider.getBlock("latest"); + const preResultTs = BigInt( + preResultBlock?.timestamp ?? Math.floor(Date.now() / 1000), + ); + if (preResultTs <= duelStartTs) { + await provider.send("evm_setNextBlockTimestamp", [ + Number(duelStartTs + 1n), + ]); + await provider.send("evm_mine", []); + } const resolvedBlock = await provider.getBlock("latest"); - const latestResolvedTs = BigInt( - resolvedBlock?.timestamp ?? Math.floor(Date.now() / 1000), + const duelEndTs = BigInt( + resolvedBlock?.timestamp ?? Number(duelStartTs + 1n), ); - const duelBetCloseTs = BigInt(lockedDuel.betCloseTs); - const duelEndTs = - latestResolvedTs > duelBetCloseTs - ? latestResolvedTs - : duelBetCloseTs + 1n; await oracleReporter.proposeResult( duel, WINNER_SIDE_A,