{"content":{"title":"如何在 Hardhat 主网 Fork 上修改以太坊存储","body":"作者: Konstantin Nekrasov - MixBytes的安全研究员\r\n\r\n![](https://img.learnblockchain.cn/2025/03/09/23.jpg)\r\n\r\nHardhat具有一个很酷的功能，可以使用hardhat_setStorageAt手动设置任何存储槽的值。这个功能对于白帽子来说非常有用，可以在以太坊主网上演示有效的漏洞，而不会造成实际损害。主网的分叉功能对于集成测试的开发者也很有用：模拟可能没有考虑到主网上真实合约的所有特性。\r\n\r\n在本教程中，我们将设置一个Hardhat主网分叉，并通过几个示例演示如何在分叉上查找和修改真实合约中的存储变量。我们将涵盖不同类型的变量，包括简单整数、打包值、映射和数组。\r\n\r\n## 设置主网分叉\r\n\r\n首先你需要安装Hardhat。请查看这篇关于如何安装Hardhat并创建你的第一个项目的教程：\r\n\r\n[https://hardhat.org/tutorial/setting-up-the-environment](https://hardhat.org/tutorial/setting-up-the-environment)\r\n\r\n简而言之，你需要运行：\r\n\r\n```coffeescript\r\nmkdir modify-storage-tutorial\r\ncd modify-storage-tutorial\r\n\r\nnpm init -y\r\nnpm install dotenv hardhat @nomiclabs/hardhat-ethers @nomiclabs/hardhat-waffle ethereum-waffle ethers chai\r\n```\r\n\r\n在简单模式下，Hardhat在你的PC上本地模拟区块链。在分叉模式下，它将你的请求重定向到一个具有真实区块链快照的服务器。例如，[alchemy.com](https://www.alchemyapi.io/) 和 [quicknode.com](https://quicknode.com/) 提供这样的API。\r\n\r\n你可以查看它们的教程，了解如何分叉以太坊主网：\r\n\r\n[https://docs.alchemy.com/alchemy/guides/how-to-fork-ethereum-mainnet](https://docs.alchemy.com/alchemy/guides/how-to-fork-ethereum-mainnet) [https://learnblockchain.cn/article/11565](https://learnblockchain.cn/article/11565)\r\n\r\n在本教程中，我们将使用Alchemy API。你必须访问 [https://www.alchemyapi.io](https://www.alchemyapi.io/)，注册并在其仪表板中创建一个新应用。你将获得配置Hardhat所需的API密钥。将其放入.env文件中，并不要忘记将文件名添加到.gitignore，因为该密钥是秘密的：\r\n\r\n```ruby\r\necho 'ALCHEMY_API_KEY=XXXXXXXXXX' >> .env\r\necho '.env' >> .gitignore\r\n```\r\n\r\n现在创建hardhat.config.js：\r\n\r\n```javascript\r\nrequire(\"@nomiclabs/hardhat-waffle\");\r\n\r\n// 读取.env文件\r\nrequire('dotenv').config()\r\n\r\n// 访问 https://www.alchemyapi.io，注册，创建\r\n// 在其仪表板中的新应用并将其密钥导出\r\n// 到环境变量ALCHEMY_API_KEY\r\nconst ALCHEMY_API_KEY = process.env.ALCHEMY_API_KEY;\r\n\r\nif (!ALCHEMY_API_KEY) throw new Error(\"需要ALCHEMY_API_KEY\");\r\n\r\n/**\r\n * @type import('hardhat/config').HardhatUserConfig\r\n */\r\nmodule.exports = {\r\n  solidity: {\r\n    compilers: [\r\n      // 你可以为你的项目添加额外的版本\r\n      {\r\n        version: '0.8.9',\r\n      },\r\n    ],\r\n  },\r\n  defaultNetwork: \"hardhat\",\r\n  networks: {\r\n    hardhat: {\r\n      forking: {\r\n        url: \"https://eth-mainnet.alchemyapi.io/v2/\" + ALCHEMY_API_KEY,\r\n\r\n        // 指定一个块进行分叉\r\n        // 如果你想从最后一个区块分叉，请删除此行\r\n        blockNumber: 14674245,\r\n      }\r\n    }\r\n  }\r\n};\r\n```\r\n\r\n最后，你可以检查一切是否正常工作：\r\n\r\n```bash\r\nnpx hardhat test\r\n```\r\n\r\n## 如何修改单个槽变量\r\n\r\n更改Tether USD合约的所有者地址\r\n\r\n[USDT智能合约](https://etherscan.io/token/0xdac17f958d2ee523a2206206994597c13d831ec7)有一个公共变量 [address owner](https://etherscan.io/token/0xdac17f958d2ee523a2206206994597c13d831ec7#readContract)。让我们找到它的槽并将其更改为我们的签名地址。一旦完成，我们将能够运行一些特权方法，例如增加总供应量。\r\n\r\n首先，我们添加一个接口来与USDT进行通信。该接口依赖于IERC20，因此我们需要安装Openzeppelin合约：\r\n\r\n```coffeescript\r\nnpm install @openzeppelin/contracts\r\n```\r\n\r\n现在添加一个 contracts/IUSDT.sol 文件：\r\n\r\n```php\r\nimport \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\r\n\r\ninterface IUSDT is IERC20 {\r\n    function getOwner() external view returns (address);\r\n\r\n    function issue(uint256) external;\r\n}\r\n```\r\n\r\n第一个猜测是所有者变量位于零槽。事实证明这是正确的！\r\n\r\n```javascript\r\nconst { expect } = require(\"chai\");\r\nconst { ethers } = require(\"hardhat\");\r\n\r\nconst usdtAddress = \"0xdac17f958d2ee523a2206206994597c13d831ec7\"\r\n\r\n// 槽必须是去掉前导零的十六进制字符串！没有填充！\r\n// https://ethereum.stackexchange.com/questions/129645/not-able-to-set-storage-slot-on-hardhat-network\r\nconst ownerSlot = \"0x0\"\r\n\r\nit(\"更改USDT所有权\", async function () {\r\n    const usdt = await ethers.getContractAt(\"IUSDT\", usdtAddress);\r\n    const [signer] = await ethers.getSigners();\r\n    const signerAddress = await signer.getAddress();\r\n\r\n    // 存储值必须是32字节长的、用前导零填充的十六进制字符串\r\n    const value = ethers.utils.hexlify(ethers.utils.zeroPad(signerAddress, 32))\r\n\r\n    await ethers.provider.send(\"hardhat_setStorageAt\", [usdtAddress, ownerSlot, value])\r\n\r\n    expect(await usdt.getOwner()).to.be.eq(signerAddress)\r\n})\r\n```\r\n\r\n你可以运行测试，看看它是否通过：\r\n\r\n```bash\r\nnpx hardhat test test/ChangeUSDTOwner.js\r\n```\r\n\r\n## 铸造USDT\r\n\r\n现在我们是所有者，我们可以铸造额外的代币：\r\n\r\n```javascript\r\nconst { expect } = require(\"chai\");\r\nconst { ethers } = require(\"hardhat\");\r\n\r\nconst usdtAddress = \"0xdac17f958d2ee523a2206206994597c13d831ec7\"\r\n\r\n// 槽必须是去掉前导零的十六进制字符串！没有填充！\r\n// https://ethereum.stackexchange.com/questions/129645/not-able-to-set-storage-slot-on-hardhat-network\r\nconst ownerSlot = \"0x0\"\r\n\r\nit(\"铸造USDT\", async function () {\r\n    const usdt = await ethers.getContractAt(\"IUSDT\", usdtAddress);\r\n    const [signer] = await ethers.getSigners();\r\n    const signerAddress = await signer.getAddress();\r\n\r\n    // 存储值必须是32字节长的、用前导零填充的十六进制字符串\r\n    const value = ethers.utils.hexlify(ethers.utils.zeroPad(signerAddress, 32))\r\n\r\n    await ethers.provider.send(\"hardhat_setStorageAt\", [usdtAddress, ownerSlot, value])\r\n\r\n    expect(await usdt.getOwner()).to.be.eq(signerAddress)\r\n\r\n    const amount = 1000\r\n    const before = await usdt.totalSupply()\r\n    await usdt.issue(1000)\r\n    const after = await usdt.totalSupply()\r\n\r\n    expect(after - before).to.be.eq(amount)\r\n})\r\n```\r\n\r\n运行测试以查看它是否通过：\r\n\r\n```bash\r\nnpx hardhat test test/MintUSDT.js\r\n```\r\n\r\n## 如何修改映射\r\n\r\n更改USDC用户余额\r\n\r\n现在我们来更改[USDC智能合约](https://etherscan.io/token/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48)中的用户余额。\r\n\r\n用户余额存储在变量mapping(address => uint) balanceOf中。\r\n\r\n我们可以直接通过hardhat_setStorageAt编辑余额，但首先我们需要找到正确的槽。这一点有点棘手。你可以查看映射在以太坊存储中是如何存储的，文档在 [https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html#mappings-and-dynamic-arrays](https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html#mappings-and-dynamic-arrays)\r\n\r\n基本上，用户的余额存储在槽：\r\n\r\n```auto hljs\r\nkeccak256(padZeros(userAddress) . mappingSlot)\r\n```\r\n\r\n在javascript中的实现是：\r\n\r\n```javascript\r\nfunction getSlot(userAddress, mappingSlot) {\r\n    return ethers.utils.solidityKeccak256(\r\n        [\"uint256\", \"uint256\"],\r\n        [userAddress, mappingSlot]\r\n    )\r\n}\r\n```\r\n\r\n那么我们如何知道mappingSlot呢？这是balanceOf变量的槽吗？我们将使用暴力搜索来找到它。你可以在[https://blog.euler.finance/brute-force-storage-layout-discovery-in-erc20-contracts-with-hardhat-7ff9342143ed](https://blog.euler.finance/brute-force-storage-layout-discovery-in-erc20-contracts-with-hardhat-7ff9342143ed)中读取如何做的示例。\r\n\r\n我们将用简单的检查进行暴力搜索：\r\n\r\n```cs\r\nasync function checkSlot(erc20, mappingSlot) {\r\n    const contractAddress = erc20.address\r\n    const userAddress = ethers.constants.AddressZero\r\n\r\n    // 槽必须是去掉前导零的十六进制字符串！没有填充！\r\n    // https://ethereum.stackexchange.com/questions/129645/not-able-to-set-storage-slot-on-hardhat-network\r\n    const balanceSlot = getSlot(userAddress, mappingSlot)\r\n\r\n    // 存储值必须是32字节长的、用前导零填充的十六进制字符串\r\n    const value = 0xDEADBEEF\r\n    const storageValue = ethers.utils.hexlify(ethers.utils.zeroPad(value, 32))\r\n\r\n    await ethers.provider.send(\r\n        \"hardhat_setStorageAt\",\r\n        [\r\n            contractAddress,\r\n            balanceSlot,\r\n            storageValue\r\n        ]\r\n    )\r\n    return await erc20.balanceOf(userAddress) == value\r\n}\r\n```\r\n\r\n这是暴力搜索的方法：\r\n\r\n```javascript\r\nasync function findBalanceSlot(erc20) {\r\n    const snapshot = await network.provider.send(\"evm_snapshot\")\r\n    for (let slotNumber = 0; slotNumber < 100; slotNumber++) {\r\n        try {\r\n            if (await checkSlot(erc20, slotNumber)) {\r\n                await ethers.provider.send(\"evm_revert\", [snapshot])\r\n                return slotNumber\r\n            }\r\n        } catch { }\r\n        await ethers.provider.send(\"evm_revert\", [snapshot])\r\n    }\r\n}\r\n```\r\n\r\ntry..catch和evm_revert是必需的，因为随机存储修改可能会破坏合约并导致异常。现在我们可以编写一个最终测试，以检查我们是否能够在USDC合约中找到并修改用户余额：\r\n\r\n```cs\r\nit(\"更改USDC用户余额\", async function() {\r\n    const usdc = await ethers.getContractAt(\"IERC20\", usdcAddress)\r\n    const [signer] = await ethers.getSigners()\r\n    const signerAddress = await signer.getAddress()\r\n\r\n    // 自动找到映射槽\r\n    const mappingSlot = await findBalanceSlot(usdc)\r\n    console.log(\"找到USDC.balanceOf槽: \", mappingSlot)\r\n\r\n    // 计算 balanceOf[signerAddress] 槽\r\n    const signerBalanceSlot = getSlot(signerAddress, mappingSlot)\r\n\r\n    // 将其设置为值\r\n    const value = 123456789\r\n    await ethers.provider.send(\r\n        \"hardhat_setStorageAt\",\r\n        [\r\n            usdc.address,\r\n            signerBalanceSlot,\r\n            ethers.utils.hexlify(ethers.utils.zeroPad(value, 32))\r\n        ]\r\n    )\r\n\r\n    // 检查用户余额是否等于预期值\r\n    expect(await usdc.balanceOf(signerAddress)).to.be.eq(value)\r\n})\r\n```\r\n\r\n运行测试以查看它是否通过：\r\n\r\n```bash\r\nnpx hardhat test test/ChangeBalanceOf.js\r\n```\r\n\r\n## 如何修改数组\r\n\r\n修改Aave LendingPoolAddressesProviderRegistry\r\n\r\n让我们分析一个简单的示例，如何查找、读取和修改保存在Aave的[LendingPoolAddressesProviderRegistry](https://docs.aave.com/developers/v/2.0/the-core-protocol/addresses-provider-registry)中的私有动态地址数组，该数组存储在[0x52D306e36E3B6B02c153d0266ff0f85d18BCD413](https://etherscan.io/address/0x52D306e36E3B6B02c153d0266ff0f85d18BCD413#code)中。\r\n\r\n首先，我们需要知道以太坊状态中地址数组是如何存储的：\r\n\r\n1. 可见性修饰符，如private、public或internal不会影响存储机制。\r\n2. 对于地址动态数组，变量的槽p存储元素的数量。例如，如果数组中有两个元素，则槽p存储0x02。\r\n3. 对应的两个元素依次存储，从keccak256(p)开始。\r\n4. 尽管address类型的长度为20字节，但每个数组元素仍然存储在单独的32字节槽中。因此，数组的第一个元素将在槽keccak256(p) + 0中，第二个元素将在槽keccak256(p) + 1中。\r\n\r\n如果你对其他类型的数组如何存储感兴趣，请阅读[https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html#mappings-and-dynamic-arrays](https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html#mappings-and-dynamic-arrays)\r\n\r\nLendingPoolAddressesProviderRegistry的代码在[https://github.com/aave/protocol-v2/blob/master/contracts/protocol/configuration/LendingPoolAddressesProviderRegistry.sol](https://github.com/aave/protocol-v2/blob/master/contracts/protocol/configuration/LendingPoolAddressesProviderRegistry.sol)\r\n\r\n我们对这部分感兴趣：\r\n\r\n```php\r\ncontract LendingPoolAddressesProviderRegistry is ... {\r\n  mapping(address => uint256) private _addressesProviders;\r\n  address[] private _addressesProvidersList;\r\n\r\n  ...\r\n\r\n  function getAddressesProvidersList()\r\n    external\r\n    view\r\n    returns (address[] memory)\r\n  { ... }\r\n\r\n  function getAddressesProviderIdByAddress(\r\n    address addressesProvider\r\n  )\r\n    external\r\n    view\r\n    returns (uint256)\r\n  { ... }\r\n\r\n  ...\r\n```\r\n\r\n我们想找到_addressesProvidersList槽。首先，让我们通过调用getAddressesProvidersList方法检查其内容。为此，我们需要将LendingPoolAddressesProviderRegistry接口添加到我们的项目中：\r\n\r\n```php\r\ninterface ILendingPoolAddressesProviderRegistry {\r\n    function getAddressesProvidersList() external view returns (address[] memory);\r\n\r\n    function getAddressesProviderIdByAddress(address addressesProvider) external view returns (uint256);\r\n}\r\n```\r\n\r\n现在我们可以在Hardhat的控制台中运行它。使用以下命令启动控制台：\r\n\r\n```coffeescript\r\nnpx hardhat console\r\n```\r\n\r\n然后运行以下javascript代码：\r\n\r\n```cs\r\nconst target = await ethers.getContractAt(\"ILendingPoolAddressesProviderRegistry\", \"0x52D306e36E3B6B02c153d0266ff0f85d18BCD413\")\r\n\r\nawait target.getAddressesProvidersList()\r\n```\r\n\r\n输出：\r\n\r\n```json\r\n[\r\n  '0xB53C1a33016B2DC2fF3653530bfF1848a515c8c5',\r\n  '0xAcc030EF66f9dFEAE9CbB0cd1B25654b82cFA8d5'\r\n]\r\n```\r\n\r\n所以，这个数组有两个元素。现在我们知道_addressesProvidersList的槽存储着0x02值。让我们读取前几个槽以找到值：\r\n\r\n```css\r\nawait ethers.provider.getStorageAt(target.address, \"0x0\")\r\nawait ethers.provider.getStorageAt(target.address, \"0x1\")\r\nawait ethers.provider.getStorageAt(target.address, \"0x2\")\r\n```\r\n\r\n输出：\r\n\r\n```cpp\r\n0x000000000000000000000000b9062896ec3a615a4e4444df183f0531a77218ae\r\n0x0000000000000000000000000000000000000000000000000000000000000000\r\n0x0000000000000000000000000000000000000000000000000000000000000002\r\n```\r\n\r\n让我们分析存储布局：\r\n\r\n- 槽0被我们范围外的某个变量使用。\r\n- 槽1似乎被映射_addressesProviders使用，因为映射槽不存储元素，它总是为零。\r\n- 槽2存储0x02，似乎是_addressesProvidersList的槽？\r\n\r\n让我们将槽2的值更改为0x03，这样数组_addressesProvidersList将有3个元素：\r\n\r\n```sql\r\nawait ethers.provider.send(\r\n  \"hardhat_setStorageAt\", [\r\n    target.address,\r\n\r\n    // 槽必须是去掉前导零的十六进制字符串！没有填充！\r\n    // https://ethereum.stackexchange.com/questions/129645/not-able-to-set-storage-slot-on-hardhat-network\r\n    \"0x2\",\r\n\r\n    // 存储值必须是32字节长的、用前导零填充的十六进制字符串\r\n    ethers.utils.hexlify(ethers.utils.zeroPad(3, 32))\r\n  ]\r\n)\r\n```\r\n\r\n现在让我们调用getAddressesProvidersList看看是否有效：\r\n\r\n```cs\r\nawait target.getAddressesProvidersList()\r\n```\r\n\r\n输出：\r\n\r\n```json\r\n[\r\n  '0xB53C1a33016B2DC2fF3653530bfF1848a515c8c5',\r\n  '0xAcc030EF66f9dFEAE9CbB0cd1B25654b82cFA8d5',\r\n  '0x0000000000000000000000000000000000000000'\r\n]\r\n```\r\n\r\n成功了！现在让我们将数组的第三个元素设置为0xDEADBEEF：\r\n\r\n```cs\r\nconst arraySlot = ethers.BigNumber.from(ethers.utils.solidityKeccak256([\"uint256\"], [2]))\r\nconst elementSlot = arraySlot.add(2).toHexString()\r\nconst value = \"0xDEADBEEF\"\r\nconst value32 = ethers.utils.hexlify(ethers.utils.zeroPad(value, 32))\r\n\r\nawait ethers.provider.send(\r\n  \"hardhat_setStorageAt\", [\r\n    target.address,\r\n    elementSlot,\r\n    value32,\r\n  ])\r\n```\r\n\r\n现在，如果我们再次运行getAddressesProvidersList，我们将得到：\r\n\r\n```json\r\n[\r\n  '0xB53C1a33016B2DC2fF3653530bfF1848a515c8c5',\r\n  '0xAcc030EF66f9dFEAE9CbB0cd1B25654b82cFA8d5',\r\n  '0x00000000000000000000000000000000DeaDBeef'\r\n]\r\n```\r\n\r\n但为什么？我们不是改变了第三个元素吗？原因在于getAddressesProvidersList如何工作。它仅输出存储在映射_addressesProviders中的元素。请参考代码 [https://github.com/aave/protocol-v2/blob/master/contracts/protocol/configuration/LendingPoolAddressesProviderRegistry.sol#L33](https://github.com/aave/protocol-v2/blob/master/contracts/protocol/configuration/LendingPoolAddressesProviderRegistry.sol#L33)：\r\n\r\n```cpp\r\nfor (uint256 i = 0; i < maxLength; i++) {\r\n  if (_addressesProviders[addressesProvidersList[i]] > 0) {\r\n    activeProviders[i] = addressesProvidersList[i];\r\n  }\r\n}\r\n\r\nreturn activeProviders;\r\n```\r\n\r\n幸运的是，我们已经知道_addressesProviders映射的槽：它是槽1。我们可以直接将我们的0xDEADBEEF添加到 _addressesProviders中：\r\n\r\n```cpp\r\nconst deadBeefSlot = ethers.utils.solidityKeccak256(\r\n  [\"uint256\", \"uint256\"],\r\n  [0xDEADBEEF, 1]\r\n)\r\nawait ethers.provider.send(\r\n  \"hardhat_setStorageAt\",\r\n  [\r\n    target.address,\r\n    deadBeefSlot,\r\n    ethers.utils.hexlify(ethers.utils.zeroPad(1, 32))\r\n  ]\r\n)\r\n```\r\n\r\n让我们再检查一下我们的数组：\r\n\r\n```cs\r\nawait target.getAddressesProvidersList()\r\n```\r\n\r\n输出：\r\n\r\n```json\r\n[\r\n  '0xB53C1a33016B2DC2fF3653530bfF1848a515c8c5',\r\n  '0xAcc030EF66f9dFEAE9CbB0cd1B25654b82cFA8d5',\r\n  '0x00000000000000000000000000000000DeaDBeef'\r\n]\r\n```\r\n\r\n太好了！值0x00000000000000000000000000000000DeaDBeef作为_addressesProvidersList数组的第三个元素存储。你可以通过如下方式运行完整脚本：\r\n\r\n```nginx\r\nnpx hardhat run scripts/ChangeAaveAddressProviderList.js\r\n```\r\n\r\n## 结论\r\n\r\n在本文中，我们分析了几种如何查找以太坊状态中不同类型变量槽的示例，以及如何读取和修改它们的值。我们查看了如何修改公共地址、公共映射(address => uint)和私有地址[]，在USDT、USDC和Aave等合约中使用。\r\n\r\n这些技巧肯定会帮助你准备和演示有效的漏洞。如果你不是白帽子而是开发者，那么这绝对会帮助你编写集成测试。\r\n\r\n祝好运！\r\n\r\n### 相关链接\r\n\r\n- [MixBytes: 如何分叉主网进行测试](https://learnblockchain.cn/article/12402)\r\n- [Euler: 使用Hardhat进行ERC20合约存储布局发现的暴力破解](https://blog.euler.finance/brute-force-storage-layout-discovery-in-erc20-contracts-with-hardhat-7ff9342143ed)\r\n- [Hardhat: 设置环境](https://hardhat.org/tutorial/setting-up-the-environment)\r\n- [Hardhat: 分叉其他网络](https://hardhat.org/hardhat-network/docs/guides/forking-other-networks)\r\n- [Alchemy: 如何分叉以太坊主网](https://docs.alchemy.com/alchemy/guides/how-to-fork-ethereum-mainnet)\r\n- [Quicknode: 如何使用Hardhat分叉以太坊主网](https://learnblockchain.cn/article/11565)\r\n- [«无法在hardhat网络上设置存储槽»](https://ethereum.stackexchange.com/questions/129645/not-able-to-set-storage-slot-on-hardhat-network)\r\n- [Solidity: 存储中的状态变量布局](https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html)\r\n- [Aave: 地址提供者注册](https://docs.aave.com/developers/v/2.0/the-core-protocol/addresses-provider-registry)\r\n\r\n- MixBytes是谁？\r\n\r\n\r\n[MixBytes](https://mixbytes.io/) 是一支专业的区块链审计和安全研究团队，专注于为EVM兼容和基于Substrate的项目提供全面的智能合约审计和技术咨询服务。加入我们，在[X](https://twitter.com/MixBytes)上随时关注最新的行业趋势和见解。\r\n\r\n>- 原文链接： [mixbytes.io/blog/modify-...](https://mixbytes.io/blog/modify-ethereum-storage-hardhats-mainnet-fork)\r\n>- 登链社区 AI 助手，为大家转译优秀英文文章，如有翻译不通的地方，还请包涵～"},"author":{"user":"https://learnblockchain.cn/people/24680","address":null},"history":null,"timestamp":1741493734,"version":1}