Skip to main content
BeginnerEst6 minsdk-core15.4.1Project build checked

Send an ESDT

Send a fungible ESDT token using sdk-core's TransfersController and the Token / TokenTransfer classes, directly against DevnetEntrypoint. Sibling recipe to Send EGLD to an address; same Controller pattern, this time for a custom token instead of the native one.

If instead you are building a browser dApp where the end user's own wallet should sign, see Sign and send a transaction. This recipe is for when your own code holds the private key.

Prerequisites

  • Node.js >= 20.19.0.
  • A devnet wallet with some devnet EGLD (for gas), see Send EGLD to an address's Prerequisites for how to generate and fund a throwaway one.
  • A devnet ESDT token identifier you (or your test wallet) already hold a balance of, plus its exact decimal count, see Pitfall 1.

Install

mkdir send-esdt
cd send-esdt
# Create the project files shown on this page.
npm install
npm run build
npm start -- ./wallet.pem erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th TEST-abcdef 2.5 6
Complete project files omitted from the main walkthrough
package.json
{
"name": "cookbook-recipe-send-esdt",
"version": "1.0.0",
"private": true,
"description": "Cookbook recipe — send an ESDT (fungible token) to an address using sdk-core's TransfersController and the Token/TokenTransfer classes, directly against DevnetEntrypoint.",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit --strict"
},
"dependencies": {
"@multiversx/sdk-core": "15.4.1",
"axios": "1.18.1",
"bignumber.js": "9.3.1",
"protobufjs": "7.6.5"
},
"devDependencies": {
"@types/node": "20.19.43",
"typescript": "5.9.3"
},
"engines": {
"node": ">=20.19.0"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"module": "commonjs",
"moduleResolution": "node",
"resolveJsonModule": true,
"declaration": false,
"sourceMap": false,
"outDir": "dist",
"types": ["node"]
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
src/account.ts
// src/account.ts — load a devnet Account from a PEM file and prime its
// local nonce from the network. Framework-agnostic sdk-core, no sdk-dapp
// involved — this is the "your own code holds the keys" pattern (a script,
// a bot, a backend service), not a browser wallet-connected flow.
//
// Fetch the account nonce from the network before sending, then increment
// locally with getNonceThenIncrement(). This helper implements the fetch half;
// see the manage-nonces recipe for the increment half in depth.

import { Account } from '@multiversx/sdk-core';
import type { DevnetEntrypoint } from '@multiversx/sdk-core';

/**
* Loads an Account from a PEM file and sets its local `nonce` from the
* network's current value — the "fetch" half of the fetch-then-increment
* pattern (see the manage-nonces recipe for the "increment" half across a
* batch of sends).
*
* `entrypoint.recallAccountNonce(address)` is a thin convenience wrapper
* around `networkProvider.getAccount(address).nonce`
* (node_modules/@multiversx/sdk-core/out/entrypoints/entrypoints.js) — used
* here instead of constructing a network provider by hand.
*/
export async function loadDevnetAccount(
entrypoint: DevnetEntrypoint,
pemPath: string,
): Promise<Account> {
const account = await Account.newFromPem(pemPath);
account.nonce = await entrypoint.recallAccountNonce(account.address);
return account;
}
src/index.ts
// src/index.ts — CLI entry point. Wires together account.ts, amount.ts,
// and sendEsdt.ts into a single runnable command.
//
// Usage:
// npm run build && npm start -- <pemPath> <receiverBech32> <tokenIdentifier> <amount> <numDecimals>
//
// Example (a devnet token with 6 decimals, sending 2.5 units):
// npm start -- ./wallet.pem erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th TEST-abcdef 2.5 6
//
// `numDecimals` is required because guessing a token's precision is
// unsafe. See the prerequisites above for a devnet PEM wallet and test token.

import { DevnetEntrypoint } from '@multiversx/sdk-core';
import { loadDevnetAccount } from './account';
import { toSmallestDenomination } from './amount';
import { sendEsdt } from './sendEsdt';

async function main(): Promise<void> {
const [pemPath, receiver, tokenIdentifier, amountArg, numDecimalsArg] = process.argv.slice(2);

if (!pemPath || !receiver || !tokenIdentifier || !amountArg || !numDecimalsArg) {
console.error(
'Usage: npm start -- <pemPath> <receiverBech32> <tokenIdentifier> <amount> <numDecimals>',
);
process.exitCode = 1;
return;
}

const numDecimals = Number(numDecimalsArg);
if (!Number.isInteger(numDecimals) || numDecimals < 0) {
console.error(`numDecimals must be a non-negative integer, got "${numDecimalsArg}".`);
process.exitCode = 1;
return;
}

// DevnetEntrypoint() with no options targets the devnet API and chain "D".
const entrypoint = new DevnetEntrypoint({ clientName: 'mvx-cookbook' });

const sender = await loadDevnetAccount(entrypoint, pemPath);
console.log(`Sender: ${sender.address.toBech32()} (nonce ${sender.nonce})`);
console.log(`Receiver: ${receiver}`);
console.log(`Token: ${tokenIdentifier}`);
console.log(`Amount: ${amountArg} (${numDecimals} decimals)`);

const { txHash } = await sendEsdt(entrypoint, sender, {
receiver,
tokenIdentifier,
amountInSmallestDenomination: toSmallestDenomination(amountArg, numDecimals),
});

console.log(`\nSent. Transaction hash: ${txHash}`);
console.log(`Explorer: https://devnet-explorer.multiversx.com/transactions/${txHash}`);

const outcome = await entrypoint.awaitCompletedTransaction(txHash);
console.log(`Status: ${outcome.status.status} (successful: ${outcome.status.isSuccessful()})`);
}

main().catch((err: unknown) => {
console.error(err);
process.exitCode = 1;
});

(2.5 units of a 6-decimal token, adjust the last argument to match the token you are actually sending.)

Converting the amount

src/amount.ts
// src/amount.ts — decimal token-amount string <-> smallest-denomination
// bigint, for a token with an arbitrary (not fixed-18) number of decimals.
//
// Unlike EGLD (always 18 decimals), an ESDT's decimal count is per-token —
// set at issuance (the `numDecimals` argument) and readable from the token's
// properties on-chain. This recipe takes it as an explicit parameter rather
// than assuming 18, since assuming EGLD's decimals for an arbitrary ESDT
// would silently send the wrong amount by orders of magnitude.

import BigNumber from 'bignumber.js';

/**
* Converts a decimal token amount (e.g. "2.5") to the smallest-denomination
* bigint the network expects, given the token's own decimal count.
*
* Throws if the input has more precision than `numDecimals` supports.
*/
export function toSmallestDenomination(amount: string, numDecimals: number): bigint {
const value = new BigNumber(amount).multipliedBy(new BigNumber(10).pow(numDecimals));
if (!value.isFinite() || value.isNegative()) {
throw new Error(`"${amount}" is not a valid non-negative token amount.`);
}
if (!value.isInteger()) {
throw new Error(
`"${amount}" has more precision than this token supports (${numDecimals} decimals).`,
);
}
return BigInt(value.toFixed(0));
}

Sending

src/sendEsdt.ts
// src/sendEsdt.ts — the actual subject of this recipe: sending a fungible
// ESDT via sdk-core's TransfersController and the Token/TokenTransfer
// classes.
//
// Sibling recipe to "Send EGLD to an address" — same Controller pattern,
// same "your own code holds the keys" scope (see that recipe for the
// contrast with the browser + connected-wallet flow). The only structural
// difference is which factory method builds the transaction and what
// `resources` input shape it takes.
//
// One easy-to-miss, source-verified naming gotcha this recipe deliberately
// gets right: the CONTROLLER method is
// `createTransactionForEsdtTokenTransfer` (lowercase "sdt"), while the
// underlying FACTORY method it wraps is
// `createTransactionForESDTTokenTransfer` (uppercase "ESDT") — confirmed
// from node_modules/@multiversx/sdk-core/out/transfers/transfersControllers.d.ts
// vs .../transferTransactionsFactory.d.ts. Copying the Factory's casing
// onto a Controller call (or vice versa) is a real `tsc --strict` error,
// not a style nit — TypeScript's property-name matching is exact.

import { Address, Token, TokenTransfer } from '@multiversx/sdk-core';
import type { Account, DevnetEntrypoint } from '@multiversx/sdk-core';

export interface SendEsdtInput {
/** Bech32 address of the recipient. */
receiver: string;
/** Token identifier, e.g. "TICKER-abcdef" (fungible — no nonce). */
tokenIdentifier: string;
/** Amount in the token's own smallest denomination — see src/amount.ts. */
amountInSmallestDenomination: bigint;
}

export interface SendEsdtOutput {
txHash: string;
}

/**
* Builds, signs (via TransfersController), and sends a single fungible ESDT
* transfer. `sender.nonce` must already reflect the network's current
* nonce — see src/account.ts's loadDevnetAccount.
*/
export async function sendEsdt(
entrypoint: DevnetEntrypoint,
sender: Account,
input: SendEsdtInput,
): Promise<SendEsdtOutput> {
const controller = entrypoint.createTransfersController();

const token = new Token({ identifier: input.tokenIdentifier });
const transfer = new TokenTransfer({ token, amount: input.amountInSmallestDenomination });

const transaction = await controller.createTransactionForEsdtTokenTransfer(
sender,
sender.getNonceThenIncrement(),
{
receiver: Address.newFromBech32(input.receiver),
tokenTransfers: [transfer],
},
);

const txHash = await entrypoint.sendTransaction(transaction);
return { txHash };
}

Run it

npm start -- ./wallet.pem <receiverBech32> <tokenIdentifier> <amount> <numDecimals>

Expected output:

Sender:   erd1... (nonce 42)
Receiver: erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th
Token: TEST-abcdef
Amount: 2.5 (6 decimals)

Sent. Transaction hash: <64-char hex hash>
Explorer: https://devnet-explorer.multiversx.com/transactions/<hash>
Status: success (successful: true)

How it works

Token + TokenTransfer describe what to send; TransfersController builds and signs the transaction. new Token({ identifier }) for a fungible token needs no nonce field (that is only for NFTs/SFTs). controller.createTransactionForEsdtTokenTransfer(sender, nonce, { receiver, tokenTransfers: [transfer] }) builds the ESDT payload, computes gas, sets the nonce, and signs, the same Controller pattern as Send EGLD to an address.

Gas for an ESDT transfer is higher than a plain EGLD one, computed for you. The factory underneath adds gasLimitESDTTransfer (200,000) plus a fixed 100,000 on top of the base data-movement gas. Verified against a real devnet request: sending 2.5 units of a 6-decimal token produced a payload ESDTTransfer@544553542d616263646566@2625a0 (decodes to ESDTTransfer + hex("TEST-abcdef") + hex(2,500,000)) with gasLimit: 413000, exactly (50,000 + 1,500 x 42 bytes) + 200,000 + 100,000.

Pitfalls

Pitfall 1: guessing a token's decimal count is a real footgun

EGLD is always 18 decimals; an ESDT's decimal count is chosen by whoever issued it and varies token to token, 6, 8, and 18 are all common. Sending amount * 10^18 to a 6-decimal token would be off by 10^12. This recipe requires numDecimals as an explicit argument rather than defaulting it, look the real value up before running this against a token you do not already know the decimals for.

Pitfall 2: the Controller method's casing does not match the Factory it wraps

It is createTransactionForEsdtTokenTransfer (lowercase "sdt") on TransfersController, but createTransactionForESDTTokenTransfer (uppercase "ESDT") on the underlying TransferTransactionsFactory, confirmed from both .d.ts files directly. Using the wrong casing for the pattern you are using is a tsc --strict error, not a lint nit.

Pitfall 3: this sends a single fungible token in one transaction

For NFTs or SFTs, Token needs a non-zero nonce; for more than one token transfer in the same transaction, pass multiple entries in tokenTransfers (see Multi-token transfer in one tx).

See also