MDX Limo
ERC-505: Native Liquidity Tokens

ERC-505: Native Liquidity Tokens

Abstract

ERC-505 extends ERC-20 with a native constant-product automated market maker (AMM) and an on-chain limit order book, both denominated in the chain's native currency (ETH, etc.). Because the exchange lives inside the token contract itself, buying requires only a payable call with no prior approval, and selling requires only an internal balance transfer—no external router, no allowance, no separate pool contract.

A compliant token is intended to be used via simple inheritance:

1contract MyToken is ERC505 { 2 constructor() ERC505("My Token", "MTK", 1_000_000e18) {} 3}

The standard eliminates the approval→swap two-step, removes dependence on external DEX infrastructure, and provides every token with a canonical on-chain exchange from the moment liquidity is deployed.

Motivation

The approval problem

The ERC-20 approve + transferFrom pattern is the dominant way tokens interact with DeFi. Every approval is an attack surface: unlimited approvals have led to hundreds of millions of dollars lost through approval-based exploits, phishing attacks, and malicious contract upgrades. Users are conditioned to accept this risk as a cost of participation.

A token that can be traded without ever granting a spending allowance to a third party eliminates this entire class of vulnerability.

The external liquidity problem

Today, a newly deployed ERC-20 is inert until someone pairs it on an external DEX and seeds liquidity. This creates a dependency chain: the token depends on a router, the router depends on a factory, the factory depends on a pair contract, and the pair contract depends on approval of both tokens. Every link is a trust assumption and a potential point of failure.

A token that carries its own exchange needs no external infrastructure.

The fragmented liquidity problem

When a token is listed across multiple DEXes, liquidity fragments. Arbitrageurs profit from the inefficiency, extracting value from holders. A single canonical exchange embedded in the token contract consolidates all liquidity in one place, producing tighter spreads and better execution for all participants.

The limit order gap

Constant-product AMMs offer infinite liquidity along a curve but provide no mechanism for price-specific orders. Users wanting to buy or sell at a target price must rely on external protocols, centralized services, or manual monitoring. Embedding a limit order book alongside the AMM gives users native price-targeted execution with hybrid routing that fills limit orders first and routes the remainder through the AMM.

Specification

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.

Definitions

  • Native currency: The chain's base denomination (ETH on Ethereum, etc.).
  • Sell-side reserve (reserveSellSide): The quantity of tokens held by the AMM.
  • Buy-side reserve (reserveBuySide): The quantity of native currency held by the AMM.
  • Limit order: A resting order offering a fixed quantity of one asset in exchange for a desired quantity of the other.

Interface

Every compliant contract MUST implement ERC-20 (IERC20) and MUST additionally implement the following interface:

