diff --git a/src/HavenoClient.test.ts b/src/HavenoClient.test.ts index 1f6d5391..04a90a3f 100644 --- a/src/HavenoClient.test.ts +++ b/src/HavenoClient.test.ts @@ -63,7 +63,7 @@ enum BaseCurrencyNetwork { } // clients -const startupHavenods: HavenoClient[] = []; +const startupHavenodUrls: string[] = []; let arbitrator: HavenoClient; let user1: HavenoClient; let user2: HavenoClient; @@ -148,7 +148,9 @@ const defaultTradeConfig: Partial = { 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() + maxConcurrency: getMaxConcurrency(), + isPrivateOffer: false, + buyerAsTakerWithoutDeposit: undefined } /** @@ -188,11 +190,14 @@ class TradeContext { priceMargin?: number; triggerPrice?: number; reserveExactAmount?: boolean; + isPrivateOffer?: boolean; + buyerAsTakerWithoutDeposit?: boolean; // buyer as taker security deposit is optional for private offers // take offer awaitFundsToTakeOffer?: boolean; offerId?: string; takerPaymentAccountId?: string; + challenge?: string; testTraderChat?: boolean; // resolve dispute @@ -287,6 +292,10 @@ class TradeContext { return this.isPaymentReceived ? "PAYMENT_RECEIVED" : this.isPaymentSent ? "PAYMENT_SENT" : "DEPOSITS_UNLOCKED"; } + hasBuyerAsTakerWithoutDeposit() { + return this.getBuyer() === this.getTaker() && this.buyerAsTakerWithoutDeposit; + } + static init(ctxP: Partial | undefined): TradeContext { let ctx = ctxP instanceof TradeContext ? ctxP : new TradeContext(ctxP); if (!ctx.offerAmount && ctx.tradeAmount) ctx.offerAmount = ctx.tradeAmount; @@ -487,7 +496,7 @@ interface TxContext { } // track started haveno processes -const HAVENO_PROCESSES: HavenoClient[] = []; +const HAVENO_CLIENTS: HavenoClient[] = []; const HAVENO_PROCESS_PORTS: string[] = []; const HAVENO_WALLETS: Map = new Map(); @@ -506,6 +515,10 @@ function isGitHubActions() { jest.setTimeout(TestConfig.testTimeout); +beforeEach(async () => { + HavenoUtils.log(0, "BEFORE TEST \"" + expect.getState().currentTestName + "\""); +}); + beforeAll(async () => { try { @@ -532,11 +545,15 @@ beforeAll(async () => { } // start configured haveno daemons + const startupHavenods: HavenoClient[] = []; 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); + if (settledPromise.status === "fulfilled") { + startupHavenods.push((settledPromise as PromiseFulfilledResult).value); + startupHavenodUrls.push(startupHavenods[startupHavenods.length - 1].getUrl()); + } else if (!err) err = new Error((settledPromise as PromiseRejectedResult).reason); } if (err) throw err; @@ -564,8 +581,27 @@ beforeAll(async () => { } }); -beforeEach(async () => { - HavenoUtils.log(0, "BEFORE TEST \"" + expect.getState().currentTestName + "\""); +afterEach(async () => { + + // collect unreleased clients + const unreleasedClients: HavenoClient[] = []; + for (const havenod of HAVENO_CLIENTS.slice()) { + if (!moneroTs.GenUtils.arrayContains(startupHavenodUrls, havenod.getUrl())) { + unreleasedClients.push(havenod); + } + } + + // release clients + if (unreleasedClients.length > 0) { + const promises: Promise[] = []; + HavenoUtils.log(0, unreleasedClients.length + " Haveno clients were not released after test \"" + expect.getState().currentTestName + "\", releasing..."); + for (const client of unreleasedClients) { + HavenoUtils.log(0, "\tUnreleased Haveno client: " + client.getUrl()); + promises.push(releaseHavenoClient(client)); + } + await Promise.all(promises); + HavenoUtils.log(0, "Done releasing " + unreleasedClients.length + " unreleased Haveno clients"); + } }); afterAll(async () => { @@ -574,10 +610,10 @@ afterAll(async () => { async function shutDown() { - // release haveno processes + // release all clients const promises: Promise[] = []; - for (const havenod of startupHavenods) { - promises.push(havenod.getProcess() ? releaseHavenoProcess(havenod) : havenod.disconnect()); + for (const client of HAVENO_CLIENTS.slice()) { + promises.push(releaseHavenoClient(client)); } await Promise.all(promises); @@ -1221,7 +1257,7 @@ test("Can register as an arbitrator (CI)", async () => { 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); + for (const offer of offers) testOffer(offer); } }); @@ -1235,7 +1271,7 @@ test("Can get my offers (CI)", async () => { for (const assetCode of TestConfig.assetCodes) { const offers: OfferInfo[] = await user1.getMyOffers(assetCode); for (const offer of offers) { - testOffer(offer); + testOffer(offer, undefined, true); expect(assetCode).toEqual(isCrypto(assetCode) ? offer.getBaseCurrencyCode() : offer.getCounterCurrencyCode()); // crypto asset codes are base } } @@ -1659,6 +1695,24 @@ test("Cannot post offer exceeding trade limit (CI, sanity check)", async () => { if (err.message.indexOf("amount is larger than") < 0) throw err; } + // test posting sell offer above limit without buyer deposit + try { + await executeTrade({ + offerAmount: moneroTs.MoneroUtils.xmrToAtomicUnits(1.1), // limit is 1 xmr without deposit or fee + offerMinAmount: moneroTs.MoneroUtils.xmrToAtomicUnits(0.25), + direction: OfferDirection.SELL, + assetCode: assetCode, + makerPaymentAccountId: account.getId(), + isPrivateOffer: true, + buyerAsTakerWithoutDeposit: true, + takeOffer: false, + price: 142.23 + }); + throw new Error("Should have rejected posting offer above trade limit") + } catch (err: any) { + if (err.message.indexOf("amount is larger than") < 0) throw err; + } + // test that sell limit is higher than buy limit let offerId = await executeTrade({ offerAmount: 2100000000000n, @@ -1670,7 +1724,7 @@ test("Cannot post offer exceeding trade limit (CI, sanity check)", async () => { await user1.removeOffer(offerId); }); -test("Can complete a trade within a range", async () => { +test("Can complete a trade within a range and without a buyer deposit", async () => { // create payment accounts let paymentMethodId = "cash_at_atm"; @@ -1682,7 +1736,7 @@ test("Can complete a trade within a range", async () => { const tradeStatisticsPre = await arbitrator.getTradeStatistics(); // execute trade - const offerAmount = HavenoUtils.xmrToAtomicUnits(2); + const offerAmount = HavenoUtils.xmrToAtomicUnits(1); const offerMinAmount = HavenoUtils.xmrToAtomicUnits(.15); const tradeAmount = getRandomBigIntWithinRange(offerMinAmount, offerAmount); const ctx: Partial = { @@ -1693,9 +1747,11 @@ test("Can complete a trade within a range", async () => { testPayoutUnlocked: true, // override to test unlock makerPaymentAccountId: makerPaymentAccount.getId(), takerPaymentAccountId: takerPaymentAccount.getId(), - direction: OfferDirection.SELL, assetCode: assetCode, - testBalanceChangeEndToEnd: true + testBalanceChangeEndToEnd: true, + direction: OfferDirection.SELL, + isPrivateOffer: true, + buyerAsTakerWithoutDeposit: true } await executeTrade(ctx); @@ -1751,7 +1807,7 @@ test("Can complete all trade combinations (stress)", async () => { 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)); + ctxs.push(new TradeContext(Object.assign({}, new TradeContext(TestConfig.trade), ctx))); } } } @@ -1760,7 +1816,12 @@ test("Can complete all trade combinations (stress)", async () => { // execute trades const ctxIdx = undefined; // run single index for debugging - if (ctxIdx !== undefined) ctxs = ctxs.slice(ctxIdx, ctxIdx + 1); + if (ctxIdx !== undefined) { + ctxs = ctxs.slice(ctxIdx, ctxIdx + 1); + HavenoUtils.log(0, "Executing single trade configuration"); + console.log(ctxs[0]); + console.log(await ctxs[0].toSummary()); + } HavenoUtils.log(0, "Executing " + ctxs.length + " trade configurations"); await executeTrades(ctxs); }); @@ -1910,7 +1971,7 @@ test("Can go offline while resolving a dispute (CI)", async () => { buyerOfflineAfterTake: true, sellerOfflineAfterDisputeOpened: true, buyerOfflineAfterDisputeOpened: false, - sellerDisputeContext: DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK, + buyerDisputeContext: DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK, disputeWinner: DisputeResult.Winner.SELLER, disputeReason: DisputeResult.Reason.NO_REPLY, disputeSummary: "Seller wins dispute because buyer has not replied", @@ -2445,6 +2506,7 @@ async function executeTrade(ctxP: Partial): Promise { ctx.buyerAppName = ctx.getBuyer().havenod!.getAppName(); if (ctx.buyerOfflineAfterTake) { HavenoUtils.log(0, "Buyer going offline"); + assertNotStaticClient(ctx.getBuyer().havenod!); promises.push(releaseHavenoProcess(ctx.getBuyer().havenod!)); if (ctx.isBuyerMaker()) ctx.maker.havenod = undefined; else ctx.taker.havenod = undefined; @@ -2452,6 +2514,7 @@ async function executeTrade(ctxP: Partial): Promise { ctx.sellerAppName = ctx.getSeller().havenod!.getAppName(); if (ctx.sellerOfflineAfterTake) { HavenoUtils.log(0, "Seller going offline"); + assertNotStaticClient(ctx.getSeller().havenod!); promises.push(releaseHavenoProcess(ctx.getSeller().havenod!)); if (ctx.isBuyerMaker()) ctx.taker.havenod = undefined; else ctx.maker.havenod = undefined; @@ -2460,7 +2523,8 @@ async function executeTrade(ctxP: Partial): Promise { // wait for deposit txs to unlock if (ctx.isStopped) return ctx.offerId!; - await waitForUnlockedTxs(trade.getMakerDepositTxId(), trade.getTakerDepositTxId()); + if (ctx.hasBuyerAsTakerWithoutDeposit()) await waitForUnlockedTxs(trade.getMakerDepositTxId()); + else await waitForUnlockedTxs(trade.getMakerDepositTxId(), trade.getTakerDepositTxId()); // buyer comes online if offline and used if (ctx.isStopped) return ctx.offerId!; @@ -2554,6 +2618,7 @@ async function executeTrade(ctxP: Partial): Promise { if (ctx.isStopped) return ctx.offerId!; if (ctx.buyerOfflineAfterPaymentSent) { HavenoUtils.log(0, "Buyer going offline"); + assertNotStaticClient(ctx.getBuyer().havenod!); await releaseHavenoProcess(ctx.getBuyer().havenod!); if (ctx.isBuyerMaker()) ctx.maker.havenod = undefined; else ctx.taker.havenod = undefined; @@ -2757,8 +2822,10 @@ async function makeOffer(ctxP?: Partial): Promise { ctx.priceMargin, ctx.triggerPrice, ctx.offerMinAmount, - ctx.reserveExactAmount); - testOffer(offer, ctx); + ctx.reserveExactAmount, + ctx.isPrivateOffer, + ctx.buyerAsTakerWithoutDeposit); + testOffer(offer, ctx, true); // offer is included in my offers only if (!getOffer(await ctx.maker.havenod!.getMyOffers(ctx.assetCode!, ctx.direction), offer.getId())) { @@ -2771,6 +2838,7 @@ async function makeOffer(ctxP?: Partial): Promise { // collect context ctx.maker.splitOutputTxFee = BigInt(offer.getSplitOutputTxFee()); ctx.taker.splitOutputTxFee = 0n; + ctx.challenge = offer.getChallenge(); // 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 @@ -2835,12 +2903,12 @@ async function takeOffer(ctxP: Partial): Promise { 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); + const takerTrade = await ctx.taker.havenod!.takeOffer(ctx.offerId, ctx.takerPaymentAccountId!, ctx.tradeAmount, ctx.challenge); 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()); + const tradeNotifications = getNotifications(makerNotifications, NotificationMessage.NotificationType.TRADE_UPDATE, takerTrade.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"); @@ -2852,33 +2920,35 @@ async function takeOffer(ctxP: Partial): Promise { // 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.hasBuyerAsTakerWithoutDeposit()) assert(!ctx.arbitrator.trade!.getTakerDepositTxId()); + else ctx.taker.depositTx = await monerod.getTx(ctx.arbitrator.trade!.getTakerDepositTxId()); + if (!ctx.maker.depositTx || (!ctx.taker.depositTx && !ctx.hasBuyerAsTakerWithoutDeposit())) { 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..."); + if (!ctx.taker.depositTx && !ctx.hasBuyerAsTakerWithoutDeposit()) 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.hasBuyerAsTakerWithoutDeposit()) 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()); + if (!ctx.taker.depositTx && !ctx.hasBuyerAsTakerWithoutDeposit()) 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.tradeAmount = BigInt(takerTrade.getAmount()); // reassign 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()!); + ctx.taker.depositTxFee = BigInt(ctx.hasBuyerAsTakerWithoutDeposit() ? 0 : ctx.taker.depositTx!.getFee()); + ctx.maker.tradeFee = BigInt(takerTrade.getMakerFee()); + ctx.taker.tradeFee = BigInt(takerTrade.getTakerFee()); + if (ctx.hasBuyerAsTakerWithoutDeposit()) assert.equal(ctx.taker.depositTxFee, 0n); + ctx.getBuyer().securityDepositActual = BigInt(takerTrade.getBuyerSecurityDeposit()!); + ctx.getSeller().securityDepositActual = BigInt(takerTrade.getSellerSecurityDeposit()!); } // test trade model - await testTrade(trade, ctx); + await testTrade(takerTrade, ctx); // test buyer and seller balances after offer taken if (!ctx.concurrentTrades) { @@ -2886,14 +2956,14 @@ async function takeOffer(ctxP: Partial): Promise { // test buyer balances after offer taken const buyerBalanceDiffReservedTrade = BigInt(ctx.getBuyer().balancesAfterTake!.getReservedTradeBalance()) - BigInt(ctx.getBuyer().balancesBeforeTake!.getReservedTradeBalance()); - expect(buyerBalanceDiffReservedTrade).toEqual(BigInt(trade.getBuyerSecurityDeposit()!)); + expect(buyerBalanceDiffReservedTrade).toEqual(BigInt(takerTrade.getBuyerSecurityDeposit()!)); const buyerBalanceDiffReservedOffer = BigInt(ctx.getBuyer().balancesAfterTake!.getReservedOfferBalance()) - BigInt(ctx.getBuyer().balancesBeforeTake!.getReservedOfferBalance()); const buyerBalanceDiff = BigInt(ctx.getBuyer().balancesAfterTake!.getBalance()) - BigInt(ctx.getBuyer().balancesBeforeTake!.getBalance()); expect(buyerBalanceDiff).toEqual(-1n * buyerBalanceDiffReservedOffer - buyerBalanceDiffReservedTrade - ctx.getBuyer().depositTxFee! - ctx.getBuyer().tradeFee!); // test seller balances after offer taken const sellerBalanceDiffReservedTrade = BigInt(ctx.getSeller().balancesAfterTake!.getReservedTradeBalance()) - BigInt(ctx.getSeller().balancesBeforeTake!.getReservedTradeBalance()); - expect(sellerBalanceDiffReservedTrade).toEqual(BigInt(trade.getAmount()) + BigInt(trade.getSellerSecurityDeposit()!)); + expect(sellerBalanceDiffReservedTrade).toEqual(BigInt(takerTrade.getAmount()) + BigInt(takerTrade.getSellerSecurityDeposit()!)); const sellerBalanceDiffReservedOffer = BigInt(ctx.getSeller().balancesAfterTake!.getReservedOfferBalance()) - BigInt(ctx.getSeller().balancesBeforeTake!.getReservedOfferBalance()); const sellerBalanceDiff = BigInt(ctx.getSeller().balancesAfterTake!.getBalance()) - BigInt(ctx.getSeller().balancesBeforeTake!.getBalance()); expect(sellerBalanceDiff).toEqual(-1n * sellerBalanceDiffReservedOffer - sellerBalanceDiffReservedTrade - ctx.getSeller().depositTxFee! - ctx.getSeller().tradeFee!); @@ -2912,25 +2982,25 @@ async function takeOffer(ctxP: Partial): Promise { // 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())); + if (takerTrade.getOffer()!.getUseMarketBasedPrice()) { + assert.equal(ctx.tradeAmount, BigInt(takerTrade.getAmount())); } else { - expect(Math.abs(HavenoUtils.percentageDiff(ctx.tradeAmount!, BigInt(trade.getAmount())))).toBeLessThan(TestConfig.maxAdjustmentPct); + expect(Math.abs(HavenoUtils.percentageDiff(ctx.tradeAmount!, BigInt(takerTrade.getAmount())))).toBeLessThan(TestConfig.maxAdjustmentPct); } // maker is notified of balance change // taker is notified of balance change - return trade; + return takerTrade; } 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) + // test security deposit = max(0.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.getBuyerSecurityDeposit())).toEqual(ctx.hasBuyerAsTakerWithoutDeposit() ? 0n : expectedSecurityDeposit - ctx.getBuyer().depositTxFee!); expect(BigInt(trade.getSellerSecurityDeposit())).toEqual(expectedSecurityDeposit - ctx.getSeller().depositTxFee!); // test phase @@ -3138,8 +3208,6 @@ async function resolveDispute(ctxP: Partial) { } // 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); @@ -3235,16 +3303,16 @@ async function testAmountsAfterComplete(tradeCtx: TradeContext) { } else { // get expected payouts for disputed trade - const winnerGetsAll = tradeCtx.disputeWinnerAmount === tradeCtx.maker.securityDepositActual! + tradeCtx.taker.securityDepositActual! + tradeCtx.tradeAmount!; + const winnerGetsAll = tradeCtx.disputeWinnerAmount === tradeCtx.maker.securityDepositActual! + tradeCtx.taker.securityDepositActual! + tradeCtx.tradeAmount! || (tradeCtx.hasBuyerAsTakerWithoutDeposit() && tradeCtx.disputeWinner === DisputeResult.Winner.SELLER && tradeCtx.disputeWinnerAmount === undefined); 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()!.payoutTxFee = winnerGetsAll ? payoutTxFee : payoutTxFee / 2n; tradeCtx.getDisputeWinner()!.payoutAmount = tradeCtx.tradeAmount! + tradeCtx.getDisputeWinner()!.securityDepositActual! - tradeCtx.getDisputeWinner()!.payoutTxFee!; - tradeCtx.getDisputeLoser()!.payoutTxFee = payoutTxFee / 2n; + tradeCtx.getDisputeLoser()!.payoutTxFee = winnerGetsAll ? 0n : payoutTxFee / 2n; tradeCtx.getDisputeLoser()!.payoutAmount = tradeCtx.getDisputeLoser()!.securityDepositActual! - tradeCtx.getDisputeLoser()!.payoutTxFee!; } } @@ -3457,18 +3525,20 @@ async function initHaveno(ctx?: HavenodContext): Promise { "--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); } + // add to list of clients + HAVENO_CLIENTS.push(havenod) + // open account if configured if (ctx.autoLogin) { try { await initHavenoAccount(havenod, ctx.accountPassword!); } catch (err) { - await releaseHavenoProcess(havenod); + await releaseHavenoClient(havenod); throw err; } } @@ -3487,11 +3557,21 @@ async function initHaveno(ctx?: HavenodContext): Promise { } } +/** + * Release a Haveno client by shutting down its process or disconnecting. + */ +async function releaseHavenoClient(client: HavenoClient, deleteProcessAppDir?: boolean) { + if (client.getProcess()) return releaseHavenoProcess(client, deleteProcessAppDir); + else await client.disconnect(); +} + /** * Release a Haveno process for reuse and try to shutdown. */ async function releaseHavenoProcess(havenod: HavenoClient, deleteAppDir?: boolean) { - moneroTs.GenUtils.remove(HAVENO_PROCESSES, havenod); + if (!testsOwnProcess(havenod)) throw new Error("Cannot shut down havenod process which is not owned by test"); + if (!moneroTs.GenUtils.arrayContains(HAVENO_CLIENTS, havenod)) throw new Error("Cannot release Haveno client which is not in list of clients"); + moneroTs.GenUtils.remove(HAVENO_CLIENTS, havenod); moneroTs.GenUtils.remove(HAVENO_PROCESS_PORTS, getPort(havenod.getUrl())); try { await havenod.shutdownServer(); @@ -3501,6 +3581,14 @@ async function releaseHavenoProcess(havenod: HavenoClient, deleteAppDir?: boolea if (deleteAppDir) deleteHavenoInstance(havenod); } +function testsOwnProcess(havenod: HavenoClient) { + return havenod.getProcess(); +} + +function assertNotStaticClient(client: HavenoClient) { + if (client === user1 || client === user2 || client === arbitrator) throw new Error("Tests are not designed to shut down user1, user2, or arbitrator") +} + /** * Delete a Haveno instance from disk. */ @@ -4007,13 +4095,26 @@ function testCryptoPaymentAccountsEqual(acct1: PaymentAccount, acct2: PaymentAcc expect(acct1.getPaymentAccountPayload()!.getCryptoCurrencyAccountPayload()!.getAddress()).toEqual(acct2.getPaymentAccountPayload()!.getCryptoCurrencyAccountPayload()!.getAddress()); } -function testOffer(offer: OfferInfo, ctx?: Partial) { +function testOffer(offer: OfferInfo, ctxP?: Partial, isMyOffer?: boolean) { + let ctx = TradeContext.init(ctxP); expect(offer.getId().length).toBeGreaterThan(0); if (ctx) { - expect(offer.getBuyerSecurityDepositPct()).toEqual(ctx?.securityDepositPct); - expect(offer.getSellerSecurityDepositPct()).toEqual(ctx?.securityDepositPct); + expect(offer.getIsPrivateOffer()).toEqual(ctx?.isPrivateOffer ? true : false); // TODO: update tests for security deposit + if (offer.getIsPrivateOffer()) { + if (isMyOffer) expect(offer.getChallenge().length).toBeGreaterThan(0); + else expect(offer.getChallenge()).toEqual(""); + if (ctx.isBuyerMaker()) { + expect(offer.getBuyerSecurityDepositPct()).toEqual(ctx.securityDepositPct); + } else { + expect(offer.getBuyerSecurityDepositPct()).toEqual(ctx.buyerAsTakerWithoutDeposit ? 0 : ctx.securityDepositPct); + } + } else { + expect(offer.getBuyerSecurityDepositPct()).toEqual(ctx.securityDepositPct); + expect(offer.getChallenge()).toEqual(""); + } + expect(offer.getSellerSecurityDepositPct()).toEqual(ctx.securityDepositPct); expect(offer.getUseMarketBasedPrice()).toEqual(!ctx?.price); - expect(offer.getMarketPriceMarginPct()).toEqual(ctx?.priceMargin ? ctx?.priceMargin : 0); + expect(offer.getMarketPriceMarginPct()).toEqual(ctx?.priceMargin ? ctx.priceMargin : 0); // TODO: test rest of offer } diff --git a/src/HavenoClient.ts b/src/HavenoClient.ts index 512258e8..6998ac3d 100644 --- a/src/HavenoClient.ts +++ b/src/HavenoClient.ts @@ -1046,6 +1046,8 @@ export default class HavenoClient { * @param {number} triggerPrice - price to remove offer (optional) * @param {bigint} minAmount - minimum amount to trade (optional, default to fixed amount) * @param {number} reserveExactAmount - reserve exact amount needed for offer, incurring on-chain transaction and 10 confirmations before the offer goes live (default = false) + * @param {boolean} isPrivateOffer - whether the offer is private (default = false) + * @param {boolean} buyerAsTakerWithoutDeposit - waive buyer as taker deposit and fee (default false) * @return {OfferInfo} the posted offer */ async postOffer(direction: OfferDirection, @@ -1057,7 +1059,9 @@ export default class HavenoClient { marketPriceMarginPct?: number, triggerPrice?: number, minAmount?: bigint, - reserveExactAmount?: boolean): Promise { + reserveExactAmount?: boolean, + isPrivateOffer?: boolean, + buyerAsTakerWithoutDeposit?: boolean): Promise { console.log("Posting offer with security deposit %: " + securityDepositPct) try { const request = new PostOfferRequest() @@ -1065,13 +1069,15 @@ export default class HavenoClient { .setAmount(amount.toString()) .setCurrencyCode(assetCode) .setPaymentAccountId(paymentAccountId) - .setBuyerSecurityDepositPct(securityDepositPct) + .setSecurityDepositPct(securityDepositPct) .setUseMarketBasedPrice(price === undefined) .setMinAmount(minAmount ? minAmount.toString() : amount.toString()); if (price) request.setPrice(price.toString()); if (marketPriceMarginPct) request.setMarketPriceMarginPct(marketPriceMarginPct); if (triggerPrice) request.setTriggerPrice(triggerPrice.toString()); - if (reserveExactAmount) request.setReserveExactAmount(reserveExactAmount); + if (reserveExactAmount) request.setReserveExactAmount(true); + if (isPrivateOffer) request.setIsPrivateOffer(true); + if (buyerAsTakerWithoutDeposit) request.setBuyerAsTakerWithoutDeposit(true); return (await this._offersClient.postOffer(request, {password: this._password})).getOffer()!; } catch (e: any) { throw new HavenoError(e.message, e.code); @@ -1097,16 +1103,19 @@ export default class HavenoClient { * @param {string} offerId - id of the offer to take * @param {string} paymentAccountId - id of the payment account * @param {bigint|undefined} amount - amount the taker chooses to buy or sell within the offer range (default is max offer amount) + * @param {string|undefined} challenge - the challenge to use for the private offer * @return {TradeInfo} the initialized trade */ async takeOffer(offerId: string, paymentAccountId: string, - amount?: bigint): Promise { + amount?: bigint, + challenge?: string): Promise { try { const request = new TakeOfferRequest() .setOfferId(offerId) .setPaymentAccountId(paymentAccountId); if (amount) request.setAmount(amount.toString()); + if (challenge) request.setChallenge(challenge); const resp = await this._tradesClient.takeOffer(request, {password: this._password}); if (resp.getTrade()) return resp.getTrade()!; throw new HavenoError(resp.getFailureReason()?.getDescription()!, resp.getFailureReason()?.getAvailabilityResult()); @@ -1319,7 +1328,7 @@ export default class HavenoClient { try { await this.disconnect(); await this._shutdownServerClient.stop(new StopRequest(), {password: this._password}); // process receives 'exit' event - if (this._process) return HavenoUtils.kill(this._process); + if (this._process) await HavenoUtils.kill(this._process); } catch (e: any) { throw new HavenoError(e.message, e.code); }