const assert = require("assert");
const TestUtils = require("./utils/TestUtils");
const TestMoneroWalletCommon = require("./TestMoneroWalletCommon");
const StartMining = require("./utils/StartMining");
const WalletSyncPrinter = require("./utils/WalletSyncPrinter");
const WalletEqualityUtils = require("./utils/WalletEqualityUtils");
const monerojs = require("../../index");
const MoneroWalletListener = monerojs.MoneroWalletListener;
const LibraryUtils = monerojs.LibraryUtils;
const MoneroWalletConfig = monerojs.MoneroWalletConfig;
const GenUtils = monerojs.GenUtils;
const MoneroUtils = monerojs.MoneroUtils;
const BigInteger = monerojs.BigInteger;
const MoneroNetworkType = monerojs.MoneroNetworkType;
const MoneroTxWallet = monerojs.MoneroTxWallet;
const MoneroTxConfig = monerojs.MoneroTxConfig;
const MoneroDestination = monerojs.MoneroDestination;
const MoneroOutputQuery = monerojs.MoneroOutputQuery;
const MoneroOutputWallet = monerojs.MoneroOutputWallet;
const MoneroRpcConnection = monerojs.MoneroRpcConnection;
const MoneroWalletFull = monerojs.MoneroWalletFull;

/**
 * Tests a Monero wallet using WebAssembly to bridge to monero-project's wallet2.
 */
class TestMoneroWalletFull extends TestMoneroWalletCommon {
  
  constructor(testConfig) {
    super(testConfig);
  }
  
  async beforeAll(currentTest) {
    await super.beforeAll(currentTest);
  }
  
  async beforeEach(currentTest) {
    await super.beforeEach(currentTest);
  }
  
  async afterAll() {
    await super.afterAll();
    TestMoneroWalletFull.FULL_TESTS_RUN = true;
  }
  
  async afterEach(currentTest) {
    await super.afterEach(currentTest);
    
    // print memory usage
    console.log("WASM memory usage: " + await LibraryUtils.getWasmMemoryUsed());
    //console.log(process.memoryUsage());
    
    // remove non-whitelisted wallets
    let whitelist = [TestUtils.WALLET_NAME, "ground_truth", "moved"];
    let items = TestUtils.getDefaultFs().readdirSync(TestUtils.TEST_WALLETS_DIR);
    for (let item of items) {
      let found = false;
      for (let whitelisted of whitelist) {
        if (item === whitelisted || item === whitelisted + ".keys" || item === whitelisted + ".address.txt") {
          found = true;
          break;
        }
      }
      if (!found) TestUtils.getDefaultFs().unlinkSync(TestUtils.TEST_WALLETS_DIR + "/" + item);
    }
  }
  
  async getTestWallet() {
    return await TestUtils.getWalletFull();
  }
  
  async getTestDaemon() {
    return await TestUtils.getDaemonRpc();
  }
  
  async openWallet(config, startSyncing) {
    
    // assign defaults
    config = new MoneroWalletConfig(config);
    if (config.getPassword() === undefined) config.setPassword(TestUtils.WALLET_PASSWORD);
    if (config.getNetworkType() === undefined) config.setNetworkType(TestUtils.NETWORK_TYPE);
    if (config.getProxyToWorker() === undefined) config.setProxyToWorker(TestUtils.PROXY_TO_WORKER);
    if (config.getServer() === undefined && config.getServerUri() === undefined) config.setServer(TestUtils.getDaemonRpcConnection());
    if (config.getFs() === undefined) config.setFs(TestUtils.getDefaultFs());
    
    // open wallet
    let wallet = await monerojs.openWalletFull(config);
    if (startSyncing !== false && await wallet.isConnectedToDaemon()) await wallet.startSyncing(TestUtils.SYNC_PERIOD_IN_MS);
    return wallet;
  }
  
  async createWallet(config, startSyncing) {
    
    // assign defaults
    config = new MoneroWalletConfig(config);
    let random = config.getSeed() === undefined && config.getPrimaryAddress() === undefined;
    if (config.getPath() === undefined) config.setPath(TestUtils.TEST_WALLETS_DIR + "/" + GenUtils.getUUID());
    if (config.getPassword() === undefined) config.setPassword(TestUtils.WALLET_PASSWORD);
    if (config.getNetworkType() === undefined) config.setNetworkType(TestUtils.NETWORK_TYPE);
    if (!config.getRestoreHeight() && !random) config.setRestoreHeight(0);
    if (!config.getServer() && !config.getConnectionManager()) config.setServer(TestUtils.getDaemonRpcConnection());
    if (config.getProxyToWorker() === undefined) config.setProxyToWorker(TestUtils.PROXY_TO_WORKER);
    if (config.getFs() === undefined) config.setFs(TestUtils.getDefaultFs());
    
    // create wallet
    let wallet = await monerojs.createWalletFull(config);
    if (!random) assert.equal(await wallet.getRestoreHeight(), config.getRestoreHeight() === undefined ? 0 : config.getRestoreHeight());
    if (startSyncing !== false && await wallet.isConnectedToDaemon()) await wallet.startSyncing(TestUtils.SYNC_PERIOD_IN_MS);
    return wallet;
  }
  
  async closeWallet(wallet, save) {
    await wallet.close(save);
  }
  
  async getSeedLanguages() {
    return await MoneroWalletFull.getSeedLanguages();
  }
  
  // ------------------------------- BEGIN TESTS ------------------------------
  
  runTests() {
    let that = this;
    let testConfig = this.testConfig;
    describe("TEST MONERO WALLET FULL", function() {
        
      // register handlers to run before and after tests
      before(async function() { await that.beforeAll(); });
      beforeEach(async function() { await that.beforeEach(this.currentTest); });
      after(async function() { await that.afterAll(); });
      afterEach(async function() { await that.afterEach(this.currentTest); });
      
      // run tests specific to full wallet
      that._testWalletFull();
      
      // run common tests
      that.runCommonTests();
    });
  }
  
