رمزگذاری ABI

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

در این راهنما، نحوه تفسیر داده‌های رمزگذاری‌شده ABI را توضیح می‌دهیم، روش محاسبه آن را بررسی می‌کنیم، و ارتباط بین امضای تابع (function signature) و ساختار رمزگذاری ABI را نشان می‌دهیم.

بیایید وارد جزئیات شویم…

استفاده از abi.encodeWithSignature و فراخوانی در سطح پایین در سالیدیتی

فرض کنید قصد داریم از طریق یک فراخوانی سطح پایین (low-level call) تابع عمومی foo(uint256 x) را در یک قرارداد دیگر اجرا کنیم و مقدار 5 را به عنوان آرگومان وارد کنیم. در این حالت، از دستور زیر استفاده می کنیم:

اگر بخواهیم محتوای واقعی داده ای که با abi.encodeWithSignature("foo(uint256)", (5)) تولید می شود را مشاهده کنیم، می توانیم تابع زیر را بنویسیم:
این تابع، رشته رمزگذاری‌شده زیر را به‌عنوان خروجی بازمی‌گرداند:
هدف اصلی این مقاله آن است که نحوه تفسیر و درک دقیق چنین داده‌هایی را یاد بگیریم و بتوانیم معنی هر بخش از این رشته را مشخص کنیم.

اجزای اصلی یک فراخوانی تابع رمزگذاری شده به روش ABI

فراخوانی تابعی که با روش ABI رمزگذاری می‌شود، از دو بخش اصلی تشکیل می‌گیرد: شناسه تابع (Function Selector) و آرگومان های رمزگذاری شده. اگر تابع دارای ورودی باشد، سیستم این آرگومان ها را رمزگذاری کرده و به شناسه تابع اضافه می‌کند.

امضای تابع (Function Signature)

امضای تابع از ترکیب نام تابع و نوع داده آرگومان ها (بدون فاصله) به‌دست می‌آید.

برای مثال، امضای تابع زیر را در نظر بگیرید:

به صورت زیر نوشته می شود:

چند نکته مهم وجود دارد:

  • نوع داده ها باید به صورت کامل نوشته شوند. مثلاً باید از uint256 استفاده کنید، نه uint.

  • نام متغیرها (مانند _to و amount) در امضای تابع نقشی ندارند.

  • نباید هیچ فاصله ای در رشته امضای تابع قرار دهید. برای مثال، transfer(address, uint256) ساختار درستی ندارد.

طبق مستندات Solidity، هنگام محاسبه امضای تابع باید به چند مورد خاص توجه کنید:

  • باید ساختارها (Structs) را مانند Tuple در نظر بگیرید.

  • باید آدرس های قابل پرداخت (payable)، اینترفیس ها (interfaces) و انواع قرارداد (contract types) را به صورت address در نظر بگیرید.

  • باید اصلاح کننده های حافظه مانند memory و calldata را نادیده بگیرید.

  • باید نوع enum را معادل uint8 در نظر بگیرید.

  • باید نوع تعریف شده توسط کاربر (user-defined type) را معادل نوع پایه ای آن پردازش کنید.

شناسه تابع (Function Selector)

function selector در سالیدیتی، از اولین ۴ بایت هش Keccak-256 امضای تابع به دست می آید. سالیدیتی از این شناسه برای شناسایی و تفکیک توابع استفاده می کند.

برای مثال، هش Keccak-256 مربوط به امضای تابع transfer(address,uint256) که در بخش قبل بررسی کردیم، به صورت زیر است:

اما تنها ۴ بایت ابتدایی این هش، یعنی: 0xa9059cbb برای شناسایی تابع مورد استفاده قرار می گیرد. این ۴ بایت، همان شناسه تابع (function selector) هستند.

می توانید با استفاده از کتابخانه ethers (نسخه ۶)، امضای تابع transfer(address,uint256) را به شناسه تابع تبدیل کنید. کد زیر این کار را انجام می دهد:

خروجی این کد به صورت زیر خواهد بود:

تابع جاوا اسکریپت برای تبدیل امضای تابع به یک انتخابگر تابع

در زبان سالیدیتی، می توانید با نوشتن تابعی ساده، شناسه تابع را محاسبه کنید:

اگر نمی خواهید کد بنویسید، می توانید از وبسایت های آنلاین برای محاسبه Keccak-256 استفاده کنید و امضای تابع را به شناسه آن تبدیل کنید.

قطعه کد وب‌سایت مبدل Keccak-256

حالا که مفهوم شناسه تابع را به طور کامل بررسی کردیم، در ادامه به سراغ بخش بعدی رمزگذاری ABI در فراخوانی توابع می رویم: آرگومان های تابع (function inputs یا arguments).

ورودی ها یا آرگومان های تابع

وقتی تابعی هیچ آرگومانی نداشته باشد، فراخوانی آن فقط به شناسه تابع (function selector) وابسته است. همین شناسه به‌تنهایی تمام داده‌ مورد نیاز برای اجرای تابع را فراهم می‌کند. برای مثال، تابع play() با این شناسه شناسایی می شود: 0x93e84cd9 در این حالت، همین شناسه به تنهایی برای فراخوانی تابع کافی است.

اما وقتی تابعی دارای آرگومان باشد — مانند transfer(address to, uint256 amount) — در آن صورت باید آرگومان ها را طبق استاندارد ABI رمزگذاری کنیم و آن ها را به شناسه تابع بچسبانیم.

