OneSwap Series 14 - Tips for Using Truffle

OneSwap
6 min readNov 12, 2020

This series of articles summarizes in detail the various practical experiences and skills learned/accumulated during the development of the OneSwap project, and emphasizes one point: It is not difficult to develop smart contracts on Ethereum using the Solidity programming language, yet it is not easy to develop gas-efficient and bug-free smart contracts. If that is your goal, in addition to following the best practices and techniques we introduced earlier, it is also necessary to fully test the code, and an effective tool can greatly facilitate the development. Truffleis such a one-stop tool, which integrates many functions such as dependency management, unit testing, project build, project deployment, and a wealth of third-party plug-ins. In view of Truffle’s comprehensive documentation, this article will not focus on its basic usage. Instead, it will offer some tips for using Truffle in unit testing for OneSwap.

Test exceptions

We have introduced the Guard Check pattern in the article “Application of Common Solidity Patterns in OneSwap” and discussed in detail the usage and implementation principles of the three built-in functions assert(), require(), and revert(). This pattern is extensively used in OneSwap for various checks. Therefore, in addition to testing the normal paths during unit testing, these abnormal paths are also tested.

The OneSwap project mainly uses the built-in require()function, and the assert()function in a few places. Both of these built-in functions are implemented with the REVERT instruction. To facilitate testing, the OneSwap project defines the revert()function in the test/exceptions.js file. The code is as follows:

constPREFIX="Returned error: VM Exception while processing transaction: ";

module.exports.revert=asyncfunction(promise, errMsg) {
leterrType="revert";
try{
awaitpromise;
thrownull;
} catch(error) {
assert(error, "Expected an error but did not get one");
assert(error.message.startsWith(PREFIX+errType), "Expected an error starting with '"+PREFIX+errType+"' but got '"+error.message+"' instead");
assert.include(error.message, errMsg);
}
};

With this helper function, exception testing is very simple. For example, the following is a test in test/LockSend.js:

it("locksend should failed if amt is 0", async() =>{
awaitrevert(lock.lockSend(account_two, 0, token.address, unlock_time),
"LockSend: LOCKED_AMOUNT_SHOULD_BE_NONZERO");
});

Skip block time

Generally speaking, the execution of the contract should not depend on the block time (because miners can adjust the block time arbitrarily within a certain range). But for some simple logic that only depends on coarse-grained time, judging the block time is not a big deal, and the code can be clear. For example, in the OneSwap project:

  • The LockSend contract uses the block time to determine the unlock time of the locked transfer
  • The SupervisedSend contract uses the block time to determine the unlock time of the supervised transfer
  • The OneSwapGov contract uses block time to determine the expiration time of proposal voting and the interval time of text proposals
  • The OneSwapRouter contract uses block time to determine the deadline of a swap or pending orders

Taking the OneSwapRouter contract as an example, the judgment of the deadline is encapsulated in a custom ensure()modifier:

modifier ensure(uint deadline) {
// solhint-disable-next-line not-rely-on-time,
require(deadline >= block.timestamp, "OneSwapRouter: EXPIRED");
_;
}

