9fcfa2fb302a1ed14596be222f520d03.png

前言

本文要求你对Solidity开发有一点基础的了解。

我会使用Solidity开发一个简单的众筹智能合约,合约的主要功能就是向希望给我捐赠的人收钱,然后我再将钱从合约账户里取出来。

因为程序简单,这里使用Remix在线IDE进行智能合约的开发。

基本结构

打开Remix后,在contracts文件夹中创建「FundMe.sol」的文件,在FundMe.sol中,通过solidity写入捐款的代码,如下:

// SPDX-License-Identifier: MIT

pragma solidity ^0.6.0;

contract FundMe {
    
    // 通过address可以找到uint256
    mapping(address => uint256) public addressToAmountFunded;
    
    function fund() public payable {
        // 记录消息发送者的地址和其发送的金额 
        addressToAmountFunded[msg.sender] += msg.value;
    }
}

solidity使用//来表示注释,这点与JavaScript相同,这里第一行注释主要用于声明你代码的开源协议,通常使用MIT协议。

然后通过pragma关键字指定这次编写智能合约时,要使用的版本,这里使用0.6.0以上的版本,注意^0.6.0表示大于等于0.6.0小于0.7.0,即你不可以使用大于0.7.0版本的solidity编译器来编译该文件。

然后通过contract关键字来定义智能合约主体,这类似于Java中class的概念,在contract中,我们可以定义变量,也可以定义函数(function)。

之所以说它像Java中的class而不是Python中的class,是因为Solidity对函数与变量提供了四种可见性的关键字,分别是:

  • public:可以在合约内部或外部进行调用

  • interal:函数只能在合约内部调用,类似于Java的protected

  • external:函数只能在合约外部被调用

  • private:只能在当前合约内使用,继承的合约也不可访问。

回到代码,在contract中,通过mapping关键字,定义了mapping结构,其实就是key->value的结构,mapping(address => uint256)表示key的类型是address(地址),value的类型是uint256(无符号整型)。

通过function关键字,定义了一个名为fund的函数,使用public让函数暴露出去,任何人都可以调用该方法,因为要实现转账,所以还需要使用payable关键字,该关键字会将函数变为转账函数,即调用该方法的账号可以实现将自己账号中的钱转到智能合约的账号上。

为了方便知道谁对这个合约转了账,转了多少钱,这里通过msg.sender获得调用该函数的人以及通过msg.value方法获得调用者要转多少钱。

solidity中之所以使用msg关键字来获取调用者信息,是因为调用一次智能合约中的函数,相当于发送一次消息。

代码弄到Remix上,如下图:

d68ca941e535de508bc7ff7fc4d7f982.png

使用Remix的好处就是,你不需要考虑编译部署的问题,Remix已经提供了相关的功能,首先看一下编译,如下图:

7a91fecd6125551433559cd2d551adb7.png

COMPILER需要选择大于等于0.6.0小于0.7.0的,然后点击Compile FundMe.sol就完成编译了。

编译完后,我们还需要将这个智能合约部署到链上,如下图:

41b7f398066bde690afe8829d426bad5.png

上图中,ENVIRONMENT选择了JavaScript VM,即部署到本地由JavaScript VM虚构的区块链网络中,这通常是必要的一步,用于对智能合约中的逻辑进行测试,然后选择要部署的合约,即CONTRACT选项,因为FundMe.sol中只有一个合约,所以暂时无需关心。点击Deploy,则完成部署,如下图:

391d26bf3e3625664d14bd4a104be8c2.png

从图中可以看出,部署后,我们可以获得智能合约的地址,以及我们通过public暴露出的函数和变量,其中fund函数是红色按钮,因为有payable关键字,该方法与支付相关。

普通的变量或方法是蓝色的,但还有一种颜色的按钮,我们增加一些代码来看看,如下图:

57e89ec1dad936b97499408e650321cb.png

上图中,通过struct关键字定义了一个新的对象,新的对象有自己的属性,基于People对象我们定义了people列表,然后定义了addPerson函数,该函数会接收_name与_age参数,然后使用传入的参数实例化People对象并将实例化后的对象添加到people列表中。

