Crypto OS
Technical Crypto OSStage 4 · DeFi Engineering

T20 · DEX Architecture

How many layers does one swap pass through?

Capabilities practiced
BuilderFinancial Literacy
Hands on
Implement a two-hop router and compare the final execution price of a direct swap with one via an intermediate asset.
AI Lab
Have AI design a routing algorithm. Test whether its paths are optimal using real pool data.

A Real-World Problem

The T19 pool works, so you connect it to a front end.

The interface reads both reserves, applies the quote formula, and displays “1 ETH is about 3,000 USDC.” The user confirms, and it calls swap with the calculated output amount.

Local tests are perfect. On the second day after launch, users report two problems:

  • Some transactions fail. They confirm in the wallet, sign, and then get a revert while still paying gas.
  • Worse, some succeed but receive a different amount from what the page displayed. The page said 3,000; the user received 2,890.

You inspect a transaction. The pool was not attacked, the contract has no bug, and the quote formula was correct. Where did those 110 USDC go?

The answer lies in a question you never considered carefully: how long passes between seeing a quote and the transaction actually executing onchain?

Usually tens of seconds or minutes. During that interval the reserves can change through ordinary trading, or because someone sees your transaction and acts first.

Your front end bundled quoting, routing, and execution together and put the most fragile one, the quote, offchain. This chapter separates the three.

Thought Experiment

Step away from blockchains. Picture an airport currency-exchange counter.

One exchange passes through three steps performed by different parties:

  1. The rate board at the entrance gives an approximate current rate. It refreshes periodically and clearly says “for reference only.”
  2. The clerk accepts your money and chooses a path: exchange directly, go through dollars first, or split the order across allowances.
  3. The vault counts out and hands over the actual money.

At the airport, all three happen in the same minute, so they barely feel separate.

Onchain, they happen at three distinct times:

t0   Your browser reads reserves, calculates a quote, and displays it (offchain, read-only)
t1   You sign; the transaction enters the mempool                  (offchain, publicly visible)
t2   A block producer includes and executes it                     (onchain, perhaps with very different state)

Anyone can change the pool between t0 and t2. More significantly, between t1 and t2 your transaction is public in the mempool. Anyone can see what you intend to buy and for how much, and can trade ahead of you.

Return to the airport. If the clerk promises only “the rate on the board” and someone changes the board on your way to the counter, what rate will you receive?

Your only reliable move is to say as you hand over the money: “If I receive less than this amount, do not exchange.”

That sentence is this chapter's most important parameter.

Your Decision

How do you change the swap function so users do not receive an amount different from the page's display?

What Happens

Three parameters prevent three different failures:

ParameterWhat it preventsWho must choose itIf omitted
Minimum outputPrice drift from frontrunning, sandwiches, or ordinary volatilityTransaction senderExecution at any price
DeadlineTime drift while a transaction remains pendingTransaction senderExecution hours later under stale intent
RecipientMisrouted assetsTransaction senderFunds stranded in an intermediary contract

The rightmost column points to one rule: all three parameters must be signed by the sender.

That is more important than any individual parameter. A signature is the user's last enforceable boundary. Once signed, the front end, backend, RPC node, Router, and block producer are outside the user's control at inclusion time.

Another counterintuitive point deserves its own statement:

Your slippage tolerance is the bounty you voluntarily post.

Allow 5% slippage and someone can extract profit from you up to that boundary; your trade still succeeds. You receive no error, the chain looks normal, and execution lands near the edge of your own tolerance.

ToleranceFailure rateMaximum extraction
0.1%High; modest volatility causes failure0.1%
0.5%Moderate0.5%
5%Low5%
50%Almost never fails50%

Some wallets choose a wide default to make trades succeed. Users see success and assume all is well. Slippage tolerance is a risk budget, not a usability switch.

Build a Model

Separate a swap into three layers, each with a different job, location, and cost:

  1. Quote
  2. Route
  3. Execute
