آموزش جامع ERC721Enumerable در سالیدیتی

استاندارد ERC721Enumerable در سالیدیتی نسخه‌ای پیشرفته‌تر از ERC721 است که به قرارداد هوشمند این امکان را می‌دهد تا بتواند فهرستی از تمام NFTهایی که یک آدرس مالک آن‌هاست، ایجاد کند. در این مقاله، ساختار و عملکرد ERC721Enumerable را بررسی می‌کنیم و می‌بینیم چطور می‌توان آن را در پروژه‌های ERC721 موجود پیاده سازی کرد. این مقاله بخشی از مسیر آموزش برنامه نویسی در حوزه قراردادهای هوشمند است و از پیاده سازی محبوب OpenZeppelin برای ERC721Enumerable به عنوان پایه توضیحات خود استفاده می‌کنیم.

پیش نیازها

از آنجا که ERC721Enumerable یک افزونه برای استاندارد ERC721 محسوب می‌شود، در این مقاله فرض بر این است که خواننده با استاندارد ERC721 در سالیدیتی آشنایی دارد یا مقاله قبلی ما درباره ERC721 را مطالعه کرده است.

روش Swap and Pop برای حذف از آرایه

در زبان Solidity، حذف یک عنصر از آرایه معمولاً با تکنیک «Swap and Pop» انجام می‌شود. در این روش، ابتدا آخرین عنصر آرایه را به جای عنصر مورد نظر برای حذف قرار می دهیم و سپس آخرین عنصر را از آرایه حذف (pop) می کنیم. این روش از نظر مصرف گس بسیار بهینه‌تر از جابه‌جایی همه عناصر به سمت چپ است.

در انیمیشن زیر، نحوه حذف عنصر با ایندکس ۱ (عدد ۵) با استفاده از این روش نشان داده شده است:

چرا به ERC721Enumerable نیاز داریم؟

برای درک بهتر اینکه چرا افزونه‌ای مانند ERC721Enumerable ضروری است، بیایید یک سناریوی واقعی را بررسی کنیم.

فرض کنید می‌خواهیم تمام NFTهایی را که یک کیف پول خاص از یک قرارداد ERC721 دارد پیدا کنیم. با امکانات پایه استاندارد ERC721 چطور می‌توان این کار را انجام داد؟

ابتدا باید تابع balanceOf() را با آدرس مورد نظر صدا بزنیم تا تعداد NFTهایی که آن آدرس در اختیار دارد مشخص شود. اما این تابع فقط تعداد را برمی‌گرداند، نه شناسه آن‌ها را.

سپس باید روی تمام شناسه‌های توکن (tokenID) موجود در قرارداد حلقه بزنیم و برای هر یک از آن‌ها تابع ownerOf() را اجرا کنیم تا ببینیم آیا به آن آدرس تعلق دارد یا نه.

اکنون فرض کنید کل عرضه این مجموعه ۱۰۰۰ توکن باشد و آدرس مورد نظر تنها مالک دو عدد از آن‌ها باشد؛ مثلاً توکن شماره ۱ و توکن شماره ۱۰۰۰.

در این حالت باید ۱۰۰۰ بار تابع ownerOf() را اجرا کنیم تا فقط دو NFT شناسایی شوند! این روش نه تنها ناکارآمد است، بلکه در مقیاس‌های بزرگ بسیار پرهزینه از نظر گس خواهد بود.

برای یافتن این دو توکن (توکن شماره ۱ و توکن شماره ۱۰۰۰) که در مالکیت یک آدرس خاص هستند، باید روی تمام NFTهای موجود در قرارداد حلقه بزنیم و برای هر شناسه، تابع ownerOf() را اجرا کنیم (از ۱ تا ۱۰۰۰). این فرآیند از نظر محاسباتی بسیار پرهزینه است.

از طرفی، همیشه نمی‌دانیم تمام tokenIDهای موجود در قرارداد چه هستند؛ بنابراین حتی ممکن است امکان اجرای این فرآیند وجود نداشته باشد.

در بخش‌های بعدی، خواهیم دید که استاندارد ERC721Enumerable در سالیدیتی چگونه این مشکل را به شکلی بهینه حل می‌کند.

