Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optional ticket #10

Merged
merged 6 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 34 additions & 11 deletions contracts/Lootery.sol
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ contract Lootery is
uint256 public seedJackpotMinValue;
/// @notice Ticket SVG renderer
address public ticketSVGRenderer;
/// @notice Callback gas limit
uint256 public callbackGasLimit = 500_000;

/// @dev Total supply of tokens/tickets, also used to determine next tokenId
uint256 public totalSupply;
Expand Down Expand Up @@ -136,10 +138,18 @@ contract Lootery is

factory = msg.sender;

if (initConfig.pickLength == 0) {
// Pick length of 0 doesn't make sense, pick length > 32 would consume
// too much gas. Also realistically, lottos usually pick 5-8 numbers.
if (initConfig.pickLength == 0 || initConfig.pickLength > 32) {
revert InvalidPickLength(initConfig.pickLength);
}
pickLength = initConfig.pickLength;

// If pick length > max ball value, then it's impossible to even
// purchase tickets. This is a configuration error.
if (initConfig.pickLength > initConfig.maxBallValue) {
revert InvalidMaxBallValue(initConfig.maxBallValue);
}
maxBallValue = initConfig.maxBallValue;

if (initConfig.gamePeriod < 10 minutes) {
Expand Down Expand Up @@ -189,6 +199,15 @@ contract Lootery is
});
}

/// @notice Set the callback gas limit
/// @param newCallbackGasLimit New callback gas limit
function setCallbackGasLimit(
uint256 newCallbackGasLimit
) external onlyOwner {
callbackGasLimit = newCallbackGasLimit;
emit CallbackGasLimitSet(newCallbackGasLimit);
}

/// @notice Get all beneficiaries (shouldn't be such a huge list)
function beneficiaries()
external
Expand Down Expand Up @@ -281,17 +300,21 @@ contract Lootery is
address whomst = tickets[t].whomst;
uint8[] memory pick = tickets[t].pick;

