Meme Atlas AZ · read-contract-permissions

合約權限狀態怎麼讀:鑄幣、凍結、擁有者與可升級代理

依 EIP-20、OpenZeppelin 與 Etherscan 文件,逐項讀出代幣合約的鑄幣、凍結、擁有者與代理升級權限,並說明每個讀數能支持什麼說法。

發布
更新
編輯責任
Meme Atlas AZ

打開一個代幣的合約頁,最先看見的多半是名稱、精度與總量。這幾欄好讀,也的確是標準規定的欄位。真正卡住人的是下一個問題:這串數字明天會不會被誰改掉。頁面上沒有一欄直接寫著「誰能改」。

權限在鏈上其實可以讀,只是散落在幾個彼此不相通的位置:合約採用了哪一套存取控制、這個地址背後是否還掛著一份可替換的邏輯、在 Solana 上你手裡的那串公開鍵屬於哪一層。要把它們讀齊,需要一個順序,也需要在每一步記下這個讀數能支持什麼樣的說法。

底下寫的是讀法。全文不對任何代幣給出安全與否的判斷,也不涉及買賣。

標準規定的欄位裡沒有「誰能鑄幣」

EIP-20 原文列出的介面只有這些:name、symbol、decimals、totalSupply、balanceOf、transfer、transferFrom、approve、allowance,再加上 Transfer 與 Approval 兩個事件。清單到此為止。mint、burn、pause、owner、凍結與黑名單,一項都不在裡面。

這條事實有個反向用途。一個代幣若具備增發或暫停的能力,那是它在標準之外自己寫進去的功能,屬於實作層面的選擇。因此「它是標準的 ERC-20」這句話推不出「它不能增發」,同樣也推不出「它可以增發」。

於是問題要換個問法:不再問這個代幣是不是標準代幣,而是問它在標準之外多寫了哪些函式、那些函式由誰調用。答案不在標準文件裡,只能到合約自己的程式碼與權限設定去找。

先確定你讀的程式碼還在生效:代理與 implementation 槽

代理合約會讓上面這件事整個往前挪一格。OpenZeppelin 的代理文件把機制講得很直白:Proxy 提供 fallback,透過 EVM 的 delegatecall 把收到的呼叫轉交給另一個合約執行,而那個 implementation 地址是可以被更換的。你在區塊瀏覽器上打開的那個地址,未必是目前真正在跑的邏輯所在。

這兩個地址存放在 ERC-1967 指定的槽位裡:implementation 槽是 `0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc`,admin 槽是 `0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103`,兩者都可以用 eth_getStorageAt 讀出來。

真的動手讀的時候有個小習慣值得養成:槽位回傳的是一個完整儲存字,不是縮寫過的地址;把回傳值原樣抄進記錄,不要只留後半段。如果讀到全零,該寫的是「這個槽在這個區塊為空」,而不是「這不是代理合約」——非標準的代理實作也存在,全零只說明這一個特定槽位沒有值。

代理本身還分做法。Transparent Proxy 的規則是:非 admin 發出的呼叫一律轉發到 implementation;admin 發出的呼叫則不被轉發,並且可以執行 upgradeToAndCall。能升級的只有 admin 那一個身分。UUPS 把升級邏輯搬進 implementation 本身,實作要繼承 UUPSUpgradeable 並覆寫 `_authorizeUpgrade`,這條升級路徑最終甚至可以被移除;它內建的機制會阻止升級到不相容 UUPS 的實作。兩種安排下,「誰能換掉邏輯」要去看的地方並不相同。

為什麼先讀代理槽再讀 owner

代理槽應該排在 owner 之前讀。這個順序不是隨手排的。

讀 owner 這個動作本身依賴於一個前提:你正在讀的程式碼就是現在生效的程式碼。如果一個地址掛著代理,你在 admin 槽以外看到的所有權限函式都來自某一份 implementation。先把 implementation 定下來,後面讀到的 owner、角色與鑄幣函式才有明確的歸屬對象。倒過來做,你會得到一組讀數,卻不知道它們屬於哪一份邏輯,一旦後來發現有代理,前面那些記錄多半得重讀一遍。

