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#
| Method | Kind | What it does | Deposit |
|---|---|---|---|
ft_transfer(receiver_id, amount, memo) | change | Move amount from the caller to receiver_id. | exactly 1 yoctoNEAR |
ft_transfer_call(receiver_id, amount, memo, msg) | change | Transfer, then call receiver_id.ft_on_transfer(...); refunds unused tokens. Returns the amount actually used. | exactly 1 yoctoNEAR |
ft_total_supply() | view | Total supply as a string (U128). | — |
ft_balance_of(account_id) | view | Balance 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 callback | Runs after ft_on_transfer; performs the refund. Only callable by the token contract itself. | — |
How a transfer-and-call works#
- Alice calls
token.ft_transfer_call(receiver_id: "dex.near", amount: "100", msg: "...")with 1 yoctoNEAR. - The token contract moves 100 from Alice to
dex.nearimmediately, then schedulesdex.near.ft_on_transfer(sender_id: "alice.near", amount: "100", msg). dex.neardoes its work (e.g. credits a deposit or performs a swap described inmsg) and returns the unused amount, say"20".- The token contract’s
ft_resolve_transfercallback runs and moves 20 back fromdex.nearto Alice. Ifft_on_transferpanicked or does not exist, the full 100 is refunded. ft_transfer_callresolves to the amount used (80). Every step is a separate receipt, so the whole flow takes a few blocks.
// 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;
}
}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))
}
}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