  _testWalletFull() {
    let that = this;
    let testConfig = this.testConfig;
    describe("Tests specific to WebAssembly wallet", function() {
      
      if (false && testConfig.testNonRelays)
      it("Does not leak memory", async function() {
        let restoreHeight = TestUtils.FIRST_RECEIVE_HEIGHT;
        //let wallet = await that.createWallet({seed: TestUtils.SEED, restoreHeight: restoreHeight}, false);
        for (let i = 0; i < 100; i++) {
          console.log(process.memoryUsage());
          await _testSyncSeed(TestUtils.FIRST_RECEIVE_HEIGHT, undefined, false, true);
        }
      });
      
      if (testConfig.testNonRelays)
      it("Can get the daemon's height", async function() {
        assert(await that.wallet.isConnectedToDaemon());
        let daemonHeight = await that.wallet.getDaemonHeight();
        assert(daemonHeight > 0);
      });
      
      if (testConfig.testNonRelays && !testConfig.liteMode)
      it("Can open, sync, and close wallets repeatedly", async function() {
        let wallets = [];
        for (let i = 0; i < 4; i++) {
          let wallet = await that.createWallet({seed: TestUtils.SEED, restoreHeight: TestUtils.FIRST_RECEIVE_HEIGHT});
          await wallet.startSyncing();
          wallets.push(wallet);
        }
        for (let wallet of wallets) await wallet.close();
      });
      
      if (testConfig.testNonRelays)
      it("Can get the daemon's max peer height", async function() {
        let height = await that.wallet.getDaemonMaxPeerHeight();
        assert(height > 0);
      });
      
      if (testConfig.testNonRelays)
      it("Can create a random full wallet", async function() {
        
        // create unconnected random wallet
        let wallet = await that.createWallet({networkType: MoneroNetworkType.MAINNET, serverUri: TestUtils.OFFLINE_SERVER_URI});
        await MoneroUtils.validateMnemonic(await wallet.getSeed());
        await MoneroUtils.validateAddress(await wallet.getPrimaryAddress(), MoneroNetworkType.MAINNET);
        assert.equal(await wallet.getNetworkType(), MoneroNetworkType.MAINNET);
        assert.deepEqual(await wallet.getDaemonConnection(), new MoneroRpcConnection(TestUtils.OFFLINE_SERVER_URI));
        assert(!(await wallet.isConnectedToDaemon()));
        assert.equal(await wallet.getSeedLanguage(), "English");
        assert(!(await wallet.isSynced()));
        assert.equal(await wallet.getHeight(), 1); // TODO monero-project: why does height of new unsynced wallet start at 1?
        assert(await wallet.getRestoreHeight() >= 0);
        
        // cannot get daemon chain height
        try {
          await wallet.getDaemonHeight();
        } catch (e) {
          assert.equal(e.message, "Wallet is not connected to daemon");
        }
        
        // set daemon connection and check chain height
        await wallet.setDaemonConnection(await that.daemon.getRpcConnection());
        assert.equal(await wallet.getDaemonHeight(), await that.daemon.getHeight());
        
        // close wallet which releases resources
        await wallet.close();

        // create random wallet with non defaults
        wallet = await that.createWallet({networkType: MoneroNetworkType.TESTNET, language: "Spanish"}, false);
        await MoneroUtils.validateMnemonic(await wallet.getSeed());
        await MoneroUtils.validateAddress(await wallet.getPrimaryAddress(), MoneroNetworkType.TESTNET);
        assert.equal(await wallet.getNetworkType(), await MoneroNetworkType.TESTNET);
        assert(await wallet.getDaemonConnection());
        assert((await that.daemon.getRpcConnection()).getConfig() !== (await wallet.getDaemonConnection()).getConfig());         // not same reference
        assert.equal((await wallet.getDaemonConnection()).getUri(), (await that.daemon.getRpcConnection()).getUri());
        assert.equal((await wallet.getDaemonConnection()).getUsername(), (await that.daemon.getRpcConnection()).getUsername());
        assert.equal((await wallet.getDaemonConnection()).getPassword(), (await that.daemon.getRpcConnection()).getPassword());
        assert(await wallet.isConnectedToDaemon());
        assert.equal(await wallet.getSeedLanguage(), "Spanish");
        assert(!(await wallet.isSynced()));
        assert.equal(await wallet.getHeight(), 1); // TODO monero-project: why is height of unsynced wallet 1?
        if (await that.daemon.isConnected()) assert.equal(await wallet.getRestoreHeight(), await that.daemon.getHeight());
        else assert(await wallet.getRestoreHeight() >= 0);
        await wallet.close();
      });
      
      if (testConfig.testNonRelays)
      it("Can create a full wallet from seed", async function() {
        
        // create unconnected wallet with mnemonic
        let wallet = await that.createWallet({seed: TestUtils.SEED, serverUri: TestUtils.OFFLINE_SERVER_URI});
        assert.equal(await wallet.getSeed(), TestUtils.SEED);
        assert.equal(await wallet.getPrimaryAddress(), TestUtils.ADDRESS);
        assert.equal(await wallet.getNetworkType(), TestUtils.NETWORK_TYPE);
        assert.deepEqual(await wallet.getDaemonConnection(), new MoneroRpcConnection(TestUtils.OFFLINE_SERVER_URI));
        assert(!(await wallet.isConnectedToDaemon()));
        assert.equal(await wallet.getSeedLanguage(), "English");
        assert(!(await wallet.isSynced()));
        assert.equal(await wallet.getHeight(), 1);
        assert.equal(await wallet.getRestoreHeight(), 0);
        try { await wallet.startSyncing(); } catch (e) { assert.equal(e.message, "Wallet is not connected to daemon"); }
        await wallet.close();
        
        // create wallet without restore height
        wallet = await that.createWallet({seed: TestUtils.SEED}, false);
        assert.equal(await wallet.getSeed(), TestUtils.SEED);
        assert.equal(await wallet.getPrimaryAddress(), TestUtils.ADDRESS);
        assert.equal(TestUtils.NETWORK_TYPE, await wallet.getNetworkType());
        assert(await wallet.getDaemonConnection());
        assert(await that.daemon.getRpcConnection() != await wallet.getDaemonConnection());
        assert.equal((await wallet.getDaemonConnection()).getUri(), (await that.daemon.getRpcConnection()).getUri());
        assert.equal((await wallet.getDaemonConnection()).getUsername(), (await that.daemon.getRpcConnection()).getUsername());
        assert.equal((await wallet.getDaemonConnection()).getPassword(), (await that.daemon.getRpcConnection()).getPassword());
        assert(await wallet.isConnectedToDaemon());
        assert.equal(await wallet.getSeedLanguage(), "English");
        assert(!(await wallet.isSynced()));
        assert.equal(await wallet.getHeight(), 1); // TODO monero-project: why does height of new unsynced wallet start at 1?
        assert.equal(await wallet.getRestoreHeight(), 0);
        await wallet.close();
        
        // create wallet with seed, no connection, and restore height
        let restoreHeight = 10000;
        wallet = await that.createWallet({seed: TestUtils.SEED, restoreHeight: restoreHeight, serverUri: TestUtils.OFFLINE_SERVER_URI});
        assert.equal(await wallet.getSeed(), TestUtils.SEED);
        assert.equal(await wallet.getPrimaryAddress(), TestUtils.ADDRESS);
        assert.equal(await wallet.getNetworkType(), TestUtils.NETWORK_TYPE);
        assert.deepEqual(await wallet.getDaemonConnection(), new MoneroRpcConnection(TestUtils.OFFLINE_SERVER_URI));
        assert(!(await wallet.isConnectedToDaemon()));
        assert.equal(await wallet.getSeedLanguage(), "English");
        assert.equal(await wallet.getHeight(), 1); // TODO monero-project: why does height of new unsynced wallet start at 1?
        assert.equal(await wallet.getRestoreHeight(), restoreHeight);
        let path = await wallet.getPath();
        await wallet.close(true);
        wallet = await that.openWallet({path: path, serverUri: TestUtils.OFFLINE_SERVER_URI});
        assert(!(await wallet.isConnectedToDaemon()));
        assert(!(await wallet.isSynced()));
        assert.equal(await wallet.getHeight(), 1);
        assert.equal(await wallet.getRestoreHeight(), restoreHeight);
        await wallet.close();

        // create wallet with seed, connection, and restore height
        wallet = await that.createWallet({seed: TestUtils.SEED, restoreHeight: restoreHeight}, false);
        assert.equal(await wallet.getSeed(), TestUtils.SEED);
        assert(await wallet.getPrimaryAddress(), TestUtils.ADDRESS);
        assert(await wallet.getNetworkType(), TestUtils.NETWORK_TYPE);
        assert(await wallet.getDaemonConnection());
        assert(await that.daemon.getRpcConnection() != wallet.getDaemonConnection());
        assert.equal((await wallet.getDaemonConnection()).getUri(), (await that.daemon.getRpcConnection()).getUri());
        assert.equal((await wallet.getDaemonConnection()).getUsername(), (await that.daemon.getRpcConnection()).getUsername());
        assert.equal((await wallet.getDaemonConnection()).getPassword(), (await that.daemon.getRpcConnection()).getPassword());
        assert(await wallet.isConnectedToDaemon());
        assert.equal(await wallet.getSeedLanguage(), "English");
        assert(!(await wallet.isSynced()));
        assert.equal(await wallet.getHeight(), 1); // TODO monero-project: why does height of new unsynced wallet start at 1?
        assert.equal(await wallet.getRestoreHeight(), restoreHeight);
        await wallet.close();
      });
      
      if (testConfig.testNonRelays)
      it("Can create a full wallet from keys", async function() {
        
        // recreate test wallet from keys
        let wallet = that.wallet;
        let walletKeys = await that.createWallet({serverUri: TestUtils.OFFLINE_SERVER_URI, networkType: await wallet.getNetworkType(), primaryAddress: await wallet.getPrimaryAddress(), privateViewKey: await wallet.getPrivateViewKey(), privateSpendKey: await wallet.getPrivateSpendKey(), restoreHeight: TestUtils.FIRST_RECEIVE_HEIGHT});
        let err;
        try {
          assert.equal(await walletKeys.getSeed(), await wallet.getSeed());
          assert.equal(await walletKeys.getPrimaryAddress(), await wallet.getPrimaryAddress());
          assert.equal(await walletKeys.getPrivateViewKey(), await wallet.getPrivateViewKey());
          assert.equal(await walletKeys.getPublicViewKey(), await wallet.getPublicViewKey());
          assert.equal(await walletKeys.getPrivateSpendKey(), await wallet.getPrivateSpendKey());
          assert.equal(await walletKeys.getPublicSpendKey(), await wallet.getPublicSpendKey());
          assert.equal(await walletKeys.getRestoreHeight(), TestUtils.FIRST_RECEIVE_HEIGHT);
          assert(!await walletKeys.isConnectedToDaemon());
          assert(!(await walletKeys.isSynced()));
        } catch (e) {
          err = e;
        }
        await walletKeys.close();
        if (err) throw err;
      });
      
      if (testConfig.testNonRelays && !GenUtils.isBrowser())
      it("Is compatible with monero-wallet-rpc wallet files", async function() {
        
        // create wallet using monero-wallet-rpc
        let walletName = GenUtils.getUUID();
        let walletRpc = await TestUtils.getWalletRpc();
        await walletRpc.createWallet(new MoneroWalletConfig().setPath(walletName).setPassword(TestUtils.WALLET_PASSWORD).setSeed(TestUtils.SEED).setRestoreHeight(TestUtils.FIRST_RECEIVE_HEIGHT));
        await walletRpc.sync();
        let balance = await walletRpc.getBalance();
        let outputsHex = await walletRpc.exportOutputs();
        assert(outputsHex.length > 0);
        await walletRpc.close(true);
        
        // open as full wallet
        let walletFull = await monerojs.openWalletFull(new MoneroWalletConfig().setPath(TestUtils.WALLET_RPC_LOCAL_WALLET_DIR + "/" + walletName).setPassword(TestUtils.WALLET_PASSWORD).setNetworkType(TestUtils.NETWORK_TYPE).setServer(TestUtils.DAEMON_RPC_CONFIG));
        await walletFull.sync();
        assert.equal(TestUtils.SEED, await walletFull.getSeed());
        assert.equal(TestUtils.ADDRESS, await walletFull.getPrimaryAddress());
        assert.equal(balance.toString(), (await walletFull.getBalance()).toString());
        assert.equal(outputsHex.length, (await walletFull.exportOutputs()).length);
        await walletFull.close(true);
        
        // create full wallet
        walletName = GenUtils.getUUID();
        let path = TestUtils.WALLET_RPC_LOCAL_WALLET_DIR + "/" + walletName;
        walletFull = await monerojs.createWalletFull(new MoneroWalletConfig().setPath(path).setPassword(TestUtils.WALLET_PASSWORD).setNetworkType(TestUtils.NETWORK_TYPE).setSeed(TestUtils.SEED).setRestoreHeight(TestUtils.FIRST_RECEIVE_HEIGHT).setServer(TestUtils.DAEMON_RPC_CONFIG));
        await walletFull.sync();
        balance = await walletFull.getBalance();
        outputsHex = await walletFull.exportOutputs();
        await walletFull.close(true);
        
        // rebuild wallet cache using full wallet
        TestUtils.getDefaultFs().unlinkSync(path);
        walletFull = await monerojs.openWalletFull(new MoneroWalletConfig().setPath(path).setPassword(TestUtils.WALLET_PASSWORD).setNetworkType(TestUtils.NETWORK_TYPE).setServer(TestUtils.DAEMON_RPC_CONFIG));
        await walletFull.close(true);
        
        // open wallet using monero-wallet-rpc
        await walletRpc.openWallet(new MoneroWalletConfig().setPath(walletName).setPassword(TestUtils.WALLET_PASSWORD));
        await walletRpc.sync();
        assert.equal(TestUtils.SEED, await walletRpc.getSeed());
        assert.equal(TestUtils.ADDRESS, await walletRpc.getPrimaryAddress());
        assert.equal(balance.toString(), (await walletRpc.getBalance()).toString());
        assert.equal(outputsHex.length, (await walletRpc.exportOutputs()).length);
        await walletRpc.close(true);
      });
      
      if (!testConfig.liteMode && (testConfig.testNonRelays || testConfig.testRelays))
      it("Is compatible with monero-wallet-rpc outputs and offline transaction signing", async function() {
        
        // create view-only wallet in wallet rpc process
        let viewOnlyWallet = await TestUtils.startWalletRpcProcess();
        await viewOnlyWallet.createWallet({path: GenUtils.getUUID(), password: TestUtils.WALLET_PASSWORD, primaryAddress: await that.wallet.getPrimaryAddress(), privateViewKey: await that.wallet.getPrivateViewKey(), restoreHeight: TestUtils.FIRST_RECEIVE_HEIGHT});
        await viewOnlyWallet.sync();
        
        // create offline full wallet
        let offlineWallet = await that.createWallet({primaryAddress: await that.wallet.getPrimaryAddress(), privateViewKey: await that.wallet.getPrivateViewKey(), privateSpendKey: await that.wallet.getPrivateSpendKey(), serverUri: TestUtils.OFFLINE_SERVER_URI, restoreHeight: 0});
        
        // test tx signing with wallets
        let err;
        try { await that._testViewOnlyAndOfflineWallets(viewOnlyWallet, offlineWallet); }
        catch (e) { err = e; }
        
        // finally
        TestUtils.stopWalletRpcProcess(viewOnlyWallet);
        await that.closeWallet(offlineWallet);
        if (err) throw err;
      });
      
      if (!testConfig.liteMode)
      it("Is compatible with monero-wallet-rpc multisig wallets", async function() {
        
        // create participants with monero-wallet-rpc and full wallet
        let participants = [];
        participants.push(await (await TestUtils.startWalletRpcProcess()).createWallet(new MoneroWalletConfig().setPath(GenUtils.getUUID()).setPassword(TestUtils.WALLET_PASSWORD)));
        participants.push(await (await TestUtils.startWalletRpcProcess()).createWallet(new MoneroWalletConfig().setPath(GenUtils.getUUID()).setPassword(TestUtils.WALLET_PASSWORD)));
        participants.push(await that.createWallet(new MoneroWalletConfig()));
        
        // test multisig
        let err;
        try {
          await that._testMultisigParticipants(participants, 3, 3, true);
        } catch (e) {
          err = e;
        }
        
        // stop mining at end of test
        try { await that.daemon.stopMining(); }
        catch (e) { }
        
        // save and close participants
        if (participants[0] instanceof monerojs.MoneroWalletRpc) await TestUtils.stopWalletRpcProcess(participants[0]);
        else participants[0].close(true); // multisig tests might restore wallet from seed
        await TestUtils.stopWalletRpcProcess(participants[1]);
        await that.closeWallet(participants[2], true);
        if (err) throw err;
      });
      
      // TODO monero-project: cannot re-sync from lower block height after wallet saved
      if (testConfig.testNonRelays && !testConfig.liteMode && false)
      it("Can re-sync an existing wallet from scratch", async function() {
        let wallet = await that.openWallet({path: TestUtils.WALLET_FULL_PATH, password: TestUtils.WALLET_PASSWORD, networkType: MoneroNetworkType.TESTNET, serverUri: TestUtils.OFFLINE_SERVER_URI}, true);  // wallet must already exist
        await wallet.setDaemonConnection(TestUtils.getDaemonRpcConnection());
        //long startHeight = TestUtils.TEST_RESTORE_HEIGHT;
        let startHeight = 0;
        let progressTester = new SyncProgressTester(wallet, startHeight, await wallet.getDaemonHeight());
        await wallet.setRestoreHeight(1);
        let result = await wallet.sync(progressTester, 1);
        await progressTester.onDone(await wallet.getDaemonHeight());
        
        // test result after syncing
        assert(await wallet.isConnectedToDaemon());
        assert(await wallet.isSynced());
        assert.equal(result.getNumBlocksFetched(), await wallet.getDaemonHeight() - startHeight);
        assert(result.getReceivedMoney());
        assert.equal(await wallet.getHeight(), await that.daemon.getHeight());
        await wallet.close();
      });
      
      if (testConfig.testNonRelays)
      it("Can sync a wallet with a randomly generated seed", async function() {
        assert(await that.daemon.isConnected(), "Not connected to daemon");

        // create test wallet
        let restoreHeight = await that.daemon.getHeight();
        let wallet = await that.createWallet({}, false);

        // test wallet's height before syncing
        let walletGt;
        let err;
        try {
          assert.equal((await wallet.getDaemonConnection()).getUri(), (await that.daemon.getRpcConnection()).getUri());
          assert.equal((await wallet.getDaemonConnection()).getUsername(), (await that.daemon.getRpcConnection()).getUsername());
          assert.equal((await wallet.getDaemonConnection()).getPassword(), (await that.daemon.getRpcConnection()).getPassword());
          assert.equal(await wallet.getDaemonHeight(), restoreHeight);
          assert(await wallet.isConnectedToDaemon());
          assert(!(await wallet.isSynced()));
          assert.equal(await wallet.getHeight(), 1);
          assert.equal(await wallet.getRestoreHeight(), restoreHeight);
  
          // sync the wallet
          let progressTester = new SyncProgressTester(wallet, await wallet.getRestoreHeight(), await wallet.getDaemonHeight());
          let result = await wallet.sync(progressTester, undefined);
          await progressTester.onDone(await wallet.getDaemonHeight());
        
          // test result after syncing
          walletGt = await that.createWallet({seed: await wallet.getSeed(), restoreHeight: restoreHeight});
          await walletGt.sync();
          assert(await wallet.isConnectedToDaemon());
          assert(await wallet.isSynced());
          assert.equal(result.getNumBlocksFetched(), 0);
          assert(!result.getReceivedMoney());
          if (await wallet.getHeight() !== await that.daemon.getHeight()) console.log("WARNING: wallet height " + await wallet.getHeight() + " is not synced with daemon height " + await that.daemon.getHeight());  // TODO: height may not be same after long sync

          // sync the wallet with default params
          await wallet.sync();
          assert(await wallet.isSynced());
          assert.equal(await wallet.getHeight(), await that.daemon.getHeight());
          
          // compare wallet to ground truth
          await TestMoneroWalletFull._testWalletEqualityOnChain(walletGt, wallet);
        } catch (e) {
          err = e;
        }
        
        // finally 
        if (walletGt) await walletGt.close();
        await wallet.close();
        if (err) throw err;
        
        // attempt to sync unconnected wallet
        wallet = await that.createWallet({serverUri: TestUtils.OFFLINE_SERVER_URI});
        err = undefined;
        try {
          await wallet.sync();
          throw new Error("Should have thrown exception");
        } catch (e1) {
          try {
            assert.equal(e1.message, "Wallet is not connected to daemon");
          } catch (e2) {
            err = e2;
          }
        }
        
        // finally
        await wallet.close();
        if (err) throw err;
      });
      
      if (false && testConfig.testNonRelays && !testConfig.liteMode) // TODO: re-enable before release
      it("Can sync a wallet created from seed from the genesis", async function() {
        await _testSyncSeed(undefined, undefined, true, false);
      });
      
      if (testConfig.testNonRelays)
      it("Can sync a wallet created from seed from a restore height", async function() {
        await _testSyncSeed(undefined, TestUtils.FIRST_RECEIVE_HEIGHT);
      });
      
      if (testConfig.testNonRelays && !testConfig.liteMode)
      it("Can sync a wallet created from seed from a start height.", async function() {
        await _testSyncSeed(TestUtils.FIRST_RECEIVE_HEIGHT, undefined, false, true);
      });
      
      if (testConfig.testNonRelays && !testConfig.liteMode)
      it("Can sync a wallet created from seed from a start height less than the restore height", async function() {
        await _testSyncSeed(TestUtils.FIRST_RECEIVE_HEIGHT, TestUtils.FIRST_RECEIVE_HEIGHT + 3);
      });
      
      if (testConfig.testNonRelays && !testConfig.liteMode)
      it("Can sync a wallet created from seed from a start height greater than the restore height", async function() {
        await _testSyncSeed(TestUtils.FIRST_RECEIVE_HEIGHT + 3, TestUtils.FIRST_RECEIVE_HEIGHT);
      });
      
      async function _testSyncSeed(startHeight, restoreHeight, skipGtComparison, testPostSyncNotifications) {
        assert(await that.daemon.isConnected(), "Not connected to daemon");
        if (startHeight !== undefined && restoreHeight != undefined) assert(startHeight <= TestUtils.FIRST_RECEIVE_HEIGHT || restoreHeight <= TestUtils.FIRST_RECEIVE_HEIGHT);
        
        // create wallet from seed
        let wallet = await that.createWallet({seed: TestUtils.SEED, restoreHeight: restoreHeight}, false);
        
        // sanitize expected sync bounds
        if (restoreHeight === undefined) restoreHeight = 0;
        let startHeightExpected = startHeight === undefined ? restoreHeight : startHeight;
        if (startHeightExpected === 0) startHeightExpected = 1;
        let endHeightExpected = await wallet.getDaemonMaxPeerHeight();
        
        // test wallet and close as final step
        let walletGt = undefined;
        let err = undefined;  // to permit final cleanup like Java's try...catch...finally
        try {
          
          // test wallet's height before syncing
          assert(await wallet.isConnectedToDaemon());
          assert(!(await wallet.isSynced()));
          assert.equal(await wallet.getHeight(), 1);
          assert.equal(await wallet.getRestoreHeight(), restoreHeight);
          
          // register a wallet listener which tests notifications throughout the sync
          let walletSyncTester = new WalletSyncTester(wallet, startHeightExpected, endHeightExpected);
          await wallet.addListener(walletSyncTester);
          
          // sync the wallet with a listener which tests sync notifications
          let progressTester = new SyncProgressTester(wallet, startHeightExpected, endHeightExpected);
          let result = await wallet.sync(progressTester, startHeight);
          
          // test completion of the wallet and sync listeners
          await progressTester.onDone(await wallet.getDaemonHeight());
          await walletSyncTester.onDone(await wallet.getDaemonHeight());
          
          // test result after syncing
          assert(await wallet.isSynced());
          assert.equal(result.getNumBlocksFetched(), await wallet.getDaemonHeight() - startHeightExpected);
          assert(result.getReceivedMoney());
          if (await wallet.getHeight() !== await that.daemon.getHeight()) console.log("WARNING: wallet height " + await wallet.getHeight() + " is not synced with daemon height " + await that.daemon.getHeight());  // TODO: height may not be same after long sync
          assert.equal(await wallet.getDaemonHeight(), await that.daemon.getHeight(), "Daemon heights are not equal: " + await wallet.getDaemonHeight() + " vs " + await that.daemon.getHeight());
          if (startHeightExpected > TestUtils.FIRST_RECEIVE_HEIGHT) assert((await wallet.getTxs())[0].getHeight() > TestUtils.FIRST_RECEIVE_HEIGHT);  // wallet is partially synced so first tx happens after true restore height
          else assert.equal((await wallet.getTxs())[0].getHeight(), TestUtils.FIRST_RECEIVE_HEIGHT);  // wallet should be fully synced so first tx happens on true restore height
          
          // sync the wallet with default params
          result = await wallet.sync();
          assert(await wallet.isSynced());
          if (await wallet.getHeight() !== await that.daemon.getHeight()) console.log("WARNING: wallet height " + await wallet.getHeight() + " is not synced with daemon height " + await that.daemon.getHeight() + " after re-syncing");
          assert.equal(result.getNumBlocksFetched(), 0);
          assert(!result.getReceivedMoney());
          
          // compare with ground truth
          if (!skipGtComparison) {
            walletGt = await TestUtils.createWalletGroundTruth(TestUtils.NETWORK_TYPE, await wallet.getSeed(), startHeight, restoreHeight);
            await TestMoneroWalletFull._testWalletEqualityOnChain(walletGt, wallet);
          }
          
          // if testing post-sync notifications, wait for a block to be added to the chain
          // then test that sync arg listener was not invoked and registered wallet listener was invoked
          if (testPostSyncNotifications) {
            
            // start automatic syncing
            await wallet.startSyncing(TestUtils.SYNC_PERIOD_IN_MS);
            
            // attempt to start mining to push the network along  // TODO: TestUtils.tryStartMining() : reqId, TestUtils.tryStopMining(reqId)
            let startedMining = false;
            let miningStatus = await that.daemon.getMiningStatus();
            if (!miningStatus.isActive()) {
              try {
                await StartMining.startMining();
                startedMining = true;
              } catch (e) {
                // no problem
              }
            }
            
            try {
              
              // wait for block
              console.log("Waiting for next block to test post sync notifications");
              await that.daemon.waitForNextBlockHeader();
              
              // ensure wallet has time to detect new block
              await new Promise(function(resolve) { setTimeout(resolve, TestUtils.SYNC_PERIOD_IN_MS + 3000); }); // sleep for wallet interval + time to sync
              
              // test that wallet listener's onSyncProgress() and onNewBlock() were invoked after previous completion
              assert(walletSyncTester.getOnSyncProgressAfterDone());
              assert(walletSyncTester.getOnNewBlockAfterDone());
            } catch (e) {
              err = e;
            }
            
            // finally
            if (startedMining) {
              await that.daemon.stopMining();
              //await wallet.stopMining();  // TODO: support client-side mining?
            }
            if (err) throw err;
          }
        } catch (e) {
          err = e;
        }
        
        // finally
        if (walletGt !== undefined) await walletGt.close(true);
        await wallet.close();
        if (err) throw err;
      }
      
      if (testConfig.testNonRelays)
      it("Can sync a wallet created from keys", async function() {
        
        // recreate test wallet from keys
        let walletKeys = await that.createWallet({primaryAddress: await that.wallet.getPrimaryAddress(), privateViewKey: await that.wallet.getPrivateViewKey(), privateSpendKey: await that.wallet.getPrivateSpendKey(), restoreHeight: TestUtils.FIRST_RECEIVE_HEIGHT}, false);
        
        // create ground truth wallet for comparison
        let walletGt = await TestUtils.createWalletGroundTruth(TestUtils.NETWORK_TYPE, TestUtils.SEED, undefined, TestUtils.FIRST_RECEIVE_HEIGHT);
        
        // test wallet and close as final step
        let err;
        try {
          assert.equal(await walletKeys.getSeed(), await walletGt.getSeed());
          assert.equal(await walletKeys.getPrimaryAddress(), await walletGt.getPrimaryAddress());
          assert.equal(await walletKeys.getPrivateViewKey(), await walletGt.getPrivateViewKey());
          assert.equal(await walletKeys.getPublicViewKey(), await walletGt.getPublicViewKey());
          assert.equal(await walletKeys.getPrivateSpendKey(), await walletGt.getPrivateSpendKey());
          assert.equal(await walletKeys.getPublicSpendKey(), await walletGt.getPublicSpendKey());
          assert.equal(await walletKeys.getRestoreHeight(), TestUtils.FIRST_RECEIVE_HEIGHT);
          assert(await walletKeys.isConnectedToDaemon());
          assert(!(await walletKeys.isSynced()));

          // sync the wallet
          let progressTester = new SyncProgressTester(walletKeys, TestUtils.FIRST_RECEIVE_HEIGHT, await walletKeys.getDaemonMaxPeerHeight());
          let result = await walletKeys.sync(progressTester);
          await progressTester.onDone(await walletKeys.getDaemonHeight());
          
          // test result after syncing
          assert(await walletKeys.isSynced());
          assert.equal(result.getNumBlocksFetched(), await walletKeys.getDaemonHeight() - TestUtils.FIRST_RECEIVE_HEIGHT);
          assert(result.getReceivedMoney());
          assert.equal(await walletKeys.getHeight(), await that.daemon.getHeight());
          assert.equal(await walletKeys.getDaemonHeight(), await that.daemon.getHeight());
          assert.equal((await walletKeys.getTxs())[0].getHeight(), TestUtils.FIRST_RECEIVE_HEIGHT);  // wallet should be fully synced so first tx happens on true restore height
          
          // compare with ground truth
          await TestMoneroWalletFull._testWalletEqualityOnChain(walletGt, walletKeys);
        } catch (e) {
          err = e;
        }
        
        // finally
        await walletGt.close(true);
        await walletKeys.close();
        if (err) throw err;
      });
      
      // TODO: test start syncing, notification of syncs happening, stop syncing, no notifications, etc
      if (testConfig.testNonRelays)
      it("Can start and stop syncing", async function() {
        
        // test unconnected wallet
        let err;  // used to emulate Java's try...catch...finally
        let path = TestMoneroWalletFull._getRandomWalletPath();
        let wallet = await that.createWallet({path: path, password: TestUtils.WALLET_PASSWORD, networkType: TestUtils.NETWORK_TYPE, serverUri: TestUtils.OFFLINE_SERVER_URI});
        try {
          assert.notEqual(await wallet.getSeed(), undefined);
          assert.equal(await wallet.getHeight(), 1);
          assert.equal(await wallet.getBalance(), BigInteger.parse("0"));
          await wallet.startSyncing();
        } catch (e1) {  // first error is expected
          try {
            assert.equal(e1.message, "Wallet is not connected to daemon");
          } catch (e2) {
            err = e2;
          }
        }
        
        // finally
        await wallet.close();
        if (err) throw err;
        
        // test connecting wallet
        path = TestMoneroWalletFull._getRandomWalletPath();
        wallet = await that.createWallet({path: path, password: TestUtils.WALLET_PASSWORD, networkType: TestUtils.NETWORK_TYPE, serverUri: TestUtils.OFFLINE_SERVER_URI});
        try {
          assert.notEqual(wallet.getSeed(), undefined);
          assert(!await wallet.isConnectedToDaemon());
          await wallet.setDaemonConnection(await that.daemon.getRpcConnection());
          assert.equal(await wallet.getHeight(), 1);
          assert(!await wallet.isSynced());
          assert.equal((await wallet.getBalance()).compare(new BigInteger(0)), 0);
          let chainHeight = await wallet.getDaemonHeight();
          await wallet.setRestoreHeight(chainHeight - 3);
          await wallet.startSyncing();
          assert.equal(await wallet.getRestoreHeight(), chainHeight - 3);
          assert.equal((await wallet.getDaemonConnection()).getUri(), (await that.daemon.getRpcConnection()).getUri()); // TODO: replace with config comparison
          assert.equal((await wallet.getDaemonConnection()).getUsername(), (await that.daemon.getRpcConnection()).getUsername());
          assert.equal((await wallet.getDaemonConnection()).getPassword(), (await that.daemon.getRpcConnection()).getPassword());
          await wallet.stopSyncing();
          await wallet.sync();
          await wallet.stopSyncing();
          await wallet.stopSyncing();
        } catch (e) {
          err = e;
        }
        
        // finally
        await wallet.close();
        if (err) throw err;
        
        // test that sync starts automatically
        let restoreHeight = await that.daemon.getHeight() - 100;
        path = TestMoneroWalletFull._getRandomWalletPath();
        wallet = await that.createWallet({path: path, password: TestUtils.WALLET_PASSWORD, networkType: TestUtils.NETWORK_TYPE, seed: TestUtils.SEED, server: await that.daemon.getRpcConnection(), restoreHeight: restoreHeight}, false);
        try {
          
          // start syncing
          assert.equal(await wallet.getHeight(), 1);
          assert.equal(await wallet.getRestoreHeight(), restoreHeight);
          assert(!(await wallet.isSynced()));
          assert.equal(await wallet.getBalance(), new BigInteger(0));
          await wallet.startSyncing(TestUtils.SYNC_PERIOD_IN_MS);
          
          // pause for sync to start
          await new Promise(function(resolve) { setTimeout(resolve, TestUtils.SYNC_PERIOD_IN_MS + 1000); }); // in ms
          
          // test that wallet has started syncing
          assert(await wallet.getHeight() > 1);
          
          // stop syncing
          await wallet.stopSyncing();
          
       // TODO monero-project: wallet.cpp m_synchronized only ever set to true, never false
//          // wait for block to be added to chain
//          await that.daemon.waitForNextBlockHeader();
//          
//          // wallet is no longer synced
//          assert(!(await wallet.isSynced()));  
        } catch (e) {
          err = e;
        }
                
        // finally
        await wallet.close();
        if (err) throw err;
      });
      
      if (testConfig.testNonRelays)
      it("Does not interfere with other wallet notifications", async function() {
        
        // create 2 wallets with a recent restore height
        let height = await that.daemon.getHeight();
        let restoreHeight = height - 5;
        let wallet1 = await that.createWallet({seed: TestUtils.SEED, restoreHeight: restoreHeight}, false);
        let wallet2 = await that.createWallet({seed: TestUtils.SEED, restoreHeight: restoreHeight}, false);
        
        // track notifications of each wallet
        let tester1 = new SyncProgressTester(wallet1, restoreHeight, height);
        let tester2 = new SyncProgressTester(wallet2, restoreHeight, height);
        await wallet1.addListener(tester1);
        await wallet2.addListener(tester2);
        
        // sync first wallet and test that 2nd is not notified
        await wallet1.sync();
        assert(tester1.isNotified());
        assert(!tester2.isNotified());
        
        // sync 2nd wallet and test that 1st is not notified
        let tester3 = new SyncProgressTester(wallet1, restoreHeight, height);
        await wallet1.addListener(tester3);
        await wallet2.sync();
        assert(tester2.isNotified());
        assert(!(tester3.isNotified()));
        
        // close wallets
        await wallet1.close();
        await wallet2.close();
      });
      
      if (testConfig.testNonRelays)
      it("Is equal to the RPC wallet.", async function() {
        await WalletEqualityUtils.testWalletEqualityOnChain(await TestUtils.getWalletRpc(), that.wallet);
      });

      if (testConfig.testNonRelays)
      it("Is equal to the RPC wallet with a seed offset", async function() {
        
        // use common offset to compare wallet implementations
        let seedOffset = "my super secret offset!";
        
        // create rpc wallet with offset
        let walletRpc = await TestUtils.getWalletRpc();
        await walletRpc.createWallet({path: GenUtils.getUUID(), password: TestUtils.WALLET_PASSWORD, seed: await walletRpc.getSeed(), restoreHeight: TestUtils.FIRST_RECEIVE_HEIGHT, seedOffset: seedOffset});
        
        // create full wallet with offset
        let walletFull = await that.createWallet({seed: TestUtils.SEED, restoreHeight: TestUtils.FIRST_RECEIVE_HEIGHT, seedOffset: seedOffset});
        
        // deep compare
        let err;
        try {
          await WalletEqualityUtils.testWalletEqualityOnChain(walletRpc, walletFull);
        } catch (e) {
          err = e;
        }
        await walletFull.close();
        if (err) throw err;
      });
      
      if (testConfig.testNonRelays)
      it("Supports multisig sample code", async function() {
        await testCreateMultisigWallet(2, 2);
        await testCreateMultisigWallet(2, 3);
        await testCreateMultisigWallet(2, 4);
      });
      
      async function testCreateMultisigWallet(M, N) {
        console.log("Creating " + M + "/" + N + " multisig wallet");
        
        // create participating wallets
        let wallets = []
        for (let i = 0; i < N; i++) {
          wallets.push(await that.createWallet());
        }
        
        // prepare and collect multisig hex from each participant
        let preparedMultisigHexes = []
        for (let wallet of wallets) preparedMultisigHexes.push(await wallet.prepareMultisig());
        
        // make each wallet multisig and collect results
        let madeMultisigHexes = [];
        for (let i = 0; i < wallets.length; i++) {
          
          // collect prepared multisig hexes from wallet's peers
          let peerMultisigHexes = [];
          for (let j = 0; j < wallets.length; j++) if (j !== i) peerMultisigHexes.push(preparedMultisigHexes[j]);
        
          // make wallet multisig and collect result hex
          let multisigHex = await wallets[i].makeMultisig(peerMultisigHexes, M, TestUtils.WALLET_PASSWORD);
          madeMultisigHexes.push(multisigHex);
        }
        
        // exchange multisig keys N - M + 1 times
        let multisigHexes = madeMultisigHexes;
        for (let i = 0; i < N - M + 1; i++) {
          
          // exchange multisig keys among participants and collect results for next round if applicable
          let resultMultisigHexes = [];
          for (let wallet of wallets) {
            
            // import the multisig hex of other participants and collect results
            let result = await wallet.exchangeMultisigKeys(multisigHexes, TestUtils.WALLET_PASSWORD);
            resultMultisigHexes.push(result.getMultisigHex());
          }
          
          // use resulting multisig hex for next round of exchange if applicable
          multisigHexes = resultMultisigHexes;
        }
        
        // wallets are now multisig
        for (let wallet of wallets) {
          let primaryAddress = await wallet.getAddress(0, 0);
          await MoneroUtils.validateAddress(primaryAddress, await wallet.getNetworkType());
          let info = await wallet.getMultisigInfo();
          assert(info.isMultisig());
          assert(info.isReady());
          assert.equal(info.getThreshold(), M);
          assert.equal(info.getNumParticipants(), N);
          await wallet.close(true);
        }
      }
      
      if (testConfig.testNonRelays)
      it("Can be saved", async function() {
        
        // create unique path for new test wallet
        let path = TestMoneroWalletFull._getRandomWalletPath();
        
        // wallet does not exist
        assert(!(await MoneroWalletFull.walletExists(path, TestUtils.getDefaultFs())));
        
        // cannot open non-existent wallet
        try {
          await that.openWallet({path: path, serverUri: ""});
          throw new Error("Cannot open non-existent wallet");
        } catch (e) {
          assert.equal(e.message, "Wallet does not exist at path: " + path);
        }
        
        // create wallet at the path
        let restoreHeight = await that.daemon.getHeight() - 200;
        let wallet = await that.createWallet({path: path, password: TestUtils.WALLET_PASSWORD, networkType: TestUtils.NETWORK_TYPE, seed: TestUtils.SEED, restoreHeight: restoreHeight, serverUri: TestUtils.OFFLINE_SERVER_URI});
        
        // test wallet at newly created state
        let err;
        try {
          assert(await MoneroWalletFull.walletExists(path, TestUtils.getDefaultFs()));
          assert.equal(await wallet.getSeed(), TestUtils.SEED);
          assert.equal(await wallet.getNetworkType(), TestUtils.NETWORK_TYPE);
          assert.deepEqual(await wallet.getDaemonConnection(), new MoneroRpcConnection(TestUtils.OFFLINE_SERVER_URI));
          assert.equal(await wallet.getRestoreHeight(), restoreHeight);
          assert.equal(await wallet.getSeedLanguage(), "English");
          assert.equal(await wallet.getHeight(), 1);
          assert.equal(await wallet.getRestoreHeight(), restoreHeight);
          
          // set the wallet's connection and sync
          await wallet.setDaemonConnection(TestUtils.getDaemonRpcConnection());
          await wallet.sync();
          if (await wallet.getHeight() !== await wallet.getDaemonHeight()) console.log("WARNING: wallet height " + await wallet.getHeight() + " is not synced with daemon height " + await that.daemon.getHeight());  // TODO: height may not be same after long sync
          
          // close the wallet without saving
          await wallet.close();
          
          // re-open the wallet
          wallet = await that.openWallet({path: path, serverUri: TestUtils.OFFLINE_SERVER_URI});
          
          // test wallet is at newly created state
          assert(await MoneroWalletFull.walletExists(path, TestUtils.getDefaultFs()));
          assert.equal(await wallet.getSeed(), TestUtils.SEED);
          assert.equal(await wallet.getNetworkType(), TestUtils.NETWORK_TYPE);
          assert.deepEqual(await wallet.getDaemonConnection(), new MoneroRpcConnection(TestUtils.OFFLINE_SERVER_URI));
          assert(!(await wallet.isConnectedToDaemon()));
          assert.equal(await wallet.getSeedLanguage(), "English");
          assert(!(await wallet.isSynced()));
          assert.equal(await wallet.getHeight(), 1);
          assert(await wallet.getRestoreHeight() > 0);
          
          // set the wallet's connection and sync
          await wallet.setDaemonConnection(TestUtils.getDaemonRpcConnection());
          assert(await wallet.isConnectedToDaemon());
          await wallet.setRestoreHeight(restoreHeight);
          await wallet.sync();
          assert(await wallet.isSynced());
          assert.equal(await wallet.getHeight(), await wallet.getDaemonHeight());
          let prevHeight = await wallet.getHeight();
          
          // save and close the wallet
          await wallet.save();
          await wallet.close();
          
          // re-open the wallet
          wallet = await that.openWallet({path: path, serverUri: TestUtils.OFFLINE_SERVER_URI});
          
          // test wallet state is saved
          assert(!(await wallet.isConnectedToDaemon()));
          await wallet.setDaemonConnection(TestUtils.getDaemonRpcConnection());  // TODO monero-project: daemon connection not stored in wallet files so must be explicitly set each time
          assert.deepEqual(await wallet.getDaemonConnection(), TestUtils.getDaemonRpcConnection());
          assert(await wallet.isConnectedToDaemon());
          assert.equal(await wallet.getHeight(), prevHeight);
          assert(await wallet.getRestoreHeight() > 0);
          assert(await MoneroWalletFull.walletExists(path, TestUtils.getDefaultFs()));
          assert.equal(await wallet.getSeed(), TestUtils.SEED);
          assert.equal(await wallet.getNetworkType(), TestUtils.NETWORK_TYPE);
          assert.equal(await wallet.getSeedLanguage(), "English");
          
          // sync
          await wallet.sync();
        } catch (e) {
          err = e;
        }
        
        // finally
        await wallet.close();
        if (err) throw err;
      });
      
      if (testConfig.testNonRelays)
      it("Can export and import wallet files", async function() {
        let err;
        let wallet
        try {
          
          // create random wallet
          wallet = await monerojs.createWalletFull({
            networkType: "mainnet",
            password: "password123"
          });
          
          // export wallet files
          let walletData = await wallet.getData();
          let keysData = walletData[0];
          let cacheData = walletData[1];
          
          // import wallet files
          let wallet2 = await monerojs.openWalletFull({
            networkType: "mainnet",
            password: "password123",
            keysData: keysData,
            cacheData: cacheData
          });
          
          // test that wallets are equal
          assert.equal(await wallet.getSeed(), await wallet2.getSeed());
          await TestMoneroWalletFull._testWalletEqualityOnChain(wallet, wallet2);
        } catch (e) {
          err = e;
        }
        
        // finally
        if (wallet) await wallet.close();
        if (err) throw err;
      });
      
      if (testConfig.testNonRelays)
      it("Can be moved", async function() {
        let err;
        let wallet;
        try {
          
          // create random in-memory wallet with defaults
          wallet = await that.createWallet(new MoneroWalletConfig().setPath(""));
          let mnemonic = await wallet.getSeed();
          await wallet.setAttribute("mykey", "myval1");

          // change password of in-memory wallet
          let password2 = "abc123";
          await wallet.changePassword(TestUtils.WALLET_PASSWORD, password2);
          
          // move wallet from memory to disk
          let path1 = TestUtils.TEST_WALLETS_DIR + "/" + GenUtils.getUUID();
          assert(!MoneroWalletFull.walletExists(path1, TestUtils.getDefaultFs()));
          await wallet.moveTo(path1);
          assert(MoneroWalletFull.walletExists(path1, TestUtils.getDefaultFs()));
          assert.equal(await wallet.getSeed(), mnemonic);
          assert.equal("myval1", await wallet.getAttribute("mykey"));
          
          // move to same path which is same as saving
          await wallet.setAttribute("mykey", "myval2");
          await wallet.moveTo(path1);
          await wallet.close();
          assert(MoneroWalletFull.walletExists(path1, TestUtils.getDefaultFs()));
          wallet = await that.openWallet(new MoneroWalletConfig().setPath(path1).setPassword(password2));
          assert.equal(await wallet.getSeed(), mnemonic);
          assert.equal("myval2", await wallet.getAttribute("mykey"));
          
          // move wallet to new directory
          let path2 = TestUtils.TEST_WALLETS_DIR + "/moved/" + GenUtils.getUUID();
          await wallet.setAttribute("mykey", "myval3");
          await wallet.moveTo(path2);
          assert(!MoneroWalletFull.walletExists(path1, TestUtils.getDefaultFs()));
          assert(MoneroWalletFull.walletExists(path2, TestUtils.getDefaultFs()));
          assert.equal(await wallet.getSeed(), mnemonic);
          
          // re-open and test wallet
          await wallet.close();
          wallet = await that.openWallet(new MoneroWalletConfig().setPath(path2).setPassword(password2));
          await wallet.sync();
          assert.equal(await wallet.getSeed(), mnemonic);
          assert.equal("myval3", await wallet.getAttribute("mykey"));
        } catch (e) {
          err = e;
        }
        
        // final cleanup
        if (wallet) await wallet.close();
        if (err) throw err;
      });
      
      if (testConfig.testNonRelays)
      it("Can be closed", async function() {
        let err;
        let wallet;
        try {
          
          // create a test wallet
          wallet = await that.createWallet();
          let path = await wallet.getPath();
          await wallet.sync();
          assert(await wallet.getHeight() > 1);
          assert(await wallet.isSynced());
          assert.equal(await wallet.isClosed(), false);
          
          // close the wallet
          await wallet.close();
          assert(await wallet.isClosed());
          
          // attempt to interact with the wallet
          try { await wallet.getHeight(); }
          catch (e) { assert.equal(e.message, "Wallet is closed"); }
          try { await wallet.getSeed(); }
          catch (e) { assert.equal(e.message, "Wallet is closed"); }
          try { await wallet.sync(); }
          catch (e) { assert.equal(e.message, "Wallet is closed"); }
          try { await wallet.startSyncing(); }
          catch (e) { assert.equal(e.message, "Wallet is closed"); }
          try { await wallet.stopSyncing(); }
          catch (e) { assert.equal(e.message, "Wallet is closed"); }
          
          // re-open the wallet
          wallet = await that.openWallet({path: path});
          await wallet.sync();
          assert.equal(await wallet.getHeight(), await wallet.getDaemonHeight());
          assert.equal(await wallet.isClosed(), false);
        } catch (e) {
          console.log(e);
          err = e;
        }
        
        // final cleanup
        await wallet.close();
        assert(await wallet.isClosed());
        if (err) throw err;
      });

      if (false)
      it("Does not leak memory", async function() {
        let err;
        try {
          console.log("Infinite loop starting, monitor memory in OS process manager...");
          let i = 0;
          let closeWallet = false;
          if (closeWallet) await that.wallet.close(true);
          while (true) {
            if (closeWallet) that.wallet = await TestUtils.getWalletFull();
            await that.wallet.sync();
            await that.wallet.getTxs();
            await that.wallet.getTransfers();
            await that.wallet.getOutputs(new MoneroOutputQuery().setIsSpent(false));
            if (i % 1000) {
              console.log("Iteration: " + i);
            }
            if (closeWallet) await that.wallet.close(true);
          }
        } catch (e) {
          console.log(e);
          err = e;
        }
        
        // final cleanup
        if (err) throw err;
      });
    });
  }
  
