您正在查看: Ethereum 分类下的文章

Solidity 十大常见安全问题

在2018年,我们(CheckMarx)曾对智能合约安全状况进行过初步研究,重点是Solidity编写的智能合约。 当时,我们根据公开的合约源代码(译者注:本文称之为已扫描合约,本文出现的 x% 是以此为基数)编写了最常见的10 个智能合约安全问题。 两年过去了该更新研究并评估智能合约安全性发展的如何了。

0. 值得关注的其他问题

尽管有一个安全问题排名很不错,但它往往一些有趣的细节,因为某些细节与排名列表并不完全一致。 在深入挖掘 10 大问题之前,必要阐述一下原始研究中一些值得关注的亮点问题:

  • 在2018年,最主要的两个问题是 外部合约拒绝服务和重入。 但是现在这些问题有所缓解(不过依旧值得关注)。 可以从我们的研究博客中了解更多有关Reentrancy的信息:从安全角度出发审视智能合约
    译者注: 实际上由于 DeFi 应用之间的组合应用(例如闪电贷),又导致了多起严重的重入攻击事件。
  • 现在 Solidity v0.6.x 发布了,它带来了许多重大变化,然而扫描的智能合约中有50%甚至还没有准备好使用Solidity v0.5.0编译器。 另外 30% 智能合约使用了过时的语法(例如:使用 sha3、throw 、constant等),并且 83%的合约在指定编译器版本存在规范问题(pragma)。
  • 尽管可见性问题没有出现在2018年的前10位,也没有出现今年的前 10,但可见性问题增加了48%,值得关注。
    下表比较了2018年和2020年十大常见问题列表之间的变化。 这些问题按严重程度和流行程度排序:

1. 未检查的外部调用

在 2018 年 Solidity十大安全问题榜单上未检查的外部调用是第三个常见问题。由于现在前两个解决了, 因此未检查的外部调用成为了2020年更新列表中最常见的问题。
Solidity 底层调用方法,(例如 address.call()) 不会抛出异常。而是在遇到错误,返回false。
而如果使用合约调用ExternalContract.doSomething()时,如果 doSomething()抛出异常,则异常会继续“冒泡”传播。
应该通过检查返回值来显式处理不成功的情况,以下使用addr.send()进行以太币转账是一个很好的例子,这对于其他外部调用也有效。

if(!addr.send(1)) {
    revert()
}

2. 高成本循环

高成本循环从Solidity安全榜单的第四名上升至第二名。受该问题影响的智能合约数量增长了近30%。
大家都知道,以太坊上的运算是需要付费的。因此,减少完成操作所需的计算,不仅仅是优化问题(效率),还涉及到成本费用。
循环是一个昂贵的操作,这里有一个很好的例子:数组中包含的元素越多,就需要更多迭代才能完成循环。最终,无限循环会耗尽所有可用GAS。

for(uint256 i=0; i< elements.length; i++) {
    // do something
}

如果攻击者能够影响元素数组的长度,则上述代码将导致拒绝服务(执行无法跳出循环)。 而在扫描的智能合约中发现有8%的合约存在数组长度操纵问题。

3. 权力过大的所有者

这是Soldiity十大安全问题新出现的问题,该问题影响了约16%的合约,某些合约与其所有者(Owner)紧密相关,某些函数只能由所有者地址调用, 如下例所示:

只有合约所有者能够调用doSomething()和doSomethingElse()函数:前者使用onlyOwner修饰器, 而后者则显式执行该修饰器。这带来了严重的风险:如果所有者的私钥遭到泄露, 则攻击者可以控制该合约。

4. 算术精度问题

由于使用256位虚拟机(EVM),Solidity的数据类型有些复杂。 Solidity 不提供浮点运算, 并且少于32个字节的数据类型将被打包到同一个32字节的槽位中。考虑到这一点,你应该预见以下程序精度问题:

function calculateBonus(uint amount) returns (uint) {
  return amount/DELIMITER * BONUS;
}

如上例所示,在乘法之前执行的除法,可能会有巨大的舍入误差。

5. 依赖 tx.origin

智能合约不应依赖于tx.origin进行身份验证,因为恶意合约可能会进行中间人攻击,耗尽所有资金。 建议改用msg.sender:

function transferTo(address dest, uint amount) {
    require(tx.origin == owner) {
       dest.transfer(amount);
    }
}

可以在Solidity的文档中找到 Tx Origin攻击的详细说明 。简单的说,tx.origin始终是合约调用链中的最初的发起者帐户,而msg.sender则表示直接调用者。如果链中的最后一个 合约依赖于tx.origin进行身份验证,那么调用链中间环节的合约将能够榨干被调用合约的资金,因为身份验证没有检查究竟是谁(msg.sender)进行了调用。

6. 溢出(Overflow / Underflow)

Solidity的256位虚拟机存在上溢出和下溢出问题(译者注:由于结果超出取值范围称为溢出), 这里有具体的分析。 在for循环条件中使用uint数据类型时,开发人员要格外小心,因为它可能导致无限循环:

for (uint i = border; i >= 0; i--) {
  ans += i;
}

在上面的示例中,当i的值为0时,下一个值为2^256 -1,这使条件始终为true。 开发人员应当尽量使用<、>、!=和==进行比较。

7. 不安全的类型推导

