Solmate ERC20 safeTransferFrom vs Openzeppelin safeTransferFrom (Part 1)

Nattawat Songsom
5 min readJul 5, 2023

--

มาเทียบ safeTransfer ของทั้งสองเจ้าแบบไวๆกัน

พอดีไปเห็น post นี้มา

โดยเค้าบอกว่า safeTransfer และ safeTransferFrom ของ Solmate เนี่ย ถึงเราจะเรียกโอน ERC20 ที่ไม่มีอยู่จริงก็ไม่ revert (น่าจะหมายถึง address ที่ยังไม่มี contract ไป deploy)

โดยเค้าบอกว่าควรใช้ safeTransferFrom ของ Openzeppelin แทน เนื่องจากจะ revert เมื่อโอน ERC20 ที่ไม่มีอยู่จริง

แต่ ทำไมถึงเป็นยังงั้นละ ?

โอเค มาลองแกะ code safeTransferFrom ของแต่ละเจ้ากัน

Solmate safeTransferFrom

    function safeTransferFrom(
ERC20 token,
address from,
address to,
uint256 amount
) internal {
bool success;

/// @solidity memory-safe-assembly
assembly {
// Get a pointer to some free memory.
let freeMemoryPointer := mload(0x40)

// Write the abi-encoded calldata into memory, beginning with the function selector.
mstore(freeMemoryPointer, 0x23b872dd00000000000000000000000000000000000000000000000000000000)

เริ่มจากการนำ function selector ไปใส่ใน memory

mstore(add(freeMemoryPointer, 4), and(from, 0xffffffffffffffffffffffffffffffffffffffff)) // Append and mask the "from" argument.
mstore(add(freeMemoryPointer, 36), and(to, 0xffffffffffffffffffffffffffffffffffffffff)) // Append and mask the "to" argument.
mstore(add(freeMemoryPointer, 68), amount) // Append the "amount" argument. Masking not required as it's a full 32 byte type.

จากนั้นนำ parameter ทั้ง 3 ไปใส่ใน memory

      success := and(
// Set success to whether the call reverted, if not we check it either
// returned exactly 1 (can't just be non-zero data), or had no return data.
or(and(eq(mload(0), 1), gt(returndatasize(), 31)), iszero(returndatasize())),
// We use 100 because the length of our calldata totals up like so: 4 + 32 * 3.
// We use 0 and 32 to copy up to 32 bytes of return data into the scratch space.
// Counterintuitively, this call must be positioned second to the or() call in the
// surrounding and() call or else returndatasize() will be zero during the computation.
call(gas(), token, 0, freeMemoryPointer, 100, 0, 32)
)
}

require(success, "TRANSFER_FROM_FAILED");

ต่อมาเป็น core logic ส่วนเช็คว่า safeTransfer สำเร็จมั้ย

โดยจาก post นี้อธิบายไว้ว่า

success = การเรียก low level call success และ ( (ค่า return เป็น type boolean และเท่ากับ true) หรือ (ไม่มีค่า return) )

โอเค มาดู code กัน

จาก (ค่า return เป็น type boolean และเท่ากับ true) หรือ (ไม่มีค่า return)

จะได้ code คือ

// Set success to whether the call reverted, if not we check it either
// returned exactly 1 (can't just be non-zero data), or had no return data.
or(and(eq(mload(0), 1), gt(returndatasize(), 31)), iszero(returndatasize())),

มาแยก code เป็นส่วนย่อยๆกัน

or(and(eq(mload(0), 1), gt(returndatasize(), 31)), iszero(returndatasize()))
แยกได้เป็น
and(eq(mload(0), 1), gt(returndatasize(), 31)) OR iszero(returndatasize()

and(eq(mload(0), 1), gt(returndatasize(), 31))
แยกได้เป็น
eq(mload(0), 1) AND gt(returndatasize(), 31)

โอเค เริ่มจากส่วนแรก eq(mload(0), 1) => เป็นการ check return value ของ external call ไปยัง ERC20 ปลายทางว่า return true รึเปล่า

แต่เดี๋ยวนะ เราจะเช็ค return value ของ external call ได้ยังไง ถ้าเรายังไม่ได้เรียก external call นั้นที

จาก post นี้เราจะเจอว่า เวลา solc compile logic ของ Yul จะทำการอ่าน code จากขวาไปซ้าย

แสดงว่า จาก code

success := and(
or(and(eq(mload(0), 1), gt(returndatasize(), 31)), iszero(returndatasize())),
call(gas(), token, 0, freeMemoryPointer, 100, 0, 32)
)
}

จะเรียงใหม่เป็น

success := and(
call(gas(), token, 0, freeMemoryPointer, 100, 0, 32),
or(and(eq(mload(0), 1), gt(returndatasize(), 31)), iszero(returndatasize()))
)
}

