From 061b437e6df502787e227fab2b41a7e00445088b Mon Sep 17 00:00:00 2001 From: Facundo Spagnuolo Date: Thu, 9 May 2019 12:41:47 -0300 Subject: [PATCH 01/19] meta-txs: measure gas ovearload for different alternatives --- contracts/apps/AppStorage.sol | 23 ++++++ contracts/apps/AragonApp.sol | 10 ++- contracts/lib/sig/ECDSA.sol | 69 ++++++++++++++++ contracts/relayer/BaseRelayer.sol | 61 ++++++++++++++ ...elayedAragonAppWithParameterizedSender.sol | 24 ++++++ .../RelayedAragonAppWithVolatileSender.sol | 29 +++++++ ...elayerAragonAppWithParameterizedSender.sol | 29 +++++++ .../RelayerAragonAppWithVolatileSender.sol | 29 +++++++ contracts/relayer/StandAloneRelayer.sol | 21 +++++ contracts/test/mocks/lib/sig/ECDSAMock.sol | 12 +++ ...edAragonAppWithParameterizedSenderMock.sol | 31 +++++++ ...RelayedAragonAppWithVolatileSenderMock.sol | 23 ++++++ ...erAragonAppWithParameterizedSenderMock.sol | 31 +++++++ ...RelayerAragonAppWithVolatileSenderMock.sol | 23 ++++++ test/contracts/lib/sig/ecsda.js | 26 ++++++ .../relayer/parameterized_relayed_app.js | 81 ++++++++++++++++++ .../relayer/parameterized_relayer_app.js | 70 ++++++++++++++++ .../contracts/relayer/volatile_relayed_app.js | 82 +++++++++++++++++++ .../contracts/relayer/volatile_relayer_app.js | 70 ++++++++++++++++ 19 files changed, 741 insertions(+), 3 deletions(-) create mode 100644 contracts/lib/sig/ECDSA.sol create mode 100644 contracts/relayer/BaseRelayer.sol create mode 100644 contracts/relayer/RelayedAragonAppWithParameterizedSender.sol create mode 100644 contracts/relayer/RelayedAragonAppWithVolatileSender.sol create mode 100644 contracts/relayer/RelayerAragonAppWithParameterizedSender.sol create mode 100644 contracts/relayer/RelayerAragonAppWithVolatileSender.sol create mode 100644 contracts/relayer/StandAloneRelayer.sol create mode 100644 contracts/test/mocks/lib/sig/ECDSAMock.sol create mode 100644 contracts/test/mocks/relayer/RelayedAragonAppWithParameterizedSenderMock.sol create mode 100644 contracts/test/mocks/relayer/RelayedAragonAppWithVolatileSenderMock.sol create mode 100644 contracts/test/mocks/relayer/RelayerAragonAppWithParameterizedSenderMock.sol create mode 100644 contracts/test/mocks/relayer/RelayerAragonAppWithVolatileSenderMock.sol create mode 100644 test/contracts/lib/sig/ecsda.js create mode 100644 test/contracts/relayer/parameterized_relayed_app.js create mode 100644 test/contracts/relayer/parameterized_relayer_app.js create mode 100644 test/contracts/relayer/volatile_relayed_app.js create mode 100644 test/contracts/relayer/volatile_relayer_app.js diff --git a/contracts/apps/AppStorage.sol b/contracts/apps/AppStorage.sol index b37dd363d..1a7c060b8 100644 --- a/contracts/apps/AppStorage.sol +++ b/contracts/apps/AppStorage.sol @@ -18,6 +18,9 @@ contract AppStorage { bytes32 internal constant KERNEL_POSITION = 0x4172f0f7d2289153072b0a6ca36959e0cbe2efc3afe50fc81636caa96338137b; bytes32 internal constant APP_ID_POSITION = 0xd625496217aa6a3453eecb9c3489dc5a53e6c67b444329ea2b2cbc9ff547639b; + bytes32 internal constant USED_NONCE_POSITION_BASE = keccak256("aragonOS.appStorage.usedNonce"); + bytes32 internal constant VOLATILE_SENDER_POSITION = keccak256("aragonOS.appStorage.volatile.sender"); + function kernel() public view returns (IKernel) { return IKernel(KERNEL_POSITION.getStorageAddress()); } @@ -26,6 +29,14 @@ contract AppStorage { return APP_ID_POSITION.getStorageBytes32(); } + function volatileStorageSender() public view returns (address) { + return VOLATILE_SENDER_POSITION.getStorageAddress(); + } + + function usedNonce(address _account, uint256 _nonce) public view returns (bool) { + return usedNoncePosition(_account, _nonce).getStorageBool(); + } + function setKernel(IKernel _kernel) internal { KERNEL_POSITION.setStorageAddress(address(_kernel)); } @@ -33,4 +44,16 @@ contract AppStorage { function setAppId(bytes32 _appId) internal { APP_ID_POSITION.setStorageBytes32(_appId); } + + function setVolatileStorageSender(address _sender) internal { + VOLATILE_SENDER_POSITION.setStorageAddress(_sender); + } + + function setUsedNonce(address _account, uint256 _nonce, bool _used) internal { + return usedNoncePosition(_account, _nonce).setStorageBool(_used); + } + + function usedNoncePosition(address _account, uint256 _nonce) internal returns (bytes32) { + return keccak256(abi.encodePacked(USED_NONCE_POSITION_BASE, _account, _nonce)); + } } diff --git a/contracts/apps/AragonApp.sol b/contracts/apps/AragonApp.sol index f53c40721..38c1e13e3 100644 --- a/contracts/apps/AragonApp.sol +++ b/contracts/apps/AragonApp.sol @@ -19,15 +19,15 @@ import "../evmscript/EVMScriptRunner.sol"; // ReentrancyGuard, EVMScriptRunner, and ACLSyntaxSugar are not directly used by this contract, but // are included so that they are automatically usable by subclassing contracts contract AragonApp is AppStorage, Autopetrified, VaultRecoverable, ReentrancyGuard, EVMScriptRunner, ACLSyntaxSugar { - string private constant ERROR_AUTH_FAILED = "APP_AUTH_FAILED"; + string internal 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/lib/sig/ECDSA.sol b/contracts/lib/sig/ECDSA.sol new file mode 100644 index 000000000..932c514d5 --- /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/BaseRelayer.sol b/contracts/relayer/BaseRelayer.sol new file mode 100644 index 000000000..86ed50c87 --- /dev/null +++ b/contracts/relayer/BaseRelayer.sol @@ -0,0 +1,61 @@ +pragma solidity ^0.4.24; + +import "../lib/sig/ECDSA.sol"; +import "../apps/AragonApp.sol"; +import "../common/DepositableStorage.sol"; + + +contract BaseRelayer is AragonApp, DepositableStorage { + using ECDSA for bytes32; + + bytes32 public constant OFF_CHAIN_RELAYER_SERVICE_ROLE = keccak256("OFF_CHAIN_RELAYER_SERVICE_ROLE"); + + uint256 private constant EXTERNAL_TX_COST = 21000; + + string private constant ERROR_GAS_REFUND_FAIL = "RELAYER_GAS_REFUND_FAIL"; + string private constant ERROR_NONCE_ALREADY_USED = "RELAYER_NONCE_ALREADY_USED"; + string private constant ERROR_INVALID_SENDER_SIGNATURE = "RELAYER_INVALID_SENDER_SIGNATURE"; + + event FundsReceived(address indexed sender, uint256 amount); + event TransactionRelayed(address indexed from, address indexed to, uint256 nonce, bytes calldata); + + modifier refundGas() { + uint256 startGas = gasleft(); + _; + uint256 refund = EXTERNAL_TX_COST + startGas - gasleft(); + require(msg.sender.send(refund), ERROR_GAS_REFUND_FAIL); + } + + function () external payable { + emit FundsReceived(msg.sender, msg.value); + } + + function initialize() public onlyInit { + initialized(); + setDepositable(true); + } + + function isNonceUsed(address sender, uint256 nonce) public view returns (bool); + + function assertValidTransaction(address from, uint256 nonce, bytes calldata, bytes signature) internal view { + require(!isNonceUsed(from, nonce), ERROR_NONCE_ALREADY_USED); + require(isValidSignature(from, messageHash(calldata, nonce), signature), ERROR_INVALID_SENDER_SIGNATURE); + } + + function isValidSignature(address sender, bytes32 hash, bytes signature) internal pure returns (bool) { + address signer = hash.toEthSignedMessageHash().recover(signature); + return sender == signer; + } + + function messageHash(bytes calldata, uint256 nonce) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(keccak256(calldata), nonce)); + } + + function revertForwardingError() internal { + assembly { + let ptr := mload(0x40) + returndatacopy(ptr, 0, returndatasize) + revert(ptr, returndatasize) + } + } +} diff --git a/contracts/relayer/RelayedAragonAppWithParameterizedSender.sol b/contracts/relayer/RelayedAragonAppWithParameterizedSender.sol new file mode 100644 index 000000000..1c2b3d932 --- /dev/null +++ b/contracts/relayer/RelayedAragonAppWithParameterizedSender.sol @@ -0,0 +1,24 @@ +pragma solidity ^0.4.24; + +import "../apps/AragonApp.sol"; + + +contract RelayedAragonAppWithParameterizedSender is AragonApp { + bytes32 public constant RELAYER_ROLE = keccak256("RELAYER_ROLE"); + + modifier relayedAuth(address _sender, bytes32 _role) { + assertRelayer(); + require(canPerform(_sender, _role, new uint256[](0)), ERROR_AUTH_FAILED); + _; + } + + modifier relayedAuthP(address _sender, bytes32 _role, uint256[] _params) { + assertRelayer(); + require(canPerform(_sender, _role, _params), ERROR_AUTH_FAILED); + _; + } + + function assertRelayer() private { + require(canPerform(msg.sender, RELAYER_ROLE, new uint256[](0)), ERROR_AUTH_FAILED); + } +} diff --git a/contracts/relayer/RelayedAragonAppWithVolatileSender.sol b/contracts/relayer/RelayedAragonAppWithVolatileSender.sol new file mode 100644 index 000000000..1f7cb63b6 --- /dev/null +++ b/contracts/relayer/RelayedAragonAppWithVolatileSender.sol @@ -0,0 +1,29 @@ +pragma solidity ^0.4.24; + +import "../apps/AragonApp.sol"; + + +contract RelayedAragonAppWithVolatileSender is AragonApp { + bytes32 public constant RELAYER_ROLE = keccak256("RELAYER_ROLE"); + + function exec(address from, bytes calldata) external auth(RELAYER_ROLE) { + setVolatileStorageSender(from); + bool success = address(this).call(calldata); + if (!success) revertForwardingError(); + setVolatileStorageSender(address(0)); + } + + function sender() internal view returns (address) { + if (msg.sender != address(this)) return msg.sender; + address volatileSender = volatileStorageSender(); + return volatileSender != address(0) ? volatileSender : address(this); + } + + function revertForwardingError() internal { + assembly { + let ptr := mload(0x40) + returndatacopy(ptr, 0, returndatasize) + revert(ptr, returndatasize) + } + } +} diff --git a/contracts/relayer/RelayerAragonAppWithParameterizedSender.sol b/contracts/relayer/RelayerAragonAppWithParameterizedSender.sol new file mode 100644 index 000000000..cf450863a --- /dev/null +++ b/contracts/relayer/RelayerAragonAppWithParameterizedSender.sol @@ -0,0 +1,29 @@ +pragma solidity ^0.4.24; + +import "./BaseRelayer.sol"; + + +contract RelayerAragonAppWithParameterizedSender is BaseRelayer { + modifier relayedAuth(address _sender, bytes32 _role) { + require(canPerform(_sender, _role, new uint256[](0)), ERROR_AUTH_FAILED); + _; + } + + modifier relayedAuthP(address _sender, bytes32 _role, uint256[] _params) { + require(canPerform(_sender, _role, _params), ERROR_AUTH_FAILED); + _; + } + + function exec(address from, uint256 nonce, bytes calldata, bytes signature) external refundGas auth(OFF_CHAIN_RELAYER_SERVICE_ROLE) { + assertValidTransaction(from, nonce, calldata, signature); + + setUsedNonce(from, nonce, true); + bool success = address(this).call(calldata); + if (!success) revertForwardingError(); + emit TransactionRelayed(from, address(this), nonce, calldata); + } + + function isNonceUsed(address _account, uint256 _nonce) public view returns (bool) { + return usedNonce(_account, _nonce); + } +} diff --git a/contracts/relayer/RelayerAragonAppWithVolatileSender.sol b/contracts/relayer/RelayerAragonAppWithVolatileSender.sol new file mode 100644 index 000000000..6fac09731 --- /dev/null +++ b/contracts/relayer/RelayerAragonAppWithVolatileSender.sol @@ -0,0 +1,29 @@ +pragma solidity ^0.4.24; + +import "./BaseRelayer.sol"; + + +contract RelayerAragonAppWithVolatileSender is BaseRelayer { + function exec(address from, uint256 nonce, bytes calldata, bytes signature) external refundGas auth(OFF_CHAIN_RELAYER_SERVICE_ROLE) { + assertValidTransaction(from, nonce, calldata, signature); + + setVolatileStorageSender(from); + setUsedNonce(from, nonce, true); + + bool success = address(this).call(calldata); + if (!success) revertForwardingError(); + + setVolatileStorageSender(address(0)); + emit TransactionRelayed(from, address(this), nonce, calldata); + } + + function isNonceUsed(address _account, uint256 _nonce) public view returns (bool) { + return usedNonce(_account, _nonce); + } + + function sender() internal view returns (address) { + if (msg.sender != address(this)) return msg.sender; + address volatileSender = volatileStorageSender(); + return volatileSender != address(0) ? volatileSender : address(this); + } +} diff --git a/contracts/relayer/StandAloneRelayer.sol b/contracts/relayer/StandAloneRelayer.sol new file mode 100644 index 000000000..2ce9d0672 --- /dev/null +++ b/contracts/relayer/StandAloneRelayer.sol @@ -0,0 +1,21 @@ +pragma solidity ^0.4.24; + +import "./BaseRelayer.sol"; + + +contract StandAloneRelayer is BaseRelayer { + mapping (address => mapping (uint256 => bool)) internal usedNonces; + + function relay(address from, address to, uint256 nonce, bytes calldata, bytes signature) external refundGas auth(OFF_CHAIN_RELAYER_SERVICE_ROLE) { + assertValidTransaction(from, nonce, calldata, signature); + + usedNonces[from][nonce] = true; + bool success = to.call(calldata); + if (!success) revertForwardingError(); + emit TransactionRelayed(from, to, nonce, calldata); + } + + function isNonceUsed(address sender, uint256 nonce) public view returns (bool) { + return usedNonces[sender][nonce]; + } +} 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/RelayedAragonAppWithParameterizedSenderMock.sol b/contracts/test/mocks/relayer/RelayedAragonAppWithParameterizedSenderMock.sol new file mode 100644 index 000000000..804f71336 --- /dev/null +++ b/contracts/test/mocks/relayer/RelayedAragonAppWithParameterizedSenderMock.sol @@ -0,0 +1,31 @@ +pragma solidity 0.4.24; + +import "../../../relayer/RelayedAragonAppWithParameterizedSender.sol"; + + +contract RelayedAragonAppWithParameterizedSenderMock is RelayedAragonAppWithParameterizedSender { + 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)) { + _write(_x); + } + + function relayedWrite(address _sender, uint256 _x) public relayedAuthP(_sender, WRITING_ROLE, arr(_x)) { + _write(_x); + } + + function _write(uint256 _x) internal { + x = _x; + } +} diff --git a/contracts/test/mocks/relayer/RelayedAragonAppWithVolatileSenderMock.sol b/contracts/test/mocks/relayer/RelayedAragonAppWithVolatileSenderMock.sol new file mode 100644 index 000000000..a9edb1424 --- /dev/null +++ b/contracts/test/mocks/relayer/RelayedAragonAppWithVolatileSenderMock.sol @@ -0,0 +1,23 @@ +pragma solidity 0.4.24; + +import "../../../relayer/RelayedAragonAppWithVolatileSender.sol"; + + +contract RelayedAragonAppWithVolatileSenderMock is RelayedAragonAppWithVolatileSender { + 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/RelayerAragonAppWithParameterizedSenderMock.sol b/contracts/test/mocks/relayer/RelayerAragonAppWithParameterizedSenderMock.sol new file mode 100644 index 000000000..65ebf2feb --- /dev/null +++ b/contracts/test/mocks/relayer/RelayerAragonAppWithParameterizedSenderMock.sol @@ -0,0 +1,31 @@ +pragma solidity 0.4.24; + +import "../../../relayer/RelayerAragonAppWithParameterizedSender.sol"; + + +contract RelayerAragonAppWithParameterizedSenderMock is RelayerAragonAppWithParameterizedSender { + bytes32 public constant WRITING_ROLE = keccak256("WRITING_ROLE"); + + uint256 private x; + + function initialize() public onlyInit { + super.initialize(); + x = 42; + } + + function read() public view returns (uint256) { + return x; + } + + function write(uint256 _x) public authP(WRITING_ROLE, arr(_x)) { + _write(_x); + } + + function relayedWrite(address _sender, uint256 _x) public relayedAuthP(_sender, WRITING_ROLE, arr(_x)) { + _write(_x); + } + + function _write(uint256 _x) internal { + x = _x; + } +} diff --git a/contracts/test/mocks/relayer/RelayerAragonAppWithVolatileSenderMock.sol b/contracts/test/mocks/relayer/RelayerAragonAppWithVolatileSenderMock.sol new file mode 100644 index 000000000..6522b66ef --- /dev/null +++ b/contracts/test/mocks/relayer/RelayerAragonAppWithVolatileSenderMock.sol @@ -0,0 +1,23 @@ +pragma solidity 0.4.24; + +import "../../../relayer/RelayerAragonAppWithVolatileSender.sol"; + + +contract RelayerAragonAppWithVolatileSenderMock is RelayerAragonAppWithVolatileSender { + bytes32 public constant WRITING_ROLE = keccak256("WRITING_ROLE"); + + uint256 private x; + + function initialize() public onlyInit { + super.initialize(); + 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/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/parameterized_relayed_app.js b/test/contracts/relayer/parameterized_relayed_app.js new file mode 100644 index 000000000..6320896f0 --- /dev/null +++ b/test/contracts/relayer/parameterized_relayed_app.js @@ -0,0 +1,81 @@ +const { sha3, soliditySha3 } = require('web3-utils') + +const ACL = artifacts.require('ACL') +const Kernel = artifacts.require('Kernel') +const Relayer = artifacts.require('StandAloneRelayer') +const DAOFactory = artifacts.require('DAOFactory') +const SampleApp = artifacts.require('RelayedAragonAppWithParameterizedSenderMock') + +const getEventArgument = (receipt, event, arg) => receipt.logs.filter(l => l.event === event)[0].args[arg] + +contract('ParameterizedRelayedApp', ([_, root, sender, vault, offChainRelayerService]) => { + let daoFactory, dao, acl, app, relayer, relayedTx, nonce = 0 + let kernelBase, aclBase, sampleAppBase, relayerBase + let WRITING_ROLE, APP_MANAGER_ROLE, RELAYER_ROLE, OFF_CHAIN_RELAYER_SERVICE_ROLE + + 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 roles', async () => { + WRITING_ROLE = await sampleAppBase.WRITING_ROLE() + RELAYER_ROLE = await sampleAppBase.RELAYER_ROLE() + APP_MANAGER_ROLE = await kernelBase.APP_MANAGER_ROLE() + OFF_CHAIN_RELAYER_SERVICE_ROLE = await relayerBase.OFF_CHAIN_RELAYER_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 relayer instance', async () => { + const receipt = await dao.newAppInstance('0x11111', relayerBase.address, '0x', false, { from: root }) + relayer = Relayer.at(getEventArgument(receipt, 'NewAppProxy', 'proxy')) + await relayer.initialize() + + await web3.eth.sendTransaction({ from: vault, to: relayer.address, value: 10e18 }) + await acl.createPermission(offChainRelayerService, relayer.address, OFF_CHAIN_RELAYER_SERVICE_ROLE, root, { from: root }) + }) + + beforeEach('create sample app instance', async () => { + const receipt = await dao.newAppInstance('0x22222', sampleAppBase.address, '0x', false, { from: root }) + app = SampleApp.at(getEventArgument(receipt, 'NewAppProxy', 'proxy')) + await app.initialize() + + await acl.createPermission(sender, app.address, WRITING_ROLE, root, { from: root }) + await acl.createPermission(relayer.address, app.address, RELAYER_ROLE, root, { from: root }) + }) + + beforeEach('relay transaction', async () => { + const calldata = app.contract.relayedWrite.getData(sender, 10) + const messageHash = soliditySha3(sha3(calldata), nonce) + const signature = web3.eth.sign(sender, messageHash) + + relayedTx = await relayer.relay(sender, app.address, nonce, calldata, signature, { from: offChainRelayerService }) + nonce++ + }) + + it('relays transactions to app', async () => { + assert.equal((await app.read()).toString(), 10, 'app value does not match') + }) + + it('overloads a transaction with ~94k of gas', async () => { + const { receipt: { cumulativeGasUsed: relayedGasUsed } } = relayedTx + const { receipt: { cumulativeGasUsed: nonRelayerGasUsed } } = await app.write(10, { from: sender }) + + const gasOverload = relayedGasUsed - nonRelayerGasUsed + console.log('relayedGasUsed:', relayedGasUsed) + console.log('nonRelayerGasUsed:', nonRelayerGasUsed) + console.log('gasOverload:', gasOverload) + + assert.isBelow(gasOverload, 94000, 'relayed txs gas overload is higher than 94k') + }) +}) diff --git a/test/contracts/relayer/parameterized_relayer_app.js b/test/contracts/relayer/parameterized_relayer_app.js new file mode 100644 index 000000000..920945741 --- /dev/null +++ b/test/contracts/relayer/parameterized_relayer_app.js @@ -0,0 +1,70 @@ +const { sha3, soliditySha3 } = require('web3-utils') + +const ACL = artifacts.require('ACL') +const Kernel = artifacts.require('Kernel') +const DAOFactory = artifacts.require('DAOFactory') +const SampleApp = artifacts.require('RelayerAragonAppWithParameterizedSenderMock') + +const getEventArgument = (receipt, event, arg) => receipt.logs.filter(l => l.event === event)[0].args[arg] + +contract('ParameterizedRelayerApp', ([_, root, sender, vault, offChainRelayerService]) => { + let daoFactory, dao, acl, app, relayedTx, nonce = 0 + let kernelBase, aclBase, sampleAppBase + let WRITING_ROLE, APP_MANAGER_ROLE, OFF_CHAIN_RELAYER_SERVICE_ROLE + + before('deploy base implementations', async () => { + aclBase = await ACL.new() + kernelBase = await Kernel.new(true) // petrify immediately + sampleAppBase = await SampleApp.new() + daoFactory = await DAOFactory.new(kernelBase.address, aclBase.address, '0x0') + }) + + before('load roles', async () => { + WRITING_ROLE = await sampleAppBase.WRITING_ROLE() + APP_MANAGER_ROLE = await kernelBase.APP_MANAGER_ROLE() + OFF_CHAIN_RELAYER_SERVICE_ROLE = await sampleAppBase.OFF_CHAIN_RELAYER_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 }) + }) + + beforeEach('create sample app instance', async () => { + const receipt = await dao.newAppInstance('0x22222', sampleAppBase.address, '0x', false, { from: root }) + app = SampleApp.at(getEventArgument(receipt, 'NewAppProxy', 'proxy')) + await app.initialize() + + await web3.eth.sendTransaction({ from: vault, to: app.address, value: 10e18 }) + await acl.createPermission(sender, app.address, WRITING_ROLE, root, { from: root }) + await acl.createPermission(offChainRelayerService, app.address, OFF_CHAIN_RELAYER_SERVICE_ROLE, root, { from: root }) + }) + + beforeEach('relay transaction', async () => { + const calldata = app.contract.relayedWrite.getData(sender, 10) + const messageHash = soliditySha3(sha3(calldata), nonce) + const signature = web3.eth.sign(sender, messageHash) + + relayedTx = await app.exec(sender, nonce, calldata, signature, { from: offChainRelayerService }) + nonce++ + }) + + it('relays transactions to app', async () => { + assert.equal((await app.read()).toString(), 10, 'app value does not match') + }) + + it('overloads a transaction with ~75k of gas', async () => { + const { receipt: { cumulativeGasUsed: relayedGasUsed } } = relayedTx + const { receipt: { cumulativeGasUsed: nonRelayerGasUsed } } = await app.write(10, { from: sender }) + + const gasOverload = relayedGasUsed - nonRelayerGasUsed + console.log('relayedGasUsed:', relayedGasUsed) + console.log('nonRelayerGasUsed:', nonRelayerGasUsed) + console.log('gasOverload:', gasOverload) + + assert.isBelow(gasOverload, 75000, 'relayed txs gas overload is higher than 75k') + }) +}) diff --git a/test/contracts/relayer/volatile_relayed_app.js b/test/contracts/relayer/volatile_relayed_app.js new file mode 100644 index 000000000..2d66b3120 --- /dev/null +++ b/test/contracts/relayer/volatile_relayed_app.js @@ -0,0 +1,82 @@ +const { sha3, soliditySha3 } = require('web3-utils') + +const ACL = artifacts.require('ACL') +const Kernel = artifacts.require('Kernel') +const Relayer = artifacts.require('StandAloneRelayer') +const DAOFactory = artifacts.require('DAOFactory') +const SampleApp = artifacts.require('RelayedAragonAppWithVolatileSenderMock') + +const getEventArgument = (receipt, event, arg) => receipt.logs.filter(l => l.event === event)[0].args[arg] + +contract('VolatileRelayedApp', ([_, root, sender, vault, offChainRelayerService]) => { + let daoFactory, dao, acl, app, relayer, relayedTx, nonce = 0 + let kernelBase, aclBase, sampleAppBase, relayerBase + let WRITING_ROLE, APP_MANAGER_ROLE, RELAYER_ROLE, OFF_CHAIN_RELAYER_SERVICE_ROLE + + 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 roles', async () => { + WRITING_ROLE = await sampleAppBase.WRITING_ROLE() + RELAYER_ROLE = await sampleAppBase.RELAYER_ROLE() + APP_MANAGER_ROLE = await kernelBase.APP_MANAGER_ROLE() + OFF_CHAIN_RELAYER_SERVICE_ROLE = await relayerBase.OFF_CHAIN_RELAYER_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 relayer instance', async () => { + const receipt = await dao.newAppInstance('0x11111', relayerBase.address, '0x', false, { from: root }) + relayer = Relayer.at(getEventArgument(receipt, 'NewAppProxy', 'proxy')) + await relayer.initialize() + + await web3.eth.sendTransaction({ from: vault, to: relayer.address, value: 10e18 }) + await acl.createPermission(offChainRelayerService, relayer.address, OFF_CHAIN_RELAYER_SERVICE_ROLE, root, { from: root }) + }) + + beforeEach('create sample app instance', async () => { + const receipt = await dao.newAppInstance('0x22222', sampleAppBase.address, '0x', false, { from: root }) + app = SampleApp.at(getEventArgument(receipt, 'NewAppProxy', 'proxy')) + await app.initialize() + + await acl.createPermission(sender, app.address, WRITING_ROLE, root, { from: root }) + await acl.createPermission(relayer.address, app.address, RELAYER_ROLE, root, { from: root }) + }) + + beforeEach('relay transaction', async () => { + const mainCalldata = app.contract.write.getData(10) + const execCalldata = app.contract.exec.getData(sender, mainCalldata) + const messageHash = soliditySha3(sha3(execCalldata), nonce) + const signature = web3.eth.sign(sender, messageHash) + + relayedTx = await relayer.relay(sender, app.address, nonce, execCalldata, signature, { from: offChainRelayerService }) + nonce++ + }) + + it('relays transactions to app', async () => { + assert.equal((await app.read()).toString(), 10, 'app value does not match') + }) + + it('overloads a transaction with ~115k of gas', async () => { + const { receipt: { cumulativeGasUsed: relayedGasUsed } } = relayedTx + const { receipt: { cumulativeGasUsed: nonRelayerGasUsed } } = await app.write(10, { from: sender }) + + const gasOverload = relayedGasUsed - nonRelayerGasUsed + console.log('relayedGasUsed:', relayedGasUsed) + console.log('nonRelayerGasUsed:', nonRelayerGasUsed) + console.log('gasOverload:', gasOverload) + + assert.isBelow(gasOverload, 115000, 'relayed txs gas overload is higher than 115k') + }) +}) diff --git a/test/contracts/relayer/volatile_relayer_app.js b/test/contracts/relayer/volatile_relayer_app.js new file mode 100644 index 000000000..fb6f34cac --- /dev/null +++ b/test/contracts/relayer/volatile_relayer_app.js @@ -0,0 +1,70 @@ +const { sha3, soliditySha3 } = require('web3-utils') + +const ACL = artifacts.require('ACL') +const Kernel = artifacts.require('Kernel') +const DAOFactory = artifacts.require('DAOFactory') +const SampleApp = artifacts.require('RelayerAragonAppWithVolatileSenderMock') + +const getEventArgument = (receipt, event, arg) => receipt.logs.filter(l => l.event === event)[0].args[arg] + +contract('VolatileRelayerApp', ([_, root, sender, vault, offChainRelayerService]) => { + let daoFactory, dao, acl, app, relayedTx, nonce = 0 + let kernelBase, aclBase, sampleAppBase + let WRITING_ROLE, APP_MANAGER_ROLE, OFF_CHAIN_RELAYER_SERVICE_ROLE + + before('deploy base implementations', async () => { + aclBase = await ACL.new() + kernelBase = await Kernel.new(true) // petrify immediately + sampleAppBase = await SampleApp.new() + daoFactory = await DAOFactory.new(kernelBase.address, aclBase.address, '0x0') + }) + + before('load roles', async () => { + WRITING_ROLE = await sampleAppBase.WRITING_ROLE() + APP_MANAGER_ROLE = await kernelBase.APP_MANAGER_ROLE() + OFF_CHAIN_RELAYER_SERVICE_ROLE = await sampleAppBase.OFF_CHAIN_RELAYER_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 }) + }) + + beforeEach('create sample app instance', async () => { + const receipt = await dao.newAppInstance('0x22222', sampleAppBase.address, '0x', false, { from: root }) + app = SampleApp.at(getEventArgument(receipt, 'NewAppProxy', 'proxy')) + await app.initialize() + + await web3.eth.sendTransaction({ from: vault, to: app.address, value: 10e18 }) + await acl.createPermission(sender, app.address, WRITING_ROLE, root, { from: root }) + await acl.createPermission(offChainRelayerService, app.address, OFF_CHAIN_RELAYER_SERVICE_ROLE, root, { from: root }) + }) + + beforeEach('relay transaction', async () => { + const calldata = app.contract.write.getData(10) + const messageHash = soliditySha3(sha3(calldata), nonce) + const signature = web3.eth.sign(sender, messageHash) + + relayedTx = await app.exec(sender, nonce, calldata, signature, { from: offChainRelayerService }) + nonce++ + }) + + it('relays transactions to app', async () => { + assert.equal((await app.read()).toString(), 10, 'app value does not match') + }) + + it('overloads a transaction with ~84k of gas', async () => { + const { receipt: { cumulativeGasUsed: relayedGasUsed } } = relayedTx + const { receipt: { cumulativeGasUsed: nonRelayerGasUsed } } = await app.write(10, { from: sender }) + + const gasOverload = relayedGasUsed - nonRelayerGasUsed + console.log('relayedGasUsed:', relayedGasUsed) + console.log('nonRelayerGasUsed:', nonRelayerGasUsed) + console.log('gasOverload:', gasOverload) + + assert.isBelow(gasOverload, 84000, 'relayed txs gas overload is higher than 84k') + }) +}) From db8c7ff78275a7a603b6ae40e766fe351072d25a Mon Sep 17 00:00:00 2001 From: Facundo Spagnuolo Date: Thu, 9 May 2019 19:58:48 -0300 Subject: [PATCH 02/19] meta-txs: optimize nonce tracking costs --- contracts/apps/AppStorage.sol | 26 ++++++++++--------- ...elayerAragonAppWithParameterizedSender.sol | 4 +-- .../RelayerAragonAppWithVolatileSender.sol | 4 +-- contracts/relayer/StandAloneRelayer.sol | 6 ++--- .../relayer/parameterized_relayed_app.js | 6 ++--- .../relayer/parameterized_relayer_app.js | 2 +- .../contracts/relayer/volatile_relayed_app.js | 6 ++--- .../contracts/relayer/volatile_relayer_app.js | 6 ++--- 8 files changed, 31 insertions(+), 29 deletions(-) diff --git a/contracts/apps/AppStorage.sol b/contracts/apps/AppStorage.sol index 1a7c060b8..c1c6c9c7e 100644 --- a/contracts/apps/AppStorage.sol +++ b/contracts/apps/AppStorage.sol @@ -11,15 +11,17 @@ 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 LAST_NONCE_POSITION_BASE = keccak256("aragonOS.appStorage.lastNonce"); + * bytes32 internal constant VOLATILE_SENDER_POSITION = keccak256("aragonOS.appStorage.volatile.sender"); */ bytes32 internal constant KERNEL_POSITION = 0x4172f0f7d2289153072b0a6ca36959e0cbe2efc3afe50fc81636caa96338137b; bytes32 internal constant APP_ID_POSITION = 0xd625496217aa6a3453eecb9c3489dc5a53e6c67b444329ea2b2cbc9ff547639b; - - bytes32 internal constant USED_NONCE_POSITION_BASE = keccak256("aragonOS.appStorage.usedNonce"); - bytes32 internal constant VOLATILE_SENDER_POSITION = keccak256("aragonOS.appStorage.volatile.sender"); + bytes32 internal constant LAST_NONCE_POSITION_BASE = 0x66c8c1e117f8d5835231a971a56ce0c7b70f9291340698a4263ada738d9269bd; + bytes32 internal constant VOLATILE_SENDER_POSITION = 0xd6486d5aa3dac4242db35dd7559408452252cf8050988dbc66956eaa315379ce; function kernel() public view returns (IKernel) { return IKernel(KERNEL_POSITION.getStorageAddress()); @@ -33,8 +35,8 @@ contract AppStorage { return VOLATILE_SENDER_POSITION.getStorageAddress(); } - function usedNonce(address _account, uint256 _nonce) public view returns (bool) { - return usedNoncePosition(_account, _nonce).getStorageBool(); + function lastNonce(address _account) public view returns (uint256) { + return lastNoncePosition(_account).getStorageUint256(); } function setKernel(IKernel _kernel) internal { @@ -49,11 +51,11 @@ contract AppStorage { VOLATILE_SENDER_POSITION.setStorageAddress(_sender); } - function setUsedNonce(address _account, uint256 _nonce, bool _used) internal { - return usedNoncePosition(_account, _nonce).setStorageBool(_used); + function setLastNonce(address _account, uint256 _lastNonce) internal { + return lastNoncePosition(_account).setStorageUint256(_lastNonce); } - function usedNoncePosition(address _account, uint256 _nonce) internal returns (bytes32) { - return keccak256(abi.encodePacked(USED_NONCE_POSITION_BASE, _account, _nonce)); + function lastNoncePosition(address _account) internal returns (bytes32) { + return keccak256(abi.encodePacked(LAST_NONCE_POSITION_BASE, _account)); } } diff --git a/contracts/relayer/RelayerAragonAppWithParameterizedSender.sol b/contracts/relayer/RelayerAragonAppWithParameterizedSender.sol index cf450863a..5a0243312 100644 --- a/contracts/relayer/RelayerAragonAppWithParameterizedSender.sol +++ b/contracts/relayer/RelayerAragonAppWithParameterizedSender.sol @@ -17,13 +17,13 @@ contract RelayerAragonAppWithParameterizedSender is BaseRelayer { function exec(address from, uint256 nonce, bytes calldata, bytes signature) external refundGas auth(OFF_CHAIN_RELAYER_SERVICE_ROLE) { assertValidTransaction(from, nonce, calldata, signature); - setUsedNonce(from, nonce, true); + setLastNonce(from, nonce); bool success = address(this).call(calldata); if (!success) revertForwardingError(); emit TransactionRelayed(from, address(this), nonce, calldata); } function isNonceUsed(address _account, uint256 _nonce) public view returns (bool) { - return usedNonce(_account, _nonce); + return lastNonce(_account) >= _nonce; } } diff --git a/contracts/relayer/RelayerAragonAppWithVolatileSender.sol b/contracts/relayer/RelayerAragonAppWithVolatileSender.sol index 6fac09731..b46fa0625 100644 --- a/contracts/relayer/RelayerAragonAppWithVolatileSender.sol +++ b/contracts/relayer/RelayerAragonAppWithVolatileSender.sol @@ -8,7 +8,7 @@ contract RelayerAragonAppWithVolatileSender is BaseRelayer { assertValidTransaction(from, nonce, calldata, signature); setVolatileStorageSender(from); - setUsedNonce(from, nonce, true); + setLastNonce(from, nonce); bool success = address(this).call(calldata); if (!success) revertForwardingError(); @@ -18,7 +18,7 @@ contract RelayerAragonAppWithVolatileSender is BaseRelayer { } function isNonceUsed(address _account, uint256 _nonce) public view returns (bool) { - return usedNonce(_account, _nonce); + return lastNonce(_account) >= _nonce; } function sender() internal view returns (address) { diff --git a/contracts/relayer/StandAloneRelayer.sol b/contracts/relayer/StandAloneRelayer.sol index 2ce9d0672..c14e7db72 100644 --- a/contracts/relayer/StandAloneRelayer.sol +++ b/contracts/relayer/StandAloneRelayer.sol @@ -4,18 +4,18 @@ import "./BaseRelayer.sol"; contract StandAloneRelayer is BaseRelayer { - mapping (address => mapping (uint256 => bool)) internal usedNonces; + mapping (address => uint256) internal lastUsedNonce; function relay(address from, address to, uint256 nonce, bytes calldata, bytes signature) external refundGas auth(OFF_CHAIN_RELAYER_SERVICE_ROLE) { assertValidTransaction(from, nonce, calldata, signature); - usedNonces[from][nonce] = true; + lastUsedNonce[from] = nonce; bool success = to.call(calldata); if (!success) revertForwardingError(); emit TransactionRelayed(from, to, nonce, calldata); } function isNonceUsed(address sender, uint256 nonce) public view returns (bool) { - return usedNonces[sender][nonce]; + return lastUsedNonce[sender] >= nonce; } } diff --git a/test/contracts/relayer/parameterized_relayed_app.js b/test/contracts/relayer/parameterized_relayed_app.js index 6320896f0..db18eda45 100644 --- a/test/contracts/relayer/parameterized_relayed_app.js +++ b/test/contracts/relayer/parameterized_relayed_app.js @@ -9,7 +9,7 @@ const SampleApp = artifacts.require('RelayedAragonAppWithParameterizedSenderMock const getEventArgument = (receipt, event, arg) => receipt.logs.filter(l => l.event === event)[0].args[arg] contract('ParameterizedRelayedApp', ([_, root, sender, vault, offChainRelayerService]) => { - let daoFactory, dao, acl, app, relayer, relayedTx, nonce = 0 + let daoFactory, dao, acl, app, relayer, relayedTx, nonce = 1 let kernelBase, aclBase, sampleAppBase, relayerBase let WRITING_ROLE, APP_MANAGER_ROLE, RELAYER_ROLE, OFF_CHAIN_RELAYER_SERVICE_ROLE @@ -67,7 +67,7 @@ contract('ParameterizedRelayedApp', ([_, root, sender, vault, offChainRelayerSer assert.equal((await app.read()).toString(), 10, 'app value does not match') }) - it('overloads a transaction with ~94k of gas', async () => { + it('overloads a transaction with ~78k of gas', async () => { const { receipt: { cumulativeGasUsed: relayedGasUsed } } = relayedTx const { receipt: { cumulativeGasUsed: nonRelayerGasUsed } } = await app.write(10, { from: sender }) @@ -76,6 +76,6 @@ contract('ParameterizedRelayedApp', ([_, root, sender, vault, offChainRelayerSer console.log('nonRelayerGasUsed:', nonRelayerGasUsed) console.log('gasOverload:', gasOverload) - assert.isBelow(gasOverload, 94000, 'relayed txs gas overload is higher than 94k') + assert.isBelow(gasOverload, 78000, 'relayed txs gas overload is higher than 78k') }) }) diff --git a/test/contracts/relayer/parameterized_relayer_app.js b/test/contracts/relayer/parameterized_relayer_app.js index 920945741..50cac8b38 100644 --- a/test/contracts/relayer/parameterized_relayer_app.js +++ b/test/contracts/relayer/parameterized_relayer_app.js @@ -8,7 +8,7 @@ const SampleApp = artifacts.require('RelayerAragonAppWithParameterizedSenderMock const getEventArgument = (receipt, event, arg) => receipt.logs.filter(l => l.event === event)[0].args[arg] contract('ParameterizedRelayerApp', ([_, root, sender, vault, offChainRelayerService]) => { - let daoFactory, dao, acl, app, relayedTx, nonce = 0 + let daoFactory, dao, acl, app, relayedTx, nonce = 1 let kernelBase, aclBase, sampleAppBase let WRITING_ROLE, APP_MANAGER_ROLE, OFF_CHAIN_RELAYER_SERVICE_ROLE diff --git a/test/contracts/relayer/volatile_relayed_app.js b/test/contracts/relayer/volatile_relayed_app.js index 2d66b3120..079e5c0ad 100644 --- a/test/contracts/relayer/volatile_relayed_app.js +++ b/test/contracts/relayer/volatile_relayed_app.js @@ -9,7 +9,7 @@ const SampleApp = artifacts.require('RelayedAragonAppWithVolatileSenderMock') const getEventArgument = (receipt, event, arg) => receipt.logs.filter(l => l.event === event)[0].args[arg] contract('VolatileRelayedApp', ([_, root, sender, vault, offChainRelayerService]) => { - let daoFactory, dao, acl, app, relayer, relayedTx, nonce = 0 + let daoFactory, dao, acl, app, relayer, relayedTx, nonce = 1 let kernelBase, aclBase, sampleAppBase, relayerBase let WRITING_ROLE, APP_MANAGER_ROLE, RELAYER_ROLE, OFF_CHAIN_RELAYER_SERVICE_ROLE @@ -68,7 +68,7 @@ contract('VolatileRelayedApp', ([_, root, sender, vault, offChainRelayerService] assert.equal((await app.read()).toString(), 10, 'app value does not match') }) - it('overloads a transaction with ~115k of gas', async () => { + it('overloads a transaction with ~99k of gas', async () => { const { receipt: { cumulativeGasUsed: relayedGasUsed } } = relayedTx const { receipt: { cumulativeGasUsed: nonRelayerGasUsed } } = await app.write(10, { from: sender }) @@ -77,6 +77,6 @@ contract('VolatileRelayedApp', ([_, root, sender, vault, offChainRelayerService] console.log('nonRelayerGasUsed:', nonRelayerGasUsed) console.log('gasOverload:', gasOverload) - assert.isBelow(gasOverload, 115000, 'relayed txs gas overload is higher than 115k') + assert.isBelow(gasOverload, 99000, 'relayed txs gas overload is higher than 99k') }) }) diff --git a/test/contracts/relayer/volatile_relayer_app.js b/test/contracts/relayer/volatile_relayer_app.js index fb6f34cac..3ab944bbb 100644 --- a/test/contracts/relayer/volatile_relayer_app.js +++ b/test/contracts/relayer/volatile_relayer_app.js @@ -8,7 +8,7 @@ const SampleApp = artifacts.require('RelayerAragonAppWithVolatileSenderMock') const getEventArgument = (receipt, event, arg) => receipt.logs.filter(l => l.event === event)[0].args[arg] contract('VolatileRelayerApp', ([_, root, sender, vault, offChainRelayerService]) => { - let daoFactory, dao, acl, app, relayedTx, nonce = 0 + let daoFactory, dao, acl, app, relayedTx, nonce = 1 let kernelBase, aclBase, sampleAppBase let WRITING_ROLE, APP_MANAGER_ROLE, OFF_CHAIN_RELAYER_SERVICE_ROLE @@ -56,7 +56,7 @@ contract('VolatileRelayerApp', ([_, root, sender, vault, offChainRelayerService] assert.equal((await app.read()).toString(), 10, 'app value does not match') }) - it('overloads a transaction with ~84k of gas', async () => { + it('overloads a transaction with ~83k of gas', async () => { const { receipt: { cumulativeGasUsed: relayedGasUsed } } = relayedTx const { receipt: { cumulativeGasUsed: nonRelayerGasUsed } } = await app.write(10, { from: sender }) @@ -65,6 +65,6 @@ contract('VolatileRelayerApp', ([_, root, sender, vault, offChainRelayerService] console.log('nonRelayerGasUsed:', nonRelayerGasUsed) console.log('gasOverload:', gasOverload) - assert.isBelow(gasOverload, 84000, 'relayed txs gas overload is higher than 84k') + assert.isBelow(gasOverload, 83000, 'relayed txs gas overload is higher than 83k') }) }) From fd420ff25d4206cbdeeed20db5f5af1614078b4f Mon Sep 17 00:00:00 2001 From: Facundo Spagnuolo Date: Fri, 10 May 2019 10:05:24 -0300 Subject: [PATCH 03/19] meta-txs: pick relayed app using volatile storage approach --- contracts/apps/AppStorage.sol | 14 ---- contracts/apps/AragonApp.sol | 2 +- contracts/relayer/RelayHelper.sol | 12 +++ ...olatileSender.sol => RelayedAragonApp.sol} | 13 +-- ...elayedAragonAppWithParameterizedSender.sol | 24 ------ .../relayer/{BaseRelayer.sol => Relayer.sol} | 26 +++--- ...elayerAragonAppWithParameterizedSender.sol | 29 ------- .../RelayerAragonAppWithVolatileSender.sol | 29 ------- contracts/relayer/StandAloneRelayer.sol | 21 ----- ...atileSenderMock.sol => RelayedAppMock.sol} | 4 +- ...edAragonAppWithParameterizedSenderMock.sol | 31 ------- ...erAragonAppWithParameterizedSenderMock.sol | 31 ------- ...RelayerAragonAppWithVolatileSenderMock.sol | 23 ------ .../relayer/parameterized_relayed_app.js | 81 ------------------- .../relayer/parameterized_relayer_app.js | 70 ---------------- .../{volatile_relayed_app.js => relayer.js} | 4 +- .../contracts/relayer/volatile_relayer_app.js | 70 ---------------- 17 files changed, 36 insertions(+), 448 deletions(-) create mode 100644 contracts/relayer/RelayHelper.sol rename contracts/relayer/{RelayedAragonAppWithVolatileSender.sol => RelayedAragonApp.sol} (66%) delete mode 100644 contracts/relayer/RelayedAragonAppWithParameterizedSender.sol rename contracts/relayer/{BaseRelayer.sol => Relayer.sol} (75%) delete mode 100644 contracts/relayer/RelayerAragonAppWithParameterizedSender.sol delete mode 100644 contracts/relayer/RelayerAragonAppWithVolatileSender.sol delete mode 100644 contracts/relayer/StandAloneRelayer.sol rename contracts/test/mocks/relayer/{RelayedAragonAppWithVolatileSenderMock.sol => RelayedAppMock.sol} (71%) delete mode 100644 contracts/test/mocks/relayer/RelayedAragonAppWithParameterizedSenderMock.sol delete mode 100644 contracts/test/mocks/relayer/RelayerAragonAppWithParameterizedSenderMock.sol delete mode 100644 contracts/test/mocks/relayer/RelayerAragonAppWithVolatileSenderMock.sol delete mode 100644 test/contracts/relayer/parameterized_relayed_app.js delete mode 100644 test/contracts/relayer/parameterized_relayer_app.js rename test/contracts/relayer/{volatile_relayed_app.js => relayer.js} (96%) delete mode 100644 test/contracts/relayer/volatile_relayer_app.js diff --git a/contracts/apps/AppStorage.sol b/contracts/apps/AppStorage.sol index c1c6c9c7e..ed0cec69b 100644 --- a/contracts/apps/AppStorage.sol +++ b/contracts/apps/AppStorage.sol @@ -15,12 +15,10 @@ contract AppStorage { * 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 LAST_NONCE_POSITION_BASE = keccak256("aragonOS.appStorage.lastNonce"); * bytes32 internal constant VOLATILE_SENDER_POSITION = keccak256("aragonOS.appStorage.volatile.sender"); */ bytes32 internal constant KERNEL_POSITION = 0x4172f0f7d2289153072b0a6ca36959e0cbe2efc3afe50fc81636caa96338137b; bytes32 internal constant APP_ID_POSITION = 0xd625496217aa6a3453eecb9c3489dc5a53e6c67b444329ea2b2cbc9ff547639b; - bytes32 internal constant LAST_NONCE_POSITION_BASE = 0x66c8c1e117f8d5835231a971a56ce0c7b70f9291340698a4263ada738d9269bd; bytes32 internal constant VOLATILE_SENDER_POSITION = 0xd6486d5aa3dac4242db35dd7559408452252cf8050988dbc66956eaa315379ce; function kernel() public view returns (IKernel) { @@ -35,10 +33,6 @@ contract AppStorage { return VOLATILE_SENDER_POSITION.getStorageAddress(); } - function lastNonce(address _account) public view returns (uint256) { - return lastNoncePosition(_account).getStorageUint256(); - } - function setKernel(IKernel _kernel) internal { KERNEL_POSITION.setStorageAddress(address(_kernel)); } @@ -50,12 +44,4 @@ contract AppStorage { function setVolatileStorageSender(address _sender) internal { VOLATILE_SENDER_POSITION.setStorageAddress(_sender); } - - function setLastNonce(address _account, uint256 _lastNonce) internal { - return lastNoncePosition(_account).setStorageUint256(_lastNonce); - } - - function lastNoncePosition(address _account) internal returns (bytes32) { - return keccak256(abi.encodePacked(LAST_NONCE_POSITION_BASE, _account)); - } } diff --git a/contracts/apps/AragonApp.sol b/contracts/apps/AragonApp.sol index 38c1e13e3..1e9570e0c 100644 --- a/contracts/apps/AragonApp.sol +++ b/contracts/apps/AragonApp.sol @@ -19,7 +19,7 @@ import "../evmscript/EVMScriptRunner.sol"; // ReentrancyGuard, EVMScriptRunner, and ACLSyntaxSugar are not directly used by this contract, but // are included so that they are automatically usable by subclassing contracts contract AragonApp is AppStorage, Autopetrified, VaultRecoverable, ReentrancyGuard, EVMScriptRunner, ACLSyntaxSugar { - string internal constant ERROR_AUTH_FAILED = "APP_AUTH_FAILED"; + string private constant ERROR_AUTH_FAILED = "APP_AUTH_FAILED"; modifier auth(bytes32 _role) { require(canPerform(sender(), _role, new uint256[](0)), ERROR_AUTH_FAILED); diff --git a/contracts/relayer/RelayHelper.sol b/contracts/relayer/RelayHelper.sol new file mode 100644 index 000000000..f1bd316b7 --- /dev/null +++ b/contracts/relayer/RelayHelper.sol @@ -0,0 +1,12 @@ +pragma solidity ^0.4.24; + + +library RelayHelper { + function revertForwardingError() internal { + assembly { + let ptr := mload(0x40) + returndatacopy(ptr, 0, returndatasize) + revert(ptr, returndatasize) + } + } +} diff --git a/contracts/relayer/RelayedAragonAppWithVolatileSender.sol b/contracts/relayer/RelayedAragonApp.sol similarity index 66% rename from contracts/relayer/RelayedAragonAppWithVolatileSender.sol rename to contracts/relayer/RelayedAragonApp.sol index 1f7cb63b6..d74d748bd 100644 --- a/contracts/relayer/RelayedAragonAppWithVolatileSender.sol +++ b/contracts/relayer/RelayedAragonApp.sol @@ -1,15 +1,16 @@ pragma solidity ^0.4.24; +import "./RelayHelper.sol"; import "../apps/AragonApp.sol"; -contract RelayedAragonAppWithVolatileSender is AragonApp { +contract RelayedAragonApp is AragonApp { bytes32 public constant RELAYER_ROLE = keccak256("RELAYER_ROLE"); function exec(address from, bytes calldata) external auth(RELAYER_ROLE) { setVolatileStorageSender(from); bool success = address(this).call(calldata); - if (!success) revertForwardingError(); + if (!success) RelayHelper.revertForwardingError(); setVolatileStorageSender(address(0)); } @@ -18,12 +19,4 @@ contract RelayedAragonAppWithVolatileSender is AragonApp { address volatileSender = volatileStorageSender(); return volatileSender != address(0) ? volatileSender : address(this); } - - function revertForwardingError() internal { - assembly { - let ptr := mload(0x40) - returndatacopy(ptr, 0, returndatasize) - revert(ptr, returndatasize) - } - } } diff --git a/contracts/relayer/RelayedAragonAppWithParameterizedSender.sol b/contracts/relayer/RelayedAragonAppWithParameterizedSender.sol deleted file mode 100644 index 1c2b3d932..000000000 --- a/contracts/relayer/RelayedAragonAppWithParameterizedSender.sol +++ /dev/null @@ -1,24 +0,0 @@ -pragma solidity ^0.4.24; - -import "../apps/AragonApp.sol"; - - -contract RelayedAragonAppWithParameterizedSender is AragonApp { - bytes32 public constant RELAYER_ROLE = keccak256("RELAYER_ROLE"); - - modifier relayedAuth(address _sender, bytes32 _role) { - assertRelayer(); - require(canPerform(_sender, _role, new uint256[](0)), ERROR_AUTH_FAILED); - _; - } - - modifier relayedAuthP(address _sender, bytes32 _role, uint256[] _params) { - assertRelayer(); - require(canPerform(_sender, _role, _params), ERROR_AUTH_FAILED); - _; - } - - function assertRelayer() private { - require(canPerform(msg.sender, RELAYER_ROLE, new uint256[](0)), ERROR_AUTH_FAILED); - } -} diff --git a/contracts/relayer/BaseRelayer.sol b/contracts/relayer/Relayer.sol similarity index 75% rename from contracts/relayer/BaseRelayer.sol rename to contracts/relayer/Relayer.sol index 86ed50c87..96241aa0b 100644 --- a/contracts/relayer/BaseRelayer.sol +++ b/contracts/relayer/Relayer.sol @@ -1,11 +1,12 @@ pragma solidity ^0.4.24; +import "./RelayHelper.sol"; import "../lib/sig/ECDSA.sol"; import "../apps/AragonApp.sol"; import "../common/DepositableStorage.sol"; -contract BaseRelayer is AragonApp, DepositableStorage { +contract Relayer is AragonApp, DepositableStorage { using ECDSA for bytes32; bytes32 public constant OFF_CHAIN_RELAYER_SERVICE_ROLE = keccak256("OFF_CHAIN_RELAYER_SERVICE_ROLE"); @@ -19,6 +20,8 @@ contract BaseRelayer is AragonApp, DepositableStorage { event FundsReceived(address indexed sender, uint256 amount); event TransactionRelayed(address indexed from, address indexed to, uint256 nonce, bytes calldata); + mapping (address => uint256) internal lastUsedNonce; + modifier refundGas() { uint256 startGas = gasleft(); _; @@ -35,7 +38,18 @@ contract BaseRelayer is AragonApp, DepositableStorage { setDepositable(true); } - function isNonceUsed(address sender, uint256 nonce) public view returns (bool); + function relay(address from, address to, uint256 nonce, bytes calldata, bytes signature) external refundGas auth(OFF_CHAIN_RELAYER_SERVICE_ROLE) { + assertValidTransaction(from, nonce, calldata, signature); + + lastUsedNonce[from] = nonce; + bool success = to.call(calldata); + if (!success) RelayHelper.revertForwardingError(); + emit TransactionRelayed(from, to, nonce, calldata); + } + + function isNonceUsed(address sender, uint256 nonce) public view returns (bool) { + return lastUsedNonce[sender] >= nonce; + } function assertValidTransaction(address from, uint256 nonce, bytes calldata, bytes signature) internal view { require(!isNonceUsed(from, nonce), ERROR_NONCE_ALREADY_USED); @@ -50,12 +64,4 @@ contract BaseRelayer is AragonApp, DepositableStorage { function messageHash(bytes calldata, uint256 nonce) internal pure returns (bytes32) { return keccak256(abi.encodePacked(keccak256(calldata), nonce)); } - - function revertForwardingError() internal { - assembly { - let ptr := mload(0x40) - returndatacopy(ptr, 0, returndatasize) - revert(ptr, returndatasize) - } - } } diff --git a/contracts/relayer/RelayerAragonAppWithParameterizedSender.sol b/contracts/relayer/RelayerAragonAppWithParameterizedSender.sol deleted file mode 100644 index 5a0243312..000000000 --- a/contracts/relayer/RelayerAragonAppWithParameterizedSender.sol +++ /dev/null @@ -1,29 +0,0 @@ -pragma solidity ^0.4.24; - -import "./BaseRelayer.sol"; - - -contract RelayerAragonAppWithParameterizedSender is BaseRelayer { - modifier relayedAuth(address _sender, bytes32 _role) { - require(canPerform(_sender, _role, new uint256[](0)), ERROR_AUTH_FAILED); - _; - } - - modifier relayedAuthP(address _sender, bytes32 _role, uint256[] _params) { - require(canPerform(_sender, _role, _params), ERROR_AUTH_FAILED); - _; - } - - function exec(address from, uint256 nonce, bytes calldata, bytes signature) external refundGas auth(OFF_CHAIN_RELAYER_SERVICE_ROLE) { - assertValidTransaction(from, nonce, calldata, signature); - - setLastNonce(from, nonce); - bool success = address(this).call(calldata); - if (!success) revertForwardingError(); - emit TransactionRelayed(from, address(this), nonce, calldata); - } - - function isNonceUsed(address _account, uint256 _nonce) public view returns (bool) { - return lastNonce(_account) >= _nonce; - } -} diff --git a/contracts/relayer/RelayerAragonAppWithVolatileSender.sol b/contracts/relayer/RelayerAragonAppWithVolatileSender.sol deleted file mode 100644 index b46fa0625..000000000 --- a/contracts/relayer/RelayerAragonAppWithVolatileSender.sol +++ /dev/null @@ -1,29 +0,0 @@ -pragma solidity ^0.4.24; - -import "./BaseRelayer.sol"; - - -contract RelayerAragonAppWithVolatileSender is BaseRelayer { - function exec(address from, uint256 nonce, bytes calldata, bytes signature) external refundGas auth(OFF_CHAIN_RELAYER_SERVICE_ROLE) { - assertValidTransaction(from, nonce, calldata, signature); - - setVolatileStorageSender(from); - setLastNonce(from, nonce); - - bool success = address(this).call(calldata); - if (!success) revertForwardingError(); - - setVolatileStorageSender(address(0)); - emit TransactionRelayed(from, address(this), nonce, calldata); - } - - function isNonceUsed(address _account, uint256 _nonce) public view returns (bool) { - return lastNonce(_account) >= _nonce; - } - - function sender() internal view returns (address) { - if (msg.sender != address(this)) return msg.sender; - address volatileSender = volatileStorageSender(); - return volatileSender != address(0) ? volatileSender : address(this); - } -} diff --git a/contracts/relayer/StandAloneRelayer.sol b/contracts/relayer/StandAloneRelayer.sol deleted file mode 100644 index c14e7db72..000000000 --- a/contracts/relayer/StandAloneRelayer.sol +++ /dev/null @@ -1,21 +0,0 @@ -pragma solidity ^0.4.24; - -import "./BaseRelayer.sol"; - - -contract StandAloneRelayer is BaseRelayer { - mapping (address => uint256) internal lastUsedNonce; - - function relay(address from, address to, uint256 nonce, bytes calldata, bytes signature) external refundGas auth(OFF_CHAIN_RELAYER_SERVICE_ROLE) { - assertValidTransaction(from, nonce, calldata, signature); - - lastUsedNonce[from] = nonce; - bool success = to.call(calldata); - if (!success) revertForwardingError(); - emit TransactionRelayed(from, to, nonce, calldata); - } - - function isNonceUsed(address sender, uint256 nonce) public view returns (bool) { - return lastUsedNonce[sender] >= nonce; - } -} diff --git a/contracts/test/mocks/relayer/RelayedAragonAppWithVolatileSenderMock.sol b/contracts/test/mocks/relayer/RelayedAppMock.sol similarity index 71% rename from contracts/test/mocks/relayer/RelayedAragonAppWithVolatileSenderMock.sol rename to contracts/test/mocks/relayer/RelayedAppMock.sol index a9edb1424..fdbe45a82 100644 --- a/contracts/test/mocks/relayer/RelayedAragonAppWithVolatileSenderMock.sol +++ b/contracts/test/mocks/relayer/RelayedAppMock.sol @@ -1,9 +1,9 @@ pragma solidity 0.4.24; -import "../../../relayer/RelayedAragonAppWithVolatileSender.sol"; +import "../../../relayer/RelayedAragonApp.sol"; -contract RelayedAragonAppWithVolatileSenderMock is RelayedAragonAppWithVolatileSender { +contract RelayedAppMock is RelayedAragonApp { bytes32 public constant WRITING_ROLE = keccak256("WRITING_ROLE"); uint256 private x; diff --git a/contracts/test/mocks/relayer/RelayedAragonAppWithParameterizedSenderMock.sol b/contracts/test/mocks/relayer/RelayedAragonAppWithParameterizedSenderMock.sol deleted file mode 100644 index 804f71336..000000000 --- a/contracts/test/mocks/relayer/RelayedAragonAppWithParameterizedSenderMock.sol +++ /dev/null @@ -1,31 +0,0 @@ -pragma solidity 0.4.24; - -import "../../../relayer/RelayedAragonAppWithParameterizedSender.sol"; - - -contract RelayedAragonAppWithParameterizedSenderMock is RelayedAragonAppWithParameterizedSender { - 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)) { - _write(_x); - } - - function relayedWrite(address _sender, uint256 _x) public relayedAuthP(_sender, WRITING_ROLE, arr(_x)) { - _write(_x); - } - - function _write(uint256 _x) internal { - x = _x; - } -} diff --git a/contracts/test/mocks/relayer/RelayerAragonAppWithParameterizedSenderMock.sol b/contracts/test/mocks/relayer/RelayerAragonAppWithParameterizedSenderMock.sol deleted file mode 100644 index 65ebf2feb..000000000 --- a/contracts/test/mocks/relayer/RelayerAragonAppWithParameterizedSenderMock.sol +++ /dev/null @@ -1,31 +0,0 @@ -pragma solidity 0.4.24; - -import "../../../relayer/RelayerAragonAppWithParameterizedSender.sol"; - - -contract RelayerAragonAppWithParameterizedSenderMock is RelayerAragonAppWithParameterizedSender { - bytes32 public constant WRITING_ROLE = keccak256("WRITING_ROLE"); - - uint256 private x; - - function initialize() public onlyInit { - super.initialize(); - x = 42; - } - - function read() public view returns (uint256) { - return x; - } - - function write(uint256 _x) public authP(WRITING_ROLE, arr(_x)) { - _write(_x); - } - - function relayedWrite(address _sender, uint256 _x) public relayedAuthP(_sender, WRITING_ROLE, arr(_x)) { - _write(_x); - } - - function _write(uint256 _x) internal { - x = _x; - } -} diff --git a/contracts/test/mocks/relayer/RelayerAragonAppWithVolatileSenderMock.sol b/contracts/test/mocks/relayer/RelayerAragonAppWithVolatileSenderMock.sol deleted file mode 100644 index 6522b66ef..000000000 --- a/contracts/test/mocks/relayer/RelayerAragonAppWithVolatileSenderMock.sol +++ /dev/null @@ -1,23 +0,0 @@ -pragma solidity 0.4.24; - -import "../../../relayer/RelayerAragonAppWithVolatileSender.sol"; - - -contract RelayerAragonAppWithVolatileSenderMock is RelayerAragonAppWithVolatileSender { - bytes32 public constant WRITING_ROLE = keccak256("WRITING_ROLE"); - - uint256 private x; - - function initialize() public onlyInit { - super.initialize(); - 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/test/contracts/relayer/parameterized_relayed_app.js b/test/contracts/relayer/parameterized_relayed_app.js deleted file mode 100644 index db18eda45..000000000 --- a/test/contracts/relayer/parameterized_relayed_app.js +++ /dev/null @@ -1,81 +0,0 @@ -const { sha3, soliditySha3 } = require('web3-utils') - -const ACL = artifacts.require('ACL') -const Kernel = artifacts.require('Kernel') -const Relayer = artifacts.require('StandAloneRelayer') -const DAOFactory = artifacts.require('DAOFactory') -const SampleApp = artifacts.require('RelayedAragonAppWithParameterizedSenderMock') - -const getEventArgument = (receipt, event, arg) => receipt.logs.filter(l => l.event === event)[0].args[arg] - -contract('ParameterizedRelayedApp', ([_, root, sender, vault, offChainRelayerService]) => { - let daoFactory, dao, acl, app, relayer, relayedTx, nonce = 1 - let kernelBase, aclBase, sampleAppBase, relayerBase - let WRITING_ROLE, APP_MANAGER_ROLE, RELAYER_ROLE, OFF_CHAIN_RELAYER_SERVICE_ROLE - - 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 roles', async () => { - WRITING_ROLE = await sampleAppBase.WRITING_ROLE() - RELAYER_ROLE = await sampleAppBase.RELAYER_ROLE() - APP_MANAGER_ROLE = await kernelBase.APP_MANAGER_ROLE() - OFF_CHAIN_RELAYER_SERVICE_ROLE = await relayerBase.OFF_CHAIN_RELAYER_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 relayer instance', async () => { - const receipt = await dao.newAppInstance('0x11111', relayerBase.address, '0x', false, { from: root }) - relayer = Relayer.at(getEventArgument(receipt, 'NewAppProxy', 'proxy')) - await relayer.initialize() - - await web3.eth.sendTransaction({ from: vault, to: relayer.address, value: 10e18 }) - await acl.createPermission(offChainRelayerService, relayer.address, OFF_CHAIN_RELAYER_SERVICE_ROLE, root, { from: root }) - }) - - beforeEach('create sample app instance', async () => { - const receipt = await dao.newAppInstance('0x22222', sampleAppBase.address, '0x', false, { from: root }) - app = SampleApp.at(getEventArgument(receipt, 'NewAppProxy', 'proxy')) - await app.initialize() - - await acl.createPermission(sender, app.address, WRITING_ROLE, root, { from: root }) - await acl.createPermission(relayer.address, app.address, RELAYER_ROLE, root, { from: root }) - }) - - beforeEach('relay transaction', async () => { - const calldata = app.contract.relayedWrite.getData(sender, 10) - const messageHash = soliditySha3(sha3(calldata), nonce) - const signature = web3.eth.sign(sender, messageHash) - - relayedTx = await relayer.relay(sender, app.address, nonce, calldata, signature, { from: offChainRelayerService }) - nonce++ - }) - - it('relays transactions to app', async () => { - assert.equal((await app.read()).toString(), 10, 'app value does not match') - }) - - it('overloads a transaction with ~78k of gas', async () => { - const { receipt: { cumulativeGasUsed: relayedGasUsed } } = relayedTx - const { receipt: { cumulativeGasUsed: nonRelayerGasUsed } } = await app.write(10, { from: sender }) - - const gasOverload = relayedGasUsed - nonRelayerGasUsed - console.log('relayedGasUsed:', relayedGasUsed) - console.log('nonRelayerGasUsed:', nonRelayerGasUsed) - console.log('gasOverload:', gasOverload) - - assert.isBelow(gasOverload, 78000, 'relayed txs gas overload is higher than 78k') - }) -}) diff --git a/test/contracts/relayer/parameterized_relayer_app.js b/test/contracts/relayer/parameterized_relayer_app.js deleted file mode 100644 index 50cac8b38..000000000 --- a/test/contracts/relayer/parameterized_relayer_app.js +++ /dev/null @@ -1,70 +0,0 @@ -const { sha3, soliditySha3 } = require('web3-utils') - -const ACL = artifacts.require('ACL') -const Kernel = artifacts.require('Kernel') -const DAOFactory = artifacts.require('DAOFactory') -const SampleApp = artifacts.require('RelayerAragonAppWithParameterizedSenderMock') - -const getEventArgument = (receipt, event, arg) => receipt.logs.filter(l => l.event === event)[0].args[arg] - -contract('ParameterizedRelayerApp', ([_, root, sender, vault, offChainRelayerService]) => { - let daoFactory, dao, acl, app, relayedTx, nonce = 1 - let kernelBase, aclBase, sampleAppBase - let WRITING_ROLE, APP_MANAGER_ROLE, OFF_CHAIN_RELAYER_SERVICE_ROLE - - before('deploy base implementations', async () => { - aclBase = await ACL.new() - kernelBase = await Kernel.new(true) // petrify immediately - sampleAppBase = await SampleApp.new() - daoFactory = await DAOFactory.new(kernelBase.address, aclBase.address, '0x0') - }) - - before('load roles', async () => { - WRITING_ROLE = await sampleAppBase.WRITING_ROLE() - APP_MANAGER_ROLE = await kernelBase.APP_MANAGER_ROLE() - OFF_CHAIN_RELAYER_SERVICE_ROLE = await sampleAppBase.OFF_CHAIN_RELAYER_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 }) - }) - - beforeEach('create sample app instance', async () => { - const receipt = await dao.newAppInstance('0x22222', sampleAppBase.address, '0x', false, { from: root }) - app = SampleApp.at(getEventArgument(receipt, 'NewAppProxy', 'proxy')) - await app.initialize() - - await web3.eth.sendTransaction({ from: vault, to: app.address, value: 10e18 }) - await acl.createPermission(sender, app.address, WRITING_ROLE, root, { from: root }) - await acl.createPermission(offChainRelayerService, app.address, OFF_CHAIN_RELAYER_SERVICE_ROLE, root, { from: root }) - }) - - beforeEach('relay transaction', async () => { - const calldata = app.contract.relayedWrite.getData(sender, 10) - const messageHash = soliditySha3(sha3(calldata), nonce) - const signature = web3.eth.sign(sender, messageHash) - - relayedTx = await app.exec(sender, nonce, calldata, signature, { from: offChainRelayerService }) - nonce++ - }) - - it('relays transactions to app', async () => { - assert.equal((await app.read()).toString(), 10, 'app value does not match') - }) - - it('overloads a transaction with ~75k of gas', async () => { - const { receipt: { cumulativeGasUsed: relayedGasUsed } } = relayedTx - const { receipt: { cumulativeGasUsed: nonRelayerGasUsed } } = await app.write(10, { from: sender }) - - const gasOverload = relayedGasUsed - nonRelayerGasUsed - console.log('relayedGasUsed:', relayedGasUsed) - console.log('nonRelayerGasUsed:', nonRelayerGasUsed) - console.log('gasOverload:', gasOverload) - - assert.isBelow(gasOverload, 75000, 'relayed txs gas overload is higher than 75k') - }) -}) diff --git a/test/contracts/relayer/volatile_relayed_app.js b/test/contracts/relayer/relayer.js similarity index 96% rename from test/contracts/relayer/volatile_relayed_app.js rename to test/contracts/relayer/relayer.js index 079e5c0ad..ca6814de9 100644 --- a/test/contracts/relayer/volatile_relayed_app.js +++ b/test/contracts/relayer/relayer.js @@ -2,9 +2,9 @@ const { sha3, soliditySha3 } = require('web3-utils') const ACL = artifacts.require('ACL') const Kernel = artifacts.require('Kernel') -const Relayer = artifacts.require('StandAloneRelayer') +const Relayer = artifacts.require('Relayer') const DAOFactory = artifacts.require('DAOFactory') -const SampleApp = artifacts.require('RelayedAragonAppWithVolatileSenderMock') +const SampleApp = artifacts.require('RelayedAppMock') const getEventArgument = (receipt, event, arg) => receipt.logs.filter(l => l.event === event)[0].args[arg] diff --git a/test/contracts/relayer/volatile_relayer_app.js b/test/contracts/relayer/volatile_relayer_app.js deleted file mode 100644 index 3ab944bbb..000000000 --- a/test/contracts/relayer/volatile_relayer_app.js +++ /dev/null @@ -1,70 +0,0 @@ -const { sha3, soliditySha3 } = require('web3-utils') - -const ACL = artifacts.require('ACL') -const Kernel = artifacts.require('Kernel') -const DAOFactory = artifacts.require('DAOFactory') -const SampleApp = artifacts.require('RelayerAragonAppWithVolatileSenderMock') - -const getEventArgument = (receipt, event, arg) => receipt.logs.filter(l => l.event === event)[0].args[arg] - -contract('VolatileRelayerApp', ([_, root, sender, vault, offChainRelayerService]) => { - let daoFactory, dao, acl, app, relayedTx, nonce = 1 - let kernelBase, aclBase, sampleAppBase - let WRITING_ROLE, APP_MANAGER_ROLE, OFF_CHAIN_RELAYER_SERVICE_ROLE - - before('deploy base implementations', async () => { - aclBase = await ACL.new() - kernelBase = await Kernel.new(true) // petrify immediately - sampleAppBase = await SampleApp.new() - daoFactory = await DAOFactory.new(kernelBase.address, aclBase.address, '0x0') - }) - - before('load roles', async () => { - WRITING_ROLE = await sampleAppBase.WRITING_ROLE() - APP_MANAGER_ROLE = await kernelBase.APP_MANAGER_ROLE() - OFF_CHAIN_RELAYER_SERVICE_ROLE = await sampleAppBase.OFF_CHAIN_RELAYER_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 }) - }) - - beforeEach('create sample app instance', async () => { - const receipt = await dao.newAppInstance('0x22222', sampleAppBase.address, '0x', false, { from: root }) - app = SampleApp.at(getEventArgument(receipt, 'NewAppProxy', 'proxy')) - await app.initialize() - - await web3.eth.sendTransaction({ from: vault, to: app.address, value: 10e18 }) - await acl.createPermission(sender, app.address, WRITING_ROLE, root, { from: root }) - await acl.createPermission(offChainRelayerService, app.address, OFF_CHAIN_RELAYER_SERVICE_ROLE, root, { from: root }) - }) - - beforeEach('relay transaction', async () => { - const calldata = app.contract.write.getData(10) - const messageHash = soliditySha3(sha3(calldata), nonce) - const signature = web3.eth.sign(sender, messageHash) - - relayedTx = await app.exec(sender, nonce, calldata, signature, { from: offChainRelayerService }) - nonce++ - }) - - it('relays transactions to app', async () => { - assert.equal((await app.read()).toString(), 10, 'app value does not match') - }) - - it('overloads a transaction with ~83k of gas', async () => { - const { receipt: { cumulativeGasUsed: relayedGasUsed } } = relayedTx - const { receipt: { cumulativeGasUsed: nonRelayerGasUsed } } = await app.write(10, { from: sender }) - - const gasOverload = relayedGasUsed - nonRelayerGasUsed - console.log('relayedGasUsed:', relayedGasUsed) - console.log('nonRelayerGasUsed:', nonRelayerGasUsed) - console.log('gasOverload:', gasOverload) - - assert.isBelow(gasOverload, 83000, 'relayed txs gas overload is higher than 83k') - }) -}) From 87030a0d7ee9ec75e0ee453f0f238939229798b8 Mon Sep 17 00:00:00 2001 From: Facundo Spagnuolo Date: Fri, 10 May 2019 10:08:15 -0300 Subject: [PATCH 04/19] meta-txs: use depositable proxies functionality from relayer --- contracts/relayer/Relayer.sol | 10 +++++----- test/contracts/relayer/relayer.js | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/contracts/relayer/Relayer.sol b/contracts/relayer/Relayer.sol index 96241aa0b..b5f1e09c1 100644 --- a/contracts/relayer/Relayer.sol +++ b/contracts/relayer/Relayer.sol @@ -17,7 +17,6 @@ contract Relayer is AragonApp, DepositableStorage { string private constant ERROR_NONCE_ALREADY_USED = "RELAYER_NONCE_ALREADY_USED"; string private constant ERROR_INVALID_SENDER_SIGNATURE = "RELAYER_INVALID_SENDER_SIGNATURE"; - event FundsReceived(address indexed sender, uint256 amount); event TransactionRelayed(address indexed from, address indexed to, uint256 nonce, bytes calldata); mapping (address => uint256) internal lastUsedNonce; @@ -29,15 +28,16 @@ contract Relayer is AragonApp, DepositableStorage { require(msg.sender.send(refund), ERROR_GAS_REFUND_FAIL); } - function () external payable { - emit FundsReceived(msg.sender, msg.value); - } - function initialize() public onlyInit { initialized(); setDepositable(true); } + function allowRecoverability(address token) public view returns (bool) { + // does not allow to recover ETH + return token != ETH; + } + function relay(address from, address to, uint256 nonce, bytes calldata, bytes signature) external refundGas auth(OFF_CHAIN_RELAYER_SERVICE_ROLE) { assertValidTransaction(from, nonce, calldata, signature); diff --git a/test/contracts/relayer/relayer.js b/test/contracts/relayer/relayer.js index ca6814de9..c55ef9ec6 100644 --- a/test/contracts/relayer/relayer.js +++ b/test/contracts/relayer/relayer.js @@ -41,7 +41,8 @@ contract('VolatileRelayedApp', ([_, root, sender, vault, offChainRelayerService] relayer = Relayer.at(getEventArgument(receipt, 'NewAppProxy', 'proxy')) await relayer.initialize() - await web3.eth.sendTransaction({ from: vault, to: relayer.address, value: 10e18 }) + const SEND_ETH_GAS = 31000 // 21k base tx cost + 10k limit on depositable proxies + await relayer.sendTransaction({ from: vault, value: 1e18, gas: SEND_ETH_GAS }) await acl.createPermission(offChainRelayerService, relayer.address, OFF_CHAIN_RELAYER_SERVICE_ROLE, root, { from: root }) }) From ee34afa406021c994015d000874584db7663be64 Mon Sep 17 00:00:00 2001 From: Facundo Spagnuolo Date: Fri, 10 May 2019 10:33:26 -0300 Subject: [PATCH 05/19] meta-txs: implement shared interface between relayer and apps --- contracts/relayer/RelayHelper.sol | 12 ------------ contracts/relayer/RelayedAragonApp.sol | 18 +++++++++++++++--- contracts/relayer/Relayer.sol | 8 ++++---- test/contracts/relayer/relayer.js | 11 +++++------ 4 files changed, 24 insertions(+), 25 deletions(-) delete mode 100644 contracts/relayer/RelayHelper.sol diff --git a/contracts/relayer/RelayHelper.sol b/contracts/relayer/RelayHelper.sol deleted file mode 100644 index f1bd316b7..000000000 --- a/contracts/relayer/RelayHelper.sol +++ /dev/null @@ -1,12 +0,0 @@ -pragma solidity ^0.4.24; - - -library RelayHelper { - function revertForwardingError() internal { - assembly { - let ptr := mload(0x40) - returndatacopy(ptr, 0, returndatasize) - revert(ptr, returndatasize) - } - } -} diff --git a/contracts/relayer/RelayedAragonApp.sol b/contracts/relayer/RelayedAragonApp.sol index d74d748bd..bf0d4f138 100644 --- a/contracts/relayer/RelayedAragonApp.sol +++ b/contracts/relayer/RelayedAragonApp.sol @@ -1,16 +1,20 @@ pragma solidity ^0.4.24; -import "./RelayHelper.sol"; + import "../apps/AragonApp.sol"; -contract RelayedAragonApp is AragonApp { +interface IRelayedAragonApp { + function exec(address from, bytes calldata) external; +} + +contract RelayedAragonApp is IRelayedAragonApp, AragonApp { bytes32 public constant RELAYER_ROLE = keccak256("RELAYER_ROLE"); function exec(address from, bytes calldata) external auth(RELAYER_ROLE) { setVolatileStorageSender(from); bool success = address(this).call(calldata); - if (!success) RelayHelper.revertForwardingError(); + if (!success) revertForwardingError(); setVolatileStorageSender(address(0)); } @@ -19,4 +23,12 @@ contract RelayedAragonApp is AragonApp { address volatileSender = volatileStorageSender(); return volatileSender != address(0) ? volatileSender : address(this); } + + function revertForwardingError() private { + assembly { + let ptr := mload(0x40) + returndatacopy(ptr, 0, returndatasize) + revert(ptr, returndatasize) + } + } } diff --git a/contracts/relayer/Relayer.sol b/contracts/relayer/Relayer.sol index b5f1e09c1..2c99cbe78 100644 --- a/contracts/relayer/Relayer.sol +++ b/contracts/relayer/Relayer.sol @@ -1,6 +1,6 @@ pragma solidity ^0.4.24; -import "./RelayHelper.sol"; +import "./RelayedAragonApp.sol"; import "../lib/sig/ECDSA.sol"; import "../apps/AragonApp.sol"; import "../common/DepositableStorage.sol"; @@ -24,7 +24,8 @@ contract Relayer is AragonApp, DepositableStorage { modifier refundGas() { uint256 startGas = gasleft(); _; - uint256 refund = EXTERNAL_TX_COST + startGas - gasleft(); + uint256 refundGas = EXTERNAL_TX_COST + startGas - gasleft(); + uint256 refund = refundGas * tx.gasprice; require(msg.sender.send(refund), ERROR_GAS_REFUND_FAIL); } @@ -42,8 +43,7 @@ contract Relayer is AragonApp, DepositableStorage { assertValidTransaction(from, nonce, calldata, signature); lastUsedNonce[from] = nonce; - bool success = to.call(calldata); - if (!success) RelayHelper.revertForwardingError(); + IRelayedAragonApp(to).exec(from, calldata); emit TransactionRelayed(from, to, nonce, calldata); } diff --git a/test/contracts/relayer/relayer.js b/test/contracts/relayer/relayer.js index c55ef9ec6..bb7c2bc28 100644 --- a/test/contracts/relayer/relayer.js +++ b/test/contracts/relayer/relayer.js @@ -56,12 +56,11 @@ contract('VolatileRelayedApp', ([_, root, sender, vault, offChainRelayerService] }) beforeEach('relay transaction', async () => { - const mainCalldata = app.contract.write.getData(10) - const execCalldata = app.contract.exec.getData(sender, mainCalldata) - const messageHash = soliditySha3(sha3(execCalldata), nonce) + const calldata = app.contract.write.getData(10) + const messageHash = soliditySha3(sha3(calldata), nonce) const signature = web3.eth.sign(sender, messageHash) - relayedTx = await relayer.relay(sender, app.address, nonce, execCalldata, signature, { from: offChainRelayerService }) + relayedTx = await relayer.relay(sender, app.address, nonce, calldata, signature, { from: offChainRelayerService }) nonce++ }) @@ -69,7 +68,7 @@ contract('VolatileRelayedApp', ([_, root, sender, vault, offChainRelayerService] assert.equal((await app.read()).toString(), 10, 'app value does not match') }) - it('overloads a transaction with ~99k of gas', async () => { + it('overloads a transaction with ~96k of gas', async () => { const { receipt: { cumulativeGasUsed: relayedGasUsed } } = relayedTx const { receipt: { cumulativeGasUsed: nonRelayerGasUsed } } = await app.write(10, { from: sender }) @@ -78,6 +77,6 @@ contract('VolatileRelayedApp', ([_, root, sender, vault, offChainRelayerService] console.log('nonRelayerGasUsed:', nonRelayerGasUsed) console.log('gasOverload:', gasOverload) - assert.isBelow(gasOverload, 99000, 'relayed txs gas overload is higher than 99k') + assert.isBelow(gasOverload, 96000, 'relayed txs gas overload is higher than 96k') }) }) From 3c6a500dba240b33f14ddbf7c3402b39072e6474 Mon Sep 17 00:00:00 2001 From: Facundo Spagnuolo Date: Fri, 10 May 2019 10:43:51 -0300 Subject: [PATCH 06/19] meta-txs: optimize off chain service authorization --- contracts/relayer/Relayer.sol | 38 +++++++++++++++++++++++-------- test/contracts/relayer/relayer.js | 11 +++++---- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/contracts/relayer/Relayer.sol b/contracts/relayer/Relayer.sol index 2c99cbe78..c55107f2a 100644 --- a/contracts/relayer/Relayer.sol +++ b/contracts/relayer/Relayer.sol @@ -9,23 +9,33 @@ import "../common/DepositableStorage.sol"; contract Relayer is AragonApp, DepositableStorage { using ECDSA for bytes32; - bytes32 public constant OFF_CHAIN_RELAYER_SERVICE_ROLE = keccak256("OFF_CHAIN_RELAYER_SERVICE_ROLE"); + bytes32 public constant ALLOW_OFF_CHAIN_SERVICE_ROLE = keccak256("ALLOW_OFF_CHAIN_SERVICE_ROLE"); + bytes32 public constant DISALLOW_OFF_CHAIN_SERVICE_ROLE = keccak256("DISALLOW_OFF_CHAIN_SERVICE_ROLE"); uint256 private constant EXTERNAL_TX_COST = 21000; string private constant ERROR_GAS_REFUND_FAIL = "RELAYER_GAS_REFUND_FAIL"; 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"; + event ServiceAllowed(address indexed service); + event ServiceDisallowed(address indexed service); event TransactionRelayed(address indexed from, address indexed to, uint256 nonce, bytes calldata); + mapping (address => bool) internal allowedServices; mapping (address => uint256) internal lastUsedNonce; + modifier onlyAllowedServices() { + require(allowedServices[msg.sender], ERROR_SERVICE_NOT_ALLOWED); + _; + } + modifier refundGas() { uint256 startGas = gasleft(); _; - uint256 refundGas = EXTERNAL_TX_COST + startGas - gasleft(); - uint256 refund = refundGas * tx.gasprice; + uint256 totalGas = EXTERNAL_TX_COST + startGas - gasleft(); + uint256 refund = totalGas * tx.gasprice; require(msg.sender.send(refund), ERROR_GAS_REFUND_FAIL); } @@ -34,12 +44,7 @@ contract Relayer is AragonApp, DepositableStorage { setDepositable(true); } - function allowRecoverability(address token) public view returns (bool) { - // does not allow to recover ETH - return token != ETH; - } - - function relay(address from, address to, uint256 nonce, bytes calldata, bytes signature) external refundGas auth(OFF_CHAIN_RELAYER_SERVICE_ROLE) { + function relay(address from, address to, uint256 nonce, bytes calldata, bytes signature) external refundGas onlyAllowedServices { assertValidTransaction(from, nonce, calldata, signature); lastUsedNonce[from] = nonce; @@ -47,6 +52,21 @@ contract Relayer is AragonApp, DepositableStorage { emit TransactionRelayed(from, to, nonce, calldata); } + function allowService(address service) external authP(ALLOW_OFF_CHAIN_SERVICE_ROLE, arr(service)) { + allowedServices[service] = true; + emit ServiceAllowed(service); + } + + function disallowService(address service) external authP(DISALLOW_OFF_CHAIN_SERVICE_ROLE, arr(service)) { + allowedServices[service] = false; + emit ServiceDisallowed(service); + } + + function allowRecoverability(address token) public view returns (bool) { + // does not allow to recover ETH + return token != ETH; + } + function isNonceUsed(address sender, uint256 nonce) public view returns (bool) { return lastUsedNonce[sender] >= nonce; } diff --git a/test/contracts/relayer/relayer.js b/test/contracts/relayer/relayer.js index bb7c2bc28..1f3557395 100644 --- a/test/contracts/relayer/relayer.js +++ b/test/contracts/relayer/relayer.js @@ -11,7 +11,7 @@ const getEventArgument = (receipt, event, arg) => receipt.logs.filter(l => l.eve contract('VolatileRelayedApp', ([_, root, sender, vault, offChainRelayerService]) => { let daoFactory, dao, acl, app, relayer, relayedTx, nonce = 1 let kernelBase, aclBase, sampleAppBase, relayerBase - let WRITING_ROLE, APP_MANAGER_ROLE, RELAYER_ROLE, OFF_CHAIN_RELAYER_SERVICE_ROLE + let WRITING_ROLE, APP_MANAGER_ROLE, RELAYER_ROLE, ALLOW_OFF_CHAIN_SERVICE_ROLE before('deploy base implementations', async () => { aclBase = await ACL.new() @@ -25,7 +25,7 @@ contract('VolatileRelayedApp', ([_, root, sender, vault, offChainRelayerService] WRITING_ROLE = await sampleAppBase.WRITING_ROLE() RELAYER_ROLE = await sampleAppBase.RELAYER_ROLE() APP_MANAGER_ROLE = await kernelBase.APP_MANAGER_ROLE() - OFF_CHAIN_RELAYER_SERVICE_ROLE = await relayerBase.OFF_CHAIN_RELAYER_SERVICE_ROLE() + ALLOW_OFF_CHAIN_SERVICE_ROLE = await relayerBase.ALLOW_OFF_CHAIN_SERVICE_ROLE() }) before('deploy DAO', async () => { @@ -43,7 +43,8 @@ contract('VolatileRelayedApp', ([_, root, sender, vault, offChainRelayerService] const SEND_ETH_GAS = 31000 // 21k base tx cost + 10k limit on depositable proxies await relayer.sendTransaction({ from: vault, value: 1e18, gas: SEND_ETH_GAS }) - await acl.createPermission(offChainRelayerService, relayer.address, OFF_CHAIN_RELAYER_SERVICE_ROLE, root, { from: root }) + await acl.createPermission(root, relayer.address, ALLOW_OFF_CHAIN_SERVICE_ROLE, root, { from: root }) + await relayer.allowService(offChainRelayerService, { from: root }) }) beforeEach('create sample app instance', async () => { @@ -68,7 +69,7 @@ contract('VolatileRelayedApp', ([_, root, sender, vault, offChainRelayerService] assert.equal((await app.read()).toString(), 10, 'app value does not match') }) - it('overloads a transaction with ~96k of gas', async () => { + it('overloads a transaction with ~78k of gas', async () => { const { receipt: { cumulativeGasUsed: relayedGasUsed } } = relayedTx const { receipt: { cumulativeGasUsed: nonRelayerGasUsed } } = await app.write(10, { from: sender }) @@ -77,6 +78,6 @@ contract('VolatileRelayedApp', ([_, root, sender, vault, offChainRelayerService] console.log('nonRelayerGasUsed:', nonRelayerGasUsed) console.log('gasOverload:', gasOverload) - assert.isBelow(gasOverload, 96000, 'relayed txs gas overload is higher than 96k') + assert.isBelow(gasOverload, 78000, 'relayed txs gas overload is higher than 78k') }) }) From a5764131cc4633eed1e56451582c355fc1947f94 Mon Sep 17 00:00:00 2001 From: Facundo Spagnuolo Date: Fri, 10 May 2019 10:47:21 -0300 Subject: [PATCH 07/19] meta-txs: use events helpers --- test/contracts/relayer/relayer.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/contracts/relayer/relayer.js b/test/contracts/relayer/relayer.js index 1f3557395..789f8ea8e 100644 --- a/test/contracts/relayer/relayer.js +++ b/test/contracts/relayer/relayer.js @@ -1,4 +1,5 @@ const { sha3, soliditySha3 } = require('web3-utils') +const { getEventArgument, getNewProxyAddress } = require('../../helpers/events') const ACL = artifacts.require('ACL') const Kernel = artifacts.require('Kernel') @@ -6,8 +7,6 @@ const Relayer = artifacts.require('Relayer') const DAOFactory = artifacts.require('DAOFactory') const SampleApp = artifacts.require('RelayedAppMock') -const getEventArgument = (receipt, event, arg) => receipt.logs.filter(l => l.event === event)[0].args[arg] - contract('VolatileRelayedApp', ([_, root, sender, vault, offChainRelayerService]) => { let daoFactory, dao, acl, app, relayer, relayedTx, nonce = 1 let kernelBase, aclBase, sampleAppBase, relayerBase @@ -38,7 +37,7 @@ contract('VolatileRelayedApp', ([_, root, sender, vault, offChainRelayerService] before('create relayer instance', async () => { const receipt = await dao.newAppInstance('0x11111', relayerBase.address, '0x', false, { from: root }) - relayer = Relayer.at(getEventArgument(receipt, 'NewAppProxy', 'proxy')) + relayer = Relayer.at(getNewProxyAddress(receipt)) await relayer.initialize() const SEND_ETH_GAS = 31000 // 21k base tx cost + 10k limit on depositable proxies @@ -49,7 +48,7 @@ contract('VolatileRelayedApp', ([_, root, sender, vault, offChainRelayerService] beforeEach('create sample app instance', async () => { const receipt = await dao.newAppInstance('0x22222', sampleAppBase.address, '0x', false, { from: root }) - app = SampleApp.at(getEventArgument(receipt, 'NewAppProxy', 'proxy')) + app = SampleApp.at(getNewProxyAddress(receipt)) await app.initialize() await acl.createPermission(sender, app.address, WRITING_ROLE, root, { from: root }) From d83a20905c9842b849ec65c7fded5635f4b0a2d8 Mon Sep 17 00:00:00 2001 From: Facundo Spagnuolo Date: Fri, 10 May 2019 14:32:54 -0300 Subject: [PATCH 08/19] meta-txs: optimize volatile sender using calldata --- contracts/apps/AppStorage.sol | 10 --- contracts/common/MemoryHelpers.sol | 49 ++++++++++++++ contracts/kernel/IKernel.sol | 2 + contracts/kernel/Kernel.sol | 10 +++ contracts/kernel/KernelConstants.sol | 2 + contracts/relayer/IRelayer.sol | 6 ++ contracts/relayer/RelayedAragonApp.sol | 33 ++++----- contracts/relayer/Relayer.sol | 30 ++++++++- .../test/mocks/common/KeccakConstants.sol | 1 + .../test/mocks/kernel/KernelConstantsMock.sol | 1 + test/contracts/common/keccak_constants.js | 1 + test/contracts/relayer/relayer.js | 67 ++++++++++++------- 12 files changed, 153 insertions(+), 59 deletions(-) create mode 100644 contracts/common/MemoryHelpers.sol create mode 100644 contracts/relayer/IRelayer.sol diff --git a/contracts/apps/AppStorage.sol b/contracts/apps/AppStorage.sol index ed0cec69b..8c258b4cb 100644 --- a/contracts/apps/AppStorage.sol +++ b/contracts/apps/AppStorage.sol @@ -15,11 +15,9 @@ contract AppStorage { * 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 VOLATILE_SENDER_POSITION = keccak256("aragonOS.appStorage.volatile.sender"); */ bytes32 internal constant KERNEL_POSITION = 0x4172f0f7d2289153072b0a6ca36959e0cbe2efc3afe50fc81636caa96338137b; bytes32 internal constant APP_ID_POSITION = 0xd625496217aa6a3453eecb9c3489dc5a53e6c67b444329ea2b2cbc9ff547639b; - bytes32 internal constant VOLATILE_SENDER_POSITION = 0xd6486d5aa3dac4242db35dd7559408452252cf8050988dbc66956eaa315379ce; function kernel() public view returns (IKernel) { return IKernel(KERNEL_POSITION.getStorageAddress()); @@ -29,10 +27,6 @@ contract AppStorage { return APP_ID_POSITION.getStorageBytes32(); } - function volatileStorageSender() public view returns (address) { - return VOLATILE_SENDER_POSITION.getStorageAddress(); - } - function setKernel(IKernel _kernel) internal { KERNEL_POSITION.setStorageAddress(address(_kernel)); } @@ -40,8 +34,4 @@ contract AppStorage { function setAppId(bytes32 _appId) internal { APP_ID_POSITION.setStorageBytes32(_appId); } - - function setVolatileStorageSender(address _sender) internal { - VOLATILE_SENDER_POSITION.setStorageAddress(_sender); - } } diff --git a/contracts/common/MemoryHelpers.sol b/contracts/common/MemoryHelpers.sol new file mode 100644 index 000000000..98ecd6872 --- /dev/null +++ b/contracts/common/MemoryHelpers.sol @@ -0,0 +1,49 @@ +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 dest, uint256 src, uint256 len) private pure { + // 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/relayer/IRelayer.sol b/contracts/relayer/IRelayer.sol new file mode 100644 index 000000000..1a4aaa869 --- /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 calldata, bytes signature) external; +} diff --git a/contracts/relayer/RelayedAragonApp.sol b/contracts/relayer/RelayedAragonApp.sol index bf0d4f138..4ccba686e 100644 --- a/contracts/relayer/RelayedAragonApp.sol +++ b/contracts/relayer/RelayedAragonApp.sol @@ -1,34 +1,25 @@ pragma solidity ^0.4.24; - +import "./IRelayer.sol"; import "../apps/AragonApp.sol"; -interface IRelayedAragonApp { - function exec(address from, bytes calldata) external; -} +contract RelayedAragonApp is AragonApp { -contract RelayedAragonApp is IRelayedAragonApp, AragonApp { - bytes32 public constant RELAYER_ROLE = keccak256("RELAYER_ROLE"); + function sender() internal view returns (address) { + address relayer = address(_relayer()); + if (msg.sender != relayer) return msg.sender; - function exec(address from, bytes calldata) external auth(RELAYER_ROLE) { - setVolatileStorageSender(from); - bool success = address(this).call(calldata); - if (!success) revertForwardingError(); - setVolatileStorageSender(address(0)); + address signer = _decodeSigner(); + return signer != address(0) ? signer : relayer; } - function sender() internal view returns (address) { - if (msg.sender != address(this)) return msg.sender; - address volatileSender = volatileStorageSender(); - return volatileSender != address(0) ? volatileSender : address(this); + function _decodeSigner() internal returns (address signer) { + bytes memory calldata = msg.data; + assembly { signer := mload(add(calldata, calldatasize)) } } - function revertForwardingError() private { - assembly { - let ptr := mload(0x40) - returndatacopy(ptr, 0, returndatasize) - revert(ptr, returndatasize) - } + function _relayer() internal returns (IRelayer) { + return kernel().relayer(); } } diff --git a/contracts/relayer/Relayer.sol b/contracts/relayer/Relayer.sol index c55107f2a..ced51ea78 100644 --- a/contracts/relayer/Relayer.sol +++ b/contracts/relayer/Relayer.sol @@ -1,13 +1,16 @@ pragma solidity ^0.4.24; +import "./IRelayer.sol"; import "./RelayedAragonApp.sol"; import "../lib/sig/ECDSA.sol"; import "../apps/AragonApp.sol"; +import "../common/MemoryHelpers.sol"; import "../common/DepositableStorage.sol"; -contract Relayer is AragonApp, DepositableStorage { +contract Relayer is IRelayer, AragonApp, DepositableStorage { using ECDSA for bytes32; + using MemoryHelpers for bytes; bytes32 public constant ALLOW_OFF_CHAIN_SERVICE_ROLE = keccak256("ALLOW_OFF_CHAIN_SERVICE_ROLE"); bytes32 public constant DISALLOW_OFF_CHAIN_SERVICE_ROLE = keccak256("DISALLOW_OFF_CHAIN_SERVICE_ROLE"); @@ -21,7 +24,7 @@ contract Relayer is AragonApp, DepositableStorage { event ServiceAllowed(address indexed service); event ServiceDisallowed(address indexed service); - event TransactionRelayed(address indexed from, address indexed to, uint256 nonce, bytes calldata); + event TransactionRelayed(address from, address to, uint256 nonce, bytes calldata); mapping (address => bool) internal allowedServices; mapping (address => uint256) internal lastUsedNonce; @@ -48,8 +51,9 @@ contract Relayer is AragonApp, DepositableStorage { assertValidTransaction(from, nonce, calldata, signature); lastUsedNonce[from] = nonce; - IRelayedAragonApp(to).exec(from, calldata); + relayCall(from, to, calldata); emit TransactionRelayed(from, to, nonce, calldata); + forwardReturnedData(); } function allowService(address service) external authP(ALLOW_OFF_CHAIN_SERVICE_ROLE, arr(service)) { @@ -84,4 +88,24 @@ contract Relayer is AragonApp, DepositableStorage { function messageHash(bytes calldata, uint256 nonce) internal pure returns (bytes32) { return keccak256(abi.encodePacked(keccak256(calldata), nonce)); } + + function relayCall(address from, address to, bytes calldata) private { + bytes memory encodedSignerCalldata = calldata.append(from); + assembly { + let success := call(gas, to, 0, add(encodedSignerCalldata, 0x20), mload(encodedSignerCalldata), 0, 0) + switch success case 0 { + let ptr := mload(0x40) + returndatacopy(ptr, 0, returndatasize) + revert(ptr, returndatasize) + } + } + } + + function forwardReturnedData() private { + assembly { + let ptr := mload(0x40) + returndatacopy(ptr, 0, returndatasize) + return(ptr, returndatasize) + } + } } 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/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/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/relayer/relayer.js b/test/contracts/relayer/relayer.js index 789f8ea8e..266f3b071 100644 --- a/test/contracts/relayer/relayer.js +++ b/test/contracts/relayer/relayer.js @@ -1,4 +1,5 @@ const { sha3, soliditySha3 } = require('web3-utils') +const { assertRevert } = require('../../helpers/assertThrow') const { getEventArgument, getNewProxyAddress } = require('../../helpers/events') const ACL = artifacts.require('ACL') @@ -7,10 +8,10 @@ const Relayer = artifacts.require('Relayer') const DAOFactory = artifacts.require('DAOFactory') const SampleApp = artifacts.require('RelayedAppMock') -contract('VolatileRelayedApp', ([_, root, sender, vault, offChainRelayerService]) => { - let daoFactory, dao, acl, app, relayer, relayedTx, nonce = 1 +contract('RelayedApp', ([_, root, member, anyone, vault, offChainRelayerService]) => { + let daoFactory, dao, acl, app, relayer, relayedTx, nonce = 0 let kernelBase, aclBase, sampleAppBase, relayerBase - let WRITING_ROLE, APP_MANAGER_ROLE, RELAYER_ROLE, ALLOW_OFF_CHAIN_SERVICE_ROLE + let WRITING_ROLE, APP_MANAGER_ROLE, ALLOW_OFF_CHAIN_SERVICE_ROLE, RELAYER_APP_ID before('deploy base implementations', async () => { aclBase = await ACL.new() @@ -20,9 +21,9 @@ contract('VolatileRelayedApp', ([_, root, sender, vault, offChainRelayerService] daoFactory = await DAOFactory.new(kernelBase.address, aclBase.address, '0x0') }) - before('load roles', async () => { + before('load constants', async () => { + RELAYER_APP_ID = await kernelBase.DEFAULT_RELAYER_APP_ID() WRITING_ROLE = await sampleAppBase.WRITING_ROLE() - RELAYER_ROLE = await sampleAppBase.RELAYER_ROLE() APP_MANAGER_ROLE = await kernelBase.APP_MANAGER_ROLE() ALLOW_OFF_CHAIN_SERVICE_ROLE = await relayerBase.ALLOW_OFF_CHAIN_SERVICE_ROLE() }) @@ -36,7 +37,7 @@ contract('VolatileRelayedApp', ([_, root, sender, vault, offChainRelayerService] }) before('create relayer instance', async () => { - const receipt = await dao.newAppInstance('0x11111', relayerBase.address, '0x', false, { from: root }) + const receipt = await dao.newAppInstance(RELAYER_APP_ID, relayerBase.address, '0x', true, { from: root }) relayer = Relayer.at(getNewProxyAddress(receipt)) await relayer.initialize() @@ -51,32 +52,48 @@ contract('VolatileRelayedApp', ([_, root, sender, vault, offChainRelayerService] app = SampleApp.at(getNewProxyAddress(receipt)) await app.initialize() - await acl.createPermission(sender, app.address, WRITING_ROLE, root, { from: root }) - await acl.createPermission(relayer.address, app.address, RELAYER_ROLE, root, { from: root }) + await acl.createPermission(member, app.address, WRITING_ROLE, root, { from: root }) }) - beforeEach('relay transaction', async () => { - const calldata = app.contract.write.getData(10) - const messageHash = soliditySha3(sha3(calldata), nonce) - const signature = web3.eth.sign(sender, messageHash) + beforeEach('increment nonce', () => nonce++) - relayedTx = await relayer.relay(sender, app.address, nonce, calldata, signature, { from: offChainRelayerService }) - nonce++ - }) + context('when the sender is authorized', () => { + const sender = member + + beforeEach('relay transaction', async () => { + const calldata = app.contract.write.getData(10) + const messageHash = soliditySha3(sha3(calldata), nonce) + const signature = web3.eth.sign(sender, messageHash) + + relayedTx = await relayer.relay(sender, app.address, nonce, calldata, signature, { from: offChainRelayerService }) + }) + + it('relays transactions to app', async () => { + assert.equal((await app.read()).toString(), 10, 'app value does not match') + }) + + it('overloads a transaction with ~34k of gas', async () => { + const { receipt: { cumulativeGasUsed: relayedGasUsed } } = relayedTx + const { receipt: { cumulativeGasUsed: nonRelayerGasUsed } } = await app.write(10, { from: sender }) + + const gasOverload = relayedGasUsed - nonRelayerGasUsed + console.log('relayedGasUsed:', relayedGasUsed) + console.log('nonRelayerGasUsed:', nonRelayerGasUsed) + console.log('gasOverload:', gasOverload) - it('relays transactions to app', async () => { - assert.equal((await app.read()).toString(), 10, 'app value does not match') + assert.isBelow(gasOverload, 34000, 'relayed txs gas overload is higher than 34k') + }) }) - it('overloads a transaction with ~78k of gas', async () => { - const { receipt: { cumulativeGasUsed: relayedGasUsed } } = relayedTx - const { receipt: { cumulativeGasUsed: nonRelayerGasUsed } } = await app.write(10, { from: sender }) + context('when the sender is not authorized', () => { + const sender = anyone - const gasOverload = relayedGasUsed - nonRelayerGasUsed - console.log('relayedGasUsed:', relayedGasUsed) - console.log('nonRelayerGasUsed:', nonRelayerGasUsed) - console.log('gasOverload:', gasOverload) + it('reverts', async () => { + const calldata = app.contract.write.getData(10) + const messageHash = soliditySha3(sha3(calldata), nonce) + const signature = web3.eth.sign(sender, messageHash) - assert.isBelow(gasOverload, 78000, 'relayed txs gas overload is higher than 78k') + await assertRevert(relayer.relay(sender, app.address, nonce, calldata, signature, { from: offChainRelayerService }), 'APP_AUTH_FAILED') + }) }) }) From ada4788054e49bc594a15d8ce60f36dc783bdafb Mon Sep 17 00:00:00 2001 From: Facundo Spagnuolo Date: Mon, 13 May 2019 15:09:04 -0300 Subject: [PATCH 09/19] meta-txs: add solidity tests --- contracts/common/MemoryHelpers.sol | 2 +- contracts/relayer/RelayedAragonApp.sol | 11 ++- contracts/relayer/Relayer.sol | 2 +- contracts/test/tests/TestMemoryHelpers.sol | 85 ++++++++++++++++++++ contracts/test/tests/TestRelayerCalldata.sol | 49 +++++++++++ test/contracts/common/memory_helpers.js | 3 + test/contracts/relayer/relayer_calldata.js | 3 + 7 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 contracts/test/tests/TestMemoryHelpers.sol create mode 100644 contracts/test/tests/TestRelayerCalldata.sol create mode 100644 test/contracts/common/memory_helpers.js create mode 100644 test/contracts/relayer/relayer_calldata.js diff --git a/contracts/common/MemoryHelpers.sol b/contracts/common/MemoryHelpers.sol index 98ecd6872..63376d200 100644 --- a/contracts/common/MemoryHelpers.sol +++ b/contracts/common/MemoryHelpers.sol @@ -28,7 +28,7 @@ library MemoryHelpers { } // From https://github.com/Arachnid/solidity-stringutils/blob/master/src/strings.sol - function memcpy(uint256 dest, uint256 src, uint256 len) private pure { + function memcpy(uint256 dest, uint256 src, uint256 len) internal pure { // Copy word-length chunks while possible for(; len >= 32; len -= 32) { assembly { diff --git a/contracts/relayer/RelayedAragonApp.sol b/contracts/relayer/RelayedAragonApp.sol index 4ccba686e..1b133e69d 100644 --- a/contracts/relayer/RelayedAragonApp.sol +++ b/contracts/relayer/RelayedAragonApp.sol @@ -15,8 +15,15 @@ contract RelayedAragonApp is AragonApp { } function _decodeSigner() internal returns (address signer) { - bytes memory calldata = msg.data; - assembly { signer := mload(add(calldata, calldatasize)) } + // 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 returns (IRelayer) { diff --git a/contracts/relayer/Relayer.sol b/contracts/relayer/Relayer.sol index ced51ea78..1c91eaf83 100644 --- a/contracts/relayer/Relayer.sol +++ b/contracts/relayer/Relayer.sol @@ -89,7 +89,7 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage { return keccak256(abi.encodePacked(keccak256(calldata), nonce)); } - function relayCall(address from, address to, bytes calldata) private { + function relayCall(address from, address to, bytes calldata) internal { bytes memory encodedSignerCalldata = calldata.append(from); assembly { let success := call(gas, to, 0, add(encodedSignerCalldata, 0x20), mload(encodedSignerCalldata), 0, 0) 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..72f3ae44b --- /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 x, bytes32 y, string z) 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/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/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') From 464ee8fab58fa1620b7d88a2d6244ca093df4dc9 Mon Sep 17 00:00:00 2001 From: Facundo Spagnuolo Date: Tue, 14 May 2019 19:55:16 -0300 Subject: [PATCH 10/19] meta-txs: implement gas refunds quotas --- contracts/relayer/IRelayer.sol | 2 +- contracts/relayer/Relayer.sol | 82 ++-- .../test/mocks/common/TimeHelpersMock.sol | 55 +++ contracts/test/mocks/relayer/RelayerMock.sol | 9 + test/contracts/relayer/relayer.js | 420 ++++++++++++++++-- 5 files changed, 502 insertions(+), 66 deletions(-) create mode 100644 contracts/test/mocks/relayer/RelayerMock.sol diff --git a/contracts/relayer/IRelayer.sol b/contracts/relayer/IRelayer.sol index 1a4aaa869..448cdb437 100644 --- a/contracts/relayer/IRelayer.sol +++ b/contracts/relayer/IRelayer.sol @@ -2,5 +2,5 @@ pragma solidity ^0.4.24; contract IRelayer { - function relay(address from, address to, uint256 nonce, bytes calldata, bytes signature) external; + function relay(address from, address to, uint256 nonce, bytes data, uint256 gasRefund, uint256 gasPrice, bytes signature) external; } diff --git a/contracts/relayer/Relayer.sol b/contracts/relayer/Relayer.sol index 1c91eaf83..de1712de2 100644 --- a/contracts/relayer/Relayer.sol +++ b/contracts/relayer/Relayer.sol @@ -3,21 +3,24 @@ pragma solidity ^0.4.24; import "./IRelayer.sol"; import "./RelayedAragonApp.sol"; import "../lib/sig/ECDSA.sol"; +import "../lib/math/SafeMath.sol"; import "../apps/AragonApp.sol"; +import "../common/IsContract.sol"; +import "../common/TimeHelpers.sol"; import "../common/MemoryHelpers.sol"; import "../common/DepositableStorage.sol"; contract Relayer is IRelayer, AragonApp, DepositableStorage { using ECDSA for bytes32; + using SafeMath for uint256; using MemoryHelpers for bytes; bytes32 public constant ALLOW_OFF_CHAIN_SERVICE_ROLE = keccak256("ALLOW_OFF_CHAIN_SERVICE_ROLE"); bytes32 public constant DISALLOW_OFF_CHAIN_SERVICE_ROLE = keccak256("DISALLOW_OFF_CHAIN_SERVICE_ROLE"); - uint256 private constant EXTERNAL_TX_COST = 21000; - 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_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"; @@ -26,34 +29,40 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage { event ServiceDisallowed(address indexed service); event TransactionRelayed(address from, address to, uint256 nonce, bytes calldata); + uint256 public startDate; + uint256 public monthlyRefundQuota; mapping (address => bool) internal allowedServices; + mapping (address => uint256) internal totalRefunds; mapping (address => uint256) internal lastUsedNonce; modifier onlyAllowedServices() { - require(allowedServices[msg.sender], ERROR_SERVICE_NOT_ALLOWED); + require(isServiceAllowed(msg.sender), ERROR_SERVICE_NOT_ALLOWED); _; } - modifier refundGas() { - uint256 startGas = gasleft(); - _; - uint256 totalGas = EXTERNAL_TX_COST + startGas - gasleft(); - uint256 refund = totalGas * tx.gasprice; - require(msg.sender.send(refund), ERROR_GAS_REFUND_FAIL); - } - - function initialize() public onlyInit { + function initialize(uint256 _monthlyRefundQuota) public onlyInit { initialized(); + startDate = getTimestamp(); + monthlyRefundQuota = _monthlyRefundQuota; setDepositable(true); } - function relay(address from, address to, uint256 nonce, bytes calldata, bytes signature) external refundGas onlyAllowedServices { - assertValidTransaction(from, nonce, calldata, signature); + function relay(address from, address to, uint256 nonce, bytes data, uint256 gasRefund, uint256 gasPrice, bytes signature) + external + onlyAllowedServices + { + uint256 refund = gasRefund.mul(gasPrice); + require(canRefund(from, refund), ERROR_GAS_QUOTA_EXCEEDED); + require(!isNonceUsed(from, nonce), ERROR_NONCE_ALREADY_USED); + require(isValidSignature(from, messageHash(to, nonce, data, gasRefund, gasPrice), signature), ERROR_INVALID_SENDER_SIGNATURE); + totalRefunds[from] = totalRefunds[from].add(refund); lastUsedNonce[from] = nonce; - relayCall(from, to, calldata); - emit TransactionRelayed(from, to, nonce, calldata); - forwardReturnedData(); + + relayCall(from, to, data); + emit TransactionRelayed(from, to, nonce, data); + + require(msg.sender.send(refund), ERROR_GAS_REFUND_FAIL); } function allowService(address service) external authP(ALLOW_OFF_CHAIN_SERVICE_ROLE, arr(service)) { @@ -71,13 +80,26 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage { return token != ETH; } + function isServiceAllowed(address service) public view returns (bool) { + return allowedServices[service]; + } + + function getLastUsedNonce(address sender) public view returns (uint256) { + return lastUsedNonce[sender]; + } + + function getTotalRefunds(address sender) public view returns (uint256) { + return totalRefunds[sender]; + } + function isNonceUsed(address sender, uint256 nonce) public view returns (bool) { - return lastUsedNonce[sender] >= nonce; + return getLastUsedNonce(sender) >= nonce; } - function assertValidTransaction(address from, uint256 nonce, bytes calldata, bytes signature) internal view { - require(!isNonceUsed(from, nonce), ERROR_NONCE_ALREADY_USED); - require(isValidSignature(from, messageHash(calldata, nonce), signature), ERROR_INVALID_SENDER_SIGNATURE); + function canRefund(address sender, uint256 refund) public view returns (bool) { + uint256 monthsSinceStart = (getTimestamp().sub(startDate) / (30 days)) + 1; + uint256 maxRefunds = monthsSinceStart.mul(monthlyRefundQuota); + return getTotalRefunds(sender).add(refund) <= maxRefunds; } function isValidSignature(address sender, bytes32 hash, bytes signature) internal pure returns (bool) { @@ -85,14 +107,14 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage { return sender == signer; } - function messageHash(bytes calldata, uint256 nonce) internal pure returns (bytes32) { - return keccak256(abi.encodePacked(keccak256(calldata), nonce)); + function messageHash(address to, uint256 nonce, bytes data, uint256 gasRefund, uint256 gasPrice) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(to, nonce, keccak256(data), gasRefund, gasPrice)); } - function relayCall(address from, address to, bytes calldata) internal { - bytes memory encodedSignerCalldata = calldata.append(from); + function relayCall(address from, address to, bytes data) internal { + bytes memory encodedSignerData = data.append(from); assembly { - let success := call(gas, to, 0, add(encodedSignerCalldata, 0x20), mload(encodedSignerCalldata), 0, 0) + 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) @@ -100,12 +122,4 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage { } } } - - function forwardReturnedData() private { - assembly { - let ptr := mload(0x40) - returndatacopy(ptr, 0, returndatasize) - return(ptr, returndatasize) - } - } } 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/relayer/RelayerMock.sol b/contracts/test/mocks/relayer/RelayerMock.sol new file mode 100644 index 000000000..2374baf6b --- /dev/null +++ b/contracts/test/mocks/relayer/RelayerMock.sol @@ -0,0 +1,9 @@ +pragma solidity 0.4.24; + +import "../../../relayer/Relayer.sol"; +import "../../../test/mocks/common/TimeHelpersMock.sol"; + + +contract RelayerMock is Relayer, TimeHelpersMock { + // solium-disable-previous-line no-empty-blocks +} diff --git a/test/contracts/relayer/relayer.js b/test/contracts/relayer/relayer.js index 266f3b071..5cacc06b9 100644 --- a/test/contracts/relayer/relayer.js +++ b/test/contracts/relayer/relayer.js @@ -1,17 +1,24 @@ const { sha3, soliditySha3 } = require('web3-utils') const { assertRevert } = require('../../helpers/assertThrow') 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('Relayer') +const Relayer = artifacts.require('RelayerMock') const DAOFactory = artifacts.require('DAOFactory') const SampleApp = artifacts.require('RelayedAppMock') -contract('RelayedApp', ([_, root, member, anyone, vault, offChainRelayerService]) => { - let daoFactory, dao, acl, app, relayer, relayedTx, nonce = 0 +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' + +contract('Relayer', ([_, root, member, anyone, vault, offChainRelayerService]) => { + let daoFactory, dao, acl, app, relayer, nextNonce = 1 let kernelBase, aclBase, sampleAppBase, relayerBase - let WRITING_ROLE, APP_MANAGER_ROLE, ALLOW_OFF_CHAIN_SERVICE_ROLE, RELAYER_APP_ID + let WRITING_ROLE, APP_MANAGER_ROLE, ALLOW_OFF_CHAIN_SERVICE_ROLE, DISALLOW_OFF_CHAIN_SERVICE_ROLE, RELAYER_APP_ID + + const GAS_PRICE = 1e9 + const MONTHLY_REFUND_GAS = 1e6 * 5 + const MONTHLY_REFUND_QUOTA = MONTHLY_REFUND_GAS * GAS_PRICE before('deploy base implementations', async () => { aclBase = await ACL.new() @@ -26,6 +33,7 @@ contract('RelayedApp', ([_, root, member, anyone, vault, offChainRelayerService] WRITING_ROLE = await sampleAppBase.WRITING_ROLE() APP_MANAGER_ROLE = await kernelBase.APP_MANAGER_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 () => { @@ -39,11 +47,10 @@ contract('RelayedApp', ([_, root, member, anyone, vault, offChainRelayerService] before('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.initialize() + await relayer.initialize(MONTHLY_REFUND_QUOTA) - const SEND_ETH_GAS = 31000 // 21k base tx cost + 10k limit on depositable proxies - await relayer.sendTransaction({ from: vault, value: 1e18, gas: SEND_ETH_GAS }) 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 }) await relayer.allowService(offChainRelayerService, { from: root }) }) @@ -55,45 +62,396 @@ contract('RelayedApp', ([_, root, member, anyone, vault, offChainRelayerService] await acl.createPermission(member, app.address, WRITING_ROLE, root, { from: root }) }) - beforeEach('increment nonce', () => nonce++) + beforeEach('increment nonce and time by one month', async () => { + nextNonce++ + await relayer.mockIncreaseTime(60 * 60 * 24 * 31) + }) + + describe('relay', () => { + let calldata, signature, gasRefund = 50000 + + context('when the sender is an allowed service', () => { + const from = offChainRelayerService + + context('when the signature valid', () => { + context('when the sender is authorized', () => { + const sender = member + + context('when the nonce is not used', () => { + context('when the sender can refund requested gas amount', () => { + beforeEach('build tx data', async () => { + calldata = app.contract.write.getData(10) + const messageHash = soliditySha3(app.address, nextNonce, sha3(calldata), gasRefund, GAS_PRICE) + signature = web3.eth.sign(sender, messageHash) + }) + + context('when the relayer does not have funds', () => { + it('reverts', async () => { + await assertRevert(relayer.relay(sender, app.address, nextNonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_GAS_REFUND_FAIL') + }) + }) + + context('when the relayer has funds', () => { + before('fund relayer', async () => { + const SEND_ETH_GAS = 31000 // 21k base tx cost + 10k limit on depositable proxies + await web3.eth.sendTransaction({ from: vault, to: relayer.address, value: 1e18, gas: SEND_ETH_GAS }) + }) + + it('relays transactions to app', async () => { + await relayer.relay(sender, app.address, nextNonce, 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(sender, app.address, nextNonce, 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 and refunds of the signer', async () => { + const previousTotalRefunds = await relayer.getTotalRefunds(sender) + await relayer.relay(sender, app.address, nextNonce, calldata, gasRefund, GAS_PRICE, signature, { from }) + + assert.isTrue(await relayer.isNonceUsed(sender, nextNonce), 'last nonce should have been updated') + assert.isFalse(await relayer.isNonceUsed(sender, nextNonce + 1), 'next nonce should not be used') + + const txRefund = gasRefund * GAS_PRICE + const currentTotalRefunds = await relayer.getTotalRefunds(sender) + assert.equal(previousTotalRefunds.toString(), currentTotalRefunds.minus(txRefund).toString(), 'total refunds should have been updated') + }) + + it.only('emits an event', async () => { + const receipt = await relayer.relay(sender, app.address, nextNonce, calldata, gasRefund, GAS_PRICE, signature, { from }) + + assertAmountOfEvents(receipt, 'TransactionRelayed') + assertEvent(receipt, 'TransactionRelayed', { from: sender, to: app.address, nonce: nextNonce, calldata }) + }) + + it.only('overloads a transaction with ~50k of gas', async () => { + const { receipt: { cumulativeGasUsed: relayedGasUsed } } = await relayer.relay(sender, app.address, nextNonce, calldata, gasRefund, GAS_PRICE, signature, { from }) + const { receipt: { cumulativeGasUsed: nonRelayerGasUsed } } = await app.write(10, { from: sender }) + + const gasOverload = relayedGasUsed - nonRelayerGasUsed + console.log('relayedGasUsed:', relayedGasUsed) + console.log('nonRelayerGasUsed:', nonRelayerGasUsed) + console.log('gasOverload:', gasOverload) + + assert.isBelow(gasOverload, 50000, 'relayed txs gas overload is higher than 50k') + }) + }) + }) + + context('when the sender can not refund requested gas amount', () => { + const hugeGasRefund = MONTHLY_REFUND_QUOTA + 1 + + beforeEach('build tx data', async () => { + calldata = app.contract.write.getData(10) + const messageHash = soliditySha3(app.address, nextNonce, sha3(calldata), hugeGasRefund, GAS_PRICE) + signature = web3.eth.sign(sender, messageHash) + }) + + it('reverts', async () => { + await assertRevert(relayer.relay(sender, app.address, nextNonce, calldata, hugeGasRefund, GAS_PRICE, signature, { from }), 'RELAYER_GAS_QUOTA_EXCEEDED') + }) + }) + }) + + context('when the nonce is already used', () => { + beforeEach('build tx data', async () => { + calldata = app.contract.write.getData(10) + const messageHash = soliditySha3(app.address, nextNonce - 3, sha3(calldata), gasRefund, GAS_PRICE) + signature = web3.eth.sign(sender, messageHash) + }) + + it('reverts', async () => { + await assertRevert(relayer.relay(sender, app.address, nextNonce - 3, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_NONCE_ALREADY_USED') + }) + }) + }) + + context('when the sender is not authorized', () => { + const sender = anyone + + it('reverts', async () => { + calldata = app.contract.write.getData(10) + const messageHash = soliditySha3(app.address, nextNonce, sha3(calldata), gasRefund, GAS_PRICE) + signature = web3.eth.sign(sender, messageHash) + + await assertRevert(relayer.relay(sender, app.address, nextNonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'APP_AUTH_FAILED') + }) + }) + }) + + context('when the signature is not valid', () => { + calldata = '0x0' + + context('when the sender is authorized', () => { + const sender = member + + it('reverts', async () => { + const messageHash = soliditySha3("bla") + const signature = web3.eth.sign(sender, messageHash) + + await assertRevert(relayer.relay(sender, anyone, nextNonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_INVALID_SENDER_SIGNATURE') + }) + }) + + context('when the sender is not authorized', () => { + const sender = anyone + + it('reverts', async () => { + const messageHash = soliditySha3("bla") + const signature = web3.eth.sign(sender, messageHash) + + await assertRevert(relayer.relay(sender, anyone, nextNonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_INVALID_SENDER_SIGNATURE') + }) + }) + }) + }) + + context('when the sender is not an allowed service', () => { + calldata = '0x0' + const from = anyone + + it('reverts', async () => { + await assertRevert(relayer.relay(member, anyone, nextNonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_SERVICE_NOT_ALLOWED') + }) + }) + }) + + describe('getLastUsedNonce', () => { + context('when the given sender has already sent some transactions', () => { + const account = member + + it('returns the last nonce', async () => { + assert.isTrue((await relayer.getLastUsedNonce(account)).gt(0), 'last nonce does not match') + }) + }) + + context('when the given sender did not send transactions yet', () => { + const account = anyone + + it('returns zero', async () => { + assert.equal(await relayer.getLastUsedNonce(account), 0, 'last nonce does not match') + }) + }) + }) + + describe('isNonceUsed', () => { + const sender = member + + context('when the requested nonce is zero', () => { + const nonce = 0 + + context('when the requested sender is the actual sender', () => { + const account = sender + + it('returns true', async () => { + assert.isTrue(await relayer.isNonceUsed(account, nonce), 'nonce should be used') + }) + }) + + context('when the requested sender is another account', () => { + const account = anyone + + it('returns true', async () => { + assert.isTrue(await relayer.isNonceUsed(account, nonce), 'nonce should be used') + }) + }) + }) + + context('when the requested nonce is greater than zero but lower than the nonce used', () => { + const nonce = 1 + + context('when the requested sender is the actual sender', () => { + const account = sender + + it('returns true', async () => { + assert.isTrue(await relayer.isNonceUsed(account, nonce), 'nonce should be used') + }) + }) + + context('when the requested sender is another account', () => { + const account = anyone + + it('returns false', async () => { + assert.isFalse(await relayer.isNonceUsed(account, nonce), 'nonce should not be used') + }) + }) + }) + + context('when the requested nonce is equal to the nonce used', () => { + let nonce + + beforeEach('set nonce', async () => nonce = await relayer.getLastUsedNonce(sender)) + + context('when the requested sender is the actual sender', () => { + const account = sender + + it('returns true', async () => { + assert.isTrue(await relayer.isNonceUsed(account, nonce), 'nonce should be used') + }) + }) + + context('when the requested sender is another account', () => { + const account = anyone + + it('returns false', async () => { + assert.isFalse(await relayer.isNonceUsed(account, nonce), 'nonce should not be used') + }) + }) + }) + + context('when the requested nonce is greater than the nonce used', () => { + let nonce + + beforeEach('set nonce', async () => nonce = (await relayer.getLastUsedNonce(sender)).plus(1)) + + context('when the requested sender is the actual sender', () => { + const account = sender + + it('returns false', async () => { + assert.isFalse(await relayer.isNonceUsed(account, nonce), 'nonce should not be used') + }) + }) + + context('when the requested sender is another account', () => { + const account = anyone + + it('returns false', async () => { + assert.isFalse(await relayer.isNonceUsed(account, nonce), 'nonce should not be used') + }) + }) + }) + }) + + describe('getTotalRefunds', () => { + context('when the given sender has already sent some transactions', () => { + const account = member + + it('returns the total refunds amount', async () => { + assert.isTrue((await relayer.getTotalRefunds(account)).gt(0), 'total refunds do not match') + }) + }) + + context('when the given sender did not send transactions yet', () => { + const account = anyone - context('when the sender is authorized', () => { + it('returns zero', async () => { + assert.equal(await relayer.getTotalRefunds(account), 0, 'total refunds do not match') + }) + }) + }) + + describe('canRefund', () => { + let remainingRefunds const sender = member - beforeEach('relay transaction', async () => { - const calldata = app.contract.write.getData(10) - const messageHash = soliditySha3(sha3(calldata), nonce) - const signature = web3.eth.sign(sender, messageHash) + beforeEach('fetch total refunds', async () => { + remainingRefunds = new web3.BigNumber(MONTHLY_REFUND_QUOTA).minus(await relayer.getTotalRefunds(sender)) + }) + + context('when the requested amount does not exceed the monthly quota', () => { + it('returns true', async () => { + assert.isTrue(await relayer.canRefund(sender, remainingRefunds.minus(1)), 'should be allowed to spend amount') + }) + }) - relayedTx = await relayer.relay(sender, app.address, nonce, calldata, signature, { from: offChainRelayerService }) + context('when the requested amount is equal to the monthly quota', () => { + it('returns true', async () => { + assert.isTrue(await relayer.canRefund(sender, remainingRefunds), 'should be allowed to spend amount') + }) }) - it('relays transactions to app', async () => { - assert.equal((await app.read()).toString(), 10, 'app value does not match') + context('when the requested amount is greater than the monthly quota', () => { + it('returns true', async () => { + assert.isTrue(await relayer.canRefund(sender, remainingRefunds.plus(1)), 'should not be allowed to spend amount') + }) }) + }) - it('overloads a transaction with ~34k of gas', async () => { - const { receipt: { cumulativeGasUsed: relayedGasUsed } } = relayedTx - const { receipt: { cumulativeGasUsed: nonRelayerGasUsed } } = await app.write(10, { from: sender }) + describe('isDepositable', () => { + it('returns true', async () => { + assert.isTrue(await relayer.isDepositable(), 'should be depositable') + }) + }) - const gasOverload = relayedGasUsed - nonRelayerGasUsed - console.log('relayedGasUsed:', relayedGasUsed) - console.log('nonRelayerGasUsed:', nonRelayerGasUsed) - console.log('gasOverload:', gasOverload) + describe('allowRecoverability', () => { + context('when the token is ETH', () => { + it('returns false', async () => { + assert.isFalse(await relayer.allowRecoverability(ZERO_ADDRESS), 'should not allow ETH recoverability') + }) + }) - assert.isBelow(gasOverload, 34000, 'relayed txs gas overload is higher than 34k') + context('when the token is not ETH', () => { + it('returns true', async () => { + assert.isTrue(await relayer.allowRecoverability(anyone), 'should allow tokens recoverability') + }) }) }) - context('when the sender is not authorized', () => { - const sender = anyone + describe('allowService', () => { + context('when the sender is allowed', () => { + const from = root + + it('adds a new allowed service', async () => { + await relayer.allowService(anyone, { from }) + + assert(await relayer.isServiceAllowed(anyone), 'service should be allowed') + }) + + it('emits an event', async () => { + const receipt = await relayer.allowService(anyone, { from }) + + assertAmountOfEvents(receipt, 'ServiceAllowed') + assertEvent(receipt, 'ServiceAllowed', { service: anyone }) + }) + }) + + context('when the sender is not allowed', () => { + const from = anyone + + it('reverts', async () => { + await assertRevert(relayer.allowService(anyone, { from }), 'APP_AUTH_FAILED') + }) + }) + }) + + describe('disallowService', () => { + context('when the sender is allowed', () => { + const from = root + + it('adds a new allowed service', async () => { + await relayer.disallowService(anyone, { from }) + + assert.isFalse(await relayer.isServiceAllowed(anyone), 'service should not be allowed') + }) + + it('emits an event', async () => { + const receipt = await relayer.disallowService(anyone, { from }) + + assertAmountOfEvents(receipt, 'ServiceDisallowed') + assertEvent(receipt, 'ServiceDisallowed', { service: anyone }) + }) + }) - it('reverts', async () => { - const calldata = app.contract.write.getData(10) - const messageHash = soliditySha3(sha3(calldata), nonce) - const signature = web3.eth.sign(sender, messageHash) + context('when the sender is not allowed', () => { + const from = anyone - await assertRevert(relayer.relay(sender, app.address, nonce, calldata, signature, { from: offChainRelayerService }), 'APP_AUTH_FAILED') + it('reverts', async () => { + await assertRevert(relayer.disallowService(anyone, { from }), 'APP_AUTH_FAILED') + }) }) }) }) From d528882dc9bca7d99e362f7d81df2ee84e701b82 Mon Sep 17 00:00:00 2001 From: Facundo Spagnuolo Date: Tue, 14 May 2019 20:24:10 -0300 Subject: [PATCH 11/19] meta-txs: fix linting rules --- contracts/common/MemoryHelpers.sol | 8 ++++++-- contracts/relayer/RelayedAragonApp.sol | 4 +++- contracts/relayer/Relayer.sol | 3 ++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/contracts/common/MemoryHelpers.sol b/contracts/common/MemoryHelpers.sol index 63376d200..a7e398e09 100644 --- a/contracts/common/MemoryHelpers.sol +++ b/contracts/common/MemoryHelpers.sol @@ -28,9 +28,13 @@ library MemoryHelpers { } // From https://github.com/Arachnid/solidity-stringutils/blob/master/src/strings.sol - function memcpy(uint256 dest, uint256 src, uint256 len) internal pure { + 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) { + for (; len >= 32; len -= 32) { assembly { mstore(dest, mload(src)) } diff --git a/contracts/relayer/RelayedAragonApp.sol b/contracts/relayer/RelayedAragonApp.sol index 1b133e69d..1c794a739 100644 --- a/contracts/relayer/RelayedAragonApp.sol +++ b/contracts/relayer/RelayedAragonApp.sol @@ -8,7 +8,9 @@ contract RelayedAragonApp is AragonApp { function sender() internal view returns (address) { address relayer = address(_relayer()); - if (msg.sender != relayer) return msg.sender; + if (msg.sender != relayer) { + return msg.sender; + } address signer = _decodeSigner(); return signer != address(0) ? signer : relayer; diff --git a/contracts/relayer/Relayer.sol b/contracts/relayer/Relayer.sol index de1712de2..108a1b230 100644 --- a/contracts/relayer/Relayer.sol +++ b/contracts/relayer/Relayer.sol @@ -40,7 +40,7 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage { _; } - function initialize(uint256 _monthlyRefundQuota) public onlyInit { + function initialize(uint256 _monthlyRefundQuota) external onlyInit { initialized(); startDate = getTimestamp(); monthlyRefundQuota = _monthlyRefundQuota; @@ -62,6 +62,7 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage { relayCall(from, to, data); emit TransactionRelayed(from, to, nonce, data); + /* solium-disable security/no-send */ require(msg.sender.send(refund), ERROR_GAS_REFUND_FAIL); } From ec06318ede0eeae13df2f63fb99a963ab24ceaed Mon Sep 17 00:00:00 2001 From: Facundo Spagnuolo Date: Tue, 14 May 2019 20:26:19 -0300 Subject: [PATCH 12/19] meta-txs: skip gas test for coverage measure --- test/contracts/relayer/relayer.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/contracts/relayer/relayer.js b/test/contracts/relayer/relayer.js index 5cacc06b9..d4321ae83 100644 --- a/test/contracts/relayer/relayer.js +++ b/test/contracts/relayer/relayer.js @@ -1,3 +1,4 @@ +const { skipCoverage } = require('../../helpers/coverage') const { sha3, soliditySha3 } = require('web3-utils') const { assertRevert } = require('../../helpers/assertThrow') const { getEventArgument, getNewProxyAddress } = require('../../helpers/events') @@ -131,14 +132,14 @@ contract('Relayer', ([_, root, member, anyone, vault, offChainRelayerService]) = assert.equal(previousTotalRefunds.toString(), currentTotalRefunds.minus(txRefund).toString(), 'total refunds should have been updated') }) - it.only('emits an event', async () => { + it('emits an event', async () => { const receipt = await relayer.relay(sender, app.address, nextNonce, calldata, gasRefund, GAS_PRICE, signature, { from }) assertAmountOfEvents(receipt, 'TransactionRelayed') assertEvent(receipt, 'TransactionRelayed', { from: sender, to: app.address, nonce: nextNonce, calldata }) }) - it.only('overloads a transaction with ~50k of gas', async () => { + it('overloads a transaction with ~50k of gas', skipCoverage(async () => { const { receipt: { cumulativeGasUsed: relayedGasUsed } } = await relayer.relay(sender, app.address, nextNonce, calldata, gasRefund, GAS_PRICE, signature, { from }) const { receipt: { cumulativeGasUsed: nonRelayerGasUsed } } = await app.write(10, { from: sender }) @@ -148,7 +149,7 @@ contract('Relayer', ([_, root, member, anyone, vault, offChainRelayerService]) = console.log('gasOverload:', gasOverload) assert.isBelow(gasOverload, 50000, 'relayed txs gas overload is higher than 50k') - }) + })) }) }) From 3d2b814cd4a225e86b1a7b5ef12643c7394f6962 Mon Sep 17 00:00:00 2001 From: Facundo Spagnuolo Date: Tue, 14 May 2019 21:29:32 -0300 Subject: [PATCH 13/19] meta-txs: fix kernel tests --- .../test/mocks/kernel/KernelOverloadMock.sol | 44 +++----- test/contracts/kernel/kernel_apps.js | 103 +++++++----------- test/contracts/kernel/kernel_lifecycle.js | 2 +- 3 files changed, 53 insertions(+), 96 deletions(-) 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/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)) } From dcd3db7a7590fe16276fb00a8ef67c5c23b26439 Mon Sep 17 00:00:00 2001 From: Facundo Spagnuolo Date: Tue, 14 May 2019 22:06:22 -0300 Subject: [PATCH 14/19] meta-txs: increase coverage --- test/contracts/relayer/relayer.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/contracts/relayer/relayer.js b/test/contracts/relayer/relayer.js index d4321ae83..f592cc53e 100644 --- a/test/contracts/relayer/relayer.js +++ b/test/contracts/relayer/relayer.js @@ -68,6 +68,13 @@ contract('Relayer', ([_, root, member, anyone, vault, offChainRelayerService]) = await relayer.mockIncreaseTime(60 * 60 * 24 * 31) }) + 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: anyone }), 'APP_AUTH_FAILED') + }) + describe('relay', () => { let calldata, signature, gasRefund = 50000 From 306d35ccb299e8df7ffd10fafa060e456c235c74 Mon Sep 17 00:00:00 2001 From: Facundo Spagnuolo Date: Wed, 15 May 2019 16:42:14 -0300 Subject: [PATCH 15/19] meta-txs: multiple enhancements --- contracts/relayer/Relayer.sol | 205 ++++- contracts/test/tests/TestRelayerCalldata.sol | 2 +- test/contracts/relayer/relayer.js | 906 +++++++++++++------ 3 files changed, 817 insertions(+), 296 deletions(-) diff --git a/contracts/relayer/Relayer.sol b/contracts/relayer/Relayer.sol index 108a1b230..e89aa6359 100644 --- a/contracts/relayer/Relayer.sol +++ b/contracts/relayer/Relayer.sol @@ -16,30 +16,75 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage { using SafeMath for uint256; using MemoryHelpers for bytes; - bytes32 public constant ALLOW_OFF_CHAIN_SERVICE_ROLE = keccak256("ALLOW_OFF_CHAIN_SERVICE_ROLE"); - bytes32 public constant DISALLOW_OFF_CHAIN_SERVICE_ROLE = keccak256("DISALLOW_OFF_CHAIN_SERVICE_ROLE"); - 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_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"; + // 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); - event TransactionRelayed(address from, address to, uint256 nonce, bytes calldata); - uint256 public startDate; - uint256 public monthlyRefundQuota; + /** + * @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 member + * @param newQuota New monthly refunds quota in ETH for each allowed member + */ + event MonthlyRefundQuotaSet(address indexed who, uint256 previousQuota, uint256 newQuota); + + // Timestamp to start counting monthly refunds quotas for each member + uint256 internal startDate; + + // Monthly refunds quota in ETH for each member + 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 (address => uint256) internal totalRefunds; + + // Mapping from members to nonce numbers that indicates the last nonce used by each member mapping (address => uint256) internal lastUsedNonce; + // Mapping from members to monthly refunds that indicates the refunds requested per member per month + mapping (address => mapping (uint256 => uint256)) internal monthlyRefunds; + + // 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); + 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 member + */ function initialize(uint256 _monthlyRefundQuota) external onlyInit { initialized(); startDate = getTimestamp(); @@ -47,72 +92,174 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage { 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 { - uint256 refund = gasRefund.mul(gasPrice); - require(canRefund(from, refund), ERROR_GAS_QUOTA_EXCEEDED); - require(!isNonceUsed(from, nonce), ERROR_NONCE_ALREADY_USED); - require(isValidSignature(from, messageHash(to, nonce, data, gasRefund, gasPrice), signature), ERROR_INVALID_SENDER_SIGNATURE); + uint256 currentMonth = _getCurrentMonth(); + uint256 requestedRefund = gasRefund.mul(gasPrice); + + require(_canUseNonce(from, nonce), ERROR_NONCE_ALREADY_USED); + require(_canRefund(from, currentMonth, requestedRefund), ERROR_GAS_QUOTA_EXCEEDED); + require(_isValidSignature(from, _messageHash(to, nonce, data, gasRefund, gasPrice), signature), ERROR_INVALID_SENDER_SIGNATURE); - totalRefunds[from] = totalRefunds[from].add(refund); lastUsedNonce[from] = nonce; + monthlyRefunds[from][currentMonth] = monthlyRefunds[from][currentMonth].add(requestedRefund); - relayCall(from, to, data); + _relayCall(from, to, data); emit TransactionRelayed(from, to, nonce, data); /* solium-disable security/no-send */ - require(msg.sender.send(refund), ERROR_GAS_REFUND_FAIL); + 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 Set new monthly refunds quota per address of `@tokenAmount(newQuota, 0x00)`. + * @param newQuota New monthly refunds quota in ETH for each allowed member + */ + function setMonthlyRefundQuota(uint256 newQuota) external authP(SET_MONTHLY_REFUND_QUOTA_ROLE, arr(newQuota)) { + emit MonthlyRefundQuotaSet(msg.sender, monthlyRefundQuota, newQuota); + monthlyRefundQuota = newQuota; + } + + /** + * @notice Return the start date timestamp used to count the monthly refunds quotas for each member. + * @return The start date timestamp used to count the monthly refunds quotas for each member + */ + function getStartDate() external view isInitialized returns (uint256) { + return startDate; + } + + /** + * @notice Return the monthly refunds quotas for each member. + * @return The monthly refunds quotas for each member + */ + 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 Return the last used nonce for a given sender `sender`. + * @return The last used nonce for a given sender + */ + function getLastUsedNonce(address sender) external view isInitialized returns (uint256) { + return _getLastUsedNonce(sender); + } + + /** + * @notice Return the amount of refunds for a given sender `sender` corresponding to month `month`. + * @return The amount of refunds for a given sender in a certain month + */ + function getMonthlyRefunds(address sender, uint256 month) external view isInitialized returns (uint256) { + return _getMonthlyRefunds(sender, month); + } + + /** + * @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` 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 sender, uint256 nonce) external view isInitialized returns (bool) { + return _canUseNonce(sender, 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 sender, uint256 month, uint256 amount) external view isInitialized returns (bool) { + return _canRefund(sender, month, 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 isServiceAllowed(address service) public view returns (bool) { - return allowedServices[service]; + function _getCurrentMonth() internal view returns (uint256) { + uint256 passedSeconds = getTimestamp().sub(startDate); + return passedSeconds / 30 days; } - function getLastUsedNonce(address sender) public view returns (uint256) { + function _getLastUsedNonce(address sender) internal view returns (uint256) { return lastUsedNonce[sender]; } - function getTotalRefunds(address sender) public view returns (uint256) { - return totalRefunds[sender]; + function _getMonthlyRefunds(address sender, uint256 month) internal view returns (uint256) { + return monthlyRefunds[sender][month]; + } + + function _isServiceAllowed(address service) internal view returns (bool) { + return allowedServices[service]; } - function isNonceUsed(address sender, uint256 nonce) public view returns (bool) { - return getLastUsedNonce(sender) >= nonce; + function _canUseNonce(address sender, uint256 nonce) internal view returns (bool) { + return _getLastUsedNonce(sender) < nonce; } - function canRefund(address sender, uint256 refund) public view returns (bool) { - uint256 monthsSinceStart = (getTimestamp().sub(startDate) / (30 days)) + 1; - uint256 maxRefunds = monthsSinceStart.mul(monthlyRefundQuota); - return getTotalRefunds(sender).add(refund) <= maxRefunds; + function _canRefund(address sender, uint256 month, uint256 amount) internal view returns (bool) { + uint256 monthRefunds = _getMonthlyRefunds(sender, month); + return monthRefunds.add(amount) <= monthlyRefundQuota; } - function isValidSignature(address sender, bytes32 hash, bytes signature) internal pure returns (bool) { + function _isValidSignature(address sender, bytes32 hash, bytes signature) internal pure returns (bool) { address signer = hash.toEthSignedMessageHash().recover(signature); return sender == signer; } - function messageHash(address to, uint256 nonce, bytes data, uint256 gasRefund, uint256 gasPrice) internal pure returns (bytes32) { + function _messageHash(address to, uint256 nonce, bytes data, uint256 gasRefund, uint256 gasPrice) internal pure returns (bytes32) { return keccak256(abi.encodePacked(to, nonce, keccak256(data), gasRefund, gasPrice)); } - function relayCall(address from, address to, bytes data) internal { + 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) diff --git a/contracts/test/tests/TestRelayerCalldata.sol b/contracts/test/tests/TestRelayerCalldata.sol index 72f3ae44b..0c796535d 100644 --- a/contracts/test/tests/TestRelayerCalldata.sol +++ b/contracts/test/tests/TestRelayerCalldata.sol @@ -44,6 +44,6 @@ contract TestRelayerCalldata is Relayer { function testSignerEncodedCalls() public { signer = msg.sender; bytes memory calldata = abi.encodeWithSelector(appTest.callme.selector, uint8(15), bytes32(0xf00), "relayed"); - relayCall(signer, address(appTest), calldata); + _relayCall(signer, address(appTest), calldata); } } diff --git a/test/contracts/relayer/relayer.js b/test/contracts/relayer/relayer.js index f592cc53e..13b7d917e 100644 --- a/test/contracts/relayer/relayer.js +++ b/test/contracts/relayer/relayer.js @@ -10,17 +10,26 @@ 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, anyone, vault, offChainRelayerService]) => { - let daoFactory, dao, acl, app, relayer, nextNonce = 1 +contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) => { + let daoFactory, dao, acl, app, relayer let kernelBase, aclBase, sampleAppBase, relayerBase - let WRITING_ROLE, APP_MANAGER_ROLE, ALLOW_OFF_CHAIN_SERVICE_ROLE, DISALLOW_OFF_CHAIN_SERVICE_ROLE, RELAYER_APP_ID + let WRITING_ROLE, APP_MANAGER_ROLE, SET_MONTHLY_REFUND_QUOTA_ROLE, ALLOW_OFF_CHAIN_SERVICE_ROLE, DISALLOW_OFF_CHAIN_SERVICE_ROLE, RELAYER_APP_ID 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 = ({ from, to, nonce, calldata = '0x0', gasRefund, gasPrice = GAS_PRICE }) => { + const messageHash = soliditySha3(to, nonce, sha3(calldata), gasRefund, gasPrice) + return web3.eth.sign(from, messageHash) + } + before('deploy base implementations', async () => { aclBase = await ACL.new() kernelBase = await Kernel.new(true) // petrify immediately @@ -33,6 +42,7 @@ contract('Relayer', ([_, root, member, anyone, vault, offChainRelayerService]) = 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_OFF_CHAIN_SERVICE_ROLE = await relayerBase.ALLOW_OFF_CHAIN_SERVICE_ROLE() DISALLOW_OFF_CHAIN_SERVICE_ROLE = await relayerBase.DISALLOW_OFF_CHAIN_SERVICE_ROLE() }) @@ -45,420 +55,784 @@ contract('Relayer', ([_, root, member, anyone, vault, offChainRelayerService]) = await acl.createPermission(root, dao.address, APP_MANAGER_ROLE, root, { from: root }) }) - before('create relayer instance', async () => { + 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.initialize(MONTHLY_REFUND_QUOTA) + await relayer.mockSetTimestamp(NOW) + + 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 }) - await relayer.allowService(offChainRelayerService, { from: root }) }) - beforeEach('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() + describe('initialize', () => { + it('is not initialized by default', async () => { + assert.isFalse(await relayer.hasInitialized(), 'should not be initialized') + }) - await acl.createPermission(member, app.address, WRITING_ROLE, root, { from: root }) + 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') + }) }) - beforeEach('increment nonce and time by one month', async () => { - nextNonce++ - await relayer.mockIncreaseTime(60 * 60 * 24 * 31) + 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') + }) + }) }) - 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') + 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') + }) + }) - await assertRevert(app.write(10, { from: anyone }), 'APP_AUTH_FAILED') + 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('relay', () => { - let calldata, signature, gasRefund = 50000 + describe('getStartDate', () => { + context('when the app is initialized', () => { - context('when the sender is an allowed service', () => { - const from = offChainRelayerService + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) - context('when the signature valid', () => { - context('when the sender is authorized', () => { - const sender = member + it('returns the start date', async () => { + const startDate = await relayer.getStartDate() + assert.equal(startDate.toString(), NOW, 'start date does not match') + }) + }) - context('when the nonce is not used', () => { - context('when the sender can refund requested gas amount', () => { - beforeEach('build tx data', async () => { - calldata = app.contract.write.getData(10) - const messageHash = soliditySha3(app.address, nextNonce, sha3(calldata), gasRefund, GAS_PRICE) - signature = web3.eth.sign(sender, messageHash) - }) + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.getStartDate(), 'INIT_NOT_INITIALIZED') + }) + }) + }) - context('when the relayer does not have funds', () => { - it('reverts', async () => { - await assertRevert(relayer.relay(sender, app.address, nextNonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_GAS_REFUND_FAIL') - }) - }) + describe('getMonthlyRefundQuota', () => { + context('when the app is initialized', () => { + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) - context('when the relayer has funds', () => { - before('fund relayer', async () => { - const SEND_ETH_GAS = 31000 // 21k base tx cost + 10k limit on depositable proxies - await web3.eth.sendTransaction({ from: vault, to: relayer.address, value: 1e18, gas: SEND_ETH_GAS }) - }) + it('returns the start date', async () => { + const quota = await relayer.getMonthlyRefundQuota() + assert.equal(quota.toString(), MONTHLY_REFUND_QUOTA, 'monthly refunds quota does not match') + }) + }) - it('relays transactions to app', async () => { - await relayer.relay(sender, app.address, nextNonce, calldata, gasRefund, GAS_PRICE, signature, { from }) - assert.equal((await app.read()).toString(), 10, 'app value 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)) - it('refunds the off-chain service', async () => { - const previousRelayerBalance = await web3.eth.getBalance(relayer.address) - const previousServiceBalance = await web3.eth.getBalance(offChainRelayerService) + 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') + }) + }) - const { tx, receipt: { gasUsed } } = await relayer.relay(sender, app.address, nextNonce, calldata, gasRefund, GAS_PRICE, signature, { from }) - const { gasPrice: gasPriceUsed } = await web3.eth.getTransaction(tx) + 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)) - const txRefund = gasRefund * GAS_PRICE - const realTxCost = gasPriceUsed.mul(gasUsed) + it('returns 0', async () => { + const currentMonth = await relayer.getCurrentMonth() + assert.equal(currentMonth.toString(), 0, 'current month quota does not match') + }) + }) - const currentRelayerBalance = await web3.eth.getBalance(relayer.address) - const currentServiceBalance = await web3.eth.getBalance(offChainRelayerService) + context('when it has passed 30 days since its initialization', () => { + beforeEach('increase time by 30 days', async () => await relayer.mockIncreaseTime(ONE_MONTH)) - assert.equal(currentRelayerBalance.toString(), previousRelayerBalance.minus(txRefund).toString()) - assert.equal(currentServiceBalance.toString(), previousServiceBalance.minus(realTxCost).plus(txRefund).toString()) - }) + it('returns 1', async () => { + const currentMonth = await relayer.getCurrentMonth() + assert.equal(currentMonth.toString(), 1, 'current month quota does not match') + }) + }) + }) - it('updates the last nonce and refunds of the signer', async () => { - const previousTotalRefunds = await relayer.getTotalRefunds(sender) - await relayer.relay(sender, app.address, nextNonce, calldata, gasRefund, GAS_PRICE, signature, { from }) + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.getCurrentMonth(), 'INIT_NOT_INITIALIZED') + }) + }) + }) - assert.isTrue(await relayer.isNonceUsed(sender, nextNonce), 'last nonce should have been updated') - assert.isFalse(await relayer.isNonceUsed(sender, nextNonce + 1), 'next nonce should not be used') + describe('getLastUsedNonce', () => { + context('when the app is initialized', () => { + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) - const txRefund = gasRefund * GAS_PRICE - const currentTotalRefunds = await relayer.getTotalRefunds(sender) - assert.equal(previousTotalRefunds.toString(), currentTotalRefunds.minus(txRefund).toString(), 'total refunds should have been updated') - }) + context('when the given sender did not send transactions yet', () => { + it('returns zero', async () => { + assert.equal((await relayer.getLastUsedNonce(member)).toString(), 0, 'last nonce does not match') + }) + }) - it('emits an event', async () => { - const receipt = await relayer.relay(sender, app.address, nextNonce, calldata, gasRefund, GAS_PRICE, signature, { from }) + context('when the given sender has already sent some transactions', () => { + beforeEach('relay a transaction', async () => { + const nonce = 2 + const calldata = '0x11111111' + const gasRefund = 50000 + const signature = signRelayedTx({ from: member, to: someone, nonce, calldata, gasRefund }) - assertAmountOfEvents(receipt, 'TransactionRelayed') - assertEvent(receipt, 'TransactionRelayed', { from: sender, to: app.address, nonce: nextNonce, calldata }) - }) + await relayer.allowService(offChainRelayerService, { from: root }) + await web3.eth.sendTransaction({ from: vault, to: relayer.address, value: 1e18, gas: SEND_ETH_GAS }) + await relayer.relay(member, someone, nonce, calldata, gasRefund, GAS_PRICE, signature, { from: offChainRelayerService }) + }) + + it('returns the last nonce', async () => { + assert.equal((await relayer.getLastUsedNonce(member)).toString(), 2, 'last nonce does not match') + }) + }) + }) + + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.getLastUsedNonce(member), 'INIT_NOT_INITIALIZED') + }) + }) + }) - it('overloads a transaction with ~50k of gas', skipCoverage(async () => { - const { receipt: { cumulativeGasUsed: relayedGasUsed } } = await relayer.relay(sender, app.address, nextNonce, calldata, gasRefund, GAS_PRICE, signature, { from }) - const { receipt: { cumulativeGasUsed: nonRelayerGasUsed } } = await app.write(10, { from: sender }) + describe('getMonthlyRefunds', () => { + const month = 0 - const gasOverload = relayedGasUsed - nonRelayerGasUsed - console.log('relayedGasUsed:', relayedGasUsed) - console.log('nonRelayerGasUsed:', nonRelayerGasUsed) - console.log('gasOverload:', gasOverload) + context('when the app is initialized', () => { + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) - assert.isBelow(gasOverload, 50000, 'relayed txs gas overload is higher than 50k') - })) - }) - }) + context('when the given sender did not send transactions yet', () => { + it('returns zero', async () => { + assert.equal((await relayer.getMonthlyRefunds(member, month)).toString(), 0, 'monthly refunds do not match') + }) + }) - context('when the sender can not refund requested gas amount', () => { - const hugeGasRefund = MONTHLY_REFUND_QUOTA + 1 + context('when the given sender has already sent some transactions', () => { + const gasRefund = 50000 - beforeEach('build tx data', async () => { - calldata = app.contract.write.getData(10) - const messageHash = soliditySha3(app.address, nextNonce, sha3(calldata), hugeGasRefund, GAS_PRICE) - signature = web3.eth.sign(sender, messageHash) - }) + beforeEach('relay a transaction', async () => { + const nonce = 2 + const calldata = '0x11111111' + const signature = signRelayedTx({ from: member, to: someone, nonce, calldata, gasRefund }) - it('reverts', async () => { - await assertRevert(relayer.relay(sender, app.address, nextNonce, calldata, hugeGasRefund, GAS_PRICE, signature, { from }), 'RELAYER_GAS_QUOTA_EXCEEDED') - }) - }) + await web3.eth.sendTransaction({ from: vault, to: relayer.address, value: 1e18, gas: SEND_ETH_GAS }) + await relayer.allowService(offChainRelayerService, { from: root }) + await relayer.relay(member, someone, nonce, calldata, gasRefund, GAS_PRICE, signature, { from: offChainRelayerService }) + }) + + it('returns the last nonce', async () => { + assert.equal((await relayer.getMonthlyRefunds(member, month)).toString(), gasRefund * GAS_PRICE, 'monthly refunds do not match') + }) + + it('returns zero for the next month', async () => { + assert.equal((await relayer.getMonthlyRefunds(member, month + 1)).toString(), 0, 'monthly refunds do not match') + }) + }) + }) + + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.getMonthlyRefunds(member, month), '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 nonce is already used', () => { - beforeEach('build tx data', async () => { - calldata = app.contract.write.getData(10) - const messageHash = soliditySha3(app.address, nextNonce - 3, sha3(calldata), gasRefund, GAS_PRICE) - signature = web3.eth.sign(sender, messageHash) + 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 be allowed') + }) + }) + }) + + context('when the given address was never allowed', () => { + it('returns false', async () => { + assert.isFalse(await relayer.isServiceAllowed(offChainRelayerService), 'off chain service should be allowed') + }) + }) + }) + + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.isServiceAllowed(offChainRelayerService), '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 = 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.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') }) + }) - it('reverts', async () => { - await assertRevert(relayer.relay(sender, app.address, nextNonce - 3, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_NONCE_ALREADY_USED') + 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 sender is not authorized', () => { - const sender = anyone + context('when the requested nonce is greater than zero but lower than the nonce used', () => { + const nonce = usedNonce - 1 - it('reverts', async () => { - calldata = app.contract.write.getData(10) - const messageHash = soliditySha3(app.address, nextNonce, sha3(calldata), gasRefund, GAS_PRICE) - signature = web3.eth.sign(sender, messageHash) + context('when the requested sender is the actual sender', () => { + const sender = member - await assertRevert(relayer.relay(sender, app.address, nextNonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'APP_AUTH_FAILED') + 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 signature is not valid', () => { - calldata = '0x0' + context('when the requested nonce is equal to the nonce used', () => { + const nonce = usedNonce - context('when the sender is authorized', () => { - const sender = member + context('when the requested sender is the actual sender', () => { + const sender = member - it('reverts', async () => { - const messageHash = soliditySha3("bla") - const signature = web3.eth.sign(sender, messageHash) + it('returns false', async () => { + assert.isFalse(await relayer.canUseNonce(sender, nonce), 'should not be allowed to use given nonce') + }) + }) - await assertRevert(relayer.relay(sender, anyone, nextNonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_INVALID_SENDER_SIGNATURE') + 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 sender is not authorized', () => { - const sender = anyone + context('when the requested nonce is greater than the nonce used', () => { + let nonce = usedNonce + 1 - it('reverts', async () => { - const messageHash = soliditySha3("bla") - const signature = web3.eth.sign(sender, messageHash) + context('when the requested sender is the actual sender', () => { + const sender = member - await assertRevert(relayer.relay(sender, anyone, nextNonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_INVALID_SENDER_SIGNATURE') + 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 sender is not an allowed service', () => { - calldata = '0x0' - const from = anyone - + context('when the app is not initialized', () => { it('reverts', async () => { - await assertRevert(relayer.relay(member, anyone, nextNonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_SERVICE_NOT_ALLOWED') + await assertRevert(relayer.canUseNonce(member, 0), 'INIT_NOT_INITIALIZED') }) }) }) - describe('getLastUsedNonce', () => { - context('when the given sender has already sent some transactions', () => { - const account = member + describe('canRefund', () => { + context('when the app is initialized', () => { + let currentMonth - it('returns the last nonce', async () => { - assert.isTrue((await relayer.getLastUsedNonce(account)).gt(0), 'last nonce does not match') + beforeEach('initialize relayer app', async () => { + await relayer.initialize(MONTHLY_REFUND_QUOTA) + currentMonth = await relayer.getCurrentMonth() }) - }) - context('when the given sender did not send transactions yet', () => { - const account = anyone + 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 zero', async () => { - assert.equal(await relayer.getLastUsedNonce(account), 0, 'last nonce does not match') - }) - }) - }) + it('returns true', async () => { + assert(await relayer.canRefund(member, currentMonth, amount), 'should be allowed to spend given amount') + }) + }) - describe('isNonceUsed', () => { - const sender = member + context('when the requested amount is equal to the monthly quota', () => { + const amount = MONTHLY_REFUND_QUOTA - context('when the requested nonce is zero', () => { - const nonce = 0 + it('returns true', async () => { + assert(await relayer.canRefund(member, currentMonth, amount), 'should be allowed to spend given amount') + }) + }) - context('when the requested sender is the actual sender', () => { - const account = sender + context('when the requested amount is greater than the monthly quota', () => { + const amount = MONTHLY_REFUND_QUOTA + 1 - it('returns true', async () => { - assert.isTrue(await relayer.isNonceUsed(account, nonce), 'nonce should be used') + it('returns false', async () => { + assert.isFalse(await relayer.canRefund(member, currentMonth, amount), 'should not be allowed to spend given amount') + }) }) }) - context('when the requested sender is another account', () => { - const account = anyone + context('when the given sender has already sent some transactions', () => { + const gasRefund = 50000 + const monthlySpent = gasRefund * GAS_PRICE + const remainingQuota = MONTHLY_REFUND_QUOTA - monthlySpent - it('returns true', async () => { - assert.isTrue(await relayer.isNonceUsed(account, nonce), 'nonce should be used') + beforeEach('relay a transaction', async () => { + const nonce = 1 + const calldata = '0x11111111' + const signature = 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.relay(member, someone, nonce, calldata, gasRefund, GAS_PRICE, signature, { from: offChainRelayerService }) }) - }) - }) - context('when the requested nonce is greater than zero but lower than the nonce used', () => { - const nonce = 1 + context('when the asking for the current month', () => { + context('when the requested amount does not exceed the remaining monthly quota', () => { + const amount = remainingQuota - 1 - context('when the requested sender is the actual sender', () => { - const account = sender + it('returns true', async () => { + assert.isTrue(await relayer.canRefund(member, currentMonth, amount), 'should be allowed to spend amount') + }) + }) - it('returns true', async () => { - assert.isTrue(await relayer.isNonceUsed(account, nonce), 'nonce should be used') + 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, currentMonth, 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, currentMonth, amount), 'should not be allowed to spend amount') + }) + }) }) - }) - context('when the requested sender is another account', () => { - const account = anyone + context('when the asking for the next month', () => { + context('when the requested amount does not exceed the remaining monthly quota', () => { + const amount = remainingQuota - 1 - it('returns false', async () => { - assert.isFalse(await relayer.isNonceUsed(account, nonce), 'nonce should not be used') + it('returns true', async () => { + assert.isTrue(await relayer.canRefund(member, currentMonth + 1, 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, currentMonth + 1, 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 true', async () => { + assert.isTrue(await relayer.canRefund(member, currentMonth + 1, amount), 'should be allowed to spend amount') + }) + }) }) }) }) - context('when the requested nonce is equal to the nonce used', () => { - let nonce + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.canRefund(member, 0, MONTHLY_REFUND_QUOTA), 'INIT_NOT_INITIALIZED') + }) + }) + }) - beforeEach('set nonce', async () => nonce = await relayer.getLastUsedNonce(sender)) + describe('relay', () => { + context('when the app is initialized', () => { + let signature, calldata, gasRefund = 50000, nonce = 10 - context('when the requested sender is the actual sender', () => { - const account = sender + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) - it('returns true', async () => { - assert.isTrue(await relayer.isNonceUsed(account, nonce), 'nonce should be used') - }) + 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') }) - context('when the requested sender is another account', () => { - const account = anyone + context('when the sender is an allowed service', () => { + const from = offChainRelayerService - it('returns false', async () => { - assert.isFalse(await relayer.isNonceUsed(account, nonce), 'nonce should not be used') - }) - }) - }) + beforeEach('allow service', async () => await relayer.allowService(offChainRelayerService, { from: root })) + + context('when the relayed call does not revert', () => { + context('when the signature valid', () => { + beforeEach('sign relayed call', () => { + calldata = app.contract.write.getData(10) + signature = signRelayedTx({ from: member, to: app.address, nonce, calldata, gasRefund }) + }) - context('when the requested nonce is greater than the nonce used', () => { - let nonce + 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 }) + }) - beforeEach('set nonce', async () => nonce = (await relayer.getLastUsedNonce(sender)).plus(1)) + 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') + }) - context('when the requested sender is the actual sender', () => { - const account = sender + it('refunds the off-chain service', async () => { + const previousRelayerBalance = await web3.eth.getBalance(relayer.address) + const previousServiceBalance = await web3.eth.getBalance(offChainRelayerService) - it('returns false', async () => { - assert.isFalse(await relayer.isNonceUsed(account, nonce), 'nonce should not be used') + 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 }) + + assert.equal((await relayer.getLastUsedNonce(member)).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 currentMonth = await relayer.getCurrentMonth() + const previousMonthlyRefunds = await relayer.getMonthlyRefunds(member, currentMonth) + await relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }) + + const txRefund = gasRefund * GAS_PRICE + const currentMonthlyRefunds = await relayer.getMonthlyRefunds(member, currentMonth) + 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 ~80k and the followings with ~50k 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 = 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, 80000, 'first relayed txs gas overload is higher than 80k') + assert.isBelow(secondGasOverload, 50000, 'following relayed txs gas overload is higher than 50k') + })) + }) + + 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 requested sender is another account', () => { - const account = anyone + context('when the relayed call reverts', () => { + context('when the signature is not valid', () => { + it('forwards the revert reason', async () => { + calldata = app.contract.write.getData(10) + signature = signRelayedTx({ from: someone, to: app.address, calldata, nonce, gasRefund }) - it('returns false', async () => { - assert.isFalse(await relayer.isNonceUsed(account, nonce), 'nonce should not be used') + 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') + }) + }) }) }) - }) - }) - describe('getTotalRefunds', () => { - context('when the given sender has already sent some transactions', () => { - const account = member + context('when the sender is not an allowed service', () => { + const from = someone - it('returns the total refunds amount', async () => { - assert.isTrue((await relayer.getTotalRefunds(account)).gt(0), 'total refunds do not match') + it('reverts', async () => { + await assertRevert(relayer.relay(member, someone, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_SERVICE_NOT_ALLOWED') + }) }) }) - context('when the given sender did not send transactions yet', () => { - const account = anyone - - it('returns zero', async () => { - assert.equal(await relayer.getTotalRefunds(account), 0, 'total refunds do not match') + 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('canRefund', () => { - let remainingRefunds - const sender = member + describe('allowService', () => { + context('when the app is initialized', () => { + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) - beforeEach('fetch total refunds', async () => { - remainingRefunds = new web3.BigNumber(MONTHLY_REFUND_QUOTA).minus(await relayer.getTotalRefunds(sender)) - }) + context('when the sender is allowed', () => { + const from = root - context('when the requested amount does not exceed the monthly quota', () => { - it('returns true', async () => { - assert.isTrue(await relayer.canRefund(sender, remainingRefunds.minus(1)), 'should be allowed to spend amount') - }) - }) + it('adds a new allowed service', async () => { + await relayer.allowService(someone, {from}) - context('when the requested amount is equal to the monthly quota', () => { - it('returns true', async () => { - assert.isTrue(await relayer.canRefund(sender, remainingRefunds), 'should be allowed to spend amount') - }) - }) + assert(await relayer.isServiceAllowed(someone), 'service should be allowed') + }) - context('when the requested amount is greater than the monthly quota', () => { - it('returns true', async () => { - assert.isTrue(await relayer.canRefund(sender, remainingRefunds.plus(1)), 'should not be allowed to spend amount') + it('emits an event', async () => { + const receipt = await relayer.allowService(someone, {from}) + + assertAmountOfEvents(receipt, 'ServiceAllowed') + assertEvent(receipt, 'ServiceAllowed', {service: someone}) + }) }) - }) - }) - describe('isDepositable', () => { - it('returns true', async () => { - assert.isTrue(await relayer.isDepositable(), 'should be depositable') - }) - }) + context('when the sender is not allowed', () => { + const from = someone - describe('allowRecoverability', () => { - context('when the token is ETH', () => { - it('returns false', async () => { - assert.isFalse(await relayer.allowRecoverability(ZERO_ADDRESS), 'should not allow ETH recoverability') + it('reverts', async () => { + await assertRevert(relayer.allowService(someone, {from}), 'APP_AUTH_FAILED') + }) }) }) - context('when the token is not ETH', () => { - it('returns true', async () => { - assert.isTrue(await relayer.allowRecoverability(anyone), 'should allow tokens recoverability') + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.allowService(offChainRelayerService, { from: root }), 'APP_AUTH_FAILED') }) }) }) - describe('allowService', () => { - context('when the sender is allowed', () => { - const from = root + 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 }) - it('adds a new allowed service', async () => { - await relayer.allowService(anyone, { from }) + assert.isFalse(await relayer.isServiceAllowed(someone), 'service should not be allowed') + }) + + it('emits an event', async () => { + const receipt = await relayer.disallowService(someone, { from }) - assert(await relayer.isServiceAllowed(anyone), 'service should be allowed') + assertAmountOfEvents(receipt, 'ServiceDisallowed') + assertEvent(receipt, 'ServiceDisallowed', { service: someone }) + }) }) - it('emits an event', async () => { - const receipt = await relayer.allowService(anyone, { from }) + context('when the sender is not allowed', () => { + const from = someone - assertAmountOfEvents(receipt, 'ServiceAllowed') - assertEvent(receipt, 'ServiceAllowed', { service: anyone }) + it('reverts', async () => { + await assertRevert(relayer.disallowService(someone, { from }), 'APP_AUTH_FAILED') + }) }) }) - context('when the sender is not allowed', () => { - const from = anyone - + context('when the app is not initialized', () => { it('reverts', async () => { - await assertRevert(relayer.allowService(anyone, { from }), 'APP_AUTH_FAILED') + await assertRevert(relayer.disallowService(offChainRelayerService, { from: root }), 'APP_AUTH_FAILED') }) }) }) - describe('disallowService', () => { - context('when the sender is allowed', () => { - const from = root + 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('adds a new allowed service', async () => { - await relayer.disallowService(anyone, { from }) + 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') + }) - assert.isFalse(await relayer.isServiceAllowed(anyone), 'service should not be allowed') + 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 }) + }) }) - it('emits an event', async () => { - const receipt = await relayer.disallowService(anyone, { from }) + context('when the sender is not allowed', () => { + const from = someone - assertAmountOfEvents(receipt, 'ServiceDisallowed') - assertEvent(receipt, 'ServiceDisallowed', { service: anyone }) + it('reverts', async () => { + await assertRevert(relayer.setMonthlyRefundQuota(newQuota, { from }), 'APP_AUTH_FAILED') + }) }) }) - context('when the sender is not allowed', () => { - const from = anyone - + context('when the app is not initialized', () => { it('reverts', async () => { - await assertRevert(relayer.disallowService(anyone, { from }), 'APP_AUTH_FAILED') + await assertRevert(relayer.setMonthlyRefundQuota(newQuota, { from: root }), 'APP_AUTH_FAILED') }) }) }) From f1e30eb268364312a217e4d4229e94caf78cda75 Mon Sep 17 00:00:00 2001 From: Facundo Spagnuolo Date: Wed, 15 May 2019 17:41:50 -0300 Subject: [PATCH 16/19] meta-txs: implement whitelist of allowed senders --- contracts/relayer/Relayer.sol | 59 ++++++ test/contracts/relayer/relayer.js | 327 +++++++++++++++++++++--------- 2 files changed, 288 insertions(+), 98 deletions(-) diff --git a/contracts/relayer/Relayer.sol b/contracts/relayer/Relayer.sol index e89aa6359..ac8b236be 100644 --- a/contracts/relayer/Relayer.sol +++ b/contracts/relayer/Relayer.sol @@ -18,10 +18,17 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage { 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"; + // 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"); @@ -43,6 +50,18 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage { */ 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 @@ -66,6 +85,9 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage { // Monthly refunds quota in ETH for each member uint256 internal monthlyRefundQuota; + // Mapping that indicates whether a given address is allowed to use the relay service + mapping (address => bool) internal allowedSenders; + // Mapping that indicates whether a given address is allowed as off-chain service to relay transactions mapping (address => bool) internal allowedServices; @@ -81,6 +103,12 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage { _; } + // Check whether a given address belongs to the list of allowed senders that can use the relay service + modifier onlyAllowedSender(address sender) { + require(_isSenderAllowed(sender), ERROR_SENDER_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 member @@ -105,6 +133,7 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage { function relay(address from, address to, uint256 nonce, bytes data, uint256 gasRefund, uint256 gasPrice, bytes signature) external onlyAllowedServices + onlyAllowedSender(from) { uint256 currentMonth = _getCurrentMonth(); uint256 requestedRefund = gasRefund.mul(gasPrice); @@ -141,6 +170,24 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage { emit ServiceDisallowed(service); } + /** + * @notice Add a new sender `sender` to the list of allowed addresses that can use the relay service + * @param sender Address of the sender to be allowed + */ + function allowSender(address sender) external authP(ALLOW_SENDER_ROLE, arr(sender)) { + allowedSenders[sender] = true; + emit SenderAllowed(sender); + } + + /** + * @notice Remove sender `sender` from the list of allowed addresses that can use the relay service + * @param sender Address of the sender to be disallowed + */ + function disallowSender(address sender) external authP(DISALLOW_SENDER_ROLE, arr(sender)) { + allowedSenders[sender] = false; + emit SenderDisallowed(sender); + } + /** * @notice Set new monthly refunds quota per address of `@tokenAmount(newQuota, 0x00)`. * @param newQuota New monthly refunds quota in ETH for each allowed member @@ -198,6 +245,14 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage { 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 sender) external view isInitialized returns (bool) { + return _isSenderAllowed(sender); + } + /** * @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 @@ -241,6 +296,10 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage { return allowedServices[service]; } + function _isSenderAllowed(address sender) internal view returns (bool) { + return allowedSenders[sender]; + } + function _canUseNonce(address sender, uint256 nonce) internal view returns (bool) { return _getLastUsedNonce(sender) < nonce; } diff --git a/test/contracts/relayer/relayer.js b/test/contracts/relayer/relayer.js index 13b7d917e..04ebe93f5 100644 --- a/test/contracts/relayer/relayer.js +++ b/test/contracts/relayer/relayer.js @@ -17,7 +17,8 @@ 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, SET_MONTHLY_REFUND_QUOTA_ROLE, ALLOW_OFF_CHAIN_SERVICE_ROLE, DISALLOW_OFF_CHAIN_SERVICE_ROLE, RELAYER_APP_ID + 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 @@ -43,6 +44,8 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) 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() }) @@ -68,6 +71,8 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) 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 }) @@ -220,8 +225,9 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) const gasRefund = 50000 const signature = signRelayedTx({ from: member, to: someone, nonce, calldata, gasRefund }) - await relayer.allowService(offChainRelayerService, { from: root }) 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 }) }) @@ -260,6 +266,7 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) 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 }) }) @@ -297,14 +304,14 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) beforeEach('disallow service', async () => await relayer.disallowService(offChainRelayerService, { from: root })) it('returns false', async () => { - assert.isFalse(await relayer.isServiceAllowed(offChainRelayerService), 'off chain service should be allowed') + 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 be allowed') + assert.isFalse(await relayer.isServiceAllowed(offChainRelayerService), 'off chain service should not be allowed') }) }) }) @@ -316,6 +323,42 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) }) }) + 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)) @@ -348,6 +391,7 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) 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 }) }) @@ -487,6 +531,7 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) 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 }) }) @@ -564,151 +609,163 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) await assertRevert(app.write(10, { from: someone }), 'APP_AUTH_FAILED') }) - context('when the sender is an allowed service', () => { + context('when the service is not allowed', () => { const from = offChainRelayerService beforeEach('allow service', async () => await relayer.allowService(offChainRelayerService, { from: root })) - context('when the relayed call does not revert', () => { - context('when the signature valid', () => { - beforeEach('sign relayed call', () => { - calldata = app.contract.write.getData(10) - signature = signRelayedTx({ from: member, to: app.address, nonce, calldata, gasRefund }) - }) + 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 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 }) - }) + context('when the signature valid', () => { + beforeEach('sign relayed call', () => { + calldata = app.contract.write.getData(10) + signature = signRelayedTx({ from: member, to: app.address, nonce, calldata, gasRefund }) + }) - 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') - }) + 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('refunds the off-chain service', async () => { - const previousRelayerBalance = await web3.eth.getBalance(relayer.address) - const previousServiceBalance = await web3.eth.getBalance(offChainRelayerService) + 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') + }) - 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) + it('refunds the off-chain service', async () => { + const previousRelayerBalance = await web3.eth.getBalance(relayer.address) + const previousServiceBalance = await web3.eth.getBalance(offChainRelayerService) - const txRefund = gasRefund * GAS_PRICE - const realTxCost = gasPriceUsed.mul(gasUsed) + 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 currentRelayerBalance = await web3.eth.getBalance(relayer.address) - const currentServiceBalance = await web3.eth.getBalance(offChainRelayerService) + const txRefund = gasRefund * GAS_PRICE + const realTxCost = gasPriceUsed.mul(gasUsed) - assert.equal(currentRelayerBalance.toString(), previousRelayerBalance.minus(txRefund).toString()) - assert.equal(currentServiceBalance.toString(), previousServiceBalance.minus(realTxCost).plus(txRefund).toString()) - }) + const currentRelayerBalance = await web3.eth.getBalance(relayer.address) + const currentServiceBalance = await web3.eth.getBalance(offChainRelayerService) - it('updates the last nonce used of the sender', async () => { - await relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }) + assert.equal(currentRelayerBalance.toString(), previousRelayerBalance.minus(txRefund).toString()) + assert.equal(currentServiceBalance.toString(), previousServiceBalance.minus(realTxCost).plus(txRefund).toString()) + }) - assert.equal((await relayer.getLastUsedNonce(member)).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 last nonce used of the sender', async () => { + await relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }) - it('updates the monthly refunds of the sender', async () => { - const currentMonth = await relayer.getCurrentMonth() - const previousMonthlyRefunds = await relayer.getMonthlyRefunds(member, currentMonth) - await relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }) + assert.equal((await relayer.getLastUsedNonce(member)).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') + }) - const txRefund = gasRefund * GAS_PRICE - const currentMonthlyRefunds = await relayer.getMonthlyRefunds(member, currentMonth) - assert.equal(previousMonthlyRefunds.toString(), currentMonthlyRefunds.minus(txRefund).toString(), 'total refunds should have been updated') - }) + it('updates the monthly refunds of the sender', async () => { + const currentMonth = await relayer.getCurrentMonth() + const previousMonthlyRefunds = await relayer.getMonthlyRefunds(member, currentMonth) + await relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }) - it('emits an event', async () => { - const receipt = await relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }) + const txRefund = gasRefund * GAS_PRICE + const currentMonthlyRefunds = await relayer.getMonthlyRefunds(member, currentMonth) + assert.equal(previousMonthlyRefunds.toString(), currentMonthlyRefunds.minus(txRefund).toString(), 'total refunds should have been updated') + }) - assertAmountOfEvents(receipt, 'TransactionRelayed') - assertEvent(receipt, 'TransactionRelayed', { from: member, to: app.address, nonce, data: calldata }) - }) + 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 ~80k and the followings with ~50k of gas', skipCoverage(async () => { - const { receipt: { cumulativeGasUsed: nonRelayerGasUsed } } = await app.write(10, { from: member }) + it('overloads the first relayed transaction with ~80k and the followings with ~50k 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 { receipt: { cumulativeGasUsed: firstRelayedGasUsed } } = await relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }) - const secondSignature = 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 secondSignature = 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 + const firstGasOverload = firstRelayedGasUsed - nonRelayerGasUsed + const secondGasOverload = secondRelayedGasUsed - nonRelayerGasUsed - console.log('firstGasOverload:', firstGasOverload) - console.log('secondGasOverload:', secondGasOverload) + console.log('firstGasOverload:', firstGasOverload) + console.log('secondGasOverload:', secondGasOverload) - assert.isBelow(firstGasOverload, 80000, 'first relayed txs gas overload is higher than 80k') - assert.isBelow(secondGasOverload, 50000, 'following relayed txs gas overload is higher than 50k') - })) + assert.isBelow(firstGasOverload, 80000, 'first relayed txs gas overload is higher than 80k') + assert.isBelow(secondGasOverload, 50000, 'following relayed txs gas overload is higher than 50k') + })) + }) + + 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 relayer does not have funds', () => { + 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_REFUND_FAIL') + await assertRevert(relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_GAS_QUOTA_EXCEEDED') }) }) }) - 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 }) + 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_GAS_QUOTA_EXCEEDED') + await assertRevert(relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_NONCE_ALREADY_USED') }) }) }) - 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 }) - }) - + context('when the signature is not valid', () => { it('reverts', async () => { - await assertRevert(relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_NONCE_ALREADY_USED') + 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 signature is not valid', () => { - it('reverts', async () => { - const signature = web3.eth.sign(member, 'bla') + 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 = signRelayedTx({ from: someone, to: app.address, calldata, nonce, gasRefund }) - await assertRevert(relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_INVALID_SENDER_SIGNATURE') + await assertRevert(relayer.relay(someone, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'APP_AUTH_FAILED') + }) }) - }) - }) - context('when the relayed call reverts', () => { - context('when the signature is not valid', () => { - it('forwards the revert reason', async () => { - calldata = app.contract.write.getData(10) - signature = signRelayedTx({ from: someone, to: app.address, calldata, nonce, gasRefund }) + 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 }), 'APP_AUTH_FAILED') + await assertRevert(relayer.relay(someone, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_INVALID_SENDER_SIGNATURE') + }) }) }) + }) - 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 sender is not an allowed service', () => { + context('when the service is not allowed', () => { const from = someone it('reverts', async () => { @@ -756,7 +813,7 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) context('when the app is not initialized', () => { it('reverts', async () => { - await assertRevert(relayer.allowService(offChainRelayerService, { from: root }), 'APP_AUTH_FAILED') + await assertRevert(relayer.allowService(someone, { from: root }), 'APP_AUTH_FAILED') }) }) }) @@ -793,7 +850,81 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) context('when the app is not initialized', () => { it('reverts', async () => { - await assertRevert(relayer.disallowService(offChainRelayerService, { from: root }), 'APP_AUTH_FAILED') + 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') }) }) }) From f092d3bc51cd9b818d8fec971b70da1b9b8e1e18 Mon Sep 17 00:00:00 2001 From: Facundo Spagnuolo Date: Thu, 16 May 2019 01:05:14 -0300 Subject: [PATCH 17/19] meta-txs: support EIP712 --- contracts/lib/misc/EIP712.sol | 33 ++++++++++++++++++ contracts/lib/sig/ECDSA.sol | 12 +++---- contracts/relayer/RelayedAragonApp.sol | 4 +-- contracts/relayer/Relayer.sol | 31 +++++++++++++---- contracts/test/mocks/relayer/RelayerMock.sol | 8 ++++- contracts/test/tests/TestRelayerCalldata.sol | 2 +- lib/signTypedData.js | 35 ++++++++++++++++++++ test/contracts/relayer/relayer.js | 30 ++++++++--------- 8 files changed, 124 insertions(+), 31 deletions(-) create mode 100644 contracts/lib/misc/EIP712.sol create mode 100644 lib/signTypedData.js diff --git a/contracts/lib/misc/EIP712.sol b/contracts/lib/misc/EIP712.sol new file mode 100644 index 000000000..ada700abe --- /dev/null +++ b/contracts/lib/misc/EIP712.sol @@ -0,0 +1,33 @@ +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(string name, string version, uint256 chainId, address verifyingContract) internal pure returns (bytes32) { + return _hash(Domain({ + name: name, + version: version, + chainId: chainId, + verifyingContract: verifyingContract + })); + } + + 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 + )); + } +} diff --git a/contracts/lib/sig/ECDSA.sol b/contracts/lib/sig/ECDSA.sol index 932c514d5..8527d6f80 100644 --- a/contracts/lib/sig/ECDSA.sol +++ b/contracts/lib/sig/ECDSA.sol @@ -13,9 +13,9 @@ library ECDSA { * @param signature bytes signature, the signature is generated using web3.eth.sign() */ function recover(bytes32 hash, bytes signature) - internal - pure - returns (address) + internal + pure + returns (address) { bytes32 r; bytes32 s; @@ -56,9 +56,9 @@ library ECDSA { * and hash the result */ function toEthSignedMessageHash(bytes32 hash) - internal - pure - returns (bytes32) + internal + pure + returns (bytes32) { // 32 is the length in bytes of hash, // enforced by the type signature above diff --git a/contracts/relayer/RelayedAragonApp.sol b/contracts/relayer/RelayedAragonApp.sol index 1c794a739..c0458e656 100644 --- a/contracts/relayer/RelayedAragonApp.sol +++ b/contracts/relayer/RelayedAragonApp.sol @@ -16,7 +16,7 @@ contract RelayedAragonApp is AragonApp { return signer != address(0) ? signer : relayer; } - function _decodeSigner() internal returns (address signer) { + 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 @@ -28,7 +28,7 @@ contract RelayedAragonApp is AragonApp { } } - function _relayer() internal returns (IRelayer) { + function _relayer() internal view returns (IRelayer) { return kernel().relayer(); } } diff --git a/contracts/relayer/Relayer.sol b/contracts/relayer/Relayer.sol index ac8b236be..7f666f04e 100644 --- a/contracts/relayer/Relayer.sol +++ b/contracts/relayer/Relayer.sol @@ -3,6 +3,7 @@ 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/IsContract.sol"; @@ -11,7 +12,7 @@ import "../common/MemoryHelpers.sol"; import "../common/DepositableStorage.sol"; -contract Relayer is IRelayer, AragonApp, DepositableStorage { +contract Relayer is IRelayer, AragonApp, DepositableStorage, EIP712 { using ECDSA for bytes32; using SafeMath for uint256; using MemoryHelpers for bytes; @@ -23,6 +24,14 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage { 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"); @@ -140,7 +149,7 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage { require(_canUseNonce(from, nonce), ERROR_NONCE_ALREADY_USED); require(_canRefund(from, currentMonth, requestedRefund), ERROR_GAS_QUOTA_EXCEEDED); - require(_isValidSignature(from, _messageHash(to, nonce, data, gasRefund, gasPrice), signature), ERROR_INVALID_SENDER_SIGNATURE); + require(_isValidSignature(from, to, nonce, data, gasRefund, gasPrice, signature), ERROR_INVALID_SENDER_SIGNATURE); lastUsedNonce[from] = nonce; monthlyRefunds[from][currentMonth] = monthlyRefunds[from][currentMonth].add(requestedRefund); @@ -309,13 +318,23 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage { return monthRefunds.add(amount) <= monthlyRefundQuota; } - function _isValidSignature(address sender, bytes32 hash, bytes signature) internal pure returns (bool) { - address signer = hash.toEthSignedMessageHash().recover(signature); + 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 pure returns (bytes32) { - return keccak256(abi.encodePacked(to, nonce, keccak256(data), gasRefund, gasPrice)); + 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 _domainSeparator() internal view returns (bytes32) { + return _domainSeparator(EIP_712_DOMAIN_NAME, EIP_712_DOMAIN_VERSION, EIP_712_DOMAIN_CHAIN_ID, address(this)); } function _relayCall(address from, address to, bytes data) internal { diff --git a/contracts/test/mocks/relayer/RelayerMock.sol b/contracts/test/mocks/relayer/RelayerMock.sol index 2374baf6b..946747733 100644 --- a/contracts/test/mocks/relayer/RelayerMock.sol +++ b/contracts/test/mocks/relayer/RelayerMock.sol @@ -5,5 +5,11 @@ import "../../../test/mocks/common/TimeHelpersMock.sol"; contract RelayerMock is Relayer, TimeHelpersMock { - // solium-disable-previous-line no-empty-blocks + 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/TestRelayerCalldata.sol b/contracts/test/tests/TestRelayerCalldata.sol index 0c796535d..d9c16c3f0 100644 --- a/contracts/test/tests/TestRelayerCalldata.sol +++ b/contracts/test/tests/TestRelayerCalldata.sol @@ -6,7 +6,7 @@ import "../../common/MemoryHelpers.sol"; contract RelayedAppTest is RelayedAragonApp { - function callme(uint8 x, bytes32 y, string z) public { + 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] 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/relayer/relayer.js b/test/contracts/relayer/relayer.js index 04ebe93f5..7ed3b5858 100644 --- a/test/contracts/relayer/relayer.js +++ b/test/contracts/relayer/relayer.js @@ -1,5 +1,5 @@ +const signTypedData = require('../../../lib/signTypedData')(web3) const { skipCoverage } = require('../../helpers/coverage') -const { sha3, soliditySha3 } = require('web3-utils') const { assertRevert } = require('../../helpers/assertThrow') const { getEventArgument, getNewProxyAddress } = require('../../helpers/events') const { assertEvent, assertAmountOfEvents } = require('../../helpers/assertEvent')(web3) @@ -26,9 +26,9 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) const SEND_ETH_GAS = 31000 // 21k base tx cost + 10k limit on depositable proxies - const signRelayedTx = ({ from, to, nonce, calldata = '0x0', gasRefund, gasPrice = GAS_PRICE }) => { - const messageHash = soliditySha3(to, nonce, sha3(calldata), gasRefund, gasPrice) - return web3.eth.sign(from, messageHash) + 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 () => { @@ -223,7 +223,7 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) const nonce = 2 const calldata = '0x11111111' const gasRefund = 50000 - const signature = signRelayedTx({ from: member, to: someone, nonce, calldata, gasRefund }) + 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 }) @@ -262,7 +262,7 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) beforeEach('relay a transaction', async () => { const nonce = 2 const calldata = '0x11111111' - const signature = signRelayedTx({ from: member, to: someone, nonce, calldata, gasRefund }) + 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 }) @@ -387,7 +387,7 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) beforeEach('relay a transaction', async () => { const calldata = '0x11111111' const gasRefund = 50000 - const signature = signRelayedTx({ from: member, to: someone, nonce: usedNonce, calldata, gasRefund }) + 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 }) @@ -527,7 +527,7 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) beforeEach('relay a transaction', async () => { const nonce = 1 const calldata = '0x11111111' - const signature = signRelayedTx({ from: member, to: someone, nonce, calldata, gasRefund }) + 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 }) @@ -619,9 +619,9 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) beforeEach('allow sender', async () => await relayer.allowSender(member, { from: root })) context('when the signature valid', () => { - beforeEach('sign relayed call', () => { + beforeEach('sign relayed call', async () => { calldata = app.contract.write.getData(10) - signature = signRelayedTx({ from: member, to: app.address, nonce, calldata, gasRefund }) + signature = await signRelayedTx({ from: member, to: app.address, nonce, calldata, gasRefund }) }) context('when the nonce is not used', () => { @@ -678,12 +678,12 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) assertEvent(receipt, 'TransactionRelayed', { from: member, to: app.address, nonce, data: calldata }) }) - it('overloads the first relayed transaction with ~80k and the followings with ~50k of gas', skipCoverage(async () => { + 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 = signRelayedTx({ from: member, to: app.address, nonce: nonce + 1, calldata, gasRefund }) + 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 @@ -692,8 +692,8 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) console.log('firstGasOverload:', firstGasOverload) console.log('secondGasOverload:', secondGasOverload) - assert.isBelow(firstGasOverload, 80000, 'first relayed txs gas overload is higher than 80k') - assert.isBelow(secondGasOverload, 50000, 'following relayed txs gas overload is higher than 50k') + 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') })) }) @@ -742,7 +742,7 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) context('when the signature is valid', () => { it('forwards the revert reason', async () => { calldata = app.contract.write.getData(10) - signature = signRelayedTx({ from: someone, to: app.address, calldata, nonce, gasRefund }) + 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') }) From 3b91caaa78103d4832315bddf4e50679c72fbb50 Mon Sep 17 00:00:00 2001 From: Facundo Spagnuolo Date: Thu, 16 May 2019 18:10:08 -0300 Subject: [PATCH 18/19] meta-txs: ignore relayer contracts for coverage measure --- .solcover.js | 8 +- contracts/relayer/Relayer.sol | 2 - test/contracts/relayer/relayer.js | 1130 +++++++++++++++-------------- test/helpers/coverage.js | 2 +- 4 files changed, 572 insertions(+), 570 deletions(-) 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/relayer/Relayer.sol b/contracts/relayer/Relayer.sol index 7f666f04e..81624b951 100644 --- a/contracts/relayer/Relayer.sol +++ b/contracts/relayer/Relayer.sol @@ -6,8 +6,6 @@ import "../lib/sig/ECDSA.sol"; import "../lib/misc/EIP712.sol"; import "../lib/math/SafeMath.sol"; import "../apps/AragonApp.sol"; -import "../common/IsContract.sol"; -import "../common/TimeHelpers.sol"; import "../common/MemoryHelpers.sol"; import "../common/DepositableStorage.sol"; diff --git a/test/contracts/relayer/relayer.js b/test/contracts/relayer/relayer.js index 7ed3b5858..d4d1c5e1c 100644 --- a/test/contracts/relayer/relayer.js +++ b/test/contracts/relayer/relayer.js @@ -1,6 +1,6 @@ const signTypedData = require('../../../lib/signTypedData')(web3) -const { skipCoverage } = require('../../helpers/coverage') const { assertRevert } = require('../../helpers/assertThrow') +const { skipCoverage } = require('../../helpers/coverage') const { getEventArgument, getNewProxyAddress } = require('../../helpers/events') const { assertEvent, assertAmountOfEvents } = require('../../helpers/assertEvent')(web3) @@ -78,893 +78,895 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) await acl.createPermission(root, relayer.address, DISALLOW_OFF_CHAIN_SERVICE_ROLE, root, { from: root }) }) - 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('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') - it('cannot be initialized again', async () => { - await relayer.initialize(MONTHLY_REFUND_QUOTA) - await assertRevert(relayer.initialize(MONTHLY_REFUND_QUOTA), 'INIT_ALREADY_INITIALIZED') - }) + await assertRevert(app.write(10, { from: someone }), 'APP_AUTH_FAILED') }) - describe('isDepositable', () => { - context('when the app is initialized', () => { - beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + if (!process.env.SOLIDITY_COVERAGE) { + describe('initialize', () => { + it('is not initialized by default', async () => { + assert.isFalse(await relayer.hasInitialized(), 'should not be initialized') + }) - it('returns true', async () => { - assert.isTrue(await relayer.isDepositable(), 'should be depositable') + it('initializes the relayer app correctly', async () => { + await relayer.initialize(MONTHLY_REFUND_QUOTA) + assert.isTrue(await relayer.hasInitialized(), 'should be initialized') }) - }) - context('when the app is not initialized', () => { - it('returns false', async () => { - assert.isFalse(await relayer.isDepositable(), 'should not be depositable') + it('cannot be initialized again', async () => { + await relayer.initialize(MONTHLY_REFUND_QUOTA) + await assertRevert(relayer.initialize(MONTHLY_REFUND_QUOTA), 'INIT_ALREADY_INITIALIZED') }) }) - }) - 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') + 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 token is not ETH', () => { - it('returns true', async () => { - assert.isTrue(await relayer.allowRecoverability(someone), 'should allow tokens recoverability') + context('when the app is not initialized', () => { + it('returns false', async () => { + assert.isFalse(await relayer.isDepositable(), 'should not be depositable') }) }) - } + }) - context('when the app is initialized', () => { - beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + 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') + }) + }) - itReturnsTrueUnlessETH() - }) + 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', () => { - itReturnsTrueUnlessETH() + 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', () => { + describe('getStartDate', () => { + context('when the app is initialized', () => { - beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + 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') + 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') + 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)) + 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') + 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') + 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)) + 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 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)) + 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') + 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)) + 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') + 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') + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.getCurrentMonth(), 'INIT_NOT_INITIALIZED') + }) }) }) - }) - describe('getLastUsedNonce', () => { - context('when the app is initialized', () => { - beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + describe('getLastUsedNonce', () => { + 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 zero', async () => { - assert.equal((await relayer.getLastUsedNonce(member)).toString(), 0, 'last nonce does not match') + context('when the given sender did not send transactions yet', () => { + it('returns zero', async () => { + assert.equal((await relayer.getLastUsedNonce(member)).toString(), 0, 'last nonce does not match') + }) }) - }) - context('when the given sender has already sent some transactions', () => { - beforeEach('relay a transaction', async () => { - const nonce = 2 - const calldata = '0x11111111' - const gasRefund = 50000 - const signature = await signRelayedTx({ from: member, to: someone, nonce, calldata, gasRefund }) + context('when the given sender has already sent some transactions', () => { + beforeEach('relay a transaction', async () => { + const nonce = 2 + const calldata = '0x11111111' + const gasRefund = 50000 + 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 }) - }) + 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 last nonce', async () => { - assert.equal((await relayer.getLastUsedNonce(member)).toString(), 2, 'last nonce does not match') + it('returns the last nonce', async () => { + assert.equal((await relayer.getLastUsedNonce(member)).toString(), 2, 'last nonce does not match') + }) }) }) - }) - context('when the app is not initialized', () => { - it('reverts', async () => { - await assertRevert(relayer.getLastUsedNonce(member), 'INIT_NOT_INITIALIZED') + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.getLastUsedNonce(member), 'INIT_NOT_INITIALIZED') + }) }) }) - }) - describe('getMonthlyRefunds', () => { - const month = 0 + describe('getMonthlyRefunds', () => { + const month = 0 - context('when the app is initialized', () => { - beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + 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 zero', async () => { - assert.equal((await relayer.getMonthlyRefunds(member, month)).toString(), 0, 'monthly refunds do not match') + context('when the given sender did not send transactions yet', () => { + it('returns zero', async () => { + assert.equal((await relayer.getMonthlyRefunds(member, month)).toString(), 0, 'monthly refunds do not match') + }) }) - }) - context('when the given sender has already sent some transactions', () => { - const gasRefund = 50000 + 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 }) + 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 }) - }) + 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 last nonce', async () => { - assert.equal((await relayer.getMonthlyRefunds(member, month)).toString(), gasRefund * GAS_PRICE, 'monthly refunds do not match') - }) + it('returns the last nonce', async () => { + assert.equal((await relayer.getMonthlyRefunds(member, month)).toString(), gasRefund * GAS_PRICE, 'monthly refunds do not match') + }) - it('returns zero for the next month', async () => { - assert.equal((await relayer.getMonthlyRefunds(member, month + 1)).toString(), 0, 'monthly refunds do not match') + it('returns zero for the next month', async () => { + assert.equal((await relayer.getMonthlyRefunds(member, month + 1)).toString(), 0, 'monthly refunds do not match') + }) }) }) - }) - context('when the app is not initialized', () => { - it('reverts', async () => { - await assertRevert(relayer.getMonthlyRefunds(member, month), 'INIT_NOT_INITIALIZED') + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.getMonthlyRefunds(member, month), 'INIT_NOT_INITIALIZED') + }) }) }) - }) - describe('isServiceAllowed', () => { - context('when the app is initialized', () => { - beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + 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 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 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 })) + 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 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') }) }) }) - 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 })) - describe('isSenderAllowed', () => { - context('when the app is initialized', () => { - beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + 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 allowed', () => { - beforeEach('allow sender', async () => await relayer.allowSender(someone, { from: root })) + context('when the given address was already disallowed', () => { + beforeEach('disallow sender', async () => await relayer.disallowSender(someone, { from: root })) - context('when the given address is still allowed', () => { - it('returns true', async () => { - assert(await relayer.isSenderAllowed(someone), 'sender should be allowed') + it('returns false', async () => { + assert.isFalse(await relayer.isSenderAllowed(someone), 'sender should not be allowed') + }) }) }) - context('when the given address was already disallowed', () => { - beforeEach('disallow sender', async () => await relayer.disallowSender(someone, { from: root })) - + context('when the given address was never allowed', () => { it('returns false', async () => { - assert.isFalse(await relayer.isSenderAllowed(someone), 'sender should not be allowed') + assert.isFalse(await relayer.isSenderAllowed(someone), 'sender should 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') }) }) }) - 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)) + 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 + 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') + 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 + 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') + 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 + 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 }) + 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 }) - }) + 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 nonce is zero', () => { + const nonce = 0 - context('when the requested sender is the actual sender', () => { - const sender = member + 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') + 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 + 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') + 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 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 + 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') + 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 + 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') + 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 nonce is equal to the nonce used', () => { + const nonce = usedNonce - context('when the requested sender is the actual sender', () => { - const sender = member + 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') + 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 + 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') + 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 nonce is greater than the nonce used', () => { + let nonce = usedNonce + 1 - context('when the requested sender is the actual sender', () => { - const sender = member + 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') + 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 + 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') + 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') + 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', () => { - let currentMonth + describe('canRefund', () => { + context('when the app is initialized', () => { + let currentMonth - beforeEach('initialize relayer app', async () => { - await relayer.initialize(MONTHLY_REFUND_QUOTA) - currentMonth = await relayer.getCurrentMonth() - }) + beforeEach('initialize relayer app', async () => { + await relayer.initialize(MONTHLY_REFUND_QUOTA) + currentMonth = await relayer.getCurrentMonth() + }) - 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 + 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, currentMonth, amount), 'should be allowed to spend given amount') + it('returns true', async () => { + assert(await relayer.canRefund(member, currentMonth, amount), 'should be allowed to spend given amount') + }) }) - }) - context('when the requested amount is equal to the monthly quota', () => { - const amount = MONTHLY_REFUND_QUOTA + 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, currentMonth, amount), 'should be allowed to spend given amount') + it('returns true', async () => { + assert(await relayer.canRefund(member, currentMonth, 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 + 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, currentMonth, amount), 'should not be allowed to spend given amount') + it('returns false', async () => { + assert.isFalse(await relayer.canRefund(member, currentMonth, 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 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 asking for the current month', () => { - context('when the requested amount does not exceed the remaining monthly quota', () => { - const amount = remainingQuota - 1 + context('when the asking for the current month', () => { + 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, currentMonth, amount), 'should be allowed to spend amount') + it('returns true', async () => { + assert.isTrue(await relayer.canRefund(member, currentMonth, amount), 'should be allowed to spend amount') + }) }) - }) - context('when the requested amount is equal to the remaining monthly quota', () => { - const amount = remainingQuota + 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, currentMonth, amount), 'should be allowed to spend amount') + it('returns true', async () => { + assert.isTrue(await relayer.canRefund(member, currentMonth, amount), 'should be allowed to spend amount') + }) }) - }) - context('when the requested amount is greater than the remaining monthly quota', () => { - const amount = remainingQuota + 1 + 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, currentMonth, amount), 'should not be allowed to spend amount') + it('returns false', async () => { + assert.isFalse(await relayer.canRefund(member, currentMonth, amount), 'should not be allowed to spend amount') + }) }) }) - }) - context('when the asking for the next month', () => { - context('when the requested amount does not exceed the remaining monthly quota', () => { - const amount = remainingQuota - 1 + context('when the asking for the next month', () => { + 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, currentMonth + 1, amount), 'should be allowed to spend amount') + it('returns true', async () => { + assert.isTrue(await relayer.canRefund(member, currentMonth + 1, amount), 'should be allowed to spend amount') + }) }) - }) - context('when the requested amount is equal to the remaining monthly quota', () => { - const amount = remainingQuota + 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, currentMonth + 1, amount), 'should be allowed to spend amount') + it('returns true', async () => { + assert.isTrue(await relayer.canRefund(member, currentMonth + 1, amount), 'should be allowed to spend amount') + }) }) - }) - context('when the requested amount is greater than the remaining monthly quota', () => { - const amount = remainingQuota + 1 + context('when the requested amount is greater than the remaining monthly quota', () => { + const amount = remainingQuota + 1 - it('returns true', async () => { - assert.isTrue(await relayer.canRefund(member, currentMonth + 1, amount), 'should be allowed to spend amount') + it('returns true', async () => { + assert.isTrue(await relayer.canRefund(member, currentMonth + 1, amount), 'should be allowed to spend amount') + }) }) }) }) }) - }) - context('when the app is not initialized', () => { - it('reverts', async () => { - await assertRevert(relayer.canRefund(member, 0, MONTHLY_REFUND_QUOTA), 'INIT_NOT_INITIALIZED') + context('when the app is not initialized', () => { + it('reverts', async () => { + await assertRevert(relayer.canRefund(member, 0, MONTHLY_REFUND_QUOTA), 'INIT_NOT_INITIALIZED') + }) }) }) - }) - describe('relay', () => { - context('when the app is initialized', () => { - let signature, calldata, gasRefund = 50000, nonce = 10 + 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)) + beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) - 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') + context('when the service is allowed', () => { + const from = offChainRelayerService - await assertRevert(app.write(10, { from: someone }), 'APP_AUTH_FAILED') - }) - - context('when the service is not allowed', () => { - const from = offChainRelayerService + beforeEach('allow service', async () => await relayer.allowService(offChainRelayerService, { from: root })) - 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 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 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 }) + }) - 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('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) - 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 { 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 txRefund = gasRefund * GAS_PRICE - const realTxCost = gasPriceUsed.mul(gasUsed) + const currentRelayerBalance = await web3.eth.getBalance(relayer.address) + const currentServiceBalance = await web3.eth.getBalance(offChainRelayerService) - 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()) + }) - 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 }) - it('updates the last nonce used of the sender', async () => { - await relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }) + assert.equal((await relayer.getLastUsedNonce(member)).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') + }) - assert.equal((await relayer.getLastUsedNonce(member)).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 currentMonth = await relayer.getCurrentMonth() + const previousMonthlyRefunds = await relayer.getMonthlyRefunds(member, currentMonth) + await relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }) - it('updates the monthly refunds of the sender', async () => { - const currentMonth = await relayer.getCurrentMonth() - const previousMonthlyRefunds = await relayer.getMonthlyRefunds(member, currentMonth) - await relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }) + const txRefund = gasRefund * GAS_PRICE + const currentMonthlyRefunds = await relayer.getMonthlyRefunds(member, currentMonth) + assert.equal(previousMonthlyRefunds.toString(), currentMonthlyRefunds.minus(txRefund).toString(), 'total refunds should have been updated') + }) - const txRefund = gasRefund * GAS_PRICE - const currentMonthlyRefunds = await relayer.getMonthlyRefunds(member, currentMonth) - 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 }) - 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 }) + }) - 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 }) - 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 { 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 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 - const firstGasOverload = firstRelayedGasUsed - nonRelayerGasUsed - const secondGasOverload = secondRelayedGasUsed - nonRelayerGasUsed + console.log('firstGasOverload:', firstGasOverload) + console.log('secondGasOverload:', secondGasOverload) - 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') + })) + }) - 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 relayer does not have funds', () => { + 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_REFUND_FAIL') + await assertRevert(relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_GAS_QUOTA_EXCEEDED') }) }) }) - 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 }) + 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_GAS_QUOTA_EXCEEDED') + await assertRevert(relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_NONCE_ALREADY_USED') }) }) }) - 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 }) - }) - + context('when the signature is not valid', () => { it('reverts', async () => { - await assertRevert(relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_NONCE_ALREADY_USED') + 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 signature is not valid', () => { - it('reverts', async () => { - const signature = web3.eth.sign(member, 'bla') + context('when the relayed call reverts', () => { + beforeEach('allow sender', async () => await relayer.allowSender(someone, { from: root })) - await assertRevert(relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_INVALID_SENDER_SIGNATURE') - }) - }) - }) + 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 }) - context('when the relayed call reverts', () => { - beforeEach('allow sender', async () => await relayer.allowSender(someone, { from: root })) + await assertRevert(relayer.relay(someone, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'APP_AUTH_FAILED') + }) + }) - 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 }) + 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 }), 'APP_AUTH_FAILED') + await assertRevert(relayer.relay(someone, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_INVALID_SENDER_SIGNATURE') + }) }) }) + }) - 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 sender is 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_SENDER_NOT_ALLOWED') + await assertRevert(relayer.relay(member, someone, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_SERVICE_NOT_ALLOWED') }) }) }) - context('when the service is not allowed', () => { - const from = someone - + context('when the app is not initialized', () => { it('reverts', async () => { - await assertRevert(relayer.relay(member, someone, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }), 'RELAYER_SERVICE_NOT_ALLOWED') + await assertRevert(relayer.relay(member, someone, 1, '0x', 10, GAS_PRICE, '0x'), '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)) - 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}) - context('when the sender is allowed', () => { - const from = root + assert(await relayer.isServiceAllowed(someone), 'service should be allowed') + }) - it('adds a new allowed service', async () => { - await relayer.allowService(someone, {from}) + it('emits an event', async () => { + const receipt = await relayer.allowService(someone, {from}) - assert(await relayer.isServiceAllowed(someone), 'service should be allowed') + assertAmountOfEvents(receipt, 'ServiceAllowed') + assertEvent(receipt, 'ServiceAllowed', {service: someone}) + }) }) - it('emits an event', async () => { - const receipt = await relayer.allowService(someone, {from}) + context('when the sender is not allowed', () => { + const from = someone - assertAmountOfEvents(receipt, 'ServiceAllowed') - assertEvent(receipt, 'ServiceAllowed', {service: someone}) + it('reverts', async () => { + await assertRevert(relayer.allowService(someone, {from}), 'APP_AUTH_FAILED') + }) }) }) - context('when the sender is not allowed', () => { - const from = someone - + context('when the app is not initialized', () => { it('reverts', async () => { - await assertRevert(relayer.allowService(someone, {from}), 'APP_AUTH_FAILED') + await assertRevert(relayer.allowService(someone, { from: root }), '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)) - 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 - context('when the sender is allowed', () => { - const from = root + it('adds a new allowed service', async () => { + await relayer.disallowService(someone, { from }) - it('adds a new allowed service', async () => { - await relayer.disallowService(someone, { from }) + assert.isFalse(await relayer.isServiceAllowed(someone), 'service should not be allowed') + }) - 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 }) + }) }) - it('emits an event', async () => { - const receipt = await relayer.disallowService(someone, { from }) + context('when the sender is not allowed', () => { + const from = someone - assertAmountOfEvents(receipt, 'ServiceDisallowed') - assertEvent(receipt, 'ServiceDisallowed', { service: someone }) + it('reverts', async () => { + await assertRevert(relayer.disallowService(someone, { from }), 'APP_AUTH_FAILED') + }) }) }) - context('when the sender is not allowed', () => { - const from = someone - + context('when the app is not initialized', () => { it('reverts', async () => { - await assertRevert(relayer.disallowService(someone, { from }), 'APP_AUTH_FAILED') + await assertRevert(relayer.disallowService(someone, { from: root }), '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 - describe('allowSender', () => { - context('when the app is initialized', () => { - beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + it('adds a new allowed sender', async () => { + await relayer.allowSender(someone, {from}) - context('when the sender is allowed', () => { - const from = root + assert(await relayer.isSenderAllowed(someone), 'sender should be allowed') + }) - it('adds a new allowed sender', async () => { - await relayer.allowSender(someone, {from}) + it('emits an event', async () => { + const receipt = await relayer.allowSender(someone, {from}) - assert(await relayer.isSenderAllowed(someone), 'sender should be allowed') + assertAmountOfEvents(receipt, 'SenderAllowed') + assertEvent(receipt, 'SenderAllowed', { sender: someone }) + }) }) - it('emits an event', async () => { - const receipt = await relayer.allowSender(someone, {from}) + context('when the sender is not allowed', () => { + const from = someone - assertAmountOfEvents(receipt, 'SenderAllowed') - assertEvent(receipt, 'SenderAllowed', { sender: someone }) + it('reverts', async () => { + await assertRevert(relayer.allowSender(someone, {from}), 'APP_AUTH_FAILED') + }) }) }) - context('when the sender is not allowed', () => { - const from = someone - + context('when the app is not initialized', () => { it('reverts', async () => { - await assertRevert(relayer.allowSender(someone, {from}), 'APP_AUTH_FAILED') + await assertRevert(relayer.allowSender(someone, { from: root }), '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)) - 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 }) - context('when the sender is allowed', () => { - const from = root + assert.isFalse(await relayer.isSenderAllowed(someone), 'sender should not be allowed') + }) - it('adds a new allowed sender', async () => { - await relayer.disallowSender(someone, { from }) + it('emits an event', async () => { + const receipt = await relayer.disallowSender(someone, { from }) - assert.isFalse(await relayer.isSenderAllowed(someone), 'sender should not be allowed') + assertAmountOfEvents(receipt, 'SenderDisallowed') + assertEvent(receipt, 'SenderDisallowed', { sender: someone }) + }) }) - it('emits an event', async () => { - const receipt = await relayer.disallowSender(someone, { from }) + context('when the sender is not allowed', () => { + const from = someone - assertAmountOfEvents(receipt, 'SenderDisallowed') - assertEvent(receipt, 'SenderDisallowed', { sender: someone }) + it('reverts', async () => { + await assertRevert(relayer.disallowSender(someone, { from }), 'APP_AUTH_FAILED') + }) }) }) - context('when the sender is not allowed', () => { - const from = someone - + context('when the app is not initialized', () => { it('reverts', async () => { - await assertRevert(relayer.disallowSender(someone, { from }), 'APP_AUTH_FAILED') + await assertRevert(relayer.disallowSender(someone, { from: root }), '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 - 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 app is initialized', () => { - beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + context('when the sender is allowed', () => { + const from = root - context('when the sender is allowed', () => { - const from = root + it('changes the monthly refunds quota', async () => { + await relayer.setMonthlyRefundQuota(newQuota, { from }) - 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 }) - assert.equal((await relayer.getMonthlyRefundQuota()).toString(), newQuota, 'monthly refunds quota does not match') + assertAmountOfEvents(receipt, 'MonthlyRefundQuotaSet') + assertEvent(receipt, 'MonthlyRefundQuotaSet', { who: from, previousQuota: MONTHLY_REFUND_QUOTA, newQuota }) + }) }) - it('emits an event', async () => { - const receipt = await relayer.setMonthlyRefundQuota(newQuota, { from }) + context('when the sender is not allowed', () => { + const from = someone - assertAmountOfEvents(receipt, 'MonthlyRefundQuotaSet') - assertEvent(receipt, 'MonthlyRefundQuotaSet', { who: from, previousQuota: MONTHLY_REFUND_QUOTA, newQuota }) + it('reverts', async () => { + await assertRevert(relayer.setMonthlyRefundQuota(newQuota, { from }), 'APP_AUTH_FAILED') + }) }) }) - context('when the sender is not allowed', () => { - const from = someone - + context('when the app is not initialized', () => { it('reverts', async () => { - await assertRevert(relayer.setMonthlyRefundQuota(newQuota, { from }), 'APP_AUTH_FAILED') + await assertRevert(relayer.setMonthlyRefundQuota(newQuota, { from: root }), '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/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, } From a439ac5981c59f13db64893618543e7765e04d22 Mon Sep 17 00:00:00 2001 From: Facundo Spagnuolo Date: Wed, 22 May 2019 12:36:17 -0300 Subject: [PATCH 19/19] meta-txs: store senders information using a struct --- contracts/lib/misc/EIP712.sol | 14 ++- contracts/relayer/Relayer.sol | 164 ++++++++++++++++-------------- test/contracts/relayer/relayer.js | 134 +++++++----------------- 3 files changed, 130 insertions(+), 182 deletions(-) diff --git a/contracts/lib/misc/EIP712.sol b/contracts/lib/misc/EIP712.sol index ada700abe..9798aba39 100644 --- a/contracts/lib/misc/EIP712.sol +++ b/contracts/lib/misc/EIP712.sol @@ -12,12 +12,12 @@ contract EIP712 { address verifyingContract; } - function _domainSeparator(string name, string version, uint256 chainId, address verifyingContract) internal pure returns (bytes32) { + function _domainSeparator() internal view returns (bytes32) { return _hash(Domain({ - name: name, - version: version, - chainId: chainId, - verifyingContract: verifyingContract + name: _domainName(), + version: _domainVersion(), + chainId: _domainChainId(), + verifyingContract: address(this) })); } @@ -30,4 +30,8 @@ contract EIP712 { domain.verifyingContract )); } + + function _domainName() internal view returns (string); + function _domainVersion() internal view returns (string); + function _domainChainId() internal view returns (uint256); } diff --git a/contracts/relayer/Relayer.sol b/contracts/relayer/Relayer.sol index 81624b951..54c592874 100644 --- a/contracts/relayer/Relayer.sol +++ b/contracts/relayer/Relayer.sol @@ -81,28 +81,30 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage, EIP712 { /** * @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 member - * @param newQuota New monthly refunds quota in ETH for each allowed member + * @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); - // Timestamp to start counting monthly refunds quotas for each member + // 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 member + // Monthly refunds quota in ETH for each sender uint256 internal monthlyRefundQuota; - // Mapping that indicates whether a given address is allowed to use the relay service - mapping (address => bool) internal allowedSenders; - // Mapping that indicates whether a given address is allowed as off-chain service to relay transactions mapping (address => bool) internal allowedServices; - // Mapping from members to nonce numbers that indicates the last nonce used by each member - mapping (address => uint256) internal lastUsedNonce; - - // Mapping from members to monthly refunds that indicates the refunds requested per member per month - mapping (address => mapping (uint256 => uint256)) internal monthlyRefunds; + // 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() { @@ -110,15 +112,9 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage, EIP712 { _; } - // Check whether a given address belongs to the list of allowed senders that can use the relay service - modifier onlyAllowedSender(address sender) { - require(_isSenderAllowed(sender), ERROR_SENDER_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 member + * @param _monthlyRefundQuota Monthly refunds quota in ETH for each allowed sender */ function initialize(uint256 _monthlyRefundQuota) external onlyInit { initialized(); @@ -140,18 +136,17 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage, EIP712 { function relay(address from, address to, uint256 nonce, bytes data, uint256 gasRefund, uint256 gasPrice, bytes signature) external onlyAllowedServices - onlyAllowedSender(from) { + Sender storage sender = senders[from]; uint256 currentMonth = _getCurrentMonth(); uint256 requestedRefund = gasRefund.mul(gasPrice); - require(_canUseNonce(from, nonce), ERROR_NONCE_ALREADY_USED); - require(_canRefund(from, currentMonth, requestedRefund), ERROR_GAS_QUOTA_EXCEEDED); + 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); - lastUsedNonce[from] = nonce; - monthlyRefunds[from][currentMonth] = monthlyRefunds[from][currentMonth].add(requestedRefund); - + _updateSenderInfo(sender, nonce, currentMonth, requestedRefund); _relayCall(from, to, data); emit TransactionRelayed(from, to, nonce, data); @@ -179,25 +174,25 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage, EIP712 { /** * @notice Add a new sender `sender` to the list of allowed addresses that can use the relay service - * @param sender Address of the sender to be allowed + * @param senderAddress Address of the sender to be allowed */ - function allowSender(address sender) external authP(ALLOW_SENDER_ROLE, arr(sender)) { - allowedSenders[sender] = true; - emit SenderAllowed(sender); + 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 sender Address of the sender to be disallowed + * @param senderAddress Address of the sender to be disallowed */ - function disallowSender(address sender) external authP(DISALLOW_SENDER_ROLE, arr(sender)) { - allowedSenders[sender] = false; - emit SenderDisallowed(sender); + 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 member + * @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); @@ -205,16 +200,30 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage, EIP712 { } /** - * @notice Return the start date timestamp used to count the monthly refunds quotas for each member. - * @return The start date timestamp used to count the monthly refunds quotas for each member + * @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 member. - * @return The monthly refunds quotas for each member + * @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; @@ -228,22 +237,6 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage, EIP712 { return _getCurrentMonth(); } - /** - * @notice Return the last used nonce for a given sender `sender`. - * @return The last used nonce for a given sender - */ - function getLastUsedNonce(address sender) external view isInitialized returns (uint256) { - return _getLastUsedNonce(sender); - } - - /** - * @notice Return the amount of refunds for a given sender `sender` corresponding to month `month`. - * @return The amount of refunds for a given sender in a certain month - */ - function getMonthlyRefunds(address sender, uint256 month) external view isInitialized returns (uint256) { - return _getMonthlyRefunds(sender, month); - } - /** * @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 @@ -256,24 +249,25 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage, EIP712 { * @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 sender) external view isInitialized returns (bool) { - return _isSenderAllowed(sender); + 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 sender, uint256 nonce) external view isInitialized returns (bool) { - return _canUseNonce(sender, nonce); + 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 sender, uint256 month, uint256 amount) external view isInitialized returns (bool) { - return _canRefund(sender, month, amount); + function canRefund(address senderAddress, uint256 amount) external view isInitialized returns (bool) { + uint256 currentMonth = _getCurrentMonth(); + return _canRefund(senders[senderAddress], currentMonth, amount); } /** @@ -291,29 +285,23 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage, EIP712 { return passedSeconds / 30 days; } - function _getLastUsedNonce(address sender) internal view returns (uint256) { - return lastUsedNonce[sender]; - } - - function _getMonthlyRefunds(address sender, uint256 month) internal view returns (uint256) { - return monthlyRefunds[sender][month]; - } - function _isServiceAllowed(address service) internal view returns (bool) { return allowedServices[service]; } - function _isSenderAllowed(address sender) internal view returns (bool) { - return allowedSenders[sender]; + function _isSenderAllowed(Sender storage sender) internal view returns (bool) { + return sender.allowed; } - function _canUseNonce(address sender, uint256 nonce) internal view returns (bool) { - return _getLastUsedNonce(sender) < nonce; + function _canUseNonce(Sender storage sender, uint256 nonce) internal view returns (bool) { + return sender.lastUsedNonce < nonce; } - function _canRefund(address sender, uint256 month, uint256 amount) internal view returns (bool) { - uint256 monthRefunds = _getMonthlyRefunds(sender, month); - return monthRefunds.add(amount) <= monthlyRefundQuota; + 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) @@ -331,10 +319,6 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage, EIP712 { return keccak256(abi.encodePacked("\x19\x01", _domainSeparator(), hash)); } - function _domainSeparator() internal view returns (bytes32) { - return _domainSeparator(EIP_712_DOMAIN_NAME, EIP_712_DOMAIN_VERSION, EIP_712_DOMAIN_CHAIN_ID, address(this)); - } - function _relayCall(address from, address to, bytes data) internal { bytes memory encodedSignerData = data.append(from); assembly { @@ -346,4 +330,26 @@ contract Relayer is IRelayer, AragonApp, DepositableStorage, EIP712 { } } } + + 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/test/contracts/relayer/relayer.js b/test/contracts/relayer/relayer.js index d4d1c5e1c..b9f4f5c9f 100644 --- a/test/contracts/relayer/relayer.js +++ b/test/contracts/relayer/relayer.js @@ -216,51 +216,18 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) }) }) - describe('getLastUsedNonce', () => { + 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 zero', async () => { - assert.equal((await relayer.getLastUsedNonce(member)).toString(), 0, 'last nonce does not match') - }) - }) - - context('when the given sender has already sent some transactions', () => { - beforeEach('relay a transaction', async () => { - const nonce = 2 - const calldata = '0x11111111' - const gasRefund = 50000 - 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 last nonce', async () => { - assert.equal((await relayer.getLastUsedNonce(member)).toString(), 2, 'last nonce does not match') - }) - }) - }) - - context('when the app is not initialized', () => { - it('reverts', async () => { - await assertRevert(relayer.getLastUsedNonce(member), 'INIT_NOT_INITIALIZED') - }) - }) - }) - - describe('getMonthlyRefunds', () => { - const month = 0 - - context('when the app is initialized', () => { - beforeEach('initialize relayer app', async () => await relayer.initialize(MONTHLY_REFUND_QUOTA)) + it('returns empty data', async () => { + const [allowed, lastUsedNonce, lastActiveMonth, lastActiveMonthRefunds] = await relayer.getSender(member) - context('when the given sender did not send transactions yet', () => { - it('returns zero', async () => { - assert.equal((await relayer.getMonthlyRefunds(member, month)).toString(), 0, 'monthly refunds do not match') + 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') }) }) @@ -278,19 +245,20 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) await relayer.relay(member, someone, nonce, calldata, gasRefund, GAS_PRICE, signature, { from: offChainRelayerService }) }) - it('returns the last nonce', async () => { - assert.equal((await relayer.getMonthlyRefunds(member, month)).toString(), gasRefund * GAS_PRICE, 'monthly refunds do not match') - }) + it('returns the corresponding information', async () => { + const [allowed, lastUsedNonce, lastActiveMonth, lastActiveMonthRefunds] = await relayer.getSender(member) - it('returns zero for the next month', async () => { - assert.equal((await relayer.getMonthlyRefunds(member, month + 1)).toString(), 0, 'monthly refunds do not match') + 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.getMonthlyRefunds(member, month), 'INIT_NOT_INITIALIZED') + await assertRevert(relayer.getSender(member), 'INIT_NOT_INITIALIZED') }) }) }) @@ -494,11 +462,8 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) describe('canRefund', () => { context('when the app is initialized', () => { - let currentMonth - beforeEach('initialize relayer app', async () => { await relayer.initialize(MONTHLY_REFUND_QUOTA) - currentMonth = await relayer.getCurrentMonth() }) context('when the given sender did not send transactions yet', () => { @@ -506,7 +471,7 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) const amount = MONTHLY_REFUND_QUOTA - 1 it('returns true', async () => { - assert(await relayer.canRefund(member, currentMonth, amount), 'should be allowed to spend given amount') + assert(await relayer.canRefund(member, amount), 'should be allowed to spend given amount') }) }) @@ -514,7 +479,7 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) const amount = MONTHLY_REFUND_QUOTA it('returns true', async () => { - assert(await relayer.canRefund(member, currentMonth, amount), 'should be allowed to spend given amount') + assert(await relayer.canRefund(member, amount), 'should be allowed to spend given amount') }) }) @@ -522,7 +487,7 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) const amount = MONTHLY_REFUND_QUOTA + 1 it('returns false', async () => { - assert.isFalse(await relayer.canRefund(member, currentMonth, amount), 'should not be allowed to spend given amount') + assert.isFalse(await relayer.canRefund(member, amount), 'should not be allowed to spend given amount') }) }) }) @@ -543,55 +508,27 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) await relayer.relay(member, someone, nonce, calldata, gasRefund, GAS_PRICE, signature, { from: offChainRelayerService }) }) - context('when the asking for the current month', () => { - 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, currentMonth, amount), 'should be allowed to spend amount') - }) - }) - - context('when the requested amount is equal to the remaining monthly quota', () => { - const amount = remainingQuota + 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, currentMonth, 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, currentMonth, amount), 'should not be allowed to spend amount') - }) + it('returns true', async () => { + assert.isTrue(await relayer.canRefund(member, amount), 'should be allowed to spend amount') }) }) - context('when the asking for the next month', () => { - context('when the requested amount does not exceed the remaining monthly quota', () => { - const amount = remainingQuota - 1 + 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, currentMonth + 1, 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, currentMonth + 1, amount), 'should be allowed to spend amount') - }) + 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 + context('when the requested amount is greater than the remaining monthly quota', () => { + const amount = remainingQuota + 1 - it('returns true', async () => { - assert.isTrue(await relayer.canRefund(member, currentMonth + 1, amount), 'should be allowed to spend amount') - }) + it('returns false', async () => { + assert.isFalse(await relayer.canRefund(member, amount), 'should not be allowed to spend amount') }) }) }) @@ -599,7 +536,7 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) context('when the app is not initialized', () => { it('reverts', async () => { - await assertRevert(relayer.canRefund(member, 0, MONTHLY_REFUND_QUOTA), 'INIT_NOT_INITIALIZED') + await assertRevert(relayer.canRefund(member, MONTHLY_REFUND_QUOTA), 'INIT_NOT_INITIALIZED') }) }) }) @@ -657,18 +594,19 @@ contract('Relayer', ([_, root, member, someone, vault, offChainRelayerService]) it('updates the last nonce used of the sender', async () => { await relayer.relay(member, app.address, nonce, calldata, gasRefund, GAS_PRICE, signature, { from }) - assert.equal((await relayer.getLastUsedNonce(member)).toString(), nonce, 'last nonce should match') + 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 currentMonth = await relayer.getCurrentMonth() - const previousMonthlyRefunds = await relayer.getMonthlyRefunds(member, currentMonth) + 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.getMonthlyRefunds(member, currentMonth) + const currentMonthlyRefunds = (await relayer.getSender(member))[3] assert.equal(previousMonthlyRefunds.toString(), currentMonthlyRefunds.minus(txRefund).toString(), 'total refunds should have been updated') })