اوراکل TWAP در Uniswap v2

در این مقاله با مفهوم TWAP در Uniswap v2 و نحوه عملکرد آن در قالب اوراکل قیمت آشنا می‌شویم. TWAP که مخفف Time Weighted Average Price است، یکی از روش‌های متداول برای محاسبه میانگین قیمت دارایی ها در بازه‌های زمانی مشخص محسوب می‌شود و نقش مهمی در مقابله با دستکاری قیمت در قراردادهای هوشمند دارد.

منظور از “قیمت” در Uniswap چیست؟

فرض کنید در یک استخر، ۱ اتر (Ether) و ۲۰۰۰ واحد USDC قرار دارد. این ترکیب نشان می‌دهد که قیمت هر اتر ۲۰۰۰ USDC است. به بیان ساده، نسبت ۲۰۰۰ USDC به ۱ اتر برابر با قیمت اتر است. (در اینجا اعشار را در نظر نمی‌گیریم.)

به‌طور کلی، وقتی می‌خواهیم قیمت یک دارایی را نسبت به دارایی دیگر محاسبه کنیم، آن را به‌صورت یک نسبت می‌نویسیم. در این نسبت، دارایی‌ که برای ما اهمیت دارد در مخرج قرار می‌گیرد.

مثلاً در همین مثال، داریم: “برای به‌دست آوردن یک واحد از Foo، چند واحد Bar لازم است؟” (هزینه کارمزدها را فعلاً نادیده می‌گیریم.)

قیمت یک نسبت است

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

برای مثال، فرض کنید قیمت اتریوم ۲۰۰۰ باشد و معادل آن در USDC چیزی در حدود ۰.۰۰۰۵ در نظر گرفته شود (در اینجا اعشار دقیق هر دو دارایی نادیده گرفته شده است).

یونی سواپ از قالب عدد اعشاری ثابت استفاده می‌کند که در آن، ۱۱۲ بیت برای بخش صحیح و ۱۱۲ بیت برای بخش اعشاری اختصاص یافته است. این ساختار در مجموع ۲۲۴ بیت فضا اشغال می‌کند. اگر همراه با یک عدد ۳۲ بیتی ذخیره شود، فقط یک اسلات حافظه را در قرارداد هوشمند مصرف خواهد کرد.

تعریف اوراکل

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

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

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

دلیل استفاده از TWAP در Uniswap v2

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

اوراکل یونی سواپ نسخه ۲ برای مقابله با این مشکل از دو راهکار استفاده می‌کند:

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

  2. موجودی فعلی استخر در محاسبه قیمت اوراکل در نظر گرفته نمی‌شود.

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

TWAP در Uniswap v2 چگونه کار می‌کند؟

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

برای مثال:

  • در ۲۴ ساعت گذشته، قیمت یک دارایی برای ۱۲ ساعت اول ۱۰ دلار بوده و برای ۱۲ ساعت دوم ۱۱ دلار. در این حالت، میانگین قیمت با TWAP برابر است: ۱۰.۵ دلار.

  • در ۲۴ ساعت گذشته، قیمت یک دارایی برای ۲۳ ساعت اول ۱۰ دلار بوده و فقط در یک ساعت پایانی به ۱۱ دلار رسیده. در این شرایط، میانگین مورد انتظار باید به ۱۰ نزدیک‌تر باشد، اما همچنان بین ۱۰ و ۱۱ قرار می‌گیرد. به‌طور دقیق:
    (۱۰ × ۲۳ + ۱۱ × ۱) ÷ ۲۴ = ۱۰.۰۴۱۷ دلار

  • در ۲۴ ساعت گذشته، قیمت یک دارایی برای ساعت اول ۱۰ دلار بوده و در ۲۳ ساعت باقی‌مانده ۱۱ دلار. در این حالت، انتظار داریم TWAP به ۱۱ نزدیک‌تر باشد. به‌طور دقیق:
    (۱۰ × ۱ + ۱۱ × ۲۳) ÷ ۲۴ = ۱۰.۹۵۸۳ دلار

در حالت کلی، فرمول TWAP در Uniswap v2 به شکل زیر است:

فرمول TWAP در Uniswap V2

در این فرمول، T نشان‌دهنده مدت زمان است، نه یک زمان خاص (Timestamp). یعنی مشخص می‌کند که یک قیمت خاص چه مدت زمانی در آن سطح باقی مانده است.

Uniswap V2 مقدار بازه زمانی یا مخرج را ذخیره نمی‌کند

در مثال بالا فقط قیمت‌های ۲۴ ساعت گذشته را بررسی کردیم. اما اگر بخواهیم قیمت‌ها را در بازه‌ای مثل یک ساعت، یک هفته یا هر بازه زمانی دیگری بررسی کنیم چه؟ واضح است که یونی سواپ نمی‌تواند تمام بازه‌های زمانی مورد نظر کاربران را ذخیره کند. علاوه بر این، راه مناسبی هم برای ثبت مداوم اسنپ‌شات از قیمت وجود ندارد، چون انجام این کار نیاز به پرداخت گس دارد.

