परिचय
इस लेख में, हम invariants पर चर्चा करेंगे और यह जानेंगे कि Foundry टेस्ट सूट का उपयोग करके Solidity स्मार्ट कॉन्ट्रैक्ट्स पर invariant test कैसे किया जाता है।
Invariant testing कोड की शुद्धता को सत्यापित करने के लिए यूनिट टेस्ट और फ़ज़िंग (fuzzing) जैसी ही एक अन्य टेस्ट कार्यप्रणाली है। यदि आप यूनिट टेस्ट से अपरिचित हैं, तो कृपया Foundry का उपयोग करके यूनिट टेस्ट पर हमारा लेख देखें।
इस लेख के व्यावहारिक पहलू को समझने के लिए, आपसे यह अपेक्षा की जाती है कि आप Solidity से परिचित हों और आपके कंप्यूटर पर Foundry इंस्टॉल हो। अन्यथा, यहां देखें कि इसे कैसे करना है।
साथ दी गई Repo
यदि आप केवल कुछ कोड कॉपी और पेस्ट करना चाहते हैं, तो हमारे द्वारा यहां दी गई repo को क्लोन करें। इस ट्यूटोरियल का अनुसरण करने के लिए आप इस repo का भी उपयोग कर सकते हैं। github.com/RareSkills/invariant-testing-foundry-tutorial
आपको invariants के लिए टेस्ट क्यों करना चाहिए
Invariant testing हमें स्मार्ट कॉन्ट्रैक्ट के उन पहलुओं का परीक्षण करने की अनुमति देता है जो यूनिट टेस्ट में छूट जाने की संभावना है। यूनिट टेस्ट केवल टेस्ट में निर्दिष्ट गुणों (properties) को कवर करते हैं और कुछ नहीं। लेकिन invariant testing के साथ, कोड में खामियां खोजने के लिए स्मार्ट कॉन्ट्रैक्ट्स को कई रैंडम स्टेट्स (random states) के तहत आजमाया और परखा जाता है।
इन invariants का परीक्षण करके, डेवलपर्स उन संभावित समस्याओं को पकड़ सकते हैं जिनका पता यूनिट टेस्ट या मैनुअल कोड रिव्यू नहीं लगा पाते हैं।
Invariants क्या हैं
Invariants वे शर्तें हैं जो अच्छी तरह से परिभाषित मान्यताओं के एक निश्चित सेट के तहत हमेशा सत्य होनी चाहिए। उदाहरण के लिए, एक ERC20 contract में, एक invariant यह होगा कि कॉन्ट्रैक्ट में सभी बैलेंस का योग कुल सप्लाई (total supply) के बराबर होना चाहिए। यदि कोई फ़ंक्शन कॉल या ट्रांज़ैक्शन इस invariant का उल्लंघन करता है, तो कोड में कुछ गलत हो गया है, और सिस्टम अब ठीक से काम नहीं कर रहा है।
जबकि यूनिट टेस्ट विशिष्ट व्यवहार (specific behavior) को सत्यापित करते हैं, invariants समग्र रूप से पूरे सिस्टम के बारे में कुछ बताते हैं। यहाँ कुछ उदाहरण दिए गए हैं:
- यदि mint या burn को कॉल नहीं किया जाता है तो ERC20 टोकन की कुल सप्लाई (total supply) नहीं बदलती है
- एक स्मार्ट कॉन्ट्रैक्ट से मिलने वाले कुल रिवॉर्ड एक निश्चित अवधि में एक निश्चित प्रतिशत से अधिक नहीं हो सकते हैं
- यूज़र्स अपने जमा किए गए अमाउंट + कुछ कैप किए गए (capped) रिवॉर्ड से अधिक नहीं निकाल (withdraw) सकते हैं
शुरुआत करना
Foundry में एक invariant test एक stateful fuzz test होता है, जहाँ किसी निर्दिष्ट invariant को तोड़ने के प्रयास में, फ़ज़र (fuzzer) द्वारा कॉन्ट्रैक्ट के फ़ंक्शंस को रैंडम इनपुट के साथ रैंडमली कॉल किया जाता है। एक stateful fuzz test का मतलब है कि एक कॉल पर टेस्ट की स्टेट अगली कॉल के लिए सेव हो जाती है।
आइए एक स्मार्ट कॉन्ट्रैक्ट पर invariant test करने के लिए एक नया Foundry प्रोजेक्ट इनिशियलाइज़ करें।
निम्नलिखित कमांड चलाएँ:
forge init invariant-exercise
cd invariant-exercise
अब हमारा Foundry प्रोजेक्ट तैयार है।
Foundry कॉन्फ़िग्स (Configs)
हम foundry.toml फ़ाइल के अंदर अपने invariant test के लिए वैकल्पिक कॉन्फ़िगरेशन (optional configuration) वैल्यूज़ सेट कर सकते हैं। यदि कोई कॉन्फ़िग वैल्यू सेट नहीं की गई है, तो Foundry डिफ़ॉल्ट वैल्यूज़ का उपयोग करता है। जैसे-जैसे हम इस लेख में आगे बढ़ेंगे, हम केवल महत्वपूर्ण वाले ही सेट करेंगे। सभी उपलब्ध invariant कॉन्फ़िग्स देखने के लिए यहाँ जाएँ।
- runs: प्रत्येक invariant test समूह के लिए एग्जीक्यूट होने वाले रन्स (runs) की संख्या (डिफ़ॉल्ट वैल्यू 256 है)।
- depth: एक रन में invariants को तोड़ने का प्रयास करने के लिए एग्जीक्यूट की गई कॉल्स की संख्या (डिफ़ॉल्ट वैल्यू 15 है)।
fail_on_revert: यदि कोई रिवर्ट (revert) होता है तो invariant फ़ज़िंग (fuzzing) को फ़ेल कर देता है (डिफ़ॉल्ट वैल्यू false है)।
foundry.toml में एक उदाहरण कॉन्फ़िग इस प्रकार दिखेगा:
[invariant]
runs = 1000
depth = 1000
वैकल्पिक रूप से, इन पैरामीटर्स को एनवायरनमेंट वेरिएबल्स (environment variables) में सेट किया जा सकता है, उदाहरण के लिए FOUNDRY_INVARIANT_RUNS=10000।
एक सरल उदाहरण
अब Foundry के साथ आने वाले Counter.sol का नाम बदलकर Deposit.sol करें और इस कोड को पेस्ट करें।
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");
}
}
यह एक सरल कॉन्ट्रैक्ट है जो किसी को भी ईथर (ether) जमा (deposit) करने और निकालने (withdraw) की अनुमति देता है।
जमा किया गया ईथर हमेशा हर समय जमाकर्ता द्वारा निकालने योग्य (withdrawable) होना चाहिए क्योंकि इसमें कोई प्रतिबंध नहीं है।
हमारा invariant यह होना चाहिए कि जमा की गई कोई भी राशि उसी व्यक्ति द्वारा और उसी राशि के रूप में निकाली जा सकने योग्य हो।
हम यह पुष्टि करने के लिए एक invariant test लागू करेंगे कि:
- जमाकर्ता जमा किए गए ईथर को निकाल सकता है।
- जमा की गई समान राशि जमाकर्ता द्वारा निकाली गई समान राशि होगी।
आइए दोनों मामलों के लिए एक invariant test लिखकर सत्यापित करें कि हमारा कोड सही है। हमारे Foundry प्रोजेक्ट में test फ़ोल्डर में जाएँ, Counter.t.sol का नाम बदलकर Deposit.t.sol करें, और नीचे दिए गए कोड को पेस्ट करें।
// 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 {}
}
टेस्ट को समझना (Explaining the test)
हम जो करने जा रहे हैं वह ‘‘Open Testing’’ है। Open testing वह जगह है जहां लक्ष्य अनुबंधों (target contracts) के लिए डिफ़ॉल्ट कॉन्फ़िगरेशन को टेस्ट फ़ंक्शन के अंदर डिप्लॉय किए गए सभी कॉन्ट्रैक्ट्स पर सेट किया जाता है। यदि चाहें तो आप यहाँ और पढ़ सकते हैं।
Invariants: जमाकर्ता जमा किए गए ईथर को निकाल सकता है, और जमा की गई समान राशि जमाकर्ता द्वारा निकाली गई समान राशि होगी।
इस बात की पुष्टि करने वाला कोड कि यह सही है, invariant_alwaysWithdrawable टेस्ट फ़ंक्शन है, जो यह है:
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 test है।
हम टेस्ट कॉन्ट्रैक्ट से एक ईथर जमा करना शुरू करते हैं। चूँकि Deposit कॉन्ट्रैक्ट balance मैपिंग के माध्यम से जमा की गई राशि पर नज़र रखता है, इसलिए हम जमा करने के तुरंत बाद अपने बैलेंस को नोट करने के लिए इसका उपयोग करते हैं {यह एक ईथर के बराबर होना चाहिए क्योंकि हमने यही जमा किया है}।
इसके बाद, हम ईथर को वापस लेने के लिए withdraw फ़ंक्शन को कॉल करते हैं और फिर से अपना बैलेंस नोट करते हैं (इस बिंदु पर यह शून्य होना चाहिए)।
बैलेंस नोट करने का यह काम balanceBefore और balanceAfter लोकल वेरिएबल्स (local variables) के साथ किया जाता है।
हम उम्मीद करते हैं कि हमने जो राशि जमा की है वह एक ईथर हो, इसलिए हम assertEq(balanceBefore, 1 ether); के साथ इसकी पुष्टि करते हैं।
यह पुष्टि करने के लिए कि invariant कायम है, हम उम्मीद करते हैं कि balanceBefore, balanceAfter से अधिक हो क्योंकि जब हमने जमा किया था तब यह हमारा बैलेंस था।
इसे सत्यापित करने के लिए, हम Foundry एसरशन (assertion) assertGt(balanceBefore, balanceAfter); का उपयोग करते हैं।
यदि हम forge test --mt invariant_alwaysWithdrawable के साथ टेस्ट चलाते हैं, तो हमें निम्नलिखित आउटपुट मिलता है:
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
टेस्ट पैरामीटर्स (Test parameters)
runs पैरामीटर का तात्पर्य यह है कि किसी विशेष टेस्ट फ़ंक्शन को कितनी बार निष्पादित (executed) किया जाता है। हर बार जब टेस्ट फ़ंक्शन चलाया जाता है, तो यह अलग-अलग परिदृश्यों (scenarios) का परीक्षण करने और यह सुनिश्चित करने के लिए कि कॉन्ट्रैक्ट विभिन्न परिस्थितियों में सही ढंग से कार्य करता है, अलग-अलग इनपुट या शर्तें पास करता है।
Calls का अर्थ है कि एक ही टेस्ट रन के दौरान स्मार्ट कॉन्ट्रैक्ट में फ़ंक्शंस को कितनी बार कॉल किया जाता है।
Reverts का तात्पर्य उस संख्या से है कि स्मार्ट कॉन्ट्रैक्ट के भीतर किसी भी फ़ंक्शन को कॉल करने पर किसी त्रुटि (error) या अपवाद (exception) के कारण ट्रांज़ैक्शन को कितनी बार रिवर्ट (revert) किया गया।
एक रिवर्ट (revert) की अपेक्षा करना
हम देख सकते हैं कि टेस्ट सफल है, और टेस्ट ने हमारे invariants को तोड़ने के लिए हमारे कॉन्ट्रैक्ट के फ़ंक्शंस को 3840 बार कॉल किया, जैसा कि कॉल्स की संख्या में दिखाया गया है।
यह 1917 बार रिवर्ट भी हुआ। ऐसा तब हो सकता है जब invariant test या फ़ज़र स्मार्ट कॉन्ट्रैक्ट में किसी भी फ़ंक्शन को फ़ंक्शन की आवश्यकताओं (requirements) को पूरा किए बिना कॉल करने का प्रयास करता है। हम अपनी foundry.toml फ़ाइल को मॉडिफाई करेंगे और इसकी पुष्टि करने के लिए निम्नलिखित invariant test कॉन्फ़िग जोड़ेंगे।
[invariant]
fail_on_revert = true
इससे यदि हमारे invariant को तोड़ने की कोशिश करते समय कोई रिवर्ट होता है, तो टेस्ट फ़ेल हो जाएगा।
अब, हम forge test --mt invariant_alwaysWithdrawable के साथ टेस्ट को फिर से चलाते हैं, और हमें निम्नलिखित प्राप्त होता है:
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
हम देख सकते हैं कि invariant test शुरू से ही रैंडमली withdraw फ़ंक्शन को कॉल करता है, भले ही हमने इसे निर्दिष्ट (specify) न किया हो (ध्यान दें कि यह पूरी तरह से रैंडम है, और आपको अलग-अलग ट्रायल्स पर एक अलग परिणाम मिल सकता है)। ऐसा इसलिए है क्योंकि हमारे कॉन्ट्रैक्ट के सभी फ़ंक्शंस ओपन-टेस्टिंग विधि (open-testing method) के माध्यम से फ़ज़र के लिए उपलब्ध हैं। हम देखेंगे कि बाद में इस लेख में “Invariant targets” पर चर्चा करते समय विशिष्ट कॉन्ट्रैक्ट्स/फ़ंक्शंस को कैसे बाहर रखा जाए (exclude) या शामिल किया जाए (include)।
यह रैंडम फ़ंक्शन कॉल किसी भी तरह से हमारे invariant को तोड़ने का प्रयास करता है। लेकिन जैसा कि कोड में निर्दिष्ट है, यदि सेंडर (sender) के पास कोई बैलेंस नहीं है तो फ़ंक्शन रिवर्ट हो जाएगा।
चूँकि invariant test इस तरह व्यवहार करता है, हम कुछ रिवर्ट केस (revert cases) देखते हैं, भले ही हमारा टेस्ट पास हो जाए।
(याद रखें कि fail_on_revert को बदलकर false कर दें, ताकि हमारा टेस्ट चलना बंद न हो)।
टेस्टिंग के लिए कॉन्ट्रैक्ट में एक भेद्यता (vulnerability) पेश करना
आगे परीक्षण करने के लिए, आइए कॉन्ट्रैक्ट में एक भेद्यता (vulnerability) पेश करें जो किसी को भी किसी भी एड्रेस (address) के जमा किए गए बैलेंस को बदलने की अनुमति देती है। Deposit कॉन्ट्रैक्ट में निम्नलिखित कोड जोड़ें:
function changeBalance(address depositor, uint amount) public {
balance[depositor] = amount;
}
अब हम टेस्ट को फिर से इसके साथ चलाते हैं,
forge test --mt invariant_alwaysWithdrawable
और हमें निम्नलिखित आउटपुट मिलता है:
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=0x0000000000000000000000000000000000000f7a addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[]
sender=0x73575ade2424045cf0df8fa1712dde9137c56416 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=changeBalance(address,uint256), args=[0xba2840574eA60882e96881D1cC3C1d7D90af0e1d, 3]
sender=0xff1cb1b0420410582bfd4b6b345769b2cc4a51f1 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=withdraw(), args=[]
sender=0x000000000000000000000808080808149a59da1d addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[]
sender=0x6383b40e80395f66de7f61df26bc9bafbbf3cb0f addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=withdraw(), args=[]
sender=0x97c68648bd6e6ed8a62e640937543f7bf47e39ba addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=changeBalance(address,uint256), args=[0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496, 2193]
invariant_alwaysWithdrawable() (runs: 31, calls: 456, reverts: 160)
Encountered a total of 1 failing tests, 0 tests succeeded
कॉल अनुक्रम (call sequence) में अंतिम फ़ंक्शन कॉल पर ध्यान दें। हम देख सकते हैं कि changeBalance फ़ंक्शन को कॉल किया गया है। यहाँ पास किए गए पैरामीटर्स हैं; 1. Foundry टेस्ट कॉन्ट्रैक्ट का address और 2. 2193 (पूरी तरह से रैंडम नंबर)।
यह उस टेस्ट कॉन्ट्रैक्ट के बैलेंस को बदल देगा, जिसका उपयोग हमने पहले एक ईथर जमा करने में किया था। तो अब हमारा बैलेंस एक ईथर होने के बजाय, 2193 है। इस प्रकार यह इस invariant को तोड़ता है कि “जमा की गई समान राशि जमाकर्ता द्वारा निकाली गई समान राशि होगी”।
यह पुष्टि करने के लिए कि changeBalance फ़ंक्शंस को पास किया गया address टेस्ट कॉन्ट्रैक्ट का address था, हम इसके बजाय टेस्ट के लिए एक वांछित (desired) address को प्रतिरूपित (impersonate) कर सकते हैं।
लेकिन changeBalance() टेस्ट्स में नहीं है, इसे कैसे कॉल किया गया?!
यही वह चीज़ है जो invariant testing को शानदार बनाती है। भले ही हमने कभी स्पष्ट रूप से changeBalance() को कॉल नहीं किया, invariant टेस्टर ने टेस्ट में फ़ंक्शन कॉल्स के स्पष्ट अनुक्रम (explicit sequence) को अंजाम देते समय इसमें रैंडमली कॉल्स डाल दिए।
यह invariant testing को उन पहलुओं का परीक्षण करने में सक्षम बनाता है जिनके बारे में हमने ‘‘सोचा ही नहीं था।’’
कॉन्ट्रैक्ट के बैलेंस के बजाय यूज़र के बैलेंस को बदलना
आइए टेस्ट फ़ंक्शन को इस प्रकार मॉडिफाई करें:
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);
}
हम अभी भी वही कर रहे हैं जो पहले कर रहे थे, सिवाय इसके कि टेस्ट कॉन्ट्रैक्ट msg.sender होने के बजाय, यह वह address(0xaa) होगा जिसे हमने अभी प्रैंक (prank) किया है।
अब forge test --mt invariant_alwaysWithdrawable के साथ टेस्ट को फिर से चलाएँ, और हमें निम्नलिखित प्राप्त होता है:
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
वही क्रिया दोहराई गई, लेकिन इस बार address(0xaa) के साथ (जैसा कि हम अंतिम कॉल अनुक्रम में देख सकते हैं) न कि टेस्ट कॉन्ट्रैक्ट के एड्रेस के साथ।
तार्किक रूप से, यह पहले invariant को भी तोड़ता है: ‘‘जमाकर्ता जमा किए गए ईथर को निकाल सकता है।’’ हमारे द्वारा पेश किए गए changeBalance फ़ंक्शन को balance बदलने के लिए किसी भी address और शून्य amount के साथ कॉल किया जा सकता है।
यह उस एड्रेस को, जिसने संभवतः पहले जमा किया है, अब शून्य बैलेंस वाला बना देगा और इस प्रकार वे अपना ईथर कॉन्ट्रैक्ट में होने पर भी नहीं निकाल सकते हैं।
कंडीशनल इनवेरिएंट्स (Conditional invariants)
हालाँकि invariants का हर समय कायम रहना आवश्यक है, कुछ invariants के लिए कुछ शर्तों (conditions) का कायम रहना आवश्यक होता है। उदाहरण के लिए, एक invariant जैसे assertEq(token.totalSupply(), 0); केवल तभी लागू होना चाहिए जब कोई मिंट (mint) न हुआ हो। यदि टोकन मिंट किया गया होता तो कुल सप्लाई (total supply) शून्य नहीं होती।
इन invariants को कंडीशनल इनवेरिएंट्स (conditional invariants) कहा जाता है क्योंकि लागू होने से पहले प्रोटोकॉल या स्मार्ट कॉन्ट्रैक्ट का कुछ शर्तों के अधीन होना आवश्यक है। अधिक जानने के लिए, आप इसे यहाँ देख सकते हैं।
Invariant test कॉन्फ़िग को बदलना
यदि हम प्रत्येक टेस्ट के लिए रन्स (runs) की संख्या बढ़ाना चाहते हैं, तो हम कॉन्फ़िग्स को foundry.toml फ़ाइल में जोड़ सकते हैं, जैसा कि इस लेख में पहले बताया गया है।
[invariant] अनुभाग (section) के नीचे foundry.toml फ़ाइल में निम्नलिखित जोड़ें।
[invariant] #invariant section
fail_on_revert = false
runs = 1215
depth = 23
अब forge test --mt invariant_alwaysWithdrawable के साथ टेस्ट फिर से चलाएं (सुनिश्चित करें कि आपने changeBalance फ़ंक्शन को हटा दिया है या कमेंट आउट कर दिया है)।
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 की संख्या सामान्य से काफी अधिक है क्योंकि हमने इसे अपने कॉन्फ़िग में मॉडिफाई किया है। आप शून्य से लेकर uint32.max तक किसी भी संख्या का उपयोग करना चुन सकते हैं।
यदि हम runs पैरामीटर को uint32 से बड़ी संख्या पर सेट करते हैं, तो जब हम टेस्ट चलाने का प्रयास करेंगे तो Foundry एक त्रुटि (error) देगा।
उदाहरण के लिए, आइए इसे 23000000000000 पर सेट करें और टेस्ट चलाने का प्रयास करें।
हमें यह मिलता है:
Error:
failed to extract foundry config:
foundry config error: invalid value signed int `23000000000000`, expected u32 for setting `invariant.depth`
एक बड़ी संख्या का अर्थ है अधिक टेस्ट परिदृश्य (test scenarios), लेकिन बड़ी संख्याएँ टेस्ट को धीमा कर देती हैं।
लगभग वास्तविक जीवन के उदाहरण (Near real-life examples)
हमने अपने कॉन्ट्रैक्ट के साथ Foundry में invariant testing की कम से कम बुनियादी बातों को कवर कर लिया है, लेकिन आइए आगे बढ़ें और एक लोकप्रिय कॉन्ट्रैक्ट पर invariant test करें।
हम SideEntranceLenderPool कॉन्ट्रैक्ट का परीक्षण करेंगे, जो लोकप्रिय रूप से ज्ञात Damn Vulnerable DeFi CTF के चौथे लेवल का कॉन्ट्रैक्ट है।
नीचे दिया गया कॉन्ट्रैक्ट यह है:
// 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"
);
}
}
हमारे Foundry प्रोजेक्ट और हम जो चाहते हैं उसमें फ़िट होने के लिए कॉन्ट्रैक्ट को थोड़ा मॉडिफाई किया गया है (openzeppelin इम्पोर्ट पर भी ध्यान दें)। हमने आवश्यक डिपेंडेंसीज़ (OpenZeppelin Address लाइब्रेरी इम्पोर्ट) भी इंस्टॉल कर ली हैं।
यह कॉन्ट्रैक्ट flashLoan फ़ंक्शन में असुरक्षित (vulnerable) है जो किसी को इसका फ़ायदा उठाने (exploit) और इसका ईथर बैलेंस निकालने (drain) की अनुमति देता है। एक हमलावर लोन लेने के लिए flashLoan फ़ंक्शन को कॉल कर सकता है और हमलावर के बैलेंस के रूप में deposit फ़ंक्शन के साथ उसी लोन को वापस कॉन्ट्रैक्ट में जमा कर सकता है, बाद में वे बैलेंस निकाल सकते हैं और इसके साथ भाग सकते हैं, भले ही यह मूल रूप से एक लोन था और उनका ईथर नहीं था।
तो यहाँ हमारा invariant क्या होने वाला है?
सबसे पहले, यह ध्यान रखना महत्वपूर्ण है कि कॉन्ट्रैक्ट में एक payable कंस्ट्रक्टर (constructor) है, और लोन के लिए उपयोग किया जाने वाला ईथर डिप्लॉयमेंट के दौरान जमा किया जाता है। उस प्रारंभिक रूप से जमा किए गए ईथर को निकालने का कोई तरीका भी नहीं है। ईथर को केवल deposit फ़ंक्शन का उपयोग करके कॉन्ट्रैक्ट में जोड़ा जा सकता है और withdraw फ़ंक्शन के साथ निकाला जा सकता है (केवल तभी जब फ़ंक्शन कॉलर ने पहले जमा किया हो)।
तो अगर हम इसे ध्यान में रखें, तो हम कह सकते हैं कि invariant यह होगा:
assert(address(SideEntranceLenderPool).balance >= SideEntranceLenderPool.initialPoolBalance());
(initialPoolBalance एक पब्लिक स्टेट वेरिएबल (public state variable) है जिसका उपयोग यह स्टोर करने के लिए किया जाता है कि डिप्लॉयमेंट के दौरान कितना ईथर जमा किया गया था)।
हम दावा करते हैं (assert) कि SideEntranceLenderPool ईथर बैलेंस हमेशा डिप्लॉयमेंट के दौरान जमा किए गए ईथर से अधिक या उसके बराबर होता है।
यदि सब कुछ ठीक काम करता है, तो यह invariant कायम रहना चाहिए। लेकिन जैसा कि पहले कहा गया है, एक भेद्यता (vulnerability) किसी को कॉन्ट्रैक्ट से लिए गए लोन को जमा करने और बाद में उसे निकालने की अनुमति देती है।
अगले भाग में, हम बेहतर परिणाम प्राप्त करने के लिए Foundry invariant testing में एक नया कॉन्सेप्ट पेश करेंगे जिसे — Handler कहा जाता है।
हैंडलर-आधारित परीक्षण (Handler-based testing)
अधिक जटिल प्रोटोकॉल या कॉन्ट्रैक्ट्स का परीक्षण करने के लिए एक हैंडलर कॉन्ट्रैक्ट (handler contract) का उपयोग किया जाता है। यह एक रैपर (wrapper) कॉन्ट्रैक्ट के रूप में काम करता है जिसका उपयोग हमारे वांछित कॉन्ट्रैक्ट के साथ इंटरैक्ट करने या कॉल करने के लिए किया जाएगा।
यह विशेष रूप से तब आवश्यक होता है जब एनवायरनमेंट को एक निश्चित तरीके से कॉन्फ़िगर करने की आवश्यकता होती है (अर्थात एक कंस्ट्रक्टर को कुछ निश्चित पैरामीटर्स के साथ कॉल किया जाता है)।
यह इस तरह काम करता है कि, टेस्ट फ़ाइल में setUp फ़ंक्शन में, हम वह हैंडलर कॉन्ट्रैक्ट डिप्लॉय करते हैं जो पूल कॉन्ट्रैक्ट को कॉल करेगा और targetContract(address target) टेस्ट हेल्पर फ़ंक्शन का उपयोग करके टेस्ट में केवल इस हैंडलर कॉन्ट्रैक्ट को टारगेट कॉन्ट्रैक्ट के रूप में सेट करेगा।
इस वजह से, फ़ज़र (fuzzer) द्वारा केवल हैंडलर कॉन्ट्रैक्ट के फ़ंक्शंस को रैंडमली कॉल किया जाएगा।
एक और लाभ यह है कि यदि मुख्य कॉन्ट्रैक्ट (इस मामले में SideEntranceLenderPool कॉन्ट्रैक्ट) में किसी फ़ंक्शन को कॉल किए जाने से पहले एक निश्चित शर्त (condition) की आवश्यकता होती है, तो हम फ़ंक्शन कॉल से पहले इसे हैंडलर कॉन्ट्रैक्ट में आसानी से परिभाषित कर सकते हैं।
हैंडलर कॉन्ट्रैक्ट forge-std Test को भी इनहेरिट (inherit) कर सकता है और Foundry चीटशीट्स जैसे vm.deal, vm.prank, आदि का उपयोग कर सकता है। जैसे-जैसे हम आगे बढ़ेंगे हम इसे प्रदर्शित करेंगे।
आइए test फ़ोल्डर के अंदर एक /handler फ़ोल्डर और उसके अंदर एक handler.sol फ़ाइल बनाएँ।
यह हमारे हैंडलर कॉन्ट्रैक्ट के लिए कोड होगा।
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 {}
}
हमने हैंडलर कॉन्ट्रैक्ट में फ़ंक्शंस परिभाषित किए हैं जो SideEntranceLenderPool कॉन्ट्रैक्ट फ़ंक्शंस को कॉल करते हैं। ऐसा इसलिए है ताकि हम अधिक एज केसेस (edge cases) का परीक्षण कर सकें और व्यावहारिक रूप से भेद्यता (vulnerability) का लाभ उठा सकें।
हैंडलर कॉन्ट्रैक्ट forge-std Test को इनहेरिट करता है जैसा कि पहले बताया गया है, और हैंडलर कॉन्ट्रैक्ट के कंस्ट्रक्टर के अंदर कॉन्ट्रैक्ट को कुछ ईथर देने के लिए vm.deal मेथड का उपयोग किया जाता है।
Invariant Targets और test helpers
Foundry forge-std लाइब्रेरी में टेस्ट हेल्पर फ़ंक्शंस (test helper functions) के साथ आता है जो हमें हमारे टारगेट कॉन्ट्रैक्ट्स, टारगेट आर्टिफैक्ट्स (target artifacts), टारगेट सिलेक्टर्स (target selectors), और टारगेट आर्टिफैक्ट्स सिलेक्टर्स को निर्दिष्ट (specify) करने की अनुमति देता है।
कुछ हेल्पर फ़ंक्शंस हैं
targetContract(address newTargetedContract_)targetSelector(FuzzSelector memory newTargetedSelector_)excludeContract(address newExcludedContract_).
सभी उपलब्ध टेस्ट हेल्पर फ़ंक्शंस देखने के लिए, यहाँ और यहाँ देखें।
हम test फ़ोल्डर के अंदर एक SideEntranceLenderPool.t.sol टेस्ट फ़ाइल बनाएंगे। यहाँ हम SideEntranceLenderPool कॉन्ट्रैक्ट के लिए अपने invariant test को परिभाषित करेंगे और हैंडलर कॉन्ट्रैक्ट को अपने invariant target के रूप में निर्दिष्ट करेंगे।
टेस्ट फ़ाइल के अंदर निम्नलिखित कोड पेस्ट करें:
// 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());
}
}
कोड पेस्ट करने के बाद, आइए इसके साथ टेस्ट चलाएं
forge test --mt invariant_poolBalanceAlwaysGtThanInitialBalance
हमें यह आउटपुट मिलता है:
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)
टेस्ट invariant को तोड़ने और एक्सप्लॉइट (exploit) खोजने में सक्षम था।
पहले flashLoan फ़ंक्शन को कॉल किया गया, और फिर withdraw फ़ंक्शन को।
संपूर्ण स्टैक ट्रेस (stack trace) और कॉल अनुक्रम (call sequence) देखने के लिए, हम इसके साथ टेस्ट फिर से चला सकते हैं
forge test --mt invariant_poolBalanceAlwaysGtThanInitialBalance -vvvv
[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"
अब हम पूरे कॉल अनुक्रम (call sequence) की कल्पना कर सकते हैं और देख सकते हैं कि invariant कैसे टूटा।
एक गणितीय कथन (mathematical statement) के साथ एक उदाहरण
यह उदाहरण एक स्टेटलेस फ़ज़ (stateless fuzz) होगा, यानी इसका व्यवहार (behavior) पिछली कॉल्स पर निर्भर नहीं करता है। यहाँ उद्देश्य फ़ज़िंग की सीमाओं और इसके समाधान (work around) को प्रदर्शित करना है। हम इसे एक stateful fuzz में बदलने के लिए कुछ स्टोरेज वेरिएबल्स जोड़ सकते हैं, लेकिन यह अभी के लिए ध्यान भटकाने वाला होगा।
यहाँ हमारा उदाहरण कॉन्ट्रैक्ट है:
// 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 बूलियन (boolean) वेरिएबल हर समय true है, जो कि यह है; assertTrue(quadratic.ok());
यह केवल तभी false हो जाता है जब notOkay फ़ंक्शन को एक ऐसी संख्या के साथ कॉल किया जाता है जो इस कथन को पूरा करती है: (x - 11111) * (x - 11113) < 0।
यह आसान लग सकता है, लेकिन देखते हैं कि क्या फ़ज़र कोई संख्या ढूंढ सकता है और invariant को तोड़ सकता है।
हम यहाँ हैंडलर मेथड का भी उपयोग करेंगे, इसलिए /test/ हैंडलर फ़ोल्डर के अंदर एक Handler_2.sol फ़ाइल बनाएँ और इस कोड को पेस्ट करें।
// 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);
}
}
अब test फ़ोल्डर के अंदर एक Quadratic.t.sol फ़ाइल बनाएँ और इस कोड को अंदर पेस्ट करें:
// 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 को invariant_NotOkay फ़ंक्शन में परिभाषित किया है।
इसके साथ टेस्ट चलाएँ
forge test --mt invariant_NotOkay
और हमें मिलता है:
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) की संख्या बढ़ाएं और देखें कि क्या यह इसे खोज पाता है।
रन्स की संख्या 20,000 पर सेट करें।
[invariant]
runs = 20000
हमने टेस्ट को फिर से चलाया और निम्नलिखित प्राप्त किया:
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
हाई रन (high run) के बावजूद, टेस्ट उस invariant को नहीं तोड़ सका, जो ऐसी संख्या पास करे जिससे ok फ़ॉल्स (false) हो जाए।
यह पुष्टि करने के लिए कि कोई संख्या मौजूद है, समीकरण (equation) इनपुट करने पर desmos graph इस संख्या को दिखाता है, जैसा कि नीचे दी गई छवि में नीले घेरे वाले भाग में दिखाया गया है।

