XRP NFTs Minting and Trading Dapp

21 min read

An XRPL Hackathon Project

For this project I made use of the XLS-20 Standard preliminary implementation in the XRP Ledger protocol to create a dapp for NFT minting, management and trading. Build with XRPL, MongoDB and Svelte.

cool gif

Problem and solutions

Following the XRPL NFT minting examples I found 3 big problems:

  • For a non-developer create a NFT on XRP is hard (and there is no website to do so!)
  • The example tutorial uses a predefined IPFS url, it does not support image with custom metadata
  • There are lots of parameters that need to be known by user to interact with the NFTs, making it confusing and tedious

My solution: The easy, no-code, and convenient way to create, manage and trade your own XLS-20 NFT in XRPL

  • Simple, Easy To use UI
  • Upload Custom Images and descriptions, Images are added to IPFS via Pinanta
  • No Configuration, Create Buy and Sell Orders, Manage your NFTs, and trade the on the market

Go mint an NFT on XRPL: https://xrp-nft-minter-hackathon.vercel.app/

Repo: https://github.com/Kayaba-Attribution/XRP-NFTs-Hackathon

Video (This was a one-take before a lot of improvements): https://www.youtube.com/watch?v=N9tE9FJ_zfM&t=23s

XRPL NFT Conceptual Overview

The XLS-20 standard for NFTs has a preliminary implementation that can be used in test networks, but is not yet available as an amendment to the XRP Ledger protocol. An amendment may be included in a future XRP Ledger release.

Fungible tokens can be easily traded between users for XRP or other issued assets on the XRP Ledger’s decentralized exchange. This makes them ideal for payments.

NFT Extensions

Extensions to the XRP Ledger support two new objects and a new ledger structure.

The NFToken is a native NFT type. It has operations to enumerate, purchase, sell, and hold such tokens. An NFToken is a unique, indivisible unit that is not used for payments.

The NFTokenPage object contains a set of NFToken objects owned by the same account.

General Notes

By following a cool tutorial in the XRP Ledger docs Here, I got the sufficient knowledge to use as a fondation and create my dapp. Coming from a 100% EVM compatible blockchain this was quite different but cool.

Before all the txns and interactions we must first connect to the XRPL, in order to use this we need a wallet “secret” this would be the equivalent of your ETH private key or seed phrase. Although not explicit in the docs it seems that we must connect and disconnect from the ledger on every transaction.

To obtain your NFT-Devnet Cretendials you need to request from this website

Ledger Connection

The connection always follow this format:

async function foo() {
    // build a wallet from the secret
    const wallet = xrpl.Wallet.fromSeed(secret)
    // create a client using the NFT testnet webhook url
    const client = new xrpl.Client("wss://xls20-sandbox.rippletest.net:51233")
    // establish the connection
    await client.connect()
    console.log("Connected to devnet")

Send a TX

Because XRPL does not offer Smart Contracts it makes use of pre-defined methods in order to send a tx that changes the state of the chain the docs recomment this format:

    // build the transaction with the info you need
    const transactionBlob = {
        // specify the method
        TransactionType: "NFTokenMint",
        // Method parameters
        Account: wallet.classicAddress,
        URI: xrpl.convertStringToHex(tokenUrl.value),
        Flags: parseInt(flags.value),
        TokenTaxon: 0
    }
    // send the tx with your wallet signer
    const tx = await client.submitAndWait(transactionBlob,{wallet})
    // optinal: get the balance changes for this tx
    console.log("Balance changes:", JSON.stringify(xrpl.getBalanceChanges(tx.result.meta), null, 2))

Read from the ledger

Because we are not making any changes to the ledger there is no need for a signer so we can read (again using methods) like this:

async function foo() {
    const client = new xrpl.Client("wss://xls20-sandbox.rippletest.net:51233")
    await client.connect()
    const nfts = await client.request({
        method: "account_nfts",
        account: ANY_ACCOUNT  
    })
    console.log(nfts)
}

Technical Dive

Upload Images and their decription to IPFS

The easiest way to pin JSON objects to IPFS is to use Pinata, now the recommended way to do this is to use the Pinata SDK but as I was only using this function I found this solution easier. I have a global object info which get populated with inputs fields (name, description), the image is upladed and automatically encoded into base64 string by Svelte, finally the issuer is the address that generates the secret.

    const pinJSONToIPFS = async () => {
        const info = {
            "name": "Kayaba Test",
            "description": "XLS-20 standard for NFTs testing", 
            "issuer": "",
            "image": "", 
        }
        let url = `https://api.pinata.cloud/pinning/pinJSONToIPFS`;
        const response = await fetch(url, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                pinata_api_key: pinataApiKey,
                pinata_secret_api_key: pinataSecretApiKey
            },
            body: JSON.stringify(info)
        });
        var res = await response.json()
        hash = res.IpfsHash
        console.log(hash)
    }

Minting A NFT [NFTokenMint]

XRPL uses flags to categorize NFTs types as follows:

  • 1 -> Burnable
  • 2 -> Only XRP
  • 4 -> TrustLine Creates a trust line from the creator to get the transfer fees
  • 8 -> Transferable if not present only can be from or to the creator