راه‌حل چیست؟ یونی سواپ فقط صورت کسر (numerator) را ذخیره می‌کند — هر بار که نسبت نقدینگی تغییر می‌کند (یعنی وقتی دستوراتی مثل mint، burn، swap یا sync اجرا می‌شوند)، سیستم قیمت جدید و مدت زمان پایداری قیمت قبلی را ثبت می‌کند.

کد TWAP همراه با نکات

متغیرهای price0CumulativeLast و price1CumulativeLast به صورت عمومی (public) تعریف شده‌اند. بنابراین، هر کسی که به این مقادیر علاقه دارد، باید خودش در زمان دلخواه از آن‌ها اسنپ‌شات بگیرد.

اما نکته مهمی وجود دارد که باید همیشه به خاطر داشته باشید: مقادیر price0CumulativeLast و price1CumulativeLast فقط در خطوط ۷۹ و ۸۰ کد (دایره نارنجی) به‌روزرسانی می‌شوند. این مقادیر فقط افزایش پیدا می‌کنند تا زمانی که سرریز (overflow) شوند. هیچ مکانیزمی وجود ندارد که باعث کاهش آن‌ها شود. با هر بار اجرای تابع _update، این مقادیر افزایش می‌یابند.

در نتیجه، این مقادیر از زمان راه‌اندازی استخر به‌صورت تجمعی (accumulated) ذخیره شده‌اند و ممکن است این بازه زمانی بسیار طولانی باشد.

محدود کردن بازه زمانی نگاه به گذشته

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

در اینجا، فرمول TWAP دوباره آورده شده است:

اگر فقط به قیمت‌ها از زمان T4 به بعد علاقه داشته باشیم، در این صورت باید محاسبه زیر را انجام دهیم:

چگونه این کار را در کد انجام دهیم؟ از آنجا که price0CumulativeLast به‌صورت مداوم به ثبت مقادیر ادامه می‌دهد، باید راهی برای محاسبه صحیح بازه مورد نظر پیدا کنیم.

ما به روشی نیاز داریم تا فقط بخش‌هایی را که برای ما اهمیت دارند جدا کنیم. به مثال زیر توجه کنید:

اگر در پایان زمان T3 از قیمت اسنپ شات بگیریم، مقدار حاصل برابر با UpToTime3 خواهد بود. و اگر صبر کنیم تا زمان T6 به پایان برسد، سپس عبارت price0CumulativeLast - UpToTime3 را محاسبه کنیم، در این صورت فقط قیمت‌های تجمعی مربوط به بازه اخیر را به دست می‌آوریم.

اگر این مقدار را بر مدت زمان بازه اخیر (T4+T5+T6) تقسیم کنیم، قیمت میانگین وزنی بر اساس زمان (TWAP) برای همین بازه زمانی به دست می‌آید.

به‌صورت گرافیکی، این دقیقاً همان فرآیندی است که با استفاده از تجمع‌کننده قیمت (Price Accumulator) انجام می‌دهیم.

نمودار پنجره twap

محاسبه TWAP یک ساعته در سالیدیتی

اگر بخواهیم TWAP یک ساعته را محاسبه کنیم، باید از قبل بدانیم که به یک اسنپ‌شات از تجمع‌کننده قیمت (Accumulator) در یک ساعت بعد نیاز خواهیم داشت. برای این کار، باید متغیر عمومی price0CumulativeLast و تابع عمومی getReserves() را فراخوانی کنیم تا زمان آخرین به‌روزرسانی را به‌دست آوریم و از آن‌ها اسنپ‌شات بگیریم. (به تابع snapshot() در پایین مراجعه کنید.)

پس از گذشت حداقل یک ساعت، می‌توانیم تابع getOneHourPrice() را صدا بزنیم. این تابع به مقدار جدید price0CumulativeLast از یونی سواپ نسخه ۲ دسترسی پیدا می‌کند.

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

کد زیر صرفاً برای درک بهتر، به ساده‌ترین شکل ممکن نوشته شده است. استفاده از آن در محیط واقعی (Production) توصیه نمی‌شود.

اگر آخرین اسنپ شات بیش از سه ساعت قبل ثبت شده باشد چه؟

خوانندگان بادقت ممکن است متوجه شوند که قرارداد بالا نمی‌تواند اسنپ‌شات بگیرد اگر جفت‌ارزی (pair) که با آن در تعامل است، در سه ساعت گذشته هیچ تعاملی نداشته باشد. در یونی سواپ نسخه ۲، تابع _update فقط زمانی اجرا می‌شود که عملیات‌هایی مانند mint، burn یا swap انجام شوند. اگر هیچ‌کدام از این تعاملات رخ ندهد، مقدار lastSnapshotTime به زمان دوری اشاره خواهد کرد.

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

تصویری از تابع sync در پایین آورده شده است.

اسکرین شات تابع sync

چرا TWAP باید دو نسبت قیمتی را جداگانه دنبال کند؟

