haveno-ts/src/HavenoClient.test.ts

4385 lines
206 KiB
TypeScript
Raw Permalink Normal View History

2024-03-24 07:47:07 -04:00
/*
* 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 ----------------------------------
2022-02-11 17:13:56 -06:00
// haveno imports
import {
HavenoClient,
HavenoError,
HavenoUtils,
OfferDirection,
MarketPriceInfo,
NotificationMessage,
OfferInfo,
TradeInfo,
UrlConnection,
XmrBalanceInfo,
Attachment,
DisputeResult,
PaymentMethod,
2024-08-29 11:32:14 -04:00
PaymentAccount,
PaymentAccountForm,
PaymentAccountFormField,
2024-08-29 11:32:14 -04:00
PaymentAccountPayload,
XmrDestination,
XmrNodeSettings,
XmrTx,
XmrIncomingTransfer,
XmrOutgoingTransfer,
} from "./index";
import AuthenticationStatus = UrlConnection.AuthenticationStatus;
import OnlineStatus = UrlConnection.OnlineStatus;
// other imports
2022-05-01 13:30:11 -04:00
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
2024-01-27 17:36:42 -05:00
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 {
2024-08-29 11:32:14 -04:00
havenod?: HavenoClient;
wallet?: moneroTs.MoneroWallet;
trade?: TradeInfo;
// context to test balances after trade
2024-08-29 11:32:14 -04:00
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<PeerContext>) {
Object.assign(this, ctx);
}
}
/**
* Default trade configuration.
*/
const defaultTradeConfig: Partial<TradeContext> = {
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,
2024-04-26 15:00:01 -04:00
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
2024-08-29 11:32:14 -04:00
arbitrator!: Partial<PeerContext>;
maker!: Partial<PeerContext>;
taker!: Partial<PeerContext>;
// 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;
2024-08-29 11:32:14 -04:00
walletSyncPeriodMs!: number;
maxTimePeerNoticeMs!: number;
testChatMessages!: boolean;
stopOnFailure?: boolean;
buyerAppName?: string;
sellerAppName?: string;
usedPorts?: string[];
testPayoutConfirmed?: boolean;
testPayoutUnlocked?: boolean;
payoutTxId?: string
testBalanceChangeEndToEnd?: boolean;
2024-08-29 11:32:14 -04:00
isStopped!: boolean;
maxConcurrency!: number;
constructor(ctx?: Partial<TradeContext>) {
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<TradeContext> | 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<string> {
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) {
2024-08-16 12:01:41 -04:00
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()!;
2024-08-06 05:10:08 -04:00
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,
2022-01-24 19:37:18 +01:00
moneroBinsDir: "../haveno/.localnet",
testDataDir: "./testdata",
haveno: {
path: "../haveno",
2024-09-04 08:40:40 -04:00
version: "1.0.11"
},
monerod: {
2024-01-17 11:22:12 -05:00
url: "http://localhost:" + getNetworkStartPort() + "8081", // 18081, 28081, 38081 for mainnet, testnet, and stagenet, respectively
username: "",
password: ""
2022-01-24 19:37:18 +01:00
},
2024-01-17 11:22:12 -05:00
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",
2022-07-30 17:11:40 -04:00
walletPassword: "abc123",
defaultPath: "funding_wallet-" + getBaseCurrencyNetwork(),
minimumFunding: 5000000000000n,
2023-07-25 09:14:04 -04:00
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)
2023-01-18 10:11:47 -05:00
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,
2022-12-03 13:03:44 +00:00
port: "8079",
accountPasswordRequired: false,
accountPassword: "abctesting123",
}, {
appName: "haveno-" + getBaseCurrencyNetwork() + "_user1", // user1
logProcessOutput: true,
2022-12-03 13:03:44 +00:00
port: "8080",
accountPasswordRequired: false,
accountPassword: "abctesting456",
walletUrl: "http://127.0.0.1:38091",
}, {
appName: "haveno-" + getBaseCurrencyNetwork() + "_user2", // user2
logProcessOutput: true,
2022-12-03 13:03:44 +00:00
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,
2023-02-27 12:57:06 -05:00
daemonPollPeriodMs: 5000,
maxWalletStartupMs: 10000, // TODO (woodser): make shorter by switching to jni
maxCpuPct: 0.25,
paymentMethods: Object.keys(PaymentAccountForm.FormId), // all supported payment methods
2022-08-17 13:26:24 -04:00
assetCodes: ["USD", "GBP", "EUR", "ETH", "BTC", "BCH", "LTC"], // primary asset codes
fixedPriceAssetCodes: ["XAG", "XAU", "XGB"],
2022-02-16 10:09:59 -05:00
cryptoAddresses: [{
currencyCode: "ETH",
address: "0xdBdAb835Acd6fC84cF5F9aDD3c0B5a1E25fbd99f"
}, {
currencyCode: "BTC",
address: "1G457efxTyci67msm2dSqyhFzxPYFWaghe"
2022-03-06 10:49:26 -05:00
}, {
currencyCode: "BCH",
2022-11-24 12:36:51 +00:00
address: "qz54ydhwzn25wzf8pge5s26udvtx33yhyq3lnv6vq6"
2022-02-16 11:59:31 -05:00
}, {
currencyCode: "LTC",
address: "LXUTUN5mTPc2LsS7cEjkyjTRcfYyJGoUuQ"
}
],
2022-12-03 13:03:44 +00:00
ports: new Map<string, string[]>([ // 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: []
},
2024-04-30 15:35:12 -04:00
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)
};
2022-12-03 13:03:44 +00:00
interface HavenodContext {
logProcessOutput?: boolean,
2023-01-18 10:11:47 -05:00
logLevel?: string,
2022-12-03 13:03:44 +00:00
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
2022-04-07 16:35:48 -04:00
const HAVENO_PROCESSES: HavenoClient[] = [];
const HAVENO_PROCESS_PORTS: string[] = [];
const HAVENO_WALLETS: Map<HavenoClient, any> = new Map<HavenoClient, any>();
// other config
const OFFLINE_ERR_MSG = "Http response at 400 or 500 level";
2024-04-27 10:33:46 -04:00
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());
2023-07-25 09:14:04 -04:00
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
2024-08-29 11:32:14 -04:00
} 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<HavenoClient>[] = [];
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<HavenoClient>).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<void>[] = [];
for (const havenod of startupHavenods) {
promises.push(havenod.getProcess() ? releaseHavenoProcess(havenod) : havenod.disconnect());
}
await Promise.all(promises);
2023-10-11 09:03:25 -04:00
// terminate monero-ts worker
await moneroTs.LibraryUtils.terminateWorker();
}
// ----------------------------------- TESTS ----------------------------------
test("Can get the version (CI)", async () => {
2022-05-01 13:30:11 -04:00
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());
2022-04-10 17:00:15 -04:00
// 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");
2022-05-01 13:30:11 -04:00
} 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
2022-05-01 13:30:11 -04:00
const zipFile = TestConfig.testDataDir + "/backup.zip";
const stream = fs.createWriteStream(zipFile);
const size = await user3.backupAccount(stream);
stream.end();
assert(size > 0);
2023-04-15 16:12:20 -04:00
// delete account and wait until connected
await user3.deleteAccount();
2023-04-15 16:12:20 -04:00
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());
2023-04-15 16:12:20 -04:00
HavenoUtils.log(1, "Reconnecting to havenod");
assert(!await user3.accountExists());
// restore account
2022-05-01 13:30:11 -04:00
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());
2022-04-10 17:00:15 -04:00
// check the persisted payment account
const paymentAccount2 = await user3.getPaymentAccount(paymentAccount.getId());
2022-04-10 17:00:15 -04:00
testCryptoPaymentAccountsEqual(paymentAccount, paymentAccount2);
} catch (err2) {
err = err2;
2022-01-09 17:02:43 +01:00
}
2022-01-24 19:37:18 +01:00
// stop and delete instances
if (user3) await releaseHavenoProcess(user3, true);
if (err) throw err;
2022-04-07 16:35:48 -04:00
async function testAccountNotOpen(havenod: HavenoClient): Promise<void> { // TODO: generalize this?
try { await havenod.getMoneroConnections(); throw new Error("Should have thrown"); }
2022-05-01 13:30:11 -04:00
catch (err: any) { assert.equal(err.message, "Account not open"); }
try { await havenod.getXmrTxs(); throw new Error("Should have thrown"); }
2022-05-01 13:30:11 -04:00
catch (err: any) { assert.equal(err.message, "Account not open"); }
try { await havenod.getBalances(); throw new Error("Should have thrown"); }
2022-05-01 13:30:11 -04:00
catch (err: any) { assert.equal(err.message, "Account not open"); }
2022-04-10 17:00:15 -04:00
try { await havenod.createCryptoPaymentAccount("My ETH account", TestConfig.cryptoAddresses[0].currencyCode, TestConfig.cryptoAddresses[0].address); throw new Error("Should have thrown"); }
2022-05-01 13:30:11 -04:00
catch (err: any) { assert.equal(err.message, "Account not open"); }
2022-01-09 17:02:43 +01:00
}
});
test("Can manage Monero daemon connections (CI)", async () => {
2024-01-17 11:22:12 -05:00
let monerod3: moneroTs.MoneroDaemonRpc | undefined = undefined;
let user3: HavenoClient|undefined;
2022-01-24 19:37:18 +01:00
let err: any;
try {
// start user3
user3 = await initHaveno();
2022-01-24 19:37:18 +01:00
// disable auto switch for tests
assert.equal(true, await user3.getAutoSwitch());
await user3.setAutoSwitch(false);
2022-01-24 19:37:18 +01:00
// 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);
2022-01-24 19:37:18 +01:00
// 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?
2022-01-24 19:37:18 +01:00
// add a new connection
2022-05-01 13:30:11 -04:00
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);
2022-01-24 19:37:18 +01:00
// set prioritized connection without credentials
await user3.setMoneroConnection(new UrlConnection()
2024-01-17 11:22:12 -05:00
.setUrl(TestConfig.monerod3.url)
2022-01-24 19:37:18 +01:00
.setPriority(1));
connection = await user3.getMoneroConnection();
2024-01-17 11:22:12 -05:00
testConnection(connection!, TestConfig.monerod3.url, undefined, undefined, 1); // status may or may not be known due to periodic connection checking
2022-01-24 19:37:18 +01:00
// connection is offline
connection = await user3.checkMoneroConnection();
assert(!await user3.isConnectedToMonero());
2024-01-17 11:22:12 -05:00
testConnection(connection!, TestConfig.monerod3.url, OnlineStatus.OFFLINE, AuthenticationStatus.NO_AUTHENTICATION, 1);
2022-01-24 19:37:18 +01:00
2024-01-17 11:22:12 -05:00
// start monerod3
2022-05-01 13:30:11 -04:00
const cmd = [
2022-01-24 19:37:18 +01:00
TestConfig.moneroBinsDir + "/monerod",
"--no-igd",
"--hide-my-port",
2024-01-17 11:22:12 -05:00
"--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"
2022-01-24 19:37:18 +01:00
];
if (getBaseCurrencyNetwork() !== BaseCurrencyNetwork.XMR_MAINNET) cmd.push("--" + moneroTs.MoneroNetworkType.toString(TestConfig.networkType).toLowerCase());
2024-01-17 11:22:12 -05:00
if (TestConfig.monerod3.username) cmd.push("--rpc-login", TestConfig.monerod3.username + ":" + TestConfig.monerod3.password);
monerod3 = await moneroTs.connectToDaemonRpc(cmd);
2022-01-24 19:37:18 +01:00
// connection is online and not authenticated
connection = await user3.checkMoneroConnection();
assert(!await user3.isConnectedToMonero());
2024-01-17 11:22:12 -05:00
testConnection(connection!, TestConfig.monerod3.url, OnlineStatus.ONLINE, AuthenticationStatus.NOT_AUTHENTICATED, 1);
2022-01-24 19:37:18 +01:00
// set connection credentials
await user3.setMoneroConnection(new UrlConnection()
2024-01-17 11:22:12 -05:00
.setUrl(TestConfig.monerod3.url)
.setUsername(TestConfig.monerod3.username)
.setPassword(TestConfig.monerod3.password)
2022-01-24 19:37:18 +01:00
.setPriority(1));
connection = await user3.getMoneroConnection();
2024-01-17 11:22:12 -05:00
testConnection(connection!, TestConfig.monerod3.url, undefined, undefined, 1);
2022-01-24 19:37:18 +01:00
// connection is online and authenticated
connection = await user3.checkMoneroConnection();
assert(await user3.isConnectedToMonero());
2024-01-17 11:22:12 -05:00
testConnection(connection!, TestConfig.monerod3.url, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1);
// change account password
const newPassword = "newPassword";
await user3.changePassword(TestConfig.defaultHavenod.accountPassword, newPassword);
2022-01-24 19:37:18 +01:00
// restart user3
const appName = user3.getAppName();
await releaseHavenoProcess(user3);
user3 = await initHaveno({appName: appName, accountPassword: newPassword});
2022-01-24 19:37:18 +01:00
// connection is restored, online, and authenticated
2024-04-23 10:44:13 -04:00
await user3.checkMoneroConnection();
connection = await user3.getMoneroConnection();
2024-01-17 11:22:12 -05:00
testConnection(connection!, TestConfig.monerod3.url, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1);
// priority connections are polled
2024-04-23 10:44:13 -04:00
await user3.checkMoneroConnections();
connections = await user3.getMoneroConnections();
testConnection(getConnection(connections, monerodUrl1)!, monerodUrl1, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1);
2022-01-24 19:37:18 +01:00
// enable auto switch
await user3.setAutoSwitch(true);
assert.equal(true, await user3.getAutoSwitch());
2022-01-24 19:37:18 +01:00
// stop monerod
2024-01-17 11:22:12 -05:00
//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");
2022-01-24 19:37:18 +01:00
// test auto switch after periodic connection check
await wait(TestConfig.daemonPollPeriodMs * 2);
2024-04-23 10:44:13 -04:00
await user3.checkMoneroConnection();
connection = await user3.getMoneroConnection();
testConnection(connection!, monerodUrl1, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1);
2022-01-24 19:37:18 +01:00
// stop auto switch and checking connection periodically
await user3.setAutoSwitch(false);
assert.equal(false, await user3.getAutoSwitch());
await user3.stopCheckingConnection();
2022-01-24 19:37:18 +01:00
// remove current connection
await user3.removeMoneroConnection(monerodUrl1);
2022-01-24 19:37:18 +01:00
// check current connection
connection = await user3.checkMoneroConnection();
assert.equal(connection, undefined);
2022-01-24 19:37:18 +01:00
// check all connections
await user3.checkMoneroConnections();
connections = await user3.getMoneroConnections();
testConnection(getConnection(connections, fooBarUrl)!, fooBarUrl, OnlineStatus.OFFLINE, AuthenticationStatus.NO_AUTHENTICATION, 0);
2022-01-24 19:37:18 +01:00
// set connection to previous url
await user3.setMoneroConnection(fooBarUrl);
connection = await user3.getMoneroConnection();
testConnection(connection!, fooBarUrl, OnlineStatus.OFFLINE, AuthenticationStatus.NO_AUTHENTICATION, 0);
2022-01-24 19:37:18 +01:00
// set connection to new url
2022-05-01 13:30:11 -04:00
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);
2022-01-24 19:37:18 +01:00
// reset connection
await user3.setMoneroConnection();
assert.equal(await user3.getMoneroConnection(), undefined);
2022-01-24 19:37:18 +01:00
// 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)
2022-01-24 19:37:18 +01:00
.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);
2022-01-24 19:37:18 +01:00
} catch (err2) {
err = err2;
}
2021-11-19 23:29:44 +01:00
2022-01-24 19:37:18 +01:00
// stop processes
if (user3) await releaseHavenoProcess(user3, true);
2024-01-17 11:22:12 -05:00
if (monerod3) await monerod3.stopProcess();
2022-01-24 19:37:18 +01:00
if (err) throw err;
});
2024-01-17 11:22:12 -05:00
// 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() => {
2024-10-12 06:35:15 -04:00
// 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");
2022-05-01 13:30:11 -04:00
} 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);
}
}
2024-10-12 06:35:15 -04:00
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");
2022-05-01 13:30:11 -04:00
} 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");
2022-05-01 13:30:11 -04:00
} 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();
2024-01-17 11:22:12 -05:00
const dataDir = TestConfig.moneroBinsDir + "/" + TestConfig.baseCurrencyNetwork.toLowerCase() + "/node1";
2022-05-01 13:30:11 -04:00
const logFile = dataDir + "/test.log";
settings.setBlockchainPath(dataDir);
2024-01-17 11:22:12 -05:00
settings.setStartupFlagsList(["--log-file", logFile, "--no-zmq"]);
await user1.startMoneroNode(settings);
2024-10-12 06:35:15 -04:00
assert(await user1.isMoneroNodeOnline());
// expect settings are updated
const settingsAfter = await user1.getMoneroNodeSettings();
2022-04-10 17:00:15 -04:00
testMoneroNodeSettingsEqual(settings, settingsAfter!);
// expect connection to local monero node to succeed
2024-01-17 11:22:12 -05:00
let daemon = await moneroTs.connectToDaemonRpc(TestConfig.monerod.url, "superuser", "abctesting123");
let height = await daemon.getHeight();
2024-01-17 11:22:12 -05:00
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");
2022-05-01 13:30:11 -04:00
} catch (err: any) {
if (err.message !== "Local Monero node already online") throw new Error("Unexpected error: " + err.message);
}
// expect stopped node
await user1.stopMoneroNode();
2024-10-12 06:35:15 -04:00
assert(!(await user1.isMoneroNodeOnline()));
try {
2024-01-17 11:22:12 -05:00
daemon = await moneroTs.connectToDaemonRpc(TestConfig.monerod.url);
height = await daemon.getHeight();
2024-01-17 11:22:12 -05:00
console.log("GOT HEIGHT: " + height);
throw new Error("should have thrown");
2022-05-01 13:30:11 -04:00
} catch (err: any) {
2024-10-12 06:35:15 -04:00
if (err.message.indexOf("connect ECONNREFUSED 127.0.0.1:28081") <= 0) throw new Error("Unexpected error: " + err.message);
}
2024-10-12 06:35:15 -04:00
// 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 () => {
2022-05-10 14:53:10 -04:00
// 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);
2022-08-17 12:22:08 -04:00
expect(BigInt(balancesBefore.getBalance())).toBeGreaterThanOrEqual(BigInt(balancesBefore.getAvailableBalance()));
// get transactions
const txs: XmrTx[]= await user1.getXmrTxs();
assert(txs.length > 0);
2022-05-01 13:30:11 -04:00
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");
2022-11-27 18:31:33 +00:00
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()));
2022-08-17 12:22:08 -04:00
expect(BigInt(balancesAfter.getAvailableBalance())).toBeLessThan(BigInt(balancesBefore.getAvailableBalance()));
// get relayed tx
tx = await user1.getXmrTx(txHash);
2022-11-27 18:31:33 +00:00
testTx(tx!, {isCreatedTx: false});
// relay invalid tx
try {
await user1.relayXmrTx("invalid tx metadata");
throw new Error("Cannot relay invalid tx metadata");
2022-05-01 13:30:11 -04:00
} 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();
2022-08-17 12:22:08 -04:00
expect(BigInt(balances.getAvailableBalance())).toBeGreaterThanOrEqual(0);
expect(BigInt(balances.getPendingBalance())).toBeGreaterThanOrEqual(0);
expect(BigInt(balances.getReservedOfferBalance())).toBeGreaterThanOrEqual(0);
expect(BigInt(balances.getReservedTradeBalance())).toBeGreaterThanOrEqual(0);
});
2022-12-29 11:20:57 +00:00
test("Can send and receive push notifications (CI, sanity check)", async () => {
// add notification listener
2022-05-01 13:30:11 -04:00
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);
}
});
2023-08-29 06:40:25 -04:00
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 () => {
2022-01-24 19:37:18 +01:00
// get all market prices
const prices: MarketPriceInfo[] = await user1.getPrices();
2022-01-24 19:37:18 +01:00
expect(prices.length).toBeGreaterThan(1);
2022-05-01 13:30:11 -04:00
for (const price of prices) {
2022-01-24 19:37:18 +01:00
expect(price.getCurrencyCode().length).toBeGreaterThan(0);
expect(price.getPrice()).toBeGreaterThanOrEqual(0);
}
2022-02-16 10:09:59 -05:00
// get market prices of primary assets
2022-05-01 13:30:11 -04:00
for (const assetCode of TestConfig.assetCodes) {
const price = await user1.getPrice(assetCode);
2022-01-24 19:37:18 +01:00
expect(price).toBeGreaterThan(0);
}
2022-01-24 19:37:18 +01:00
// test that prices are reasonable
const usd = await user1.getPrice("USD");
2022-01-24 19:37:18 +01:00
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);
2022-01-24 19:37:18 +01:00
expect(btc).toBeLessThan(0.4);
2022-01-24 19:37:18 +01:00
// test invalid currency
await expect(async () => { await user1.getPrice("INVALID_CURRENCY") })
2022-01-24 19:37:18 +01:00
.rejects
.toThrow('Currency not found: INVALID_CURRENCY');
});
test("Can get market depth (CI, sanity check)", async () => {
2022-05-01 13:30:11 -04:00
const assetCode = "eth";
2022-02-11 17:13:56 -06:00
// clear offers
await clearOffers(user1, assetCode);
await clearOffers(user2, assetCode);
2022-04-07 16:35:48 -04:00
async function clearOffers(havenod: HavenoClient, assetCode: string) {
2022-05-01 13:30:11 -04:00
for (const offer of await havenod.getMyOffers(assetCode)) {
if (offer.getBaseCurrencyCode().toLowerCase() === assetCode.toLowerCase()) {
2022-02-11 17:13:56 -06:00
await havenod.removeOffer(offer.getId());
}
}
}
2022-02-11 17:13:56 -06:00
// market depth has no data
await wait(TestConfig.trade.maxTimePeerNoticeMs);
let marketDepth = await user1.getMarketDepth(assetCode);
2022-02-11 17:13:56 -06:00
expect(marketDepth.getBuyPricesList().length).toEqual(0);
expect(marketDepth.getBuyDepthList().length).toEqual(0);
expect(marketDepth.getSellPricesList().length).toEqual(0);
expect(marketDepth.getSellDepthList().length).toEqual(0);
2022-02-11 17:13:56 -06:00
// 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);
2022-02-11 17:13:56 -06:00
// 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);
2022-02-11 17:13:56 -06:00
// 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()) });
2022-05-26 10:58:15 -04:00
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()));
2022-02-11 17:13:56 -06:00
expect(marketDepth.getBuyDepthList()[0]).toEqual(0.15);
expect(marketDepth.getBuyDepthList()[1]).toEqual(0.30);
expect(marketDepth.getBuyDepthList()[2]).toEqual(0.65);
2022-02-11 17:13:56 -06:00
// 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()) });
2022-05-26 10:58:15 -04:00
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()));
2022-02-11 17:13:56 -06:00
expect(marketDepth.getSellDepthList()[0]).toEqual(0.3);
expect(marketDepth.getSellDepthList()[1]).toEqual(0.6);
expect(marketDepth.getSellDepthList()[2]).toEqual(1);
2022-02-11 17:13:56 -06:00
// clear offers
await clearOffers(user1, assetCode);
await clearOffers(user2, assetCode);
2022-02-11 17:13:56 -06:00
// test invalid currency
await expect(async () => {await user1.getMarketDepth("INVALID_CURRENCY")})
2022-02-11 17:13:56 -06:00
.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");
2022-05-01 13:30:11 -04:00
} 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");
2022-05-01 13:30:11 -04:00
} 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 () => {
2022-05-01 13:30:11 -04:00
for (const assetCode of TestConfig.assetCodes) {
const offers: OfferInfo[] = await user1.getOffers(assetCode);
2022-05-01 13:30:11 -04:00
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
2022-05-01 13:30:11 -04:00
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
}
2022-02-16 10:09:59 -05:00
}
});
test("Can get payment methods (CI)", async () => {
const paymentMethods: PaymentMethod[] = await user1.getPaymentMethods();
2022-02-16 10:09:59 -05:00
expect(paymentMethods.length).toBeGreaterThan(0);
2022-05-01 13:30:11 -04:00
for (const paymentMethod of paymentMethods) {
2022-02-16 10:09:59 -05:00
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();
2022-05-01 13:30:11 -04:00
for (const paymentAccount of paymentAccounts) {
2021-11-16 08:06:20 -05:00
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
2023-08-26 06:55:56 -04:00
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 () => {
2022-02-16 10:09:59 -05:00
// get payment account form
const paymentMethodId = HavenoUtils.getPaymentMethodId(PaymentAccountForm.FormId.REVOLUT);
const accountForm = await user1.getPaymentAccountForm(paymentMethodId);
2022-02-16 10:09:59 -05:00
// edit form
2023-11-01 06:39:35 -04:00
HavenoUtils.setFormValue(accountForm, PaymentAccountFormField.FieldId.ACCOUNT_NAME, "Revolut account " + moneroTs.GenUtils.getUUID());
2024-06-08 10:50:50 -04:00
HavenoUtils.setFormValue(accountForm, PaymentAccountFormField.FieldId.USERNAME, "user123");
2023-11-01 06:39:35 -04:00
HavenoUtils.setFormValue(accountForm, PaymentAccountFormField.FieldId.TRADE_CURRENCIES, "gbp,eur,usd");
2022-02-16 10:09:59 -05:00
// 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");
2022-02-16 10:09:59 -05:00
expect(fiatAccount.getTradeCurrenciesList().length).toBeGreaterThan(0);
expect(fiatAccount.getPaymentAccountPayload()!.getPaymentMethodId()).toEqual(paymentMethodId);
2024-06-08 10:50:50 -04:00
expect(fiatAccount.getPaymentAccountPayload()!.getRevolutAccountPayload()!.getUsername()).toEqual(HavenoUtils.getFormValue(accountForm, PaymentAccountFormField.FieldId.USERNAME));
2022-02-16 10:09:59 -05:00
// payment account added
let found = false;
for (const paymentAccount of await user1.getPaymentAccounts()) {
2022-02-16 10:09:59 -05:00
if (paymentAccount.getId() === fiatAccount.getId()) {
found = true;
break;
2022-02-16 10:09:59 -05:00
}
}
assert(found, "Payment account not found after adding");
2024-07-16 16:19:29 -04:00
// 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;
}
2022-02-16 10:09:59 -05:00
});
test("Can create crypto payment accounts (CI)", async () => {
2022-02-16 10:09:59 -05:00
// test each crypto
2022-05-01 13:30:11 -04:00
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);
2024-07-16 16:19:29 -04:00
// 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 () => {
2022-12-15 21:39:43 +00:00
await prepareForTrading(5, user1, user2);
2022-05-26 10:58:15 -04:00
});
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
2022-08-17 12:22:08 -04:00
const availableBalanceBefore = BigInt((await user1.getBalances()).getAvailableBalance());
// post crypto offer
2022-05-26 10:58:15 -04:00
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");
2022-05-26 10:58:15 -04:00
assert.equal(parseFloat(offer.getPrice()), price);
// has offer
offer = await user1.getMyOffer(offer.getId());
assert.equal(offer.getState(), "AVAILABLE");
2022-05-26 10:58:15 -04:00
// 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
2022-08-17 12:22:08 -04:00
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");
2022-05-26 10:58:15 -04:00
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
2022-08-17 12:22:08 -04:00
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");
2022-04-08 18:33:37 -04:00
});
// 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
2022-05-26 10:58:15 -04:00
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";
2023-11-01 06:39:35 -04:00
const account = await createPaymentAccount(user1, assetCode, "zelle");
// test posting buy offer above limit
try {
await executeTrade({
2024-02-12 10:41:21 -05:00
offerAmount: moneroTs.MoneroUtils.xmrToAtomicUnits(3.1),
direction: OfferDirection.BUY,
2023-11-01 06:39:35 -04:00
assetCode: assetCode,
makerPaymentAccountId: account.getId(),
takeOffer: false
});
throw new Error("Should have rejected posting offer above trade limit")
2024-08-29 11:32:14 -04:00
} catch (err: any) {
2023-11-01 06:39:35 -04:00
assert(err.message.indexOf("amount is larger than") === 0);
}
// test posting sell offer above limit
2022-12-15 21:39:43 +00:00
try {
2023-11-01 06:39:35 -04:00
await executeTrade({
2024-02-12 10:41:21 -05:00
offerAmount: moneroTs.MoneroUtils.xmrToAtomicUnits(12.1),
direction: OfferDirection.SELL,
2023-11-01 06:39:35 -04:00
assetCode: assetCode,
makerPaymentAccountId: account.getId(),
takeOffer: false
});
2022-12-15 21:39:43 +00:00
throw new Error("Should have rejected posting offer above trade limit")
2024-08-29 11:32:14 -04:00
} catch (err: any) {
2022-12-15 21:39:43 +00:00
assert(err.message.indexOf("amount is larger than") === 0);
}
2023-11-01 06:39:35 -04:00
// test that sell limit is higher than buy limit
let offerId = await executeTrade({
offerAmount: 2100000000000n,
direction: OfferDirection.SELL,
2023-11-01 06:39:35 -04:00
assetCode: assetCode,
makerPaymentAccountId: account.getId(),
takeOffer: false
});
await user1.removeOffer(offerId);
2022-12-15 21:39:43 +00:00
});
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<TradeContext> = {
price: 142.23,
offerAmount: offerAmount,
offerMinAmount: offerMinAmount,
tradeAmount: tradeAmount,
2023-07-27 09:57:40 -04:00
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)
2024-06-03 10:41:48 -04:00
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<TradeContext> = {
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
2024-06-03 10:41:48 -04:00
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");
2022-11-28 13:15:04 +00:00
await executeTrades(ctxs);
2022-02-16 10:09:59 -05:00
});
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"
2022-10-26 01:05:49 -04:00
});
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())
2022-10-26 01:05:49 -04:00
});
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())
2022-10-26 01:05:49 -04:00
});
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<TradeContext> = {};
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;
2022-02-16 10:09:59 -05:00
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
2022-02-16 10:09:59 -05:00
try {
await makeOffer({maker: {havenod: user3}, makerPaymentAccountId: paymentAccount.getId(), awaitFundsToMakeOffer: false});
2022-02-16 10:09:59 -05:00
throw new Error("Should have failed making offer with insufficient funds")
2022-05-01 13:30:11 -04:00
} catch (err: any) {
if (!err.message.includes("not enough money")) throw err;
const errTyped = err as HavenoError;
2022-02-16 10:09:59 -05:00
assert.equal(errTyped.code, 2);
}
2023-02-24 11:58:33 -05:00
// user1 gets or posts offer
const offers: OfferInfo[] = await user1.getMyOffers(TestConfig.trade.assetCode);
2022-02-16 10:09:59 -05:00
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});
2022-02-16 10:09:59 -05:00
assert.equal(offer.getState(), "AVAILABLE");
await wait(TestConfig.trade.walletSyncPeriodMs * 2);
2022-02-16 10:09:59 -05:00
}
// user3 cannot take offer with insufficient funds
2022-02-16 10:09:59 -05:00
try {
await user3.takeOffer(offer.getId(), paymentAccount.getId());
2022-02-16 10:09:59 -05:00
throw new Error("Should have failed taking offer with insufficient funds")
2022-05-01 13:30:11 -04:00
} catch (err: any) {
const errTyped = err as HavenoError;
2022-02-16 10:09:59 -05:00
assert(errTyped.message.includes("not enough money"), "Unexpected error: " + errTyped.message);
assert.equal(errTyped.code, 2);
}
// user3 does not have trade
2022-02-16 10:09:59 -05:00
try {
await user3.getTrade(offer.getId());
2022-05-01 13:30:11 -04:00
} catch (err: any) {
const errTyped = err as HavenoError;
2022-02-16 10:09:59 -05:00
assert.equal(errTyped.code, 3);
2022-04-05 14:34:19 -04:00
assert(errTyped.message.includes("trade with id '" + offer.getId() + "' not found"));
2022-02-16 10:09:59 -05:00
}
2023-02-24 11:58:33 -05:00
// remove offer if posted
if (!offers.length) await user1.removeOffer(offer.getId());
2022-02-16 10:09:59 -05:00
} catch (err2) {
err = err2;
}
// stop user3
if (user3) await releaseHavenoProcess(user3, true);
2022-02-16 10:09:59 -05:00
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);
2022-05-01 13:30:11 -04:00
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());
2022-05-01 13:30:11 -04:00
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});
2024-08-29 11:32:14 -04:00
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");
2022-05-01 13:30:11 -04:00
} 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 () => {
2022-04-07 16:35:48 -04:00
let traders: HavenoClient[] = [];
let err: any;
try {
// start and fund 3 trader processes
2022-02-16 10:09:59 -05:00
HavenoUtils.log(1, "Starting trader processes");
2022-04-05 15:18:36 -04:00
traders = await initHavenos(3);
2022-02-16 10:09:59 -05:00
HavenoUtils.log(1, "Funding traders");
const tradeAmount = 250000000000n;
await waitForAvailableBalance(tradeAmount * 2n, traders[0], traders[1], traders[2]);
// trader 0 posts offer
2022-02-16 10:09:59 -05:00
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);
2022-05-01 13:30:11 -04:00
for (const frozenOutput of await traderWallet.getOutputs({isFrozen: true})) await traderWallet.thawOutput(frozenOutput.getKeyImage().getHex());
2022-02-16 10:09:59 -05:00
HavenoUtils.log(1, "Sweeping trade funds");
await traderWallet.sweepUnlocked({address: await traderWallet.getPrimaryAddress(), relay: true});
2022-05-01 13:30:11 -04:00
} 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")
2022-05-01 13:30:11 -04:00
} catch (err: any) {
2022-02-04 13:16:57 -05:00
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");
}
2022-07-17 07:57:51 -04:00
// 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);
2022-05-01 13:30:11 -04:00
for (const frozenOutput of await traderWallet.getOutputs({isFrozen: true})) await traderWallet.thawOutput(frozenOutput.getKeyImage().getHex());
2022-02-16 10:09:59 -05:00
HavenoUtils.log(1, "Sweeping offer funds");
await traderWallet.sweepUnlocked({address: await traderWallet.getPrimaryAddress(), relay: true});
2022-05-01 13:30:11 -04:00
} catch (err: any) {
console.log("Caught error sweeping funds!");
console.log(err);
}
});
// trader 2 tries to take offer
paymentAccount = await createCryptoPaymentAccount(traders[2]);
try {
2022-02-16 10:09:59 -05:00
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")
2022-05-01 13:30:11 -04:00
} 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
2022-05-01 13:30:11 -04:00
const trader2Balances = await traders[2].getBalances();
expect(BigInt(trader2Balances.getReservedTradeBalance())).toEqual(0n);
expect(BigInt(trader2Balances.getAvailableBalance())).toBeGreaterThan(0n);
} catch (err2) {
err = err2;
}
// stop traders
2022-05-01 13:30:11 -04:00
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()?
2022-12-03 13:03:44 +00:00
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);
2023-02-24 11:58:33 -05:00
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");
2024-08-29 11:32:14 -04:00
} 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;
}
});
2024-07-16 16:10:13 -04:00
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<TradeContext>[], executionCtx?: Partial<TradeContext>): Promise<string[]> {
2022-11-28 13:15:04 +00:00
// assign default execution context
if (!executionCtx) executionCtx = {};
2022-11-28 13:15:04 +00:00
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
2022-11-28 13:15:04 +00:00
let miningStarted = executionCtx.concurrentTrades && await startMining();
2022-11-28 13:15:04 +00:00
// 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<any, number>();
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<void>[] = [];
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
2022-11-28 13:15:04 +00:00
if (executionCtx.concurrentTrades) {
const tradePromises: Promise<string>[] = [];
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
2024-08-29 11:32:14 -04:00
} catch (e3: any) {
HavenoUtils.log(0, "Error awaiting other trades to stop after error: " + e3.message);
HavenoUtils.log(0, e3.stack);
}
throw e2;
}
} else {
2022-11-28 13:15:04 +00:00
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<TradeContext>): Promise<string> {
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();
2022-12-03 13:03:44 +00:00
// 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<void>[] = [];
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!;
2024-08-29 11:32:14 -04:00
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) {
2024-04-26 13:25:29 -04:00
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();
2024-08-29 11:32:14 -04:00
} 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<TradeContext>) {
let ctx = TradeContext.init(ctxP);
2022-11-23 14:24:52 +00:00
// 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!);
2022-11-27 18:31:33 +00:00
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);
2022-11-27 18:31:33 +00:00
expect(payoutTx?.getIsConfirmed());
2022-11-23 14:24:52 +00:00
// test after payout unlocked
if (!ctx.testPayoutUnlocked) return;
trade = await ctx.arbitrator.havenod!.getTrade(ctx.offerId!);
2022-11-27 18:31:33 +00:00
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);
2022-11-27 18:31:33 +00:00
expect(!payoutTx?.getIsLocked());
2022-10-26 01:05:49 -04:00
}
async function testTradeState(trade: TradeInfo, ctx: Partial<TradeContext>) {
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);
}
2022-10-26 01:05:49 -04:00
}
async function makeOffer(ctxP?: Partial<TradeContext>): Promise<OfferInfo> {
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,
2023-07-26 18:58:48 -04:00
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<TradeContext>): Promise<TradeInfo> {
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
2023-12-28 07:59:55 -05:00
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
2023-12-28 07:59:55 -05:00
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()!));
2024-08-29 11:32:14 -04:00
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()!));
2024-08-29 11:32:14 -04:00
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<void> {
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!));
2024-08-29 11:32:14 -04:00
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<void> {
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<TradeContext>) {
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); });
2024-04-26 15:00:01 -04:00
// 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
2024-08-06 05:10:08 -04:00
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;
}
2024-04-26 15:00:01 -04:00
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);
2024-08-06 05:10:08 -04:00
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;
}
2024-04-26 15:00:01 -04:00
// 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<TradeContext>) {
let ctx = TradeContext.init(ctxP);
// stop buyer or seller depending on configuration
const promises: Promise<void>[] = [];
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
2023-03-11 08:33:11 -05:00
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!);
2023-04-17 10:13:56 -04:00
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;
2024-08-29 11:32:14 -04:00
tradeCtx.getBuyer().payoutAmount = tradeCtx.getBuyer().securityDepositActual! + tradeCtx.tradeAmount! - tradeCtx.getBuyer().payoutTxFee!;
tradeCtx.getSeller().payoutTxFee = payoutTxFee / 2n;
2024-08-29 11:32:14 -04:00
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;
2024-08-29 11:32:14 -04:00
tradeCtx.getDisputeWinner()!.payoutAmount = tradeCtx.disputeWinnerAmount - tradeCtx.getDisputeWinner()!.payoutTxFee!;
tradeCtx.getDisputeLoser()!.payoutTxFee = winnerGetsAll ? 0n : payoutTxFee;
2024-08-29 11:32:14 -04:00
tradeCtx.getDisputeLoser()!.payoutAmount = tradeCtx.maker.securityDepositActual! + tradeCtx.taker.securityDepositActual! + tradeCtx.tradeAmount! - tradeCtx.disputeWinnerAmount - tradeCtx.getDisputeLoser()!.payoutTxFee!;
} else {
tradeCtx.getDisputeWinner()!.payoutTxFee = payoutTxFee / 2n;
2024-08-29 11:32:14 -04:00
tradeCtx.getDisputeWinner()!.payoutAmount = tradeCtx.tradeAmount! + tradeCtx.getDisputeWinner()!.securityDepositActual! - tradeCtx.getDisputeWinner()!.payoutTxFee!;
tradeCtx.getDisputeLoser()!.payoutTxFee = payoutTxFee / 2n;
2024-08-29 11:32:14 -04:00
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
2024-08-29 11:32:14 -04:00
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());
2024-08-29 11:32:14 -04:00
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<TradeContext>) {
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 = ["", " ", "<script>alert('test');</script>", "さようなら"];
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<HavenoClient>[] = [];
for (let i = 0; i < numDaemons; i++) havenodPromises.push(initHaveno(config));
return Promise.all(havenodPromises);
}
2022-12-03 13:03:44 +00:00
async function initHaveno(ctx?: HavenodContext): Promise<HavenoClient> {
if (!ctx) ctx = {};
Object.assign(ctx, TestConfig.defaultHavenod, Object.assign({}, ctx));
if (!ctx.appName) ctx.appName = "haveno-" + TestConfig.baseCurrencyNetwork + "_instance_" + moneroTs.GenUtils.getUUID();
2022-12-03 13:03:44 +00:00
// connect to existing server or start new process
let havenod: HavenoClient;
try {
// try to connect to existing server
2022-12-03 13:03:44 +00:00
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
2022-12-03 13:03:44 +00:00
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))) {
2022-12-03 13:03:44 +00:00
HAVENO_PROCESS_PORTS.push(httpPort);
ctx.port = httpPort;
break;
}
}
}
2022-12-03 13:03:44 +00:00
if (!ctx.port) throw new Error("No unused test ports available");
// start haveno process using configured ports if available
2022-05-01 13:30:11 -04:00
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",
2022-12-03 13:03:44 +00:00
"--nodePort", TestConfig.ports.get(ctx.port)![1],
"--appName", ctx.appName,
"--apiPassword", "apitest",
2022-12-03 13:03:44 +00:00
"--apiPort", TestConfig.ports.get(ctx.port)![0],
"--walletRpcBindPort", ctx.walletUrl ? getPort(ctx.walletUrl) : "" + await getAvailablePort(), // use configured port if given
2023-01-18 10:11:47 -05:00
"--passwordRequired", (ctx.accountPasswordRequired ? "true" : "false"),
"--logLevel", ctx.logLevel!
];
2022-12-03 13:03:44 +00:00
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<number> {
2022-02-11 17:13:56 -06:00
return new Promise(function(resolve) {
2022-05-01 13:30:11 -04:00
const srv = net.createServer();
srv.listen(0, function() {
2022-05-01 13:30:11 -04:00
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();
2022-05-01 13:30:11 -04:00
} 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.
*/
2022-04-07 16:35:48 -04:00
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;
2022-05-01 13:30:11 -04:00
} catch (err: any) {
// do nothing
}
// open wallet if necessary
if (!walletIsOpen) {
// attempt to open funding wallet
try {
2022-07-30 17:11:40 -04:00
await fundingWallet.openWallet({path: TestConfig.fundingWallet.defaultPath, password: TestConfig.fundingWallet.walletPassword});
2022-05-01 13:30:11 -04:00
} 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
2022-05-01 13:30:11 -04:00
if (err.getCode() === -1) {
// create wallet
await fundingWallet.createWallet({
path: TestConfig.fundingWallet.defaultPath,
password: TestConfig.fundingWallet.walletPassword,
2023-07-25 09:14:04 -04:00
seed: TestConfig.fundingWallet.seed,
restoreHeight: TestConfig.fundingWallet.restoreHeight
});
} else {
2022-05-01 13:30:11 -04:00
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<boolean> {
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);
2024-01-05 06:29:32 -05:00
HavenoUtils.log(2, "Mining started");
return true;
2022-05-01 13:30:11 -04:00
} 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.
*/
2022-08-17 12:22:08 -04:00
async function waitForAvailableBalance(amount: bigint, ...wallets: any[]) {
// wrap common wallet functionality for tests
class WalletWrapper {
_wallet: moneroTs.MoneroWallet;
constructor(wallet: any) {
this._wallet = wallet;
}
2022-08-17 12:22:08 -04:00
async getAvailableBalance(): Promise<bigint> {
if (this._wallet instanceof HavenoClient) return BigInt((await this._wallet.getBalances()).getAvailableBalance());
else return await this._wallet.getUnlockedBalance();
}
2022-08-17 12:22:08 -04:00
async getPendingBalance(): Promise<bigint> {
if (this._wallet instanceof HavenoClient) return BigInt((await this._wallet.getBalances()).getPendingBalance());
else return await this._wallet.getBalance() - await this.getAvailableBalance();
}
async getDepositAddress(): Promise<string> {
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});
2022-05-01 13:30:11 -04:00
for (const wallet of wallets) {
2022-08-17 12:22:08 -04:00
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()) {
2022-08-17 12:22:08 -04:00
await waitForAvailableBalance(TestConfig.fundingWallet.minimumFunding, fundingWallet); // TODO (woodser): wait for enough to cover tx amount + fee
try { await fundingWallet.createTx(fundConfig); }
2022-05-01 13:30:11 -04:00
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);
2022-05-01 13:30:11 -04:00
const promises: Promise<void>[] = [];
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());
}
2022-05-01 13:30:11 -04:00
// eslint-disable-next-line no-async-promise-executor
promises.push(new Promise(async (resolve) => {
const taskLooper: any = new moneroTs.TaskLooper(async function() {
2022-08-18 10:01:05 -04:00
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");
2022-05-01 13:30:11 -04:00
}
async function waitForUnlockedTxs(...txHashes: string[]) {
if (txHashes.length === 0) return;
HavenoUtils.log(1, "Mining to unlock txs");
const miningStarted = await startMining();
2022-05-01 13:30:11 -04:00
const promises: Promise<void>[] = [];
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() {
2022-05-01 13:30:11 -04:00
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<boolean> {
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<void> {
if (numOutputs === undefined) numOutputs = 1;
if (waitForUnlock === undefined) waitForUnlock = true;
// collect destinations
const destinations: moneroTs.MoneroDestination[] = [];
2022-05-01 13:30:11 -04:00
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);
2022-05-01 13:30:11 -04:00
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) {
2022-05-01 13:30:11 -04:00
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 {
2022-05-01 13:30:11 -04:00
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
2022-05-01 13:30:11 -04:00
for (const incomingTransfer of tx.getIncomingTransfersList()) testTransfer(incomingTransfer, ctx);
if (tx.getOutgoingTransfer()) testTransfer(tx.getOutgoingTransfer()!, ctx);
2022-02-11 17:13:56 -06:00
if (ctx.isCreatedTx) testCreatedTx(tx);
}
2022-02-11 17:13:56 -06:00
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);
}
2022-02-11 17:13:56 -06:00
function testTransfer(transfer: XmrIncomingTransfer | XmrOutgoingTransfer, ctx: TxContext) {
expect(BigInt(transfer.getAmount())).toBeGreaterThanOrEqual(0n);
assert(transfer.getAccountIndex() >= 0);
2022-02-11 17:13:56 -06:00
if (transfer instanceof XmrIncomingTransfer) testIncomingTransfer(transfer);
else testOutgoingTransfer(transfer, ctx);
}
2022-02-11 17:13:56 -06:00
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);
2022-05-01 13:30:11 -04:00
for (const subaddressIdx of transfer.getSubaddressIndicesList()) assert(subaddressIdx >= 0);
// test destinations sum to outgoing amount
if (transfer.getDestinationsList().length > 0) {
let sum = 0n;
2022-05-01 13:30:11 -04:00
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));
}
2022-02-16 10:09:59 -05:00
function getRandomAssetCode() {
return TestConfig.assetCodes[moneroTs.GenUtils.getRandomInt(0, TestConfig.assetCodes.length - 1)];
2022-05-26 10:58:15 -04:00
}
async function hasPaymentAccount(config: { trader: HavenoClient; assetCode?: string; paymentMethod?: string }): Promise<boolean> {
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;
2022-05-26 10:58:15 -04:00
}
return false;
2022-02-16 10:09:59 -05:00
}
function isCrypto(assetCode: string) {
2022-05-26 10:58:15 -04:00
return getCryptoAddress(assetCode) !== undefined;
2022-02-16 10:09:59 -05:00
}
function getCryptoAddress(currencyCode: string): string|undefined {
2022-05-26 10:58:15 -04:00
for (const cryptoAddress of TestConfig.cryptoAddresses) {
if (cryptoAddress.currencyCode === currencyCode.toUpperCase()) return cryptoAddress.address;
}
2022-02-16 10:09:59 -05:00
}
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()));
2023-11-01 06:39:35 -04:00
if (HavenoUtils.hasFormField(accountForm, PaymentAccountFormField.FieldId.TRADE_CURRENCIES)) HavenoUtils.setFormValue(accountForm, PaymentAccountFormField.FieldId.TRADE_CURRENCIES, assetCodes);
return await trader.createPaymentAccount(accountForm);
2022-02-16 10:09:59 -05:00
}
2022-04-07 16:35:48 -04:00
async function createCryptoPaymentAccount(trader: HavenoClient, currencyCode = "eth"): Promise<PaymentAccount> {
2022-05-01 13:30:11 -04:00
for (const cryptoAddress of TestConfig.cryptoAddresses) {
2022-02-16 10:09:59 -05:00
if (cryptoAddress.currencyCode.toLowerCase() !== currencyCode.toLowerCase()) continue;
2022-02-11 17:13:56 -06:00
return trader.createCryptoPaymentAccount(
cryptoAddress.currencyCode + " " + cryptoAddress.address.substr(0, 8) + "... " + moneroTs.GenUtils.getUUID(),
2022-02-16 10:09:59 -05:00
cryptoAddress.currencyCode,
cryptoAddress.address);
2022-02-11 17:13:56 -06:00
}
throw new Error("No test config for crypto: " + currencyCode);
}
function getOffer(offers: OfferInfo[], id: string): OfferInfo|undefined {
return offers.find(offer => offer.getId() === id);
}
2022-04-10 17:00:15 -04:00
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);
2022-05-01 13:30:11 -04:00
const tradeCurrency = acct.getTradeCurrenciesList()[0];
expect(tradeCurrency.getName().length).toBeGreaterThan(0);
2022-04-10 17:00:15 -04:00
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<TradeContext>) {
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) {
2024-01-17 11:22:12 -05:00
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);
}
2023-08-26 06:55:56 -04:00
function getValidFormInput(form: PaymentAccountForm, fieldId: PaymentAccountFormField.FieldId): string {
const field = getFormField(form, fieldId);
switch (fieldId) {
case PaymentAccountFormField.FieldId.ACCEPTED_COUNTRY_CODES:
2023-08-26 06:55:56 -04:00
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:
2022-06-23 10:40:10 -04:00
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:
2022-06-22 13:17:00 -04:00
return "12345678";
case PaymentAccountFormField.FieldId.ACCOUNT_OWNER:
2022-06-23 10:40:10 -04:00
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:
2024-06-04 11:55:38 -04:00
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";
2024-06-08 10:50:50 -04:00
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:
2024-06-04 11:55:38 -04:00
return "john.doe@example.com";
case PaymentAccountFormField.FieldId.PIX_KEY:
throw new Error("Not implemented");
case PaymentAccountFormField.FieldId.POSTAL_ADDRESS:
2023-08-29 06:25:17 -04:00
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 "";
2022-06-22 13:17:00 -04:00
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:
2023-08-26 06:55:56 -04:00
if (field.getComponent() === PaymentAccountFormField.Component.SELECT_ONE) {
if (form.getId() === PaymentAccountForm.FormId.F2F) return "XAU";
2023-09-07 16:00:29 -04:00
if (form.getId() === PaymentAccountForm.FormId.PAY_BY_MAIL) return "XGB";
2023-08-26 06:55:56 -04:00
return field.getSupportedCurrenciesList()[0]!.getCode(); // TODO: randomly select?
}
else return field.getSupportedCurrenciesList().map(currency => currency.getCode()).join(',');
2024-06-08 10:50:50 -04:00
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 {
2022-06-17 09:14:07 -04:00
const field = getFormField(form, fieldId);
switch (fieldId) {
case PaymentAccountFormField.FieldId.ACCEPTED_COUNTRY_CODES:
return "US,XX";
case PaymentAccountFormField.FieldId.ACCOUNT_ID:
2022-06-23 10:40:10 -04:00
return "";
case PaymentAccountFormField.FieldId.ACCOUNT_NAME:
return "";
case PaymentAccountFormField.FieldId.ACCOUNT_NR:
2022-06-22 13:17:00 -04:00
return "123457A";
case PaymentAccountFormField.FieldId.ACCOUNT_OWNER:
2022-06-23 10:40:10 -04:00
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:
2024-06-04 11:55:38 -04:00
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
2024-06-08 10:50:50 -04:00
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:
2024-06-04 11:55:38 -04:00
return "A";
case PaymentAccountFormField.FieldId.PIX_KEY:
throw new Error("Not implemented");
case PaymentAccountFormField.FieldId.POSTAL_ADDRESS:
2023-08-29 06:25:17 -04:00
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";
2022-06-22 13:17:00 -04:00
case PaymentAccountFormField.FieldId.SORT_CODE:
return "12345A";
case PaymentAccountFormField.FieldId.SPECIAL_INSTRUCTIONS:
throw new Error("Special instructions have no invalid input");
2022-06-17 09:14:07 -04:00
case PaymentAccountFormField.FieldId.STATE: {
const country = HavenoUtils.getFormValue(form, PaymentAccountFormField.FieldId.COUNTRY);
return moneroTs.GenUtils.arrayContains(field.getRequiredForCountriesList(), country) ? "" : "My state";
2022-06-17 09:14:07 -04:00
}
case PaymentAccountFormField.FieldId.TRADE_CURRENCIES:
return "abc,def";
2024-06-08 10:50:50 -04:00
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
2024-06-08 10:50:50 -04:00
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:
2024-06-08 10:50:50 -04:00
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());
2022-06-22 11:10:54 -04:00
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;
2023-04-17 10:13:56 -04:00
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());
2024-06-08 10:50:50 -04:00
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;
2022-06-17 09:14:07 -04:00
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());
2022-06-17 09:14:07 -04:00
expect(account.getTradeCurrenciesList().map(currency => currency.getCode()).join(",")).toEqual(getFormField(form, PaymentAccountFormField.FieldId.TRADE_CURRENCIES).getValue());
break;
2022-06-22 13:17:00 -04:00
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());
2022-06-22 13:17:00 -04:00
break;
2022-06-23 10:40:10 -04:00
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());
2022-06-23 10:40:10 -04:00
break;
2022-06-23 11:16:39 -04:00
case PaymentAccountForm.FormId.PAXUM:
expect(account.getPaymentAccountPayload()!.getPaxumAccountPayload()!.getEmail()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.EMAIL).getValue());
2022-06-23 11:16:39 -04:00
expect(account.getTradeCurrenciesList().map(currency => currency.getCode()).join(",")).toEqual(getFormField(form, PaymentAccountFormField.FieldId.TRADE_CURRENCIES).getValue());
break;
2023-08-29 06:25:17 -04:00
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;
2023-09-09 09:18:53 -04:00
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;
2024-06-04 11:55:38 -04:00
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;
2024-06-08 10:50:50 -04:00
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());
}
}