آموزش delegatecall در سالیدیتی

در این مقاله، عملکرد دستور delegatecall در سالیدیتی را به طور کامل و دقیق بررسی می‌کنیم. ماشین مجازی اتریوم (Ethereum Virtual Machine یا به اختصار EVM) چهار دستور اصلی برای برقراری ارتباط بین قراردادها ارائه می‌دهد:

  • CALL (کد عملیاتی F1)

  • CALLCODE (کد عملیاتی F2)

  • STATICCALL (کد عملیاتی FA)

  • و DELEGATECALL (کد عملیاتی F4)

نکته مهم این است که از نسخه 5 زبان سالیدیتی به بعد، استفاده از CALLCODE منسوخ شده و DELEGATECALL جایگزین آن شده است. این دستورات در زبان سالیدیتی به‌صورت مستقیم پیاده سازی شده‌اند و می‌توان آن‌ها را به عنوان متدهایی از متغیرهای نوع address فراخوانی کرد.

برای درک بهتر نحوه عملکرد delegatecall در سالیدیتی، ابتدا لازم است با منطق اجرایی دستور CALL آشنا شویم.

دستور Call در سالیدیتی

برای درک بهتر عملکرد دستور call، بیایید ابتدا قرارداد ساده‌ای را بررسی کنیم:

ساده‌ترین راه برای اجرای تابع increment() از یک قرارداد دیگر، استفاده از واسط (interface) قرارداد Called است. در این روش، اگر متغیر called نمایانگر آدرس قرارداد Called باشد، می‌توان تابع را با دستور ساده called.increment() فراخوانی کرد.

اما یک روش دیگر برای انجام همین کار، استفاده از فراخوانی سطح پایین (low-level call) است. به عنوان مثال در قرارداد زیر:

در زبان سالیدیتی، تمام متغیرهای از نوع 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 را به شکل زیر بازنویسی کنیم:

نکته مهم این است که متد call هیچ‌گاه باعث بازگرداندن خطا (revert) نمی‌شود. اگر اجرای تراکنش موفق نباشد، مقدار success برابر false خواهد بود. بنابراین، برنامه نویس باید این وضعیت را بررسی کرده و در صورت لزوم، اقدامات مناسب را انجام دهد.

مدیریت شکست در فراخوانی با call

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

من به‌صورت عمدی دو فراخوانی ایجاد کردم: یکی با امضای صحیح تابع 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 اجرا می‌شود:

و در ادامه، کد مربوط به قرارداد Caller را مشاهده می‌کنید

در فراخوانی از نوع delegatecall، قرارداد Caller تابع increment را اجرا می کند. با این حال، این اجرا یک تفاوت اساسی با حالت‌های معمول دارد: تغییرات تنها در داده‌ های قرارداد Caller اعمال می شوند و فضای ذخیره‌ سازی قرارداد Called بدون تغییر باقی می‌ماند. در واقع، Caller صرفاً کد قرارداد Called را قرض می‌گیرد و آن را در بستر اجرایی خودش اجرا می‌کند.

نمودار زیر به‌صورت شفاف نشان می دهد که delegatecall فقط داده های قرارداد Caller را تغییر می دهد و به حافظه قرارداد Called دست نمی زند.

قرارداد فراخواننده با استفاده از تابع فراخوانی به یک قرارداد فراخوانی شده

تصویر زیر تفاوت اجرای تابع increment با استفاده از call و delegatecall را نشان می دهد. این مقایسه به خوبی مشخص می کند که در call، تغییرات در حافظه قرارداد فراخوانی‌شده (Called) اعمال می شود، در حالی که در delegatecall، همان تابع در بستر قرارداد فراخوان (Caller) اجرا شده و تنها داده های آن را تحت تاثیر قرار می دهد.

تفاوت بین اجرای تابع افزایشی با استفاده از فراخوانی (call) و فراخوانی نماینده (delegatecall).

برخورد شیارهای ذخیره سازی (Storage Slot Collision)

