Building Gasless NFT with EIP-2771 and Opensea Compatible

Natthapach Anuwattananon
5 min readSep 24, 2022

--

สวัสดีครับ บทความนี้ก็เป็นบทความสุดท้ายในซีรี่ย์ Building NFT Smart Contract for Real World Usecase แล้วนะครับ สำหรับบทความอื่นๆในซีรี่ย์นี้ก็ตามวาร์ปด้านล่างเลยครับ

บทความที่แล้วผมได้เหล่าถึง EIP 712 Typed Data, EIP 2771 Native Meta Transaction และการทำ Gasless Payment ไปแล้ว บทความนี้เราจะใช้คอนเซปที่เล่าไปในการสร้าง NFT แบบ Gasless ครับ ซึ่ง user จะสามารถขายหรือส่ง NFT นี้บน opensea ได้ฟรี โดยไม่เสียค่า gas เลยครับ

**อัพเดต ตอนนี้ Opensea ย้ายมาใช้ Seaport engine แล้ว ทำให้ไม่ซัพพอร์ต Gas-free บน polygon แล้วนะครับ ถึงจะน่าเสียดายแต่เนื้อหาในบทความนี้น่าจะมีประโยชน์สำหรับการเข้าใจ EIP-2771 และการประยุกต์ใช้อื่นๆ ครับ

Basis of Gasless NFT

เนื่องจากการทำ gasless NFT นั้นมีคอนเซปที่เกี่ยวข้องค่อนข้างเยอะ ดังนั้นก่อนจะพาไปดู implementation ผมจะเล่าถึงคอนเซปต่างๆที่เกี่ยวข้องก่อนนะครับ

ภายในบทความผมจะตัดโค้ดบางส่วนมาค่อยๆอธิบายให้ครับ สำหรับโค้ดฉบับเต็มสามารถดูได้บน mainnet ตาม address นี้เลยครับ 0xa5451C2599b0dF18562468944e2BECD01Df2e5Fa

Opensea Polygon NFT

หลายคนน่าจะเคยได้ยินว่าหากทำการสร้าง NFT ใน opensea บน Polygon chain นั้นจะสามารถทำได้ฟรี โดยไม่ต้องเสียค่า gas เองเลย เบื้องหลังเทคนิคนี้ก็คือการที่ smart contract NFT ของ opensea นั้นใช้ Native Meta Transaction เช่นกันครับ

นอกจากนี้ ตาม document ของ opensea หาก contract อื่นได้ implement meta transaction ไว้ opensea ก็จะทำให้สามารถใช้งานฟีเจอร์ gasless ได้เช่นกันครับ

ดังนั้นแล้ว เป้าหมายของเราในวันนี้คือการสร้าง NFT ที่มีการ implement meta transaction เพิ่มไว้นั้นเองครับ

Native Meta Transaction (EIP-2771)

Meta transaction concept

ทบทวน meta transaction แบบสั้นๆกันก่อนสักรอบครับ ตามคอนเซปของ meta transaction นั้น ผู้ใช้งาน จะต้องทำการ sign transaction request ที่ข้างในระบุรายละเอียดว่าต้องการทำธุรกรรมอะไรกับ smart contract

จากนั้นจะส่ง signature ดังกล่าวให้กับ Gas Relayer ผู้ซึ่งจะนำ signature นั้นไปสร้างเป็น transaction ส่งไป execute ให้ พร้อมทั้งออกค่า gas ครับ

ส่วนทางด้าน smart contract เมื่อได้รับ transaction มา จะทำการ verify transaction request และ execute ในนามของ user ที่ทำการ sign request มาครับ

ซึ่งภายใน executeMetaTransaction นั้นจะแบ่งหลักๆออกเป็น 2 ขั้นตอนครับ คือการ verify signature และ data ที่ส่งเข้ามา ว่าผู้ที่ sign นั้นเป็น user address จริงๆหรือไม่ จากนั้นก็จะทำการ call function ตามที่ request เข้ามาครับ ซึ่งหลังจากขั้นตอนนี้ก็จะเป็น logic ภายในฟังก์ชั่นนั้นๆแล้วครับ

Function Signature

องค์ประกอบหลักๆของทุกๆ transaction ใน blockchain มี 4 ส่วนครับ คือ sender address, receiver address, data และ ether

ถ้าอย่างนั้นแล้ว การคอล smart contract จะสร้าง transaction ออกมาแบบไหนล่ะ?

ในการคอล smart contract นั้น receiver address ก็คือ address ของ smart contract ครับ ส่วน data คือข้อมูลเพื่อบอก contract ว่าต้องการ execute method อะไร ด้วย parameter อะไรบ้าง

