# DeFi

In today's tutorial, we will explore one of the original Decentralized finance (DeFi) protocols, called Uniswap v1 (opens new window). It is a protocol for an automated market maker (AMM) that automatically trades ERC20 tokens. In a traditional, centralized finance, the market maker processes an ordering book that matches buyers and sellers with their desirable prices. Uniswap attempts to automate this process with the help of the liquidity providers and arbitragers that make the price of an ERC20 token close to the market value without managing the ordering book.

# AMM

First, please take time to read the overview of the Uniswap V1 (opens new window) and its brief history (opens new window). The latest iteration of Uniswap is Uniswap V3 but, in our tutorial, we'd like to focus on the original version that demonstrates the simple idea of a constant product AMM (later).

Simply put, it determines the price () of a token () with this simple formula, , where is an arbitrary constant set at the creation of the exchange and remained identical across the transactions. For example, when selling a token (i.e., putting a token to the liquidity pool), , it calculates the token's price (i.e., returning ether), , with the following formula:

So,

With a fee (), which is charged to the input entity (e.g., the token in our example), the formula becomes a bit complicated:

So,

Thanks to the fee, the , in fact, slightly increases over time, .

Let's see how Uniswap implements this formula in its code:

# @dev Pricing function for converting between ETH and Tokens.
# @param input_amount Amount of ETH or Tokens being sold.
# @param input_reserve Amount of ETH or Tokens (input type) in exchange reserves.
# @param output_reserve Amount of ETH or Tokens (output type) in exchange reserves.
# @return Amount of ETH or Tokens bought.
@private
@constant
def getInputPrice(input_amount: uint256, input_reserve: uint256, 
                  output_reserve: uint256) -> uint256:
    assert input_reserve > 0 and output_reserve > 0
    input_amount_with_fee: uint256 = input_amount * 997
    numerator: uint256 = input_amount_with_fee * output_reserve
    denominator: uint256 = (input_reserve * 1000) + input_amount_with_fee
    return numerator / denominator

getInputPrice() takes an input_amount (i.e., selling or putting it to the liquidity pool) and returns the amount bought (i.e., returning ether in our example). It uses input for selling and output for buying in the code.

In the code, input_reserve output_reserve , and the fee, .

(input_reserve input_amount) (output_reserve - return)

It simplifies to:

return numerator / denominator

where

numerator input_amount output_reserve

denominator input_reserve input_amount

# Factory

In Uniswap, anyone can create an exchange of any ERC20 token via createExchange().

@public
def createExchange(token: address) -> address:
    assert token != ZERO_ADDRESS
    assert self.exchangeTemplate != ZERO_ADDRESS
    assert self.token_to_exchange[token] == ZERO_ADDRESS
    exchange: address = create_with_code_of(self.exchangeTemplate)
    Exchange(exchange).setup(token)
    self.token_to_exchange[token] = exchange
    self.exchange_to_token[exchange] = token
    token_id: uint256 = self.tokenCount + 1
    self.tokenCount = token_id
    self.id_to_token[token_id] = token
    log.NewExchange(token, exchange)
    return exchange

Simply put, it creates (Exchange(exchange).setup(token)) a new exchange of a given ERC20 token. Once created, any one can interact with the exchange of the ERC20 token. However, it is created with no token or ether, as a liquidity pool, so the original creator might want to add the proper amount of a ERC20 token with a desirable rate of an ether via AddLiquidity() of Exchange.

