آموزش تست Invariant در Foundry

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

تست Invariant یکی از روش‌های مهم در اعتبارسنجی کد است که در کنار تست واحد (Unit Test) و تست تصادفی (Fuzzing) برای بررسی صحت منطق برنامه به کار می‌رود.

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

چرا باید تست Invariant انجام داد؟

تست Invariant به شما این امکان را می‌دهد تا بخش‌هایی از منطق قرارداد هوشمند را بررسی کنید که تست‌های واحد معمولاً از آن‌ها غافل می‌مانند. تست واحد فقط ویژگی‌هایی را که صراحتاً در تست مشخص شده‌اند ارزیابی می‌کند و خارج از آن محدوده را در نظر نمی‌گیرد. اما در تست Invariant، سیستم قرارداد هوشمند را در وضعیت‌های مختلف و تصادفی اجرا می‌کند تا خطاهای احتمالی در منطق داخلی آن آشکار شوند.

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

Invariants چیست؟

Invariants یا شرط های ثابت، قوانینی هستند که تحت مجموعه‌ای مشخص از فرضیات، همیشه باید برقرار بمانند. مثلاً در یک قرارداد ERC20، یکی از این قوانین این است که مجموع همه موجودی‌های حساب‌ها باید همیشه با total supply برابر باشد. اگر اجرای یک تابع یا تراکنش باعث شود این تساوی به‌هم بخورد، یعنی در کد مشکلی وجود دارد و سیستم دیگر به‌درستی عمل نمی‌کند.

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

  • اگر هیچ‌کدام از تابع‌های mint یا burn اجرا نشوند، مقدار total supply در توکن ERC20 نباید تغییر کند.

  • مجموع پاداش‌هایی که یک قرارداد هوشمند ارائه می‌دهد نباید از یک درصد مشخص طی یک بازه زمانی بیشتر شود.

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

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

شروع کار

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

ویژگی این نوع تست در Foundry این است که وضعیت (state) قرارداد بین اجرای توابع مختلف حفظ می‌شود؛ یعنی خروجی و تغییرات هر بار اجرای تابع، روی اجرای بعدی تأثیر دارد. به همین دلیل به آن تست دارای وضعیت یا حالت‌دار گفته می‌شود.

برای اینکه یک پروژه Foundry برای تست Invariant بسازید، دستورهای زیر را در ترمینال اجرا کنید:

بعد از اجرای این دستورات، پروژه Foundry ایجاد می‌شود و می‌توانیم کار روی قرارداد و تست را آغاز کنیم.

تنظیمات Foundry

برای تست های Invariant در Foundry می‌توانیم برخی تنظیمات اختیاری را در فایل پیکربندی foundry.toml مشخص کنیم. اگر هیچ مقداری برای این تنظیمات وارد نکنیم، Foundry از مقادیر پیش‌فرض خودش استفاده می‌کند. در این مقاله فقط تنظیماتی را که واقعاً مهم هستند و در روند کار تأثیر دارند، تنظیم خواهیم کرد. اگر می‌خواهید فهرست کامل گزینه‌های قابل تنظیم برای تست Invariant را ببینید، به مستندات مربوطه مراجعه کنید.

در ادامه، برخی از تنظیمات پرکاربرد را معرفی می‌کنیم:

  • runs: تعداد دفعاتی که هر گروه تست Invariant باید اجرا شود (مقدار پیش‌فرض: ۲۵۶)

  • depth: تعداد دفعاتی که در هر بار اجرا، توابع مختلف قرارداد فراخوانی می‌شوند تا سعی شود شرط های ثابت نقض شوند (مقدار پیش‌فرض: ۱۵)

  • fail_on_revert: اگر فعال شود، در صورتی که حین تست، تراکنشی برگشت بخورد (revert شود)، تست با شکست مواجه خواهد شد. مقدار پیش‌فرض این گزینه false است.

نمونه‌ای از تنظیمات بخش Invariant در فایل foundry.toml به شکل زیر است:

همچنین می‌توانید این مقادیر را از طریق متغیرهای محیطی (Environment Variables) نیز تنظیم کنید. به‌عنوان مثال:
این روش معمولاً زمانی کاربرد دارد که بخواهید مقادیر تست را بدون تغییر فایل پروژه و به‌صورت موقتی تعیین کنید.