1// SPDX-License-Identifier: CC0-1.0 2pragma solidity >=0.8.0; 3 4interface IERC505 /* is IERC20 */ { 5 6 // ───────────────────────── Structs ───────────────────────── 7 8 /// @notice A resting order in the on-chain order book. 9 struct LimitOrder { 10 address maker; // Creator of the order 11 bool isBuy; // true = offering native for tokens 12 uint256 offerAmount; // Amount offered (native for buys, tokens for sells) 13 uint256 desiredAmount; // Amount desired (tokens for buys, native for sells) 14 bool isActive; // Whether the order can still be filled 15 } 16 17 /// @notice Instruction to partially or fully fill a resting order. 18 struct LimitOrderFill { 19 uint256 orderId; // ID of the order to fill 20 uint256 fillAmount; // Amount to fill from this order 21 } 22 23 // ───────────────────────── Errors ────────────────────────── 24 25 error InsufficientLiquidity(); 26 error InsufficientValue(); 27 error LiquidityNotDeployed(); 28 error LiquidityAlreadyDeployed(); 29 error InvalidAmount(); 30 error LessThanMinimum(); 31 error TransferFailed(); 32 error OrderDoesNotExist(); 33 error OrderNotActive(); 34 error NotOrderMaker(); 35 error InvalidFillAmount(); 36 error BadRatio(); 37 error TooManyOrderFills(); 38 39 // ───────────────────────── Events ────────────────────────── 40 41 /// @notice Emitted when liquidity is deployed, activating the AMM and order book. 42 event LiquidityDeployed( 43 uint256 reserveSellSide, 44 uint256 reserveBuySide 45 ); 46 47 /// @notice Emitted after every AMM swap. 48 event Swap( 49 address indexed user, 50 bool indexed isBuy, 51 uint256 amountIn, 52 uint256 amountOut, 53 uint256 fee, 54 uint256 newReserveSellSide, 55 uint256 newReserveBuySide 56 ); 57 58 /// @notice Emitted when a limit order is created. 59 event LimitOrderPlaced( 60 uint256 indexed orderId, 61 address indexed maker, 62 bool indexed isBuy, 63 uint256 offerAmount, 64 uint256 desiredAmount 65 ); 66 67 /// @notice Emitted when a limit order is (partially or fully) filled. 68 event LimitOrderFilled( 69 uint256 indexed orderId, 70 address indexed filler, 71 address indexed maker, 72 uint256 amountFilled, 73 uint256 remainingOffer, 74 uint256 remainingDesired, 75 bool orderCompleted 76 ); 77 78 /// @notice Emitted when a limit order is cancelled by its maker. 79 event LimitOrderCancelled( 80 uint256 indexed orderId, 81 address indexed maker, 82 uint256 refundedAmount, 83 bool wasBuyOrder 84 ); 85 86 /// @notice Emitted when a fill is skipped during combo execution. 87 event OrderSkipped(uint256 indexed orderId, string reason); 88 89 // ──────────────── Liquidity Deployment ───────────────────── 90 91 /// @notice Deploy the AMM liquidity pool. 92 /// @dev Sets `reserveSellSide` to the contract's entire token balance and 93 /// `reserveBuySide` to the contract's entire native currency balance. 94 /// MUST revert if either balance is zero. 95 /// MUST be callable at most once. 96 /// After this call, all trading functions become operational. 97 function deployLiquidity() external; 98 99 // ─────────────────── AMM View Functions ──────────────────── 100 101 /// @notice Token reserve in the AMM. 102 function reserveSellSide() external view returns (uint256); 103 104 /// @notice Native currency reserve in the AMM. 105 function reserveBuySide() external view returns (uint256); 106 107 /// @notice Preview the output amount for a given swap direction and input. 108 /// @param isBuy true = native→token, false = token→native 109 /// @param amountIn Input amount 110 /// @return amountOut Tokens (if buying) or native currency (if selling) 111 function getSwapAmount(bool isBuy, uint256 amountIn) 112 external view returns (uint256 amountOut); 113 114 // ─────────────────── Trading Functions ───────────────────── 115 116 /// @notice Buy tokens with native currency. 117 /// @dev MUST accept native currency via `msg.value`. 118 /// If `limitOrderFills` is non-empty, matching sell-side limit orders 119 /// MUST be filled first at their stated prices; any remaining native 120 /// currency MUST be swapped through the AMM. 121 /// @param minAmountOut Minimum tokens to receive (slippage protection) 122 /// @param limitOrderFills Orders to fill before the AMM (may be empty) 123 function buy( 124 uint256 minAmountOut, 125 LimitOrderFill[] calldata limitOrderFills 126 ) external payable; 127 128 /// @notice Sell tokens for native currency. 129 /// @dev Tokens MUST be pulled from `msg.sender` via an internal transfer 130 /// (no prior approval to an external contract required). 131 /// If `limitOrderFills` is non-empty, matching buy-side limit orders 132 /// MUST be filled first; any remaining tokens MUST be swapped through 133 /// the AMM. 134 /// @param amountIn Tokens to sell 135 /// @param minAmountOut Minimum native currency to receive 136 /// @param limitOrderFills Orders to fill before the AMM (may be empty) 137 function sell( 138 uint256 amountIn, 139 uint256 minAmountOut, 140 LimitOrderFill[] calldata limitOrderFills 141 ) external; 142 143 // ─────────────── Limit Order Functions ───────────────────── 144 145 /// @notice Place a limit buy order (offer native currency for tokens). 146 /// @dev MUST revert with `BadRatio` if the requested price is better than 147 /// the current AMM spot price (limit orders rest *behind* the curve). 148 /// @param desiredAmount Tokens the maker wants to receive 149 /// @return orderId Unique order identifier 150 function limitBuy(uint256 desiredAmount) 151 external payable returns (uint256 orderId); 152 153 /// @notice Place a limit sell order (offer tokens for native currency). 154 /// @dev Tokens MUST be transferred into the contract upon placement. 155 /// MUST revert with `BadRatio` if the requested price is better than 156 /// the current AMM spot price. 157 /// @param offerAmount Tokens the maker is selling 158 /// @param desiredAmount Native currency the maker wants to receive 159 /// @return orderId Unique order identifier 160 function limitSell(uint256 offerAmount, uint256 desiredAmount) 161 external returns (uint256 orderId); 162 163 /// @notice Cancel an active limit order and reclaim escrowed assets. 164 /// @dev MUST revert if caller is not the order maker. 165 /// @param orderId The order to cancel 166 function cancelLimitOrder(uint256 orderId) external; 167 168 // ─────────────── Order Book View Functions ───────────────── 169 170 /// @notice Read a limit order by ID. 171 function limitOrders(uint256 orderId) 172 external view returns ( 173 address maker, 174 bool isBuy, 175 uint256 offerAmount, 176 uint256 desiredAmount, 177 bool isActive 178 ); 179}

