Encode JSON data into transaction memo fields using hex conversion. Build a memo parser to decode memos from on-chain transactions.
What you'll learn
A payment moves value, but it rarely travels alone. An invoice number, an order id, a short note to the recipient — somewhere there's context that says what this transfer was for. On the XRP Ledger you can pin that context directly to the transaction itself, using the Memos field. Once the transaction is validated, the note lives on-chain forever, right next to the money it describes.
In this module you'll do both halves of that conversation. First you'll write a memo: take a plain JavaScript object, encode it the way the ledger expects, and attach it to a Payment. Then you'll read memos back: subscribe to an account's transactions and decode each note into readable JSON the moment it arrives. By the end you'll have a small memos.ts that round-trips a JSON object through the ledger and proves the decoded data matches what you sent.
This builds straight on the previous module — you'll reuse the same testnet connection, funded wallets, and submitAndWait flow. The only new idea is the Memos field and the hex encoding it requires.
Memos is an array. That's the first thing to internalise: a single transaction can carry several memos, so even when you attach just one note you wrap it in a one-element list. Each element is an object with a single Memo key, and inside that the memo's content lives in fields like MemoData:
const memo = {
Memo: {
MemoData: memoHex, // <-- hex string, not raw text
},
}
The catch lives in that comment. The ledger does not store your memo as human text — it stores hexadecimal. Anything you put in MemoData (and the optional MemoType/MemoFormat sibling fields) must be a hex string. Hand it raw text or JSON and the transaction will be rejected. So the real work of "writing a memo" is just encoding: turn your data into a string, then turn that string into hex.
Pick whatever shape your application needs — here we'll model a tiny invoice. Serialise it with JSON.stringify, then convert the UTF-8 bytes to a hex string with Node's Buffer:
const memoData = {
invoice_id: '12345',
description: 'Coffee and Milk',
date: '2026-06-17',
}
const memoJson = JSON.stringify(memoData)
const memoHex = Buffer.from(memoJson, 'utf8').toString('hex')
Buffer.from(text, 'utf8') reads your string as raw bytes; .toString('hex') renders those bytes as a hex string. That memoHex is exactly what MemoData wants. There's nothing XRPL-specific about this step — it's the same encoding you'd use for any binary-safe transport — which is why it's reliable in both directions.
Now fold the memo into a Payment. This is the familiar transaction from the payments module with one extra field: a Memos array holding our single encoded entry.
import * as xrpl from 'xrpl'
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233')
async function main() {
await client.connect()
const { wallet: sender } = await client.fundWallet()
const { wallet: receiver } = await client.fundWallet()
const memoData = {
invoice_id: '12345',
description: 'Coffee and Milk',
date: '2026-06-17',
}
const memoHex = Buffer.from(JSON.stringify(memoData), 'utf8').toString('hex')
const tx: xrpl.Payment = {
TransactionType: 'Payment',
Account: sender.classicAddress,
Destination: receiver.classicAddress,
Amount: xrpl.xrpToDrops('1'),
Memos: [
{
Memo: {
MemoData: memoHex,
},
},
],
}
const result = await client.submitAndWait(tx, { autofill: true, wallet: sender })
const code = result.result.meta?.TransactionResult
if (code !== 'tesSUCCESS') throw new Error(`Memo payment failed: ${code}`)
console.log('Memo sent, tx hash:', result.result.hash)
await client.disconnect()
}
main()
Run it with npx tsx memos.ts. On tesSUCCESS, the note is now part of a validated ledger. Paste the printed hash into the Testnet Explorer and you'll see your memo listed under the transaction — though the explorer shows it as hex, which is a nice reminder of why the next half matters.
Reading is the mirror image of writing. Take the MemoData hex string, turn it back into UTF-8 text, then parse it as JSON:
const memoDataHex = memo.Memo.MemoData
const memoDataJson = Buffer.from(memoDataHex, 'hex').toString('utf8')
const memoDataObject = JSON.parse(memoDataJson)
Buffer.from(hex, 'hex') reverses the encoding — hex string back to raw bytes — and .toString('utf8') reads those bytes as text. Because a transaction can hold several memos, wrap this in a small helper that walks the whole array and merges what it finds. Guard each step: not every memo is JSON, and not every memo even has MemoData, so a defensive parser won't crash on someone else's note.
function parseMemo(memos?: { Memo: { MemoData?: string } }[]): Record<string, unknown> {
if (!memos) return {}
const parsed: Record<string, unknown> = {}
for (const { Memo } of memos) {
if (!Memo?.MemoData) continue
const text = Buffer.from(Memo.MemoData, 'hex').toString('utf8')
try {
Object.assign(parsed, JSON.parse(text))
} catch {
// not JSON — keep the raw text under a fallback key
parsed._raw = text
}
}
return parsed
}
The try/catch is the important habit: memos are free-form, so anyone can put plain text or another format in MemoData. Your decoder should degrade gracefully rather than throw on the first non-JSON note it meets.
Decoding a memo you already have is one thing; catching them as they land is where this gets useful. xrpl.js lets you subscribe to an account and receive every transaction that touches it. Tell the client which account to watch, then listen on the transaction event and run each one through parseMemo:
await client.request({
command: 'subscribe',
accounts: [receiver.classicAddress],
})
client.on('transaction', (event: any) => {
const memos = event.transaction?.Memos
const decoded = parseMemo(memos)
if (Object.keys(decoded).length > 0) {
console.log('Incoming memo:', decoded)
}
})
Leave that listener running, send the Payment from earlier to receiver, and you'll watch the original { invoice_id, description, date } object print to your console — reconstructed byte-for-byte from the hex on the ledger. That's the full round trip: object in, hex on-chain, object out.
A few things to keep in mind as you build. Memos are public and permanent — never put secrets, personal data, or anything you'd regret in them. They also cost space, which affects the transaction fee, so keep them small; memos are for short references and identifiers, not for storing files. And because anyone can attach a memo claiming anything, treat incoming memo data as untrusted input: validate its shape before you act on it.
You're done when your memos.ts, in one flow, encodes a JSON object to hex, sends a Payment carrying it in the Memos array, confirms tesSUCCESS, and then decodes that same memo back to JSON — either by reading the validated transaction or by catching it on a live subscription — so the recovered object matches what you sent. Capture the transaction hash and confirm the memo on the Testnet Explorer. Submit a repo or gist containing your memos.ts (writer plus parseMemo) and the broadcast tx hash for a peer to verify.
Assignments
0 of 2 complete