JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

CALL 對上 DELEGATECALL:合約之間如何互相呼叫

兩個操作碼——CALL 與 DELEGATECALL——決定了誰的程式碼執行、又動到誰的儲存空間。搞懂這一個分野,你就看懂了函式庫、可升級代理,以及那場凍結約 1.5 億美元的 Parity 事件。

向另一份合約求助的兩種方式

想像你開一間小店,需要有人幫忙記帳。選項 A: 你打電話給一家會計事務所。他們在「自己的」辦公室、用「自己的」檔案完成工作,再把答案寄回給你——你的檔案櫃從頭到尾沒被打開。選項 B: 你把辦公室鑰匙交給一名外包人員,說:「用我的桌子、我的檔案櫃,就在這裡做。」兩種都把事情辦完了,但「誰動到誰的檔案」卻天差地別。這正是 EVMCALLDELEGATECALL 的分別。

在本階前幾篇導覽中,你看過 EVM 是一台逐一咀嚼操作碼的堆疊機,也學過合約把資料放在哪——永久的 storage、暫存的 memory、唯讀的 calldata。但合約幾乎不會獨自存在。一個 DeFi 路由器呼叫流動性池、池子呼叫代幣、代幣寫下事件——每一次跳轉都是一次訊息呼叫,而每一跳最關鍵的問題只有一個:被呼叫的程式碼,寫進的是誰的 storage? 一旦搞錯,輕則合約報廢,重則直接把它送給攻擊者。

CALL:在「他們家」執行他們的程式碼

當合約 A 對合約 B 發出一次 CALL,EVM 會開啟一個全新的執行框架。B 的程式碼針對 B 自己的 storageB 自己的 餘額執行。在 B 內部看出去,`msg.sender` 是 A 的位址——而非最初發起交易的那個人——而 `msg.value` 則是 A 決定轉過去的 ETH。B 完完全全是它自己,A 只是向它提了個問題。

// Contract A makes a normal CALL into Target.
// Inside Target: msg.sender == address(A), msg.value == 1 ether,
// and every SSTORE writes Target's OWN storage.
(bool ok, bytes memory ret) = target.call{value: 1 ether}(
    abi.encodeWithSignature("deposit(uint256)", 100)
);
require(ok, "deposit call failed"); // <-- MUST check: a failed CALL does NOT auto-revert you
一次帶金額的低階 CALL。回傳的 `ok` 布林值是 B 是否成功的唯一訊號。

留意回傳的形狀。CALL 操作碼會把一個成功布林值推上 堆疊,並把 B 回傳的內容複製進「return data」緩衝區。關鍵在於:如果 B revert 了,EVM 並不會自動回退你的交易——你只會拿到 `ok == false`。默默忽略這個布林值,就是經典的 未檢查外部呼叫漏洞:你以為付了錢,轉帳其實悄悄失敗,你的帳本從此說了個謊。

  1. A 在自己的 memory 中排好 calldata(選擇器 + 參數)。
  2. CALL 操作碼接收:要轉送的 gas、目標位址、value,以及輸入/輸出的 memory 區段。
  3. 推入一個新框架:msg.sender = A、msg.value = 轉送的 ETH,當前作用的 storage 是 B 的。
  4. B 開始執行;它可以寫自己的 storage、發出 log,或 revert。
  5. 框架回傳一個成功布林值與 return data;若帶了 value,餘額便從 A 移到了 B。

DELEGATECALL:借用他們的腦袋,留在你自己家

DELEGATECALL 是那個既怪異又強大的傢伙。EVM 取出 B 的程式碼,卻在 A 的執行情境裡執行它:A 的 storage、A 的餘額,而且——這才是重點——`msg.sender` 與 `msg.value` 維持成「呼叫 A 時」的原樣。彷彿 B 的位元組碼被原地貼進 A 裡執行一般。此時 B 不再是另一個獨立角色,而是一顆借來的腦袋

// Logic library: bump() writes to slot 0 of WHOEVER delegatecalls it.
contract Counter {
    uint256 public count;            // storage slot 0
    function bump() external { count += 1; }
}

// Wallet borrows Counter's code but keeps its OWN storage.
contract Wallet {
    uint256 public count;            // MUST also be slot 0
    function bumpViaLib(address lib) external {
        // Runs Counter.bump() in Wallet's context:
        //   Wallet.count goes up; Counter.count NEVER moves.
        //   msg.sender and msg.value are inherited unchanged.
        (bool ok, ) = lib.delegatecall(abi.encodeWithSignature("bump()"));
        require(ok, "delegatecall failed");
    }
}
同一段 bump() 程式碼,作用在 Wallet 的 storage 上。函式庫自己的 count 永遠停在零。

這一個小把戲,正是 函式庫與可升級合約之所以可能的原因。把邏輯部署一次;讓許多其他合約透過 DELEGATECALL 去借用它,各自保有自己的狀態。部署出來的函式庫是共享的唯讀程式碼,呼叫方則提供資料。這省下了龐大的 gas 與部署成本——但正如我們將看到的,它也是一把巨大的自走砲。

