Skip to content

Commit 4f2bd9b

Browse files
Add fuzz tests for trade flow. Random and semi random values for trade inputs.
Co-authored-by: Ilan Doron <[email protected]>
1 parent 84fbda1 commit 4f2bd9b

File tree

8 files changed

+1611
-24
lines changed

8 files changed

+1611
-24
lines changed

contracts/sol6/mock/MockReserve.sol

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ contract MockReserve is IKyberReserve, Utils5 {
2222
sellTokenRates[address(token)] = sellRate;
2323
}
2424

25+
function withdrawAllEth() public {
26+
msg.sender.transfer(address(this).balance);
27+
}
28+
29+
function withdrawAllToken(IERC20 token) public {
30+
token.transfer(msg.sender, token.balanceOf(address(this)));
31+
}
32+
2533
function trade(
2634
IERC20 srcToken,
2735
uint256 srcAmount,
@@ -40,6 +48,7 @@ contract MockReserve is IKyberReserve, Utils5 {
4048
uint256 srcDecimals = getDecimals(srcToken);
4149
uint256 destDecimals = getDecimals(destToken);
4250
uint256 destAmount = calcDstQty(srcAmount, srcDecimals, destDecimals, conversionRate);
51+
require(destAmount > 0, "dest amount is 0");
4352

4453
// collect src tokens
4554
if (srcToken != ETH_TOKEN_ADDRESS) {
@@ -66,12 +75,21 @@ contract MockReserve is IKyberReserve, Utils5 {
6675
uint256 blockNumber
6776
) public view override returns (uint256) {
6877
blockNumber;
69-
uint256 rate;
70-
srcQty;
71-
72-
rate = (src == ETH_TOKEN_ADDRESS)
78+
uint256 rate = (src == ETH_TOKEN_ADDRESS)
7379
? buyTokenRates[address(dest)]
7480
: sellTokenRates[address(src)];
81+
uint256 srcDecimals = getDecimals(src);
82+
uint256 destDecimals = getDecimals(dest);
83+
if (srcQty > MAX_QTY || rate > MAX_RATE ) {
84+
return 0;
85+
}
86+
uint256 destAmount = calcDstQty(srcQty, srcDecimals, destDecimals, rate);
87+
if (dest == ETH_TOKEN_ADDRESS && address(this).balance < destAmount) {
88+
return 0;
89+
}
90+
if (dest != ETH_TOKEN_ADDRESS && dest.balanceOf(address(this)) < destAmount) {
91+
return 0;
92+
}
7593
return rate;
7694
}
7795
}

scripts/networkSimulator.js

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
const Helper = require("../test/helper.js");
2+
const nwHelper = require("../test/sol6/networkHelper.js");
3+
4+
const BN = web3.utils.BN;
5+
6+
const { precisionUnits, zeroBN } = require("../test/helper.js");
7+
const { expectRevert } = require('@openzeppelin/test-helpers');
8+
9+
const winston = require('winston');
10+
11+
const TradeParamGenerator = require("./trades/tradeParamsGenerator.js");
12+
const { TRADE, UPDATE_RESERVE_RATE, RevertType, allRevertMessages } = require("./trades/tradeParamsGenerator.js");
13+
14+
let numberSuccessfulTrades = 0;
15+
let numberGettingsZeroRates = 0;
16+
let numberUpdateReserveRates = 0;
17+
let numberRevertedTrades = {};
18+
let listRevertedReasons = [];
19+
20+
const logger = winston.createLogger({
21+
format: winston.format.combine(winston.format.colorize(), winston.format.splat(), winston.format.simple()),
22+
transports: [
23+
new winston.transports.Console({ level: 'info' }),
24+
new winston.transports.File({ filename: 'fuzz_trade.log', level: 'debug' })
25+
]
26+
});
27+
28+
// number iterations to print the progress of the test
29+
const progressIterations = 20;
30+
31+
// do fuzz trade tests with number of loops
32+
// then log the results of the whoe loop
33+
// random revert type, then create inputs
34+
module.exports.doFuzzTradeTests = async function(
35+
network, networkProxy, storage, matchingEngine,
36+
reserveInstances, accounts, tokens, numberLoops
37+
) {
38+
listRevertedReasons = [];
39+
logger.info(`Running semi-random fuzz trade tests with ${numberLoops} loops`);
40+
// prepare number of reverted trades for each revert message
41+
for(let i = 0; i < allRevertMessages.length; i++) {
42+
numberRevertedTrades[allRevertMessages[i]] = 0;
43+
listRevertedReasons.push(allRevertMessages[i]);
44+
}
45+
46+
let consecutiveFails = 0;
47+
let hasUpdatedRates = true;
48+
for(let loop = 0; loop < numberLoops; loop++) {
49+
if (loop % progressIterations == 0) {
50+
process.stdout.write(".");
51+
}
52+
let nextOperation = TradeParamGenerator.getNextOperation();
53+
if (nextOperation == TRADE || hasUpdatedRates) {
54+
hasUpdatedRates = false;
55+
// Note: revert is random, then inputs are created based on revert type
56+
let isTradeSuccess = await doTradeAndCompare(
57+
tokens, network, networkProxy, storage,
58+
matchingEngine, reserveInstances, accounts, false, loop
59+
);
60+
if (isTradeSuccess) {
61+
consecutiveFails = 0;
62+
} else {
63+
consecutiveFails++;
64+
if (consecutiveFails == Math.max(1, numberLoops / 10000)) {
65+
// too many consecutive times we can not get a valid trade inputs
66+
logger.debug(`Update rates for reserves`);
67+
await updateRatesForReserves(reserveInstances, tokens, accounts);
68+
hasUpdatedRates = true;
69+
consecutiveFails = 0;
70+
}
71+
}
72+
} else if (nextOperation == UPDATE_RESERVE_RATE) {
73+
logger.debug(`Loop ${loop}: Update rates for reserves`);
74+
await updateRatesForReserves(reserveInstances, tokens, accounts);
75+
hasUpdatedRates = true;
76+
}
77+
}
78+
process.stdout.write("\n");
79+
logTestResults(numberLoops);
80+
}
81+
82+
// do fuzz trade tests with number of loops
83+
// then log the results of the whoe loop
84+
// random inputs, then guess the revert type
85+
module.exports.doRandomFuzzTradeTests = async function(
86+
network, networkProxy, storage, matchingEngine,
87+
reserveInstances, accounts, tokens, numberLoops
88+
) {
89+
listRevertedReasons = [];
90+
logger.info(`Running random fuzz trade tests with ${numberLoops} loops`);
91+
// prepare number of reverted trades for each revert message
92+
for(let i = 0; i < allRevertMessages.length; i++) {
93+
numberRevertedTrades[allRevertMessages[i]] = 0;
94+
listRevertedReasons.push(allRevertMessages[i]);
95+
}
96+
97+
let consecutiveFails = 0;
98+
let hasUpdatedRates = true;
99+
for(let loop = 0; loop < numberLoops; loop++) {
100+
if (loop % progressIterations == 0) {
101+
process.stdout.write(".");
102+
}
103+
let nextOperation = TradeParamGenerator.getNextOperation();
104+
if (nextOperation == TRADE || hasUpdatedRates) {
105+
hasUpdatedRates = false;
106+
// Note: inputs are randomized, then guess the revert type
107+
let isTradeSuccess = await doTradeAndCompare(
108+
tokens, network, networkProxy, storage,
109+
matchingEngine, reserveInstances, accounts, true, loop
110+
);
111+
if (isTradeSuccess) {
112+
consecutiveFails = 0;
113+
} else {
114+
consecutiveFails++;
115+
if (consecutiveFails == Math.max(1, numberLoops / 10000)) {
116+
// too many consecutive times we can not get a valid trade inputs
117+
logger.debug(`Loop ${loop}: Update rates for reserves`);
118+
await updateRatesForReserves(reserveInstances, tokens, accounts);
119+
hasUpdatedRates = true;
120+
consecutiveFails = 0;
121+
}
122+
}
123+
} else if (nextOperation == UPDATE_RESERVE_RATE) {
124+
logger.debug(`Loop ${loop}: Update rates for reserves`);
125+
await updateRatesForReserves(reserveInstances, tokens, accounts);
126+
hasUpdatedRates = true;
127+
}
128+
}
129+
process.stdout.write("\n");
130+
logTestResults(numberLoops);
131+
}
132+
133+
// do trade and compare result if trade is successful
134+
// if reverted type is not None -> trade should be reverted
135+
// check if the trade is reverted with expected reason
136+
// isRandomInput: true if we random inputs, then guess the revert type
137+
// otherwise we random revert type, then create inputs
138+
async function doTradeAndCompare(
139+
tokens, network, networkProxy, storage,
140+
matchingEngine, reserveInstances, accounts, isRandomInput, loop
141+
) {
142+
let tradeData;
143+
if (isRandomInput) {
144+
tradeData = await TradeParamGenerator.generateRandomizedTradeParams(tokens, network, networkProxy, storage, matchingEngine, reserveInstances, accounts);
145+
} else {
146+
tradeData = await TradeParamGenerator.generateTradeParams(tokens, network, networkProxy, storage, matchingEngine, reserveInstances, accounts);
147+
}
148+
if (tradeData.srcQty.gt(zeroBN) || tradeData.revertType != RevertType.None) {
149+
if (tradeData.revertType == RevertType.None) {
150+
// calculate expected result
151+
let expectedResult = tradeData.expectedResult;
152+
let actualSrcQty = tradeData.actualSrcQty;
153+
logger.debug(`Loop ${loop}: Execute trade ${tradeData.message}, networkFeeBps=${tradeData.networkFeeBps.toString(10)},
154+
srcQty=${tradeData.srcQty.toString(10)}, srcDecimals=${tradeData.srcDecimals}, actualSrcQty=${actualSrcQty.toString(10)},
155+
dstQty=${expectedResult.actualDestAmount.toString(10)}, dstDecimals=${tradeData.destDecimals},
156+
platformFee=${expectedResult.platformFeeWei.toString(10)}, networkFee=${expectedResult.networkFeeWei.toString(10)}, accountedFeeBps=${expectedResult.feePayingReservesBps.toString(10)},
157+
minConversionRate=${tradeData.minConversionRate}, rateWithNetworkFee=${expectedResult.rateWithNetworkFee.toString(10)},
158+
rateWithAllFees=${expectedResult.rateWithAllFees.toString(10)}`);
159+
await testNormalTradeSuccessful(tradeData.srcAddress, tradeData.destAddress, tradeData, tradeData.taker, tradeData.callValue, tradeData.recipient, actualSrcQty,
160+
expectedResult, networkProxy
161+
)
162+
} else {
163+
if (numberRevertedTrades[tradeData.revertType] == null) {
164+
numberRevertedTrades[tradeData.revertType] = 1;
165+
} else {
166+
numberRevertedTrades[tradeData.revertType]++;
167+
}
168+
if (!listRevertedReasons.includes(tradeData.revertType)) {
169+
listRevertedReasons.push(tradeData.revertType);
170+
}
171+
logger.debug(`Loop ${loop}: Execute trade fail with reason: ${tradeData.revertType}`)
172+
await testTradeShouldRevert(tradeData.srcAddress, tradeData.destAddress, tradeData, tradeData.taker,
173+
tradeData.recipient, tradeData.callValue, networkProxy, tradeData.gasPrice, tradeData.message);
174+
175+
// reset some data like: transfer back src token, reset allowance, enable network if needed, etc
176+
await TradeParamGenerator.resetDataAfterTradeReverted(tradeData, network, networkProxy, accounts);
177+
}
178+
return true;
179+
}
180+
181+
logger.debug(`Loop ${loop}: Getting zero rates for trade`);
182+
numberGettingsZeroRates++;
183+
return false;
184+
}
185+
186+
// test should revert, there are 4 cases:
187+
// test is reverted with unspecified, e.g: sender not enough src token, not enough allowance
188+
// test is reverted but with assertion
189+
// test is reverted but can not check, e.g: callValue + fee > eth balance of sender
190+
// test is reverted with the given reason in errorMessage
191+
async function testTradeShouldRevert(srcAddress, destAddress, tradeData, taker, recipientAddress, callValue, networkProxy, gasPrice, errorMessage) {
192+
if (errorMessage == "unspecified") {
193+
// unspecified revert
194+
await expectRevert.unspecified(
195+
networkProxy.tradeWithHintAndFee(
196+
srcAddress,
197+
tradeData.srcQty,
198+
destAddress,
199+
recipientAddress,
200+
tradeData.maxDestAmount,
201+
tradeData.minConversionRate,
202+
taker, // platform wallet
203+
tradeData.platformFeeBps,
204+
tradeData.hint,
205+
{from: taker, value: callValue, gasPrice: gasPrice}
206+
)
207+
)
208+
} else if (errorMessage == "assertion") {
209+
await expectRevert.assertion(
210+
networkProxy.tradeWithHintAndFee(
211+
srcAddress,
212+
tradeData.srcQty,
213+
destAddress,
214+
recipientAddress,
215+
tradeData.maxDestAmount,
216+
tradeData.minConversionRate,
217+
taker, // platform wallet
218+
tradeData.platformFeeBps,
219+
tradeData.hint,
220+
{from: taker, value: callValue, gasPrice: gasPrice}
221+
)
222+
)
223+
logger.error("ASSERTION");
224+
} else if (errorMessage == "try/catch") {
225+
// need to do try catch
226+
try {
227+
await networkProxy.tradeWithHintAndFee(
228+
srcAddress,
229+
tradeData.srcQty,
230+
destAddress,
231+
recipientAddress,
232+
tradeData.maxDestAmount,
233+
tradeData.minConversionRate,
234+
taker, // platform wallet
235+
tradeData.platformFeeBps,
236+
tradeData.hint,
237+
{from: taker, value: callValue, gasPrice: gasPrice}
238+
);
239+
assert(false, "expected revert in line above");
240+
} catch (e) { }
241+
} else {
242+
await expectRevert(
243+
networkProxy.tradeWithHintAndFee(
244+
srcAddress,
245+
tradeData.srcQty,
246+
destAddress,
247+
recipientAddress,
248+
tradeData.maxDestAmount,
249+
tradeData.minConversionRate,
250+
taker, // platform wallet
251+
tradeData.platformFeeBps,
252+
tradeData.hint,
253+
{from: taker, value: callValue, gasPrice: gasPrice}
254+
),
255+
errorMessage
256+
);
257+
}
258+
}
259+
260+
// test normal trade is successful and data of balance changes as expected
261+
async function testNormalTradeSuccessful(
262+
srcAddress, destAddress, tradeData, taker, callValue, recipient,
263+
actualSrcQty, expectedResult, networkProxy)
264+
{
265+
// get balances before trade
266+
let initialReserveBalances = await nwHelper.getReserveBalances(tradeData.srcToken, tradeData.destToken, expectedResult);
267+
let initialTakerBalances = await nwHelper.getTakerBalances(tradeData.srcToken, tradeData.destToken, recipient, taker);
268+
await networkProxy.tradeWithHintAndFee(
269+
srcAddress,
270+
tradeData.srcQty,
271+
destAddress,
272+
recipient,
273+
tradeData.maxDestAmount,
274+
tradeData.minConversionRate,
275+
taker, // platform wallet
276+
tradeData.platformFeeBps,
277+
tradeData.hint,
278+
{from: taker, value: callValue, gasPrice: new BN(0)}
279+
);
280+
await nwHelper.compareBalancesAfterTrade(tradeData.srcToken, tradeData.destToken, actualSrcQty,
281+
initialReserveBalances, initialTakerBalances, expectedResult, recipient, taker);
282+
numberSuccessfulTrades++;
283+
}
284+
285+
// randomly update rates for reserves
286+
// normally when too many consecutive iterations can not get a successful trades
287+
async function updateRatesForReserves(reserveInstances, tokens, accounts) {
288+
numberUpdateReserveRates++;
289+
let ethInit = new BN(100).mul(new BN(10).pow(new BN(18)));
290+
let ethSender = 0;
291+
for(const [key, value] of Object.entries(reserveInstances)) {
292+
let reserve = value.instance;
293+
// deposit more eth if needed
294+
await reserve.withdrawAllEth({from: accounts[ethSender]});
295+
await Helper.sendEtherWithPromise(accounts[ethSender], reserve.address, ethInit);
296+
let val = TradeParamGenerator.getRandomInt(1, (ethSender + 1) * 10);
297+
let tokensPerEther = precisionUnits.mul(new BN(val));
298+
let ethersPerToken = precisionUnits.div(new BN(val));
299+
for(let i = 0; i < tokens.length; i++) {
300+
let token = tokens[i];
301+
await reserve.withdrawAllToken(token.address);
302+
let initialTokenAmount = new BN(2000000).mul(new BN(10).pow(new BN(await token.decimals())));
303+
await token.transfer(reserve.address, initialTokenAmount);
304+
await reserve.setRate(token.address, tokensPerEther, ethersPerToken);
305+
}
306+
ethSender++;
307+
}
308+
}
309+
310+
function logTestResults(numberLoops) {
311+
logger.info(`--- SUMMARY RESULTS AFTER ${numberLoops} LOOPS ---`)
312+
logger.info(`${numberSuccessfulTrades} succesful trades`);
313+
logger.info(`${numberUpdateReserveRates} times updating reserve rates`);
314+
logger.info(`${numberGettingsZeroRates} times getting zero for rates`);
315+
for(let i = 0; i < listRevertedReasons.length; i++) {
316+
logger.info(`${numberRevertedTrades[listRevertedReasons[i]]} reverted trades with reason: ${listRevertedReasons[i]}`);
317+
}
318+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#!/bin/sh
2+
3+
for _ in {1..100}
4+
do
5+
npx buidler test --no-compile test/sol6/tradeFuzzTests.js
6+
done

0 commit comments

Comments
 (0)