
ERC 草案提案:鏈級協議託管標準 (ProtocolEscrow)
我們提出一個共享託管合約的最小標準介面,可作為鏈級基礎設施或獨立部署,允許任何鏈上協議委託資產託管,而無需將其業務邏輯與資金管理耦合。
類別: EIPs → ERC
狀態: 徵求討論 (Request for Discussion)
作者: Louis Liu (@louisliu2048)
依賴項目: ERC-20
摘要
我們提出了一個用於共享託管合約的最小標準介面——可作為鏈級基礎設施或獨立合約部署。該介面允許任何鏈上協議委託資產託管,而無需將其業務邏輯與資金管理耦合。
協議在 ProtocolEscrow 中註冊,用戶存入協議子帳戶,協議在完成自身的授權檢查後觸發釋放。託管層從不檢查協議如何授權提款,它僅強制執行調用是由已註冊的協議合約發起的,並確保基本的安全不變量成立。
動機
每個獨立持有用戶資產的協議都面臨三個重複出現的問題:
1. 安全攻擊面。 每個協議的資金池都是一個獨立的攻擊目標。大多數大型 DeFi 漏洞針對的是託管邏輯,而非業務邏輯。重入漏洞、轉帳手續費(fee-on-transfer)計帳錯誤、重放漏洞——這些都是已解決的問題,不應在每個協議中重新解決。
2. 合規負擔。 在許多司法管轄區,持有用戶資金會觸發託管人義務。將託管委託給獨立治理的鏈級基礎設施的協議團隊,可以更有力地辯稱自己是軟體提供商,而非金融託管人。
3. 重複工程。 每個協議都獨立地重新實現重入保護、餘額增量計帳、重放保護和暫停機制。這是共享的基礎設施,重複開發對任何人均無益處。
一個值得明確說明的細微之處:ProtocolEscrow 並未移除協議對其資金的合法業務控制。協議仍然完全定義何時提款有效、可以釋放多少以及釋放給誰。改變的是違規成本:協議團隊不再能悄悄行動。每一次合約變更都必須經過公開的時間鎖;每一次釋放都會在鏈上記錄。合法的協議運作方式與以前完全相同,而惡意行為者現在必須公開行動,否則就無法行動。
ERC-4626 標準化了收益金庫介面。本標準解決了互補的問題:無收益託管,其目標是嚴格的資產隔離和無許可的釋放觸發。
使用案例
ZK 隱私傳輸協議。 ZK 隱私協議的價值在於其密碼學,而非其託管工程。使用 ProtocolEscrow,協議合約簡化為:驗證 ZK 證明 → 調用釋放。無需編寫託管代碼。協議團隊還避開了託管人分類:資金由鏈的獨立治理持有,而非協議團隊。
跨鏈橋。 橋接鎖定合約是最高價值的 DeFi 攻擊目標。使用 ProtocolEscrow,橋接器註冊為協議;用戶存入其子帳戶;中繼者在確認目標鏈終局性後調用釋放。releaseId = keccak256(srcChainId, srcTxHash, asset, recipient, amount) 自然地確保了每次轉帳的唯一性。
支付與發票協議。 預付發票、里程碑付款、去中心化 OTC——任何在可驗證的鏈上條件達成前持有資金的條件支付流程。條件邏輯存在於已註冊的協議合約中;ProtocolEscrow 負責資產結算。
時間鎖定託管。 歸屬計劃(Vesting schedules)、懸崖釋放(cliff releases)、定時支付——所有這些都可以簡化為「檢查 block.timestamp,然後調用釋放」。無需自定義金庫。
共享鏈級基礎設施。 對於將 ProtocolEscrow 作為預部署系統合約的鏈,每個新協議從第一天起就擁有經過審計的託管基礎設施。隨著協議從自管金庫遷移到共享託管層,鏈的總體攻擊面將會減少。
規範
本文中的關鍵詞「必須」(MUST)、「不得」(MUST NOT)、「應該」(SHOULD)、「建議」(RECOMMENDED) 和「可以」(MAY) 應按照 RFC 2119 中的描述進行解釋。
定義
| 術語 | 定義 |
|---|---|
| ProtocolEscrow | 實現此標準的託管合約。 |
| protocolId | 唯一且雙向綁定到一個協議合約地址的 bytes32 標識符。 |
| asset | ERC-20 代幣地址,或 address(0) 代表原生代幣。 |
| sub-account (子帳戶) | 由 ProtocolEscrow 維護的邏輯餘額槽 (protocolId, asset)。 |
| releaseId | 由協議生成的用於重放保護的一次性標識符。在給定 protocolId 的所有資產中強制執行全局唯一性。 |
| authorization data (授權數據) | 在調用釋放前由協議驗證的協議特定數據(ZK 證明、簽名、時間條件等)。ProtocolEscrow 從不檢查此數據。 |
第 1 節:核心介面(強制)
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.0;
interface IProtocolEscrow {
// 當記錄存款時觸發(amount = 實際入帳金額,扣除轉帳手續費後)
event Deposited(bytes32 indexed protocolId, address indexed asset, address indexed depositor, uint256 amount);
// 當執行結算釋放時觸發
event Released(bytes32 indexed protocolId, address indexed asset, address indexed recipient, uint256 amount, bytes32 releaseId);
/// @notice 將資產存入協議子帳戶。
/// 必須在以下情況回退:protocolId 未註冊、存款已暫停(如果實現了暫停)、
/// 資產不在白名單中(如果實現了白名單)、protocolId=0、amount=0、
/// ERC-20 且 msg.value>0,或原生代幣且 msg.value!=amount。
/// 使用餘額增量法(對轉帳手續費代幣安全)增加餘額。
/// 如果 hook 模式為 REQUIRED,則在入帳後調用 IEscrowDepositHook.onDeposit。
/// 必須具備重入保護。
function deposit(bytes32 protocolId, address asset, uint256 amount, bytes calldata data) external payable;
/// @notice 執行結算釋放。僅能由已註冊的協議合約調用。
/// 必須在以下情況回退:調用者不是註冊合約、to=address(0)、amount=0、
/// releaseId 已被使用(全局範圍)、餘額不足、釋放已暫停。
/// 遵循 CEI 模式:標記 releaseId 已使用 → 扣除餘額 → 轉帳。
/// ERC-20 轉帳必須使用 SafeERC20。必須具備 nonReentrant。
function release(bytes32 protocolId, address asset, address to, uint256 amount, bytes32 releaseId, bytes calldata data) external;
/// @notice 當前子帳戶餘額。
function escrowBalance(bytes32 protocolId, address asset) external view returns (uint256);
/// @notice 最大可存款金額。如果暫停或未註冊則返回 0。
/// 對於已註冊協議且資產允許的情況,未暫停時返回 >0。
function maxDeposit(bytes32 protocolId, address asset) external view returns (uint256);
/// @notice 最大可釋放金額。如果釋放暫停則返回 0。
function maxRelease(bytes32 protocolId, address asset) external view returns (uint256);
/// @notice release() 的忠實預演。如果成功則返回 (true, 0)。
/// 鏡像 release() 的簽名以確保模擬完整——會檢查調用者和接收者。
function canRelease(
address caller, bytes32 protocolId, address asset, address to,
uint256 amount, bytes32 releaseId, uint256 dataLength
) external view returns (bool ok, bytes32 reasonCode);
}
第 2 節:可選 — 存款 Hook 介面
協議可以通過實現 IEscrowDepositHook 並將 hook 模式設置為 REQUIRED 來選擇接收存款通知。
interface IEscrowDepositHook {
// 由 ProtocolEscrow 在增加餘額後調用。
// 必須在 msg.sender != ProtocolEscrow 時回退。回退將導致存款操作回滾。
function onDeposit(bytes32 protocolId, address depositor, address asset, uint256 amount, bytes calldata data) external;
}
| 模式 | 行為 |
|---|---|
| NONE | 無回調。增加餘額並觸發 Deposited 事件。 |
| REQUIRED | 在入帳後調用 onDeposit (CEI)。Hook 回退則回滾存款。 |
第 3 節:可選 — 擴展查詢介面
interface IProtocolEscrowQueryable {
function protocolContractOf(bytes32 protocolId) external view returns (address);
function isProtocolRegistered(bytes32 protocolId) external view returns (bool);
function isDepositPaused() external view returns (bool);
function isProtocolDepositPaused(bytes32 protocolId) external view returns (bool);
function protocolReleasePauseUntil(bytes32 protocolId) external view returns (uint64);
// 全局範圍 — 無資產參數
function isReleaseIdUsed(bytes32 protocolId, bytes32 releaseId) external view returns (bool);
// 如果資產被接受則返回 true;如果沒有白名單,應對所有資產返回 true
function isAssetAllowed(address asset) external view returns (bool);
// 如果沒有限制則返回 type(uint256).max
function maxCallDataBytes() external view returns (uint256);
}
第 4 節:標準錯誤代碼
計算方式為 keccak256(abi.encodePacked("CODE_NAME")) — 使用 packed 編碼,而非 abi.encode。
| 常數名稱 | 條件 |
|---|---|
| REASON_UNREGISTERED | protocolId 沒有註冊的合約。 |
| REASON_UNAUTHORIZED | 調用者不是 protocolId 的註冊合約。 |
| REASON_RELEASE_PAUSED | 協議釋放在活動的暫停窗口內。 |
| REASON_RELEASE_ID_USED | releaseId 已被使用。 |
| REASON_INSUFFICIENT_BALANCE | 子帳戶餘額 < amount。 |
| REASON_INVALID_AMOUNT | amount 為 0。 |
| REASON_INVALID_RECIPIENT | to 為 address(0)。 |
| REASON_DATA_TOO_LONG (可選) | dataLength 超過實現的最大值。 |
第 5 節:關鍵行為規則
註冊
protocolId↔protocolContract是一對一、雙向且永久的綁定。不提供unregisterProtocol。- 重新註冊現有的
protocolId必須回退。 - 支持註冊後地址變更的實現必須使用兩階段過程:提議 → 時間鎖 → 激活。不可變部署除外。
存款
- 使用餘額增量法(balanceAfter − balanceBefore)計算入帳餘額。這對轉帳手續費代幣是強制性的。
- ERC-20 存款若
msg.value > 0必須回退(防止意外發送的 ETH 無法找回)。
釋放
- 必須同時檢查正向(
protocolContracts[protocolId] == msg.sender)和反向(protocolIdsByContract[msg.sender] == protocolId)綁定。 - CEI 順序:標記 releaseId 已使用 → 扣除餘額 → 轉帳。
- ProtocolEscrow 不得檢查協議的授權機制。
Release ID 防重放
- 在
(protocolId, releaseId)級別強制執行唯一性——跨所有資產全局生效。 - 明確禁止 以
(protocolId, asset, releaseId)為範圍:這會產生跨資產重放風險,即相同的授權載荷可用於耗盡多個子帳戶。 - 建議構造方式:
keccak256(abi.encode(protocolId, chainId, asset, recipient, amount, nonce))。
資產白名單 (可選)
- 從白名單中移除資產僅阻止未來的存款。現有儲備必須保持可釋放狀態。
暫停語義 (可選)
- 釋放暫停必須有時間限制 (TTL) 並自動過期。禁止永久凍結。
- 如果實現了
emergencyGuardian:冷卻時間必須嚴格大於maxPauseSeconds(相等則允許無縫鏈接暫停,等同於永久凍結)。守護者不得為 EOA。守護者不得具備任何資產轉移能力。提前手動取消暫停必須僅限於治理操作。
運作原理
存款
用戶 → ProtocolEscrow.deposit(protocolId, USDC, 1000, data)
├─ 驗證:已註冊、未暫停、資產允許、msg.value==0
├─ safeTransferFrom(user, this, 1000)
├─ credited = balanceAfter - balanceBefore ← 對轉帳手續費代幣安全
├─ reserves[protocolId][USDC] += credited
├─ [若為 REQUIRED] Protocol.onDeposit(protocolId, user, USDC, credited, data)
└─ 觸發 Deposited(protocolId, USDC, user, credited)
釋放
用戶 → Protocol.withdraw(authData, ...)
├─ 協議驗證授權(ZK 證明 / 簽名 / 時間鎖 / 等)
└─ 協議 → ProtocolEscrow.release(protocolId, USDC, user, 1000, releaseId, "")
├─ 檢查:caller == protocolContracts[protocolId]
├─ 檢查:protocolIdsByContract[caller] == protocolId
├─ 檢查:to!=0, amount>0, 未暫停, releaseId 未使用, 餘額>=amount
├─ usedReleaseIds[protocolId][releaseId] = true ← CEI 步驟 1
├─ reserves[protocolId][USDC] -= 1000 ← CEI 步驟 2
├─ safeTransfer(USDC, user, 1000) ← CEI 步驟 3
└─ 觸發 Released(...) + ReleaseIdConsumed(...)
原理闡述
為何分為兩個功能? 介面設計有意保持極簡:接收資金和釋放資金。所有業務邏輯——何時接受、需要什麼驗證、如何追蹤用戶餘額——都屬於協議。這種邊界意味著 ProtocolEscrow 可以經過一次審計後由任意數量的協議共享。
為何是身份驗證中立的? 與在託管層硬編碼 ZK 證明驗證的設計不同,release 信任調用的註冊合約已經完成了自身的授權檢查。ZK 協議、多簽協議、時間鎖協議以及任何未來的方案都可以在不更改合約的情況下共享同一個託管層。
為何 releaseId 是跨資產全局的? 以 (protocolId, asset, releaseId) 為範圍會產生跨資產重放風險:同一個 ZK 作廢符(nullifier)或簽名載荷可能會被每種資產提交一次,從而耗盡多個子帳戶。全局範圍完全消除了這類攻擊。協議應將資產包含在其 releaseId 的原像中作為第二層防禦。
為何存款 Hook 是可選的? 要求所有協議都執行 onDeposit 會增加一個外部調用(重入面),而無狀態協議並不需要它。將 Hook 設為由治理按協議配置(NONE / REQUIRED),可以讓簡單的託管協議跳過回調,同時讓 ZK 隱私協議接收實時的承諾(commitment)更新。
為何 canRelease 完全鏡像 release? 預覽功能只有在能準確預測實際調用時才有用。包含 caller、to 和 dataLength 可以防止出現預覽返回 true 但實際調用因 UNAUTHORIZED_CALLER、INVALID_RECIPIENT 或 DATA_TOO_LONG 而回退的假陽性情況。
為何沒有強制提款 (ForceWithdraw)? 鏈級的強制提款需要 ProtocolEscrow 理解每個協議的授權語義——誰被允許強制退出哪個用戶。由於授權邏輯被刻意保留在協議合約中,託管層無法實現通用的強制退出。需要抗審查性的協議必須確保其自身的提款是無許可的。
先前技術
| 關係 | 說明 |
|---|---|
| ERC-4626 | 啟發了預覽模式(maxDeposit, maxRelease, canRelease)。ERC-4626 解決收益計帳;本標準解決無收益託管。 |
| Tornado Cash / Railgun | 這些協議各自維護自己的資金池。本標準將該層提取為共享基礎設施,讓協議純粹專注於其證明系統。 |
| Gnosis Safe Modules | 將資產託管與授權邏輯分離的類似哲學。Safe Modules 在帳戶級別運作;ProtocolEscrow 在協議級別運作,具有治理控制的註冊和隔離的子帳戶。 |
| ERC-1155 | (protocolId, asset) 子帳戶模型在概念上類似於 ERC-1155 的多代幣 ID 空間,但應用於託管餘額而非代幣所有權。 |
安全考量
重入。 deposit 和 release 都必須具備 nonReentrant。deposit 調用 onDeposit(不可信);release 調用 safeTransfer(可能是不可信代幣)。
releaseId 跨資產重放。 通過協議全局去重消除。已消耗的 releaseId 不能在同一協議下的任何資產中重放。按資產劃分範圍不符合本標準。
轉帳手續費代幣。 必須使用餘額增量入帳。直接入帳調用者提供的金額會產生虛假餘額,最終導致釋放回退。
ERC-20 且 msg.value > 0。 必須回退。隨 ERC-20 存款發送的 ETH 沒有回收路徑。
emergencyGuardian 暫停濫用。 冷卻時間必須嚴格大於 maxPauseSeconds。相等的值允許無縫的連續暫停,構成事實上的永久凍結。
治理密鑰洩漏。 洩漏的治理密鑰無法直接轉移用戶資產。但它可能會註冊一個惡意的協議合約來耗盡其自身的子帳戶。具體的治理模型由部署定義;實現應對高風險操作使用帶有時間鎖的多簽。
協議合約變更窗口。 在「提議 → 激活」期間,舊合約仍具備授權。支持暫停的部署可以在此窗口期間凍結釋放。不支持暫停的部署應相應調整時間鎖長度。
集中化風險。 ProtocolEscrow 的漏洞會同時影響所有已註冊的協議。這是公認的權衡:一個高質量的經過審計的合約取代了許多低質量的各協議自管金庫。
測試清單(摘要)
第一層 — 核心一致性(強制)
- ERC-20 和原生代幣的 deposit/release 正常流程
- 餘額按增量計入,而非按調用者提供的金額
- 未註冊的調用者、錯誤的 protocolId、反向綁定不匹配均觸發回退
- 重複的 releaseId 回退;不同資產使用相同 releaseId 也回退(全局範圍)
- amount > balance 回退;protocolId=0 / to=0 / amount=0 均回退
- ERC-20 且 msg.value>0 回退;原生代幣且 msg.value!=amount 回退
- canRelease 是忠實的:當且僅當 release 會成功時返回 (true,0);每個失敗都有正確的 reasonCode
- 重入:release 中的 ERC-20 轉帳回調不會破壞狀態;(如果實現了 hook) onDeposit 重入不會破壞狀態
第二層 — 擴展配置文件(如果實現了該功能則為必填)
- Hook 模式:NONE 跳過回調;REQUIRED 在 hook 失敗時回退;非 ProtocolEscrow 調用者回退
- 暫停:全局/按協議的存款和釋放暫停隔離;TTL 自動過期
- 守護者冷卻:冷卻期內的第二次暫停回退;守護者無法取消暫停
- 白名單:非允許資產存款回退;已移除資產的釋放仍成功
- 合約變更:時間鎖狀態機(eta 之前、過期後、並發提議)
- 治理:任何特權功能上的未授權調用者均回退
向後兼容性
此 ERC 引入了新的介面,不修改任何現有標準。現有的 ERC-20 和原生代幣機制保持不變。希望採用此標準的協議通過實現上述註冊和釋放觸發流程進行集成。
待討論的開放問題
- protocolId 派生標準。 ERC 是否應強制要求
keccak256(abi.encode(chainId, name, owner)),還是留給具體實現? - Released vs ReleaseIdConsumed。 我們指定了這兩者——一個用於財務審計追蹤,一個用於重放檢測。Gas 的重複消耗是否值得,或者一個事件是否應承擔這兩個角色?
- canRelease 參數開銷。
caller、to和dataLength使預演更加忠實,但增加了 calldata 成本。是否應將更輕量的canReleaseSimple(protocolId, asset, amount, releaseId)作為標準的一部分? - 治理介面標準化。 規範將治理模型完全留給實現定義。ERC 是否應指定最小的提議/執行介面,還是保持開放?
- 預部署 vs. 獨立部署。 設計時考慮了鏈級預部署,但作為獨立合約同樣有效。ERC 是否應明確兼顧兩者?
- 多資產批量釋放。 可選的
batchRelease是否應成為標準的一部分,還是留給協議自行實現? - 協議災難恢復要求。 如果協議的提款變得永久不可用,ProtocolEscrow 不提供鏈級回退。目前的無許可提款准入檢查僅驗證正常路徑。標準是否應要求已註冊協議展示災難恢復路徑(例如,記錄在案的升級計劃,通過合約遷移保留用戶資金訪問權)?三種立場:
- 嚴格: 要求將記錄在案的升級/恢復計劃作為註冊的治理先決條件。
- 軟性: 將其作為最佳實踐推薦;將執行權留給每次部署。
- 不可知: 超出範圍——協議級別的容錯是協議自身的責任。
我們期待來自構建隱私協議、跨鏈橋、支付系統或任何其他目前維護自身資金池的團隊的反饋。目標是確定這種抽象是否處於正確的層級、介面是否存在缺陷,以及安全屬性是否足以用於生產環境。