From 8134dcff15c6773e529fe7656b3941f2667a5c39 Mon Sep 17 00:00:00 2001 From: "Essam A. Hassan" Date: Thu, 19 Oct 2023 15:51:12 +0200 Subject: [PATCH] upgrade opfwdr contracts to v0.8 and implement multiforward (#10933) * upgrade opfwdr contracts to v0.8 * move dirs, port tests * fix test path * HH test suite pass on v0.8 * add tests for multiforward * prettify * fix linting issues * undo bad contract copy * address review comments * prettier:write * fix pragma * rm empty lines * name mapping k,v --- .../shared/interfaces/OwnableInterface.sol | 10 + .../AuthorizedReceiverInterface.sol | 10 + .../src/v0.8/interfaces/OperatorInterface.sol | 6 - .../src/v0.8/interfaces/OracleInterface.sol | 2 - .../dev/AuthorizedForwarder.sol | 92 + .../dev/AuthorizedReceiver.sol | 65 + .../dev/LinkTokenReceiver.sol | 50 + .../v0.8/operatorforwarder/dev/Operator.sol | 507 +++ .../operatorforwarder/dev/OperatorFactory.sol | 74 + .../dev/interfaces/WithdrawalInterface.sol | 13 + .../AuthorizedForwarder.test.ts | 724 ++++ .../operatorforwarder/ConfirmedOwner.test.ts | 136 + .../v0.8/operatorforwarder/Operator.test.ts | 3819 +++++++++++++++++ .../operatorforwarder/OperatorFactory.test.ts | 293 ++ 14 files changed, 5793 insertions(+), 8 deletions(-) create mode 100644 contracts/src/v0.8/dev/shared/interfaces/OwnableInterface.sol create mode 100644 contracts/src/v0.8/interfaces/AuthorizedReceiverInterface.sol create mode 100644 contracts/src/v0.8/operatorforwarder/dev/AuthorizedForwarder.sol create mode 100644 contracts/src/v0.8/operatorforwarder/dev/AuthorizedReceiver.sol create mode 100644 contracts/src/v0.8/operatorforwarder/dev/LinkTokenReceiver.sol create mode 100644 contracts/src/v0.8/operatorforwarder/dev/Operator.sol create mode 100644 contracts/src/v0.8/operatorforwarder/dev/OperatorFactory.sol create mode 100644 contracts/src/v0.8/operatorforwarder/dev/interfaces/WithdrawalInterface.sol create mode 100644 contracts/test/v0.8/operatorforwarder/AuthorizedForwarder.test.ts create mode 100644 contracts/test/v0.8/operatorforwarder/ConfirmedOwner.test.ts create mode 100644 contracts/test/v0.8/operatorforwarder/Operator.test.ts create mode 100644 contracts/test/v0.8/operatorforwarder/OperatorFactory.test.ts diff --git a/contracts/src/v0.8/dev/shared/interfaces/OwnableInterface.sol b/contracts/src/v0.8/dev/shared/interfaces/OwnableInterface.sol new file mode 100644 index 00000000000..a24cbee504c --- /dev/null +++ b/contracts/src/v0.8/dev/shared/interfaces/OwnableInterface.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface OwnableInterface { + function owner() external returns (address); + + function transferOwnership(address recipient) external; + + function acceptOwnership() external; +} diff --git a/contracts/src/v0.8/interfaces/AuthorizedReceiverInterface.sol b/contracts/src/v0.8/interfaces/AuthorizedReceiverInterface.sol new file mode 100644 index 00000000000..28b20b14f33 --- /dev/null +++ b/contracts/src/v0.8/interfaces/AuthorizedReceiverInterface.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface AuthorizedReceiverInterface { + function isAuthorizedSender(address sender) external view returns (bool); + + function getAuthorizedSenders() external returns (address[] memory); + + function setAuthorizedSenders(address[] calldata senders) external; +} diff --git a/contracts/src/v0.8/interfaces/OperatorInterface.sol b/contracts/src/v0.8/interfaces/OperatorInterface.sol index 668c167cf4b..4114cce16d3 100644 --- a/contracts/src/v0.8/interfaces/OperatorInterface.sol +++ b/contracts/src/v0.8/interfaces/OperatorInterface.sol @@ -27,10 +27,4 @@ interface OperatorInterface is OracleInterface, ChainlinkRequestInterface { function ownerTransferAndCall(address to, uint256 value, bytes calldata data) external returns (bool success); function distributeFunds(address payable[] calldata receivers, uint256[] calldata amounts) external payable; - - function getAuthorizedSenders() external returns (address[] memory); - - function setAuthorizedSenders(address[] calldata senders) external; - - function getForwarder() external returns (address); } diff --git a/contracts/src/v0.8/interfaces/OracleInterface.sol b/contracts/src/v0.8/interfaces/OracleInterface.sol index c3a9c5edfb4..40365822e10 100644 --- a/contracts/src/v0.8/interfaces/OracleInterface.sol +++ b/contracts/src/v0.8/interfaces/OracleInterface.sol @@ -11,8 +11,6 @@ interface OracleInterface { bytes32 data ) external returns (bool); - function isAuthorizedSender(address node) external view returns (bool); - function withdraw(address recipient, uint256 amount) external; function withdrawable() external view returns (uint256); diff --git a/contracts/src/v0.8/operatorforwarder/dev/AuthorizedForwarder.sol b/contracts/src/v0.8/operatorforwarder/dev/AuthorizedForwarder.sol new file mode 100644 index 00000000000..801f7539b6c --- /dev/null +++ b/contracts/src/v0.8/operatorforwarder/dev/AuthorizedForwarder.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {ConfirmedOwnerWithProposal} from "../../shared/access/ConfirmedOwnerWithProposal.sol"; +import {AuthorizedReceiver} from "./AuthorizedReceiver.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; + +// solhint-disable custom-errors +contract AuthorizedForwarder is ConfirmedOwnerWithProposal, AuthorizedReceiver { + using Address for address; + + // solhint-disable-next-line chainlink-solidity/prefix-immutable-variables-with-i + address public immutable linkToken; + + event OwnershipTransferRequestedWithMessage(address indexed from, address indexed to, bytes message); + + constructor( + address link, + address owner, + address recipient, + bytes memory message + ) ConfirmedOwnerWithProposal(owner, recipient) { + require(link != address(0), "Link token cannot be a zero address"); + linkToken = link; + if (recipient != address(0)) { + emit OwnershipTransferRequestedWithMessage(owner, recipient, message); + } + } + + // solhint-disable-next-line chainlink-solidity/all-caps-constant-storage-variables + string public constant typeAndVersion = "AuthorizedForwarder 1.0.0"; + + // @notice Forward a call to another contract + // @dev Only callable by an authorized sender + // @param to address + // @param data to forward + function forward(address to, bytes calldata data) external validateAuthorizedSender { + require(to != linkToken, "Cannot forward to Link token"); + _forward(to, data); + } + + // @notice Forward multiple calls to other contracts in a multicall style + // @dev Only callable by an authorized sender + // @param tos An array of addresses to forward the calls to + // @param datas An array of data to forward to each corresponding address + function multiForward(address[] calldata tos, bytes[] calldata datas) external validateAuthorizedSender { + require(tos.length == datas.length, "Arrays must have the same length"); + + for (uint256 i = 0; i < tos.length; ++i) { + address to = tos[i]; + require(to != linkToken, "Cannot forward to Link token"); + + // Perform the forward operation + _forward(to, datas[i]); + } + } + + // @notice Forward a call to another contract + // @dev Only callable by the owner + // @param to address + // @param data to forward + function ownerForward(address to, bytes calldata data) external onlyOwner { + _forward(to, data); + } + + // @notice Transfer ownership with instructions for recipient + // @param to address proposed recipient of ownership + // @param message instructions for recipient upon accepting ownership + function transferOwnershipWithMessage(address to, bytes calldata message) external { + transferOwnership(to); + emit OwnershipTransferRequestedWithMessage(msg.sender, to, message); + } + + // @notice concrete implementation of AuthorizedReceiver + // @return bool of whether sender is authorized + function _canSetAuthorizedSenders() internal view override returns (bool) { + return owner() == msg.sender; + } + + // @notice common forwarding functionality and validation + function _forward(address to, bytes calldata data) private { + require(to.isContract(), "Must forward to a contract"); + // solhint-disable-next-line avoid-low-level-calls + (bool success, bytes memory result) = to.call(data); + if (!success) { + if (result.length == 0) revert("Forwarded call reverted without reason"); + assembly { + revert(add(32, result), mload(result)) + } + } + } +} diff --git a/contracts/src/v0.8/operatorforwarder/dev/AuthorizedReceiver.sol b/contracts/src/v0.8/operatorforwarder/dev/AuthorizedReceiver.sol new file mode 100644 index 00000000000..549033509c4 --- /dev/null +++ b/contracts/src/v0.8/operatorforwarder/dev/AuthorizedReceiver.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {AuthorizedReceiverInterface} from "../../interfaces/AuthorizedReceiverInterface.sol"; + +// solhint-disable custom-errors +abstract contract AuthorizedReceiver is AuthorizedReceiverInterface { + mapping(address sender => bool authorized) private s_authorizedSenders; + address[] private s_authorizedSenderList; + + event AuthorizedSendersChanged(address[] senders, address changedBy); + + // @notice Sets the fulfillment permission for a given node. Use `true` to allow, `false` to disallow. + // @param senders The addresses of the authorized Chainlink node + function setAuthorizedSenders(address[] calldata senders) external override validateAuthorizedSenderSetter { + require(senders.length > 0, "Must have at least 1 sender"); + // Set previous authorized senders to false + uint256 authorizedSendersLength = s_authorizedSenderList.length; + for (uint256 i = 0; i < authorizedSendersLength; i++) { + s_authorizedSenders[s_authorizedSenderList[i]] = false; + } + // Set new to true + for (uint256 i = 0; i < senders.length; i++) { + require(s_authorizedSenders[senders[i]] == false, "Must not have duplicate senders"); + s_authorizedSenders[senders[i]] = true; + } + // Replace list + s_authorizedSenderList = senders; + emit AuthorizedSendersChanged(senders, msg.sender); + } + + // @notice Retrieve a list of authorized senders + // @return array of addresses + function getAuthorizedSenders() external view override returns (address[] memory) { + return s_authorizedSenderList; + } + + // @notice Use this to check if a node is authorized for fulfilling requests + // @param sender The address of the Chainlink node + // @return The authorization status of the node + function isAuthorizedSender(address sender) public view override returns (bool) { + return s_authorizedSenders[sender]; + } + + // @notice customizable guard of who can update the authorized sender list + // @return bool whether sender can update authorized sender list + function _canSetAuthorizedSenders() internal virtual returns (bool); + + // @notice validates the sender is an authorized sender + function _validateIsAuthorizedSender() internal view { + require(isAuthorizedSender(msg.sender), "Not authorized sender"); + } + + // @notice prevents non-authorized addresses from calling this method + modifier validateAuthorizedSender() { + _validateIsAuthorizedSender(); + _; + } + + // @notice prevents non-authorized addresses from calling this method + modifier validateAuthorizedSenderSetter() { + require(_canSetAuthorizedSenders(), "Cannot set authorized senders"); + _; + } +} diff --git a/contracts/src/v0.8/operatorforwarder/dev/LinkTokenReceiver.sol b/contracts/src/v0.8/operatorforwarder/dev/LinkTokenReceiver.sol new file mode 100644 index 00000000000..cfde9a4d583 --- /dev/null +++ b/contracts/src/v0.8/operatorforwarder/dev/LinkTokenReceiver.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +// solhint-disable custom-errors +abstract contract LinkTokenReceiver { + // @notice Called when LINK is sent to the contract via `transferAndCall` + // @dev The data payload's first 2 words will be overwritten by the `sender` and `amount` + // values to ensure correctness. Calls oracleRequest. + // @param sender Address of the sender + // @param amount Amount of LINK sent (specified in wei) + // @param data Payload of the transaction + function onTokenTransfer( + address sender, + uint256 amount, + bytes memory data + ) public validateFromLINK permittedFunctionsForLINK(data) { + assembly { + // solhint-disable-next-line avoid-low-level-calls + mstore(add(data, 36), sender) // ensure correct sender is passed + // solhint-disable-next-line avoid-low-level-calls + mstore(add(data, 68), amount) // ensure correct amount is passed0.8.19 + } + // solhint-disable-next-line avoid-low-level-calls + (bool success, ) = address(this).delegatecall(data); // calls oracleRequest + require(success, "Unable to create request"); + } + + function getChainlinkToken() public view virtual returns (address); + + // @notice Validate the function called on token transfer + function _validateTokenTransferAction(bytes4 funcSelector, bytes memory data) internal virtual; + + // @dev Reverts if not sent from the LINK token + modifier validateFromLINK() { + require(msg.sender == getChainlinkToken(), "Must use LINK token"); + _; + } + + // @dev Reverts if the given data does not begin with the `oracleRequest` function selector + // @param data The data payload of the request + modifier permittedFunctionsForLINK(bytes memory data) { + bytes4 funcSelector; + assembly { + // solhint-disable-next-line avoid-low-level-calls + funcSelector := mload(add(data, 32)) + } + _validateTokenTransferAction(funcSelector, data); + _; + } +} diff --git a/contracts/src/v0.8/operatorforwarder/dev/Operator.sol b/contracts/src/v0.8/operatorforwarder/dev/Operator.sol new file mode 100644 index 00000000000..02a306f6e4a --- /dev/null +++ b/contracts/src/v0.8/operatorforwarder/dev/Operator.sol @@ -0,0 +1,507 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {AuthorizedReceiver} from "./AuthorizedReceiver.sol"; +import {LinkTokenReceiver} from "./LinkTokenReceiver.sol"; +import {ConfirmedOwner} from "../../shared/access/ConfirmedOwner.sol"; +import {LinkTokenInterface} from "../../shared/interfaces/LinkTokenInterface.sol"; +import {AuthorizedReceiverInterface} from "../../interfaces/AuthorizedReceiverInterface.sol"; +import {OperatorInterface} from "../../interfaces/OperatorInterface.sol"; +import {IOwnable} from "../../shared/interfaces/IOwnable.sol"; +import {WithdrawalInterface} from "./interfaces/WithdrawalInterface.sol"; +import {OracleInterface} from "../../interfaces/OracleInterface.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {SafeCast} from "../../vendor/openzeppelin-solidity/v4.8.0/contracts/utils/math/SafeCast.sol"; + +// @title The Chainlink Operator contract +// @notice Node operators can deploy this contract to fulfill requests sent to them +// solhint-disable custom-errors +contract Operator is AuthorizedReceiver, ConfirmedOwner, LinkTokenReceiver, OperatorInterface, WithdrawalInterface { + using Address for address; + + struct Commitment { + bytes31 paramsHash; + uint8 dataVersion; + } + + uint256 public constant EXPIRYTIME = 5 minutes; + uint256 private constant MAXIMUM_DATA_VERSION = 256; + uint256 private constant MINIMUM_CONSUMER_GAS_LIMIT = 400000; + uint256 private constant SELECTOR_LENGTH = 4; + uint256 private constant EXPECTED_REQUEST_WORDS = 2; + uint256 private constant MINIMUM_REQUEST_LENGTH = SELECTOR_LENGTH + (32 * EXPECTED_REQUEST_WORDS); + // We initialize fields to 1 instead of 0 so that the first invocation + // does not cost more gas. + uint256 private constant ONE_FOR_CONSISTENT_GAS_COST = 1; + // oracleRequest is intended for version 1, enabling single word responses + bytes4 private constant ORACLE_REQUEST_SELECTOR = this.oracleRequest.selector; + // operatorRequest is intended for version 2, enabling multi-word responses + bytes4 private constant OPERATOR_REQUEST_SELECTOR = this.operatorRequest.selector; + + LinkTokenInterface internal immutable i_linkToken; + mapping(bytes32 => Commitment) private s_commitments; + mapping(address => bool) private s_owned; + // Tokens sent for requests that have not been fulfilled yet + uint256 private s_tokensInEscrow = ONE_FOR_CONSISTENT_GAS_COST; + + event OracleRequest( + bytes32 indexed specId, + address requester, + bytes32 requestId, + uint256 payment, + address callbackAddr, + bytes4 callbackFunctionId, + uint256 cancelExpiration, + uint256 dataVersion, + bytes data + ); + + event CancelOracleRequest(bytes32 indexed requestId); + + event OracleResponse(bytes32 indexed requestId); + + event OwnableContractAccepted(address indexed acceptedContract); + + event TargetsUpdatedAuthorizedSenders(address[] targets, address[] senders, address changedBy); + + // @notice Deploy with the address of the LINK token + // @dev Sets the LinkToken address for the imported LinkTokenInterface + // @param link The address of the LINK token + // @param owner The address of the owner + constructor(address link, address owner) ConfirmedOwner(owner) { + i_linkToken = LinkTokenInterface(link); // external but already deployed and unalterable + } + + // solhint-disable-next-line chainlink-solidity/all-caps-constant-storage-variables + string public constant typeAndVersion = "Operator 1.0.0"; + + // @notice Creates the Chainlink request. This is a backwards compatible API + // with the Oracle.sol contract, but the behavior changes because + // callbackAddress is assumed to be the same as the request sender. + // @param callbackAddress The consumer of the request + // @param payment The amount of payment given (specified in wei) + // @param specId The Job Specification ID + // @param callbackAddress The address the oracle data will be sent to + // @param callbackFunctionId The callback function ID for the response + // @param nonce The nonce sent by the requester + // @param dataVersion The specified data version + // @param data The extra request parameters + function oracleRequest( + address sender, + uint256 payment, + bytes32 specId, + address callbackAddress, + bytes4 callbackFunctionId, + uint256 nonce, + uint256 dataVersion, + bytes calldata data + ) external override validateFromLINK { + (bytes32 requestId, uint256 expiration) = _verifyAndProcessOracleRequest( + sender, + payment, + callbackAddress, + callbackFunctionId, + nonce, + dataVersion + ); + emit OracleRequest(specId, sender, requestId, payment, sender, callbackFunctionId, expiration, dataVersion, data); + } + + // @notice Creates the Chainlink request + // @dev Stores the hash of the params as the on-chain commitment for the request. + // Emits OracleRequest event for the Chainlink node to detect. + // @param sender The sender of the request + // @param payment The amount of payment given (specified in wei) + // @param specId The Job Specification ID + // @param callbackFunctionId The callback function ID for the response + // @param nonce The nonce sent by the requester + // @param dataVersion The specified data version + // @param data The extra request parameters + function operatorRequest( + address sender, + uint256 payment, + bytes32 specId, + bytes4 callbackFunctionId, + uint256 nonce, + uint256 dataVersion, + bytes calldata data + ) external override validateFromLINK { + (bytes32 requestId, uint256 expiration) = _verifyAndProcessOracleRequest( + sender, + payment, + sender, + callbackFunctionId, + nonce, + dataVersion + ); + emit OracleRequest(specId, sender, requestId, payment, sender, callbackFunctionId, expiration, dataVersion, data); + } + + // @notice Called by the Chainlink node to fulfill requests + // @dev Given params must hash back to the commitment stored from `oracleRequest`. + // Will call the callback address' callback function without bubbling up error + // checking in a `require` so that the node can get paid. + // @param requestId The fulfillment request ID that must match the requester's + // @param payment The payment amount that will be released for the oracle (specified in wei) + // @param callbackAddress The callback address to call for fulfillment + // @param callbackFunctionId The callback function ID to use for fulfillment + // @param expiration The expiration that the node should respond by before the requester can cancel + // @param data The data to return to the consuming contract + // @return Status if the external call was successful + function fulfillOracleRequest( + bytes32 requestId, + uint256 payment, + address callbackAddress, + bytes4 callbackFunctionId, + uint256 expiration, + bytes32 data + ) + external + override + validateAuthorizedSender + validateRequestId(requestId) + validateCallbackAddress(callbackAddress) + returns (bool) + { + _verifyOracleRequestAndProcessPayment(requestId, payment, callbackAddress, callbackFunctionId, expiration, 1); + emit OracleResponse(requestId); + require(gasleft() >= MINIMUM_CONSUMER_GAS_LIMIT, "Must provide consumer enough gas"); + // All updates to the oracle's fulfillment should come before calling the + // callback(addr+functionId) as it is untrusted. + // See: https://solidity.readthedocs.io/en/develop/security-considerations.html#use-the-checks-effects-interactions-pattern + (bool success, ) = callbackAddress.call(abi.encodeWithSelector(callbackFunctionId, requestId, data)); // solhint-disable-line avoid-low-level-calls + return success; + } + + // @notice Called by the Chainlink node to fulfill requests with multi-word support + // @dev Given params must hash back to the commitment stored from `oracleRequest`. + // Will call the callback address' callback function without bubbling up error + // checking in a `require` so that the node can get paid. + // @param requestId The fulfillment request ID that must match the requester's + // @param payment The payment amount that will be released for the oracle (specified in wei) + // @param callbackAddress The callback address to call for fulfillment + // @param callbackFunctionId The callback function ID to use for fulfillment + // @param expiration The expiration that the node should respond by before the requester can cancel + // @param data The data to return to the consuming contract + // @return Status if the external call was successful + function fulfillOracleRequest2( + bytes32 requestId, + uint256 payment, + address callbackAddress, + bytes4 callbackFunctionId, + uint256 expiration, + bytes calldata data + ) + external + override + validateAuthorizedSender + validateRequestId(requestId) + validateCallbackAddress(callbackAddress) + validateMultiWordResponseId(requestId, data) + returns (bool) + { + _verifyOracleRequestAndProcessPayment(requestId, payment, callbackAddress, callbackFunctionId, expiration, 2); + emit OracleResponse(requestId); + require(gasleft() >= MINIMUM_CONSUMER_GAS_LIMIT, "Must provide consumer enough gas"); + // All updates to the oracle's fulfillment should come before calling the + // callback(addr+functionId) as it is untrusted. + // See: https://solidity.readthedocs.io/en/develop/security-considerations.html#use-the-checks-effects-interactions-pattern + (bool success, ) = callbackAddress.call(abi.encodePacked(callbackFunctionId, data)); // solhint-disable-line avoid-low-level-calls + return success; + } + + // @notice Transfer the ownership of ownable contracts. This is primarily + // intended for Authorized Forwarders but could possibly be extended to work + // with future contracts.OracleInterface + // @param ownable list of addresses to transfer + // @param newOwner address to transfer ownership to + function transferOwnableContracts(address[] calldata ownable, address newOwner) external onlyOwner { + for (uint256 i = 0; i < ownable.length; i++) { + s_owned[ownable[i]] = false; + IOwnable(ownable[i]).transferOwnership(newOwner); + } + } + + // @notice Accept the ownership of an ownable contract. This is primarily + // intended for Authorized Forwarders but could possibly be extended to work + // with future contracts. + // @dev Must be the pending owner on the contract + // @param ownable list of addresses of Ownable contracts to accept + function acceptOwnableContracts(address[] calldata ownable) public validateAuthorizedSenderSetter { + for (uint256 i = 0; i < ownable.length; i++) { + s_owned[ownable[i]] = true; + emit OwnableContractAccepted(ownable[i]); + IOwnable(ownable[i]).acceptOwnership(); + } + } + + // @notice Sets the fulfillment permission for + // @param targets The addresses to set permissions on + // @param senders The addresses that are allowed to send updates + function setAuthorizedSendersOn( + address[] calldata targets, + address[] calldata senders + ) public validateAuthorizedSenderSetter { + emit TargetsUpdatedAuthorizedSenders(targets, senders, msg.sender); + + for (uint256 i = 0; i < targets.length; i++) { + AuthorizedReceiverInterface(targets[i]).setAuthorizedSenders(senders); + } + } + + // @notice Accepts ownership of ownable contracts and then immediately sets + // the authorized sender list on each of the newly owned contracts. This is + // primarily intended for Authorized Forwarders but could possibly be + // extended to work with future contracts. + // @param targets The addresses to set permissions on + // @param senders The addresses that are allowed to send updates + function acceptAuthorizedReceivers( + address[] calldata targets, + address[] calldata senders + ) external validateAuthorizedSenderSetter { + acceptOwnableContracts(targets); + setAuthorizedSendersOn(targets, senders); + } + + // @notice Allows the node operator to withdraw earned LINK to a given address + // @dev The owner of the contract can be another wallet and does not have to be a Chainlink node + // @param recipient The address to send the LINK token to + // @param amount The amount to send (specified in wei) + function withdraw( + address recipient, + uint256 amount + ) external override(OracleInterface, WithdrawalInterface) onlyOwner validateAvailableFunds(amount) { + assert(i_linkToken.transfer(recipient, amount)); + } + + // @notice Displays the amount of LINK that is available for the node operator to withdraw + // @dev We use `ONE_FOR_CONSISTENT_GAS_COST` in place of 0 in storage + // @return The amount of withdrawable LINK on the contract + function withdrawable() external view override(OracleInterface, WithdrawalInterface) returns (uint256) { + return _fundsAvailable(); + } + + // @notice Forward a call to another contract + // @dev Only callable by the owner + // @param to address + // @param data to forward + function ownerForward(address to, bytes calldata data) external onlyOwner validateNotToLINK(to) { + require(to.isContract(), "Must forward to a contract"); + // solhint-disable-next-line avoid-low-level-calls + (bool status, ) = to.call(data); + require(status, "Forwarded call failed"); + } + + // @notice Interact with other LinkTokenReceiver contracts by calling transferAndCall + // @param to The address to transfer to. + // @param value The amount to be transferred. + // @param data The extra data to be passed to the receiving contract. + // @return success bool + function ownerTransferAndCall( + address to, + uint256 value, + bytes calldata data + ) external override onlyOwner validateAvailableFunds(value) returns (bool success) { + return i_linkToken.transferAndCall(to, value, data); + } + + // @notice Distribute funds to multiple addresses using ETH send + // to this payable function. + // @dev Array length must be equal, ETH sent must equal the sum of amounts. + // A malicious receiver could cause the distribution to revert, in which case + // it is expected that the address is removed from the list. + // @param receivers list of addresses + // @param amounts list of amounts + function distributeFunds(address payable[] calldata receivers, uint256[] calldata amounts) external payable { + require(receivers.length > 0 && receivers.length == amounts.length, "Invalid array length(s)"); + uint256 valueRemaining = msg.value; + for (uint256 i = 0; i < receivers.length; i++) { + uint256 sendAmount = amounts[i]; + valueRemaining = valueRemaining - sendAmount; + receivers[i].transfer(sendAmount); + } + require(valueRemaining == 0, "Too much ETH sent"); + } + + // @notice Allows recipient to cancel requests sent to this oracle contract. + // Will transfer the LINK sent for the request back to the recipient address. + // @dev Given params must hash to a commitment stored on the contract in order + // for the request to be valid. Emits CancelOracleRequest event. + // @param requestId The request ID + // @param payment The amount of payment given (specified in wei) + // @param callbackFunc The requester's specified callback function selector + // @param expiration The time of the expiration for the request + function cancelOracleRequest( + bytes32 requestId, + uint256 payment, + bytes4 callbackFunc, + uint256 expiration + ) external override { + bytes31 paramsHash = _buildParamsHash(payment, msg.sender, callbackFunc, expiration); + require(s_commitments[requestId].paramsHash == paramsHash, "Params do not match request ID"); + // solhint-disable-next-line not-rely-on-time + require(expiration <= block.timestamp, "Request is not expired"); + + delete s_commitments[requestId]; + emit CancelOracleRequest(requestId); + + i_linkToken.transfer(msg.sender, payment); + } + + // @notice Allows requester to cancel requests sent to this oracle contract. + // Will transfer the LINK sent for the request back to the recipient address. + // @dev Given params must hash to a commitment stored on the contract in order + // for the request to be valid. Emits CancelOracleRequest event. + // @param nonce The nonce used to generate the request ID + // @param payment The amount of payment given (specified in wei) + // @param callbackFunc The requester's specified callback function selector + // @param expiration The time of the expiration for the request + function cancelOracleRequestByRequester( + uint256 nonce, + uint256 payment, + bytes4 callbackFunc, + uint256 expiration + ) external { + bytes32 requestId = keccak256(abi.encodePacked(msg.sender, nonce)); + bytes31 paramsHash = _buildParamsHash(payment, msg.sender, callbackFunc, expiration); + require(s_commitments[requestId].paramsHash == paramsHash, "Params do not match request ID"); + // solhint-disable-next-line not-rely-on-time + require(expiration <= block.timestamp, "Request is not expired"); + + delete s_commitments[requestId]; + emit CancelOracleRequest(requestId); + + i_linkToken.transfer(msg.sender, payment); + } + + // @notice Returns the address of the LINK token + // @dev This is the public implementation for chainlinkTokenAddress, which is + // an internal method of the ChainlinkClient contract + function getChainlinkToken() public view override returns (address) { + return address(i_linkToken); + } + + // @notice Require that the token transfer action is valid + // @dev OPERATOR_REQUEST_SELECTOR = multiword, ORACLE_REQUEST_SELECTOR = singleword + function _validateTokenTransferAction(bytes4 funcSelector, bytes memory data) internal pure override { + require(data.length >= MINIMUM_REQUEST_LENGTH, "Invalid request length"); + require( + funcSelector == OPERATOR_REQUEST_SELECTOR || funcSelector == ORACLE_REQUEST_SELECTOR, + "Must use whitelisted functions" + ); + } + + // @notice Verify the Oracle Request and record necessary information + // @param sender The sender of the request + // @param payment The amount of payment given (specified in wei) + // @param callbackAddress The callback address for the response + // @param callbackFunctionId The callback function ID for the response + // @param nonce The nonce sent by the requester + function _verifyAndProcessOracleRequest( + address sender, + uint256 payment, + address callbackAddress, + bytes4 callbackFunctionId, + uint256 nonce, + uint256 dataVersion + ) private validateNotToLINK(callbackAddress) returns (bytes32 requestId, uint256 expiration) { + requestId = keccak256(abi.encodePacked(sender, nonce)); + require(s_commitments[requestId].paramsHash == 0, "Must use a unique ID"); + // solhint-disable-next-line not-rely-on-time + expiration = block.timestamp + EXPIRYTIME; + bytes31 paramsHash = _buildParamsHash(payment, callbackAddress, callbackFunctionId, expiration); + s_commitments[requestId] = Commitment(paramsHash, SafeCast.toUint8(dataVersion)); + s_tokensInEscrow = s_tokensInEscrow + payment; + return (requestId, expiration); + } + + // @notice Verify the Oracle request and unlock escrowed payment + // @param requestId The fulfillment request ID that must match the requester's + // @param payment The payment amount that will be released for the oracle (specified in wei) + // @param callbackAddress The callback address to call for fulfillment + // @param callbackFunctionId The callback function ID to use for fulfillment + // @param expiration The expiration that the node should respond by before the requester can cancel + function _verifyOracleRequestAndProcessPayment( + bytes32 requestId, + uint256 payment, + address callbackAddress, + bytes4 callbackFunctionId, + uint256 expiration, + uint256 dataVersion + ) internal { + bytes31 paramsHash = _buildParamsHash(payment, callbackAddress, callbackFunctionId, expiration); + require(s_commitments[requestId].paramsHash == paramsHash, "Params do not match request ID"); + require(s_commitments[requestId].dataVersion <= SafeCast.toUint8(dataVersion), "Data versions must match"); + s_tokensInEscrow = s_tokensInEscrow - payment; + delete s_commitments[requestId]; + } + + // @notice Build the bytes31 hash from the payment, callback and expiration. + // @param payment The payment amount that will be released for the oracle (specified in wei) + // @param callbackAddress The callback address to call for fulfillment + // @param callbackFunctionId The callback function ID to use for fulfillment + // @param expiration The expiration that the node should respond by before the requester can cancel + // @return hash bytes31 + function _buildParamsHash( + uint256 payment, + address callbackAddress, + bytes4 callbackFunctionId, + uint256 expiration + ) internal pure returns (bytes31) { + return bytes31(keccak256(abi.encodePacked(payment, callbackAddress, callbackFunctionId, expiration))); + } + + // @notice Returns the LINK available in this contract, not locked in escrow + // @return uint256 LINK tokens available + function _fundsAvailable() private view returns (uint256) { + return i_linkToken.balanceOf(address(this)) - (s_tokensInEscrow - ONE_FOR_CONSISTENT_GAS_COST); + } + + // @notice concrete implementation of AuthorizedReceiver + // @return bool of whether sender is authorized + function _canSetAuthorizedSenders() internal view override returns (bool) { + return isAuthorizedSender(msg.sender) || owner() == msg.sender; + } + + // MODIFIERS + + // @dev Reverts if the first 32 bytes of the bytes array is not equal to requestId + // @param requestId bytes32 + // @param data bytes + modifier validateMultiWordResponseId(bytes32 requestId, bytes calldata data) { + require(data.length >= 32, "Response must be > 32 bytes"); + bytes32 firstDataWord; + assembly { + firstDataWord := calldataload(data.offset) + } + require(requestId == firstDataWord, "First word must be requestId"); + _; + } + + // @dev Reverts if amount requested is greater than withdrawable balance + // @param amount The given amount to compare to `s_withdrawableTokens` + modifier validateAvailableFunds(uint256 amount) { + require(_fundsAvailable() >= amount, "Amount requested is greater than withdrawable balance"); + _; + } + + // @dev Reverts if request ID does not exist + // @param requestId The given request ID to check in stored `commitments` + modifier validateRequestId(bytes32 requestId) { + require(s_commitments[requestId].paramsHash != 0, "Must have a valid requestId"); + _; + } + + // @dev Reverts if the callback address is the LINK token + // @param to The callback address + modifier validateNotToLINK(address to) { + require(to != address(i_linkToken), "Cannot call to LINK"); + _; + } + + // @dev Reverts if the target address is owned by the operator + modifier validateCallbackAddress(address callbackAddress) { + require(!s_owned[callbackAddress], "Cannot call owned contract"); + _; + } +} diff --git a/contracts/src/v0.8/operatorforwarder/dev/OperatorFactory.sol b/contracts/src/v0.8/operatorforwarder/dev/OperatorFactory.sol new file mode 100644 index 00000000000..62ace2451c5 --- /dev/null +++ b/contracts/src/v0.8/operatorforwarder/dev/OperatorFactory.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {Operator} from "./Operator.sol"; +import {AuthorizedForwarder} from "./AuthorizedForwarder.sol"; + +// @title Operator Factory +// @notice Creates Operator contracts for node operators +// solhint-disable custom-errors +contract OperatorFactory { + // solhint-disable-next-line chainlink-solidity/prefix-immutable-variables-with-i + address public immutable linkToken; + mapping(address => bool) private s_created; + + event OperatorCreated(address indexed operator, address indexed owner, address indexed sender); + event AuthorizedForwarderCreated(address indexed forwarder, address indexed owner, address indexed sender); + + // @param linkAddress address + constructor(address linkAddress) { + linkToken = linkAddress; + } + + // solhint-disable-next-line chainlink-solidity/all-caps-constant-storage-variables + string public constant typeAndVersion = "OperatorFactory 1.0.0"; + + // @notice creates a new Operator contract with the msg.sender as owner + function deployNewOperator() external returns (address) { + Operator operator = new Operator(linkToken, msg.sender); + + s_created[address(operator)] = true; + emit OperatorCreated(address(operator), msg.sender, msg.sender); + + return address(operator); + } + + // @notice creates a new Operator contract with the msg.sender as owner and a + // new Operator Forwarder with the Operator as the owner + function deployNewOperatorAndForwarder() external returns (address, address) { + Operator operator = new Operator(linkToken, msg.sender); + s_created[address(operator)] = true; + emit OperatorCreated(address(operator), msg.sender, msg.sender); + + AuthorizedForwarder forwarder = new AuthorizedForwarder(linkToken, address(this), address(operator), new bytes(0)); + s_created[address(forwarder)] = true; + emit AuthorizedForwarderCreated(address(forwarder), address(this), msg.sender); + + return (address(operator), address(forwarder)); + } + + // @notice creates a new Forwarder contract with the msg.sender as owner + function deployNewForwarder() external returns (address) { + AuthorizedForwarder forwarder = new AuthorizedForwarder(linkToken, msg.sender, address(0), new bytes(0)); + + s_created[address(forwarder)] = true; + emit AuthorizedForwarderCreated(address(forwarder), msg.sender, msg.sender); + + return address(forwarder); + } + + // @notice creates a new Forwarder contract with the msg.sender as owner + function deployNewForwarderAndTransferOwnership(address to, bytes calldata message) external returns (address) { + AuthorizedForwarder forwarder = new AuthorizedForwarder(linkToken, msg.sender, to, message); + + s_created[address(forwarder)] = true; + emit AuthorizedForwarderCreated(address(forwarder), msg.sender, msg.sender); + + return address(forwarder); + } + + // @notice indicates whether this factory deployed an address + function created(address query) external view returns (bool) { + return s_created[query]; + } +} diff --git a/contracts/src/v0.8/operatorforwarder/dev/interfaces/WithdrawalInterface.sol b/contracts/src/v0.8/operatorforwarder/dev/interfaces/WithdrawalInterface.sol new file mode 100644 index 00000000000..c064b0627b5 --- /dev/null +++ b/contracts/src/v0.8/operatorforwarder/dev/interfaces/WithdrawalInterface.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface WithdrawalInterface { + // @notice transfer LINK held by the contract belonging to msg.sender to + // another address + // @param recipient is the address to send the LINK to + // @param amount is the amount of LINK to send + function withdraw(address recipient, uint256 amount) external; + + // @notice query the available amount of LINK to withdraw by msg.sender + function withdrawable() external view returns (uint256); +} diff --git a/contracts/test/v0.8/operatorforwarder/AuthorizedForwarder.test.ts b/contracts/test/v0.8/operatorforwarder/AuthorizedForwarder.test.ts new file mode 100644 index 00000000000..4a84f7662ee --- /dev/null +++ b/contracts/test/v0.8/operatorforwarder/AuthorizedForwarder.test.ts @@ -0,0 +1,724 @@ +import { ethers } from 'hardhat' +import { publicAbi } from '../../test-helpers/helpers' +import { assert, expect } from 'chai' +import { Contract, ContractFactory, ContractReceipt } from 'ethers' +import { getUsers, Roles } from '../../test-helpers/setup' +import { evmRevert } from '../../test-helpers/matchers' + +let getterSetterFactory: ContractFactory +let forwarderFactory: ContractFactory +let brokenFactory: ContractFactory +let linkTokenFactory: ContractFactory + +let roles: Roles +const zeroAddress = ethers.constants.AddressZero + +before(async () => { + const users = await getUsers() + + roles = users.roles + getterSetterFactory = await ethers.getContractFactory( + 'src/v0.4/tests/GetterSetter.sol:GetterSetter', + roles.defaultAccount, + ) + brokenFactory = await ethers.getContractFactory( + 'src/v0.8/tests/Broken.sol:Broken', + roles.defaultAccount, + ) + forwarderFactory = await ethers.getContractFactory( + 'src/v0.8/operatorforwarder/dev/AuthorizedForwarder.sol:AuthorizedForwarder', + roles.defaultAccount, + ) + linkTokenFactory = await ethers.getContractFactory( + 'src/v0.4/LinkToken.sol:LinkToken', + roles.defaultAccount, + ) +}) + +describe('AuthorizedForwarder', () => { + let link: Contract + let forwarder: Contract + + beforeEach(async () => { + link = await linkTokenFactory.connect(roles.defaultAccount).deploy() + forwarder = await forwarderFactory + .connect(roles.defaultAccount) + .deploy( + link.address, + await roles.defaultAccount.getAddress(), + zeroAddress, + '0x', + ) + }) + + it('has a limited public interface [ @skip-coverage ]', () => { + publicAbi(forwarder, [ + 'forward', + 'multiForward', + 'getAuthorizedSenders', + 'linkToken', + 'isAuthorizedSender', + 'ownerForward', + 'setAuthorizedSenders', + 'transferOwnershipWithMessage', + 'typeAndVersion', + // ConfirmedOwner + 'transferOwnership', + 'acceptOwnership', + 'owner', + ]) + }) + + describe('#typeAndVersion', () => { + it('describes the authorized forwarder', async () => { + assert.equal( + await forwarder.typeAndVersion(), + 'AuthorizedForwarder 1.0.0', + ) + }) + }) + + describe('deployment', () => { + it('sets the correct link token', async () => { + assert.equal(await forwarder.linkToken(), link.address) + }) + + it('reverts on zeroAddress value for link token', async () => { + await evmRevert( + forwarderFactory.connect(roles.defaultAccount).deploy( + zeroAddress, // Link Address + await roles.defaultAccount.getAddress(), + zeroAddress, + '0x', + ), + ) + }) + + it('sets no authorized senders', async () => { + const senders = await forwarder.getAuthorizedSenders() + assert.equal(senders.length, 0) + }) + }) + + describe('#setAuthorizedSenders', () => { + let newSenders: string[] + let receipt: ContractReceipt + describe('when called by the owner', () => { + describe('set authorized senders containing duplicate/s', () => { + beforeEach(async () => { + newSenders = [ + await roles.oracleNode1.getAddress(), + await roles.oracleNode1.getAddress(), + await roles.oracleNode2.getAddress(), + await roles.oracleNode3.getAddress(), + ] + }) + it('reverts with a must not have duplicate senders message', async () => { + await evmRevert( + forwarder + .connect(roles.defaultAccount) + .setAuthorizedSenders(newSenders), + 'Must not have duplicate senders', + ) + }) + }) + + describe('setting 3 authorized senders', () => { + beforeEach(async () => { + newSenders = [ + await roles.oracleNode1.getAddress(), + await roles.oracleNode2.getAddress(), + await roles.oracleNode3.getAddress(), + ] + const tx = await forwarder + .connect(roles.defaultAccount) + .setAuthorizedSenders(newSenders) + receipt = await tx.wait() + }) + + it('adds the authorized nodes', async () => { + const authorizedSenders = await forwarder.getAuthorizedSenders() + assert.equal(newSenders.length, authorizedSenders.length) + for (let i = 0; i < authorizedSenders.length; i++) { + assert.equal(authorizedSenders[i], newSenders[i]) + } + }) + + it('emits an event', async () => { + assert.equal(receipt.events?.length, 1) + const responseEvent = receipt.events?.[0] + assert.equal(responseEvent?.event, 'AuthorizedSendersChanged') + const encodedSenders = ethers.utils.defaultAbiCoder.encode( + ['address[]', 'address'], + [newSenders, await roles.defaultAccount.getAddress()], + ) + assert.equal(responseEvent?.data, encodedSenders) + }) + + it('replaces the authorized nodes', async () => { + const newSenders = await forwarder + .connect(roles.defaultAccount) + .getAuthorizedSenders() + assert.notIncludeOrderedMembers(newSenders, [ + await roles.oracleNode.getAddress(), + ]) + }) + + after(async () => { + await forwarder + .connect(roles.defaultAccount) + .setAuthorizedSenders([await roles.oracleNode.getAddress()]) + }) + }) + + describe('setting 0 authorized senders', () => { + beforeEach(async () => { + newSenders = [] + }) + + it('reverts with a minimum senders message', async () => { + await evmRevert( + forwarder + .connect(roles.defaultAccount) + .setAuthorizedSenders(newSenders), + 'Must have at least 1 sender', + ) + }) + }) + }) + + describe('when called by a non-owner', () => { + it('cannot add an authorized node', async () => { + await evmRevert( + forwarder + .connect(roles.stranger) + .setAuthorizedSenders([await roles.stranger.getAddress()]), + 'Cannot set authorized senders', + ) + }) + }) + }) + + describe('#forward', () => { + let bytes: string + let payload: string + let mock: Contract + + beforeEach(async () => { + mock = await getterSetterFactory.connect(roles.defaultAccount).deploy() + bytes = ethers.utils.hexlify(ethers.utils.randomBytes(100)) + payload = getterSetterFactory.interface.encodeFunctionData( + getterSetterFactory.interface.getFunction('setBytes'), + [bytes], + ) + }) + + describe('when called by an unauthorized node', () => { + it('reverts', async () => { + await evmRevert( + forwarder.connect(roles.stranger).forward(mock.address, payload), + ) + }) + }) + + describe('when called by an authorized node', () => { + beforeEach(async () => { + await forwarder + .connect(roles.defaultAccount) + .setAuthorizedSenders([await roles.defaultAccount.getAddress()]) + }) + + describe('when destination call reverts', () => { + let brokenMock: Contract + let brokenPayload: string + let brokenMsgPayload: string + + beforeEach(async () => { + brokenMock = await brokenFactory + .connect(roles.defaultAccount) + .deploy() + brokenMsgPayload = brokenFactory.interface.encodeFunctionData( + brokenFactory.interface.getFunction('revertWithMessage'), + ['Failure message'], + ) + + brokenPayload = brokenFactory.interface.encodeFunctionData( + brokenFactory.interface.getFunction('revertSilently'), + [], + ) + }) + + describe('when reverts with message', () => { + it('return revert message', async () => { + await evmRevert( + forwarder + .connect(roles.defaultAccount) + .forward(brokenMock.address, brokenMsgPayload), + "reverted with reason string 'Failure message'", + ) + }) + }) + + describe('when reverts without message', () => { + it('return silent failure message', async () => { + await evmRevert( + forwarder + .connect(roles.defaultAccount) + .forward(brokenMock.address, brokenPayload), + 'Forwarded call reverted without reason', + ) + }) + }) + }) + + describe('when sending to a non-contract address', () => { + it('reverts', async () => { + await evmRevert( + forwarder + .connect(roles.defaultAccount) + .forward(zeroAddress, payload), + 'Must forward to a contract', + ) + }) + }) + + describe('when attempting to forward to the link token', () => { + it('reverts', async () => { + const sighash = linkTokenFactory.interface.getSighash('name') // any Link Token function + await evmRevert( + forwarder + .connect(roles.defaultAccount) + .forward(link.address, sighash), + ) + }) + }) + + describe('when forwarding to any other address', () => { + it('forwards the data', async () => { + const tx = await forwarder + .connect(roles.defaultAccount) + .forward(mock.address, payload) + await tx.wait() + assert.equal(await mock.getBytes(), bytes) + }) + + it('perceives the message is sent by the AuthorizedForwarder', async () => { + const tx = await forwarder + .connect(roles.defaultAccount) + .forward(mock.address, payload) + await expect(tx) + .to.emit(mock, 'SetBytes') + .withArgs(forwarder.address, bytes) + }) + }) + }) + }) + + describe('#multiForward', () => { + let bytes: string + let payload: string + let mock: Contract + + beforeEach(async () => { + mock = await getterSetterFactory.connect(roles.defaultAccount).deploy() + bytes = ethers.utils.hexlify(ethers.utils.randomBytes(100)) + payload = getterSetterFactory.interface.encodeFunctionData( + getterSetterFactory.interface.getFunction('setBytes'), + [bytes], + ) + }) + + describe('when called by an unauthorized node', () => { + it('reverts', async () => { + await evmRevert( + forwarder + .connect(roles.stranger) + .multiForward([mock.address], [payload]), + ) + }) + }) + + describe('when it receives a single call by an authorized node', () => { + beforeEach(async () => { + await forwarder + .connect(roles.defaultAccount) + .setAuthorizedSenders([await roles.defaultAccount.getAddress()]) + }) + + describe('when destination call reverts', () => { + let brokenMock: Contract + let brokenPayload: string + let brokenMsgPayload: string + + beforeEach(async () => { + brokenMock = await brokenFactory + .connect(roles.defaultAccount) + .deploy() + brokenMsgPayload = brokenFactory.interface.encodeFunctionData( + brokenFactory.interface.getFunction('revertWithMessage'), + ['Failure message'], + ) + + brokenPayload = brokenFactory.interface.encodeFunctionData( + brokenFactory.interface.getFunction('revertSilently'), + [], + ) + }) + + describe('when reverts with message', () => { + it('return revert message', async () => { + await evmRevert( + forwarder + .connect(roles.defaultAccount) + .multiForward([brokenMock.address], [brokenMsgPayload]), + "reverted with reason string 'Failure message'", + ) + }) + }) + + describe('when reverts without message', () => { + it('return silent failure message', async () => { + await evmRevert( + forwarder + .connect(roles.defaultAccount) + .multiForward([brokenMock.address], [brokenPayload]), + 'Forwarded call reverted without reason', + ) + }) + }) + }) + + describe('when sending to a non-contract address', () => { + it('reverts', async () => { + await evmRevert( + forwarder + .connect(roles.defaultAccount) + .multiForward([zeroAddress], [payload]), + 'Must forward to a contract', + ) + }) + }) + + describe('when attempting to forward to the link token', () => { + it('reverts', async () => { + const sighash = linkTokenFactory.interface.getSighash('name') // any Link Token function + await evmRevert( + forwarder + .connect(roles.defaultAccount) + .multiForward([link.address], [sighash]), + ) + }) + }) + + describe('when forwarding to any other address', () => { + it('forwards the data', async () => { + const tx = await forwarder + .connect(roles.defaultAccount) + .multiForward([mock.address], [payload]) + await tx.wait() + assert.equal(await mock.getBytes(), bytes) + }) + + it('perceives the message is sent by the AuthorizedForwarder', async () => { + const tx = await forwarder + .connect(roles.defaultAccount) + .multiForward([mock.address], [payload]) + await expect(tx) + .to.emit(mock, 'SetBytes') + .withArgs(forwarder.address, bytes) + }) + }) + }) + + describe('when its called by an authorized node', () => { + beforeEach(async () => { + await forwarder + .connect(roles.defaultAccount) + .setAuthorizedSenders([await roles.defaultAccount.getAddress()]) + }) + + describe('when 1/1 calls reverts', () => { + let brokenMock: Contract + let brokenPayload: string + let brokenMsgPayload: string + + beforeEach(async () => { + brokenMock = await brokenFactory + .connect(roles.defaultAccount) + .deploy() + brokenMsgPayload = brokenFactory.interface.encodeFunctionData( + brokenFactory.interface.getFunction('revertWithMessage'), + ['Failure message'], + ) + + brokenPayload = brokenFactory.interface.encodeFunctionData( + brokenFactory.interface.getFunction('revertSilently'), + [], + ) + }) + + describe('when reverts with message', () => { + it('return revert message', async () => { + await evmRevert( + forwarder + .connect(roles.defaultAccount) + .multiForward([brokenMock.address], [brokenMsgPayload]), + "reverted with reason string 'Failure message'", + ) + }) + }) + + describe('when reverts without message', () => { + it('return silent failure message', async () => { + await evmRevert( + forwarder + .connect(roles.defaultAccount) + .multiForward([brokenMock.address], [brokenPayload]), + 'Forwarded call reverted without reason', + ) + }) + }) + }) + + describe('when 1/many calls revert', () => { + let brokenMock: Contract + let brokenPayload: string + let brokenMsgPayload: string + + beforeEach(async () => { + brokenMock = await brokenFactory + .connect(roles.defaultAccount) + .deploy() + brokenMsgPayload = brokenFactory.interface.encodeFunctionData( + brokenFactory.interface.getFunction('revertWithMessage'), + ['Failure message'], + ) + + brokenPayload = brokenFactory.interface.encodeFunctionData( + brokenFactory.interface.getFunction('revertSilently'), + [], + ) + }) + + describe('when reverts with message', () => { + it('return revert message', async () => { + await evmRevert( + forwarder + .connect(roles.defaultAccount) + .multiForward( + [brokenMock.address, mock.address], + [brokenMsgPayload, payload], + ), + "reverted with reason string 'Failure message'", + ) + + await evmRevert( + forwarder + .connect(roles.defaultAccount) + .multiForward( + [mock.address, brokenMock.address], + [payload, brokenMsgPayload], + ), + "reverted with reason string 'Failure message'", + ) + }) + }) + + describe('when reverts without message', () => { + it('return silent failure message', async () => { + await evmRevert( + // first + forwarder + .connect(roles.defaultAccount) + .multiForward( + [brokenMock.address, mock.address], + [brokenPayload, payload], + ), + 'Forwarded call reverted without reason', + ) + + await evmRevert( + forwarder + .connect(roles.defaultAccount) + .multiForward( + [mock.address, brokenMock.address], + [payload, brokenPayload], + ), + 'Forwarded call reverted without reason', + ) + }) + }) + }) + + describe('when sending to a non-contract address', () => { + it('reverts', async () => { + await evmRevert( + forwarder + .connect(roles.defaultAccount) + .multiForward([zeroAddress], [payload]), + 'Must forward to a contract', + ) + }) + }) + + describe('when attempting to forward to the link token', () => { + it('reverts', async () => { + const sighash = linkTokenFactory.interface.getSighash('name') // any Link Token function + await evmRevert( + forwarder + .connect(roles.defaultAccount) + .multiForward([link.address], [sighash]), + ) + }) + }) + + describe('when forwarding to any other address', () => { + it('forwards the data', async () => { + const tx = await forwarder + .connect(roles.defaultAccount) + .multiForward([mock.address], [payload]) + await tx.wait() + assert.equal(await mock.getBytes(), bytes) + }) + + it('perceives the message is sent by the AuthorizedForwarder', async () => { + const tx = await forwarder + .connect(roles.defaultAccount) + .multiForward([mock.address], [payload]) + await expect(tx) + .to.emit(mock, 'SetBytes') + .withArgs(forwarder.address, bytes) + }) + }) + }) + }) + + describe('#transferOwnershipWithMessage', () => { + const message = '0x42' + + describe('when called by a non-owner', () => { + it('reverts', async () => { + await evmRevert( + forwarder + .connect(roles.stranger) + .transferOwnershipWithMessage( + await roles.stranger.getAddress(), + message, + ), + 'Only callable by owner', + ) + }) + }) + + describe('when called by the owner', () => { + it('calls the normal ownership transfer proposal', async () => { + const tx = await forwarder + .connect(roles.defaultAccount) + .transferOwnershipWithMessage( + await roles.stranger.getAddress(), + message, + ) + const receipt = await tx.wait() + + assert.equal(receipt?.events?.[0]?.event, 'OwnershipTransferRequested') + assert.equal(receipt?.events?.[0]?.address, forwarder.address) + assert.equal( + receipt?.events?.[0]?.args?.[0], + await roles.defaultAccount.getAddress(), + ) + assert.equal( + receipt?.events?.[0]?.args?.[1], + await roles.stranger.getAddress(), + ) + }) + + it('calls the normal ownership transfer proposal', async () => { + const tx = await forwarder + .connect(roles.defaultAccount) + .transferOwnershipWithMessage( + await roles.stranger.getAddress(), + message, + ) + const receipt = await tx.wait() + + assert.equal( + receipt?.events?.[1]?.event, + 'OwnershipTransferRequestedWithMessage', + ) + assert.equal(receipt?.events?.[1]?.address, forwarder.address) + assert.equal( + receipt?.events?.[1]?.args?.[0], + await roles.defaultAccount.getAddress(), + ) + assert.equal( + receipt?.events?.[1]?.args?.[1], + await roles.stranger.getAddress(), + ) + assert.equal(receipt?.events?.[1]?.args?.[2], message) + }) + }) + }) + + describe('#ownerForward', () => { + let bytes: string + let payload: string + let mock: Contract + + beforeEach(async () => { + mock = await getterSetterFactory.connect(roles.defaultAccount).deploy() + bytes = ethers.utils.hexlify(ethers.utils.randomBytes(100)) + payload = getterSetterFactory.interface.encodeFunctionData( + getterSetterFactory.interface.getFunction('setBytes'), + [bytes], + ) + }) + + describe('when called by a non-owner', () => { + it('reverts', async () => { + await evmRevert( + forwarder.connect(roles.stranger).ownerForward(mock.address, payload), + ) + }) + }) + + describe('when called by owner', () => { + describe('when attempting to forward to the link token', () => { + it('does not revert', async () => { + const sighash = linkTokenFactory.interface.getSighash('name') // any Link Token function + + await forwarder + .connect(roles.defaultAccount) + .ownerForward(link.address, sighash) + }) + }) + + describe('when forwarding to any other address', () => { + it('forwards the data', async () => { + const tx = await forwarder + .connect(roles.defaultAccount) + .ownerForward(mock.address, payload) + await tx.wait() + assert.equal(await mock.getBytes(), bytes) + }) + + it('reverts when sending to a non-contract address', async () => { + await evmRevert( + forwarder + .connect(roles.defaultAccount) + .ownerForward(zeroAddress, payload), + 'Must forward to a contract', + ) + }) + + it('perceives the message is sent by the Operator', async () => { + const tx = await forwarder + .connect(roles.defaultAccount) + .ownerForward(mock.address, payload) + await expect(tx) + .to.emit(mock, 'SetBytes') + .withArgs(forwarder.address, bytes) + }) + }) + }) + }) +}) diff --git a/contracts/test/v0.8/operatorforwarder/ConfirmedOwner.test.ts b/contracts/test/v0.8/operatorforwarder/ConfirmedOwner.test.ts new file mode 100644 index 00000000000..3bd347320c5 --- /dev/null +++ b/contracts/test/v0.8/operatorforwarder/ConfirmedOwner.test.ts @@ -0,0 +1,136 @@ +import { ethers } from 'hardhat' +import { publicAbi } from '../../test-helpers/helpers' +import { assert, expect } from 'chai' +import { Contract, ContractFactory, Signer } from 'ethers' +import { Personas, getUsers } from '../../test-helpers/setup' +import { evmRevert } from '../../test-helpers/matchers' + +let confirmedOwnerTestHelperFactory: ContractFactory +let confirmedOwnerFactory: ContractFactory + +let personas: Personas +let owner: Signer +let nonOwner: Signer +let newOwner: Signer + +before(async () => { + const users = await getUsers() + personas = users.personas + owner = personas.Carol + nonOwner = personas.Neil + newOwner = personas.Ned + + confirmedOwnerTestHelperFactory = await ethers.getContractFactory( + 'src/v0.7/tests/ConfirmedOwnerTestHelper.sol:ConfirmedOwnerTestHelper', + owner, + ) + confirmedOwnerFactory = await ethers.getContractFactory( + 'src/v0.8/shared/access/ConfirmedOwner.sol:ConfirmedOwner', + owner, + ) +}) + +describe('ConfirmedOwner', () => { + let confirmedOwner: Contract + + beforeEach(async () => { + confirmedOwner = await confirmedOwnerTestHelperFactory + .connect(owner) + .deploy() + }) + + it('has a limited public interface [ @skip-coverage ]', () => { + publicAbi(confirmedOwner, [ + 'acceptOwnership', + 'owner', + 'transferOwnership', + // test helper public methods + 'modifierOnlyOwner', + ]) + }) + + describe('#constructor', () => { + it('assigns ownership to the deployer', async () => { + const [actual, expected] = await Promise.all([ + owner.getAddress(), + confirmedOwner.owner(), + ]) + + assert.equal(actual, expected) + }) + + it('reverts if assigned to the zero address', async () => { + await evmRevert( + confirmedOwnerFactory + .connect(owner) + .deploy(ethers.constants.AddressZero), + 'Cannot set owner to zero', + ) + }) + }) + + describe('#onlyOwner modifier', () => { + describe('when called by an owner', () => { + it('successfully calls the method', async () => { + const tx = await confirmedOwner.connect(owner).modifierOnlyOwner() + await expect(tx).to.emit(confirmedOwner, 'Here') + }) + }) + + describe('when called by anyone but the owner', () => { + it('reverts', async () => + await evmRevert(confirmedOwner.connect(nonOwner).modifierOnlyOwner())) + }) + }) + + describe('#transferOwnership', () => { + describe('when called by an owner', () => { + it('emits a log', async () => { + const tx = await confirmedOwner + .connect(owner) + .transferOwnership(await newOwner.getAddress()) + await expect(tx) + .to.emit(confirmedOwner, 'OwnershipTransferRequested') + .withArgs(await owner.getAddress(), await newOwner.getAddress()) + }) + + it('does not allow ownership transfer to self', async () => { + await evmRevert( + confirmedOwner + .connect(owner) + .transferOwnership(await owner.getAddress()), + 'Cannot transfer to self', + ) + }) + }) + }) + + describe('when called by anyone but the owner', () => { + it('reverts', async () => + await evmRevert( + confirmedOwner + .connect(nonOwner) + .transferOwnership(await newOwner.getAddress()), + )) + }) + + describe('#acceptOwnership', () => { + describe('after #transferOwnership has been called', () => { + beforeEach(async () => { + await confirmedOwner + .connect(owner) + .transferOwnership(await newOwner.getAddress()) + }) + + it('allows the recipient to call it', async () => { + const tx = await confirmedOwner.connect(newOwner).acceptOwnership() + await expect(tx) + .to.emit(confirmedOwner, 'OwnershipTransferred') + .withArgs(await owner.getAddress(), await newOwner.getAddress()) + }) + + it('does not allow a non-recipient to call it', async () => + await evmRevert(confirmedOwner.connect(nonOwner).acceptOwnership())) + }) + }) +}) diff --git a/contracts/test/v0.8/operatorforwarder/Operator.test.ts b/contracts/test/v0.8/operatorforwarder/Operator.test.ts new file mode 100644 index 00000000000..2c64c2dc93b --- /dev/null +++ b/contracts/test/v0.8/operatorforwarder/Operator.test.ts @@ -0,0 +1,3819 @@ +import { ethers } from 'hardhat' +import { + publicAbi, + toBytes32String, + toWei, + stringToBytes, + increaseTime5Minutes, + getLog, +} from '../../test-helpers/helpers' +import { assert, expect } from 'chai' +import { + BigNumber, + constants, + Contract, + ContractFactory, + ContractReceipt, + ContractTransaction, + Signer, +} from 'ethers' +import { getUsers, Roles } from '../../test-helpers/setup' +import { bigNumEquals, evmRevert } from '../../test-helpers/matchers' +import type { providers } from 'ethers' +import { + convertCancelParams, + convertCancelByRequesterParams, + convertFufillParams, + convertFulfill2Params, + decodeRunRequest, + encodeOracleRequest, + encodeRequestOracleData, + RunRequest, +} from '../../test-helpers/oracle' + +let v7ConsumerFactory: ContractFactory +let basicConsumerFactory: ContractFactory +let multiWordConsumerFactory: ContractFactory +let gasGuzzlingConsumerFactory: ContractFactory +let getterSetterFactory: ContractFactory +let maliciousRequesterFactory: ContractFactory +let maliciousConsumerFactory: ContractFactory +let maliciousMultiWordConsumerFactory: ContractFactory +let operatorFactory: ContractFactory +let forwarderFactory: ContractFactory +let linkTokenFactory: ContractFactory +const zeroAddress = ethers.constants.AddressZero + +let roles: Roles + +before(async () => { + const users = await getUsers() + + roles = users.roles + v7ConsumerFactory = await ethers.getContractFactory( + 'src/v0.7/tests/Consumer.sol:Consumer', + ) + basicConsumerFactory = await ethers.getContractFactory( + 'src/v0.6/tests/BasicConsumer.sol:BasicConsumer', + ) + multiWordConsumerFactory = await ethers.getContractFactory( + 'src/v0.7/tests/MultiWordConsumer.sol:MultiWordConsumer', + ) + gasGuzzlingConsumerFactory = await ethers.getContractFactory( + 'src/v0.6/tests/GasGuzzlingConsumer.sol:GasGuzzlingConsumer', + ) + getterSetterFactory = await ethers.getContractFactory( + 'src/v0.4/tests/GetterSetter.sol:GetterSetter', + ) + maliciousRequesterFactory = await ethers.getContractFactory( + 'src/v0.4/tests/MaliciousRequester.sol:MaliciousRequester', + ) + maliciousConsumerFactory = await ethers.getContractFactory( + 'src/v0.4/tests/MaliciousConsumer.sol:MaliciousConsumer', + ) + maliciousMultiWordConsumerFactory = await ethers.getContractFactory( + 'src/v0.6/tests/MaliciousMultiWordConsumer.sol:MaliciousMultiWordConsumer', + ) + operatorFactory = await ethers.getContractFactory( + 'src/v0.8/operatorforwarder/dev/Operator.sol:Operator', + ) + forwarderFactory = await ethers.getContractFactory( + 'src/v0.8/operatorforwarder/dev/AuthorizedForwarder.sol:AuthorizedForwarder', + ) + linkTokenFactory = await ethers.getContractFactory( + 'src/v0.4/LinkToken.sol:LinkToken', + ) +}) + +describe('Operator', () => { + let fHash: string + let specId: string + let to: string + let link: Contract + let operator: Contract + let forwarder1: Contract + let forwarder2: Contract + let owner: Signer + + beforeEach(async () => { + fHash = getterSetterFactory.interface.getSighash('requestedBytes32') + specId = + '0x4c7b7ffb66b344fbaa64995af81e355a00000000000000000000000000000000' + to = '0x80e29acb842498fe6591f020bd82766dce619d43' + link = await linkTokenFactory.connect(roles.defaultAccount).deploy() + owner = roles.defaultAccount + operator = await operatorFactory + .connect(owner) + .deploy(link.address, await owner.getAddress()) + await operator + .connect(roles.defaultAccount) + .setAuthorizedSenders([await roles.oracleNode.getAddress()]) + }) + + it('has a limited public interface [ @skip-coverage ]', () => { + publicAbi(operator, [ + 'acceptAuthorizedReceivers', + 'acceptOwnableContracts', + 'cancelOracleRequest', + 'cancelOracleRequestByRequester', + 'distributeFunds', + 'fulfillOracleRequest', + 'fulfillOracleRequest2', + 'getAuthorizedSenders', + 'getChainlinkToken', + 'EXPIRYTIME', + 'isAuthorizedSender', + 'onTokenTransfer', + 'operatorRequest', + 'oracleRequest', + 'ownerForward', + 'ownerTransferAndCall', + 'setAuthorizedSenders', + 'setAuthorizedSendersOn', + 'transferOwnableContracts', + 'typeAndVersion', + 'withdraw', + 'withdrawable', + // Ownable methods: + 'acceptOwnership', + 'owner', + 'transferOwnership', + ]) + }) + + describe('#typeAndVersion', () => { + it('describes the operator', async () => { + assert.equal(await operator.typeAndVersion(), 'Operator 1.0.0') + }) + }) + + describe('#transferOwnableContracts', () => { + beforeEach(async () => { + forwarder1 = await forwarderFactory + .connect(owner) + .deploy(link.address, operator.address, zeroAddress, '0x') + forwarder2 = await forwarderFactory + .connect(owner) + .deploy(link.address, operator.address, zeroAddress, '0x') + }) + + describe('being called by the owner', () => { + it('cannot transfer to self', async () => { + await evmRevert( + operator + .connect(owner) + .transferOwnableContracts([forwarder1.address], operator.address), + 'Cannot transfer to self', + ) + }) + + it('emits an ownership transfer request event', async () => { + const tx = await operator + .connect(owner) + .transferOwnableContracts( + [forwarder1.address, forwarder2.address], + await roles.oracleNode1.getAddress(), + ) + const receipt = await tx.wait() + assert.equal(receipt?.events?.length, 2) + const log1 = receipt?.events?.[0] + assert.equal(log1?.event, 'OwnershipTransferRequested') + assert.equal(log1?.address, forwarder1.address) + assert.equal(log1?.args?.[0], operator.address) + assert.equal(log1?.args?.[1], await roles.oracleNode1.getAddress()) + const log2 = receipt?.events?.[1] + assert.equal(log2?.event, 'OwnershipTransferRequested') + assert.equal(log2?.address, forwarder2.address) + assert.equal(log2?.args?.[0], operator.address) + assert.equal(log2?.args?.[1], await roles.oracleNode1.getAddress()) + }) + }) + + describe('being called by a non-owner', () => { + it('reverts with message', async () => { + await evmRevert( + operator + .connect(roles.stranger) + .transferOwnableContracts( + [forwarder1.address], + await roles.oracleNode2.getAddress(), + ), + 'Only callable by owner', + ) + }) + }) + }) + + describe('#acceptOwnableContracts', () => { + describe('being called by the owner', () => { + let operator2: Contract + let receipt: ContractReceipt + + beforeEach(async () => { + operator2 = await operatorFactory + .connect(roles.defaultAccount) + .deploy(link.address, await roles.defaultAccount.getAddress()) + forwarder1 = await forwarderFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address, zeroAddress, '0x') + forwarder2 = await forwarderFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address, zeroAddress, '0x') + await operator + .connect(roles.defaultAccount) + .transferOwnableContracts( + [forwarder1.address, forwarder2.address], + operator2.address, + ) + const tx = await operator2 + .connect(roles.defaultAccount) + .acceptOwnableContracts([forwarder1.address, forwarder2.address]) + receipt = await tx.wait() + }) + + it('sets the new owner on the forwarder', async () => { + assert.equal(await forwarder1.owner(), operator2.address) + }) + + it('emits ownership transferred events', async () => { + assert.equal(receipt?.events?.[0]?.event, 'OwnableContractAccepted') + assert.equal(receipt?.events?.[0]?.args?.[0], forwarder1.address) + + assert.equal(receipt?.events?.[1]?.event, 'OwnershipTransferred') + assert.equal(receipt?.events?.[1]?.address, forwarder1.address) + assert.equal(receipt?.events?.[1]?.args?.[0], operator.address) + assert.equal(receipt?.events?.[1]?.args?.[1], operator2.address) + + assert.equal(receipt?.events?.[2]?.event, 'OwnableContractAccepted') + assert.equal(receipt?.events?.[2]?.args?.[0], forwarder2.address) + + assert.equal(receipt?.events?.[3]?.event, 'OwnershipTransferred') + assert.equal(receipt?.events?.[3]?.address, forwarder2.address) + assert.equal(receipt?.events?.[3]?.args?.[0], operator.address) + assert.equal(receipt?.events?.[3]?.args?.[1], operator2.address) + }) + }) + + describe('being called by a non-owner authorized sender', () => { + it('does not revert', async () => { + await operator + .connect(roles.defaultAccount) + .setAuthorizedSenders([await roles.oracleNode1.getAddress()]) + + await operator.connect(roles.oracleNode1).acceptOwnableContracts([]) + }) + }) + + describe('being called by a non owner', () => { + it('reverts with message', async () => { + await evmRevert( + operator + .connect(roles.stranger) + .acceptOwnableContracts([await roles.oracleNode2.getAddress()]), + 'Cannot set authorized senders', + ) + }) + }) + }) + + describe('#distributeFunds', () => { + describe('when called with empty arrays', () => { + it('reverts with invalid array message', async () => { + await evmRevert( + operator.connect(roles.defaultAccount).distributeFunds([], []), + 'Invalid array length(s)', + ) + }) + }) + + describe('when called with unequal array lengths', () => { + it('reverts with invalid array message', async () => { + const receivers = [ + await roles.oracleNode2.getAddress(), + await roles.oracleNode3.getAddress(), + ] + const amounts = [1, 2, 3] + await evmRevert( + operator + .connect(roles.defaultAccount) + .distributeFunds(receivers, amounts), + 'Invalid array length(s)', + ) + }) + }) + + describe('when called with not enough ETH', () => { + it('reverts with subtraction overflow message', async () => { + const amountToSend = toWei('2') + const ethSent = toWei('1') + await evmRevert( + operator + .connect(roles.defaultAccount) + .distributeFunds( + [await roles.oracleNode2.getAddress()], + [amountToSend], + { + value: ethSent, + }, + ), + 'Arithmetic operation underflowed or overflowed outside of an unchecked block', + ) + }) + }) + + describe('when called with too much ETH', () => { + it('reverts with too much ETH message', async () => { + const amountToSend = toWei('2') + const ethSent = toWei('3') + await evmRevert( + operator + .connect(roles.defaultAccount) + .distributeFunds( + [await roles.oracleNode2.getAddress()], + [amountToSend], + { + value: ethSent, + }, + ), + 'Too much ETH sent', + ) + }) + }) + + describe('when called with correct values', () => { + it('updates the balances', async () => { + const node2BalanceBefore = await roles.oracleNode2.getBalance() + const node3BalanceBefore = await roles.oracleNode3.getBalance() + const receivers = [ + await roles.oracleNode2.getAddress(), + await roles.oracleNode3.getAddress(), + ] + const sendNode2 = toWei('2') + const sendNode3 = toWei('3') + const totalAmount = toWei('5') + const amounts = [sendNode2, sendNode3] + + await operator + .connect(roles.defaultAccount) + .distributeFunds(receivers, amounts, { value: totalAmount }) + + const node2BalanceAfter = await roles.oracleNode2.getBalance() + const node3BalanceAfter = await roles.oracleNode3.getBalance() + + assert.equal( + node2BalanceAfter.sub(node2BalanceBefore).toString(), + sendNode2.toString(), + ) + + assert.equal( + node3BalanceAfter.sub(node3BalanceBefore).toString(), + sendNode3.toString(), + ) + }) + }) + }) + + describe('#setAuthorizedSenders', () => { + let newSenders: string[] + let receipt: ContractReceipt + describe('when called by the owner', () => { + describe('setting 3 authorized senders', () => { + beforeEach(async () => { + newSenders = [ + await roles.oracleNode1.getAddress(), + await roles.oracleNode2.getAddress(), + await roles.oracleNode3.getAddress(), + ] + const tx = await operator + .connect(roles.defaultAccount) + .setAuthorizedSenders(newSenders) + receipt = await tx.wait() + }) + + it('adds the authorized nodes', async () => { + const authorizedSenders = await operator.getAuthorizedSenders() + assert.equal(newSenders.length, authorizedSenders.length) + for (let i = 0; i < authorizedSenders.length; i++) { + assert.equal(authorizedSenders[i], newSenders[i]) + } + }) + + it('emits an event on the Operator', async () => { + assert.equal(receipt.events?.length, 1) + + const encodedSenders1 = ethers.utils.defaultAbiCoder.encode( + ['address[]', 'address'], + [newSenders, await roles.defaultAccount.getAddress()], + ) + + const responseEvent1 = receipt.events?.[0] + assert.equal(responseEvent1?.event, 'AuthorizedSendersChanged') + assert.equal(responseEvent1?.data, encodedSenders1) + }) + + it('replaces the authorized nodes', async () => { + const originalAuthorization = await operator + .connect(roles.defaultAccount) + .isAuthorizedSender(await roles.oracleNode.getAddress()) + assert.isFalse(originalAuthorization) + }) + + after(async () => { + await operator + .connect(roles.defaultAccount) + .setAuthorizedSenders([await roles.oracleNode.getAddress()]) + }) + }) + + describe('setting 0 authorized senders', () => { + beforeEach(async () => { + newSenders = [] + }) + + it('reverts with a minimum senders message', async () => { + await evmRevert( + operator + .connect(roles.defaultAccount) + .setAuthorizedSenders(newSenders), + 'Must have at least 1 sender', + ) + }) + }) + }) + + describe('when called by an authorized sender', () => { + beforeEach(async () => { + newSenders = [await roles.oracleNode1.getAddress()] + await operator + .connect(roles.defaultAccount) + .setAuthorizedSenders(newSenders) + }) + + it('succeeds', async () => { + await operator + .connect(roles.defaultAccount) + .setAuthorizedSenders([await roles.stranger.getAddress()]) + }) + }) + + describe('when called by a non-owner', () => { + it('cannot add an authorized node', async () => { + await evmRevert( + operator + .connect(roles.stranger) + .setAuthorizedSenders([await roles.stranger.getAddress()]), + 'Cannot set authorized senders', + ) + }) + }) + }) + + describe('#setAuthorizedSendersOn', () => { + let newSenders: string[] + + beforeEach(async () => { + await operator + .connect(roles.defaultAccount) + .setAuthorizedSenders([await roles.oracleNode1.getAddress()]) + newSenders = [ + await roles.oracleNode2.getAddress(), + await roles.oracleNode3.getAddress(), + ] + + forwarder1 = await forwarderFactory + .connect(owner) + .deploy(link.address, operator.address, zeroAddress, '0x') + forwarder2 = await forwarderFactory + .connect(owner) + .deploy(link.address, operator.address, zeroAddress, '0x') + }) + + describe('when called by a non-authorized sender', () => { + it('reverts', async () => { + await evmRevert( + operator + .connect(roles.stranger) + .setAuthorizedSendersOn(newSenders, [forwarder1.address]), + 'Cannot set authorized senders', + ) + }) + }) + + describe('when called by an owner', () => { + it('does not revert', async () => { + await operator + .connect(roles.defaultAccount) + .setAuthorizedSendersOn( + [forwarder1.address, forwarder2.address], + newSenders, + ) + }) + }) + + describe('when called by an authorized sender', () => { + it('does not revert', async () => { + await operator + .connect(roles.oracleNode1) + .setAuthorizedSendersOn( + [forwarder1.address, forwarder2.address], + newSenders, + ) + }) + + it('does revert with 0 senders', async () => { + await operator + .connect(roles.oracleNode1) + .setAuthorizedSendersOn( + [forwarder1.address, forwarder2.address], + newSenders, + ) + }) + + it('emits a log announcing the change and who made it', async () => { + const targets = [forwarder1.address, forwarder2.address] + const tx = await operator + .connect(roles.oracleNode1) + .setAuthorizedSendersOn(targets, newSenders) + + const receipt = await tx.wait() + const encodedArgs = ethers.utils.defaultAbiCoder.encode( + ['address[]', 'address[]', 'address'], + [targets, newSenders, await roles.oracleNode1.getAddress()], + ) + + const event1 = receipt.events?.[0] + assert.equal(event1?.event, 'TargetsUpdatedAuthorizedSenders') + assert.equal(event1?.address, operator.address) + assert.equal(event1?.data, encodedArgs) + }) + + it('updates the sender list on each of the targets', async () => { + const tx = await operator + .connect(roles.oracleNode1) + .setAuthorizedSendersOn( + [forwarder1.address, forwarder2.address], + newSenders, + ) + + const receipt = await tx.wait() + assert.equal(receipt.events?.length, 3, receipt.toString()) + const encodedSenders = ethers.utils.defaultAbiCoder.encode( + ['address[]', 'address'], + [newSenders, operator.address], + ) + + const event1 = receipt.events?.[1] + assert.equal(event1?.event, 'AuthorizedSendersChanged') + assert.equal(event1?.address, forwarder1.address) + assert.equal(event1?.data, encodedSenders) + + const event2 = receipt.events?.[2] + assert.equal(event2?.event, 'AuthorizedSendersChanged') + assert.equal(event2?.address, forwarder2.address) + assert.equal(event2?.data, encodedSenders) + }) + }) + }) + + describe('#acceptAuthorizedReceivers', () => { + let newSenders: string[] + + describe('being called by the owner', () => { + let operator2: Contract + let receipt: ContractReceipt + + beforeEach(async () => { + operator2 = await operatorFactory + .connect(roles.defaultAccount) + .deploy(link.address, await roles.defaultAccount.getAddress()) + forwarder1 = await forwarderFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address, zeroAddress, '0x') + forwarder2 = await forwarderFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address, zeroAddress, '0x') + await operator + .connect(roles.defaultAccount) + .transferOwnableContracts( + [forwarder1.address, forwarder2.address], + operator2.address, + ) + newSenders = [ + await roles.oracleNode2.getAddress(), + await roles.oracleNode3.getAddress(), + ] + + const tx = await operator2 + .connect(roles.defaultAccount) + .acceptAuthorizedReceivers( + [forwarder1.address, forwarder2.address], + newSenders, + ) + receipt = await tx.wait() + }) + + it('sets the new owner on the forwarder', async () => { + assert.equal(await forwarder1.owner(), operator2.address) + }) + + it('emits ownership transferred events', async () => { + assert.equal(receipt?.events?.[0]?.event, 'OwnableContractAccepted') + assert.equal(receipt?.events?.[0]?.args?.[0], forwarder1.address) + + assert.equal(receipt?.events?.[1]?.event, 'OwnershipTransferred') + assert.equal(receipt?.events?.[1]?.address, forwarder1.address) + assert.equal(receipt?.events?.[1]?.args?.[0], operator.address) + assert.equal(receipt?.events?.[1]?.args?.[1], operator2.address) + + assert.equal(receipt?.events?.[2]?.event, 'OwnableContractAccepted') + assert.equal(receipt?.events?.[2]?.args?.[0], forwarder2.address) + + assert.equal(receipt?.events?.[3]?.event, 'OwnershipTransferred') + assert.equal(receipt?.events?.[3]?.address, forwarder2.address) + assert.equal(receipt?.events?.[3]?.args?.[0], operator.address) + assert.equal(receipt?.events?.[3]?.args?.[1], operator2.address) + + assert.equal( + receipt?.events?.[4]?.event, + 'TargetsUpdatedAuthorizedSenders', + ) + + const encodedSenders = ethers.utils.defaultAbiCoder.encode( + ['address[]', 'address'], + [newSenders, operator2.address], + ) + assert.equal(receipt?.events?.[5]?.event, 'AuthorizedSendersChanged') + assert.equal(receipt?.events?.[5]?.address, forwarder1.address) + assert.equal(receipt?.events?.[5]?.data, encodedSenders) + + assert.equal(receipt?.events?.[6]?.event, 'AuthorizedSendersChanged') + assert.equal(receipt?.events?.[6]?.address, forwarder2.address) + assert.equal(receipt?.events?.[6]?.data, encodedSenders) + }) + }) + + describe('being called by a non owner', () => { + it('reverts with message', async () => { + await evmRevert( + operator + .connect(roles.stranger) + .acceptAuthorizedReceivers( + [forwarder1.address, forwarder2.address], + newSenders, + ), + 'Cannot set authorized senders', + ) + }) + }) + }) + + describe('#onTokenTransfer', () => { + describe('when called from any address but the LINK token', () => { + it('triggers the intended method', async () => { + const callData = encodeOracleRequest( + specId, + to, + fHash, + 0, + constants.HashZero, + ) + + await evmRevert( + operator.onTokenTransfer( + await roles.defaultAccount.getAddress(), + 0, + callData, + ), + ) + }) + }) + + describe('when called from the LINK token', () => { + it('triggers the intended method', async () => { + const callData = encodeOracleRequest( + specId, + to, + fHash, + 0, + constants.HashZero, + ) + + const tx = await link.transferAndCall(operator.address, 0, callData, { + value: 0, + }) + const receipt = await tx.wait() + + assert.equal(3, receipt.logs?.length) + }) + + describe('with no data', () => { + it('reverts', async () => { + await evmRevert( + link.transferAndCall(operator.address, 0, '0x', { + value: 0, + }), + ) + }) + }) + }) + + describe('malicious requester', () => { + let mock: Contract + let requester: Contract + const paymentAmount = toWei('1') + + beforeEach(async () => { + mock = await maliciousRequesterFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address) + await link.transfer(mock.address, paymentAmount) + }) + + it('cannot withdraw from oracle', async () => { + const operatorOriginalBalance = await link.balanceOf(operator.address) + const mockOriginalBalance = await link.balanceOf(mock.address) + + await evmRevert(mock.maliciousWithdraw()) + + const operatorNewBalance = await link.balanceOf(operator.address) + const mockNewBalance = await link.balanceOf(mock.address) + + bigNumEquals(operatorOriginalBalance, operatorNewBalance) + bigNumEquals(mockNewBalance, mockOriginalBalance) + }) + + describe('if the requester tries to create a requestId for another contract', () => { + it('the requesters ID will not match with the oracle contract', async () => { + const tx = await mock.maliciousTargetConsumer(to) + const receipt = await tx.wait() + + const mockRequestId = receipt.logs?.[0].data + const requestId = (receipt.events?.[0].args as any).requestId + assert.notEqual(mockRequestId, requestId) + }) + + it('the target requester can still create valid requests', async () => { + requester = await basicConsumerFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address, specId) + await link.transfer(requester.address, paymentAmount) + await mock.maliciousTargetConsumer(requester.address) + await requester.requestEthereumPrice('USD', paymentAmount) + }) + }) + }) + + it('does not allow recursive calls of onTokenTransfer', async () => { + const requestPayload = encodeOracleRequest( + specId, + to, + fHash, + 0, + constants.HashZero, + ) + + const ottSelector = + operatorFactory.interface.getSighash('onTokenTransfer') + const header = + '000000000000000000000000c5fdf4076b8f3a5357c5e395ab970b5b54098fef' + // to + '0000000000000000000000000000000000000000000000000000000000000539' + // amount + '0000000000000000000000000000000000000000000000000000000000000060' + // offset + '0000000000000000000000000000000000000000000000000000000000000136' // length + + const maliciousPayload = ottSelector + header + requestPayload.slice(2) + + await evmRevert( + link.transferAndCall(operator.address, 0, maliciousPayload, { + value: 0, + }), + ) + }) + }) + + describe('#oracleRequest', () => { + describe('when called through the LINK token', () => { + const paid = 100 + let log: providers.Log | undefined + let receipt: providers.TransactionReceipt + + beforeEach(async () => { + const args = encodeOracleRequest( + specId, + to, + fHash, + 1, + constants.HashZero, + ) + const tx = await link.transferAndCall(operator.address, paid, args) + receipt = await tx.wait() + assert.equal(3, receipt?.logs?.length) + + log = receipt.logs && receipt.logs[2] + }) + + it('logs an event', async () => { + assert.equal(operator.address, log?.address) + + assert.equal(log?.topics?.[1], specId) + + const req = decodeRunRequest(receipt?.logs?.[2]) + assert.equal(await roles.defaultAccount.getAddress(), req.requester) + bigNumEquals(paid, req.payment) + }) + + it('uses the expected event signature', async () => { + // If updating this test, be sure to update models.RunLogTopic. + const eventSignature = + '0xd8d7ecc4800d25fa53ce0372f13a416d98907a7ef3d8d3bdd79cf4fe75529c65' + assert.equal(eventSignature, log?.topics?.[0]) + }) + + it('does not allow the same requestId to be used twice', async () => { + const args2 = encodeOracleRequest( + specId, + to, + fHash, + 1, + constants.HashZero, + ) + await evmRevert(link.transferAndCall(operator.address, paid, args2)) + }) + + describe('when called with a payload less than 2 EVM words + function selector', () => { + it('throws an error', async () => { + const funcSelector = + operatorFactory.interface.getSighash('oracleRequest') + const maliciousData = + funcSelector + + '0000000000000000000000000000000000000000000000000000000000000000000' + await evmRevert( + link.transferAndCall(operator.address, paid, maliciousData), + ) + }) + }) + + describe('when called with a payload between 3 and 9 EVM words', () => { + it('throws an error', async () => { + const funcSelector = + operatorFactory.interface.getSighash('oracleRequest') + const maliciousData = + funcSelector + + '000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001' + await evmRevert( + link.transferAndCall(operator.address, paid, maliciousData), + ) + }) + }) + }) + + describe('when dataVersion is higher than 255', () => { + it('throws an error', async () => { + const paid = 100 + const args = encodeOracleRequest( + specId, + to, + fHash, + 1, + constants.HashZero, + 256, + ) + await evmRevert(link.transferAndCall(operator.address, paid, args)) + }) + }) + + describe('when not called through the LINK token', () => { + it('reverts', async () => { + await evmRevert( + operator + .connect(roles.oracleNode) + .oracleRequest( + '0x0000000000000000000000000000000000000000', + 0, + specId, + to, + fHash, + 1, + 1, + '0x', + ), + ) + }) + }) + }) + + describe('#operatorRequest', () => { + describe('when called through the LINK token', () => { + const paid = 100 + let log: providers.Log | undefined + let receipt: providers.TransactionReceipt + + beforeEach(async () => { + const args = encodeRequestOracleData( + specId, + fHash, + 1, + constants.HashZero, + ) + const tx = await link.transferAndCall(operator.address, paid, args) + receipt = await tx.wait() + assert.equal(3, receipt?.logs?.length) + + log = receipt.logs && receipt.logs[2] + }) + + it('logs an event', async () => { + assert.equal(operator.address, log?.address) + + assert.equal(log?.topics?.[1], specId) + + const req = decodeRunRequest(receipt?.logs?.[2]) + assert.equal(await roles.defaultAccount.getAddress(), req.requester) + bigNumEquals(paid, req.payment) + }) + + it('uses the expected event signature', async () => { + // If updating this test, be sure to update models.RunLogTopic. + const eventSignature = + '0xd8d7ecc4800d25fa53ce0372f13a416d98907a7ef3d8d3bdd79cf4fe75529c65' + assert.equal(eventSignature, log?.topics?.[0]) + }) + + it('does not allow the same requestId to be used twice', async () => { + const args2 = encodeRequestOracleData( + specId, + fHash, + 1, + constants.HashZero, + ) + await evmRevert(link.transferAndCall(operator.address, paid, args2)) + }) + + describe('when called with a payload less than 2 EVM words + function selector', () => { + it('throws an error', async () => { + const funcSelector = + operatorFactory.interface.getSighash('oracleRequest') + const maliciousData = + funcSelector + + '0000000000000000000000000000000000000000000000000000000000000000000' + await evmRevert( + link.transferAndCall(operator.address, paid, maliciousData), + ) + }) + }) + + describe('when called with a payload between 3 and 9 EVM words', () => { + it('throws an error', async () => { + const funcSelector = + operatorFactory.interface.getSighash('oracleRequest') + const maliciousData = + funcSelector + + '000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001' + await evmRevert( + link.transferAndCall(operator.address, paid, maliciousData), + ) + }) + }) + }) + + describe('when dataVersion is higher than 255', () => { + it('throws an error', async () => { + const paid = 100 + const args = encodeRequestOracleData( + specId, + fHash, + 1, + constants.HashZero, + 256, + ) + await evmRevert(link.transferAndCall(operator.address, paid, args)) + }) + }) + + describe('when not called through the LINK token', () => { + it('reverts', async () => { + await evmRevert( + operator + .connect(roles.oracleNode) + .oracleRequest( + '0x0000000000000000000000000000000000000000', + 0, + specId, + to, + fHash, + 1, + 1, + '0x', + ), + ) + }) + }) + }) + + describe('#fulfillOracleRequest', () => { + const response = 'Hi Mom!' + let maliciousRequester: Contract + let basicConsumer: Contract + let maliciousConsumer: Contract + let gasGuzzlingConsumer: Contract + let request: ReturnType + + describe('gas guzzling consumer [ @skip-coverage ]', () => { + beforeEach(async () => { + gasGuzzlingConsumer = await gasGuzzlingConsumerFactory + .connect(roles.consumer) + .deploy(link.address, operator.address, specId) + const paymentAmount = toWei('1') + await link.transfer(gasGuzzlingConsumer.address, paymentAmount) + const tx = + await gasGuzzlingConsumer.gassyRequestEthereumPrice(paymentAmount) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + }) + + it('emits an OracleResponse event', async () => { + const fulfillParams = convertFufillParams(request, response) + const tx = await operator + .connect(roles.oracleNode) + .fulfillOracleRequest(...fulfillParams) + const receipt = await tx.wait() + assert.equal(receipt.events?.length, 1) + const responseEvent = receipt.events?.[0] + assert.equal(responseEvent?.event, 'OracleResponse') + assert.equal(responseEvent?.args?.[0], request.requestId) + }) + }) + + describe('cooperative consumer', () => { + beforeEach(async () => { + basicConsumer = await basicConsumerFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address, specId) + const paymentAmount = toWei('1') + await link.transfer(basicConsumer.address, paymentAmount) + const currency = 'USD' + const tx = await basicConsumer.requestEthereumPrice( + currency, + paymentAmount, + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + }) + + describe('when called by an unauthorized node', () => { + beforeEach(async () => { + assert.equal( + false, + await operator.isAuthorizedSender( + await roles.stranger.getAddress(), + ), + ) + }) + + it('raises an error', async () => { + await evmRevert( + operator + .connect(roles.stranger) + .fulfillOracleRequest(...convertFufillParams(request, response)), + ) + }) + }) + + describe('when fulfilled with the wrong function', () => { + let v7Consumer + beforeEach(async () => { + v7Consumer = await v7ConsumerFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address, specId) + const paymentAmount = toWei('1') + await link.transfer(v7Consumer.address, paymentAmount) + const currency = 'USD' + const tx = await v7Consumer.requestEthereumPrice( + currency, + paymentAmount, + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + }) + + it('raises an error', async () => { + await evmRevert( + operator + .connect(roles.stranger) + .fulfillOracleRequest(...convertFufillParams(request, response)), + ) + }) + }) + + describe('when called by an authorized node', () => { + it('raises an error if the request ID does not exist', async () => { + request.requestId = ethers.utils.formatBytes32String('DOESNOTEXIST') + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest(...convertFufillParams(request, response)), + ) + }) + + it('sets the value on the requested contract', async () => { + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest(...convertFufillParams(request, response)) + + const currentValue = await basicConsumer.currentPrice() + assert.equal(response, ethers.utils.parseBytes32String(currentValue)) + }) + + it('emits an OracleResponse event', async () => { + const fulfillParams = convertFufillParams(request, response) + const tx = await operator + .connect(roles.oracleNode) + .fulfillOracleRequest(...fulfillParams) + const receipt = await tx.wait() + assert.equal(receipt.events?.length, 3) + const responseEvent = receipt.events?.[0] + assert.equal(responseEvent?.event, 'OracleResponse') + assert.equal(responseEvent?.args?.[0], request.requestId) + }) + + it('does not allow a request to be fulfilled twice', async () => { + const response2 = response + ' && Hello World!!' + + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest(...convertFufillParams(request, response)) + + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest(...convertFufillParams(request, response2)), + ) + + const currentValue = await basicConsumer.currentPrice() + assert.equal(response, ethers.utils.parseBytes32String(currentValue)) + }) + }) + + describe('when the oracle does not provide enough gas', () => { + // if updating this defaultGasLimit, be sure it matches with the + // defaultGasLimit specified in store/tx_manager.go + const defaultGasLimit = 500000 + + beforeEach(async () => { + bigNumEquals(0, await operator.withdrawable()) + }) + + it('does not allow the oracle to withdraw the payment', async () => { + await evmRevert( + operator.connect(roles.oracleNode).fulfillOracleRequest( + ...convertFufillParams(request, response, { + gasLimit: 70000, + }), + ), + ) + + bigNumEquals(0, await operator.withdrawable()) + }) + + it(`${defaultGasLimit} is enough to pass the gas requirement`, async () => { + await operator.connect(roles.oracleNode).fulfillOracleRequest( + ...convertFufillParams(request, response, { + gasLimit: defaultGasLimit, + }), + ) + + bigNumEquals(request.payment, await operator.withdrawable()) + }) + }) + }) + + describe('with a malicious requester', () => { + beforeEach(async () => { + const paymentAmount = toWei('1') + maliciousRequester = await maliciousRequesterFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address) + await link.transfer(maliciousRequester.address, paymentAmount) + }) + + it('cannot cancel before the expiration', async () => { + await evmRevert( + maliciousRequester.maliciousRequestCancel( + specId, + ethers.utils.toUtf8Bytes('doesNothing(bytes32,bytes32)'), + ), + ) + }) + + it('cannot call functions on the LINK token through callbacks', async () => { + await evmRevert( + maliciousRequester.request( + specId, + link.address, + ethers.utils.toUtf8Bytes('transfer(address,uint256)'), + ), + ) + }) + + describe('requester lies about amount of LINK sent', () => { + it('the oracle uses the amount of LINK actually paid', async () => { + const tx = await maliciousRequester.maliciousPrice(specId) + const receipt = await tx.wait() + const req = decodeRunRequest(receipt.logs?.[3]) + + assert(toWei('1').eq(req.payment)) + }) + }) + }) + + describe('with a malicious consumer', () => { + const paymentAmount = toWei('1') + + beforeEach(async () => { + maliciousConsumer = await maliciousConsumerFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address) + await link.transfer(maliciousConsumer.address, paymentAmount) + }) + + describe('fails during fulfillment', () => { + beforeEach(async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('assertFail(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + }) + + it('allows the oracle node to receive their payment', async () => { + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest(...convertFufillParams(request, response)) + + const balance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(balance, 0) + + await operator + .connect(roles.defaultAccount) + .withdraw(await roles.oracleNode.getAddress(), paymentAmount) + + const newBalance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(paymentAmount, newBalance) + }) + + it("can't fulfill the data again", async () => { + const response2 = 'hack the planet 102' + + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest(...convertFufillParams(request, response)) + + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest(...convertFufillParams(request, response2)), + ) + }) + }) + + describe('calls selfdestruct', () => { + beforeEach(async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('doesNothing(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + await maliciousConsumer.remove() + }) + + it('allows the oracle node to receive their payment', async () => { + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest(...convertFufillParams(request, response)) + + const balance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(balance, 0) + + await operator + .connect(roles.defaultAccount) + .withdraw(await roles.oracleNode.getAddress(), paymentAmount) + const newBalance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(paymentAmount, newBalance) + }) + }) + + describe('request is canceled during fulfillment', () => { + beforeEach(async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('cancelRequestOnFulfill(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + + bigNumEquals(0, await link.balanceOf(maliciousConsumer.address)) + }) + + it('allows the oracle node to receive their payment', async () => { + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest(...convertFufillParams(request, response)) + + const mockBalance = await link.balanceOf(maliciousConsumer.address) + bigNumEquals(mockBalance, 0) + + const balance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(balance, 0) + + await operator + .connect(roles.defaultAccount) + .withdraw(await roles.oracleNode.getAddress(), paymentAmount) + const newBalance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(paymentAmount, newBalance) + }) + + it("can't fulfill the data again", async () => { + const response2 = 'hack the planet 102' + + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest(...convertFufillParams(request, response)) + + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest(...convertFufillParams(request, response2)), + ) + }) + }) + + describe('tries to steal funds from node', () => { + it('is not successful with call', async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('stealEthCall(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest(...convertFufillParams(request, response)) + + bigNumEquals( + 0, + await ethers.provider.getBalance(maliciousConsumer.address), + ) + }) + + it('is not successful with send', async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('stealEthSend(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest(...convertFufillParams(request, response)) + bigNumEquals( + 0, + await ethers.provider.getBalance(maliciousConsumer.address), + ) + }) + + it('is not successful with transfer', async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('stealEthTransfer(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest(...convertFufillParams(request, response)) + bigNumEquals( + 0, + await ethers.provider.getBalance(maliciousConsumer.address), + ) + }) + }) + + describe('when calling an owned contract', () => { + beforeEach(async () => { + forwarder1 = await forwarderFactory + .connect(roles.defaultAccount) + .deploy(link.address, link.address, operator.address, '0x') + }) + + it('does not allow the contract to callback to owned contracts', async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('whatever(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + let request = decodeRunRequest(receipt.logs?.[3]) + let responseParams = convertFufillParams(request, response) + // set the params to be the owned address + responseParams[2] = forwarder1.address + + //accept ownership + await operator + .connect(roles.defaultAccount) + .acceptOwnableContracts([forwarder1.address]) + + // do the thing + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest(...responseParams), + 'Cannot call owned contract', + ) + + await operator + .connect(roles.defaultAccount) + .transferOwnableContracts([forwarder1.address], link.address) + //reverts for a different reason after transferring ownership + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest(...responseParams), + 'Params do not match request ID', + ) + }) + }) + }) + }) + + describe('#fulfillOracleRequest2', () => { + describe('single word fulfils', () => { + const response = 'Hi mom!' + const responseTypes = ['bytes32'] + const responseValues = [toBytes32String(response)] + let maliciousRequester: Contract + let basicConsumer: Contract + let maliciousConsumer: Contract + let gasGuzzlingConsumer: Contract + let request: ReturnType + let request2: ReturnType + + describe('gas guzzling consumer [ @skip-coverage ]', () => { + beforeEach(async () => { + gasGuzzlingConsumer = await gasGuzzlingConsumerFactory + .connect(roles.consumer) + .deploy(link.address, operator.address, specId) + const paymentAmount = toWei('1') + await link.transfer(gasGuzzlingConsumer.address, paymentAmount) + const tx = + await gasGuzzlingConsumer.gassyRequestEthereumPrice(paymentAmount) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + }) + + it('emits an OracleResponse2 event', async () => { + const fulfillParams = convertFulfill2Params( + request, + responseTypes, + responseValues, + ) + const tx = await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2(...fulfillParams) + const receipt = await tx.wait() + assert.equal(receipt.events?.length, 1) + const responseEvent = receipt.events?.[0] + assert.equal(responseEvent?.event, 'OracleResponse') + assert.equal(responseEvent?.args?.[0], request.requestId) + }) + }) + + describe('cooperative consumer', () => { + beforeEach(async () => { + basicConsumer = await basicConsumerFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address, specId) + const paymentAmount = toWei('1') + await link.transfer(basicConsumer.address, paymentAmount) + const currency = 'USD' + const tx = await basicConsumer.requestEthereumPrice( + currency, + paymentAmount, + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + }) + + describe('when called by an unauthorized node', () => { + beforeEach(async () => { + assert.equal( + false, + await operator.isAuthorizedSender( + await roles.stranger.getAddress(), + ), + ) + }) + + it('raises an error', async () => { + await evmRevert( + operator + .connect(roles.stranger) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ), + ) + }) + }) + + describe('when called by an authorized node', () => { + it('raises an error if the request ID does not exist', async () => { + request.requestId = ethers.utils.formatBytes32String('DOESNOTEXIST') + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ), + ) + }) + + it('sets the value on the requested contract', async () => { + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + const currentValue = await basicConsumer.currentPrice() + assert.equal( + response, + ethers.utils.parseBytes32String(currentValue), + ) + }) + + it('emits an OracleResponse2 event', async () => { + const fulfillParams = convertFulfill2Params( + request, + responseTypes, + responseValues, + ) + const tx = await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2(...fulfillParams) + const receipt = await tx.wait() + assert.equal(receipt.events?.length, 3) + const responseEvent = receipt.events?.[0] + assert.equal(responseEvent?.event, 'OracleResponse') + assert.equal(responseEvent?.args?.[0], request.requestId) + }) + + it('does not allow a request to be fulfilled twice', async () => { + const response2 = response + ' && Hello World!!' + const response2Values = [toBytes32String(response2)] + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + response2Values, + ), + ), + ) + + const currentValue = await basicConsumer.currentPrice() + assert.equal( + response, + ethers.utils.parseBytes32String(currentValue), + ) + }) + }) + + describe('when the oracle does not provide enough gas', () => { + // if updating this defaultGasLimit, be sure it matches with the + // defaultGasLimit specified in store/tx_manager.go + const defaultGasLimit = 500000 + + beforeEach(async () => { + bigNumEquals(0, await operator.withdrawable()) + }) + + it('does not allow the oracle to withdraw the payment', async () => { + await evmRevert( + operator.connect(roles.oracleNode).fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + { + gasLimit: 70000, + }, + ), + ), + ) + + bigNumEquals(0, await operator.withdrawable()) + }) + + it(`${defaultGasLimit} is enough to pass the gas requirement`, async () => { + await operator.connect(roles.oracleNode).fulfillOracleRequest2( + ...convertFulfill2Params(request, responseTypes, responseValues, { + gasLimit: defaultGasLimit, + }), + ) + + bigNumEquals(request.payment, await operator.withdrawable()) + }) + }) + }) + + describe('with a malicious oracle', () => { + beforeEach(async () => { + // Setup Request 1 + basicConsumer = await basicConsumerFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address, specId) + const paymentAmount = toWei('1') + await link.transfer(basicConsumer.address, paymentAmount) + const currency = 'USD' + const tx = await basicConsumer.requestEthereumPrice( + currency, + paymentAmount, + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + // Setup Request 2 + await link.transfer(basicConsumer.address, paymentAmount) + const tx2 = await basicConsumer.requestEthereumPrice( + currency, + paymentAmount, + ) + const receipt2 = await tx2.wait() + request2 = decodeRunRequest(receipt2.logs?.[3]) + }) + + it('cannot spoof requestId in response data by moving calldata offset', async () => { + // Malicious Oracle Fulfill 2 + const functionSelector = '0x6ae0bc76' // fulfillOracleRequest2 + const dataOffset = + '0000000000000000000000000000000000000000000000000000000000000100' // Moved to 0x0124 + const fillerBytes = + '0000000000000000000000000000000000000000000000000000000000000000' + const expectedCalldataStart = request.requestId.slice(2) // 0xe4, this is checked against requestId in validateMultiWordResponseId + const dataSize = + '0000000000000000000000000000000000000000000000000000000000000040' // Two 32 byte blocks + const maliciousCalldataId = request2.requestId.slice(2) // 0x0124, set to a different requestId + const calldataData = + '1122334455667788991122334455667788991122334455667788991122334455' // some garbage value as response value + + const data = + functionSelector + + /** Input Params - slice off 0x prefix and pad with 0's */ + request.requestId.slice(2) + + request.payment.slice(2).padStart(64, '0') + + request.callbackAddr.slice(2).padStart(64, '0') + + request.callbackFunc.slice(2).padEnd(64, '0') + + request.expiration.slice(2).padStart(64, '0') + + // calldata "data" + dataOffset + + fillerBytes + + expectedCalldataStart + + dataSize + + maliciousCalldataId + + calldataData + + await evmRevert( + operator.connect(roles.oracleNode).signer.sendTransaction({ + to: operator.address, + data, + }), + ) + }) + }) + + describe('with a malicious requester', () => { + beforeEach(async () => { + const paymentAmount = toWei('1') + maliciousRequester = await maliciousRequesterFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address) + await link.transfer(maliciousRequester.address, paymentAmount) + }) + + it('cannot cancel before the expiration', async () => { + await evmRevert( + maliciousRequester.maliciousRequestCancel( + specId, + ethers.utils.toUtf8Bytes('doesNothing(bytes32,bytes32)'), + ), + ) + }) + + it('cannot call functions on the LINK token through callbacks', async () => { + await evmRevert( + maliciousRequester.request( + specId, + link.address, + ethers.utils.toUtf8Bytes('transfer(address,uint256)'), + ), + ) + }) + + describe('requester lies about amount of LINK sent', () => { + it('the oracle uses the amount of LINK actually paid', async () => { + const tx = await maliciousRequester.maliciousPrice(specId) + const receipt = await tx.wait() + const req = decodeRunRequest(receipt.logs?.[3]) + + assert(toWei('1').eq(req.payment)) + }) + }) + }) + + describe('with a malicious consumer', () => { + const paymentAmount = toWei('1') + + beforeEach(async () => { + maliciousConsumer = await maliciousConsumerFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address) + await link.transfer(maliciousConsumer.address, paymentAmount) + }) + + describe('fails during fulfillment', () => { + beforeEach(async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('assertFail(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + }) + + it('allows the oracle node to receive their payment', async () => { + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + const balance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(balance, 0) + + await operator + .connect(roles.defaultAccount) + .withdraw(await roles.oracleNode.getAddress(), paymentAmount) + + const newBalance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(paymentAmount, newBalance) + }) + + it("can't fulfill the data again", async () => { + const response2 = 'hack the planet 102' + const response2Values = [toBytes32String(response2)] + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + response2Values, + ), + ), + ) + }) + }) + + describe('calls selfdestruct', () => { + beforeEach(async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('doesNothing(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + await maliciousConsumer.remove() + }) + + it('allows the oracle node to receive their payment', async () => { + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + const balance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(balance, 0) + + await operator + .connect(roles.defaultAccount) + .withdraw(await roles.oracleNode.getAddress(), paymentAmount) + const newBalance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(paymentAmount, newBalance) + }) + }) + + describe('request is canceled during fulfillment', () => { + beforeEach(async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes( + 'cancelRequestOnFulfill(bytes32,bytes32)', + ), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + + bigNumEquals(0, await link.balanceOf(maliciousConsumer.address)) + }) + + it('allows the oracle node to receive their payment', async () => { + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + const mockBalance = await link.balanceOf(maliciousConsumer.address) + bigNumEquals(mockBalance, 0) + + const balance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(balance, 0) + + await operator + .connect(roles.defaultAccount) + .withdraw(await roles.oracleNode.getAddress(), paymentAmount) + const newBalance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(paymentAmount, newBalance) + }) + + it("can't fulfill the data again", async () => { + const response2 = 'hack the planet 102' + const response2Values = [toBytes32String(response2)] + + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + response2Values, + ), + ), + ) + }) + }) + + describe('tries to steal funds from node', () => { + it('is not successful with call', async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('stealEthCall(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + bigNumEquals( + 0, + await ethers.provider.getBalance(maliciousConsumer.address), + ) + }) + + it('is not successful with send', async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('stealEthSend(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + bigNumEquals( + 0, + await ethers.provider.getBalance(maliciousConsumer.address), + ) + }) + + it('is not successful with transfer', async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('stealEthTransfer(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + bigNumEquals( + 0, + await ethers.provider.getBalance(maliciousConsumer.address), + ) + }) + }) + + describe('when calling an owned contract', () => { + beforeEach(async () => { + forwarder1 = await forwarderFactory + .connect(roles.defaultAccount) + .deploy(link.address, link.address, operator.address, '0x') + }) + + it('does not allow the contract to callback to owned contracts', async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('whatever(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + let request = decodeRunRequest(receipt.logs?.[3]) + let responseParams = convertFufillParams(request, response) + // set the params to be the owned address + responseParams[2] = forwarder1.address + + //accept ownership + await operator + .connect(roles.defaultAccount) + .acceptOwnableContracts([forwarder1.address]) + + // do the thing + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest2(...responseParams), + 'Cannot call owned contract', + ) + + await operator + .connect(roles.defaultAccount) + .transferOwnableContracts([forwarder1.address], link.address) + //reverts for a different reason after transferring ownership + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest(...responseParams), + 'Params do not match request ID', + ) + }) + }) + }) + }) + + describe('multi word fulfils', () => { + describe('one bytes parameter', () => { + const response = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.\ + Fusce euismod malesuada ligula, eget semper metus ultrices sit amet.' + const responseTypes = ['bytes'] + const responseValues = [stringToBytes(response)] + let maliciousRequester: Contract + let multiConsumer: Contract + let maliciousConsumer: Contract + let gasGuzzlingConsumer: Contract + let request: ReturnType + + describe('gas guzzling consumer [ @skip-coverage ]', () => { + beforeEach(async () => { + gasGuzzlingConsumer = await gasGuzzlingConsumerFactory + .connect(roles.consumer) + .deploy(link.address, operator.address, specId) + const paymentAmount = toWei('1') + await link.transfer(gasGuzzlingConsumer.address, paymentAmount) + const tx = + await gasGuzzlingConsumer.gassyMultiWordRequest(paymentAmount) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + }) + + it('emits an OracleResponse2 event', async () => { + const fulfillParams = convertFulfill2Params( + request, + responseTypes, + responseValues, + ) + const tx = await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2(...fulfillParams) + const receipt = await tx.wait() + assert.equal(receipt.events?.length, 1) + const responseEvent = receipt.events?.[0] + assert.equal(responseEvent?.event, 'OracleResponse') + assert.equal(responseEvent?.args?.[0], request.requestId) + }) + }) + + describe('cooperative consumer', () => { + beforeEach(async () => { + multiConsumer = await multiWordConsumerFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address, specId) + const paymentAmount = toWei('1') + await link.transfer(multiConsumer.address, paymentAmount) + const currency = 'USD' + const tx = await multiConsumer.requestEthereumPrice( + currency, + paymentAmount, + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + }) + + it("matches the consumer's request ID", async () => { + const nonce = await multiConsumer.publicGetNextRequestCount() + const tx = await multiConsumer.requestEthereumPrice('USD', 0) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + const packed = ethers.utils.solidityPack( + ['address', 'uint256'], + [multiConsumer.address, nonce], + ) + const expected = ethers.utils.keccak256(packed) + assert.equal(expected, request.requestId) + }) + + describe('when called by an unauthorized node', () => { + beforeEach(async () => { + assert.equal( + false, + await operator.isAuthorizedSender( + await roles.stranger.getAddress(), + ), + ) + }) + + it('raises an error', async () => { + await evmRevert( + operator + .connect(roles.stranger) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ), + ) + }) + }) + + describe('when called by an authorized node', () => { + it('raises an error if the request ID does not exist', async () => { + request.requestId = + ethers.utils.formatBytes32String('DOESNOTEXIST') + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ), + ) + }) + + it('sets the value on the requested contract', async () => { + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + const currentValue = await multiConsumer.currentPrice() + assert.equal(response, ethers.utils.toUtf8String(currentValue)) + }) + + it('emits an OracleResponse2 event', async () => { + const fulfillParams = convertFulfill2Params( + request, + responseTypes, + responseValues, + ) + const tx = await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2(...fulfillParams) + const receipt = await tx.wait() + assert.equal(receipt.events?.length, 3) + const responseEvent = receipt.events?.[0] + assert.equal(responseEvent?.event, 'OracleResponse') + assert.equal(responseEvent?.args?.[0], request.requestId) + }) + + it('does not allow a request to be fulfilled twice', async () => { + const response2 = response + ' && Hello World!!' + const response2Values = [stringToBytes(response2)] + + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + response2Values, + ), + ), + ) + + const currentValue = await multiConsumer.currentPrice() + assert.equal(response, ethers.utils.toUtf8String(currentValue)) + }) + }) + + describe('when the oracle does not provide enough gas', () => { + // if updating this defaultGasLimit, be sure it matches with the + // defaultGasLimit specified in store/tx_manager.go + const defaultGasLimit = 500000 + + beforeEach(async () => { + bigNumEquals(0, await operator.withdrawable()) + }) + + it('does not allow the oracle to withdraw the payment', async () => { + await evmRevert( + operator.connect(roles.oracleNode).fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + { + gasLimit: 70000, + }, + ), + ), + ) + + bigNumEquals(0, await operator.withdrawable()) + }) + + it(`${defaultGasLimit} is enough to pass the gas requirement`, async () => { + await operator.connect(roles.oracleNode).fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + { + gasLimit: defaultGasLimit, + }, + ), + ) + + bigNumEquals(request.payment, await operator.withdrawable()) + }) + }) + }) + + describe('with a malicious requester', () => { + beforeEach(async () => { + const paymentAmount = toWei('1') + maliciousRequester = await maliciousRequesterFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address) + await link.transfer(maliciousRequester.address, paymentAmount) + }) + + it('cannot cancel before the expiration', async () => { + await evmRevert( + maliciousRequester.maliciousRequestCancel( + specId, + ethers.utils.toUtf8Bytes('doesNothing(bytes32,bytes32)'), + ), + ) + }) + + it('cannot call functions on the LINK token through callbacks', async () => { + await evmRevert( + maliciousRequester.request( + specId, + link.address, + ethers.utils.toUtf8Bytes('transfer(address,uint256)'), + ), + ) + }) + + describe('requester lies about amount of LINK sent', () => { + it('the oracle uses the amount of LINK actually paid', async () => { + const tx = await maliciousRequester.maliciousPrice(specId) + const receipt = await tx.wait() + const req = decodeRunRequest(receipt.logs?.[3]) + + assert(toWei('1').eq(req.payment)) + }) + }) + }) + + describe('with a malicious consumer', () => { + const paymentAmount = toWei('1') + + beforeEach(async () => { + maliciousConsumer = await maliciousMultiWordConsumerFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address) + await link.transfer(maliciousConsumer.address, paymentAmount) + }) + + describe('fails during fulfillment', () => { + beforeEach(async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('assertFail(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + }) + + it('allows the oracle node to receive their payment', async () => { + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + const balance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(balance, 0) + + await operator + .connect(roles.defaultAccount) + .withdraw(await roles.oracleNode.getAddress(), paymentAmount) + + const newBalance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(paymentAmount, newBalance) + }) + + it("can't fulfill the data again", async () => { + const response2 = 'hack the planet 102' + const response2Values = [stringToBytes(response2)] + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + response2Values, + ), + ), + ) + }) + }) + + describe('calls selfdestruct', () => { + beforeEach(async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('doesNothing(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + await maliciousConsumer.remove() + }) + + it('allows the oracle node to receive their payment', async () => { + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + const balance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(balance, 0) + + await operator + .connect(roles.defaultAccount) + .withdraw(await roles.oracleNode.getAddress(), paymentAmount) + const newBalance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(paymentAmount, newBalance) + }) + }) + + describe('request is canceled during fulfillment', () => { + beforeEach(async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes( + 'cancelRequestOnFulfill(bytes32,bytes32)', + ), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + + bigNumEquals(0, await link.balanceOf(maliciousConsumer.address)) + }) + + it('allows the oracle node to receive their payment', async () => { + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + const mockBalance = await link.balanceOf( + maliciousConsumer.address, + ) + bigNumEquals(mockBalance, 0) + + const balance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(balance, 0) + + await operator + .connect(roles.defaultAccount) + .withdraw(await roles.oracleNode.getAddress(), paymentAmount) + const newBalance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(paymentAmount, newBalance) + }) + + it("can't fulfill the data again", async () => { + const response2 = 'hack the planet 102' + const response2Values = [stringToBytes(response2)] + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + response2Values, + ), + ), + ) + }) + }) + + describe('tries to steal funds from node', () => { + it('is not successful with call', async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('stealEthCall(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + bigNumEquals( + 0, + await ethers.provider.getBalance(maliciousConsumer.address), + ) + }) + + it('is not successful with send', async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('stealEthSend(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + bigNumEquals( + 0, + await ethers.provider.getBalance(maliciousConsumer.address), + ) + }) + + it('is not successful with transfer', async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('stealEthTransfer(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + bigNumEquals( + 0, + await ethers.provider.getBalance(maliciousConsumer.address), + ) + }) + }) + }) + }) + + describe('multiple bytes32 parameters', () => { + const response1 = '100' + const response2 = '7777777' + const response3 = 'forty two' + const responseTypes = ['bytes32', 'bytes32', 'bytes32'] + const responseValues = [ + toBytes32String(response1), + toBytes32String(response2), + toBytes32String(response3), + ] + let maliciousRequester: Contract + let multiConsumer: Contract + let maliciousConsumer: Contract + let gasGuzzlingConsumer: Contract + let request: ReturnType + + describe('gas guzzling consumer [ @skip-coverage ]', () => { + beforeEach(async () => { + gasGuzzlingConsumer = await gasGuzzlingConsumerFactory + .connect(roles.consumer) + .deploy(link.address, operator.address, specId) + const paymentAmount = toWei('1') + await link.transfer(gasGuzzlingConsumer.address, paymentAmount) + const tx = + await gasGuzzlingConsumer.gassyMultiWordRequest(paymentAmount) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + }) + + it('emits an OracleResponse2 event', async () => { + const fulfillParams = convertFulfill2Params( + request, + responseTypes, + responseValues, + ) + const tx = await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2(...fulfillParams) + const receipt = await tx.wait() + assert.equal(receipt.events?.length, 1) + const responseEvent = receipt.events?.[0] + assert.equal(responseEvent?.event, 'OracleResponse') + assert.equal(responseEvent?.args?.[0], request.requestId) + }) + }) + + describe('cooperative consumer', () => { + beforeEach(async () => { + multiConsumer = await multiWordConsumerFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address, specId) + const paymentAmount = toWei('1') + await link.transfer(multiConsumer.address, paymentAmount) + const currency = 'USD' + const tx = await multiConsumer.requestMultipleParameters( + currency, + paymentAmount, + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + }) + + describe('when called by an unauthorized node', () => { + beforeEach(async () => { + assert.equal( + false, + await operator.isAuthorizedSender( + await roles.stranger.getAddress(), + ), + ) + }) + + it('raises an error', async () => { + await evmRevert( + operator + .connect(roles.stranger) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ), + ) + }) + }) + + describe('when called by an authorized node', () => { + it('raises an error if the request ID does not exist', async () => { + request.requestId = + ethers.utils.formatBytes32String('DOESNOTEXIST') + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ), + ) + }) + + it('sets the value on the requested contract', async () => { + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + const firstValue = await multiConsumer.usd() + const secondValue = await multiConsumer.eur() + const thirdValue = await multiConsumer.jpy() + assert.equal( + response1, + ethers.utils.parseBytes32String(firstValue), + ) + assert.equal( + response2, + ethers.utils.parseBytes32String(secondValue), + ) + assert.equal( + response3, + ethers.utils.parseBytes32String(thirdValue), + ) + }) + + it('emits an OracleResponse2 event', async () => { + const fulfillParams = convertFulfill2Params( + request, + responseTypes, + responseValues, + ) + const tx = await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2(...fulfillParams) + const receipt = await tx.wait() + assert.equal(receipt.events?.length, 3) + const responseEvent = receipt.events?.[0] + assert.equal(responseEvent?.event, 'OracleResponse') + assert.equal(responseEvent?.args?.[0], request.requestId) + }) + + it('does not allow a request to be fulfilled twice', async () => { + const response4 = response3 + ' && Hello World!!' + const repeatedResponseValues = [ + toBytes32String(response1), + toBytes32String(response2), + toBytes32String(response4), + ] + + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + repeatedResponseValues, + ), + ), + ) + + const firstValue = await multiConsumer.usd() + const secondValue = await multiConsumer.eur() + const thirdValue = await multiConsumer.jpy() + assert.equal( + response1, + ethers.utils.parseBytes32String(firstValue), + ) + assert.equal( + response2, + ethers.utils.parseBytes32String(secondValue), + ) + assert.equal( + response3, + ethers.utils.parseBytes32String(thirdValue), + ) + }) + }) + + describe('when the oracle does not provide enough gas', () => { + // if updating this defaultGasLimit, be sure it matches with the + // defaultGasLimit specified in store/tx_manager.go + const defaultGasLimit = 500000 + + beforeEach(async () => { + bigNumEquals(0, await operator.withdrawable()) + }) + + it('does not allow the oracle to withdraw the payment', async () => { + await evmRevert( + operator.connect(roles.oracleNode).fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + { + gasLimit: 70000, + }, + ), + ), + ) + + bigNumEquals(0, await operator.withdrawable()) + }) + + it(`${defaultGasLimit} is enough to pass the gas requirement`, async () => { + await operator.connect(roles.oracleNode).fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + { + gasLimit: defaultGasLimit, + }, + ), + ) + + bigNumEquals(request.payment, await operator.withdrawable()) + }) + }) + }) + + describe('with a malicious requester', () => { + beforeEach(async () => { + const paymentAmount = toWei('1') + maliciousRequester = await maliciousRequesterFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address) + await link.transfer(maliciousRequester.address, paymentAmount) + }) + + it('cannot cancel before the expiration', async () => { + await evmRevert( + maliciousRequester.maliciousRequestCancel( + specId, + ethers.utils.toUtf8Bytes('doesNothing(bytes32,bytes32)'), + ), + ) + }) + + it('cannot call functions on the LINK token through callbacks', async () => { + await evmRevert( + maliciousRequester.request( + specId, + link.address, + ethers.utils.toUtf8Bytes('transfer(address,uint256)'), + ), + ) + }) + + describe('requester lies about amount of LINK sent', () => { + it('the oracle uses the amount of LINK actually paid', async () => { + const tx = await maliciousRequester.maliciousPrice(specId) + const receipt = await tx.wait() + const req = decodeRunRequest(receipt.logs?.[3]) + + assert(toWei('1').eq(req.payment)) + }) + }) + }) + + describe('with a malicious consumer', () => { + const paymentAmount = toWei('1') + + beforeEach(async () => { + maliciousConsumer = await maliciousMultiWordConsumerFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address) + await link.transfer(maliciousConsumer.address, paymentAmount) + }) + + describe('fails during fulfillment', () => { + beforeEach(async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('assertFail(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + }) + + it('allows the oracle node to receive their payment', async () => { + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + const balance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(balance, 0) + + await operator + .connect(roles.defaultAccount) + .withdraw(await roles.oracleNode.getAddress(), paymentAmount) + + const newBalance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(paymentAmount, newBalance) + }) + + it("can't fulfill the data again", async () => { + const response4 = 'hack the planet 102' + const repeatedResponseValues = [ + toBytes32String(response1), + toBytes32String(response2), + toBytes32String(response4), + ] + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + repeatedResponseValues, + ), + ), + ) + }) + }) + + describe('calls selfdestruct', () => { + beforeEach(async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('doesNothing(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + await maliciousConsumer.remove() + }) + + it('allows the oracle node to receive their payment', async () => { + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + const balance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(balance, 0) + + await operator + .connect(roles.defaultAccount) + .withdraw(await roles.oracleNode.getAddress(), paymentAmount) + const newBalance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(paymentAmount, newBalance) + }) + }) + + describe('request is canceled during fulfillment', () => { + beforeEach(async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes( + 'cancelRequestOnFulfill(bytes32,bytes32)', + ), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + + bigNumEquals(0, await link.balanceOf(maliciousConsumer.address)) + }) + + it('allows the oracle node to receive their payment', async () => { + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + const mockBalance = await link.balanceOf( + maliciousConsumer.address, + ) + bigNumEquals(mockBalance, 0) + + const balance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(balance, 0) + + await operator + .connect(roles.defaultAccount) + .withdraw(await roles.oracleNode.getAddress(), paymentAmount) + const newBalance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + bigNumEquals(paymentAmount, newBalance) + }) + + it("can't fulfill the data again", async () => { + const response4 = 'hack the planet 102' + const repeatedResponseValues = [ + toBytes32String(response1), + toBytes32String(response2), + toBytes32String(response4), + ] + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + repeatedResponseValues, + ), + ), + ) + }) + }) + + describe('tries to steal funds from node', () => { + it('is not successful with call', async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('stealEthCall(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + + bigNumEquals( + 0, + await ethers.provider.getBalance(maliciousConsumer.address), + ) + }) + + it('is not successful with send', async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('stealEthSend(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + bigNumEquals( + 0, + await ethers.provider.getBalance(maliciousConsumer.address), + ) + }) + + it('is not successful with transfer', async () => { + const tx = await maliciousConsumer.requestData( + specId, + ethers.utils.toUtf8Bytes('stealEthTransfer(bytes32,bytes32)'), + ) + const receipt = await tx.wait() + request = decodeRunRequest(receipt.logs?.[3]) + + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest2( + ...convertFulfill2Params( + request, + responseTypes, + responseValues, + ), + ) + bigNumEquals( + 0, + await ethers.provider.getBalance(maliciousConsumer.address), + ) + }) + }) + }) + }) + }) + + describe('when the response data is too short', () => { + const response = 'Hi mom!' + const responseTypes = ['bytes32'] + const responseValues = [toBytes32String(response)] + + it('reverts', async () => { + let basicConsumer = await basicConsumerFactory + .connect(roles.defaultAccount) + .deploy(link.address, operator.address, specId) + const paymentAmount = toWei('1') + await link.transfer(basicConsumer.address, paymentAmount) + const tx = await basicConsumer.requestEthereumPrice( + 'USD', + paymentAmount, + ) + const receipt = await tx.wait() + let request = decodeRunRequest(receipt.logs?.[3]) + + const fulfillParams = convertFulfill2Params( + request, + responseTypes, + responseValues, + ) + fulfillParams[5] = '0x' // overwrite the data to be of lenght 0 + await evmRevert( + operator + .connect(roles.oracleNode) + .fulfillOracleRequest2(...fulfillParams), + 'Response must be > 32 bytes', + ) + }) + }) + }) + + describe('#withdraw', () => { + describe('without reserving funds via oracleRequest', () => { + it('does nothing', async () => { + let balance = await link.balanceOf(await roles.oracleNode.getAddress()) + assert.equal(0, balance.toNumber()) + await evmRevert( + operator + .connect(roles.defaultAccount) + .withdraw(await roles.oracleNode.getAddress(), toWei('1')), + ) + balance = await link.balanceOf(await roles.oracleNode.getAddress()) + assert.equal(0, balance.toNumber()) + }) + + describe('recovering funds that were mistakenly sent', () => { + const paid = 1 + beforeEach(async () => { + await link.transfer(operator.address, paid) + }) + + it('withdraws funds', async () => { + const operatorBalanceBefore = await link.balanceOf(operator.address) + const accountBalanceBefore = await link.balanceOf( + await roles.defaultAccount.getAddress(), + ) + + await operator + .connect(roles.defaultAccount) + .withdraw(await roles.defaultAccount.getAddress(), paid) + + const operatorBalanceAfter = await link.balanceOf(operator.address) + const accountBalanceAfter = await link.balanceOf( + await roles.defaultAccount.getAddress(), + ) + + const accountDifference = + accountBalanceAfter.sub(accountBalanceBefore) + const operatorDifference = + operatorBalanceBefore.sub(operatorBalanceAfter) + + bigNumEquals(operatorDifference, paid) + bigNumEquals(accountDifference, paid) + }) + }) + }) + + describe('reserving funds via oracleRequest', () => { + const payment = 15 + let request: ReturnType + + beforeEach(async () => { + const requester = await roles.defaultAccount.getAddress() + const args = encodeOracleRequest( + specId, + requester, + fHash, + 0, + constants.HashZero, + ) + const tx = await link.transferAndCall(operator.address, payment, args) + const receipt = await tx.wait() + assert.equal(3, receipt.logs?.length) + request = decodeRunRequest(receipt.logs?.[2]) + }) + + describe('but not freeing funds w fulfillOracleRequest', () => { + it('does not transfer funds', async () => { + await evmRevert( + operator + .connect(roles.defaultAccount) + .withdraw(await roles.oracleNode.getAddress(), payment), + ) + const balance = await link.balanceOf( + await roles.oracleNode.getAddress(), + ) + assert.equal(0, balance.toNumber()) + }) + + describe('recovering funds that were mistakenly sent', () => { + const paid = 1 + beforeEach(async () => { + await link.transfer(operator.address, paid) + }) + + it('withdraws funds', async () => { + const operatorBalanceBefore = await link.balanceOf(operator.address) + const accountBalanceBefore = await link.balanceOf( + await roles.defaultAccount.getAddress(), + ) + + await operator + .connect(roles.defaultAccount) + .withdraw(await roles.defaultAccount.getAddress(), paid) + + const operatorBalanceAfter = await link.balanceOf(operator.address) + const accountBalanceAfter = await link.balanceOf( + await roles.defaultAccount.getAddress(), + ) + + const accountDifference = + accountBalanceAfter.sub(accountBalanceBefore) + const operatorDifference = + operatorBalanceBefore.sub(operatorBalanceAfter) + + bigNumEquals(operatorDifference, paid) + bigNumEquals(accountDifference, paid) + }) + }) + }) + + describe('and freeing funds', () => { + beforeEach(async () => { + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest( + ...convertFufillParams(request, 'Hello World!'), + ) + }) + + it('does not allow input greater than the balance', async () => { + const originalOracleBalance = await link.balanceOf(operator.address) + const originalStrangerBalance = await link.balanceOf( + await roles.stranger.getAddress(), + ) + const withdrawalAmount = payment + 1 + + assert.isAbove(withdrawalAmount, originalOracleBalance.toNumber()) + await evmRevert( + operator + .connect(roles.defaultAccount) + .withdraw(await roles.stranger.getAddress(), withdrawalAmount), + ) + + const newOracleBalance = await link.balanceOf(operator.address) + const newStrangerBalance = await link.balanceOf( + await roles.stranger.getAddress(), + ) + + assert.equal( + originalOracleBalance.toNumber(), + newOracleBalance.toNumber(), + ) + assert.equal( + originalStrangerBalance.toNumber(), + newStrangerBalance.toNumber(), + ) + }) + + it('allows transfer of partial balance by owner to specified address', async () => { + const partialAmount = 6 + const difference = payment - partialAmount + await operator + .connect(roles.defaultAccount) + .withdraw(await roles.stranger.getAddress(), partialAmount) + const strangerBalance = await link.balanceOf( + await roles.stranger.getAddress(), + ) + const oracleBalance = await link.balanceOf(operator.address) + assert.equal(partialAmount, strangerBalance.toNumber()) + assert.equal(difference, oracleBalance.toNumber()) + }) + + it('allows transfer of entire balance by owner to specified address', async () => { + await operator + .connect(roles.defaultAccount) + .withdraw(await roles.stranger.getAddress(), payment) + const balance = await link.balanceOf( + await roles.stranger.getAddress(), + ) + assert.equal(payment, balance.toNumber()) + }) + + it('does not allow a transfer of funds by non-owner', async () => { + await evmRevert( + operator + .connect(roles.stranger) + .withdraw(await roles.stranger.getAddress(), payment), + ) + const balance = await link.balanceOf( + await roles.stranger.getAddress(), + ) + assert.isTrue(ethers.constants.Zero.eq(balance)) + }) + + describe('recovering funds that were mistakenly sent', () => { + const paid = 1 + beforeEach(async () => { + await link.transfer(operator.address, paid) + }) + + it('withdraws funds', async () => { + const operatorBalanceBefore = await link.balanceOf(operator.address) + const accountBalanceBefore = await link.balanceOf( + await roles.defaultAccount.getAddress(), + ) + + await operator + .connect(roles.defaultAccount) + .withdraw(await roles.defaultAccount.getAddress(), paid) + + const operatorBalanceAfter = await link.balanceOf(operator.address) + const accountBalanceAfter = await link.balanceOf( + await roles.defaultAccount.getAddress(), + ) + + const accountDifference = + accountBalanceAfter.sub(accountBalanceBefore) + const operatorDifference = + operatorBalanceBefore.sub(operatorBalanceAfter) + + bigNumEquals(operatorDifference, paid) + bigNumEquals(accountDifference, paid) + }) + }) + }) + }) + }) + + describe('#withdrawable', () => { + let request: ReturnType + const amount = toWei('1') + + beforeEach(async () => { + const requester = await roles.defaultAccount.getAddress() + const args = encodeOracleRequest( + specId, + requester, + fHash, + 0, + constants.HashZero, + ) + const tx = await link.transferAndCall(operator.address, amount, args) + const receipt = await tx.wait() + assert.equal(3, receipt.logs?.length) + request = decodeRunRequest(receipt.logs?.[2]) + await operator + .connect(roles.oracleNode) + .fulfillOracleRequest(...convertFufillParams(request, 'Hello World!')) + }) + + it('returns the correct value', async () => { + const withdrawAmount = await operator.withdrawable() + bigNumEquals(withdrawAmount, request.payment) + }) + + describe('funds that were mistakenly sent', () => { + const paid = 1 + beforeEach(async () => { + await link.transfer(operator.address, paid) + }) + + it('returns the correct value', async () => { + const withdrawAmount = await operator.withdrawable() + + const expectedAmount = amount.add(paid) + bigNumEquals(withdrawAmount, expectedAmount) + }) + }) + }) + + describe('#ownerTransferAndCall', () => { + let operator2: Contract + let args: string + let to: string + const startingBalance = 1000 + const payment = 20 + + beforeEach(async () => { + operator2 = await operatorFactory + .connect(roles.oracleNode2) + .deploy(link.address, await roles.oracleNode2.getAddress()) + to = operator2.address + args = encodeOracleRequest( + specId, + operator.address, + operatorFactory.interface.getSighash('fulfillOracleRequest'), + 1, + constants.HashZero, + ) + }) + + describe('when called by a non-owner', () => { + it('reverts with owner error message', async () => { + await link.transfer(operator.address, startingBalance) + await evmRevert( + operator + .connect(roles.stranger) + .ownerTransferAndCall(to, payment, args), + 'Only callable by owner', + ) + }) + }) + + describe('when called by the owner', () => { + beforeEach(async () => { + await link.transfer(operator.address, startingBalance) + }) + + describe('without sufficient funds in contract', () => { + it('reverts with funds message', async () => { + const tooMuch = startingBalance * 2 + await evmRevert( + operator + .connect(roles.defaultAccount) + .ownerTransferAndCall(to, tooMuch, args), + 'Amount requested is greater than withdrawable balance', + ) + }) + }) + + describe('with sufficient funds', () => { + let tx: ContractTransaction + let receipt: ContractReceipt + let requesterBalanceBefore: BigNumber + let requesterBalanceAfter: BigNumber + let receiverBalanceBefore: BigNumber + let receiverBalanceAfter: BigNumber + + before(async () => { + requesterBalanceBefore = await link.balanceOf(operator.address) + receiverBalanceBefore = await link.balanceOf(operator2.address) + tx = await operator + .connect(roles.defaultAccount) + .ownerTransferAndCall(to, payment, args) + receipt = await tx.wait() + requesterBalanceAfter = await link.balanceOf(operator.address) + receiverBalanceAfter = await link.balanceOf(operator2.address) + }) + + it('emits an event', async () => { + assert.equal(3, receipt.logs?.length) + const transferLog = await getLog(tx, 1) + const parsedLog = link.interface.parseLog({ + data: transferLog.data, + topics: transferLog.topics, + }) + await expect(parsedLog.name).to.equal('Transfer') + }) + + it('transfers the tokens', async () => { + bigNumEquals( + requesterBalanceBefore.sub(requesterBalanceAfter), + payment, + ) + bigNumEquals(receiverBalanceAfter.sub(receiverBalanceBefore), payment) + }) + }) + }) + }) + + describe('#cancelOracleRequestByRequester', () => { + const nonce = 17 + + describe('with no pending requests', () => { + it('fails', async () => { + const fakeRequest: RunRequest = { + requestId: ethers.utils.formatBytes32String('1337'), + payment: '0', + callbackFunc: + getterSetterFactory.interface.getSighash('requestedBytes32'), + expiration: '999999999999', + + callbackAddr: '', + data: Buffer.from(''), + dataVersion: 0, + specId: '', + requester: '', + topic: '', + } + await increaseTime5Minutes(ethers.provider) + + await evmRevert( + operator + .connect(roles.stranger) + .cancelOracleRequestByRequester( + ...convertCancelByRequesterParams(fakeRequest, nonce), + ), + ) + }) + }) + + describe('with a pending request', () => { + const startingBalance = 100 + let request: ReturnType + let receipt: providers.TransactionReceipt + + beforeEach(async () => { + const requestAmount = 20 + + await link.transfer(await roles.consumer.getAddress(), startingBalance) + + const args = encodeOracleRequest( + specId, + await roles.consumer.getAddress(), + fHash, + nonce, + constants.HashZero, + ) + const tx = await link + .connect(roles.consumer) + .transferAndCall(operator.address, requestAmount, args) + receipt = await tx.wait() + + assert.equal(3, receipt.logs?.length) + request = decodeRunRequest(receipt.logs?.[2]) + + // pre conditions + const oracleBalance = await link.balanceOf(operator.address) + bigNumEquals(request.payment, oracleBalance) + + const consumerAmount = await link.balanceOf( + await roles.consumer.getAddress(), + ) + assert.equal( + startingBalance - Number(request.payment), + consumerAmount.toNumber(), + ) + }) + + describe('from a stranger', () => { + it('fails', async () => { + await evmRevert( + operator + .connect(roles.consumer) + .cancelOracleRequestByRequester( + ...convertCancelByRequesterParams(request, nonce), + ), + ) + }) + }) + + describe('from the requester', () => { + it('refunds the correct amount', async () => { + await increaseTime5Minutes(ethers.provider) + await operator + .connect(roles.consumer) + .cancelOracleRequestByRequester( + ...convertCancelByRequesterParams(request, nonce), + ) + const balance = await link.balanceOf( + await roles.consumer.getAddress(), + ) + + assert.equal(startingBalance, balance.toNumber()) // 100 + }) + + it('triggers a cancellation event', async () => { + await increaseTime5Minutes(ethers.provider) + const tx = await operator + .connect(roles.consumer) + .cancelOracleRequestByRequester( + ...convertCancelByRequesterParams(request, nonce), + ) + const receipt = await tx.wait() + + assert.equal(receipt.logs?.length, 2) + assert.equal(request.requestId, receipt.logs?.[0].topics[1]) + }) + + it('fails when called twice', async () => { + await increaseTime5Minutes(ethers.provider) + await operator + .connect(roles.consumer) + .cancelOracleRequestByRequester( + ...convertCancelByRequesterParams(request, nonce), + ) + + await evmRevert( + operator + .connect(roles.consumer) + .cancelOracleRequestByRequester(...convertCancelParams(request)), + ) + }) + }) + }) + }) + + describe('#cancelOracleRequest', () => { + describe('with no pending requests', () => { + it('fails', async () => { + const fakeRequest: RunRequest = { + requestId: ethers.utils.formatBytes32String('1337'), + payment: '0', + callbackFunc: + getterSetterFactory.interface.getSighash('requestedBytes32'), + expiration: '999999999999', + + callbackAddr: '', + data: Buffer.from(''), + dataVersion: 0, + specId: '', + requester: '', + topic: '', + } + await increaseTime5Minutes(ethers.provider) + + await evmRevert( + operator + .connect(roles.stranger) + .cancelOracleRequest(...convertCancelParams(fakeRequest)), + ) + }) + }) + + describe('with a pending request', () => { + const startingBalance = 100 + let request: ReturnType + let receipt: providers.TransactionReceipt + + beforeEach(async () => { + const requestAmount = 20 + + await link.transfer(await roles.consumer.getAddress(), startingBalance) + + const args = encodeOracleRequest( + specId, + await roles.consumer.getAddress(), + fHash, + 1, + constants.HashZero, + ) + const tx = await link + .connect(roles.consumer) + .transferAndCall(operator.address, requestAmount, args) + receipt = await tx.wait() + + assert.equal(3, receipt.logs?.length) + request = decodeRunRequest(receipt.logs?.[2]) + }) + + it('has correct initial balances', async () => { + const oracleBalance = await link.balanceOf(operator.address) + bigNumEquals(request.payment, oracleBalance) + + const consumerAmount = await link.balanceOf( + await roles.consumer.getAddress(), + ) + assert.equal( + startingBalance - Number(request.payment), + consumerAmount.toNumber(), + ) + }) + + describe('from a stranger', () => { + it('fails', async () => { + await evmRevert( + operator + .connect(roles.consumer) + .cancelOracleRequest(...convertCancelParams(request)), + ) + }) + }) + + describe('from the requester', () => { + it('refunds the correct amount', async () => { + await increaseTime5Minutes(ethers.provider) + await operator + .connect(roles.consumer) + .cancelOracleRequest(...convertCancelParams(request)) + const balance = await link.balanceOf( + await roles.consumer.getAddress(), + ) + + assert.equal(startingBalance, balance.toNumber()) // 100 + }) + + it('triggers a cancellation event', async () => { + await increaseTime5Minutes(ethers.provider) + const tx = await operator + .connect(roles.consumer) + .cancelOracleRequest(...convertCancelParams(request)) + const receipt = await tx.wait() + + assert.equal(receipt.logs?.length, 2) + assert.equal(request.requestId, receipt.logs?.[0].topics[1]) + }) + + it('fails when called twice', async () => { + await increaseTime5Minutes(ethers.provider) + await operator + .connect(roles.consumer) + .cancelOracleRequest(...convertCancelParams(request)) + + await evmRevert( + operator + .connect(roles.consumer) + .cancelOracleRequest(...convertCancelParams(request)), + ) + }) + }) + }) + }) + + describe('#ownerForward', () => { + let bytes: string + let payload: string + let mock: Contract + + beforeEach(async () => { + bytes = ethers.utils.hexlify(ethers.utils.randomBytes(100)) + payload = getterSetterFactory.interface.encodeFunctionData( + getterSetterFactory.interface.getFunction('setBytes'), + [bytes], + ) + mock = await getterSetterFactory.connect(roles.defaultAccount).deploy() + }) + + describe('when called by a non-owner', () => { + it('reverts', async () => { + await evmRevert( + operator.connect(roles.stranger).ownerForward(mock.address, payload), + ) + }) + }) + + describe('when called by owner', () => { + describe('when attempting to forward to the link token', () => { + it('reverts', async () => { + const sighash = linkTokenFactory.interface.getSighash('name') + await evmRevert( + operator + .connect(roles.defaultAccount) + .ownerForward(link.address, sighash), + 'Cannot call to LINK', + ) + }) + }) + + describe('when forwarding to any other address', () => { + it('forwards the data', async () => { + const tx = await operator + .connect(roles.defaultAccount) + .ownerForward(mock.address, payload) + await tx.wait() + assert.equal(await mock.getBytes(), bytes) + }) + + it('reverts when sending to a non-contract address', async () => { + await evmRevert( + operator + .connect(roles.defaultAccount) + .ownerForward(zeroAddress, payload), + 'Must forward to a contract', + ) + }) + + it('perceives the message is sent by the Operator', async () => { + const tx = await operator + .connect(roles.defaultAccount) + .ownerForward(mock.address, payload) + const receipt = await tx.wait() + const log: any = receipt.logs?.[0] + const logData = mock.interface.decodeEventLog( + mock.interface.getEvent('SetBytes'), + log.data, + log.topics, + ) + assert.equal(ethers.utils.getAddress(logData.from), operator.address) + }) + }) + }) + }) +}) diff --git a/contracts/test/v0.8/operatorforwarder/OperatorFactory.test.ts b/contracts/test/v0.8/operatorforwarder/OperatorFactory.test.ts new file mode 100644 index 00000000000..89b6d70b0a0 --- /dev/null +++ b/contracts/test/v0.8/operatorforwarder/OperatorFactory.test.ts @@ -0,0 +1,293 @@ +import { ethers } from 'hardhat' +import { evmWordToAddress, publicAbi } from '../../test-helpers/helpers' +import { assert } from 'chai' +import { Contract, ContractFactory, ContractReceipt } from 'ethers' +import { getUsers, Roles } from '../../test-helpers/setup' + +let linkTokenFactory: ContractFactory +let operatorGeneratorFactory: ContractFactory +let operatorFactory: ContractFactory +let forwarderFactory: ContractFactory + +let roles: Roles + +before(async () => { + const users = await getUsers() + + roles = users.roles + linkTokenFactory = await ethers.getContractFactory( + 'src/v0.4/LinkToken.sol:LinkToken', + roles.defaultAccount, + ) + operatorGeneratorFactory = await ethers.getContractFactory( + 'src/v0.8/operatorforwarder/dev/OperatorFactory.sol:OperatorFactory', + roles.defaultAccount, + ) + operatorFactory = await ethers.getContractFactory( + 'src/v0.8/operatorforwarder/dev/Operator.sol:Operator', + roles.defaultAccount, + ) + forwarderFactory = await ethers.getContractFactory( + 'src/v0.8/operatorforwarder/dev/AuthorizedForwarder.sol:AuthorizedForwarder', + roles.defaultAccount, + ) +}) + +describe('OperatorFactory', () => { + let link: Contract + let operatorGenerator: Contract + let operator: Contract + let forwarder: Contract + let receipt: ContractReceipt + let emittedOperator: string + let emittedForwarder: string + + beforeEach(async () => { + link = await linkTokenFactory.connect(roles.defaultAccount).deploy() + operatorGenerator = await operatorGeneratorFactory + .connect(roles.defaultAccount) + .deploy(link.address) + }) + + it('has a limited public interface [ @skip-coverage ]', () => { + publicAbi(operatorGenerator, [ + 'created', + 'deployNewOperator', + 'deployNewOperatorAndForwarder', + 'deployNewForwarder', + 'deployNewForwarderAndTransferOwnership', + 'linkToken', + 'typeAndVersion', + ]) + }) + + describe('#typeAndVersion', () => { + it('describes the authorized forwarder', async () => { + assert.equal( + await operatorGenerator.typeAndVersion(), + 'OperatorFactory 1.0.0', + ) + }) + }) + + describe('#deployNewOperator', () => { + beforeEach(async () => { + const tx = await operatorGenerator + .connect(roles.oracleNode) + .deployNewOperator() + + receipt = await tx.wait() + emittedOperator = evmWordToAddress(receipt.logs?.[0].topics?.[1]) + }) + + it('emits an event', async () => { + assert.equal(receipt?.events?.[0]?.event, 'OperatorCreated') + assert.equal(emittedOperator, receipt.events?.[0].args?.[0]) + assert.equal( + await roles.oracleNode.getAddress(), + receipt.events?.[0].args?.[1], + ) + assert.equal( + await roles.oracleNode.getAddress(), + receipt.events?.[0].args?.[2], + ) + }) + + it('sets the correct owner', async () => { + operator = await operatorFactory + .connect(roles.defaultAccount) + .attach(emittedOperator) + const ownerString = await operator.owner() + assert.equal(ownerString, await roles.oracleNode.getAddress()) + }) + + it('records that it deployed that address', async () => { + assert.isTrue(await operatorGenerator.created(emittedOperator)) + }) + }) + + describe('#deployNewOperatorAndForwarder', () => { + beforeEach(async () => { + const tx = await operatorGenerator + .connect(roles.oracleNode) + .deployNewOperatorAndForwarder() + + receipt = await tx.wait() + emittedOperator = evmWordToAddress(receipt.logs?.[0].topics?.[1]) + emittedForwarder = evmWordToAddress(receipt.logs?.[3].topics?.[1]) + }) + + it('emits an event recording that the operator was deployed', async () => { + assert.equal( + await roles.oracleNode.getAddress(), + receipt.events?.[0].args?.[1], + ) + assert.equal(receipt?.events?.[0]?.event, 'OperatorCreated') + assert.equal(receipt?.events?.[0]?.args?.[0], emittedOperator) + assert.equal( + receipt?.events?.[0]?.args?.[1], + await roles.oracleNode.getAddress(), + ) + assert.equal( + receipt?.events?.[0]?.args?.[2], + await roles.oracleNode.getAddress(), + ) + }) + + it('proposes the transfer of the forwarder to the operator', async () => { + assert.equal( + await roles.oracleNode.getAddress(), + receipt.events?.[0].args?.[1], + ) + assert.equal( + receipt?.events?.[1]?.topics?.[0], + '0xed8889f560326eb138920d842192f0eb3dd22b4f139c87a2c57538e05bae1278', //OwnershipTransferRequested(address,address) + ) + assert.equal( + evmWordToAddress(receipt?.events?.[1]?.topics?.[1]), + operatorGenerator.address, + ) + assert.equal( + evmWordToAddress(receipt?.events?.[1]?.topics?.[2]), + emittedOperator, + ) + + assert.equal( + receipt?.events?.[2]?.topics?.[0], + '0x4e1e878dc28d5f040db5969163ff1acd75c44c3f655da2dde9c70bbd8e56dc7e', //OwnershipTransferRequestedWithMessage(address,address,bytes) + ) + assert.equal( + evmWordToAddress(receipt?.events?.[2]?.topics?.[1]), + operatorGenerator.address, + ) + assert.equal( + evmWordToAddress(receipt?.events?.[2]?.topics?.[2]), + emittedOperator, + ) + }) + + it('emits an event recording that the forwarder was deployed', async () => { + assert.equal(receipt?.events?.[3]?.event, 'AuthorizedForwarderCreated') + assert.equal(receipt?.events?.[3]?.args?.[0], emittedForwarder) + assert.equal(receipt?.events?.[3]?.args?.[1], operatorGenerator.address) + assert.equal( + receipt?.events?.[3]?.args?.[2], + await roles.oracleNode.getAddress(), + ) + }) + + it('sets the correct owner on the operator', async () => { + operator = await operatorFactory + .connect(roles.defaultAccount) + .attach(receipt?.events?.[0]?.args?.[0]) + assert.equal(await roles.oracleNode.getAddress(), await operator.owner()) + }) + + it('sets the operator as the owner of the forwarder', async () => { + forwarder = await forwarderFactory + .connect(roles.defaultAccount) + .attach(emittedForwarder) + assert.equal(operatorGenerator.address, await forwarder.owner()) + }) + + it('records that it deployed that address', async () => { + assert.isTrue(await operatorGenerator.created(emittedOperator)) + assert.isTrue(await operatorGenerator.created(emittedForwarder)) + }) + }) + + describe('#deployNewForwarder', () => { + beforeEach(async () => { + const tx = await operatorGenerator + .connect(roles.oracleNode) + .deployNewForwarder() + + receipt = await tx.wait() + emittedForwarder = receipt.events?.[0].args?.[0] + }) + + it('emits an event', async () => { + assert.equal(receipt?.events?.[0]?.event, 'AuthorizedForwarderCreated') + assert.equal( + await roles.oracleNode.getAddress(), + receipt.events?.[0].args?.[1], + ) // owner + assert.equal( + await roles.oracleNode.getAddress(), + receipt.events?.[0].args?.[2], + ) // sender + }) + + it('sets the caller as the owner', async () => { + forwarder = await forwarderFactory + .connect(roles.defaultAccount) + .attach(emittedForwarder) + const ownerString = await forwarder.owner() + assert.equal(ownerString, await roles.oracleNode.getAddress()) + }) + + it('records that it deployed that address', async () => { + assert.isTrue(await operatorGenerator.created(emittedForwarder)) + }) + }) + + describe('#deployNewForwarderAndTransferOwnership', () => { + const message = '0x42' + + beforeEach(async () => { + const tx = await operatorGenerator + .connect(roles.oracleNode) + .deployNewForwarderAndTransferOwnership( + await roles.stranger.getAddress(), + message, + ) + receipt = await tx.wait() + + emittedForwarder = evmWordToAddress(receipt.logs?.[2].topics?.[1]) + }) + + it('emits an event', async () => { + assert.equal(receipt?.events?.[2]?.event, 'AuthorizedForwarderCreated') + assert.equal( + await roles.oracleNode.getAddress(), + receipt.events?.[2].args?.[1], + ) // owner + assert.equal( + await roles.oracleNode.getAddress(), + receipt.events?.[2].args?.[2], + ) // sender + }) + + it('sets the caller as the owner', async () => { + forwarder = await forwarderFactory + .connect(roles.defaultAccount) + .attach(emittedForwarder) + const ownerString = await forwarder.owner() + assert.equal(ownerString, await roles.oracleNode.getAddress()) + }) + + it('proposes a transfer to the recipient', async () => { + const emittedOwner = evmWordToAddress(receipt.logs?.[0].topics?.[1]) + assert.equal(emittedOwner, await roles.oracleNode.getAddress()) + const emittedRecipient = evmWordToAddress(receipt.logs?.[0].topics?.[2]) + assert.equal(emittedRecipient, await roles.stranger.getAddress()) + }) + + it('proposes a transfer to the recipient with the specified message', async () => { + const emittedOwner = evmWordToAddress(receipt.logs?.[1].topics?.[1]) + assert.equal(emittedOwner, await roles.oracleNode.getAddress()) + const emittedRecipient = evmWordToAddress(receipt.logs?.[1].topics?.[2]) + assert.equal(emittedRecipient, await roles.stranger.getAddress()) + + const encodedMessage = ethers.utils.defaultAbiCoder.encode( + ['bytes'], + [message], + ) + assert.equal(receipt?.logs?.[1]?.data, encodedMessage) + }) + + it('records that it deployed that address', async () => { + assert.isTrue(await operatorGenerator.created(emittedForwarder)) + }) + }) +})