  //----------------------------- PRIVATE HELPERS -----------------------------
  
  static _getRandomWalletPath() {
    return TestUtils.TEST_WALLETS_DIR + "/test_wallet_" + GenUtils.getUUID();
  }
  
  // possible configuration: on chain xor local wallet data ("strict"), txs ordered same way? TBD
  static async _testWalletEqualityOnChain(wallet1, wallet2) {
    await WalletEqualityUtils.testWalletEqualityOnChain(wallet1, wallet2);
    assert.equal(await wallet2.getNetworkType(), await wallet1.getNetworkType());
    assert.equal(await wallet2.getRestoreHeight(), await wallet1.getRestoreHeight());
    assert.deepEqual(await wallet1.getDaemonConnection(), await wallet2.getDaemonConnection());
    assert.equal(await wallet2.getSeedLanguage(), await wallet1.getSeedLanguage());
    // TODO: more wasm-specific extensions
  }
}

/**
 * Helper class to test progress updates.
 */
class SyncProgressTester extends WalletSyncPrinter {
  
  constructor(wallet, startHeight, endHeight) {
    super();
    this.wallet = wallet;
    assert(startHeight >= 0);
    assert(endHeight >= 0);
    this.startHeight = startHeight;
    this.prevEndHeight = endHeight;
    this.isDone = false;
  }
  
