From 95891b8785565d0a94281be149716af67785b470 Mon Sep 17 00:00:00 2001 From: yuetloo Date: Thu, 17 Aug 2023 10:38:54 -0400 Subject: [PATCH 01/13] add snapshot user registry --- contracts/.env.example | 2 +- .../MerklePatriciaProofVerifier.sol | 255 ++++++++++++++++++ contracts/contracts/userRegistry/README.md | 47 +--- .../userRegistry/SnapshotUserRegistry.sol | 151 +++++++++++ .../userRegistry/StateProofVerifier.sol | 99 +++++++ contracts/package.json | 2 +- contracts/scripts/deploy.ts | 11 +- contracts/tasks/findStorageSlot.ts | 57 ++++ contracts/tasks/index.ts | 2 + contracts/tasks/setStorageRoot.ts | 44 +++ contracts/tests/userRegistrySnapshot.ts | 144 ++++++++++ contracts/utils/contracts.ts | 2 +- package.json | 1 + vue-app/.env.example | 2 +- vue-app/src/api/abi.ts | 2 + vue-app/src/api/core.ts | 5 +- vue-app/src/api/user.ts | 22 +- vue-app/src/components/Cart.vue | 14 +- vue-app/src/locales/en.json | 3 +- vue-app/src/router/index.ts | 4 +- vue-app/src/views/Verify.vue | 45 +++- yarn.lock | 8 +- 22 files changed, 861 insertions(+), 61 deletions(-) create mode 100644 contracts/contracts/userRegistry/MerklePatriciaProofVerifier.sol create mode 100644 contracts/contracts/userRegistry/SnapshotUserRegistry.sol create mode 100644 contracts/contracts/userRegistry/StateProofVerifier.sol create mode 100644 contracts/tasks/findStorageSlot.ts create mode 100644 contracts/tasks/setStorageRoot.ts create mode 100644 contracts/tests/userRegistrySnapshot.ts 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/MerklePatriciaProofVerifier.sol b/contracts/contracts/userRegistry/MerklePatriciaProofVerifier.sol new file mode 100644 index 000000000..236fc2715 --- /dev/null +++ b/contracts/contracts/userRegistry/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/userRegistry/README.md b/contracts/contracts/userRegistry/README.md index b1b853a43..8d01e94ad 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 ERC20 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..d2469aaa5 --- /dev/null +++ b/contracts/contracts/userRegistry/SnapshotUserRegistry.sol @@ -0,0 +1,151 @@ +// 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 "./StateProofVerifier.sol"; + + +/** + * @dev A user registry based on 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; + + // + 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; + + mapping(address => Status) public users; + + // Events + event UserAdded(address indexed _user); + event UserRejected(address indexed _user); + event UserReset(address indexed _user); + event MinBalanceChanged(uint256 newBalance); + + /** + * @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; + } + + /** + * @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), 'UserRegistry: Registry is not initialized'); + require(_user != address(0), 'UserRegistry: User address is zero'); + require(users[_user] == Status.Unverified, 'UserRegistry: 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, 'UserRegistry: User not qualified as a contributor'); + require(slotValue.value >= minBalance , 'UserRegistry: 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, 'The minimum balance must be greater than 0'); + + minBalance = newMinBalance; + + emit MinBalanceChanged(minBalance); + } + + /** + * @dev Reject user + * @param _user user account address + */ + function rejectUser(address _user) external onlyOwner { + users[_user] = Status.Rejected; + emit UserRejected(_user); + } + + /** + * @dev Reset user status so that it can be added again + * @param _user user account address + */ + function resetUser(address _user) external onlyOwner { + delete users[_user]; + emit UserReset(_user); + } +} \ No newline at end of file diff --git a/contracts/contracts/userRegistry/StateProofVerifier.sol b/contracts/contracts/userRegistry/StateProofVerifier.sol new file mode 100644 index 000000000..d3230a901 --- /dev/null +++ b/contracts/contracts/userRegistry/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..c6c558d39 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -25,7 +25,7 @@ "@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", 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/tasks/findStorageSlot.ts b/contracts/tasks/findStorageSlot.ts new file mode 100644 index 000000000..27f4cd24f --- /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 '../utils/contracts' + +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..c2912f622 --- /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 ERC20 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..55ee40497 --- /dev/null +++ b/contracts/tests/userRegistrySnapshot.ts @@ -0,0 +1,144 @@ +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 tokenAddress = '0x65bc8dd04808d99cf8aa6749f128d55c2051edde' +const userAccount = '0x0B0Fe9D858F7e3751A3dcC7ffd0B9236be5E4bf5' + +// storage slot for balances in the token (0x65bc8dd04808d99cf8aa6749f128d55c2051edde) on arbitrum goerli +const storageSlot = 2 + +// get proof with this block number +const blockNumber = 34677758 + +const provider = new providers.InfuraProvider('arbitrum-goerli') + +/** + * 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 +): 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.only('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() + + block = await getBlock(blockNumber, provider) + + try { + const proof = await getAccountProof(tokenAddress, block.hash, provider) + const accountProofRlpBytes = rlpEncodeProof(proof.accountProof) + const tx = await userRegistry.setStorageRoot( + tokenAddress, + block.hash, + block.stateRoot, + storageSlot, + accountProofRlpBytes + ) + await tx.wait() + } catch (err) { + console.log('error setting storage hash', err) + throw err + } + }) + + describe('Add user', function () { + it('Shoule be able to add a user that meets requirement', async function () { + this.timeout(200000) + + await expect(addUser(userAccount, block.hash, userRegistry)) + .to.emit(userRegistry, 'UserAdded') + .withArgs(userAccount) + expect(await userRegistry.isVerifiedUser(userAccount)).to.equal(true) + }) + + it('Shoule not add a user with token balance 0', async function () { + this.timeout(200000) + + const user = ethers.Wallet.createRandom() + await expect( + addUser(user.address, block.hash, userRegistry) + ).to.be.revertedWith('UserRegistry: User not qualified as a contributor') + expect(await userRegistry.isVerifiedUser(user.address)).to.equal(false) + }) + }) + + describe('Reject user', function () { + it('Shoule be able to reject a user', async function () { + this.timeout(200000) + + const isVerified = await userRegistry.isVerifiedUser(userAccount) + if (!isVerified) { + const tx = await addUser(userAccount, block.hash, userRegistry) + await tx.wait() + } + + await expect(userRegistry.rejectUser(userAccount)) + .to.emit(userRegistry, 'UserRejected') + .withArgs(userAccount) + expect(await userRegistry.isVerifiedUser(userAccount)).to.equal(false) + + await expect( + addUser(userAccount, block.hash, userRegistry) + ).to.be.revertedWith('UserRegistry: User already added') + }) + }) + + describe('Reset user', function () { + it('Shoule be able to add a user after it has been reset', async function () { + this.timeout(200000) + + await expect(userRegistry.resetUser(userAccount)) + .to.emit(userRegistry, 'UserReset') + .withArgs(userAccount) + expect(await userRegistry.isVerifiedUser(userAccount)).to.equal(false) + + await expect(addUser(userAccount, block.hash, userRegistry)) + .to.emit(userRegistry, 'UserAdded') + .withArgs(userAccount) + expect(await userRegistry.isVerifiedUser(userAccount)).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/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/Cart.vue b/vue-app/src/components/Cart.vue index f3d4d9dd7..b8d1f2c96 100644 --- a/vue-app/src/components/Cart.vue +++ b/vue-app/src/components/Cart.vue @@ -164,7 +164,7 @@ }) }} -
+
{{ $t('cart.link3') }}
+
+ 🚀 +
+

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

+

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

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