您正在查看: Ethereum-优秀转载 分类下的文章

使用Foundry来探索可升级的合约

使用Foundry工具来探索实现可升级的合约,本文介绍了可升级的合约使用的 delegatecall 时遇到的变量冲撞的问题,以及应该如何应对。
这篇文章需要大家对 Solidity的基本知识, 有所了解。

让合约可升级提供了很大的灵活性,但也使代码更难推理。这主要是由于需要绕过Solidity的类型系统,这意味着编译器捕捉错误的能力受到严重限制。最近的Audius攻击就是一个很好的例子,说明在使用可升级合约时很容易犯错,并强调了真正理解抽象概念下发生的事情的重要性。

这篇文章是两部分系列中的第一部分。我们将看到可升级合约是如何实现的,特别是什么地方会出错。然后,第二部分将仔细研究Audius攻击,重现攻击者执行的每一个步骤。即使有了第一部分的知识,如果你不去寻找它,被攻击者利用的错误仍然很难发现。

在整个文章中,我们使用用 Solidity 编写的 Foundry 测试来说明用于使合约可升级的各种技术,并使实验和探索尽可能容易。所有的代码都可以在 此 repo 中找到。

有一点需要注意。这里介绍的代码都不应该在生产中使用,因为开发时没有考虑到这个目的。

序言

区块链的不可更改性与传统的软件开发过程有根本性的冲突,在传统的软件开发过程中,代码被不断地更新以修复错误和增加新功能。但是,即使一个特定的智能合约的代码仍然是不可改变的,仍然有一些技术可以达到与更新代码相同的效果。

这主要是通过两个功能的组合实现的。Solidity中的delegatecallEVM指令和回退函数fallback。

代码和存储

为了理解 "delegatecall",描绘智能合约执行过程中EVM的状态是很有帮助的。


https://takenobu-hs.github.io/downloads/ethereum_evm_illustrated.pdf

我们可以区分持久性和易失性的执行状态。持久性状态在不同的交易中都会被记住,而易失性状态在交易执行后会立即被遗忘。EVM代码(智能合约代码)和存储都是持久性的,而堆栈、内存、程序计数器和剩余Gas都是易失性。在这里,我们主要对代码和存储部分感兴趣,因为它们对于理解可升级合约来说是最重要的。

虽然代码和存储都是持久的,但两者之间有一个根本的区别。存储是可变的,可以被修改,而代码一旦部署就不可改变。为此,代码与存储在内存的只读部分分开保存。(这与典型的冯-诺依曼架构如x86不同,后者的代码和内存共享同一个地址空间)。这种分离使得delegatecall指令成为可能,它使用一个合约的代码和另一个合约的存储(见下一节)。

区分存储和内存是很重要的。存储器是持久的,它将32个字节的地址映射到32个字节的值,这些值被称为槽。另一方面,内存是不稳定的,它将32字节的地址映射为1字节1字节的值。换句话说,存储是按字处理(一个字是32字节),而内存是按字节处理的。

在 Solidity 中,任何在合约层声明的变量都被映射到一个或多个存储槽。例如,考虑下面的合约:

contract Token {
  address immutable owner;
  uint256 public totalSupply;
  mapping(address => uint256) public balanceOf;

  constructor() {
    owner = msg.sender;
  }

  function mint(address user, uint256 amount) external {
    require(msg.sender == owner, "Only owner is allowed to mint");
    balanceOf[user] += amount;
  }
}

第一个变量被映射到槽0,第二个被映射到槽1 [注1]。从原理上讲,我们可以如下图所示表示这个合约。

注1:若占用少于32字节的变量可以存储在同一个槽中。例如,一个槽可以包含两个类型为uint128的变量。
(注意Token::owner是不可变的(immutable),因此不占用任何存储槽)。虽然像 address 和 uint256 这样的简单类型的值最多需要32个字节,因此可以放入一个存储槽,但对于映射和动态数组来说,情况并非如此。由于这个原因,即使balanceOf被映射到槽1,实际上没有任何东西被存储在这个槽里。相反,如果我们想访问balanceOf[addr],相应的槽被计算出来,如下所示:

keccak(
  leftPadTo32Bytes(addr) ++ leftPadTo32Bytes(1)
)

我们把key(这里是:addr)和映射的槽号(这里是:1),把它们都零扩展到32字节,把它们连接起来(用++表示),最后计算结果的keccak哈希值。下面的 Foundry 测试展示了如何用 Solidity 来表达:

Token t = new Token();
t.mint(Alice, 5 ether);

bytes32 aliceBalanceSlot = keccak256(
    abi.encodePacked(uint256(uint160(Alice)), uint256(1))
);

uint256 aliceBalance = uint256(vm.load(address(t), aliceBalanceSlot));

// Check that we have correctly computed the slot at which Alice’s balance is stored
assertEq(aliceBalance, t.balanceOf(Alice));

在这个例子中,我们想检索t.balanceOf(Alice)的值,但我们没有直接这样做,而是手动计算Alice的余额所在的槽。为此,aliceBalanceSlot是按照上面的描述计算的。然后我们使用 Foundry 提供的作弊代码 vm.load() 来加载合约 t中存储在计算槽上的值。最后,我们使用 assertEq() 来确保我们实际上已经加载了正确的值。参见Storage.t.sol以了解完整的例子。

对于像Token这样的简单合约,我们可以很容易地手动计算出合约变量的槽。然而,对于使用继承的更复杂的合约,或者有多个变量被存储在同一个槽中的合约,这项任务就变得更加困难了。幸运的是,Foundry 提供了一个命令来可视化合约的存储布局。例如,要显示 Token 合约的存储布局,你可以使用以下命令。

$ forge inspect Token storage-layout –-pretty

这适用于任何属于当前 Foundry 项目的合约。如果你想分析已经部署的合约,可以看看 sol2uml 工具。

delegatecall(委托调用)

如果代码是不可变的,那么怎么可能升级智能合约并改变其行为呢?这主要是由于delegatecall指令[注2],它使用一个合约的代码,并使用另一个合约的存储来执行它。这可以通过一个简单的例子来说明。

注2: 如果我们一般性地谈论改变合约的行为,那么这已经可以用selfdestruct来实现了,因为它删除了所有代码。虽然这是一种非常有限的(而且不是非常有用的)改变合约行为的形式,但当与create2结合时,就有了更多的可能性。然而,delegatecall仍然是实现可升级合约的主要方式。

contract Counter {
  uint256 number;

  function get() external view returns(uint256) {
    return number;
  }

  function add(uint256 n) external {
    require(n <= 5, "Max increment is 5");
    number += n;
  }
}

contract DelegateCounter {
  uint256 number;

  function get() external view returns(uint256) {
    return number;
  }

  function delegateAdd(Counter c, uint256 n) external {
    bytes memory callData = abi.encodeWithSignature("add(uint256)", n);
    (bool ok,) = address(c).delegatecall(callData);

    if(!ok) revert("Delegate call failed");
  }
}

Counter合约代表了一个每次最多只能增加五的计数器。为此,它定义了一个函数 add() 来执行这个动作,还定义了一个函数 get() 来获取当前的计数器值。除了函数delegateAdd()外,DelegateCounter合约与Counter基本相同。为了解释delegateAdd()是如何工作的,将这两个合约形象化是有帮助的。

直观地说,delegateAdd()使用delegatecall来执行来自合约Counter的函数add(),使用DelegateCounter的存储。为了使其发挥作用,两个合约应该有兼容的存储布局,也就是说,它们应该将相同的变量分配到相同的存储槽中。

delegatecall是Solidity中的一个低级原语,使用起来不如普通函数调用方便。一般来说,每当我们想在一个合约上调用一个函数时,我们需要同时指定我们想调用的函数和我们想传递的论据。这些信息需要以一种众所周知的格式进行编码,以便目标合约知道如何解释它。这种格式也被称为应用二进制接口(ABI),并在 合约ABI规范 中描述。对于正常的函数调用,Solidity为我们做了这个编码,但是当使用delegatecall时,我们需要自己做。这是在delegateAdd()的第一行完成的。

bytes memory callData = abi.encodeWithSignature("add(uint256)", n);

