diff --git a/packages/tasks/contracts/interfaces/primitives/IDepositor.sol b/packages/tasks/contracts/interfaces/primitives/IDepositor.sol new file mode 100644 index 00000000..776d49db --- /dev/null +++ b/packages/tasks/contracts/interfaces/primitives/IDepositor.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +pragma solidity >=0.8.0; + +import '../ITask.sol'; + +/** + * @dev Depositor task interface + */ +interface IDepositor is ITask { + /** + * @dev The token is zero + */ + error TaskTokenZero(); + + /** + * @dev The amount is zero + */ + error TaskAmountZero(); + + /** + * @dev The msg value is zero + */ + error TaskValueZero(); + + /** + * @dev The previous balance connector is not zero + */ + error TaskPreviousConnectorNotZero(bytes32 id); + + /** + * @dev The tokens source to be set is not the contract itself + */ + error TaskDepositorBadTokensSource(address tokensSource); + + /** + * @dev Emitted every time the tokens source is set + */ + event TokensSourceSet(address indexed tokensSource); + + /** + * @dev Sets the tokens source address + * @param tokensSource Address of the tokens source to be set + */ + function setTokensSource(address tokensSource) external; + + /** + * @dev Executes the withdrawer task + */ + function call(address token, uint256 amount) external; +} diff --git a/packages/tasks/contracts/primitives/Depositor.sol b/packages/tasks/contracts/primitives/Depositor.sol index cfc4613f..303cbfdc 100644 --- a/packages/tasks/contracts/primitives/Depositor.sol +++ b/packages/tasks/contracts/primitives/Depositor.sol @@ -14,45 +14,139 @@ pragma solidity ^0.8.0; +import '@openzeppelin/contracts/utils/Address.sol'; + import '@mimic-fi/v3-helpers/contracts/utils/ERC20Helpers.sol'; import '@mimic-fi/v3-helpers/contracts/utils/Denominations.sol'; -import './Collector.sol'; -import '../interfaces/primitives/ICollector.sol'; +import '../Task.sol'; +import '../interfaces/primitives/IDepositor.sol'; /** * @title Depositor - * @dev Task that extends the Collector task to be the source from where funds can be pulled + * @dev Task that can be used as the origin to start any workflow */ -contract Depositor is ICollector, Collector { +contract Depositor is IDepositor, Task { + // Execution type for relayers + bytes32 public constant override EXECUTION_TYPE = keccak256('DEPOSITOR'); + + // Address from where the tokens will be pulled + address internal _tokensSource; + + /** + * @dev Deposit config. Only used in the initializer. + */ + struct DepositConfig { + address tokensSource; + TaskConfig taskConfig; + } + + /** + * @dev Initializes the depositor + * @param config Deposit config + */ + function initialize(DepositConfig memory config) external virtual initializer { + __Depositor_init(config); + } + /** - * @dev The tokens source to be set is not the contract itself + * @dev Initializes the depositor. It does call upper contracts initializers. + * @param config Deposit config */ - error TaskDepositorBadTokensSource(address tokensSource); + function __Depositor_init(DepositConfig memory config) internal onlyInitializing { + __Task_init(config.taskConfig); + __Depositor_init_unchained(config); + } + + /** + * @dev Initializes the depositor. It does not call upper contracts initializers. + * @param config Deposit config + */ + function __Depositor_init_unchained(DepositConfig memory config) internal onlyInitializing { + _setTokensSource(config.tokensSource); + } + + /** + * @dev Tells the address from where the token amounts to execute this task are fetched + */ + function getTokensSource() public view virtual override(IBaseTask, BaseTask) returns (address) { + return _tokensSource; + } + + /** + * @dev Tells the balance of the depositor for a given token + * @param token Address of the token being queried + */ + function getTaskAmount(address token) public view virtual override(IBaseTask, BaseTask) returns (uint256) { + return ERC20Helpers.balanceOf(token, getTokensSource()); + } + + /** + * @dev Sets the tokens source address. Sender must be authorized. + * @param tokensSource Address of the tokens source to be set + */ + function setTokensSource(address tokensSource) external override authP(authParams(tokensSource)) { + _setTokensSource(tokensSource); + } /** * @dev It allows receiving native token transfers */ receive() external payable { - // solhint-disable-previous-line no-empty-blocks + if (msg.value == 0) revert TaskValueZero(); } /** - * @dev Approves the requested amount of tokens to the smart vault in case it's not the native token + * @dev Execute Depositor */ - function _beforeCollector(address token, uint256 amount) internal virtual override { - super._beforeCollector(token, amount); - if (!Denominations.isNativeToken(token)) { + function call(address token, uint256 amount) external override authP(authParams(token, amount)) { + if (amount == 0) amount = getTaskAmount(token); + _beforeDepositor(token, amount); + + if (Denominations.isNativeToken(token)) { + Address.sendValue(payable(smartVault), amount); + } else { ERC20Helpers.approve(token, smartVault, amount); + ISmartVault(smartVault).collect(token, _tokensSource, amount); } + + _afterDepositor(token, amount); + } + + /** + * @dev Before depositor hook + */ + function _beforeDepositor(address token, uint256 amount) internal virtual { + _beforeTask(token, amount); + if (token == address(0)) revert TaskTokenZero(); + if (amount == 0) revert TaskAmountZero(); + } + + /** + * @dev After depositor hook + */ + function _afterDepositor(address token, uint256 amount) internal virtual { + _increaseBalanceConnector(token, amount); + _afterTask(token, amount); + } + + /** + * @dev Sets the balance connectors. Previous balance connector must be unset. + * @param previous Balance connector id of the previous task in the workflow + * @param next Balance connector id of the next task in the workflow + */ + function _setBalanceConnectors(bytes32 previous, bytes32 next) internal virtual override { + if (previous != bytes32(0)) revert TaskPreviousConnectorNotZero(previous); + super._setBalanceConnectors(previous, next); } /** - * @dev Sets the tokens source address + * @dev Sets the source address * @param tokensSource Address of the tokens source to be set */ - function _setTokensSource(address tokensSource) internal override { + function _setTokensSource(address tokensSource) internal virtual { if (tokensSource != address(this)) revert TaskDepositorBadTokensSource(tokensSource); - super._setTokensSource(tokensSource); + _tokensSource = tokensSource; + emit TokensSourceSet(tokensSource); } } diff --git a/packages/tasks/test/primitives/Depositor.test.ts b/packages/tasks/test/primitives/Depositor.test.ts index 4579f2f5..f5b63770 100644 --- a/packages/tasks/test/primitives/Depositor.test.ts +++ b/packages/tasks/test/primitives/Depositor.test.ts @@ -2,6 +2,7 @@ import { assertEvent, assertIndirectEvent, assertNoEvent, + assertNoIndirectEvent, BigNumberish, deployProxy, deployTokenMock, @@ -42,11 +43,31 @@ describe('Depositor', () => { describe('execution type', () => { it('defines it correctly', async () => { - const expectedType = ethers.utils.solidityKeccak256(['string'], ['COLLECTOR']) + const expectedType = ethers.utils.solidityKeccak256(['string'], ['DEPOSITOR']) expect(await task.EXECUTION_TYPE()).to.be.equal(expectedType) }) }) + describe('receive', () => { + context('when sending some value', () => { + const value = 1 + + it('accepts native tokens', async () => { + await owner.sendTransaction({ to: task.address, value }) + + expect(await ethers.provider.getBalance(task.address)).to.be.equal(1) + }) + }) + + context('when sending no value', () => { + const value = 0 + + it('reverts', async () => { + await expect(owner.sendTransaction({ to: task.address, value })).to.be.revertedWith('TaskValueZero') + }) + }) + }) + describe('setTokensSource', () => { context('when the sender is authorized', async () => { beforeEach('set sender', async () => { @@ -88,37 +109,11 @@ describe('Depositor', () => { }) describe('call', () => { - let token: Contract - - const threshold = fp(2) - - beforeEach('set token', async () => { - token = await deployTokenMock('USDC') - }) - beforeEach('authorize task', async () => { const collectRole = smartVault.interface.getSighash('collect') await authorizer.connect(owner).authorize(task.address, smartVault.address, collectRole, []) }) - beforeEach('set tokens acceptance type', async () => { - const setTokensAcceptanceTypeRole = task.interface.getSighash('setTokensAcceptanceType') - await authorizer.connect(owner).authorize(owner.address, task.address, setTokensAcceptanceTypeRole, []) - await task.connect(owner).setTokensAcceptanceType(1) - }) - - beforeEach('set tokens acceptance list', async () => { - const setTokensAcceptanceListRole = task.interface.getSighash('setTokensAcceptanceList') - await authorizer.connect(owner).authorize(owner.address, task.address, setTokensAcceptanceListRole, []) - await task.connect(owner).setTokensAcceptanceList([token.address], [true]) - }) - - beforeEach('set default token threshold', async () => { - const setDefaultTokenThresholdRole = task.interface.getSighash('setDefaultTokenThreshold') - await authorizer.connect(owner).authorize(owner.address, task.address, setDefaultTokenThresholdRole, []) - await task.connect(owner).setDefaultTokenThreshold(token.address, threshold, 0) - }) - context('when the sender is authorized', () => { beforeEach('set sender', async () => { const callRole = task.interface.getSighash('call') @@ -126,95 +121,191 @@ describe('Depositor', () => { task = task.connect(owner) }) - context('when the given token is allowed', () => { - context('when the threshold has passed', () => { - const amount = threshold + context('when the token is not zero', () => { + context('when the token is an ERC20', () => { + let token: Contract - beforeEach('fund task', async () => { - await token.mint(task.address, amount) + beforeEach('set token', async () => { + token = await deployTokenMock('USDC') }) - const itExecutesTheTaskProperly = (requestedAmount: BigNumberish) => { - it('calls the collect primitive', async () => { - const tx = await task.call(token.address, requestedAmount) + context('when the amount is not zero', () => { + const amount = fp(5) - await assertIndirectEvent(tx, smartVault.interface, 'Collected', { token, from: task, amount }) + beforeEach('fund task', async () => { + await token.mint(task.address, amount) }) - it('emits an Executed event', async () => { - const tx = await task.call(token.address, requestedAmount) + const itExecutesTheTaskProperly = (requestedAmount: BigNumberish) => { + it('calls the collect primitive', async () => { + const tx = await task.call(token.address, requestedAmount) + + await assertIndirectEvent(tx, smartVault.interface, 'Collected', { token, from: task, amount }) + }) + + it('emits an Executed event', async () => { + const tx = await task.call(token.address, requestedAmount) - await assertEvent(tx, 'Executed') + await assertEvent(tx, 'Executed') + }) + } + + context('without balance connectors', () => { + const requestedAmount = amount + + itExecutesTheTaskProperly(requestedAmount) + + it('does not update any balance connectors', async () => { + const tx = await task.call(token.address, requestedAmount) + + await assertNoEvent(tx, 'BalanceConnectorUpdated') + }) }) - } - context('without balance connectors', () => { - const requestedAmount = amount + context('with balance connectors', () => { + const requestedAmount = 0 + const nextConnectorId = '0x0000000000000000000000000000000000000000000000000000000000000002' + + beforeEach('set balance connectors', async () => { + const setBalanceConnectorsRole = task.interface.getSighash('setBalanceConnectors') + await authorizer.connect(owner).authorize(owner.address, task.address, setBalanceConnectorsRole, []) + await task.connect(owner).setBalanceConnectors(ZERO_BYTES32, nextConnectorId) + }) + + beforeEach('authorize task to update balance connectors', async () => { + const updateBalanceConnectorRole = smartVault.interface.getSighash('updateBalanceConnector') + await authorizer + .connect(owner) + .authorize(task.address, smartVault.address, updateBalanceConnectorRole, []) + }) - itExecutesTheTaskProperly(requestedAmount) + itExecutesTheTaskProperly(requestedAmount) - it('does not update any balance connectors', async () => { - const tx = await task.call(token.address, requestedAmount) + it('updates the balance connectors properly', async () => { + const tx = await task.call(token.address, requestedAmount) - await assertNoEvent(tx, 'BalanceConnectorUpdated') + await assertIndirectEvent(tx, smartVault.interface, 'BalanceConnectorUpdated', { + id: nextConnectorId, + token, + amount, + added: true, + }) + }) }) }) - context('with balance connectors', () => { - const requestedAmount = 0 - const nextConnectorId = '0x0000000000000000000000000000000000000000000000000000000000000002' + context('when the amount is zero', () => { + const amount = 0 - beforeEach('set balance connectors', async () => { - const setBalanceConnectorsRole = task.interface.getSighash('setBalanceConnectors') - await authorizer.connect(owner).authorize(owner.address, task.address, setBalanceConnectorsRole, []) - await task.connect(owner).setBalanceConnectors(ZERO_BYTES32, nextConnectorId) + it('reverts', async () => { + await expect(task.call(token.address, amount)).to.be.revertedWith('TaskAmountZero') }) + }) + }) + + context('when the token is the native token', () => { + const token = NATIVE_TOKEN_ADDRESS - beforeEach('authorize task to update balance connectors', async () => { - const updateBalanceConnectorRole = smartVault.interface.getSighash('updateBalanceConnector') - await authorizer - .connect(owner) - .authorize(task.address, smartVault.address, updateBalanceConnectorRole, []) + context('when the amount is not zero', () => { + const amount = fp(2) + + beforeEach('fund task', async () => { + await owner.sendTransaction({ to: task.address, value: amount }) }) - itExecutesTheTaskProperly(requestedAmount) + const itExecutesTheTaskProperly = (requestedAmount: BigNumberish) => { + it('does not call the collect primitive', async () => { + const tx = await task.call(token, requestedAmount) + + await assertNoIndirectEvent(tx, smartVault.interface, 'Collected') + }) + + it('sends the amount to the smart vault', async () => { + const previousTaskBalance = await ethers.provider.getBalance(task.address) + const previousSmartVaultBalance = await ethers.provider.getBalance(smartVault.address) + + await task.call(token, requestedAmount) + + const currentTaskBalance = await ethers.provider.getBalance(task.address) + expect(currentTaskBalance).to.be.equal(previousTaskBalance.sub(amount)) + + const currentSmartVaultBalance = await ethers.provider.getBalance(smartVault.address) + expect(currentSmartVaultBalance).to.be.equal(previousSmartVaultBalance.add(amount)) + }) + + it('emits an Executed event', async () => { + const tx = await task.call(token, requestedAmount) + + await assertEvent(tx, 'Executed') + }) + } + + context('without balance connectors', () => { + const requestedAmount = amount - it('updates the balance connectors properly', async () => { - const tx = await task.call(token.address, requestedAmount) + itExecutesTheTaskProperly(requestedAmount) - await assertIndirectEvent(tx, smartVault.interface, 'BalanceConnectorUpdated', { - id: nextConnectorId, - token, - amount, - added: true, + it('does not update any balance connectors', async () => { + const tx = await task.call(token, requestedAmount) + + await assertNoEvent(tx, 'BalanceConnectorUpdated') }) }) - }) - }) - context('when the threshold has not passed', () => { - const amount = threshold.sub(1) + context('with balance connectors', () => { + const requestedAmount = 0 + const nextConnectorId = '0x0000000000000000000000000000000000000000000000000000000000000002' + + beforeEach('set balance connectors', async () => { + const setBalanceConnectorsRole = task.interface.getSighash('setBalanceConnectors') + await authorizer.connect(owner).authorize(owner.address, task.address, setBalanceConnectorsRole, []) + await task.connect(owner).setBalanceConnectors(ZERO_BYTES32, nextConnectorId) + }) + + beforeEach('authorize task to update balance connectors', async () => { + const updateBalanceConnectorRole = smartVault.interface.getSighash('updateBalanceConnector') + await authorizer + .connect(owner) + .authorize(task.address, smartVault.address, updateBalanceConnectorRole, []) + }) + + itExecutesTheTaskProperly(requestedAmount) - beforeEach('fund smart vault', async () => { - await token.mint(smartVault.address, amount) + it('updates the balance connectors properly', async () => { + const tx = await task.call(token, requestedAmount) + + await assertIndirectEvent(tx, smartVault.interface, 'BalanceConnectorUpdated', { + id: nextConnectorId, + token, + amount, + added: true, + }) + }) + }) }) - it('reverts', async () => { - await expect(task.call(token.address, amount)).to.be.revertedWith('TaskTokenThresholdNotMet') + context('when the amount is zero', () => { + const amount = 0 + + it('reverts', async () => { + await expect(task.call(token, amount)).to.be.revertedWith('TaskAmountZero') + }) }) }) }) - context('when the given token is not allowed', () => { + context('when the token is zero', () => { + const token = ZERO_ADDRESS + it('reverts', async () => { - await expect(task.call(NATIVE_TOKEN_ADDRESS, 0)).to.be.revertedWith('TaskTokenNotAllowed') + await expect(task.call(token, 1)).to.be.revertedWith('TaskTokenZero') }) }) }) context('when the sender is not authorized', () => { it('reverts', async () => { - await expect(task.call(token.address, 0)).to.be.revertedWith('AuthSenderNotAllowed') + await expect(task.call(ZERO_ADDRESS, 0)).to.be.revertedWith('AuthSenderNotAllowed') }) }) })