From 38c751973f423699d4c802a5a9039f86e3720978 Mon Sep 17 00:00:00 2001 From: Pablo Maldonado Date: Mon, 7 Aug 2023 00:01:22 +0100 Subject: [PATCH] feat: add drand off-chain voting example (#21) Signed-off-by: Pablo Maldonado Co-authored-by: Alison Haire --- .../contracts/OffChainTimelockedVoting.sol | 579 +++++++++++++ examples/drand-timelock-voting/Dockerfile | 9 + .../abi/GovernanceToken.json | 398 +++++++++ .../abi/GovernorContract.json | 770 ++++++++++++++++++ examples/drand-timelock-voting/index.js | 190 +++++ examples/drand-timelock-voting/package.json | 20 + examples/drand-timelock-voting/readme.md | 55 ++ 7 files changed, 2021 insertions(+) create mode 100644 examples/contracts/OffChainTimelockedVoting.sol create mode 100644 examples/drand-timelock-voting/Dockerfile create mode 100644 examples/drand-timelock-voting/abi/GovernanceToken.json create mode 100644 examples/drand-timelock-voting/abi/GovernorContract.json create mode 100644 examples/drand-timelock-voting/index.js create mode 100644 examples/drand-timelock-voting/package.json create mode 100644 examples/drand-timelock-voting/readme.md diff --git a/examples/contracts/OffChainTimelockedVoting.sol b/examples/contracts/OffChainTimelockedVoting.sol new file mode 100644 index 0000000..12e704a --- /dev/null +++ b/examples/contracts/OffChainTimelockedVoting.sol @@ -0,0 +1,579 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Snapshot.sol"; +import "@openzeppelin/contracts/utils/Address.sol"; +import "@openzeppelin/contracts/utils/Strings.sol"; +import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; +import "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {Context} from "@openzeppelin/contracts/utils/Context.sol"; +import "./DataGovernanceToken.sol"; +import "./LilypadCallerInterface.sol"; +import "./LilypadEventsUpgradeable.sol"; + +contract GovernanceToken is ERC20Snapshot, Ownable { + constructor() ERC20("GovernanceToken", "GOV") { + _mint(msg.sender, 1000000000000000000000000); + } + + function snapshot() public onlyOwner returns (uint256) { + return _snapshot(); + } + + function mint(address wallet, uint256 amount) public onlyOwner { + _mint(wallet, amount); + } +} + +// This sample contract is a timelocked off-chain voting Governor contract that uses Lilypad to +// resolve the timelock encrypted off-chain votes with drand, calculate the vote resolution and rewards off-chain +// and publish the results on-chain. +// This contract works together with the docker image in examples/drand-timelock-voting +// The contract is based on the Governor contract from DeFiKicks(https://github.com/md0x/defikicks) + +contract GovernorContract is Context, LilypadCallerInterface, Ownable { + GovernanceToken public token; + + uint256 public votingPeriod; + uint256 public quorumPercentage; + uint256 public emissionPerVote = 1 * 10 ** 16; + string public dockerImage = "your_drand_timelock_voting_image"; + + LilypadEventsUpgradeable bridge; + + event ProposalExecuted(bytes32 indexed proposalId); + + event ProposalCreated( + bytes32 indexed proposalId, + address proposer, + address[] targets, + uint256[] values, + bytes[] calldatas, + uint256 voteStart, + uint256 voteEnd, + string description, + bytes32 descriptionHash + ); + + event VoteResolutionRequested(bytes32 indexed proposalId, uint256 bridgeId); + + event ProposalUpdated( + bytes32 indexed proposalId, + bytes32 voteMerkleRoot, + uint256 forVotes, + uint256 againstVotes, + uint256 abstainVotes, + string data + ); + + event ClaimedReward( + address indexed user, + uint256 amount, + bytes32 indexed proposalId + ); + + enum ProposalState { + Pending, + Active, + Canceled, + Defeated, + Succeeded, + Queued, + Expired, + Executed, + ResolutionToRequest, + ResolutionRequested + } + + struct ProposalCore { + uint64 voteStart; + address proposer; + uint64 voteEnd; + bool executed; + bool canceled; + uint256 forVotes; + uint256 againstVotes; + uint256 abstainVotes; + uint256 bridgeId; + bytes32 voteMerkleRoot; + uint256 snapshotId; + } + + struct ResolutionResponse { + uint256 forVotes; + uint256 againstVotes; + uint256 abstainVotes; + bytes32 voteMerkleRoot; + string data; + } + + string constant specStart = + "{" + '"Engine": "docker",' + '"Verifier": "noop",' + '"PublisherSpec": {"Type": "estuary"},' + '"Docker": {' + '"Image": "'; + + string constant specMiddle = + '",' + '"EnvironmentVariables": ["PROPOSAL_ID='; + + string constant specEnd = + '","NODE_URL=https://api.calibration.node.glif.io/rpc/v0"]},' + '"Language":{"JobContext":{}},' + '"Wasm":{"EntryModule":{}},' + '"Resources":{"GPU":""},' + '"Deal": {"Concurrency": 1}' + "}"; + + mapping(bytes32 => ProposalCore) public proposals; + mapping(uint256 => bytes32) public jobIdToProposal; + mapping(bytes32 => mapping(address => bool)) public alreadyClaimed; + + constructor(address _token, address bridgeContract) { + bridge = LilypadEventsUpgradeable(bridgeContract); + token = GovernanceToken(_token); + } + + // Setters with only owner + + function setEmissionPerVote(uint256 _emissionPerVote) external onlyOwner { + emissionPerVote = _emissionPerVote; + } + + function setVotingPeriod(uint256 period) external onlyOwner { + votingPeriod = period; + } + + function setQuorumPercentage(uint256 percentage) external onlyOwner { + quorumPercentage = percentage; + } + + function setDockerImage(string memory image) external onlyOwner { + dockerImage = image; + } + + function claimReward( + bytes32 proposalId, + uint256 amount, + bytes32[] memory merkleProof + ) external { + bytes32 leaf = keccak256( + bytes.concat(keccak256(abi.encode(msg.sender, amount))) + ); + + require( + MerkleProof.verify( + merkleProof, + proposals[proposalId].voteMerkleRoot, + leaf + ), + "Invalid proof" + ); + + require( + !alreadyClaimed[proposalId][msg.sender], + "Reward already claimed" + ); + + alreadyClaimed[proposalId][msg.sender] = true; + + token.mint(msg.sender, amount); + + emit ClaimedReward(msg.sender, amount, proposalId); + } + + function requestVoteResolution(bytes32 proposalId) public payable virtual { + require( + state(proposalId) == ProposalState.ResolutionToRequest, + "Governor: vote not in ResolutionToRequest state" + ); + uint256 lilypadFee = bridge.getLilypadFee(); + require(msg.value >= lilypadFee, "Governor: insufficient fee"); + + string memory spec = getSpecForProposalId(proposalId); + + uint256 id = bridge.runLilypadJob{value: lilypadFee}( + address(this), + spec, + uint8(LilypadResultType.StdOut) + ); + require(id > 0, "job didn't return a value"); + proposals[proposalId].bridgeId = id; + jobIdToProposal[id] = proposalId; + emit VoteResolutionRequested(proposalId, id); + } + + function propose( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description + ) public virtual returns (bytes32) { + address proposer = _msgSender(); + require( + _isValidDescriptionForProposer(proposer, description), + "Governor: proposer restricted" + ); + + uint256 currentTimepoint = clock(); + + bytes32 proposalId = hashProposal( + targets, + values, + calldatas, + keccak256(bytes(description)) + ); + + require( + targets.length == values.length, + "Governor: invalid proposal length" + ); + require( + targets.length == calldatas.length, + "Governor: invalid proposal length" + ); + require(targets.length > 0, "Governor: empty proposal"); + require( + proposals[proposalId].voteStart == 0, + "Governor: proposal already exists" + ); + + uint256 snapshot = currentTimepoint; + uint256 deadline = snapshot + votingPeriod; + + proposals[proposalId] = ProposalCore({ + proposer: proposer, + voteStart: SafeCast.toUint64(snapshot), + voteEnd: SafeCast.toUint64(deadline), + executed: false, + canceled: false, + forVotes: 0, + againstVotes: 0, + abstainVotes: 0, + bridgeId: 0, + voteMerkleRoot: bytes32(0), + snapshotId: token.snapshot() + }); + + emit ProposalCreated( + proposalId, + proposer, + targets, + values, + calldatas, + snapshot, + deadline, + description, + keccak256(bytes(description)) + ); + + return proposalId; + } + + function execute( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) public payable virtual returns (bytes32) { + bytes32 proposalId = hashProposal( + targets, + values, + calldatas, + descriptionHash + ); + + ProposalState currentState = state(proposalId); + + require( + currentState == ProposalState.Succeeded || + currentState == ProposalState.Queued, + "Governor: proposal not successful" + ); + + proposals[proposalId].executed = true; + + emit ProposalExecuted(proposalId); + + _execute(proposalId, targets, values, calldatas, descriptionHash); + + return proposalId; + } + + // Lilypad callbacks + function lilypadFulfilled( + address _from, + uint _jobId, + LilypadResultType _resultType, + string calldata _result + ) external override { + require(_resultType == LilypadResultType.StdOut); + require(msg.sender == address(bridge)); + + ResolutionResponse memory resolutionResponse = abi.decode( + hexStrToBytes(_result), + (ResolutionResponse) + ); + ProposalCore storage proposal = proposals[jobIdToProposal[_jobId]]; + proposal.voteMerkleRoot = resolutionResponse.voteMerkleRoot; + proposal.forVotes = resolutionResponse.forVotes; + proposal.againstVotes = resolutionResponse.againstVotes; + proposal.abstainVotes = resolutionResponse.abstainVotes; + + emit ProposalUpdated( + jobIdToProposal[_jobId], + resolutionResponse.voteMerkleRoot, + resolutionResponse.forVotes, + resolutionResponse.againstVotes, + resolutionResponse.abstainVotes, + resolutionResponse.data + ); + } + + function lilypadCancelled( + address _from, + uint256 _jobId, + string calldata _errorMsg + ) external override { + require(_from == address(bridge)); + proposals[jobIdToProposal[_jobId]].bridgeId = 0; + } + + // Getters + function getSpecForProposalId( + bytes32 proposalId + ) public view virtual returns (string memory) { + return + string.concat( + specStart, + dockerImage, + specMiddle, + Strings.toHexString(uint256(proposalId)), + specEnd + ); + } + + function clock() public view virtual returns (uint48) { + return SafeCast.toUint48(block.timestamp); + } + + function getLilypadFee() public view virtual returns (uint256) { + return bridge.getLilypadFee(); + } + + function proposalSnapshot( + bytes32 proposalId + ) public view virtual returns (uint256) { + return proposals[proposalId].voteStart; + } + + function proposalDeadline( + bytes32 proposalId + ) public view virtual returns (uint256) { + return proposals[proposalId].voteEnd; + } + + function state( + bytes32 proposalId + ) public view virtual returns (ProposalState) { + ProposalCore storage proposal = proposals[proposalId]; + + if (proposal.executed) { + return ProposalState.Executed; + } + + if (proposal.canceled) { + return ProposalState.Canceled; + } + + uint256 snapshot = proposalSnapshot(proposalId); + + if (snapshot == 0) { + revert("Governor: unknown proposal id"); + } + + uint256 currentTimepoint = clock(); + + if (snapshot >= currentTimepoint) { + return ProposalState.Pending; + } + + uint256 deadline = proposalDeadline(proposalId); + + if (deadline >= currentTimepoint) { + return ProposalState.Active; + } + + if (proposals[proposalId].bridgeId == 0) { + return ProposalState.ResolutionToRequest; + } + + if (proposals[proposalId].voteMerkleRoot == bytes32(0)) { + return ProposalState.ResolutionRequested; + } + + if (_quorumReached(proposalId) && _voteSucceeded(proposalId)) { + return ProposalState.Succeeded; + } else { + return ProposalState.Defeated; + } + } + + function hashProposal( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) public pure virtual returns (bytes32) { + return + keccak256(abi.encode(targets, values, calldatas, descriptionHash)); + } + + // Internal + function _quorumReached( + bytes32 proposalId + ) internal view virtual returns (bool) { + return + proposals[proposalId].forVotes >= + (quorumPercentage * + token.totalSupplyAt(proposals[proposalId].snapshotId)) / + 1 ether; + } + + function _voteSucceeded( + bytes32 proposalId + ) internal view virtual returns (bool) { + return + proposals[proposalId].forVotes > proposals[proposalId].againstVotes; + } + + function _execute( + bytes32 /* proposalId */, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 /*descriptionHash*/ + ) internal virtual { + string memory errorMessage = "Governor: call reverted without message"; + for (uint256 i = 0; i < targets.length; ++i) { + (bool success, bytes memory returndata) = targets[i].call{ + value: values[i] + }(calldatas[i]); + Address.verifyCallResult(success, returndata, errorMessage); + } + } + + function _tryHexToUint(bytes1 char) private pure returns (bool, uint8) { + uint8 c = uint8(char); + unchecked { + // Case 0-9 + if (47 < c && c < 58) { + return (true, c - 48); + } + // Case A-F + else if (64 < c && c < 71) { + return (true, c - 55); + } + // Case a-f + else if (96 < c && c < 103) { + return (true, c - 87); + } + // Else: not a hex char + else { + return (false, 0); + } + } + } + + function _isValidDescriptionForProposer( + address proposer, + string memory description + ) internal view virtual returns (bool) { + uint256 len = bytes(description).length; + + // Length is too short to contain a valid proposer suffix + if (len < 52) { + return true; + } + + // Extract what would be the `#proposer=0x` marker beginning the suffix + bytes12 marker; + assembly { + // - Start of the string contents in memory = description + 32 + // - First character of the marker = len - 52 + // - Length of "#proposer=0x0000000000000000000000000000000000000000" = 52 + // - We read the memory word starting at the first character of the marker: + // - (description + 32) + (len - 52) = description + (len - 20) + // - Note: Solidity will ignore anything past the first 12 bytes + marker := mload(add(description, sub(len, 20))) + } + + // If the marker is not found, there is no proposer suffix to check + if (marker != bytes12("#proposer=0x")) { + return true; + } + + // Parse the 40 characters following the marker as uint160 + uint160 recovered = 0; + for (uint256 i = len - 40; i < len; ++i) { + (bool isHex, uint8 value) = _tryHexToUint(bytes(description)[i]); + // If any of the characters is not a hex digit, ignore the suffix entirely + if (!isHex) { + return true; + } + recovered = (recovered << 4) | value; + } + + return recovered == uint160(proposer); + } + + // Helper functions + function hexStrToBytes( + string memory _hexStr + ) public pure returns (bytes memory) { + bytes memory strBytes = bytes(_hexStr); + + // Check for '0x' prefix + uint offset; + if ( + strBytes.length >= 2 && + strBytes[0] == bytes1("0") && + strBytes[1] == bytes1("x") + ) { + offset = 2; + } + + require( + (strBytes.length - offset) % 2 == 0, + "Invalid hex string length!" + ); + + bytes memory result = new bytes((strBytes.length - offset) / 2); + + for (uint i = offset; i < strBytes.length; i += 2) { + uint8 upper = charToUint8(uint8(strBytes[i])); + uint8 lower = charToUint8(uint8(strBytes[i + 1])); + + result[(i - offset) / 2] = bytes1((upper << 4) | lower); + } + + return result; + } + + function charToUint8(uint8 c) private pure returns (uint8) { + if (c >= 48 && c <= 57) { + return c - 48; + } + if (c >= 97 && c <= 102) { + return 10 + c - 97; + } + if (c >= 65 && c <= 70) { + return 10 + c - 65; + } + + revert("Invalid hex char!"); + } +} diff --git a/examples/drand-timelock-voting/Dockerfile b/examples/drand-timelock-voting/Dockerfile new file mode 100644 index 0000000..508a187 --- /dev/null +++ b/examples/drand-timelock-voting/Dockerfile @@ -0,0 +1,9 @@ +FROM --platform=linux/x86-64 node:18 + +WORKDIR /usr/src/app + +COPY package*.json ./ +RUN npm install +COPY . . + +CMD [ "node", "./index.js" ] diff --git a/examples/drand-timelock-voting/abi/GovernanceToken.json b/examples/drand-timelock-voting/abi/GovernanceToken.json new file mode 100644 index 0000000..685f53e --- /dev/null +++ b/examples/drand-timelock-voting/abi/GovernanceToken.json @@ -0,0 +1,398 @@ +[ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "id", + "type": "uint256" + } + ], + "name": "Snapshot", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "internalType": "uint256", + "name": "snapshotId", + "type": "uint256" + } + ], + "name": "balanceOfAt", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "subtractedValue", + "type": "uint256" + } + ], + "name": "decreaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "addedValue", + "type": "uint256" + } + ], + "name": "increaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "snapshot", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "snapshotId", + "type": "uint256" + } + ], + "name": "totalSupplyAt", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/examples/drand-timelock-voting/abi/GovernorContract.json b/examples/drand-timelock-voting/abi/GovernorContract.json new file mode 100644 index 0000000..1548f19 --- /dev/null +++ b/examples/drand-timelock-voting/abi/GovernorContract.json @@ -0,0 +1,770 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "_token", + "type": "address" + }, + { + "internalType": "address", + "name": "bridgeContract", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "proposalId", + "type": "bytes32" + } + ], + "name": "ClaimedReward", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "proposalId", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "address", + "name": "proposer", + "type": "address" + }, + { + "indexed": false, + "internalType": "address[]", + "name": "targets", + "type": "address[]" + }, + { + "indexed": false, + "internalType": "uint256[]", + "name": "values", + "type": "uint256[]" + }, + { + "indexed": false, + "internalType": "bytes[]", + "name": "calldatas", + "type": "bytes[]" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "voteStart", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "voteEnd", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "string", + "name": "description", + "type": "string" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "descriptionHash", + "type": "bytes32" + } + ], + "name": "ProposalCreated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "proposalId", + "type": "bytes32" + } + ], + "name": "ProposalExecuted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "proposalId", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "voteMerkleRoot", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "forVotes", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "againstVotes", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "abstainVotes", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "string", + "name": "data", + "type": "string" + } + ], + "name": "ProposalUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "proposalId", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "bridgeId", + "type": "uint256" + } + ], + "name": "VoteResolutionRequested", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "alreadyClaimed", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "proposalId", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "bytes32[]", + "name": "merkleProof", + "type": "bytes32[]" + } + ], + "name": "claimReward", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "clock", + "outputs": [ + { + "internalType": "uint48", + "name": "", + "type": "uint48" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "dockerImage", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "emissionPerVote", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "targets", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "values", + "type": "uint256[]" + }, + { + "internalType": "bytes[]", + "name": "calldatas", + "type": "bytes[]" + }, + { + "internalType": "bytes32", + "name": "descriptionHash", + "type": "bytes32" + } + ], + "name": "execute", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "getLilypadFee", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "proposalId", + "type": "bytes32" + } + ], + "name": "getSpecForProposalId", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "targets", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "values", + "type": "uint256[]" + }, + { + "internalType": "bytes[]", + "name": "calldatas", + "type": "bytes[]" + }, + { + "internalType": "bytes32", + "name": "descriptionHash", + "type": "bytes32" + } + ], + "name": "hashProposal", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "_hexStr", + "type": "string" + } + ], + "name": "hexStrToBytes", + "outputs": [ + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "jobIdToProposal", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_from", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_jobId", + "type": "uint256" + }, + { + "internalType": "string", + "name": "_errorMsg", + "type": "string" + } + ], + "name": "lilypadCancelled", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_from", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_jobId", + "type": "uint256" + }, + { + "internalType": "enum LilypadResultType", + "name": "_resultType", + "type": "uint8" + }, + { + "internalType": "string", + "name": "_result", + "type": "string" + } + ], + "name": "lilypadFulfilled", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "proposalId", + "type": "bytes32" + } + ], + "name": "proposalDeadline", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "proposalId", + "type": "bytes32" + } + ], + "name": "proposalSnapshot", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "proposals", + "outputs": [ + { + "internalType": "uint64", + "name": "voteStart", + "type": "uint64" + }, + { + "internalType": "address", + "name": "proposer", + "type": "address" + }, + { + "internalType": "uint64", + "name": "voteEnd", + "type": "uint64" + }, + { + "internalType": "bool", + "name": "executed", + "type": "bool" + }, + { + "internalType": "bool", + "name": "canceled", + "type": "bool" + }, + { + "internalType": "uint256", + "name": "forVotes", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "againstVotes", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "abstainVotes", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "bridgeId", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "voteMerkleRoot", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "snapshotId", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "targets", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "values", + "type": "uint256[]" + }, + { + "internalType": "bytes[]", + "name": "calldatas", + "type": "bytes[]" + }, + { + "internalType": "string", + "name": "description", + "type": "string" + } + ], + "name": "propose", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "quorumPercentage", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "proposalId", + "type": "bytes32" + } + ], + "name": "requestVoteResolution", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "image", + "type": "string" + } + ], + "name": "setDockerImage", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_emissionPerVote", + "type": "uint256" + } + ], + "name": "setEmissionPerVote", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "percentage", + "type": "uint256" + } + ], + "name": "setQuorumPercentage", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "period", + "type": "uint256" + } + ], + "name": "setVotingPeriod", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "proposalId", + "type": "bytes32" + } + ], + "name": "state", + "outputs": [ + { + "internalType": "enum GovernorContract.ProposalState", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "token", + "outputs": [ + { + "internalType": "contract DefiKicksDataGovernanceToken", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "votingPeriod", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/examples/drand-timelock-voting/index.js b/examples/drand-timelock-voting/index.js new file mode 100644 index 0000000..ec34a53 --- /dev/null +++ b/examples/drand-timelock-voting/index.js @@ -0,0 +1,190 @@ +import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; +import { HttpCachingChain, HttpChainClient } from "drand-client"; +import { BigNumber, ethers } from "ethers"; +import { timelockDecrypt } from "tlock-js"; +import TokenABI from "./abi/GovernanceToken.json" assert { type: "json" }; +import GovernorABI from "./abi/GovernorContract.json" assert { type: "json" }; + +export const timelockDecryption = async (ciphertext) => { + const fastestNodeClient = await getFastestNode(); + const result = await timelockDecrypt(ciphertext, fastestNodeClient); + return result; +}; + +const addresses = { + token: "0xd61801Fa782da077873394E75cc7740EaA2809A5", + governor: "0xcF53746114e9b1B0Dd0900CDd76b39D77d14bb86", + registry: "0xc799246Ec502b51e34c975cAd3CD7541a85DA9F6", +}; + +const getVotes = async (proposalId) => { + // TODO implement your own logic to get the votes from IPFS or any other source + return [ + { + account: "0x000000", + cyphertext: "0x000000", + signature: "0x000000", + }, + ]; +}; + +const testnetUnchainedUrl = + "https://pl-eu.testnet.drand.sh/7672797f548f3f4748ac4bf3352fc6c6b6468c9ad40ad456a397545c6e2df5bf"; + +const getFastestNode = async () => { + const chain = new HttpCachingChain(testnetUnchainedUrl); + const client = new HttpChainClient(chain); + + return client; +}; + +async function run() { + const proposalId = process.env.PROPOSAL_ID; + const nodeUrl = process.env.NODE_URL; + + const provider = new ethers.providers.JsonRpcProvider(nodeUrl); + + const governorContract = new ethers.Contract( + addresses.governor, + GovernorABI, + provider + ); + + const proposalStruct = await governorContract.proposals(proposalId); + + const votes = await getVotes(proposalStruct.id); + + const decryptedVotes = []; + + // Decrypt timelocked votes using drand + for (const vote of votes) { + let message; + try { + message = await timelockDecryption(vote.cyphertext); + } catch (e) {} + decryptedVotes.push({ + ...vote, + message, + }); + } + + // Verify signatures + const signedVotes = []; + for (const vote of decryptedVotes) { + const recoveredAddress = ethers.utils.verifyMessage( + vote.message, + vote.signature + ); + if (recoveredAddress === vote.account) { + signedVotes.push(vote); + } + } + + const tokenContract = new ethers.Contract( + addresses.token, + TokenABI, + provider + ); + + // Count votes + let forVotes = BigNumber.from(0); + let againstVotes = BigNumber.from(0); + let abstainVotes = BigNumber.from(0); + for (const vote of signedVotes) { + const balance = await tokenContract.balanceOfAt( + vote.account, + proposalStruct.snapshotId.toString() + ); + const voteObj = JSON.parse(vote.message); + + if (voteObj.vote === "for") { + forVotes = forVotes.add(balance); + } + if (voteObj.vote === "against") { + againstVotes = againstVotes.add(balance); + } + } + + const totalSupplyAtVote = await tokenContract.totalSupplyAt( + proposalStruct.snapshotId.toString() + ); + abstainVotes = totalSupplyAtVote.sub(forVotes.add(againstVotes)); + + // Reward calculation + const quorumPercentage = await governorContract.quorumPercentage(); + const majority = forVotes.gt(againstVotes) ? "for" : "against"; + const majorityValue = majority === "for" ? forVotes : againstVotes; + const arrivedToConsensus = + !forVotes.eq(againstVotes) && + majorityValue.gte( + totalSupplyAtVote.mul(quorumPercentage).div(ethers.utils.parseEther("1")) + ); + + const toReward = []; + const emissionPerVote = await governorContract.emissionPerVote(); + if (arrivedToConsensus) { + for (const vote of signedVotes) { + const balance = await tokenContract.balanceOfAt( + vote.account, + proposalStruct.snapshotId.toString() + ); + const voteObj = JSON.parse(vote.message); + if (voteObj.vote == majority) { + toReward.push([ + vote.account, + balance + .mul(emissionPerVote) + .div(ethers.utils.parseEther("1")) + .toString(), + ]); + } + } + } else { + toReward.push([ethers.constants.AddressZero, "0"]); + } + + const tree = StandardMerkleTree.of(toReward, ["address", "uint256"]); + + const proofData = {}; + for (const [i, v] of tree.entries()) { + // (3) + const proof = tree.getProof(i); + + proofData[v[0]] = { + amount: v[1], + proof, + }; + } + const data = JSON.stringify(proofData); + + // Encode calldata + const calldata = ethers.utils.defaultAbiCoder.encode( + [ + { + type: "tuple", + components: [ + { name: "forVotes", type: "uint256" }, + { name: "againstVotes", type: "uint256" }, + { name: "abstainVotes", type: "uint256" }, + { name: "voteMerkleRoot", type: "bytes32" }, + { name: "data", type: "string" }, + ], + }, + ], + [ + { + forVotes, + againstVotes, + abstainVotes, + voteMerkleRoot: tree.root, + data, + }, + ] + ); + + // This is the only log that should be done as this will be send to lilypad by the bacalhaul operator + // by calling the returnLilypadResults function on the lilypad contract with the calldata as parameter + console.log("calldata", calldata); +} + +run(); diff --git a/examples/drand-timelock-voting/package.json b/examples/drand-timelock-voting/package.json new file mode 100644 index 0000000..28726db --- /dev/null +++ b/examples/drand-timelock-voting/package.json @@ -0,0 +1,20 @@ +{ + "private": true, + "type": "module", + "version": "0.0.0", + "dependencies": { + "@openzeppelin/merkle-tree": "^1.0.4", + "axios": "^1.4.0", + "drand-client": "^1.1.0", + "ethers": "^5.7.2", + "key-did-provider-ed25519": "^3.0.1", + "tlock-js": "^0.6.1" + }, + "devDependencies": { + "@types/node": "^20.3.1", + "typescript": "^5.1.3" + }, + "scripts": { + "build": "tsc" + } +} diff --git a/examples/drand-timelock-voting/readme.md b/examples/drand-timelock-voting/readme.md new file mode 100644 index 0000000..b65368d --- /dev/null +++ b/examples/drand-timelock-voting/readme.md @@ -0,0 +1,55 @@ +### Description + +--- + +This sample container, which offers timelock encryption and off-chain voting features, was taken from the HackFS 2023 finalist [DeFiKicks](https://defikicks.xyz) project. Please visit their [GitHub](https://github.com/md0x/defikicks) repository or the [project page on EthGlobal](https://ethglobal.com/showcase/defikicks-b1wo0) for more details. + +### How to build this container + +--- + +This container is built using the following command: + +```bash +docker build -t yourName/yourImageName:latest . +``` + +### How to run this container + +--- + +This container is run using the following command: + +```bash +docker run yourName/yourImageName:latest +``` + +### How to deploy this image to Docker Hub + +--- + +```bash +docker login +export IMAGE=yourName/yourImageName:latest +docker build -t {$IMAGE} . +docker image push {$IMAGE} +``` + +### How to run with bacalhau + +--- + +```bash +bacalhau docker run \ + --env PROPOSAL_ID={$PROPOSAL_ID} \ + --env NODE_URL={$NODE_URL} \ + {$IMAGE} +``` + +### How to get the job spec + +--- + +```bash +bacalhau describe --json +```