はじめに
初めまして。
『DApps開発入門』という本や色々記事を書いているかるでねです。
以下でも情報発信しているので、興味ある記事があればぜひ読んでみてください!
今回は、NFT同士の参照関係(DAG)に基づいて、作成者・協力者・参照元NFTオーナーに段階的にロイヤリティを自動分配する仕組みを提案しているERC8034についてまとめていきます!
以下にまとめられているものを解説しながらまとめていきます。
他にも様々なEIP・BIP・SLIP・CAIP・ENSIP・RFC・ACPについてまとめています。
概要
ERC8034とは、ERC5521で導入された参照可能NFT(rNFT)のために設計された、独立型のロイヤリティ分配標準です。
ERC5521については以下の記事を参考にしてください。
従来のNFTロイヤリティ標準であるERC2981は、1つのNFTに対して1人の受取人しか指定できず、NFT同士が互いに参照し合う関係性を考慮していませんでした。
ERC2981については以下の記事を参考にしてください。
ERC8034は、この制約を解消するために設計されています。
rNFTは有向非巡回グラフ(DAG)と呼ばれるネットワーク構造を形成します。
あるNFTが別のNFTを「参照」し、その参照先がさらに別のNFTを参照するという連鎖が可能です。
この提案では、その参照関係に沿ってロイヤリティを段階的に分配します。
1つのNFTに対して複数の受取人(作成者や協力者)を指定でき、さらに参照先のNFTオーナーにも一定割合が自動的に流れる仕組みです。
以下の図は、rNFTが形成するDAG構造のイメージです。
この提案はERC2981とは独立して動作します。
トークン規格にも依存しませんが、実際にはERC5521のrNFTを対象としており、ERC721の所有権セマンティクスに基づいて動作します。
ERC721については以下の記事を参考にしてください。
動機
既存ロイヤリティ標準の限界
ERC2981は、NFTの二次販売時にロイヤリティを自動計算する標準として広く使われています。
しかし、NFTを1つの独立した作品として扱う設計になっており、以下の制約がありました。
受取人が1人に限定されるため、複数のクリエイターが共同制作したNFTでも、ロイヤリティの受け取り先を1つのアドレスにしか指定できません。
実際のクリエイティブワークでは、イラストレーター、音楽家、プログラマーなど複数の貢献者が関わることが一般的であり、彼ら全員に直接ロイヤリティを配分する手段がありませんでした。
さらに、NFT同士の関係性を考慮できないという根本的な限界があります。
ERC5521のrNFTでは、あるNFTが別のNFTを「参照」してDAG構造を形成します。
例えば、あるアート作品が先行する作品にインスピレーションを受けて制作された場合、その参照関係をオンチェーンで表現できます。
しかしERC2981には、この参照チェーンに沿ってロイヤリティを伝播させる仕組みがありません。
この提案による解決策
ERC8034は、以下の仕組みでこれらの課題を解決します。
1つのNFTに対して複数の受取人と個別のロイヤリティ比率を設定できます。
例えば「作成者に3%、協力者に2%」のように、貢献度に応じた配分が可能です。
参照関係に基づくロイヤリティ伝播の仕組みを導入しています。
NFTが参照しているNFTのオーナーにも、一定の割合(デフォルトで2%)が自動的に分配されます。
この伝播はreferenceDepthパラメータで制御され、無限にチェーンをたどることはなく、指定した深さで打ち切られます。
ロイヤリティの設定はオンチェーンに保存されるため、誰でも透明に確認できます。
また、EIP712の型付き署名を使ったオフチェーン設定にも対応しており、ガスコストを抑えながら柔軟な運用が可能です。
EIP712については以下の記事を参考にしてください。
ERC165のsupportsInterfaceを通じたインターフェース検出にも対応しているため、マーケットプレイスやウォレットがこの規格への対応を自動検出できます。
ERC165については以下の記事を参考にしてください。
以下の図は、ERC2981とERC8034の違いを視覚的にまとめたものです。
仕様
インターフェース定義
ERC8034のコア機能はIRNFTRoyaltyインターフェースとして定義されています。
このインターフェースはERC165を継承しており、supportsInterfaceによる検出が可能です。
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.0;
interface IRNFTRoyalty is ERC165 {
struct RoyaltyInfo {
address recipient;
uint256 royaltyAmount;
}
struct ReferenceRoyalty {
RoyaltyInfo[] royaltyInfos;
uint256 referenceDepth;
}
event ReferenceRoyaltiesPaid(
address indexed rNFTContract,
uint256 indexed tokenId,
address indexed buyer,
address marketplace,
ReferenceRoyalty royalties
);
function getReferenceRoyaltyInfo(
address rNFTContract,
uint256 tokenId,
uint256 salePrice
) external view returns (ReferenceRoyalty memory royalties);
function getReferenceRoyaltyInfo(
address rNFTContract,
uint256 tokenId
) external view returns (ReferenceRoyalty memory royalties);
function setReferenceRoyalty(
address rNFTContract,
uint256 tokenId,
address[] memory recipients,
uint256[] memory royaltyFractions,
uint256 referenceDepth
) external;
function setReferenceRoyalty(
address rNFTContract,
uint256 tokenId,
address[] memory recipients,
uint256[] memory royaltyFractions,
uint256 referenceDepth,
address signer,
uint256 deadline,
bytes calldata signature
) external;
function supportsReferenceRoyalties() external view returns (bool);
function royaltyNonce(
address signer,
address rNFTContract,
uint256 tokenId
) external view returns (uint256);
}
インターフェースは大きく3つの機能群で構成されています。
ロイヤリティの照会(getReferenceRoyaltyInfoの2つのオーバーロード)、ロイヤリティの設定(setReferenceRoyaltyの2つのオーバーロード)、そして機能検出(supportsReferenceRoyalties)です。
royaltyNonceは署名ベース設定のリプレイ防止に使用されるnonce値の照会関数です。
構造体
RoyaltyInfo
struct RoyaltyInfo {
address recipient;
uint256 royaltyAmount;
}
ロイヤリティの受取人とその金額を1組として表現する構造体です。
| フィールド | 型 | 説明 |
|---|---|---|
recipient |
address |
ロイヤリティを受け取るアドレス。作成者、協力者、または参照先NFTのオーナーアドレスが入る。 |
royaltyAmount |
uint256 |
ロイヤリティの金額。getReferenceRoyaltyInfoにsalePriceを渡した場合はwei単位の実金額、渡さなかった場合はbasis points(100 = 1%)で表現される。 |
royaltyAmountの単位が関数のオーバーロードによって変わる点がこの提案の設計上のポイントです。
販売価格を指定して呼び出すと、マーケットプレイスがそのまま送金に使える金額(wei)が返ります。
販売価格なしで呼び出すと、設定されているロイヤリティ比率(basis points)がそのまま返るため、ロイヤリティ設定の透明な確認に使えます。
ReferenceRoyalty
struct ReferenceRoyalty {
RoyaltyInfo[] royaltyInfos;
uint256 referenceDepth;
}
1つのNFTに関するロイヤリティ配分の全体像を表す構造体です。
| フィールド | 型 | 説明 |
|---|---|---|
royaltyInfos |
RoyaltyInfo[] |
ロイヤリティ受取人の配列。プライマリレベルの受取人(作成者・協力者)と参照先NFTのオーナーが含まれる。 |
referenceDepth |
uint256 |
DAG上でのロイヤリティ伝播の最大深さ。この値を超える深さの参照先にはロイヤリティが伝播しない。推奨上限は3。 |
royaltyInfos配列には、直接設定された受取人だけでなく、DAGをたどって計算された参照先のオーナーも含まれます。
つまり、この1つの構造体を読むだけで、あるNFTの売却時に誰にいくら支払うべきかの全体像がわかります。
関数
getReferenceRoyaltyInfo
この関数には2つのオーバーロードがあります。
まず、販売価格を渡すバージョンです。
function getReferenceRoyaltyInfo(
address rNFTContract,
uint256 tokenId,
uint256 salePrice
) external view returns (ReferenceRoyalty memory royalties);
マーケットプレイスがNFTの二次販売を処理する時に呼び出す関数です。
指定した販売価格に基づいて、各受取人への支払い額をwei単位で計算して返します。
例えば、販売価格が100 ETH(= 100,000,000,000,000,000,000 wei)で、プライマリロイヤリティが5%(作成者3%、協力者2%)の場合、作成者には3 ETH、協力者には2 ETHが計算されます。
さらに参照先NFTが存在しreferenceDepthが1以上であれば、参照先のオーナーへの分配額も含めた結果が返されます。
| パラメータ | 型 | 説明 |
|---|---|---|
rNFTContract |
address |
rNFTコントラクトのアドレス。ERC5521を実装しているコントラクトを指定する。 |
tokenId |
uint256 |
ロイヤリティを照会するNFTのトークンID。 |
salePrice |
uint256 |
NFTの販売価格(wei単位)。0を渡すと全受取人のロイヤリティ額が0で返される。 |
- 戻り値
ReferenceRoyalty 各受取人のアドレスとwei単位の支払い額、およびreferenceDepthを含む構造体。マーケットプレイスはこの情報に基づいて売上の一部を各受取人に送金する。
販売価格なしバージョン
次に、販売価格を渡さないバージョンです。
function getReferenceRoyaltyInfo(
address rNFTContract,
uint256 tokenId
) external view returns (ReferenceRoyalty memory royalties);
ロイヤリティの設定内容を透明に確認するための関数です。
販売価格に依存せず、設定されているロイヤリティ比率をbasis points(100 = 1%)で返します。
この関数は、マーケットプレイスのUIでロイヤリティ情報を表示したり、購入前にロイヤリティ設定を確認する時に利用します。
実際の支払い額を計算する時は、販売価格ありのオーバーロードを使います。
| パラメータ | 型 | 説明 |
|---|---|---|
rNFTContract |
address |
rNFTコントラクトのアドレス。 |
tokenId |
uint256 |
ロイヤリティを照会するNFTのトークンID。 |
- 戻り値
ReferenceRoyalty 各受取人のアドレスとbasis points単位のロイヤリティ比率を含む構造体。ロイヤリティが未設定の場合は空の配列が返る。
setReferenceRoyalty
この関数にも2つのオーバーロードがあります。
まず、直接オンチェーンで設定するバージョンです。
function setReferenceRoyalty(
address rNFTContract,
uint256 tokenId,
address[] memory recipients,
uint256[] memory royaltyFractions,
uint256 referenceDepth
) external;
rNFTのロイヤリティ設定を直接オンチェーンで行う関数です。
CONFIGURATOR_ROLEを持つアドレスのみが呼び出せます。
| パラメータ | 型 | 説明 |
|---|---|---|
rNFTContract |
address |
対象のrNFTコントラクトアドレス。ゼロアドレスは不可。 |
tokenId |
uint256 |
ロイヤリティを設定するトークンID。 |
recipients |
address[] |
プライマリレベルのロイヤリティ受取人アドレスの配列。最大64件まで。各アドレスはゼロアドレス不可。 |
royaltyFractions |
uint256[] |
各受取人のロイヤリティ比率(basis points)の配列。recipientsと同じ長さが必要。合計値は1000 bps(10%)以下に制限される。 |
referenceDepth |
uint256 |
DAGをたどるロイヤリティ伝播の最大深さ。3以下に制限される(推奨)。 |
例えば、作成者に300 bps(3%)、協力者に200 bps(2%)、参照深度2を設定する場合、recipientsには2つのアドレス、royaltyFractionsには[300, 200]、referenceDepthには2を渡します。
署名ベースバージョン
次に、EIP712署名を使ったオフチェーン承認バージョンです。
function setReferenceRoyalty(
address rNFTContract,
uint256 tokenId,
address[] memory recipients,
uint256[] memory royaltyFractions,
uint256 referenceDepth,
address signer,
uint256 deadline,
bytes calldata signature
) external;
EIP712の型付き構造化データ署名を使って、オフチェーンで承認されたロイヤリティ設定を適用する関数です。
リレイヤー(代理実行者)がトランザクションを送信できるため、署名者自身がガス代を支払う必要がありません。
| パラメータ | 型 | 説明 |
|---|---|---|
rNFTContract |
address |
対象のrNFTコントラクトアドレス。 |
tokenId |
uint256 |
ロイヤリティを設定するトークンID。 |
recipients |
address[] |
プライマリレベルのロイヤリティ受取人アドレスの配列。 |
royaltyFractions |
uint256[] |
各受取人のロイヤリティ比率(basis points)の配列。 |
referenceDepth |
uint256 |
DAGをたどるロイヤリティ伝播の最大深さ。 |
signer |
address |
署名したアドレス。CONFIGURATOR_ROLEを持つか、対象トークンのownerOfである必要がある。 |
deadline |
uint256 |
署名の有効期限(UNIXタイムスタンプ)。block.timestampがこの値を超えると署名は拒否される。 |
signature |
bytes |
EIP712準拠の署名データ。 |
署名のリプレイ攻撃を防止するため、署名者ごと・rNFTコントラクトごと・トークンIDごとにnonceが管理されます。
署名検証に成功するとnonceがインクリメントされ、同じ署名を再利用できなくなります。
以下の図は、署名ベース設定の全体的なシーケンスを示しています。
署名に使用する型付きデータ
SetReferenceRoyalty(
address rNFTContract,
uint256 tokenId,
bytes32 recipientsHash,
bytes32 royaltyFractionsHash,
uint256 referenceDepth,
address signer,
uint256 deadline,
uint256 nonce
)
recipientsHashとroyaltyFractionsHashは、それぞれkeccak256(abi.encode(recipients))とkeccak256(abi.encode(royaltyFractions))で計算されたハッシュ値です。
配列をハッシュ化することで、署名データのサイズを一定に保ちつつ、改ざんを検出できるようにしています。
EIP712ドメインには以下の値が推奨されています。
| フィールド | 値 |
|---|---|
name |
"RNFTRoyalty" |
version |
"2" |
chainId |
デプロイ先チェーンのID |
verifyingContract |
ロイヤリティコントラクト自身のアドレス |
supportsReferenceRoyalties
function supportsReferenceRoyalties() external view returns (bool);
コントラクトがこの提案を実装しているかどうかを返すシンプルな関数です。
常にtrueを返します。
ただし、機能検出はERC165のsupportsInterfaceに依存する設計となっています。
royaltyNonce
function royaltyNonce(
address signer,
address rNFTContract,
uint256 tokenId
) external view returns (uint256);
署名ベース設定で使用されるnonceの現在値を照会する関数です。
オフチェーンで署名を作成する時に、この関数でnonceを確認してから署名データに含めます。
| パラメータ | 型 | 説明 |
|---|---|---|
signer |
address |
nonceを照会する署名者アドレス。 |
rNFTContract |
address |
対象のrNFTコントラクトアドレス。 |
tokenId |
uint256 |
対象のトークンID。 |
- 戻り値
uint256 現在のnonce値。署名ベースのsetReferenceRoyaltyが成功するたびに1ずつインクリメントされる。
イベント
ReferenceRoyaltiesPaid
event ReferenceRoyaltiesPaid(
address indexed rNFTContract,
uint256 indexed tokenId,
address indexed buyer,
address marketplace,
ReferenceRoyalty royalties
);
ロイヤリティが支払われた時に発行されるイベントです。
マーケットプレイスがrecordRoyaltyPayment関数を呼び出すことで記録されます。
| パラメータ | 型 | indexed | 説明 |
|---|---|---|---|
rNFTContract |
address |
yes | ロイヤリティ支払い対象のrNFTコントラクトアドレス。 |
tokenId |
uint256 |
yes | 売却されたNFTのトークンID。 |
buyer |
address |
yes | NFTの購入者アドレス。 |
marketplace |
address |
no | ロイヤリティ支払いを処理したマーケットプレイスのアドレス(msg.sender)。 |
royalties |
ReferenceRoyalty |
no | 支払われたロイヤリティの詳細。royaltyAmountはwei単位。 |
このイベントにより、ロイヤリティの支払い履歴がオンチェーンで追跡可能になります。
indexedパラメータでrNFTコントラクト、トークンID、購入者を指定しているため、特定のNFTや購入者に関するロイヤリティ履歴を効率的に検索できます。
ERC165対応
この提案を実装するコントラクトは、supportsInterfaceでtype(IRNFTRoyalty).interfaceIdに対してtrueを返す必要があります。
AccessControlなど他のインターフェースを継承している場合は、super.supportsInterface(interfaceId)で親コントラクトのインターフェース検出を転送する設計が推奨されています。
function supportsInterface(bytes4 interfaceId)
public
view
override(AccessControl, IERC165)
returns (bool)
{
return
interfaceId == type(IRNFTRoyalty).interfaceId ||
super.supportsInterface(interfaceId);
}
マーケットプレイスは、NFTの売却処理の前にこのインターフェースチェックを行うことで、rNFTロイヤリティへの対応を自動的に判断できます。
ロイヤリティ分配モデル
この提案のロイヤリティ分配は、「プライマリロイヤリティ」 と**「参照ロイヤリティ」** の2層構造になっています。
この2層構造こそが、ERC2981との最大の違いです。
プライマリロイヤリティ
NFTに直接設定された受取人(作成者や協力者)へのロイヤリティです。
setReferenceRoyaltyのrecipientsとroyaltyFractionsで設定します。
例えば、作成者に300 bps(3%)、協力者に200 bps(2%)のように、合計で最大1000 bps(10%)まで設定できます。
参照ロイヤリティ
DAG上で参照されているNFTのオーナーへ伝播するロイヤリティです。
各深さのホップで、REFERRED_ROYALTY_FRACTION(デフォルト200 bps = 2%)がプライマリロイヤリティから切り出され、その深さの参照先NFTに分配されます。
参照先が複数ある場合は、各NFTの設定済みロイヤリティウェイトに比例して分配されます。
すべてのウェイトが0の場合は均等分配になります。
上限とパラメータ
| パラメータ | 値 | 説明 |
|---|---|---|
MAX_ROYALTY_FRACTION |
1000 bps(10%) | プライマリレベルのロイヤリティ比率の合計上限。 |
REFERRED_ROYALTY_FRACTION |
200 bps(2%) | 各ホップで参照先に転送されるロイヤリティの割合。 |
referenceDepth |
最大3(推奨) | DAG上でロイヤリティが伝播する最大深さ。ガスコスト制御のため3以下に制限。 |
MAX_RECIPIENTS |
64 | 1つのNFTに設定できるプライマリ受取人の最大数。 |
MAX_CHAIN_STEPS |
32 | DAG走査時の最大ステップ数。無限ループ防止のための安全弁。 |
具体例
コントラクト0xABCのトークンID 1が100 ETHで売却された場合を考えます。
このNFTには以下の設定がされています。
- プライマリロイヤリティ合計5%(作成者3%、協力者2%)
-
referenceDepth= 2 - 深さ1に2つの参照先NFT(Token AとToken B、同じウェイト)
販売価格なし(basis points照会)の場合、getReferenceRoyaltyInfo(0xABC, 1)は以下を返します。
| 受取人 | royaltyAmount(bps) | 説明 |
|---|---|---|
| 作成者 | 300 | プライマリロイヤリティ3%分 |
| 協力者 | 200 | プライマリロイヤリティ2%分 |
| Token Aオーナー | 100 | 参照ロイヤリティ2%のうち半分 |
| Token Bオーナー | 100 | 参照ロイヤリティ2%のうち半分 |
販売価格100 ETHの場合、getReferenceRoyaltyInfo(0xABC, 1, 100 ether)は以下を返します。
| 受取人 | royaltyAmount(ETH) | 計算根拠 |
|---|---|---|
| 作成者 | 3 ETH | 100 ETH × 3% |
| 協力者 | 2 ETH | 100 ETH × 2% |
| Token Aオーナー | 1 ETH | 100 ETH × 2% ÷ 2 |
| Token Bオーナー | 1 ETH | 100 ETH × 2% ÷ 2 |
合計で7 ETH(7%)がロイヤリティとして分配されます。
プライマリレベルの5%に加えて、参照ロイヤリティ2%が参照先NFTのオーナーに流れる仕組みです。
Token CはreferenceDepth = 2で打ち切りとなるため、ロイヤリティを受け取りません。
補足
固定ロイヤリティ照会の意義
販売価格なしのgetReferenceRoyaltyInfoは、ロイヤリティの透明性を確保するために設計されています。
マーケットプレイスのUIで「このNFTのロイヤリティ設定」を表示する時や、購入前にロイヤリティ比率を確認する時に使えます。
販売価格に依存しないため、任意のタイミングで設定状況を照会できます。
複数受取人の設計思想
ERC2981が1対1のシンプルなモデルであるのに対し、この提案は1対多のモデルを採用しています。
これは、現実のクリエイティブワークが単独作業ではなく共同作業であることが多い点を反映しています。
RoyaltyInfoを配列で持つことで、プロジェクトの貢献者全員に直接ロイヤリティを配分できます。
単一深度制限の理由
referenceDepthを1つの値で管理する設計は、設定のシンプルさとガスコストの抑制を両立させるためです。
深さごとに異なる設定を持たせることも考えられますが、設定の複雑化とガスコストの増大を招きます。
推奨上限の3は、実用上十分な参照チェーンの深さでありながら、ガスリミットに収まる範囲として選ばれています。
独立型設計の利点
この提案はERC2981に依存しない独立した規格として設計されています。
ERC2981を既に実装しているコントラクトとの競合を避け、ERC5521のrNFT構造に特化したロイヤリティメカニズムを提供します。
マーケットプレイスはERC165でインターフェースを検出し、対応を判断できます。
自発的ロイヤリティ
ロイヤリティの支払いは強制ではなく自発的です。
これは現在のマーケットプレイスの実態に合わせた設計であり、ロイヤリティ設定の透明性を高めることで、マーケットプレイスが自主的にロイヤリティ配分を尊重する動機づけを行っています。
互換性
既存規格との関係
| 規格 | 関係 | 詳細 |
|---|---|---|
| ERC2981 | 独立 | この提案はERC2981に依存しない。両方を同時に実装することは可能だが、ロイヤリティ計算ロジックは完全に分離されている。 |
| ERC5521 | 前提 | rNFTのDAG構造(referringOf/referredOf)を利用してロイヤリティを伝播する。ERC5521を実装していないコントラクトでは参照ロイヤリティが機能しない。 |
| ERC721 | 間接的依存 |
ownerOfを使って参照先NFTのオーナーアドレスを取得する。ERC5521がERC721の上に構築されているため、間接的に依存している。 |
| EIP712 | オプション | 署名ベースの設定に使用。直接設定のみ使用する場合は不要。 |
| ERC165 | 必須 | インターフェース検出のためにsupportsInterfaceの実装が必要。 |
マーケットプレイス統合方法
マーケットプレイスがこの提案に対応する手順は以下の通りです。
-
ERC165の
supportsInterface(type(IRNFTRoyalty).interfaceId)でrNFTロイヤリティ対応を確認する - 対応している場合、
getReferenceRoyaltyInfo(販売価格あり)を呼び出してロイヤリティ額を計算する - 各受取人に計算された金額を送金する
-
recordRoyaltyPaymentを呼び出してオンチェーンにロイヤリティ支払いを記録する
オプションとして、署名ベースのsetReferenceRoyaltyを使えば、NFTオーナーがオフチェーンで署名したロイヤリティ設定をマーケットプレイスが代理で適用することも可能です。
参考実装
以下がERC8034の参考実装の全文です。
OpenZeppelinのAccessControl、EIP712、ECDSA、ReentrancyGuardを利用しています。
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/introspection/IERC165.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "./IRNFTRoyalty.sol";
interface IERC_5521 is IERC165 {
function setNode(uint256 tokenId, address[] memory addresses, uint256[][] memory tokenIds) external;
function referringOf(address _address, uint256 tokenId) external view returns (address[] memory, uint256[][] memory);
function referredOf(address _address, uint256 tokenId) external view returns (address[] memory, uint256[][] memory);
function supportsInterface(bytes4 interfaceId) external view returns (bool);
}
contract RNFTRoyalty is IRNFTRoyalty, AccessControl, EIP712, ReentrancyGuard {
using ECDSA for bytes32;
bytes32 public constant CONFIGURATOR_ROLE = keccak256("CONFIGURATOR_ROLE");
uint256 private constant MAX_ROYALTY_FRACTION = 1000;
uint256 private constant REFERRED_ROYALTY_FRACTION = 200;
uint256 private constant MAX_CHAIN_STEPS = 32;
uint256 private constant MAX_RECIPIENTS = 64;
mapping(address => mapping(uint256 => ReferenceRoyalty)) private _royalties;
event ReferenceRoyaltyConfigured(
address indexed rNFTContract,
uint256 indexed tokenId,
address indexed setter,
address[] recipients,
uint256[] royaltyFractions,
uint256 referenceDepth,
bool viaSignature
);
bytes32 private constant _SET_TYPEHASH =
keccak256("SetReferenceRoyalty(address rNFTContract,uint256 tokenId,bytes32 recipientsHash,bytes32 royaltyFractionsHash,uint256 referenceDepth,address signer,uint256 deadline,uint256 nonce)");
mapping(address => mapping(address => mapping(uint256 => uint256))) private _sigNonces;
constructor() EIP712("RNFTRoyalty", "2") {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(CONFIGURATOR_ROLE, msg.sender);
}
function royaltyNonce(address signer, address rNFTContract, uint256 tokenId)
external
view
returns (uint256)
{
return _sigNonces[signer][rNFTContract][tokenId];
}
function setReferenceRoyalty(
address rNFTContract,
uint256 tokenId,
address[] calldata recipients,
uint256[] calldata royaltyFractions,
uint256 referenceDepth
) external onlyRole(CONFIGURATOR_ROLE) {
_configureRoyalty(rNFTContract, tokenId, recipients, royaltyFractions, referenceDepth);
emit ReferenceRoyaltyConfigured(
rNFTContract, tokenId, msg.sender,
recipients, royaltyFractions, referenceDepth, false
);
}
function setReferenceRoyalty(
address rNFTContract,
uint256 tokenId,
address[] calldata recipients,
uint256[] calldata royaltyFractions,
uint256 referenceDepth,
address signer,
uint256 deadline,
bytes calldata signature
) external nonReentrant {
_checkParams(rNFTContract, recipients, royaltyFractions, referenceDepth);
bytes32 recipientsHash = keccak256(abi.encode(recipients));
bytes32 fractionsHash = keccak256(abi.encode(royaltyFractions));
require(signer != address(0), "Invalid signer");
uint256 nonce = _sigNonces[signer][rNFTContract][tokenId];
require(block.timestamp <= deadline, "Signature expired");
bytes32 structHash = keccak256(
abi.encode(
_SET_TYPEHASH,
rNFTContract, tokenId,
recipientsHash, fractionsHash,
referenceDepth, signer, deadline, nonce
)
);
bytes32 digest = _hashTypedDataV4(structHash);
address recovered = ECDSA.recover(digest, signature);
require(recovered == signer && signer != address(0), "Invalid signature");
bool authorized = hasRole(CONFIGURATOR_ROLE, signer);
if (!authorized) {
address owner = _safeOwnerOf(IERC721(rNFTContract), tokenId);
require(signer == owner, "Signer not authorized");
}
_sigNonces[signer][rNFTContract][tokenId] = nonce + 1;
_configureRoyalty(rNFTContract, tokenId, recipients, royaltyFractions, referenceDepth);
emit ReferenceRoyaltyConfigured(
rNFTContract, tokenId, signer,
recipients, royaltyFractions, referenceDepth, true
);
}
function getReferenceRoyaltyInfo(
address rNFTContract,
uint256 tokenId,
uint256 salePrice
) external view returns (ReferenceRoyalty memory royalties) {
royalties = _royalties[rNFTContract][tokenId];
if (salePrice == 0) {
uint256 len = royalties.royaltyInfos.length;
if (len == 0) return royalties;
RoyaltyInfo[] memory zeroed = new RoyaltyInfo[](len);
for (uint256 i = 0; i < len; i++) {
zeroed[i] = RoyaltyInfo(royalties.royaltyInfos[i].recipient, 0);
}
royalties.royaltyInfos = zeroed;
return royalties;
}
RoyaltyInfo[] memory chainRoyalties = _calculateChainRoyalties(rNFTContract, tokenId, salePrice);
royalties.royaltyInfos = chainRoyalties;
return royalties;
}
function getReferenceRoyaltyInfo(
address rNFTContract,
uint256 tokenId
) external view returns (ReferenceRoyalty memory royalties) {
royalties = _royalties[rNFTContract][tokenId];
if (royalties.royaltyInfos.length == 0) return royalties;
RoyaltyInfo[] memory bpsRoyalties = _calculateChainRoyalties(rNFTContract, tokenId, 0);
royalties.royaltyInfos = bpsRoyalties;
return royalties;
}
function supportsReferenceRoyalties() external pure returns (bool) {
return true;
}
function supportsInterface(bytes4 interfaceId)
public
view
override(AccessControl, IERC165)
returns (bool)
{
return
interfaceId == type(IRNFTRoyalty).interfaceId ||
super.supportsInterface(interfaceId);
}
function _checkParams(
address rNFTContract,
address[] calldata recipients,
uint256[] calldata royaltyFractions,
uint256 referenceDepth
) internal pure {
require(rNFTContract != address(0), "Invalid contract");
require(recipients.length == royaltyFractions.length, "Length mismatch");
require(recipients.length <= MAX_RECIPIENTS, "Too many recipients");
require(referenceDepth <= 3, "Depth too high");
for (uint256 i = 0; i < recipients.length; ++i) {
require(recipients[i] != address(0), "Zero recipient");
}
}
function _configureRoyalty(
address rNFTContract,
uint256 tokenId,
address[] calldata recipients,
uint256[] calldata royaltyFractions,
uint256 referenceDepth
) internal {
uint256 totalFraction = 0;
for (uint256 i = 0; i < royaltyFractions.length; i++) {
totalFraction += royaltyFractions[i];
}
require(totalFraction <= MAX_ROYALTY_FRACTION, "Royalty cap exceeded");
ReferenceRoyalty memory config;
config.referenceDepth = referenceDepth;
config.royaltyInfos = new RoyaltyInfo[](recipients.length);
for (uint256 i = 0; i < recipients.length; i++) {
config.royaltyInfos[i] = RoyaltyInfo(recipients[i], royaltyFractions[i]);
}
_royalties[rNFTContract][tokenId] = config;
}
function _safeOwnerOf(IERC721 rNFT, uint256 tokenId) internal view returns (address) {
address owner = rNFT.ownerOf(tokenId);
require(owner != address(0), "No owner");
return owner;
}
function _calculateChainRoyalties(
address rNFTContract,
uint256 tokenId,
uint256 salePrice
) internal view returns (RoyaltyInfo[] memory) {
ReferenceRoyalty memory currentRoyalty = _royalties[rNFTContract][tokenId];
if (currentRoyalty.royaltyInfos.length == 0) {
return new RoyaltyInfo[](0);
}
RoyaltyInfo[] memory staged = new RoyaltyInfo[](MAX_CHAIN_STEPS * 32 + 32);
uint256 count = 0;
uint256 totalShare = _sumShares(currentRoyalty);
(uint256 netPrimary, uint256 remainder) = _splitRoyalty(totalShare, salePrice, currentRoyalty.referenceDepth > 0);
count = _appendDistribution(staged, count, currentRoyalty, netPrimary);
if (remainder == 0) {
return _shrink(staged, count);
}
uint256 maxItems = MAX_CHAIN_STEPS * 32 + 32;
address[] memory curContracts = new address[](maxItems);
uint256[] memory curIds = new uint256[](maxItems);
uint256[] memory curAmts = new uint256[](maxItems);
uint256[] memory curDepths = new uint256[](maxItems);
uint256 curCount = 0;
if (remainder > 0 && currentRoyalty.referenceDepth > 0) {
curContracts[0] = rNFTContract;
curIds[0] = tokenId;
curAmts[0] = remainder;
curDepths[0] = currentRoyalty.referenceDepth;
curCount = 1;
}
address[] memory processedContracts = new address[](maxItems);
uint256[] memory processed = new uint256[](maxItems);
uint256 processedCount = 0;
while (curCount > 0) {
address[] memory nextContracts = new address[](maxItems);
uint256[] memory nextIds = new uint256[](maxItems);
uint256[] memory nextAmts = new uint256[](maxItems);
uint256[] memory nextDepths = new uint256[](maxItems);
uint256 nextCount = 0;
for (uint256 iL = 0; iL < curCount; iL++) {
address curContract = curContracts[iL];
uint256 curId = curIds[iL];
uint256 amt = curAmts[iL];
uint256 depth = curDepths[iL];
if (amt == 0) continue;
IERC_5521 curRNFT = IERC_5521(curContract);
IERC721 curRNFT721 = IERC721(curContract);
bool seen = false;
for (uint256 p = 0; p < processedCount; p++) {
if (processed[p] == curId && processedContracts[p] == curContract) {
seen = true;
break;
}
}
if (seen) {
address cycOwner = _safeOwnerOf(curRNFT721, curId);
staged[count++] = RoyaltyInfo(cycOwner, amt);
continue;
}
uint256 maxChildren = 32;
address[] memory childContracts = new address[](maxChildren);
uint256[] memory childIds = new uint256[](maxChildren);
uint256 children = _collectReferring(curRNFT, curContract, curId, childContracts, childIds);
if (depth == 0 || children == 0) {
address fallbackOwner = _safeOwnerOf(curRNFT721, curId);
staged[count++] = RoyaltyInfo(fallbackOwner, amt);
processedContracts[processedCount] = curContract;
processed[processedCount++] = curId;
continue;
}
uint256 keepBase = (amt * (10_000 - REFERRED_ROYALTY_FRACTION)) / 10_000;
uint256 passBase = amt - keepBase;
uint256[] memory childWeights = new uint256[](maxChildren);
ReferenceRoyalty[] memory childConfigs = new ReferenceRoyalty[](maxChildren);
uint256 sumWeights = 0;
for (uint256 j = 0; j < children; j++) {
address childContract = childContracts[j];
uint256 cid = childIds[j];
ReferenceRoyalty memory cfg = _royalties[childContract][cid];
childConfigs[j] = cfg;
if (cfg.royaltyInfos.length > 0) {
uint256 w = _sumShares(cfg);
childWeights[j] = w;
sumWeights += w;
}
}
if (sumWeights == 0) {
uint256 each = amt / children;
uint256 rem = amt - (each * children);
for (uint256 j = 0; j < children; j++) {
address ow = _safeOwnerOf(IERC721(childContracts[j]), childIds[j]);
uint256 share = each + (j == children - 1 ? rem : 0);
staged[count++] = RoyaltyInfo(ow, share);
}
processedContracts[processedCount] = curContract;
processed[processedCount++] = curId;
continue;
}
uint256 passDistributed = 0;
uint256 lastWeightedIdx = 0;
uint256[] memory keepShares = new uint256[](children);
uint256[] memory passShares = new uint256[](children);
for (uint256 j = 0; j < children; j++) {
if (childWeights[j] == 0) continue;
lastWeightedIdx = j;
uint256 kShare = (keepBase * childWeights[j]) / sumWeights;
uint256 pShare = (passBase * childWeights[j]) / sumWeights;
keepShares[j] = kShare;
passShares[j] = pShare;
passDistributed += pShare;
}
uint256 passRemainder = passBase - passDistributed;
if (passRemainder > 0) {
passShares[lastWeightedIdx] += passRemainder;
}
uint256 keepDistributed = 0;
for (uint256 j2 = 0; j2 < children; j2++) {
keepDistributed += keepShares[j2];
}
uint256 keepRemainder = keepBase - keepDistributed;
if (keepRemainder > 0) {
keepShares[lastWeightedIdx] += keepRemainder;
}
for (uint256 j = 0; j < children; j++) {
if (childWeights[j] == 0) continue;
uint256 kShare = keepShares[j];
uint256 pShare = passShares[j];
ReferenceRoyalty memory cfgj = childConfigs[j];
uint256 cid2 = childIds[j];
address childContract = childContracts[j];
uint256 nextDepth = depth > 0 ? depth - 1 : 0;
if (nextDepth == 0) {
count = _appendDistribution(staged, count, cfgj, kShare + pShare);
} else {
if (kShare > 0) {
count = _appendDistribution(staged, count, cfgj, kShare);
}
if (pShare > 0) {
bool merged = false;
for (uint256 nx = 0; nx < nextCount; nx++) {
if (nextIds[nx] == cid2 && nextContracts[nx] == childContract) {
nextAmts[nx] += pShare;
if (nextDepth > nextDepths[nx]) {
nextDepths[nx] = nextDepth;
}
merged = true;
break;
}
}
if (!merged) {
nextContracts[nextCount] = childContract;
nextIds[nextCount] = cid2;
nextAmts[nextCount] = pShare;
nextDepths[nextCount] = nextDepth;
nextCount++;
}
}
}
}
processedContracts[processedCount] = curContract;
processed[processedCount++] = curId;
}
for (uint256 k = 0; k < nextCount; k++) {
curContracts[k] = nextContracts[k];
curIds[k] = nextIds[k];
curAmts[k] = nextAmts[k];
curDepths[k] = nextDepths[k];
}
curCount = nextCount;
}
return _shrink(staged, count);
}
function _splitRoyalty(uint256 totalRate, uint256 salePrice, bool canPropagate)
internal
pure
returns (uint256 netPrimary, uint256 forwardedAmount)
{
if (totalRate == 0) {
return (0, 0);
}
if (!canPropagate) {
if (salePrice == 0) {
return (totalRate, 0);
}
return ((salePrice * totalRate) / 10_000, 0);
}
if (salePrice == 0) {
if (totalRate <= REFERRED_ROYALTY_FRACTION) {
return (0, totalRate);
}
return (totalRate - REFERRED_ROYALTY_FRACTION, REFERRED_ROYALTY_FRACTION);
}
uint256 gross = (salePrice * totalRate) / 10_000;
uint256 forwarded = (salePrice * REFERRED_ROYALTY_FRACTION) / 10_000;
if (forwarded > gross) {
forwarded = gross;
}
return (gross > forwarded ? gross - forwarded : 0, forwarded);
}
function _sumShares(ReferenceRoyalty memory config) internal pure returns (uint256 total) {
for (uint256 i = 0; i < config.royaltyInfos.length; i++) {
total += config.royaltyInfos[i].royaltyAmount;
}
}
function _collectReferring(
IERC_5521 rNFT,
address rNFTContract,
uint256 tokenId,
address[] memory childContracts,
uint256[] memory childIds
) internal view returns (uint256 childCount) {
(address[] memory refContracts, uint256[][] memory refTokenIds) =
rNFT.referringOf(rNFTContract, tokenId);
uint256 maxChildren = childIds.length;
uint256 listLen = refContracts.length;
if (refTokenIds.length < listLen) {
listLen = refTokenIds.length;
}
for (uint256 i = 0; i < listLen && childCount < maxChildren; i++) {
uint256[] memory ids = refTokenIds[i];
for (uint256 j = 0; j < ids.length && childCount < maxChildren; j++) {
childContracts[childCount] = refContracts[i];
childIds[childCount] = ids[j];
childCount++;
}
}
}
function _appendDistribution(
RoyaltyInfo[] memory staged,
uint256 count,
ReferenceRoyalty memory config,
uint256 amount
) internal pure returns (uint256) {
uint256 len = config.royaltyInfos.length;
if (len == 0) {
return count;
}
require(count + len <= staged.length, "royalty overflow");
if (amount == 0) {
for (uint256 i = 0; i < len; i++) {
staged[count++] = RoyaltyInfo(config.royaltyInfos[i].recipient, 0);
}
return count;
}
uint256 totalShare = _sumShares(config);
if (totalShare == 0) {
staged[count++] = RoyaltyInfo(config.royaltyInfos[0].recipient, amount);
for (uint256 i = 1; i < len; i++) {
staged[count++] = RoyaltyInfo(config.royaltyInfos[i].recipient, 0);
}
return count;
}
uint256 remaining = amount;
for (uint256 i = 0; i < len; i++) {
uint256 share = config.royaltyInfos[i].royaltyAmount;
if (share == 0) {
staged[count++] = RoyaltyInfo(config.royaltyInfos[i].recipient, 0);
continue;
}
uint256 portion = (amount * share) / totalShare;
if (portion > remaining) {
portion = remaining;
}
remaining -= portion;
staged[count++] = RoyaltyInfo(config.royaltyInfos[i].recipient, portion);
}
if (remaining > 0) {
staged[count - 1].royaltyAmount += remaining;
}
return count;
}
function _shrink(RoyaltyInfo[] memory staged, uint256 count)
internal
pure
returns (RoyaltyInfo[] memory out)
{
out = new RoyaltyInfo[](count);
for (uint256 i = 0; i < count; i++) {
out[i] = staged[i];
}
}
function recordRoyaltyPayment(
address rNFTContract,
uint256 tokenId,
address buyer,
ReferenceRoyalty memory royalties
) external {
emit ReferenceRoyaltiesPaid(rNFTContract, tokenId, buyer, msg.sender, royalties);
}
}
参考実装では、DAG走査にBFS(幅優先探索)を使用しています。
_calculateChainRoyalties関数が中核であり、プライマリロイヤリティの計算に加えて、referenceDepthに基づいて参照チェーンを段階的にたどりながら、各深さでの分配額を計算します。
実装のポイント
_splitRoyalty関数がプライマリロイヤリティと参照ロイヤリティの分離を行います。
プライマリロイヤリティの合計からREFERRED_ROYALTY_FRACTION(200 bps)を切り出して参照先に回し、残りをプライマリ受取人に分配します。
BFS走査時に同じNFTが複数経路から到達された場合は、金額がマージされます。
これにより、DAG構造での重複支払いが防止されます。
サイクル検出も実装されています。
既に処理済みのNFTに再到達した場合は、そのNFTのオーナーにフォールバックで金額が渡され、無限ループを回避します。
セキュリティ
アクセス制御
setReferenceRoyaltyの直接呼び出しはCONFIGURATOR_ROLEを持つアドレスに制限されています。
署名ベースの呼び出しでは、署名者がCONFIGURATOR_ROLEを持つか、対象トークンのownerOfであることが検証されます。
不正なアドレスがロイヤリティ設定を改ざんすることを防止しています。
プライマリロイヤリティ上限
プライマリレベルのroyaltyFractions合計は1000 bps(10%)に制限されています。
この制限により、過度なロイヤリティ設定によって売買の経済性が損なわれることを防いでいます。
参照ロイヤリティはREFERRED_ROYALTY_FRACTIONとreferenceDepthで別途制御されます。
ガスリミット対策
referenceDepthは3以下に制限されており、DAG走査のガスコストが予測可能な範囲に収まるように設計されています。
MAX_CHAIN_STEPS(32)とMAX_RECIPIENTS(64)もハードリミットとして設定されており、意図的に巨大なDAGを作成してガスを枯渇させる攻撃を防止しています。
署名リプレイ防止
EIP712署名にはnonceが含まれており、署名者ごと・rNFTコントラクトごと・トークンIDごとに管理されます。
署名の検証に成功するとnonceがインクリメントされるため、同じ署名を再利用することはできません。
さらにdeadlineパラメータにより、有効期限が切れた署名は拒否されます。
リエントランシー対策
署名ベースのsetReferenceRoyaltyにはnonReentrant修飾子が適用されています。
これは、悪意のあるrNFTコントラクトがownerOfの呼び出し時にリエントランシー攻撃を仕掛けることを防止するためです。
インターフェースシグナリング
supportsInterfaceの実装では、super.supportsInterface(interfaceId)を呼び出して親コントラクト(AccessControl等)のインターフェースも正しく転送する設計が推奨されています。
これにより、マーケットプレイスやウォレットがインターフェース検出を正確に行えます。
引用
最後に
今回は「ERC8034によるNFT参照関係に基づくロイヤリティ自動分配」についてまとめてきました。
いかがだったでしょうか?
ERC5521のrNFTが作るDAG構造を活用して、作成者・協力者・参照元の全員にロイヤリティが行き渡る仕組みは、NFTクリエイターエコノミーの新しい形を提案しています。
EIP712署名によるガス効率の良い設定や、透明なbasis points照会など、実用性を意識した設計も魅力的です。
質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!
他の媒体でも情報発信しているのでぜひ他も見ていってください!