Gasless or Meta Transactions

Gas can lead to UX problems, “Gasless” transactions may hold the solution.

{This post is a continuation of a previous post.}

The concept of gas is unfamiliar to most non-technical users. However the idea of paying a transaction fee is not. Many traditional brick and mortar banks charge a fixed transaction fee for each debit card transaction, or roll these fees into a fixed quarterly “maintenance” fee. Indeed such is the perceived UX problem, some chains have moved to the reverse model where the executed contract pays for the gas, rather than the transaction sender.

Gas costs can be translated into monetary cost by the equation: Cost in $ = $/ETH * Gas Price * Gas Amount. The gas amount depends on the complexity of the transaction, the gas price is a user chosen value which typically dictates how long the transaction will take to be included in a block. The time of the day has a strong influence on the level of network congestion, thus if the transaction is not urgent, a low gas price can be set and the transaction is likely to be included eventually.

Key pairs that do not have any associated Ether cannot send transactions directly on the Ethereum network due to their lack of ability to pay for gas, but fear not, several mechanisms have been devised to side step this problem. Some of these mechanisms tie in nicely with the concept of throwaway keys, previously discussed here. The ability to sign a transaction adhering to ERC-191 and send this transaction to a Ether/Gas rich node which can then broadcast this signed transaction to the network, is a powerful concept. This relayer can be economically incentivized to broadcast these transactions, via the payment of some ERC-20 tokens, or even replication of the traditional “maintenance” fee model (users pay a particular quarterly fee for transactions).

Identity Based Gas Relay

One of these gasless transaction models is described in Status IdentityGasRelay.sol. This contract inherits Identity.sol which is analysed in detail in this previous post. The nonce field inherited from Identity.sol is checked against the passed _nonce and incremented in order to maintain resistance against replay attacks and to ensure the transaction is synchronised with the Identity contract.

MANAGEMENT and ACTION keys are used to ensure the transaction signer has sufficient permission to carry out the requested transaction, while the purposeThreshold mapping is utilised to ensure a sufficient number of signatories has been reached for that key purpose. isKeyPurpose(), which was used extensively in Identity.sol is again used to ensure the key is of the purpose expected. Similar to the Identity contract, it is intended that an individual instance of this contract is deployed for each user (this is in contrast to uPort’s ERC-1056 Lightweight Identity proposal).

How it works

The main functionality of this Gas Relay is realised through two main functions: callGasRelayed(...) and approveAndCallGasRelayed(...) . The overall structure of these functions is similar:

  1. Perform sanity checks
  2. Calculate the hash of what should be signed
  3. Ensure the signature(s) are correct
  4. Send the transaction, emit events to confirm to observers
  5. Refund the caller appropriately

Both these callGasRelayed(...) and approveAndCallGasRelayed(...) take similar parameters: the transaction to be relayed destination address, the transactions value, the transactions data, the current nonce, the gas price to be refunded to the caller of this function. These functions are intended to be called by the “gas rich” relayer, and asuch logic is built into these functions to refund the caller for this gas expenditure, either in the form of Ether, or if specified an ERC-20 token of some type.

Both of these externally callable functions begins by performing some sanity and security checks: ensuring the function call has sufficient gas compared to the amount expected to be used, ensuring the nonce’s match, and in the case of approveAndCallRelayed if the refunding ERC-20 token address is valid.

The signHash is then calculated. This is a keccak hash of the passed parameters, adhering to ERC-191: signHash = keccak256(“\x19Ethereum Signed Message:\n32”, _hash); . This signHash will be used to ensure the transaction was signed by the appropriate keys.

The primary difference between these two functions lies within the checks that are carried out to ensure the signing key has the correct permissioning. callGasRelayed() allows the signatures to be from either MANAGEMENT or ACTION keys:

// callGasRelayed(...)
...
verifySignatures(
_to == address(this) ? MANAGEMENT_KEY : ACTION_KEY,
signHash,
_messageSignatures
);
...

callGasRelayed then continues to execute the transaction with a low level call: _to.call.value(_value)(_data) and emits a ExecutedGasRelayed event, indicating the success of this call. In contrast,approveAndCallGasRelayed does not allow MANAGEMENT keys to be used:

// approveAndCallGasRelayed(...)
...
verifySignatures(
ACTION_KEY, //no management with approveAndCall
signHash,
_messageSignatures
);
...

Verifying Signatures

verifySignatures() has the view modifier which allows the code within the function to read the contracts state but not modify it in anyway. Built-in modifiers such as view and pure are considered best practices, mirroring C++’s encapsulation principle nicely — limiting state modification ability as much as possible.

function verifySignatures(uint256 _requiredKey,
bytes32 _signHash,
bytes _messageSignatures )

