Simple summary
A proposal for adopting an ERC20 implementation that uses felt
instead of Uint256
for better user and developer experience.
Abstract
Due to strict compliance with ERC20 the canonical token implementation from OpenZeppelin uses Uint256
. This is a suboptimal choice. As a result, developers have to deal with more complex code and more frustrating experience when building on StarkNet. At the same time, users of StarkNet are forced to pay significantly more for normal operations than if a felt based token standard was adopted.
Preamble
Initially, the decision to use Uint256
was a judgement call by the OpenZeppelin team. There was only a limited discussion when the community was still small (but already then there were calls to use a felt based token). Thatās of course completely fine, it allowed for the ecosystem to move forward and build what we have now.
Nevertheless, I believe itās time to open up the discussion again.
Firstly, now that the ecosystem has grown significantly, the choice of felt
vs. Uint256
deserves a new, broader debate.
Secondly, it takes time for best practices to emerge - a prime example is the adoption of namespaces in cairo-contracts
v0.2.0. Itās both a breaking change and a change for the better.
Lastly, we still have enough time to switch to felt based tokens. Weāre in alpha, things are backward incompatible all the time. Almost no project is on mainnet yet and regenesis is coming up which presents an ample opportunity to fix whatās broken.
Motivation
There are two main reasons to adopt a felt based ERC20 token.
Improved developer experience
Working with Uint256 is very cumbersome and frustrating, both in Cairo and also when interacting with StarkNet. A couple of points to illustrate that:
- One of the most common issue a new developer coming to StarkNet faces is when they try to mint or transfer a test token. They are greeted with an error message like:
"Error: AssertionError: Expected at least 3 inputs, got 2."
.
Atransfer
takes an address and an amount, so passing two parameters on the command line seems sufficient. Itās not obvious at all that amount is a Uint256 and hence needs to be passed as 2 values (100 0
instead of just100
). Thatās poor DX. - The use of Uint256 makes front-end dev and testing also very confusing. Thereās variance in terms of whether Uint256 is a struct or just a sequence of two integers. This would go away by adopting felts (one could say, the pain of converting would no longer be felt).
- Doing any kind of math operations and/or comparisons with Uint256 is pretty infuriating as Iām sure anyone who has done so can attest to. Take this example from the OZ repo to compare for infinite allowance:
let (current_allowance: Uint256) = ERC20_allowances.read(owner, spender)
let (infinite: Uint256) = uint256_not(Uint256(0, 0))
let (is_infinite: felt ) = uint256_eq(current_allowance, infinite)
if is_infinite == FALSE:
# do stuff
end
In a felt based token wolrd, this can be replaced by the following:
let (current_allowance : felt) = ERC20_allowances.read(owner, spender)
if current_allowance != -1:
# do stuff
end
- Working with felts is inherently safer. Due to all the complexity, itās easier to make a mistake when working with Uint256s. Even simple math formulas become convoluted in code.
- To harden the previous point, thereās also a hidden footgun in the stdlib where
split_felt
- function used to convert a felt into a Uint256 - returns a(high, low)
tuple, but Uint256 is(low, high)
. With a felt based approach, we wouldnāt have to worry about it, at least not when dealing with tokens and associated math.
Cheaper transactions for users
Besides the improved developer experience, moving to a felt based token brings a significant benefit for StarkNet users. A simple transfer
is ~20% cheaper, a transferFrom
about ~26% cheaper and a approve
about ~5%. The last one doesnāt seem like much, but if apps adopt a safe transfer pattern of āapprove(app, 300) + transferFrom(user, app, 300) + approve(app, 0)ā we get the benefit twice in a single multicall.
Transferring a token is one of the cheapest transactions so the savings in a single TX in absolute value might not be much. Yet itās also one of the most fundamental transactions that will happen gazillion times a day (not an exact number). Multiplied by the centuries in front of us that StarkNet is going to operate, the numbers add up to massive savings.
For those interested in how I got the numbers above, check out this repo.
Philosophical reasons
Besides the two objective reasons above, I want to present a couple of subjective ones.
Having Cairo and not having EVM is an asset. In many ways, StarkNet is a chance at a fresh start - like when StarkNet just has Account Abstraction built in whereas Ethereum is struggling for years to finalize an EIP. We should learn from the shortcomings of Ethereum not replicate them. Using Uint256 is the opposite - itās bringing in technical debt to a project that barely launched.
Next, we donāt need to represent the value 115792089237316195423570985008687907853269984665640564039457584007913129639934. Thatās 10 orders of magnitude more than the number of atoms in our galaxy. Vitalik himself stated that 256 was a mistake. Louis is not a fan either. 2^251 is plenty (Iād even argue 2^128 would be enough which would make TXs even cheaper). In fact, a lot of the projects that care already convert from Uint256 to felt for internal use (so they donāt even support values above max felt) and only use Uint256 on the interface level. Uint256 is an unnecessary hassle.
Lastly, I think a single numerical value is more in line with the spirit of ERC20; a two-tuple is not.
Proposed solution
By far, the best solution I came across is from Tarrence van As.
The idea is to have bridges act as the compatibility layer with the chains outside of StarkNet. Itās a simple, elegant and future-proof. The basic implementation is already present in the documentation.
On StarkNet, we will have a clean felt based interface. For interacting with L1, other L2s or L3s, bridges have to do whatever makes sense to convert the other-chain-native value types. The bridges already are the interface between various chains, it only makes sense they handle this as well.
The elegance of this solution is also in the fact that it unlocks the path to a better token standard, beyond ERC20.
The one drawback is if you need to represent values greater than max(felt). One solution to this could be that the token authors create their own StarkNet bridge with custom bridging logic, supported by custom token that can handle these values.
As for the ERC20 interface, it would not chance except for replacing Uint256
with felt
:
@contract_interface
namespace IERC20:
func name() -> (name : felt):
end
func symbol() -> (symbol : felt):
end
func decimals() -> (decimals : felt):
end
func totalSupply() -> (totalSupply : felt):
end
func balanceOf(account : felt) -> (balance : felt):
end
func allowance(owner : felt, spender : felt) -> (remaining : felt):
end
func transfer(recipient : felt, amount : felt) -> (success : felt):
end
func transferFrom(sender : felt, recipient : felt, amount : felt) -> (success : felt):
end
func approve(spender : felt, amount : felt) -> (success : felt):
end
end
The path forward
By writing this proposal, I want to raise awarness of the suboptimal solution of using Uint256 based tokens and start a discussion on the topic. I urge everyone whoās not ambivalent about the issue to participate - show your support, voice your concerns, propose a different solution
Of course, my hope is this proposal will be accepted by the broader StarkNet developer community, that the OpenZeppelin team will update the ERC20 implementation in the next release of cairo-contracts
(it being the canonical implementation most projects are using) and that projects building on StarkNet will update their code accordingly. )
As mentioned above, I believe we still have plenty of time to get this right.