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
{
"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"
}
}
{
"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 — 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 — 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 — 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 — 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:
- The transaction's outer
receiveris the SENDER'S OWN address, not the real destination. A request built for receivererd1qyu5...logged"receiver":"erd1ayggn...", the sender's own address. The real destination is instead the first argument insidedata, hex-encoded, decoding it back to bech32 gives exactly the intended receiver. If you inspect a built transaction's.receiveror.valuedirectly expecting the real recipient or EGLD amount, you will be looking at the wrong fields for this transaction type. - Combining
nativeAmountwithtokenTransfersfolds the EGLD into a THIRD synthetic token-transfer entry, using the special identifierEGLD-000000, not carried invalue(which is"0"here). 2 real token transfers plus 1 native amount produced a decoded data field startingMultiESDTNFTTransfer@<addressHex>@03@..., count03, not02.
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
Both point at the sender's own address / zero, by protocol design, for this
transaction shape. The real information is encoded in tx.data.
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.
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
- Send an ESDT is the single-token case this recipe generalizes.
- Send EGLD to an address is the native-only case.
- Manage nonces covers the fetch-then-increment pattern this recipe relies on.