AMM Specification

  1. Formula. The AMM MUST use the constant-product invariant: the product of reserves after a swap (minus fees) MUST be greater than or equal to the product before the swap.

  2. Fee. Implementations SHOULD charge a swap fee. The fee MUST be applied to the input amount before computing the output. The RECOMMENDED fee is 0.3% (computed as amountIn / 333).

  3. Reserve tracking. The contract MUST maintain reserveSellSide (token reserve) and reserveBuySide (native currency reserve) as public state variables.

  4. Output calculation. For a given input amountIn against reserves (reserveIn, reserveOut):

1amountInAfterFee = amountIn − fee(amountIn) 2amountOut = (reserveOut × amountInAfterFee) / (reserveIn + amountInAfterFee)

Limit Order Specification

  1. Placement. Limit orders MUST rest at prices behind the current AMM spot price (i.e., worse for the maker than an immediate AMM swap). This ensures limit orders represent resting liquidity rather than instant arbitrage.

  2. Partial fills. Limit orders MUST support proportional partial fills. When a fill cannot be completed in full, the filled portion MUST be computed proportionally and the order's offerAmount and desiredAmount MUST be decremented accordingly.

  3. Fill-then-swap routing. When limitOrderFills is provided, the contract MUST attempt to fill each specified order sequentially. Any remaining input after all fills MUST be routed through the AMM. Orders that are inactive or of the wrong direction MUST be silently skipped (emitting OrderSkipped).

  4. Cancellation. Only the maker of an order MAY cancel it. Cancellation MUST refund the full remaining offerAmount.

  5. Maximum fills. Implementations SHOULD impose a per-transaction limit on the number of orders that can be filled (RECOMMENDED: 50) to bound gas costs.

Liquidity Deployment

The deployLiquidity() function activates the AMM and order book. Its behavior is intentionally simple: use whatever the contract currently holds.

  1. Precondition. The contract MUST hold a non-zero balance of both its own token and native currency at the time deployLiquidity() is called. If either balance is zero, the call MUST revert.

  2. Reserve assignment. deployLiquidity() MUST set reserveSellSide to the contract's entire token balance and reserveBuySide to the contract's entire native currency balance. The initial spot price is determined by the ratio of these two values:

1initialPrice = reserveBuySide / reserveSellSide (native currency per token)
  1. One-time. deployLiquidity() MUST be callable at most once. Subsequent calls MUST revert with LiquidityAlreadyDeployed. After successful deployment, buy(), sell(), limitBuy(), and limitSell() become operational. Before deployment, these functions MUST revert with LiquidityNotDeployed.

  2. Event. deployLiquidity() MUST emit the LiquidityDeployed event with the initial reserve values.

  3. Seeding. The standard is deliberately unopinionated about how the contract acquires its initial token and native currency balances. Any strategy is valid, including but not limited to:

    • Direct funding: The deployer mints tokens to the contract and sends native currency, then calls deployLiquidity().
    • Constructor seeding: The constructor mints tokens to address(this) and the deployer sends native currency along with deployment, then deployLiquidity() is called in a follow-up transaction or by the constructor itself.
    • Crowdfunded bootstrapping: A deposit phase collects native currency from participants, mints tokens proportionally, then someone calls deployLiquidity() to activate trading.
    • Programmatic seeding: A factory contract mints tokens and forwards native currency in a single atomic transaction, then calls deployLiquidity().

    The only invariant is: the contract holds tokens and native currency, and the ratio at the moment deployLiquidity() is called defines the opening price.

  4. Access control. The standard does not mandate who may call deployLiquidity(). Implementations MAY restrict it to the contract owner, allow any caller, require a timelock, or use any other access control mechanism appropriate to the token's launch strategy.

Rationale

Why embed the exchange in the token?

The fundamental insight is that an ERC-20 token contract already holds all the state needed for an exchange: balances, total supply, and transfer logic. Adding reserve variables and swap math to the same contract eliminates cross-contract calls, approval requirements, and the entire class of vulnerabilities that arise from token↔exchange interactions.

Why native currency only?

Trading against the chain's native currency (rather than arbitrary ERC-20 pairs) enables the simplest possible UX: send ETH, receive tokens. It also eliminates the need for wrapped native tokens and the associated approval/deposit flows. For tokens that primarily need a way to bootstrap and maintain liquidity, a single native-currency pair covers the dominant use case.

