Open Action | Rentable Billboard

Turn your post into a rentable billboard

ℹ️ This technical guide will go over our open action from a protocol perspective, and then with a full typescript example to initialize and process a post with the RentableSpaceAction

Smart Contract Overview

The RentableSpaceAction open action enables profiles to rent their publication space for advertising. Payment is done via ERC20 tokens on polygon such as BONSAI or WMATIC, and space is rented on a per second basis. A creator initializes a post with this action module and sets the token cost per second, the allowed category for ads (optional), and whether open actions can also be promoted. A profile that wants to act on this module must specify how long they wish to rent space for and approve the required payment; if a category was defined on init - provide the merkle proof that their content fits the allowed category. Funds are transferred and the content/open action from the pubId passed in are hot-swapped with this post's contentURI / open action. Clients that integrate this open action must fetch active ad content via the view function #getActiveAd.

 * @notice Get the active ad content and open action module (if any) for a given publication space
 * @param profileId The publication space profile id
 * @param profileId The publication space id
function getActiveAd(
  uint256 profileId, uint256 pubId
) external view returns (string memory contentUri, uint256 adProfileId, uint256 adPubId, address openActionModule);

How to initialize

To initialize a publication with this action module, the publication creator must supply in the encoded module init data

  • currency: the ERC20 token to use for payment

  • allowOpenAction: whether to allow open actions to also be promoted

  • expireAt: the expiry timestamp for when this post is accepting ads

  • clientFeePerActBps: the client fee % on any act, to incentivize clients to promote

  • referralFeePerActBps: the referral fee % on any act, to incentivize mirrors

  • interestMerkleRoot: [optional] a merkle tree root for the allowed interest; if set, actors must provide the proof that their profile / content is part of this tree. This will be provided by the MadFi API. More info on this down below in Advanced Configuration => Merkle Lists.

How to process

When a profile wishes to rent the space, they call act() via Lens Protocol, we expect a struct of type RentParams in the encoded action module data:

struct RentParams {
    uint256 adPubId; // [optional] the pub id to pull contentUri and action module for the ad
    uint256 duration; // the amount of time the advertiser wishes to pay for
    uint256 costPerSecond; // the amount the advertisers is willing to pay per second
    uint256 merkleProofIndex; // proof index the space's category merkle
    address clientAddress; // [optional] the whitelisted client address to receive fees
    address openActionModule; // [optional] the linked open action module
    string adContentUri; // [optional] if no pub id passed in, use this lens metadata uri
    bytes32[] merkleProof; // proof for the space's category merkle

By providing an existing adPubId, the advertiser is wishing to include their post (and the attached openActionModule) in their ad. Otherwise, they can just provide adContentUri which will be resolved to Lens content metadata.

An advertiser must query for the ad cost (including fees) for a rentable space, which also accounts for the markup when there's an active ad (more info on Advertiser Bidding below). They can query the contract to get the active space's currency.

struct ActiveSpace {
    uint256 spaceId; // the pub id initialized with this action module
    address currency; // accepted currency
    uint256 costPerSecond; // cost per second to rent the pub space (in wei)
    uint256 expireAt; // how long the pub space is active for (0 for no expiry)
    bytes32 interestMerkleRoot; // [optional] require advertisers to fit this interest category
    bool allowOpenAction; // allow advertisers to link their publication's open action as part of the ad
    uint16 clientFeePerActBps; // give clients a % of rented space fees, after protocol fee
    uint16 referralFeePerActBps; // give referrer profiles a % of rented space fees, after protocol/client fees

 * @notice Returns struct data for an active space
 * @param profileId The profile id of the post creator (of the rentable space)
function activeSpaces(uint256 profileId) public view returns (ActiveSpace space);

 * @notice Returns the ad cost, cost with fee, and cost per second for an active space (if it exists)
 * @param profileId The profile id of the post creator (of the rentable space)
 * @param duration The amount of seconds the advertiser is wishing to pay for ad space
function getAdCost(
    uint256 profileId,
    uint256 duration
) public view returns (uint256 cost, uint256 costWithFee, uint256 costPerSecond);

The return value to use from getAdCost() that must be approved to pay for ad space is costWithFee.

Replacing active spaces

A post creator can always replace their rentable billboard setting by creating a new publication with this open action. This effectively updates the cost, currency, and all other configuration.

It is still up to each client to choose which rentable billboard to feature on the feed / profile.

Advertiser Bidding

To allow for bidding on a rentable space, we allow advertisers to pay the costPerSecond times minBidIncreaseBps which is set to 20%. So advertisers wishing to replace an active ad must bid 20% more than the current active bid.

Replacing an ad refunds the previous advertiser from the contract.

Clients and referrers are paid automatically, and creators must call withdrawFeesEarned to claim their fees. Due to the refund mechanism, the creator of a rentable space can only withdraw fees after the time period for an active ad has passed.

