Sending Non-XRP Payments | XRPL Development 101 | XRP Academy - XRP Academy
3 free lessons remaining this month

Free preview access resets monthly

Upgrade for Unlimited
Skip to main content
intermediate50 min

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 2

You want to deliver exactly 100 EUR and you have USD. What happens if you set SendMax too low?

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

1

delivered_amount is truth

: Never trust the Amount field for accounting. Always check what was actually delivered.

2

SendMax protects senders

: Set it to the maximum you're willing to spend. The transaction fails if this isn't enough.

3

Pathfinding is essential for cross-currency

: Without paths, cross-currency payments fail. Always verify paths exist.

4

Transfer fees reduce delivery

: Factor in issuer fees when calculating expected delivery amounts.

5

Partial payments are dangerous

: Only use when intentional, and always verify recipients check delivered_amount. ---