Lens protocol 合约代码浅析

Lens protocol 是 Aave 团队出品的 SocialFi 项目,我们今天结合它的文档来聊聊它的业务逻辑和合约代码。这篇文章主要着重于对大家看 Lens 代码的一个引导,不会详细解读代码的每一个点,主要是方便大家在看完文章后能对代码更熟悉一些,更快地理解代码。

代码分析

在大体业务上来说,Lens 的业务逻辑有些类似于 Twitter,主要包括了下面几个方面:

  • Profile,一个地址可以拥有多个 Profile,创建一个 Profile 就可以拥有一个对应 NFT,类似于一个人可以拥有多个账户,可以指定默认 Profile

  • Publication,发表内容

  • Comment,对内容发表评论,也可以对评论发表评论

  • Mirror,类似于转发功能

  • Collect,将内容铸造为 NFT

  • Follow,关注其他 Profile,关注后可以获得 FollowNFT

相对推特来说,主要就是多了 Collect 功能,接下来我们分功能来看看各自的主要逻辑。

用户的主要操作逻辑入口都是在 LensHub 合约中,其中也包含了许多 setter 方法。主要实现逻辑是在 PublishingLogicInteractionLogic 库合约中。

Profile

创建 Profile

function createProfile(DataTypes.CreateProfileData calldata vars)
    external
    override
    whenNotPaused
    returns (uint256)
{
    // 白名单中的用户才能创建
    if (!_profileCreatorWhitelisted[msg.sender]) 
        revert Errors.ProfileCreatorNotWhitelisted();
    unchecked {
        // ++ 表示从 1 开始
        uint256 profileId = ++_profileCounter;
        _mint(vars.to, profileId);
        PublishingLogic.createProfile(
            vars,
            profileId,
            _profileIdByHandleHash,
            _profileById,
            _followModuleWhitelisted
        );
        return profileId;
    }
}

创建 profile,只有白名单中的地址可以创建,这是为了防止用户名被恶意占领。每次创建一个 profile 会获得一个 NFT。

Profiles can only be minted by addresses that have been whitelisted by governance. This ensures that, given the low-fee environment present on Polygon, the namespace is not reserved by squatters.

我们来看看入参 CreateProfileData 的结构:

struct CreateProfileData {
    // 接收 profile 的地址
    address to;
    // 可以理解为用户名
    string handle;
    // 头像地址
    string imageURI;
    address followModule;
    bytes followModuleInitData;
    string followNFTURI;
}

前面的几个都是一些比较基础的数据字段,后面的几个 follow 字段下文再解释。

下面是创建 profile 的主要逻辑:

function createProfile(
    DataTypes.CreateProfileData calldata vars,
    uint256 profileId,
    mapping(bytes32 => uint256) storage _profileIdByHandleHash,
    mapping(uint256 => DataTypes.ProfileStruct) storage _profileById,
    mapping(address => bool) storage _followModuleWhitelisted
) external {
    // 校验 handle 的内容,只在创建 profile 的时候校验
    _validateHandle(vars.handle);

    // 要求 imageURI 的长度不能大于限制
    if (bytes(vars.imageURI).length > Constants.MAX_PROFILE_IMAGE_URI_LENGTH)
        revert Errors.ProfileImageURILengthInvalid();

    bytes32 handleHash = keccak256(bytes(vars.handle));

    // handle 必须唯一
    if (_profileIdByHandleHash[handleHash] != 0) revert Errors.HandleTaken();

    // handle hash 对应 profileId
    _profileIdByHandleHash[handleHash] = profileId;

    // 存储 profileId 对应的数据,handle,imageURI,followNFTURI
    _profileById[profileId].handle = vars.handle;
    _profileById[profileId].imageURI = vars.imageURI;
    _profileById[profileId].followNFTURI = vars.followNFTURI;

    bytes memory followModuleReturnData;
    if (vars.followModule != address(0)) {
        _profileById[profileId].followModule = vars.followModule;
        followModuleReturnData = _initFollowModule(
            profileId,
            vars.followModule,
            vars.followModuleInitData,
            _followModuleWhitelisted
        );
    }

    _emitProfileCreated(profileId, vars, followModuleReturnData);
}

代码相对也比较好理解,最后的 _initFollowModule 调用我们同样放在后面再讲解,大家记住这里出现过就行。

