در این مقاله، عملکرد دستور delegatecall در سالیدیتی را به طور کامل و دقیق بررسی میکنیم. ماشین مجازی اتریوم (Ethereum Virtual Machine یا به اختصار EVM) چهار دستور اصلی برای برقراری ارتباط بین قراردادها ارائه میدهد:
-
CALL
(کد عملیاتی F1) -
CALLCODE
(کد عملیاتی F2) -
STATICCALL
(کد عملیاتی FA) -
و
DELEGATECALL
(کد عملیاتی F4)
نکته مهم این است که از نسخه 5 زبان سالیدیتی به بعد، استفاده از CALLCODE
منسوخ شده و DELEGATECALL
جایگزین آن شده است. این دستورات در زبان سالیدیتی بهصورت مستقیم پیاده سازی شدهاند و میتوان آنها را به عنوان متدهایی از متغیرهای نوع address
فراخوانی کرد.
برای درک بهتر نحوه عملکرد delegatecall در سالیدیتی، ابتدا لازم است با منطق اجرایی دستور CALL
آشنا شویم.
دستور Call در سالیدیتی
برای درک بهتر عملکرد دستور call
، بیایید ابتدا قرارداد سادهای را بررسی کنیم:
1 2 3 4 5 6 7 |
contract Called { uint public number; function increment() public { number++; } } |
سادهترین راه برای اجرای تابع increment()
از یک قرارداد دیگر، استفاده از واسط (interface) قرارداد Called
است. در این روش، اگر متغیر called
نمایانگر آدرس قرارداد Called
باشد، میتوان تابع را با دستور ساده called.increment()
فراخوانی کرد.
اما یک روش دیگر برای انجام همین کار، استفاده از فراخوانی سطح پایین (low-level call) است. به عنوان مثال در قرارداد زیر:
1 2 3 4 5 6 7 8 |
contract Caller { address constant public calledAddress = 0xd9145CCE52D386f254917e481eB44e9943F39138; // Called's address function callIncrement() public { calledAddress.call(abi.encodeWithSignature("increment()")); } } |
در زبان سالیدیتی، تمام متغیرهای از نوع address
، از جمله calledAddress
، یک متد به نام call
دارند. این متد انتظار دارد ورودی مورد نظر برای اجرا در تراکنش را بهصورت calldata
کدگذاری شده بر اساس ABI دریافت کند. در مثال بالا، داده ارسالی باید با امضای تابع increment()
مطابقت داشته باشد. شناسه این تابع (function selector
) برابر است با 0xd09de08a
.
برای تولید این شناسه از تعریف تابع، از دستور abi.encodeWithSignature("increment()")
استفاده میکنیم.
اگر تابع callIncrement
را در قرارداد Caller
اجرا کنید، مقدار متغیر number
در قرارداد Called
یک واحد افزایش پیدا میکند. با این حال، توجه داشته باشید که متد call
بررسی نمیکند که آدرس مقصد واقعاً متعلق به یک قرارداد باشد یا اینکه تابع مشخصشده را داشته باشد یا نه.
بازگشت مقادیر از دستور call
متد call
در زمان اجرا، یک جفت مقدار (tuple) را برمیگرداند. مقدار اول یک متغیر بولی (boolean
) است که مشخص میکند تراکنش با موفقیت انجام شده یا نه. مقدار دوم، از نوع bytes
است و در صورتی که تابع مورد نظر مقداری بازگرداند، این داده خروجی بهصورت کدگذاری شده با ABI در آن ذخیره میشود.
برای دریافت مقدار بازگشتی از call
، میتوانیم تابع callIncrement
را به شکل زیر بازنویسی کنیم:
1 2 3 4 5 |
function callIncrement() public { (bool success, bytes memory data) = called.call( abi.encodeWithSignature("increment()") ); } |
call
هیچگاه باعث بازگرداندن خطا (revert) نمیشود. اگر اجرای تراکنش موفق نباشد، مقدار success
برابر false
خواهد بود. بنابراین، برنامه نویس باید این وضعیت را بررسی کرده و در صورت لزوم، اقدامات مناسب را انجام دهد.
مدیریت شکست در فراخوانی با call
حال بیایید قرارداد قبلی را طوری تغییر دهیم که شامل یک فراخوانی دیگر به یک تابع غیرواقعی (که در مقصد وجود ندارد) نیز باشد. به عنوان نمونه:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
contract Caller { address public constant calledAddress = 0xd9145CCE52D386f254917e481eB44e9943F39138; function callIncrement() public { (bool success, bytes memory data) = called.call( abi.encodeWithSignature("increment()") ); if (!success) { revert("Something went wrong"); } } // calls a non-existent function function callWrong() public { (bool success, bytes memory data) = called.call( abi.encodeWithSignature("thisFunctionDoesNotExist()") ); if (!success) { revert("Something went wrong"); } } } |
من بهصورت عمدی دو فراخوانی ایجاد کردم: یکی با امضای صحیح تابع increment
و دیگری با امضای نادرست. در فراخوانی اول، چون تابع واقعاً در قرارداد مقصد وجود دارد، مقدار بازگشتی true
خواهد بود که نشاندهنده موفقیت است. اما در فراخوانی دوم، به دلیل اشتباه بودن امضای تابع و نبودن چنین تابعی در قرارداد مقصد، مقدار false
بازگردانده میشود.
در اینجا، مقدار بولی بازگشتی بهصورت دقیق بررسی شده است و در صورتی که فراخوانی موفق نباشد (success == false
)، تراکنش با استفاده از دستور require
متوقف خواهد شد (revert).
نکته کلیدی این است که همیشه باید بررسی کنیم آیا فراخوانی با call
موفق بوده یا نه، چرا که در غیر این صورت ممکن است بدون هشدار خطا، تغییرات ناخواسته در وضعیت قرارداد ایجاد شود. در ادامه مقاله، دوباره به اهمیت این موضوع خواهیم پرداخت.
آنچه در پشت صحنه در EVM اتفاق میافتد
هدف اصلی تابع increment
این است که متغیر حالت (state variable
) به نام number
را افزایش دهد. اما باید توجه داشت که ماشین مجازی اتریوم (EVM) هیچ اطلاعی از نام یا نوع متغیرهای حالت ندارد. EVM تنها با شیار های ذخیره سازی (storage slots) کار میکند.
در واقع، اجرای تابع increment
باعث میشود مقدار ذخیره شده در اولین شیار حافظه (که به آن slot 0 گفته میشود) افزایش پیدا کند. این عملیات دقیقاً در فضای ذخیره سازی قرارداد Called
انجام میشود، نه در قرارداد فراخواننده.
اکنون که با نحوه عملکرد متد call
آشنا شدیم و دانستیم که تغییرات به حافظه قرارداد مقصد مربوط میشوند، میتوانیم به سراغ دستور delegatecall
برویم و ببینیم چگونه میتوان از آن برای دستیابی به رفتاری متفاوت استفاده کرد.
DELEGATECALL در سالیدیتی: اجرای کد قرارداد دیگر در محیط خود
وقتی یک قرارداد از دستور delegatecall
برای فراخوانی قرارداد دیگری استفاده میکند، منطق (logic) قرارداد مقصد را در محیط خودش اجرا میکند؛ یعنی هر تغییری که آن کد اعمال کند، در حافظه و وضعیت قرارداد فراخواننده اعمال خواهد شد، نه در قرارداد مقصد.
میتوان این فرآیند را به این صورت تصور کرد: قرارداد فراخواننده، کد قرارداد هدف را “کپی” میکند و خودش آن را اجرا میکند، گویی این کد بخشی از خودش است.
در این ساختار، معمولاً به قرارداد هدفی که حاوی کد واقعی منطق برنامه است، “قرارداد پیاده سازی” (implementation contract) گفته میشود. این الگو نقش کلیدی در معماری های قابل ارتقا (upgradable contracts) دارد، چرا که اجازه میدهد وضعیت در یک قرارداد ثابت باقی بماند، در حالی که منطق از طریق قراردادهای خارجی تغییر پیدا میکند.
دستور delegatecall در سالیدیتی نیز مانند call
، نیاز به داده ورودی (input data) دارد که مشخص میکند کدام تابع از قرارداد هدف باید اجرا شود. این داده بهعنوان پارامتر به delegatecall
ارسال میشود.
در ادامه کد قرارداد Called
را میبینید که هنگام استفاده از delegatecall
، منطق آن در محیط قرارداد Caller
اجرا میشود:
1 2 3 4 5 6 7 |
contract Called { uint public number; function increment() public { number++; } } |
Caller
را مشاهده میکنید
1 2 3 4 5 6 7 8 9 |
contract Caller { uint public number; function callIncrement(address _calledAddress) public { _calledAddress.delegatecall( abi.encodeWithSignature("increment()") ); } } |
در فراخوانی از نوع delegatecall
، قرارداد Caller تابع increment
را اجرا می کند. با این حال، این اجرا یک تفاوت اساسی با حالتهای معمول دارد: تغییرات تنها در داده های قرارداد Caller اعمال می شوند و فضای ذخیره سازی قرارداد Called بدون تغییر باقی میماند. در واقع، Caller صرفاً کد قرارداد Called را قرض میگیرد و آن را در بستر اجرایی خودش اجرا میکند.
نمودار زیر بهصورت شفاف نشان می دهد که delegatecall
فقط داده های قرارداد Caller را تغییر می دهد و به حافظه قرارداد Called دست نمی زند.
تصویر زیر تفاوت اجرای تابع increment
با استفاده از call
و delegatecall
را نشان می دهد. این مقایسه به خوبی مشخص می کند که در call
، تغییرات در حافظه قرارداد فراخوانیشده (Called) اعمال می شود، در حالی که در delegatecall
، همان تابع در بستر قرارداد فراخوان (Caller) اجرا شده و تنها داده های آن را تحت تاثیر قرار می دهد.
برخورد شیارهای ذخیره سازی (Storage Slot Collision)
وقتی یک قرارداد از delegatecall
استفاده می کند، باید با دقت بسیار بالا پیشبینی کند که کدام شیارهای ذخیره سازی (storage slots) ممکن است تغییر کنند. دلیل این احتیاط ساده است: در delegatecall
، کد قرارداد دیگر در بستر قرارداد فعلی اجرا می شود و هر تغییری مستقیماً بر داده های قرارداد فراخوان (Caller) اثر می گذارد.
در مثالی که قبلاً دیدیم، همه چیز بهدرستی کار کرد، چون قرارداد Caller هیچ متغیر وضعیتیای در شیار 0 نداشت. اما یکی از خطاهای رایج هنگام استفاده از delegatecall در سالیدیتی، بیتوجهی به همین نکته است.
در ادامه، مثالی را بررسی می کنیم که این مشکل را بهخوبی نشان می دهد.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
contract Called { uint public number; function increment() public { number++; } } contract Caller { // there is a new storage variable here address public calledAddress = 0xd9145CCE52D386f254917e481eB44e9943F39138; uint public myNumber; function callIncrement() public { called.delegatecall( abi.encodeWithSignature("increment()") ); } } |
در نسخه به روزرسانیشده قرارداد بالا، توجه داشته باشید که مقدار ذخیره شده در شیار 0، آدرس قرارداد Called است، و متغیر myNumber
اکنون در شیار 1 ذخیره می شود.
اگر این قراردادها را مستقر کنید و تابع callIncrement
را اجرا نمایید، شیار 0 از فضای ذخیره سازی قرارداد Caller افزایش خواهد یافت. اما نکته مهم اینجاست که در این شیار، متغیر calledAddress
قرار دارد، نه myNumber
. بنابراین بهجای آنکه مقدار myNumber
افزایش یابد، مقدار آدرس ذخیره شده تغییر می کند که این رفتار، نتیجه برخورد شیارهای ذخیره سازی (storage slot collision) است.
بیایید با استفاده از یک تصویر یا نمودار، توضیح دهیم چه اتفاقی افتاده تا موضوع بهتر و واضحتر درک شود.
بنابراین هنگام استفاده از delegatecall
باید نهایت دقت را به خرج داد، چون این فراخوانی ممکن است ناخواسته باعث ایجاد اختلال در عملکرد قرارداد شود. در مثال بالا، به احتمال زیاد هدف برنامه نویس این نبوده که با اجرای تابع callIncrement
مقدار متغیر calledAddress
را تغییر دهد.
اکنون می خواهیم یک تغییر کوچک در قرارداد Caller اعمال کنیم و متغیر myNumber
را به شیار 0 منتقل کنیم.
1 2 3 4 5 6 7 8 9 10 11 12 |
contract Caller { uint public myNumber; address public calledAddress = 0xd9145CCE52D386f254917e481eB44e9943F39138; function callIncrement() public { called.delegatecall( abi.encodeWithSignature("increment()") ); } } |
اکنون، زمانی که تابع callIncrement
اجرا می شود، مقدار متغیر myNumber
افزایش خواهد یافت، چون این دقیقاً هدف تابع increment
است. من عمداً نام متغیر در قرارداد Caller را متفاوت از نام متغیر در قرارداد Called انتخاب کردم تا نشان دهم که نام متغیرها اهمیتی ندارد؛ آنچه اهمیت دارد محل قرارگیری آنها در شیارهای ذخیره سازی است.
برای اینکه delegatecall
بهدرستی عمل کند، باید چینش (ترتیب و موقعیت) متغیرهای وضعیتی در هر دو قرارداد کاملاً با هم هماهنگ باشد. این هماهنگی برای عملکرد صحیح و بدون خطا ضروری است.
جدا کردن منطق اجرا از داده ها
یکی از کاربردهای بسیار مهم delegatecall در سالیدیتی این است که بتوان منطق اجرایی قرارداد را از محل ذخیره داده ها جدا کرد. در این مثال، قرارداد Caller داده ها را نگهداری می کند و قرارداد Called منطق اجرای توابع را در خود جای داده است. اگر در آینده تصمیم به تغییر منطق اجرا گرفته شود، توسعه دهنده می تواند بهسادگی یک قرارداد جدید جایگزین Called کند و آدرس آن را در Caller بهروزرسانی نماید؛ در این فرآیند، داده های ذخیرهشده در Caller دستنخورده باقی میمانند و نیازی به تغییر آنها نیست. این الگو یکی از پایههای رایج در طراحی قراردادهای قابل ارتقا در سالیدیتی و همچنین یکی از مفاهیم کلیدی در آموزش برنامه نویسی پیشرفته بلاکچین محسوب میشود.
با استفاده از این روش، قرارداد Caller دیگر به توابع داخلی خودش محدود نیست و می تواند توابع مورد نیازش را از سایر قراردادها از طریق delegatecall
فراخوانی کند.
برای مثال، اگر بخواهید بهجای افزایش مقدار myNumber
، آن را یک واحد کاهش دهید، کافی است یک قرارداد پیاده سازی جدید طراحی کنید، همانطور که در ادامه نشان داده شده است:
1 2 3 4 5 6 7 8 |
contract NewCalled { uint public number; function increment() public { number = number - 1; } } |
متأسفانه نمی توان نام تابعی که قرار است فراخوانی شود را تغییر داد، زیرا این کار باعث تغییر در امضای تابع (function signature) خواهد شد.
پس از ساخت قرارداد جدیدی با نام NewCalled
، می توان آن را مستقر کرد و مقدار متغیر calledAddress
را در قرارداد Caller به آدرس جدید تغییر داد. البته برای انجام این کار، باید در قرارداد Caller مکانیزمی برای بهروزرسانی آدرس مقصد در نظر گرفته شده باشد، که ما برای ساده نگه داشتن کد، آن را در مثال پیاده سازی نکردیم.
با این روش، منطق اجرایی مورد استفاده در قرارداد Caller با موفقیت تغییر یافته است. این جداسازی میان داده ها و منطق اجرا، امکان ایجاد قراردادهای هوشمند قابل ارتقا را در سالیدیتی فراهم می کند.
در تصویر بالا، قرارداد سمت چپ هم داده ها را در خود نگه می دارد و هم منطق اجرایی را مدیریت می کند. اما در ساختار سمت راست، قرارداد بالایی تنها مسئول نگهداری داده ها است، در حالی که منطق مربوط به بهروزرسانی این داده ها در یک قرارداد جداگانه به نام قرارداد منطقی (Logic Contract) قرار دارد.
برای اعمال تغییرات بر داده ها، قرارداد اصلی از طریق delegatecall
به قرارداد منطق متصل می شود و از توابع آن برای انجام عملیات مورد نیاز استفاده می کند.
مدیریت مقدار بازگشتی delegatecall در سالیدیتی
درست مانند call
، دستور delegatecall
نیز یک tuple بازمیگرداند که شامل دو مقدار است: یک مقدار بولی که موفقیت یا عدم موفقیت اجرای تابع را مشخص می کند، و خروجی تابعی که از طریق delegatecall
اجرا شده است، بهصورت داده باینری (bytes
).
برای درک بهتر نحوه مدیریت این مقادیر بازگشتی، در ادامه یک مثال جدید می نویسیم.
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 |
contract Called { function calculateDiscountPrice( uint256 amount, uint256 discountRate ) public pure returns (uint) { return amount - (amount * _discountRate)/100; } } contract Caller { uint public price = 200; uint public discountRate = 10; address public called; function setCalled(address _called) public { called = _called; } function setDiscountPrice() public { (bool success, bytes memory data) = called.delegatecall( abi.encodeWithSignature( "calculateDiscountPrice(uint256,uint256)", price, discountRate) ); if (success) { uint newPrice = abi.decode(data, (uint256)); price = newPrice; } } } |
قرارداد Called شامل منطق محاسبه قیمت با تخفیف است. برای استفاده از این منطق، تابع calculateDiscountPrice
را با استفاده از delegatecall
اجرا می کنیم. این تابع یک مقدار بازمیگرداند که باید با استفاده از abi.decode
آن را به شکل قابل استفاده تبدیل کنیم.
اما قبل از اینکه براساس این مقدار تصمیمگیری کنیم، لازم است بررسی کنیم که آیا اجرای تابع با موفقیت انجام شده یا خیر. در غیر این صورت، ممکن است تلاش کنیم نتیجهای را تجزیه کنیم که اصلاً وجود ندارد، یا بهاشتباه پیام خطایی (revert reason) را بهعنوان خروجی پردازش کنیم.
زمانی که call
یا delegatecall
مقدار false
برمیگردانند
یکی از نکات بسیار مهم درک این موضوع است که چه زمانی مقدار موفقیت (success
) در call
یا delegatecall
برابر با true
یا false
خواهد بود. این موضوع به این بستگی دارد که آیا تابع در حال اجرا با خطا متوقف می شود (revert) یا نه.
بهطور کلی، سه حالت باعث می شوند که اجرای یک تابع متوقف شده و بازگردانده شود (revert):
-
اگر در طول اجرا به دستور
REVERT
برسد، -
اگر گاز مصرفی آن تمام شود،
-
اگر عملیات ممنوعی انجام دهد، مثل تقسیم بر صفر.
اگر تابعی که از طریق delegatecall
(یا call
) اجرا می شود با یکی از این سه حالت روبرو شود، اجرای آن متوقف می شود و مقدار بازگشتی delegatecall
برابر false
خواهد بود.
یکی از پرسشهای رایجی که بسیاری از توسعه دهندگان را گیج می کند این است که چرا فراخوانی delegatecall
به یک آدرس ناموجود باعث revert
نمی شود و همچنان اجرای آن موفق گزارش می شود. براساس نکاتی که گفته شد، یک آدرس خالی (یا نادرست) هیچکدام از این سه شرط را برای revert
شدن فراهم نمی کند؛ بنابراین چنین فراخوانی متوقف نمی شود و مقدار موفقیت آن همچنان true
خواهد بود.
نمونهای دیگر از خطاهای مربوط به متغیرهای ذخیره سازی
بیایید با ایجاد یک تغییر جزئی در کد بالا، یک مثال دیگر از باگ های مرتبط با نحوه چینش متغیرهای ذخیره سازی ارائه کنیم.
قرارداد Caller همچنان از طریق delegatecall
به یک قرارداد پیاده سازی (implementation contract) متصل می شود. اما این بار، قرارداد Called بهجای صرفاً تغییر داده ها، مقدار یک متغیر وضعیتی را نیز از فضای ذخیره سازی می خواند. این تغییر ممکن است در ظاهر ساده به نظر برسد، اما در عمل می تواند به یک فاجعه منجر شود.
می توانید حدس بزنید چرا؟ دلیل آن این است که قرارداد Called در حال خواندن متغیری است که بر اساس ساختار داخلی خودش در یک شیار خاص ذخیره شده، در حالی که هنگام اجرای delegatecall
، عملیات خواندن از فضای ذخیره سازی قرارداد Caller انجام می شود. اگر ترتیب و موقعیت متغیرها در دو قرارداد همخوانی نداشته باشد، قرارداد Called ممکن است دادهای اشتباه را بخواند و تصمیمگیری نادرستی انجام دهد، بدون آنکه هیچ خطایی اعلام شود. این نوع خطاها معمولاً بسیار پنهان هستند و شناسایی آنها می تواند دشوار باشد.
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 |
contract Called { uint public discountRate = 20; function calculateDiscountPrice(uint256 amount) public pure returns (uint) { return amount - (amount * discountRate)/100; } } contract Caller { uint public price = 200; address public called; function setCalled(address _called) public { called = _called; } function setDiscount() public { (bool success, bytes memory data) =called.delegatecall( abi.encodeWithSignature( "calculateDiscountPrice(uint256)", price ) ); if (success) { uint newPrice = abi.decode(data, (uint256)); price = newPrice; } } } |
مشکل از آنجا شروع می شود که تابع calculateDiscountPrice
در حال خواندن یک متغیر وضعیتی است؛ بهطور خاص، متغیری که در شیار 0 قرار دارد. فراموش نکنید که در delegatecall
، توابع در بستر فضای ذخیره سازی قرارداد فراخوان (Caller) اجرا می شوند. به بیان دیگر، ممکن است فکر کنید در حال استفاده از متغیر discountRate
در قرارداد Called هستید تا قیمت جدید را محاسبه کنید، اما در واقع دارید از متغیر price
در قرارداد Caller استفاده می کنید!
در این مثال، هر دو متغیر discountRate
در قرارداد Called و price
در قرارداد Caller در شیار 0 حافظه ذخیره شدهاند. بنابراین زمانی که calculateDiscountPrice
مقدار discountRate
را میخواند، در حقیقت مقدار price
در Caller را دریافت می کند.
نتیجه چه خواهد بود؟ تابع تصور می کند نرخ تخفیف برابر با عددی بسیار بزرگ است (مثلاً ۲۰۰٪) و بر اساس آن قیمت نهایی را محاسبه می کند. این موضوع باعث می شود که قیمت نهایی منفی شود، که برای نوع داده uint
غیرمجاز است و در نهایت منجر به revert
شدن اجرای تابع خواهد شد. این مثال بهخوبی نشان می دهد که ناهماهنگی در چینش متغیرهای ذخیره سازی می تواند به باگهای جدی و پنهان منجر شود.
متغیرهای تغییرناپذیر و ثابت در delegatecall: داستان یک باگ پنهان
یکی دیگر از چالشهای مهم هنگام استفاده از delegatecall در سالیدیتی، زمانی رخ می دهد که با متغیرهای immutable
یا constant
سروکار داریم. این موضوع یکی از مواردی است که حتی بسیاری از برنامه نویسان باتجربه در سالیدیتی به اشتباه متوجه می شوند.
بیایید با یک مثال این موضوع را بررسی کنیم؛ مثالی که می تواند منجر به رفتارهای غیرمنتظره و باگهای پنهان در قرارداد هوشمند شما شود.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
contract Caller { uint256 private immutable a = 3; function getValueDelegate(address called) public pure returns (uint256) { (bool success, bytes memory data) = called.delegatecall( abi.encodeWithSignature("getValue()")); return abi.decode(data, (uint256)); // is this 3 or 2? } } contract Called { uint256 private immutable a = 2; function getValue() public pure returns (uint256) { return a; } } |
پرسش اینجاست: وقتی تابع getValueDelegate
اجرا می شود، خروجی آن عدد ۲ خواهد بود یا ۳؟ بیایید این موضوع را با منطق بررسی کنیم.
- تابع
getValueDelegate
در واقع تابعgetValue
را فراخوانی می کند؛ تابعی که قرار است مقدار متغیری را که در شیار صفر قرار دارد بازگرداند. - از آنجایی که این فراخوانی از طریق
delegatecall
انجام می شود، باید فضای ذخیره سازی قرارداد فراخوان (Caller) را بررسی کنیم، نه قرارداد فراخوانیشده (Called). - در قرارداد Caller، متغیر
a
دارای مقدار ۳ است. پس بهنظر می رسد که خروجی باید ۳ باشد. کاملاً منطقی به نظر می رسد، درست است؟
اما پاسخ درست، بهطرز عجیبی، عدد ۲ است. چرا؟!
دلیل این موضوع به نحوه رفتار متغیرهای immutable
و constant
در سالیدیتی برمیگردد. برخلاف متغیرهای معمولی، این نوع متغیرها اصلاً در شیارهای ذخیره سازی قرار نمی گیرند. در واقع، مقدار آنها هنگام کامپایل، بهصورت مستقیم داخل بایت کد قرارداد نوشته می شود و اصلاً از حافظه قرارداد خوانده نمی شوند.
در این مثال، مقدار متغیر a
در قرارداد Called از نوع immutable
بوده و مقدار ۲ به آن اختصاص داده شده است. بنابراین زمانی که تابع getValue
از طریق delegatecall
اجرا می شود، این مقدار ثابت که در بایتکد قرارداد قرار دارد، مستقیماً بازگردانده می شود—نه مقدار واقعی ذخیرهشده در فضای قرارداد Caller. به همین دلیل خروجی ۲ خواهد بود، نه ۳.
این رفتار یکی از نقاط گمراه کننده در استفاده از delegatecall
است و می تواند منجر به خطاهایی شود که بهراحتی قابل تشخیص نیستند.
استفاده از msg.sender، msg.value و address(this) در delegatecall
وقتی در داخل قرارداد Called از مقادیر msg.sender
، msg.value
یا address(this)
استفاده می کنیم، تمام این مقادیر به اطلاعات قرارداد Caller مربوط می شوند، نه Called. به بیان دیگر، مقادیری که در Called دیده می شوند، در واقع همان مقادیر msg.sender
، msg.value
و address(this)
مربوط به قرارداد Caller هستند.
دلیل این موضوع کاملاً به نحوه عملکرد delegatecall
برمی گردد: در این نوع فراخوانی، تمام عملیات در بستر قرارداد فراخوان (Caller) انجام می شود. قرارداد پیاده سازی (مثل Called) صرفاً کد اجرایی (bytecode) را فراهم می کند و خودش هیچ زمینه اجرایی مستقل ندارد.
بنابراین، در delegatecall
هر چیزی—از جمله پیامدهنده تراکنش (msg.sender
) و مقدار ارسالی (msg.value
)—همان چیزی خواهد بود که در زمینه قرارداد Caller تعریف شده است. این نکته یکی از اصول پایهای در درک امنیت و رفتار واقعی delegatecall
محسوب می شود.
بیایید این مفهوم را در یک مثال به کار ببریم. کد زیر را در نظر بگیرید:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
contract Called { function getInfo() public payable returns (address, uint, address) { return (msg.sender, msg.value, address(this)); } } contract Caller { function getDelegatedInfo( address _called ) public payable returns (address, uint, address) { (bool success, bytes memory data) = _called.delegatecall( abi.encodeWithSignature("getInfo()") ); return abi.decode(data, (address, uint, address)); } } |
در قرارداد Called، از msg.sender
، msg.value
و address(this)
استفاده کردهایم و این مقادیر از طریق تابع getInfo
بازگردانده میشوند. اجرای تابع getDelegateInfo
در محیط Remix در تصویر زیر نمایش داده شده است. در این تصویر میتوان مقادیر بازگشتی را بهصورت دقیق مشاهده کرد.
msg.sender
مربوط به حسابی است که تراکنش را اجرا کرده، یعنی همان حساب پیشفرض اول در Remix با آدرس0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
.msg.value
نشان دهنده مقدار ۱ اتر است که در تراکنش اصلی ارسال شده است.address(this)
برابر با آدرس قرارداد Caller است، همانطور که در سمت چپ تصویر مشاهده می شود، و نه آدرس قرارداد Called.
در محیط Remix، لاگهایی که ثبت میشوند شامل
msg.sender
در شاخص ۰، msg.value
در شاخص ۱ و address(this)
در شاخص ۲ هستند. این مقادیر بهصورت ساختیافته در کنسول نمایش داده میشوند و امکان بررسی دقیق رفتار قرارداد را فراهم میکنند.
msg.data و داده ورودی در delegatecall
ویژگی msg.data
داده های خام (calldata) مربوط به زمینه ای را که در حال اجرا است بازمیگرداند. زمانی که یک تابع مستقیماً از طریق تراکنش و توسط یک حساب معمولی (EOA) اجرا می شود، مقدار msg.data
برابر است با داده های ورودی همان تراکنش.
اما وقتی از call
یا delegatecall
استفاده می کنیم، باید داده های ورودی (input data) را بهعنوان آرگومان مشخص کنیم تا در قرارداد پیاده سازی اجرا شوند. به همین دلیل، calldata
اصلی با calldata
موجود در زمینه فرعیای که توسط delegatecall
ایجاد شده متفاوت است، و در نتیجه مقدار msg.data
نیز متفاوت خواهد بود.
کد زیر برای نمایش این موضوع مورد استفاده قرار می گیرد.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
contract Called { function returnMsgData() public pure returns (bytes memory) { return msg.data; } } contract Caller { function delegateMsgData( address _called ) public returns (bytes memory data) { (, data) = _called.delegatecall( abi.encodeWithSignature("returnMsgData()")); } } |
تراکنش اصلی تابع delegateMsgData
را اجرا می کند؛ تابعی که یک پارامتر از نوع address
دریافت می کند. در نتیجه، داده های ورودی (input data) شامل امضای تابع بههمراه یک آدرس است که بهصورت ABI کدگذاری شده است.
تابع delegateMsgData
نیز به نوبه خود تابع returnMsgData
را از طریق delegatecall
فراخوانی می کند. برای انجام این کار، داده های ارسالی به زمان اجرا (runtime) باید شامل امضای تابع returnMsgData
باشند. بنابراین، مقدار msg.data
درون تابع returnMsgData
برابر با امضای خودش خواهد بود؛ یعنی 0x0b1c837f
.
در تصویر زیر مشاهده می کنیم که خروجی تابع returnMsgData
همان امضای خودش است که بهصورت ABI کدگذاری شده بازگردانده شده است.
خروجی دیکدشده، امضای تابع returnMsgData
است که بهصورت بایت و مطابق با استاندارد ABI کدگذاری شده است.
CODESIZE بهعنوان یک مثال نقض در delegatecall
پیشتر گفتیم که می توان delegatecall در سالیدیتی را بهگونهای تصور کرد که قرارداد فراخوان (Caller) کد اجرایی را از قرارداد پیاده سازی قرض می گیرد و آن را در بستر خودش اجرا می کند. اما یک استثنای مهم در این تصویر ذهنی وجود دارد: دستور CODESIZE
.
فرض کنید یک قرارداد هوشمند شامل دستور CODESIZE
در بایت کد خود باشد. این دستور اندازه کد همان قراردادی را برمیگرداند که در آن قرار دارد. یعنی اگر در حین delegatecall
این دستور اجرا شود، اندازه کد قرارداد فراخوان (Caller) را باز نمیگرداند، بلکه اندازه کد همان قراردادی را بازمیگرداند که از آن delegatecall
شده است.
برای نشان دادن این ویژگی، کد زیر را در نظر گرفتهایم. در سالیدیتی، دستور CODESIZE
را می توان در سطح اسمبلی از طریق تابع codesize()
اجرا کرد. ما دو قرارداد پیادهسازی به نامهای CalledA
و CalledB
داریم که تنها تفاوت آنها در وجود یک متغیر محلی است (که در CalledB
تعریف شده اما در CalledA
وجود ندارد). همین تفاوت باعث می شود اندازه بایتکد این دو قرارداد با هم متفاوت باشد.
در قرارداد Caller، تابع getSizes
از طریق delegatecall
این دو قرارداد را فراخوانی می کند تا اندازه کد آنها را بازیابی کند. این مثال بهخوبی نشان می دهد که دستور CODESIZE
برخلاف دیگر دستورات در delegatecall
، بهجای زمینه Caller، اطلاعات مربوط به قرارداد پیاده سازی را برمیگرداند.
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 |
// codesize 1103 contract Caller { function getSizes( address _calledA, address _calledB ) public returns (uint sizeA, uint sizeB) { (, bytes memory dataA) = _calledA.delegatecall( abi.encodeWithSignature("getCodeSize()") ); (, bytes memory dataB) = _calledB.delegatecall( abi.encodeWithSignature("getCodeSize()") ); sizeA = abi.decode(dataA, (uint256)); sizeB = abi.decode(dataB, (uint256)); } } // codesize 174 contract CalledA { function getCodeSize() public pure returns (uint size) { assembly { size := codesize() } } } // codesize 180 contract CalledB { function getCodeSize() public pure returns (uint size) { uint unused = 100; assembly { size := codesize() } } } // You can use this contract to check the size of contracts contract MeasureContractSize { function measureConctract(address c) external view returns (uint256 size){ size = c.code.length; } } |
اگر تابع codesize
اندازه قرارداد Caller را بازمیگرداند، در آن صورت مقادیری که از تابع getSizes()
دریافت می شد، چه هنگام فراخوانی ContractA و چه هنگام فراخوانی ContractB، باید یکسان می بود—یعنی همان اندازه قرارداد Caller که برابر با ۱۱۰۳ است.
اما همانطور که در تصویر زیر مشاهده می کنید، مقادیر بازگشتی متفاوت هستند. این تفاوت بهوضوح نشان می دهد که دستور codesize
در واقع اندازه کد قراردادهای CalledA و CalledB را برمیگرداند، نه Caller. این رفتار استثنا بودن CODESIZE
را در delegatecall بهروشنی اثبات می کند.
delegatecall تو در تو
ممکن است این سؤال پیش بیاید که اگر یک قرارداد از طریق delegatecall
به قراردادی دوم متصل شود و آن قرارداد دوم نیز مجدداً از delegatecall
برای ارتباط با قرارداد سومی استفاده کند، چه اتفاقی میافتد؟ در چنین حالتی، زمینه اجرایی (context) همچنان متعلق به قرارداد اولیه (Caller) باقی میماند و نه قرارداد میانی.
جریان به این صورت عمل میکند:
-
قرارداد Caller تابع
logSender()
را در قرارداد CalledFirst با استفاده ازdelegatecall
اجرا میکند. -
این تابع قرار است یک رویداد (event) ثبت کند که مقدار
msg.sender
را نمایش میدهد. -
علاوه بر این، قرارداد CalledFirst در داخل همین تابع، یک
delegatecall
دیگر به قرارداد CalledLast انجام میدهد. -
قرارداد CalledLast نیز یک رویداد مشابه ثبت میکند که در آن
msg.sender
را لاگ میکند.
در تصویر زیر، این جریان بهصورت نموداری نمایش داده شده است تا بهخوبی روشن شود که در هر مرحله، msg.sender
همان فرستنده اولیه است و زمینه اجرای تمام توابع، همچنان متعلق به قرارداد Caller باقی میماند.
به خاطر داشته باشید که delegatecall
صرفاً بایتکد قرارداد مقصد را “قرض میگیرد”. یکی از راههای ساده برای درک این موضوع آن است که تصور کنیم بایت کد قرارداد مقصد بهصورت موقت درون قرارداد فراخوان (Caller) جذب شده است.
وقتی از این زاویه به قضیه نگاه کنیم، متوجه میشویم که مقدار msg.sender
همیشه همان فرستنده اصلی تراکنش باقی میماند، زیرا تمام عملیات—حتی در صورت چندین مرحله delegatecall
—در بستر قرارداد Caller انجام می شود.
در ادامه، کدی ارائه شده است که برای تست مفهوم delegatecall به یک delegatecall دیگر طراحی شده است:
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 |
contract Caller { address calledFirst = 0xF27374C91BF602603AC5C9DaCC19BE431E3501cb; function delegateCallToFirst() public { calledFirst.delegatecall( abi.encodeWithSignature("logSender()") ); } } contract CalledFirst { event SenderAtCalledFirst(address sender); address constant calledLast = 0x1d142a62E2e98474093545D4A3A0f7DB9503B8BD; function logSender() public { emit SenderAtCalledFirst(msg.sender); calledLast.delegatecall( abi.encodeWithSignature("logSender()") ); } } contract CalledLast { event SenderAtCalledLast(address sender); function logSender() public { emit SenderAtCalledLast(msg.sender); } } |
ممکن است تصور کنیم که مقدار msg.sender
در قرارداد CalledLast برابر با آدرس قرارداد CalledFirst خواهد بود، چون این قرارداد مستقیماً CalledLast را فراخوانی کرده است. اما این تصور با مدل واقعی delegatecall
همخوانی ندارد—مدلی که طبق آن، بایت کد قرارداد مقصد فقط «قرض گرفته می شود» و تمام عملیات در زمینه اجرایی قرارداد فراخوان (Caller) انجام می شود.
در نهایت، مقدار msg.sender
هم در قرارداد CalledFirst و هم در CalledLast، برابر با آدرس حسابی است که تراکنش اولیه را با فراخوانی Caller.delegateCallToFirst()
آغاز کرده است. این رفتار در محیط Remix قابل مشاهده است؛ در تصویر زیر، روند اجرا و لاگ های ثبتشده بهخوبی این موضوع را تأیید می کنند.
msg.sender
در هر دو قرارداد CalledFirst و CalledLast یکسان است.
یکی از دلایل سردرگمی درک این ساختار، نحوه بیان آن است. برخی ممکن است این روند را اینگونه توصیف کنند: «Caller با استفاده از delegatecall به CalledFirst متصل می شود و CalledFirst نیز با delegatecall به CalledLast متصل می شود.» اما این بیان اشتباه است، چون اینطور بهنظر می رسد که خود CalledFirst دارد delegatecall
انجام می دهد—در حالی که چنین نیست.
در واقع، CalledFirst تنها بایت کد را فراهم می کند. این بایت کد توسط قرارداد Caller اجرا می شود، و delegatecall
به CalledLast نیز در همان زمینه Caller انجام می شود. بنابراین، این Caller است که تمام عملیات را انجام می دهد، نه CalledFirst.
فراخوانی از داخل یک delegatecall در سالیدیتی
حالا بیایید با ایجاد یک تغییر مهم در قرارداد CalledFirst، مسیر اجرا را تغییر دهیم. اینبار قرار است CalledFirst بهجای استفاده از delegatecall
، از call
برای فراخوانی قرارداد CalledLast استفاده کند. این تغییر باعث تفاوت در زمینه اجرایی (context
) می شود و نتایج متفاوتی را برای msg.sender
به همراه خواهد داشت.
بهعبارت دیگر، قرارداد CalledFirst باید به کد زیر بهروزرسانی شود:
1 2 3 4 5 6 7 8 9 10 11 |
contract CalledFirst { event SenderAtCalledFirst(address sender); address constant calledLast = ...; function logSender() public { emit SenderAtCalledFirst(msg.sender); calledLast.call( abi.encodeWithSignature("logSender()") ); // this is new } } |
سؤال اصلی اینجاست: مقدار msg.sender
که در رویداد SenderAtCalledLast
ثبت می شود، چه خواهد بود؟
زمانی که قرارداد Caller تابعی را از طریق delegatecall
در قرارداد CalledFirst فراخوانی می کند، آن تابع در زمینه اجرایی Caller اجرا می شود. بهخاطر داشته باشید که CalledFirst صرفاً بایتکد خود را «قرض» می دهد تا توسط Caller اجرا شود.
در این لحظه، انگار که msg.sender
مستقیماً در داخل قرارداد Caller فراخوانی شده است. بنابراین، مقدار msg.sender
برابر است با آدرس حسابی که تراکنش را آغاز کرده است.
اکنون، قرارداد CalledFirst به قرارداد CalledLast فراخوانی انجام می دهد، اما چون CalledFirst در زمینه اجرایی Caller در حال اجراست، این فراخوانی در واقع بهگونهای انجام می شود که انگار خود Caller مستقیماً به CalledLast فراخوانی کرده است.
در چنین حالتی، مقدار msg.sender
در قرارداد CalledLast برابر خواهد بود با آدرس قرارداد Caller.
در تصویر زیر که لاگهای مربوط به اجرای این فرایند در محیط Remix را نشان می دهد، می توان مشاهده کرد که اینبار مقادیر msg.sender
متفاوت هستند—و این تفاوت دقیقاً به دلیل تغییر نوع فراخوانی از delegatecall
به call
است.
msg.sender در CalledLast برابر با آدرس Caller است
تمرین: اگر قرارداد Caller تابعی از CalledFirst را فراخوانی کند و CalledFirst نیز از طریق delegatecall
به CalledLast متصل شود، و هر سه قرارداد مقدار msg.sender
را در رویداد لاگ کنند، هر کدام از آنها چه مقداری را ثبت خواهند کرد؟
delegatecall در سطح پایین
در این بخش، از delegatecall
در زبان YUL استفاده می کنیم تا عملکرد آن را در سطح پایینتر و با جزئیات دقیقتر بررسی کنیم. توابع در YUL شباهت زیادی به نگارش مستقیم دستورات اسمبلی EVM دارند؛ به همین دلیل، ابتدا بهتر است تعریف دستور DELEGATECALL
را بررسی کنیم.
دستور DELEGATECALL
شش آرگومان را بهترتیب از روی پشته دریافت می کند:
-
gas
: مقدار گازی که باید برای اجرای زمینه فرعی ارسال شود. هر میزان گازی که در زمینه فرعی مصرف نشود، به زمینه فعلی بازمیگردد. -
address
: آدرس حسابی که قرار است کد آن اجرا شود. -
argsOffset
: مکان شروع داده های ورودی در حافظه (بر حسب بایت). این دادهها بهعنوان calldata زمینه فرعی ارسال می شوند. -
argsSize
: اندازه داده های ورودی (calldata) بر حسب بایت. -
retOffset
: مکان ذخیره نتیجه برگشتی در حافظه (بر حسب بایت). -
retSize
: اندازهای که از داده های بازگشتی باید در حافظه کپی شود (بر حسب بایت).
ارسال اتر به یک قرارداد از طریق delegatecall
مجاز نیست—و تصور کنید اگر این امکان وجود داشت چه آسیبپذیریهایی ممکن بود ایجاد شود! در مقابل، دستور CALL
چنین امکانی را فراهم می کند و یک پارامتر اضافی برای مشخصکردن مقدار اتر ارسالی دارد.
در زبان YUL، تابع delegatecall
دقیقاً همان رفتار دستور DELEGATECALL
در EVM را شبیهسازی می کند و شامل همان ۶ آرگومان است که پیشتر توضیح داده شد. نگارش این تابع به شکل زیر است:
1 |
delegatecall(g, a, in, insize, out, outsize). |
delegatecall
را اجرا می کنند. یکی از این توابع بهصورت کامل با سالیدیتی نوشته شده و دیگری از زبان YUL برای اجرای همان عملیات استفاده می کند.
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 |
contract DelegateYUL { function delegateInSolidity( address _address ) public returns (bytes memory data) { (, data) = _address.delegatecall( abi.encodeWithSignature("sayOne()") ); } function delegateInYUL( address _address ) public returns (uint data) { assembly { mstore(0x00, 0x34ee2172) // Load the calldata I intend to send into memory at 0x00. The first slot will become 0x0000000000000000000000000000000000000000000000000000000034ee2172 let result := delegatecall(gas(), _address, 0x1c, 4, 0, 0x20) // The third parameter indicates the starting position in memory where the calldata is located, the fourth parameter specifies its size in bytes, and the fifth parameter specifies where the returned calldata, if any, should be stored in memory data := mload(0) // Read delegatecall return from memory } } } contract Called { function sayOne() public pure returns (uint) { return 1; } } |
در تابع delegateInSolidity
، از متد delegatecall
در سالیدیتی استفاده کردهايم و امضای تابع sayOne
را بهعنوان ورودی با استفاده از متد abi.encodeWithSignature
تولید و ارسال کردهايم.
اگر از قبل اندازه داده های بازگشتی را نمیدانیم، جای نگرانی نیست. میتوانیم در ادامه با استفاده از تابع returndatacopy
این داده ها را بهدرستی مدیریت کنیم. در یک مقاله دیگر، زمانی که عمیقتر به مبحث طراحی قراردادهای قابل ارتقاء با استفاده از delegatecall
بپردازیم، تمام این جزئیات را بهطور کامل بررسی خواهیم کرد.
EIP-150 و گاز انتقالی (Forwarded Gas)
نکتهای مهم درباره میزان گازی که هنگام delegatecall
منتقل میشود: در کدهایی که از delegatecall
استفاده میکنند، معمولاً مقدار گاز را با استفاده از تابع gas()
بهعنوان نخستین پارامتر ارسال میکنیم. این کار به این معناست که میخواهیم تمام گاز موجود را به زمینه فرعی منتقل کنیم.
اما از زمان هاردفورک Tangerine Whistle (که در قالب EIP-150 معرفی شد)، محدودیتی در این زمینه اعمال شده است: حداکثر ۶۳/۶۴ از گاز موجود اجازه دارد به زمینه جدید منتقل شود. به بیان دیگر، اگرچه تابع gas()
تمام گاز باقیمانده را بازمیگرداند، تنها ۶۳/۶۴ آن در اختیار delegatecall
قرار میگیرد و ۱/۶۴ گاز همیشه در زمینه فعلی حفظ میشود.
این محدودیت با هدف جلوگیری از حملات بازگشتی طراحی شده و نهتنها برای delegatecall
، بلکه برای سایر دستورات مشابه مانند call
نیز اعمال میشود. در نتیجه، هنگام طراحی قراردادهایی که از delegatecall
استفاده میکنند—بهویژه در ساختارهای پیچیده یا قابل ارتقاء—باید به این محدودیت گاز توجه ویژه داشت.
جمع بندی
در پایان این مقاله، بیایید آنچه را که آموختیم مرور کنیم. دستور delegatecall
این امکان را فراهم می کند که توابع تعریفشده در قراردادهای دیگر، در زمینه اجرایی قرارداد فراخوان (Caller) اجرا شوند. قرارداد مقصد که به آن قرارداد پیادهسازی (Implementation Contract) نیز گفته می شود، صرفاً بایتکد خود را در اختیار میگذارد؛ هیچ تغییری در داده های داخلی آن صورت نمیگیرد و هیچ اطلاعاتی از فضای ذخیرهسازی آن خوانده نمی شود.
delegatecall
با هدف جدا کردن محل ذخیره داده ها از منطق اجرایی یا پیادهسازی توابع استفاده می شود. این الگو، پایه اصلی رایجترین شیوه طراحی قراردادهای قابل ارتقاء در سالیدیتی است.
با این حال، همانطور که در طول مقاله دیدیم، استفاده از delegatecall
نیازمند دقت بسیار بالایی است. اگر متغیرهای وضعیتی بهدرستی هماهنگ نشوند یا ترتیب آنها در قراردادها با هم همخوانی نداشته باشد، ممکن است تغییرات ناخواستهای در داده ها ایجاد شده و در نهایت عملکرد قرارداد فراخوان دچار اختلال یا حتی از کار افتادگی کامل شود.
راستی! برای دریافت مطالب جدید در کانال تلگرام یا پیج اینستاگرام سورس باران عضو شوید.
- انتشار: ۱۰ تیر ۱۴۰۴
دسته بندی موضوعات
- آموزش ارز دیجیتال
- آموزش برنامه نویسی
- آموزش متنی برنامه نویسی
- اطلاعیه و سایر مطالب
- پروژه برنامه نویسی
- دوره های تخصصی برنامه نویسی
- رپورتاژ
- فیلم های آموزشی
- ++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
- اچ تی ام ال
- بانک اطلاعاتی
- برنامه نویسی سوکت
- برنامه نویسی موبایل
- پاسکال
- پایان نامه
- پایتون
- جاوا
- جاوا اسکریپت
- جی کوئری
- داده کاوی
- دلفی
- رباتیک
- سئو
- سایر کتاب ها
- سخت افزار
- سی اس اس
- سی پلاس پلاس
- سی شارپ
- طراحی الگوریتم
- فتوشاپ
- مقاله
- مهندسی نرم افزار
- هک و امنیت
- هوش مصنوعی
- ویژوال بیسیک
- نرم افزار و ابزار برنامه نویسی
- وردپرس