# @notice Deposit ETH and Tokens (self.token) at current ratio to mint UNI tokens.
# @dev min_liquidity does nothing when total UNI supply is 0.
# @param min_liquidity Minimum number of UNI sender will mint if total UNI supply is greater than 0.
# @param max_tokens Maximum number of tokens deposited. Deposits max amount if total UNI supply is 0.
# @param deadline Time after which this transaction can no longer be executed.
# @return The amount of UNI minted.
@public
@payable
def addLiquidity(min_liquidity: uint256, max_tokens: uint256, 
                 deadline: timestamp) -> uint256:
    assert deadline > block.timestamp and (max_tokens > 0 and msg.value > 0)
    total_liquidity: uint256 = self.totalSupply
    if total_liquidity > 0:
        ...
    else:
        assert (self.factory != ZERO_ADDRESS and self.token != ZERO_ADDRESS) 
               and msg.value >= 1000000000
        assert self.factory.getExchange(self.token) == self
        token_amount: uint256 = max_tokens
        
        # msg.value sent to the exchange
        initial_liquidity: uint256 = as_unitless_number(self.balance)
        self.totalSupply = initial_liquidity
        self.balances[msg.sender] = initial_liquidity

        # token sent to the exchanges
        assert self.token.transferFrom(msg.sender, self, token_amount)

        log.AddLiquidity(msg.sender, msg.value, token_amount)
        log.Transfer(ZERO_ADDRESS, msg.sender, initial_liquidity)
        return initial_liquidity

addLiquidity transfers max_tokens (the number of tokens to put) to the exchange, and accepts balance (the price of the tokens) as a liquidity pool -- min_liquidity is ignored when addLiquidity() is invoked for the first time.

Let's try set up an Uniswap exchange (opens new window)!

$ brownie run scripts/uniswap.py -I
...
>>> factory
<Factory Contract '0xb5F338E1397D646AFFf707DD47ea94eAacdBa45D'>

# Exchange

Factory provides a set of APIs that create a new exchange for an ERC20 token. You can either use the ERC20 token you created in the last tutorial or use a generic one like below. Note that it extends the ERC20 implementation from OpenZeppelin (opens new window) and allows a minting of new tokens with a non-standard mint() interface, but only for the owner of the contract by using the isOwner modifier provided by Owner.sol.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./ERC20/ERC20.sol";
import "./Owner.sol";

contract ERCToken is ERC20, Owner {
    constructor(uint256 initialSupply, string memory symbol)
        ERC20("General ERC20 Token", symbol) {
        _mint(msg.sender, initialSupply);
    }

    function mint(address _receiver, uint256 amount) isOwner public {
        _mint(_receiver, amount);
    }
}
>>> token = ERCToken.deploy(1000, "A")
<ERCToken Contract '0xcFf3e49D8947D75Fa04115cFD3f9343c95956eb7'>

>>> factory.createExchange(token.address)
>>> factory.getExchange(token.address)
'0x0bEf07F1fF27538ee5C7D64e7582A42867069f4e'

The exchange for the token is created with createExchange() but it lacks an interface for its ABI. You can populate a contract with the proper ABI with new_exchange() provided by uniswap.py.

>>> exchange = new_exchange(factory, token)
<Exchange Contract '0x0bEf07F1fF27538ee5C7D64e7582A42867069f4e'>

>>> exchange.tokenAddress()
'0xcFf3e49D8947D75Fa04115cFD3f9343c95956eb7'

# no liquidity pool
>>> exchange.totalSupply()
0
# no balance
>>> exchange.balance()
0
# no token
>>> token.balanceOf(exchange.address)
0

To add liquidity to the exchange, addLiquidity() should be invoked.

# Note.
#   min_liquidity will be ignored for the very first setup
#   max_tokens indicates #tokens to be put
#   msg.value should be provided as part of the invocation
def addLiquidity(min_liquidity: uint256, max_tokens: uint256, 
                 deadline: timestamp) -> uint256:

One thing you might notice is the deadline parameter. If the transaction's block.timestamp is not within the provided deadline, uniswap will revert it. This can help prevent funds from beiing locked away and traded at an unfavorable price in case your transaction takes a long time to confirm.

It might also be there to mitigate a front running (or a sandwich attack) that manipulates the price of the token in the liquidity pool before the victim's transaction executes. A tight deadline can render such attack invalid as the attacker attempts to delay the commit of the transaction, but I doubt it is really effective in practice -- modern bots can quickly examine the transactions and insert their own on the fly.

# allow the exchange to spend 100 tokens
>>> token.approve(exchange.address, 100)