راه حل ساده برای پیگیری مالکیت توکن ها

یک راه حل ابتدایی برای ردیابی توکن هایی که هر آدرس در اختیار دارد، این است که یک mapping از آدرس به لیستی از شناسه‌های NFT تعریف کنیم:

اما این روش به دلایل زیر ناکارآمد و ناقص است:

  1. مصرف بالای گس در آرایه‌های بزرگ: اگر کاربر تعداد زیادی توکن داشته باشد، خواندن این آرایه در یک قرارداد هوشمند می‌تواند مقدار زیادی گس مصرف کند، چون ذخیره آرایه‌های طولانی در حافظه بسیار پرهزینه است.

  2. روش‌های بهینه‌تری برای ذخیره‌سازی وجود دارد: بعدها خواهیم دید که می‌توان داده‌ها را با استفاده از ساختارهای کم‌هزینه‌تری ذخیره کرد.

  3. حذف توکن از آرایه دشوار است: اگر بخواهیم یک توکن خاص را از آرایه مربوط به یک کاربر حذف کنیم، باید کل آرایه را اسکن کنیم تا آن توکن را پیدا کنیم. این کار نیز در آرایه‌های بزرگ می‌تواند باعث تمام شدن گس شود.

استاندارد ERC721Enumerable در سالیدیتی برای حل مشکلات اول و دوم، به‌جای استفاده از mapping از آرایه استفاده می‌کند (که در بخش بعدی توضیح داده می‌شود). و برای حل مشکل سوم، یک ساختار داده اضافی در نظر گرفته شده که شناسه توکن را به اندیسی که در آن قرار دارد نگاشت می‌کند تا دسترسی و حذف آسان‌تر انجام شود.

استفاده از Mapping به جای آرایه

در سالیدیتی می‌توان از mapping به شکلی استفاده کرد که عملکردی شبیه به آرایه داشته باشد؛ به این صورت که کلیدها (keys) به‌عنوان ایندکس (index) عمل می‌کنند و مقادیر (values) همان داده‌هایی هستند که در آن ایندکس ذخیره می‌شوند.

نموداری که نشان می‌دهد چگونه می‌توان از یک mapping به عنوان یک آرایه استفاده کرد

اگر در مثال قبلی، به جای آرایه از یک mapping استفاده کنیم، ایندکس های آرایه به عنوان کلید (key) در نظر گرفته می شوند و شناسه های توکن (tokenID) به عنوان مقدار (value) در آن ایندکس ذخیره می شوند.

در سالیدیتی، mapping نسبت به آرایه ها از نظر مصرف گس بهینه تر است. چرا که هنگام استفاده از آرایه، در هر بار دسترسی به یک ایندکس، بررسی ای به صورت ضمنی انجام می شود تا مطمئن شود مقدار i کوچکتر از array.length است. این بررسی، باعث افزایش مصرف گس می شود.

اما زمانی که از mapping استفاده می کنیم، چنین بررسی انجام نمی شود، در نتیجه مصرف گس کاهش می یابد.

با این حال، برخلاف آرایه ها، mapping به صورت پیش فرض ویژگی length ندارد؛ یعنی نمی توان به راحتی تعداد کل NFTها یا عناصر ذخیره شده را پیگیری کرد. به همین دلیل، mapping همیشه جایگزین مناسبی برای آرایه ها نیست.

در بخش بعد، هر یک از ساختارهای داده ای مورد استفاده در استاندارد ERC721Enumerable را به صورت جداگانه بررسی خواهیم کرد.

ERC721Enumerable: ساختارهای داده

استاندارد ERC721Enumerable دو هدف اصلی را دنبال می کند:

  1. ردیابی تمام tokenIDهایی که در قرارداد وجود دارند

  2. ردیابی تمام tokenIDهایی که در مالکیت یک آدرس خاص هستند

برای دستیابی به هدف اول، از دو ساختار داده زیر استفاده می شود:

  • _allTokens

  • _allTokensIndex

و برای دستیابی به هدف دوم، از این دو ساختار داده استفاده می شود:

  • _ownedTokens

  • _ownedTokensIndex

