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 USDC2. 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 accountChannel 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 immediatelyCore 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 bytesPayment 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 sysvarSecurity 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 channel5. 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_deposit3. 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::Closed5. 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 period6. 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 period7. 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 channelOverdraft 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 == 0SPL 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
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_limitFundsAdded
amount, debt_settled, net_deposit, remaining_debt, new_balanceDebtSettled
amount_settled, remaining_debtPaymentClaimed
amount, total_claimed, nonce, overdraft_incurredDebtIncurred
overdraft_amount, total_debt, credit_limitDisputeInitiated
channel_id, disputer, reasonChannelDisputeClosed
channel_id, to_server, to_clientDisputeResolved
channel_id, to_client, to_server, resolverChannelClosed
channel_id, remaining_returnedError Codes (19 Total)
InvalidExpiry - Expiry timestamp invalid/pastInvalidDeposit - Deposit below minimum (1 USDC)ChannelClosed - Channel already closedInvalidNonce - Nonce not greater than currentInsufficientFunds - Insufficient balance for operationCannotClose - Generic close preventionInvalidSignature - Ed25519 signature verification failedUnauthorizedAccess - Caller not authorizedInvalidAmount - Amount is zero or invalidInvalidMint - Token mint mismatch (not USDC)ChannelExpired - Channel past expiry timestampArithmeticOverflow - Math operation overflowNonceIncrementTooLarge - Nonce jumped by >10,000DepositTooSmall - Below 1 USDC minimumChannelNotDisputed - Dispute operation on non-disputed channelInvalidResolution - Dispute resolution amounts invalidExceedsCreditLimit - Overdraft exceeds credit limitCannotCloseWithDebt - Cannot close while debt_owed > 0InvalidCreditLimit - 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