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};