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

Multi-token transfer in one tx

Send native EGLD plus two (or more) ESDT/NFT/SFT tokens in a single transaction, via sdk-core's TransfersController.createTransactionForTransfer.

Sibling recipe to Send an ESDT (one fungible token) and Send EGLD to an address (native only), this one combines native EGLD with 2+ token transfers in the exact same call.

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 a throwaway one.

Install

mkdir multi-token-transfer
cd multi-token-transfer
# Create the project files shown on this page.
npm install
npm run build
npm start -- ./wallet.pem erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th
Complete project files omitted from the main walkthrough
package.json
{
"name": "cookbook-recipe-multi-token-transfer",
"version": "1.0.0",
"private": true,
"description": "Cookbook recipe — send EGLD plus two or more ESDT/NFT/SFT tokens in a single transaction using sdk-core's TransfersController.createTransactionForTransfer, 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/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, which always has 18 decimals, an ESDT's decimal count is
// set per token at issuance and is 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));
}
src/index.ts
// src/index.ts — CLI entry point. Sends a small native EGLD amount plus
// two illustrative token transfers (one fungible, one NFT-shaped) in a
// single transaction.
//
// Usage:
// npm run build && npm start -- <pemPath> <receiverBech32>
//
// Example:
// npm start -- ./wallet.pem erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th
//
// The token identifiers below (TEST-abcdef, TESTNFT-abcdef) are
// illustrative placeholders — same convention as the `send-esdt` recipe's
// TEST-abcdef example. This recipe proves the multi-transfer payload is
// well-formed; its offline verification does not require the sender to actually
// hold these tokens.

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

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

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

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('Sending in ONE transaction:');
console.log(' - 0.001 EGLD (native)');
console.log(' - 2.5 TEST-abcdef (fungible, 6 decimals)');
console.log(' - 1 TESTNFT-abcdef-01 (NFT, nonce 1)');

const { txHash } = await sendMultiTokenTransfer(entrypoint, sender, {
receiver,
nativeAmountInSmallestDenomination: toSmallestDenomination('0.001', 18),
tokenTransfers: [
{ identifier: 'TEST-abcdef', nonce: 0n, amount: toSmallestDenomination('2.5', 6) },
{ identifier: 'TESTNFT-abcdef', nonce: 1n, amount: 1n },
],
});

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

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

TEST-abcdef and TESTNFT-abcdef are illustrative placeholder identifiers, same convention as Send an ESDT's TEST-abcdef example.

Sending

src/multiTransfer.ts
// src/multiTransfer.ts — the actual subject of this recipe: sending
// native EGLD plus two (or more) ESDT/NFT/SFT tokens in a SINGLE
// transaction, via `TransfersController.createTransactionForTransfer`.
//
// Confirmed from node_modules/@multiversx/sdk-core/out/transfers/resources.d.ts:
//
// export declare type CreateTransferTransactionInput = {
// receiver: Address;
// nativeAmount?: bigint;
// tokenTransfers?: TokenTransfer[];
// data?: Uint8Array;
// };
//
// Unlike the ESDT-transfer casing trap this Cookbook's `send-esdt` recipe
// flags (`createTransactionForEsdtTokenTransfer` vs
// `createTransactionForESDTTokenTransfer`), `createTransactionForTransfer`
// is spelled identically on both `TransfersController` and
// `TransferTransactionsFactory` — no casing trap here, confirmed by
// reading both .d.ts files.
//
// Sibling recipe to `send-esdt` (a single fungible token) and `send-egld`
// (native only) — this one combines native EGLD with 2+ token transfers
// in the exact same call.

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

export interface TokenTransferInput {
/** Token identifier, e.g. "TICKER-abcdef" (fungible) or "TICKER-abcdef-0a" broken apart into identifier + nonce. */
identifier: string;
/** 0 for a fungible token; > 0 for an NFT/SFT. */
nonce: bigint;
/** Amount in the token's own smallest denomination. */
amount: bigint;
}

export interface MultiTransferInput {
receiver: string;
/** Optional native EGLD to send alongside the token transfers, in smallest denomination. */
nativeAmountInSmallestDenomination?: bigint;
tokenTransfers: TokenTransferInput[];
}

export interface MultiTransferOutput {
txHash: string;
}

