Thoughts on the token standard

So I was thinking a lot about what exactly should the perfect token standard look like, and now I’m finally ready to declare my opinion on this subject. I thought, it might be more appropriate to share it here and not on the sailfish’s github repo.

In short, I believe that we should reject ERCs and come up with something extremely basic, to only support basic operations and to give an ability to extend the implementation from the outside.

Short prelude. Why do people even need application-level standards like ERC? The main answer is interoperability. Since wallets are off-chain entities on Ethereum, the community came up with this idea to make a standard so any wallet which understands this standard could work with any token which implements the standard.
Is this good? Well, yes, I think. But these guys were limited by the technology and the mindset of their time, and this, in my opinion, made their token standards bad. I want to start a discussion on that topic and to propose an alternative solution which, I believe, could bring another level of value to the system than classic ERCs.

24 Likes

Let’s take a closer look at ERC20 (EIP-20: ERC-20 Token Standard) - the first and the most famous token standard out there. It is very a simple interface which basically consists of methods each falling in one of four categories:

1 - Basic value management

// creates new tokens, usually implemented with some kind of access control
// this one is not a part of the standard, and is listed here just to provide a complete view
mint(toAddress, quantity)

// destroys tokens from caller's balance
burn(quantity)

// destroys tokens from caller's balance and create the same quantity of tokens on recipient's balance
transfer(toAddress, quantity)

2 - Basic getters

// returns the amount of tokens possessed by the address
balanceOf(address)

// returns the total amount of tokens in circulation
totalSupply()

3 - Optional getters

// returns the name of the token (e.g. "Internet Computer Voting Token")
name()

// returns the symbol of the token (e.g. "ICP")
symbol()

// returns the denomination of the token (how much cents there are in a dollar)
decimals()

4 - Approvals-related methods

// lets another entity to spend up to some amount of caller's tokens
approve(operator, quantity)

// spends some of previously approved tokens
transferFrom(ownerAddress, toAddress, quantity)

// how much is someone allowed to transferFrom
allowance(ownerAddress, spenderAddress)
3 Likes

So, why do I think this standard is bad? There are two reasons:

1 - Abused ERC20 inheritance by other token standards.
ERC20 specifies an interface for fungible (indistinguishable from each other) and singular (one token type per one smart-contract) tokens, without anything else (no onchain balance history, no partitions, etc.). But to simplify the integration process by various wallets, other token standards often have a very similar interface.
The worst example of this is ERC721 (EIP-721: ERC-721 Non-Fungible Token Standard) - the famous NFTs. I find this interface absolutely gross and unhealthy for the goal this standard targets to reach. Just look at that:

function balanceOf(address _owner) external view returns (uint256);
function approve(address _approved, uint256 _tokenId) external payable;

I won’t touch the approve method for now (this is dedicated to the next section), but balanceOf, really? For those not familiar with NFTs - they are intended to represent an ownership of some unique things, like real estate, or cars, or art objects or even cows. In this context the method balanceOf seems very inappropriate to me, since I don’t really need to keep track of the quantity of houses I have in my wallet, I want to see each of them individually and read some detailed information about them.
I’m not blaming the developers of this standard - they were forced to make it work that way because they wanted out-of-the-box wallet support for these tokens. And what I’m trying to say is each token should be implemented in an optimal and a convenient way for the task it’s supposed to solve and no standard should prevent creativity of such a way.

5 Likes

2 - Approvals as a way to automate value flow.
Someone new to Ethereum and coins in general looking at the interface for ERC20 I’ve left above might ask: “yea, I understand what are all these balanceOf(), mint() and transfer() for, but why would I ever need to approve()?”. At least for me, when I was reading about all of these tokens for the first time, it seemed like a very niche mechanics. Like… okay, if I have a friend whom I’d like to allow to spend some of my crypto, I could just send it to them, right?
Let’s read the original description for transferFrom method.

The transferFrom method is used for a withdraw workflow, allowing contracts to transfer tokens on your behalf. This can be used for example to allow a contract to transfer tokens on your behalf and/or to charge fees in sub-currencies.

