Abstract

This ERC proposes a push-based mechanism for sending data, allowing publisher contract to automatically push certain data to subscriber contracts during a call. The specific implementation relies on two interfaces: one for publisher contract to push data, and another for the subscriber contract to receive data. When the publisher contract is called, it checks if the called function corresponds to subscriber addresses. If it does, the publisher contract push data to the subscriber contracts.

Motivation

Currently, there are many keepers rely on off-chain data or seperate data collection process to monitor the events on chain. This proposal aims to establish a system where the publisher contract can atomicly push data to inform subscriber contracts about the updates. The direct on-chain interaction bewteen the publisher and the subscriber allows the system to be more trustless and efficient.

This proposal will offer significant advantages across a range of applications, such as enabling the boundless and permissionless expansion of DeFi, as well as enhancing DAO governance, among others.

Lending Protocol

An example of publisher contract could be an oracle, which can automatically push the price update through initiating a call to the subscriber protocol. The lending protocol, as the subscriber, can automatically liquidate the lending positions based on the received price.

Automatic Payment

A service provider can use a smart contract as a publisher contract, so that when a user call this contract, it can push the information to the subsriber contracts, such as, the users’ wallets like NFT bound accounts that follows ERC-6551 or other smart contract wallets. The user’s smart contract wallet can thus perform corresponding payment operations automatically. Compared to traditional approve needed approach, this solution allows more complex logic in implementation, such as limited payment, etc.

PoS Without Transferring Assets

For some staking scenarios, especially NFT staking, the PoS contract can be set as the subscriber and the NFT contracts can be set as the publisher. Staking can thus achieved through contracts interation, allowing users to earn staking rewards without transferring assets.

When operations like transfer of NFT occur, the NFT contract can push this information to the PoS contract, which can then perform unstaking or other functions.

DAO Voting

The DAO governance contract as a publisher could automatically triggers the push mechanism after the vote is completed, calling relevant subscriber contracts to directly implement the voting results, such as injecting funds into a certain account or pool.

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.

Overview

The push mechanism can be divided into the following four steps:

  1. The publisher contract is called.
  2. The publisher contract query the subscriber list from the selector of the function called. The subscriber contract can put the selected data into inbox.
  3. The publisher contract push selector and data through calling exec function of the subscriber contract.
  4. The subscriber contract executes based on pushed selector and data, or it may request information from the publisher contract’s inbox function as needed.

In the second step, the relationship between a called function and the corresponding subscriber can be configured in the publisher contract. Two configuration schemes are proposed:

  1. Unconditional Push: Any call to the configured selector triggers a push
  2. Conditional Push: Only the conditioned calls to the configured selector trigger a push based on the configuration.

It’s allowed to configure multiple, different types of subscriber contracts for a single selector. The publisher contract will call the exec function of each subscriber contract to push the request.

When unsubscribing a contract from a selector, publisher contract MUST check whether isLocked function of the subscriber contract returns true.

It is OPTIONAL for a publisher contract to use the inbox mechanism to store data.

In the fourth step, the subscriber contract SHOULD handle all possible selector requests and data in the implementation of exec function. In some cases, exec MAY call inbox function of publisher contract to obtain the pushed data in full.

Workflow

Contract interface

As mentioned above, there are Unconditional Push and Conditional Push two types of implementation. To implement Unconditional Push, the publisher contract SHOULD implement the following interface:

interface IPushForce {
    event ForceApprove(bytes4 indexed selector, address indexed target);
    event ForceCancel(bytes4 indexed selector, address indexed target);
    event RenounceForceApprove();
    event RenounceForceCancel();

    error MustRenounce();
    error ForceApproveRenounced();
    error ForceCancelRenounced();

    function isForceApproved(bytes4 selector, address target) external returns (bool);
    function forceApprove(bytes4 selector, address target) external;
    function forceCancel(bytes4 selector, address target) external;
    function isRenounceForceApprove() external returns (bool);
    function isRenounceForceCancel() external returns (bool);
    function renounceForceApprove(bytes memory) external;
    function renounceForceCancel(bytes memory) external;
}