/**
* Sends native EGLD (optional) plus 2+ token transfers in a single
* transaction. `sender.nonce` must already reflect the network's current
* nonce — see src/account.ts's loadDevnetAccount.
*/
export async function sendMultiTokenTransfer(
entrypoint: DevnetEntrypoint,
sender: Account,
input: MultiTransferInput,
): Promise<MultiTransferOutput> {
const controller = entrypoint.createTransfersController();

const tokenTransfers = input.tokenTransfers.map(
(t) =>
new TokenTransfer({
token: new Token({ identifier: t.identifier, nonce: t.nonce }),
amount: t.amount,
}),
);

const transaction = await controller.createTransactionForTransfer(
sender,
sender.getNonceThenIncrement(),
{
receiver: Address.newFromBech32(input.receiver),
...(input.nativeAmountInSmallestDenomination !== undefined
? { nativeAmount: input.nativeAmountInSmallestDenomination }
: {}),
tokenTransfers,
},
);

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

Run it

npm start -- <pemPath> <receiverBech32>

Expected output:

Sender:   erd1... (nonce 0)
Receiver: erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th
Sending in ONE transaction:
- 0.001 EGLD (native)
- 2.5 TEST-abcdef (fungible, 6 decimals)
- 1 TESTNFT-abcdef-01 (NFT, nonce 1)

Sent. Transaction hash: <64-char hex hash>

How it works

createTransactionForTransfer is spelled identically on both API levels, confirmed from resources.d.ts and both TransfersController/TransferTransactionsFactory .d.ts files. Unlike the ESDT-transfer casing trap that send-esdt documents, there is no Controller-vs-Factory naming mismatch here.

Two genuinely surprising, hand-verified facts about the transaction this produces:

  1. The transaction's outer receiver is the SENDER'S OWN address, not the real destination. A request built for receiver erd1qyu5... logged "receiver":"erd1ayggn...", the sender's own address. The real destination is instead the first argument inside data, hex-encoded, decoding it back to bech32 gives exactly the intended receiver. If you inspect a built transaction's .receiver or .value directly expecting the real recipient or EGLD amount, you will be looking at the wrong fields for this transaction type.
  2. Combining nativeAmount with tokenTransfers folds the EGLD into a THIRD synthetic token-transfer entry, using the special identifier EGLD-000000, not carried in value (which is "0" here). 2 real token transfers plus 1 native amount produced a decoded data field starting MultiESDTNFTTransfer@<addressHex>@03@..., count 03, not 02.

The full decoded payload, field by field (from a real run of this recipe):

MultiESDTNFTTransfer
@0139472eff6886771a982f3083da5d421f24c29181e63888228dc81ca60d69e1 (destination address)
@03 (3 transfers: 2 real tokens + 1 synthetic EGLD)
@544553542d616263646566 ("TEST-abcdef")
@ (nonce 0 — fungible, encoded as an EMPTY argument)
@2625a0 (2,500,000 = 2.5 units at 6 decimals)
@544553544e46542d616263646566 ("TESTNFT-abcdef")
@01 (nonce 1 — NFT)
@01 (amount 1)
@45474c442d303030303030 ("EGLD-000000" — the synthetic native-EGLD entry)
@ (nonce 0, empty again)
@038d7ea4c68000 (1,000,000,000,000,000 = 0.001 EGLD)

2625a0 = 2,500,000 matches the same hex send-esdt independently verified for "2.5 units of a 6-decimal token", confirming consistent encoding across both recipes.

Pitfalls

Pitfall 1: do not read tx.receiver or tx.value expecting the real destination/amount

Both point at the sender's own address / zero, by protocol design, for this transaction shape. The real information is encoded in tx.data.

Pitfall 2: a zero nonce encodes as an empty argument, not a literal 00 byte

This recipe's own decoded output shows an empty string between the two @ separators for both zero-nonce entries, worth knowing if you are hand-parsing a MultiESDTNFTTransfer payload instead of trusting the SDK's decoder.

Pitfall 3: this recipe deliberately never succeeds against a real balance or token holdings

It proves the payload is well-formed via a clean "insufficient funds" rejection (gas is always paid in EGLD, independent of which tokens move), not a confirmed on-chain transfer.

See also