diff --git a/common/.gitignore b/common/.gitignore new file mode 100644 index 000000000..e3fbd9833 --- /dev/null +++ b/common/.gitignore @@ -0,0 +1,2 @@ +build +node_modules diff --git a/common/README.md b/common/README.md new file mode 100644 index 000000000..7529c3404 --- /dev/null +++ b/common/README.md @@ -0,0 +1 @@ +# Common clr.fund utility functions used by contracts and vue-app diff --git a/common/package.json b/common/package.json new file mode 100644 index 000000000..039734e11 --- /dev/null +++ b/common/package.json @@ -0,0 +1,28 @@ +{ + "name": "@clrfund/common", + "version": "0.0.1", + "description": "Common utility functions used by clrfund scripts and app", + "main": "src/index", + "scripts": { + "build": "tsc", + "lint": "eslint 'src/**/*.ts'", + "clean": "rm -rf build" + }, + "license": "GPL-3.0", + "devDependencies": { + "eslint": "^8.31.0", + "typescript": "^4.9.3" + }, + "dependencies": { + "ethers": "^5.7.2" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clrfund/monorepo.git" + }, + "author": "", + "bugs": { + "url": "https://github.com/clrfund/monorepo/issues" + }, + "homepage": "https://github.com/clrfund/monorepo#readme" +} diff --git a/common/src/block.ts b/common/src/block.ts new file mode 100644 index 000000000..e065e146a --- /dev/null +++ b/common/src/block.ts @@ -0,0 +1,20 @@ +import { providers } from 'ethers' + +export interface Block { + blockNumber: number + hash: string + stateRoot: string +} + +/* + * get the block stateRoot using eth_getBlockByHash + */ +export async function getBlock( + blockNumber: number, + provider: providers.JsonRpcProvider +): Promise { + const block = await provider.getBlock(blockNumber) + const blockParams = [block.hash, false] + const rawBlock = await provider.send('eth_getBlockByHash', blockParams) + return { blockNumber, hash: block.hash, stateRoot: rawBlock.stateRoot } +} diff --git a/common/src/index.ts b/common/src/index.ts new file mode 100644 index 000000000..a8f361efc --- /dev/null +++ b/common/src/index.ts @@ -0,0 +1,2 @@ +export * from './block' +export * from './proof' diff --git a/common/src/proof.ts b/common/src/proof.ts new file mode 100644 index 000000000..5cac6127f --- /dev/null +++ b/common/src/proof.ts @@ -0,0 +1,85 @@ +import { utils, providers } from 'ethers' + +/** + * RLP encode the proof returned from eth_getProof + * @param proof proof from the eth_getProof + * @returns + */ +export function rlpEncodeProof(proof: string[]) { + const decodedProof = proof.map((node: string) => utils.RLP.decode(node)) + + return utils.RLP.encode(decodedProof) +} + +/** + * The storage key used in eth_getProof and eth_getStorageAt + * @param account Account address + * @param slotIndex Slot index of the balanceOf storage + * @returns storage key used in the eth_getProof params + */ +export function getStorageKey(account: string, slotIndex: number) { + return utils.keccak256( + utils.concat([ + utils.hexZeroPad(account, 32), + utils.hexZeroPad(utils.hexValue(slotIndex), 32), + ]) + ) +} + +/** + * Get proof from eth_getProof + * @param params Parameter fro eth_getProof + * @returns proof returned from eth_getProof + */ +async function getProof( + params: Array, + provider: providers.JsonRpcProvider +): Promise { + try { + const proof = await provider.send('eth_getProof', params) + return proof + } catch (err) { + console.error( + 'Unable to get proof. Your node may not support eth_getProof. Try a different provider such as Infura', + err + ) + throw err + } +} +/** + * Get the storage proof + * @param token Token contract address + * @param blockHash The block hash to get the proof for + * @param provider provider to connect to the node + * @returns proof returned from eth_getProof + */ +export async function getAccountProof( + token: string, + blockHash: string, + provider: providers.JsonRpcProvider +): Promise { + const params = [token, [], blockHash] + return getProof(params, provider) +} + +/** + * Get the storage proof + * @param token Token contract address + * @param blockHash The block hash to get the storage proof for + * @param userAccount User account to get the proof for + * @param storageSlotIndex The storage index for the balanceOf storage + * @param provider provider to connect to the node + * @returns proof returned from eth_getProof + */ +export async function getStorageProof( + token: string, + blockHash: string, + userAccount: string, + storageSlotIndex: number, + provider: providers.JsonRpcProvider +): Promise { + const storageKey = getStorageKey(userAccount, storageSlotIndex) + + const params = [token, [storageKey], blockHash] + return getProof(params, provider) +} diff --git a/common/tsconfig.json b/common/tsconfig.json new file mode 100644 index 000000000..957cdc1a2 --- /dev/null +++ b/common/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "skipLibCheck": true, + "experimentalDecorators": true, + "alwaysStrict": true, + "noImplicitAny": false, + "forceConsistentCasingInFileNames": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "sourceMap": true, + "strict": true, + "outDir": "./build", + "target": "es2018", + "esModuleInterop": true, + "module": "commonjs", + "declaration": true + }, + "exclude": ["node_modules/**"], + "include": ["./src"] +} diff --git a/contracts/.env.example b/contracts/.env.example index cd34f8dd9..40ce5f71a 100644 --- a/contracts/.env.example +++ b/contracts/.env.example @@ -1,7 +1,7 @@ # Recipient registry type for local deployment: simple, optimistic RECIPIENT_REGISTRY_TYPE=optimistic -# Supported values: simple, brightid +# Supported values: simple, brightid, snapshot USER_REGISTRY_TYPE=simple # clr.fund (prod) or CLRFundTest (testing) BRIGHTID_CONTEXT=clr.fund diff --git a/contracts/contracts/userRegistry/README.md b/contracts/contracts/userRegistry/README.md index b1b853a43..e7534c04f 100644 --- a/contracts/contracts/userRegistry/README.md +++ b/contracts/contracts/userRegistry/README.md @@ -1,5 +1,7 @@ ## Description +### BrightIdUserRegistry + This is a contract to register verified users context ids by BrightID node's verification data, and be able to query a user verification. This contract consist of: @@ -7,46 +9,13 @@ This contract consist of: - Check a user is verified or not
`function isVerifiedUser(address _user) override external view returns (bool);` - Register a user by BrightID node's verification data
`function register(bytes32 _context, address[] calldata _addrs, uint _timestamp, uint8 _v, bytes32 _r, bytes32 _s external;` -## Demonstration - -> TODO: update the following with a goerli contract - -[Demo contract on the Rinkeby](https://rinkeby.etherscan.io/address/0xf99e2173db1f341a947ce9bd7779af2245309f91) -Sample of Registered Data: - -``` -{ - "data": { - "unique": true, - "context": "clr.fund", - "contextIds": [ - "0xb1775295f3b250c2849366801149479471fa7362", - "0x9ed6d9086f5ee9edc14dd2caca44d65ee8cabdde", - "0x79af508c9698076bc1c2dfa224f7829e9768b11e" - ], - "sig": { - "r": "ec6a9c3e10f238acb757ceea5507cf33366acd05356d513ca80cd1148297d079", - "s": "0e918c709ea7a458f7c95769145f475df94c01f3bc9e9ededf38153aa5b9041b", - "v": 28 - }, - "timestamp": 1602353670884, - "publicKey": "03ab573225151072be57d4808861e0f706595fb143c71630e188051fe4a6bda594" - } -} -``` - -You can see the contract settings [here](https://rinkeby.etherscan.io/address/0xf99e2173db1f341a947ce9bd7779af2245309f91#readContract) -You can update the BrightID settings and test register [here](https://rinkeby.etherscan.io/address/0xf99e2173db1f341a947ce9bd7779af2245309f91#writeContract) +### SnapshotUserRegistry -## Deploy contract +This is a contract to register verified users by the proof that the users held the minimum amount of tokens at a given block. -This contract needs two constructor arguments +The main functions: -- `context bytes32`
BrightID context used for verifying users. - -- `verifier address`
BrightID verifier address that signs BrightID verifications. - -## Points - -We can simply use an ERC20 token as authorization for the verifiers to be able have multiple verifiers. +- Set storage root
`function setStorageRoot(address tokenAddress, bytes32 stateRoot uint256 slotIndex, bytes memory accountProofRlpBytes) external onlyOwner;` +- Check a user is verified or not
`function isVerifiedUser(address _user) override external view returns (bool);` +- Add a user with the proof from eth_getProof
`function addUser(address _user, bytes memory storageProofRlpBytes) external;` diff --git a/contracts/contracts/userRegistry/SnapshotUserRegistry.sol b/contracts/contracts/userRegistry/SnapshotUserRegistry.sol new file mode 100644 index 000000000..4c089e9b0 --- /dev/null +++ b/contracts/contracts/userRegistry/SnapshotUserRegistry.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity ^0.6.12; + +import '@openzeppelin/contracts/access/Ownable.sol'; + +import './IUserRegistry.sol'; + +import {RLPReader} from "solidity-rlp/contracts/RLPReader.sol"; +import {StateProofVerifier} from "../utils/cryptography/StateProofVerifier.sol"; + + +/** + * @dev A user registry that verifies users based on ownership of a token + * at a specific block snapshot + */ +contract SnapshotUserRegistry is Ownable, IUserRegistry { + using RLPReader for RLPReader.RLPItem; + using RLPReader for bytes; + + enum Status { + Unverified, + Verified, + Rejected + } + + // User must hold this token at a specific block to be added to this registry + address public token; + + // block hash of the snapshot block + bytes32 public blockHash; + + // The storage root for the token at a specified block + bytes32 public storageRoot; + + // The slot index for the token balance + uint256 public storageSlot; + + // The minimum balance the user must hold to be verified + uint256 public minBalance = 1; + + // verified users + mapping(address => Status) public users; + + // Events + event UserAdded(address indexed _user); + event MinBalanceChanged(uint256 newBalance); + event StorageRootChanged(address indexed _token, bytes32 indexed _blockHash, uint256 storageSlot); + + /** + * @dev Set the storage root for the token contract at a specific block + * @param _tokenAddress Token address + * @param _blockHash Block hash + * @param _stateRoot Block state root + * @param _slotIndex slot index of the token balances storage + * @param _accountProofRlpBytes RLP encoded accountProof from eth_getProof + */ + function setStorageRoot( + address _tokenAddress, + bytes32 _blockHash, + bytes32 _stateRoot, + uint256 _slotIndex, + bytes memory _accountProofRlpBytes + ) + external + onlyOwner + { + + RLPReader.RLPItem[] memory proof = _accountProofRlpBytes.toRlpItem().toList(); + bytes32 addressHash = keccak256(abi.encodePacked(uint160(_tokenAddress))); + + StateProofVerifier.Account memory account = StateProofVerifier.extractAccountFromProof( + addressHash, + _stateRoot, + proof + ); + + token = _tokenAddress; + blockHash = _blockHash; + storageRoot = account.storageRoot; + storageSlot = _slotIndex; + + emit StorageRootChanged(token, blockHash, storageSlot); + } + + /** + * @dev Add a verified user to the registry. + * @param _user user account address + * @param storageProofRlpBytes RLP-encoded storage proof from eth_getProof + */ + function addUser( + address _user, + bytes memory storageProofRlpBytes + ) + external + { + require(storageRoot != bytes32(0), 'SnapshotUserRegistry: Registry is not initialized'); + require(_user != address(0), 'SnapshotUserRegistry: User address is zero'); + require(users[_user] == Status.Unverified, 'SnapshotUserRegistry: User already added'); + + RLPReader.RLPItem[] memory proof = storageProofRlpBytes.toRlpItem().toList(); + + bytes32 userSlotHash = keccak256(abi.encodePacked(uint256(uint160(_user)), storageSlot)); + bytes32 proofPath = keccak256(abi.encodePacked(userSlotHash)); + StateProofVerifier.SlotValue memory slotValue = StateProofVerifier.extractSlotValueFromProof(proofPath, storageRoot, proof); + require(slotValue.exists, 'SnapshotUserRegistry: User is not qualified'); + require(slotValue.value >= minBalance , 'SnapshotUserRegistry: User did not meet the minimum balance requirement'); + + users[_user] = Status.Verified; + emit UserAdded(_user); + } + + /** + * @dev Check if the user is verified. + */ + function isVerifiedUser(address _user) + override + external + view + returns (bool) + { + return users[_user] == Status.Verified; + } + + /** + * @dev Change the minimum balance a user must hold to be verified + * @param newMinBalance The new minimum balance + */ + function setMinBalance(uint256 newMinBalance) external onlyOwner { + require(newMinBalance > 0, 'SnapshotUserRegistry: The minimum balance must be greater than 0'); + + minBalance = newMinBalance; + + emit MinBalanceChanged(minBalance); + } +} diff --git a/contracts/contracts/utils/cryptography/MerklePatriciaProofVerifier.sol b/contracts/contracts/utils/cryptography/MerklePatriciaProofVerifier.sol new file mode 100644 index 000000000..236fc2715 --- /dev/null +++ b/contracts/contracts/utils/cryptography/MerklePatriciaProofVerifier.sol @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: MIT + +/** + * Modified from https://github.com/lidofinance/curve-merkle-oracle/blob/main/contracts/MerklePatriciaProofVerifier.sol + * git commit hash 1033b3e84142317ffd8f366b52e489d5eb49c73f + */ +pragma solidity ^0.6.12; + +import {RLPReader} from "solidity-rlp/contracts/RLPReader.sol"; + + +library MerklePatriciaProofVerifier { + using RLPReader for RLPReader.RLPItem; + using RLPReader for bytes; + + /// @dev Validates a Merkle-Patricia-Trie proof. + /// If the proof proves the inclusion of some key-value pair in the + /// trie, the value is returned. Otherwise, i.e. if the proof proves + /// the exclusion of a key from the trie, an empty byte array is + /// returned. + /// @param rootHash is the Keccak-256 hash of the root node of the MPT. + /// @param path is the key of the node whose inclusion/exclusion we are + /// proving. + /// @param stack is the stack of MPT nodes (starting with the root) that + /// need to be traversed during verification. + /// @return value whose inclusion is proved or an empty byte array for + /// a proof of exclusion + function extractProofValue( + bytes32 rootHash, + bytes memory path, + RLPReader.RLPItem[] memory stack + ) internal pure returns (bytes memory value) { + bytes memory mptKey = _decodeNibbles(path, 0); + uint256 mptKeyOffset = 0; + + bytes32 nodeHashHash; + RLPReader.RLPItem[] memory node; + + RLPReader.RLPItem memory rlpValue; + + if (stack.length == 0) { + // Root hash of empty Merkle-Patricia-Trie + require(rootHash == 0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421, "MerklePatriciaProofVerifier: Invalid empty root hash"); + return new bytes(0); + } + + // Traverse stack of nodes starting at root. + for (uint256 i = 0; i < stack.length; i++) { + + // We use the fact that an rlp encoded list consists of some + // encoding of its length plus the concatenation of its + // *rlp-encoded* items. + + // The root node is hashed with Keccak-256 ... + if (i == 0 && rootHash != stack[i].rlpBytesKeccak256()) { + revert("MerklePatriciaProofVerifier: Invalid first root hash"); + } + // ... whereas all other nodes are hashed with the MPT + // hash function. + if (i != 0 && nodeHashHash != _mptHashHash(stack[i])) { + revert("MerklePatriciaProofVerifier: Invalid node hash"); + } + // We verified that stack[i] has the correct hash, so we + // may safely decode it. + node = stack[i].toList(); + + if (node.length == 2) { + // Extension or Leaf node + + bool isLeaf; + bytes memory nodeKey; + (isLeaf, nodeKey) = _merklePatriciaCompactDecode(node[0].toBytes()); + + uint256 prefixLength = _sharedPrefixLength(mptKeyOffset, mptKey, nodeKey); + mptKeyOffset += prefixLength; + + if (prefixLength < nodeKey.length) { + // Proof claims divergent extension or leaf. (Only + // relevant for proofs of exclusion.) + // An Extension/Leaf node is divergent iff it "skips" over + // the point at which a Branch node should have been had the + // excluded key been included in the trie. + // Example: Imagine a proof of exclusion for path [1, 4], + // where the current node is a Leaf node with + // path [1, 3, 3, 7]. For [1, 4] to be included, there + // should have been a Branch node at [1] with a child + // at 3 and a child at 4. + + // Sanity check + if (i < stack.length - 1) { + // divergent node must come last in proof + revert("MerklePatriciaProofVerifier: divergent node must come last in the proof"); + } + + return new bytes(0); + } + + if (isLeaf) { + // Sanity check + if (i < stack.length - 1) { + // leaf node must come last in proof + revert("MerklePatriciaProofVerifier: leaf node must come last in the proof"); + } + + if (mptKeyOffset < mptKey.length) { + return new bytes(0); + } + + rlpValue = node[1]; + return rlpValue.toBytes(); + } else { // extension + // Sanity check + if (i == stack.length - 1) { + // shouldn't be at last level + revert("MerklePatriciaProofVerifier: unexpected last level for extension"); + } + + if (!node[1].isList()) { + // rlp(child) was at least 32 bytes. node[1] contains + // Keccak256(rlp(child)). + nodeHashHash = node[1].payloadKeccak256(); + } else { + // rlp(child) was less than 32 bytes. node[1] contains + // rlp(child). + nodeHashHash = node[1].rlpBytesKeccak256(); + } + } + } else if (node.length == 17) { + // Branch node + + if (mptKeyOffset != mptKey.length) { + // we haven't consumed the entire path, so we need to look at a child + uint8 nibble = uint8(mptKey[mptKeyOffset]); + mptKeyOffset += 1; + if (nibble >= 16) { + // each element of the path has to be a nibble + revert("MerklePatriciaProofVerifier: Each element of the path has to be a nibble"); + } + + if (_isEmptyBytesequence(node[nibble])) { + // Sanity + if (i != stack.length - 1) { + // leaf node should be at last level + revert("MerklePatriciaProofVerifier: leaf node should be at the last level"); + } + + return new bytes(0); + } else if (!node[nibble].isList()) { + nodeHashHash = node[nibble].payloadKeccak256(); + } else { + nodeHashHash = node[nibble].rlpBytesKeccak256(); + } + } else { + // we have consumed the entire mptKey, so we need to look at what's contained in this node. + + // Sanity + if (i != stack.length - 1) { + // should be at last level + revert("MerklePatriciaProofVerifier: Should be at the last level"); + } + + return node[16].toBytes(); + } + } + } + } + + + /// @dev Computes the hash of the Merkle-Patricia-Trie hash of the RLP item. + /// Merkle-Patricia-Tries use a weird "hash function" that outputs + /// *variable-length* hashes: If the item is shorter than 32 bytes, + /// the MPT hash is the item. Otherwise, the MPT hash is the + /// Keccak-256 hash of the item. + /// The easiest way to compare variable-length byte sequences is + /// to compare their Keccak-256 hashes. + /// @param item The RLP item to be hashed. + /// @return Keccak-256(MPT-hash(item)) + function _mptHashHash(RLPReader.RLPItem memory item) private pure returns (bytes32) { + if (item.len < 32) { + return item.rlpBytesKeccak256(); + } else { + return keccak256(abi.encodePacked(item.rlpBytesKeccak256())); + } + } + + function _isEmptyBytesequence(RLPReader.RLPItem memory item) private pure returns (bool) { + if (item.len != 1) { + return false; + } + uint8 b; + uint256 memPtr = item.memPtr; + assembly { + b := byte(0, mload(memPtr)) + } + return b == 0x80 /* empty byte string */; + } + + + function _merklePatriciaCompactDecode(bytes memory compact) private pure returns (bool isLeaf, bytes memory nibbles) { + require(compact.length > 0); + uint256 firstNibble = uint8(compact[0]) >> 4 & 0xF; + uint256 skipNibbles; + if (firstNibble == 0) { + skipNibbles = 2; + isLeaf = false; + } else if (firstNibble == 1) { + skipNibbles = 1; + isLeaf = false; + } else if (firstNibble == 2) { + skipNibbles = 2; + isLeaf = true; + } else if (firstNibble == 3) { + skipNibbles = 1; + isLeaf = true; + } else { + // Not supposed to happen! + revert("MerklePatriciaProofVerifier: Unexpected firstNibble value"); + } + return (isLeaf, _decodeNibbles(compact, skipNibbles)); + } + + + function _decodeNibbles(bytes memory compact, uint256 skipNibbles) private pure returns (bytes memory nibbles) { + require(compact.length > 0); + + uint256 length = compact.length * 2; + require(skipNibbles <= length); + length -= skipNibbles; + + nibbles = new bytes(length); + uint256 nibblesLength = 0; + + for (uint256 i = skipNibbles; i < skipNibbles + length; i += 1) { + if (i % 2 == 0) { + nibbles[nibblesLength] = bytes1((uint8(compact[i/2]) >> 4) & 0xF); + } else { + nibbles[nibblesLength] = bytes1((uint8(compact[i/2]) >> 0) & 0xF); + } + nibblesLength += 1; + } + + assert(nibblesLength == nibbles.length); + } + + + function _sharedPrefixLength(uint256 xsOffset, bytes memory xs, bytes memory ys) private pure returns (uint256) { + uint256 i; + for (i = 0; i + xsOffset < xs.length && i < ys.length; i++) { + if (xs[i + xsOffset] != ys[i]) { + return i; + } + } + return i; + } +} diff --git a/contracts/contracts/utils/cryptography/StateProofVerifier.sol b/contracts/contracts/utils/cryptography/StateProofVerifier.sol new file mode 100644 index 000000000..d3230a901 --- /dev/null +++ b/contracts/contracts/utils/cryptography/StateProofVerifier.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.6.12; + +import {RLPReader} from "solidity-rlp/contracts/RLPReader.sol"; +import {MerklePatriciaProofVerifier} from "./MerklePatriciaProofVerifier.sol"; + +/** + * @title A helper library for verification of Merkle Patricia account and state proofs. + * + * Modified from https://github.com/lidofinance/curve-merkle-oracle/blob/main/contracts/StateProofVerifier.sol + * git commit hash 1033b3e84142317ffd8f366b52e489d5eb49c73f + * + */ +library StateProofVerifier { + using RLPReader for RLPReader.RLPItem; + using RLPReader for bytes; + + struct Account { + bool exists; + uint256 nonce; + uint256 balance; + bytes32 storageRoot; + bytes32 codeHash; + } + + struct SlotValue { + bool exists; + uint256 value; + } + + /** + * @notice Verifies Merkle Patricia proof of an account and extracts the account fields. + * + * @param _addressHash Keccak256 hash of the address corresponding to the account. + * @param _stateRootHash MPT root hash of the Ethereum state trie. + */ + function extractAccountFromProof( + bytes32 _addressHash, // keccak256(abi.encodePacked(address)) + bytes32 _stateRootHash, + RLPReader.RLPItem[] memory _proof + ) + internal pure returns (Account memory) + { + bytes memory acctRlpBytes = MerklePatriciaProofVerifier.extractProofValue( + _stateRootHash, + abi.encodePacked(_addressHash), + _proof + ); + + Account memory account; + + if (acctRlpBytes.length == 0) { + return account; + } + + RLPReader.RLPItem[] memory acctFields = acctRlpBytes.toRlpItem().toList(); + require(acctFields.length == 4, "ProofVerifier: Invalid account length"); + + account.exists = true; + account.nonce = acctFields[0].toUint(); + account.balance = acctFields[1].toUint(); + account.storageRoot = bytes32(acctFields[2].toUint()); + account.codeHash = bytes32(acctFields[3].toUint()); + + return account; + } + + + /** + * @notice Verifies Merkle Patricia proof of a slot and extracts the slot's value. + * + * @param _slotHash Keccak256 hash of the slot position. + * @param _storageRootHash MPT root hash of the account's storage trie. + */ + function extractSlotValueFromProof( + bytes32 _slotHash, + bytes32 _storageRootHash, + RLPReader.RLPItem[] memory _proof + ) + internal pure returns (SlotValue memory) + { + bytes memory valueRlpBytes = MerklePatriciaProofVerifier.extractProofValue( + _storageRootHash, + abi.encodePacked(_slotHash), + _proof + ); + + SlotValue memory value; + + if (valueRlpBytes.length != 0) { + value.exists = true; + value.value = valueRlpBytes.toRlpItem().toUint(); + } + + return value; + } + +} diff --git a/contracts/package.json b/contracts/package.json index 019b1fde5..e2f51762f 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -25,10 +25,11 @@ "@openzeppelin/contracts": "3.2.0", "dotenv": "^8.2.0", "maci-contracts": "0.10.1", - "solidity-rlp": "2.0.3" + "solidity-rlp": "2.0.8" }, "devDependencies": { "@clrfund/maci-utils": "^0.0.1", + "@clrfund/common": "^0.0.1", "@ethereum-waffle/mock-contract": "^3.4.4", "@kleros/gtcr-encoder": "^1.4.0", "@nomiclabs/hardhat-ethers": "^2.2.1", diff --git a/contracts/scripts/deploy.ts b/contracts/scripts/deploy.ts index 160dee577..0acd9fef2 100644 --- a/contracts/scripts/deploy.ts +++ b/contracts/scripts/deploy.ts @@ -81,11 +81,20 @@ async function main() { process.env.BRIGHTID_VERIFIER_ADDR, process.env.BRIGHTID_SPONSOR ) + } else if (userRegistryType === 'snapshot') { + const SnapshotUserRegistry = await ethers.getContractFactory( + 'SnapshotUserRegistry', + deployer + ) + userRegistry = await SnapshotUserRegistry.deploy() } else { throw new Error('unsupported user registry type') } + await userRegistry.deployTransaction.wait() - console.log(`User registry deployed: ${userRegistry.address}`) + console.log( + `User registry (${userRegistryType}) deployed: ${userRegistry.address}` + ) const setUserRegistryTx = await fundingRoundFactory.setUserRegistry( userRegistry.address diff --git a/contracts/scripts/deployUserRegistry.ts b/contracts/scripts/deployUserRegistry.ts index 4f198ba04..26bad9f8d 100644 --- a/contracts/scripts/deployUserRegistry.ts +++ b/contracts/scripts/deployUserRegistry.ts @@ -40,6 +40,12 @@ async function main() { process.env.BRIGHTID_SPONSOR ) console.log('transaction hash', userRegistry.deployTransaction.hash) + } else if (userRegistryType === 'snapshot') { + const SnapshotUserRegistry = await ethers.getContractFactory( + 'SnapshotUserRegistry', + deployer + ) + userRegistry = await SnapshotUserRegistry.deploy() } else { throw new Error('unsupported user registry type') } diff --git a/contracts/tasks/findStorageSlot.ts b/contracts/tasks/findStorageSlot.ts new file mode 100644 index 000000000..c4c4f0452 --- /dev/null +++ b/contracts/tasks/findStorageSlot.ts @@ -0,0 +1,57 @@ +/** + * This is a best effort to find the storage slot used to query eth_getProof from + * the first 50 slots + * This assumes that `holder` holds a positive balance of tokens + * + * Copied from findMapSlot() from + * https://github.com/vocdoni/storage-proofs-eth-js/blob/main/src/erc20.ts#L62 + * + * + * Usage: hardhat find-storage-slot --token --holder --network arbitrum + */ + +import { task, types } from 'hardhat/config' +import { Contract, BigNumber } from 'ethers' +import { getStorageKey } from '@clrfund/common' + +const ERC20_ABI = [ + 'function balanceOf(address _owner) public view returns (uint256 balance)', +] + +task('find-storage-slot', 'Find the storage slot for an ERC20 token') + .addParam('token', 'ERC20 contract address') + .addParam('holder', 'The address of a token holder') + .addOptionalParam('maxSlot', 'Maximum slots to try', 50, types.int) + .setAction(async ({ token, holder, maxSlot }, { ethers }) => { + const blockNumber = await ethers.provider.getBlockNumber() + const tokenInstance = new Contract(token, ERC20_ABI, ethers.provider) + const balance = (await tokenInstance.balanceOf(holder)) as BigNumber + if (balance.isZero()) { + console.log( + 'The holder has no balance, try a different holder with a positive balance of tokens' + ) + return + } + + for (let pos = 0; pos < maxSlot; pos++) { + try { + const storageKey = getStorageKey(holder, pos) + + const value = await ethers.provider.getStorageAt( + token, + storageKey, + blockNumber + ) + + const onChainBalance = BigNumber.from(value) + if (!onChainBalance.eq(balance)) continue + + console.log('Storage slot index', pos) + return + } catch (err) { + continue + } + } + + console.log('Unable to find slot index') + }) diff --git a/contracts/tasks/index.ts b/contracts/tasks/index.ts index c37ce2f42..890bc9c20 100644 --- a/contracts/tasks/index.ts +++ b/contracts/tasks/index.ts @@ -14,3 +14,5 @@ import './setDurations' import './deploySponsor' import './loadUsers' import './tally' +import './findStorageSlot' +import './setStorageRoot' diff --git a/contracts/tasks/setStorageRoot.ts b/contracts/tasks/setStorageRoot.ts new file mode 100644 index 000000000..adc537dc5 --- /dev/null +++ b/contracts/tasks/setStorageRoot.ts @@ -0,0 +1,44 @@ +/** + * This script set the storage root in the snapshot user registry + * + * Usage: hardhat set-storage-root --registry --slot --token --block --network arbitrum-goerli + * + * Note: get the slot number using the `find-storage-slot` task + */ + +import { task, types } from 'hardhat/config' +import { getBlock, getAccountProof, rlpEncodeProof } from '@clrfund/common' +import { providers } from 'ethers' + +task('set-storage-root', 'Set the storage root in the snapshot user registry') + .addParam('registry', 'The snapshot user registry contract address') + .addParam('token', 'The token address') + .addParam('block', 'The block number', undefined, types.int) + .addParam( + 'slot', + 'The slot index of the balanceOf storage', + undefined, + types.int + ) + .setAction(async ({ token, slot, registry, block }, { ethers, network }) => { + const userRegistry = await ethers.getContractAt( + 'SnapshotUserRegistry', + registry + ) + + const blockInfo = await getBlock(block, ethers.provider) + const providerUrl = (network.config as any).url + const jsonRpcProvider = new providers.JsonRpcProvider(providerUrl) + const proof = await getAccountProof(token, blockInfo.hash, jsonRpcProvider) + const accountProofRlp = rlpEncodeProof(proof.accountProof) + const tx = await userRegistry.setStorageRoot( + token, + blockInfo.hash, + blockInfo.stateRoot, + slot, + accountProofRlp + ) + + console.log('Set storage root at tx hash', tx.hash) + await tx.wait() + }) diff --git a/contracts/tests/userRegistrySnapshot.ts b/contracts/tests/userRegistrySnapshot.ts new file mode 100644 index 000000000..fd0959742 --- /dev/null +++ b/contracts/tests/userRegistrySnapshot.ts @@ -0,0 +1,208 @@ +import { ethers } from 'hardhat' +import { use, expect } from 'chai' +import { solidity } from 'ethereum-waffle' +import { Contract, ContractTransaction, providers } from 'ethers' +import { + Block, + getBlock, + getAccountProof, + getStorageProof, + rlpEncodeProof, +} from '@clrfund/common' + +use(solidity) + +// Accounts from arbitrum-goerli to call eth_getProof as hardhat network +// does not support eth_getProof +const provider = new providers.InfuraProvider('arbitrum-goerli') + +const tokens = [ + { + address: '0x65bc8dd04808d99cf8aa6749f128d55c2051edde', + type: 'ERC20', + // get proof with this block number + snapshotBlock: 35904051, + // storage slot for balances in the token (0x65bc8dd04808d99cf8aa6749f128d55c2051edde) on arbitrum goerli + storageSlot: 2, + holders: { + currentAndSnapshotHolder: '0x0B0Fe9D858F7e3751A3dcC7ffd0B9236be5E4bf5', + snapshotHolder: '0xBa8aC318F2dd829AF3D0D93882b4F1a9F3307bFD', + currentHolderOnly: '0x5Fd5b076F6Ba8E8195cffb38A028afe5694b3d27', + zeroBalance: '0xfb96F12fDD64D674631DB7B40bC35cFE74E98aF7', + }, + }, + { + address: '0x7E8206276F8FE511bfa44c9135B222DEe75e58f4', + type: 'ERC721', + snapshotBlock: 35904824, + storageSlot: 3, + holders: { + currentAndSnapshotHolder: '0x980825655805509f47EbDE41515966aeD5Df883D', + snapshotHolder: '0x326850D078c34cBF6996756523b00f0f1731dF12', + currentHolderOnly: '0x8D4EFdF0891DC38AC3DA2C3C5E683C982D3F7426', + zeroBalance: '0x99c68BFfF94d70f736491E9824caeDd19307167B', + }, + }, +] + +/** + * Add a user to the snapshotUserRegistry + * @param userAccount The user address to add + * @param block Block containing the state root + * @param userRegistry The user registry contract + * @returns transaction + */ +async function addUser( + userAccount: string, + blockHash: string, + userRegistry: Contract, + tokenAddress: string, + storageSlot: number +): Promise { + const proof = await getStorageProof( + tokenAddress, + blockHash, + userAccount, + storageSlot, + provider + ) + + const storageRoot = await userRegistry.storageRoot() + expect(proof.storageHash).to.equal(storageRoot) + + const proofRlpBytes = rlpEncodeProof(proof.storageProof[0].proof) + return userRegistry.addUser(userAccount, proofRlpBytes) +} + +describe('SnapshotUserRegistry', function () { + let userRegistry: Contract + let block: Block + + before(async function () { + const [deployer] = await ethers.getSigners() + + const SnapshotUserRegistry = await ethers.getContractFactory( + 'SnapshotUserRegistry', + deployer + ) + userRegistry = await SnapshotUserRegistry.deploy() + }) + + describe('Add user', function () { + tokens.forEach((token) => { + describe(token.type, function () { + before(async function () { + try { + block = await getBlock(token.snapshotBlock, provider) + + const proof = await getAccountProof( + token.address, + block.hash, + provider + ) + const accountProofRlpBytes = rlpEncodeProof(proof.accountProof) + const tx = await userRegistry.setStorageRoot( + token.address, + block.hash, + block.stateRoot, + token.storageSlot, + accountProofRlpBytes + ) + await tx.wait() + } catch (err) { + console.log('error setting storage hash', err) + throw err + } + }) + + it('Shoule be able to add a user that meets requirement', async function () { + this.timeout(200000) + + const userAccount = token.holders.currentAndSnapshotHolder + await expect( + addUser( + userAccount, + block.hash, + userRegistry, + token.address, + token.storageSlot + ) + ) + .to.emit(userRegistry, 'UserAdded') + .withArgs(userAccount) + expect(await userRegistry.isVerifiedUser(userAccount)).to.equal(true) + }) + + it('Shoule not add non-holder', async function () { + this.timeout(200000) + + const user = ethers.Wallet.createRandom() + await expect( + addUser( + user.address, + block.hash, + userRegistry, + token.address, + token.storageSlot + ) + ).to.be.revertedWith('SnapshotUserRegistry: User is not qualified') + expect(await userRegistry.isVerifiedUser(user.address)).to.equal( + false + ) + }) + + it('Should not add a user with token balance 0', async function () { + this.timeout(200000) + + const user = { address: token.holders.zeroBalance } + await expect( + addUser( + user.address, + block.hash, + userRegistry, + token.address, + token.storageSlot + ) + ).to.be.revertedWith('SnapshotUserRegistry: User is not qualified') + expect(await userRegistry.isVerifiedUser(user.address)).to.equal( + false + ) + }) + + it('Shoule not add a user who currently meet the requirements, but did not at the snapshot block', async function () { + this.timeout(200000) + + const userAddress = token.holders.currentHolderOnly + await expect( + addUser( + userAddress, + block.hash, + userRegistry, + token.address, + token.storageSlot + ) + ).to.be.revertedWith('SnapshotUserRegistry: User is not qualified') + expect(await userRegistry.isVerifiedUser(userAddress)).to.equal(false) + }) + + it('Shoule add a user who met the requirement at the snapshot block, but not currently', async function () { + this.timeout(200000) + + const userAddress = token.holders.snapshotHolder + await expect( + addUser( + userAddress, + block.hash, + userRegistry, + token.address, + token.storageSlot + ) + ) + .to.emit(userRegistry, 'UserAdded') + .withArgs(userAddress) + expect(await userRegistry.isVerifiedUser(userAddress)).to.equal(true) + }) + }) + }) + }) +}) diff --git a/contracts/utils/contracts.ts b/contracts/utils/contracts.ts index f6ef16ef6..9fa12808d 100644 --- a/contracts/utils/contracts.ts +++ b/contracts/utils/contracts.ts @@ -1,4 +1,4 @@ -import { BigNumber, Contract } from 'ethers' +import { BigNumber, Contract, utils } from 'ethers' import { TransactionResponse } from '@ethersproject/abstract-provider' export async function getGasUsage( diff --git a/docs/deployment.md b/docs/deployment.md index 05bea95fe..435493bb4 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -20,7 +20,7 @@ Once the app is registered, you will get an appId which will be set to `BRIGHTID ## Deploy Contracts -### Deploy the BrightID sponsor contract +### Deploy the BrightID sponsor contract (if using BrightID) 1. Run `yarn hardhat --network {network} deploy-sponsor` 2. Verify the contract by running `yarn hardhat --network arbitrum-goerli verify {contract address}` @@ -44,20 +44,35 @@ BRIGHTID_SPONSOR= 1. Adjust the `/contracts/scripts/deploy.ts` as you wish. 2. Run `yarn hardhat run --network {network} scripts/deploy.ts` or use one of the `yarn deploy:{network}` available in `/contracts/package.json`. -3. Make sure to save in a safe place the serializedCoordinatorPrivKey and the serializedCoordinatorPubKey, you are going to need them for the website and tallying the votes in future steps. -4. To deploy a new funding round, update the .env file with the funding round factory address and the COORDINATOR_PK (with serializedCoordinatorPrivKey) deployed in the previous step and run the `newRound.ts` script: +3. Make sure to save in a safe place the serializedCoordinatorPrivKey, you are going to need it for tallying the votes in future steps. +4. To deploy a new funding round, update the .env file: ``` # .env +# The funding round factory address FACTORY_ADDRESS= +# The coordinator MACI private key (serializedCoordinatorPrivKey saved in step 3) COORDINATOR_PK= +# The coordinator wallet private key +COORDINATOR_ETH_PK= ``` +5. If using a snapshot user registry, run the `set-storage-root` task to set the storage root for the block snapshot for user account verification + +``` +yarn hardhat --network {network} set-storage-root --registry 0x7113b39Eb26A6F0a4a5872E7F6b865c57EDB53E0 --slot 2 --token 0x65bc8dd04808d99cf8aa6749f128d55c2051edde --block 34677758 --network arbitrum-goerli +``` + +Note: to get the storage slot '--slot' value, run the `find-storage-slot` task. + + +6. Run the `newRound.ts` script to deploy a new funding round: + ``` yarn hardhat run --network {network} scripts/newRound.ts ``` -4. Verify all deployed contracts: +5. Verify all deployed contracts: ``` yarn hardhat verify-all {funding-round-factory-address} --network {network} diff --git a/package.json b/package.json index 4f4211270..1f9757ac3 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "private": true, "workspaces": { "packages": [ + "common", "contracts", "maci-utils", "vue-app", diff --git a/vue-app/.env.example b/vue-app/.env.example index 23cceb272..0ecef6b3b 100644 --- a/vue-app/.env.example +++ b/vue-app/.env.example @@ -21,7 +21,7 @@ VITE_SUBGRAPH_URL=http://localhost:8000/subgraphs/name/clrfund/clrfund VITE_CLRFUND_FACTORY_ADDRESS=0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 -# Supported values: simple, brightid +# Supported values: simple, brightid, snapshot VITE_USER_REGISTRY_TYPE=simple # clr.fund (prod) or CLRFundTest (testing) # Learn more about BrightID and context in /docs/brightid.md diff --git a/vue-app/src/api/abi.ts b/vue-app/src/api/abi.ts index f8ffc89d8..ac98156d4 100644 --- a/vue-app/src/api/abi.ts +++ b/vue-app/src/api/abi.ts @@ -5,6 +5,7 @@ import { abi as MACIFactory } from '../../../contracts/build/contracts/contracts import { abi as MACI } from '../../../contracts/build/contracts/maci-contracts/sol/MACI.sol/MACI.json' import { abi as UserRegistry } from '../../../contracts/build/contracts/contracts/userRegistry/IUserRegistry.sol/IUserRegistry.json' import { abi as BrightIdUserRegistry } from '../../../contracts/build/contracts/contracts/userRegistry/BrightIdUserRegistry.sol/BrightIdUserRegistry.json' +import { abi as SnapshotUserRegistry } from '../../../contracts/build/contracts/contracts/userRegistry/SnapshotUserRegistry.sol/SnapshotUserRegistry.json' import { abi as SimpleRecipientRegistry } from '../../../contracts/build/contracts/contracts/recipientRegistry/SimpleRecipientRegistry.sol/SimpleRecipientRegistry.json' import { abi as OptimisticRecipientRegistry } from '../../../contracts/build/contracts/contracts/recipientRegistry/OptimisticRecipientRegistry.sol/OptimisticRecipientRegistry.json' import { abi as KlerosGTCR } from '../../../contracts/build/contracts/contracts/recipientRegistry/IKlerosGTCR.sol/IKlerosGTCR.json' @@ -17,6 +18,7 @@ export { MACIFactory, MACI, UserRegistry, + SnapshotUserRegistry, BrightIdUserRegistry, SimpleRecipientRegistry, OptimisticRecipientRegistry, diff --git a/vue-app/src/api/core.ts b/vue-app/src/api/core.ts index 703c758ae..77be17398 100644 --- a/vue-app/src/api/core.ts +++ b/vue-app/src/api/core.ts @@ -46,8 +46,10 @@ export const userRegistryType = import.meta.env.VITE_USER_REGISTRY_TYPE export enum UserRegistryType { BRIGHT_ID = 'brightid', SIMPLE = 'simple', + SNAPSHOT = 'snapshot', } -if (![UserRegistryType.BRIGHT_ID, UserRegistryType.SIMPLE].includes(userRegistryType as UserRegistryType)) { + +if (!Object.values(UserRegistryType).includes(userRegistryType as UserRegistryType)) { throw new Error('invalid user registry type') } export const recipientRegistryType = import.meta.env.VITE_RECIPIENT_REGISTRY_TYPE @@ -92,6 +94,7 @@ export const showComplianceRequirement = /^yes$/i.test(import.meta.env.VITE_SHOW export const isBrightIdRequired = userRegistryType === 'brightid' export const isOptimisticRecipientRegistry = recipientRegistryType === 'optimistic' +export const isUserRegistrationRequired = userRegistryType !== UserRegistryType.SIMPLE // Try to get the next scheduled start date const nextStartDate = import.meta.env.VITE_NEXT_ROUND_START_DATE diff --git a/vue-app/src/api/user.ts b/vue-app/src/api/user.ts index 20e4776d9..a7b42e34b 100644 --- a/vue-app/src/api/user.ts +++ b/vue-app/src/api/user.ts @@ -1,10 +1,12 @@ import makeBlockie from 'ethereum-blockies-base64' -import { BigNumber, Contract } from 'ethers' +import { BigNumber, Contract, Signer, type ContractTransaction } from 'ethers' import type { Web3Provider } from '@ethersproject/providers' import { UserRegistry, ERC20 } from './abi' import { factory, ipfsGatewayUrl, provider, operator } from './core' import type { BrightId } from './bright-id' +import { SnapshotUserRegistry } from './abi' +import { getStorageProof, rlpEncodeProof } from '@clrfund/common' //TODO: update anywhere this is called to take factory address as a parameter, default to env. variable export const LOGIN_MESSAGE = `Welcome to ${operator}! @@ -53,3 +55,21 @@ export async function getTokenBalance(tokenAddress: string, walletAddress: strin export async function getEtherBalance(walletAddress: string): Promise { return await provider.getBalance(walletAddress) } + +export async function registerSnapshotUser( + registryAddress: string, + walletAddress: string, + signer: Signer, +): Promise { + const registry = new Contract(registryAddress, SnapshotUserRegistry, signer) + const [tokenAddress, blockHash, storageSlot] = await Promise.all([ + registry.token(), + registry.blockHash(), + registry.storageSlot(), + ]) + + const proof = await getStorageProof(tokenAddress, blockHash, walletAddress, storageSlot, provider) + const proofRlpBytes = rlpEncodeProof(proof.storageProof[0].proof) + + return registry.addUser(walletAddress, proofRlpBytes) +} diff --git a/vue-app/src/components/CallToActionCard.vue b/vue-app/src/components/CallToActionCard.vue index 46e3d3512..ae6ba1c39 100644 --- a/vue-app/src/components/CallToActionCard.vue +++ b/vue-app/src/components/CallToActionCard.vue @@ -35,6 +35,16 @@ {{ $t('callToActionCard.link1') }} {{ $t('callToActionCard.link2') }} +
+ 🚀 +
+

{{ $t('callToActionCard.h2_3') }}

+

+ {{ $t('callToActionCard.p3') }} +

+
+ {{ $t('callToActionCard.link1') }} +