  onSyncProgress(height, startHeight, endHeight, percentDone, message) {
    super.onSyncProgress(height, startHeight, endHeight, percentDone, message);
    
    // registered wallet listeners will continue to get sync notifications after the wallet's initial sync
    if (this.isDone) {
      assert(this.wallet.getListeners().includes(this), "Listener has completed and is not registered so should not be called again");
      this.onSyncProgressAfterDone = true;
    }
    
    // update tester's start height if new sync session
    if (this.prevCompleteHeight !== undefined && startHeight === this.prevCompleteHeight) this.startHeight = startHeight;  
    
    // if sync is complete, record completion height for subsequent start heights
    if (percentDone === 1) this.prevCompleteHeight = endHeight;
    
    // otherwise start height is equal to previous completion height
    else if (this.prevCompleteHeight !== undefined) assert.equal(startHeight, this.prevCompleteHeight);
    
    assert(endHeight > startHeight, "end height > start height");
    assert.equal(startHeight, this.startHeight);
    assert(endHeight >= this.prevEndHeight);  // chain can grow while syncing
    this.prevEndHeight = endHeight;
    assert(height >= startHeight);
    assert(height < endHeight);
    let expectedPercentDone = (height - startHeight + 1) / (endHeight - startHeight);
    assert.equal(expectedPercentDone, percentDone);
    if (this.prevHeight === undefined) assert.equal(height, startHeight);
    else assert.equal(this.prevHeight + 1, height);
    this.prevHeight = height;
  }
  
