Guides

Creating an Open Action (Publication Action)

📘

A bit of history:

In Lens V1 the Collect operation has been proven to be successful. So far, more than 400k publications has been collected, minting more than 3.5M NFTs in total.

However, this operation had its own specific purpose, and does not fit all the use cases. For example, if we want to tip someone because they made a great publication, we do not necessarily want a clone of the publication as NFT in exchange.

Publication Actions provide a way for developers to build custom operations that can be executed on or through publications.

When is it worth to build a publication action?

Anytime you want to link the provenance of that action to a publication, potentially distributing rewards to the users that contributed to the action to occur. Let’s use the tipping example to illustrate this better.

Imagine @alice has done a great post and @bob wants to tip her because of it.

Someone can say that there is no need for building any custom action for this, as tipping can be done by simply executing an ERC-20 token transfer from the owner of @bob profile to the owner of @alice profile.

This is technically true - However, let’s see all the use cases that making this through a tipping publication action can bring to the table:

  • Publication actions are executed by Lens profiles. Thus, @alice can see that @bob has tipped her, instead of receiving a tip from an unknown address.
  • Using a tipping publication action allows indexers to link the ERC-20 transfer to the publication where the action was executed. This allows @alice to track how much tipping revenue each of her publications has done or, combining it with the previous item, she can track which profiles are her top tippers, and many other useful stats.
  • Publication actions can allow rewards through a referral system. For example, @alice can setup the tipping action to recompense users that interact or share her publications by giving them a percentage of the tip if they helped it to happen.
    So, if @carl quotes @alice's publication, and @bob ends up discovering @alice's publication because of @carl's quote, then when @bob tips @alice, @carl receives a part of it as commission. This not only applies to quotes, but to mirrors and comments too.

Here are other examples of publication actions that can be developed. In all of them, the referral system can be applied.

  • Collecting the publication.
  • Minting an NFT from a public collection through a publication that announces or recommends it.
  • Buying an NFT from a publication that is communicating that it is on sale.
  • Voting on a publication’s poll.
  • Join a publication’s on-chain raffle.

How to build a Publication Action?

Overview

Developing your publication action is easy. All you need to do is build a smart contract that complies with the publication action module interface.

/**
 * @title IPublicationAction
 * @author Lens Protocol
 *
 * @notice This is the standard interface for all Lens-compatible Publication Actions.
 * Publication action modules allow users to execute actions directly from a publication, like:
 *  - Minting NFTs.
 *  - Collecting a publication.
 *  - Sending funds to the publication author (e.g. tipping).
 *  - Etc.
 * Referrers are supported, so any publication or profile that references the publication can receive a share from the
 * publication's action if the action module supports it.
 */
interface IPublicationActionModule {
    /**
     * @notice Initializes the action module for the given publication being published with this Action module.
     * @custom:permissions LensHub.
     *
     * @param profileId The profile ID of the author publishing the content with this Publication Action.
     * @param pubId The publication ID being published.
     * @param transactionExecutor The address of the transaction executor (e.g. for any funds to transferFrom).
     * @param data Arbitrary data passed from the user to be decoded by the Action Module during initialization.
     *
     * @return bytes Any custom ABI-encoded data. This will be a LensHub event params that can be used by
     * indexers or UIs.
     */
    function initializePublicationAction(
        uint256 profileId,
        uint256 pubId,
        address transactionExecutor,
        bytes calldata data
    ) external returns (bytes memory);

    /**
     * @notice Processes the action for a given publication. This includes the action's logic and any monetary/token
     * operations.
     * @custom:permissions LensHub.
     *
     * @param processActionParams The parameters needed to execute the publication action.
     *
     * @return bytes Any custom ABI-encoded data. This will be a LensHub event params that can be used by
     * indexers or UIs.
     */
    function processPublicationAction(
        Types.ProcessActionParams calldata processActionParams
    ) external returns (bytes memory);
}

