diff --git a/README.md b/README.md
index 97eba492..f078e273 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ Running the [top-level API tests](./src/HavenoDaemon.test.ts) is a great way to
1. [Run a local Haveno test network](https://github.com/haveno-dex/haveno/blob/master/docs/installing.md), running Alice and Bob as daemons with `make alice-daemon` and `make bob-daemon`.
2. Clone this project to the same parent directory as the haveno project: `git clone https://github.com/haveno-dex/haveno-ui-poc`
-3. In a new terminal, start envoy with the config in haveno-ui-poc/config/envoy.test.yaml (change absolute path for your system): `docker run --rm --add-host host.docker.internal:host-gateway -it -v ~/git/haveno-ui-poc/config/envoy.test.yaml:/envoy.test.yaml -p 8080:8080 -p 8081:8081 envoyproxy/envoy-dev:8a2143613d43d17d1eb35a24b4a4a4c432215606 -c /envoy.test.yaml`
+3. In a new terminal, start envoy with the config in haveno-ui-poc/config/envoy.test.yaml (change absolute path for your system): `docker run --rm --add-host host.docker.internal:host-gateway -it -v ~/git/haveno-ui-poc/config/envoy.test.yaml:/envoy.test.yaml -p 8080:8080 -p 8081:8081 -p 8082:8082 -p 8083:8083 -p 8084:8084 -p 8085:8085 -p 8086:8086 envoyproxy/envoy-dev:8a2143613d43d17d1eb35a24b4a4a4c432215606 -c /envoy.test.yaml`
4. In a new terminal, start the funding wallet. This wallet will be automatically funded in order to fund Alice and Bob during the tests.
For example: `cd ~/git/haveno && make funding-wallet`.
5. Install protobuf for your system:
mac: `brew install protobuf`
diff --git a/config/envoy.test.yaml b/config/envoy.test.yaml
index 7c946c87..102e46a8 100644
--- a/config/envoy.test.yaml
+++ b/config/envoy.test.yaml
@@ -1,4 +1,4 @@
-# envoy configuration to test with alice and bob trader instances
+# envoy configuration to test with haveno instances
admin:
access_log_path: /tmp/admin_access.log
@@ -73,6 +73,171 @@ static_resources:
- name: envoy.filters.http.grpc_web
- name: envoy.filters.http.cors
- name: envoy.filters.http.router
+ - name: haveno1_listener
+ address:
+ socket_address: { address: 0.0.0.0, port_value: 8082 }
+ filter_chains:
+ - filters:
+ - name: envoy.filters.network.http_connection_manager
+ typed_config:
+ "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
+ codec_type: auto
+ stat_prefix: ingress_http
+ route_config:
+ name: local_route
+ virtual_hosts:
+ - name: local_service
+ domains: ["*"]
+ routes:
+ - match: { prefix: "/" }
+ route:
+ cluster: haveno1_service
+ timeout: 0s
+ max_stream_duration:
+ grpc_timeout_header_max: 0s
+ cors:
+ allow_origin_string_match:
+ - prefix: "*"
+ allow_methods: GET, PUT, DELETE, POST, OPTIONS
+ allow_headers: password,keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
+ max_age: "1728000"
+ expose_headers: custom-header-1,grpc-status,grpc-message
+ http_filters:
+ - name: envoy.filters.http.grpc_web
+ - name: envoy.filters.http.cors
+ - name: envoy.filters.http.router
+ - name: haveno2_listener
+ address:
+ socket_address: { address: 0.0.0.0, port_value: 8083 }
+ filter_chains:
+ - filters:
+ - name: envoy.filters.network.http_connection_manager
+ typed_config:
+ "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
+ codec_type: auto
+ stat_prefix: ingress_http
+ route_config:
+ name: local_route
+ virtual_hosts:
+ - name: local_service
+ domains: ["*"]
+ routes:
+ - match: { prefix: "/" }
+ route:
+ cluster: haveno2_service
+ timeout: 0s
+ max_stream_duration:
+ grpc_timeout_header_max: 0s
+ cors:
+ allow_origin_string_match:
+ - prefix: "*"
+ allow_methods: GET, PUT, DELETE, POST, OPTIONS
+ allow_headers: password,keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
+ max_age: "1728000"
+ expose_headers: custom-header-1,grpc-status,grpc-message
+ http_filters:
+ - name: envoy.filters.http.grpc_web
+ - name: envoy.filters.http.cors
+ - name: envoy.filters.http.router
+ - name: haveno3_listener
+ address:
+ socket_address: { address: 0.0.0.0, port_value: 8084 }
+ filter_chains:
+ - filters:
+ - name: envoy.filters.network.http_connection_manager
+ typed_config:
+ "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
+ codec_type: auto
+ stat_prefix: ingress_http
+ route_config:
+ name: local_route
+ virtual_hosts:
+ - name: local_service
+ domains: ["*"]
+ routes:
+ - match: { prefix: "/" }
+ route:
+ cluster: haveno3_service
+ timeout: 0s
+ max_stream_duration:
+ grpc_timeout_header_max: 0s
+ cors:
+ allow_origin_string_match:
+ - prefix: "*"
+ allow_methods: GET, PUT, DELETE, POST, OPTIONS
+ allow_headers: password,keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
+ max_age: "1728000"
+ expose_headers: custom-header-1,grpc-status,grpc-message
+ http_filters:
+ - name: envoy.filters.http.grpc_web
+ - name: envoy.filters.http.cors
+ - name: envoy.filters.http.router
+ - name: haveno4_listener
+ address:
+ socket_address: { address: 0.0.0.0, port_value: 8085 }
+ filter_chains:
+ - filters:
+ - name: envoy.filters.network.http_connection_manager
+ typed_config:
+ "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
+ codec_type: auto
+ stat_prefix: ingress_http
+ route_config:
+ name: local_route
+ virtual_hosts:
+ - name: local_service
+ domains: ["*"]
+ routes:
+ - match: { prefix: "/" }
+ route:
+ cluster: haveno4_service
+ timeout: 0s
+ max_stream_duration:
+ grpc_timeout_header_max: 0s
+ cors:
+ allow_origin_string_match:
+ - prefix: "*"
+ allow_methods: GET, PUT, DELETE, POST, OPTIONS
+ allow_headers: password,keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
+ max_age: "1728000"
+ expose_headers: custom-header-1,grpc-status,grpc-message
+ http_filters:
+ - name: envoy.filters.http.grpc_web
+ - name: envoy.filters.http.cors
+ - name: envoy.filters.http.router
+ - name: haveno5_listener
+ address:
+ socket_address: { address: 0.0.0.0, port_value: 8086 }
+ filter_chains:
+ - filters:
+ - name: envoy.filters.network.http_connection_manager
+ typed_config:
+ "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
+ codec_type: auto
+ stat_prefix: ingress_http
+ route_config:
+ name: local_route
+ virtual_hosts:
+ - name: local_service
+ domains: ["*"]
+ routes:
+ - match: { prefix: "/" }
+ route:
+ cluster: haveno5_service
+ timeout: 0s
+ max_stream_duration:
+ grpc_timeout_header_max: 0s
+ cors:
+ allow_origin_string_match:
+ - prefix: "*"
+ allow_methods: GET, PUT, DELETE, POST, OPTIONS
+ allow_headers: password,keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
+ max_age: "1728000"
+ expose_headers: custom-header-1,grpc-status,grpc-message
+ http_filters:
+ - name: envoy.filters.http.grpc_web
+ - name: envoy.filters.http.cors
+ - name: envoy.filters.http.router
clusters:
- name: alice_service
connect_timeout: 0.25s
@@ -101,4 +266,74 @@ static_resources:
address:
socket_address:
address: host.docker.internal
- port_value: 10000
\ No newline at end of file
+ port_value: 10000
+ - name: haveno1_service
+ connect_timeout: 0.25s
+ type: logical_dns
+ http2_protocol_options: {}
+ lb_policy: round_robin
+ load_assignment:
+ cluster_name: cluster_0
+ endpoints:
+ - lb_endpoints:
+ - endpoint:
+ address:
+ socket_address:
+ address: host.docker.internal
+ port_value: 10001
+ - name: haveno2_service
+ connect_timeout: 0.25s
+ type: logical_dns
+ http2_protocol_options: {}
+ lb_policy: round_robin
+ load_assignment:
+ cluster_name: cluster_0
+ endpoints:
+ - lb_endpoints:
+ - endpoint:
+ address:
+ socket_address:
+ address: host.docker.internal
+ port_value: 10002
+ - name: haveno3_service
+ connect_timeout: 0.25s
+ type: logical_dns
+ http2_protocol_options: {}
+ lb_policy: round_robin
+ load_assignment:
+ cluster_name: cluster_0
+ endpoints:
+ - lb_endpoints:
+ - endpoint:
+ address:
+ socket_address:
+ address: host.docker.internal
+ port_value: 10003
+ - name: haveno4_service
+ connect_timeout: 0.25s
+ type: logical_dns
+ http2_protocol_options: {}
+ lb_policy: round_robin
+ load_assignment:
+ cluster_name: cluster_0
+ endpoints:
+ - lb_endpoints:
+ - endpoint:
+ address:
+ socket_address:
+ address: host.docker.internal
+ port_value: 10004
+ - name: haveno5_service
+ connect_timeout: 0.25s
+ type: logical_dns
+ http2_protocol_options: {}
+ lb_policy: round_robin
+ load_assignment:
+ cluster_name: cluster_0
+ endpoints:
+ - lb_endpoints:
+ - endpoint:
+ address:
+ socket_address:
+ address: host.docker.internal
+ port_value: 10005
\ No newline at end of file
diff --git a/src/HavenoDaemon.test.ts b/src/HavenoDaemon.test.ts
index 168f34a9..4bc36e15 100644
--- a/src/HavenoDaemon.test.ts
+++ b/src/HavenoDaemon.test.ts
@@ -2,6 +2,8 @@
// import haveno types
import {HavenoDaemon} from "./HavenoDaemon";
+import {HavenoUtils} from "./HavenoUtils";
+import * as grpcWeb from 'grpc-web';
import {XmrBalanceInfo, OfferInfo, TradeInfo, MarketPriceInfo} from './protobuf/grpc_pb'; // TODO (woodser): better names; haveno_grpc_pb, haveno_pb
import {PaymentAccount, Offer} from './protobuf/pb_pb';
@@ -11,38 +13,45 @@ const GenUtils = monerojs.GenUtils;
const MoneroTxConfig = monerojs.MoneroTxConfig;
const TaskLooper = monerojs.TaskLooper;
-// import console because jest swallows messages in real time
-const console = require('console');
+// other required imports
+const console = require('console'); // import console because jest swallows messages in real time
+const assert = require("assert");
// --------------------------- TEST CONFIGURATION -----------------------------
+// set log level (gets more verbose increasing from 0)
+HavenoUtils.setLogLevel(0);
+
+// path to directory with haveno binaries
+const HAVENO_PATH = "../haveno";
+
// wallet to fund alice and bob during tests
-const fundingWalletUrl = "http://localhost:38084";
-const fundingWalletUsername = "rpc_user";
-const fundingWalletPassword = "abc123";
-const defaultFundingWalletPath = "test_funding_wallet";
-const minimumFunding = BigInt("5000000000000");
+const FUNDING_WALLET_URL = "http://localhost:38084";
+const FUNDING_WALLET_USERNAME = "rpc_user";
+const FUNDING_WALLET_PASSWORD = "abc123";
+const DEFAULT_FUNDING_WALLET_PATH = "test_funding_wallet";
+const MINIMUM_FUNDING = BigInt("5000000000000");
let fundingWallet: any;
// alice config
-const havenoVersion = "1.6.2";
-const aliceDaemonUrl = "http://localhost:8080";
-const aliceDaemonPassword = "apitest";
-const alice: HavenoDaemon = new HavenoDaemon(aliceDaemonUrl, aliceDaemonPassword);
-const aliceWalletUrl = "http://127.0.0.1:38091"; // alice's internal haveno wallet for direct testing
-const aliceWalletUsername = "rpc_user";
-const aliceWalletPassword = "abc123";
+const HAVENO_VERSION = "1.6.2";
+const ALICE_DAEMON_URL = "http://localhost:8080";
+const ALICE_DAEMON_PASSWORD = "apitest";
+const ALICE_WALLET_URL = "http://127.0.0.1:38091"; // alice's internal haveno wallet for direct testing
+const ALICE_WALLET_USERNAME = "rpc_user";
+const ALICE_WALLET_PASSWORD = "abc123";
+let alice: HavenoDaemon;
let aliceWallet: any;
// bob config
-const bobDaemonUrl = "http://localhost:8081";
-const bobDaemonPassword = "apitest";
-const bob: HavenoDaemon = new HavenoDaemon(bobDaemonUrl, bobDaemonPassword);
+const BOB_DAEMON_URL = "http://localhost:8081";
+const BOB_DAEMON_PASSWORD = "apitest";
+let bob: HavenoDaemon = new HavenoDaemon(BOB_DAEMON_URL, BOB_DAEMON_PASSWORD);
// monero daemon config
-const moneroDaemonUrl = "http://localhost:38081"
-const moneroDaemonUsername = "superuser";
-const moneroDaemonPassword = "abctesting123";
+const MONERO_DAEMON_URL = "http://localhost:38081"
+const MONERO_DAEMON_USERNAME = "superuser";
+const MONERO_DAEMON_PASSWORD = "abctesting123";
let monerod: any;
// other test config
@@ -60,15 +69,29 @@ const TEST_CRYPTO_ACCOUNTS = [ // TODO (woodser): test other cryptos, fiat
}
];
+// map proxied ports to havenod api and p2p ports
+const PROXY_PORTS = new Map([
+ ["8080", ["9999", "5555"]],
+ ["8081", ["10000", "6666"]],
+ ["8082", ["10001", "7777"]],
+ ["8083", ["10002", "7778"]],
+ ["8084", ["10003", "7779"]],
+ ["8085", ["10004", "7780"]],
+ ["8086", ["10005", "7781"]],
+]);
+
+// track started haveno processes
+const HAVENO_PROCESSES: HavenoDaemon[] = [];
+
// ----------------------------------- TESTS ----------------------------------
beforeAll(async () => {
- // initialize client of monerod
- monerod = await monerojs.connectToDaemonRpc(moneroDaemonUrl, moneroDaemonUsername, moneroDaemonPassword);
-
- // create client connected to alice's internal wallet
- aliceWallet = await monerojs.connectToWalletRpc(aliceWalletUrl, aliceWalletUsername, aliceWalletPassword);
+ // initialize clients
+ alice = new HavenoDaemon(ALICE_DAEMON_URL, ALICE_DAEMON_PASSWORD);
+ bob = new HavenoDaemon(BOB_DAEMON_URL, BOB_DAEMON_PASSWORD);
+ monerod = await monerojs.connectToDaemonRpc(MONERO_DAEMON_URL, MONERO_DAEMON_USERNAME, MONERO_DAEMON_PASSWORD);
+ aliceWallet = await monerojs.connectToWalletRpc(ALICE_WALLET_URL, ALICE_WALLET_USERNAME, ALICE_WALLET_PASSWORD);
// initialize funding wallet
await initFundingWallet();
@@ -83,7 +106,7 @@ beforeAll(async () => {
jest.setTimeout(300000);
test("Can get the version", async () => {
let version = await alice.getVersion();
- expect(version).toEqual(havenoVersion);
+ expect(version).toEqual(HAVENO_VERSION);
});
test("Can get market prices", async () => {
@@ -201,7 +224,7 @@ test("Can post and remove an offer", async () => {
let unlockedBalanceBefore: bigint = BigInt((await alice.getBalances()).getUnlockedBalance());
// post offer
- let offer: OfferInfo = await postOffer(alice, "buy", BigInt("200000000000"));
+ let offer: OfferInfo = await postOffer(alice, "buy", BigInt("200000000000"), undefined);
// cancel offer
await alice.removeOffer(offer.getId());
@@ -225,7 +248,7 @@ test("Invalidates offers when reserved funds are spent", async () => {
// post offer
await wait(1000);
- let offer: OfferInfo = await postOffer(alice, "buy", tradeAmount);
+ let offer: OfferInfo = await postOffer(alice, "buy", tradeAmount, undefined);
// get key images reserved by offer
let reservedKeyImages = [];
@@ -266,9 +289,73 @@ test("Invalidates offers when reserved funds are spent", async () => {
await monerod.flushTxPool(tx.getHash());
});
+test("Cannot make or take offer with insufficient unlocked funds", async () => {
+ let charlie: HavenoDaemon | undefined;
+ let err: any;
+ try {
+
+ // start charlie
+ charlie = await startTraderProcess();
+
+ // charlie creates ethereum payment account
+ let testAccount = TEST_CRYPTO_ACCOUNTS[0];
+ let ethPaymentAccount: PaymentAccount = await charlie.createCryptoPaymentAccount(
+ testAccount.currencyCode + " " + testAccount.address.substr(0, 8) + "... " + GenUtils.getUUID(),
+ testAccount.currencyCode,
+ testAccount.address);
+
+ // charlie cannot make offer with insufficient funds
+ try {
+ await postOffer(charlie, "buy", BigInt("200000000000"), ethPaymentAccount.getId());
+ throw new Error("Should have failed making offer with insufficient funds")
+ } catch (err) {
+ let errTyped = err as grpcWeb.RpcError;
+ assert.equal(errTyped.code, 2);
+ assert(errTyped.message.includes("not enough money"));
+ }
+
+ // alice posts offer
+ let offers: OfferInfo[] = await alice.getMyOffers("buy"); // TODO: support alice.getMyOffers() without direction
+ let offer: OfferInfo;
+ if (offers.length) offer = offers[0];
+ else {
+ let tradeAmount: bigint = BigInt("250000000000");
+ await waitForUnlockedBalance(tradeAmount * BigInt("2"), alice);
+ offer = await postOffer(alice, "buy", tradeAmount, undefined);
+ assert.equal(offer.getState(), "AVAILABLE");
+ }
+
+ // charlie cannot take offer with insufficient funds
+ try {
+ await charlie.takeOffer(offer.getId(), ethPaymentAccount.getId()); // TODO (woodser): this returns before trade is fully initialized. this fails with bad error message if trade is not yet seen by peer
+ throw new Error("Should have failed taking offer with insufficient funds")
+ } catch (err) {
+ let errTyped = err as grpcWeb.RpcError;
+ assert.equal(errTyped.code, 2);
+ assert(errTyped.message.includes("not enough money")); // TODO (woodser): error message does not contain stacktrace
+ }
+
+ // charlie does not have trade
+ try {
+ await charlie.getTrade(offer.getId());
+ } catch (err) {
+ let errTyped = err as grpcWeb.RpcError;
+ assert.equal(errTyped.code, 3);
+ assert(errTyped.message.includes("trade with id '" + offer.getId() + "' not found")); // TODO (woodser): error message does not contain stacktrace
+ }
+ } catch (err2) {
+ err = err2;
+ }
+
+ // stop charlie
+ if (charlie) await stopHavenoProcess(charlie);
+ // TODO: how to delete trader app folder at end of test?
+ if (err) throw err;
+});
+
// TODO (woodser): test grpc notifications
test("Can complete a trade", async () => {
-
+
// wait for alice and bob to have unlocked balance for trade
let tradeAmount: bigint = BigInt("250000000000");
await waitForUnlockedBalance(tradeAmount * BigInt("2"), alice, bob);
@@ -278,7 +365,7 @@ test("Can complete a trade", async () => {
// alice posts offer to buy xmr
console.log("Alice posting offer");
let direction = "buy";
- let offer: OfferInfo = await postOffer(alice, direction, tradeAmount);
+ let offer: OfferInfo = await postOffer(alice, direction, tradeAmount, undefined);
expect(offer.getState()).toEqual("AVAILABLE");
console.log("Alice done posting offer");
@@ -368,13 +455,60 @@ test("Can complete a trade", async () => {
// ------------------------------- HELPERS ------------------------------------
+/**
+ * Start a Haveno trader process.
+ *
+ * @return {HavenoDaemon} the client connected to the started Haveno process
+ */
+async function startTraderProcess(): Promise {
+
+ // iterate to find unused proxy port
+ for (let port of Array.from(PROXY_PORTS.keys())) {
+ if (port === "8080" || port === "8081") continue; // reserved for alice and bob
+ let used = false;
+ for (let havenod of HAVENO_PROCESSES) {
+ if (port === new URL(havenod.getUrl()).port) {
+ used = true;
+ break;
+ }
+ }
+
+ // start haveno process on unused port
+ if (!used) {
+ let appName = "haveno-XMR_STAGENET_trader_" + GenUtils.getUUID();
+ let cmd: string[] = [
+ "./haveno-daemon",
+ "--baseCurrencyNetwork", "XMR_STAGENET",
+ "--useLocalhostForP2P", "true",
+ "--useDevPrivilegeKeys", "true",
+ "--nodePort", PROXY_PORTS.get(port)![1],
+ "--appName", appName,
+ "--apiPassword", "apitest",
+ "--apiPort", PROXY_PORTS.get(port)![0]
+ ];
+ let havenod = await HavenoDaemon.startProcess(HAVENO_PATH, cmd, "http://localhost:" + port);
+ HAVENO_PROCESSES.push(havenod);
+ return havenod;
+ }
+ }
+ throw new Error("No unused test ports available");
+}
+
+/**
+ * Stop a Haveno trader process and release its ports for reuse.
+ */
+async function stopHavenoProcess(havenod: HavenoDaemon) {
+ await havenod.stopProcess();
+ GenUtils.remove(HAVENO_PROCESSES, havenod);
+}
+
/**
* Open or create funding wallet.
*/
async function initFundingWallet() {
// init client connected to monero-wallet-rpc
- fundingWallet = await monerojs.connectToWalletRpc(fundingWalletUrl, fundingWalletUsername, fundingWalletPassword);
+ fundingWallet = await monerojs.connectToWalletRpc(FUNDING_WALLET_URL, FUNDING_WALLET_USERNAME, FUNDING_WALLET_PASSWORD);
// check if wallet is open
let walletIsOpen = false
@@ -388,7 +522,7 @@ async function initFundingWallet() {
// attempt to open funding wallet
try {
- await fundingWallet.openWallet({path: defaultFundingWalletPath, password: fundingWalletPassword});
+ await fundingWallet.openWallet({path: DEFAULT_FUNDING_WALLET_PATH, password: FUNDING_WALLET_PASSWORD});
} catch (e) {
if (!(e instanceof monerojs.MoneroRpcError)) throw e;
@@ -396,7 +530,7 @@ async function initFundingWallet() {
if (e.getCode() === -1) {
// create wallet
- await fundingWallet.createWallet({path: defaultFundingWalletPath, password: fundingWalletPassword});
+ await fundingWallet.createWallet({path: DEFAULT_FUNDING_WALLET_PATH, password: FUNDING_WALLET_PASSWORD});
} else {
throw e;
}
@@ -447,7 +581,7 @@ async function waitForUnlockedBalance(amount: bigint, ...wallets: any[]) {
if (depositNeeded > BigInt("0") && wallet._wallet !== fundingWallet) fundConfig.addDestination(await wallet.getDepositAddress(), depositNeeded * BigInt("10")); // deposit 10 times more than needed
}
if (fundConfig.getDestinations()) {
- await waitForUnlockedBalance(minimumFunding, fundingWallet); // TODO (woodser): wait for enough to cover tx amount + fee
+ await waitForUnlockedBalance(MINIMUM_FUNDING, fundingWallet); // TODO (woodser): wait for enough to cover tx amount + fee
try { await fundingWallet.createTx(fundConfig); }
catch (err) { throw new Error("Error funding wallets: " + err.message); }
}
@@ -506,14 +640,17 @@ async function wait(durationMs: number) {
return new Promise(function(resolve) { setTimeout(resolve, durationMs); });
}
-async function postOffer(maker: HavenoDaemon, direction: string, amount: bigint) {
+async function postOffer(maker: HavenoDaemon, direction: string, amount: bigint, paymentAccountId: string|undefined) {
- // maker creates ethereum payment account
- let testAccount = TEST_CRYPTO_ACCOUNTS[0];
- let ethPaymentAccount: PaymentAccount = await maker.createCryptoPaymentAccount(
- testAccount.currencyCode + " " + testAccount.address.substr(0, 8) + "... " + GenUtils.getUUID(),
- testAccount.currencyCode,
- testAccount.address);
+ // create payment account if not given
+ if (!paymentAccountId) {
+ let testAccount = TEST_CRYPTO_ACCOUNTS[0];
+ let ethPaymentAccount: PaymentAccount = await maker.createCryptoPaymentAccount(
+ testAccount.currencyCode + " " + testAccount.address.substr(0, 8) + "... " + GenUtils.getUUID(),
+ testAccount.currencyCode,
+ testAccount.address);
+ paymentAccountId = ethPaymentAccount.getId();
+ }
// get unlocked balance before reserving offer
let unlockedBalanceBefore: bigint = BigInt((await maker.getBalances()).getUnlockedBalance());
@@ -527,7 +664,7 @@ async function postOffer(maker: HavenoDaemon, direction: string, amount: bigint)
amount, // amount
BigInt("150000000000"), // min amount
0.15, // buyer security deposit, e.g. 15%
- ethPaymentAccount.getId(), // payment account id
+ paymentAccountId, // payment account id
undefined); // trigger price // TODO: fails if there is a decimal, gets converted to long?
testOffer(offer);
diff --git a/src/HavenoDaemon.ts b/src/HavenoDaemon.ts
index c4a2d552..ab1224e2 100644
--- a/src/HavenoDaemon.ts
+++ b/src/HavenoDaemon.ts
@@ -1,7 +1,9 @@
+import {HavenoUtils} from "./HavenoUtils";
import * as grpcWeb from 'grpc-web';
import {GetVersionClient, PriceClient, WalletsClient, OffersClient, PaymentAccountsClient, TradesClient} from './protobuf/GrpcServiceClientPb';
import {GetVersionRequest, GetVersionReply, MarketPriceRequest, MarketPriceReply, MarketPricesRequest, MarketPricesReply, MarketPriceInfo, GetBalancesRequest, GetBalancesReply, XmrBalanceInfo, GetOffersRequest, GetOffersReply, OfferInfo, GetPaymentAccountsRequest, GetPaymentAccountsReply, CreateCryptoCurrencyPaymentAccountRequest, CreateCryptoCurrencyPaymentAccountReply, CreateOfferRequest, CreateOfferReply, CancelOfferRequest, TakeOfferRequest, TakeOfferReply, TradeInfo, GetTradeRequest, GetTradeReply, GetNewDepositSubaddressRequest, GetNewDepositSubaddressReply, ConfirmPaymentStartedRequest, ConfirmPaymentReceivedRequest} from './protobuf/grpc_pb';
import {PaymentAccount, AvailabilityResult} from './protobuf/pb_pb';
+const console = require('console');
/**
* Haveno daemon client using gRPC.
@@ -11,6 +13,7 @@ class HavenoDaemon {
// instance variables
_url: string;
_password: string;
+ _process: any;
_getVersionClient: GetVersionClient;
_priceClient: PriceClient;
_walletsClient: WalletsClient;
@@ -22,9 +25,12 @@ class HavenoDaemon {
* Construct a client connected to a Haveno daemon.
*
* @param {string} url - Haveno daemon url
- * @param {string} password - Haveno daemon password if applicable
+ * @param {string} password - Haveno daemon password
*/
constructor(url: string, password: string) {
+ HavenoUtils.log(1, "Creating HavenoDaemon(" + url + ", " + password + ")");
+ if (!url) throw new Error("Must provide URL of Haveno daemon");
+ if (!password) throw new Error("Must provide password of Haveno daemon");
this._url = url;
this._password = password;
this._getVersionClient = new GetVersionClient(this._url);
@@ -35,6 +41,101 @@ class HavenoDaemon {
this._tradesClient = new TradesClient(this._url);
}
+ /**
+ * Start a new Haveno process.
+ *
+ * @param {string} havenoPath - path to Haveno binaries
+ * @param {string[]} cmd - command to start the process
+ * @param {string} url - Haveno daemon url (must proxy to api port)
+ * @return {HavenoDaemon} a client connected to the newly started Haveno process
+ */
+ static async startProcess(havenoPath: string, cmd: string[], url: string): Promise {
+ HavenoUtils.log(1, "Starting Haveno process: " + cmd);
+
+ // start process
+ let process = require('child_process').spawn(cmd[0], cmd.slice(1), {cwd: havenoPath});
+ process.stdout.setEncoding('utf8');
+ process.stderr.setEncoding('utf8');
+
+ // return promise which resolves after starting havenod
+ let output = "";
+ let isResolved = false;
+ return new Promise(function(resolve, reject) {
+
+ // handle stdout
+ process.stdout.on('data', async function(data: any) {
+ let line = data.toString();
+ HavenoUtils.log(2, line);
+ output += line + '\n'; // capture output in case of error
+
+ // read success message
+ if (line.indexOf("initDomainServices") >= 0) {
+
+ // get api password
+ let passwordIdx = cmd.indexOf("--apiPassword");
+ if (passwordIdx < 0) {
+ reject("Must provide API password to start Haveno daemon");
+ return;
+ }
+ let password = cmd[passwordIdx + 1];
+
+ // create client connected to internal process
+ let daemon = new HavenoDaemon(url, password);
+ daemon._process = process;
+
+ // resolve promise with client connected to internal process
+ isResolved = true;
+ resolve(daemon);
+ }
+ });
+
+ // handle stderr
+ process.stderr.on('data', function(data: any) {
+ if (HavenoUtils.getLogLevel() >= 2) console.error(data);
+ });
+
+ // handle exit
+ process.on("exit", function(code: any) {
+ if (!isResolved) reject(new Error("Haveno process terminated with exit code " + code + (output ? ":\n\n" + output : "")));
+ });
+
+ // handle error
+ process.on("error", function(err: any) {
+ if (err.message.indexOf("ENOENT") >= 0) reject(new Error("haveno-daemon does not exist at path '" + cmd[0] + "'"));
+ if (!isResolved) reject(err);
+ });
+
+ // handle uncaught exception
+ process.on("uncaughtException", function(err: any, origin: any) {
+ console.error("Uncaught exception in Haveno process: " + err.message);
+ console.error(origin);
+ reject(err);
+ });
+ });
+ }
+
+ /**
+ * Stop a previously started Haveno process.
+ */
+ async stopProcess(): Promise {
+ if (this._process === undefined) throw new Error("HavenoDaemon instance not created from new process");
+ let that = this;
+ return new Promise(function(resolve, reject) {
+ that._process.on("exit", function() { resolve(); });
+ that._process.on("error", function(err: any) { reject(err); });
+ that._process.kill("SIGINT");
+ });
+ }
+
+ /**
+ * Get the URL of the Haveno daemon.
+ *
+ * @return {string} the URL of the Haveno daemon
+ */
+ getUrl(): string {
+ return this._url;
+ }
+
/**
* Get the Haveno version.
*
@@ -198,7 +299,7 @@ class HavenoDaemon {
* @param {number} buyerSecurityDeposit - buyer security deposit as % of trade amount
* @param {string} paymentAccountId - payment account id
* @param {number} triggerPrice - price to remove offer
- * @return {OfferInfo} the created offer
+ * @return {OfferInfo} the posted offer
*/
async postOffer(currencyCode: string,
direction: string,
diff --git a/src/HavenoUtils.ts b/src/HavenoUtils.ts
new file mode 100644
index 00000000..4ead44a8
--- /dev/null
+++ b/src/HavenoUtils.ts
@@ -0,0 +1,42 @@
+const assert = require("assert");
+const console = require('console');
+
+/**
+ * Collection of utilities for working with Haveno.
+ */
+class HavenoUtils {
+
+ static LOG_LEVEL = 0;
+
+ /**
+ * Log a message.
+ *
+ * @param {int} level - log level of the message
+ * @param {string} msg - message to log
+ */
+ static log(level: number, msg: string) {
+ assert(level === parseInt(level + "", 10) && level >= 0, "Log level must be an integer >= 0");
+ if (HavenoUtils.LOG_LEVEL >= level) process.stdout.write(msg);
+ }
+
+ /**
+ * Set the log level with 0 being least verbose.
+ *
+ * @param {int} level - the log level
+ */
+ static async setLogLevel(level: number) {
+ assert(level === parseInt(level + "", 10) && level >= 0, "Log level must be an integer >= 0");
+ HavenoUtils.LOG_LEVEL = level;
+ }
+
+ /**
+ * Get the log level.
+ *
+ * @return {int} the current log level
+ */
+ static getLogLevel(): number {
+ return HavenoUtils.LOG_LEVEL;
+ }
+}
+
+export {HavenoUtils};