وقتی یک قرارداد از delegatecall استفاده می کند، باید با دقت بسیار بالا پیش‌بینی کند که کدام شیارهای ذخیره سازی (storage slots) ممکن است تغییر کنند. دلیل این احتیاط ساده است: در delegatecall، کد قرارداد دیگر در بستر قرارداد فعلی اجرا می شود و هر تغییری مستقیماً بر داده های قرارداد فراخوان (Caller) اثر می گذارد.

در مثالی که قبلاً دیدیم، همه چیز به‌درستی کار کرد، چون قرارداد Caller هیچ متغیر وضعیتی‌ای در شیار 0 نداشت. اما یکی از خطاهای رایج هنگام استفاده از delegatecall در سالیدیتی، بی‌توجهی به همین نکته است.

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

در نسخه به روزرسانی‌شده قرارداد بالا، توجه داشته باشید که مقدار ذخیره شده در شیار 0، آدرس قرارداد Called است، و متغیر myNumber اکنون در شیار 1 ذخیره می شود.

اگر این قراردادها را مستقر کنید و تابع callIncrement را اجرا نمایید، شیار 0 از فضای ذخیره سازی قرارداد Caller افزایش خواهد یافت. اما نکته مهم اینجاست که در این شیار، متغیر calledAddress قرار دارد، نه myNumber. بنابراین به‌جای آنکه مقدار myNumber افزایش یابد، مقدار آدرس ذخیره شده تغییر می کند که این رفتار، نتیجه برخورد شیارهای ذخیره سازی (storage slot collision) است.

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

حافظه‌ی فراخوانی‌کننده هنگام استفاده از delegatecall()

بنابراین هنگام استفاده از delegatecall باید نهایت دقت را به خرج داد، چون این فراخوانی ممکن است ناخواسته باعث ایجاد اختلال در عملکرد قرارداد شود. در مثال بالا، به احتمال زیاد هدف برنامه نویس این نبوده که با اجرای تابع callIncrement مقدار متغیر calledAddress را تغییر دهد.

اکنون می خواهیم یک تغییر کوچک در قرارداد Caller اعمال کنیم و متغیر myNumber را به شیار 0 منتقل کنیم.

اکنون، زمانی که تابع callIncrement اجرا می شود، مقدار متغیر myNumber افزایش خواهد یافت، چون این دقیقاً هدف تابع increment است. من عمداً نام متغیر در قرارداد Caller را متفاوت از نام متغیر در قرارداد Called انتخاب کردم تا نشان دهم که نام متغیرها اهمیتی ندارد؛ آنچه اهمیت دارد محل قرارگیری آن‌ها در شیارهای ذخیره سازی است.

برای اینکه delegatecall به‌درستی عمل کند، باید چینش (ترتیب و موقعیت) متغیرهای وضعیتی در هر دو قرارداد کاملاً با هم هماهنگ باشد. این هماهنگی برای عملکرد صحیح و بدون خطا ضروری است.

جدا کردن منطق اجرا از داده ها

یکی از کاربردهای بسیار مهم delegatecall در سالیدیتی این است که بتوان منطق اجرایی قرارداد را از محل ذخیره داده ها جدا کرد. در این مثال، قرارداد Caller داده ها را نگهداری می کند و قرارداد Called منطق اجرای توابع را در خود جای داده است. اگر در آینده تصمیم به تغییر منطق اجرا گرفته شود، توسعه‌ دهنده می تواند به‌سادگی یک قرارداد جدید جایگزین Called کند و آدرس آن را در Caller به‌روزرسانی نماید؛ در این فرآیند، داده های ذخیره‌شده در Caller دست‌نخورده باقی می‌مانند و نیازی به تغییر آن‌ها نیست. این الگو یکی از پایه‌های رایج در طراحی قراردادهای قابل ارتقا در سالیدیتی و همچنین یکی از مفاهیم کلیدی در آموزش برنامه نویسی پیشرفته بلاکچین محسوب می‌شود.

با استفاده از این روش، قرارداد Caller دیگر به توابع داخلی خودش محدود نیست و می تواند توابع مورد نیازش را از سایر قراردادها از طریق delegatecall فراخوانی کند.

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

متأسفانه نمی توان نام تابعی که قرار است فراخوانی شود را تغییر داد، زیرا این کار باعث تغییر در امضای تابع (function signature) خواهد شد.