بیایید برای درک بهتر روند رمزگذاری آرگومان ها، از تابع transfer(address to, uint256 amount) به عنوان مثال اصلی استفاده کنیم:

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

در سایت Etherscan، می توانید calldata مربوط به یک تراکنش را مشاهده کنید. تصویر زیر نمونه ای از calldata مربوط به یک تراکنش انتقال توکن ERC-20 را نمایش می دهد:

calldata مربوط به یک تراکنش

در Etherscan، شناسه تابع را با عنوان MethodID نمایش می دهند. همان طور که در کادر قرمز زیر توضیح تابع دیده می شود، شناسه تابع transfer() برابر است با: 0xa9059cbb.

شناسه تابع و امضا در etherscan نشان داده شده است

بعد از آن، دو مقدار هگزادسیمال طولانی مشاهده می شوند که با [0] و [1] مشخص شد اند. این دو مقدار، به ترتیب نمایانگر آرگومان اول یعنی آدرس to و آرگومان دوم یعنی مقدار amount هستند.

Etherscan برای افزایش خوانایی، داده‌ های calldata را به بخش‌های ۳۲ بایتی (یعنی هر خط شامل ۶۴ کاراکتر هگزادسیمال) تقسیم می‌کند. این نوع نمایش به توسعه‌ دهنده‌ ها کمک می‌کند تا بتوانند ورودی‌ های تابع را راحت‌تر تفسیر و بررسی کنند.

با این حال، در واقعیت، تمام این داده‌ ها به‌صورت یک رشته طولانی و پیوسته (بدون فاصله یا خط جدید) در قالب یک تراکنش ارسال می‌شوند. برای نمونه، calldata کامل مربوط به تابع transfer() ممکن است به شکل زیر ارسال شود:

در Etherscan، اگر بخواهید calldata را دقیقاً به همان شکلی ببینید که در تراکنش ارسال شده، کافی است روی گزینه “View input as” کلیک کنید و حالت “Original” را انتخاب کنید. این گزینه داده‌ ها را بدون هیچ‌گونه پردازشی، به‌صورت رشته‌ای خام نمایش می‌دهد.

گزینه اصلی برای خواندن calldata در Etherscan

در ادامه، برای درک بهتر فرآیند رمزگذاری، ساختار calldata را خط به خط بررسی می کنیم و اطلاعات کلیدی آن را مرحله به مرحله استخراج خواهیم کرد.

بررسی calldata

در این بخش، قصد داریم calldata زیر را بررسی کنیم و اجزای آن را شناسایی کنیم:

برای شروع، ابتدا باید امضای تابعی را که این calldata به آن مربوط می‌شود، مشخص کنیم. بدون داشتن امضای دقیق تابع، امکان رمزگشایی داده وجود ندارد.

در این مثال، امضای تابع به‌صورت زیر تعریف شده است:

اکنون، calldata را به بخش های مشخص تقسیم می کنیم تا ساختار آن را بهتر درک کنیم:

  • ابتدا پیشوند 0x را که نشان دهنده مقدار هگزادسیمال است، جدا می کنیم.

  • سپس، شناسه تابع (function selector) را که همیشه ۴ بایت (۸ کاراکتر هگزادسیمال) است در یک خط می آوریم.

  • در نهایت، هر آرگومان را در بلوک های ۳۲ بایتی (۶۴ کاراکتر) جداگانه نمایش می دهیم.

ساختار تفکیک شده به صورت زیر است:

شناسه تابع

در ابتدای calldata، اولین ۴ بایت داده (۸ کاراکتر هگزادسیمال) شناسه تابع را تشکیل می دهد: 0xa9059cbb.

چهار بایت اول یک calldata نشان‌دهنده‌ شناسه تابع است.

آدرس (Address)

پس از شناسه تابع، ۳۲ بایت بعدی مربوط به آرگومان اول یعنی آدرس دریافت کننده (address) است. با وجود اینکه آدرس در سالیدیتی فقط ۲۰ بایت است، برای سازگاری با استاندارد ABI آن را با صفرهای ابتدایی پَدگذاری (padding) می کنند تا به ۳۲ بایت برسد.

مقدار ۳۲ بایتی مربوط به آدرس در calldata:

۳۲ بایت از calldata که پارامتر آدرس را نشان می‌دهد

برای به‌دست آوردن آدرس واقعی، باید صفرهای اضافی را حذف کنیم و تنها ۲۰ بایت پایانی (۴۰ کاراکتر هگزادسیمال آخر) را نگه داریم. در نتیجه، آدرس گیرنده به صورت زیر خواهد بود:

مقدار (Amount)

و در نهایت، آخرین آرگومان در تابع transfer(address,uint256) مربوط به مقدار (amount) است. مقدار مورد نظر در calldata به صورت زیر نمایش داده شده است:

بخشی ۳۲ بایتی از calldata که پارامتر amount را نشان می‌دهد.

برای اینکه این مقدار با فرمت ABI سازگار باشد، سالیدیتی آن را با صفرهای ابتدایی (leading zeros) پدگذاری کرده تا دقیقاً ۳۲ بایت (۶۴ کاراکتر هگزادسیمال) را تشکیل دهد.

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

