เจาะลึก Uniswap V3 Smart Contracts

Unnawut L.
SCB 10X OFFICIAL PAGE
8 min readAug 16, 2021

--

จากหลายๆ โปรโตคอลที่เราได้ทำการศึกษาและทดลองที่ SCB 10X เราพบว่าโปรโตคอล Uniswap V3 เป็นหนึ่งในโปรโตคอลที่มีความน่าสนใจทั้งในแง่ของการเป็น Decentralize Exchange (DEX) ที่มีมูลค่าธุรกรรมอันดับ 1 ในโลก Decentralized Finance อีกทั้งยังมีความซับซ้อนในแง่โครงสร้างและส่วนประกอบของ smart contracts ที่น่าหยิบยกมาเป็นกรณีศึกษา

วันนี้จึงขอมาเล่าว่า Uniswap V3 มีโครงสร้างและส่วนประกอบทาง smart contracts ที่น่าสนใจอย่างไรบ้าง เราหวังว่าบทความนี้จะเป็นประโยชน์สำหรับผู้ที่ต้องการศึกษาและพัฒนา smart contracts บนโลก blockchain ทั้งสำหรับผู้เริ่มต้นและผู้ที่มีประสบการณ์แล้วแต่ยังไม่มีโอกาสได้ศึกษา Uniswap V3 smart contracts ครับ

บทความนี้แบ่งออกเป็น 4 ส่วน ได้แก่

  1. Code Repositories
    แจกแจง code repositories ที่เกี่ยวข้องกับ Uniswap V3 พร้อมอธิบายขอบเขตความรับผิดชอบของแต่ละ repositories
  2. V3 Core Deep Dive
    อธิบายการทำงานของ smart contracts ที่สำคัญใน uniswap-v3-core
  3. V3 Periphery Deep Dive
    อธิบายการทำงานของ smart contracts ที่สำคัญใน uniswap-v3-periphery
  4. Project Setup
    เล่าโครงสร้างของ project setup และ build pipeline ใน Uniswap V3

1. Code Repositories

Uniswap V3 ประกอบไปด้วย 5 repositories ที่มีหน้าที่รับผิดชอบแตกต่างกันดังนี้

1.1) V3 Core contracts (uniswap-v3-core)

uniswap-v3-core เป็นหัวใจของ Uniswap V3 เลยก็ว่าได้ มีหน้าที่หลัก 2 ส่วนคือ

  1. UniswapV3Factory
    - สร้าง liquidity pool ใหม่ (createPool())
    - เก็บ address mapping จาก token ไปยังแต่ละ liquidity pool เพื่อให้ contract อื่น และ SDK สามารถค้นหา pool ที่มีใน Uniswap V3 ได้
    - ในจักรวาล Uniswap V3 มี contract นี้อยู่เพียงชุดเดียว
  2. UniswapV3Pool
    - เก็บข้อมูลสินทรัพย์สุทธิใน 1 liquidity pool (1 คู่เหรียญ)
    - คำนวณและแลกเปลี่ยนเหรียญใน liquidity pool นั้นๆ
    - 1 contract จะดูแล 1 liquidity pool
    - ถูกสร้างโดย UniswapV3Factory

1.2) V3 Periphery contracts (uniswap-v3-periphery)

เนื่องจาก uniswap-v3-core มีเพียงโค้ดแกนกลางที่จำเป็นต่อการแลกเปลี่ยนเหรียญ ทำให้การทำธุรกรรมผ่าน uniswap-v3-core โดยตรงนั้นทำได้ค่อนข้างยาก ต้องมีการเตรียมข้อมูลหลายขั้นตอน

