close

DEV Community

Cover image for Borrow First, Settle Later, No Collateral: Flash Loans
Adi
Adi

Posted on

Borrow First, Settle Later, No Collateral: Flash Loans

Five posts in. We've covered what AMMs are, mapped the architecture, walked through adding liquidity, and dissected the swap in more detail than most people do in an entire career of using Uniswap. At this point you understand the constant product formula, how fees accumulate, how multi-hop routing works, why the optimistic transfer isn't actually reckless, and what checks-effects-interactions is and when it's okay to bend it. You're close. One more flow, and then we have one final post on TWAP oracles, one of the most underappreciated pieces of the whole protocol, and this series is done.

This post is the flash loan. Or as Uniswap calls it, the flash swap. Same idea, slightly different flavour, and we'll get to why.


What even is a flash loan

The premise sounds like something a first-year economics student would mark as logically impossible. You borrow tokens from a pool with zero collateral, no credit check, no approval process, and no prior relationship with the protocol. You use them however you want. Then you return them. The whole thing happens and is settled within a single transaction.

To understand why that's even possible, you need one piece of context about how the EVM executes transactions. Every transaction on Ethereum is atomic. That word gets thrown around a lot, but what it actually means here is simple: a transaction either completes in full, with every state change it made committed to the chain, or it fails entirely, with every state change discarded as if the transaction never ran. There is no partial success. There is no "the first half went through but the second half didn't." If anything inside a transaction reverts, the whole thing reverts, backwards, completely, to exactly the state the chain was in before the transaction started. Token balances, contract storage, everything. Rolled back.

This is what makes flash loans possible. The repayment isn't enforced by a legal contract or a credit score or collateral sitting in escrow. It's enforced by the fact that the borrow and the repayment are steps inside the same transaction. If the repayment step fails or is missing, the transaction reverts, which means the borrow step also reverts, which means the tokens were never actually sent. The pool didn't take a risk and get lucky. It was never at risk. The EVM's all-or-nothing execution model is the collateral.

So if you don't return the tokens, the loan never happened. Not metaphorically. Literally. The entire transaction reverts, every state change unwinds, and the blockchain has no record of you having received anything. The pool loses nothing.

The natural reaction at this point is: okay, but what's the point? If I have to return the money by the end of the transaction, what can I actually do with it? I can't buy a house. I can't pay my rent. I can't even move it to another wallet and keep it there, because the transaction would revert the moment repayment fails. It sounds like borrowing a pen and having to return it before you finish writing the sentence.

That reaction makes complete sense. It's also missing the point entirely. To see why, we need a scenario.


The trade that's only profitable at scale

