Gas keys

An access key with its own gas balance.

A gas key is an ordinary access key with a NEAR balance of its own. Gas for anything the key signs is paid from that balance, not from the account; attached deposits still come from the account. Gas refunds return to the key, deposit refunds to the account.

Two things follow. Any account can top the key up with TransferToGasKey, so an app can pay a user's gas without running a relayer: fund once, then stay out of the way. And each key carries 1 to 1,024 independent nonce lanes, so one key can send in parallel without nonce collisions.

NEAR added gas keys in NEP-611 (protocol version 85) so a transaction's fee is guaranteed before it executes; a prepaid, key-scoped balance does that and closes the drain-then-spam gap.

Gas keys ship in @fastnear/api 2.4.0+ — npm i @fastnear/api, or <script src="https://js.fastnear.com/near.js"></script> for the near global — with no extra package. The RPC you point at must report protocol_version 85 or later; check with near.queryProtocolVersion(). Mainnet and testnet both do at the time of writing. Every card on this page signs locally with sendTx({ signer, signerId }); the wallet path for adding, funding and draining a key is in the callout below.

What a gas key is
  • Two permissions, added with the ordinary AddKey: GasKeyFullAccess { balance, num_nonces }, or GasKeyFunctionCall { balance, num_nonces, receiver_id, method_names } scoped to one contract like a function-call key.
  • Balance starts at 0. AddKey cannot set one; fund it afterwards. A function-call gas key has no allowance (the view shows allowance: null): the balance is the allowance.
  • Lanes. num_nonces (1 to 1,024) independent nonce sequences. A send names its lane with nonceIndex; lanes never wait on each other, and one lane is sequential. The AddKey fee grows with the count.
  • Strings out. balance is yoctoNEAR as a decimal string. near.gasKeyInfoFromPermission turns a view_access_key permission into { balance, num_nonces, functionCall }, or null for a classical key.
Who can do what
  • Fund: anyone. TransferToGasKey { publicKey, deposit } may be sent by any account. The deposit leaves the sender; the transaction's receiver is the key's owner. The sponsor never needs the key.
  • Withdraw: the owner only. WithdrawFromGasKey { publicKey, amount } must be signed by the owning account and pays out to that account. A sponsor cannot pull a top-up back.
  • Delete: the owner, after draining. The ordinary DeleteKey. It refuses if the key holds more than 1 NEAR and burns whatever is left below that.
  • Sign: the key itself, on a lane. sendTx({ signer, signerId, nonceIndex }) detects the GasKey* permission and signs a TransactionV1. Gas from the key, deposits from the account. It cannot sign a NEP-366 delegate.
When to reach for it
  • Sponsor a user without a relayer. A NEP-366 relayer signs and pays every transaction, so it must be online for each one. A gas-key sponsor funds once and steps away until the balance runs low.
  • Many sends from one key. A classical key serializes on its single nonce. Four lanes let four workers send at once.
  • Not an allowance. A function-call key's allowance is the account's own NEAR, spent from the account. A gas key is a separate balance any account can refill.
  • Not for delegates. A gas key cannot sign a delegate action (near.signDelegate refuses it), and WithdrawFromGasKey is refused inside a delegate.
Before you start

Adding, funding and draining a gas key through a wallet works with @fastnear/wallet 2.5.0+ and a near-connect 0.14.1+ wallet whose manifest sets features.gasKeys — Meteor Wallet, verified on testnet on 2026-09-24. Wallets without the flag are refused rather than risk a downgraded key. Signing with a gas key (nonceIndex) is always local: near.signDelegate refuses a gas-key signer and no wallet holds the lane nonce, so every card on this page signs with sendTx({ signer, signerId }). Also, DeleteKey burns whatever balance the key still holds (and refuses above 1 NEAR): drain with WithdrawFromGasKey first. The last card below does both in one transaction.

Quickstarts

Add, fund, send, inspect, drain.

Five cards, straight from the gasKeys block of recipes.json (ESM), in lifecycle order. Read the first three as one story: the sponsored counter. A user account adds a gas key scoped to count.mike.testnet / increase — the contract the home-page demo already calls. A different account, the sponsor, funds it with TransferToGasKey. The user's client then calls increase signed by the gas key: the user's account balance is unchanged by the call, the key's balance drops by the gas burnt, and the gas refund lands back on the key. The sponsor is not on the path of that call and is only involved again when the balance needs topping up — and any account, not just the original sponsor, can do that.