编译部署后,会发现addPerson函数是橙色的,这些颜色之间有何本质区别?

对于不需要改变区块链状态的函数都是蓝色的,比如读取区块链中的数据这个行为不会改变区块链的状态,所以addressToAmountFunded与people是蓝色的,因为不会改变区块链的状态,所以在调用蓝色按钮对应的函数或变量时,是不需要消耗Gas的(即不用给区块链网络中上块的节点手续费)。

而橙色和红色的按钮,表示调用这个函数会改变区块链的状态,比如addPerson函数会向区块链中新增数据,fund函数会产生交易等。

红色按钮另外一个含义就是,对应的方法是与交易相关的。

删掉People相关的逻辑,继续编写代码。

获取链下信息

我希望众筹一定金额的美元,那么我就需要将他人在以太坊上给我转的ETH换算为USD,这就引出了一个问题,我们怎么获得ETH/USD的汇率?

传统的软件开发中,通常可以通过调用接口的方式来获取这些外部的数据,所以算不上是一个问题,但在区块链网络中,网络中的节点是不允许主动调用外部的API获取数据的,其原因是因为区块链网络是分布式架构且要求每个节点的状态是相同的,如果你的智能合约中有调用外部API的情况,那么分布在世界各地的节点在调用这个API时,可能因为调用时间、调用地区的不同,而获得不同的返回结果,从而导致网络中节点最终的状态不一致,如下图所示:

b6172043f1ecc732d30f56e39a90a222.png

对于智能合约而言,也是这样,不同节点执行相同合约在相同传入参数的情况下,不论时间如何推移、地区如果改变、网络情况如何,都要获得相同的执行结果,要达到这样的效果,主动调用外部API的方法就是不被允许的。

解决这个问题的方案称为Oracle(预言机),预言机会将现实世界(链下(off-china))中的数据与区块链相链接,从而实现让智能合约可以使用真实世界中数据的效果。

目前很火的DeFi(去中心化金融)就离不开Oracle,因为多数DeFi应用都需要知道现实世界中汇率等基本的金融信息。

Oracle技术细节挺多,也是个比较大的技术话题,下次我实践后再讨论。

回到我的众筹智能合约,我需要知道ETH/USD,就需要找一个提供这种服务的Oracle,这里选择ChainLink提供的Oracle服务。目前Oracle有不同的提供商,提供商的可信度很重要,因为数据来源有问题,那么智能合约执行的结果必然有问题,曾经出现过,Oracle被黑,导致巨大金融损失的事件。

阅读ChianLink提供的文档:https://docs.chain.link/docs/get-the-latest-price/,将相应的方法导入则可:

import "@chainlink/contracts/src/v0.6/interfaces/AggregatorV3Interface.sol";

需要注意,文档里提供的AggregatorV3Interface.sol是v0.8的,我们需要改成v0.6,因为我们开发智能合约的solidity是0.6.0版本。

import关键字与传统编程语言的import相同,它可以将对应文件的代码导入,这里直接导入远程npm库中对应的sol文件。

ChianLink将相应的代码提供到了Github上,阅读Github上的代码,发现AggregatorV3Interface是个interface(接口),接口的主要作用就是供智能合约调用的,只要通过import关键字导入了AggregatorV3Interface.sol,该interface下的函数就可以直接使用了,代码如下:

function getPrice() public view returns(uint256) {
        // 实例化AggregatorV3Interface,传入对应网络的地址
        AggregatorV3Interface priceFeed = AggregatorV3Interface(0x8A753747A1Fa494EC906cE90E9f37563A8AF630e);
        // 调用接口中的方法latestRoundData,获得ETH/USD的比值
        (
          uint80 roundId,
          int256 answer,
          uint256 startedAt,
          uint256 updatedAt,
          uint80 answeredInRound
        ) = priceFeed.latestRoundData();
        return uint256(answer * 10000000000);
    }

编译时,会发现,Remix报了一下warning,如下图:

1127c380dc062d04a9d649c81e77be2b.png

warning级别的警告,我们是可以不用理会的也可以正常编译使用的,这里的warning表示,你有一些变量定义了但没有使用,我们可以简单改进一下,如下图:

3f7a949aade5b2eb05453c03e71f2ba7.png

简单而言,不需要的值,我们接受返回后,直接忽略它则可。

此外,getPrice函数定义时还使用了view关键字,view表示这个函数不会改变区块链状态,只是从中读取信息,即调用该方法不需要花费Gas。

部署到Rinkeby测试网络

我们在实例化AggregatorV3Interface时,传入了一个地址,我们可以在ChainLink文档中找到它:https://docs.chain.link/docs/ethereum-addresses/。

需要注意的是,文档中针对不同的以太坊网络都提供了对应的地址,这里我们选择Rinkeby测试网络.

79ffb37515193c2b0d9efb2f0fd7a4f0.png

在一开始,我们通过Remix部署时,部署环境是JavaScript VM环境,即本地虚构的以太坊环境,现在你需要使用ChinaLink提供的ETH/USD预言机了,这个预言机不可能部署到你本地环境中,所以你需要连接上真正的网络。

通常,在开发智能合约时,会先在JavaScript VM测试,然后上测试网络测试,最后才是上真正的以太网主网。

以太坊提供多个测试网络,这些测试网络使用的共识机制有所不同,但通常不影响智能合约的运行,这里使用Rinkeby测试网络,主要原因是,Rinkeby网络比较好通过水龙头拿到测试的ETH

不同于部署到本地的JavaScript VM,上测试网络是需要花费Gas的,而我们测试网络的账号目前没有ETH,我们需要通过水龙头获取一些ETH以供测试,这里使用https://faucet.rinkeby.io/(按格式发twitter,则会在对应的账号中获得测试用的ETH)。

847b232b6dfea5e1dd8f7edb6a483e72.png

回到Remix,在部署处,将ENVIRONMENT由JavaScript VM改为Injected Web3,第一次设置,Remix会唤醒MetaMask,需要其中的账号进行授权,因为部署需要花费Gas。

此外,因为现在import了AggregatorV3Interface,所以部署时需要选择部署的是FundMe的contract。

b0507c57d61fae26029a6edee3f2972a.png

点击部署后,会唤醒MetaMask,MetaMask会告诉你,当前部署这个合约需要花费多少Gas,你点击确定,则开始部署。

d439c762a23a6be65c512ae59750bf42.png

Rinkeby网络与主网没有特别大的差别,部署也是需要等待矿工节点将合约数据入块。

部署完后,可以发现多了一个getPrice方法的按钮,点击便可以获得ETH/USD的汇率。

4cbda7e5a309f7e73036c15728adcc61.png

最低转账要求

因为每次捐赠都会产生一次交易,这需要花费相应的Gas,如果捐赠的金额太小,比如小于Gas的成本,那么每次捐赠都在亏钱。

这里拍脑袋选个值,比如少有100美元就拒绝其转账申请。

要实现这个功能,我们需要将用户捐赠的ETH转成的美元单位,然后与100美元做对比,如果小于,则拒绝转账,避免损失,调整后的代码如下:

// SPDX-License-Identifier: MIT

pragma solidity ^0.6.0;

import "@chainlink/contracts/src/v0.6/interfaces/AggregatorV3Interface.sol";

