Build an encrypted messaging system using XRPL transaction memo fields. Learn public-key cryptography, Ed25519 to X25519 key conversion, and NaCl box encryption.
What you'll learn
Every transaction on the XRP Ledger can carry a memo — a small free-form field that rides along with the payment and is stored, forever, in the validated ledger. People use memos for invoice numbers, order IDs, or short notes. In this module you'll use them for something more ambitious: a working, end-to-end encrypted chat where the messages travel as transactions and only the intended recipient can read them.
Here's the tension that makes this interesting. The ledger is public. Anyone can pull up your transaction in an explorer and read the memo verbatim. So if you just drop "meet me at 8" into a memo, you've published it to the world. Encryption is what lets you use a public bulletin board as a private channel: you scramble the message so that the bytes sitting on-chain are meaningless to everyone except the one person holding the right key.
Keep one honest caveat in mind throughout: nothing is encrypted forever. The ciphertext is permanent and public, and the cryptography that protects it today is only as strong as today's math and today's key secrecy. You're building a private channel over a permanent public record — a great way to learn applied cryptography, not a substitute for a hardened secure-messaging app. With that framing set, let's build it.
You want sender Alice to put a message on-chain that only recipient Bob can read. That calls for public-key (asymmetric) cryptography: Bob has a key pair — a public key he can hand out freely, and a private key he guards. Alice encrypts to Bob's public key; only Bob's private key can undo it.
We'll use tweetnacl (the JavaScript port of NaCl) and specifically its box primitive. box is authenticated public-key encryption: it takes the recipient's public key and the sender's private key, mixes them into a shared secret, and produces ciphertext that Bob can both decrypt and verify came from Alice. That mutual ingredient is why decryption later needs both keys too.
There's one wrinkle. XRPL accounts use Ed25519 keys — a curve tuned for signing. NaCl's box needs X25519 keys — the same underlying curve, but the "Montgomery" form used for key exchange. So before any encryption happens, we convert Ed25519 keys into X25519 keys. The math is standard and lossless; we just call a helper.
Create a project and install the three pieces you need: the ledger client, the crypto box, and the curve-conversion helpers.
npm init -y
npm i xrpl tweetnacl @noble/curves
npm i -D typescript tsx @types/node
Every XRPL Wallet exposes wallet.publicKey and wallet.privateKey as hex strings. For Ed25519 wallets these are prefixed with the algorithm marker ED, so when we feed them into the curve converter we slice off that two-character prefix first.
@noble/curves gives us edwardsToMontgomeryPub and edwardsToMontgomeryPriv — they take the raw Ed25519 key bytes and return the X25519 key bytes that box expects.
import { Buffer } from 'buffer'
import {
edwardsToMontgomeryPub,
edwardsToMontgomeryPriv,
} from '@noble/curves/ed25519'
// recipientPublicKey / senderSecretKey are hex strings from a Wallet,
// e.g. wallet.publicKey and wallet.privateKey ("ED..." prefixed).
function toCurveKeys(recipientPublicKey: string, senderSecretKey: string) {
const pubKeyBytes = Buffer.from(recipientPublicKey.slice(2), 'hex')
const secretKeyBytes = Buffer.from(senderSecretKey.slice(2), 'hex')
return {
pubKeyCurve: edwardsToMontgomeryPub(pubKeyBytes),
privKeyCurve: edwardsToMontgomeryPriv(secretKeyBytes),
}
}
The .slice(2) drops the ED prefix so only the 32-byte key material is decoded. Do this conversion on both ends — sender and recipient — and the keys will line up.
Now the core. box(message, nonce, theirPublicKey, mySecretKey) returns the ciphertext. The nonce is a one-time random value generated with randomBytes(box.nonceLength); it doesn't need to be secret, but it must travel with the ciphertext so the other side can decrypt. We bundle both as base64 inside a small JSON object.
import tweetnacl from 'tweetnacl'
import { Buffer } from 'buffer'
import {
edwardsToMontgomeryPub,
edwardsToMontgomeryPriv,
} from '@noble/curves/ed25519'
const { box, randomBytes } = tweetnacl
export function encryptMessage(
message: string,
recipientPublicKey: string,
senderSecretKey: string,
): string {
const pubKeyBytes = Buffer.from(recipientPublicKey.slice(2), 'hex')
const secretKeyBytes = Buffer.from(senderSecretKey.slice(2), 'hex')
const nonce = randomBytes(box.nonceLength)
const messageUint8 = Buffer.from(message)
const pubKeyCurve = edwardsToMontgomeryPub(pubKeyBytes)
const privKeyCurve = edwardsToMontgomeryPriv(secretKeyBytes)
const encryptedMessage = box(messageUint8, nonce, pubKeyCurve, privKeyCurve)
return JSON.stringify({
encrypted: Buffer.from(encryptedMessage).toString('base64'),
nonce: Buffer.from(nonce).toString('base64'),
})
}
The returned string — {"encrypted":"…","nonce":"…"} — is the entire encrypted payload. It carries everything Bob needs except the keys, which he already has.
A memo's MemoData must be hex-encoded, so convert your JSON payload to hex and attach it to a Payment. The XRP amount is almost incidental here — you're sending a tiny payment purely so the memo lands on-chain.
import * as xrpl from 'xrpl'
const cypherMessage = encryptMessage(
'meet me at 8',
recipientWallet.publicKey, // Bob's public key
senderWallet.privateKey, // Alice's private key
)
const tx: xrpl.Payment = {
TransactionType: 'Payment',
Account: senderWallet.classicAddress,
Destination: recipientWallet.classicAddress,
Amount: '10000000', // 10 XRP, in drops
Memos: [
{
Memo: {
MemoData: Buffer.from(cypherMessage).toString('hex'),
},
},
],
}
const result = await client.submitAndWait(tx, { autofill: true, wallet: senderWallet })
console.log('Sent encrypted memo:', result.result.hash)
Open that transaction in the Testnet Explorer and you'll see the memo — but only as opaque hex that decodes to base64 noise. The message is on-chain and unreadable. That's the whole point.
On the receiving side, Bob subscribes to his account and watches for transactions that carry memos. client.request({ command: 'subscribe', accounts: [address] }) opens the stream; the 'transaction' event fires for each new transaction touching the account.
await client.connect()
await client.request({
command: 'subscribe',
accounts: [recipientWallet.classicAddress],
})
client.on('transaction', (txEvent: any) => {
const memos = txEvent?.transaction?.Memos
if (!memos) return
for (const m of memos) {
const memoHex = m?.Memo?.MemoData
if (!memoHex) continue
// hex -> the JSON payload string we built when encrypting
const payload = Buffer.from(memoHex, 'hex').toString('utf8')
console.log('Encrypted payload:', payload)
// ...hand `payload` to decryptMessage (next section)
}
})
First you reverse the encoding (hex -> utf8) to recover the { encrypted, nonce } JSON string. Then you decrypt it.
Decryption is the mirror image of encryption: convert keys, parse the payload, and call box.open with the keys swapped to this side. Bob uses Alice's public key and his own private key — the same shared secret, derived from the opposite direction.
export function decryptMessage(
messageWithNonce: string,
recipientPublicKey: string, // the *sender's* public key, from Bob's view
senderSecretKey: string, // the *recipient's* private key, from Bob's view
): string {
const pubKeyBytes = Buffer.from(recipientPublicKey.slice(2), 'hex')
const secretKeyBytes = Buffer.from(senderSecretKey.slice(2), 'hex')
const pubKeyCurve = edwardsToMontgomeryPub(pubKeyBytes)
const privKeyCurve = edwardsToMontgomeryPriv(secretKeyBytes)
const { encrypted, nonce } = JSON.parse(messageWithNonce)
const messageBytes = Buffer.from(encrypted, 'base64')
const nonceBytes = Buffer.from(nonce, 'base64')
const decryptedMessage = box.open(messageBytes, nonceBytes, pubKeyCurve, privKeyCurve)
if (!decryptedMessage) {
throw new Error('Failed to decrypt message')
}
return new TextDecoder().decode(decryptedMessage)
}
box.open returns null if the keys don't match or the ciphertext was tampered with — that's the authenticated part doing its job, so always check for the null case rather than trusting whatever comes back. Wire it into your listener and you have a live, decrypting inbox: a transaction arrives, you pull the hex memo, decode it, and print "meet me at 8" in your terminal while the explorer still shows only noise.
A practical note: for Alice and Bob to message each other, each side needs the other's public key out of band. Exchanging public keys is the part the chat layer has to arrange — the encryption assumes you already know who you're talking to.
You're done when, in one demo, you encrypt a message to a recipient's public key, send it inside a transaction memo on testnet (confirmed with a tesSUCCESS hash you can open in the Testnet Explorer), and a listener detects that memo, decodes it from hex, and prints the decrypted plaintext — while the on-chain memo remains unreadable to anyone else. Submit a repo or gist with your encrypt.ts, your listener/decrypt.ts, and the broadcast tx hash for a peer to verify.
Resources
Assignments
0 of 2 complete