这里有一个小细节,方法入参中有 mapping 类型,这是只有在 library 库合约中的方法才能实现的,普通的合约方法不能接收 mapping 类型的参数。

Publication

发表内容

function post(DataTypes.PostData calldata vars)
    external
    override
    whenPublishingEnabled
    returns (uint256)
{
    // dispatcher 可以替 owner 为其 post
    _validateCallerIsProfileOwnerOrDispatcher(vars.profileId);
    return
        _createPost(
            vars.profileId,
            vars.contentURI,
            vars.collectModule,
            vars.collectModuleInitData,
            vars.referenceModule,
            vars.referenceModuleInitData
        );
}

_validateCallerIsProfileOwnerOrDispatcher 是校验只有用户本人或者用户的 dispatcher 可以调用,类似于 ERC721 中的 operator 角色:

function _validateCallerIsProfileOwnerOrDispatcher(uint256 profileId) internal view {
    if (msg.sender == ownerOf(profileId) || msg.sender == _dispatcherByProfile[profileId]) {
        return;
    }
    revert Errors.NotProfileOwnerOrDispatcher();
}

我们来看看入参 PostData 的结构:

struct PostData {
    uint256 profileId;
    // 存储内容的 URI
    string contentURI;
    address collectModule;
    bytes collectModuleInitData;
    address referenceModule;
    bytes referenceModuleInitData;
}

后面的几个 collect 和 reference 相关内容我们下文再说。

function _createPost(
    uint256 profileId,
    string memory contentURI,
    address collectModule,
    bytes memory collectModuleData,
    address referenceModule,
    bytes memory referenceModuleData
) internal returns (uint256) {
    unchecked {
        // 更新发布数量
        uint256 pubId = ++_profileById[profileId].pubCount;
        PublishingLogic.createPost(
            profileId,
            contentURI,
            collectModule,
            collectModuleData,
            referenceModule,
            referenceModuleData,
            pubId,
            _pubByIdByProfile,
            _collectModuleWhitelisted,
            _referenceModuleWhitelisted
        );
        return pubId;
    }
}

_createPost 方法仅仅更新了发布数量就进入了库合约中的逻辑:

function createPost(
    uint256 profileId,
    string memory contentURI,
    address collectModule,
    bytes memory collectModuleInitData,
    address referenceModule,
    bytes memory referenceModuleInitData,
    uint256 pubId,
    mapping(uint256 => mapping(uint256 => DataTypes.PublicationStruct))
        storage _pubByIdByProfile,
    mapping(address => bool) storage _collectModuleWhitelisted,
    mapping(address => bool) storage _referenceModuleWhitelisted
) external {
    _pubByIdByProfile[profileId][pubId].contentURI = contentURI;

    // 新建 post 的时候需要初始化 collet module 和 reference module
    // Collect module initialization
    bytes memory collectModuleReturnData = _initPubCollectModule(
        profileId,
        pubId,
        collectModule,
        collectModuleInitData,
        _pubByIdByProfile,
        _collectModuleWhitelisted
    );

    // Reference module initialization
    bytes memory referenceModuleReturnData = _initPubReferenceModule(
        profileId,
        pubId,
        referenceModule,
        referenceModuleInitData,
        _pubByIdByProfile,
        _referenceModuleWhitelisted
    );

    emit Events.PostCreated(
        profileId,
        pubId,
        contentURI,
        collectModule,
        collectModuleReturnData,
        referenceModule,
        referenceModuleReturnData,
        block.timestamp
    );
}

我们来看看 _pubByIdByProfile 的结构:

mapping(uint256 => mapping(uint256 => DataTypes.PublicationStruct)) internal _pubByIdByProfile;

它的结构是 profileIdpubIdPublicationStruct,其中 PublicationStruct 的结构是:

struct PublicationStruct {
    uint256 profileIdPointed;
    uint256 pubIdPointed;

    string contentURI;
    address referenceModule;
    address collectModule;
    address collectNFT;
}

前面两个 pointed 数据代表的是该内容指向的原始内容的 id,只有在当前内容为 Mirror 或者 Comment 类型的时候才有值。

两个 _init 方法我们下文再说。

代码中还有很多的 WithSig 结尾的方法,例如 postWithSig,这是利用 EIP-712 实现的利用签名可以让其他人代替发送交易的方法,不了解的朋友可以看看我的这篇文章