یک مثال ساده

در این بخش، یک مثال ساده از تست Invariant را بررسی می‌کنیم.

ابتدا فایل Counter.sol که به‌صورت پیش‌فرض در پروژه Foundry ایجاد شده را به Deposit.sol تغییر نام دهید و کد زیر را داخل آن قرار دهید:

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

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

  • هر کاربری که اتر واریز کرده است، باید بتواند همان مقدار را برداشت کند.

  • مقدار برداشت‌شده دقیقاً باید با مقدار واریز‌شده برابر باشد.

برای اطمینان از درستی این منطق، یک تست Invariant می‌نویسیم که بررسی کند آیا این شرط‌ها همیشه برقرار هستند یا نه.

به پوشه test در پروژه Foundry خود بروید، فایل Counter.t.sol را به Deposit.t.sol تغییر نام دهید، و سپس کد زیر را در آن قرار دهید:

توضیح تست

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

هدف Invariant در این مثال:

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

  • مقدار واریزشده دقیقاً با مقدار برداشت‌شده برابر باشد.

تستی که این شرط ها را بررسی می‌کند، تابع invariant_alwaysWithdrawable است:

به این نکته توجه کنید که نام تابع با کلمه invariant شروع شده است. این موضوع اهمیت زیادی دارد، چون Foundry از همین پیشوند استفاده می‌کند تا بفهمد این تابع، یک تست Invariant است.

در این تست، از قرارداد تست (یعنی خود فایل InvariantDeposit) یک اتر واریز می‌کنیم. قرارداد Deposit با استفاده از mapping balance پیگیری می‌کند که هر کاربر چقدر واریز کرده است. بلافاصله پس از واریز، با استفاده از deposit.balance(address(this)) موجودی را بررسی می‌کنیم. انتظار داریم این مقدار برابر یک اتر باشد، چون همین مقدار را واریز کردیم.

سپس تابع withdraw() را اجرا می‌کنیم تا همان اتر را برداشت کنیم. پس از برداشت، دوباره موجودی را بررسی می‌کنیم. در این مرحله، انتظار داریم که موجودی صفر شده باشد.

برای ثبت این مقادیر از دو متغیر balanceBefore و balanceAfter استفاده می‌کنیم.

ابتدا با استفاده از assertEq(balanceBefore, 1 ether) مطمئن می‌شویم که مقدار واریزشده دقیقاً همان یک اتر بوده است.

در مرحله بعد، با assertGt(balanceBefore, balanceAfter) بررسی می‌کنیم که مقدار برداشت انجام شده و موجودی کاهش یافته است. این یعنی قرارداد به درستی کار می‌کند و شرط Invariant نقض نشده است.

اگر این تست را با دستور زیر اجرا کنیم:

خروجی مشابه زیر دریافت خواهیم کرد:
این نتیجه نشان می‌دهد که تست با موفقیت اجرا شده، شرط Invariant برقرار بوده، و هیچ خطایی در اجرای ۲۵۶ بار تست مشاهده نشده است.

پارامترهای تست

در خروجی تست هایی که در Foundry اجرا می‌شوند، معمولاً با سه پارامتر مهم مواجه می‌شویم: runs، calls و reverts. در ادامه توضیح می‌دهیم هر کدام چه مفهومی دارند:

  • runs
    این پارامتر مشخص می‌کند که یک تابع تست (مثلاً invariant_alwaysWithdrawable) چند بار اجرا شده است. در هر بار اجرا، Foundry شرایط و ورودی‌های متفاوتی را به تابع تست می‌دهد تا سناریوهای مختلفی را پوشش دهد و مطمئن شود قرارداد در شرایط گوناگون هم به‌درستی عمل می‌کند.

  • calls
    تعداد دفعاتی است که توابع مختلف داخل قرارداد هوشمند، در طول یک اجرای تست (یک run) فراخوانی شده‌اند. این فراخوانی‌ها می‌توانند شامل deposit()، withdraw() یا هر تابع دیگری باشند. در واقع calls نشان می‌دهد که موتور تست چند بار تلاش کرده قرارداد را تحت فشار قرار دهد و واکنش‌های مختلف آن را بررسی کند.

  • reverts
    تعداد دفعاتی است که یک فراخوانی به یکی از توابع قرارداد باعث برگشت تراکنش (revert) شده است. این حالت معمولاً زمانی رخ می‌دهد که خطایی در اجرای تابع وجود داشته باشد، مثلاً require شکست بخورد یا شرایط اجرای تابع برآورده نشده باشد.

