Payment Integration Patterns - From Simple to Complex
Learning Objectives
Implement XRP payments with correct drops conversion and validation
Handle destination tags correctly for exchange and custodial integration
Execute issued currency payments with trust line verification
Use pathfinding for cross-currency payments with appropriate limits
Protect against the partial payment vulnerability in all payment handling
Payments seem simple—move value from A to B. On XRPL, this basic operation has many variations:
PAYMENT SCENARIOS:
Simple XRP → Direct transfer of native asset
+ Destination Tag → Required by exchanges/custodians
+ Issued Currency → Requires trust lines
+ Cross-Currency → Requires pathfinding
+ Partial Payments → The security trap
Each layer adds complexity and potential for bugs.
This lesson builds from simple to complex, with security considerations throughout.
The simplest payment: send XRP from one account to another.
const payment = {
TransactionType: 'Payment',
Account: wallet.address,
Destination: 'rRecipient...',
Amount: xrpl.xrpToDrops('25') // 25 XRP = "25000000" drops
}
const result = await client.submitAndWait(payment, { wallet })
```
XRP is stored and transmitted in "drops"—the smallest unit:
1 XRP = 1,000,000 drops
CONVERSIONS:
xrpl.xrpToDrops('25') → '25000000'
xrpl.xrpToDrops('0.001') → '1000'
xrpl.xrpToDrops('0.000001') → '1' (minimum)
xrpl.dropsToXrp('25000000') → '25'
xrpl.dropsToXrp('1000') → '0.001'
Common Mistake:
// WRONG: This sends 25 drops, not 25 XRP!
Amount: '25'
// RIGHT: Convert XRP to drops
Amount: xrpl.xrpToDrops('25') // '25000000'
// Also RIGHT: Already in drops
Amount: '25000000'
MINIMUM AMOUNTS:
To existing account: 1 drop (0.000001 XRP)
To create new account: reserve_base (currently 10 XRP)
If destination doesn't exist and amount < reserve:
Result: tecNO_DST_INSUF_XRP
Checking If Account Exists:
async function getMinimumPayment(client, destination) {
try {
await client.request({
command: 'account_info',
account: destination,
ledger_index: 'validated'
})
return 1 // Account exists, minimum is 1 drop
} catch (error) {
if (error.data?.error === 'actNotFound') {
// Account doesn't exist, need reserve to create
const serverInfo = await client.request({ command: 'server_info' })
const reserve = serverInfo.result.info.validated_ledger.reserve_base_xrp
return reserve * 1_000_000 // Convert XRP to drops
}
throw error
}
}async function validateXRPPayment(client, sender, destination, amountDrops) {
const errors = []
// Validate addresses
if (!xrpl.isValidAddress(sender)) {
errors.push('Invalid sender address')
}
if (!xrpl.isValidAddress(destination)) {
errors.push('Invalid destination address')
}
if (sender === destination) {
errors.push('Cannot send to self')
}
// Validate amount
const amount = parseInt(amountDrops)
if (isNaN(amount) || amount <= 0) {
errors.push('Invalid amount')
}
// Check sender balance
try {
const senderInfo = await client.request({
command: 'account_info',
account: sender,
ledger_index: 'validated'
})
const balance = parseInt(senderInfo.result.account_data.Balance)
const ownerCount = senderInfo.result.account_data.OwnerCount
const serverInfo = await client.request({ command: 'server_info' })
const reserves = serverInfo.result.info.validated_ledger
const reserved = (reserves.reserve_base_xrp + ownerCount * reserves.reserve_inc_xrp) * 1_000_000
const available = balance - reserved - 12 // Leave room for fee
if (amount > available) {
errors.push(Insufficient balance. Available: ${available} drops)
}
} catch (error) {
errors.push(Cannot verify sender account: ${error.message})
}
// Check minimum for destination
const minimum = await getMinimumPayment(client, destination)
if (amount < minimum) {
errors.push(Amount below minimum (${minimum} drops for this destination))
}
return errors
}
```
XRPL supports tokens issued by any account. These require trust lines:
ISSUED CURRENCY STRUCTURE:
{
"currency": "USD", // 3-char code or 40-hex
"issuer": "rIssuerAddress", // Who issued it
"value": "100.00" // Amount as string
}
vs XRP (native):
"25000000" // Just drops as string
Before receiving an issued currency, an account must create a trust line:
Trust Line = "I trust [issuer] for up to [limit] of [currency]"
Without trust line: tecNO_DST (destination can't receive)
With trust line: Payment succeeds
async function hasTrustLine(client, account, currency, issuer) {
const response = await client.request({
command: 'account_lines',
account: account,
peer: issuer,
ledger_index: 'validated'
})
const line = response.result.lines.find(
l => l.currency === currency && l.account === issuer
)
if (!line) return false
return {
exists: true,
balance: parseFloat(line.balance),
limit: parseFloat(line.limit)
}
}
```
const payment = {
TransactionType: 'Payment',
Account: wallet.address,
Destination: 'rRecipient...',
Amount: {
currency: 'USD',
issuer: 'rBitstamp...',
value: '100.00' // String, not number
}
}
const result = await client.submitAndWait(payment, { wallet })
```
When paying with issued currencies, specify the maximum you're willing to send:
const payment = {
TransactionType: 'Payment',
Account: wallet.address,
Destination: 'rRecipient...',
Amount: {
currency: 'USD',
issuer: 'rIssuer...',
value: '100.00'
},
SendMax: {
currency: 'USD',
issuer: 'rIssuer...',
value: '100.50' // Allow small overage for fees/rounding
}
}XRPL can convert currencies atomically using the DEX:
You have: EUR
They want: JPY
Path: EUR → XRP → JPY (or EUR → USD → JPY, etc.)
The payment finds the best path and executes atomically.
Before cross-currency payment, find available paths:
const paths = await client.request({
command: 'ripple_path_find',
source_account: wallet.address,
destination_account: 'rRecipient...',
destination_amount: {
currency: 'JPY',
issuer: 'rJPYIssuer...',
value: '10000'
},
source_currencies: [
{ currency: 'EUR', issuer: 'rEURIssuer...' },
{ currency: 'XRP' }
]
})
console.log(paths.result.alternatives)
// [
// {
// paths_computed: [...],
// source_amount: { currency: 'EUR', value: '78.50', ... }
// },
// ...
// ]
const pathResult = paths.result.alternatives[0]
const payment = {
TransactionType: 'Payment',
Account: wallet.address,
Destination: 'rRecipient...',
Amount: {
currency: 'JPY',
issuer: 'rJPYIssuer...',
value: '10000'
},
SendMax: pathResult.source_amount, // Maximum EUR to spend
Paths: pathResult.paths_computed // Use found paths
}
const result = await client.submitAndWait(payment, { wallet })
```
CONSIDERATIONS:
1. Slippage: Prices change between pathfind and execution
1. Liquidity: Paths may not have enough depth
1. Path Expiry: Paths can become invalid
1. Fees: Transfer fees may apply to issued currencies
---
The tfPartialPayment flag allows a payment to deliver less than the Amount field specifies:
const payment = {
TransactionType: 'Payment',
Account: wallet.address,
Destination: 'rRecipient...',
Amount: xrpl.xrpToDrops('1000'), // Requested: 1000 XRP
Flags: xrpl.PaymentFlags.tfPartialPayment // Flag: 0x00020000
}
// This payment might deliver only 100 XRP and still succeed!
```
Legitimate use: Cross-currency payments where exact conversion is uncertain.
Scenario: Pay exactly 1000 JPY, spending up to 10 EUR
- If 10 EUR converts to 1050 JPY, transaction fails (too much)
- You get 1000 JPY, spend only ~9.52 EUR
- Flexible for the sender
THE PROBLEM: If you check the Amount field instead of delivered_amount, you can be tricked:
// VULNERABLE CODE - DO NOT USE
function processPayment(tx) {
const amount = tx.Amount // WRONG! Could be manipulated
creditUser(tx.Destination, amount)
}
// Attacker sends:
// Amount: 1000 XRP
// Flags: tfPartialPayment
// delivered_amount: 1 XRP
//
// Your code credits 1000 XRP, attacker sent 1 XRP
```
Real-World Impact: Multiple exchanges have lost millions of dollars to this exploit.
ALWAYS use delivered_amount:
function safeGetDeliveredAmount(tx) {
// For successful payments, meta.delivered_amount is authoritative
if (tx.meta?.delivered_amount) {
return tx.meta.delivered_amount
}
// For pre-2014 transactions or edge cases
// Only trust Amount if partial payment flag is NOT set
const flags = tx.Flags || 0
const tfPartialPayment = 0x00020000
if ((flags & tfPartialPayment) === 0) {
return tx.Amount // Safe if no partial payment flag
}
// Partial payment without delivered_amount - reject
throw new Error('Cannot determine delivered amount for partial payment')
}
// Complete safe payment processing
function processIncomingPayment(tx) {
// Verify it's a Payment
if (tx.TransactionType !== 'Payment') return null
// Verify it succeeded
if (tx.meta?.TransactionResult !== 'tesSUCCESS') return null
// Get the ACTUAL delivered amount
const delivered = safeGetDeliveredAmount(tx)
// Now safe to process
return {
destination: tx.Destination,
destinationTag: tx.DestinationTag,
amount: delivered,
hash: tx.hash,
ledger: tx.ledger_index
}
}
function isPartialPayment(tx) {
const flags = tx.Flags || 0
const tfPartialPayment = 0x00020000
return (flags & tfPartialPayment) !== 0
}
function analyzePayment(tx) {
const isPartial = isPartialPayment(tx)
const requestedAmount = tx.Amount
const deliveredAmount = tx.meta?.delivered_amount
return {
isPartialPayment: isPartial,
requested: requestedAmount,
delivered: deliveredAmount,
fullyDelivered: !isPartial ||
JSON.stringify(requestedAmount) === JSON.stringify(deliveredAmount)
}
}
```
Many systems simply reject partial payments entirely:
function validateDeposit(tx) {
if (isPartialPayment(tx)) {
console.log('Rejecting partial payment:', tx.hash)
return {
valid: false,
reason: 'Partial payments not accepted'
}
}
// Safe to use Amount field if not partial payment
return {
valid: true,
amount: tx.Amount
}
}
class PaymentGateway {
constructor(client, wallet) {
this.client = client
this.wallet = wallet
}
async sendXRP(destination, amountXRP, options = {}) {
// Validate
const amountDrops = xrpl.xrpToDrops(amountXRP)
const errors = await validateXRPPayment(
this.client,
this.wallet.address,
destination,
amountDrops
)
if (errors.length > 0) {
throw new Error(Validation failed: ${errors.join(', ')})
}
// Check destination tag requirement
if (await requiresDestinationTag(this.client, destination)) {
if (!options.destinationTag) {
throw new Error('Destination requires a tag')
}
}
// Build payment
const payment = {
TransactionType: 'Payment',
Account: this.wallet.address,
Destination: destination,
Amount: amountDrops
}
if (options.destinationTag) {
payment.DestinationTag = parseInt(options.destinationTag)
}
if (options.sourceTag) {
payment.SourceTag = parseInt(options.sourceTag)
}
if (options.memo) {
payment.Memos = [{
Memo: {
MemoData: Buffer.from(options.memo).toString('hex').toUpperCase()
}
}]
}
// Submit and wait
const result = await this.client.submitAndWait(payment, {
wallet: this.wallet
})
// Verify success
if (result.result.meta.TransactionResult !== 'tesSUCCESS') {
throw new Error(Payment failed: ${result.result.meta.TransactionResult})
}
return {
success: true,
hash: result.result.hash,
deliveredAmount: result.result.meta.delivered_amount,
ledger: result.result.ledger_index,
fee: result.result.Fee
}
}
processIncomingPayment(tx) {
// Must be a Payment
if (tx.TransactionType !== 'Payment') {
return null
}
// Must have succeeded
if (tx.meta?.TransactionResult !== 'tesSUCCESS') {
return null
}
// Reject partial payments
if (isPartialPayment(tx)) {
console.warn(Rejecting partial payment: ${tx.hash})
return {
valid: false,
reason: 'Partial payment rejected',
hash: tx.hash
}
}
// Get amount (safe since we rejected partial payments)
const amount = tx.meta?.delivered_amount || tx.Amount
return {
valid: true,
from: tx.Account,
to: tx.Destination,
destinationTag: tx.DestinationTag,
sourceTag: tx.SourceTag,
amount: amount,
hash: tx.hash,
ledger: tx.ledger_index,
timestamp: tx.date
}
}
}
```
✅ Drops conversion is essential: Getting this wrong sends wrong amounts
✅ Destination tags are critical for custodians: Missing tags lose customer funds
✅ Partial payment vulnerability is real: Exchanges have lost real money
✅ delivered_amount is the only safe field: Never trust Amount for deposits
⚠️ Pathfinding reliability: Path quality varies; may need multiple attempts
⚠️ Cross-currency slippage: Markets move; SendMax margins need tuning
⚠️ Partial payment policy: Some legitimate use cases exist; blanket rejection is safe but limiting
🔴 Using Amount instead of delivered_amount: Potential for significant financial loss
🔴 Missing destination tags: Funds credited to wrong user or lost
🔴 Cross-currency without SendMax limits: Could spend more than intended
🔴 Trusting pathfinding results indefinitely: Paths expire; execute quickly
Payment integration looks simple but has security implications that have cost real money. The partial payment vulnerability is the most critical—using delivered_amount instead of Amount is non-negotiable. Destination tags are the second most common integration issue, causing support headaches. Get these two things right, and most payment integrations will work correctly.
Assignment: Build a complete payment handling module that safely processes both outgoing and incoming payments.
Requirements:
Part 1: Outgoing Payments (40%)
- XRP payment with validation
- Destination tag handling (detect requirement, include when provided)
- Balance and reserve checking before submission
- Error handling with clear messages
Part 2: Incoming Payment Processing (40%)
- Safe payment extraction from transaction
- Partial payment detection and rejection
- Correct delivered_amount usage
- Support for destination tag routing
Part 3: Testing (20%)
Send XRP payment successfully
Send payment with destination tag
Process incoming payment correctly
Detect and reject partial payment (simulate if possible)
Correct delivered_amount handling: 30%
Destination tag handling: 20%
Outgoing payment validation: 20%
Error handling and edge cases: 20%
Code quality: 10%
Time Investment: 3-4 hours
Submission: Code module with tests and documentation
Security Note: This code handles money. Review carefully. The partial payment check is the most critical component.
1. Partial Payment Security (Tests Critical Understanding):
An incoming payment has Amount: "1000000000" (1000 XRP) but delivered_amount: "1000000" (1 XRP). How much should you credit the user?
A) 1000 XRP - that's what the Amount says
B) 1 XRP - use delivered_amount
C) 500.5 XRP - average of the two
D) 0 XRP - reject due to discrepancy
Correct Answer: B
Explanation: ALWAYS use delivered_amount for incoming payments. The Amount field can be manipulated with partial payments. The actual delivered value was 1 XRP, and that's what should be credited. Option A is the vulnerability that has lost exchanges millions. Option D is overly conservative—legitimate partial payments exist.
2. Destination Tags (Tests Application):
You're building an exchange. A user complains their deposit wasn't credited. Investigation shows a payment to your address with no destination tag. What happened?
A) The payment failed
B) The funds were received but you don't know which user to credit
C) The user sent to the wrong address
D) This is impossible if your address requires destination tags
Correct Answer: B
Explanation: If your exchange address doesn't have the RequireDestTag flag set, payments without tags will succeed. You receive the funds but have no way to know which customer deposited. This is why exchanges should set RequireDestTag and why users must include their assigned tag. Option D would be true only if RequireDestTag is set.
3. Drops Conversion (Tests Knowledge):
A user wants to send 25 XRP. Which Amount value is correct?
A) "25"
B) "25000000"
C) 25000000
D) Both B and C
Correct Answer: B
Explanation: Amounts in XRPL are always strings representing drops. 25 XRP = 25,000,000 drops = "25000000". Option A would send 25 drops (0.000025 XRP). Option C is a number, but amounts should be strings. While some libraries may accept numbers, the canonical format is string.
4. Cross-Currency Payments (Tests Understanding):
Why is SendMax important for cross-currency payments?
A) It speeds up the transaction
B) It limits the maximum amount you're willing to spend for the conversion
C) It's required by the XRPL protocol
D) It sets the exchange rate
Correct Answer: B
Explanation: SendMax sets the maximum amount of source currency you'll spend to deliver the destination amount. Without it (or with too high a value), price slippage could result in spending more than expected. It's a protection against unfavorable exchange rates, not a protocol requirement or exchange rate setter.
5. Trust Lines (Tests Comprehension):
You try to send USD (issued by Bitstamp) to an account that has no trust line to Bitstamp. What happens?
A) The payment creates a trust line automatically
B) The payment fails with tecNO_LINE
C) The payment converts USD to XRP automatically
D) The payment succeeds but the recipient can't use the USD
Correct Answer: B (Note: The actual result code may vary; tecPATH_DRY or similar)
Explanation: Accounts must explicitly create trust lines before receiving issued currencies. Without a trust line, the recipient cannot hold that currency, and the payment fails. Trust lines are never created automatically by payments—this is a security feature preventing unwanted token receipt.
- Payment Transaction: https://xrpl.org/payment.html
- Partial Payments: https://xrpl.org/partial-payments.html
- Paths: https://xrpl.org/paths.html
- ripple_path_find: https://xrpl.org/ripple_path_find.html
- Trust Lines: https://xrpl.org/trust-lines-and-issuing.html
- Destination Tags: https://xrpl.org/source-and-destination-tags.html
- Currency Formats: https://xrpl.org/currency-formats.html
- Partial Payment Exploitation: Search for exchange security incidents
- RequireDestTag: https://xrpl.org/require-destination-tags.html
For Next Lesson:
Lesson 8 covers subscription streams and event-driven architecture—building real-time payment monitors, handling account notifications, and designing systems that respond to ledger events as they happen.
End of Lesson 7
Total words: ~4,900
Estimated completion time: 55 minutes reading + 3-4 hours for deliverable
Key Takeaways
Always convert XRP to drops:
`xrpl.xrpToDrops()` prevents orders-of-magnitude errors. Never send raw numbers assuming they're XRP.
Check and require destination tags:
Many accounts require tags. Sending without one when required loses funds or creates support issues.
NEVER trust Amount for incoming payments:
Always use `delivered_amount`. The partial payment vulnerability has cost exchanges millions.
Validate before submission:
Check balances, destination existence, and minimum amounts before trying to send.
For cross-currency, use pathfinding:
Let the network find optimal paths, but set appropriate SendMax limits and execute quickly. ---