Hands-on coding: connect to the XRP Ledger testnet, create funded wallets, and transfer XRP between accounts using xrpl.js. Includes a "print money" utility for testing.
What you'll learn
In this module you stop reading about the XRP Ledger and start moving value on it. By the end you'll have a single TypeScript file that connects to the public testnet, conjures two funded accounts out of thin air, sends XRP from one to the other, and proves the transfer landed — all in a few seconds and without spending a cent of real money.
Everything here runs against the testnet, a full copy of the XRP Ledger whose XRP has no monetary value. That's what makes it the perfect playground: a faucet hands you 100 test-XRP per wallet on demand, so you can break things, retry, and experiment freely. The same code, pointed at a mainnet endpoint and funded with real XRP, would behave identically — payments are payments.
We'll lean on xrpl.js, the official JavaScript/TypeScript client. It handles the WebSocket connection, key management, transaction autofilling (sequence numbers, fees), signing, and submission — so you can focus on the intent of a payment rather than the wire format.
Create a new project and install the one dependency you need:
npm init -y
npm i xrpl
npm i -D typescript tsx @types/node
Create payment.ts and open a connection to the testnet. The endpoint is a WebSocket URL — xrpl.js keeps a live socket open so it can stream ledger updates and wait for your transaction to be validated.
import * as xrpl from 'xrpl'
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233')
async function main() {
await client.connect()
console.log('Connected to the XRPL testnet')
// ... everything below goes here ...
await client.disconnect()
console.log('Done')
}
main()
Run it any time with npx tsx payment.ts. If you see "Connected to the XRPL testnet", your socket is open and you're ready to fund wallets.
A wallet on the XRP Ledger is just a keypair plus an address. But an address only becomes a real account once it holds at least the base reserve (currently 1 XRP on testnet). client.fundWallet() does both steps for you: it generates a fresh keypair and asks the testnet faucet to send it 100 XRP, activating the account.
const { wallet: wallet1, balance: balance1 } = await client.fundWallet()
const { wallet: wallet2, balance: balance2 } = await client.fundWallet()
console.log({
address1: wallet1.address,
balance1,
address2: wallet2.address,
balance2,
})
Each call returns a Wallet object (which carries the keys you'll sign with) and the starting balance. Copy one of the addresses into the Testnet Explorer and you'll see the funding transaction already recorded on-chain.
Now the main event. A Payment transaction needs three things: who's sending (Account), who's receiving (Destination), and how much. Amounts in XRP are always expressed in drops — the ledger's smallest unit, where 1 XRP = 1,000,000 drops. Use xrpl.xrpToDrops() so you never have to count zeros.
const tx: xrpl.Payment = {
TransactionType: 'Payment',
Account: wallet1.classicAddress,
Destination: wallet2.classicAddress,
Amount: xrpl.xrpToDrops('13'),
}
const result = await client.submitAndWait(tx, {
autofill: true,
wallet: wallet1,
})
submitAndWait does a lot of quiet work: with autofill: true it fills in the account Sequence, a network Fee, and the LastLedgerSequence; it signs the transaction with wallet1's keys; it submits it; and then it waits until the transaction is included in a validated ledger before resolving.
Once it resolves, the real source of truth is the transaction's metadata. A transaction can be submitted yet still fail — so always check the engine result code:
const code = result.result.meta?.TransactionResult
if (code === 'tesSUCCESS') {
console.log('Payment validated:', result.result.hash)
} else {
throw new Error(`Payment failed: ${code}`)
}
tesSUCCESS is the only code that means the ledger applied your payment. Anything else (tecUNFUNDED_PAYMENT, tecNO_DST, …) tells you exactly what went wrong.
Don't take the result code's word for it — read the ledger directly. client.getXrpBalance(address) returns the account's current XRP balance as a number. Capture balances before and after the payment so the transfer is visible:
const before2 = await client.getXrpBalance(wallet2.address)
// ... submit the Payment from the previous section ...
const after1 = await client.getXrpBalance(wallet1.address)
const after2 = await client.getXrpBalance(wallet2.address)
console.log({ before2, after1, after2 })
Two things to notice. wallet2 went up by exactly 13 XRP. wallet1 went down by 13 XRP plus the transaction fee (a tiny fraction of a drop on testnet) — payments are not free, even when the amount is round. And neither account can spend below its reserve: the ledger keeps the base reserve (1 XRP) locked to keep the account alive, so a balance is never the same as "spendable XRP".
Once you start testing, you'll constantly need to top accounts back up. Wrap the fund-and-forward pattern into a helper. printMoney spins up a brand-new funded wallet from the faucet, then forwards XRP from it to whatever destination you hand it — effectively an on-demand money printer for testnet.
// utils.ts
import * as xrpl from 'xrpl'
export async function printMoney(
client: xrpl.Client,
destination: xrpl.Wallet,
amount = '90',
): Promise<string> {
const { wallet: faucetWallet } = await client.fundWallet()
const tx: xrpl.Payment = {
TransactionType: 'Payment',
Account: faucetWallet.classicAddress,
Destination: destination.classicAddress,
Amount: xrpl.xrpToDrops(amount),
}
const result = await client.submitAndWait(tx, {
autofill: true,
wallet: faucetWallet,
})
const code = result.result.meta?.TransactionResult
if (code !== 'tesSUCCESS') throw new Error(`printMoney failed: ${code}`)
console.log(`Topped up ${destination.address}:`, await client.getXrpBalance(destination.address))
return result.result.hash
}
Call it whenever an account runs low:
import { printMoney } from './utils'
await printMoney(client, wallet2) // forwards the default 90 XRP
await printMoney(client, wallet2, '250') // or any amount you like
We forward up to ~90 XRP because each fresh faucet wallet starts with 100 and must keep its own 1 XRP reserve plus a fee. Need more? Call printMoney again — every call mints a fresh source wallet.
You're done when you have a single script that, in one run, funds two testnet wallets, prints both balances before the transfer, sends a Payment from one to the other, confirms the engine result is tesSUCCESS, and prints both balances afterward so the transfer is visible. Capture the validated transaction hash and confirm it on the Testnet Explorer. Submit a repo or gist containing your payment.ts (and the broadcast tx hash) for a peer to verify.
Resources
Assignments
0 of 3 complete