آنچه در این مقاله می‌خوانید [پنهان‌سازی]

برای ایمن نگه داشتن داده‌ها در Storage Layout قراردادهای Upgradeable، قانون طلایی این است: فقط متغیرهای جدید را در انتهای چیدمان ذخیره‌سازی اضافه کنید و هرگز ترتیب، نوع یا ارث‌بری والدها را طوری تغییر ندهید که اسلات‌های قبلی جابه‌جا یا بازنویسی شوند. پیش از هر ارتقا، چیدمان را با ابزارهای اعتبارسنجی بررسی کنید و از الگوی __gap برای رزرو فضا بهره ببرید.

چرا Storage Layout در قراردادهای Upgradeable حیاتی است؟

در الگوی Proxy، وضعیت (state) داخل پروکسی ذخیره می‌شود و منطق در پیاده‌سازی (implementation) ارتقا می‌یابد. اجرای delegatecall باعث می‌شود کد نسخه جدید روی همان اسلات‌های ذخیره‌سازی نسخه قبلی بنویسد. هر تغییر ناسازگار در چیدمان ذخیره‌سازی مانند جابه‌جایی یا تغییر نوع فیلدها، می‌تواند داده‌های قدیمی را خراب کند یا به خواندن/نوشتن اشتباه منجر شود.

اصول ایمن طراحی Storage Layout

  • فقط افزودن در انتها: متغیرهای state را فقط به انتهای آخرین قرارداد در زنجیره ارث‌بری اضافه کنید.
  • نوع و ترتیب را حفظ کنید: نوع، ترتیب و تعداد متغیرهای موجود را تغییر ندهید و آن‌ها را حذف نکنید.
  • ساختارها و آرایه‌ها را با احتیاط تغییر دهید: افزودن فیلد به struct فقط وقتی ایمن است که struct فقط در mapping استفاده شود، نه در آرایه.
  • enum را جابه‌جا نکنید: ترتیب مقادیر enum را تغییر ندهید؛ فقط در انتها عضو جدید اضافه کنید.
  • الگوی __gap: در هر قرارداد قابل ارتقا یک آرایه رزرو مانند uint256[50] __gap; نگه دارید تا در ارتقاهای بعدی فضای امن داشته باشید.
  • ارث‌بری را پایدار نگه دارید: ترتیب والدها را تغییر ندهید و والد جدید را فقط در انتهای خطی‌سازی ارث‌بری قرار دهید.
  • constructor ممنوع، initializer لازم: از Initializable استفاده کنید و مقداردهی را با initializer/reinitializer انجام دهید.
  • اعتبارسنجی خودکار: پیش از ارتقا، چیدمان را با ابزارهای شناخته‌شده بررسی کنید.

اشتباهات رایج که نباید انجام دهید

۱) جابه‌جایی یا حذف متغیرهای state

جابجا کردن فیلدها یا حذف یک فیلد، اندیس اسلات‌ها را تغییر می‌دهد و داده‌های قبلی را نابود می‌کند. اگر فیلدی دیگر استفاده نمی‌شود، آن را نگه دارید و مقدارش را نادیده بگیرید.

۲) تغییر نوع (type) حتی با «سازگار» به‌نظر رسیدن

تبدیل uint128 به uint256 ممکن است بزرگ‌تر به نظر برسد، اما چیدمان بسته‌بندی (packing) و اندازه اسلات را تغییر می‌دهد. نتیجه می‌تواند هم‌پوشانی با فیلد بعدی یا خواندن نادرست باشد. نوع را تغییر ندهید.

۳) افزودن فیلد جدید در میانه قرارداد یا پیش از __gap

هر افزودنی باید در انتهای زنجیره ارث‌بری و پیش از __gap همان قرارداد انجام شود. افزودن وسط کار، همه اسلات‌های بعدی را جابه‌جا می‌کند.

۴) تغییر ترتیب والدها یا وارد کردن والد جدید در ابتدای خطی‌سازی

ترتیب والدها تعیین‌کننده ترتیب اسلات‌های ذخیره‌سازی است. افزودن یا جابه‌جایی والدها در بالا، چیدمان قبلی را به‌هم می‌زند. اگر مجبورید والد جدید اضافه کنید، آن را انتهایی کنید و والد جدید فقط شامل __gap یا state جدیدی باشد که در انتها قرار می‌گیرد.

۵) دست‌کاری struct بدون توجه به محل استفاده

  • struct در mapping: افزودن فیلد جدید (در انتها) معمولا ایمن است، زیرا هر کلید فضای جدایی دارد.
  • struct در آرایه: افزودن فیلد خطرناک است، چون اندازه عنصر عوض می‌شود و اندیس‌گذاری عناصر قبلی از هم می‌پاشد.