该问题在Solidity十大安全问题排行榜中上升了两位,现在影响到的智能合约比之前多了 17%以上。
Solidity 支持类型推导,但有一些奇怪的表现。例如,字面量0会被推断为byte类型, 而不是通常期望的整型。
在下面的示例中,i的类型被推断为uint8,因为这时能够存储i的值 uint8 就足够。但如果elements数组包含256个以上的元素,则下面的代码就会发生溢出:

for (var i = 0; i < elements.length; i++) {
   // to something 
}

建议明确声明数据类型,以避免意外的行为和/或错误。
译者注: 在 Solidity 0.6 已经移除了var 定义变量( Solidity 0.6之后不再有类型推导了),如果使用新的编译器,将不是问题。

8. 不正确的转账

此问题在Solidity十大安全问题榜单中从第六位下降到第八位,目前影响不到1%的智能合约。
在合约之间进行以太币转账有多种方法。虽然官方推荐使用addr.transfer(x)函数,但我们仍然找到了还在使用send()函数的智能合约:

if(!addr.send(1)) {
    revert()
}

请注意,如果转账不成功,则addr.transfer(x)会自动引发异常,同样减轻第一个未检查外部调用的问题

9. 循环内转帐

当在循环体中进行以太币转账时,如果其中一个转账失败(例如,一个合约不能接收),那么整个交易将被回滚。

for (uint i = 0; i < users.lenghth; i++) {
   users[i].transfer(amount);
}

在这个例子中,攻击者可能利用此行为来进行拒绝服务攻击,从而阻止其他用户接收以太币。

10. 时间戳依赖

在2018年,时间戳依赖问题排名第五,重要的是要记住,智能合约在不同时刻多个节点上运行的。以太坊虚拟机(EVM)不提供时钟时间,并且通常用于获取时间戳的now变量(block.timestamp的别名)实际上是矿工可以操纵的环境变量。

if (timeHasCome == block.timestamp) {
    winner.transfer(amount);
}

由于矿工可以操纵当前的环境变量,因此只能在不等式>、<、>=和<=中使用其值。

如果你的应用需要随机性,可以参考RANDAO合约, 该合约基于任何人都可以参与的去中心化自治组织(DAO),是所有参与者共同生成的随机数。

总结

比较2018年和2020年十大常见问题时,我们可以观察到开发最佳实践的一些进展,尤其是那些影响安全性的实践。 看到2018年排名前2位的问题:外部合约拒绝服务和重入,已经不再榜单了,这是一个积极的信号,但仍然需要采取措施来避免这类常见错误。

请记住,智能合约在设计上是不可变的,这意味着一旦创建,就无法修补源代码。 这对安全性构成了巨大挑战,开发人员应利用可用的安全测试工具来确保在部署之前对源代码进行了充分的测试和审核。

链接:https://securityboulevard.com/2020/05/solidity-top-10-common-issues/
转载:https://learnblockchain.cn/article/1218

Solidity delegatecall 的使用和误区

Solidity delegatecall (委托调用)是一个低级别的函数,其强大但棘手,如果使用得当,可以帮助我们创建 可扩展 的智能合约,帮助我们修复漏洞,并为现有的智能合约增加新的功能

Solidity delegatecall (委托调用)是一个低级别的函数,它允许我们在主合约的上下文的情况下加载和调用另一个合约的代码。这意味着被调用合约的代码被执行,但被调用合约所做的任何状态改变实际上是在主合约的存储中进行的,而不是在被调用合约的存储中。
这对创建库和代理合约模式很有用,我们把调用委托给不同的合约,"给它们"权限来修改调用合约的状态。
这个功能也有一些我们需要注意的隐患,基本上是本文要重点关注的内容。
正如另一篇关于存储中状态变量布局的文章Solidity 文档 中解释的那样,合约中声明的每个状态变量都在存储中占据一个槽,如果它们的类型小于32字节,并且可以一起放入一个槽中,则可能与其他状态变量共享一个公共槽。

所以,当我们访问这些状态变量,给它们赋值或从它们那里读取时,Solidity 用该状态变量的声明位置来知道访问哪个存储槽并从它那里读取或更新它。

例如,给定以下合约:

contract EntryPointContract {
  address public owner = msg.sender;
  uint256 public id = 5;
  uint256 public updatedAt = block.timestamp;
}

我们看到它声明了3个状态变量,owner,id和updatedAt。这些状态变量有赋值,在存储中,它们看起来像这样:

我们看到,在索引0 存储槽处,我们有第一个状态变量的值使用零填充,因为每个槽可以容纳32个字节的数据。
第二个槽,索引为1,保存了 "id"状态变量的值。
第三个槽,索引为2,有第三个状态变量updatedAt的值。所有存储的数据都以十六进制表示,所以转换 0x62fc3adb到十进制是1660697307,用js转换为日期:

const date = new Date(1660697307 * 1000);
console.log(date)

结果:

Tue Aug 16 2022 20:48:27 GMT-0400 (Atlantic Standard Time))

所以,在访问状态变量id时,我们是在访问索引为1的槽。
很好,那么,使用delegatecall的陷阱在哪里?
为了让委托合约对主合约的存储进行修改,它同样需要声明自己的变量,其顺序与主合约的声明顺序完全相同,而且通常有相同数量的状态变量。
例如,上面的 EntryPointContract 的委托合约,需要看起来是这样的:

contract DelegateContract {
  address public owner;
  uint256 public id;
  uint256 public updatedAt;
}