# add liquidity of (100 tokens, 1000000000 wei)
>>> tx = exchange.addLiquidity(0, 100, int(time.time()) + 1000, {"value": 1000000000})
>>> tx.info()
Transaction was Mined
---------------------
Tx Hash: 0x799b9ad7e2d4f00b2b386c97e1b244688e038d209af0e9f9854c8794ab655528
From: 0x66aB6D9362d4F35596279692F0251Db635165871
To: 0x0bEf07F1fF27538ee5C7D64e7582A42867069f4e
Value: 1000000000
Function: Transaction
Block: 2183
Gas Used: 102307 / 12000000 (0.9%)

Events In This Transaction
--------------------------
├── General ERC20 Token (0xcFf3e49D8947D75Fa04115cFD3f9343c95956eb7)
│   ├── Approval
│   │   ├── owner: 0x66aB6D9362d4F35596279692F0251Db635165871
│   │   ├── spender: 0x0bEf07F1fF27538ee5C7D64e7582A42867069f4e
│   │   └── value: 0
│   └── Transfer
│       ├── from: 0x66aB6D9362d4F35596279692F0251Db635165871
│       ├── to: 0x0bEf07F1fF27538ee5C7D64e7582A42867069f4e
│       └── value: 100
│
└── 0x0bEf07F1fF27538ee5C7D64e7582A42867069f4e
    ├── AddLiquidity
    │   ├── provider: 0x66aB6D9362d4F35596279692F0251Db635165871
    │   ├── eth_amount: 1000000000
    │   └── token_amount: 100
    └── Transfer
        ├── _from: 0x0000000000000000000000000000000000000000
        ├── _to: 0x66aB6D9362d4F35596279692F0251Db635165871
        └── _value: 1000000000

# we can also examine their balance and #tokens 
>>> exchange.balance()
1000000000
>>> exchange.balanceOf(a[0].address)
1000000000
>>> exchange.totalSupply()
1000000000
>>> token.balanceOf(exchange.address)
100

# ERC20 ↔ ETH

Let's interact with the exchange! So far, we added (100 Tokens, 1000000000 Wei) to the liquidity pool and so the price of a token would be 1000000000 wei / 100 token = 10000000 wei/token. To check the price of the token when selling (i.e., inputting to the exchange), getTokenToEthInputPrice() can be used:

# @notice Public price function for Token to ETH trades with an exact input.
# @param tokens_sold Amount of Tokens sold.
# @return Amount of ETH that can be bought with input Tokens.
@public
@constant
def getTokenToEthInputPrice(tokens_sold: uint256) -> uint256(wei):
    assert tokens_sold > 0
    token_reserve: uint256 = self.token.balanceOf(self)
    eth_bought: uint256 = self.getInputPrice(tokens_sold, token_reserve, as_unitless_number(self.balance))
    return as_wei_value(eth_bought, 'wei')

In fact, it's a simple wrapper of getInputPrice() we studied and, given a token to be sold, it returns the amount of ETH can be bought (i.e., outputting ether).

>>> exchange.getTokenToEthInputPrice(1)
9871580
>>> 10000000 - 9871580
128420

With the fee, it returns slightly lesser amount than the ideal, 10000000 wei/token. Let's see how the token price changes if we exchanges a token with ETH by using tokenToEthSwapInput():

# @notice Convert Tokens to ETH.
# @dev User specifies exact input and minimum output.
# @param tokens_sold Amount of Tokens sold.
# @param min_eth Minimum ETH purchased.
# @param deadline Time after which this transaction can no longer be executed.
# @return Amount of ETH bought.
@public
def tokenToEthSwapInput(tokens_sold: uint256, min_eth: uint256(wei), deadline: timestamp) -> uint256(wei):
    return self.tokenToEthInput(tokens_sold, min_eth, deadline, msg.sender, msg.sender)

It accepts the number of the token to be sold (i.e., inputting to the exchange) and returns the ether back to the msg.sender.

