Near Learn

NEP-141: Fungible tokens

NEP-141 fungible token standard on NEAR: ft_transfer, ft_transfer_call and its callbacks, the 1 yoctoNEAR rule, and why there is no ERC-20 approve.

Intermediate7 min read3-question check

NEP-141 is the core fungible-token interface on NEAR — the role ERC-20 plays on Ethereum. USDC, USDT, wNEAR and every other NEAR token implement it. On its own it only defines balances and transfers; a complete token also implements storage management (NEP-145) and metadata (NEP-148), and emits NEP-297 events.

The headline difference from ERC-20: there is no `approve` / `allowance` / `transferFrom`. To pay a contract you call ft_transfer_call, which moves the tokens and notifies the receiving contract in one flow, with an automatic refund of whatever it does not use.

Methods#

MethodKindWhat it doesDeposit
ft_transfer(receiver_id, amount, memo)changeMove amount from the caller to receiver_id.exactly 1 yoctoNEAR
ft_transfer_call(receiver_id, amount, memo, msg)changeTransfer, then call receiver_id.ft_on_transfer(...); refunds unused tokens. Returns the amount actually used.exactly 1 yoctoNEAR
ft_total_supply()viewTotal supply as a string (U128).—
ft_balance_of(account_id)viewBalance of an account as a string; "0" if unknown.—
ft_on_transfer(sender_id, amount, msg)change (on the receiver)Implemented by contracts that accept tokens. Returns how many tokens to refund.—
ft_resolve_transfer(sender_id, receiver_id, amount)private callbackRuns after ft_on_transfer; performs the refund. Only callable by the token contract itself.—

How a transfer-and-call works#

  1. Alice calls token.ft_transfer_call(receiver_id: "dex.near", amount: "100", msg: "...") with 1 yoctoNEAR.
  2. The token contract moves 100 from Alice to dex.near immediately, then schedules dex.near.ft_on_transfer(sender_id: "alice.near", amount: "100", msg).
  3. dex.near does its work (e.g. credits a deposit or performs a swap described in msg) and returns the unused amount, say "20".
  4. The token contract’s ft_resolve_transfer callback runs and moves 20 back from dex.near to Alice. If ft_on_transfer panicked or does not exist, the full 100 is refunded.
  5. ft_transfer_call resolves to the amount used (80). Every step is a separate receipt, so the whole flow takes a few blocks.
ERC-20 approve-then-pull vs NEP-141 push-and-notify
Solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

// user first sends: token.approve(vault, amount)
contract Vault {
    IERC20 public immutable token;
    mapping(address => uint256) public deposits;

    constructor(IERC20 t) { token = t; }

    function deposit(uint256 amount) external {
        // pull the tokens using the allowance
        token.transferFrom(msg.sender, address(this), amount);
        deposits[msg.sender] += amount;
    }
}
Rust
use near_contract_standards::fungible_token::receiver::FungibleTokenReceiver;
use near_sdk::json_types::U128;
use near_sdk::store::LookupMap;
use near_sdk::{env, near, require, AccountId, PanicOnDefault, PromiseOrValue};

// user calls token.ft_transfer_call(receiver_id: vault, amount, msg: "")
#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct Vault {
    token: AccountId,
    deposits: LookupMap<AccountId, u128>,
}

#[near]
impl Vault {
    #[init]
    pub fn new(token: AccountId) -> Self {
        Self { token, deposits: LookupMap::new(b"d") }
    }
}

#[near]
impl FungibleTokenReceiver for Vault {
    fn ft_on_transfer(
        &mut self,
        sender_id: AccountId,
        amount: U128,
        msg: String,
    ) -> PromiseOrValue<U128> {
        // anyone can call this method — only trust the token we expect
        require!(env::predecessor_account_id() == self.token, "unsupported token");
        require!(msg.is_empty(), "unexpected msg");

        let current = self.deposits.get(&sender_id).copied().unwrap_or(0);
        self.deposits.insert(sender_id, current + amount.0);

        // keep everything: refund 0
        PromiseOrValue::Value(U128(0))
    }
}
Coming from EVM

The receiver never pulls; it is pushed tokens and told about it. Returning a non-zero U128 asks the token contract to refund that many tokens to the sender.

ft_on_transfer is a public method — always check `predecessor_account_id()` against the token you accept, or anyone can call it and fake a deposit.

Check yourself

3 questions · progress saved in this browser

  1. 1.Why must ft_transfer be called with exactly 1 yoctoNEAR attached?
  2. 2.A receiver’s ft_on_transfer panics during ft_transfer_call. What happens to the tokens?
  3. 3.What does the receiver return from ft_on_transfer?