有完全相同的状态变量,完全相同的类型,完全相同的顺序,最好有完全相同数量的状态变量。在此案例中,每个合约有3个状态变量。
让我们展示一下这两个合约:

contract DelegateContract {
  address public owner;
  uint256 public id;
  uint256 public updatedAt;

  function setValues(uint256 _newId) public {
    id = _newId;
  }

}

contract EntryPointContract {
  address public owner = msg.sender;
  uint256 public id = 5;
  uint256 public updatedAt = block.timestamp;
  address delegateContract;

  constructor(address _delegateContract) {
    delegateContract = _delegateContract;
  }

  function delegate(uint256 _newId) public returns(bool) {
    (bool success, ) =
    delegateContract.delegatecall(abi.encodeWithSignature("setValues(uint256)",
      _newId));
    return success;
  }

}

这里我们看到了一个真正简单的代理合约的实现。EntryPointContract有一个构造函数,接收部署的DelegateContract的地址来委托它的调用,以便自己的状态被DelegateContract修改。

该delegate函数收到一个要设置的_newId,所以它使用低级别的delegatecall将该调用委托给DelegateContract 来更新id变量。

在用新的id值调用delegate函数,并检查EntryPointContract和DelegateContract合约的变量id值后,我们看到只有EntryPointContract的状态变量id有值,而DelegateContract的id状态变量没有赋值,仍然被设置为0,因为DelegateContract修改的不是它自己的存储,而是EntryPointContract的存储。

很好!

在第7行,我们看到id = _newId,但是,虽然听起来很奇怪,它并没有修改EntryPointContract的id变量,却实际上修改了EntryPointContract的存储槽, 我们知道EntryPointContract中的id变量被声明在索引为1的槽中,如上图所示。

这可能会引起混淆,因为我们实际上看到代码正在给DelegateContract中的id变量赋值,你可能认为不管这个变量在EntryPointContract或DelegateContract中的位置在哪里,它仍然会修改EntryPointContract中的id状态变量槽。但是不是这样的。

例如,在下面的合约中,我在DelegateContract中声明了id状态变量的第三个位置,这意味着现在它指向索引为2的槽,而不管EntryPointContract中的id 状态变量名。

contract DelegateContract {
  address public owner;
  // 注意:两个变量换了位置
  uint256 public updatedAt;
  uint256 public id;

  function setValues(uint256 _newId) public {
    id = _newId;
  }

}

contract EntryPointContract {
  address public owner = msg.sender;
  uint256 public id = 5;
  uint256 public updatedAt = block.timestamp;
  address delegateContract;

  constructor(address _delegateContract) {
    delegateContract = _delegateContract;
  }

  function delegate(uint256 _newId) public returns(bool) {
    (bool success, ) =
    delegateContract.delegatecall(abi.encodeWithSignature("setValues(uint256)",
      _newId));
    return success;
  }

}

现在 ,如果我用一个新的id值15再次调用delegate,会发生什么?
让我们看看...
DelegateContract被部署在:0x2eD309e2aBC21e6036584dD748d051c0a6E03709
我们可以用Remix来分析它:

EntryPointContract被部署在: 0x172443F1D272BB9f6d03C35Ecf42A96041FabB09
我们可以用Remix检查它的值:

很好!

现在让我们 用参数 15调用delegate,看看会发生什么。
检查一下DelegateContract的状态变量值:

没有变化,正如预期的那样,因为它不应该改变自己的状态,因为它被委托了EntryPointContract的状态。

让我们检查一下EntryPointContract的状态变量值(记住,我们希望id现在是15,其他都保持不变)。

哦哦! EntryPointContract的id仍然是5,实际受到影响的状态变量是updatedAt。为什么?

正如我在上面解释的,DelegateContract实际上不是通过名字来修改状态变量,而是通过它们在存储中的声明位置。

我们知道,id状态变量在EntryPointContract中被声明在第二位,这意味着它将在存储中占据索引为1的槽。updatedAt在EntryPointContract中被声明为第三位,因此占据了索引为2的存储槽。但是我们看到,DelegateContract将id变量声明为第三位,而将updatedAt声明为第二位。所以,当DelegateContract试图修改id时,它实际上是在修改EntryPointContract存储槽的索引2,也就是updatedAt状态变量在EntryPointContract中的位置。这就是为什么我们看到updatedAt是被更新的,而不是id。

让我们来详细说明一下:

EntryPointContract存储显示了声明的状态变量的顺序和它们的值。

EntryPointContract存储“发送到”(委托的)DelegateContract,按照DelegateContract中声明的顺序显示状态变量,但按照EntryPointContract状态变量的声明顺序显示数值:

所以,我们清楚地看到,在DelegateContract中,id变量实际上是指向EntryPointContract存储中的updatedAt值,而DelegateContract的updatedAt值实际上是指向id变量在EntryPointContract存储中有其值的槽。

所以,这就是为什么我们在委托调用另一个合约时需要非常小心的原因,因为拥有相同的变量类型和名称并不能确保调用合约中的这些变量会被使用。它们需要在两个合约中以相同的顺序声明。

另一个有趣的事实是,委托合约可以比主合约有更多的状态变量,有效地将值添加到主存储区,但它不能直接访问,因为主合约没有一个变量指向该存储区。

