آموزش Transparent Upgradeable Proxy در سالیدیتی

الگوی Transparent Upgradeable Proxy در سالیدیتی یکی از روش های طراحی است که امکان ارتقای قرارداد پراکسی را فراهم می کند، بدون اینکه تداخلی در شناسه توابع (function selector) ایجاد شود.

برای اینکه یک پراکسی روی شبکه اتریوم عملکرد درستی داشته باشد، باید دو ویژگی کلیدی داشته باشد:

  1. یک جایگاه مشخص در حافظه (storage slot) برای ذخیره آدرس قرارداد پیاده سازی

  2. یک سازوکار که مدیر بتواند با استفاده از آن آدرس پیاده سازی را تغییر دهد

استاندارد ERC-1967 محل ذخیره آدرس قرارداد پیاده سازی را تعیین می کند. این استاندارد کمک می کند تا برخورد داده ها در حافظه (storage collision) به حداقل برسد. با این حال، استاندارد ERC-1967 روشی برای تغییر این آدرس مشخص نمی کند.

اگر تابعی مانند updateImplementation(address _newImplementation) را مستقیماً در پراکسی قرار دهیم، ممکن است به مشکل برخورد کنیم. این تابع ممکن است با یکی از توابع موجود در قرارداد پیاده سازی تداخل داشته باشد. چنین تداخلی می تواند منجر به رفتارهای ناپایدار و ناخواسته شود.

تداخل در شناسه تابع (Function Selector Clashing)

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

برای درک بهتر، در ادامه یک مثال ساده را بررسی می کنیم.

به خاطر داشته باشید: فراخوانی fallback همیشه در آخر انجام می شود. قبل از اینکه تابع fallback فعال شود، قرارداد پراکسی ابتدا بررسی می کند که آیا شناسه تابع ۴ بایتی با changeImplementation یا هر تابع عمومی دیگری که در پراکسی تعریف شده مطابقت دارد یا نه.

بنابراین، اگر یک تابع عمومی در قرارداد پراکسی تعریف کنیم، دو نوع تداخل در شناسه تابع ممکن است اتفاق بیفتد:

  1. تداخل مستقیم در امضای تابع: اگر قرارداد پیاده سازی (implementation) تابعی با همان امضا (signature) داشته باشد، آن تابع عملاً قابل فراخوانی نخواهد بود. دلیلش این است که قرارداد پراکسی ابتدا تابع خودش را اجرا می کند، نه fallback. و اگر fallback فراخوانی نشود، هیچ فراخوانی از نوع delegatecall به قرارداد پیاده سازی صورت نمی گیرد.

  2. تداخل اتفاقی در شناسه تابع: اگر قرارداد پیاده سازی تابعی داشته باشد که شناسه آن دقیقاً با شناسه یکی از توابع عمومی پراکسی برابر باشد، آن تابع نیز قابل اجرا نخواهد بود. دلیل این اتفاق آن است که شناسه تابع ممکن است به‌صورت تصادفی یکسان شود، حتی اگر امضای تابع متفاوت باشد. وقتی دو تابع متفاوت شناسه یکسانی داشته باشند، احتمال وقوع چنین تداخلی حدود ۱ در ۴.۲۹ میلیارد خواهد بود. چون شناسه تابع از ۴ بایت تشکیل می شود، در مجموع حدود ۴.۲۹ میلیارد شناسه ممکن وجود دارد. این احتمال بسیار کم است، اما صفر نیست. برای نمونه، تابع clash550254402() همان شناسه ای را دارد که تابع proxyAdmin() دارد.

این مسئله نشان میدهد که حتی توابعی که از نظر نام و ورودی با هم تفاوت دارند، اگر شناسه یکسانی داشته باشند، می توانند باعث اختلال در عملکرد پراکسی شوند.

الگوی Transparent Upgradeable Proxy چگونه به‌طور کامل از تداخل در شناسه تابع جلوگیری می کند

الگوی Transparent Upgradeable Proxy به‌گونه‌ای عمل می‌کند که احتمال تداخل در شناسه توابع (Function Selector Clashing) را به‌طور کامل از بین می‌برد.

این الگو تصریح می‌کند که فقط تابع fallback باید در پراکسی به‌صورت عمومی تعریف شود و هیچ تابع عمومی دیگری نباید وجود داشته باشد.

اما در این حالت، یک سوال پیش می‌آید:
وقتی فقط یک تابع fallback داریم، چطور می‌توانیم تابع ارتقای پراکسی را فراخوانی کنیم؟