uniswap-v3-periphery จึงเป็นเหมือนผู้ช่วยที่รวบรวมฟังก์ชั่นต่างๆ ทำให้การฝาก/ถอน/แลกเหรียญสามารถทำได้ง่ายขึ้น โดยมี 2 contracts หลัก นั่นคือ

  1. NonfungiblePositionManager
    - ทำหน้าที่บันทึกว่า user คนไหนมีสินทรัพย์อะไรในระบบบ้าง ไม่ว่าจะเป็น position ใน UniswapV3Pool ไหนก็ตาม (NFT position และ owner ถูกบันทึกที่นี่)
    - สร้าง position ใหม่ (mint()) และทำลาย position เก่า (burn())
    - ปรับเปลี่ยนปริมาณเหรียญใน position (increaseLiquidity(), decreaseLiquidity())
    - ใน Uniswap V3 มี deploy contract นี้เพียงชุดเดียว ใช้ร่วมกันทุก pool
  2. SwapRouter
    - ใช้แลกเปลี่ยนเหรียญ จะเป็นการแลกผ่าน liquidity pool เดียว (exactInputSingle(), exactOutputSingle()) หรือหลาย liquidity pools ก็ได้ (exactInput(), exactOutput())
    - ใน Uniswap V3 มีการ deploy contract นี้เพียงชุดเดียว ใช้ร่วมกันทุก pool

1.3) Library contracts (uniswap-lib)

ทำหน้าที่เก็บ utility functions ยิบย่อยที่ถูกใช้งานทั้งใน Uniswap V2 และ V3 เช่น BitMath, FixedPoint, TransferHelper, ฯลฯ

1.4) User interface (uniswap-interface)

ทำหน้าที่เก็บ web interface ของ uniswap ที่เราใช้งานกันที่ http://app.uniswap.org/ ซึ่งเราจะไม่ลงรายละเอียดในบทความนี้

1.5) V3 SDK (uniswap-v3-sdk)

ทำหน้าที่เก็บ Typescript SDK เพื่อให้นักพัฒนาสามารถเรียกหา V3 smart contracts ผ่าน typescript/javascript ได้ง่ายขึ้น ซึ่งเราจะไม่ลงรายละเอียดในบทความนี้

และเมื่อเราลองวาดูโฟลว์การทำงานระหว่าง 5 code repositories ด้านบน เราจะเห็นการแบ่งความรับผิดชอบและความสัมพันธ์ระหว่างแต่ละ repository ได้ชัดเจนมาก

ตัวอย่างโฟลว์การทำงานของ swap ตั้งแต่ user trigger -> V3 SDK -> V3 smart contract

กล่าวง่ายๆ ก็คือ

  1. uniswap-interface ไว้เก็บรูปร่างหน้าตาเว็บ
  2. uniswap-v3-sdk ไว้เก็บโค้ดที่ทำให้หน้าเว็บคุยกับ smart contract ได้
  3. uniswap-v3-core ไว้เก็บ smart contract ที่เป็น core logic ในการสร้าง pool และการคำนวณเหรียญเข้าออก pool
  4. uniswap-v3-periphery ไว้เก็บ smart contract ที่ช่วยเตรียม parameters ก่อนส่งไปให้ core contracts
  5. uniswap-lib ไว้เก็บ utility function จิปาถะที่ใช้ร่วมกันทั้ง Uniswap V2 และ V3 ได้

V3 Core Deep Dive

ต่อมา เรามาสำรวจหน้าที่สำคัญๆ ของ uniswap-v3-core กันบ้าง

UniswapV3Factory.sol

UniswapV3Factory เป็นเหมือนจุดเริ่มต้นของกลไกทั้งหมดใน Uniswap V3

เพราะหน้าที่ของ smart contract นี้ก็คือการสร้าง pool ใหม่ จึงทำให้ contract นี้ค่อนข้างเรียบง่าย มีเพียง createPool() เป็นฟังก์ชั่นหลัก ที่เหลือคือ setOwner() และ enableFeeAmount() ซึ่งเป็นฟังก์ชั่นในการดูแลรักษา pool ที่ทำได้โดย contract owner เท่านั้น

แต่สิ่งที่น่าสนใจคือวิธีการสร้างและจัดเก็บ pool เนื่องจากแต่ละ pool จะขึ้นอยู่กับคู่เหรียญ ซึ่งตัว Factory รู้ได้อย่างไรว่า ETH-USDC กับ USDC-ETH จะต้องเป็น pool เดียวกัน?

คำตอบก็คือในบรรทัดที่ 12 ด้านบน ฟังก์ชั่นcreatePool() จะทำการเรียงลำดับ token address ให้ address ที่ค่าต่ำกว่าเป็น token0และค่าที่สูงกว่าเป็น token1 เสมอ เช่น 0x999... < 0xfff… แล้วจัดเก็บข้อมูลนี้ไว้ในตัวแปร public getPool

