Theme
The contracts, and the order to deploy them
Two chains, five contracts of Jua's concern. Deploy in this order; each step's output feeds the next.
BNB Smart Chain TON
─────────────── ───
1. The owner Safe (multisig) 1. The owner multisig
2. Operator and resolver keys 2. Operator (and resolver) wallets
3. JuaVault ← the fee vault 3. JuaTonVault ← the fee vault
4. ConditionalTokens + NoWalletFactory 4. JuaTonExchange (fee recipient = the vault)
+ CTFExchange (fee receiver = the vault)
5. The API's EXCHANGE_* settings 5. The API's TON_* settingsTestnet first, every time: BSC testnet (chain 97) and the TON testnet, with test coins, then mainnet.
BNB Smart Chain
1. The owner Safe
A Safe on BNB Smart Chain with at least 2 of 3 signers on hardware wallets, held by different people, none of them a server key. It becomes the exchange's only admin and the fee vault's owner. The deploy scripts refuse an owner that is not a Safe of at least two signers (except on a testnet or a development node with *_ALLOW_EOA_OWNER=true, for tests).
2. The operator and resolver keys
Two different keys, neither the deployer key:
- The operator: the API's key (
EXCHANGE_OPERATOR_KEY), or, preferred with real money, a key in a KMS or HSM behind a remote signer that answerseth_signTransaction(EXCHANGE_OPERATOR_SIGNER_URL,EXCHANGE_OPERATOR_ADDRESS,EXCHANGE_OPERATOR_SIGNER_AUTH). It prepares conditions, sends matches and pays commissions out of the fee vault. Fund it with a little BNB for gas; it never needs USDT. - The resolver: the oracle of every market's condition, the only account that can report a result. Either a key the API holds (
EXCHANGE_RESOLVER_KEY) or, better, a second Safe whose signers report each result by hand (then onlyEXCHANGE_RESOLVER, its address, is set).
Also a deployer key with a little BNB, used once. Keep it, with the nonce it deployed from, in the Safe signers' custody.
3. The fee vault: JuaVault
The exchange sends every fee to its fee receiver. Referrers collect commissions from that receiver, so it is a JuaVault: owned by the Safe, paid out of by the exchange's operator within a per-payout and a daily limit.
bash
VAULT_RPC_URL=https://bsc-dataseed.bnbchain.org VAULT_CHAIN_ID=56 \
VAULT_DEPLOYER_KEY=0x… VAULT_TOKEN=0x55d398326f99059fF775485246999027B3197955 \
VAULT_OWNER=0x<Safe> VAULT_OPERATOR=0x<the exchange's operator> \
VAULT_MAX_PER_PAYOUT=500 VAULT_DAILY_LIMIT=2000 \
bun run --filter @jua/contracts deployLimits are whole USDT. Size them from commissions, not withdrawals: a stolen operator key can pay out at most the daily limit per 24-hour window (two windows' worth across a window's turn) until the Safe rotates it. Before signing, the script checks the RPC's chain, the token (official USDT or USDC on mainnet), that the owner is a Safe with the right signers and that the operator is neither the deployer nor the owner; after, it reads the vault back and prints JuaVault deployed at … with its block. Verify the source on BscScan and check owner(), operator() and the limits there.
4. Conditional Tokens, NoWalletFactory and CTF Exchange
bash
EXCHANGE_RPC_URL=https://bsc-dataseed.bnbchain.org EXCHANGE_CHAIN_ID=56 \
EXCHANGE_DEPLOYER_KEY=0x… \
EXCHANGE_TOKEN=0x55d398326f99059fF775485246999027B3197955 \
EXCHANGE_OWNER=0x<Safe> EXCHANGE_OPERATOR=0x<operator> EXCHANGE_RESOLVER=0x<resolver> \
EXCHANGE_FEE_RECIPIENT=0x<the JuaVault from step 3> EXCHANGE_MAX_FEE_RATE_BPS=500 \
EXCHANGE_LICENSED_FOR_PRODUCTION=true \
bun run --filter @jua/contracts deploy:exchangeWhat it does: deploys Gnosis's ConditionalTokens (or reuses one in EXCHANGE_CTF, whose code must be the artifact's), Jua's NoWalletFactory, and Polymarket's CTFExchange with the deployer as first admin; then addOperator(operator), setMaxFeeRate, addAdmin(Safe), and removes the deployer as operator and admin. From then on only the Safe administers the exchange. It refuses, before signing anything: an RPC on another chain; production without the licence acknowledged; a coin that is not the official USDT or USDC on a main network; roles that share a key; an owner that is not a Safe of at least two signers; a fee limit outside 1 to 2,000 basis points. After, it reads everything back (roles, wiring, the fee limit, the EIP-712 domain, the code of all three contracts) and prints the variables the API needs: EXCHANGE_ADDRESS, EXCHANGE_CTF, EXCHANGE_CHAIN_ID, EXCHANGE_TOKEN, EXCHANGE_TOKEN_DECIMALS, EXCHANGE_TOKEN_SYMBOL, EXCHANGE_RESOLVER, EXCHANGE_START_BLOCK.
EXCHANGE_MAX_FEE_RATE_BPS: set it to the platform's fee rate, never higher
It is the most the operator may charge on any fill. The platform's fee rate (fee.tradeBps, 5% by default) must never exceed it, or the exchange refuses every buy; and a limit above the rate is exactly what a stolen operator key could charge every customer. Keep them equal; raise them together, the ceiling first (setMaxFeeRate from the Safe). The exchange reads 0 as no limit, so the script refuses it. See Fee and affiliate settings.
The licence
CTF Exchange V2 is under the Business Source License 1.1: production use (real money) needs a commercial licence from Blockratize Inc. (Polymarket) until 27 March 2030. EXCHANGE_LICENSED_FOR_PRODUCTION=true states that the licence is held; the script refuses any chain but BSC testnet, Sepolia and a development node without it. Jua holds the licence (confirmed by the owner on 31 October 2026). Conditional Tokens is Gnosis's (LGPL-3.0); the Solady files are MIT; NoWalletFactory is Jua's (MIT).
Verify the sources on BscScan with the settings in the artifacts, and check isAdmin(Safe), isOperator(operator) and getMaxFeeRate() there.
5. Configure the API and switch it on
Set the printed values as variables, the RPCs and the operator key (or signer) as secrets, EXCHANGE_FEE_VAULT to the JuaVault's address, and deploy (Deploying). On the site, EXCHANGE_ADDRESS, EXCHANGE_CHAIN_ID and EXCHANGE_TOKEN_SYMBOL reach the web container too. On the next housekeeping pass the exchange worker cancels every resting order from before (nobody signed them), and Buy Yes starts asking the wallet to approve and sign.
TON
1. The owner multisig, 2. the wallets
- The owner: a TON multisig wallet. It sets the operator, resolver and fee recipient, pauses and unpauses, returns stranded USDT and shares, withdraws spare TON. It can never move an order's USDT, mint or burn shares, change a recorded result or upgrade (there are no upgrades).
- The operator: a v5r1 (or v4) wallet whose ed25519 seed the API holds (
TON_EXCHANGE_OPERATOR_KEY). It opens markets, sends matches and closes, refunds, claims fees to the vault and pays commissions out of it. Fund it with TON for gas: it holds no customer money. - The resolver: records results, once per market. A key the API holds (
TON_EXCHANGE_RESOLVER_KEY), or a multisig whose signers record results themselves. - Optionally Jua's market maker wallet, with Jua's own USDT and some TON.
3. JuaTonVault, 4. JuaTonExchange
Both are Tact contracts in packages/ton-contracts, compiled with the pinned compiler; the committed build is what the tests run and what is deployed. One script deploys both, from a machine that holds the deployer wallet's seed (a v5r1 wallet by default, TON_DEPLOYER_WALLET=v4 otherwise), funded with about 8 TON:
bash
TON_NETWORK=mainnet TON_ALLOW_MAINNET=true TON_TONCENTER_API_KEY=… \
TON_DEPLOYER_KEY=<the deployer wallet's seed, 64 hex> \
TON_OWNER=<the multisig> TON_OPERATOR=<the API's operator wallet> TON_RESOLVER=<the resolver> \
TON_VAULT_MAX_PER_PAYOUT=500 TON_VAULT_DAILY_LIMIT=2000 \
bun run deploy:tonLimits are whole USDT, sized as the EVM vault's. On the testnet: TON_NETWORK=testnet and TON_EXCHANGE_JETTON_MASTER=<the test jetton> (Tether's USDT exists on mainnet only, and is the only coin allowed there); TON_ALLOW_EOA_OWNER=true lets a one-key owner through on the testnet, for tests, never on mainnet. TON_TONCENTER_URL names toncenter's v3 API when it is not the network's public one; the key rides in a header.
What it does: deploys JuaTonVault (owner, operator, the USDT master, the limits) and sends it RequestCollateralWallet, waits until the vault has learned its USDT wallet from the master (TEP-89), then deploys JuaTonExchange with the vault as its fee recipient and has it learn its own wallet the same way; each contract is funded with its reserve (TON_VAULT_TON, 2 by default; TON_EXCHANGE_TON, 5). It refuses, before signing anything: mainnet without TON_ALLOW_MAINNET=true or without a toncenter key; an owner, operator, resolver or deployer that share an account, or any of them the jetton master; an owner that is not a multisig (get_multisig_data of ton-blockchain/multisig-contract-v2: at least two signatures, none of the signers the deployer, the operator or the resolver); a coin other than Tether's on mainnet, or one that answers no jetton getter; limits out of order; a deployer wallet short of TON (a wallet derived for the other network holds none, so a deploy aimed at the wrong network stops here). The contracts' addresses follow from their parameters, so a run that finds either already deployed uses it and finishes what is missing: running it again is safe. After, it reads both back (roles, limits, pause, the fee recipient, the coin, the empty books, each USDT wallet against the master's own get_wallet_address) and prints the variables the API needs: TON_NETWORK, TON_EXCHANGE_ADDRESS, TON_EXCHANGE_FEE_VAULT, TON_EXCHANGE_JETTON_MASTER, TON_EXCHANGE_START_LT (the exchange's deployment transaction), TON_ALLOW_MAINNET. Nothing it prints holds the seed or the key. packages/ton-contracts/tests/deploy.test.ts runs it against the development chain, refusals included.
Check on the explorer afterwards: the vault's vaultData (owner, operator, limits) and the exchange's config (owner, operator, resolver, feeRecipient the vault, usdtWallet set) are what the script printed. The deployer wallet holds no role: take it offline, with its seed in the multisig signers' custody.
Before real money on TON
The design document (docs/ton-exchange.md, §9) requires, in order: a rehearsal on the TON testnet with the test jetton (every order kind, cancels, expiries, every match kind, a result, redemption, bounces, the market maker; toncenter's and tonapi's answers checked against what the indexer code reads); an external audit of the three contracts and the settlement worker; the owner a multisig and the operator key in a remote signer; a TON reserve and an alert when it runs low. Only then TON_ALLOW_MAINNET=true.
5. Configure the API
TON_NETWORK, TON_EXCHANGE_ADDRESS, TON_EXCHANGE_JETTON_MASTER, TON_EXCHANGE_START_LT, TON_EXCHANGE_FEE_VAULT, the two indexers (TON_TONCENTER_URL + key, TON_TONAPI_URL + key), the operator seed and wallet kind, the resolver if the API records results, and TON_ALLOW_MAINNET (Deploying). A value that cannot work switches TON trading off and the API logs which one. From then on the TON worker opens a market's two share minters as each market opens, and the Mini App's Buy Yes sends the customer's transfer to the exchange.
What the contracts guarantee
- ConditionalTokens always holds a dollar per pair. Nothing is created or lost in a match; the tests check it after every one.
- A signature bounds the trade, not the fee: hence the fee ceiling rule above.
- No order can be cancelled on chain; a customer's
pauseUserstops all their orders afteruserPauseBlockIntervalblocks (100 by default). - Results are the oracle's alone, and final. Holders redeem from their own wallets; nobody can redeem for anyone else.
- Nothing is upgradeable. The admin's powers are exactly the ones listed in Keys and roles.
- JuaTonExchange's USDT always equals what it owes plus what is stranded; the owner can move only what is counted as stranded, never an order's escrow or the coin behind shares.
- JuaTonVault pays each id once, within the limits, and reads a payout as delivered only from the recipient's own USDT wallet, which it learns from the master before each payout; the owner sweeps, the operator can only pay.
Testing before any of this
bun run --filter @jua/contracts test: the artifacts match a fresh compile, every match type, fees, pausing, results and redemption on the real bytecode in an in-process EVM;test:fuzzruns the Foundry suites.bun run --filter @jua/ton-contracts test: the sandbox suite, the fee agreement with@jua/money, the randomised conservation run.bun run e2e:exchange: a production build of the site in a real browser, the API on a throwaway PostgreSQL, and CTF Exchange on a development chain, two scripted wallets trading end to end.