From a70bd490fd5b1e417fb0081c359fff8a0d2a3a72 Mon Sep 17 00:00:00 2001 From: Jia Liu Date: Wed, 8 May 2024 15:12:48 +0100 Subject: [PATCH 1/3] lottery contract, tests and demo script --- contracts/lottery.sol | 87 +++++++++++++++++++++++++++++++++++++++ demo-config.json | 7 ++++ package.json | 5 ++- scripts/lottery/admin.ts | 76 ++++++++++++++++++++++++++++++++++ scripts/lottery/deploy.ts | 28 +++++++++++++ scripts/lottery/play.ts | 46 +++++++++++++++++++++ test/zkdvrf.spec.ts | 70 ++++++++++++++++++++++++++++++- 7 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 contracts/lottery.sol create mode 100644 scripts/lottery/admin.ts create mode 100644 scripts/lottery/deploy.ts create mode 100644 scripts/lottery/play.ts diff --git a/contracts/lottery.sol b/contracts/lottery.sol new file mode 100644 index 0000000..6f62b4e --- /dev/null +++ b/contracts/lottery.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {zkdvrf} from "./zkdvrf.sol"; + +import "@openzeppelin/contracts/utils/Strings.sol"; +import '@openzeppelin/contracts/access/Ownable.sol'; + +contract Lottery is Ownable { + using Strings for uint256; + + address payable[] public players; + uint256 public minBet; + + address public zkdvrfAddr; + uint256 public randRoundNum; + bytes32 public randValue; + + enum Status { + Setup, + Open, + Close + } + + Status public contractPhase; + + event BetOpen(uint256 randRoundNumber, uint256 minBetAmount); + + constructor(address zkdvrfAddress) Ownable(msg.sender) { + zkdvrfAddr = zkdvrfAddress; + } + + function setup(uint256 randRoundNumber, uint256 minBetAmount) public onlyOwner { + require(contractPhase == Status.Setup, "Setup has already been completed"); + randRoundNum = randRoundNumber; + minBet = minBetAmount; + + contractPhase = Status.Open; + emit BetOpen(randRoundNumber, minBetAmount); + } + + // check if random has been produced or is being produced + function roundReached() public returns (bool) { + uint256 latestRoundNum = zkdvrf(zkdvrfAddr).currentRoundNum(); + return randRoundNum <= latestRoundNum; + } + + function enter() public payable { + require(contractPhase == Status.Open, "Not open yet"); + // Once the random generation starts or has completed, players are no longer allowed to enter + require(!roundReached(), "Too late. Random has been produced or is being produced"); + require(msg.value >= minBet, "Must provide enough bet"); + + players.push(payable(msg.sender)); + } + + // Fisher-Yates Shuffle + function shuffle() private { + require(randValue != 0x00, "Random not ready yet"); + + for (uint i = 0; i < players.length; i++) { + bytes32 randomBytes = keccak256(abi.encodePacked(randValue, i)); + uint256 random = uint256(randomBytes); + + uint j = random % (i + 1); + (players[i], players[j]) = (players[j], players[i]); + } + } + + function pickWinner() public onlyOwner { + require(players.length > 0, "No players"); + // read random from zkdvrf contract + randValue = zkdvrf(zkdvrfAddr).getRandomAtRound(randRoundNum).value; + shuffle(); // Shuffle the players array + // The winner is the first player in the shuffled array + // The permutation is randomly generated so we can also take more winners if needed + players[0].transfer(address(this).balance); + // players = new address payable; // Resetting the players array + + contractPhase = Status.Close; + } + + + function getPlayers() public view returns (address payable[] memory) { + return players; + } +} diff --git a/demo-config.json b/demo-config.json index f51d7df..e8e307c 100644 --- a/demo-config.json +++ b/demo-config.json @@ -13,5 +13,12 @@ "0x742e271d909d026b2b4dcc0384ec0b8df8f674f0773c354b57c24858419e89d3", "0xeeb82181766c7d0fe45d2cb5b3399b1da17a1a432938ec8c4d73daca85eedaea", "0x88b68d7e8d96d8465cfe3dc4abf707e21aa49139189a784e2d50cc9ada9076c3" + ], + "lotteryAddress": "0x019254975A0e82C44e16CCEd295e332C1F6774f2", + "lotteryAdminKey": "0xc9224470c28153019fb65b231370e8c946b65b9d012e46e57d6f9f3f7827e5cd", + "lotteryPlayerKeys": [ + "0xa8a4ba3252cdf977c2d16a67f8eefae5d26e88637a23b6c82b4dbb2364eb7fb5", + "0x7fa41fbee14d382f42df346fe92cbde2fcb3bbd30fbfffb505c5466a62bc5d77", + "0x3fc3e099ad4811fa4b8c7f2c2d796337de84b5e8ec34817015d7f4c1ac92efef" ] } \ No newline at end of file diff --git a/package.json b/package.json index 5e7aefe..2102a9d 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,10 @@ "register": "npx hardhat run scripts/register.ts", "nidkg": "npx hardhat run scripts/nidkg.ts", "member": "yarn register && yarn nidkg", - "random": "npx hardhat run scripts/random.ts" + "random": "npx hardhat run scripts/random.ts", + "lottery:deploy": "npx hardhat run scripts/lottery/deploy.ts", + "lottery:admin": "npx hardhat run scripts/lottery/admin.ts", + "lottery:play": "npx hardhat run scripts/lottery/play.ts" }, "repository": { "type": "git", diff --git a/scripts/lottery/admin.ts b/scripts/lottery/admin.ts new file mode 100644 index 0000000..e44ac00 --- /dev/null +++ b/scripts/lottery/admin.ts @@ -0,0 +1,76 @@ +import hre, {artifacts, ethers} from "hardhat"; +import {Contract, ContractFactory, providers, utils, Wallet} from "ethers"; +import {readJsonFromFile} from "../utils"; + +const config = readJsonFromFile("demo-config.json") +const zkdvrfAddress = config.zkdvrfAddress +const lotteryAddress = config.lotteryAddress +const adminKey = config.lotteryAdminKey + +async function main() { + const netprovider = new providers.JsonRpcProvider(process.env.RPC_URL) + const adminWallet = new Wallet(adminKey, netprovider) + + const Lottery = await ethers.getContractFactory('Lottery') + const contractABI = Lottery.interface.format(); + const contract = new ethers.Contract(lotteryAddress, contractABI, netprovider).connect(adminWallet) + + const randRoundNumber = 3 + const minBet = ethers.utils.parseEther("5"); + const res = await contract.setup(randRoundNumber, minBet) + const receipt = await netprovider.getTransactionReceipt(res.hash); + // Check if the transaction was successful + if (receipt.status === 1) { + console.log(`Transaction setup(..) successful!`); + } else { + console.error(`Transaction setup(..) failed!`); + } + console.log("Bet starts") + console.log("Waiting for random in round ", randRoundNumber) + + const Zkdvrf = await ethers.getContractFactory('zkdvrf') + const zkContractABI = Zkdvrf.interface.format(); + const zkContract = new ethers.Contract(zkdvrfAddress, zkContractABI, netprovider) + + // This will run when the event is emitted + const eventName = `RandomReady` + zkContract.on(eventName, async (roundNum, input, event) => { + console.log("event", eventName, roundNum, input); + // Proceed to the next step here + if (roundNum == randRoundNumber) { + // the random number is ready + const res = await contract.pickWinner() + // Check if the transaction was successful + const receipt = await netprovider.getTransactionReceipt(res.hash); + if (receipt.status === 1) { + console.log("Transaction pickWinner() successful!"); + } else { + console.error("Transaction pickWinner() failed!"); + } + + const status = await contract.contractPhase() + console.log("Lottery contract status:", status) + + const players = await contract.getPlayers() + console.log("Players:", players) + + // query users balance + for (let i = 0; i < players.length; i++) { + netprovider.getBalance(players[i]).then((balance) => { + // Convert Wei to Ether + let etherString = ethers.utils.formatEther(balance); + console.log(players[i], " balance: " + etherString); + }).catch((err) => { + console.error(err); + }); + } + } + }); + +} + + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/lottery/deploy.ts b/scripts/lottery/deploy.ts new file mode 100644 index 0000000..981cc1d --- /dev/null +++ b/scripts/lottery/deploy.ts @@ -0,0 +1,28 @@ +import hre, {artifacts, ethers} from "hardhat"; +import {Contract, ContractFactory, providers, utils, Wallet} from "ethers"; +import {readJsonFromFile} from "../utils"; + +const config = readJsonFromFile("demo-config.json") +const zkdvrfAddress = config.zkdvrfAddress +const adminKey = config.lotteryAdminKey + +async function main() { + const netprovider = new providers.JsonRpcProvider(process.env.RPC_URL) + // const accPrivateKey = process.env.PRIVATE_KEY ?? '' + const deployerWallet = new Wallet(adminKey, netprovider) + + + const Lottery = await ethers.getContractFactory('Lottery') + const lottery = await Lottery.connect(deployerWallet).deploy(zkdvrfAddress) + await lottery.deployed() + + console.log("Lottery contract deployed at", lottery.address) +} + +main().then(() => { + process.exit(0); +}) +.catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/lottery/play.ts b/scripts/lottery/play.ts new file mode 100644 index 0000000..ba9588e --- /dev/null +++ b/scripts/lottery/play.ts @@ -0,0 +1,46 @@ +import hre, {artifacts, ethers} from "hardhat"; +import {providers, Wallet} from "ethers"; +import {readJsonFromFile} from "../utils"; + +const config = readJsonFromFile("demo-config.json") +const lotteryAddress = config.lotteryAddress +const playerKeys = config.lotteryPlayerKeys + + +async function main() { + const netprovider = new providers.JsonRpcProvider(process.env.RPC_URL) + + const Lottery = await ethers.getContractFactory('Lottery') + const contractABI = Lottery.interface.format(); + const contract = new ethers.Contract(lotteryAddress, contractABI, netprovider); + + // This will run when the event is emitted + const eventName = `BetOpen` + contract.on(eventName, async (randRoundNum, minBet, event) => { + console.log("event", eventName, randRoundNum, minBet); + // Proceed to the next step here + + for (let i = 0; i < playerKeys.length; i++) { + const userWallet = new Wallet(playerKeys[i], netprovider) + const userAddress = userWallet.address + const userContract = contract.connect(userWallet) + + try { + let tx = await userContract.enter({ + value: minBet, + from: userAddress, + }); + console.log(tx); + } catch (err) { + console.error(err); + } + } + + process.exit(0); + }); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); \ No newline at end of file diff --git a/test/zkdvrf.spec.ts b/test/zkdvrf.spec.ts index 2badead..95763cd 100644 --- a/test/zkdvrf.spec.ts +++ b/test/zkdvrf.spec.ts @@ -11,8 +11,10 @@ let Halo2Verifier: Contract let Halo2VerifyingKey: Contract let GlobalPublicParams: Contract let PseudoRand: Contract +let Lottery: Contract let minDeposit = utils.parseEther('0.01') +let minBet = utils.parseEther('0.5') let account1: Signer let account2: Signer @@ -25,6 +27,16 @@ let account3Address: string let account4Address: string let account5Address: string +let lotteryAdmin: Signer +let player1: Signer +let player2: Signer +let player3: Signer +let player4: Signer +let lotteryAdminAddress: string +let player1Address: string +let player2Address: string +let player3Address: string + let pubKeyAcc1 = {x:"0x1c5dd8ff5b131cbcd6c38d8143a5db4c6bd1593f436390b24880a4ce41ca5a47", y:"0x23976573e74b5ed2daa1ae1870a90d3463d86cf751381e2486c36a83ae2ee5dd"} let pubKeyAcc2 = {x:"0x213c1d8acf39ba783cbc7d5f678c768736c0c1a1aee852adb041c9d66294391a", y:"0x2468b7c61830e845f883d6469479d7a5b4b33692f45ffaf24e22159c91413992"} @@ -77,7 +89,7 @@ describe('ZKDVRF on-chain tests', async () => { Zkdvrf = await ( await ethers.getContractFactory('zkdvrf') ).deploy(3, 5, Halo2Verifier.address, Halo2VerifyingKey.address, GlobalPublicParams.address, PseudoRand.address, minDeposit) - + account1 = (await ethers.getSigners())[0] account2 = (await ethers.getSigners())[1] account3 = (await ethers.getSigners())[2] @@ -88,6 +100,21 @@ describe('ZKDVRF on-chain tests', async () => { account3Address = await account3.getAddress() account4Address = await account4.getAddress() account5Address = await account5.getAddress() + + lotteryAdmin = (await ethers.getSigners())[5] + player1 = (await ethers.getSigners())[6] + player2 = (await ethers.getSigners())[7] + player3 = (await ethers.getSigners())[8] + player4 = (await ethers.getSigners())[9] + lotteryAdminAddress = await lotteryAdmin.getAddress() + player1Address = await player1.getAddress() + player2Address = await player2.getAddress() + player3Address = await player3.getAddress() + + Lottery = await ( + await ethers.getContractFactory('Lottery') + ).connect(lotteryAdmin).deploy(Zkdvrf.address) + }) describe('Initialization', async () => { @@ -100,6 +127,30 @@ describe('ZKDVRF on-chain tests', async () => { }) }) + describe('Lottery Initialization', async () => { + it('lottery should be initialized', async () => { + expect(await Lottery.zkdvrfAddr()).to.be.eq(Zkdvrf.address) + expect(await Lottery.owner()).to.be.eq(lotteryAdminAddress) + }) + + it('lottery setup', async () => { + await Lottery.connect(lotteryAdmin).setup(1, minBet) + expect(await Lottery.randRoundNum()).to.be.eq(1) + expect(await Lottery.minBet()).to.be.eq(minBet) + expect(await Lottery.contractPhase()).to.be.eq(1) + }) + + it('lottery enter', async () => { + await Lottery.connect(player1).enter({value: minBet}) + await Lottery.connect(player2).enter({value: minBet}) + await Lottery.connect(player3).enter({value: minBet}) + const players = await Lottery.getPlayers() + expect(players[0]).to.be.eq(player1Address) + expect(players[1]).to.be.eq(player2Address) + expect(players[2]).to.be.eq(player3Address) + }) + }) + describe('Phase 0 - Adding Nodes', async () => { it('should be able to add nodes', async () => { await Zkdvrf.addPermissionedNodes(account1Address); @@ -263,6 +314,12 @@ describe('ZKDVRF on-chain tests', async () => { }) }) + describe('Lottery Deadline', async () => { + it('should not be able to enter lottery after random initiation of target round', async () => { + await expect(Lottery.connect(player4).enter({value: minBet})).to.be.revertedWith(`Too late. Random has been produced or is being produced`) + }) + }) + describe('Phase 2 - Submit Partial Evaluation', async () => { it('should not be able to submit partial eval with invalid proof', async () => { await expect(Zkdvrf.submitPartialEval(pEvalInvalid)).to.be.revertedWith('Verification of partial eval failed') @@ -332,4 +389,15 @@ describe('ZKDVRF on-chain tests', async () => { await expect(Zkdvrf.getRandomAtRound(2)).to.be.revertedWith('Answer does not exist for the round yet') }) }) + + describe('Lottery Pick Winner', async () => { + it('lottery pickWinner()', async () => { + await Lottery.connect(lotteryAdmin).pickWinner() + expect(await Lottery.contractPhase()).to.be.eq(2) + const players = await Lottery.getPlayers() + expect(players[0]).to.be.eq(player2Address) + expect(players[1]).to.be.eq(player1Address) + expect(players[2]).to.be.eq(player3Address) + }) + }) }) From dc3186c4058dd597cffe8b13d28e288b10b3aa3d Mon Sep 17 00:00:00 2001 From: Jia Liu Date: Mon, 10 Jun 2024 16:02:13 +0100 Subject: [PATCH 2/3] minor improvements --- contracts/lottery.sol | 8 ++++++-- scripts/lottery/deploy.ts | 2 -- test/zkdvrf.spec.ts | 24 +++++++++++------------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/contracts/lottery.sol b/contracts/lottery.sol index 6f62b4e..5398ba3 100644 --- a/contracts/lottery.sol +++ b/contracts/lottery.sol @@ -10,6 +10,7 @@ contract Lottery is Ownable { using Strings for uint256; address payable[] public players; + mapping(address => bool) public hasEntered; uint256 public minBet; address public zkdvrfAddr; @@ -49,9 +50,11 @@ contract Lottery is Ownable { require(contractPhase == Status.Open, "Not open yet"); // Once the random generation starts or has completed, players are no longer allowed to enter require(!roundReached(), "Too late. Random has been produced or is being produced"); + require(!hasEntered[msg.sender], "You have already entered the lottery"); require(msg.value >= minBet, "Must provide enough bet"); players.push(payable(msg.sender)); + hasEntered[msg.sender] = true; } // Fisher-Yates Shuffle @@ -68,14 +71,15 @@ contract Lottery is Ownable { } function pickWinner() public onlyOwner { + require(contractPhase == Status.Open, "Not open"); require(players.length > 0, "No players"); // read random from zkdvrf contract randValue = zkdvrf(zkdvrfAddr).getRandomAtRound(randRoundNum).value; shuffle(); // Shuffle the players array // The winner is the first player in the shuffled array // The permutation is randomly generated so we can also take more winners if needed - players[0].transfer(address(this).balance); - // players = new address payable; // Resetting the players array + (bool success, ) = players[0].call{value: address(this).balance}(""); + require(success, "Transfer failed."); contractPhase = Status.Close; } diff --git a/scripts/lottery/deploy.ts b/scripts/lottery/deploy.ts index 981cc1d..9f915ff 100644 --- a/scripts/lottery/deploy.ts +++ b/scripts/lottery/deploy.ts @@ -8,10 +8,8 @@ const adminKey = config.lotteryAdminKey async function main() { const netprovider = new providers.JsonRpcProvider(process.env.RPC_URL) - // const accPrivateKey = process.env.PRIVATE_KEY ?? '' const deployerWallet = new Wallet(adminKey, netprovider) - const Lottery = await ethers.getContractFactory('Lottery') const lottery = await Lottery.connect(deployerWallet).deploy(zkdvrfAddress) await lottery.deployed() diff --git a/test/zkdvrf.spec.ts b/test/zkdvrf.spec.ts index 95763cd..8142b6b 100644 --- a/test/zkdvrf.spec.ts +++ b/test/zkdvrf.spec.ts @@ -75,10 +75,6 @@ let expectedValue = '0xc2345a834612b9be480f1007e098485a91b2573b9bd6147f549047b84 let pseudoRandom = {proof: combinedSigma, value: expectedValue} const cfg = hre.network.config -const local_provider = new providers.JsonRpcProvider(cfg['url']) - -// const deployerPK = hre.network.config.accounts[0] -// const deployerWallet = new Wallet(deployerPK, local_provider) describe('ZKDVRF on-chain tests', async () => { before(async () => { @@ -144,10 +140,13 @@ describe('ZKDVRF on-chain tests', async () => { await Lottery.connect(player1).enter({value: minBet}) await Lottery.connect(player2).enter({value: minBet}) await Lottery.connect(player3).enter({value: minBet}) - const players = await Lottery.getPlayers() - expect(players[0]).to.be.eq(player1Address) - expect(players[1]).to.be.eq(player2Address) - expect(players[2]).to.be.eq(player3Address) + expect(await Lottery.players(0)).to.be.eq(player1Address) + expect(await Lottery.players(1)).to.be.eq(player2Address) + expect(await Lottery.players(2)).to.be.eq(player3Address) + }) + + it('should not be able to enter again', async () => { + await expect(Lottery.connect(player1).enter({value: minBet})).to.be.revertedWith("You have already entered the lottery"); }) }) @@ -315,7 +314,7 @@ describe('ZKDVRF on-chain tests', async () => { }) describe('Lottery Deadline', async () => { - it('should not be able to enter lottery after random initiation of target round', async () => { + it('should not be able to enter lottery after initiation of target round', async () => { await expect(Lottery.connect(player4).enter({value: minBet})).to.be.revertedWith(`Too late. Random has been produced or is being produced`) }) }) @@ -394,10 +393,9 @@ describe('ZKDVRF on-chain tests', async () => { it('lottery pickWinner()', async () => { await Lottery.connect(lotteryAdmin).pickWinner() expect(await Lottery.contractPhase()).to.be.eq(2) - const players = await Lottery.getPlayers() - expect(players[0]).to.be.eq(player2Address) - expect(players[1]).to.be.eq(player1Address) - expect(players[2]).to.be.eq(player3Address) + expect(await Lottery.players(0)).to.be.eq(player2Address) + expect(await Lottery.players(1)).to.be.eq(player1Address) + expect(await Lottery.players(2)).to.be.eq(player3Address) }) }) }) From 16b356f7612da61fb0cfd35900264d397ac1579a Mon Sep 17 00:00:00 2001 From: Jia Liu Date: Mon, 1 Jul 2024 14:32:43 +0100 Subject: [PATCH 3/3] add lottery steps and rebrand --- README.md | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e660f17..024868c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# zkDVRF +# zkRand -zkDVRF is a t-out-of-n threshold scheme that runs among a group of n distributed members. The protocol consists of two components: +zkRand is a t-out-of-n threshold scheme that runs among a group of n distributed members. The protocol consists of two components: a snark-based non-interactive distributed key generation (NI-DKG) and randomness generation based on threshold bls-signatures. ### To build: @@ -191,7 +191,7 @@ which can be used to obtain "evals.json" by converting all the big integers into ## Deploy -To deploy the Zkdvrf contracts on-chain- +To deploy the zkRand contracts on-chain- 1. Set up your .env (use .env.example for reference) @@ -263,7 +263,7 @@ DEGREE=18 ### Step-2: Deploy Contracts -1. To deploy the zkdvrf contracts, run- +1. To deploy the zkRand contracts, run- ``` yarn deploy @@ -271,7 +271,7 @@ yarn deploy 2. Populate your demo-config.json file using- -a) your zkdvrf deployed address +a) your zkdvrf.sol deployed address b) five sample addresses, and their private keys from ganache pre-generated accounts ### Step-3: NIDKG @@ -309,3 +309,29 @@ If you have exited the admin script, but have already been through the NIDKG pro yarn admin:restart ``` +### Continuing with lottery demo +1. Deploy the lottery contracts + +``` +yarn lottery:deploy +``` + +2. Populate your demo-config.json file using- + +a) your lottery.sol deployed address +b) private keys for lottery admin and three players from ganache pre-generated accounts + +3. Run the lottery admin to start the lottery +``` +yarn lottery:admin +``` +The lottery will set a target random for picking up a winner. +The round number for target random is set to be 3 in the script. + +4. Run the players to place bets +``` +yarn lottery:play +``` +Before zkdvrf.sol starts producing the target random, players can enter the lottery by depositing a certain amount of ethers. + +5. Continuing the above Step-4 for generating random until the round number hits 3 which will trigger the lottery admin to pick and pay a winner. \ No newline at end of file