Skip to content
by Unity Nodes
docs.near.orgweb3 apps › tutorials › account validation

Validating Recipient Accounts

Updated 01-11-2026

Best practices for validating recipient account IDs before sending transactions to prevent token loss.

When building an application that lets users send tokens, NFTs, or any other assets to another NEAR account, validating the recipient before broadcasting the transaction is one of the most impactful UX decisions you can make. A mistyped account, an unfunded implicit address, or a non-existent named account is a common cause of irreversible token loss.

This guide describes the validation strategy that your application can implement to catch the most common failure modes, and the UX patterns that help users avoid mistakes.


What can go wrong?

There are four common failure modes when an end user enters a recipient account:

  1. Invalid format — the input is not a valid NEAR account ID (wrong characters, length, etc.).
  2. Unfunded implicit / 0x account — the address corresponds to a key pair, but no one has activated the account by funding it. Funds sent here are reachable only by whoever holds the private key.
  3. Unclaimed universal (0u…) account — funds sent to a 0u address that has never run its UniversalStateInit sit in an uninitialized account. Only the holders of the keys committed in that address's state init can control the funds, and someone has to know the exact state init to initialize it. Unlike an implicit account, the address alone doesn't tell you which key controls it — if no one knows the state init, the balance is effectively stranded.
  4. Typo of an existing account — the entered value is a valid, existing account, but not the one the user intended. This is the hardest case to catch and benefits the most from confirmation UX.

For background on each address type (.near, .tg, .sweat, implicit, 0x, deterministic, universal), see the Address (Account ID) reference.

[!TIP]

If you transfer tokens to a non-existing named account the transfer will simply fail and the assets will be returned

[!CAUTION]

If you delete an account and the beneficiary account does not exist, all the NEAR funds will be lost


Validation strategy

Apply these checks in order. Each step is cheap and rules out a specific failure mode. The strategy is language-agnostic — the examples below show JavaScript (for frontends) and Rust (for backends, CLIs, and indexers).

1. Validate the format

Reject inputs that cannot be valid NEAR account IDs before doing any network call. A NEAR account ID must:

  • Be 2 to 64 characters long
  • Contain only lowercase letters (a-z), digits (0-9), and the separators ., -, _
  • Not start or end with a separator, and not have two separators in a row

Ethereum-like (0x...) and deterministic (0s...) addresses are 42 characters (the 0x/0s prefix plus 40 lowercase hex characters). Implicit accounts are 64 lowercase hex characters. Universal (0u...) addresses are 54 characters: the 0u prefix plus 52 lowercase Crockford base32 characters (0-9 and a-z excluding i, l, o, u).

[tab] 🌐 JavaScript

const NEAR_ACCOUNT_REGEX = /^(([a-z\d]+[\-_])*[a-z\d]+\.)*([a-z\d]+[\-_])*[a-z\d]+$/;

export function isValidAccountIdFormat(accountId) {
  if (typeof accountId !== "string") return false;
  if (accountId.length < 2 || accountId.length > 64) return false;
  return NEAR_ACCOUNT_REGEX.test(accountId);
}

[tab] 🦀 Rust

The near-account-id crate already implements the protocol-level format rules, so you can rely on AccountId::from_str instead of writing the regex yourself:

use near_account_id::AccountId;
use std::str::FromStr;

pub fn is_valid_account_id_format(input: &str) -> bool {
    AccountId::from_str(input).is_ok()
}

2. Classify the address type

Once the format is valid, classify the account so you can apply the right rules:

[tab] 🌐 JavaScript

export function classifyAccountId(accountId) {
  if (/^0x[0-9a-f]{40}$/.test(accountId)) return "ethereum";
  if (/^0s[0-9a-f]{40}$/.test(accountId)) return "deterministic";
  if (/^0u[0-9a-hj-km-np-tv-z]{52}$/.test(accountId)) return "universal";
  if (/^[0-9a-f]{64}$/.test(accountId)) return "implicit";
  if (accountId.endsWith(".near") || accountId.endsWith(".testnet")) return "named-tla";
  return "named";
}

[tab] 🦀 Rust