پاسخ این است که با بررسی msg.sender مشخص می‌کنیم آیا فراخوانی توسط مدیر (admin) انجام شده یا نه.

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

مدیر تغییرناپذیر (Immutable Admin)

در قطعه کدی که پیش‌تر بررسی کردیم، مدیر (admin) به‌صورت تغییرناپذیر (immutable) تعریف شده است. این یعنی از نظر فنی، قرارداد با استاندارد ERC-1967 کاملاً هم‌راستا نیست. طبق این استاندارد، آدرس مدیر باید در یک جایگاه خاص از حافظه ذخیره شود:

0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103
یا به‌صورت معادل محاسباتی:
bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1)

برای حفظ سازگاری با ERC-1967، پراکسی Transparent Upgradeable آدرس مدیر را در همین جایگاه حافظه ذخیره می‌کند، اما عملاً از این متغیر استفاده نمی‌کند.

وجود یک آدرس در این جایگاه، به ابزارهای تحلیل زنجیره (مثل اکسپلوررها) نشان می‌دهد که این قرارداد یک پراکسی است؛ که یکی از اهداف استاندارد ERC-1967 نیز دقیقاً همین بوده است. با این حال، اگر پراکسی بخواهد در هر فراخوانی این مقدار را از حافظه بخواند، حدود ۲۱۰۰ گس به هر فراخوانی اضافه می‌شود. به همین دلیل، استفاده از یک متغیر تغییرناپذیر (immutable) برای مدیر از نظر مصرف گس به‌مراتب بهینه‌تر است.

تغییر مدیر پراکسی

اگرچه توسعه دهنده، متغیر مدیر را به‌صورت تغییرناپذیر (immutable) تعریف می‌کند، اما همچنان باید راهی برای تغییر آدرس مدیر در صورت نیاز وجود داشته باشد. در نگاه اول، این کار غیرممکن به نظر می‌رسد، چون پراکسی از متغیر immutable برای تعیین مدیر استفاده می‌کند.

اما الگوی Transparent Upgradeable Proxy برای حل این مشکل، از یک راهکار دو مرحله‌ای استفاده می‌کند. در گام نخست، به‌جای یک آدرس معمولی، یک قرارداد دیگر به نام ProxyAdmin به‌عنوان مدیر پراکسی معرفی می‌شود.

نموداری که رابطه پروکسی، پروکسی ادمین و مالک را در Transparent Upgradeable Proxy نشان می‌دهد

از آنجا که آدرس یک قرارداد هوشمند هرگز تغییر نمی‌کند، می‌توان بدون مشکل، آدرس مدیر را به‌صورت یک متغیر تغییرناپذیر (immutable) در پراکسی ذخیره کرد. این ویژگی کاملاً با معماری Transparent Upgradeable Proxy سازگار است.

در مرحله دوم، قرارداد ProxyAdmin به عنوان واسط عمل می‌کند و مالک آن به عنوان “مدیر واقعی” شناخته می‌شود. این قرارداد، فراخوانی های مالک را دریافت می‌کند و آن‌ها را به پراکسی منتقل می‌سازد. در واقع، مدیر واقعی ابتدا با ProxyAdmin ارتباط برقرار می‌کند و سپس این قرارداد، درخواست‌ها را به پراکسی منتقل می‌کند. به همین دلیل، اگر مالکیت ProxyAdmin را تغییر دهیم، در عمل فردی را که اختیار ارتقای پراکسی را دارد تغییر داده‌ایم. این ساختار باعث می‌شود مدیریت پراکسی انعطاف‌پذیر باقی بماند، بدون آنکه نیاز به بازنویسی متغیر immutable در خود پراکسی داشته باشیم.

AdminProxy

در ادامه، کد مربوط بهAdminProxyاز کتابخانه OpenZeppelin را مشاهده می‌کنید (توضیحات کامنتی حذف شده‌اند). نکته مهم این است که این قرارداد تنها یک تابع دارد: upgradeAndCall().

این تابع فقط می‌تواند متد upgradeToAndCall() را روی قرارداد پراکسی فراخوانی کند و هیچ کار دیگری انجام نمی‌دهد. این طراحی ساده و محدود باعث می‌شود کنترل ارتقای قرارداد پراکسی تنها از طریق همین مسیر مشخص انجام شود و از هرگونه دسترسی ناخواسته یا پیچیدگی اضافی جلوگیری گردد.