छवि दिखाती है कि हमें जो संख्या चाहिए वह 11112 है।
आइए उन संख्याओं की सीमा (range) को बाउंड (bound) करने का प्रयास करें जिनका उपयोग फ़ज़र हैंडलर कॉन्ट्रैक्ट पर x = bound(x, 11_000, 100_000); के साथ करेगा। हैंडलर कॉन्ट्रैक्ट (दूसरे हैंडलर कॉन्ट्रैक्ट) के notOkay फ़ंक्शन में कोड की इस लाइन को जोड़ें।
यह अब इस तरह दिखना चाहिए:
function notOkay(int x) external {
x = bound(x, 10_000, 100_000);
quadratic.notOkay(x);
}
बाउंड (bound) हेल्पर फ़ंक्शन forge-std Test लाइब्रेरी के साथ आता है; हम फ़ज़ किए गए इनपुट्स (fuzzed inputs) की सीमा को सीमित कर सकते हैं।
इसके साथ टेस्ट को फिर से चलाएँ
forge test --mt invariant_NotOkay -vvv
और हमें निम्नलिखित प्राप्त होता है:
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 ने एसरशन (assertion) को तोड़ दिया, लेकिन कॉल अनुक्रम को देखते हुए, हम यह नहीं देख सकते कि notOkay फ़ंक्शन को 11112 के साथ कहाँ कॉल किया गया था।
क्या हो रहा था यह देखने के लिए हमने वर्बोसिटी फ़्लैग (verbosity flag) -vvv का उपयोग किया।
टेस्ट परिणाम में लॉग ट्रेसेस (log traces) भी हैं, जो ये हैं:
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)
│ └─ ← ()
└─ ← ()
लॉग ट्रेसेस दिखाते हैं कि फ़ज़र ने notOkay फ़ंक्शन को उन संख्याओं के साथ कॉल किया जो वांछित (desired) संख्या के करीब भी नहीं हैं। फिर भी, बाउंड फ़ंक्शन इन इनपुट्स को तब तक बदलता रहा जब तक कि सही संख्या प्राप्त नहीं हो गई, जैसा कि अंतिम बाउंड परिणाम और कॉल अनुक्रम में दिखाया गया है।
उन मामलों में बाउंड फ़ंक्शन का उपयोग करना जहां संख्याओं की एक विशेष सीमा (range) का परीक्षण किया जाना चाहिए, उपयोगी हो सकता है और बेहतर परिणाम प्राप्त करने में मदद करता है।
निष्कर्ष (Conclusion)
इस लेख में, हमने सीखा कि invariants क्या हैं, वे क्यों महत्वपूर्ण हैं, और Foundry में invariant testing कैसे की जाती है।
हमने कंडीशनल इनवेरिएंट्स (conditional invariants), हैंडलर-आधारित सेटअप (handler-based setup), और फ़ज़ किए गए इनपुट वैल्यूज़ की सीमा को कैसे और कब बाउंड करना है, इस पर भी चर्चा की।
और जानें (Learn More)
हमारा advanced Solidity training यूनिट टेस्ट से आगे आधुनिक स्मार्ट कॉन्ट्रैक्ट टेस्टिंग सिखाता है। अधिक जानने के लिए इसे देखें।
लेखक (Authorship)
इस लेख को जेसी रेमंड (Jesse Raymond) (LinkedIn, Twitter) द्वारा RareSkills Research and Technical Writing Program के हिस्से के रूप में सह-लिखा गया था।
मूल रूप से 28 अप्रैल, 2023 को प्रकाशित