Mint NFTs on the XRP Ledger, set URI metadata, create sell offers, and manage NFT lifecycle including cancellation and burning.
What you'll learn
In the payments module you moved fungible value: one XRP is interchangeable with any other XRP. NFTs flip that idea on its head. A non-fungible token is a unique, indivisible ledger object — a deed, a ticket, a piece of art — and no two are the same. On the XRP Ledger, NFTs are a native feature. There's no smart contract to deploy and no token standard to copy-paste: minting, trading, and burning are first-class transaction types built into the protocol.
By the end of this module you'll have a TypeScript script that mints an NFT against the testnet, reads back its on-chain identifier, lists a sell offer, and accepts that offer from a second wallet — a complete marketplace round-trip in a single run. You already know the rhythm from payments: connect, fund wallets, build a transaction, submitAndWait, check for tesSUCCESS. NFTs are the same rhythm with new transaction types.
We'll keep using xrpl.js, and everything runs on the testnet, so the NFTs you mint cost nothing and live on a throwaway copy of the ledger.
One mental model to carry through: on most chains an NFT is a smart contract you author and audit, and a marketplace is more contract code on top. On the XRP Ledger the protocol already knows what an NFT is. That means fewer moving parts and fewer ways to get hacked — but it also means the rules are fixed by the ledger, not by you. You can't invent a brand-new royalty scheme in a contract; you work within the fields the protocol gives you. The upside is that those fields cover the vast majority of real use cases, and they behave identically for everyone.
If you're continuing from the payments module you already have what you need. Otherwise:
npm init -y
npm i xrpl
npm i -D typescript tsx @types/node
Create nft.ts, connect, and fund two wallets — a minter who will create and sell the NFT, and a buyer who will accept the offer:
import * as xrpl from 'xrpl'
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233')
async function main() {
await client.connect()
const { wallet: minter } = await client.fundWallet()
const { wallet: buyer } = await client.fundWallet()
console.log({ minter: minter.address, buyer: buyer.address })
// ... NFT logic goes here ...
await client.disconnect()
}
main()
Run it any time with npx tsx nft.ts.
Minting creates a brand-new NFT and assigns it to your account. The NFTokenMint transaction has a few fields worth understanding:
URI — a pointer to the NFT's metadata (an image, a JSON file on IPFS, anything). The ledger stores this as a hex string, not plain text, so you convert it with xrpl.convertStringToHex(). Note the ledger only stores the pointer — the metadata itself lives off-chain.NFTokenTaxon — an integer you choose to group related NFTs (think "collection ID"). It's required; use 0 if you don't need grouping.Flags — bitwise options that lock in the NFT's behavior forever at mint time. The two you'll use most are tfTransferable (the NFT can be traded to other accounts) and tfBurnable (the issuer can destroy it later).const uri = 'ipfs://bafybeigdyrexamplemetadata/metadata.json'
const mintTx: xrpl.NFTokenMint = {
TransactionType: 'NFTokenMint',
Account: minter.address,
URI: xrpl.convertStringToHex(uri),
NFTokenTaxon: 0,
Flags: xrpl.NFTokenMintFlags.tfBurnable | xrpl.NFTokenMintFlags.tfTransferable,
}
const mintResult = await client.submitAndWait(mintTx, {
autofill: true,
wallet: minter,
})
if (mintResult.result.meta && typeof mintResult.result.meta !== 'string') {
if (mintResult.result.meta.TransactionResult !== 'tesSUCCESS') {
throw new Error(`Mint failed: ${mintResult.result.meta.TransactionResult}`)
}
}
Without tfTransferable, the NFT can only ever move between its issuer and one other account — fine for a non-resellable ticket, wrong for a marketplace item. Because flags are permanent, decide deliberately before you mint.
There's one more field worth knowing even though we won't set it here: TransferFee. It lets the issuer collect a royalty — a percentage of every secondary sale — automatically enforced by the ledger. It's why protocol-native NFTs are interesting for creators: the royalty isn't a marketplace's polite suggestion that a buyer can route around, it's a rule the network applies on every accepted offer. (Setting a TransferFee requires the issuer to have first enabled the relevant account setting, so we leave it at zero for this exercise.)
Every NFT gets a unique 64-character hex identifier, the NFTokenID, computed by the ledger at mint time. You'll need it for every subsequent operation, so grab it from the mint transaction's metadata:
const meta = mintResult.result.meta as xrpl.NFTokenMintMetadata
const nftId = meta.nftoken_id
console.log('Minted NFTokenID:', nftId)
You can also list everything an account owns with the account_nfts request — useful for confirming the mint landed and for building a wallet view:
const owned = await client.request({
command: 'account_nfts',
account: minter.address,
})
console.log(owned.result.account_nfts)
Each entry includes the NFTokenID, the (hex-encoded) URI, the NFTokenTaxon, and the flags — the full on-chain record of the token.
You don't transfer an NFT directly; you create an offer and someone accepts it. NFTokenCreateOffer works in two directions depending on its flags:
Flags: tfSellNFToken, value 1) — the NFT's current owner offers to sell it for Amount.Here the minter lists the NFT for 5 XRP. Amount is in drops, just like a payment, so use xrpl.xrpToDrops(). An Amount of "0" would make it a free gift:
const offerTx: xrpl.NFTokenCreateOffer = {
TransactionType: 'NFTokenCreateOffer',
Account: minter.address,
NFTokenID: nftId,
Amount: xrpl.xrpToDrops('5'),
Flags: xrpl.NFTokenCreateOfferFlags.tfSellNFToken,
}
const offerResult = await client.submitAndWait(offerTx, {
autofill: true,
wallet: minter,
})
Optionally you can add a Destination field to restrict the offer to a single buyer — handy for a directed sale where you've already agreed on a price off-chain. Leave it out and the offer is open to anyone.
It's worth pausing on why the ledger uses this offer-and-accept dance instead of a single "send NFT" transaction. The answer is authorization and payment in one step. An NFT has value, so you don't want it landing in an account that never agreed to receive it (and didn't pay). By requiring the counterparty to actively accept, the ledger guarantees both sides consented and the money and the token change hands together. A single token can even have many open offers at once — several buy offers and several sell offers — and they sit on the ledger until accepted or cancelled, which is exactly what a marketplace UI reads to show "current bids".
Accepting an offer requires the offer's ledger index, which is a different value from the NFTokenID. Query the open sell offers for the token with nft_sell_offers:
const offers = await client.request({
command: 'nft_sell_offers',
nft_id: nftId,
})
const offerIndex = offers.result.offers[0].nft_offer_index
Now the buyer accepts. NFTokenAcceptOffer references the sell offer by its index. When it succeeds, ownership transfers to the buyer and the 5 XRP moves to the seller atomically — there's no window where one side has both the NFT and the money:
const acceptTx: xrpl.NFTokenAcceptOffer = {
TransactionType: 'NFTokenAcceptOffer',
Account: buyer.address,
NFTokenSellOffer: offerIndex,
}
const acceptResult = await client.submitAndWait(acceptTx, {
autofill: true,
wallet: buyer,
})
console.log('Sale complete:', acceptResult.result.hash)
Re-run the account_nfts request for both wallets and you'll see the NFT has left the minter and now sits with the buyer.
Two lifecycle operations round out the picture.
If a sale offer is no longer wanted, the account that created it withdraws it with NFTokenCancelOffer, passing the offer index in the NFTokenOffers array (you can cancel several at once):
const cancelTx: xrpl.NFTokenCancelOffer = {
TransactionType: 'NFTokenCancelOffer',
Account: minter.address,
NFTokenOffers: [offerIndex],
}
And when an NFT should cease to exist, the owner (or the issuer, if it was minted with tfBurnable) destroys it with NFTokenBurn. Burning is irreversible — the token is gone from the ledger for good:
const burnTx: xrpl.NFTokenBurn = {
TransactionType: 'NFTokenBurn',
Account: buyer.address,
NFTokenID: nftId,
}
That tfBurnable decision you made back at mint time matters here: it determines whether the issuer can reclaim and destroy an NFT after it's been sold, or whether only the current holder can.
You're done when you have a single script that, in one run, mints an NFT with a hex-encoded URI and the transferable flag, reads back its NFTokenID, creates a 5-XRP sell offer, looks up that offer's index, and accepts it from a second wallet — with every transaction returning tesSUCCESS. Confirm the ownership change by querying account_nfts for both wallets, and capture the three validated tx hashes (mint, create-offer, accept-offer). Submit a repo or gist containing your nft.ts and those hashes for a peer to verify on the Testnet Explorer.
Assignments
0 of 2 complete