Here are the types involved in the interface above:

/**
 * @notice An enum specifically used in a helper function to easily retrieve the publication type for integrations.
 *
 * @param Nonexistent An indicator showing the queried publication does not exist.
 * @param Post A standard post, having an URI, action modules and no pointer to another publication.
 * @param Comment A comment, having an URI, action modules and a pointer to another publication.
 * @param Mirror A mirror, having a pointer to another publication, but no URI or action modules.
 * @param Quote A quote, having an URI, action modules, and a pointer to another publication.
 */		
    enum PublicationType {
    Nonexistent,
    Post,
    Comment,
    Mirror,
    Quote
}

/**
 * @notice A struct containing the parameters required for the execution of Publication Action via `act()` function.
 *
 * @param publicationActedProfileId publisher of the publication that is being acted on
 * @param publicationActedId ID of the publication that is being acted on
 * @param actorProfileId profile of the user who is acting on the publication
 * @param actorProfileOwner owner of the profile of the user who is acting on the publication
 * @param transactionExecutor address that is executing the transaction
 * @param referrerProfileIds profile ids array of the referrer chain
 * @param referrerPubIds publication ids array of the referrer chain
 * @param referrerPubTypes publication types array of the referrer chain
 * @param actionModuleData arbitrary data to pass to the actionModule if needed
 */
struct ProcessActionParams {
    uint256 publicationActedProfileId;
    uint256 publicationActedId;
    uint256 actorProfileId;
    address actorProfileOwner;
    address transactionExecutor;
    uint256[] referrerProfileIds;
    uint256[] referrerPubIds;
    Types.PublicationType[] referrerPubTypes;
    bytes actionModuleData;
}

The interface has just two functions, let’s see what is the purpose of each of them:

  • initializePublicationAction is called when the publication using this action is being published, its purpose is to initialize any state that the publication action module may require.
  • processPublicationAction is called when the action is triggered by some profile, it purpose is to execute the action itself, so it should contain the logic of it.

Take into account that all the core validations like existence of the publication, existence of the actor profile, and many more, are done by the LensHub contract, right before delegating the execution to the publication action module contract.

Let’s use again the tipping as an example and build a publication action for it.

Building tipping publication action as example

The following action allows any user to enable tipping on their publication specifying a custom receiver of the funds, which permits redirecting all the revenue to some person, organization, social cause, charity, etc.

Implementing the initializePublicationAction function

This function will decode the tip receiver address from the custom initialization data, and will store it associated to the publication being initialized. The LensHub contract will emit an event, each time a publication is published, which contains all the information to reconstruct the publication action initialization state off-chain.

function initializePublicationAction(
    uint256 profileId,
    uint256 pubId,
    address /* transactionExecutor */,
    bytes calldata data
) external override onlyHub returns (bytes memory) {
    address tipReceiver = abi.decode(data, (address));

    _tipReceivers[profileId][pubId] = tipReceiver;

    return data;
}

Implementing the processPublicationAction function

This function will decode the currency and tip amount from the custom data included in the action execution parameters.

function processPublicationAction(
    Types.ProcessActionParams calldata params
) external override onlyHub returns (bytes memory) {
    (address currency, uint96 tipAmount) = abi.decode(params.actionModuleData, (address, uint96));
    
		// ...

Then, it will check that the currency is whitelisted, as well as that the tip amount is not zero.

		// ...

		if (!MODULE_GLOBALS.isCurrencyWhitelisted(currency)) {
				revert CurrencyNotWhitelisted();
		}

		if (tipAmount == 0) {
		    revert TipAmountCannotBeZero();
		}

		// ...

And finally, it will get the receiver address associated with the publication where the action is performed on, and execute the funds transfer.

		// ...

		address tipReceiver = _tipReceivers[params.publicationActedProfileId][params.publicationActedId];

		IERC20(currency).transferFrom(
		    params.transactionExecutor,
		    tipReceiver,
		    tipAmount
		);

		return abi.encode(tipReceiver, currency, tipAmount);
}

