Tornado Cash is an open-source, decentralised cryptocurrency mixer. Using zero-knowledge proofs, this mixes identifiable funds with others, obscuring the original source of the funds. On 08 August 2022, the U.S. Office of Foreign Assets Control (OFAC) banned the Tornado Cash mixer, arguing that it had played a central role in the laundering of more than $7 billion.

The USD Coin (USDC) is a centralised digital currency that can be used for online payments. The issuer of the USDCs – the Circle company – guarantees that every digital coin is fully backed by actual U.S. dollars, with the value of one USDC pegged to an actual U.S. dollar. Following the ban, the Circle company started to freeze addresses linked with the Tornado Cash mixer.

This article does not aim to address any political views or opinions but rather to present an interesting case study on how this was technically achieved. We can seize this opportunity to investigate several basic but key concepts of Ethereum and Ethereum-based blockchains. For simplicity, in this article we will primarily focus on Ethereum.

Understanding ERC-20 Tokens

With Ethereum, tokens are handled by smart contracts – simple and short programmes stored on the blockchain that can be called via transactions. The smart contract is then responsible among other things for handling users’ transactions or storing owners’ balances.

A standard ABI (Application Binary Interface) for manipulating tokens called ERC-20 (Ethereum Request for Comments 20) was released to ease interoperability, and is described in the Ethereum Improvement Proposals (EIP) 20. The USDC follows that standard.

ERC-20 specifications are fairly short. To be a valid ERC-20 token, the deployed smart contract must simply implement the following functions:

  • totalSupply()
  • balanceOf(account)
  • allowance(owner, spender)
  • approve(spender, amount)
  • transfer(recipient, amount)
  • transferFrom(sender, recipient, amount)

It must also implement the following events:

  • Transfer(from, to, value)
  • Approval(owner, spender, value)

The USDC token

To understand how the USDC was implemented we only need the smart contract address and its source code, published by Circle:

There is a subtlety here but we will not go into detail. The source code for the real ERC-20 API for USDC can be retrieved from a proxy contract, which can be found at the following address:

You can check OpenZeppelin’s Unstructured Storage proxy pattern for more information. In short, using a proxy contract is a convenient way to manage upgrades.

The totalSupply() function

The totalSupply() function is pretty much self-explanatory and can be used at any time to find out how many tokens were minted in total.

Open Etherscan and search for the USDC contract address. Go to the “Contract” tab next to “Transactions”, “Internal Txns” and “Erc20 Token Txns”. Then click on the “Read as Proxy” button and scroll down the list to “totalSupply”.

At the time of writing, this was 42039807469599550 and with the decimal 42,039,807,469.599550 USDC. ERC-20 tokens can freely implement a decimals() function which is set to 6 here. Because we only “read” from the blockchain, these operations are free.

The transfer() Function

In order to send an ERC-20 token to another address, one would need to send a transaction to the transfer() function with the recipient address and the number of tokens to send as arguments. To make things easier we will only discuss here how a transaction is sent to a full Ethereum node and skip the part where it is actually added to the blockchain.

Let us examine how the transfer() function was implemented. The released code is written in Solidity. This is mostly straightforward, and not necessary to know in order to understand the following.

You can see notBlacklisted(msg.sender) and notBlacklisted(to) on lines 867 and 868. These are function modifiers, similar to Python’s decorators, and they wrap the function underneath.

The source code of the modifier is quite explicit. In Solidity, require() is a control function in which the initial parameter must be set to true, otherwise the transaction is reverted. Here the _account address is checked against the blacklisted mapping which is simply a hash table. It can be accessed with a key, i.e. the address, and it returns a value. If the address is not in the mapping, 0 is returned.

The value msg.sender is the address issuing the transaction, and to is the recipient. If none of these addresses are found in the blacklisted mapping, the _transfer() function is called and the transaction is enabled.

The blacklisted mapping is filled using the blacklist function.

Similarly, the onlyBlacklister() modifier protects unauthorised blacklisting of addresses.

TransferFrom() and Approve() functions

The transferFrom() function is very similar to the transfer() function and is mostly used by smart contracts to transfer tokens on your behalf. In theory it is possible to send tokens directly to a smart contract using transfer() and then call the desired function. However, this requires two transactions and the smart contract would have no idea about the first one.

The solution is to grant a smart contract access to transfer a limited or unlimited amount of tokens. This is achieved using the approve() function.

Following approval, the transferFrom() function can be called.

Both functions are of course covered by the notBlacklisted() modifier.

How to check whether an address is blacklisted

Now that we understand how Circle can block token transfers, we can play with the smart contract to determine whether an address is banned. For the demo we will use Vitalik’s, one of the Ethereum’s founders, wallet address.

The smart contract exports a function called isBlacklisted; all we need to do is to call it with the desired address.

Below is a small TypeScript piece of code that does exactly that:

import "dotenv/config";
import { ethers } from "ethers";

const USDC_PROXY_ADDRESS = "0xB7277a6e95992041568D9391D09d0122023778A2";
const VITALIK_WALLET = "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B";

const isBlacklisted = async (
   usdcContract: ethers.Contract,
   address: string
) => {
   const ret = await usdcContract.isBlacklisted(address);
   console.log(`Wallet ${address} is ${ret ? "" : "not"} blacklisted.`);
};

