Skip to content

Commit

Permalink
Merge pull request #11 from lottopgf/feature/rerequest
Browse files Browse the repository at this point in the history
add #forceRedraw escape hatch
  • Loading branch information
kevincharm authored Sep 22, 2024
2 parents c2683ed + d2073f1 commit 576afce
Show file tree
Hide file tree
Showing 8 changed files with 258 additions and 143 deletions.
89 changes: 53 additions & 36 deletions contracts/Lootery.sol
Original file line number Diff line number Diff line change
Expand Up @@ -423,45 +423,62 @@ contract Lootery is
_setupNextGame();
} else {
// Case #2: Tickets were sold
currentGame.state = GameState.DrawPending;

// Assert that we have enough in operational funds so as to not eat
// into jackpots or whatever else.
uint256 requestPrice = getRequestPrice();
if (msg.value > requestPrice) {
// Refund excess to caller, if any
uint256 excess = msg.value - requestPrice;
(bool success, bytes memory data) = msg.sender.call{
value: excess
}("");
if (!success) {
revert TransferFailure(msg.sender, excess, data);
}
emit ExcessRefunded(msg.sender, excess);
}
if (address(this).balance < requestPrice) {
revert InsufficientOperationalFunds(
address(this).balance,
requestPrice
);
}
// VRF call to trusted coordinator
// slither-disable-next-line reentrancy-eth,arbitrary-send-eth
uint256 requestId = IAnyrand(randomiser).requestRandomness{
value: requestPrice
}(block.timestamp + 30, callbackGasLimit);
if (requestId > type(uint208).max) {
revert RequestIdOverflow(requestId);
_requestRandomness();
}
}

/// @notice This is an escape hatch to re-request randomness in case there
/// is some issue with the VRF fulfiller.
function forceRedraw() external payable onlyInState(GameState.DrawPending) {
RandomnessRequest memory request = randomnessRequest;
if (request.requestId == 0) {
revert NoRandomnessRequestInFlight();
}

// There is a pending request present: check if it's been waiting for a while
if (block.timestamp >= request.timestamp + 1 hours) {
// 30 minutes have passed since the request was made
_requestRandomness();
} else {
revert WaitLonger(request.timestamp + 1 hours);
}
}