นอกจากนี้ เราจะเห็นจากบรรทัดที่ 18 และ 20 ว่าตัว UniswapV3Factory เก็บ mapping ทั้ง token0/token1 และ token1/token0 ไว้อีกด้วย ทำให้เราสามารถเรียก getPool โดยเรียง token address อย่างไรก็ได้ ไม่จำเป็นต้องทำ address ordering ซ้ำ

และเนื่องจากเราเรียก createPool() เพียงครั้งเดียว แต่การเรียก getPool() จะเกิดขึ้นนับไม่ถ้วน การทำ address ordering และเสีย storage เพิ่มจากการสลับ token address เพียงครั้งเดียว และไม่ต้องทำ address ordering อีกเลยถือว่าเป็น optimization ที่ดีมากๆ

และเมื่อเจาะเข้าไปดูใน UniswapV3PoolDeployer.deploy() ที่ถูกเรียกโดย createPool() นั้นจะเห็นว่ามีการให้ constructor() ของ pool ใหม่เรียกกลับมาที่ deployer.parameters() แทนที่จะส่งค่าไปให้ constructor() โดยตรง ซึ่งวิธีนี้ทำให้ bytecode ที่ใช้ในการ deploy pool ใหม่คงที่เสมอ (หากใส่ constructor parameter จะทำให้ contract bytecode เปลี่ยน) ซึ่งเดี๋ยวเราจะกล่าวถึงประโยชน์ตรงนี้ต่อไปเกี่ยวกับ POOL_INIT_CODE_HASH ในหัวข้อ callbacks ด้านล่าง

Deployer ตั้ง constructor arguments ไว้ในตัวแปร parameters แต่ไม่ส่งไปให้ constructor()
constructor() เรียกกลับไปที่ deployer.parameters() เองแทน

UniswapV3Pool.sol

UniswapV3Pool มีหน้าที่หลักในการ track จำนวนโทเคน การคำนวณอัตราแลกเปลี่ยน และทำการแลกเปลี่ยนโทเคนตาม user request

UniswapV3Pool นี้เป็นหนึ่งใน contract ปีศาจที่เมื่อเห็นครั้งแรกอาจทำให้ผวาได้ แต่หากเราข้ามพวก struct และ private functions ไปก่อน แล้วโฟกัสเฉพาะ external functions ก่อน จะทำให้เราเข้าใจ UniswapV3Pool ง่ายขึ้นมาก

  • initialize() ทำหน้าที่กำหนด initial price ของ pool นั้นๆ ใช้เพียงครั้งเดียวตอน deploy pool ใหม่
  • mint() ทำหน้าที่สร้าง liquidity position ใหม่ หรือ increase liquidity ใน position ที่มีอยู่เดิม
  • burn() ทำหน้าที่ลบ liquidity position ทิ้งหรือ reduce liquidity ออกจาก position ที่มีอยู่เดิม
  • collect() ทำหน้าที่โอนเหรียญคงค้างที่ไม่ได้อยู่ใน liquidity position ออกมาสู่กระเป๋าเจ้าของ position (เหรียญคงค้างได้แก่ unclaimed trading fee + reduced/removed liquidity)
  • swap() ทำหน้าที่แลกเปลี่ยนเหรียญจากเหรียญ A ไปเหรียญ B ตามที่ user ร้องขอ

external functions ที่เหลือนอกจากด้านบนนี้ทำหน้าที่เกี่ยวกับ pool state (snapshotCumulativesInside()), price oracle (observe(), increaseObservationCardinalityNext()), flash loan (flash()) และ protocol fees (setFeeProtocol(), collectProtocol()) ซึ่งยังไม่จำเป็นสำหรับการทำความเข้าใจ Uniswap V3 เบื้องต้น

และจากการศึกษาการทำงานของ UniswapV3Pool เราพบจุดที่น่าสนใจเกี่ยวกับการทำ callback ระหว่าง trusted contracts ที่เราอยากหยิบยกขึ้นมาเจาะลึกกัน

Cross-contract token transfers via callbacks

Uniswap V3 ออกแบบให้ NonfungiblePositionManager เป็นตัวกลางระหว่าง liquidity provider และ UniswapV3Pool ทำให้เราสามารถ approve NonfungiblePositionManager เหรียญละ 1 ครั้ง ก็สามารถ provide liquidity ให้กับทุก pool ได้โดยไม่ต้อง approve เป็นราย pool

แต่ด้วยดีไซน์นี้ทำให้ pool ไม่มีสิทธิ์ที่จะดึงเหรียญของ user เข้า pool … Uniswap V3 จึงใช้ callback ให้ UniswapV3Pool.mint() เรียก callback กลับมาที่NonfungiblePositionManager.uniswapV3MintCallback() เพื่อดึง ERC-20 เข้า pool อีกทีหนึ่ง ตามโค้ดด้านล่างนี้

ซึ่งฟังก์ชั่น uniswapV3MintCallback() จะต้องประกาศเป็น external เพื่อให้ UniswapV3Pool เรียกกลับมาได้ แต่การทำเช่นนั้นจะทำให้ใครก็สามารถเรียก uniswapV3MintCallback() เพื่อดึงเงินออกไปได้…

ตัวฟังก์ชั่นจึงมีการเรียก CallbackValidation.verifyCallback() ก่อนจะทำการโอนเหรียญด้วย pay() เพื่อตรวจสอบว่า callback ที่ได้รับนั้นมาจาก UniswapV3Pool จริงๆ หรือไม่

จากโค้ดด้านบน ตัว NonfungiblePositionManager จะมั่นใจได้อย่างไรว่า address(pool)คือ UniswapV3Pool จริงๆ? เราต้องมาดูกันต่อที่ implementation ของ PoolAddress.computeAddress(factory, poolKey):

จะเห็นว่า PoolAddress.computeAddress() คือการคำนวณหา address ตาม EIP-1014 ซึ่งก็คือ 0xff + deployerAddress + salt + contractHashedByteCode ซึ่งทำให้คอนเฟิร์มได้ทันทีว่าคนเรียก callback กลับมาคือ pool ของ Uniswap V3 จริงๆ เพราะการที่ msg.sender == PoolAddress.computeAddress(...)ได้นั้น เงื่อนไขเหล่านี้ต้องตรงกันทั้งหมด:

  • deployerAddress จะต้องเป็นUniswapV3Factory address
  • salt จะต้องเป็น keccak256(abi.encode(key.token0, key.token1, key.fee)) ของ pool นั้นๆ ทำให้ pool ที่เรียกมาจะต้องมี token0, token1 และ fee ตรงตาม callback data
  • contractHashedByteCode จะต้องตรงกับ hashed bytecode ของ UniswapV3Pool ซึ่งถูกกำหนดไว้ตั้งแต่แรกที่ PoolAddress.POOL_INIT_CODE_HASH เป็นการล็อคว่าจะทำงานกับ pool implementation เวอร์ชั่นนี้เท่านั้น

เมื่อครบตามเงื่อนไขด้านบน address ของ msg.sender และ PoolAddress.computeAddress() จึงจะตรงกันได้

salt ที่ใช้ใน PoolAddress.computeAddress() ถูกตั้งไว้ตั้งแต่ตอน new UniswapV3Pool
POOL_INIT_CODE_HASH คือ keccak256(bytecode) ของ UniswapV3Pool เป็นการล็อคการทำงานเข้ากับ pool implementation อันนี้เท่านั้น

สำหรับฟังก์ชั่นอื่นๆ ใน uniswap-v3-core นั้นค่อนข้างตรงไปตรงมาตาม Uniswap V3 whitepaper จึงขอลงรายละเอียดเพียงเท่านี้ ความยากจะเหลือเพียงการตามหาว่าฟังก์ชั่นที่เรียกอยู่ที่ไหน ซึ่งทั้งหมดจะกระจายอยู่ใน 13 libraries ที่ถูก include มาจาก libraries/ หากใช้ Code Editor ที่สามารถ navigate solidity function declarations ได้ หรือ search file content เอาก็จะหาได้ไม่ยาก