const main = async () => {
   const provider = new ethers.providers.JsonRpcProvider(
      process.env.HTTPS_ENDPOINT
   );

   const usdcContract = new ethers.Contract(
      USDC_PROXY_ADDRESS,
      ["function isBlacklisted(address _account) view returns (bool)"],
      provider
   );

   await isBlacklisted(usdcContract, VITALIK_WALLET);
};

Full code is available here.

$ ts-node src/isblacklisted.ts
Wallet 0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B is not blacklisted.

Vitalik’s wallet is safe!

Or we could simply ask Etherscan again.

How to find all blacklisted addresses

We know how to check whether a single address was banned, but how can we retrieve all blacklisted addresses? Unfortunately for us, transactions are not indexed in the Ethereum blockchain and it is not possible to simply list the content of the mapping.

An important point here! Mapping cannot be used to store any secret. Anyone with a copy of the blockchain can retrieve all transaction data.

One way would be to go through every block and transaction and then dissect them to find transactions to the blacklist() function. However, this would be quite inefficient and extremely slow. Fortunately, Circle implemented an event that is issued every time an address is banned. And unlike transactions, events are indexed.

If we check the blacklist() function code, we can see the event on the last line.

The _account argument is also indexed.

To access logs, we can use the RPC method eth_getLogs() of an Ethereum node. This method accepts a few parameters:

  • fromBlock and toBlock
  • a contract address
  • and an array called topics

Topics are indexed parameters of an event, and they can be viewed as filters. The first topic, topic[0] is always the event signature, a keccak256 hash of the event name and parameters. This is easily computed using the ethers.js library.

ethers.utils.id("Blacklisted(address)");

The hash in our case is:

  • 0xffa4e6181777692565cf28528fc88fd1516ea86b56da075235fa575af6a4b855

The other topics are the arguments. For Blacklisted() it is an address. Since we want to find all events, this argument is left empty.

Even with an event filter, searching for the entire blockchain would take too long as there have been too many transactions since the genesis block. In this example we will only list Blacklisted() events that happened on the day of the ban, on 08 August 2022.

  • 2022-08-08 00:00
    • block number: 15298283
  • 2022-08-08 23:59
    • block number: 15304705
const filter = {
   address: USDC_ERC20_ADDRESS,
   fromBlock: 15298283,
   toBlock: 15304705,
   topics: [ethers.utils.id("Blacklisted(address)")],
};

Using ethers.js, we can call the getLogs() method using our filter.

const logs = await this.provider.getLogs(filter);

/* Sorting unique addresses. */
this.addresses = [
   ...(new Set() <
   string >
   logs.map((log) =>
      ethers.utils.getAddress(`0x${log.topics[1].substr(26)}`)
   )),
];

All we need to do now is to display the wallet addresses and frozen balances:

const symbol = await this.usdcContract.symbol();
console.log(`[+] ${this.addresses.length} wallets address found:`);

await Promise.all(
   this.addresses.map(async (address) => {
      const amount = await this.usdcContract.balanceOf(address);
      console.log(
         ` - ${address}: ${ethers.utils.formatUnits(amount, "mwei")} ${symbol}`
      );
   })
);

Running the script from the terminal gives us all the wallets that were banned that day.

> ts-node src/findbanned.ts
[+] 38 wallets address found:
- 0x8589427373D6D84E98730D7795D8f6f8731FDA16: 0.0 USDC
- 0xd90e2f925DA726b50C4Ed8D0Fb90Ad053324F31b: 0.0 USDC
- 0xDD4c48C0B24039969fC16D1cdF626eaB821d3384: 149.752 USDC
- 0xD4B88Df4D29F5CedD6857912842cff3b20C8Cfa3: 0.0 USDC
- 0x722122dF12D4e14e13Ac3b6895a86e84145b6967: 0.0 USDC
- 0xFD8610d20aA15b7B2E3Be39B396a1bC3516c7144: 0.0 USDC
- 0xF60dD140cFf0706bAE9Cd734Ac3ae76AD9eBC32A: 0.0 USDC
- 0xd96f2B1c14Db8458374d9Aca76E26c3D18364307: 3900.0 USDC
- 0x910Cbd523D972eb0a6f4cAe4618aD62622b39DbF: 0.0 USDC
- 0x4736dCf1b7A3d580672CcE6E7c65cd5cc9cFBa9D: 71000.0 USDC
- 0xb1C8094B234DcE6e03f10a5b673c1d8C69739A00: 0.0 USDC
- 0xA160cdAB225685dA1d56aa342Ad8841c3b53f291: 0.0 USDC
- 0xBA214C1c1928a32Bffe790263E38B4Af9bFCD659: 0.0 USDC
- 0x22aaA7720ddd5388A3c0A3333430953C68f1849b: 0.0 USDC

[...]

- 0x2717c5e28cf931547B621a5dddb772Ab6A35B701: 0.0 USDC
- 0x178169B423a011fff22B9e3F3abeA13414dDD0F1: 0.0 USDC

As mentioned previously, full code is available here.

Conclusion

Crypto assets are of a new kind of asset and a blooming technology. Understanding how Circle banned Tornado Cash users was a good excuse to understand key concepts and to explore the Ethereum blockchain. However we have only scratched the surface. Other assets may have different implementations, restrictions, different trade-offs. So always remember the famous principle: Don’t trust, verify!