ERC-8017 - Payout Race

Created 2025-08-31
Status Draft
Category ERC
Type Standards Track
Authors
  • Kyle Thornton (@kyle) <kyle at cowrie.io>

Requires

Abstract

This ERC specifies a small contract surface for a "payout race": a bucket that holds a single payout asset type and transfers the entire bucket to a recipient when a caller pays a fixed required payment in a configured desired payment asset. The desired payment asset can be ETH or one ERC-20. The payout asset can be ETH or one ERC-20.

This ERC is inspired by the Uniswap Foundation's Unistaker proposal, which introduced the term Payout Race and motivated this design.

Motivation

Many protocols need an ongoing way to convert a continuous stream of value into another asset at or near prevailing market prices. Typical cases include buying back a protocol token using protocol revenue, accumulating a reserve asset, funding incentive budgets, or rebalancing treasuries. Existing patterns have material drawbacks. Integrating an AMM couples outcomes to external liquidity, slippage, and fees, and requires retuning when pool conditions change. General on-chain auctions add operational complexity and higher gas, especially when run continuously.

This ERC defines a deterministic, revenue-driven primitive that is analogous to a Dutch auction. Sources of value flow into this contract, filling a "bucket" of purchasable assets. The first caller that supplies the required payment in the desired payment asset receives the entire current balance of the payout token in the bucket. The interface is small, auditable, and easy to compose with upstream controllers that decide when the exchange is economically sound.

Specification

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

Definitions

Interface

// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.24;

interface IPayoutRace {
    /// @notice Payout asset. address(0) means ETH payout.
    function payoutAsset() external view returns (address);

    /// @notice Desired payment asset. address(0) means ETH payment.
    function desiredAsset() external view returns (address);

    /// @notice Fixed amount required to win the race, denominated in the desired payment asset.
    function requiredPayment() external view returns (uint256);

    /// @notice Destination that receives the buyer's payment.
    function paymentSink() external view returns (address);

    /// @notice Pay the required amount and receive the entire current balance of the payout token to `to`.
    /// @dev Reverts if the computed dispensed amount is zero. Must be safe against reentrancy.
    /// @return dispensed The amount of payout token transferred to `to`.
    function purchase(address to) external payable returns (uint256 dispensed);

    // Admin surface.
    function setRequiredPayment(uint256 amount) external;
    function setPaymentSink(address sink) external;

    // Events
    event Purchased(address indexed buyer, address indexed to, uint256 dispensed, uint256 paid);
    event PaymentConfigUpdated(address desiredAsset, uint256 requiredPayment, address sink);
}

Required Behavior

  1. Exact required payment. Callers MUST provide exactly requiredPayment() in the configured desiredAsset or in ETH to call purchase.
  2. Token pairing. payoutAsset and desiredAsset MUST NOT both be address(0). ETH on both sides is disallowed.
  3. Desired payment asset immutability. desiredAsset MUST NOT change after initialization. A conforming contract MUST NOT expose any callable setter that can change desiredAsset.
  4. Payout asset immutability. payoutAsset MUST NOT change after initialization. A conforming contract MUST NOT expose any callable setter that can change payoutAsset.
  5. All-or-nothing dispense. On purchase, a conforming contract MUST compute the amount to dispense as the live balance of the payout token captured at function entry, before any external calls. The contract MUST transfer exactly this amount to to in a single call and the call MUST revert if this amount is zero.
  6. Payment collection.

  7. If desiredAsset == address(0), purchase MUST require msg.value == requiredPayment() and MUST forward that ETH to paymentSink().

  8. If desiredAsset != address(0), purchase MUST require msg.value == 0 and MUST call transferFrom(msg.sender, paymentSink(), requiredPayment()) on desiredAsset.
  9. Admin changes. A conforming contract MUST restrict the admin setters to an authorized role and MUST emit PaymentConfigUpdated when requiredPayment or paymentSink change.

Optional Extensions

Rationale

Admin Considerations

Access control for admin setters is intentionally unspecified; EIP-173 ownership or a role-based pattern is recommended.

Some deployments may renounce or restrict admin rights for policy or compliance reasons (for example, renouncing ownership or disabling roles). This ERC does not prescribe any specific mechanism.

The reference uses EIP-173 style ownership for illustration. Any access control that enforces the Required behavior is acceptable. Deployments may assign distinct roles per setter or make one or more parameters immutable. The specification is agnostic to the mechanism.

Parameter Selection and Degenerate Cases

