اشتباه رایج در سالیدیتی

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

این لیست به هیچ وجه فهرست کاملی از تمام اشتباهاتی که یک توسعه دهنده Solidity ممکن است مرتکب شود، نیست. حتی توسعه دهندگان متوسط و باتجربه نیز ممکن است این خطاها را انجام دهند.

با این حال، این اشتباهات رایج در سالیدیتی بیشتر در مراحل اولیه یادگیری رخ می‌دهند، بنابراین ارزش آن را دارد که آن‌ها را لیست کنیم.

۱. انجام تقسیم قبل از ضرب

در سالیدیتی، عملیات تقسیم باید همیشه در پایان انجام شود، زیرا تقسیم باعث گرد شدن (به سمت پایین) عدد می‌شود.

برای مثال، اگر بخواهیم محاسبه کنیم که باید به کسی ۳۳.۳۳٪ سود پرداخت کنیم، روش اشتباه برای انجام این کار به صورت زیر است:

اگر مقدار principal کمتر از ۳۳۳۳ باشد، مقدار interest به صفر گرد می‌شود. در عوض، باید به صورت زیر محاسبه شود:
در ادامه، محاسبات ریاضی نشان داده می‌شود که چرا مثال اول منجر به گرد شدن نامطلوب می‌شود اما مثال دوم درست عمل می‌کند:

یافتن این خطا با استفاده از Slither

Slither یک ابزار تحلیل ایستای کد است که توسط شرکت Trail of Bits توسعه داده شده و کدهای Solidity را بررسی کرده و الگوهای رایج خطا را شناسایی می‌کند.

اگر کد زیر را در فایل interest.sol بنویسیم که شامل یک اشتباه رایج است:

و سپس در ترمینال دستور زیر را اجرا کنیم:
هشدار زیر را دریافت می‌کنیم:

تصویری از هشدار Slither مبنی بر اینکه ضرب پس از تقسیم اتفاق می‌افتد

در این هشدار، Slither اعلام می‌کند که ابتدا عمل تقسیم و سپس ضرب انجام شده است، که در Solidity به‌طور کلی باید از آن اجتناب شود، زیرا می‌تواند به گرد شدن مقادیر به صفر یا نتیجه‌ای نادرست منجر شود.

۲. رعایت نکردن الگوی Check-Effects-Interaction

در زبان سالیدیتی، پیروی از الگوی «بررسی – تغییر وضعیت – تعامل» (Check-Effects-Interaction) بسیار حیاتی است تا از حملات re-entrancy (دوباره واردی) جلوگیری شود. این الگو می‌گوید که تماس با قرارداد دیگر یا ارسال ETH به یک آدرس خارجی باید آخرین عملیات در یک تابع باشد. در غیر این صورت، قرارداد می‌تواند در معرض حمله قرار گیرد.

در مثال زیر، قرارداد BadBank این الگو را رعایت نکرده و در نتیجه می‌توان کل موجودی ETH آن را خالی کرد.

قرارداد حمله زیر می‌تواند برای تخلیه بانک استفاده شود:
در این حمله، از یک قرارداد به نام BankDrainer برای خالی کردن موجودی بانک استفاده می‌شود. شما می‌توانید این کدها را در محیط Remix آزمایش کنید. همچنین یک ویدیو برای نمایش عملی این حمله وجود دارد:

علت آسیب‌پذیری:

تابع withdraw() در قرارداد BadBank، قبل از به‌روزرسانی موجودی حساب کاربر، ETH را ارسال می‌کند. این عمل باعث فراخوانی تابع receive() در قرارداد مهاجم (BankDrainer) می‌شود، و چون موجودی هنوز کاهش نیافته، مهاجم می‌تواند چندین بار به تابع withdraw() وارد شود و موجودی بانک را خالی کند.

برای جلوگیری از این آسیب‌پذیری، همیشه ابتدا شرایط را بررسی کنید (check)، سپس وضعیت را تغییر دهید (effects)، و در پایان تعامل خارجی انجام دهید (interaction). این دسته از حملات تحت عنوان Re-entrancy شناخته می‌شوند