متغیرهای حالت ERC-721 Enumerable برجسته شده‌اند

برای ساده شدن توضیحات، در تمام مثال ها و توضیحات از یک مجموعه ثابت از tokenIDها استفاده می کنیم: یعنی ۲، ۵، ۹، ۷ و ۱.

آرایه _allTokens

آرایه _allTokens به ما امکان می‌دهد که بتوانیم به صورت ترتیبی روی تمام NFTهای موجود در یک قرارداد پیمایش (iterate) انجام دهیم. این آرایه‌ی خصوصی (private) شامل تمام tokenIDهای موجود در قرارداد است، بدون توجه به این که در حال حاضر متعلق به چه کسی هستند.

در ابتدا، ترتیب tokenIDها در آرایه‌ی _allTokens بر اساس ترتیب مینت شدن آن‌ها تعیین می‌شود. برای مثال، در دیاگرام بالا، tokenID شماره ۲ در ایندکس ۰ قرار دارد چون زودتر از بقیه مینت شده است.

البته باید توجه داشت که این ترتیب می‌تواند پس از سوزاندن (burn) یک توکن تغییر کند.

mapping مربوط به _allTokensIndex

mapping به نام _allTokensIndex برای این طراحی شده که با گرفتن یک tokenID، ایندکس مربوط به آن را در آرایه _allTokens برگرداند.

به جای آن که برای پیدا کردن ایندکس یک tokenID در آرایه _allTokens، مجبور باشیم کل آرایه را پیمایش کنیم، می‌توانیم مستقیماً از خود tokenID به عنوان کلید استفاده کنیم تا ایندکس آن را از طریق mapping _allTokensIndex به‌دست آوریم.

توانایی دسترسی سریع به ایندکس یک tokenID باعث می‌شود که تابع burn بتواند آن توکن را به شکل بهینه و سریع از آرایه حذف کند.

نموداری که نشان می‌دهد چگونه _allTokensIndex اندیس‌های tokenIDها را از آرایه _allTokens نگه می‌دارد.

در تصویر بالا، ساختار mapping به‌وضوح نمایش داده شده است: برای مثال، tokenID شماره ۲ به ایندکس ۰ نگاشت می‌شود، چون اولین توکنی بوده که در قرارداد مینت شده است. این الگو برای همه توکن‌هایی که مینت می‌شوند ادامه پیدا می‌کند.

mapping مربوط به _ownedTokens

mapping به نام _ownedTokens برای ردیابی tokenIDهایی استفاده می‌شود که متعلق به یک آدرس خاص هستند. این mapping به‌صورت تو در تو تعریف شده است و ساختار آن به این صورت است: tokenID → ایندکس → address مالک

یعنی ابتدا آدرس مالک را مشخص می‌کنیم، سپس با استفاده از ایندکس (که بین صفر تا balanceOf آن آدرس است) به tokenIDهایی می‌رسیم که آن آدرس در اختیار دارد.

نموداری که نشان می‌دهد چگونه نگاشت _ownedTokens یک آدرس را به اندیس tokenID نگاشت می‌کند.

در دیاگرام بالا، آدرس 0xAli3c3 مالک ۳ عدد NFT است و به همین دلیل، mapping آن شامل ۳ tokenID مختلف است. آدرس دیگر یعنی 0xb0b فقط مالک یک توکن است، بنابراین mapping مربوط به آن شامل فقط یک tokenID می‌باشد.

برای نمونه، ایندکس شماره ۲ در mapping مربوط به آدرس 0xAli3c3 به tokenID شماره ۱ نگاشت می‌شود.

mapping مربوط به _ownedTokensIndex

درست همانطور که _allTokensIndex تصویر معکوس آرایه _allTokens بود، mapping به نام _ownedTokensIndex نیز تصویر معکوس mapping مربوط به _ownedTokens است.

این mapping از یک tokenID به ایندکس آن token در ساختار _ownedTokens برای یک کاربر خاص نگاشت می‌دهد. نمودار زیر را در نظر بگیرید:

نموداری که نشان می‌دهد چگونه _ownedTokenIndex اندیس یک توکن را در _ownedTokens نگه می‌دارد.

