near/NEPs

Interactive Fungible Token

robert-zaremba opened this issue · 24 comments

Following early discussion about NEP-21, the work on NEARswap and author work on security tokens in Ethereum ecosystem, we propose a new fungible token standard.

Rationale

The only approved token standard in NEAR ecosystem is NEP-21.
It's an adaptation of ERC-20 token standard from Ethereum. Both NEP-21 and ERC-20 are designed to be minimalistic and functional: provide clear interface for token transfers, allowance management (give access to token transfers to other entities or smart-contract - this is useful if other smart contract wants to withdraw it's allowance for things we buy). The ERC-20 standard is the first standard Ethereum fungible token standard and created lot of legacy. All early tooling (wallets, explorers, token templates) were build with ERC-20 in mind. Over time, the ecosystem listed many problems related to ERC-20 design:

  1. decimals should be required, rather than optional. This is essential to user-friendly token amount handling.
  2. Lack of standarized metdata reference. Token should provide a resource which describes it's metadata (name, description, etc...) - this can be an URL, CID, or directly written in the contract code.
  3. transfer and transferFrom are lacking a reference (memo) argument - which is essential for compliance reasons. We need to be able to be able to link the transfer to an an external document (eg: invoice), order ID or other on-chain transaction or simply set a transfer reason.
  4. Direct transfers to smart-contract in general is an error and should be protected. Both in Ethereum in NEAR, smart-contracts are NOT notified if someone is sending them tokens. This causes funds lost, token locked and many critical misbehavior.
  5. Avoid problems mentioned in the previous point, all transfers should be done through approve (allowance creation) and transferFrom, which is less intuitive and makes UX more complex: not only we need to create and keep track of right allowance (with all edge cases: user creates allowance, but token is not calling transferFrom and makes user to create another allowance).
  6. Fees calculation. With approve + transferFrom, the business provider has to make an additional transaction (transferFrom) and calculate it in the operation cost.

There are few articles analyzing ERC-20 flaws (and NEP-21): What’s Wrong With ERC-20 Token?, Critical problems of ERC20 token standard.

And the NEP-110 discussion: #110 addressing same issues in a bit different way.

Related work

Token design

We propose a new token standard to solve issues above. The design goals:

  1. not trading off simplicity - the contract must be easy to implement and use
  2. completely remove allowance: removes UX flaws and optimize contract storage space
  3. simplify interaction with other smart-contracts
  4. simplify flow in NEP-122
  5. remove frictions related to different decimals
  6. enable smart contract composability through token transfers

Our work is mostly influenced by the aforementioned ERC-223 and NEP-122: Allowance-free vault-based token standard.

Transfer reference (memo)

We add a required memo argument to all transfer functions. Similarly to bank transfer and payment orders, the memo argument allows to reference transfer to other event (on-chain or off-chain). It is a schema less, so user can use it to reference an external document, invoice, order ID, ticket ID, or other on-chain transaction. With memo you can set a transfer reason, often required for compliance.
This is also useful and very convenient for implementing FATA (Financial Action Task Force) guidelines (section 7(b) ). Especially a requirement for VASPs (Virtual Asset Service Providers) to collect and transfer customer information during transactions. VASP is any entity which provides to a user token custody, management, exchange or investment services. With ERC-20 (and NEP-21) it is not possible to do it in atomic way. With memo field, we can provide such reference in the same transaction and atomically bind it to the money transfer.

Decimal management

