هدف ما از این مقاله تحقیر برنامه نویسانی که در ابتدای مسیر یادگیری هستند، نیست. با بررسی کدهای بسیاری از توسعه دهندگان سالیدیتی، متوجه شدهایم برخی اشتباهات بیشتر از بقیه تکرار میشوند و در اینجا آنها را فهرست کردهایم.
این لیست به هیچ وجه فهرست کاملی از تمام اشتباهاتی که یک توسعه دهنده Solidity ممکن است مرتکب شود، نیست. حتی توسعه دهندگان متوسط و باتجربه نیز ممکن است این خطاها را انجام دهند.
با این حال، این اشتباهات رایج در سالیدیتی بیشتر در مراحل اولیه یادگیری رخ میدهند، بنابراین ارزش آن را دارد که آنها را لیست کنیم.
۱. انجام تقسیم قبل از ضرب
در سالیدیتی، عملیات تقسیم باید همیشه در پایان انجام شود، زیرا تقسیم باعث گرد شدن (به سمت پایین) عدد میشود.
برای مثال، اگر بخواهیم محاسبه کنیم که باید به کسی ۳۳.۳۳٪ سود پرداخت کنیم، روش اشتباه برای انجام این کار به صورت زیر است:
1 |
interest = principal / 3_333 * 10_000; |
principal
کمتر از ۳۳۳۳ باشد، مقدار interest
به صفر گرد میشود. در عوض، باید به صورت زیر محاسبه شود:
1 |
interest = principal * 10_000 / 3_333; |
1 2 3 4 5 6 7 8 9 10 11 12 |
**// Wrong way:** If principal = 3000, interest = principal / 3333 * 10000 interest = 3000 / 3333 * 10000 interest = 0 * 10000 (rounding down in division) interest = 0 // **Correct Calculation:** If principal = 3000, interest = principal * 10000 / 3333 interest = 3000 * 10000 / 3333 interest = 30000000 / 3333 interest approx 9000 |
یافتن این خطا با استفاده از Slither
Slither یک ابزار تحلیل ایستای کد است که توسط شرکت Trail of Bits توسعه داده شده و کدهای Solidity را بررسی کرده و الگوهای رایج خطا را شناسایی میکند.
اگر کد زیر را در فایل interest.sol
بنویسیم که شامل یک اشتباه رایج است:
1 2 3 4 5 6 7 |
contract Interest { // 1 basis point is 0.01% or 1/10_000 function calculateInterest(uint256 principal, uint256 interestBasisPoints) public pure returns (uint256 interest){ interest = principal / 10_000 * interestBasisPoints; } } |
1 |
slither interest.sol |
در این هشدار، Slither اعلام میکند که ابتدا عمل تقسیم و سپس ضرب انجام شده است، که در Solidity بهطور کلی باید از آن اجتناب شود، زیرا میتواند به گرد شدن مقادیر به صفر یا نتیجهای نادرست منجر شود.
۲. رعایت نکردن الگوی Check-Effects-Interaction
در زبان سالیدیتی، پیروی از الگوی «بررسی – تغییر وضعیت – تعامل» (Check-Effects-Interaction) بسیار حیاتی است تا از حملات re-entrancy (دوباره واردی) جلوگیری شود. این الگو میگوید که تماس با قرارداد دیگر یا ارسال ETH به یک آدرس خارجی باید آخرین عملیات در یک تابع باشد. در غیر این صورت، قرارداد میتواند در معرض حمله قرار گیرد.
در مثال زیر، قرارداد BadBank
این الگو را رعایت نکرده و در نتیجه میتوان کل موجودی ETH آن را خالی کرد.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.26; // DO NOT USE contract BadBank { mapping(address => uint256) public balances; constructor() payable { require(msg.value == 10 ether, "deposit 10 eth"); } function deposit() external payable { balances[msg.sender] += msg.value; } function withdraw() external { (bool ok, ) = msg.sender.call{value: balances[msg.sender]}(""); require(ok, "transfer failed"); balances[msg.sender] = 0; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
contract BankDrainer { function steal(BadBank bank) external payable { require(msg.value == 1 ether, "send deposit 1 eth"); bank.deposit{value: 1 ether}(); bank.withdraw(); } receive() external payable { // msg.sender is the BadBank because the BadBank // called `receive()` when it transfered either while (msg.sender.balance >= 1 ether) { BadBank(msg.sender).withdraw(); } } } |
BankDrainer
برای خالی کردن موجودی بانک استفاده میشود. شما میتوانید این کدها را در محیط Remix آزمایش کنید. همچنین یک ویدیو برای نمایش عملی این حمله وجود دارد:
علت آسیبپذیری:
تابع withdraw()
در قرارداد BadBank
، قبل از بهروزرسانی موجودی حساب کاربر، ETH را ارسال میکند. این عمل باعث فراخوانی تابع receive()
در قرارداد مهاجم (BankDrainer) میشود، و چون موجودی هنوز کاهش نیافته، مهاجم میتواند چندین بار به تابع withdraw()
وارد شود و موجودی بانک را خالی کند.
برای جلوگیری از این آسیبپذیری، همیشه ابتدا شرایط را بررسی کنید (check)، سپس وضعیت را تغییر دهید (effects)، و در پایان تعامل خارجی انجام دهید (interaction). این دسته از حملات تحت عنوان Re-entrancy شناخته میشوند
اگر این کد را با ابزار Slither بررسی کنیم، دو هشدار دریافت میکنیم:
-
هشدار اول مبنی بر «ارسال ETH به کاربر دلخواه» که ممکن است در اینجا مثبت کاذب باشد. درست است که هر کسی میتواند تابع
withdraw
را فراخوانی کند، اما فقط موجودی خودش را میتواند برداشت کند (حداقل در ابتدا!). -
هشدار دوم بهدرستی وجود آسیبپذیری Re-entrancy را تشخیص میدهد.
۳. استفاده از transfer
یا send
در زبان Solidity دو تابع ساده برای ارسال اتر (ETH) از قرارداد به آدرس مقصد وجود دارد: transfer()
و send()
. با این حال، نباید از این توابع استفاده کنید.
وبلاگ معروف شرکت Consensys در مورد دلایل پرهیز از استفاده از transfer
و send
، یکی از مطالب پایه ای است که هر توسعه دهنده سالیدیتی باید آن را مطالعه کند.
چرا این توابع به وجود آمدند؟
پس از حمله DAO که منجر به دو شاخه شدن اتریوم به Ethereum و Ethereum Classic شد، توسعه دهندگان به شدت از حملات Re-entrancy (دوبارهواردی) ترسیدند. برای کاهش این ریسک، توابع transfer()
و send()
معرفی شدند تا مقدار گاز قابل استفاده برای دریافت کننده را به ۲۳۰۰ واحد گاز محدود کنند. این مقدار گاز باعث میشد دریافت کننده نتواند کد پیچیده ای اجرا کند و در نتیجه حمله Re-entrancy رخ ندهد.
گاز (Gas) چیست؟
گاز یک واحد اندازه گیری مصرف منابع محاسباتی در بلاکچین اتریوم است.
هر عملیات (مثل ذخیره داده، اجرای تابع، انتقال پول و…) در شبکه اتریوم هزینهای دارد که به صورت گاز پرداخت میشود. پرداخت گاز از طریق ETH انجام میشود و باعث میشود اجرای قراردادهای هوشمند بهینه، محدود و مقاوم در برابر سوءاستفاده باشد. گاز همچنین مانع از حملات DoS میشود، چون اجرای کدهای زیاد، هزینهبر خواهد بود.
سناریوی نمونه:
در مثالهای قبلی، این کد را داشتیم:
1 2 |
(bool ok, ) = msg.sender.call{value: balances[msg.sender]}(""); require(ok, "transfer failed"); |
1 |
payable(msg.sender).transfer(balances[msg.sender]); |
با این تغییر، بانک دیگر در برابر حمله Re-entrancy آسیبپذیر نیست.
اما مشکل کجاست؟ اگر قرارداد مقصد نیاز به گاز بیشتری برای پردازش دریافتی ها داشته باشد، این روش باعث شکست تعامل خواهد شد. مثلاً اگر قرارداد مقصد بخواهد بعد از دریافت ETH، اطلاعات فرستنده را در یک متغیر ذخیره کند یا عملیات حسابداری انجام دهد، چون فقط ۲۳۰۰ گاز دارد، این کار انجام نخواهد شد و انتقال با شکست مواجه میشود.
به مثال زیر توجه کنید:
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 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.26; contract GoodBank { mapping(address => uint256) public balances; function withdraw() external { uint256 balance = balances[msg.sender]; balances[msg.sender] = 0; (bool ok, ) = msg.sender.call{value: balance}(""); require(ok, "transfer failed"); } receive() external payable { balances[msg.sender] += msg.value; } } contract SendToBank { address owner; constructor() { owner = msg.sender; } function depositInBank( address bank ) external payable { require(msg.sender == owner, "not owner"); // THIS LINE WILL FAIL payable(bank).transfer(msg.value); } function withdrawBank( address payable bank ) external { require(msg.sender == owner, "not owner"); // this triggers the receive function GoodBank(bank).withdraw(); // the receive function has completed // and now this contract has a balance // send it to the owner (bool ok, ) = msg.sender.call{value: address(this).balance}(""); require(ok, "transfer failed"); } // we need this to receive Ether from the bank receive() external payable { } } |
میتوانید کد بالا را در Remix تست کنید. و در زیر ویدیویی وجود قرار دادیم که انتقال ناموفق را نشان میدهد:
تراکنش به این دلیل با شکست مواجه میشود که تابع receive()
هنگام افزایش موجودی فرستنده با کمبود گاز مواجه میشود.
تراکنش به این دلیل با شکست مواجه میشود که تابع receive()
هنگام افزایش موجودی فرستنده با کمبود گاز مواجه میشود.
پس از transfer
یا send
استفاده نکنید و کدی با قابلیت دوباره واردی (re-entrancy) ننویسید.
اولین گزینه این است که transfer
یا send
را با دستور زیر جایگزین کنید:
1 |
(bool success, ) = address(receiver).call{value: amountToSend}(""); |
هر دو روش در ادامه نشان داده شدهاند.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import {Address} from "@openzeppelin/contracts/utils/Address.sol"; contract SendEthExample { using Address for address payable; // both these functions do the same thing. Note that OZ requires // payable addresses, but a low-level call does not function sendSomeEthV1(address receiver, uint256 amount) external payable { payable(receiver).sendValue(amount); } function sendSomeEthV2(address receiver, uint256 amount) external payable { (bool ok, ) = receiver.call{value: amount}(""); require(ok, "transfer failed"); } } |
transfer
و send
اجتناب کنید، چراکه محدودیت گاز آنها باعث اختلال در ارتباط با قراردادهای دیگر میشود و ممکن است موجب باگ هایی شود که شناساییشان دشوار است.
۴. استفاده از tx.origin
به جای msg.sender
در زبان سالیدیتی، دو روش برای تشخیص اینکه «چه کسی در حال فراخوانی من است» وجود دارد: یکی tx.origin
و دیگری msg.sender
.
-
tx.origin
آدرس کیف پولی است که تراکنش را امضا کرده. -
msg.sender
مستقیماً فراخوانندهی فعلی قرارداد است.
اگر یک کیف پول مستقیماً یک قرارداد را فراخوانی کند:
کیف پول ← قرارداد
در این حالت، از دید قرارداد، آدرس کیف پول هم msg.sender
است و هم tx.origin
.
اما اگر کیف پول ابتدا یک قرارداد واسطه را فراخوانی کند که سپس قرارداد نهایی را فراخوانی میکند:
کیف پول ← قرارداد واسطه ← قرارداد نهایی
در این حالت، برای قرارداد نهایی، tx.origin
برابر با آدرس کیف پول است، اما msg.sender
آدرس قرارداد واسطه است.
استفاده از tx.origin
برای شناسایی فراخواننده میتواند منجر به آسیب پذیری امنیتی شود. فرض کنید کاربر فریب داده میشود و یک قرارداد مخرب واسطه را فراخوانی میکند:
کیف پول ← قرارداد واسطه مخرب ← قرارداد نهایی
در این حالت، قرارداد واسطه مخرب با استفاده از tx.origin
میتواند از مجوزهای کیف پول سوءاستفاده کند و کارهایی مثل انتقال وجه انجام دهد، بدون اینکه msg.sender
بررسی شود.
نکته: ابزار Slither در حال حاضر درباره استفاده از tx.origin
هشدار نمیدهد، اما با این حال باید از آن پرهیز کرد.
۵. استفاده نکردن از safeTransfer
برای توکن های ERC-20
استاندارد ERC-20 فقط مشخص کرده که اگر کاربر بخواهد بیشتر از موجودی خود انتقال دهد، باید خطا رخ دهد. اما اگر انتقال به دلایلی غیر از کمبود موجودی شکست بخورد، استاندارد صراحتاً مشخص نکرده که چه اتفاقی باید بیفتد.
تابع استاندارد انتقال در ERC-20 به شکل زیر تعریف شده است:
1 |
function transfer(address _to, uint256 _value) public returns (bool success); |
که طبق این تعریف، انتظار میرود توکن در صورت شکست عملیات، مقدار false
برگرداند.
اما در عمل، توکن های ERC-20 به شیوه های متفاوتی پیاده سازی شدهاند:
-
برخی هنگام شکست عملیات، تراکنش را revert میکنند.
-
برخی هیچ مقداری برنمیگردانند (یعنی حتی امضای تابع را هم رعایت نمیکنند).
کتابخانه SafeERC20
از OpenZeppelin برای مدیریت این تفاوتها طراحی شده و شرایط مختلف را بررسی میکند:
-
اگر عملیات revert شود،
SafeERC20
همان خطا را بالا میآورد. -
اگر عملیات revert نشود:
-
اگر هیچ داده ای برنگردد و آدرس مورد نظر قرارداد نباشد (مثلاً آدرس خالی باشد)، کتابخانه عملیات را revert میکند.
-
اگر داده ای برگشته ولی مقدار آن
false
باشد، باز هم عملیات را revert میکند. -
فقط اگر بازگشت موفقیتآمیز باشد (یا داده نداشته باشد ولی آدرس معتبر باشد)، عملیات موفق تلقی میشود.
-
در ادامه نشان داده میشود که چگونه باید از کتابخانه SafeERC20
استفاده کرد:
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 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.25; import "@openzeppelin/contracts@5.0.0/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts@5.0.0/token/ERC20/ERC20.sol"; contract SafeTransferDemo { using SafeERC20 for IERC20; function deposit( IERC20 token, uint256 amount) external { token.safeTransferFrom(msg.sender, address(this), amount); } // withdraw function not shown } contract MyToken is ERC20("MyToken", "MT") { constructor() { // mint the supply of 10_000 tokens // to the deployer _mint(msg.sender, 10_000 * 1e18); } } |
۶. استفاده از SafeMath در سالیدیتی نسخه ۰.۸.۰ یا بالاتر
پیش از نسخه ۰.۸.۰ سالیدیتی، در صورت انجام عملیات ریاضی که نتیجه ای بزرگتر از ظرفیت متغیر تولید میکرد، سرریز (overflow) رخ میداد. برای جلوگیری از این مشکل، کتابخانه محبوب SafeMath
از OpenZeppelin معرفی شد. برای مثال، تابع جمع در این کتابخانه به این شکل عمل میکرد:
1 2 3 4 5 |
function add(uint256 x, uint256 y) internal pure returns (uint256) { uint256 sum = x + y; require(sum >= x || sum >= y, "overflow"); return sum; } |
مقدار جمعشده باید همیشه از x یا y بزرگتر باشد. اگر این شرط برقرار نباشد، سرریز اتفاق افتاده و تابع خطا میدهد.
در کدهای قدیمیتر اغلب میبینید:
1 |
using SafeMath for uint256; |
1 |
uint256 sum = x.add(y); |
اما در سالیدیتی ۰.۸.۰ و نسخه های بالاتر، دیگر نیازی به استفاده از SafeMath نیست، چرا که کامپایلر بهصورت پیشفرض بررسی سرریز را انجام میدهد.
بنابراین، استفاده از کتابخانه SafeMath برای عملیات ساده ریاضی در نسخه های جدید:
-
خوانایی کد را پایین می آورد،
-
باعث کاهش کارایی میشود،
-
و هیچ مزیت امنیتی اضافه ای ندارد.
۷. فراموش کردن کنترل دسترسی (Access Control)
بیایید یک مثال ساده بررسی کنیم. آیا میتوانید مشکل زیر را تشخیص دهید؟
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import "@openzeppelin/contracts@5.0.0/token/ERC721/ERC721.sol"; contract NFTSale is ERC721("MyTok", "MT") { uint256 public price; uint256 public currentId; function setPrice( uint256 price_ ) public { price = price_; } function buyNFT() external payable { require(msg.value == price, "wrong price"); currentId++; _mint(msg.sender, currentId); } } |
در این حالت، هر کسی میتواند تابع setPrice()
را فراخوانی کند و قیمت را روی صفر قرار دهد، سپس تابع buyNFT()
را با هزینه صفر اجرا کند و NFT را رایگان بخرد!
هرگاه تابعی را بهصورت public
یا external
تعریف میکنید، حتما از خودتان بپرسید:
“آیا همه باید اجازه فراخوانی این تابع را داشته باشند؟”
در نسخه ای دیگر از همین مشکل:
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 |
import "@openzeppelin/contracts@5.0.0/token/ERC721/ERC721.sol"; contract NFTSale is ERC721("MyTok", "MT") { uint256 public price; address owner; uint256 public currentId; constructor() { owner = msg.sender; } modifier onlyOwner() { require(msg.sender == owner, "onlyOwner"); _; } function setPrice( uint256 price_ ) public onlyOwner { price = price_; } function buyNFT() external payable { require(msg.value == price, "wrong price"); currentId++; _mint(msg.sender, currentId); } } |
onlyOwner
استفاده شده که فقط به مالک قرارداد اجازه تغییر قیمت را میدهد.این کار، یک لایه امنیتی حیاتی برای جلوگیری از سوءاستفادههای احتمالی است.
۸. انجام عملیات های پرهزینه در حلقه ها
آرایه هایی که میتوانند بدون محدودیت رشد کنند مشکلساز هستند، زیرا هزینه تراکنش برای اجرای حلقه روی آنها ممکن است بسیار زیاد شود.
قرارداد زیر کمکهای اتر دریافت میکند و اهداکنندگان را به یک آرایه اضافه میکند. بعداً، مالک قرارداد تابع distributeNFTs() را فراخوانی کرده و برای همه اهداکنندگان یک NFT ضرب میکند. با این حال، اگر تعداد اهداکنندگان زیاد باشد، ممکن است این کار برای مالک بیش از حد پرهزینه باشد و نتواند فرایند اهدای NFT را کامل کند.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import "@openzeppelin/contracts@5.0.0/token/ERC721/ERC721.sol"; import "@openzeppelin/contracts@5.0.0/access/Ownable.sol"; contract GiveNFTToDonors is ERC721("MyTok", "MT"), Ownable(msg.sender) { address[] donors; uint256 currentId; receive() external payable { require(msg.value >= 0.1 ether, "donation too small"); donors.push(msg.sender); } function distributeNFTs() external onlyOwner { for (uint256 i = 0; i < donors.length; i++) { currentId++; _mint(msg.sender, currentId); } } } |
در اینجا تابع distributeNFTs()
سعی میکند روی کل آرایهٔ donors
حلقه بزند و به همه NFT بدهد.
اگر این لیست خیلی بزرگ شود، اجرای این تابع:
-
بسیار پرهزینه میشود،
-
ممکن است به حد گس بلاک برسد و تراکنش شکست بخورد.
ابزار Slither در چنین مواقعی هشدار میدهد که اجرای تابع در صورت بزرگ بودن آرایه، عملی نخواهد بود:
راه حل این مسئله با عنوان «کشیدن به جای هل دادن» (pull over push) شناخته میشود. بهجای آنکه شما برای هر گیرنده NFT را ارسال کنید، آنها باید تابعی را فراخوانی کنند که در صورت فراخوانی توسط آن آدرس، NFT را به آن آدرس منتقل کند.
۹. نبود بررسی سلامت روی ورودی های تابع
هر زمان که یک تابع عمومی (public) مینویسید، بهصورت صریح مشخص کنید که چه مقادیری انتظار میرود به آرگومان های آن پاس داده شوند و مطمئن شوید که با استفاده از دستور require
این شرایط را اعمال میکنید.
برای مثال، افراد نباید بتوانند بیشتر از موجودی شان برداشت کنند. همچنین، نباید بتوانند دارایی هایی را برداشت کنند که خودشان واریز نکردهاند.
به مثال های زیر توجه کنید:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
contract LendingProtocol is Ownable { function offerLoan( uint256 amount, uint256 interest, uint256 duration) external {} function setProtocolFee( uint256 feeInBasisPoints) external onlyOwner {} } |
طراح باید در نظر داشته باشد که چه پارامترهایی معقول هستند. نرخ بهرهای بیش از ۱۰۰۰٪ غیرمنطقی است. یا مدتی بسیار کوتاه مثل یک ساعت نیز غیرمنطقی محسوب میشود.
بهطور مشابه، تابع setProtocolFee
باید یک حد بالای معقول برای میزان کارمزدی که مالک میتواند تنظیم کند داشته باشد. در غیر این صورت، کاربران ممکن است با افزایش ناگهانی و غیرمنتظره کارمزدهای استفاده از پروتکل روبرو شوند.
برای پیاده سازی این بررسی های منطقی، کافی است از require
استفاده کنید تا محدوده مجاز برای ورودی ها را مشخص کنید.
هنگام طراحی هر تابع عمومی، همیشه بررسی کنید که چه بازهای از پارامترها برای آرگومان های آن تابع منطقی و قابل قبول است.
۱۰. کد ناقص در سالیدیتی
برخی باگ ها در سالیدیتی به دلیل نبود بخشی از کد اتفاق میافتند، نه به خاطر اشتباه در کدی که وجود دارد. قرارداد زیر که برای مینت کردن NFT طراحی شده، به مالک اجازه میدهد که مشخص کند چه کسانی مجاز به مینت کردن هستند و هرکدام تا چه مقدار میتوانند مینت کنند.
(البته این روش بهینه ای برای مصرف گاز نیست، اما هدف اینجا تمرکز روی اصل موضوع است.)
در این کد چه چیزی کم است؟
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 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.25; import "@openzeppelin/contracts@5.0.0/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts@5.0.0/token/ERC721/ERC721.sol"; import "@openzeppelin/contracts@5.0.0/access/Ownable2Step.sol"; contract MissingCode is ERC721("MissingCode", "MC"), Ownable(msg.sender) { uint256 id; mapping(address => uint256) public amountAllowedToMint; function mint( uint256 amount ) external { require(amount < amountAllowedToMint[msg.sender], "not enough allocation"); for (uint256 i = 0; i < amount; i++) { id++; _mint(msg.sender, id); } } function setAmountAllowedToMint( address[] calldata minters, uint256[] calldata amounts ) external onlyOwner { require(minters.length == amounts.length, "length mismatch"); for (uint256 i = 0; i < minters.length; i++) { amountAllowedToMint[minters[i]] = amounts[i]; } } } |
مشکل اینجاست که مقدار مینت شده توسط خریدار از amountAllowedToMint
کم نمیشود، بنابراین «محدودیت» عملاً اعمال نمیشود. یک آدرس موجود در مپ میتواند هر تعداد دلخواه توکن مینت کند.
باید بعد از تابع _mint()
یک خط اضافه شود:
1 |
amountAllowedToMint[msg.sender] -= amount; |
۱۱. عدم تعیین نسخه دقیق Pragma در Solidity
هنگامی که کد کتابخانه های سالیدیتی را میخوانید، اغلب در بالای فایل عباراتی مانند زیر را میبینید:
1 2 |
//SPDX-License-Identifier: MIT pragma solidity ^0.8.0; |
به همین دلیل، بسیاری از توسعه دهندگان تازهکار این الگو را بدون فکر کپی میکنند.
اما استفاده از ^0.8.0
فقط برای کتابخانه ها مناسب است. چون نویسنده کتابخانه نمیداند توسعه دهنده بعدی با کدام نسخه کامپایل خواهد کرد، فقط یک حداقل نسخه را مشخص میکند.
اما وقتی شما بهعنوان توسعه دهنده قصد دیپلوی یک اپلیکیشن را دارید، دقیقاً میدانید که از چه نسخه ای از کامپایلر استفاده میکنید. بنابراین بهتر است نسخه را دقیق قفل کنید تا در زمان بررسی کد توسط دیگران، کاملاً مشخص باشد از چه نسخه ای استفاده کردهاید. برای مثال به جای:
1 |
pragma solidity ^0.8.0; |
1 |
pragma solidity 0.8.26; |
این کار باعث شفافیت بیشتر برای ممیزان و توسعه دهندگان دیگر خواهد شد.
۱۲. رعایت نکردن راهنمای سبک کدنویسی (Style Guide)
استانداردهای کدنویسی در سالیدیتی در یک پست جداگانه منتشر شده است، اما نکات کلیدی آن عبارتاند از:
-
تابع
constructor
باید اولین تابع در قرارداد باشد. -
در صورت وجود، توابع
fallback()
وreceive()
باید بعد از constructor بیایند. -
سپس بهترتیب: توابع
external
، سپسpublic
، سپسinternal
و در نهایتprivate
نوشته شوند. -
در هر دستهبندی:
-
ابتدا توابع
payable
-
سپس توابع
non-payable
(غیر قابل دریافت اتر) -
و در آخر توابع
view
وpure
-
رعایت این ترتیب، خوانایی کد را افزایش داده و فهم ساختار قرارداد را برای دیگران آسانتر میکند.
۱۳. نداشتن لاگ (event) یا استفاده نادرست از event ها
در شبکه اتریوم، هیچ روش داخلی برای مشاهده مستقیم تمام تراکنش های ارسال شده به یک قرارداد هوشمند وجود ندارد، مگر اینکه این اطلاعات را از بلاکاکسپلوررها جستجو کنید. اما با استفاده از event
ها در قرارداد میتوانید این قابلیت را فراهم کنید.
نکات کلی در مورد استفاده از رویدادها:
-
هر تابعی که متغیر ذخیرهشده (storage variable) را تغییر میدهد، باید یک
event
منتشر کند. -
رویداد باید اطلاعات کافی داشته باشد تا فردی که لاگ ها را بررسی میکند بتواند تشخیص دهد مقدار متغیر در آن لحظه چه بوده است.
-
هر پارامتر از نوع
address
باید باindexed
مشخص شود تا بتوان به راحتی فعالیت یک کیف پول خاص را دنبال کرد. -
توابع
view
وpure
نباید رویداد منتشر کنند چون وضعیت را تغییر نمیدهند.
بهطور کلی، اگر در کدی متغیری را تغییر میدهید یا انتقال اتر انجام میدهید، باید یک رویداد منتشر کنید.
۱۴. ننوشتن تست های واحد (Unit Tests)
چطور مطمئن میشوید قرارداد شما در تمام شرایط ممکن به درستی کار میکند، اگر هیچگاه آن را تست نکرده باشید؟
از دید ما، جای تعجب دارد که برخی قراردادهای هوشمند بدون هیچ تست واحدی دیپلوی میشوند. این نباید اتفاق بیفتد.
نوشتن تست واحد به شما امکان میدهد:
-
منطق قرارداد را در شرایط مختلف بررسی کنید.
-
از بروز خطاهای ناخواسته جلوگیری کنید.
-
باگ ها را قبل از انتشار عمومی کشف کنید.
-
اطمینان بیشتری به کاربران و ممیزان بدهید.
توصیه: پیش از هرگونه دیپلوی روی شبکه اصلی، یک مجموعه تست کامل برای تمام توابع بنویسید (با ابزارهایی مثل Foundry یا Hardhat).
۱۵. گرد کردن (Rounding) در جهت اشتباه
در سالیدیتی، چون از اعشار (float) پشتیبانی نمیشود، عملیات تقسیم همیشه به سمت پایین گرد میشود. مثلاً اگر 100 را تقسیم بر 3 کنید پاسخ 33 خواهد بود، در صورتی که جواب درست 33/3333 است.
در اینجا، 0.3333 واحد ناپدید شده! این موضوع اهمیت زیادی دارد و میتواند باعث از دست رفتن سرمایه یا سوءاستفاده شود.
🔑 قانون طلایی در تقسیم: همیشه طوری گرد کنید که کاربر ضرر کند یا پروتکل سود کند.
برای مثال، اگر در حال محاسبه مبلغی هستید که یک کاربر باید برای چیزی بپردازد، تقسیم باعث میشود تخمین کمتر از مقدار واقعی باشد. در مثال بالا، کاربر 0.3333 تخفیف دریافت میکند.
وضعیت ۱: وقتی پروتکل به کاربر پرداخت میکند
اگر با 100 / 3
میزان پرداخت به کاربر را محاسبه کنیم، کاربر فقط 33 واحد دریافت میکند (کمتر از مقدار واقعی). این حالت خوب است، چون کاربر نمیتواند از پروتکل سوءاستفاده کند.
وضعیت ۲: محاسبه اینکه کاربر چقدر باید پرداخت کند
از طرف دیگر، اگر ما بخواهیم با محاسبهٔ ۳÷۱۰۰ تعیین کنیم که کاربر باید چه مقدار به قرارداد هوشمند پرداخت کند، با یک مشکل مواجه خواهیم شد، زیرا کاربر ۰.۳۳۳ کمتر از مقدار واقعی پرداخت میکند. اگر کاربر بتواند آن دارایی را با سود ۰.۳۳۳ بفروشد، میتواند این فرآیند را بارها تکرار کند تا در نهایت پروتکل را تخلیه کند!
کار درست در این شرایط این است که یک واحد به نتیجه تقسیم اضافه کنیم تا مقداری که در اعشار از دست رفته جبران شود. بهعبارت دیگر، باید محاسبه کنیم که کاربر چقدر باید پرداخت کند بهصورت ۱ + ۳÷۱۰۰، بنابراین کاربر باید ۳۴ واحد برای داراییای که ۳۳.۳۳۳ ارزش دارد پرداخت کند. این مقدار اندکِ از دسترفته برای کاربر، از سرقت قرارداد هوشمند جلوگیری خواهد کرد.
۱۶. استفاده نکردن از ابزار فرمت کننده کد در سالیدیتی
هیچ نیازی به اختراع دوبارهٔ چرخ برای فرمتبندی کدهای Solidity وجود ندارد. میتوانید از دستور forge fmt
در Foundry یا ابزار solfmt
استفاده کنید. این کار باعث میشود کد شما برای بازبینها خواناتر و قابلدرکتر شود.
کدی که بهصورت زیر نوشته شده، بدون دلیل خاصی خوانایی کمی دارد:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
contract GoodBank { mapping(address=>uint256) public balances; function withdraw () external { uint256 balance=balances[msg.sender]; balances[msg.sender] = 0; (bool ok,) =msg.sender.call{value: balance}(""); require(ok,"transfer failed"); } receive() external payable { balances[msg.sender]+=msg.value; } } |
این کد باید از طریق ابزار فرمتکننده اجرا شود تا فاصله گذاری ها یکنواخت و خواناتر شود:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
contract GoodBank { mapping(address => uint256) public balances; function withdraw() external { uint256 balance = balances[msg.sender]; balances[msg.sender] = 0; (bool ok,) = msg.sender.call{value: balance}(""); require(ok, "transfer failed"); } receive() external payable { balances[msg.sender] += msg.value; } } |
۱۷. استفاده از _msgSender() در قراردادهایی که از متاتراکنش ها پشتیبانی نمیکنند
توسعه دهندگان تازه کار Solidity اغلب با استفاده مکرر از تابع _msgSender() در قراردادهای OpenZeppelin سردرگم میشوند. برای مثال، در کتابخانه ERC-20 شرکت OpenZeppelin از _msgSender() استفاده شده است:
مگر اینکه در حال ساخت قراردادی باشید که از تراکنشهای بدون گاز یا متاتراکنش پشتیبانی میکند، باید از msg.sender
معمولی استفاده کنید، نه _msgSender().
_msgSender() تابعی است که توسط قرارداد Context.sol در OpenZeppelin تعریف شده است:
این تابع فقط در قراردادهایی کاربرد دارد که از متاتراکنش ها پشتیبانی میکنند.
متاتراکنش یا تراکنش بدون گاز حالتی است که یک رِلیِر (relayer) تراکنش را بهجای کاربر ارسال کرده و هزینه گاز آن را پرداخت میکند. از آنجایی که این تراکنش از طرف relayer ارسال شده، مقدار msg.sender
، کاربر واقعی نخواهد بود. قراردادهایی که از متاتراکنش استفاده میکنند، فرستنده واقعی را در جای دیگری از تراکنش رمزگذاری کرده و از طریق بازنویسی تابع _msgSender() آن را مشخص میکنند.
اگر چنین کاری انجام نمیدهید، هیچ دلیلی برای استفاده از _msgSender() وجود ندارد. در این صورت، تنها از msg.sender
استفاده کنید.
۱۸. کامیت کردن تصادفی کلیدهای API یا کلیدهای خصوصی در گیت هاب
اگرچه این اتفاق زیاد رخ نمیدهد، اما در موارد معدودی که رخ داده، پیامدهای بسیار فاجعهباری داشته است. اگر کلیدهای API یا کلیدهای خصوصی را در فایل .env
قرار میدهید، همیشه این فایل را به .gitignore
اضافه کنید تا از کامیت شدن آن جلوگیری شود.
۱۹. در نظر نگرفتن frontrunning، اسلیپیج (slippage)، یا تأخیر بین امضای تراکنش و اجرای آن
Frontrunning یک مشکل غیرمنتظره در قراردادهای Solidity است، زیرا در برنامه نویسی Web2 معمولاً نمونهای مشابه آن وجود ندارد.
مثال ۱: تغییر قیمت در حالی که تراکنش خرید در حال انتظار است
به قرارداد زیر توجه کنید که به فروشنده یک NFT اجازه میدهد تا در یک تراکنش، توکن خود را با USDC از خریدار معاوضه کند. از نظر تئوری این روش مزیت دارد چون هیچکدام از طرفین مجبور نیستند ابتدا توکن خود را ارسال کنند و به طرف مقابل اعتماد کنند که او نیز توکن خود را ارسال خواهد کرد.
اما این قرارداد دارای آسیب پذیری frontrunning است. فروشنده میتواند قیمت معاوضه را در حالی که تراکنش در حال انتظار است، تغییر دهد.
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 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.25; import "@openzeppelin/contracts@5.0.0/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts@5.0.0/token/ERC721/ERC721.sol"; contract BadSwapERC20ForNFT is Ownable(msg.sender) { using SafeERC20 for IERC20; uint256 price; IERC20 token; IERC721 nft; address public seller; constructor(IERC721 nft_, IERC20 token_) { nft = nft_; token = token_; seller = msg.sender; } function setPrice(uint256 price_) external { require(msg.sender == seller, "only seller"); price = price_; } // the buyer calls this function function atomicSwap(uint256 nftId) external // requires both the seller and buyer // to approve their tokens first token.safeTransferFrom(msg.sender, owner(), price); nft.transferFrom(owner(), msg.sender, nftId); } } |
مثال ۲: NFT که با هر خرید، قیمت آن افزایش مییابد
در این مثال، قیمت فروش NFT طوری برنامهریزی شده که با هر خرید، ۵٪ افزایش یابد. این قرارداد نیز مشکل مشابهی دارد. قیمتی که خریدار هنگام امضای تراکنش میبیند، ممکن است همان قیمتی نباشد که تراکنش با آن تأیید میشود. اگر ۱۰ خریدار به طور همزمان تراکنش خرید بفرستند، ۹ نفر از آنها قیمت بالاتری نسبت به انتظار خود پرداخت خواهند کرد.
وقتی یک قرارداد محاسبه میکند که چه مقدار توکن باید از حساب کاربر منتقل شود، باید کاربر یک سقف برای حداکثر مقدار قابل انتقال مشخص کند.
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 |
// SPDX-License-Identifier: MIT pragma solidity 0.8.25; import "@openzeppelin/contracts@5.0.0/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts@5.0.0/token/ERC721/ERC721.sol"; import "@openzeppelin/contracts@5.0.0/access/Ownable2Step.sol"; contract BadNFTSale is ERC721("BadNFT", "BNFT"), Ownable(msg.sender) { using SafeERC20 for IERC20; uint256 price = 100e6; // USDC / USDT have 6 decimals IERC20 immutable token; uint256 id; constructor(IERC20 token_) { token = token_; } function buyNFT() external { token.safeTransferFrom(msg.sender, owner(), price); price = price * 105 / 100; id++; _mint(msg.sender, id); } } |
در این حالت، احتمال دارد خریدار هنوز برای توکن جدید به قرارداد اجازه انتقال نداده باشد و در نتیجه transferFrom
با شکست مواجه شود. اما در یک قرارداد پیچیدهتر که ممکن است مجوزهای متعددی داشته باشد، این موضوع میتواند واقعاً مشکلساز شود.
۲۰. توابعی که امکان انجام چندباره یک تراکنش توسط کاربر را در نظر نمیگیرند
قراردادهای هوشمند باید احتمال این را در نظر بگیرند که یک کاربر ممکن است یک تراکنش خاص را بیش از یک بار انجام دهد. به مثال زیر توجه کنید:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
contract DepositAndWithdraw { mapping(address => uint256) public balances; function deposit() external payable { balances[msg.sender] = msg.value; } function withdraw( uint256 amount ) external { require( amount <= balances[msg.sender], "insufficient balance" ); balances[msg.sender] -= amount; (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "transfer failed"); } } |
قراردادهای هوشمند باید احتمال این را در نظر بگیرند که یک کاربر ممکن است یک تراکنش خاص را بیش از یک بار انجام دهد. به مثال زیر توجه کنید:
اگر تابع deposit
دوبار فراخوانی شود، موجودی (balance) قبلی با تراکنش دوم جایگزین میشود و مبلغ تراکنش اول از بین میرود.
برای مثال، اگر کاربر ابتدا تابع deposit()
را با مقدار ۱ اتر فراخوانی کند و سپس دوباره همان تابع را با مقدار ۲ اتر فراخوانی کند، موجودی نهایی آدرس فقط ۲ اتر خواهد بود، در حالی که در مجموع ۳ اتر واریز شده است.
راهحل این مشکل، افزایش موجودی بهجای جایگزینی آن است، یعنی بهصورت زیر:
1 |
balances[msg.sender] += msg.value; |
راستی! برای دریافت مطالب جدید در کانال تلگرام یا پیج اینستاگرام سورس باران عضو شوید.
- انتشار: ۲۲ اردیبهشت ۱۴۰۴
دسته بندی موضوعات
- آموزش ارز دیجیتال
- آموزش برنامه نویسی
- آموزش متنی برنامه نویسی
- اطلاعیه و سایر مطالب
- پروژه برنامه نویسی
- دوره های تخصصی برنامه نویسی
- رپورتاژ
- فیلم های آموزشی
- ++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
- اچ تی ام ال
- بانک اطلاعاتی
- برنامه نویسی سوکت
- برنامه نویسی موبایل
- پاسکال
- پایان نامه
- پایتون
- جاوا
- جاوا اسکریپت
- جی کوئری
- داده کاوی
- دلفی
- رباتیک
- سئو
- سایر کتاب ها
- سخت افزار
- سی اس اس
- سی پلاس پلاس
- سی شارپ
- طراحی الگوریتم
- فتوشاپ
- مقاله
- مهندسی نرم افزار
- هک و امنیت
- هوش مصنوعی
- ویژوال بیسیک
- نرم افزار و ابزار برنامه نویسی
- وردپرس