So, it is used not like I was first wondering. It is used to let smart-contracts transfer your tokens and automate actions. Okay, but why did they choose to do it this way?
Is this the only way? - No, there is at least one more (we’ll talk about it a little later).
Is this the most efficient way? - No, you have to make two transactions to send your tokens to a smart-contract, since they are reactive and only do something when they’re told (one to approve an amount and one to trigger a smart-contract to call transferFrom for you).
Is this the safest way? - Maybe. Back in those days they were really afraid of famous re-entrancy attack, and since the approach with approvals is immune to this attack by default, I believe, they decided to stick with it. But now re-entrancy is studied well and while we write our code carefully we’re good to go. So, I believe, while approvals are safe, they are inconvenient to integrate with and misleading to understand, but that’s not the real problem.

3 Likes

There are several younger standards (like ERC677 and ERC777) which use different mechanics to send tokens to a smart-contract and trigger an action in one transaction. This is very similar to how Ethereum works by itself. When you want to send some ether to a smart-contract to trigger some paid action, you just attach it to the transaction. These standards provide us with something similar:

function send(address to, uint256 amount, bytes calldata data) external;

Now, Dfinity introduced another mechanics to automate ICP flow - notifications. Since there is much more freedom on the IC and we can implement on-chain ledgers like it’s nothing, there is no problem to just say to another canister “hey, I’ve just sent some tokens to your principal, here is the transfer transaction; would you kindly execute this paid action for me?”.
Why did they do that? Why didn’t they chose approve nor sendAndCall approach? I don’t know for sure, but we could speculate on it a little. As you might notice, ICP was first listed on coinbase. This hints us with some kind of relationships between these two organizations. Coinbase is also known as creators of Rosetta API - a standard which they use to integrate different coins into their exchange fast. Let’s look a little closer to this specification. Hm… it looks like your blockchain should have blocks and transactions to implement this standard. But the IC doesn’t store blocks - it doesn’t need them to provide security guarantees, so blocks are just rejected once there is a consensus reached and there is no chance for fork to appear. So, they had to implement on-chain ledger so they could be listed on coinbase. And since there is already a ledger on chain, I believe, it looked convenient for them to just stick with notifying canisters about transactions, instead of using other mechanics.

What I wanna say is that there is no perfect solution for this task - every application has it’s own requirements. Sometimes you might want to avoid re-entrancy threat completely, so you stick with the approve and transferFrom way (which I personally find disgusting, but who knows). Sometimes you want your UX to be as snappy as possible so transferAndCall is your case.

3 Likes

To repeat once again, what are the problems of Ethereum and ERCs:

  • ERC20 inheritance is often inappropriate
  • Value flow automation is not abstracted away

As a early community, if we want to create a standard that will survive the time test, we should make it solve the problems of tomorrow. Since we don’t know what exactly are those problems, we have two options:

1 - To try to predict these problems and make ultra-mega-super-token-standard-3000 which can handle any problem we can imagine at this moment. Will it work? - I’m not sure. Maybe tomorrow there will be another “DeFi boom” and the paradigm will shift once again, making our standard outdated in one moment.

2 - To do what the agile methodology tells us - to be agile and to reject long-term planning. This is what I want to propose to you.

6 Likes

The proposal

What do we know for sure and what will never change?

  • Gas is reverted here on IC - it is safe to build long inter-canister operations, since each canister pays for its own computations
  • There is only one wallet on IC and it is on-chain - it is safe to build very different tokens, each serving its own purpose and solving its own task - each app is responsible for proper handling (show, send etc.) of its own tokens. Moreover, in my opinion, this should be encouraged.
  • Some tokens are so much different, that it should be discouraged to treat them the same way. For example dollars and cars. Right now there are, I believe, only two such clusters: fungible and non-fungible tokens, but there might be more one day.

These three points might look like “so, we don’t need a standard then”, but:

  • There will be applications which operate over many different tokens. A classic example is DEX or maybe some application that wants to be able to receive donations with different tokens. These applications need a way to treat these tokens and to react when tokens move.

So…

  1. If we want to encourage creativity and diversity, but provide interoperability, we need a tiny yet highly agile standard.
  2. If we want to emphasize that there are clusters of tokens, each representing a whole different concept of value, we need a separate idiomatic standard for each such a cluster.

With that being said:
ERC20 basics + semantics + subscriptions = perfect token standard

6 Likes

the next notation is given in rust

trait IFungibleToken {
  