انتظار برای Revert شدن

در خروجی تست قبلی دیدیم که تست با موفقیت اجرا شد و ابزار تست در مجموع ۳۸۴۰ بار توابع مختلف قرارداد ما را فراخوانی کرد تا سعی کند شرط های ثابت (invariants) را نقض کند. این مقدار دقیقاً برابر با پارامتر calls بود.

همچنین دیدیم که ۱۹۱۷ بار تراکنش ها با شکست مواجه شدند (revert شدند). این اتفاق معمولاً زمانی رخ می‌دهد که ابزار تست یا موتور فازینگ سعی می‌کند تابعی از قرارداد را بدون رعایت پیش‌نیازهای آن اجرا کند. برای مثال، فراخوانی تابع withdraw() توسط آدرسی که هیچ موجودی ندارد، منجر به revert می‌شود.

برای بررسی دقیق‌تر این موضوع، فایل foundry.toml را ویرایش می‌کنیم و تنظیم زیر را به آن اضافه می‌کنیم:

با این تنظیم، اگر در طول تست، هرگونه revert اتفاق بیفتد، تست با شکست مواجه خواهد شد، حتی اگر invariant هنوز برقرار باشد.

اکنون دوباره تست را با دستور زیر اجرا می‌کنیم:

و خروجی مشابه زیر دریافت می‌کنیم:

این پیام نشان می‌دهد که ابزار تست به‌صورت تصادفی، بلافاصله در ابتدای تست تابع withdraw() را بدون هیچ واریزی قبلی فراخوانی کرده است. از آنجایی که هیچ موجودی وجود نداشته، تابع withdraw() طبق تعریف خودش دچار خطا شده و revert داده است.

دلیل بروز این رفتار این است که در روش Open Testing، همه توابع قرارداد به‌صورت پیش‌فرض در دسترس موتور فازینگ هستند، حتی اگر ما مستقیماً آن‌ها را در کد تست صدا نزنیم. در ادامه مقاله، در بخش مربوط به Invariant targets، یاد می‌گیریم که چطور فقط توابع یا قراردادهای خاصی را هدف تست قرار دهیم و سایر موارد را مستثنا کنیم.

در اینجا، چون فراخوانی تابع withdraw() بدون واریز قبلی انجام شده و شرط require در آن فعال بوده، این فراخوانی منجر به revert شده است. به همین دلیل، با فعال بودن گزینه fail_on_revert، تست شکست خورده است.

نکته مهم: بعد از انجام این بررسی، حتماً مقدار fail_on_revert را به false بازگردانید تا تست ها در مواجهه با خطاهای کنترل‌شده متوقف نشوند:

در غیر این صورت، حتی در شرایطی که invariant برقرار است، تست به خاطر revertهایی که انتظار آن‌ها می‌رود شکست می‌خورد.

ایجاد آسیب‌پذیری عمدی در قرارداد برای تست

برای اینکه درک بهتری از تست Invariant داشته باشیم و ببینیم چگونه این تست می‌تواند خطاها یا آسیب‌پذیری‌های واقعی را شناسایی کند، بیایید عمداً یک آسیب‌پذیری به قرارداد اضافه کنیم؛ به‌طوری که هر کسی بتواند موجودی (balance) هر آدرسی را به دلخواه تغییر دهد.

در فایل Deposit.sol، تابع زیر را به قرارداد اضافه کنید:

این تابع به هر کسی اجازه می‌دهد تا موجودی هر آدرس دلخواه را به عدد دلخواه تغییر دهد. بدون هیچ محدودیت یا کنترل دسترسی.

اکنون دوباره تست را با دستور زیر اجرا می‌کنیم:

خروجی تست به شکل زیر خواهد بود:

اگر به آخرین فراخوانی در دنباله تست دقت کنید، می‌بینید که تابع changeBalance فراخوانی شده و پارامترهایی که به آن پاس داده شده‌اند شامل:

  1. آدرس قرارداد تست Foundry

  2. عددی کاملاً تصادفی (در اینجا: 2193)

می‌باشند.

در نتیجه، این فراخوانی باعث می‌شود موجودی آدرس قرارداد تست، که در ابتدا یک اتر واریز کرده بود، به ۲۱۹۳ تغییر کند. پس وقتی تابع withdraw() اجرا می‌شود، کاربر بیشتر از آنچه واریز کرده برداشت می‌کند.

این رفتار باعث می‌شود شرط Invariant ما که می‌گوید: “مقدار برداشت‌شده باید دقیقاً با مقدار واریزشده برابر باشد”نقض شود و تست با شکست مواجه گردد.

اگر بخواهیم مطمئن شویم که آدرسی که به تابع changeBalance داده شده، همان آدرس قرارداد تست ما بوده، می‌توانیم از قابلیت impersonation (تغییر موقتی هویت آدرس ها) در ابزار تست Foundry استفاده کنیم تا آدرس مشخصی را به‌صورت کنترل‌شده در تست مورد بررسی قرار دهیم.

این روش را در بخش‌های بعدی، زمانی که به بحث اهداف تست Invariant و impersonation دقیق‌تر می‌پردازیم، بیشتر بررسی خواهیم کرد.

اما تابع changeBalance() که داخل تست ما نبود، پس چطور فراخوانی شد؟!

اینجاست که قدرت واقعی تست Invariant خودش را نشان می‌دهد.

با اینکه ما هیچ‌وقت تابع changeBalance() را به‌طور مستقیم در کد تست فراخوانی نکرده بودیم، ابزار تست Invariant در Foundry به‌صورت تصادفی این تابع را نیز در کنار توابعی که به‌طور مستقیم در کد تست نوشته بودیم، اجرا کرد.

دلیل این اتفاق این است که در تست های Invariant، موتور fuzzing به‌صورت خودکار و تصادفی تمام توابع عمومی (public و external) قرارداد را بررسی و فراخوانی می‌کند، حتی اگر ما صراحتاً آن‌ها را در کد تست صدا نزنیم. این کار با هدف شکستن شرط‌های ثابت (invariants) انجام می‌شود.

همین ویژگی باعث می‌شود تست Invariant بسیار قدرتمند باشد. چون نه‌تنها سناریوهایی که “در ذهن توسعه دهنده بوده” را تست می‌کند، بلکه به سراغ سناریوهایی می‌رود که حتی فکرش را هم نمی‌کردیم.

و این دقیقاً همان چیزی است که امنیت و پایداری یک قرارداد هوشمند را تضمین می‌کند.

تغییر موجودی یک کاربر به‌جای موجودی خود قرارداد

در این مرحله، تست را کمی تغییر می‌دهیم تا به‌جای اینکه قرارداد تست (یعنی خود فایل InvariantDeposit) نقش فرستنده تراکنش را داشته باشد، از یک آدرس دلخواه دیگر مثل address(0xaa) استفاده کنیم.

نسخه جدید تابع تست به شکل زیر است:

در این نسخه از تست، ما همچنان می‌خواهیم شرط Invariant را بررسی کنیم: “واریزکننده باید بتواند همان مقدار اتری را که واریز کرده است، برداشت کند.”

اما این بار آدرس واریزکننده را از قرارداد تست جدا کرده‌ایم و از آدرس 0xaa استفاده می‌کنیم تا سناریو واقعی‌تری داشته باشیم.

سپس با اجرای دستور زیر:

نتیجه زیر را دریافت می‌کنیم:

در خط آخر دنباله فراخوانی ها به‌وضوح می‌بینیم که تابع changeBalance دوباره به‌صورت تصادفی فراخوانی شده، و این بار روی آدرس 0xaa اجرا شده است، همان آدرسی که ما برای واریز استفاده کرده بودیم. مقدار جدید هم یک عدد کاملاً تصادفی است.