data สำหรับระบุ method และ parameter นี้แหละครับที่เราเรียกว่า function signature

function signature of transfer ERC20 token

ในเมื่อ function signature เป็นข้อมูลที่มีรายละเอียดสำหรับการ execute ครบถ้วนแล้ว ดังนั้นสิ่งนี้แหละครับ คือสิ่งที่จะถูกนำไปใช้ใน transaction request ของ Meta Transaction ส่วนจะนำไปใช้ต่อยังไงนั้น ผมมีอธิบายอยู่ในบทความนี้แล้วครับ

Verifying Signature

Step to verify typed message data

เป้าหมายของการ verify signature นั้นก็คือการใช้ signature และ message ถอดกลับไปเพื่อหาว่าใครเป็นผู้ sign message นี้ จากนั้นก็นะจะนำ signer ที่ได้ไปเทียบว่าตรงกับ user address ที่ระบุมารึเปล่าครับ

แต่ว่าจริงๆแล้ว algorithm crypto graphic ต่างๆนั้นไม่ได้ซัพพอร์ตข้อมูลแบบ structure หรอกครับ ดังนั้นจริงๆแล้วที่ EIP 712 ทำก็คือการนิยามมาตราฐานวิธีการ encode ข้อมูลแบบ structure ให้เป็น string เพื่อให้สามารถใช้งาน algorithm crypto grahpic ได้เหมือนเดิมครับ

Hashing Meta Transaction

hashMetaTransaction in NativeMetaTransaction contract

สำหรับวิธีการ hashing structure data ตามนิยามใน EIP-712 จะมีอยู่ 2 ส่วนหลักๆครับ คือ typeHash สำหรับระบุโครงสร้างของข้อมูล และ encodeData ที่เป็นข้อมูลที่เราต้องการ hash ผมสรุปวิธีตามนิยามคร่าวๆมาให้ตามด้านล่างนี้ครับ (เครื่องหมาย ‖ หมายถึง string concatenate ครับ)

encodeType = name ‖ '(' ‖ member1 ‖ ',' ‖ ... || ',' ‖ member_n ‖ ')'
typeHash = keccak256(encodeType)
encodeData = enc(v1) ‖ enc(v2) ‖ ... ‖ enc(v_n)
hashStruct = keccak256(typeHashencodeData)

เอาล่ะครับ ก่อนจะมีใครงงกับนิยามในเปเปอร์ไปก่อน เรากลับไปดูที่โค้ดกันดีกว่า สำหรับส่วนแรกที่เราต้องมีคือ typeHash ครับ ซึ่งปกติโครงสร้างของ Meta Transaction นั้นมันตายตัวอยู่แล้ว จึงนิยมประกาศเป็น constant ไปเลยครับ

ทีนี้อยากให้ลองกลับไปเทียบกับ นิยามข้างบนครับ จะเห็นว่าการสร้าง type hash นั้นก็คือการนำ name และ type มาเรียงต่อกัน (encodeType) จากนั้นก็นำไปเข้า keccak256 เพื่อสร้าง typeHash แค่นี้เองครับ

ส่วนต่อมาคือการสร้าง encodeData ครับ ซึ่งก็เพียงแค่นำข้อมูลมาเรียงต่อกัน (ต้องตามลำดับเดียวกับ encodeType นะ) เพียงแค่ก่อนนำมาเรียงต้อง encode ข้อมูลแต่ละตัวก่อน ซึ่งข้อมูลแต่ละ type นั้นจะนิยามการ encode ต่างกันครับ เช่น

  • boolean: ต้องทำเป็น uint256 ที่เก็บ 0 (false) หรือ 1 (true)
  • bytes1- bytes31: zero padding ให้เป็น bytes32
  • address: ทำเป็น uint160 (คือเหมือนเดิมแหละอันนี้)
  • dynamic bytes/string: นำเข้า keccak256

จากนั้นก็นำ typeHash และ encodeData มาเรียงต่อกันแล้วเข้า keccak256 ครับ เพียงเท่านี้ก็ได้ hashStruct แล้ว

Domain Seperator

เพียงแค่ hash data นั้นยังไม่รัดกุมเพียงพอครับ เพราะมันไม่ได้ระบุว่า message นี้สร้างขึ้นมาสำหรับ contract ไหน ดังนั้น typed data ที่สมบูรณ์ต้องเพิ่มส่วนที่ระบุ contract ที่จะนำ data นี้ไปใช้ได้ด้วย ซึ่งส่วนนี้เรียกว่า Domain Seperator ครับ

