实战练习Solidity(3)——远程购买合约

154 阅读5分钟

一、合约详情

继续官网合约示例的学习——安全的远程购买合约learnblockchain.cn/docs/solidi… 参考版本:0.8.17

目前来说,我们最熟悉的远程购买场景应该就是通过电商购买的场景了,就是一个很典型的买家和卖家相互独立,且需要远程进行交易过程的。这个过程中最需要解决的就是“信任”问题,因为远程,因此无法做到一手交钱一手交货。在我们熟悉的电商场景中,最初是支付宝作为一个值得信任的第三方支付担保平台把这个“信任”场景解决了,用户购买商品的钱,不是直接打款给商家,而是先转给这个第三方支付担保平台,当用户收到货品后确认收货,支付宝才会把应付款转给商家。

我们可以很自然的联想到把上述支付宝承担的角色转移给智能合约,在商家发货和用户收货中间这段时间,资金会存在智能合约里,一旦一方作假或违约,将会受到资金损失,从而促使买卖双方都遵循约定履行责任。

合约的主要设计逻辑为:

  • 卖家部署合约,并传入2*value,初始化合约的状态控制变量(这里没有明文写,可能enum数组默认取第一个元素吧)
  • 买家购买时,也传入2*value
  • 买家确认收货时,可以得到返还的1个value
  • 等到买家收到货品之后,卖家一共可以取得3个value

合约代码如下(官网示例):

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

contract Purchase {
    uint public value;
    address payable public seller;
    address payable public buyer;

    enum State { Created, Locked, Release, Inactive }

    State public state;

    /// Only the buyer can call this function.
    error OnlyBuyer();
    /// Only the seller can call this function.
    error OnlySeller();
    /// The function cannot be called at the current state.
    error InvalidState();
    /// The provided value has to be even.
    error ValueNotEven();
    
    modifier condition(bool condition_) {
        require(condition_);
        _;
    }

   
    modifier onlyBuyer() {
        require(
            msg.sender == buyer,
            "Only buyer can call this."
        );
        _;
    }

    modifier onlySeller() {
        require(
            msg.sender == seller,
            "Only seller can call this."
        );
        _;
    }

    modifier inState(State _state) {
        require(
            state == _state,
            "Invalid state."
        );
        _;
    }

    event Aborted();
    event PurchaseConfirmed();
    event ItemReceived();
    event SellerRefunded();

    //确保 `msg.value` 是一个偶数。
    //如果它是一个奇数,则它将被截断。
    //通过乘法检查它不是奇数。
    constructor() payable {
        seller = payable(msg.sender);
        value = msg.value / 2;
        if ((2 * value) != msg.value)
            revert ValueNotEven();
    }


    ///中止购买并回收以太币。
    ///只能在合约被锁定之前由卖家调用。
    function abort()
        external
        onlySeller
        inState(State.Created)
    {
        emit Aborted();
        state = State.Inactive;
        seller.transfer(address(this).balance);
    }

    /// 买家确认购买。
    /// 交易必须包含 `2 * value` 个以太币。
    /// 以太币会被锁定,直到 confirmReceived 被调用。
    function confirmPurchase()
        external
        inState(State.Created)
        condition(msg.value == (2 * value))
        payable
    {
        emit PurchaseConfirmed();
        buyer = payable(msg.sender);
        state = State.Locked;
    }

    /// 确认你(买家)已经收到商品。
    /// 这会释放被锁定的以太币。
    function confirmReceived()
        external
        onlyBuyer
        inState(State.Locked)
    {
        emit ItemReceived();
        // It is important to change the state first because
        // otherwise, the contracts called using `send` below
        // can call in again here.
        state = State.Release;

        buyer.transfer(value);
    }

    /// This function refunds the seller, i.e.
    /// pays back the locked funds of the seller.
    function refundSeller()
        external
        onlySeller
        inState(State.Release)
    {
        emit SellerRefunded();
        // It is important to change the state first because
        // otherwise, the contracts called using `send` below
        // can call in again here.
        state = State.Inactive;

        seller.transfer(3 * value);
    }
}

二、用例设计

我仍将进行一组简单的测试用例设计,用来调用此合约,并验证合约功能的正确性:

  1. B部署合约后,此时合约状态为Created,A确认购买时传入2倍val,此时合约的状态应该为Locked,A的账户余额减少2个val。然后进行确认收货,此时合约的状态应该为Release,A的账户余额增加1个val。B再进行提款操作,此时合约的状态为Inactive,B的账户增加3个val
  2. B部署合约后,此时合约状态为Created,再调用abort()函数,合约状态变为Inactive
  3. B部署合约后,此时合约状态为Created,A确认购买时传入2倍val,此时合约的状态应该为Locked,A的账户余额减少2个val。此时B进行提款操作,提款失败返回Invalid state
  4. ……(可补充更多用例)

针对上述第一个用例,用hardhat写一个例子:

const { ethers } = require("hardhat");
const { expect, assert, AssertionError } = require("chai");
const hre = require("hardhat");
const { loadFixture } = require("@nomicfoundation/hardhat-network-helpers");
const { changeEtherBalance } = require("@nomicfoundation/hardhat-chai-matchers");
const { isCallTrace } = require("hardhat/internal/hardhat-network/stack-traces/message-trace");

async function beforeTest(){
    const [a, b] = await ethers.getSigners();
    const val=2000;
    const purchase = await hre.ethers.getContractFactory("Purchase");
    
    return{purchase,a,b,val}  
}

describe("Purchase的测试用例集", function () {
    it("B部署合约后,此时合约状态为Created,A确认购买时传入2倍val,此时合约的状态应该为Locked,A的账户余额减少2个val。然后进行确认收货,此时合约的状态应该为Release,A的账户余额增加1个val。B再进行提款操作,此时合约的状态为Inactive,B的账户增加3个val", async function () {   
        
        const {purchase,a,b,val}= await loadFixture(beforeTest);
        const purchaseInstance=await purchase.connect(b).deploy({value:val*2});
        assert.equal(await purchaseInstance.state(),0);
        await expect (purchaseInstance.connect(a).confirmPurchase({value:val*2})).to.changeEtherBalance(a,-val*2);
        assert.equal(await purchaseInstance.state(),1);
        await expect (purchaseInstance.connect(a).confirmReceived()).to.changeEtherBalance(a,val);
        assert.equal(await purchaseInstance.state(),2);
        await expect (purchaseInstance.connect(b).refundSeller()).to.changeEtherBalance(b,val*3);
        assert.equal(await purchaseInstance.state(),3);
    });
});

三、实战经验

  1. 合约部署时可传入value,了解到合约部署时也是和其他任何交易一样,可以传入{value:xxx}的,比如本例中用到的:
await purchase.connect(b).deploy({value:val*2});
  1. 合约中状态变量的定义为:
enum State { Created, Locked, Release, Inactive } 
State public state;

合约的构造函数定义为:

constructor() payable {
        seller = payable(msg.sender);
        value = msg.value / 2;
        // state = State.Created;
        if ((2 * value) != msg.value)
            revert ValueNotEven();
    }

在部署合约时,应该就为合约状态赋值为Created,而本例中构造函数在没有显式赋值的情况下,部署完成后合约的state也是Created。说明定义了一个enum的State,这个State的实例默认值为第一个枚举元素。

  1. 在对enum类型变量进行断言时,一开始误以为其值应该是枚举值的字符串,实际上它的值不是enum本身这个字符串,而是在enum数组中的位置比如,Created的值就是0,用console.log去打印State实例的值时,得到的也是它在enum数组中的位置。