Overview
Withdrawing from Private Channels is a two-step process. First, the user burns
their channel-side token balance by calling WithdrawFunds on the Withdraw
Program. Second, an operator calls ReleaseFunds on the Escrow Program (devnet,
in this walkthrough) with a valid Sparse Merkle Tree exclusion proof to release
the funds. The SMT proof ensures each withdrawal nonce can only be used once,
preventing double-spend.
Step 1: Initiate Withdrawal on the Channel
Call WithdrawFunds on the Withdraw Program to burn your channel-side balance.
Use the Async variant, which auto-derives the tokenAccount PDA and is the
recommended form for production use:
import { getWithdrawFundsInstructionAsync } from "../private-channel-withdraw-program/clients/typescript/src/generated";import {createSolanaRpc,address,pipe,createTransactionMessage,setTransactionMessageFeePayerSigner,setTransactionMessageLifetimeUsingBlockhash,appendTransactionMessageInstruction,signAndSendTransactionMessageWithSigners,assertIsTransactionMessageWithSingleSendingSigner,getBase58Decoder} from "@solana/kit";// Point to the gateway, not a public Solana RPCconst privateChannelRpc = createSolanaRpc("http://localhost:8899");const withdrawIx = await getWithdrawFundsInstructionAsync({user: userSigner,mint: address(mintAddress),amount: 1_000_000n, // 1 USDC (6 decimals)destination: null // null = release to signer's devnet wallet});const { value: latestBlockhash } = await privateChannelRpc.getLatestBlockhash({ commitment: "confirmed" }).send();// Sent to the Private Channels gateway (not Solana RPC directly), which// expects the legacy transaction format - do not switch this to version 0.const transactionMessage = pipe(createTransactionMessage({ version: "legacy" }),(m) => setTransactionMessageFeePayerSigner(userSigner, m),(m) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m),(m) => appendTransactionMessageInstruction(withdrawIx, m));assertIsTransactionMessageWithSingleSendingSigner(transactionMessage);const signatureBytes =await signAndSendTransactionMessageWithSigners(transactionMessage);const signature = getBase58Decoder().decode(signatureBytes);console.log("Withdrawal initiated:", signature);
- Withdraw Program ID:
J231K9UEpS4y4KAPwGc4gsMNCjKFRMYcQBcjVW7vBhVi - Submit this transaction to the gateway (
http://localhost:8899), not to a public Solana RPC - If
destinationis provided, released funds go to that wallet instead of the signer's
What to Expect
No further action is required after calling WithdrawFunds. The operator
services handle settlement automatically:
indexer-private-channelpolls the channel every 1 second for burn events and writes a pending withdrawal record when yours is detectedoperator-private-channelpolls the database every 1 second for pending records and submitsReleaseFundsto the Escrow Program on Solana devnet with the required SMT exclusion proof; it polls for devnet confirmation up to 5 times at 400 ms intervals before retrying
Funds typically appear in your devnet wallet within a few seconds under normal conditions.
How the Operator Settles (Reference)
After the channel-side burn, a provisioned operator must call ReleaseFunds on
the Escrow Program with a valid SMT exclusion proof; the operator cannot release
funds without proving the withdrawal nonce has not been used before. This step
is handled automatically by operator-private-channel; you do not need to call
it yourself.
The operator provides:
amount: must match the burned amountuser: recipient wallet on devnetnew_withdrawal_root: updated SMT root after this withdrawaltransaction_nonce: unique nonce for this leafsibling_proofs: 512 bytes (16 x 32-byte sibling hashes for the tree proof)
Verify
After the operator call confirms, check your devnet token balance:
spl-token balance <MINT_ADDRESS> --owner <YOUR_WALLET>
Or via RPC:
import { createSolanaRpc, address } from "@solana/kit";import { findAssociatedTokenPda } from "@solana-program/token";const TOKEN_PROGRAM_ADDRESS = address("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");// Query devnet directly; ReleaseFunds settles here, not through the gatewayconst rpc = createSolanaRpc("https://api.devnet.solana.com");const [yourDevnetAta] = await findAssociatedTokenPda({mint: address(mintAddress),owner: address(userSigner.address),tokenProgram: TOKEN_PROGRAM_ADDRESS});const balance = await rpc.getTokenAccountBalance(yourDevnetAta).send();console.log(balance.value.uiAmountString);
You've Completed the Quickstart
You've run the full Private Channels cycle on devnet: deposited SPL tokens into the escrow, sent an off-chain transfer through the gateway, and withdrawn funds back to your devnet wallet.
Channel Lifecycle
Understand participants and the full deposit-to-withdrawal flow in depth.
Authentication & Roles
Add JWT authentication to your integration.
Release Funds Reference
Full ReleaseFunds instruction reference for operator tooling.
Sparse Merkle Tree
Understand how withdrawal proofs prevent double-spend.
Is this page helpful?