代理模式:升級程式碼,卻不必搬動資料

已部署的位元組碼是不可變的——你無法就地修補一份合約。那麼正經的協定要怎麼推出 bug 修正?答案是把儲存與邏輯拆開。一份輕薄的代理(Proxy)握著所有狀態與所有資金;另一份獨立的實作(Implementation)只放程式碼。每一筆進來的呼叫都打在代理的 fallback 上,由它 DELEGATECALL 進當前的實作。要升級時,你只需把代理指向一個新的實作位址——同一份 storage、同一份餘額、全新的行為。這就是 代理升級模式

contract Proxy {
    // EIP-1967 implementation slot -- a pseudo-random slot, NOT slot 0,
    // chosen as keccak256("eip1967.proxy.implementation") - 1:
    bytes32 constant SLOT =
        0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    function _impl() internal view returns (address a) {
        assembly { a := sload(SLOT) }
    }

    fallback() external payable {
        address impl = _impl();
        assembly {
            calldatacopy(0, 0, calldatasize())          // copy the incoming calldata
            let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())      // copy back whatever logic returned
            switch ok
            case 0 { revert(0, returndatasize()) }       // bubble up the revert reason
            default { return(0, returndatasize()) }
        }
    }
}
一個最小可升級代理。fallback 會把每一筆呼叫原封不動地透過 DELEGATECALL 轉送進實作。

實務系統會挑一種口味。透明代理把管理員呼叫與使用者呼叫分流,以避免選擇器衝突。UUPS 代理把 `upgradeTo` 的邏輯放進實作裡(代理更便宜,但你絕不能推出一個忘了帶它的實作)。鑽石代理(EIP-2535)把不同的函式選擇器導向不同的實作「切面(facet)」,藉此突破 24 KB 的合約大小限制。三者全都建立在同一個 DELEGATECALL 之上。

當情境搞錯時:儲存碰撞與 Parity 凍結事件

強大之處正是危險之處。由於被委派的程式碼是按 slot 編號寫進呼叫方的儲存,代理與實作必須對 storage 佈局逐位元組地達成一致。如果代理把 `implementation` 放在 slot 0,而邏輯合約拿 slot 0 來放一個普通變數,那麼第一次寫入該變數就會默默蓋掉實作指標。這就是儲存碰撞,一筆交易就能讓合約報廢或被劫持。

// VULNERABLE: proxy keeps the implementation pointer in slot 0 ...
contract BadProxy {
    address public implementation;   // slot 0
}

// ... but the logic contract ALSO uses slot 0 for a normal variable:
contract Logic {
    address public owner;            // slot 0  <-- COLLISION
    function setOwner(address o) external { owner = o; }
}
// A delegatecall to setOwner(attacker) writes `attacker` into slot 0,
// which IS the proxy's implementation pointer. The proxy now delegatecalls
// into a wrong/empty address on the next call -> bricked, or hijacked.

// FIX: store proxy internals at a namespaced EIP-1967 slot (see prior section),
// so logic variables starting at slot 0 can never overlap them.
代理與邏輯都用了 slot 0——這正是具名空間(EIP-1967)slot 所要防範的碰撞。

這並非紙上談兵。2017 年 11 月,第二起 Parity 多簽事件。 數百個 Parity 錢包共用一份函式庫合約,透過 DELEGATECALL 連到它。那份函式庫被留在未初始化的狀態,於是某個帳號直接呼叫它的 `initWallet`、成了它的擁有者——接著呼叫該函式庫的 `kill`,對函式庫本身執行了 `SELFDESTRUCT`。每一個借用它腦袋的錢包,從此都在 DELEGATECALL 進一段空程式碼。約 514,000 ETH——當時約值 1.5 億美元,如今更高——被永久凍結,而那些錢包本身一行程式碼都沒錯。這類問題的名字叫 delegatecall 注入,以及未初始化實作的陷阱。

選對操作碼(以及那扇重入之門)

一個你能帶到任何合約去用的判斷:當你要 B以它自己的身分行動時——轉送 ETH,或呼叫一個你無法掌控的外部協定——用 CALL。當你要 B 的程式碼以你的身分行動時——函式庫與代理——用 DELEGATECALL,而且只能用在你完全信任的程式碼上,因為它能改寫你整份 狀態。當你只需要讀取時,用 STATICCALL

  1. CALL 進 B -> 情境 = B 的:storage 是 B 的、msg.sender = 你、msg.value = 轉送過去的;可送 ETH。
  2. DELEGATECALL 進 B -> 情境 = 你的:storage 是你的、msg.sender 與 msg.value 繼承而來;無法傳入新的 value。
  3. STATICCALL 進 B -> 情境 = B 的,但唯讀:任何狀態變更都會讓呼叫 revert。

你現在握住了那一個分野——從 OpenZeppelin 的代理,到 DeFi 史上最慘重的損失,全都繞著它轉:誰的程式碼、誰的 storage。 在本階最後一篇,我們會跟著你的 Solidity 一路往下——穿過 ABI 與四位元組選擇器——直到那個決定一筆原始呼叫究竟執行哪個函式的 位元組碼派發器。