V3 Periphery Deep Dive

NonfungiblePositionManager.sol

ในขณะที่ความยากในการเร่ิมต้นอ่านโค้ด UniswapV3Pool อยู่ที่การ include libraries จำนวนมาก ความยากในการเริ่มอ่าน NonfungiblePositionManager จะเกิดจาก inheritance ของ base contracts จำนวนมาก (8 base contracts) ซึ่งแต่ละ base contracts ก็อาจ inherit contract อื่นอีก ในที่นี้เราใช้ ConsenSys/Surya มาช่วยในการ visualize inheritance ระดับแรกเหล่านี้

เมื่อเห็นดังนี้แล้ว เราสามารถแบ่งหมวดหมู่ inheritances ของ NonfungiblePositionManager เป็น Core, State Management กับ Utilities ได้โดยง่าย:

  • Core:
    - ERC721Permit รวมฟังก์ชั่นที่ทำให้ NonfungiblePositionManager เป็น ERC-721
    - PoolInitializer ใช้เก็บฟังก์ชั่นในการสร้าง pool ใหม่ หรือตั้งอัตราแรกเปลี่ยนตั้งต้นให้กับ pool ใหม่
    - LiquidityManagement เป็น contract หลักที่ทำหน้าที่คำนวณ liquidity และสั่งจ่ายโอนเหรียญไปมาเพื่อปรับ liquidity
  • State Management:
    - PeripheryImmutableState เอาไว้เก็บค่า factory address กับ WETH9 address
  • Utilities:
    - Multicall ใช้เก็บฟังก์ชั่น multicall() ทำให้สามารถรวบหลายๆ smart contract calls ลงใน transaction เดียวได้
    - PeripheryValidation เก็บ modifier checkDeadline() อันเดียว ใช้เพื่อตรวจสอบว่าธุรกรรมยังไม่หมดอายุก่อนทำรายการ
    - SelfPermit รวมฟังก์ชั่นในการ approve contract ใน multicall()

ในทางเดียวกับ UniswapV3Pool เราสามารถเริ่มศึกษา NonfungiblePositionManager ได้จากการศึกษา external และ public functions:

  • positions() ทำหน้าที่ให้รายละเอียดเกี่ยวกับ liquidity position ID เช่น pool info, price range, liquidity amount, ฯลฯ
  • mint() ทำหน้าที่สร้าง liquidity position อันใหม่ (mint NFT ใหม่ออกมา)
  • tokenURI() ทำหน้าที่สร้าง URI ที่เอาไว้ใช้แสดงข้อมูล NFT ประกอบไปด้วย NFT name, description และตัวภาพ SVG ของ NFT ชิ้นนั้นๆ
  • increaseLiquidity() ทำหน้าที่เติม liquidity เข้าไปใน position
  • decreaseLiquidity() ทำหน้าที่ดึง liquidity ออกจาก position
  • collect() ทำหน้าที่เก็บ trading fees ค้างจ่ายและโอนค่าธรรมเนียมนั้นเข้ากระเป๋าเจ้าของ position
  • burn() ทำหน้าที่ลบ liquidity position (burn NFT ทิ้ง)

หากอ่านจากชื่อฟังก์ชั่นคร่าวๆ จะเห็นว่ามีหลายฟังก์ชั่นที่ชื่อทับซ้อนกับ UniswapV3Pool ทั้งนี้เพราะฟังก์ชั่นใน UniswapV3Pool จะเน้นการจัดการ pool liquidity และ price แต่ NonfungiblePositionManager จะเน้นที่การจัดการ user position และ trading fee เช่น

  1. NonfungiblePositionManager.mint() มีหน้าที่เรียก UniswapV3Pool.mint() เพื่อเพิ่ม liquidity ลงใน pool แล้วมิ้นท์ NFT position ออกมาให้กับ liquidity provider
  2. NonfungiblePositionManager.collect() มีหน้าที่คำนวณยอด trading fee คงค้างล่าสุดแล้วจึงเรียก UniswapV3Pool.collect() เพื่อดึงโทเค่นออกมาสู่กระเป๋าเจ้าของ position แต่ตัว UniswapV3Pool.collect() ลำพังเองจะไม่มีการคำนวณ trading fee ล่าสุด
  3. NonfungiblePositionManager.burn() มีหน้าที่ลบ NFT token ออกจากระบบ แต่ UniswapV3Pool.burn() มีหน้าที่ลดยอด liquidity ออกจาก pool

