Payment Channels - High-Frequency, Low-Cost | 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
intermediate55 min

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
  1. Payer opens channel with 100 XRP [On-chain: 1 TX]
  2. Payer signs claim for 0.01 XRP [Off-chain]
  3. Payer signs claim for 0.02 XRP [Off-chain]
  4. Payer signs claim for 0.03 XRP [Off-chain]
  5. 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 2

Why does the channel have a SettleDelay?

  • 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

1

Claims are cumulative

: A 100-drop claim then 200-drop claim means 200 total, not 300.

2

Payee holds the power

: Only the payee (with valid claims) can withdraw funds. The payer just authorizes.

3

SettleDelay is critical security

: It gives the payee time to submit claims when payer requests close.

4

Keep highest claim safe

: The payee should always keep their highest valid claim—it's their money.

5

Channel size limits exposure

: Only fund channels with what you're willing to spend in that relationship. ---