Lack of decimals creates difficulty for handling user experience in all sorts of tools
As mentioned in the Rationale, for interactions with tools and assure good UX, we need to know the base of token arithmetic.
ERC-20 has an optional decimals contract attribute, other Ethereum standards makes this attribute obligatory. We follow the ERC-777 proposal, and fix the decimals once and for all:

  • Each token should have 18 digits precision (decimals), same as most of the existing Ethereum tokens. If a token contract returns a balance of 500,000,000,000,000,000 (0.5e18) for a user, the user interface MUST show 0.5 tokens to the user.
  • We port the granularity concept from ERC-777: the smallest part of the token that’s (denominated in e18) not divisible. The following rules MUST be applied regarding the granularity:
  • The granularity value MUST be set at creation time.
  • The granularity value MUST NOT be changed, ever.
  • The granularity value MUST be greater than or equal to 1.
  • All balances MUST be a multiple of the granularity.
  • Any amount of tokens (in the internal denomination) minted, sent or burned MUST be a multiple of the granularity value.
  • Any operation that would result in a balance that’s not a multiple of the granularity value MUST be considered invalid, and the transaction MUST revert.

Reactive transfers

Instead of having approve + transferFrom, we propose a transfer_call function which transfer funds and calls external smart-contract to notify him about the transfer. This function essentially requires that a recipient must implement TransferCallRecipient interface described below.

Security note for transfer_call

