เจาะลึก Uniswap V3 Smart Contracts
จากหลายๆ โปรโตคอลที่เราได้ทำการศึกษาและทดลองที่ SCB 10X เราพบว่าโปรโตคอล Uniswap V3 เป็นหนึ่งในโปรโตคอลที่มีความน่าสนใจทั้งในแง่ของการเป็น Decentralize Exchange (DEX) ที่มีมูลค่าธุรกรรมอันดับ 1 ในโลก Decentralized Finance อีกทั้งยังมีความซับซ้อนในแง่โครงสร้างและส่วนประกอบของ smart contracts ที่น่าหยิบยกมาเป็นกรณีศึกษา
วันนี้จึงขอมาเล่าว่า Uniswap V3 มีโครงสร้างและส่วนประกอบทาง smart contracts ที่น่าสนใจอย่างไรบ้าง เราหวังว่าบทความนี้จะเป็นประโยชน์สำหรับผู้ที่ต้องการศึกษาและพัฒนา smart contracts บนโลก blockchain ทั้งสำหรับผู้เริ่มต้นและผู้ที่มีประสบการณ์แล้วแต่ยังไม่มีโอกาสได้ศึกษา Uniswap V3 smart contracts ครับ
บทความนี้แบ่งออกเป็น 4 ส่วน ได้แก่
- Code Repositories
แจกแจง code repositories ที่เกี่ยวข้องกับ Uniswap V3 พร้อมอธิบายขอบเขตความรับผิดชอบของแต่ละ repositories - V3 Core Deep Dive
อธิบายการทำงานของ smart contracts ที่สำคัญใน uniswap-v3-core - V3 Periphery Deep Dive
อธิบายการทำงานของ smart contracts ที่สำคัญใน uniswap-v3-periphery - 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 ส่วนคือ
- UniswapV3Factory
- สร้าง liquidity pool ใหม่ (createPool()
)
- เก็บ address mapping จาก token ไปยังแต่ละ liquidity pool เพื่อให้ contract อื่น และ SDK สามารถค้นหา pool ที่มีใน Uniswap V3 ได้
- ในจักรวาล Uniswap V3 มี contract นี้อยู่เพียงชุดเดียว - 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 หลัก นั่นคือ
- NonfungiblePositionManager
- ทำหน้าที่บันทึกว่า user คนไหนมีสินทรัพย์อะไรในระบบบ้าง ไม่ว่าจะเป็น position ในUniswapV3Pool
ไหนก็ตาม (NFT position และ owner ถูกบันทึกที่นี่)
- สร้าง position ใหม่ (mint()
) และทำลาย position เก่า (burn()
)
- ปรับเปลี่ยนปริมาณเหรียญใน position (increaseLiquidity()
,decreaseLiquidity()
)
- ใน Uniswap V3 มี deploy contract นี้เพียงชุดเดียว ใช้ร่วมกันทุก pool - 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 ได้ชัดเจนมาก
กล่าวง่ายๆ ก็คือ
uniswap-interface
ไว้เก็บรูปร่างหน้าตาเว็บuniswap-v3-sdk
ไว้เก็บโค้ดที่ทำให้หน้าเว็บคุยกับ smart contract ได้uniswap-v3-core
ไว้เก็บ smart contract ที่เป็น core logic ในการสร้าง pool และการคำนวณเหรียญเข้าออก pooluniswap-v3-periphery
ไว้เก็บ smart contract ที่ช่วยเตรียม parameters ก่อนส่งไปให้ core contractsuniswap-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 ด้านล่าง
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
addresssalt
จะต้องเป็นkeccak256(abi.encode(key.token0, key.token1, key.fee))
ของ pool นั้นๆ ทำให้ pool ที่เรียกมาจะต้องมีtoken0
,token1
และfee
ตรงตาม callback datacontractHashedByteCode
จะต้องตรงกับ hashed bytecode ของUniswapV3Pool
ซึ่งถูกกำหนดไว้ตั้งแต่แรกที่PoolAddress.POOL_INIT_CODE_HASH
เป็นการล็อคว่าจะทำงานกับ pool implementation เวอร์ชั่นนี้เท่านั้น
เมื่อครบตามเงื่อนไขด้านบน address ของ msg.sender
และ PoolAddress.computeAddress()
จึงจะตรงกันได้
สำหรับฟังก์ชั่นอื่นๆ ใน 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 เข้าไปใน positiondecreaseLiquidity()
ทำหน้าที่ดึง liquidity ออกจาก positioncollect()
ทำหน้าที่เก็บ trading fees ค้างจ่ายและโอนค่าธรรมเนียมนั้นเข้ากระเป๋าเจ้าของ positionburn()
ทำหน้าที่ลบ liquidity position (burn NFT ทิ้ง)
หากอ่านจากชื่อฟังก์ชั่นคร่าวๆ จะเห็นว่ามีหลายฟังก์ชั่นที่ชื่อทับซ้อนกับ UniswapV3Pool
ทั้งนี้เพราะฟังก์ชั่นใน UniswapV3Pool
จะเน้นการจัดการ pool liquidity และ price แต่ NonfungiblePositionManager
จะเน้นที่การจัดการ user position และ trading fee เช่น
NonfungiblePositionManager.mint()
มีหน้าที่เรียกUniswapV3Pool.mint()
เพื่อเพิ่ม liquidity ลงใน pool แล้วมิ้นท์ NFT position ออกมาให้กับ liquidity providerNonfungiblePositionManager.collect()
มีหน้าที่คำนวณยอด trading fee คงค้างล่าสุดแล้วจึงเรียกUniswapV3Pool.collect()
เพื่อดึงโทเค่นออกมาสู่กระเป๋าเจ้าของ position แต่ตัวUniswapV3Pool.collect()
ลำพังเองจะไม่มีการคำนวณ trading fee ล่าสุด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 ดังนี้
ซึ่งเมื่อเรามาดูที่ 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 กว่ามาก:
ลอจิคที่เหลือของ 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 น้อยที่สุด
“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 is2**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
เพื่อประหยัดค่า deployNFTDescriptor
เป็น 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