📝

Arpeggiのスマートコントラクトを解読してみる

2023/02/02に公開

先日a16zから資金調達しているArpeggiというweb3版DAWを作っている企業を調べてみました。
https://open.substack.com/pub/chaichai/p/arpeggi?r=e2z8b&utm_campaign=post&utm_medium=web
その際スマートコントラクトの実装コードをGithubで公開していたので、自身の勉強も兼ねて内容を読み解いていきたいと思います。
以下のコードを参照しました。
https://github.com/ArpeggiLabs/contracts/blob/main/v1/contracts/AudioRelationshipProtocol.sol
Solidity初学者が書いているため、間違いを含んでいる可能性があります。ご指摘いただけると嬉しいです。

コントラクトの宣言

/// @title Audio Relationship Protocol (ARP) v1.0
/// @author Alec Papierniak <alec@arpeggi.io>, Kyle Dhillon <kyle@arpeggi.io>
/// @notice This composability protocol enables artists and dapps to register media to make it available for permissionless reuse with attribution.
contract AudioRelationshipProtocol is Initializable, AccessControlUpgradeable, PausableUpgradeable, UUPSUpgradeable, IAudioRelationshipProtocol {

Arpeggiでは、DAWで作成した音源をARP Protocol上にNFTとして保存することで他の人が著作権を気にする事なくサンプリングやリミックスを行えるようにしています。
このARPコントラクトではopenzeppelinからいくつかの機能をインポートしています。加えて型定義をIAudioRelationshipProtocol.solによって行っています。

import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

import "../interfaces/IAudioRelationshipProtocol.sol";

AccessControlUpgradeable

ユーザーのロールに応じて異なるアクセス権を付与・管理できるようにしています。
以下では四つのロールを定義しています。

  1. コントラクトをアップデートできる
  2. メディア(音源)を上書きできる
  3. メディアへの登録を一時停止・解除できる
  4. メディアを登録できる
/// @dev Role required to upgrade the ARP contract
    bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE");

    /// @dev Role required to overwrite existing media within ARP
    bytes32 public constant OVERWRITER_ROLE = keccak256("OVERWRITER_ROLE");

    /// @dev Role required to pause/unpause the ability to register media
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

    /// @dev Role required to register media while `_requireAuthorizedWriter` is enabled
    bytes32 public constant AUTHORIZED_WRITER_ROLE = keccak256("AUTHORIZED_WRITER_ROLE");

各種変数の宣言

    /// @notice Tracks the number of media registered within ARP
    /// @dev Initialized to 0
    uint256 public _numMedia;

    /// @notice A limit on the number of subcomponents allowed for registered media with ARP
    /// @dev Enforced when `_enforceMaxSubComponents` is true initialized to 1200
    uint256 public _maxSubComponents;

    /// @notice Current version of the ARP format
    /// @dev Initialized to 1
    uint256 public _version;

    /// @notice When true, cap the number of subcomponents allowed when registering media to ARP
    /// @dev Initialized to true
    bool public _enforceMaxSubComponents;

    /// @notice When true, require caller to have `AUTHORIZED_WRITER_ROLE` role when registering media to ARP
    /// @dev Initialized to true
    bool public _requireAuthorizedWriter;

subcomponentとは音源を作成するときに使用した素材(ARPに保存されている音源)を示しています。

/// @param subcomponents array of ARP IDs of subcomponents used in this media (e.g. list of samples used in a stem)

マッピング

    /// @notice Mapping to store all media registered within ARP
    /// @dev ARP Media ID => ARP Media
    mapping(uint256 => Media) public _media;

    /// @notice Mapping used to lookup ARP media by primary origin token details.
    /// @dev chainId => contract address => tokenId => mediaId
    mapping(uint256 => mapping(address => mapping(uint256 => uint256))) public _originTokenToMediaId;

MediaはIAudioRelationshipProtocol.solによって型定義がされています。
https://github.com/Arpeggi-Labs/contracts/blob/main/v1/interfaces/IAudioRelationshipProtocol.sol#L13-L21
dataUri
Arpeggiについて調べたとき、オンチェーン上で音源を保存していると書かれていたため各トークンに音声データも保存していると思っていたのですが、dataUriをみる限りIPFSなどにデータが保存されているのでしょうか?

metadataUri
音源の特徴(ジャンル、bpmなど)をメタデータとして保存しています。具体的な項目については以下を見てみてください。
https://nice-splash-d53.notion.site/ARP-Metadata-Schema-cb63cc22a9a24b0cb19ad852f400c153

chainId
トークンがどのネットワーク上にあるのかを識別するためのIDです。
(現在ArpeggiはPolygonとMunbaiで展開されていて、それぞれのchainIdは137, 80001です。)

イベントの発生

/// @notice Emitted when media is registered
    /// @param mediaId ARP Media ID of the newly registered media
    /// @param artistAddress Address of the artist for the newly registered media
    event MediaRegistered(uint256 indexed mediaId, address indexed artistAddress);

メディアがARPに登録されるとイベントが発火するようになっています。ここではMedia Idとメディアを登録したアーティストのアドレスを通知します。

初期化

    /// @notice OpenZeppelin initializer function
    function initialize() public initializer {
        __AccessControl_init();
        __Pausable_init();
        __UUPSUpgradeable_init();

        _grantRole(DEFAULT_ADMIN_ROLE, _msgSender());
        _grantRole(UPGRADER_ROLE, _msgSender());
        _grantRole(OVERWRITER_ROLE, _msgSender());
        _grantRole(PAUSER_ROLE, _msgSender());
        _grantRole(AUTHORIZED_WRITER_ROLE, _msgSender());

        _numMedia = 0;
        _maxSubComponents = 1200;
        _enforceMaxSubComponents = true;
        _version = 1;
        _requireAuthorizedWriter = true;
    }

_grandRoleによってコントラクトを呼び出したEOAに対して各種ロールを付与しています。
https://docs.openzeppelin.com/contracts/4.x/api/access#AccessControl-_grantRole-bytes32-address-

メディアの登録

function registerMedia(
        address artistAddress,
        string calldata dataUri,
        string calldata metadataUri,
        uint256[] calldata subcomponents,
        address originContractAddress,
        uint256 originTokenId,
        uint256 originChainId,
        uint8 originType
    ) external whenNotPaused returns (uint256) {
        if (_requireAuthorizedWriter) {
            require(hasRole(AUTHORIZED_WRITER_ROLE, _msgSender()), "ARP: Unauthorized write.");
        }

        if (_enforceMaxSubComponents) {
            require(subcomponents.length < _maxSubComponents, "ARP: Too many subcomponents.");
        }

        if (subcomponents.length > 0) {
            for (uint256 i = 0; i < subcomponents.length; i++) {
                require(subcomponents[i] <= _numMedia, "ARP: Invalid subcomponent.");
            }
        }
        _numMedia++;

        _media[_numMedia].mediaId = _numMedia;
        _media[_numMedia].version = _version;
        _media[_numMedia].artistAddress = artistAddress;
        _media[_numMedia].dataUri = dataUri;
        _media[_numMedia].metadataUri = metadataUri;

        if (subcomponents.length > 0) {
            _media[_numMedia].subcomponents = subcomponents;
        }

        if (originTokenId > 0 && originContractAddress != address(0)) {
            if (originChainId == block.chainid) {
                require(IERC721(originContractAddress).ownerOf(originTokenId) != address(0), "ARP: Origin token must exist.");
            }

            // only allow a single PRIMARY origin token type
            if (OriginType(originType) == OriginType.PRIMARY) {
                require(!primaryOriginTypeExists(originContractAddress, originTokenId, originChainId), "ARP: Primary origin already registered.");
            }

            _media[_numMedia].originToken.tokenId = originTokenId;
            _media[_numMedia].originToken.contractAddress = originContractAddress;
            _media[_numMedia].originToken.chainId = originChainId;
            _media[_numMedia].originToken.originType = OriginType(originType);
            _originTokenToMediaId[originChainId][originContractAddress][originTokenId] = _numMedia;
        }

        emit MediaRegistered(_numMedia, artistAddress);

        return _numMedia;
    }

whenNotePaused
メディアの登録が停止されていないかを確認します。
https://docs.openzeppelin.com/contracts/4.x/api/security#Pausable-whenNotPaused--

バリデーションチェック

        if (_requireAuthorizedWriter) {
            require(hasRole(AUTHORIZED_WRITER_ROLE, _msgSender()), "ARP: Unauthorized write.");
        }

        if (_enforceMaxSubComponents) {
            require(subcomponents.length < _maxSubComponents, "ARP: Too many subcomponents.");
        }

        if (subcomponents.length > 0) {
            for (uint256 i = 0; i < subcomponents.length; i++) {
                require(subcomponents[i] <= _numMedia, "ARP: Invalid subcomponent.");
            }
        }

三つのif文によってコントラクトを呼び出したEOAに対して以下の3点を確認しています。

  1. メディアの登録を許可されているか
  2. サブコンポーネント(使った音源、サンプル)が上限数を超えていないか?
  3. サブコンポーネントを一つ以上使っているか、存在するサブコンポーネントか

各種データの格納

        _media[_numMedia].mediaId = _numMedia;
        _media[_numMedia].version = _version;
        _media[_numMedia].artistAddress = artistAddress;
        _media[_numMedia].dataUri = dataUri;
        _media[_numMedia].metadataUri = metadataUri;

        if (subcomponents.length > 0) {
            _media[_numMedia].subcomponents = subcomponents;
        }

        if (originTokenId > 0 && originContractAddress != address(0)) {
            if (originChainId == block.chainid) {
                require(IERC721(originContractAddress).ownerOf(originTokenId) != address(0), "ARP: Origin token must exist.");
            }

            // only allow a single PRIMARY origin token type
            if (OriginType(originType) == OriginType.PRIMARY) {
                require(!primaryOriginTypeExists(originContractAddress, originTokenId, originChainId), "ARP: Primary origin already registered.");
            }

            _media[_numMedia].originToken.tokenId = originTokenId;
            _media[_numMedia].originToken.contractAddress = originContractAddress;
            _media[_numMedia].originToken.chainId = originChainId;
            _media[_numMedia].originToken.originType = OriginType(originType);
            _originTokenToMediaId[originChainId][originContractAddress][originTokenId] = _numMedia;
        }

個人的に難しかったところを詳しく見ていきます。

if (originChainId == block.chainid) {
  require(IERC721(originContractAddress).ownerOf(originTokenId) != address(0), "ARP: Origin token must exist.");
}

originTokenのオーナーアドレスがaddress(0)でないことを確かめています
https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC721/ERC721.sol#L70-L74

address(0) is the same as "0x0", an uninitialized address.
https://ethereum.stackexchange.com/questions/23224/what-address0-stands-forより


// only allow a single PRIMARY origin token type
if (OriginType(originType) == OriginType.PRIMARY) {
  require(!primaryOriginTypeExists(originContractAddress, originTokenId, originChainId), "ARP: Primary origin already registered.");
}

OriginTypeは型定義されていて音源がオリジナルで作成されたもの(サンプル)なのかARPの音源を使用した二次作品かを判別します。
https://github.com/Arpeggi-Labs/contracts/blob/main/v1/interfaces/IAudioRelationshipProtocol.sol#L39-L42
primaryOriginTypeExists関数は

    /// @notice Determine if media has already been registered as primary type for a given origin token
    /// @param contractAddress The origin token to check
    /// @param tokenId The ID of the origin token
    /// @param chainId The chain where the origin token contract resides
    /// @return true when primary has already been registered, false otherwise
    function primaryOriginTypeExists(
        address contractAddress,
        uint256 tokenId,
        uint256 chainId
    ) internal view returns (bool) {
        return _originTokenToMediaId[chainId][contractAddress][tokenId] != 0;
    }

つまりオリジナル(Primary)の音源が重複して登録されないように判別しているようです。

メディアの取得

    /// @notice Fetches ARP Media by ID
    /// @param index ARP Media ID of the requested media
    /// @return The ARP Media, if exists
    function getMedia(uint256 index) external view returns (Media memory) {
        require(index <= _numMedia, "Invalid index.");
        return _media[index];
    }

    /// @notice Fetches media by origin token details
    /// @param tokenId The ID of the origin token on the origin contract
    /// @param contractAddress The address of the origin contract
    /// @return The ARP media, if any exists
    function getMediaByOrigin(
        uint256 chainId,
        address contractAddress,
        uint256 tokenId
    ) external view returns (Media memory) {
        uint256 index = _originTokenToMediaId[chainId][contractAddress][tokenId];
        require(index > 0, "ARP: No media for origin data."); // problem with zero index
        return _media[index];
    }

メディアの上書き

    /// @notice Determine if caller should be allowed to overwrite existing ARP Media
    /// @dev Requires msg.sender to have `OVERWRITER_ROLE` role, or caller to be the artist of the target ARP Media
    /// @param chainId The chain on which the origin token resides
    /// @param contractAddress The contract for the origin token
    /// @param tokenId The ID of the origin token
    /// @return bool true if the caller is allowed to overwrite the record within ARP, otherwise revert
    function enforceOnlyOverwriteAuthorized(
        uint256 chainId,
        address contractAddress,
        uint256 tokenId
    ) internal view returns (bool) {
        // check if the caller has overwriter role
        if (hasRole(OVERWRITER_ROLE, msg.sender)) {
            return true;
        }

        // otherwise, only allow the artist to overwrite
        Media memory media = _media[_originTokenToMediaId[chainId][contractAddress][tokenId]];

        if (media.artistAddress == msg.sender) {
            return true;
        }

        revert("ARP: Forbidden overwrite.");
    }

二段階の確認をして上書きを許可しています。

  1. msg.sender(コントラクトを呼び出しているEOA)が上書きのロールを持っている
  2. 該当メディアが本人のものであるか検証

ERC165

/// @notice ERC165
function supportsInterface(bytes4 interfaceId) public view override(AccessControlUpgradeable) returns (bool) {
  return super.supportsInterface(interfaceId);
}

ERC165とは

スマートコントラクトがどのインタフェースを実装するかを公開し発見する標準的なメソッドを作成します。
ざっくり言うと、コントラクトがどんなインターフェースを実装しているのかを確認できるようにするためのインターフェースです。
https://qiita.com/shiki_tak/items/99785f509964d920485dより

こちらの記事が参考になりました。
https://zenn.dev/techstart/articles/f513724b0cb387

感想

大まかな流れは掴むことは出来ましたが、openzeppelinの機能について具体的な内容が理解しきれていなかったので、次回以降もう少し詳しく見ていこうと思います。

Discussion