این اتفاق باعث می‌شود مقدار موجودی این آدرس (که قبلاً دقیقاً ۱ اتر واریز کرده بود) به‌طور غیرمجاز تغییر کند. در نتیجه، وقتی این آدرس قصد برداشت داشته باشد، یا چیزی کمتر از آنچه واریز کرده دریافت می‌کند، یا اصلاً نمی‌تواند برداشت کند.

این رفتار، شرط Invariant اول ما را نقض می‌کند: “واریزکننده باید بتواند همان مقدار اتری که واریز کرده است را برداشت کند.”

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

در این‌جا، تغییر دستی موجودی توسط changeBalance باعث شده شرط بالا نقض شود، بدون آن‌که در ظاهر تابع withdraw رفتار نادرستی از خود نشان داده باشد. این ضعف امنیتی در منطق ذخیره سازی و اعتبارسنجی داده ها است، و تست Invariant آن را به‌خوبی آشکار کرده است.

Invariantهای شرطی

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

فقط زمانی باید برقرار باشد که هیچ توکنی صادر (mint) نشده باشد. اگر توکن صادر شده باشد، طبیعی است که مقدار totalSupply دیگر صفر نباشد.

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

برای اطلاعات بیشتر درباره این نوع invariant ها، می‌توانید به منبع اصلی آن مراجعه کنید.

تغییر تنظیمات تست Invariant

اگر بخواهیم تعداد دفعات اجرای تست (runs) را افزایش دهیم، می‌توانیم تنظیمات مربوطه را در فایل foundry.toml قرار دهیم، همان‌طور که در بخش‌های قبلی اشاره شد.

در بخش [invariant] از فایل foundry.toml، خطوط زیر را اضافه کنید:

اکنون دوباره تست را با دستور زیر اجرا کنید (اطمینان حاصل کنید که تابع changeBalance از قرارداد حذف یا غیرفعال شده باشد):
خروجی تست:

همان‌طور که می‌بینید، تست با موفقیت انجام شده، اما این بار تعداد اجرای تست (runs)، تعداد فراخوانی ها (calls) و تعداد خطاهای revert نسبت به حالت پیش‌فرض به‌مراتب بیشتر است، چون این مقادیر را در تنظیمات افزایش داده‌ایم.

شما می‌توانید مقدار runs را به هر عددی بین صفر تا uint32.max تنظیم کنید. اگر عددی بزرگ‌تر از uint32 قرار دهید، Foundry هنگام اجرای تست خطا می‌دهد. برای مثال، اگر مقدار runs را به شکل غیرمجاز زیر تنظیم کنید:

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

مثال‌هایی نزدیک به شرایط واقعی

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

در این بخش، تست Invariant را روی قرارداد SideEntranceLenderPool انجام می‌دهیم. این قرارداد متعلق به سطح چهارم از مسابقه معروف Damn Vulnerable DeFi CTF است، یک چالش امنیتی پرطرفدار در حوزه دیفای که برای شناسایی و درک آسیب‌پذیری‌های رایج طراحی شده است.

در ادامه، کد قرارداد SideEntranceLenderPool آورده خواهد شد تا روی آن تست invariant پیاده سازی کنیم:

قراردادی که در این بخش بررسی می‌کنیم نسخه‌ اصلاح‌ شده‌ای از SideEntranceLenderPool است که برای هماهنگی با ساختار پروژه Foundry و نیازهای تست ما کمی تغییر کرده است. همچنین کتابخانه مورد نیاز OpenZeppelin (Address) به پروژه اضافه شده و در قرارداد import شده است.

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

مهاجم، تابع flashLoan را صدا می‌زند و از قرارداد یک وام آنی (flash loan) می‌گیرد. سپس همان اتر قرض‌گرفته‌شده را با استفاده از تابع deposit به قرارداد بازمی‌گرداند، اما این بار به‌عنوان موجودی خودش (یعنی به‌عنوان سپرده کاربر). در نهایت، مهاجم می‌تواند با استفاده از تابع withdraw موجودی خودش را برداشت کند و از قرارداد خارج شود، در حالی که هیچ اتر واقعی متعلق به خودش واریز نکرده است.

پس شرط Invariant ما در این سناریو چیست؟

