Issue an SFT collection + create, add, and burn quantity
A semi-fungible token (SFT) is a fungible quantity living at an NFT-style
nonce inside a collection: 100 identical festival tickets, 50 copies of an in-game
item. Here is the full lifecycle with sdk-core's
TokenManagementController:
- Issue the collection (
issueSemiFungible, costs 0.05 EGLD). - Create the first batch (
ESDTNFTCreatewith quantity > 1). - Add quantity to that batch (
ESDTNFTAddQuantity). - Burn quantity from it (
ESDTNFTBurn).
An SFT collection is issued with the same input shape as an NFT collection (both
carry canTransferNFTCreateRole), and an SFT is created with the same
ESDTNFTCreate builtin as an NFT, only the quantity differs. Contrast the
single-unit case in
Issue an NFT collection.
Prerequisites
- Node.js >= 20.19.0.
- A devnet wallet, see Send EGLD to an address's Prerequisites. No devnet EGLD required to verify the payloads, see "How it works".
Install
mkdir issue-sft-collection
cd issue-sft-collection
# Create the project files shown on this page.
npm install
npm run build
npm start -- ./wallet.pem CookbookSFT CSFT
Complete project files omitted from the main walkthrough
{
"name": "cookbook-recipe-issue-sft-collection",
"version": "1.0.0",
"private": true,
"description": "Cookbook recipe — issue a semi-fungible (SFT) collection, create an SFT, add quantity, and burn quantity with sdk-core's TokenManagementController.",
"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 SFT lifecycle recipe.
//
// Usage:
// npm run build
// npm start -- <pemPath> <tokenName> <tokenTicker>
//
// Example:
// npm start -- ./wallet.pem CookbookSFT CSFT
//
// It broadcasts the issuance (an unfunded wallet is rejected for insufficient
// funds — proving that payload is well-formed), then builds and decodes the
// create / add-quantity / burn-quantity steps offline against a PLACEHOLDER
// identifier. An unfunded issuance never lands on-chain, so there is no real
// collection identifier to mint into here — see the recipe page for the
// real-usage sequence (parse the identifier from the issuance outcome first).
import { DevnetEntrypoint } from '@multiversx/sdk-core';
import { loadDevnetAccount } from './account';
import {
issueSemiFungibleCollection,
createSft,
addSftQuantity,
burnSftQuantity,
describePayload,
} from './sft';
// Stand-in identifier for the offline create/add/burn decode. In real usage
// this comes from awaitCompletedIssueSemiFungible(txHash)[0].tokenIdentifier.
const PLACEHOLDER_IDENTIFIER = 'CSFT-000000';
function printPayload(label: string, decoded: ReturnType<typeof describePayload>): void {
console.log(`${label}`);
console.log(` function: ${decoded.function}`);
console.log(` receiver: ${decoded.receiver}`);
console.log(` value: ${decoded.value}`);
console.log(` args: [${decoded.args.join(', ')}]`);
}
async function main(): Promise<void> {
const [pemPath, tokenName, tokenTicker] = process.argv.slice(2);
if (!pemPath || !tokenName || !tokenTicker) {
console.error('Usage: npm start -- <pemPath> <tokenName> <tokenTicker>');
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(`Collection: ${tokenName} (${tokenTicker})`);
console.log('Issue cost: 0.05 EGLD + gas\n');
// Step 1 — issue the collection, then broadcast it.
const issueTx = await issueSemiFungibleCollection(entrypoint, sender, { tokenName, tokenTicker });
printPayload('ISSUE semi-fungible collection:', describePayload(issueTx));
// Steps 2-4 — decoded offline against a placeholder identifier.
const createTx = await createSft(entrypoint, sender, {
tokenIdentifier: PLACEHOLDER_IDENTIFIER,
initialQuantity: 100n,
name: 'Batch #1',
royalties: 500,
uris: ['https://ipfs.io/ipfs/CID/1.json'],
});
console.log('');
printPayload(`CREATE SFT (${PLACEHOLDER_IDENTIFIER}, quantity 100):`, describePayload(createTx));
const addTx = await addSftQuantity(entrypoint, sender, {
tokenIdentifier: PLACEHOLDER_IDENTIFIER,
tokenNonce: 1n,
quantity: 50n,
});
console.log('');
printPayload('ADD quantity (nonce 1, +50):', describePayload(addTx));
const burnTx = await burnSftQuantity(entrypoint, sender, {
tokenIdentifier: PLACEHOLDER_IDENTIFIER,
tokenNonce: 1n,
quantity: 10n,
});
console.log('');
printPayload('BURN quantity (nonce 1, -10):', describePayload(burnTx));
// Broadcast the issuance.
console.log('\nBroadcasting the issuance...');
try {
const txHash = await entrypoint.sendTransaction(issueTx);
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/sft.ts — the subject of this recipe: the full semi-fungible (SFT)
// lifecycle via sdk-core's TokenManagementController.
//
// 1. issueSemiFungibleCollection — register the collection (costs 0.05 EGLD)
// 2. createSft — mint the first batch (quantity > 1)
// 3. addSftQuantity — increase an existing batch's supply
// 4. burnSftQuantity — destroy some of a batch's supply
//
// An SFT is a fungible quantity that lives at a specific NFT-style nonce
// inside a collection. Its issuance has the SAME shape as an NFT collection
// (IssueSemiFungibleInput === IssueNonFungibleInput — both carry
// `canTransferNFTCreateRole`, all-caps NFT), and creation reuses the exact
// same `ESDTNFTCreate` builtin as an NFT, only with an initialQuantity above
// 1. See issue-nft-collection for the quantity-1 case.
//
// Two casing facts baked in (see the recipe page's Pitfalls):
// - Controller: createTransactionForCreatingNft (lowercase "Nft").
// Factory: createTransactionForCreatingNFT (all-caps "NFT").
// - royalties is a plain number, not a bigint.
import type { Account, DevnetEntrypoint, Transaction } from '@multiversx/sdk-core';
export interface IssueSftInput {
tokenName: string;
tokenTicker: string;
}
export interface CreateSftInput {
tokenIdentifier: string;
/** How many units of this new batch to mint — the point of an SFT (> 1). */
initialQuantity: bigint;
name: string;
/** Basis points, e.g. 500 = 5%. A plain number, NOT a bigint. */
royalties: number;
uris: string[];
}
export interface QuantityInput {
tokenIdentifier: string;
/** The nonce of the batch to change (from the create step's outcome). */
tokenNonce: bigint;
quantity: bigint;
}
/** Step 1 — register the SFT collection. Costs a fixed 0.05 EGLD + gas. */
export async function issueSemiFungibleCollection(
entrypoint: DevnetEntrypoint,
sender: Account,
input: IssueSftInput,
): Promise<Transaction> {
const controller = entrypoint.createTokenManagementController();
return controller.createTransactionForIssuingSemiFungible(sender, sender.getNonceThenIncrement(), {
tokenName: input.tokenName,
tokenTicker: input.tokenTicker,
canFreeze: true,
canWipe: true,
canPause: true,
// All-caps "NFT" — the field exists for SFT/NFT issuance, unlike
// fungible issuance where it does not exist at all.
canTransferNFTCreateRole: true,
canChangeOwner: true,
canUpgrade: true,
canAddSpecialRoles: true,
});
}
/** Step 2 — create (mint) the first batch. Needs the ESDTRoleNFTCreate role. */
export async function createSft(
entrypoint: DevnetEntrypoint,
sender: Account,
input: CreateSftInput,
): Promise<Transaction> {
const controller = entrypoint.createTokenManagementController();
// createTransactionForCreatingNft — lowercase "Nft" on the controller.
return controller.createTransactionForCreatingNft(sender, sender.getNonceThenIncrement(), {
tokenIdentifier: input.tokenIdentifier,
initialQuantity: input.initialQuantity,
name: input.name,
royalties: input.royalties, // plain number
hash: '',
attributes: new Uint8Array(),
uris: input.uris,
});
}
/** Step 3 — add supply to an existing batch. Needs the ESDTRoleNFTAddQuantity role. */
export async function addSftQuantity(
entrypoint: DevnetEntrypoint,
sender: Account,
input: QuantityInput,
): Promise<Transaction> {
const controller = entrypoint.createTokenManagementController();
return controller.createTransactionForAddingQuantity(sender, sender.getNonceThenIncrement(), {
tokenIdentifier: input.tokenIdentifier,
tokenNonce: input.tokenNonce,
quantity: input.quantity,
});
}
/** Step 4 — destroy supply from a batch. Needs the ESDTRoleNFTBurn role. */
export async function burnSftQuantity(
entrypoint: DevnetEntrypoint,
sender: Account,
input: QuantityInput,
): Promise<Transaction> {
const controller = entrypoint.createTokenManagementController();
return controller.createTransactionForBurningQuantity(sender, sender.getNonceThenIncrement(), {
tokenIdentifier: input.tokenIdentifier,
tokenNonce: input.tokenNonce,
quantity: input.quantity,
});
}
/** Decode a transaction's `data` field into `<function>` + hex `<args>`. */
export function describePayload(transaction: Transaction): {
function: string;
receiver: string;
value: string;
args: string[];
} {
const parts = Buffer.from(transaction.data).toString().split('@');
return {
function: parts[0] ?? '',
receiver: transaction.receiver.toBech32(),
value: transaction.value.toString(),
args: parts.slice(1),
};
}
Run it
npm start -- <pemPath> <tokenName> <tokenTicker>
A real captured run (unfunded wallet), abbreviated:
ISSUE semi-fungible collection:
function: issueSemiFungible
receiver: erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzllls8a5w6u
value: 50000000000000000
CREATE SFT (CSFT-000000, quantity 100):
function: ESDTNFTCreate
receiver: erd18l2cww3g3g6uc35wy60efd87ss85xdqj9gh9qn6ga6halz3jscaqd4dl3k
args: [435346542d303030303030, 64, 4261746368202331, 01f4, , , 6874747073...]
ADD quantity (nonce 1, +50): ESDTNFTAddQuantity ... [CSFT..., 01, 32]
BURN quantity (nonce 1, -10): ESDTNFTBurn ... [CSFT..., 01, 0a]
Broadcasting the issuance...
Rejected (expected for an unfunded wallet): ... insufficient funds for address erd18l2c...
Two independently-verified steps, not a real end-to-end mint
An unfunded wallet's issuance never lands on-chain, so there is no real collection
identifier to mint into, the create/add/burn steps use a placeholder
(CSFT-000000). In real usage: send the issuance, parse the identifier from its
outcome (awaitCompletedIssueSemiFungible(txHash) returns [{ tokenIdentifier }]),
then feed that into the create step.
How it works
Issuance is addressed to the ESDT system contract with
value: 50000000000000000 (exactly 0.05 EGLD), and its flags include
canTransferNFTCreateRole (all-caps "NFT", same as NFT issuance).
Create/add/burn are builtin functions executed in the creator's own account, so their receiver is the sender, not a contract, hand-verified against the decoded payloads above:
ESDTNFTCreate@<tokenId>@<quantity>@<name>@<royalties>@<hash>@<attributes>@<uris…>. The quantity64is hex for 100, the whole point of an SFT. Royalties01f4is hex for 500 (a plain number, not a bigint,500nfailstsc --strictagainstMintInput.royalties: number).ESDTNFTAddQuantity@<tokenId>@<nonce>@<quantity>andESDTNFTBurn@<tokenId>@<nonce>@<quantity>both take the batch's nonce (from the create outcome) plus an amount.
Pitfalls
The controller spells it lowercase Nft; the factory spells it all-caps NFT.
Mixing them up is a tsc --strict error. This recipe uses the controller. See
Issue an NFT collection's
Pitfall 2.
royalties: 500n fails tsc --strict against MintInput.royalties: number. Use
500.
ESDTNFTAddQuantity needs ESDTRoleNFTAddQuantity and ESDTNFTBurn needs
ESDTRoleNFTBurn on the creator address. Grant them like any special role, see
Set and unset special roles
(the SFT role setter is
createTransactionForSettingSpecialRoleOnSemiFungibleToken).
See also
- Issue an NFT collection + mint an NFT
is the single-unit sibling; same issuance and
ESDTNFTCreateshape with quantity 1. - Issue a fungible ESDT
is the fully fungible case, and why
canTransferNFTCreateRoledoes not apply there. - Set and unset special roles grants the create / add-quantity / burn roles this recipe relies on.