Comment

发表评论,它的逻辑与 Post 的逻辑很类似,我们主要来看看最核心的部分:

function createComment(
    DataTypes.CommentData memory vars,
    uint256 pubId,
    mapping(uint256 => DataTypes.ProfileStruct) storage _profileById,
    mapping(uint256 => mapping(uint256 => DataTypes.PublicationStruct))
        storage _pubByIdByProfile,
    mapping(address => bool) storage _collectModuleWhitelisted,
    mapping(address => bool) storage _referenceModuleWhitelisted
) external {
    // Validate existence of the pointed publication
    // 校验传入的数据是否正确
    uint256 pubCount = _profileById[vars.profileIdPointed].pubCount;
    if (pubCount < vars.pubIdPointed || vars.pubIdPointed == 0)
        revert Errors.PublicationDoesNotExist();

    // Ensure the pointed publication is not the comment being created
    // 不能指向自己的这条 comment
    if (vars.profileId == vars.profileIdPointed && vars.pubIdPointed == pubId)
        revert Errors.CannotCommentOnSelf();

    _pubByIdByProfile[vars.profileId][pubId].contentURI = vars.contentURI;
    _pubByIdByProfile[vars.profileId][pubId].profileIdPointed = vars.profileIdPointed;
    _pubByIdByProfile[vars.profileId][pubId].pubIdPointed = vars.pubIdPointed;

    // Collect Module Initialization
    bytes memory collectModuleReturnData = _initPubCollectModule(
        vars.profileId,
        pubId,
        vars.collectModule,
        vars.collectModuleInitData,
        _pubByIdByProfile,
        _collectModuleWhitelisted
    );

    // Reference module initialization
    bytes memory referenceModuleReturnData = _initPubReferenceModule(
        vars.profileId,
        pubId,
        vars.referenceModule,
        vars.referenceModuleInitData,
        _pubByIdByProfile,
        _referenceModuleWhitelisted
    );

    // Reference module validation
    address refModule = _pubByIdByProfile[vars.profileIdPointed][vars.pubIdPointed]
        .referenceModule;
    if (refModule != address(0)) {
        IReferenceModule(refModule).processComment(
            vars.profileId,
            vars.profileIdPointed,
            vars.pubIdPointed,
            vars.referenceModuleData
        );
    }

    // Prevents a stack too deep error
    _emitCommentCreated(vars, pubId, collectModuleReturnData, referenceModuleReturnData);
}

与 Post 相比,多了指定 profileIdPointedpubIdPointed 的部分,其指向的就是对应的原始内容 Id。还多了 processComment 的调用,我们下文再讲。

入参 CommentData 的结构:

struct CommentData {
    uint256 profileId;
    string contentURI;
    uint256 profileIdPointed;
    uint256 pubIdPointed;

    bytes referenceModuleData;
    address collectModule;
    bytes collectModuleInitData;
    address referenceModule;
    bytes referenceModuleInitData;
}

Mirror

转发,它的逻辑与前面也是大同小异:

function createMirror(
    DataTypes.MirrorData memory vars,
    uint256 pubId,
    mapping(uint256 => mapping(uint256 => DataTypes.PublicationStruct))
        storage _pubByIdByProfile,
    mapping(address => bool) storage _referenceModuleWhitelisted
) external {
    // 这里是获取最原始的 id,例如 mirror 另外一个 mirror,这里需要获得最原始的数据
    (uint256 rootProfileIdPointed, uint256 rootPubIdPointed, ) = Helpers.getPointedIfMirror(
        vars.profileIdPointed,
        vars.pubIdPointed,
        _pubByIdByProfile
    );

    _pubByIdByProfile[vars.profileId][pubId].profileIdPointed = rootProfileIdPointed;
    _pubByIdByProfile[vars.profileId][pubId].pubIdPointed = rootPubIdPointed;

    // Reference module initialization
    bytes memory referenceModuleReturnData = _initPubReferenceModule(
        vars.profileId,
        pubId,
        vars.referenceModule,
        vars.referenceModuleInitData,
        _pubByIdByProfile,
        _referenceModuleWhitelisted
    );

    // Reference module validation
    address refModule = _pubByIdByProfile[rootProfileIdPointed][rootPubIdPointed]
        .referenceModule;
    if (refModule != address(0)) {
        IReferenceModule(refModule).processMirror(
            vars.profileId,
            rootProfileIdPointed,
            rootPubIdPointed,
            vars.referenceModuleData
        );
    }

    emit Events.MirrorCreated(
        vars.profileId,
        pubId,
        rootProfileIdPointed,
        rootPubIdPointed,
        vars.referenceModuleData,
        vars.referenceModule,
        referenceModuleReturnData,
        block.timestamp
    );
}