یک باور نادرست رایج وجود دارد که می‌گوید مدیر (admin) در الگوی Transparent Proxy نمی‌تواند از قرارداد استفاده کند، چون فراخوانی های او به‌جای اجرای تابع، به عملیات ارتقا هدایت می‌شوند. اما در واقع، مالک قرارداد AdminProxy می‌تواند بدون هیچ مشکلی از پراکسی استفاده کند؛ همانطور که دیاگرام زیر نشان می‌دهد.

در ادامه نیز خواهیم دید که یک مکانیزم مشخص برای انجام فراخوانی دلخواه به پراکسی از طریق ProxyAdmin وجود دارد. این قابلیت دقیقاً همان چیزی است که نام تابع upgradeToAndCall() به آن اشاره می‌کند.

نمودار مسیرهای فراخوانی احتمالی برای مالک و کاربر در یک transparent upgradeable proxy

غیرقابل ارتقا کردن پراکسی

اگر مالک (owner) قرارداد ProxyAdmin به آدرس صفر (address(0)) یا به یک قرارداد هوشمند دیگر تغییر پیدا کند که نتواند به‌درستی از تابع upgradeAndCall() استفاده کند یا مالکیت را تغییر دهد، در آن صورت پراکسی transparent دیگر قابل ارتقا نخواهد بود.

برای مثال، اگر مالک AdminProxy را طوری تنظیم کنیم که خودش یک قرارداد AdminProxy دیگر باشد، این وضعیت به‌وجود می‌آید. در چنین شرایطی، مسیر ارتقا مسدود می‌شود و عملاً پراکسی به حالت غیرقابل تغییر درمی‌آید. این ویژگی می‌تواند به‌عنوان یک اقدام امنیتی نهایی برای قفل کردن وضعیت قرارداد استفاده شود.

جزئیات پیاده سازی

در کتابخانه OpenZeppelin، الگوی Transparent Upgradeable Proxy با استفاده از سه قرارداد به‌صورت لایه‌لایه پیاده سازی شده است:

  1. Proxy.sol

  2. ERC1967Proxy.sol

  3. TransparentUpgradeableProxy.sol

این ساختار سلسله‌مراتبی باعث می‌شود هر بخش از عملکرد پراکسی به‌صورت جداگانه، ماژولار و قابل نگهداری باقی بماند.

پایه‌ای‌ترین قرارداد: Proxy.sol

قرارداد پایه در این ساختار، Proxy.sol است. این قرارداد با دریافت آدرس پیاده سازی (implementation)، فراخوانی‌ها را با استفاده از دستور delegatecall به آن منتقل می‌کند. تابع _implementation() که وظیفه مشخص‌کردن آدرس مقصد برای delegatecall را دارد، در Proxy.sol فقط تعریف شده اما پیاده سازی نشده است. این تابع در قرارداد فرزند یعنی ERC1967Proxy بازنویسی (override) و پیاده سازی می‌شود تا مقدار آدرس پیاده سازی را از جایگاه حافظه مرتبط با استاندارد ERC-1967 برگرداند. این طراحی اجازه می‌دهد که قرارداد پایه بدون وابستگی به جزئیات ذخیره‌سازی عمل کند و قراردادهای فرزند کنترل کامل بر مکانیزم پیاده سازی داشته باشند.

فرزند Proxy.sol: قرارداد ERC1967Proxy.sol

قرارداد ERC1967Proxy.sol از Proxy.sol ارث‌بری می‌کند و عملکردهای مرتبط با استاندارد ERC-1967 را به آن اضافه می‌کند. مهم‌ترین تغییر در این قرارداد، پیاده سازی تابع داخلی _implementation() است که در قرارداد پایه فقط به‌صورت تعریف‌شده وجود داشت. در این نسخه، این تابع مقدار آدرسی را باز می‌گرداند که در جایگاه حافظه‌ای ذخیره شده که استاندارد ERC-1967 تعیین کرده است. سازنده (constructor) این قرارداد نیز آدرس پیاده سازی را در همان جایگاه حافظه مشخص‌شده توسط ERC-1967 ذخیره می‌کند.

با این حال، پراکسی Transparent Upgradeable از این تابع استفاده نمی‌کند. در عوض، آدرس پیاده سازی را با استفاده از یک متغیر تغییرناپذیر (immutable variable) مدیریت می‌کند تا عملکرد بهینه‌تر و امنیت بالاتری داشته باشد.

فرزند ERC1967Proxy.sol: قرارداد TransparentUpgradeableProxy.sol

