A proposal for adopting an ERC20 implementation that uses
felt instead of
Uint256 for better user and developer experience.
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.
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
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.
There are two main reasons to adopt a felt based ERC20 token.
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.".
transfertakes 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 0instead of just
100). 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.
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.
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.
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
@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
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.