# putting a token to the exchange and gets the ether back
>>> ethPerToken = exchange.getTokenToEthInputPrice(1)
>>> token.approve(exchange.address, 1)

>>> tx = exchange.tokenToEthSwapInput(1, ethPerToken, int(time.time())+1000)
Transaction sent: 0x65880fbeb446f9edab8d26062c767541e67516ceea65310e4a3f82e1d18be683
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 9
  Transaction confirmed   Block: 10   Gas used: 51723 (0.43%)

<Transaction '0x65880fbeb446f9edab8d26062c767541e67516ceea65310e4a3f82e1d18be683'>

>>> tx.info()
Transaction was Mined
---------------------
Tx Hash: 0x65880fbeb446f9edab8d26062c767541e67516ceea65310e4a3f82e1d18be683
From: 0x66aB6D9362d4F35596279692F0251Db635165871
To: 0x87e23022F31814f835C052A9e280608bdE7aE3f9
Value: 0
Function: Transaction
Block: 10
Gas Used: 51723 / 12000000 (0.4%)

Events In This Transaction
--------------------------
├── General ERC20 Token (0xe0aA552A10d7EC8760Fc6c246D391E698a82dDf9)
│   ├── Approval
│   │   ├── owner: 0x66aB6D9362d4F35596279692F0251Db635165871
│   │   ├── spender: 0x87e23022F31814f835C052A9e280608bdE7aE3f9
│   │   └── value: 0
│   └── Transfer
│       ├── from: 0x66aB6D9362d4F35596279692F0251Db635165871
│       ├── to: 0x87e23022F31814f835C052A9e280608bdE7aE3f9
│       └── value: 1
│
└── 0x87e23022F31814f835C052A9e280608bdE7aE3f9
    └── EthPurchase
        ├── buyer: 0x66aB6D9362d4F35596279692F0251Db635165871
        ├── tokens_sold: 1
        └── eth_bought: 9871580
        
# new balance
>>> exchange.balance()
990128420

# one more token!
>>> token.balanceOf(exchange.address)
101

# new price!
>>> exchange.getTokenToEthInputPrice(1)
9678304

The exchange now has one more tokens so its price goes down (i.e., attracting arbitragers to buy the token) a bit.

from scripts.pwn import *
from scripts.uniswap import *

import time

def new_exchange_instance(factory):
    # 1000 initial tokens
    token = ERCToken.deploy(1000, "A")
    exchange = new_exchange(factory, token)

    # 100 to 1000000000 (wei) rate
    token.approve(exchange.address, 100)

    exchange.addLiquidity(0, 100, int(time.time()) + 1000, {"value": 1000000000})

    return (token, exchange)

def main():
    accounts.default = a[0]

    factory = init_uniswap()
    prices = []

    # collecting prices when increasing #token
    (token, exchange) = new_exchange_instance(factory)
    for i in range(50):
        ethPerToken = exchange.getTokenToEthInputPrice(1)

        num_token = token.balanceOf(exchange.address)
        eth_price = exchange.balance()
        prices.append((num_token, eth_price, num_token * eth_price))

        token.approve(exchange.address, 1)
        exchange.tokenToEthSwapInput(1, ethPerToken, int(time.time())+1000)

    # collecting prices when decreasing #token
    (token, exchange) = new_exchange_instance(factory)
    for i in range(50):
        ethPerToken = exchange.getEthToTokenOutputPrice(1)

        num_token = token.balanceOf(exchange.address)
        eth_price = exchange.balance()
        prices.append((num_token, eth_price, num_token * eth_price))

        exchange.ethToTokenSwapOutput(1, int(time.time())+1000, {"value": ethPerToken})

    # draw figures
    prices = sorted(prices)

    import matplotlib.pyplot as plt
    import numpy as np

    orig_k = 100 * 1000000000.0

    tokens = [p[0] for p in prices]
    eths = [p[1] for p in prices]
    ks = [p[2] for p in prices]

    fig, (ax0, ax1) = plt.subplots(nrows=2, ncols=1, sharex=True)
    ax0.set_title("(token, eth)")
    ax0.set_xlim((50, 150))
    ax0.plot(tokens, eths)

    ax1.set_title("k=token * eth (drift)")
    ax1.set_ylim((0.99*orig_k, 1.01*orig_k))
    ax0.set_xlim((50, 150))
    ax1.plot(tokens, ks)

    plt.show()

