/* * Copyright Haveno * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // --------------------------------- IMPORTS ---------------------------------- // haveno imports import { HavenoClient, HavenoError, HavenoUtils, OfferDirection, MarketPriceInfo, NotificationMessage, OfferInfo, TradeInfo, UrlConnection, XmrBalanceInfo, Attachment, DisputeResult, PaymentMethod, PaymentAccount, PaymentAccountForm, PaymentAccountFormField, PaymentAccountPayload, XmrDestination, XmrNodeSettings, XmrTx, XmrIncomingTransfer, XmrOutgoingTransfer, } from "./index"; import AuthenticationStatus = UrlConnection.AuthenticationStatus; import OnlineStatus = UrlConnection.OnlineStatus; // other imports import fs from "fs"; import path from "path"; import net from "net"; import assert from "assert"; import console from "console"; // import console because jest swallows messages in real time import moneroTs from "monero-ts"; import * as os from 'os'; // ------------------------------ TEST CONFIG --------------------------------- enum BaseCurrencyNetwork { XMR_MAINNET = "XMR_MAINNET", XMR_STAGENET = "XMR_STAGENET", XMR_LOCAL = "XMR_LOCAL" } // clients const startupHavenods: HavenoClient[] = []; let arbitrator: HavenoClient; let user1: HavenoClient; let user2: HavenoClient; let monerod: moneroTs.MoneroDaemon; let fundingWallet: moneroTs.MoneroWalletRpc; let user1Wallet: moneroTs.MoneroWalletRpc; let user2Wallet: moneroTs.MoneroWalletRpc; enum TradeRole { MAKER = "MAKER", TAKER = "TAKER", } enum SaleRole { BUYER = "BUYER", SELLER = "SELLER" } enum DisputeContext { NONE = "NONE", OPEN_AFTER_DEPOSITS_UNLOCK = "OPEN_AFTER_DEPOSITS_UNLOCK", OPEN_AFTER_PAYMENT_SENT = "OPEN_AFTER_PAYMENT_SENT" } /** * Test context for a single peer in a trade. */ class PeerContext { havenod?: HavenoClient; wallet?: moneroTs.MoneroWallet; trade?: TradeInfo; // context to test balances after trade balancesBeforeOffer?: XmrBalanceInfo; splitOutputTxFee?: bigint; balancesBeforeTake?: XmrBalanceInfo; balancesAfterTake?: XmrBalanceInfo; balancesBeforePayout?: XmrBalanceInfo; balancesAfterPayout?: XmrBalanceInfo; tradeFee?: bigint; depositTx?: moneroTs.MoneroTx; depositTxFee?: bigint; securityDepositActual?: bigint; payoutTxFee?: bigint; payoutAmount?: bigint; constructor(ctx?: Partial) { Object.assign(this, ctx); } } /** * Default trade configuration. */ const defaultTradeConfig: Partial = { arbitrator: new PeerContext(), maker: new PeerContext(), taker: new PeerContext(), makeOffer: true, takeOffer: true, awaitFundsToMakeOffer: true, direction: OfferDirection.BUY, // buy or sell xmr offerAmount: 193312996088n, offerMinAmount: undefined, assetCode: "usd", // counter asset to trade makerPaymentAccountId: undefined, securityDepositPct: 0.15, price: undefined, // use market price if undefined triggerPrice: undefined, awaitFundsToTakeOffer: true, offerId: undefined, takerPaymentAccountId: undefined, buyerSendsPayment: true, sellerReceivesPayment: true, resolveDispute: true, // resolve dispute after opening disputeWinner: DisputeResult.Winner.SELLER, disputeReason: DisputeResult.Reason.PEER_WAS_LATE, disputeSummary: "Seller is winner", walletSyncPeriodMs: 5000, maxTimePeerNoticeMs: 5000, testChatMessages: true, stopOnFailure: false, // TODO: setting to true can cause error: Http response at 400 or 500 level, http status code: 503 testPayoutConfirmed: true, testPayoutUnlocked: false, maxConcurrency: getMaxConcurrency() } /** * Configuration and context for a single trade. */ class TradeContext { // trade peers arbitrator!: Partial; maker!: Partial; taker!: Partial; // trade flow concurrentTrades?: boolean; // testing trades at same time makeOffer?: boolean; takeOffer?: boolean; buyerOfflineAfterTake?: boolean; sellerOfflineAfterTake?: boolean; buyerOfflineAfterPaymentSent?: boolean buyerOfflineAfterDisputeOpened?: boolean; sellerOfflineAfterDisputeOpened?: boolean; sellerDisputeContext?: DisputeContext; buyerDisputeContext?: DisputeContext; buyerSendsPayment?: boolean; sellerReceivesPayment?: boolean // make offer awaitFundsToMakeOffer?: boolean direction?: OfferDirection; assetCode?: string; offerAmount?: bigint; // offer amount or max offerMinAmount?: bigint; tradeAmount?: bigint; // trade amount within offer range makerPaymentAccountId?: string; securityDepositPct?: number; price?: number; priceMargin?: number; triggerPrice?: number; reserveExactAmount?: boolean; // take offer awaitFundsToTakeOffer?: boolean; offerId?: string; takerPaymentAccountId?: string; testTraderChat?: boolean; // resolve dispute resolveDispute?: boolean disputeOpener?: SaleRole; disputeWinner?: DisputeResult.Winner; disputeReason?: DisputeResult.Reason; disputeSummary?: string; disputeWinnerAmount?: bigint; // other context offer?: OfferInfo; index?: number; isOfferTaken?: boolean; isPaymentSent?: boolean; isPaymentReceived?: boolean; phase?: string; payoutState?: string[]; disputeState?: string; isCompleted?: boolean; isPayoutPublished?: boolean; // TODO: test isDepositsPublished; etc isPayoutConfirmed?: boolean; isPayoutUnlocked?: boolean buyerOpenedDispute?: boolean; sellerOpenedDispute?: boolean; walletSyncPeriodMs!: number; maxTimePeerNoticeMs!: number; testChatMessages!: boolean; stopOnFailure?: boolean; buyerAppName?: string; sellerAppName?: string; usedPorts?: string[]; testPayoutConfirmed?: boolean; testPayoutUnlocked?: boolean; payoutTxId?: string testBalanceChangeEndToEnd?: boolean; isStopped!: boolean; maxConcurrency!: number; constructor(ctx?: Partial) { Object.assign(this, ctx); if (this.arbitrator) this.arbitrator = new PeerContext(this.arbitrator); if (this.maker) this.maker = new PeerContext(this.maker); if (this.taker) this.taker = new PeerContext(this.taker); } getMaker(): PeerContext { return this.maker as PeerContext; } getTaker(): PeerContext { return this.taker as PeerContext; } getBuyer(): PeerContext { return (this.direction === OfferDirection.BUY ? this.maker : this.taker) as PeerContext; } getSeller(): PeerContext { return (this.direction === OfferDirection.BUY ? this.taker : this.maker) as PeerContext; } isBuyerMaker(): boolean { return this.direction === OfferDirection.BUY; } getDisputeOpener(): PeerContext | undefined { if (this.disputeOpener === undefined) return undefined; return this.disputeOpener === SaleRole.BUYER ? this.getBuyer() : this.getSeller(); } getDisputePeer(): PeerContext | undefined { if (this.disputeOpener === undefined) return undefined; return this.disputeOpener === SaleRole.BUYER ? this.getSeller() : this.getBuyer(); } getDisputeWinner(): PeerContext | undefined { if (this.disputeWinner === undefined) return undefined; return this.disputeWinner === DisputeResult.Winner.BUYER ? this.getBuyer() : this.getSeller(); } getDisputeLoser(): PeerContext | undefined { if (this.disputeWinner === undefined) return undefined; return this.disputeWinner === DisputeResult.Winner.BUYER ? this.getSeller() : this.getBuyer(); } isOfflineFlow() { return this.buyerOfflineAfterDisputeOpened || this.sellerOfflineAfterDisputeOpened || this.buyerOfflineAfterPaymentSent || this.buyerOfflineAfterTake || this.sellerOfflineAfterTake; } getPhase() { return this.isPaymentReceived ? "PAYMENT_RECEIVED" : this.isPaymentSent ? "PAYMENT_SENT" : "DEPOSITS_UNLOCKED"; } static init(ctxP: Partial | undefined): TradeContext { let ctx = ctxP instanceof TradeContext ? ctxP : new TradeContext(ctxP); if (!ctx.offerAmount && ctx.tradeAmount) ctx.offerAmount = ctx.tradeAmount; if (!ctx.offerMinAmount && ctx.offerAmount) ctx.offerMinAmount = ctx.offerAmount; Object.assign(ctx, new TradeContext(TestConfig.trade), Object.assign({}, ctx)); return ctx; } async toSummary(): Promise { let str: string = ""; str += "Type: Maker/" + (this.direction === OfferDirection.BUY ? "Buyer" : "Seller") + ", Taker/" + (this.direction === OfferDirection.BUY ? "Seller" : "Buyer"); str += "\nOffer id: " + this.offerId; if (this.maker.havenod) str += "\nMaker uri: " + this.maker?.havenod?.getUrl(); if (this.taker.havenod) str += "\nTaker uri: " + this.taker?.havenod?.getUrl(); str += "\nAsset code: " + this.assetCode?.toUpperCase(); str += "\nMaker payment account id: " + this.makerPaymentAccountId; str += "\nTaker payment account id: " + this.takerPaymentAccountId; str += "\nTrade amount: " + this.tradeAmount; str += "\nMin amount: " + this.offerMinAmount; str += "\nMax amount: " + this.offerAmount; str += "\nSecurity deposit percent: " + this.securityDepositPct; str += "\nMaker balance before offer: " + this.maker.balancesBeforeOffer?.getBalance(); str += "\nMaker split output tx fee: " + this.maker.splitOutputTxFee; if (this.offer) { str += "\nMaker fee percent: " + this.offer!.getMakerFeePct(); str += "\nTaker fee percent: " + this.offer!.getTakerFeePct(); } if (this.arbitrator && this.arbitrator!.trade) { str += "\nMaker trade fee: " + this.arbitrator?.trade?.getMakerFee(); str += "\nMaker deposit tx id: " + this.arbitrator!.trade!.getMakerDepositTxId(); if (this.arbitrator!.trade!.getMakerDepositTxId()) { let tx = await monerod.getTx(this.arbitrator!.trade!.getMakerDepositTxId()); str += "\nMaker deposit tx fee: " + (tx ? tx?.getFee() : undefined); } str += "\nMaker security deposit received: " + (this.direction == OfferDirection.BUY ? this.arbitrator!.trade!.getBuyerSecurityDeposit() : this.arbitrator!.trade!.getSellerSecurityDeposit()); } str += "\nTaker balance before offer: " + this.taker.balancesBeforeOffer?.getBalance(); if (this.arbitrator && this.arbitrator!.trade) { str += "\nTaker trade fee: " + this.arbitrator?.trade?.getTakerFee(); str += "\nTaker deposit tx id: " + this.arbitrator!.trade!.getTakerDepositTxId(); if (this.arbitrator!.trade!.getTakerDepositTxId()) { let tx = await monerod.getTx(this.arbitrator!.trade!.getTakerDepositTxId()); str += "\nTaker deposit tx fee: " + (tx ? tx?.getFee() : undefined); } str += "\nTaker security deposit received: " + (this.direction == OfferDirection.BUY ? this.arbitrator!.trade!.getSellerSecurityDeposit() : this.arbitrator!.trade!.getBuyerSecurityDeposit()); if (this.disputeWinner) str += "\nDispute winner: " + (this.disputeWinner == DisputeResult.Winner.BUYER ? "Buyer" : "Seller"); str += "\nPayout tx id: " + this.payoutTxId; if (this.payoutTxId) { str += "\nPayout fee: " + (await monerod.getTx(this.payoutTxId!))!.getFee()!; if (this.getBuyer().havenod) str += "\nBuyer payout: " + (await this.getBuyer().havenod!.getXmrTx(this.payoutTxId!))?.getIncomingTransfersList()[0].getAmount()!; if (this.getSeller().havenod) str += "\nSeller payout: " + (await this.getSeller().havenod!.getXmrTx(this.payoutTxId!))?.getIncomingTransfersList()[0].getAmount()!; } } str += "\nOffer json: " + JSON.stringify(this.offer?.toObject()); return str; } } /** * Default test configuration. */ const TestConfig = { logLevel: 2, baseCurrencyNetwork: getBaseCurrencyNetwork(), networkType: getBaseCurrencyNetwork() == BaseCurrencyNetwork.XMR_MAINNET ? moneroTs.MoneroNetworkType.MAINNET : getBaseCurrencyNetwork() == BaseCurrencyNetwork.XMR_LOCAL ? moneroTs.MoneroNetworkType.TESTNET : moneroTs.MoneroNetworkType.STAGENET, moneroBinsDir: "../haveno/.localnet", testDataDir: "./testdata", haveno: { path: "../haveno", version: "1.0.11" }, monerod: { url: "http://localhost:" + getNetworkStartPort() + "8081", // 18081, 28081, 38081 for mainnet, testnet, and stagenet, respectively username: "", password: "" }, monerod3: { // corresponds to monerod3-local in Makefile url: "http://localhost:58081", username: "superuser", password: "abctesting123", p2pBindPort: "58080", rpcBindPort: "58081", zmqRpcBindPort: "58082" }, fundingWallet: { url: "http://localhost:" + getNetworkStartPort() + "8084", // 18084, 28084, 38084 for mainnet, testnet, stagenet respectively username: "rpc_user", password: "abc123", walletPassword: "abc123", defaultPath: "funding_wallet-" + getBaseCurrencyNetwork(), minimumFunding: 5000000000000n, seed: "origin hickory pavements tudor sizes hornet tether segments sack technical elbow unsafe legion nitrogen adapt yearbook idols fuzzy pitched goes tusks elbow erase fossil erase", primaryAddress: "9xSyMy1r9h3BVjMrF3CTqQCQy36yCfkpn7uVfMyTUbez3hhumqBUqGUNNALjcd7f1HJBRdeH82bCC3veFHW7z3xm28gug4d", restoreHeight: 150 }, defaultHavenod: { logProcessOutput: true, // log output for processes started by tests (except arbitrator, user1, and user2 which are configured separately) logLevel: "info", apiPassword: "apitest", walletUsername: "haveno_user", walletDefaultPassword: "password", // only used if account password not set accountPasswordRequired: true, accountPassword: "abctesting789", autoLogin: true }, startupHavenods: [{ appName: "haveno-" + getBaseCurrencyNetwork() + "_arbitrator", // arbritrator logProcessOutput: true, port: "8079", accountPasswordRequired: false, accountPassword: "abctesting123", }, { appName: "haveno-" + getBaseCurrencyNetwork() + "_user1", // user1 logProcessOutput: true, port: "8080", accountPasswordRequired: false, accountPassword: "abctesting456", walletUrl: "http://127.0.0.1:38091", }, { appName: "haveno-" + getBaseCurrencyNetwork() + "_user2", // user2 logProcessOutput: true, port: "8081", accountPasswordRequired: false, accountPassword: "abctesting789", walletUrl: "http://127.0.0.1:38092", } ], maxFee: HavenoUtils.xmrToAtomicUnits(0.5), // local testnet fees can be relatively high minSecurityDeposit: moneroTs.MoneroUtils.xmrToAtomicUnits(0.1), maxAdjustmentPct: 0.2, daemonPollPeriodMs: 5000, maxWalletStartupMs: 10000, // TODO (woodser): make shorter by switching to jni maxCpuPct: 0.25, paymentMethods: Object.keys(PaymentAccountForm.FormId), // all supported payment methods assetCodes: ["USD", "GBP", "EUR", "ETH", "BTC", "BCH", "LTC"], // primary asset codes fixedPriceAssetCodes: ["XAG", "XAU", "XGB"], cryptoAddresses: [{ currencyCode: "ETH", address: "0xdBdAb835Acd6fC84cF5F9aDD3c0B5a1E25fbd99f" }, { currencyCode: "BTC", address: "1G457efxTyci67msm2dSqyhFzxPYFWaghe" }, { currencyCode: "BCH", address: "qz54ydhwzn25wzf8pge5s26udvtx33yhyq3lnv6vq6" }, { currencyCode: "LTC", address: "LXUTUN5mTPc2LsS7cEjkyjTRcfYyJGoUuQ" } ], ports: new Map([ // map http ports to havenod api and p2p ports ["8079", ["9998", "4444"]], // arbitrator ["8080", ["9999", "5555"]], // user1 ["8081", ["10000", "6666"]], // user2 ["8082", ["10001", "7777"]], ["8083", ["10002", "7778"]], ["8084", ["10003", "7779"]], ["8085", ["10004", "7780"]], ["8086", ["10005", "7781"]], ]), arbitratorPrivKeys: { XMR_LOCAL: ["6ac43ea1df2a290c1c8391736aa42e4339c5cb4f110ff0257a13b63211977b7a", "d96c4e7be030564cfa64a4040060574a8e92a79f574104ab8bb0c1166db28047", "6d5c86cbc5fc7ce3c97b06969661eae5c018cb2923856cc51341d182a45d1e9d"], XMR_STAGENET: ["1aa111f817b7fdaaec1c8d5281a1837cc71c336db09b87cf23344a0a4e3bb2cb", "6b5a404eb5ff7154f2357126c84c3becfe2e7c59ca3844954ce9476bec2a6228", "fd4ef301a2e4faa3c77bc26393919895fa29b0908f2bbd51f6f6de3e46fb7a6e"], XMR_MAINNET: [] }, tradeStepTimeoutMs: getBaseCurrencyNetwork() === BaseCurrencyNetwork.XMR_LOCAL ? 60000 : 180000, testTimeout: getBaseCurrencyNetwork() === BaseCurrencyNetwork.XMR_LOCAL ? 2400000 : 5400000, // timeout in ms for each test to complete (40 minutes for private network, 90 minutes for public network) trade: new TradeContext(defaultTradeConfig) }; interface HavenodContext { logProcessOutput?: boolean, logLevel?: string, apiPassword?: string, walletUsername?: string, walletDefaultPassword?: string, accountPasswordRequired?: boolean, accountPassword?: string, autoLogin?: boolean, appName?: string, port?: string, excludePorts?: string[], walletUrl?: string } interface TxContext { isCreatedTx: boolean; } // track started haveno processes const HAVENO_PROCESSES: HavenoClient[] = []; const HAVENO_PROCESS_PORTS: string[] = []; const HAVENO_WALLETS: Map = new Map(); // other config const OFFLINE_ERR_MSG = "Http response at 400 or 500 level"; function getMaxConcurrency() { return isGitHubActions() ? 4 : 20; } function isGitHubActions() { return process.env.GITHUB_ACTIONS === 'true'; } // -------------------------- BEFORE / AFTER TESTS ---------------------------- jest.setTimeout(TestConfig.testTimeout); beforeAll(async () => { try { // set log level for tests HavenoUtils.setLogLevel(TestConfig.logLevel); // initialize funding wallet HavenoUtils.log(0, "Initializing funding wallet"); await initFundingWallet(); HavenoUtils.log(0, "Funding wallet balance: " + await fundingWallet.getBalance()); HavenoUtils.log(0, "Funding wallet unlocked balance: " + await fundingWallet.getUnlockedBalance()); const subaddress = await fundingWallet.createSubaddress(0); HavenoUtils.log(0, "Funding wallet height: " + await fundingWallet.getHeight()); HavenoUtils.log(0, "Funding wallet seed: " + await fundingWallet.getSeed()); HavenoUtils.log(0, "Funding wallet primary address: " + await fundingWallet.getPrimaryAddress()); HavenoUtils.log(0, "Funding wallet new subaddress: " + subaddress.getAddress()); // initialize monerod try { monerod = await moneroTs.connectToDaemonRpc(TestConfig.monerod.url, TestConfig.monerod.username, TestConfig.monerod.password); await mineToHeight(160); // initialize blockchain to latest block type } catch (err: any) { HavenoUtils.log(0, "Error initializing internal monerod: " + err.message); // allowed in order to test starting and stopping local node } // start configured haveno daemons const promises: Promise[] = []; let err; for (const config of TestConfig.startupHavenods) promises.push(initHaveno(config)); for (const settledPromise of await Promise.allSettled(promises)) { if (settledPromise.status === "fulfilled") startupHavenods.push((settledPromise as PromiseFulfilledResult).value); else if (!err) err = new Error((settledPromise as PromiseRejectedResult).reason); } if (err) throw err; // assign arbitrator, user1, user2 arbitrator = startupHavenods[0]; user1 = startupHavenods[1]; user2 = startupHavenods[2]; TestConfig.trade.arbitrator.havenod = arbitrator; TestConfig.trade.maker.havenod = user1; TestConfig.trade.taker.havenod = user2; // connect client wallets user1Wallet = await moneroTs.connectToWalletRpc(TestConfig.startupHavenods[1].walletUrl!, TestConfig.defaultHavenod.walletUsername, TestConfig.startupHavenods[1].accountPasswordRequired ? TestConfig.startupHavenods[1].accountPassword : TestConfig.defaultHavenod.walletDefaultPassword); user2Wallet = await moneroTs.connectToWalletRpc(TestConfig.startupHavenods[2].walletUrl!, TestConfig.defaultHavenod.walletUsername, TestConfig.startupHavenods[2].accountPasswordRequired ? TestConfig.startupHavenods[2].accountPassword : TestConfig.defaultHavenod.walletDefaultPassword); // register arbitrator dispute agent await arbitrator.registerDisputeAgent("arbitrator", getArbitratorPrivKey(0)); // create test data directory if it doesn't exist if (!fs.existsSync(TestConfig.testDataDir)) fs.mkdirSync(TestConfig.testDataDir); } catch (err) { await shutDown(); throw err; } }); beforeEach(async () => { HavenoUtils.log(0, "BEFORE TEST \"" + expect.getState().currentTestName + "\""); }); afterAll(async () => { await shutDown(); }); async function shutDown() { // release haveno processes const promises: Promise[] = []; for (const havenod of startupHavenods) { promises.push(havenod.getProcess() ? releaseHavenoProcess(havenod) : havenod.disconnect()); } await Promise.all(promises); // terminate monero-ts worker await moneroTs.LibraryUtils.terminateWorker(); } // ----------------------------------- TESTS ---------------------------------- test("Can get the version (CI)", async () => { const version = await arbitrator.getVersion(); expect(version).toEqual(TestConfig.haveno.version); }); test("Can convert between XMR and atomic units (CI)", async () => { expect(BigInt(250000000000)).toEqual(HavenoUtils.xmrToAtomicUnits(0.25)); expect(HavenoUtils.atomicUnitsToXmr("250000000000")).toEqual(.25); expect(HavenoUtils.atomicUnitsToXmr(250000000000n)).toEqual(.25); }); test("Can manage an account (CI)", async () => { let user3: HavenoClient|undefined; let err: any; try { // start user3 without opening account user3 = await initHaveno({autoLogin: false}); assert(!await user3.accountExists()); // test errors when account not open await testAccountNotOpen(user3); // create account let password = "testPassword"; await user3.createAccount(password); if (await user3.isConnectedToMonero()) await user3.getBalances(); // only connected if local node running assert(await user3.accountExists()); assert(await user3.isAccountOpen()); // create payment account const paymentAccount = await user3.createCryptoPaymentAccount("My ETH account", TestConfig.cryptoAddresses[0].currencyCode, TestConfig.cryptoAddresses[0].address); // close account await user3.closeAccount(); assert(await user3.accountExists()); assert(!await user3.isAccountOpen()); await testAccountNotOpen(user3); // open account with wrong password try { await user3.openAccount("wrongPassword"); throw new Error("Should have failed opening account with wrong password"); } catch (err: any) { assert.equal(err.message, "Incorrect password"); } // open account await user3.openAccount(password); assert(await user3.accountExists()); assert(await user3.isAccountOpen()); // restart user3 const user3Config = {appName: user3.getAppName(), autoLogin: false} await releaseHavenoProcess(user3); user3 = await initHaveno(user3Config); assert(await user3.accountExists()); assert(!await user3.isAccountOpen()); // open account await user3.openAccount(password); assert(await user3.accountExists()); assert(await user3.isAccountOpen()); // try changing incorrect password try { await user3.changePassword("wrongPassword", "abc123"); throw new Error("Should have failed changing wrong password"); } catch (err: any) { assert.equal(err.message, "Incorrect password"); } // try setting password below minimum length try { await user3.changePassword(password, "abc123"); throw new Error("Should have failed setting password below minimum length") } catch (err: any) { assert.equal(err.message, "Password must be at least 8 characters"); } // change password const newPassword = "newPassword"; await user3.changePassword(password, newPassword); password = newPassword; assert(await user3.accountExists()); assert(await user3.isAccountOpen()); // restart user3 await releaseHavenoProcess(user3); user3 = await initHaveno(user3Config); await testAccountNotOpen(user3); // open account await user3.openAccount(password); assert(await user3.accountExists()); assert(await user3.isAccountOpen()); // backup account to zip file const zipFile = TestConfig.testDataDir + "/backup.zip"; const stream = fs.createWriteStream(zipFile); const size = await user3.backupAccount(stream); stream.end(); assert(size > 0); // delete account and wait until connected await user3.deleteAccount(); HavenoUtils.log(1, "Waiting to be connected to havenod after deleting account"); // TODO: build this into deleteAccount do { await wait(1000); } while(!await user3.isConnectedToDaemon()); HavenoUtils.log(1, "Reconnecting to havenod"); assert(!await user3.accountExists()); // restore account const zipBytes: Uint8Array = new Uint8Array(fs.readFileSync(zipFile)); await user3.restoreAccount(zipBytes); do { await wait(1000); } while(!await user3.isConnectedToDaemon()); assert(await user3.accountExists()); // open restored account await user3.openAccount(password); assert(await user3.isAccountOpen()); // check the persisted payment account const paymentAccount2 = await user3.getPaymentAccount(paymentAccount.getId()); testCryptoPaymentAccountsEqual(paymentAccount, paymentAccount2); } catch (err2) { err = err2; } // stop and delete instances if (user3) await releaseHavenoProcess(user3, true); if (err) throw err; async function testAccountNotOpen(havenod: HavenoClient): Promise { // TODO: generalize this? try { await havenod.getMoneroConnections(); throw new Error("Should have thrown"); } catch (err: any) { assert.equal(err.message, "Account not open"); } try { await havenod.getXmrTxs(); throw new Error("Should have thrown"); } catch (err: any) { assert.equal(err.message, "Account not open"); } try { await havenod.getBalances(); throw new Error("Should have thrown"); } catch (err: any) { assert.equal(err.message, "Account not open"); } try { await havenod.createCryptoPaymentAccount("My ETH account", TestConfig.cryptoAddresses[0].currencyCode, TestConfig.cryptoAddresses[0].address); throw new Error("Should have thrown"); } catch (err: any) { assert.equal(err.message, "Account not open"); } } }); test("Can manage Monero daemon connections (CI)", async () => { let monerod3: moneroTs.MoneroDaemonRpc | undefined = undefined; let user3: HavenoClient|undefined; let err: any; try { // start user3 user3 = await initHaveno(); // disable auto switch for tests assert.equal(true, await user3.getAutoSwitch()); await user3.setAutoSwitch(false); // test default connections const monerodUrl1 = "http://127.0.0.1:" + getNetworkStartPort() + "8081"; // TODO: (woodser): move to config let connections: UrlConnection[] = await user3.getMoneroConnections(); testConnection(getConnection(connections, monerodUrl1)!, monerodUrl1, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1); // test default connection let connection: UrlConnection|undefined = await user3.getMoneroConnection(); assert(await user3.isConnectedToMonero()); testConnection(connection!, monerodUrl1, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1); // TODO: should be no authentication? // add a new connection const fooBarUrl = "http://foo.bar"; await user3.addMoneroConnection(fooBarUrl); connections = await user3.getMoneroConnections(); connection = getConnection(connections, fooBarUrl); testConnection(connection!, fooBarUrl, OnlineStatus.UNKNOWN, AuthenticationStatus.NO_AUTHENTICATION, 0); // set prioritized connection without credentials await user3.setMoneroConnection(new UrlConnection() .setUrl(TestConfig.monerod3.url) .setPriority(1)); connection = await user3.getMoneroConnection(); testConnection(connection!, TestConfig.monerod3.url, undefined, undefined, 1); // status may or may not be known due to periodic connection checking // connection is offline connection = await user3.checkMoneroConnection(); assert(!await user3.isConnectedToMonero()); testConnection(connection!, TestConfig.monerod3.url, OnlineStatus.OFFLINE, AuthenticationStatus.NO_AUTHENTICATION, 1); // start monerod3 const cmd = [ TestConfig.moneroBinsDir + "/monerod", "--no-igd", "--hide-my-port", "--data-dir", TestConfig.moneroBinsDir + "/" + TestConfig.baseCurrencyNetwork.toLowerCase() + "/node3", "--p2p-bind-ip", "127.0.0.1", "--p2p-bind-port", TestConfig.monerod3.p2pBindPort, "--rpc-bind-port", TestConfig.monerod3.rpcBindPort, "--zmq-rpc-bind-port", TestConfig.monerod3.zmqRpcBindPort, "--log-level", "0", "--confirm-external-bind", "--rpc-access-control-origins", "http://localhost:8080", "--fixed-difficulty", "500", "--disable-rpc-ban" ]; if (getBaseCurrencyNetwork() !== BaseCurrencyNetwork.XMR_MAINNET) cmd.push("--" + moneroTs.MoneroNetworkType.toString(TestConfig.networkType).toLowerCase()); if (TestConfig.monerod3.username) cmd.push("--rpc-login", TestConfig.monerod3.username + ":" + TestConfig.monerod3.password); monerod3 = await moneroTs.connectToDaemonRpc(cmd); // connection is online and not authenticated connection = await user3.checkMoneroConnection(); assert(!await user3.isConnectedToMonero()); testConnection(connection!, TestConfig.monerod3.url, OnlineStatus.ONLINE, AuthenticationStatus.NOT_AUTHENTICATED, 1); // set connection credentials await user3.setMoneroConnection(new UrlConnection() .setUrl(TestConfig.monerod3.url) .setUsername(TestConfig.monerod3.username) .setPassword(TestConfig.monerod3.password) .setPriority(1)); connection = await user3.getMoneroConnection(); testConnection(connection!, TestConfig.monerod3.url, undefined, undefined, 1); // connection is online and authenticated connection = await user3.checkMoneroConnection(); assert(await user3.isConnectedToMonero()); testConnection(connection!, TestConfig.monerod3.url, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1); // change account password const newPassword = "newPassword"; await user3.changePassword(TestConfig.defaultHavenod.accountPassword, newPassword); // restart user3 const appName = user3.getAppName(); await releaseHavenoProcess(user3); user3 = await initHaveno({appName: appName, accountPassword: newPassword}); // connection is restored, online, and authenticated await user3.checkMoneroConnection(); connection = await user3.getMoneroConnection(); testConnection(connection!, TestConfig.monerod3.url, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1); // priority connections are polled await user3.checkMoneroConnections(); connections = await user3.getMoneroConnections(); testConnection(getConnection(connections, monerodUrl1)!, monerodUrl1, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1); // enable auto switch await user3.setAutoSwitch(true); assert.equal(true, await user3.getAutoSwitch()); // stop monerod //await monerod3.stopProcess(); // TODO (monero-ts): monerod remains available after await monerod.stopProcess() for up to 40 seconds await moneroTs.GenUtils.killProcess(monerod3.getProcess(), "SIGKILL"); // test auto switch after periodic connection check await wait(TestConfig.daemonPollPeriodMs * 2); await user3.checkMoneroConnection(); connection = await user3.getMoneroConnection(); testConnection(connection!, monerodUrl1, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1); // stop auto switch and checking connection periodically await user3.setAutoSwitch(false); assert.equal(false, await user3.getAutoSwitch()); await user3.stopCheckingConnection(); // remove current connection await user3.removeMoneroConnection(monerodUrl1); // check current connection connection = await user3.checkMoneroConnection(); assert.equal(connection, undefined); // check all connections await user3.checkMoneroConnections(); connections = await user3.getMoneroConnections(); testConnection(getConnection(connections, fooBarUrl)!, fooBarUrl, OnlineStatus.OFFLINE, AuthenticationStatus.NO_AUTHENTICATION, 0); // set connection to previous url await user3.setMoneroConnection(fooBarUrl); connection = await user3.getMoneroConnection(); testConnection(connection!, fooBarUrl, OnlineStatus.OFFLINE, AuthenticationStatus.NO_AUTHENTICATION, 0); // set connection to new url const fooBarUrl2 = "http://foo.bar.xyz"; await user3.setMoneroConnection(fooBarUrl2); connections = await user3.getMoneroConnections(); connection = getConnection(connections, fooBarUrl2); testConnection(connection!, fooBarUrl2, OnlineStatus.UNKNOWN, AuthenticationStatus.NO_AUTHENTICATION, 0); // reset connection await user3.setMoneroConnection(); assert.equal(await user3.getMoneroConnection(), undefined); // test auto switch after start checking connection await user3.setAutoSwitch(false); await user3.startCheckingConnection(5000); // checks the connection await user3.setAutoSwitch(true); await user3.addMoneroConnection(new UrlConnection() .setUrl(TestConfig.monerod.url) .setUsername(TestConfig.monerod.username) .setPassword(TestConfig.monerod.password) .setPriority(2)); await wait(10000); connection = await user3.getMoneroConnection(); testConnection(connection!, TestConfig.monerod.url, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 2); } catch (err2) { err = err2; } // stop processes if (user3) await releaseHavenoProcess(user3, true); if (monerod3) await monerod3.stopProcess(); if (err) throw err; }); // NOTE: To run full test, the following conditions must be met: // - monerod1-local must be stopped // - monerod2-local must be running // - user1-daemon-local must be running and own its monerod process (so it can be stopped) test("Can start and stop a local Monero node (CI)", async() => { // expect error stopping stopped local node try { await user1.stopMoneroNode(); HavenoUtils.log(1, "Running local Monero node stopped"); await user1.stopMoneroNode(); // stop 2nd time to force error throw new Error("should have thrown"); } catch (err: any) { if (err.message !== "Local Monero node is not running" && err.message !== "Cannot stop local Monero node because we don't own its process") { throw new Error("Unexpected error: " + err.message); } } if (await user1.isMoneroNodeOnline()) { HavenoUtils.log(0, "Warning: local Monero node is already running, skipping start and stop local Monero node tests"); // expect error due to existing running node const newSettings = new XmrNodeSettings(); try { await user1.startMoneroNode(newSettings); throw new Error("should have thrown"); } catch (err: any) { if (err.message !== "Local Monero node already online") throw new Error("Unexpected error: " + err.message); } } else { // expect error when passing in bad arguments const badSettings = new XmrNodeSettings(); badSettings.setStartupFlagsList(["--invalid-flag"]); try { await user1.startMoneroNode(badSettings); throw new Error("should have thrown"); } catch (err: any) { if (!err.message.startsWith("Failed to start monerod:")) throw new Error("Unexpected error: "); } // expect successful start with custom settings const connectionsBefore = await user1.getMoneroConnections(); const settings: XmrNodeSettings = new XmrNodeSettings(); const dataDir = TestConfig.moneroBinsDir + "/" + TestConfig.baseCurrencyNetwork.toLowerCase() + "/node1"; const logFile = dataDir + "/test.log"; settings.setBlockchainPath(dataDir); settings.setStartupFlagsList(["--log-file", logFile, "--no-zmq"]); await user1.startMoneroNode(settings); assert(await user1.isMoneroNodeOnline()); // expect settings are updated const settingsAfter = await user1.getMoneroNodeSettings(); testMoneroNodeSettingsEqual(settings, settingsAfter!); // expect connection to local monero node to succeed let daemon = await moneroTs.connectToDaemonRpc(TestConfig.monerod.url, "superuser", "abctesting123"); let height = await daemon.getHeight(); assert(height > 0); // expect error due to existing running node const newSettings = new XmrNodeSettings(); try { await user1.startMoneroNode(newSettings); throw new Error("should have thrown"); } catch (err: any) { if (err.message !== "Local Monero node already online") throw new Error("Unexpected error: " + err.message); } // expect stopped node await user1.stopMoneroNode(); assert(!(await user1.isMoneroNodeOnline())); try { daemon = await moneroTs.connectToDaemonRpc(TestConfig.monerod.url); height = await daemon.getHeight(); console.log("GOT HEIGHT: " + height); throw new Error("should have thrown"); } catch (err: any) { if (err.message.indexOf("connect ECONNREFUSED 127.0.0.1:28081") <= 0) throw new Error("Unexpected error: " + err.message); } // start local node again await user1.startMoneroNode(settings); assert(await user1.isMoneroNodeOnline()); } }); // test wallet balances, transactions, deposit addresses, create and relay txs test("Has a Monero wallet (CI)", async () => { // get seed phrase const seed = await user1.getXmrSeed(); await moneroTs.MoneroUtils.validateMnemonic(seed); // get primary address const primaryAddress = await user1.getXmrPrimaryAddress(); await moneroTs.MoneroUtils.validateAddress(primaryAddress, TestConfig.networkType); // wait for user1 to have unlocked balance const tradeAmount = 250000000000n; await waitForAvailableBalance(tradeAmount * 2n, user1); // test balances const balancesBefore: XmrBalanceInfo = await user1.getBalances(); // TODO: rename to getXmrBalances() for consistency? expect(BigInt(balancesBefore.getAvailableBalance())).toBeGreaterThan(0n); expect(BigInt(balancesBefore.getBalance())).toBeGreaterThanOrEqual(BigInt(balancesBefore.getAvailableBalance())); // get transactions const txs: XmrTx[]= await user1.getXmrTxs(); assert(txs.length > 0); for (const tx of txs) { testTx(tx, {isCreatedTx: false}); } // get new subaddresses for (let i = 0; i < 0; i++) { const address = await user1.getXmrNewSubaddress(); await moneroTs.MoneroUtils.validateAddress(address, TestConfig.networkType); } // create withdraw tx const destination = new XmrDestination().setAddress(await user1.getXmrNewSubaddress()).setAmount("100000000000"); let tx: XmrTx|undefined = await user1.createXmrTx([destination]); testTx(tx, {isCreatedTx: true}); // relay withdraw tx const txHash = await user1.relayXmrTx(tx.getMetadata()); expect(txHash.length).toEqual(64); await wait(TestConfig.trade.walletSyncPeriodMs * 2); // wait for wallet to sync relayed tx // balances decreased const balancesAfter = await user1.getBalances(); expect(BigInt(balancesAfter.getBalance())).toBeLessThan(BigInt(balancesBefore.getBalance())); expect(BigInt(balancesAfter.getAvailableBalance())).toBeLessThan(BigInt(balancesBefore.getAvailableBalance())); // get relayed tx tx = await user1.getXmrTx(txHash); testTx(tx!, {isCreatedTx: false}); // relay invalid tx try { await user1.relayXmrTx("invalid tx metadata"); throw new Error("Cannot relay invalid tx metadata"); } catch (err: any) { if (err.message !== "Failed to parse hex.") throw new Error("Unexpected error: " + err.message); } }); test("Can get balances (CI, sanity check)", async () => { const balances: XmrBalanceInfo = await user1.getBalances(); expect(BigInt(balances.getAvailableBalance())).toBeGreaterThanOrEqual(0); expect(BigInt(balances.getPendingBalance())).toBeGreaterThanOrEqual(0); expect(BigInt(balances.getReservedOfferBalance())).toBeGreaterThanOrEqual(0); expect(BigInt(balances.getReservedTradeBalance())).toBeGreaterThanOrEqual(0); }); test("Can send and receive push notifications (CI, sanity check)", async () => { // add notification listener const notifications: NotificationMessage[] = []; await user1.addNotificationListener(notification => { notifications.push(notification); }); // send test notification for (let i = 0; i < 3; i++) { await user1._sendNotification(new NotificationMessage() .setTimestamp(Date.now()) .setTitle("Test title " + i) .setMessage("Test message " + i)); } // test notification await wait(1000); assert(notifications.length >= 3); for (let i = 0; i < 3; i++) { assert(notifications[i].getTimestamp() > 0); assert.equal(notifications[i].getTitle(), "Test title " + i); assert.equal(notifications[i].getMessage(), "Test message " + i); } }); test("Can get asset codes with prices and their payment methods (CI, sanity check)", async() => { const assetCodes = await user1.getPricedAssetCodes(); for (const assetCode of assetCodes) { const paymentMethods = await user1.getPaymentMethods(assetCode); expect(paymentMethods.length).toBeGreaterThanOrEqual(0); } }); test("Can get market prices (CI, sanity check)", async () => { // get all market prices const prices: MarketPriceInfo[] = await user1.getPrices(); expect(prices.length).toBeGreaterThan(1); for (const price of prices) { expect(price.getCurrencyCode().length).toBeGreaterThan(0); expect(price.getPrice()).toBeGreaterThanOrEqual(0); } // get market prices of primary assets for (const assetCode of TestConfig.assetCodes) { const price = await user1.getPrice(assetCode); expect(price).toBeGreaterThan(0); } // test that prices are reasonable const usd = await user1.getPrice("USD"); expect(usd).toBeGreaterThan(50); expect(usd).toBeLessThan(5000); const ltc = await user1.getPrice("LTC"); expect(ltc).toBeGreaterThan(0.0004); expect(ltc).toBeLessThan(40); const btc = await user1.getPrice("BTC"); expect(btc).toBeGreaterThan(0.0004); expect(btc).toBeLessThan(0.4); // test invalid currency await expect(async () => { await user1.getPrice("INVALID_CURRENCY") }) .rejects .toThrow('Currency not found: INVALID_CURRENCY'); }); test("Can get market depth (CI, sanity check)", async () => { const assetCode = "eth"; // clear offers await clearOffers(user1, assetCode); await clearOffers(user2, assetCode); async function clearOffers(havenod: HavenoClient, assetCode: string) { for (const offer of await havenod.getMyOffers(assetCode)) { if (offer.getBaseCurrencyCode().toLowerCase() === assetCode.toLowerCase()) { await havenod.removeOffer(offer.getId()); } } } // market depth has no data await wait(TestConfig.trade.maxTimePeerNoticeMs); let marketDepth = await user1.getMarketDepth(assetCode); expect(marketDepth.getBuyPricesList().length).toEqual(0); expect(marketDepth.getBuyDepthList().length).toEqual(0); expect(marketDepth.getSellPricesList().length).toEqual(0); expect(marketDepth.getSellDepthList().length).toEqual(0); // post offers to buy and sell await makeOffer({maker: {havenod: user1}, direction: OfferDirection.BUY, offerAmount: 150000000000n, assetCode: assetCode, price: 17.0}); await makeOffer({maker: {havenod: user1}, direction: OfferDirection.BUY, offerAmount: 150000000000n, assetCode: assetCode, price: 17.2}); await makeOffer({maker: {havenod: user1}, direction: OfferDirection.BUY, offerAmount: 200000000000n, assetCode: assetCode, price: 17.3}); await makeOffer({maker: {havenod: user1}, direction: OfferDirection.BUY, offerAmount: 150000000000n, assetCode: assetCode, price: 17.3}); await makeOffer({maker: {havenod: user1}, direction: OfferDirection.SELL, offerAmount: 300000000000n, assetCode: assetCode, priceMargin: 0.00}); await makeOffer({maker: {havenod: user1}, direction: OfferDirection.SELL, offerAmount: 300000000000n, assetCode: assetCode, priceMargin: 0.02}); await makeOffer({maker: {havenod: user1}, direction: OfferDirection.SELL, offerAmount: 400000000000n, assetCode: assetCode, priceMargin: 0.05}); // get user2's market depth await wait(TestConfig.trade.maxTimePeerNoticeMs); marketDepth = await user1.getMarketDepth(assetCode); // each unique price has a depth expect(marketDepth.getBuyPricesList().length).toEqual(3); expect(marketDepth.getSellPricesList().length).toEqual(3); expect(marketDepth.getBuyPricesList().length).toEqual(marketDepth.getBuyDepthList().length); expect(marketDepth.getSellPricesList().length).toEqual(marketDepth.getSellDepthList().length); // test buy prices and depths const buyOffers = (await user1.getOffers(assetCode, OfferDirection.BUY)).concat(await user1.getMyOffers(assetCode, OfferDirection.BUY)).sort(function(a, b) { return parseFloat(a.getPrice()) - parseFloat(b.getPrice()) }); expect(marketDepth.getBuyPricesList()[0]).toEqual(1 / parseFloat(buyOffers[0].getPrice())); // TODO: price when posting offer is reversed. this assumes crypto counter currency expect(marketDepth.getBuyPricesList()[1]).toEqual(1 / parseFloat(buyOffers[1].getPrice())); expect(marketDepth.getBuyPricesList()[2]).toEqual(1 / parseFloat(buyOffers[2].getPrice())); expect(marketDepth.getBuyDepthList()[0]).toEqual(0.15); expect(marketDepth.getBuyDepthList()[1]).toEqual(0.30); expect(marketDepth.getBuyDepthList()[2]).toEqual(0.65); // test sell prices and depths const sellOffers = (await user1.getOffers(assetCode, OfferDirection.SELL)).concat(await user1.getMyOffers(assetCode, OfferDirection.SELL)).sort(function(a, b) { return parseFloat(b.getPrice()) - parseFloat(a.getPrice()) }); expect(marketDepth.getSellPricesList()[0]).toEqual(1 / parseFloat(sellOffers[0].getPrice())); expect(marketDepth.getSellPricesList()[1]).toEqual(1 / parseFloat(sellOffers[1].getPrice())); expect(marketDepth.getSellPricesList()[2]).toEqual(1 / parseFloat(sellOffers[2].getPrice())); expect(marketDepth.getSellDepthList()[0]).toEqual(0.3); expect(marketDepth.getSellDepthList()[1]).toEqual(0.6); expect(marketDepth.getSellDepthList()[2]).toEqual(1); // clear offers await clearOffers(user1, assetCode); await clearOffers(user2, assetCode); // test invalid currency await expect(async () => {await user1.getMarketDepth("INVALID_CURRENCY")}) .rejects .toThrow('Currency not found: INVALID_CURRENCY'); }); test("Can register as an arbitrator (CI)", async () => { // test bad dispute agent type try { await arbitrator.registerDisputeAgent("unsupported type", getArbitratorPrivKey(0)); throw new Error("should have thrown error registering bad type"); } catch (err: any) { if (err.message !== "unknown dispute agent type 'unsupported type'") throw new Error("Unexpected error: " + err.message); } // test bad key try { await arbitrator.registerDisputeAgent("mediator", "bad key"); throw new Error("should have thrown error registering bad key"); } catch (err: any) { if (err.message !== "invalid registration key") throw new Error("Unexpected error: " + err.message); } // register arbitrator with good key await arbitrator.registerDisputeAgent("arbitrator", getArbitratorPrivKey(0)); }); test("Can get offers (CI)", async () => { for (const assetCode of TestConfig.assetCodes) { const offers: OfferInfo[] = await user1.getOffers(assetCode); for (const offer of offers) testOffer(offer); } }); test("Can get my offers (CI)", async () => { // get all offers const offers: OfferInfo[] = await user1.getMyOffers(); for (const offer of offers) testOffer(offer); // get offers by asset code for (const assetCode of TestConfig.assetCodes) { const offers: OfferInfo[] = await user1.getMyOffers(assetCode); for (const offer of offers) { testOffer(offer); expect(assetCode).toEqual(isCrypto(assetCode) ? offer.getBaseCurrencyCode() : offer.getCounterCurrencyCode()); // crypto asset codes are base } } }); test("Can get payment methods (CI)", async () => { const paymentMethods: PaymentMethod[] = await user1.getPaymentMethods(); expect(paymentMethods.length).toBeGreaterThan(0); for (const paymentMethod of paymentMethods) { expect(paymentMethod.getId().length).toBeGreaterThan(0); expect(BigInt(paymentMethod.getMaxTradeLimit())).toBeGreaterThan(0n); expect(BigInt(paymentMethod.getMaxTradePeriod())).toBeGreaterThan(0n); expect(paymentMethod.getSupportedAssetCodesList().length).toBeGreaterThanOrEqual(0); } }); test("Can get payment accounts (CI)", async () => { const paymentAccounts: PaymentAccount[] = await user1.getPaymentAccounts(); for (const paymentAccount of paymentAccounts) { if (paymentAccount.getPaymentAccountPayload()!.getCryptoCurrencyAccountPayload()) { // TODO (woodser): test non-crypto testCryptoPaymentAccount(paymentAccount); } } }); // TODO: FieldId represented as number test("Can validate payment account forms (CI, sanity check)", async () => { // get payment methods const paymentMethods = await user1.getPaymentMethods(); expect(paymentMethods.length).toEqual(TestConfig.paymentMethods.length); for (const paymentMethod of paymentMethods) { assert(moneroTs.GenUtils.arrayContains(TestConfig.paymentMethods, paymentMethod.getId()), "Payment method is not expected: " + paymentMethod.getId()); } // test form for each payment method for (const paymentMethod of paymentMethods) { // generate form const accountForm = await user1.getPaymentAccountForm(paymentMethod.getId()); // complete form, validating each field for (const field of accountForm.getFieldsList()) { // validate invalid form field try { const invalidInput = getInvalidFormInput(accountForm, field.getId()); await user1.validateFormField(accountForm, field.getId(), invalidInput); throw new Error("Should have thrown error validating form field '" + field.getId() + "' with invalid value '" + invalidInput + "'"); } catch (err: any) { if (err.message.indexOf("Not implemented") >= 0) throw err; if (err.message.indexOf("Should have thrown") >= 0) throw err; } // validate valid form field const validInput = getValidFormInput(accountForm, field.getId()); await user1.validateFormField(accountForm, field.getId(), validInput); field.setValue(validInput); } // create payment account const paymentAccount = await user1.createPaymentAccount(accountForm); // payment account added let found = false; for (const userAccount of await user1.getPaymentAccounts()) { if (paymentAccount.getId() === userAccount.getId()) { found = true; break; } } assert(found, "Payment account not found after adding"); // test payment account expect(paymentAccount.getPaymentMethod()!.getId()).toEqual(paymentMethod.getId()); testPaymentAccount(paymentAccount, accountForm); // delete payment account // await user1.deletePaymentAccount(paymentAccount.getId()); // TODO: support deleting payment accounts over grpc } }); test("Can create fiat payment accounts (CI)", async () => { // get payment account form const paymentMethodId = HavenoUtils.getPaymentMethodId(PaymentAccountForm.FormId.REVOLUT); const accountForm = await user1.getPaymentAccountForm(paymentMethodId); // edit form HavenoUtils.setFormValue(accountForm, PaymentAccountFormField.FieldId.ACCOUNT_NAME, "Revolut account " + moneroTs.GenUtils.getUUID()); HavenoUtils.setFormValue(accountForm, PaymentAccountFormField.FieldId.USERNAME, "user123"); HavenoUtils.setFormValue(accountForm, PaymentAccountFormField.FieldId.TRADE_CURRENCIES, "gbp,eur,usd"); // create payment account const fiatAccount = await user1.createPaymentAccount(accountForm); expect(fiatAccount.getAccountName()).toEqual(HavenoUtils.getFormValue(accountForm, PaymentAccountFormField.FieldId.ACCOUNT_NAME)); expect(fiatAccount.getSelectedTradeCurrency()!.getCode()).toEqual("USD"); expect(fiatAccount.getTradeCurrenciesList().length).toBeGreaterThan(0); expect(fiatAccount.getPaymentAccountPayload()!.getPaymentMethodId()).toEqual(paymentMethodId); expect(fiatAccount.getPaymentAccountPayload()!.getRevolutAccountPayload()!.getUsername()).toEqual(HavenoUtils.getFormValue(accountForm, PaymentAccountFormField.FieldId.USERNAME)); // payment account added let found = false; for (const paymentAccount of await user1.getPaymentAccounts()) { if (paymentAccount.getId() === fiatAccount.getId()) { found = true; break; } } assert(found, "Payment account not found after adding"); // delete payment account await user1.deletePaymentAccount(fiatAccount.getId()); // no longer has payment account try { await user1.getPaymentAccount(fiatAccount.getId()); throw new Error("Should have thrown error getting deleted payment account"); } catch (err: any) { if (err.message.indexOf("Should have thrown") >= 0) throw err; } }); test("Can create crypto payment accounts (CI)", async () => { // test each crypto for (const testAccount of TestConfig.cryptoAddresses) { // create payment account const name = testAccount.currencyCode + " " + testAccount.address.substr(0, 8) + "... " + moneroTs.GenUtils.getUUID(); const paymentAccount: PaymentAccount = await user1.createCryptoPaymentAccount(name, testAccount.currencyCode, testAccount.address); testCryptoPaymentAccount(paymentAccount); testCryptoPaymentAccountEquals(paymentAccount, testAccount, name); // fetch and test payment account let fetchedAccount: PaymentAccount|undefined; for (const account of await user1.getPaymentAccounts()) { if (paymentAccount.getId() === account.getId()) { fetchedAccount = account; break; } } if (!fetchedAccount) throw new Error("Payment account not found after being added"); testCryptoPaymentAccount(paymentAccount); testCryptoPaymentAccountEquals(fetchedAccount, testAccount, name); // delete payment account await user1.deletePaymentAccount(paymentAccount.getId()); // no longer has payment account try { await user1.getPaymentAccount(paymentAccount.getId()); throw new Error("Should have thrown error getting deleted payment account"); } catch (err: any) { if (err.message.indexOf("Should have thrown") >= 0) throw err; } } // test invalid currency code await expect(async () => { await user1.createCryptoPaymentAccount("My first account", "ABC", "123"); }) .rejects .toThrow("crypto currency with code 'abc' not found"); // test invalid address await expect(async () => { await user1.createCryptoPaymentAccount("My second account", "ETH", "123"); }) .rejects .toThrow('123 is not a valid eth address'); // test address duplicity let uid = "Unique account name " + moneroTs.GenUtils.getUUID(); await user1.createCryptoPaymentAccount(uid, TestConfig.cryptoAddresses[0].currencyCode, TestConfig.cryptoAddresses[0].address) await expect(async () => { await user1.createCryptoPaymentAccount(uid, TestConfig.cryptoAddresses[0].currencyCode, TestConfig.cryptoAddresses[0].address); }) .rejects .toThrow("Account '" + uid + "' is already taken"); function testCryptoPaymentAccountEquals(paymentAccount: PaymentAccount, testAccount: any, name: string) { expect(paymentAccount.getAccountName()).toEqual(name); expect(paymentAccount.getPaymentAccountPayload()!.getCryptoCurrencyAccountPayload()!.getAddress()).toEqual(testAccount.address); expect(paymentAccount.getSelectedTradeCurrency()!.getCode()).toEqual(testAccount.currencyCode.toUpperCase()); } }); test("Can prepare for trading (CI)", async () => { await prepareForTrading(5, user1, user2); }); test("Can post and remove an offer (CI, sanity check)", async () => { // wait for user1 to have unlocked balance to post offer await waitForAvailableBalance(250000000000n * 2n, user1); // get unlocked balance before reserving funds for offer const availableBalanceBefore = BigInt((await user1.getBalances()).getAvailableBalance()); // post crypto offer let assetCode = "BCH"; let price = 1 / 17; price = 1 / price; // TODO: price in crypto offer is inverted let offer: OfferInfo = await makeOffer({maker: {havenod: user1}, assetCode: assetCode, price: price}); assert.equal(offer.getState(), "AVAILABLE"); assert.equal(offer.getBaseCurrencyCode(), assetCode); // TODO: base and counter currencies inverted in crypto offer assert.equal(offer.getCounterCurrencyCode(), "XMR"); assert.equal(parseFloat(offer.getPrice()), price); // has offer offer = await user1.getMyOffer(offer.getId()); assert.equal(offer.getState(), "AVAILABLE"); // peer sees offer await wait(TestConfig.trade.maxTimePeerNoticeMs); if (!getOffer(await user2.getOffers(assetCode, TestConfig.trade.direction), offer.getId())) throw new Error("Offer " + offer.getId() + " was not found in peer's offers after posted"); // cancel offer await user1.removeOffer(offer.getId()); // offer is removed from my offers if (getOffer(await user1.getMyOffers(assetCode, OfferDirection.BUY), offer.getId())) throw new Error("Offer " + offer.getId() + " was found in my offers after removal"); // peer does not see offer await wait(TestConfig.trade.maxTimePeerNoticeMs); if (getOffer(await user2.getOffers(assetCode, TestConfig.trade.direction), offer.getId())) throw new Error("Offer " + offer.getId() + " was found in peer's offers after removed"); // reserved balance released expect(BigInt((await user1.getBalances()).getAvailableBalance())).toEqual(availableBalanceBefore); // post fiat offer assetCode = "USD"; price = 180.0; offer = await makeOffer({maker: {havenod: user1}, assetCode: assetCode, price: price}); assert.equal(offer.getState(), "AVAILABLE"); assert.equal(offer.getBaseCurrencyCode(), "XMR"); assert.equal(offer.getCounterCurrencyCode(), "USD"); assert.equal(parseFloat(offer.getPrice()), price); // has offer offer = await user1.getMyOffer(offer.getId()); assert.equal(offer.getState(), "AVAILABLE"); // peer sees offer await wait(TestConfig.trade.maxTimePeerNoticeMs); if (!getOffer(await user2.getOffers(assetCode, TestConfig.trade.direction), offer.getId())) throw new Error("Offer " + offer.getId() + " was not found in peer's offers after posted"); // cancel offer await user1.removeOffer(offer.getId()); // offer is removed from my offers if (getOffer(await user1.getMyOffers(assetCode, OfferDirection.BUY), offer.getId())) throw new Error("Offer " + offer.getId() + " was found in my offers after removal"); // reserved balance released expect(BigInt((await user1.getBalances()).getAvailableBalance())).toEqual(availableBalanceBefore); // peer does not see offer await wait(TestConfig.trade.maxTimePeerNoticeMs); if (getOffer(await user2.getOffers(assetCode, TestConfig.trade.direction), offer.getId())) throw new Error("Offer " + offer.getId() + " was found in peer's offers after removed"); }); // TODO: provide number of confirmations in offer status test("Can schedule offers with locked funds (CI)", async () => { let user3: HavenoClient|undefined; let err: any; try { // configure test const completeTrade = true; const resolveDispute = Math.random() < 0.5; // start user3 user3 = await initHaveno(); const user3Wallet = await moneroTs.connectToWalletRpc("http://127.0.0.1:" + user3.getWalletRpcPort(), TestConfig.defaultHavenod.walletUsername, TestConfig.defaultHavenod.accountPassword); // fund user3 with 2 outputs of 0.5 XMR const outputAmt = 500000000000n; await fundOutputs([user3Wallet], outputAmt, 2, false); // schedule offer const assetCode = "BCH"; const direction = OfferDirection.BUY; const ctx = new TradeContext({maker: {havenod: user3}, assetCode: assetCode, direction: direction, awaitFundsToMakeOffer: false, reserveExactAmount: true}); let offer: OfferInfo = await makeOffer(ctx); assert.equal(offer.getState(), "PENDING"); // has offer offer = await user3.getMyOffer(offer.getId()); assert.equal(offer.getState(), "PENDING"); // balances unchanged expect(BigInt((await user3.getBalances()).getPendingBalance())).toEqual(outputAmt * 2n); expect(BigInt((await user3.getBalances()).getReservedOfferBalance())).toEqual(0n); // peer does not see offer because it's scheduled await wait(TestConfig.trade.maxTimePeerNoticeMs); if (getOffer(await user1.getOffers(assetCode, direction), offer.getId())) throw new Error("Offer " + offer.getId() + " was found in peer's offers before posted"); // cancel offer await user3.removeOffer(offer.getId()); if (getOffer(await user3.getOffers(assetCode, direction), offer.getId())) throw new Error("Offer " + offer.getId() + " was found after canceling offer"); // balances unchanged expect(BigInt((await user3.getBalances()).getPendingBalance())).toEqual(outputAmt * 2n); expect(BigInt((await user3.getBalances()).getReservedOfferBalance())).toEqual(0n); // schedule offer offer = await makeOffer({maker: {havenod: user3}, assetCode: assetCode, direction: direction, awaitFundsToMakeOffer: false, reserveExactAmount: true}); assert.equal(offer.getState(), "PENDING"); // peer does not see offer because it's scheduled await wait(TestConfig.trade.maxTimePeerNoticeMs); if (getOffer(await user1.getOffers(assetCode, direction), offer.getId())) throw new Error("Offer " + offer.getId() + " was found in peer's offers before posted"); // stop user3 let user3Config = {appName: user3.getAppName()}; await releaseHavenoProcess(user3); // mine 10 blocks await mineBlocks(10); // restart user3 user3 = await initHaveno(user3Config); ctx.maker.havenod = user3; // awaiting split output await waitForAvailableBalance(outputAmt, user3); offer = await user3.getMyOffer(offer.getId()); assert.equal(offer.getState(), "PENDING"); // stop user3 user3Config = {appName: user3.getAppName()}; await releaseHavenoProcess(user3); // mine 10 blocks await mineBlocks(10); // restart user3 user3 = await initHaveno(user3Config); ctx.maker.havenod = user3; // offer is available await waitForAvailableBalance(outputAmt + outputAmt / 2n, user3); await wait(TestConfig.trade.walletSyncPeriodMs); offer = await user3.getMyOffer(offer.getId()); assert.equal(offer.getState(), "AVAILABLE"); ctx.maker.splitOutputTxFee = BigInt(offer.getSplitOutputTxFee()); // one output is reserved, remaining is unlocked const balances = await user3.getBalances(); expect(BigInt((balances.getPendingBalance()))).toEqual(0n); expect(BigInt((balances.getAvailableBalance()))).toBeGreaterThan(outputAmt); // TODO: testScheduleOffer(reserveExactAmount) to test these expect(BigInt((balances.getReservedOfferBalance()))).toEqual(outputAmt * 2n - ctx.maker.splitOutputTxFee! - BigInt(balances.getAvailableBalance())); // peer sees offer await wait(TestConfig.trade.maxTimePeerNoticeMs); if (!getOffer(await user1.getOffers(assetCode, direction), offer.getId())) throw new Error("Offer " + offer.getId() + " was not found in peer's offers after posted"); // complete trade or cancel offer depending on configuration if (completeTrade) { HavenoUtils.log(1, "Completing trade from scheduled offer, opening and resolving dispute: " + resolveDispute); await executeTrade(Object.assign(ctx, {buyerDisputeContext: resolveDispute ? DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK : DisputeContext.NONE})); } else { // cancel offer await user3.removeOffer(offer.getId()); // offer is removed from my offers if (getOffer(await user3.getMyOffers(assetCode), offer.getId())) throw new Error("Offer " + offer.getId() + " was found in my offers after removal"); // reserved balance becomes unlocked expect(BigInt((await user3.getBalances()).getAvailableBalance())).toEqual(outputAmt * 2n); expect(BigInt((await user3.getBalances()).getPendingBalance())).toEqual(0n); expect(BigInt((await user3.getBalances()).getReservedOfferBalance())).toEqual(0n); } } catch (err2) { err = err2; } // stop and delete instances if (user3) await releaseHavenoProcess(user3, true); if (err) throw err; }); test("Can reserve exact amount needed for offer (CI)", async () => { let randomOfferAmount = 1.0 + (Math.random() * 1.0); // random amount between 1 and 2 xmr await executeTrade({ price: 150, offerAmount: HavenoUtils.xmrToAtomicUnits(randomOfferAmount), offerMinAmount: HavenoUtils.xmrToAtomicUnits(.15), tradeAmount: HavenoUtils.xmrToAtomicUnits(.92), reserveExactAmount: true, testBalanceChangeEndToEnd: true }); }); test("Cannot post offer exceeding trade limit (CI, sanity check)", async () => { let assetCode = "USD"; const account = await createPaymentAccount(user1, assetCode, "zelle"); // test posting buy offer above limit try { await executeTrade({ offerAmount: moneroTs.MoneroUtils.xmrToAtomicUnits(3.1), direction: OfferDirection.BUY, assetCode: assetCode, makerPaymentAccountId: account.getId(), takeOffer: false }); throw new Error("Should have rejected posting offer above trade limit") } catch (err: any) { assert(err.message.indexOf("amount is larger than") === 0); } // test posting sell offer above limit try { await executeTrade({ offerAmount: moneroTs.MoneroUtils.xmrToAtomicUnits(12.1), direction: OfferDirection.SELL, assetCode: assetCode, makerPaymentAccountId: account.getId(), takeOffer: false }); throw new Error("Should have rejected posting offer above trade limit") } catch (err: any) { assert(err.message.indexOf("amount is larger than") === 0); } // test that sell limit is higher than buy limit let offerId = await executeTrade({ offerAmount: 2100000000000n, direction: OfferDirection.SELL, assetCode: assetCode, makerPaymentAccountId: account.getId(), takeOffer: false }); await user1.removeOffer(offerId); }); test("Can complete a trade within a range", async () => { // create payment accounts let paymentMethodId = "cash_at_atm"; let assetCode = "aud"; let makerPaymentAccount = await createPaymentAccount(user1, assetCode, paymentMethodId); // TODO: support getPaymentAccount() which gets or creates let takerPaymentAccount = await createPaymentAccount(user2, assetCode, paymentMethodId); // get trade statistics before const tradeStatisticsPre = await arbitrator.getTradeStatistics(); // execute trade const offerAmount = HavenoUtils.xmrToAtomicUnits(2); const offerMinAmount = HavenoUtils.xmrToAtomicUnits(.15); const tradeAmount = getRandomBigIntWithinRange(offerMinAmount, offerAmount); const ctx: Partial = { price: 142.23, offerAmount: offerAmount, offerMinAmount: offerMinAmount, tradeAmount: tradeAmount, testPayoutUnlocked: true, // override to test unlock makerPaymentAccountId: makerPaymentAccount.getId(), takerPaymentAccountId: takerPaymentAccount.getId(), assetCode: assetCode, testBalanceChangeEndToEnd: true } await executeTrade(ctx); // test trade statistics after if (ctx.buyerSendsPayment && ctx.sellerReceivesPayment) { const tradeStatisticsPost = await arbitrator.getTradeStatistics(); assert(tradeStatisticsPost.length - tradeStatisticsPre.length === 1); } }); test("Can complete trades at the same time (CI, sanity check)", async () => { // create trade contexts with customized payment methods and random amounts const ctxs = getTradeContexts(TestConfig.assetCodes.length); for (let i = 0; i < ctxs.length; i++) { ctxs[i].assetCode = TestConfig.assetCodes[i]; // test each asset code ctxs[i].offerAmount = getRandomBigIntWithinPercent(TestConfig.trade.offerAmount!, 0.15); let paymentMethodId; if (ctxs[i].assetCode === "USD") paymentMethodId = "zelle"; if (ctxs[i].assetCode === "EUR") paymentMethodId = "revolut"; ctxs[i].makerPaymentAccountId = (await createPaymentAccount(ctxs[i].maker.havenod!, ctxs[i].assetCode!, paymentMethodId)).getId(); ctxs[i].takerPaymentAccountId = (await createPaymentAccount(ctxs[i].taker.havenod!, ctxs[i].assetCode!, paymentMethodId)).getId(); } // execute trades with capped concurrency for CI tests await executeTrades(ctxs); }); test("Can complete all trade combinations (stress)", async () => { // generate trade context for each combination (buyer/seller, maker/taker, dispute(s), dispute winner) let ctxs: TradeContext[] = []; const MAKER_OPTS = [TradeRole.MAKER, TradeRole.TAKER]; const DIRECTION_OPTS = [OfferDirection.BUY, OfferDirection.SELL]; const BUYER_DISPUTE_OPTS = [DisputeContext.NONE, DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK, DisputeContext.OPEN_AFTER_PAYMENT_SENT]; const SELLER_DISPUTE_OPTS = [DisputeContext.NONE, DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK, DisputeContext.OPEN_AFTER_PAYMENT_SENT]; const DISPUTE_WINNER_OPTS = [DisputeResult.Winner.BUYER, DisputeResult.Winner.SELLER]; for (let i = 0; i < MAKER_OPTS.length; i++) { for (let j = 0; j < DIRECTION_OPTS.length; j++) { for (let k = 0; k < BUYER_DISPUTE_OPTS.length; k++) { for (let l = 0; l < SELLER_DISPUTE_OPTS.length; l++) { for (let m = 0; m < DISPUTE_WINNER_OPTS.length; m++) { if (BUYER_DISPUTE_OPTS[k] !== DisputeContext.NONE && SELLER_DISPUTE_OPTS[l] !== DisputeContext.NONE) continue; // skip both opening a dispute const ctx: Partial = { walletSyncPeriodMs: 8000, // increase for stress test maxTimePeerNoticeMs: 8000, maker: { havenod: MAKER_OPTS[i] === TradeRole.MAKER ? user1 : user2 }, taker: { havenod: MAKER_OPTS[i] === TradeRole.MAKER ? user2 : user1 }, direction: DIRECTION_OPTS[j], buyerDisputeContext: BUYER_DISPUTE_OPTS[k], sellerDisputeContext: SELLER_DISPUTE_OPTS[l], disputeWinner: DISPUTE_WINNER_OPTS[m], disputeSummary: "After much deliberation, " + (DISPUTE_WINNER_OPTS[m] === DisputeResult.Winner.BUYER ? "buyer" : "seller") + " is winner", offerAmount: getRandomBigIntWithinPercent(TestConfig.trade.offerAmount!, 0.15) }; ctxs.push(Object.assign({}, new TradeContext(TestConfig.trade), ctx)); } } } } } // execute trades const ctxIdx = undefined; // run single index for debugging if (ctxIdx !== undefined) ctxs = ctxs.slice(ctxIdx, ctxIdx + 1); HavenoUtils.log(0, "Executing " + ctxs.length + " trade configurations"); await executeTrades(ctxs); }); test("Can go offline while completing a trade (CI, sanity check)", async () => { let traders: HavenoClient[] = []; let ctx: TradeContext = new TradeContext(TestConfig.trade); let err: any; try { // start 2 trader processes HavenoUtils.log(1, "Starting trader processes"); traders = await initHavenos(2); // fund traders HavenoUtils.log(1, "Funding traders"); const tradeAmount = 250000000000n; await waitForAvailableBalance(tradeAmount * 2n, ...traders); // create trade config ctx.maker.havenod = traders[0]; ctx.taker.havenod = traders[1]; ctx.buyerOfflineAfterTake = true; ctx.sellerOfflineAfterTake = true; ctx.buyerOfflineAfterPaymentSent = true; // execute trade await executeTrade(ctx); } catch (e) { err = e; } // stop traders if (ctx.maker.havenod) await releaseHavenoProcess(ctx.maker.havenod, true); if (ctx.taker.havenod) await releaseHavenoProcess(ctx.taker.havenod, true); if (err) throw err; }); test("Can resolve a dispute (CI)", async () => { // create payment accounts let paymentMethodId = "revolut"; let assetCode = "usd"; let makerPaymentAccount = await createPaymentAccount(user1, assetCode, paymentMethodId); let takerPaymentAccount = await createPaymentAccount(user2, assetCode, paymentMethodId); // execute trade await executeTrade({ price: 142.23, offerAmount: HavenoUtils.xmrToAtomicUnits(2), offerMinAmount: HavenoUtils.xmrToAtomicUnits(.15), tradeAmount: HavenoUtils.xmrToAtomicUnits(1), testPayoutUnlocked: true, // override to test unlock makerPaymentAccountId: makerPaymentAccount.getId(), takerPaymentAccountId: takerPaymentAccount.getId(), assetCode: assetCode, buyerDisputeContext: DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK, disputeWinner: DisputeResult.Winner.SELLER, disputeWinnerAmount: HavenoUtils.xmrToAtomicUnits(.767), disputeReason: DisputeResult.Reason.OTHER, disputeSummary: "Payment not completed, so returning trade amount to seller.", testBalanceChangeEndToEnd: true }); // TODO: test receiver = BUYER }); test("Can resolve disputes (CI)", async () => { // execute all configs unless config index given let configIdx = undefined; let testBalancesSequentially = false; // runs each config sequentially to test balances before and after // TODO: this test takes much longer to run in sequence in order to test balances. use test weight config // create test configurations which stop before payment sent const ctxs = getTradeContexts(4); for (const config of ctxs) config.buyerSendsPayment = false; Object.assign(ctxs[3], { offerAmount: HavenoUtils.xmrToAtomicUnits(1), offerMinAmount: HavenoUtils.xmrToAtomicUnits(.15), tradeAmount: HavenoUtils.xmrToAtomicUnits(.578), }); // initiate trades const tradeIds = await executeTrades(ctxs.slice(configIdx, configIdx === undefined ? undefined : configIdx + 1)); // open disputes at same time but do not resolve const trade1 = await user1.getTrade(tradeIds[configIdx === undefined ? 1 : 0]); const trade2 = await user1.getTrade(tradeIds[configIdx === undefined ? 2 : 0]); Object.assign(ctxs[0], { resolveDispute: false, sellerDisputeContext: DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK, disputeWinner: DisputeResult.Winner.SELLER, disputeReason: DisputeResult.Reason.PEER_WAS_LATE, disputeSummary: "Seller is winner" }); Object.assign(ctxs[1], { resolveDispute: false, buyerDisputeContext: DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK, disputeWinner: DisputeResult.Winner.BUYER, disputeReason: DisputeResult.Reason.SELLER_NOT_RESPONDING, disputeSummary: "Split trade amount", disputeWinnerAmount: BigInt(trade1.getAmount()) / 2n + BigInt(trade1.getBuyerSecurityDeposit()) }); Object.assign(ctxs[2], { resolveDispute: false, buyerDisputeContext: DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK, disputeWinner: DisputeResult.Winner.SELLER, disputeReason: DisputeResult.Reason.TRADE_ALREADY_SETTLED, disputeSummary: "Seller gets everything", disputeWinnerAmount: BigInt(trade2.getAmount()) + BigInt(trade2.getBuyerSecurityDeposit()) + BigInt(trade2.getSellerSecurityDeposit()) }); Object.assign(ctxs[3], { resolveDispute: false, buyerSendsPayment: true, sellerDisputeContext: DisputeContext.OPEN_AFTER_PAYMENT_SENT, disputeWinner: DisputeResult.Winner.BUYER, disputeReason: DisputeResult.Reason.TRADE_ALREADY_SETTLED, disputeSummary: "Buyer wins dispute after sending payment", disputeWinnerAmount: HavenoUtils.xmrToAtomicUnits(.1171), }); HavenoUtils.log(1, "Opening disputes"); await executeTrades(ctxs.slice(configIdx, configIdx === undefined ? undefined : configIdx + 1)); // resolve disputes for (const ctx of ctxs) { ctx.resolveDispute = true; ctx.testPayoutUnlocked = false; } HavenoUtils.log(1, "Resolving disputes"); await executeTrades(ctxs.slice(configIdx, configIdx === undefined ? undefined : configIdx + 1), {concurrentTrades: !testBalancesSequentially}); }); test("Can go offline while resolving a dispute (CI)", async () => { let traders: HavenoClient[] = []; let ctx: Partial = {}; let err: any; try { // start trader processes HavenoUtils.log(1, "Starting trader processes"); traders = await initHavenos(2); // create trade config ctx = new TradeContext({ maker: {havenod: traders[0]}, taker: {havenod: traders[1]}, buyerOfflineAfterTake: true, sellerOfflineAfterDisputeOpened: true, buyerOfflineAfterDisputeOpened: false, sellerDisputeContext: DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK, disputeWinner: DisputeResult.Winner.SELLER, disputeReason: DisputeResult.Reason.NO_REPLY, disputeSummary: "Seller wins dispute because buyer has not replied", testPayoutUnlocked: false }); // fund traders const tradeAmount = 250000000000n; await waitForAvailableBalance(tradeAmount * 2n, ...traders); // execute trade await executeTrade(ctx); } catch (e) { err = e; } // stop and delete traders if (ctx.maker && ctx.maker.havenod) await releaseHavenoProcess(ctx.maker!.havenod!, true); if (ctx.taker && ctx.taker.havenod) await releaseHavenoProcess(ctx.taker!.havenod!, true); // closing this client after first induces HttpClientImpl.shutdown() to hang, so this tests timeout handling if (ctx.sellerAppName) deleteHavenoInstanceByAppName(ctx.sellerAppName!); // seller is offline if (err) throw err; }); test("Cannot make or take offer with insufficient unlocked funds (CI, sanity check)", async () => { let user3: HavenoClient|undefined; let err: any; try { // start user3 user3 = await initHaveno(); // user3 creates payment account const paymentAccount = await createPaymentAccount(user3, TestConfig.trade.assetCode!); // user3 cannot make offer with insufficient funds try { await makeOffer({maker: {havenod: user3}, makerPaymentAccountId: paymentAccount.getId(), awaitFundsToMakeOffer: false}); throw new Error("Should have failed making offer with insufficient funds") } catch (err: any) { if (!err.message.includes("not enough money")) throw err; const errTyped = err as HavenoError; assert.equal(errTyped.code, 2); } // user1 gets or posts offer const offers: OfferInfo[] = await user1.getMyOffers(TestConfig.trade.assetCode); let offer: OfferInfo; if (offers.length) offer = offers[0]; else { const tradeAmount = 250000000000n; await waitForAvailableBalance(tradeAmount * 2n, user1); offer = await makeOffer({maker: {havenod: user1}, offerAmount: tradeAmount, awaitFundsToMakeOffer: false}); assert.equal(offer.getState(), "AVAILABLE"); await wait(TestConfig.trade.walletSyncPeriodMs * 2); } // user3 cannot take offer with insufficient funds try { await user3.takeOffer(offer.getId(), paymentAccount.getId()); throw new Error("Should have failed taking offer with insufficient funds") } catch (err: any) { const errTyped = err as HavenoError; assert(errTyped.message.includes("not enough money"), "Unexpected error: " + errTyped.message); assert.equal(errTyped.code, 2); } // user3 does not have trade try { await user3.getTrade(offer.getId()); } catch (err: any) { const errTyped = err as HavenoError; assert.equal(errTyped.code, 3); assert(errTyped.message.includes("trade with id '" + offer.getId() + "' not found")); } // remove offer if posted if (!offers.length) await user1.removeOffer(offer.getId()); } catch (err2) { err = err2; } // stop user3 if (user3) await releaseHavenoProcess(user3, true); if (err) throw err; }); test("Invalidates offers when reserved funds are spent (CI)", async () => { let err; let tx; try { // wait for user1 to have unlocked balance for trade const tradeAmount = 250000000000n; await waitForAvailableBalance(tradeAmount * 2n, user1); // get frozen key images before posting offer const frozenKeyImagesBefore: any[] = []; for (const frozenOutput of await user1Wallet.getOutputs({isFrozen: true})) frozenKeyImagesBefore.push(frozenOutput.getKeyImage().getHex()); // post offer await wait(1000); const assetCode = getRandomAssetCode(); const offer: OfferInfo = await makeOffer({maker: {havenod: user1}, assetCode: assetCode, offerAmount: tradeAmount}); // get key images reserved by offer const reservedKeyImages: any[] = []; const frozenKeyImagesAfter: any[] = []; for (const frozenOutput of await user1Wallet.getOutputs({isFrozen: true})) frozenKeyImagesAfter.push(frozenOutput.getKeyImage().getHex()); for (const frozenKeyImageAfter of frozenKeyImagesAfter) { if (!frozenKeyImagesBefore.includes(frozenKeyImageAfter)) reservedKeyImages.push(frozenKeyImageAfter); } // offer is available to peers await wait(TestConfig.trade.walletSyncPeriodMs * 2); if (!getOffer(await user2.getOffers(assetCode, OfferDirection.BUY), offer.getId())) throw new Error("Offer " + offer.getId() + " was not found in peer's offers after posting"); // spend one of offer's reserved outputs if (!reservedKeyImages.length) throw new Error("No reserved key images detected"); await user1Wallet.thawOutput(reservedKeyImages[0]); tx = await user1Wallet.sweepOutput({keyImage: reservedKeyImages[0], address: await user1Wallet.getPrimaryAddress(), relay: false}); await monerod.submitTxHex(tx.getFullHex()!, true); // mine block so spend is confirmed await mineBlocks(1); // offer is removed from peer offers await wait(20000); if (getOffer(await user2.getOffers(assetCode, OfferDirection.BUY), offer.getId())) throw new Error("Offer " + offer.getId() + " was found in peer's offers after reserved funds spent"); // offer is removed from my offers if (getOffer(await user1.getMyOffers(assetCode, OfferDirection.BUY), offer.getId())) throw new Error("Offer " + offer.getId() + " was found in my offers after reserved funds spent"); // offer is automatically cancelled try { await user1.removeOffer(offer.getId()); throw new Error("cannot remove invalidated offer"); } catch (err: any) { if (err.message === "cannot remove invalidated offer") throw new Error("Unexpected error: " + err.message); } } catch (err2) { err = err2; } // flush tx from pool if (tx) await monerod.flushTxPool(tx.getHash()); if (err) throw err; }); // TODO (woodser): test arbitrator state too // TODO (woodser): test breaking protocol after depositing to multisig (e.g. don't send payment account payload by deleting it) test("Can handle unexpected errors during trade initialization", async () => { let traders: HavenoClient[] = []; let err: any; try { // start and fund 3 trader processes HavenoUtils.log(1, "Starting trader processes"); traders = await initHavenos(3); HavenoUtils.log(1, "Funding traders"); const tradeAmount = 250000000000n; await waitForAvailableBalance(tradeAmount * 2n, traders[0], traders[1], traders[2]); // trader 0 posts offer HavenoUtils.log(1, "Posting offer"); let offer = await makeOffer({maker: {havenod: traders[0]}, offerAmount: tradeAmount}); offer = await traders[0].getMyOffer(offer.getId()); assert.equal(offer.getState(), "AVAILABLE"); // wait for offer to be seen await wait(TestConfig.trade.walletSyncPeriodMs * 2); // trader 1 spends trade funds after initializing trade let paymentAccount = await createCryptoPaymentAccount(traders[1]); wait(3000).then(async function() { try { const traderWallet = await moneroTs.connectToWalletRpc("http://localhost:" + traders[1].getWalletRpcPort(), TestConfig.defaultHavenod.walletUsername, TestConfig.defaultHavenod.accountPassword); for (const frozenOutput of await traderWallet.getOutputs({isFrozen: true})) await traderWallet.thawOutput(frozenOutput.getKeyImage().getHex()); HavenoUtils.log(1, "Sweeping trade funds"); await traderWallet.sweepUnlocked({address: await traderWallet.getPrimaryAddress(), relay: true}); } catch (err: any) { console.log("Caught error sweeping funds!"); console.log(err); } }); // trader 1 tries to take offer try { HavenoUtils.log(1, "Trader 1 taking offer " + offer.getId()); await traders[1].takeOffer(offer.getId(), paymentAccount.getId()); throw new Error("Should have failed taking offer because taker trade funds spent") } catch (err: any) { assert(err.message.includes("not enough unlocked money"), "Unexpected error: " + err.message); } // TODO: test it's unavailable right after taking (taker will know before maker) // trader 0's offer remains available await wait(15000); // give time for trade initialization to fail and offer to become available offer = await traders[0].getMyOffer(offer.getId()); if (offer.getState() !== "AVAILABLE") { HavenoUtils.log(1, "Offer is not yet available, waiting to become available after timeout..."); // TODO (woodser): fail trade on nack during initialization to save a bunch of time await wait(TestConfig.tradeStepTimeoutMs); // wait for offer to become available after timeout offer = await traders[0].getMyOffer(offer.getId()); assert.equal(offer.getState(), "AVAILABLE"); } // trader 0 spends trade funds after trader 2 takes offer wait(3000).then(async function() { try { const traderWallet = await moneroTs.connectToWalletRpc("http://localhost:" + traders[0].getWalletRpcPort(), TestConfig.defaultHavenod.walletUsername, TestConfig.defaultHavenod.accountPassword); for (const frozenOutput of await traderWallet.getOutputs({isFrozen: true})) await traderWallet.thawOutput(frozenOutput.getKeyImage().getHex()); HavenoUtils.log(1, "Sweeping offer funds"); await traderWallet.sweepUnlocked({address: await traderWallet.getPrimaryAddress(), relay: true}); } catch (err: any) { console.log("Caught error sweeping funds!"); console.log(err); } }); // trader 2 tries to take offer paymentAccount = await createCryptoPaymentAccount(traders[2]); try { HavenoUtils.log(1, "Trader 2 taking offer") await traders[2].takeOffer(offer.getId(), paymentAccount.getId()); throw new Error("Should have failed taking offer because maker trade funds spent") } catch (err: any) { // determine if error is expected let expected = false; const expectedErrMsgs = ["not enough unlocked money", "timeout reached. protocol did not complete", "trade is already taken"]; for (const expectedErrMsg of expectedErrMsgs) { if (err.message.indexOf(expectedErrMsg) >= 0) { expected = true; break; } } if (!expected) throw err; } // trader 2's balance is unreserved const trader2Balances = await traders[2].getBalances(); expect(BigInt(trader2Balances.getReservedTradeBalance())).toEqual(0n); expect(BigInt(trader2Balances.getAvailableBalance())).toBeGreaterThan(0n); } catch (err2) { err = err2; } // stop traders for (const trader of traders) await releaseHavenoProcess(trader, true); if (err) throw err; }); // TODO: test opening and resolving dispute as arbitrator and traders go offline test("Selects arbitrators which are online, registered, and least used", async () => { // complete 2 trades using main arbitrator so it's most used // TODO: these trades are not registered with seednode until it's restarted HavenoUtils.log(1, "Preparing for trades"); await prepareForTrading(4, user1, user2); HavenoUtils.log(1, "Completing trades with main arbitrator"); await executeTrades(getTradeContexts(2), {testPayoutConfirmed: false}); // start and register arbitrator2 let arbitrator2 = await initHaveno(); HavenoUtils.log(1, "Registering arbitrator2"); await arbitrator2.registerDisputeAgent("arbitrator", getArbitratorPrivKey(1)); // TODO: re-registering with same address corrupts messages (Cannot decrypt) because existing pub key; overwrite? or throw when registration fails because dispute map can't be updated await wait(TestConfig.trade.walletSyncPeriodMs * 2); // get internal api addresses const arbitrator1ApiUrl = "localhost:" + TestConfig.ports.get(getPort(arbitrator.getUrl()))![1]; // TODO: havenod.getApiUrl()? const arbitrator2ApiUrl = "localhost:" + TestConfig.ports.get(getPort(arbitrator2.getUrl()))![1]; let err = undefined; try { // post offers signed by each arbitrator randomly HavenoUtils.log(1, "Posting offers signed by both arbitrators randomly"); let offer1: OfferInfo | undefined; let offer2: OfferInfo | undefined; while (true) { const offer = await makeOffer({maker: {havenod: user1}}); if (offer.getArbitratorSigner() === arbitrator1ApiUrl && !offer1) offer1 = offer; else if (offer.getArbitratorSigner() === arbitrator2ApiUrl && !offer2) offer2 = offer; else await user1.removeOffer(offer.getId()); if (offer1 && offer2) break; } await wait(TestConfig.trade.walletSyncPeriodMs * 2); // complete a trade which uses arbitrator2 since it's least used HavenoUtils.log(1, "Completing trade using arbitrator2"); await executeTrade({maker: {havenod: user1}, taker: {havenod: user2}, arbitrator: {havenod: arbitrator2}, offerId: offer1.getId(), makerPaymentAccountId: offer1.getPaymentAccountId(), testPayoutConfirmed: false}); let trade = await user1.getTrade(offer1.getId()); assert.equal(trade.getArbitratorNodeAddress(), arbitrator2ApiUrl); // arbitrator2 goes offline without unregistering HavenoUtils.log(1, "Arbitrator2 going offline"); const arbitrator2AppName = arbitrator2.getAppName() await releaseHavenoProcess(arbitrator2); // post offer which uses main arbitrator since arbitrator2 is offline HavenoUtils.log(1, "Posting offer which uses main arbitrator since arbitrator2 is offline"); let offer = await makeOffer({maker: {havenod: user1}}); assert.equal(offer.getArbitratorSigner(), arbitrator1ApiUrl); await user1.removeOffer(offer.getId()); // complete a trade which uses main arbitrator since arbitrator2 is offline HavenoUtils.log(1, "Completing trade using main arbitrator since arbitrator2 is offline"); await executeTrade({maker: {havenod: user1}, taker: {havenod: user2}, offerId: offer2.getId(), makerPaymentAccountId: offer2.getPaymentAccountId(), testPayoutConfirmed: false}); trade = await user1.getTrade(offer2.getId()); assert.equal(trade.getArbitratorNodeAddress(), arbitrator1ApiUrl); // start and unregister arbitrator2 HavenoUtils.log(1, "Starting and unregistering arbitrator2"); arbitrator2 = await initHaveno({appName: arbitrator2AppName}); await arbitrator2.unregisterDisputeAgent("arbitrator"); await wait(TestConfig.trade.walletSyncPeriodMs * 2); // cannot take offers signed by unregistered arbitrator HavenoUtils.log(1, "Taking offer signed by unregistered arbitrator"); try { await executeTrade({maker: {havenod: user1}, taker: {havenod: user2}, offerId: offer2.getId()}); throw new Error("Should have failed taking offer signed by unregistered arbitrator"); } catch (e2: any) { assert (e2.message.indexOf("not found") > 0); } // TODO: offer is removed and unreserved or re-signed, ideally keeping the same id // post offer which uses main arbitrator since arbitrator2 is unregistered offer = await makeOffer({maker: {havenod: user1}}); assert.equal(offer.getArbitratorSigner(), arbitrator1ApiUrl); await wait(TestConfig.trade.walletSyncPeriodMs * 2); // complete a trade which uses main arbitrator since arbitrator2 is unregistered HavenoUtils.log(1, "Completing trade with main arbitrator since arbitrator2 is unregistered"); await executeTrade({maker: {havenod: user1}, taker: {havenod: user2}, offerId: offer.getId(), makerPaymentAccountId: offer.getPaymentAccountId(), testPayoutConfirmed: false}); HavenoUtils.log(1, "Done completing trade with main arbitrator since arbitrator2 is unregistered"); trade = await user2.getTrade(offer.getId()); HavenoUtils.log(1, "Done getting trade"); assert.equal(trade.getArbitratorNodeAddress(), arbitrator1ApiUrl); // release arbitrator2 HavenoUtils.log(1, "Done getting trade"); await releaseHavenoProcess(arbitrator2, true); } catch (e) { err = e; } // cleanup if error if (err) { try { await arbitrator2.unregisterDisputeAgent("arbitrator"); } catch (err) { /*ignore*/ } await releaseHavenoProcess(arbitrator2, true); throw err; } }); test("Can get trade statistics", async () => { const tradeStatisticsArbitrator = await arbitrator.getTradeStatistics(); const tradeStatisticsUser1 = await user1.getTradeStatistics(); const tradeStatisticsUser2 = await user2.getTradeStatistics(); HavenoUtils.log(0, "Trade statistics size (arb/u1/u2): " + tradeStatisticsArbitrator.length + "/" + tradeStatisticsUser1.length + "/" + tradeStatisticsUser2.length); assert(tradeStatisticsArbitrator.length === tradeStatisticsUser1.length && tradeStatisticsUser1.length === tradeStatisticsUser2.length); }); // ----------------------------- TEST HELPERS --------------------------------- function getTradeContexts(numConfigs: number): TradeContext[] { const configs: TradeContext[] = []; for (let i = 0; i < numConfigs; i++) configs.push(new TradeContext(TestConfig.trade)); return configs; } async function executeTrades(ctxs: Partial[], executionCtx?: Partial): Promise { // assign default execution context if (!executionCtx) executionCtx = {}; if (executionCtx.concurrentTrades === undefined) executionCtx.concurrentTrades = ctxs.length > 1; Object.assign(executionCtx, new TradeContext(TestConfig.trade), Object.assign({}, executionCtx)); // start mining if executing trades concurrently let miningStarted = executionCtx.concurrentTrades && await startMining(); // execute trades let offerIds: string[] = []; let err = undefined try { // assign default configs for (let i = 0; i < ctxs.length; i++) Object.assign(ctxs[i], new TradeContext(TestConfig.trade), Object.assign({index: i}, ctxs[i])); // wait for traders to have unlocked balance for trades let tradeAmount: bigint|undefined = undefined; const outputCounts = new Map(); for (const ctx of ctxs) { if (!tradeAmount || tradeAmount < ctx.offerAmount!) tradeAmount = ctx.offerAmount; // use max amount if (ctx.awaitFundsToMakeOffer && ctx.makeOffer && !ctx.offerId) { const wallet = await getWallet(ctx.maker!.havenod!); if (outputCounts.has(wallet)) outputCounts.set(wallet, outputCounts.get(wallet)! + 1); else outputCounts.set(wallet, 1); } if (ctx.awaitFundsToTakeOffer && ctx.takeOffer && !ctx.isOfferTaken) { const wallet = await getWallet(ctx.taker!.havenod!); if (outputCounts.has(wallet)) outputCounts.set(wallet, outputCounts.get(wallet)! + 1); else outputCounts.set(wallet, 1); } } const fundWalletPromises: Promise[] = []; for (const wallet of outputCounts.keys()) { if (outputCounts.get(wallet)! > 0) { fundWalletPromises.push(fundOutputs([wallet], tradeAmount! * 2n, outputCounts.get(wallet))); } } await Promise.all(fundWalletPromises); // execute trades in thread pool unless serial if (executionCtx.concurrentTrades) { const tradePromises: Promise[] = []; const pool = new moneroTs.ThreadPool(executionCtx.maxConcurrency!); for (let i = 0; i < ctxs.length; i++) ctxs[i] = TradeContext.init(Object.assign(ctxs[i], {concurrentTrades: executionCtx!.concurrentTrades})); // inititalize full trade contexts to avoid copy for (const ctx of ctxs) tradePromises.push(pool.submit(() => executeTrade(ctx))); try { offerIds = await Promise.all(tradePromises); } catch (e2) { if (executionCtx.stopOnFailure) for (const ctx of ctxs) ctx.isStopped = true; // stop trades on failure try { await Promise.allSettled(tradePromises); // wait for other trades to complete } catch (e3: any) { HavenoUtils.log(0, "Error awaiting other trades to stop after error: " + e3.message); HavenoUtils.log(0, e3.stack); } throw e2; } } else { for (const ctx of ctxs) { offerIds.push(await executeTrade(Object.assign(ctx, {concurrentTrades: executionCtx.concurrentTrades}))); } } } catch (e) { err = e; } // stop mining if started, throw error or return offer ids if (miningStarted) await stopMining(); if (err) throw err; return offerIds; } // TODO (woodser): test grpc notifications async function executeTrade(ctxP: Partial): Promise { let ctx = TradeContext.init(ctxP); try { // fund maker and taker if (ctx.isStopped) return ctx.offerId!; const makingOffer = ctx.makeOffer && !ctx.offerId; const clientsToFund: HavenoClient[] = []; if (!ctx.concurrentTrades) { // already funded if (ctx.awaitFundsToMakeOffer && makingOffer && !ctx.offerId) clientsToFund.push(ctx.maker.havenod!); if (ctx.awaitFundsToTakeOffer && ctx.takeOffer && !ctx.isOfferTaken) clientsToFund.push(ctx.taker.havenod!); await waitForAvailableBalance(ctx.offerAmount! * 2n, ...clientsToFund); } // make offer if configured if (ctx.isStopped) return ctx.offerId!; if (makingOffer) { ctx.offer = await makeOffer(ctx); expect(ctx.offer.getState()).toEqual(ctx.reserveExactAmount ? "PENDING" : "AVAILABLE"); ctx.offerId = ctx.offer.getId(); await wait(ctx.maxTimePeerNoticeMs); } else { ctx.offer = getOffer(await ctx.maker.havenod!.getMyOffers(ctx.assetCode!, ctx.direction), ctx.offerId!); if (!ctx.offer) { try { const trade = await ctx.maker.havenod!.getTrade(ctx.offerId!); ctx.offer = trade.getOffer(); } catch (err) { /* ignore */ } } } // TODO (woodser): test error message taking offer before posted // wait for split output tx to unlock if (ctx.isStopped) return ctx.offerId!; if (ctx.reserveExactAmount) { const splitOutputTxId = ctx.offer?.getSplitOutputTxHash(); HavenoUtils.log(1, "Waiting for split output tx " + splitOutputTxId + " to unlock"); if (splitOutputTxId) { await mineToUnlock(splitOutputTxId); await wait(TestConfig.trade.walletSyncPeriodMs + TestConfig.trade.maxTimePeerNoticeMs); } } // take offer or get existing trade if (ctx.isStopped) return ctx.offerId!; let trade: TradeInfo|undefined = undefined; if (ctx.isOfferTaken) trade = await ctx.taker.havenod!.getTrade(ctx.offerId!); else { if (!ctx.takeOffer) return ctx.offerId!; trade = await takeOffer(ctx); ctx.isOfferTaken = true; } // test trader chat if (ctx.isStopped) return ctx.offerId!; if (ctx.testTraderChat) await testTradeChat(ctx); // get expected payment account payloads if (ctx.isStopped) return ctx.offerId!; let expectedBuyerPaymentAccountPayload = (await ctx.getBuyer().havenod?.getPaymentAccount(ctx.maker.havenod == ctx.getBuyer().havenod ? ctx.makerPaymentAccountId! : ctx.takerPaymentAccountId!))?.getPaymentAccountPayload(); let expectedSellerPaymentAccountPayload = (await ctx.getSeller().havenod?.getPaymentAccount(ctx.maker.havenod == ctx.getBuyer().havenod ? ctx.takerPaymentAccountId! : ctx.makerPaymentAccountId!))?.getPaymentAccountPayload(); // seller does not have buyer's payment account payload until payment sent if (ctx.isStopped) return ctx.offerId!; let fetchedTrade = await ctx.getSeller().havenod!.getTrade(ctx.offerId!); let contract = fetchedTrade.getContract()!; let buyerPaymentAccountPayload = contract.getIsBuyerMakerAndSellerTaker() ? contract.getMakerPaymentAccountPayload() : contract.getTakerPaymentAccountPayload(); if (ctx.isPaymentSent) expect(buyerPaymentAccountPayload).toEqual(expectedBuyerPaymentAccountPayload); else expect(buyerPaymentAccountPayload).toBeUndefined(); // record context before payout if (ctx.isStopped) return ctx.offerId!; if (!ctx.isCompleted) { if (ctx.maker.havenod) ctx.maker.balancesBeforePayout = await ctx.maker.havenod!.getBalances(); if (ctx.taker.havenod) ctx.taker.balancesBeforePayout = await ctx.taker.havenod!.getBalances(); } // shut down buyer and seller if configured if (ctx.isStopped) return ctx.offerId!; ctx.usedPorts = [getPort(ctx.getBuyer().havenod!.getUrl()), getPort(ctx.getSeller().havenod!.getUrl())]; const promises: Promise[] = []; ctx.buyerAppName = ctx.getBuyer().havenod!.getAppName(); if (ctx.buyerOfflineAfterTake) { HavenoUtils.log(0, "Buyer going offline"); promises.push(releaseHavenoProcess(ctx.getBuyer().havenod!)); if (ctx.isBuyerMaker()) ctx.maker.havenod = undefined; else ctx.taker.havenod = undefined; } ctx.sellerAppName = ctx.getSeller().havenod!.getAppName(); if (ctx.sellerOfflineAfterTake) { HavenoUtils.log(0, "Seller going offline"); promises.push(releaseHavenoProcess(ctx.getSeller().havenod!)); if (ctx.isBuyerMaker()) ctx.taker.havenod = undefined; else ctx.maker.havenod = undefined; } await Promise.all(promises); // wait for deposit txs to unlock if (ctx.isStopped) return ctx.offerId!; await waitForUnlockedTxs(trade.getMakerDepositTxId(), trade.getTakerDepositTxId()); // buyer comes online if offline and used if (ctx.isStopped) return ctx.offerId!; if (ctx.buyerOfflineAfterTake && ((ctx.buyerSendsPayment && !ctx.isPaymentSent && ctx.sellerDisputeContext !== DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK) || (ctx.buyerDisputeContext === DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK && !ctx.buyerOpenedDispute))) { HavenoUtils.log(0, "Buyer coming online"); const buyer = await initHaveno({appName: ctx.buyerAppName, excludePorts: ctx.usedPorts}); // change buyer's node address if (ctx.isBuyerMaker()) ctx.maker.havenod = buyer; else ctx.taker.havenod = buyer; ctx.usedPorts.push(getPort(buyer.getUrl())); } // wait for traders to observe if (ctx.isStopped) return ctx.offerId!; await wait(TestConfig.maxWalletStartupMs + ctx.walletSyncPeriodMs * 2); // test buyer trade state if online if (ctx.isStopped) return ctx.offerId!; const expectedState = ctx.isPaymentSent ? "PAYMENT_SENT" : "DEPOSITS_UNLOCKED" // TODO: test COMPLETED, PAYMENT_RECEIVED states? if (ctx.getBuyer().havenod) { expect((await ctx.getBuyer().havenod!.getTrade(ctx.offer!.getId())).getPhase()).toEqual(expectedState); fetchedTrade = await ctx.getBuyer().havenod!.getTrade(ctx.offerId!); expect(fetchedTrade.getIsDepositsUnlocked()).toBe(true); expect(fetchedTrade.getPhase()).toEqual(expectedState); } // test seller trade state if online if (ctx.isStopped) return ctx.offerId!; if (ctx.getSeller().havenod) { fetchedTrade = await ctx.getSeller().havenod!.getTrade(trade.getTradeId()); expect(fetchedTrade.getIsDepositsUnlocked()).toBe(true); expect(fetchedTrade.getPhase()).toEqual(expectedState); } // buyer has seller's payment account payload after first confirmation if (ctx.isStopped) return ctx.offerId!; let sellerPaymentAccountPayload: PaymentAccountPayload | undefined; let form; let expectedForm; if (ctx.getBuyer().havenod) { fetchedTrade = await ctx.getBuyer().havenod!.getTrade(ctx.offerId!); contract = fetchedTrade.getContract()!; sellerPaymentAccountPayload = contract.getIsBuyerMakerAndSellerTaker() ? contract.getTakerPaymentAccountPayload() : contract.getMakerPaymentAccountPayload(); expect(sellerPaymentAccountPayload).toEqual(expectedSellerPaymentAccountPayload); form = await ctx.getBuyer().havenod!.getPaymentAccountPayloadForm(sellerPaymentAccountPayload!); expectedForm = await ctx.getBuyer().havenod!.getPaymentAccountPayloadForm(expectedSellerPaymentAccountPayload!); expect(HavenoUtils.formToString(form)).toEqual(HavenoUtils.formToString(expectedForm)); } // buyer notified to send payment TODO // open dispute(s) if configured if (ctx.isStopped) return ctx.offerId!; if (ctx.buyerDisputeContext === DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK && !ctx.buyerOpenedDispute) { await ctx.getBuyer().havenod!.openDispute(ctx.offerId!); ctx.buyerOpenedDispute = true; ctx.disputeOpener = SaleRole.BUYER; } if (ctx.isStopped) return ctx.offerId!; if (ctx.sellerDisputeContext === DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK && !ctx.sellerOpenedDispute) { await ctx.getSeller().havenod!.openDispute(ctx.offerId!); ctx.sellerOpenedDispute = true; if (!ctx.disputeOpener) ctx.disputeOpener = SaleRole.SELLER; } // handle opened dispute if (ctx.isStopped) return ctx.offerId!; if (ctx.disputeOpener) { // test open dispute await testOpenDispute(ctx); // resolve dispute if configured if (ctx.resolveDispute) await resolveDispute(ctx); // return offer id return ctx.offerId!; } // buyer confirms payment is sent if (ctx.isStopped) return ctx.offerId!; if (!ctx.buyerSendsPayment) return ctx.offer!.getId(); else if (!ctx.isPaymentSent) { HavenoUtils.log(1, "Buyer confirming payment sent"); await ctx.getBuyer().havenod!.confirmPaymentSent(trade.getTradeId()); ctx.isPaymentSent = true; fetchedTrade = await ctx.getBuyer().havenod!.getTrade(trade.getTradeId()); expect(fetchedTrade.getPhase()).toEqual("PAYMENT_SENT"); } // buyer goes offline if configured if (ctx.isStopped) return ctx.offerId!; if (ctx.buyerOfflineAfterPaymentSent) { HavenoUtils.log(0, "Buyer going offline"); await releaseHavenoProcess(ctx.getBuyer().havenod!); if (ctx.isBuyerMaker()) ctx.maker.havenod = undefined; else ctx.taker.havenod = undefined; } // seller comes online if offline if (ctx.isStopped) return ctx.offerId!; if (!ctx.getSeller().havenod) { HavenoUtils.log(0, "Seller coming online"); const seller = await initHaveno({appName: ctx.sellerAppName, excludePorts: ctx.usedPorts}); if (ctx.isBuyerMaker()) ctx.taker.havenod = seller; else ctx.maker.havenod = seller; ctx.usedPorts.push(getPort(ctx.getSeller().havenod!.getUrl())) } // seller notified payment is sent if (ctx.isStopped) return ctx.offerId!; await wait(ctx.maxTimePeerNoticeMs + TestConfig.maxWalletStartupMs); // TODO: test notification if (ctx.sellerOfflineAfterTake) await wait(ctx.walletSyncPeriodMs); // wait to process mailbox messages fetchedTrade = await ctx.getSeller().havenod!.getTrade(trade.getTradeId()); expect(fetchedTrade.getPhase()).toEqual("PAYMENT_SENT"); expect(fetchedTrade.getPayoutState()).toEqual("PAYOUT_UNPUBLISHED"); // seller has buyer's payment account payload after payment sent if (ctx.isStopped) return ctx.offerId!; fetchedTrade = await ctx.getSeller().havenod!.getTrade(ctx.offerId!); contract = fetchedTrade.getContract()!; buyerPaymentAccountPayload = contract.getIsBuyerMakerAndSellerTaker() ? contract.getMakerPaymentAccountPayload() : contract.getTakerPaymentAccountPayload(); expect(buyerPaymentAccountPayload).toEqual(expectedBuyerPaymentAccountPayload); form = await ctx.getSeller().havenod!.getPaymentAccountPayloadForm(sellerPaymentAccountPayload!); expectedForm = await ctx.getSeller().havenod!.getPaymentAccountPayloadForm(expectedSellerPaymentAccountPayload!); expect(HavenoUtils.formToString(form)).toEqual(HavenoUtils.formToString(expectedForm)); // open dispute(s) if configured if (ctx.isStopped) return ctx.offerId!; if (ctx.buyerDisputeContext === DisputeContext.OPEN_AFTER_PAYMENT_SENT && !ctx.buyerOpenedDispute) { await ctx.getBuyer().havenod!.openDispute(ctx.offerId!); ctx.buyerOpenedDispute = true; if (!ctx.disputeOpener) ctx.disputeOpener = SaleRole.BUYER; } if (ctx.isStopped) return ctx.offerId!; if (ctx.sellerDisputeContext === DisputeContext.OPEN_AFTER_PAYMENT_SENT && !ctx.sellerOpenedDispute) { await ctx.getSeller().havenod!.openDispute(ctx.offerId!); ctx.sellerOpenedDispute = true; if (!ctx.disputeOpener) ctx.disputeOpener = SaleRole.SELLER; } if (ctx.isStopped) return ctx.offerId!; if (ctx.disputeOpener) await testOpenDispute(ctx); // if dispute opened, resolve dispute if configured and return if (ctx.isStopped) return ctx.offerId!; if (ctx.disputeOpener) { if (ctx.resolveDispute) await resolveDispute(ctx); return ctx.offerId!; } // seller confirms payment is received if (ctx.isStopped) return ctx.offerId!; if (!ctx.sellerReceivesPayment) return ctx.offer!.getId(); else if (!ctx.isPaymentReceived) { HavenoUtils.log(1, "Seller confirming payment received"); await ctx.getSeller().havenod!.confirmPaymentReceived(trade.getTradeId()); ctx.isPaymentReceived = true; fetchedTrade = await ctx.getSeller().havenod!.getTrade(trade.getTradeId()); expect(fetchedTrade.getPhase()).toEqual("PAYMENT_RECEIVED"); await wait(ctx.walletSyncPeriodMs * 2); // buyer or arbitrator will sign and publish payout tx await testTradeState(await ctx.getSeller().havenod!.getTrade(trade.getTradeId()), {phase: "PAYMENT_RECEIVED", payoutState: ["PAYOUT_PUBLISHED", "PAYOUT_CONFIRMED", "PAYOUT_UNLOCKED"], isCompleted: false, isPayoutPublished: true}); } // payout tx is published by buyer (priority) or arbitrator if (ctx.isStopped) return ctx.offerId!; await wait(ctx.walletSyncPeriodMs); await testTradeState(await ctx.getSeller().havenod!.getTrade(trade.getTradeId()), {phase: "PAYMENT_RECEIVED", payoutState: ["PAYOUT_PUBLISHED", "PAYOUT_CONFIRMED", "PAYOUT_UNLOCKED"], isCompleted: false, isPayoutPublished: true}); await testTradeState(await ctx.arbitrator.havenod!.getTrade(trade.getTradeId()), {phase: "PAYMENT_RECEIVED", payoutState: ["PAYOUT_PUBLISHED", "PAYOUT_CONFIRMED", "PAYOUT_UNLOCKED"], isCompleted: true, isPayoutPublished: true}); // arbitrator trade auto completes // buyer comes online if offline if (ctx.isStopped) return ctx.offerId!; if (ctx.buyerOfflineAfterPaymentSent) { HavenoUtils.log(0, "Buyer coming online"); const buyer = await initHaveno({appName: ctx.buyerAppName, excludePorts: ctx.usedPorts}); if (ctx.isBuyerMaker()) ctx.maker.havenod = buyer; else ctx.taker.havenod = buyer; ctx.usedPorts.push(getPort(buyer.getUrl())); HavenoUtils.log(1, "Done starting buyer"); await wait(TestConfig.maxWalletStartupMs + ctx.walletSyncPeriodMs); } if (ctx.isStopped) return ctx.offerId!; await testTradeState(await ctx.getBuyer().havenod!.getTrade(trade.getTradeId()), {phase: "PAYMENT_RECEIVED", payoutState: ["PAYOUT_PUBLISHED", "PAYOUT_CONFIRMED", "PAYOUT_UNLOCKED"], isCompleted: false, isPayoutPublished: true}); // test trade completion if (ctx.isStopped) return ctx.offerId!; await ctx.getBuyer().havenod!.completeTrade(trade.getTradeId()); await testTradeState(await ctx.getBuyer().havenod!.getTrade(trade.getTradeId()), {phase: "PAYMENT_RECEIVED", payoutState: ["PAYOUT_PUBLISHED", "PAYOUT_CONFIRMED", "PAYOUT_UNLOCKED"], isCompleted: true, isPayoutPublished: true}); await ctx.getSeller().havenod!.completeTrade(trade.getTradeId()); await testTradeState(await ctx.getSeller().havenod!.getTrade(trade.getTradeId()), {phase: "PAYMENT_RECEIVED", payoutState: ["PAYOUT_PUBLISHED", "PAYOUT_CONFIRMED", "PAYOUT_UNLOCKED"], isCompleted: true, isPayoutPublished: true}); // record balances on completion if (ctx.isStopped) return ctx.offerId!; if (!ctx.maker.balancesAfterPayout) { ctx.maker.balancesAfterPayout = await ctx.maker.havenod?.getBalances(); ctx.taker.balancesAfterPayout = await ctx.taker.havenod?.getBalances(); // record payout tx id ctx.payoutTxId = (await ctx.getSeller().havenod!.getTrade(ctx.offerId!)).getPayoutTxId(); if (!ctx.payoutTxId) ctx.payoutTxId = (await ctx.arbitrator.havenod!.getTrade(ctx.offerId!)).getPayoutTxId(); // TODO: arbitrator will sign and publish payout tx id if buyer is offline; detect payout tx id on 0 conf if (!ctx.payoutTxId) ctx.payoutTxId = (await ctx.getBuyer().havenod!.getTrade(ctx.offerId!)).getPayoutTxId(); // TODO: arbitrator does not have payout tx id until first confirmation because they defer publishing } // test balances after payout tx unless other trades can interfere if (ctx.isStopped) return ctx.offerId!; if (!ctx.concurrentTrades) await testAmountsAfterComplete(ctx); // test payout unlock if (ctx.isStopped) return ctx.offerId!; await testTradePayoutUnlock(ctx); if (ctx.offer!.getId() !== ctx.offerId) throw new Error("Expected offer ids to match"); return ctx.offer!.getId(); } catch (err: any) { HavenoUtils.log(0, "Error executing trade " + ctx!.offerId + (ctx!.index === undefined ? "" : " at index " + ctx!.index) + ": " + err.message); HavenoUtils.log(0, await ctx.toSummary()); throw err; } } async function testTradePayoutUnlock(ctxP: Partial) { let ctx = TradeContext.init(ctxP); // test after payout confirmed if (!ctx.testPayoutConfirmed) return; const height = await monerod.getHeight(); const payoutTxId = (await ctx.arbitrator.havenod!.getTrade(ctx.offerId!)).getPayoutTxId(); let trade = await ctx.arbitrator.havenod!.getTrade(ctx.offerId!); if (trade.getPayoutState() !== "PAYOUT_CONFIRMED") await mineToHeight(height + 1); await wait(TestConfig.maxWalletStartupMs + ctx.walletSyncPeriodMs * 2); const disputeState = ctx.isPaymentReceived ? "NO_DISPUTE" : "DISPUTE_CLOSED"; if (ctx.getBuyer().havenod) await testTradeState(await ctx.getBuyer().havenod!.getTrade(ctx.offerId!), {phase: ctx.getPhase(), disputeState: disputeState, payoutState: ["PAYOUT_CONFIRMED", "PAYOUT_UNLOCKED"]}); if (ctx.getSeller().havenod) await testTradeState(await ctx.getSeller().havenod!.getTrade(ctx.offerId!), {phase: ctx.getPhase(), disputeState: disputeState, payoutState: ["PAYOUT_CONFIRMED", "PAYOUT_UNLOCKED"]}); await testTradeState(await ctx.arbitrator.havenod!.getTrade(ctx.offerId!), {phase: ctx.getPhase(), payoutState: ["PAYOUT_CONFIRMED", "PAYOUT_UNLOCKED"]}); let payoutTx = ctx.getBuyer().havenod ? await ctx.getBuyer().havenod?.getXmrTx(payoutTxId) : await ctx.getSeller().havenod?.getXmrTx(payoutTxId); expect(payoutTx?.getIsConfirmed()); // test after payout unlocked if (!ctx.testPayoutUnlocked) return; trade = await ctx.arbitrator.havenod!.getTrade(ctx.offerId!); if (trade.getPayoutState() !== "PAYOUT_UNLOCKED") await mineToHeight(height + 10); await wait(TestConfig.maxWalletStartupMs + ctx.walletSyncPeriodMs * 2); if (await ctx.getBuyer().havenod) await testTradeState(await ctx.getBuyer().havenod!.getTrade(ctx.offerId!), {phase: ctx.getPhase(), disputeState: disputeState, payoutState: ["PAYOUT_UNLOCKED"]}); if (await ctx.getSeller().havenod) await testTradeState(await ctx.getSeller().havenod!.getTrade(ctx.offerId!), {phase: ctx.getPhase(), disputeState: disputeState, payoutState: ["PAYOUT_UNLOCKED"]}); await testTradeState(await ctx.arbitrator.havenod!.getTrade(ctx.offerId!), {phase: ctx.getPhase(), disputeState: disputeState, payoutState: ["PAYOUT_UNLOCKED"]}); payoutTx = ctx.getBuyer().havenod ? await ctx.getBuyer().havenod?.getXmrTx(payoutTxId) : await ctx.getSeller().havenod?.getXmrTx(payoutTxId); expect(!payoutTx?.getIsLocked()); } async function testTradeState(trade: TradeInfo, ctx: Partial) { assert.equal(trade.getPhase(), ctx.phase, "expected trade phase to be " + ctx.phase + " but was " + trade.getPhase() + " for trade " + trade.getTradeId()); assert(moneroTs.GenUtils.arrayContains(ctx.payoutState, trade.getPayoutState()), "expected one of payout state " + ctx.payoutState + " but was " + trade.getPayoutState() + " for trade " + trade.getTradeId()); if (ctx.disputeState) expect(trade.getDisputeState()).toEqual(ctx.disputeState); if (ctx.isCompleted !== undefined) expect(trade.getIsCompleted()).toEqual(ctx.isCompleted); if (ctx.isPayoutPublished !== undefined) expect(trade.getIsPayoutPublished()).toEqual(ctx.isPayoutPublished); if (ctx.isPayoutConfirmed !== undefined) expect(trade.getIsPayoutConfirmed()).toEqual(ctx.isPayoutConfirmed); if (ctx.isPayoutConfirmed) expect(trade.getIsPayoutPublished()).toEqual(true); if (ctx.isPayoutUnlocked !== undefined) expect(trade.getIsPayoutUnlocked()).toEqual(ctx.isPayoutUnlocked); if (ctx.isPayoutUnlocked) { expect(trade.getIsPayoutConfirmed()).toEqual(true); expect(trade.getIsPayoutPublished()).toEqual(true); } } async function makeOffer(ctxP?: Partial): Promise { let ctx = TradeContext.init(ctxP); // wait for unlocked balance if (!ctx.concurrentTrades && ctx.awaitFundsToMakeOffer) await waitForAvailableBalance(ctx.offerAmount! * 2n, ctx.maker.havenod); // create payment account if not given // TODO: re-use existing payment account if (!ctx.makerPaymentAccountId) ctx.makerPaymentAccountId = (await createPaymentAccount(ctx.maker.havenod!, ctx.assetCode!)).getId(); // get unlocked balance before reserving offer let unlockedBalanceBefore = BigInt((await ctx.maker.havenod!.getBalances()).getAvailableBalance()); if (ctx.awaitFundsToMakeOffer && unlockedBalanceBefore === 0n) { HavenoUtils.log(0, "WARNING: unlocked balance before posting offer is 0, waiting..."); await wait(5000); unlockedBalanceBefore = BigInt((await ctx.maker.havenod!.getBalances()).getAvailableBalance()); if (unlockedBalanceBefore === 0n) throw new Error("Unlocked balance before posting offer was 0, even after waiting"); } // initialize balances before offer, once if (!ctx.maker.balancesBeforeOffer) { ctx.maker.balancesBeforeOffer = await ctx.maker.havenod?.getBalances(); ctx.taker.balancesBeforeOffer = await ctx.taker.havenod?.getBalances(); } // post offer const offer: OfferInfo = await ctx.maker.havenod!.postOffer( ctx.direction!, ctx.offerAmount!, ctx.assetCode!, ctx.makerPaymentAccountId!, ctx.securityDepositPct!, ctx.price, ctx.priceMargin, ctx.triggerPrice, ctx.offerMinAmount, ctx.reserveExactAmount); testOffer(offer, ctx); // offer is included in my offers only if (!getOffer(await ctx.maker.havenod!.getMyOffers(ctx.assetCode!, ctx.direction), offer.getId())) { console.warn("Offer is not included in my offers after posting, waiting up to 10 seconds"); await wait(10000); // TODO: remove this wait time if (!getOffer(await ctx.maker.havenod!.getMyOffers(ctx.assetCode!, ctx.direction), offer.getId())) throw new Error("Offer " + offer.getId() + " was not found in my offers"); } if (getOffer(await ctx.maker.havenod!.getOffers(ctx.assetCode!, ctx.direction), offer.getId())) throw new Error("My offer " + offer.getId() + " should not appear in available offers"); // collect context ctx.maker.splitOutputTxFee = BigInt(offer.getSplitOutputTxFee()); ctx.taker.splitOutputTxFee = 0n; // market-priced offer amounts are unadjusted, fixed-priced offer amounts are adjusted (e.g. cash at atm is $10 increments) // TODO: adjustments should be based on currency and payment method, not fixed-price if (!ctx.offerMinAmount) ctx.offerMinAmount = ctx.offerAmount; if (offer.getUseMarketBasedPrice()) { expect(BigInt(offer.getAmount())).toEqual(ctx.offerAmount!); expect(BigInt(offer.getMinAmount())).toEqual(ctx.offerMinAmount!); } else { expect(Math.abs(HavenoUtils.percentageDiff(ctx.offerAmount!, BigInt(offer.getAmount())))).toBeLessThan(TestConfig.maxAdjustmentPct); expect(Math.abs(HavenoUtils.percentageDiff(ctx.offerMinAmount!, BigInt(offer.getMinAmount())))).toBeLessThan(TestConfig.maxAdjustmentPct); } // unlocked balance has decreased let unlockedBalanceAfter = BigInt((await ctx.maker.havenod!.getBalances()).getAvailableBalance()); if (offer.getState() === "PENDING") { if (!ctx.reserveExactAmount && unlockedBalanceAfter !== unlockedBalanceBefore) throw new Error("Unlocked balance should not change for scheduled offer " + offer.getId()); } else if (offer.getState() === "AVAILABLE") { if (unlockedBalanceAfter === unlockedBalanceBefore) { console.warn("Unlocked balance did not change after posting offer, waiting a sync period"); await wait(ctx.walletSyncPeriodMs); unlockedBalanceAfter = BigInt((await ctx.maker.havenod!.getBalances()).getAvailableBalance()); if (unlockedBalanceAfter === unlockedBalanceBefore) throw new Error("Unlocked balance did not change after posting offer " + offer.getId() + ", before=" + unlockedBalanceBefore + ", after=" + unlockedBalanceAfter); } } else { throw new Error("Unexpected offer state after posting: " + offer.getState()); } return offer; } async function takeOffer(ctxP: Partial): Promise { let ctx = TradeContext.init(ctxP); // assign default config Object.assign(ctx, new TradeContext(TestConfig.trade), Object.assign({}, ctx)); // taker sees offer if (!ctx.offerId) throw new Error("Must provide offer id"); const takerOffer = getOffer(await ctx.taker.havenod!.getOffers(ctx.assetCode!, ctx.direction), ctx.offerId); if (!takerOffer) throw new Error("Offer " + ctx.offerId + " was not found in taker's offers"); expect(takerOffer.getState()).toEqual("UNKNOWN"); // TODO: offer state should be known // wait for unlocked balance if (ctx.awaitFundsToTakeOffer) await waitForAvailableBalance(ctx.offerAmount! * 2n, ctx.taker.havenod); // create payment account if not given // TODO: re-use existing payment account if (!ctx.takerPaymentAccountId) ctx.takerPaymentAccountId = (await createPaymentAccount(ctx.taker.havenod!, ctx.assetCode!)).getId(); // register to receive notifications const makerNotifications: NotificationMessage[] = []; const takerNotifications: NotificationMessage[] = []; await ctx.maker.havenod!.addNotificationListener(notification => { makerNotifications.push(notification); }); await ctx.taker.havenod!.addNotificationListener(notification => { takerNotifications.push(notification); }); // record balances before offer taken, once if (ctx.taker.balancesBeforeTake === undefined) { ctx.maker.balancesBeforeTake = await ctx.maker.havenod?.getBalances(); ctx.taker.balancesBeforeTake = await ctx.taker.havenod?.getBalances(); } // take offer const takerBalancesBefore: XmrBalanceInfo = await ctx.taker.havenod!.getBalances(); const startTime = Date.now(); HavenoUtils.log(1, "Taking offer " + ctx.offerId); const trade = await ctx.taker.havenod!.takeOffer(ctx.offerId, ctx.takerPaymentAccountId!, ctx.tradeAmount); HavenoUtils.log(1, "Done taking offer " + ctx.offerId + " in " + (Date.now() - startTime) + " ms"); // maker is notified that offer is taken await wait(ctx.maxTimePeerNoticeMs); const tradeNotifications = getNotifications(makerNotifications, NotificationMessage.NotificationType.TRADE_UPDATE, trade.getTradeId()); expect(tradeNotifications.length).toBe(1); assert(moneroTs.GenUtils.arrayContains(["DEPOSITS_PUBLISHED", "DEPOSITS_CONFIRMED", "DEPOSITS_UNLOCKED"], tradeNotifications[0].getTrade()!.getPhase()), "Unexpected trade phase: " + tradeNotifications[0].getTrade()!.getPhase()); expect(tradeNotifications[0].getTitle()).toEqual("Offer Taken"); expect(tradeNotifications[0].getMessage()).toEqual("Your offer " + ctx.offerId + " has been accepted"); // set context after offer taken, once if (ctx.getBuyer().balancesAfterTake === undefined) { // wait to observe deposit txs ctx.arbitrator.trade = await ctx.arbitrator.havenod!.getTrade(ctx.offerId!); ctx.maker.depositTx = await monerod.getTx(ctx.arbitrator.trade!.getMakerDepositTxId()); ctx.taker.depositTx = await monerod.getTx(ctx.arbitrator.trade!.getTakerDepositTxId()); if (!ctx.maker.depositTx || !ctx.taker.depositTx) { if (!ctx.maker.depositTx) HavenoUtils.log(0, "Maker deposit tx not found with id " + ctx.arbitrator.trade!.getMakerDepositTxId() + ", waiting..."); if (!ctx.taker.depositTx) HavenoUtils.log(0, "Taker deposit tx not found with id " + ctx.arbitrator.trade!.getTakerDepositTxId() + ", waiting..."); await wait(ctx.walletSyncPeriodMs); ctx.maker.depositTx = await monerod.getTx(ctx.arbitrator.trade!.getMakerDepositTxId()); ctx.taker.depositTx = await monerod.getTx(ctx.arbitrator.trade!.getTakerDepositTxId()); if (!ctx.maker.depositTx) throw new Error("Maker deposit tx not found with id " + ctx.arbitrator.trade!.getMakerDepositTxId()); if (!ctx.taker.depositTx) throw new Error("Taker deposit tx not found with id " + ctx.arbitrator.trade!.getTakerDepositTxId()); } // record context ctx.tradeAmount = BigInt(trade.getAmount()); // re-assign trade amount which could be adjusted ctx.maker.trade = await ctx.maker.havenod!.getTrade(ctx.offerId!); ctx.taker.trade = await ctx.taker.havenod!.getTrade(ctx.offerId!); ctx.maker.balancesAfterTake = await ctx.maker.havenod!.getBalances(); ctx.taker.balancesAfterTake = await ctx.taker.havenod!.getBalances(); ctx.maker.depositTxFee = BigInt(ctx.maker.depositTx!.getFee()); ctx.taker.depositTxFee = BigInt(ctx.taker.depositTx!.getFee()); ctx.maker.tradeFee = BigInt(trade.getMakerFee()); ctx.taker.tradeFee = BigInt(trade.getTakerFee()); ctx.getBuyer().securityDepositActual = BigInt(trade.getBuyerSecurityDeposit()!); ctx.getSeller().securityDepositActual = BigInt(trade.getSellerSecurityDeposit()!); } // test trade model await testTrade(trade, ctx); // test buyer and seller balances after offer taken if (!ctx.concurrentTrades) { ctx.arbitrator!.trade = await ctx.arbitrator.havenod!.getTrade(ctx.offerId!); // test buyer balances after offer taken const buyerBalanceDiff = BigInt(ctx.getBuyer().balancesAfterTake!.getBalance()) - BigInt(ctx.getBuyer().balancesBeforeTake!.getBalance()); const buyerBalanceDiffReservedTrade = BigInt(ctx.getBuyer().balancesAfterTake!.getReservedTradeBalance()) - BigInt(ctx.getBuyer().balancesBeforeTake!.getReservedTradeBalance()); const buyerBalanceDiffReservedOffer = BigInt(ctx.getBuyer().balancesAfterTake!.getReservedOfferBalance()) - BigInt(ctx.getBuyer().balancesBeforeTake!.getReservedOfferBalance()); expect(buyerBalanceDiffReservedTrade).toEqual(BigInt(trade.getBuyerSecurityDeposit()!)); expect(buyerBalanceDiff).toEqual(-1n * buyerBalanceDiffReservedOffer - buyerBalanceDiffReservedTrade - ctx.getBuyer().depositTxFee! - ctx.getBuyer().tradeFee!); // test seller balances after offer taken const sellerBalanceDiff = BigInt(ctx.getSeller().balancesAfterTake!.getBalance()) - BigInt(ctx.getSeller().balancesBeforeTake!.getBalance()); const sellerBalanceDiffReservedTrade = BigInt(ctx.getSeller().balancesAfterTake!.getReservedTradeBalance()) - BigInt(ctx.getSeller().balancesBeforeTake!.getReservedTradeBalance()); expect(sellerBalanceDiffReservedTrade).toEqual(BigInt(trade.getAmount()) + BigInt(trade.getSellerSecurityDeposit()!)); expect(sellerBalanceDiff).toEqual(0n - ctx.getSeller().depositTxFee! - ctx.getSeller().tradeFee! - ctx.getSeller().securityDepositActual! - ctx.tradeAmount!); // test maker balances after offer taken const makerBalanceDiffReservedOffer = BigInt(ctx.getMaker().balancesAfterTake!.getReservedOfferBalance()) - BigInt(ctx.getMaker().balancesBeforeTake!.getReservedOfferBalance()); expect(makerBalanceDiffReservedOffer).toBeLessThan(0n); // TODO: more precise? // test taker balances after offer taken const takerBalanceDiffReservedOffer = BigInt(ctx.getTaker().balancesAfterTake!.getReservedOfferBalance()) - BigInt(ctx.getTaker().balancesBeforeTake!.getReservedOfferBalance()); expect(takerBalanceDiffReservedOffer).toEqual(0n); } // test getting trade for all parties await testGetTrade(ctx); // market-priced offer amounts are unadjusted, fixed-priced offer amounts are adjusted (e.g. cash at atm is $10 increments) // TODO: adjustments are based on payment method, not fixed-price if (trade.getOffer()!.getUseMarketBasedPrice()) { assert.equal(ctx.tradeAmount, BigInt(trade.getAmount())); } else { expect(Math.abs(HavenoUtils.percentageDiff(ctx.tradeAmount!, BigInt(trade.getAmount())))).toBeLessThan(TestConfig.maxAdjustmentPct); } // maker is notified of balance change // taker is notified of balance change return trade; } async function testTrade(trade: TradeInfo, ctx: TradeContext, havenod?: HavenoClient): Promise { expect(BigInt(trade.getAmount())).toEqual(ctx!.tradeAmount); // test security deposit = max(.1, trade amount * security deposit pct) const expectedSecurityDeposit = HavenoUtils.max(HavenoUtils.xmrToAtomicUnits(.1), HavenoUtils.multiply(ctx.tradeAmount!, ctx.securityDepositPct!)); expect(BigInt(trade.getBuyerSecurityDeposit())).toEqual(expectedSecurityDeposit - ctx.getBuyer().depositTxFee!); expect(BigInt(trade.getSellerSecurityDeposit())).toEqual(expectedSecurityDeposit - ctx.getSeller().depositTxFee!); // test phase if (!ctx.isPaymentSent) { assert(moneroTs.GenUtils.arrayContains(["DEPOSITS_PUBLISHED", "DEPOSITS_CONFIRMED", "DEPOSITS_UNLOCKED"], trade.getPhase()), "Unexpected trade phase: " + trade.getPhase()); } // test role const role = trade.getRole(); assert(role.length > 0); // TODO: test role string based on context // TODO: test more fields } async function testGetTrade(ctx: TradeContext, havenod?: HavenoClient): Promise { if (havenod) { const trade = await havenod.getTrade(ctx.offerId!); await testTrade(trade, ctx); const trades = await havenod.getTrades(); const foundTrade = trades.find((trade) => trade.getTradeId() === ctx.offerId); assert(foundTrade); await testTrade(foundTrade, ctx, havenod); } else { await testGetTrade(ctx, ctx.maker.havenod); await testGetTrade(ctx, ctx.taker.havenod); await testGetTrade(ctx, ctx.arbitrator.havenod); } } async function testOpenDispute(ctxP: Partial) { let ctx = TradeContext.init(ctxP); // TODO: test open dispute when buyer or seller offline if (!ctx.getBuyer().havenod || !ctx.getSeller().havenod) { HavenoUtils.log(0, "WARNING: skipping test open dispute tests because a trader is offline"); // TODO: update tests for offline trader return; } // test dispute state const openerDispute = await ctx.getDisputeOpener()!.havenod!.getDispute(ctx.offerId!); expect(openerDispute.getTradeId()).toEqual(ctx.offerId); expect(openerDispute.getIsOpener()).toBe(true); expect(openerDispute.getDisputeOpenerIsBuyer()).toBe(ctx.getDisputeOpener()!.havenod === ctx.getBuyer().havenod); // get non-existing dispute should fail try { await ctx.getDisputeOpener()!.havenod!.getDispute("invalid"); throw new Error("get dispute with invalid id should fail"); } catch (err: any) { assert.equal(err.message, "dispute for trade id 'invalid' not found"); } // peer sees the dispute await wait(ctx.maxTimePeerNoticeMs + TestConfig.maxWalletStartupMs); const peerDispute = await ctx.getDisputePeer()!.havenod!.getDispute(ctx.offerId!); expect(peerDispute.getTradeId()).toEqual(ctx.offerId); expect(peerDispute.getIsOpener()).toBe(false || ctx.buyerDisputeContext === ctx.sellerDisputeContext); // TODO: both peers think they're the opener if disputes opened at same time since not waiting for ack // arbitrator sees both disputes const disputes = await ctx.arbitrator.havenod!.getDisputes(); expect(disputes.length).toBeGreaterThanOrEqual(2); const arbDisputePeer = disputes.find(d => d.getId() === peerDispute.getId()); assert(arbDisputePeer); const arbDisputeOpener = disputes.find(d => d.getId() === openerDispute.getId()); assert(arbDisputeOpener); // arbitrator has seller's payment account info let sellerPaymentAccountPayload = arbDisputeOpener.getContract()!.getIsBuyerMakerAndSellerTaker() ? arbDisputeOpener.getTakerPaymentAccountPayload() : arbDisputeOpener.getMakerPaymentAccountPayload(); let expectedSellerPaymentAccountPayload = (await ctx.getSeller().havenod?.getPaymentAccount(sellerPaymentAccountPayload?.getId()!))?.getPaymentAccountPayload(); expect(sellerPaymentAccountPayload).toEqual(expectedSellerPaymentAccountPayload); expect(await ctx.arbitrator.havenod?.getPaymentAccountPayloadForm(sellerPaymentAccountPayload!)).toEqual(await ctx.arbitrator.havenod?.getPaymentAccountPayloadForm(expectedSellerPaymentAccountPayload!)); sellerPaymentAccountPayload = arbDisputePeer.getContract()!.getIsBuyerMakerAndSellerTaker() ? arbDisputePeer.getTakerPaymentAccountPayload() : arbDisputeOpener.getMakerPaymentAccountPayload(); expect(sellerPaymentAccountPayload).toEqual(expectedSellerPaymentAccountPayload); expect(await ctx.arbitrator.havenod?.getPaymentAccountPayloadForm(sellerPaymentAccountPayload!)).toEqual(await ctx.arbitrator.havenod?.getPaymentAccountPayloadForm(expectedSellerPaymentAccountPayload!)); // arbitrator has buyer's payment account info unless opener is seller and payment not sent let buyerPaymentAccountPayload = arbDisputeOpener.getContract()!.getIsBuyerMakerAndSellerTaker() ? arbDisputeOpener.getMakerPaymentAccountPayload() : arbDisputeOpener.getTakerPaymentAccountPayload(); if (ctx.getDisputeOpener()!.havenod === ctx.getSeller().havenod && !ctx.isPaymentSent) expect(buyerPaymentAccountPayload).toBeUndefined(); else { let expectedBuyerPaymentAccountPayload = (await ctx.getBuyer().havenod?.getPaymentAccount(buyerPaymentAccountPayload?.getId()!))?.getPaymentAccountPayload(); expect(buyerPaymentAccountPayload).toEqual(expectedBuyerPaymentAccountPayload); expect(await ctx.arbitrator.havenod?.getPaymentAccountPayloadForm(buyerPaymentAccountPayload!)).toEqual(await ctx.arbitrator.havenod?.getPaymentAccountPayloadForm(expectedBuyerPaymentAccountPayload!)); } buyerPaymentAccountPayload = arbDisputePeer.getContract()!.getIsBuyerMakerAndSellerTaker() ? arbDisputePeer.getMakerPaymentAccountPayload() : arbDisputePeer.getTakerPaymentAccountPayload(); if (ctx.getDisputeOpener()!.havenod === ctx.getSeller().havenod && !ctx.isPaymentSent) expect(buyerPaymentAccountPayload).toBeUndefined(); else { let expectedBuyerPaymentAccountPayload = (await ctx.getBuyer().havenod?.getPaymentAccount(buyerPaymentAccountPayload?.getId()!))?.getPaymentAccountPayload(); expect(buyerPaymentAccountPayload).toEqual(expectedBuyerPaymentAccountPayload); expect(await ctx.arbitrator.havenod?.getPaymentAccountPayloadForm(buyerPaymentAccountPayload!)).toEqual(await ctx.arbitrator.havenod?.getPaymentAccountPayloadForm(expectedBuyerPaymentAccountPayload!)); } // register to receive notifications const disputeOpenerNotifications: NotificationMessage[] = []; const disputePeerNotifications: NotificationMessage[] = []; const arbitratorNotifications: NotificationMessage[] = []; await ctx.getDisputeOpener()!.havenod!.addNotificationListener(notification => { HavenoUtils.log(3, "Dispute opener received notification " + notification.getType() + " " + (notification.getChatMessage() ? notification.getChatMessage()?.getMessage() : "")); disputeOpenerNotifications.push(notification); }); await ctx.getDisputePeer()!.havenod!.addNotificationListener(notification => { HavenoUtils.log(3, "Dispute peer received notification " + notification.getType() + " " + (notification.getChatMessage() ? notification.getChatMessage()?.getMessage() : "")); disputePeerNotifications.push(notification); }); await arbitrator.addNotificationListener(notification => { HavenoUtils.log(3, "Arbitrator received notification " + notification.getType() + " " + (notification.getChatMessage() ? notification.getChatMessage()?.getMessage() : "")); arbitratorNotifications.push(notification); }); // test chat messages if (ctx.testChatMessages) { // arbitrator sends chat messages to traders HavenoUtils.log(1, "Arbitrator sending chat messages to traders. tradeId=" + ctx.offerId + ", disputeId=" + openerDispute.getId()); await ctx.arbitrator.havenod!.sendDisputeChatMessage(arbDisputeOpener!.getId(), "Arbitrator chat message to dispute opener", []); await ctx.arbitrator.havenod!.sendDisputeChatMessage(arbDisputePeer!.getId(), "Arbitrator chat message to dispute peer", []); // traders reply to arbitrator chat messages await wait(ctx.maxTimePeerNoticeMs); // wait for arbitrator's message to arrive const attachment = new Attachment(); const bytes = new Uint8Array(Buffer.from("Proof dispute opener was scammed", "utf8")); attachment.setBytes(bytes); attachment.setFileName("proof.txt"); const attachment2 = new Attachment(); const bytes2 = new Uint8Array(Buffer.from("picture bytes", "utf8")); attachment2.setBytes(bytes2); attachment2.setFileName("proof.png"); HavenoUtils.log(2, "Dispute opener sending chat message to arbitrator. tradeId=" + ctx.offerId + ", disputeId=" + openerDispute.getId()); await ctx.getDisputeOpener()!.havenod!.sendDisputeChatMessage(openerDispute.getId(), "Dispute opener chat message", [attachment, attachment2]); await wait(ctx.maxTimePeerNoticeMs); // wait for user2's message to arrive HavenoUtils.log(2, "Dispute peer sending chat message to arbitrator. tradeId=" + ctx.offerId + ", disputeId=" + peerDispute.getId()); await ctx.getDisputePeer()!.havenod!.sendDisputeChatMessage(peerDispute.getId(), "Dispute peer chat message", []); // test trader chat messages await wait(ctx.maxTimePeerNoticeMs); let dispute = await ctx.getDisputeOpener()!.havenod!.getDispute(ctx.offerId!); let messages = dispute.getChatMessageList(); expect(messages.length).toBeGreaterThanOrEqual(3); // last messages are chat, first messages are system message and possibly DisputeOpenedMessage acks try { expect(messages[messages.length - 2].getMessage()).toEqual("Arbitrator chat message to dispute opener"); expect(messages[messages.length - 1].getMessage()).toEqual("Dispute opener chat message"); } catch (err) { console.log("Dispute peer chat messages length: " + messages.length); console.log("Dispute peer chat messages : " + JSON.stringify(messages)); throw err; } let attachments = messages[messages.length - 1].getAttachmentsList(); expect(attachments.length).toEqual(2); expect(attachments[0].getFileName()).toEqual("proof.txt"); expect(attachments[0].getBytes()).toEqual(bytes); expect(attachments[1].getFileName()).toEqual("proof.png"); expect(attachments[1].getBytes()).toEqual(bytes2); dispute = await ctx.getDisputePeer()!.havenod!.getDispute(ctx.offerId!); messages = dispute.getChatMessageList(); expect(messages.length).toBeGreaterThanOrEqual(3); try { expect(messages[messages.length - 2].getMessage()).toEqual("Arbitrator chat message to dispute peer"); expect(messages[messages.length - 1].getMessage()).toEqual("Dispute peer chat message"); } catch (err) { console.log("Dispute peer chat messages length: " + messages.length); console.log("Dispute peer chat messages : " + JSON.stringify(messages)); throw err; } // test notifications of chat messages let chatNotifications = getNotifications(disputeOpenerNotifications, NotificationMessage.NotificationType.CHAT_MESSAGE, ctx.offerId); expect(chatNotifications.length).toBe(1); expect(chatNotifications[0].getChatMessage()?.getMessage()).toEqual("Arbitrator chat message to dispute opener"); chatNotifications = getNotifications(disputePeerNotifications, NotificationMessage.NotificationType.CHAT_MESSAGE, ctx.offerId); expect(chatNotifications.length).toBe(1); expect(chatNotifications[0].getChatMessage()?.getMessage()).toEqual("Arbitrator chat message to dispute peer"); // arbitrator has 2 chat messages, one with attachments chatNotifications = getNotifications(arbitratorNotifications, NotificationMessage.NotificationType.CHAT_MESSAGE, ctx.offerId); expect(chatNotifications.length).toBe(2); expect(chatNotifications[0].getChatMessage()?.getMessage()).toEqual("Dispute opener chat message"); assert(chatNotifications[0].getChatMessage()?.getAttachmentsList()); attachments = chatNotifications[0].getChatMessage()?.getAttachmentsList()!; expect(attachments[0].getFileName()).toEqual("proof.txt"); expect(attachments[0].getBytes()).toEqual(bytes); expect(attachments[1].getFileName()).toEqual("proof.png"); expect(attachments[1].getBytes()).toEqual(bytes2); expect(chatNotifications[1].getChatMessage()?.getMessage()).toEqual("Dispute peer chat message"); } } async function resolveDispute(ctxP: Partial) { let ctx = TradeContext.init(ctxP); // stop buyer or seller depending on configuration const promises: Promise[] = []; if (ctx.getBuyer().havenod && ctx.buyerOfflineAfterDisputeOpened) { promises.push(releaseHavenoProcess(ctx.getBuyer().havenod!)); // stop buyer if (ctx.isBuyerMaker()) ctx.maker.havenod = undefined; else ctx.taker.havenod = undefined; } if (ctx.getSeller().havenod && ctx.sellerOfflineAfterDisputeOpened) { promises.push(releaseHavenoProcess(ctx.getSeller().havenod!)); // stop seller if (ctx.isBuyerMaker()) ctx.taker.havenod = undefined; else ctx.maker.havenod = undefined; } await Promise.all(promises); // award too much to winner (majority receiver) let trade = await arbitrator.getTrade(ctx.offerId!) const tradeAmount: bigint = BigInt(trade!.getAmount()); let customWinnerAmount = tradeAmount + BigInt(trade.getBuyerSecurityDeposit()) + BigInt(trade.getSellerSecurityDeposit() + 1n); // mining fee is subtracted from security deposits try { await arbitrator.resolveDispute(ctx.offerId!, ctx.disputeWinner!, ctx.disputeReason!, "Winner gets too much", customWinnerAmount); throw new Error("Should have failed resolving dispute with too much winner payout"); } catch (err: any) { assert.equal(err.message, "Winner payout is more than the trade wallet's balance"); } // award too little to loser (minority receiver) let makerDepositTx = await monerod.getTx(trade.getMakerDepositTxId()); let takerDepositTx = await monerod.getTx(trade.getTakerDepositTxId()); customWinnerAmount = tradeAmount + BigInt(trade.getBuyerSecurityDeposit()) + BigInt(trade.getSellerSecurityDeposit()) - 10000n; try { await arbitrator.resolveDispute(ctx.offerId!, ctx.disputeWinner!, ctx.disputeReason!, "Loser gets too little", customWinnerAmount); throw new Error("Should have failed resolving dispute with insufficient loser payout"); } catch (err: any) { assert.equal(err.message, "Loser payout is too small to cover the mining fee"); } // resolve dispute according to configuration HavenoUtils.log(1, "Resolving dispute for trade " + ctx.offerId); const startTime = Date.now(); await arbitrator.resolveDispute(ctx.offerId!, ctx.disputeWinner!, ctx.disputeReason!, ctx.disputeSummary!, ctx.disputeWinnerAmount); HavenoUtils.log(1, "Done resolving dispute (" + (Date.now() - startTime) + ")"); // start buyer or seller depending on configuration if (!ctx.getBuyer().havenod && ctx.buyerOfflineAfterDisputeOpened === false) { // TODO: wait additional time before starting to avoid 503? need to wait after shut down? const buyer = await initHaveno({appName: ctx.buyerAppName, excludePorts: ctx.usedPorts}); // start buyer if (ctx.isBuyerMaker()) ctx.maker.havenod = buyer; else ctx.taker.havenod = buyer; ctx.usedPorts!.push(getPort(buyer.getUrl())); } if (!ctx.getSeller().havenod && ctx.sellerOfflineAfterDisputeOpened === false) { const seller = await initHaveno({appName: ctx.sellerAppName, excludePorts: ctx.usedPorts}); // start seller if (ctx.isBuyerMaker()) ctx.taker.havenod = seller; else ctx.maker.havenod = seller; ctx.usedPorts!.push(getPort(ctx.getSeller().havenod!.getUrl())) } // test resolved dispute await wait(TestConfig.maxWalletStartupMs + ctx.walletSyncPeriodMs * 2); if (ctx.getDisputeOpener()!.havenod) { const dispute = await ctx.getDisputeOpener()!.havenod!.getDispute(ctx.offerId!); assert(dispute.getIsClosed(), "Dispute is not closed for opener, trade " + ctx.offerId); } if (ctx.getDisputePeer()!.havenod) { const dispute = await ctx.getDisputePeer()!.havenod!.getDispute(ctx.offerId!); assert(dispute.getIsClosed(), "Dispute is not closed for opener's peer, trade " + ctx.offerId); } // test trade state if (ctx.getBuyer().havenod) await testTradeState(await ctx.getBuyer().havenod!.getTrade(ctx.offerId!), {phase: ctx.getPhase(), payoutState: ["PAYOUT_PUBLISHED", "PAYOUT_CONFIRMED", "PAYOUT_UNLOCKED"], disputeState: "DISPUTE_CLOSED", isCompleted: true, isPayoutPublished: true}); if (ctx.getSeller().havenod) await testTradeState(await ctx.getSeller().havenod!.getTrade(ctx.offerId!), {phase: ctx.getPhase(), payoutState: ["PAYOUT_PUBLISHED", "PAYOUT_CONFIRMED", "PAYOUT_UNLOCKED"], disputeState: "DISPUTE_CLOSED", isCompleted: true, isPayoutPublished: true}); await testTradeState(await ctx.arbitrator.havenod!.getTrade(ctx.offerId!), {phase: ctx.getPhase(), payoutState: ["PAYOUT_PUBLISHED", "PAYOUT_CONFIRMED", "PAYOUT_UNLOCKED"], disputeState: "DISPUTE_CLOSED", isCompleted: true, isPayoutPublished: true}); // signing peer has payout tx id on 0 conf (peers must wait for confirmation to see outgoing tx) const winnerd = ctx.disputeWinner === DisputeResult.Winner.BUYER ? ctx.getBuyer().havenod : ctx.getSeller().havenod; const loserd = ctx.disputeWinner === DisputeResult.Winner.BUYER ? ctx.getSeller().havenod : ctx.getBuyer().havenod; const signerd = winnerd ? winnerd : loserd; ctx.payoutTxId = (await signerd!.getTrade(ctx.offerId!)).getPayoutTxId(); // record balances on completion if (!ctx.maker.balancesAfterPayout) { ctx.maker.balancesAfterPayout = await ctx.maker.havenod?.getBalances(); ctx.taker.balancesAfterPayout = await ctx.taker.havenod?.getBalances(); } // test balances after payout tx unless concurrent trades if (!ctx.concurrentTrades) await testAmountsAfterComplete(ctx); // test payout unlock await testTradePayoutUnlock(ctx); } async function testAmountsAfterComplete(tradeCtx: TradeContext) { // get payout tx if (!tradeCtx.payoutTxId) throw new Error("Missing payout tx id"); const payoutTx = await monerod.getTx(tradeCtx.payoutTxId); const payoutTxFee = BigInt(payoutTx!.getFee()); // get expected payouts for normal trade const isDisputedTrade = tradeCtx.getDisputeOpener() !== undefined; if (!isDisputedTrade) { tradeCtx.getBuyer().payoutTxFee = payoutTxFee / 2n; tradeCtx.getBuyer().payoutAmount = tradeCtx.getBuyer().securityDepositActual! + tradeCtx.tradeAmount! - tradeCtx.getBuyer().payoutTxFee!; tradeCtx.getSeller().payoutTxFee = payoutTxFee / 2n; tradeCtx.getSeller().payoutAmount = tradeCtx.getSeller().securityDepositActual! - tradeCtx.getSeller().payoutTxFee!; } else { // get expected payouts for disputed trade const winnerGetsAll = tradeCtx.disputeWinnerAmount === tradeCtx.maker.securityDepositActual! + tradeCtx.taker.securityDepositActual! + tradeCtx.tradeAmount!; if (tradeCtx.disputeWinnerAmount) { tradeCtx.getDisputeWinner()!.payoutTxFee = winnerGetsAll ? payoutTxFee : 0n; tradeCtx.getDisputeWinner()!.payoutAmount = tradeCtx.disputeWinnerAmount - tradeCtx.getDisputeWinner()!.payoutTxFee!; tradeCtx.getDisputeLoser()!.payoutTxFee = winnerGetsAll ? 0n : payoutTxFee; tradeCtx.getDisputeLoser()!.payoutAmount = tradeCtx.maker.securityDepositActual! + tradeCtx.taker.securityDepositActual! + tradeCtx.tradeAmount! - tradeCtx.disputeWinnerAmount - tradeCtx.getDisputeLoser()!.payoutTxFee!; } else { tradeCtx.getDisputeWinner()!.payoutTxFee = payoutTxFee / 2n; tradeCtx.getDisputeWinner()!.payoutAmount = tradeCtx.tradeAmount! + tradeCtx.getDisputeWinner()!.securityDepositActual! - tradeCtx.getDisputeWinner()!.payoutTxFee!; tradeCtx.getDisputeLoser()!.payoutTxFee = payoutTxFee / 2n; tradeCtx.getDisputeLoser()!.payoutAmount = tradeCtx.getDisputeLoser()!.securityDepositActual! - tradeCtx.getDisputeLoser()!.payoutTxFee!; } } // TODO: payout tx is unknown to offline non-signer until confirmed if (isDisputedTrade || tradeCtx.isOfflineFlow()) { await mineToHeight(await monerod.getHeight() + 1); await wait(TestConfig.maxWalletStartupMs + tradeCtx.walletSyncPeriodMs * 2); } // test trade payouts if (tradeCtx.maker.havenod) await testPeerAmountsAfterComplete(tradeCtx, tradeCtx.getMaker()); if (tradeCtx.taker.havenod) await testPeerAmountsAfterComplete(tradeCtx, tradeCtx.getTaker()); } async function testPeerAmountsAfterComplete(tradeCtx: TradeContext, peerCtx: PeerContext) { // get trade const trade = await peerCtx.havenod!.getTrade(tradeCtx.offerId!); // test trade amounts const isBuyer = tradeCtx.getBuyer() === peerCtx; if (isBuyer) expect(BigInt(trade.getBuyerDepositTxFee())).toEqual(tradeCtx.getBuyer().depositTxFee); // TODO: get and test peer's security deposit tx fee? else expect(BigInt(trade.getSellerDepositTxFee())).toEqual(tradeCtx.getSeller().depositTxFee); expect(BigInt(trade.getBuyerPayoutTxFee())).toEqual(tradeCtx.getBuyer().payoutTxFee); expect(BigInt(trade.getSellerPayoutTxFee())).toEqual(tradeCtx.getSeller().payoutTxFee); expect(BigInt(trade.getBuyerPayoutAmount())).toEqual(tradeCtx.getBuyer().payoutAmount); expect(BigInt(trade.getSellerPayoutAmount())).toEqual(tradeCtx.getSeller().payoutAmount); // test balance change after payout tx const differenceAfterPayout = BigInt(peerCtx.balancesAfterPayout?.getBalance()!) - BigInt(peerCtx.balancesBeforePayout?.getBalance()!); expect(differenceAfterPayout).toEqual(peerCtx.payoutAmount); // test balance change since before offer if (tradeCtx.testBalanceChangeEndToEnd) { // calculate expected balance from before offer const sendTradeAmount = tradeCtx.getBuyer() === peerCtx ? 0n : BigInt(trade.getAmount()); const expectedBalanceAfterComplete = BigInt(peerCtx.balancesBeforeOffer?.getBalance()!) - peerCtx.splitOutputTxFee! - peerCtx.tradeFee! - sendTradeAmount - peerCtx.depositTxFee! - peerCtx.securityDepositActual! + peerCtx.payoutAmount!; // log the math HavenoUtils.log(1, "Testing end-to-end balance change:"); HavenoUtils.log(1, "Expected balance after = balance before - split output tx fee if maker and exact amount reserved - trade fee - trade amount if seller - deposit tx fee - security deposit received + (trade amount if seller + security deposit received - (payout tx fee / 2))"); HavenoUtils.log(1, expectedBalanceAfterComplete + " = " + BigInt(peerCtx.balancesBeforeOffer?.getBalance()!) + " - " + peerCtx.splitOutputTxFee + " - " + peerCtx.tradeFee! + " - " + sendTradeAmount + " - " + peerCtx.depositTxFee + " - " + peerCtx.securityDepositActual + " + " + peerCtx.payoutAmount); // test the expected balance expect(BigInt(peerCtx.balancesAfterPayout?.getBalance()!)).toEqual(expectedBalanceAfterComplete); } } async function testTradeChat(ctxP: Partial) { const ctx = TradeContext.init(ctxP); HavenoUtils.log(1, "Testing trade chat"); // invalid trade should throw error try { await user1.getChatMessages("invalid"); throw new Error("get chat messages with invalid id should fail"); } catch (err: any) { assert.equal(err.message, "trade with id 'invalid' not found"); } // trade chat should be in initial state let messages = await user1.getChatMessages(ctx.offerId!); assert(messages.length === 0); messages = await user2.getChatMessages(ctx.offerId!); assert(messages.length === 0); // add notification handlers and send some messages const user1Notifications: NotificationMessage[] = []; const user2Notifications: NotificationMessage[] = []; await user1.addNotificationListener(notification => { user1Notifications.push(notification); }); await user2.addNotificationListener(notification => { user2Notifications.push(notification); }); // send simple conversation and verify the list of messages const user1Msg = "Hi I'm user1"; await user1.sendChatMessage(ctx.offerId!, user1Msg); await wait(ctx.maxTimePeerNoticeMs); messages = await user2.getChatMessages(ctx.offerId!); expect(messages.length).toEqual(2); expect(messages[0].getIsSystemMessage()).toEqual(true); // first message is system expect(messages[1].getMessage()).toEqual(user1Msg); const user2Msg = "Hello I'm user2"; await user2.sendChatMessage(ctx.offerId!, user2Msg); await wait(ctx.maxTimePeerNoticeMs); messages = await user1.getChatMessages(ctx.offerId!); expect(messages.length).toEqual(3); expect(messages[0].getIsSystemMessage()).toEqual(true); expect(messages[1].getMessage()).toEqual(user1Msg); expect(messages[2].getMessage()).toEqual(user2Msg); // verify notifications let chatNotifications = getNotifications(user1Notifications, NotificationMessage.NotificationType.CHAT_MESSAGE); expect(chatNotifications.length).toBe(1); expect(chatNotifications[0].getChatMessage()?.getMessage()).toEqual(user2Msg); chatNotifications = getNotifications(user2Notifications, NotificationMessage.NotificationType.CHAT_MESSAGE); expect(chatNotifications.length).toBe(1); expect(chatNotifications[0].getChatMessage()?.getMessage()).toEqual(user1Msg); // additional msgs const msgs = ["", " ", "", "さようなら"]; for(const msg of msgs) { await user1.sendChatMessage(ctx.offerId!, msg); await wait(1000); // the async operation can result in out of order messages } await wait(ctx.maxTimePeerNoticeMs); messages = await user2.getChatMessages(ctx.offerId!); let offset = 3; // 3 existing messages expect(messages.length).toEqual(offset + msgs.length); expect(messages[0].getIsSystemMessage()).toEqual(true); expect(messages[1].getMessage()).toEqual(user1Msg); expect(messages[2].getMessage()).toEqual(user2Msg); for (let i = 0; i < msgs.length; i++) { expect(messages[i + offset].getMessage()).toEqual(msgs[i]); } chatNotifications = getNotifications(user2Notifications, NotificationMessage.NotificationType.CHAT_MESSAGE); offset = 1; // 1 existing notification expect(chatNotifications.length).toBe(offset + msgs.length); expect(chatNotifications[0].getChatMessage()?.getMessage()).toEqual(user1Msg); for (let i = 0; i < msgs.length; i++) { expect(chatNotifications[i + offset].getChatMessage()?.getMessage()).toEqual(msgs[i]); } } // ---------------------------- OTHER HELPERS --------------------------------- function getPort(url: string): string { return new URL(url).port; } function getBaseCurrencyNetwork(): BaseCurrencyNetwork { const str = getBaseCurrencyNetworkStr(); if (str === "XMR_MAINNET") return BaseCurrencyNetwork.XMR_MAINNET; else if (str === "XMR_STAGENET") return BaseCurrencyNetwork.XMR_STAGENET; else if (str === "XMR_LOCAL") return BaseCurrencyNetwork.XMR_LOCAL; else throw new Error("Unhandled base currency network: " + str); function getBaseCurrencyNetworkStr() { for (const arg of process.argv) { if (arg.indexOf("--baseCurrencyNetwork") === 0) { return arg.substring(arg.indexOf("=") + 1); } } throw new Error("Must provide base currency network, e.g.: `npm run test -- --baseCurrencyNetwork=XMR_LOCAL -t \"my test\"`"); } } function getNetworkStartPort() { switch (getBaseCurrencyNetwork()) { case BaseCurrencyNetwork.XMR_MAINNET: return 1; case BaseCurrencyNetwork.XMR_LOCAL: return 2; case BaseCurrencyNetwork.XMR_STAGENET: return 3; default: throw new Error("Unhandled base currency network: " + getBaseCurrencyNetwork()); } } function getArbitratorPrivKey(index: number) { const privKey = TestConfig.arbitratorPrivKeys[getBaseCurrencyNetwork()][index]; if (!privKey) throw new Error("No arbitrator private key at index " + index); return privKey; } async function initHavenos(numDaemons: number, config?: any) { const havenodPromises: Promise[] = []; for (let i = 0; i < numDaemons; i++) havenodPromises.push(initHaveno(config)); return Promise.all(havenodPromises); } async function initHaveno(ctx?: HavenodContext): Promise { if (!ctx) ctx = {}; Object.assign(ctx, TestConfig.defaultHavenod, Object.assign({}, ctx)); if (!ctx.appName) ctx.appName = "haveno-" + TestConfig.baseCurrencyNetwork + "_instance_" + moneroTs.GenUtils.getUUID(); // connect to existing server or start new process let havenod: HavenoClient; try { // try to connect to existing server if (!ctx.port) throw new Error("Cannot connect without port"); havenod = new HavenoClient("http://localhost:" + ctx.port, ctx.apiPassword!); await havenod.getVersion(); } catch (err: any) { // get port for haveno process if (!ctx.port) { for (const httpPort of Array.from(TestConfig.ports.keys())) { if (httpPort === "8079" || httpPort === "8080" || httpPort === "8081") continue; // reserved for arbitrator, user1, and user2 if (!moneroTs.GenUtils.arrayContains(HAVENO_PROCESS_PORTS, httpPort) && (!ctx.excludePorts || !moneroTs.GenUtils.arrayContains(ctx.excludePorts, httpPort))) { HAVENO_PROCESS_PORTS.push(httpPort); ctx.port = httpPort; break; } } } if (!ctx.port) throw new Error("No unused test ports available"); // start haveno process using configured ports if available const cmd: string[] = [ "./haveno-daemon", "--baseCurrencyNetwork", TestConfig.baseCurrencyNetwork, "--useLocalhostForP2P", TestConfig.baseCurrencyNetwork === BaseCurrencyNetwork.XMR_MAINNET ? "false" : "true", // TODO: disable for stagenet too "--useDevPrivilegeKeys", TestConfig.baseCurrencyNetwork === BaseCurrencyNetwork.XMR_LOCAL ? "true" : "false", "--nodePort", TestConfig.ports.get(ctx.port)![1], "--appName", ctx.appName, "--apiPassword", "apitest", "--apiPort", TestConfig.ports.get(ctx.port)![0], "--walletRpcBindPort", ctx.walletUrl ? getPort(ctx.walletUrl) : "" + await getAvailablePort(), // use configured port if given "--passwordRequired", (ctx.accountPasswordRequired ? "true" : "false"), "--logLevel", ctx.logLevel! ]; havenod = await HavenoClient.startProcess(TestConfig.haveno.path, cmd, "http://localhost:" + ctx.port, ctx.logProcessOutput!); HAVENO_PROCESSES.push(havenod); // wait to process network notifications await wait(3000); } // open account if configured if (ctx.autoLogin) { try { await initHavenoAccount(havenod, ctx.accountPassword!); } catch (err) { await releaseHavenoProcess(havenod); throw err; } } return havenod; async function getAvailablePort(): Promise { return new Promise(function(resolve) { const srv = net.createServer(); srv.listen(0, function() { const port = (srv.address() as net.AddressInfo).port; srv.close(function() { resolve(port); }); }); }); } } /** * Release a Haveno process for reuse and try to shutdown. */ async function releaseHavenoProcess(havenod: HavenoClient, deleteAppDir?: boolean) { moneroTs.GenUtils.remove(HAVENO_PROCESSES, havenod); moneroTs.GenUtils.remove(HAVENO_PROCESS_PORTS, getPort(havenod.getUrl())); try { await havenod.shutdownServer(); } catch (err: any) { assert(err.message.indexOf(OFFLINE_ERR_MSG) >= 0, "Unexpected error shutting down server: " + err.message); } if (deleteAppDir) deleteHavenoInstance(havenod); } /** * Delete a Haveno instance from disk. */ function deleteHavenoInstance(havenod: HavenoClient) { if (!havenod.getAppName()) throw new Error("Cannot delete Haveno instance owned by different process") deleteHavenoInstanceByAppName(havenod.getAppName()!); } function deleteHavenoInstanceByAppName(appName: string) { const userDataDir = process.env.APPDATA || (process.platform === 'darwin' ? process.env.HOME + '/Library/Application Support' : process.env.HOME + "/.local/share"); const appPath = path.normalize(userDataDir + "/" + appName); fs.rmSync(appPath, { recursive: true, force: true }); } /** * Create or open an account with the given daemon and password. */ async function initHavenoAccount(havenod: HavenoClient, password: string) { if (await havenod.isAccountOpen()) return; if (await havenod.accountExists()) return havenod.openAccount(password); await havenod.createAccount(password); return; } /** * Open or create funding wallet. */ async function initFundingWallet() { // init client connected to monero-wallet-rpc fundingWallet = await moneroTs.connectToWalletRpc(TestConfig.fundingWallet.url, TestConfig.fundingWallet.username, TestConfig.fundingWallet.password); // check if wallet is open let walletIsOpen = false try { await fundingWallet.getPrimaryAddress(); walletIsOpen = true; } catch (err: any) { // do nothing } // open wallet if necessary if (!walletIsOpen) { // attempt to open funding wallet try { await fundingWallet.openWallet({path: TestConfig.fundingWallet.defaultPath, password: TestConfig.fundingWallet.walletPassword}); } catch (err: any) { if (!(err instanceof moneroTs.MoneroRpcError)) throw err; // -1 returned when wallet does not exist or fails to open e.g. it's already open by another application if (err.getCode() === -1) { // create wallet await fundingWallet.createWallet({ path: TestConfig.fundingWallet.defaultPath, password: TestConfig.fundingWallet.walletPassword, seed: TestConfig.fundingWallet.seed, restoreHeight: TestConfig.fundingWallet.restoreHeight }); } else { throw err; } } } } async function prepareForTrading(numTrades: number, ...havenods: HavenoClient[]) { // create payment account for each payment method for (const havenod of havenods) { for (const paymentMethod of await havenod.getPaymentMethods()) { if (await hasPaymentAccount({trader: havenod, paymentMethod: paymentMethod.getId()})) continue; // skip if exists const accountForm = await user1.getPaymentAccountForm(paymentMethod.getId()); for (const field of accountForm.getFieldsList()) field.setValue(getValidFormInput(accountForm, field.getId())); // set all form fields await havenod.createPaymentAccount(accountForm); } } // create payment account for each asset code for (const havenod of havenods) { for (const assetCode of TestConfig.assetCodes.concat(TestConfig.fixedPriceAssetCodes)) { if (await hasPaymentAccount({trader: havenod, assetCode: assetCode})) continue; // skip if exists await createPaymentAccount(havenod, assetCode); } } // fund wallets const tradeAmount = 500000000000n; const wallets: moneroTs.MoneroWallet[] = []; for (const havenod of havenods) wallets.push(await getWallet(havenod)); await fundOutputs(wallets, tradeAmount * 2n, numTrades); } async function getWallet(havenod: HavenoClient) { if (!HAVENO_WALLETS.has(havenod)) { let wallet: any; if (havenod === user1) wallet = user1Wallet; else if (havenod === user2) wallet = user2Wallet; else wallet = await moneroTs.connectToWalletRpc("http://127.0.0.1:" + havenod.getWalletRpcPort(), TestConfig.defaultHavenod.walletUsername, TestConfig.defaultHavenod.accountPassword); HAVENO_WALLETS.set(havenod, wallet); } return HAVENO_WALLETS.get(havenod); } async function startMining(): Promise { try { const numThreads = getBaseCurrencyNetwork() === BaseCurrencyNetwork.XMR_LOCAL ? 1 : Math.max(1, Math.floor(os.cpus().length * TestConfig.maxCpuPct)); await monerod.startMining(await fundingWallet.getPrimaryAddress(), numThreads); HavenoUtils.log(2, "Mining started"); return true; } catch (err: any) { if (err.message !== "Already mining") throw err; HavenoUtils.log(2, ("Already mining")); return false; } } async function stopMining() { await monerod.stopMining(); HavenoUtils.log(2, "Mining stopped"); } async function mineBlocks(numBlocks: number) { await mineToHeight(await monerod.getHeight() + numBlocks); } async function mineToHeight(height: number) { if (await monerod.getHeight() >= height) return; const miningStarted = await startMining(); while (await monerod.getHeight() < height) { await moneroTs.GenUtils.waitFor(TestConfig.trade.walletSyncPeriodMs); } if (miningStarted) await stopMining(); } async function mineToUnlock(txHash: string) { let tx = await monerod.getTx(txHash); if (tx && tx.getNumConfirmations() >= 10) return; // TODO: tx.getIsLocked() const miningStarted = await startMining(); while (!tx || tx.getNumConfirmations() < 10) { await moneroTs.GenUtils.waitFor(TestConfig.trade.walletSyncPeriodMs); tx = await monerod.getTx(txHash); } if (miningStarted) await stopMining(); } /** * Wait for unlocked balance in wallet or Haveno daemon. */ async function waitForAvailableBalance(amount: bigint, ...wallets: any[]) { // wrap common wallet functionality for tests class WalletWrapper { _wallet: moneroTs.MoneroWallet; constructor(wallet: any) { this._wallet = wallet; } async getAvailableBalance(): Promise { if (this._wallet instanceof HavenoClient) return BigInt((await this._wallet.getBalances()).getAvailableBalance()); else return await this._wallet.getUnlockedBalance(); } async getPendingBalance(): Promise { if (this._wallet instanceof HavenoClient) return BigInt((await this._wallet.getBalances()).getPendingBalance()); else return await this._wallet.getBalance() - await this.getAvailableBalance(); } async getDepositAddress(): Promise { if (this._wallet instanceof HavenoClient) return await this._wallet.getXmrNewSubaddress(); else return (await this._wallet.createSubaddress(0)).getAddress(); } } // wrap wallets for (let i = 0; i < wallets.length; i++) wallets[i] = new WalletWrapper(wallets[i]); // fund wallets with insufficient balance let miningNeeded = false; const fundConfig: moneroTs.MoneroTxConfig = new moneroTs.MoneroTxConfig({accountIndex: 0, relay: true}); for (const wallet of wallets) { const availableBalance = await wallet.getAvailableBalance(); if (availableBalance < amount) miningNeeded = true; const depositNeeded: bigint = amount - availableBalance - await wallet.getPendingBalance(); if (depositNeeded > 0n && wallet._wallet !== fundingWallet) { for (let i = 0; i < 5; i++) { fundConfig.addDestination(await wallet.getDepositAddress(), depositNeeded * 2n); // make several deposits } } } if (fundConfig.getDestinations()) { await waitForAvailableBalance(TestConfig.fundingWallet.minimumFunding, fundingWallet); // TODO (woodser): wait for enough to cover tx amount + fee try { await fundingWallet.createTx(fundConfig); } catch (err: any) { throw new Error("Error funding wallets: " + err.message); } } // done if all wallets have sufficient unlocked balance if (!miningNeeded) return; // wait for funds to unlock const miningStarted = await startMining(); HavenoUtils.log(1, "Mining for unlocked balance of " + amount); const promises: Promise[] = []; for (const wallet of wallets) { if (wallet._wallet === fundingWallet) { const subaddress = await fundingWallet.createSubaddress(0); HavenoUtils.log(0, "Mining to funding wallet. Alternatively, deposit to: " + subaddress.getAddress()); } // eslint-disable-next-line no-async-promise-executor promises.push(new Promise(async (resolve) => { const taskLooper: any = new moneroTs.TaskLooper(async function() { if (await wallet.getAvailableBalance() >= amount) { taskLooper.stop(); resolve(); } }); taskLooper.start(5000); })); } await Promise.all(promises); if (miningStarted) await stopMining(); HavenoUtils.log(0, "Funds unlocked, done mining"); } async function waitForUnlockedTxs(...txHashes: string[]) { if (txHashes.length === 0) return; HavenoUtils.log(1, "Mining to unlock txs"); const miningStarted = await startMining(); const promises: Promise[] = []; for (const txHash of txHashes) { // eslint-disable-next-line no-async-promise-executor promises.push(new Promise(async (resolve) => { const taskLooper = new moneroTs.TaskLooper(async function() { const tx = await monerod.getTx(txHash); if (!tx) HavenoUtils.log(1, "WARNING: tx hash " + txHash + " not found"); else if (tx.getIsConfirmed() && tx.getBlock().getHeight() <= await monerod.getHeight() - 10) { taskLooper.stop(); resolve(); } }); taskLooper.start(5000); })); } await Promise.all(promises); HavenoUtils.log(1, "Done waiting for txs to unlock"); if (miningStarted) await stopMining(); } /** * Indicates if the given wallets have unspent outputs. * * @param {MoneroWallet[]} wallets - wallets to check * @param {BigInt} amt - amount to check * @param {number?} numOutputs - number of outputs of the given amount (default 1) * @param {boolean?} isLocked - specifies if the outputs must be locked or unlocked (default either) */ async function hasUnspentOutputs(wallets: any[], amt: BigInt, numOutputs?: number, isLocked?: boolean): Promise { if (numOutputs === undefined) numOutputs = 1; for (const wallet of wallets) { const unspentOutputs = await wallet.getOutputs({isSpent: false, isFrozen: false, minAmount: amt, txQuery: {isLocked: isLocked}}); if (unspentOutputs.length < numOutputs) return false; } return true; } /** * Fund the given wallets. * * @param {MoneroWallet} wallets - monerojs wallets * @param {BigInt} amt - the amount to fund * @param {number} [numOutputs] - the number of outputs of the given amount (default 1) * @param {boolean} [waitForUnlock] - wait for outputs to unlock (default false) */ async function fundOutputs(wallets: moneroTs.MoneroWallet[], amt: bigint, numOutputs?: number, waitForUnlock?: boolean): Promise { if (numOutputs === undefined) numOutputs = 1; if (waitForUnlock === undefined) waitForUnlock = true; // collect destinations const destinations: moneroTs.MoneroDestination[] = []; for (const wallet of wallets) { if (await hasUnspentOutputs([wallet], amt, numOutputs, undefined)) continue; for (let i = 0; i < numOutputs; i++) { destinations.push(new moneroTs.MoneroDestination((await wallet.createSubaddress(0)).getAddress(), amt)); } } if (!destinations.length) return; // fund destinations let txConfig = new moneroTs.MoneroTxConfig().setAccountIndex(0).setRelay(true); const txHashes: string[] = []; let sendAmt = 0n; for (let i = 0; i < destinations.length; i++) { txConfig.addDestination(destinations[i], undefined); // TODO: remove once converted to MoneroTxConfig.ts sendAmt = sendAmt + destinations[i].getAmount(); if (i === destinations.length - 1 || (i > 0 && i % 15 === 0)) { await waitForAvailableBalance(sendAmt, fundingWallet); const txs = await fundingWallet.createTxs(txConfig); for (const tx of txs) txHashes.push(tx.getHash()); txConfig = new moneroTs.MoneroTxConfig().setAccountIndex(0).setRelay(true); sendAmt = 0n; } } // if not waiting to unlock, wait to observe txs and return if (txHashes.length && !waitForUnlock) { await wait(TestConfig.trade.walletSyncPeriodMs); return; } // mine until outputs unlocked let miningStarted = false; let miningAttempted = false; while (!await hasUnspentOutputs(wallets, amt, numOutputs, false)) { if (waitForUnlock && !miningAttempted) { HavenoUtils.log(1, "Mining to fund outputs"); miningStarted = await startMining(); miningAttempted = true; } await wait(TestConfig.trade.walletSyncPeriodMs); } if (miningStarted) await stopMining(); } // eslint-disable-next-line @typescript-eslint/no-unused-vars function getBalancesStr(balances: XmrBalanceInfo): string { return "Balance: " + balances.getBalance() + ",\n" + "Available balance: " + balances.getAvailableBalance() + ",\n" + "Pending balance: " + balances.getPendingBalance() + ",\n" + "Reserved in offers: " + balances.getReservedOfferBalance() + ",\n" + "Locked in trade: " + balances.getReservedTradeBalance(); } async function wait(durationMs: number) { return new Promise(function(resolve) { setTimeout(resolve, durationMs); }); } function getNotifications(notifications: NotificationMessage[], notificationType: NotificationMessage.NotificationType, tradeId?: string) { const filteredNotifications: NotificationMessage[] = []; for (const notification of notifications) { if (notification.getType() !== notificationType) continue; if (tradeId) { let found = false; if (notification.getTrade() && notification.getTrade()!.getTradeId() === tradeId) found = true; if (notification.getChatMessage() && notification.getChatMessage()!.getTradeId() === tradeId) found = true; if (!found) continue; } filteredNotifications.push(notification); } return filteredNotifications; } function getConnection(connections: UrlConnection[], url: string): UrlConnection|undefined { for (const connection of connections) if (connection.getUrl() === url) return connection; return undefined; } function testConnection(connection: UrlConnection, url?: string, onlineStatus?: OnlineStatus, authenticationStatus?: AuthenticationStatus, priority?: number) { if (url) assert.equal(connection.getUrl(), url); assert.equal(connection.getPassword(), ""); // TODO (woodser): undefined instead of ""? assert.equal(connection.getUsername(), ""); if (onlineStatus !== undefined) assert.equal(connection.getOnlineStatus(), onlineStatus); if (authenticationStatus !== undefined) assert.equal(connection.getAuthenticationStatus(), authenticationStatus); if (priority !== undefined) assert.equal(connection.getPriority(), priority); } function testTx(tx: XmrTx, ctx: TxContext) { assert(tx.getHash()); expect(BigInt(tx.getFee())).toBeLessThan(TestConfig.maxFee); if (tx.getIsConfirmed()) { assert(tx.getTimestamp() > 1000); assert(tx.getHeight() > 0); } else { assert.equal(tx.getHeight(), 0); } assert(tx.getOutgoingTransfer() || tx.getIncomingTransfersList().length); // TODO (woodser): test transfers for (const incomingTransfer of tx.getIncomingTransfersList()) testTransfer(incomingTransfer, ctx); if (tx.getOutgoingTransfer()) testTransfer(tx.getOutgoingTransfer()!, ctx); if (ctx.isCreatedTx) testCreatedTx(tx); } function testCreatedTx(tx: XmrTx) { assert.equal(tx.getTimestamp(), 0); assert.equal(tx.getIsConfirmed(), false); assert.equal(tx.getIsLocked(), true); assert(tx.getMetadata() && tx.getMetadata().length > 0); } function testTransfer(transfer: XmrIncomingTransfer | XmrOutgoingTransfer, ctx: TxContext) { expect(BigInt(transfer.getAmount())).toBeGreaterThanOrEqual(0n); assert(transfer.getAccountIndex() >= 0); if (transfer instanceof XmrIncomingTransfer) testIncomingTransfer(transfer); else testOutgoingTransfer(transfer, ctx); } function testIncomingTransfer(transfer: XmrIncomingTransfer) { assert(transfer.getAddress()); assert(transfer.getSubaddressIndex() >= 0); assert(transfer.getNumSuggestedConfirmations() > 0); } function testOutgoingTransfer(transfer: XmrOutgoingTransfer, ctx: TxContext) { if (!ctx.isCreatedTx) assert(transfer.getSubaddressIndicesList().length > 0); for (const subaddressIdx of transfer.getSubaddressIndicesList()) assert(subaddressIdx >= 0); // test destinations sum to outgoing amount if (transfer.getDestinationsList().length > 0) { let sum = 0n; for (const destination of transfer.getDestinationsList()) { testDestination(destination); expect(BigInt(destination.getAmount())).toBeGreaterThan(0n); sum += BigInt(destination.getAmount()); } assert.equal(sum, BigInt(transfer.getAmount())); } } function testDestination(destination: XmrDestination) { assert(destination.getAddress()); expect(BigInt(destination.getAmount())).toBeGreaterThan(0n); } function getRandomBigIntWithinPercent(base: bigint, percent: number): bigint { return getRandomBigIntWithinRange(base - multiply(base, percent), base + multiply(base, percent)); } function multiply(amount: bigint, multiplier: number): bigint { return BigInt(Math.round(Number(amount) * multiplier)); } function getRandomBigIntWithinRange(min: bigint, max: bigint): bigint { return BigInt(Math.floor(Math.random() * (Number(max) - Number(min))) + Number(min)); } function getRandomAssetCode() { return TestConfig.assetCodes[moneroTs.GenUtils.getRandomInt(0, TestConfig.assetCodes.length - 1)]; } async function hasPaymentAccount(config: { trader: HavenoClient; assetCode?: string; paymentMethod?: string }): Promise { for (const paymentAccount of await config.trader.getPaymentAccounts()) { if (config.assetCode?.toUpperCase() === paymentAccount.getSelectedTradeCurrency()!.getCode()) return true; if (config.paymentMethod?.toUpperCase() === paymentAccount.getPaymentMethod()!.getId()) return true; } return false; } function isCrypto(assetCode: string) { return getCryptoAddress(assetCode) !== undefined; } function getCryptoAddress(currencyCode: string): string|undefined { for (const cryptoAddress of TestConfig.cryptoAddresses) { if (cryptoAddress.currencyCode === currencyCode.toUpperCase()) return cryptoAddress.address; } } async function createPaymentAccount(trader: HavenoClient, assetCodes: string, paymentMethodId?: string | PaymentAccountForm.FormId) { if (!paymentMethodId) paymentMethodId = isCrypto(assetCodes!) ? PaymentAccountForm.FormId.BLOCK_CHAINS : PaymentAccountForm.FormId.PAY_BY_MAIL; const accountForm = await trader.getPaymentAccountForm(paymentMethodId); for (const field of accountForm.getFieldsList()) field.setValue(getValidFormInput(accountForm, field.getId())); if (HavenoUtils.hasFormField(accountForm, PaymentAccountFormField.FieldId.TRADE_CURRENCIES)) HavenoUtils.setFormValue(accountForm, PaymentAccountFormField.FieldId.TRADE_CURRENCIES, assetCodes); return await trader.createPaymentAccount(accountForm); } async function createCryptoPaymentAccount(trader: HavenoClient, currencyCode = "eth"): Promise { for (const cryptoAddress of TestConfig.cryptoAddresses) { if (cryptoAddress.currencyCode.toLowerCase() !== currencyCode.toLowerCase()) continue; return trader.createCryptoPaymentAccount( cryptoAddress.currencyCode + " " + cryptoAddress.address.substr(0, 8) + "... " + moneroTs.GenUtils.getUUID(), cryptoAddress.currencyCode, cryptoAddress.address); } throw new Error("No test config for crypto: " + currencyCode); } function getOffer(offers: OfferInfo[], id: string): OfferInfo|undefined { return offers.find(offer => offer.getId() === id); } function testCryptoPaymentAccount(acct: PaymentAccount) { expect(acct.getId().length).toBeGreaterThan(0); expect(acct.getAccountName().length).toBeGreaterThan(0); expect(acct.getPaymentAccountPayload()!.getCryptoCurrencyAccountPayload()!.getAddress().length).toBeGreaterThan(0); expect(acct.getSelectedTradeCurrency()!.getCode().length).toBeGreaterThan(0); expect(acct.getTradeCurrenciesList().length).toEqual(1); const tradeCurrency = acct.getTradeCurrenciesList()[0]; expect(tradeCurrency.getName().length).toBeGreaterThan(0); expect(tradeCurrency.getCode()).toEqual(acct.getSelectedTradeCurrency()!.getCode()); } function testCryptoPaymentAccountsEqual(acct1: PaymentAccount, acct2: PaymentAccount) { expect(acct1.getId()).toEqual(acct2.getId()); expect(acct1.getAccountName()).toEqual(acct2.getAccountName()); expect(acct1.getSelectedTradeCurrency()!.getCode()).toEqual(acct2.getSelectedTradeCurrency()!.getCode()); expect(acct1.getPaymentAccountPayload()!.getCryptoCurrencyAccountPayload()!.getAddress()).toEqual(acct2.getPaymentAccountPayload()!.getCryptoCurrencyAccountPayload()!.getAddress()); } function testOffer(offer: OfferInfo, ctx?: Partial) { expect(offer.getId().length).toBeGreaterThan(0); if (ctx) { expect(offer.getBuyerSecurityDepositPct()).toEqual(ctx?.securityDepositPct); expect(offer.getSellerSecurityDepositPct()).toEqual(ctx?.securityDepositPct); expect(offer.getUseMarketBasedPrice()).toEqual(!ctx?.price); expect(offer.getMarketPriceMarginPct()).toEqual(ctx?.priceMargin ? ctx?.priceMargin : 0); // TODO: test rest of offer } } function testMoneroNodeSettingsEqual(settingsBefore: XmrNodeSettings, settingsAfter: XmrNodeSettings) { expect(settingsAfter.getBlockchainPath()).toEqual(settingsBefore.getBlockchainPath()); expect(settingsAfter.getBootstrapUrl()).toEqual(settingsBefore.getBootstrapUrl()); expect(settingsAfter.getStartupFlagsList()).toEqual(settingsBefore.getStartupFlagsList()); } function getFormField(form: PaymentAccountForm, fieldId: PaymentAccountFormField.FieldId): PaymentAccountFormField { for (const field of form.getFieldsList()) { if (field.getId() == fieldId) return field; } throw new Error("Form field not found: " + fieldId); } function getValidFormInput(form: PaymentAccountForm, fieldId: PaymentAccountFormField.FieldId): string { const field = getFormField(form, fieldId); switch (fieldId) { case PaymentAccountFormField.FieldId.ACCEPTED_COUNTRY_CODES: if (form.getId() === PaymentAccountForm.FormId.SEPA || form.getId() === PaymentAccountForm.FormId.SEPA_INSTANT) return "BE," + field.getSupportedSepaEuroCountriesList().map(country => country.getCode()).join(','); return field.getSupportedCountriesList().map(country => country.getCode()).join(','); case PaymentAccountFormField.FieldId.ACCOUNT_ID: return "jdoe@no.com"; case PaymentAccountFormField.FieldId.ACCOUNT_NAME: return "Form_" + form.getId() + " " + moneroTs.GenUtils.getUUID(); // TODO: rename to form.getPaymentMethodId() case PaymentAccountFormField.FieldId.ACCOUNT_NR: return "12345678"; case PaymentAccountFormField.FieldId.ACCOUNT_OWNER: return "John Doe"; case PaymentAccountFormField.FieldId.ACCOUNT_TYPE: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.ANSWER: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.BANK_ACCOUNT_NAME: return "John Doe"; case PaymentAccountFormField.FieldId.BANK_ACCOUNT_NUMBER: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.BANK_ACCOUNT_TYPE: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.BANK_ADDRESS: return "456 example st"; case PaymentAccountFormField.FieldId.BANK_BRANCH: return "Bank branch XYZ"; case PaymentAccountFormField.FieldId.BANK_BRANCH_CODE: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.BANK_BRANCH_NAME: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.BANK_ID: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.BANK_NAME: return "Bank XYZ"; case PaymentAccountFormField.FieldId.BANK_SWIFT_CODE: return "12345678901"; // TODO: use real swift code case PaymentAccountFormField.FieldId.BENEFICIARY_ACCOUNT_NR: return "1234567890"; case PaymentAccountFormField.FieldId.BENEFICIARY_ADDRESS: return "123 example st"; case PaymentAccountFormField.FieldId.BENEFICIARY_CITY: return "Acme"; case PaymentAccountFormField.FieldId.BENEFICIARY_NAME: return "Jane Doe"; case PaymentAccountFormField.FieldId.BENEFICIARY_PHONE: return "123-456-7890"; case PaymentAccountFormField.FieldId.BIC: return "ATLNFRPP"; case PaymentAccountFormField.FieldId.BRANCH_ID: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.CITY: return "Atlanta"; case PaymentAccountFormField.FieldId.CONTACT: return "Email please"; case PaymentAccountFormField.FieldId.COUNTRY: case PaymentAccountFormField.FieldId.BANK_COUNTRY_CODE: case PaymentAccountFormField.FieldId.INTERMEDIARY_COUNTRY_CODE: return field.getSupportedCountriesList().length ? field.getSupportedCountriesList()[0]!.getCode() : "FR"; case PaymentAccountFormField.FieldId.EMAIL: return "jdoe@no.com"; case PaymentAccountFormField.FieldId.EMAIL_OR_MOBILE_NR: return "876-512-7813"; case PaymentAccountFormField.FieldId.EMAIL_OR_MOBILE_NR_OR_USERNAME: return "john.doe" case PaymentAccountFormField.FieldId.EMAIL_OR_MOBILE_NR_OR_CASHTAG: return "john.doe" case PaymentAccountFormField.FieldId.EXTRA_INFO: return "Please and thanks"; case PaymentAccountFormField.FieldId.HOLDER_ADDRESS: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.HOLDER_EMAIL: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.HOLDER_NAME: return "user1 Doe"; case PaymentAccountFormField.FieldId.HOLDER_TAX_ID: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.IBAN: return "FR1420041010050500013M02606"; case PaymentAccountFormField.FieldId.IFSC: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.INTERMEDIARY_ADDRESS: return "123 intermediary example st"; case PaymentAccountFormField.FieldId.INTERMEDIARY_BRANCH: return "Intermediary bank branch XYZ"; case PaymentAccountFormField.FieldId.INTERMEDIARY_NAME: return "Intermediary bank XYZ"; case PaymentAccountFormField.FieldId.INTERMEDIARY_SWIFT_CODE: return "10987654321"; // TODO: use real swift code case PaymentAccountFormField.FieldId.MOBILE_NR: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.NATIONAL_ACCOUNT_ID: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.PAYID: return "john.doe@example.com"; case PaymentAccountFormField.FieldId.PIX_KEY: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.POSTAL_ADDRESS: return "123 street"; case PaymentAccountFormField.FieldId.PROMPT_PAY_ID: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.QUESTION: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.REQUIREMENTS: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.SALT: return ""; case PaymentAccountFormField.FieldId.SORT_CODE: return "123456"; case PaymentAccountFormField.FieldId.SPECIAL_INSTRUCTIONS: return "asap plz"; case PaymentAccountFormField.FieldId.STATE: const country = HavenoUtils.getFormValue(form, PaymentAccountFormField.FieldId.COUNTRY); return moneroTs.GenUtils.arrayContains(field.getRequiredForCountriesList(), country) ? "My state" : ""; case PaymentAccountFormField.FieldId.TRADE_CURRENCIES: if (field.getComponent() === PaymentAccountFormField.Component.SELECT_ONE) { if (form.getId() === PaymentAccountForm.FormId.F2F) return "XAU"; if (form.getId() === PaymentAccountForm.FormId.PAY_BY_MAIL) return "XGB"; return field.getSupportedCurrenciesList()[0]!.getCode(); // TODO: randomly select? } else return field.getSupportedCurrenciesList().map(currency => currency.getCode()).join(','); case PaymentAccountFormField.FieldId.USERNAME: return "user123"; case PaymentAccountFormField.FieldId.ADDRESS: const currencyCode = HavenoUtils.getFormValue(form, PaymentAccountFormField.FieldId.TRADE_CURRENCIES); for (let cryptoAddress of TestConfig.cryptoAddresses) { if (cryptoAddress.currencyCode.toLowerCase() === currencyCode.toLowerCase()) return cryptoAddress.address; } throw new Error("Unsupported blockchain currency code: " + currencyCode); default: throw new Error("Unhandled form field: " + fieldId); } } // TODO: improve invalid inputs function getInvalidFormInput(form: PaymentAccountForm, fieldId: PaymentAccountFormField.FieldId): string { const field = getFormField(form, fieldId); switch (fieldId) { case PaymentAccountFormField.FieldId.ACCEPTED_COUNTRY_CODES: return "US,XX"; case PaymentAccountFormField.FieldId.ACCOUNT_ID: return ""; case PaymentAccountFormField.FieldId.ACCOUNT_NAME: return ""; case PaymentAccountFormField.FieldId.ACCOUNT_NR: return "123457A"; case PaymentAccountFormField.FieldId.ACCOUNT_OWNER: return "J"; case PaymentAccountFormField.FieldId.ACCOUNT_TYPE: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.ANSWER: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.BANK_ACCOUNT_NAME: return "F"; case PaymentAccountFormField.FieldId.BANK_ACCOUNT_NUMBER: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.BANK_ACCOUNT_TYPE: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.BANK_ADDRESS: return ""; case PaymentAccountFormField.FieldId.BANK_BRANCH: return "A"; case PaymentAccountFormField.FieldId.BANK_BRANCH_CODE: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.BANK_BRANCH_NAME: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.BANK_CODE: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.BANK_COUNTRY_CODE: return "A"; case PaymentAccountFormField.FieldId.BANK_ID: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.BANK_NAME: return "A"; case PaymentAccountFormField.FieldId.BANK_SWIFT_CODE: return "A"; case PaymentAccountFormField.FieldId.BENEFICIARY_ACCOUNT_NR: return "1"; case PaymentAccountFormField.FieldId.BENEFICIARY_ADDRESS: return ""; case PaymentAccountFormField.FieldId.BENEFICIARY_CITY: return "A"; case PaymentAccountFormField.FieldId.BENEFICIARY_NAME: return "A"; case PaymentAccountFormField.FieldId.BENEFICIARY_PHONE: return "1"; case PaymentAccountFormField.FieldId.BIC: return "123"; case PaymentAccountFormField.FieldId.BRANCH_ID: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.CITY: return "A"; case PaymentAccountFormField.FieldId.CONTACT: return ""; case PaymentAccountFormField.FieldId.COUNTRY: return "abc" case PaymentAccountFormField.FieldId.EMAIL: return "@no.com"; case PaymentAccountFormField.FieldId.EMAIL_OR_MOBILE_NR: return ""; // TODO: validate phone numbers, e.g. 876 case PaymentAccountFormField.FieldId.EMAIL_OR_MOBILE_NR_OR_USERNAME: return "A" case PaymentAccountFormField.FieldId.EMAIL_OR_MOBILE_NR_OR_CASHTAG: return "A" case PaymentAccountFormField.FieldId.EXTRA_INFO: throw new Error("Extra info has no invalid input"); case PaymentAccountFormField.FieldId.HOLDER_ADDRESS: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.HOLDER_EMAIL: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.HOLDER_NAME: return "A"; case PaymentAccountFormField.FieldId.HOLDER_TAX_ID: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.IBAN: return "abc"; case PaymentAccountFormField.FieldId.IFSC: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.INTERMEDIARY_ADDRESS: return ""; case PaymentAccountFormField.FieldId.INTERMEDIARY_BRANCH: return "A"; case PaymentAccountFormField.FieldId.INTERMEDIARY_COUNTRY_CODE: return "A"; case PaymentAccountFormField.FieldId.INTERMEDIARY_NAME: return "A"; case PaymentAccountFormField.FieldId.INTERMEDIARY_SWIFT_CODE: return "A"; case PaymentAccountFormField.FieldId.MOBILE_NR: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.NATIONAL_ACCOUNT_ID: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.PAYID: return "A"; case PaymentAccountFormField.FieldId.PIX_KEY: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.POSTAL_ADDRESS: return ""; case PaymentAccountFormField.FieldId.PROMPT_PAY_ID: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.QUESTION: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.REQUIREMENTS: throw new Error("Not implemented"); case PaymentAccountFormField.FieldId.SALT: return "abc"; case PaymentAccountFormField.FieldId.SORT_CODE: return "12345A"; case PaymentAccountFormField.FieldId.SPECIAL_INSTRUCTIONS: throw new Error("Special instructions have no invalid input"); case PaymentAccountFormField.FieldId.STATE: { const country = HavenoUtils.getFormValue(form, PaymentAccountFormField.FieldId.COUNTRY); return moneroTs.GenUtils.arrayContains(field.getRequiredForCountriesList(), country) ? "" : "My state"; } case PaymentAccountFormField.FieldId.TRADE_CURRENCIES: return "abc,def"; case PaymentAccountFormField.FieldId.USERNAME: return "A"; case PaymentAccountFormField.FieldId.ADDRESS: return "A123"; default: throw new Error("Unhandled form field: " + fieldId); } } function testPaymentAccount(account: PaymentAccount, form: PaymentAccountForm) { if (account.getPaymentAccountPayload()?.getCryptoCurrencyAccountPayload()) testCryptoPaymentAccount(account); // TODO: test non-crypto expect(account.getAccountName()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.ACCOUNT_NAME).getValue()); // TODO: using number as payment method, account payload's account name = username const isCountryBased = account.getPaymentAccountPayload()!.getCountryBasedPaymentAccountPayload() !== undefined; if (isCountryBased) expect(account.getPaymentAccountPayload()!.getCountryBasedPaymentAccountPayload()!.getCountryCode()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.COUNTRY).getValue()); switch (form.getId()) { case PaymentAccountForm.FormId.BLOCK_CHAINS: expect(account.getPaymentAccountPayload()!.getCryptoCurrencyAccountPayload()!.getAddress()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.ADDRESS).getValue()); expect(account.getTradeCurrenciesList().map(currency => currency.getCode()).join(",")).toEqual(getFormField(form, PaymentAccountFormField.FieldId.TRADE_CURRENCIES).getValue()); break; case PaymentAccountForm.FormId.REVOLUT: expect(account.getPaymentAccountPayload()!.getRevolutAccountPayload()!.getUsername()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.USERNAME).getValue()); expect(account.getTradeCurrenciesList().map(currency => currency.getCode()).join(",")).toEqual(getFormField(form, PaymentAccountFormField.FieldId.TRADE_CURRENCIES).getValue()); break; case PaymentAccountForm.FormId.SEPA: expect(account.getPaymentAccountPayload()!.getCountryBasedPaymentAccountPayload()!.getSepaAccountPayload()!.getHolderName()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.HOLDER_NAME).getValue()); //expect(account.getPaymentAccountPayload()!.getCountryBasedPaymentAccountPayload()!.getSepaAccountPayload().getEmail()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.EMAIL).getValue()); // TODO: if this is deprecated, remove from sepa model expect(account.getPaymentAccountPayload()!.getCountryBasedPaymentAccountPayload()!.getSepaAccountPayload()!.getIban()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.IBAN).getValue()); expect(account.getPaymentAccountPayload()!.getCountryBasedPaymentAccountPayload()!.getSepaAccountPayload()!.getBic()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.BIC).getValue()); expect(account.getPaymentAccountPayload()!.getCountryBasedPaymentAccountPayload()!.getAcceptedCountryCodesList().join(",")).toEqual(getFormField(form, PaymentAccountFormField.FieldId.ACCEPTED_COUNTRY_CODES).getValue()); break; case PaymentAccountForm.FormId.SEPA_INSTANT: expect(account.getPaymentAccountPayload()!.getCountryBasedPaymentAccountPayload()!.getSepaInstantAccountPayload()!.getHolderName()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.HOLDER_NAME).getValue()); expect(account.getPaymentAccountPayload()!.getCountryBasedPaymentAccountPayload()!.getSepaInstantAccountPayload()!.getIban()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.IBAN).getValue()); expect(account.getPaymentAccountPayload()!.getCountryBasedPaymentAccountPayload()!.getSepaInstantAccountPayload()!.getBic()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.BIC).getValue()); expect(account.getPaymentAccountPayload()!.getCountryBasedPaymentAccountPayload()!.getAcceptedCountryCodesList().join(",")).toEqual(getFormField(form, PaymentAccountFormField.FieldId.ACCEPTED_COUNTRY_CODES).getValue()); break; case PaymentAccountForm.FormId.TRANSFERWISE: expect(account.getPaymentAccountPayload()!.getTransferwiseAccountPayload()!.getEmail()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.EMAIL).getValue()); break; case PaymentAccountForm.FormId.ZELLE: expect(account.getPaymentAccountPayload()!.getZelleAccountPayload()!.getHolderName()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.HOLDER_NAME).getValue()); expect(account.getPaymentAccountPayload()!.getZelleAccountPayload()!.getEmailOrMobileNr()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.EMAIL_OR_MOBILE_NR).getValue()); expect(account.getTradeCurrenciesList().length).toEqual(1); expect(account.getTradeCurrenciesList()[0].getCode()).toEqual("USD"); break; case PaymentAccountForm.FormId.SWIFT: expect(account.getPaymentAccountPayload()!.getSwiftAccountPayload()!.getBankSwiftCode()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.BANK_SWIFT_CODE).getValue()); expect(account.getPaymentAccountPayload()!.getSwiftAccountPayload()!.getBankCountryCode()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.BANK_COUNTRY_CODE).getValue()); expect(account.getPaymentAccountPayload()!.getSwiftAccountPayload()!.getBankName()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.BANK_NAME).getValue()); expect(account.getPaymentAccountPayload()!.getSwiftAccountPayload()!.getBankBranch()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.BANK_BRANCH).getValue()); expect(account.getPaymentAccountPayload()!.getSwiftAccountPayload()!.getBankAddress()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.BANK_ADDRESS).getValue()); expect(account.getPaymentAccountPayload()!.getSwiftAccountPayload()!.getIntermediarySwiftCode()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.INTERMEDIARY_SWIFT_CODE).getValue()); expect(account.getPaymentAccountPayload()!.getSwiftAccountPayload()!.getIntermediaryCountryCode()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.INTERMEDIARY_COUNTRY_CODE).getValue()); expect(account.getPaymentAccountPayload()!.getSwiftAccountPayload()!.getIntermediaryName()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.INTERMEDIARY_NAME).getValue()); expect(account.getPaymentAccountPayload()!.getSwiftAccountPayload()!.getIntermediaryBranch()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.INTERMEDIARY_BRANCH).getValue()); expect(account.getPaymentAccountPayload()!.getSwiftAccountPayload()!.getIntermediaryAddress()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.INTERMEDIARY_ADDRESS).getValue()); expect(account.getPaymentAccountPayload()!.getSwiftAccountPayload()!.getBeneficiaryName()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.BENEFICIARY_NAME).getValue()); expect(account.getPaymentAccountPayload()!.getSwiftAccountPayload()!.getBeneficiaryAccountNr()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.BENEFICIARY_ACCOUNT_NR).getValue()); expect(account.getPaymentAccountPayload()!.getSwiftAccountPayload()!.getBeneficiaryAddress()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.BENEFICIARY_ADDRESS).getValue()); expect(account.getPaymentAccountPayload()!.getSwiftAccountPayload()!.getBeneficiaryCity()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.BENEFICIARY_CITY).getValue()); expect(account.getPaymentAccountPayload()!.getSwiftAccountPayload()!.getBeneficiaryPhone()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.BENEFICIARY_PHONE).getValue()); expect(account.getPaymentAccountPayload()!.getSwiftAccountPayload()!.getSpecialInstructions()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.SPECIAL_INSTRUCTIONS).getValue()); break; case PaymentAccountForm.FormId.F2F: expect(account.getPaymentAccountPayload()!.getCountryBasedPaymentAccountPayload()!.getF2fAccountPayload()!.getCity()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.CITY).getValue()); expect(account.getPaymentAccountPayload()!.getCountryBasedPaymentAccountPayload()!.getF2fAccountPayload()!.getContact()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.CONTACT).getValue()); expect(account.getPaymentAccountPayload()!.getCountryBasedPaymentAccountPayload()!.getF2fAccountPayload()!.getExtraInfo()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.EXTRA_INFO).getValue()); break; case PaymentAccountForm.FormId.STRIKE: expect(account.getPaymentAccountPayload()!.getCountryBasedPaymentAccountPayload()!.getStrikeAccountPayload()!.getHolderName()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.HOLDER_NAME).getValue()); break; case PaymentAccountForm.FormId.MONEY_GRAM: expect(account.getPaymentAccountPayload()!.getMoneyGramAccountPayload()!.getCountryCode()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.COUNTRY).getValue()); // TODO: ok to not be CountryBasedPaymentAccountPayload? expect(account.getPaymentAccountPayload()!.getMoneyGramAccountPayload()!.getState()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.STATE).getValue()); expect(account.getPaymentAccountPayload()!.getMoneyGramAccountPayload()!.getHolderName()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.HOLDER_NAME).getValue()); expect(account.getPaymentAccountPayload()!.getMoneyGramAccountPayload()!.getEmail()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.EMAIL).getValue()); expect(account.getTradeCurrenciesList().map(currency => currency.getCode()).join(",")).toEqual(getFormField(form, PaymentAccountFormField.FieldId.TRADE_CURRENCIES).getValue()); break; case PaymentAccountForm.FormId.FASTER_PAYMENTS: expect(account.getPaymentAccountPayload()!.getFasterPaymentsAccountPayload()!.getHolderName()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.HOLDER_NAME).getValue()); expect(account.getPaymentAccountPayload()!.getFasterPaymentsAccountPayload()!.getSortCode()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.SORT_CODE).getValue()); expect(account.getPaymentAccountPayload()!.getFasterPaymentsAccountPayload()!.getAccountNr()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.ACCOUNT_NR).getValue()); break; case PaymentAccountForm.FormId.UPHOLD: expect(account.getPaymentAccountPayload()!.getUpholdAccountPayload()!.getAccountOwner()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.ACCOUNT_OWNER).getValue()); expect(account.getPaymentAccountPayload()!.getUpholdAccountPayload()!.getAccountId()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.ACCOUNT_ID).getValue()); break; case PaymentAccountForm.FormId.PAXUM: expect(account.getPaymentAccountPayload()!.getPaxumAccountPayload()!.getEmail()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.EMAIL).getValue()); expect(account.getTradeCurrenciesList().map(currency => currency.getCode()).join(",")).toEqual(getFormField(form, PaymentAccountFormField.FieldId.TRADE_CURRENCIES).getValue()); break; case PaymentAccountForm.FormId.PAY_BY_MAIL: expect(account.getPaymentAccountPayload()!.getPayByMailAccountPayload()!.getContact()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.CONTACT).getValue()); expect(account.getPaymentAccountPayload()!.getPayByMailAccountPayload()!.getPostalAddress()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.POSTAL_ADDRESS).getValue()); expect(account.getPaymentAccountPayload()!.getPayByMailAccountPayload()!.getExtraInfo()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.EXTRA_INFO).getValue()); expect(account.getTradeCurrenciesList().length).toEqual(1); expect(account.getTradeCurrenciesList()[0].getCode()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.TRADE_CURRENCIES).getValue()); break; case PaymentAccountForm.FormId.CASH_AT_ATM: expect(account.getPaymentAccountPayload()!.getCashAtAtmAccountPayload()!.getExtraInfo()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.EXTRA_INFO).getValue()); expect(account.getTradeCurrenciesList().length).toEqual(1); expect(account.getTradeCurrenciesList()[0].getCode()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.TRADE_CURRENCIES).getValue()); break; case PaymentAccountForm.FormId.AUSTRALIA_PAYID: expect(account.getPaymentAccountPayload()!.getAustraliaPayidPayload()!.getBankAccountName()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.BANK_ACCOUNT_NAME).getValue()); expect(account.getPaymentAccountPayload()!.getAustraliaPayidPayload()!.getPayid()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.PAYID).getValue()); expect(account.getPaymentAccountPayload()!.getAustraliaPayidPayload()!.getExtraInfo()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.EXTRA_INFO).getValue()); break; case PaymentAccountForm.FormId.CASH_APP: expect(account.getPaymentAccountPayload()!.getCashAppAccountPayload()!.getEmailOrMobileNrOrCashtag()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.EMAIL_OR_MOBILE_NR_OR_CASHTAG).getValue()); expect(account.getTradeCurrenciesList().length).toEqual(2); expect(account.getTradeCurrenciesList().map(currency => currency.getCode()).join(",")).toEqual(getFormField(form, PaymentAccountFormField.FieldId.TRADE_CURRENCIES).getValue()); break; case PaymentAccountForm.FormId.PAYPAL: expect(account.getPaymentAccountPayload()!.getPaypalAccountPayload()!.getEmailOrMobileNrOrUsername()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.EMAIL_OR_MOBILE_NR_OR_USERNAME).getValue()); expect(account.getTradeCurrenciesList().map(currency => currency.getCode()).join(",")).toEqual(getFormField(form, PaymentAccountFormField.FieldId.TRADE_CURRENCIES).getValue()); break; case PaymentAccountForm.FormId.VENMO: expect(account.getPaymentAccountPayload()!.getVenmoAccountPayload()!.getEmailOrMobileNrOrUsername()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.EMAIL_OR_MOBILE_NR_OR_USERNAME).getValue()); expect(account.getTradeCurrenciesList().length).toEqual(1); expect(account.getTradeCurrenciesList()[0].getCode()).toEqual("USD"); break; default: throw new Error("Unhandled payment method type: " + form.getId()); } }