diff --git a/.gitmodules b/.gitmodules index bc8f8eae..60045c69 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,9 +4,9 @@ [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts -[submodule "lib/chainlink"] - path = lib/chainlink - url = https://github.com/smartcontractkit/chainlink [submodule "lib/LayerZero-v2"] path = lib/LayerZero-v2 url = https://github.com/LayerZero-Labs/LayerZero-v2 +[submodule "lib/gelato-automate"] + path = lib/gelato-automate + url = https://github.com/gelatodigital/automate diff --git a/lib/chainlink b/lib/chainlink deleted file mode 160000 index 594387e4..00000000 --- a/lib/chainlink +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 594387e4795adeed7fd32893dfecc7ae04b987ed diff --git a/lib/gelato-automate b/lib/gelato-automate new file mode 160000 index 00000000..ae1caca8 --- /dev/null +++ b/lib/gelato-automate @@ -0,0 +1 @@ +Subproject commit ae1caca8c4341532213dbb088555e13a5e1e443e diff --git a/remappings.txt b/remappings.txt index e18ab1c7..66f181ef 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,5 +1,5 @@ ds-test/=lib/forge-std/lib/ds-test/src/ forge-std/=lib/forge-std/src/ openzeppelin-contracts/=lib/openzeppelin-contracts/contracts/ -chainlink/=lib/chainlink/contracts/src/v0.8/ layer-zero-v2/=lib/LayerZero-v2/ +gelato-automate/=lib/gelato-automate/contracts/ diff --git a/src/DripsDeployer.sol b/src/DripsDeployer.sol index c3cb668e..5e2b5e73 100644 --- a/src/DripsDeployer.sol +++ b/src/DripsDeployer.sol @@ -7,7 +7,7 @@ import {Drips} from "./Drips.sol"; import {ImmutableSplitsDriver} from "./ImmutableSplitsDriver.sol"; import {Managed, ManagedProxy} from "./Managed.sol"; import {NFTDriver} from "./NFTDriver.sol"; -import {OperatorInterface, RepoDriver} from "./RepoDriver.sol"; +import {RepoDriver} from "./RepoDriver.sol"; import {Ownable2Step} from "openzeppelin-contracts/access/Ownable2Step.sol"; import {Address} from "openzeppelin-contracts/utils/Address.sol"; @@ -293,27 +293,19 @@ contract ImmutableSplitsDriverModule is DriverModule(2) { } contract RepoDriverModule is CallerDependentModule, DriverModule(3) { - OperatorInterface public immutable operator; - bytes32 public immutable jobId; - uint96 public immutable defaultFee; + string public ipfsCid; function args() public view override returns (bytes memory) { - return abi.encode(dripsDeployer, proxyAdmin, operator, jobId, defaultFee); + return abi.encode(dripsDeployer, proxyAdmin, ipfsCid); } - constructor( - DripsDeployer dripsDeployer_, - address proxyAdmin_, - OperatorInterface operator_, - bytes32 jobId_, - uint96 defaultFee_ - ) BaseModule(dripsDeployer_, "RepoDriver") { - operator = operator_; - jobId = jobId_; - defaultFee = defaultFee_; + constructor(DripsDeployer dripsDeployer_, address proxyAdmin_, string memory ipfsCid_) + BaseModule(dripsDeployer_, "RepoDriver") + { + ipfsCid = ipfsCid_; + bytes memory data = abi.encodeCall(RepoDriver.updateGelatoTask, (ipfsCid_)); // slither-disable-next-line too-many-digits - _deployProxy(proxyAdmin_, type(RepoDriver).creationCode); - repoDriver().initializeAnyApiOperator(operator, jobId, defaultFee); + _deployProxy(proxyAdmin_, type(RepoDriver).creationCode, data); } function logicArgs() public view override returns (bytes memory) { @@ -321,7 +313,7 @@ contract RepoDriverModule is CallerDependentModule, DriverModule(3) { } function repoDriver() public view returns (RepoDriver) { - return RepoDriver(proxy()); + return RepoDriver(payable(proxy())); } } diff --git a/src/RepoDriver.sol b/src/RepoDriver.sol index 1edd9e27..b4edf097 100644 --- a/src/RepoDriver.sol +++ b/src/RepoDriver.sol @@ -1,16 +1,20 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity ^0.8.20; -import { - AccountMetadata, Drips, StreamReceiver, IERC20, SafeERC20, SplitsReceiver -} from "./Drips.sol"; +import {AccountMetadata, Drips, StreamReceiver, IERC20, SplitsReceiver} from "./Drips.sol"; import {DriverTransferUtils} from "./DriverTransferUtils.sol"; import {Managed} from "./Managed.sol"; -import {ERC677ReceiverInterface} from "chainlink/interfaces/ERC677ReceiverInterface.sol"; -import {LinkTokenInterface} from "chainlink/interfaces/LinkTokenInterface.sol"; -import {OperatorInterface} from "chainlink/interfaces/OperatorInterface.sol"; -import {BufferChainlink, CBORChainlink} from "chainlink/Chainlink.sol"; -import {ShortString, ShortStrings} from "openzeppelin-contracts/utils/ShortStrings.sol"; +import { + IAutomate, + IGelato, + IProxyModule, + Module, + ModuleData, + TriggerType +} from "gelato-automate/integrations/Types.sol"; +import {IAutomate as IAutomate2} from "gelato-automate/interfaces/IAutomate.sol"; +import {IOpsProxyFactory} from "gelato-automate/interfaces/IOpsProxyFactory.sol"; +import {Address} from "openzeppelin-contracts/utils/Address.sol"; /// @notice The supported forges where repositories are stored. enum Forge { @@ -22,95 +26,105 @@ enum Forge { /// Each repository stored in one of the supported forges has a deterministic account ID assigned. /// By default the repositories have no owner and their accounts can't be controlled by anybody, /// use `requestUpdateOwner` to update the owner. -contract RepoDriver is ERC677ReceiverInterface, DriverTransferUtils, Managed { - using SafeERC20 for IERC20; - using CBORChainlink for BufferChainlink.buffer; - +contract RepoDriver is DriverTransferUtils, Managed { /// @notice The Drips address used by this driver. Drips public immutable drips; /// @notice The driver ID which this driver uses when calling Drips. uint32 public immutable driverId; - /// @notice The Link token used for paying the operators. - LinkTokenInterface public immutable linkToken; - /// @notice The JSON path inside `FUNDING.json` where the account ID owner is stored. - ShortString internal immutable jsonPath; + /// @notice The Gelato Automate contract used for running oracle tasks. + IAutomate public immutable gelatoAutomate; + + /// @notice The address collecting Gelato fees. + address payable internal immutable gelatoFeeCollector; + /// @notice The placeholder address meaning that the Gelato fee is paid in native tokens. + address internal constant GELATO_NATIVE_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; /// @notice The ERC-1967 storage slot holding a single `RepoDriverStorage` structure. bytes32 private immutable _repoDriverStorageSlot = _erc1967Slot("eip1967.repoDriver.storage"); - /// @notice The ERC-1967 storage slot holding a single `RepoDriverAnyApiStorage` structure. - bytes32 private immutable _repoDriverAnyApiStorageSlot = - _erc1967Slot("eip1967.repoDriver.anyApi.storage"); - - /// @notice Emitted when the AnyApi operator configuration is updated. - /// @param operator The new address of the AnyApi operator. - /// @param jobId The new AnyApi job ID used for requesting account owner updates. - /// @param defaultFee The new fee in Link for each account owner. - /// update request when the driver is covering the cost. - event AnyApiOperatorUpdated( - OperatorInterface indexed operator, bytes32 indexed jobId, uint96 defaultFee - ); + /// @notice The ERC-1967 storage slot holding a single `GelatoStorage` structure. + bytes32 private immutable _gelatoStorageSlot = _erc1967Slot("eip1967.repoDriver.gelato.storage"); /// @notice Emitted when the account ownership update is requested. /// @param accountId The ID of the account. /// @param forge The forge where the repository is stored. /// @param name The name of the repository. - event OwnerUpdateRequested(uint256 indexed accountId, Forge forge, bytes name); + /// @param payer The address of the user paying the fees. + /// The Gelato fee will be paid in native tokens when the actual owner update is made. + /// The fee is paid from the funds deposited for the message sender calling this function. + /// If these funds aren't enough, the missing part is paid from the common funds. + event OwnerUpdateRequested(uint256 indexed accountId, Forge forge, bytes name, address payer); /// @notice Emitted when the account ownership is updated. /// @param accountId The ID of the account. /// @param owner The new owner of the repository. event OwnerUpdated(uint256 indexed accountId, address owner); + /// @notice Emitted when Gelato task performing account ownership lookups is updated. + /// @param taskId The ID of the created Gelato task. + /// @param ipfsCid The IPFS CID of the code to be run + /// by the Gelato Web3 Function task to lookup the account ownership. + event GelatoTaskUpdated(bytes32 taskId, string ipfsCid); + + /// @notice Emitted when native tokens are deposited for the user. + /// These funds will be used to pay Gelato fees for that user's requests. + /// @param user The user for whom the deposit was made. + /// @param amount The deposited amount. + event UserFundsDeposited(address indexed user, uint256 amount); + + /// @notice Emitted when native tokens are withdrawn for the user. + /// @param user The user who withdrew their funds. + /// @param amount The amount that was withdrawn. + /// @param receiver The address to which the withdrawn funds were sent. + event UserFundsWithdrawn(address indexed user, uint256 amount, address payable receiver); + + /// @notice Emitted when the Gelato fee is paid. + /// @param user The paying user. + /// @param userFundsUsed The amount paid from the user's deposit. + /// @param commonFundsUsed The amount paid from the common deposit. + event GelatoFeePaid(address indexed user, uint256 userFundsUsed, uint256 commonFundsUsed); + struct RepoDriverStorage { /// @notice The owners of the accounts. - mapping(uint256 accountId => address) accountOwners; + mapping(uint256 accountId => AccountOwner) accountOwners; + } + + struct AccountOwner { + /// @notice The address of the account owner. + address owner; + /// @notice The block height at which the account ownership was looked up off-chain. + uint96 fromBlock; + } + + struct GelatoStorage { + /// @notice The amount of native tokens deposited by each user. + /// Used to pay Gelato fees for that user's requests. + mapping(address user => uint256 amount) userFunds; + /// @notice The total amount of native tokens deposited by all users. + uint256 userFundsTotal; + /// @notice The address of the proxy delivering Gelato responses. + address gelatoProxy; } - struct RepoDriverAnyApiStorage { - /// @notice The requested account owner updates. - mapping(bytes32 requestId => uint256 accountId) requestedUpdates; - /// @notice The new address of the AnyApi operator. - OperatorInterface operator; - /// @notice The fee in Link for each account owner. - /// update request when the driver is covering the cost. - uint96 defaultFee; - /// @notice The AnyApi job ID used for requesting account owner updates. - bytes32 jobId; - /// @notice If false, the initial operator configuration is possible. - bool isInitialized; - /// @notice The AnyApi requests counter used as a nonce when calculating the request ID. - uint248 nonce; + modifier onlyOwner(uint256 accountId) { + require(_msgSender() == ownerOf(accountId), "Caller is not the account owner"); + _; } /// @param drips_ The Drips contract to use. /// @param forwarder The ERC-2771 forwarder to trust. May be the zero address. /// @param driverId_ The driver ID to use when calling Drips. - constructor(Drips drips_, address forwarder, uint32 driverId_) DriverTransferUtils(forwarder) { + /// @param gelatoAutomate_ The Gelato Automate contract used for running oracle tasks + constructor(Drips drips_, address forwarder, uint32 driverId_, IAutomate gelatoAutomate_) + DriverTransferUtils(forwarder) + { drips = drips_; driverId = driverId_; - string memory chainName; - address _linkToken; - if (block.chainid == 1) { - chainName = "ethereum"; - _linkToken = 0x514910771AF9Ca656af840dff83E8264EcF986CA; - } else if (block.chainid == 5) { - chainName = "goerli"; - _linkToken = 0x326C977E6efc84E512bB9C30f76E30c160eD06FB; - } else if (block.chainid == 11155111) { - chainName = "sepolia"; - _linkToken = 0x779877A7B0D9E8603169DdbD7836e478b4624789; - } else { - chainName = "other"; - _linkToken = address(bytes20("dummy link token")); - } - jsonPath = ShortStrings.toShortString(string.concat("drips,", chainName, ",ownedBy")); - linkToken = LinkTokenInterface(_linkToken); + gelatoAutomate = gelatoAutomate_; + IGelato gelato = IGelato(gelatoAutomate.gelato()); + gelatoFeeCollector = payable(gelato.feeCollector()); } - modifier onlyOwner(uint256 accountId) { - require(_msgSender() == ownerOf(accountId), "Caller is not the account owner"); - _; - } + receive() external payable {} /// @notice Returns the address of the Drips contract to use for ERC-20 transfers. function _drips() internal view override returns (Drips) { @@ -134,7 +148,7 @@ contract RepoDriver is ERC677ReceiverInterface, DriverTransferUtils, Managed { /// and it must be formatted identically as in the repository's URL, /// including the case of each letter and special characters being removed. /// @return accountId The account ID. - function calcAccountId(Forge forge, bytes memory name) + function calcAccountId(Forge forge, bytes calldata name) public view returns (uint256 accountId) @@ -175,213 +189,188 @@ contract RepoDriver is ERC677ReceiverInterface, DriverTransferUtils, Managed { accountId = (accountId << 216) | nameEncoded; } - /// @notice Initializes the AnyApi operator configuration. - /// Callable only once, and only before any calls to `updateAnyApiOperator`. - /// @param operator The initial address of the AnyApi operator. - /// @param jobId The initial AnyApi job ID used for requesting account owner updates. - /// @param defaultFee The initial fee in Link for each account owner. - /// update request when the driver is covering the cost. - function initializeAnyApiOperator(OperatorInterface operator, bytes32 jobId, uint96 defaultFee) - public - whenNotPaused - { - require(!_repoDriverAnyApiStorage().isInitialized, "Already initialized"); - _updateAnyApiOperator(operator, jobId, defaultFee); + /// @notice Gets the account owner. + /// @param accountId The ID of the account. + /// @return owner The owner of the account. + function ownerOf(uint256 accountId) public view returns (address owner) { + return _repoDriverStorage().accountOwners[accountId].owner; } - /// @notice Updates the AnyApi operator configuration. Callable only by the admin. - /// @param operator The new address of the AnyApi operator. - /// @param jobId The new AnyApi job ID used for requesting account owner updates. - /// @param defaultFee The new fee in Link for each account owner. - /// update request when the driver is covering the cost. - function updateAnyApiOperator(OperatorInterface operator, bytes32 jobId, uint96 defaultFee) - public - whenNotPaused - onlyAdmin - { - _updateAnyApiOperator(operator, jobId, defaultFee); + /// @notice Updates the Gelato task performing account ownership lookups. + /// Calling this function cancels all previously created tasks and creates a new one. + /// Callable only by the admin or inside the constructor of a proxy delegating to this contract. + /// @param ipfsCid The IPFS CID of the code to be run + /// by the Gelato Web3 Function task to lookup the account ownership. + /// It must accept no arguments, expect `OwnerUpdateRequested` events when executed, + /// and call `updateOwnerByGelato` with the results. + function updateGelatoTask(string calldata ipfsCid) public onlyAdminOrConstructor { + _initGelatoProxy(); + _cancelAllGelatoTasks(); + bytes32 taskId = _createGelatoTask(ipfsCid); + emit GelatoTaskUpdated(taskId, ipfsCid); } - /// @notice Updates the AnyApi operator configuration. Callable only by the admin. - /// @param operator The new address of the AnyApi operator. - /// @param jobId The new AnyApi job ID used for requesting account owner updates. - /// @param defaultFee The new fee in Link for each account owner. - /// update request when the driver is covering the cost. - function _updateAnyApiOperator(OperatorInterface operator, bytes32 jobId, uint96 defaultFee) - internal - { - RepoDriverAnyApiStorage storage storageRef = _repoDriverAnyApiStorage(); - storageRef.isInitialized = true; - storageRef.operator = operator; - storageRef.jobId = jobId; - storageRef.defaultFee = defaultFee; - emit AnyApiOperatorUpdated(operator, jobId, defaultFee); + /// @notice Deploys and stores the address of the proxy delivering Gelato responses. + function _initGelatoProxy() internal { + if (_gelatoStorage().gelatoProxy != address(0)) return; + IProxyModule proxyModule = IProxyModule(gelatoAutomate.taskModuleAddresses(Module.PROXY)); + IOpsProxyFactory proxyFactory = IOpsProxyFactory(proxyModule.opsProxyFactory()); + bool isDeployed; + (_gelatoStorage().gelatoProxy, isDeployed) = proxyFactory.getProxyOf(address(this)); + if (!isDeployed) proxyFactory.deploy(); } - /// @notice Gets the current AnyApi operator configuration. - /// @return operator The address of the AnyApi operator. - /// @return jobId The AnyApi job ID used for requesting account owner updates. - /// @return defaultFee The fee in Link for each account owner. - /// update request when the driver is covering the cost. - function anyApiOperator() - public - view - returns (OperatorInterface operator, bytes32 jobId, uint96 defaultFee) - { - RepoDriverAnyApiStorage storage storageRef = _repoDriverAnyApiStorage(); - operator = storageRef.operator; - jobId = storageRef.jobId; - defaultFee = storageRef.defaultFee; + /// @notice Cancels all previously created Gelato tasks. + function _cancelAllGelatoTasks() internal { + // `IAutomate` interface doesn't cover `getTaskIdsByUser`. + IAutomate2 gelatoAutomate2 = IAutomate2(address(gelatoAutomate)); + bytes32[] memory tasks = gelatoAutomate2.getTaskIdsByUser(address(this)); + for (uint256 i = 0; i < tasks.length; i++) { + gelatoAutomate.cancelTask(tasks[i]); + } } - /// @notice Gets the account owner. - /// @param accountId The ID of the account. - /// @return owner The owner of the account. - function ownerOf(uint256 accountId) public view returns (address owner) { - return _repoDriverStorage().accountOwners[accountId]; + /// @notice Creates a Gelato task. + /// @param ipfsCid The IPFS CID of the code to be run + /// by the Gelato Web3 Function task to lookup the account ownership. + /// It must accept no arguments, expect `OwnerUpdateRequested` events when executed, + /// and call `updateOwnerByGelato` with the results. + /// @return taskId The ID of the created Gelato task. + function _createGelatoTask(string calldata ipfsCid) internal returns (bytes32 taskId) { + ModuleData memory moduleData = ModuleData(new Module[](3), new bytes[](3)); + + // Receive responses via the proxy. + moduleData.modules[0] = Module.PROXY; + + // Run the web3 function stored under `ipfsCid` with no arguments. + moduleData.modules[1] = Module.WEB3_FUNCTION; + moduleData.args[1] = abi.encode(ipfsCid, ""); + + bytes32[][] memory topics = new bytes32[][](1); + topics[0] = new bytes32[](1); + topics[0][0] = OwnerUpdateRequested.selector; + // Trigger when this address emits `OwnerUpdateRequested` with 1 block confirmation. + moduleData.modules[2] = Module.TRIGGER; + moduleData.args[2] = abi.encode(TriggerType.EVENT, abi.encode(this, topics, 1)); + + // The task callback is the zero address called with the zero function selector. + // These parameters are never used because the web3 function constructs the real callbacks. + return gelatoAutomate.createTask(address(0), hex"00000000", moduleData, GELATO_NATIVE_TOKEN); } /// @notice Requests an update of the ownership of the account representing the repository. /// The actual update of the owner will be made in a future transaction. - /// The driver will cover the fee in Link that must be paid to the operator. - /// If you want to cover the fee yourself, use `onTokenTransfer`. + /// The Gelato fee will be paid in native tokens when the actual owner update is made. + /// The fee is paid from the funds deposited for the message sender calling this function. + /// If these funds aren't enough, the missing part is paid from the common funds. /// /// The repository must contain a `FUNDING.json` file in the project root in the default branch. /// The file must be a valid JSON with arbitrary data, but it must contain the owner address /// as a hexadecimal string under `drips` -> `` -> `ownedBy`, a minimal example: /// `{ "drips": { "ethereum": { "ownedBy": "0x0123456789abcDEF0123456789abCDef01234567" } } }`. - /// If the operator can't read the owner when processing the update request, - /// it ignores the request and no change to the account ownership is made. + /// If for whatever reason the owner address can't be obtained, it's assumed to be address zero. /// @param forge The forge where the repository is stored. /// @param name The name of the repository. /// For GitHub and GitLab it must follow the `user_name/repository_name` structure /// and it must be formatted identically as in the repository's URL, /// including the case of each letter and special characters being removed. - /// @return accountId The ID of the account. - function requestUpdateOwner(Forge forge, bytes memory name) - public - whenNotPaused - returns (uint256 accountId) - { - uint256 fee = _repoDriverAnyApiStorage().defaultFee; - require(linkToken.balanceOf(address(this)) >= fee, "Link balance too low"); - return _requestUpdateOwner(forge, name, fee); + function requestUpdateOwner(Forge forge, bytes calldata name) public whenNotPaused { + emit OwnerUpdateRequested(calcAccountId(forge, name), forge, name, _msgSender()); } - /// @notice The function called when receiving funds from ERC-677 `transferAndCall`. - /// Only supports receiving Link tokens, callable only by the Link token smart contract. - /// The only supported usage is requesting account ownership updates, - /// the transferred tokens are then used for paying the AnyApi operator fee, - /// see `requestUpdateOwner` for more details. - /// The received tokens are never refunded, so make sure that - /// the amount isn't too low to cover the fee, isn't too high and wasteful, - /// and the repository's content is valid so its ownership can be verified. - /// @param amount The transferred amount, it will be used as the AnyApi operator fee. - /// @param data The `transferAndCall` payload. - /// It must be a valid ABI-encoded calldata for `requestUpdateOwner`. - /// The call parameters will be used the same way as when calling `requestUpdateOwner`, - /// to determine which account's ownership update is requested. - function onTokenTransfer(address, /* sender */ uint256 amount, bytes calldata data) + /// @notice Updates the account owner. + /// Callable only via the Gelato proxy by the Gelato task created by this contract. + /// @param accountId The ID of the account having the ownership updated. + /// @param owner The new owner of the account. + /// @param fromBlock The block height at which the account ownership was looked up off-chain. + /// @param payer The address of the user paying the fees. + /// The Gelato fee will be paid in native tokens when the actual owner update is made. + /// The fee is paid from the funds deposited for the message sender calling this function. + /// If these funds aren't enough, the missing part is paid from the common funds. + function updateOwnerByGelato(uint256 accountId, address owner, uint96 fromBlock, address payer) public whenNotPaused { - require(msg.sender == address(linkToken), "Callable only by the Link token"); - require(data.length >= 4, "Data not a valid calldata"); - require(bytes4(data[:4]) == this.requestUpdateOwner.selector, "Data not requestUpdateOwner"); - (Forge forge, bytes memory name) = abi.decode(data[4:], (Forge, bytes)); - _requestUpdateOwner(forge, name, amount); + require(msg.sender == _gelatoStorage().gelatoProxy, "Callable only by Gelato"); + AccountOwner storage accountOwner = _repoDriverStorage().accountOwners[accountId]; + if (accountOwner.fromBlock < fromBlock) { + accountOwner.owner = owner; + accountOwner.fromBlock = fromBlock; + emit OwnerUpdated(accountId, owner); + } + _payGelatoFee(payer); } - /// @notice Requests an update of the ownership of the account representing the repository. - /// See `requestUpdateOwner` for more details. - /// @param forge The forge where the repository is stored. - /// @param name The name of the repository. - /// @param fee The fee in Link to pay for the request. - /// @return accountId The ID of the account. - function _requestUpdateOwner(Forge forge, bytes memory name, uint256 fee) - internal - returns (uint256 accountId) - { - RepoDriverAnyApiStorage storage storageRef = _repoDriverAnyApiStorage(); - address operator = address(storageRef.operator); - require(operator != address(0), "Operator address not set"); - uint256 nonce = storageRef.nonce++; - bytes32 requestId = keccak256(abi.encodePacked(this, nonce)); - accountId = calcAccountId(forge, name); - storageRef.requestedUpdates[requestId] = accountId; - bytes memory payload = _requestPayload(forge, name); - bytes memory callData = abi.encodeCall( - OperatorInterface.operatorRequest, - ( - address(0), // ignored, will be replaced in the operator with this contract address - 0, // ignored, will be replaced in the operator with the fee - storageRef.jobId, - this.updateOwnerByAnyApi.selector, - nonce, - 2, // data version - payload - ) - ); - require(linkToken.transferAndCall(operator, fee, callData), "Transfer and call failed"); - // slither-disable-next-line reentrancy-events - emit OwnerUpdateRequested(accountId, forge, name); + /// @notice Pay the Gelato fee for the currently executed Gelato task. + /// @param payer The address of the user paying the fees. + /// The Gelato fee will be paid in native tokens when the actual owner update is made. + /// The fee is paid from the funds deposited for the message sender calling this function. + /// If these funds aren't enough, the missing part is paid from the common funds. + function _payGelatoFee(address payer) internal { + (uint256 amount, address token) = gelatoAutomate.getFeeDetails(); + require(token == GELATO_NATIVE_TOKEN, "Payment must be in native tokens"); + if (amount == 0) return; + uint256 userFundsUsed = userFunds(payer); + if (userFundsUsed >= amount) { + userFundsUsed = amount; + } else { + require(commonFunds() >= amount - userFundsUsed, "Not enough funds"); + } + if (userFundsUsed != 0) { + _gelatoStorage().userFunds[payer] -= userFundsUsed; + _gelatoStorage().userFundsTotal -= userFundsUsed; + } + Address.sendValue(gelatoFeeCollector, amount); + emit GelatoFeePaid(payer, userFundsUsed, amount - userFundsUsed); } - /// @notice Builds the AnyApi generic `bytes` fetching request payload. - /// It instructs the operator to fetch the current owner of the account. - /// @param forge The forge where the repository is stored. - /// @param name The name of the repository. - /// @return payload The AnyApi request payload. - function _requestPayload(Forge forge, bytes memory name) - internal - view - returns (bytes memory payload) - { - // slither-disable-next-line uninitialized-local - BufferChainlink.buffer memory buffer; - buffer = BufferChainlink.init(buffer, 256); - buffer.encodeString("get"); - buffer.encodeString(_requestUrl(forge, name)); - buffer.encodeString("path"); - buffer.encodeString(ShortStrings.toString(jsonPath)); - return buffer.buf; + /// @notice The amount of native tokens deposited as common funds. + /// Used to pay Gelato fees when the user's funds aren't enough. + /// To deposit more tokens transfer them to this contract address. + /// @return amount The deposited amount. + function commonFunds() public view returns (uint256 amount) { + return address(this).balance - _gelatoStorage().userFundsTotal; } - /// @notice Builds the URL for fetch the `FUNDING.json` file for the given repository. - /// @param forge The forge where the repository is stored. - /// @param name The name of the repository. - /// @return url The built URL. - function _requestUrl(Forge forge, bytes memory name) - internal - pure - returns (string memory url) + /// @notice The amount of native tokens deposited by the user. + /// Used to pay Gelato fees for that user's requests. + /// If these funds aren't enough, the missing part is paid from the common funds. + /// @return amount The deposited amount. + function userFunds(address user) public view returns (uint256 amount) { + return _gelatoStorage().userFunds[user]; + } + + /// @notice Deposits the native tokens sent with the message for the user. + /// These funds will be used to pay Gelato fees for that user's requests. + /// @param user The user for whom the deposit is made. + function depositUserFunds(address user) public payable whenNotPaused { + _gelatoStorage().userFunds[user] += msg.value; + _gelatoStorage().userFundsTotal += msg.value; + emit UserFundsDeposited(user, msg.value); + } + + /// @notice Withdraws the native tokens deposited for the message sender. + /// @param amount The amount to withdraw or `0` to withdraw all. + /// @param receiver The address to send the withdrawn funds to. + /// @return withdrawnAmount The amount that was withdrawn. + function withdrawUserFunds(uint256 amount, address payable receiver) + public + whenNotPaused + returns (uint256 withdrawnAmount) { - if (forge == Forge.GitHub) { - return string.concat( - "https://raw.githubusercontent.com/", string(name), "/HEAD/FUNDING.json" - ); - } else if (forge == Forge.GitLab) { - return string.concat("https://gitlab.com/", string(name), "/-/raw/HEAD/FUNDING.json"); + address user = _msgSender(); + uint256 maxAmount = userFunds(user); + if (amount == 0) { + amount = maxAmount; + if (amount == 0) return 0; } else { - revert("Unsupported forge"); + require(amount <= maxAmount, "Not enough user funds"); } - } - - /// @notice Updates the account owner. Callable only by the AnyApi operator. - /// @param requestId The ID of the AnyApi request. - /// Must be the same as the request ID generated when requesting an owner update, - /// this function will update the account ownership that was requested back then. - /// @param ownerRaw The new owner of the account. Must be a 20 bytes long address. - function updateOwnerByAnyApi(bytes32 requestId, bytes calldata ownerRaw) public whenNotPaused { - RepoDriverAnyApiStorage storage storageRef = _repoDriverAnyApiStorage(); - require(msg.sender == address(storageRef.operator), "Callable only by the operator"); - uint256 accountId = storageRef.requestedUpdates[requestId]; - require(accountId != 0, "Unknown request ID"); - delete storageRef.requestedUpdates[requestId]; - require(ownerRaw.length == 20, "Invalid owner length"); - address owner = address(bytes20(ownerRaw)); - _repoDriverStorage().accountOwners[accountId] = owner; - emit OwnerUpdated(accountId, owner); + _gelatoStorage().userFunds[user] -= amount; + _gelatoStorage().userFundsTotal -= amount; + Address.sendValue(receiver, amount); + emit UserFundsWithdrawn(user, amount, receiver); + return amount; } /// @notice Collects the account's received already split funds @@ -544,14 +533,10 @@ contract RepoDriver is ERC677ReceiverInterface, DriverTransferUtils, Managed { } } - /// @notice Returns the RepoDriver storage specific to AnyApi. + /// @notice Returns the Gelato storage. /// @return storageRef The storage. - function _repoDriverAnyApiStorage() - internal - view - returns (RepoDriverAnyApiStorage storage storageRef) - { - bytes32 slot = _repoDriverAnyApiStorageSlot; + function _gelatoStorage() internal view returns (GelatoStorage storage storageRef) { + bytes32 slot = _gelatoStorageSlot; // slither-disable-next-line assembly assembly { storageRef.slot := slot diff --git a/test/RepoDriver.t.sol b/test/RepoDriver.t.sol index 3b7b9338..6c91b496 100644 --- a/test/RepoDriver.t.sol +++ b/test/RepoDriver.t.sol @@ -12,45 +12,186 @@ import { SplitsReceiver } from "src/Drips.sol"; import {ManagedProxy} from "src/Managed.sol"; -import {BufferChainlink, CBORChainlink} from "chainlink/Chainlink.sol"; -import {ERC677ReceiverInterface} from "chainlink/interfaces/ERC677ReceiverInterface.sol"; -import {OperatorInterface} from "chainlink/interfaces/OperatorInterface.sol"; -import {LinkTokenInterface} from "chainlink/interfaces/LinkTokenInterface.sol"; -import {console2, Test} from "forge-std/Test.sol"; +import {console2, StdAssertions, Test} from "forge-std/Test.sol"; +import { + IAutomate, + IGelato, + IProxyModule, + Module, + ModuleData, + TriggerType +} from "gelato-automate/integrations/Types.sol"; +import {IAutomate as IAutomate2} from "gelato-automate/interfaces/IAutomate.sol"; import { ERC20, IERC20, ERC20PresetFixedSupply } from "openzeppelin-contracts/token/ERC20/presets/ERC20PresetFixedSupply.sol"; +import {Address} from "openzeppelin-contracts/utils/Address.sol"; + +address constant GELATO_NATIVE_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; -using CBORChainlink for BufferChainlink.buffer; +contract Events { + event OwnerUpdateRequested(uint256 indexed accountId, Forge forge, bytes name, address payer); +} -contract TestLinkToken is ERC20("", "") { - function mint(address receiver, uint256 amount) public { - _mint(receiver, amount); +contract Automate is StdAssertions, Events { + /// @dev Used by RepoDriver + Gelato public immutable gelato; + ProxyModule public immutable proxyModule; + bytes32[] internal taskIds; + string internal expectedIpfsCid; + uint256 internal _feeAmount; + address internal _feeToken; + + constructor(address user) { + proxyModule = new ProxyModule(user); + gelato = new Gelato(); } - function transferAndCall(address to, uint256 value, bytes calldata data) - external - returns (bool success) - { - super.transfer(to, value); - ERC677ReceiverInterface(to).onTokenTransfer(msg.sender, value, data); - return true; + /// @dev Used by RepoDriver + function taskModuleAddresses(Module module) public returns (address moduleAddress) { + assertTrue(module == Module.PROXY, "Only proxy module supported"); + return address(proxyModule); + } + + function assertUserSupported(address user) internal { + assertEq(user, proxyModule.opsProxyFactory().user(), "Unsupported user"); + } + + /// @dev Used by RepoDriver + function getTaskIdsByUser(address user) public returns (bytes32[] memory taskIds_) { + assertUserSupported(user); + return taskIds; + } + + function pushTaskId(bytes32 taskId) public { + taskIds.push(taskId); + } + + /// @dev Used by RepoDriver + function cancelTask(bytes32 taskId) public { + assertUserSupported(msg.sender); + for (uint256 i = 0; i < taskIds.length; i++) { + if (taskIds[i] == taskId) { + taskIds[i] = taskIds[taskIds.length - 1]; + taskIds.pop(); + return; + } + } + assertTrue(false, "Task ID not found"); + } + + function expectIpfsCid(string calldata ipfsCid) public { + expectedIpfsCid = ipfsCid; + } + + /// @dev Used by RepoDriver + function createTask( + address execAddress, + bytes calldata execDataOrSelector, + ModuleData calldata moduleData, + address feeToken + ) public returns (bytes32 taskId) { + assertGe(execDataOrSelector.length, 4, "Exec data too short"); + + assertEq(moduleData.modules.length, 3, "Invalid modules length"); + assertEq(moduleData.args.length, 3, "Invalid args length"); + + assertTrue(moduleData.modules[0] == Module.PROXY, "Invalid module 0"); + assertEq(moduleData.args[0], "", "Invalid args 0"); + + assertTrue(moduleData.modules[1] == Module.WEB3_FUNCTION, "Invalid module 1"); + assertEq(moduleData.args[1], abi.encode(expectedIpfsCid, ""), "Invalid args 1"); + + assertTrue(moduleData.modules[2] == Module.TRIGGER, "Invalid module 2"); + bytes32[][] memory topics = new bytes32[][](1); + topics[0] = new bytes32[](1); + topics[0][0] = OwnerUpdateRequested.selector; + bytes memory trigger = abi.encode(msg.sender, topics, 1); + assertEq(moduleData.args[2], abi.encode(TriggerType.EVENT, trigger), "Invalid args 2"); + + assertEq(feeToken, GELATO_NATIVE_TOKEN, "Fee token not native"); + + assertEq(taskIds.length, 0, "Uncancelled tasks"); + + taskId = keccak256(abi.encode(execAddress, execDataOrSelector, moduleData, feeToken)); + taskIds.push(taskId); + } + + function setFeeDetails(uint256 feeAmount, address feeToken) public { + _feeAmount = feeAmount; + _feeToken = feeToken; + } + + /// @dev Used by RepoDriver + function getFeeDetails() public view returns (uint256 feeAmount, address feeToken) { + return (_feeAmount, _feeToken); + } + + fallback() external { + assertTrue(false, "Automate function not implemented"); } } -contract MockDummy { - fallback() external payable { - revert("Call not mocked"); +contract Gelato is StdAssertions { + /// @dev Used by RepoDriver + address public immutable feeCollector = address(bytes20("fee collector")); + + fallback() external { + assertTrue(false, "Gelato function not implemented"); } } -contract RepoDriverTest is Test { +contract ProxyModule is StdAssertions { + /// @dev Used by RepoDriver + OpsProxyFactory public immutable opsProxyFactory; + + constructor(address user) { + opsProxyFactory = new OpsProxyFactory(user); + } + + fallback() external { + assertTrue(false, "ProxyModule function not implemented"); + } +} + +contract OpsProxyFactory is StdAssertions { + address public immutable user; + address public immutable proxy = address(bytes20("gelato proxy")); + bool public isDeployed; + + constructor(address user_) { + user = user_; + } + + fallback() external { + assertTrue(false, "OpsProxyFactory function not implemented"); + } + + function assertUserSupported(address user_) public { + assertEq(user_, user, "Unsupported user"); + } + + /// @dev Used by RepoDriver + function getProxyOf(address user_) external returns (address, bool) { + assertUserSupported(user_); + return (proxy, isDeployed); + } + + /// @dev Used by RepoDriver + function deploy() external returns (address) { + assertUserSupported(msg.sender); + assertFalse(isDeployed, "Proxy already deployed"); + isDeployed = true; + return proxy; + } +} + +contract RepoDriverTest is Test, Events { Drips internal drips; Caller internal caller; RepoDriver internal driver; - uint256 internal driverNonce; IERC20 internal erc20; address internal admin = address(1); @@ -59,13 +200,9 @@ contract RepoDriverTest is Test { uint256 internal accountId1; uint256 internal accountId2; uint256 internal accountIdUser; + uint256 internal accountIdUnclaimed; bytes internal constant ERROR_NOT_OWNER = "Caller is not the account owner"; - bytes internal constant ERROR_ALREADY_INITIALIZED = "Already initialized"; - - uint256 internal constant CHAIN_ID_MAINNET = 1; - uint256 internal constant CHAIN_ID_GOERLI = 5; - uint256 internal constant CHAIN_ID_SEPOLIA = 11155111; function setUp() public { Drips dripsLogic = new Drips(10); @@ -73,15 +210,29 @@ contract RepoDriverTest is Test { caller = new Caller(); + address driverAddress = + vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 2); + Automate automate_ = new Automate(driverAddress); + // Make RepoDriver's driver ID non-0 to test if it's respected by RepoDriver drips.registerDriver(address(1)); drips.registerDriver(address(1)); - deployDriver(CHAIN_ID_MAINNET); + uint32 driverId = drips.registerDriver(driverAddress); + + string memory ipfsCid = "Gelato Function"; + automate_.expectIpfsCid(ipfsCid); + bytes memory data = abi.encodeCall(RepoDriver.updateGelatoTask, (ipfsCid)); - accountId = initialUpdateOwner(address(this), "this/repo1"); - accountId1 = initialUpdateOwner(address(this), "this/repo2"); - accountId2 = initialUpdateOwner(address(this), "this/repo3"); - accountIdUser = initialUpdateOwner(user, "user/repo"); + RepoDriver driverLogic = + new RepoDriver(drips, address(caller), driverId, IAutomate(address(automate_))); + driver = RepoDriver(payable(new ManagedProxy(driverLogic, admin, data))); + require(address(driver) == driverAddress, "Invalid driver address"); + + accountId = initialUpdateOwner("this/repo1", address(this)); + accountId1 = initialUpdateOwner("this/repo2", address(this)); + accountId2 = initialUpdateOwner("this/repo3", address(this)); + accountIdUser = initialUpdateOwner("user/repo", user); + accountIdUnclaimed = driver.calcAccountId(Forge.GitHub, "this/repo"); erc20 = new ERC20PresetFixedSupply("test", "test", type(uint136).max, address(this)); erc20.approve(address(driver), type(uint256).max); @@ -90,24 +241,12 @@ contract RepoDriverTest is Test { erc20.approve(address(driver), type(uint256).max); } - function deployDriver(uint256 chainId) internal { - vm.chainId(chainId); - deployDriverUninitialized(); - initializeAnyApiOperator( - OperatorInterface(address(new MockDummy())), keccak256("job ID"), 2 - ); - TestLinkToken linkToken = TestLinkToken(address(driver.linkToken())); - vm.etch(address(linkToken), address(new TestLinkToken()).code); - linkToken.mint(address(this), 100); - linkToken.mint(address(driver), 100); + function automate() internal view returns (Automate automate_) { + return Automate(address(driver.gelatoAutomate())); } - function deployDriverUninitialized() internal { - uint32 driverId = drips.registerDriver(address(this)); - RepoDriver driverLogic = new RepoDriver(drips, address(caller), driverId); - driver = RepoDriver(address(new ManagedProxy(driverLogic, admin, ""))); - drips.updateDriverAddress(driverId, address(driver)); - driverNonce = 0; + function gelatoProxy() internal view returns (address proxy) { + return automate().proxyModule().opsProxyFactory().proxy(); } function noMetadata() internal pure returns (AccountMetadata[] memory accountMetadata) { @@ -119,135 +258,124 @@ contract RepoDriverTest is Test { accountMetadata[0] = AccountMetadata("key", "value"); } - function assertAnyApiOperator( - OperatorInterface expectedOperator, - bytes32 expectedJobId, - uint96 expectedDefaultFee - ) internal { - (OperatorInterface operator, bytes32 jobId, uint96 defaultFee) = driver.anyApiOperator(); - assertEq(address(operator), address(expectedOperator), "Invalid operator after the update"); - assertEq(jobId, expectedJobId, "Invalid job ID after the update"); - assertEq(defaultFee, expectedDefaultFee, "Invalid default fee after the update"); - } - - function initializeAnyApiOperator(OperatorInterface operator, bytes32 jobId, uint96 defaultFee) - internal - { - driver.initializeAnyApiOperator(operator, jobId, defaultFee); - assertAnyApiOperator(operator, jobId, defaultFee); - } - - function updateAnyApiOperator(OperatorInterface operator, bytes32 jobId, uint96 defaultFee) + function initialUpdateOwner(bytes memory name, address owner) internal + returns (uint256 accountId_) { - vm.prank(admin); - driver.updateAnyApiOperator(operator, jobId, defaultFee); - assertAnyApiOperator(operator, jobId, defaultFee); + accountId_ = driver.calcAccountId(Forge.GitHub, name); + updateOwnerByGelato(accountId_, owner, 1, address(0)); + assertOwner(accountId_, owner); } - function initialUpdateOwner(address owner, string memory name) + function updateOwnerByGelato(uint256 accountId_, address owner, uint96 fromBlock, address payer) internal - returns (uint256 ownedAccountId) { - Forge forge = Forge.GitHub; - updateOwner( - forge, - bytes(name), - owner, - string.concat("https://raw.githubusercontent.com/", name, "/HEAD/FUNDING.json"), - "drips,ethereum,ownedBy" - ); - return driver.calcAccountId(forge, bytes(name)); + updateOwnerByGelato(accountId_, owner, fromBlock, payer, 0); } - function updateOwner( - Forge forge, - bytes memory name, + function updateOwnerByGelato( + uint256 accountId_, address owner, - string memory url, - string memory path + uint96 fromBlock, + address payer, + uint256 feeAmount ) internal { - bytes32 requestId = requestUpdateOwner(forge, name, url, path); - updateOwnerByAnyApi(requestId, owner); - assertOwner(forge, name, owner); - } - - function requestUpdateOwner( - Forge forge, - bytes memory name, - string memory url, - string memory path - ) internal returns (bytes32 requestId) { - (OperatorInterface operator,, uint96 fee) = driver.anyApiOperator(); - LinkTokenInterface linkToken = driver.linkToken(); - uint256 driverBalance = linkToken.balanceOf(address(driver)); - uint256 operatorBalance = linkToken.balanceOf(address(operator)); - - mockOperatorRequest(url, path, driverNonce, fee); - driver.requestUpdateOwner(forge, name); - vm.clearMockedCalls(); + automate().setFeeDetails(feeAmount, GELATO_NATIVE_TOKEN); + vm.prank(gelatoProxy()); + driver.updateOwnerByGelato(accountId_, owner, fromBlock, payer); + } - assertEq( - linkToken.balanceOf(address(driver)), driverBalance - fee, "Invalid driver balance" - ); - assertEq( - linkToken.balanceOf(address(operator)), - operatorBalance + fee, - "Invalid operator balance" - ); - return calcRequestId(driverNonce++); + function testUpgradeOwnerByGelato() public { + updateOwnerByGelato(accountIdUnclaimed, user, 1, address(this)); + + assertOwner(accountIdUnclaimed, user); } - function mockOperatorRequest(string memory url, string memory path, uint256 nonce, uint256 fee) - internal - { - (OperatorInterface operator, bytes32 jobId,) = driver.anyApiOperator(); - - BufferChainlink.buffer memory buffer; - buffer = BufferChainlink.init(buffer, 256); - buffer.encodeString("get"); - buffer.encodeString(url); - buffer.encodeString("path"); - buffer.encodeString(path); - - vm.mockCall( - address(operator), - abi.encodeCall( - ERC677ReceiverInterface.onTokenTransfer, - ( - address(driver), - fee, - abi.encodeCall( - OperatorInterface.operatorRequest, - ( - address(0), - 0, - jobId, - RepoDriver.updateOwnerByAnyApi.selector, - nonce, - 2, - buffer.buf - ) - ) - ) - ), - "" - ); + function testUpgradeOwnerByGelatoPaidByCommonFunds() public { + Address.sendValue(payable(driver), 3); + + updateOwnerByGelato(accountIdUnclaimed, user, 1, address(this), 2); + + assertOwner(accountIdUnclaimed, user); + assertCommonFunds(1); + assertFeeCollectorBalance(2); + } + + function testUpgradeOwnerByGelatoPaidByUserFunds() public { + driver.depositUserFunds{value: 3}(address(this)); + + updateOwnerByGelato(accountIdUnclaimed, user, 1, address(this), 2); + + assertOwner(accountIdUnclaimed, user); + assertUserFunds(address(this), 1); + assertFeeCollectorBalance(2); + } + + function testUpgradeOwnerByGelatoPaidByCommonAndUserFunds() public { + Address.sendValue(payable(driver), 2); + driver.depositUserFunds{value: 2}(address(this)); + + updateOwnerByGelato(accountIdUnclaimed, user, 1, address(this), 3); + + assertOwner(accountIdUnclaimed, user); + assertCommonFunds(1); + assertUserFunds(address(this), 0); + assertFeeCollectorBalance(3); + } + + function testUpgradeOwnerByGelatoRevertsIfNotEnoughFunds() public { + automate().setFeeDetails(1, GELATO_NATIVE_TOKEN); + vm.prank(gelatoProxy()); + vm.expectRevert("Not enough funds"); + driver.updateOwnerByGelato(accountIdUnclaimed, user, 1, address(this)); + } + + function testUpgradeOwnerByGelatoRevertsIfFeeNotInNativeTokens() public { + automate().setFeeDetails(0, address(1)); + vm.prank(gelatoProxy()); + vm.expectRevert("Payment must be in native tokens"); + driver.updateOwnerByGelato(accountIdUnclaimed, user, 1, address(this)); + } + + function testUpgradeOwnerByGelatoRevertsIfNotCalledByProxy() public { + vm.expectRevert("Callable only by Gelato"); + driver.updateOwnerByGelato(accountIdUnclaimed, user, 1, address(this)); + } + + function testUpgradeOwnerByGelatoDoesNothingIfBlockLowerThanFromBlock() public { + updateOwnerByGelato(accountIdUnclaimed, user, 2, address(this)); + assertOwner(accountIdUnclaimed, user); + updateOwnerByGelato(accountIdUnclaimed, address(this), 1, address(this)); + assertOwner(accountIdUnclaimed, user); } - function updateOwnerByAnyApi(bytes32 requestId, address owner) internal { - (OperatorInterface operator,,) = driver.anyApiOperator(); - vm.prank(address(operator)); - driver.updateOwnerByAnyApi(requestId, abi.encodePacked(owner)); + function testUpgradeOwnerByGelatoDoesNothingIfBlockEqualToFromBlock() public { + updateOwnerByGelato(accountIdUnclaimed, user, 1, address(this)); + assertOwner(accountIdUnclaimed, user); + updateOwnerByGelato(accountIdUnclaimed, address(this), 1, address(this)); + assertOwner(accountIdUnclaimed, user); } - function assertOwner(Forge forge, bytes memory name, address expectedOwner) internal { - uint256 repoAccountId = driver.calcAccountId(forge, name); - assertEq(driver.ownerOf(repoAccountId), expectedOwner, "Invalid account owner"); + function assertOwner(uint256 accountId_, address expectedOwner) internal { + assertEq(driver.ownerOf(accountId_), expectedOwner, "Invalid account owner"); } - function calcRequestId(uint256 nonce) internal view returns (bytes32 requestId) { - return keccak256(abi.encodePacked(address(driver), nonce)); + function assertCommonFunds(uint256 expectedAmt) internal { + assertEq(driver.commonFunds(), expectedAmt, "Invalid common funds amount"); + } + + function assertUserFunds(address user_, uint256 expectedAmt) internal { + assertEq(driver.userFunds(user_), expectedAmt, "Invalid user funds amount"); + } + + function assertAddressBalance(address user_, uint256 expectedAmt) internal { + assertEq(user_.balance, expectedAmt, "Invalid address balance"); + } + + function assertFeeCollectorBalance(uint256 expectedAmt) internal { + assertEq( + automate().gelato().feeCollector().balance, expectedAmt, "Invalid fee collector balance" + ); } function testAccountIdsDoNotCollideBetweenForges() public { @@ -313,216 +441,109 @@ contract RepoDriverTest is Test { assertEq(bytes32(actualAccountId), bytes32(expectedAccountId), "Invalid account ID"); } - function testUpdateAnyApiOperator() public { - (OperatorInterface operator, bytes32 jobId, uint96 defaultFee) = driver.anyApiOperator(); - OperatorInterface newOperator = OperatorInterface(address(~uint160(address(operator)))); - updateAnyApiOperator(newOperator, ~jobId, ~defaultFee); + function testUpdateGelatoTask() public { + automate().pushTaskId(hex"1234"); + string memory ipfsCid = "The new Gelato Function"; + automate().expectIpfsCid(ipfsCid); + vm.prank(admin); + driver.updateGelatoTask(ipfsCid); } - function testUpdateAnyApiOperatorRevertsIfNotCalledByAdmin() public { + function testUpdateGelatoTaskRevertsIfNotCalledByAdmin() public { + string memory ipfsCid = "The new Gelato Function"; + automate().expectIpfsCid(ipfsCid); vm.expectRevert("Caller not the admin"); - driver.updateAnyApiOperator(OperatorInterface(address(1234)), keccak256("job ID"), 123); - } - - function testInitializeAnyApiOperator() public { - deployDriverUninitialized(); - initializeAnyApiOperator(OperatorInterface(address(1234)), keccak256("job ID"), 123); + driver.updateGelatoTask(ipfsCid); } - function testInitializeAnyApiOperatorRevertsIfCalledTwice() public { - deployDriverUninitialized(); - initializeAnyApiOperator(OperatorInterface(address(1234)), keccak256("job ID"), 123); - - vm.expectRevert(ERROR_ALREADY_INITIALIZED); - driver.initializeAnyApiOperator(OperatorInterface(address(1234)), keccak256("job ID"), 123); - } - - function testInitializeAnyApiOperatorRevertsIfCalledAfterUpdate() public { - deployDriverUninitialized(); - updateAnyApiOperator(OperatorInterface(address(1234)), keccak256("job ID"), 123); - - vm.expectRevert(ERROR_ALREADY_INITIALIZED); - driver.initializeAnyApiOperator(OperatorInterface(address(1234)), keccak256("job ID"), 123); - } + function testRequestUpdateOwner() public { + Forge forge = Forge.GitHub; + bytes memory name = "this/repo"; - function testUpdateOwnerGitHubMainnet() public { - updateOwner( - Forge.GitHub, - "me/repo", - user, - "https://raw.githubusercontent.com/me/repo/HEAD/FUNDING.json", - "drips,ethereum,ownedBy" - ); + vm.expectEmit(address(driver)); + emit OwnerUpdateRequested(driver.calcAccountId(forge, name), forge, name, address(this)); + driver.requestUpdateOwner(forge, name); } - function testUpdateOwnerGitLabMainnet() public { - updateOwner( - Forge.GitLab, - "me/repo", - user, - "https://gitlab.com/me/repo/-/raw/HEAD/FUNDING.json", - "drips,ethereum,ownedBy" - ); - } + function testRequestUpdateOwnerViaForwarder() public { + Forge forge = Forge.GitHub; + bytes memory name = "this/repo"; + vm.prank(user); + caller.authorize(address(this)); + bytes memory data = abi.encodeCall(driver.requestUpdateOwner, (forge, name)); - function testUpdateOwnerGitHubGoerli() public { - deployDriver(CHAIN_ID_GOERLI); - updateOwner( - Forge.GitHub, - "me/repo", - user, - "https://raw.githubusercontent.com/me/repo/HEAD/FUNDING.json", - "drips,goerli,ownedBy" - ); + vm.expectEmit(address(driver)); + emit OwnerUpdateRequested(driver.calcAccountId(forge, name), forge, name, user); + caller.callAs(user, address(driver), data); } - function testUpdateOwnerGitHubSepolia() public { - deployDriver(CHAIN_ID_SEPOLIA); - updateOwner( - Forge.GitHub, - "me/repo", - user, - "https://raw.githubusercontent.com/me/repo/HEAD/FUNDING.json", - "drips,sepolia,ownedBy" - ); + function testReceivedNativeTokensAreAddedToCommonFunds() public { + assertCommonFunds(0); + Address.sendValue(payable(driver), 1); + assertCommonFunds(1); + Address.sendValue(payable(driver), 2); + assertCommonFunds(3); } - function testUpdateOwnerGitHubOtherChain() public { - deployDriver(1234567890); - updateOwner( - Forge.GitHub, - "me/repo", - user, - "https://raw.githubusercontent.com/me/repo/HEAD/FUNDING.json", - "drips,other,ownedBy" - ); - } + function testDepositUserFunds() public { + assertUserFunds(user, 0); - function testRequestUpdateOwnerRevertsWhenNotEnoughLink() public { - uint256 balance = driver.linkToken().balanceOf(address(driver)); - (OperatorInterface operator, bytes32 jobId,) = driver.anyApiOperator(); - updateAnyApiOperator(operator, jobId, uint96(balance) + 1); - vm.expectRevert("Link balance too low"); - driver.requestUpdateOwner(Forge.GitHub, "me/repo"); - } + driver.depositUserFunds{value: 1}(user); + assertUserFunds(user, 1); - function testRequestUpdateOwnerRevertsWhenOperatorAddressIsZero() public { - (, bytes32 jobId, uint96 fee) = driver.anyApiOperator(); - updateAnyApiOperator(OperatorInterface(address(0)), jobId, fee); - vm.expectRevert("Operator address not set"); - driver.requestUpdateOwner(Forge.GitHub, "me/repo"); + driver.depositUserFunds{value: 2}(user); + assertUserFunds(user, 3); } - function testUpdateOwnerByAnyApiRevertsIfNotCalledByTheOperator() public { - bytes32 requestId = requestUpdateOwner( - Forge.GitHub, - "me/repo", - "https://raw.githubusercontent.com/me/repo/HEAD/FUNDING.json", - "drips,ethereum,ownedBy" - ); - vm.expectRevert("Callable only by the operator"); - driver.updateOwnerByAnyApi(requestId, abi.encodePacked(user)); - } + function testWithdrawFunds() public { + driver.depositUserFunds{value: 3}(address(this)); + assertUserFunds(address(this), 3); + assertAddressBalance(admin, 0); - function testUpdateOwnerByAnyApiRevertsIfUnknownRequestId() public { - (OperatorInterface operator,,) = driver.anyApiOperator(); - vm.prank(address(operator)); - vm.expectRevert("Unknown request ID"); - driver.updateOwnerByAnyApi(keccak256("requestId"), abi.encodePacked(user)); + driver.withdrawUserFunds(2, payable(admin)); + assertUserFunds(address(this), 1); + assertAddressBalance(admin, 2); } - function testUpdateOwnerByAnyApiRevertsIfReusedRequestId() public { - bytes32 requestId = requestUpdateOwner( - Forge.GitHub, - "me/repo", - "https://raw.githubusercontent.com/me/repo/HEAD/FUNDING.json", - "drips,ethereum,ownedBy" - ); - (OperatorInterface operator,,) = driver.anyApiOperator(); + function testWithdrawFundsViaForwarder() public { + driver.depositUserFunds{value: 3}(user); + assertUserFunds(user, 3); + assertAddressBalance(user, 0); - vm.prank(address(operator)); - driver.updateOwnerByAnyApi(requestId, abi.encodePacked(user)); - - vm.prank(address(operator)); - vm.expectRevert("Unknown request ID"); - driver.updateOwnerByAnyApi(requestId, abi.encodePacked(user)); - } - - function testUpdateOwnerByAnyApiRevertsIfOwnerIsNotAddress() public { - bytes32 requestId = requestUpdateOwner( - Forge.GitHub, - "me/repo", - "https://raw.githubusercontent.com/me/repo/HEAD/FUNDING.json", - "drips,ethereum,ownedBy" - ); - (OperatorInterface operator,,) = driver.anyApiOperator(); + vm.prank(user); + caller.authorize(address(this)); + bytes memory data = abi.encodeCall(driver.withdrawUserFunds, (2, payable(admin))); - vm.prank(address(operator)); - vm.expectRevert("Invalid owner length"); - driver.updateOwnerByAnyApi(requestId, abi.encodePacked(user, uint8(0))); + caller.callAs(user, address(driver), data); + assertUserFunds(user, 1); + assertAddressBalance(admin, 2); } - function testOnTokenTransfer() public { - (OperatorInterface operator,,) = driver.anyApiOperator(); - LinkTokenInterface linkToken = driver.linkToken(); - uint256 thisBalance = linkToken.balanceOf(address(this)); - uint256 operatorBalance = linkToken.balanceOf(address(operator)); - uint256 fee = thisBalance / 2; - - mockOperatorRequest( - "https://raw.githubusercontent.com/me/repo/HEAD/FUNDING.json", - "drips,ethereum,ownedBy", - driverNonce, - fee - ); - linkToken.transferAndCall( - address(driver), - fee, - abi.encodeCall(driver.requestUpdateOwner, (Forge.GitHub, "me/repo")) - ); + function testWithdrawFundsAll() public { + driver.depositUserFunds{value: 3}(address(this)); + assertUserFunds(address(this), 3); + assertAddressBalance(admin, 0); - assertEq(linkToken.balanceOf(address(this)), thisBalance - fee, "Invalid this balance"); - assertEq( - linkToken.balanceOf(address(operator)), - operatorBalance + fee, - "Invalid operator balance" - ); - updateOwnerByAnyApi(calcRequestId(driverNonce), user); + driver.withdrawUserFunds(0, payable(admin)); + assertUserFunds(address(this), 0); + assertAddressBalance(admin, 3); } - function testOnTokenTransferRevertsIfNotLinkIsReceived() public { - TestLinkToken notLinkToken = new TestLinkToken(); - notLinkToken.mint(address(this), 1); - vm.expectRevert("Callable only by the Link token"); - notLinkToken.transferAndCall( - address(driver), 1, abi.encodeCall(driver.requestUpdateOwner, (Forge.GitHub, "me/repo")) - ); - } + function testWithdrawFundsAllWhenBalanceIsZero() public { + assertUserFunds(address(this), 0); + assertAddressBalance(admin, 0); - function testOnTokenTransferRevertsIfPayloadIsNotCalldata() public { - LinkTokenInterface linkToken = driver.linkToken(); - vm.expectRevert("Data not a valid calldata"); - linkToken.transferAndCall(address(driver), 1, "abc"); + driver.withdrawUserFunds(0, payable(admin)); + assertUserFunds(address(this), 0); + assertAddressBalance(admin, 0); } - function testOnTokenTransferRevertsIfPayloadHasInvalidSelector() public { - LinkTokenInterface linkToken = driver.linkToken(); - vm.expectRevert("Data not requestUpdateOwner"); - linkToken.transferAndCall( - address(driver), - 1, - abi.encodeWithSelector(driver.updateOwnerByAnyApi.selector, Forge.GitHub, "me/repo") - ); - } + function testWithdrawFundsRevertsWhenAmountTooHigh() public { + driver.depositUserFunds{value: 3}(address(this)); + assertUserFunds(address(this), 3); - function testOnTokenTransferRevertsIfPayloadIsNotValidCalldata() public { - LinkTokenInterface linkToken = driver.linkToken(); - vm.expectRevert(bytes("")); - linkToken.transferAndCall( - address(driver), - 1, - abi.encodeWithSelector(driver.requestUpdateOwner.selector, "me/repo") - ); + vm.expectRevert("Not enough user funds"); + driver.withdrawUserFunds(4, payable(admin)); } function testCollect() public { @@ -657,24 +678,20 @@ contract RepoDriverTest is Test { _; } - function testInitializeAnyApiOperatorCanBePaused() public canBePausedTest { - driver.initializeAnyApiOperator(OperatorInterface(address(0)), 0, 0); - } - - function testUpdateAnyApiOperatorCanBePaused() public canBePausedTest { - driver.updateAnyApiOperator(OperatorInterface(address(0)), 0, 0); - } - function testRequestUpdateOwnerCanBePaused() public canBePausedTest { driver.requestUpdateOwner(Forge.GitHub, ""); } - function testOnTokenTransferCanBePaused() public canBePausedTest { - driver.onTokenTransfer(address(0), 0, ""); + function testUpdateOwnerByGelatoCanBePaused() public canBePausedTest { + driver.updateOwnerByGelato(0, address(0), 0, address(0)); + } + + function testDepositUserFundsCanBePaused() public canBePausedTest { + driver.depositUserFunds(address(0)); } - function testUpdateOwnerByAnyApiCanBePaused() public canBePausedTest { - driver.updateOwnerByAnyApi(0, ""); + function testWithdrawUserFundsCanBePaused() public canBePausedTest { + driver.withdrawUserFunds(0, payable(0)); } function testCollectCanBePaused() public canBePausedTest {