Token prices

# ERC20 ↔ ERC20

Uniswap provides a means to convert ERC20 ↔ ETH, and by using ETH as a common basis, it can also convert ERC20 ↔ ERC20. If you'd like to explore, please check the contracts/uniswap-v1/uniswap_exchange.vy!

# @notice Convert Tokens (self.token) to Tokens (token_addr).
# @dev User specifies exact input and minimum output.
# @param tokens_sold Amount of Tokens sold.
# @param min_tokens_bought Minimum Tokens (token_addr) purchased.
# @param min_eth_bought Minimum ETH purchased as intermediary.
# @param deadline Time after which this transaction can no longer be executed.
# @param token_addr The address of the token being purchased.
# @return Amount of Tokens (token_addr) bought.
@public
def tokenToTokenSwapInput(tokens_sold: uint256, min_tokens_bought: uint256, min_eth_bought: uint256(wei), 
                          deadline: timestamp, token_addr: address) -> uint256:
    exchange_addr: address = self.factory.getExchange(token_addr)
    return self.tokenToTokenInput(tokens_sold, min_tokens_bought, min_eth_bought, 
                                  deadline, msg.sender, msg.sender, exchange_addr)

# New Upgrade: ERC777 (opens new window)

Upgrade

ERC777 (opens new window) is an upgraded version of the ERC20 standard that maintains compatibility to ERC20. Take time to explore its EIP (opens new window) and an implementation in a popular library (opens new window).

The crux of ERC777 is to provide ❶ advanced features like hooks via ERC1820 (opens new window) and ❷ maintains the compatibility layer to ERC20 (opens new window). Simply put, it calls back to the preregistered contract whenever a token sends out (ERC777TokensSender) or whenever it receives a token (ERC777TokensRecipient). The registration of these hooks are done by a single contract, called ERC1820 (opens new window) --- its implementation and the idea of maintaining a common, static address across different chains are super interesting. If you are interested in, please check the suggested deployment method (opens new window) of ERC1820. One can imagine an advanced feature like performing an programmable access control of the ERC20 on top of the hooks, for example.

contract ERC777 is Context, IERC777, IERC20 {
    ...
    function transferFrom(address holder, address recipient, uint256 amount) public virtual override returns (bool) {
        address spender = _msgSender();
        _spendAllowance(holder, spender, amount);
*       _send(holder, recipient, amount, "", "", false);
        return true;
    }

    function _send(
        address from,
        address to,
        uint256 amount,
        bytes memory userData,
        bytes memory operatorData,
        bool requireReceptionAck
    ) internal virtual {
        require(from != address(0), "ERC777: transfer from the zero address");
        require(to != address(0), "ERC777: transfer to the zero address");

        address operator = _msgSender();

*       _callTokensToSend(operator, from, to, amount, userData, operatorData);

        _move(operator, from, to, amount, userData, operatorData);

        _callTokensReceived(operator, from, to, amount, userData, operatorData, requireReceptionAck);
    }

    function _callTokensToSend(
        address operator,
        address from,
        address to,
        uint256 amount,
        bytes memory userData,
        bytes memory operatorData
    ) private {
*       address implementer = _ERC1820_REGISTRY.getInterfaceImplementer(from, _TOKENS_SENDER_INTERFACE_HASH);
        if (implementer != address(0)) {
*           IERC777Sender(implementer).tokensToSend(operator, from, to, amount, userData, operatorData);
        }
    }
}

Let's follow the code snippets marked *. When transferFrom() is invoked, _send()_ and _callTokensToSend() are invoked inside. And, in _callTokensToSend(), it looks up the registered call and invoked tokensToSend() of the recipient.