اگر این کد را با ابزار Slither بررسی کنیم، دو هشدار دریافت می‌کنیم:

تصویری از Slither که دو هشدار از کد منبع Solidity را نشان می‌دهد.

  • هشدار اول مبنی بر «ارسال ETH به کاربر دلخواه» که ممکن است در اینجا مثبت کاذب باشد. درست است که هر کسی می‌تواند تابع withdraw را فراخوانی کند، اما فقط موجودی خودش را می‌تواند برداشت کند (حداقل در ابتدا!).

  • هشدار دوم به‌درستی وجود آسیب‌پذیری Re-entrancy را تشخیص می‌دهد.

۳. استفاده از transfer یا send

در زبان Solidity دو تابع ساده برای ارسال اتر (ETH) از قرارداد به آدرس مقصد وجود دارد: transfer() و send(). با این حال، نباید از این توابع استفاده کنید.

وبلاگ معروف شرکت Consensys در مورد دلایل پرهیز از استفاده از transfer و send، یکی از مطالب پایه ای است که هر توسعه دهنده سالیدیتی باید آن را مطالعه کند.

چرا این توابع به وجود آمدند؟

پس از حمله DAO که منجر به دو شاخه شدن اتریوم به Ethereum و Ethereum Classic شد، توسعه دهندگان به شدت از حملات Re-entrancy (دوباره‌واردی) ترسیدند. برای کاهش این ریسک، توابع transfer() و send() معرفی شدند تا مقدار گاز قابل استفاده برای دریافت کننده را به ۲۳۰۰ واحد گاز محدود کنند. این مقدار گاز باعث می‌شد دریافت کننده نتواند کد پیچیده ای اجرا کند و در نتیجه حمله Re-entrancy رخ ندهد.

گاز (Gas) چیست؟

گاز یک واحد اندازه گیری مصرف منابع محاسباتی در بلاکچین اتریوم است.
هر عملیات (مثل ذخیره داده، اجرای تابع، انتقال پول و…) در شبکه اتریوم هزینه‌ای دارد که به صورت گاز پرداخت می‌شود. پرداخت گاز از طریق ETH انجام می‌شود و باعث می‌شود اجرای قراردادهای هوشمند بهینه، محدود و مقاوم در برابر سوءاستفاده باشد. گاز همچنین مانع از حملات DoS می‌شود، چون اجرای کدهای زیاد، هزینه‌بر خواهد بود.

سناریوی نمونه:

در مثال‌های قبلی، این کد را داشتیم:

می‌توان آن را با این خط جایگزین کرد:

با این تغییر، بانک دیگر در برابر حمله Re-entrancy آسیب‌پذیر نیست.

اما مشکل کجاست؟ اگر قرارداد مقصد نیاز به گاز بیشتری برای پردازش دریافتی ها داشته باشد، این روش باعث شکست تعامل خواهد شد. مثلاً اگر قرارداد مقصد بخواهد بعد از دریافت ETH، اطلاعات فرستنده را در یک متغیر ذخیره کند یا عملیات حسابداری انجام دهد، چون فقط ۲۳۰۰ گاز دارد، این کار انجام نخواهد شد و انتقال با شکست مواجه می‌شود.

به مثال زیر توجه کنید:

می‌توانید کد بالا را در Remix تست کنید. و در زیر ویدیویی وجود قرار دادیم که انتقال ناموفق را نشان می‌دهد:

تراکنش به این دلیل با شکست مواجه می‌شود که تابع receive() هنگام افزایش موجودی فرستنده با کمبود گاز مواجه می‌شود.

تراکنش به این دلیل با شکست مواجه می‌شود که تابع receive() هنگام افزایش موجودی فرستنده با کمبود گاز مواجه می‌شود.

پس از transfer یا send استفاده نکنید و کدی با قابلیت دوباره واردی (re-entrancy) ننویسید.
اولین گزینه این است که transfer یا send را با دستور زیر جایگزین کنید:

یا می‌توان از کتابخانه Address متعلق به OpenZeppelin برای انجام همین کار استفاده کرد.
هر دو روش در ادامه نشان داده شده‌اند.
جالب است که ابزار Slither در این مورد هشداری نمی‌دهد، اما با این حال باید به‌طور کلی از transfer و send اجتناب کنید، چراکه محدودیت گاز آن‌ها باعث اختلال در ارتباط با قراردادهای دیگر می‌شود و ممکن است موجب باگ هایی شود که شناسایی‌شان دشوار است.

۴. استفاده از tx.origin به جای msg.sender

در زبان سالیدیتی، دو روش برای تشخیص اینکه «چه کسی در حال فراخوانی من است» وجود دارد: یکی tx.origin و دیگری msg.sender.

  • tx.origin آدرس کیف پولی است که تراکنش را امضا کرده.

  • msg.sender مستقیماً فراخواننده‌ی فعلی قرارداد است.

اگر یک کیف پول مستقیماً یک قرارداد را فراخوانی کند:

کیف پول ← قرارداد

در این حالت، از دید قرارداد، آدرس کیف پول هم msg.sender است و هم tx.origin.

اما اگر کیف پول ابتدا یک قرارداد واسطه را فراخوانی کند که سپس قرارداد نهایی را فراخوانی می‌کند:

کیف پول ← قرارداد واسطه ← قرارداد نهایی

در این حالت، برای قرارداد نهایی، tx.origin برابر با آدرس کیف پول است، اما msg.sender آدرس قرارداد واسطه است.

استفاده از tx.origin برای شناسایی فراخواننده می‌تواند منجر به آسیب پذیری امنیتی شود. فرض کنید کاربر فریب داده می‌شود و یک قرارداد مخرب واسطه را فراخوانی می‌کند:

کیف پول ← قرارداد واسطه مخرب ← قرارداد نهایی

در این حالت، قرارداد واسطه مخرب با استفاده از tx.origin می‌تواند از مجوزهای کیف پول سوءاستفاده کند و کارهایی مثل انتقال وجه انجام دهد، بدون اینکه msg.sender بررسی شود.

نکته: ابزار Slither در حال حاضر درباره استفاده از tx.origin هشدار نمی‌دهد، اما با این حال باید از آن پرهیز کرد.

۵. استفاده نکردن از safeTransfer برای توکن های ERC-20

استاندارد ERC-20 فقط مشخص کرده که اگر کاربر بخواهد بیشتر از موجودی خود انتقال دهد، باید خطا رخ دهد. اما اگر انتقال به دلایلی غیر از کمبود موجودی شکست بخورد، استاندارد صراحتاً مشخص نکرده که چه اتفاقی باید بیفتد.

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

که طبق این تعریف، انتظار می‌رود توکن در صورت شکست عملیات، مقدار false برگرداند.

اما در عمل، توکن های ERC-20 به شیوه های متفاوتی پیاده سازی شده‌اند:

  • برخی هنگام شکست عملیات، تراکنش را revert می‌کنند.

  • برخی هیچ مقداری برنمی‌گردانند (یعنی حتی امضای تابع را هم رعایت نمی‌کنند).

کتابخانه SafeERC20 از OpenZeppelin برای مدیریت این تفاوت‌ها طراحی شده و شرایط مختلف را بررسی می‌کند:

  1. اگر عملیات revert شود، SafeERC20 همان خطا را بالا می‌آورد.

  2. اگر عملیات revert نشود:

    • اگر هیچ داده ای برنگردد و آدرس مورد نظر قرارداد نباشد (مثلاً آدرس خالی باشد)، کتابخانه عملیات را revert می‌کند.

    • اگر داده ای برگشته ولی مقدار آن false باشد، باز هم عملیات را revert می‌کند.

    • فقط اگر بازگشت موفقیت‌آمیز باشد (یا داده نداشته باشد ولی آدرس معتبر باشد)، عملیات موفق تلقی می‌شود.

در ادامه نشان داده می‌شود که چگونه باید از کتابخانه SafeERC20 استفاده کرد:

۶. استفاده از SafeMath در سالیدیتی نسخه ۰.۸.۰ یا بالاتر

پیش از نسخه ۰.۸.۰ سالیدیتی، در صورت انجام عملیات ریاضی که نتیجه ای بزرگ‌تر از ظرفیت متغیر تولید می‌کرد، سرریز (overflow) رخ می‌داد. برای جلوگیری از این مشکل، کتابخانه محبوب SafeMath از OpenZeppelin معرفی شد. برای مثال، تابع جمع در این کتابخانه به این شکل عمل می‌کرد:

مقدار جمع‌شده باید همیشه از x یا y بزرگ‌تر باشد. اگر این شرط برقرار نباشد، سرریز اتفاق افتاده و تابع خطا می‌دهد.

در کدهای قدیمی‌تر اغلب می‌بینید:

و عملیات ریاضی به این صورت انجام می‌شد:

اما در سالیدیتی ۰.۸.۰ و نسخه های بالاتر، دیگر نیازی به استفاده از SafeMath نیست، چرا که کامپایلر به‌صورت پیش‌فرض بررسی سرریز را انجام می‌دهد.

بنابراین، استفاده از کتابخانه SafeMath برای عملیات ساده ریاضی در نسخه های جدید:

  • خوانایی کد را پایین می آورد،

  • باعث کاهش کارایی می‌شود،

  • و هیچ مزیت امنیتی اضافه ای ندارد.

۷. فراموش کردن کنترل دسترسی (Access Control)

بیایید یک مثال ساده بررسی کنیم. آیا می‌توانید مشکل زیر را تشخیص دهید؟

در این حالت، هر کسی می‌تواند تابع setPrice() را فراخوانی کند و قیمت را روی صفر قرار دهد، سپس تابع buyNFT() را با هزینه صفر اجرا کند و NFT را رایگان بخرد!

هرگاه تابعی را به‌صورت public یا external تعریف می‌کنید، حتما از خودتان بپرسید:
“آیا همه باید اجازه فراخوانی این تابع را داشته باشند؟”

در نسخه ای دیگر از همین مشکل:

در اینجا از یک مُدیفایر (modifier) به نام onlyOwner استفاده شده که فقط به مالک قرارداد اجازه تغییر قیمت را می‌دهد.
این کار، یک لایه امنیتی حیاتی برای جلوگیری از سوءاستفاده‌های احتمالی است.

۸. انجام عملیات های پرهزینه در حلقه ها

آرایه هایی که می‌توانند بدون محدودیت رشد کنند مشکل‌ساز هستند، زیرا هزینه تراکنش برای اجرای حلقه روی آن‌ها ممکن است بسیار زیاد شود.

قرارداد زیر کمک‌های اتر دریافت می‌کند و اهداکنندگان را به یک آرایه اضافه می‌کند. بعداً، مالک قرارداد تابع distributeNFTs() را فراخوانی کرده و برای همه اهداکنندگان یک NFT ضرب می‌کند. با این حال، اگر تعداد اهداکنندگان زیاد باشد، ممکن است این کار برای مالک بیش از حد پرهزینه باشد و نتواند فرایند اهدای NFT را کامل کند.

در اینجا تابع distributeNFTs() سعی می‌کند روی کل آرایهٔ donors حلقه بزند و به همه NFT بدهد.
اگر این لیست خیلی بزرگ شود، اجرای این تابع:

  • بسیار پرهزینه می‌شود،

  • ممکن است به حد گس بلاک برسد و تراکنش شکست بخورد.

ابزار Slither در چنین مواقعی هشدار می‌دهد که اجرای تابع در صورت بزرگ بودن آرایه، عملی نخواهد بود:

تصویری از هشدار Slither در مورد عملیات پرهزینه در یک حلقه

راه حل این مسئله با عنوان «کشیدن به جای هل دادن» (pull over push) شناخته می‌شود. به‌جای آنکه شما برای هر گیرنده NFT را ارسال کنید، آن‌ها باید تابعی را فراخوانی کنند که در صورت فراخوانی توسط آن آدرس، NFT را به آن آدرس منتقل کند.

