diff --git a/src/HavenoClient.test.ts b/src/HavenoClient.test.ts index 0e195444..5bacb89c 100644 --- a/src/HavenoClient.test.ts +++ b/src/HavenoClient.test.ts @@ -196,6 +196,7 @@ class TradeContext { isPrivateOffer?: boolean; buyerAsTakerWithoutDeposit?: boolean; // buyer as taker security deposit is optional for private offers extraInfo?: string; + sourceOfferId?: string; // take offer awaitFundsToTakeOffer?: boolean; @@ -1553,6 +1554,40 @@ test("Can post and remove an offer (Test, CI, sanity check)", async () => { 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"); }); +test("Can clone offers (Test, 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 offer + let assetCode = "BCH"; + let ctx: Partial = {maker: {havenod: user1}, isPrivateOffer: true, buyerAsTakerWithoutDeposit: true, assetCode: assetCode, extraInfo: "My extra info"}; + let offer: OfferInfo = await makeOffer(ctx);; + assert.equal(offer.getState(), "AVAILABLE"); + + // clone offer + const clonedOffer = await makeOffer({ + sourceOfferId: offer.getId(), + assetCode: "BCH" + }); + assert.notEqual(clonedOffer.getId(), offer.getId()); + assert.equal(clonedOffer.getState(), "DEACTIVATED"); // deactivated if same payment method and currency + assert.equal(clonedOffer.getBaseCurrencyCode(), assetCode); + assert.equal(clonedOffer.getCounterCurrencyCode(), "XMR"); + assert.equal(clonedOffer.getAmount(), offer.getAmount()); + assert.equal(clonedOffer.getMinAmount(), offer.getMinAmount()); + assert.equal(clonedOffer.getIsPrivateOffer(), offer.getIsPrivateOffer()); + + // TODO: test edited fields on clone, etc + + // remove offers + await user1.removeOffer(offer.getId()); + await user1.removeOffer(clonedOffer.getId()); +}); + // TODO: provide number of confirmations in offer status test("Can schedule offers with locked funds (Test, CI)", async () => { let user3: HavenoClient|undefined; @@ -2919,21 +2954,31 @@ async function makeOffer(ctxP?: Partial): Promise { 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, - ctx.isPrivateOffer, - ctx.buyerAsTakerWithoutDeposit, - ctx.extraInfo); + // post or clone offer + const offer: OfferInfo = await ctx.maker.havenod!.postOffer({ + direction: ctx.direction, + amount: ctx.offerAmount, + assetCode: ctx.assetCode, + paymentAccountId: ctx.makerPaymentAccountId, + securityDepositPct: ctx.securityDepositPct, + price: ctx.price, + marketPriceMarginPct: ctx.priceMargin, + triggerPrice: ctx.triggerPrice, + minAmount: ctx.offerMinAmount, + reserveExactAmount: ctx.reserveExactAmount, + isPrivateOffer: ctx.isPrivateOffer, + buyerAsTakerWithoutDeposit: ctx.buyerAsTakerWithoutDeposit, + extraInfo: ctx.extraInfo, + sourceOfferId: ctx.sourceOfferId + }); + + // transfer context from clone source + if (ctx.sourceOfferId) { + const sourceOffer = await ctx.maker.havenod!.getMyOffer(ctx.sourceOfferId); + ctx.isPrivateOffer = sourceOffer.getIsPrivateOffer(); + } + + // test offer testOffer(offer, ctx, true); // offer is included in my offers only @@ -2962,13 +3007,13 @@ async function makeOffer(ctxP?: Partial): Promise { 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) { + if (!ctx.sourceOfferId && 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 { + } else if (!ctx.sourceOfferId) { // cloned offers can be deactivated after creating throw new Error("Unexpected offer state after posting: " + offer.getState()); } diff --git a/src/HavenoClient.ts b/src/HavenoClient.ts index 77f78b2e..a1d8c947 100644 --- a/src/HavenoClient.ts +++ b/src/HavenoClient.ts @@ -24,6 +24,27 @@ import { GetTradeStatisticsClient, GetVersionClient, AccountClient, XmrConnectio import { GetTradeStatisticsRequest, GetTradeStatisticsReply, GetVersionRequest, GetVersionReply, IsAppInitializedRequest, IsAppInitializedReply, RegisterDisputeAgentRequest, UnregisterDisputeAgentRequest, MarketPriceRequest, MarketPriceReply, MarketPricesRequest, MarketPricesReply, MarketPriceInfo, MarketDepthRequest, MarketDepthReply, MarketDepthInfo, GetBalancesRequest, GetBalancesReply, XmrBalanceInfo, GetMyOfferRequest, GetMyOfferReply, GetOffersRequest, GetOffersReply, OfferInfo, GetPaymentMethodsRequest, GetPaymentMethodsReply, GetPaymentAccountFormRequest, CreatePaymentAccountRequest, ValidateFormFieldRequest, CreatePaymentAccountReply, GetPaymentAccountFormReply, GetPaymentAccountsRequest, GetPaymentAccountsReply, CreateCryptoCurrencyPaymentAccountRequest, CreateCryptoCurrencyPaymentAccountReply, DeletePaymentAccountRequest, DeletePaymentAccountReply, PostOfferRequest, PostOfferReply, CancelOfferRequest, TakeOfferRequest, TakeOfferReply, TradeInfo, GetTradeRequest, GetTradeReply, GetTradesRequest, GetTradesReply, GetXmrSeedRequest, GetXmrSeedReply, GetXmrPrimaryAddressRequest, GetXmrPrimaryAddressReply, GetXmrNewSubaddressRequest, GetXmrNewSubaddressReply, ConfirmPaymentSentRequest, ConfirmPaymentReceivedRequest, CompleteTradeRequest, XmrTx, GetXmrTxsRequest, GetXmrTxsReply, XmrDestination, CreateXmrTxRequest, CreateXmrTxReply, RelayXmrTxRequest, RelayXmrTxReply, CreateAccountRequest, AccountExistsRequest, AccountExistsReply, DeleteAccountRequest, OpenAccountRequest, IsAccountOpenRequest, IsAccountOpenReply, CloseAccountRequest, ChangePasswordRequest, BackupAccountRequest, BackupAccountReply, RestoreAccountRequest, StopRequest, NotificationMessage, RegisterNotificationListenerRequest, SendNotificationRequest, UrlConnection, AddConnectionRequest, RemoveConnectionRequest, GetConnectionRequest, GetConnectionsRequest, SetConnectionRequest, CheckConnectionRequest, CheckConnectionsReply, CheckConnectionsRequest, StartCheckingConnectionRequest, StopCheckingConnectionRequest, GetBestConnectionRequest, SetAutoSwitchRequest, GetAutoSwitchRequest, CheckConnectionReply, GetConnectionsReply, GetConnectionReply, GetBestConnectionReply, GetDisputeRequest, GetDisputeReply, GetDisputesRequest, GetDisputesReply, OpenDisputeRequest, ResolveDisputeRequest, SendDisputeChatMessageRequest, SendChatMessageRequest, GetChatMessagesRequest, GetChatMessagesReply, StartXmrNodeRequest, StopXmrNodeRequest, IsXmrNodeOnlineRequest, IsXmrNodeOnlineReply, GetXmrNodeSettingsRequest, GetXmrNodeSettingsReply } from "./protobuf/grpc_pb"; import { TradeStatistics3, OfferDirection, PaymentMethod, PaymentAccountForm, PaymentAccountFormField, PaymentAccount, PaymentAccountPayload, AvailabilityResult, Attachment, DisputeResult, Dispute, ChatMessage, XmrNodeSettings } from "./protobuf/pb_pb"; + +/** + * Configuration to post, clone, or edit an offer. + */ +export interface OfferConfig { + direction?: OfferDirection; + amount?: bigint; + minAmount?: bigint; + assetCode?: string; + paymentAccountId?: string; + securityDepositPct?: number; + price?: number; + marketPriceMarginPct?: number; + triggerPrice?: number; + reserveExactAmount?: boolean; + isPrivateOffer?: boolean; + buyerAsTakerWithoutDeposit?: boolean; + extraInfo?: string; + sourceOfferId?: string; +} + /** * Haveno daemon client. */ @@ -1035,53 +1056,44 @@ export default class HavenoClient { } /** - * Post an offer. + * Post or clone an offer. * - * @param {OfferDirection} direction - "buy" or "sell" XMR - * @param {bigint} amount - amount of XMR to trade - * @param {string} assetCode - asset code to trade for XMR - * @param {string} paymentAccountId - payment account id - * @param {number} securityDepositPct - security deposit as % of trade amount for buyer and seller - * @param {number} price - trade price (optional, default to market price) - * @param {number} marketPriceMarginPct - if using market price, % from market price to accept (optional, default 0%) - * @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) - * @param {string} extraInfo - extra information to include with the offer (optional) + * @param {OfferConfig} config - configures the offer to post or clone + * @param {OfferDirection} [config.direction] - specifies to buy or sell xmr (default buy) + * @param {bigint} [config.amount] - amount of XMR to trade + * @param {string} [config.assetCode] - asset code to trade for XMR + * @param {string} [config.paymentAccountId] - payment account id + * @param {number} [config.securityDepositPct] - security deposit as % of trade amount for buyer and seller + * @param {number} [config.price] - trade price (optional, default to market price) + * @param {number} [config.marketPriceMarginPct] - if using market price, % from market price to accept (optional, default 0%) + * @param {number} [config.triggerPrice] - price to remove offer (optional) + * @param {bigint} [config.minAmount] - minimum amount to trade (optional, default to fixed amount) + * @param {number} [config.reserveExactAmount] - reserve exact amount needed for offer, incurring on-chain transaction and 10 confirmations before the offer goes live (default = false) + * @param {boolean} [config.isPrivateOffer] - whether the offer is private (default = false) + * @param {boolean} [config.buyerAsTakerWithoutDeposit] - waive buyer as taker deposit and fee (default false) + * @param {string} [config.extraInfo] - extra information to include with the offer (optional) + * @param {string} [config.sourceOfferId] - create a clone of a source offer which shares the same reserved funds. overrides other fields which are immutable or unspecified (optional) * @return {OfferInfo} the posted offer */ - async postOffer(direction: OfferDirection, - amount: bigint, - assetCode: string, - paymentAccountId: string, - securityDepositPct: number, - price?: number, - marketPriceMarginPct?: number, - triggerPrice?: number, - minAmount?: bigint, - reserveExactAmount?: boolean, - isPrivateOffer?: boolean, - buyerAsTakerWithoutDeposit?: boolean, - extraInfo?: string): Promise { - console.log("Posting offer with security deposit %: " + securityDepositPct) + async postOffer(config: OfferConfig): Promise { + console.log("Posting offer with security deposit %: " + config.securityDepositPct) try { - const request = new PostOfferRequest() - .setDirection(direction === OfferDirection.BUY ? "buy" : "sell") - .setAmount(amount.toString()) - .setCurrencyCode(assetCode) - .setPaymentAccountId(paymentAccountId) - .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(true); - if (isPrivateOffer) request.setIsPrivateOffer(true); - if (buyerAsTakerWithoutDeposit) request.setBuyerAsTakerWithoutDeposit(true); - if (extraInfo) request.setExtraInfo(extraInfo); + const request = new PostOfferRequest(); + if (config.direction) request.setDirection(config.direction === OfferDirection.BUY ? "buy" : "sell"); + if (config.amount) request.setAmount(config.amount.toString()); + request.setMinAmount(config.minAmount ? config.minAmount.toString() : config.amount!.toString()); + if (config.assetCode) request.setCurrencyCode(config.assetCode); + if (config.paymentAccountId) request.setPaymentAccountId(config.paymentAccountId); + if (config.securityDepositPct) request.setSecurityDepositPct(config.securityDepositPct); + request.setUseMarketBasedPrice(config.price === undefined); + if (config.price) request.setPrice(config.price?.toString()) + if (config.marketPriceMarginPct) request.setMarketPriceMarginPct(config.marketPriceMarginPct); + if (config.triggerPrice) request.setTriggerPrice(config.triggerPrice.toString()); + if (config.reserveExactAmount) request.setReserveExactAmount(true); + if (config.isPrivateOffer) request.setIsPrivateOffer(true); + if (config.buyerAsTakerWithoutDeposit) request.setBuyerAsTakerWithoutDeposit(true); + if (config.extraInfo) request.setExtraInfo(config.extraInfo); + if (config.sourceOfferId) request.setSourceOfferId(config.sourceOfferId); return (await this._offersClient.postOffer(request, {password: this._password})).getOffer()!; } catch (e: any) { throw new HavenoError(e.message, e.code);