本文详细介绍了MetaProxy标准,它是一种用于创建智能合约克隆的最小字节码实现,允许每个克隆附加不可变的元数据。文章还展示了如何使用MetaProxy标准创建ERC20合约,并解释了字节码的结构和如何操作元数据。
最小代理标准允许我们参数化克隆的创建,但这需要额外的初始化交易。完全可以绕过这一步,将我们关心的值参数化在代理的字节码中,而不是使用存储。
MetaProxy 标准也是一种最小字节码实现,用于创建智能合约克隆,并为每个克隆增加了唯一的不可变元数据。
这些元数据可以是任何东西,从字符串到数字,并且可以具有任意长度。然而,预期的用途是作为函数参数,用于参数化实现合约的行为。
由于该标准的字节码是已知的,因此用户和第三方工具(如 Etherscan)可以借此确定代理总是会重定向到特定的实现合约地址以及附加的元数据。
让我们来看看没有元数据的 MetaProxy 的字节码。
600b380380600b3d393df3363d3d373d3d3d3d60368038038091363936013d73bebebebebebebebebebebebebebebebebebebebe5af43d3d93803e603457fd5bf3
MetaProxy 字节码的长度为 65 字节,其中包括 11 字节的初始化代码和 54 字节的运行时代码。
尽管 MetaProxy 合约的字节码类似于最小代理标准,但字节码的某些部分是不同的,例如,下面字节码的绿色部分包含一些额外的操作码命令,我们将在后面进行解释。
有一个虚拟地址 0xbebebebebebebebebebebebebebebebebebebebe
将在部署后替换为实现合约地址。
本文由 Jesse Raymond (LinkedIn, Twitter) 共同撰写,作为 RareSkills 技术写作计划的一部分。
在本节中,我们将创建一个 ERC20 合约的 MetaProxy 克隆。让我们深入了解如何做到这一点,并直观展示元数据如何添加到克隆中。
为了实现 ERC20 合约,我们将继承 OpenZeppelin 的 ERC20Upgradeable
合约,该合约具有用于初始化 ERC20 状态变量的 “ERC20_init” 函数,而不是构造函数,因为在我们所构建的代理模式下,构造函数无法使用。
这是因为构造函数是在合约部署时调用的,如果我们遵循此方法,ERC20 标准的状态变量 name
和 symbol
将不会在 ERC20 MetaProxy 克隆字节码中初始化,因为构造函数将设置实现合约的存储,而不是克隆的存储。
然而,我们不会使用初始化函数,因为可以简单地从字节码中获取 ERC20 MetaProxy 克隆的 name
、symbol
和 totalSupply
,将它们作为元数据添加进来。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
contract ERC20Implementation is ERC20Upgradeable {
// 从元数据中获取 ERC20 名称
function name()
public
view
virtual
override
returns (string memory name__)
{
(name__, , ) = getMetadata();
}
// 从元数据中获取 ERC20 符号
function symbol()
public
view
virtual
override
returns (string memory symbol__)
{
(, symbol__, ) = getMetadata();
}
// 从元数据中获取 ERC20 总供应量
function totalSupply()
public
view
virtual
override
returns (uint256 totalSupply_)
{
(, , totalSupply_) = getMetadata();
}
// 铸造函数
function mint(uint amount) public {
_mint(msg.sender, amount * 10 ** 18);
}
/// 返回此 (ERC20 MetaProxy) 合约解码的元数据。
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 abi.decode(data, (string, string, uint256));
}
}
getMetadata
函数在实现中用于返回克隆的元数据。由于 MetaProxy 在其函数被调用时总是加载其元数据(这是标准的设计,我们将在本文后面进行解释),因此使用 getMetadata
函数提取调用中的元数据并将其作为元组返回。
它还在 ERC20 的 name
、symbol
和 totalSupply
函数中使用,以获取元数据的特定部分,无论是字符串 name
和 symbol
还是 uint256 的 totalSupply
。
我们从示例实现中推导了此函数并进行了修改,以适应我们的 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) {
// 编码 ERC20 构造函数参数
bytes memory metadata = abi.encode(_name, _symbol, _initialSupply);
// 创建代理
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) {
// 部署新克隆的代码
}
以下是一个硬hat脚本,用于在 sepolia 网络上部署合约并与已部署的克隆进行交互:
const hre = require("hardhat");
async function main() {
const ERC20ProxyFactory = await hre.ethers.getContractFactory(
"ERC20MetaProxyFactory"
);
const erc20ProxyFactory = await ERC20ProxyFactory.deploy();
// 部署 erc20 代理工厂合约
await erc20ProxyFactory.deployed();
console.log(
`ERC20 代理工厂合约部署到 ${erc20ProxyFactory.address}`
);
// 创建克隆
const tx1 = await erc20ProxyFactory.createClone(
"Meta Token V1",
"MTV1",
"150000000000000000000000" //150,000 初始供应量 * 10^18 小数位
);
await tx1.wait();
const proxyCloneAddress = await erc20ProxyFactory.proxyAddresses(0);
console.log("代理克隆部署到", proxyCloneAddress);
// 加载克隆
const proxyClone = await hre.ethers.getContractAt(
"ERC20Implementation",
proxyCloneAddress
);
// 检索元数据
const metadata = await proxyClone.getMetadata();
console.log("克隆的元数据: ", metadata);
// 从元数据中检索 "name" 字符串
const name = await proxyClone.name();
console.log("从元数据中获取的 ERC20 克隆的名称: ", name);
const tx2 = await proxyClone.mint(150_000);
tx2.wait();
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
运行此脚本后,控制台输出如下:
ERC20 代理工厂合约部署到 0xd45f2c555ba30aCb89EB0a3fff6a4416f8cC06e2
代理克隆部署到 0x5170672424194899F52B29E60e85C1632F0C732e
克隆的元数据: [
'MetaProxy Token',
'MPRXT',
BigNumber { value: "150000000000000000000000" },
name__: 'MetaProxy Token',
symbol__: 'MPRXT',
totalSupply__: BigNumber { value: "150000000000000000000000" }
]
从元数据中获取的 ERC20 克隆的名称: MetaProxy Token
我们还在 sepolia 网络上部署了我们的合约,以下是三个合约的详细信息。
请注意 ERC20 MetaProxy 合约的“读取”和“作为代理写入”,这意味着 Etherscan 将代理合约视为不仅仅是另一种智能合约,而是一种代理合约。
出于方便起见,我们的代码保留了已部署克隆的列表,以便在我们的 hardhat 环境中可以轻松访问,但这并不是必须的。
正如在介绍中所述,如果代理克隆在重定向调用到实现合约时发生错误,则回退有效负载会返回给克隆并显示给用户。
让我们测试一下,看看它是否按预期工作。
在之前的 ERC20 合约示例中,我们将在没有授权的情况下尝试调用 “transferFrom” 函数,以查看交易是否成功或者错误是否返回给我们。
我们用这个 hardhat 脚本来实现。
try {
await proxyClone.transferFrom(
proxyCloneAddress,
erc20ProxyFactory.address,2000000000);
} catch (error) {
console.error(error);
}
结果是!我们得到了一个错误。
错误:处理事务时的虚拟机异常:以理由撤回
字符串 'ERC20: 余额不足'
这意味着回退和回退的理由会愉快地返回给克隆!
记住,我们之前说过克隆的元数据附加在克隆的末尾。在本节中,我们将解释 MetaProxy 克隆的已部署字节码。
请注意,所有克隆的字节码遵循 MetaProxy 标准的最小字节码,除了每个克隆在其字节码末尾具有元数据。
让我们看看 ERC20 克隆的字节码。
0x363d3d373d3d3d3d60368038038091363936013d731bf70065f6b4e424b7b642b3a76a5e01f208e3fc5af43d3d93803e603457fd5bf3000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000174876e800000000000000000000000000000000000000000000000000000000000000000a50726f7879546f6b656e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000650546f6b656e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e0
这是一长串零!我们的字节码长度为 310 字节。
让我们进一步拆解。
<=== MetaProxy 标准的运行时字节码 ===>
0x363d3d373d3d3d3d60368038038091363936013d731bf70065f6b4e424b7b642b3a76a5e01f208e3fc5af43d3d93803e603457fd5bf3
<===>
<=== abi 编码元数据 ===>
000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000174876e800000000000000000000000000000000000000000000000000000000000000000a50726f7879546f6b656e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000650546f6b656e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e0
<===>
编码的元数据由编码值存储的内存偏移量、编码字符串值的长度、值和下一个可用内存(游离内存指针)组成。以下是元数据的详细分解。
根据 ABI 规范,前三组 32 字节的字词代表我们正在编码的数据的值,或者在它们具有动态类型时的指针。我们有两个字符串和一个无符号整数,分别表示名称、符号和总供应量。由于名称和符号是动态的,因此它们的插槽包含指针,而总供应量简单地存储在其插槽中。
// memory[0x00 - 0x20] 0000000000000000000000000000000000000000000000000000000000000060 // 名称字符串的内存偏移
// memory[0x20 - 0x40] 00000000000000000000000000000000000000000000000000000000000000a0 // 符号字符串的内存偏移
// memory[0x40 - 0x60] 000000000000000000000000000000000000000000000000000000174876e800 // 编码的总供应量 (uint256)
// memory[0x60 - 0x80] 000000000000000000000000000000000000000000000000000000000000000a // 名称字符串的长度 (0x0a == 10)
// memory[0x80 - 0xa0] 50726f7879546f6b656e00000000000000000000000000000000000000000000 // 编码的名称字符串
// memory[0xa0 - 0xc0] 0000000000000000000000000000000000000000000000000000000000000006 // 符号字符串的长度 (6)
// memory[0xc0 - 0xe0] 50546f6b656e0000000000000000000000000000000000000000000000000000 // 编码的符号字符串
// memory[0xe0 ] 00000000000000000000000000000000000000000000000000000000000000e0 // 元数据的长度 (0xe0 == 224)
如前所述,运行时代码为 54 字节。如果我们将 ERC20 克隆的字节码分成两部分,去掉前 54 字节的运行时代码,剩下的就是 ABI 编码的元数据,即 224 字节,以及添加到代码末尾的元数据长度,即 32 字节。
根据 标准
... MetaProxy 字节码之后的所有内容可以是任意元数据,字节码的最后 32 字节(一个字)必须指示元数据的长度(以字节为单位)。
在我们的例子中,元数据的长度为 224 字节,其长度存储在最后 32 字节 (0x000…000e0)。
将元数据存储在末尾可能看起来奇怪,因为 ABI 编码通常会在数据开始之前存储长度,但在这种情况下,这使实现合约可以用之前提到的代码来解析额外的元数据。
let posOfMetadataSize := sub(calldatasize(), 32)
如果我们在 这里 解码元数据,得到的是克隆的初始化数据。
让我们逐步回顾字节码的助记符。
<=== 运行时字节码的开始 ===>
// 注意,在字节码的某些部分使用 RETURNDATASIZE 将零推送到栈中。
// 这是因为 RETURNDATASIZE(2 瓦斯)的成本低于 PUSH1 0(3 瓦斯)。
// 复制交易调用数据
[00] CALLDATASIZE
[01] RETURNDATASIZE
[02] RETURNDATASIZE
[03] CALLDATACOPY
// 为 delegate 调用准备栈
[04] RETURNDATASIZE
[05] RETURNDATASIZE
[06] RETURNDATASIZE
[07] RETURNDATASIZE
[08] PUSH1 36 // 0x36 == 54, 这是运行时代码的长度
[0a] DUP1
[0b] CODESIZE // 获取克隆的字节码总长度 + 元数据,总计 310 字节
[0c] SUB // 从字节码中减去运行时代码,以获取元数据(剩余的 256 字节)。这在 delegatecall 中使用
[0d] DUP1
[0e] SWAP2
[0f] CALLDATASIZE
[10] CODECOPY // 将元数据复制到内存中,并在 delegatecall 过程中将其转发到实现合约。
[11] CALLDATASIZE
[12] ADD
[13] RETURNDATASIZE
// 将实现合约的地址推送到栈并执行 delegatecall
[14] PUSH20 1bf70065f6b4e424b7b642b3a76a5e01f208e3fc
[29] GAS
[2a] DELEGATECALL
// 将返回数据(调用结果)复制到内存并设置栈以进行条件跳转
[2b] RETURNDATASIZE
[2c] RETURNDATASIZE
[2d] SWAP4
[2e] DUP1
[2f] RETURNDATACOPY
[30] PUSH1 34
// 如果调用成功,跳转到第 34 行并返回调用结果,否则在第 33 行回退
[32] JUMPI
[33] REVERT
[34] JUMPDEST
[35] RETURN
<<=== 元数据从这里开始 ===>>
这是克隆字节码工作方式的高层描述。总之,它复制在事务中发送给它的调用数据,并使用那些调用数据对实现合约进行 delegatecall,同时转发元数据。
请注意,由于元数据也在所有调用中转发,因此即使是与编码元数据无关的一些函数(如 ERC20 的 balanceOf()
函数),它也会与之一起发送。
EIP-3448 MetaProxy 标准可视为对 EIP-1167 最小代理标准的扩展,允许将不可变元数据附加到每个克隆的运行时字节码。
MetaProxy 标准允许用户在克隆的字节码中参数化他们关心的值,而不是使用存储,这减少了Gas成本。
此外,第三方工具如 Etherscan 可以利用标准已知的字节码来确定克隆将始终以特定方式重定向到特定实现合约地址,并查询附加到克隆的元数据。
这些材料是我们进阶 Solidity Bootcamp 的一部分。查看我们的 区块链训练营 以了解我们提供的所有课程。
最初发布于2023年3月3日
- 原文链接: rareskills.io/post/erc-3...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!