در دیاگرام فرضی بالا، اگر tokenID شماره ۲ یا ۹ را در mapping مربوط به _ownedTokensIndex وارد کنیم، مقدار ۰ برمی‌گردد. چرا؟ چون این توکن‌ها، اولین توکن های متعلق به Alice و Bob هستند.

دقیقاً مانند _allTokensIndex، هدف از استفاده از این ساختار داده این است که بتوانیم به صورت سریع و بهینه، یک tokenID خاص را در _ownedTokens پیدا کنیم تا در صورت نیاز آن را حذف کنیم (مثلاً وقتی کاربر آن را انتقال می‌دهد یا می‌سوزاند).

نکته مهم این است که تمام این ساختارهای داده به صورت private تعریف شده اند، بنابراین از خارج قرارداد نمی‌توان به‌طور مستقیم با آن‌ها تعامل داشت. در بخش بعدی، توابعی را بررسی خواهیم کرد که برای خواندن یا تغییر این ساختارها به کار می‌روند.

توابع ERC721Enumerable در سالیدیتی

بر اساس مستندات رسمی ERC721، استاندارد ERC721Enumerable سه تابع عمومی مهم دارد که امکان شمارش و فهرست‌برداری از توکن ها را فراهم می‌کنند:

totalSupply()

تابع ()totalSupply

تابع totalSupply() برای دریافت تعداد کل توکن های NFT موجود در قرارداد استفاده می‌شود. این تابع طول آرایه‌ _allTokens را بازمی‌گرداند؛ یعنی مشخص می‌کند چند توکن تا این لحظه مینت شده‌اند (چه مالک داشته باشند و چه نداشته باشند).

tokenByIndex()

تابع tokenByIndex()

تابع tokenByIndex() در واقع یک wrapper ساده بر روی آرایه‌ _allTokens است. با دریافت یک ایندکس عددی، tokenID مربوط به آن ایندکس در آرایه‌ _allTokens را باز می‌گرداند.

به عنوان مثال اگر tokenByIndex(0) فراخوانی شود، توکن مینت‌شده در ایندکس صفر (اولین توکن ایجادشده) را بازمی‌گرداند.

tokenOfOwnerByIndex()

تابع tokenOfOwnerByIndex()

تابع tokenOfOwnerByIndex() نیز wrapper بر روی mapping مربوط به _ownedTokens است. با دریافت آدرس یک مالک و یک ایندکس، tokenID مربوط به آن ایندکس برای آن آدرس خاص را بازمی‌گرداند.

یک مثال نمودار بصری از _ownedTokens

در مثال بالا از mapping مربوط به _ownedTokens، آدرس 0xAli3c3 مالک ۳ توکن است. اگر تابع tokenOfOwnerByIndex با این آدرس و ایندکس عدد ۲ فراخوانی شود، tokenID شماره ۱ بازگردانده خواهد شد.

افزودن و حذف tokenIDها از لیست‌های شمارشی (Enumeration)

علاوه بر توابع عمومی‌ای که پیش‌تر معرفی شد، پیاده‌سازی OpenZeppelin از ERC721Enumerable شامل ۴ تابع خصوصی دیگر نیز هست که وظیفه آن‌ها به‌روزرسانی ساختارهای داده‌ای مثل _allTokens و _ownedTokens است. این توابع به صورت داخلی در تابع _update فراخوانی می‌شوند تا مالکیت هر توکن به درستی در تمام ساختارها ثبت یا حذف شود.

ما وارد جزئیات تمام این توابع نخواهیم شد، چون این توابع بخشی از مشخصات استاندارد ERC721 نیستند. با این حال، اجازه بدهید نگاهی به یکی از آن‌ها بیندازیم:

تابع _removeTokenFromOwnerEnumeration

تابع removeTokenFromOwnerEnumeration()

این تابع در مواقعی استفاده می‌شود که یک توکن باید از لیست توکن های متعلق به یک آدرس حذف شود. این موقعیت معمولاً زمانی رخ می‌دهد که:

  • مالک توکن را منتقل می‌کند

  • یا توکن سوزانده (burn) می‌شود