encodeWithSignature()的第一个参数表示我们要调用的函数的签名,其余参数表示我们要传递给该函数的值。在上面的例子中,我们对一个名为add的函数的调用进行了编码,该函数需要一个uint256类型的参数,其值应该是n。如果我们假设n是,例如,4,那么callData将看起来如下:

0x1003e2d20000000000000000000000000000000000000000000000000000000000000004

(你可以通过在delegateAdd()函数中添加console.logBytes(callData)来验证这一点。)

前四个字节代表 "函数选择器",它是通过获取函数签名的 keccak 哈希值中最重要的前四个字节计算出来的。这个函数签名是 "add(uint256)",我们可以使用 Foundry 自带的 cast 命令行工具来计算其 keccak 哈希值。

$ cast keccak "add(uint256)"
0x1003e2d21e48445eba32f76cea1db2f704e754da30edaf8608ddc0f67abca5d0

正如你所看到的,哈希值的四个字节与 "callData" 中最重要的四个字节相匹配。
函数选择器后面是参数("callData" 值部分),这只是表示为 "uint256 "的值4,即32字节的无符号数。
现在我们已经在callData中存储了编码的函数调用,我们可以把它传递给delegatecall。

(bool ok,) = address(c).delegatecall(callData);

这一行在当前合约的上下文中执行函数Counter.add()。特别是,任何由Counter.add()执行的存储访问都将使用调用合约的存储,在此案例中,它是DelegateCounter类型。因此,当Counter.add()函数写到槽0以更新存储变量number时,它更新的是DelegateCounter的存储,而不是Counter的。

delegatecall返回两个值。一个表示调用是否成功的布尔值,和一个包含任何返回数据的字节数组。由于Counter.add()不返回任何东西,delegateAdd()忽略返回数据,只检查调用是否成功。这一点特别重要,因为当使用delegatecall时,被调用的函数中的reverts不会自动传播, [注3]。
注3: 为了简单起见,我们用一个固定的消息来作为revert 消息,而不是传播原始错误。

if(!ok) revert("Delegate call failed");

为了使这一切更具体,这里有一个例子:

Counter c = new Counter();
DelegateCounter d = new DelegateCounter();

// Sanity check: both counters should start at zero
assert(c.get() == 0);
assert(d.get() == 0);

d.delegateAdd(c, 4);

// Check that `d` has been updated and that `c` remains unchanged
assert(c.get() == 0);
assert(d.get() == 4);

我们首先创建了 Counter 和 DelegateCounter 合约的新实例,并使自己相信它们都是从0开始的。然后是有趣的部分,即调用d.delegateAdd(c, 4)。如上所述,delegateAdd()本质上是调用c.add(4),其方式是所有存储访问都指向d而不是c。下面两个断言验证了这一点,它们检查了c仍然为零,而d已经被更新。

现在我们可以清楚地看到delegatecall是如何用来实现可升级的合约的,因为我们可以将任何合约传递给delegateAdd(),它实现了一个签名为add(uint256)的函数。因此,即使 DelegateCounter 保持不变,我们也可以通过向 delegateAdd() 传递一些其他合约来改变其行为。然而,为了完全实现可升级的合约,我们还需要关注一个特性,即回退函数。这将在回退函数一节中介绍。然而,在我们继续之前,看看如何处理delegatecall的第二个返回值,即包含从被调用函数返回的数据的字节数组,是很有用的。

处理返回值

正如我们已经注意到的,使用delegatecall比正常的函数调用要不方便,因为我们必须根据ABI对调用进行手动编码。从调用中返回的数据也是如此。我们只是得到一个原始的字节数组,我们需要根据被调用的函数的返回类型自己解码。为了说明如何做到这一点,我们现在为DelegateCounter实现一个delegateGet()函数。

contract DelegateCounter {
  // ...

  function delegateGet(Counter c) external returns(uint256) {
    bytes memory callData = abi.encodeWithSignature("get()");
    (bool ok, bytes memory retVal) = address(c).delegatecall(callData);

    if(!ok) revert("Delegate call failed");

    return abi.decode(retVal, (uint256));
  }
}

这个实现与delegateAdd()非常相似。我们首先对我们想要执行的调用进行ABI编码,然后使用delegatecall来进行调用。然而,这一次我们也处理了由调用返回的数据,我们将其存储在retVal中。因为get()返回一个uint256,ABI规定像uint256这样的固定宽度类型的值是通过简单的取其big-endian表示并将结果填充到32字节来编码的,返回的数据可以通过简单的将retVal 类型转换为uint256来解码。

return uint256(bytes32(retVal));

然而,对于复杂的类型,解码变得更加复杂。幸运的是,Solidity提供了函数abi.decode(),可以为我们执行解码。使用这个函数,我们可以将返回语句重写如下:

return abi.decode(retVal, (uint256));

函数abi.decode()需要两个参数。一个包含一些ABI编码值的字节数组,以及一个包含编码值类型的元组。

泛化

为了为以后做准备,我们可以对delegateGet()做最后的修改,以便对处理返回数据的方式进行概括。注意,当我们用abi.decode(retVal, (uint256))对返回数据进行解码时,我们对返回类型进行了硬编码。如果我们想在任意函数中使用delegatecall,那么我们也需要能够处理任意的返回数据。这在纯 Solidity 中是不可能的,所以我们需要转向汇编。特别是,我们需要替换:

return abi.decode(retVal, (uint256));

替换为:

assembly {
  let data := add(retVal, 32)
  let size := mload(retVal)
  return(data, size)
}


return(data,size)指令结束当前函数的执行,并返回由data和size给出的内存范围内的数据,其中data表示起始地址,size表示数据的字节大小(详见Yul规范)。在上面的例子中,data和size的计算方式可能不是很明显。要理解这一点,重要的是要知道数组是如何在内存中布局的。首先,请注意,当我们从汇编块中引用像retVal这样的内存变量时,我们实际上是指它的地址。因此,当我们在上面的汇编块中使用retVal时,我们指的是retVal所表示的字节数组在内存中的起始地址。其次,Solidity在内存中排列数组的方式如下 [注4]。首先是数组的长度, 存储为一个32字节的无符号数字, 然后是所有的数组元素. 因此,retVal的数组长度直接存储在retVal的地址(我们通过mload加载),为了得到数组元素的地址,我们需要给retVal增加一个32字节的偏移量。
注4: 这与数组在存储中的布局不同,见存储中状态变量的布局内存中的布局

有了上述汇编,我们可以简单地转发任何来自delegatecall的返回数据,而不需要知道编码后的值的类型。这使得我们可以调用任意的函数而不需要事先知道它们的返回类型。

要想玩转这段代码,请看DelegateCall.t

回退函数

回退函数是实现可升级合约时另一个有用的功能。它们允许开发者指定当一个不存在的函数被调用时应该发生什么。默认的行为是回退,但这可以被改变。

interface Ifc {
  function hello() external;
  function bye() external;
}
contract C {
  event Log(string msg);

  function hello() external {
    emit Log("hello");
  }

  fallback() external {
    emit Log("fallback");
  }
}

上面我们定义了一个带有函数hello()和bye()的简单接口。此外,我们定义了一个合约C,它包含一个函数hello()和一个fallback函数。现在考虑下面的例子:

Ifc ifc = Ifc(address(new C()));
ifc.hello(); // Emits Log("hello")
ifc.bye();   // Emits Log("fallback")

我们创建了一个新的合约C的实例,并将其转换为 Ifc 类型,这使得我们可以同时调用hello()和bye()。当我们调用Bye()时,由于C没有定义,所以会执行回退函数。

一个有用的事实是,我们可以使用msg.data来访问触发回退函数的原始调用数据。例如,如果在C的回退函数中加入console.logBytes(msg.data),那么在调用ifc.bye()时就会产生如下日志信息

0xe71b8b93

正如你所期望的,这只是bye()的函数选择器(因为bye()没有参数,所以没有编码的参数)。这意味着通过检查msg.data我们可以确定用户最初打算调用哪个函数。
完整的例子见Fallback.t.sol

可升级的合约

