Anatomy of a Promise
What a NEAR Promise really is: a receipt under construction. Low-level Promise::new actions vs #[ext_contract] calls, and what returning a Promise does.
Intermediate11 min read3-question check
A near-sdk Promise is not a future and it never “resolves” inside your method. It is a builder for a receipt: a receiver account plus a list of actions, optionally linked to other promises with .then or .and. Nothing is sent while you build it. The SDK hands it to the runtime when the Promise value is returned or dropped, and the runtime turns it into an outgoing receipt once your method has finished successfully.
If your method panics after building promises, none of them are sent. That is the one place where NEAR gives you “all or nothing”: your state changes and your outgoing calls commit together, or not at all.
Low level: Promise::new plus actions#
use near_sdk::serde_json::json;
use near_sdk::{env, near, AccountId, Gas, GasWeight, NearToken, Promise};
#[near(contract_state)]
#[derive(Default)]
pub struct Payer {}
#[near]
impl Payer {
// a plain NEAR transfer: one Transfer action on the receiver
pub fn tip(&mut self, to: AccountId) -> Promise {
Promise::new(to).transfer(NearToken::from_millinear(10))
}
// a function call built by hand: method name, raw argument bytes,
// attached deposit and static gas
pub fn pay_tokens(&mut self, token: AccountId, to: AccountId, amount: String) -> Promise {
let args = json!({ "receiver_id": to, "amount": amount, "memo": null });
Promise::new(token).function_call(
"ft_transfer".to_string(),
args.to_string().into_bytes(),
NearToken::from_yoctonear(1),
Gas::from_tgas(10),
)
}
// same, but also asking for a share of the leftover gas
pub fn pay_tokens_weighted(&mut self, token: AccountId, to: AccountId, amount: String) -> Promise {
let args = json!({ "receiver_id": to, "amount": amount, "memo": null });
Promise::new(token).function_call_weight(
"ft_transfer".to_string(),
args.to_string().into_bytes(),
NearToken::from_yoctonear(1),
Gas::from_tgas(10),
GasWeight(1),
)
}
pub fn whoami(&self) -> AccountId {
env::current_account_id()
}
}High level: #[ext_contract]#
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20 {
function transfer(address to, uint256 amount)
external returns (bool);
}
contract Payer {
function pay(IERC20 token, address to, uint256 amount) external {
// runs NOW, inside this call; reverts bubble up
bool ok = token.transfer(to, amount);
require(ok, "transfer failed");
}
}use near_sdk::json_types::U128;
use near_sdk::{ext_contract, near, AccountId, Gas, NearToken, Promise};
#[ext_contract(ext_ft)]
pub trait FungibleToken {
fn ft_transfer(&mut self, receiver_id: AccountId, amount: U128, memo: Option<String>);
}
#[near(contract_state)]
#[derive(Default)]
pub struct Payer {}
#[near]
impl Payer {
pub fn pay(&mut self, token: AccountId, to: AccountId, amount: U128) -> Promise {
// builds the SAME FunctionCall action as the hand-written
// version: name "ft_transfer", JSON-encoded args
ext_ft::ext(token)
.with_attached_deposit(NearToken::from_yoctonear(1))
.with_static_gas(Gas::from_tgas(10))
.ft_transfer(to, amount, None)
}
}#[ext_contract(ext_ft)] generates a module ext_ft with an ext(account_id) constructor and one builder method per trait function. The trait is never implemented; it only describes the remote interface. Arguments are serialised as JSON by default.
The same macro is applied to your own contract automatically: that is where Self::ext(env::current_account_id()) for callbacks comes from.
| Method | Default | What it controls |
|---|---|---|
.with_static_gas(Gas) | 0 | Gas reserved for the call when the promise is created |
.with_unused_gas_weight(u64) | 1 | Share of the gas left over when your method ends |
.with_attached_deposit(NearToken) | 0 | NEAR moved with the call. The callee sees it as env::attached_deposit(); if the call fails it is refunded to your contract, not to the user |
Returned, detached, or chained#
What you do with the finished Promise decides who ever hears about its result:
// 1. RETURNED: the method's result becomes the promise's result.
// The transaction outcome (or your caller's callback) sees what
// ft_transfer returned, or its failure.
pub fn pay(&mut self, token: AccountId, to: AccountId, amount: U128) -> Promise {
ext_ft::ext(token)
.with_attached_deposit(NearToken::from_yoctonear(1))
.with_static_gas(Gas::from_tgas(10))
.ft_transfer(to, amount, None)
}
// 2. DETACHED: still sent, but nobody receives the result.
// Fire-and-forget; this method returns () right away.
pub fn pay_and_forget(&mut self, token: AccountId, to: AccountId, amount: U128) {
ext_ft::ext(token)
.with_attached_deposit(NearToken::from_yoctonear(1))
.with_static_gas(Gas::from_tgas(10))
.ft_transfer(to, amount, None)
.detach();
}
// 3. CHAINED: a callback on this contract receives the result,
// and the callback's return value becomes the method's result.
pub fn pay_checked(&mut self, token: AccountId, to: AccountId, amount: U128) -> Promise {
ext_ft::ext(token)
.with_attached_deposit(NearToken::from_yoctonear(1))
.with_static_gas(Gas::from_tgas(10))
.ft_transfer(to.clone(), amount, None)
.then(
Self::ext(env::current_account_id())
.with_static_gas(Gas::from_tgas(5))
.on_paid(to, amount),
)
}Returning a promise works through the whole chain. If pay is itself called by another contract, that contract’s callback waits not for pay’s receipt but for the end of the chain pay returned. The runtime forwards the data receipt along: pay’s outcome is SuccessReceiptId, and the value finally delivered is ft_transfer’s result.
MoreWhat the SDK does under the hood
Promise::new(account)→env::promise_batch_create(account)returns a promise index; each action becomes apromise_batch_action_*host call on that index..then(next)→env::promise_batch_then(prev, account): the new receipt gets the previous one’s output as an input data dependency..and(other)→env::promise_and(&[..]): a joint index that completes when every member completes.- Returning a promise from a
#[near]method →env::promise_return(index): the method’s outcome becomesSuccessReceiptIdpointing at that receipt. - Building promises inside a view call fails with
ProhibitedInView: a view has no transaction to attach receipts to.
Check yourself
3 questions · progress saved in this browser