در چنین شرایطی، توکن مربوطه دیگر نباید در لیست دارایی های آدرس قبلی باقی بماند، و این همان کاری است که _removeTokenFromOwnerEnumeration انجام می‌دهد:‌ به‌روزرسانی و حذف صحیح tokenID از mapping مربوط به مالک قبلی.

فرآیند حذف

قبل از حذف، تابع با استفاده از نگاشت _ownedTokensIndex بررسی می‌کند که آیا tokenId در آخرین ایندکس از توکن های متعلق به مالک قرار دارد یا نه. اگر اینطور نباشد، tokenId با توکن قرارگرفته در آخرین ایندکس جابجا می‌شود.

این کار ضروری است، چون اگر tokenId مستقیماً حذف شود، در ایندکس های توکن های مالک یک شکاف باقی می‌ماند که باعث می‌شود تابع balanceOf() هنگام فراخوانی با آدرس مالک، نتایج نادرست برگرداند.

بعد از این جابجایی، تابع tokenId (که حالا در انتهای لیست قرار دارد) را از نگاشت های _ownedTokensIndex و _ownedTokens حذف می‌کند و در عمل آن توکن را از شمارش خارج می‌سازد.

سایر توابع موجود در این افزونه به شرح زیر هستند:

  • _addTokenToOwnerEnumeration

این تابع هنگام ضرب (mint) یا انتقال یک tokenId به آدرس غیر صفر، آن را به ساختارهای _ownedTokens و _ownedTokensIndex اضافه می‌کند.

برای تعیین ایندکس اختصاص داده شده به tokenId جدید، از تابع balanceOf() استفاده می‌شود.

مثلاً اگر تابع balanceOf() برای یک آدرس مقدار ۳ را برگرداند (یعنی آن آدرس ۳ توکن دارد)، پس tokenId جدید در ایندکس ۳ قرار می‌گیرد (چون ایندکس گذاری از صفر شروع می‌شود).

تابع _addTokenToOwnerEnumeration

  • _addTokenToAllTokensEnumeration

این تابع هنگام ضرب یک tokenId آن را به ساختارهای داده ای که تمام توکن ها را پیگیری می‌کنند اضافه می‌کند (مانند _allTokensIndex).

تابع _addTokenToAllTokensEnumeration

  • _removeTokenFromAllTokensEnumeration

از این تابع زمانی استفاده می‌شود که یک tokenId سوزانده شود تا ساختارهای داده به‌روزرسانی شوند.

تابع _removeTokenFromAllTokensEnumeration از فرآیندی مشابه با _removeTokenFromOwnerEnumeration برای حذف استفاده می‌کند.

تابع _removeTokenFromAllTokensEnumeration

کنار هم گذاشتن تکه ها: تابع _update

چهار تابع خصوصی که در بخش قبل به‌طور خلاصه بررسی کردیم، در تابع _update مورد استفاده قرار می‌گیرند تا عملیات ضرب (mint)، سوزاندن (burn)، یا انتقال (transfer) توکن های NFT را انجام دهند.

تابع به‌روزرسانی _ شمارش‌پذیر ERC721

این تابع هر بار که مالکیت یک tokenId تغییر می‌کند فراخوانی می‌شود. داخل این تابع، دو جفت شرط if وجود دارد که عملکرد آن‌ها به شرح زیر است:

شرط‌های اول: بررسی آدرس فرستنده (Sender)

اولین جفت شرط بررسی می‌کند که آیا tokenId در حال ضرب (mint) شدن است یا در حال انتقال. هدف این بخش، حذف tokenId از ساختارهای داده مالک قبلی است. تخصیص مالک جدید به tokenId در شرط بعدی انجام می‌شود.

حالت اول: توکن ضرب می‌شود (Mint)

اگر توکن در حال ضرب باشد، تابع _addTokenToAllTokensEnumeration فراخوانی می‌شود. این تابع tokenId را به آرایه های _allTokens و نگاشت _allTokensIndex اضافه می‌کند.

نموداری که کدهای تغییر وضعیت را نشان می‌دهد t _addTokensToAllTokenEnumerationحالت دوم: توکن منتقل می‌شود (Transfer)