使用delegatecall和回退函数,我们可以实现一个基于代理 的可升级合约的一般解决方案。其核心思想如下。对于每一个我们希望其代码可以升级的合约,我们实际上部署了两个合约。一个代理合约和一个逻辑合约。代理合约是存储所有数据的合约,而逻辑合约则包含对这些数据进行操作的功能。用户将只与代理合约进行交互。当用户在代理上调用一个函数时,代理会使用一个委托调用将调用转发给逻辑合约。因为代理使用委托调用,执行逻辑合约的函数会影响代理的存储。因此,当使用可升级合约时,代理持有状态,而逻辑合约持有代码。从用户的角度来看,代理的行为与逻辑合约的行为是一样的。升级合约只是意味着代理使用了一个新的逻辑合约。

初次尝试 (不成功)

通过上面的解释,人们也许会被诱惑去实现代理合约,如下所示:

contract FaultyProxy {
  address public implementation;

  function upgradeTo(address newImpl) external {
    implementation = newImpl;
  }

  fallback() external payable {
    (bool ok, bytes memory returnData) = implementation.delegatecall(msg.data);

    if(!ok)
      revert("Calling logic contract failed");

    // Forward the return value
    assembly {
      let data := add(returnData, 32)
      let size := mload(returnData)
      return(data, size)
    }
  }
}

正如它的名字FaultyProxy所示,这个代理在一般情况下是不起作用的。然而,了解为什么它不能工作仍然是有意义的,特别是因为我们后面要看的Audius协议中的错误与上述代理中的错误非常相似。

代理有一个单一的存储变量,implementation,它存储了逻辑合约的地址。通过调用upgradeTo()可以改变逻辑合约,使逻辑(换句话说:代码)可以升级。(现在,任何人都可以调用upgradeTo(),这当然是不可取的。我们稍后会回到这个问题上)。拼图的最后一块是回退函数。它的目的是转发任何对使用delegatecall的逻辑合约的调用。(除了对upgradeTo()和implementation()的调用,这些调用是由代理本身处理的。) 但我们怎么知道用户想调用哪个函数呢?幸运的是,触发回退函数的原始calldata可以通过msg.data [注 5] 访问。由于calldata包含函数签名和参数值,我们可以简单地将msg.data传递给delegatecall。之后,我们检查调用是否成功。如果不成功,我们就还原,否则就转发返回数据。
注 5: 回退函数也可以使用不同的签名,其中calldata被直接作为参数传递。更多信息请参见 Solidity 文档 关于 fallback 函数 的说明。

下面的例子显示了代理应该如何使用:

// (1) 创建逻辑合约
Counter logic = new Counter();

// (2) Create proxy and tell it which logic contract to use
FaultyProxy proxy = new FaultyProxy();
proxy.upgradeTo(address(logic));

// (3) To be able to call functions from the logic contract, we need to
//     cast the proxy to the right type
Counter proxied = Counter(address(proxy));

// (4) Now we treat the proxy as if it were the logic contract
proxied.add(2);

// (5) Did it work? (Spoiler: no!)
console.log(“counter =”, proxied.get()); // Reverts!

前两步分别创建了逻辑和代理合约。第二步还调用了upgradeTo(),这样代理就知道要使用哪个逻辑合约。第三步需要告诉Solidity编译器,我们现在计划使用代理,就像它是逻辑合约一样。第四步是它变得有趣的地方。我们在代理上调用add()函数。由于代理没有定义任何该名称的函数,其回调函数被执行。在回调函数中,msg.data包含以下调用数据:

0x1003e2d20000000000000000000000000000000000000000000000000000000000000002

这代表了对一个签名为 "add(uint256)"、参数为2的函数的调用 。然后,回退函数用上述调用数据执行一个 "delegatecall",使用代理的存储空间执行 "Counter "合约中的 "add() "函数。

最后,在第五步中,我们试图从代理中获取当前的计数器值。然而,执行proxied.get()实际上是回退了! 这个错误的原因可以通过可视化代理和逻辑合约来轻松解释。

当比较两个合约的存储布局时,我们可以注意到它们在0槽中存储了不同的变量。这产生了一个不幸的后果。当Counter.add()使用FaultyProxy的存储空间执行时,它修改了存储槽0,以便更新number。然而,在合约FaultyProxy中,槽0包含implementation的值。因此,当我们在步骤(4)中调用proxied.add(2)时,我们实际上将存储在implementation中的地址增加了2,使得地址无效。更确切地说,现在产生的地址指向一个账户,而这个账户很可能没有被部署过合约。当对一个空账户进行委托时,调用将成功,但没有数据被返回。然而,由于我们确实希望返回一个uint256类型的值,所以测试被还原了。
参见FaultyProxy.t.sol中的代码。

可工作的版本 (但有错误)

我们怎样才能解决代理和逻辑合约之间的存储槽碰撞问题呢?一个简单的方法是在Counter合约中的number前添加一个虚拟存储变量。然后,number将被存储在槽1中,它将不再与FaultyProxy的implementation发生冲突。然而,这并不是一个好的解决方案。它很脆弱,很容易被遗忘,而且如果逻辑合约继承了其他合约,可能很难执行。

那么,代理合约怎样才能在不产生槽冲突的情况下存储逻辑合约的地址呢?有多种方法,见“设计选择”一节。在这里,我们将遵循非结构化存储模式,它被广泛使用(例如OpenZeppelin,也见使用非结构化存储的可升级性),它为这个问题提供了一个不需要对逻辑合约进行任何修改的解决方案。这个想法是让代理将逻辑合约地址存储在某个遥远的插槽中,这样一来,插槽碰撞的几率就可以忽略不计了。有了这个想法,我们可以实现一个新的代理:

// This proxy is working but still has flaws, so don’t use it for anything serious
contract Proxy {
  bytes32 constant IMPLEMENTATION_SLOT =
    bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1);

  function upgradeTo(address newImpl) external {
    bytes32 slot = IMPLEMENTATION_SLOT;
    assembly {
      sstore(slot, newImpl)
    }
  }

  function implementation() public view returns(address impl) {
    bytes32 slot = IMPLEMENTATION_SLOT;
    assembly {
      impl := sload(slot)
    }
  }

  fallback() external payable {
        (bool ok, bytes memory returnData) =
            implementation().delegatecall(msg.data);

        if(!ok)
            revert("Calling logic contract failed");

        // Forward the return value
        assembly {
            let data := add(returnData, 32)
            let size := mload(returnData)
            return(data, size)
        }
    }
}

Proxy和FaultyProxy之间的关键区别是,Proxy没有声明任何存储变量。相反,逻辑合约的地址被存储在槽IMPLEMENTATION_SLOT中,它被计算为 eip1967.proxy.implementation 字符串的keccak散列值减去1[注6] 。顾名思义,这个槽位号在EIP-1967中被标准化。有了一个定义明确的槽来存储逻辑合约,像Etherscan这样的服务可以自动检测合约是否具有代理功能,在此案例中,可以显示代理和逻辑合约的信息。例如,如果你在Etherscan上查看USDC的代码,除了正常的 读/写合约 标签外,还有 作为代理读/写 的选项,它提供了一个指向当前逻辑合约的链接。

