Operators

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:

  1. Index Mainnet for deposits - indexer-solana watches Solana Mainnet for Deposit events via Yellowstone gRPC; operator-solana picks up confirmed deposits and mints the equivalent token balance on the channel network
  2. Index the channel for withdrawals - indexer-private-channel polls the channel every second for WithdrawFunds burn events and writes pending withdrawal records to the database
  3. Release funds on Mainnet - operator-private-channel picks up pending records and calls ReleaseFunds on the Escrow Program with a valid SMT exclusion proof
  4. Manage the SMT root - operator-private-channel calls ResetSmtRoot automatically when tree epochs rotate; the on-chain verify_smt_exclusion_proof check is the last line of defense against unauthorized withdrawals
  5. 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_SECRET is 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 service
AUTH_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.com
DEVNET_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-ui
pnpm install
echo "PRIVATE_CHANNEL_RPC_URL=http://localhost:8899" > .env
pnpm dev # opens at http://localhost:5173

Create an escrow instance

  1. Set your browser wallet to Devnet and ensure you have Devnet SOL for fees
  2. In the Admin UI, click Create New Instance and approve the transaction
  3. Copy the Instance Address and set it as ESCROW_INSTANCE_ID in .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-passphrase
solana-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 AllowMint and 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:

  1. Allow Mint: Admin Functions -> Mint Management -> enter mint address -> Allow Mint
  2. 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 ReleaseFunds call, 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 services
make 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

  1. Confirm the Mainnet deposit transaction landed on a Mainnet explorer
  2. Verify indexer-solana is running: docker compose -f docker-compose.devnet.yml --env-file versions.env --env-file .env.devnet logs -f indexer-solana
  3. Verify operator-solana is running: docker compose -f docker-compose.devnet.yml --env-file versions.env --env-file .env.devnet logs -f operator-solana
  4. Check that your Yellowstone gRPC endpoint is reachable and the token is valid (DEVNET_YELLOWSTONE_ENDPOINT, INDEXER_YELLOWSTONE_TOKEN)
  5. Allow up to 30 seconds after on-chain confirmation, since the indexer applies a finality safety delay before crediting

Withdrawal not settling to Mainnet

  1. Verify indexer-private-channel is running: docker compose -f docker-compose.devnet.yml --env-file versions.env --env-file .env.devnet logs -f indexer-private-channel
  2. Verify operator-private-channel is running: docker compose -f docker-compose.devnet.yml --env-file versions.env --env-file .env.devnet logs -f operator-private-channel
  3. Confirm the operator keypair in ADMIN_PRIVATE_KEY matches the key registered with AddOperator on-chain
  4. 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)

  1. Confirm JWT_SECRET is identical on both the gateway and auth service containers
  2. Confirm the stack was started with --profile auth
  3. 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?

© 2026 Solana Foundation. All rights reserved.