pub enum AccountKind { Ethereum, Deterministic, Universal, Implicit, NamedTla, Named }

pub fn classify(account_id: &str) -> AccountKind {
    let is_hex40 = |s: &str| s.len() == 40 && s.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase());
    let is_crockford52 = |s: &str| {
        s.len() == 52 && s.chars().all(|c| c.is_ascii_digit() || matches!(c, 'a'..='z') && !matches!(c, 'i' | 'l' | 'o' | 'u'))
    };

    if let Some(rest) = account_id.strip_prefix("0x") { if is_hex40(rest) { return AccountKind::Ethereum; } }
    if let Some(rest) = account_id.strip_prefix("0s") { if is_hex40(rest) { return AccountKind::Deterministic; } }
    if let Some(rest) = account_id.strip_prefix("0u") { if is_crockford52(rest) { return AccountKind::Universal; } }
    if account_id.len() == 64 && is_hex40(&account_id[..40]) && is_hex40(&account_id[24..]) {
        return AccountKind::Implicit;
    }
    if account_id.ends_with(".near") || account_id.ends_with(".testnet") { return AccountKind::NamedTla; }
    AccountKind::Named
}

This lets your UI explain to the user what kind of account they are about to send to (e.g. "This looks like an Ethereum-style address. Make sure the recipient controls the private key.").

3. Check whether the account exists on chain

Call the RPC view_account method. If the account exists you will receive its amount, code_hash, and storage_usage; if it does not, the RPC returns an error code such as AccountDoesNotExist or UNKNOWN_ACCOUNT. For a universal account, also check the response's state field — its presence ("uninitialized") is how you distinguish a claimed account from one that only exists because someone sent it a transfer.

[tab] 🌐 near-api-js

import { JsonRpcProvider } from "near-api-js";

const provider = new JsonRpcProvider({ url: "https://rpc.mainnet.fastnear.com" });

export async function accountExists(accountId) {
  try {
    await provider.query({
      request_type: "view_account",
      finality: "final",
      account_id: accountId,
    });
    return true;
  } catch (err) {
    if (err?.type === "AccountDoesNotExist") return false;
    throw err;
  }
}

[tab] 🦀 near-jsonrpc-client

use near_jsonrpc_client::{methods, JsonRpcClient};
use near_jsonrpc_primitives::types::query::{QueryResponseKind, RpcQueryError};
use near_primitives::types::{BlockReference, Finality};
use near_primitives::views::QueryRequest;

pub async fn account_exists(
    client: &JsonRpcClient,
    account_id: near_account_id::AccountId,
) -> anyhow::Result<bool> {
    let request = methods::query::RpcQueryRequest {
        block_reference: BlockReference::Finality(Finality::Final),
        request: QueryRequest::ViewAccount { account_id },
    };

    match client.call(request).await {
        Ok(resp) => Ok(matches!(resp.kind, QueryResponseKind::ViewAccount(_))),
        Err(near_jsonrpc_client::errors::JsonRpcError::ServerError(
            near_jsonrpc_client::errors::JsonRpcServerError::HandlerError(
                RpcQueryError::UnknownAccount { .. },
            ),
        )) => Ok(false),
        Err(err) => Err(err.into()),
    }
}

[tab] 🦀 near-api-rs

use near_api::{Account, NetworkConfig};

pub async fn account_exists(account_id: &str) -> anyhow::Result<bool> {
    let network = NetworkConfig::mainnet();
    match Account(account_id.parse()?).view().fetch_from(&network).await {
        Ok(_) => Ok(true),
        Err(err) if err.to_string().contains("UnknownAccount") => Ok(false),
        Err(err) => Err(err.into()),
    }
}

[tab] cURL

curl -s https://rpc.mainnet.fastnear.com \
  -H 'Content-Type: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": "dontcare",
    "method": "query",
    "params": {
      "request_type": "view_account",
      "finality": "final",
      "account_id": "example.near"
    }
  }'

4. Apply recipient-class rules

Combine the classification and the existence check into a decision:

Type Exists on chain? Recommended UX
Named (alice.near) ✅ Continue, optionally show resolved on-chain data
Named (alicee.near) ❌ Block the send, show "this account does not exist".
Implicit (64 hex) ✅ Continue
Implicit (64 hex) ❌ Warn: "the account does not exist yet — the recipient must hold the private key to claim these funds." Require explicit confirmation
Ethereum-like (0x…) ✅ Continue, optionally label as MetaMask-style
Ethereum-like (0x…) ❌ Same warning as implicit — require explicit confirmation
Deterministic (0s…) ❌ Block: deterministic accounts are not transfer targets unless explicitly known
Universal (0u…) ✅ and initialized Continue
Universal (0u…) ✅ but uninitialized Warn strongly: the address holds a balance but hasn't been initialized yet — it's only usable once someone submits the exact UniversalStateInit it commits to, and only the keys committed there will control it. Check state in the view_account response, not just existence
Universal (0u…) ❌ Warn: sending here creates the account uninitialized (see above) — require explicit confirmation that the recipient knows the state init this address commits to and holds one of its keys, since (unlike implicit accounts) the address alone doesn't identify a key pair

5. Allowlist supported assets and add confidence signals

For higher-value transfers (or as a default for new recipients), layer an asset allowlist and other confidence signals on top of the existence check:

  • Verified asset allowlist: map every asset your app supports to its exact token-contract account ID. Never infer that a token is legitimate from a symbol, name, or an account ID that looks similar. For example, the USD₮ contract on NEAR is usdt.tether-token.near, not usdt.near. You can use Rhea Finance's on-chain get_whitelisted_tokens result from v2.ref-finance.near as an input to your list. It is Rhea's DEX trading allowlist, not a universal security guarantee, so review and own the list you present to users.
  • First-time recipient confirmation: if the user has never sent to this account before, require a second confirmation step or suggest a small test transaction before the real transfer.
  • Token-specific registration: for fungible token transfers, verify the recipient is registered with the FT contract (storage_balance_of). If not, batch a storage_deposit action — otherwise the transfer will fail.

The allowlist helper uses the decoded result of the contract query.

const RHEA_DEX = "v2.ref-finance.near";

async function getRheaWhitelistedTokens(provider) {
  const response = await provider.query({
    request_type: "call_function",
    finality: "final",
    account_id: RHEA_DEX,
    method_name: "get_whitelisted_tokens",
    args_base64: "",
  });

  return JSON.parse(new TextDecoder().decode(Uint8Array.from(response.result)));
}

// Pseudo-code: combine an asset allowlist with recipient checks.
async function evaluateRecipient(accountId, tokenContractId, amount, provider) {
  if (!isValidAccountIdFormat(accountId)) return { status: "invalid" };

  const rheaWhitelistedTokens = new Set(await getRheaWhitelistedTokens(provider));
  if (!rheaWhitelistedTokens.has(tokenContractId)) {
    return { status: "block", reason: "unsupported-token" };
  }

  const type = classifyAccountId(accountId);
  const exists = await accountExists(accountId);

  if (!exists && type === "named") return { status: "block", reason: "account-does-not-exist" };
  if (!exists) return { status: "confirm", reason: "unfunded-account", type };

  const activity = await fetchAccountActivity(accountId).catch(() => null);
  if (activity?.txCount === 0 || amount > activity?.totalInflow) {
    return { status: "confirm", reason: "low-activity", activity };
  }
  return { status: "ok" };
}

UI recommendations

Validation is only useful if the user can act on it. A few UX patterns that work well:

  • Show the resolved account inline as the user types — name, balance preview, and "exists" indicator. This catches typos before the user clicks Send.
  • Distinguish blocking errors from warnings. Invalid format and non-existent named accounts should disable the Send button. Unfunded implicit / 0x accounts and low-activity accounts should require an extra confirmation, not a block.
  • Always show the full account ID in the confirmation step — not just a truncated abc...xyz. Many losses come from look-alike characters in the middle of a long hex string.
  • Suggest a test transaction when the recipient looks suspicious. Sending 0.01 NEAR first costs almost nothing and surfaces problems before larger amounts are at risk.

See also