代價是多花一步。大量合約根本不是代理,讀完兩個槽拿到兩個零值,看起來像白做工。這一步仍然值得放在第一位,因為漏掉代理造成的錯誤是靜默的:你會得到一份完整、自洽、卻指向錯誤對象的權限記錄。

擁有者與角色是兩套並排的權限面

EVM 上的存取控制常見兩種寫法,讀法各不相同。

Ownable 只有單一擁有者。合約建立時 owner 由 initialOwner 參數決定,之後可以用 transferOwnership 移交,或用 renounceOwnership 放棄。OpenZeppelin 文件對後者附了一句明確的警告:放棄擁有者之後,受 onlyOwner 保護的管理操作將無法再被調用。這句警告對讀者的意義是雙向的,它同時說明了放棄之前這些操作是可以被調用的。

AccessControl 是另一種形狀。它用 bytes32 的角色識別碼區分權限,文件裡的示例是 MINTER_ROLE,由 `keccak256("MINTER_ROLE")` 得出。DEFAULT_ADMIN_ROLE 是所有角色的預設管理角色。還有一條容易被略過的規則:預設情況下,持有某個角色的帳戶並不能自行授予或撤銷該角色,grantRole 與 revokeRole 要求調用者持有對應的 admin role。

同一份文件用示例展示了 mint 與 burn 由角色限制。這正好接回開頭那一節——鑄幣能力來自擴展與角色設計,而不是 ERC-20 標準本身。讀權限時若只確認了 owner 是誰就停手,凡是用角色管理的鑄幣、暫停或參數調整,都不會出現在你的記錄裡。

Solana 的 mint authority 與 freeze authority 分屬兩層

換到 Solana,第一個要問的問題會變成:你正在看的帳戶屬於哪一層。

Mint Account 這一層有兩個欄位。mint authority 是被授權創建該代幣新單位、增加供應的帳戶;若沒有 mint authority,這個 mint 就是固定供應,不能再鑄造。freeze authority 是被授權凍結某個 Token Account 內代幣、使其無法轉移或銷毀的帳戶。文件裡這兩個欄位在 Rust 結構中的型別是可為空的選項型別,也就是說「沒有值」是一個合法且有意義的狀態,跟「讀取失敗」屬於兩件事。

Token Account 是另一層。它的 owner 是被授權從該 Token Account 轉出代幣的帳戶,另外還有 delegate 欄位與對應的授權額度。這一層作用在單一帳戶上,管的是那一份餘額怎麼動。

把層級混起來會讀出很奇怪的結論。某個持倉很大的地址在它自己的 Token Account 上是 owner,這只說明它能動自己那份餘額,跟整個代幣能不能增發沒有關係。反過來,mint 層級的 freeze authority 有值,說明的是這個能力存在於這個代幣上,並不告訴你它被用過幾次、對誰用過。

驗證等級決定你能讀到哪些權限函式

前面所有讀法都預設了一件事:你讀得到源碼。這件事本身有等級。

Etherscan 的知識庫把源碼驗證描述為開發者證明並公開鏈上那份合約源碼的動作,讓使用者可以閱讀源碼、獨立核對某個合約函式是否確實在做它宣稱的事。驗證狀態分四種:Unverified;Similar Match,不計入 constructor arguments;Exact Match,含 constructor arguments;Runtime Match,不計入 deployment bytecode 與 constructor arguments。

這幾級的差異落到讀權限上很實際。驗證這個動作本身就是把源碼公開出來,因此 Unverified 只說明這個動作還沒發生——owner、角色與升級授權函式都不會有可讀的名字。另外三級的差別在於匹配涵蓋的範圍:Similar Match 不計入 constructor arguments,Runtime Match 連 deployment bytecode 與 constructor arguments 都不計入,只有 Exact Match 把 constructor arguments 一起算進去。這個差別值得記一筆,因為 initialOwner 正是建立期才決定的參數,匹配範圍到哪裡,你能據以核對的部分就到哪裡。此外,四級之中只有 Exact Match 提供內建的 Read / Write contract 按鈕互動。

還有一件事要說清楚:源碼驗證回答的是源碼與鏈上 bytecode 是否匹配這一件事。這個限度本站已在不靠分數研究風險一文說明過,這裡只補上它對讀權限的直接後果:驗證等級決定你能讀到多少函式名,不決定裡面寫了什麼。這一層是本文從上述條文自行推出的看法,不是任何平台的官方表述。