Here the complete function implementation:

function processPublicationAction(
    Types.ProcessActionParams calldata params
) external override onlyHub returns (bytes memory) {
    (address currency, uint96 tipAmount) = abi.decode(params.actionModuleData, (address, uint96));
    
		if (!MODULE_GLOBALS.isCurrencyWhitelisted(currency)) {
				revert CurrencyNotWhitelisted();
		}

    if (tipAmount == 0) {
        revert TipAmountCannotBeZero();
    }

    address tipReceiver = _tipReceivers[params.publicationActedProfileId][params.publicationActedId];

    IERC20(currency).transferFrom(
        params.transactionExecutor,
        tipReceiver,
        tipAmount
    );

    return abi.encode(tipReceiver, currency, tipAmount);
}

Tip action - Full contract

Here is how the final contract will look like:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.18;

import {HubRestricted} from 'contracts/base/HubRestricted.sol';
import {Types} from 'contracts/libraries/constants/Types.sol';
import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol';
import {IPublicationActionModule} from 'contracts/interfaces/IPublicationActionModule.sol';
import {LensModule} from 'contracts/modules/LensModule.sol';

import {IModuleGlobals} from 'contracts/interfaces/IModuleGlobals.sol';

contract TipPublicationAction is LensModule, HubRestricted, IPublicationActionModule {
    mapping(uint256 profileId => mapping(uint256 pubId => address tipReceiver)) internal _tipReceivers;

		error CurrencyNotWhitelisted();
    error TipAmountCannotBeZero();

		IModuleGlobals public immutable MODULE_GLOBALS;

    constructor(address hub, address moduleGlobals) HubRestricted(hub) {
				MODULE_GLOBALS = IModuleGlobals(moduleGlobals);
		}

  	function supportsInterface(bytes4 interfaceID) public pure override returns (bool) {
    	return interfaceID == type(IPublicationActionModule).interfaceId || super.supportsInterface(interfaceID);
 		}

    function initializePublicationAction(
        uint256 profileId,
        uint256 pubId,
        address /* transactionExecutor */,
        bytes calldata data
    ) external override onlyHub returns (bytes memory) {
        address tipReceiver = abi.decode(data, (address));

        _tipReceivers[profileId][pubId] = tipReceiver;

        return data;
    }

    function processPublicationAction(
        Types.ProcessActionParams calldata params
    ) external override onlyHub returns (bytes memory) {
        (address currency, uint96 tipAmount) = abi.decode(params.actionModuleData, (address, uint96));
        
				if (!MODULE_GLOBALS.isCurrencyWhitelisted(currency)) {
						revert CurrencyNotWhitelisted();
				}

        if (tipAmount == 0) {
            revert TipAmountCannotBeZero();
        }

        address tipReceiver = _tipReceivers[params.publicationActedProfileId][params.publicationActedId];

        IERC20(currency).transferFrom(
            params.transactionExecutor,
            tipReceiver,
            tipAmount
        );

        return abi.encode(tipReceiver, currency, tipAmount);
    }
  
    function getModuleMetadataURI() external view returns (string memory) {
        return 'yourModuleMetadataUriHere';
    }
}

Make sure you follow the permissions model

❗️

THIS IS CRITICAL: Don't forget about onlyHub modifier!

The functions of the Publication Action module are assumed to be called ONLY by the LensHub contract, which is the entry point of every protocol interaction.

For that, we provide a convenient HubRestricted base contract that includes the onlyHub modifier, which restricts the call to the LensHub address passed on the constructor.

Also, any currency used needs to be whitelisted. For this, the ModuleGlobals contract should be queried.

Put the correct metadata in your metadata getter

Follow the instructions at Module Metadata Standard and Registering a Module for more information about Metadata, so you can Register your module without any problems.