Then read the third and fourth cards again as the lanes story. A key added with numNonces: 4 has four independent nonces. Promise.all over nonceIndex 0 through 3 sends four transactions at once; queryGasKeyNonces before and after shows each lane advanced by one. A classical key serializes every send on its single nonce. A gas key gives one signer many workers — the shape backends and agents want.

Add a full-access gas key

Signed by an existing full-access key like any AddKey, after gating on protocolVersion. The permission reads back as { GasKeyFullAccess: { balance: "0", num_nonces: 4 } }. For the sponsored counter, swap the action for near.actions.addLimitedAccessGasKey({ publicKey, numNonces, accountId: "count.mike.testnet", methodNames: ["increase"] }) — the balance still starts at "0", and there is no allowance to set.

import { actions, queryAccessKey, queryProtocolVersion, sendTx } from "@fastnear/api";
import { privateKeyFromRandom, signerFromPrivateKey } from "@fastnear/utils";

export async function addGasKey({ accountId, ownerSigner, numNonces = 4 }) {
  const protocolVersion = await queryProtocolVersion({ network: "testnet" });
  if (protocolVersion < 85) {
    throw new Error(`testnet protocol ${protocolVersion} does not support gas keys`);
  }

  // A gas key is an ordinary ed25519 key pair; only its permission differs.
  const gasKeyPrivateKey = privateKeyFromRandom("ed25519");
  const gasSigner = signerFromPrivateKey(gasKeyPrivateKey);

  await sendTx({
    signerId: accountId,
    signer: ownerSigner,
    receiverId: accountId,
    // numNonces = independent transaction lanes (1..1024); the AddKey fee grows with it.
    actions: [actions.addFullAccessGasKey({ publicKey: gasSigner.publicKey, numNonces })],
    waitUntil: "FINAL",
    network: "testnet",
  });

  const view = await queryAccessKey({
    accountId,
    publicKey: gasSigner.publicKey,
    blockId: "final",
    network: "testnet",
  });
  // view.result.permission -> { GasKeyFullAccess: { balance: "0", num_nonces: 4 } }
  return { gasKeyPrivateKey, publicKey: gasSigner.publicKey, permission: view.result.permission };
}
Fund it from any account

The sponsor's only transaction. signerId is the funder and receiverId is the key's owner; they may differ, and the deposit leaves the funder. The read-back goes through gasKeyInfoFromPermission, which returns null for a classical key; balance is a yoctoNEAR decimal string.

import { actions, gasKeyInfoFromPermission, queryAccessKey, sendTx } from "@fastnear/api";

export async function fundGasKey({ funderId, funderSigner, accountId, publicKey, amount = "0.05 NEAR" }) {
  await sendTx({
    signerId: funderId,
    signer: funderSigner,
    receiverId: accountId,
    actions: [actions.transferToGasKey({ publicKey, deposit: amount })],
    waitUntil: "FINAL",
    network: "testnet",
  });

  const view = await queryAccessKey({ accountId, publicKey, blockId: "final", network: "testnet" });
  const info = gasKeyInfoFromPermission(view.result.permission);
  if (!info) throw new Error(`${publicKey} is not a gas key on ${accountId}`);
  return info.balance; // yoctoNEAR decimal string
}
Send with the gas key

The user's transaction. signer is the gas key, signerId is the user, and nonceIndex picks the lane (default 0). sendTx sees the GasKey* permission and signs a TransactionV1: a 0x01 prefix, a GasKeyNonce { nonce, nonceIndex }, and a trailing NonceMode ("monotonic" by default, nonce > stored; "strict", exactly stored + 1). For the counter, receiverId is count.mike.testnet and methodName is increase. sendOnTwoLanes is the lanes story in two lines; widen [0, 1] to [0, 1, 2, 3] for a key added with numNonces: 4.

import { actions, queryGasKeyNonces, sendTx } from "@fastnear/api";

export async function sendWithGasKey({ accountId, gasSigner, receiverId, nonceIndex = 0 }) {
  const result = await sendTx({
    signerId: accountId,
    signer: gasSigner,
    receiverId,
    actions: [actions.functionCall({ methodName: "ping", args: {}, gas: "30 Tgas", deposit: "0" })],
    nonceIndex,
    waitUntil: "FINAL",
    network: "testnet",
  });

  const nonces = await queryGasKeyNonces({
    accountId,
    publicKey: gasSigner.publicKey,
    blockId: "final",
    network: "testnet",
  });
  return { txHash: result.transaction?.hash ?? null, nonces: nonces.result.nonces };
}

// Lanes are independent nonce sequences, so these do not serialize behind each other.
export const sendOnTwoLanes = (params) =>
  Promise.all([0, 1].map((nonceIndex) => sendWithGasKey({ ...params, nonceIndex })));
