まいにちDapps12日目はNFTのステーキングについて整理していきます。11日目ではオンチェーン・ガバナンスを題材にしましたが、少し関連性のある内容ですね。
NFTのステーキングとは何か、プロジェクト側にどんな狙いがあり、ユーザー側にどんな体験が提供されるか、考えてみましょう。
ステーキングとは何か
そもそもトークンのステーキングとは特定のトークンをプラットフォームやプロジェクトに一定期間「拘束」するプロセスです。
まず押さえておくべきはプルーフ・オブ・ステーク(PoS)またはその変種を使用するブロックチェーンネットワークのネイティブトークンのステーキングです。
ネットワークメンバー(バリデーター)は、ブロックを生成する権利を得るために、ネットワークのネイティブトークンを「ステーキング」(拘束)します。
ステーキングされたトークンの量や他の要因に基づいて、特定のバリデーターがブロックの提案と検証のために選ばれます。
選ばれたバリデーターは新しいブロックを提案し、他のバリデーターによって検証されます。正当なブロックが検証されると、ネットワークの一部として追加されます。
ブロックを提案し、正しく検証するバリデーターは報酬を受け取ります。この報酬は、通常、新しく発行されたネイティブトークンとトランザクション手数料の形で与えられます。
攻撃者がネットワークを攻撃するには、大量のトークンをステーキングする必要があります。これによって攻撃が非常に高コストとなりセキュリティが高まります。
(参考: https://hub.oasys.games/staking)
次にガバナンストークンやユーティリティトークンなどのERC20のステーキングを採用しているプロジェクトの狙いは、コミュニティメンバーにプラットフォーム上での投票権や意思決定への参加を促進し、ロイヤリティとエンゲージメントを高めます。また市場からトークンの供給量を減らすことで、トークン価格の安定化が図れます。
また中央集権取引所が行なっているステーキングは、ユーザーから資金を借りてそれを運用し運用益の一部はユーザーにステーキング報酬として還元して残りを取引所の利益としている、銀行の預金のようなビジネスモデルです。
(参考:https://www.sbivc.co.jp/services/staking)
ではNFTのステーキングについてですが、自分はあまり腹落ちしていないところもあります。
ステーキングに参加することでユーザー側に提供される体験として、ユーザーはしばしば報酬や特典を受け取り、プロジェクトへのより深い関与を通じてコミュニティの一部であるという参加感を得ることができるでしょう。これは分かります。
プロジェクト側の狙いとしても上記のユーザーエンゲージメントの向上で、原資は自分で発行している独自トークンなので無から生み出しているので配りまくれる、ということなのでしょうか?
詳しい方いたらぜひ教えてください!
ERC721ステーキング
前置きが長くなりましたがステーキングの実装について見ていきます!
How to Create an ERC721 NFT Staking Smart Contract + Web App
毎度お馴染みThirdwebさんにAudit済みのERC721のステーキングコントラクトとチュートリアルがあるので参考にして進めてみましょう。
まずERC721のコントラクトとERC20のコントラクトをデプロイして、それぞれ適当にmintしておきます。
https://thirdweb.com/mumbai/0xE71eE93Ad57c8b355e1bCDFe435B05673d8B4930
https://thirdweb.com/mumbai/0x15A142002C032CA499150359Ce2A3ef66c8533B0
StakeERC721というコントラクトをデプロイします。
https://thirdweb.com/thirdweb.eth/NFTStake?ref=blog.thirdweb.com
Time Unit for Rewardsは、ステーキング報酬が計算されていく時間を設定します。Rewards per unit timeは1単位の時間で何wei報酬がもらえるかを設定します。つまり上記の画像では60秒に1000weiがステーキング報酬として加算されていく設定になっています。
少しコントラクトを覗いてみます。コメントで簡単に解説を入れました。
function _stake(uint256[] calldata _tokenIds) internal virtual {
uint256 len = _tokenIds.length;
require(len != 0, "Staking 0 tokens");
address _stakingToken = stakingToken;
// stakerが追加ステーキングであればアップデート
if (stakers[_stakeMsgSender()].amountStaked > 0) {
_updateUnclaimedRewardsForStaker(_stakeMsgSender());
} else {
// stakerが新しくステーキングを開始する場合
// stakerの配列にpush
// block.timestampを保存
// stakingCondition(ステークした時の条件)を保存
stakersArray.push(_stakeMsgSender());
stakers[_stakeMsgSender()].timeOfLastUpdate = block.timestamp;
stakers[_stakeMsgSender()].conditionIdOflastUpdate = nextConditionId - 1;
}
for (uint256 i = 0; i < len; ++i) {
// NFTをユーザーからコントラクトへTransfer
require(
IERC721(_stakingToken).ownerOf(_tokenIds[i]) == _stakeMsgSender() &&
(IERC721(_stakingToken).getApproved(_tokenIds[i]) == address(this) ||
IERC721(_stakingToken).isApprovedForAll(_stakeMsgSender(), address(this))),
"Not owned or approved"
);
isStaking = 2;
IERC721(_stakingToken).safeTransferFrom(_stakeMsgSender(), address(this), _tokenIds[i]);
isStaking = 1;
stakerAddress[_tokenIds[i]] = _stakeMsgSender();
if (!isIndexed[_tokenIds[i]]) {
isIndexed[_tokenIds[i]] = true;
indexedTokens.push(_tokenIds[i]);
}
}
stakers[_stakeMsgSender()].amountStaked += len;
emit TokensStaked(_stakeMsgSender(), _tokenIds);
}
withdrawも見ていきます。withdrawはステーキングしたNFTを引き出す関数です。
function _withdraw(uint256[] calldata _tokenIds) internal virtual {
uint256 _amountStaked = stakers[_stakeMsgSender()].amountStaked;
uint256 len = _tokenIds.length;
require(len != 0, "Withdrawing 0 tokens");
require(_amountStaked >= len, "Withdrawing more than staked");
address _stakingToken = stakingToken;
_updateUnclaimedRewardsForStaker(_stakeMsgSender());
if (_amountStaked == len) {
address[] memory _stakersArray = stakersArray;
for (uint256 i = 0; i < _stakersArray.length; ++i) {
if (_stakersArray[i] == _stakeMsgSender()) {
stakersArray[i] = _stakersArray[_stakersArray.length - 1];
stakersArray.pop();
break;
}
}
}
stakers[_stakeMsgSender()].amountStaked -= len;
for (uint256 i = 0; i < len; ++i) {
require(stakerAddress[_tokenIds[i]] == _stakeMsgSender(), "Not staker");
stakerAddress[_tokenIds[i]] = address(0);
IERC721(_stakingToken).safeTransferFrom(address(this), _stakeMsgSender(), _tokenIds[i]);
}
emit TokensWithdrawn(_stakeMsgSender(), _tokenIds);
}
ややこしいですがwithdrawRewardTokensはAdminがRewardTokenをコントラクトから引き上げるための関数です。
claimRewardsでユーザーはステーキング報酬を得ることができます。
function _claimRewards() internal virtual {
uint256 rewards = stakers[_stakeMsgSender()].unclaimedRewards + _calculateRewards(_stakeMsgSender());
require(rewards != 0, "No rewards");
stakers[_stakeMsgSender()].timeOfLastUpdate = block.timestamp;
stakers[_stakeMsgSender()].unclaimedRewards = 0;
stakers[_stakeMsgSender()].conditionIdOflastUpdate = nextConditionId - 1;
_mintRewards(_stakeMsgSender(), rewards);
emit RewardsClaimed(_stakeMsgSender(), rewards);
}
function _mintRewards(address _staker, uint256 _rewards) internal override {
require(_rewards <= rewardTokenBalance, "Not enough reward tokens");
rewardTokenBalance -= _rewards;
CurrencyTransferLib.transferCurrencyWithWrapper(
rewardToken,
address(this),
_staker,
_rewards,
nativeTokenWrapper
);
}
ユーザーはNFTとステーキング報酬の引き出しを1トランザクションではできず別々にする必要があります。逆にいうとNFTはステーキングしたままでステーキング報酬だけClaimすることができるということですね。
ではAdminのウォレットからERC20トークンをステーキングコントラクトにデポジットします。
ERC20トークン側でステーキングコントラクトのアドレスをapproveします。その後ステーキングコントラクト側でdepositRewardTokensします。
フロントエンドから叩けるようにしましょう。
Next.jsのプロジェクトを作成してこちらのコードをコピペします。
https://github.com/thirdweb-example/nft-staking-app/blob/main/pages/stake.tsx
ステークにはApproveと実際のStake関数の実行の2トランザクションが必要です。
ステーキングができました。
claim・withdrawもできます。
まとめ
トークンのステーキングについて改めて概念を整理し、NFTステーキングのコントラクトをデプロイする方法を解説しました。これでお客さんから「NFTをステーキングさせたいんだけど」という要望が来ても対応できますね!ただしリワードのトークンが国内で適法で発行できるかは確認が必要なのでご注意ください!
弊社Pontechはweb3に関わる開発を得意とするテック企業です。サービス開発に関するご相談はこちらのフォームからお願いいたします。
また、受託開発案件に共に取り組むメンバーを募集しています!ご興味のある方はぜひお話させてください!