  // adds a caller's *callback* to the subscriber list, 
  // from now on each time there appears a transfer *from* -> *to*, 
  // the *callback* method will be called asynchronously,
  // without an awaiting
  // 
  //
  // *from* and *to* are filters applied by AND rule:
  //
  // subscribe(Some(Some("aaaa-a")), Some(Some("bbbb-b")), "cb")
  // creates a subscription that is triggered only when there is a transfer
  // from "aaaa-a" to "bbbb-b"
  //
  // subscribe(Some(Some("aaaa-a")), None), "cb")
  // creates a subscription that is triggered only when "aaaa-a" is a sender
  //
  // subscribe(None, Some(Some("bbbb-b")), "cb")
  // creates a subscription that is triggered only when "bbbb-b" is a recipient
  //
  // subscribe(None, None), "cb")
  // creates a subscription that is triggered on each transfer, no matter
  // who are the participants
  fn subscribe(
    from: Option<Option<Principal>>, 
    to: Option<Option<Principal>>,
    callback: String
  );

  // removes the caller's *callback* from the subscriber list
  fn unsubscribe(callback: String);

  // NOT A PART OF THE STANDARD - just for demo purposes
  // calls a subscriber's *callback* method with transfer details as a payload
  fn _publish(
    sub_canister_id: Principal,
    sub_method_name: String,
    from: Option<Principal>,
    to: Option<Principal>,
    qty: u64
  );

  // creates new tokens for *to*
  // calls every callback method subscribed 
  // to *from: Some(None)* or *to: Some(to)*
  fn mint(to: Principal, qty: u64);

  // sends token from caller to *to*
  // calls every callback method subscribed 
  // to *from: Some(caller)* or *to: Some(to)*
  fn send(to: Principal, qty: u64);

  // destroys tokens of the caller
  // calls every callback method subscribed 
  // to *from: Some(caller)* or *to: Some(None)*
  fn burn(qty: u64);

  // returns the balance of *token_holder*
  fn balance_of(token_holder: Principal) -> u64;

  // returns the total amount of tokens in circulation
  fn total_supply() -> u64;

}
3 Likes

Q: Are you crazy?

No, of course not :crazy_face:

Q: Why subscriptions?

Subscriptions are a great way to add extendability to our tokens.

You want to create a ledger of some token(s)? - No problem, subscribe to them and pack your ledger however you want in a separate canister.
You want to keep track of token balances at each moment in time, to use these tokens for voting? - Just take it, it’s yours.
You want to give your special token to everyone who donates more than $100 to charity? - Yes, please. All you need is to know some Principals of charity organizations wallets.

Every time you want your dapp to react to transfer of some token, you just need to subscribe to it. Moreover, with previous approaches a canister could only react when someone sends tokens to it, but now it can react to anything.

You still can implement approve or transferAndCall approaches - they will work, but they shouldn’t be in the standard.

And, by the way. This thing with subscriptions transforms our canister from just “a smart-contract” to “Open Internet Service”, because now it extends the system, not just exists there.

Q: Is it safe?

It is, if the token implementation is not awaiting for calls to return result. And if these calls made AFTER state modification to prevent possible re-entrancy.

3 Likes

Q: How is it exactly better than ERCXXX?

  1. It’s smaller. Less code, less errors, easier to understand, easier to implement.
  2. It abstracts value automation.

Q: What about NFTs?

It is obvious that subscriptions would work well with NFTs also. But I’m writing this post for almost 5 hours now and not in condition to provide a decent specification design for them at the moment.

Q: Was it possible to do the same on Ethereum?

Technically it was. But because of non-reversed gas model, it was vulnerable. Implementing this in your token was meaning to let anybody increase gas costs per transfer of your token holders as much as the attacker likes. On the IC we’re immune to this.

Another example when correct and convenient model pushes boundaries of what is possible.

Q: Why there is u64 everywhere? Where are Nats?

Do we really need so much tokens? This is not critical to me, but if you think there is a strong reason to make them into Nats, feel free to share it.

Q: What about optional getters from ERC20?

Since they are optional, they are not in the standard.

1 Like

Q: Why do we call subscribers dynamically? Why can’t we declare some common interface like fn on_token_publish(args)?

Because a single subscriber can have any number of subscriptions for any number of different tokens. We can’t just declare a single function and filter inside it for this, because a subscriber might (and should) want to save its cycles, and in this case it can’t.

