NEP | Title | Author | Status | DiscussionsTo | Type | Category | Created | Replaces | Requires |
---|---|---|---|---|---|---|---|---|---|
141 |
Fungible Token Standard |
Evgeny Kuzyakov <[email protected]>, Robert Zaremba <@robert-zaremba>, @oysterpack |
Final |
Standards Track |
Contract |
03-Mar-2022 |
21 |
297 |
A standard interface for fungible tokens that allows for a normal transfer as well as a transfer and method call in a single transaction. The storage standard addresses the needs (and security) of storage staking. The fungible token metadata standard provides the fields needed for ergonomics across dApps and marketplaces.
NEAR Protocol uses an asynchronous, sharded runtime. This means the following:
- Storage for different contracts and accounts can be located on the different shards.
- Two contracts can be executed at the same time in different shards.
While this increases the transaction throughput linearly with the number of shards, it also creates some challenges for cross-contract development. For example, if one contract wants to query some information from the state of another contract (e.g. current balance), by the time the first contract receives the balance the real balance can change. In such an async system, a contract can't rely on the state of another contract and assume it's not going to change.
Instead the contract can rely on temporary partial lock of the state with a callback to act or unlock, but it requires careful engineering to avoid deadlocks. In this standard we're trying to avoid enforcing locks. A typical approach to this problem is to include an escrow system with allowances. This approach was initially developed for NEP-21 which is similar to the Ethereum ERC-20 standard. There are a few issues with using an escrow as the only avenue to pay for a service with a fungible token. This frequently requires more than one transaction for common scenarios where fungible tokens are given as payment with the expectation that a method will subsequently be called.
For example, an oracle contract might be paid in fungible tokens. A client contract that wishes to use the oracle must either increase the escrow allowance before each request to the oracle contract, or allocate a large allowance that covers multiple calls. Both have drawbacks and ultimately it would be ideal to be able to send fungible tokens and call a method in a single transaction. This concern is addressed in the ft_transfer_call
method. The power of this comes from the receiver contract working in concert with the fungible token contract in a secure way. That is, if the receiver contract abides by the standard, a single transaction may transfer and call a method.
Note: there is no reason why an escrow system cannot be included in a fungible token's implementation, but it is simply not necessary in the core standard. Escrow logic should be moved to a separate contract to handle that functionality. One reason for this is because the Rainbow Bridge will be transferring fungible tokens from Ethereum to NEAR, where the token locker (a factory) will be using the fungible token core standard.
Prior art:
- ERC-20 standard
- NEP#4 NEAR NFT standard: near/neps#4
Learn about NEP-141:
We should be able to do the following:
- Initialize contract once. The given total supply will be owned by the given account ID.
- Get the total supply.
- Transfer tokens to a new user.
- Transfer tokens from one user to another.
- Transfer tokens to a contract, have the receiver contract call a method and "return" any fungible tokens not used.
- Remove state for the key/value pair corresponding with a user's account, withdrawing a nominal balance of Ⓝ that was used for storage.
There are a few concepts in the scenarios above:
- Total supply: the total number of tokens in circulation.
- Balance owner: an account ID that owns some amount of tokens.
- Balance: an amount of tokens.
- Transfer: an action that moves some amount from one account to another account, either an externally owned account or a contract account.
- Transfer and call: an action that moves some amount from one account to a contract account where the receiver calls a method.
- Storage amount: the amount of storage used for an account to be "registered" in the fungible token. This amount is denominated in Ⓝ, not bytes, and represents the storage staked.
Note that precision (the number of decimal places supported by a given token) is not part of this core standard, since it's not required to perform actions. The minimum value is always 1 token. See the Fungible Token Metadata Standard to learn how to support precision/decimals in a standardized way.
Given that multiple users will use a Fungible Token contract, and their activity will result in an increased storage staking burden for the contract's account, this standard is designed to interoperate nicely with the Account Storage standard for storage deposits and refunds.
Alice wants to send 5 wBTC tokens to Bob. Assumptions
- The wBTC token contract is
wbtc
. - Alice's account is
alice
. - Bob's account is
bob
. - The precision ("decimals" in the metadata standard) on wBTC contract is
10^8
. - The 5 tokens is
5 * 10^8
or as a number is500000000
.
Alice needs to issue one transaction to wBTC contract to transfer 5 tokens (multiplied by precision) to Bob.
alice
callswbtc::ft_transfer({"receiver_id": "bob", "amount": "500000000"})
.
Alice wants to deposit 1000 DAI tokens to a compound interest contract to earn extra tokens.
- The DAI token contract is
dai
. - Alice's account is
alice
. - The compound interest contract is
compound
. - The precision ("decimals" in the metadata standard) on DAI contract is
10^18
. - The 1000 tokens is
1000 * 10^18
or as a number is1000000000000000000000
. - The compound contract can work with multiple token types.
For this example, you may expand this section to see how a previous fungible token standard using escrows would deal with the scenario.
Alice needs to issue 2 transactions. The first one to dai
to set an allowance for compound
to be able to withdraw tokens from alice
.
The second transaction is to the compound
to start the deposit process. Compound will check that the DAI tokens are supported and will try to withdraw the desired amount of DAI from alice
.
- If transfer succeeded,
compound
can increase local ownership foralice
to 1000 DAI - If transfer fails,
compound
doesn't need to do anything in current example, but maybe can notifyalice
of unsuccessful transfer.
alice
callsdai::set_allowance({"escrow_account_id": "compound", "allowance": "1000000000000000000000"})
.alice
callscompound::deposit({"token_contract": "dai", "amount": "1000000000000000000000"})
. During thedeposit
call,compound
does the following:- makes async call
dai::transfer_from({"owner_id": "alice", "new_owner_id": "compound", "amount": "1000000000000000000000"})
. - attaches a callback
compound::on_transfer({"owner_id": "alice", "token_contract": "dai", "amount": "1000000000000000000000"})
.
- makes async call
Alice needs to issue 1 transaction, as opposed to 2 with a typical escrow workflow.
alice
callsdai::ft_transfer_call({"receiver_id": "compound", "amount": "1000000000000000000000", "msg": "invest"})
. During theft_transfer_call
call,dai
does the following:- makes async call
compound::ft_on_transfer({"sender_id": "alice", "amount": "1000000000000000000000", "msg": "invest"})
. - attaches a callback
dai::ft_resolve_transfer({"sender_id": "alice", "receiver_id": "compound", "amount": "1000000000000000000000"})
. - compound finishes investing, using all attached fungible tokens
compound::invest({…})
then returns the value of the tokens that weren't used or needed. In this case, Alice asked for the tokens to be invested, so it will return 0. (In some cases a method may not need to use all the fungible tokens, and would return the remainder.) - the
dai::ft_resolve_transfer
function receives success/failure of the promise. If success, it will contain the unused tokens. Then thedai
contract uses simple arithmetic (not needed in this case) and updates the balance for Alice.
- makes async call
Alice wants to swap 5 wrapped NEAR (wNEAR) for BNNA tokens at current market rate, with less than 2% slippage.
- The wNEAR token contract is
wnear
. - Alice's account is
alice
. - The AMM's contract is
amm
. - BNNA's contract is
bnna
. - The precision ("decimals" in the metadata standard) on wNEAR contract is
10^24
. - The 5 tokens is
5 * 10^24
or as a number is5000000000000000000000000
.
Alice needs to issue one transaction to wNEAR contract to transfer 5 tokens (multiplied by precision) to amm
, specifying her desired action (swap), her destination token (BNNA) & minimum slippage (<2%) in msg
.
Alice will probably make this call via a UI that knows how to construct msg
in a way the amm
contract will understand. However, it's possible that the amm
contract itself may provide view functions which take desired action, destination token, & slippage as input and return data ready to pass to msg
for ft_transfer_call
. For the sake of this example, let's say amm
implements a view function called ft_data_to_msg
.
Alice needs to attach one yoctoNEAR. This will result in her seeing a confirmation page in her preferred NEAR wallet. NEAR wallet implementations will (eventually) attempt to provide useful information in this confirmation page, so receiver contracts should follow a strong convention in how they format msg
. We will update this documentation with a recommendation, as community consensus emerges.
Altogether then, Alice may take two steps, though the first may be a background detail of the app she uses.
-
View
amm::ft_data_to_msg({ action: "swap", destination_token: "bnna", min_slip: 2 })
. Using NEAR CLI:near view amm ft_data_to_msg \ '{"action": "swap", "destination_token": "bnna", "min_slip": 2}'
Then Alice (or the app she uses) will hold onto the result and use it in the next step. Let's say this result is
"swap:bnna,2"
. -
Call
wnear::ft_on_transfer
. Using NEAR CLI:near call wnear ft_transfer_call \ '{"receiver_id": "amm", "amount": "5000000000000000000000000", "msg": "swap:bnna,2"}' \ --accountId alice --depositYocto 1
During the
ft_transfer_call
call,wnear
does the following:- Decrease the balance of
alice
and increase the balance ofamm
by 5000000000000000000000000. - Makes async call
amm::ft_on_transfer({"sender_id": "alice", "amount": "5000000000000000000000000", "msg": "swap:bnna,2"})
. - Attaches a callback
wnear::ft_resolve_transfer({"sender_id": "alice", "receiver_id": "compound", "amount": "5000000000000000000000000"})
. amm
finishes the swap, either successfully swapping all 5 wNEAR within the desired slippage, or failing.- The
wnear::ft_resolve_transfer
function receives success/failure of the promise. Assumingamm
implements all-or-nothing transfers (as in, it will not transfer less-than-the-specified amount in order to fulfill the slippage requirements),wnear
will do nothing at this point if the swap succeeded, or it will decrease the balance ofamm
and increase the balance ofalice
by 5000000000000000000000000.
- Decrease the balance of
NOTES:
- All amounts, balances and allowance are limited by
U128
(max value2**128 - 1
). - Token standard uses JSON for serialization of arguments and results.
- Amounts in arguments and results have are serialized as Base-10 strings, e.g.
"100"
. This is done to avoid JSON limitation of max integer value of2**53
. - The contract must track the change in storage when adding to and removing from collections. This is not included in this core fungible token standard but instead in the Storage Standard.
- To prevent the deployed contract from being modified or deleted, it should not have any access keys on its account.
Simple transfer to a receiver.
Requirements:
- Caller of the method must attach a deposit of 1 yoctoⓃ for security purposes
- Caller must have greater than or equal to the
amount
being requested
Arguments:
receiver_id
: the valid NEAR account receiving the fungible tokens.amount
: the number of tokens to transfer, wrapped in quotes and treated like a string, although the number will be stored as an unsigned integer with 128 bits.memo
(optional): for use cases that may benefit from indexing or providing information for a transfer.
function ft_transfer(
receiver_id: string,
amount: string,
memo: string | null
): void;
Transfer tokens and call a method on a receiver contract. A successful
workflow will end in a success execution outcome to the callback on the same
contract at the method ft_resolve_transfer
.
You can think of this as being similar to attaching native NEAR tokens to a
function call. It allows you to attach any Fungible Token in a call to a
receiver contract.
Requirements:
- Caller of the method must attach a deposit of 1 yoctoⓃ for security purposes
- Caller must have greater than or equal to the
amount
being requested - The receiving contract must implement
ft_on_transfer
according to the standard. If it does not, FT contract'sft_resolve_transfer
MUST deal with the resulting failed cross-contract call and roll back the transfer. - Contract MUST implement the behavior described in
ft_resolve_transfer
Arguments:
receiver_id
: the valid NEAR account receiving the fungible tokens.amount
: the number of tokens to transfer, wrapped in quotes and treated like a string, although the number will be stored as an unsigned integer with 128 bits.memo
(optional): for use cases that may benefit from indexing or providing information for a transfer.msg
: specifies information needed by the receiving contract in order to properly handle the transfer. Can indicate both a function to call and the parameters to pass to that function.
function ft_transfer_call(
receiver_id: string,
amount: string,
memo: string | null,
msg: string
): Promise;
This function is implemented on the receiving contract.
As mentioned, the msg
argument contains information necessary for the receiving contract to know how to process the request. This may include method names and/or arguments.
Returns a value, or a promise which resolves with a value. The value is the
number of unused tokens in string form. For instance, if amount
is 10 but only 9 are
needed, it will return "1".
function ft_on_transfer(sender_id: string, amount: string, msg: string): string;
Returns the total supply of fungible tokens as a string representing the value as an unsigned 128-bit integer.
function ft_total_supply(): string
Returns the balance of an account in string form representing a value as an unsigned 128-bit integer. If the account doesn't exist must returns "0"
.
function ft_balance_of(account_id: string): string;
The following behavior is required, but contract authors may name this function something other than the conventional ft_resolve_transfer
used here.
Finalize an ft_transfer_call
chain of cross-contract calls.
The ft_transfer_call
process:
- Sender calls
ft_transfer_call
on FT contract - FT contract transfers
amount
tokens from sender to receiver - FT contract calls
ft_on_transfer
on receiver contract - [receiver contract may make other cross-contract calls]
- FT contract resolves promise chain with
ft_resolve_transfer
, and may refund sender some or all of originalamount
Requirements:
- Contract MUST forbid calls to this function by any account except self
- If promise chain failed, contract MUST revert token transfer
- If promise chain resolves with a non-zero amount given as a string,
contract MUST return this amount of tokens to
sender_id
Arguments:
sender_id
: the sender offt_transfer_call
receiver_id
: thereceiver_id
argument given toft_transfer_call
amount
: theamount
argument given toft_transfer_call
Returns a string representing a string version of an unsigned 128-bit
integer of how many total tokens were spent by sender_id. Example: if sender
calls ft_transfer_call({ "amount": "100" })
, but receiver_id
only uses
80, ft_on_transfer
will resolve with "20"
, and ft_resolve_transfer
will return "80"
.
function ft_resolve_transfer(
sender_id: string,
receiver_id: string,
amount: string
): string;
Standard interfaces for FT contract actions that extend NEP-297
NEAR and third-party applications need to track mint
, transfer
, burn
events for all FT-driven apps consistently.
This extension addresses that.
Keep in mind that applications, including NEAR Wallet, could require implementing additional methods, such as ft_metadata
, to display the FTs correctly.
Fungible Token Events MUST have standard
set to "nep141"
, standard version set to "1.0.0"
, event
value is one of ft_mint
, ft_burn
, ft_transfer
, and data
must be of one of the following relevant types: FtMintLog[] | FtTransferLog[] | FtBurnLog[]
:
interface FtEventLogData {
standard: "nep141";
version: "1.0.0";
event: "ft_mint" | "ft_burn" | "ft_transfer";
data: FtMintLog[] | FtTransferLog[] | FtBurnLog[];
}
// An event log to capture tokens minting
// Arguments
// * `owner_id`: "account.near"
// * `amount`: the number of tokens to mint, wrapped in quotes and treated
// like a string, although the number will be stored as an unsigned integer
// with 128 bits.
// * `memo`: optional message
interface FtMintLog {
owner_id: string;
amount: string;
memo?: string;
}
// An event log to capture tokens burning
// Arguments
// * `owner_id`: owner of tokens to burn
// * `amount`: the number of tokens to burn, wrapped in quotes and treated
// like a string, although the number will be stored as an unsigned integer
// with 128 bits.
// * `memo`: optional message
interface FtBurnLog {
owner_id: string;
amount: string;
memo?: string;
}
// An event log to capture tokens transfer
// Arguments
// * `old_owner_id`: "owner.near"
// * `new_owner_id`: "receiver.near"
// * `amount`: the number of tokens to transfer, wrapped in quotes and treated
// like a string, although the number will be stored as an unsigned integer
// with 128 bits.
// * `memo`: optional message
interface FtTransferLog {
old_owner_id: string;
new_owner_id: string;
amount: string;
memo?: string;
}
Batch mint:
EVENT_JSON:{
"standard": "nep141",
"version": "1.0.0",
"event": "ft_mint",
"data": [
{"owner_id": "foundation.near", "amount": "500"}
]
}
Batch transfer:
EVENT_JSON:{
"standard": "nep141",
"version": "1.0.0",
"event": "ft_transfer",
"data": [
{"old_owner_id": "from.near", "new_owner_id": "to.near", "amount": "42", "memo": "hi hello bonjour"},
{"old_owner_id": "user1.near", "new_owner_id": "user2.near", "amount": "7500"}
]
}
Batch burn:
EVENT_JSON:{
"standard": "nep141",
"version": "1.0.0",
"event": "ft_burn",
"data": [
{"owner_id": "foundation.near", "amount": "100"},
]
}
Note that the example events covered above cover two different kinds of events:
- Events that are not specified in the FT Standard (
ft_mint
,ft_burn
) - An event that is covered in the FT Core Standard. (
ft_transfer
)
Please feel free to open pull requests for extending the events standard detailed here as needs arise.
The near-contract-standards
cargo package of the Near Rust SDK contain the following implementations of NEP-141:
- Minimum Viable Interface
- The Core Fungible Token Implementation
- Optional Fungible Token Events
- Core Fungible Token tests
- The
msg
argument toft_transfer
andft_transfer_call
is freeform, which may necessitate conventions. - The paradigm of an escrow system may be familiar to developers and end users, and education on properly handling this in another contract may be needed.
- Support for multiple token types
- Minting and burning
See also the discussions:
Copyright and related rights waived via CC0.