diff --git a/contracts/core/interfaces/IBaseStrategy.sol b/contracts/core/interfaces/IBaseStrategy.sol index fd2cbd7b3..74644125a 100644 --- a/contracts/core/interfaces/IBaseStrategy.sol +++ b/contracts/core/interfaces/IBaseStrategy.sol @@ -20,6 +20,9 @@ interface IBaseStrategy { /// @notice Error when the pool ID is invalid error BaseStrategy_INVALID_POOL_ID(); + /// @notice Error when the withdraw amount leaves the pool with insufficient funds + error BaseStrategy_WITHDRAW_MORE_THAN_POOL_AMOUNT(); + /// ====================== /// ======= Events ======= /// ====================== diff --git a/contracts/strategies/CoreBaseStrategy.sol b/contracts/strategies/CoreBaseStrategy.sol index e3d4557d3..7c7dbdbbc 100644 --- a/contracts/strategies/CoreBaseStrategy.sol +++ b/contracts/strategies/CoreBaseStrategy.sol @@ -108,6 +108,10 @@ abstract contract CoreBaseStrategy is IBaseStrategy { onlyPoolManager(msg.sender) { _beforeWithdraw(_token, _amount, _recipient); + // If the token is the pool token, revert if the amount is greater than the pool amount + if (_token.getBalance(address(this)) - _amount < poolAmount) { + revert BaseStrategy_WITHDRAW_MORE_THAN_POOL_AMOUNT(); + } _token.transferAmount(_recipient, _amount); _afterWithdraw(_token, _amount, _recipient); diff --git a/contracts/strategies/QVImpactStream.sol b/contracts/strategies/QVImpactStream.sol new file mode 100644 index 000000000..c070a80e5 --- /dev/null +++ b/contracts/strategies/QVImpactStream.sol @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.19; + +// External Libraries +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +// Interfaces +import {IAllo} from "../core/interfaces/IAllo.sol"; +// Core Contracts +import {QVSimple} from "./QVSimple.sol"; + +// ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣾⣿⣷⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣗⠀⠀⠀⢸⣿⣿⣿⡯⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +// ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣿⣿⣿⣿⣷⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⣿⣿⣿⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣗⠀⠀⠀⢸⣿⣿⣿⡯⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +// ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣸⣿⣿⣿⢿⣿⣿⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣗⠀⠀⠀⢸⣿⣿⣿⡯⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +// ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⣿⣿⣿⣿⣿⣿⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⣿⣿⣿⡟⠘⣿⣿⣿⣷⡀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣗⠀⠀⠀⢸⣿⣿⣿⡯⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +// ⠀⠀⠀⠀⠀⠀⠀⠀⣀⣴⣾⣿⣿⣿⣿⣾⠻⣿⣿⣿⣿⣿⣿⣿⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣿⣿⣿⡿⠀⠀⠸⣿⣿⣿⣧⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣗⠀⠀⠀⢸⣿⣿⣿⡯⠀⠀⠀⠀⠀⠀⢀⣠⣴⣴⣶⣶⣶⣦⣦⣀⡀⠀⠀⠀⠀⠀⠀ +// ⠀⠀⠀⠀⠀⠀⠀⣴⣿⣿⣿⣿⣿⣿⡿⠃⠀⠙⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣿⣿⣿⣿⠁⠀⠀⠀⢻⣿⣿⣿⣧⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣗⠀⠀⠀⢸⣿⣿⣿⡯⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⡀⠀⠀⠀⠀ +// ⠀⠀⠀⠀⠀⢀⣾⣿⣿⣿⣿⣿⣿⡿⠁⠀⠀⠀⠘⣿⣿⣿⣿⣿⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣾⣿⣿⣿⠃⠀⠀⠀⠀⠈⢿⣿⣿⣿⣆⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣗⠀⠀⠀⢸⣿⣿⣿⡯⠀⠀⠀⣰⣿⣿⣿⡿⠋⠁⠀⠀⠈⠘⠹⣿⣿⣿⣿⣆⠀⠀⠀ +// ⠀⠀⠀⠀⢀⣾⣿⣿⣿⣿⣿⣿⡿⠀⠀⠀⠀⠀⠀⠈⢿⣿⣿⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⠏⠀⠀⠀⠀⠀⠀⠘⣿⣿⣿⣿⡄⠀⠀⠀⠀⠀⢸⣿⣿⣿⣗⠀⠀⠀⢸⣿⣿⣿⡯⠀⠀⢰⣿⣿⣿⣿⠁⠀⠀⠀⠀⠀⠀⠀⠘⣿⣿⣿⣿⡀⠀⠀ +// ⠀⠀⠀⢠⣿⣿⣿⣿⣿⣿⣿⣟⠀⡀⢀⠀⡀⢀⠀⡀⢈⢿⡟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⠀⠀⠀⠀⢸⣿⣿⣿⣗⠀⠀⠀⢸⣿⣿⣿⡯⠀⠀⢸⣿⣿⣿⣗⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⡇⠀⠀ +// ⠀⠀⣠⣿⣿⣿⣿⣿⣿⡿⠋⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣸⣿⣿⣿⡿⢿⠿⠿⠿⠿⠿⠿⠿⠿⠿⢿⣿⣿⣿⣷⡀⠀⠀⠀⢸⣿⣿⣿⣗⠀⠀⠀⢸⣿⣿⣿⡯⠀⠀⠸⣿⣿⣿⣷⡀⠀⠀⠀⠀⠀⠀⠀⢠⣿⣿⣿⣿⠂⠀⠀ +// ⠀⠀⠙⠛⠿⠻⠻⠛⠉⠀⠀⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⣿⣿⣿⣿⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢿⣿⣿⣿⣧⠀⠀⠀⢸⣿⣿⣿⣗⠀⠀⠀⢸⣿⣿⣿⡯⠀⠀⠀⢻⣿⣿⣿⣷⣀⢀⠀⠀⠀⡀⣰⣾⣿⣿⣿⠏⠀⠀⠀ +// ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠛⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⣿⣿⣿⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⣿⣿⣿⣧⠀⠀⢸⣿⣿⣿⣗⠀⠀⠀⢸⣿⣿⣿⡯⠀⠀⠀⠀⠹⢿⣿⣿⣿⣿⣾⣾⣷⣿⣿⣿⣿⡿⠋⠀⠀⠀⠀ +// ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠙⠋⠛⠙⠋⠛⠙⠋⠛⠙⠋⠃⠀⠀⠀⠀⠀⠀⠀⠀⠠⠿⠻⠟⠿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⠟⠿⠟⠿⠆⠀⠸⠿⠿⠟⠯⠀⠀⠀⠸⠿⠿⠿⠏⠀⠀⠀⠀⠀⠈⠉⠻⠻⡿⣿⢿⡿⡿⠿⠛⠁⠀⠀⠀⠀⠀⠀ +// allo.gitcoin.co +contract QVImpactStream is QVSimple { + /// ====================== + /// ======= Events ======= + /// ====================== + + /// @notice Emitted when the payouts are set + /// @param payouts The payouts to distribute + /// @param sender The sender of the transaction + event PayoutSet(Payout[] payouts, address sender); + + /// ====================== + /// ======= Errors ======= + /// ====================== + + /// @notice Thrown when payout is already set + error PAYOUT_ALREADY_SET(); + + /// @notice Thrown when the total set payout is more than the pool balance + error PAYOUT_MORE_THAN_POOL_BALANCE(); + + /// ====================== + /// ======= Storage ====== + /// ====================== + + /// @notice Returns the amount to pay to the recipient + /// @dev recipientId => payouts + mapping(address => uint256) public payouts; + + /// @notice Returns true if the payout is set + bool public payoutSet; + + /// ====================== + /// ======= Struct ======= + /// ====================== + + /// @notice The details of the payout set by the pool managers + struct Payout { + address recipientId; + uint256 amount; + } + + /// ==================================== + /// ========== Constructor ============= + /// ==================================== + + /// @notice Constructor for the QV Impact Stream strategy + /// @param _allo The 'Allo' contract + constructor(address _allo) QVSimple(_allo) {} + + /// ==================================== + /// ==== External/Public Functions ===== + /// ==================================== + + /// @notice Add allocator array + /// @dev Only the pool manager(s) can call this function and emits an `AllocatorAdded` event + /// @param _allocators The allocator address array + function batchAddAllocator(address[] memory _allocators) external onlyPoolManager(msg.sender) { + uint256 length = _allocators.length; + for (uint256 i = 0; i < length;) { + _addAllocator(_allocators[i]); + + unchecked { + ++i; + } + } + } + + /// @notice Remove allocator array + /// @dev Only the pool manager(s) can call this function and emits an `AllocatorRemoved` event + /// @param _allocators The allocators address array + function batchRemoveAllocator(address[] memory _allocators) external onlyPoolManager(msg.sender) { + uint256 length = _allocators.length; + for (uint256 i = 0; i < length;) { + _removeAllocator(_allocators[i]); + + unchecked { + ++i; + } + } + } + + /// @notice Set the payouts to distribute + /// @dev Only the pool manager(s) can call this function + /// @param _payouts The payouts to distribute + function setPayouts(Payout[] memory _payouts) external onlyPoolManager(msg.sender) onlyAfterAllocation { + if (payoutSet) revert PAYOUT_ALREADY_SET(); + payoutSet = true; + + uint256 totalAmount; + + uint256 length = _payouts.length; + for (uint256 i = 0; i < length;) { + Payout memory payout = _payouts[i]; + uint256 amount = payout.amount; + address recipientId = payout.recipientId; + + if (amount == 0 || _getRecipientStatus(recipientId) != Status.Accepted) { + revert RECIPIENT_ERROR(recipientId); + } + + payouts[recipientId] = amount; + totalAmount += amount; + unchecked { + ++i; + } + } + + if (totalAmount > poolAmount) revert PAYOUT_MORE_THAN_POOL_BALANCE(); + + emit PayoutSet(_payouts, msg.sender); + } + + /// ============================= + /// ==== Internal Functions ===== + /// ============================= + + function _distribute(address[] memory _recipientIds, bytes memory, address _sender) + internal + virtual + override + onlyAfterAllocation + { + IAllo.Pool memory pool = allo.getPool(poolId); + address poolToken = pool.token; + + uint256 length = _recipientIds.length; + for (uint256 i = 0; i < length;) { + address recipientId = _recipientIds[i]; + Recipient storage recipient = _recipients[recipientId]; + + address recipientAddress = recipient.recipientAddress; + uint256 amount = payouts[recipientId]; + + if (amount == 0) revert RECIPIENT_ERROR(recipientId); + + delete payouts[recipientId]; + + _transferAmount(poolToken, recipientAddress, amount); + + bytes memory data = abi.encode(recipientAddress, amount, _sender); + + emit Distributed(recipientId, data); + + unchecked { + ++i; + } + } + } + + /// ========================= + /// ==== View Functions ===== + /// ========================= + + /// @notice Get the total votes received for a recipient + /// @param _recipient The address of the recipient + /// @return The total votes received by the recipient + function getTotalVotesForRecipient(address _recipient) external view returns (uint256) { + return _votingState.recipientVotes[_recipient]; + } + + /// @notice Get the payout for a single recipient + /// @param _recipientId The ID of the recipient + /// @return The payout as a 'Payout' struct + function getPayout(address _recipientId) external view returns (Payout memory) { + uint256 amount = payouts[_recipientId]; + return Payout(_recipientId, amount); + } +} diff --git a/contracts/strategies/QVSimple.sol b/contracts/strategies/QVSimple.sol index 856d73e16..46150ecfd 100644 --- a/contracts/strategies/QVSimple.sol +++ b/contracts/strategies/QVSimple.sol @@ -71,7 +71,7 @@ contract QVSimple is CoreBaseStrategy, RecipientsExtension { /// @notice Initialize the strategy /// @param _poolId The pool id /// @param _data The data to initialize the strategy (Must include RecipientInitializeData and QVSimpleInitializeData) - function initialize(uint256 _poolId, bytes memory _data) external override { + function initialize(uint256 _poolId, bytes memory _data) external virtual override { __BaseStrategy_init(_poolId); ( @@ -132,18 +132,14 @@ contract QVSimple is CoreBaseStrategy, RecipientsExtension { /// @dev Only the pool manager(s) can call this function and emits an `AllocatorAdded` event /// @param _allocator The allocator address function addAllocator(address _allocator) external onlyPoolManager(msg.sender) { - allowedAllocators[_allocator] = true; - - emit AllocatorAdded(_allocator, msg.sender); + _addAllocator(_allocator); } /// @notice Remove allocator /// @dev Only the pool manager(s) can call this function and emits an `AllocatorRemoved` event /// @param _allocator The allocator address function removeAllocator(address _allocator) external onlyPoolManager(msg.sender) { - allowedAllocators[_allocator] = false; - - emit AllocatorRemoved(_allocator, msg.sender); + _removeAllocator(_allocator); } /// ==================================== @@ -231,6 +227,24 @@ contract QVSimple is CoreBaseStrategy, RecipientsExtension { voiceCreditsAllocated[_sender] += voiceCreditsToAllocate; } + /// @notice Add allocator + /// @dev Only the pool manager(s) can call this function and emits an `AllocatorAdded` event + /// @param _allocator The allocator address + function _addAllocator(address _allocator) internal virtual { + allowedAllocators[_allocator] = true; + + emit AllocatorAdded(_allocator, msg.sender); + } + + /// @notice Remove allocator + /// @dev Only the pool manager(s) can call this function and emits an `AllocatorRemoved` event + /// @param _allocator The allocator address + function _removeAllocator(address _allocator) internal virtual { + allowedAllocators[_allocator] = false; + + emit AllocatorRemoved(_allocator, msg.sender); + } + /// @notice Returns if the recipient is accepted /// @param _recipientId The recipient id /// @return true if the recipient is accepted diff --git a/test/foundry/integration/QVImpactStream.t.sol b/test/foundry/integration/QVImpactStream.t.sol new file mode 100644 index 000000000..c95af9311 --- /dev/null +++ b/test/foundry/integration/QVImpactStream.t.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.19; + +import {Test} from "forge-std/Test.sol"; +import {Allo} from "contracts/core/Allo.sol"; +import {Registry, Metadata} from "contracts/core/Registry.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {QVImpactStream} from "contracts/strategies/QVImpactStream.sol"; +import {QVSimple} from "contracts/strategies/QVSimple.sol"; +import {IRecipientsExtension} from "contracts/extensions/interfaces/IRecipientsExtension.sol"; + +contract IntegrationQVImpactStream is Test { + Allo public allo; + Registry public registry; + QVImpactStream public strategy; + + address public owner; + address public treasury; + address public profileOwner; + address public recipient0; + address public recipient1; + address public recipient2; + address public allocator0; + address public allocator1; + + bytes32 public profileId; + + uint256 public poolId; + + address public constant dai = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + + function setUp() public { + vm.createSelectFork(vm.rpcUrl("mainnet"), 20289932); + + owner = makeAddr("owner"); + treasury = makeAddr("treasury"); + profileOwner = makeAddr("profileOwner"); + recipient0 = makeAddr("recipient0"); + recipient1 = makeAddr("recipient1"); + recipient2 = makeAddr("recipient2"); + allocator0 = makeAddr("allocator0"); + allocator1 = makeAddr("allocator1"); + + // Deploying contracts + allo = new Allo(); + registry = new Registry(); + strategy = new QVImpactStream(address(allo)); + + // Initialize contracts + allo.initialize(owner, address(registry), payable(treasury), 0, 0, address(1)); // NOTE: trusted forwarder is not used + registry.initialize(owner); + + // Creating profile + vm.prank(profileOwner); + profileId = registry.createProfile( + 0, "Test Profile", Metadata({protocol: 0, pointer: ""}), profileOwner, new address[](0) + ); + + // Deal + deal(dai, profileOwner, 100000 ether); + vm.prank(profileOwner); + IERC20(dai).approve(address(allo), 100000 ether); + + // Creating pool (and deploying strategy) + address[] memory managers = new address[](1); + managers[0] = profileOwner; + vm.prank(profileOwner); + poolId = allo.createPoolWithCustomStrategy( + profileId, + address(strategy), + abi.encode( + IRecipientsExtension.RecipientInitializeData({ + metadataRequired: false, + registrationStartTime: uint64(block.timestamp), + registrationEndTime: uint64(block.timestamp + 7 days) + }), + QVSimple.QVSimpleInitializeData({ + allocationStartTime: uint64(block.timestamp), + allocationEndTime: uint64(block.timestamp + 7 days), + maxVoiceCreditsPerAllocator: 100 + }) + ), + dai, + 100000 ether, + Metadata({protocol: 0, pointer: ""}), + managers + ); + + // Adding allocators + vm.startPrank(profileOwner); + strategy.addAllocator(allocator0); + strategy.addAllocator(allocator1); + vm.stopPrank(); + + // Adding recipients + vm.startPrank(address(allo)); + + address[] memory recipients = new address[](1); + bytes[] memory data = new bytes[](1); + + recipients[0] = recipient0; + data[0] = abi.encode(address(0), Metadata({protocol: 0, pointer: ""})); + strategy.register(recipients, abi.encode(data), recipient0); + + recipients[0] = recipient1; + data[0] = abi.encode(address(0), Metadata({protocol: 0, pointer: ""})); + strategy.register(recipients, abi.encode(data), recipient1); + + recipients[0] = recipient2; + data[0] = abi.encode(address(0), Metadata({protocol: 0, pointer: ""})); + strategy.register(recipients, abi.encode(data), recipient2); + + vm.stopPrank(); + + // Review recipients (Mark them as accepted) + vm.startPrank(profileOwner); + + // TODO: make them in batch + IRecipientsExtension.ApplicationStatus[] memory statuses = new IRecipientsExtension.ApplicationStatus[](1); + statuses[0] = _getApplicationStatus(recipient0, 2); + strategy.reviewRecipients(statuses, strategy.recipientsCounter()); + + statuses[0] = _getApplicationStatus(recipient1, 2); + strategy.reviewRecipients(statuses, strategy.recipientsCounter()); + + statuses[0] = _getApplicationStatus(recipient2, 2); + strategy.reviewRecipients(statuses, strategy.recipientsCounter()); + + vm.stopPrank(); + } + + function _getApplicationStatus(address _recipientId, uint256 _status) + internal + view + returns (IRecipientsExtension.ApplicationStatus memory) + { + IRecipientsExtension.Recipient memory recipient = strategy.getRecipient(_recipientId); + uint256 recipientIndex = recipient.statusIndex - 1; + + uint256 rowIndex = recipientIndex / 64; + uint256 colIndex = (recipientIndex % 64) * 4; + uint256 currentRow = strategy.statusesBitMap(rowIndex); + uint256 newRow = currentRow & ~(15 << colIndex); + uint256 statusRow = newRow | (_status << colIndex); + + return IRecipientsExtension.ApplicationStatus({index: rowIndex, statusRow: statusRow}); + } + + function test_AllocateSetPayoutsDistributeFlow() public { + address[] memory recipients = new address[](3); + recipients[0] = recipient0; + recipients[1] = recipient1; + recipients[2] = recipient2; + + // Allocator 0 + uint256[] memory amounts0 = new uint256[](3); + amounts0[0] = 10; + amounts0[1] = 20; + amounts0[2] = 30; + + vm.prank(address(allo)); + strategy.allocate(recipients, amounts0, "", allocator0); + assertEq(strategy.voiceCreditsAllocated(allocator0), 60); + + // Allocator 1 + uint256[] memory amounts1 = new uint256[](3); + amounts1[0] = 50; + amounts1[1] = 20; + amounts1[2] = 10; + + vm.prank(address(allo)); + strategy.allocate(recipients, amounts1, "", allocator1); + assertEq(strategy.voiceCreditsAllocated(allocator1), 80); + + QVImpactStream.Payout[] memory payouts = new QVImpactStream.Payout[](3); + payouts[0] = QVImpactStream.Payout({recipientId: recipient0, amount: 10}); + payouts[1] = QVImpactStream.Payout({recipientId: recipient1, amount: 20}); + payouts[2] = QVImpactStream.Payout({recipientId: recipient2, amount: 30}); + + vm.warp(block.timestamp + 8 days); + vm.prank(profileOwner); + strategy.setPayouts(payouts); + + assertEq(strategy.getPayout(recipient0).amount, 10); + assertEq(strategy.getPayout(recipient1).amount, 20); + assertEq(strategy.getPayout(recipient2).amount, 30); + + vm.prank(address(allo)); + strategy.distribute(recipients, "", profileOwner); + + assertEq(IERC20(dai).balanceOf(recipient0), 10); + assertEq(IERC20(dai).balanceOf(recipient1), 20); + assertEq(IERC20(dai).balanceOf(recipient2), 30); + } +} diff --git a/test/foundry/strategies/CoreBaseStrategy.t.sol b/test/foundry/strategies/CoreBaseStrategy.t.sol index e719e47e9..a0f360601 100644 --- a/test/foundry/strategies/CoreBaseStrategy.t.sol +++ b/test/foundry/strategies/CoreBaseStrategy.t.sol @@ -1,6 +1,7 @@ pragma solidity 0.8.19; import "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; // Test libraries import {AlloSetup} from "../shared/AlloSetup.sol"; @@ -43,4 +44,33 @@ contract CoreBaseStrategyTest is Test, AlloSetup { strategy.increasePoolAmount(100); assertEq(strategy.getPoolAmount(), 100); } + + function test_withdraw() public { + vm.mockCall(address(allo()), abi.encodeWithSelector(IAllo.isPoolManager.selector), abi.encode(true)); + + /// Increase pool amount + vm.prank(address(allo())); + strategy.increasePoolAmount(100); + + address _token = allo().getPool(0).token; + + vm.mockCall(_token, abi.encodeWithSelector(IERC20.balanceOf.selector, address(strategy)), abi.encode(150)); + strategy.withdraw(_token, 50, address(this)); + + assertEq(strategy.getPoolAmount(), 100); + } + + function testRevert_withdrawMoreThanPoolAmount() public { + vm.mockCall(address(allo()), abi.encodeWithSelector(IAllo.isPoolManager.selector), abi.encode(true)); + + /// Increase pool amount + vm.prank(address(allo())); + strategy.increasePoolAmount(100); + + address _token = allo().getPool(0).token; + + vm.mockCall(_token, abi.encodeWithSelector(IERC20.balanceOf.selector, address(strategy)), abi.encode(100)); + vm.expectRevert(IBaseStrategy.BaseStrategy_WITHDRAW_MORE_THAN_POOL_AMOUNT.selector); + strategy.withdraw(_token, 50, address(this)); + } } diff --git a/test/foundry/strategies/QVImpactStream.t.sol b/test/foundry/strategies/QVImpactStream.t.sol new file mode 100644 index 000000000..718cdd9c6 --- /dev/null +++ b/test/foundry/strategies/QVImpactStream.t.sol @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.19; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {StdStorage, Test, stdStorage} from "forge-std/Test.sol"; +import {IAllo} from "../../../contracts/core/interfaces/IAllo.sol"; +import {IStrategy} from "../../../contracts/core/interfaces/IStrategy.sol"; +import {IRecipientsExtension} from "../../../contracts/extensions/interfaces/IRecipientsExtension.sol"; +import {QVSimple} from "../../../contracts/strategies/QVSimple.sol"; +import {QVImpactStream} from "../../../contracts/strategies/QVImpactStream.sol"; +import {IRecipientsExtension} from "../../../contracts/extensions/interfaces/IRecipientsExtension.sol"; +import {Errors} from "../../../contracts/core/libraries/Errors.sol"; +import {Metadata} from "../../../contracts/core/libraries/Metadata.sol"; + +contract QVImpactStreamTest is Test { + using stdStorage for StdStorage; + + event PayoutSet(QVImpactStream.Payout[] payouts, address sender); + event AllocatorAdded(address indexed allocator, address sender); + event AllocatorRemoved(address indexed allocator, address sender); + event Distributed(address indexed _recipient, bytes _data); + + QVImpactStream qvImpactStream; + + address mockAlloAddress; + address poolManager; + address recipient1; + address recipient2; + + uint256 allocationWindow; + + QVImpactStream.Payout[] payouts; + + function setUp() external { + /// create a mock users + mockAlloAddress = makeAddr("allo"); + poolManager = makeAddr("poolManager"); + recipient1 = makeAddr("recipient1"); + recipient2 = makeAddr("recipient2"); + + allocationWindow = 7 days; + + /// deploy the strategy + qvImpactStream = new QVImpactStream(mockAlloAddress); + + IRecipientsExtension.RecipientInitializeData memory recipientInitData = IRecipientsExtension + .RecipientInitializeData({ + metadataRequired: false, + registrationStartTime: uint64(block.timestamp), + registrationEndTime: uint64(block.timestamp + allocationWindow) + }); + + QVSimple.QVSimpleInitializeData memory qvInitData = QVSimple.QVSimpleInitializeData({ + allocationStartTime: uint64(block.timestamp), + allocationEndTime: uint64(block.timestamp + allocationWindow), + maxVoiceCreditsPerAllocator: 100 + }); + /// initialize + vm.prank(mockAlloAddress); + qvImpactStream.initialize(1, abi.encode(recipientInitData, qvInitData)); + + /// create mock payouts array + payouts.push(QVImpactStream.Payout({recipientId: recipient1, amount: 60})); + payouts.push(QVImpactStream.Payout({recipientId: recipient2, amount: 40})); + } + + modifier callWithPoolManager() { + vm.mockCall( + mockAlloAddress, abi.encodeWithSelector(IAllo.isPoolManager.selector, 1, poolManager), abi.encode(true) + ); + _; + } + + function test_BatchAddAllocatorWhenCalledByPoolManager() external callWithPoolManager { + // if AllocatorAdded event emits with the correct parameters then _addAllocator was also called + // with the correct parameters + vm.expectEmit(true, true, true, true); + emit AllocatorAdded(recipient1, poolManager); + + address[] memory _recipients = new address[](1); + _recipients[0] = recipient1; + + vm.prank(poolManager); + qvImpactStream.batchAddAllocator(_recipients); + + assertTrue(qvImpactStream.allowedAllocators(recipient1)); + } + + function test_BatchRemoveAllocatorWhenCalledByPoolManager() external callWithPoolManager { + // if AllocatorRemoved event emits with the correct parameters then _removeAllocator was also called + // with the correct parameters + vm.expectEmit(true, true, true, true); + emit AllocatorRemoved(recipient1, poolManager); + + address[] memory _recipients = new address[](1); + _recipients[0] = recipient1; + + vm.prank(poolManager); + qvImpactStream.batchRemoveAllocator(_recipients); + + assertFalse(qvImpactStream.allowedAllocators(recipient1)); + } + + function test_SetPayoutsRevertWhen_PayoutSetIsTrue() external callWithPoolManager { + stdstore.target(address(qvImpactStream)).sig("payoutSet()").checked_write(true); + vm.expectRevert(QVImpactStream.PAYOUT_ALREADY_SET.selector); + + /// make it after allocation finished + vm.warp(block.timestamp + allocationWindow + 1 days); + + vm.prank(poolManager); + qvImpactStream.setPayouts(payouts); + } + + function test_SetPayoutsRevertWhen_PayoutAmountIsZero() external callWithPoolManager { + vm.expectRevert(abi.encodeWithSelector(Errors.RECIPIENT_ERROR.selector, recipient1)); + payouts.push(QVImpactStream.Payout({recipientId: recipient1, amount: 0})); + + /// make it after allocation finished + vm.warp(block.timestamp + allocationWindow + 1 days); + + vm.prank(poolManager); + qvImpactStream.setPayouts(payouts); + } + + function test_SetPayoutsRevertWhen_RecipientStatusIsNotAccepted() external callWithPoolManager { + vm.expectRevert(abi.encodeWithSelector(Errors.RECIPIENT_ERROR.selector, recipient1)); + + /// make it after allocation finished + vm.warp(block.timestamp + allocationWindow + 1 days); + + /// since the recipient is not registered, his status should be NONE + /// so setting payouts should fail + vm.prank(poolManager); + qvImpactStream.setPayouts(payouts); + } + + function test_SetPayoutsRevertWhen_TotalPayoutIsGreaterThanPoolAmount() external { + // it should revert + vm.skip(true); + } + + function test_SetPayoutsWhenCalledWithValidParameters() external { + // it should set the payouts + vm.skip(true); + } + + function test__distributeRevertWhen_PayoutAmountForRecipientIsZero() external callWithPoolManager { + IAllo.Pool memory poolData = IAllo.Pool({ + profileId: keccak256(abi.encodePacked(recipient1)), + strategy: IStrategy(address(qvImpactStream)), + token: address(0), + metadata: Metadata({protocol: 0, pointer: ""}), + managerRole: keccak256("MANAGER_ROLE"), + adminRole: keccak256("ADMIN_ROLE") + }); + vm.mockCall(mockAlloAddress, abi.encodeWithSelector(IAllo.getPool.selector, 1), abi.encode(poolData)); + + /// make it after allocation finished + vm.warp(block.timestamp + allocationWindow + 1 days); + + /// it should revert + vm.expectRevert(abi.encodeWithSelector(Errors.RECIPIENT_ERROR.selector, recipient1)); + + address[] memory _recipients = new address[](1); + _recipients[0] = recipient1; + + vm.prank(mockAlloAddress); + qvImpactStream.distribute(_recipients, new bytes(0), poolManager); + } + + function test__distributeWhenCalled() external callWithPoolManager { + stdstore.target(address(qvImpactStream)).sig("payouts(address)").with_key(address(0)).checked_write(100); + + IAllo.Pool memory poolData = IAllo.Pool({ + profileId: keccak256(abi.encodePacked(recipient1)), + strategy: IStrategy(address(qvImpactStream)), + token: address(0), + metadata: Metadata({protocol: 0, pointer: ""}), + managerRole: keccak256("MANAGER_ROLE"), + adminRole: keccak256("ADMIN_ROLE") + }); + vm.mockCall(mockAlloAddress, abi.encodeWithSelector(IAllo.getPool.selector, 1), abi.encode(poolData)); + + // it should emit event + vm.expectEmit(true, true, true, true); + emit Distributed(address(0), abi.encode(address(0), 100, poolManager)); + + address[] memory _recipients = new address[](1); + /// normally it would be recipient1 instead of address(0) but we cant mock the _recipients mapping + _recipients[0] = address(0); + + /// make it after allocation finished + vm.warp(block.timestamp + allocationWindow + 1 days); + + vm.prank(mockAlloAddress); + qvImpactStream.distribute(_recipients, new bytes(0), poolManager); + } + + function test_GetTotalVotesForRecipientWhenCalled() external { + // it should return the total votes for the recipient + vm.skip(true); + } + + function test_GetPayoutWhenCalled() external { + stdstore.target(address(qvImpactStream)).sig("payouts(address)").with_key(recipient1).checked_write(100); + + QVImpactStream.Payout memory payout = qvImpactStream.getPayout(recipient1); + assertEq(payout.amount, 100); + assertEq(payout.recipientId, recipient1); + } +} diff --git a/test/foundry/strategies/QVImpactStream.tree b/test/foundry/strategies/QVImpactStream.tree new file mode 100644 index 000000000..cfa32cc02 --- /dev/null +++ b/test/foundry/strategies/QVImpactStream.tree @@ -0,0 +1,37 @@ +QVImpactStream::batchAddAllocator +└── when called by PoolManager + └── it should call _addAllocator + +QVImpactStream::batchRemoveAllocator +└── when called by PoolManager + └── it should call _removeAllocator + + +QVImpactStream::setPayouts +├── when payoutSet is true +│ └── it should revert +├── when payout amount is zero +│ └── it should revert +├── when recipient status is not accepted +│ └── it should revert +├── when total payout is greater than pool amount +│ └── it should revert +└── when called with valid parameters + ├── it should set the payouts + └── it should emit event + +QVImpactStream::_distribute +├── when payout amount for recipient is zero +│ └── it should revert +└── when called + ├── it should remove the recipient from the payouts + ├── it should transfer to the recipient + └── it should emit event + +QVImpactStream::getTotalVotesForRecipient +└── when called + └── it should return the total votes for the recipient + +QVImpactStream::getPayout +└── when called + └── it should return the payout for the recipient