add test to bootstrap network with random offers and trades

This commit is contained in:
woodser 2025-01-17 09:52:29 -05:00
parent 974362cd23
commit 72aad448d8
No known key found for this signature in database
GPG Key ID: 55A10DD48ADEE5EF
2 changed files with 147 additions and 41 deletions

View File

@ -180,6 +180,7 @@ class TradeContext {
// make offer
awaitFundsToMakeOffer?: boolean
direction?: OfferDirection;
paymentMethodId?: string;
assetCode?: string;
offerAmount?: bigint; // offer amount or max
offerMinAmount?: bigint;
@ -341,6 +342,8 @@ class TradeContext {
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());
str += "\nBuyer dispute context: " + disputeContextToString(this.buyerDisputeContext);
str += "\nSeller dispute context: " + disputeContextToString(this.sellerDisputeContext);
if (this.disputeWinner) str += "\nDispute winner: " + (this.disputeWinner == DisputeResult.Winner.BUYER ? "Buyer" : "Seller");
str += "\nPayout tx id: " + this.payoutTxId;
if (this.payoutTxId) {
@ -351,6 +354,11 @@ class TradeContext {
}
str += "\nOffer json: " + JSON.stringify(this.offer?.toObject());
return str;
function disputeContextToString(disputeContext: DisputeContext | undefined): string {
if (!disputeContext) return "undefined";
return disputeContext === DisputeContext.NONE ? "NONE" : disputeContext === DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK ? "OPEN_AFTER_DEPOSITS_UNLOCK" : "OPEN_AFTER_PAYMENT_SENT";
}
}
}
@ -433,6 +441,7 @@ const TestConfig = {
paymentMethods: Object.keys(PaymentAccountForm.FormId), // all supported payment methods
assetCodes: ["USD", "GBP", "EUR", "ETH", "BTC", "BCH", "LTC", "USDT-ERC20", "USDT-TRC20", "USDC-ERC20"], // crypto asset codes
fixedPriceAssetCodes: ["XAG", "XAU", "XGB"],
fixedPricePaymentMethods: ["CASH_AT_ATM"],
cryptoAddresses: [{
currencyCode: "ETH",
address: "0xdBdAb835Acd6fC84cF5F9aDD3c0B5a1E25fbd99f"
@ -619,18 +628,18 @@ async function shutDown() {
// ----------------------------------- TESTS ----------------------------------
test("Can get the version (CI)", async () => {
test("Can get the version (Test, CI)", async () => {
const version = await arbitrator.getVersion();
expect(version).toEqual(TestConfig.haveno.version);
});
test("Can convert between XMR and atomic units (CI)", async () => {
test("Can convert between XMR and atomic units (Test, 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 () => {
test("Can manage an account (Test, CI)", async () => {
let user3: HavenoClient|undefined;
let err: any;
try {
@ -765,7 +774,7 @@ test("Can manage an account (CI)", async () => {
}
});
test("Can manage Monero daemon connections (CI)", async () => {
test("Can manage Monero daemon connections (Test, CI)", async () => {
let monerod3: moneroTs.MoneroDaemonRpc | undefined = undefined;
let user3: HavenoClient|undefined;
let err: any;
@ -938,7 +947,7 @@ test("Can manage Monero daemon connections (CI)", async () => {
// - 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() => {
test("Can start and stop a local Monero node (Test, CI)", async() => {
// expect error stopping stopped local node
try {
@ -1024,7 +1033,7 @@ test("Can start and stop a local Monero node (CI)", async() => {
});
// test wallet balances, transactions, deposit addresses, create and relay txs
test("Has a Monero wallet (CI)", async () => {
test("Has a Monero wallet (Test, CI)", async () => {
// get seed phrase
const seed = await user1.getXmrSeed();
@ -1084,7 +1093,7 @@ test("Has a Monero wallet (CI)", async () => {
}
});
test("Can get balances (CI, sanity check)", async () => {
test("Can get balances (Test, CI, sanity check)", async () => {
const balances: XmrBalanceInfo = await user1.getBalances();
expect(BigInt(balances.getAvailableBalance())).toBeGreaterThanOrEqual(0);
expect(BigInt(balances.getPendingBalance())).toBeGreaterThanOrEqual(0);
@ -1092,7 +1101,7 @@ test("Can get balances (CI, sanity check)", async () => {
expect(BigInt(balances.getReservedTradeBalance())).toBeGreaterThanOrEqual(0);
});
test("Can send and receive push notifications (CI, sanity check)", async () => {
test("Can send and receive push notifications (Test, CI, sanity check)", async () => {
// add notification listener
const notifications: NotificationMessage[] = [];
@ -1118,7 +1127,7 @@ test("Can send and receive push notifications (CI, sanity check)", async () => {
}
});
test("Can get asset codes with prices and their payment methods (CI, sanity check)", async() => {
test("Can get asset codes with prices and their payment methods (Test, CI, sanity check)", async() => {
const assetCodes = await user1.getPricedAssetCodes();
for (const assetCode of assetCodes) {
const paymentMethods = await user1.getPaymentMethods(assetCode);
@ -1126,7 +1135,7 @@ test("Can get asset codes with prices and their payment methods (CI, sanity chec
}
});
test("Can get market prices (CI, sanity check)", async () => {
test("Can get market prices (Test, CI, sanity check)", async () => {
// get all market prices
const prices: MarketPriceInfo[] = await user1.getPrices();
@ -1154,12 +1163,10 @@ test("Can get market prices (CI, sanity check)", async () => {
expect(btc).toBeLessThan(0.4);
// test invalid currency
await expect(async () => { await user1.getPrice("INVALID_CURRENCY") })
.rejects
.toThrow('Currency not found: INVALID_CURRENCY');
expect(await user1.getPrice("INVALID_CURRENCY")).toEqual(undefined);
});
test("Can get market depth (CI, sanity check)", async () => {
test("Can get market depth (Test, CI, sanity check)", async () => {
const assetCode = "eth";
// clear offers
@ -1228,7 +1235,7 @@ test("Can get market depth (CI, sanity check)", async () => {
.toThrow('Currency not found: INVALID_CURRENCY');
});
test("Can register as an arbitrator (CI)", async () => {
test("Can register as an arbitrator (Test, CI)", async () => {
// test bad dispute agent type
try {
@ -1250,14 +1257,14 @@ test("Can register as an arbitrator (CI)", async () => {
await arbitrator.registerDisputeAgent("arbitrator", getArbitratorPrivKey(0));
});
test("Can get offers (CI)", async () => {
test("Can get offers (Test, CI)", async () => {
for (const assetCode of TestConfig.assetCodes) {
const offers: OfferInfo[] = await user1.getOffers(assetCode);
for (const offer of offers) testOffer(offer);
}
});
test("Can get my offers (CI)", async () => {
test("Can get my offers (Test, CI)", async () => {
// get all offers
const offers: OfferInfo[] = await user1.getMyOffers();
@ -1273,7 +1280,7 @@ test("Can get my offers (CI)", async () => {
}
});
test("Can get payment methods (CI)", async () => {
test("Can get payment methods (Test, CI)", async () => {
const paymentMethods: PaymentMethod[] = await user1.getPaymentMethods();
expect(paymentMethods.length).toBeGreaterThan(0);
for (const paymentMethod of paymentMethods) {
@ -1284,7 +1291,7 @@ test("Can get payment methods (CI)", async () => {
}
});
test("Can get payment accounts (CI)", async () => {
test("Can get payment accounts (Test, CI)", async () => {
const paymentAccounts: PaymentAccount[] = await user1.getPaymentAccounts();
for (const paymentAccount of paymentAccounts) {
if (paymentAccount.getPaymentAccountPayload()!.getCryptoCurrencyAccountPayload()) { // TODO (woodser): test non-crypto
@ -1294,7 +1301,7 @@ test("Can get payment accounts (CI)", async () => {
});
// TODO: FieldId represented as number
test("Can validate payment account forms (CI, sanity check)", async () => {
test("Can validate payment account forms (Test, CI, sanity check)", async () => {
// get payment methods
const paymentMethods = await user1.getPaymentMethods();
@ -1345,12 +1352,16 @@ test("Can validate payment account forms (CI, sanity check)", async () => {
expect(paymentAccount.getPaymentMethod()!.getId()).toEqual(paymentMethod.getId());
testPaymentAccount(paymentAccount, accountForm);
// convert to payment account payload form
const accountPayloadForm = await user1.getPaymentAccountPayloadForm(paymentAccount.getPaymentAccountPayload()!);
expect(accountPayloadForm.toObject()).toBeDefined();
// delete payment account
// await user1.deletePaymentAccount(paymentAccount.getId()); // TODO: support deleting payment accounts over grpc
}
});
test("Can create fiat payment accounts (CI)", async () => {
test("Can create fiat payment accounts (Test, CI)", async () => {
// get payment account form
const paymentMethodId = HavenoUtils.getPaymentMethodId(PaymentAccountForm.FormId.REVOLUT);
@ -1391,7 +1402,7 @@ test("Can create fiat payment accounts (CI)", async () => {
}
});
test("Can create crypto payment accounts (CI)", async () => {
test("Can create crypto payment accounts (Test, CI)", async () => {
// test each crypto
for (const testAccount of TestConfig.cryptoAddresses) {
@ -1450,11 +1461,11 @@ test("Can create crypto payment accounts (CI)", async () => {
}
});
test("Can prepare for trading (CI)", async () => {
test("Can prepare for trading (Test, CI)", async () => {
await prepareForTrading(5, user1, user2);
});
test("Can post and remove an offer (CI, sanity check)", async () => {
test("Can post and remove an offer (Test, CI, sanity check)", async () => {
// wait for user1 to have unlocked balance to post offer
await waitForAvailableBalance(250000000000n * 2n, user1);
@ -1525,7 +1536,7 @@ test("Can post and remove an offer (CI, sanity check)", async () => {
});
// TODO: provide number of confirmations in offer status
test("Can schedule offers with locked funds (CI)", async () => {
test("Can schedule offers with locked funds (Test, CI)", async () => {
let user3: HavenoClient|undefined;
let err: any;
try {
@ -1647,7 +1658,7 @@ test("Can schedule offers with locked funds (CI)", async () => {
if (err) throw err;
});
test("Can reserve exact amount needed for offer (CI)", async () => {
test("Can reserve exact amount needed for offer (Test, CI)", async () => {
let randomOfferAmount = 1.0 + (Math.random() * 1.0); // random amount between 1 and 2 xmr
await executeTrade({
price: 150,
@ -1659,7 +1670,7 @@ test("Can reserve exact amount needed for offer (CI)", async () => {
});
});
test("Cannot post offer exceeding trade limit (CI, sanity check)", async () => {
test("Cannot post offer exceeding trade limit (Test, CI, sanity check)", async () => {
let assetCode = "USD";
const account = await createPaymentAccount(user1, assetCode, "zelle");
@ -1720,7 +1731,7 @@ test("Cannot post offer exceeding trade limit (CI, sanity check)", async () => {
await user1.removeOffer(offerId);
});
test("Can complete a trade within a range and without a buyer deposit", async () => {
test("Can complete a trade within a range and without a buyer deposit (Test, CI)", async () => {
// create payment accounts
let paymentMethodId = "cash_at_atm";
@ -1758,7 +1769,7 @@ test("Can complete a trade within a range and without a buyer deposit", async ()
}
});
test("Can complete trades at the same time (CI, sanity check)", async () => {
test("Can complete trades at the same time (Test, CI, sanity check)", async () => {
// create trade contexts with customized payment methods and random amounts
const ctxs = getTradeContexts(TestConfig.assetCodes.length);
@ -1776,7 +1787,7 @@ test("Can complete trades at the same time (CI, sanity check)", async () => {
await executeTrades(ctxs);
});
test("Can complete all trade combinations (stress)", async () => {
test("Can complete all trade combinations (Test, stress)", async () => {
// generate trade context for each combination (buyer/seller, maker/taker, dispute(s), dispute winner)
let ctxs: TradeContext[] = [];
@ -1822,7 +1833,7 @@ test("Can complete all trade combinations (stress)", async () => {
await executeTrades(ctxs);
});
test("Can go offline while completing a trade (CI, sanity check)", async () => {
test("Can go offline while completing a trade (Test, CI, sanity check)", async () => {
let traders: HavenoClient[] = [];
let ctx: TradeContext = new TradeContext(TestConfig.trade);
let err: any;
@ -1856,7 +1867,7 @@ test("Can go offline while completing a trade (CI, sanity check)", async () => {
if (err) throw err;
});
test("Can resolve a dispute (CI)", async () => {
test("Can resolve a dispute (Test, CI)", async () => {
// create payment accounts
let paymentMethodId = "revolut";
@ -1885,7 +1896,7 @@ test("Can resolve a dispute (CI)", async () => {
// TODO: test receiver = BUYER
});
test("Can resolve disputes (CI)", async () => {
test("Can resolve disputes (Test, CI)", async () => {
// execute all configs unless config index given
let configIdx = undefined;
@ -1950,7 +1961,7 @@ test("Can resolve disputes (CI)", async () => {
await executeTrades(ctxs.slice(configIdx, configIdx === undefined ? undefined : configIdx + 1), {concurrentTrades: !testBalancesSequentially});
});
test("Can go offline while resolving a dispute (CI)", async () => {
test("Can go offline while resolving a dispute (Test, CI)", async () => {
let traders: HavenoClient[] = [];
let ctx: Partial<TradeContext> = {};
let err: any;
@ -1991,7 +2002,7 @@ test("Can go offline while resolving a dispute (CI)", async () => {
if (err) throw err;
});
test("Cannot make or take offer with insufficient funds (CI, sanity check)", async () => {
test("Cannot make or take offer with insufficient funds (Test, CI, sanity check)", async () => {
let user3: HavenoClient|undefined;
let err: any;
try {
@ -2054,7 +2065,7 @@ test("Cannot make or take offer with insufficient funds (CI, sanity check)", asy
if (err) throw err;
});
test("Invalidates offers when reserved funds are spent (CI)", async () => {
test("Invalidates offers when reserved funds are spent (Test, CI)", async () => {
let err;
let tx;
try {
@ -2118,7 +2129,7 @@ test("Invalidates offers when reserved funds are spent (CI)", async () => {
// 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 () => {
test("Can handle unexpected errors during trade initialization (Test)", async () => {
let traders: HavenoClient[] = [];
let err: any;
try {
@ -2221,7 +2232,7 @@ test("Can handle unexpected errors during trade initialization", async () => {
});
// TODO: test opening and resolving dispute as arbitrator and traders go offline
test("Selects arbitrators which are online, registered, and least used", async () => {
test("Selects arbitrators which are online, registered, and least used (Test)", async () => {
// complete 2 trades using main arbitrator so it's most used
// TODO: these trades are not registered with seednode until it's restarted
@ -2325,7 +2336,7 @@ test("Selects arbitrators which are online, registered, and least used", async (
}
});
test("Can get trade statistics", async () => {
test("Can get trade statistics (Test, CI)", async () => {
const tradeStatisticsArbitrator = await arbitrator.getTradeStatistics();
const tradeStatisticsUser1 = await user1.getTradeStatistics();
const tradeStatisticsUser2 = await user2.getTradeStatistics();
@ -2333,6 +2344,82 @@ test("Can get trade statistics", async () => {
assert(tradeStatisticsArbitrator.length === tradeStatisticsUser1.length && tradeStatisticsUser1.length === tradeStatisticsUser2.length);
});
// specialty test to bootstrap a network with random offers, trades, and disputes
// TODO: this bootstrap test encounters errors
// TODO: paymentMethodId config only used here
test("Can bootstrap a network", async () => {
// get random trade configs
const ctxs: TradeContext[] = [];
for (let i = 0; i < 20; i++) {
ctxs.push(await getRandomBootstrapConfig());
}
// execute trades
HavenoUtils.log(0, "Executing " + ctxs.length + " random bootstrap configurations");
await executeTrades(ctxs);
async function getRandomBootstrapConfig(ctxP?: Partial<TradeContext>): Promise<TradeContext> {
if (!ctxP) ctxP = {};
// randomize offer config
const user1AsMaker = getRandomOutcome(1/2);
if (ctxP.maker === undefined) ctxP.maker = {};
if (ctxP.taker === undefined) ctxP.taker = {};
if (ctxP.maker.havenod === undefined) ctxP.maker.havenod = user1AsMaker ? user1 : user2;
if (ctxP.taker.havenod === undefined) ctxP.taker.havenod = user1AsMaker ? user2 : user1;
if (ctxP.direction === undefined) ctxP.direction = getRandomOutcome(1/2) ? OfferDirection.BUY : OfferDirection.SELL;
const offerAmountAnchor = HavenoUtils.xmrToAtomicUnits(1.5);
const minAmountAnchor = HavenoUtils.xmrToAtomicUnits(0.3);
const isRangeOffer = getRandomOutcome(1/2);
if (ctxP.offerAmount === undefined) ctxP.offerAmount = getRandomBigIntWithinPercent(offerAmountAnchor, 0.15);
if (isRangeOffer && ctxP.offerMinAmount === undefined) ctxP.offerMinAmount = getRandomBigIntWithinPercent(minAmountAnchor, 0.15);
if (ctxP.reserveExactAmount === undefined) ctxP.reserveExactAmount = getRandomOutcome(3/4);
// randomize payment method and asset code
if (ctxP.assetCode && (!ctxP.makerPaymentAccountId || !ctxP.paymentMethodId)) throw new Error("Cannot specify asset code without payment account or method ID");
if (!ctxP.paymentMethodId) ctxP.paymentMethodId = getRandomPaymentMethodId();
if (!ctxP.makerPaymentAccountId) ctxP.makerPaymentAccountId = (await createPaymentAccount2(ctxP.maker.havenod!, ctxP.paymentMethodId)).getId();
if (!ctxP.takerPaymentAccountId) ctxP.takerPaymentAccountId = (await createPaymentAccount2(ctxP.taker.havenod!, ctxP.paymentMethodId)).getId();
if (!ctxP.assetCode) ctxP.assetCode = getRandomAssetCodeForPaymentAccount(await ctxP.maker.havenod.getPaymentAccount(ctxP.makerPaymentAccountId));
if (await isFixedPrice(ctxP)) ctxP.price = 142.23;
// randomize trade config
if (ctxP.takeOffer === undefined) ctxP.takeOffer = getRandomOutcome(4/5);
if (ctxP.tradeAmount === undefined) ctxP.tradeAmount = isRangeOffer ? getRandomBigIntWithinRange(ctxP.offerMinAmount!, ctxP.offerAmount) : ctxP.offerAmount;
if (ctxP.buyerSendsPayment === undefined) ctxP.buyerSendsPayment = getRandomOutcome(5/7);
if (ctxP.sellerReceivesPayment === undefined) ctxP.sellerReceivesPayment = getRandomOutcome(6/7);
if (ctxP.buyerDisputeContext === undefined) ctxP.buyerDisputeContext = getRandomOutcome(1/14) ? DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK : undefined;
if (ctxP.buyerDisputeContext === undefined) ctxP.buyerDisputeContext = getRandomOutcome(1/14) ? DisputeContext.OPEN_AFTER_PAYMENT_SENT : undefined;
if (ctxP.sellerDisputeContext === undefined) ctxP.sellerDisputeContext = getRandomOutcome(1/14) ? DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK : undefined;
if (ctxP.sellerDisputeContext === undefined) ctxP.sellerDisputeContext = getRandomOutcome(1/14) ? DisputeContext.OPEN_AFTER_PAYMENT_SENT : undefined;
if (ctxP.resolveDispute === undefined) ctxP.resolveDispute = getRandomOutcome(5/7);
return TradeContext.init(ctxP);
}
async function isFixedPrice(ctxP: Partial<TradeContext>): Promise<boolean> {
if (moneroTs.GenUtils.arrayContains(TestConfig.fixedPriceAssetCodes, ctxP.assetCode)) return true;
if (moneroTs.GenUtils.arrayContains(TestConfig.fixedPricePaymentMethods, ctxP.paymentMethodId?.toUpperCase())) return true;
const marketPrice = await user1.getPrice(ctxP.assetCode!);
if (marketPrice === undefined) return true;
return false;
}
// TODO: reconcile with createPaymentAccount
async function createPaymentAccount2(trader: HavenoClient, paymentMethodId?: string, assetCodes?: string[]): Promise<PaymentAccount> {
if (assetCodes && !paymentMethodId) throw new Error("Cannot create payment account with asset codes and no payment method ID");
if (!paymentMethodId) paymentMethodId = getRandomPaymentMethodId();
const accountForm = await trader.getPaymentAccountForm(paymentMethodId);
if (assetCodes) HavenoUtils.setFormValue(accountForm, PaymentAccountFormField.FieldId.TRADE_CURRENCIES, assetCodes.join(","));
for (const field of accountForm.getFieldsList()) {
if (field.getValue() !== "") continue; // skip if already set
field.setValue(getValidFormInput(accountForm, field.getId()));
}
return await trader.createPaymentAccount(accountForm);
}
});
// ----------------------------- TEST HELPERS ---------------------------------
function getTradeContexts(numConfigs: number): TradeContext[] {
@ -2845,6 +2932,9 @@ async function makeOffer(ctxP?: Partial<TradeContext>): Promise<OfferInfo> {
} 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);
if (ctx.tradeAmount === ctx.offerAmount) ctx.tradeAmount = BigInt(offer.getAmount()); // adjust trade amount
ctx.offerAmount = BigInt(offer.getAmount());
ctx.offerMinAmount = BigInt(offer.getMinAmount());
}
// unlocked balance has decreased
@ -4011,6 +4101,21 @@ function testDestination(destination: XmrDestination) {
expect(BigInt(destination.getAmount())).toBeGreaterThan(0n);
}
function getRandomPaymentMethodId(): string {
if (getRandomOutcome(1/5)) return "BLOCK_CHAINS";
let allPaymentMethodIds = Object.keys(PaymentAccountForm.FormId);
return allPaymentMethodIds[moneroTs.GenUtils.getRandomInt(0, allPaymentMethodIds.length - 1)];
}
function getRandomAssetCodeForPaymentAccount(paymentAccount: PaymentAccount): string {
const allTradeCurrencies = paymentAccount.getTradeCurrenciesList();
return allTradeCurrencies[moneroTs.GenUtils.getRandomInt(0, allTradeCurrencies.length - 1)].getCode();
}
function getRandomOutcome(percentChance: number): boolean {
return Math.random() <= percentChance;
}
function getRandomBigIntWithinPercent(base: bigint, percent: number): bigint {
return getRandomBigIntWithinRange(base - multiply(base, percent), base + multiply(base, percent));
}

View File

@ -802,12 +802,13 @@ export default class HavenoClient {
* Get the current market price per 1 XMR in the given currency.
*
* @param {string} assetCode - asset code to get the price of
* @return {number} the price of the asset per 1 XMR
* @return {number|undefined} the price of the asset per 1 XMR
*/
async getPrice(assetCode: string): Promise<number> {
async getPrice(assetCode: string): Promise<number|undefined> {
try {
return (await this._priceClient.getMarketPrice(new MarketPriceRequest().setCurrencyCode(assetCode), {password: this._password})).getPrice();
} catch (e: any) {
if (e.message.indexOf("not found") >= 0) return undefined;
throw new HavenoError(e.message, e.code);
}
}