Near Learn

NEP-366: Meta transactions

NEP-366 meta transactions on NEAR: signed delegate actions, relayers that pay the gas, nonces and expiry, and how it compares to ERC-2771 and ERC-4337.

Advanced6 min read3-question check

NEP-366 is a protocol feature that lets someone else pay for your transaction. The user signs a DelegateAction — “I, alice.near, want these actions executed on game.near” — without submitting it. A relayer wraps that signed object in an ordinary transaction, signs it with its own key and pays the gas.

This is how apps onboard users who hold no NEAR: the user still authorises every action with their own key, but the app’s relayer covers the fees.

What the user signs#

The shape of a delegate action (as defined in nearcore primitives)
Rust
pub struct DelegateAction {
    /// the user — the account the actions run "as"
    pub sender_id: AccountId,
    /// the single account all actions are sent to
    pub receiver_id: AccountId,
    /// the actions (no nested DelegateAction allowed)
    pub actions: Vec<NonDelegateAction>,
    /// must be greater than the current nonce of the user's access key
    pub nonce: Nonce,
    /// the action is invalid at or after this block height
    pub max_block_height: BlockHeight,
    /// the user's key that produced the signature
    pub public_key: PublicKey,
}

pub struct SignedDelegateAction {
    pub delegate_action: DelegateAction,
    pub signature: Signature,
}

The signature covers the sha256 of the Borsh-serialised DelegateAction with a 4-byte prefix tag from the on-chain range starting at 2³⁰ (the value 2³⁰ + 366). The tag guarantees a delegate-action signature can never be replayed as a normal transaction or as an off-chain message (NEP-413 uses the off-chain range starting at 2³¹).

The flow#

  1. The app builds a DelegateAction for the user (e.g. a FunctionCall to game.near), picks nonce = access key nonce + 1 and a max_block_height a little in the future, and the user’s wallet signs it.
  2. The app sends the SignedDelegateAction to its relayer (usually over HTTP).
  3. The relayer submits a transaction whose receiver_id is the user’s account (sender_id) and whose single action is the signed delegate action. The relayer signs and pays for this transaction.
  4. The protocol checks the user’s signature, nonce and expiry, then creates a receipt to receiver_id that looks as if the user sent it: predecessor_id is the user, while signer_id is the relayer.
  5. Gas refunds go to the relayer; deposit refunds go back to the user (if the user’s account exists).
ERC-2771: the contract must trust a forwarder. NEP-366: the contract does not change
Solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol";

contract Game is ERC2771Context {
    mapping(address => uint256) public score;

    constructor(address trustedForwarder) ERC2771Context(trustedForwarder) {}

    function play() external {
        // msg.sender is the forwarder; the real user is appended to calldata
        score[_msgSender()] += 1;
    }
}
Rust
use near_sdk::store::LookupMap;
use near_sdk::{env, near, AccountId, PanicOnDefault};

#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct Game {
    score: LookupMap<AccountId, u64>,
}

#[near]
impl Game {
    pub fn play(&mut self) {
        // in a meta transaction the predecessor is still the user —
        // nothing special to support. (signer_account_id() would be the relayer!)
        let player = env::predecessor_account_id();
        let s = self.score.get(&player).copied().unwrap_or(0);
        self.score.insert(player, s + 1);
    }
}
Coming from EVM

ERC-2771 needs a trusted forwarder contract and _msgSender() everywhere. ERC-4337 needs smart-contract wallets, an EntryPoint, bundlers and paymasters. NEP-366 is built into the protocol and works with any existing contract and any NEAR account.

Check yourself

3 questions · progress saved in this browser

  1. 1.In a meta transaction, who is env::predecessor_account_id() inside the target contract?
  2. 2.What is the receiver_id of the transaction the relayer submits?
  3. 3.What stops a relayer from submitting the same signed delegate action twice?