Why suddenly are talking about ERC777 in Uniswap? Remember, it's compatible to ERC20! It means a ERC777 token can be used as a regular ERC20 token in Uniswap and any one can create an exchange for the token! Nice.

Sadly, Uniswap V1 is not built to handle an advanced feature in a secure manner. More specifically, an attacker interpose the execution of tokenToEthInput() right before the token is transferred to the exchange:

@private
def tokenToEthInput(tokens_sold: uint256, min_eth: uint256(wei), deadline: timestamp, buyer: address, recipient: address) -> uint256(wei):
    assert deadline >= block.timestamp and (tokens_sold > 0 and min_eth > 0)
*   token_reserve: uint256 = self.token.balanceOf(self)
    eth_bought: uint256 = self.getInputPrice(tokens_sold, token_reserve, as_unitless_number(self.balance))
    wei_bought: uint256(wei) = as_wei_value(eth_bought, 'wei')
    assert wei_bought >= min_eth
    send(recipient, wei_bought)
*   assert self.token.transferFrom(buyer, self, tokens_sold)
    log.EthPurchase(buyer, tokens_sold, wei_bought)
    return wei_bought

It means, while transferFrom() is executing (i.e., a token is not yet transferred), one can invoke another transactions, whose balanceOf() is not yet reflecting the past transaction.

Task

By selling 50 tokens, how much can you receive from the exchange? If you start selling 1 token at a time, its token price decreases quickly. You only can get 332525206 wei if you sell one token at a time.

# eth per token
>>> exchange.getTokenToEthInputPrice(1)
9871580

>>> token.approve(exchange.address, 50)
>>> for i in range(50): exchange.tokenToEthSwapInput(1, 1, time.time()+1000)

# how much did we get?
>>> 1000000000 - exchange.balance()
332525206

# eth per token after selling 50
>>> exchange.getTokenToEthInputPrice(1)
4407189

What about selling 50 together? We can get a bit more -- plus, avoiding some gas fee.

>>> exchange.getTokenToEthInputPrice(50)
332665999

>>> token.approve(exchange.address, 50)
>>> exchange.tokenToEthSwapInput(50, 1, time.time()+1000)

# how much did we get?
>>> 1000000000 - exchange.balance()
332665999

Can you sell 50 tokens pricier than this? Say 350000000?

contract Lab06 {
    ERC777Token public token;
    UniswapExchange public exchange;
    UniswapFactory public factory;

    constructor(address _player) payable {
        require(msg.value == 1000000000);
        address[] memory defaultOperators = new address[](1);
        defaultOperators[0] = address(this);

        token = new ERC777Token(1000, defaultOperators);

        factory = UniswapFactory(0x6B6aB591396f868bda7c42899Fc58A9E4F4AC3B8);
        exchange = UniswapExchange(factory.createExchange(address(token)));

        // give you 50 tokens to play with!
        token.transfer(_player, 50);

        // put 100 tokens to the liquidity pool
        token.approve(address(exchange), 100);
        exchange.addLiquidity{value: 1000000000}(0, 100, block.timestamp+1000);
    }

    function completed() public view returns (bool) {
        return token.balanceOf(address(exchange)) == 150
            && address(exchange).balance < (1000000000 - 350000000);
    }
}

ERC777 Token

You can find ERC777 token and related code in the repository!

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./ERC777/ERC777.sol";
import "./Owner.sol";

contract AdvERCToken is ERC777, Owner {
    constructor(uint256 initialSupply,
                string memory symbol,
                address[] memory defaultOperators)
        ERC777("General ERC777 Token", symbol, defaultOperators) {
        _mint(msg.sender, initialSupply, "", "");
    }

    function mint(address _receiver, uint256 amount) isOwner public {
        _mint(_receiver, amount, "", "");
    }
}
>>> token = AdvERCToken.deploy(1000, "B", [a[0].address])

# it works like ERC20
>>> token.balanceOf(a[0].address)
1000

ERC1820