if (pick.length != pickLength_) {
// Empty pick means this particular player does not wish to receive
// an entry to the lottery.
if (pick.length != pickLength_ && pick.length != 0) {
revert InvalidPickLength(pick.length);
}

// Assert balls are ascendingly sorted, with no possibility of duplicates
uint8 lastBall;
for (uint256 i; i < pickLength_; ++i) {
uint8 ball = pick[i];
if (ball <= lastBall) revert UnsortedPick(pick);
if (ball > maxBallValue_) revert InvalidBallValue(ball);
lastBall = ball;
if (pick.length != 0) {
// Assert balls are ascendingly sorted, with no possibility of duplicates
uint8 lastBall;
for (uint256 i; i < pickLength_; ++i) {
uint8 ball = pick[i];
if (ball <= lastBall) revert UnsortedPick(pick);
if (ball > maxBallValue_) revert InvalidBallValue(ball);
lastBall = ball;
}
}

// Record picked numbers
Expand Down Expand Up @@ -400,7 +423,7 @@ contract Lootery is
// Assert that we have enough in operational funds so as to not eat
// into jackpots or whatever else.
uint256 requestPrice = IAnyrand(randomiser).getRequestPrice(
500_000 /** TODO: Really need to make this configurable */
callbackGasLimit
);
if (msg.value > requestPrice) {
// Refund excess to caller, if any
Expand All @@ -423,7 +446,7 @@ contract Lootery is
// slither-disable-next-line reentrancy-eth,arbitrary-send-eth
uint256 requestId = IAnyrand(randomiser).requestRandomness{
value: requestPrice
}(block.timestamp + 30, 500_000);
}(block.timestamp + 30, callbackGasLimit);
if (requestId > type(uint208).max) {
revert RequestIdOverflow(requestId);
}
Expand Down
2 changes: 2 additions & 0 deletions contracts/interfaces/ILootery.sol
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,11 @@ interface ILootery is IRandomiserCallback, IERC721 {
event BeneficiaryRemoved(address indexed beneficiary);
event ExcessRefunded(address indexed to, uint256 value);
event ProtocolFeePaid(address indexed to, uint256 value);
event CallbackGasLimitSet(uint256 newCallbackGasLimit);

error TransferFailure(address to, uint256 value, bytes reason);
error InvalidPickLength(uint256 pickLength);
error InvalidMaxBallValue(uint256 maxBallValue);
error InvalidGamePeriod(uint256 gamePeriod);
error InvalidTicketPrice(uint256 ticketPrice);
error InvalidFeeShares();
Expand Down
9 changes: 0 additions & 9 deletions contracts/periphery/TicketSVGRenderer.sol
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,6 @@ contract TicketSVGRenderer is ITicketSVGRenderer, ERC165 {
uint8 maxPick,
uint8[] memory pick
) public pure returns (string memory) {
if (pick.length == 0) revert EmptyPicks();
if (pick[0] > maxPick) revert OutOfRange(pick[0], maxPick);
if (pick.length > 1) {
for (uint256 i = 1; i < pick.length; ++i) {
if (pick[i - 1] >= pick[i]) revert UnsortedPick(pick);
if (pick[i] > maxPick) revert OutOfRange(pick[i], maxPick);
}
}

uint256 rows = (maxPick / NUMBERS_PER_ROW) +
(maxPick % NUMBERS_PER_ROW == 0 ? 0 : 1);
uint256 positionY = 75;
Expand Down
39 changes: 38 additions & 1 deletion contracts/test/fuzz/EchidnaLootery.sol
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,40 @@ contract EchidnaLootery {
lootery.seedJackpot(value);
}

function ownerPick(uint256 numTickets, uint256 seed) external {
numTickets = numTickets % 20; // max 20 tix
lastTicketSeed = seed;

///////////////////////////////////////////////////////////////////////
/// Initial state /////////////////////////////////////////////////////
uint256 totalSupply0 = lootery.totalSupply();
uint256 jackpot0 = lootery.jackpot();
uint256 accruedCommunityFees0 = lootery.accruedCommunityFees();
///////////////////////////////////////////////////////////////////////

ILootery.Ticket[] memory tickets = new ILootery.Ticket[](numTickets);
for (uint256 i = 0; i < numTickets; i++) {
lastTicketSeed = uint256(
keccak256(abi.encodePacked(lastTicketSeed))
);
bool isEmptyPick = lastTicketSeed % 2 == 0;
tickets[i] = ILootery.Ticket({
whomst: msg.sender,
pick: isEmptyPick
? new uint8[](0)
: lootery.computeWinningPick(lastTicketSeed)
});
}
lootery.ownerPick(tickets);

///////////////////////////////////////////////////////////////////////
/// Postconditions ////////////////////////////////////////////////////
assert(lootery.totalSupply() == totalSupply0 + numTickets);
assert(lootery.jackpot() == jackpot0);
assert(lootery.accruedCommunityFees() == accruedCommunityFees0);
///////////////////////////////////////////////////////////////////////
}

function purchase(uint256 numTickets, uint256 seed) external {
numTickets = numTickets % 20; // max 20 tix
lastTicketSeed = seed;
Expand All @@ -117,9 +151,12 @@ contract EchidnaLootery {
lastTicketSeed = uint256(
keccak256(abi.encodePacked(lastTicketSeed))
);
bool isEmptyPick = lastTicketSeed % 2 == 0;
tickets[i] = ILootery.Ticket({
whomst: msg.sender,
pick: lootery.computeWinningPick(lastTicketSeed)
pick: isEmptyPick
? new uint8[](0)
: lootery.computeWinningPick(lastTicketSeed)
});
}
// TODO: fuzz beneficiaries
Expand Down
24 changes: 24 additions & 0 deletions exported/abi/ILootery.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,17 @@
"name": "InvalidGamePeriod",
"type": "error"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "maxBallValue",
"type": "uint256"
}
],
"name": "InvalidMaxBallValue",
"type": "error"
},
{
"inputs": [
{
Expand Down Expand Up @@ -416,6 +427,19 @@
"name": "BeneficiaryRemoved",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "uint256",
"name": "newCallbackGasLimit",
"type": "uint256"
}
],
"name": "CallbackGasLimitSet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
Expand Down
50 changes: 50 additions & 0 deletions exported/abi/Lootery.json
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,17 @@
"name": "InvalidInputs",
"type": "error"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "maxBallValue",
"type": "uint256"
}
],
"name": "InvalidMaxBallValue",
"type": "error"
},
{
"inputs": [
{
Expand Down Expand Up @@ -599,6 +610,19 @@
"name": "BeneficiaryRemoved",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "uint256",
"name": "newCallbackGasLimit",
"type": "uint256"
}
],
"name": "CallbackGasLimitSet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
Expand Down Expand Up @@ -1070,6 +1094,19 @@
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "callbackGasLimit",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
Expand Down Expand Up @@ -1826,6 +1863,19 @@
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "newCallbackGasLimit",
"type": "uint256"
}
],
"name": "setCallbackGasLimit",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
Expand Down
71 changes: 71 additions & 0 deletions test/Lootery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,34 @@ describe('Lootery', () => {
)
})