  async onDone(chainHeight) {
    assert(!this.isDone);
    this.isDone = true;
    if (this.prevHeight === undefined) {
      assert.equal(this.prevCompleteHeight, undefined);
      assert.equal(this.startHeight, chainHeight);
    } else {
      assert.equal(this.prevHeight, chainHeight - 1);  // otherwise last height is chain height - 1
      assert.equal(this.prevCompleteHeight, chainHeight);
    }
  }
  
  isNotified() {
    return this.prevHeight !== undefined;
  }
  
  getOnSyncProgressAfterDone() {
    return this.onSyncProgressAfterDone;
  }
}

/**
 * Internal class to test all wallet notifications on sync. 
 */
class WalletSyncTester extends SyncProgressTester {
  
  constructor(wallet, startHeight, endHeight) {
    super(wallet, startHeight, endHeight);
    assert(startHeight >= 0);
    assert(endHeight >= 0);
    this.incomingTotal = BigInteger.parse("0");
    this.outgoingTotal = BigInteger.parse("0");
  }
  
  onNewBlock(height) {
    if (this.isDone) {
      assert(this.wallet.getListeners().includes(this), "Listener has completed and is not registered so should not be called again");
      this.onNewBlockAfterDone = true;
    }
    if (this.walletTesterPrevHeight !== undefined) assert.equal(height, this.walletTesterPrevHeight + 1);
    assert(height >= this.startHeight);
    this.walletTesterPrevHeight = height;
  }
  
