一、简单竞拍合约
1.合约介绍
学习的合约仍是官网提供的示例: learnblockchain.cn/docs/solidi…
参考版本:0.8.17
其中的秘密竞价(盲拍)合约部分,这里的示例分两个,简单竞拍合约和秘密竞拍合约。
先来看简单竞拍合约要实现的功能:
参与者可以调用合约进行下注,下注时通过msg.value把下注的以太币发送的合约,出价最高的人竞拍成功。当竞拍结束后,除了最高出价之外,其他参与者均可以从合约中提取走自己下注的钱。
2.测试用例
我将通过设计一组测试用例的形式来调用合约,同时验证合约功能的正确性。
- 开始竞拍后,A出价10参与竞拍,竞拍结束后,A竞拍成功,最高竞拍价为10
- 开始竞拍后,A出价10参与竞拍,B出价20参与竞拍,竞拍结束后,B竞拍成功,最高竞拍价为12,A可以取回出价,B不能取回出价
- 开始竞拍后,A出价10参与竞拍,B出价20参与竞拍,C出价30参与竞拍,竞拍结束后,C竞拍成功,最高竞拍价为15
- 开始竞拍后,A出价10参与竞拍,B出价5参与竞拍,但是B竞拍失败时发生回退“BidNotHighEnough”
- 开始竞拍后,A出价10参与竞拍,竞拍结束后,B出价12参与竞拍,发生回退返回“AuctionAlreadyEnded”
说明:
(1)官网示例合约存在少许错误,微调后可编译通过,比如有明显的命名错误,将结束时间变量统一改为“auctionEndTime”。
(2)列举出部分测试用例,是为了解合约调用情况,测试用例不具备完备性。
3.实践经验
3.1 对于返回值是布尔类型的函数处理
本次测试还是选用了hardhat框架,用JavaScript编写用例脚本,中间遇到了一个比较典型的问题,记录在此:
在处理“A可以取回出价,B不能取回出价”的断言时,发现SimpleAuction合约里的withdraw()方法虽然是返回bool类型的,但是我在js调用的时候得到的返回值却是一个json结构体:
{ hash: '0x32a476aae6d0907b067b670feb929e1857c7acfb2e81d39680b229e2afddb974', type: 2, accessList: [], blockHash: '0xac52fb24ddb59f26068375e29f804f04d8baa4ffdd8d9edf11abf7e3a05afc51', blockNumber: 5, transactionIndex: 0, confirmations: 1, from: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', gasPrice: BigNumber { value: "1517219740" }, maxPriorityFeePerGas: BigNumber { value: "1000000000" }, maxFeePerGas: BigNumber { value: "2034439480" }, gasLimit: BigNumber { value: "29021272" }, to: '0x5FbDB2315678afecb367f032d93F642f64180aa3', value: BigNumber { value: "0" }, nonce: 1, data: '0x3ccfd60b', r: '0xced15a9738a7e934119bd235ee4169f88ae8630882fdedddf92d8f8529928233', s: '0x1dc3cb18c25e3575ce61742d5682a885bd8f1ec76ceaaa291c20f063d39c1ccb', v: 0, creates: null, chainId: 31337, wait: [Function (anonymous)] }
原本以为对withdraw()的调用会返回给我一个布尔值,因此调用及结果断言中写的是:
assert.isTrue(await auctionInstance.connect(bidderA).withdraw());
assert.isFalse(await auctionInstance.connect(bidderB).withdraw());
经过一番查询,找到了相似的问题:github.com/NomicFounda…
其中,hardhat成员给出的回复如下:
我选择采用方法二,为withdraw函数增加一个event事件,定义事件如下:
// 提现事件
event Withdraw(address withdrawer, uint amount,bool status);
修改withdraw函数:
/// 取回出价(当该出价已被超越)
function withdraw() external returns (bool) {
uint amount = pendingReturns[msg.sender];
if (amount > 0) {
// 这里很重要,首先要设零值。
// 因为,作为接收调用的一部分,
// 接收者可以在 `send` 返回之前,重新调用该函数。
pendingReturns[msg.sender] = 0;
// msg.sender is not of type `address payable` and must be
// explicitly converted using `payable(msg.sender)` in order
// use the member function `send()`.
if (!payable(msg.sender).send(amount)) {
// 这里不需抛出异常,只需重置未付款
pendingReturns[msg.sender] = amount;
//+++++++新增事件+++++++
emit Withdraw(msg.sender, amount, false);
return false;
}
}
//+++++++新增事件+++++++
emit Withdraw(msg.sender, amount, true);//新增事件
return true;
}
然后在我的测试代码里增加相应的判断:
await expect(auctionInstance.connect(bidderA).withdraw()).to.emit(auctionInstance, "Withdraw").withArgs(bidderA.address, 10,true);
await expect(auctionInstance.connect(bidderB).withdraw()).to.emit(auctionInstance, "Withdraw").withArgs(bidderB.address, 0,true);
对于中标的B,auction结束的时候,B的钱会transfer到合约指定的beneficiary地址中,因此B的balance就是0了,会触发emit Withdraw(bidderB.address, 0,true)的事件,也符合原本代码中的withdraw逻辑。
3.2 更安全的资金处理方式
从这个合约里可以学到的资金处理方式: (1)参与竞拍时的出价,参与者的钱转移到合约地址上来 具体实现为bid()函数,在这里没有用参数接受出价的以太币,参与者调用bid()时,通过value传递即可,比如在测试脚本中的调用为:
await auctionInstance.connect(bidderA).bid({value:10});
/// 对拍卖进行出价,具体的出价随交易一起发送。
/// 如果没有在拍卖中胜出,则返还出价。
function bid() external payable {
// 参数不是必要的。因为所有的信息已经包含在了交易中。
// 对于能接收以太币的函数,关键字 payable 是必须的。
// 如果拍卖已结束,撤销函数的调用。
if (block.timestamp > auctionEndTime)
revert AuctionAlreadyEnded();
// 如果出价不够高,返还你的钱
if (msg.value <= highestBid)
revert BidNotHighEnough(highestBid);
if (highestBid != 0) {
// 返还出价时,简单地直接调用 highestBidder.send(highestBid) 函数,
// 是有安全风险的,因为它有可能执行一个非信任合约。
// 更为安全的做法是让接收方自己提取金钱。
pendingReturns[highestBidder] += highestBid;
}
highestBidder = msg.sender;
highestBid = msg.value;
emit HighestBidIncreased(msg.sender, msg.value);
}
(2)竞拍结束后,不主动发送,而是让非中标者提走自己的钱
这里的处理不是合约send出去钱,而是提供外部函数withdraw,让非中标者主动提钱。 可以看到在bid()函数中已经拿当前调用者msg的出价和当前最高价进行了比较,如果当前调用者的msg.value更多,会把原来的最高价记录在pendingReturns这个map中,而不做highestBidder.send(highestBid)这种主动发送的事情。非中标者调用竞拍合约对外提供的withdraw()方法,此方法先从pendingReturns中获取调用者msg应该退还的金额amount,然后将记录的pendingReturns[msg.sender]金额置为0后,再通过“payable(msg.sender).send(amount)”的方式让调用者提取到钱。如果send失败了,只需还原pendingReturns中msg.sender原有金额即可。
二、秘密竞拍合约
1.合约介绍
上面的简单竞拍合约实现上很通顺,但是由于在链上进行的任何交易都是可以查到的,因此很容易通过浏览器查看到已经发生的竞拍交易,有针对性的进行出价。但是如果想要实现盲拍,应该如何在公开的平台上进行操作呢?
实现的思路有点绕,我整理一下:
盲拍合约的设计要分为两个部分:下注阶段和披露阶段。
-
下注阶段:
- 参与者调用合约的bid函数进行出价,出价的形式为传输一个哈希值,同时发送以太币。这个哈希值是用“出价值”+“标记位”+“秘钥”进行哈希计算得到的。当标记位fake为false,并且通过msg.value发送的以太币大于等于传输参数中包含的出价值时,才算做真正的下注,其他情况为无效出价。
-
披露阶段:【出价顺序和披露顺序有关系吗?——有关,顺序不对无法提款】
- 参与者调用合约的reveal函数进行披露,需要将自己所有的出价操作集中在一次进行全部披露
- 披露时应该有序传输包含“出价值”+“标记位”+“秘钥”的有序数组,未能进行正确披露的,将无法退还竞拍投入的以太币
其他的结束拍卖和让参与者提现的函数与简单竞拍合约中的函数处理逻辑保持一致。
另外,官网提供的秘密竞拍合约代码中也是存在一定的错误,无法直接编译通过,需要做适当修改。
2.测试用例
我仍以设计一组测试用例的形式来调用盲拍合约,测试用例如下:
- 开始竞拍后,A真实出价10,B真实出价20,A再虚假出价30,B再真实出价40。进入揭示阶段,A和B都正确揭示竞拍数据,竞拍结束后,B以40的价格竞拍成功,A可以提取退款10,B可以提取退款20
- 开始竞拍后,A真实出价10,B虚假出价20,A再虚假出价30,B虚假出价40。进入揭示阶段,A和B都正确揭示竞拍数据,竞拍结束后,A以10的价格竞拍成功,A提取退款为0,B提取退款为0
- 开始竞拍后,A真实出价10,但是下注时作弊传输的value只有1,B真实出价5,下注的value也是5,进入揭示阶段,A虚假揭示自己的出价为10,B正确揭示自己的出价。竞拍结束后,B以5的价格竞拍成功,A尝试提取退款,但是所得退款金额为0
- 揭示顺序是否重要:开始竞拍后,A真实出价为100,B第一次真实出价10,第二次真实出价20,第三次虚假出价30,进入揭示阶段,A正确揭示自己的出价,B倒序排列了自己的出价,竞拍结束后,A以100的价格竞拍成功,B提取退款金额为0
- 只出价不揭示的场景:开始竞拍后,A第一次真实出价10,第二次真实出价20,B第一次虚假出价20,第二次真实出价30,C真实出价50。进入揭示阶段,A和B都正确揭示自己的出价,C没有揭示,竞拍结束后,B以30的价格竞拍成功,A提取退款30,B提取退款20,C提取退款0
3.实践经验
在用JS写测试脚本时,也遇到了一些问题:
3.1 哈希计算的方式
在官网提供的盲拍合约代码中,哈希计算的方式是:
bid.blindedBid != keccak256(value, fake, secret)
这行代码无法编译通过,我修改成了:
b.blindedBid != keccak256(abi.encodePacked(value, fake, secret))
js生成对应入参的方式:
ethers.utils.solidityKeccak256(["uint","bool","bytes32"],[valuesA[0],fakesA[0],secrets[0]])
3.2 账户余额变动校验
原本校验余额的思路为:
balanceBefore=bidderA.getBalance();
auctionInstance.connect(bidderA).withdraw();
balanceAfter=bidderA.getBalance();
assert.equal(balanceAfter-balanceBefore,expectValue)
但事实上,断言失败,因为忽略了手续费的变动。关于交易手续费的影响,可以参考这篇文章:dev.to/turboza/sol…
改进思路:
链上所有类型的交易都涉及手续费的变动,在做余额变更断言时,可以统一把手续费的变动给统一排除掉,仅关心交易实际金额的变动,这个思路应该已经有现成的方法可用,查看hardhat文档,发现hardhat-chai-matcher断言工具已经完成了这层封装。
使用这个工具进行断言的方法如下:
(1)安装包
看hardhat-chai-matcher这个包的文档,引入方法为npm命令行安装:npm install --save-dev @nomicfoundation/hardhat-chai-matchers,安装执行完成后可以看到在工程目录的package.json下自动添加了最新的包
(2)引入方法
在需要用到校验交易行为前后账户金额变动的脚本里,加上引入语句:
const { changeEtherBalance } = require("@nomicfoundation/hardhat-chai-matchers");
(3)使用断言
如下使用,将有changeEtherBalance方法封装了手续费变化的判断,调用时仅关系交易金额的变动即可。
await expect(auctionInstance.connect(bidderA).withdraw()).to.changeEtherBalance(bidderA,valuesA[0]);
await expect(auctionInstance.connect(bidderB).withdraw()).to.changeEtherBalance(bidderB,valuesB[0]);