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#
- The backer calls
ft_transfer_callon the token contract withreceiver_id= our contract, anamount,msg= the campaign id, and exactly 1 yoctoNEAR attached. - The token contract moves the tokens to our contract’s balance and calls
ft_on_transfer(sender_id, amount, msg)on our contract. - We record the pledge and return how many tokens we did not use.
- The token contract’s
ft_resolve_transfercallback sends the unused part back to the backer. Ifft_on_transferpanicked, it sends everything back.
The receiver: ft_on_transfer#
/// 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_transferdirectly with anyamount. The only proof that tokens really arrived is that the caller is the token contract the campaign accepts.sender_idis 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_transferreceives no NEAR, so it cannot charge for new records. Backers callregister_backer(with a small NEAR deposit, excess refunded) before their first token pledge — the same idea as registering with a token via NEP-145storage_deposit.
Paying out in tokens#
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),
}
}// 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.
# 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 sendCheck yourself
3 questions · progress saved in this browser