۶) بازچینی مقادیر enum

تغییر ترتیب اعضای enum مقدار عددی پشت‌صحنه را عوض می‌کند. فقط در انتها عضو جدید اضافه کنید.

۷) استفاده از constructor یا immutable در منطق قابل ارتقا

در الگوی Proxy، constructor پیاده‌سازی اجرا نمی‌شود. تنظیم state در constructor از دست می‌رود. از Initializable استفاده کنید. متغیرهای immutable در بایتکد ثابت می‌شوند و با الگوی ارتقا اغلب هم‌خوان نیستند.

الگوی مطمئن افزودن state با استفاده از __gap

در الگوی OpenZeppelin، هر قرارداد قابل ارتقا یک __gap رزرو می‌کند تا در ارتقاهای بعدی بدون جابه‌جایی فیلدهای موجود، فضا داشته باشید.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";

contract VaultV1 is Initializable, UUPSUpgradeable {
    // --- V1 state ---
    address public owner;      // slot 0
    uint256 public total;      // slot 1
    uint64  public feeBps;     // slot 2 (packed with future small vars if any)

    // Reserve storage space to allow for layout changes in the future.
    uint256[47] private __gap; // keep at the end

    function initialize(address _owner) public initializer {
        owner = _owner;
        total = 0;
        feeBps = 0;
    }

    function _authorizeUpgrade(address) internal override onlyOwner {}

    modifier onlyOwner() {
        require(msg.sender == owner, "not owner");
        _;
    }
}

نسخه صحیح V2، فیلدهای جدید را فقط قبل از __gap و بعد از فیلدهای موجود اضافه می‌کند و طول __gap را به تناسب کاهش می‌دهد.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";

contract VaultV2 is Initializable, UUPSUpgradeable {
    // --- V1 state (بدون تغییر ترتیب/نوع/حذف) ---
    address public owner;      // slot 0
    uint256 public total;      // slot 1
    uint64  public feeBps;     // slot 2

    // --- V2 additions (تنها در انتها اضافه می‌شوند) ---
    address public treasury;   // slot 3
    bool    public paused;     // slot 4 (packed)

    // به همان نسبت، اندازه gap را کاهش دهید
    uint256[45] private __gap;

    function reinitializeV2(address _treasury) public reinitializer(2) {
        treasury = _treasury;
        paused = false;
    }

    function _authorizeUpgrade(address) internal override onlyOwner {}

    modifier onlyOwner() {
        require(msg.sender == owner, "not owner");
        _;
    }
}

نمونه اشتباه V2 که نباید انجام دهید: تغییر نوع یا جابه‌جایی فیلدهای قدیمی.

// ❌ بد: نوع feeBps عوض شده و یک فیلد جدید قبل از فیلدهای قبلی آمده است
contract VaultV2_Broken {
    uint256 public total;      // قبلا در slot 1 بود؛ حالا ممکن است به slot 0 برود!
    address public owner;      // قبلا slot 0 بود؛ این جابه‌جایی داده را خراب می‌کند
    uint256 public feeBps;     // تغییر نوع از uint64 به uint256 مخرب است
    // ...
}

struct در mapping ایمن‌تر از struct در آرایه

افزودن فیلد به struct وقتی در mapping استفاده شده باشد معمولا امن است، اما برای آرایه‌ها خطرناک است.

pragma solidity ^0.8.20;

contract UsersV1 {
    struct User { uint128 balance; } // اندازه اولیه

    mapping(address => User) internal users; // ایمن‌تر برای افزودن فیلد در آینده
    User[] internal list; // افزودن فیلد در آینده ناایمن است (اندازه عنصر تغییر می‌کند)

    uint256[49] private __gap;
}

// V2 - ایمن برای mapping، ناایمن برای array
contract UsersV2 is UsersV1 {
    struct User { 
        uint128 balance;
        uint128 debt;   // افزودن در انتها
    }
    // users[address].debt اکنون کار می‌کند (اسلات‌های جدید)
    // اما list[i] اکنون اندازه متفاوتی دارد و عناصر قبلی به‌هم می‌ریزند ❌
}

بررسی و اعتبارسنجی Storage Layout قبل از ارتقا

روش پیشنهادی با OpenZeppelin Upgrades (Hardhat)

افزودن افزونه ارتقا باعث می‌شود ناسازگاری چیدمان پیش از دیپلوی شناسایی شود.

// hardhat.config.js
require('@openzeppelin/hardhat-upgrades');
require('@nomicfoundation/hardhat-toolbox');

module.exports = {
  solidity: {
    version: "0.8.20",
    settings: { optimizer: { enabled: true, runs: 200 } }
  }
};
// scripts/deploy.js
const { ethers, upgrades } = require("hardhat");

