البناء على Duel
مجمّعات Duel هي مجمّعات Uniswap V4 قياسية مزوّدة بـhook، وكل رقم يعرضه التطبيق يأتي من واجهات وأحداث عامة. ويمكن للروبوتات والمنصات ولوحات المعلومات ومجمّعات الأسعار البناء عليها دون استئذان.
قراءة مبارزة
يجمع DuelLens كل ما يخص المبارزة في استدعاء واحد:
function getPair(uint256 pairId) external view returns (PairView memory);
function getPairs(uint256 offset, uint256 limit) external view returns (PairView[] memory);
يحتوي PairView على معرّف المبارزة ومنشئها وURI بياناتها الوصفية، والجانبين (الرمز، والاسم، والرمز المختصر، والمعروض، وسعر المجمّع والـtick الخاص به، والـETH لكل رمز، والـETH الحقيقي في المجمّع)، ومفاتيح المجمّعات الثلاثة، والنسبة الحية (gaugeBps، حصة الجانب A بنقاط الأساس)، ومتوسط الجولة حتى الآن (roundGaugeBps)، والصندوق، ورسوم المنشئ، والحجم وعمليات إعادة الشراء منذ البداية، وضريبة مكافحة القنص الحالية، ورقم الجولة وبدايتها ونهايتها وهل جرت فيها صفقات (roundTraded)، ووقت الإطلاق والـtick عنده (launchTime، startTick)، وهل هي مبارزة داعمة، وفئتها (category، وفق ترقيم التطبيق، 0 لغير المصنفة)، وشروط رسومها (fees)، والجزء الذي يأخذه البروتوكول الآن من حصته (protocolRateBps، من أصل 10,000): يحصل البروتوكول على fees.protocolShareBps × protocolRateBps / 10,000 من رسوم المبادلة، ويحصل الصندوق على باقي تلك الحصة. وفي العقود وواجهات ABI الخاصة بها، يُسمّى الجانب B باسم D: d وtokenD وkeyD.
وللـhook واجهات أصغر: pairCount() وgetPair(pairId) وpoolKeys(pairId) وgaugeBps(pairId) وsnipeTaxBps(pairId) وprotocolRateBps() وprotocolFees() وfirstRoundEnd(launchTime).
التداول
يمكن لأي موجّه Uniswap V4 التداول في مجمّعات المبارزة. ابنِ مفاتيح المجمّعات من poolKeys(pairId):
| المجمّع | currency0 | currency1 | fee | tickSpacing |
|---|---|---|---|---|
| ETH / A | ETH (address(0)) | الرمز A | 0 | 200 |
| ETH / B | ETH (address(0)) | الرمز B | 0 | 200 |
| A / B | عنوان الرمز الأصغر | العنوان الأكبر | 0 | 60 |
يقتطع الـhook رسومه عبر return deltas، لذا سعّر باستخدام V4 Quoter: فإجاباته تشمل كل الرسوم والضرائب. والتبديل عبر ETH هو quoteExactInput الخاص به على المسار: الرمز ← ETH ← الرمز الآخر.
المبادلة التي يقتطع الـhook رسومها قبل تنفيذها (شراء أو تبديل بمدخل محدد، أو بيع بمخرج محدد) يجب أن تكتمل بالكامل: فالمبادلة التي قد يوقفها مبكرًا حدّ سعرها، أو مجمّع ETH ينقصه الـETH، تُلغى بالخطأ PartialFill. ويغلّف الـPoolManager خطأ الـhook: فيصل PartialFill وArbitrageStarved داخل WrappedError(address target, bytes4 selector, bytes reason, bytes details)، وداخل UnexpectedRevertBytes(bytes revertData) حين يبلّغ عنهما V4 Quoter. فكّ ترميز reason لقراءة خطأ الـhook.
تنتهي كل صفقة بإعادة موازنة الـhook، وهي تحتاج إلى غازها الخاص: فالمبادلة التي تترك لها غازًا أقل من اللازم تُلغى بالخطأ ArbitrageStarved بدلًا من تخطّيها. أرسل المبادلات بالغاز الذي يعطيه التقدير، لا أقل منه أبدًا، ومع هامش إضافي: فأول مبادلة بعد إغلاق جولة تسوّي الجولة أيضًا (إعادة الشراء فيها، ثم إعادة موازنة، أي نحو 170,000 وحدة غاز إضافية)، وهو ما لا يحتسبه تقدير أُجري قبل الإغلاق. ويضيف التطبيق 500,000 وحدة غاز إلى كل تقدير.
يوفّر DuelRouter استدعاءً لمبادلة في مجمّع واحد:
function swapExactIn(
PoolKey calldata key,
bool zeroForOne,
uint256 amountIn,
uint256 minAmountOut,
address recipient,
uint256 deadline
) external payable returns (uint256 amountOut);
واستدعاءً آخر للتبديل عبر ETH، يُباع فيه الجانب الذي تغادره في مجمّع ETH الخاص به ويُنفق كل الـETH الناتج في مجمّع الجانب الآخر:
function swapThroughEth(
PoolKey calldata keyIn,
PoolKey calldata keyOut,
uint256 amountIn,
uint256 minAmountOut,
address recipient,
uint256 deadline
) external returns (uint256 amountOut);
البيع في مجمّع ETH من Duel الذي يتوقف عند سعر إطلاق المجمّع بعد نفاد ETH فيه يُرسَل مجددًا لما تبقّى في الاستدعاء نفسه، بعد أن تعيد إعادةُ موازنة الـhook شراءَ ما يستوعبه المجمّع الواقع بين الجانبين من المجمّع: حتى SALE_PASSES() (8) مبادلات، ما دام سعر المجمّع دون حدّ المبادلات. تدفع كل منها رسوم الـhook على ETH الخاص بها، ويحتسب minAmountOut جميعها. يسعّر V4 Quoter من Uniswap مبادلة واحدة: وما تجاوز ما تنفّذه يجيب عنه بـ NotEnoughLiquidity. ويسعّر التطبيق مثل هذا البيع بتشغيل الموجّه بصفة المتداول (eth_call مع تجاوز للحالة، DuelSimulator).
أما ما لم تعد إعادة الموازنة قادرة على إعادة شرائه، فيتولّى sell بيعه عبر المجمّع الواقع بين الجانبين إلى مجمّع ETH الخاص بالجانب الآخر، حيث نقلت عمليات إعادة الموازنة الـETH الذي يدفع ثمنه:
function sell(
PoolKey calldata keyIn,
PoolKey calldata keyCross,
PoolKey calldata keyOther,
uint256 amountIn,
uint256 minAmountOut,
address recipient,
uint256 deadline
) external returns (uint256 amountOut);
يبيع sell في keyIn كما يفعل swapExactIn، ثم يمرّر ما تبقّى عبر keyCross، الذي يستوعبه كله (وتُحرق رسوم التبديل فيه، كما في أي تبديل)، ثم يبيعه في keyOther، على عدة مراحل هنا أيضًا. ويحتسب minAmountOut الـETH من المجمّعين معًا. وما لا يستطيع keyOther بدوره إعادة شرائه يُدفع إلى recipient برمز ذلك المجمّع، دون أن يُحتسب في الحد الأدنى. ويبيع التطبيق عبر sell حين يرفض V4 Quoter تسعير البيع ويُظهر DuelSimulator.simulateSale أن sell يبيع كل شيء مقابل ETH.
أما أي موجّه V4 آخر فينفّذ مبادلة واحدة. ولا يرفض الـhook بيعًا بمدخل محدد يتجاوز ما يدفع ثمنه الـETH في المجمّع (فرسومه تُقتطع بعد المبادلة، من الـETH المدفوع): يُنفَّذ البيع جزئيًا، نزولًا حتى سعر إطلاق المجمّع، ويبقى الباقي لدى البائع. حدّد الحد الأدنى للمخرج بما يمكن تنفيذه، وبِع الباقي في معاملة لاحقة، مقابل ما أعادت إعادة الموازنة شراءه.
أرسل ETH مع swapExactIn للشراء. وللبيع أو التبديل، امنح الموجّه موافقة على الرمز، أو دع permit (EIP-2612) يمنحها في المعاملة نفسها: يأخذ swapExactInWithPermit وswapThroughEthWithPermit وsellWithPermit الوسائط نفسها، إضافةً إلى permit المستدعي بقيمة amountIn بالضبط، موقّعًا للموجّه:
struct Permit {
uint256 deadline;
uint8 v;
bytes32 r;
bytes32 s;
}
توقّع محفظة المستخدم تصاريح permit لكل رمز من رموز Duel ضمن نطاق EIP-712 الخاص بالرمز، واسمه Duel، الإصدار 1، باستخدام عنوان الرمز (ويعلن ذلك eip712Domain() الخاص به، وفق ERC-5267). ويستخدم الموجّه الـpermit أولًا ويتجاهل فشله: فالـpermit الذي أرسله أي أحد من قبل قد ضبط الموافقة، والذي يفشل لا يغيّر شيئًا؛ وعندها تأخذ المبادلة ما تغطيه الموافقة القائمة وتُلغى إن لم تكفِ. والمبادلة التي لا تكتمل إلا جزئيًا تُبقي مقدار الموافقة غير المستخدم. وتحدد مهلة الـpermit متى يمكن تقديم توقيعه، ولا تنهي الموافقة التي مُنحت بالفعل. ولا يسحب الموجّه الرموز إلا من المستدعي. ويبقى ETH التبديل في الـPoolManager لينفقه المجمّع الثاني؛ فمجمّع Duel ينفقه كله وإلا ألغى الـhook المبادلة (PartialFill)، وفي أي مجمّع آخر يُعاد إلى المستدعي ما لا يستطيع إنفاقه.
الإطلاق
تطلق DuelFactory.createPair(CreateParams) مبارزة؛ أرسل معها رسوم الإطلاق ومبالغ الشراء عند الإطلاق، ويُعاد أي فائض. ويتكوّن CreateParams من nameA وsymbolA وnameD وsymbolD (الجانب B) وmetadataURI وbuyA وbuyD (الـETH المُنفَق على كل جانب عند الإطلاق، معفى من ضريبة مكافحة القنص) وsupporter وcategory (وفق ترقيم الفئات في التطبيق، 0 لغير المصنفة) وconfig، وهو configHash() الخاص بالمصنع كما قرأته: يُلغى الإطلاق بالخطأ ConfigChanged إذا تغيّرت الشروط بعد ذلك. يتكوّن الاسم من 1 إلى 128 بايت، والرمز المختصر من 1 إلى 40، وURI البيانات الوصفية من 4,096 بايت على الأكثر (InvalidMetadata)؛ ويُلغى الشراء عند الإطلاق إذا تجاوز 5% من معروض الجانب (CreatorBuyTooLarge).
يبدأ المصنع مع إيقاف عمليات الإطلاق مؤقتًا. ما دامت paused() تساوي true، لا يستطيع إطلاق مبارزة إلا owner()؛ وتُلغى استدعاءات الآخرين بخطأ CreationPaused. يفتح المالك عمليات الإطلاق باستخدام setPaused(false). يستمر تداول المبارزات القائمة.
الأحداث
| العقد | الحدث | متى |
|---|---|---|
| DuelFactory | PairCreated(pairId, creator, tokenA, tokenD) | أُطلقت مبارزة |
| DuelHook | PairRegistered(pairId, tokenA, tokenD, creator, category) | سُجّلت مجمّعاتها لدى الـhook، في المعاملة نفسها؛ وcategory موضوعها وفق ترقيم الفئات في التطبيق (0 لغير المصنفة) |
| DuelHook | FeeTaken(pairId, fee, snipeTax) | دفعت صفقة مقابل ETH رسومها |
| DuelHook | SwitchFeeBurned(pairId, token, amount) | أحرق تبديلٌ رسومه |
| DuelHook | ArbitrageCaptured(pairId, profit) | ملأت إعادة الموازنة الصندوق |
| DuelHook | RoundSettled(pairId, roundIndex, averageTick, winner, ethSpent, tokensBurned) | أُغلقت جولة |
| DuelHook | CreatorFeesClaimed(pairId, to, amount) | دُفع لمنشئ |
| DuelFactory | CreatorTransferStarted(pairId, creator, newCreator) | عرض منشئ دوره (إلى العنوان الصفري: سُحب العرض) |
| DuelHook | CreatorChanged(pairId, from, to) | انتقل دور المنشئ في مبارزة إلى عنوان آخر |
| DuelHook | CreatorFeesGivenUp(pairId, creator) | حوّل المنشئ حصته من الرسوم المستقبلية إلى الصناديق نهائيًا |
| DuelHook | ProtocolFeesClaimed(to, amount) | دُفع للخزينة |
| DuelHook | ProtocolRateSet(rateBps) | تغيّرت حصة البروتوكول من رسوم المبادلة في كل المبارزات فورًا: يأخذ منها rateBps (10,000 للحصة كاملة)، ويذهب الباقي إلى الصناديق |
| DuelFactory | ConfigProposed(config, eta), ConfigApplied(config), ConfigCancelled() | شروط إطلاق اقتُرحت لما بعد 48 ساعة، أو طُبّقت، أو سُحبت |
| DuelFactory | PausedSet(paused) | أُوقفت عمليات الإطلاق أو استُؤنفت |
| PoolManager | Swap(id, sender, amount0, amount1, sqrtPriceX96, liquidity, tick, fee) | كل صفقة، بما فيها إعادة الموازنة التي يجريها الـhook نفسه (وفيها يكون sender هو الـhook) |
تقرأ قوائم الصفقات المبادلات من المجمّعات الثلاثة؛ أما مخططات السعر والنسبة فلا تقرأ إلا مجمّعَي ETH. وتستخدم قوائم الحائزين أحداث Transfer الخاصة بالرموز. ويفهرس الـsubgraph المبادلات والأرصدة والحائزين والجولات المسوّاة، كي يحمّل التطبيق التاريخ دون إعادة تشغيل الشبكة منذ الإطلاق.
التسوية والمطالبة
settle(pairId)تسوّي جولة منتهية. ويمكن لأي أحد استدعاؤها.claimCreatorFees(pairId)لا تدفع إلا لمنشئ المبارزة: المحفظة التي أطلقتها أو العنوان الذي سلّمته دورها؛ وclaimProtocolFees()تدفع للخزينة المضبوطة وقت المطالبة. ويمكن لأي أحد تشغيل أيٍّ من المطالبتين. وقد تتغير الخزينة بعد مهلة المصنع البالغة 48 ساعة؛ ولا يستطيع المستدعي اختيار مستلم آخر.transferCreator(pairId, newCreator)على المصنع تعرض دور المنشئ في مبارزة، ويتولاهacceptCreator(pairId)حين يستدعيهnewCreator؛ وتقرأpendingCreator(pairId)العرض. ولا يعرضه إلا المنشئ، ولا يتولاه إلا العنوان المعروض عليه.- تُحوّل
giveUpCreatorFees(pairId)على الهوك حصة المنشئ من الرسوم المستقبلية إلى صناديق الجولات نهائيًا. لا يستطيع استدعاءها إلا المنشئ الحالي. تظل الرسوم المتراكمة قابلة للمطالبة من قِبل المنشئ، ويظل نقل دور المنشئ ممكنًا.