What is a Private Channels Operator?
An operator is a trusted, on-chain-permissioned entity that bridges Solana
Mainnet and the private channel network. Operators are provisioned by the
instance admin via AddOperator, which creates an on-chain Operator PDA;
without this, no party can call ReleaseFunds. In practice, an operator is an
organization or team running the services that watch for deposits, mint
channel-side tokens, detect withdrawals, and settle funds back to Mainnet.
Running an instance gives your users private, high-volume transfers that don't
appear on Solana Mainnet, instant zero-fee throughput beyond native Solana TPS,
and controlled access via RBAC.
If you're a developer integrating against an existing Private Channels instance rather than deploying one, start with the Quickstart instead.
Before You Start
Prerequisites
Pin these versions on the host to match the Docker images:
- Docker Engine 26+ (macOS Apple Silicon: enable "Docker VMM" in Settings -> Virtual Machine Options)
- Node.js 24.7.0 and pnpm 10.15.1
- Solana CLI 3.1.13 (Agave)
- Rust 1.91.0
- A Yellowstone gRPC endpoint for Devnet (available from Helius, Triton, QuickNode)
For network requirements and default port assignments, see
docs/TECHNICAL_REQUIREMENTS.md
in the repository.
Install the pinned Solana toolchain and warm the SBF cache:
make install-toolchain
Services
Running a Private Channels instance means owning five ongoing responsibilities, each handled by dedicated containers in the Docker Compose stack:
- Index Mainnet for deposits -
indexer-solanawatches Solana Mainnet forDepositevents via Yellowstone gRPC;operator-solanapicks up confirmed deposits and mints the equivalent token balance on the channel network - Index the channel for withdrawals -
indexer-private-channelpolls the channel every second forWithdrawFundsburn events and writes pending withdrawal records to the database - Release funds on Mainnet -
operator-private-channelpicks up pending records and callsReleaseFundson the Escrow Program with a valid SMT exclusion proof - Manage the SMT root -
operator-private-channelcallsResetSmtRootautomatically when tree epochs rotate; the on-chainverify_smt_exclusion_proofcheck is the last line of defense against unauthorized withdrawals - Run the gateway and auth service - the gateway is the single public
endpoint for all client traffic; the auth service (optional) enforces
JWT/RBAC when
JWT_SECRETis set
For the full service inventory and port assignments, see the Configuration reference.
Security note: Write-node and read-node ports are bound to loopback (
127.0.0.1) only, but several other services (gateway, auth, operator metrics, Grafana, Prometheus, cAdvisor) are published to all network interfaces by default. See the Configuration reference for the full port table and firewall these before any public-facing deployment. RBAC only covers the gateway's own JSON-RPC methods, not these other services.
Access Control: Open vs. RBAC
By default the gateway accepts all connections; no tokens required. To enable
JWT-based RBAC, set JWT_SECRET and start the stack with --profile auth. See
Authentication & Roles for the
full configuration reference, including how to provision the operator role and
register user wallets.
If enabling auth, add these to your environment before starting the stack:
JWT_SECRET=<openssl rand -hex 32> # must match on gateway and auth serviceAUTH_PORT=8903
Environment Setup
.env.devnet is already tracked in the repository with devnet-specific defaults
filled in; edit it directly rather than regenerating it from .env.example,
which would overwrite those defaults.
Fill in the remaining values as you work through the deploy steps below; some
are only available mid-deployment. Secrets go in the gitignored .env file;
non-secret variables go in .env.devnet.
Secrets - set these immediately:
POSTGRES_PASSWORD=<openssl rand -hex 32>POSTGRES_REPLICATION_PASSWORD=<openssl rand -hex 32>
Variables obtained during deployment:
ESCROW_INSTANCE_ID=<instance address - from Step 3>ADMIN_PRIVATE_KEY=<operator keypair as u8 array or base58 - from Step 4>DEVNET_RPC_URL=https://api.devnet.solana.comDEVNET_YELLOWSTONE_ENDPOINT=<your Yellowstone gRPC endpoint>INDEXER_YELLOWSTONE_TOKEN=<your Yellowstone auth token>
ADMIN_PRIVATE_KEY is the off-chain services' own required fee-payer signer,
unrelated to the on-chain instance admin from Step 3. This guide puts the
operator keypair generated in Step 4 below into ADMIN_PRIVATE_KEY and
leaves the optional OPERATOR_PRIVATE_KEY unset, so the operator signer falls
back to the same key. Never put the protocol-level instance admin keypair from
Step 3 into either variable.
For the full environment variable reference, see Configuration.
Deploy
Build images
make docker-devnet-build
This compiles all Rust services into a shared Docker image. The first build takes 30 minutes to an hour.
Set up the Admin UI
The Admin UI is a browser-based tool for creating and configuring the escrow
instance: a development and administration utility, not a user-facing product
and not a required runtime component. All operations it performs
(CreateInstance, AllowMint, AddOperator) can also be run via the CLI
scripts in the repository.
cd admin-uipnpm installecho "PRIVATE_CHANNEL_RPC_URL=http://localhost:8899" > .envpnpm dev # opens at http://localhost:5173
Create an escrow instance
- Set your browser wallet to Devnet and ensure you have Devnet SOL for fees
- In the Admin UI, click Create New Instance and approve the transaction
- Copy the Instance Address and set it as
ESCROW_INSTANCE_IDin.env.devnet
Alternatively, use the CLI script:
cargo run --bin create_instance -- https://api.devnet.solana.com ./keypairs/admin.json
Generate an operator keypair
solana-keygen new -o operator-keypair.json -s --no-bip39-passphrasesolana-keygen pubkey operator-keypair.json
Set the keypair contents as ADMIN_PRIVATE_KEY in your environment. The public
key isn't an environment variable; you'll pass it directly as the operator
pubkey in the "Configure the instance" step below.
Finalize environment variables
Update .env.devnet with ESCROW_INSTANCE_ID, DEVNET_RPC_URL,
DEVNET_YELLOWSTONE_ENDPOINT, and INDEXER_YELLOWSTONE_TOKEN. Put secrets
(POSTGRES_PASSWORD, POSTGRES_REPLICATION_PASSWORD, ADMIN_PRIVATE_KEY) in
the gitignored .env file.
If you decided to enable RBAC
(Access Control: Open vs. RBAC), also add
JWT_SECRET and AUTH_PORT now.
Start all services
Without auth:
make docker-devnet-up
With auth:
docker compose -f docker-compose.devnet.yml --env-file versions.env --env-file .env.devnet --env-file .env --profile auth up -d
Compose disables its automatic .env auto-load once any --env-file flag is
passed, so the trailing --env-file .env is required. Without it,
POSTGRES_PASSWORD, ADMIN_PRIVATE_KEY, and JWT_SECRET (which you put in
.env above) resolve empty and the stack fails to start correctly.
Start services before configuring the instance. The indexer streams events in real time, so bringing the stack up first ensures
AllowMintand your first deposit are indexed in order without needing a backfill.
Configure the instance
With the stack running, whitelist a token mint and add your operator via the Admin UI:
- Allow Mint: Admin Functions -> Mint Management -> enter mint address -> Allow Mint
- Add Operator: Admin Functions -> Operator Management -> enter operator pubkey -> Add Operator
Or via CLI:
cargo run --bin add_operator -- \https://api.devnet.solana.com \./keypairs/admin.json \<INSTANCE_ID> \<OPERATOR_PUBKEY>
This guide targets Solana devnet. For Mainnet:
- Program IDs are compiled in via
declare_id!(): verify you are using the correct Mainnet IDs from the repository - Yellowstone gRPC endpoints require a Mainnet plan; devnet endpoints will not stream Mainnet events
- The operator wallet pays SOL fees for every
ReleaseFundscall, so size the SOL balance for your expected withdrawal volume - Change all default credentials (Grafana, PostgreSQL) before any public-facing deployment
Operations
Useful Commands
# View logs (all services)make docker-devnet-logs# View logs (specific service)docker compose -f docker-compose.devnet.yml --env-file versions.env --env-file .env.devnet logs -f indexer-solana# Stop servicesmake docker-devnet-down# Stop and wipe all state (volumes)make docker-devnet-clean
Observability
The stack includes Prometheus, Grafana, and cAdvisor for metrics and container
monitoring. Grafana is accessible on port 37429.
The default Grafana password is admin. Change it before exposing port 37429
to any network beyond localhost.
Troubleshooting
Channel balance not updating after deposit
- Confirm the Mainnet deposit transaction landed on a Mainnet explorer
- Verify
indexer-solanais running:docker compose -f docker-compose.devnet.yml --env-file versions.env --env-file .env.devnet logs -f indexer-solana - Verify
operator-solanais running:docker compose -f docker-compose.devnet.yml --env-file versions.env --env-file .env.devnet logs -f operator-solana - Check that your Yellowstone gRPC endpoint is reachable and the token is valid
(
DEVNET_YELLOWSTONE_ENDPOINT,INDEXER_YELLOWSTONE_TOKEN) - Allow up to 30 seconds after on-chain confirmation, since the indexer applies a finality safety delay before crediting
Withdrawal not settling to Mainnet
- Verify
indexer-private-channelis running:docker compose -f docker-compose.devnet.yml --env-file versions.env --env-file .env.devnet logs -f indexer-private-channel - Verify
operator-private-channelis running:docker compose -f docker-compose.devnet.yml --env-file versions.env --env-file .env.devnet logs -f operator-private-channel - Confirm the operator keypair in
ADMIN_PRIVATE_KEYmatches the key registered withAddOperatoron-chain - If logs show "SMT root mismatch", the service shuts down rather than submit an invalid proof. Stop the stack, restore from a consistent state, and restart
JWT authentication failures (401 on all requests)
- Confirm
JWT_SECRETis identical on both the gateway and auth service containers - Confirm the stack was started with
--profile auth - Tokens expire after 24 hours; re-authenticate to get a fresh token
First build takes too long
Expected. The first make docker-devnet-build compiles all Rust services and
can take 30-60 minutes on typical hardware. Subsequent builds use Docker layer
cache and are significantly faster.
Next Steps
Is this page helpful?