Fungible Tokens: Issuing and Managing VFX20 Tokens
This guide walks through the full life of a fungible (VFX20) token with the VerifiedX Web SDK: deploying the contract, minting supply, moving it between holders, and the owner controls (pause, ban, ownership transfer, holder votes).
Prerequisites
Section titled “Prerequisites”vfx-web-sdk3.4.0 or later- A funded VFX address. Token operations pay only the ordinary network fee in VFX (a few thousandths of a VFX each); there is no deployment charge beyond that.
- Testnet is strongly recommended while learning: a ban cannot be undone, and a token cannot be deleted.
How Tokens Work on VerifiedX
Section titled “How Tokens Work on VerifiedX”A VFX20 token is a smart contract deployed by its owner. Its identifier, the scIdentifier (for example 9dbadf6ca06b4b75ac8594ca4de52cba:1791216037), is what every later call is addressed to.
- The owner deploys, mints (if the token is mintable), pauses, bans, transfers ownership and opens votes.
- Holders transfer, burn their own balance (if the token is burnable) and vote.
- Supply is either fixed at deployment or mintable. A mintable token starts at zero and the owner mints into existence what is needed.
- Validation happens on chain. Spyglass, the VerifiedX explorer, indexes the result, and the SDK reads token state from it.
Step 1: Set Up a Client and Keypair
Section titled “Step 1: Set Up a Client and Keypair”import { VfxClient, Network } from 'vfx-web-sdk';
const client = new VfxClient(Network.Testnet);
const privateKey = process.env.VFX_PRIVATE_KEY!;const owner = { privateKey, publicKey: client.publicFromPrivate(privateKey), address: client.addressFromPrivate(privateKey),};Every method below also accepts a Signer in place of the keypair, so the key can live in an HSM or MPC service. See External Signing.
Step 2: Deploy the Token
Section titled “Step 2: Deploy the Token”const deployed = await client.deployToken(owner, { name: 'Acme Fund', ticker: 'ACME', // up to 20 characters, stored upper-case decimalPlaces: 2, // 1–18, default 8 mintable: true, burnable: true, // initialSupply: 1_000_000, // whole tokens, for a fixed-supply token (must be 0 when mintable) // voting: true, // enables holder votes // image: { url: 'https://example.com/acme.png' }, // optional, must be publicly fetchable});
if (!deployed) throw new Error('Deploy was rejected before reaching the node');const { scIdentifier, transactionHash } = deployed;The scIdentifier is returned as soon as the contract compiles, before the transaction is confirmed. Store it: it is the token’s permanent identifier.
Without an image, the network assigns a default token image.
Step 3: Wait for Confirmation
Section titled “Step 3: Wait for Confirmation”Mutating methods return the transaction hash once the node accepts the transaction. Blocks arrive roughly every 10 seconds, and Spyglass indexes them shortly after. Before acting on a result, confirm it by reading state back:
async function waitForToken(scIdentifier: string) { for (let i = 0; i < 30; i++) { try { const { token } = await client.getFungibleToken(scIdentifier); return token; } catch { await new Promise((r) => setTimeout(r, 5000)); } } throw new Error(`Token ${scIdentifier} not indexed yet`);}
const token = await waitForToken(scIdentifier);console.log(token.owner_address, token.can_mint, token.is_paused);The same applies between dependent steps: mint, wait until the balance shows, then transfer.
Step 4: Mint, Transfer and Burn
Section titled “Step 4: Mint, Transfer and Burn”// Owner only, token must be mintable. Tokens are credited to the signer.await client.mintToken(owner, { scIdentifier, amount: 10 });
// Any holder.await client.transferToken(owner, { scIdentifier, toAddress: 'xRecipient...', amount: 3 });
// Any holder, from their own balance. Token must be burnable; there is no issuer-side burn.await client.burnToken(owner, { scIdentifier, amount: 2 });Each transaction carries the token’s ticker and name for indexers and wallets. Pass ticker and name to skip the Spyglass lookup the SDK otherwise makes:
await client.transferToken(owner, { scIdentifier, toAddress, amount: 1, ticker: 'ACME', name: 'Acme Fund' });Step 5: Read Balances and Holders
Section titled “Step 5: Read Balances and Holders”// Every token an address holdsconst balances = await client.getFungibleTokenBalances(owner.address);// [{ token: { sc_identifier, ticker, ... }, balance: 5 }]
// One token, with its holder mapconst { token, holders } = await client.getFungibleToken(scIdentifier);// holders: { 'xOwner...': 5, 'xRecipient...': 3 }
// Browse all tokensconst page = await client.listFungibleTokens(1, 25);Step 6: Owner Controls
Section titled “Step 6: Owner Controls”Pause and Resume
Section titled “Pause and Resume”await client.toggleTokenPause(owner, { scIdentifier }); // active -> paused// ...laterawait client.toggleTokenPause(owner, { scIdentifier }); // paused -> activePause is a toggle: the network flips the current state. Send it once per intended change and confirm is_paused before sending again, because a second toggle undoes the first. The SDK reads the current state before sending, so the transaction is labelled with the state it produces (which explorers and wallets display).
Ban an Address
Section titled “Ban an Address”await client.banTokenAddress(owner, { scIdentifier, address: 'xBadActor...' });A banned address can no longer send the token, but it can still receive. The network has no way to lift a ban, so treat it as permanent.
Transfer Ownership
Section titled “Transfer Ownership”await client.transferTokenOwnership(owner, { scIdentifier, toAddress: 'xNewOwner...' });Step 7: Holder Votes
Section titled “Step 7: Holder Votes”For tokens deployed with voting: true, the owner opens a topic and holders vote:
await client.createTokenVoteTopic(owner, { scIdentifier, name: 'Distribute Q3', description: 'Pay out the Q3 distribution', votingDays: 7, minimumVoteRequirement: 51,});
const topics = await client.listTokenVotingTopics(scIdentifier);await client.castTokenVote(holder, { scIdentifier, topicUid: topics.results[0].topic_id, vote: true });Dry Runs
Section titled “Dry Runs”A client created with dryRun builds, signs and has the node verify each transaction, then stops before sending. It is a safe way to check parameters on mainnet:
const dry = new VfxClient(Network.Mainnet, true);const check = await dry.deployToken(owner, { name: 'Acme Fund', ticker: 'ACME', initialSupply: 1_000_000 });// check.scIdentifier and check.transactionHash are returned; nothing is broadcastReserve Accounts and Tokens
Section titled “Reserve Accounts and Tokens”Token methods can be signed from a reserve (vault) account and accept unlockHours. Note that a token transfer from a vault carries the unlock time the network requires but settles immediately: a vault protects VFX, vBTC and NFTs, not fungible tokens. Deploying a token from a vault is refused. See Reserve Accounts.
Common Issues
Section titled “Common Issues”A non-mintable token with initialSupply 0 could never have any supply
Section titled “A non-mintable token with initialSupply 0 could never have any supply”Set mintable: true, or give a fixed-supply token an initialSupply.
Mint or transfer rejected right after deploy
Section titled “Mint or transfer rejected right after deploy”The deploy is not confirmed yet. Wait until getFungibleToken returns the token.
Transfer rejected for a holder
Section titled “Transfer rejected for a holder”The token may be paused, or the sender banned. Check is_paused and banned_addresses on getFungibleToken.
What’s Next?
Section titled “What’s Next?”- External Signing: keep the issuer key in an HSM or MPC service
- Reserve Accounts: time-delayed, recoverable treasury accounts
- VfxClient API Reference: every token method and its parameters