多了 getPointedIfMirror 方法,用于获取最原始的 Id,例如 A 文章是原创,B Mirror 了 A,C 又 Mirror 了 B,那么这里不论 Mirror 了多少层,总是获得 A 的 Id。同时后面也多了 processMirror 方法的调用。

入参 MirrorData 的结构:

struct MirrorData {
    // 发表在哪个 profile 的名下
    uint256 profileId;
    // mirror 指向的 profile id
    uint256 profileIdPointed;
    // mirror 指向的 pub id
    uint256 pubIdPointed;
    
    bytes referenceModuleData;
    address referenceModule;
    bytes referenceModuleInitData;
}

Collect

Collect 是将内容铸造为 NFT 的过程,例如用户认为某个内容很好,想将其铸造为 NFT,可以类比为将喜爱的照片装裱的过程。

来看看代码:

function collect(
    address collector,
    uint256 profileId,
    uint256 pubId,
    bytes calldata collectModuleData,
    address collectNFTImpl,
    mapping(uint256 => mapping(uint256 => DataTypes.PublicationStruct))
        storage _pubByIdByProfile,
    mapping(uint256 => DataTypes.ProfileStruct) storage _profileById
) external returns (uint256) {
    // 这里是获取最原始的 pub 数据,包括 profileId, pubId,collectModule
    (uint256 rootProfileId, uint256 rootPubId, address rootCollectModule) = Helpers
        .getPointedIfMirror(profileId, pubId, _pubByIdByProfile);

    uint256 tokenId;
    // Avoids stack too deep
    {
        address collectNFT = _pubByIdByProfile[rootProfileId][rootPubId].collectNFT;
        if (collectNFT == address(0)) {
            // 如果是第一次 collect,则部署一个新的 collect
            collectNFT = _deployCollectNFT(
                rootProfileId,
                rootPubId,
                _profileById[rootProfileId].handle,
                collectNFTImpl
            );
            _pubByIdByProfile[rootProfileId][rootPubId].collectNFT = collectNFT;
        }
        // 第一次 collect 需要部署合约,后面的不需要,直接 mint 就行
        tokenId = ICollectNFT(collectNFT).mint(collector);
    }

    ICollectModule(rootCollectModule).processCollect(
        profileId,
        collector,
        rootProfileId,
        rootPubId,
        collectModuleData
    );
    _emitCollectedEvent(
        collector,
        profileId,
        pubId,
        rootProfileId,
        rootPubId,
        collectModuleData
    );

    return tokenId;
}

既然 collect 会铸造 NFT,那么肯定就有 mint NFT 的过程。我们在上面代码中看到,从 _pubByIdByProfile 中获取 collectNFT 的地址,如果为空,说明该内容是第一次被 collect,此时需要部署一个 collectNFT 的合约,如果不为空,则直接 mint 即可。部署合约这块运用了 EIP-1167 的内容,可以节省 Gas 费用,不了解的朋友可以看看我的这篇文章

processCollect 是执行 collect 的一些逻辑。我们前面涉及到 collectModule 的部分一直没讲,现在来看看这一块究竟是什么逻辑。实际上 collectModule 就是对 collect 过程的个性化定制模块。例如,内容创造者要求最多只能 collect 100 份,或者是他想要对 collect 进行收费,其他用户必须缴纳一定费用才能进行 collect。collectModule 就是用来实现这些多种多样的功能。目前 Lens 官方配置了下面几种 collect 配置:

  • FreeCollectModule,免费 collect

  • FeeCollectModule,用户需要支付一定量的费用(Token)才能 collect

  • TimedFeeCollectModule,用户只能在内容发布后的一段时间内进行 collect,且需要支付费用

  • LimitedFeeCollectModule,用户需要支付费用,并且有数量上限,例如最多只能 collect 500份

  • LimitedTimedFeeCollectModule,用户需要支付费用,并且有数量上限,同时也有时间限制

  • RevertCollectModule,任何情况下都不能 collect,否则交易失败

