前面 2 篇我们介绍了账户抽象，第一篇：如何使用智能合约钱包来实现 EOA 钱包一样的功能，第二篇 : 介绍如何使用第三方来代为支付手续费。 钱包创建 我们还没有解决的问题是每个用户的钱包合约首先是如何在区块链上如何生成的。部署合约的 "传统" 方式是使用EOA来发送一个没有接收者（没有to字段）的交易，其中包含合约的部署代码。这在将是相当不满意的，因为我们刚刚做了很多工作，使某人可以在没有EOA的情况下与链交互。如果用户需要自己的EOA才能开始，那这一切是为了什么？ 为了明确我们的想法，一个想要钱包但还没有钱包的人应该能够在链上拥有一个全新的钱包，要么用ETH支付他们自己的Gas（即使他们还没有钱包），要么通过找到一个愿意为他们的交易付Gas的支付者（我们在第二部分中涉及到），需要应该能够做到这一点，而不需要创建一个EOA。 还有一个不太明显的目标也相当重要。 当我创建一个新的EOA时，我可以在本地生成我的私钥，并在不发送任何交易的情况下申请我的账户。 我可以告诉别人我的地址，并在我自己发送交易之前开始接收ETH或代币。 我们希望我们的钱包有同样的属性，也就是说，我们应该能够在实际部署我们的钱包合约之前告诉别人我们的地址并接收资产。 前提：用CREATE2确定的合约地址 关于在我们实际部署合约之前就能在我们的地址接收资产， 这一点是对我们需要实现他的一点提示。这意味着，尽管我们可能还没有部署钱包合约，但我们需要知道当我们最终能够真正部署它时，它将在什么地址上。 一个合约最终将被部署但尚未部署的地址被称为反事实地址（counterfactual address）。 实现这一目标的关键因素是CREATE2操作码，它在一个地址上部署一个合约，这个地址可以通过以下输入确定地计算出来。 调用CREATE2的合约的地址 一个盐（salt），它可以是任何32字节的值 被部署的合约的init代码 初始代码是一些EVM字节码，指定了一个函数，该函数在执行时返回一个不同的EVM字节码，保存作为新部署的智能合约。这是一个有趣的花絮：很多人都没有意识到。当你部署一个合约时，你提交的代码并不是最终出现在链上合约中的代码。特别是，多次使用相同的初始代码并不能保证部署的合约会有相同的代码，因为初始代码可能会从存储中读取或使用TIMESTAMP等操作码。 译者注：合约的字节码分为创建字节码及运行时字节码， 可参考 理解 EVM 专栏 的 解构Solidity合约 #1 - 字节码 第一次尝试： 入口点部署任意的合约 既然我们知道了CREATE2，我们的第一个计划就很简单。我们将让用户传入初始代码，如果合约还不存在，则由入口点部署合约。首先，我们将为用户操作添加一个新的字段： struct UserOperation { // ... bytes initCode; } 然后，我们将更新入口点handleOps的验证部分，做以下工作： 作为验证用户操作的一部分，如果该操作有非空的initCode，则使用CREATE2来部署一个带有该initCode的合约。 然后继续进行其他正常的验证工作。 调用新创建的钱包的validateOp方法 然后，如果该操作有一个paymaster，调用paymaster的validatePaymasterOp方法 这是一个很好的尝试! 它实现了上面讨论的所有目标：用户可以部署任意的合约，并提前知道它们最终的地址，而且部署可以由paymasters或用户自己赞助（如果他们将ETH存入合约最终的地址）。 但是有一些缺陷，这些缺陷都是围绕着这样一个事实：我们要求用户提交及入口点要验证一个任意的字节码。 当一个paymaster 看到一个用户操作时，它不能合理地分析这一串字节码来决定它是否要为其付款。 当用户提交一串字节码来部署合约时，他们不能轻易地验证他们提交的字节码是否符合他们的要求。如果用户使用一个工具来部署他们的合约，那么如果该工具是恶意的或被黑客攻击的，它可以提交initCode，将后门安装到部署的合约中，而这种攻击不容易被发现。 回顾第一篇，捆绑者希望对它包含在捆绑中的每个操作进行模拟验证，这样它就不会最终包括那些未能通过验证的操作，然后它就不得不自掏腰包支付Gas。但由于initCode是任意代码，它很容易在模拟过程中成功，但在执行过程中失败。 我们需要一种方法让用户在不提交任意字节码的情况下部署合约，并让其他参与者能够对部署行为有一些保证。 像往常一样，当我们想要更多的执行保证时，就是引入新的智能合约的时候了。 更好的尝试： 引入工厂 与其让入口点接受任意字节码并调用CREATE2，我们将允许用户选择一个合约作为调用CREATE2的合约。这些选择的合约，我们称之为工厂，如果他们愿意，可以专门创建不同种类的钱包合约。 例如，可能有一个工厂生产保护他们的 Carbonated Courage 代币的钱包，另一个工厂生产 3/5 多签来签署交易的钱包。 工厂将暴露一个可以被调用的方法，以创建一个合约： contract Factory { function deployContract(bytes data) returns (address); } 我们让工厂返回新创建的合约的地址，这样用户就可以模拟这个方法，在部署合约之前找出他们的合约会在什么地址上，这也是我们最初的目标之一。 我们还将在用户操作中添加字段，这样，如果操作试图部署一个钱包，那么它就会指定使用哪个工厂，以及传递工厂将收到的数据作为输入：。 struct UserOperation { // ... address factory; bytes factoryData; } 用户可以调用称为工厂的合约，这些工厂专门创建不同种类的钱包合约。 这就解决了上一节中的前两个问题: 如果用户调用工厂生成保护 Carbonated Courage 代币的钱包，假设工厂合约经过审核，他们肯定知道最终会得到一个保护 Carbonated Courage 的钱包，没有后门，而且他们不需要审查任何字节码就能做到。 paymasters可以选择为某些经授权的工厂的部署付款。 上一节的最后一个问题是，部署代码在模拟过程中可能会成功，但在执行过程中会失败。 这正是我们在paymaster的validatePaymasterOp方法中遇到的问题，我们将以同样的方式解决它。 捆绑器将限制工厂只能访问他们自己的相关存储和他们正在部署的钱包的存储，并且不允许他们调用被禁止的方法，如TIMESTAMP。 我们也会要求工厂使用入口点的addStake方法质押一些ETH，然后捆绑者可以根据工厂最近的模拟伪造频率来限制或禁止工厂。 和paymaster一样，如果工厂的部署方法只访问它所部署的钱包的关联存储，而不是工厂自己的关联存储，就不需要质押。 我们已经做的不错，钱包创建从来没有这么好过。 在这一点上，我们创建的架构可以执行实际EIP-4337的所有功能! 我们将在第四篇介绍的唯一剩下的目标是关于聚合签名，实现优化以节省Gas，我们可以继续期待下一篇。 转载自：https://learnblockchain.cn/article/5442
理解账户抽象第二篇，看看如何使用第三方代替自己支付。 在本系列的第一篇中，我们从头开始创建了就有EOA的功能的智能合约钱包，并通过允许用户选择自己的自定义验证逻辑进行改进。但是现在，钱包仍然需要支付Gas，这意味着钱包所有者需要找到一种方法来获得一些ETH，然后才能在链上执行任何操作。 如果我们想让钱包主人以外的其他人代替我们支付Gas呢？ 有一些很好的理由支持我们加入这个功能： 如果钱包所有者是一个区块链新手，那么在执行链上操作之前需要获得ETH是一个巨大的绊脚石。 一个dapp可能愿意为其方法支付Gas费，这样Gas费就不会吓跑潜在用户了 一些赞助者（或项目方）可能会允许钱包以ETH以外的其他代币支付Gas费用，例如以USDC支付。 为了保护隐私，用户可能想从混合器中提取资产到一个新的地址，并将Gas费用记入一个与他们无关的账户中 引入Paymaster 假设我是一个想为其他人支付Gas费的dapp。我可能不想在任何地方为每个人的Gas付费，所以我需要把自定义逻辑放到链上，它可以查看用户的操作并决定是否要为该操作付费。 把自定义逻辑放到链上的方法是部署一个合约，我们称之为paymaster（支付者或付款人）。 它将有一个方法来查看用户操作并决定是否愿意为该操作付费： contract Paymaster { function validatePaymasterOp(UserOperation op); } 然后，当一个钱包提交一个操作时，他们需要指出他们希望哪个支付方（如果有的话）来支付他们的Gas。 我们将在 "用户操作"中添加一个新的字段来指定支付者。 我们还将在用户操作中添加一个字段，钱包可以用它来向支付者传递任意的数据，以帮助它说服支付者支付其费用。 例如，这可能是由支付者的所有者在链外签署的内容。 struct UserOperation { // ... address paymaster; bytes paymasterData; } 接下来，我们将改变入口点的handleOps，以利用新的paymaster。 现在它的行为将是： 对于每个操作（op）: 在操作者指定的钱包上调用validateOp。 如果该操作有一个paymaster地址，那么对该paymaster调用validatePaymasterOp。 任何验证失败的操作都会被丢弃。 对于每个操作，在操作的发送者钱包上调用executeOp，跟踪我们使用了多少Gas，然后将ETH转账到执行者那里以支付这些Gas。如果该操作有一个paymaster字段，那么这个ETH来自paymaster。否则，它就像以前一样来自钱包。 就像钱包一样，paymaster通过入口点（entrypoint）的存款方法存入他们的ETH，然后才能用于支付操作。 执行者同时调用支付者合约和用户的智能合约钱包，以确定用户的交易是否可以被赞助。 这其实是很简单的，对吗？ 我们只要让捆绑器更新它的模拟操作和... 在上一篇文章中，钱包向捆绑者退款，捆绑者会先模拟操作，试图避免执行验证失败的操作，因为这意味着钱包不会付款，所以捆绑者将承担Gas成本。 这里也出现了同样的问题： 捆绑者想避免提交验证失败的操作，因为paymaster不会付款，而捆绑者又要负担成本。 起初，我们似乎可以对validatePaymasterOp 施加和validateOp同样的限制（即它只能访问钱包和它自己的相关存储，不能使用被禁止的操作代码），然后捆绑者可以在模拟钱包的validateOp的同时，简单地为用户操作模拟validatePaymasterOp。 但是，这里有一个问题 因为存储限制说一个钱包的validateOp只能访问该钱包的相关存储，我们知道一个捆绑程序中的多个操作的验证不能相互干扰，只要它们来自不同的钱包，因为它们访问的共同存储非常少。 但是一个paymaster的存储在捆绑中所有使用该paymaster的操作中是共享的。 这意味着一个validatePaymasterOp的行为有可能导致使用同一个paymaster的交易包中的许多其他操作的验证失败。 恶意的paymaster可以利用这一点来破坏系统。 为了防止这种情况，我们引入了一个信誉系统。 我们将让捆绑者跟踪每个paymaster最近验证失败的频率，并通过节流或禁止使用该paymaster的操作来惩罚那些经常失败的paymaster。 如果一个恶意的paymaster可以创建许多自己的实例（ Sybil 攻击），这个信誉系统就不会起作用，所以我们要求paymaster用ETH做抵押。这样一来，它就不能从拥有多个账户中获益。 让我们在EntryPoint添加新的方法来处理抵押： contract EntryPoint { // ... function addStake() payable; function unlockStake(); function withdrawStake(address payable destination); } 一旦投入了质押，在调用unlockStake后，要经过一定的延迟才能撤出。 这些新方法有别于之前讨论的deposit和withdrawTo，后者是由钱包和paymaster用来添加ETH，这些ETH将被用来支付Gas，并且可以在任何时候立即提取。 质押规则有一个例外： 如果paymaster只访问过钱包的相关存储，而不是paymaster自己的存储，那么它就不需要质押，因为在此案例中，捆绑的多个操作所访问的存储不会相互重叠，原因与钱包的validateOp调用相同。 实际上，我不认为信誉系统的详细规则有多么重要，如果想详细了解，你可以在这里读到它们，但只要你知道捆绑者将有一个机制来避免从一个“刚刚销毁自己的支付者”那里选择操作（OP），这就足够了。 另外，每个捆绑者都会在本地跟踪信誉，所以如果一个捆绑者认为自己能做得更好，并且不会给其他捆绑者带来麻烦，那么它可以自由地实现自己的信誉逻辑。 与许多质押模式不同，这里的赌注从未被削减。它们只是作为一种方式存在，要求潜在的攻击者锁定大量的资金来进行大规模的攻击。 改进: paymaster postOp 我们可以做一个小小的改进，让paymaster做得更多。现在，只有在验证步骤中，在操作实际运行之前，才会调用paymaster。 但是，paymaster也可能需要根据操作的结果做一些不同的事情。 例如，一个允许用户用美元支付Gas费的支付机构需要知道该操作实际使用了多少Gas，这样它就知道该收取多少美元。 因此，我们将为paymaster添加一个新的方法postOp，在操作完成后，入口点将调用这个方法，并传递给它使用了多少Gas。 我们还希望paymaster能够 "向自己传递信息"，并在postOp的步骤中使用这些在验证过程中计算的数据，因此我们将允许验证返回任意的 "上下文" 数据，这些数据将在以后被传递给postOp。 对postOp的第一次尝试将是这样的： contract Paymaster { function validatePaymasterOp(UserOperation op) returns (bytes context); function postOp(bytes context, uint256 actualGasCost); } 但是，对于想在最后以USDC收费的paymaster来说，有一些棘手的事情。 paymaster在授权执行之前（在validatePaymasterOp中）可以检查用户是否有足够的USDC来支付该操作。但是，在执行过程中，执行操作完全有可能将钱包中所有的USDC 转出，这将意味着paymaster无法在最后提取付款。 paymaster能否通过在开始时收取最大数额的USDC，然后在结束时退还未使用的部分来避免这种情况？这也是可行的，但它很混乱：它需要两次转账调用，而不是一次，这就增加了Gas成本，并且会发出两个不同的转账事件。我们将看看我们是否能做得更好。 我们需要一种方法，让paymaster在操作完成后导致操作失败，如果它这样做，它应该能够提取付款，因为无论发生什么，它在验证PaymasterOp时已经同意支付Gas。 设置这个的方法是让入口点有可能调用postOp两次。 入口点首先调用postOp，作为它刚刚运行钱包executeOp的同一个执行的一部分，因此，如果postOp 回退（revert），它会导致executeOp的所有执行也回退。 如果发生这种情况，那么入口点就会再次调用postOp，但现在我们处于executeOp发生之前的情况，并且在这种情况下，我们刚刚检查了validatePaymasterOp，paymaster应该能够提取其应得的。 为了给postOp提供更多的背景，我们将给它多一个参数：一个标志，以表明我们是否在它的 "第二次运行"中，因为它已经回退了一次。 contract Paymaster { function validatePaymasterOp(UserOperation op) returns (bytes context); function postOp(bool hasAlreadyReverted, bytes context, uint256 actualGasCost); } 回顾一下paymaster如何启用赞助交易 为了让钱包主人以外的人支付Gas，我们引入了一个新的实体paymaster，这是部署了一个具有以下接口的智能合约： contract Paymaster { function validatePaymasterOp(UserOperation op) returns (bytes context); function postOp(bool hasAlreadyReverted, bytes context, uint256 actualGasCost); } 在用户操作中增加了新的字段，以允许钱包指定对应的paymaster。 struct UserOperation { // ... address paymaster; bytes paymasterData; } paymaster 将ETH存入EntryPoint，与钱包支付自己的Gas的方式相同。 入口点合约更新其handleOps方法，以便对每个操作，除了通过钱包的validateOp进行钱包验证外，还通过paymaster的validatePaymasterOp对操作的paymaster（如果有）进行验证，然后执行操作，最后调用paymaster的postOp。 为了处理模拟paymaster验证的一些作弊的问题，我们需要引入一个质押系统，paymaster会锁定ETH。 这是通过加入一些新的入口点方法来实现： contract EntryPoint { // ... function addStake() payable; function unlockStake(); function withdrawStake(address payable destination); } 随着paymaster的加入，我们已经实现了大多数人在要求账户抽象时想到的所有功能! 我们也已经非常接近EIP-4337，现在已经感觉挺好了，但仍有一些我们需要的功能（请期待），以实现相同的效果。 转载：https://learnblockchain.cn/article/5432
本文提供一个新的视角来理解账户抽象（Account Abstraction）。通过从零开始设计智能合约钱包，你会明白几个简单选择之后，让 EIP-4337 如此复杂的原因。 理解账户抽象 - #1 通过从零开始设计智能合约钱包，你会明白几个简单选择之后，让 ERC-4337 如此复杂的原因。 账户抽象(Account Abstraction)即将完全改变我们与区块链的交互方式。但EIP-4337提出的账户抽象的版本艰难阅读，要理解为什么有这么多的参与者，为什么他们会以这样的方式交互，是一件很困难的事情。 会不会有更简单的东西呢？ 在这篇文章中，我将介绍试图设计一个简单的账户抽象的过程，将看到随着我们增加更多的需求和解决出现的问题，我们最终会得到一个复杂度膨胀的东西，并且越来越接近于EIP-4337。 这篇文章的目标受众是对智能合约有一定了解，但对账户抽象没有特别了解的人。 因为本文是在探索发明账户抽象的过程，所以会有很多时候，我描述的API或行为与EIP-4337的最终版本不一致。 例如，当我列出一个用户操作的字段时，请不要认为这些是实际的字段。这些字段代表了在最终版本之前对用户操作的第一次尝试性定义。 好了，准备好了吗？我们开始吧。 目标：创建一个能保护我们资产的钱包 为了开始工作，让我们发明一种方法来保护我们最宝贵的资产。我们希望能够用一个单一的私钥来签署大多数交易（就像一个典型的账户），但我们无价的Carbonated Courage NFT 应该只有在我们用第二把私钥签署时才有可能转移，我们会把它锁在由”三头犬“看守的银行保险库里。 这里是第一个问题： 每个以太坊账户要么是智能合约，要么是外部拥有的账户（EOA），其中后者是由链外使用私钥控制的。持有这些资产的账户应该是智能合约还是EOA？ 事实上，资产持有者必须是一个智能合约。如果它是一个EOA，那么资产总是可以通过EOA的私钥签署的交易来转移，这就绕过了我们想要的安全性。 因此，与今天的大多数人不同，我们在链上的存在将由一个智能合约来代表，而不是一个EOA，我们将其称为智能合约钱包，或只是一个 "钱包"。 我们需要一种方法来向这个智能合约发布命令，以便它执行我们想要的行动。特别是，我们（从EOA 账户发起）需要能够命令智能合约进行任何形式的转账或调用。 每个希望自己的资产以这种方式得到保护的用户都需要自己的智能合约。不可能有一个大的合约持有多个人的资产，因为生态系统的其他应用假定一个地址代表一个实体。 例如，如果有人想向联合钱包合约中的某人发送NFT，NFT的转移API将只允许发送者指定联合钱包的地址，而不是其中的单个用户。 用户操作 我将部署一个钱包智能合约，它将持有我的资产，钱包需要有一个函数，以便我把我希望它进行的调用的信息传递给它。 这里把代表希望我的钱包执行的行动的数据称为用户操作（User Operation）。 因此，钱包的合约看起来像这样： contract Wallet { function executeOp(UserOperation op); } 用户操作的内容是什么？ 首先，需要所有我们通常会传递给eth_sendTransaction的参数： struct UserOperation { address to; bytes data; uint256 value; // Amount of wei sent uint256 gas; // ... } 除此之外，我们还需要提供一些东西来授权请求 -- 也就是说，钱包将查看一块数据（签名）来决定是否要执行该操作。 对于我们的NFT保护的钱包，对于大多数用户的操作，我们会传递一个主密钥签名的操作部分的数据。 但是，如果用户操作是转移我们超级有价值的 Carbonated Courage NFT，那么钱包将需要我们传递由我们两个密钥各自签署的操作的签名。 我们还将需要有一个nonce，以防止重放攻击，即有人可以重新发送以前的用户操作来再次运行它。 struct UserOperation { // ... bytes signature; uint256 nonce; } 这实际上已经达到了目的! 只要我的 Carbonated Courage NFT 被这个合约所持有，没有两个人的签名，它就不能被转让。 虽然钱包可以选择如何解释signature和nonce字段，但我希望几乎所有的钱包都使用signature字段来接收某种覆盖所有其他字段的签名，以防止未经授权的各方伪造或篡改操作。同样地，我希望几乎所有的钱包都能拒绝一个它已经使用过的nonce授权操作。 谁调用智能合约钱包？ 这里有一个未回答的问题是executeOp(op)如何被调用。如果没有我的私钥的签名，它不会做任何事情，我们可以让任何人尝试调用它，不会有任何安全风险。但我们确实需要人来实际调用，以使操作得以发生。 在以太坊上，所有的交易都必须来自一个EOA，而调用的EOA必须用自己的ETH来支付Gas。 我可以做的是有一个单独的EOA账户，其唯一目的是调用我的钱包合约。虽然这个EOA不会像钱包合约那样有双签名保护，但它只需要持有足够的ETH来支付我的钱包运行所需的Gas，而更安全的钱包合约可以持有我所有的宝贵财产。 因此，我们实际上只用一个相当简单的合约就得到了大部分账户抽象的功能! 用户使用独立EOA调用智能合约的钱包。 我所说的 "钱包合约" 在ERC-4337中被称为 "账户"。我觉得这很让人困惑，因为我认为每个地址都是一个账户。我总是把这个参与者称为 "钱包合约" 或只是一个 "钱包"。 目标：没有单独的EOA 上述解决方案的一个缺点是要求我运行一个单独的EOA账户来调用我的钱包。如果我不想这样做呢？目前，我仍然愿意用ETH支付自己的Gas。我只是不想有两个独立的账户。 我们说过，钱包合约的executionOp方法可以被任何人调用，所以我们可以直接让其他拥有EOA的人帮我们调用它。我将把这个EOA和运行它的人称为 "执行者"。 由于执行者是支付Gas费的人，没有多少人愿意免费做这件事。所以新的计划是，钱包合约将持有一些ETH，作为执行者调用的一部分，钱包将转账一些ETH给执行者，以补偿执行者使用的任何Gas。 "执行者（Executor）" 不是ERC-4337的术语，但它很好地描述了这个参与者的工作。稍后，我们将用ERC-4337使用的实际术语 "捆绑者（bundler）"来代替它，但现在这样做还没有意义，因为我们目前还没有做任何捆绑工作。其他协议也可能把这个参与者称为 "中继者（relayer）"。 第一次尝试钱包在最后向执行者付款 让我们试着保持简单。钱包的接口是： contract Wallet { function executeOp(UserOperation op); } 我们将尝试修改executeOp的行为，以便在最后，它查看自己使用了多少Gas，并向执行者发送适当数量的ETH来支付它。 执行者调用智能合约的钱包，而不是用户自己的EOA。 第一次模拟 如果我的钱包是值得信赖的，那么这就很好用了！但执行者需要确定钱包真的会支付还款。如果执行者调用executionOp，但钱包并没有真正退还Gas，那么执行者就会承担Gas费的责任。 为了避免这种情况，执行者可以尝试在本地模拟executeOp操作，可能使用debug_traceCall，看看它是否真的得到了Gas的补偿。只有这样，它才会发送实际的交易。 这里的一个问题是，模拟并不能完美地预测未来。钱包在模拟过程中支付Gas，而在交易实际被添加到区块中时却没有这样做，也是完全有可能的。一个不诚实的钱包可以故意这样做，让它的操作免费执行，并为执行者带来巨大的Gas费。 由于以下原因，模拟可能与实际执行不同： 操作可以从存储空间读取，而存储空间在模拟和执行的时候可能会发生变化。 该操作可以使用TIMESTAMP、BLOCKHASH、BASEFEE等操作码。这些操作码从环境中读取信息，并在不同的区块之间发生不可预知的变化 执行者可以尝试的一件事是限制被允许操作什么，比如拒绝任何使用任何 "环境" 操作码的操作。但这将是一个非常苛刻的限制。 请记住，我们希望钱包能够做任何EOA能做的事情，所以禁止这些操作码会阻止太多的合法使用。例如，它将阻止钱包与Uniswap交互，后者广泛地使用TIMESTAMP。 由于钱包的executeOp可以包含任意的代码，而且我们不能合理地限制它以阻止它欺骗模拟，这个问题在目前的接口上是无法解决的。executeOp 是一个复杂的黑盒。 更好的尝试：引入入口点合约 这里的问题是，我们要求执行者从一个不受信任的合约中运行代码。执行者想要的是在一个给予某些保证的背景下运行这些不受信任的操作。这是智能合约的全部目的，所以我们将引入一个新的受信任的（即经过审计、源代码验证的）合约，称为入口（EntryPoint），并给它一个方法，由执行者来代替调用。 contract EntryPoint { function handleOp(UserOperation op); // ... } handleOp 将做以下工作： 检查钱包是否有足够的资金来支付它可能使用的最大数量的Gas（基于用户操作中的Gas字段）。如果没有足够资金，拒绝执行。 调用钱包的executeOp方法（使用适当的Gas），跟踪它实际使用的Gas数量。 将钱包中的一些ETH发送给执行者，以支付Gas费用。 为了使第三个要点发挥作用，我们实际上需要EntryPoint持有支付Gas的ETH，而不是钱包本身，因为正如我们在上一节看到的，我们不能确定我们能够从钱包中获得ETH。因此，入口点还需要一个方法，让钱包（或代表钱包的人）将ETH放入EntryPoint，以支付其Gas，我们将有另一个方法，钱包就可以在想要的时候将其ETH 取回来。 contract EntryPoint { // ... function deposit(address wallet) payable; function withdrawTo(address payable destination); } 有了这个实现，无论如何，执行者都会得到Gas的退款。 引入经过审计、源代码验证的入口点合约，以确保执行者得到补偿。 这对执行者来说是件好事!但对于钱包来说，这实际上是一个相当大的问题...... 钱包难道不应该用自己的ETH而不是存入EntryPoint的ETH来支付Gas吗？是的，它应该! 我们会解决这个问题的，但是在我们有了下一节的变化之前，我们不能这样做，即使这样，仍然需要存款/提款系统。另外，我们以后还需要存款/取款系统来支持付款人。 分离验证和执行 我们之前将钱包的接口定义为： contract Wallet { function executeOp(UserOperation op); } 这个方法实际上做了两件事：它验证了用户操作（op）是被授权的，然后它实际执行了op所指定的调用。当钱包的主人用自己的账户支付Gas时，这种区别并不重要，但现在我们要求执行者来做这件事，这就很重要了。 我们目前的实现让钱包无论如何都要把Gas费退给执行者。但实际上，如果验证失败，我们不希望钱包付款。 如果验证失败，这意味着没有权力控制钱包的人要求钱包做一些事情。 在此案例中，钱包的executionOp将正确地阻止该操作，但在目前的实现下，钱包仍然要支付Gas。 这是一个问题，因为与钱包没有关系的人可以向该钱包请求进行一堆操作，并耗尽该钱包的所有Gas钱。 相比之下，如果验证成功，但之后的操作失败，那么该钱包应该被收取Gas费。这代表钱包所有者授权了一个行动，但结果却没有成功，就像从EOA中发送一个revert的交易一样，由于他们授权了，所以他们应该负责缴费。 目前的钱包接口只有一个方法，并没有提供区分验证失败和执行失败的方法，所以我们需要把它分成两部分。 我们的新钱包接口将是： contract Wallet { function validateOp(UserOperation op); function executeOp(UserOperation op); } 调用validateOp如果失败，就此停止。 从钱包的存款中留出ETH，用于支付它可能使用的最大数量的Gas（基于op的Gas字段）。如果钱包没有足够的钱，则拒绝。 调用executionOp并跟踪它使用了多少Gas。无论这个调用是成功还是失败，都从我们预留的资金中退还执行者的Gas，并将其余的资金返还给钱包存款。 现在对钱包来说，事情看起来很好! 除了它授权的操作，它不会被收取Gas费。 将验证和执行分开，以区分验证失败和执行失败。 但对于执行者来说，事情看起来又很棘手了...... 我们应该确保未经授权的用户不能直接调用钱包的executeOp，导致它未经验证就采取行动。钱包可以通过强制执行executeOp只能由入口点调用来防止这种情况。 另一个问题：为什么一个不诚实的钱包不在validateOp函数中做所有的执行，这样如果执行失败，它就不会被收取Gas？我们马上就会看到，validateOp 将有很大的限制，使其不适合 "真正的" 操作。 再次模拟 现在，当未经授权的用户给钱包提交操作时，该操作将在validateOp中失败，钱包无需支付。但是执行者仍然要为validateOp的链上执行支付Gas，而且不会得到补偿。 不诚实的钱包不能再让他们的操作免费运行，但攻击者仍然可以在任何时候让执行者为失败的操作损失Gas钱。 在前面的模拟部分，执行者先尝试在本地模拟操作，看是否会通过，然后才提交交易，在链上调用handleOp。 我们遇到了问题，因为执行者无法合理地限制执行，以防止它在模拟过程中成功，但在实际交易中失败。 但这次有些不同。 执行者不需要模拟整个执行过程，现在由validateOp和executeOp组成。它只需要模拟第一部分，validateOp，以知道它是否会得到报酬。而且，与executeOp不同的是，executeOp需要能够执行任意的操作，以便钱包能够自由地与区块链交互，我们可以对validateOp施加更严格的限制。 具体来说，除非validateOp满足以下限制，否则执行者将拒绝用户的操作，而不把它发送到链上： 它从不使用某个禁止列表中的操作码，其中包括TIMESTAMP、BLOCKHASH等代码。 它访问的唯一存储是钱包的相关存储，定义为以下任何一种： 钱包自己的存储。 对应于钱包的mapping(adress => value)中的插槽另一个合约的存储。 另一个合约在与钱包地址相等的存储槽中的存储（这是一个不寻常的存储方案，在 Solidity 中并不自然出现）。 这些规则的目的是尽量减少验证Op在模拟中成功但在实际执行中失败的情况。 被禁止的操作码是不言而喻的，但这些存储限制可能看起来有点奇怪。 我们的想法是，任何存储访问都代表着虚假模拟的危险，因为存储槽在模拟和执行之间可以改变，但如果我们将存储限制在与这个钱包相关的位置，那么攻击者就需要更新这个钱包的特定存储来伪造模拟结果。我们希望更新这个存储空间的成本足以阻止破坏者的行为。 有了这种模拟，钱包和执行者都是安全的。 这种存储限制还有另一个好处，那就是我们知道对不同钱包的操作调用validateOp不太可能相互干扰，因为它们都能访问的存储是有限的。当我们谈论捆绑时，这将是很重要的。 改进直接从钱包中支付Gas费用 目前，钱包提供ETH购买Gas的方式是先将其存入入口点(EntryPoint)，然后才发送用户操作。但一个普通的EOA从自己的ETH储备中支付Gas。我们的钱包能否也这样做？ 现在可以做到这一点，因为我们已经将验证和执行分开，因为入口点可以要求钱包向入口点发送ETH作为验证步骤的一部分，否则操作会被拒绝。 我们将更新钱包的validateOp方法，这样入口点就可以要求它提供资金，如果validateOp没有向入口点支付要求的金额，入口点就会拒绝该操作。 contract Wallet { function validateOp(UserOperation op, uint256 requiredPayment); function executeOp(UserOperation op); } 由于在验证时我们不知道执行过程中会使用多少Gas，入口点根据操作的Gas字段要求执行可能使用的最大数量。然后在执行结束时，我们要将未使用的Gas钱返回到钱包中。 但这里我们遇到了一个问题。 在编写智能合约时，向任意合约发送ETH是不可靠的，因为这样做会调用该合约上的任意代码，可能会失败，使用不可预测的Gas量，甚至试图对我们进行可重入攻击。所以我们不会直接把多余的Gas钱送回钱包。 相反，我们会扣留它，并允许钱包在以后通过调用提取它来获得它。这就是拉取-支付模式。 因此，我们实际上要做的是让多余的Gas钱进入与入金时发送的ETH相同的地方，而钱包可以在以后通过 withdrawTo 将其取出。 事实证明，我们毕竟需要存款/取款系统（或者至少是取款部分）。 这意味着一个钱包的Gas支付实际上可以来自两个不同的地方：EntryPoint持有的ETH，以及钱包自己持有的ETH。 EntryPoint将首先尝试使用存入的ETH来支付Gas，然后如果没有足够的存款，它将在调用钱包的验证Op时要求获得剩余部分。 执行者激励机制 目前，作为一个执行者是一项不容易的任务。他们需要运行大量的模拟，没有任何利润，有时当他们的模拟被伪造时，还被迫自掏腰包买Gas。 为了补偿执行者，我们将允许钱包所有者在他们的用户操作中提交一个小费，这个小费将交给执行者。 我们将在用户操作中添加一个字段来表达这一点： struct UserOperation { // ... uint256 maxPriorityFeePerGas; } 与普通交易中类似的字段一样，maxPriorityFeePerGas表示发送方愿意支付的费用，以使其操作得到优先处理。 执行者在发送其交易以调用入口点的handleOp时，可以选择一个较低的maxPriorityFeePerGas，并将其中的差额收入囊中。 入口点作为单例 我们谈到了入口点应该是一个可信的合约，以及它的作用。你可能会注意到，关于入口点没有任何东西是特定于钱包或执行者的。因此，入口点可以是整个生态系统中的一个单例。所有的钱包和所有的执行者都将与同一个入口点合约交互。 这确实意味着我们需要调整用户的操作，以便他们也指定他们是为哪个钱包服务的，这样当操作被传递给入口点的handleOp时，入口点将知道要求哪个钱包进行验证和执行。 让我们更新一下： struct UserOperation { // ... address sender; } 不在需要单独的EOA 我们的目标是创建一个链上钱包，支付自己的Gas，而其所有者不需要管理一个单独的EOA，现在我们已经实现了这个目标! 我们所拥有的是一个具有以下接口的钱包： contract Wallet { function validateOp(UserOperation op, uint256 requiredPayment); function executeOp(UserOperation op); } 我们还有一个区块链范围内的单例入口，接口为： contract EntryPoint { function handleOp(UserOperation op); function deposit(address wallet) payable; function withdrawTo(address destination); } 当钱包所有者想要执行一个动作时，他们制作一个用户操作(UserOperation)，并在链外要求一个执行者为他们处理。 执行者在这个用户操作上模拟钱包的validateOp方法，以决定是否要接受它。 如果它接受，执行者将发送一个交易到入口点，调用handleOp。 然后入口点处理验证和执行链上的操作，之后从钱包的存款中向执行者退还ETH。 耶! 这是一个很大的问题，但我们做到了! 插曲：捆绑 在我们进入下一个大功能之前，让我们花点时间做一个令人惊讶的简单优化。 到目前为止，我们已经实现了，执行者发送一个交易来执行一个用户操作。但现在我们有了一个不只绑定在一个钱包上的入口合约，我们可以通过从不同的人那里收集一堆用户操作，然后在一个交易中全部执行，从而节省一些Gas。 这种对用户操作的捆绑，将通过不重复支付固定的21,000 的交易费用，以及降低执行冷存储访问的费用（在一个交易中多次访问同一存储，在第一次之后会更便宜）来节省Gas。 需要的修改少得令人耳目一新。 我们将取代： contract EntryPoint { function handleOp(UserOperation op); // ... } 用这个： contract EntryPoint { function handleOps(UserOperation[] ops); // ... } 基本上就是这样了： 在一个交易中捆绑并执行一堆用户操作。 新的handleOps方法或多或少做了你所期望的事情。 对于每个操作，在操作的发送者钱包上调用验证Op。任何验证失败的操作都会被丢弃。 对于每个操作，在操作的发送者钱包上调用executeOp，跟踪我们使用了多少Gas，然后将ETH转给执行者以支付这些Gas。 这里需要注意的是，我们首先执行所有的验证，然后才执行所有的执行，而不是验证和执行每一个操作后再进入下一个操作。 这对于保护模拟是很重要的。 如果在handleOps过程中，我们在验证下一个操作之前先执行一个操作，那么第一个操作的执行将能够自由地扰乱第二个操作的验证所依赖的存储，并导致其失败，即使第二个操作在模拟时通过验证。 按照类似的思路，我们希望避免出现一个操作的验证会扰乱捆绑中的后一个操作的验证的情况。 只要这个捆绑中不包括同一个钱包的多个操作，我们实际上就可以容易实现这个，因为上面讨论的存储限制：如果两个操作的验证不接触相同的存储，它们就不会相互干扰。为了利用这一点，执行者将确保一个捆绑包最多包含一个来自任何特定钱包的操作。 对执行者来说，有一件好事是，他们有了新的收入来源。 执行者有机会通过在捆绑物中安排用户操作（也可能插入他们自己的操作），以一种有利可图的方式获得一些最大可提取价值（MEV）。 现在我们有了捆绑，我们可以不再把这些参与者称为 "执行者"，而开始用他们真正的名字，捆绑者来称呼他们。 为了与ERC-4337的术语保持一致，我将在本系列的其余几篇文章称他们为捆绑者，但实际上我发现 "执行者"在我的头脑中是一个很好的方式，因为它强调他们的工作是通过从EOA发送交易来实际启动链上执行。 捆绑者作为网络参与者 我们有一个设置，钱包所有者向捆绑者提交用户操作，希望将这些操作包含在一个捆绑中。这与普通交易的设置非常相似，账户所有者将交易提交给区块构建者，希望将这些交易包含在区块中，所以我们可以从一些相同的网络架构中受益。 就像节点在mempool中存储普通交易并将其广播给其他节点一样，捆绑者可以在mempool中存储经过验证的用户操作，并将其广播给其他捆绑者。捆绑者可以在与其他捆绑者共享之前验证用户操作，从而节省了彼此验证每个操作的工作。 捆绑者也可以通过成为区块构建者而受益，因为如果他们可以选择他们的捆绑物所包含的区块，他们可以减少甚至消除操作在模拟成功后执行过程中失败的可能性。此外，区块构建者和捆绑者可以通过了解如何提取MEV来获得类似的好处。 随着时间的推移，我们可能会期望捆绑者和区块构建者合并成同一个角色。 耶! 已经探索很多了 到目前为止，我们已经弄清楚了如何创建一个智能合约钱包来保护我们最宝贵的资产，以及如何依靠一个执行者，或捆绑者，代表我们调用这个智能合约钱包。 继续阅读 接下来，我们会继续探索 sponsored 交易、钱包创建和聚合签名。 转载：https://learnblockchain.cn/article/5426
简单概括，KZG Ceremony 是实现 EIP-4844 不可绕过的一个初始化环节，而 EIP-4844 是实现以太坊sharding过程中的先行版本。 一、Sharding: 以太坊扩容的长久之计 Sharding 从数据可用性角度扩容，而 rollup 主要从执行层面扩容，一起缓解主网拥堵问题；我认为sharding 可能是突破区块链不可能三角的一次尝试。 下面这张以太坊区块大小的走势图可以从另一个角度说明数据层面扩容的必要性。从创世区块开始，以太坊从底层架构到上层应用都在不断地快速迭代，但平均区块大小仍为 90Kb 左右，最高点也没有质的突破。虽然Rollup从计算层切入，解决以太坊拥堵问题，但性能仍然受制于layer 1的数据存储能力。 由于实现过程的复杂度和安全性考虑，以太坊开发团队将 sharding 分成了多个阶段，其中就包括最近提到的 proto-danksharding 和 danksharding 。整个过程将会是一个历时数年的更新； 在目前的以太坊的数据存储模式下，只有少数特定高配置的机器可以加入网络成为节点，而 full sharding 之后 ethereum 不再需要每个节点都保存全部数据，在降低主网数据存储成本的同时加强了安全性（成为节点的门槛降低，去中心化程度进一步提升，同时降低被攻击的风险）。 二、EIP-4844: 短期高回报，Sharding的精简先行版 EIP-4844 = Proto-Danksharding； Proto来自以太坊研究者的名称 由于实现 danksharding（下一节会分析）的复杂度很高，开发周期至少是以年为单位的。因此 proto-danksharding 是实现 danksharding 前对以太坊的扩容方案，主要实现了 danksharding 中的交易格式、precompile 等设计； 1. Proto-danksharding概述 Proto-Danksharding 主要引入了一种新的 transaction type，也就是 blob-carrying transaction。至此，Rollup 的数据通过blob 的形式以更低的成本向layer 1传输，并作非永久存储。同时，blob 远大于现在的 calldata，可以更好地支持 rollup 上的高TPS。 关于blob： 每个transaction最多挂2个 blob； 每个block理想状态包含8个 blob，约为1MB，最多包含16个 blob，约为2 MB； Blob 不需要像calldata一样作为 history log 被永久存储； 相比 danksharding，节点还是需要对完整的 DA进行验证。 2.Blob-carrying transaction解读 给transaction挂上blob（数据单元） 作用 参考Vitalik在提案中给出的图，Data blob 和当前的 calldata 相似，rollup 可以将交易、证明等数据通过blob上传到 layer 1 来保证 data availability。 成本 Data blob 的设计初衷是支持 rollup 高通量的交易，相比同等大小的 calldata（使用链上存储），blob 的成本将会降低很多（不需要永久存储）。因此，rollup 在维持数据可用性上花费的gas相比之前会显著降低。 容量 每个blob的大小约为125kB（fact: 当前平均block size只有～90kB）。 3. Blob-carrying transaction的价值和挑战 价值 可以把blob看成一种缓存，rollup 提交的交易数据从此以缓存的形式存在。降低对存储硬件的要求，为以太坊提供额外的数据扩容并降低 gas 成本。 挑战：对以太坊节点硬件性能的要求 Ethereum 当前平均区块大小只有～90kB，但是一个 blob 就有～125kB 根据 EIP-4844 的设计，每个slot正常情况下约为1 MB，因此每年增加的数据量为： 1 MB/block 5 block/min 43200 min/month * 12 month/year = 2.47 TB per year 每年增加的数据大小远超过了以太坊数据总量，这样的存储方案显然是不高效的。 解决方案 从短期扩容效果看，由于每个节点仍然需要储存全量历史数据，在实现 EIP-4844 的同时，对一段时间窗以外的 blob（具体的 limit time 还没有最终确定，可能是1个月或是1年）进行自动删除； 从 sharding 的长期利益看，实现EIP-4444，即节点不需要存储全量历史数据，而是只需要参照 history expiry，存储特定时间之后的数据； 这两种解决方案从不同程度上缓解了 blob-carrying transaction 在存储空间上的 tradeoff。 4. KZG Commitment KZG Commitment是EIP-4844中采用的多项式承诺方案 解析 KZG commitment KZG 是作者 Aniket Kate, Gregory M. Zaverucha和Ian Goldberg 姓氏的缩写，他们在2010年发表了多项式承诺方案论文“Constant-Size Commitments to Polynomials and Their Applications” ，并且这个方案在plonk-style的zk-snark协议中有很广泛的应用。 参考 Dankrad Feist 演讲中的示意图，KZG root 类似 Merkle root，区别在于 KZG root 承诺一个多项式，即所有 position 都在这个多项式上。基于 proto-danksharding 的场景，KZG root 承诺了一堆数据，其中的任何一个数据都可以被验证属于这个整体。 这也是为什么KZG commitment 在兼容性上对后面实现 DAS 更友好。 KZG commitment 的流程如下： Prover：提供证明，计算 data 的 commitment，prover 无法改变给定的多项式，并且用于证明的commitment 只对当前这一个多项式有效； Verifier：接收 prover 发送的 commitment value 并进行验证，确保 prover 提供了有效的证明。 KZG Commitment 的优势 我认为主要出于对成本和安全性的思考，可以归纳但不局限于以下几点： 成本 KZG commitment具备快速验证、复杂度相对更低、简洁的特点； 不需要提交额外的proof，因此成本更低、更省 bandwidth； 数据触达所需的 Point evaluation precompile可以获得更低的成本。 安全 假设出现了failure，也只会影响 commitment 对应的 blob 中的数据，而不会其他深远的影响。 更兼容 纵观sharding的整体方案，KZG commitment 对D AS 方案兼容，避免了重复开发的成本。 5. KZG Ceremony(trusted setup) KZG Ceremony 是为KZG Commitment 提供 trust setup，目前吸引了超过20,000 participants 的参与和贡献，已经成为历史上最大规模的 trust setup。 最近社区参与热情高涨的KZG Ceremony就是为EIP-4844采用的KZG commitment提供trust setup； KZG Ceremony的流程 参考Vitalik的流程图，任何人都可以作为participants贡献secret并与之前的结果进行混合产生一个新的result，以此类推，通过套娃的形式获得最终的SRS，并协助完成KZG commitment的trust setup trust setup EIP-4844中采用了一种常见的multi-participant trust setup，即powers-of-tau； 遵循1-of-N 可信模型，不管多少人参与generating setup的过程，只要有一个人不泄漏自己的生成方式，可信初始化就是有效的； 必要性 KZG commitment的trust setup可以简单理解为：生成一个在每次执行cryptographic protocol时需要依赖的一个参数，类似于zk-snark需要可信初始化； Prover在提供证明时，KZG commitment C = f(s)g1。其中f是评估函数，s就是KZG trusted setup最终获得的final secret； 可以看出final secret是生成多项式承诺的核心参数，而作为获取这个核心参数的可信流程，这次KZG Ceremony对于整个sharding的实现非常重要。 6.EIP-4844带来的变化 Rollup 参考ethresear上给出的示意图，rollup需要将state delta、KZG commitment的versioned hash包含在calldata中进行提交（zk-rollup还需要提交zk proof） 可以发现不同的是，calldata只包含一些数据量小的，比如state delta、KZG commitment，而将包含大量交易数据的transaction batch放到了blob里。 有效降低成本，放calldata里很贵； 降低对区块空间的占用 安全性 Data availability: Blob存储在信标链上，等同于layer 1的安全性； 历史数据：节点不会只会将blob存储一段时间，需要layer 2 rollup做永久数据存储，因此安全性依赖于rollup。 成本 Proto-Danksharding引入了新的交易类型，低成本数据格式blob的加入无疑会让rollup的成本进一步降低，取决于实际链上应用和实现进展，优化后rollup的成本可能降低x10甚至x50； 同时EIP-4844引入了blob fee； Gas和blob将会分别有可调节的gas price和limit； Blob的收费单元还是gas，gas amount随traffic变动，以此维持每个block平均挂8个blob的目标（限制额外增加的数据量） Precompile的实现 Blob中的数据本身无法被直接触达，EVM只能获取data blob的commitment。因此需要rollup提供precompile来验证commitment的有效性. 下面分析两种EIP-4844中提到的precompile算法 Point evaluation precompile（对数学原理感兴趣参考vitalik的解析） 证明多个 commitments 指向同一数据； 主要针对 zk-rollup，rollup需要提供2种 commitments: 1. KZG commitment; 2. zk-rollup 本身的commitment； 对于 optimistic rollup，大多数已经采用了multi-round fraud proof的机制，final round fraud proof 所需的数据量较小。因此，采用 point evaluation precompile 能达到更低的成本 Blob verification precompile 证明 versioned hash 和blob 是有效对应的； optimistic rollup 在提交欺诈证明时需要 access 全量数据，因此先验证 versioned hash 和 blob 合法，再进行fraud proof verification 三、Danksharding: 迈向full sharding的重要一步 Danksharding 的命名来自以太坊研究员Dankrad Feist 1. 扩容：进一步扩充Rollup的缓存空间 在proto-danksharding 实现后，由于新的交易格式引入了 blob，每个区块可以平均额外获得1MB的缓存空间。Danksharking 实现后，每个区块额外16MB，最大允许32MB。 2. Data availability：存储和验证策略更高效 相比proto-danksharding要求全节点下载全量数据，Danksharking 实现后以太坊节点只需要对blob抽样。Sampling 后的数据会分布在全网节点中，并可以组成完成的data blob。 DAS：高效抽样检查 通过纠缠码（erasure coding）帮助全网节点在下载部分数据的情况下更容易发现原始数据的丢失的概率，从而提升安全性 3.安全性：基本不变 由于每个节点不再保存全量历史数据，从数据可用性、备份和抽样检查的角度出发，安全性由至少一个节点保存全量数据变为多个节点存储部分数据，并最终还原完整数据。 虽然乍一看对单点的依赖安全性远高于对多点的依赖，但是以太坊网络中的节点数量够多，完全可以保证数据备份的需求，因此安全性并不会有很大变化。 4. 新的挑战：对 block builder 的要求提升 虽然验证者不需要下载并保留全量历史数据，对 bandwidth 和存储硬件的要求得到了缓解，但是区块创建者仍然需要上传包含全量 transaction data 的 blob 数据。 这里简单介绍一下 PBS(proposer/builder separation)，参考 Dankrad 给出 PBS 在 danksharding 方案中的应用图： 将负责出块负责人的角色拆分为 proposer和 builder。最初是为了做anti-MEV设计的提案，在danksharding 的设计中为了降低区块创建时对bandwidth的要求。 四、其他sharding方案：Shardeum的动态分片 Shardeum 是 EVM 兼容的 layer 1公链, 与以太坊的 static sharding 方案不同，shardeum 通过dynamic state sharding的方案提升底层可扩展性和安全性，同时，天然地保证较高的去中心化程度； 1. Dynamic state sharding 优势 Dynamic state sharding 带来最直观的优势在于linear scaling，接入网络的节点可以非常高效的被sharding 算法动态分组，并快速响应，提升区块链网络的 TPS。在 dynamic state sharding 的设计中，每个节点会覆盖不同 range 的 address ，并且覆盖范围又会有冗余设计以保证高效的 sharding 和安全性。 场景内实现 抛开生态的复杂度，单从两种技术实现的角度看，dynamic state sharding 的难度大于 static sharding。可以看出Shardeum的技术团队在 sharding 的技术层面有很深的积累，团队之前在 Shardus technology上的研发也对这条公链的底层技术做出了很大贡献，甚至在项目早期阶段很好的展示了dynamic state sharding 所带来的 linear scaling。 2. Shardeum综合归纳 产品 将节点划分到不同的group，参考 divide and conquer 的思路，把计算和存储的 workload 进行分流，从而允许更高程度的并行处理。因此，可以容纳更多节点加入，进一步提升公链的 throughput 和去中心化程度。 团队 市场经验丰富，叙事能力超强，对动态分片研究很深。 技术 针对自己的场景设计了合适的sharding方案( dynamic state sharding )和共识的设计( Proof of Stake+ Proof of Quorum )，以提升可扩展性为第一目标，保证更高程度的去中心化和安全性。 进度 将在2023-02-02 launch betanet，值得关注。 五、对sharding的思考和展望 Sharding 是以太坊扩容的长久之计，也是一个价值巨大、意义深远的长期方案。实现 sharding 的过程中，现有所有方案都可能被不断迭代，包括现在提到的 proto-danksharding、danksharding 等，值得持续关注； 对 sharding 大方向的理解很重要，但是每一个实现 full sharding 过程中的提案所采纳的技术方案（PBS、DAS、multidimensional fee market）同样值得关注，并且我相信也会涌现很多相关的优秀团队和项目； Sharding 是对一种扩容技术的统称，但具体落地的方案并不是只有一种。需要认识到不同的公链会有适合自己场景的sharding方案。比如danksharding中的一些设计也只适合以太坊网络，安全性的tradeoff需要大量的节点来抵消； Sharding 和其他扩容方案的合理结合对于可扩展性的提升1+1>2。目前的 Danksharding 并不是自成一派的扩容方案，而是和以太坊生态的其他实现相辅相成的。比如 Danksharding 和 rollup 一起，为以太坊扩容达成更好的效果。 Reference https://notes.ethereum.org/@dankrad/kzg_commitments_in_proofs https://notes.ethereum.org/@dankrad/new_sharding https://vitalik.ca/general/2022/03/14/trustedsetup.html https://notes.ethereum.org/@vbuterin/proto_danksharding_faq#Why-use-the-hash-of-the-KZG-instead-of-the-KZG-directly https://ethresear.ch/t/easy-proof-of-equivalence-between-multiple-polynomial-commitment-schemes-to-the-same-data/8188 https://dankradfeist.de/ethereum/2020/06/16/kate-polynomial-commitments.html https://notes.ethereum.org/@dankrad/new_sharding https://eips.ethereum.org/EIPS/eip-4844 https://www.eip4844.com/ https://biquanlibai.notion.site/Data-Availability-caa896aae59d489b98f2448f17b01640 https://docs.google.com/presentation/d/1-pe9TMF1ld185GL-5HSWMAsaZLbEqgfU1sYsHGdD0Vw/edit#slide=id.g1150d91b32e_0_474 https://ethresear.ch/t/a-design-of-decentralized-zk-rollups-based-on-eip-4844/12434 原文链接：https://mp.weixin.qq.com/s/45y2L25XwZBVHKzMsAxxrA 转载：https://learnblockchain.cn/article/5359
这一讲，我们将介绍选择器碰撞攻击，它是导致跨链桥 Poly Network 被黑的原因之一。在2021年8月，Poly Network在ETH，BSC，和Polygon上的跨链桥合约被盗，损失高达6.11亿美元。这是2021年最大的区块链黑客事件，也是历史被盗金额榜单上第2名，仅次于 Ronin 桥黑客事件。 选择器碰撞 以太坊智能合约中，函数选择器是函数签名 "()" 的哈希值的前4个字节（8位十六进制）。当用户调用合约的函数时，calldata的前4字节就是目标函数的选择器，决定了调用哪个函数。如果你不了解它，可以阅读WTF Solidity极简教程第29讲：函数选择器。 由于函数选择器只有4字节，非常短，很容易被碰撞出来：即我们很容易找到两个不同的函数，但是他们有着相同的函数选择器。比如transferFrom(address,address,uint256)和gasprice_bit_ether(int128)有着相同的选择器：0x23b872dd。当然你也可以写个脚本暴力破解。 大家可以用这两个网站来查同一个选择器对应的不同函数： https://www.4byte.directory/ https://sig.eth.samczsun.com/ 你也可以使用下面的Power Clash工具进行暴力破解： PowerClash: https://github.com/AmazingAng/power-clash 相比之下，钱包的公钥有256字节，被碰撞出来的概率几乎为0，非常安全。 漏洞合约例子 漏洞合约 下面我们来看一下有漏洞的合约例子。SelectorClash合约有1个状态变量 solved，初始化为false，攻击者需要将它改为true。合约主要有2个函数，函数名沿用自 Poly Network 漏洞合约。 putCurEpochConPubKeyBytes() ：攻击者调用这个函数后，就可以将solved改为true，完成攻击。但是这个函数检查msg.sender == address(this)，因此调用者必须为合约本身，我们需要看下其他函数。 executeCrossChainTx() ：通过它可以调用合约内的函数，但是函数参数的类型和目标函数不太一样：目标函数的参数为(bytes)，而这里调用的函数参数为(bytes,bytes,uint64)。 contract SelectorClash { bool public solved; // 攻击是否成功 // 攻击者需要调用这个函数，但是调用者 msg.sender 必须是本合约。 function putCurEpochConPubKeyBytes(bytes memory _bytes) public { require(msg.sender == address(this), "Not Owner"); solved = true; } // 有漏洞，攻击者可以通过改变 _method 变量碰撞函数选择器，调用目标函数并完成攻击。 function executeCrossChainTx(bytes memory _method, bytes memory _bytes, bytes memory _bytes1, uint64 _num) public returns(bool success){ (success, ) = address(this).call(abi.encodePacked(bytes4(keccak256(abi.encodePacked(_method, "(bytes,bytes,uint64)"))), abi.encode(_bytes, _bytes1, _num))); } } 攻击方法 我们的目标是利用executeCrossChainTx()函数调用合约中的putCurEpochConPubKeyBytes()，目标函数的选择器为：0x41973cd9。观察到executeCrossChainTx()中是利用_method参数和"(bytes,bytes,uint64)"作为函数签名计算的选择器。因此，我们只需要选择恰当的_method，让这里算出的选择器等于0x41973cd9，通过选择器碰撞调用目标函数。 Poly Network黑客事件中，黑客碰撞出的_method为 f1121318093，即f1121318093(bytes,bytes,uint64)的哈希前4位也是0x41973cd9，可以成功的调用函数。接下来我们要做的就是将0x41973cd9转换为bytes类型：0x6631313231333138303933，然后作为参数输入到executeCrossChainTx()中。executeCrossChainTx()函数另3个参数不重要，都填 0x 就可以。 Remix演示 部署SelectorClash合约。 调用executeCrossChainTx()，参数填0x6631313231333138303933，0x，0x，0x，发起攻击。 查看solved变量的值，被修改为ture，攻击成功。 总结 这一讲，我们介绍了选择器碰撞攻击，它是导致跨链桥 Poly Network 被黑 6.1 亿美金的的原因之一。这个攻击告诉了我们： 函数选择器很容易被碰撞，即使改变参数类型，依然能构造出具有相同选择器的函数。 管理好合约函数的权限，确保拥有特殊权限的合约的函数不能被用户调用。 转载：https://mirror.xyz/wtfacademy.eth/5rwcsBZzphdlKZj4MoIpn8aqwQ1MzQ8qy50ZEGNU_HU
回调函数 Solidity支持两种特殊的回调函数，receive()和fallback()，他们主要在两种情况下被使用： 接收ETH 处理合约中不存在的函数调用（代理合约proxy contract） 我们这一讲主要介绍接收ETH的情况。 接收ETH函数 receive receive()只用于处理接收ETH。一个合约最多有一个receive()函数，声明方式与一般函数不一样，不需要function关键字：receive() external payable { ... } receive()函数不能有任何的参数，不能返回任何值，必须包含external和payable。 当合约接收ETH的时候，receive()会被触发。receive()最好不要执行太多的逻辑因为如果别人用send和transfer方法发送ETH的话，gas会限制在2300，receive()太复杂可能会触发Out of Gas报错；如果用call就可以自定义gas执行更复杂的逻辑（这三种发送ETH的方法我们之后会讲到）。 我们可以在receive()里发送一个event，例如： // 定义事件 event Received(address Sender, uint Value); // 接收ETH时释放Received事件 receive() external payable { emit Received(msg.sender, msg.value); } 有些恶意合约，会在receive()函数嵌入恶意消耗gas的内容，使得一些退款合约不能正常工作：Akutar NFT项目因此被永久锁定了11539 ETH，接近2亿元！因此写包含退款等逻辑的合约时候，一定要注意这种情况。 回退函数 fallback fallback()函数会在调用合约不存在的函数时被触发。可用于接收ETH，也可以用于代理合约proxy contract。fallback()声明时不需要function关键字，必须由external修饰，一般也会用payable修饰，用于接收ETH:fallback() external payable { ... }。 我们定义一个fallback()函数，被触发时候会释放fallbackCalled事件，并输出msg.sender，msg.value和msg.data: // fallback fallback() external payable{ emit fallbackCalled(msg.sender, msg.value, msg.data); } receive和fallback的区别 receive和fallback都能够用于接收ETH，他们触发的规则如下： 触发fallback() 还是 receive()? 接收ETH | msg.data是空？ / \ 是 否 / \ receive()存在? fallback() / \ 是 否 / \ receive() fallback() 简单来说，合约接收ETH时，msg.data为空且存在receive()时，会触发receive()；msg.data不为空或不存在receive()时，会触发fallback()，此时fallback()必须为payable。 receive()和payable fallback()均不存在的时候，向合约发送ETH将会报错。 总结 这一讲，我介绍了Solidity中的两种特殊函数，receive()和fallback()，他们主要在两种情况下被使用，他们主要用于处理接收ETH和代理合约proxy contract。 转载：https://mirror.xyz/wtfacademy.eth/EroVZqHW1lfJFai3umiu4tb9r1ZbDVPOYC-puaZklAw
实例验证下call,delegatecall,两种方式下msg.sender和数据storage存储位置的区别 合约A pragma solidity ^0.8.19; contract A { address public temp1; uint256 public temp2; function three_call(address addr) public { (bool success, bytes memory result) = addr.call( abi.encodeWithSignature("test()") ); // 1 require(success, "The call to B contract failed"); } function three_delegatecall(address addr) public { (bool success, bytes memory result) = addr.delegatecall( abi.encodeWithSignature("test()") ); // 2 require(success, "The delegatecall to B contract failed"); } } 合约B pragma solidity ^0.8.19; contract B { address public temp1; uint256 public temp2; function test() public { temp1 = msg.sender; temp2 = 100; } } call 测试 B合约地址：0x086866663330344C7D1C51Bf19FF981AF3cB5782 A合约地址：0x05715D87C062B9685DD877d307b584bAbec964Ed 交易发起地址：0x6BC0E9C6a939f8f6d3413091738665aD1D7d2776 执行前数据 A B temp1 0x0000000000000000000000000000000000000000 0x0000000000000000000000000000000000000000 temp2 0 0 A合约执行three_call，参数为B合约地址 执行后数据 A B temp1 0x0000000000000000000000000000000000000000 0x05715D87C062B9685DD877d307b584bAbec964Ed temp2 0 100 delegatecall 测试 B合约地址：0x27153EDA2E085534811b040f6062f6528D6B80a1 A合约地址：0xd3C23F354Ca2160E6dC168564AB8954146cF35C9 交易发起地址：0x6BC0E9C6a939f8f6d3413091738665aD1D7d2776 执行前数据 A B temp1 0x0000000000000000000000000000000000000000 0x0000000000000000000000000000000000000000 temp2 0 0 A合约执行three_delegatecall，参数为B合约地址 执行后数据 A B temp1 address: 0x6BC0E9C6a939f8f6d3413091738665aD1D7d2776 0x0000000000000000000000000000000000000000 temp2 100 0 总结 A发起地址->B合约call->C合约 msg.sender为B合约地址,非原始A发起地址，数据保存在C合约中 A地址->B合约delegatecall->C合约 msg.sender为A发起地址，数据保存在B合约中 注意 callcode 已经在solidity 0.5+废弃，被delegatecall取代，故不做分析测试 TypeError: "callcode" has been deprecated in favour of "delegatecall". 参考 https://hicoldcat.com/posts/web3/senior-track-5/
方案的具体讲解来自《智能合约安全审计入门篇 —— 访问私有数据》，本片文章将在QEasyWeb3测试链上进行数据验证 测试合约 contract Vault { uint256 public count = 123; address public owner = msg.sender; bool public isTrue = true; uint16 public u16 = 31; bytes32 private password; bytes32[3] public data; struct User { uint256 id; bytes32 password; } User[] private users; mapping(uint256 => User) private idToUser; constructor(bytes32 _password) { password = _password; } function addUser(bytes32 _password) public { User memory user = User({id: users.length, password: _password}); users.push(user); idToUser[user.id] = user; } } 部署合约 通过https://remix.ethereum.org/ 进行部署，具体操作不做详细讲解 网络配置 QEasyWeb3的网络配置，可以查看《QEasyChain 测试链信息》 类型 RPC https https://qeasyweb3.com http http://qeasyweb3.com chain id: 9528 合约操作 合约部署时，构造函数参数bytes32 _password，需要传入初始化参数，用于写入测试状态变量password 例如：0x4141414242424343430000000000000000000000000000000000000000000001 然后通过addUser再添加两个测试数据 0x4141414242424343430000000000000000000000000000000000000000000002 0x4141414242424343430000000000000000000000000000000000000000000003 执行完成后，开始slot的读取测试。 读取私有slot数据 为了简化操作，我们直接使用python3 + web3py进行测试脚本的编写, 也可以选用其他web3库，大同小异 我们上面合约部署完的合约地址是0x4398BdBD9eF8bcACc2A41Abc671BF8f428BB4904 测试脚本 import time from web3 import Web3 w3 = Web3(Web3.HTTPProvider('http://qeasyweb3.com')) if __name__ == "__main__": for i in range(0,7): print("slot"+str(i)+" = " + w3.toHex(w3.eth.getStorageAt("0x4398BdBD9eF8bcACc2A41Abc671BF8f428BB4904",hex(i)))) 执行后返回数据如下 slot0 = 0x000000000000000000000000000000000000000000000000000000000000007b slot1 = 0x000000000000000000001f016bc0e9c6a939f8f6d3413091738665ad1d7d2776 slot2 = 0x4141414242424343430000000000000000000000000000000000000000000001 slot3 = 0x0000000000000000000000000000000000000000000000000000000000000000 slot4 = 0x0000000000000000000000000000000000000000000000000000000000000000 slot5 = 0x0000000000000000000000000000000000000000000000000000000000000000 slot6 = 0x0000000000000000000000000000000000000000000000000000000000000002 slot0 存储的为状态变量count的值，等于123的十六进制 slot1 由于存储紧凑原则，当前slot存储了3部署数据，从右往左依次是 状态变量owner的值，等于用于部署合约的msg.sender=0x6BC0E9C6a939f8f6d3413091738665aD1D7d2776 状态变量isTrue = 01 = true 状态变量u16 = 1f = 31 slot2 存储的为状态变量password slot3 存储的为状态变量data第1个存储值 = data[0] slot4 存储的为状态变量data第2个存储值 = data[1] slot5 存储的为状态变量data第3个存储值 = data[2] slot6 存储的为变长数组状态变量users的长度，当前已执行两次addUser 查看私有变长数组数据 上面我们从slot6中查询到了变长数组users的长度，下面我们继续查看users中存储的数据。 我们先再次回顾下变长数组的存储原则 对于变长数组，会先启用一个新的插槽 slotA 用来存储数组的长度，其数据存储在另外的编号为 slotV 的插槽中。slotA 表示变长数组声明的位置，用 length 表示变长数组的长度，用 slotV 表示变长数组数据存储的位置，用 value 表示变长数组某个数据的值 对应的代码逻辑 length = sload(slotA) slotV = keccak256(slotA) + index// 索引下标 value = sload(slotV) 依据变长数组的存储以及紧凑打包的原则，所以对于前面两次addUser的数据存储槽位置为 user1.id == keccak256(slotA) + 0 user1.password == keccak256(slotA) + 1 user2.id == keccak256(slotA) + 2 user2.password == keccak256(slotA) + 3 我们继续使用python3 + web3py进行验证 先计算keccak256(slotA) print("slot6-keccak = " +w3.toHex(Web3.keccak(hexstr="0x0000000000000000000000000000000000000000000000000000000000000006"))) 得到 0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d3f 所以user1和user2的存储槽位置为 user1.id == 0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d3f user1.password == 0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d40 user2.id == 0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d41 user2.password == 0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d42 通过测试脚本，获取上面存储槽位置的值 print("user1.id = " + w3.toHex(w3.eth.getStorageAt("0x4398BdBD9eF8bcACc2A41Abc671BF8f428BB4904", "0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d3f"))) print("user1.password = " + w3.toHex(w3.eth.getStorageAt("0x4398BdBD9eF8bcACc2A41Abc671BF8f428BB4904", "0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d40"))) print("user2.id = " + w3.toHex(w3.eth.getStorageAt("0x4398BdBD9eF8bcACc2A41Abc671BF8f428BB4904", "0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d41"))) print("user2.password = " + w3.toHex(w3.eth.getStorageAt("0x4398BdBD9eF8bcACc2A41Abc671BF8f428BB4904", "0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d42"))) 得到数据如下 user1.id = 0x0000000000000000000000000000000000000000000000000000000000000000 user1.password = 0x4141414242424343430000000000000000000000000000000000000000000002 user2.id = 0x0000000000000000000000000000000000000000000000000000000000000001 user2.password = 0x4141414242424343430000000000000000000000000000000000000000000003 经测试与前面两次addUser的数据一致 总结 对于定长数组data，每个元素单独一个存储槽，所以data状态变量依次为slot3-5 对于变长数组users，会先启用一个新的插槽 slotA 用来存储数组的长度，其数据存储在另外的编号为 slotV 的插槽中。slotA 表示变长数组声明的位置，用 length 表示变长数组的长度，用 slotV 表示变长数组数据存储的位置，用 value 表示变长数组某个数据的值
智能合约安全审计入门篇 —— 访问私有数据
了解如何访问合约中的私有数据（private 数据） 前置知识 我们先来了解一下 solidity 中的三种数据存储方式： 1. storage（存储） storage 中的数据被永久存储。其以键值对的形式存储在 slot 插槽中。 storage 中的数据会被写在区块链中（因此它们会更改状态），这就是为什么使用存储非常昂贵的原因。 占用 256 位插槽的 gas 成本为 20,000 gas。 修改 storage 的值将花费 5,000 gas 。 清理存储插槽时（即将非零字节设置为零），将退还一定量的 gas 。 storage 共有 2^256 个插槽，每个插槽 32 个字节数据按声明顺序依次存储，数据将会从每个插槽的右边开始存储，如果相邻变量适合单个 32 字节，然后它们被打包到同一个插槽中否则将会启用新的插槽来存储。 storage 中的数组的存储方式就比较独特了，首先，solidity 中的数组分为两种： a.定长数组（长度固定）： 定长数组中的每个元素都会有一个独立的插槽来存储。以一个含有三个 uint64 元素的定长数组为例，下图可以清楚的看出其存储方式： b.变长数组（长度随元素的数量而改变）： 变长数组的存储方式就很奇特，在遇到变长数组时，会先启用一个新的插槽 slotA 用来存储数组的长度，其数据存储在另外的编号为 slotV 的插槽中。slotA 表示变长数组声明的位置，用 length 表示变长数组的长度，用 slotV 表示变长数组数据存储的位置，用 value 表示变长数组某个数据的值，用 index 表示 value 对应的索引下标，则length = sload(slotA) slotV = keccak256(slotA) + index value = sload(slotV) 变长数组在编译期间无法知道数组的长度，没办法提前预留存储空间，所以 Solidity 就用 slotA 位置存储了变长数组的长度。 我们写一个简单的例子来验证上面描述的变长数组的存储方式： pragma solidity ^0.8.0; contract haha{ uint[] user; function addUser(uint a) public returns (bytes memory){ user.push(a); return abi.encode(user); } } 部署这个合约后调用 addUser 函数并传入参数 a = 998，debug 后可以看出变长数组的存储方式： 其中第一个插槽为（这里存储的是变长数组的长度）： 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563 这个值等于： sha3("0x0000000000000000000000000000000000000000000000000000000000000000") key = 0 这是当前插槽的编号 value = 1 这说明变长数组 user[] 中只有一条数据也就是数组长度为 1 ; 第二个插槽为（这里存储的是变长数组中的数据）： 0x510e4e770828ddbf7f7b00ab00a9f6adaf81c0dc9cc85f1f8249c256942d61d9 这个值等于： sha3("0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563") 插槽编号为： key=0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563 这个值等于： sha3("0x0000000000000000000000000000000000000000000000000000000000000000")+0 插槽中存储的数据为： value=0x00000000000000000000000000000000000000000000000000000000000003e6 也就是 16 进制表示的 998 ，也就是我们传入的 a 的值。 为了更准确的验证我们再调用一次 addUser 函数并传入 a=999 可以得到下面的结果： 这里我们可以看到新的插槽为： 0x6c13d8c1c5df666ea9ca2a428504a3776c8ca01021c3a1524ca7d765f600979a 这个值等于： sha3("0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e564") 插槽编号为： key=0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e564 这个值等于： sha3("0x0000000000000000000000000000000000000000000000000000000000000000")+1 插槽中的存储数据为： value=0x00000000000000000000000000000000000000000000000000000000000003e7 这个值就是 16 进制表示的 999 也就是我们刚刚调用 addUser 函数传入的 a 的值。 通过上面的例子应该可以大致理解变长数组的存储方式了。 2. memory（内存） memory 是一个字节数组，其插槽大小为 256 位（32 个字节）。数据仅在函数执行期间存储，执行完之后，将会被删除。它们不会保存到区块链中。 读或写一个字节（256 位）需要 3 gas 。 为了避免给矿工带来太多工作，在进行 22 次读写操作后，之后的读写成本开始上升。 3. calldata（调用数据） calldata 是一个不可修改的，非持久性的区域，用于存储函数参数，并且其行为基本上类似于 memory。 调用外部函数的参数需要 calldata，也可用于其他变量。 它避免了复制，并确保了数据不能被修改。 带有 calldata 数据位置的数组和结构体也可以从函数中返回，但是不可以为这种类型赋值。 可见性关键字 了解了 solidity 中的三种存储方式后我们再来了解一下合约中的四种可见性关键字：在 solidity 中，有四种可见性关键字：external，public，internal 和 private。默认时函数可见性为 public。对状态变量而言，除了不能用 external 来定义，其它三个都可以来定义变量，状态变量默认的可见性为 internal。 1. external 关键字 external 定义的外部函数可以被其它合约调用。用 external 修饰的外部函数 function() 不能作为内部函数直接调用，也就是说 function() 的调用方式必须用 this.function() 。 2. public 关键字 public 定义的函数可以被内部函数或外部消息调用。对用 public 定义的状态变量，系统会自动生成一个 getter 函数。 3. internal 用关键字 internal 定义的函数和状态变量只能在（当前合约或当前合约派生的合约）内部进行访问。 4. private 关键字 private 定义的函数和状态变量只对定义它的合约可见，该合约派生的合约都不能调用和访问该函数及状态变量。 综上可知，合约中修饰变量存储的关键字仅仅限制了其调用的范围，并没有限制其是否可读。所以我们今天就来带大家了解如何读取合约中的所有数据。 漏洞示例 这次我们的目标合约是部署在 Ropsten 上的一个合约。 合约地址： 0x3505a02BCDFbb225988161a95528bfDb279faD6b 链接： https://ropsten.etherscan.io/address/0x3505a02BCDFbb225988161a95528bfDb279faD6b#code 这里我也给大家把合约源码展示出来： contract Vault { uint256 public count = 123; address public owner = msg.sender; bool public isTrue = true; uint16 public u16 = 31; bytes32 private password; uint256 public constant someConst = 123; bytes32[3] public data; struct User { uint256 id; bytes32 password; } User[] private users; mapping(uint256 => User) private idToUser; constructor(bytes32 _password) { password = _password; } function addUser(bytes32 _password) public { User memory user = User({id: users.length, password: _password}); users.push(user); idToUser[user.id] = user; } function getArrayLocation( uint256 slot, uint256 index, uint256 elementSize ) public pure returns (uint256) { return uint256(keccak256(abi.encodePacked(slot))) + (index * elementSize); } function getMapLocation(uint256 slot, uint256 key) public pure returns (uint256) { return uint256(keccak256(abi.encodePacked(key, slot))); } } 漏洞分析 由上面的合约代码我们可以看到，Vault 合约将用户的用户名和密码这样的敏感数据记录在了合约中，由前置知识中我们可以了解到，合约中修饰变量的关键字仅限制其调用范围，这也就间接证明了合约中的数据均是公开的，可任意读取的，将敏感数据记录在合约中是不安全的。 读取数据 下面我们就带大家来读取这个合约中的数据。首先我们先看 slot0 中的数据： 由合约中可以看到 slot0 中只存储了一个 uint 类型的数据，我们读取出来看一下： 我这里使用 Web3.py 取得数据 首先写好程序 运行后得到 我们使用进制转换器转换一下 这里我们就成功的去到了合约中的第一个插槽 slot0 中存储的 uint 类型的变量 count=123 ，下面我们继续： slot1 中存储三个变量：u16, isTrue, owner 从右往左依次为 owner = f36467c4e023c355026066b8dc51456e7b791d99 isTrue = 01 = true u16 = 1f = 31 slot2 中就存储着私有变量 password 我们读取看看 slot 3, 4, 5 中存储着定长数组中的三个元素 slot6 中存储着变长数组的长度 我们从合约代码中可以看到用户的 id 和 password 是由键值对的形式存储的，下面我们来读取两个用户的 id 和 password： user1 user2 好了，这里我们就成功的将合约中的所有数据读取完成，现在大家应该都能得出一个结论：合约中的私有数据也是可以读取的。 修复建议 作为开发者 不要将任何敏感数据存放在合约中，因为合约中的任何数据都可被读取。 作为审计者 在审计过程中应当注意合约中是否存在敏感数据，例如：秘钥，游戏通关口令等。 参考文献 本期讲解的知识有点偏底层，可以参考以下文章帮助你更好地理解： https://solidity-by-example.org/hacks/accessing-private-data/ 《跟我学 Solidity : 变量的存储》https://learnblockchain.cn/article/1759 《快速入门——web3.py》https://web3py.readthedocs.io/ 《状态变量、函数的权限》https://blog.csdn.net/liyuechun520/article/details/78408608 转载：https://learnblockchain.cn/article/3880