async function main() {
  const VaultV1 = await ethers.getContractFactory("VaultV1");
  const v1 = await upgrades.deployProxy(VaultV1, [/* owner */], { initializer: 'initialize' });
  await v1.waitForDeployment();
  console.log("Proxy deployed:", await v1.getAddress());
}

main();
// scripts/upgrade.js
const { ethers, upgrades } = require("hardhat");

async function main() {
  const proxy = "0xYourProxyAddress";
  const VaultV2 = await ethers.getContractFactory("VaultV2");

  // این فراخوانی قبل از دیپلوی واقعی، چیدمان را اعتبارسنجی می‌کند و در صورت ناسازگاری خطا می‌دهد
  await upgrades.upgradeProxy(proxy, VaultV2);

  console.log("Upgraded to V2");
}

main();

اگر تغییری ناسازگار باشد (مثلا جابه‌جایی فیلدها)، مرحله upgrade خطا می‌دهد و ارتقا متوقف می‌شود.

بازبینی چیدمان با Foundry

forge inspect VaultV1 storage-layout
forge inspect VaultV2 storage-layout

خروجی دو دستور را مقایسه کنید. افزوده‌ها باید فقط در انتهای چیدمان ظاهر شوند و هیچ اسلات قبلی تغییر نکند.

بررسی سریع با خواندن اسلات‌های حساس

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

// نمونه ساده با ethers.js
const beforeOwner = await proxy.owner();
// ... perform upgrade ...
const afterOwner = await proxy.owner();
if (beforeOwner !== afterOwner) throw new Error("Owner slot corrupted");

نکات تکمیلی و ریزه‌کاری‌ها

  • تغییر نام متغیر تاثیری بر چیدمان ندارد، اما بهتر است برای ردیابی تاریخچه از نام‌گذاری باثبات استفاده کنید.
  • ثابت‌ها (constant) و انواع تعریف-در-کد (immutable) در storage قرار نمی‌گیرند؛ اما immutable برای قراردادهای قابل ارتقا معمولا مناسب نیست.
  • EIP-1967 اسلات‌های ثابت پروکسی (implementation/admin) را مشخص می‌کند؛ اگر از کتابخانه‌های استاندارد استفاده می‌کنید با اسلات‌های شما تداخل ندارند.
  • روی بسته‌بندی (packing) حساب باز نکنید؛ افزودن نوع‌های کوچک در میانه یا تغییر نوع می‌تواند هم‌پوشانی ناخواسته بسازد.

چک لیست قبل از هر Upgrade

  1. لیست کامل state فعلی را استخراج کنید و به عنوان مرجع قفل کنید.
  2. متغیرهای جدید را فقط در انتهای قراردادی که state را تعریف کرده اضافه کنید و __gap را به تناسب کاهش دهید.
  3. نوع، ترتیب و وجود فیلدهای قبلی را بدون تغییر نگه دارید؛ struct/enum را فقط مطابق قواعد ایمنی اصلاح کنید.
  4. ترتیب والدها را تغییر ندهید؛ والد جدید را فقط انتهایی کنید.
  5. اعتبارسنجی خودکار را در محیط توسعه/CI اجرا کنید و ارتقا را بدون خطا نهایی نکنید.
  6. پس از ارتقا، چند مقدار کلیدی را بخوانید و با قبل مقایسه کنید.

سوالات متداول

آیا می‌توانم یک متغیر قبلی را حذف یا جایگزین کنم؟

خیر. حذف یا جایگزینی متغیر state اسلات‌ها را جابه‌جا می‌کند و داده را خراب می‌کند. آن را نگه دارید و استفاده نکنید.

افزودن فیلد به struct همیشه امن است؟

خیر. فقط وقتی struct در mapping استفاده می‌شود معمولا امن است. اگر struct در آرایه استفاده شده، افزودن فیلد خطرناک است.

آیا تغییر visibility (public/private) روی چیدمان اثر دارد؟

خیر. visibility روی چیدمان storage اثر مستقیم ندارد، اما ABI و سازگاری رابط را در نظر بگیرید.

اگر مجبور به تغییر بزرگ در state باشم چه کنم؟

الگوی مهاجرت داده بسازید: قرارداد جدید با چیدمان تازه مستقر کنید و داده‌ها را مرحله‌ای منتقل کنید، سپس به آدرس جدید مهاجرت دهید.

گام بعدی

اعتبارسنجی Storage Layout را به خط لوله CI اضافه کنید و پیش از هر ارتقا، مقایسه خودکار چیدمان را الزامی کنید. اگر به الگوهای ارث‌بری، __gap و اعتبارسنجی‌ها نیاز دارید با تمرین ساخت قراردادهای واقعی، «دوره آموزش سالیدیتی» می‌تواند مسیر یادگیری شما را کامل‌تر کند.