  async onBalancesChanged(newBalance, newUnlockedBalance) {
    if (this.prevBalance !== undefined) assert(newBalance.toString() !== this.prevBalance.toString() || newUnlockedBalance.toString() !== this.prevUnlockedBalance.toString());
    this.prevBalance = newBalance;
    this.prevUnlockedBalance = newUnlockedBalance;
  }

  onOutputReceived(output) {
    assert.notEqual(output, undefined);
    this.prevOutputReceived = output;
    
    // test output
    TestUtils.testUnsignedBigInteger(output.getAmount());
    assert(output.getAccountIndex() >= 0);
    assert(output.getSubaddressIndex() >= 0);
    
    // test output's tx
    assert(output.getTx());
    assert(output.getTx() instanceof MoneroTxWallet);
    assert(output.getTx().getHash());
    assert.equal(output.getTx().getHash().length, 64);
    assert(output.getTx().getVersion() >= 0);
    assert(output.getTx().getUnlockTime() >= 0);
    assert.equal(output.getTx().getInputs(), undefined);
    assert.equal(output.getTx().getOutputs().length, 1);
    assert(output.getTx().getOutputs()[0] === output);
    
    // extra is not sent over the wasm bridge
    assert.equal(output.getTx().getExtra(), undefined);
    
    // add incoming amount to running total
    if (output.isLocked()) this.incomingTotal = this.incomingTotal.add(output.getAmount());
  }