isForceApproved is to query whether selector has already unconditionally bound to the subscriber contract with the address target. forceApprove is to bind selector to the subscriber contract target. forceCancel is to cancel the binding relationship between selector and target, where isLocked function of target returns true is REQUIRED.

renounceForceApprove is used to relinquish the forceApprove permission. After calling the renounceForceApprove function, forceApprove can no longer be called. Similarly, renounceForceCancel is used to relinquish the forceCancel permission. After calling the renounceForceCancel function, forceCancel can no longer be called.

To implement Conditional Push, the publisher contract SHOULD implement the following interface:

interface IPushFree {
    event Approve(bytes4 indexed selector, address indexed target, bytes data);
    event Cancel(bytes4 indexed selector, address indexed target, bytes data);

    function inbox(bytes4 selector) external returns (bytes memory);
    function isApproved(bytes4 selector, address target, bytes calldata data) external returns (bool);
    function approve(bytes4 selector, address target, bytes calldata data) external;
    function cancel(bytes4 selector, address target, bytes calldata data) external;
}

isApproved, approve, and cancel have functionalities similar to the corresponding functions in IPushForce. However, an additional data parameter is introduced here for checking whether a push is needed. The inbox here is used to store data in case of being called from the subscriber contract.

The publisher contract SHOULD implement _push(bytes4 selector, bytes calldata data) function, which acts as a hook. Any function within the publisher contract that needs to implement push mechanism must call this internal function. The function MUST include querying both unconditional and conditional subscription contracts based on selector and data, and then calling corresponding exec function of the subscribers.

A subscriber need to implement the following interface:

interface IExec {
    function isLocked(bytes4 selector, bytes calldata data) external returns (bool);
    function exec(bytes4 selector, bytes calldata data) external;
}

exec is to receive requests from the publisher contracts and further proceed to execute. isLocked is to check the status of whether the subscriber contract can unsubscribe the publisher contract based on selector and data. It is triggered when a request to unsubscribe is received.

Rationale

Unconditional and Conditional Configuration

When the sending contract is called, it is possible to trigger a push, requiring the caller to pay the resulting gas fees. In some cases, an Unconditional Push is necessary, such as pushing price changes to a lending protocol. While, Conditional Push will reduce the unwanted gas consumption.

Check isLocked Before Unsubscribing

Before forceCancel or cancel, the publisher contract MUST call the isLocked function of the subscriber contract to avoid unilateral unsubscribing. The subscriber contract may have a significant logical dependence on the publisher contract, and thus unsubscription could lead to severe issues within the subscriber contract. Therefore, the subscriber contract should implement isLocked function with thorough consideration.

inbox Mechanism

In certain scenarios, the publisher contract may only push essential data with selector to the subscriber contracts, while the full data might be stored within inbox. Upon receiving the push from the publisher contract, the subscriber contract is optional to call inbox. inbox mechanism simplifies the push information while still ensuring the availability of complete data, thereby reducing gas consumption.

Using Function Selectors as Parameters

Using function selectors to retrieve the addresses of subscriber contracts allows more detailed configuration. For the subscriber contract, having the specific function of the request source based on the push information enables more accurate handling of the push information.

Renounce Safety Enhancement

Both forceApprove and forceCancel permissions can be relinquished using their respective renounce functions. When both renounceForceApprove and renounceForceCancel are called, the registered push targets can longer be changed, greatly enhancing security.

Reference Implementation

pragma solidity ^0.8.24;

import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
import {IPushFree, IPushForce} from "./interfaces/IPush.sol";
import {IExec} from "./interfaces/IExec.sol";