วิธีสร้าง domain separator นั้นเหมือนกับ hashStruct เลยครับ เพียงแต่จุดสำคัญอยู่ที่ข้อมูลที่อยู่ข้างในครับ ตามนิยามใน EIP 712 ต้องมีข้อมูลดังนี้

  • name: ชื่อของ smart contract ซึ่งโดยปกติแล้วเราก็จะใช้ชื่อของ token หรือ NFT อันเดียวกับที่อยู่ใน ERC20/ERC721 เลยครับ
  • version: version ของ smart contract ตรงนี้สร้างมาเพื่อกันพวก upgradable contract ครับ (แต่ถ้า upgrade แล้ว version เดิมก็ไม่ช่วยอยุ่ดีนะครับ 5555)
  • chainId: chain id ของ contract ครับ อันนี้ระบุเพื่อป้องกันการที่เรา sign message สำหรับเชนนึง แล้วโดนเอาไป execute ที่เชนอื่น
  • verifyingContract: contract address อันนี้เข้าใจง่ายสุดครับ ระบุเพื่อป้องกันการเอา message ไป execute ที่ contract อื่น
  • salt: อันนี้สารภาพตรงๆว่ายังไม่เข้าใจว่าใน paper สร้างขึ้นมาเพื่ออะไรกันแน่ครับ (ท่านใดเข้าใจทักมาคุยด้วยกันได้นะครับ)

แต่จากใน code จะเห็นได้ว่า implementation ส่วนใหญ่จะใช้ chainId ลงไปใน salt เลยครับ

Domain Separator นั้นไม่จำเป็นต้องคำนวณใหม่ทุกครั้ง ดังนั้นจึงสามารถสร้างเก็บไว้ตั้งแต่ช่วง initial contract ได้เลยครับ

Typed Message Hash

เอาล่ะครับ มาถึงการประกอบร่างทุกอย่างที่เราเตรียมมาเป็น typed message hash แล้ว (ใครหลุดแล้ว เลื่อนกลับไปดูแผนภาพด้านบนได้นะครับ)

การสร้าง typed message hash ต้องใช้ message 3 อย่างมาต่อกันครับ message prefix, domain separator และ hash struct (message hash) ก่อนนำไปเข้า keccak256

สำหรับสองอย่างหลังนั้นคือสิ่งที่เราทำมาจากด้านบนครับ ส่วน message prefix x19x01 คืออะไรนั้น ผมจะเล่าให้ฟังครับ

EIP-191: Signed Data Standard

โดยปกติเรา การที่เรานำ private key มา sign message จะมี message อยู่ 3 ประเภทครับ คือ Transaction, Plain String Message (personal sign) และ Struct Data (sign typed data)

คำถามคือ แล้วถ้าเราเห็น message ที่จะ sign ในรูป binary สามารถแยกออกหรือไม่ว่าเป็น message แบบใด? ดังนั้น EIP-191 จึงนิยาม prefix ของ message ทั้งสามแบบไว้ดังนี้ครับ

  • message ที่ไม่ใช่ transaction ให้ byte แรกเป็น x19 (เพราะ message transaction จะไม่มีทางที่ byte แรกจะเป็น x19 ครับ อ่านต่อได้ที่ RLP_encode)
  • byte ที่สอง (เรียกว่า version byte) กำหนดให้ x01 สำหรับ struct data และ x45 สำหรับ Plain String Message (personal sign)

นี้แหละครับที่มาของ x19x01

Verify Signer

หลังจากเราสร้าง Typed Message Hash ได้แล้ว ก็นำไป recovery กลับหา signer message ด้วยฟังก์ชัน ecrecover ครับ จากนั้นก็นำไปเทียบกับ userAddress ที่ระบุมา

ในที่สุดเราก็ผ่านมาครึ่งทางสำหรับ execute meta transaction แล้วครับ!!

Call Function

ปัญหาสำคัญของการ call function ก็คือ msg.sender นั้นคือ Gas Relayer ครับ ไม่ใช่ user ดังนั้นเราต้องหาวิธีทำให้ฟังก์ชั่นต่างๆใน contract ของเรารู้ว่าจริงๆแล้วใครคือคนที่ต้องการคอลฟังก์ชั่นนี้กันแน่ครับ

ContextMixIn

ทวนเรื่องการ call function เล็กน้อยครับ โดยปกติแล้วเราสามารถคอลฟังก์ชั่นของ contract ใดๆก็ได้ ด้วย encode function และ parameter จากนั้นก็ส่งไปหา contract นั้นๆ (ลองดูใน transaction data เวลาคอล smart contract ได้ครับ)

คุ้นๆไหมครับว่าการ encode แบบนั้นคืออะไรที่เรามีอยู่?

.

