Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SmartContract #28

Open
jackalchenxu opened this issue Sep 6, 2022 · 27 comments
Open

SmartContract #28

jackalchenxu opened this issue Sep 6, 2022 · 27 comments

Comments

@jackalchenxu
Copy link
Owner

all things about smartcontract, not limited to Solidity of Ethereum

@jackalchenxu
Copy link
Owner Author

jackalchenxu commented Sep 6, 2022

for mind sake,i choose description in Chinese.

想要了解智能合约,就需要了解每一种链的关于账户的机制(比特币账户机制,以太坊的账户机制,跟Solana各不一样),当然共同概念也非常的多,如执行引擎。

以太坊账户类别:

  • CA(Contract Account) 合约账户 (data区域保存着合约代码)
  • EOA(Externally Owned Account) 即通常意义上的用户账户
    两者都有余额数据域(balance);EOA账户有对应的私钥,而CA没有对应的私钥(想想要怎么保证)。

交易,唯一触发合约执行;交易可以形成调用链条(交易A -->触发合约B,合约代码生成交易C--> 触发合约D,--->...),起始交易的触发一定是从EOA账户发起的交易,另外以太坊中把合约生成的交易叫message call(消息调用),只是叫法不同,消息本质也是交易,也要消耗gas费。

CA/合约账户,data区域对应着合约的状态,如下的合约代码:

// SPDX-License-Idenetifier: MIT       //license声明
pragma solidity ^0.8.0;                      //指定solidity支持版本号


contract SimpleStorage {                    //合约名,CamelCase风格

   uint storeData;                            //变量名, camelCase风格
   

   // function 函数名(参数类型 参数名)  modifier
   function set(uint x) public {
      storeData = x;
   }
   
   // function 函数名(参数类型 参数名)  modifier returns (返回值类型)
   function get() constant returns (uint) {   
      return storedData;
   }
}

其中用户调用过set函数后,storeData就存在于CA账户的data数据中,对应着合约的状态。

上面函数的modifier有好几类,public/private/...是可见性invisibility,constant是mutablity,...

Solidity合约编程有官方style
语法索引 -- 带链接,很清楚

后续不断更新一些小section

@jackalchenxu
Copy link
Owner Author

jackalchenxu commented Sep 6, 2022

简单token铸造和派发:
mint(amount)
//合约创建人可以调用mint(amount)来生成指定数量的token,记在合约创建人账户上

send(receiver, amount)
//合约创建人调用transfer amount token给receiver,同时自己账户减少对应数量token

// SPDX-License-Idenetifier: MIT
pragma solidity ^0.8.0;

contract Token {
   address public minter;
   mapping (address => uint) public balances;

   event Sent(address from, address to, uint amount);

   constructor() public  {
      minter = msg.sender;
   }
   
   function mint(uint amount) public {
      if (msg.sender != minter) return;
      balances[minter] += amount;
   }
   
   function send(address receiver, uint amount) public {
      if (balances[minter] < amount)  return;
      balances[minter] -= amount;
      balances[receiver] += amount;
      emit Sent(msg.sender, receiver,  amount)
   } 
}     

notes:

  • address public minter 声明一个地址类型的状态变量minter,public的一个作用是让该变量minter可被其他合约访问,另一个作用是Solidity编译器会自动为该变量生成一个同名函数,以此来访问该变量(minter())
  • mapping类型是Solidity中的类似hashmap结构的类型,这里也用了public来修饰,这样balances(acc)即可获取acc账户的token余额
  • Sent事件会在send函数被触发后发出,事件非常有用,不仅仅可用于debug,钱包等App还会监听事件,进而做各种处理,事件实际上也是控制流程中的一部分 (callback); (虽然日志会被记录在链上数据中,但合约本身访问不了日志)

@jackalchenxu
Copy link
Owner Author

jackalchenxu commented Sep 6, 2022

以太坊账户结构:

无论是CA账户还是EOA账户,如下数据域都存在:

  • nonce
  • balance
  • codeHash
  • storageRoot

codeHash对于CA账户有意义,合约账户的合约代码,都有Kecca256 Hash值,在EVM处,代表该合约;也就是说,EOA账户调用了合约A,即指定了合约的codeHash,在EVM处,由指定的codeHash即可找到合约A。

storageRoot对于CA账户,即代表了账户代码中各种变量的保存,比如上说上面代码,Token合约中的minter和balances,在底层是以key->value实行存储。

EOA账户创建不需要发交易,不会消耗gas;但CA账户创建要发交易(to address为特定的0x0),也要消耗gas

如下是一个概要图:
Snipaste_2022-09-06_14-06-24

以太坊的交易消息 - transaction vs message

交易结构

Nonce -- 交易nonce(作用是交易排序,防止重放攻击等等)
Gas price
Gas Limit
Recipient -- 目标(以太坊)地址
Value -- Ether币数量
Data -- 交易数据(目的地址是合约地址,则data数据指定要调用的函数和传参等)
v, r, s(ECDSA) -- 签名数据

很明显,交易中包含签名,交易的发起方一定是EOA账户。
交易的表现会在多个层面,比如:

  • 交易会在以太坊网络中传播,被网络中每一个节点在EVM中验证,出块节点会把交易内容放入区块中
  • 通常交易都会改变账户的状态,比如用户A转账给用户B,两人的账户状态(余额)发生了变化,而变化都体现在区块和区块头(区块头包含StateRoot,ReceiptsRoot,TransactionRoot,其中StateRoot的变化包含了两人账户的变化);又比如用户A调用了合约B的函数,该函数修改了合约B的状态变量,此时(合约)账户B的状态发生了变化,也会对应着新交易和区块。
  • 用户A调用合约B的函数,合约B的函数再调用合约C的函数,完成一桩业务,与之对应的一笔交易的生成;
    但交易表面并不会完整对应业务所做的一切,但从交易数据来看,就nonce,gas,from,to,data,value

消息

是从另一个层面来定义的:消息在账户与账户之间传递数据(data)与价值(value),具体表现形式如下:

  • 数据:一个账户向另一个账户请求函数调用。
  • 价值:一个账户向另一个账户发送以太币。

消息的确可以由外部账户通过请求一次交易来触发。但是,被作用的对象如果是一个智能合约的话, 它还可以进一步调用其他的合约 (进一步发送消息);

举例说明:某用户调用了智能合约A的一个方法。可能会依序发生如下的效果:

  • A合约代码被运行,该方法中调用了另一个智能合约B的一个方法。(函数调用)
  • B合约代码被运行。运行中要给账户C 转账以太币。(价值转移)
  • 以上的函数调用和价值转移这类消息并不是外部可见的,不记录在区块里。

@jackalchenxu
Copy link
Owner Author

jackalchenxu commented Sep 6, 2022

overflow, underflow and unchecked block

Solidity中integer类型有:unsigned和signed,分别对应uint和int
当然类型中也有长度之分,uint8,uint16,...uint256; int8,int16,... int256
uint就是uint256, int也即int256 (256bit看起来在Solidity是一等公民)

solidity也支持数学运算,此时如果integer加上数学运算,就一定会发生overflow和underflow。

比如这个:

function g(uint a, uint b) pure public returns (uint) { return a - b; }

我输入(2,3)会发生什么?
答案是会函数会出错,合约所在交易会回滚 - 我们称之为revert,原因是"2 - 3"会产生underflow,而underflow/overflow的后果是revert

如果我想要(2 - 3),得到结果值为最大的正整数的结果(即 type(uint128).max),那我要怎么写代码?

function g(uint a, uint b) pure public returns (uint) { 
   unchecked {
      return a - b; 
   }
}

unchecked { ... }是一个语句块,其作用是暂时屏蔽overflow/underflow, 产生一个wrap的效果


客制化错误,save gas消耗

