Sending Non-XRP Payments
Learning Objectives
Send issued currency payments between accounts with trust lines
Use pathfinding to discover payment routes across currencies
Implement cross-currency payments that exchange through the DEX
Handle partial payments and understand their risks
Account for transfer fees in payment calculations
When you send XRP, it moves directly from your account to the destination. Simple.
- Both parties need trust lines to the same issuer (usually)
- The payment might ripple through intermediaries
- Cross-currency payments need exchange paths
- Issuers might charge transfer fees
- The delivered amount might differ from the sent amount
Understanding these mechanics is essential for building reliable applications.
The simplest case: both parties trust the same issuer.
// src/payments/send-token.js
const xrpl = require('xrpl');
async function sendToken(senderWallet, destination, currency, issuer, amount) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
// Verify sender has the tokens
const senderLines = await client.request({
command: 'account_lines',
account: senderWallet.address,
peer: issuer
});
const senderLine = senderLines.result.lines.find(
l => l.currency === currency
);
if (!senderLine || Number(senderLine.balance) < Number(amount)) {
throw new Error(Insufficient ${currency} balance);
}
// Verify recipient has trust line
const recipientLines = await client.request({
command: 'account_lines',
account: destination,
peer: issuer
});
const recipientLine = recipientLines.result.lines.find(
l => l.currency === currency
);
if (!recipientLine) {
throw new Error(Recipient has no trust line for ${currency});
}
// Build payment
const payment = {
TransactionType: 'Payment',
Account: senderWallet.address,
Destination: destination,
Amount: {
currency: currency,
issuer: issuer,
value: amount.toString()
}
};
console.log(Sending ${amount} ${currency} to ${destination});
const prepared = await client.autofill(payment);
const signed = senderWallet.sign(prepared);
const result = await client.submitAndWait(signed.tx_blob);
const txResult = result.result.meta.TransactionResult;
const delivered = result.result.meta.delivered_amount;
return {
success: txResult === 'tesSUCCESS',
hash: signed.hash,
resultCode: txResult,
delivered: delivered
};
} finally {
await client.disconnect();
}
}
module.exports = { sendToken };
```
Critical concept: The Amount field is what you requested. The delivered_amount is what actually arrived.
// Why they might differ:
// 1. Transfer fees charged by issuer
// 2. Partial payments (if enabled)
// 3. Exchange rate fluctuations (cross-currency)
// 4. Rounding in complex paths
// ALWAYS check delivered_amount for accounting
function verifyDelivery(result, expectedAmount) {
const delivered = result.result.meta.delivered_amount;
// For issued currencies, delivered_amount is an object
if (typeof delivered === 'object') {
const deliveredValue = Number(delivered.value);
const expectedValue = Number(expectedAmount);
if (deliveredValue < expectedValue) {
console.warn(Warning: Delivered ${deliveredValue}, expected ${expectedValue});
console.warn(Shortfall: ${expectedValue - deliveredValue});
}
return {
delivered: deliveredValue,
expected: expectedValue,
complete: deliveredValue >= expectedValue
};
}
// For XRP, it's a string of drops
return {
delivered: Number(delivered) / 1_000_000,
expected: Number(expectedAmount),
complete: true // XRP doesn't have transfer fees
};
}
```
XRPL can exchange currencies during payment using the native DEX:
Scenario: You have USD, recipient wants EUR
Path: USD → XRP → EUR
1. Your USD sold on DEX for XRP
2. XRP sold on DEX for EUR
3. EUR delivered to recipient
All atomic - either complete payment succeeds or nothing happens
Before sending cross-currency, discover available paths:
// src/payments/pathfind.js
const xrpl = require('xrpl');
async function findPaymentPaths(
sourceAccount,
destinationAccount,
destinationAmount
) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
const pathfind = await client.request({
command: 'ripple_path_find',
source_account: sourceAccount,
destination_account: destinationAccount,
destination_amount: destinationAmount
});
const alternatives = pathfind.result.alternatives;
console.log(`Found ${alternatives.length} payment paths:`);
return alternatives.map((alt, i) => {
console.log(`\nPath ${i + 1}:`);
console.log(` Source amount: ${formatAmount(alt.source_amount)}`);
console.log(` Paths through: ${alt.paths_computed.length} hops`);
return {
sourceAmount: alt.source_amount,
paths: alt.paths_computed
};
});
} finally {
await client.disconnect();
}
}
function formatAmount(amount) {
if (typeof amount === 'string') {
return `${Number(amount) / 1_000_000} XRP`;
}
return `${amount.value} ${amount.currency}`;
}
// Example: Find paths to deliver 100 EUR
async function example() {
const paths = await findPaymentPaths(
'rSenderAddress...',
'rRecipientAddress...',
{
currency: 'EUR',
issuer: 'rEURIssuer...',
value: '100'
}
);
// paths[0].sourceAmount tells you what you need to send
// paths[0].paths tells you the route to use
}
module.exports = { findPaymentPaths };
// src/payments/cross-currency.js
const xrpl = require('xrpl');
async function sendCrossCurrency(
senderWallet,
destination,
deliverAmount, // What recipient should get
sendMax, // Max you're willing to spend
paths = [] // Optional: pre-computed paths
) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
// If no paths provided, find them
if (paths.length === 0) {
const pathResult = await client.request({
command: 'ripple_path_find',
source_account: senderWallet.address,
destination_account: destination,
destination_amount: deliverAmount
});
if (pathResult.result.alternatives.length === 0) {
throw new Error('No payment paths found');
}
paths = pathResult.result.alternatives[0].paths_computed;
// Also get recommended source amount
const recommendedMax = pathResult.result.alternatives[0].source_amount;
console.log(Recommended SendMax: ${formatAmount(recommendedMax)});
}
// Build payment with paths
const payment = {
TransactionType: 'Payment',
Account: senderWallet.address,
Destination: destination,
Amount: deliverAmount, // What to deliver
SendMax: sendMax, // Max to spend
Paths: paths // Route to take
};
console.log(Cross-currency payment:);
console.log( Deliver: ${formatAmount(deliverAmount)});
console.log( Send max: ${formatAmount(sendMax)});
const prepared = await client.autofill(payment);
const signed = senderWallet.sign(prepared);
const result = await client.submitAndWait(signed.tx_blob);
const txResult = result.result.meta.TransactionResult;
if (txResult === 'tesSUCCESS') {
const delivered = result.result.meta.delivered_amount;
console.log(✓ Delivered: ${formatAmount(delivered)});
return {
success: true,
hash: signed.hash,
delivered: delivered
};
} else {
console.log(✗ Failed: ${txResult});
return {
success: false,
resultCode: txResult
};
}
} finally {
await client.disconnect();
}
}
function formatAmount(amount) {
if (typeof amount === 'string') {
return ${Number(amount) / 1_000_000} XRP;
}
return ${amount.value} ${amount.currency};
}
module.exports = { sendCrossCurrency };
```
SendMax is crucial for cross-currency payments:
// SendMax protects you from unfavorable exchange rates
// Scenario: Send EUR, deliver USD
const payment = {
TransactionType: 'Payment',
Account: sender,
Destination: recipient,
Amount: { // Deliver exactly 100 USD
currency: 'USD',
issuer: usdIssuer,
value: '100'
},
SendMax: { // Spend at most 95 EUR
currency: 'EUR',
issuer: eurIssuer,
value: '95'
},
Paths: computedPaths
};
// If exchange rate is 1 EUR = 1.05 USD:
// Need ~95.24 EUR to deliver 100 USD
// This exceeds SendMax of 95 EUR
// Transaction FAILS (tecPATH_PARTIAL) - protects sender
// If exchange rate is 1 EUR = 1.10 USD:
// Need ~90.91 EUR to deliver 100 USD
// Under SendMax of 95 EUR
// Transaction SUCCEEDS, only ~91 EUR debited
Normally, a payment must deliver the full Amount or fail. Partial payments allow delivering less:
// Regular payment:
// Amount: 100 USD → Must deliver exactly 100 USD or fail
// Partial payment (tfPartialPayment flag):
// Amount: 100 USD → May deliver less if paths can't support full amount
// delivered_amount shows actual delivery
```
Critical security issue: If you don't check delivered_amount, attackers can exploit partial payments.
// DANGEROUS pattern:
function processPayment(tx) {
// DON'T DO THIS
const amount = tx.Amount; // What was requested
creditCustomer(tx.Destination, amount); // WRONG!
}
// An attacker sends:
// Amount: 1000000 USD
// tfPartialPayment: true
// Actual delivery: 0.000001 USD
// Your code credits 1,000,000 USD
// SAFE pattern:
function processPaymentSafe(tx) {
const delivered = tx.meta.delivered_amount;
// Check for partial payment flag
const isPartial = (tx.Flags & 0x00020000) !== 0; // tfPartialPayment
if (isPartial) {
console.warn('Partial payment received - check delivered_amount');
}
// Always use delivered_amount
creditCustomer(tx.Destination, delivered);
}
```
// Legitimate use: Sending entire balance
async function sendAllTokens(wallet, destination, currency, issuer) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
// Get current balance
const lines = await client.request({
command: 'account_lines',
account: wallet.address,
peer: issuer
});
const line = lines.result.lines.find(l => l.currency === currency);
const balance = line?.balance || '0';
// Use partial payment to send "up to" balance
// Handles transfer fees automatically
const payment = {
TransactionType: 'Payment',
Account: wallet.address,
Destination: destination,
Amount: {
currency: currency,
issuer: issuer,
value: balance // Request full balance
},
Flags: 131072, // tfPartialPayment
SendMax: {
currency: currency,
issuer: issuer,
value: balance // Can't spend more than balance
}
};
const prepared = await client.autofill(payment);
const signed = wallet.sign(prepared);
const result = await client.submitAndWait(signed.tx_blob);
// Actual delivered will be balance minus transfer fee
const delivered = result.result.meta.delivered_amount;
console.log(Delivered: ${delivered.value} ${currency});
return result;
} finally {
await client.disconnect();
}
}
```
When an issuer sets a transfer fee, recipients receive less than senders send:
Issuer has 1% transfer fee
- Alice's balance: -100 USD
- Bob's balance: +99 USD (100 - 1% fee)
- Issuer obligation reduced by 1 USD
The fee is destroyed (reduces issuer's liability)
// src/payments/transfer-fee.js
const xrpl = require('xrpl');
async function getTransferFee(issuer) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
const info = await client.request({
command: 'account_info',
account: issuer
});
const transferRate = info.result.account_data.TransferRate;
if (!transferRate) {
return {
rate: 1.0,
feePercent: 0,
description: 'No transfer fee'
};
}
// TransferRate is in billionths
// 1000000000 = 0% fee (1:1 transfer)
// 1010000000 = 1% fee
// 1100000000 = 10% fee
const rate = transferRate / 1000000000;
const feePercent = (rate - 1) * 100;
return {
rate: rate,
feePercent: feePercent,
description: ${feePercent.toFixed(2)}% transfer fee
};
} finally {
await client.disconnect();
}
}
function calculateDelivery(sendAmount, feePercent) {
const rate = 1 + (feePercent / 100);
return sendAmount / rate;
}
function calculateRequired(deliverAmount, feePercent) {
const rate = 1 + (feePercent / 100);
return deliverAmount * rate;
}
// Example:
// Issuer has 2% fee
// To deliver 100 USD, need to send: 100 * 1.02 = 102 USD
// Sending 100 USD delivers: 100 / 1.02 = 98.04 USD
module.exports = { getTransferFee, calculateDelivery, calculateRequired };
```
Transfer fees compound with exchange rates:
async function calculateCrossCurrencyWithFees(
sourceIssuer,
destIssuer,
exchangeRate, // e.g., 1.1 (1 source = 1.1 dest)
deliverAmount
) {
const sourceFee = await getTransferFee(sourceIssuer);
const destFee = await getTransferFee(destIssuer);
// Work backwards from delivery
// 1. Account for destination transfer fee
const beforeDestFee = deliverAmount * (1 + destFee.feePercent / 100);
// 2. Account for exchange rate
const inSourceCurrency = beforeDestFee / exchangeRate;
// 3. Account for source transfer fee
const sendAmount = inSourceCurrency * (1 + sourceFee.feePercent / 100);
console.log(`To deliver ${deliverAmount}:`);
console.log(` Before dest fee: ${beforeDestFee.toFixed(4)}`);
console.log(` In source currency: ${inSourceCurrency.toFixed(4)}`);
console.log(` Need to send: ${sendAmount.toFixed(4)}`);
return {
sendAmount,
fees: {
source: sourceFee.feePercent,
dest: destFee.feePercent
}
};
}
DeliverMin ensures the recipient gets at least a minimum amount:
// src/payments/deliver-min.js
async function sendWithMinimumDelivery(
senderWallet,
destination,
targetAmount, // What you're trying to deliver
minimumAmount, // Fail if can't deliver at least this
sendMax
) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
const payment = {
TransactionType: 'Payment',
Account: senderWallet.address,
Destination: destination,
Amount: targetAmount,
SendMax: sendMax,
DeliverMin: minimumAmount, // Minimum acceptable delivery
Flags: 131072 // tfPartialPayment (required with DeliverMin)
};
// Get paths if cross-currency
if (typeof targetAmount === 'object' && typeof sendMax === 'object') {
const pathResult = await client.request({
command: 'ripple_path_find',
source_account: senderWallet.address,
destination_account: destination,
destination_amount: targetAmount
});
if (pathResult.result.alternatives.length > 0) {
payment.Paths = pathResult.result.alternatives[0].paths_computed;
}
}
const prepared = await client.autofill(payment);
const signed = senderWallet.sign(prepared);
const result = await client.submitAndWait(signed.tx_blob);
if (result.result.meta.TransactionResult === 'tesSUCCESS') {
const delivered = result.result.meta.delivered_amount;
console.log(`Delivered: ${formatAmount(delivered)}`);
console.log(`Minimum was: ${formatAmount(minimumAmount)}`);
return { success: true, delivered };
} else {
// tecPATH_PARTIAL means couldn't meet DeliverMin
return {
success: false,
resultCode: result.result.meta.TransactionResult
};
}
} finally {
await client.disconnect();
}
}
function formatAmount(amount) {
if (typeof amount === 'string') {
return `${Number(amount) / 1_000_000} XRP`;
}
return `${amount.value} ${amount.currency}`;
}
// src/payments/token-payments.js
const xrpl = require('xrpl');
class TokenPayments {
constructor(client) {
this.client = client;
}
// Simple same-currency payment
async sendToken(wallet, destination, currency, issuer, amount) {
const payment = {
TransactionType: 'Payment',
Account: wallet.address,
Destination: destination,
Amount: {
currency,
issuer,
value: amount.toString()
}
};
return this.submitPayment(wallet, payment);
}
// Cross-currency with automatic pathfinding
async sendCrossCurrency(wallet, destination, deliverAmount, sendMax) {
// Find paths
const pathResult = await this.client.request({
command: 'ripple_path_find',
source_account: wallet.address,
destination_account: destination,
destination_amount: deliverAmount
});
if (pathResult.result.alternatives.length === 0) {
throw new Error('No payment paths found');
}
const payment = {
TransactionType: 'Payment',
Account: wallet.address,
Destination: destination,
Amount: deliverAmount,
SendMax: sendMax,
Paths: pathResult.result.alternatives[0].paths_computed
};
return this.submitPayment(wallet, payment);
}
// Send maximum possible (handles transfer fees)
async sendMaximum(wallet, destination, currency, issuer) {
const balance = await this.getTokenBalance(wallet.address, currency, issuer);
if (Number(balance) <= 0) {
throw new Error('No balance to send');
}
const payment = {
TransactionType: 'Payment',
Account: wallet.address,
Destination: destination,
Amount: { currency, issuer, value: balance },
SendMax: { currency, issuer, value: balance },
Flags: 131072 // tfPartialPayment
};
return this.submitPayment(wallet, payment);
}
// Helper: Get token balance
async getTokenBalance(address, currency, issuer) {
const lines = await this.client.request({
command: 'account_lines',
account: address,
peer: issuer
});
const line = lines.result.lines.find(l => l.currency === currency);
return line?.balance || '0';
}
// Helper: Submit and process result
async submitPayment(wallet, payment) {
const prepared = await this.client.autofill(payment);
const signed = wallet.sign(prepared);
const result = await this.client.submitAndWait(signed.tx_blob);
const txResult = result.result.meta.TransactionResult;
return {
success: txResult === 'tesSUCCESS',
hash: signed.hash,
resultCode: txResult,
delivered: result.result.meta.delivered_amount,
ledger: result.result.ledger_index
};
}
}
module.exports = { TokenPayments };
```
Cross-currency payments are powerful but complex. The atomic nature (all-or-nothing) provides safety, but the many variables (paths, fees, rates) require careful handling. Always use delivered_amount for accounting, always set SendMax appropriately, and test thoroughly with realistic scenarios.
Assignment: Build a complete application for sending payments across different currencies.
Requirements:
Discover payment paths for currency pairs
Display path details (hops, rates)
Compare multiple alternative paths
Calculate required send amount given desired delivery
Account for transfer fees on both currencies
Estimate exchange rates from path data
Send same-currency payments
Send cross-currency payments with paths
Proper SendMax setting
Verification of delivered amount
Validate destination has trust line
Verify sender has sufficient balance
Check transfer fee impact
Warn about large rate differences
Pathfinding works correctly (25%)
Calculator handles fees accurately (25%)
Payments execute reliably (25%)
Safety checks comprehensive (25%)
Time investment: 3 hours
Value: Cross-currency capability unlocks XRPL's full payment potential
Knowledge Check
Question 1 of 2You want to deliver exactly 100 EUR and you have USD. What happens if you set SendMax too low?
- Payment transaction: https://xrpl.org/payment.html
- Partial payments: https://xrpl.org/partial-payments.html
- Paths: https://xrpl.org/paths.html
- Transfer fees: https://xrpl.org/transfer-fees.html
- ripple_path_find: https://xrpl.org/ripple_path_find.html
- path_find (subscription): https://xrpl.org/path_find.html
- Partial payment exploit: https://xrpl.org/partial-payments.html#partial-payments-exploit
For Next Lesson:
You now understand payments. Lesson 8 introduces the native DEX—where you'll learn to create and manage orders, understand the order book, and leverage auto-bridging through XRP.
End of Lesson 7
Total words: ~4,800
Estimated completion time: 50 minutes reading + 3 hours for deliverable
Key Takeaways
delivered_amount is truth
: Never trust the Amount field for accounting. Always check what was actually delivered.
SendMax protects senders
: Set it to the maximum you're willing to spend. The transaction fails if this isn't enough.
Pathfinding is essential for cross-currency
: Without paths, cross-currency payments fail. Always verify paths exist.
Transfer fees reduce delivery
: Factor in issuer fees when calculating expected delivery amounts.
Partial payments are dangerous
: Only use when intentional, and always verify recipients check delivered_amount. ---