最小代理(minimal proxy)标准允许我们将克隆的创建进行参数化,但这需要一次额外的初始化交易。我们可以完全绕过这一步,直接在代理的字节码中对我们关心的值进行参数化,而不是使用存储(storage)。
MetaProxy 标准也是一种用于创建智能合约克隆的最小字节码实现,但它为每个克隆添加了独特的不可变元数据。
这种元数据可以是任何内容(从字符串到数字),并且可以具有任意长度。然而,其预期用途是作为函数参数,以参数化实现合约(implementation contracts)的行为。
由于该标准的字节码是已知的,用户和第三方工具(如 Etherscan)可以利用它来识别出一个克隆将始终重定向到特定的实现合约地址,并附带追加的元数据。
让我们来看一下没有元数据的 MetaProxy 的字节码。
600b380380600b3d393df3363d3d373d3d3d3d60368038038091363936013d73bebebebebebebebebebebebebebebebebebebebe5af43d3d93803e603457fd5bf3
MetaProxy 字节码的长度为 65 字节,由 11 字节的初始化代码(init code)和 54 字节的运行时代码(runtime code)组成。
尽管 MetaProxy 合约的字节码与 Minimal Proxy 标准相似,但字节码的某些部分是不同的。例如,(下方)字节码的绿色部分有一些额外的操作码(opcode)指令,我们将在稍后进行解释。

