intermediate 60 min

Multi-Purpose Tokens (MPT)

Create and manage Multi-Purpose Tokens: configure flags (canLock, requireAuth, canTransfer), authorize holders, freeze/unfreeze, and clawback. Includes a company shares example.

Prerequisites

Complete these before starting this module:

What you'll learn

  • Issue an MPTokenIssuanceCreate with an asset scale, metadata, and a combined flag set, then read back its MPTokenIssuanceID.
  • Authorize a holder (and have the holder opt in) so they can receive a regulated token.
  • Send Multi-Purpose Tokens with a Payment using the object Amount form.
  • Lock and unlock a holder's balance with MPTokenIssuanceSet, and reclaim tokens with Clawback.
Complete this module by peer review. Jump to assessment

Overview

You already know how to move native XRP. Now you'll mint your own token — and not the old-style trust-line kind, but a Multi-Purpose Token (MPT): a newer, self-contained asset designed to be cheaper, simpler, and friendlier to regulated use cases like stablecoins or company shares.

The mental model is worth getting right up front. A classic XRPL issued token lives in a trust line — a two-sided relationship every holder must establish before they can hold a cent of it. MPTs flip that around. The issuer creates one on-ledger object, the MPTokenIssuance, that defines the token once: its precision, its supply cap, its transfer fee, and a set of permanent rules baked in as flags. Holders then attach a lightweight balance to that single issuance. Think of the issuance as the master certificate for a share class, and each holder's MPT as a numbered line in the cap table that points back to it.

In this module you'll build a small TypeScript script that walks the full lifecycle of a regulated token — call it a company share. You'll issue it, authorize a holder, send them some, then lock their balance and claw it back. Every step runs on the testnet, costs nothing, and ends in a tesSUCCESS you can confirm on the explorer.

This builds directly on the Payments module: same xrpl.js client, same connect/fund/submitAndWait rhythm. What's new is the transactions in the middle.

Setup

Same toolchain as before. If you're continuing from the Payments module you already have everything:

npm init -y
npm i xrpl
npm i -D typescript tsx @types/node

Create mpt.ts, connect to testnet, and fund two wallets — issuer (the company) and holder (an investor):

import * as xrpl from 'xrpl'

const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233')

async function main() {
  await client.connect()

  const { wallet: issuer } = await client.fundWallet()
  const { wallet: holder } = await client.fundWallet()
  console.log({ issuer: issuer.address, holder: holder.address })

  // ... everything below goes here ...

  await client.disconnect()
}

main()

A tiny helper to keep the rest readable — submit, wait, and throw on anything that isn't tesSUCCESS:

async function submit(tx: xrpl.Transaction, wallet: xrpl.Wallet) {
  const res = await client.submitAndWait(tx, { autofill: true, wallet })
  const code = (res.result.meta as any)?.TransactionResult
  if (code !== 'tesSUCCESS') throw new Error(`${tx.TransactionType} failed: ${code}`)
  return res
}

Issue the token

Issuance is a single transaction: MPTokenIssuanceCreate. A few fields define the token forever:

  • AssetScale — how many decimal places the token has. 2 means amounts are tracked in hundredths, like cents. For whole shares you might use 0.
  • MaximumAmount — the hard supply cap, as a string of base units. Optional, but a share class usually has a fixed total.
  • TransferFee — a fee on secondary transfers, in units of 1/100,000 (so 50000 is the maximum, 50%). Leave it off for 0.
  • MPTokenMetadata — arbitrary metadata as a hex string. Convert your JSON or label with xrpl.convertStringToHex().
  • Flags — the rules, as a single summed integer (more on this next).

The flags are the heart of MPT design. Each is a power of two, and you OR/add them together:

Flag Value What it does
canLock 2 Issuer can lock balances (freeze)
requireAuth 4 Holders need issuer approval before holding
canEscrow 8 Eligible for escrow (reserved)
canTrade 16 Eligible for DEX trading (reserved)
canTransfer 32 Holders may transfer to each other
canClawback 64 Issuer can reclaim tokens

For a regulated share you want canLock + requireAuth + canTransfer + canClawback = 2 + 4 + 32 + 64 = 102. Each flag is a distinct power of two (a single bit), so summing them is really a bitwise OR — every flag occupies its own bit and the total 102 encodes exactly which ones are on, with no overlap. These choices are immutable — you cannot add canClawback later, so decide deliberately.

const create = await submit({
  TransactionType: 'MPTokenIssuanceCreate',
  Account: issuer.address,
  AssetScale: 0,
  MaximumAmount: '1000000',
  MPTokenMetadata: xrpl.convertStringToHex(
    JSON.stringify({ name: 'Acme Shares', ticker: 'ACME' }),
  ),
  Flags: 2 + 4 + 32 + 64, // canLock | requireAuth | canTransfer | canClawback
}, issuer)

