Skip to content

Choosing a Secure Exchange

💡
Before you start

Python 3 and a terminal. Nothing to install, no account needed. macOS and Linux ship with Python; on Windows get it from python.org and tick “Add python.exe to PATH”, then confirm with python3 --version.

You are going to build the thing exchanges publish and call proof of reserves — a Merkle tree — so that when one shows you a page of hashes you can tell whether it proves anything about your money. Everything runs on eight invented customers on your own machine; no exchange is contacted and no real balances are involved.

Why Exchange Choice Matters

When you use a centralized cryptocurrency exchange, you are trusting that company to hold your funds safely. The history of crypto is littered with exchange failures — hacks, fraud, mismanagement, and outright theft have cost users billions of dollars. Choosing the right exchange is a critical security decision.

Centralized vs Decentralized Exchanges

Centralized Exchange (CEX) A company that holds your funds and matches orders. Examples: Coinbase, Kraken, Binance. Easier to use, supports fiat currency, but you surrender custody of your keys. If the exchange fails, your funds may be lost.
Decentralized Exchange (DEX) A smart contract that matches trades directly between users. Examples: Uniswap, Aave. You keep custody of your keys. No KYC requirements, but no customer support either. Smart contract risk exists instead of custodial risk.

What to Look For in a CEX

  • Regulatory compliance — licensed and registered in reputable jurisdictions. This does not eliminate risk but provides legal accountability.
  • Proof of reserves — periodic, independently verified audits showing the exchange holds at least as much crypto as customers have deposited. Published Merkle tree proofs are a good sign.
  • Security track record — how has the exchange handled past incidents? Did they compensate users? Transparency about past breaches (and what they fixed) is actually a positive sign.
  • Insurance fund — some exchanges maintain funds specifically to cover losses from security breaches (e.g., Coinbase's insurance, Binance's SAFU fund)
  • Cold storage ratio — reputable exchanges keep the vast majority (95%+) of funds in cold storage, with only a small amount in hot wallets for operational liquidity
  • Security features — 2FA support (especially hardware key support), withdrawal address whitelisting, anti-phishing codes, and session management

Major Exchange Failures: Lessons Learned

Mt. Gox (2014) Once handling 70% of all Bitcoin transactions. Lost 850,000 BTC (worth roughly $450 million at the time) to a hack and mismanagement. Users waited over a decade for partial recovery. Lesson: even dominant exchanges can fail catastrophically.
QuadrigaCX (2019) The founder allegedly died with the only keys to $190 million in customer funds. Investigation revealed the exchange had been operating a Ponzi scheme. Lesson: opaque, single-person operations are extreme risks.
FTX (2022) Once valued at $32 billion. Customer funds were secretly used by the founder's trading firm. $8+ billion in customer funds lost. Lesson: even VC-backed, celebrity-endorsed exchanges can be fraudulent. Proof of reserves and regulatory oversight matter.
⚠️
Not your keys, not your coins

No matter how trustworthy an exchange appears, keeping large amounts on an exchange long-term is a risk. Buy on the exchange, then withdraw to your own wallet for storage.

Best Practices

  • Use established exchanges with multi-year track records and regulatory licenses
  • Verify proof of reserves if available — check the auditor is reputable and independent
  • Do not keep more on the exchange than you need for active trading
  • Withdraw to self-custody after purchasing — especially for amounts you are holding long-term
  • Diversify across exchanges if you must keep funds on exchanges for trading
  • Monitor exchange news — signs of trouble include withdrawal delays, leadership departures, and regulatory actions

Now Audit an Exchange Yourself, in Five Steps

“Proof of reserves” is the strongest-sounding claim an exchange makes, and most people accept it without knowing what is being proved. In the next twenty-five minutes you will build one, verify your own balance inside it, catch an exchange quietly shaving your account, and then find the two things the proof cannot show you — which is exactly what FTX’s customers discovered too late. Every number below came from running these files.

1
Build the tree an exchange publishes

Go: open a terminal in a folder you can write to, e.g. cd ~/Desktop (Windows: cd %USERPROFILE%\Desktop).

Do: save this as merkle.py. It prints nothing by itself — it is the toolbox for the whole exercise.

import hashlib

def h(x: bytes) -> str:
    return hashlib.sha256(x).hexdigest()

def leaf(user, balance):
    return h(f"{user}:{balance}".encode())

def build(leaves):
    """Return every level of the tree, bottom first."""
    levels = [leaves]
    while len(levels[-1]) > 1:
        row = levels[-1]
        if len(row) % 2:
            row = row + [row[-1]]
        levels.append([h((row[i] + row[i + 1]).encode())
                       for i in range(0, len(row), 2)])
    return levels