پس از ساخت قرارداد جدیدی با نام NewCalled، می توان آن را مستقر کرد و مقدار متغیر calledAddress را در قرارداد Caller به آدرس جدید تغییر داد. البته برای انجام این کار، باید در قرارداد Caller مکانیزمی برای به‌روزرسانی آدرس مقصد در نظر گرفته شده باشد، که ما برای ساده نگه داشتن کد، آن را در مثال پیاده سازی نکردیم.

با این روش، منطق اجرایی مورد استفاده در قرارداد Caller با موفقیت تغییر یافته است. این جداسازی میان داده ها و منطق اجرا، امکان ایجاد قراردادهای هوشمند قابل ارتقا را در سالیدیتی فراهم می کند.

delegtecall() به یک قرارداد اجازه می‌دهد تا داده‌ها و منطق کسب‌وکار را از هم جدا کند.

در تصویر بالا، قرارداد سمت چپ هم داده ها را در خود نگه می دارد و هم منطق اجرایی را مدیریت می کند. اما در ساختار سمت راست، قرارداد بالایی تنها مسئول نگهداری داده ها است، در حالی که منطق مربوط به به‌روزرسانی این داده ها در یک قرارداد جداگانه به نام قرارداد منطقی (Logic Contract) قرار دارد.

برای اعمال تغییرات بر داده ها، قرارداد اصلی از طریق delegatecall به قرارداد منطق متصل می شود و از توابع آن برای انجام عملیات مورد نیاز استفاده می کند.

مدیریت مقدار بازگشتی delegatecall در سالیدیتی

درست مانند call، دستور delegatecall نیز یک tuple بازمی‌گرداند که شامل دو مقدار است: یک مقدار بولی که موفقیت یا عدم موفقیت اجرای تابع را مشخص می کند، و خروجی تابعی که از طریق delegatecall اجرا شده است، به‌صورت داده باینری (bytes).

برای درک بهتر نحوه مدیریت این مقادیر بازگشتی، در ادامه یک مثال جدید می نویسیم.

قرارداد Called شامل منطق محاسبه قیمت با تخفیف است. برای استفاده از این منطق، تابع calculateDiscountPrice را با استفاده از delegatecall اجرا می کنیم. این تابع یک مقدار بازمی‌گرداند که باید با استفاده از abi.decode آن را به شکل قابل استفاده تبدیل کنیم.

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

زمانی که call یا delegatecall مقدار false برمی‌گردانند

یکی از نکات بسیار مهم درک این موضوع است که چه زمانی مقدار موفقیت (success) در call یا delegatecall برابر با true یا false خواهد بود. این موضوع به این بستگی دارد که آیا تابع در حال اجرا با خطا متوقف می شود (revert) یا نه.

به‌طور کلی، سه حالت باعث می شوند که اجرای یک تابع متوقف شده و بازگردانده شود (revert):

  1. اگر در طول اجرا به دستور REVERT برسد،

  2. اگر گاز مصرفی آن تمام شود،

  3. اگر عملیات ممنوعی انجام دهد، مثل تقسیم بر صفر.

اگر تابعی که از طریق delegatecall (یا call) اجرا می شود با یکی از این سه حالت روبرو شود، اجرای آن متوقف می شود و مقدار بازگشتی delegatecall برابر false خواهد بود.

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

نمونه‌ای دیگر از خطاهای مربوط به متغیرهای ذخیره سازی

بیایید با ایجاد یک تغییر جزئی در کد بالا، یک مثال دیگر از باگ های مرتبط با نحوه چینش متغیرهای ذخیره سازی ارائه کنیم.

قرارداد Caller همچنان از طریق delegatecall به یک قرارداد پیاده‌ سازی (implementation contract) متصل می شود. اما این بار، قرارداد Called به‌جای صرفاً تغییر داده‌ ها، مقدار یک متغیر وضعیتی را نیز از فضای ذخیره‌ سازی می خواند. این تغییر ممکن است در ظاهر ساده به نظر برسد، اما در عمل می تواند به یک فاجعه منجر شود.