让我们看看这些合约,以便更清楚理解:

 contract DelegateContract {
   address public owner;
   uint256 public id;
   uint256 public updatedAt;
   address public addressPlaceholder;
   uint256 public unreachableValueByTheMainContract;

   function setValues(uint256 _newId) public {
     id = _newId;
     unreachableValueByTheMainContract = 8;
   }
 }

 contract EntryPointContract {
   address public owner = msg.sender;
   uint256 public id = 5;
   uint256 public updatedAt = block.timestamp;
   address public delegateContract;

   constructor(address _delegateContract) {
     delegateContract = _delegateContract;
   }

   function delegate(uint256 _newId) public returns(bool) {
     (bool success, ) =
     delegateContract.delegatecall(abi.encodeWithSignature("setValues(uint256)",
       _newId));
     return success;
   }

 }

我们看到,EntryPointContract仍然声明了4个状态变量,而DelegateContract声明了5个。我们知道,当EntryPointContract委托调用DelegateContract时,它将把自己的存储发送到DelegateContract.,但是EntryPointContract没有第五个状态变量(unreachableValueByTheMainContract)。那么,当DelegateContract修改它声明的但EntryPointContract没有声明的第五个变量时会发生什么?

嗯,它实际上会修改EntryPointContract存储的槽索引4(第五个位置)。EntryPointContract将不能直接访问它,因为该槽没有对应声明的状态变量,但该值将在那里,我们可以用web3.eth.getStorageAt(entryPointContractAddress, 4)这样的方法来访问它。

EntryPointContract被部署在0xA80a6609e0cA08ed3D531FA1B8bbCC945b8ff409,我们看到它的值:

现在让我们调用delegate,其值为18:

棒极了! 但是设置为unreachableValueByTheMainContract的值8在哪里呢?让我们看看它是否在 DelegateContract 状态下。

可以看到,它没有值。因为DelegateContract没有修改自己的状态,即使状态变量没有在EntryPointContract中声明。但由于unreachableValueByMainContract状态变量被声明在第五个位置(存储槽索引4),那么它无论如何都会影响EntryPointContract索引4的存储槽。我们可以直接检查它的值:

web3.eth.getStorageAt("0xA80a6609e0cA08ed3D531FA1B8bbCC945b8ff409", 4)

返回:

0x0000000000000000000000000000000000000000000000000000000000000008

是的! 说明EntryPointContract 确实保存了这个数据。

这是一种有趣的方式,即智能合约可以在部署后被 "扩展",只需在第一时间将其行动委托给另一个合约。这需要精心制作和设计。委托合约的地址需要能够在需要时被动态替换,这样入口点合约就可以在任何时候指向一个新的实现。

有一些方法可以解决这个问题,其中之一就是EIP-1967: Standard Proxy Storage Slots

结论

delegatecall是一个强大但棘手的功能,如果使用得当,我们可以创建 可扩展 的智能合约,帮助我们修复漏洞,并为现有的智能合约增加新的功能,使其动态地将其行动委托给另一个合约并由其修改自己的状态。

我们需要牢记代理合约和执行合约中的状态变量的顺序,以避免对存储数据进行非预期的修改。

转载自:https://learnblockchain.cn/article/5372

各智能合约编程语言的权衡

各种智能合约语言有自己的设计哲学,他们并非一样,这篇文章探索一下 Solidity、Cairo、Rust和Move的语言设计的权衡。
我相信一些智能合约新手甚至精通web2的开发者可能会好奇,如何选择第一门智能合约编程语言来学习。现在,有很多选择,如EVM的Solidity,Starknet的Cairo,Solona的Rust,Aptos & Sui的Move,等等。

事实是,没有绝对正确或错误的答案。每种语言都有取舍。真正的问题是 "当我们在使用一种智能合约编程语言开发时,是否意识到了他们的差异?"

就个人而言,我使用这个图表来可视化编程语言的基本功能和它们的局限性。

语言的权衡:实用性与安全性

事实上,这种权衡不仅是特定于智能合约领域。它适用于任何语言。

安全性

安全性有以下两个特点:

  • Progress(进行):一个类型良好的程序可以被评估或求值
  • Preservation(维持):维护步骤执行时的类型

因此,一旦类型被检查过,它们就可以被忽略,并且计算的运行不会出现错误。

一个安全的编程语言的例子是 "Haskell"。它是一种通用的和纯函数编程语言,具有静态的和强类型系统。它可以被认为是一种具有最少语法垃圾的数学语言。它的纯洁性提供了确定性、不可逆性和可控的副作用。因此,用Haskell编写的程序是可读的、合理的、可维护的、模块化的和可测试的。所有这些都直接或间接地与安全属性相联系。

因此,在对域模式l或域类型进行原型设计时,Haskell是一个很好的解决方案,因为它有复杂的类型推理引擎。它允许我们进行低成本的测试驱动开发。这有助于我们在投入大量精力在另一种语言的生产中进行真正的实现之前获得快速迭代和反馈。

实用性(有效性)

为了更清楚,你可能想知道为什么Haskell只是学术和研究工作中使用,而不是普遍用于生产中?它的函数式风格使它在某些工作中很有用,比如编译器。然而,在处理其他一些需要性能或效率的工业问题时,如网络服务器,它是没有意义的,因为这些应用需要状态操作或副作用,在这些方面,命令式编程范式语言做得非常好。换句话说,有两种计算模型(函数式编程的Lambda 演算和命令式编程的图灵机)。

