Technical Architecture

Deep dive into the x402 payment channel implementation on Solana

System Overview

x402 implements unidirectional payment channels on Solana, optimized for HTTP API micropayments. The protocol minimizes on-chain transactions while maintaining security and trustlessness.

On-Chain State

Payment channel accounts store channel state, balances, and participant keys on Solana

Off-Chain Flow

HTTP requests include payment proofs that update channel state without blockchain transactions

Security Model

Cryptographic signatures ensure payment authenticity and prevent double-spending

Performance

Sub-millisecond payment verification with only 2 on-chain transactions per channel lifecycle

Payment Channel Lifecycle

1. Channel Opening (with USDC & Credit Limit)

Client opens a channel by depositing USDC and setting optional credit limit for overdraft protection

// Generate unique channel ID
const channelId = Buffer.from(randomBytes(32));

// Derive channel PDA
const [channelPDA] = PublicKey.findProgramAddressSync(
  [Buffer.from("channel"), channelId],
  programId
);

// Derive token account PDA for channel's USDC
const [channelTokenAccount] = PublicKey.findProgramAddressSync(
  [Buffer.from("channel_token"), channelId],
  programId
);

const tx = await program.methods
  .openChannel(
    Array.from(channelId),
    new BN(1000000),    // initialDeposit: 1 USDC (min: 1 USDC)
    new BN(expiry),     // expiry: Unix timestamp
    new BN(1000000000)  // creditLimit: 1000 USDC (max: 1000 USDC)
  )
  .accounts({
    channel: channelPDA,
    channelToken: channelTokenAccount,
    client: clientPublicKey,
    server: serverPublicKey,
    clientTokenAccount: clientUSDCAccount,
    mint: usdcMintAddress,
    tokenProgram: TOKEN_PROGRAM_ID,
    systemProgram: SystemProgram.programId,
  })
  .rpc();

// Channel now exists on-chain with:
// - channel_id: unique 32-byte identifier
// - client: client public key
// - server: server public key
// - client_deposit: 1 USDC
// - server_claimed: 0
// - nonce: 0
// - status: ChannelStatus::Open
// - debt_owed: 0
// - credit_limit: 1000 USDC

2. Off-Chain Payments

Each HTTP request includes a signed payment proof updating channel state

// Client generates payment proof for each request
const payment = {
  channelId: channelAccount.publicKey,
  amount: 0.001,
  nonce: currentNonce + 1,
  timestamp: Date.now(),
};

// Sign the payment
const signature = await wallet.signMessage(
  Buffer.from(JSON.stringify(payment))
);

// Include in HTTP request headers
headers: {
  'X-Payment-Channel': channelId,
  'X-Payment-Amount': amount,
  'X-Payment-Nonce': nonce,
  'X-Payment-Signature': signature,
}

// Server verifies signature and updates internal state
// No on-chain transaction needed!

3. Channel Closing

Either party can close the channel, settling final balance on-chain

// Server or client initiates close
const tx = await program.methods
  .closeChannel(finalNonce, totalPaid)
  .accounts({
    channel: channelAccount.publicKey,
    sender: wallet.publicKey,
    recipient: serverPublicKey,
  })
  .rpc();

// Settlement logic:
// - Verify final payment signature
// - Transfer totalPaid to recipient
// - Return remaining balance to sender
// - Close channel account

Channel State Machine

#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq, Eq)]
pub enum ChannelStatus {
  Open,      // Channel is active, accepting off-chain payments
  Closed,    // Channel settled and closed (funds distributed)
  Disputed,  // Dispute initiated, 24-hour resolution period active
}

// State transitions:
// Open -> Closed: close_channel() called (only if debt_owed == 0)
// Open -> Disputed: dispute_channel() called by either party
// Disputed -> Closed: dispute_close() or resolve_dispute() called
//
// Note: No "Closing" state exists - channels close immediately

Core Data Structures

Channel Account (On-Chain) - 162 Bytes

#[account]
pub struct PaymentChannel {
    /// Unique channel identifier
    pub channel_id: [u8; 32],      // 32 bytes

    /// Client public key (who funds the channel)
    pub client: Pubkey,             // 32 bytes

    /// Server public key (who provides the service)
    pub server: Pubkey,             // 32 bytes

    /// Total USDC deposited by client
    pub client_deposit: u64,        // 8 bytes

    /// Cumulative USDC claimed by server
    pub server_claimed: u64,        // 8 bytes

    /// Monotonically increasing payment nonce
    pub nonce: u64,                 // 8 bytes

    /// Channel expiration timestamp (Unix)
    pub expiry: i64,                // 8 bytes

    /// Current channel status
    pub status: ChannelStatus,      // 1 byte + padding

    /// Channel creation timestamp
    pub created_at: i64,            // 8 bytes

    /// Last state update timestamp
    pub last_update: i64,           // 8 bytes

    /// Outstanding debt owed by client (overdraft)
    pub debt_owed: u64,             // 8 bytes

    /// Maximum allowed overdraft amount
    pub credit_limit: u64,          // 8 bytes

