How an NFT sells for $500m — flash loan deep dive

Nate Lapinski
7 min readMar 9, 2023

--

Better get your checkbook

A while ago, a Cryptopunk generated some news coverage by selling for roughly $500 million dollars. It was quickly identified that the owner was selling the token back to themselves using a flash loan. Even though this was more of a publicity stunt, it’s interesting to see how this works at a technical level. Over $500 million dollars was mobilized in a publicity stunt/market manipulation, the code and transactions are all public information, so there’s no reason not to have a technical understanding of this. Let’s see how this was possible.

This is going to be a technical dive into the smart contract calls that made this possible. If you just want more of an overview, skip to “Great, why?”.

Contents
- What are Flash Loans?
- The Players
- The Technical Details
- Chaining Smart Contracts
- Great, why?
- Conclusion

What are Flash Loans?

Flash loans are a way to borrow a large sum of money in DeFi with no collateral. Traditionally, people won’t lend you money unless you stake something as collateral. e.g. I’ll lend you money but I get your car if you don’t pay me back. DeFi doesn’t do this — but flash loans have the stipulation that you have to pay the loan back in the same transaction. Not just the same block — the same transaction.

If you’re going to do anything interesting with the money, it has to be done in the same transaction that you borrow the money. This means that we will need to compose smart contract calls together in order to make this happen within one transaction. Something to the effect of

  • smart contract a: borrow money
  • smart contract(s) b (c,d,…) : do stuff with money
  • smart contract a: return money

Think of it like you are chaining function calls together in a conventional programming language (or maybe more like chaining api calls).

We’ll see the smart contract calls in detail below, but it’s important to have a mental model before going in. We can have access to as much money as we want, but we have a very strict constraint — everything has to be done and paid back in one transaction (feels a bit like the plot for some 80s movie). We will operate within that constraint by chaining smart contract calls together — this will allow us to accomplish something useful in one transaction.

The Players

Compound: A lending protocol.

dYdX: A decentralized exchange (DEX) which supports flash loans.

The Technical Details

It all starts with Etherscan:
https://etherscan.io/tx/0x92488a00dfa0746c300c66a716e6cc11ba9c0f9d40d8c58e792cc7fcebf432d0/advanced

Everything we need to understand the technical details can be found here. Especially in the event logs:
https://etherscan.io/tx/0x92488a00dfa0746c300c66a716e6cc11ba9c0f9d40d8c58e792cc7fcebf432d0/advanced#eventlog

Let’s start with the highest level. Let’s monitor the ERC20 tokens included in this transaction.

A flash loan

What this shows is:

  • 500m DAI supplied to Compound as collateral
  • ~87k ETH borrowed from Compound
  • ~20.4k ETH borrowed and then repaid to dYdX
  • ~87k ETH repaid to Compound
  • Slightly less than 500m DAI withdrawn from Compound

Once the dust settles, we’re basically back where we started. Now that we have an understanding of what value was moved (DAI, ETH), let’s look at the chain of smart contract calls.

Chaining Smart Contracts

In theory, all smart contract code is publicly viewable on Ethereum. There can be inconsistencies between what is in a github repo, or on etherscan, and which bytecode is actually deployed on-chain — but for tracing smart contract calls, etherscan is a great resource — it’s like getting stack traces and memory dumps for free!

Let’s start with the logs:
https://etherscan.io/tx/0x92488a00dfa0746c300c66a716e6cc11ba9c0f9d40d8c58e792cc7fcebf432d0#eventlog

We start with a series of ERC20 Transfer events before hitting on a call to FlashLoan at address `0x1eb4cf3a948e7d72a198fe073ccb8c7a948cd853`

FlashLoan call with data

Address `0x6B175474E89094C44Da98b954EedeAC495271d0F` is a DAI contract. Here’s the Solidity source for the flashLoan call.

    function flashLoan(
IERC3156FlashBorrower receiver,
address token,
uint256 amount,
bytes calldata data
) external override lock returns (bool) {
require(token == address(dai), "DssFlash/token-unsupported");
require(amount <= max, "DssFlash/ceiling-exceeded");

uint256 amt = _mul(amount, RAY);
uint256 fee = _mul(amount, toll) / WAD;
uint256 total = _add(amount, fee);

vat.suck(address(this), address(this), amt);
daiJoin.exit(address(receiver), amount);

emit FlashLoan(address(receiver), token, amount, fee);

require(
receiver.onFlashLoan(msg.sender, token, amount, fee, data) == CALLBACK_SUCCESS,
"DssFlash/callback-failed"
);

dai.transferFrom(address(receiver), address(this), total); // The fee is also enforced here
daiJoin.join(address(this), total);
vat.heal(amt);

return true;
}

Of note here is the `IERC3156FlashBorrower receiver` argument, which at call time had the value `0x9B5A5C5800c91Af9C965B3Bf06Ad29cAa6d00F9b`. Keep in mind that everything has to be done in one transaction for a flash loan, so it’s necessary to call the onFlashLoan method on the receiver, and assuming it succeeded (the require statement), calling the transferFrom method.

As we move through the logs, we see several Transfer events, along with some other events related to borrowing. Eventually, we come to this batch of events from the CryptoPunks smart contract:

