در این مقاله، مفهوم تست 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 بسازید، دستورهای زیر را در ترمینال اجرا کنید:
1 2 |
forge init invariant-exercise cd invariant-exercise |
تنظیمات Foundry
برای تست های Invariant در Foundry میتوانیم برخی تنظیمات اختیاری را در فایل پیکربندی foundry.toml
مشخص کنیم. اگر هیچ مقداری برای این تنظیمات وارد نکنیم، Foundry از مقادیر پیشفرض خودش استفاده میکند. در این مقاله فقط تنظیماتی را که واقعاً مهم هستند و در روند کار تأثیر دارند، تنظیم خواهیم کرد. اگر میخواهید فهرست کامل گزینههای قابل تنظیم برای تست Invariant را ببینید، به مستندات مربوطه مراجعه کنید.
در ادامه، برخی از تنظیمات پرکاربرد را معرفی میکنیم:
-
runs
: تعداد دفعاتی که هر گروه تست Invariant باید اجرا شود (مقدار پیشفرض: ۲۵۶) -
depth
: تعداد دفعاتی که در هر بار اجرا، توابع مختلف قرارداد فراخوانی میشوند تا سعی شود شرط های ثابت نقض شوند (مقدار پیشفرض: ۱۵) -
fail_on_revert
: اگر فعال شود، در صورتی که حین تست، تراکنشی برگشت بخورد (revert شود)، تست با شکست مواجه خواهد شد. مقدار پیشفرض این گزینه false است.
نمونهای از تنظیمات بخش Invariant در فایل foundry.toml
به شکل زیر است:
1 2 3 |
[invariant] runs = 1000 depth = 1000 |
1 |
FOUNDRY_INVARIANT_RUNS=10000 |
یک مثال ساده
در این بخش، یک مثال ساده از تست Invariant را بررسی میکنیم.
ابتدا فایل Counter.sol
که بهصورت پیشفرض در پروژه Foundry ایجاد شده را به Deposit.sol
تغییر نام دهید و کد زیر را داخل آن قرار دهید:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
contract Deposit { address public seller = msg.sender; mapping(address => uint256) public balance; function deposit() external payable { balance[msg.sender] += msg.value; } function withdraw() external { uint256 amount = balance[msg.sender]; balance[msg.sender] = 0; (bool s, ) = msg.sender.call{value: amount}(""); require(s, "failed to send"); } } |
این قرارداد بسیار ساده است و به هر کاربری اجازه میدهد که مقدار دلخواهی اتر واریز کند و هر زمان که خواست، آن را برداشت نماید.
از آنجا که هیچ شرط یا محدودیتی روی برداشت وجود ندارد، منطقی است که هر مقدار اتر واریزشده توسط یک کاربر باید همیشه قابل برداشت باشد. به همین دلیل، شرط Invariant ما باید این باشد که:
-
هر کاربری که اتر واریز کرده است، باید بتواند همان مقدار را برداشت کند.
-
مقدار برداشتشده دقیقاً باید با مقدار واریزشده برابر باشد.
برای اطمینان از درستی این منطق، یک تست Invariant مینویسیم که بررسی کند آیا این شرطها همیشه برقرار هستند یا نه.
به پوشه test
در پروژه Foundry خود بروید، فایل Counter.t.sol
را به Deposit.t.sol
تغییر نام دهید، و سپس کد زیر را در آن قرار دهید:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import "forge-std/Test.sol"; import "../src/Deposit.sol"; contract InvariantDeposit is Test { Deposit deposit; function setUp() external { deposit = new Deposit(); vm.deal(address(deposit), 100 ether); // به قرارداد، اتر تزریق میکنیم } function invariant_alwaysWithdrawable() external payable { deposit.deposit{value: 1 ether}(); // واریز ۱ اتر uint256 balanceBefore = deposit.balance(address(this)); assertEq(balanceBefore, 1 ether); // بررسی مقدار واریزشده deposit.withdraw(); // برداشت uint256 balanceAfter = deposit.balance(address(this)); assertGt(balanceBefore, balanceAfter); // تأیید اینکه موجودی کاهش یافته } receive() external payable {} } |
توضیح تست
کاری که در این مرحله انجام میدهیم نوعی تست باز (Open Testing) است. در تست باز، Foundry بهصورت پیشفرض تمام قراردادهایی که داخل تابع تست ایجاد میشوند را بهعنوان هدف تست در نظر میگیرد. اگر مایل هستید در اینباره بیشتر بدانید، میتوانید مستندات مربوط به Open Testing را مطالعه کنید.
هدف Invariant در این مثال:
-
واریزکننده بتواند همان مقدار اتری که واریز کرده، برداشت کند.
-
مقدار واریزشده دقیقاً با مقدار برداشتشده برابر باشد.
تستی که این شرط ها را بررسی میکند، تابع invariant_alwaysWithdrawable
است:
1 2 3 4 5 6 7 8 |
function invariant_alwaysWithdrawable() external payable { deposit.deposit{value: 1 ether}(); uint256 balanceBefore = deposit.balance(address(this)); assertEq(balanceBefore, 1 ether); deposit.withdraw(); uint256 balanceAfter = deposit.balance(address(this)); assertGt(balanceBefore, balanceAfter); } |
به این نکته توجه کنید که نام تابع با کلمه invariant
شروع شده است. این موضوع اهمیت زیادی دارد، چون Foundry از همین پیشوند استفاده میکند تا بفهمد این تابع، یک تست Invariant است.
در این تست، از قرارداد تست (یعنی خود فایل InvariantDeposit
) یک اتر واریز میکنیم. قرارداد Deposit
با استفاده از mapping balance
پیگیری میکند که هر کاربر چقدر واریز کرده است. بلافاصله پس از واریز، با استفاده از deposit.balance(address(this))
موجودی را بررسی میکنیم. انتظار داریم این مقدار برابر یک اتر باشد، چون همین مقدار را واریز کردیم.
سپس تابع withdraw()
را اجرا میکنیم تا همان اتر را برداشت کنیم. پس از برداشت، دوباره موجودی را بررسی میکنیم. در این مرحله، انتظار داریم که موجودی صفر شده باشد.
برای ثبت این مقادیر از دو متغیر balanceBefore
و balanceAfter
استفاده میکنیم.
ابتدا با استفاده از assertEq(balanceBefore, 1 ether)
مطمئن میشویم که مقدار واریزشده دقیقاً همان یک اتر بوده است.
در مرحله بعد، با assertGt(balanceBefore, balanceAfter)
بررسی میکنیم که مقدار برداشت انجام شده و موجودی کاهش یافته است. این یعنی قرارداد به درستی کار میکند و شرط Invariant نقض نشده است.
اگر این تست را با دستور زیر اجرا کنیم:
1 |
forge test --mt invariant_alwaysWithdrawable |
1 2 3 |
Running 1 test for test/Deposit.t.sol:InvariantDeposit [PASS] invariant_alwaysWithdrawable() (runs: 256, calls: 3840, reverts: 1917) Test result: ok. 1 passed; 0 failed; finished in 347.19ms |
پارامترهای تست
در خروجی تست هایی که در Foundry اجرا میشوند، معمولاً با سه پارامتر مهم مواجه میشویم: runs
، calls
و reverts
. در ادامه توضیح میدهیم هر کدام چه مفهومی دارند:
-
runs
این پارامتر مشخص میکند که یک تابع تست (مثلاًinvariant_alwaysWithdrawable
) چند بار اجرا شده است. در هر بار اجرا، Foundry شرایط و ورودیهای متفاوتی را به تابع تست میدهد تا سناریوهای مختلفی را پوشش دهد و مطمئن شود قرارداد در شرایط گوناگون هم بهدرستی عمل میکند. -
calls
تعداد دفعاتی است که توابع مختلف داخل قرارداد هوشمند، در طول یک اجرای تست (یک run) فراخوانی شدهاند. این فراخوانیها میتوانند شاملdeposit()
،withdraw()
یا هر تابع دیگری باشند. در واقعcalls
نشان میدهد که موتور تست چند بار تلاش کرده قرارداد را تحت فشار قرار دهد و واکنشهای مختلف آن را بررسی کند. -
reverts
تعداد دفعاتی است که یک فراخوانی به یکی از توابع قرارداد باعث برگشت تراکنش (revert) شده است. این حالت معمولاً زمانی رخ میدهد که خطایی در اجرای تابع وجود داشته باشد، مثلاًrequire
شکست بخورد یا شرایط اجرای تابع برآورده نشده باشد.
انتظار برای Revert شدن
در خروجی تست قبلی دیدیم که تست با موفقیت اجرا شد و ابزار تست در مجموع ۳۸۴۰ بار توابع مختلف قرارداد ما را فراخوانی کرد تا سعی کند شرط های ثابت (invariants) را نقض کند. این مقدار دقیقاً برابر با پارامتر calls
بود.
همچنین دیدیم که ۱۹۱۷ بار تراکنش ها با شکست مواجه شدند (revert شدند). این اتفاق معمولاً زمانی رخ میدهد که ابزار تست یا موتور فازینگ سعی میکند تابعی از قرارداد را بدون رعایت پیشنیازهای آن اجرا کند. برای مثال، فراخوانی تابع withdraw()
توسط آدرسی که هیچ موجودی ندارد، منجر به revert
میشود.
برای بررسی دقیقتر این موضوع، فایل foundry.toml
را ویرایش میکنیم و تنظیم زیر را به آن اضافه میکنیم:
1 2 |
[invariant] fail_on_revert = true |
با این تنظیم، اگر در طول تست، هرگونه revert
اتفاق بیفتد، تست با شکست مواجه خواهد شد، حتی اگر invariant هنوز برقرار باشد.
اکنون دوباره تست را با دستور زیر اجرا میکنیم:
1 |
forge test --mt invariant_alwaysWithdrawable |
1 2 3 4 5 6 7 8 9 10 11 |
Test result: FAILED. 0 passed; 1 failed; finished in 8.53ms Failing tests: Encountered 1 failing test in test/Deposit.t.sol:InvariantDeposit [FAIL. Reason: no balance] [Sequence] sender=0x00000000000000000000000000000000e3d670d7 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=withdraw(), args=[] invariant_alwaysWithdrawable() (runs: 1, calls: 1, reverts: 1) Encountered a total of 1 failing tests, 0 tests succeeded |
این پیام نشان میدهد که ابزار تست بهصورت تصادفی، بلافاصله در ابتدای تست تابع withdraw()
را بدون هیچ واریزی قبلی فراخوانی کرده است. از آنجایی که هیچ موجودی وجود نداشته، تابع withdraw()
طبق تعریف خودش دچار خطا شده و revert
داده است.
دلیل بروز این رفتار این است که در روش Open Testing، همه توابع قرارداد بهصورت پیشفرض در دسترس موتور فازینگ هستند، حتی اگر ما مستقیماً آنها را در کد تست صدا نزنیم. در ادامه مقاله، در بخش مربوط به Invariant targets، یاد میگیریم که چطور فقط توابع یا قراردادهای خاصی را هدف تست قرار دهیم و سایر موارد را مستثنا کنیم.
در اینجا، چون فراخوانی تابع withdraw()
بدون واریز قبلی انجام شده و شرط require
در آن فعال بوده، این فراخوانی منجر به revert
شده است. به همین دلیل، با فعال بودن گزینه fail_on_revert
، تست شکست خورده است.
نکته مهم: بعد از انجام این بررسی، حتماً مقدار fail_on_revert
را به false
بازگردانید تا تست ها در مواجهه با خطاهای کنترلشده متوقف نشوند:
1 2 |
[invariant] fail_on_revert = false |
revert
هایی که انتظار آنها میرود شکست میخورد.
ایجاد آسیبپذیری عمدی در قرارداد برای تست
برای اینکه درک بهتری از تست Invariant داشته باشیم و ببینیم چگونه این تست میتواند خطاها یا آسیبپذیریهای واقعی را شناسایی کند، بیایید عمداً یک آسیبپذیری به قرارداد اضافه کنیم؛ بهطوری که هر کسی بتواند موجودی (balance) هر آدرسی را به دلخواه تغییر دهد.
در فایل Deposit.sol
، تابع زیر را به قرارداد اضافه کنید:
1 2 3 |
function changeBalance(address depositor, uint amount) public { balance[depositor] = amount; } |
این تابع به هر کسی اجازه میدهد تا موجودی هر آدرس دلخواه را به عدد دلخواه تغییر دهد. بدون هیچ محدودیت یا کنترل دسترسی.
اکنون دوباره تست را با دستور زیر اجرا میکنیم:
1 |
forge test --mt invariant_alwaysWithdrawable |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
Test result: FAILED. 0 passed; 1 failed; finished in 74.09ms Failing tests: Encountered 1 failing test in test/Deposit.t.sol:InvariantDeposit [FAIL. Reason: Assertion failed.] [Sequence] sender=... calldata=deposit() sender=... calldata=changeBalance(address,uint256) sender=... calldata=withdraw() ... sender=... calldata=changeBalance(address,uint256), args=[0x7FA9..., 2193] invariant_alwaysWithdrawable() (runs: 31, calls: 456, reverts: 160) Encountered a total of 1 failing tests, 0 tests succeeded |
اگر به آخرین فراخوانی در دنباله تست دقت کنید، میبینید که تابع changeBalance
فراخوانی شده و پارامترهایی که به آن پاس داده شدهاند شامل:
-
آدرس قرارداد تست Foundry
-
عددی کاملاً تصادفی (در اینجا:
2193
)
میباشند.
در نتیجه، این فراخوانی باعث میشود موجودی آدرس قرارداد تست، که در ابتدا یک اتر واریز کرده بود، به ۲۱۹۳ تغییر کند. پس وقتی تابع withdraw()
اجرا میشود، کاربر بیشتر از آنچه واریز کرده برداشت میکند.
این رفتار باعث میشود شرط Invariant ما که میگوید: “مقدار برداشتشده باید دقیقاً با مقدار واریزشده برابر باشد”نقض شود و تست با شکست مواجه گردد.
اگر بخواهیم مطمئن شویم که آدرسی که به تابع changeBalance
داده شده، همان آدرس قرارداد تست ما بوده، میتوانیم از قابلیت impersonation (تغییر موقتی هویت آدرس ها) در ابزار تست Foundry استفاده کنیم تا آدرس مشخصی را بهصورت کنترلشده در تست مورد بررسی قرار دهیم.
این روش را در بخشهای بعدی، زمانی که به بحث اهداف تست Invariant و impersonation دقیقتر میپردازیم، بیشتر بررسی خواهیم کرد.
اما تابع changeBalance() که داخل تست ما نبود، پس چطور فراخوانی شد؟!
اینجاست که قدرت واقعی تست Invariant خودش را نشان میدهد.
با اینکه ما هیچوقت تابع changeBalance()
را بهطور مستقیم در کد تست فراخوانی نکرده بودیم، ابزار تست Invariant در Foundry بهصورت تصادفی این تابع را نیز در کنار توابعی که بهطور مستقیم در کد تست نوشته بودیم، اجرا کرد.
دلیل این اتفاق این است که در تست های Invariant، موتور fuzzing بهصورت خودکار و تصادفی تمام توابع عمومی (public و external) قرارداد را بررسی و فراخوانی میکند، حتی اگر ما صراحتاً آنها را در کد تست صدا نزنیم. این کار با هدف شکستن شرطهای ثابت (invariants) انجام میشود.
همین ویژگی باعث میشود تست Invariant بسیار قدرتمند باشد. چون نهتنها سناریوهایی که “در ذهن توسعه دهنده بوده” را تست میکند، بلکه به سراغ سناریوهایی میرود که حتی فکرش را هم نمیکردیم.
و این دقیقاً همان چیزی است که امنیت و پایداری یک قرارداد هوشمند را تضمین میکند.
تغییر موجودی یک کاربر بهجای موجودی خود قرارداد
در این مرحله، تست را کمی تغییر میدهیم تا بهجای اینکه قرارداد تست (یعنی خود فایل InvariantDeposit
) نقش فرستنده تراکنش را داشته باشد، از یک آدرس دلخواه دیگر مثل address(0xaa)
استفاده کنیم.
نسخه جدید تابع تست به شکل زیر است:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function invariant_alwaysWithdrawable() external payable { vm.startPrank(address(0xaa)); vm.deal(address(0xaa), 10 ether); deposit.deposit{value: 1 ether}(); uint256 balanceBefore = deposit.balance(address(0xaa)); vm.stopPrank(); assertEq(balanceBefore, 1 ether); vm.prank(address(0xaa)); deposit.withdraw(); uint256 balanceAfter = deposit.balance(address(0xaa)); vm.stopPrank(); assertGt(balanceBefore, balanceAfter); } |
در این نسخه از تست، ما همچنان میخواهیم شرط Invariant را بررسی کنیم: “واریزکننده باید بتواند همان مقدار اتری را که واریز کرده است، برداشت کند.”
اما این بار آدرس واریزکننده را از قرارداد تست جدا کردهایم و از آدرس 0xaa
استفاده میکنیم تا سناریو واقعیتری داشته باشیم.
سپس با اجرای دستور زیر:
1 |
forge test --mt invariant_alwaysWithdrawable |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
Test result: FAILED. 0 passed; 1 failed; finished in 85.64ms Failing tests: Encountered 1 failing test in test/Deposit.t.sol:InvariantDeposit [FAIL. Reason: Assertion failed.] [Sequence] sender=0x00000000000000000000000000000000000000e6 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[] sender=0x0000000000000000000000000000000090c5013b addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[] sender=0x0000000000000000000000000000000000000001 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=changeBalance(address,uint256), args=[0x00000000000000000000000000000000000000A1, 296312983667185193009] sender=0x000000000000000000000000000000000000000c addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=withdraw(), args=[] sender=0x0000000000000000000000000000000000000009 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[] sender=0x0000000000000000000000000000000000000fc5 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[] sender=0x00000000000000000000000000000000000005fb addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=withdraw(), args=[] sender=0x0000000000000000000000000000000000000005 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[] sender=0xb30de0face1af7a50fbd59f1a0d9f31e9282d40f addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[] sender=0x0000000000000000000000000000000000000a94 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=changeBalance(address,uint256), args=[0x00000000000000000000000000000000000000AA, 4594637] invariant_alwaysWithdrawable() (runs: 2, calls: 33, reverts: 8) Encountered a total of 1 failing tests, 0 tests succeeded |
در خط آخر دنباله فراخوانی ها بهوضوح میبینیم که تابع changeBalance
دوباره بهصورت تصادفی فراخوانی شده، و این بار روی آدرس 0xaa
اجرا شده است، همان آدرسی که ما برای واریز استفاده کرده بودیم. مقدار جدید هم یک عدد کاملاً تصادفی است.
این اتفاق باعث میشود مقدار موجودی این آدرس (که قبلاً دقیقاً ۱ اتر واریز کرده بود) بهطور غیرمجاز تغییر کند. در نتیجه، وقتی این آدرس قصد برداشت داشته باشد، یا چیزی کمتر از آنچه واریز کرده دریافت میکند، یا اصلاً نمیتواند برداشت کند.
این رفتار، شرط Invariant اول ما را نقض میکند: “واریزکننده باید بتواند همان مقدار اتری که واریز کرده است را برداشت کند.”
نکته امنیتی مهم: تابع changeBalance()
از آنجا که هیچ بررسی سطح دسترسی ندارد، میتواند توسط هر کسی فراخوانی شود و موجودی هر آدرسی را به صفر یا هر عدد دلخواه دیگر تغییر دهد. این آسیبپذیری میتواند منجر به این شود که کاربران حتی اگر اتر داخل قرارداد داشته باشند، نتوانند آن را برداشت کنند.
در اینجا، تغییر دستی موجودی توسط changeBalance
باعث شده شرط بالا نقض شود، بدون آنکه در ظاهر تابع withdraw
رفتار نادرستی از خود نشان داده باشد. این ضعف امنیتی در منطق ذخیره سازی و اعتبارسنجی داده ها است، و تست Invariant آن را بهخوبی آشکار کرده است.
Invariantهای شرطی
در حالی که invariant ها بهطور کلی باید در تمام شرایط برقرار باشند، بعضی از آنها فقط در صورت وجود شرایط خاصی معتبر هستند. برای مثال، شرطی مانند:
1 |
assertEq(token.totalSupply(), 0); |
فقط زمانی باید برقرار باشد که هیچ توکنی صادر (mint) نشده باشد. اگر توکن صادر شده باشد، طبیعی است که مقدار totalSupply دیگر صفر نباشد.
به چنین invariant هایی، invariant های شرطی گفته میشود، چون لازم است قرارداد یا پروتکل در یک وضعیت خاص قرار داشته باشد تا آن invariant معتبر باشد.
برای اطلاعات بیشتر درباره این نوع invariant ها، میتوانید به منبع اصلی آن مراجعه کنید.
تغییر تنظیمات تست Invariant
اگر بخواهیم تعداد دفعات اجرای تست (runs) را افزایش دهیم، میتوانیم تنظیمات مربوطه را در فایل foundry.toml
قرار دهیم، همانطور که در بخشهای قبلی اشاره شد.
در بخش [invariant]
از فایل foundry.toml
، خطوط زیر را اضافه کنید:
1 2 3 4 |
[invariant] # بخش مربوط به invariant fail_on_revert = false runs = 1215 depth = 23 |
changeBalance
از قرارداد حذف یا غیرفعال شده باشد):
1 |
forge test --mt invariant_alwaysWithdrawable |
1 2 3 |
Running 1 test for test/Deposit.t.sol:InvariantDeposit [PASS] invariant_alwaysWithdrawable() (runs: 1215, calls: 27945, reverts: 13965) Test result: ok. 1 passed; 0 failed; finished in 4.39s |
همانطور که میبینید، تست با موفقیت انجام شده، اما این بار تعداد اجرای تست (runs)، تعداد فراخوانی ها (calls) و تعداد خطاهای revert نسبت به حالت پیشفرض بهمراتب بیشتر است، چون این مقادیر را در تنظیمات افزایش دادهایم.
شما میتوانید مقدار runs
را به هر عددی بین صفر تا uint32.max
تنظیم کنید. اگر عددی بزرگتر از uint32
قرار دهید، Foundry هنگام اجرای تست خطا میدهد. برای مثال، اگر مقدار runs
را به شکل غیرمجاز زیر تنظیم کنید:
1 |
runs = 23000000000000 |
1 2 3 |
Error: failed to extract foundry config: foundry config error: invalid value signed int `23000000000000`, expected u32 for setting `invariant.depth` |
مثالهایی نزدیک به شرایط واقعی
تا اینجا با اصول اولیه تست Invariant در Foundry آشنا شدیم و آن را روی یک قرارداد ساده پیاده سازی کردیم. اما حالا میخواهیم یک گام جلوتر برویم و این نوع تست را روی یک قرارداد واقعی و معروف اجرا کنیم.
در این بخش، تست Invariant را روی قرارداد SideEntranceLenderPool
انجام میدهیم. این قرارداد متعلق به سطح چهارم از مسابقه معروف Damn Vulnerable DeFi CTF است، یک چالش امنیتی پرطرفدار در حوزه دیفای که برای شناسایی و درک آسیبپذیریهای رایج طراحی شده است.
در ادامه، کد قرارداد SideEntranceLenderPool
آورده خواهد شد تا روی آن تست invariant پیاده سازی کنیم:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
// SPDX-License-Identifier: MIT pragma solidity ^0.8.13; import "openzeppelin-contracts/contracts/utils/Address.sol"; interface IFlashLoanEtherReceiver { function execute() external payable; } /** * @title SideEntranceLenderPool * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz) */contract SideEntranceLenderPool { using Address for address payable; mapping(address => uint256) private balances; uint256 public initialPoolBalance; constructor() payable { initialPoolBalance = address(this).balance; } function deposit() external payable { balances[msg.sender] += msg.value; } function withdraw() external { uint256 amountToWithdraw = balances[msg.sender]; balances[msg.sender] = 0; payable(msg.sender).sendValue(amountToWithdraw); } function flashLoan(uint256 amount) external { uint256 balanceBefore = address(this).balance; require(balanceBefore >= amount, "Not enough ETH in balance"); IFlashLoanEtherReceiver(msg.sender).execute{value: amount}(); require( address(this).balance >= balanceBefore, "Flash loan hasn't been paid back" ); } } |
قراردادی که در این بخش بررسی میکنیم نسخه اصلاح شدهای از SideEntranceLenderPool
است که برای هماهنگی با ساختار پروژه Foundry و نیازهای تست ما کمی تغییر کرده است. همچنین کتابخانه مورد نیاز OpenZeppelin (Address
) به پروژه اضافه شده و در قرارداد import شده است.
این قرارداد در تابع flashLoan
دچار یک آسیبپذیری جدی است. یک مهاجم میتواند با استفاده از همین تابع، اتر موجود در قرارداد را تخلیه کند. روش حمله به این شکل است:
مهاجم، تابع flashLoan
را صدا میزند و از قرارداد یک وام آنی (flash loan) میگیرد. سپس همان اتر قرضگرفتهشده را با استفاده از تابع deposit
به قرارداد بازمیگرداند، اما این بار بهعنوان موجودی خودش (یعنی بهعنوان سپرده کاربر). در نهایت، مهاجم میتواند با استفاده از تابع withdraw
موجودی خودش را برداشت کند و از قرارداد خارج شود، در حالی که هیچ اتر واقعی متعلق به خودش واریز نکرده است.
پس شرط Invariant ما در این سناریو چیست؟
نکته کلیدی اینجاست:
-
قرارداد دارای یک سازنده قابل پرداخت (payable constructor) است.
-
اتر مورد استفاده برای وام، در زمان استقرار قرارداد و از طریق سازنده وارد شده است.
-
راهی برای برداشت این مقدار اولیه وجود ندارد.
-
اتر فقط از طریق تابع
deposit
میتواند وارد قرارداد شود و تنها کاربری که قبلاً واریز کرده باشد، میتواند باwithdraw
آن را برداشت کند.
با در نظر گرفتن این موارد، شرط Invariant ما به صورت زیر تعریف میشود:
1 |
assert(address(SideEntranceLenderPool).balance >= SideEntranceLenderPool.initialPoolBalance()); |
initialPoolBalance
یک متغیر وضعیت (state variable) عمومی است که مقدار اولیه اتر تزریقشده به قرارداد در زمان استقرار را نگه میدارد.
این شرط میگوید که موجودی اتر قرارداد SideEntranceLenderPool
همیشه باید بزرگتر یا مساوی مقدار اولیهای باشد که در هنگام استقرار به آن واریز شده است.
اگر منطق قرارداد سالم باشد، این شرط همواره باید برقرار باشد. اما همانطور که توضیح داده شد، آسیبپذیری موجود در flashLoan
میتواند باعث شود این شرط نقض شود. یعنی موجودی قرارداد از مقدار اولیه کمتر شود، بدون آنکه واریزی واقعی از خارج انجام شده باشد.
در بخش بعدی، با معرفی مفهوم Handler در تست Invariant در Foundry، تست های دقیقتر و هدفمندتری طراحی خواهیم کرد. این ابزار به ما کمک میکند رفتارهای خاصی را بهتر شبیه سازی و کنترل کنیم.
تست مبتنی بر Handler
در تست قراردادهای پیچیده یا چندمرحلهای، از چیزی به نام قرارداد Handler استفاده میکنیم. این قرارداد نقش یک لایهی واسط را دارد که از طریق آن توابع قرارداد اصلی را فراخوانی میکنیم. این مفهوم برای افرادی که به دنبال درک عمیقتری از ساختارهای پیشرفته در آموزش برنامه نویسی قراردادهای هوشمند هستند، اهمیت ویژهای دارد.
Handler در واقع یک wrapper contract است. یعنی قراردادی که مانند یک پوشش روی قرارداد اصلی قرار میگیرد تا از طریق آن تعاملات انجام شود.
این روش زمانی بسیار مفید و حتی ضروری است که محیط تست نیاز به پیکربندی خاصی داشته باشد، مثلاً وقتی سازنده (constructor) قرارداد اصلی باید با پارامترهای مشخصی اجرا شود.
روش کار Handler:
در تابع setUp
داخل فایل تست، به جای آنکه مستقیماً با قرارداد اصلی کار کنیم:
-
ابتدا یک قرارداد Handler ایجاد میکنیم.
-
این Handler طوری نوشته میشود که به قرارداد اصلی متصل باشد و توابع آن را فراخوانی کند.
-
سپس با استفاده از تابع کمکی
targetContract(address target)
فقط همین Handler را به عنوان هدف تست تعریف میکنیم.
در نتیجه، فقط توابع قرارداد Handler هستند که به صورت تصادفی توسط موتور fuzzing فراخوانی میشوند، نه توابع قرارداد اصلی.
مزیت دیگر این روش چیست؟
اگر اجرای یک تابع در قرارداد اصلی نیاز به شرایط خاصی داشته باشد (مثلاً اینکه کاربر از قبل اتر واریز کرده باشد)، ما میتوانیم این شرایط را در خود قرارداد Handler قبل از فراخوانی آن تابع مشخص کنیم.
بهعلاوه، Handler میتواند از ابزارهای داخلی Foundry مثل vm.deal
، vm.prank
و سایر cheat codeها استفاده کند، چون میتواند از forge-std/Test.sol
ارثبری کند.
در ادامه این مقاله، این موضوع را به صورت عملی نشان خواهیم داد.
برای شروع، یک پوشه جدید به نام /handler
داخل پوشه test
بسازید و یک فایل به نام Handler.sol
در آن قرار دهید.
در گام بعدی، کد قرارداد Handler را خواهیم نوشت:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
import {SideEntranceLenderPool} from "../../src/SideEntranceLenderPool.sol"; import "forge-std/Test.sol"; contract Handler is Test { // the pool contract SideEntranceLenderPool pool; // used to check if the handler can withdraw ether after the exploit bool canWithdraw; constructor(SideEntranceLenderPool _pool) { pool = _pool; vm.deal(address(this), 10 ether); } // this function will be called by the pool during the flashloan function execute() external payable { pool.deposit{value: msg.value}(); canWithdraw = true; } // used for withdrawing ether balance in the pool function withdraw() external { if (canWithdraw) pool.withdraw(); } // call the flashloan function of the pool, with a fuzzed amount function flashLoan(uint amount) external { pool.flashLoan(amount); } receive() external payable {} } |
ما در قرارداد 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 هدف اصلی تست باشد.
در گام بعدی، کد تست را داخل این فایل قرار میدهیم.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import "forge-std/Test.sol"; import "forge-std/Vm.sol"; import "forge-std/console2.sol"; import "../src/SideEntranceLenderPool.sol"; import "./handlers/Handler.sol"; contract InvariantSideEntranceLenderPool is Test { SideEntranceLenderPool pool; Handler handler; function setUp() external { // deploy the pool contract with 25 ether pool = new SideEntranceLenderPool{value: 25 ether}(); // deploy the handler contract handler = new Handler(pool); // set the handler contract as the target for our test targetContract(address(handler)); } // invariant test function function invariant_poolBalanceAlwaysGtThanInitialBalance() external { // assert that the pool balance will never go below the initial balance (the 10 ether deposited during deployment) assert(address(pool).balance >= pool.initialPoolBalance()); } } |
اجرای تست و مشاهده نتیجه
پس از قراردادن کد تست در فایل SideEntranceLenderPool.t.sol
، تست را با دستور زیر اجرا میکنیم:
1 |
forge test --mt invariant_poolBalanceAlwaysGtThanInitialBalance |
1 2 3 4 5 6 7 8 9 10 |
Test result: FAILED. 0 passed; 1 failed; finished in 19.08ms Failing tests: Encountered 1 failing test in test/SideEntranceLenderPool.t.sol:InvariantSideEntranceLenderPool [FAIL. Reason: Assertion violated] [Sequence] sender=0x0000000000000000000000000000000000000531 addr=[test/handlers/Handler.sol:Handler]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=flashLoan(uint256), args=[3041954473] sender=0x0000000000000000000000000000000000000423 addr=[test/handlers/Handler.sol:Handler]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=withdraw(), args=[] invariant_poolBalanceAlwaysGtThanInitialBalance() (runs: 1, calls: 8, reverts: 0) |
در دنباله فراخوانیها میبینیم که ابتدا تابع flashLoan
فراخوانی شده، سپس withdraw
، که این همان مسیر حملهای است که قبلاً دربارهاش صحبت کردیم.
بررسی دقیقتر مسیر فراخوانی
برای مشاهده کامل جزییات تست، از دستور زیر استفاده میکنیم:
1 |
forge test --mt invariant_poolBalanceAlwaysGtThanInitialBalance -vvvv |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
[45514] Handler::flashLoan(3041954473) ├─ [40246] SideEntranceLenderPool::flashLoan(3041954473) │ ├─ [32885] Handler::execute{value: 3041954473}() │ │ ├─ [22437] SideEntranceLenderPool::deposit{value: 3041954473}() │ │ │ └─ ← () │ │ └─ ← () │ └─ ← () └─ ← () [14076] Handler::withdraw() ├─ [9828] SideEntranceLenderPool::withdraw() │ ├─ [55] Handler::receive{value: 3041954473}() │ │ └─ ← () │ └─ ← () └─ ← () [7724] InvariantSideEntranceLenderPool::invariant_poolBalanceAlwaysGtThanInitialBalance() ├─ [2261] SideEntranceLenderPool::initialPoolBalance() [staticcall] │ └─ ← 25000000000000000000 #initial balance was 25 ether └─ ← "Assertion violated" |
نتیجهگیری
در این گزارش کاملاً مشخص است که ابتدا تابع flashLoan
برای دریافت وام فراخوانی شده، سپس همان مقدار وام بهصورت سپرده به قرارداد بازگردانده شده است (از طریق deposit
)، و در نهایت با فراخوانی withdraw
، مهاجم توانسته این اتر را از قرارداد خارج کند.
این تست نهتنها آسیبپذیری را بهوضوح کشف کرد، بلکه مسیر دقیق بهرهبرداری را نیز نمایش داد. این نشاندهنده قدرت بالای تست های invariant در شناسایی باگهای منطقی در قراردادهای هوشمند است.
مثالی با یک عبارت ریاضی
در این مثال، قصد داریم یک تست فازینگ ساده و بدون وضعیت (stateless fuzz) انجام دهیم. یعنی نتیجه تست به فراخوانی های قبلی وابسته نیست و هر ورودی بهطور مستقل بررسی میشود. هدف این مثال، نشان دادن محدودیتهای فازینگ است و اینکه چطور میتوان آنها را دور زد.
(اگر بخواهیم تست را حالتدار کنیم، میتوانیم از متغیرهای ذخیره سازی استفاده کنیم، اما فعلاً این موضوع خارج از بحث ماست.)
این مثال قرارداد ماست:
1 2 3 4 5 6 7 8 9 10 11 |
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; contract Quadratic { bool public ok = true; function notOkay(int x) external { if ((x - 11111) * (x - 11113) < 0) { ok = false; } } } |
این مثال بسیار ساده است. ما فقط میخواهیم بررسی کنیم که مقدار متغیر بولی ok
همیشه برابر با true باقی بماند. به عبارت دیگر، در تست invariant، تنها شرط ما این است:
1 |
assertTrue(quadratic.ok()); |
مقدار ok
فقط در صورتی به false
تغییر میکند که تابع notOkay
با عددی فراخوانی شود که شرط زیر را برقرار کند:
1 |
(x - 11111) * (x - 11113) < 0 |
یعنی عدد x
دقیقاً باید بین 11111 و 11113 باشد. مثلاً مقدار 11112 باعث میشود این عبارت کمتر از صفر شود و در نتیجه مقدار ok
به false
تغییر کند.
در نگاه اول ممکن است این تست خیلی ساده بهنظر برسد، اما سوال اینجاست:
آیا موتور فازینگ Foundry (fuzzer) میتواند عدد دقیق 11112 را بهصورت تصادفی تولید کند و invariant را بشکند؟
برای بررسی این موضوع، از روش تست مبتنی بر Handler استفاده میکنیم.
اکنون باید یک فایل به نام Handler_2.sol
در مسیر /test/handler
بسازید و کد مربوط به قرارداد Handler را در آن قرار دهید.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import "../../src/Quadratic.sol"; import "forge-std/Test.sol"; contract Handler_2 is Test { Quadratic quadratic; constructor(Quadratic _quadratic) { quadratic = _quadratic; } function notOkay(int x) external { quadratic.notOkay(x); } } |
Quadratic.t.sol
داخل پوشه test
بسازید و کد زیر را در آن قرار دهید:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import "forge-std/Test.sol"; import "./handlers/Handler_2.sol"; import "../src/Quadratic.sol"; contract InvariantQuadratic is Test { Quadratic quadratic; Handler_2 handler; function setUp() external { quadratic = new Quadratic(); handler = new Handler_2(quadratic); targetContract(address(handler)); } function invariant_NotOkay() external { assertTrue(quadratic.ok()); } } |
invariant_NotOkay
را بهعنوان تابع اصلی invariant تعریف کردهایم که بررسی میکند مقدار ok
در قرارداد Quadratic
همچنان برابر true
باقی مانده است.
برای اجرای تست، از دستور زیر استفاده کنید:
1 |
forge test --mt invariant_NotOkay |
خروجی اجرای تست:
1 2 3 |
Running 1 test for test/Quadratic.t.sol:InvariantQuadratic [PASS] invariant_NotOkay() (runs: 256, calls: 3840, reverts: 760) Test result: ok. 1 passed; 0 failed; finished in 576.70ms |
تست با موفقیت اجرا شد و invariant همچنان برقرار ماند. اما مسئله اینجاست: عددی وجود دارد که میتواند این invariant را بشکند. ما بعداً آن عدد را مشخص خواهیم کرد، ولی فعلاً هدف این است که ببینیم آیا با افزایش تعداد اجرای تست (runs)، موتور فازینگ میتواند آن را پیدا کند یا نه.
در فایل foundry.toml
، مقدار runs
را به ۲۰۰۰۰ افزایش میدهیم:
1 2 |
[invariant] runs = 20000 |
1 2 3 |
Running 1 test for test/Quadratic.t.sol:InvariantQuadratic [PASS] invariant_NotOkay() (runs: 20000, calls: 300000, reverts: 74275) Test result: ok. 1 passed; 0 failed; finished in 92.41s |
حتی با اجرای ۲۰۰۰۰ بار تست و ۳۰۰٬۰۰۰ فراخوانی تابع، موتور fuzzing نتوانسته عددی پیدا کند که شرط (x - 11111) * (x - 11113) < 0
را برقرار کند و باعث شود مقدار ok
برابر false
شود.
یعنی invariant همچنان برقرار مانده، اما نه به این خاطر که قرارداد بدون خطاست، بلکه به این دلیل که fuzzer هنوز نتوانسته مقدار مناسبی را کشف کند.
برای تأیید اینکه چنین عددی واقعاً وجود دارد، میتوانیم نمودار این تابع را در ابزارهایی مانند Desmos رسم کنیم. در نمودار حاصل از معادله:
1 |
f(x) = (x - 11111)(x - 11113) |
بهوضوح دیده میشود که خروجی تابع تنها در بازه بین x = 11111
و x = 11113
منفی میشود. بهطور خاص، x = 11112 مقدار شرط را منفی میکند و در نتیجه باعث میشود ok
به false
تغییر کند.
در تصویر مربوطه، این ناحیه با یک دایره آبی مشخص شده و نشان میدهد که مقدار بحرانی که invariant را میشکند، وجود دارد. اما بهدلیل اینکه بازه آن بسیار محدود است، موتور فازینگ بهصورت تصادفی موفق به تولید آن نشده است.
تصویر بهروشنی نشان میدهد که عددی که invariant را میشکند ۱۱۱۱۲ است.
اکنون قصد داریم با محدود کردن بازه ورودیها در قرارداد Handler، به موتور fuzzing کمک کنیم تا راحتتر این مقدار بحرانی را پیدا کند.
در فایل Handler_2.sol
، تابع notOkay
را بهصورت زیر تغییر دهید:
1 2 3 4 |
function notOkay(int x) external { x = bound(x, 10_000, 100_000); // محدود کردن بازهی ورودیها quadratic.notOkay(x); } |
bound()
از کتابخانه forge-std/Test.sol
ارائه شده و به ما امکان میدهد مقدار x
را در یک بازه مشخص نگه داریم. در اینجا، ما بازه را بین ۱۰٬۰۰۰ تا ۱۰۰٬۰۰۰ انتخاب کردهایم، تا عدد ۱۱۱۱۲ حتماً در بین گزینههای تولیدشده قرار بگیرد.
اکنون تست را با دستور زیر اجرا میکنیم:
1 |
forge test --mt invariant_NotOkay -vvv |
خروجی تست:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
Test result: FAILED. 0 passed; 1 failed; finished in 20.49s Failing tests: Encountered 1 failing test in test/Quadratic.t.sol:InvariantQuadratic [FAIL. Reason: Assertion failed.] [Sequence] sender=0x000000000000000000000000000000000001373a addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[-5675015641267] sender=0x0000000000000000000000000000000000002df6 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[-3] sender=0x0000000000000000000000000000000000009208 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[1912195698230241887953774934318906299036] sender=0x00000000000000000000000000000000000172fd addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=failed():(bool), args=[] sender=0x41b9a90e4836f4df4fe8ed9933c618c49163d8c3 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=failed():(bool), args=[] sender=0x0000000000000000000000000000000000005001 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[57896044618658097711785492504343953926634992332820282019728792003956564819794] sender=0x000000000000000000000000000000000000e860 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=failed():(bool), args=[] sender=0x2039383034370000000000000000000000000000 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[5137619242564313626262060176411679498446697733570] sender=0x0000000000000000000000000000000000008ead addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=failed():(bool), args=[] sender=0x2d4326d8f5a6b7c3ef871eb0063dc7771fd571d8 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=failed():(bool), args=[] sender=0xc7ebe193ccfed949da23e957c37020d88a068c34 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[57896044618658097711785492504343953926634992332813620401282714769779013280756] sender=0xd72485927db413065ce2730222fc574be7f38a83 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[-57896044618658097711785492504343953926634992332820282019728792003956564809711] invariant_NotOkay() (runs: 3470, calls: 52047, reverts: 0) Encountered a total of 1 failing tests, 0 tests succeeded |
این بار، invariant شکست خورد و دلیل آن، محدود کردن بازه ورودیهای تولید شده توسط fuzzer بود. اما نکته جالب اینجاست که در دنباله فراخوانی ها (call sequence)، عدد 11112 مستقیماً نمایش داده نمیشود.
حتی با استفاده از فلگ -vvv
که برای مشاهده جزئیات بیشتر در تست استفاده کردیم.
در خروجی تست، ردیفهای لاگ نیز وجود دارند که به شرح زیر هستند:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
invariant_NotOkay() (runs: 3470, calls: 52047, reverts: 0) Logs: Bound result 23762 Bound result 89998 Bound result 44363 Bound result 88972 Bound result 11664 Bound result 33484 Bound result 11112 Traces: [14840] Handler_2::notOkay(-5675015641267) ├─ [0] VM::toString(23762) [staticcall] │ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053233373632000000000000000000000000000000000000000000000000000000 ├─ [0] console::log(Bound result, 23762) [staticcall] │ └─ ← () ├─ [607] Quadratic::notOkay(23762) │ └─ ← () └─ ← () [14840] Handler_2::notOkay(-3) ├─ [0] VM::toString(89998) [staticcall] │ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053839393938000000000000000000000000000000000000000000000000000000 ├─ [0] console::log(Bound result, 89998) [staticcall] │ └─ ← () ├─ [607] Quadratic::notOkay(89998) │ └─ ← () └─ ← () [14772] Handler_2::notOkay(1912195698230241887953774934318906299036) ├─ [0] VM::toString(44363) [staticcall] │ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053434333633000000000000000000000000000000000000000000000000000000 ├─ [0] console::log(Bound result, 44363) [staticcall] │ └─ ← () ├─ [607] Quadratic::notOkay(44363) │ └─ ← () └─ ← () [14772] Handler_2::notOkay(57896044618658097711785492504343953926634992332820282019728792003956564819794) ├─ [0] VM::toString(88972) [staticcall] │ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053838393732000000000000000000000000000000000000000000000000000000 ├─ [0] console::log(Bound result, 88972) [staticcall] │ └─ ← () ├─ [607] Quadratic::notOkay(88972) │ └─ ← () └─ ← () [14772] Handler_2::notOkay(5137619242564313626262060176411679498446697733570) ├─ [0] VM::toString(11664) [staticcall] │ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053131363634000000000000000000000000000000000000000000000000000000 ├─ [0] console::log(Bound result, 11664) [staticcall] │ └─ ← () ├─ [607] Quadratic::notOkay(11664) │ └─ ← () └─ ← () [14772] Handler_2::notOkay(57896044618658097711785492504343953926634992332813620401282714769779013280756) ├─ [0] VM::toString(33484) [staticcall] │ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053333343834000000000000000000000000000000000000000000000000000000 ├─ [0] console::log(Bound result, 33484) [staticcall] │ └─ ← () ├─ [607] Quadratic::notOkay(33484) │ └─ ← () └─ ← () [15887] Handler_2::notOkay(-57896044618658097711785492504343953926634992332820282019728792003956564809711) ├─ [0] VM::toString(11112) [staticcall] │ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053131313132000000000000000000000000000000000000000000000000000000 ├─ [0] console::log(Bound result, 11112) [staticcall] │ └─ ← () ├─ [4500] Quadratic::notOkay(11112) │ └─ ← () └─ ← () |
لاگها نشان میدهند که fuzzer تابع notOkay
را با اعدادی فراخوانی کرده که حتی به عدد موردنظر ما نزدیک هم نبودهاند. با این حال، تابع bound
این ورودیها را تغییر داده تا زمانی که به عدد درست رسیدیم. همانطور که در نتیجه نهایی bound
و دنباله فراخوانیها مشاهده میشود.
استفاده از تابع bound
در مواردی که نیاز داریم بازه خاصی از اعداد تست شود، بسیار مفید است و به بهبود کیفیت و دقت نتایج تست کمک میکند.
جمعبندی
در این مقاله با مفهوم invariant آشنا شدیم، دلایل اهمیت آن را بررسی کردیم، و یاد گرفتیم چطور تست های invariant را در محیط Foundry پیاده سازی کنیم.
همچنین مباحثی مانند invariant های شرطی، ساختار مبتنی بر Handler، و زمان مناسب برای محدود کردن بازه ورودیهای فازینگ با تابع bound را نیز مورد بررسی قرار دادیم.
این تکنیکها به ما کمک میکنند تا تستهایی دقیقتر، هدفمندتر و کارآمدتر برای شناسایی باگها و آسیبپذیریهای منطقی در قراردادهای هوشمند ایجاد کنیم.
راستی! برای دریافت مطالب جدید در کانال تلگرام یا پیج اینستاگرام سورس باران عضو شوید.
- انتشار: ۶ مرداد ۱۴۰۴
دسته بندی موضوعات
- آموزش ارز دیجیتال
- آموزش برنامه نویسی
- آموزش متنی برنامه نویسی
- اطلاعیه و سایر مطالب
- پروژه برنامه نویسی
- دوره های تخصصی برنامه نویسی
- رپورتاژ
- فیلم های آموزشی
- ++C
- ADO.NET
- Adobe Flash
- Ajax
- AngularJS
- apache
- ARM
- Asp.Net
- ASP.NET MVC
- AVR
- Bootstrap
- CCNA
- CCNP
- CMD
- CSS
- Dreameaver
- EntityFramework
- HTML
- IOS
- jquery
- Linq
- Mysql
- Oracle
- PHP
- PHPMyAdmin
- Rational Rose
- silver light
- SQL Server
- Stimulsoft Reports
- Telerik
- UML
- VB.NET&VB6
- WPF
- Xml
- آموزش های پروژه محور
- اتوکد
- الگوریتم تقریبی
- امنیت
- اندروید
- اندروید استودیو
- بک ترک
- بیسیک فور اندروید
- پایتون
- جاوا
- جاوا اسکریپت
- جوملا
- دلفی
- دوره آموزش Go
- دوره های رایگان پیشنهادی
- زامارین
- سئو
- ساخت CMS
- سی شارپ
- شبکه و مجازی سازی
- طراحی الگوریتم
- طراحی بازی
- طراحی وب
- فتوشاپ
- فریم ورک codeigniter
- فلاتر
- کانستراکت
- کریستال ریپورت
- لاراول
- معماری کامپیوتر
- مهندسی اینترنت
- هوش مصنوعی
- یونیتی
- کتاب های آموزشی
- Android
- ASP.NET
- AVR
- LINQ
- php
- Workflow
- اچ تی ام ال
- بانک اطلاعاتی
- برنامه نویسی سوکت
- برنامه نویسی موبایل
- پاسکال
- پایان نامه
- پایتون
- جاوا
- جاوا اسکریپت
- جی کوئری
- داده کاوی
- دلفی
- رباتیک
- سئو
- سایر کتاب ها
- سخت افزار
- سی اس اس
- سی پلاس پلاس
- سی شارپ
- طراحی الگوریتم
- فتوشاپ
- مقاله
- مهندسی نرم افزار
- هک و امنیت
- هوش مصنوعی
- ویژوال بیسیک
- نرم افزار و ابزار برنامه نویسی
- وردپرس