重要的是,我提到,"实用"在这里意味着"有效"。特别是,它对世界有影响,它改变了某些状态。我绝对不是说一种具有使用价值的语言比其他语言优越。

然而,世界正在向函数式范式迈进。我们可以看到一些现代编程语言正在实现函数式编程的特点和设计。例如,functors、monoids、monads等等。

因此,多范式编程语言(在面向对象(OOP)与函数式风格之间混合)越来越受欢迎。具体来说,它并不严格要求命令式、面向对象(OOP)或函数式编程。例如,在Rust语言中,程序员可以在一个简单的boiler-page-free的宏或一个强大的(但程序化的)宏之间进行选择。另一个例子是Scala语言。它仍然是一种函数式编程语言,但没有Haskell那么纯粹。尽管如此,它也支持OOP,而且它的实用性足以解决现实世界的工业问题。

那么,用于工业生产的实用的语言有哪些?这方面的例子包括: Java, Ruby, Python, Javascript, Typescript, Rust, Scala 等。

这可以用下面的图来表示:

这个图表是从Simon Peyton Jones(Haskell语言的贡献者)那里借用和修改的。

有人提出了一个有趣的观点:"实用" 是主观的。例如,许多有经验的开发者可能会说,Typescript比Javascript更实用。在此案例中,我指出,用Typescript编写的程序需要更多的时间来定义类型,由于之前没有开发第三方库。因此,Javascript可能更实用。

重申,这个图表并不意味着某种语言比其他语言更好。实际上,它们是无法判断的。我并不是说 Java 在实践中比 Rust 更实用。它只是一个比喻,用来比较函数式和命令式范式。

转到智能合约编程语言背景...
根据该图,实用性,在这里与区块链的独特功能更相关,如无信任性和去中心化。准确地说,智能合约程序的编写应该是为了做传统程序不能解决的问题。

不同的语言有不同的特点。这意味着这些语言也有不同程度的安全性权衡,这取决于每种语言。

Solidity

Solidity 是第一个主流的图灵完备的编程语言,使以太坊虚拟机(EVM)进行可组合的计算。另外,Solidity是经过编译的高级语言。

例如,"可组合性 ",使任何人都能将现有部署的智能合约组件结合起来。这使得我们可以通过复制、修改和整合这些可互操作的乐高积木来创新出新的复杂系统。此外,智能合约应用程序通常是开源的,这意味着开发人员可以免费访问其源代码。

而对于传统的软件,开发者有时则需要从头开始开发一些基本的组件,这就需要花费金钱和时间。
值得注意的是,用Solidity编写的程序允许:

  • 能够被编译成透明的字节码(没有隐藏的网络API)和ABI(应用二进制接口)(标准化的API结构 )
  • 无停机,确保高可用性
  • 在EVM中基本的状态机功能(状态、访问、更新等)
  • 不同智能合约之间的可兼容接口
  • 内置Gas和支付系统(不依赖第三支付方)
  • 通过Solidity Assembly(汇编)的低级别性能访问

然而,可组合计算的主要权衡是安全。在某种程度上,可组合性,意味着外部行为者可以部署恶意的智能合约,同时隐藏他们的身份。同样,这些行为者不仅可以访问智能合约应用程序的代码库,而且还可以无限制地进行压力测试。

与传统软件相比,当软件是闭源的时候,这种攻击者不可能经常观察到代码库。此外,如果有一个bug,维护者可以在发现bug阶段保持离线。

因此,Solidity可以定义为高实用性和低安全性。

Cairo

Cairo是第一个图灵完备的编程语言,使Layer2能够进行可证明计算。

可证明性意味着可以在Layer2(证明者(Prover)、Starknet OS、序列器(Sequencer))上生成一个证明,在Layer1完整节点上(通过EVM)验证Cairo程序的输出已经被正确计算。从而实现可扩展性,因为生成证明的运行时间几乎是线性的,而验证则是计算次数的对数。因此,链上计算的成本不会随着计算量的增加而发生很大变化。简单地说,计算发生在第2层而不是第1层,而第1层的全节点使用较少的计算资源来验证生成的证明。这是因为在验证过程中没有重新执行的计算。

证明者(Prover)是一个独立的进程,它接收Cairo程序的输出,然后生成STARK证明,进行验证。证明者将STARK证明提交给验证者,验证者在Layer1上注册事实。

StarkNet OS是基于 Cairo 的操作系统,本质上是 Cairo 程序 输出被证明和验证的程序。它根据交易作为输入的来更新系统的 L2状态。

序列器(或块提议者)- Sequencer:这个特殊的节点用相关的输入执行StarkNet OS Cairo 程序,用证明器证明结果,并最终在StarkNet核心合约上更新网络状态

另一方面,EVM中的可组合计算与可证明计算的范式不同。事实上,solidity开发者使用OOP和命令式编程作为主要范式。而Cairo的开发者则抽象出计算的输出是可以接受的。你可能认为这种范式是函数式编程,但它是非确定性的。

特别是,用Cairo编写的程序适用以下范式:

  • 能够创建STARK-provable程序,用于一般计算。
  • felt,一个低级的整数数据类型。在数学上,它使用模块化算术,其中modulo是一个素数。在进行可证明计算时,它比uint256更有效率。
  • 支持在只读的非确定性存储器上运行