contract FundMe {
    
    mapping(address => uint256) public addressToAmountFunded;
    address[] public funders;
    
    function fund() public payable {
        // 最少100美元
        uint256 minimumUSD = 100 * 10 ** 18;
        // require关键字其作用类似于if
        // 如果msg.value(用户捐赠的钱)大于100美元,才会继续执行,否则则报错,且返回「You need to spend more ETH!」
        require(getConversionRate(msg.value) >= minimumUSD, "You need to spend more ETH!");
        addressToAmountFunded[msg.sender] += msg.value;
        // 记录捐赠者
        funders.push(msg.sender);
    }
    
    function getConversionRate(uint256 ethAmount) public view returns (uint256) {
        // 获得1ETH可以兑换多少美元
        uint256 ethPrice = getPrice();
        // 获得 ethAmount ETH 可以兑换多少美元
        uint256 ethAmountInUsd = (ethPrice * ethAmount) / 1000000000000000000;
        return ethAmountInUsd;
    }
    
    function getPrice() public view returns(uint256) {
        AggregatorV3Interface priceFeed = AggregatorV3Interface(0x8A753747A1Fa494EC906cE90E9f37563A8AF630e);
        (
          ,
          int256 answer,
          ,
          ,
        ) = priceFeed.latestRoundData();
        return uint256(answer * 10000000000);
    }
}

部署到Rinkeby网络中,然后测试一下。

首先我们转很少的钱,比如100wei,会发现Remix会直接提示我们,这个交易会失败

805a34dc120eaaee645cc1b145b2b02f.png

Remix的提示交易会失败,我们继续点击「Send Transaction」。

188244ee3ba525c3cc752b133a36073b.png

点击完「Send Transaction」后,MetaMask会被唤醒,点击确定,等待一段时间,会发现交易失败:

cb5f21f28189912b0f21d56eb4b93efd.png

我们可以通过MetaMask查看这次交易失败的详情,当然也可以直接通过Etherscan网站去搜索这次交易,从而获得此次交易的明细。

1c8020fd1185b309aeea081a54e9d838.png

然后我们再试一下转100000000gwei(即0.1eth)。

60b2f646a23fe2e0533c9f8b8e639a61.png

点击确定则可,完成交易后(交易是需要时间的),再通过addressToAmountFunded和funders便可以看到相应的数据。

ed6b3e4d7fcf8332c4161f39af6b5add.png

从智能合约中获取金额

通过前面的代码,已经实现了他人调用fund方法,向当前合约地址转钱的功能,接着我们需要从合约里取钱,代码如下:

function withdraw() payable public {
        // 将钱从合约地址传到调用withdraw函数的账号中。
        msg.sender.transfer(address(this).balance);
    }

通过msg.sender.transfer实现转账,通过address(this).balance获得当前合约中所有的钱,但这里有个致命的问题,那便是任何人都可以调用这个withdraw函数将合约中的钱转走。

我们需要增加限制,只有合约的部署的账号才能从合约中转走这些钱,我们可以通过构造函数来存储合约部署时账号地址,构造函数只有在合约部署时会运行一次,非常适合这个需求,代码如下:

address public owner;
    
    // 构造函数 - 在合约部署时运行且只运行那一次
    constructor() public {
        owner = msg.sender;
    }

对以太坊网络而言,我们部署合约的行为其实也是产生一个交易信息的行为,这个行为也需要发起账号,即代码中的msg.sender,也需要花费Gas。

有owner后,修改一下withdraw方法。

// 修饰符
    modifier onlyOwner() {
        // 满足require条件,才能继续执行
        require(msg.sender == owner);
        // 被修饰方法的代码
        _;
    }
    
    function withdraw() payable onlyOwner public {
        // 转账 
        msg.sender.transfer(address(this).balance);
        
        // 使用for循环,置空addressToAmountFunded中的值
        for (uint256 i=0; i < funders.length; i++) {
            address funder = funders[i];
            addressToAmountFunded[funder] = 0;
        }
        // 置空funders数组(以创建新的空数组的方式置空它)
        funders = new address[](0);
    }

上述代码中,modifier(修饰符)关键字的作用与Python的装饰器很像,可以动态调整被修饰的函数。

withdraw函数使用了onlyOwner,实现只有合约部署人才能取走合约里钱的限制。

试验一下,先从账号中转1ETH到合约中,如下图所示,Account1有18.x ETH减少为17.x ETH

8e6546dcc98bb52cda74e846b3feaad5.png

a8024e3a361762780034c9904949a3b5.png

然后创建一个名为「恶意账号」的新账号,然后连接到Remix中。

99e6ac2406f0a3024e07c17ee128b188.png