上面这几个,除了最后一个 RevertCollectModule,其他的都有一个先决条件是先判断是否只有 follower 才能 collect,这个配置是在用户创建 profile 的时候配置的,也就是说用户可以设置是否只有 follower 才能 collect,这个设置是在 followModule 中。

collectModule 主要有两个模块,initprocessinit 会初始化一些参数,例如如果用户使用的是 FeeCollectModule,那么 init 就会根据用户传入的参数指定 Token 的类型以及数量等,process 是执行 collect 时进行的逻辑,在这里就会执行转账操作。我们前面看到的 _initPubCollectModule 方法就属于 init 部分,用户会在创建 post 或者 comment 的时候调用 init,在 collect 的时候执行 process

Follow

Follow 顾名思义就是关注其他 profile,与 collect 相同的是 follow 时也会铸造 NFT,类似于粉丝拥有了一个明星的徽章。

function follow(
    address follower,
    uint256[] calldata profileIds,
    bytes[] calldata followModuleDatas,
    mapping(uint256 => DataTypes.ProfileStruct) storage _profileById,
    mapping(bytes32 => uint256) storage _profileIdByHandleHash
) external returns (uint256[] memory) {
    if (profileIds.length != followModuleDatas.length) revert Errors.ArrayMismatch();
    uint256[] memory tokenIds = new uint256[](profileIds.length);
    for (uint256 i = 0; i < profileIds.length; ) {
        string memory handle = _profileById[profileIds[i]].handle;
        if (_profileIdByHandleHash[keccak256(bytes(handle))] != profileIds[i])
            revert Errors.TokenDoesNotExist();

        address followModule = _profileById[profileIds[i]].followModule;
        address followNFT = _profileById[profileIds[i]].followNFT;

        if (followNFT == address(0)) {
            followNFT = _deployFollowNFT(profileIds[i]);
            _profileById[profileIds[i]].followNFT = followNFT;
        }

        tokenIds[i] = IFollowNFT(followNFT).mint(follower);

        if (followModule != address(0)) {
            IFollowModule(followModule).processFollow(
                follower,
                profileIds[i],
                followModuleDatas[i]
            );
        }
        unchecked {
            ++i;
        }
    }
    emit Events.Followed(follower, profileIds, followModuleDatas, block.timestamp);
    return tokenIds;
}

follow 方法支持批量 follow,因此接收的是一个 profileIds 的数组,遍历这一个数组,逐个进行 follow。同样的,如果是该 profile 第一次被 follow,则需要部署 NFT 合约,否则直接 mint 即可。

follow 与 collect 同样都支持定制化模块,follow 目前支持的模块如下:

  • ProfileFollowModule,正常 follow,没有限制

  • ApprovalFollowModule,只有授权过的地址可以 follow

  • FeeFollowModule,follow 需要付费

  • RevertFollowModule,不允许 follow,否则直接失败

followModule 同样包含 initprocess 两个模块。用户可以在创建 profile 的时候调用 init,即 _initFollowModule,也可以后期修改。在 follow 的时候执行 process

Reference

其实 reference 不算是一个额外的功能,它指的是 comment 或者 mirror 操作。但是它也有自己对应的 module,所以这里把它单独拿出来讲讲。在用户创建 postcommentmirror 的时候,可以指定当前创建的这个新 pub 在被 reference 时的要求。目前只支持一个模块:

  • FollowerOnlyReferenceModule,只有 follower 可以进行 reference

referenceModule 也是包含 initprocess 两个模块。在用户创建 postcommentmirror 的时候,会调用 init,即 _initPubReferenceModule。在用户进行 commentmirror 的时候会调用 process。注意前后两个 commentmirror 对应的不是一个对象,init 时是指定当该 pub 被 reference 时的要求,process 时是执行当前 reference 对象的要求。

总结

我们已经将 Lens 的主要逻辑代码看完,整体来说不算难,业务逻辑相对也比较简单,没有特别绕的逻辑。希望能对大家起一个代码导读的作用,接下来看代码会更加容易。

关于我

欢迎和我交流

参考

Subscribe to xyyme.eth
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.