และในทางเดียวกันกับ UniswapV3Pool การทำงานของฟังก์ชั่นต่างๆ ค่อนข้างตรงไปตรงมาตามสมการที่เขียนไว้ใน Uniswap V3 whitepaper แต่ก็มีจุดที่น่าสนใจเกี่ยวกับการทำงานกับโทเคนที่น่าหยิบยกขึ้นมาขยายความ

ERC20-Based Implementation

หากใครพอคุ้นเคยกับการ transfer token บน EVM/Solidity จะทราบดีว่า Ether (ETH) เป็น native token ของ Ethereum ซึ่งเป็นโทเคนที่ฝังมากับระบบ Ethereum ตั้งแต่แรกและเป็นโทเคนเดียวที่ใช้ในการจ่ายค่า gas ได้ ส่วนโทเคนที่เราคุ้นเคยกันในนาม ERC-20 tokens เป็นฟีเจอร์ที่พัฒนาขึ้นมาบน smart contract ในภายหลัง เพราะฉะนั้นเหรียญ ETH และ ERC-20 จึงมีฟีเจอร์ที่ไม่สัมพันธ์กัน และใช้โค้ดเรียกต่างกัน

เพื่อลดความซับซ้อนและความเสี่ยงจากการมี ETH และ ERC-20 implementation กระจายอยู่ทั่ว smart contracts ตัว Uniswap (ทั้ง V2 และ V3) จึงดำเนินการทุกอย่างผ่าน WETH (Wrapped ETH) ตั้งแต่การสร้าง pool, การ provide liquidity ไปจนถึงการ swap ทำให้กระบวนการทุกอย่างใน smart contracts ทำงานกับ ERC-20 อย่างเดียวได้

ความน่าสนใจคือ Uniswap ทำ user experience ตรงนี้ได้ราบรื่นมาก เราในฐานะ user สามารถใช้งาน Uniswap กับ ETH ได้โดยไม่จำเป็นต้องรู้เลยว่าเบื้องหลัง contracts จะทำงานกับ WETH อย่างเดียว เพราะ uniswap-v3-sdk จัดการตรงนี้ให้ทั้งการ provide liquidity และ swap

โดยวิธีทำก็คือ เมื่อ user ระบุให้ทำรายการด้วย ETH ภายใน uniswap-v3-sdk จะใช้ token address ของ WETH9 แทนที่ ETH ทันที และเพิ่มตัวแปร value: ลงไปใน transaction เพื่อให้มีการส่ง ETH ออกไปที่ contract ดังนี้

ส่ง `value:` ไปกับ liquidity transaction เมื่อ user ระบุว่าให้ทำธุรกรรมด้วย native token
ส่ง `value:` ไปกับ swap transaction เมื่อ user ระบุว่าให้ทำธุรกรรมด้วย native token

ซึ่งเมื่อเรามาดูที่ uniswap-v3-periphery เราจะเห็นว่าตัว smart contracts ต้องเช็คเพียงแค่ว่า transaction ระบุเป็น WETH9 address และมี value: ส่งมาด้วยหรือไม่ ถ้ามีก็ให้ wrap Ether value นั้นเป็น WETH9 ซะ แต่ถ้าส่ง WETH9 address มาแต่ไม่มี value: ติดมา แสดงว่าธุรกรรมนี้ให้ทำกับ WETH9 ได้ทันที ดังโค้ดจาก PeripheryPayments ด้านล่างนี้ (ซึ่งถูกใช้โดย SwapRouter และ NonfungiblePositionManager)

SwapRouter.sol

