Compare commits

...

18 Commits

Author SHA1 Message Date
woodser
76e0a3e5a1 test havenod v1.1.0 2025-04-17 23:25:34 -04:00
woodser
ff417f8e36 add expected error message when offer funds swept while taking 2025-04-13 08:34:36 -04:00
woodser
89168bf052 bootstrap network takes offer 3 out of 4 times 2025-04-13 08:34:36 -04:00
woodser
10862656e3 increase max time peer notice to 6s 2025-04-07 09:28:30 -04:00
woodser
0cd3c54e37 test clone grpc api with new post offer config 2025-04-05 20:46:22 -04:00
woodser
5764a1959f set max monerod connections per ip 2025-04-04 09:56:31 -04:00
woodser
4ed5c3758d fix bugs to recreate stale data error 2025-03-18 17:08:18 -04:00
woodser
0f29130b9a adjust bootstrap odds of payment sent and received 2025-03-18 17:08:18 -04:00
woodser
15375d3462
test havenod v1.0.19 2025-03-09 16:58:03 -04:00
woodser
7bb8701301
test DAI-ERC20 (#360) 2025-03-09 16:52:34 -04:00
woodser
24050df072
fix selects arbitrators test by reverting to localhost (#362) 2025-03-09 16:52:18 -04:00
woodser
13a675e0fa increase max connections per ip for docker compose 2025-03-04 13:27:50 -05:00
woodser
9ca83478b7 fix CI tests by setting pricenode HOME 2025-02-10 09:58:18 -05:00
woodser
52c7f14e4e test paysafe 2025-02-05 07:44:19 -05:00
woodser
68f596cf4a remove cardless cash as fixed price payment method 2025-01-29 10:57:44 -05:00
woodser
6b5503f82e update instructions for sample application 2025-01-27 10:16:32 -05:00
woodser
57cdcff458 update config and instructions to run haveno-ts with envoy proxy 2025-01-27 09:55:48 -05:00
woodser
84543b84de test offer extra info 2025-01-26 23:15:22 -05:00
6 changed files with 220 additions and 117 deletions

View File

@ -6,11 +6,6 @@
TypeScript library for using Haveno.
## Install
1. Start a Haveno daemon (see [installing.md](https://github.com/haveno-dex/haveno/blob/master/docs/installing.md)).
2. Install haveno-ts in your project: `npm install haveno-ts`
## Sample code
```js
@ -30,6 +25,10 @@ const trade = await alice.takeOffer(offers[0].getId(), paymentAccounts[0].getId(
await alice.disconnect();
```
## Sample application
See the [sample application](https://github.com/haveno-dex/haveno-sample-app) to start a Haveno daemon and connect to it from haveno-ts.
## TypeDocs
See [TypeDocs](https://haveno-dex.github.io/haveno-ts/classes/HavenoClient.HavenoClient.html).

View File

@ -1,15 +1,15 @@
# envoy configuration to run user1 ui
# envoy configuration for haveno instance
admin:
access_log_path: /tmp/admin_access.log
address:
socket_address: { address: 0.0.0.0, port_value: 9901 }
socket_address: { address: 127.0.0.1, port_value: 9901 }
static_resources:
listeners:
- name: user1_listener
- name: haveno_listener
address:
socket_address: { address: 0.0.0.0, port_value: 8080 }
socket_address: { address: 127.0.0.1, port_value: 8080 }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
@ -17,6 +17,9 @@ static_resources:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
codec_type: auto
stat_prefix: ingress_http
common_http_protocol_options:
idle_timeout: 0s
max_requests_per_connection: 1
route_config:
name: local_route
virtual_hosts:
@ -25,7 +28,7 @@ static_resources:
routes:
- match: { prefix: "/" }
route:
cluster: user1_service
cluster: haveno_service
timeout: 0s
max_stream_duration:
grpc_timeout_header_max: 0s
@ -47,7 +50,7 @@ static_resources:
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: user1_service
- name: haveno_service
connect_timeout: 0.25s
type: logical_dns
http2_protocol_options: {}
@ -59,5 +62,5 @@ static_resources:
- endpoint:
address:
socket_address:
address: host.docker.internal
port_value: 9999
address: 127.0.0.1
port_value: 1201

View File

@ -35,6 +35,8 @@ services:
"--rpc-bind-port=48081",
"--no-zmq",
"--add-exclusive-node=127.0.0.1:28080",
"--max-connections-per-ip=10",
"--rpc-max-connections-per-private-ip=100",
"--rpc-access-control-origins=http://localhost:8080",
"--fixed-difficulty=150",
"--non-interactive",
@ -55,6 +57,8 @@ services:
"--no-zmq",
"--confirm-external-bind",
"--add-exclusive-node=127.0.0.1:48080",
"--max-connections-per-ip=10",
"--rpc-max-connections-per-private-ip=100",
"--rpc-access-control-origins=http://localhost:8080",
"--fixed-difficulty=150",
"--non-interactive",

View File

@ -12,6 +12,10 @@ RUN set -ex && adduser --system --group --disabled-password pricenode && \
USER pricenode
# Ensure HOME is set correctly
ENV HOME=/home/pricenode
ENV GRADLE_USER_HOME=$HOME/.gradle
RUN set -ex && git clone --recursive https://github.com/haveno-dex/haveno-pricenode.git /home/pricenode
WORKDIR /home/pricenode

View File

@ -145,7 +145,7 @@ const defaultTradeConfig: Partial<TradeContext> = {
disputeReason: DisputeResult.Reason.PEER_WAS_LATE,
disputeSummary: "Seller is winner",
walletSyncPeriodMs: 5000,
maxTimePeerNoticeMs: 5000,
maxTimePeerNoticeMs: 6000,
testChatMessages: true,
stopOnFailure: false, // TODO: setting to true can cause error: Http response at 400 or 500 level, http status code: 503
testPayoutConfirmed: true,
@ -195,6 +195,8 @@ class TradeContext {
reserveExactAmount?: boolean;
isPrivateOffer?: boolean;
buyerAsTakerWithoutDeposit?: boolean; // buyer as taker security deposit is optional for private offers
extraInfo?: string;
sourceOfferId?: string;
// take offer
awaitFundsToTakeOffer?: boolean;
@ -267,6 +269,10 @@ class TradeContext {
return this.direction === OfferDirection.BUY;
}
wasDisputeOpened() {
return this.buyerOpenedDispute || this.sellerOpenedDispute;
}
getDisputeOpener(): PeerContext | undefined {
if (this.disputeOpener === undefined) return undefined;
return this.disputeOpener === SaleRole.BUYER ? this.getBuyer() : this.getSeller();
@ -376,7 +382,7 @@ const TestConfig = {
deferralMs: 25000,
haveno: {
path: "../haveno",
version: "1.0.18"
version: "1.1.0"
},
monerod: {
url: "http://127.0.0.1:" + getNetworkStartPort() + "8081", // 18081, 28081, 38081 for mainnet, testnet, and stagenet, respectively
@ -441,9 +447,9 @@ const TestConfig = {
maxWalletStartupMs: 10000, // TODO (woodser): make shorter by switching to jni
maxCpuPct: 0.25,
paymentMethods: Object.keys(PaymentAccountForm.FormId), // all supported payment methods
assetCodes: ["USD", "GBP", "EUR", "ETH", "BTC", "BCH", "LTC", "USDT-ERC20", "USDT-TRC20", "USDC-ERC20"], // crypto asset codes
assetCodes: ["USD", "GBP", "EUR", "ETH", "BTC", "BCH", "LTC", "USDT-ERC20", "USDT-TRC20", "USDC-ERC20", "DAI-ERC20"],
fixedPriceAssetCodes: ["XAG", "XAU", "XGB"],
fixedPricePaymentMethods: [HavenoUtils.getPaymentMethodId(PaymentAccountForm.FormId.CASH_AT_ATM)],
fixedPricePaymentMethods: [],
cryptoAddresses: [{
currencyCode: "ETH",
address: "0xdBdAb835Acd6fC84cF5F9aDD3c0B5a1E25fbd99f"
@ -465,6 +471,9 @@ const TestConfig = {
}, {
currencyCode: "USDC-ERC20",
address: "0x1165cE9056620C9012D098103a08267e3c48B86B"
}, {
currencyCode: "DAI-ERC20",
address: "0x3fDBeb7b074a42D7B5810488d4aAD5992097DF93"
}
],
ports: new Map<string, string[]>([ // map http ports to havenod api and p2p ports
@ -832,7 +841,9 @@ test("Can manage Monero daemon connections (Test, CI)", async () => {
"--confirm-external-bind",
"--rpc-access-control-origins", "http://127.0.0.1:8080",
"--fixed-difficulty", "500",
"--disable-rpc-ban"
"--disable-rpc-ban",
"--rpc-max-connections-per-private-ip", "100",
"--max-connections-per-ip", "10"
];
if (getBaseCurrencyNetwork() !== BaseCurrencyNetwork.XMR_MAINNET) cmd.push("--" + moneroTs.MoneroNetworkType.toString(TestConfig.networkType).toLowerCase());
if (TestConfig.monerod3.username) cmd.push("--rpc-login", TestConfig.monerod3.username + ":" + TestConfig.monerod3.password);
@ -1479,7 +1490,8 @@ test("Can post and remove an offer (Test, CI, sanity check)", async () => {
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});
let ctx: Partial<TradeContext> = {maker: {havenod: user1}, assetCode: assetCode, price: price, extraInfo: "My extra info"};
let offer: OfferInfo = await makeOffer(ctx);;
assert.equal(offer.getState(), "AVAILABLE");
assert.equal(offer.getBaseCurrencyCode(), assetCode); // TODO: base and counter currencies inverted in crypto offer
assert.equal(offer.getCounterCurrencyCode(), "XMR");
@ -1491,7 +1503,9 @@ test("Can post and remove an offer (Test, CI, sanity check)", async () => {
// 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");
let peerOffer = getOffer(await user2.getOffers(assetCode, TestConfig.trade.direction), offer.getId());
if (!peerOffer) throw new Error("Offer " + offer.getId() + " was not found in peer's offers after posted");
testOffer(peerOffer, ctx, false);
// cancel offer
await user1.removeOffer(offer.getId());
@ -1509,7 +1523,8 @@ test("Can post and remove an offer (Test, CI, sanity check)", async () => {
// post fiat offer
assetCode = "USD";
price = 180.0;
offer = await makeOffer({maker: {havenod: user1}, assetCode: assetCode, price: price});
ctx = {maker: {havenod: user1}, assetCode: assetCode, price: price, extraInfo: "My extra info 2"};
offer = await makeOffer(ctx);
assert.equal(offer.getState(), "AVAILABLE");
assert.equal(offer.getBaseCurrencyCode(), "XMR");
assert.equal(offer.getCounterCurrencyCode(), "USD");
@ -1521,7 +1536,9 @@ test("Can post and remove an offer (Test, CI, sanity check)", async () => {
// 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");
peerOffer = getOffer(await user2.getOffers(assetCode, TestConfig.trade.direction), offer.getId());
if (!peerOffer) throw new Error("Offer " + offer.getId() + " was not found in peer's offers after posted");
testOffer(peerOffer, ctx, false);
// cancel offer
await user1.removeOffer(offer.getId());
@ -1537,6 +1554,40 @@ test("Can post and remove an offer (Test, CI, sanity check)", async () => {
if (getOffer(await user2.getOffers(assetCode, TestConfig.trade.direction), offer.getId())) throw new Error("Offer " + offer.getId() + " was found in peer's offers after removed");
});
test("Can clone offers (Test, CI, sanity check)", async () => {
// wait for user1 to have unlocked balance to post offer
await waitForAvailableBalance(250000000000n * 2n, user1);
// get unlocked balance before reserving funds for offer
const availableBalanceBefore = BigInt((await user1.getBalances()).getAvailableBalance());
// post offer
let assetCode = "BCH";
let ctx: Partial<TradeContext> = {maker: {havenod: user1}, isPrivateOffer: true, buyerAsTakerWithoutDeposit: true, assetCode: assetCode, extraInfo: "My extra info"};
let offer: OfferInfo = await makeOffer(ctx);;
assert.equal(offer.getState(), "AVAILABLE");
// clone offer
const clonedOffer = await makeOffer({
sourceOfferId: offer.getId(),
assetCode: "BCH"
});
assert.notEqual(clonedOffer.getId(), offer.getId());
assert.equal(clonedOffer.getState(), "DEACTIVATED"); // deactivated if same payment method and currency
assert.equal(clonedOffer.getBaseCurrencyCode(), assetCode);
assert.equal(clonedOffer.getCounterCurrencyCode(), "XMR");
assert.equal(clonedOffer.getAmount(), offer.getAmount());
assert.equal(clonedOffer.getMinAmount(), offer.getMinAmount());
assert.equal(clonedOffer.getIsPrivateOffer(), offer.getIsPrivateOffer());
// TODO: test edited fields on clone, etc
// remove offers
await user1.removeOffer(offer.getId());
await user1.removeOffer(clonedOffer.getId());
});
// TODO: provide number of confirmations in offer status
test("Can schedule offers with locked funds (Test, CI)", async () => {
let user3: HavenoClient|undefined;
@ -1759,7 +1810,8 @@ test("Can complete a trade within a range and without a buyer deposit (Test, CI)
testBalanceChangeEndToEnd: true,
direction: OfferDirection.SELL,
isPrivateOffer: true,
buyerAsTakerWithoutDeposit: true
buyerAsTakerWithoutDeposit: true,
extraInfo: "My extra info"
}
await executeTrade(ctx);
@ -1797,25 +1849,30 @@ test("Can complete all trade combinations (Test, stress)", async () => {
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];
const RESOLVE_DISPUTE_OPTS = [false, true];
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(new TradeContext(Object.assign({}, new TradeContext(TestConfig.trade), ctx)));
for (let n = 0; n < RESOLVE_DISPUTE_OPTS.length; n++) {
if (BUYER_DISPUTE_OPTS[k] !== DisputeContext.NONE && SELLER_DISPUTE_OPTS[l] !== DisputeContext.NONE) continue; // skip both opening a dispute
if (BUYER_DISPUTE_OPTS[k] === DisputeContext.NONE && SELLER_DISPUTE_OPTS[l] === DisputeContext.NONE && RESOLVE_DISPUTE_OPTS[n]) continue; // skip permutations to resolve dispute when no 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],
resolveDispute: RESOLVE_DISPUTE_OPTS[n],
disputeSummary: "After much deliberation, " + (DISPUTE_WINNER_OPTS[m] === DisputeResult.Winner.BUYER ? "buyer" : "seller") + " is winner",
offerAmount: getRandomBigIntWithinPercent(TestConfig.trade.offerAmount!, 0.15)
};
ctxs.push(new TradeContext(Object.assign({}, new TradeContext(TestConfig.trade), ctx)));
}
}
}
}
@ -1920,6 +1977,7 @@ test("Can resolve disputes (Test, CI)", async () => {
const trade2 = await user1.getTrade(tradeIds[configIdx === undefined ? 2 : 0]);
Object.assign(ctxs[0], {
resolveDispute: false,
buyerSendsPayment: false,
sellerDisputeContext: DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK,
disputeWinner: DisputeResult.Winner.SELLER,
disputeReason: DisputeResult.Reason.PEER_WAS_LATE,
@ -1927,6 +1985,7 @@ test("Can resolve disputes (Test, CI)", async () => {
});
Object.assign(ctxs[1], {
resolveDispute: false,
buyerSendsPayment: false,
buyerDisputeContext: DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK,
disputeWinner: DisputeResult.Winner.BUYER,
disputeReason: DisputeResult.Reason.SELLER_NOT_RESPONDING,
@ -1935,6 +1994,7 @@ test("Can resolve disputes (Test, CI)", async () => {
});
Object.assign(ctxs[2], {
resolveDispute: false,
buyerSendsPayment: false,
buyerDisputeContext: DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK,
disputeWinner: DisputeResult.Winner.SELLER,
disputeReason: DisputeResult.Reason.TRADE_ALREADY_SETTLED,
@ -1944,6 +2004,7 @@ test("Can resolve disputes (Test, CI)", async () => {
Object.assign(ctxs[3], {
resolveDispute: false,
buyerSendsPayment: true,
sellerReceivesPayment: false,
sellerDisputeContext: DisputeContext.OPEN_AFTER_PAYMENT_SENT,
disputeWinner: DisputeResult.Winner.BUYER,
disputeReason: DisputeResult.Reason.TRADE_ALREADY_SETTLED,
@ -2209,7 +2270,7 @@ test("Can handle unexpected errors during trade initialization (Test)", async ()
// determine if error is expected
let expected = false;
const expectedErrMsgs = ["not enough unlocked money", "timeout reached. protocol did not complete", "trade is already taken"];
const expectedErrMsgs = ["not enough unlocked money", "timeout reached. protocol did not complete", "trade is already taken", "open offer has been removed"];
for (const expectedErrMsg of expectedErrMsgs) {
if (err.message.indexOf(expectedErrMsg) >= 0) {
expected = true;
@ -2249,8 +2310,8 @@ test("Selects arbitrators which are online, registered, and least used (Test)",
await wait(TestConfig.trade.walletSyncPeriodMs * 2);
// get internal api addresses
const arbitrator1ApiUrl = "127.0.0.1:" + TestConfig.ports.get(getPort(arbitrator.getUrl()))![1]; // TODO: havenod.getApiUrl()?
const arbitrator2ApiUrl = "127.0.0.1:" + TestConfig.ports.get(getPort(arbitrator2.getUrl()))![1];
const arbitrator1ApiUrl = "localhost:" + TestConfig.ports.get(getPort(arbitrator.getUrl()))![1]; // TODO: havenod.getApiUrl()?
const arbitrator2ApiUrl = "localhost:" + TestConfig.ports.get(getPort(arbitrator2.getUrl()))![1];
let err = undefined;
try {
@ -2384,10 +2445,10 @@ test("Can bootstrap a network", async () => {
if (await isFixedPrice(ctxP)) ctxP.price = 142.23;
// randomize trade config
if (ctxP.takeOffer === undefined) ctxP.takeOffer = getRandomOutcome(4/5);
if (ctxP.takeOffer === undefined) ctxP.takeOffer = getRandomOutcome(3/4);
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.buyerSendsPayment === undefined) ctxP.buyerSendsPayment = getRandomOutcome(1/2);
if (ctxP.sellerReceivesPayment === undefined) ctxP.sellerReceivesPayment = getRandomOutcome(1/2);
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;
@ -2673,16 +2734,16 @@ async function executeTrade(ctxP: Partial<TradeContext>): Promise<string> {
// handle opened dispute
if (ctx.isStopped) return ctx.offerId!;
if (ctx.disputeOpener) {
if (ctx.wasDisputeOpened()) {
// test open dispute
await testOpenDispute(ctx);
// resolve dispute if configured
if (ctx.resolveDispute) await resolveDispute(ctx);
// return offer id
return ctx.offerId!;
if (ctx.resolveDispute) {
await resolveDispute(ctx);
return ctx.offerId!;
}
}
// buyer confirms payment is sent
@ -2748,12 +2809,12 @@ async function executeTrade(ctxP: Partial<TradeContext>): Promise<string> {
if (!ctx.disputeOpener) ctx.disputeOpener = SaleRole.SELLER;
}
if (ctx.isStopped) return ctx.offerId!;
if (ctx.disputeOpener) await testOpenDispute(ctx);
if (ctx.wasDisputeOpened()) await testOpenDispute(ctx);
// if dispute opened, resolve dispute if configured and return
// if dispute opened, resolve dispute if configured
if (ctx.isStopped) return ctx.offerId!;
if (ctx.disputeOpener) {
if (ctx.resolveDispute) await resolveDispute(ctx);
if (ctx.wasDisputeOpened() && ctx.resolveDispute) {
await resolveDispute(ctx);
return ctx.offerId!;
}
@ -2835,7 +2896,7 @@ async function testTradePayoutUnlock(ctxP: Partial<TradeContext>) {
let trade = await ctx.arbitrator.havenod!.getTrade(ctx.offerId!);
if (trade.getPayoutState() !== "PAYOUT_CONFIRMED") await mineToHeight(height + 1);
await wait(TestConfig.maxWalletStartupMs + ctx.walletSyncPeriodMs * 2);
const disputeState = ctx.isPaymentReceived ? "NO_DISPUTE" : "DISPUTE_CLOSED";
const disputeState = ctx.wasDisputeOpened() ? "DISPUTE_CLOSED" : "NO_DISPUTE";
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"]});
@ -2893,20 +2954,31 @@ async function makeOffer(ctxP?: Partial<TradeContext>): Promise<OfferInfo> {
ctx.taker.balancesBeforeOffer = await ctx.taker.havenod?.getBalances();
}
// post offer
const offer: OfferInfo = await ctx.maker.havenod!.postOffer(
ctx.direction!,
ctx.offerAmount!,
ctx.assetCode!,
ctx.makerPaymentAccountId!,
ctx.securityDepositPct!,
ctx.price,
ctx.priceMargin,
ctx.triggerPrice,
ctx.offerMinAmount,
ctx.reserveExactAmount,
ctx.isPrivateOffer,
ctx.buyerAsTakerWithoutDeposit);
// post or clone offer
const offer: OfferInfo = await ctx.maker.havenod!.postOffer({
direction: ctx.direction,
amount: ctx.offerAmount,
assetCode: ctx.assetCode,
paymentAccountId: ctx.makerPaymentAccountId,
securityDepositPct: ctx.securityDepositPct,
price: ctx.price,
marketPriceMarginPct: ctx.priceMargin,
triggerPrice: ctx.triggerPrice,
minAmount: ctx.offerMinAmount,
reserveExactAmount: ctx.reserveExactAmount,
isPrivateOffer: ctx.isPrivateOffer,
buyerAsTakerWithoutDeposit: ctx.buyerAsTakerWithoutDeposit,
extraInfo: ctx.extraInfo,
sourceOfferId: ctx.sourceOfferId
});
// transfer context from clone source
if (ctx.sourceOfferId) {
const sourceOffer = await ctx.maker.havenod!.getMyOffer(ctx.sourceOfferId);
ctx.isPrivateOffer = sourceOffer.getIsPrivateOffer();
}
// test offer
testOffer(offer, ctx, true);
// offer is included in my offers only
@ -2935,13 +3007,13 @@ async function makeOffer(ctxP?: Partial<TradeContext>): Promise<OfferInfo> {
if (offer.getState() === "PENDING") {
if (!ctx.reserveExactAmount && unlockedBalanceAfter !== unlockedBalanceBefore) throw new Error("Unlocked balance should not change for scheduled offer " + offer.getId());
} else if (offer.getState() === "AVAILABLE") {
if (unlockedBalanceAfter === unlockedBalanceBefore) {
if (!ctx.sourceOfferId && unlockedBalanceAfter === unlockedBalanceBefore) {
console.warn("Unlocked balance did not change after posting offer, waiting a sync period");
await wait(ctx.walletSyncPeriodMs);
unlockedBalanceAfter = BigInt((await ctx.maker.havenod!.getBalances()).getAvailableBalance());
if (unlockedBalanceAfter === unlockedBalanceBefore) throw new Error("Unlocked balance did not change after posting offer " + offer.getId() + ", before=" + unlockedBalanceBefore + ", after=" + unlockedBalanceAfter);
}
} else {
} else if (!ctx.sourceOfferId) { // cloned offers can be deactivated after creating
throw new Error("Unexpected offer state after posting: " + offer.getState());
}
@ -3151,16 +3223,17 @@ async function testOpenDispute(ctxP: Partial<TradeContext>) {
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
// arbitrator has buyer's payment account info unless seller opens dispute before payment sent
// TODO: should arbitrator receive buyer's payment account info if seller opens dispute before payment sent?
let buyerPaymentAccountPayload = arbDisputeOpener.getContract()!.getIsBuyerMakerAndSellerTaker() ? arbDisputeOpener.getMakerPaymentAccountPayload() : arbDisputeOpener.getTakerPaymentAccountPayload();
if (ctx.getDisputeOpener()!.havenod === ctx.getSeller().havenod && !ctx.isPaymentSent) expect(buyerPaymentAccountPayload).toBeUndefined();
if (ctx.getDisputeOpener()!.havenod === ctx.getSeller().havenod && ctx.sellerDisputeContext === DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK) 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();
if (ctx.getDisputeOpener()!.havenod === ctx.getSeller().havenod && ctx.sellerDisputeContext === DisputeContext.OPEN_AFTER_DEPOSITS_UNLOCK) expect(buyerPaymentAccountPayload).toBeUndefined();
else {
let expectedBuyerPaymentAccountPayload = (await ctx.getBuyer().havenod?.getPaymentAccount(buyerPaymentAccountPayload?.getId()!))?.getPaymentAccountPayload();
expect(buyerPaymentAccountPayload).toEqual(expectedBuyerPaymentAccountPayload);
@ -3368,8 +3441,8 @@ async function testAmountsAfterComplete(tradeCtx: TradeContext) {
const payoutTxFee = BigInt(payoutTx!.getFee());
// get expected payouts for normal trade
const isDisputedTrade = tradeCtx.getDisputeOpener() !== undefined;
if (!isDisputedTrade) {
const isResolvedByDispute = tradeCtx.wasDisputeOpened() && tradeCtx.resolveDispute;
if (!isResolvedByDispute) {
tradeCtx.getBuyer().payoutTxFee = payoutTxFee / 2n;
tradeCtx.getBuyer().payoutAmount = tradeCtx.getBuyer().securityDepositActual! + tradeCtx.tradeAmount! - tradeCtx.getBuyer().payoutTxFee!;
tradeCtx.getSeller().payoutTxFee = payoutTxFee / 2n;
@ -3392,7 +3465,7 @@ async function testAmountsAfterComplete(tradeCtx: TradeContext) {
}
// TODO: payout tx is unknown to offline non-signer until confirmed
if (isDisputedTrade || tradeCtx.isOfflineFlow()) {
if (isResolvedByDispute || tradeCtx.isOfflineFlow()) {
await mineToHeight(await monerod.getHeight() + 1);
await wait(TestConfig.maxWalletStartupMs + tradeCtx.walletSyncPeriodMs * 2);
}
@ -4203,6 +4276,7 @@ function testOffer(offer: OfferInfo, ctxP?: Partial<TradeContext>, isMyOffer?: b
expect(offer.getBuyerSecurityDepositPct()).toEqual(ctx.securityDepositPct);
expect(offer.getChallenge()).toEqual("");
}
if (ctx.extraInfo) expect(offer.getExtraInfo().indexOf(ctx.extraInfo)).toBeGreaterThanOrEqual(0); // may contain extra info from payment account
expect(offer.getSellerSecurityDepositPct()).toEqual(ctx.securityDepositPct);
expect(offer.getUseMarketBasedPrice()).toEqual(!ctx?.price);
expect(offer.getMarketPriceMarginPct()).toEqual(ctx?.priceMargin ? ctx.priceMargin : 0);
@ -4315,7 +4389,7 @@ function getValidFormInput(form: PaymentAccountForm, fieldId: PaymentAccountForm
case PaymentAccountFormField.FieldId.INTERMEDIARY_SWIFT_CODE:
return "10987654321"; // TODO: use real swift code
case PaymentAccountFormField.FieldId.MOBILE_NR:
throw new Error("Not implemented");
return "876-512-7813";
case PaymentAccountFormField.FieldId.NATIONAL_ACCOUNT_ID:
throw new Error("Not implemented");
case PaymentAccountFormField.FieldId.PAYID:
@ -4454,7 +4528,7 @@ function getInvalidFormInput(form: PaymentAccountForm, fieldId: PaymentAccountFo
case PaymentAccountFormField.FieldId.INTERMEDIARY_SWIFT_CODE:
return "A";
case PaymentAccountFormField.FieldId.MOBILE_NR:
throw new Error("Not implemented");
return "A";
case PaymentAccountFormField.FieldId.NATIONAL_ACCOUNT_ID:
throw new Error("Not implemented");
case PaymentAccountFormField.FieldId.PAYID:
@ -4603,6 +4677,10 @@ function testPaymentAccount(account: PaymentAccount, form: PaymentAccountForm) {
expect(account.getTradeCurrenciesList().length).toEqual(1);
expect(account.getTradeCurrenciesList()[0].getCode()).toEqual("USD");
break;
case PaymentAccountForm.FormId.PAYSAFE:
expect(account.getPaymentAccountPayload()!.getPaysafeAccountPayload()!.getEmail()).toEqual(getFormField(form, PaymentAccountFormField.FieldId.EMAIL).getValue());
expect(account.getTradeCurrenciesList().map(currency => currency.getCode()).join(",")).toEqual(getFormField(form, PaymentAccountFormField.FieldId.TRADE_CURRENCIES).getValue());
break;
default:
throw new Error("Unhandled payment method type: " + form.getId());
}

View File

@ -24,6 +24,27 @@ import { GetTradeStatisticsClient, GetVersionClient, AccountClient, XmrConnectio
import { GetTradeStatisticsRequest, GetTradeStatisticsReply, GetVersionRequest, GetVersionReply, IsAppInitializedRequest, IsAppInitializedReply, RegisterDisputeAgentRequest, UnregisterDisputeAgentRequest, MarketPriceRequest, MarketPriceReply, MarketPricesRequest, MarketPricesReply, MarketPriceInfo, MarketDepthRequest, MarketDepthReply, MarketDepthInfo, GetBalancesRequest, GetBalancesReply, XmrBalanceInfo, GetMyOfferRequest, GetMyOfferReply, GetOffersRequest, GetOffersReply, OfferInfo, GetPaymentMethodsRequest, GetPaymentMethodsReply, GetPaymentAccountFormRequest, CreatePaymentAccountRequest, ValidateFormFieldRequest, CreatePaymentAccountReply, GetPaymentAccountFormReply, GetPaymentAccountsRequest, GetPaymentAccountsReply, CreateCryptoCurrencyPaymentAccountRequest, CreateCryptoCurrencyPaymentAccountReply, DeletePaymentAccountRequest, DeletePaymentAccountReply, PostOfferRequest, PostOfferReply, CancelOfferRequest, TakeOfferRequest, TakeOfferReply, TradeInfo, GetTradeRequest, GetTradeReply, GetTradesRequest, GetTradesReply, GetXmrSeedRequest, GetXmrSeedReply, GetXmrPrimaryAddressRequest, GetXmrPrimaryAddressReply, GetXmrNewSubaddressRequest, GetXmrNewSubaddressReply, ConfirmPaymentSentRequest, ConfirmPaymentReceivedRequest, CompleteTradeRequest, XmrTx, GetXmrTxsRequest, GetXmrTxsReply, XmrDestination, CreateXmrTxRequest, CreateXmrTxReply, RelayXmrTxRequest, RelayXmrTxReply, CreateAccountRequest, AccountExistsRequest, AccountExistsReply, DeleteAccountRequest, OpenAccountRequest, IsAccountOpenRequest, IsAccountOpenReply, CloseAccountRequest, ChangePasswordRequest, BackupAccountRequest, BackupAccountReply, RestoreAccountRequest, StopRequest, NotificationMessage, RegisterNotificationListenerRequest, SendNotificationRequest, UrlConnection, AddConnectionRequest, RemoveConnectionRequest, GetConnectionRequest, GetConnectionsRequest, SetConnectionRequest, CheckConnectionRequest, CheckConnectionsReply, CheckConnectionsRequest, StartCheckingConnectionRequest, StopCheckingConnectionRequest, GetBestConnectionRequest, SetAutoSwitchRequest, GetAutoSwitchRequest, CheckConnectionReply, GetConnectionsReply, GetConnectionReply, GetBestConnectionReply, GetDisputeRequest, GetDisputeReply, GetDisputesRequest, GetDisputesReply, OpenDisputeRequest, ResolveDisputeRequest, SendDisputeChatMessageRequest, SendChatMessageRequest, GetChatMessagesRequest, GetChatMessagesReply, StartXmrNodeRequest, StopXmrNodeRequest, IsXmrNodeOnlineRequest, IsXmrNodeOnlineReply, GetXmrNodeSettingsRequest, GetXmrNodeSettingsReply } from "./protobuf/grpc_pb";
import { TradeStatistics3, OfferDirection, PaymentMethod, PaymentAccountForm, PaymentAccountFormField, PaymentAccount, PaymentAccountPayload, AvailabilityResult, Attachment, DisputeResult, Dispute, ChatMessage, XmrNodeSettings } from "./protobuf/pb_pb";
/**
* Configuration to post, clone, or edit an offer.
*/
export interface OfferConfig {
direction?: OfferDirection;
amount?: bigint;
minAmount?: bigint;
assetCode?: string;
paymentAccountId?: string;
securityDepositPct?: number;
price?: number;
marketPriceMarginPct?: number;
triggerPrice?: number;
reserveExactAmount?: boolean;
isPrivateOffer?: boolean;
buyerAsTakerWithoutDeposit?: boolean;
extraInfo?: string;
sourceOfferId?: string;
}
/**
* Haveno daemon client.
*/
@ -1035,50 +1056,44 @@ export default class HavenoClient {
}
/**
* Post an offer.
* Post or clone an offer.
*
* @param {OfferDirection} direction - "buy" or "sell" XMR
* @param {bigint} amount - amount of XMR to trade
* @param {string} assetCode - asset code to trade for XMR
* @param {string} paymentAccountId - payment account id
* @param {number} securityDepositPct - security deposit as % of trade amount for buyer and seller
* @param {number} price - trade price (optional, default to market price)
* @param {number} marketPriceMarginPct - if using market price, % from market price to accept (optional, default 0%)
* @param {number} triggerPrice - price to remove offer (optional)
* @param {bigint} minAmount - minimum amount to trade (optional, default to fixed amount)
* @param {number} reserveExactAmount - reserve exact amount needed for offer, incurring on-chain transaction and 10 confirmations before the offer goes live (default = false)
* @param {boolean} isPrivateOffer - whether the offer is private (default = false)
* @param {boolean} buyerAsTakerWithoutDeposit - waive buyer as taker deposit and fee (default false)
* @param {OfferConfig} config - configures the offer to post or clone
* @param {OfferDirection} [config.direction] - specifies to buy or sell xmr (default buy)
* @param {bigint} [config.amount] - amount of XMR to trade
* @param {string} [config.assetCode] - asset code to trade for XMR
* @param {string} [config.paymentAccountId] - payment account id
* @param {number} [config.securityDepositPct] - security deposit as % of trade amount for buyer and seller
* @param {number} [config.price] - trade price (optional, default to market price)
* @param {number} [config.marketPriceMarginPct] - if using market price, % from market price to accept (optional, default 0%)
* @param {number} [config.triggerPrice] - price to remove offer (optional)
* @param {bigint} [config.minAmount] - minimum amount to trade (optional, default to fixed amount)
* @param {number} [config.reserveExactAmount] - reserve exact amount needed for offer, incurring on-chain transaction and 10 confirmations before the offer goes live (default = false)
* @param {boolean} [config.isPrivateOffer] - whether the offer is private (default = false)
* @param {boolean} [config.buyerAsTakerWithoutDeposit] - waive buyer as taker deposit and fee (default false)
* @param {string} [config.extraInfo] - extra information to include with the offer (optional)
* @param {string} [config.sourceOfferId] - create a clone of a source offer which shares the same reserved funds. overrides other fields which are immutable or unspecified (optional)
* @return {OfferInfo} the posted offer
*/
async postOffer(direction: OfferDirection,
amount: bigint,
assetCode: string,
paymentAccountId: string,
securityDepositPct: number,
price?: number,
marketPriceMarginPct?: number,
triggerPrice?: number,
minAmount?: bigint,
reserveExactAmount?: boolean,
isPrivateOffer?: boolean,
buyerAsTakerWithoutDeposit?: boolean): Promise<OfferInfo> {
console.log("Posting offer with security deposit %: " + securityDepositPct)
async postOffer(config: OfferConfig): Promise<OfferInfo> {
console.log("Posting offer with security deposit %: " + config.securityDepositPct)
try {
const request = new PostOfferRequest()
.setDirection(direction === OfferDirection.BUY ? "buy" : "sell")
.setAmount(amount.toString())
.setCurrencyCode(assetCode)
.setPaymentAccountId(paymentAccountId)
.setSecurityDepositPct(securityDepositPct)
.setUseMarketBasedPrice(price === undefined)
.setMinAmount(minAmount ? minAmount.toString() : amount.toString());
if (price) request.setPrice(price.toString());
if (marketPriceMarginPct) request.setMarketPriceMarginPct(marketPriceMarginPct);
if (triggerPrice) request.setTriggerPrice(triggerPrice.toString());
if (reserveExactAmount) request.setReserveExactAmount(true);
if (isPrivateOffer) request.setIsPrivateOffer(true);
if (buyerAsTakerWithoutDeposit) request.setBuyerAsTakerWithoutDeposit(true);
const request = new PostOfferRequest();
if (config.direction) request.setDirection(config.direction === OfferDirection.BUY ? "buy" : "sell");
if (config.amount) request.setAmount(config.amount.toString());
request.setMinAmount(config.minAmount ? config.minAmount.toString() : config.amount!.toString());
if (config.assetCode) request.setCurrencyCode(config.assetCode);
if (config.paymentAccountId) request.setPaymentAccountId(config.paymentAccountId);
if (config.securityDepositPct) request.setSecurityDepositPct(config.securityDepositPct);
request.setUseMarketBasedPrice(config.price === undefined);
if (config.price) request.setPrice(config.price?.toString())
if (config.marketPriceMarginPct) request.setMarketPriceMarginPct(config.marketPriceMarginPct);
if (config.triggerPrice) request.setTriggerPrice(config.triggerPrice.toString());
if (config.reserveExactAmount) request.setReserveExactAmount(true);
if (config.isPrivateOffer) request.setIsPrivateOffer(true);
if (config.buyerAsTakerWithoutDeposit) request.setBuyerAsTakerWithoutDeposit(true);
if (config.extraInfo) request.setExtraInfo(config.extraInfo);
if (config.sourceOfferId) request.setSourceOfferId(config.sourceOfferId);
return (await this._offersClient.postOffer(request, {password: this._password})).getOffer()!;
} catch (e: any) {
throw new HavenoError(e.message, e.code);