The NFTokenMint basic field needed are the following

    // build the transaction with the info you need
    const transactionBlob = {
        // specify the method
        TransactionType: "NFTokenMint",
        // Method parameters
        Account: wallet.classicAddress,
        // URI the IPFS Url
        URI: xrpl.convertStringToHex(tokenUrl.value),
        // Type of NFT
        Flags: 8,
        // Optional Identifier
        TokenTaxon: 0
    }
    // send the tx with your wallet signer
    const tx = await client.submitAndWait(transactionBlob,{wallet})

And the result of the txn:

{
    "id": 5,
    "result": {
        "Account": "r4B8gFnKqqTGEYjAd7r1BssWUFwCDr7jQ",
        "Fee": "12",
        "Flags": 8,
        "LastLedgerSequence": 401768,
        "Sequence": 395447,
        "SigningPubKey": "03E47F0A51AB5A769EC5BE0CE4BECBF2CA1DEE53C9775BD38DDBDC446A5C43AB2E",
        "TokenTaxon": 0,
        "TransactionType": "NFTokenMint",
        "TxnSignature": "3045022100A915521644B6D0B4E87E18EB06DDFD45EAC9BCBA76640689530EA18DE170C05602200B65B0607206EFD4D44D8796D30A818464A30C1944FC988D55E0378591B6CECA",
        "URI": "68747470733A2F2F676174657761792E70696E6174612E636C6F75642F697066732F516D565A504134735045336739574271666B6F71667332765A636438514E48376E37704B7363554C36454C6A3550",
        "date": 700703210,
        "hash": "5759FE4C8A33D7C9F3483E7CCD45F638D57FE7B530133C5EFC23E98A17A75FE7",
        "inLedger": 401750,
        "ledger_index": 401750,
        "meta": {
            "AffectedNodes": [
                {
                    "ModifiedNode": {
                        "FinalFields": {
                            "Flags": 0,
                            "NonFungibleTokens": [...]
                        },
                        "LedgerEntryType": "NFTokenPage",
                        "LedgerIndex": "0401BD852B9C9E81E196CE0C687774732377FE31FFFFFFFFFFFFFFFFFFFFFFFF",
                        "PreviousFields": {
                            "NonFungibleTokens": [...]
                        },
                        "PreviousTxnID": "0A4E17DB6BC2ECDCCDDB9DACA72EFD0468A5557964A7C8617E3CF5DF6FECF1FD",
                        "PreviousTxnLgrSeq": 395984
                    }
                },
                {
                    "ModifiedNode": {
                        "FinalFields": {
                            "Account": "r4B8gFnKqqTGEYjAd7r1BssWUFwCDr7jQ",
                            "Balance": "9999999880",
                            "Flags": 0,
                            "MintedTokens": 5,
                            "OwnerCount": 6,
                            "Sequence": 395448
                        },
                        "LedgerEntryType": "AccountRoot",
                        "LedgerIndex": "F77E0898A6D50B851E98C888FE9E1DF3F69C920FF200E4765B156881E6BD888C",
                        "PreviousFields": {
                            "Balance": "9999999892",
                            "MintedTokens": 4,
                            "Sequence": 395447
                        },
                        "PreviousTxnID": "D45215EAAED7D6AEED4038AF89EDA28DBB2499FD0CE0CE455E9463BBA9301D1C",
                        "PreviousTxnLgrSeq": 396000
                    }
                }
            ],
            "TransactionIndex": 0,
            "TransactionResult": "tesSUCCESS"
        },
        "validated": true
    },
    "type": "response"
}

In order to track the NFTs that are being minted in the Dapp I made use of MongoDB, on each Mint Success the info of the token is saved in the database, now I assumed that the ID of the NFTs would match the order of creation and the reponse but it turns out that this is not the case.

To find the tokenId of the latest NFT created by the wallet I had to make 2 account_nfts calls that returns an array of the NFTs held by the account then format the response,compare them and get the difference. Here is the function:

export async function findNewTokenId(before, after){
    let _before = []
    let _after = []

    console.log(before)
    console.log(after)

    for (let i = 0; i < after.length; i++) {
        _after.push(after[i].TokenID)
        if(i < before.length){
            _before.push(before[i].TokenID)
        }
    }

    let difference = _after.filter(x => !_before.includes(x));

    console.log("New Token ID:", difference)
    return difference
}

All of the other Transactions Methods can be found in the documentation

Wallet Page

Wallet

First I thought that I only needed to call account_nfts and display the NFTs but remember that when we mint a NFT the URI is converted to Hex? Then, I had to converted back to a string.