def proof_for(levels, index):
    """The sibling hashes needed to climb from one leaf to the root."""
    path = []
    for level in levels[:-1]:
        row = level + ([level[-1]] if len(level) % 2 else [])
        sibling = index ^ 1
        path.append((("right" if index % 2 == 0 else "left"), row[sibling]))
        index //= 2
    return path

def replay(leaf_hash, path):
    node = leaf_hash
    for side, sibling in path:
        node = h((node + sibling).encode()) if side == "right" else h((sibling + node).encode())
    return node

Now save this as publish.py and run python3 publish.py:

from merkle import leaf, build

accounts = [("alice", 12), ("bob", 3), ("carol", 40), ("dan", 7),
            ("erin", 25), ("frank", 1), ("grace", 60), ("you", 15)]

levels = build([leaf(u, b) for u, b in accounts])
print("customers in the tree :", len(accounts))
print("total customer coins  :", sum(b for _, b in accounts))
print("published root        :", levels[-1][0])

You should see: eight customers reduced to a single 64-character number:

customers in the tree : 8
total customer coins  : 163
published root        : f105808937c5aa1d93c49ab615c9e82ebc730654bdb8c71ed6bcb9cb5a3c9acd

That root is what an exchange puts on its website. It reveals nobody’s balance, and it cannot be changed without changing the number — which is the entire idea.

If not: ModuleNotFoundError: No module named 'merkle' means the two files are not in the same folder. A different root means a balance or a name was mistyped; every character feeds the hash.

2
Verify your own balance is inside it

Go: same folder. This is the part a real exchange gives you in your account settings, usually behind a button called “Merkle proof”.

Do: save this as myproof.py and run it.

from merkle import leaf, build, proof_for, replay

accounts = [("alice", 12), ("bob", 3), ("carol", 40), ("dan", 7),
            ("erin", 25), ("frank", 1), ("grace", 60), ("you", 15)]
leaves = [leaf(u, b) for u, b in accounts]
levels = build(leaves)
root = levels[-1][0]

path = proof_for(levels, 7)                 # "you" are account number 7
print("your leaf     :", leaves[7][:16], "...")
for side, sib in path:
    print(f"  sibling on the {side:5s}: {sib[:16]}...")
print("replayed root :", replay(leaves[7], path)[:16], "...")
print("matches the published root:", replay(leaves[7], path) == root)

You should see: three sibling hashes carrying you up to the same root:

your leaf     : b15b14a576c0fc2f ...
  sibling on the left : ef3a84e4dacf2aef...
  sibling on the left : c2c942c1b0ea17c6...
  sibling on the left : f93547ce69b99b82...
replayed root : f105808937c5aa1d ...
matches the published root: True

Three hashes proved your balance is in a set of eight; for eight million customers it would take twenty-three. You learned nothing about anyone else’s money, and the exchange proved something it cannot take back.

If not: False on the last line means the index and the leaf disagree — both must be 7. If the sibling sides read “right”, you are proving a different account; that is fine, but the root must still match.

3
Catch the exchange shaving your balance

Go: same folder. Now make the exchange dishonest and see whether the proof notices.

Do: save this as shaved.py and run it. The exchange builds its tree recording you as holding 5 coins while your account page still says 15.

from merkle import leaf, build, proof_for, replay

honest = [("alice", 12), ("bob", 3), ("carol", 40), ("dan", 7),
          ("erin", 25), ("frank", 1), ("grace", 60), ("you", 15)]
shaved = [(u, 5 if u == "you" else b) for u, b in honest]

for label, accounts in (("honest books", honest), ("your balance shaved", shaved)):
    leaves = [leaf(u, b) for u, b in accounts]
    levels = build(leaves)
    mine = leaf("you", 15)                   # what YOUR statement says
    ok = replay(mine, proof_for(levels, 7)) == levels[-1][0]
    print(f"{label:20s} root {levels[-1][0][:16]}...  your proof verifies: {ok}")

You should see: the lie failing your check:

honest books         root f105808937c5aa1d...  your proof verifies: True
your balance shaved  root a659834746a7f592...  your proof verifies: False

This is the one thing proof of reserves genuinely gives you, and it is worth having: if you check your proof after every publication and it always verifies, the exchange cannot be understating what it owes you. The catch is that you have to check — a proof nobody verifies constrains nobody.

If not: if both lines say True, the mine line is being rebuilt from the shaved list instead of your own statement — it must stay leaf("you", 15).

