Ethereum smart service payment with tokens
I no longer post directly on Medium; to view the latest copy of this article please go to https://www.wealdtech.com/articles/ethereum-smart-service-payment-with-tokens/
The previous article explained how Ethereum, and specifically ERC-20, token contracts operate. This article examines how tokens can be used to pay for services provided by smart contracts.
It is worth first examining the process by which Ether, Ethereum’s base currency, is used to pay for a service. Take a service that stores a number on the blockchain in return for payment of 1 Ether: when a user wishes to store something they send the information they want to store along with the payment in a transaction:
The above diagram shows a sender sending a transaction to a service contract, with the transaction calling storeData(4)
to store the value 4 and including a payment of 1 Ether. The equivalent service contract function in Solidity to carry out this operation would look as follows:
function storeData(uint256 payload) public payable {
require(msg.value == 1 ether);
info[msg.sender] = payload;
}
This function is very simple: the first line confirms that the sender has sent 1 Ether with the transaction, and the second line stores the payload (in the above example the payload is 4).
Now consider a similar service that requires payment in tokens rather than Ether. Suppose that the STORE token exists and that it costs 1 STORE token to store information. It might be assumed that the equivalent transaction to the one above but with a token for payment might work as shown below:
and the service contract function to look as follows:
function storeData(uint256 payload) public payable {
require(msg.value == 1 STORE);
info[msg.sender] = payload;
}
however this is not possible because value in Ethereum is always in Ether and cannot be an amount of tokens. Instead, the sender and/or the service contract must interact with the STORE token contract to move the tokens from sender to service contract. There are a number of ways to do this, and this article will provide details of the most common.
approve() and transferFrom()
The approve()
and transferFrom()
functions are part of the ERC-20 standard and should be present in every compliant token contract. The operation of approve()
is to authorise a third party (in this case the service contract) to transfer tokens from the sender’s account, and the operation of transferFrom()
is to carry out the transfer by the third party.
The process is as follows:
- The sender informs the token contract that a payment of 1 STORE to the service contract is authorised (by calling the token contract’s
approve()
function) - The sender asks the service contract to carry out a service (by calling the service contract’s
storeData()
function) - The service contract instructs the token contract to transfer payment from the sender’s account to the service contract’s account (by calling the token contract’s
transferFrom()
function) and stores the data
A graphical view of this process is shown below:
Here the dashed line for the transferFrom()
function shows that it is called as part of the storeData()
transaction, not requiring any manual intervention on behalf of the service contract to trigger it (this also means that it will not show up as a separate transaction on the blockchain, but be part of the storeData()
transaction).
Using a token in this manner changes the storeData()
service contract function in Solidity to the following:
function storeData(uint256 payload) public {
require(tokenContract.transferFrom(msg.sender, address(this), 1));
info[msg.sender] = payload;
}
The first line has changed to carry out the pre-approved transfer of 1 STORE token from the sender of the message to the service contract. If this fails for any reason then the function will terminate, ensuring that the contract is paid for the work it undertakes. Otherwise it stores the payload as requested.
approveAndCall()
Although the above process works it requires two transactions from the sender, one for approve()
and one for storeData()
. This is undesirable as it puts additional work on to the sender. Use of approveAndCall()
allows the sender to carry out the same work with a single transaction.
The process is as follows:
- The sender informs the token contract that a payment of 1 STORE to the service contract is authorised (by calling the token contract’s
approveAndCall()
function) - The token contract call informs the service contract that a payment of 1 STORE to the service contract has been authorised (by calling the service contract’s
receiveApproval()
function) - The service contract instructs the token contract to transfer payment from the sender’s account to the service contract’s account (by calling the token contract’s
transferFrom()
function) and stores the data
A graphical view of this process is shown below:
This requires approveAndCall()
to be present in the token contract, as follows:
function approveAndCall(address _recipient,
uint256 _value,
bytes _extraData) {
approve(_recipient, _value);
TokenRecipient(_recipient).receiveApproval(msg.sender,
_value,
address(this),
_extraData);
}
where the first line carries out the standard approve()
call and the second line calls the receiveApproval()
function in the service contract. The receiveApproval()
function in the service contract is as follows:
function receiveApproval(address _sender,
uint256 _value,
TokenContract _tokenContract,
bytes _extraData) {
require(_tokenContract == tokenContract);
require(tokenContract.transferFrom(_sender, address(this), 1));
uint256 payloadSize;
uint256 payload;
assembly {
payloadSize := mload(_extraData)
payload := mload(add(_extraData, 0x20))
}
payload = payload >> 8*(32 - payloadSize);
info[_sender] = payload;
}
where the first line confirms that it is the token contract calling this function, the second line obtains the required tokens, lines 3–9 obtain the payload and line 10 stores the payload.
It can immediately be seen that a major concern is that receiveApproval()
contains significantly more complex code than has been seen so far. This is due to the fact that the data is passed as bytes
and so needs additional structure to understand the value, and additional work to decode the value according to that structure. It also needs to be encoded prior to being sent, increasing the complexity to the sender.
Additionally, it is important to note that the definition of approveAndCall()
is not part of the ERC-20 standard, which means that its implementation can be spotty. Specifically:
- Some token contracts do not support
approveAndCall()
at all - Some token contracts only support a single hard-coded
receiveApproval()
function as the target ofapproveAndCall()
(as shown in the diagram above) - Some token contracts support calling arbitrary service contract functions by adding the relevant function signature to the
approveAndCall()
transmitted by the sender, as shown in the diagram below (withstoreData()
added to the sender’s call):
This has implications for both token contract creators, who need to choose which of the various implementations they wish to support, and token users, who need to find out which implementation is supported prior to using the functionality.
transferAndCall()
ERC-677 provides a variant of approveAndCall()
called transferAndCall()
. As might be surmised from the function name, transferAndCall()
carries out a transfer()
and then calls a function on the service contract.
The process is as follows:
- The sender transfers 1 STORE to the service contract (by calling the token contract’s
transferAndCall()
function) - The token contract call informs the service contract that a payment of 1 STORE to the service contract has been made (by calling the token contract’s
transferAndCall()
function) - The service contract carries out the service(the token contract’s
tokenFallback()
function)
A graphical view of this process is shown below:
This requires the non-standard transferAndCall()
function to be present in the token contract as follows:
function transferAndCall(address _recipient,
uint256 _value,
bytes _extraData) {
transfer(_recipient, _value);
require(TokenRecipient(_recipient).tokenFallback(msg.sender,
_value,
_extraData));
}
where the first line carries out the standard transfer()
call and the second line calls the tokenFallback()
function in the service contract. The tokenFallback()
function in the service contract is as follows:
function tokenFallback(address _sender,
uint256 _value,
bytes _extraData) returns (bool) {
require(msg.sender == tokenContract);
require(_value == 1);
uint256 payloadSize;
uint256 payload;
assembly {
payloadSize := mload(_extraData)
payload := mload(add(_extraData, 0x20))
}
payload = payload >> 8*(32 - payloadSize);
info[sender] = payload;
return true;
}
where the first line confirms that it is the token contract calling this function, the second line confirms the required tokens were transferred, lines 3–9 obtain the payload and line 10 stores the payload.
Initially transferAndCall()
appears to be a straight upgrade over approveAndCall()
. It provides similar functionality for less gas and lower complexity. It does, however, lose some flexibility in advanced scenarios and this will be discussed later.
As with approveAndCall()
this is a non-standard function and there are a number of variants available. It should also be noted that ERC-223’s transfer()
function contains similar functionality to transferAndCall()
and it is likely that the two proposals will be merged in to whatever final token standard supercedes ERC-20 (possibly with changes to function names and signatures).
Advanced scenarios
All of the above functions have been considered in the scope of a single fixed-price transaction, however there are two advanced scenarios that should be considered. The first is variable-price transactions and the second is repeat transactions.
Variable-price transactions are where the price of a service falls within certain bounds but is not known by the sender at the time of the transaction. An example of this is a service market where the sender might want to pay up to 4 tokens for a particular service with the price ultimately dependent on the service’s load.
Repeated transactions are where a service contract is called multiple times by the same sender. An example of this is a gambling contract where multiple bets can be placed across different games.
In both of these scenarios a sender could use a single approve()
followed by one or more calls directly to the service contract rather than multiple approveAndCall()
or transferAndCall()
functions. This allows the sender to have control of their overall cost for a batch of transactions (by setting their spending limit once in approve()
rather than managing the cost for each service contract transaction individually.
Summary
When using tokens to pay for services there are three main options available: approve()
, approveAndCall()
, and transferAndCall()
:
approve()
is a standard ERC-20 method and generally well-supported but requires multiple transactions by the senderapproveAndCall()
is more powerful but more complex and not part of the ERC-20 standard so cannot be relied upon to be present in any particular token contracttransferAndCall()
is a better solution thanapproveAndCall()
when the cost of the service is fixed but apart from that has similar complexities.
Each method has benefits and drawbacks, with no one method being the perfect solution. As such, when building a service contract that accepts tokens as payment it is important to clearly define the requirements. This will allow selection of the method or methods that best fit the contract’s needs.