۹. نبود بررسی سلامت روی ورودی های تابع

هر زمان که یک تابع عمومی (public) می‌نویسید، به‌صورت صریح مشخص کنید که چه مقادیری انتظار می‌رود به آرگومان های آن پاس داده شوند و مطمئن شوید که با استفاده از دستور require این شرایط را اعمال می‌کنید.
برای مثال، افراد نباید بتوانند بیشتر از موجودی شان برداشت کنند. همچنین، نباید بتوانند دارایی هایی را برداشت کنند که خودشان واریز نکرده‌اند.

به مثال های زیر توجه کنید:

طراح باید در نظر داشته باشد که چه پارامترهایی معقول هستند. نرخ بهره‌ای بیش از ۱۰۰۰٪ غیرمنطقی است. یا مدتی بسیار کوتاه مثل یک ساعت نیز غیرمنطقی محسوب می‌شود.

به‌طور مشابه، تابع setProtocolFee باید یک حد بالای معقول برای میزان کارمزدی که مالک می‌تواند تنظیم کند داشته باشد. در غیر این صورت، کاربران ممکن است با افزایش ناگهانی و غیرمنتظره کارمزدهای استفاده از پروتکل روبرو شوند.

برای پیاده سازی این بررسی های منطقی، کافی است از require استفاده کنید تا محدوده مجاز برای ورودی ها را مشخص کنید.

هنگام طراحی هر تابع عمومی، همیشه بررسی کنید که چه بازه‌ای از پارامترها برای آرگومان های آن تابع منطقی و قابل قبول است.

۱۰. کد ناقص در سالیدیتی

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

در این کد چه چیزی کم است؟

مشکل اینجاست که مقدار مینت شده توسط خریدار از amountAllowedToMint کم نمی‌شود، بنابراین «محدودیت» عملاً اعمال نمی‌شود. یک آدرس موجود در مپ می‌تواند هر تعداد دلخواه توکن مینت کند.

باید بعد از تابع _mint() یک خط اضافه شود:

۱۱. عدم تعیین نسخه دقیق Pragma در Solidity

هنگامی که کد کتابخانه های سالیدیتی را می‌خوانید، اغلب در بالای فایل عباراتی مانند زیر را می‌بینید:

به همین دلیل، بسیاری از توسعه دهندگان تازه‌کار این الگو را بدون فکر کپی می‌کنند.

اما استفاده از ^0.8.0 فقط برای کتابخانه ها مناسب است. چون نویسنده کتابخانه نمی‌داند توسعه دهنده بعدی با کدام نسخه کامپایل خواهد کرد، فقط یک حداقل نسخه را مشخص می‌کند.

اما وقتی شما به‌عنوان توسعه دهنده قصد دیپلوی یک اپلیکیشن را دارید، دقیقاً می‌دانید که از چه نسخه ای از کامپایلر استفاده می‌کنید. بنابراین بهتر است نسخه را دقیق قفل کنید تا در زمان بررسی کد توسط دیگران، کاملاً مشخص باشد از چه نسخه ای استفاده کرده‌اید. برای مثال به جای:

بنویسید:

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

۱۲. رعایت نکردن راهنمای سبک کدنویسی (Style Guide)

استانداردهای کدنویسی در سالیدیتی در یک پست جداگانه منتشر شده است، اما نکات کلیدی آن عبارت‌اند از:

  • تابع constructor باید اولین تابع در قرارداد باشد.

  • در صورت وجود، توابع fallback() و receive() باید بعد از constructor بیایند.

  • سپس به‌ترتیب: توابع external، سپس public، سپس internal و در نهایت private نوشته شوند.

  • در هر دسته‌بندی:

    • ابتدا توابع payable

    • سپس توابع non-payable (غیر قابل دریافت اتر)

    • و در آخر توابع view و pure

رعایت این ترتیب، خوانایی کد را افزایش داده و فهم ساختار قرارداد را برای دیگران آسان‌تر می‌کند.

۱۳. نداشتن لاگ (event) یا استفاده نادرست از event ها

