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..56f45b22d --- /dev/null +++ b/common/package.json @@ -0,0 +1,29 @@ +{ + "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": { + "@openzeppelin/merkle-tree": "^1.0.5", + "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..fd00cf3f4 --- /dev/null +++ b/common/src/block.ts @@ -0,0 +1,20 @@ +import { providers, utils } 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 blockNumberHex = utils.hexValue(blockNumber) + const blockParams = [blockNumberHex, false] + const rawBlock = await provider.send('eth_getBlockByNumber', blockParams) + return { blockNumber, hash: rawBlock.hash, stateRoot: rawBlock.stateRoot } +} diff --git a/common/src/index.ts b/common/src/index.ts new file mode 100644 index 000000000..d1c59a047 --- /dev/null +++ b/common/src/index.ts @@ -0,0 +1,4 @@ +export * from './block' +export * from './proof' +export * from './merkle' +export * from './ipfs' diff --git a/common/src/ipfs.ts b/common/src/ipfs.ts new file mode 100644 index 000000000..4254607e8 --- /dev/null +++ b/common/src/ipfs.ts @@ -0,0 +1,18 @@ +import { utils } from 'ethers' + +const IPFS_BASE_URL = 'https://ipfs.io' + +/** + * Get the IPFS content given the IPFS hash + * @param hash The IPFS hash + * @param gatewayUrl The IPFS gateway url + * @returns The IPFS content + */ +export async function getIpfsContent( + hash: string, + gatewayUrl = IPFS_BASE_URL +): Promise { + const url = `${gatewayUrl}/ipfs/${hash}` + const result = utils.fetchJson(url) + return result +} diff --git a/common/src/merkle.ts b/common/src/merkle.ts new file mode 100644 index 000000000..c93c4d2a1 --- /dev/null +++ b/common/src/merkle.ts @@ -0,0 +1,37 @@ +import { StandardMerkleTree } from '@openzeppelin/merkle-tree' + +/** + * Load users into a merkle tree + * @param users Users to load + * @returns user merkle tree + */ +export function loadUserMerkleTree( + users: string[] +): StandardMerkleTree { + const tree = StandardMerkleTree.of( + users.map((user) => [user.toLowerCase()]), + ['address'] + ) + return tree +} + +/** + * Get the merkle proof for the user + * @param userAccount User wallet address to get the proof for + * @param userMerkleTree The merkle tree containing all approved users + * @returns + */ +export function getUserMerkleProof( + userAccount: string, + userMerkleTree: StandardMerkleTree +): string[] | null { + try { + return userMerkleTree.getProof([userAccount.toLowerCase()]) + } catch (err) { + console.log('userAccount', userAccount.toLowerCase()) + console.log('getUserMerkleProof error', err) + return null + } +} + +export { StandardMerkleTree } 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 629924f37..9edad8c82 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, merkle USER_REGISTRY_TYPE=simple # clr.fund (prod) or CLRFundTest (testing) BRIGHTID_CONTEXT=clr.fund diff --git a/contracts/contracts/userRegistry/MerkleUserRegistry.sol b/contracts/contracts/userRegistry/MerkleUserRegistry.sol new file mode 100644 index 000000000..3a5c82960 --- /dev/null +++ b/contracts/contracts/userRegistry/MerkleUserRegistry.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity ^0.6.12; + +import '@openzeppelin/contracts/access/Ownable.sol'; + +import './IUserRegistry.sol'; +import {MerkleProof} from "../utils/cryptography/MerkleProof.sol"; + + +/** + * @dev A merkle user registry add users to the registry based on + * a successful verification against the merkle root set by + * the funding round coordinator. + */ +contract MerkleUserRegistry is Ownable, IUserRegistry { + + // verified users grouped by merkleRoot + // merkleRoot -> user -> status + mapping(bytes32 => mapping(address => bool)) private users; + + // merkle root + bytes32 public merkleRoot; + + // ipfs hash of the merkle tree file + string public merkleHash; + + // Events + event UserAdded(address indexed _user, bytes32 indexed merkleRoot); + event MerkleRootChanged(bytes32 indexed root, string ipfsHash); + + /** + * @dev Set the merkle root used to verify users + * @param root Merkle root + * @param ipfsHash The IPFS hash of the merkle tree file + */ + function setMerkleRoot(bytes32 root, string calldata ipfsHash) external onlyOwner { + require(root != bytes32(0), 'MerkleUserRegistry: Merkle root is zero'); + require(bytes(ipfsHash).length != 0, 'MerkleUserRegistry: Merkle hash is empty string'); + + merkleRoot = root; + merkleHash = ipfsHash; + + emit MerkleRootChanged(root, ipfsHash); + } + + /** + * @dev Add verified unique user to the registry. + */ + function addUser(address _user, bytes32[] calldata proof) + external + { + require(merkleRoot != bytes32(0), 'MerkleUserRegistry: Merkle root is not initialized'); + require(_user != address(0), 'MerkleUserRegistry: User address is zero'); + require(!users[merkleRoot][_user], 'MerkleUserRegistry: User already verified'); + + // verifies user against the merkle root + bytes32 leaf = keccak256(abi.encodePacked(keccak256(abi.encode(_user)))); + bool verified = MerkleProof.verifyCalldata(proof, merkleRoot, leaf); + require(verified, 'MerkleUserRegistry: User is not authorized'); + + users[merkleRoot][_user] = true; + emit UserAdded(_user, merkleRoot); + + } + + /** + * @dev Check if the user is verified. + */ + function isVerifiedUser(address _user) + override + external + view + returns (bool) + { + return users[merkleRoot][_user]; + } +} 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..857399ed3 --- /dev/null +++ b/contracts/contracts/userRegistry/SnapshotUserRegistry.sol @@ -0,0 +1,140 @@ +// 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 + // blockHash -> user -> status + mapping(bytes32 => mapping(address => Status)) public users; + + // Events + event UserAdded(address indexed _user, bytes32 indexed blockHash); + 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 + { + require(_tokenAddress != address(0), 'SnapshotUserRegistry: Token address is zero'); + require(_blockHash != bytes32(0), 'SnapshotUserRegistry: Block hash is zero'); + require(_stateRoot != bytes32(0), 'SnapshotUserRegistry: State root is zero'); + + 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[blockHash][_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[blockHash][_user] = Status.Verified; + emit UserAdded(_user, blockHash); + } + + /** + * @dev Check if the user is verified. + */ + function isVerifiedUser(address _user) + override + external + view + returns (bool) + { + return users[blockHash][_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/MerkleProof.sol b/contracts/contracts/utils/cryptography/MerkleProof.sol new file mode 100644 index 000000000..686125ec6 --- /dev/null +++ b/contracts/contracts/utils/cryptography/MerkleProof.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +// Modified from OpenZeppelin Contracts (last updated v4.9.2) (utils/cryptography/MerkleProof.sol) + +pragma solidity ^0.6.12; + +/** + * @dev These functions deal with verification of Merkle Tree proofs. + * + * The tree and the proofs can be generated using our + * https://github.com/OpenZeppelin/merkle-tree[JavaScript library]. + * You will find a quickstart guide in the readme. + * + * WARNING: You should avoid using leaf values that are 64 bytes long prior to + * hashing, or use a hash function other than keccak256 for hashing leaves. + * This is because the concatenation of a sorted pair of internal nodes in + * the merkle tree could be reinterpreted as a leaf value. + * OpenZeppelin's JavaScript library generates merkle trees that are safe + * against this attack out of the box. + */ +library MerkleProof { + + /** + * @dev Returns true if a `leaf` can be proved to be a part of a Merkle tree + * defined by `root`. For this, a `proof` must be provided, containing + * sibling hashes on the branch from the leaf to the root of the tree. Each + * pair of leaves and each pair of pre-images are assumed to be sorted. + */ + function verifyCalldata(bytes32[] calldata proof, bytes32 root, bytes32 leaf) internal pure returns (bool) { + return processProofCalldata(proof, leaf) == root; + } + + /** + * @dev Returns the rebuilt hash obtained by traversing a Merkle tree up + * from `leaf` using `proof`. A `proof` is valid if and only if the rebuilt + * hash matches the root of the tree. When processing the proof, the pairs + * of leafs & pre-images are assumed to be sorted. + */ + function processProofCalldata(bytes32[] calldata proof, bytes32 leaf) internal pure returns (bytes32) { + bytes32 computedHash = leaf; + for (uint256 i = 0; i < proof.length; i++) { + computedHash = _hashPair(computedHash, proof[i]); + } + return computedHash; + } + + function _hashPair(bytes32 a, bytes32 b) private pure returns (bytes32) { + return a < b ? _efficientHash(a, b) : _efficientHash(b, a); + } + + function _efficientHash(bytes32 a, bytes32 b) private pure returns (bytes32 value) { + /// @solidity memory-safe-assembly + assembly { + mstore(0x00, a) + mstore(0x20, b) + value := keccak256(0x00, 0x40) + } + } +} \ No newline at end of file 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 6d7e91ed2..bf020df62 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@clrfund/contracts", - "version": "4.2.6", + "version": "4.3.0", "license": "GPL-3.0", "scripts": { "hardhat": "hardhat", @@ -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..e65d93654 100644 --- a/contracts/scripts/deploy.ts +++ b/contracts/scripts/deploy.ts @@ -1,8 +1,12 @@ import { ethers } from 'hardhat' -import { Contract, utils, Wallet } from 'ethers' +import { Contract, Wallet } from 'ethers' import { UNIT } from '../utils/constants' -import { deployMaciFactory } from '../utils/deployment' +import { + deployMaciFactory, + deployUserRegistry, + getBrightIdParams, +} from '../utils/deployment' import { Keypair, PrivKey } from '@clrfund/maci-utils' // Number.MAX_SAFE_INTEGER - 1 @@ -63,29 +67,15 @@ async function main() { await transferOwnershipTx.wait() const userRegistryType = process.env.USER_REGISTRY_TYPE || 'simple' - let userRegistry: Contract - if (userRegistryType === 'simple') { - const SimpleUserRegistry = await ethers.getContractFactory( - 'SimpleUserRegistry', - deployer - ) - userRegistry = await SimpleUserRegistry.deploy() - } else if (userRegistryType === 'brightid') { - const BrightIdUserRegistry = await ethers.getContractFactory( - 'BrightIdUserRegistry', - deployer - ) - - userRegistry = await BrightIdUserRegistry.deploy( - utils.formatBytes32String(process.env.BRIGHTID_CONTEXT || 'clr.fund'), - process.env.BRIGHTID_VERIFIER_ADDR, - process.env.BRIGHTID_SPONSOR - ) - } else { - throw new Error('unsupported user registry type') - } - await userRegistry.deployTransaction.wait() - console.log(`User registry deployed: ${userRegistry.address}`) + const brightidParams = getBrightIdParams(userRegistryType) + const userRegistry: Contract = await deployUserRegistry( + userRegistryType, + deployer, + brightidParams + ) + 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..2373d5451 100644 --- a/contracts/scripts/deployUserRegistry.ts +++ b/contracts/scripts/deployUserRegistry.ts @@ -1,5 +1,5 @@ import { ethers } from 'hardhat' -import { Contract, utils } from 'ethers' +import { deployUserRegistry, getBrightIdParams } from '../utils/deployment' async function main() { console.log('*******************') @@ -20,32 +20,14 @@ async function main() { console.log('funding round factory address ', fundingRoundFactory.address) const userRegistryType = process.env.USER_REGISTRY_TYPE || 'simple' - let userRegistry: Contract - if (userRegistryType === 'simple') { - const SimpleUserRegistry = await ethers.getContractFactory( - 'SimpleUserRegistry', - deployer - ) - userRegistry = await SimpleUserRegistry.deploy() - } else if (userRegistryType === 'brightid') { - console.log('deploying brightid user registry') - const BrightIdUserRegistry = await ethers.getContractFactory( - 'BrightIdUserRegistry', - deployer - ) - - userRegistry = await BrightIdUserRegistry.deploy( - utils.formatBytes32String(process.env.BRIGHTID_CONTEXT || 'clr.fund'), - process.env.BRIGHTID_VERIFIER_ADDR, - process.env.BRIGHTID_SPONSOR - ) - console.log('transaction hash', userRegistry.deployTransaction.hash) - } else { - throw new Error('unsupported user registry type') - } - await userRegistry.deployTransaction.wait() + const brightidParams = getBrightIdParams(userRegistryType) + const userRegistry = await deployUserRegistry( + userRegistryType, + deployer, + brightidParams + ) console.log( - `Deployed ${userRegistryType} user registry at ${userRegistry.address}` + `deployed ${userRegistryType} user registry at ${userRegistry.address}` ) const setUserRegistryTx = await fundingRoundFactory.setUserRegistry( @@ -53,7 +35,7 @@ async function main() { ) await setUserRegistryTx.wait() console.log( - 'set user registry in funding round factory', + 'set user registry in funding round factory at tx hash', setUserRegistryTx.hash ) 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 35ade368e..8cd3f1ac2 100644 --- a/contracts/tasks/index.ts +++ b/contracts/tasks/index.ts @@ -14,3 +14,6 @@ import './setDurations' import './deploySponsor' import './loadUsers' import './tally' +import './findStorageSlot' +import './setStorageRoot' +import './loadMerkleUsers' diff --git a/contracts/tasks/loadMerkleUsers.ts b/contracts/tasks/loadMerkleUsers.ts new file mode 100644 index 000000000..ea1eb6179 --- /dev/null +++ b/contracts/tasks/loadMerkleUsers.ts @@ -0,0 +1,137 @@ +import { task, types } from 'hardhat/config' +import { Contract, utils } from 'ethers' +import fs from 'fs' +import { StandardMerkleTree } from '@clrfund/common' +import { getIpfsHash } from '../utils/ipfs' + +/* + * Load users into the the merkle user registry by generating a merkle tree and + * setting the merkle root of the merkle user registry. It will generate an output + * file with the dump of the merkle tree. This file should be uploaded to the IPFS. + * + * File path can be relative or absolute path. + * The script can only be run by the owner of the user registry + * + * Sample usage: + * + * yarn hardhat load-merkle-users --address-file addresses.txt --user-registry
--network goerli + */ + +const MAX_ADDRESSES_SUPPORTED = 10000 + +/** + * Load users in the file into the simple user registry + * + * @param registry Merkle user registry contract + * @param addressFile The path of the file containing the addresses + * @param output The path for the merkle tree output file + * @param silent true - do not print every address as it's being processed + */ +async function loadFile( + registry: Contract, + addressFile: string, + output: string, + silent: boolean +) { + let content: string | null = null + try { + content = fs.readFileSync(addressFile, 'utf8') + } catch (err) { + console.error('Failed to read file', addressFile, err) + return + } + + const addresses: string[] = [] + content.split(/\r?\n/).forEach(async (address) => { + addresses.push(address) + }) + + const validAddresses: string[] = [] + + console.log('Processing addresses...') + for (let i = 0; i < addresses.length; i++) { + const address = addresses[i] + const isValidAddress = Boolean(address) && utils.isAddress(address) + if (isValidAddress) { + try { + validAddresses.push(address) + if (!silent) { + console.log('Added address', address) + } + } catch (err: any) { + if (err.reason) { + console.error('Failed to add address', address, err.reason) + } else { + console.error('Failed to add address', address, err) + } + } + } else { + if (address) { + console.warn('Skipping invalid address', address) + } + } + } + + if (validAddresses.length === 0) { + throw new Error(`No valid address found in ${addressFile}`) + } + + if (validAddresses.length > MAX_ADDRESSES_SUPPORTED) { + // If the tree output file is too large, the web app will get error reading it from IPFS + throw new Error( + `We currently support loading a maximum of ${MAX_ADDRESSES_SUPPORTED} addresses` + ) + } + + const tree = StandardMerkleTree.of( + validAddresses.map((address) => [address]), + ['address'] + ) + + const treeDump = tree.dump() + fs.writeFileSync(output, JSON.stringify(treeDump, null, 4)) + + const ipfsHash = await getIpfsHash(treeDump) + + const tx = await registry.setMerkleRoot(tree.root, ipfsHash) + return tx +} + +task('load-merkle-users', 'Bulkload recipients into the simple user registry') + .addParam('userRegistry', 'The merkle user registry contract address') + .addParam( + 'addressFile', + 'The path of the file containing addresses separated by newline' + ) + .addOptionalParam( + 'output', + 'The output json file path for the merkle tree', + undefined, + types.string + ) + .addOptionalParam( + 'silent', + 'Do not log every address being processed', + true, + types.boolean + ) + .setAction( + async ({ userRegistry, addressFile, output, silent }, { ethers }) => { + const registry = await ethers.getContractAt( + 'MerkleUserRegistry', + userRegistry + ) + + console.log('User merkle registry', userRegistry) + console.log('Deployer', await registry.signer.getAddress()) + const timeMs = new Date().getTime() + const outputFile = output ? output : `./merkle_users_${timeMs}.json` + const tx = await loadFile(registry, addressFile, outputFile, silent) + console.log('User merkle root updated at tx hash', tx.hash) + await tx.wait() + + console.log( + `User merkle tree file generated at ${outputFile}, make sure to upload it to IPFS.` + ) + } + ) 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/userRegistryMerkle.ts b/contracts/tests/userRegistryMerkle.ts new file mode 100644 index 000000000..5ee1467eb --- /dev/null +++ b/contracts/tests/userRegistryMerkle.ts @@ -0,0 +1,85 @@ +import { ethers, waffle } from 'hardhat' +import { use, expect } from 'chai' +import { solidity } from 'ethereum-waffle' +import { Contract, utils, Wallet } from 'ethers' +import { loadUserMerkleTree, getUserMerkleProof } from '@clrfund/common' + +use(solidity) + +describe('Merkle User Registry', () => { + const provider = waffle.provider + const [, deployer, user1, user2] = provider.getWallets() + + const signers = { [user1.address]: user1, [user2.address]: user2 } + const authorizedUsers = [user1.address, user2.address] + let registry: Contract + let tree: any + + beforeEach(async () => { + const MerkleUserRegistry = await ethers.getContractFactory( + 'MerkleUserRegistry', + deployer + ) + registry = await MerkleUserRegistry.deploy() + tree = loadUserMerkleTree(authorizedUsers) + const tx = await registry.setMerkleRoot(tree.root, 'test') + await tx.wait() + }) + + it('rejects zero merkle root', async () => { + await expect( + registry.setMerkleRoot(utils.hexZeroPad('0x0', 32), 'testzero') + ).to.be.revertedWith('MerkleUserRegistry: Merkle root is zero') + }) + + it('should not allow non-owner to set the merkle root', async () => { + const registryAsUser = registry.connect(signers[user1.address]) + await expect( + registryAsUser.setMerkleRoot(utils.hexZeroPad('0x1', 32), 'non owner') + ).to.be.revertedWith('Ownable: caller is not the owner') + }) + + describe('registration', () => { + it('allows valid verified user to register', async () => { + for (const user of authorizedUsers) { + const proof = getUserMerkleProof(user, tree) + + const registryAsUser = registry.connect(signers[user]) + await expect(registryAsUser.addUser(user, proof)) + .to.emit(registryAsUser, 'UserAdded') + .withArgs(user, tree.root) + expect(await registryAsUser.isVerifiedUser(user)).to.equal(true) + } + }) + + it('rejects unauthorized user', async () => { + const user = ethers.Wallet.createRandom() + const proof = tree.getProof(0) + const registryAsUser = registry.connect(signers[user1.address]) + await expect( + registryAsUser.addUser(user.address, proof) + ).to.be.revertedWith('MerkleUserRegistry: User is not authorized') + expect(await registryAsUser.isVerifiedUser(user.address)).to.equal(false) + }) + + it('should be able load 10k users', async function () { + this.timeout(200000) + + const allAuthorizedUsers = Array.from(authorizedUsers) + for (let i = 0; i < 10000; i++) { + const randomWallet = new Wallet(utils.randomBytes(32)) + allAuthorizedUsers.push(randomWallet.address) + } + tree = loadUserMerkleTree(allAuthorizedUsers) + const tx = await registry.setMerkleRoot(tree.root, 'test') + await tx.wait() + + const registryAsUser = registry.connect(user1) + const proof = getUserMerkleProof(user1.address, tree) + await expect(registryAsUser.addUser(user1.address, proof)) + .to.emit(registryAsUser, 'UserAdded') + .withArgs(user1.address, tree.root) + expect(await registryAsUser.isVerifiedUser(user1.address)).to.equal(true) + }) + }) +}) diff --git a/contracts/tests/userRegistrySnapshot.ts b/contracts/tests/userRegistrySnapshot.ts new file mode 100644 index 000000000..97a4422e2 --- /dev/null +++ b/contracts/tests/userRegistrySnapshot.ts @@ -0,0 +1,261 @@ +import { ethers } from 'hardhat' +import { use, expect } from 'chai' +import { solidity } from 'ethereum-waffle' +import { + Contract, + ContractTransaction, + providers, + constants, + utils, +} 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('Set Storage Root', function () { + const token = tokens[0] + let accountProofRlpBytes: string + before(async function () { + block = await getBlock(token.snapshotBlock, provider) + + const proof = await getAccountProof(token.address, block.hash, provider) + accountProofRlpBytes = rlpEncodeProof(proof.accountProof) + }) + + it('Should throw if token address is 0', async function () { + await expect( + userRegistry.setStorageRoot( + constants.AddressZero, + block.hash, + block.stateRoot, + token.storageSlot, + accountProofRlpBytes + ) + ).to.be.revertedWith('SnapshotUserRegistry: Token address is zero') + }) + + it('Should throw if block hash is 0', async function () { + await expect( + userRegistry.setStorageRoot( + token.address, + utils.hexZeroPad('0x00', 32), + block.stateRoot, + token.storageSlot, + accountProofRlpBytes + ) + ).to.be.revertedWith('SnapshotUserRegistry: Block hash is zero') + }) + + it('Should throw if state root is 0', async function () { + await expect( + userRegistry.setStorageRoot( + token.address, + block.hash, + utils.hexZeroPad('0x00', 32), + token.storageSlot, + accountProofRlpBytes + ) + ).to.be.revertedWith('SnapshotUserRegistry: State root is zero') + }) + }) + + 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, block.hash) + 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, block.hash) + 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/contracts/utils/deployment.ts b/contracts/utils/deployment.ts index 87b161a82..342de8d81 100644 --- a/contracts/utils/deployment.ts +++ b/contracts/utils/deployment.ts @@ -1,12 +1,53 @@ import { ethers, config } from 'hardhat' import { Libraries } from 'hardhat/types/runtime' -import { Signer, Contract } from 'ethers' +import { Signer, Contract, utils } from 'ethers' import { link } from 'ethereum-waffle' import path from 'path' import { MaciParameters } from './maci' import { readFileSync } from 'fs' +const userRegistryNames: Record = { + simple: 'SimpleUserRegistry', + brightid: 'BrightIdUserRegistry', + snapshot: 'SnapshotUserRegistry', + merkle: 'MerkleUserRegistry', +} + +export interface BrightIdParams { + context: string + verifierAddress: string + sponsor: string +} + +/** + * Return the brightid user registry contructor parameter values + * @param userRegistryType user registry type + * @returns BrightIdParams or null + */ +export function getBrightIdParams( + userRegistryType: string +): BrightIdParams | null { + if (userRegistryType === 'brightid') { + const verifierAddress = process.env.BRIGHTID_VERIFIER_ADDR + const sponsor = process.env.BRIGHTID_SPONSOR + if (!verifierAddress) { + throw new Error('Missing environment variable BRIGHTID_VERIFIER_ADDR') + } + if (!sponsor) { + throw new Error('Missing environment variable BRIGHTID_SPONSOR') + } + + return { + context: process.env.BRIGHTID_CONTEXT || 'clr.fund', + verifierAddress, + sponsor, + } + } else { + return null + } +} + export function linkBytecode( bytecode: string, libraries: { [name: string]: string } @@ -193,3 +234,41 @@ export async function deployMaciFactory( return maciFactory } + +export async function deployUserRegistry( + userRegistryType: string, + deployer: Signer, + brightid: BrightIdParams | null +): Promise { + let userRegistry: Contract + if (userRegistryType === 'brightid') { + if (!brightid) { + throw new Error('Missing BrightId parameter') + } + + const BrightIdUserRegistry = await ethers.getContractFactory( + 'BrightIdUserRegistry', + deployer + ) + + userRegistry = await BrightIdUserRegistry.deploy( + utils.formatBytes32String(brightid.context), + brightid.verifierAddress, + brightid.sponsor + ) + } else { + const userRegistryName = userRegistryNames[userRegistryType] + if (!userRegistryName) { + throw new Error('unsupported user registry type: ' + userRegistryType) + } + + const UserRegistry = await ethers.getContractFactory( + userRegistryName, + deployer + ) + userRegistry = await UserRegistry.deploy() + } + + await userRegistry.deployTransaction.wait() + return userRegistry +} diff --git a/docs/deployment.md b/docs/deployment.md index 05bea95fe..e82ec0680 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,44 @@ 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. + +5. If using a merkle user registry, run the `load-merkle-users` task to set the merkle root for all the authorized users + +``` +# for example: +yarn hardhat load-merkle-users --address-file ./addresses.txt --user-registry 0x9E1c12Af45504e66D16D592cAF3B877ddc6fF643 --network arbitrum-goerli +``` + +Note: Make sure to upload generated merkle tree file to IPFS. + + +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/subgraph/package.json b/subgraph/package.json index 154155e2f..566cf576a 100644 --- a/subgraph/package.json +++ b/subgraph/package.json @@ -1,6 +1,6 @@ { "name": "@clrfund/subgraph", - "version": "4.2.6", + "version": "4.3.0", "repository": "https://github.com/clrfund/monorepo/subgraph", "keywords": [ "clr.fund", diff --git a/vue-app/.env.example b/vue-app/.env.example index 23cceb272..238134534 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, merkle 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/package.json b/vue-app/package.json index 9766c62c6..275c9d584 100644 --- a/vue-app/package.json +++ b/vue-app/package.json @@ -1,6 +1,6 @@ { "name": "@clrfund/vue-app", - "version": "4.2.6", + "version": "4.3.0", "private": true, "license": "GPL-3.0", "scripts": { diff --git a/vue-app/src/api/abi.ts b/vue-app/src/api/abi.ts index f8ffc89d8..76a08cba0 100644 --- a/vue-app/src/api/abi.ts +++ b/vue-app/src/api/abi.ts @@ -5,6 +5,8 @@ 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 MerkleUserRegistry } from '../../../contracts/build/contracts/contracts/userRegistry/MerkleUserRegistry.sol/MerkleUserRegistry.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 +19,8 @@ export { MACIFactory, MACI, UserRegistry, + SnapshotUserRegistry, + MerkleUserRegistry, BrightIdUserRegistry, SimpleRecipientRegistry, OptimisticRecipientRegistry, diff --git a/vue-app/src/api/core.ts b/vue-app/src/api/core.ts index 703c758ae..3a4467de9 100644 --- a/vue-app/src/api/core.ts +++ b/vue-app/src/api/core.ts @@ -46,8 +46,11 @@ export const userRegistryType = import.meta.env.VITE_USER_REGISTRY_TYPE export enum UserRegistryType { BRIGHT_ID = 'brightid', SIMPLE = 'simple', + SNAPSHOT = 'snapshot', + MERKLE = 'merkle', } -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 +95,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..252f44a15 100644 --- a/vue-app/src/api/user.ts +++ b/vue-app/src/api/user.ts @@ -1,10 +1,18 @@ 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 { FundingRound, UserRegistry, ERC20 } from './abi' import { factory, ipfsGatewayUrl, provider, operator } from './core' import type { BrightId } from './bright-id' +import { SnapshotUserRegistry, MerkleUserRegistry } from './abi' +import { + getUserMerkleProof, + getStorageProof, + rlpEncodeProof, + getIpfsContent, + StandardMerkleTree, +} 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}! @@ -45,6 +53,12 @@ export async function isVerifiedUser(userRegistryAddress: string, walletAddress: return await registry.isVerifiedUser(walletAddress) } +export async function isRegisteredUser(fundingRoundAddress: string, walletAddress: string): Promise { + const round = new Contract(fundingRoundAddress, FundingRound, provider) + const contributor = await round.contributors(walletAddress) + return contributor.isRegistered +} + export async function getTokenBalance(tokenAddress: string, walletAddress: string): Promise { const token = new Contract(tokenAddress, ERC20, provider) return await token.balanceOf(walletAddress) @@ -53,3 +67,75 @@ export async function getTokenBalance(tokenAddress: string, walletAddress: strin export async function getEtherBalance(walletAddress: string): Promise { return await provider.getBalance(walletAddress) } + +/** + * Register a user in the Snapshot user registry + * @param registryAddress The snapshot user registry contract address + * @param proofRlpBytes The RLP encoded proof + * @param signer The signer + * @returns The contract transaction + */ +export async function registerUserSnapshot( + registryAddress: string, + proofRlpBytes: string, + signer: Signer, +): Promise { + const registry = new Contract(registryAddress, SnapshotUserRegistry, signer) + const walletAddress = await signer.getAddress() + return registry.addUser(walletAddress, proofRlpBytes) +} + +/** + * Get the snapshot proof for the signer + * @param registryAddress Th snapshot user registry address + * @param signer Th user to get the proof for + * @returns RLP encoded proof + */ +export async function getProofSnapshot(registryAddress: string, signer: Signer) { + const registry = new Contract(registryAddress, SnapshotUserRegistry, signer) + const [tokenAddress, blockHash, storageSlot] = await Promise.all([ + registry.token(), + registry.blockHash(), + registry.storageSlot(), + ]) + + const walletAddress = await signer.getAddress() + const proof = await getStorageProof(tokenAddress, blockHash, walletAddress, storageSlot, provider) + return rlpEncodeProof(proof.storageProof[0].proof) +} + +/** + * Register a user in the merkle user registry + * @param registryAddress The merkle user registry + * @param proof The merkle proof + * @param signer The user to be registered + * @returns The contract transaction + */ +export async function registerUserMerkle( + registryAddress: string, + proof: string[], + signer: Signer, +): Promise { + const registry = new Contract(registryAddress, MerkleUserRegistry, signer) + const walletAddress = await signer.getAddress() + return registry.addUser(walletAddress, proof) +} + +/** + * Get the merkle proof for the signer + * @param registryAddress The merkle user registry + * @param signer The user to get the proof for + * @returns proof + */ +export async function getProofMerkle(registryAddress: string, signer: Signer): Promise { + const registry = new Contract(registryAddress, MerkleUserRegistry, signer) + const merkleHash = await registry.merkleHash() + if (!merkleHash) { + throw new Error('User registry is not initialized, missing merkle hash') + } + + const treeRaw = await getIpfsContent(merkleHash, ipfsGatewayUrl) + const tree = StandardMerkleTree.load(treeRaw) + const walletAddress = await signer.getAddress() + return getUserMerkleProof(walletAddress, tree) +} diff --git a/vue-app/src/components/CallToActionCard.vue b/vue-app/src/components/CallToActionCard.vue index 46e3d3512..7d094a1a6 100644 --- a/vue-app/src/components/CallToActionCard.vue +++ b/vue-app/src/components/CallToActionCard.vue @@ -24,16 +24,28 @@
- - 🚀 -
-

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

-

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

+
+ + 🚀 +
+

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

+

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

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

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

+

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

+
+ {{ $t('callToActionCard.link1') }}
- {{ $t('callToActionCard.link1') }} - {{ $t('callToActionCard.link2') }}
@@ -43,7 +55,7 @@ import { computed } from 'vue' import BrightIdWidget from '@/components/BrightIdWidget.vue' import Links from '@/components/Links.vue' -import { userRegistryType, UserRegistryType } from '@/api/core' +import { userRegistryType, UserRegistryType, isBrightIdRequired } from '@/api/core' import { useAppStore, useUserStore } from '@/stores' import { storeToRefs } from 'pinia' @@ -57,7 +69,7 @@ const hasStartedVerification = computed( ) const showUserVerification = computed(() => { return ( - userRegistryType === UserRegistryType.BRIGHT_ID && + userRegistryType !== UserRegistryType.SIMPLE && currentRound.value && currentUser.value?.isRegistered !== undefined && !currentUser.value.isRegistered diff --git a/vue-app/src/components/Cart.vue b/vue-app/src/components/Cart.vue index f3d4d9dd7..a8a602e27 100644 --- a/vue-app/src/components/Cart.vue +++ b/vue-app/src/components/Cart.vue @@ -164,8 +164,11 @@ }) }}
-
- {{ $t('cart.link3') }} +
+ + {{ $t('cart.link3') }} + {{ $t('cart.linkRegister') }} +