Say WBTC is trading at $65,000 on a Uniswap V2 pool on Ethereum. On SushiSwap, another DEX on Ethereum that is for all practical purposes Uniswap V2 with a different coat of paint and a sushi logo (SushiSwap famously launched in 2020 by literally forking the Uniswap V2 codebase, copying it token for token, and then running a "vampire attack" to drain Uniswap's liquidity into their own pools - an origin story that is either impressive or embarrassing depending on how you look at it, and definitely worth looking up once, but either way proved that the V2 architecture was solid enough to be worth stealing), the same WBTC is trading at $65,800.

A trader notices this. The same asset, two different prices, on two platforms that are both just AMMs running the constant product formula. If you could buy WBTC cheaply on Uniswap and sell it at the higher price on SushiSwap, you'd pocket the difference. The math is straightforward. The problem is scale. BTW if you didn't already figure out yet, this trade is whats referred to as arbitrage.

On a $1,000 position, the $800 price difference gives you a theoretical profit of roughly 1.2%, before gas, before swap fees on both sides, before the price impact of your own trade moving both pools. By the time you account for all of that, you're probably not making money. You might be losing it.

On a $500,000 position, the same 1.2% spread is $6,000. After fees and price impact, there might be $3,000 left. That's a real trade. But most traders don't have $500,000 sitting idle waiting for the right price discrepancy to show up. And even if they did, locking up half a million dollars in a single speculative trade that depends on the spread still being there when the transaction executes is not something most people are comfortable doing.

This is the problem flash loans solve. The trader doesn't need the capital. He just needs to be able to use it for the duration of a single transaction.

Here's what he actually does. He writes a contract that borrows $500,000 worth of WBTC from the Uniswap pool in a single call, uses those tokens to execute the trade on SushiSwap, receives the proceeds, repays the Uniswap pool the borrowed amount plus the 0.3% fee, and keeps the remainder. The entire sequence i.e. borrow, trade and repay happens atomically. If any step fails, including the repayment, everything reverts. The pool's only exposure is the duration of one transaction, which is to say it has no exposure at all.

The fee is the only real cost. And unlike traditional finance where borrowing half a million dollars involves credit checks, collateral, counterparty risk, and days of processing, this takes about 15 seconds and a smart contract.


The actual flow

Flash swaps use the exact same swap() function on the pair contract as regular swaps. There's no separate entry point, no special mode to enable. The difference is in the data parameter.

In a regular swap, data is empty. In a flash swap, data is non-empty — it's whatever payload you want to pass into your callback. The pair contract checks data.length > 0 and if true, triggers the callback. That's the entire branching condition.

So to initiate a flash swap, you call swap() directly on the pair contract (bypassing the Router entirely, since the Router doesn't support flash swaps and doesn't need to), passing the amount you want to borrow as either amount0Out or amount1Out, your contract's address as to, and any non-empty bytes as data.

pair.swap(amountBorrow, 0, address(this), abi.encode(data));
Enter fullscreen mode Exit fullscreen mode

The pair immediately and optimistically sends the requested tokens to your contract. If you read the swap post, this is the same optimistic transfer we already discussed — the pair sends first and verifies after. For flash swaps this isn't just a design quirk, it's a necessity. Your callback needs the tokens in hand before it can do anything with them. There's no other way to structure this.


The callback: uniswapV2Call()

After sending the tokens, the pair calls uniswapV2Call() on your contract. Your contract must implement this function. The interface is:

uniswapV2Call(address sender, uint amount0, uint amount1, bytes calldata data)
Enter fullscreen mode Exit fullscreen mode

sender is the address that initiated the swap. amount0 and amount1 are the amounts sent out by the pair. data is whatever you passed in.

Inside uniswapV2Call() is where you do your thing. Execute the trade on the other exchange. Liquidate the undercollateralised position. Swap the collateral type. Whatever the opportunity is, it happens here, in this callback, before the function returns.

Before it returns, your contract must transfer the repayment back to the pair. Not to the Router, not to any intermediary. Directly to the pair contract. The pair will read its own balance after the callback and use that to verify the invariant.


The repayment math

In the swap post, we derived getAmountOut() — given an input, what's the maximum output the pool can give while preserving k after the fee:

$amountOut = \frac{amountIn \times 997 \times reserveOut}{reserveIn \times 1000 + amountIn \times 997}$

For flash loan repayment, we need the inverse. We know the output (what was borrowed) and need to find the minimum input (what must be repaid). This is getAmountIn() from UniswapV2Library, and it's just the same equation solved for amountIn instead of amountOut.

Starting from the same invariant constraint after the fee adjustment:

$(reserveIn + amountIn \times \frac{997}{1000}) \times (reserveOut - amountOut) = reserveIn \times reserveOut$

Solving for amountIn:

$amountIn = \frac{reserveIn \times amountOut \times 1000}{(reserveOut - amountOut) \times 997}$

In integer arithmetic, there's a + 1 added to the numerator to round up rather than down. Rounding down would give a repayment that's one wei short of what the invariant requires, which would cause the check to fail. The + 1 ensures you always repay at least enough:

$amountIn = \frac{reserveIn \times amountOut \times 1000}{(reserveOut - amountOut) \times 997} + 1$

Your contract computes this before calling swap(), confirms the trade opportunity covers this cost, and budgets accordingly. If the trade doesn't cover the repayment plus whatever profit threshold makes it worth doing, you don't initiate the flash swap. Nothing happens. No cost, no exposure.


Back in swap(): the invariant check

After uniswapV2Call() returns, execution is back inside the pair's swap() function. This is the moment of truth, and it's worth pausing on the sequence one more time because it's still a little unintuitive even after the swap post.

The pair has sent tokens out. It called your callback. Your callback did things. Now the pair reads its current balances and checks the invariant:

$balance0Adjusted \times balance1Adjusted \geq reserve0 \times reserve1 \times 1000^2$

Where:

$balance0Adjusted = balance0 \times 1000 - amount0In \times 3$

$balance1Adjusted = balance1 \times 1000 - amount1In \times 3$

If your repayment is sitting in the contract, balance0 or balance1 (whichever token you're repaying in) will be higher than the reserve by at least the required amount. The invariant holds. The transaction succeeds.

If you didn't repay, or repaid less than required, the product falls short of the right-hand side. The transaction reverts. The pair's optimistic transfer from the beginning of swap() unwinds. You never had the tokens. The pool never lost anything.

This is why the send-first verify-later pattern is not a violation of checks-effects-interactions in the dangerous sense. There is no state in which the pool is permanently worse off. Either the invariant holds and everything is fine, or it doesn't and everything is undone. The EVM's atomicity is the enforcement mechanism. There's no in-between state that persists.

_update() syncs the reserves, the Swap event is emitted, and the function returns. Same ending as every other flow.

Uniswap V2 flash loan interaction sequence


One thing worth noting

You can repay a flash swap in either token, not just the one you borrowed. Borrow token0, repay in token1. As long as the invariant holds after the callback, the pool doesn't care. This opens up collateral swap patterns where you borrow one asset, use it to unwind a position in another, and repay in the proceeds. The math works out as long as the product of the adjusted balances clears the bar.


What's next

That's the four flows done. Mint, swap, burn (which we'll come back to — it's the shortest and most self-contained post and will tie up a few loose ends from the liquidity side), and now flash loans.

The final post in this series is on TWAP oracles. We've mentioned the price accumulators in _update() in both the Mint and Swap posts without fully explaining them. They're one of the most important and most underappreciated architectural decisions in Uniswap V2, and they ended up becoming the foundation for how a significant portion of DeFi gets reliable on-chain price data. It's worth doing properly.

Top comments (0)