On ethereum, there are two main smart contract languages which are currently being used to deploy smart contracts: Solidity and Vyper. In this series of posts, I will discuss some of the peculiarities of writing smart contracts and use examples from both languages to explain them.
The first contract that we will look at is the ERC20 token
, of which there are many examples such as Dai
, WETH
and UNI
. The smart contracts for these tokens act as a distributed ledger, which keeps track of the balances of all users. When a user transfers their tokens to another user, the smart contract must first check whether the user has sufficient balance, and then update the balances of both the sender and receiver. To highlight some of the differences between Solidity and Vyper, I will display just the code required to transfer tokens from the sender to another user. Full implementations can be found for Solidity and Vyper.
Solidity
pragma solidity ^0.8.0;
contract ERC20 {
mapping(address => uint256) public balanceOf;
function transfer(address to, uint256 amount) public returns (bool) {
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
return true;
}
}
Vyper
# @version ^0.3.0
balanceOf: public(HashMap[address, uint256])
@external
def transfer(to: address, amount: uint256) -> bool:
self.balanceOf[msg.sender] -= amount
self.balanceOf[to] += amount
return True
It should be apparent that both smart contract languages are very similar in their implementation of an ERC20 token. Both contracts utilise a mapping from an address
to a uint256
in order to signify a users balance.
It is important to highlight what would happen with arithmetic over/underflow. If we allow a user to send tokens when they have an insufficient balance, this would result in that user ending with a negative amount of tokens. As the balance is stored as a uint256
, this would underflow - causing the user to have a balance in the region of 2 ** 256
which would obviously be a very serious bug! All versions of Vyper natively check for these arithmetic errors, and cause the transaction to be reverted. This feature was also added to Solidity in version 0.8.0. Anybody choosing to write a contract in an older version of Solidity will need to manually check for over/underflow or utilise a library to do this.
To give an illustration of what would happen if we use an older version of Solidity, I have used the exact same code as presented above but changed the compiler version to 0.7.0. If we run the code, we get the following:
>>> sender = accounts[0]
>>> receiver = accounts[1]
>>> overflow_erc20 = sender.deploy(ERC20)
Transaction sent: 0xd7f0c229b122fb0795d03693222b527c5a7f0077651f36c93ec9e58e01125d8a
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 0
ERC20.constructor confirmed Block: 1 Gas used: 116515 (0.97%)
ERC20 deployed at: 0x299784aE2AF66133155848a9D2bcAde3B9f5b32c
>>> overflow_erc20.transfer(receiver, 1, {"from": sender})
Transaction sent: 0x4383921e24d79d235318c2fbad56a191586ab113f1c515ad94bd500f42c156ad
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 1
ERC20.transfer confirmed Block: 2 Gas used: 63634 (0.53%)
<Transaction '0x4383921e24d79d235318c2fbad56a191586ab113f1c515ad94bd500f42c156ad'>
>>> overflow_erc20.balanceOf(receiver)
1
>>> overflow_erc20.balanceOf(sender)
115792089237316195423570985008687907853269984665640564039457584007913129639935
This overflow error has resulted in the user now having more tokens than there are atoms in the universe!
Developers must be very careful to ensure that these errors do not occur. In particular, it is important to be aware of the different behaviour provided by different compiler versions. When deploying any smart contract, it is important to have the code reviewed (either by external reviewers, or full audits) to reduce the risk of accidentally introducing any error that could result in significant loss for its users.
Introduction
A few topics recently crossed my attention. A user on reddit lost 500K by sending WETH to the WETH contract address
Reddit: Did I just lose half a million dollars by sending WETH to WETH's contract address?
There has been some discussion recently on gas optimisations in smart contracts, which has been particularly relevant due to increasing gas prices.
Should a smart contract prevent users from performing actions that would result in the loss of user funds?
Adding extra checks (such as ensuring that the sender
and receiver
are not the same as the contract address) adds an additional gas stipend onto each transaction.
An argument could be made that it should be the responsibility of wallets and front-ends to prevent these transactions from ever reaching the blockchain. However, given that it is fairly common for tokens to be sent to the contract address, we know that this is not the case.
I thought it would be a good idea to look into this further to decide for myself whether it made sense to add these checks from an economic perspective.
Idea
I would write two smart contracts representing ERC20 tokens: an 'unsafe' ERC20 which allows any transfer (ofcourse, the standard rules of balances and approvals will remain) and a 'safe' ERC20 which checks that the receiver and sender are valid addresses (by ensuring that they are not the zero address, or the contract address)
From these contracts, I will get an estimate of the average gas cost for each function.
I will then try and use these figures to get an idea of the gas saving that would have occurred during the lifespan of the WETH contract. I will also get an idea of how much WETH has been 'lost' after being sent to the WETH contract and compare these figures
Data
All contracts/scripts are available on Github.
The gas costs for each of the functions is displayed below, I will be using the average for my calculations. The average has been calculated from 1000 executions with random variables
safeERC20 <Contract>
├─ approve - avg: 42565 avg (confirmed): 42565 low: 24521 high: 44117
├─ transfer - avg: 31945 avg (confirmed): 31945 low: 27012 high: 35808
└─ transferFrom - avg: 20442 avg (confirmed): 20442 low: 18819 high: 29250
unsafeERC20 <Contract>
├─ approve - avg: 42519 avg (confirmed): 42519 low: 24475 high: 44071
├─ transfer - avg: 31875 avg (confirmed): 31875 low: 26942 high: 35738
└─ transferFrom - avg: 20400 avg (confirmed): 20400 low: 18782 high: 29176
In order to get a list of transactions on the WETH contract, I initially performed the following query:
SELECT
input, gas_price, gas
FROM `bigquery-public-data.crypto_ethereum.transactions` as tx
WHERE
tx.to_address = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'
I then realised that this misses out on the majority of transactions on the WETH contract given that many contracts will sub-call the WETH contract, therefore the to
address on the transaction will not be the WETH address.
My new updated query looks like:
WITH traces AS (
SELECT tr.transaction_hash, tr.gas_used, SUBSTRING(tr.input, 0, 10) as sig_hash
FROM `bigquery-public-data.crypto_ethereum.traces` tr --tracks internal transactions
WHERE tr.to_address = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' -- WETH
AND tr.status = 1
AND tr.call_type = 'call'
),
txs AS (
SELECT tr.sig_hash, (tx.gas_price)/1e9 as gwei
FROM `bigquery-public-data.crypto_ethereum.transactions` as tx
JOIN traces tr
ON tr.transaction_hash = tx.hash
)
SELECT sig_hash, SUM(gwei) as gwei, COUNT(gwei) FROM txs
GROUP BY sig_hash
I didn't think that this query would also return the calls for totalSupply()
decimals()
, etc. In future queries, I'll make sure to filter these out!
The script get_gas_saving.py
iterates through the functions above and sums the gas saved for each function. The output is as follows:
Running 'scripts/get_gas_saving.py::main'...
970677015.604 Gwei/gas spent on performing transferFrom(). Each function saves 42 gas
Total ETH saved by transferFrom(): 40.768
--
8570367257.755 Gwei/gas spent on performing transfer(). Each function saves 70 gas
Total ETH saved by transfer(): 599.926
--
311808609.989 Gwei/gas spent on performing approve(). Each function saves 46 gas
Total ETH saved by approve(): 14.343
--
Total ETH saving across all functions: 655.037
Total number of transactions: 91959066
Discussion
A cursory glance at the etherscan page shows that the WETH contract has 432.162 WETH lost within the contract, which is lower than the 655 ETH saved in gas fees.
The conclusion that I would draw here is that users of a smart contract would end up overpaying for the additional sanity checks discussed above.
It may be interesting to perform the same query looking at all ERC20 tokens, rather than just WETH to see whether the same conclusion is valid across all ERC20s or just WETH.
However, another argument could be made that it doesn't really make too much of a difference given the sheer number of transactions that have taken place. The difference of 223 ETH, when amortised over 92 million transactions is a value of 0.0000024 ETH per transaction.
After I published this post, I spoke with a few other people. One interesting perspective was that the additional sanity checks act as a form of socialised insurance. I personally do not mind paying fractionally more on a transaction, knowing that it might prevent somebody else from losing their funds.