Why constant-product?

The constant-product formula is the simplest AMM design that provides continuous liquidity across all prices. More complex curves (concentrated liquidity, stableswap, etc.) are not precluded by this standard—they are simply not required. Implementations MAY substitute alternative AMM formulas provided they satisfy the same interface.

Why a limit order book alongside the AMM?

AMMs provide passive, always-on liquidity but offer no mechanism for price-targeted orders. A complementary order book lets users express precise price preferences. The hybrid fill-then-swap routing gives takers the best of both worlds: specific-price fills where available, continuous AMM liquidity for the remainder.

Why must limit orders rest behind the curve?

If a limit order offered a better price than the AMM, it would be immediately arbitraged. Requiring limit orders to rest behind the spot price ensures they represent genuine resting liquidity that will be filled when the market moves to that level.

Why is deployLiquidity() so simple?

The function does exactly one thing: snapshot the contract's current balances as AMM reserves. This keeps the standard minimal and composable. Any pre-deployment logic—public sales, airdrops, vesting, migration from another DEX—happens before the call and is entirely outside the standard's scope. The only invariant that matters is: the contract holds tokens and native currency, and the ratio defines the opening price. Everything upstream of that moment is application-specific.

Backwards Compatibility

ERC-505 tokens are fully ERC-20 compliant. All standard transfer, transferFrom, approve, balanceOf, and allowance functions behave identically to any ERC-20 token. Existing wallets, block explorers, and indexers will recognize ERC-505 tokens as standard ERC-20 tokens without modification.

The additional trading interface (buy, sell, limitBuy, limitSell, cancelLimitOrder, deployLiquidity) extends rather than modifies the ERC-20 surface area. Contracts and applications that interact only with ERC-20 methods will function correctly. The approval-free trading path is additive—traditional approve + transferFrom workflows remain available for interoperability with existing DeFi infrastructure.

Reference Implementation

A conforming ERC-505 implementation requires:

  • An ERC-20 token with buy() and sell() functions per this specification
  • A deployLiquidity() function that snapshots balances into AMM reserves
  • A constant-product AMM with reserve tracking
  • A limit order book with placement, fill, and cancellation logic
  • Hybrid fill-then-swap routing

A typical usage pattern:

1import {ERC505} from "erc505/ERC505.sol"; 2 3contract MyToken is ERC505 { 4 constructor() ERC505("My Token", "MTK", 1_000_000e18) { 5 // Tokens are minted to address(this) by the ERC505 constructor. 6 // Deployer sends ETH with this transaction. 7 // Call deployLiquidity() once ETH is in the contract. 8 } 9}

Extended Implementation: FlexDex

FlexDex extends ERC-505 with a crowdfunded liquidity bootstrapping mechanism and a price floor system built on top of the limit order primitives defined in this standard. It demonstrates how the core ERC-505 building blocks can be composed into a full token launch platform.

Security Considerations

Reentrancy

ERC-505 contracts send native currency to external addresses during sells, limit order fills, and cancellations. All state mutations MUST be completed before any external calls. Implementations SHOULD use reentrancy guards on all public trading functions. The fill-then-swap pattern in buy() and sell() SHOULD batch all native currency transfers and execute them after all order book and reserve state has been finalized.

Reserve manipulation

Because the AMM reserves are internal to the token contract, they cannot be manipulated by external flash loans against separate pool contracts. However, large swaps can still move the price significantly within a single block. Implementations SHOULD provide minAmountOut slippage protection on all swap functions.

Limit order griefing

Limit orders that are very small or placed at extreme prices could be used to grief takers who specify them in their limitOrderFills array. Implementations SHOULD skip invalid or unfillable orders gracefully (emitting OrderSkipped) rather than reverting the entire transaction.

Front-running

Swap transactions are subject to the same front-running risks as any on-chain AMM. The minAmountOut parameter provides protection against sandwich attacks. Limit orders, once placed, execute at their stated price regardless of market manipulation, providing additional front-running resistance for patient traders.

Pre-deployment balance manipulation

Before deployLiquidity() is called, the contract's token and native currency balances determine the opening price. If deployLiquidity() is callable by anyone, an attacker could send a small amount of native currency (or tokens via transfer) to skew the initial ratio. Implementations SHOULD use access control on deployLiquidity() or verify that balances match expected values.

Rounding and dust

Proportional fill arithmetic may leave dust amounts in limit orders (where offerAmount or desiredAmount rounds to zero). Implementations MUST deactivate orders when either side rounds to zero to prevent division-by-zero errors in subsequent fill attempts.