functionSignature ที่ส่งเข้ามานั่นเองครับ!

ในเมื่อเรามีข้อมูลสำหรับการคอลเรียบร้อยแล้ว ก็สามารถนำไปเรียกฟังก์ชั่น .call ได้เลยครับ แต่ความพิเศษอยู่ตรงนี้ครับ คือเราจะแปะ user address ติดไปกับท้าย call data ด้วย

แต่ว่าการแปะ user address ไปแค่นั้นฟังก์ชั่นที่เราคอลไปก็ไม่รู้อยู่ดีครับ ดังนั้นขั้นตอนต่อไปเราจะมาสร้างฟังก์ชันเพื่อถอด user address ออกมาใช้แทน msg.sender กันครับ

ขั้นแรก เราจะสร้างฟังก์ชันใหม่ msgSenderสำหรับใช้เรียกแทน msg.sender ครับ โดยภายในจะทำการเช็คว่า msg.sender จริงๆแล้วเป็นตัว contract เองรึเปล่า ถ้าใช่แสดงว่าเป็น call มาจาก executeMetaTransaction ครับ ในเคสนี้เราจะต้องแกะ userAddress ที่แปะไว้ออกมาจาก data ครับ

ขั้นตอนการเช็คแบบนี้เทียบเท่ากับ trust forwarder ในเปเปอร์ EIP-2771โดยมองว่า trust forward มีเพียงตัว contract เองครับ

จุดนี้ต้องตั้งสติกันนิดนึงนะครับ เพราะว่าต้องใช้ assembly ช่วยแล้ว ก่อนอื่นมาทบทวนกันอีกเล็กน้อยครับ ในฟังก์ชั่นนี้เรามี msg.data ที่ข้อมูลส่วนหน้าเป็น data จริงๆสำหรับใช้ใน function ส่วน 20 bytes สุดท้ายเป็น userAddress ที่เราแปะเข้ามา

ดังนั้นเป้าหมายของเราคือการหาว่า 20 bytes สุดท้ายใน msg.data นั้นมีค่าเป็นอะไรครับ

msgSender algorithm in ContextMixIn

msg.data นั้นเป็นข้อมูลประเภท bytes[] (array of bytes) ดังนั้นค่าของ msg.data จริงๆคือ pointer ระบุตำแหน่งของข้อมูล array ที่อยู่ใน memory ครับ โดยการเก็บข้อมูล array ใน memory นั้น จะใช้ byte แรกในการเก็บความยาวของ array ครับ

ดังนั้น add(array, index) ถ้าเทียบกับในรูปจะมีค่าเท่ากับ d+L ครับ (function add นั้นรับ int แต่ตัวแปร array เป็น pointer จึงถูก cast ให้เป็น int ซึ่งก็คือตำแหน่งเริ่มต้นของ array นั้นเองครับ)

หลังจากได้ตำแหน่งท้ายของ msg.data แล้ว เราก็จะใช้ mload เพื่ออ่านข้อมูลออกมาครับ แต่ว่า mload นั้นอ่านข้อมูลออกมา 32 bytes ทั้งๆที่เราต้องการเพียง 20 bytes สุดท้ายสำหรับ userAddress

ดังนั้นเราจึงต้องใช้ bitwise mask เพื่อเลือกเอาเฉพาะ 20 bytes ที่เราต้องการออกมาครับ เพียงเท่านี้เราก็จะได้ user address สำหรับไปใช้แทน msg.sender แล้วครับ

จากนั้นเราก็แทนที่ msg.sender ต่างๆใน contract ด้วย msgSender ได้เลยครับ

ข้อควรระวัง คือหากใช้ใน function ที่ต้องมีการคอลกันภายใน smart contract เอง จริงๆ ก็จะถูกมองว่าเป็น trust forwarder แล้วเข้าวิธีแกะ msg sender แบบนี้เช่นกันได้ครับ

Try It on Opensea

เอาล่ะครับ หลังจากทุกอย่างครบแล้ว ก็ได้เวลา deploy และไปทดสอบบน opensea กันแล้วครับว่าสามารถใช้งานแบบ Gasless ได้จริงไหม

วิธีทดสอบก็ง่ายๆครับ เพียงแค่ลองกด transfer หรือ sell ดู ในขั้นตอนการ sign จะเป็นเพียงแค่การ sign message เท่านั้น ไม่ใช่ transaction ครับ ตามภาพด้านล่างเลย

transfer without gas fee
sell without gas fee

สามารถไปทดลอง mint และเล่น contract ที่อยู่ในบทความนี้กันได้นะครับ contract, opensea

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

แล้วพบกันใหม่ในบทความชุดต่อไปครับ

--

--