همچنین، برای تبدیل عدد دسیمال به مقدار هگزادسیمال معادل:

نوع داده ها و پدگذاری (Padding)

در مراحل قبل دیدیم که در رمزگذاری calldata، هر مقدار ورودی به صورت یک کلمه ۳۲ بایتی (۳۲-byte word) رمزگذاری می شود. اگر اندازه واقعی داده از ۳۲ بایت کمتر باشد، سیستم آن را با صفر پدگذاری می کند تا به طول ۳۲ بایت برسد.

بر اساس قانون کلی برای نوع داده های با اندازه ثابت (Fixed-size types) مانند int، bool، و تمام انواع uint (از uint8 تا uint256)، رمزگذاری به صورت ۳۲ بایت انجام می شود و در صورت نیاز، با صفرهای سمت چپ (left-padding) پر می شوند.

مثال:

اگر یک متغیر uint8 با مقدار ۵ داشته باشیم، رمزگذاری آن به شکل زیر خواهد بود:

اگر یک مقدار bool برابر true داشته باشیم، به صورت زیر رمزگذاری می شود:
به همین ترتیب، یک مقدار از نوع bool که برابر با true باشد، نیز با صفرهای سمت چپ پُر می شود و به صورت زیر رمزگذاری خواهد شد:

اما نوع داده هایی که اندازه متغیر دارند مانند bytes و string، با صفرهای سمت راست پُر می شوند. برای مثال، مقدار 0x68656c6c6f که متن "hello" را نشان می دهد، در قالب یک کلمه ۳۲ بایتی به صورت زیر رمزگذاری می شود:

در اینجا، داده اصلی تنها ۵ بایت فضا اشغال می‌کند و سیستم برای هم‌خوانی با استاندارد ABI، باقی فضای ۳۲ بایتی را با صفرهای انتهایی پر می‌کند.

نوع داده های با اندازه ثابت (Fixed-size) در سالیدیتی عبارتند از:

  • bool

  • انواع uint (مثل uint8 تا uint256)

  • bytes با اندازه ثابت (مثلاً bytes1 تا bytes32)

  • address

  • tuple یا structهایی که فقط شامل داده های با اندازه ثابت باشند

  • آرایه هایی با اندازه مشخص که فقط شامل نوع داده ثابت باشند

نوع داده های با اندازه متغیر (Dynamic-size) در سالیدیتی عبارتند از:

  • bytes (بدون عدد مشخص پس از آن)

  • string

  • آرایه های پویا (dynamic array)

  • آرایه های با اندازه مشخص که داخل خود نوع داده متغیر داشته باشند

  • structهایی که شامل هر یک از نوع داده های متغیر بالا باشند

کار با calldata پویا (Dynamic Calldata)

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

فرض کنیم تابعی داریم که یک آرایه از uint‌ها و یک address به عنوان ورودی دریافت می‌کند. در اینجا جزئیات پیاده‌سازی تابع برای ما اهمیتی ندارد؛ فقط امضای تابع مهم است:

بیایید فرض کنیم داده‌ زیر را به تابع ارسال می‌کنیم:

هدف ما این است که calldata مربوط به این فراخوانی را بررسی کنیم و ببینیم چگونه هر بخش از آن رمزگذاری می‌شود.

در قدم اول باید مقدار offset مربوط به آرگومان آرایه uint256[] را رمزگذاری کنیم. اما offset دقیقاً چیست؟

برجسته‌سازی آرگومان آرایه‌ای از داده‌های کدگذاری‌شده‌ ABI

Offset چیست؟

ما در رمزگذاری ABI از offset استفاده می‌کنیم تا دقیقاً مشخص کنیم داده‌های پویا (مثل آرایه یا رشته) از کجای calldata شروع می‌شوند. این مقدار به تابع کمک می‌کند تا محتوای واقعی داده را از مکان درست بازیابی کند.

در مثال ما، یک نوع داده پویا uint256[] و یک نوع داده ثابت address وجود دارد. مقدار offset مربوط به uint256[] در calldata بالا برابر است با 0x40 در مبنای هگزادسیمال (یا 64 در مبنای دسیمال) و این مقدار در قالب یک کلمه ۳۲ بایتی رمزگذاری می‌شود.

از آنجایی که آرایه پویا اولین آرگومان تابع محسوب می‌شود، offset آن در اولین کلمه ۳۲ بایتی از calldata قرار می‌گیرد:

برجسته‌سازی آفست یک داده کدگذاری شده با abi

برای توضیح بیشتر درباره نحوه عملکرد offset، تصویر بالا دقیقاً محل قرارگیری offset آرایه در داخل calldata را مشخص کرده است. هر کلمه ۳۲ بایتی به صورت زیر شماره‌گذاری می‌شود:

  • 0 تا 31 (ردیف اول شامل ۳۲ بایت)
  • 32 تا 63 (ردیف دوم شامل ۳۲ بایت)
  • 64 تا 95 (ردیف سوم شامل ۳۲ بایت)
  • و به همین ترتیب ادامه دارد.

بنابراین مقدار 64 (یا 0x40 در مبنای هگزادسیمال) دقیقاً برابر با اولین بایت (جفت کاراکتر هگزادسیمال سمت چپ) در ردیف سوم است؛ همان‌جایی که هایلایت سبز به پایان می‌رسد. این موقعیت همان نقطه‌ای است که offset به آن اشاره می‌کند.