But thats not all! What is the URI? an IPFS Url! I had to make an extra call for every URI get the info and save it. Then I could display the NFTs.

    let nfts = []
    let CleanNFTs = []

    async function getNFTS() {
        const wallet = xrpl.Wallet.fromSeed($secret)
        const client = new xrpl.Client("wss://xls20-sandbox.rippletest.net:51233")
        await client.connect()
        console.log("Connected to Sandbox")
    
        //get all NFTs for address
        const res = await client.request({
            method: "account_nfts",
            account: wallet.classicAddress
        })

        nfts = res.result.account_nfts
        console.log(nfts)

        //Convert URI to String and make the extra call
        //save the json obejct into the CleanNFTs array
        var obj;
        for (let i = 0; i < nfts.length; i++) {
            let url = xrpl.convertHexToString(nfts[i].URI)
            const res = await fetch(url)
                .then(res => res.json())
                .then(data => obj = data)
            console.log("Response:", obj)
            CleanNFTs.push(obj)   
        }
        console.log("NFTs", nfts)
        console.log("NFTs", CleanNFTs)
        

        client.disconnect()
        return CleanNFTs
    } 
    // refresh
    async function refreshInfo() {
        nfts = []
        CleanNFTs = []
        CleanNFTs = await getNFTS()
    }

All NFTs Page

In comparison to the previous page this was super easy. API call and display the NFTs

	let nfts = [];
    onMount(async () => {
        const res = await fetch(`/api/nfts`);
        nfts = (await res.json()).reverse()
    })

Wallet

Handling the Secret Global State

Unlike in metamask where users can connect to a site I had to create a system that allows the app to use the secret and something to store the secret over sessions.

Decided to store the secret in localStorage (I know is not a best practice) it seemed enough for a hackathon, I used these 2 to save and load the secret

export async function loadSecretFromLocal(){
    if(localStorage.getItem("secret")){
        await addWallet(localStorage.getItem("secret"))
    } else{
        console.log("No Secret in Storage")
    }
}

export async function saveSecretLocal(_val){
    try {
        localStorage.setItem("secret", _val)
    } catch (e) {
        console.log("Error on secret save", e)
    }
}

To allow all the pages to access the info I create stores for the variables that I needed

export const secret = writable('')
export const address = writable('')
export const balance = writable('0')
export const nativeBalanceUSD = writable('0')

To connect a wallet I used a helper function that loads the secret in localstorage, updates the stores, and fetches the wallet balance and calulates the value in USD

export async function addWallet(input_secret){
    const wallet = xrpl.Wallet.fromSeed(input_secret)
    const client = new xrpl.Client("wss://xls20-sandbox.rippletest.net:51233")
    await client.connect()
    console.log("Connected to Sandbox")

    const response = await client.request({
        "command": "account_info",
        "account": wallet.classicAddress,
        "ledger_index": "validated"
    })
    //save secret localStorage
    saveSecretLocal(input_secret)
    //Update Stores With New Info
	secret.update(n => input_secret);
    // save accout 
    let _account = response.result.account_data.Account
    address.update(n => _account);
    // save balance 
    let _balance = response.result.account_data.Balance
    balance.update(n => _balance);
    // save nativeBalanceUSD 
    let _nativeBalanceUSD = Number(_balance)/10**7 * (await spotUSD("XRP")) + " USD"
    nativeBalanceUSD.update(n => _nativeBalanceUSD)

}

If you are curious about getting the spot price I re-used and old function from past projects:

// Most coins supported
export async function spotUSD(_symbol){
    let response = await fetch('https://api.binance.com/api/v3/ticker/price?symbol='+_symbol +'USDT');
    response = await response.json();
    let price = Number(response.price).toFixed(2)
    console.log(`💲 ${_symbol} to USD: ${price} USD 💲`)
    return price
}
// For more underground coins
export async function spotMTR(){
    let response = await fetch('https://api.coingecko.com/api/v3/coins/meter-stable');
    response = await response.json();
    let price = Number(response.market_data.current_price.usd).toFixed(2)
    console.log(`💲 MTR to USD: ${price} USD 💲`)
    return price
}

And that is pretty much it, I wont go into detail for the buy/sell orders because the XRPL Documentation already does it.

Extras

  • Most actions have error handling and load animations
  • Depending on the wallet that is connected buttons are rendered or not (only the creator of a buy offer can see the cancel offer button)
  • If you are the creator of a NFT then only you can see the burn button, and so on.

It was quite fun to create work on this project, it is definitely a new world coming from EVM but the XRPL is well implemented and documented with great examples. This project has the aim to allow any user to get their feet wet into the XRPL NFTs and have a great time doing so. In the future, I may work on this a little more, enable collections creation (instead of single NFTs), add search filters, integrate all the newly minted NFTs (not only the ones minted in the app) and create a royalty system. And of course, I have to work on the UI!

Go mint an NFT on XRPL: https://xrp-nft-minter-hackathon.vercel.app/

Repo: https://github.com/Kayaba-Attribution/XRP-NFTs-Hackathon

Video (This was a one-take before a lot of improvements): https://www.youtube.com/watch?v=N9tE9FJ_zfM&t=23s

Blog: https://www.kayaba-attribution.dev/posts/XRP%20NFTs%20Minting%20and%20Trading%20Dapp


Previous Post

Perpetual NFTs Protocol

16 min read

Create a DeFi lending Platform using NFTs as collateral, manage mint money with AAVE, and guarantee users a value! Showcase Presentation GitHub build with Polygon, AAVE, HardHat and Svelte.