一次權限讀取要留下哪四欄

把上面幾節壓成可存檔的形式,四欄就夠:觀察項、讀到的值、讀取時間與區塊、尚未確定的部分。沒有分數,沒有權重,也不上顏色。以下逐項列出這一輪要留下的紀錄:標題本身就是觀察項,底下三行是其餘三欄。

ERC-1967 implementation 槽

  • 讀到的值:完整儲存字原值
  • 讀取時間與區塊:時間與區塊高度
  • 尚未確定:是否存在非標準代理寫法

ERC-1967 admin 槽

  • 讀到的值:完整儲存字原值
  • 讀取時間與區塊:時間與區塊高度
  • 尚未確定:admin 地址背後的控制形式

驗證狀態

  • 讀到的值:四級之一
  • 讀取時間與區塊:觀察日期
  • 尚未確定:未驗證時全部函式語意

owner

  • 讀到的值:地址或零地址
  • 讀取時間與區塊:區塊高度
  • 尚未確定:該地址的帳戶性質

已查到的角色

  • 讀到的值:角色識別碼與持有者
  • 讀取時間與區塊:區塊高度
  • 尚未確定:未列舉到的其他角色

mint authority

  • 讀到的值:地址或空值
  • 讀取時間與區塊:slot
  • 尚未確定:歷史上是否變更過

freeze authority

  • 讀到的值:地址或空值
  • 讀取時間與區塊:slot
  • 尚未確定:曾否被實際使用

欄位讀不到的時候寫「未取得」,不寫零。這兩者在後續引用時的含義差很多。

讀完權限之後仍然不能斷定的事

有幾類問題不會因為你把上面幾欄填滿就有答案。

owner 欄位給你的是一個地址,至於它屬於哪一種帳戶形態,鏈上並不自己說明,那要另起一輪讀取才知道,不能靠 owner 這一欄推出來。

放棄擁有者也不等於權限清空。一份合約可以同時使用 Ownable 與角色,也可以掛在代理後面。owner 變成零地址,只說明 onlyOwner 那一組操作關閉了;角色系統裡的持有者、代理的 admin,都在另外的位置,要分別讀。

最後是意圖。所有這些欄位描述的是能力。讀到某個帳戶具備凍結能力,能支持的說法僅止於「在這個時間點,這個能力存在且指向這個帳戶」。它有沒有被使用過、將來會不會被使用,需要另外的證據,本文的讀法給不出這個答案。

權限讀數為什麼要綁定區塊高度

權限狀態是可變的。transferOwnership 可以在任何一個區塊執行,implementation 同樣可以被換掉。角色的授予與撤銷也不需要預告。上一節之所以要求每一項都帶區塊高度,就是因為離開了那個高度,這些值就只是歷史記錄。

這也影響到怎麼引用舊記錄。三個月前讀到「mint authority 為空」,今天能支持的說法是「三個月前的那個時點讀到為空」。要說現在,就得重讀一次。重讀時值若不同,兩次記錄並排保存,比覆蓋掉舊值更能說明中間發生過什麼。

想把身分與權限這兩件事接起來,合約地址核對那一篇處理的是前半段:確認你讀的是哪一條鏈上的哪一個物件。

常見問題

合約已驗證,是不是就代表沒有增發權限?

驗證處理的是源碼與鏈上 bytecode 的匹配關係,它讓你能讀到程式碼;至於程式碼裡有沒有受角色或 owner 保護的鑄幣函式,要自己讀出來。驗證等級只決定你能讀到多少,不決定裡面寫了什麼。

owner 是零地址,權限是不是就都沒有了?

只能說 onlyOwner 保護的那一組操作無法再被調用,OpenZeppelin 文件對 renounceOwnership 的警告講的正是這一件事。合約若另外使用角色,或本身位於代理之後,那些權限要到各自的位置去讀。

Solana 上一個地址顯示是 owner,是不是就能增發?

要先看那是哪一層的 owner。Token Account 的 owner 被授權從該帳戶轉出代幣,管的是單一帳戶的餘額;增發能力屬於 Mint Account 層級的 mint authority,兩者是不同概念。

內容來源