ERC-8377 - Reference-Relative Slippage Bounds

Created 2026-08-05
Status Draft
Category ERC
Type Standards Track
Authors
Requires

Abstract

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.

Motivation

Today a swap is protected by a single minAmountOut chosen when the transaction is built. This is the exact lever MEV extraction exploits:

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.

Specification

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.

Slippage policy

struct SlippagePolicy {
    address quoteOracle;      // an ERC-7726 oracle for (tokenIn, tokenOut); MUST enforce freshness
    uint32  expectedCostBps;  // known non-adversarial cost vs the mid reference (fee + impact)
    uint32  maxDeviationBps;  // adverse-only shortfall tolerance beyond the expected output
    uint256 hardFloor;        // absolute minimum output accepted regardless of the reference
    uint256 deadline;         // unix seconds past which the intent expires; 0 means unbounded
}

Guarded swap interface

interface ISlippageBoundedSwap {
    error SlippageExceeded(uint256 realizedOut, uint256 floor);
    error InvalidPolicy(uint32 expectedCostBps, uint32 maxDeviationBps);
    error InvalidRecipient();
    error DeadlineExpired(uint256 deadline, uint256 timestamp);

    /// @dev MUST revert DeadlineExpired before reading the reference or running the
    ///      route when deadline != 0 && block.timestamp > deadline, then
    ///      read the reference at execution via ERC-7726 getQuote, compute
    ///      referenceOut = getQuote(amountIn, tokenIn, tokenOut),
    ///      expectedOut = referenceOut * (10_000 - expectedCostBps) / 10_000,
    ///      floor = max(expectedOut * (10_000 - maxDeviationBps) / 10_000, hardFloor),
    ///      measure realizedOut as the recipient's tokenOut balance delta, and revert
    ///      SlippageExceeded if realizedOut < floor.
    function swapWithPolicy(
        address tokenIn,
        address tokenOut,
        uint256 amountIn,
        address recipient,
        SlippagePolicy calldata policy,
        bytes calldata routeData
    ) external returns (uint256 amountOut);
}

An executor implementing ISlippageBoundedSwap:

  1. MUST revert InvalidPolicy if policy.expectedCostBps > 10_000 or policy.maxDeviationBps > 10_000, and MUST revert InvalidRecipient if recipient is the zero address.
  2. MUST revert 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.
  3. MUST obtain the reference at execution time by calling 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).
  4. MUST compute expectedOut = referenceOut * (10_000 - policy.expectedCostBps) / 10_000, then floor = max(expectedOut * (10_000 - policy.maxDeviationBps) / 10_000, policy.hardFloor).
  5. MUST execute the route and measure the realized 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.
  6. MUST revert SlippageExceeded(amountOut, floor) if amountOut < floor.

Interface detection

Implementers MUST support ERC-165 and MUST return true from supportsInterface for the ISlippageBoundedSwap interface id 0x41b46b60.

Rationale

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.

Backwards Compatibility

Additive. Routers that do not implement ISlippageBoundedSwap are unaffected, and callers can keep using static-minAmountOut entrypoints. A router can implement both.

Test Cases

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.

Reference Implementation

Security Considerations

Copyright

Copyright and related rights waived via CC0.