ตัว SwapRouter ทำหน้าที่แค่ยิง swap ไปให้ถูก pool ตามที่ตั้งค่าเข้ามา ส่วนหน้าที่ในการหา route ที่คุ้มค่าที่สุดจะอยู่ที่หน้าบ้านใน uniswap-interface การทำงานของ SwapRouter จึงค่อนข้างตรงไปตรงมา

  • exactInput() ใช้ในการแลกเหรียญโดยยึดจากจำนวนเหรียญที่ต้องการปล่อย ซึ่งอาจจะวิ่งผ่านหลาย pool เพื่อใช้เรทที่ดีที่สุดได้
  • exactInputSingle() เหมือน exactInput() แต่จะแลกผ่าน pool เดียว
  • exactOutput() ใช้ในการแลกเหรียญโดยยึดจากจำนวนเหรียญที่ต้องการได้รับ ซึ่งอาจจะวิ่งผ่านหลาย pool เพื่อใช้เรทที่ดีที่สุดได้
  • exactOutputSingle() เหมือน exactOutput() แต่แลกผ่าน pool เดียว
  • uniswapV3SwapCallback() หน้าที่คล้าย callback ของ NonfungiblePositionManager ที่มีหน้าที่ดึงเหรียญไป provide liquidity แต่ในที่นี้คือมีไว้เพื่อให้ UniswapV3Pool สามารถ callback กลับมาเพื่อดึงเหรียญไปแลกเปลี่ยนได้

เราอาจจะสงสัยว่าทำไมต้องมี exactInputSingle() และ exactOutputSingle() ในเมื่อเราใช้ exactInput() และ exactOutput() แทนก็ได้ หากเราลองเปรียบเทียบโค้ดดู จะเห็นว่าโค้ดแบบ single pool มี operation น้อยกว่ามากๆ ซึ่งทำให้การทำ single pool swap นั้น gas efficient กว่ามาก:

โค้ดแบบ single pool สามารถเรียก swap operation ได้ทันที
โค้ดแบบ multi pool มีทั้งลูป while(true), function calls, comparisons และ conditionals เพิ่มขึ้นจำนวนมาก

ลอจิคที่เหลือของ SwapRouter ทำหน้าที่เตรียมดาต้าเพื่อเรียกไปยัง UniswapV3Pool เพื่อทำการแลกเปลี่ยนเหรียญเท่านั้น

Project Setup

Hardhat Configuration

Hardhat config ของทั้ง uniswap-v3-core และ uniswap-v3-periphery นั้นค่อนข้างเรียบง่าย โดยจุดที่น่ากล่าวถึงน่าจะมีเพียงจุดเดียวคือการเลือก optimizer runs ของ periphery ที่ตั้งค่าแตกต่างกันแล้วแต่ contracts:

การเซ็ทค่า runs นั้นไม่ได้เป็นการกำหนดว่า optimizer จะรันกี่ iteration แต่เป็นการคาดการณ์ให้ optimizer รู้ว่าโค้ดชุดนี้จะถูกเรียกใช้บ่อยแค่ไหน การใช้เลข runs น้อย (เช่น runs: 1) เราจะได้โค้ดที่ deploy ถูกที่สุด แต่ใช้ gas ในการ call มากที่สุด ในขณะที่การใช้เลข runs สูงๆ (เช่น runs: 4_294_967_295 (2³²-1) ซึ่งเป็นค่าสูงสุดที่ใส่ได้) เราจะได้โค้ดที่ deploy แพงที่สุด แต่ใช้ gas ในการ call น้อยที่สุด

Optimizer Parameter Runs:

“The number of runs (--optimize-runs) specifies roughly how often each opcode of the deployed code will be executed across the life-time of the contract. This means it is a trade-off parameter between code size (deploy cost) and code execution cost (cost after deployment). A “runs” parameter of “1” will produce short but expensive code. In contrast, a larger “runs” parameter will produce longer but more gas efficient code. The maximum value of the parameter is 2**32-1.

Note: A common misconception is that this parameter specifies the number of iterations of the optimizer. This is not true: The optimizer will always run as many times as it can still improve the code.”