در این مثال، offset فاصله‌ای را نشان می‌دهد که از ابتدای اولین بایت بعد از شناسه تابع شروع می‌شود تا جایی که داده پویا (یعنی آرایه) آغاز می‌شود. البته در ادامه خواهیم دید که offset همیشه به معنی “فاصله از اولین بایت بعد از شناسه تابع” نیست.

رمزگذاری داده ثابت — آدرس

در خط بعدی، سیستم آدرس را رمزگذاری می‌کند. این مقدار را به صورت یک کلمه ۳۲ بایتی نمایش می‌دهد و با صفرهای ابتدایی پُر می‌شود. این همان آدرسی است که پیش‌تر به تابع داده‌ایم، زیرا آدرس از قبل در قالب هگزادسیمال قرار دارد.

رمزگذاری آدرس با فرمت ABI

رمزگذاری طول یک داده پویا — آرایه

در خط بعدی، سیستم طول آرایه را رمزگذاری می‌کند؛ یعنی تعداد آیتم‌هایی که درون آرایه قرار دارند. همان‌طور که مشاهده می‌کنید، این آرایه شامل سه مقدار است: [5769, 14894, 7854]. بنابراین، سیستم عدد ۳ را به‌عنوان طول آرایه در نظر می‌گیرد و در تصویر زیر همین مقدار را نمایش می‌دهد:

طول آرایه در داده‌های کدگذاری شده ABI هایلایت شده است

رمزگذاری عناصر آرایه به صورت هگزادسیمال

در مراحل قبل، ما نوع داده ثابت، مقدار offset و طول آرایه را رمزگذاری کردیم. حالا باید عناصر آرایه را رمزگذاری کنیم. هر عنصر آرایه را به عددی هگزادسیمال تبدیل می‌کنیم، مانند آنچه در تصویر بعدی مشاهده می‌شود:

تبدیل دهدهی به هگز با کدگذاری ABI

ما هر یک از اعداد صحیح موجود در آرایه را به معادل هگزادسیمال آن تبدیل می‌کنیم و سپس با صفرهای ابتدایی آن‌ها را تا ۳۲ بایت کامل می‌کنیم. در نتیجه، آیتم‌های آرایه به صورت زیر رمزگذاری می‌شوند:

مقایسه داده‌های کدگذاری شده ABI با پارامترهای تابع فراخوانی شده

با این بخش، بررسی ما درباره calldata مربوط به رمزگذاری ABI برای تابع transfer(uint256[], address) کامل می‌شود.

رمزگذاری آرگومان از نوع رشته (string) در ABI

رمزگذاری یک مقدار از نوع string فرآیند ساده‌ای دارد و فقط شامل موارد زیر است:

  • تعیین offset

  • مشخص کردن طول رشته

  • رمزگذاری محتوای رشته با فرمت UTF-8

در ادامه یک مثال با تابعی می‌بینید که دارای آرگومان string است:

وقتی مقدار زیر را به تابع ارسال می‌کنیم:
calldata مربوط به این فراخوانی به صورت زیر خواهد بود:

مقدار offset به صورت 0x20 (مبنای هگزادسیمال) نمایش داده شده است، زیرا محل قرارگیری رمزگذاری رشته، دقیقاً ۳۲ بایت بعد از ابتدای calldata (پس از شناسه تابع) قرار دارد. عدد ۳۲ در مبنای ده‌دهی برابر با ۲۰ در مبنای هگزادسیمال است.

همچنین مشاهده می‌کنیم که طول رشته "Eze" برابر با ۳ است، چون این رشته شامل ۳ کاراکتر می‌باشد و هر کاراکتر معادل ۱ بایت است (هر بایت معادل ۲ کاراکتر هگزادسیمال است).

داده‌های کدگذاری شده ABI از یک آرگومان رشته‌ای

رشته "Eze" فقط شامل نویسه‌های ASCII است که هر کدام دقیقاً ۱ بایت فضا اشغال می‌کنند، به همین دلیل طول رشته ۳ بایت محاسبه می‌شود. اما در مقابل، نویسه‌هایی که در قالب Unicode هستند — مانند "好" — معمولاً چند بایتی هستند. این کاراکتر خاص در UTF-8 به صورت ۳ بایت رمزگذاری می‌شود. در واقع، هر نویسه UTF-8 می‌تواند حداکثر تا ۴ بایت فضا بگیرد. برای مثال، رشته "你好" که شامل دو نویسه چینی است، در UTF-8 به صورت ۶ بایت (۳ بایت برای هر نویسه) رمزگذاری می‌شود.

رمزگذاری struct و tuple در calldata

در رمزگذاری ABI، tuple‌ها و struct‌ها به یک شکل رمزگذاری می‌شوند، زیرا کامپایلر سالیدیتی نوع داده struct را به صورت tuple در ABI تفسیر می‌کند.

طبق مشخصات رسمی رمزگذاری ABI در سالیدیتی، رمزگذاری یک struct از به‌هم‌پیوستن رمزگذاری تک‌تک اعضای آن تشکیل می‌شود؛ در این فرآیند، نوع داده‌هایی که اندازه ثابتی دارند، با صفرهای ابتدایی طوری پُر می‌شوند که به طول ۳۲ بایت برسند.

فرض کنیم قرارداد زیر را داریم:

امضای تابع foo به صورت زیر خواهد بود:

این ساختار هیچ تفاوتی با حالتی ندارد که تابع به جای struct، مستقیماً یک tuple به عنوان ورودی دریافت کند.

اگر تابع، آرایه‌ای پویا از ساختارهای مشابه را دریافت کند، امضای آن به صورت زیر خواهد بود:

اگر تمام فیلدهای یک struct از نوع داده‌های با اندازه ثابت باشند، ما کل ساختار را به عنوان یک نوع داده‌ی ثابت رمزگذاری می‌کنیم و در این صورت نیازی به offset نخواهد بود.

اما اگر حتی یکی از فیلدهای struct از نوع داده‌ متغیر (مثل string یا bytes) باشد، ساختار آن به عنوان داده‌ی پویا در نظر گرفته می‌شود و بنابراین نحوه‌ی رمزگذاری آن تغییر خواهد کرد.

برای مثال، ساختاری مانند نمونه زیر را در نظر بگیرید:

در صورتی که آرگومان‌های زیر را به تابع ارسال کنیم:

ساختار RareToken(1) به عنوان نوع داده ثابت (static type) رمزگذاری خواهد شد؛ زیرا تمام اعضای این ساختار اندازه ثابتی دارند.

در تصویر زیر، می‌توانید نحوه دقیق رمزگذاری این داده ها را مشاهده کنید:

رمزگذاری ABI از تاپل ها

اما اگر یکی از فیلدهای struct از نوع داده پویا باشد، باید کل ساختار را به عنوان نوع داده پویا (dynamic type) رمزگذاری کنیم. بیایید ساختار زیر را به عنوان مثال در نظر بگیریم:

اگر داده‌ زیر را به تابع ارسال کنیم:

رمزگذاری این فراخوانی با کدی مطابقت دارد که در تصویر زیر نمایش داده شده است. در این ساختار، RareToken دو فیلد دارد: یکی از نوع داده‌ ثابت (uint256 با مقدار 50) و دیگری از نوع داده‌ پویا (string با مقدار “Eze”). این ساختار ترکیبی از یک نوع پویا و یک نوع ثابت را در بر می‌گیرد، بنابراین آن را به‌عنوان یک نوع داده‌ پویا رمزگذاری می‌کنیم.

offset رشته در ساختار

رمزگذاری ABI برای چندین آرگومان از نوع struct

حال فرض کنید تابع send() به جای یک ساختار، سه struct به عنوان ورودی دریافت کند. در این صورت، calldata تابع مطابق با ساختار زیر رمزگذاری خواهد شد:

رمزگذاری ABI برای چندین آرگومان از نوع struct

سه کلمه ۳۲ بایتی اول در calldata همگی offset هستند، چون تابع مورد نظر ما سه آرگومان دریافت می‌کند و هر کدام از این آرگومان ها، یک نوع داده پویا محسوب می‌شوند (یعنی structهایی که حداقل یک فیلد با نوع داده پویا دارند).

ستونی که در سمت چپ شامل مقادیری مانند 0x00، 0x20، …، 0x1c0 است، نشان می‌دهد که هر offset دقیقاً به کدام بخش از calldata اشاره می‌کند. دقت کنید که مقدار offset از ابتدای اولین offset شروع می‌شود، نه از موقعیتی که خود offset در آن قرار دارد. در ادامه و هنگام بررسی داده‌ های پویا تو در تو (nested dynamic data)، به‌صورت دقیق‌تری نحوه‌ کارکرد offset‌ را تحلیل می‌کنیم.

نحوه رمزگذاری آرایه‌های با اندازه و نوع داده ثابت

رمزگذاری آرایه‌ های با اندازه ثابت به محتوای آن‌ها بستگی دارد. اگر آرایه تنها از نوع داده‌های ثابت تشکیل شود، آن را به‌عنوان یک نوع داده ثابت در نظر می‌گیریم و بر همین اساس رمزگذاری را انجام می‌دهیم. اما اگر آرایه شامل نوع داده‌ پویا باشد—در حالتی که طول ثابتی داشته باشد—با آن مثل یک آرایه پویا برخورد می‌کنیم و رمزگذاری را مطابق قوانین مربوط به داده‌ های پویا انجام می‌دهیم. این رویکرد همان منطق رمزگذاری struct هایی را دنبال می‌کند که دارای نوع داده‌ پویا هستند؛ موضوعی که در بخش قبلی به آن پرداختیم.

بیایید با یک آرایه با اندازه ثابت که فقط نوع داده‌های ثابت دارد شروع کنیم. طول این آرایه برابر با ۳ است:

و مقدار زیر را به تابع ارسال می‌کنیم:
در این حالت، رمزگذاری آرایه به صورت زیر انجام می‌شود:

رمزگذاری ABI برای آرایه با اندازه ثابت و نوع داده ثابت

همان‌طور که می‌بینید، در calldata هیچ offset وجود ندارد. فقط مقادیر عناصر آرایه به ترتیب و در قالب کلمه‌های ۳۲ بایتی رمزگذاری شده‌اند.

نحوه رمزگذاری آرایه fixed size دارای نوع داده پویا

بیایید حالتی را بررسی کنیم که در آن، آرایه fixed size فقط داده‌ هایی از نوع پویا در خود داشته باشد. در مثال زیر، تابعی داریم که یک آرایه از دو رشته (string[2]) را به عنوان ورودی دریافت می‌کند:

اگر رشته‌های زیر را به آن ارسال کنیم:

در زمان رمزگذاری، calldata به صورت زیر تولید خواهد شد:

رمزگذاری ABI برای آرایه با اندازه ثابت شامل اعضای با نوع داده پویا

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

اگر همین تابع را به گونه‌ای تعریف کنیم که آرایه دارای طول پویا باشد:

در این حالت متوجه می‌شویم که طول آرایه نیز در calldata رمزگذاری شده است.

طول آرایه هایلایت شده در داده‌های کدگذاری شده ABI

آرگومان‌های چند آرایه ای و آرایه های تو در تو در calldata

کار با آرایه های متعدد و آرایه های تو در تو در calldata ممکن است کمی پیچیده و فریب‌دهنده به نظر برسد. با این حال، الگوی کلی رمزگذاری همچنان ثابت باقی می‌ماند. در این بخش یاد می‌گیریم که چگونه آرایه های تو در تو را رمزگذاری و رمزگشایی کنیم و درک دقیق‌تری از نحوه عملکرد offset پیدا کنیم.

از امضای تابع زیر به عنوان مثال استفاده می‌کنیم:

و این داده‌ ها را به عنوان آرگومان به تابع ارسال می‌کنیم:
در نتیجه، calldata مربوط به این تابع و آرگومان ها به صورت هگزادسیمال خواهد بود:

ویژگی جدیدی که در تابع transfer() داریم این است که دو آرایه به عنوان ورودی دریافت می‌کند و یکی از این آرایه ها، خودش شامل دو زیر‌آرایه است.

در ادامه، ساختار کلی رمزگذاری calldata برای آرایه های تو در تو و چند آرگومانی را مرور می‌کنیم:

  • Offsets: رمزگذاری با تعریف offsetها آغاز می‌شود؛ یعنی مشخص می‌کنیم هر آرایه در چه موقعیتی از calldata قرار دارد. اگر چند آرایه به عنوان ورودی داشته باشیم، ابتدا offset تمام آن‌ها رمزگذاری می‌شود.
    در مثال ما، دو offset خواهیم داشت:
    • offset مربوط به آرایه اول (uint256[][])

    • offset مربوط به آرایه دوم (address[])

    • و در صورت وجود آرایه‌ های بیشتر، offsetهای بعدی به همین ترتیب ادامه خواهند داشت

  • طول آرایه اول (که در مثال ما برابر ۲ است: [[123, 456], [789]]) در مرحله بعدی قرار می‌گیرد و دقیقاً در موقعیتی نوشته می‌شود که offset اول به آن اشاره کرده است. این مقدار، طول کل آرایه را مشخص می‌کند. پیش از آن‌که رمزگذاری هر زیرآرایه را شروع کنید، ابتدا باید طول آن آرایه را مشخص کنید. بنابراین، در رمزگذاری ABI، برای هر زیرآرایه نیز ابتدا طول آن تعریف و رمزگذاری می‌شود. این کار باعث می‌شود هنگام خواندن داده‌ها، مشخص باشد که هر بخش از calldata چه تعداد مقدار در بر دارد.
  • در مرحله بعد، نوبت به رمزگذاری زیرآرایه های آرگومان اول می‌رسد:
    • offset مربوط به زیرآرایه اول: [123, 456]

    • offset مربوط به زیرآرایه دوم: [789]
      • طول زیرآرایه اول (در مثال ما برابر ۲ است)
        • مقدار اول زیرآرایه اول: 123

        • مقدار دوم زیرآرایه اول: 456

      • طول زیرآرایه دوم (در مثال ما برابر ۱ است)

        • مقدار اول زیرآرایه دوم: 789

  • پس از اینکه تمام مقادیر مربوط به آرایه اول را رمزگذاری کردید، باید رمزگذاری آرگومان بعدی یعنی آرایه دوم را آغاز کنید.
    • ابتدا طول آرایه دوم را مشخص کنید (در مثال ما شامل ۲ آدرس است)

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

اکنون، برای درک بهتر ساختار calldata، بیایید آن را به‌صورت تصویری بررسی کنیم.

ابتدا، برای خوانایی بهتر، رشته‌ calldata را به خطوطی تقسیم می‌کنیم که هر کدام ۳۲ بایت (معادل ۶۴ کاراکتر هگزادسیمال) داشته باشند. پیشوند 0x و شناسه تابع (function selector) را جدا در ابتدای داده قرار می‌دهیم. امضای تابع و رشته کامل calldata در بالای تصویر نمایش داده شده است:

Calldata برای آرایه تو در تو و آرایه پویا

offset آرگومان اول از نوع آرایه

اولین کلمه ۳۲ بایتی در رشته calldata نشان‌دهنده offset است؛ یعنی موقعیتی که داده های مربوط به آرگومان اول (که یک آرایه تو در تو است) از آن‌جا شروع می‌شوند. در تصویر زیر، می‌توانید محل دقیق offset مربوط به آرگومان uint256[][] را درون calldata مشاهده کنید:

offset آرایه تو در تو در داخل calldata

offset آرگومان دوم از نوع آرایه

در گام بعد، باید offset مربوط به آرگومان دوم یعنی آرایه آدرس ها را رمزگذاری کنیم.

