Editorial polish is still intentionally in progress
This section is hidden from primary navigation. Translation and editorial polish are intentionally out of scope until the section is public.
Advanced Features
This page covers NEAR's advanced transaction features: DelegateAction (NEP-366) for gasless meta-transactions, and Promise Yield/Resume (NEP-519) for suspending execution while waiting for external data.
DelegateAction: Meta-Transactions
DelegateAction enables gasless transactions - users can interact with NEAR without holding any NEAR for gas. A relayer pays the gas on their behalf.
The Problem It Solves
Traditional flow:
User signs tx → User pays gas → Transaction executesWith DelegateAction:
User signs DelegateAction → Relayer wraps in tx → Relayer pays gas → Action executes as userUse Cases
| Use Case | Description |
|---|---|
| User onboarding | New users can use dApps without buying NEAR first |
| Sponsored transactions | dApps pay for their users' gas |
| Gas station networks | Third-party relayer services |
| UX improvement | Hide blockchain complexity from end users |
DelegateAction Structure
Source: core/primitives/src/action/delegate.rs
pub struct DelegateAction {
/// Who authorizes this action
pub sender_id: AccountId,
/// Where actions execute
pub receiver_id: AccountId,
/// Actions to perform (no nested delegates!)
pub actions: Vec<NonDelegateAction>,
/// Nonce for sender's access key
pub nonce: Nonce,
/// Block height expiration
pub max_block_height: BlockHeight,
/// Which key signed this
pub public_key: PublicKey,
}
pub struct SignedDelegateAction {
pub delegate_action: DelegateAction,
pub signature: Signature,
}NonDelegateAction
Prevents nesting (which would complicate gas accounting):
pub struct NonDelegateAction(Action);
impl TryFrom<Action> for NonDelegateAction {
type Error = IsDelegateAction;
fn try_from(action: Action) -> Result<Self, IsDelegateAction> {
if matches!(action, Action::Delegate(_)) {
Err(IsDelegateAction) // Can't nest!
} else {
Ok(Self(action))
}
}
}The Signing Scheme (NEP-461)
DelegateAction uses a special hash to prevent signature reuse:
Source: core/primitives/src/signable_message.rs
impl DelegateAction {
pub fn get_nep461_hash(&self) -> CryptoHash {
let signable = SignableMessage::new(&self, SignableMessageType::DelegateAction);
let bytes = borsh::to_vec(&signable).expect("serialization works");
hash(&bytes)
}
}The discriminant (2^30 + 366) ensures:
- DelegateAction signatures can't be reused for regular transactions
- Different message types have different hash domains
The Complete Meta-Transaction Flow
Step 1: User Creates and Signs
let delegate_action = DelegateAction {
sender_id: "alice.near".parse()?,
receiver_id: "contract.near".parse()?,
actions: vec![
NonDelegateAction::try_from(
Action::FunctionCall(FunctionCallAction {
method_name: "do_something".to_string(),
args: b"{}".to_vec(),
gas: 30_000_000_000_000,
deposit: 0,
})
)?
],
nonce: alice_access_key_nonce + 1,
max_block_height: current_block + 100,
public_key: alice_public_key,
};
let hash = delegate_action.get_nep461_hash();
let signature = alice_private_key.sign(hash.as_bytes());
let signed = SignedDelegateAction { delegate_action, signature };Step 2: Relayer Wraps and Submits
let transaction = Transaction {
signer_id: "relayer.near".parse()?,
receiver_id: "alice.near".parse()?, // Must match sender_id!
public_key: relayer_public_key,
nonce: relayer_nonce + 1,
block_hash: recent_block_hash,
actions: vec![Action::Delegate(Box::new(signed))],
};
// Relayer signs and submits
let signed_tx = transaction.sign(&relayer_key);
submit(signed_tx);Step 3: Runtime Processing
Source: runtime/runtime/src/actions.rs
The runtime:
- Verifies signature using NEP-461 hash
- Checks expiration against
max_block_height - Verifies sender matches tx receiver
- Validates access key (nonce, permissions)
- Creates new receipt with inner actions
Access Key Validation
DelegateAction still respects access key permissions:
fn validate_delegate_action_key(...) {
// 1. Key must exist on sender's account
let access_key = get_access_key(state, &sender_id, &public_key)?;
// 2. Nonce must be increasing
if delegate_action.nonce <= access_key.nonce {
return Err(DelegateActionInvalidNonce);
}
// 3. Nonce can't be too far in future
let upper_bound = block_height * ACCESS_KEY_NONCE_RANGE_MULTIPLIER;
if delegate_action.nonce >= upper_bound {
return Err(DelegateActionNonceTooLarge);
}
// 4. Update nonce (prevents replay)
access_key.nonce = delegate_action.nonce;
set_access_key(state, ...);
// 5. Check FunctionCall key permissions if applicable
// - Only one action allowed
// - No deposits allowed
// - Receiver must match
// - Method must be in allowed list
}Gas and Refund Flow
The relayer pays everything:
Relayer prepays: send fees + exec fees + inner action gas
If success:
- Gas refund → relayer (signer_id)
- Deposit refund (if any) → alice (sender via predecessor_id)
If failure:
- All gas refund → relayer
- All deposit refund → aliceDelegateAction Security Properties
| Property | Mechanism |
|---|---|
| No nested delegates | NonDelegateAction type enforces |
| Signature domain separation | NEP-461 discriminant |
| Expiration | max_block_height prevents indefinite validity |
| Replay protection | Nonce must be strictly increasing |
| Permission preservation | Access key permissions still enforced |
Promise Yield/Resume: External Data Integration
Promise Yield/Resume (NEP-519) enables contracts to suspend execution and wait for external data. This is NEAR's most advanced async primitive.
The Problem It Solves
Traditional async in NEAR:
- Contract calls another contract
- Waits for callback with result
- All data must come from on-chain
Yield/Resume enables:
- Pause execution waiting for off-chain data
- External service provides data when ready
- Execution resumes with that data
Use Cases
| Use Case | Description |
|---|---|
| Oracle data | Price feeds, randomness, weather data |
| Off-chain computation | ML inference, complex calculations |
| Cross-chain bridges | Data from other blockchains |
| External APIs | Real-world data integration |
Receipt Types for Yield/Resume
Source: core/primitives/src/receipt.rs
pub enum ReceiptEnum {
// ...
PromiseYield(ActionReceipt) = 2, // Suspended execution
PromiseResume(DataReceipt) = 3, // Wake-up signal with data
}The Yield/Resume API
promise_yield_create()
Creates a yield point and returns a data_id that identifies this suspended execution:
Source: runtime/near-vm-runner/src/logic/logic.rs
pub fn promise_yield_create(
&mut self,
method_name_len: u64,
method_name_ptr: u64,
arguments_len: u64,
arguments_ptr: u64,
gas: u64,
gas_weight: u64,
register_id: u64, // Where to write the data_id
) -> Result<u64>Returns:
- Promise index (like regular promises)
- Writes
data_idto specified register
The data_id is a 32-byte hash that:
- Uniquely identifies this yield point
- Must be saved (in contract state or passed off-chain)
- Is needed to resume execution
promise_yield_resume()
Provides data to a suspended yield, triggering the callback:
pub fn promise_yield_resume(
&mut self,
data_id_len: u64,
data_id_ptr: u64,
payload_len: u64,
payload_ptr: u64,
) -> Result<u32>Returns:
1(true): Matching yield found and resolved0(false): No matching yield (maybe already resolved or expired)
The Complete Yield/Resume Flow
Step 1: Contract Creates Yield
// Contract code
pub fn request_oracle_data(&mut self, request: String) -> Promise {
// Create yield point, callback will run when resumed
let promise = env::promise_yield_create(
"on_oracle_data", // callback method
b"{}", // initial args
30_000_000_000_000, // gas for callback
0, // gas weight
0 // register for data_id
);
// Read the data_id from register 0
let data_id = env::read_register(0);
// Save data_id for the oracle service
self.pending_requests.insert(request.clone(), data_id);
// Emit event for oracle to see
env::log_str(&format!("OracleRequest:{}:{}", request, hex::encode(&data_id)));
promise
}Step 2: Off-Chain Service Monitors and Responds
// Oracle service (off-chain)
nearEvents.on('OracleRequest', async (request, dataId) => {
// Fetch the requested data
const price = await fetchPrice(request);
// Call resume on the contract
await contract.resume_oracle({
data_id: dataId,
payload: JSON.stringify({ price }),
});
});Step 3: Contract Resumes with Data
// Contract code
pub fn resume_oracle(&mut self, data_id: Vec<u8>, payload: Vec<u8>) {
// Call the resume primitive
let result = env::promise_yield_resume(
&data_id,
&payload
);
if result == 0 {
env::panic_str("No matching yield found (expired?)");
}
// The callback (on_oracle_data) will now execute with payload as input
}
// This runs when resumed
pub fn on_oracle_data(&mut self) {
// Read the resume payload
let data = env::promise_result(0);
match data {
PromiseResult::Successful(bytes) => {
let price: PriceData = serde_json::from_slice(&bytes).unwrap();
// Use the oracle data!
self.prices.insert(price.symbol, price.value);
}
PromiseResult::Failed => {
// Resume timed out or was called with None
env::panic_str("Oracle data not received");
}
}
}Flow Diagram
Timeout Mechanism
Yields don't wait forever. There's a configurable timeout (~200 blocks / ~200 seconds).
Source: core/parameters/src/vm.rs
pub yield_timeout_length_in_blocks: u64,Timeout Processing
When a yield times out:
PromiseResumeis created withdata: None- Callback executes with
PromiseResult::Failed - Contract should handle the failure case
pub fn on_oracle_data(&mut self) {
match env::promise_result(0) {
PromiseResult::Successful(bytes) => {
// Normal processing
}
PromiseResult::Failed => {
// Handle timeout - maybe refund user, use fallback data
self.handle_oracle_timeout();
}
}
}Gas Economics for Yield/Resume
Gas for a yielded promise is fixed at creation time. You cannot add more gas when calling promise_yield_resume.
Gas is Prepaid at Creation
When you call promise_yield_create, the gas parameter is immediately reserved:
// Inside promise_yield_create:
self.result_state.gas_counter.prepay_gas(Gas::from_gas(gas))?;Resume Has No Gas Parameter
pub fn promise_yield_resume(
data_id_len: u64,
data_id_ptr: u64,
payload_len: u64,
payload_ptr: u64,
) -> Result<u32>
// Note: NO gas parameter!The resume operation only delivers data - it doesn't provide additional resources.
Comparison with Regular Cross-Contract Calls
| Aspect | Regular promise_then | Yield/Resume |
|---|---|---|
| Gas attachment | Attached at each call | Fixed at creation only |
| Caller can add gas | Yes | No |
| Gas timing | Charged when promise executes | Prepaid immediately |
| Flexibility | High - adjust per call | Low - must estimate upfront |
Practical Implications
1. Allocate generously:
let promise = env::promise_yield_create(
"on_oracle_data",
b"{}",
100_000_000_000_000, // 100 TGas - be generous!
0,
0
);2. You can't recover from under-allocation:
- If callback runs out of gas, it fails
- You can't "top up" and retry with the same yield
- The yield is consumed (resolved) regardless of callback success
3. Timeout uses the same gas:
- Whether resumed or timed out, callback gets the same gas
- Timeout provides
PromiseResult::Failedinstead of data
4. Consider worst-case paths:
// Callback must handle both success AND failure within same gas budget
pub fn on_oracle_data(&mut self) {
match env::promise_result(0) {
PromiseResult::Successful(data) => {
// Parse and process - may be gas-intensive
}
PromiseResult::Failed => {
// Timeout handling - might also need gas for cleanup
}
}
}Constraints and Limitations
| Constraint | Description |
|---|---|
| Same account | Resume must come from the same account that created the yield |
| One-time | Each data_id can only be resolved once |
| Timeout | Yields expire after yield_timeout_length_in_blocks |
| Max payload | Limited by max_yield_payload_size config |
| No view calls | Cannot yield in view functions |
Advanced Pattern: Oracle Network
impl OracleContract {
// User requests data
pub fn request(&mut self, query: String) -> Promise {
let promise = env::promise_yield_create("callback", b"{}", gas, 0, 0);
let data_id = env::read_register(0);
// Store request
self.requests.insert(&data_id, &OracleRequest {
query,
requester: env::predecessor_account_id(),
created_at: env::block_height(),
});
promise
}
// Oracle operator submits response
pub fn submit_response(&mut self, data_id: Vec<u8>, response: String) {
// Verify caller is authorized oracle
require!(self.oracles.contains(&env::predecessor_account_id()));
// Resume the yield
let payload = serde_json::to_vec(&response).unwrap();
env::promise_yield_resume(&data_id, &payload);
}
// Callback processes response
pub fn callback(&mut self) {
match env::promise_result(0) {
PromiseResult::Successful(data) => {
let response: String = serde_json::from_slice(&data).unwrap();
// Deliver to requester
}
PromiseResult::Failed => {
// Timeout - refund requester
}
}
}
}Key Takeaways
DelegateAction (Meta-Transactions)
- Enables gasless user experience
- Relayer pays gas, user signs intent
- NEP-461 signature domain prevents reuse
- Access key permissions still enforced
max_block_heightprovides expiration
Promise Yield/Resume
- Enables waiting for off-chain data
- Gas is fixed at creation, not resumption
- Timeout ensures yields don't block forever
- Same account must create and resume
- Handle both success and timeout in callback