Koine docs

Security

Koine runs a custodial wallet for each trader at Privy. This page covers who holds which key, what each can sign, and what the untie delay and the withdrawal approver add.

Custody model #

A trader doesn't sign their own trades. Koine's trading service asks Privy to sign, and Privy checks every request against that wallet's policies. That's what makes one-tap trades and automatic exits possible.

A new wallet is created, given its two policies, locked to its owner, and registered with the withdrawal approver before its address is shown. If the approver can't be reached or refuses, no address is shown and the next try reuses the same wallet.

Every trader chooses an untie delay before their first buy, and None is allowed. Withdrawals, a change of saved address and key export all wait it. See Knots.

Source: trade/server/users.ts (ensureWallet), trade/server/privy.ts (createWallet, createPolicy, lockWallet), trade/server/approver.ts (bindWallet)

Keys #

Key Where it lives What it can do
The wallet's private key Privy Sign what the policies allow. It leaves only as an export encrypted to a one-time key made by the requester (for a trader, their browser)
Trade key (PRIVY_TRADE_KEY) The trading server Sign as the wallet's trade signer, within the swap policy. With the approver's key, act as the owner
Approver key The withdrawal approver, not the trading server With the trade key, act as the wallet's owner
Approver swap key (optional) The withdrawal approver When swap co-signing is on, the second half of the trade signer
Privy app secret The trading server Call Privy's API
Receipts key (TRADE_RECEIPTS_KEYPAIR) The trading server Sign and pay for receipt memos. Holds no trader money
Ops key (TRADE_OPS_KEYPAIR) The trading server Pay rent for nonce accounts. Holds no trader money
Referral payout key (TRADE_REFERRAL_PAYOUT_KEYPAIR) The trading server Pay referral shares. A hot key, meant to hold little
Service secrets The services that share each one Sign requests between Koine's services, one secret per pair. The trading service accepts each signature it receives once, within 60 seconds

Each wallet's owner is a 2-of-2 quorum of the trade key and the approver key (PRIVY_OWNER_QUORUM_ID). The trade signer (PRIVY_TRADE_QUORUM_ID) is the trade key alone, or, with swap co-signing on, a 2-of-2 of the trade key and the approver's swap key.

Swap co-signing is optional. It is on only when the trading server has PRIVY_SWAP_COSIGN=1 and the trade quorum has been switched with npm run swap-signer -- on (in trade/). With it off, the trade key signs swaps alone, within the swap policy.

Source: trade/server/config.ts, trade/server/privy.ts, trade/server/approver.ts (swapCosigner), trade/scripts/setup.ts, trade/scripts/swap-signer.ts, trade/server/internal.ts

Signing rules #

Privy enforces two policies, built in trade/server/policy.ts (version 5). Privy denies by default, and every instruction must match a rule.

Swap policy, for the trade signer:

  • Compute budget and the associated token account program.
  • SOL transfers only to Koine's fee wallet, the Helius Sender tip accounts, and the wallet's own wrapped-SOL account.
  • Durable nonce advances, wrapping SOL, and closing token accounts back to the wallet itself.
  • Jupiter route_v2 and shared_accounts_route_v2 buys where the source mint is wrapped SOL, and sells whose proceeds go to the wallet's own wrapped-SOL account.
  • Meteora Alpha Vault presale instructions: an escrow owned by the wallet, deposits from and withdrawals and refunds to its own wrapped-SOL account, and claims where the wallet is the escrow owner. The vault itself can't be pinned.
  • From version 5, a Koine launch fee: one SOL transfer of exactly the launch fee (LAUNCH_FEE_SOL) to the launchpad's treasury (LAUNCH_TREASURY), and the memo program, which moves nothing and carries the launch's nonce. Any other amount to the treasury, or the fee amount to anyone else, matches no rule. Without those two settings a wallet gets version 4's rules and is recorded as version 4.

There is no per-trade size cap in the policy.

npm run launchfee-check (in trade/) checks the launch fee rule against the real Privy API on a new, empty, throwaway wallet. On 2026-11-09 Privy accepted the full version 5 swap policy (16 rules, 98 KB), signed exactly the fee to the treasury with the memo, and refused one lamport more, one lamport less and the fee to another address (policy_violation).

Before signing, Koine also checks Jupiter's answer against the order: program, authority, mints, the wallet's own destination account, the input amount, the minimum output and every surrounding instruction. A second Jupiter instruction is refused. This can't catch an answer that lies consistently about the price.

