Repository: thehubbleproject/hubble-contracts (ts)

Issue:
Integration test suite
Before we start patching up the client we should have integration tests to test the flow.
- Register token
- Deposit Tokens
- Perform N transfers
- Perform mass migration
- Spoke registry
- Withdraw Tokens

Pull request description:
Integration test 2
Should be merged after #389

Close #221

Patch:
diff --git a/ts/commitments.ts b/ts/commitments.ts
index b9a4133..265fddd 100644
--- a/ts/commitments.ts
+++ b/ts/commitments.ts
@@ -17,7 +17,7 @@ interface SolStruct {
     body: any;
 }
 
-interface CommitmentInclusionProof {
+export interface CommitmentInclusionProof {
     commitment: CompressedStruct;
     path: number;
     witness: string[];
@@ -49,7 +49,7 @@ abstract class Commitment {
     }
 }
 
-export class GenesisCommitment extends Commitment {
+export class BodylessCommitment extends Commitment {
     get bodyRoot() {
         return ZERO_BYTES32;
     }
@@ -64,7 +64,7 @@ export class GenesisCommitment extends Commitment {
 export function getGenesisProof(
     stateRoot: BytesLike
 ): CommitmentInclusionProof {
-    return new GenesisCommitment(stateRoot).toBatch().proofCompressed(0);
+    return new BodylessCommitment(stateRoot).toBatch().proofCompressed(0);
 }
 
 export class TransferCommitment extends Commitment {
diff --git a/ts/constants.ts b/ts/constants.ts
index 2dbeedf..798bce2 100644
--- a/ts/constants.ts
+++ b/ts/constants.ts
@@ -15,7 +15,7 @@ export const TESTING_PARAMS: DeploymentParameters = {
 
 export const PRODUCTION_PARAMS: DeploymentParameters = {
     MAX_DEPTH: 20,
-    MAX_DEPOSIT_SUBTREE_DEPTH: 1,
+    MAX_DEPOSIT_SUBTREE_DEPTH: 2,
     STAKE_AMOUNT: toWei("0.1"),
     BLOCKS_TO_FINALISE: 7 * 24 * 60 * 4, // 7 days of blocks
     MIN_GAS_LEFT: 10000,
@@ -28,6 +28,9 @@ export const PRODUCTION_PARAMS: DeploymentParameters = {
 export const COMMIT_SIZE = 32;
 export const STATE_TREE_DEPTH = 32;
 
+export const BLOCKS_PER_SLOT = 100;
+export const DELTA_BLOCKS_INITIAL_SLOT = 1000;
+
 // ethers.utils.keccak256(ethers.constants.HashZero)
 export const ZERO_BYTES32 =
     "0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563";
diff --git a/ts/decimal.ts b/ts/decimal.ts
index 18b2f2e..a7e1cd3 100644
--- a/ts/decimal.ts
+++ b/ts/decimal.ts
@@ -54,6 +54,19 @@ export class DecimalCodec {
         return BigNumber.from(Math.round(validNum * 10 ** this.place));
     }
 
+    /**
+     * Find a BigNumber that's less than the input and compressable
+     */
+    public castBigNumber(input: BigNumber): BigNumber {
+        let mantissa = input;
+        for (let exponent = 0; exponent < this.exponentMax; exponent++) {
+            if (mantissa.lte(this.mantissaMax))
+                return mantissa.mul(BigNumber.from(10).pow(exponent));
+            mantissa = mantissa.div(10);
+        }
+        throw new EncodingError(`Can't cast input ${input.toString()}`);
+    }
+
     public encode(input: number) {
         // Use Math.round here to prevent the case
         // > 32.3 * 10 ** 6
diff --git a/ts/exceptions.ts b/ts/exceptions.ts
index d249802..0ba6c69 100644
--- a/ts/exceptions.ts
+++ b/ts/exceptions.ts
@@ -46,3 +46,5 @@ export class StateAlreadyExist extends StateTreeExceptions {}
 export class WrongTokenID extends StateTreeExceptions {}
 
 export class InsufficientFund extends StateTreeExceptions {}
+
+export class ZeroAmount extends StateTreeExceptions {}
diff --git a/ts/factory.ts b/ts/factory.ts
index bd1af72..82c7c09 100644
--- a/ts/factory.ts
+++ b/ts/factory.ts
@@ -92,6 +92,39 @@ export class Group {
             yield user;
         }
     }
+    // Useful when want to divide users into sub-groups
+    public *groupInterator(subgroupSize: number) {
+        let subgroup = [];
+        for (const user of this.users) {
+            subgroup.push(user);
+            if (subgroup.length == subgroupSize) {
+                yield new Group(subgroup, this.stateProvider);
+                subgroup = [];
+            }
+        }
+    }
+    public pickRandom(): { user: User; index: number } {
+        const index = Math.floor(Math.random() * this.users.length);
+        const user = this.users[index];
+        return { user, index };
+    }
+    public join(other: Group) {
+        const allUsers = [];
+        for (const user of this.userIterator()) {
+            allUsers.push(user);
+        }
+        for (const user of other.userIterator()) {
+            allUsers.push(user);
+        }
+        return new Group(allUsers, this.stateProvider);
+    }
+    public slice(n: number) {
+        if (n > this.users.length)
+            throw new UserNotExist(
+                `Want ${n} users but this group has only ${this.users.length} users`
+            );
+        return new Group(this.users.slice(0, n), this.stateProvider);
+    }
     public getUser(i: number) {
         if (i >= this.users.length) throw new UserNotExist(`${i}`);
         return this.users[i];
@@ -145,8 +178,8 @@ export function txTransferFactory(
         const sender = group.getUser(i % group.size);
         const receiver = group.getUser((i + 5) % group.size);
         const senderState = group.getState(sender);
-        const amount = senderState.balance.div(10);
-        const fee = amount.div(10);
+        const amount = USDT.castBigNumber(senderState.balance.div(10));
+        const fee = USDT.castBigNumber(amount.div(10));
         const tx = new TxTransfer(
             sender.stateID,
             receiver.stateID,
@@ -176,8 +209,8 @@ export function txCreate2TransferFactory(
         const sender = registered.getUser(i);
         const reciver = unregistered.getUser(i);
         const senderState = registered.getState(sender);
-        const amount = senderState.balance.div(10);
-        const fee = amount.div(10);
+        const amount = USDT.castBigNumber(senderState.balance.div(10));
+        const fee = USDT.castBigNumber(amount.div(10));
 
         const tx = new TxCreate2Transfer(
             sender.stateID,
@@ -205,8 +238,8 @@ export function txMassMigrationFactory(
     const senders = [];
     for (const sender of group.userIterator()) {
         const senderState = group.getState(sender);
-        const amount = senderState.balance.div(10);
-        const fee = amount.div(10);
+        const amount = USDT.castBigNumber(senderState.balance.div(10));
+        const fee = USDT.castBigNumber(amount.div(10));
         const tx = new TxMassMigration(
             sender.stateID,
             amount,
diff --git a/ts/stateTree.ts b/ts/stateTree.ts
index 473447b..20a2fb7 100644
--- a/ts/stateTree.ts
+++ b/ts/stateTree.ts
@@ -10,7 +10,8 @@ import {
     ReceiverNotExist,
     SenderNotExist,
     StateAlreadyExist,
-    WrongTokenID
+    WrongTokenID,
+    ZeroAmount
 } from "./exceptions";
 
 export interface StateProvider {
@@ -267,11 +268,11 @@ export class StateTree implements StateProvider {
         tx: TxTransfer,
         tokenID: number
     ): SolStateMerkleProof[] {
-        const decrement = tx.amount.add(tx.fee);
         const senderProof = this.processSender(
             tx.fromIndex,
             tokenID,
-            decrement
+            tx.amount,
+            tx.fee
         );
         const receiverProof = this.processReceiver(
             tx.toIndex,
@@ -285,18 +286,18 @@ export class StateTree implements StateProvider {
         tx: TxMassMigration,
         tokenID: number
     ): SolStateMerkleProof {
-        return this.processSender(tx.fromIndex, tokenID, tx.amount.add(tx.fee));
+        return this.processSender(tx.fromIndex, tokenID, tx.amount, tx.fee);
     }
 
     public processCreate2Transfer(
         tx: TxCreate2Transfer,
         tokenID: number
     ): SolStateMerkleProof[] {
-        const decrement = tx.amount.add(tx.fee);
         const senderProof = this.processSender(
             tx.fromIndex,
             tokenID,
-            decrement
+            tx.amount,
+            tx.fee
         );
         const receiverProof = this.processCreate(
             tx.toIndex,
@@ -318,10 +319,13 @@ export class StateTree implements StateProvider {
     public processSender(
         senderIndex: number,
         tokenID: number,
-        decrement: BigNumber
+        amount: BigNumber,
+        fee: BigNumber
     ): SolStateMerkleProof {
         const state = this.states[senderIndex];
         if (!state) throw new SenderNotExist(`stateID: ${senderIndex}`);
+        if (amount.isZero()) throw new ZeroAmount();
+        const decrement = amount.add(fee);
         if (state.balance.lt(decrement))
             throw new InsufficientFund(
                 `balance: ${state.balance}, tx amount+fee: ${decrement}`
diff --git a/ts/utils.ts b/ts/utils.ts
index 5341b40..ea91c3a 100644
--- a/ts/utils.ts
+++ b/ts/utils.ts
@@ -4,6 +4,7 @@ import { randomBytes, hexlify, hexZeroPad, parseEther } from "ethers/lib/utils";
 import { Wei } from "./interfaces";
 import { ContractTransaction } from "ethers";
 import { assert, expect } from "chai";
+import { Rollup } from "../types/ethers-contracts/Rollup";
 
 export const FIELD_ORDER = BigNumber.from(
     "0x30644e72e131a029b85045b68181585d97816a916871ca8d3c208c16d87cfd47"
@@ -80,3 +81,7 @@ export async function expectRevert(
         }
     );
 }
+
+export async function getBatchID(rollup: Rollup): Promise<number> {
+    return Number(await rollup.nextBatchID()) - 1;
+}


Test patch:
diff --git a/test/burnAuction.test.ts b/test/burnAuction.test.ts
index 5c91b15..11aef6a 100644
--- a/test/burnAuction.test.ts
+++ b/test/burnAuction.test.ts
@@ -1,18 +1,16 @@
 // Tests borrowed from https://github.com/iden3/rollup/blob/master/test/contracts/RollupBurnAuction.test.js
 import { assert, expect } from "chai";
-import { BigNumber, ContractReceipt, Signer, Transaction } from "ethers";
-import { formatEther, isAddress } from "ethers/lib/utils";
+import { BigNumber, ContractReceipt, Signer } from "ethers";
 import { ethers } from "hardhat";
 import { expectRevert, toWei } from "../ts/utils";
 import { MockRollup } from "../types/ethers-contracts/MockRollup";
 import { MockRollupFactory } from "../types/ethers-contracts/MockRollupFactory";
 import { TestBurnAuction } from "../types/ethers-contracts/TestBurnAuction";
 import { TestBurnAuctionFactory } from "../types/ethers-contracts/TestBurnAuctionFactory";
+import { BLOCKS_PER_SLOT, DELTA_BLOCKS_INITIAL_SLOT } from "../ts/constants";
 
 const zeroAddress = "0x0000000000000000000000000000000000000000";
 const donationAddress = "0x00000000000000000000000000000000000000d0";
-const BLOCKS_PER_SLOT = 100;
-const DELTA_BLOCKS_INITIAL_SLOT = 1000;
 const DONATION_NUMERATOR = 7500;
 const DONATION_DENOMINATOR = 10000;
 
diff --git a/test/integration.test.ts b/test/integration.test.ts
new file mode 100644
index 0000000..82be770
--- /dev/null
+++ b/test/integration.test.ts
@@ -0,0 +1,358 @@
+import { assert } from "chai";
+import { Signer } from "ethers";
+import { ethers } from "hardhat";
+import { AccountRegistry } from "../ts/accountTree";
+import { allContracts } from "../ts/allContractsInterfaces";
+import { BLOCKS_PER_SLOT, PRODUCTION_PARAMS } from "../ts/constants";
+import { deployAll } from "../ts/deploy";
+import {
+    Group,
+    txCreate2TransferFactory,
+    txMassMigrationFactory,
+    txTransferFactory
+} from "../ts/factory";
+import { DeploymentParameters } from "../ts/interfaces";
+import { MigrationTree, StateTree } from "../ts/stateTree";
+import { BurnAuction } from "../types/ethers-contracts/BurnAuction";
+import * as mcl from "../ts/mcl";
+import {
+    BodylessCommitment,
+    CommitmentInclusionProof,
+    Create2TransferBatch,
+    Create2TransferCommitment,
+    getGenesisProof,
+    MassMigrationBatch,
+    MassMigrationCommitment,
+    TransferBatch,
+    TransferCommitment
+} from "../ts/commitments";
+import { getBatchID, hexToUint8Array, mineBlocks } from "../ts/utils";
+import { serialize } from "../ts/tx";
+import { ExampleToken } from "../types/ethers-contracts/ExampleToken";
+import { ExampleTokenFactory } from "../types/ethers-contracts";
+import { USDT } from "../ts/decimal";
+
+// In the deploy script, we already have a TestToken registered with tokenID 0
+// We are deploying a new token with tokenID 1
+const tokenID = 1;
+
+describe("Integration Test", function() {
+    let contracts: allContracts;
+    let stateTree: StateTree;
+    let parameters: DeploymentParameters;
+    let deployer: Signer;
+    let coordinator: Signer;
+    let stakedBatchIDs: number[] = [];
+    let withdrawer: Signer;
+    let accountRegistry: AccountRegistry;
+    let newToken: ExampleToken;
+    let nextStateID = 0;
+    let previousProof: CommitmentInclusionProof;
+    let earlyAdopters: Group;
+    let newUsers: Group;
+    let genesisRoot: string;
+    let domain: Uint8Array;
+    let migrationTrees: MigrationTree[] = [];
+    let lastBiddedSlot: number = -100;
+
+    before(async function() {
+        await mcl.init();
+        [deployer, coordinator, withdrawer] = await ethers.getSigners();
+        parameters = PRODUCTION_PARAMS;
+        parameters.BLOCKS_TO_FINALISE = 100;
+        stateTree = StateTree.new(parameters.MAX_DEPTH);
+        genesisRoot = stateTree.root;
+        contracts = await deployAll(deployer, {
+            ...parameters,
+            GENESIS_STATE_ROOT: genesisRoot
+        });
+        const { rollup, blsAccountRegistry } = contracts;
+        domain = hexToUint8Array(await rollup.appID());
+
+        accountRegistry = await AccountRegistry.new(
+            blsAccountRegistry.connect(coordinator)
+        );
+    });
+    beforeEach(async function() {
+        // Remember to bid when a new slot starts
+        if (lastBiddedSlot > 0) {
+            const burnAuction = contracts.chooser as BurnAuction;
+            const newSlot = Number(await burnAuction.currentSlot());
+            if (newSlot + 2 > lastBiddedSlot) {
+                console.log("New slot", newSlot, "bid now");
+                await burnAuction.connect(coordinator).bid("1", { value: "1" });
+                lastBiddedSlot = newSlot + 2;
+                console.log("can propose at slot", lastBiddedSlot);
+            }
+        }
+    });
+    it("Register another token", async function() {
+        const { tokenRegistry } = contracts;
+        newToken = await new ExampleTokenFactory(coordinator).deploy();
+        await tokenRegistry.requestRegistration(newToken.address);
+        const tx = await tokenRegistry.finaliseRegistration(newToken.address);
+        const [event] = await tokenRegistry.queryFilter(
+            tokenRegistry.filters.RegisteredToken(null, null),
+            tx.blockHash
+        );
+
+        assert.equal(event.args?.tokenID, tokenID);
+    });
+    it("Coordinator bid the first auction", async function() {
+        const burnAuction = contracts.chooser as BurnAuction;
+        const genesisBlock = Number(await burnAuction.genesisBlock());
+
+        // mine to slot 0
+        await mineBlocks(
+            ethers.provider,
+            genesisBlock - ethers.provider.blockNumber
+        );
+        assert.equal(Number(await burnAuction.currentSlot()), 0);
+        // bid slot 2
+        await burnAuction.connect(coordinator).bid("1", { value: "1" });
+        lastBiddedSlot = 2;
+        // can't propose at slot 0 and 1
+        // mine to slot 1
+        await mineBlocks(ethers.provider, BLOCKS_PER_SLOT);
+        assert.equal(Number(await burnAuction.currentSlot()), 1);
+        // bid slot 3
+        await burnAuction.connect(coordinator).bid("1", { value: "1" });
+        await mineBlocks(ethers.provider, BLOCKS_PER_SLOT);
+        // Slot 2 is when the auction finalize and the coordinator can propose
+        assert.equal(Number(await burnAuction.currentSlot()), 2);
+        for (const slot of [2, 3]) {
+            const bid = await burnAuction.auction(slot);
+            assert.equal(bid.coordinator, await coordinator.getAddress());
+        }
+        // Set currentSlot to a positive number, and we remember to bid in beforeEach
+        lastBiddedSlot = 3;
+    });
+    it("Deposit some users", async function() {
+        const { depositManager, rollup } = contracts;
+        const subtreeSize = 1 << parameters.MAX_DEPOSIT_SUBTREE_DEPTH;
+        const nSubtrees = 10;
+        const nDeposits = nSubtrees * subtreeSize;
+        nextStateID = nDeposits;
+        earlyAdopters = Group.new({
+            n: nDeposits,
+            initialStateID: 0,
+            initialPubkeyID: 0,
+            domain
+        }).connect(stateTree);
+        const balance = USDT.castInt(1234.0);
+        const fromBlockNumber = await deployer.provider?.getBlockNumber();
+        for (const user of earlyAdopters.userIterator()) {
+            const pubkeyID = await accountRegistry.register(user.pubkey);
+            assert.equal(pubkeyID, user.pubkeyID);
+            await newToken
+                .connect(coordinator)
+                .approve(depositManager.address, balance);
+            await depositManager
+                .connect(coordinator)
+                .depositFor(user.pubkeyID, balance, tokenID);
+        }
+
+        const subtreeReadyEvents = await depositManager.queryFilter(
+            depositManager.filters.DepositSubTreeReady(null),
+            fromBlockNumber
+        );
+        assert.equal(subtreeReadyEvents.length, nSubtrees);
+        previousProof = getGenesisProof(genesisRoot);
+        const subgroups = Array.from(earlyAdopters.groupInterator(subtreeSize));
+        for (let i = 0; i < nSubtrees; i++) {
+            const mergeOffsetLower = i * subtreeSize;
+            const subgroup = subgroups[i];
+            const vacant = stateTree.getVacancyProof(
+                mergeOffsetLower,
+                parameters.MAX_DEPOSIT_SUBTREE_DEPTH
+            );
+            await rollup
+                .connect(coordinator)
+                .submitDeposits(previousProof, vacant, {
+                    value: parameters.STAKE_AMOUNT
+                });
+            const batchID = await getBatchID(rollup);
+            stakedBatchIDs.push(batchID);
+            subgroup.createStates({
+                initialBalance: balance,
+                tokenID,
+                zeroNonce: true
+            });
+            const depositBatch = new BodylessCommitment(
+                stateTree.root
+            ).toBatch();
+            const batch = await rollup.getBatch(batchID);
+            assert.equal(batch.commitmentRoot, depositBatch.commitmentRoot);
+            previousProof = depositBatch.proofCompressed(0);
+        }
+    }).timeout(40000);
+    it("Users doing Transfers", async function() {
+        console.log("Next state ID", nextStateID);
+        const { rollup } = contracts;
+        const numCommits = 32;
+        const feeReceiverID = 0;
+        const commits = [];
+
+        for (let i = 0; i < numCommits; i++) {
+            const { txs, signature } = txTransferFactory(
+                earlyAdopters,
+                parameters.MAX_TXS_PER_COMMIT
+            );
+            stateTree.processTransferCommit(txs, feeReceiverID);
+            const commit = TransferCommitment.new(
+                stateTree.root,
+                accountRegistry.root(),
+                signature,
+                feeReceiverID,
+                serialize(txs)
+            );
+            commits.push(commit);
+        }
+        await new TransferBatch(commits).submit(
+            rollup.connect(coordinator),
+            parameters.STAKE_AMOUNT
+        );
+        const batchID = await getBatchID(rollup);
+        stakedBatchIDs.push(batchID);
+    });
+    it("Getting new users via Create to transfer", async function() {
+        const { rollup } = contracts;
+        const numCommits = 32;
+        const nNewUsers = parameters.MAX_TXS_PER_COMMIT * numCommits;
+        // We'll update pubkeyIDs later
+        newUsers = Group.new({
+            n: nNewUsers,
+            initialStateID: nextStateID,
+            initialPubkeyID: 0,
+            domain
+        });
+        const batchSize = accountRegistry.batchSize;
+        // We happen to number of newUsers as a multiple of batchSize, so no need to handle single registration case
+        for (const group of newUsers.groupInterator(batchSize)) {
+            const firstID = await accountRegistry.registerBatch(
+                group.getPubkeys()
+            );
+            for (let i = 0; i < batchSize; i++) {
+                group.getUser(i).pubkeyID = firstID + i;
+            }
+        }
+        const feeReceiverID = 0;
+
+        const commits: TransferCommitment[] = [];
+        const kindEarlyAdopters = earlyAdopters.slice(
+            parameters.MAX_TXS_PER_COMMIT
+        );
+        const newUsersGroups = Array.from(
+            newUsers.groupInterator(parameters.MAX_TXS_PER_COMMIT)
+        );
+        for (let i = 0; i < numCommits; i++) {
+            const { txs, signature } = txCreate2TransferFactory(
+                kindEarlyAdopters,
+                newUsersGroups[i]
+            );
+            stateTree.processCreate2TransferCommit(txs, feeReceiverID);
+            const commit = Create2TransferCommitment.new(
+                stateTree.root,
+                accountRegistry.root(),
+                signature,
+                feeReceiverID,
+                serialize(txs)
+            );
+            commits.push(commit);
+        }
+        await new Create2TransferBatch(commits).submit(
+            rollup.connect(coordinator),
+            parameters.STAKE_AMOUNT
+        );
+        const batchID = await getBatchID(rollup);
+        stakedBatchIDs.push(batchID);
+    }).timeout(40000);
+    it("Exit via mass migration", async function() {
+        const { rollup, withdrawManager } = contracts;
+        // The spokeID of the withdrawManager preregistered in the deploy script
+        const spokeID = 1;
+        const numCommits = 32;
+        const feeReceiverID = 0;
+        const commits = [];
+        const allUsers = earlyAdopters.join(newUsers);
+        const allUserGroups = Array.from(
+            allUsers.groupInterator(parameters.MAX_TXS_PER_COMMIT)
+        );
+        for (let i = 0; i < numCommits; i++) {
+            const group = allUserGroups[i];
+            const { txs, signature } = txMassMigrationFactory(group, spokeID);
+            stateTree.processMassMigrationCommit(txs, feeReceiverID);
+
+            const {
+                commitment,
+                migrationTree
+            } = MassMigrationCommitment.fromStateProvider(
+                accountRegistry.root(),
+                txs,
+                signature,
+                feeReceiverID,
+                stateTree
+            );
+            commits.push(commitment);
+            migrationTrees.push(migrationTree);
+        }
+        const batch = new MassMigrationBatch(commits);
+        await batch.submit(
+            rollup.connect(coordinator),
+            parameters.STAKE_AMOUNT
+        );
+        const batchID = await getBatchID(rollup);
+        stakedBatchIDs.push(batchID);
+
+        await mineBlocks(ethers.provider, parameters.BLOCKS_TO_FINALISE);
+        for (let i = 0; i < commits.length; i++) {
+            await withdrawManager.processWithdrawCommitment(
+                batchID,
+                batch.proof(i)
+            );
+        }
+
+        // We sample only one user from each commitment to withdraw
+        // Let all users share same withdrawerAddress
+        const withdrawerAddress = await withdrawer.getAddress();
+        for (let i = 0; i < numCommits; i++) {
+            const preBalance = await newToken.balanceOf(withdrawerAddress);
+            const group = allUserGroups[i];
+            const tree = migrationTrees[i];
+            const { user, index } = group.pickRandom();
+            const signature = user.signRaw(withdrawerAddress).sol;
+            // The new stateID in the migration tree is the position user in the group
+            const withdrawProof = tree.getWithdrawProof(index);
+            await withdrawManager
+                .connect(withdrawer)
+                .claimTokens(
+                    tree.root,
+                    withdrawProof,
+                    user.pubkey,
+                    signature,
+                    accountRegistry.witness(user.pubkeyID)
+                );
+            const postBalance = await newToken.balanceOf(withdrawerAddress);
+            assert.equal(
+                postBalance.sub(preBalance).toString(),
+                withdrawProof.state.balance.toString()
+            );
+        }
+    }).timeout(240000);
+    it("Coordinator withdrew their stake", async function() {
+        const { rollup } = contracts;
+        for (const batchID of stakedBatchIDs) {
+            const preBalance = await coordinator.getBalance();
+            const tx = await rollup.connect(coordinator).withdrawStake(batchID);
+            const txFee = (await tx.wait()).gasUsed.mul(tx.gasPrice);
+            const postBalance = await coordinator.getBalance();
+            assert.equal(
+                postBalance
+                    .sub(preBalance)
+                    .add(txFee)
+                    .toString(),
+                parameters.STAKE_AMOUNT.toString()
+            );
+        }
+    });
+});