advanced 90 min

Encrypted Chat on XRPL

Build an encrypted messaging system using XRPL transaction memo fields. Learn public-key cryptography, Ed25519 to X25519 key conversion, and NaCl box encryption.

Prerequisites

Complete these before starting this module:

What you'll learn

  • Explain why on-chain memos are public and what end-to-end encryption does (and does not) protect.
  • Convert an XRPL Ed25519 keypair into X25519 keys suitable for NaCl box encryption.
  • Encrypt a message with tweetnacl box and embed the ciphertext in a transaction MemoData field as hex.
  • Subscribe to an account, read MemoData back from hex, and decrypt incoming messages with box.open.
Complete this module by peer review. Jump to assessment

Overview

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.

The shape of the problem

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.

Setup

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.

Convert Ed25519 to X25519

@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.

Encrypt a message

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.

Put the ciphertext in a memo

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.

Listen for incoming messages

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.

Decrypt

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.

Check

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.

Assignments

0 of 2 complete