Owner policy, for both owner keys together:

  • Compute budget.
  • SOL only to the trader's saved address, once there is one.
  • Key export only inside one window that Koine opens for a reveal, from 60 seconds before to 600 seconds after the request. Koine closes it right after the export; if that fails, it lapses at the end.

Withdrawals are SOL only. Changing a policy takes the owner, so both keys. npm run update-policies brings older wallets up to the current swap policy and must run where the approver key is available. For version 5 it needs LAUNCH_TREASURY and LAUNCH_FEE_SOL set, and refuses to run without them. A new treasury or fee needs a new policy version and the same run.

A launch fee with swap co-signing on. The trade signer is then 2-of-2, and the approver co-signs only swaps and presale actions it has checked; it refuses a launch fee ("Not a swap…"). Its checks weren't widened for this. So with co-signing on, launching from a Koine wallet is refused until the approver learns a narrow launch-fee check of its own. Its chain watcher would also alert on the fee, as SOL sent outside a swap, fee, tip or saved address.

Source: trade/server/policy.ts (swapRules, launchFeeRules, policyVersion), trade/server/launchfee.ts, trade/server/build.ts (checkRoute, refusedExtra), trade/server/export.ts (revealRow), trade/scripts/update-policies.ts, trade/scripts/launchfee-check.ts

The withdrawal approver #

The approver is a separate service holding the second owner key, and the trade signer's second key when swap co-signing is on. Its code is in approver/. It runs as a Cloudflare Worker, as it did for Mast at approver.mast.markets, or on Node (npm start in approver/). It should never run on the trading server. The code was rebuilt from development transcripts after Mast's repository was lost, so it may differ in places from what Mast deployed.

The trading service talks to it in three calls, each signed with TRADE_APPROVER_SECRET (an HMAC over a timestamp and the body, good for 60 seconds):

  • Notices (/notice): a new wallet, a new saved address, a withdrawal, a key export, a cancel, and every untie delay change. The approver records each on its own clock and replies with its own unlock time. Koine waits for the later of its time and the approver's.
  • Co-signatures (/approve): the exact Privy request for a withdrawal, a policy update or an export.
  • Swap co-signatures (/swap, when on): the exact signing request, with the trader's buy and sell slippage and most per buy.

A 403 or 409 is a refusal; the trader is told "Koine's second security check refused" it. Any other error, a 503 included, or no answer within 10 seconds, is not a refusal: the trader is told it "isn't answering", and a withdrawal is tried again, up to 3 tries in all.

Source: approver/src/approver.ts (handle), approver/src/wire.ts (verifyHmac), trade/server/approver.ts, trade/server/withdraw.ts, trade/server/export.ts

What it records #

  • Wallet binding. The first wallet notice binds the Privy wallet id and both policy ids to that trader for good. Another trader naming any of them is refused, and a trader can't swap in a different wallet. Every co-signature checks the bindings still hold.
  • Untie delay. Kept the way the bot applies it: a longer delay applies at once, a shorter one waits the current delay.
  • Saved address. The first one applies at once. A change waits the delay. The wallet's own address is refused.
  • Withdrawals and exports. Each unlocks the trader's delay after the approver first heard of it. A cancelled one is never signed.

Source: approver/src/approver.ts (notice, syncDelay, settle), approver/src/records.ts

What it signs #

  • A withdrawal: only signTransaction, no lookup tables, the wallet paying its own fee, and nothing but compute budget and one SOL transfer from the wallet to the saved address, no more than the amount in the notice. At most 3 signatures per withdrawal, for retries.

  • A policy update: only the owner policy's rules, and only exactly the rules for the saved address. An export window may be added only while an unlocked export is open, and only for up to 15 minutes. It never signs a change to the swap policy.

  • A key export: only an HPKE export, after it unlocks. At most 2 signatures.

  • A swap, only when PRIVY_SWAP_KEY and an RPC are set: one Jupiter route that is the wallet's own, buying with SOL or selling to SOL; output to a token account the wallet owns, read from the chain; a minimum output within the trader's slippage (at most 50%) plus 5% of a quote the approver fetches itself; a buy no bigger than the trader's most per buy; and SOL leaving besides the swap at most 2% of its value plus 0.01 SOL. Anything else the trade key signs may only advance a nonce or close token accounts. A looser slippage or most per buy waits the delay.

  • A presale action, asked for as a swap co-signature, so only when swap co-signing is set up and APPROVER_PRESALE_CREATORS lists the launchpad keeper's address (otherwise refused). The approver doesn't see which launch or amount the trader asked for; the trading service checks that. It checks what it can from the transaction and the chain:

    • the wallet pays its own fee, with no Jupiter route in the same transaction;
    • only compute budget, SOL into the wallet's own wrapped-SOL account, wrapping and closing token accounts back to the wallet, token accounts made for the wallet (or the vault's own wrapped-SOL account), and the five Alpha Vault instructions a presale uses;
    • exactly one deposit, withdrawal, claim or refund, an escrow only alongside a deposit, and one vault;
    • the escrow is the wallet's, and every account the presale or a SOL transfer moves funds through is a token account the wallet owns (read from the chain, or made in the same transaction), wrapped SOL where SOL moves;
    • the vault is an Alpha Vault whose pool's creator is a listed keeper, so a deposit can only go into a Koine launch's presale;
    • a deposit is no bigger than the trader's most per buy.