In Lab06Exploit.sol, you can find a template of ERC777 that uses ERC1820 interfaces. Please digest the template code and launches your exploit!

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.11;

import "./ERC777/IERC777Sender.sol";
import "./ERC777/IERC1820Registry.sol";
import "./ERC20/IERC20.sol";

interface UniswapExchange {
    function tokenToEthSwapInput(uint256 tokens_sold, uint256 min_eth, uint256 deadline) external returns (uint256);
    function ethToTokenSwapInput(uint256 min_tokens, uint256 deadline) external payable returns (uint256);
}

contract Lab06Exploit is IERC777Sender {
    UniswapExchange private exchange;
    IERC20 private token;
    IERC1820Registry private ERC1820 = IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);
    address owner;

    constructor(address _exchangeAddress, address _tokenAddress) public {
        exchange = UniswapExchange(_exchangeAddress);
        token = IERC20(_tokenAddress);
        owner = msg.sender;

        // register a hook
        ERC1820.setInterfaceImplementer(address(this), keccak256("ERC777TokensSender"), address(this));
    }

    // ERC777 hook
    event Log(string log);
    function tokensToSend(address, address, address, uint256, bytes calldata, bytes calldata) external {
        emit Log("triggered?");
    }

    function testMe() external {
        token.approve(address(exchange), 1);
        exchange.tokenToEthSwapInput(1, 1, block.timestamp * 2);
    }

    receive() external payable {}
}

If testMe() is invoked, you can find "triggered?" in the log, which means the tokensToSend() call back is invoked in the middle of Uniswap's transaction.

>>> x = Lab06Exploit.deploy(exchange.address, token.address)
>>> token.transfer(x.address, 1)

>>> tx = x.testMe()
>>> tx.info()
Transaction was Mined
---------------------
Tx Hash: 0x3aad72dbb1bd23f4c859cd11cabec36504c5b89f75b0b9b255fdf5f99848ada4
From: 0xfed61D4a212A14143DC51f21d8D6617072e7a621
To: 0x16a318b60Ca20Ab4d538cf9f96C8f7817F9e4175
Value: 0
Function: Lab06Exploit.testMe
Block: 2197
Gas Used: 84067 / 12000000 (0.7%)

Events In This Transaction
--------------------------
├── General ERC777 Token (0x03f3621F38E44E099f704D08f3132A46d4329946)
│   ├── Approval
│   │   ├── owner: 0x16a318b60Ca20Ab4d538cf9f96C8f7817F9e4175
│   │   ├── spender: 0x9055210007e68928C8Db8A981153Da34633ceba8
│   │   └── value: 1
│   └── Approval
│       ├── owner: 0x16a318b60Ca20Ab4d538cf9f96C8f7817F9e4175
│       ├── spender: 0x9055210007e68928C8Db8A981153Da34633ceba8
│       └── value: 0
│
├── Lab06Exploit (0x16a318b60Ca20Ab4d538cf9f96C8f7817F9e4175)
│   └── Log
│       └── log: triggered?
│
├── General ERC777 Token (0x03f3621F38E44E099f704D08f3132A46d4329946)
│   ├── Sent
│   │   ├── operator: 0x9055210007e68928C8Db8A981153Da34633ceba8
│   │   ├── from: 0x16a318b60Ca20Ab4d538cf9f96C8f7817F9e4175
│   │   ├── to: 0x9055210007e68928C8Db8A981153Da34633ceba8
│   │   ├── amount: 1
│   │   ├── data: 0x00
│   │   └── operatorData: 0x00
│   └── Transfer
│       ├── from: 0x16a318b60Ca20Ab4d538cf9f96C8f7817F9e4175
│       ├── to: 0x9055210007e68928C8Db8A981153Da34633ceba8
│       └── value: 1
│
└── 0x9055210007e68928C8Db8A981153Da34633ceba8
    └── EthPurchase
        ├── buyer: 0x16a318b60Ca20Ab4d538cf9f96C8f7817F9e4175
        ├── tokens_sold: 1
        └── eth_bought: 9871580