注6: 为什么我们要从keccak的哈希值中减去一个?纯粹从功能的角度来看,这没有什么区别。使用不加-1的keccak哈希值也一样可以工作。然而,正如在EIP-1967中提到的,增加-1的偏移量是为了使预像攻击更加困难(见: https://github.com/ethereum/EIPs/pull/1967#issuecomment-489276813

为了升级合约,upgradeTo()函数需要修改IMPLEMENTATION_SLOT给出的槽位上的地址,这可以使用sstore指令。注意,我们需要将IMPLEMENTATION_SLOT复制到一个局部变量中,因为不可能直接从汇编中读取常数。函数implementation()以类似的方式实现,读取存储在槽IMPLEMENTATION_SLOT的地址。最后,回退函数保持不变,只是我们现在使用implementation()函数而不是存储变量来获取逻辑合约地址。

我们使用sstore/sload来访问逻辑合约,而不是使用合约变量,这使得这个代理非结构化,这也解释了非结构化存储模式的名字。我们可以再次直观地看到代理和逻辑合约。

(这里,IMPL_SLOT = IMPLEMENTATION_SLOT) 之前提到,当使用delegatecall时,必须确保调用者合约和被调用者合约都有兼容的存储布局,以防止被调用者弄乱调用者的存储。对于delegatecall部分的Counter和DelegateCounter合约,这很容易验证,因为这两个合约定义的存储变量完全相同。另一方面,Proxy与Counter的存储布局不相同,但由于两个合约使用完全不同的存储槽,因此不会"踩到对方的脚趾",这不是一个问题。(事实上,Proxy是完全独立于所使用的具体逻辑合约,因此这意味着人们只需要写一个可以被所有人使用的代理。)

当然,这只有在IMPLEMENTATION_SLOT表示的槽不会意外地与逻辑合约中的任何存储变量冲突时才安全。这实际上能保证吗?首先,请注意,IMPLEMENTATION_SLOT表示一个相当大的值。由于像 uint256 这样有固定大小的存储变量被分配到从零开始的槽号,现实中我们可以假设它们的槽号比IMPLEMENTATION_SLOT小得多。而且在任何情况下,由于这些槽是在编译时分配的,编译器可以检测到与IMPLEMENTATION_SLOT的碰撞并报告错误。

但是,对于动态大小的类型,如映射和动态数组,情况有点不同,其元素的存储槽是使用keccak hashes计算的(见Solidity文档中的映射和动态数组)。这样计算的槽实际上可能与IMPLEMENTATION_SLOT相冲突。然而,一般的共识是,这种情况发生的几率很小,所以这不被认为是一个问题。

最后,虽然上面的代理实现是可行的,但它仍然有基本的缺陷,因此不应该被用于任何严肃的生产环境,这些缺陷是

  1. 该代理容易受到函数选择器冲突的影响,这可能导致意外的行为(见”设计选择“ 一节)。
  2. upgradeTo()是无权限的,这意味着任何人都可以升级合约。由于升级合约可以极大地改变其行为,这是一个明显的安全问题,任何代理实现都必须解决。我们将在后续的文章中讨论与此直接相关的Audius攻击。

初始化

在迄今为止的例子中,我们只使用了Counter合约作为逻辑合约,它非常简单,甚至没有一个用户定义的构造函数。这让我们成功地忽略了使用代理时产生的一个重要限制。不能使用构造函数。原因是构造函数实际上不是函数,因此不能被delegatecall调用。解决的办法是使用一个单独的初始化函数。让我们修改Counter,这样我们可以用计数器的初始值来初始化它。

contract Counter {
  bool isInitialized;
  uint256 number;


  function initialize(uint256 start) external {
    require(!isInitialized, “Already initialized”);
    number = start;
    isInitialized = true;
  }

  function get() external view returns(uint256) {
    return number;
  }


  function add(uint256 n) external {
    require(n <= 5, "Max increment is 5");
    number += n;
  }
}

我们做了两个改动。我们添加了isInitialized存储变量和initialize()函数。与构造函数相比,initialize()函数只是一个普通的函数,可以被调用任意次数。由于安全敏感的参数经常在初始化过程中被设置,所以防止重新初始化是很重要的,我们在这里借助isInitialized来做到这一点。虽然这在这个简单的例子中是可行的,但对于生产来说,建议使用像OpenZeppelin的Initializable这样的东西,它可以正确处理继承,并支持在升级后重新初始化。

最后一个例子

我们已经谈了很多关于可升级的合约,但到目前为止,我们还没有升级任何东西。让我们创建一个 "CounterV2",它与 "Counter" 类似,但将增量限制从5增加到10。

contract CounterV2 {
  // ...


  function add(uint256 n) external {
    require(n <= 10, "Max increment is 10"); // Increase max increment to 10
    number += n;
  }
}

下面的例子说明了部署和升级一个合约的整个过程:

// (1) Create logic contract
Counter logic = new Counter();

// (2) Create proxy and tell it which logic contract to use
Proxy proxy = new Proxy();
proxy.upgradeTo(address(logic));

// (3) To be able to call functions from the logic contract, we need to
//     cast the proxy to the right type
Counter proxied = Counter(address(proxy));
proxied.initialize(23);

// (4) Now we treat the proxy as if it were the logic contract
proxied.add(2); // Works as expected
// proxied.add(7); Would fail (as expected)

// (5) Upgrade to a new logic contract
CounterV2 logicV2 = new CounterV2();
proxy.upgradeTo(address(logicV2));

// (6) Now adding a value larger than 5 actually works!
proxied.add(7); // Works as expected

注意,在我们的代理实现中,初始化是一个多步骤的过程。在步骤(2)中,我们创建一个新的代理并分配逻辑合约,在步骤(3)中我们调用初始化函数。相比之下,OpenZeppelin的实现可以在一个步骤中完成所有这些工作(见ERC1967Proxy.constructor()),这可以防止前面的攻击,而且更节省Gas。

每一步之后,代理的存储看起来如下(只显示已经被写入的槽):

当代理在第2步创建时,它还没有存储任何来自逻辑合约的状态。这只发生在第3步之后,当逻辑合约被初始化,将槽0(isInitialized)设置为true,槽1(counter)设置为23。

完整的例子见Proxy.t.sol

设计选择

在实现可升级合约的代理时,有两个基本问题需要回答。

  1. 如何防止代理和逻辑合约之间的存储槽碰撞?
  2. 如何处理代理和逻辑合约之间的函数选择器冲突?

在这篇文章中,我们只看了第一个问题,我们的答案是使用非结构化存储模式。然而,还有其他的方法,比如Inherited 存储或 Eternal存储(参见代理模式,了解相关概况)。

关于第二个问题。正如我们所看到的,函数在内部是由函数选择器来识别的,这些选择器有四个字节长,来自函数签名的keccak散列。这使得不同签名的函数有可能映射到同一个函数选择器上,从而导致选择器冲突。

例如,签名proxyOwner()和clash550254402()的函数选择器是一样的,见这里

$ cast keccak "proxyOwner()"
0x025313a28d329398d78fa09178ac78e400c933630f1766058a2d7e26bb05d8ea
$ cast keccak "clash550254402()"
0x025313a2bba9fda619061d44004df81011846caa708c8d9abf09d256021e23ee

通常,这不是一个问题,因为如果函数选择器冲突发生在单个合约的两个函数之间,那么 Solidity 编译器会以错误中止。然而,如果这样的冲突发生在不同合约的两个函数之间,那么就不会报告错误,因为这通常并不重要。但是,使用代理时除外。代理的回退函数会将任何它自己没有定义的函数转发给逻辑合约。现在,如果代理和逻辑合约定义了一个具有相同选择器的函数,那么代理将永远不会把对该函数的调用转发给逻辑合约,而是自己处理调用。更多信息请参见以太坊代理中的恶意后门

对这个问题至少有两种流行的解决方案:透明代理模式和通用可升级代理标准(UUPS)。透明代理模式的工作原理是,根据信息发送者的情况,将所有功能调用转发到逻辑合约,或者完全不转发。如果消息发送者是一个指定的代理管理员,那么我们假设他们只想调用代理本身的功能,而不是逻辑合约。对他们来说,调用是不会被转发的。另一方面,对于任何其他用户,我们假设他们只想调用逻辑合约中的功能,因此他们的调用总是被转发。这就避免了任何源于函数选择器冲突的问题,因为发送者决定了应该使用哪个合约。更多信息,请参阅透明代理模式

UUPS模式描述于EIP-1822。在这里,函数选择器冲突的问题是通过在代理中不定义任何公共函数来避免的。相反,所有管理代理的功能(包括upgradeTo())都在逻辑合约中实现。更多信息请参见Transparent vs UUPS Proxies

你可能已经注意到了,我们的例子代理合约既没有实现透明代理模式也没有实现UUPS。事实上,它根本没有防止函数选择器的冲突,而且完全受到上述问题的影响。请看一下OpenZeppelin Proxy Library的生产就绪的代理实现。

最后,还有其他具有不同权衡的代理模式。例如,有一个Beacon Proxy模式,它引入了另一个层次的间接性,但允许一次升级许多合约。还有Diamond Pattern,它允许将逻辑分散在多个合约中,规避了任何代码大小的限制。

结论和展望

在这篇文章中,我们已经开发了一个基本的代理实现,并讨论了一路走来的各种陷阱。不过,我们的实现还是有两个主要的缺点:

  1. 它很容易受到函数选择器冲突的影响
  2. upgradeTo()是无权限的。

在这个系列中,我们不会解决第一个缺点(关于潜在的解决方案,见设计选择一节)。然而,在下一篇文章中,我们将更仔细地研究第二个问题,因为这将直接导致Audius攻击中被利用的漏洞

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

如何与以太坊智能合约交互?

背景

智能合约部署在区块链上,它们是包含一些逻辑的代码片段,由EVM执行,将以太坊区块链变成一种世界分布式计算机。

智能合约可以被链下用户/程序调用,向区块链提交交易。链上的合约之间也可以通过调用对方的方法进行交互(不过是在链下调用之后,智能合约不能 "主动触发" 调用)。

智能合约的交互必须遵循ABI规范,这是一套规则和定义,用于规范以太坊生态系统中的智能合约通信。

在这篇博客中,我将以简化的方式介绍,根据ABI规范,必须如何提交数据给区块链,以触发智能合约。然后,我将谈论用不同方法从链下和链上调用智能合约函数。

介绍

在我们开始之前,我将简单地说明我将在这篇博客中使用哪些工具。

对于链下实体,我将使用web3 javascript库(web3.js),因为它封装了JSON-RPC协议,这是用于与区块链通信的实际协议。, 也可以使用其他相同目的的库库,如:ethers.js,不过语法可能会有所不同。

对于链上智能合约,我将使用solidity语言,但任何其他EVM兼容的编程语言也可以。

ABI规范

ABI规范指出了在调用智能合约函数时如何构建发送到 "交易"中作为 "data" 的字节数据。字节数据包含2个主要部分:

  • 函数选择器:前4个字节。它们表明被调用的智能合约的确切函数。函数选择器是通过计算函数签名的哈希值(keccak256)获得的(函数名称及参数类型 "func1(bool,uint256,address) "),然后简单地提取其前4个字节。可能会出现一些函数碰撞,因为我们只是使用4个字节,但可能性非常小...
  • 参数编码:从第5个字节开始,我们必须按照函数签名中指定的顺序添加作为输入参数传递的编码参数。有两种类型的参数,静态参数(值数据类型,如bool,unit256,...)和动态参数(引用数据类型,如数组,...)。静态参数需要32个字节(在方法签名所指示的位置),它们包含参数的值(必要时用0填充)。动态参数则以不同的方式编码。先预留32个字节(在方法签名所指示的位置)表示实际包含参数值的位置(作为从编码的参数部分开始计算的字节偏移),在指定的位置上,前32个字节表示参数的长度(它包含多少个值),然后列出实际的值。

示例 1

  • 函数: baz(uint32 val, bool check) 返回 bool
  • 函数签名 : baz(uint32,bool)
  • 调用 : baz(69, true) 时,ABI 规范编码数据为:

  • 橙色字节 = 函数选择器,keccak256("baz(uint32,bool)")的前4个字节。
  • 蓝色字节 = 第一个编码参数,它是一个静态参数,值为 "69"(0x....45)。
  • 红色字节 = 第二个编码参数,它是一个值为 "true"的静态参数(0x....01)。

示例 2

  • 函数 : sam(bytes name, bool check, uint256[] ids)
  • 函数签名 : sam(byte,bool,uint256[])
  • 调用 : sam("dave", true, [1,2,3]) 时,ABI 规范编码数据为:

  • 橙色字节=函数选择器, keccak256("sam(bytes,bool,uint256[])") 的前4个字节。
  • 蓝色字节=第一个编码参数,它是一个动态的参数,首先表明它的位置(字节0x60)。然后在位置0x60上,第一个字节表示长度(0x.....04 = 4字节,因为数据类型是字节),第二个字节表示参数的实际值: "dave" (0x646176650.........0)。
  • 红色字节=第二个编码参数,它是一个静态参数,值为 "true"(0x....01)。
  • 绿色字节=第三个编码参数,它是一个动态参数,首先指示它的位置(字节0xa0)。然后在位置0xa0上,第一个字节表示长度(0x....03 = 3个字,因为它是一个uint256数组),然后三个字节表示值: "1"、"2"、"3"(0x.....01, 0x....02, 0x......03)。

链下到链上的通信

你有一个前端或后端应用程序,需要与一些以太坊智能合约交互。我将使用javascript的web3.js库,它将处理JSON-RPC协议,也会生成必须提交给区块链的符合abi规范的字节串。

有两种可能的情况,你要么有智能合约JSON ABI,要么没有。

有智能合约JSON ABI

智能合约JSON ABI是一个JSON文件,在你构建智能合约时由solidity编译器生成。编译器实际上会生成两个文件:

  • ByteCode: 将被部署在区块链上的操作码(EVM操作)和来自 "构造函数 "函数的操作码(如果存在的话),在部署智能合约时只执行一次,为字节格式。
  • JSON ABI: 一个json数组,包含与你的智能合约相关的 public 和 external函数事件和错误的列表。每个函数、事件和错误都是数组中的一个json对象,它们包含所有必要的信息,以便链下实体与合约交互。

JSON ABI对象包含以下信息:

函数对象:

  • Type(类型) : 表示函数的类型,选项有 "function"(用于常规函数)、"receive"、"fallback" 和 "constructor"(用于特殊以太坊函数)。
  • Name :函数名称。
  • Inputs(参数) :包含每个函数输入参数的名称、类型和组件的对象数组。
  • Outputs(返回值) : 就像输入参数一样,但对于函数的输出参数。
  • 状态可变性 :函数的可变性:选项是 "view"(只从区块链中读取),"pure"(既不写也不从区块链中读取),"nonpayable"(不能接收以太币)和 "payable"(可接收以太币)。

事件对象:

  • Type : 总是 "事件" 。
  • Name :事件名称 。
  • Inputs : 包含每个事件参数的数组,其名称、类型、组件和(是否有)索引。
  • Anonymous : 如果事件被声明为匿名,则为真。

错误对象:

  • Type(类型) : 总是 "error"
  • Name(名称) :错误名称
  • Inputs : 包含每个错误参数的对象数组,其名称、类型和组件。

为了从你的链下应用程序与智能合约进行交互,首先需要导入JSON Abi文件,然后提供JSON Abi和指向智能合约的地址来实例化一个的对象。从那一刻起,你可以像对待其他对象一样直接调用合约的方法。

智能合约的调用将以异步方式完成:

// Reference the smart contract
const SmartContract= require(“SmartContract”);

// Retrieve the JSON ABI and address
const SmartContractAbi = SmartContract.abi;
const SmartContractAddress = "0x......"

// Instantiate an object that "encapsulates" the smart contract
const SmartContractObject = new web3.eth.Contract(SmartContractAbi, SmartContractAddress);

// Now you are ready to interact with the smart contract. Functions // invocations will return promises.
SmartContractObject.methods.func1(…).send({from: …, …}).on(…);
SmartContractObject.methods.func2(…).call({from: …}).on(…);

没有智能合约的JSON ABI

如果你没有JSON ABI,你仍然可以与智能合约进行交互,但这将是一个有点麻烦和烦人的过程。
你将不得不自己从方法定义(json格式)、希望提交的输入参数中创建区块链交易,并将其直接发送到智能合约地址。
你可以提交一个 "send"交易(将改变区块链状态的实际交易)或一个 "call" 交易(从以太坊的角度看不是一个实际的交易,因为它将只读取数据)。
交易将以异步方式提交:

// Define the Transaction Data
const TransactionData = web3.eth.abi.encodeFunctionCall({
    name: 'myMethod',
    type: 'function',
    inputs: [{
        type: 'uint256',
        name: 'myNumber'
    },{
        type: 'string',
        name: 'myString'
    }]
}, ['2345675643', 'Hello!%']);

// Now you can either send a transaction or make a call. In both 
// cases you will be dealing with Promises
web3.eth.sendTransaction({from: …, to: …, data: TransactionData, …}).on(…);
web3.eth.call({from: …, to: …, data: TransactionData, …}).on(…);

链上到链上的通信

你正在实现一个智能合约,想从你的代码中调用另一个合约的函数。可以使用 solidity 编程语言,它提供了一些内置的函数,来生成符合 abi 规范的字节串。

就像链下到链上的情况一样,有两种可能的情况,你要么有智能合约接口,要么没有。

有智能合约接口

如果你有你想调用的智能合约的接口,solidity将为你做大部分的工作。

你只需要将接口导入到智能合约文件,实例化一个接口类型的对象,并传递智能合约地址,你就可以开始了。就可以像其他对象一样调用合约的方法了。

// Import the interface and define the contract object using the 
// interface as a data type
import "IContract.sol";
IContract Contract;

// Instantiate the contract with its address
address contractAddress = 0x.......;
Contract = IContract(contractAddress);

// Invoke the contract's methods as defined by the interface
Contract.func1(....);

没有智能合约接口

如果你没有合约接口,那么你将不得不构建整个消息。
你将需要合约地址,方法签名(方法名称和输入参数类型用逗号分隔)和你希望提交的参数(也用逗号分隔)。

// Contract Address and function signature
address contractAddress = 0x.......;
string memory Method = “func1(uint256,bool)”;

// Define the abi compliant data
bytes memory AbiData = abi.encodeWithSignature(Method, 345223, true);

// Send the message
(bool success, bytes memory data) = contractAddress.call(AbiData);

警告

需要注意的是,不管你与智能合约的交互方式如何,如果你使用的智能合约地址是错误的,你仍然可以提交交易,没有任何的检查。如果智能合约确实有一个与你的调用相匹配的函数,它将被执行,如果没有,那么交易可能失败,也可能成功,如果智能合约有一个 "fallback()" 函数......重点是,后果可能是意想不到的,而且可能是无法检测的,这就是为什么你必须确定你向哪个合约发送交易,始终确保合约地址是正确的。

参考原文: https://medium.com/coinmonks/ethereum-smart-contracts-how-to-communicate-with-them-abi-specification-web3-solidity-db056218b251
转载:https://learnblockchain.cn/article/5090

详解 MPC 和智能合约钱包的优缺点与面临的挑战

概述

智能合约钱包与多方计算 (MPC) 协议,从长远来看并不是竞争关系,而是互补关系。

自我托管一直被誉为管理加密资产的最佳实践。FTX 和 Celsius 的崩溃是一长串事件中的最新一起,这些事件提醒业内 「非彼之钥,则非彼之币」,引得人们纷纷奔向非托管钱包。在 FTX 事件曝光后,Safe 获得了 8 亿美元以上的净流入,Ledger 在短时间内连续经历了多个历史新高的销售额,Trezor 销售额飙升 300%,ZenGo 在一夜之间实现了三位数增长,存款达到历史最高水平,所有这些都发生在同一周内。

然而,大量用户仍然愿意承担托管风险,以换取较低的成本和易用性。在非托管钱包基础设施成为保护和管理资产阻力最小的途径之前,我们还有很长的路要走。

幸运的是,现在有一个蓬勃发展的钱包生态系统,为个人、DAO 和机构提供了更多的选择。加密不再只涉及安全存储,它还包括在新经济中使用资产。但是,不断增加的攻击面和漏洞,再加上日益丰富的功能,使得钱包需要既能够抵御攻击,同时又能支持日常业务和个人使用。

与所有的设计决策一样,这是针对给定用例的多个考虑因素的优化问题,也是钱包解决方案和密钥管理实践的能力,它们需要务实地平衡目标用户的集体需求:

  • 个人需要无缝的用户体验、低费用、与 dApp 交互的灵活性。
  • DAO 需要透明的金库管理、生态系统治理参与。
  • 机构希望通过链不可知性、可审计性和机构级安全性来外包责任。

有两类替代密钥管理解决方案取得了重大进展:智能合约钱包 ( 包括多重签名钱包 ) 和多方计算 (MPC) 协议。

本文涵盖:

  • 钱包中要考虑的属性;
  • 传统、MPC 和智能合约钱包的概述;
  • 钱包生态系统的持续挑战;
  • 当前钱包解决方案的权衡总结,以及钱包基础设施前景展望。

钱包中需要考虑的属性

  • 安全
    从简单攻击到复杂攻击的保护程度。「良好的密钥管理」需要选择一系列解决方案,其加入和运营成本与链上活动的性质和风险金额相匹配。
  • 成本
    创建帐户、管理访问和执行交易的成本有多高。
  • 用户体验和灵活性。
    访问控制管理、开销策略、限制和权限的粒度。
  • 可恢复性
    在受到威胁或造成损失的情况下,有能力恢复资产和访问权。
  • 可扩展性
    可以为核心产品带来新功能,以及能够建造出综合的产品和服务生态系统。
  • 隐私
    地址可以轻松链接到个人。

传统 (HD) 钱包

传统钱包使用助记词和分层确定性 (HD) 结构来派生私钥、对应的公钥和链上地址。这些钱包允许用户生成用于签署交易的私钥,并使用助记词恢复所有密钥。

到目前为止,传统钱包一直是用户保管资产的工具,也是他们与区块链应用程序交互的主要入口。像 MetaMask 这样的浏览器扩展程序和像 Rainbow 这样的移动应用程序已经为这个生态系统吸引了数百万用户。想要降低风险的用户可以选择 Ledger 和 Trezor 等硬件钱包,它们可以离线保护私钥,从而提供更好的安全性。

虽然业界已经做出了巨大的集体努力来告知用户保持助记词和密钥安全的重要性,但这个单点故障仍然是广泛采用的一个重要障碍。如果私钥丢失,除了失去所有资产外,用户还必须手动跟踪多个地址、代币批准,并因必须为新地址提供资金而损害隐私。

今天,不可撤销的字符串不仅可以让一个人的毕生积蓄全部被「访问」,而且越来越多地趋势是将用户在线身份的链上历史联系起来。获取私钥访问权的动机就是这么大,以至于黑客们,每个人都投入无限的资源,进行越来越有创意的攻击。现在,仅仅依靠用户已经不够了——我们需要完全消除这个单点故障。

多方计算 (MPC) 钱包和智能合约钱包帮助我们实现这一目标,并且已经有一个由机构、个人和 DAO 等采用的关于这两类产品和服务的生态系统。虽然这两种类型的钱包都消除了单点故障,但它们有一些基本的技术差异,导致了不同的折衷方案。

MPC 钱包

广义上讲,多方计算 (MPC) 使一组互不信任的各方能够根据他们的输入共同计算一个函数,同时保持这些输入的私密性。在密码学中,这对于保存用于解密数据或生成数字签名的私钥特别有用。

MPC 钱包通过使用阈值签名方案 (TSS) 消除了单点故障。在这个范式下,我们创建并分发私钥的一部分,这样就没有一个人或机器能够完全控制私钥——这个过程被称为分布式密钥生成 (DKG)。然后,我们可以通过合并部分,并且在不暴露各方之间的部分的情况下共同生成公钥。


为了对消息和交易进行签名,每一方都要输入秘密共享部分与公共输入 ( 要签名的消息 ),以生成数字签名。从那里,任何知道公钥的人 ( 即验证者节点 ) 都应该能够验证和验证签名。由于密钥部分是被组合的,签名是在链下生成的,因此从 MPC 钱包生成的交易与传统的私钥钱包的交易没有区别。

这为 MPC 钱包用户保护了一定程度的隐私。对于那些希望将其签名方案和签名者活动置于公众视线之外的组织来说,这个特性是开箱即用的,因为这些过程发生在链下。这样,组织就可以保留参与签名的内部日志,而不对外公开。

Private Key Rotation 是另一种 MPC 协议,它将秘密共享部分作为输入,并输出一组新的秘密共享部分。旧的秘密共享部分可以被删除并替换为新的共享部分,新的共享部分可以以相同的方式使用,而无需更改相应的公钥和地址。

MPC 钱包的优势

  • 无单点故障
    一个完整的私钥在任何时候都不会集中在一台设备上。也没有助记词。
  • 可调整的签名方案
    批准固定人数可以随着个人和组织需求的变化而修改,同时保持相同的地址。组织可以动态调整签名方案,而不必每次都通知交易对手一个新地址。
  • 粒度访问控制
    机构用户可以为一个策略分配无限数量的交易审批者,并分配能够准确反映组织角色和安全措施 ( 时间锁、MFA、欺诈监控 ) 的权限。个人可以通过 MPC 钱包即服务 (wallet-as-a-service) 选择半托管路线,第三方持有其中一个关键共享部分。
  • 更低的交易和回收成本
    MPC 钱包在区块链上表示为单个地址,其 gas 费用与常规私钥地址相同。这对于每天进行数百个交易的用户 ( 例如在 B2C 用例中 ) 来说非常重要。丢失的密钥共享部分也可以进行链下回收。
  • 区块链不可知论者
    密钥生成和签名依赖于链下的纯密码学。将兼容性扩展到新的区块链很简单,因为钱包只需要能够使用该链识别的算法生成签名。

MPC 钱包的缺点

  • 链下问责制
    签署授权政策和批准固定人数是在链下管理的,因此这些自定义规则仍然容易出现中心化问题。密钥共享仍然是加密秘密,应该像处理整个私钥一样处理。链下规则和签名阻碍了透明度,需要更严格的运营审计。
  • 与许多用户采用的大多数传统钱包不兼容 ( 没有助记词,没有完整的私钥存储在单个设备上
    MPC 算法也没有标准化,也没有得到机构级安全设备 ( 如 iPhone SEP 和 HSM) 的原生支持。
  • 大多是孤立的定制产品
    许多 MPC 库和解决方案都不是开源的,因此,如果出现问题,生态系统很难独立审计和集成它们。

基于 MPC 的解决方案主要针对机构客户,如基金、交易所和托管人。像 Fireblocks 和 Qredo 这样的 MPC 技术提供商,允许他们的客户为不同类型的交易定义自己的工作流,使他们能够保持合规和安全。然而,散户投资者的基础仍然依赖于独立的研究和私人密钥钱包。Web3Auth 最近发布了一个 MPC SDK,用户可以使用他们的 iCloud 或电子邮件作为备份。像 Entropy 这样的去中心化托管协议正在为消费者和 DAO 构建开源工具,以便他们能够在线存储资产。

MPC 中值得注意的发展:可编程密钥对

Lit 是一个去中心化协议,它将密钥共享存储在 Lit 网络节点上。公钥 / 私钥对由 PKP( 可编程密钥对 )NFT 表示,其所有者是密钥对的唯一控制者。然后,PKP 所有者可以触发网络聚合密钥共享,以解密文件或在满足任意定义的条件时代表他们签名。

这对去中心化访问控制、资产管理和链上自动化交互具有很大的意义。通过向 Lit Action( 部署到 IPFS 的不可变代码 ) 授予签名特权,PKP 可以用作 MPC 或去中心化云钱包,使用任何可用 javascript 表示的身份验证方法。

铸造 PKP NFT 是基于 MPC 的分布式密钥生成过程,它使 NFT 所有者成为 PKP 的根所有者。因此,转移这个 NFT 相当于交易私钥,这实际上打破了「灵魂绑定」代币 (SBT) 的概念,因为 SBT 是与特定的所有者绑定,现在是钱包本身可以安全地交易,因此,「钱包绑定代币」可能是更合适的名称。

智能合约钱包

以太坊目前有两种账户类型:

  • 外部拥有帐户 (EOA)——由私钥控制
  • 智能合约帐户——由代码控制

智能合约钱包 (「智能钱包」) 是一种行为类似于钱包的智能合约,即一个允许用户管理资金、进行 web3 登录和与 dApp 交互的界面。与私钥钱包不同的是,智能钱包的创建需要初始成本,因为智能合约需要部署在链上。

多重签名钱包是智能合约钱包,它需要 M-of-N 密钥的签名才能执行交易。MPC 只创建单个签名,而不管参与的密钥共享的数量,多重签名使用由不同私钥生成的不同签名对交易进行签名。这使得它与现有的私钥钱包兼容,并位于 Ledger 或 MetaMask 等传统钱包地址之上的一层。

像 Safe 这样的智能合约账户标准为资产管理产品和服务的生态系统提供了一个基础层。功能是通过模块添加的,它允许用户定义管理密钥逻辑、支出限制、重复交易、帐户自动化、分层访问等等。目前最多产的一组 Safe 模块是由 Zodiac 团队构建的。

智能合约钱包的优势

  • 无单点故障
    执行交易需要多个签名。
  • 可编程访问控制
    用户可以定义不同的政策,设置时间锁、支出限制、自动化。
  • 可以实现交易批处理以节省成本。
  • 可扩展
    由于智能合约的可组合性,钱包开发人员可以创建一个模块生态系统,用户可以选择将这些模块添加到他们的钱包中,为 NFT 借贷框架、DAO 投票模块和非托管资产管理服务等新功能创建一个应用程序商店。
  • 可编程恢复
    钱包可以提供几种选择,将资金回收到智能合约本身。
  • 链上问责制
    链上签名授权策略和聚合可以明确使用哪些密钥对交易进行签名,从而使操作更加透明和直接,以便在出现错误的情况下审计谁参与了交易。
  • 支持迁移到其他签名方案
    智能合约钱包可以将其签名方案改为更简单、更省 gas 或抗量子的方案。他们还可以在 iOS 和 Android 设备上使用 ( 将手机变成硬件钱包 ),或启用 Ed25519,允许使用 iOS 生物识别和网络认证。
  • 开源
    任何人都可以审计智能钱包的实现和功能扩展,从而通过生态系统的方式解决漏洞和添加新功能。

智能合约钱包的缺点

  • 更高的费用
    智能钱包的费用比普通的单地址交易要高,因为需要验证多个签名。添加 / 删除所有者和更改阈值等操作也需要链上交易。
  • 没有得到普遍支持
    虽然智能钱包可以部署在相同地址的任何 EVM 链上,但它们需要在非 EVM 链上定制实现。
  • 恢复成本更高
    虽然恢复逻辑是可编程的,但需要支付链上费用来执行它。
  • 与不可升级的合约不兼容
    尽管 EIP -1271 允许应用程序代表合约钱包进行签名,但它仍然没有得到普遍支持,并且不能添加到不可升级的合约中。

智能合约钱包中值得注意的发展:帐户抽象

智能钱包在生态系统范围内,在努力完全摆脱 EOA 和私钥(也称为帐户抽象)的过程中发挥着至关重要的作用。在这种范式下,所有账户都是智能合约,它们有自己的逻辑来规定什么是有效的交易,允许用户根据自己的特定需求定制账户。

自 2016 年以来一直在讨论帐户抽象,但生态系统在对解决方案上的协调方面一直进展缓慢。L2 已经极大地加快了其意识和采用,例如 StarkWare 已经将所有 Starknet 账户本地化为智能钱包,zkSync 2.0 也将与 AA 一起推出。

在以太坊上,存在多个 EIP 来完成路线图上的里程碑,使帐户抽象成为现实。

  • EIP-4337:将签名验证、gas 支付和重放保护从核心协议移出到 EVM 中,让用户能够使用包含任意验证逻辑的智能钱包,而不是将 EOA 作为他们的主要帐户,同时也无需任何共识层更改。这个 EIP 引入了一个 UserOperations 内存池,它与现有的内存池并行存在。捆绑器 ( 验证者、MEV 搜索者或应用程序本身 ) 从 UserOperations 池获取交易,将它们转发给区块链并支付费用。在这里,启动钱包本身不支付 gas 费用,但应用程序可以通过收费订阅模式为用户聚合。
  • EIP-3074:允许 EOA 将控制权委托给合约,让现有的 EOA 发送由第三方支付的操作。
  • EIP-5003:将现有的 EOA 升级为合约,并允许其从 ECDSA 迁移到更高效或抗量子签名方案。

钱包开发生态系统面临的挑战

技术漏洞

Parity Multisig 黑客攻击和最近的 Rabby Swap 攻击表明,如果实现有缺陷,即使是最好的存储资金的概念方法也没有什么意义。我们可以预见,智能合约账户的标准将会出现

社交攻击层面

任何技术解决方案的优点仍然不能消除社会层面的风险。损失 6 亿美元的 Ronin Bridge 漏洞不是由于任何技术缺陷,而是针对 Sky Mavis 一名员工的社会工程攻击,使攻击者能够访问验证者密钥。除了决定使用哪个钱包来管理资产之外,组织还需要确保这个关键系统的每个「组件」在社交和技术层是真正独立的。

安全和迁移成本

从一个帐户迁移到另一个帐户既不有趣也不便宜。尽管目前市场上有强大的钱包替代品,但用户迁移现有的 EOA 是有实际成本的:交易费用、关闭 / 打开 DeFi 头寸、收入影响、用户错误、时间和精力。

操作安全

自我保管对于今天的大多数用户来说是一个可怕的前景,因为提高是需要有意识的努力的,这可能是一项艰巨的任务。大多数交易数据是不可读的 ( 尽管这一点正在改变 ),错误是不可逆转的。就像加密教育一样,这个问题不能由一个团队单独解决,需要工具和用户体验模式。

结论


尽管 MPC 和智能钱包有着共同的「这个 vs 那个」框架,但从长远来看它们并不是竞争关系,而是互补关系。MPC 在密钥生成和管理级别提供了共享安全性,而智能合约为功能和应用程序开发带来了可扩展性和生态系统方法。例如:

  • MPC 可以通过将一个或多个私钥分割成多个部分来增强现有的多重签名方案。如果三个人被用来保护一个 2 / 3 的多重签名,这三个用户中的每个人都可以使用 MPC 细分他们的个人私钥,并将他们的 MPC 密钥部分存储在独立的机器上。
  • 社区或 DAO 可以是拥有 PKP NFT 的多重签名的签名者,该 NFT 管理去中心化的云钱包,可用于自动投资或 DEX 交互。

今年,中心化实体的不计后果的行为在许多方面削弱了加密货币,它们侵蚀了行业的信任,最重要的是失去了用户的资金。本文重点介绍的技术和项目为每个人都可以参与去中心化经济,同时不用将命运掌握在少数人手中的未来铺平了道路。

原文

https://medium.com/1kxnetwork/wallets-91c7c3457578

如何在solidity中开始使用无gas元交易

元交易,也被称为 "无gas" 交易,是一种允许用户与智能合约交互而无需自己支付gas的方式。这对于需要用户进行频繁或小额交易的应用来说特别有用,因为Gas费的成本会迅速增加。在这篇博文中,我们将讨论如何在Solidity中开始实现无gas元交易,Solidity是用于在以太坊区块链上编写智能合约的编程语言。

什么是元交易?

在以太坊网络中,每次用户想与智能合约交互时,他们必须向网络支付一笔费用(以Gas形式),以便执行交易。这种费用对于激励矿工将交易纳入区块链并确保网络保持去中心化和安全是必要的。

然而,这种模式对于需要用户进行频繁或小额交易的应用来说是有局限性的,因为Gas费用的成本会迅速增加,并成为用户进入的障碍。元交易提供了一种解决方法,允许用户与智能合约交互,而不必自己支付加Gas费。

在元交易中,用户的交易实际上是由另一个账户执行的,该账户代表他们支付Gas费。这个账户被称为 "relayer",它可以是一个合约或普通的以太坊账户。中继者从用户那里收到交易,用自己的私钥签名,然后将其提交给网络进行开采。用户的交易基本上被包裹在支付Gas费用的第二笔交易中,允许用户与合约交互,而无需自己支付Gas费用。

在 Solidity 中实现无gas元交易

为了在Solidity智能合约中实现无gas元交易,我们需要做以下工作。

  1. 创建一个函数,允许中继者代表用户执行交易。
  2. 检查中继者是否被授权代表用户执行交易。
  3. 验证用户交易的签名以确保其真实性。
  4. 执行用户的交易,并使用中继者的账户支付Gas费。

让我们更详细地了解一下这些步骤中的每一个。

1. 为中继者创建一个函数来执行交易

首先,我们需要在我们的智能合约中创建一个函数,允许中继者代表用户执行交易。这个函数应该接受以下参数。

  • _user: 想执行交易的用户的地址。
  • _data: 用户的交易数据,编码为字节数组。这通常是用户想调用的函数的签名和参数,使用abi.encode()函数进行编码。
  • _signature: 用户交易的签名,使用eth_signTypedData()函数生成。

下面是这个函数在Solidity中的一个例子。

function execute(address _user, bytes _data, bytes _signature) public {
  // TODO: Add code to verify the relayer and signature
  // TODO: Add code to execute the user's transaction
}

2. 检查中继者是否被授权

接下来,我们需要检查中继器是否被授权代表用户执行交易。这对于防止恶意行为者代表其他用户提交任意交易非常重要。

做到这一点的一个方法是让用户明确授权中继器代表他们执行交易。这可以通过在智能合约中添加一个映射来实现,该映射存储了每个用户的授权中继者。然后execute()函数可以检查这个映射,以验证调用者是否被授权代表用户执行交易。

下面是一个例子,说明这种映射和验证在Solidity中可能是怎样的。

mapping(address => address[]) public authorizedRelayers;

function execute(address _user, bytes _data, bytes _signature) public {
  // Check that the caller is authorized to execute transactions on behalf of the user
  require(authorizedRelayers[_user].contains(msg.sender), "Unauthorized relayer");

  // TODO: Add code to verify the signature
  // TODO: Add code to execute the user's transaction
}

在这个例子中,authorizedRelayers映射被用来为每个用户存储一个授权中继者数组。然后execute()函数检查调用者(msg.sender)是否在该用户的授权中继者数组中,然后再继续执行。

3. 验证签名

接下来,我们需要验证用户交易的签名,以确保它是真实的。这对于防止恶意行为者提交实际上并非由用户签名的交易非常重要。

为了验证签名,我们可以使用ecrecover()函数,该函数将签名、交易数据和链ID作为输入,并返回签署该交易的地址。然后我们可以将这个地址与传递给execute()函数的_user参数进行比较,以确保它们相匹配。

下面是这个签名验证在Solidity中可能出现的例子。

function execute(address _user, bytes _data, bytes _signature) public {
  // Check that the caller is authorized to execute transactions on behalf of the user
  require(authorizedRelayers[_user].contains(msg.sender), "Unauthorized relayer");

  // Verify the signature
  bytes32 hash = keccak256(abi.encodePacked(chainId, _data));
  address signer = ecrecover(hash, sig.v, sig.r, sig.s);
  require(signer == _user, "Invalid signature");

  // TODO: Add code to execute the user's transaction
}

4. 执行用户的交易

最后,我们需要执行用户的交易,用中继者的账户支付Gas费。要做到这一点,我们可以使用delegatecall()函数,它允许我们用当前合约的调用者和参数调用另一个合约的函数。

下面是一个在Solidity中可能出现的例子。

function execute(address _user, bytes _data, bytes _signature) public {
  // Check that the caller is authorized to execute transactions on behalf of the user
  require(authorizedRelayers[_user].contains(msg.sender), "Unauthorized relayer");

  // Verify the signature
  bytes32 hash = keccak256(abi.encodePacked(chainId, _data));
  address signer = ecrecover(hash, sig.v, sig.r, sig.s);
  require(signer == _user, "Invalid signature");

  // Execute the user's transaction
  // The relayer's account is used to pay for the gas fees
  delegatecall(_data);
}

在这个例子中,delegatecall()函数被用来执行用户的交易,使用relayer的账户来支付Gas费。_data参数包含用户交易的函数签名和参数,被传递给delegatecall()函数作为调用的合约和参数。

总结

在这篇博文中,我们讨论了如何在Solidity中实现无gas元交易,Solidity是用于在以太坊区块链上编写智能合约的编程语言。我们走过了以下步骤:创建一个允许中转者代表用户执行交易的函数,验证中转者是否被授权,签名是否真实,以及执行用户的交易,同时使用中转者的账户支付Gas费用。

元交易对于需要用户进行频繁或小额交易的应用来说是一个有用的工具,因为它们允许用户与智能合约交互,而不必自己支付Gas费。通过遵循这篇博文中概述的步骤,你可以在你自己的 Solidity 智能合约中实现无gas元交易。
Source:https://coinsbench.com/how-to-get-started-with-gasless-meta-transactions-in-solidity-90e6d18f758
转载自:https://learnblockchain.cn/article/5347