در نهایت، قرارداد TransparentUpgradeableProxy.sol از ERC1967Proxy.sol ارث‌بری می‌کند و منطق نهایی الگوی پراکسی شفاف را پیاده سازی می‌کند. در سازنده (constructor) این قرارداد، ابتدا یک نمونه از ProxyAdmin مستقر می‌شود (deploy)، و سپس اولین متغیر قرارداد که به‌صورت تغییرناپذیر (immutable) تعریف شده، یعنی admin، به آدرس همین ProxyAdmin اختصاص داده می‌شود.

با این کار، پراکسی می‌تواند رفتار متفاوتی برای مدیر (admin) و کاربران عادی داشته باشد. همچنین استفاده از متغیر immutable برای admin باعث بهینه شدن مصرف گس و افزایش امنیت قرارداد می‌شود، چرا که آدرس admin در لحظه استقرار ثابت و غیرقابل تغییر باقی می‌ماند.

فرض کنیم msg.sender برابر با _proxyAdmin باشد. در این حالت، فراخوانی به تابع داخلی _dispatchUpgradeToAndCall() هدایت می‌شود. اما پیش از آن، تابع _fallback() بررسی می‌کند که آیا شناسه تابع (selector) ارسال‌شده با شناسه تابع upgradeToAndCall مطابقت دارد یا نه. نکته مهم اینجاست که این «شناسه تابع» در واقع یک شناسه واقعی نیست؛ چرا که پراکسی شفاف هیچ تابع عمومی به‌جز fallback ندارد. با این حال، برای اینکه ProxyAdmin بتواند یک فراخوانی سطح بالا (high-level) از طریق اینترفیس سالیدیتی به تابع upgradeToAndCall انجام دهد، پراکسی باید داده های calldata کدگذاری‌شده مطابق ABI مربوط به upgradeToAndCall() را بپذیرد.

به یاد داشته باشید که ProxyAdmin در حال انجام یک فراخوانی اینترفیس به تابع upgradeToAndCall در داخل پراکسی است، حتی با اینکه پراکسی عملاً هیچ تابع عمومی به جز fallback ندارد. در ادامه کد ProxyAdmin نمایش داده می‌شود تا نحوه انجام این فراخوانی دقیق‌تر بررسی شود:

قطعه کد پروکسی ادمین با تابع upgradeToAndCall هایلایت شده

در ادامه، ویدیویی نمایش داده می‌شود که هر سه بخش کد را به‌صورت کنار هم نشان می‌دهد و دقیقاً توضیح می‌دهد که چگونه قراردادهای مختلف در زنجیره ارث‌بری — یعنی Proxy، ERC1967Proxy و TransparentUpgradeableProxy — با یکدیگر تعامل دارند:

چرا از upgradeToAndCall() استفاده می‌کنیم و نه فقط upgradeTo()؟

وقتی می‌خواهیم قرارداد پیاده سازی (implementation) را ارتقا دهیم، این امکان وجود دارد که هم‌زمان با ارتقا، یک فراخوانی هم به قرارداد جدید انجام دهیم — به شکلی که انگار ProxyAdmin فرستنده (msg.sender) تراکنش بوده و آن فراخوانی از طریق delegatecall به قرارداد جدید منتقل شده است، درست مانند یک تعامل عادی با پراکسی. اما این رفتار درون fallback اتفاق نمی‌افتد، چون فراخوانی های مربوط به ProxyAdmin به منطق ارتقا هدایت می‌شوند، نه به مسیر معمول delegatecall.

به همین دلیل از تابع upgradeToAndCall() استفاده می‌شود. این تابع نه‌تنها آدرس قرارداد پیاده سازی را به‌روزرسانی می‌کند، بلکه بلافاصله پس از آن، یک فراخوانی (call) به قرارداد جدید نیز انجام می‌دهد — برای مثال، جهت مقداردهی اولیه (initialize) یا تنظیم پارامترهای مورد نیاز.

در کتابخانه ERC1967Utils.sol که در ساختار TransparentUpgradeableProxy نیز استفاده می‌شود، این قابلیت از طریق یک تابع داخلی فراهم می‌شود. این تابع، جایگاه حافظه مربوط به آدرس پیاده سازی را به‌روزرسانی می‌کند و امکان مدیریت استاندارد و ایمن فرآیند ارتقا را فراهم می‌سازد.

جزئیات فنی تابع upgradeToAndCall()

تابع upgradeToAndCall() تنها زمانی یک delegatecall به قرارداد پیاده سازی انجام می‌دهد که طول داده (data.length) بیشتر از صفر باشد. این یعنی اگر هیچ داده‌ای همراه درخواست ارسال نشود، صرفاً آدرس پیاده سازی به‌روزرسانی می‌شود و فراخوانی‌ای به آن صورت نمی‌گیرد.