می توانید حدس بزنید چرا؟ دلیل آن این است که قرارداد Called در حال خواندن متغیری است که بر اساس ساختار داخلی خودش در یک شیار خاص ذخیره شده، در حالی که هنگام اجرای delegatecall، عملیات خواندن از فضای ذخیره‌ سازی قرارداد Caller انجام می شود. اگر ترتیب و موقعیت متغیرها در دو قرارداد همخوانی نداشته باشد، قرارداد Called ممکن است داده‌ای اشتباه را بخواند و تصمیم‌گیری نادرستی انجام دهد، بدون آنکه هیچ خطایی اعلام شود. این نوع خطاها معمولاً بسیار پنهان هستند و شناسایی آن‌ها می تواند دشوار باشد.

مشکل از آنجا شروع می شود که تابع calculateDiscountPrice در حال خواندن یک متغیر وضعیتی است؛ به‌طور خاص، متغیری که در شیار 0 قرار دارد. فراموش نکنید که در delegatecall، توابع در بستر فضای ذخیره سازی قرارداد فراخوان (Caller) اجرا می شوند. به بیان دیگر، ممکن است فکر کنید در حال استفاده از متغیر discountRate در قرارداد Called هستید تا قیمت جدید را محاسبه کنید، اما در واقع دارید از متغیر price در قرارداد Caller استفاده می کنید!

در این مثال، هر دو متغیر discountRate در قرارداد Called و price در قرارداد Caller در شیار 0 حافظه ذخیره شده‌اند. بنابراین زمانی که calculateDiscountPrice مقدار discountRate را می‌خواند، در حقیقت مقدار price در Caller را دریافت می کند.

نتیجه چه خواهد بود؟ تابع تصور می کند نرخ تخفیف برابر با عددی بسیار بزرگ است (مثلاً ۲۰۰٪) و بر اساس آن قیمت نهایی را محاسبه می کند. این موضوع باعث می شود که قیمت نهایی منفی شود، که برای نوع داده uint غیرمجاز است و در نهایت منجر به revert شدن اجرای تابع خواهد شد. این مثال به‌خوبی نشان می دهد که ناهماهنگی در چینش متغیرهای ذخیره سازی می تواند به باگ‌های جدی و پنهان منجر شود.

متغیرهای تغییرناپذیر و ثابت در delegatecall: داستان یک باگ پنهان

یکی دیگر از چالش‌های مهم هنگام استفاده از delegatecall در سالیدیتی، زمانی رخ می دهد که با متغیرهای immutable یا constant سروکار داریم. این موضوع یکی از مواردی است که حتی بسیاری از برنامه نویسان باتجربه در سالیدیتی به اشتباه متوجه می شوند.

بیایید با یک مثال این موضوع را بررسی کنیم؛ مثالی که می تواند منجر به رفتارهای غیرمنتظره و باگ‌های پنهان در قرارداد هوشمند شما شود.

پرسش اینجاست: وقتی تابع 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 محسوب می شود.

تابع ()delegatecall بایت‌کد قرارداد فراخوانی‌شده را به قرارداد فراخواننده قرض می‌دهد.

بیایید این مفهوم را در یک مثال به کار ببریم. کد زیر را در نظر بگیرید:

در قرارداد Called، از msg.sender، msg.value و address(this) استفاده کرده‌ایم و این مقادیر از طریق تابع getInfo بازگردانده می‌شوند. اجرای تابع getDelegateInfo در محیط Remix در تصویر زیر نمایش داده شده است. در این تصویر می‌توان مقادیر بازگشتی را به‌صورت دقیق مشاهده کرد.

  • msg.sender مربوط به حسابی است که تراکنش را اجرا کرده، یعنی همان حساب پیش‌فرض اول در Remix با آدرس 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4.
  • msg.value نشان‌ دهنده مقدار ۱ اتر است که در تراکنش اصلی ارسال شده است.
  • address(this) برابر با آدرس قرارداد Caller است، همان‌طور که در سمت چپ تصویر مشاهده می شود، و نه آدرس قرارداد Called.

تابع ()delegatecall بایت‌کد قرارداد فراخوانی‌شده را به قرارداد فراخواننده قرض می‌دهد.در محیط 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 نیز متفاوت خواهد بود.