In synchronous like environment (Ethereum EVM and all it's clones), reactive calls (like transfer_call, or transfer from ERC223) are susceptible for reentrancy attacks. In the discussion below lets denote a transaction for contract A which calls external smart contract B(we write A->B).
An attack vector is to call back the originating smart-contract (B->A) in the same transaction - we call it reentrancy. This creates various issues since the reentrance call is happening before all changes have been committed and it's not isolated from the originating call. This leads to many exploits which have been widely discussed and audited.

In asynchronous environment like NEAR, an external smart contract call execution is happening in a new, isolated routine once the originating call finished and all state changes have been committed. This eliminates the reentrancy - any call from external smart contract back to the originating smart contract (A->B->A) is isolated from the originating smart-contract. The attack vector is limited and essentially it's "almost" reduced to other attacks happening in separate transaction. Almost - because a user still need to manage the callbacks.

Handling not accepted calls

If a recipient of transfer_call fails, we would like to preserve the tokens from being lost. For that, a token MAY implement pattern developed by NEP-110: when sending tokens through transfer_call, append a finalize_token_call callback promise to the on_ft_receive call. This callback will check if the previous one was successful, and if not, it will rollback the transfer.

You can check the NEP-110 handle_token_received implementation (the NEP-110 reference implementation uses handle_token_received for function name instead of finalize_token_call). This function shouldn't be called externally.

We propose to standarize the final call back:

  • Use finalize_token_call as a function name
  • When scheduling a call to recipient.on_ft_receive, don't pass all NEAR for fees. Reserve some NEAR for finalize_token_call to make sure we will be able to handle both scenarios: when on_ft_receive succeeds and when it fails.

Metadata

NEP-110 stores metadata on chain, and defines a structure for the metadata.
We adopt this concept, but relax on it's content. Metadata should be as minimal as possible. We combine it with other token related data, and require that a contract will have following attributes:

struct Metadata {
  name: String,  // token name
  symbol: String, // token symbol
  reference: String, // URL to additional resources about the token.
  granularity: uint8,
  decimals = 18,
}

Comparative analysis

We improve the token NEP-110 design by:

  • handling compliance issues
  • solving UX issues related to decimals
  • clear support for smart-contract and basic transfers

We improve the NEP-122 design by:

  • simplifying the flow (no need to create safe locks) and less callbacks
  • handling compliance issues
  • solving UX issues related to decimals

We improve the NEP-21 design by:

  • all points mentioned above
  • greatly simplifying implementation
  • reducing the storage size (no need to store allowances)
  • making the transfer interactive: being able to notify the recipient smart contract for the purchase / transfer
  • enabling smart contract composability through token transfers (NEP-21 smart-contract can't react on a token transfer).

Advantages of NEP-21

The pay to smart-contract flow (approve + tranferFrom), even though it's not very user friendly and prone for wrong allowance, it's very simple. It moves a complexity of handling token-recipient interaction from the contract implementation to the recipient. This makes the contract design simpler and more secure in domains where reentrancy attack is possible.

Token interface

Please look at the source code for more details and comments.

struct Metadata {
    name: String,       // token name
    symbol: String,     // token symbol
    reference: String,  // URL to additional resources about the token.
    granularity: uint8, // the smallest part of the token that’s (denominated in e18) not divisible
    decimals: uint8,    // MUST be 18,
}

pub trait TransferCallRecipient {
    fn metadata() -> Metadata;
    fn total_supply(&self) -> U128;
    fn balance_of(&self, token: AccountId, holder: AccountId) -> U128;

    #[payable]
    fn transfer(&mut self, recipient: AccountId, amount: U128, msg: String, memo: String) -> bool;

    #[payable]
    fn transfer_call(
        &mut self,
        recipient: AccountId,
        amount: U128,
        msg: String,
        memo: String,
    ) -> bool;
}

pub trait TransferCallRecipient {
    fn on_ft_receive(&mut self, token: AccountId, from: AccountId, amount: U128, msg: String);
}

Comparing to #122 this standard doesn't allow to withdraw only a part of tokens during transfer_call. With uniswap and async environment you may want to attach more tokens with the transfer, but the remote contract doesn't have to take them all. Similarly how allowance solved this issue. Allowance didn't transfer all tokens immediately, but instead allowed to withdraw the necessary amount. Similarly vaults allow to withdraw not the full amount of tokens from sender.

@evgenykuzyakov - this is solved by creating a return reminder receipt:

  1. User sends to the contract the max amount s/he agrees on
  2. contract do the calculation and uses simple transfer function to return the the reminder.

Security wise: in all cases (approve, vault, transfer_call) we can't protect from a greedy contract which will like to take more than it should.

BTW please the main repository for more details and analysis. Maybe I should paste the whole content of it into this issue description?

Yes. Please post all details here.

Also explain the flow of the transfer_call - when the actual tokens from the sender are transferred out? Does the receiver has the tokens already in their account during the transfer_call call? Or does the receiver first has to accept the transfer by returning true?

If the receiver doesn't have tokens until it returns true, then it can't act on it, since you can't schedule a callback on the parent promise.

If you already have tokens, then why do you even need to return true if you can just start spending them. You can also arrive in a state where you've spent some tokens and then returned false. How the token contract can handle it.

So to make this standard more robust, we need to go through a few scenarios where at least some composability is involved. e.g. vault token standard allows for this to happen by separating tokens from sender and receiver accounts before they are accepted and partially/fully used.

I've updated the description, It's long, so I think it's easier to read it from: https://github.com/robert-zaremba/nep-136-fungible-token

Also explain the flow of the transfer_call - when the actual tokens from the sender are transferred out?

@evgenykuzyakov here is how it works. Let's say user wants to transfer tokens A to contract B.

  1. users calls A.transfer_call
  2. A updates the balances
  3. A calls B.on_ft_receive
  4. A should schedule a callback after calling B as described in Handling not accepted calls. I made it optional, but I'm considering to make it required, what do you think?

IMHO, we need to update balance before calling B to assert proper contract composability. Error scenarios must be handled through promise callbacks.

If you already have tokens, then why do you even need to return true if you can just start spending them.

Right, I will change the on_ft_receive method signature to return nothing (void).

I also should note that NEP-21 doesn't support smart contract composability through token transfer (adding this to the description).

@robert-zaremba - thanks and nice job for writing this up. It definitely proposes great ideas that address integration and UX issues.

For reference, I posted my feedback and thoughts on the NEP-122 discussion thread.

I have updated the description:

  • added the register account concept proposed by @oysterpack
  • removed the payable attribute from the transfer function.

Please see the Smart contract interface file for more details and function comments.

Initially, I made the register_account more general - that any account could be a sponsor and register someones else account:

/// if address === "" it registers the env::predecessor_account_id()
#[payable]
fn register_account(&mut self, address: AccountId);

But after thinking more about it, I decided to only allow the caller to register itself.
Allowing any account register any other account would create spamming, compliance and security issues (someone could register a contract account, which doesn't handle token transfers).

What do you think if transfer_call requires the resulting promise to return the used_amount. The remaining amount - used_amount will be refunded. If the promise fails, it should be considered the used amount 0 and the whole transfer amount is refunded.

This allows to support vault like functionality, e.g. non-complete withdrawals. It also prevents accidental acceptance.

removed the payable attribute from the transfer function.

I've used payable for transfer_with_vault in #122 to guard against function-call access keys security flaw. Basically, if you authorize an access key through a web wallet Auth flaw towards a token contract, it would allow you to call transfer without explicit confirmation from the wallet. But if the token contract requires to attach at least 1 yoctoNEAR, then a function-call permission access key will not be able to withdraw.

But after thinking more about it, I decided to only allow the caller to register itself.

Registration is payable, and it maybe beneficial to register your friends account when sending them tokens. Mandatory registration will prevent this. As for compliance, you don't completely solve this. Since once you register, anyone can send you tokens.

EDIT: To cross link, here is a comment from NEP-122: #122 (comment)

@evgenykuzyakov What's the benefit of returning used_amount vs transferring back the tokens? The idea is to have a simple transfer protocol with notifications.

@evgenykuzyakov What's the benefit of returning used_amount vs transferring back the tokens? The idea is to have a simple transfer protocol with notifications.

It will save a round trip to do this. Since the callback result is already going back to the token contract, then we can just use this almost for free.

it would allow you to call transfer without explicit confirmation from the wallet

This sounds like a flaw of the wallet. Requiring [#payable] for each critical smart-contract method to get a wallet confirmation is a hack IMHO. Ideally, we should have an option on the wallet side to request a confirmation.

As for compliance, you don't completely solve this. Since once you register, anyone can send you tokens.

Good comment. It solves only part of it - that I can decide which tokens I want to use. This will be useful for smart contract recipients. I know that Stellar and Algorand requires user opt-in for an asset to allow sending that asset to a user. It's driven there by the storage design and it's also motivated by a spamming protection. Let's think about it more - we can make an additional, optional method: register_external(address). What do you think?

Compliance is a complex thing. Services will have to have compliance rules both on the token contract side and recipient contracts. Thus my require to use only transfer_call.

This sounds like a flaw of the wallet. Requiring [#payable] for each critical smart-contract method to get a wallet confirmation is a hack IMHO. Ideally, we should have an option on the wallet side to request a confirmation.

Well, for common tokens it will be possible to protect, but users are stupid, so it's possible to trick the user to accept the authorization towards a new contract.

So, do you suggest that all "critical" user related methods must be [#payable] and requiring an "artificial" 1yocto payment?

So, do you suggest that all "critical" user related methods must be [#payable] and requiring an "artificial" 1yocto payment?

I guess we don't need to enforce it using payable once there is contract level metadata that can mark methods as require confirmation. This may show a big warning on the Wallet, when an access key with such method is requested. Similarly when you request a full-access key. @vgrichina @kcole16

Yes, this is better approach.

regarding #122 (comment) - to consolidate and reset with new NEPs ...

we also need to close out #136, #110, #102, #121

Will close it once an alternative will be out

it would allow you to call transfer without explicit confirmation from the wallet

This sounds like a flaw of the wallet. Requiring [#payable] for each critical smart-contract method to get a wallet confirmation is a hack IMHO. Ideally, we should have an option on the wallet side to request a confirmation.

also it kinda prevents actually granting authorization to token contract if I want.

like say if I'm logging in into Berry Club app – I might reasonably want to trust it managing bananas and cucumbers without approve each time

frol commented

@robert-zaremba Do you believe we can close this issue as resolved given that we have NEP-141 and a few extensions already approved?

Wow, I thought we closed it 1.5 year ago ;)