Handle NEP141 Transfer Calls and Track the Target Contract's Balance
Learn to use ft_transfer_call function calls. This method needs a bit of Rust logic to make it work with the target contract.
Register the target address, transfer the minimum NEAR balance and make your dApp accept fungible token transfers to create engaging games with real money 🤑

For a NEAR wallet to receive NEP141 payments, for example USDT usdt.tether-token.near
transfers, a simple ft_transfer
call will suffice. If the source wallet has the necessary balance, the money will be transferred. But what if you want to notify another contract about this balance transfer and act upon it?
This is what we do in Prompt Wars. When a user submits a prompt, they are actually transferring their fungible token balance to the game contract, the game contract is "notified" and registers the player's prompt to list them in the game.
IMPORTANT
New contracts that should receive NEP141 fungible token transfers need to be registered.
The ft_receiver contract
Part of the Prompt Wars suite of methods, is the ft_on_transfer
method. This method will be called after a successful ft_transfer_call
call on the receiving NEP141 funginble token contract.
fn ft_on_transfer(
&mut self,
sender_id: AccountId,
amount: U128,
msg: String,
) -> PromiseOrValue<U128> {
let amount: WrappedBalance = amount.try_into().unwrap();
assert!(amount > 0, "ERR_ZERO_AMOUNT");
let payload: Payload = serde_json::from_str(&msg).expect("ERR_INVALID_PAYLOAD");
match payload {
Payload::CreateOutcomeTokenArgs(payload) => {
self.create_outcome_token(sender_id, amount, payload)
}
};
// All the collateral was used, so we should issue no refund on ft_resolve_transfer
return PromiseOrValue::Value(U128::from(0));
}
}
The ft_on_transfer
msg
Payload
ft_on_transfer
msg
PayloadThe pattern above expects a String
as the msg
parameter, this String
is formatted by JSON.stringify
and sent via the cross-contract call to be unwrapped by this line:
let payload: Payload = serde_json::from_str(&msg).expect("ERR_INVALID_PAYLOAD");
Rust does the work of checking the Payload
structure and then passes it to the match
block:
match payload {
Payload::CreateOutcomeTokenArgs(payload) => {
self.create_outcome_token(sender_id, amount, payload)
}
};
Where self.create_outcome_token(sender_id, amount, payload)
is part of the game's private contract methods.
#[private]
#[payable]
pub fn create_outcome_token(
&mut self,
sender_id: AccountId,
amount: WrappedBalance,
payload: CreateOutcomeTokenArgs,
) -> WrappedBalance {
self.assert_is_open();
self.assert_is_not_resolved();
self.assert_price(amount);
let outcome_id = sender_id;
let prompt = payload.prompt;
match self.outcome_tokens.get(&outcome_id) {
Some(_token) => env::panic_str("ERR_CREATE_OUTCOME_TOKEN_outcome_id_EXIST"),
None => {
self.internal_create_outcome_token(outcome_id, prompt, amount);
}
}
amount
}
Calling ft_transfer_call
from the client-side
ft_transfer_call
from the client-sideThis whole pattern is used from the client-side when a user's wallet is connected. Notice how we format the msg
parameter expected by ft_transfer_call
:
const ftTransferCall = async (prompt: Prompt) => {
if (!marketContractValues) {
return;
}
try {
assertWalletConnection();
setActions((prev) => ({
...prev,
ftTransferCall: {
...prev.ftTransferCall,
isLoading: true,
},
}));
const amount = marketContractValues.fees.price.toString();
const msg = JSON.stringify({ CreateOutcomeTokenArgs: { prompt: JSON.stringify(prompt) } });
await FungibleTokenContract.ftTransferCall(
walletStateContext.context.wallet!,
marketContractValues.collateralToken.id!,
{
receiver_id: marketId,
amount,
msg,
},
Unwrapping ft_on_transfer result
If the NEP141 transfer succeeds, then there should be a new balance registered for the target contract, in this case our game.
To check for a successful transfer, remember that create_outcome_token
returns an amount. This amount should be greater than 0 and should match the amount we are charging to play the game:
#[private]
pub fn create_outcome_token(
&mut self,
sender_id: AccountId,
amount: WrappedBalance,
payload: CreateOutcomeTokenArgs,
) -> WrappedBalance {
self.assert_is_open();
self.assert_is_not_resolved();
self.assert_price(amount);
let outcome_id = sender_id;
let prompt = payload.prompt;
match self.outcome_tokens.get(&outcome_id) {
Some(_token) => env::panic_str("ERR_CREATE_OUTCOME_TOKEN_outcome_id_EXIST"),
None => {
self.internal_create_outcome_token(outcome_id, prompt, amount);
}
}
amount
}
This amount
is unwrapped on ftTransferCall
.
Displaying the game contract's balance
This client-side step is the easiest. Just call ft_balance_of
to check the balance of a target accountId
on any NEP141 fungible token:
}
return "0.00";
}
async ftBalanceOf(args: FtBalanceOfArgs) {
try {
const result = await this.contract.ft_balance_of(args);
return result;
} catch (error) {
Formatting NEP141 Fungible Token balances
Each fungible token has its own decimals
value definition. For USDT for example, it is decimals: 6
.
Remember that smart-contracts use fixed-point decimals (no .
symbol to distinguish cents as in 1.0
). For example:
NEAR_ENV=mainnet near view usdt.tether-token.near ft_metadata 1 ↵
View call: usdt.tether-token.near.ft_metadata()
{
spec: 'ft-1.0.0',
name: 'Tether USD',
symbol: 'USDt',
icon: "data:image/svg+xml,%3Csvg width='111' height='90' viewBox='0 0 111 90' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M24.4825 0.862305H88.0496C89.5663 0.862305 90.9675 1.64827 91.7239 2.92338L110.244 34.1419C111.204 35.7609 110.919 37.8043 109.549 39.1171L58.5729 87.9703C56.9216 89.5528 54.2652 89.5528 52.6139 87.9703L1.70699 39.1831C0.305262 37.8398 0.0427812 35.7367 1.07354 34.1077L20.8696 2.82322C21.6406 1.60483 23.0087 0.862305 24.4825 0.862305ZM79.8419 14.8003V23.5597H61.7343V29.6329C74.4518 30.2819 83.9934 32.9475 84.0642 36.1425L84.0638 42.803C83.993 45.998 74.4518 48.6635 61.7343 49.3125V64.2168H49.7105V49.3125C36.9929 48.6635 27.4513 45.998 27.3805 42.803L27.381 36.1425C27.4517 32.9475 36.9929 30.2819 49.7105 29.6329V23.5597H31.6028V14.8003H79.8419ZM55.7224 44.7367C69.2943 44.7367 80.6382 42.4827 83.4143 39.4727C81.0601 36.9202 72.5448 34.9114 61.7343 34.3597V40.7183C59.7966 40.8172 57.7852 40.8693 55.7224 40.8693C53.6595 40.8693 51.6481 40.8172 49.7105 40.7183V34.3597C38.8999 34.9114 30.3846 36.9202 28.0304 39.4727C30.8066 42.4827 42.1504 44.7367 55.7224 44.7367Z' fill='%23009393'/%3E%3C/svg%3E",
reference: null,
reference_hash: null,
decimals: 6
}
With decimals being 6, an amount of USDT 1.0 would be represented as:
1000000
But this would be confusing for your users, since they are used to the dot notation.
Fixed-point decimal amount format helpers
Luckily for you, Prompt Wars needed to display formatted amounts and we developed some helpers that may suit your dApp as well:
const toDecimalsPrecisionString = (amount: string | number, decimals: number) =>
Last updated