然而,可证明计算的权衡与可组合计算是一样的。原因是,Cairo是Solidity等价的。这意味着存在一个编译器,这样用Solidity编写的智能合约程序可以被编译成用Cairo编写的智能程序。因此,Cairo生态系统继承了许多以太坊标准(以太坊改进提案:EIPs)。在某种程度上,如果这些开发者之前在EVM生态系统中开发过,那么新的开发者很容易开始开发。不过注意,安全风险也是可以继承的,但 Cairo 开发者需要更认真地考虑这个问题。

除了在solidity中发现的常见漏洞(如重入、奇偶性等),Cairo引入了新的攻击载体。例如,Cairo语言只有一个数据类型,即Finite Field(又称felt)。因此,需要使用库来处理其他数据类型,这有可能导致安全风险。另一个有趣的错误是Under-constrained bug(非约束性错误)。在Cairo中,程序描述了理想的结果,而Hint是证明者如何产生这样的结果的抽象。

一个hint是一个块的代码(不一定是 Cairo 代码),它将在下一条指令之前被证明者执行

问题发生在有一个不恰当的断言时,所以在Cairo代码中的约束性编码允许一个恶意的证明者产生非预期的结果。这是因为那些证明者不一定使用开发者的hint !。

综上所述,Cairo的安全风险远大于Solidity,它可以用以下方式表示:

Rust

Rust是一种编译的、系统级的、多范式的编程语言(并发式、函数式和命令式)。

具体来说,Rust 实现了以下功能:

  • 函数式编程:闭包、匿名函数、迭代器。
  • Traits( 在其他语言中,对应是接口)
  • 生命周期(处理引用)
  • 零成本抽象
  • 通过宏系统进行元编程、异步编程(async/await)

实际上,Rust开发者可以有效地管理内存,并利用并行处理的高性能。一个很好的例子是Solana,它有助于实现高吞吐量的高扩展性。

更重要的是,众所周知,许多加密算法和基于零知识的应用都是使用Rust实现的。

尽管如此,区块链的实用性(如无信任和去中心化)被权衡了,因为它是一种通用的编程语言,不限于智能合约背景。换句话说,Rust比上面提到的其他智能合约语言更安全。

事实上,Rust的主要特点是安全。Rust可以提供强大的安全保障,这要归功于借用(borrowing)检查功能。一旦代码在充分测试的情况下被编译,程序就会在安全保证下运行。例如,Rust在字符串处理方面做得很好,因为它很容易写出不能溢出的代码。

值得注意的是,尽管与代码意图无关的技术实现(如溢出)被最小化了,但开发者仍然需要照顾到领域业务或设计层面的潜在缺陷。

因此,Rust在这个位置:

Move

Move是一种解释型的,和OOP的编程语言。它特意为基于区块链的应用而设计。

具体来说,Rust强调了以下范式:

  • 一流的资源管理(资产asset被抽象为一个特定的资源类型系统)。
  • 可验证性(链外静态验证工具)

另外,Move是基于Rust的。由于高效的内存管理和并行处理,因此它允许高吞吐量。尽管如此,它是解释语言而不是编译语言。一般来说,性能可能会比较慢。

关于它的安全性,来自编译器的错误被消除了,因为Move是一种解释语言。此外,由于安全的资源类型系统,一些众所周知的智能合约漏洞(如重入)被跟除。

尽管如此,开发人员在对Move智能合约进行编程时,还是需要谨慎对待。由于它是一种非常新的语言,社区规模相对较小,安全实践还不成熟,而且有很多未知的逻辑错误,让开发者跳入兔子洞。

所以,Move可以被认为是介于Solidity和Rust之间,具体位置如下:

最后的思考

如前所述,在这些编程语言中,实用性与安全性之间的权衡是相当不同的。尽管这种权衡对于传统的和智能合约编程语言来说都是真实的,但这些智能合约语言和相关框架的生命周期都比传统的短得多。此外,每天都有越来越多的新区块链语言出现,一个人在短时间内成为所有语言的专家是不可能的。因此,选择正确的可持续发展的语言是相当重要的,它将会持续很长时间。我希望在深入研究任何语言之前,所提供的图表可以作为一个指导原则。

参考资料

ethereum.org. smart contract composability
starknet.io. Hints
ctrlc03.github.io. Cairo and StarkNet Security
Peteris Erins. 可证明与可组合计算或为什么Cairo将取代Solidity
Yield App Labs. Solidity vs Move vs Rust: 智能合约编程语言的演变
Gwyneth Iredale. Move编程语言概述

转载自:https://learnblockchain.cn/article/5365

使用默克尔(Merkle)树实现NFT白名单

简介

在我们今天所知道和喜爱的区块链出现之前,默克尔树一直是密码学和计算机科学领域的一个方面。如今,我们开始慢慢看到它们在链上更频繁地被用于数据验证的目的。在这篇文章中,我将解释Merkle Trees如何在NFT(ERC-721)背景下实现代币白名单的目的,它们是如何提供保证只能由预定参与者认领代币。

什么是Merkle树?

默克尔树是一种树状结构,树上的每个节点都由一个值表示,这个值是一些加密哈希函数的结果。哈希函数是单向的,从一个输入产生一个输出很容易,但从一个输出确定一个输入在计算上是不可行的。默克尔树有3种类型的节点,如下所示:

叶子节点

叶子节点位于树的最底部,它们的值是原始数据根据指定的哈希函数进行哈希的结果。一棵树上有多少个叶子节点,就有多少个需要哈希的原始数据。例如,如果有7个数据需要被哈希,就会有7个叶子节点。

