Building a Complete Application
Learning Objectives
Architect a complete XRPL application with proper separation of concerns
Integrate multiple XRPL features into a cohesive system
Handle the full transaction lifecycle from initiation to confirmation
Implement operational monitoring for production readiness
Build modular, maintainable code that can evolve with requirements
- Connections and queries (Lessons 1-4)
- Trust lines, payments, DEX, escrow, channels (Lessons 6-11)
- Multi-sig, WebSockets, error handling, security, performance (Lessons 12-16)
Now we combine them. A real application isn't just individual features—it's how they work together, fail gracefully, and operate reliably over time.
┌─────────────────────────────────────────────────────────────────┐
│ Payment Processor Service │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ API │ │ WebSocket │ │ Background Jobs │ │
│ │ Endpoints │ │ Listeners │ │ (confirmations, etc) │ │
│ └──────┬──────┘ └──────┬──────┘ └───────────┬─────────────┘ │
│ │ │ │ │
│ └────────────────┼──────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Business Logic Layer │ │
│ │ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ │
│ │ │ Payment │ │ Balance │ │ Withdrawal │ │ │
│ │ │ Service │ │ Service │ │ Service │ │ │
│ │ └───────────────┘ └───────────────┘ └───────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ XRPL Layer │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐│ │
│ │ │ Connection │ │ Transaction │ │ Query Service ││ │
│ │ │ Pool │ │ Submitter │ │ (cached, batched) ││ │
│ │ └─────────────┘ └─────────────┘ └─────────────────────┘│ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Infrastructure │ │
│ │ ┌────────────┐ ┌─────────────┐ ┌───────────────────┐ │ │
│ │ │ Logging │ │ Metrics │ │ Config/Secrets │ │ │
│ │ └────────────┘ └─────────────┘ └───────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘payment-processor/
├── src/
│ ├── index.js # Application entry point
│ ├── config/
│ │ └── index.js # Configuration management
│ ├── xrpl/
│ │ ├── connection.js # Connection pool
│ │ ├── transaction.js # Transaction submission
│ │ ├── queries.js # Query helpers
│ │ └── subscriptions.js # WebSocket subscriptions
│ ├── services/
│ │ ├── payment.js # Payment business logic
│ │ ├── balance.js # Balance tracking
│ │ └── withdrawal.js # Withdrawal processing
│ ├── api/
│ │ ├── routes.js # API route definitions
│ │ └── middleware.js # Validation, auth, etc.
│ ├── workers/
│ │ └── confirmation.js # Background confirmation checker
│ └── utils/
│ ├── logger.js # Logging utility
│ ├── metrics.js # Performance metrics
│ └── cache.js # Caching layer
├── tests/
│ ├── unit/
│ └── integration/
├── .env.example
├── package.json
└── README.md// src/config/index.js
require('dotenv').config();
const config = {
xrpl: {
servers: (process.env.XRPL_SERVERS || 'wss://s.altnet.rippletest.net:51233')
.split(','),
isTestnet: process.env.XRPL_NETWORK === 'testnet',
connectionPoolSize: parseInt(process.env.XRPL_POOL_SIZE || '3'),
requestTimeoutMs: parseInt(process.env.XRPL_TIMEOUT || '30000')
},
wallet: {
// Never log or expose these
hotWalletSeed: process.env.HOT_WALLET_SEED,
warmWalletSeed: process.env.WARM_WALLET_SEED
},
limits: {
maxPaymentXRP: parseFloat(process.env.MAX_PAYMENT_XRP || '1000'),
dailyLimitXRP: parseFloat(process.env.DAILY_LIMIT_XRP || '10000'),
minPaymentXRP: parseFloat(process.env.MIN_PAYMENT_XRP || '0.001')
},
server: {
port: parseInt(process.env.PORT || '3000'),
rateLimitPerMinute: parseInt(process.env.RATE_LIMIT || '60')
},
cache: {
balanceTTLMs: parseInt(process.env.CACHE_BALANCE_TTL || '5000'),
accountInfoTTLMs: parseInt(process.env.CACHE_INFO_TTL || '30000')
}
};
// Validate required config
function validateConfig() {
const required = ['wallet.hotWalletSeed'];
const missing = required.filter(key => {
const parts = key.split('.');
let value = config;
for (const part of parts) {
value = value?.[part];
}
return !value;
});
if (missing.length > 0) {
throw new Error(Missing required config: ${missing.join(', ')});
}
}
validateConfig();
module.exports = config;
```
// src/xrpl/connection.js
const xrpl = require('xrpl');
const config = require('../config');
const logger = require('../utils/logger');
class XRPLService {
constructor() {
this.connections = [];
this.currentIndex = 0;
this.isInitialized = false;
this.wallet = null;
}
async initialize() {
if (this.isInitialized) return;
// Initialize wallet
this.wallet = xrpl.Wallet.fromSeed(config.wallet.hotWalletSeed);
logger.info(Wallet initialized: ${this.wallet.address});
// Create connection pool
for (let i = 0; i < config.xrpl.connectionPoolSize; i++) {
const client = new xrpl.Client(
config.xrpl.servers[i % config.xrpl.servers.length]
);
client.on('disconnected', () => {
logger.warn(Connection ${i} disconnected);
this.handleDisconnect(client, i);
});
client.on('error', (error) => {
logger.error(Connection ${i} error:, error);
});
await client.connect();
this.connections.push(client);
logger.info(Connection ${i} established);
}
this.isInitialized = true;
logger.info('XRPL service initialized');
}
getClient() {
// Round-robin connection selection
const client = this.connections[this.currentIndex];
this.currentIndex = (this.currentIndex + 1) % this.connections.length;
return client;
}
async request(params) {
const client = this.getClient();
const startTime = Date.now();
try {
const result = await client.request(params);
logger.debug(Request ${params.command} completed in ${Date.now() - startTime}ms);
return result;
} catch (error) {
logger.error(Request ${params.command} failed:, error);
throw error;
}
}
async handleDisconnect(client, index) {
// Attempt reconnection
for (let attempt = 1; attempt <= 5; attempt++) {
try {
await new Promise(r => setTimeout(r, 1000 * attempt));
await client.connect();
logger.info(Connection ${index} restored);
return;
} catch (error) {
logger.warn(Reconnection attempt ${attempt} failed);
}
}
logger.error(Connection ${index} failed to restore);
}
async shutdown() {
for (const client of this.connections) {
await client.disconnect();
}
this.isInitialized = false;
logger.info('XRPL service shut down');
}
}
// Singleton instance
const xrplService = new XRPLService();
module.exports = xrplService;
```
// src/services/payment.js
const xrpl = require('xrpl');
const xrplService = require('../xrpl/connection');
const config = require('../config');
const logger = require('../utils/logger');
const metrics = require('../utils/metrics');
const cache = require('../utils/cache');
class PaymentService {
constructor() {
this.dailyTotal = 0;
this.dailyResetDate = new Date().toDateString();
this.pendingPayments = new Map();
}
// Process outgoing payment
async sendPayment(destination, amountXRP, options = {}) {
const paymentId = options.paymentId || this.generatePaymentId();
logger.info(Processing payment ${paymentId}: ${amountXRP} XRP to ${destination});
metrics.paymentStarted();
try {
// Validation
await this.validatePayment(destination, amountXRP);
// Check daily limits
this.checkDailyLimit(amountXRP);
// Build transaction
const payment = {
TransactionType: 'Payment',
Account: xrplService.wallet.address,
Destination: destination,
Amount: xrpl.xrpToDrops(amountXRP)
};
if (options.destinationTag !== undefined) {
payment.DestinationTag = options.destinationTag;
}
if (options.memo) {
payment.Memos = [{
Memo: {
MemoType: Buffer.from('text').toString('hex'),
MemoData: Buffer.from(options.memo).toString('hex')
}
}];
}
// Submit transaction
const result = await this.submitTransaction(payment);
// Update daily total
this.dailyTotal += amountXRP;
// Track for confirmation
this.pendingPayments.set(result.hash, {
paymentId,
destination,
amount: amountXRP,
submittedAt: new Date()
});
logger.info(Payment ${paymentId} submitted: ${result.hash});
metrics.paymentSubmitted();
return {
success: true,
paymentId,
hash: result.hash,
status: 'submitted'
};
} catch (error) {
logger.error(Payment ${paymentId} failed:, error);
metrics.paymentFailed();
return {
success: false,
paymentId,
error: error.message
};
}
}
async validatePayment(destination, amountXRP) {
// Validate amount
if (amountXRP < config.limits.minPaymentXRP) {
throw new Error(Amount below minimum: ${config.limits.minPaymentXRP} XRP);
}
if (amountXRP > config.limits.maxPaymentXRP) {
throw new Error(Amount exceeds maximum: ${config.limits.maxPaymentXRP} XRP);
}
// Validate destination
if (!xrpl.isValidAddress(destination)) {
throw new Error('Invalid destination address');
}
// Check destination exists (optional - will auto-fund if enough XRP)
try {
await xrplService.request({
command: 'account_info',
account: destination
});
} catch (error) {
if (error.data?.error === 'actNotFound') {
if (amountXRP < 10) {
throw new Error('Destination does not exist and amount is less than 10 XRP');
}
logger.info(Destination ${destination} will be created);
}
}
// Check our balance
const balance = await this.getBalance();
const required = amountXRP + 1; // Buffer for fees and reserve
if (balance.available < required) {
throw new Error(Insufficient balance: have ${balance.available}, need ${required});
}
}
checkDailyLimit(amountXRP) {
// Reset if new day
const today = new Date().toDateString();
if (today !== this.dailyResetDate) {
this.dailyTotal = 0;
this.dailyResetDate = today;
}
if (this.dailyTotal + amountXRP > config.limits.dailyLimitXRP) {
throw new Error(Would exceed daily limit of ${config.limits.dailyLimitXRP} XRP);
}
}
async submitTransaction(transaction) {
const client = xrplService.getClient();
// Autofill
const prepared = await client.autofill(transaction);
// Sign
const signed = xrplService.wallet.sign(prepared);
// Submit and wait
const result = await client.submitAndWait(signed.tx_blob);
if (result.result.meta.TransactionResult !== 'tesSUCCESS') {
throw new Error(Transaction failed: ${result.result.meta.TransactionResult});
}
return {
hash: signed.hash,
result: result.result
};
}
async getBalance() {
const cacheKey = balance:${xrplService.wallet.address};
const cached = cache.get(cacheKey);
if (cached) return cached;
const info = await xrplService.request({
command: 'account_info',
account: xrplService.wallet.address
});
const balance = Number(info.result.account_data.Balance) / 1_000_000;
const ownerCount = info.result.account_data.OwnerCount;
const reserve = 10 + (ownerCount * 2);
const available = balance - reserve;
const result = { balance, reserve, available };
cache.set(cacheKey, result, config.cache.balanceTTLMs);
return result;
}
generatePaymentId() {
return pay_${Date.now()}_${Math.random().toString(36).substr(2, 9)};
}
getPendingPayments() {
return Array.from(this.pendingPayments.entries()).map(([hash, info]) => ({
hash,
...info
}));
}
confirmPayment(hash) {
this.pendingPayments.delete(hash);
metrics.paymentConfirmed();
}
}
module.exports = new PaymentService();
```
// src/api/routes.js
const express = require('express');
const paymentService = require('../services/payment');
const xrplService = require('../xrpl/connection');
const { validatePaymentRequest, rateLimit } = require('./middleware');
const logger = require('../utils/logger');
const router = express.Router();
// Health check
router.get('/health', async (req, res) => {
try {
const balance = await paymentService.getBalance();
res.json({
status: 'healthy',
wallet: xrplService.wallet.address,
balance: balance.available,
pending: paymentService.getPendingPayments().length
});
} catch (error) {
res.status(503).json({ status: 'unhealthy', error: error.message });
}
});
// Get balance
router.get('/balance', async (req, res) => {
try {
const balance = await paymentService.getBalance();
res.json(balance);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Send payment
router.post('/payments', rateLimit, validatePaymentRequest, async (req, res) => {
const { destination, amount, destinationTag, memo } = req.body;
logger.info(Payment request: ${amount} XRP to ${destination});
const result = await paymentService.sendPayment(destination, amount, {
destinationTag,
memo
});
if (result.success) {
res.status(202).json(result);
} else {
res.status(400).json(result);
}
});
// Get payment status
router.get('/payments/:hash', async (req, res) => {
const { hash } = req.params;
try {
const result = await xrplService.request({
command: 'tx',
transaction: hash
});
res.json({
hash,
validated: result.result.validated,
result: result.result.meta?.TransactionResult,
ledgerIndex: result.result.ledger_index
});
} catch (error) {
if (error.data?.error === 'txnNotFound') {
res.status(404).json({ error: 'Transaction not found' });
} else {
res.status(500).json({ error: error.message });
}
}
});
// Get pending payments
router.get('/payments/pending', (req, res) => {
res.json(paymentService.getPendingPayments());
});
module.exports = router;
```
// src/api/middleware.js
const xrpl = require('xrpl');
const config = require('../config');
const logger = require('../utils/logger');
// Request rate limiting (simple in-memory)
const requestCounts = new Map();
function rateLimit(req, res, next) {
const ip = req.ip;
const now = Date.now();
const windowMs = 60000; // 1 minute
let requests = requestCounts.get(ip) || [];
requests = requests.filter(t => t > now - windowMs);
if (requests.length >= config.server.rateLimitPerMinute) {
logger.warn(Rate limit exceeded for ${ip});
return res.status(429).json({ error: 'Too many requests' });
}
requests.push(now);
requestCounts.set(ip, requests);
next();
}
// Payment request validation
function validatePaymentRequest(req, res, next) {
const { destination, amount } = req.body;
const errors = [];
if (!destination) {
errors.push('destination is required');
} else if (!xrpl.isValidAddress(destination)) {
errors.push('destination is not a valid XRPL address');
}
if (amount === undefined || amount === null) {
errors.push('amount is required');
} else if (typeof amount !== 'number' || amount <= 0) {
errors.push('amount must be a positive number');
} else if (amount < config.limits.minPaymentXRP) {
errors.push(amount must be at least ${config.limits.minPaymentXRP} XRP);
} else if (amount > config.limits.maxPaymentXRP) {
errors.push(amount must not exceed ${config.limits.maxPaymentXRP} XRP);
}
if (req.body.destinationTag !== undefined) {
const tag = req.body.destinationTag;
if (!Number.isInteger(tag) || tag < 0 || tag > 4294967295) {
errors.push('destinationTag must be an integer 0-4294967295');
}
}
if (errors.length > 0) {
return res.status(400).json({ errors });
}
next();
}
module.exports = { rateLimit, validatePaymentRequest };
```
// src/workers/confirmation.js
const xrplService = require('../xrpl/connection');
const paymentService = require('../services/payment');
const logger = require('../utils/logger');
class ConfirmationWorker {
constructor() {
this.isRunning = false;
this.checkInterval = 5000; // 5 seconds
}
start() {
if (this.isRunning) return;
this.isRunning = true;
logger.info('Confirmation worker started');
this.run();
}
stop() {
this.isRunning = false;
logger.info('Confirmation worker stopped');
}
async run() {
while (this.isRunning) {
try {
await this.checkPendingPayments();
} catch (error) {
logger.error('Confirmation check error:', error);
}
await new Promise(r => setTimeout(r, this.checkInterval));
}
}
async checkPendingPayments() {
const pending = paymentService.getPendingPayments();
if (pending.length === 0) return;
logger.debug(Checking ${pending.length} pending payments);
for (const payment of pending) {
try {
const result = await xrplService.request({
command: 'tx',
transaction: payment.hash
});
if (result.result.validated) {
const txResult = result.result.meta.TransactionResult;
if (txResult === 'tesSUCCESS') {
logger.info(Payment ${payment.paymentId} confirmed: ${payment.hash});
paymentService.confirmPayment(payment.hash);
// Emit event or callback for notification
this.onConfirmation(payment, result.result);
} else {
logger.warn(Payment ${payment.paymentId} failed: ${txResult});
paymentService.confirmPayment(payment.hash);
this.onFailure(payment, txResult);
}
}
} catch (error) {
if (error.data?.error === 'txnNotFound') {
// Check if expired
const age = Date.now() - payment.submittedAt.getTime();
if (age > 120000) { // 2 minutes
logger.warn(Payment ${payment.paymentId} expired);
paymentService.confirmPayment(payment.hash);
this.onExpiration(payment);
}
}
}
}
}
onConfirmation(payment, result) {
// Override for notifications (webhooks, etc.)
}
onFailure(payment, resultCode) {
// Override for error handling
}
onExpiration(payment) {
// Override for timeout handling
}
}
module.exports = new ConfirmationWorker();
```
// src/index.js
const express = require('express');
const config = require('./config');
const xrplService = require('./xrpl/connection');
const apiRoutes = require('./api/routes');
const confirmationWorker = require('./workers/confirmation');
const logger = require('./utils/logger');
const metrics = require('./utils/metrics');
const app = express();
// Middleware
app.use(express.json());
app.use((req, res, next) => {
logger.debug(${req.method} ${req.path});
next();
});
// Routes
app.use('/api', apiRoutes);
// Error handling
app.use((err, req, res, next) => {
logger.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' });
});
// Startup
async function start() {
try {
logger.info('Starting payment processor...');
// Initialize XRPL
await xrplService.initialize();
// Start confirmation worker
confirmationWorker.start();
// Start metrics collection
metrics.start();
// Start HTTP server
app.listen(config.server.port, () => {
logger.info(Server listening on port ${config.server.port});
});
} catch (error) {
logger.error('Startup failed:', error);
process.exit(1);
}
}
// Graceful shutdown
async function shutdown(signal) {
logger.info(Received ${signal}, shutting down...);
confirmationWorker.stop();
await xrplService.shutdown();
logger.info('Shutdown complete');
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
start();
```
This architecture provides a solid foundation but isn't production-complete. Real production requires database persistence, multi-instance coordination, comprehensive monitoring, and operational runbooks. Use this as a starting point, not a final solution.
Assignment: Build a complete payment processing service using this architecture.
Requirements:
Configuration management
XRPL connection pool
Payment service with validation
RESTful endpoints for payments
Input validation
Rate limiting
Confirmation worker
Expiration handling
Status updates
Health check endpoint
Logging throughout
Basic metrics
Core service functional (30%)
API complete and validated (25%)
Background processing reliable (25%)
Operational features present (20%)
Time investment: 5-6 hours
Value: Real, deployable payment processing infrastructure
Knowledge Check
Question 1 of 5Why separate the XRPL layer from business logic?
- Clean Architecture principles
- Microservices patterns
- Event-driven architecture
- Database integration
- Message queues for async processing
- Container orchestration
For Next Lesson:
You've built a complete application structure. Lesson 18 covers testing strategies—how to test XRPL applications effectively without using mainnet or risking real funds.
End of Lesson 17
Total words: ~5,000
Estimated completion time: 60 minutes reading + 5-6 hours for deliverable
Key Takeaways
Layer your application
: Separate XRPL concerns from business logic from API handling.
Handle the full lifecycle
: From request validation through confirmation—don't just submit and hope.
Build for failure
: Every network call can fail. Every component can disconnect.
Make it observable
: Logging and metrics from day one, not retrofitted later.
Start simple, evolve
: This structure can grow. Don't over-engineer initially. ---