  onOutputSpent(output) {
    assert.notEqual(output, undefined);
    this.prevOutputSpent = output;
    
    // test output
    TestUtils.testUnsignedBigInteger(output.getAmount());
    assert(output.getAccountIndex() >= 0);
    if (output.getSubaddressIndex() !== undefined) assert(output.getSubaddressIndex() >= 0); // TODO (monero-project): can be undefined because inputs not provided so one created from outgoing transfer
    
    // test output's tx
    assert(output.getTx());
    assert(output.getTx() instanceof MoneroTxWallet);
    assert(output.getTx().getHash());
    assert.equal(output.getTx().getHash().length, 64);
    assert(output.getTx().getVersion() >= 0);
    assert(output.getTx().getUnlockTime() >= 0);
    assert.equal(output.getTx().getInputs().length, 1);
    assert(output.getTx().getInputs()[0] === output);
    assert.equal(output.getTx().getOutputs(), undefined);
    
    // extra is not sent over the wasm bridge
    assert.equal(output.getTx().getExtra(), undefined);
    
    // add outgoing amount to running total
    if (output.isLocked()) this.outgoingTotal = this.outgoingTotal.add(output.getAmount());
  }
  
  async onDone(chainHeight) {
    await super.onDone(chainHeight);
    assert.notEqual(this.walletTesterPrevHeight, undefined);
    assert.notEqual(this.prevOutputReceived, undefined);
    assert.notEqual(this.prevOutputSpent, undefined);
    let balance = this.incomingTotal.subtract(this.outgoingTotal);
    assert.equal(balance.toString(), (await this.wallet.getBalance()).toString());
    assert.equal(this.prevBalance.toString(), (await this.wallet.getBalance()).toString());
    assert.equal(this.prevUnlockedBalance.toString(), (await this.wallet.getUnlockedBalance()).toString());
  }
  
  getOnNewBlockAfterDone() {
    return this.onNewBlockAfterDone;
  }
}

module.exports = TestMoneroWalletFull;