This mechanism works best when value accrues gradually. Large, lumpy deposits can overshoot the required payment threshold and leak value to the first successful caller. Operators should size requiredPayment relative to observed inflow volatility and adjust conservatively. If the payout asset appreciates against the desired payment asset, purchases may stall. If it depreciates, purchases may trigger so frequently that value is lost whenever a large trade pushes the bucket well above the threshold.

Changing requiredPayment carries risks. Lowering it can leak value at the moment of change if accrued payout already exceeds the new threshold, since searchers can win a bargain. Raising it can disrupt or bankrupt naive searchers and MEV bots that provide rewards by arbitraging fee collection. Mitigations may include timelocked or scheduled parameter changes, announce windows, caps on per-block deposits, cooldowns after changes, and time-weighted average pricing (TWAP)-based or ratcheted adjustments to requiredPayment.

Considered Alternatives: Multi-Asset Sweep

This design could be extended to support multiple payout assets by maintaining an explicit allowlist and, on a successful purchase, sweeping each allowlisted token to the recipient using the same mechanics as the single-asset case.

Backwards Compatibility

Compatible with any ERC-20. Wallets and dApps can integrate using standard allowance flows or optional permit helpers.

Reference Implementation

// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.24;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract PayoutRace is ReentrancyGuard {
    address public immutable payoutAsset;        // address(0) for ETH payout
    address public immutable desiredAsset;       // address(0) for ETH payment
    uint256 public requiredPayment;              // fixed amount owed by buyer
    address public paymentSink;

    address private _owner;

    event Purchased(address indexed buyer, address indexed to, uint256 dispensed, uint256 paid);
    event PaymentConfigUpdated(address desiredAsset, uint256 requiredPayment, address sink);
    event OwnershipTransferred(address indexed oldOwner, address indexed newOwner);

    modifier onlyOwner() { require(msg.sender == _owner, "not owner"); _; }

    constructor(address _payoutAsset, address _desiredAsset, uint256 _required, address _sink) {
        require(!(_payoutAsset == address(0) && _desiredAsset == address(0)), "ETH-ETH disallowed");
        _owner = msg.sender;
        payoutAsset = _payoutAsset;              // zero means ETH payout
        desiredAsset = _desiredAsset;            // zero means ETH payment
        requiredPayment = _required;
        paymentSink = _sink;
        emit OwnershipTransferred(address(0), _owner);
        emit PaymentConfigUpdated(desiredAsset, requiredPayment, paymentSink);
    }

    function owner() external view returns (address) { return _owner; }
    function transferOwnership(address n) external onlyOwner { _owner = n; emit OwnershipTransferred(msg.sender, n); }

    /// @notice Accept ETH only when this instance vends ETH
    receive() external payable {
        require(payoutToken == address(0), "ETH payout disabled");
    }

    // desiredAsset is immutable in this reference; no setter is provided.
    function setRequiredPayment(uint256 amount) external onlyOwner { requiredPayment = amount; emit PaymentConfigUpdated(desiredAsset, requiredPayment, paymentSink); }
    function setPaymentSink(address sink) external onlyOwner { paymentSink = sink; emit PaymentConfigUpdated(desiredAsset, requiredPayment, paymentSink); }

    function purchase(address to) external payable nonReentrant returns (uint256 dispensed) {
        uint256 toDispense;
        if (payoutAsset == address(0)) {
            // capture live ETH balance
            toDispense = address(this).balance;
        } else {
            toDispense = IERC20(payoutAsset).balanceOf(address(this));
        }
        require(toDispense > 0, "empty");

        // collect payment
        if (desiredAsset == address(0)) {
            require(msg.value == requiredPayment, "bad msg.value");
            (bool ok, ) = paymentSink.call{value: msg.value}("");
            require(ok, "sink transfer failed");
        } else {
            require(msg.value == 0, "unexpected ETH");
            require(IERC20(desiredAsset).transferFrom(msg.sender, paymentSink, requiredPayment), "payment transfer failed");
        }

        // payout
        if (payoutAsset == address(0)) {
            (bool ok2, ) = to.call{value: toDispense}("");
            require(ok2, "ETH payout failed");
        } else {
            require(IERC20(payoutAsset).transfer(to, toDispense), "token payout failed");
        }

        emit Purchased(msg.sender, to, toDispense, requiredPayment);
        return toDispense;
    }
}

Security Considerations

Copyright

Copyright and related rights waived via CC0.