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#
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#
- The app builds a
DelegateActionfor the user (e.g. aFunctionCalltogame.near), picksnonce= access key nonce + 1 and amax_block_heighta little in the future, and the user’s wallet signs it. - The app sends the
SignedDelegateActionto its relayer (usually over HTTP). - The relayer submits a transaction whose
receiver_idis the user’s account (sender_id) and whose single action is the signed delegate action. The relayer signs and pays for this transaction. - The protocol checks the user’s signature, nonce and expiry, then creates a receipt to
receiver_idthat looks as if the user sent it:predecessor_idis the user, whilesigner_idis the relayer. - Gas refunds go to the relayer; deposit refunds go back to the user (if the user’s account exists).
// 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;
}
}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);
}
}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