همان‌طور که در مثال structهای پویا توضیح دادیم، هنگام محاسبه offset، شمارش را از محل فعلی offset شروع نمی‌کنیم. در عوض، شمارش را از اولین offset آغاز می‌کنیم که آن سطح از ساختار داده (مثل آرایه بیرونی یا struct اصلی) را تعریف کرده است.

به‌طور کلی، در ساختارهای تو در تو، هر offset مسیر خود را نسبت به اولین موقعیت سطح مربوط به خودش مشخص می‌کند. وقتی به رمزگذاری زیرآرایه‌ها برسیم، این منطق کاملاً روشن خواهد شد.

در تصویر زیر، می‌بینید که offset مربوط به آرگومان دوم به داده‌های آرایه آدرس‌ها اشاره دارد. چون این آرگومان شامل دو آدرس است، offset به عدد 2 ارجاع می‌دهد.

offset آرگومان دوم در داخل calldata

همان‌طور که می‌دانیم، موقعیت کلمه‌های ۳۲ بایتی به شکل زیر شماره‌گذاری می‌شود:

  • بایت 0 تا 31 → ردیف اول

  • بایت 32 تا 63 → ردیف دوم

  • بایت 64 تا 95 → ردیف سوم

در این مثال، مقدار offset برابر 320 بایت (یا 0x140 هگزادسیمال) است. این مقدار، به اولین بایت سمت چپ در ردیفی اشاره می‌کند که هایلایت پایان می‌یابد.

طول آرایه اول

در مرحله بعد، باید طول آرایه اول را رمزگذاری کنیم. این آرایه شامل دو زیرآرایه است: [123, 456] و [789] که با هم ساختار [[123, 456], [789]] را تشکیل می‌دهند. چون این آرایه ۲ زیرآرایه دارد، طول آن برابر با ۲ خواهد بود. این مقدار را به صورت یک عدد ۳۲ بایتی در calldata قرار می‌دهیم و آن را دقیقاً در موقعیتی رمزگذاری می‌کنیم که offset اول به آن اشاره می‌کند. تصویر زیر محل قرارگیری طول آرگومان اول را در داخل calldata نشان می‌دهد.

طول آرگومان اول از نوع آرایه در داخل calldata

offsetهای زیرآرایه‌ ها در آرگومان اول از نوع آرایه

بعد از طول آرایه، offsetهایی قرار دارند که نشان می‌دهند محتوای این آرایه‌ ها در کجای calldata ذخیره شده‌اند. دو offset وجود دارد، چون دو زیرآرایه داریم: [123, 456] و [789].

offset زیرآرایه اول

offset مربوط به زیرآرایه اول ([123, 456]) مقدار 0x40 است و کادر سمت راست آن را نشان می‌دهد. در اینجا، شمارش را از اولین کلمه پس از مقدار طول آرایه (جایی که آرایه تعریف می‌شود) آغاز می‌کنیم. همچنین باید دقت کنید که این offset به کلمه‌ای اشاره می‌کند که مقدار 2 دارد، چون زیرآرایه [123, 456] شامل دو مقدار است.

offset زیرآرایه اول درون calldata

offset زیرآرایه دوم

offset مربوط به زیرآرایه دوم ([789]) از ابتدای calldata شروع نمی‌شود. این offset در موقعیت 0xa0 قرار دارد (که در تصویر زیر با هایلایت قرمز مشخص شده) و فاصله آن از محل تعریف offset زیرآرایه اول، دقیقاً ۱۶۰ بایت (در مبنای دسیمال) است.

یادآوری می‌کنیم که offsetها به‌طور کلی شمارش را از محل فعلی خود شروع نمی‌کنند، بلکه از اولین offset مربوط به همان سطح از ساختار تو در تو آغاز می‌کنند. در این مثال، ما اکنون در یک سطح داخلی از آرایه تو در تو هستیم؛ بنابراین اولین offset در این سطح مقدار 0x40 است (که در تصویر با هایلایت بنفش مشخص شده). offset دوم نیز شمارش را از همین نقطه (یعنی 0x40) آغاز می‌کند، نه از مکان فعلی خودش:

offset زیرآرایه دوم درون calldata

طول زیرآرایه اول

در مرحله بعد، طول زیرآرایه اول قرار می‌گیرد. این زیرآرایه شامل ۲ مقدار است، بنابراین طول آن برابر با 2 خواهد بود. این مقدار به صورت یک کلمه ۳۲ بایتی در calldata رمزگذاری می‌شود و در تصویر زیر با هایلایت زرد مشخص شده است:

طول زیرآرایه اول درون calldata

مقادیر زیرآرایه اول

دو کلمه بعدی در calldata، نمایش هگزادسیمال دو مقدار موجود در زیرآرایه اول هستند. این مقادیر همان 123 و 456 هستند که به‌صورت جداگانه و با فرمت ۳۲ بایتی رمزگذاری شده‌اند، همان‌طور که در تصویر زیر مشاهده می‌شود:

مقادیر زیرآرایه اول درون calldata

طول زیرآرایه دوم

سپس به رمزگذاری طول زیرآرایه دوم می‌رسیم که فقط شامل یک مقدار است. بنابراین، مقدار طول آن برابر با 1 خواهد بود:

طول زیرآرایه دوم درون calldata

مقدار زیرآرایه دوم

