Private Channels has not been security audited and is not recommended for production use with real funds without a thorough security review.
Deploying an instance? Go to the Operators guide. Integrating against an existing instance? Go to the Quickstart. This page is the architecture reference for both audiences.
Architecture
Private Channels is composed of four components: two on-chain Solana programs (Escrow and Withdraw) and two off-chain services (Gateway and Auth Service). Together they form a state channel protocol where funds live on Mainnet but transfers settle off-chain.
Escrow Program
The Escrow Program is an on-chain Solana program that holds deposited SPL tokens. It is the trust anchor of the system: all funds ultimately live in escrow until an operator provides a valid Sparse Merkle Tree exclusion proof to release them.
- Program ID:
9tgHa1DcnaSSUtmMsst8ovKTe1Gfxzezn27KnH9xXYeU - This ID is compiled into the program binary via
declare_id!(). Off-chain services read the same ID at compile time from the generated client crate, not from an environment variable. - Manages
Instance,AllowedMint, andOperatorPDAs - Instructions:
CreateInstance,AllowMint,BlockMint,AddOperator,RemoveOperator,SetNewAdmin,Deposit,ReleaseFunds,ResetSmtRoot
Withdraw Program
The Withdraw Program runs on the private channel network, not on Solana Mainnet.
Users call WithdrawFunds to burn their channel-side token balance. This burn
does not automatically release funds; it signals to the operator that a
withdrawal is pending. The operator then calls ReleaseFunds on the Escrow
Program with a valid SMT proof to complete settlement.
- Program ID:
J231K9UEpS4y4KAPwGc4gsMNCjKFRMYcQBcjVW7vBhVi - This ID is compiled into the program binary. Off-chain services read the same ID at compile time from the generated client crate, not from an environment variable.
Gateway
The Gateway is a Solana JSON-RPC-compatible proxy that routes client requests to
the channel network's write node (for transaction submission) and read node (for
queries). It is configured via environment variables: GATEWAY_PORT,
GATEWAY_WRITE_URL, GATEWAY_READ_URL.
Health endpoints (no authentication required):
GET /health- liveness check; returns200 {"status":"ok"}GET /ready- deep readiness, probes write + read nodes; returns200 {"status":"ready"}or503 {"status":"degraded"}
RPC Method Routing and Access
The gateway routes sendTransaction to the write node and all other methods to
the read node. Requests larger than 64 KB are rejected with HTTP 413. When
authentication is enabled, method access is gated by JWT role. See
Authentication & Roles for the
full method matrix.
Auth Service
The Auth Service is an optional component that issues HS256 JWTs (24-hour
expiry) for gateway access control. It is enabled when the JWT_SECRET
environment variable is set. Without it, the gateway accepts all connections.
JWT claims: sub (user UUID), role ("user" or "operator"), iss
("private-channel-auth"), aud ("private-channel-gateway"), exp (Unix
timestamp). iss and aud are validated by the gateway's JWT configuration,
not deserialized into the application claims struct: only sub, role, and
exp are available to application-layer code.
Roles:
user- access gated to own verified wallets; cannot callgetBlock,getTransaction, orsimulateTransactionoperator- bypasses all ownership checks; full RPC method access; must be provisioned in the database (no self-service escalation)
Streamer
The Streamer is a WebSocket server that pushes channel state updates to connected clients in real time, eliminating the need to poll the RPC. It polls PostgreSQL for state changes. It's part of the base Docker Compose stack, not the devnet stack this guide deploys; see the Configuration reference.
- Port:
8902, configurable viaSTREAMER_PORT - Connect:
ws://localhost:8902 - Health endpoint:
GET /health- returns503if any internal poll loop stalls beyond 30 seconds
The WebSocket event schema is not yet publicly documented. Refer to
core/src/bin/streamer.rs
for implementation details until formal documentation is available.
Transaction Pipeline
Transaction -> [1:Dedup] -> [2:SigVerify] -> [3:Sequencer] -> [4:Executor] -> [5:Settler] -> Database
Transactions submitted to the Gateway flow through a five-stage pipeline before their state is committed:
- Dedup - filters duplicate transactions before they enter the pipeline
- SigVerify - validates transaction signatures against the signer's public key
- Sequencer - orders valid transactions deterministically to establish a canonical history
- Executor - executes transactions against the channel's accounts layer (BOB Cache + AccountsDB), updating balances off-chain
- Settler - commits accumulated transaction results to PostgreSQL and
updates the Redis cache; generates new blockhashes for the next block cycle.
Mainnet settlement (calling
ReleaseFunds) is handled separately by theoperator-private-channelservice
Key Features
Privacy
Transfers between channel participants are not recorded on Solana Mainnet. Only deposits (entering the channel) and final withdrawals (leaving the channel) appear on-chain. Counterparty identities and transfer amounts are not visible to outside observers during channel operation.
Performance
The off-chain pipeline removes Solana's block time from the critical path. Transfers confirm when the sequencer processes them, not when a Solana block confirms. This enables sub-second finality and throughput beyond Solana's native TPS for app-layer transfers.
Settlement
Every withdrawal is protected by an on-chain Sparse Merkle Tree proof. The SMT
root is stored in Instance.withdrawal_transactions_root on the Escrow Program.
When ReleaseFunds is called, the program first verifies an exclusion proof for
an unseen nonce against the current on-chain root, then verifies a separate
inclusion proof for that nonce against the caller-supplied new root. Only after
both checks pass does it store the new root, making double-spend impossible even
if an operator key is compromised.
Security Model
Admin key - controls instance creation (CreateInstance) and operator
provisioning (AddOperator / RemoveOperator). Compromise of the admin key
enables arbitrary operator provisioning. SetNewAdmin transfers admin authority
irreversibly in a single step; protect the admin key accordingly.
Operator keys - can call ReleaseFunds and ResetSmtRoot. They cannot
release funds without a valid SMT exclusion proof against the current on-chain
root. The on-chain verify_smt_exclusion_proof check is the last line of
defense against unauthorized withdrawals: a compromised operator key alone is
not sufficient to drain the escrow.
SMT root - stored on-chain in Instance.withdrawal_transactions_root.
Updated atomically with each ReleaseFunds call. Because each proof must
reference an unseen nonce, double-spending the same channel balance is
impossible even if an operator key is compromised.
Tree rotation - Instance.current_tree_index tracks tree epochs. When
ResetSmtRoot is called, it increments the tree index and invalidates all
nonces from the previous tree epoch, providing a clean slate for new settlement
cycles.
Operational Key Security
The off-chain services use their own signer vocabulary, which is unrelated to
the on-chain admin/operator authorities described in the Security Model above.
ADMIN_PRIVATE_KEY is required for every operator service and pays
transaction fees; a separate, optional OPERATOR_PRIVATE_KEY supplies the
on-chain Operator signature for ReleaseFunds and ResetSmtRoot, and falls
back to ADMIN_PRIVATE_KEY's value when unset. Never put the protocol-level
instance admin key (used for CreateInstance / AddOperator / SetNewAdmin)
into either variable or expose it at runtime; keep that key cold and offline.
ReleaseFunds and ResetSmtRoot require two on-chain signatures: the fee payer
(from ADMIN_PRIVATE_KEY) and the Operator PDA's authority (from
OPERATOR_PRIVATE_KEY, or ADMIN_PRIVATE_KEY if that's unset). This deployment
guide's devnet walkthrough puts the generated operator keypair into
ADMIN_PRIVATE_KEY and leaves OPERATOR_PRIVATE_KEY unset, so the same keypair
fills both signer roles. Treat whichever key ends up in ADMIN_PRIVATE_KEY with
the same controls as a hot wallet private key:
- Store it only in the gitignored
.envfile, never in.env.devnetor any committed config - For production deployments, consider a secrets manager (AWS Secrets Manager, HashiCorp Vault) rather than a plaintext env var
- The protocol-level instance admin keypair (used to call
AddOperator/SetNewAdmin) should be kept cold; it is only needed during instance setup and operator provisioning, not during runtime
SetNewAdmin transfers admin rights irreversibly in a single transaction:
the current admin has no recovery path without the new admin's cooperation. Do
not call it without verifying the target address.
Next Steps
Is this page helpful?