Inspect balance and lanes

Three reads at finality: view_access_key for balance, lane count, and function-call scope; view_gas_key_nonces for one nonce per lane (the key's own view_access_key nonce is always 0, and a classical key answers UNKNOWN_GAS_KEY); the key list to confirm the key is present. Run it before and after sendOnTwoLanes to watch lanes 0 and 1 each advance by one. queryAccessKeyList now paginates: pass limit and page with afterKey; more than 100 keys is refused unpaginated.

import {
  gasKeyInfoFromPermission,
  queryAccessKey,
  queryAccessKeyList,
  queryGasKeyNonces,
} from "@fastnear/api";

export async function inspectGasKey({ accountId, publicKey }) {
  const [direct, lanes, list] = await Promise.all([
    queryAccessKey({ accountId, publicKey, blockId: "final", network: "testnet" }),
    queryGasKeyNonces({ accountId, publicKey, blockId: "final", network: "testnet" }),
    queryAccessKeyList({ accountId, blockId: "final", network: "testnet" }),
  ]);
  const info = gasKeyInfoFromPermission(direct.result.permission);
  if (!info) throw new Error(`${publicKey} is not a gas key on ${accountId}`);
  return {
    balance: info.balance,
    numNonces: info.num_nonces,
    nonces: lanes.result.nonces,
    // Set only for GasKeyFunctionCall keys.
    receiverId: info.functionCall?.receiver_id ?? null,
    listed: list.result.keys.some((key) => key.public_key === publicKey),
  };
}
Drain, then delete

One batched transaction signed by the owning account — WithdrawFromGasKey cannot be signed by anyone else. The withdraw is skipped when the balance is already 0. DeleteKey refuses above 1 NEAR and burns anything below it, so the order inside the batch matters.

import { actions, gasKeyInfoFromPermission, queryAccessKey, sendTx } from "@fastnear/api";

export async function withdrawAndDeleteGasKey({ accountId, ownerSigner, publicKey }) {
  const view = await queryAccessKey({ accountId, publicKey, blockId: "final", network: "testnet" });
  const info = gasKeyInfoFromPermission(view.result.permission);
  if (!info) throw new Error(`${publicKey} is not a gas key on ${accountId}`);

  // DeleteKey fails above 1 NEAR and burns anything below it, so withdraw first.
  // WithdrawFromGasKey must be signed by the owning account itself.
  const drain = BigInt(info.balance) > 0n
    ? [actions.withdrawFromGasKey({ publicKey, amount: info.balance })]
    : [];
  await sendTx({
    signerId: accountId,
    signer: ownerSigner,
    receiverId: accountId,
    actions: [...drain, actions.deleteKey({ publicKey })],
    waitUntil: "FINAL",
    network: "testnet",
  });

  const after = await queryAccessKey({ accountId, publicKey, blockId: "final", network: "testnet" });
  if (!/UnknownAccessKey|does not exist/i.test(after.result.error ?? "")) {
    throw new Error("gas key still present");
  }
}
Safety rules

Gate on the protocol, read at finality, drain before you delete.

These rules come from the machine-readable catalog — agents and humans should apply the same ones.

Activation and key material
  • Check the selected RPC's active protocol_version and require 85 or later before adding or using a gas key; do not use node software versions as the activation signal.
  • Treat the gas key's private key like any signing secret. Keep recovery records public-only: network, account ID, public key.
  • The balance is the only spend limit on a gas key, and any account can raise it with TransferToGasKey. If the key must not sign arbitrary actions, scope it with GasKeyFunctionCall's receiver_id and method_names.
Reads and lifecycle
  • Read balances and lane nonces at finality (blockId: "final") after a waitUntil: "FINAL" send before asserting on them; the default query finality is optimistic.
  • Drain with WithdrawFromGasKey before DeleteKey. A gas refund that lands after your balance read is burnt on deletion; the loss is bounded by 1 NEAR because DeleteKey refuses above that.
  • Gas keys cannot sign NEP-366 delegate actions, and WithdrawFromGasKey is refused inside a delegate. Do not route gas-key traffic through a relayer.
Go deeper

AI agents: the gasKeys key in recipes.json carries all five quickstarts (gas-key-add, gas-key-fund, gas-key-send, gas-key-inspect, gas-key-withdraw-delete) plus the limits, permissionViews, rpc, builders, rules, and safety on this page. The protocol change is NEP-611. For a relayer-paid flow instead, see meta-transactions; the API lives in @fastnear/api on npm. Rate-limited? Free trial credits are at dashboard.fastnear.com.