it('should revert if pickLength > maxBallValue', async () => {
await expect(
lotto.init({
...validConfig,
maxBallValue: 5,
pickLength: 6,
}),
).to.be.revertedWithCustomError(lotto, 'InvalidMaxBallValue')

// pickLength == maxBallValue is ok (even though every ticket would be a winner)
await expect(
lotto.init({
...validConfig,
maxBallValue: 6,
pickLength: 6,
}),
).to.not.be.reverted
})

it('should revert if pickLength > 32', async () => {
await expect(
lotto.init({
...validConfig,
pickLength: 33,
}),
).to.be.revertedWithCustomError(lotto, 'InvalidPickLength')
})

it('should revert if gamePeriod < 10 minutes', async () => {
await expect(
lotto.init({
Expand Down Expand Up @@ -418,6 +446,24 @@ describe('Lootery', () => {
expect(await lotto.ownerOf(3n)).to.equal(bob.address)
})

it('should allow an empty pick to mint dummy tickets', async () => {
// A player should be able to purchase a ticket specifying an empty pick,
// which means that they only wish to donate and not participate in the lottery.
await lotto.setGameState(GameState.Purchase)
const purchaseTx = lotto.pickTickets([
{ whomst: alice.address, pick: [] },
{ whomst: bob.address, pick: [] },
])
await expect(purchaseTx)
.to.emit(lotto, 'TicketPurchased')
.withArgs(0, alice.address, 1n, [])
await expect(purchaseTx)
.to.emit(lotto, 'TicketPurchased')
.withArgs(0, bob.address, 2n, [])
expect(await lotto.ownerOf(1n)).to.equal(alice.address)
expect(await lotto.ownerOf(2n)).to.equal(bob.address)
})

it('should revert if ticket has invalid pick length', async () => {
await lotto.setGameState(GameState.Purchase)
await expect(lotto.pickTickets([{ whomst: bob.address, pick: [1, 2, 3, 4, 5, 6] }]))
Expand Down Expand Up @@ -1425,6 +1471,31 @@ describe('Lootery', () => {
expect(tokenUri.startsWith('data:application/json;base64,')).to.eq(true)
})
})

describe('#setCallbackGasLimit', () => {
it('should set callback gas limit if called by owner', async () => {
const { lotto } = await deployLotto({
deployer,
factory,
gamePeriod: 3600n,
prizeToken: testERC20,
})
await expect(lotto.setCallbackGasLimit(1_000_000))
.to.emit(lotto, 'CallbackGasLimitSet')
.withArgs(1_000_000)
expect(await lotto.callbackGasLimit()).to.eq(1_000_000)
})

it('should revert if not called by owner', async () => {
const { lotto } = await deployLotto({
deployer,
factory,
gamePeriod: 3600n,
prizeToken: testERC20,
})
await expect(lotto.connect(alice).setCallbackGasLimit(1_000_000)).to.be.reverted
})
})
})

async function deployUninitialisedLootery(deployer: SignerWithAddress) {
Expand Down
Loading
Loading