The token's permanent identifier — its MPTokenIssuanceID — is returned in the transaction metadata. Grab it; every later transaction references it:

const issuanceId = (create.result.meta as any).mpt_issuance_id as string
console.log('Issuance ID:', issuanceId)

Authorize a holder

Because you set requireAuth, holding this token is a two-handshake process — exactly the point of a regulated asset. Both sides use the same transaction type, MPTokenAuthorize, but play different roles.

The holder opts in by submitting MPTokenAuthorize for the issuance with no Holder field — they're acting on their own balance:

await submit({
  TransactionType: 'MPTokenAuthorize',
  Account: holder.address,
  MPTokenIssuanceID: issuanceId,
}, holder)

The issuer then approves that holder by submitting MPTokenAuthorize with the holder's address in the Holder field:

await submit({
  TransactionType: 'MPTokenAuthorize',
  Account: issuer.address,
  MPTokenIssuanceID: issuanceId,
  Holder: holder.address,
}, issuer)

Only after both have run is the holder cleared to receive. (If you had not set requireAuth, the holder's opt-in alone would be enough — no issuer step.) A holder can later walk away from a zero balance by submitting MPTokenAuthorize with Flags: 1 (the unauthorize flag).

Send the token

Sending an MPT is a plain Payment — the only twist is the Amount. Instead of a drops string, you pass an object naming the issuance and a string value:

await submit({
  TransactionType: 'Payment',
  Account: issuer.address,
  Destination: holder.address,
  Amount: {
    mpt_issuance_id: issuanceId,
    value: '500',
  },
}, issuer)

That moves 500 shares from issuer to holder. The same object form works holder-to-holder too (since you enabled canTransfer). To confirm the balance landed, query the holder's MPTs directly:

const objs = await client.request({
  command: 'account_objects',
  account: holder.address,
  type: 'mptoken',
})
console.log(JSON.stringify(objs.result.account_objects, null, 2))

You'll see an MPToken object whose MPTAmount reads 500, pointing back to your issuance ID.

Lock and claw back

Regulated assets sometimes need the issuer to step in — a compliance freeze, a court order, a mistaken transfer. Two tools cover this, and both depend on flags you set at creation.

Locking uses MPTokenIssuanceSet. With a Holder, you freeze just that account; omit Holder to freeze every holder globally. Flags: 1 locks, Flags: 2 unlocks. A locked holder cannot send or receive the token until you reverse it.

// Freeze just this holder
await submit({
  TransactionType: 'MPTokenIssuanceSet',
  Account: issuer.address,
  MPTokenIssuanceID: issuanceId,
  Holder: holder.address,
  Flags: 1, // lock
}, issuer)

// ...later, lift it
await submit({
  TransactionType: 'MPTokenIssuanceSet',
  Account: issuer.address,
  MPTokenIssuanceID: issuanceId,
  Holder: holder.address,
  Flags: 2, // unlock
}, issuer)

This only works because you set canLock at issuance. Without it, no one — not even the issuer — can ever freeze the token. That permanence is a feature: it lets an issuer credibly promise "your tokens can never be frozen."

Clawback uses the Clawback transaction to pull tokens back out of a holder's balance into the issuer. The Amount uses the same MPT object form, and Holder names the target:

await submit({
  TransactionType: 'Clawback',
  Account: issuer.address,
  Holder: holder.address,
  Amount: {
    mpt_issuance_id: issuanceId,
    value: '500',
  },
}, issuer)

Re-query the holder's account_objects and the MPTAmount is back to 0. Like locking, clawback is gated on a creation flag — canClawback. An issuer who wants to signal "I can never take these back" simply omits flag 64, and the chain enforces that promise forever.

That combination of choose-once flags is what makes MPTs a real tool for regulated issuance: the rules of the asset are public, on-ledger, and immutable, so a holder can verify exactly what powers the issuer kept and what powers they permanently gave up.

Check

You're done when a single script, in one run, issues an MPT with requireAuth + canLock + canTransfer + canClawback, prints the MPTokenIssuanceID, has the holder opt in and the issuer authorize them, sends tokens with an object Amount, locks the holder's balance with MPTokenIssuanceSet, then claws back the full amount — with every transaction confirmed tesSUCCESS. Capture the issuance ID and the broadcast tx hashes, and confirm them on the Testnet Explorer. Submit a repo or gist containing your mpt.ts (plus the IDs and hashes) for a peer to verify.

Assignments

0 of 2 complete