در شبکه اتریوم، هیچ روش داخلی برای مشاهده مستقیم تمام تراکنش های ارسال شده به یک قرارداد هوشمند وجود ندارد، مگر اینکه این اطلاعات را از بلاک‌اکسپلوررها جستجو کنید. اما با استفاده از eventها در قرارداد می‌توانید این قابلیت را فراهم کنید.

نکات کلی در مورد استفاده از رویدادها:

  • هر تابعی که متغیر ذخیره‌شده (storage variable) را تغییر می‌دهد، باید یک event منتشر کند.

  • رویداد باید اطلاعات کافی داشته باشد تا فردی که لاگ ها را بررسی می‌کند بتواند تشخیص دهد مقدار متغیر در آن لحظه چه بوده است.

  • هر پارامتر از نوع address باید با indexed مشخص شود تا بتوان به راحتی فعالیت یک کیف پول خاص را دنبال کرد.

  • توابع view و pure نباید رویداد منتشر کنند چون وضعیت را تغییر نمی‌دهند.

به‌طور کلی، اگر در کدی متغیری را تغییر می‌دهید یا انتقال اتر انجام می‌دهید، باید یک رویداد منتشر کنید.

۱۴. ننوشتن تست های واحد (Unit Tests)

چطور مطمئن می‌شوید قرارداد شما در تمام شرایط ممکن به درستی کار می‌کند، اگر هیچ‌گاه آن را تست نکرده باشید؟

از دید ما، جای تعجب دارد که برخی قراردادهای هوشمند بدون هیچ تست واحدی دیپلوی می‌شوند. این نباید اتفاق بیفتد.

نوشتن تست واحد به شما امکان می‌دهد:

  • منطق قرارداد را در شرایط مختلف بررسی کنید.

  • از بروز خطاهای ناخواسته جلوگیری کنید.

  • باگ ها را قبل از انتشار عمومی کشف کنید.

  • اطمینان بیشتری به کاربران و ممیزان بدهید.

توصیه: پیش از هرگونه دیپلوی روی شبکه اصلی، یک مجموعه تست کامل برای تمام توابع بنویسید (با ابزارهایی مثل Foundry یا Hardhat).

۱۵. گرد کردن (Rounding) در جهت اشتباه

در سالیدیتی، چون از اعشار (float) پشتیبانی نمی‌شود، عملیات تقسیم همیشه به سمت پایین گرد می‌شود. مثلاً اگر 100 را تقسیم بر 3 کنید پاسخ 33 خواهد بود، در صورتی که جواب درست 33/3333 است.

در اینجا، 0.3333 واحد ناپدید شده! این موضوع اهمیت زیادی دارد و می‌تواند باعث از دست رفتن سرمایه یا سوء‌استفاده شود.

🔑 قانون طلایی در تقسیم: همیشه طوری گرد کنید که کاربر ضرر کند یا پروتکل سود کند.

برای مثال، اگر در حال محاسبه مبلغی هستید که یک کاربر باید برای چیزی بپردازد، تقسیم باعث می‌شود تخمین کمتر از مقدار واقعی باشد. در مثال بالا، کاربر 0.3333 تخفیف دریافت می‌کند.

وضعیت ۱: وقتی پروتکل به کاربر پرداخت می‌کند

اگر با 100 / 3 میزان پرداخت به کاربر را محاسبه کنیم، کاربر فقط 33 واحد دریافت می‌کند (کمتر از مقدار واقعی). این حالت خوب است، چون کاربر نمی‌تواند از پروتکل سوءاستفاده کند.

وضعیت ۲: محاسبه اینکه کاربر چقدر باید پرداخت کند

از طرف دیگر، اگر ما بخواهیم با محاسبهٔ ۳÷۱۰۰ تعیین کنیم که کاربر باید چه مقدار به قرارداد هوشمند پرداخت کند، با یک مشکل مواجه خواهیم شد، زیرا کاربر ۰.۳۳۳ کمتر از مقدار واقعی پرداخت می‌کند. اگر کاربر بتواند آن دارایی را با سود ۰.۳۳۳ بفروشد، می‌تواند این فرآیند را بارها تکرار کند تا در نهایت پروتکل را تخلیه کند!

