A felt based ERC20 token

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:

  1. 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.".
    A transfer 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 just 100). That’s poor DX.
  2. 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).
  3. 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
  1. 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.
  2. 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 :pray:

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.

24 Likes

Hi, a quick question.

Lets say there is native felt based ERC20 on starknet, we want to bridge it on L1 to uint256 ERC20 and then back. Can you foresee any problems here? I’m just curious :slight_smile: … You mentioned that it is a problem of the bridge that has to deal with it, which is relatively vague (and my knowledge doesn’t cover that well enough).

Otherwise I like it.

2 Likes

Thank you for formally putting this proposal forward. Very well articulated reasoning and I am in full support. As you mentioned there are significant benefits to a felt based native starknet implementation and, imo, very few downsides.

I think this is the eventual end state. Starknet + L3+ increase onchain capacity by several orders of magnitude. Eventually, very few L2+ native dapps will have L1 native assets. In those cases, fee market forces will drive the adoption of standards with less overhead, and there will be no reason to maintain uint256 compatibility for those applications. So I think it is likely we see a felt based ERC20 implementation emerge eventually. Better to standardize with the more efficient implementation today and manage the edge cases with adapters at the bridging interface.

7 Likes

That’s not an issue. The bridge simply has to convert between Uint256 and felt. When it talks to L1, it uses Uint256, and when it talks to the L2 token it uses felt.

5 Likes

100% in support. Discarding EVM equivalence/compatibility was a bold move, so lets make another one and not bring EVM technical baggage to StarkNet :]

4 Likes

I fully agree with your different arguments, and that is something I mentioned several time to the OZ team when they where working on the first implementation of their ERC20 contract.

4 Likes

This is great. uint256 is destined to become an implementation detail for rollup contracts. Uint256 is what is driving our field prime to be less than 2^256 and it wouldn’t make sense to require our default number representation to also be larger than the max uint in L1.

Happy to keep this proposal narrow in scope but I’d love to also add some conventions around the use of uint256 in general since there should be some overarching logic that makes sense across all different StarkNet standards, not just ERC20. I’m definitely supportive of dropping uint256 for ERC20 tokens and extensions as far as amounts are concerned, as for other conventions…

Conventions - what about tokenId?

Regarding ERC721 and tokenId, there is no bijection from uint256 to felt so there’s a question of how bridges could uniformly handle tokenId conversion if we decide not to use uint256 there. As an alternative, we could reserve uint256 to still be used for tokenId.

Conventions - what about hashes?

Are there any examples of hashing that output uint256 that we would need to preserve? Who’s best placed to comment on that? For example the OZ account implementation is currently using vrs cairo-contracts/library.cairo at main · OpenZeppelin/cairo-contracts · GitHub.

Conventions - storage?

I’m guessing for L1 storage retrieval (e.g., fossil) we would also conventionally want to use uint256 and associated conversion.

Conventions - messaging?

Messaging from L2 to L1 is another interesting one where it seems like we would need proper uint256support.

11 Likes

This is awesome! I appreciate the analysis here. Here are a few thoughts.

  1. Minimize context switching. Most complex programs that use erc20s will also have other business logic. This logic likely uses felts. Forcing devs to switch between two different types creates a big mental overhead.

  2. Timing. Because starknet is still so young it will be easier to switch sooner rather than later when large code bases develop.

4 Likes

Thank you for the proposal!

Is there any ERC20 token live on Eth mainnet that actually uses more than 251 bits to represent e.g. total supply? If not even SHIB uses all of them, then I don’t see a reason not to switch and have bridges simply refuse to bridge these esoteric tokens to StarkNet.

2 Likes

I’m not aware of any real usages, but here are a few (dumb or dumber) possibilities where it could be in use. Even if they sound dumb, in reality I’m quite sure something similar is in use in multiple projects:

  1. A token which has a total supply of max(uint256) assigned to a single user
  2. A token with max(uint256) total supply, divided to multiple users. How should bridges map the total supply: as max(felt) (making it incorrect) or as something else? Or simply refuse to bridge it?
  3. A ‘normal’ token which has small amounts, but minting enabled. On day 123 bridging works just fine. On day 124 someone mints tokens so that total supply becomes max(felt) + 1 - should the bridge suddenly refuse the token?

I appreciate this proposal and thanks for all the work put into it. I agree implementation at the bridge level is the most fluent approach, but I’m just worried that it’s the bridge builders then who have to make these difficult decisions, which are difficult to fix if done ‘wrong’. That’s a lot of power to a small group of people, and the community has a very limited amount of influence over arbitrary bridge projects.

5 Likes

This is the correct path forward IMO. Thanks for the analysis!

I really appreciate that OpenZeppelin created the uint256 standard in Cairo, it has helped a lot specially to make the initial switch to Cairo, but Imo felt based approach is the correct way forward.

With felts we’d not only be reducing tx costs but also optimizing storage within contracts, and there’s already assertions we can use from the cairo standard language (assert_le_felt and assert_lt_felt, along with assert_250_bit).

4 Likes

Hi,

I’m all for this proposal!

And I think that in any case if (for some weird reason) there is a strict requirement of using Uint256 then it could be possible to create a wrapper to deal with it.
It’ll be a bit of a hassle for the user to have to swap it’s felt token to Uint256 token (even though it could probably be added to the handling contract in a better way).

I’d be interested in having all the reasons that pushed OZ to go with this approach.
Even though I guess the most obvious is be to be compliant with Solidity’s code/interface.

Let’s try to keep fueling this debate as much as possible as we can still change things!

G.

