قراردادهای هوشمند در اتریوم هنگامی که راهاندازی میشوند، قابل تغییر نیستند. این عدم تغییرپذیری اعتماد و اعتمادپذیری را فراهم میکند، به طوری که منطق آن نمیتواند پس از راهاندازی قرارداد توسط دیگران دخالت شود. با این حال، این وضعیت مشکلاتی را برای قابلیت تعمیر و ارتقا قراردادها ایجاد میکند، زیرا باگها نمیتوانند تصحیح شوند و بهبودها تنها با راهاندازی دوباره یک قرارداد جدید امکانپذیر است.
برای دور زدن این مشکل، مفهوم الگوهای پروکسی در قراردادهای هوشمند معرفی شد. این مقاله دو نوع الگوی پروکسی مورد استفاده در قراردادهای هوشمند Solidity را بررسی میکند — پروکسی ثابت و پروکسی پویا — ارائه مثالهای پیادهسازی برای هر دو، همراه با ملاحظات امنیتی مهم.
پیشنهاد مطالعه: بلاکچین به زبان ساده
الگوی پروکسی ثابت به ما اجازه میدهد تا منطق یک قرارداد هوشمند را ارتقا دهیم در حالی که وضعیت قرارداد حفظ میشود. در این الگو، قرارداد پروکسی ثابت است و تماسهای تابع به قرارداد پیادهسازی ارسال میشود که حاوی منطق است و قابل ارتقا است.
قرارداد پروکسی آدرس قرارداد پیادهسازی را ذخیره میکند. برای ارتقا قرارداد، نسخه جدیدی از قرارداد پیادهسازی را راهاندازی میکنیم و مرجع در قرارداد پروکسی به منظور اشاره به آدرس قرارداد پیادهسازی جدید بهروزرسانی میشود.
در زیر یک مثال ساده آورده شده است:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract FixedProxy {
address internal _implementation;
function upgradeTo(address newImplementation) public {
_implementation = newImplementation;
}
fallback() external payable {
address implementation = _implementation;
require(implementation != address(0));
assembly {
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize())
let result := delegatecall(gas(), implementation, ptr, calldatasize(), 0, 0)
let size := returndatasize()
returndatacopy(ptr, 0, size)
switch result
case 0 { revert(ptr, size) }
default { return(ptr, size) }
}
}
}
تابع فالبک تماس را به قرارداد پیادهسازی در قرارداد پروکسی ثابت فوق هدایت میکند. تابع delegatecall کد را در آدرس قرارداد پیادهسازی اجرا میکند در محیط قرارداد پروکسی، حافظه آن را حفظ میکند.
قراردادهای هوشمند پروکسی پویا بر خلاف الگوی پروکسی ثابت، الگوی پروکسی پویا اجازه میدهد تا قرارداد پروکسی و قرارداد پیادهسازی تغییر کنند. در این الگو، قرارداد پروکسی قابل ارتقا است، که تغییراتی در ساختار ذخیرهسازی و مکانیسمهای ارتقا در طول زمان را ممکن میسازد.
یک قرارداد پروکسی پویا به یک قرارداد ثبت نیاز دارد تا ورژنهای قرارداد پروکسی و منطق را مدیریت کند. در زیر یک مثال آورده شده است:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Registry {
address public logicContract;
address public proxyContract;
function upgradeLogic(address newLogicContract) public {
logicContract = newLogicContract;
}
function upgradeProxy(address newProxyContract) public {
proxyContract = newProxyContract;
}
}
contract DynamicProxy {
address public registry;
constructor(address _registry) {
registry = _registry;
}
fallback() external payable {
Registry r = Registry(registry);
address implementation = r.logicContract();
require(implementation != address(0));
assembly {
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize())
let result := delegatecall(gas(), implementation, ptr, calldatasize(), 0, 0)
let size := returndatasize()
returndatacopy(ptr, 0, size)
switch result
case 0 { revert(ptr, size) }
default { return(ptr, size) }
}
}
}
قرارداد ثبت آدرسهای فعلی قرارداد پروکسی پویا و منطق را ذخیره میکند و اجازه به آنها برای بهروزرسانی میدهد. قرارداد پروکسی پویا شامل یک تابع فالبک میباشد که مشابه قرارداد پروکسی ثابت است، اما به جای نگهداری مستقیم آدرس قرارداد منطق، به قرارداد ثبت مراجعه میکند تا آدرس فعلی قرارداد منطق را بیابد.
قراردادهای پروکسی و تماسهای delegate عناصر اساسی در قراردادهای قابل ارتقا در Solidity هستند. آنها میتوانند به شما کمک کنند تا از هزینه راهاندازی یک قرارداد جدید هر بار که میخواهید ویژگیهای جدیدی اضافه کنید یا باگها را برطرف کنید، جلوگیری کنند. با این حال، استفاده از آنها همچنین با ملاحظات امنیتی قابل توجهی همراه است. در ادامه چند موردی که همیشه باید به آنها توجه داشته باشید:
اگر قرارداد پروکسی تماس را به یک قرارداد غیرقابل اعتماد اعطا کند، حملات Re-Entrancy ممکن است رخ دهد. اگر تابع قرارداد اعطا شده تماس خارجی با یک قرارداد دیگر برقرار کند، یک حملهکننده ممکن است قرارداد خوانده شده را به قرارداد فراخواننده برگرداند قبل از اینکه تماس اولیه پایان یابد. این میتواند باعث تغییر ناگهانی وضعیت قرارداد فراخواننده شود.
تداخلهای انتخابکننده تابع: انتخابکننده تابع اولین چهار بایت از هش keccak256 امضای تابع است. در Solidity، انتخابکننده تابع برای تعیین اینکه کدام تابع را باید فراخوانی کرد، استفاده میشود. اگر دو تابع از قراردادهای مختلف دارای همان انتخابکننده تابع باشند، رفتار غیرمنتظره ممکن است رخ دهد زیرا تابع اشتباه ممکن است فراخوانی شود.
هنگام استفاده از تماس delegate، قرارداد فراخوانیشده دسترسی به ذخیرهسازی قرارداد فراخواننده را دارد. بنابراین، اگر ترتیب متغیرهای ذخیرهسازی در قرارداد فراخوانی شده با ترتیب در قرارداد پروکسی همخوانی نداشته باشد، رفتار غیرمنتظره یا حملات مخرب ممکن است رخ دهد.
در Solidity، برخی از متغیرها به عنوان ثابت تنظیم شدهاند، که به معنای آن است که تنها میتوانند هنگام ایجاد قرارداد تنظیم شوند و نمیتوانند بعداً تغییر کنند. با این حال، هنگام استفاده از تماسهای delegate، متغیرهای ثابت قرارداد فراخوانیشده قابل دسترسی نیستند و مقادیر همیشه به عنوان اگر آنها مقداردهی نشدهاند، خواهند بود.
یک تماس delegate باعث اجرای سازنده قرارداد فراخوانیشده نمیشود، و خودنابود به دلیل انتظار شما کار نمیکند. اگر یک قرارداد بر روی برخی از کد تنظیمات خود در سازنده خود وابسته باشد یا از خودنابود برای اهداف امنیتی استفاده کند، این ویژگیها زمانی که از تماسهای delegate استفاده میشود، به درستی کار نخواهند کرد.
از آنجایی که تماس delegate در محیط قرارداد فراخواننده اجرا میشود، msg.sender و msg.value از قراردادهای فراخواننده حفظ میشوند. اگر به درستی انجام نشود، این ممکن است منجر به مشکلات امنیتی پتانسیلی شود
هرچند مدلهای پروکسی ثابت و پروکسی پویا اساسی برای قراردادهای هوشمند قابل ارتقا را فراهم میکنند، اما چالشها و ملاحظات جدیدی را نیز معرفی میکنند. برخی از این ملاحظات پیشرفته عبارتند از:
پیشنهاد ویژه: آموزش رایگان سالیدیتی
با توجه به پیچیدگیها و تکراری بودن چالشهایی که وجود دارند، اغلب انتظار میرود که چندین کتابخانه Ethereum سعی دارند فرایند راهاندازی و مدیریت قراردادهای قابل ارتقا را آسانتر کنند. دو مثال معروف، افزونههای Upgrades از OpenZeppelin و افزونه Upgrades از Truffle هستند.
این ابزارها امکانات متنوعی ارائه میدهند مانند مدیریت خودکار نسخههای قرارداد شما، مدیریت راهاندازیها، بررسی تداخلهای ذخیرهسازی و غیره.
در نهایت، به یاد داشته باشید که ارتقاپذیری تنها در برخی موارد مطلوب است. وعده از ابتدایی بودن و قابل پیشبینی بودن یکی از ستونهای اصلی بلاکچین و قراردادهای هوشمند است. توانایی ارتقا قراردادها انعطاف بسیاری را به همراه میآورد، اما این امر نقطهای از مرکزیت و آسیبپذیری محتمل را نیز به همراه دارد.
به عنوان یک توسعهدهنده، حائز اهمیت است که توازن دقیقی را برقرار کنید و در نظر داشته باشید که آیا ارتقاپذیری برای قرارداد شما لازم است و چگونه ممکن است بر مدل اعتماد آن تأثیر بگذارد. گاهی اوقات، یک سری قراردادهای ابداعناپذیر با مکانیزمهای تعاملی بهتر ممکن است بهتر از یک قرارداد کاملاً قابل ارتقا خدمت کند.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// Transparent upgradeable proxy pattern
contract CounterV1 {
uint public count;
function inc() external {
count += 1;
}
}
contract CounterV2 {
uint public count;
function inc() external {
count += 1;
}
function dec() external {
count -= 1;
}
}
contract BuggyProxy {
address public implementation;
address public admin;
constructor() {
admin = msg.sender;
}
function _delegate() private {
(bool ok, ) = implementation.delegatecall(msg.data);
require(ok, "delegatecall failed");
}
fallback() external payable {
_delegate();
}
receive() external payable {
_delegate();
}
function upgradeTo(address _implementation) external {
require(msg.sender == admin, "not authorized");
implementation = _implementation;
}
}
contract Dev {
function selectors() external view returns (bytes4, bytes4, bytes4) {
return (
Proxy.admin.selector,
Proxy.implementation.selector,
Proxy.upgradeTo.selector
);
}
}
contract Proxy {
// All functions / variables should be private, forward all calls to fallback
// -1 for unknown preimage
// 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
bytes32 private constant IMPLEMENTATION_SLOT =
bytes32(uint(keccak256("eip1967.proxy.implementation")) - 1);
// 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103
bytes32 private constant ADMIN_SLOT =
bytes32(uint(keccak256("eip1967.proxy.admin")) - 1);
constructor() {
_setAdmin(msg.sender);
}
modifier ifAdmin() {
if (msg.sender == _getAdmin()) {
_;
} else {
_fallback();
}
}
function _getAdmin() private view returns (address) {
return StorageSlot.getAddressSlot(ADMIN_SLOT).value;
}
function _setAdmin(address _admin) private {
require(_admin != address(0), "admin = zero address");
StorageSlot.getAddressSlot(ADMIN_SLOT).value = _admin;
}
function _getImplementation() private view returns (address) {
return StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value;
}
function _setImplementation(address _implementation) private {
require(_implementation.code.length > 0, "implementation is not contract");
StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value = _implementation;
}
// Admin interface //
function changeAdmin(address _admin) external ifAdmin {
_setAdmin(_admin);
}
// 0x3659cfe6
function upgradeTo(address _implementation) external ifAdmin {
_setImplementation(_implementation);
}
// 0xf851a440
function admin() external ifAdmin returns (address) {
return _getAdmin();
}
// 0x5c60da1b
function implementation() external ifAdmin returns (address) {
return _getImplementation();
}
// User interface //
function _delegate(address _implementation) internal virtual {
assembly {
// Copy msg.data. We take full control of memory in this inline assembly
// block because it will not return to Solidity code. We overwrite the
// Solidity scratch pad at memory position 0.
// calldatacopy(t, f, s) - copy s bytes from calldata at position f to mem at position t
// calldatasize() - size of call data in bytes
calldatacopy(0, 0, calldatasize())
// Call the implementation.
// out and outsize are 0 because we don't know the size yet.
// delegatecall(g, a, in, insize, out, outsize) -
// - call contract at address a
// - with input mem[in…(in+insize))
// - providing g gas
// - and output area mem[out…(out+outsize))
// - returning 0 on error (eg. out of gas) and 1 on success
let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
// Copy the returned data.
// returndatacopy(t, f, s) - copy s bytes from returndata at position f to mem at position t
// returndatasize() - size of the last returndata
returndatacopy(0, 0, returndatasize())
switch result
// delegatecall returns 0 on error.
case 0 {
// revert(p, s) - end execution, revert state changes, return data mem[p…(p+s))
revert(0, returndatasize())
}
default {
// return(p, s) - end execution, return data mem[p…(p+s))
return(0, returndatasize())
}
}
}
function _fallback() private {
_delegate(_getImplementation());
}
fallback() external payable {
_fallback();
}
receive() external payable {
_fallback();
}
}
contract ProxyAdmin {
address public owner;
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
function getProxyAdmin(address proxy) external view returns (address) {
(bool ok, bytes memory res) = proxy.staticcall(abi.encodeCall(Proxy.admin, ()));
require(ok, "call failed");
return abi.decode(res, (address));
}
function getProxyImplementation(address proxy) external view returns (address) {
(bool ok, bytes memory res) = proxy.staticcall(
abi.encodeCall(Proxy.implementation, ())
);
require(ok, "call failed");
return abi.decode(res, (address));
}
function changeProxyAdmin(address payable proxy, address admin) external onlyOwner {
Proxy(proxy).changeAdmin(admin);
}
function upgrade(address payable proxy, address implementation) external onlyOwner {
Proxy(proxy).upgradeTo(implementation);
}
}
library StorageSlot {
struct AddressSlot {
address value;
}
function getAddressSlot(
bytes32 slot
) internal pure returns (AddressSlot storage r) {
assembly {
r.slot := slot
}
}
}
contract TestSlot {
bytes32 public constant slot = keccak256("TEST_SLOT");
function getSlot() external view returns (address) {
return StorageSlot.getAddressSlot(slot).value;
}
function writeSlot(address _addr) external {
StorageSlot.getAddressSlot(slot).value = _addr;
}
}