This proposal defines an interface for reference-relative slippage protection on token swaps. Instead of committing to a static minAmountOut at signing time, the caller supplies a slippage policy, an ERC-7726 quote oracle and a maximum deviation, and the executing contract derives the acceptable output floor from the reference price read at execution time, reverting if the realized output deviates beyond tolerance.
By moving the slippage floor from a stale, sign-time constant to a live, execution-time bound, this shrinks the window a sandwich attacker can extract, and lets wallets and aggregators express slippage protection in a single interoperable way, reusing the existing ERC-7726 oracle API rather than inventing another price source.
Today a swap is protected by a single minAmountOut chosen when the transaction is built. This is the exact lever MEV extraction exploits:
minAmountOut is set against a quote from block N, but the swap executes at block N+k. A sandwich bot moves the pool price inside that gap; as long as realized output stays above the stale floor, the sandwich is profitable and the victim cannot tell.A reference-relative floor addresses the first two: the floor is computed at execution against a fresh reference, so it tracks real market conditions rather than a number already stale when signed. Standardizing the interface addresses the third. This is not a claim to eliminate MEV; it narrows the extractable band and makes slippage protection legible and composable.
The key words "MUST", "MUST NOT", "REQUIRED", "SHOULD", "SHOULD NOT", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.
quoteOracle MUST implement ERC-7726 (getQuote). Its quote is a mid price, with no fee or price impact.expectedCostBps is the known, non-adversarial discount from the mid: the pool fee plus the modeled price impact for this size. It separates expected execution cost from slippage protection, so a normal, honest fill is not mistaken for an attack. It MUST be <= 10_000.maxDeviationBps is the adverse-only shortfall tolerated beyond the expected output. It MUST be <= 10_000. Splitting the two is what keeps this narrower than ERC-5143's single band: folding fee, impact, and drift into one tolerance rebuilds the wide band this proposal exists to shrink.hardFloor is an absolute floor; the effective floor is max(referenceFloor, hardFloor).deadline is unix seconds past which the intent to trade expires. A zero deadline is unbounded, so a policy that sets none behaves as though the field were absent. It bounds a different staleness from the floor: the floor is recomputed at execution and does not go stale, but the caller's decision to trade at all does.getQuote is stateless and the standard makes no freshness guarantee, an implementation MUST use an oracle that enforces a freshness bound and reverts when it cannot give a reliable quote, and MUST NOT treat a quote whose freshness cannot be established as valid.quoteOracle MUST NOT be a spot price read from the pool the swap executes against, because an attacker who moves that pool moves the floor with it and the bound becomes self-referential.An executor implementing ISlippageBoundedSwap:
InvalidPolicy if policy.expectedCostBps > 10_000 or policy.maxDeviationBps > 10_000, and MUST revert InvalidRecipient if recipient is the zero address.DeadlineExpired(policy.deadline, block.timestamp) if policy.deadline != 0 and block.timestamp > policy.deadline. This check MUST happen before the reference is read and before the route runs, so rejecting an expired intent does not depend on an oracle read succeeding. A policy.deadline of zero imposes no bound.IERC7726(policy.quoteOracle).getQuote(amountIn, tokenIn, tokenOut). It MUST NOT accept a reference output supplied by the caller, and MUST use an oracle that enforces freshness (see Slippage policy).expectedOut = referenceOut * (10_000 - policy.expectedCostBps) / 10_000, then floor = max(expectedOut * (10_000 - policy.maxDeviationBps) / 10_000, policy.hardFloor).amountOut as recipient's tokenOut balance increase across the call. It MUST NOT use a value the route reports. routeData is an opaque execution hint and MUST NOT influence the token pair, the recipient, or the measured amountOut.SlippageExceeded(amountOut, floor) if amountOut < floor.Implementers MUST support ERC-165 and MUST return true from supportsInterface for the ISlippageBoundedSwap interface id 0x41b46b60.
Why reference-relative instead of a static minimum? A static minAmountOut encodes the market as of signing; the attacker operates in the delta to execution. Recomputing the floor against a fresh reference collapses that delta into whatever the oracle's freshness and manipulation cost allow.
Why reuse ERC-7726? A quote oracle is exactly ERC-7726's remit (getQuote returns an explicit token amount for a (base, quote) pair), and it already has adapters across venues. Defining another oracle interface here would fragment the ecosystem and duplicate a standard; this proposal fixes only the slippage contract on top of it.
Why two fields (expectedCostBps and maxDeviationBps) instead of one tolerance? The ERC-7726 reference is a mid price, so a real fill is always below it by the pool fee plus price impact before any attack. A single tolerance would have to absorb that expected cost, which pushes it back above 100 basis points and rebuilds the wide extractable band the Motivation criticizes. Separating the known cost (expectedCostBps) from the adverse-only tolerance (maxDeviationBps) lets the guard subtract what execution honestly costs and then police only the adversarial remainder, which is the difference that makes this narrower than a static single band.
Why a shortfall tolerance rather than the caller passing a floor? So protection scales with size and live price automatically, and wallets can express one policy ("expect 0.3 percent cost, never more than 0.5 percent adverse below that") rather than recomputing a number per trade.
Why measure the output on-chain rather than trust the route? routeData is an opaque call to an arbitrary venue. If the guard trusted a number the route returned, the route could report a passing amount it never paid. Measuring recipient's tokenOut balance delta makes the floor check independent of what the route claims, so the security property does not depend on the honesty of the route.
Why measure at the recipient rather than the executor? The bound is a statement about what the trade delivered, and the executor is only the caller. It may forward the output, take a fee, or sit in the path, so an executor that keeps what the route paid would satisfy a floor checked against its own balance while the account the swap settles to received nothing. Naming the recipient makes the guarantee land on the account it is about. Passing the executor's own address is still allowed and reproduces the simpler case.
Why a deadline as well as a live reference? This proposal exists because a number computed at signing time goes stale, and its answer is to carry the policy and derive the number at execution. A deadline is the other half of that same problem. The policy does not go stale, but the decision to trade does. Because a reference-relative bound is immune to price drift by construction, a caller who decided to swap yesterday gets today's price with the same bps guarantee, and no reference-relative bound can protect against that. That is the honest trade this proposal makes, and a deadline is what covers it.
This is a different property from a stale quote, and the two need separate mechanisms. The floor already fails closed when the oracle cannot produce a fresh quote, because the oracle reverts and the executor bubbles it. That covers a stale reference. It says nothing about a stale intent, because the reference the guard reads is fresh in exactly the case the caller's decision is old.
Why keep hardFloor? Oracles fail. hardFloor guarantees a worst case the caller pre-accepts even if the reference is unavailable within tolerance.
Relationship to ERC-5143. ERC-5143 defines slippage-protected variants of the ERC-4626 vault entrypoints (deposit, mint, withdraw, redeem with a caller-supplied bound). It is scoped to tokenized vaults and to a static, caller-supplied minimum. This proposal is scoped to general swaps and derives the bound from a live ERC-7726 reference rather than a static input. They are complementary.
Additive. Routers that do not implement ISlippageBoundedSwap are unaffected, and callers can keep using static-minAmountOut entrypoints. A router can implement both.
All cases use amountIn = 1000 and a mid reference from the oracle. expectedOut = referenceOut * (10_000 - expectedCostBps) / 10_000, floor = max(expectedOut * (10_000 - maxDeviationBps) / 10_000, hardFloor). Integer division truncates.
| # | referenceOut | expectedCostBps | maxDeviationBps | hardFloor | floor | realized amountOut | Expected result |
|---|---|---|---|---|---|---|---|
| 1 | 1000 | 0 | 100 | 0 | 990 | 995 | returns 995 |
| 2 | 1000 | 0 | 100 | 0 | 990 | 989 | reverts SlippageExceeded(989, 990) |
| 3 | 1000 | 200 | 100 | 0 | 970 | 970 | returns 970 |
| 4 | 1000 | 200 | 100 | 0 | 970 | 969 | reverts SlippageExceeded(969, 970) |
| 5 | 1000 | 0 | 100 | 996 | 996 | 995 | reverts SlippageExceeded(995, 996) |
| 6 | 2000 | 0 | 100 | 0 | 1980 | 1979 | reverts SlippageExceeded(1979, 1980) |
| 7 | 1000 | 30 | 50 | 0 | 992 | 991 | reverts SlippageExceeded(991, 992) |
| 8 | 1000 | 30 | 50 | 0 | 992 | 992 | returns 992 |
| 9 | 1000 | 0 | 100 | 0 | 990 | 0 | reverts SlippageExceeded(0, 990) |
| 10 | 1000 | 0 | 100 | 0 | 990 | 0 to the recipient, 1000 kept by the executor | reverts SlippageExceeded(0, 990) |
Cases 3 and 4 show the two fields stacking rather than collapsing: a 2% known cost yields expectedOut = 980, and the 1% adverse tolerance applies to that, not to the mid. Case 5 shows hardFloor taking over when it is higher than the reference floor. Case 6 changes only the oracle rate, so a floor that moves with it proves the reference is read at execution rather than supplied by the caller. Cases 7 and 8 are a sandwich either side of the boundary: the reference stays a fresh mid at 1000 while the fill is pushed to 991, one unit below the floor. Case 9 is a route that delivers nothing, which the guard catches because it measures a balance delta rather than trusting a route-reported amount. Case 10 is the same rejection for a route that did pay in full but paid the executor instead of the recipient, which is why the measurement is taken at the recipient.
Two policy cases are independent of the floor arithmetic:
| Input | Expected result |
|---|---|
expectedCostBps = 10_001, maxDeviationBps = 100 | reverts InvalidPolicy(10001, 100) |
expectedCostBps = 0, maxDeviationBps = 10_001 | reverts InvalidPolicy(0, 10001) |
recipient = address(0) | reverts InvalidRecipient() |
Three deadline cases, all with referenceOut = 1000, expectedCostBps = 0, maxDeviationBps = 100, hardFloor = 0 and a realized output of 995, which is above the floor of 990 and so settles unless the deadline rejects first:
deadline | block.timestamp | Expected result |
|---|---|---|
999_999 | 1_000_000 | reverts DeadlineExpired(999999, 1000000) |
1_000_000 | 1_000_000 | returns 995; the deadline is the last second that still settles |
0 | 4_000_000_000 | returns 995; a zero deadline imposes no bound |
The first case also holds with an oracle that cannot quote: DeadlineExpired is what surfaces, because the intent is checked before the reference is read.
An oracle that cannot produce a fresh quote reverts, and the executor bubbles that revert rather than falling back to an unbounded swap.
These cases are executable as SlippageBoundedSwap.t.sol. ForkSlippageBounded.t.sol additionally derives the floor from a live Chainlink ETH/USD reference through an ERC-7726 adapter and settles a real USDC balance delta.
SlippageBoundedSwap.sol - SlippagePolicy, the ISlippageBoundedSwap interface and its errors, and an abstract base implementing the floor logic, with route execution left as the internal _route hook so any router can inherit the guard.ChainlinkQuoteOracle.sol - an ERC-7726 adapter over a Chainlink feed that enforces the freshness bound.MockQuoteOracle.sol - the oracle used by the unit tests.getQuote is stateless and the standard makes no freshness guarantee, so this proposal does not lean on an unstated assumption: the Specification requires an oracle that enforces a freshness bound and reverts when it cannot give a reliable quote. The guarantee is a property of the deployed oracle instance the policy points at, not of the interface: an oracle that is conforming by interface but configured with no staleness bound silently opts out of that requirement, so callers need to verify that the specific oracle instance enforces the freshness the trade needs. Callers are advised to select an oracle appropriate to the trade, for example a TWAP window sized so moving it costs more than the sandwich it would enable. The Specification forbids using a spot price from the pool being traded as the reference.recipient's tokenOut balance delta across the route, so a malicious or buggy routeData cannot pass the floor with an output it did not deliver. The Specification forbids re-introducing a route-reported amount into the floor check.maxDeviationBps. It composes with private mempools and PBS-level protections rather than replacing them.hardFloor path; callers set hardFloor as the accepted worst case.maxDeviationBps for the venue's normal basis.Copyright and related rights waived via CC0.