Q: Is it possible to implement this in Motoko?

Not right now. Now only rust’s ic-cdk supports dynamically constructed calls. But subscribers can be done in Motoko, since they just need to provide a method name. Anyway, one day Motoko will also get the support for this and everything will be fine.

Q: What’s next?

Let’s discuss this guys. I’ve written the whole 7 parts Medium post here and I’m thrilled to know if you like it. If you guys do, I could implement a reference implementation and we could continue to investigate this topic further.


That’s all. Thanks for reading this far :tada: :tada: :tada:

14 Likes

Had to split in several posts. Forum engine was not ready for my commitment.

4 Likes

Thanks Alexander (senior.joinu :slight_smile:) for sharing your thoughts on the forum… it is an impressive deep dive… irrespective of which path the implementation(s) take from hereon, one thing is for sure - you’ve got across the message that we should be open-minded and thinking at the root-level. I’m really looking forward to the token standards that will develop on the IC platform. I believe that the core design of the IC platform provides a really wide and open canvas for innovative thinking on this front. Thanks again!

6 Likes

@senior.joinu Why Option<Option<Principal>> instead of Option<Principal>?

Thanks for sharing your ideas!

I do love your subscription proposal, but I think it should be complementary to approve, not deprecating it.

  1. I do think approve model is easier to understand and integrate, and
  2. existing dev/user base are already familiar with this concept (also analog to credit card UX in real life, less market education needed I guess?) so it’s more likely to stay around, just like QWERTY keyboard.
3 Likes

Why Option<Option<Principal>> instead of Option<Principal> ?

When it is None - it means ‘empty filter’ - when any transfer occurs.
When it is Some(None) - it means ‘filtered by empty principal’ - only when tokens are created or burned.
When it is Some(Some(x)) - it means ‘filtered by exact principal’ - only when x is a participant of this transfer.

1 Like

My man! I support your post in a lot of ways :slight_smile: transferAndCall is definitely the way to go.

We should be using AccountIdentifier’s as opposed to Principal though, to have parity with ICP Ledger. I’ve published a motoko library which helps achieve this: GitHub - stephenandrews/motoko-accountid

Callbacks are the way to go, currently the ICP Ledger notifies recipients via a notify call from the sender, which forwards the tx to the recipient via “transaction_notification” function (i.e. the receive needs to have this function defined). I think this is a good approach, however ICP Ledger does this via a secondary transaction for safety I assume. We could do something similar and force canister developers to include a set defined transaction like “transaction_notification” which is triggered on receipt of a tx.

I do like the subscription method, but I would maybe alter it and leave the different from/to specifics to the canister and not handled by the token canister. So just a simple subscribe method that allows canisters to define the callback that should be notified on receipt of payment, and any logic to do with the sender can be handled internally.

Some ideas, I’ll put some more details around a standard that looks similar from what I’ve got. Liking to look of it. I’ve been working on a wallet as well, which can be connected via II or using your own mnemonic seed (hardware wallet support is being worked on too). The main purpose is to act as a token standard for other things we are working on. Hoping to release a beta of our wallet very soon, as well as deploy our initial DEX when we can:


6 Likes

Hey @senior.joinu . These thoughts are great. We (Fleek) have been jamming on this topic with several of the devs/projects in the community. We recently put out this draft GitHub - PsychedelicDAO/token-standard: [WIP-DRAFT] Token Standard for the Dfinity's Internet Computer. - It takes a similar approach to your thinking (keep the base layer extremely simple but make it extendable). The goal was to try to align on something quickly since people are already launching or preparing to launch tokens in the near future (ourselves included).

Would love to get any thoughts/suggestions/additions you might have on the current draft!

7 Likes

Thanks.
I don’t see anything I could add to my previous words.

It looks like your draft uses both notifications and approvals. I believe you should reconsider this design decision and make you draft simpler.

The whole goal of this thread is to encourage other developers to not to follow the same road we took on Ethereum, but rather find another idiomatic ways of solving our tasks.

UPD: And while I really like the idea with compute_fee, I don’t see if it fits into “extremely simple” vision, since the token could exist without it.

1 Like

Well, I’d propose use Some(Principal::management_canister()) instead. aaaaa-aa is practically address 0x0. The semantic looks better this way IMO.

1 Like