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

Local mint and burn (change a fungible token's supply)

Once a fungible ESDT is issued, its circulating supply is not fixed. An address holding the right role can mint more (ESDTLocalMint, needs ESDTRoleLocalMint) or burn its own (ESDTLocalBurn, needs ESDTRoleLocalBurn). Both are builtin functions executed in the caller's own account, so the transaction's receiver is the sender itself, not the ESDT system contract.

This recipe builds both, decodes the payloads, and broadcasts the mint. It also walks straight into a real sdk-core method-name trap.

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 local-mint-burn-supply
cd local-mint-burn-supply
# Create the project files shown on this page.
npm install
npm run build
npm start -- ./wallet.pem COOK-123456 1000
Complete project files omitted from the main walkthrough
package.json
{
"name": "cookbook-recipe-local-mint-burn-supply",
"version": "1.0.0",
"private": true,
"description": "Cookbook recipe — increase and decrease a fungible ESDT's circulating supply with ESDTLocalMint / ESDTLocalBurn via sdk-core, controller and factory.",
"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 local mint/burn recipe.
//
// Usage:
// npm run build
// npm start -- <pemPath> <tokenIdentifier> <amount> [--factory]
//
// Example (mint/burn 1000 base units of COOK-123456):
// npm start -- ./wallet.pem COOK-123456 1000
//
// It decodes both the local-mint and local-burn payloads, then broadcasts the
// mint. An unfunded wallet is rejected for insufficient funds, proving the
// payload is well-formed without spending real EGLD or needing the token to
// exist. (A real mint/burn also needs the matching local role — see
// set-special-roles.)

import { DevnetEntrypoint } from '@multiversx/sdk-core';
import { loadDevnetAccount } from './account';
import { localMint, localBurn, describeSupplyPayload } from './supply';

function printPayload(label: string, decoded: ReturnType<typeof describeSupplyPayload>): void {
console.log(`${label}`);
console.log(` function: ${decoded.function}`);
console.log(` receiver: ${decoded.receiver}`);
console.log(` tokenIdentifier: ${decoded.tokenIdentifier}`);
console.log(` amount (hex): ${decoded.amountHex}`);
}

async function main(): Promise<void> {
const args = process.argv.slice(2);
const [pemPath, tokenIdentifier, amountArg] = args;
const useFactory = args.includes('--factory');

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

const amount = BigInt(amountArg);
const entrypoint = new DevnetEntrypoint({ clientName: 'mvx-cookbook' });
const sender = await loadDevnetAccount(entrypoint, pemPath);

console.log(`Sender: ${sender.address.toBech32()} (nonce ${sender.nonce})`);
console.log(`Token: ${tokenIdentifier}, amount ${amount}`);
console.log(`API level: ${useFactory ? 'factory' : 'controller'}\n`);

const mintTx = await localMint(entrypoint, sender, { tokenIdentifier, amount }, useFactory);
printPayload('LOCAL MINT (ESDTLocalMint):', describeSupplyPayload(mintTx));

const burnTx = await localBurn(entrypoint, sender, { tokenIdentifier, amount }, useFactory);
console.log('');
printPayload('LOCAL BURN (ESDTLocalBurn):', describeSupplyPayload(burnTx));

console.log('\nBroadcasting the mint...');
try {
const txHash = await entrypoint.sendTransaction(mintTx);
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/supply.ts
// src/supply.ts — the subject of this recipe: changing a fungible ESDT's
// circulating supply after issuance, using the ESDTLocalMint / ESDTLocalBurn
// builtin functions.
//
// - localMint — requires the ESDTRoleLocalMint role (see set-special-roles)
// - localBurn — requires the ESDTRoleLocalBurn role
//
// Both are builtin functions executed in the caller's OWN account context,
// so the transaction's receiver is the sender itself (not the ESDT system
// contract). This is the correct, on-chain-verified shape for these two
// operations — contrast the freeze/pause/wipe manager operations in the
// token-lifecycle-operations recipe, which must instead target the ESDT
// system contract.
//
// TWO method-name traps, both confirmed against the installed v15.4.1 (see
// the recipe page's Pitfalls):
// - MINT, controller: createTransactionForLocaMinting (note the typo:
// "Loca", missing an "l" — it is NOT createTransactionForLocalMinting).
// - MINT, factory: createTransactionForLocalMint (correct spelling,
// but no "-ing" suffix).
// - BURN is createTransactionForLocalBurning on BOTH — the only consistent
// one of the pair.

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

export interface SupplyInput {
tokenIdentifier: string;
/** Amount in the token's smallest denomination (respect its numDecimals). */
amount: bigint;
}

/** Mint additional supply of a fungible token you hold the local-mint role on. */
export async function localMint(
entrypoint: DevnetEntrypoint,
sender: Account,
input: SupplyInput,
useFactory: boolean,
): Promise<Transaction> {
if (useFactory) {
const factory = entrypoint.createTokenManagementTransactionsFactory();
// Factory: createTransactionForLocalMint — no "-ing", correct spelling.
const transaction = await factory.createTransactionForLocalMint(sender.address, {
tokenIdentifier: input.tokenIdentifier,
supplyToMint: input.amount,
});
transaction.nonce = sender.getNonceThenIncrement();
transaction.signature = await sender.signTransaction(transaction);
return transaction;
}

const controller = entrypoint.createTokenManagementController();
// Controller: createTransactionForLocaMinting — "Loca", a real SDK typo.
return controller.createTransactionForLocaMinting(sender, sender.getNonceThenIncrement(), {
tokenIdentifier: input.tokenIdentifier,
supplyToMint: input.amount,
});
}

/** Burn supply of a fungible token you hold the local-burn role on. */
export async function localBurn(
entrypoint: DevnetEntrypoint,
sender: Account,
input: SupplyInput,
useFactory: boolean,
): Promise<Transaction> {
if (useFactory) {
const factory = entrypoint.createTokenManagementTransactionsFactory();
const transaction = await factory.createTransactionForLocalBurning(sender.address, {
tokenIdentifier: input.tokenIdentifier,
supplyToBurn: input.amount,
});
transaction.nonce = sender.getNonceThenIncrement();
transaction.signature = await sender.signTransaction(transaction);
return transaction;
}

const controller = entrypoint.createTokenManagementController();
return controller.createTransactionForLocalBurning(sender, sender.getNonceThenIncrement(), {
tokenIdentifier: input.tokenIdentifier,
supplyToBurn: input.amount,
});
}

/** Decode a transaction's `data` field into `<function>@<tokenId>@<amount>`. */
export function describeSupplyPayload(transaction: Transaction): {
function: string;
receiver: string;
tokenIdentifier: string;
amountHex: string;
} {
const parts = Buffer.from(transaction.data).toString().split('@');
const tokenHex = parts[1] ?? '';
return {
function: parts[0] ?? '',
receiver: transaction.receiver.toBech32(),
tokenIdentifier: Buffer.from(tokenHex, 'hex').toString(),
amountHex: parts[2] ?? '',
};
}

Run it

npm start -- <pemPath> <tokenIdentifier> <amount> [--factory]

A real captured run (unfunded wallet):

LOCAL MINT (ESDTLocalMint):
function: ESDTLocalMint
receiver: erd18l2cww3g3g6uc35wy60efd87ss85xdqj9gh9qn6ga6halz3jscaqd4dl3k
tokenIdentifier: COOK-123456
amount (hex): 03e8

LOCAL BURN (ESDTLocalBurn):
function: ESDTLocalBurn
amount (hex): 03e8

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

How it works

Both payloads decode to <function>@<tokenIdentifier>@<amount> addressed to the sender. 03e8 is hex for 1000, remember this is in the token's smallest denomination, so respect its numDecimals (a 6-decimal token needs 1000000 for one whole unit). The unfunded broadcast is rejected with a clean insufficient funds keyed to the sender, proving the payload is well-formed; a real mint additionally needs the token to exist and the sender to hold the matching local role (see Set and unset special roles).

The method names are a minefield, this is the recipe's real value. Against the installed v15.4.1:

  • Mint, controller: createTransactionForLocaMinting, note the typo, "Loca" with a missing "l". It is not createTransactionForLocalMinting.
  • Mint, factory: createTransactionForLocalMint, correct spelling, but no "-ing" suffix.
  • Burn: createTransactionForLocalBurning on both, the only consistent name of the pair.

Pitfalls

Pitfall 1: the controller mint method is misspelled createTransactionForLocaMinting

"Loca", not "Local". Autocomplete for createTransactionForLocal… will not surface it on the controller. The factory instead uses createTransactionForLocalMint (no "-ing"). Copy the exact name from the code above.

Pitfall 2: amounts are in the smallest denomination

ESDTLocalMint@…@03e8 mints 1000 base units. For a token issued with 6 decimals that is 0.001 of a whole unit. See Send an ESDT's decimals pitfall.

Pitfall 3: local mint/burn needs the local role, not ownership

Being the token's owner is not enough; the sender needs ESDTRoleLocalMint / ESDTRoleLocalBurn explicitly granted. Grant it with Set and unset special roles.

See also