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

Token lifecycle — freeze, unfreeze, pause, unpause, wipe

A token manager can control a token after issuance through the ESDT system contract:

  • pause / unpause, globally halt or resume all transfers of the token.
  • freeze / unfreeze, freeze or unfreeze one account's holding.
  • wipe, remove a frozen account's holding entirely.

These are privileged manager calls, distinct from the builtin ESDTLocalMint / ESDTNFTCreate self-calls in Local mint and burn. And in sdk-core v15.4.1 they ship with a confirmed bug that this recipe both demonstrates and works around.

Prerequisites

  • Node.js >= 20.19.0.
  • A devnet wallet, see Send EGLD to an address's Prerequisites. No devnet EGLD required, see "How it works".

Install

mkdir token-lifecycle-operations
cd token-lifecycle-operations
# Create the project files shown on this page.
npm install
npm run build
npm start -- ./wallet.pem COOK-123456
Complete project files omitted from the main walkthrough
package.json
{
"name": "cookbook-recipe-token-lifecycle-operations",
"version": "1.0.0",
"private": true,
"description": "Cookbook recipe — freeze/unfreeze an account's token, pause/unpause globally, and wipe with sdk-core, including the v15.4.1 wrong-receiver workaround.",
"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.
//
// Identical to the helper used by issue-fungible-token / send-egld /
// manage-nonces. Fetch the account nonce from the network first, then
// increment locally with getNonceThenIncrement().

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).
*/
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 for the token lifecycle recipe.
//
// Usage:
// npm run build
// npm start -- <pemPath> <tokenIdentifier> [--user <bech32>]
//
// Example:
// npm start -- ./wallet.pem COOK-123456
//
// It builds all five lifecycle operations and decodes each, showing the
// v15.4.1 wrong-receiver bug (receiver = sender). It then corrects the freeze
// transaction's receiver to the ESDT system contract, signs it, and
// broadcasts. An unfunded wallet is rejected for insufficient funds, proving
// the corrected payload is well-formed without spending real EGLD.

import { Address, DevnetEntrypoint } from '@multiversx/sdk-core';
import type { Account } from '@multiversx/sdk-core';
import { loadDevnetAccount } from './account';
import {
buildPause,
buildFreeze,
buildWipe,
withCorrectReceiver,
describeLifecyclePayload,
ESDT_SYSTEM_CONTRACT,
} from './lifecycle';

function printPayload(label: string, decoded: ReturnType<typeof describeLifecyclePayload>): void {
const flag = decoded.receiver === ESDT_SYSTEM_CONTRACT.toBech32() ? 'ESDT contract' : 'SENDER (bug)';
console.log(`${label}`);
console.log(` function: ${decoded.function}`);
console.log(` receiver: ${decoded.receiver} <- ${flag}`);
console.log(` args: [${decoded.args.join(', ')}]`);
}

async function main(): Promise<void> {
const args = process.argv.slice(2);
const [pemPath, tokenIdentifier] = args;
const userFlagIndex = args.indexOf('--user');
const userArg = userFlagIndex >= 0 ? args[userFlagIndex + 1] : undefined;

if (!pemPath || !tokenIdentifier) {
console.error('Usage: npm start -- <pemPath> <tokenIdentifier> [--user <bech32>]');
process.exitCode = 1;
return;
}

const entrypoint = new DevnetEntrypoint({ clientName: 'mvx-cookbook' });
const sender: Account = await loadDevnetAccount(entrypoint, pemPath);
const user = userArg ? Address.newFromBech32(userArg) : sender.address;

console.log(`Sender: ${sender.address.toBech32()} (nonce ${sender.nonce})`);
console.log(`Token: ${tokenIdentifier}`);
console.log(`Freeze/wipe target: ${user.toBech32()}\n`);
console.log('As the SDK builds them in v15.4.1 (note the receiver):');

const pauseTx = await buildPause(entrypoint, sender, tokenIdentifier, false);
printPayload('PAUSE:', describeLifecyclePayload(pauseTx));
const freezeTx = await buildFreeze(entrypoint, sender, tokenIdentifier, user, false);
console.log('');
printPayload('FREEZE:', describeLifecyclePayload(freezeTx));
const wipeTx = await buildWipe(entrypoint, sender, tokenIdentifier, user);
console.log('');
printPayload('WIPE:', describeLifecyclePayload(wipeTx));

// Correct the freeze transaction's receiver, then sign + broadcast it.
const patched = withCorrectReceiver(freezeTx);
console.log(`\nCorrected freeze receiver -> ESDT system contract (patched: ${patched}):`);
printPayload('FREEZE (corrected):', describeLifecyclePayload(freezeTx));

freezeTx.nonce = sender.getNonceThenIncrement();
freezeTx.signature = await sender.signTransaction(freezeTx);

console.log('\nBroadcasting the corrected freeze...');
try {
const txHash = await entrypoint.sendTransaction(freezeTx);
console.log(`Sent. Transaction hash: ${txHash}`);
console.log(`Explorer: https://devnet-explorer.multiversx.com/transactions/${txHash}`);
} catch (err) {
console.error(`Rejected (expected for an unfunded wallet): ${(err as Error).message}`);
process.exitCode = 1;
}
}

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