قیمت دارایی A نسبت به B به‌سادگی برابر است با A تقسیم بر B. بالعکس، قیمت B نسبت به A نیز برابر است با B تقسیم بر A. برای مثال، اگر در یک استخر ۲۰۰۰ واحد USDC و ۱ واحد اتریوم داشته باشیم (بدون در نظر گرفتن اعشار)، قیمت هر واحد اتریوم برابر است با: ۲۰۰۰ USDC ÷ ۱ ETH.

قیمت USDC بر حسب ETH نیز فقط نسبت معکوس همین عدد است. یعنی صورت و مخرج جایگزین می‌شوند.

اما هنگام محاسبه تجمعی قیمت‌ها نمی‌توانیم صرفاً یکی از این نسبت‌ها را معکوس کنیم تا به نسبت دیگر برسیم. برای درک بهتر این موضوع، به مثال زیر توجه کنید:

فرض کنید مقدار اولیه تجمع‌کننده قیمت برابر ۲ باشد و سپس مقدار ۳ به آن اضافه شود. در این حالت، نمی‌توانیم صرفاً عدد نهایی را معکوس کنیم تا نسبت معکوس به دست آید.

با این حال، قیمت‌ها همچنان «تا حدی متقارن» هستند. به همین دلیل، در نمایش آن‌ها با محاسبات اعشاری ثابت (Fixed Point Arithmetic)، باید ظرفیت بخش صحیح و بخش اعشاری عدد برابر در نظر گرفته شود.

اگر ETH هزار برابر «ارزشمندتر» از USDC باشد، در مقابل، USDC نیز هزار برابر «کم‌ارزش‌تر» از ETH خواهد بود. برای ذخیره دقیق این نسبت‌های متقارن، عدد اعشاری ثابت باید اندازه‌ای برابر در هر دو طرف ممیز داشته باشد.

به همین دلیل است که یونی سواپ از قالب عددی u112x112 استفاده کرده است.

PriceCumulativeLast همیشه افزایش پیدا می‌کند تا زمانی که سرریز شود، سپس دوباره از صفر ادامه می‌دهد

یونی سواپ نسخه ۲ قبل از انتشار سالیدیتی ۰.۸.۰ ساخته شده است؛ در نتیجه، عملیات‌های ریاضی به‌صورت پیش‌فرض دچار سرریز (overflow) و کم‌ریز (underflow) می‌شدند. در پیاده‌سازی‌های مدرن اوراکل قیمت، باید از بلوک unchecked استفاده شود تا این سرریزها مطابق انتظار انجام شوند.

و در نهایت، مقادیر priceAccumulator و زمان بلاک (block timestamp) هر دو دچار سرریز خواهند شد. در چنین حالتی، مقدار ذخیره‌شده قبلی (reserve قبلی) بیشتر از مقدار جدید به نظر می‌رسد. هنگام محاسبه تغییر قیمت توسط اوراکل، مقدار منفی به دست می‌آید. اما این مسئله مشکلی ایجاد نمی‌کند، چون قوانین حساب مدولار (modular arithmetic) این اختلاف را به‌درستی مدیریت می‌کنند.

برای ساده‌سازی، فرض کنیم از یک عدد صحیح بدون علامت (unsigned integer) استفاده می‌کنیم که در مقدار ۱۰۰ دچار سرریز می‌شود.

اگر در مقدار ۸۰ از priceAccumulator اسنپ‌شات بگیریم، و پس از چند تراکنش یا بلاک، مقدار آن به ۱۱۰ برسد، مقدار ذخیره‌شده دچار سرریز شده و به ۱۰ تبدیل می‌شود. وقتی ۸۰ را از ۱۰ کم می‌کنیم، حاصل برابر با ۷۰- می‌شود. اما چون عدد به‌صورت unsigned ذخیره شده، نتیجه محاسبه برابر خواهد بود با ۷۰- mod(100) که برابر با ۳۰ است. این همان مقداری است که در صورت نبود سرریز به دست می‌آمد (۱۱۰ – ۸۰ = ۳۰).

این رفتار نه‌فقط در مثال ما با مرز ۱۰۰، بلکه برای تمام مقادیر سرریز صدق می‌کند. سرریز شدن timestamp یا priceAccumulator مشکلی ایجاد نمی‌کند، چون محاسبات طبق قواعد حساب مدولار انجام می‌شود.

سرریز شدن timestamp

همین اتفاق در مورد سرریز شدن timestamp نیز رخ می‌دهد. چون برای نمایش آن از نوع داده uint32 استفاده می‌کنیم، امکان ایجاد عدد منفی وجود ندارد. برای ساده‌سازی، فرض کنیم سرریز در عدد ۱۰۰ اتفاق می‌افتد. اگر در زمان ۹۸ اسنپ‌شات بگیریم و در زمان ۴ از اوراکل قیمت استفاده کنیم، یعنی ۶ ثانیه گذشته است.
۴ – ۹۸ mod ۱۰۰ = ۶، که دقیقاً همان مقداری است که انتظار داریم.

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

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

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

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

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

مشاهده همه

نظرات

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