تصویری که نشان می‌دهد msg.data با delegatecall() برای فراخواننده و contractهای فراخوانی‌شده متفاوت است

کد زیر برای نمایش این موضوع مورد استفاده قرار می گیرد.

تراکنش اصلی تابع delegateMsgData را اجرا می کند؛ تابعی که یک پارامتر از نوع address دریافت می کند. در نتیجه، داده‌ های ورودی (input data) شامل امضای تابع به‌همراه یک آدرس است که به‌صورت ABI کدگذاری شده است.

تابع delegateMsgData نیز به نوبه خود تابع returnMsgData را از طریق delegatecall فراخوانی می کند. برای انجام این کار، داده‌ های ارسالی به زمان اجرا (runtime) باید شامل امضای تابع returnMsgData باشند. بنابراین، مقدار msg.data درون تابع returnMsgData برابر با امضای خودش خواهد بود؛ یعنی 0x0b1c837f.

در تصویر زیر مشاهده می کنیم که خروجی تابع returnMsgData همان امضای خودش است که به‌صورت ABI کدگذاری شده بازگردانده شده است.

تصویر بازگشتیِ MsgData امضای خودش است که با ABI کدگذاری شده است.

خروجی دیکدشده، امضای تابع returnMsgData است که به‌صورت بایت و مطابق با استاندارد ABI کدگذاری شده است.

CODESIZE به‌عنوان یک مثال نقض در delegatecall

پیش‌تر گفتیم که می توان delegatecall در سالیدیتی را به‌گونه‌ای تصور کرد که قرارداد فراخوان (Caller) کد اجرایی را از قرارداد پیاده‌ سازی قرض می گیرد و آن را در بستر خودش اجرا می کند. اما یک استثنای مهم در این تصویر ذهنی وجود دارد: دستور CODESIZE.

فرض کنید یک قرارداد هوشمند شامل دستور CODESIZE در بایت‌ کد خود باشد. این دستور اندازه کد همان قراردادی را برمی‌گرداند که در آن قرار دارد. یعنی اگر در حین delegatecall این دستور اجرا شود، اندازه کد قرارداد فراخوان (Caller) را باز نمی‌گرداند، بلکه اندازه کد همان قراردادی را بازمی‌گرداند که از آن delegatecall شده است.

برای نشان دادن این ویژگی، کد زیر را در نظر گرفته‌ایم. در سالیدیتی، دستور CODESIZE را می توان در سطح اسمبلی از طریق تابع codesize() اجرا کرد. ما دو قرارداد پیاده‌سازی به نام‌های CalledA و CalledB داریم که تنها تفاوت آن‌ها در وجود یک متغیر محلی است (که در CalledB تعریف شده اما در CalledA وجود ندارد). همین تفاوت باعث می شود اندازه بایت‌کد این دو قرارداد با هم متفاوت باشد.

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

اگر تابع codesize اندازه قرارداد Caller را بازمی‌گرداند، در آن صورت مقادیری که از تابع getSizes() دریافت می شد، چه هنگام فراخوانی ContractA و چه هنگام فراخوانی ContractB، باید یکسان می بود—یعنی همان اندازه قرارداد Caller که برابر با ۱۱۰۳ است.

اما همان‌طور که در تصویر زیر مشاهده می کنید، مقادیر بازگشتی متفاوت هستند. این تفاوت به‌وضوح نشان می دهد که دستور codesize در واقع اندازه کد قراردادهای CalledA و CalledB را برمی‌گرداند، نه Caller. این رفتار استثنا بودن CODESIZE را در delegatecall به‌روشنی اثبات می کند.

خروجی با اندازه کد، قرارداد CalledA و CalledB

delegatecall تو در تو

ممکن است این سؤال پیش بیاید که اگر یک قرارداد از طریق delegatecall به قراردادی دوم متصل شود و آن قرارداد دوم نیز مجدداً از delegatecall برای ارتباط با قرارداد سومی استفاده کند، چه اتفاقی می‌افتد؟ در چنین حالتی، زمینه اجرایی (context) همچنان متعلق به قرارداد اولیه (Caller) باقی می‌ماند و نه قرارداد میانی.