The first two run offchain and can be expensive and complex; execution runs onchain and must be simple, deterministic, and verifiable.
LayerWhereWhat it doesCan it be trusted?
QuoteOffchainReads reserves across pools and estimates each path's resultNo; the data is already seconds old
RouteOffchainPicks a candidate path, perhaps splitting the orderNo; it is only a suggestion
ExecuteOnchainSwaps hop by hop on the given path and checks final outputYes; it performs arithmetic and checks

Remember: calculate the path offchain; verify the result onchain.

Do not enumerate every pool inside a contract to find the best path. Gas would be exorbitant. More importantly, the onchain reserves being read can themselves be manipulated. An attacker can first change a pool within the same transaction, enticing your onchain algorithm toward a path that favors the attacker.

Keep routing offchain and sign the path as part of the user's intent. Then there is only one indisputable onchain question: after following this path, how much did I actually receive?

A Router skeleton looks like this:

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

function swapExactTokensForTokens(
    uint256 amountIn,
    uint256 amountOutMin,     // Minimum, signed by the sender
    address[] calldata path,  // Path calculated offchain
    address to,               // Recipient chosen by the sender
    uint256 deadline          // Expiry chosen by the sender
) external returns (uint256 amountOut) {
    require(block.timestamp <= deadline, "EXPIRED");
    require(path.length >= 2, "BAD_PATH");

    _transferFrom(path[0], msg.sender, _pairFor(path[0], path[1]), amountIn);

    uint256 amount = amountIn;
    for (uint256 i = 0; i + 1 < path.length; i++) {
        amount = _swapOneHop(path[i], path[i + 1], amount, i + 2 == path.length ? to : _pairFor(path[i + 1], path[i + 2]));
    }

    amountOut = amount;
    require(amountOut >= amountOutMin, "INSUFFICIENT_OUTPUT");
}

Four details matter:

  1. Check deadline first. Do nothing further when the trade has expired.
  2. Send an intermediate hop to the next pool, not to the Router. This saves a transfer and avoids funds lingering in the Router.
  3. Check amountOutMin at the end. It constrains the final receipt, not any intermediate hop. A poor intermediate price is acceptable if the final output meets the bound.
  4. The Router holds no funds of its own. A well-designed Router should have a zero balance between transactions.

Why can two hops beat one? Spell out the tradeoff:

Direct swap: A -> C
  One pool with depth D1 bears the whole trade

Two hops:    A -> B -> C
  First hop depth D2, second hop depth D3; each has slippage, but both may be lower
  Cost: one more fee (about 0.6% total instead of 0.3%) and more gas

When two hops win:
  When A/C is shallow but A/B and B/C are deep
  Crossover: extra slippage on the direct route > additional 0.3% fee + gas

That is the routing algorithm's entire question. It weighs slippage against fees rather than finding the shortest path.

What It Is Called

PoolLiquidity pool

A contract holding two assets and pricing them according to a curve. It knows only its own two assets, not the wider market.

T19 built one. A Pool need not know that a Router exists.

RouterRouter contract

A contract that accepts a path, calls Pools hop by hop, and checks slippage at the end.

Three design rules: do not hold funds, do not make price judgments, and take every constraint from the caller. Break any one and the Router turns from a tool into a risk.

QuoteQuote

An expected result calculated offchain.

A quote is always in the past tense. It reflects reserves at the moment you read them, not at execution. Treating it as a promise causes every failure in this chapter.

Slippage ProtectionSlippage protection

A boundary on execution encoded in signed parameters.

The usual form is minimum output, paired with a deadline. Their value comes entirely from one fact: they are in the user's signature.

AggregatorAggregator

A system that compares prices across DEXs and splits an order.

It pushes routing to its limit: a trade may be divided across protocols. That expands the trust surface. An aggregator's execution contract may need to call arbitrary targets; if poorly constrained, this permission can compromise users' token allowances.

Slippage ToleranceSlippage tolerance

The maximum deviation a user accepts.

It determines minimum output. Treat it as a risk budget, not a usability setting: its value states how much someone else may take from this trade.

Hands-on

Hands onBuild a two-hop router and compare final execution against a direct swapSolidity and any contract test frameworkFree, on a local network only

Deploy three pools. Use your T19 contract and three test assets A, B, and C. Deliberately set different depths:

A/C  100,000 on each side       (shallow)
A/B  5,000,000 on each side     (deep)
B/C  5,000,000 on each side     (deep)

Write two Router functions: offchain getAmountsOut(amountIn, path), a read-only result for every hop; and onchain swapExactTokensForTokens for execution.

Mark getAmountsOut as view. It must share exactly the same quote formula as execution; one cannot be approximate while the other is exact.

Compare the paths. For inputs of 1,000, 10,000, and 100,000, run both direct A-to-C and two-hop A-to-B-to-C routes and fill in the table:

AmountDirect receiptTwo-hop receiptAdditional two-hop feeBetter path
1,000
10,000
100,000

Find the crossover point below which paying the extra 0.3% is not worthwhile. That is what your routing algorithm actually has to calculate.

Verify slippage protection works. Run three tests:

  1. On the normal path, set amountOutMin to 0.5% below the quote; the trade should succeed.
  2. Before execution, use a second address for a large trade that changes reserves; execution should revert with INSUFFICIENT_OUTPUT.
  3. Set deadline to a timestamp in the past; it should revert with EXPIRED.

The second is the key test. If it does not revert, your protection is in the wrong place. Check whether you compared the minimum against an intermediate hop rather than final output.

Calculate attack economics in reverse. This is the chapter's quantitative exercise.

Earlier chapters asked how much an attacker must spend. Here ask how much profit your settings leave available:

Known: your trade size V, slippage tolerance s, pool depth D on one side

1. The attacker first moves the price until your execution lands at the tolerance boundary
2. Required capital: invert the T19 formula, on the order of D * (sqrt(1+s) - 1)
3. Gross profit: about V * s
4. Costs: fees on two trades (about 0.6% of capital) + two gas payments + frontrun bid
5. Net profit = gross profit - costs; if positive, the attacker may act

For your pool, use V = 10,000 and s = 0.5%, then 5%, and work through all five lines.

Then ask: at this pool depth, what trade size becomes worth attacking? Below that threshold, an attacker will ignore the trade. This threshold tells you where the risk starts, more concretely than a generic defense.

T24 completes this calculation, including the attacker's code.

Work entirely on a local network without real funds. Real funds enter only the capstone after Stage 5 security acceptance.

AI Lab

AI LabAsk AI to design a routing algorithm; test its route against real pool data yourselfLevel 2 · AI Copilot

Ask in two steps. The second matters more:

Step one:
Design a DEX routing algorithm. Given reserves and fee rates for several
constant-product pools, plus input asset, output asset, and amount, find the
path with maximum final output. State your weight function and explain why
a shortest-path algorithm is insufficient.

Step two:
Give three situations where your algorithm chooses a suboptimal path.
For each, explain the pool layout that triggers it, the mistake, and its cost.
If an order may be split across routes, how must the algorithm change?
Below what trade size does the extra gas for splitting outweigh the benefit?

Step two forces the model to admit limits. The hard part of routing is not finding a path; slippage makes an edge's weight depend on the amount flowing through it. The same path can be optimal for 100 units and poor for a million. Many answers ignore this.

Check its answer against the three pools in your Lab. You already filled in the table, so you have a known correct answer for verification.

Verify these points yourself after using AI

  • Does it include fees in edge weights? Sorting only by depth is the most common error
  • Does it assume the shortest path is best? Fewer hops do not guarantee better execution
  • Give it the three pools from your Lab; does its path match the best path you calculated by hand?
  • What path-length limit does it use, and why can the algorithm not add hops indefinitely?
  • Does it say the path must be part of the user signature rather than chosen by a backend during execution?
  • Can you find the interfaces, function signatures, and parameter order it cites in your own contracts?

Real-World Case

A deadline that is effectively absentOngoing

Two common patterns nullify deadline: pass type(uint256).max, or have the front end use the current block timestamp plus a huge interval.

The first never expires. A transaction can sit in the mempool for months until someone finds it profitable to include under favorable market conditions.

The second can be subtler: if the contract computes deadline onchain from block.timestamp during execution, the check will always pass; the block.timestamp at execution cannot exceed itself.

The test is simple: was this number fixed before the user signed? If not, it offers no protection.