父节点

父节点可以位于树的不同层次,这取决于整个树的大小,父节点总是位于叶节点之上。父节点的值是由它下面的节点的哈希值决定的,通常从左到右开始。由于不同的输入总是会产生不同的哈希值,不考虑哈希值的碰撞,节点哈希值的连接顺序很重要。值得一提的是,根据树的大小,父节点可以Hash其他父节点。

根节点

根节点位于树的顶端,由位于它下面的两个父节点的哈希值连接而成,同样从左到右开始。任何默克尔树上都只有一个根节点,根节点拥有根哈希值。

我知道这是一个需要消化的信息,所以请参考下面的图表(图1),以便更好地了解这些树的结构。

上下文背景

如前所述,在NFT(ERC-721)的背景下使用Merkle树,如果为选定的参与者群体保留一定数量的代币,这其实就是一个白名单。Merkle树必须是预先计算的,在这种情况下,可以让一个叶子节点代表我们白名单中的一个钱包地址。

让我们想象一下,你的项目已经确定了一个白名单策略,为选定的钱包地址保留了任意数量的代币,这些地址可能是通过竞争、抽奖或其他系统的方式选择。这些白名单上的地址可以在某个时间点(通常在公共铸币之前)能申领获得为他们保留的代币。当然,还可以出于各种其他的原因,这些可能涉及到避免高额的Gas费,奖励创造力,鼓励早期参与,社区参与等等。

由于这些地址是已知的,而且是不变的,我们可以使用这些信息来创建一个Merkle树。我们可以使用merkletreejs和keccak256 JavaScript库来进行实现。

注:为了简单起见,将只使用7个钱包地址,以保持树的大小精简。

JavaScript实现

要做的第一件事是衍生出我们的叶子节点。如果你还记得,在一棵树上位于叶子节点正上方的每个父节点,最多只能Hash两个叶子节点。如果叶子节点的数量不均匀,父节点将处理一个叶子节点。每个叶子节点应该是某种形式的Hash数据,所以在这个例子中,让我们使用keccak256库来哈希白名单上的所有地址(图2)。我们使用这种特定的哈希算法,因为它将在以后的Solidity智能合约中使用。


图2. 衍生出叶子节点和默克尔树对象

对白名单上的所有地址进行了哈希,从而获得了我们的叶子节点,现在就可以创建Merkle树对象。我们使用merkletreejs库,通过调用new MerkleTree()函数,将叶子节点作为第一个参数,哈希算法作为第二个参数,{ sortPairs: true }选项作为最后一个参数。最后一个参数是可选的,但我在试图在这个例子不使用它时遇到了很大困难。

图3. Merkle树的可视化和根哈希。

现在已经得出了一个完整的Merkle树,可以通过调用Merkle树对象的getRoot()方法(图3)来获得根哈希值。记住,Merkle树的根哈希值是树上根节点正下方的两个前面的父节点的哈希值。在本例中,0xf352...和0x3cc0...。使用toString()方法在控制台打印Merkle树,为我们提供了一个很好的可视化的树的结构。

Merkle树的巧妙之处在于,它不需要任何关于原始数据块的知识来验证一个节点是否属于我们的树。如果我们试图验证一个叶子节点属于我们的树,只需要知道直接相邻的叶子节点哈希值(如果有的话),以及叶子节点正上方相邻的父节点哈希值就可以了。对于这个工作原理的简短解释,我建议查看Tara Vancil的这个视频。这个信息被称为proof,将被Solidity智能合约使用,以验证调用者是否属于白名单。

Web实现

现在我们有了Merkle树对象和它的根哈希值,我们准备开始考虑如何让白名单用户申领他们的代币时向智能合约提供Merkle证明。我们需要做的是在项目网站上实现一些JavaScript,在铸币页面上请求外部API。这个API将接收连接的钱包地址,因为它是我们最初用来生成叶子节点的,并返回指定的证明。

在服务器端,你会收到地址,使用keccak256进行哈希,并使用Merkle Tree对象上的getHexProof()方法检索证明。下图(图4)显示了你可能从这个API调用中返回的例子。

图4. 对应地址的Merkle证明。编辑:0x7b地址可以忽略,这是我的一个打印错误。

前端在收到这个证明之后,并将其作为参数与参与者的交易一起发送到合约,我们现在可以开始研究如何在智能合约中验证它。

智能合约的实现

注:本文展示的智能合约例子是用最小的代码量构建的,以展示一个概念证明。它绝不是一个你应该如何编写铸币功能的例子。

为了验证所提供的证明,需要做的第一件事是导入OpenZeppelin MerkleProof.sol contract(第6行,图5),这将使我们能够在智能合约代码中使用MerkleProof.verify()函数。接下来需要做的是定义根Merkle哈希值。如果智能合约在白名单确定之前已经被部署到以太坊主网上,那么可以假设有一些setter函数可以用来在以后的时间点更新这个值。在这个例子中,我对根Merkle哈希值进行了硬编码,以便在部署时被设置(第12行,图5)。

图5. 智能合约代码

接下来,我们需要验证该证明。 证明是一个bytes32类型的值数组。技术上来说,它们是string类型的,但无论如何,Solidity都会正确解释。先生成的目标叶子节点(第25行,图5),如果你记得,这是一个白名单地址的keccak256哈希。在这个例子中,通过哈希msg.sender的值来生成目标叶节点。记住,这个值是不可改变的,不能被恶意改变。

