|
| 1 | +--- |
| 2 | +eip: 8017 |
| 3 | +title: Payout Race |
| 4 | +description: Minimal ERC for a single-asset payout bucket that vends its entire balance for a fixed payment amount. |
| 5 | +author: Kyle Thornton (@kyle) <[email protected]> |
| 6 | +discussions-to: https://ethereum-magicians.org/t/erc-8017-payout-race/25311 |
| 7 | +status: Draft |
| 8 | +type: Standards Track |
| 9 | +category: ERC |
| 10 | +created: 2025-08-31 |
| 11 | +requires: 20 |
| 12 | +--- |
| 13 | + |
| 14 | +## Abstract |
| 15 | + |
| 16 | +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](./eip-20.md). The payout asset can be ETH or one ERC-20. |
| 17 | + |
| 18 | +This ERC is inspired by the Uniswap Foundation's **Unistaker** proposal, which introduced the term **Payout Race** and motivated this design. |
| 19 | + |
| 20 | +## Motivation |
| 21 | + |
| 22 | +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. |
| 23 | + |
| 24 | +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. |
| 25 | + |
| 26 | +## Specification |
| 27 | + |
| 28 | +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. |
| 29 | + |
| 30 | +### Definitions |
| 31 | + |
| 32 | +* **Conforming contract**: Any smart contract that exposes this interface and claims compliance with this ERC. This includes proxies and clones. Requirements in this document apply to the observable runtime behavior of the deployed contract. |
| 33 | + |
| 34 | +* **Payout asset**: Asset dispensed from the bucket. `payoutAsset == address(0)` means ETH payout. |
| 35 | + |
| 36 | +* **Desired payment asset**: Asset the buyer must pay. Referred to as `desiredAsset` in the interface. `desiredAsset == address(0)` means ETH payment. |
| 37 | + |
| 38 | +* **Required payment**: Fixed amount of the desired payment asset (`desiredAsset`) or ETH that must be provided by the buyer to trigger the payout. |
| 39 | + |
| 40 | +### Interface |
| 41 | + |
| 42 | +```solidity |
| 43 | +// SPDX-License-Identifier: CC0-1.0 |
| 44 | +pragma solidity ^0.8.24; |
| 45 | +
|
| 46 | +interface IPayoutRace { |
| 47 | + /// @notice Payout asset. address(0) means ETH payout. |
| 48 | + function payoutAsset() external view returns (address); |
| 49 | +
|
| 50 | + /// @notice Desired payment asset. address(0) means ETH payment. |
| 51 | + function desiredAsset() external view returns (address); |
| 52 | +
|
| 53 | + /// @notice Fixed amount required to win the race, denominated in the desired payment asset. |
| 54 | + function requiredPayment() external view returns (uint256); |
| 55 | +
|
| 56 | + /// @notice Destination that receives the buyer's payment. |
| 57 | + function paymentSink() external view returns (address); |
| 58 | +
|
| 59 | + /// @notice Pay the required amount and receive the entire current balance of the payout token to `to`. |
| 60 | + /// @dev Reverts if the computed dispensed amount is zero. Must be safe against reentrancy. |
| 61 | + /// @return dispensed The amount of payout token transferred to `to`. |
| 62 | + function purchase(address to) external payable returns (uint256 dispensed); |
| 63 | +
|
| 64 | + // Admin surface. |
| 65 | + function setRequiredPayment(uint256 amount) external; |
| 66 | + function setPaymentSink(address sink) external; |
| 67 | +
|
| 68 | + // Events |
| 69 | + event Purchased(address indexed buyer, address indexed to, uint256 dispensed, uint256 paid); |
| 70 | + event PaymentConfigUpdated(address desiredAsset, uint256 requiredPayment, address sink); |
| 71 | +} |
| 72 | +``` |
| 73 | + |
| 74 | +### Required Behavior |
| 75 | + |
| 76 | +1. **Exact required payment.** Callers **MUST** provide exactly `requiredPayment()` in the configured `desiredAsset` or in ETH to call `purchase`. |
| 77 | +2. **Token pairing.** `payoutAsset` and `desiredAsset` **MUST NOT** both be `address(0)`. ETH on both sides is disallowed. |
| 78 | +3. **Desired payment asset immutability.** `desiredAsset` **MUST NOT** change after initialization. A conforming contract **MUST NOT** expose any callable setter that can change `desiredAsset`. |
| 79 | +4. **Payout asset immutability.** `payoutAsset` **MUST NOT** change after initialization. A conforming contract **MUST NOT** expose any callable setter that can change `payoutAsset`. |
| 80 | +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. |
| 81 | +6. **Payment collection.** |
| 82 | + |
| 83 | + * If `desiredAsset == address(0)`, `purchase` **MUST** require `msg.value == requiredPayment()` and **MUST** forward that ETH to `paymentSink()`. |
| 84 | + * If `desiredAsset != address(0)`, `purchase` **MUST** require `msg.value == 0` and **MUST** call `transferFrom(msg.sender, paymentSink(), requiredPayment())` on `desiredAsset`. |
| 85 | +7. **Admin changes.** A conforming contract **MUST** restrict the admin setters to an authorized role and **MUST** emit `PaymentConfigUpdated` when `requiredPayment` or `paymentSink` change. |
| 86 | + |
| 87 | +### Optional Extensions |
| 88 | + |
| 89 | +* **Permit for payment**: A conforming contract **MAY** expose `purchaseWithPermit(...)` that accepts [EIP-2612](./eip-2612.md) permit parameters. If implemented, the function **MUST** require `desiredAsset != address(0)`, **MUST** call `permit` on `desiredAsset` with the supplied signature, and **MUST** collect `requiredPayment` via `transferFrom` in the same transaction. The call **MUST** revert if `desiredAsset` does not implement EIP-2612 or if the permit does not yield sufficient allowance. |
| 90 | +* **Rescue for unintended assets**: A conforming contract **MAY** implement an admin-only `rescue` function to recover assets that are not the `payoutAsset` (e.g., unsolicited ERC-20s or ETH sent when `payoutAsset` is an ERC-20). If provided, the function **MUST NOT** transfer the `payoutAsset`, **MUST** emit a `Rescued(address token, address to, uint256 amount)` event, and **MUST** be restricted to an authorized role. |
| 91 | + |
| 92 | +## Rationale |
| 93 | + |
| 94 | +* A single required payment pairs well with controllers that evaluate when the exchange is economically sound and trigger `purchase` only when conditions justify it. The onchain primitive then validates the payment and atomically transfers the entire bucket. |
| 95 | +* `paymentSink` reduces persistent balances in the contract and simplifies audits. Sinks can be treasuries, splitters, or burns. |
| 96 | +* Using the live onchain balance as the source of truth automatically captures rebases and fee-on-transfer mechanics, and keeps the onchain tracking minimized. It also implies that unsolicited transfers to the contract will be included in the next payout, which purchasers may want to account for at the integration level. |
| 97 | + |
| 98 | +### Admin Considerations |
| 99 | + |
| 100 | +Access control for admin setters is intentionally unspecified; [EIP-173](./eip-173.md) ownership or a role-based pattern is recommended. |
| 101 | + |
| 102 | +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. |
| 103 | + |
| 104 | +The reference uses [EIP-173](./eip-173.md) 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. |
| 105 | + |
| 106 | +### Parameter Selection and Degenerate Cases |
| 107 | + |
| 108 | +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. |
| 109 | + |
| 110 | +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`. |
| 111 | + |
| 112 | +### Considered Alternatives: Multi-Asset Sweep |
| 113 | + |
| 114 | +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. |
| 115 | + |
| 116 | +## Backwards Compatibility |
| 117 | + |
| 118 | +Compatible with any [ERC-20](./eip-20.md). Wallets and dApps can integrate using standard allowance flows or optional `permit` helpers. |
| 119 | + |
| 120 | +## Reference Implementation |
| 121 | + |
| 122 | +```solidity |
| 123 | +// SPDX-License-Identifier: CC0-1.0 |
| 124 | +pragma solidity ^0.8.24; |
| 125 | +
|
| 126 | +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; |
| 127 | +import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; |
| 128 | +
|
| 129 | +contract PayoutRace is ReentrancyGuard { |
| 130 | + address public immutable payoutAsset; // address(0) for ETH payout |
| 131 | + address public immutable desiredAsset; // address(0) for ETH payment |
| 132 | + uint256 public requiredPayment; // fixed amount owed by buyer |
| 133 | + address public paymentSink; |
| 134 | +
|
| 135 | + address private _owner; |
| 136 | +
|
| 137 | + event Purchased(address indexed buyer, address indexed to, uint256 dispensed, uint256 paid); |
| 138 | + event PaymentConfigUpdated(address desiredAsset, uint256 requiredPayment, address sink); |
| 139 | + event OwnershipTransferred(address indexed oldOwner, address indexed newOwner); |
| 140 | +
|
| 141 | + modifier onlyOwner() { require(msg.sender == _owner, "not owner"); _; } |
| 142 | +
|
| 143 | + constructor(address _payoutAsset, address _desiredAsset, uint256 _required, address _sink) { |
| 144 | + require(!(_payoutAsset == address(0) && _desiredAsset == address(0)), "ETH-ETH disallowed"); |
| 145 | + _owner = msg.sender; |
| 146 | + payoutAsset = _payoutAsset; // zero means ETH payout |
| 147 | + desiredAsset = _desiredAsset; // zero means ETH payment |
| 148 | + requiredPayment = _required; |
| 149 | + paymentSink = _sink; |
| 150 | + emit OwnershipTransferred(address(0), _owner); |
| 151 | + emit PaymentConfigUpdated(desiredAsset, requiredPayment, paymentSink); |
| 152 | + } |
| 153 | +
|
| 154 | + function owner() external view returns (address) { return _owner; } |
| 155 | + function transferOwnership(address n) external onlyOwner { _owner = n; emit OwnershipTransferred(msg.sender, n); } |
| 156 | +
|
| 157 | + /// @notice Accept ETH only when this instance vends ETH |
| 158 | + receive() external payable { |
| 159 | + require(payoutToken == address(0), "ETH payout disabled"); |
| 160 | + } |
| 161 | +
|
| 162 | + // desiredAsset is immutable in this reference; no setter is provided. |
| 163 | + function setRequiredPayment(uint256 amount) external onlyOwner { requiredPayment = amount; emit PaymentConfigUpdated(desiredAsset, requiredPayment, paymentSink); } |
| 164 | + function setPaymentSink(address sink) external onlyOwner { paymentSink = sink; emit PaymentConfigUpdated(desiredAsset, requiredPayment, paymentSink); } |
| 165 | +
|
| 166 | + function purchase(address to) external payable nonReentrant returns (uint256 dispensed) { |
| 167 | + uint256 toDispense; |
| 168 | + if (payoutAsset == address(0)) { |
| 169 | + // capture live ETH balance |
| 170 | + toDispense = address(this).balance; |
| 171 | + } else { |
| 172 | + toDispense = IERC20(payoutAsset).balanceOf(address(this)); |
| 173 | + } |
| 174 | + require(toDispense > 0, "empty"); |
| 175 | +
|
| 176 | + // collect payment |
| 177 | + if (desiredAsset == address(0)) { |
| 178 | + require(msg.value == requiredPayment, "bad msg.value"); |
| 179 | + (bool ok, ) = paymentSink.call{value: msg.value}(""); |
| 180 | + require(ok, "sink transfer failed"); |
| 181 | + } else { |
| 182 | + require(msg.value == 0, "unexpected ETH"); |
| 183 | + require(IERC20(desiredAsset).transferFrom(msg.sender, paymentSink, requiredPayment), "payment transfer failed"); |
| 184 | + } |
| 185 | +
|
| 186 | + // payout |
| 187 | + if (payoutAsset == address(0)) { |
| 188 | + (bool ok2, ) = to.call{value: toDispense}(""); |
| 189 | + require(ok2, "ETH payout failed"); |
| 190 | + } else { |
| 191 | + require(IERC20(payoutAsset).transfer(to, toDispense), "token payout failed"); |
| 192 | + } |
| 193 | +
|
| 194 | + emit Purchased(msg.sender, to, toDispense, requiredPayment); |
| 195 | + return toDispense; |
| 196 | + } |
| 197 | +} |
| 198 | +``` |
| 199 | + |
| 200 | +## Security Considerations |
| 201 | + |
| 202 | +* **Payout accounting.** The dispensed amount is computed from the live onchain balance of the payout asset. Because ETH-to-ETH is disallowed, there is no ambiguity about subtracting `msg.value`. Capture the amount to dispense at function entry and use that value for the transfer. |
| 203 | + |
| 204 | +* **Reentrancy and external calls.** Use the Checks-Effects-Interactions pattern and a reentrancy guard. Avoid any external calls before you (a) capture the amount to dispense and (b) forward payment to `paymentSink`. Do not perform callbacks between collecting payment and completing the payout. |
| 205 | + |
| 206 | +* **Receiver constraints.** The recipient `to` must be able to receive the asset being dispensed. ETH payouts require a payable fallback; [ERC-20](./eip-20.md) payouts require that `to` is not a contract that reverts on `transfer`. |
| 207 | + |
| 208 | +* **Payment sink constraints.** The `paymentSink` must be able to receive the desired payment asset. For ETH payments, `paymentSink` must be payable. For [ERC-20](./eip-20.md) payments, `paymentSink` must not revert when credited via `transferFrom`. Using a burn address, splitter, or treasury is acceptable; the specification is agnostic to the mechanism. |
| 209 | + |
| 210 | +* **Unsolicited transfers.** The next payout will include any assets pushed to the contract (e.g., direct ETH sends or [ERC-20](./eip-20.md) transfers). Operators should account for this at the integration layer, or front the contract with filters if needed. An optional admin-only `rescue` for non-`payoutAsset` assets can mitigate mistakes without affecting conformance. |
| 211 | + |
| 212 | +* **Approvals and permits.** When using [ERC-20](./eip-20.md) payments, callers should consider allowance race conditions. If a `purchaseWithPermit` helper is implemented, verify domain separator, deadline, and nonce handling, and revert on insufficient post‑permit allowance. |
| 213 | + |
| 214 | +* **Admin changes.** Because setters can change `requiredPayment` or `paymentSink`, governance should protect these operations. Common mitigations include timelocks, scheduled changes with announcement windows, and immutability for parameters that should never change. |
| 215 | + |
| 216 | +* **Proxies and clones.** Constructors do not run per proxy or minimal clone. Implementations should set `payoutAsset` and `desiredAsset` once during initialization and ensure they cannot change afterward. Avoid exposing setters and protect initializers against re-entry or multiple calls. |
| 217 | + |
| 218 | +## Copyright |
| 219 | + |
| 220 | +Copyright and related rights waived via [CC0](../LICENSE.md). |
0 commit comments