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:
- The rate board at the entrance gives an approximate current rate. It refreshes periodically and clearly says “for reference only.”
- The clerk accepts your money and chooses a path: exchange directly, go through dollars first, or split the order across allowances.
- 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:
| Parameter | What it prevents | Who must choose it | If omitted |
|---|---|---|---|
| Minimum output | Price drift from frontrunning, sandwiches, or ordinary volatility | Transaction sender | Execution at any price |
| Deadline | Time drift while a transaction remains pending | Transaction sender | Execution hours later under stale intent |
| Recipient | Misrouted assets | Transaction sender | Funds 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.
| Tolerance | Failure rate | Maximum extraction |
|---|---|---|
| 0.1% | High; modest volatility causes failure | 0.1% |
| 0.5% | Moderate | 0.5% |
| 5% | Low | 5% |
| 50% | Almost never fails | 50% |
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:
- Quote
- Route
- Execute
| Layer | Where | What it does | Can it be trusted? |
|---|---|---|---|
| Quote | Offchain | Reads reserves across pools and estimates each path's result | No; the data is already seconds old |
| Route | Offchain | Picks a candidate path, perhaps splitting the order | No; it is only a suggestion |
| Execute | Onchain | Swaps hop by hop on the given path and checks final output | Yes; 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:
- Check
deadlinefirst. Do nothing further when the trade has expired. - Send an intermediate hop to the next pool, not to the Router. This saves a transfer and avoids funds lingering in the Router.
- Check
amountOutMinat the end. It constrains the final receipt, not any intermediate hop. A poor intermediate price is acceptable if the final output meets the bound. - 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 + gasThat is the routing algorithm's entire question. It weighs slippage against fees rather than finding the shortest path.
What It Is Called
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.
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.
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.
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.
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.
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
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:
| Amount | Direct receipt | Two-hop receipt | Additional two-hop fee | Better 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:
- On the normal path, set
amountOutMinto 0.5% below the quote; the trade should succeed. - Before execution, use a second address for a large trade that changes reserves; execution should revert with
INSUFFICIENT_OUTPUT. - Set
deadlineto a timestamp in the past; it should revert withEXPIRED.
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 actFor 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
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
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.
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.”
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 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
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.
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.
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.
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
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.
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.
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.
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
Signing is the last point at which a user can impose constraints. After that, the front end, backend, RPC, Router, and block producer are outside the user's control.
If the Router calculates a minimum during execution, it uses reserves at that moment, which may be exactly the state an attacker manipulated. A protective bound matters only if it was fixed before the attacker acts.
The same reasoning applies to deadline and recipient.
No. They address independent dimensions.
Slippage protection limits the amount received at any execution time. It says nothing about time: a three-hour-old trade can execute if today's price still happens to satisfy the bound, even though the user's intent is stale.
A deadline limits execution time. It says nothing about the amount: within the window, a trade without a price floor accepts any price.
Both must be signed to close both gaps.
Two reasons; the second is more important.
First, cost: reading every reserve requires external calls and enumerating combinations can use more gas than any route improvement saves.
Second, manipulability: an attacker can change the reserves seen by an onchain algorithm earlier in the same transaction, creating an apparently optimal path that benefits the attacker.
Signing a fixed path removes that judgment from execution. The contract only verifies the result. Do judgments offchain when possible, but never omit checks that must run onchain.
Fees are linear while slippage is nonlinear.
The fee is a fixed percentage of input. Two hops pay two 0.3% fees. Slippage grows rapidly with trade size relative to pool depth; a large swap against a shallow pool can incur several percent.
The crossover is where extra direct-route slippage exceeds the second fee plus gas. A larger trade or shallower direct pool favors two hops; on a tiny trade the extra gas may erase the benefit.
There is no single answer. Check these points:
- Is tolerance derived from live price impact rather than fixed for all trades?
- Does the interface emphasize “minimum received” or “estimated received”? Those create different expectations.
- Do large trades trigger a warning or a suggestion to split the order?
- Does retry logic automatically widen tolerance after failure? That behavior can be exploited repeatedly.
- Can you state the maximum amount a typical trade could lose under your current settings?
If you cannot answer point 5, the earlier choices need more thought.
Key idea
Quoting, routing, and execution must be separate, or you cannot control slippage or frontrunning.