اگر توکن در حال انتقال باشد، تابع _removeTokenFromOwnerEnumeration فراخوانی می‌شود. این تابع tokenId را از ساختارهای _ownedTokens و _ownedTokensIndex مربوط به آدرس مالک قبلی حذف می‌کند (که به عنوان ورودی به تابع داده شده است).

نموداری که کدهای تغییر وضعیت را نشان می‌دهد که tokenID را از _ownedTokens و _owned TokensIndex در تابع _removeTokenFromOwnerEnumeration() حذف می‌کنند.

شرط‌های دوم: بررسی آدرس گیرنده (Receiver)

شرط اول به آدرسی که قرار است tokenId به آن منتقل شود کاری ندارد. این شرط دوم است که بررسی می‌کند آیا tokenId در حال سوزاندن (burn) است یا در حال انتقال به یک آدرس معتبر (غیر صفر).

حالت اول: توکن سوزانده می‌شود (Burn)

اگر توکن در حال سوزانده شدن باشد، تابع _removeTokenFromAllTokensEnumeration فراخوانی می‌شود که tokenId را از ساختارهای _allTokens و _allTokensIndex حذف می‌کند.

نمودار، کدهای تغییر وضعیت را نشان می‌دهد که توکن‌ها را در _allTokensIndex حذف می‌کنند.

حالت دوم: توکن منتقل می‌شود (Transfer)

اگر توکن به یک آدرس معتبر (غیر صفر) منتقل شود، تابع _addTokenToOwnerEnumeration فراخوانی می‌شود. این تابع tokenId را به ساختارهای _ownedTokens و _ownedTokensIndex آدرس مقصد اضافه می‌کند.

نموداری که کدهای تغییر وضعیت را نشان می‌دهد که توکن‌ها را به _ownedTokens در تابع _addTokenToOwnerEnumeration() اضافه می‌کنند.

افزودن ERC721Enumerable به پروژه شما

در این بخش یاد می‌گیریم که چگونه افزونه ERC721Enumerable از کتابخانه OpenZeppelin را در دو مرحله به قرارداد ERC721 خود اضافه کنیم.

1. وارد کردن ERC721Enumerable

در ابتدای فایل قرارداد ERC721 خود، خط کد زیر را در کنار سایر import ها اضافه کنید:

سپس قرارداد را به شکل زیر تعریف کنید:

2. بازنویسی توابع (Overriding Functions)

افزودن ERC721Enumerable نیاز دارد که چند تابع از ERC721 بازنویسی (override) شوند. این توابع عبارتند از:

تابع _update

تابع _increaseBalance
تابع supportsInterface
🔔 نکته: سایر افزونه‌های ERC721 که تابع balanceOf() سفارشی دارند (مانند ERC721Consecutive)، نمی‌توانند همراه با افزونه ERC721Enumerable استفاده شوند، زیرا در عملکرد آن اختلال ایجاد می‌کنند.

شمارش با هزینه: نکات مهم در مورد افزونه ERC721Enumerable

در هر انتقال توکن، ساختارهای داده ای موجود در ERC721Enumerable باید به‌روزرسانی شوند. این موضوع باعث می‌شود قرارداد مقدار قابل توجهی گس مصرف کند و سنگین‌تر شود.

با این حال، در پروژه‌هایی که نیاز دارند شناسه توکن ها (tokenIDs) به صورت درون زنجیره‌ای (on-chain) قابل لیست شدن باشند، این هزینه اجتناب‌ناپذیر است و باید آن را به عنوان بخشی از طراحی پذیرفت.

5/5 - (1 امتیاز)

راستی! برای دریافت مطالب جدید در کانال تلگرام یا پیج اینستاگرام سورس باران عضو شوید.

دوره آموزش پروژه محور طراحی وب سایت پزشک یاب با بوت استرپ
  • انتشار: ۳۰ اردیبهشت ۱۴۰۴

دسته بندی موضوعات

آخرین محصولات فروشگاه

مشاهده همه

نظرات

بازخوردهای خود را برای ما ارسال کنید