    /// PDA bump seed
    pub bump: u8,                   // 1 byte
}
// Total: 162 bytes

Payment Proof (Off-Chain) - 109 Byte Message

// Client constructs 109-byte message for Ed25519 signing
const message = Buffer.concat([
  Buffer.from("x402-channel-claim-v1"),  // Domain separator (21 bytes)
  channelPDA.toBuffer(),                  // Channel PDA address (32 bytes)
  serverPubkey.toBuffer(),                // Server public key (32 bytes)
  amountBuffer,                           // Claimed amount u64 LE (8 bytes)
  nonceBuffer,                            // Nonce u64 LE (8 bytes)
  expiryBuffer,                           // Expiry timestamp i64 LE (8 bytes)
]);
// Total: 21 + 32 + 32 + 8 + 8 + 8 = 109 bytes

// Client signs with Ed25519 
const signature = nacl.sign.detached(message, clientKeypair.secretKey);

// Verification on Solana uses Ed25519 instruction sysvar pattern
// Two instructions are created:
// 1. Ed25519Program.createInstructionWithPublicKey()
// 2. claim_payment() instruction that verifies signature via sysvar

Security Features

1. Ed25519 Signature Verification

All payment claims use Ed25519 signatures. On-chain verification via Ed25519 instruction sysvar:

// Construct exact 109-byte message
const message = Buffer.concat([
  Buffer.from("x402-channel-claim-v1"),  // Domain separator
  channelPDA.toBuffer(),
  serverPubkey.toBuffer(),
  amountBuffer,   // u64 little-endian
  nonceBuffer,    // u64 little-endian
  expiryBuffer,   // i64 little-endian
]);

// Verify using Ed25519 sysvar instruction
// Program checks: ed25519_instruction_index == 0
require!(message.len() == 109, "InvalidMessageLength");

2. Domain Separator (Replay Protection)

"x402-channel-claim-v1" domain separator prevents cross-protocol replay attacks. Signatures from other systems cannot be reused.

3. Nonce Increment Limits

Nonce can only increase by max 10,000 per claim. Prevents griefing attacks where malicious client skips billions of nonces to DoS channel.

4. Balance & Credit Enforcement

On-chain program enforces balance constraints with overdraft protection:

// Check if claim exceeds available balance
if claim_amount > available_balance {
    let overdraft = claim_amount - available_balance;
    require!(
        channel.debt_owed + overdraft <= channel.credit_limit,
        ErrorCode::ExceedsCreditLimit
    );
}

// Minimum deposit: 1 USDC (1,000,000 microunits)
// Maximum credit: 1,000 USDC per channel

5. Expiry & Timeout Protection

Channels have expiry timestamps. Expired channels can be force-closed by either party, preventing fund lockup from unresponsive counterparties.

6. Dispute Resolution (24-Hour Challenge Period)

Either party can initiate a dispute. During 24-hour challenge period, parties can submit latest signed state. After timeout, funds distributed according to highest valid claim.

Complete Instruction Handlers (7 Total)

1. open_channel

Creates a new payment channel with USDC deposit and optional credit limit

pub fn open_channel(
    ctx: Context<OpenChannel>,
    channel_id: [u8; 32],
    initial_deposit: u64,  // Min: 1 USDC (1,000,000 microunits)
    expiry: i64,           // Unix timestamp
    credit_limit: u64      // Max: 1000 USDC (1,000,000,000 microunits)
) -> Result<()>

2. add_funds

Client adds more USDC to channel. Automatically settles debt first if any exists.

pub fn add_funds(
    ctx: Context<AddFunds>,
    amount: u64
) -> Result<()>

// Debt settlement logic:
// 1. debt_payment = min(amount, debt_owed)
// 2. net_deposit = amount - debt_payment
// 3. debt_owed -= debt_payment
// 4. client_deposit += net_deposit

3. claim_payment

Server claims USDC using client's Ed25519 signature. Supports overdraft up to credit limit.

pub fn claim_payment(
    ctx: Context<ClaimPayment>,
    amount: u64,
    nonce: u64,
    ed25519_instruction_index: u8  // Must be 0
) -> Result<()>

// Overdraft logic:
// if amount > available_balance {
//     overdraft = amount - available_balance
//     require!(debt_owed + overdraft <= credit_limit)
//     debt_owed += overdraft
// }

4. close_channel

Closes channel and returns remaining funds. Cannot close if debt exists.

pub fn close_channel(
    ctx: Context<CloseChannel>
) -> Result<()>

// Requirements:
// - debt_owed must be 0
// - Returns: (client_deposit - server_claimed) to client
// - Sets status = ChannelStatus::Closed

5. dispute_channel

Either party initiates a dispute, starting 24-hour challenge period.

pub fn dispute_channel(
    ctx: Context<DisputeChannel>,
    reason: String  // Optional dispute reason
) -> Result<()>

// Sets status = ChannelStatus::Disputed
// Starts 24-hour timer for challenge period

6. dispute_close

During dispute period, submit latest signed state with higher nonce.

