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
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.
Payout asset: Asset dispensed from the bucket. payoutAsset == address(0) means ETH payout.
Desired payment asset: Asset the buyer must pay. Referred to as desiredAsset in the interface. desiredAsset == address(0) means ETH payment.
Required payment: Fixed amount of the desired payment asset (desiredAsset) or ETH that must be provided by the buyer to trigger the payout.
Interface
// SPDX-License-Identifier: CC0-1.0
pragmasolidity^0.8.24;interfaceIPayoutRace{/// @notice Payout asset. address(0) means ETH payout.
functionpayoutAsset()externalviewreturns(address);/// @notice Desired payment asset. address(0) means ETH payment.
functiondesiredAsset()externalviewreturns(address);/// @notice Fixed amount required to win the race, denominated in the desired payment asset.
functionrequiredPayment()externalviewreturns(uint256);/// @notice Destination that receives the buyer's payment.
functionpaymentSink()externalviewreturns(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`.
functionpurchase(addressto)externalpayablereturns(uint256dispensed);// Admin surface.
functionsetRequiredPayment(uint256amount)external;functionsetPaymentSink(addresssink)external;// Events
eventPurchased(addressindexedbuyer,addressindexedto,uint256dispensed,uint256paid);eventPaymentConfigUpdated(addressdesiredAsset,uint256requiredPayment,addresssink);}
Required Behavior
Exact required payment. Callers MUST provide exactly requiredPayment() in the configured desiredAsset or in ETH to call purchase.
Token pairing.payoutAsset and desiredAssetMUST NOT both be address(0). ETH on both sides is disallowed.
Desired payment asset immutability.desiredAssetMUST NOT change after initialization. A conforming contract MUST NOT expose any callable setter that can change desiredAsset.
Payout asset immutability.payoutAssetMUST NOT change after initialization. A conforming contract MUST NOT expose any callable setter that can change payoutAsset.
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.
Payment collection.
If desiredAsset == address(0), purchaseMUST require msg.value == requiredPayment() and MUST forward that ETH to paymentSink().
If desiredAsset != address(0), purchaseMUST require msg.value == 0 and MUST call transferFrom(msg.sender, paymentSink(), requiredPayment()) on desiredAsset.
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
Permit for payment: A conforming contract MAY expose purchaseWithPermit(...) that accepts EIP-2612 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.
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.
Rationale
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.
paymentSink reduces persistent balances in the contract and simplifies audits. Sinks can be treasuries, splitters, or burns.
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.
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
pragmasolidity^0.8.24;import{IERC20}from"@openzeppelin/contracts/token/ERC20/IERC20.sol";import{ReentrancyGuard}from"@openzeppelin/contracts/security/ReentrancyGuard.sol";contractPayoutRaceisReentrancyGuard{addresspublicimmutablepayoutAsset;// address(0) for ETH payout
addresspublicimmutabledesiredAsset;// address(0) for ETH payment
uint256publicrequiredPayment;// fixed amount owed by buyer
addresspublicpaymentSink;addressprivate_owner;eventPurchased(addressindexedbuyer,addressindexedto,uint256dispensed,uint256paid);eventPaymentConfigUpdated(addressdesiredAsset,uint256requiredPayment,addresssink);eventOwnershipTransferred(addressindexedoldOwner,addressindexednewOwner);modifieronlyOwner(){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;emitOwnershipTransferred(address(0),_owner);emitPaymentConfigUpdated(desiredAsset,requiredPayment,paymentSink);}functionowner()externalviewreturns(address){return_owner;}functiontransferOwnership(addressn)externalonlyOwner{_owner=n;emitOwnershipTransferred(msg.sender,n);}/// @notice Accept ETH only when this instance vends ETH
receive()externalpayable{require(payoutToken==address(0),"ETH payout disabled");}// desiredAsset is immutable in this reference; no setter is provided.
functionsetRequiredPayment(uint256amount)externalonlyOwner{requiredPayment=amount;emitPaymentConfigUpdated(desiredAsset,requiredPayment,paymentSink);}functionsetPaymentSink(addresssink)externalonlyOwner{paymentSink=sink;emitPaymentConfigUpdated(desiredAsset,requiredPayment,paymentSink);}functionpurchase(addressto)externalpayablenonReentrantreturns(uint256dispensed){uint256toDispense;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");(boolok,)=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)){(boolok2,)=to.call{value:toDispense}("");require(ok2,"ETH payout failed");}else{require(IERC20(payoutAsset).transfer(to,toDispense),"token payout failed");}emitPurchased(msg.sender,to,toDispense,requiredPayment);returntoDispense;}}
Security Considerations
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.
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.
Receiver constraints. The recipient to must be able to receive the asset being dispensed. ETH payouts require a payable fallback; ERC-20 payouts require that to is not a contract that reverts on transfer.
Payment sink constraints. The paymentSink must be able to receive the desired payment asset. For ETH payments, paymentSink must be payable. For ERC-20 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.
Unsolicited transfers. The next payout will include any assets pushed to the contract (e.g., direct ETH sends or ERC-20 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.
Approvals and permits. When using ERC-20 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.
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.
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.