4
Find the first thing the proof cannot show: a customer left out

Go: same folder. Your proof verified. Does that mean the tree is complete?

Do: save this as omitted.py and run it. The exchange leaves its largest customer out of the tree entirely.

from merkle import leaf, build

full = [("alice", 12), ("bob", 3), ("carol", 40), ("dan", 7),
        ("erin", 25), ("frank", 1), ("grace", 60), ("you", 15)]
hidden = [a for a in full if a[0] != "grace"]      # a big account quietly left out

for label, accounts in (("all 8 customers", full), ("grace omitted", hidden)):
    levels = build([leaf(u, b) for u, b in accounts])
    print(f"{label:16s} liabilities {sum(b for _, b in accounts):>4}   "
          f"root {levels[-1][0][:16]}...")

You should see: the exchange’s stated obligations dropping by more than a third:

all 8 customers  liabilities  163   root f105808937c5aa1d...
grace omitted    liabilities  103   root 5adcb260557f7342...

Grace’s proof would now fail — but Grace has to look. Everybody else’s proof still verifies perfectly against a tree that understates what the exchange owes by 60 coins. A Merkle proof shows that your account is included; it cannot show that everyone’s is. That gap is why these publications are supposed to be checked by an auditor who can compare the tree against the exchange’s internal ledger.

If not: if both roots are identical, the filter did not remove anything — check the name is spelled "grace" exactly.

5
Find the second: coins that were only borrowed for the photograph

Go: same folder. The tree counts what is owed. The wallets show what is held. Compare them.

Do: save this as solvency.py and run it.

liabilities = 163          # the total the tree commits the exchange to
on_chain    = 171          # coins visibly held in the exchange's published wallets

print(f"customer coins owed (from the tree): {liabilities}")
print(f"coins in the published wallets     : {on_chain}")
print(f"reserve ratio                      : {on_chain / liabilities:.1%}")
print()
borrowed = 60
print("but if 60 of those coins were borrowed for the day of the snapshot:")
print(f"  really owned                     : {on_chain - borrowed}")
print(f"  real reserve ratio               : {(on_chain - borrowed) / liabilities:.1%}")

You should see: a healthy-looking exchange turning insolvent on one assumption:

customer coins owed (from the tree): 163
coins in the published wallets     : 171
reserve ratio                      : 104.9%

but if 60 of those coins were borrowed for the day of the snapshot:
  really owned                     : 111
  real reserve ratio               : 68.1%

A snapshot proves coins were in a wallet at one instant, not that they belong to the exchange, and not that they are still there tomorrow. This is not a theoretical worry: borrowing assets to pass a point-in-time check has happened, which is why a proof of reserves without a proof of liabilities and an auditor’s attestation is a marketing page, not an audit.

So what do you actually do? Treat proof of reserves as one input among several: whether your own proof verifies each time, whether an auditor is named, whether withdrawals are working today for other people, and above all how much you leave there. The only reserve you control is the coins you moved off the exchange.

If not: ValueError: Invalid format specifier means the :.1% was mistyped — it is a colon, a dot, a one and a percent sign.

🎉
Check yourself before moving on

Without scrolling up: an exchange publishes a Merkle root every month, your proof verifies every time, and the wallets show 105% of liabilities. Name two ways it could still fail tomorrow, and the one action that protects you from both. Answer: it could be omitting customers from the tree, so the stated liabilities are lower than the real ones (step 4); and the reserve coins could be borrowed, pledged as collateral, or simply moved out the day after the snapshot (step 5). Neither is visible in a proof your own account passes. The single action that covers both is to hold long-term funds in your own wallet and leave on the exchange only what you are actively trading — a proof of reserves changes how much you can verify, never how much you can recover.

Now do it without the page: add two more customers to accounts, rebuild, and produce a working proof for the new last account. Note how many sibling hashes the proof needs now, and work out how many it would take for a million customers. That number — about twenty — is why this scheme is used at all: verification cost grows by one hash each time the customer base doubles.

Summary

  • Centralized exchanges are convenient but carry custodial risk — your funds are only as safe as the exchange
  • Look for regulatory compliance, proof of reserves, insurance funds, and cold storage practices
  • History shows that even the largest, most trusted exchanges can fail
  • Minimize exchange exposure: buy and withdraw to self-custody
  • Decentralized exchanges eliminate custodial risk but introduce smart contract risk
🎉
You can now evaluate exchange security!

Next, learn how to secure your exchange account with 2FA, whitelists, and other protective measures.