The code

src/lifecycle.ts
// src/lifecycle.ts — the subject of this recipe: the manager-only lifecycle
// operations a token owner can perform through the ESDT system contract.
//
// - pause / unpause — globally halt / resume all transfers of a token
// - freeze / unfreeze — freeze / unfreeze ONE account's holding of a token
// - wipe — remove a frozen account's holding entirely
//
// These are distinct from ESDTLocalMint/Burn and ESDTNFTCreate (builtin
// functions on the caller's own account — see local-mint-burn-supply). A
// pause/freeze/wipe is a privileged call the token MANAGER makes TO the ESDT
// system contract, which enforces it.
//
// ============================================================================
// CONFIRMED sdk-core v15.4.1 BUG — wrong receiver.
// ----------------------------------------------------------------------------
// createTransactionForPausing / Unpausing / Freezing / Unfreezing / Wiping
// all build the transaction with `receiver: sender` (the caller's own
// address). Every real on-chain pause/freeze/wipe is instead addressed to the
// ESDT system contract erd1qqqq...zllls8a5w6u (verified via the mainnet API),
// which is the SAME receiver the SDK correctly uses for issue and
// setSpecialRole. A transaction sent to the caller's own address will NOT
// perform the operation. This recipe builds via the factory and overrides the
// receiver before signing — see withCorrectReceiver below.
//
// Confirmed in both the installed build and the v15.4.1 source tag. Contrast:
// issue/setSpecialRole correctly target the ESDT contract; ESDTLocalMint/Burn
// and ESDTNFTCreate correctly target the sender. Only these five are wrong.
// ============================================================================

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

/**
* The ESDT system contract — the correct receiver for pause/freeze/wipe.
* The documented ESDT system contract address; verified on-chain as the
* receiver of every real freeze/pause transaction.
*/
export const ESDT_SYSTEM_CONTRACT = Address.newFromBech32(
'erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzllls8a5w6u',
);

/** Build a pause (or unpause) transaction. Factory path, unsigned. */
export async function buildPause(
entrypoint: DevnetEntrypoint,
sender: Account,
tokenIdentifier: string,
unpause: boolean,
): Promise<Transaction> {
const factory = entrypoint.createTokenManagementTransactionsFactory();
return unpause
? factory.createTransactionForUnpausing(sender.address, { tokenIdentifier })
: factory.createTransactionForPausing(sender.address, { tokenIdentifier });
}

/** Build a freeze (or unfreeze) transaction for one user's holding. */
export async function buildFreeze(
entrypoint: DevnetEntrypoint,
sender: Account,
tokenIdentifier: string,
user: Address,
unfreeze: boolean,
): Promise<Transaction> {
const factory = entrypoint.createTokenManagementTransactionsFactory();
// Factory: createTransactionForUnfreezing (lowercase "f"). The controller
// spells the same method createTransactionForUnFreezing (capital "F").
return unfreeze
? factory.createTransactionForUnfreezing(sender.address, { user, tokenIdentifier })
: factory.createTransactionForFreezing(sender.address, { user, tokenIdentifier });
}

/** Build a wipe transaction — removes a frozen account's holding. */
export async function buildWipe(
entrypoint: DevnetEntrypoint,
sender: Account,
tokenIdentifier: string,
user: Address,
): Promise<Transaction> {
const factory = entrypoint.createTokenManagementTransactionsFactory();
return factory.createTransactionForWiping(sender.address, { user, tokenIdentifier });
}

/**
* The v15.4.1 workaround: if the SDK addressed this lifecycle transaction to
* the caller instead of the ESDT system contract, fix it. Returns true if a
* correction was applied. Call this BEFORE setting the nonce and signing, so
* the signature covers the corrected receiver.
*/
export function withCorrectReceiver(transaction: Transaction): boolean {
if (transaction.receiver.toBech32() === ESDT_SYSTEM_CONTRACT.toBech32()) {
return false;
}
transaction.receiver = ESDT_SYSTEM_CONTRACT;
return true;
}

/** Decode a lifecycle transaction's `data` field. */
export function describeLifecyclePayload(transaction: Transaction): {
function: string;
receiver: string;
args: string[];
} {
const parts = Buffer.from(transaction.data).toString().split('@');
return {
function: parts[0] ?? '',
receiver: transaction.receiver.toBech32(),
args: parts.slice(1),
};
}

Run it

npm start -- <pemPath> <tokenIdentifier> [--user <bech32>]

A real captured run (unfunded wallet):

As the SDK builds them in v15.4.1 (note the receiver):
PAUSE: function: pause receiver: erd18l2c...z3jscaqd4dl3k <- SENDER (bug)
FREEZE: function: freeze receiver: erd18l2c...z3jscaqd4dl3k <- SENDER (bug)
WIPE: function: wipe receiver: erd18l2c...z3jscaqd4dl3k <- SENDER (bug)

Corrected freeze receiver -> ESDT system contract (patched: true):
FREEZE (corrected):
function: freeze
receiver: erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzllls8a5w6u <- ESDT contract

Broadcasting the corrected freeze...
Rejected (expected for an unfunded wallet): ... insufficient funds for address erd18l2c...

How it works

The v15.4.1 bug: wrong receiver. createTransactionForPausing, Unpausing, Freezing, Unfreezing, and Wiping all build the transaction with receiver: sender, the caller's own address. But every real on-chain pause/freeze/wipe is addressed to the ESDT system contract (erd1qqqq…zllls8a5w6u), the same receiver the SDK correctly uses for issue and setSpecialRole. This was verified two ways: the decoded run above shows receiver = SENDER, and a lookup of real mainnet pause and freeze transactions shows receiver = the ESDT contract. A lifecycle transaction sent to the caller's own address will not perform the operation. Confirmed in both the installed build and the tagged v15.4.1 source.

The fix. withCorrectReceiver() overrides transaction.receiver to the ESDT system contract. Because this recipe builds via the factory (which does not sign), the override happens before the nonce is set and the transaction is signed, so the signature covers the corrected receiver. The corrected freeze above is a genuine, well-formed transaction: it is rejected only for insufficient funds, keyed to the sender.

Argument shapes. pause/unPause take only <tokenIdentifier>. freeze/unFreeze/wipe take <tokenIdentifier>@<userAddress> (the account whose holding is affected). On the wire the unfreeze function is spelled UnFreeze (capital "F").

Pitfalls

Pitfall 1: v15.4.1 addresses pause/freeze/wipe to the sender, not the ESDT contract

As shipped, these transactions will not take effect. Override transaction.receiver to erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzllls8a5w6u before signing (build via the factory so the signature covers the fix), exactly as withCorrectReceiver() does.

Pitfall 2: createTransactionForUnFreezing (Controller) vs ...Unfreezing (Factory)

The controller capitalizes the "F" (UnFreezing); the factory does not (Unfreezing). Another instance of this SDK's Controller/Factory casing drift.

Pitfall 3: wipe only works on a frozen account

You must freeze an account's holding before you can wipe it. Freeze/wipe also require the token to have been issued with canFreeze / canWipe, see Issue a fungible ESDT.

Pitfall 4: pause is global, freeze is per-account

pause halts every transfer of the token for everyone; freeze targets one address. Do not reach for pause when you mean to restrict a single account.

See also

  • Set and unset special roles is the other manager-side surface; grants operational roles rather than restricting holders.
  • Issue a fungible ESDT sets canFreeze / canWipe / canPause at issuance so these operations are permitted.
  • Local mint and burn covers the builtin self-calls whose receiver: sender is correct, for contrast with this recipe's bug.