public view returns(bool)
{
uint _amountSignatures = _messageSignatures.length / 72;
require(_amountSignatures == purposeThreshold[_requiredKey]);
bytes32 _lastKey = 0;
for (uint256 i = 0; i < _amountSignatures; i++)
{
bytes32 _currentKey = recoverKey(_signHash,_messageSignatures,i);
require(_currentKey > _lastKey); //assert keys are different
require(isKeyPurpose(_currentKey, _requiredKey));
_lastKey = _currentKey;
}
return true;
}

Multiple signatures are passed to verifySignatures() as _messageSignatures, the number of signatures determined by dividing by 72 bytes (the expected size of a singular signature = 32 + 32 + 1) and sent to recoverKey() along with the signHash calculated earlier, and the index of the signature within the _messageSignatures bytes variable.

function recoverKey (bytes32 _signHash,
bytes _messageSignature,
uint256 _pos)
pure public returns(bytes32)
{
uint8 v;
bytes32 r;
bytes32 s;
(v,r,s) = signatureSplit(_messageSignature, _pos);
return bytes32(ecrecover(_signHash,v,r,s));
}

signatureSplit() takes blob of bytes and an index pos(loop iterator) and uses inline assembly to extract the v, r and s components of the signature. Inline assembly is necessary here for fine grained extraction of information encoded in the _signatures bytes, recalling the EVM is a 256 bit stack based virtual machine, and thus defaults to words of 32 bytes. The signature format is a compact form of {bytes32 r}{bytes32 s}{uint8 v}. The values r and s are simple to extract — the index is multiplied by an offset of 32 bytes, and this index offset is added to the pointer pointing at the start of _signatures. This technique of using inline assembly to extract values is explained well here.

Compact means, uint8 is not padded to 32 bytes, which makes it a little tricker to extract v. Additionally there is no mload8 opcode in the EVM instruction set to do this. Instead we load the last 32 bytes, including 31 bytes of s , and use and with 0xff (255 in decimal) to extract the final single byte.

// signatureSplit(...)
...
assembly {
r := mload(add(_signatures, mul(32,pos)));
s := mload(add(_signatures, mul(64,pos)));
v := and(mload(add(_signatures, mul(65,pos))), 0xff);
}

ecrecover() is a function that lives in a precompiled contract, and has been available since the Frontier release. The equivalent function can be expressed in solidity. The function takes the v, r and s parameters of an EC signature and the hash of the message that these parameters represent and returns the address corresponding to the public key from the signing key pair. It is required that v is equal to 27 or 28. This parameter helps mitigate against a particular replay attack.

Back up in verifySignature() the returned address from ecrecover() is checked against the required key for that action with require(isKeyPurpose(_currentKey, _requiredKey)) . So the signature checking control flow looks like:

[callGasRelayed() or approveAndCallGasRelayed()] => verifySignatures() =>
forEach(signature){
=> recoverKey() => splitSignature() => ecrecover() => isKeyPurpose()
}

Economic Incentive

After the nonce is incremented, the intended value and data is sent, or ERC-20 token transfer approved, and aExecutedGasRelayed event emitted, it is time for the function called (the account fronting the gas) to be economically rewarded:

...
if (_gasPrice > 0) {
uint256 _amount = 21000 + (startGas - gasleft());
_amount = _amount * _gasPrice;
if (_gasToken == address(0)) { // if no token specified send Ether
address(msg.sender).transfer(_amount);
}
else { // if token specified then
ERC20Token(_gasToken).transfer(msg.sender, _amount);
}
}

This section of code can be configured as is deemed economically appropriate. For example if the relayer has a choice of Ether or refunding via an ERC-20 token approval, then it may want to check the value of the ERC-20 tokens it is receiving is sufficient and make a decision based on this information. This is most likely to be done off chain since a call to an oracle contract may not justify the increase in gas use, but the possibilities and the flexibility of a Turing complete language are evident here. As mentioned in the introduction of this post, traditional transaction fee models may be implemented here. The relayer could maintain a mapping of balances in a separate contract and decrement or increment accordingly.


Analysis

This architecture is powerful and flexible, allowing arbitrary transaction execution but there are a few concerns that arise:

  • Censorship: the transaction must somehow arrive at the relayer. Since this is assumably done off-chain there are trust assumptions that the gas rich relayer will behave as expected and not engauge of censoring of this transaction. This problem is not unique to this situation, with censorship of transactions a relatively unsolved problem in the blockchain space. “Edge of the network” problems like this are difficult to solve effectively.
  • Signature Persistence: This architecture necessitates that all the signatures required for that action be present in a single call to callGasRelayed orapproveAndGasRelayed . A slightly more flexible setup would be to have some persistence across relayed calls, although this can be established in a more roundabout method, by using IdentityGasRelay.sol to call the persistent functions in Identity.sol.
  • Signature Ordering: for some reason, perhaps to enforce simplicity and reduce gas costs, the signatures must be sent in ascending order, which reduces the flexibility of the contract.

Shout out to Austin Griffith who has contributed significantly to the space of meta transactions.

Follow me on Medium or Hit me up on Twitter: https://twitter.com/gawnieg