diff --git a/.solcover.js b/.solcover.js index 38a69357e..69ab1ebc7 100644 --- a/.solcover.js +++ b/.solcover.js @@ -2,9 +2,11 @@ const skipFiles = [ 'lib', 'test', 'acl/ACLSyntaxSugar.sol', - 'common/DepositableStorage.sol', // Used in tests that send ETH - 'common/SafeERC20.sol', // solidity-coverage fails on assembly if (https://github.com/sc-forks/solidity-coverage/issues/287) - 'common/UnstructuredStorage.sol' // Used in tests that send ETH + 'common/DepositableStorage.sol', // Used in tests that send ETH + 'common/SafeERC20.sol', // solidity-coverage fails on assembly if (https://github.com/sc-forks/solidity-coverage/issues/287) + 'common/UnstructuredStorage.sol', // Used in tests that send ETH + 'relayer/Relayer.sol', // solidity-coverage uses test-rpc which does not implement eth_signTypedData + 'relayer/RelayedAragonApp.sol' // solidity-coverage uses test-rpc which does not implement eth_signTypedData ] module.exports = { diff --git a/contracts/apps/AppStorage.sol b/contracts/apps/AppStorage.sol index b37dd363d..8c258b4cb 100644 --- a/contracts/apps/AppStorage.sol +++ b/contracts/apps/AppStorage.sol @@ -11,9 +11,10 @@ import "../kernel/IKernel.sol"; contract AppStorage { using UnstructuredStorage for bytes32; - /* Hardcoded constants to save gas - bytes32 internal constant KERNEL_POSITION = keccak256("aragonOS.appStorage.kernel"); - bytes32 internal constant APP_ID_POSITION = keccak256("aragonOS.appStorage.appId"); + /* + * Hardcoded constants to save gas + * bytes32 internal constant KERNEL_POSITION = keccak256("aragonOS.appStorage.kernel"); + * bytes32 internal constant APP_ID_POSITION = keccak256("aragonOS.appStorage.appId"); */ bytes32 internal constant KERNEL_POSITION = 0x4172f0f7d2289153072b0a6ca36959e0cbe2efc3afe50fc81636caa96338137b; bytes32 internal constant APP_ID_POSITION = 0xd625496217aa6a3453eecb9c3489dc5a53e6c67b444329ea2b2cbc9ff547639b; diff --git a/contracts/apps/AragonApp.sol b/contracts/apps/AragonApp.sol index f53c40721..1e9570e0c 100644 --- a/contracts/apps/AragonApp.sol +++ b/contracts/apps/AragonApp.sol @@ -22,12 +22,12 @@ contract AragonApp is AppStorage, Autopetrified, VaultRecoverable, ReentrancyGua string private constant ERROR_AUTH_FAILED = "APP_AUTH_FAILED"; modifier auth(bytes32 _role) { - require(canPerform(msg.sender, _role, new uint256[](0)), ERROR_AUTH_FAILED); + require(canPerform(sender(), _role, new uint256[](0)), ERROR_AUTH_FAILED); _; } modifier authP(bytes32 _role, uint256[] _params) { - require(canPerform(msg.sender, _role, _params), ERROR_AUTH_FAILED); + require(canPerform(sender(), _role, _params), ERROR_AUTH_FAILED); _; } @@ -65,4 +65,8 @@ contract AragonApp is AppStorage, Autopetrified, VaultRecoverable, ReentrancyGua // Funds recovery via a vault is only available when used with a kernel return kernel().getRecoveryVault(); // if kernel is not set, it will revert } + + function sender() internal view returns (address) { + return msg.sender; + } } diff --git a/contracts/common/MemoryHelpers.sol b/contracts/common/MemoryHelpers.sol new file mode 100644 index 000000000..a7e398e09 --- /dev/null +++ b/contracts/common/MemoryHelpers.sol @@ -0,0 +1,53 @@ +pragma solidity ^0.4.24; + + +library MemoryHelpers { + + function append(bytes memory self, address addr) internal pure returns (bytes memory) { + // alloc required encoded data size + uint256 dataSize = self.length; + uint256 appendedDataSize = dataSize + 32; + bytes memory appendedData = new bytes(appendedDataSize); + + // copy data + uint256 inputPointer; + uint256 outputPointer; + assembly { + inputPointer := add(self, 0x20) + outputPointer := add(appendedData, 0x20) + } + memcpy(outputPointer, inputPointer, dataSize); + + // append address + assembly { + let signerPointer := add(add(appendedData, 0x20), dataSize) + mstore(signerPointer, addr) + } + + return appendedData; + } + + // From https://github.com/Arachnid/solidity-stringutils/blob/master/src/strings.sol + function memcpy(uint256 output, uint256 input, uint256 length) internal pure { + uint256 len = length; + uint256 dest = output; + uint256 src = input; + + // Copy word-length chunks while possible + for (; len >= 32; len -= 32) { + assembly { + mstore(dest, mload(src)) + } + dest += 32; + src += 32; + } + + // Copy remaining bytes + uint256 mask = 256 ** (32 - len) - 1; + assembly { + let srcpart := and(mload(src), not(mask)) + let destpart := and(mload(dest), mask) + mstore(dest, or(destpart, srcpart)) + } + } +} diff --git a/contracts/kernel/IKernel.sol b/contracts/kernel/IKernel.sol index e1a2b40e5..95b7d3955 100644 --- a/contracts/kernel/IKernel.sol +++ b/contracts/kernel/IKernel.sol @@ -5,6 +5,7 @@ pragma solidity ^0.4.24; import "../acl/IACL.sol"; +import "../relayer/IRelayer.sol"; import "../common/IVaultRecoverable.sol"; @@ -16,6 +17,7 @@ interface IKernelEvents { // This should be an interface, but interfaces can't inherit yet :( contract IKernel is IKernelEvents, IVaultRecoverable { function acl() public view returns (IACL); + function relayer() public view returns (IRelayer); function hasPermission(address who, address where, bytes32 what, bytes how) public view returns (bool); function setApp(bytes32 namespace, bytes32 appId, address app) public; diff --git a/contracts/kernel/Kernel.sol b/contracts/kernel/Kernel.sol index 1fc919055..eba6a716a 100644 --- a/contracts/kernel/Kernel.sol +++ b/contracts/kernel/Kernel.sol @@ -5,6 +5,7 @@ import "./KernelConstants.sol"; import "./KernelStorage.sol"; import "../acl/IACL.sol"; import "../acl/ACLSyntaxSugar.sol"; +import "../relayer/IRelayer.sol"; import "../common/ConversionHelpers.sol"; import "../common/IsContract.sol"; import "../common/Petrifiable.sol"; @@ -169,6 +170,7 @@ contract Kernel is IKernel, KernelStorage, KernelAppIds, KernelNamespaceConstant function APP_ADDR_NAMESPACE() external pure returns (bytes32) { return KERNEL_APP_ADDR_NAMESPACE; } function KERNEL_APP_ID() external pure returns (bytes32) { return KERNEL_CORE_APP_ID; } function DEFAULT_ACL_APP_ID() external pure returns (bytes32) { return KERNEL_DEFAULT_ACL_APP_ID; } + function DEFAULT_RELAYER_APP_ID() external pure returns (bytes32) { return KERNEL_DEFAULT_RELAYER_APP_ID; } /* solium-enable function-order, mixedcase */ /** @@ -197,6 +199,14 @@ contract Kernel is IKernel, KernelStorage, KernelAppIds, KernelNamespaceConstant return IACL(getApp(KERNEL_APP_ADDR_NAMESPACE, KERNEL_DEFAULT_ACL_APP_ID)); } + /** + * @dev Get the installed Relayer app + * @return Relayer app + */ + function relayer() public view returns (IRelayer) { + return IRelayer(getApp(KERNEL_APP_ADDR_NAMESPACE, KERNEL_DEFAULT_RELAYER_APP_ID)); + } + /** * @dev Function called by apps to check ACL on kernel or to check permission status * @param _who Sender of the original call diff --git a/contracts/kernel/KernelConstants.sol b/contracts/kernel/KernelConstants.sol index 77816a74c..462b35881 100644 --- a/contracts/kernel/KernelConstants.sol +++ b/contracts/kernel/KernelConstants.sol @@ -10,10 +10,12 @@ contract KernelAppIds { bytes32 internal constant KERNEL_CORE_APP_ID = apmNamehash("kernel"); bytes32 internal constant KERNEL_DEFAULT_ACL_APP_ID = apmNamehash("acl"); bytes32 internal constant KERNEL_DEFAULT_VAULT_APP_ID = apmNamehash("vault"); + bytes32 internal constant KERNEL_DEFAULT_VAULT_APP_ID = apmNamehash("relayer"); */ bytes32 internal constant KERNEL_CORE_APP_ID = 0x3b4bf6bf3ad5000ecf0f989d5befde585c6860fea3e574a4fab4c49d1c177d9c; bytes32 internal constant KERNEL_DEFAULT_ACL_APP_ID = 0xe3262375f45a6e2026b7e7b18c2b807434f2508fe1a2a3dfb493c7df8f4aad6a; bytes32 internal constant KERNEL_DEFAULT_VAULT_APP_ID = 0x7e852e0fcfce6551c13800f1e7476f982525c2b5277ba14b24339c68416336d1; + bytes32 internal constant KERNEL_DEFAULT_RELAYER_APP_ID = 0x7641595d1a2007abf0fe95c31d0b7a822954acbf6fb0cbe3bd1161d9dec9e1d3; } diff --git a/contracts/lib/misc/EIP712.sol b/contracts/lib/misc/EIP712.sol new file mode 100644 index 000000000..9798aba39 --- /dev/null +++ b/contracts/lib/misc/EIP712.sol @@ -0,0 +1,37 @@ +pragma solidity ^0.4.24; + + +contract EIP712 { + string private constant DOMAIN_TYPE = "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"; + bytes32 private constant DOMAIN_TYPEHASH = keccak256(DOMAIN_TYPE); + + struct Domain { + string name; + string version; + uint256 chainId; + address verifyingContract; + } + + function _domainSeparator() internal view returns (bytes32) { + return _hash(Domain({ + name: _domainName(), + version: _domainVersion(), + chainId: _domainChainId(), + verifyingContract: address(this) + })); + } + + function _hash(Domain domain) internal pure returns (bytes32) { + return keccak256(abi.encode( + DOMAIN_TYPEHASH, + keccak256(bytes(domain.name)), + keccak256(bytes(domain.version)), + domain.chainId, + domain.verifyingContract + )); + } + + function _domainName() internal view returns (string); + function _domainVersion() internal view returns (string); + function _domainChainId() internal view returns (uint256); +} diff --git a/contracts/lib/sig/ECDSA.sol b/contracts/lib/sig/ECDSA.sol new file mode 100644 index 000000000..8527d6f80 --- /dev/null +++ b/contracts/lib/sig/ECDSA.sol @@ -0,0 +1,69 @@ +pragma solidity ^0.4.24; + + +/** + * @title Elliptic curve signature operations + * @dev Based on https://github.com/OpenZeppelin/openzeppelin-solidity/blob/v2.0.0/contracts/cryptography/ECDSA.sol + */ +library ECDSA { + + /** + * @dev Recover signer address from a message by using their signature + * @param hash bytes32 message, the hash is the signed message. What is recovered is the signer address. + * @param signature bytes signature, the signature is generated using web3.eth.sign() + */ + function recover(bytes32 hash, bytes signature) + internal + pure + returns (address) + { + bytes32 r; + bytes32 s; + uint8 v; + + // Check the signature length + if (signature.length != 65) { + return (address(0)); + } + + // Divide the signature in r, s and v variables + // ecrecover takes the signature parameters, and the only way to get them + // currently is to use assembly. + // solium-disable-next-line security/no-inline-assembly + assembly { + r := mload(add(signature, 0x20)) + s := mload(add(signature, 0x40)) + v := byte(0, mload(add(signature, 0x60))) + } + + // Version of signature should be 27 or 28, but 0 and 1 are also possible versions + if (v < 27) { + v += 27; + } + + // If the version is correct return the signer address + if (v != 27 && v != 28) { + return (address(0)); + } else { + // solium-disable-next-line arg-overflow + return ecrecover(hash, v, r, s); + } + } + + /** + * toEthSignedMessageHash + * @dev prefix a bytes32 value with "\x19Ethereum Signed Message:" + * and hash the result + */ + function toEthSignedMessageHash(bytes32 hash) + internal + pure + returns (bytes32) + { + // 32 is the length in bytes of hash, + // enforced by the type signature above + return keccak256( + abi.encodePacked("\x19Ethereum Signed Message:\n32", hash) + ); + } +} diff --git a/contracts/relayer/IRelayer.sol b/contracts/relayer/IRelayer.sol new file mode 100644 index 000000000..448cdb437 --- /dev/null +++ b/contracts/relayer/IRelayer.sol @@ -0,0 +1,6 @@ +pragma solidity ^0.4.24; + + +contract IRelayer { + function relay(address from, address to, uint256 nonce, bytes data, uint256 gasRefund, uint256 gasPrice, bytes signature) external; +} diff --git a/contracts/relayer/RelayedAragonApp.sol b/contracts/relayer/RelayedAragonApp.sol new file mode 100644 index 000000000..c0458e656 --- /dev/null +++ b/contracts/relayer/RelayedAragonApp.sol @@ -0,0 +1,34 @@ +pragma solidity ^0.4.24; + +import "./IRelayer.sol"; +import "../apps/AragonApp.sol"; + + +contract RelayedAragonApp is AragonApp { + + function sender() internal view returns (address) { + address relayer = address(_relayer()); + if (msg.sender != relayer) { + return msg.sender; + } + + address signer = _decodeSigner(); + return signer != address(0) ? signer : relayer; + } + + function _decodeSigner() internal pure returns (address signer) { + // Note that calldatasize includes one word more than the original calldata array, due to the address of the + // signer that is being appended at the end of it. Thus, we are loading the last word of the calldata array to + // fetch the actual signed of the relayed call + assembly { + let ptr := mload(0x40) + mstore(0x40, add(ptr, 0x20)) + calldatacopy(ptr, sub(calldatasize, 0x20), 0x20) + signer := mload(ptr) + } + } + + function _relayer() internal view returns (IRelayer) { + return kernel().relayer(); + } +} diff --git a/contracts/relayer/Relayer.sol b/contracts/relayer/Relayer.sol new file mode 100644 index 000000000..54c592874 --- /dev/null +++ b/contracts/relayer/Relayer.sol @@ -0,0 +1,355 @@ +pragma solidity ^0.4.24; + +import "./IRelayer.sol"; +import "./RelayedAragonApp.sol"; +import "../lib/sig/ECDSA.sol"; +import "../lib/misc/EIP712.sol"; +import "../lib/math/SafeMath.sol"; +import "../apps/AragonApp.sol"; +import "../common/MemoryHelpers.sol"; +import "../common/DepositableStorage.sol"; + + +contract Relayer is IRelayer, AragonApp, DepositableStorage, EIP712 { + using ECDSA for bytes32; + using SafeMath for uint256; + using MemoryHelpers for bytes; + + string private constant ERROR_GAS_REFUND_FAIL = "RELAYER_GAS_REFUND_FAIL"; + string private constant ERROR_GAS_QUOTA_EXCEEDED = "RELAYER_GAS_QUOTA_EXCEEDED"; + string private constant ERROR_SENDER_NOT_ALLOWED = "RELAYER_SENDER_NOT_ALLOWED"; + string private constant ERROR_NONCE_ALREADY_USED = "RELAYER_NONCE_ALREADY_USED"; + string private constant ERROR_SERVICE_NOT_ALLOWED = "RELAYER_SERVICE_NOT_ALLOWED"; + string private constant ERROR_INVALID_SENDER_SIGNATURE = "RELAYER_INVALID_SENDER_SIGNATURE"; + + // Constant values used to identify the domain for the current app following EIP 712 spec + string private constant EIP_712_DOMAIN_NAME = "Aragon Relayer"; + string private constant EIP_712_DOMAIN_VERSION = "1"; + uint256 private constant EIP_712_DOMAIN_CHAIN_ID = 1; + + // Type hash used to validate signatures based on EIP-712 + bytes32 public constant TRANSACTION_TYPE = keccak256("Transaction(address to,uint256 nonce,bytes data,uint256 gasRefund,uint256 gasPrice)"); + + // ACL role used to validate who is able to add a new senders to use the relay service + bytes32 public constant ALLOW_SENDER_ROLE = keccak256("ALLOW_SENDER_ROLE"); + + // ACL role used to validate who is able to remove already allowed senders to use the relay service + bytes32 public constant DISALLOW_SENDER_ROLE = keccak256("DISALLOW_SENDER_ROLE"); + + // ACL role used to validate who is able to add a new allowed off-chain services to relay transactions + bytes32 public constant ALLOW_OFF_CHAIN_SERVICE_ROLE = keccak256("ALLOW_OFF_CHAIN_SERVICE_ROLE"); + + // ACL role used to validate who is able to remove already allowed off-chain services to relay transactions + bytes32 public constant DISALLOW_OFF_CHAIN_SERVICE_ROLE = keccak256("DISALLOW_OFF_CHAIN_SERVICE_ROLE"); + + // ACL role used to validate who is able to change the refunds monthly quota + bytes32 public constant SET_MONTHLY_REFUND_QUOTA_ROLE = keccak256("SET_MONTHLY_REFUND_QUOTA_ROLE"); + + /** + * @dev Event logged when a new address is added to the list of off-chain services allowed to relay transactions + * @param service Address of the off-chain service allowed + */ + event ServiceAllowed(address indexed service); + + /** + * @dev Event logged when a an address is removed from the list of off-chain services allowed to relay transactions + * @param service Address of the off-chain service disallowed + */ + event ServiceDisallowed(address indexed service); + + /** + * @dev Event logged when a new address is added to the list of allowed senders to use the relay service + * @param sender Address of the sender sallowed to use the relayer service + */ + event SenderAllowed(address indexed sender); + + /** + * @dev Event logged when a an address is removed from the list of allowed senders to use the relay service + * @param sender Address of the sender disallowed to use the relayer service + */ + event SenderDisallowed(address indexed sender); + + /** + * @dev Event logged when a new transaction is relayed successfully + * @param from Address executed a transaction on behalf of + * @param to Target address of the relayed transaction + * @param nonce Nonce of the signer used for the relayed transaction + * @param data Calldata included in the relayed transaction + */ + event TransactionRelayed(address from, address to, uint256 nonce, bytes data); + + /** + * @dev Event logged when the monthly refunds quota is changed + * @param who Address of the account that change the monthly refunds quota + * @param previousQuota Previous monthly refunds quota in ETH for each allowed sender + * @param newQuota New monthly refunds quota in ETH for each allowed sender + */ + event MonthlyRefundQuotaSet(address indexed who, uint256 previousQuota, uint256 newQuota); + + // Sender information carrying allowance, last nonce number used, and last active month related data + struct Sender { + bool allowed; + uint256 lastUsedNonce; + uint256 lastActiveMonth; + uint256 lastActiveMonthRefunds; + } + + // Timestamp to start counting monthly refunds quotas for each sender + uint256 internal startDate; + + // Monthly refunds quota in ETH for each sender + uint256 internal monthlyRefundQuota; + + // Mapping that indicates whether a given address is allowed as off-chain service to relay transactions + mapping (address => bool) internal allowedServices; + + // Mapping from senders to their related information + mapping (address => Sender) internal senders; + + // Check whether the msg.sender belongs to the list of allowed services to relay transactions + modifier onlyAllowedServices() { + require(_isServiceAllowed(msg.sender), ERROR_SERVICE_NOT_ALLOWED); + _; + } + + /** + * @notice Initialize Relayer app setting a monthly refunds quota per address of `@tokenAmount(_monthlyRefundQuota, 0x00)`. + * @param _monthlyRefundQuota Monthly refunds quota in ETH for each allowed sender + */ + function initialize(uint256 _monthlyRefundQuota) external onlyInit { + initialized(); + startDate = getTimestamp(); + monthlyRefundQuota = _monthlyRefundQuota; + setDepositable(true); + } + + /** + * @notice Relay a transaction on behalf of `from` to target address `to`, with calldata `data`, using nonce #`nonce`, and requesting a refund of `@tokenAmount(gasRefund * gasPrice, 0x00)`. + * @param from Address to execute a transaction on behalf of + * @param to Target address that will receive the relayed transaction + * @param nonce Nonce of the signer to be used to relay the requested transaction + * @param data Calldata to be included in the relayed transaction + * @param gasRefund Amount of gas to be refunded to the caller account + * @param gasPrice Amount of ETH to pay for each gas unit that will be refunded to the caller account + * @param signature Signature used to validate if all the given parameters were deliberated by actual signer + */ + function relay(address from, address to, uint256 nonce, bytes data, uint256 gasRefund, uint256 gasPrice, bytes signature) + external + onlyAllowedServices + { + Sender storage sender = senders[from]; + uint256 currentMonth = _getCurrentMonth(); + uint256 requestedRefund = gasRefund.mul(gasPrice); + + require(_isSenderAllowed(sender), ERROR_SENDER_NOT_ALLOWED); + require(_canUseNonce(sender, nonce), ERROR_NONCE_ALREADY_USED); + require(_canRefund(sender, currentMonth, requestedRefund), ERROR_GAS_QUOTA_EXCEEDED); + require(_isValidSignature(from, to, nonce, data, gasRefund, gasPrice, signature), ERROR_INVALID_SENDER_SIGNATURE); + + _updateSenderInfo(sender, nonce, currentMonth, requestedRefund); + _relayCall(from, to, data); + emit TransactionRelayed(from, to, nonce, data); + + /* solium-disable security/no-send */ + require(msg.sender.send(requestedRefund), ERROR_GAS_REFUND_FAIL); + } + + /** + * @notice Add a new service `service` to the list of off-chain services allowed to relay transactions. + * @param service Address of the off-chain service to be allowed + */ + function allowService(address service) external authP(ALLOW_OFF_CHAIN_SERVICE_ROLE, arr(service)) { + allowedServices[service] = true; + emit ServiceAllowed(service); + } + + /** + * @notice Remove service `service` from the list of off-chain services allowed to relay transactions. + * @param service Address of the off-chain service to be disallowed + */ + function disallowService(address service) external authP(DISALLOW_OFF_CHAIN_SERVICE_ROLE, arr(service)) { + allowedServices[service] = false; + emit ServiceDisallowed(service); + } + + /** + * @notice Add a new sender `sender` to the list of allowed addresses that can use the relay service + * @param senderAddress Address of the sender to be allowed + */ + function allowSender(address senderAddress) external authP(ALLOW_SENDER_ROLE, arr(senderAddress)) { + senders[senderAddress].allowed = true; + emit SenderAllowed(senderAddress); + } + + /** + * @notice Remove sender `sender` from the list of allowed addresses that can use the relay service + * @param senderAddress Address of the sender to be disallowed + */ + function disallowSender(address senderAddress) external authP(DISALLOW_SENDER_ROLE, arr(senderAddress)) { + senders[senderAddress].allowed = false; + emit SenderDisallowed(senderAddress); + } + + /** + * @notice Set new monthly refunds quota per address of `@tokenAmount(newQuota, 0x00)`. + * @param newQuota New monthly refunds quota in ETH for each allowed sender + */ + function setMonthlyRefundQuota(uint256 newQuota) external authP(SET_MONTHLY_REFUND_QUOTA_ROLE, arr(newQuota)) { + emit MonthlyRefundQuotaSet(msg.sender, monthlyRefundQuota, newQuota); + monthlyRefundQuota = newQuota; + } + + /** + * @notice Return the information related to a given sender `sender`. + * @return The information for the requested sender + */ + function getSender(address senderAddress) external view isInitialized + returns (bool allowed, uint256 lastUsedNonce, uint256 lastActiveMonth, uint256 lastActiveMonthRefunds) + { + Sender storage sender = senders[senderAddress]; + allowed = sender.allowed; + lastUsedNonce = sender.lastUsedNonce; + lastActiveMonth = sender.lastActiveMonth; + lastActiveMonthRefunds = sender.lastActiveMonthRefunds; + } + + /** + * @notice Return the start date timestamp used to count the monthly refunds quotas for each sender. + * @return The start date timestamp used to count the monthly refunds quotas for each sender + */ + function getStartDate() external view isInitialized returns (uint256) { + return startDate; + } + + /** + * @notice Return the monthly refunds quotas for each sender. + * @return The monthly refunds quotas for each sender + */ + function getMonthlyRefundQuota() external view isInitialized returns (uint256) { + return monthlyRefundQuota; + } + + /** + * @notice Return the amount of months since the Relayer app was created. + * @return The amount of months since the Relayer app was created + */ + function getCurrentMonth() external view isInitialized returns (uint256) { + return _getCurrentMonth(); + } + + /** + * @notice Tell if a given service `service` is allowed to relay transactions through the Relayer app. + * @return True if the given service is allowed to relay transactions through the app + */ + function isServiceAllowed(address service) external view isInitialized returns (bool) { + return _isServiceAllowed(service); + } + + /** + * @notice Tell if a given sender `sender` is allowed to use the relay service. + * @return True if the given sender is allowed to use the relay service + */ + function isSenderAllowed(address senderAddress) external view isInitialized returns (bool) { + return _isSenderAllowed(senders[senderAddress]); + } + + /** + * @notice Tell if a given sender `sender` can use the nonce number `nonce` to relay a new transaction. + * @return True if the given sender can use the given nonce number to relay a new transaction + */ + function canUseNonce(address senderAddress, uint256 nonce) external view isInitialized returns (bool) { + return _canUseNonce(senders[senderAddress], nonce); + } + + /** + * @notice Tell if a given sender `sender` can relay a new transaction spending `@tokenAmount(newQuota, 0x00)` in month `month`. + * @return True if the given sender can relay a new transaction spending the given amount for the given month + */ + function canRefund(address senderAddress, uint256 amount) external view isInitialized returns (bool) { + uint256 currentMonth = _getCurrentMonth(); + return _canRefund(senders[senderAddress], currentMonth, amount); + } + + /** + * @notice Tell if the current app allows to recover amount of `token` from the Relayer app. + * @param token Token address that would be recovered + * @return True if the given address is not ETH + */ + function allowRecoverability(address token) public view returns (bool) { + // does not allow to recover ETH + return token != ETH; + } + + function _getCurrentMonth() internal view returns (uint256) { + uint256 passedSeconds = getTimestamp().sub(startDate); + return passedSeconds / 30 days; + } + + function _isServiceAllowed(address service) internal view returns (bool) { + return allowedServices[service]; + } + + function _isSenderAllowed(Sender storage sender) internal view returns (bool) { + return sender.allowed; + } + + function _canUseNonce(Sender storage sender, uint256 nonce) internal view returns (bool) { + return sender.lastUsedNonce < nonce; + } + + function _canRefund(Sender storage sender, uint256 currentMonth, uint256 amount) internal view returns (bool) { + if (currentMonth == sender.lastActiveMonth) { + return sender.lastActiveMonthRefunds.add(amount) <= monthlyRefundQuota; + } + return currentMonth > sender.lastActiveMonth; + } + + function _isValidSignature(address sender, address to, uint256 nonce, bytes data, uint256 gasRefund, uint256 gasPrice, bytes signature) + internal + view + returns (bool) + { + bytes32 messageHash = _messageHash(to, nonce, data, gasRefund, gasPrice); + address signer = messageHash.recover(signature); + return sender == signer; + } + + function _messageHash(address to, uint256 nonce, bytes data, uint256 gasRefund, uint256 gasPrice) internal view returns (bytes32) { + bytes32 hash = keccak256(abi.encode(TRANSACTION_TYPE, to, nonce, keccak256(data), gasRefund, gasPrice)); + return keccak256(abi.encodePacked("\x19\x01", _domainSeparator(), hash)); + } + + function _relayCall(address from, address to, bytes data) internal { + bytes memory encodedSignerData = data.append(from); + assembly { + let success := call(gas, to, 0, add(encodedSignerData, 0x20), mload(encodedSignerData), 0, 0) + switch success case 0 { + let ptr := mload(0x40) + returndatacopy(ptr, 0, returndatasize) + revert(ptr, returndatasize) + } + } + } + + function _updateSenderInfo(Sender storage sender, uint256 nonce, uint256 month, uint256 refund) internal { + sender.lastUsedNonce = nonce; + if (sender.lastActiveMonth != month) { + sender.lastActiveMonth = month; + sender.lastActiveMonthRefunds = refund; + } else { + sender.lastActiveMonthRefunds = sender.lastActiveMonthRefunds.add(refund); + } + } + + function _domainName() internal view returns (string) { + return EIP_712_DOMAIN_NAME; + } + + function _domainVersion() internal view returns (string) { + return EIP_712_DOMAIN_VERSION; + } + + function _domainChainId() internal view returns (uint256) { + return EIP_712_DOMAIN_CHAIN_ID; + } +} diff --git a/contracts/test/mocks/common/KeccakConstants.sol b/contracts/test/mocks/common/KeccakConstants.sol index 6cc9a5bb5..2a279db9a 100644 --- a/contracts/test/mocks/common/KeccakConstants.sol +++ b/contracts/test/mocks/common/KeccakConstants.sol @@ -22,6 +22,7 @@ contract KeccakConstants { bytes32 public constant KERNEL_APP_ID = keccak256(abi.encodePacked(APM_NODE, keccak256("kernel"))); bytes32 public constant DEFAULT_ACL_APP_ID = keccak256(abi.encodePacked(APM_NODE, keccak256("acl"))); bytes32 public constant DEFAULT_VAULT_APP_ID = keccak256(abi.encodePacked(APM_NODE, keccak256("vault"))); + bytes32 public constant DEFAULT_RELAYER_APP_ID = keccak256(abi.encodePacked(APM_NODE, keccak256("relayer"))); // ACL bytes32 public constant CREATE_PERMISSIONS_ROLE = keccak256(abi.encodePacked("CREATE_PERMISSIONS_ROLE")); diff --git a/contracts/test/mocks/common/TimeHelpersMock.sol b/contracts/test/mocks/common/TimeHelpersMock.sol index af3894a75..e1930ba34 100644 --- a/contracts/test/mocks/common/TimeHelpersMock.sol +++ b/contracts/test/mocks/common/TimeHelpersMock.sol @@ -1,9 +1,17 @@ pragma solidity 0.4.24; import "../../../common/TimeHelpers.sol"; +import "../../../lib/math/SafeMath.sol"; +import "../../../lib/math/SafeMath64.sol"; contract TimeHelpersMock is TimeHelpers { + using SafeMath for uint256; + using SafeMath64 for uint64; + + uint256 mockedTimestamp; + uint256 mockedBlockNumber; + function getBlockNumberDirect() public view returns (uint256) { return block.number; } @@ -27,4 +35,51 @@ contract TimeHelpersMock is TimeHelpers { function getTimestamp64Ext() public view returns (uint64) { return getTimestamp64(); } + + /** + * @dev Sets a mocked timestamp value, used only for testing purposes + */ + function mockSetTimestamp(uint256 _timestamp) public { + mockedTimestamp = _timestamp; + } + + /** + * @dev Increases the mocked timestamp value, used only for testing purposes + */ + function mockIncreaseTime(uint256 _seconds) public { + if (mockedTimestamp != 0) mockedTimestamp = mockedTimestamp.add(_seconds); + else mockedTimestamp = block.timestamp.add(_seconds); + } + + /** + * @dev Decreases the mocked timestamp value, used only for testing purposes + */ + function mockDecreaseTime(uint256 _seconds) public { + if (mockedTimestamp != 0) mockedTimestamp = mockedTimestamp.sub(_seconds); + else mockedTimestamp = block.timestamp.sub(_seconds); + } + + /** + * @dev Advances the mocked block number value, used only for testing purposes + */ + function mockAdvanceBlocks(uint256 _number) public { + if (mockedBlockNumber != 0) mockedBlockNumber = mockedBlockNumber.add(_number); + else mockedBlockNumber = block.number.add(_number); + } + + /** + * @dev Returns the mocked timestamp if it was set, or current `block.timestamp` + */ + function getTimestamp() internal view returns (uint256) { + if (mockedTimestamp != 0) return mockedTimestamp; + return super.getTimestamp(); + } + + /** + * @dev Returns the mocked block number if it was set, or current `block.number` + */ + function getBlockNumber() internal view returns (uint256) { + if (mockedBlockNumber != 0) return mockedBlockNumber; + return super.getBlockNumber(); + } } diff --git a/contracts/test/mocks/kernel/KernelConstantsMock.sol b/contracts/test/mocks/kernel/KernelConstantsMock.sol index 47c634848..74f38c365 100644 --- a/contracts/test/mocks/kernel/KernelConstantsMock.sol +++ b/contracts/test/mocks/kernel/KernelConstantsMock.sol @@ -12,4 +12,5 @@ contract KernelConstantsMock is Kernel { function getKernelAppId() external pure returns (bytes32) { return KERNEL_CORE_APP_ID; } function getDefaultACLAppId() external pure returns (bytes32) { return KERNEL_DEFAULT_ACL_APP_ID; } function getDefaultVaultAppId() external pure returns (bytes32) { return KERNEL_DEFAULT_VAULT_APP_ID; } + function getDefaultRelayerAppId() external pure returns (bytes32) { return KERNEL_DEFAULT_RELAYER_APP_ID; } } diff --git a/contracts/test/mocks/kernel/KernelOverloadMock.sol b/contracts/test/mocks/kernel/KernelOverloadMock.sol index 66d87ec2b..57ba0014b 100644 --- a/contracts/test/mocks/kernel/KernelOverloadMock.sol +++ b/contracts/test/mocks/kernel/KernelOverloadMock.sol @@ -11,40 +11,26 @@ import "../../../lib/misc/ERCProxy.sol"; * NOTE: awkwardly, by default we have access to the full version of `newAppInstance()` but only the * minimized version for `newPinnedAppInstance()` */ -contract KernelOverloadMock { - Kernel public kernel; +contract KernelOverloadMock is Kernel { + constructor(bool _shouldPetrify) Kernel(_shouldPetrify) public {} - event NewAppProxy(address proxy); + // Overriding function to bypass Truffle's overloading issues + function newAppInstanceWithoutPayload(bytes32 _appId, address _appBase) public returns (ERCProxy) { + return super.newAppInstance(_appId, _appBase); + } - constructor(Kernel _kernel) public { - kernel = _kernel; + // Overriding function to bypass Truffle's overloading issues + function newAppInstanceWithPayload(bytes32 _appId, address _appBase, bytes _initializePayload, bool _setDefault) public returns (ERCProxy) { + return super.newAppInstance(_appId, _appBase, _initializePayload, _setDefault); } - /* - function newAppInstance(bytes32 _appId, address _appBase) - public - auth(APP_MANAGER_ROLE, arr(KERNEL_APP_BASES_NAMESPACE, _appId)) - returns (ERCProxy appProxy) - */ - function newAppInstance(bytes32 _appId, address _appBase) - public - returns (ERCProxy appProxy) - { - appProxy = kernel.newAppInstance(_appId, _appBase); - emit NewAppProxy(appProxy); + // Overriding function to bypass Truffle's overloading issues + function newPinnedAppInstanceWithoutPayload(bytes32 _appId, address _appBase) public returns (ERCProxy) { + return super.newPinnedAppInstance(_appId, _appBase); } - /* - function newPinnedAppInstance(bytes32 _appId, address _appBase, bytes _initializePayload, bool _setDefault) - public - auth(APP_MANAGER_ROLE, arr(KERNEL_APP_BASES_NAMESPACE, _appId)) - returns (ERCProxy appProxy) - */ - function newPinnedAppInstance(bytes32 _appId, address _appBase, bytes _initializePayload, bool _setDefault) - public - returns (ERCProxy appProxy) - { - appProxy = kernel.newPinnedAppInstance(_appId, _appBase, _initializePayload, _setDefault); - emit NewAppProxy(appProxy); + // Overriding function to bypass Truffle's overloading issues + function newPinnedAppInstanceWithPayload(bytes32 _appId, address _appBase, bytes _initializePayload, bool _setDefault) public returns (ERCProxy) { + return super.newPinnedAppInstance(_appId, _appBase, _initializePayload, _setDefault); } } diff --git a/contracts/test/mocks/lib/sig/ECDSAMock.sol b/contracts/test/mocks/lib/sig/ECDSAMock.sol new file mode 100644 index 000000000..6f87d069e --- /dev/null +++ b/contracts/test/mocks/lib/sig/ECDSAMock.sol @@ -0,0 +1,12 @@ +pragma solidity ^0.4.24; + +import "../../../../lib/sig/ECDSA.sol"; + + +contract ECDSAMock { + using ECDSA for bytes32; + + function recover(bytes32 hash, bytes signature) public pure returns (address) { + return hash.toEthSignedMessageHash().recover(signature); + } +} diff --git a/contracts/test/mocks/relayer/RelayedAppMock.sol b/contracts/test/mocks/relayer/RelayedAppMock.sol new file mode 100644 index 000000000..fdbe45a82 --- /dev/null +++ b/contracts/test/mocks/relayer/RelayedAppMock.sol @@ -0,0 +1,23 @@ +pragma solidity 0.4.24; + +import "../../../relayer/RelayedAragonApp.sol"; + + +contract RelayedAppMock is RelayedAragonApp { + bytes32 public constant WRITING_ROLE = keccak256("WRITING_ROLE"); + + uint256 private x; + + function initialize() public onlyInit { + initialized(); + x = 42; + } + + function read() public view returns (uint256) { + return x; + } + + function write(uint256 _x) public authP(WRITING_ROLE, arr(_x)) { + x = _x; + } +} diff --git a/contracts/test/mocks/relayer/RelayerMock.sol b/contracts/test/mocks/relayer/RelayerMock.sol new file mode 100644 index 000000000..946747733 --- /dev/null +++ b/contracts/test/mocks/relayer/RelayerMock.sol @@ -0,0 +1,15 @@ +pragma solidity 0.4.24; + +import "../../../relayer/Relayer.sol"; +import "../../../test/mocks/common/TimeHelpersMock.sol"; + + +contract RelayerMock is Relayer, TimeHelpersMock { + function messageHash(address to, uint256 nonce, bytes data, uint256 gasRefund, uint256 gasPrice) public view returns (bytes32) { + return _messageHash(to, nonce, data, gasRefund, gasPrice); + } + + function domainSeparator() public view returns (bytes32) { + return _domainSeparator(); + } +} diff --git a/contracts/test/tests/TestMemoryHelpers.sol b/contracts/test/tests/TestMemoryHelpers.sol new file mode 100644 index 000000000..7b1b2e883 --- /dev/null +++ b/contracts/test/tests/TestMemoryHelpers.sol @@ -0,0 +1,85 @@ +pragma solidity 0.4.24; + +import "../helpers/Assert.sol"; +import "../../common/MemoryHelpers.sol"; + + +contract TestMemoryHelpers { + using MemoryHelpers for bytes; + + uint256 constant internal FIRST = uint256(10); + uint256 constant internal SECOND = uint256(1); + uint256 constant internal THIRD = uint256(15); + + function testBytesArrayCopy() public { + bytes memory blob = _initializeArbitraryBytesArray(); + uint256 blobSize = blob.length; + bytes memory copy = new bytes(blobSize); + uint256 input; + uint256 output; + assembly { + input := add(blob, 0x20) + output := add(copy, 0x20) + } + MemoryHelpers.memcpy(output, input, blobSize); + + Assert.equal(blob.length, copy.length, "should have correct length"); + + uint256 firstWord = _assertEqualMemoryWord(blob, copy, 0); + Assert.equal(firstWord, FIRST, "first value should match"); + + uint256 secondWord = _assertEqualMemoryWord(blob, copy, 1); + Assert.equal(secondWord, SECOND, "second value should match"); + + uint256 thirdWord = _assertEqualMemoryWord(blob, copy, 2); + Assert.equal(thirdWord, THIRD, "third value should match"); + } + + function testAppendAddressToBytesArray() public { + bytes memory blob = _initializeArbitraryBytesArray(); + address addr = address(0x000000000000000000000000000000000000dEaD); + bytes memory result = blob.append(addr); + + Assert.equal(blob.length + 32, result.length, "should have correct length"); + + uint256 firstWord = _assertEqualMemoryWord(blob, result, 0); + Assert.equal(firstWord, FIRST, "first value should match"); + + uint256 secondWord = _assertEqualMemoryWord(blob, result, 1); + Assert.equal(secondWord, SECOND, "second value should match"); + + uint256 thirdWord = _assertEqualMemoryWord(blob, result, 2); + Assert.equal(thirdWord, THIRD, "third value should match"); + + bytes32 storedAddress; + assembly { storedAddress := mload(add(result, 0x80))} + Assert.equal(storedAddress, bytes32(0x000000000000000000000000000000000000000000000000000000000000dEaD), "appended address should match"); + } + + function _assertEqualMemoryWord(bytes _actual, bytes _expected, uint256 _index) private returns (uint256) { + uint256 actualValue; + uint256 expectedValue; + uint256 pos = _index * 32; + assembly { + actualValue := mload(add(add(_actual, 0x20), pos)) + expectedValue := mload(add(add(_expected, 0x20), pos)) + } + Assert.equal(actualValue, expectedValue, "memory values should match"); + return expectedValue; + } + + function _initializeArbitraryBytesArray() private pure returns (bytes memory) { + bytes memory blob = new bytes(96); + + uint256 first = FIRST; + uint256 second = SECOND; + uint256 third = THIRD; + assembly { + mstore(add(blob, 0x20), first) + mstore(add(blob, 0x40), second) + mstore(add(blob, 0x60), third) + } + + return blob; + } +} diff --git a/contracts/test/tests/TestRelayerCalldata.sol b/contracts/test/tests/TestRelayerCalldata.sol new file mode 100644 index 000000000..d9c16c3f0 --- /dev/null +++ b/contracts/test/tests/TestRelayerCalldata.sol @@ -0,0 +1,49 @@ +pragma solidity 0.4.24; + +import "../helpers/Assert.sol"; +import "../../relayer/Relayer.sol"; +import "../../common/MemoryHelpers.sol"; + + +contract RelayedAppTest is RelayedAragonApp { + function callme(uint8, bytes32, string) public { + bytes memory calldata = msg.data; + // 4 32 32 32 32 32 32 + // [sig][uint8][bytes32][string starting offset][string size][string word][signer] + Assert.equal(calldata.length, 4 + 32 * 6, "should have correct length"); + + _assertCalldataWord(0x04, bytes32(0x000000000000000000000000000000000000000000000000000000000000000f)); + _assertCalldataWord(0x24, bytes32(0x0000000000000000000000000000000000000000000000000000000000000f00)); + _assertCalldataWord(0x44, bytes32(0x0000000000000000000000000000000000000000000000000000000000000060)); + _assertCalldataWord(0x64, bytes32(0x0000000000000000000000000000000000000000000000000000000000000007)); + _assertCalldataWord(0x84, bytes32(0x72656c6179656400000000000000000000000000000000000000000000000000)); + _assertCalldataWord(0xa4, bytes32(TestRelayerCalldata(msg.sender).signer())); + } + + function _assertCalldataWord(uint256 _pos, bytes32 _expectedValue) private { + bytes32 actualValue; + assembly { + let ptr := mload(0x40) + mstore(0x40, add(ptr, 0x20)) + calldatacopy(ptr, _pos, 0x20) + actualValue := mload(ptr) + } + Assert.equal(actualValue, _expectedValue, "calldata values should match"); + } +} + +contract TestRelayerCalldata is Relayer { + RelayedAppTest public appTest; + + address public signer; + + constructor () public { + appTest = new RelayedAppTest(); + } + + function testSignerEncodedCalls() public { + signer = msg.sender; + bytes memory calldata = abi.encodeWithSelector(appTest.callme.selector, uint8(15), bytes32(0xf00), "relayed"); + _relayCall(signer, address(appTest), calldata); + } +} diff --git a/lib/signTypedData.js b/lib/signTypedData.js new file mode 100644 index 000000000..a4b0af064 --- /dev/null +++ b/lib/signTypedData.js @@ -0,0 +1,35 @@ +const TYPED_DATA = (relayer, message) => ({ + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + Transaction: [ + { name: 'to', type: 'address' }, + { name: 'nonce', type: 'uint256' }, + { name: 'data', type: 'bytes' }, + { name: 'gasRefund', type: 'uint256' }, + { name: 'gasPrice', type: 'uint256' } + ], + }, + primaryType: "Transaction", + domain: { + name: 'Aragon Relayer', + version: '1', + chainId: 1, + verifyingContract: relayer.address + }, + message: message +}) + + +module.exports = (web3) => async (relayer, sender, message) => { + const params = { method: 'eth_signTypedData', params: [sender, TYPED_DATA(relayer, message)], from: sender } + return new Promise((resolve, reject) => { + web3.currentProvider.sendAsync(params, (error, tx) => { + return error ? reject(error) : resolve(tx.result) + }) + }) +} diff --git a/test/contracts/common/keccak_constants.js b/test/contracts/common/keccak_constants.js index 972bb9b03..8bcd14e32 100644 --- a/test/contracts/common/keccak_constants.js +++ b/test/contracts/common/keccak_constants.js @@ -27,6 +27,7 @@ contract('Constants', () => { assert.equal(await kernelConstants.getKernelAppId(), await keccakConstants.KERNEL_APP_ID(), "kernel app id doesn't match") assert.equal(await kernelConstants.getDefaultACLAppId(), await keccakConstants.DEFAULT_ACL_APP_ID(), "default ACL id doesn't match") assert.equal(await kernelConstants.getDefaultVaultAppId(), await keccakConstants.DEFAULT_VAULT_APP_ID(), "default vault id doesn't match") + assert.equal(await kernelConstants.getDefaultRelayerAppId(), await keccakConstants.DEFAULT_RELAYER_APP_ID(), "default relayer id doesn't match") assert.equal(await kernelConstants.getKernelCoreNamespace(), await keccakConstants.KERNEL_CORE_NAMESPACE(), "core namespace doesn't match") assert.equal(await kernelConstants.getKernelAppBasesNamespace(), await keccakConstants.KERNEL_APP_BASES_NAMESPACE(), "base namespace doesn't match") assert.equal(await kernelConstants.getKernelAppAddrNamespace(), await keccakConstants.KERNEL_APP_ADDR_NAMESPACE(), "app namespace doesn't match") diff --git a/test/contracts/common/memory_helpers.js b/test/contracts/common/memory_helpers.js new file mode 100644 index 000000000..e4eb96269 --- /dev/null +++ b/test/contracts/common/memory_helpers.js @@ -0,0 +1,3 @@ +const runSolidityTest = require('../../helpers/runSolidityTest') + +runSolidityTest('TestMemoryHelpers') diff --git a/test/contracts/kernel/kernel_apps.js b/test/contracts/kernel/kernel_apps.js index 49fea658b..136ec6463 100644 --- a/test/contracts/kernel/kernel_apps.js +++ b/test/contracts/kernel/kernel_apps.js @@ -5,7 +5,7 @@ const { getNewProxyAddress } = require('../../helpers/events') const { assertAmountOfEvents } = require('../../helpers/assertEvent')(web3) const ACL = artifacts.require('ACL') -const Kernel = artifacts.require('Kernel') +const Kernel = artifacts.require('KernelOverloadMock') const KernelProxy = artifacts.require('KernelProxy') const AppProxyUpgradeable = artifacts.require('AppProxyUpgradeable') const AppProxyPinned = artifacts.require('AppProxyPinned') @@ -14,7 +14,6 @@ const AppProxyPinned = artifacts.require('AppProxyPinned') const AppStub = artifacts.require('AppStub') const AppStub2 = artifacts.require('AppStub2') const ERCProxyMock = artifacts.require('ERCProxyMock') -const KernelOverloadMock = artifacts.require('KernelOverloadMock') const APP_ID = hash('stub.aragonpm.test') const EMPTY_BYTES = '0x' @@ -45,7 +44,7 @@ contract('Kernel apps', ([permissionsRoot]) => { // Test both the Kernel itself and the KernelProxy to make sure their behaviours are the same for (const kernelType of ['Kernel', 'KernelProxy']) { context(`> ${kernelType}`, () => { - let acl, kernel, kernelBase, app, appProxy + let acl, kernel, kernelBase before(async () => { if (kernelType === 'KernelProxy') { @@ -67,8 +66,8 @@ contract('Kernel apps', ([permissionsRoot]) => { }) /******** - * TESTS * - *********/ + * TESTS * + *********/ it('fails if setting app to 0 address', async () => { await assertRevert(kernel.setApp(APP_BASES_NAMESPACE, APP_ID, ZERO_ADDR)) }) @@ -77,73 +76,53 @@ contract('Kernel apps', ([permissionsRoot]) => { await assertRevert(kernel.setApp(APP_BASES_NAMESPACE, APP_ID, '0x1234')) }) - const newAppProxyMapping = { - 'AppProxy': 'newAppInstance', - 'AppProxyPinned': 'newPinnedAppInstance', - } - for (const appProxyType of Object.keys(newAppProxyMapping)) { - // NOTE: we have to do really hacky workarounds here due to truffle not supporting - // function overloads. - // Especially awful is how we only get the full version of `newAppInstance()` but - // not `newPinnedAppInstance()`, forcing us to apply the KernelOverloadMock on - // different proxy instances - let kernelOverload - const newInstanceFn = newAppProxyMapping[appProxyType] - + for (const appProxyType of ['AppProxy', 'AppProxyPinned']) { const onlyAppProxy = onlyIf(() => appProxyType === 'AppProxy') const onlyAppProxyPinned = onlyIf(() => appProxyType === 'AppProxyPinned') context(`> new ${appProxyType} instances`, () => { onlyAppProxy(() => - it('creates a new upgradeable app proxy instance', async () => { - const receipt = await kernel.newAppInstance(APP_ID, appBase1.address, EMPTY_BYTES, false) - const appProxy = AppProxyUpgradeable.at(getNewProxyAddress(receipt)) - assert.equal(await appProxy.kernel(), kernel.address, "new appProxy instance's kernel should be set to the originating kernel") - - // Checks ERC897 functionality - assert.equal((await appProxy.proxyType()).toString(), UPGRADEABLE, 'new appProxy instance should be upgradeable') - assert.equal(await appProxy.implementation(), appBase1.address, 'new appProxy instance should be resolving to implementation address') - }) + it('creates a new upgradeable app proxy instance', async () => { + const receipt = await kernel.newAppInstanceWithPayload(APP_ID, appBase1.address, EMPTY_BYTES, false) + const appProxy = AppProxyUpgradeable.at(getNewProxyAddress(receipt)) + assert.equal(await appProxy.kernel(), kernel.address, "new appProxy instance's kernel should be set to the originating kernel") + + // Checks ERC897 functionality + assert.equal((await appProxy.proxyType()).toString(), UPGRADEABLE, 'new appProxy instance should be upgradeable') + assert.equal(await appProxy.implementation(), appBase1.address, 'new appProxy instance should be resolving to implementation address') + }) ) onlyAppProxyPinned(() => - it('creates a new non upgradeable app proxy instance', async () => { - const receipt = await kernel.newPinnedAppInstance(APP_ID, appBase1.address) - const appProxy = AppProxyPinned.at(getNewProxyAddress(receipt)) - assert.equal(await appProxy.kernel(), kernel.address, "new appProxy instance's kernel should be set to the originating kernel") - - // Checks ERC897 functionality - assert.equal((await appProxy.proxyType()).toString(), FORWARDING, 'new appProxy instance should be not upgradeable') - assert.equal(await appProxy.implementation(), appBase1.address, 'new appProxy instance should be resolving to implementation address') - }) + it('creates a new non upgradeable app proxy instance', async () => { + const receipt = await kernel.newPinnedAppInstanceWithoutPayload(APP_ID, appBase1.address) + const appProxy = AppProxyPinned.at(getNewProxyAddress(receipt)) + assert.equal(await appProxy.kernel(), kernel.address, "new appProxy instance's kernel should be set to the originating kernel") + + // Checks ERC897 functionality + assert.equal((await appProxy.proxyType()).toString(), FORWARDING, 'new appProxy instance should be not upgradeable') + assert.equal(await appProxy.implementation(), appBase1.address, 'new appProxy instance should be resolving to implementation address') + }) ) context('> full new app instance overload', async () => { - beforeEach(async () => { - if (appProxyType === 'AppProxy') { - // No need to apply the overload - kernelOverload = kernel - } else if (appProxyType === 'AppProxyPinned') { - kernelOverload = await KernelOverloadMock.new(kernel.address) - await acl.grantPermission(kernelOverload.address, kernel.address, APP_MANAGER_ROLE) - } - }) + const newInstanceFn = appProxyType === 'AppProxy' ? 'newAppInstanceWithPayload' : 'newPinnedAppInstanceWithPayload' it('sets the app base when not previously registered', async() => { assert.equal(ZERO_ADDR, await kernel.getApp(APP_BASES_NAMESPACE, APP_ID)) - await kernelOverload[newInstanceFn](APP_ID, appBase1.address, EMPTY_BYTES, false) + await kernel[newInstanceFn](APP_ID, appBase1.address, EMPTY_BYTES, false) assert.equal(appBase1.address, await kernel.getApp(APP_BASES_NAMESPACE, APP_ID)) }) it("doesn't set the app base when already set", async() => { await kernel.setApp(APP_BASES_NAMESPACE, APP_ID, appBase1.address) - const receipt = await kernelOverload[newInstanceFn](APP_ID, appBase1.address, EMPTY_BYTES, false) + const receipt = await kernel[newInstanceFn](APP_ID, appBase1.address, EMPTY_BYTES, false) assertAmountOfEvents(receipt, 'SetApp', 0) }) it("also sets the default app", async () => { - const receipt = await kernelOverload[newInstanceFn](APP_ID, appBase1.address, EMPTY_BYTES, true) + const receipt = await kernel[newInstanceFn](APP_ID, appBase1.address, EMPTY_BYTES, true) const appProxyAddr = getNewProxyAddress(receipt) // Check that both the app base and default app are set @@ -158,7 +137,7 @@ contract('Kernel apps', ([permissionsRoot]) => { const initData = appBase1.initialize.request().params[0].data // Make sure app was initialized - const receipt = await kernelOverload[newInstanceFn](APP_ID, appBase1.address, initData, false) + const receipt = await kernel[newInstanceFn](APP_ID, appBase1.address, initData, false) const appProxyAddr = getNewProxyAddress(receipt) assert.isTrue(await AppStub.at(appProxyAddr).hasInitialized(), 'App should have been initialized') @@ -168,7 +147,7 @@ contract('Kernel apps', ([permissionsRoot]) => { }) it("fails if the app base is not given", async() => { - await assertRevert(kernelOverload[newInstanceFn](APP_ID, ZERO_ADDR, EMPTY_BYTES, false)) + await assertRevert(kernel[newInstanceFn](APP_ID, ZERO_ADDR, EMPTY_BYTES, false)) }) it('fails if the given app base is different than the existing one', async() => { @@ -177,36 +156,28 @@ contract('Kernel apps', ([permissionsRoot]) => { assert.notEqual(existingBase, differentBase, 'appBase1 and appBase2 should have different addresses') await kernel.setApp(APP_BASES_NAMESPACE, APP_ID, existingBase) - await assertRevert(kernelOverload[newInstanceFn](APP_ID, differentBase, EMPTY_BYTES, false)) + await assertRevert(kernel[newInstanceFn](APP_ID, differentBase, EMPTY_BYTES, false)) }) }) context('> minimized new app instance overload', async () => { - beforeEach(async () => { - if (appProxyType === 'AppProxy') { - kernelOverload = await KernelOverloadMock.new(kernel.address) - await acl.grantPermission(kernelOverload.address, kernel.address, APP_MANAGER_ROLE) - } else if (appProxyType === 'AppProxyPinned') { - // No need to apply the overload - kernelOverload = kernel - } - }) + const newInstanceFn = appProxyType === 'AppProxy' ? 'newAppInstanceWithoutPayload' : 'newPinnedAppInstanceWithoutPayload' it('sets the app base when not previously registered', async() => { assert.equal(ZERO_ADDR, await kernel.getApp(APP_BASES_NAMESPACE, APP_ID)) - await kernelOverload[newInstanceFn](APP_ID, appBase1.address) + await kernel[newInstanceFn](APP_ID, appBase1.address) assert.equal(appBase1.address, await kernel.getApp(APP_BASES_NAMESPACE, APP_ID)) }) it("doesn't set the app base when already set", async() => { await kernel.setApp(APP_BASES_NAMESPACE, APP_ID, appBase1.address) - const receipt = await kernelOverload[newInstanceFn](APP_ID, appBase1.address) + const receipt = await kernel[newInstanceFn](APP_ID, appBase1.address) assertAmountOfEvents(receipt, 'SetApp', 0) }) it("does not set the default app", async () => { - const receipt = await kernelOverload[newInstanceFn](APP_ID, appBase1.address) + const receipt = await kernel[newInstanceFn](APP_ID, appBase1.address) const appProxyAddr = getNewProxyAddress(receipt) // Check that only the app base is set @@ -218,7 +189,7 @@ contract('Kernel apps', ([permissionsRoot]) => { }) it("does not allow initializing proxy", async () => { - const receipt = await kernelOverload[newInstanceFn](APP_ID, appBase1.address) + const receipt = await kernel[newInstanceFn](APP_ID, appBase1.address) const appProxyAddr = getNewProxyAddress(receipt) // Make sure app was not initialized @@ -230,7 +201,7 @@ contract('Kernel apps', ([permissionsRoot]) => { }) it("fails if the app base is not given", async() => { - await assertRevert(kernelOverload[newInstanceFn](APP_ID, ZERO_ADDR)) + await assertRevert(kernel[newInstanceFn](APP_ID, ZERO_ADDR)) }) it('fails if the given app base is different than the existing one', async() => { @@ -239,7 +210,7 @@ contract('Kernel apps', ([permissionsRoot]) => { assert.notEqual(existingBase, differentBase, 'appBase1 and appBase2 should have different addresses') await kernel.setApp(APP_BASES_NAMESPACE, APP_ID, existingBase) - await assertRevert(kernelOverload[newInstanceFn](APP_ID, differentBase)) + await assertRevert(kernel[newInstanceFn](APP_ID, differentBase)) }) }) }) diff --git a/test/contracts/kernel/kernel_lifecycle.js b/test/contracts/kernel/kernel_lifecycle.js index 832a05295..5425b38bc 100644 --- a/test/contracts/kernel/kernel_lifecycle.js +++ b/test/contracts/kernel/kernel_lifecycle.js @@ -23,7 +23,7 @@ contract('Kernel lifecycle', ([root, someone]) => { assert.isFalse(await kernel.hasPermission(someone, kernel.address, APP_MANAGER_ROLE, EMPTY_BYTES)) await assertRevert(kernel.newAppInstance(APP_ID, appBase.address, EMPTY_BYTES, false)) - await assertRevert(kernel.newPinnedAppInstance(APP_ID, appBase.address)) + await assertRevert(kernel.newPinnedAppInstance(APP_ID, appBase.address, EMPTY_BYTES, false)) await assertRevert(kernel.setApp(APP_BASES_NAMESPACE, APP_ID, appBase.address)) await assertRevert(kernel.setRecoveryVaultAppId(VAULT_ID)) } diff --git a/test/contracts/lib/sig/ecsda.js b/test/contracts/lib/sig/ecsda.js new file mode 100644 index 000000000..2bbc656da --- /dev/null +++ b/test/contracts/lib/sig/ecsda.js @@ -0,0 +1,26 @@ +const { sha3, soliditySha3 } = require('web3-utils') + +const ECDSA = artifacts.require('ECDSAMock') + +contract('ECDSA', ([_, someone]) => { + let ecdsa, signature + + const MESSAGE = soliditySha3(sha3('0x11111'), 1000) + + before(async () => { + ecdsa = await ECDSA.new() + signature = await web3.eth.sign(someone, MESSAGE) + }) + + context('with correct signature', () => { + it('returns the signer address', async () => { + assert.equal(await ecdsa.recover(MESSAGE, signature), someone) + }) + }) + + context('with wrong signature', () => { + it('does not return the signer address', async () => { + assert.notEqual(await ecdsa.recover('0xdead', signature), someone) + }) + }) +}) diff --git a/test/contracts/relayer/relayer.js b/test/contracts/relayer/relayer.js new file mode 100644 index 000000000..b9f4f5c9f --- /dev/null +++ b/test/contracts/relayer/relayer.js @@ -0,0 +1,910 @@ +const signTypedData = require('../../../lib/signTypedData')(web3) +const { assertRevert } = require('../../helpers/assertThrow') +const { skipCoverage } = require('../../helpers/coverage') +const { getEventArgument, getNewProxyAddress } = require('../../helpers/events') +const { assertEvent, assertAmountOfEvents } = require('../../helpers/assertEvent')(web3) + +const ACL = artifacts.require('ACL') +const Kernel = artifacts.require('Kernel') +const Relayer = artifacts.require('RelayerMock') +const DAOFactory = artifacts.require('DAOFactory') +const SampleApp = artifacts.require('RelayedAppMock') + +const NOW = 1557945653 +const ONE_MONTH = 60 * 60 * 24 * 30 +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' + +contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) => { + let daoFactory, dao, acl, app, relayer + let kernelBase, aclBase, sampleAppBase, relayerBase + let WRITING_ROLE, APP_MANAGER_ROLE, RELAYER_APP_ID + let SET_MONTHLY_REFUND_QUOTA_ROLE, ALLOW_SENDER_ROLE, DISALLOW_SENDER_ROLE, ALLOW_OFF_CHAIN_SERVICE_ROLE, DISALLOW_OFF_CHAIN_SERVICE_ROLE + + const GAS_PRICE = 1e9 + const MONTHLY_REFUND_GAS = 1e6 * 5 + const MONTHLY_REFUND_QUOTA = MONTHLY_REFUND_GAS * GAS_PRICE + + const SEND_ETH_GAS = 31000 // 21k base tx cost + 10k limit on depositable proxies + + const signRelayedTx = async ({ from, to, nonce, calldata, gasRefund, gasPrice = GAS_PRICE }) => { + const message = { to, nonce, data: calldata, gasRefund, gasPrice } + return signTypedData(relayer, from, message) + } + + before('deploy base implementations', async () => { + aclBase = await ACL.new() + kernelBase = await Kernel.new(true) // petrify immediately + relayerBase = await Relayer.new() + sampleAppBase = await SampleApp.new() + daoFactory = await DAOFactory.new(kernelBase.address, aclBase.address, '0x0') + }) + + before('load constants', async () => { + RELAYER_APP_ID = await kernelBase.DEFAULT_RELAYER_APP_ID() + WRITING_ROLE = await sampleAppBase.WRITING_ROLE() + APP_MANAGER_ROLE = await kernelBase.APP_MANAGER_ROLE() + SET_MONTHLY_REFUND_QUOTA_ROLE = await relayerBase.SET_MONTHLY_REFUND_QUOTA_ROLE() + ALLOW_SENDER_ROLE = await relayerBase.ALLOW_SENDER_ROLE() + DISALLOW_SENDER_ROLE = await relayerBase.DISALLOW_SENDER_ROLE() + ALLOW_OFF_CHAIN_SERVICE_ROLE = await relayerBase.ALLOW_OFF_CHAIN_SERVICE_ROLE() + DISALLOW_OFF_CHAIN_SERVICE_ROLE = await relayerBase.DISALLOW_OFF_CHAIN_SERVICE_ROLE() + }) + + before('deploy DAO', async () => { + const receipt = await daoFactory.newDAO(root) + dao = Kernel.at(getEventArgument(receipt, 'DeployDAO', 'dao')) + acl = ACL.at(await dao.acl()) + + await acl.createPermission(root, dao.address, APP_MANAGER_ROLE, root, { from: root }) + }) + + before('create sample app instance', async () => { + const receipt = await dao.newAppInstance('0x22222', sampleAppBase.address, '0x', false, { from: root }) + app = SampleApp.at(getNewProxyAddress(receipt)) + await app.initialize() + await acl.createPermission(member, app.address, WRITING_ROLE, root, { from: root }) + }) + + beforeEach('create relayer instance', async () => { + const receipt = await dao.newAppInstance(RELAYER_APP_ID, relayerBase.address, '0x', true, { from: root }) + relayer = Relayer.at(getNewProxyAddress(receipt)) + + await relayer.mockSetTimestamp(NOW) + + await acl.createPermission(root, relayer.address, ALLOW_SENDER_ROLE, root, { from: root }) + await acl.createPermission(root, relayer.address, DISALLOW_SENDER_ROLE, root, { from: root }) + await acl.createPermission(root, relayer.address, SET_MONTHLY_REFUND_QUOTA_ROLE, root, { from: root }) + await acl.createPermission(root, relayer.address, ALLOW_OFF_CHAIN_SERVICE_ROLE, root, { from: root }) + await acl.createPermission(root, relayer.address, DISALLOW_OFF_CHAIN_SERVICE_ROLE, root, { from: root }) + }) + + it('can call the app without going through the relayer', async () => { + await app.write(10, { from: member }) + assert.equal((await app.read()).toString(), 10, 'app value does not match') + + await assertRevert(app.write(10, { from: someone }), 'APP_AUTH_FAILED') + }) + + if (!process.env.SOLIDITY_COVERAGE) { + describe('initialize', () => { + it('is not initialized by default', async () => { + assert.isFalse(await relayer.hasInitialized(), 'should not be initialized') + }) + + it('initializes the relayer app correctly', async () => { + await relayer.initialize(MONTHLY_REFUND_QUOTA) + assert.isTrue(await relayer.hasInitialized(), 'should be initialized') + }) + + it('cannot be initialized again', async () => { + await relayer.initialize(MONTHLY_REFUND_QUOTA) + await assertRevert(relayer.initialize(MONTHLY_REFUND_QUOTA), 'INIT_ALREADY_INITIALIZED') + }) + }) + + describe('isDepositable', () => { + context('when the app is initialized', () => { + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + + it('returns true', async () => { + assert.isTrue(await relayer.isDepositable(), 'should be depositable') + }) + }) + + context('when the app is not initialized', () => { + it('returns false', async () => { + assert.isFalse(await relayer.isDepositable(), 'should not be depositable') + }) + }) + }) + + describe('allowRecoverability', () => { + const itReturnsTrueUnlessETH = () => { + context('when the token is ETH', () => { + it('returns false', async () => { + assert.isFalse(await relayer.allowRecoverability(ZERO_ADDRESS), 'should not allow ETH recoverability') + }) + }) + + context('when the token is not ETH', () => { + it('returns true', async () => { + assert.isTrue(await relayer.allowRecoverability(someone), 'should allow tokens recoverability') + }) + }) + } + + context('when the app is initialized', () => { + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + + itReturnsTrueUnlessETH() + }) + + context('when the app is initialized', () => { + itReturnsTrueUnlessETH() + }) + }) + + describe('getStartDate', () => { + context('when the app is initialized', () => { + + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + + it('returns the start date', async () => { + const startDate = await relayer.getStartDate() + assert.equal(startDate.toString(), NOW, 'start date does not match') + }) + }) + + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.getStartDate(), 'INIT_NOT_INITIALIZED') + }) + }) + }) + + describe('getMonthlyRefundQuota', () => { + context('when the app is initialized', () => { + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + + it('returns the start date', async () => { + const quota = await relayer.getMonthlyRefundQuota() + assert.equal(quota.toString(), MONTHLY_REFUND_QUOTA, 'monthly refunds quota does not match') + }) + }) + + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.getMonthlyRefundQuota(), 'INIT_NOT_INITIALIZED') + }) + }) + }) + + describe('getCurrentMonth', () => { + context('when the app is initialized', () => { + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + + context('when it has not passed a seconds since its initialization', () => { + it('returns 0', async () => { + const currentMonth = await relayer.getCurrentMonth() + assert.equal(currentMonth.toString(), 0, 'current month quota does not match') + }) + }) + + context('when it has passed almost 30 days since its initialization', () => { + beforeEach('increase time by almost 30 days', async () => await relayer.mockIncreaseTime(ONE_MONTH - 1)) + + it('returns 0', async () => { + const currentMonth = await relayer.getCurrentMonth() + assert.equal(currentMonth.toString(), 0, 'current month quota does not match') + }) + }) + + context('when it has passed 30 days since its initialization', () => { + beforeEach('increase time by 30 days', async () => await relayer.mockIncreaseTime(ONE_MONTH)) + + it('returns 1', async () => { + const currentMonth = await relayer.getCurrentMonth() + assert.equal(currentMonth.toString(), 1, 'current month quota does not match') + }) + }) + }) + + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.getCurrentMonth(), 'INIT_NOT_INITIALIZED') + }) + }) + }) + + describe('getSender', () => { + context('when the app is initialized', () => { + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + + context('when the given sender did not send transactions yet', () => { + it('returns empty data', async () => { + const [allowed, lastUsedNonce, lastActiveMonth, lastActiveMonthRefunds] = await relayer.getSender(member) + + assert.equal(allowed, false, 'sender should be allowed') + assert.equal(lastUsedNonce.toString(), 0, 'last nonce does not match') + assert.equal(lastActiveMonth.toString(), 0, 'last active month does not match') + assert.equal(lastActiveMonthRefunds.toString(), 0, 'last active month refunds does not match') + }) + }) + + context('when the given sender has already sent some transactions', () => { + const gasRefund = 50000 + + beforeEach('relay a transaction', async () => { + const nonce = 2 + const calldata = '0x11111111' + const signature = await signRelayedTx({ from: member, to: someone, nonce, calldata, gasRefund }) + + await web3.eth.sendTransaction({ from: vault, to: relayer.address, value: 1e18, gas: SEND_ETH_GAS }) + await relayer.allowService(offChainRelayerService, { from: root }) + await relayer.allowSender(member, { from: root }) + await relayer.relay(member, someone, nonce, calldata, gasRefund, GAS_PRICE, signature, { from: offChainRelayerService }) + }) + + it('returns the corresponding information', async () => { + const [allowed, lastUsedNonce, lastActiveMonth, lastActiveMonthRefunds] = await relayer.getSender(member) + + assert.equal(allowed, true, 'sender should be allowed') + assert.equal(lastUsedNonce.toString(), 2, 'last nonce does not match') + assert.equal(lastActiveMonth.toString(), 0, 'last active month does not match') + assert.equal(lastActiveMonthRefunds.toString(), gasRefund * GAS_PRICE, 'last active month refunds does not match') + }) + }) + }) + + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.getSender(member), 'INIT_NOT_INITIALIZED') + }) + }) + }) + + describe('isServiceAllowed', () => { + context('when the app is initialized', () => { + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + + context('when the given address was allowed', () => { + beforeEach('allow service', async () => await relayer.allowService(offChainRelayerService, { from: root })) + + context('when the given address is still allowed', () => { + it('returns true', async () => { + assert(await relayer.isServiceAllowed(offChainRelayerService), 'off chain service should be allowed') + }) + }) + + context('when the given address was already disallowed', () => { + beforeEach('disallow service', async () => await relayer.disallowService(offChainRelayerService, { from: root })) + + it('returns false', async () => { + assert.isFalse(await relayer.isServiceAllowed(offChainRelayerService), 'off chain service should not be allowed') + }) + }) + }) + + context('when the given address was never allowed', () => { + it('returns false', async () => { + assert.isFalse(await relayer.isServiceAllowed(offChainRelayerService), 'off chain service should not be allowed') + }) + }) + }) + + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.isServiceAllowed(offChainRelayerService), 'INIT_NOT_INITIALIZED') + }) + }) + }) + + describe('isSenderAllowed', () => { + context('when the app is initialized', () => { + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + + context('when the given address was allowed', () => { + beforeEach('allow sender', async () => await relayer.allowSender(someone, { from: root })) + + context('when the given address is still allowed', () => { + it('returns true', async () => { + assert(await relayer.isSenderAllowed(someone), 'sender should be allowed') + }) + }) + + context('when the given address was already disallowed', () => { + beforeEach('disallow sender', async () => await relayer.disallowSender(someone, { from: root })) + + it('returns false', async () => { + assert.isFalse(await relayer.isSenderAllowed(someone), 'sender should not be allowed') + }) + }) + }) + + context('when the given address was never allowed', () => { + it('returns false', async () => { + assert.isFalse(await relayer.isSenderAllowed(someone), 'sender should be allowed') + }) + }) + }) + + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.isSenderAllowed(someone), 'INIT_NOT_INITIALIZED') + }) + }) + }) + + describe('canUseNonce', () => { + context('when the app is initialized', () => { + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + + context('when the given sender did not send transactions yet', () => { + context('when the requested nonce is zero', () => { + const nonce = 0 + + it('returns false', async () => { + assert.isFalse(await relayer.canUseNonce(member, nonce), 'should not be allowed to use nonce zero') + }) + }) + + context('when the requested nonce is greater than zero', () => { + const nonce = 1 + + it('returns true', async () => { + assert(await relayer.canUseNonce(member, nonce), 'should be allowed to use nonce') + }) + }) + }) + + context('when the given sender has already sent some transactions', () => { + const usedNonce = 2 + + beforeEach('relay a transaction', async () => { + const calldata = '0x11111111' + const gasRefund = 50000 + const signature = await signRelayedTx({ from: member, to: someone, nonce: usedNonce, calldata, gasRefund }) + + await web3.eth.sendTransaction({ from: vault, to: relayer.address, value: 1e18, gas: SEND_ETH_GAS }) + await relayer.allowService(offChainRelayerService, { from: root }) + await relayer.allowSender(member, { from: root }) + await relayer.relay(member, someone, usedNonce, calldata, gasRefund, GAS_PRICE, signature, { from: offChainRelayerService }) + }) + + context('when the requested nonce is zero', () => { + const nonce = 0 + + context('when the requested sender is the actual sender', () => { + const sender = member + + it('returns false', async () => { + assert.isFalse(await relayer.canUseNonce(sender, nonce), 'should not be allowed to use nonce zero') + }) + }) + + context('when the requested sender is another account', () => { + const sender = someone + + it('returns false', async () => { + assert.isFalse(await relayer.canUseNonce(sender, nonce), 'should not be allowed to use nonce zero') + }) + }) + }) + + context('when the requested nonce is greater than zero but lower than the nonce used', () => { + const nonce = usedNonce - 1 + + context('when the requested sender is the actual sender', () => { + const sender = member + + it('returns false', async () => { + assert.isFalse(await relayer.canUseNonce(sender, nonce), 'should not be allowed to use given nonce') + }) + }) + + context('when the requested sender is another account', () => { + const sender = someone + + it('returns true', async () => { + assert.isTrue(await relayer.canUseNonce(sender, nonce), 'should be allowed to use given nonce') + }) + }) + }) + + context('when the requested nonce is equal to the nonce used', () => { + const nonce = usedNonce + + context('when the requested sender is the actual sender', () => { + const sender = member + + it('returns false', async () => { + assert.isFalse(await relayer.canUseNonce(sender, nonce), 'should not be allowed to use given nonce') + }) + }) + + context('when the requested sender is another account', () => { + const sender = someone + + it('returns true', async () => { + assert.isTrue(await relayer.canUseNonce(sender, nonce), 'should be allowed to use given nonce') + }) + }) + }) + + context('when the requested nonce is greater than the nonce used', () => { + let nonce = usedNonce + 1 + + context('when the requested sender is the actual sender', () => { + const sender = member + + it('returns true', async () => { + assert.isTrue(await relayer.canUseNonce(sender, nonce), 'should be allowed to use given nonce') + }) + }) + + context('when the requested sender is another account', () => { + const sender = someone + + it('returns true', async () => { + assert.isTrue(await relayer.canUseNonce(sender, nonce), 'should be allowed to use given nonce') + }) + }) + }) + }) + }) + + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.canUseNonce(member, 0), 'INIT_NOT_INITIALIZED') + }) + }) + }) + + describe('canRefund', () => { + context('when the app is initialized', () => { + beforeEach('initialize relayer app', async () => { + await relayer.initialize(MONTHLY_REFUND_QUOTA) + }) + + context('when the given sender did not send transactions yet', () => { + context('when the requested amount does not exceed the monthly quota', () => { + const amount = MONTHLY_REFUND_QUOTA - 1 + + it('returns true', async () => { + assert(await relayer.canRefund(member, amount), 'should be allowed to spend given amount') + }) + }) + + context('when the requested amount is equal to the monthly quota', () => { + const amount = MONTHLY_REFUND_QUOTA + + it('returns true', async () => { + assert(await relayer.canRefund(member, amount), 'should be allowed to spend given amount') + }) + }) + + context('when the requested amount is greater than the monthly quota', () => { + const amount = MONTHLY_REFUND_QUOTA + 1 + + it('returns false', async () => { + assert.isFalse(await relayer.canRefund(member, amount), 'should not be allowed to spend given amount') + }) + }) + }) + + context('when the given sender has already sent some transactions', () => { + const gasRefund = 50000 + const monthlySpent = gasRefund * GAS_PRICE + const remainingQuota = MONTHLY_REFUND_QUOTA - monthlySpent + + beforeEach('relay a transaction', async () => { + const nonce = 1 + const calldata = '0x11111111' + const signature = await signRelayedTx({ from: member, to: someone, nonce, calldata, gasRefund }) + + await web3.eth.sendTransaction({ from: vault, to: relayer.address, value: 1e18, gas: SEND_ETH_GAS }) + await relayer.allowService(offChainRelayerService, { from: root }) + await relayer.allowSender(member, { from: root }) + await relayer.relay(member, someone, nonce, calldata, gasRefund, GAS_PRICE, signature, { from: offChainRelayerService }) + }) + + context('when the requested amount does not exceed the remaining monthly quota', () => { + const amount = remainingQuota - 1 + + it('returns true', async () => { + assert.isTrue(await relayer.canRefund(member, amount), 'should be allowed to spend amount') + }) + }) + + context('when the requested amount is equal to the remaining monthly quota', () => { + const amount = remainingQuota + + it('returns true', async () => { + assert.isTrue(await relayer.canRefund(member, amount), 'should be allowed to spend amount') + }) + }) + + context('when the requested amount is greater than the remaining monthly quota', () => { + const amount = remainingQuota + 1 + + it('returns false', async () => { + assert.isFalse(await relayer.canRefund(member, amount), 'should not be allowed to spend amount') + }) + }) + }) + }) + + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.canRefund(member, MONTHLY_REFUND_QUOTA), 'INIT_NOT_INITIALIZED') + }) + }) + }) + + describe('relay', () => { + context('when the app is initialized', () => { + let signature, calldata, gasRefund = 50000, nonce = 10 + + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + + context('when the service is allowed', () => { + const from = offChainRelayerService + + beforeEach('allow service', async () => await relayer.allowService(offChainRelayerService, { from: root })) + + context('when the sender is allowed', () => { + context('when the relayed call does not revert', () => { + beforeEach('allow sender', async () => await relayer.allowSender(member, { from: root })) + + context('when the signature valid', () => { + beforeEach('sign relayed call', async () => { + calldata = app.contract.write.getData(10) + signature = await signRelayedTx({ from: member, to: app.address, nonce, calldata, gasRefund }) + }) + + context('when the nonce is not used', () => { + context('when the sender can refund requested gas amount', () => { + context('when the relayer has funds', () => { + beforeEach('fund relayer', async () => { + await web3.eth.sendTransaction({ from: vault, to: relayer.address, value: 1e18, gas: SEND_ETH_GAS }) + }) + + it('relays transactions to app', async () => { + await relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }) + assert.equal((await app.read()).toString(), 10, 'app value does not match') + }) + + it('refunds the off-chain service', async () => { + const previousRelayerBalance = await web3.eth.getBalance(relayer.address) + const previousServiceBalance = await web3.eth.getBalance(offChainRelayerService) + + const { tx, receipt: { gasUsed } } = await relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }) + const { gasPrice: gasPriceUsed } = await web3.eth.getTransaction(tx) + + const txRefund = gasRefund * GAS_PRICE + const realTxCost = gasPriceUsed.mul(gasUsed) + + const currentRelayerBalance = await web3.eth.getBalance(relayer.address) + const currentServiceBalance = await web3.eth.getBalance(offChainRelayerService) + + assert.equal(currentRelayerBalance.toString(), previousRelayerBalance.minus(txRefund).toString()) + assert.equal(currentServiceBalance.toString(), previousServiceBalance.minus(realTxCost).plus(txRefund).toString()) + }) + + it('updates the last nonce used of the sender', async () => { + await relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }) + + const [_, lastUsedNonce] = await relayer.getSender(member) + + assert.equal(lastUsedNonce.toString(), nonce, 'last nonce should match') + assert.isFalse(await relayer.canUseNonce(member, nonce), 'last nonce should have been updated') + assert.isTrue(await relayer.canUseNonce(member, nonce + 1), 'next nonce should not be used') + }) + + it('updates the monthly refunds of the sender', async () => { + const previousMonthlyRefunds = (await relayer.getSender(member))[3] + await relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }) + + const txRefund = gasRefund * GAS_PRICE + const currentMonthlyRefunds = (await relayer.getSender(member))[3] + assert.equal(previousMonthlyRefunds.toString(), currentMonthlyRefunds.minus(txRefund).toString(), 'total refunds should have been updated') + }) + + it('emits an event', async () => { + const receipt = await relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }) + + assertAmountOfEvents(receipt, 'TransactionRelayed') + assertEvent(receipt, 'TransactionRelayed', { from: member, to: app.address, nonce, data: calldata }) + }) + + it('overloads the first relayed transaction with ~83k and the followings with ~53k of gas', skipCoverage(async () => { + const { receipt: { cumulativeGasUsed: nonRelayerGasUsed } } = await app.write(10, { from: member }) + + const { receipt: { cumulativeGasUsed: firstRelayedGasUsed } } = await relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }) + + const secondSignature = await signRelayedTx({ from: member, to: app.address, nonce: nonce + 1, calldata, gasRefund }) + const { receipt: { cumulativeGasUsed: secondRelayedGasUsed } } = await relayer.relay(member, app.address, nonce + 1, calldata, gasRefund, GAS_PRICE, secondSignature, { from }) + + const firstGasOverload = firstRelayedGasUsed - nonRelayerGasUsed + const secondGasOverload = secondRelayedGasUsed - nonRelayerGasUsed + + console.log('firstGasOverload:', firstGasOverload) + console.log('secondGasOverload:', secondGasOverload) + + assert.isBelow(firstGasOverload, 83500, 'first relayed txs gas overload is higher than 83k') + assert.isBelow(secondGasOverload, 53500, 'following relayed txs gas overload is higher than 53k') + })) + }) + + context('when the relayer does not have funds', () => { + it('reverts', async () => { + await assertRevert(relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_GAS_REFUND_FAIL') + }) + }) + }) + + context('when the sender has reached his monthly gas allowed quota', () => { + beforeEach('reduce allowed gas quota', async () => { + await relayer.setMonthlyRefundQuota(gasRefund * GAS_PRICE - 1, { from: root }) + }) + + it('reverts', async () => { + await assertRevert(relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_GAS_QUOTA_EXCEEDED') + }) + }) + }) + + context('when the nonce is already used', () => { + beforeEach('relay tx', async () => { + await web3.eth.sendTransaction({ from: vault, to: relayer.address, value: 1e18, gas: SEND_ETH_GAS }) + await relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }) + }) + + it('reverts', async () => { + await assertRevert(relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_NONCE_ALREADY_USED') + }) + }) + }) + + context('when the signature is not valid', () => { + it('reverts', async () => { + const signature = web3.eth.sign(member, 'bla') + + await assertRevert(relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_INVALID_SENDER_SIGNATURE') + }) + }) + }) + + context('when the relayed call reverts', () => { + beforeEach('allow sender', async () => await relayer.allowSender(someone, { from: root })) + + context('when the signature is valid', () => { + it('forwards the revert reason', async () => { + calldata = app.contract.write.getData(10) + signature = await signRelayedTx({ from: someone, to: app.address, calldata, nonce, gasRefund }) + + await assertRevert(relayer.relay(someone, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'APP_AUTH_FAILED') + }) + }) + + context('when the signature is not valid', () => { + it('reverts', async () => { + const signature = web3.eth.sign(someone, 'bla') + + await assertRevert(relayer.relay(someone, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_INVALID_SENDER_SIGNATURE') + }) + }) + }) + }) + + context('when the sender is not allowed', () => { + it('reverts', async () => { + await assertRevert(relayer.relay(member, someone, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_SENDER_NOT_ALLOWED') + }) + }) + }) + + context('when the service is not allowed', () => { + const from = someone + + it('reverts', async () => { + await assertRevert(relayer.relay(member, someone, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_SERVICE_NOT_ALLOWED') + }) + }) + }) + + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.relay(member, someone, 1, '0x', 10, GAS_PRICE, '0x'), 'RELAYER_SERVICE_NOT_ALLOWED') + }) + }) + }) + + describe('allowService', () => { + context('when the app is initialized', () => { + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + + context('when the sender is allowed', () => { + const from = root + + it('adds a new allowed service', async () => { + await relayer.allowService(someone, {from}) + + assert(await relayer.isServiceAllowed(someone), 'service should be allowed') + }) + + it('emits an event', async () => { + const receipt = await relayer.allowService(someone, {from}) + + assertAmountOfEvents(receipt, 'ServiceAllowed') + assertEvent(receipt, 'ServiceAllowed', {service: someone}) + }) + }) + + context('when the sender is not allowed', () => { + const from = someone + + it('reverts', async () => { + await assertRevert(relayer.allowService(someone, {from}), 'APP_AUTH_FAILED') + }) + }) + }) + + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.allowService(someone, { from: root }), 'APP_AUTH_FAILED') + }) + }) + }) + + describe('disallowService', () => { + context('when the app is initialized', () => { + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + + context('when the sender is allowed', () => { + const from = root + + it('adds a new allowed service', async () => { + await relayer.disallowService(someone, { from }) + + assert.isFalse(await relayer.isServiceAllowed(someone), 'service should not be allowed') + }) + + it('emits an event', async () => { + const receipt = await relayer.disallowService(someone, { from }) + + assertAmountOfEvents(receipt, 'ServiceDisallowed') + assertEvent(receipt, 'ServiceDisallowed', { service: someone }) + }) + }) + + context('when the sender is not allowed', () => { + const from = someone + + it('reverts', async () => { + await assertRevert(relayer.disallowService(someone, { from }), 'APP_AUTH_FAILED') + }) + }) + }) + + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.disallowService(someone, { from: root }), 'APP_AUTH_FAILED') + }) + }) + }) + + describe('allowSender', () => { + context('when the app is initialized', () => { + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + + context('when the sender is allowed', () => { + const from = root + + it('adds a new allowed sender', async () => { + await relayer.allowSender(someone, {from}) + + assert(await relayer.isSenderAllowed(someone), 'sender should be allowed') + }) + + it('emits an event', async () => { + const receipt = await relayer.allowSender(someone, {from}) + + assertAmountOfEvents(receipt, 'SenderAllowed') + assertEvent(receipt, 'SenderAllowed', { sender: someone }) + }) + }) + + context('when the sender is not allowed', () => { + const from = someone + + it('reverts', async () => { + await assertRevert(relayer.allowSender(someone, {from}), 'APP_AUTH_FAILED') + }) + }) + }) + + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.allowSender(someone, { from: root }), 'APP_AUTH_FAILED') + }) + }) + }) + + describe('disallowSender', () => { + context('when the app is initialized', () => { + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + + context('when the sender is allowed', () => { + const from = root + + it('adds a new allowed sender', async () => { + await relayer.disallowSender(someone, { from }) + + assert.isFalse(await relayer.isSenderAllowed(someone), 'sender should not be allowed') + }) + + it('emits an event', async () => { + const receipt = await relayer.disallowSender(someone, { from }) + + assertAmountOfEvents(receipt, 'SenderDisallowed') + assertEvent(receipt, 'SenderDisallowed', { sender: someone }) + }) + }) + + context('when the sender is not allowed', () => { + const from = someone + + it('reverts', async () => { + await assertRevert(relayer.disallowSender(someone, { from }), 'APP_AUTH_FAILED') + }) + }) + }) + + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.disallowSender(someone, { from: root }), 'APP_AUTH_FAILED') + }) + }) + }) + + describe('setMonthlyRefundQuota', () => { + const newQuota = 1000 + + context('when the app is initialized', () => { + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + + context('when the sender is allowed', () => { + const from = root + + it('changes the monthly refunds quota', async () => { + await relayer.setMonthlyRefundQuota(newQuota, { from }) + + assert.equal((await relayer.getMonthlyRefundQuota()).toString(), newQuota, 'monthly refunds quota does not match') + }) + + it('emits an event', async () => { + const receipt = await relayer.setMonthlyRefundQuota(newQuota, { from }) + + assertAmountOfEvents(receipt, 'MonthlyRefundQuotaSet') + assertEvent(receipt, 'MonthlyRefundQuotaSet', { who: from, previousQuota: MONTHLY_REFUND_QUOTA, newQuota }) + }) + }) + + context('when the sender is not allowed', () => { + const from = someone + + it('reverts', async () => { + await assertRevert(relayer.setMonthlyRefundQuota(newQuota, { from }), 'APP_AUTH_FAILED') + }) + }) + }) + + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.setMonthlyRefundQuota(newQuota, { from: root }), 'APP_AUTH_FAILED') + }) + }) + }) + } +}) diff --git a/test/contracts/relayer/relayer_calldata.js b/test/contracts/relayer/relayer_calldata.js new file mode 100644 index 000000000..bedf550c5 --- /dev/null +++ b/test/contracts/relayer/relayer_calldata.js @@ -0,0 +1,3 @@ +const runSolidityTest = require('../../helpers/runSolidityTest') + +runSolidityTest('TestRelayerCalldata') diff --git a/test/helpers/coverage.js b/test/helpers/coverage.js index f5f313242..a936f9140 100644 --- a/test/helpers/coverage.js +++ b/test/helpers/coverage.js @@ -10,5 +10,5 @@ const skipCoverage = test => { } module.exports = { - skipCoverage + skipCoverage, }