ซึ่งค่า default อยู่ที่ runs: 1_000_000 แปลว่า Uniswap ยอมจ่ายค่า deploy แพงหน่อยเพื่อแลกกับการ call SwapRouter ฯลฯ แต่ละครั้งที่ถูกลง มีเพียงไม่กี่ contracts ที่เป็นข้อยกเว้นตามที่เห็นใน hardhat config เช่น

  • NonfungiblePositionManager ถูกตั้งไว้ที่ runs: 2_000 เนื่องจาก contract มีขนาดใหญ่มาก แม้กระทั่งใช้ค่านี้ Uniswap เสียค่า deploy เพียง contract เดียวนี้ไป 3,546.74 USD (ประมาณ 110,000 บาท)
  • NonfungibleTokenPositionDescriptor เป็น contract ที่ใช้เรียกข้อมูลของ position ซึ่งไม่น่ามีการใช้งานใน transaction จึงใช้ runs: 1_000 เพื่อประหยัดค่า deploy
  • NFTDescriptor เป็น library ที่ใช้สร้าง NFT data สำหรับ display รวมถึงการ generate NFT art ยิ่งไม่มีการเรียก on chain เข้าไปใหญ่ จึงใช้ runs: 1_000 เหมือนกันเพื่อประหยัดค่า deploy

Fun fact: Uniswap V3 ใช้เงินประมาณ 16,000 USD (~500,000 บาท) ในการ deploy V3 contracts ทั้งชุด

Test Setup

Uniswap เขียน uniswap-v3-core/tests และ uniswap-v3-periphery/tests ไว้อย่างละเอียด แต่ค่อนข้างตรงไปตรงมา สามารถเลือกอ่านได้ตามต้องการ

ส่วน test framework นั้น Uniswap ใช้ hardhat test ซึ่งใช้ Waffle อีกที ซึ่งจะมากับท่ามาตรฐานของ EVM/Solidity นั่นก็คือ

  • Waffle (Smart contract test library)
  • Mocha (JavaScript test framework)
  • Chai (JavaScript assertion library)
  • Ethers.js (Ethereum library for JavaScript)
  • Typescript (Typed JavaScript)

Build Pipeline

Uniswap V3 ใช้ GitHub Actions ในการทำ build pipeline โดยที่ Core กับ Periphery มี pipeline ที่ต่างกันเล็กน้อย:

  • Linting (Core + Periphery): ใช้ prettier-plugin-solidity และ Solhint เป็น linter สำหรับ Solidity
  • Testing (Core + Periphery): รัน hardhat test โดยตรง
  • Fuzz testing (Core only): ใช้ Echidna ทำ smart contract fuzz testing และ Slither สำหรับ static analysis
  • Security analysis (Core only): ใช้ Mythx ในการช่วยตรวจหา security issues ใน smart contract

เรียกได้ว่า Uniswap V3 มี CI (Continuous Integration) pipeline ที่ค่อนข้างครบสำหรับ smart contract development และไม่มี CD (Continuous Delivery) pipeline ซึ่งไม่ใช่เรื่องแปลกของโลก smart contract ที่ไม่ได้มีการ deploy smart contract บ่อยครั้งนัก

เราได้ทำความรู้จัก Uniswap V3 ตั้งแต่วิธีการแบ่ง code repositories, ได้เห็นตัวอย่างของ smart contract implementation ที่น่าสนใจทั้งใน Core และ Periphery contracts, และได้เห็น project setup ที่ Uniswap V3 เลือกใช้กันไปแล้ว เราจะเห็นว่า smart contract development นั้นก็ยังใช้ศาสตร์ของ software development อยู่มาก เพียงแต่ตัวภาษาและการทำงานของ Ethereum (และ blockchain) มีลักษณะเฉพาะเจาะจง และเรากำลังเล่นกับการเปลี่ยนแปลง เคลื่อนย้าย ถ่ายโอนเหรียญที่มีมูลค่า และไม่สามารถย้อนธุรกรรมกลับได้ ทำให้การเขียนโค้ดจะต้องมีความรัดกุมในแบบของมัน ท้ายนี้เราจึงหวังว่าบทความนี้จะทำให้คุณรู้จัก Uniswap V3, ได้เห็นภาพของการพัฒนา smart contract, และสนใจที่จะก้าวมาสู่โลก decentralized finance มากขึ้นไม่มากก็น้อยครับ

Career alert: หากคุณสนใจเนื้องาน smart contract development ในลักษณะนี้ มาร่วมทีมกับเราได้ ส่ง resume มาได้ที่ join10x@scb10x.com

--

--