由于只有白名单地址被用来生成我们的叶子节点,我们可以假设,如果一个非白名单地址试图使用有效或无效的证明来调用这个函数,生成的目标叶子节点将根本不存在于我们的Merkle树上,验证将失败。这个实现的最后一步只是调用MerkleProof.verify()函数,将提供的证明作为第一个参数,将根Merkle哈希作为第二个参数,将目标叶节点作为最后一个参数。如果这个函数返回 "false",require语句将失败,交易将被简单地回退,否则,该函数将继续执行,执行铸造代币逻辑。

临别赠言

我们已经学会了如何使用默克尔树实现白名单,这是一个相对简单明了的方法,展示了在NFT项目中使用白名单生成默克尔树,实现只有白名单中的指定地址才能申领代币。我知道还有其他的解决方案,但在我研究过的方案中,我认为迄今为止最吸引人的方案。

本翻译由 Duet Protocol 赞助支持。
转载自:https://learnblockchain.cn/article/4521

使用Golang实现Merkle算法

Merkle树是区块链技术的基本组成部分。它是由不同数据块的散列组成的数学数据结构,用作块中所有交易的摘要。

它还允许对大量数据中的内容进行有效和安全的验证。此结构有助于验证数据的一致性和内容。比特币和以太坊都使用Merkle树结构。Merkle树也被称为哈希树。

从根本上说,Merkle树是数据结构树,其中每个叶节点都用数据块的哈希标记,非叶节点用加密标记 其子节点标签的哈希值。叶节点是树中的最低节点。

原理

区块链中每个区块都会有一个 Merkle 树,它从叶子节点(树的底部)开始,一个叶子节点就是一个交易哈希。叶子节点的数量必须是双数,但是并非每个块都包含了双数的交易。如果一个块里面的交易数为单数,那么就将最后一个叶子节点(也就是 Merkle 树的最后一个交易,不是区块的最后一笔交易)复制一份凑成双数。

从下往上,两两成对,连接两个节点哈希,将组合哈希作为新的哈希。新的哈希就成为新的树节点。重复该过程,直到仅有一个节点,也就是树根。根哈希然后就会当作是整个块交易的唯一标示,将它保存到区块头,然后用于工作量证明。

代码实现

package main

import (
    "bufio"
    "crypto/sha256"
    "fmt"
    "os"
    "strconv"
)

//默克尔树节点结构体
type Node struct {
    Index    int
    Value    string
    RootTree *MHTree
}

//默克尔树结构体
type MHTree struct {
    Length   int
    Nodes    []Node
    rootHash string
}

//获得默克尔树根节点哈希值
func (t *MHTree) GetRootHash() string {
    //不管是否存储,都重新计算哈希值
    t.rootHash =   t.Nodes[1].getNodeHash()                            
    return t.rootHash
}

//计算默克尔树中某个节点的哈希值
func (n *Node) getNodeHash() string {
    //叶子节点,则直接计算该节点Value的哈希值
    if n.Value != "" {
        return calDataHash(n.Value)
    }
    //非叶子节点,则递归计算哈希值,其为2个子节点哈希值的哈希值123123123123
    return calDataHash(n.RootTree.Nodes[n.Index*2].getNodeHash() + n.RootTree.Nodes[n.Index*2+1].getNodeHash()
        )                                                         

//计算数据的哈希值
func calDataHash(data string) string {
    hash := sha256.New()
    hash.Write([]byte(data))
    return string(hash.Sum(nil))
}

//从结构化文件创建默克尔树
func CreateMHTree(fileName string) MHTree {
    var tree MHTree
    //打开文件
    file, err := os.Open(fileName)
    if err != nil {
        panic(err)
    }
    defer file.Close()
    //获取读取器
    buf := bufio.NewReader(file)
    //读取首行,获得叶子节点数目(要求叶子节点数目为2的整数次幂
    dataCountStr, _, _ := buf.ReadLine()
    dataCount, _ := strconv.Atoi(string(dataCountStr))

    //判断幂次
    level := 0
    for i := 1; ; i++ {                                          
        if 2<<i == dataCount {
            level = i
            break
        }
    }
    //创建默克尔树

    //给非叶子节点赋值
    for i := 1; i <= 2<<level-1; i++ {                               
        tree.Nodes[i].Index = i
        tree.Nodes[i].RootTree = &tree
    }
    //读取文件数据,给叶子节点赋值
    for i := 2 << level; i < tree.Length; i++ {                  
        str, _, _ := buf.ReadLine()
        tree.Nodes[i].Index = i
        tree.Nodes[i].RootTree = &tree
        tree.Nodes[i].Value = string(str)
    }
    return tree
}

func main() {
    var fileName string

    fmt.Println("请输入原始数据文件名称")
    fmt.Scanln(&fileName)
    mhTree1 := CreateMHTree(fileName)
    fmt.Println("请输入比对数据文件名称")
    fmt.Scanln(&fileName)
    mhTree2 := CreateMHTree(fileName)

    hash1 := mhTree1.GetRootHash()
    hash2 := mhTree2.GetRootHash()

    if hash1 == hash2 {
        fmt.Println("用户没有改变数据")
    } else {
        fmt.Println("用户改变了数据")
    }

}

转载自:https://learnblockchain.cn/article/5364