We can see that the punk in question — 9998 — has been listed as not for sale, and then as bought:

  function punkNoLongerForSale(uint punkIndex) {
if (!allPunksAssigned) throw;
if (punkIndexToAddress[punkIndex] != msg.sender) throw;
if (punkIndex >= 10000) throw;
punksOfferedForSale[punkIndex] = Offer(false, punkIndex, msg.sender, 0, 0x0);
PunkNoLongerForSale(punkIndex);
}

Followed by a PunkBought event, both of which are emitted from the buyPunkmethod:

  function buyPunk(uint punkIndex) payable {
if (!allPunksAssigned) throw;
Offer offer = punksOfferedForSale[punkIndex];
if (punkIndex >= 10000) throw;
if (!offer.isForSale) throw; // punk not actually for sale
if (offer.onlySellTo != 0x0 && offer.onlySellTo != msg.sender) throw; // punk not supposed to be sold to this user
if (msg.value < offer.minValue) throw; // Didn't send enough ETH
if (offer.seller != punkIndexToAddress[punkIndex]) throw; // Seller no longer owner of punk

address seller = offer.seller;

punkIndexToAddress[punkIndex] = msg.sender;
balanceOf[seller]--;
balanceOf[msg.sender]++;
Transfer(seller, msg.sender, 1);

punkNoLongerForSale(punkIndex);
pendingWithdrawals[seller] += msg.value;
PunkBought(punkIndex, msg.value, seller, msg.sender);

// Check for the case where there is a bid from the new owner and refund it.
// Any other bid can stay in place.
Bid bid = punkBids[punkIndex];
if (bid.bidder == msg.sender) {
// Kill bid and refund value
pendingWithdrawals[msg.sender] += bid.value;
punkBids[punkIndex] = Bid(false, punkIndex, 0x0, 0);
}
}

Continuing down the trail of event logs, we come to a RepayBorrow event, which is buried within Compound’s repayBorrowFresh method

 /**
* @notice Borrows are repaid by another user (possibly the borrower).
* @param payer the account paying off the borrow
* @param borrower the account with the debt being payed off
* @param repayAmount the amount of undelrying tokens being returned
* @return uint 0=success, otherwise a failure (see ErrorReporter.sol for details)
*/
function repayBorrowFresh(address payer, address borrower, uint repayAmount) internal returns (uint)

Lastly, at the end of the event chain, we see a PunkTransfer event:

Which is coming from within the transferPunk method:

    // Transfer ownership of a punk to another user without requiring payment
function transferPunk(address to, uint punkIndex) {
if (!allPunksAssigned) throw;
if (punkIndexToAddress[punkIndex] != msg.sender) throw;
if (punkIndex >= 10000) throw;
if (punksOfferedForSale[punkIndex].isForSale) {
punkNoLongerForSale(punkIndex);
}
punkIndexToAddress[punkIndex] = to;
balanceOf[msg.sender]--;
balanceOf[to]++;
Transfer(msg.sender, to, 1);
PunkTransfer(msg.sender, to, punkIndex);
// Check for the case where there is a bid from the new owner and refund it.
// Any other bid can stay in place.
Bid bid = punkBids[punkIndex];
if (bid.bidder == to) {
// Kill bid and refund value
pendingWithdrawals[to] += bid.value;
punkBids[punkIndex] = Bid(false, punkIndex, 0x0, 0);
}
}

But notice something funny about this; transferPunk changes ownership without payment. This event’s signature is

    event PunkTransfer(address indexed from, address indexed to, uint256 punkIndex);

Looking at the on-chain data, we see that from, to are

It’s going from `0x9B5A5C5800c91Af9C965B3Bf06Ad29cAa6d00F9b` to `0xef764BAC8a438E7E498c2E5fcCf0f174c3E3F8dB`. But the original transaction chain was the reverse!

So the final PunkTransfer event is what transferred the punk back to its original address. 0x9B5A5C5800c91Af9C965B3Bf06Ad29cAa6d00F9b took out a flash loan to purchase the punk from 0xef764BAC8a438E7E498c2E5fcCf0f174c3E3F8dB for over $500 million, and then at the very end of the transaction, it quietly transferred the punk back to 0xef764BAC8a438E7E498c2E5fcCf0f174c3E3F8dB

Great, why?

Could just have been for fun. However, with art markets, auctions/sales are generally the only public record of how much a piece has sold for. Look at the Opensea registry for punk 9998:

Even though this sale was “fake”, it’s still listed as a sale on Opensea.

That’s a really big last sale price. Now in this particular example, this is such an isolated — and astronomical- sale for an NFT, this was probably just done as a joke.

On a more serious note, if you’ve somehow read this far, it should be clear to you how easy it is for an individual or group to manipulate the price of an NFT. They don’t even need money or collateral — they can just use a flash loan.

Conclusion

Thanks for reading. Please leave a comment if you want to see more technical deep-dives into smart contracts. I’m thinking of doing one of these on how similar tactics can be used in wash trading to affect the price/volume of ERC20 tokens.

--

--

Nate Lapinski

Blockchain Engineer and Fullstack Developer. @0xwintercode on Twitter. Web3 substack: https://0xwintercode.substack.com/