虚拟地址 0xbebebebebebebebebebebebebebebebebebebebe 会在部署后被替换为实现合约地址。
作者信息
本文由 Jesse Raymond(LinkedIn,Twitter)共同撰写,是 RareSkills Technical Writing Program 的一部分。
使用 MetaProxy 标准创建 ERC20 合约
在本节中,我们将创建一个 ERC20 合约的 MetaProxy 克隆。让我们深入探讨如何实现这一点,并可视化元数据是如何添加到克隆中的。
为了实现 ERC20 合约,我们将继承 OpenZeppelin 的 ERC20Upgradeable 合约,它具有一个用于初始化 ERC20 状态变量的 ERC20_init 函数,而不是使用构造函数(构造函数不能用于我们在这里构建的这种代理模式)。
这是因为构造函数在合约部署时被调用。如果我们采用这种方法,ERC20 标准的状态变量(如 name 和 symbol)将不会在 ERC20 MetaProxy 克隆的字节码中初始化,因为构造函数设置的是实现合约的存储,而不是克隆的存储。
然而,我们将不会使用初始化函数,因为我们在将 name、symbol 和 totalSupply 作为元数据添加后,可以直接从 ERC20 MetaProxy 克隆的字节码中获取它们。
ERC20 实现合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
contract ERC20Implementation is ERC20Upgradeable {
// get ERC20 name from the metadata
function name()
public
view
virtual
override
returns (string memory name__)
{
(name__, , ) = getMetadata();
}
// get ERC20 symbol from the metadata
function symbol()
public
view
virtual
override
returns (string memory symbol__)
{
(, symbol__, ) = getMetadata();
}
// get ERC20 total supply from the metadata
function totalSupply()
public
view
virtual
override
returns (uint256 totalSupply_)
{
(, , totalSupply_) = getMetadata();
}
// mint function
function mint(uint amount) public {
_mint(msg.sender, amount * 10 ** 18);
}
/// returns the decoded metadata of this (ERC20 MetaProxy) contract.
function getMetadata()
public
pure
returns (
string memory name__,
string memory symbol__,
uint256 totalSupply__
)
{
bytes memory data;
assembly {
let posOfMetadataSize := sub(calldatasize(), 32)
let size := calldataload(posOfMetadataSize)
let dataPtr := sub(posOfMetadataSize, size)
data := mload(64)
mstore(64, add(data, add(size, 32)))
mstore(data, size)
let memPtr := add(data, 32)
calldatacopy(memPtr, dataPtr, size)
}
//return the decoded metadata
return abi.decode(data, (string, string, uint256));
}
}
获取元数据
在实现中,getMetadata 函数用于返回克隆的元数据。由于 MetaProxy 在调用其函数时始终会加载其元数据(这是该标准的设计,我们将在本文稍后解释),因此 getMetadata 函数用于从调用中提取元数据,并在我们的实现中将其作为元组返回。
它也被用于 ERC20 的 name、symbol 和 totalSupply 函数中,以获取元数据的特定部分:用于 name 和 symbol 的字符串,或者是用于 totalSupply 的 uint256。
我们从此处的示例实现中派生了该函数,并对其进行了修改以满足我们对 ERC20 合约的需求。
工厂合约
原始 EIP 中也包含一个指向 MetaProxyFactory 实现的链接,我们在此导入并继承了它。
MetaProxyFactory 包含了用于创建新 MetaProxy 克隆的代码。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./ERC20Implementation.sol";
import "./MetaProxyFactory.sol";
contract ERC20MetaProxyFactory is MetaProxyFactory {
address[] public proxyAddresses;
function createClone(
string memory _name,
string memory _symbol,
uint256 _initialSupply
) public returns (address) {
// Encode the ERC20 constructor arguments
bytes memory metadata = abi.encode(_name, _symbol, _initialSupply);
// Create the proxy
address proxyAddress = _metaProxyFromBytes(
address(new ERC20Implementation()),
metadata
);
proxyAddresses.push(proxyAddress);
return proxyAddress;
}
}
创建克隆 - 解释工厂合约
ERC20MetaProxyFactory 是我们用于创建新克隆的工厂合约。我们使用继承自 MetaProxyFactory 的 _metaProxyFromBytes 函数来部署新克隆。
_metaProxyFromBytes 函数接受两个参数,分别是:1. 实现合约的地址(这就是为什么我们首先使用 new 关键字部署一个 “ERC20Implementation” 合约的原因)。2. 元数据。
由于此代码中智能合约的字节码以十六进制表示,因此元数据必须先进行 abi 编码,然后才能追加到克隆的字节码末尾。
这就是为什么我们在将 createClone 函数的参数作为元数据传递给 _metaProxyFromBytes 函数之前,先对它们进行编码的原因。该函数会创建新的克隆并返回地址。
这是 _metaProxyFromBytes 函数的函数签名。
function _metaProxyFromBytes (address targetContract, bytes memory metadata) internal returns (address) {
// code that deploys new clones here
}
部署克隆
这是一个 Hardhat 脚本,用于部署合约并与部署在 Sepolia 网络上的克隆进行交互:
const hre = require("hardhat");
async function main() {
const ERC20ProxyFactory = await hre.ethers.getContractFactory(
"ERC20MetaProxyFactory"
);
const erc20ProxyFactory = await ERC20ProxyFactory.deploy();
// deploy the erc20 proxy factory contract
await erc20ProxyFactory.deployed();
console.log(
`ERC20 proxy factory contract deployed to ${erc20ProxyFactory.address}`
);
// create clone
const tx1 = await erc20ProxyFactory.createClone(
"Meta Token V1",
"MTV1",
"150000000000000000000000" //150,000 initial supply * 10^18 decimals
);
await tx1.wait();
const proxyCloneAddress = await erc20ProxyFactory.proxyAddresses(0);
console.log("Proxy clone deployed to", proxyCloneAddress);
// load the clone
const proxyClone = await hre.ethers.getContractAt(
"ERC20Implementation",
proxyCloneAddress
);
// retrieve the metadata
const metadata = await proxyClone.getMetadata();
console.log("metadata for clone: ", metadata);
//retrieve the "name" string from the metadata
const name = await proxyClone.name();
console.log("ERC20 name of clone from metadata: ", name);
const tx2 = await proxyClone.mint(150_000);
tx2.wait();
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
运行此脚本后,控制台显示以下输出:
ERC20 proxy factory contract deployed to 0xd45f2c555ba30aCb89EB0a3fff6a4416f8cC06e2
Proxy clone deployed to 0x5170672424194899F52B29E60e85C1632F0C732e
metadata for clone: [
'MetaProxy Token',
'MPRXT',
BigNumber { value: "150000000000000000000000" },
name__: 'MetaProxy Token',
symbol__: 'MPRXT',
totalSupply__: BigNumber { value: "150000000000000000000000" }
]
ERC20 name of clone from metadata: MetaProxy Token
我们还在 Sepolia 网络上部署了我们的合约,以下是这 3 个合约的详细信息。
请注意 ERC20 MetaProxy 合约的 “read” 和 “write as proxy”,这意味着 Etherscan 识别出该代理合约不仅是一个普通的智能合约,而是一个代理合约。
为了方便起见,我们的代码保留了已部署克隆的列表,以便我们可以在 Hardhat 环境中轻松访问它们,但这并不是强制性的。
处理 Revert(回滚)
如引言所述,当代理克隆将调用重定向到实现合约时,如果发生错误,revert(回滚)的有效负载会返回给克隆并显示给用户。
让我们对此进行测试,看看它是否如预期工作。
在之前的 ERC20 合约示例中,我们将尝试在没有授权(allowance)的情况下调用 “transferFrom” 函数,看看交易是否成功,或者错误是否会返回给我们。
我们使用这个 Hardhat 脚本来实现:
try {
await proxyClone.transferFrom(
proxyCloneAddress,
erc20ProxyFactory.address,2000000000);
} catch (error) {
console.error(error);
}
砰!我们得到了一个错误。
Error: VM Exception while processing transaction: reverted with reason
string 'ERC20: insufficient allowance'
这意味着 revert 及发生 revert 的原因被很好地发送回了克隆合约中!
解释已部署 ERC20 克隆的字节码
还记得我们之前说过,克隆的元数据被附加到了克隆的末尾。在本节中,我们将解释已部署 MetaProxy 克隆的字节码。
请注意,所有克隆的字节码都遵循 MetaProxy 标准的最小字节码(minimal bytecode),区别仅在于每个克隆的字节码末尾都有属于自己的元数据。
让我们看一下 ERC20 克隆的字节码。
0x363d3d373d3d3d3d60368038038091363936013d731bf70065f6b4e424b7b642b3a76a5e01f208e3fc5af43d3d93803e603457fd5bf3000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000174876e800000000000000000000000000000000000000000000000000000000000000000a50726f7879546f6b656e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000650546f6b656e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e0
这里有很多个零!我们的字节码长达 310 字节。
让我们进一步对其进行拆解。
<=== the runtime bytecode of the MetaProxy standard ===>
0x363d3d373d3d3d3d60368038038091363936013d731bf70065f6b4e424b7b642b3a76a5e01f208e3fc5af43d3d93803e603457fd5bf3
<===>
<=== the abi encoded metadata ===>
000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000174876e800000000000000000000000000000000000000000000000000000000000000000a50726f7879546f6b656e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000650546f6b656e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e0
<===>
编码后的元数据包括:编码值存储在内存中的偏移量、编码字符串值的长度、具体的值以及下一个可用内存(空闲内存指针)。以下是元数据的详细拆解。
根据 ABI 规范,前三组 32 字节长的字(word)要么表示我们正在编码的数据的值,要么在它们是动态类型时表示指向其存储位置的指针。我们有两个字符串和一个无符号整数,分别表示 name、symbol 和 totalSupply。因为 name 和 symbol 是动态类型,所以它们的槽中包含的是指针,而 totalSupply 则直接存储在其对应的槽中。
// memory[0x00 - 0x20] 0000000000000000000000000000000000000000000000000000000000000060 // memory offset for name string
// memory[0x20 - 0x40] 00000000000000000000000000000000000000000000000000000000000000a0 // memory offset for symbol string
// memory[0x40 - 0x60] 000000000000000000000000000000000000000000000000000000174876e800 // the encoded total supply (uint256)
// memory[0x60 - 0x80] 000000000000000000000000000000000000000000000000000000000000000a // the length of the name string (0x0a == 10)
// memory[0x80 - 0xa0] 50726f7879546f6b656e00000000000000000000000000000000000000000000 // the encoded name string
// memory[0xa0 - 0xc0] 0000000000000000000000000000000000000000000000000000000000000006 // the length of the symbol string (6)
// memory[0xc0 - 0xe0] 50546f6b656e0000000000000000000000000000000000000000000000000000 // the encoded symbol string
// memory[0xe0 ] 00000000000000000000000000000000000000000000000000000000000000e0 // the length of the metadata (0xe0 == 224)
如前所述,运行时代码(runtime code)为 54 字节。如果我们把 ERC20 克隆的字节码一分为二,剥离出属于运行时代码的前 54 字节,剩下的就是被追加在代码末尾、经过 ABI 编码的元数据(224 字节),以及记录元数据长度的字段(32 字节)。
根据标准
……在 MetaProxy 字节码之后的所有内容可以是任意元数据,并且字节码的最后 32 个字节(一个字)必须标明该元数据的字节长度。
在我们的例子中,元数据长 224 字节,其长度值被存储在最后的 32 字节(0x000…000e0)中。
把元数据的长度存储在末尾似乎有些奇怪,因为 ABI 编码通常在数据开始之前存储长度。但在这种情况下,它使得实现合约能够通过之前展示的以下代码,轻松地解析出额外的元数据。
let posOfMetadataSize := sub(calldatasize(), 32)
如果我们在此处对元数据进行解码,我们就能得到克隆的初始化数据。

让我们来梳理一下字节码的助记符(mnemonics)。
<=== start of the runtime bytecode ===>
// Note that RETURNDATASIZE is used in some parts of the bytecode to push zero to the stack.
// This is because RETURNDATASIZE (2 gas) costs less gas than a PUSH1 0 (3 gas).
// copy transaction calldata
[00] CALLDATASIZE
[01] RETURNDATASIZE
[02] RETURNDATASIZE
[03] CALLDATACOPY
// prepare the stack for a delegate call
[04] RETURNDATASIZE
[05] RETURNDATASIZE
[06] RETURNDATASIZE
[07] RETURNDATASIZE
[08] PUSH1 36 // 0x36 == 54, this is the length of the runtime code
[0a] DUP1
[0b] CODESIZE // get the length of the clone's bytecode + the metadata, which is 310 bytes
[0c] SUB // subtract the runtime code from the bytecode, to get the metadata (the remaining 256 bytes). this is used in the delegatecall
[0d] DUP1
[0e] SWAP2
[0f] CALLDATASIZE
[10] CODECOPY // copy the metadata to memory and forward it to the implementation contract during the delegatecall.
[11] CALLDATASIZE
[12] ADD
[13] RETURNDATASIZE
// push the address of the implementation contract to the stack and perform the delegatecall
[14] PUSH20 1bf70065f6b4e424b7b642b3a76a5e01f208e3fc
[29] GAS
[2a] DELEGATECALL
// copy the return data (the result of the call) to memory and set up the stack for a conditional jump
[2b] RETURNDATASIZE
[2c] RETURNDATASIZE
[2d] SWAP4
[2e] DUP1
[2f] RETURNDATACOPY
[30] PUSH1 34
//jump to line 34 and return the result of the call if it was successful, else revert on line 33
[32] JUMPI
[33] REVERT
[34] JUMPDEST
[35] RETURN
<<=== the metadata starts from here ===>>
这是关于克隆字节码如何工作的高层次描述。总而言之,它复制了在交易中发送给它的 calldata,并使用该 calldata 对实现合约发起 delegatecall,同时在 delegatecall 中转发元数据。
请注意,由于元数据在所有调用中都会被转发,因此它也会随着某些不需要它的函数一起发送,例如与被编码进的元数据毫无关系的 ERC20 balanceOf() 函数。
结论
EIP-3448 MetaProxy 标准可以被看作是对 EIP-1167 Minimal Proxy 标准的扩展,它允许将不可变元数据附加到每个克隆的运行时字节码中。
MetaProxy 标准允许用户在克隆的字节码中对他们关心的值进行参数化,而不是占用存储,从而降低了 Gas 成本。
此外,Etherscan 等第三方工具可以使用该标准已知的字节码,来判断出一个克隆将始终以特定方式重定向到特定的实现合约地址,并查询附加到该克隆上的元数据。
了解更多
本材料是我们高级 Solidity Bootcamp 的一部分。请查看我们的 blockchain bootcamp 以了解我们的所有课程体系。
最初发布于 2023 年 3 月 3 日