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
{
"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"
}
}
{
"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.
//
// 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 — 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 — 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
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.
The controller capitalizes the "F" (UnFreezing); the factory does not
(Unfreezing). Another instance of this SDK's Controller/Factory casing drift.
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.
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/canPauseat issuance so these operations are permitted. - Local mint and burn
covers the builtin self-calls whose
receiver: senderis correct, for contrast with this recipe's bug.