زیرآرایه دوم تنها یک مقدار دارد، همان‌طور که از طول آن مشخص است. در ادامه، مقدار همین عنصر (یعنی 789) در قالب هگزادسیمال (0x315) در یک کلمه ۳۲ بایتی رمزگذاری می‌شود، همان‌طور که در تصویر زیر مشاهده می‌کنید:

مقدار زیرآرایه دوم درون calldata

طول آرایه دوم

در نهایت، به رمزگذاری طول آرگومان دوم یعنی آرایه آدرس‌ها می‌رسیم. این آرایه شامل ۲ آدرس است، بنابراین طول آن برابر با 2 خواهد بود. این مقدار به صورت یک کلمه ۳۲ بایتی در calldata رمزگذاری می‌شود و در تصویر زیر با هایلایت مشخص شده است:

طول آرایه دوم درون calldata

مقادیر آدرس در آرایه دوم

در این مرحله، آدرس‌های موجود در آرایه دوم را رمزگذاری می‌کنیم. هر آدرس را در قالب ۲۰ بایت نمایش می‌دهیم و با اضافه کردن صفرهای ابتدایی، آن را با ساختار ۳۲ بایتی ABI هماهنگ می‌کنیم.

و به این ترتیب، تابع:

به معادل هگزادسیمال قابل فهم برای ماشین مجازی اتریوم (EVM) تبدیل می‌شود.

مقادیر آدرس در آرایه دوم، درون calldata، در تصویر زیر هایلایت شده‌اند

این ویدیو خلاصه‌ای از calldata این مثال را نمایش می‌دهد.

انیمیشن آرایه سه بعدی تو در تو

در این ویدیو نشان می‌دهد چگونه یک آرایه سه‌ بعدی uint با امضای تابع f(uint[][][] memory data) به روش ABI رمزگذاری می‌شود:

طول calldata و هزینه گس در سالیدیتی

به عنوان یک توسعه‌دهنده سالیدیتی، یکی از دغدغه‌های اصلی شما باید صرفه‌جویی در مصرف گس باشد. به‌ویژه زمانی که با calldata کار می‌کنید، چون هر بایت از calldata هزینه گس دارد.

برای محاسبه هزینه calldata، ابتدا باید طول آن را بر حسب بایت مشخص کنیم. برای این کار، کافی است تعداد بایت‌ها را بشماریم. بیایید از رشته calldata مثال قبلی‌مان به عنوان مطالعه موردی استفاده کنیم:

ابتدا پیشوند 0x را حذف می‌کنیم، زیرا این فقط یک علامت برای مشخص کردن هگزادسیمال بودن داده در اتریوم است و در محاسبه طول نقشی ندارد. حالا فقط این مقدار برای ما باقی می‌ماند:

این رشته ۱۳۶ رقم هگزادسیمال دارد، یعنی طول آن معادل ۶۸ بایت است. چون هر بایت در رشته calldata با دو رقم هگزادسیمال نمایش داده می‌شود، با تقسیم ۱۳۶ بر ۲ می‌توان طول را به‌دست آورد: ۶۸ = ۲ ÷ ۱۳۶

هر بایت غیر صفر در calldata معادل ۱۶ گس هزینه دارد، در حالی که بایت‌های صفر ۴ گس هزینه دارند. بنابراین، برای ادامه محاسبه باید آن‌ها را از هم جدا کنیم.

ما داریم:

32 non-zero-bytes = 32 × 16 = 512 گس

36 zero-bytes = 36 × 4 = 144 گس

کل هزینه گس برای calldata = 512 گس + 144 گس = 656 گس.

از آنجایی که بایت‌های صفر ارزان‌تر هستند، برخی توسعه‌دهندگان اقدام به تولید (mine) آدرس‌هایی می‌کنند — چه برای حساب‌ها و چه برای قراردادهای هوشمند — که چندین بایت صفر در ابتدای خود دارند. این کار باعث می‌شود هزینه گس برای ارسال آن آدرس به عنوان آرگومان کاهش یابد.

جمع بندی

در طول این راهنما، با مفاهیم پایه‌ای رمزگذاری ABI برای فراخوانی توابع آشنا شدیم، اجزای کلیدی یک فراخوانی تابع به روش ABI را بررسی کردیم، و درک دقیق‌تری از ساختار calldata به دست آوردیم. همچنین، یاد گرفتیم چگونه هزینه گس مربوط به calldata را محاسبه کنیم، و در ادامه با مثال‌هایی پیچیده‌تر، به رمزگشایی و رمزگذاری پیشرفته‌تر نیز پرداختیم تا دانش به‌دست‌آمده را تثبیت کنیم. امیدوارم این مطلب برایتان مفید بوده باشد.

تمرین

۱. در فراخوانی تابع foo(uint16 x)، چند بایت در calldata وجود دارد؟
۲. رمزگذاری ABI برای foo(uint256 x, uint256[]) در صورتی که (2, [5, 9]) به آن ارسال شود، چیست؟
۳. رمزگذاری ABI برای foo(S[] memory s) چیست؟ (که S یک ساختار با فیلدهای uint256 x; uint256[] a; می‌باشد)

5/5 - (1 امتیاز)

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

پکیج آموزش سی شارپ | مختص ورود به بازار کار + آموزش ساخت بازی Quiz of King
  • انتشار: ۴ تیر ۱۴۰۴

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

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

مشاهده همه

نظرات

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