contract Foo is IPushFree, IPushForce {
    using EnumerableSet for EnumerableSet.AddressSet;

    bool public override isRenounceForceApprove;
    bool public override isRenounceForceCancel;

    mapping(bytes4 selector => mapping(uint256 tokenId => EnumerableSet.AddressSet targets)) private _registry;
    mapping(bytes4 selector => EnumerableSet.AddressSet targets) private _registryOfAll;
    // mapping(bytes4 => bytes) public inbox;

    modifier notLock(bytes4 selector, address target, bytes memory data) {
        require(!IExec(target).isLocked(selector, data), "Foo: lock");
        _;
    }

    function inbox(bytes4 selector) public view returns (bytes memory data) {
        uint256 loadData;
        assembly {
            loadData := tload(selector)
        }

        data = abi.encode(loadData);
    }

    function isApproved(bytes4 selector, address target, bytes calldata data) external view override returns (bool) {
        uint256 tokenId = abi.decode(data, (uint256));
        return _registry[selector][tokenId].contains(target);
    }

    function isForceApproved(bytes4 selector, address target) external view override returns (bool) {
        return _registryOfAll[selector].contains(target);
    }

    function approve(bytes4 selector, address target, bytes calldata data) external override {
        uint256 tokenId = abi.decode(data, (uint256));
        _registry[selector][tokenId].add(target);
    }

    function cancel(bytes4 selector, address target, bytes calldata data)
        external
        override
        notLock(selector, target, data)
    {
        uint256 tokenId = abi.decode(data, (uint256));
        _registry[selector][tokenId].remove(target);
    }

    function forceApprove(bytes4 selector, address target) external override {
        if (isRenounceForceApprove) revert ForceApproveRenounced();
        _registryOfAll[selector].add(target);
    }

    function forceCancel(bytes4 selector, address target) external override notLock(selector, target, "") {
        if (isRenounceForceCancel) revert ForceCancelRenounced();
        _registryOfAll[selector].remove(target);
    }

    function renounceForceApprove(bytes memory data) external override {
        (bool burn) = abi.decode(data, (bool));
        if (burn != true) {
            revert MustRenounce();
        }

        isRenounceForceApprove = true;
        emit RenounceForceApprove();
    }

    function renounceForceCancel(bytes memory data) external override {
        (bool burn) = abi.decode(data, (bool));
        if (burn != true) {
            revert MustRenounce();
        }

        isRenounceForceCancel = true;
        emit RenounceForceCancel();
    }

    function send(uint256 message) external {
        _push(this.send.selector, message);
    }

    function _push(bytes4 selector, uint256 message) internal {
        assembly {
            tstore(selector, message)
        }

        address[] memory targets = _registry[selector][message].values();
        for (uint256 i = 0; i < targets.length; i++) {
            IExec(targets[i]).exec(selector, abi.encode(message));
        }

        targets = _registryOfAll[selector].values();
        for (uint256 i = 0; i < targets.length; i++) {
            IExec(targets[i]).exec(selector, abi.encode(message));
        }
    }
}

contract Bar is IExec {
    event Log(bytes4 indexed selector, bytes data, bytes inboxData);

    function isLocked(bytes4, bytes calldata) external pure override returns (bool) {
        return true;
    }

    function exec(bytes4 selector, bytes calldata data) external {
        bytes memory inboxData = IPushFree(msg.sender).inbox(selector);

        emit Log(selector, data, inboxData);
    }
}

Security Considerations

exec Attacks

The exec function is public, therefore, it is vulnerable to malicious calls where arbitrary push information can be inserted. Implementations of exec should carefully consider the arbitrariness of calls and should not directly use data passed by the exec function without verification.

Reentrancy Attack

The publisher contract’s call to the subscriber contract’s exec function could lead to reentrancy attacks. Malicious subscription contracts might construct reentrancy attacks to the publisher contract within exec.

Arbitrary Target Approve

Implementation of forceApprove and approve should have reasonable access controls; otherwise, unnecessary gas losses could be imposed on callers.

Check the gas usage of the exec function.

isLocked implementation

Subscriber contracts should implement the isLocked function to avoid potential loss brought by unsubscription. This is particularly crucial for lending protocols implementing this proposal. Improper unsubscription can lead to abnormal clearing, causing considerable losses.

Similarly, when subscribing, the publisher contract should consider whether isLocked is properly implemented to prevent irrevocable subscriptions.

Copyright and related rights waived via CC0.