نکته کلیدی اینجاست:

  • قرارداد دارای یک سازنده قابل پرداخت (payable constructor) است.

  • اتر مورد استفاده برای وام، در زمان استقرار قرارداد و از طریق سازنده وارد شده است.

  • راهی برای برداشت این مقدار اولیه وجود ندارد.

  • اتر فقط از طریق تابع deposit می‌تواند وارد قرارداد شود و تنها کاربری که قبلاً واریز کرده باشد، می‌تواند با withdraw آن را برداشت کند.

با در نظر گرفتن این موارد، شرط Invariant ما به صورت زیر تعریف می‌شود:

در اینجا، initialPoolBalance یک متغیر وضعیت (state variable) عمومی است که مقدار اولیه اتر تزریق‌شده به قرارداد در زمان استقرار را نگه‌ می‌دارد.

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

اگر منطق قرارداد سالم باشد، این شرط همواره باید برقرار باشد. اما همان‌طور که توضیح داده شد، آسیب‌پذیری موجود در flashLoan می‌تواند باعث شود این شرط نقض شود. یعنی موجودی قرارداد از مقدار اولیه کمتر شود، بدون آنکه واریزی واقعی از خارج انجام شده باشد.

در بخش بعدی، با معرفی مفهوم Handler در تست Invariant در Foundry، تست های دقیق‌تر و هدفمندتری طراحی خواهیم کرد. این ابزار به ما کمک می‌کند رفتارهای خاصی را بهتر شبیه سازی و کنترل کنیم.

تست مبتنی بر Handler

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

Handler در واقع یک wrapper contract است. یعنی قراردادی که مانند یک پوشش روی قرارداد اصلی قرار می‌گیرد تا از طریق آن تعاملات انجام شود.

این روش زمانی بسیار مفید و حتی ضروری است که محیط تست نیاز به پیکربندی خاصی داشته باشد، مثلاً وقتی سازنده (constructor) قرارداد اصلی باید با پارامترهای مشخصی اجرا شود.

روش کار Handler:

در تابع setUp داخل فایل تست، به جای آنکه مستقیماً با قرارداد اصلی کار کنیم:

  1. ابتدا یک قرارداد Handler ایجاد می‌کنیم.

  2. این Handler طوری نوشته می‌شود که به قرارداد اصلی متصل باشد و توابع آن را فراخوانی کند.

  3. سپس با استفاده از تابع کمکی targetContract(address target) فقط همین Handler را به عنوان هدف تست تعریف می‌کنیم.

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

مزیت دیگر این روش چیست؟

اگر اجرای یک تابع در قرارداد اصلی نیاز به شرایط خاصی داشته باشد (مثلاً اینکه کاربر از قبل اتر واریز کرده باشد)، ما می‌توانیم این شرایط را در خود قرارداد Handler قبل از فراخوانی آن تابع مشخص کنیم.

به‌علاوه، Handler می‌تواند از ابزارهای داخلی Foundry مثل vm.deal، vm.prank و سایر cheat codeها استفاده کند، چون می‌تواند از forge-std/Test.sol ارث‌بری کند.

در ادامه این مقاله، این موضوع را به صورت عملی نشان خواهیم داد.

برای شروع، یک پوشه جدید به نام /handler داخل پوشه test بسازید و یک فایل به نام Handler.sol در آن قرار دهید.

در گام بعدی، کد قرارداد Handler را خواهیم نوشت:

ما در قرارداد Handler، توابعی تعریف کرده‌ایم که مستقیماً توابع قرارداد اصلی یعنی SideEntranceLenderPool را فراخوانی می‌کنند. هدف از این کار، تست کردن سناریوهای خاص‌تر و بررسی دقیق‌تر حاشیه‌های امنیتی قرارداد است. و حتی در عمل، امکان بهره برداری (exploit) از آسیب‌پذیری را فراهم می‌کند.

همان‌طور که پیش‌تر گفته شد، قرارداد Handler از کتابخانه forge-std/Test.sol ارث‌بری می‌کند. به این ترتیب، می‌توانیم در آن از قابلیت‌های ویژه Foundry مانند vm.deal استفاده کنیم.