کار درست در این شرایط این است که یک واحد به نتیجه تقسیم اضافه کنیم تا مقداری که در اعشار از دست رفته جبران شود. به‌عبارت دیگر، باید محاسبه کنیم که کاربر چقدر باید پرداخت کند به‌صورت ۱ + ۳÷۱۰۰، بنابراین کاربر باید ۳۴ واحد برای دارایی‌ای که ۳۳.۳۳۳ ارزش دارد پرداخت کند. این مقدار اندکِ از دست‌رفته برای کاربر، از سرقت قرارداد هوشمند جلوگیری خواهد کرد.

۱۶. استفاده نکردن از ابزار فرمت کننده کد در سالیدیتی

هیچ نیازی به اختراع دوبارهٔ چرخ برای فرمت‌بندی کدهای Solidity وجود ندارد. می‌توانید از دستور forge fmt در Foundry یا ابزار solfmt استفاده کنید. این کار باعث می‌شود کد شما برای بازبین‌ها خواناتر و قابل‌درک‌تر شود.

کدی که به‌صورت زیر نوشته شده، بدون دلیل خاصی خوانایی کمی دارد:

این کد باید از طریق ابزار فرمت‌کننده اجرا شود تا فاصله گذاری ها یکنواخت و خواناتر شود:

۱۷. استفاده از ‎_msgSender()‎ در قراردادهایی که از متاتراکنش ها پشتیبانی نمی‌کنند

توسعه دهندگان تازه کار Solidity اغلب با استفاده مکرر از تابع ‎_msgSender()‎ در قراردادهای OpenZeppelin سردرگم می‌شوند. برای مثال، در کتابخانه ERC-20 شرکت OpenZeppelin از ‎_msgSender()‎ استفاده شده است:

تصویری از کد OpenZeppelin با استفاده از تابع _msgSender()

مگر اینکه در حال ساخت قراردادی باشید که از تراکنش‌های بدون گاز یا متاتراکنش پشتیبانی می‌کند، باید از msg.sender معمولی استفاده کنید، نه ‎_msgSender()‎.

‎_msgSender()‎ تابعی است که توسط قرارداد Context.sol در OpenZeppelin تعریف شده است:

تصویر Context.sol از OpenZeppelin که _msgSender() هایلایت شده است

این تابع فقط در قراردادهایی کاربرد دارد که از متاتراکنش ها پشتیبانی می‌کنند.

متاتراکنش یا تراکنش بدون گاز حالتی است که یک رِلیِر (relayer) تراکنش را به‌جای کاربر ارسال کرده و هزینه گاز آن را پرداخت می‌کند. از آنجایی که این تراکنش از طرف relayer ارسال شده، مقدار msg.sender، کاربر واقعی نخواهد بود. قراردادهایی که از متاتراکنش استفاده می‌کنند، فرستنده واقعی را در جای دیگری از تراکنش رمزگذاری کرده و از طریق بازنویسی تابع ‎_msgSender()‎ آن را مشخص می‌کنند.

اگر چنین کاری انجام نمی‌دهید، هیچ دلیلی برای استفاده از ‎_msgSender()‎ وجود ندارد. در این صورت، تنها از msg.sender استفاده کنید.

۱۸. کامیت کردن تصادفی کلیدهای API یا کلیدهای خصوصی در گیت هاب

اگرچه این اتفاق زیاد رخ نمی‌دهد، اما در موارد معدودی که رخ داده، پیامدهای بسیار فاجعه‌باری داشته است. اگر کلیدهای API یا کلیدهای خصوصی را در فایل .env قرار می‌دهید، همیشه این فایل را به .gitignore اضافه کنید تا از کامیت شدن آن جلوگیری شود.

۱۹. در نظر نگرفتن frontrunning، اسلیپیج (slippage)، یا تأخیر بین امضای تراکنش و اجرای آن

Frontrunning یک مشکل غیرمنتظره در قراردادهای Solidity است، زیرا در برنامه نویسی Web2 معمولاً نمونه‌ای مشابه آن وجود ندارد.

مثال ۱: تغییر قیمت در حالی که تراکنش خرید در حال انتظار است

به قرارداد زیر توجه کنید که به فروشنده یک NFT اجازه می‌دهد تا در یک تراکنش، توکن خود را با USDC از خریدار معاوضه کند. از نظر تئوری این روش مزیت دارد چون هیچ‌کدام از طرفین مجبور نیستند ابتدا توکن خود را ارسال کنند و به طرف مقابل اعتماد کنند که او نیز توکن خود را ارسال خواهد کرد.

اما این قرارداد دارای آسیب پذیری frontrunning است. فروشنده می‌تواند قیمت معاوضه را در حالی که تراکنش در حال انتظار است، تغییر دهد.

هر زمان که قرار است توکن هایی از حساب کاربر منتقل شود، باید کاربر ملزم باشد اطلاعاتی را وارد کند که حداکثر مقداری را که حاضر است بپردازد، مشخص کند تا فروشنده نتواند در حین انتظار تراکنش، قیمت را تغییر دهد.

مثال ۲: NFT که با هر خرید، قیمت آن افزایش می‌یابد

در این مثال، قیمت فروش NFT طوری برنامه‌ریزی شده که با هر خرید، ۵٪ افزایش یابد. این قرارداد نیز مشکل مشابهی دارد. قیمتی که خریدار هنگام امضای تراکنش می‌بیند، ممکن است همان قیمتی نباشد که تراکنش با آن تأیید می‌شود. اگر ۱۰ خریدار به طور هم‌زمان تراکنش خرید بفرستند، ۹ نفر از آن‌ها قیمت بالاتری نسبت به انتظار خود پرداخت خواهند کرد.

وقتی یک قرارداد محاسبه می‌کند که چه مقدار توکن باید از حساب کاربر منتقل شود، باید کاربر یک سقف برای حداکثر مقدار قابل انتقال مشخص کند.

حتی مشکلی ظریف‌تر نیز وجود دارد: مالک ممکن است توکن را در حالی که تراکنش خریدار هنوز در حال انتظار است، تغییر دهد!

در این حالت، احتمال دارد خریدار هنوز برای توکن جدید به قرارداد اجازه انتقال نداده باشد و در نتیجه transferFrom با شکست مواجه شود. اما در یک قرارداد پیچیده‌تر که ممکن است مجوزهای متعددی داشته باشد، این موضوع می‌تواند واقعاً مشکل‌ساز شود.

۲۰. توابعی که امکان انجام چندباره‌ یک تراکنش توسط کاربر را در نظر نمی‌گیرند

قراردادهای هوشمند باید احتمال این را در نظر بگیرند که یک کاربر ممکن است یک تراکنش خاص را بیش از یک بار انجام دهد. به مثال زیر توجه کنید:

قراردادهای هوشمند باید احتمال این را در نظر بگیرند که یک کاربر ممکن است یک تراکنش خاص را بیش از یک بار انجام دهد. به مثال زیر توجه کنید:

اگر تابع deposit دوبار فراخوانی شود، موجودی (balance) قبلی با تراکنش دوم جایگزین می‌شود و مبلغ تراکنش اول از بین می‌رود.

برای مثال، اگر کاربر ابتدا تابع deposit() را با مقدار ۱ اتر فراخوانی کند و سپس دوباره همان تابع را با مقدار ۲ اتر فراخوانی کند، موجودی نهایی آدرس فقط ۲ اتر خواهد بود، در حالی که در مجموع ۳ اتر واریز شده است.

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

به این ترتیب، هر بار که کاربر واریز انجام دهد، مبلغ به موجودی قبلی اش اضافه می‌شود و از بین نمی‌رود.

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

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

پکیج صفر تا صد آموزش سئو و بهینه سازی بصورت عملی
  • انتشار: ۲۲ اردیبهشت ۱۴۰۴

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

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

مشاهده همه

نظرات

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