However, how to test the logic that depends on block time remains a problem. Fortunately, Truffle Ganacheprovides several additional RPC interfaces, allowing us time travel. The OneSwap project defines functions such as advanceTime()in the test/time_utils.js file. Part of the code is as follows (see https://gist.github.com/ejwessel/76767933280be5cbc59816dae6742661#file-utils-jsfor details):

advanceTime=(time) =>{
returnnewPromise((resolve, reject) =>{
web3.currentProvider.send({
jsonrpc: '2.0',
method: 'evm_increaseTime',
params: [time],
id: newDate().getTime()
}, (err, result) =>{
if(err) { returnreject(err) }
returnresolve(result)
})
})
}
...// Other code omitted

module.exports={
advanceTime,
advanceBlock,
advanceTimeAndBlock,
takeSnapshot,
revertToSnapShot
}

With these functions, testing the logic dependent on block time becomes simple. Taking the OneSwapGov contract as an example, the following is a test case of the ballot counting logic:

contract("OneSwapGov/tally/failed", (accounts) =>{

before(deployGov);
before(async() =>{ snapshotId=(awaittakeSnapshot())['result']; });
after(async() =>{ awaitrevertToSnapShot(snapshotId); });

it('tally failed: STILL_VOTING', async() =>{
awaitones.approve(gov.address, 2000000);
awaitgov.submitFundsProposal(TITLE, DESC, URL, accounts[9], 0, 1000001);
awaitrevert(gov.tally(), "OneSwapGov: STILL_VOTING");
awaitadvanceTime(VOTE_PERIOD-10);
awaitrevert(gov.tally(), "OneSwapGov: STILL_VOTING");
});
it('tally failed: FINISHED', async() =>{
awaitadvanceTime(11);
awaitgov.tally();
awaitrevert(gov.tally(), "OneSwapGov: NO_PROPOSAL");
});

});

Test event release

We have introduced the usage and implementation principles of Event in detail in articles such as “Every Contact Leaves a Trace: In-chain and Out-of-chain Interaction”. In the OneSwap project, events are also used extensively to transmit various information out from the chain. Considering the importance of these events (especially for the order book), it is also necessary to test the function that publishes the event in the unit test to see if these functions publish the event normally, and check the event fields. Compared with the test of abnormal conditions and block time, the event test is simpler, and we only need to check the result of the contract call. For example, the following is a test case of OneSwapPair contract:

it('addMarketOrder/sell: event', async() =>{
awaitbtc.transfer(pair.address, 20000000000, {from: boss});
constresult=awaitpair.addMarketOrder(btc.address, taker, 20000000000, {from: taker});
assert.deepEqual(getLog(result, "NewMarketOrder", decodeNewMarketOrderLog), {
isBuy: false,
amount: 20000000000n,
addrLow: BigInt(taker) &0xffffffffffffffffffffffffffffffffffn,
});
});

The getLog()function looks for the corresponding event in the result. The decodeNewMarketOrderLog()function decodes the specific event of this test. Below are the codes of these two functions:

functiongetLog(result, eventType, decoder) {
constlog=result.logs.find(log=>log.event==eventType);
assert.isNotNull(log, "log not found: "+eventType);
returndecoder(log);
}
functiondecodeNewMarketOrderLog(log) {
constdata=BigInt(log.args.data);
return{
isBuy: (data&0xffn) >0n,
amount: (data>>8n) &0xffffffffffffffffffffffffffffn,
addrLow: data>>120n,
}
}

Test gas consumption

Although it is not necessary to accurately test the gas consumption in unit testing, we still need to construct some scenarios to observe the gas consumption of certain operations. By doing so, we can see the overall gas consumption of certain operations for better knowledge or further optimization. There are two ways to test gas consumption:

  • Use the estimateGas() function to estimate the gas consumption of contract execution, or
  • Execute the contract normally, and get the real gas consumption through result.receipt.gasUsed

The second method is mainly used in the OneSwap project. For example, the OneSwapPair contract tests the gas consumption in various situations. The following is one of the test cases:

it("insert sell order with 0 deal", async() =>{
awaitbtc.approve(boss, 1000000000, {from: maker});
awaitusd.approve(boss, 1000000000, {from: maker});
awaitbtc.transferFrom(maker, pair.address, 100, {from: boss});
letresult=awaitpair.addLimitOrder(false, maker, 100, makePrice32(10000000, 18), 1, merge3(0, 0, 0), {from: maker})
console.log("gas on first insert: ", result.receipt.gasUsed);
...// Other code omitted
});

Improve test coverage

Test coverage is an important indicator to measure the quality of a project. Checking for deficiencies in the test coverage report is an effective way to improve test coverage. Truffle does not have a built-in test coverage report tool, but solidity-coverage makes up for this defect. For the installation and use of solidity-coverage, you can refer to its documentation. Here we only introduce how to find uncovered statements, branches, or situations according to the test report.

First of all, we can have a good grasp of the overall coverage (the SupervisedSend contract has not been officially launched, so no test has been added):

If there are unexecuted statements, they will be marked with a pink background. E.g:

If there is an unentered ifbranch, it will be marked with an 🅸; if there is an unentered elsebranch, it will be marked with an 🅴. E.g:

Based on these tips, we can make up for the missing tests effectively.

Summary

To write gas-efficient and bug-free smart contracts with the Solidity language, an effective tool is as important as the rigorous code. This article briefly introduces the one-stop tool Truffle, focusing on some tips that the OneSwap project has drawn on/accumulated in unit testing with Truffle.

Reference

--

--

OneSwap

A fully decentralized exchange protocol on Smart Contract, with permission-free token listing and automated market making.