4 Likes
  1. we (OpenZeppelin) ported ERC20 instead of designing a new token. ERC20 uses uint256.
  2. EVM compatibility to support the obvious stream of projects wanting to port from Ethereum (where StarkNet settles its state)
  3. we follow users, and back then the MakerDAO team was working to port DAI which was an ERC20
  4. in the same way cairo for starknet added a native-ish uint256 type, we always pushed for that same type to be smarter and be able to optimize for 1-felt usage when appropriate, as you would expect a compiler to optimize memory usage or to have native long strings in Cairo eventually.

I do believe a standard like the one proposed here is valuable and might imply a noticeable improvement in storage usage, but I would:

  • have preferred this to be fixed at the language level so we can focus on interfaces and interoperability, leaving implementation details one level below
  • not call this ERC20 nor anything similar. if we’re departing from ERC20, consider leveraging the ERC20 decoupling to do it properly as suggested in this discussion
4 Likes

What I think it’s missing here is compatibility with everything that lives outside chains, for which bridges cannot help much. What’s the plan for wallet adoption, web2 adoption, etc?

I think there’s a reason every other big L2 in the space is not breaking EVM compatibility, but trying their best to be “the more compatible alternative” and we shouldn’t ignore it. And it’s the same reason there’s a gazillion “better ERC20” standards out there, and yet you use none.

I still believe the best solution (by far) is having a better Uint256 type at the language level, and I’d be interested to hear reasons against it.

3 Likes

It is indeed true that Cairo will evolve such that Uint256 could maybe be implemented at the language level…
Would anyone from Starkware have more insights on that?
EDIT:
Even though even if there is a natif support of Uint256 since the max felt is a fixed value it will still be less efficient (unless some magic happens ^^').

2 Likes

With the recent announcement of Cairo 1.0 and StarkWare’s plans for the language, which include improved syntax and features like integers, I think that waiting to see more detail is prudent. While this proposal highlights an issue to be addressed, I think that this is something that is best resolved at the language level. Further, other comments in this thread have indicated some circumstances where a felt will not be sufficient on its own. Those circumstances also need to be taken into account. I don’t agree with placing the responsibility on bridges to be the compatibility layer where this issue can be resolved, and may well be, by changes to Cairo itself.

4 Likes

What I think it’s missing here is compatibility with everything that lives outside chains, for which bridges cannot help much. What’s the plan for wallet adoption, web2 adoption, etc?

As mentioned above, a felt is “more compatible” than Uint256, because it’s a single numerical value whereas a Uint256 is a pair of two values which has to be converted to a single one.

Re: wallets - Julien from Argent expressed support above, so did Motty from Braavos in DM (but I don’t want to put words in his mouth).

I think there’s a reason every other big L2 in the space is not breaking EVM compatibility, but trying their best to be “the more compatible alternative” and we shouldn’t ignore it.

StarkNet already broke compatibility, for good reasons and with great rewards as a result. I agree with your suggestion that we might not want to call it “ERC20” then and improve a token standard further (many people reached out privately suggesting this).

I still believe the best solution (by far) is having a better Uint256 type at the language level, and I’d be interested to hear reasons against it.

I agree here as well, that might be an acceptable solution. As @davek mentioned, the situation might improve with Cairo 1.0, but I think that would only fix the developer experience issue. As @gaetbout writes, the primitive data type of Cairo is felt, which cannot represent 256 bit values, so it will always be cheaper to use felts than a composite type. At least that’s my understanding, would love it if someone from StarkWare or the Cairo team could chip in.

4 Likes

I don’t agree with this. Uint256 is a standard library type in the cairo language which can be easily converted to/from EVM’s uint256, whereas felt cannot. Underlying representation (aka “single value”) has nothing to do with compatibility.

I wouldn’t say there’s visible rewards for breaking compatibility, and if it’s just achieving ZKRU functionality, we’re seeing other rollups achieve that without breaking compatibility thus leveraging: existing devtooling, existing developers, existing frontends, existing wallets, long etc. Interested to hear other points in here, I’m sure there’s a few I can’t think of – although not sure if this thread is the right place for it.

Breaking compatibility in an interoperable world is not something we should celebrate and let alone augment, but something we should fix/amend. Maintaining compatibility reduces our need to amend for that lack of compatibility by adding complexity in critical infra as bridges or adapters.

I don’t think it has to do with DX but interoperability. Having a native uint256 type in cairo which smartly represents “single felt uint256” as, well, a single felt, would not just provide with the performance improvement we all agree it’s needed, but it will also maintain compatibility with existing systems (other chains, other contracts, frontends, tooling, monitoring scanners, etc).

2 Likes

Hey there, coming a bit late to the debate. I also fully support this initiative, compatibility with external world can be done in bridges and js/python libraries. The current implementation of Uint256 provided by Starkware adds too much complexity.

Pros with felt

  1. Math is way simpler to write and to read.
  2. Native type that can represent several solidity types. By using it more and moving away from Uint256, developers could build strong libraries to use them, and may discover new primitives.

Cons with Uint256

  1. For most computation, you need to convert Uint256 to felt, do your computation, convert back your results to Uint256.
  2. Management of negative numbers is tricky. Also, it doesn’t feel correct to use a Uint256 to represent negative numbers.
  3. Each project using Uint256 adds several lines of code per file to take into account edge cases that may never happen in the network. This doesn’t sound sane.
5 Likes

I think you missed the pros with uint256 and cons with felt ¯\_(ツ)_/¯

I think we all agree in that we want cheaper and more efficient tokens, the discussion is at which level we’re going to fix this (because breaking compatibility with no patches is no one’s option).

The question then is should we work around this at:

  • the application level
  • the bridge/adapter level
  • the language level

To me, unless unfeasible, the third option is by far the best (still waiting for arguments against it), since adding complexity at the application or bridge level is cumbersome and risky.

1 Like