cp .env.example .env
# Edit .env with your keys
# Required (no defaults shipped): set POSTGRES_PASSWORD and POSTGRES_REPLICATION_PASSWORD
# (and JWT_SECRET if enabling auth, ADMIN_PRIVATE_KEY for the operator) or services fail to start
# Generate strong values with: openssl rand -hex 32Steps 1-3 below are escrow governance and are signed by keypairs/escrow-admin.json,
the Instance.admin key. The services run as a different key, the channel admin in
ADMIN_PRIVATE_KEY, which holds receipt-mint authority and is what step 2 registers as
the operator. Keep them separate: SetNewAdmin only rewrites Instance.admin, so a key
that is also the channel admin keeps its receipt-mint authority and its Operator PDA
after a handover and can still drain escrow custody.
The commands below read their signers from keypair files. Create them first:
mkdir -p keypairs
solana-keygen new -o ./keypairs/escrow-admin.json -s --no-bip39-passphrase # signs create_instance / add_operator / allow_mint
solana-keygen new -o ./keypairs/user.json -s --no-bip39-passphrase # signs deposit / withdrawRun all commands from project root:
Also allocates the instance's withdrawal bitmap (8 KB, about 0.058 SOL of rent), so fund the admin keypair accordingly before running this.
cargo run --bin create_instance -- \
https://api.devnet.solana.com \
./keypairs/escrow-admin.jsoncargo run --bin add_operator -- \
https://api.devnet.solana.com \
./keypairs/escrow-admin.json \
<INSTANCE_ID> \
<OPERATOR_PUBKEY>cargo run --bin allow_mint -- \
https://api.devnet.solana.com \
./keypairs/escrow-admin.json \
<INSTANCE_ID> \
<MINT_ADDRESS> \
<WITHDRAW_FEE> \
<MIN_WITHDRAW_AMOUNT> \
<MIN_DEPOSIT_AMOUNT><WITHDRAW_FEE> is in the mint's base units, charged on top of every Solana Private Channels withdrawal and paid to the operator admin; size it to cover one release's SOL cost. <MIN_WITHDRAW_AMOUNT> is the smallest amount one withdrawal may move, also in base units, which limits how many releases a balance can queue at once. 0 is allowed for either, but a fee of 0 leaves the operator paying every release, so use it only where every participant is known; see the zero-fee warning. Run again with new values, to or from 0, to reprice; they apply from the mint's next deposit. Re-running also re-opens both gates and re-pins the mint profile (accepting any change since the last allow), so follow the reprice procedure. <MIN_DEPOSIT_AMOUNT> is the smallest amount a deposit must land in the escrow, in base units, 0 for none; the escrow enforces it from the moment allow_mint lands.
Run allow_mint only once the stack is up and indexer-solana is healthy. With no backfill start_slot set, as in the shipped devnet config, a fresh escrow indexer starts at the chain tip and never indexes an earlier AllowMint, so the mint's first deposit is quarantined (docs/runbooks/deposit_manual_review.md Path F).
cargo run --bin deposit -- \
https://api.devnet.solana.com \
./keypairs/user.json \
<INSTANCE_ID> \
<MINT_ADDRESS> \
<AMOUNT>cargo run --bin withdraw -- \
http://localhost:8899 \
./keypairs/user.json \
<MINT_ADDRESS> \
<AMOUNT>Prints the mint's withdraw fee and minimum before sending, and stops if <AMOUNT> is below the minimum. The balance must cover <AMOUNT> plus the fee, and only <AMOUNT> is released on Solana. The operator admin withdraws without either.
The tool reads the mint's withdraw config before sending. With JWT on (JWT_SECRET set) the public gateway refuses that read without a token, so point it at the read node: PRIVATE_CHANNEL_READ_RPC_URL=http://localhost:8901. Sending still goes through the gateway URL.
Moves a user's channel balance into the compliance ATA, ATA(<COMPLIANCE_OWNER>, <MINT>)
under Token-2022, signed by the permanent delegate key. It is the only move the node admits
from that key; anything else is refused at ingress.
cargo run --bin seize -- \
http://localhost:8899 \
./keypairs/delegate.json \
<MINT> \
<SOURCE_OWNER> \
<COMPLIANCE_OWNER> \
[--amount <BASE_UNITS>] [--freeze-authority ./keypairs/admin.json]One transaction creates the compliance ATA if needed, thaws a frozen source, moves the whole
balance (or --amount) with TransferChecked, and freezes the source again. The freeze
authority defaults to the delegate key. It simulates with sigVerify first, because the node
drops a refused delegate move without an RPC error.
With JWT on (JWT_SECRET set) the public gateway refuses the account reads and the simulation
without a token, so point them at the read node: PRIVATE_CHANNEL_READ_RPC_URL=http://localhost:8901.
Sending still goes through the gateway URL.
Both flags are absolute, so passing false for one re-opens that gate. The script
prints the current gates before sending and warns when a flag re-opens one.
Blocking deposits leaves already-escrowed balances withdrawable.
cargo run --bin block_mint -- \
https://api.devnet.solana.com \
./keypairs/escrow-admin.json \
<INSTANCE_ID> \
<MINT_ADDRESS> \
<BLOCK_DEPOSITS> \
<BLOCK_WITHDRAWALS>Set which directions require an allowed wallet. Both flags are absolute, and the script warns when a flag turns a direction off.
cargo run --bin set_whitelist_enabled -- \
https://api.devnet.solana.com \
./keypairs/escrow-admin.json \
<INSTANCE_ID> \
<WHITELIST_DEPOSITS> \
<WHITELIST_WITHDRAWALS>Add a wallet, or rewrite its flags. Blocking both directions is rejected.
cargo run --bin add_or_update_allowed_wallet -- \
https://api.devnet.solana.com \
./keypairs/escrow-admin.json \
<INSTANCE_ID> \
<WALLET_PUBKEY> \
<BLOCK_DEPOSITS> \
<BLOCK_WITHDRAWALS>Remove a wallet; its rent goes back to the admin.
cargo run --bin remove_allowed_wallet -- \
https://api.devnet.solana.com \
./keypairs/escrow-admin.json \
<INSTANCE_ID> \
<WALLET_PUBKEY>See the whitelist notes before turning on withdrawals or removing a wallet.
# Watch deposit processing
docker logs -f private-channel-operator-solana
# Watch withdrawal processing
docker logs -f private-channel-operator-private-channel