草案 ERC:支付門檻信任轉發器
本提案引入了支付門檻交易中繼(PGTR),這是一種允許代理或設備使用支付收據而非私鑰簽名來授權鏈上操作的協議。
摘要
支付門檻交易中繼(Payment-Gated Transaction Relay, PGTR)定義了一種代表主體(代理、合約或 EOA)轉發鏈上交易的協議,其授權證明是鏈上支付收據,而非加密簽名。PGTR 轉發器(Forwarder) 接收來自付款人的 X402 式支付,根據預期金額和目標驗證支付,並調用目標合約,同時將付款人地址作為經過身份驗證的發送者提供。目標合約透過 pgtrSender() 從轉發器讀取經過驗證的付款人地址,而非從 msg.sender 讀取。
PGTR 是一種新的原語(Primitive)。它不是 ERC-2771 的配置文件。ERC-2771 解決的是 Gas 抽象——簽名者仍需要私鑰,且在中繼執行前必須產生離線簽名。PGTR 解決的是金鑰抽象(Key Abstraction)——任何控制代幣並能訪問 HTTP 端點的各方,都可以在無需管理私鑰的情況下授權鏈上操作。
動機
自主 AI 代理、物聯網(IoT)設備和其他無金鑰參與者需要一種無需金鑰管理負擔即可授權鏈上操作的方法。現有的元交易(Meta-transaction)標準(ERC-2771、ERC-4337)預設主體能夠產生有效的加密簽名。這一假設在以下情況下失效:
- 無金鑰代理 — 運行在沙盒環境中的軟體代理,在該環境中存儲金鑰是不切實際或不可取的。
- 微支付門檻 API — 接受 X402 支付並需要將其轉化為鏈上操作,且無需中間金鑰託管的 HTTP 服務。
- 代幣驗證的機器帳戶 — 授權依據是「我已支付」而非「我已簽名」的系統。
PGTR 建立了一個最小接口,目標合約透過實現該接口來信任 PGTR 轉發器,而轉發器實現該接口以便能被 ERC-165 偵測。
規範
本文件中的關鍵詞「必須」(MUST)、「不得」(MUST NOT)、「要求」(REQUIRED)、「應當」(SHALL)、「不應當」(SHOULD NOT)、「建議」(RECOMMENDED)、「可以」(MAY)和「可選」(OPTIONAL)應按照 RFC 2119 中的描述進行解釋。
概述
PGTR 流程分為三個步驟:
- 支付 — 付款人透過原子的 ERC-3009
transferWithAuthorization或等效機制將代幣轉移給轉發器(或轉發器指定的託管方)。 - 驗證 — 轉發器驗證:(a) 支付金額達到請求操作的閾值;(b) 支付收據之前未被使用過;(c) 收據未過期。
- 中繼 — 轉發器調用目標合約。目標合約透過轉發器上的
pgtrSender()讀取經過驗證的付款人地址。
ITMPForwarder 接口
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/utils/introspection/IERC165.sol";
/// @title ITMPForwarder — PGTR 轉發器接口
/// @notice 由 PGTR 轉發器合約實現。目標合約在讀取 pgtrSender() 之前必須檢查
/// isTrustedForwarder(msg.sender)。
interface ITMPForwarder is IERC165 {
/// @notice 返回 true。允許 ERC-165 偵測 PGTR 轉發器。
function isPGTRForwarder() external view returns (bool);
/// @notice 授權當前轉發調用的付款人地址。
/// 類似於 EIP-2771 附加在 msg.data 後的發送者。
/// 如果在活動的轉發調用之外調用,必須 revert。
function pgtrSender() external view returns (address payer);
/// @notice 如果 addr 被此合約信任為 PGTR 轉發器,則返回 true。
/// 鏡像了 EIP-2771 接收端的模式,用於對稱偵測。
function isTrustedForwarder(address addr) external view returns (bool);
/// @notice 每次成功轉發支付門檻調用時觸發。
/// @param payer 經過驗證的付款人地址。
/// @param target 接收調用的目標合約。
/// @param selector 轉發調用的 4 字節函數選擇器。
/// @param paymentAmount 支付的代幣數量(以代幣的最小單位計)。
event PaymentGatedCall(
address indexed payer,
address indexed target,
bytes4 indexed selector,
uint256 paymentAmount
);
}
目標合約要求
接受來自 PGTR 轉發器轉發調用的合約必須:
維護一組受信任的轉發器地址:
mapping(address => bool) public trustedForwarders;
公開 isTrustedForwarder(address) 並返回 trustedForwarders[addr]。
當需要確定有效調用者時,檢查 msg.sender 是否在 trustedForwarders 中。
如果受信任,則透過 ITMPForwarder(msg.sender).pgtrSender() 讀取經過驗證的付款人,而非直接使用 msg.sender:
function _effectiveSender() internal view returns (address) {
if (trustedForwarders[msg.sender]) {
return ITMPForwarder(msg.sender).pgtrSender();
}
return msg.sender;
}
當透過轉發器調用時,不得將 msg.sender 作為僅限請求者操作的授權檢查。轉發器在轉發前必須將 pgtrSender() 設置為正確的請求者。
轉發器要求
PGTR 轉發器必須:
實現 ITMPForwarder 並在 supportsInterface(type(ITMPForwarder).interfaceId) 中返回 true。
在單個交易中原子地執行支付轉移和目標調用。如果其中任何一個失敗,整個交易必須 revert。
在轉發前,驗證支付收據未被使用過:
mapping(bytes32 => bool) public consumedReceipts;
bytes32 receiptHash = keccak256(abi.encode(
payer, amount, nonce, expiry, target, selector
));
require(!consumedReceipts[receiptHash], "Receipt already consumed");
consumedReceipts[receiptHash] = true;
在轉發前驗證收據未過期:
require(block.timestamp <= expiry, "Receipt expired");
在轉發調用期間將 pgtrSender() 設置為經過驗證的付款人地址,並在調用返回或 revert 後重置。
在成功轉發後觸發 PaymentGatedCall 事件。
PGTR 轉發器應當:
- 在生產環境中使用時,應為多簽或治理控制的地址。單一 EOA 轉發器必須披露為中心化風險。
- 為每個「目標+選擇器」組合實現可配置的最低支付金額。
- 在繼續操作前,驗證付款人的代幣授權(allowance)或簽名授權涵蓋了所需金額。
重放保護
支付收據必須是單次使用的。規範的收據雜湊(Hash)為:
keccak256(abi.encode(payer, amount, nonce, expiry, target, selector))
其中:
payer— 支付主體的地址amount— 以代幣最小單位計的支付金額nonce— 每個付款人的唯一值以防止重放(可以是隨機的 bytes32 或單調遞增的計數器)expiry— 超過該 Unix 時間戳後收據必須被拒絕target— 目標合約地址selector— 預期調用的 4 字節函數選擇器
轉發器必須存儲已使用的收據雜湊並拒絕任何重複項。
ERC-165 接口偵測
ITMPForwarder interfaceId = 0xTBD
計算方式為以下各項的 XOR:
isPGTRForwarder()— 0xTBDpgtrSender()— 0xTBDisTrustedForwarder(address)— 0xTBD
確切的計算值請參見附錄 A。
與 ERC-2771 的關係
PGTR 和 ERC-2771 是解決不同問題的不同原語:
| 維度 | ERC-2771 | PGTR |
|---|---|---|
| 授權證明 | 離線 ECDSA 簽名 | 鏈上支付收據 |
| 主體要求 | 必須持有私鑰 | 必須持有代幣 |
| 發送者偵測 | 在 calldata 中附加 20 字節後綴 | 調用轉發器上的 pgtrSender() |
| Gas 支付 | 中繼者支付 Gas | 轉發器(服務器)支付 Gas |
| 重放保護 | 轉發器中的簽名 nonce | 轉發器中的收據雜湊 |
| 使用場景 | 為金鑰持有者提供 Gas 抽象 | 為代幣持有者提供金鑰抽象 |
目標合約可以同時支持 ERC-2771 和 PGTR,方法是先檢查 msg.sender 是否為 ERC-2771 受信任轉發器,然後再檢查是否為 PGTR 受信任轉發器。
PGTR 並不聲稱是 ERC-2771 的「配置文件」。它使用了不同的發送者偵測機制(顯式函數調用 vs. 附加 calldata 字節)和根本不同的授權模型(支付驗證 vs. 簽名驗證)。僅實現 ITMPForwarder 的實現不得聲稱符合 ERC-2771。
原理
為什麼使用 pgtrSender() 而非附加 calldata?
ERC-2771 將原始發送者附加為 calldata 的最後 20 個字節。這種方法要求目標合約在每個需要驗證發送者的函數中解析 calldata。PGTR 使用轉發器上的顯式 pgtrSender() 視圖調用,這樣做:
- 實現更簡單 — 無需操作 calldata。
- 更具可審計性 — 身份驗證路徑是顯式且可搜索的。
- 可組合性 — 任何函數都可以在
msg.sender上調用pgtrSender(),不受 calldata 大小限制。
權衡是每次調用會增加一次額外的外部 STATICCALL(按當前價格約 700 gas),相對於轉發操作的成本,這是可以忽略不計的。
為什麼使用支付收據而非簽名授權?
簽名授權要求主體:
- 擁有私鑰。
- 在調用中繼之前計算並傳輸有效的簽名。
這一要求與在受限環境中運行的輕量級無金鑰代理不相容。支付收據僅要求主體持有代幣並能訪問支付端點——任何擁有資金錢包地址的自主代理都能滿足這些要求。
為什麼使用 mapping(address => bool) trustedForwarders 而非單一地址?
單一受信任轉發器地址是單點故障。如果轉發器 EOA 被盜用或變得不可用,目標合約將癱瘓。映射允許:
- 無需重新部署合約即可進行金鑰輪換。
- 多個並行轉發器以保證可用性。
- 無停機時間的分階段過渡(添加新轉發器,移除舊轉發器)。
轉發器運營商的安全考量
- 收據有效期窗口 — 短有效期窗口(約 60–300 秒)限制了收據在傳輸過程中被截獲後的利用窗口。
- 每個選擇器的最低金額 — 為每個(目標,選擇器)對設置最低支付閾值,使重放收據在經濟上失去吸引力。
- 搶跑(Frontrunning) — 由於收據雜湊包含付款人地址,搶跑中繼調用要求攻擊者同時控制代幣轉移。在單個交易中原子執行完全緩解了搶跑問題。
- 轉發器被盜用 — 被盜用的轉發器可以代表之前已支付的付款人轉發任意調用。運營商應使用多簽或鏈上治理金鑰作為轉發器所有者。目標合約應為所有轉發調用觸發事件,以便監控偵測異常。
向後兼容性
此 ERC 引入了一個新接口,未對現有標準進行修改。實現此標準及 ERC-2771 的目標合約仍與 ERC-2771 中繼器兼容,前提是它們按優先順序檢查轉發器類型。
安全考量
- 重入(Reentrancy) — 轉發器必須在轉發調用周圍使用重入保護。目標合約必須為所有狀態修改操作使用重入保護。
- 收據重放 — 轉發器必須在轉發調用的交易中原子地消耗收據。收據不得跨鏈使用(如果擔心跨鏈收據移植性,請在雜湊中包含
chainId)。 - 轉發器信任邊界 — 目標合約不得在沒有治理控制的情況下添加轉發器。無限制的
addForwarder在功能上等同於selfdestruct。 - pgtrSender() 原子性 — 轉發器必須在轉發調用返回後重置
pgtrSender()。如果轉發器允許讀取pgtrSender()的重入調用,之前的付款人地址可能會被錯誤地歸因於重入調用。
參考實現
請參閱參考實現倉庫中的 src/interfaces/ITMPForwarder.sol。
TaskMarket.sol 中提供了一個完整的 PGTR 兼容目標合約參考實現,它實現了 trustedForwarders 映射模式,並為 ERC-165 偵測公開了 supportsInterface(type(ITMP).interfaceId)。
版權
透過 CC0 放棄版權及相關權利。
附錄 A:接口 ID 計算
ITMPForwarder 接口 ID 計算為接口中聲明的所有函數的 4 字節 Keccak256 選擇器的 XOR:
| 函數 | 選擇器 |
|---|---|
isPGTRForwarder() | 部署時計算 |
pgtrSender() | 部署時計算 |
isTrustedForwarder(address) | 部署時計算 |
在 Solidity 中計算:
bytes4 id = type(ITMPForwarder).interfaceId;
在鏈下計算(JavaScript/viem):
import { toFunctionSelector, keccak256, toBytes } from 'viem';
const xor = (a: string, b: string) =>
'0x' + (parseInt(a, 16) ^ parseInt(b, 16)).toString(16).padStart(8, '0');
const id = [
toFunctionSelector('isPGTRForwarder()'),
toFunctionSelector('pgtrSender()'),
toFunctionSelector('isTrustedForwarder(address)'),
].reduce(xor);
1 則貼文 - 1 位參與者