اما زمانی که داده‌ای وجود داشته باشد، این تابع در همان تراکنش، پس از ارتقای پیاده سازی، بلافاصله یک delegatecall به قرارداد جدید انجام می‌دهد. در واقع، رفتار این تابع دقیقاً مشابه حالتی است که ProxyAdmin پراکسی را با یک calldata خاص فراخوانی کند، و سپس پراکسی نیز آن فراخوانی را با delegatecall به قرارداد پیاده سازی منتقل کند.

نتیجه این طراحی چیست؟

به این ترتیب، قرارداد ProxyAdmin می‌تواند هرگونه فراخوانی دلخواه (arbitrary call) را از طریق پراکسی به قرارداد پیاده سازی انجام دهد. این قابلیت در بسیاری از پروژه‌های پیشرفته که در حوزه آموزش برنامه نویسی و طراحی الگوهای پیشرفته در سالیدیتی مطرح هستند،

نکته مهم این است که تابع upgradeToAndCall() نیازی ندارد که قرارداد جدید، واقعاً متفاوت از نسخه قبلی باشد. می‌توان حتی به همان نسخه قبلی “ارتقا” داد. این ویژگی، آزادی عمل زیادی به توسعه دهنده می‌دهد — برای مثال در اجرای مجدد توابع initialize.

در این فرایند، از دید قرارداد پراکسی، msg.sender همان ProxyAdmin خواهد بود. این یعنی تمام فراخوانی‌ها از طرف ProxyAdmin تلقی می‌شوند، نه کاربر عادی.

آیا این یک مشکل امنیتی است؟

خیر، این موضوع مشکلی ایجاد نمی‌کند. زیرا ProxyAdmin از ابتدا دارای دسترسی کامل برای تغییر قرارداد پیاده سازی بوده است. بنابراین امکان فراخوانی مستقیم توابع از طریق پراکسی هم‌راستا با سطح دسترسی آن است. مالک ProxyAdmin به‌طور کامل کنترل قرارداد پراکسی را در اختیار دارد.

تنها محدودیت

تابع _setImplementation که مسئول ثبت قرارداد پیاده سازی جدید است، بررسی می‌کند که آدرس ارسالی حاوی کد باشد. به‌عبارت دیگر، پراکسی اجازه ندارد به قراردادی ارتقا پیدا کند که هیچ بایت‌کدی نداشته باشد (یعنی یک آدرس خالی یا اشتباه). این بررسی با چک کردن code.length > 0 انجام می‌شود.

خلاصه الگوی Transparent Upgradeable Proxy

  • الگوی Transparent Upgradeable Proxy با هدف جلوگیری از تداخل در شناسه توابع (function selector clashing) میان پراکسی و قرارداد پیاده سازی ایجاد شده است.
  • در این ساختار، تنها تابع عمومی موجود در قرارداد پراکسی، همان تابع fallback است. تمامی فراخوانی ها از سوی آدرس هایی غیر از مدیر (admin) به‌صورت delegatecall به قرارداد پیاده سازی منتقل می‌شوند.
  • امکان ارتقای قرارداد تنها از طریق همین تابع fallback و فقط توسط مدیر (admin) وجود دارد.
  • برای کاهش مصرف گس، آدرس مدیر به‌صورت یک متغیر تغییرناپذیر (immutable) در پراکسی ذخیره می‌شود. توسعه دهنده، برای رعایت سازگاری با استاندارد ERC-1967، آدرس مدیر را در جایگاه حافظه تعیین‌شده توسط این استاندارد نیز ثبت می‌کند — اگرچه پراکسی هیچ‌گاه این مقدار را نمی‌خواند.
  • از آنجایی که آدرس admin قابل تغییر نیست، یک قرارداد هوشمند به نام AdminProxy به‌عنوان مدیر پراکسی تعیین می‌شود. این قرارداد تنها یک تابع عمومی به نام upgradeAndCall() دارد که فقط توسط مالک AdminProxy قابل اجراست. آدرس مالک AdminProxy قابل تغییر است و این یعنی می‌توان کنترل ارتقای پراکسی را با تغییر مالکیت آن جابه‌جا کرد. در نتیجه، هر کسی که مالک AdminProxy باشد، می‌تواند آدرس قرارداد پیاده سازی در TransparentUpgradeableProxy را به‌روزرسانی کند.
5/5 - (1 امتیاز)

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

دوره آموزش طراحی وب سایت مدرسه با PHP و MySql
  • انتشار: ۱۵ تیر ۱۴۰۴

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

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

مشاهده همه

نظرات

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