xyyme.eth

Posted on Nov 25, 2022Read on Mirror.xyz

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 的主要逻辑代码看完,整体来说不算难,业务逻辑相对也比较简单,没有特别绕的逻辑。希望能对大家起一个代码导读的作用,接下来看代码会更加容易。

关于我

欢迎和我交流

参考

https://docs.lens.xyz/docs

Lens