/// @notice Request randomness from VRF
function _requestRandomness() internal {
currentGame.state = GameState.DrawPending;

uint256 requestPrice = getRequestPrice();
if (msg.value > requestPrice) {
// Refund excess to caller, if any
uint256 excess = msg.value - requestPrice;
(bool success, bytes memory data) = msg.sender.call{value: excess}(
""
);
if (!success) {
revert TransferFailure(msg.sender, excess, data);
}
randomnessRequest = RandomnessRequest({
requestId: uint208(requestId),
timestamp: uint48(block.timestamp)
});
emit RandomnessRequested(
uint208(requestId),
uint48(block.timestamp)
emit ExcessRefunded(msg.sender, excess);
}
if (address(this).balance < requestPrice) {
revert InsufficientOperationalFunds(
address(this).balance,
requestPrice
);
}
// VRF call to trusted coordinator
// slither-disable-next-line reentrancy-eth,arbitrary-send-eth
uint256 requestId = IAnyrand(randomiser).requestRandomness{
value: requestPrice
}(block.timestamp + 30, callbackGasLimit);
if (requestId > type(uint208).max) {
revert RequestIdOverflow(requestId);
}
randomnessRequest = RandomnessRequest({
requestId: uint208(requestId),
timestamp: uint48(block.timestamp)
});
emit RandomnessRequested(uint208(requestId), uint48(block.timestamp));
}

/// @notice Callback for VRF fulfiller.
Expand Down
1 change: 1 addition & 0 deletions contracts/interfaces/ILootery.sol
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ interface ILootery is IRandomiserCallback, IERC721 {
error UnknownBeneficiary(address beneficiary);
error EmptyDisplayName();
error NoTicketsSpecified();
error NoRandomnessRequestInFlight();

/// @notice Initialises the contract instance
function init(InitConfig memory initConfig) external;
Expand Down
5 changes: 5 additions & 0 deletions exported/abi/ILootery.json
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,11 @@
"name": "InvalidTicketSVGRenderer",
"type": "error"
},
{
"inputs": [],
"name": "NoRandomnessRequestInFlight",
"type": "error"
},
{
"inputs": [],
"name": "NoTicketsSpecified",
Expand Down
12 changes: 12 additions & 0 deletions exported/abi/Lootery.json
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,11 @@
"name": "InvalidTicketSVGRenderer",
"type": "error"
},
{
"inputs": [],
"name": "NoRandomnessRequestInFlight",
"type": "error"
},
{
"inputs": [],
"name": "NoTicketsSpecified",
Expand Down Expand Up @@ -1239,6 +1244,13 @@
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "forceRedraw",
"outputs": [],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [
{
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"chai": "^4.2.0",
"dotenv": "^16.3.1",
"ethers": "^6.13.2",
"hardhat": "^2.22.2",
"hardhat": "^2.22.11",
"hardhat-abi-exporter": "^2.10.1",
"hardhat-contract-sizer": "^2.10.0",
"hardhat-gas-reporter": "^1.0.9",
Expand Down
42 changes: 42 additions & 0 deletions test/Lootery.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,48 @@ describe('Lootery e2e', () => {
}
})

it('should be able to force redraw if draw is pending for too long', async () => {
const gamePeriod = 1n * 60n * 60n
async function deploy() {
return deployLotto({
deployer,
factory,
gamePeriod,
prizeToken: testERC20,
})
}
const { lotto, mockRandomiser } = await loadFixture(deploy)

// Buy some tickets
await testERC20.mint(deployer, parseEther('10'))
await testERC20.approve(lotto, parseEther('10'))
await purchaseTicket(lotto, bob.address, [1, 2, 3, 4, 5])

// Draw but don't fulfill
await time.increase(gamePeriod)
await setBalance(await lotto.getAddress(), parseEther('0.1'))
await lotto.draw()
const { requestId: requestId0 } = await lotto.randomnessRequest()
// We should not be able to call forceRedraw yet
await expect(lotto.forceRedraw()).to.be.revertedWithCustomError(lotto, 'WaitLonger')

// Fast forward, then try to forceRedraw
await time.increase(61 * 60) // ~1h
await expect(lotto.forceRedraw()).to.emit(lotto, 'RandomnessRequested')
const { requestId: requestId1 } = await lotto.randomnessRequest()
expect(requestId1).to.not.eq(requestId0)

// If we try to call forceRedraw immediately again, it should revert again
await expect(lotto.forceRedraw()).to.be.revertedWithCustomError(lotto, 'WaitLonger')

// Make sure we can fulfill and continue the game as normal
// Fulfill w/ mock randomiser
await expect(mockRandomiser.fulfillRandomWords(requestId1, [6942069420n])).to.emit(
lotto,
'GameFinalised',
)
})

// The gas refund has been removed, since it's hard to test and not future proof
it.skip('should refund gas to draw() keeper', async () => {
async function deploy() {
Expand Down
59 changes: 59 additions & 0 deletions test/Lootery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,65 @@ describe('Lootery', () => {
})
})

describe('#forceRedraw', () => {
beforeEach(async () => {
;({ lotto, mockRandomiser } = await deployLotto({
deployer,
factory,
gamePeriod: 3600n,
prizeToken: testERC20,
}))
})

it('should revert if called in any state other than DrawPending', async () => {
const allOtherStates = allStates.filter((state) => state !== GameState.DrawPending)
for (const state of allOtherStates) {
await lotto.setGameState(state)
await expect(lotto.forceRedraw())
.to.be.revertedWithCustomError(lotto, 'UnexpectedState')
.withArgs(state)
}
})

it('should revert if there is no randomness request in flight', async () => {
await lotto.setGameState(GameState.DrawPending)
await expect(lotto.forceRedraw()).to.be.revertedWithCustomError(
lotto,
'NoRandomnessRequestInFlight',
)
})

it('should revert if the request is not old enough', async () => {
await lotto.setGameState(GameState.DrawPending)
const timestamp = await ethers.provider
.getBlock('latest')
.then((block) => block!.timestamp)
await lotto.setRandomnessRequest({
requestId: 1n,
timestamp,
})
await expect(lotto.forceRedraw()).to.be.revertedWithCustomError(lotto, 'WaitLonger')
})

it('should re-request randomness if the request is old enough', async () => {
await lotto.setGameState(GameState.DrawPending)
const timestamp = await ethers.provider
.getBlock('latest')
.then((block) => block!.timestamp)
await lotto.setRandomnessRequest({
requestId: 1n,
timestamp,
})
await time.increase(61 * 60) // 61 mins
const requestPrice = await lotto.getRequestPrice()
await expect(
lotto.forceRedraw({
value: requestPrice * 2n,
}),
).to.emit(lotto, 'RandomnessRequested')
})
})

describe('#receiveRandomWords', () => {
let reqId = 1n
beforeEach(async () => {
Expand Down
Loading

0 comments on commit 576afce

Please sign in to comment.