Payment Channels - High-Frequency, Low-Cost
Learning Objectives
Create payment channels between parties for ongoing value transfer
Generate and verify claims for off-chain payment updates
Close channels to settle final balances on-chain
Design streaming payment applications using payment channels
Understand security considerations for off-chain transactions
Every XRPL transaction costs a fee (~0.00001 XRP) and takes 3-5 seconds. For most use cases, this is excellent. But what about:
- Pay-per-second video streaming (hundreds of transactions per minute)
- IoT sensor data payments (thousands of micro-transactions daily)
- Gaming micro-rewards (continuous small payments)
- API usage billing (per-request charges)
Even at 0.00001 XRP per transaction, 10,000 transactions = 0.1 XRP in fees. And the network can't handle unlimited transaction volume from every user.
Payment channels solve this: Lock funds in a channel, exchange signed claims off-chain, settle only the final balance on-chain.
Without Payment Channels:
Payer → [TX1] → Payee (fee + 3-5 sec)
Payer → [TX2] → Payee (fee + 3-5 sec)
Payer → [TX3] → Payee (fee + 3-5 sec)
...
1000 transactions = 1000 fees, 1000 ledger entries
- Payer opens channel with 100 XRP [On-chain: 1 TX]
- Payer signs claim for 0.01 XRP [Off-chain]
- Payer signs claim for 0.02 XRP [Off-chain]
- Payer signs claim for 0.03 XRP [Off-chain]
- Payee closes with final claim [On-chain: 1 TX]
Result: 2 on-chain transactions, unlimited off-chain updates
```
Channel: On-chain object that locks XRP from payer, with payee designated as recipient.
Claim: Off-chain signed message from payer authorizing payee to claim a certain amount.
Settlement: On-chain transaction that closes the channel and distributes funds.
Important
: Claims are cumulative, not incremental. Claim for 0.05 XRP means "I authorize you to claim 0.05 XRP total," not "here's another 0.05 XRP."
- Can close channel anytime (with delay)
- Only funds up to locked amount at risk
- Can specify expiration
- Has signed claims from payer
- Can submit highest claim before channel closes
- Claims are cryptographically non-repudiable
- Claim more than channel balance
- Forge the other's signature
- Reverse finalized settlements
// src/payment-channels/create-channel.js
const xrpl = require('xrpl');
async function createPaymentChannel(
payerWallet,
payeeAddress,
amountXRP,
options = {}
) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
// Generate channel keys (optional: can use account keys)
// Using separate keys is more secure for automated systems
const publicKey = options.publicKey || payerWallet.publicKey;
const channelCreate = {
TransactionType: 'PaymentChannelCreate',
Account: payerWallet.address,
Destination: payeeAddress,
Amount: xrpl.xrpToDrops(amountXRP),
SettleDelay: options.settleDelay || 3600, // 1 hour default
PublicKey: publicKey
};
// Optional: Set expiration
if (options.cancelAfter) {
channelCreate.CancelAfter = dateToRippleTime(options.cancelAfter);
}
// Optional: Require destination tag
if (options.destinationTag !== undefined) {
channelCreate.DestinationTag = options.destinationTag;
}
console.log('Creating payment channel:');
console.log( Payer: ${payerWallet.address});
console.log( Payee: ${payeeAddress});
console.log( Amount: ${amountXRP} XRP);
console.log( Settle delay: ${channelCreate.SettleDelay} seconds);
const prepared = await client.autofill(channelCreate);
const signed = payerWallet.sign(prepared);
const result = await client.submitAndWait(signed.tx_blob);
if (result.result.meta.TransactionResult === 'tesSUCCESS') {
// Find the created channel ID
const channelNode = result.result.meta.AffectedNodes.find(
node => node.CreatedNode?.LedgerEntryType === 'PayChannel'
);
const channelId = channelNode?.CreatedNode?.LedgerIndex;
console.log('✓ Payment channel created');
console.log( Channel ID: ${channelId});
return {
success: true,
channelId: channelId,
publicKey: publicKey,
hash: signed.hash
};
}
return {
success: false,
resultCode: result.result.meta.TransactionResult
};
} finally {
await client.disconnect();
}
}
function dateToRippleTime(date) {
return Math.floor(date.getTime() / 1000) - 946684800;
}
module.exports = { createPaymentChannel };
```
The SettleDelay is crucial for security:
// SettleDelay = time payee has to submit claims after payer requests close
// Short delay (e.g., 60 seconds):
// + Payer gets funds back quickly
// - Payee has limited time to respond
// Long delay (e.g., 86400 = 24 hours):
// + Payee has plenty of time to claim
// - Payer's funds locked longer
// Recommendations:
// - Trusted relationship: 300-3600 seconds (5 min - 1 hour)
// - Automated systems: 3600-86400 seconds (1 - 24 hours)
// - High-value channels: 86400+ seconds (24+ hours)
Claims are signed authorizations for the payee to claim XRP:
// src/payment-channels/claims.js
const xrpl = require('xrpl');
const { encodeForSigning } = require('ripple-binary-codec');
function createClaim(channelId, amountDrops, payerWallet) {
// The claim data that gets signed
const claimData = {
channel: channelId,
amount: amountDrops.toString()
};
// Sign the claim
// Note: xrpl.js handles claim signing differently
const signature = payerWallet.sign(
encodeClaimForSigning(channelId, amountDrops)
);
return {
channelId: channelId,
amount: amountDrops,
signature: signature
};
}
// Alternative using xrpl.js built-in method
function createClaimWithXrplJs(channelId, amountDrops, payerSeed) {
const wallet = xrpl.Wallet.fromSeed(payerSeed);
// xrpl.js provides authorizeChannel for this
const signature = wallet.sign(
xrpl.encodeForSigningClaim({
channel: channelId,
amount: amountDrops.toString()
})
);
return {
channelId,
amount: amountDrops,
signature,
publicKey: wallet.publicKey
};
}
// Practical claim generation
class PaymentChannelPayer {
constructor(wallet, channelId, channelBalance) {
this.wallet = wallet;
this.channelId = channelId;
this.channelBalance = Number(channelBalance);
this.totalClaimed = 0;
}
// Create a claim for additional amount
createClaimFor(additionalDrops) {
const newTotal = this.totalClaimed + additionalDrops;
if (newTotal > this.channelBalance) {
throw new Error(`Insufficient channel balance. Have: ${this.channelBalance}, Need: ${newTotal}`);
}
const claim = {
channelId: this.channelId,
amount: newTotal.toString(),
// In production, use proper signing
signature: this.signClaim(newTotal)
};
this.totalClaimed = newTotal;
return claim;
}
signClaim(amountDrops) {
// Simplified - in production use xrpl.js signing
return `SIGNATURE_FOR_${this.channelId}_${amountDrops}`;
}
}
module.exports = { createClaim, PaymentChannelPayer };
The payee must verify claims before trusting them:
// src/payment-channels/verify-claim.js
const xrpl = require('xrpl');
class PaymentChannelPayee {
constructor(channelId, payerPublicKey) {
this.channelId = channelId;
this.payerPublicKey = payerPublicKey;
this.highestClaim = null;
this.claimHistory = [];
}
// Verify and store a claim
verifyClaim(claim) {
// 1. Check channel ID matches
if (claim.channelId !== this.channelId) {
return { valid: false, error: 'Channel ID mismatch' };
}
// 2. Check amount is higher than previous
const claimAmount = Number(claim.amount);
if (this.highestClaim && claimAmount <= Number(this.highestClaim.amount)) {
return { valid: false, error: 'Amount not higher than previous claim' };
}
// 3. Verify signature
const signatureValid = this.verifySignature(claim);
if (!signatureValid) {
return { valid: false, error: 'Invalid signature' };
}
// 4. Store as highest claim
this.highestClaim = claim;
this.claimHistory.push({
claim,
receivedAt: new Date()
});
return {
valid: true,
amount: claimAmount,
incremental: this.claimHistory.length > 1
? claimAmount - Number(this.claimHistory[this.claimHistory.length - 2].claim.amount)
: claimAmount
};
}
verifySignature(claim) {
// In production, properly verify the cryptographic signature
// using the payer's public key
return xrpl.verifySignature(
xrpl.encodeForSigningClaim({
channel: claim.channelId,
amount: claim.amount
}),
claim.signature,
this.payerPublicKey
);
}
getHighestClaim() {
return this.highestClaim;
}
}
module.exports = { PaymentChannelPayee };
// Example: Streaming payment protocol
class StreamingPaymentSession {
constructor(payer, payee, channelId, rateDropsPerSecond) {
this.payer = payer; // PaymentChannelPayer instance
this.payee = payee; // PaymentChannelPayee instance
this.channelId = channelId;
this.rate = rateDropsPerSecond;
this.intervalId = null;
}
start() {
console.log(Starting streaming payments at ${this.rate} drops/second);
this.intervalId = setInterval(() => {
// Payer creates new claim
const claim = this.payer.createClaimFor(this.rate);
// Payee verifies and accepts
const result = this.payee.verifyClaim(claim);
if (result.valid) {
console.log(Claim accepted: ${result.amount} drops total (+${result.incremental}));
} else {
console.error(Claim rejected: ${result.error});
this.stop();
}
}, 1000);
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
console.log('Streaming stopped');
console.log(Final claim: ${this.payee.getHighestClaim()?.amount} drops);
}
}
```
The payee submits their highest claim to close and receive funds:
// src/payment-channels/close-channel.js
const xrpl = require('xrpl');
async function closeChannelWithClaim(
payeeWallet,
channelId,
claim, // { amount, signature }
publicKey
) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
const channelClaim = {
TransactionType: 'PaymentChannelClaim',
Account: payeeWallet.address,
Channel: channelId,
Amount: claim.amount,
Signature: claim.signature,
PublicKey: publicKey,
Flags: 0x00020000 // tfClose - close the channel
};
console.log('Closing channel with claim:');
console.log(` Channel: ${channelId}`);
console.log(` Amount: ${xrpl.dropsToXrp(claim.amount)} XRP`);
const prepared = await client.autofill(channelClaim);
const signed = payeeWallet.sign(prepared);
const result = await client.submitAndWait(signed.tx_blob);
if (result.result.meta.TransactionResult === 'tesSUCCESS') {
console.log('✓ Channel closed successfully');
return { success: true, hash: signed.hash };
}
return {
success: false,
resultCode: result.result.meta.TransactionResult
};
} finally {
await client.disconnect();
}
}
// Payee can also claim without closing
async function claimFromChannel(
payeeWallet,
channelId,
claim,
publicKey
) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
const channelClaim = {
TransactionType: 'PaymentChannelClaim',
Account: payeeWallet.address,
Channel: channelId,
Amount: claim.amount,
Signature: claim.signature,
PublicKey: publicKey
// No tfClose flag - channel stays open
};
const prepared = await client.autofill(channelClaim);
const signed = payeeWallet.sign(prepared);
const result = await client.submitAndWait(signed.tx_blob);
return {
success: result.result.meta.TransactionResult === 'tesSUCCESS',
hash: signed.hash
};
} finally {
await client.disconnect();
}
}
module.exports = { closeChannelWithClaim, claimFromChannel };
The payer can request to close, triggering the settle delay:
async function payerRequestClose(payerWallet, channelId) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
const channelClaim = {
TransactionType: 'PaymentChannelClaim',
Account: payerWallet.address,
Channel: channelId,
Flags: 0x00010000 // tfRenew = false, requests close
};
console.log('Requesting channel close (settle delay starts)');
const prepared = await client.autofill(channelClaim);
const signed = payerWallet.sign(prepared);
const result = await client.submitAndWait(signed.tx_blob);
if (result.result.meta.TransactionResult === 'tesSUCCESS') {
// Get channel info to see when it closes
const channelInfo = await getChannelInfo(client, channelId);
const closeTime = new Date(channelInfo.Expiration * 1000);
console.log(`Channel will close at: ${closeTime.toISOString()}`);
console.log('Payee must submit claims before then');
return { success: true, closesAt: closeTime };
}
return { success: false };
} finally {
await client.disconnect();
}
}
async function getChannelInfo(client, channelId) {
const response = await client.request({
command: 'ledger_entry',
index: channelId
});
return response.result.node;
}
The payer can add more XRP to an existing channel:
async function fundChannel(payerWallet, channelId, additionalXRP) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
const fund = {
TransactionType: 'PaymentChannelFund',
Account: payerWallet.address,
Channel: channelId,
Amount: xrpl.xrpToDrops(additionalXRP)
};
// Optional: Extend expiration
// fund.Expiration = newExpirationRippleTime;
const prepared = await client.autofill(fund);
const signed = payerWallet.sign(prepared);
const result = await client.submitAndWait(signed.tx_blob);
console.log(`Added ${additionalXRP} XRP to channel`);
return {
success: result.result.meta.TransactionResult === 'tesSUCCESS',
hash: signed.hash
};
} finally {
await client.disconnect();
}
}
// src/payment-channels/query-channels.js
const xrpl = require('xrpl');
async function getAccountChannels(address, destinationAddress = null) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
const request = {
command: 'account_channels',
account: address
};
if (destinationAddress) {
request.destination_account = destinationAddress;
}
const response = await client.request(request);
return response.result.channels.map(channel => ({
channelId: channel.channel_id,
account: channel.account,
destination: channel.destination_account,
amount: Number(channel.amount) / 1_000_000,
balance: Number(channel.balance) / 1_000_000,
settleDelay: channel.settle_delay,
publicKey: channel.public_key,
expiration: channel.expiration
? new Date((channel.expiration + 946684800) * 1000)
: null,
cancelAfter: channel.cancel_after
? new Date((channel.cancel_after + 946684800) * 1000)
: null,
status: getChannelStatus(channel)
}));
} finally {
await client.disconnect();
}
}
function getChannelStatus(channel) {
const now = Date.now() / 1000 - 946684800;
if (channel.expiration && now >= channel.expiration) {
return 'expired';
}
if (channel.cancel_after && now >= channel.cancel_after) {
return 'cancellable';
}
if (channel.expiration) {
return 'closing'; // Close requested, in settle delay
}
return 'open';
}
async function displayChannels(address) {
const channels = await getAccountChannels(address);
console.log(\n=== Payment Channels for ${address} ===\n);
for (const channel of channels) {
console.log(Channel: ${channel.channelId.substring(0, 16)}...);
console.log( Destination: ${channel.destination});
console.log( Amount: ${channel.amount} XRP);
console.log( Claimed: ${channel.balance} XRP);
console.log( Available: ${channel.amount - channel.balance} XRP);
console.log( Status: ${channel.status});
console.log( Settle delay: ${channel.settleDelay} seconds);
if (channel.expiration) {
console.log( Expires: ${channel.expiration.toISOString()});
}
console.log('');
}
}
module.exports = { getAccountChannels, displayChannels };
```
// src/payment-channels/api-billing.js
class PayPerUseAPI {
constructor(channelId, pricePerCallDrops, payerSession, payeeVerifier) {
this.channelId = channelId;
this.pricePerCall = pricePerCallDrops;
this.payer = payerSession;
this.payee = payeeVerifier;
this.callCount = 0;
}
async makeAPICall(endpoint, params) {
// 1. Create claim for this call
const claim = this.payer.createClaimFor(this.pricePerCall);
// 2. Send claim with API request
const response = await this.sendRequest(endpoint, params, claim);
// 3. Service validates claim before responding
if (response.error === 'invalid_payment') {
throw new Error('Payment claim rejected');
}
this.callCount++;
return response.data;
}
async sendRequest(endpoint, params, claim) {
// In real implementation, this would be HTTP/WebSocket
// Including the claim in request headers or body
return {
data: { result: 'API response' },
accepted: true
};
}
getUsage() {
return {
calls: this.callCount,
totalPaid: this.callCount * this.pricePerCall
};
}
}
// Service side
class PayPerUseAPIService {
constructor(payeeVerifier) {
this.payee = payeeVerifier;
this.minPaymentPerCall = 1000; // 1000 drops per call
}
handleRequest(request, claim) {
// Verify payment
const result = this.payee.verifyClaim(claim);
if (!result.valid) {
return { error: 'invalid_payment', message: result.error };
}
if (result.incremental < this.minPaymentPerCall) {
return { error: 'insufficient_payment' };
}
// Process request
return { data: this.processRequest(request) };
}
processRequest(request) {
return { result: 'processed' };
}
}
```
// Pay per second of video watched
class StreamingMediaPayment {
constructor(options) {
this.channelId = options.channelId;
this.payer = options.payer;
this.payee = options.payee;
this.rateDropsPerSecond = options.rate || 100; // 0.0001 XRP/second
this.isStreaming = false;
this.intervalId = null;
}
startStreaming() {
if (this.isStreaming) return;
this.isStreaming = true;
console.log(Streaming started at ${this.rateDropsPerSecond} drops/second);
this.intervalId = setInterval(() => {
try {
const claim = this.payer.createClaimFor(this.rateDropsPerSecond);
const result = this.payee.verifyClaim(claim);
if (!result.valid) {
console.log('Payment failed, stopping stream');
this.stopStreaming();
}
} catch (error) {
if (error.message.includes('Insufficient')) {
console.log('Channel depleted, stopping stream');
this.stopStreaming();
}
}
}, 1000);
}
stopStreaming() {
this.isStreaming = false;
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
const finalClaim = this.payee.getHighestClaim();
console.log(Stream ended. Total: ${finalClaim?.amount || 0} drops);
}
getStats() {
const claim = this.payee.getHighestClaim();
return {
isStreaming: this.isStreaming,
totalPaid: claim ? Number(claim.amount) : 0,
rate: this.rateDropsPerSecond
};
}
}
```
Payment channels are elegant for high-frequency, low-value transfers between known parties. They excel for streaming payments, API billing, and IoT. However, they require both parties to be online and maintain state. For one-off payments or many recipients, regular transactions are simpler.
Assignment: Build a complete payment channel application demonstrating streaming payments.
Requirements:
Create payment channels with configurable parameters
Fund existing channels with additional XRP
Query channel status and balance
Close channels (payee and payer flows)
Generate valid claims from payer
Verify claims on payee side
Track claim history
Handle claim errors
Implement pay-per-second streaming
Display real-time payment counter
Handle channel depletion gracefully
Show final settlement
Payee submits highest claim to close
Handle partial claims
Display final balances
Error recovery procedures
Channel management works correctly (25%)
Claim generation and verification secure (25%)
Streaming demo functional (25%)
Settlement handles all cases (25%)
Time investment: 4 hours
Value: Understanding of off-chain scaling patterns applicable beyond XRPL
Knowledge Check
Question 1 of 2Why does the channel have a SettleDelay?
- Payment Channels: https://xrpl.org/payment-channels.html
- PaymentChannelCreate: https://xrpl.org/paymentchannelcreate.html
- PaymentChannelClaim: https://xrpl.org/paymentchannelclaim.html
- PaymentChannelFund: https://xrpl.org/paymentchannelfund.html
- Coil (web monetization): Historical example of payment channels for content
- Gaming micropayments: Various implementations
For Next Lesson:
Phase 2 is complete! You now understand XRPL's core features: trust lines, tokens, DEX, AMM, escrow, and payment channels. Phase 3 begins with Lesson 12: Multi-Signing—for secure, multi-party account control.
End of Lesson 11
Total words: ~5,100
Estimated completion time: 55 minutes reading + 4 hours for deliverable
Congratulations! You've completed the Core Features phase. You can now:
✅ Create and manage trust lines for issued currencies
✅ Send cross-currency payments with pathfinding
✅ Trade on the native DEX with limit orders
✅ Interact with AMM pools for liquidity
✅ Create time-locked and conditional escrows
✅ Build streaming payments with payment channels
Multi-signing for shared control
Real-time WebSocket subscriptions
Error handling and debugging
Security best practices
Performance optimization
You're ready to build production-grade applications.
Key Takeaways
Claims are cumulative
: A 100-drop claim then 200-drop claim means 200 total, not 300.
Payee holds the power
: Only the payee (with valid claims) can withdraw funds. The payer just authorizes.
SettleDelay is critical security
: It gives the payee time to submit claims when payer requests close.
Keep highest claim safe
: The payee should always keep their highest valid claim—it's their money.
Channel size limits exposure
: Only fund channels with what you're willing to spend in that relationship. ---