นั่นเอง

โอเค eq(mload(0), 1) => เช็คว่าค่า return จาก ERC20 เป็น 1 รึเปล่า

ต่อมาคือ gt(returndatasize(), 31) เป็นการเช็คว่าค่าที่ return จาก ERC20 เป็น type boolean รึเปล่า

ดังนั้นถ้าเอามารวมกันจะได้ว่า

  1. เช็คว่าค่าที่ return จาก ERC20 เป็น type boolean รึเปล่า
  2. เช็คว่าค่า return จาก ERC20 เป็น 1 รึเปล่า => 1 ของ boolean คือ true นั่นเอง

เอาละ สุดท้ายคือ

ถ้าไม่มีค่า return ก็จะยังถือว่าผ่านเงื่อนไข

or(and(eq(mload(0), 1), gt(returndatasize(), 31)), iszero(returndatasize())),

โดย iszero(returndatasize()) = ไม่มีการ return ค่ากลับมานั่นเอง

เนื่องจากมีบาง ERC20 ดันไปเขียน

 function transferFrom(...) external;

แทนที่จะเป็น

 function transferFrom(...) external returns (bool success);

ทำให้มีบางที่ return ค่าบ้าง มีบางที่ไม่ return ค่าบ้างนั่นเอง

เอาละจากประโยค success = การเรียก low level call success และ ( (ค่า return เป็น type boolean และเท่ากับ true) หรือ (ไม่มีค่า return) )

เราเคลีย code ส่วน ( (ค่า return เป็น type boolean และเท่ากับ true) หรือ (ไม่มีค่า return) ) ไปละ

มาเคลียส่วน การเรียก low level call success ต่อกัน

โดย code คือ

call(gas(), token, 0, freeMemoryPointer, 100, 0, 32)

เอาละมาดู syntax call, gas กัน

จะได้ว่าเรียก function transferFrom ที่ contract ERC20 โดย

1.g = gas() => ใช้ gas เท่าที่มีเหลือ

2. a = token => contract ERC20 มี address ตามที่ส่งเข้ามาใน safeTransferFrom

3. v = 0 => ไม่ต้องส่ง msg.value ไป

4. in = freeMemorypointer, insize = 100 แปลว่าให้ใช้ memory[32–132]

ทำไมเราถึงใช้ slot 100 ช่อง?

โดยจาก code เราจะเห็นว่า เราใช้ 4 ช่องแรกกับการเก็บ function selector

1 = 23

2 = b8

3 = 72

4 = dd

จากนั้นอีก 32*3 ช่องที่เหลือเราเอาไปเก็บ from to และ amount นั่นเอง

และสุดท้าย

5. out=0, outsize = 32 แปลว่าให้นำค่า return จากการเรียก ERC20 มาเก็บใน memory ตำแหน่ง 0–32

ที่ต้องใช้ 32 ช่อง น่าจะเพื่อให้รับกับ boolean ที่มีขนาด 32 byte ได้ละนะ

โดยค่า return จาก call จะเป็น 0 ถ้ามีการ revert

โอเค ดังนั้นในการ เช็คว่าการเรียก low level call success หรือไม่นั้น ทำได้โดยการเช็ค

call(…) == 1 นั่นเอง

โอเค จากทัังหมดที่ไล่ code มา

ทำไมการเรียก safeTransfer ของ token ที่ไม่มีอยู่จริงถึงไม่ revert ละ ?

จะพบว่า call ถูก design มาให้ return true ถ้า contract ที่ call ไปไม่มีอยู่จริงนั่นเอง

ดังนั้นเมื่อ contract ไม่มีอยู่จริง จะพบว่า เงื่อนไขการ success จะผ่านทุกข้อนั่นคือ

call return 1 => ผ่าน

function ปลายทางไม่มีการ return value => ผ่าน

โอเค แกะ code Solmate ก็ประมาณนี้ ไว้ค่อยมาแกะ code OpenZeppelin ต่อกัน

Referrences

--

--

No responses yet