pub fn dispute_close(
    ctx: Context<DisputeClose>,
    latest_amount: u64,
    latest_nonce: u64,
    ed25519_instruction_index: u8
) -> Result<()>

// Updates channel to latest valid state
// Resets 24-hour challenge period

7. resolve_dispute

After 24-hour period expires, finalize dispute and distribute funds.

pub fn resolve_dispute(
    ctx: Context<ResolveDispute>,
    to_client: u64,
    to_server: u64
) -> Result<()>

// Requirements:
// - 24 hours elapsed since last dispute_close
// - to_client + to_server == total channel funds
// - Distributes funds and closes channel

Overdraft Feature (Credit System)

Payment channels support overdraft protection, allowing servers to claim more than the current balance up to a credit limit. This prevents service disruption while client tops up funds.

// When opening channel, set credit limit
open_channel(channelId, initialDeposit: 1 USDC, expiry, creditLimit: 1000 USDC)

// Server can claim beyond available balance
available = client_deposit - server_claimed = 0.5 USDC
claim_payment(amount: 2 USDC, nonce, signature)
// ✅ Allowed! Creates 1.5 USDC debt

// Debt tracking
channel.debt_owed = 1.5 USDC  // Overdraft amount
channel.credit_limit = 1000 USDC  // Max allowed

// Client settles debt when adding funds
add_funds(10 USDC)
// Automatic settlement:
// - 1.5 USDC pays off debt
// - 8.5 USDC added to balance
// - debt_owed = 0

// Cannot close channel with outstanding debt
close_channel()  // ❌ Error: CannotCloseWithDebt

// Constraints:
// - Maximum credit_limit: 1000 USDC per channel
// - debt_owed + new_overdraft <= credit_limit
// - Cannot close until debt_owed == 0

SPL Token (USDC) Implementation

USDC, Not SOL

All payment channels use USDC (SPL Token), NOT native SOL. Channels hold funds in Program Derived Address (PDA) token accounts.

Token Account Structure

// Channel PDA seeds
seeds = [b"channel", channel_id]

// Token account PDA seeds
token_seeds = [b"channel_token", channel_id]

// Transfers use SPL Token Program
token::transfer(
    CpiContext::new(
        ctx.accounts.token_program.to_account_info(),
        Transfer {
            from: client_token_account,
            to: channel_token_account,
            authority: client,
        }
    ),
    amount  // USDC microunits (1 USDC = 1,000,000)
)

Amount Precision

- 1 USDC = 1,000,000 microunits (6 decimals)
- Minimum deposit: 1,000,000 microunits (1 USDC)
- Maximum credit limit: 1,000,000,000 microunits (1000 USDC)

Performance Metrics

Payment Verification
< 1ms
On-Chain Transactions
2 per channel
Transaction Cost
~$0.0005
Throughput
1000+ req/s

Events System (9 Events)

The program emits events for all state changes, enabling off-chain indexing and monitoring:

ChannelOpened

channel_id, client, server, deposit, expiry, credit_limit

FundsAdded

amount, debt_settled, net_deposit, remaining_debt, new_balance

DebtSettled

amount_settled, remaining_debt

PaymentClaimed

amount, total_claimed, nonce, overdraft_incurred

DebtIncurred

overdraft_amount, total_debt, credit_limit

DisputeInitiated

channel_id, disputer, reason

ChannelDisputeClosed

channel_id, to_server, to_client

DisputeResolved

channel_id, to_client, to_server, resolver

ChannelClosed

channel_id, remaining_returned

Error Codes (19 Total)

InvalidExpiry - Expiry timestamp invalid/past
InvalidDeposit - Deposit below minimum (1 USDC)
ChannelClosed - Channel already closed
InvalidNonce - Nonce not greater than current
InsufficientFunds - Insufficient balance for operation
CannotClose - Generic close prevention
InvalidSignature - Ed25519 signature verification failed
UnauthorizedAccess - Caller not authorized
InvalidAmount - Amount is zero or invalid
InvalidMint - Token mint mismatch (not USDC)
ChannelExpired - Channel past expiry timestamp
ArithmeticOverflow - Math operation overflow
NonceIncrementTooLarge - Nonce jumped by >10,000
DepositTooSmall - Below 1 USDC minimum
ChannelNotDisputed - Dispute operation on non-disputed channel
InvalidResolution - Dispute resolution amounts invalid
ExceedsCreditLimit - Overdraft exceeds credit limit
CannotCloseWithDebt - Cannot close while debt_owed > 0
InvalidCreditLimit - Credit limit exceeds max (1000 USDC)

Implementation Stack

Solana Program

  • • Anchor framework v0.29+
  • • Rust 1.75+
  • • Program derived addresses (PDAs)
  • • Cross-program invocations (CPIs)

TypeScript SDK

  • • @solana/web3.js v2.0+ (latest)
  • • @coral-xyz/anchor v0.30+
  • • 109-byte message serialization utilities
  • • Ed25519 transaction builder
  • • Channel state caching (30-second TTL)
  • • All 7 instruction wrappers
  • • Generated IDL types from Anchor
  • • Full TypeScript type safety