Overly wide default slippage toleranceAcross multiple wallets and front ends

To reduce failures, some front ends set a wide default. Users see “transaction succeeded” and do not realize execution happened at the edge of the tolerance.

Such losses rarely produce complaints because users do not know what amount to expect. The transaction is valid onchain and raises no business error.

A better approach ties tolerance to live price impact: if the trade's impact is 0.3%, perhaps allow 0.5%, rather than a fixed value for every size. Show that figure to the user, and foreground “minimum received” rather than “estimated received.”

The recipient parameter is wrongIntegration incidents

Some integrations set the Router's to parameter to the Router itself or an intermediary contract, intending to withdraw later.

The assets arrive, but if the address has no withdrawal path, whoever first finds a way to extract them may take them. Bots scan the chain for stranded balances.

A general rule: send assets only to an address you know can retrieve them. An intermediary contract should have a zero balance at transaction end.

An aggregator's arbitrary-call permissionMultiple incidents

An aggregator must interact with many protocols, so its execution contract often has the ability to make arbitrary calls to arbitrary addresses.

Users grant it token allowances to trade. If an attacker can induce the aggregator to call a token's transferFrom, balances of everyone who granted allowance can be drained.

Defenses include allowlists, argument validation, and allowances limited to each trade's actual need. The lesson is not simply that aggregators are unsafe: a token allowance remains a risk beyond the trade itself. Review and revoke unused approvals periodically.

Change One Variable

What ifA backend computes and supplies minimum output; the user signs only the input amount

The experience may improve: the backend can use fresher data, set a tighter minimum, and lower failure rates.

But the protection changes character. Once the minimum leaves the user's signature, it depends on the backend being honest, uncompromised, and bug-free. A signature draws the line after which nobody else can change the user's constraints. Moving a constraint outside that line effectively removes it.

If a backend must participate, let it suggest a value that the user signs, rather than setting it at execution.

What ifThe route length limit rises from three hops to eight

You might find a better theoretical price, but three things get worse.

Gas grows roughly linearly; every hop is a full contract call and token transfer. Failure becomes likelier; reserve changes in any pool can drag final output below the floor. Attack surface expands; trouble in any pool affects the whole trade.

Most systems cap routes near three or four hops. This is not a technical ceiling but diminishing returns: a fourth hop usually improves execution by less than its extra gas cost.

What ifSend the transaction straight to a block producer, bypassing the public mempool

The exposure between t1 and t2 disappears, along with public opportunities to frontrun or sandwich it.

You gain a new trust assumption: one relay sees the transaction and decides whether to include it. It can see the opportunity and, in principle, pass it to someone else.

Risk shifts from everyone seeing the trade to one party seeing it. T24 calculates the costs of this path in detail.

What ifA user's 0.1% tolerance causes frequent transaction failures

Failure costs gas, lets prices move further, and prompts users to retry.

Interestingly, the high failure rate itself can be exploited. An attacker who expects immediate retries can repeatedly create failure conditions, pressuring the user to widen tolerance.

Do not search for one “correct tolerance.” Vary it with trade size and pool depth, and recommend splitting once an order is large enough. When a single trade moves the price significantly, tolerance alone cannot solve the problem.

Takeaway Questions

1
What problem does it solve?

What problem does it solve? A Router is not just about exchanging assets; it safely realizes an offchain intention onchain. When evaluating a trading interface, first ask which constraints enter the user's signature.

4
Who is the user?

Who uses it? Two groups appear here: ordinary traders execute within a minimum-output bound, while arbitrageurs and bots seek trades whose bound is too wide. The same Router means very different things to those users.

5
Who is paying?

Who pays? A trader pays three costs: pool fees, gas, and slippage. The third is often largest and easiest to overlook because it appears on no invoice.

9
Who bears the risk?

Who bears the risk? The signer. A bad quote, weak route, or wide tolerance costs the sender and looks perfectly normal afterward onchain. That is why protective parameters belong in the signature.

Chapter Check

Key idea

Quoting, routing, and execution must be separate, or you cannot control slippage or frontrunning.

Finished this chapter’s lab? Mark it completeView all progress

On this page