Near Learn

Pledging with fungible tokens

Accept NEP-141 token pledges with ft_on_transfer: check the token, return unused amounts, and pay out tokens with 1 yoctoNEAR ft_transfer plus a callback.

Intermediate11 min read3-question check

Not every campaign wants to raise NEAR. A campaign created with "token": "your-token.testnet" should accept that NEP-141 token instead. In this milestone the contract becomes a token receiver, and claim / refund learn to pay in tokens.

How tokens arrive on NEAR#

  1. The backer calls ft_transfer_call on the token contract with receiver_id = our contract, an amount, msg = the campaign id, and exactly 1 yoctoNEAR attached.
  2. The token contract moves the tokens to our contract’s balance and calls ft_on_transfer(sender_id, amount, msg) on our contract.
  3. We record the pledge and return how many tokens we did not use.
  4. The token contract’s ft_resolve_transfer callback sends the unused part back to the backer. If ft_on_transfer panicked, it sends everything back.

The receiver: ft_on_transfer#

Add to the #[near] impl block
Rust
/// NEP-141 receiver: msg is the campaign id. Returns the amount NOT used.
pub fn ft_on_transfer(
    &mut self,
    sender_id: AccountId,
    amount: U128,
    msg: String,
) -> PromiseOrValue<U128> {
    let token = env::predecessor_account_id();
    let campaign_id: CampaignId = msg
        .parse()
        .unwrap_or_else(|_| env::panic_str("msg must be a campaign id"));
    let campaign = self.campaign(campaign_id);
    require!(campaign.token.as_ref() == Some(&token), "token not accepted by this campaign");

    let ended = env::block_timestamp_ms() >= campaign.deadline_ms;
    let registered = self.pledges.contains_key(&(campaign_id, sender_id.clone()));
    if ended || !registered {
        env::log_str("pledge not accepted: campaign ended or backer not registered");
        return PromiseOrValue::Value(amount); // every token goes back
    }

    self.add_pledge(campaign_id, &sender_id, amount.0);
    PromiseOrValue::Value(U128(0))
}

/// Pre-pay the storage for a backer record (needed before an FT pledge).
#[payable]
pub fn register_backer(&mut self, campaign_id: CampaignId) {
    let backer = env::predecessor_account_id();
    require!(
        env::block_timestamp_ms() < self.campaign(campaign_id).deadline_ms,
        "campaign has ended"
    );
    let cost = self.ensure_backer(campaign_id, &backer);
    refund_excess(&backer, cost);
}
  • Check the predecessor. Anyone can call ft_on_transfer directly with any amount. The only proof that tokens really arrived is that the caller is the token contract the campaign accepts. sender_id is trustworthy only after that check, because it comes from the token.
  • Return, don’t panic, for “not now”. A closed campaign or an unregistered backer returns the full amount, and the token contract refunds it. Panicking would also refund, but returning lets us log a clear reason.
  • Storage comes first. ft_on_transfer receives no NEAR, so it cannot charge for new records. Backers call register_backer (with a small NEAR deposit, excess refunded) before their first token pledge — the same idea as registering with a token via NEP-145 storage_deposit.

Paying out in tokens#

The token interface, new constants, and one payout helper for both currencies
Rust
use near_sdk::{
    env, ext_contract, near, require, AccountId, BorshStorageKey, Gas, NearToken, PanicOnDefault,
    Promise, PromiseError, PromiseOrValue,
};

const GAS_FT_TRANSFER: Gas = Gas::from_tgas(10);
const ONE_YOCTO: NearToken = NearToken::from_yoctonear(1);

#[ext_contract(ext_ft)]
pub trait FungibleToken {
    fn ft_transfer(&mut self, receiver_id: AccountId, amount: U128, memo: Option<String>);
}

/// Sends amount of the campaign currency: native NEAR or a NEP-141 token.
fn pay(token: &Option<AccountId>, to: AccountId, amount: u128) -> Promise {
    match token {
        None => Promise::new(to).transfer(NearToken::from_yoctonear(amount)),
        Some(token) => ext_ft::ext(token.clone())
            .with_attached_deposit(ONE_YOCTO)
            .with_static_gas(GAS_FT_TRANSFER)
            .ft_transfer(to, U128(amount), None),
    }
}
claim and refund now call pay() — the callbacks from lesson 5 work unchanged
Rust
// in claim
campaign.claimed = true; // effects before the interaction
let payout = pay(&campaign.token, campaign.creator.clone(), campaign.raised);
payout.then(
    Self::ext(env::current_account_id())
        .with_static_gas(GAS_CALLBACK)
        .on_claim(campaign_id),
)

// in refund: copy the token out before the pledges write
let token = campaign.token.clone();
// ... checks, deduct first ...
pay(&token, backer.clone(), amount).then(
    Self::ext(env::current_account_id())
        .with_static_gas(GAS_CALLBACK)
        .on_refund(campaign_id, backer, U128(amount)),
)

ft_transfer returns nothing on success, so on_claim / on_refund still take Result<(), PromiseError>. A token payout fails most often because the receiver is not registered with the token (no NEP-145 storage deposit) — exactly the case our callbacks repair. Tell creators to register with the token before they claim.

A token pledge from the CLI (replace the token and contract accounts with yours)
Shell
# 0. once: our contract must be registered with the token to hold it.
#    check what registration costs, then pay it for the contract account
near contract call-function as-read-only your-token.testnet storage_balance_bounds \
  json-args '{}' network-config testnet now
near contract call-function as-transaction your-token.testnet storage_deposit \
  json-args '{"account_id": "crowdfund.you.testnet"}' \
  prepaid-gas '30 Tgas' attached-deposit '0.01 NEAR' \
  sign-as you.testnet network-config testnet sign-with-keychain send

# 1. the backer pays for their record in campaign 1 (excess is refunded)
near contract call-function as-transaction crowdfund.you.testnet register_backer \
  json-args '{"campaign_id": 1}' \
  prepaid-gas '30 Tgas' attached-deposit '0.01 NEAR' \
  sign-as alice.testnet network-config testnet sign-with-keychain send

# 2. pledge: call the TOKEN, with our contract as receiver and the campaign id as msg
near contract call-function as-transaction your-token.testnet ft_transfer_call \
  json-args '{"receiver_id": "crowdfund.you.testnet", "amount": "5000000", "msg": "1"}' \
  prepaid-gas '100 Tgas' attached-deposit '1 yoctoNEAR' \
  sign-as alice.testnet network-config testnet sign-with-keychain send

Check yourself

3 questions · progress saved in this browser

  1. 1.Inside ft_on_transfer, what proves that tokens really arrived?
  2. 2.ft_on_transfer returns PromiseOrValue::Value(amount). What happens to the tokens?
  3. 3.Why must FT backers call register_backer before their first token pledge?