جریان به این صورت عمل می‌کند:

  1. قرارداد Caller تابع logSender() را در قرارداد CalledFirst با استفاده از delegatecall اجرا می‌کند.

  2. این تابع قرار است یک رویداد (event) ثبت کند که مقدار msg.sender را نمایش می‌دهد.

  3. علاوه بر این، قرارداد CalledFirst در داخل همین تابع، یک delegatecall دیگر به قرارداد CalledLast انجام می‌دهد.

  4. قرارداد CalledLast نیز یک رویداد مشابه ثبت می‌کند که در آن msg.sender را لاگ می‌کند.

در تصویر زیر، این جریان به‌صورت نموداری نمایش داده شده است تا به‌خوبی روشن شود که در هر مرحله، msg.sender همان فرستنده اولیه است و زمینه اجرای تمام توابع، همچنان متعلق به قرارداد Caller باقی می‌ماند.

سه قرارداد به ترتیب با استفاده از delegatecall() و logsender() یکدیگر را فراخوانی می‌کنند

به خاطر داشته باشید که delegatecall صرفاً بایت‌کد قرارداد مقصد را “قرض می‌گیرد”. یکی از راه‌های ساده برای درک این موضوع آن است که تصور کنیم بایت‌ کد قرارداد مقصد به‌صورت موقت درون قرارداد فراخوان (Caller) جذب شده است.

وقتی از این زاویه به قضیه نگاه کنیم، متوجه می‌شویم که مقدار msg.sender همیشه همان فرستنده اصلی تراکنش باقی می‌ماند، زیرا تمام عملیات—حتی در صورت چندین مرحله delegatecall—در بستر قرارداد Caller انجام می شود.

در ادامه، کدی ارائه شده است که برای تست مفهوم delegatecall به یک delegatecall دیگر طراحی شده است:

ممکن است تصور کنیم که مقدار msg.sender در قرارداد CalledLast برابر با آدرس قرارداد CalledFirst خواهد بود، چون این قرارداد مستقیماً CalledLast را فراخوانی کرده است. اما این تصور با مدل واقعی delegatecall هم‌خوانی ندارد—مدلی که طبق آن، بایت‌ کد قرارداد مقصد فقط «قرض گرفته می شود» و تمام عملیات در زمینه اجرایی قرارداد فراخوان (Caller) انجام می شود.

در نهایت، مقدار msg.sender هم در قرارداد CalledFirst و هم در CalledLast، برابر با آدرس حسابی است که تراکنش اولیه را با فراخوانی Caller.delegateCallToFirst() آغاز کرده است. این رفتار در محیط Remix قابل مشاهده است؛ در تصویر زیر، روند اجرا و لاگ های ثبت‌شده به‌خوبی این موضوع را تأیید می کنند.

نتیجه msg.sender برای هر دو CalledFirst و CalledLas

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 به همراه خواهد داشت.

سه قرارداد به ترتیب با استفاده از delegatecall() و logsender() یکدیگر را فراخوانی می‌کنند

به‌عبارت دیگر، قرارداد CalledFirst باید به کد زیر به‌روزرسانی شود:

سؤال اصلی اینجاست: مقدار msg.sender که در رویداد SenderAtCalledLast ثبت می شود، چه خواهد بود؟

خروجی msg.sender زمانی که یک قرارداد توسط قرارداد دیگری با استفاده از delegatecall() فراخوانی می‌شود.

زمانی که قرارداد Caller تابعی را از طریق delegatecall در قرارداد CalledFirst فراخوانی می کند، آن تابع در زمینه اجرایی Caller اجرا می شود. به‌خاطر داشته باشید که CalledFirst صرفاً بایت‌کد خود را «قرض» می دهد تا توسط Caller اجرا شود.

در این لحظه، انگار که msg.sender مستقیماً در داخل قرارداد Caller فراخوانی شده است. بنابراین، مقدار msg.sender برابر است با آدرس حسابی که تراکنش را آغاز کرده است.

تابع msg.sender هنگام مقایسه با توابع فراخوانی: delegatecall و call