如下代码:当执行出错时,换用不同的报错,会导致gas费用不一样

   ...
   function withdraw() public {
      if (msg.sender != owner) {
         revert("error")
      }
   ...

revert() 接受一个字串当作error message,此时调用withdraw报错消耗gas费用23645
我们也可以使用require,如 require(msg.sender == owner, "error"),此时效果与上面的revert是一样的。

或者换用定制错误的方式:

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;

error Unauthorized();

contract VendingMachine {
    address owner;

   function withdraw() public {
      if (msg.sender != owner) {
         revert  Unauthorized();
      }
   ...

revert 后接客制化错误,此时不要加(),
此时调用withdraw报错消耗gas费用为23391,相比前面节省了254wei
就算是revert Unauthorized(msg.sender) 加入了报错交易发起人地址,也比以前少了51wei

// 23645
revert("error");

// 23663
revert("errorerrorerrorerrorerrorerrorerrorerror");

// 23594
revert Unauthorized(msg.sender);

// 23391
revert Unauthorized();

最后一个不得不提的assert,也跟错误检测有关。
基本上assert可以算作一个惩罚性的错误检测,当检测不通过时,会回滚交易,不仅扣除运行到检测点的gas,而且还扣除剩余的gas费(相当于扣除用户的gas limit)


合约创建

我们想创建合约,需要书写*.sol,然后solc编译,然后部署合约到链上,...(得到合约的链地址,再构建交易来与合约互操作)

其实合约中也可以再创建另一个合约,见如下的代码:

   ...
contract D {
      uint public x;
      constructor(uint a) { x = a; }     //注意这还有错误
}
  
contract C {
   // C合约创建时就自动创建了合约D
   D d = new D(4);

   // 另一种创建合约D的方式
   function createD(uint arg) public returns(D) {
      D newD = new D(arg);
      return newD;
   }

   // 另一种创建合约D的方式,同时并更新新建合约的余额
   function createAndEndowD(uint arg, uint amount) public returns(D) {
      D newD = new D{value: amount}(arg);
      return newD;
   }
   ...

这边编译会报错:TypeError: Cannot set option "value", since the constructor of contract D is not payable
即如果想要给账户D充值余额,需要该账户为payable,而contract D的constructor并没有声明为payable
修改为constructor(uint a) payable { x = a; } 即可。

编译,部署,给合约账户充值(不然合约无法执行创建CA账户D的操作),然后触发合约createD 或者 createAndEndowD来让我们的合约再创建一个新合约账户; 交易,新账户的地址等信息都可以在Etherscan的交易页面Overview和Internal Txns中可以看到。

1
2
3

新合约的地址是如何产生的? 及 create2

上面已经讲了合约创建的过程,但这个是在合约编程层面;在EVM层面,有对应的操作码

  • create(v, p, n)
  • create2(v, p, n, s)

我们通常的合约创建,其合约地址就是透过create(v, p, n)来创建的,准确来说是
把交易创建者地址 + 交易nonce, 变成RLP list,然后Keccak-256哈希得到的地址(前20字节)
基本来说,交易nonce不太好预定,所以合约地址有很大变化性,即un-predictable。

为了能加强合约地址的predictable,Solidity和EVM都添加了create2来支持,其生成参数去掉了nonce,加入了其他数据。
如下是一段Solidity编写的代码,使用create2来生成合约D的地址:

function createDSalted(bytes32 salt, uint arg) public {
        // This complicated expression just tells you how the address
        // can be pre-computed. It is just there for illustration.
        // You actually only need ``new D{salt: salt}(arg)``.
        address predictedAddress = address(uint160(uint(keccak256(abi.encodePacked(
            bytes1(0xff),
            address(this),
            salt,
            keccak256(abi.encodePacked(
                type(D).creationCode,
                abi.encode(arg)
            ))
        )))));

        D d = new D{salt: salt}(arg);
        require(address(d) == predictedAddress);
    }

可以看到,create2来生成合约地址的四个要素:0xff, 发起合约创建的账户地址,salt,合约的字节码(哈希)
如果这几个信息维持不变或者预先确定,则合约地址就是确定的。
引用别人的话:

This means you can be sure the contract address calculated today would be the same as the address calculated 1 year from now. This is important is because you can interact with the address, and send it ETH, before the smart contract has been deployed to it.

It allows for contracts to be deployed at the same address on all networks
https://github.com/thegostep/solidity-create2-deployer

实际上,Uniswap也是利用了create2,当任一用户指定任意两种token,总是能找到对应的合约地址(如果之前这两种token已经在池子里面建立了交易对合约的话)
算法类似:

  1. 对token1,token2做校验(比如不能为0,不能相同等)
  2. 对token1,token2做大小排序,组成一个交易对
  3. pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
    其中bytecode是uniswap创建交易对合约代码,salt值为交易对信息的keccak256哈希值
    这样就可以保证token1,token2的交易对合约地址是确定唯一的,如果没有该交易对合约则创建新的,如果已有则使用已有存在的

一个"可升级的"智能合约。

之前谈到,以太坊上的合约是一次性部署的;如果需要修改合约(比如说合约有bug),我们就需要重新部署新合约;但有时这给应用带来了麻烦,此时我们希望该合约的新版本还部署在原来的地址上。

@jackalchenxu
Copy link
Owner Author

气死了,忘记保存,不小心按下电源键,半天写的东西都没了

@jackalchenxu
Copy link
Owner Author

jackalchenxu commented Sep 7, 2022

Solidity语法

数值类型:

  • value数值类型
  • reference引用类型
  • mapping类型
  • function类型

数值类型

bool,int256, uint256, address, address payable, bytes1, bytes2...bytes32, enum
int,uint,默认即为int256, uint256,其他类型还有int8, uint16等等。
address是一个有一点特别的类型:地址类型,20个字节长,如:let addr= address(0x1111122223333444455556666777788889999AAAA)
address payable是另一个特别的address类型,payable地址类型有额外的balance属性和transfer()方法
bytes1是一个长度为1的字节,bytes32是一个长度为32的字节(数组)。。。

特别说一点,类型之间可以相互转换,转换的语法是:T(S) ,S是原类型,T为要转换的类型,比如:

address a = address(0x123);
payable p = payable(a);

有时候忘了这一点,还以为payable()是一个特别的函数(我看stackoverflow上有人还问)

值类型的意思是,当发生赋值操作时,如:

bytes20 b
bytes20 c = b;

此时b和c都会指向各自的一个定长20的字节(数组), 两者地址不一样,虽然内容是一样的。
我这里不想说bytes20是一个字节数组,因为这样称呼容易跟下面要讲的reference类型的数组混淆。

详细的语法解释,参见文档


reference引用类型

Array/数组:有固定长度的,也有变长的,如:uint8[6] a, uint32[] b, 或者address[] wallet_addr;
bytes:变长字节数组,其中每一个元素是一个字节,类似bytes1[]
string:字符串,但没有index[]方法,也没有length属性

所有的引用类型变量,在声明时,都需要特别指定data location修饰

  • memory
  • storage
  • calldata

Storage
对于合约而言,在函数外声明的变量(我们称之为state variable),默认location都是storage (你也不能手动加storage),意思是它会存储于链上;当然,我们也可以指定函数内的变量,加上storage限定,让其存储在链上,如:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract CodiesAlert {
    string twitterAccount = "Anni_Maan";

    function displayAccount() public view {
        string storage Newvar = twitterAccount;
    }
}

此时twitterAccount和Newvar都是storage,存储于链上。
存放于链上的代价是该变量会增加gas费用,(显然Newvar是没有必要加storage的)。

Memory
memory变量是临时的(即不存于链上),通常用在函数内,申明一个新变量,或者用于接收输入参数的值。
memory变量可以被修改,被加工;这一点也许看起来很正常;但是下面的calldata就是只读的。
例子:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract CodiesAlert {
    string twitterAccount = "Anni_Maan";

    function displayAccount() public view returns(string memory) {
        string memory Newvar = "1024";
        return Newvar;
    }
}

Calldata
既然已经有了memory类型,为何还有再有一个calldata?
如果有一个变量,生成之后,就不会再发生改变(只读),此时就适合于用calldata来修饰;calldata常适合用在external函数内传参reference类型上;相对于memory,calldata的gas消耗更小。

以上这些只是从syntax角度来讲,但实际这些语法的解释,在编译后的字节码和在EVM上的实现才能真正衡量数据类型所占用资源;而这也才真正决定着这些变量所对应的gas费用,参见:
1
2

当变量之间相互赋值时,有时会发生值copy的情况,有时会只复制引用,规则见下:

  • Assignments between storage and memory (or from calldata) always create a separate copy.
  • Assignments from memory to memory only create references. Therefore changing one memory variable alters all other memory variables that refer to the same data.
  • Assignments from storage to a local storage variable also only assign a reference.
  • All other assignments to storage always copy.

函数
如下是函数的gammar:
solidity_function
要点:

  • fallback 和 receive是两个特别的函数(名)(不要再为它们加函数名了)

  • visiablity 可见性

    • external
    • public
    • internal
    • private
      以上的“可见性”是相对合约contract而言,external函数是合约A对外曝露的服务API,可供DApp和其他合约调用,合约A内其他的函数不能直接调用该external函数
      internal函数是供内部使用的,本合约内其他函数可以调用internal函数,该合约的继承合约(inherit contract)也可以调用该internal函数
      public则最开放,任何场景下都可以调用该public函数
      private则比internal限制更多一些,只能允许该合约其他函数调用,继承合约就无法调用该private函数了
  • state mutablity 可变性

    • pure
    • view
    • payable
      pure是纯函数效果,即该函数内不能read 、write合约的状态变量;view函数可以读取合约的状态变量,但不能修改其值;
      我们创建了一个合约,现在想要让该合约做一些操作,这些操作会消耗gas,而该合约账户上没有Ether,所以我们要先为该合约账户充值,假设我们为合约设计了一个函数deposite,用来接收我们的转账,则代码可能是这样的:
    function deposite() {
      msg.sender.transfer(msg.value);
    }

msg.sender就是EOA账户,我们转1个Ether到该合约账户上,msg.value就是10000000000000000, transfer()执行转账操作。
慢着,该代码实际上编译会出错,因为凡是涉及到该类操作(转入Ether/Token,转出Ether/Token),都要求该函数要加上payable这个修饰符。

实际上,为了更好处理用户调用调用合约中的函数,并附上转账操作,以太坊还单独设计了receive()和 fallback() --- 上面有列,但没有讲。
想要快速完成这个给合约充值的功能,添加一个receive()是最方便的,fallback()也可以,函数实现也非常简单,见下:

   receive() external payable {}   // 是不是特别简单? 这样一行就完成了合约充值的功能

receive和fallback的选择,如下是选择图:

Which function is called, fallback() or receive()?

        send Ether
            |
        msg.data is empty?
            /       \
          yes        no
          /           \
 receive() exists?     fallback()
        /     \
    yes        no
    /           \
receive()      fallback()

我们实际在编写合约代码时,如果没有加receive或者fallback,SOL编译器会提示我们加;但如果我们执意不加入,也没有设计函数接口来接收外部账户的转账,那么这个合约部署到链上,就是一个死合同,什么都做不了;此时我们使用EOA账户给该合约账户转Ether,转账交易也会失败。

@jackalchenxu
Copy link
Owner Author

jackalchenxu commented Sep 7, 2022

函数修饰符 function modifier

该功能类似Python中的函数修饰器作用,添加额外检查功能或者。。。,见例子:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

contract owned {
    constructor() { owner = payable(msg.sender); }
    address payable owner;

    // This contract only defines a modifier but does not use
    // it: it will be used in derived contracts.
    // The function body is inserted where the special symbol
    // `_;` in the definition of a modifier appears.
    // This means that if the owner calls this function, the
    // function is executed and otherwise, an exception is
    // thrown.
    modifier onlyOwner {
        require(
            msg.sender == owner,
            "Only owner can call this function."
        );
        _;
    }
}

contract destructible is owned {
    // This contract inherits the `onlyOwner` modifier from
    // `owned` and applies it to the `destroy` function, which
    // causes that calls to `destroy` only have an effect if
    // they are made by the stored owner.
    function destroy() public onlyOwner {
        selfdestruct(owner);
    }
}

onlyOwner 就是函数修饰器,之前文章往往在destroy函数中都要检查msg.sender是否是owner,即创建合约时的账户;现在换成用function modifier,对应的逻辑移到了onlyOwner中,想要此功能的函数只需要加上onlyOwner修饰器即可,前提条件是:

  • 被修饰符函数所在的合约,跟修饰器所在的合约,是同一个合约
    或者
  • 被修饰符函数所在的合约,继承自修饰器所在的合约

function modifier的定义就跟普通函数一样,只是把function关键词换成modifier;不过modifier还是有其自己的特性:

  • modifier函数无法access被修饰函数的传参或者是被修饰函数内定义的变量
  • modifier函数中定义的变量,仅限于modifier函数内,被修饰函数无法access它们。
  • modifier函数获取外部变量的方法:
    • 如果modifier函数和被修饰函数在同一个contract中,则modifier函数可以access合约状态变量
    • 如果modifier函数被单独定义在一个合约中A,而被修饰函数在另一个合约B,此时modifier函数想获取合约B的状态变量,需要给modifier加入传参,见如下例子:
contract priced {
    // Modifiers can receive arguments:
    modifier costs(uint price) {
        if (msg.value >= price) {
            _;
        }
    }
}

contract Register is priced {
    mapping (address => bool) registeredAddresses;
    uint price;

    constructor(uint initialPrice) { price = initialPrice; }

    // It is important to also provide the
    // `payable` keyword here, otherwise the function will
    // automatically reject all Ether sent to it.
    function register() public payable costs(price) {
        registeredAddresses[msg.sender] = true;
    }
}

另外推荐Solidity库: OpenZeppelin,其中也有很多修饰器的编写。

@jackalchenxu
Copy link
Owner Author

jackalchenxu commented Sep 8, 2022

合约的单元测试 contract unit test

仔细想想一下,如果想要做单元测试,比如想测试一下如下合约中的排序:(省去不相关的代码部分)

contract Sort {
    uint[] public numbers;

    constructor() {
        numbers = new uint[](4);
        numbers[0] = 2;
        numbers[1] = 1;
        numbers[2] = 4;
        numbers[3] = 3;
    }

    function sort() external {
        uint i = 0;
        uint left = 0;
        uint len = numbers.length;
        uint tmp;

        while (left < (len - 1)) {
            for (i = left; i < len; i++) {
                if (numbers[i] < numbers[left]) {
                    tmp = numbers[i];
                    numbers[i] = numbers[left];
                    numbers[left] = tmp;
                }
            }
            left += 1;
        }
    }
    ...
}

假设待排序的numbers数据是Storage location,sort方法来对numbers中的数据进行排序。
单元测试首先来保证排序算法正确性,(后续还可以构建其他数据集做边界测试和错误测试等...不过与本主题无关)

首先是我们不可能真的用过去那种函数调用运行(进程,函数)的方式来进行,一定是在“合约,部署,消息(调用), DApp收到返回结果,检查”这样的场景下进行。

说实话,如果全手动来构建这一切,太麻烦了。目前有两个框架工具供我们选择:Remix Solidity Unit test和Hardhat test
(在以前还有Truffle,但现在基本上就Remix和Hardhat了)

  1. Remix Solidity Unit Test
    资料
    它本身也是solidity语言写成,除了测试框架提供的hooks函数: 'beforeEach', 'beforeAll', 'afterEach' & 'afterAll',还引入remix_tests.sol -- 提供各种assert判断函数,和remix_accounts.sol -- 提供预设accounts(预先有足够Ether)
    在框架函数中创建合约,即可调用合约函数,不用再额外转账,比如如下是针对上面的sort函数的测试代码:
// SPDX-License-Identifier: GPL-3.0
        
pragma solidity >=0.4.22 <0.9.0;

// This import is automatically injected by Remix
import "remix_tests.sol"; 

// This import is required to use custom transaction context
// Although it may fail compilation in 'Solidity Compiler' plugin
// But it will work fine in 'Solidity Unit Testing' plugin
import "remix_accounts.sol";
import "../contracts/VenderMachine.sol";

// File name has to end with '_test.sol', this file can contain more than one testSuite contracts
contract testSuite {
    VenderMachine vm;

    function beforeAll() public {
        vm  = new VenderMachine();
        vm.sort();
    }

    function checkSort() public {
        Assert.equal(vm.getNumber(0), uint(1), "numbers[0] is not 1");
        Assert.equal(vm.getNumber(1), uint(2), "numbers[1] is not 2");
        Assert.equal(vm.getNumber(2), uint(3), "numbers[2] is not 3");
        Assert.equal(vm.getNumber(3), uint(4), "numbers[3] is not 4");
    }
}

我个人感觉用这个框架,优点是本身集成在Remix IDE中,有方便的generate testcase的按钮(然后用户可以按照需求,在此基础代码上修改)
缺点是:相比下面的Hardhat,Remix Unit test工程化不够

  1. Hardhat
    Hardhat test实际上是多个工具集的集合,在节点运行环境方面,有自己的hardhat node,在测试面利用了Chai,整体的工程化比较强,不仅包含assert test结果,console log,还包括区块测试等比拟真实环境的测试,另外通过插件,支持coverage测试,gas-report等等。
    Hardhat本身是基于CLI的,npx hardhat test $test_case_name (当然Remix unit test也有对应CLI命令)
    另外要强调的是Hardhat test代码基于Javascript代码写的,书写感觉更好。

如下是Hardhat的针对sort的测试代码:

const {expect} = require("chai");
const hre = require("hardhat");
const {loadFixture} = require("@nomicfoundation/hardhat-network-helpers");

describe("MyContract", function () {
    async function deploy() {
        // Contracts are deployed using the first signer/account by default
        const [owner] = await hre.ethers.getSigners();

        const contract = await hre.ethers.getContractFactory("MyContract");
        const faucet = await contract.deploy();
        return {faucet};
    }

    describe("sort", function () {
        it("sort", async function () {
            const {faucet} = await loadFixture(deploy);   //创建和部署合约
            faucet.sort();                                                   //调用合约sort方法
            for (let i = 0; i < 4; i++) {                               //做结果判断
                expect(await faucet.getNumber(i)).to.equal(i + 1);
            }
        })
    })
});

个人建议Unit test用Hardhat的方案来做比较好。
更细的测试代码, 参见

@jackalchenxu
Copy link
Owner Author

jackalchenxu commented Sep 9, 2022

以太坊的区块,交易,bird view

block区块数据结构:

  • Previous Block Hash -- 前一个区块block hash.
  • Transaction Hash Root -- 本区块内的交易的Root(hash)
  • Receipt Root Hash -- Root node of receipt hash
  • State Root Hash -- Root node of world state.
  • Timestamp -- Unix timestamp that represents the time when mining ends.
  • Difficulty -- Difficulty is a value that shows how difficult to find a hash.
  • Nonce -- 这里的nonce是区块nonce,用于PoW(并非transaction的nonce,意义完全不一样)
  • Gas Limit -- 区块内所有交易Gas limit总和
  • Gas Used -- 区块内所有交易Gas总和
  • Extra Data -- An optional and free field to store extra data.
  • number -- 区块高度 (0 is a genesis block)

区块本身不包含区块内交易的原始信息,通过transaction hash root的方式,可以索引到属于该区块的交易,[参见]
(#28 (comment))

交易的数据结构:

  • Nonce -- 交易nonce(作用是交易排序,防止重放攻击等等)
  • Gas price
  • Gas Limit
  • Recipient -- 目标(以太坊)地址
  • Value -- Ether币数量
  • Data -- 交易数据(目的地址是合约地址,则data数据指定要调用的函数和传参等)
  • v, r, s(ECDSA) -- 签名数据

签名数据中不仅可得到针对该交易的签名数据,也可以得到交易发送者的(以太坊)地址(这就是为什么交易数据结构没有包含类似 from 的数据域)


签名
签名数据实际上由v,r,s三个部分串起来。

  • v包含链ID和用于帮助恢复(recover)公钥的信息
  • r,s 包含消息签名和公钥信息

如下的javascript代码,展示对消息"hello",使用小狐狸metaMask,生成签名的过程:

  async function signMessage() {
    if (!window.ethereum) return alert("Please Install Metamask");

    // connect and get metamask account
    const accounts = await ethereum.request({ method: "eth_requestAccounts" });

    // message to sign
    const message = "hello";
    console.log({ message });

    // hash message
    const hashedMessage = Web3.utils.sha3(message);
    console.log({ hashedMessage });

    // sign hashed message
    const signature = await ethereum.request({
      method: "personal_sign",                            // metaMask支持的几种签名方法之一
      params: [hashedMessage, accounts[0]],
    });
    console.log({ signature });

    // split signature
    const r = signature.slice(0, 66);
    const s = "0x" + signature.slice(66, 130);
    const v = parseInt(signature.slice(130, 132), 16);
    console.log({ r, s, v });
  }

运行结果如下:
hashedMessage = 0x1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8
r = 0xb7cf302145348387b9e69fde82d8e634a0f8761e78da3bfa059efced97cbed0d
s = 0x2a66b69167cafe0ccfc726aec6ee393fea3cf0e4f3f9c394705e0f56d9bfe1c9
v = 28

如下代码是从签名中导出(recover)metaMask钱包地址的函数:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Verify {

    function VerifyMessage(bytes32 _hashedMessage, uint8 _v, bytes32 _r, bytes32 _s) public pure returns (address) {
        bytes memory prefix = "\x19Ethereum Signed Message:\n32";
        bytes32 prefixedHashMessage = keccak256(abi.encodePacked(prefix, _hashedMessage));
        address signer = ecrecover(prefixedHashMessage, _v, _r, _s);
        return signer;
    }
}

有一点特别的是,signMessage()函数中在调用metaMask签名时,使用了"personal_sign" method,所以后续在恢复地址
需要在hashedMessage( 0x1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8)前面附加上"\x19Ethereum Signed Message:\n32",才能保证恢复正确。
参见 1 , 2

@jackalchenxu
Copy link
Owner Author

为什么要用_msgSender(),而不用msg.sender?

前面的例子中我们都毫无例外的使用了msg.sender作为交易的发起人,这看起来很直观,没有什么问题。
但是如果浏览一下网络上Solidity的合约代码,很多例子并没有直接使用msg.sender,而是用_msgSender(),这是为什么?

这都和OpenZeppelin提供的合约库,和相关的EIP-1077,GSN相关。
事情是这样的,一个新人,通过自己的第一个钱包软件,有了一个以太坊账户;如果想要发送交易,此时遇到的最大困难是,该账户无法发送交易,因为账户里面没有Ether。

如果我们可以免费发送交易,是不是会更好?-- 获取新用户本来就要花一笔钱。
当然,也可以不完全免费,比如我在线下买了服务商的点卡/预付卡,此时透过该服务商,发送以太坊交易是不是可行?

总之,在这个思路上,很多人做了尝试,比如Meta Transaction
目前更新的提案是创建GSN(gas station network)

简单一点来说,这里面有三个角色:

  • Sender: 发送请求给GSN Hub,选择Relay,并发送交易给Relay
  • Relay(节点):注册到GSN网络,成为Relay(事先要质押Ether给GSN Hub,一方面支付gas费,另一方面做质押 - 作恶则扣除质押)
  • Relay Hub: 维护Relay列表,分派合适的Relay给用户
  • Recipient: 部署在GSN的合约,遵循RelayRecipient规范,接收从relay hub来的交易

流程图:

要特别提两点:

  • 这个是gas station network,实际上跟ethereum network并没有直接关系
  • Recipient作为交易的终点,但它在后端可以把交易数据组装为一个以太坊交易并上以太网络,上面的流程图并没有提;实际上,Recipient合约的两个hook函数:preRelayedCall, postRelayedCall,目前都还是空的,后续可以扩展。

作为Recipient合约的书写者, 在OpenZeppelin支持GSN并把合约库加入了GSN功能后,如果我们在合约中使用msg.sender,并以为它一定是 Sender, 这种假设在GSN网络中就是错误的;在GSN的场景下,Recipient合约中msg.sender会是Relay Hub。

出于这个原因,如果想要获取sender地址, OpenZeppelin提供了_msgSender()来让我们获取sender地址,代码

function _msgSender() internal view virtual override returns (address payable) {
        if (msg.sender != getHubAddr()) {
            return msg.sender;
        } else {
            return _getRelayedCallSender();
        }
}

总结:

  • 在绝大多数情况下, _msgSender()等同于msg.sender;
  • 在GSN环境下,msg.sender等于RelayHub地址,此时使用_msgSender()来获取Sender地址
  • 所以,总是使用_msgSender()来获取Sender地址

@jackalchenxu
Copy link
Owner Author

推荐一个好的VS code的solidity的插件
Solidity Visual Auditor
可以把合约中的函数在Outline窗口中列出来。它对合约复杂度有评分,详见

@jackalchenxu
Copy link
Owner Author

Solodity Library

就像std库 for C++,rust,solidity也支持库,作用一方面是提供已有现成的,更好的某功能的实现,另一个带来的结果是让用户编写的合约,总体大小变小了,gas费也低了,

Solidity的库也是部署要当作合约部署在链上,不过库的代码跟合约,在如下方面不一样:

  • 库内部不能包含有状态变量 - 这很正常,库可是会被多个合约同时调用呢,要保持库的正常运作,它必须是pure function
  • 库内部不能包含fallback函数,也不能包含payable函数(不能接收Ether/Token)
  • 不能被继承,也不能被destroy(没有selfdestruct函数)
  • 库中的函数,如果全部函数都是private或internal的,那合约(库的调用者)在被编译的时候,编译器会把库代码copy进合约代码中。(不过这样库存在的意义就消失了)

这些限制都比较符合”直觉“

另外,合约在调用库时,EVM在底层实现的时候,会使用一个叫delegatecall的调用,此时如果库的代码中有包含msg.sender, tx.origin, this,它们的值可能会在你的意料之外,因为虽然运行转移到了库这边,但calling context上下文还仍然在caller合约这边。<精通以太坊>第7章有一个很好的例子展示。

库虽然不能包含状态变量,但caller 合约可以把自己的状态变量当成函数参数,传入库的函数中,此时库函数对状态变量的修改也保存在caller合约这边。

使用库的方式:

  1. import 库名字 from '库文件.sol' 或者 import {库1, 库2, 库3} from '库文件.sol'
  2. 库.函数()
  3. 或者 using A for B
    be used to attach library functions of library A to a given type B, 比如:
import {MathLib} from  './lib-file.sol';
using MathLib for uint;    //MathLib提供函数subUint(uint x, uint y)
uint a = 10;
uint b= 10;
uint c = a.subUint(b);

@jackalchenxu
Copy link
Owner Author

jackalchenxu commented Sep 11, 2022

我们还有其他选择吗

智能合约开发语言,目前就属Solidity拥有最多用户,而与之对应的链是Ethereum。
如果我不想继续使用Solidity来开发合约,还有其他的选择吗? 或者,还可以选择其他的链吗?

在以太坊生态中,除了Solidity,剩下的还有Vyper,但我看了两者数量对比后,..., sorry。

其他的链,比如Polkadot,NEAR,Solana,首先是都有原生的开发合约的语言,Rust,和对应的SDK;那有如下两个问题:

  • 它们可能支持Solidity吗?
  • 我有可能用Polkadot,NEAR,Solana的合约开发语言rust + SDK, 制作出合约,然后跑在以太坊或者Polkadot,NEAR,Solana的链上吗? 即”一次编译,随处运行“,是不是有Java那味儿了?

其实涉及到底层的VM,虚拟机了; Solidity只是编程语言,使用solc(Solidity配套的编译器),编译出来的结果是可以跑在EVM(Ethereum Virtual Machine)上的字节码。 我们设想一下,如果现在所有的公链都提供了兼容的EVM,那么我们用Solidity编写的合约,或Rust+SDK的编写合约,可以自由的跑在各个公链上,当然这些都是假想。

我个人认为,合约和虚拟机不可能在各个公链中复用,就拿以太坊来说,合约设计到以太坊的gas费,状态变量的存储,账户的设计(solana的账户设计就跟以太坊不太一样,更不要说Solana的PDA),更不要说各个公链对虚拟机图灵完备性的不同选择等等。

真实的情况是,Polkdot,Solana,Near都有自己的虚拟机,基于WASM; 而针对EVM,每一家也都提供了各种解决方案。
比如Near,给出了Aurora(兼容EVM + Chain + Near)+ 彩虹桥(Near桥接Ethereum)
Aurora包含了兼容以太坊的EVM(或者说EVM runtime),user case请参见这个图:

aurora
要特别说一点:以太坊的交易在处理的过程中,都是在NEAR这条公链上进行的(假如交易中的合约没有call到以太坊其他合约 - - 这个目前还不支持),跟以太坊无关,生成的区块也在NEAR公链上,自然的该交易处理所花费,也是NEAR Gas;只是aurora这边最后会根据NEAR--> Ether的价格,转换为Ether的gas费用,交由用户支付。

未来WASM虚拟机规范,会成为未来的可互操作VM,实际上,以太坊已经把eWASM当作了目标,放在phase2阶段完成,不过这也是很多年以后的事情了(看看到phase0,以太坊花了多久):

phase0-2

总结

  1. 目前还没有办法用其他的合约开发语言来全面代替Solidity,在相当一段长的时间内,Solidity仍然是合约开发语言第一大
  2. 以太坊合约(实际上是EVM兼容的合约),可以部署在以太坊上,也可以部署其他公链上,比如NEAR,只要这些公链推出了兼容EVM的运行环境; 但有一点要注意的是,这些公链上的以太坊合约只能局限与该公链内(未来也许会跟以太坊有深度互操作),目前这些公链上的以太坊合约无法得到原有以太坊的账户资源。
  3. 全新公链搭配全新合约开发语言,比如MOVE,带来的是全新的挑战。

@jackalchenxu
Copy link
Owner Author

智能合约中的事件Event

智能合约中的消息,类似Javascript中的回调callback一样,在合约设计中,在重要的操作发生后,把重要信息(如函数执行结果)放入事件中,并发送。 以事件构造时指定的index为事件索引,放入以太坊的日志系统中;虽然事件信息本身不放入区块中(这样也大大节省了gas费),但只要合约存在,事件信息就存在以太坊的节点中(我不确定是存在内存中还是硬盘上),总之还是有相当的保险。

event is stored in sub node of Receipts Trie[发tree音]
Ethereum Tries

通常事件的用法是,DApp会监听相关的事件,当有对应的事件发生,前端因此做更新; 一个DApp能够与后端沟通,产生互动,事件是绝对缺少不了的。

  1. 定义事件
    例子:
event storedNumber(
    uint256 indexed oldNumber,
    uint256 indexed newNumber,
    uint256 addedNumber,
    address sender
);

加注了index的字段,称之为”主题/Topic“,Dapp可以根据该字段作为搜索参数,定位事件信息。
不建议给所有字段都加index,原因是发送事件会消耗gas费,加多一个index,也有对应的gas费。

  1. 发送事件
uint256 favoriteNumber;

function store(uint256 _favoriteNumber) public {
    emit storedNumber(
        favoriteNumber,
        _favoriteNumber,
        _favoriteNumber + favoriteNumber,
        msg.sender
    );
    favoriteNumber = _favoriteNumber;
}
  1. 查看事件
    store函数执行后,会在响应的交易的Log页面,看到事件信息
    log

事件组成有:

  • Address (发出事件的合约地址)
  • Topics (索引参数)
    上图显示有三个索引,但第一个索引topic[0]是事件哈希,我们所定义的两个索引: oldNumber是0,newNumber是1
  • Data (事件数据)
    事件中除索引参数之外的,其他参数放这里

特别说明:事件的原始信息(或者上面的截图中按Hex按钮看原始信息),Topics和Data数据都是参数的值,经过Keccak-256 hash后的。

  1. DApp监听/过滤事件
    DApp使用Ethereum Web3.js提供的API来监听事件,如下是示例代码:
var storeEvent = myContract.store({newNumber: 1});
storeEvent.watch(function(err, result) {
  if (err) {
    console.log(err)
    return;
  }
  console.log("Found ", result);
})

详见Ethereum web3.js帮助

@jackalchenxu
Copy link
Owner Author

jackalchenxu commented Sep 12, 2022

Ethereum Token Standards 以太坊代币标准

  • ERC-20
    最经典的代币标准,接口主要定义了合约中的包括代币名称,和APIs来增减balance[recipients],还内置了代币分销功能以及API
    有如下几点我觉得特别的:

    • 在本质上,Ether和Token还是不一样的,账户的Ether余额体现在个人账户状态中,而账户的Token余额体现在Token合约的状态变量中
    • 用户钱包需要输入代币合约地址,这样才知道自己的该代币的余额
    • 如果代币不小心转入到了一个合约地址,而该合约没有支持该代币,则这些代币就卡在那里/丢失了
    • ERC-20提供了两种方式来转账ERC-20 Token
      • transfer(_to, amount) 最直接的转账,在token合约那边,把msg.sender的余额减去amount,_to的余额加上amount
      • Approve + TransferFrom(_owner, _to, amount)
        上面的transfer在EOA账户之间很好用,很直接;
        但在EOA账户转Token给合约账户时就不那么好用了,比如如下场景: 我购买了10个LINK,准备支付我使用Chainlink的提供随机数服务。我转账10个LINK,目标地址是ChainLink的合约地址,但合约无法得知我已经支付过服务费。
        解决方法:
        1. 我调用approve(ChainLink合约地址, 10);
        2. 我再调用ChainLink的服务程序接口函数,在该接口函数内,会执行msg.sender.transferFrom(token_owner_address, 10), 此时msg.sender是我的账户,transferFrom(...)会以我账户生成交易,执行10个LINK Token转账到该合约地址中,如果转账成功(我的确有Approve,而且我的账户balance有那么多余额),函数执行成功,服务程序接口认可我已经付费,接着提供随机数服务。

    参考资料:

  • ERC-223 (并未merge进EIP中,之后被ERC-777代替)
    为了解决上面提到的“代币转到一个不适合的合约地址,导致丢币”的问题;添加了函数:

    • function standard() constant returns (string _standard) 实际上是返回"erc223"

    • function transfer(address _to, uint _value) returns (bool)
      只是为兼容ERC-20,但要求内部实现先做检查,如果_to地址是合约地址,则调用_to所在合约的tokenReceived(address, uint256, bytes calldata)函数,用来处理接受token的操作,当然,如果该合约地址不能处理该Token,函数执行失败会revert

         .....
         if(Address.isContract(_to)) {
             IERC223Recipient(_to).tokenReceived(msg.sender, _value, _data);
         }
         ......
    • function transfer(address _to, uint _value, bytes calldata _data) returns (bool)
      ERC-223新增函数,处理逻辑跟上面的transfer一样,只是输入参数多了_data,这个留给实现函数自行处理(可以根据业务需求保存到链上,也可以不用它,_data可为空)

    • function tokenReceived(address _from, uint _value, bytes calldata _data)
      ERC-223新增函数,每一个合约代码,如果要接收token的,都必须实现该函数,在其中处理接受的token

    参考:
    demo implememtation

  • ERC-677
    上面我们讲了一个场景,我支付10个LINK token来使用ChainLink的随机数服务,可以从交互来看,总共需要2个步骤(approve,call服务),涉及3个函数(approve,call服务,transferFrom),请问有更简化操作吗?
    Yes,here brings transferAndCall
    简单来说,就是先tranfer()费用,然后再call服务使用;实际实现需要

    • token合约新添加transferAndCall(服务提供address,转账token金额,call服务数据):调用transfer转token给服务提供合约地址,call 服务提供合约的tokenFallback函数
    • 服务提供合约添加tokenFallback(sender, 转账token金额,call服务数据) :检查sender来自token合约,token金额是否正确,然后实现服务

    这个规范存在的问题是:流程在一开始就transfer token金额,并且该金额是固定的; 但在一些场景下,如果价格存在变化,此时sender并没有足够的信息来确定价格,就显得很尴尬。这也是为什么之前的approveAndCall在这些场景下,会好于现在的transferAndCall的原因
    approveAndCall是在支付时,用户先approve额度,服务提供商在执行服务前调用transferFrom来收取确定的服务金额;当然,从交易gas费来说,transferAndCall会优于approveAndCall

  • ERC-777
    需要回答两个问题:

    1. 为什么ERC-777使用的很少?
    2. 如果建立新ERC-20代币,建议是用ERC-20还是直接用ERC-777?
    3. 已有ERC-20代币代码,是否可以直接转换为ERC-777,其他代码不用写,就可以获得优势? -- 比如采用openzeppilin库
  • ERC-721
    最经典的"非同质化代币", 对应于现实世界的房产,收藏海报,...etc。

@jackalchenxu
Copy link
Owner Author

jackalchenxu commented Sep 12, 2022

非常不错的以太坊学习资料:

@jackalchenxu
Copy link
Owner Author

jackalchenxu commented Sep 13, 2022

以太坊交易的gas用量

Gas用量代表了该笔交易所涉及的所有操作,换算为一个统一的中间单位来衡量,即gas用量(gas usage by tx)
而这个计算或者说转换过程,统统在EVM中进行。
(个人还没有接触EVM,根据之前其他项目的经验,应该是EVM对操作的所有OPCode做gas加总,得到一个gas usage)

个人做过测试,发现一个情况,如下场景:
EOA账户调用合约A函数, 该函数再call合约B函数,其中两个函数都有修改各自的状态变量。
这样的交易,构建后提交,交易的gas用量为100(举例数字);但再次构建交易,提交后,发现gas用量,比之前更多了,比如是120(举例数字),同样的操作,再次生成交易,再提交,就变成了90,之后再测试,就维持90不变了。
这感觉与之前的原理对不上啊。

经过分析,这涉及到EVM的操作和优化;在每一次的交易,可以看到:

  • 交易实际的gas用量
    gas_usage

  • Internal Txs(不要误会它是真的交易,实际是message)
    由此可以一窥中间过程
    internal_tx

  • State变化
    可以看到该交易产生了哪一些状态变量的更改,更可以作为VM内部运作一窥(合约Solidity源代码或许有对状态变量有赋值,但在
    执行面,EVM的优化,会把这些赋值优化掉,从而节省操作,节省gas用量)
    比如这个讨论,就是状态变量由0到一个值X,再由X到Y,这两个操作,对应的gas值不一样
    state_change

  • VM Trace Transaction
    该交易的Vm operation steps, 链接:https://rinkeby.etherscan.io/vmtrace?txhash=你的交易hash(带0x)
    vm

当VM operation steps一样的时候,两笔交易的gas用量必然一样。

PS:
EVM中不同的操作,消耗gas数量不同;例如将一个值从 0 变为非零值需要耗费 20000 单位的gas;修改一个 非0 值需要消耗 5000 单位gas;从0到非零值对gas的消耗要高于修改非0值的底层原因在于,从0到非零值涉及到了变量空间分配的操作。
这也会解释,同样的交易,第一次调用智能合约时,消耗gas费为120;而相同的交易(指同样的合约函数调用),第二次调用,消耗gas费会比第一次要低的原因。


做什么事情都需要gas费?

合约的函数调用,有时候是不需要生成交易:

  • 合约中的public/external,具有view 或者 pure修饰的函数,当EOA账户调用合约中的这些函数不需要生成交易,自然也不需要gas费。
    整个过程是客户端,通过RPC,连接到local node,调用该函数,不需要动用EVM来执行。
    (这里我觉得是以太坊设计上的考虑)
    如果用Remix的环境下,你可以看到这些view/pure函数,按钮是蓝色的,按下去,都只会生成call,而不会有交易产生。

without_gas

但根源并不是本身这些函数不消耗gas,实际上,我们调用一个合约的函数,该函数内部先使用transfer实现一个转账(代表一定会产生交易),然后再调用view或者pure的合约函数,整个过程算下来,这些view/pure函数仍然对应着EVM中的操作指令,这些都要计算为gas消耗。
我们在Remix环境下,view/pure函数,还是会提示一个gas用量:

view_gas_usage

@jackalchenxu
Copy link
Owner Author

jackalchenxu commented Sep 14, 2022

Oracle for Blockchain

之前了解的预言机,是因为:

  • 随机数
    公链中需要获取随机数(VRF)的,如Filecoin挑选出本轮获胜(抽签中奖)的矿工,负责打包交易,生成区块
  • 链下数据
    • Defi项目,合约需要各种token的实时兑换价格,来完成兑换业务

提供数据

但从大的角度来讲,预言机是“链上” - “链下”的中间连接,能(高质量的)满足这个功能都可以称之为预言机
预言机需要满足如下特性:

  1. 可提供链下数据给链上的“预言数据消费者” - “oracle consumer” , 即智能合约
  2. 提供的链下数据具备验证信息 - 可证明该数据确由真实有效提供方提供
  3. 提供的链下数据可被验证 -- 特别是随机数,需要有该随机数可验证明(VRF中的Verify

出于设计考虑,预言机网络与链网络分开(Filecoin的DRand network,Chainlink network)
(DRand只能提供随机数,Chainlink可以提供包括随机数和其他链下数据)

我觉得预言机还需要提供如下特性:

  • relayer,为多种公链网络提供接入服务(链下数据源虽然产生了,但要有充足的通道提供给“预言数据消费者” ,否则就会造成单点故障)
  • 去中心化(最好),去中心化意味着预言机会从多个有效数据源获取数据,涉及到汇总和数据偏差,Chainlink还为此引入了评价功能。

链下计算/计算预言机

或者称之为可验证计算

TrueBit [9]是可擴展和可驗證的離線計算的解決方案。它引入了一個求解器和驗證器系統,分別執行計算和驗證。如果解決方案受到挑戰,則在鏈上執行對計算子集的迭代驗證過程 - 一種“驗證遊戲”。遊戲通過一系列迴圈進行,每個迴圈遞迴地檢查計算的越來越小的子集。遊戲最終進入最後一輪,挑戰是微不足道的,以至於評委 - 以太坊礦工 - 可以對挑戰是否合理,在鏈上進行最終裁決。實際上,TrueBit是一個計算市場的實現,允許去中心化應用支付可在網路外執行的可驗證計算,但依靠以太坊來強制執行驗證遊戲的規則。理論上,這使無信任的智能合約能夠安全地執行任何計算任務。

我也感到了这和Layer2有相似之处。

参考:
-Chainlink

@jackalchenxu
Copy link
Owner Author

jackalchenxu commented Sep 15, 2022

Solidity的基类/子类,继承,接口,抽象类,重载

Solidity支持这些,都是从Python学来的。

到目前为止,建立使用接口,基类,子类继承的方法来构建合约,实际上这也是OpenZeppelin这边的用法,比如ERC-20的实现:

  • IERC20.sol和IERC20Metadata.sol -- interface定义文件,定义了ERC20协议需要支持的函数接口(接口只定义函数,不实现)
  • ERC20.sol -- ERC20基类,实现了IERC20的函数
    contract ERC20 is Context, IERC20, IERC20Metadata {
    ...
        function name() public view virtual override returns (string memory) { return _name; }
        ...
       funtion transferFrom(address from, address to, uint256 amount) public virtual override returns (bool) {
          address  spender = _msgSender();
          _spendAllowance(from, spender, amount);    //以_开头的都是internal函数
         _transfer(from, to, amount());
         return true;
       }
    }
  • Context.sol -- utils/Context.sol, abstract contract Context, 提供_msgSender()和_msgData()实现

目前Solidity的关于这部分的语法规则:

  • 基类的函数如果要设计为可能会被子类覆盖的,则需要加上virtual的修饰符
  • 子类的函数如果要覆盖的基类的函数,则需要加上override的修饰符
  • 子类函数实现中,如果想调用基类的同名函数实现,使用super.函数名()

考虑到多重继承的复杂性和审计代码难度,我个人觉得:

  • interface来设计API
    • 少有一些情况,需要API并实现的,可以考虑abstract contract,但个人不建议,因为可以再考虑基类实现和modifier
  • 基类实现API
  • 子类在基类的基础上扩充功能(比如加入hooks)或者覆盖基类实现

现代OOP设计里面,我觉得,应该多用组合,少用继承。

@jackalchenxu
Copy link
Owner Author

jackalchenxu commented Sep 16, 2022

EVM

  • EVM设计为图灵完备(turing completeness),具备支持任意复杂的操作(以支持构建复杂合约/业务),但搭配的就是要引入gas费来限制恶意交易导致系统无止尽运行。

  • 图灵完备的EVM也意味着,合约开发者,无法准确评估合约实际运行时的gas费用消耗。

  • 非图灵虚拟机在其他公链,最出名的应该就是比特币,但目前更多的公链,几乎都采用了图灵完备的虚拟机。

  • 在以太坊上针对交易收取的是gas费,部分给予矿工(部分销毁);其他公链,基本上也都有类似的设计,以补偿矿工 - 这件事情是一定要做的,收取费用的方式各不相同,比如新创建的存储空间收费,或者运行时消耗的cycles换算为token费用等等。

  • 有可能不收费吗? 不可能,因为如果这样,任何人都能发起交易来形成攻击。收费是经济学上的方法,能很好解决攻击/安全的问题。(另一个例子是POS)

    • 另一个跟经济学有关的例子是交易执行时失败,以太坊仍然要扣除交易过程中所花费的gas费(也有其他公链不收取的)
  • EVM的结构,支持的操作码

    • EVM采用堆栈结构来操作指令,处理数据;
    • EVM中处理数据的基本单元是256bit长度数据(称为Word/字)
    • 合约中的状态变量,在EVM中对应一个存储区域(用0初始化),之后会序列化到链上数据
    • 合约中的memory变量,在EVM中对应一个存储区域(用0初始化)
    • 操作码
      • 这里有清晰明了的操作码展示
      • 操作可以分为:运算,栈/内存数据存取,控制流jump/call函数,环境信息(gas,EVM环境),链信息(如区块号等)
        • Solidity当中的全局变量,基本上都对应于这里的环境信息和链信息
      • 有一个有意思的事情: 调用合约函数时,都会使用到function selector,就是函数原型声明(函数名+参数类型 -- 不包含返回值)的Keccak-256Hash的前四个字节,居然对应的EVM操作码为: (hash结果的32字节) div (000010000....(28个0)),因为整数相除取整的原因,就这样得到了hash结果的前四个字节; 这种操作对于熟悉嵌入式的人觉得,这个事情难道不应该是用shift操作码吗? -- 在那个时候,没有加入shift操作码,现在也仅仅是EIP145
      • EVM在进化中,添加新的操作码,更新对应的gas费; 而个人猜测,通过加入EVM版本号的方式,通过EVM软分叉来做到EVM升级。
      • 操作码对应的gas费可以修改吗?当然可以,它本身不代表什么,仅仅是维持一个合适的映射,映射到现实环境下CPU和内存为执行该项操作所花的资源;如果映射失效,即该项操作很浪费资源,但对应的gas费很少,则攻击者可以发起包含该操作码的交易,结果是gas费很少,但节点的计算机忙于处理这些交易,处理速度变慢,交易卡死 -- 历史上有过这样的攻击。
      • EVM性能升级,同样的操作码消耗更少的资源,此时就可以降低该操作的gas费,同一个区块可以容纳更多的交易,提升了tps,而且处理速度不变慢。
  • Gas费用

    • 以太坊的区块大小是没有限制的,区块中包含的交易数也是没有限制的,以太坊设计上,使用一个区块的gas usage上限,来限制区块大小和区块内交易数量
    • 一个区块的gas usage 上限= 区块内所有交易的gas usage加总;目前2020/09,以太坊区块gas usage上限值为15M(M = 百万),如果以该区块所有交易都是最低的转账 21000gas来算,区块最多能容纳715个普通转账交易;每个转账交易占109个字节,则区块大小为76KB, 这些指标对于区块链的P2P网络来说,很普通
    • 以太坊区块的gas上限可以变化,矿工根据公式协商选择gas limit,投票决定之后的区块gas上限;提醒一点,gas limit就像区块/交易数上限一样,都是共识的一部分
      • 采用投票机制来决定,我怀疑,会不会效率太低?不过区块gas limit,的确影响最大是矿工,假想一下,gas limit设置过小,会导致交易繁忙的时候,总是有很多交易未能被放入区块中;gas limit过大,区块变大,交易变多,一方面矿工的机器验证封装负担加重(另一方面是区块和交易在网络中的传播的负担),最怕的就是网络分裂。
      • gas limit变化公式:next_gas_limit = (current_gas_limit*1023 + near_1024_gas_usage * 1.5) / 1024
      • near_1024_gas_usage = 1024块内EMA加权平均值 (我也不知道这里的alpha值为多少,N为1024)
        ema
  • 合约的字节码

    • 合约编译后得到合约的字节码
    • 但光有合约字节码还不够,还要有该合约部署的字节码
    • 合约部署到链上,如果合约开发者自己不公布合约源代码,其他人可以从字节码反汇编(我没有试验过反汇编效果如何,不过现在不公布源代码而让别人放心使用的DApp已经不多了吧?)

参考:
-投票升级gas limit

@jackalchenxu
Copy link
Owner Author

jackalchenxu commented Sep 22, 2022

Meta Transaction and EIP712

Meta transaction

又叫gasless transaction,解决用户发送交易但账户没有Ether支付交易费的场景。
这个解决方案中有三个角色:用户User,交易中继者Relayer,交易接收者(smart contract)

操作流程:

  • User使用dApp组装一笔交易t,在这种场景下,我们称之为raw transaction,实际为message;透过wallet对该消息签名;之后dApp发送该消息和签名给Relayer(无gas费产生)
  • Relayer接收该消息和签名,可对交易做有效性检查(确认由User发出,因为要为他买单,付gas费);如果检查通过,则新建一笔以太坊交易T,把t放入到T中;Relayer发送交易T到以太坊网络上,支付T交易的gas费
  • smart contract处理收到的交易T,获取t,对t进行签名验证;如果验证通过,执行t的操作

这个解决方案就是为了能用户更容易使用交易(onboarding)。Relay在这个场景中,要么与用户用其他方式结算(比如用户购买充值卡)或者Relay把费用当作是拉新的成本。

为支持这个场景,dAPP需要支持(sign message,MetaMask等钱包都已支持),Relayer需要提供支持,最后smart contract也要提供支持才可以。


EIP712

Wallet软件在签署消息时,UI上提示的都是Hex String,确认要签的消息/交易几乎不可能。

EIP提出了规范,以typed structure data and siging来规范消息的形式,wallet UI显示清楚地显示要签署的交易的内容。


smart contract code handle

如下是UniswapV2在合约处的代码,permit()处添加了meta transaction的支持,提供了ERC20的approve的功能

function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
   require(deadline >= block.timestamp, 'UniswapV2: EXPIRED');
   bytes32 digest = keccak256(
      abi.encodePacked(
          '\x19\x01',
          DOMAIN_SEPARATOR,
          keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline))
       )
    );
   address recoveredAddress = ecrecover(digest, v, r, s);    // v, r, s  就是签名中的三要素,ecrecover()拿到用户公钥
   require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');
   _approve(owner, spender, value);
}

DOMAIN_SEPARATOR 和 PERMIT_TYPEHASH (permit函数原型信息hash + 自定义信息)的定义在这里:

DOMAIN_SEPARATOR = keccak256(abi.encode(
     keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
     keccak256(bytes(name)),     //name为'Uniswap V2'
     keccak256(bytes('1')),
     chainId,                                //chainId为所处以太坊chainid
     address(this)
));
bytes32 public constant PERMIT_TYPEHASH 
   = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");

最开始可能会这个格式有疑问,这个在EIP712中有规定:
encode(transaction : 𝕋) = RLP_encode(transaction)
encode(domainSeparator : 𝔹²⁵⁶, message : 𝕊) = "\x19\x01" ‖ domainSeparator ‖ hashStruct(message) where domainSeparator and hashStruct(message) are defined below.
encode(message : 𝔹⁸ⁿ) = "\x19Ethereum Signed Message:\n" ‖ len(message) ‖ message where len(message) is the non-zero-padded ascii-decimal encoding of the number of bytes in message.

Note: dApp组装消息时,也要对应这个格式来操作。
EIP712

@jackalchenxu
Copy link
Owner Author

solidity中有小数吗?

准确来说,的确是有的。主要是因为Solidity中有字面量类型

整数

符号整数和无符号整数,即int256, uint256;这种类型是EVM中最最基本的,在栈上,mapping中,操作码的指针,都是该类型;由于太基本,也缩写为int,uint, 英文也有叫“Word”,熟悉就好,对得上就行。
solidity中另一个byte/字节也用的挺多。
其他的u16,i32等等类型也是有的,但它们都基于int256,uint256之上,然后(内部)做裁剪(truncate)

整数字面量可以用_来帮助我们更容易分清位数,另外solidity也支持十六进制表示的字面量整数

uint a  = 0x10_000;
int b = 12_345;

整数在做除法运算时,做整除处理,如:

uint a = 5;
uint b = 7; 
uint c = a /b;   // c的结果为 0

(字面量)小数

小数是俗称,实际上应该分成:定点数和浮点数,定点数是类似3.5这种的,solidity中支持的浮点数类似2e10, 2e-10类型的。

uint a = 1;
uint c  = 2.345e2;
uint d = 2.5 + 0.5 + a;   // ok!
uint e = 2.5 + a + 0.5;  // error!

但solidity中小数只能是字面量,如果赋值给一个变量,就像上面的uint c = 2.345e2, 此时会把2.345e2的值,234.5,截断小数点后的整数值234,赋给c。
字面量小数是不能与变量做数学运算的, 所以uint e = 2.5 + a + 0.5; // error!
但字面量与字面量本身可以做数学运算,结果为整数,可以跟变量继续做运算

不过字面量,特别是字面量小数,的确用处没有那么大,在通常的合约编程中,运算总少不了变量,有变量赋值就会发生“小数点后的被截断”,所以从某种角度说Solidity不支持小数也没错。

@jackalchenxu
Copy link
Owner Author

2天前,我同BuildBear的的开发人员谈了一下,他向我透露BuildBear未来会与github打通,可以提供
工具自动分析用户在github上的solidity合约文件,提供包括call graph等一系列的分析结果 - 我相信或许还可以一键部署在BuildBear上并测试。

今天我看了一下Surya这个工具,是的ConseSys公司工具,这不禁让我想起了当时跟我谈话BuildBear的印度小哥。
Surya

其实这个工具已经作为插件(TintinWeb)形式,提供在VS Code中了,个人觉得还是非常不错的,唯一不足的是没有体现合约中的函数,对合约本身的状态变量的影响 - 当合约用了比较多的状态变量时。

@jackalchenxu
Copy link
Owner Author

Foundry
一个非常不错的合约开发框架,除了没有区块浏览器,其他的都很不错。
特别是复现漏洞和攻击,用Foundry简直太棒了

@jackalchenxu
Copy link
Owner Author

jackalchenxu commented Nov 18, 2022

Metamask小狐狸钱包签名这件事

slowmist.medium.com/slow-mist-blank-check-eth-sign-phishing-analysis-741115bd0b1f(漫雾写的关于盲签的钓鱼事件的分析)
我的整理分析

Metamask 签名方法

因为小狐狸钱包用的最多,所以以它家为例子进行分析。
目前小狐狸钱包支持的几种签名函数(以对接以太坊RPC为例子):

  1. eth_sign
  2. personal_sign
  3. signTypedData (currently identical to signTypedData_v1),signTypedData_v1,signTypedData_v3,signTypedData_v4

RPC

跟签名相关的RPC函数就是两个:
-eth_sign
-eth_signTransaction

eth_sign(addr, message) -> Signature

  • addr, 20 Bytes - address
  • message, N Bytes - 待签的交易内容
    eth_sign返回交易签名

签名实际上是对交易内容hash(keccak256)之后的结果,即hash值做签名,格式如下:
sign(keccak256("\x19Ethereum Signed Message:\n" + len(message) + message)))

signTransaction(object, from, to, gas, gasPrice, value, data, nonce) -> SignedData

返回的SignedData可以当作raw data被sendRawTransaction()发送

link: ethereum.org/en/developers/docs/apis/json-rpc

钱包API

eth_sign

最早出现的,但从现在来看,非常不安全的,已经是要被淘汰的接口

跟后面的personal_sign一样,都是会调用RPC的eth_sign;
但是eth_sign是先生成hash,然后在metamask UI界面上展示待签的Hash值,并提供Sign和Cancel选项给用户;
这里最大的风险在于用户无法由hash值对应到实际的交易内容(plain text),所以被称为“盲签”

通常的攻击方法为:

  1. 攻击方生成交易,交易中内容为受害者转移以太币给攻击者,或转移NFT给攻击者等操作,交易签名函数为eth_sign
  2. 诱使受害者点击,此时受害人metamask显示该交易的Hash信息,弹出警示窗口,提示此操作为不安全的操作
  3. 用户忽略警告,对该交易签名,签名后的交易发送到ethernet网络
  4. 攻击者从而获取以太币,NFT,盗取用户资产

personal_sign

跟eth_sign类似,但最大的不同是,personal_sign在metamask的处理流程为先在钱包UI上展示交易内容,待用户选择了签署按钮后,再进行hash和调用sign的动作

signTypedData Vx

实现EIP-712,即结构化的交易内容展示,最主要可以解决:

  • Cheap to verify on chain
  • Still somewhat human readable
  • Hard to phish signatures
    目前小狐狸推荐使用的signTypedData_v3, signTypedData_v4正在开发中

参见:github.com//issues/28#issuecomment-1254550216


link:
docs.metamask.io/guide/signing-data.html#signing-data
github.com/WalletConnect/walletconnect-monorepo/issues/1395
github.com/MetaMask/metamask-extension/issues/9957

@jackalchenxu
Copy link
Owner Author

签名和安全方面,想深入了解,就脱离不了钱包软件,当前就是MetaMask最广泛了。

这里的连接,是一个模拟,metamask.github.io/test-dapp/

@jackalchenxu
Copy link
Owner Author

Solana合约验证问题

在网络上有看到 https://slotana.io 这个网站提供投掷硬币的游戏,网站抽水1%,网站宣称fair play, 参与者有50%的赢率(符合概率预期)

我们无法验证网站的宣传是否属实,因为Solana上的合约,到现在也没有一个好方法来验证。

通常验证合约的流程为:

  1. 获取源代码,编译出二进制,并与链上的合约二进制作对比,如果一致,表示链上的合约确由此源代码生成
  2. 接下来,我们只需要验证该源代码可信度,则可证明链上合约可信。

以太坊上合约有成熟可靠的方法来验证合约,如果要设计一个提供验证服务的网站,则服务可以设计为如下流程:

  1. 寻找一个online Solidity Compile服务,如Ethfiddler等
  2. 基于该服务,让用户输入两个信息:
  • Solidity合约代码目录(文件目录D上传)
  • 验证以太坊上某个指定地址的合约(指定地址A)
  1. 自动验证的过程包括:编译生成合约二进制文件B和附加信息(文件目录Hash值);比较A处账户data是否与B内容一致。
  2. 如果一致,则保存该文件目录,生成对应Web页面供区块浏览器跳转),并生成映射关系(合约地址A <--> D内容, Hash值)

但Solana上,目前没有一个切实可行的方法来验证合约。
个人觉得,可能有如下原因:

  • Solana一开始就没有对这部分有详细规划
  • Solana上合约开发,大多都采用第三方工具Anchor来完成,Anchor毕竟和Solana不是同一个公司/组织,统一规划和整合成问题
  • Anchor和Solidity差异大;在Anchor上实现合约验证,技术相对困难

我在这里讲述一下第3点“Solidity合约编译”。合约验证中最重要的就是保证“确定性”,即给定合约源代码(代码中还可能包含其他的库),能否保证编译后的二进制文件,与给定合约源代码存在确定性的一一映射,也就是说:

  • 源代码哪怕只要变了一个字符(如添加了一个空格符),编译后的二进制文件也会变化。
  • 如果源代码A和源代码B,使用确定性的相同编译环境,编译得到相同的二进制文件,则我们可以确定A ≡ B

Ethereum上Solidity合约编译就可以保证确定性:
每一个合约源代码都需要在代码开始处写入该合约所使用的Solc编译器版本,且编译环境仅仅由Solc编译器确定;这样就保证了源代码编译后可以得到“确定性“的目标文件。此外合约中所引用的外部库文件也有具体的版本号(当然验证某合约文件的前提是该合约所依赖的库也先被验证过)。

Solana合约编译环境所依赖的Crates,Anchor都有版本,版本信息保存在Cargo.toml中,除此之外,还有其他的文件会影响到编译过程,如toolchain.toml,和Rust编译版本号选择机制等等都存在非确定性的因素。

在Reddit上有网友讨论,也有开发者在Solana github上提过类似的问题:

Solana上目前较为可行的一种验证方法(WormHole),如果设计为一个网络服务的话,大致的设计如下:

  • 提供多个或者用户自定义的基础Docker镜像,其中应该包含某特定版本Anchor,特定版本Rustc等工具,下面的操作均以此为基础。

  • 提供Web界面让用户可以上传合约代码文件/目录,并指定Solana链上合约地址

  • 提供界面让用户可编译合约

  • 抓取Solana合约地址的data,获取编译后的合约二进制数据
    $ solana account WormT3McKhFJ2RkiGpdw9GKvNCrB2aB54gb2uV9MfQC --output json
    {
    "pubkey": "WormT3McKhFJ2RkiGpdw9GKvNCrB2aB54gb2uV9MfQC",
    "account": {
    "lamports": 1141440,
    "data": [
    "AgAAAH5VK8SIzjJfGYZa4THx+KM/cW+pztMZ/o1tqTpBMLK/",
    "base64"
    ],
    "owner": "BPFLoaderUpgradeab1e11111111111111111111111",
    "executable": true,
    "rentEpoch": 145
    }
    }
    The address of the program data is stored in the program account's data:
    00000000 02 00 00 00 7e 55 2b c4 88 ce 32 5f 19 86 5a e1
    00000010 31 f1 f8 a3 3f 71 6f a9 ce d3 19 fe 8d 6d a9 3a
    00000020 41 30 b2 bf
    Drop the 02 00 00 00 prefix (=upgradeable program) and convert the address to Base58. You'll get 9W9hLqpqjncrV1iYf2D3dt6WQMthM2SBBQ9Q5Rr6ueLn (example conversion using CyberChef). This is the account which contains the actual program data to compare to.
    Strip the metadata from the account to get the raw binary:
    solana account 9W9hLqpqjncrV1iYf2D3dt6WQMthM2SBBQ9Q5Rr6ueLn --output json |
    jq -r .account.data[0] | base64 -d | tail -c +46 | head -c -557 > wormhole-onchain.so

  • 如果一致,则保存该文件目录,生成对应Web页面供区块浏览器跳转),并生成映射关系(合约地址A <--> D内容, Hash值)
    ref: https://github.com/wormhole-foundation/wormhole-networks/blob/master/mainnetv1/info.md#contract-verification

最后因为我个人之前接触过POW,我认为,如果合约无法验证,就做不到 “Don't Trust It,Verify IT!”

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant