diff --git a/contracts/.solhint.json b/contracts/.solhint.json index ea2a7d2b8..02fe731da 100644 --- a/contracts/.solhint.json +++ b/contracts/.solhint.json @@ -5,6 +5,7 @@ "not-rely-on-time": "off", "quotes": ["error", "single"], "reason-string": ["warn", {"maxLength": 64}], - "max-states-count": ["warn", 18] + "max-states-count": ["warn", 18], + "check-send-result": "off" } } diff --git a/contracts/contracts/recipientRegistry/OptimisticRecipientRegistry.sol b/contracts/contracts/recipientRegistry/OptimisticRecipientRegistry.sol new file mode 100644 index 000000000..d997e9784 --- /dev/null +++ b/contracts/contracts/recipientRegistry/OptimisticRecipientRegistry.sol @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.6.12; + +import '@openzeppelin/contracts/access/Ownable.sol'; + +import './BaseRecipientRegistry.sol'; + +/** + * @dev Recipient registry with optimistic execution of registrations and removals. + */ +contract OptimisticRecipientRegistry is Ownable, BaseRecipientRegistry { + + // Structs + struct Request { + address payable requester; + uint256 submissionTime; + uint256 deposit; + address recipientAddress; // Undefined in removal requests + string recipientMetadata; + } + + // State + uint256 public baseDeposit; + uint256 public challengePeriodDuration; + mapping(bytes32 => Request) private requests; + + // Events + event RequestSubmitted(bytes32 indexed _recipientId, address _recipient, string _metadata); + event RequestRejected(bytes32 indexed _recipientId); + event RecipientAdded(bytes32 indexed _recipientId, address _recipient, string _metadata, uint256 _index); + event RecipientRemoved(bytes32 indexed _recipientId); + + /** + * @dev Deploy the registry. + * @param _baseDeposit Deposit required to add or remove item. + * @param _challengePeriodDuration The time owner has to challenge a request (seconds). + * @param _controller Controller address. Normally it's a funding round factory contract. + */ + constructor( + uint256 _baseDeposit, + uint256 _challengePeriodDuration, + address _controller + ) + public + { + baseDeposit = _baseDeposit; + challengePeriodDuration = _challengePeriodDuration; + controller = _controller; + } + + /** + * @dev Change base deposit. + */ + function setBaseDeposit(uint256 _baseDeposit) + external + onlyOwner + { + baseDeposit = _baseDeposit; + } + + /** + * @dev Change challenge period duration. + */ + function setChallengePeriodDuration(uint256 _challengePeriodDuration) + external + onlyOwner + { + challengePeriodDuration = _challengePeriodDuration; + } + + /** + * @dev Submit recipient registration request. + * @param _recipient The address that receives funds. + * @param _metadata The metadata info of the recipient. + */ + function addRecipient(address _recipient, string calldata _metadata) + external + payable + { + require(_recipient != address(0), 'RecipientRegistry: Recipient address is zero'); + require(bytes(_metadata).length != 0, 'RecipientRegistry: Metadata info is empty string'); + bytes32 recipientId = keccak256(abi.encodePacked(_recipient, _metadata)); + require(recipients[recipientId].index == 0, 'RecipientRegistry: Recipient already registered'); + require(requests[recipientId].submissionTime == 0, 'RecipientRegistry: Request already submitted'); + require(msg.value == baseDeposit, 'RecipientRegistry: Incorrect deposit amount'); + requests[recipientId] = Request( + msg.sender, + block.timestamp, + msg.value, + _recipient, + _metadata + ); + emit RequestSubmitted(recipientId, _recipient, _metadata); + } + + /** + * @dev Submit recipient removal request. + * @param _recipientId The ID of recipient. + */ + function removeRecipient(bytes32 _recipientId) + external + payable + { + require(recipients[_recipientId].index != 0, 'RecipientRegistry: Recipient is not in the registry'); + require(recipients[_recipientId].removedAt == 0, 'RecipientRegistry: Recipient already removed'); + require(requests[_recipientId].submissionTime == 0, 'RecipientRegistry: Request already submitted'); + require(msg.value == baseDeposit, 'RecipientRegistry: Incorrect deposit amount'); + requests[_recipientId] = Request( + msg.sender, + block.timestamp, + msg.value, + address(0), + '' + ); + emit RequestSubmitted(_recipientId, address(0), ''); + } + + /** + * @dev Reject request. + * @param _recipientId The ID of recipient. + */ + function challengeRequest(bytes32 _recipientId) + external + onlyOwner + returns (bool) + { + Request memory request = requests[_recipientId]; + require(request.submissionTime != 0, 'RecipientRegistry: Request does not exist'); + address payable challenger = payable(owner()); + bool isSent = challenger.send(request.deposit); + delete requests[_recipientId]; + emit RequestRejected(_recipientId); + return isSent; + } + + /** + * @dev Execute request. + * @param _recipientId The ID of recipient. + */ + function executeRequest(bytes32 _recipientId) + external + returns (bool) + { + Request memory request = requests[_recipientId]; + require(request.submissionTime != 0, 'RecipientRegistry: Request does not exist'); + require( + block.timestamp - request.submissionTime >= challengePeriodDuration, + 'RecipientRegistry: Challenge period is not over' + ); + if (request.recipientAddress == address(0)) { + // No recipient address: this is a removal request + _removeRecipient(_recipientId); + emit RecipientRemoved(_recipientId); + } else { + // WARNING: Could revert if no slots are available or if recipient limit is not set + uint256 recipientIndex = _addRecipient(_recipientId, request.recipientAddress); + emit RecipientAdded( + _recipientId, + request.recipientAddress, + request.recipientMetadata, + recipientIndex + ); + } + bool isSent = request.requester.send(request.deposit); + delete requests[_recipientId]; + return isSent; + } +} diff --git a/contracts/tests/recipientRegistry.ts b/contracts/tests/recipientRegistry.ts index f6ce075d6..abe156a28 100644 --- a/contracts/tests/recipientRegistry.ts +++ b/contracts/tests/recipientRegistry.ts @@ -1,11 +1,13 @@ import { ethers, waffle } from 'hardhat' import { use, expect } from 'chai' import { solidity } from 'ethereum-waffle' -import { Contract } from 'ethers' +import { BigNumber, Contract } from 'ethers' import { keccak256 } from '@ethersproject/solidity' import { gtcrEncode } from '@kleros/gtcr-encoder' -import { ZERO_ADDRESS } from '../utils/constants' +import { UNIT, ZERO_ADDRESS } from '../utils/constants' +import { getTxFee } from '../utils/contracts' +import { deployContract } from '../utils/deployment' use(solidity) @@ -416,3 +418,332 @@ describe('Kleros GTCR adapter', () => { }) }) }) + +describe('Optimistic recipient registry', () => { + const [, deployer, controller, recipient, requester] = provider.getWallets() + let registry: Contract + + const baseDeposit = UNIT.div(10) // 0.1 ETH + const challengePeriodDuration = BigNumber.from(86400) // Seconds + + beforeEach(async () => { + registry = await deployContract(deployer, 'OptimisticRecipientRegistry', [ + baseDeposit, + challengePeriodDuration, + controller.address, + ]) + }) + + it('initializes correctly', async () => { + expect(await registry.baseDeposit()).to.equal(baseDeposit) + expect(await registry.challengePeriodDuration()).to.equal(challengePeriodDuration) + expect(await registry.controller()).to.equal(controller.address) + expect(await registry.maxRecipients()).to.equal(0) + }) + + it('changes base deposit', async () => { + const newBaseDeposit = baseDeposit.mul(2) + await registry.setBaseDeposit(newBaseDeposit) + expect(await registry.baseDeposit()).to.equal(newBaseDeposit) + }) + + it('changes challenge period duration', async () => { + const newChallengePeriodDuration = challengePeriodDuration.mul(2) + await registry.setChallengePeriodDuration(newChallengePeriodDuration) + expect(await registry.challengePeriodDuration()).to.equal(newChallengePeriodDuration) + }) + + describe('managing recipients', () => { + const recipientIndex = 1 + let recipientAddress: string + let metadata: string + let recipientId: string + + function getRecipientId(address: string, metadata: string): string { + return keccak256( + ['address', 'string'], + [address, metadata], + ) + } + + beforeEach(async () => { + await registry.connect(controller).setMaxRecipients(MAX_RECIPIENTS) + recipientAddress = recipient.address + metadata = JSON.stringify({ name: 'Recipient', description: 'Description', imageHash: 'Ipfs imageHash' }) + recipientId = getRecipientId(recipientAddress, metadata) + }) + + it('allows anyone to submit registration request', async () => { + await expect(registry.connect(requester).addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + )) + .to.emit(registry, 'RequestSubmitted') + .withArgs(recipientId, recipientAddress, metadata) + expect(await provider.getBalance(registry.address)).to.equal(baseDeposit) + }) + + it('should not accept zero-address as recipient address', async () => { + recipientAddress = ZERO_ADDRESS + await expect(registry.addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + )) + .to.be.revertedWith('RecipientRegistry: Recipient address is zero') + }) + + it('should not accept empty string as recipient metadata', async () => { + metadata = '' + await expect(registry.addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + )) + .to.be.revertedWith('RecipientRegistry: Metadata info is empty string') + }) + + it('should not accept registration request if recipient is already registered', async () => { + await registry.addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + ) + await provider.send('evm_increaseTime', [86400]) + await registry.executeRequest(recipientId) + await expect(registry.addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + )) + .to.be.revertedWith('RecipientRegistry: Recipient already registered') + }) + + it('should not accept new registration request if previous request is not resolved', async () => { + await registry.addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + ) + await expect(registry.addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + )) + .to.be.revertedWith('RecipientRegistry: Request already submitted') + }) + + it('should not accept registration request with incorrect deposit size', async () => { + await expect(registry.addRecipient( + recipientAddress, metadata, { value: baseDeposit.div(2) }, + )) + .to.be.revertedWith('RecipientRegistry: Incorrect deposit amount') + }) + + it('allows owner to challenge registration request', async () => { + await registry.addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + ) + const ownerBalanceBefore = await provider.getBalance(deployer.address) + const requestChallenged = await registry.challengeRequest(recipientId) + expect(requestChallenged) + .to.emit(registry, 'RequestRejected') + .withArgs(recipientId) + const txFee = await getTxFee(requestChallenged) + const ownerBalanceAfter = await provider.getBalance(deployer.address) + expect(ownerBalanceBefore.sub(txFee).add(baseDeposit)) + .to.equal(ownerBalanceAfter) + }) + + it('allows only owner to challenge requests', async () => { + await registry.connect(requester).addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + ) + await expect(registry.connect(requester).challengeRequest(recipientId)) + .to.be.revertedWith('Ownable: caller is not the owner') + }) + + it('should not allow to challenge resolved request', async () => { + await registry.connect(requester).addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + ) + await registry.challengeRequest(recipientId) + await expect(registry.challengeRequest(recipientId)) + .to.be.revertedWith('RecipientRegistry: Request does not exist') + }) + + it('allows anyone to execute unchallenged registration request', async () => { + await registry.connect(requester).addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + ) + await provider.send('evm_increaseTime', [86400]) + + const requesterBalanceBefore = await provider.getBalance(requester.address) + const requestExecuted = await registry.connect(requester).executeRequest(recipientId) + expect(requestExecuted) + .to.emit(registry, 'RecipientAdded') + .withArgs(recipientId, recipientAddress, metadata, recipientIndex) + const txFee = await getTxFee(requestExecuted) + const requesterBalanceAfter = await provider.getBalance(requester.address) + expect(requesterBalanceBefore.sub(txFee).add(baseDeposit)) + .to.equal(requesterBalanceAfter) + + const currentBlock = await getCurrentBlockNumber() + expect(await registry.getRecipientAddress( + recipientIndex, currentBlock, currentBlock, + )).to.equal(recipientAddress) + }) + + it('should not allow to execute request that does not exist', async () => { + await expect(registry.executeRequest(recipientId)) + .to.be.revertedWith('RecipientRegistry: Request does not exist') + }) + + it('should not allow to execute request during challenge period', async () => { + await registry.addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + ) + await expect(registry.executeRequest(recipientId)) + .to.be.revertedWith('RecipientRegistry: Challenge period is not over') + }) + + it('should remember initial deposit amount during registration', async () => { + await registry.connect(requester).addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + ) + await registry.setBaseDeposit(baseDeposit.mul(2)) + await provider.send('evm_increaseTime', [86400]) + + const requesterBalanceBefore = await provider.getBalance(requester.address) + const requestExecuted = await registry.connect(requester).executeRequest(recipientId) + const txFee = await getTxFee(requestExecuted) + const requesterBalanceAfter = await provider.getBalance(requester.address) + expect(requesterBalanceBefore.sub(txFee).add(baseDeposit)) + .to.equal(requesterBalanceAfter) + }) + + it('should limit the number of recipients', async () => { + let recipientName + for (let i = 0; i < MAX_RECIPIENTS + 1; i++) { + recipientName = String(i + 1).padStart(4, '0') + metadata = JSON.stringify({ name: recipientName, description: 'Description', imageHash: 'Ipfs imageHash' }) + recipientAddress = `0x000000000000000000000000000000000000${recipientName}` + recipientId = getRecipientId(recipientAddress, metadata) + if (i < MAX_RECIPIENTS) { + await registry.addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + ) + await provider.send('evm_increaseTime', [86400]) + await registry.executeRequest(recipientId) + } else { + await registry.addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + ) + await provider.send('evm_increaseTime', [86400]) + await expect(registry.executeRequest(recipientId)) + .to.be.revertedWith('RecipientRegistry: Recipient limit reached') + } + } + }) + + it('allows anyone to submit removal request', async () => { + await registry.addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + ) + await provider.send('evm_increaseTime', [86400]) + await registry.executeRequest(recipientId) + + await expect(registry.connect(requester).removeRecipient( + recipientId, { value: baseDeposit }, + )) + .to.emit(registry, 'RequestSubmitted') + .withArgs(recipientId, ZERO_ADDRESS, '') + expect(await provider.getBalance(registry.address)).to.equal(baseDeposit) + }) + + it('should not accept removal request if recipient is not in registry', async () => { + await expect(registry.removeRecipient( + recipientId, { value: baseDeposit }, + )) + .to.be.revertedWith('RecipientRegistry: Recipient is not in the registry') + }) + + it('should not accept removal request if recipient is already removed', async () => { + await registry.addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + ) + await provider.send('evm_increaseTime', [86400]) + await registry.executeRequest(recipientId) + + await registry.removeRecipient(recipientId, { value: baseDeposit }) + await provider.send('evm_increaseTime', [86400]) + await registry.connect(requester).executeRequest(recipientId) + + await expect(registry.removeRecipient( + recipientId, { value: baseDeposit }, + )) + .to.be.revertedWith('RecipientRegistry: Recipient already removed') + }) + + it('should not accept new removal request if previous removal request is not resolved', async () => { + await registry.addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + ) + await provider.send('evm_increaseTime', [86400]) + await registry.executeRequest(recipientId) + + await registry.removeRecipient(recipientId, { value: baseDeposit }) + await expect(registry.removeRecipient(recipientId, { value: baseDeposit })) + .to.be.revertedWith('RecipientRegistry: Request already submitted') + }) + + it('should not accept removal request with incorrect deposit size', async () => { + await registry.addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + ) + await provider.send('evm_increaseTime', [86400]) + await registry.executeRequest(recipientId) + + await expect(registry.removeRecipient(recipientId, { value: baseDeposit.div(2) })) + .to.be.revertedWith('RecipientRegistry: Incorrect deposit amount') + }) + + it('allows owner to challenge removal request', async () => { + await registry.addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + ) + await provider.send('evm_increaseTime', [86400]) + await registry.executeRequest(recipientId) + + await registry.removeRecipient(recipientId, { value: baseDeposit }) + const ownerBalanceBefore = await provider.getBalance(deployer.address) + const requestChallenged = await registry.challengeRequest(recipientId) + expect(requestChallenged) + .to.emit(registry, 'RequestRejected') + .withArgs(recipientId) + const txFee = await getTxFee(requestChallenged) + const ownerBalanceAfter = await provider.getBalance(deployer.address) + expect(ownerBalanceBefore.sub(txFee).add(baseDeposit)) + .to.equal(ownerBalanceAfter) + + // Recipient is not removed + const currentBlock = await getCurrentBlockNumber() + expect(await registry.getRecipientAddress( + recipientIndex, currentBlock, currentBlock, + )).to.equal(recipientAddress) + }) + + it('allows anyone to execute unchallenged removal request', async () => { + await registry.addRecipient( + recipientAddress, metadata, { value: baseDeposit }, + ) + await provider.send('evm_increaseTime', [86400]) + await registry.executeRequest(recipientId) + + await registry.connect(requester).removeRecipient(recipientId, { value: baseDeposit }) + await provider.send('evm_increaseTime', [86400]) + + const requesterBalanceBefore = await provider.getBalance(requester.address) + const requestExecuted = await registry.connect(requester).executeRequest(recipientId) + expect(requestExecuted) + .to.emit(registry, 'RecipientRemoved') + .withArgs(recipientId) + const txFee = await getTxFee(requestExecuted) + const requesterBalanceAfter = await provider.getBalance(requester.address) + expect(requesterBalanceBefore.sub(txFee).add(baseDeposit)) + .to.equal(requesterBalanceAfter) + + const currentBlock = await getCurrentBlockNumber() + expect(await registry.getRecipientAddress( + recipientIndex, currentBlock, currentBlock, + )).to.equal(ZERO_ADDRESS) + }) + }) +}) diff --git a/contracts/utils/contracts.ts b/contracts/utils/contracts.ts index 807264182..1ccbd4fe8 100644 --- a/contracts/utils/contracts.ts +++ b/contracts/utils/contracts.ts @@ -6,6 +6,11 @@ export async function getGasUsage(transaction: TransactionResponse): Promise { + const receipt = await transaction.wait() + return receipt.gasUsed.mul(transaction.gasPrice) +} + export async function getEventArg( transaction: TransactionResponse, contract: Contract, diff --git a/vue-app/.env.example b/vue-app/.env.example index e9879f56e..a8f64c97c 100644 --- a/vue-app/.env.example +++ b/vue-app/.env.example @@ -7,7 +7,7 @@ VUE_APP_CLRFUND_FACTORY_ADDRESS=0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 # Supported values: simple, brightid VUE_APP_USER_REGISTRY_TYPE=simple -# Supported values: simple, kleros +# Supported values: simple, optimistic, kleros VUE_APP_RECIPIENT_REGISTRY_TYPE=simple VUE_APP_EXTRA_ROUNDS= diff --git a/vue-app/src/api/core.ts b/vue-app/src/api/core.ts index 7f5591e0d..918dc606f 100644 --- a/vue-app/src/api/core.ts +++ b/vue-app/src/api/core.ts @@ -19,7 +19,7 @@ if (!['simple', 'brightid'].includes(userRegistryType as string)) { throw new Error('invalid user registry type') } export const recipientRegistryType = process.env.VUE_APP_RECIPIENT_REGISTRY_TYPE -if (!['simple', 'kleros'].includes(recipientRegistryType as string)) { +if (!['simple', 'optimistic', 'kleros'].includes(recipientRegistryType as string)) { throw new Error('invalid recipient registry type') } diff --git a/vue-app/src/api/projects.ts b/vue-app/src/api/projects.ts index 6c44f524c..9ecab459d 100644 --- a/vue-app/src/api/projects.ts +++ b/vue-app/src/api/projects.ts @@ -24,7 +24,7 @@ export async function getProjects( endBlock?: number, ): Promise { const registryAddress = await getRecipientRegistryAddress() - if (recipientRegistryType === 'simple') { + if (recipientRegistryType === 'simple' || recipientRegistryType === 'optimistic') { return await SimpleRegistry.getProjects(registryAddress, startBlock, endBlock) } else if (recipientRegistryType === 'kleros') { return await KlerosRegistry.getProjects(registryAddress, startBlock, endBlock) @@ -35,7 +35,7 @@ export async function getProjects( export async function getProject(id: string): Promise { const registryAddress = await getRecipientRegistryAddress() - if (recipientRegistryType === 'simple') { + if (recipientRegistryType === 'simple' || recipientRegistryType === 'optimistic') { return await SimpleRegistry.getProject(registryAddress, id) } else if (recipientRegistryType === 'kleros') { return await KlerosRegistry.getProject(registryAddress, id)