اکنون، قرارداد CalledFirst به قرارداد CalledLast فراخوانی انجام می دهد، اما چون CalledFirst در زمینه اجرایی Caller در حال اجراست، این فراخوانی در واقع به‌گونه‌ای انجام می شود که انگار خود Caller مستقیماً به CalledLast فراخوانی کرده است.

در چنین حالتی، مقدار msg.sender در قرارداد CalledLast برابر خواهد بود با آدرس قرارداد Caller.

در تصویر زیر که لاگ‌های مربوط به اجرای این فرایند در محیط Remix را نشان می دهد، می توان مشاهده کرد که این‌بار مقادیر msg.sender متفاوت هستند—و این تفاوت دقیقاً به دلیل تغییر نوع فراخوانی از delegatecall به call است.

تصویر مقادیر msg.sender یک قرارداد که قرارداد دیگری را با استفاده از delegatecall و call فراخوانی می‌کند

msg.sender در CalledLast برابر با آدرس Caller است

تمرین: اگر قرارداد Caller تابعی از CalledFirst را فراخوانی کند و CalledFirst نیز از طریق delegatecall به CalledLast متصل شود، و هر سه قرارداد مقدار msg.sender را در رویداد لاگ کنند، هر کدام از آن‌ها چه مقداری را ثبت خواهند کرد؟

delegatecall در سطح پایین

در این بخش، از delegatecall در زبان YUL استفاده می کنیم تا عملکرد آن را در سطح پایین‌تر و با جزئیات دقیق‌تر بررسی کنیم. توابع در YUL شباهت زیادی به نگارش مستقیم دستورات اسمبلی EVM دارند؛ به همین دلیل، ابتدا بهتر است تعریف دستور DELEGATECALL را بررسی کنیم.

دستور DELEGATECALL شش آرگومان را به‌ترتیب از روی پشته دریافت می کند:

  1. gas: مقدار گازی که باید برای اجرای زمینه فرعی ارسال شود. هر میزان گازی که در زمینه فرعی مصرف نشود، به زمینه فعلی بازمی‌گردد.

  2. address: آدرس حسابی که قرار است کد آن اجرا شود.

  3. argsOffset: مکان شروع داده‌ های ورودی در حافظه (بر حسب بایت). این داده‌ها به‌عنوان calldata زمینه فرعی ارسال می شوند.

  4. argsSize: اندازه داده‌ های ورودی (calldata) بر حسب بایت.

  5. retOffset: مکان ذخیره نتیجه برگشتی در حافظه (بر حسب بایت).

  6. retSize: اندازه‌ای که از داده‌ های بازگشتی باید در حافظه کپی شود (بر حسب بایت).

ارسال اتر به یک قرارداد از طریق delegatecall مجاز نیست—و تصور کنید اگر این امکان وجود داشت چه آسیب‌پذیری‌هایی ممکن بود ایجاد شود! در مقابل، دستور CALL چنین امکانی را فراهم می کند و یک پارامتر اضافی برای مشخص‌کردن مقدار اتر ارسالی دارد.

در زبان YUL، تابع delegatecall دقیقاً همان رفتار دستور DELEGATECALL در EVM را شبیه‌سازی می کند و شامل همان ۶ آرگومان است که پیش‌تر توضیح داده شد. نگارش این تابع به شکل زیر است:

در ادامه، قراردادی ارائه می دهیم که شامل دو تابع با عملکرد یکسان است—هردو یک delegatecall را اجرا می کنند. یکی از این توابع به‌صورت کامل با سالیدیتی نوشته شده و دیگری از زبان YUL برای اجرای همان عملیات استفاده می کند.

در تابع 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 نیازمند دقت بسیار بالایی است. اگر متغیرهای وضعیتی به‌درستی هماهنگ نشوند یا ترتیب آن‌ها در قراردادها با هم هم‌خوانی نداشته باشد، ممکن است تغییرات ناخواسته‌ای در داده‌ ها ایجاد شده و در نهایت عملکرد قرارداد فراخوان دچار اختلال یا حتی از کار افتادگی کامل شود.

به این مطلب امتیاز دهید

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

دوره آموزش پروژه محور طراحی وب سایت پزشک یاب با بوت استرپ
  • انتشار: ۱۰ تیر ۱۴۰۴

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

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

مشاهده همه

نظرات

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