Theme
Keys and roles
Who holds which key, what each can do, and what each can never do. The design principle: no server key can move a customer's money, and every server key's damage is bounded and revocable by a multisig.
On BNB Smart Chain
| Role | Held by | Can | Cannot |
|---|---|---|---|
| Owner Safe (the admin) | A Safe, 2-of-3 or more, hardware wallets, different people | On CTFExchange: addOperator, removeOperator, addAdmin, removeAdmin (never the last admin), pauseTrading, unpauseTrading, setFeeReceiver, setMaxFeeRate (below 100%), setUserPauseBlockInterval. On JuaVault: ownerPayout, sweep, setOperator, setLimits, unpause, cancelPayout, rescueToken, transferOwnership | Match orders (it is not an operator); move a customer's coin or shares; report a result; be the vault's operator |
| Operator | The API (EXCHANGE_OPERATOR_KEY) or a remote signer | matchOrders (settle signed orders whose prices cross, each at most what it signed for, with a fee per fill up to maxFeeRateBps, paid to the fee receiver); prepareCondition on CTF for each market. On JuaVault, as its operator: payout within the limits, pause, cancelPayout | Change a role or setting; fill an order beyond its size or at a worse price; trade for a paused wallet; report a result; unpause, sweep or raise limits on the vault |
| Resolver (the oracle) | A key the API holds (EXCHANGE_RESOLVER_KEY) or a resolver Safe | reportPayouts(questionId, payouts) on CTF, once per market: Yes [1, 0], No [0, 1], cancelled [1, 1] | Anything on the exchange; change a result; report another oracle's market |
| Deployer | Used once; kept with its nonce by the Safe signers | Nothing after the deployment (it removed itself) | |
| Customer | Their own wallet | approve the exchange for USDT; setApprovalForAll on CTF; sign orders; pauseUser / unpauseUser; redeemPositions after a result; mergePositions any time | Cancel one order on chain; redeem anyone else's shares |
On TON
| Role | Held by | Can | Cannot |
|---|---|---|---|
| Owner multisig | A TON multisig | On JuaTonExchange: SetOperator, SetResolver, SetFeeRecipient, Pause, Unpause, PayStranded and PayStrandedShares (return counted stranded USDT or shares), WithdrawTon, TransferOwnership. On JuaTonVault: Sweep, SetLimits, SetOperator, Unpause, WithdrawTon, TransferOwnership | Move an order's USDT or shares; mint or burn; change a recorded result; upgrade; be the vault's operator |
| Operator wallet | The API (TON_EXCHANGE_OPERATOR_KEY, a v5r1 or v4 wallet) | OpenMarket (deploy a market's two minters); Match within each order's own terms; Close (refund what is left of an order); ClaimFees to the fee recipient; Pause. On JuaTonVault: VaultPayout within the limits, Pause | Fill at a worse price, for more, or after expiry or cancel; pay anyone but an order's owner or counterparty; unpause; sweep |
| Resolver | A key the API holds, or a multisig | RecordResult once per market: Yes 10,000, No 0, cancelled 5,000 basis points | Change it |
| Market maker wallet | Jua's own (TON_MARKET_MAKER_KEY), optional | Place and cancel its own orders like any customer, with Jua's USDT | Anything else |
| Anyone | Expire an order whose time has passed (its money goes back to its owner); burn their own shares after a result (redeem); ClaimFees |
On the server
| Secret | What it opens | Rotation |
|---|---|---|
DATABASE_URL, DIRECT_URL | The database (pooler strings with the database password) | Reset the password in Supabase → Project Settings → Database; update both secrets; settings-only deploy |
SUPABASE_SERVICE_ROLE_KEY | Supabase's admin API (Auth, Storage, Realtime fan-out) | Rotate in Supabase → API Keys; update; settings-only deploy |
EXCHANGE_OPERATOR_KEY / signer auth, TON_EXCHANGE_OPERATOR_KEY | The operators (above) | Rotating the operator keys |
EXCHANGE_RESOLVER_KEY, TON_EXCHANGE_RESOLVER_KEY | Results | New key; the Safe/multisig cannot re-point existing conditions on CTF (the oracle is fixed per condition), so a new EVM resolver applies to markets opened after it |
EXCHANGE_RPC_URL*, TON_TONCENTER_API_KEY, TON_TONAPI_KEY | Provider quotas (the URLs carry keys) | New key at the provider; update; settings-only deploy |
CRON_SECRET | POST /api/cron/housekeeping | openssl rand -hex 32; update the scheduler and the secret together |
ALERT_WEBHOOK_URL, ALERT_WEBHOOK_SECRET, HOUSEKEEPING_HEARTBEAT_URL | The alert channel and the dead man's switch (the URLs are credentials) | New webhook at the receiver; update; settings-only deploy |
TELEGRAM_BOT_TOKEN, TELEGRAM_WEBHOOK_SECRET | The bot and every Mini App sign-in | /revoke in BotFather for the token; new secret; deploy; Register the bot again |
ANTHROPIC_API_KEY | Listing translations | New key in the Anthropic console |
SERVER_SSH_KEY, SERVER_KNOWN_HOSTS | The deploy's SSH access to the VM | New key pair; replace in authorized_keys; update the secret |
Nothing secret is ever a build argument or reaches the web or admin images; the staff portal holds no credentials at all. A production API reads its environment only, never a file.
Staff accounts
Staff have accounts of their own, with roles admin and resolver (The staff portal). From a checkout on your own machine, with production's DIRECT_URL in packages/db/.env.local:
bash
bun run db:staff alice@example.com admin # one person: admin or resolver; prints the password once
bun run db:staff alice@example.com admin --reset # a new password and authenticator; ends their sessions
bun run db:staff alice@example.com user # takes the staff role away; ends their sessionsHand each password over privately. With Supabase Auth, every staff member enrols an authenticator app at their first sign-in, and every staff action that moves money or changes an account needs it. Admins can also promote an existing account from the portal's Staff page.
Principles
- Four roles, four keys. The deploy scripts refuse roles that share a key: the owner is never the operator or the resolver, the operator is never the deployer.
- Hot keys are bounded. An operator can charge at most the exchange's fee ceiling and pay at most the vault's daily limit; it cannot unpause, sweep or raise limits. The owner multisig can remove it in one transaction.
- Keys off the API host where possible. The EVM operator belongs behind a remote signer (Web3Signer-style,
eth_signTransaction); the API checks every returned transaction field by field before broadcasting. - Secrets live in GitHub Actions, written per container by the deploy; never on the server by hand, never in a log (RPC URLs are redacted from every alert and error).