 * @notice Allows a profile or profile manager to withdraw any fees earned, minus the owed amount for a given active ad, if any
 * NOTE: we accrue fees in the contract in order to handle refunds in case of ads being outbidded or canceled
function withdrawFeesEarned(
  address currency, uint256 profileId
) external onlyProfileOwnerOrDelegated(profileId);

A profile owner can always check their total fees earned, and claim that full amount after the ad on their rentable space has ended.

function feesEarned(
  address profileOwner, address currency
) external view returns (uint256);

Canceling an Active Ad

A creator may cancel an active ad on their space after the protocol-defined window of 12 hours; this refunds the advertiser for the amount of time their ad wasn't live.

We also have a flagging and blacklisting feature that allows a creator to cancel an active ad for the reasons defined in enum CancelAdReason:

enum CancelAdReason {
    BAD_ACTOR, // any bad activity
    EXIT, // to allow good faith canceling after `adMinDuration`

This flags the appropriate profile for clients to be aware of during the init / act process. A flagged profile may be blacklisted by the contract owner, which prohibits them from ever calling init / act again.

function cancelActiveAd(
    uint256 profileId,
    uint256 actorProfileId,
    CancelAdReason reason,
    string calldata otherReason,
    bool closeSpace
) external onlyProfileOwnerOrDelegated(profileId);

Client Integration

Here you can find two hardhat tasks (written in typescript) which outline the steps necessary to initialize a publication with the RentableSpaceAction module, as well as how to process it.

  1. create-post-rentable to init

  2. act-rent-billboard to process

The scripts make use of the metadata set in the Lens API, and send a raw transaction to the LensHub contract to post and to act.

The main things to consider are

  • On init, the post creator must set the open action module as a delegated executor (profile manager). This is to enable an auto-mirror whenever an advertiser acts on the post, for more visibility on the ad.

  • On act, the actor profile must approve the correct costWithFee by querying the getAdCost() function on the contract.

  • On act, the advertiser has two options 1) set a adContentUri value for the input struct to simply set their own metadata (following the Lens metadata standard) as the ad or 2) set their own adPubId and openActionModule in order to, for example, promote their own collectable post.

The full gist can be found here.

Advanced Configuration

Merkle Lists

This open action allows the post creator to set a merkle root on init, which requires actors to provide a merkle proof that their profile is in the specified merkle tree. For example, the post creator can limit advertisers to

  • only profiles that are in the list for memes, music, or art

  • only profiles with high reputation scores

  • only profiles that are in a custom, user-defined allowlist

Anyone can create or maintain their own merkle trees, and provide the necessary data to users for initializing and acting on this publication.

MadFi maintains an API that returns some of these merkle lists, and we'll show an example of how to init the open action with a list, and then act on the post with a profile that is part of this list.

Initializing the open action

  1. Make a GET request to

        "merkleRoots": [
                "root": "0xd9c33124af8e3435bd3b3ca2a3e551d6eb4d7f356d17594e715a3b6bb8abfe79",
                "description": "Profiles with Lens reputation score above 7500",
                "createdAt": 1716323168
  2. Take the root value from any of the items in the response array (in this case, 0xd9c....)

  3. Use this root in the encoded init data when initializing the open action (the last input param)

        root // "0xd9c...."

Now, anyone wishing to act on the publication must provide the proof data in the process act params. Also, when fetching the active space data from the open action contract, the struct data contains the associated interestMerkleRoot that it was initialized with.

Acting on the publication

If an active space has an interestMerkleRoot - actors must provide merkle proof data in their process act params.

  1. Make a GET request with the actor's profileId in the query params to For example, the request returns

        "merkleProofs": [
                "root": "0xd9c33124af8e3435bd3b3ca2a3e551d6eb4d7f356d17594e715a3b6bb8abfe79",
                "profileId": "0x01a6",
                "index": 163,
                "createdAt": 1716328941,
                "proof": [
                "description": "Profiles with Lens reputation score above 7500"
  2. Find the object with the same root as the active spaces's interestMerkleRoot and take the proof as well as the index

  3. Use the root and index in the encoded act params struct

        adPubId: data.adPubId || "0",
        duration: data.duration.toString(),
        costPerSecond: data.costPerSecond,
        clientAddress: data.clientAddress || constants.AddressZero,
        openActionModule: data.openActionModule || constants.AddressZero,
        adContentUri: data.adContentUri || "",
        merkleProof: proof,
        merkleProofIndex: index

As long as the actor has the same profileId and provides the correct merkle proof - the act will succeed and the ad will go live!

Last updated