Source: approver/src/approver.ts (approve, approveSwap), approver/src/rules.ts, approver/src/swaps.ts (checkSwap, checkPresale), approver/src/presales.test.ts, approver/src/wire.ts

Halts, alerts and the chain watcher #

  • Halt. A halt flag refuses every withdrawal, policy-update and export co-signature with a 503, and leaves swaps alone. halt_swaps refuses only swap co-signatures, presale actions included, so with swap co-signing on every trade and exit stops. On Node, npm run halt -- on sets halt and npm run halt -- on swaps sets halt_swaps; on Cloudflare they're KV keys.
  • Alerts. Every saved-address change and export request goes to the owner from the approver's own Telegram bot, never the trading bot.
  • Breach brake. Off unless APPROVER_BRAKE_TRADERS is set. That many different traders setting off alerts within APPROVER_BRAKE_MINUTES (10) sets halt. A first saved address doesn't count; the chain watcher's findings do.
  • Chain watcher. Off unless APPROVER_RPC_URL is set. Every 2 minutes it reads the bound wallets through its own RPC and alerts on: SOL sent anywhere but a swap, Koine's fee, a Sender tip or the saved address; an instruction or program no Koine flow uses; a swap that kept less than 30% of its value at an independent price; and many wallets buying the same coin within 5 minutes. It only alerts and never halts on its own; with the breach brake on, its findings count toward it. A presale instruction is flagged only if it isn't on the wallet's own escrow; which vault it is gets checked before signing.

Source: approver/src/alerts.ts, approver/src/watcher.ts, approver/src/chain.ts, approver/src/halt.ts, approver/src/index.ts

Sign-in and what a session can do #

Website accounts sign in through Privy: an email code, a passkey, or a wallet that only proves who you are. Privy gives the page an ES256-signed access token; the trading service checks it against Privy's published key for Koine's app (or the PEM in PRIVY_VERIFICATION_KEY), with the issuer, app, expiry and user, using Node's own crypto. It then issues its own session, an httpOnly cookie on koine.markets. Privy makes no wallet for anyone: the Koine wallet is still the app-owned wallet above, under the same keys and policies.

A web session alone can't change the saved address, start an export, reveal a key or hand over a coin's stewardship. Each needs a fresh Privy login, which the server checks itself:

  • the token is this account's, from a Privy session (sid) the service has never seen, issued in the last 2 minutes. Privy starts a new session only at a login; refreshing a token keeps the old one;
  • each such session confirms one action, once, within 5 minutes, bound to the account, the action and its details (the address, the browser's export key, the coin and new steward). The action takes its details from the confirmation, not the request;
  • with PRIVY_REAUTH_VERIFIED_AT=1, Privy's own record of the account's latest verification must also be under 10 minutes old. It's off until a real login has shown that Privy updates it every time.

Telegram-only accounts do these in the bot. A stolen session can still trade, and trading can still lose the balance; it can also pay a launch fee, which only goes to the treasury. See The web terminal.

What a fresh login doesn't cover. It proves the person can still sign in. A page compromised by malicious script could start its own confirmation while you confirm something else: unlike a wallet's message, Privy's window doesn't show the action. The untie delay and the approver's own record still apply to an address change and an export.

In the bot, buttons that move money or keys out carry a one-time token issued with the screen, so a button sent by another holder of the bot token does nothing.

Source: trade/server/privyauth.ts, trade/server/reconfirm.ts, trade/server/web.ts (/web/privy/login, handleWebOnly), app/server/http.ts (handover), trade/server/bot.ts (issueAction)

Checking it yourself #

  • Every wallet is public on chain, so any explorer shows what it signed.
  • Flagged wallets write receipts you can check from chain alone.
  • See Testing for what can be rerun from this repository.

Source: trade/server/verify.ts, trade/scripts/receipts-check.ts