因为withdraw需要花费Gas,所以我们从Account1中转1ETH到恶意账号中,此时Account1的ETH剩余16.x ETH,合约里有1ETH,恶意账号里有1ETH,然后我们使用恶意账号取出合约中的钱。

提款失败,Gas也消耗了本金,如下图:

4c9a50335d5c0116629da8984793b81f.png

如果使用Account1,则可以正常取出合约中的钱。

OverFlow与UnderFlow

如果你习惯使用Python/JavaScript之类的high-level编程语言,你通常不会遇到OverFlow与UnderFlow问题,如果你经常使用C/C++,OverFlow与UnderFlow是你常需关注的问题。

Solidity其实算是high-level 编程语言,但它考虑到智能合约在运行时需要消耗Gas,它提供了很多low-level的变量,如uint8等,智能合约可以使用uint8减少运行时需要占用的内存空间,从而减少Gas的消耗,但这就容易出现OverFlow与UnderFlow的问题。

举一个具体的例子:

假设我有uint8类型的变量,它只能有8位。这意味着我们能够存储的最大数字是二进制11111111(或者十进制2 ^ 8-1 = 255),那么下面的代码,number会是多少?

uint8 number = 255;
number++;

上述代码其实会出现OverFlow,number会从255变为0,而不是期望的256,其背后的原因是:你把1加到二进制11111111,它会重置回00000000,就像一个时钟从23:59到00:00。

UnderFlow与之类似,如果你从等于0的 uint8中减去1,它将显示255(因为 uint 是无符号的,所以不能是负数)。

因为Solidity有这个问题,所以在编写与钱相关的智能合约时要小心。因为这个挺重要的,在Solidity 0.8.0以及以上的版本,Solidity已经fix了这个问题,而0.8.0以下的Solidity,尽量使用uint256,因为2 ^ 256这样的值真的是一个很大的数字,此外还可以使用SafeMathChainlink Library(库)。

Library与contract很像,不同之处在于,Library部署在区块链网络固定的地址,方便不同的合约反复使用。

SafeMathChainlink Library的主要作用就是避免OverFlow与UnderFlow的问题,在合约一开始通过using关键字使用一下则可。

至此,众筹智能合约便完成了,完整代码如下:

// SPDX-License-Identifier: MIT

pragma solidity ^0.6.0;

import "@chainlink/contracts/src/v0.6/interfaces/AggregatorV3Interface.sol";
import "@chainlink/contracts/src/v0.6/vendor/SafeMathChainlink.sol";

contract FundMe {
    using SafeMathChainlink for uint256;
    
    mapping(address => uint256) public addressToAmountFunded;
    address[] public funders;
    address public owner;
    
    constructor() public {
        owner = msg.sender;
    }
    
    function fund() public payable {
        uint256 minimumUSD = 100 * 10 ** 18;
        require(getConversionRate(msg.value) >= minimumUSD, "You need to spend more ETH!");
        addressToAmountFunded[msg.sender] += msg.value;
        funders.push(msg.sender);
    }
    
    function getConversionRate(uint256 ethAmount) public view returns (uint256) {
        uint256 ethPrice = getPrice();
        uint256 ethAmountInUsd = (ethPrice * ethAmount) / 1000000000000000000;
        return ethAmountInUsd;
    }
    
    function getPrice() public view returns(uint256) {
        AggregatorV3Interface priceFeed = AggregatorV3Interface(0x8A753747A1Fa494EC906cE90E9f37563A8AF630e);
        (
          ,
          int256 answer,
          ,
          ,
        ) = priceFeed.latestRoundData();
        return uint256(answer * 10000000000);
    }

    modifier onlyOwner() {
        require(msg.sender == owner);
        _;
    }
    
    function withdraw() payable onlyOwner public {
        msg.sender.transfer(address(this).balance);
        
        for (uint256 i=0; i < funders.length; i++) {
            address funder = funders[i];
            addressToAmountFunded[funder] = 0;
        }
        funders = new address[](0);
    }   
    
}

最后

喜欢的话记得点「在看」呀。我是二两,下篇文章见。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