در اینجا، ما در سازنده (constructor) قرارداد Handler از متد vm.deal استفاده کرده‌ایم تا به قرارداد Handler مقدار مشخصی اتر اختصاص دهیم. این اتر می‌تواند در حین اجرای تست‌ها، مثلا برای واریز یا تعاملات دیگر با قرارداد، مورد استفاده قرار گیرد.

اهداف تست Invariant و توابع کمکی تست

Foundry ابزارهایی به نام توابع کمکی تست (Test Helpers) در کتابخانه forge-std ارائه می‌دهد که به ما اجازه می‌دهد به‌صورت دقیق مشخص کنیم چه چیزی باید در تست Invariant هدف قرار بگیرد.

برخی از توابع کمکی مهم عبارت‌اند از:

  • targetContract(address newTargetedContract_)
  • targetSelector(FuzzSelector memory newTargetedSelector_)
  • excludeContract(address newExcludedContract_).

برای مشاهده فهرست کامل توابع کمکی تست می‌توانید به مستندات Foundry در اینجا و اینجا مراجعه کنید.

آماده سازی فایل تست اصلی:

اکنون باید یک فایل جدید به نام SideEntranceLenderPool.t.sol داخل پوشه test/ ایجاد کنیم. در این فایل، تست Invariant مربوط به قرارداد SideEntranceLenderPool را تعریف می‌کنیم و مشخص می‌کنیم که قرارداد Handler هدف اصلی تست باشد.

در گام بعدی، کد تست را داخل این فایل قرار می‌دهیم.

اجرای تست و مشاهده نتیجه

پس از قراردادن کد تست در فایل SideEntranceLenderPool.t.sol، تست را با دستور زیر اجرا می‌کنیم:

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

در دنباله فراخوانی‌ها می‌بینیم که ابتدا تابع flashLoan فراخوانی شده، سپس withdraw، که این همان مسیر حمله‌ای است که قبلاً درباره‌اش صحبت کردیم.

بررسی دقیق‌تر مسیر فراخوانی

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

خروجی:

نتیجه‌گیری

در این گزارش کاملاً مشخص است که ابتدا تابع flashLoan برای دریافت وام فراخوانی شده، سپس همان مقدار وام به‌صورت سپرده به قرارداد بازگردانده شده است (از طریق deposit)، و در نهایت با فراخوانی withdraw، مهاجم توانسته این اتر را از قرارداد خارج کند.

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

مثالی با یک عبارت ریاضی

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

(اگر بخواهیم تست را حالت‌دار کنیم، می‌توانیم از متغیرهای ذخیره سازی استفاده کنیم، اما فعلاً این موضوع خارج از بحث ماست.)

این مثال قرارداد ماست:

این مثال بسیار ساده است. ما فقط می‌خواهیم بررسی کنیم که مقدار متغیر بولی ok همیشه برابر با true باقی بماند. به عبارت دیگر، در تست invariant، تنها شرط ما این است:

مقدار ok فقط در صورتی به false تغییر می‌کند که تابع notOkay با عددی فراخوانی شود که شرط زیر را برقرار کند:

یعنی عدد x دقیقاً باید بین 11111 و 11113 باشد. مثلاً مقدار 11112 باعث می‌شود این عبارت کمتر از صفر شود و در نتیجه مقدار ok به false تغییر کند.

در نگاه اول ممکن است این تست خیلی ساده به‌نظر برسد، اما سوال اینجاست:

آیا موتور فازینگ Foundry (fuzzer) می‌تواند عدد دقیق 11112 را به‌صورت تصادفی تولید کند و invariant را بشکند؟

برای بررسی این موضوع، از روش تست مبتنی بر Handler استفاده می‌کنیم.

اکنون باید یک فایل به نام Handler_2.sol در مسیر /test/handler بسازید و کد مربوط به قرارداد Handler را در آن قرار دهید.

اکنون فایلی به نام Quadratic.t.sol داخل پوشه test بسازید و کد زیر را در آن قرار دهید:
در این تست، ما تابع invariant_NotOkay را به‌عنوان تابع اصلی invariant تعریف کرده‌ایم که بررسی می‌کند مقدار ok در قرارداد Quadratic همچنان برابر true باقی مانده است.

برای اجرای تست، از دستور زیر استفاده کنید:

خروجی اجرای تست:

تست با موفقیت اجرا شد و invariant همچنان برقرار ماند. اما مسئله اینجاست: عددی وجود دارد که می‌تواند این invariant را بشکند. ما بعداً آن عدد را مشخص خواهیم کرد، ولی فعلاً هدف این است که ببینیم آیا با افزایش تعداد اجرای تست (runs)، موتور فازینگ می‌تواند آن را پیدا کند یا نه.

در فایل foundry.toml، مقدار runs را به ۲۰۰۰۰ افزایش می‌دهیم:

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

حتی با اجرای ۲۰۰۰۰ بار تست و ۳۰۰٬۰۰۰ فراخوانی تابع، موتور fuzzing نتوانسته عددی پیدا کند که شرط (x - 11111) * (x - 11113) < 0 را برقرار کند و باعث شود مقدار ok برابر false شود.

یعنی invariant همچنان برقرار مانده، اما نه به این خاطر که قرارداد بدون خطاست، بلکه به این دلیل که fuzzer هنوز نتوانسته مقدار مناسبی را کشف کند.

برای تأیید اینکه چنین عددی واقعاً وجود دارد، می‌توانیم نمودار این تابع را در ابزارهایی مانند Desmos رسم کنیم. در نمودار حاصل از معادله:

به‌وضوح دیده می‌شود که خروجی تابع تنها در بازه بین x = 11111 و x = 11113 منفی می‌شود. به‌طور خاص، x = 11112 مقدار شرط را منفی می‌کند و در نتیجه باعث می‌شود ok به false تغییر کند.

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

تصویر

تصویر به‌روشنی نشان می‌دهد که عددی که invariant را می‌شکند ۱۱۱۱۲ است.

اکنون قصد داریم با محدود کردن بازه ورودی‌ها در قرارداد Handler، به موتور fuzzing کمک کنیم تا راحت‌تر این مقدار بحرانی را پیدا کند.

در فایل Handler_2.sol، تابع notOkay را به‌صورت زیر تغییر دهید:

تابع کمکی bound() از کتابخانه forge-std/Test.sol ارائه شده و به ما امکان می‌دهد مقدار x را در یک بازه مشخص نگه داریم. در اینجا، ما بازه را بین ۱۰٬۰۰۰ تا ۱۰۰٬۰۰۰ انتخاب کرده‌ایم، تا عدد ۱۱۱۱۲ حتماً در بین گزینه‌های تولیدشده قرار بگیرد.

اکنون تست را با دستور زیر اجرا می‌کنیم:

خروجی تست:

این بار، invariant شکست خورد و دلیل آن، محدود کردن بازه ورودی‌های تولید شده توسط fuzzer بود. اما نکته جالب اینجاست که در دنباله فراخوانی ها (call sequence)، عدد 11112 مستقیماً نمایش داده نمی‌شود.

حتی با استفاده از فلگ -vvv که برای مشاهده جزئیات بیشتر در تست استفاده کردیم.

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

لاگ‌ها نشان می‌دهند که fuzzer تابع notOkay را با اعدادی فراخوانی کرده که حتی به عدد موردنظر ما نزدیک هم نبوده‌اند. با این حال، تابع bound این ورودی‌ها را تغییر داده تا زمانی که به عدد درست رسیدیم. همان‌طور که در نتیجه نهایی bound و دنباله فراخوانی‌ها مشاهده می‌شود.

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

جمع‌بندی

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

همچنین مباحثی مانند invariant های شرطی، ساختار مبتنی بر Handler، و زمان مناسب برای محدود کردن بازه ورودی‌های فازینگ با تابع bound را نیز مورد بررسی قرار دادیم.

این تکنیک‌ها به ما کمک می‌کنند تا تست‌هایی دقیق‌تر، هدفمندتر و کارآمدتر برای شناسایی باگ‌ها و آسیب‌پذیری‌های منطقی در قراردادهای هوشمند ایجاد کنیم.

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

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

دوره آموزش برنامه نویسی پایتون در 24 ساعت + ساخت ربات تلگرامی
  • انتشار: ۶ مرداد ۱۴۰۴

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

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

مشاهده همه

نظرات

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