Add API functions to initialize Haveno account (#64)

Co-authored-by: woodser@protonmail.com
This commit is contained in:
duriancrepe 2022-02-09 01:41:00 -08:00 committed by GitHub
parent f53b9c3437
commit 0df355faa7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 705 additions and 329 deletions

2
.gitignore vendored
View File

@ -7,6 +7,7 @@
# testing # testing
/coverage /coverage
backup.zip
# production # production
/build /build
@ -17,7 +18,6 @@
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*

View File

@ -4,11 +4,11 @@
import {HavenoDaemon} from "./HavenoDaemon"; import {HavenoDaemon} from "./HavenoDaemon";
import {HavenoUtils} from "./utils/HavenoUtils"; import {HavenoUtils} from "./utils/HavenoUtils";
import * as grpcWeb from 'grpc-web'; import * as grpcWeb from 'grpc-web';
import {MarketPriceInfo, NotificationMessage, OfferInfo, TradeInfo, UriConnection, XmrBalanceInfo} from './protobuf/grpc_pb'; // TODO (woodser): better names; haveno_grpc_pb, haveno_pb import {MarketPriceInfo, NotificationMessage, OfferInfo, TradeInfo, UrlConnection, XmrBalanceInfo} from './protobuf/grpc_pb'; // TODO (woodser): better names; haveno_grpc_pb, haveno_pb
import {PaymentAccount} from './protobuf/pb_pb'; import {PaymentAccount} from './protobuf/pb_pb';
import {XmrDestination, XmrTx, XmrIncomingTransfer, XmrOutgoingTransfer} from './protobuf/grpc_pb'; import {XmrDestination, XmrTx, XmrIncomingTransfer, XmrOutgoingTransfer} from './protobuf/grpc_pb';
import AuthenticationStatus = UriConnection.AuthenticationStatus; import AuthenticationStatus = UrlConnection.AuthenticationStatus;
import OnlineStatus = UriConnection.OnlineStatus; import OnlineStatus = UrlConnection.OnlineStatus;
// import monero-javascript // import monero-javascript
const monerojs = require("monero-javascript"); // TODO (woodser): support typescript and `npm install @types/monero-javascript` in monero-javascript const monerojs = require("monero-javascript"); // TODO (woodser): support typescript and `npm install @types/monero-javascript` in monero-javascript
@ -19,18 +19,15 @@ const MoneroUtils = monerojs.MoneroUtils;
const TaskLooper = monerojs.TaskLooper; const TaskLooper = monerojs.TaskLooper;
// other required imports // other required imports
const console = require('console'); // import console because jest swallows messages in real time const fs = require('fs');
const assert = require("assert");
const net = require('net'); const net = require('net');
const assert = require("assert");
const console = require('console'); // import console because jest swallows messages in real time
// ------------------------------ TEST CONFIG --------------------------------- // ------------------------------ TEST CONFIG ---------------------------------
// test config
const TestConfig = { const TestConfig = {
logging: { logLevel: 0,
level: 0, // set log level (gets more verbose increasing from 0)
logProcessOutput: false // enable or disable logging process output
},
moneroBinsDir: "../haveno/.localnet", moneroBinsDir: "../haveno/.localnet",
networkType: monerojs.MoneroNetworkType.STAGENET, networkType: monerojs.MoneroNetworkType.STAGENET,
haveno: { haveno: {
@ -38,45 +35,52 @@ const TestConfig = {
version: "1.6.2" version: "1.6.2"
}, },
monerod: { monerod: {
uri: "http://localhost:38081", url: "http://localhost:38081",
username: "superuser", username: "superuser",
password: "abctesting123" password: "abctesting123"
}, },
monerod2: { monerod2: {
uri: "http://localhost:58081", url: "http://localhost:58081",
username: "superuser", username: "superuser",
password: "abctesting123" password: "abctesting123"
}, },
fundingWallet: { fundingWallet: {
uri: "http://localhost:38084", url: "http://localhost:38084",
username: "rpc_user", username: "rpc_user",
password: "abc123", password: "abc123",
defaultPath: "test_funding_wallet", defaultPath: "test_funding_wallet",
minimumFunding: BigInt("5000000000000") minimumFunding: BigInt("5000000000000")
}, },
arbitrator: { defaultHavenod: {
logProcessOutput: false, logProcessOutput: false, // log output for processes started by tests (except arbitrator, alice, and bob which are configured separately)
appName: "haveno-XMR_STAGENET_arbitrator", apiPassword: "apitest",
uri: "http://localhost:8079", walletUsername: "haveno_user",
password: "apitest", walletDefaultPassword: "password", // only used if account password not set
walletUsername: "rpc_user", accountPasswordRequired: true,
walletPassword: "abc123" accountPassword: "abctesting789",
autoLogin: true
}, },
alice: { startupHavenods: [{
logProcessOutput: false, appName: "haveno-XMR_STAGENET_arbitrator", // arbritrator
appName: "haveno-XMR_STAGENET_alice", logProcessOutput: false,
uri: "http://localhost:8080", url: "http://localhost:8079",
password: "apitest", accountPasswordRequired: false,
walletUri: "http://127.0.0.1:38091", accountPassword: "abctesting123",
walletUsername: "rpc_user", }, {
walletPassword: "abc123" appName: "haveno-XMR_STAGENET_alice", // alice
}, logProcessOutput: false,
bob: { url: "http://localhost:8080",
logProcessOutput: false, accountPasswordRequired: false,
appName: "haveno-XMR_STAGENET_bob", accountPassword: "abctesting456",
uri: "http://localhost:8081", walletUrl: "http://127.0.0.1:38091",
password: "apitest", }, {
}, appName: "haveno-XMR_STAGENET_bob", // bob
logProcessOutput: false,
url: "http://localhost:8081",
accountPasswordRequired: false,
accountPassword: "abctesting789",
}
],
maxFee: BigInt("75000000000"), maxFee: BigInt("75000000000"),
walletSyncPeriodMs: 5000, // TODO (woodser): auto adjust higher if using remote connection walletSyncPeriodMs: 5000, // TODO (woodser): auto adjust higher if using remote connection
daemonPollPeriodMs: 15000, daemonPollPeriodMs: 15000,
@ -99,7 +103,8 @@ const TestConfig = {
["8085", ["10004", "7780"]], ["8085", ["10004", "7780"]],
["8086", ["10005", "7781"]], ["8086", ["10005", "7781"]],
]), ]),
devPrivilegePrivKey: "6ac43ea1df2a290c1c8391736aa42e4339c5cb4f110ff0257a13b63211977b7a" // from DEV_PRIVILEGE_PRIV_KEY devPrivilegePrivKey: "6ac43ea1df2a290c1c8391736aa42e4339c5cb4f110ff0257a13b63211977b7a", // from DEV_PRIVILEGE_PRIV_KEY
timeout: 900000 // timeout in ms for all tests to complete (15 minutes)
}; };
interface TxContext { interface TxContext {
@ -107,6 +112,7 @@ interface TxContext {
} }
// clients // clients
let startupHavenods: HavenoDaemon[] = [];
let arbitrator: HavenoDaemon; let arbitrator: HavenoDaemon;
let alice: HavenoDaemon; let alice: HavenoDaemon;
let bob: HavenoDaemon; let bob: HavenoDaemon;
@ -118,42 +124,41 @@ let aliceWallet: any;
const HAVENO_PROCESSES: HavenoDaemon[] = []; const HAVENO_PROCESSES: HavenoDaemon[] = [];
const HAVENO_PROCESS_PORTS: string[] = []; const HAVENO_PROCESS_PORTS: string[] = [];
// other config
const OFFLINE_ERR_MSG = "Http response at 400 or 500 level";
// -------------------------- BEFORE / AFTER TESTS ---------------------------- // -------------------------- BEFORE / AFTER TESTS ----------------------------
jest.setTimeout(TestConfig.timeout);
beforeAll(async () => { beforeAll(async () => {
// set log level for tests // set log level for tests
HavenoUtils.setLogLevel(TestConfig.logging.level); HavenoUtils.setLogLevel(TestConfig.logLevel);
// connect to arbitrator, alice, and bob or start as child processes // start configured haveno daemons
let daemonPromises = []; let promises = [];
daemonPromises.push(initHavenoDaemon(TestConfig.arbitrator)); for (let config of TestConfig.startupHavenods) promises.push(initHavenoDaemon(config));
daemonPromises.push(initHavenoDaemon(TestConfig.alice)); for (let settledPromise of await Promise.allSettled(promises)) {
daemonPromises.push(initHavenoDaemon(TestConfig.bob)); if (settledPromise.status !== "fulfilled") throw new Error((settledPromise as PromiseRejectedResult).reason);
let daemons = await Promise.allSettled(daemonPromises); startupHavenods.push((settledPromise as PromiseFulfilledResult<HavenoDaemon>).value);
if (daemons[0].status === "fulfilled") arbitrator = (daemons[0] as PromiseFulfilledResult<HavenoDaemon>).value; }
else throw new Error((daemons[0] as PromiseRejectedResult).reason);
if (daemons[1].status === "fulfilled") alice = (daemons[1] as PromiseFulfilledResult<HavenoDaemon>).value; // assign arbitrator alice, bob
else throw new Error((daemons[1] as PromiseRejectedResult).reason); arbitrator = startupHavenods[0];
if (daemons[2].status === "fulfilled") bob = (daemons[2] as PromiseFulfilledResult<HavenoDaemon>).value; alice = startupHavenods[1];
else throw new Error((daemons[2] as PromiseRejectedResult).reason); bob = startupHavenods[2];
// register arbitrator as dispute agents // register arbitrator as dispute agents
await arbitrator.registerDisputeAgent("mediator", TestConfig.devPrivilegePrivKey); await arbitrator.registerDisputeAgent("mediator", TestConfig.devPrivilegePrivKey);
await arbitrator.registerDisputeAgent("refundagent", TestConfig.devPrivilegePrivKey); await arbitrator.registerDisputeAgent("refundagent", TestConfig.devPrivilegePrivKey);
// connect monero clients // connect monero clients
monerod = await monerojs.connectToDaemonRpc(TestConfig.monerod.uri, TestConfig.monerod.username, TestConfig.monerod.password); monerod = await monerojs.connectToDaemonRpc(TestConfig.monerod.url, TestConfig.monerod.username, TestConfig.monerod.password);
aliceWallet = await monerojs.connectToWalletRpc(TestConfig.alice.walletUri, TestConfig.alice.walletUsername, TestConfig.alice.walletPassword); aliceWallet = await monerojs.connectToWalletRpc(TestConfig.startupHavenods[1].walletUrl, TestConfig.defaultHavenod.walletUsername, TestConfig.startupHavenods[1].accountPasswordRequired ? TestConfig.startupHavenods[1].accountPassword : TestConfig.defaultHavenod.walletDefaultPassword);
// initialize funding wallet // initialize funding wallet
await initFundingWallet(); await initFundingWallet();
// debug tools
//for (let offer of await alice.getMyOffers("BUY")) await alice.removeOffer(offer.getId());
//for (let offer of await alice.getMyOffers("SELL")) await alice.removeOffer(offer.getId());
//console.log((await alice.getBalances()).getUnlockedBalance() + ", " + (await alice.getBalances()).getLockedBalance());
//console.log((await bob.getBalances()).getUnlockedBalance() + ", " + (await bob.getBalances()).getLockedBalance());
}); });
beforeEach(async() => { beforeEach(async() => {
@ -161,15 +166,11 @@ beforeEach(async() => {
}); });
afterAll(async () => { afterAll(async () => {
let stopPromises = []; let promises = [];
if (arbitrator && arbitrator.getProcess()) stopPromises.push(stopHavenoProcess(arbitrator)); for (let havenod of startupHavenods) if (havenod.getProcess()) promises.push(releaseHavenoProcess(havenod));
if (alice && alice.getProcess()) stopPromises.push(stopHavenoProcess(alice)); return Promise.all(promises);
if (bob && bob.getProcess()) stopPromises.push(stopHavenoProcess(bob));
return Promise.all(stopPromises);
}); });
jest.setTimeout(500000);
// ----------------------------------- TESTS ---------------------------------- // ----------------------------------- TESTS ----------------------------------
test("Can get the version", async () => { test("Can get the version", async () => {
@ -177,50 +178,114 @@ test("Can get the version", async () => {
expect(version).toEqual(TestConfig.haveno.version); expect(version).toEqual(TestConfig.haveno.version);
}); });
test("Can register as dispute agents", async () => { test("Can manage an account", async () => {
await arbitrator.registerDisputeAgent("mediator", TestConfig.devPrivilegePrivKey); // TODO: bisq mediator = haveno arbitrator let charlie: HavenoDaemon | undefined;
await arbitrator.registerDisputeAgent("refundagent", TestConfig.devPrivilegePrivKey); // TODO: no refund agent in haveno let err: any;
// test bad dispute agent type
try { try {
await arbitrator.registerDisputeAgent("unsupported type", TestConfig.devPrivilegePrivKey);
throw new Error("should have thrown error registering bad type"); // start charlie without opening account
} catch (err) { charlie = await initHavenoDaemon({autoLogin: false});
if (err.message !== "unknown dispute agent type 'unsupported type'") throw new Error("Unexpected error: " + err.message); assert(!await charlie.accountExists());
// test errors when account not open
await testAccountNotOpen(charlie);
// create account
let password = "testPassword";
await charlie.createAccount(password);
await charlie.getBalances();
assert(await charlie.accountExists());
assert(await charlie.isAccountOpen());
// close account
await charlie.closeAccount();
assert(await charlie.accountExists());
assert(!await charlie.isAccountOpen());
await testAccountNotOpen(charlie);
// open account with wrong password
try {
await charlie.openAccount("wrongPassword");
throw new Error("Should have failed opening account with wrong password");
} catch (err) {
assert.equal(err.message, "Incorrect password");
}
// open account
await charlie.openAccount(password);
assert(await charlie.accountExists());
assert(await charlie.isAccountOpen());
// restart charlie
let charlieConfig = {appName: charlie.getAppName(), autoLogin: false}
await releaseHavenoProcess(charlie);
charlie = await initHavenoDaemon(charlieConfig);
assert(await charlie.accountExists());
assert(!await charlie.isAccountOpen());
// open account
await charlie.openAccount(password);
assert(await charlie.accountExists());
assert(await charlie.isAccountOpen());
// change password
password = "newPassword";
await charlie.changePassword(password);
assert(await charlie.accountExists());
assert(await charlie.isAccountOpen());
// restart charlie
await releaseHavenoProcess(charlie);
charlie = await initHavenoDaemon(charlieConfig);
await testAccountNotOpen(charlie);
// open account
await charlie.openAccount(password);
assert(await charlie.accountExists());
assert(await charlie.isAccountOpen());
// backup account to zip file
let rootDir = process.cwd();
let zipFile = rootDir + "/backup.zip";
let stream = fs.createWriteStream(zipFile);
let size = await charlie.backupAccount(stream);
stream.end();
assert(size > 0);
// delete account which shuts down server
await charlie.deleteAccount();
assert(!await charlie.isConnectedToDaemon());
await releaseHavenoProcess(charlie);
// restore account which shuts down server
charlie = await initHavenoDaemon(charlieConfig);
let zipBytes: Uint8Array = new Uint8Array(fs.readFileSync(zipFile));
await charlie.restoreAccount(zipBytes);
assert(!await charlie.isConnectedToDaemon());
await releaseHavenoProcess(charlie);
// open restored account
charlie = await initHavenoDaemon(charlieConfig);
assert(await charlie.accountExists());
await charlie.openAccount(password);
assert(await charlie.isAccountOpen());
} catch (err2) {
console.log(err2);
err = err2;
} }
// stop processes
if (charlie) await releaseHavenoProcess(charlie);
// TODO: how to delete trader app folder at end of test?
if (err) throw err;
// test bad key async function testAccountNotOpen(havenod: HavenoDaemon): Promise<void> { // TODO: generalize this?
try { try { await havenod.getMoneroConnections(); throw new Error("Should have thrown"); }
await arbitrator.registerDisputeAgent("mediator", "bad key"); catch (err) { assert.equal(err.message, "Account not open"); }
throw new Error("should have thrown error registering bad key"); try { await havenod.getXmrTxs(); throw new Error("Should have thrown"); }
} catch (err) { catch (err) { assert.equal(err.message, "Account not open"); }
if (err.message !== "invalid registration key") throw new Error("Unexpected error: " + err.message); try { await havenod.getBalances(); throw new Error("Should have thrown"); }
} catch (err) { assert.equal(err.message, "Account not open"); }
});
test("Can receive push notifications", async () => {
// add notification listener
let notifications: NotificationMessage[] = [];
await alice.addNotificationListener(notification => {
notifications.push(notification);
});
// send test notification
for (let i = 0; i < 3; i++) {
await alice._sendNotification(new NotificationMessage()
.setTimestamp(Date.now())
.setTitle("Test title")
.setMessage("Test message"));
}
// test notification
await wait(1000);
assert.equal(3, notifications.length);
for (let i = 0; i < 3; i++) {
assert(notifications[i].getTimestamp() > 0);
assert.equal("Test title", notifications[i].getTitle());
assert.equal("Test message", notifications[i].getMessage());
} }
}); });
@ -231,38 +296,38 @@ test("Can manage Monero daemon connections", async () => {
try { try {
// start charlie // start charlie
charlie = await startHavenoProcess(undefined, TestConfig.logging.logProcessOutput); charlie = await initHavenoDaemon();
// test default connections // test default connections
let monerodUri1 = "http://localhost:38081"; // TODO: (woodser): move to config let monerodUrl1 = "http://localhost:38081"; // TODO: (woodser): move to config
let monerodUri2 = "http://haveno.exchange:38081"; let monerodUrl2 = "http://haveno.exchange:38081";
let connections: UriConnection[] = await charlie.getMoneroConnections(); let connections: UrlConnection[] = await charlie.getMoneroConnections();
testConnection(getConnection(connections, monerodUri1)!, monerodUri1, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1); testConnection(getConnection(connections, monerodUrl1)!, monerodUrl1, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1);
testConnection(getConnection(connections, monerodUri2)!, monerodUri2, OnlineStatus.UNKNOWN, AuthenticationStatus.NO_AUTHENTICATION, 2); testConnection(getConnection(connections, monerodUrl2)!, monerodUrl2, OnlineStatus.UNKNOWN, AuthenticationStatus.NO_AUTHENTICATION, 2);
// test default connection // test default connection
let connection: UriConnection | undefined = await charlie.getMoneroConnection(); let connection: UrlConnection | undefined = await charlie.getMoneroConnection();
testConnection(connection!, monerodUri1, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1); assert(await charlie.isConnectedToMonero());
//assert(await charlie.isMoneroConnected()); // TODO (woodser): support havenod.isConnected()? testConnection(connection!, monerodUrl1, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1);
// add a new connection // add a new connection
let fooBarUri = "http://foo.bar"; let fooBarUrl = "http://foo.bar";
await charlie.addMoneroConnection(fooBarUri); await charlie.addMoneroConnection(fooBarUrl);
connections = await charlie.getMoneroConnections(); connections = await charlie.getMoneroConnections();
connection = getConnection(connections, fooBarUri); connection = getConnection(connections, fooBarUrl);
testConnection(connection!, fooBarUri, OnlineStatus.UNKNOWN, AuthenticationStatus.NO_AUTHENTICATION, 0); testConnection(connection!, fooBarUrl, OnlineStatus.UNKNOWN, AuthenticationStatus.NO_AUTHENTICATION, 0);
//connection = await charlie.getMoneroConnection(uri); TODO (woodser): allow getting connection by uri?
// set prioritized connection without credentials // set prioritized connection without credentials
await charlie.setMoneroConnection(new UriConnection() await charlie.setMoneroConnection(new UrlConnection()
.setUri(TestConfig.monerod2.uri) .setUrl(TestConfig.monerod2.url)
.setPriority(1)); .setPriority(1));
connection = await charlie.getMoneroConnection(); connection = await charlie.getMoneroConnection();
testConnection(connection!, TestConfig.monerod2.uri, undefined, undefined, 1); // status may or may not be known due to periodic connection checking testConnection(connection!, TestConfig.monerod2.url, undefined, undefined, 1); // status may or may not be known due to periodic connection checking
// connection is offline // connection is offline
connection = await charlie.checkMoneroConnection(); connection = await charlie.checkMoneroConnection();
testConnection(connection!, TestConfig.monerod2.uri, OnlineStatus.OFFLINE, AuthenticationStatus.NO_AUTHENTICATION, 1); assert(!await charlie.isConnectedToMonero());
testConnection(connection!, TestConfig.monerod2.url, OnlineStatus.OFFLINE, AuthenticationStatus.NO_AUTHENTICATION, 1);
// start monerod2 // start monerod2
let cmd = [ let cmd = [
@ -280,31 +345,37 @@ test("Can manage Monero daemon connections", async () => {
// connection is online and not authenticated // connection is online and not authenticated
connection = await charlie.checkMoneroConnection(); connection = await charlie.checkMoneroConnection();
testConnection(connection!, TestConfig.monerod2.uri, OnlineStatus.ONLINE, AuthenticationStatus.NOT_AUTHENTICATED, 1); assert(!await charlie.isConnectedToMonero());
testConnection(connection!, TestConfig.monerod2.url, OnlineStatus.ONLINE, AuthenticationStatus.NOT_AUTHENTICATED, 1);
// set connection credentials // set connection credentials
await charlie.setMoneroConnection(new UriConnection() await charlie.setMoneroConnection(new UrlConnection()
.setUri(TestConfig.monerod2.uri) .setUrl(TestConfig.monerod2.url)
.setUsername(TestConfig.monerod2.username) .setUsername(TestConfig.monerod2.username)
.setPassword(TestConfig.monerod2.password) .setPassword(TestConfig.monerod2.password)
.setPriority(1)); .setPriority(1));
connection = await charlie.getMoneroConnection(); connection = await charlie.getMoneroConnection();
testConnection(connection!, TestConfig.monerod2.uri, undefined, undefined, 1); testConnection(connection!, TestConfig.monerod2.url, undefined, undefined, 1);
// connection is online and authenticated // connection is online and authenticated
connection = await charlie.checkMoneroConnection(); connection = await charlie.checkMoneroConnection();
testConnection(connection!, TestConfig.monerod2.uri, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1); assert(await charlie.isConnectedToMonero());
testConnection(connection!, TestConfig.monerod2.url, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1);
// change account password
let password = "newPassword";
await charlie.changePassword("newPassword");
// restart charlie // restart charlie
let appName = charlie.getAppName(); let appName = charlie.getAppName();
await stopHavenoProcess(charlie); await releaseHavenoProcess(charlie);
charlie = await startHavenoProcess(appName, TestConfig.logging.logProcessOutput); charlie = await initHavenoDaemon({appName: appName, accountPassword: password});
// connection is restored, online, and authenticated // connection is restored, online, and authenticated
connection = await charlie.getMoneroConnection(); connection = await charlie.getMoneroConnection();
testConnection(connection!, TestConfig.monerod2.uri, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1); testConnection(connection!, TestConfig.monerod2.url, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1);
connections = await charlie.getMoneroConnections(); connections = await charlie.getMoneroConnections();
testConnection(getConnection(connections, monerodUri1)!, monerodUri1, OnlineStatus.UNKNOWN, AuthenticationStatus.NO_AUTHENTICATION, 1); testConnection(getConnection(connections, monerodUrl1)!, monerodUrl1, OnlineStatus.UNKNOWN, AuthenticationStatus.NO_AUTHENTICATION, 1);
// enable auto switch // enable auto switch
await charlie.setAutoSwitch(true); await charlie.setAutoSwitch(true);
@ -315,58 +386,58 @@ test("Can manage Monero daemon connections", async () => {
// test auto switch after periodic connection check // test auto switch after periodic connection check
await wait(TestConfig.daemonPollPeriodMs); await wait(TestConfig.daemonPollPeriodMs);
connection = await charlie.getMoneroConnection(); connection = await charlie.getMoneroConnection();
testConnection(connection!, monerodUri1, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1); testConnection(connection!, monerodUrl1, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1);
// stop checking connection periodically // stop checking connection periodically
await charlie.stopCheckingConnection(); await charlie.stopCheckingConnection();
// remove current connection // remove current connection
await charlie.removeMoneroConnection(monerodUri1); await charlie.removeMoneroConnection(monerodUrl1);
// check current connection // check current connection
connection = await charlie.checkMoneroConnection(); connection = await charlie.checkMoneroConnection();
assert.equal(undefined, connection); assert.equal(connection, undefined);
// check all connections // check all connections
await charlie.checkMoneroConnections(); await charlie.checkMoneroConnections();
connections = await charlie.getMoneroConnections(); connections = await charlie.getMoneroConnections();
testConnection(getConnection(connections, fooBarUri)!, fooBarUri, OnlineStatus.OFFLINE, AuthenticationStatus.NO_AUTHENTICATION, 0); testConnection(getConnection(connections, fooBarUrl)!, fooBarUrl, OnlineStatus.OFFLINE, AuthenticationStatus.NO_AUTHENTICATION, 0);
for (let connection of connections) testConnection(connection!, connection.getUri(), OnlineStatus.OFFLINE, AuthenticationStatus.NO_AUTHENTICATION); for (let connection of connections) testConnection(connection!, connection.getUrl(), OnlineStatus.OFFLINE, AuthenticationStatus.NO_AUTHENTICATION);
// set connection to previous uri // set connection to previous url
await charlie.setMoneroConnection(fooBarUri); await charlie.setMoneroConnection(fooBarUrl);
connection = await charlie.getMoneroConnection(); connection = await charlie.getMoneroConnection();
testConnection(connection!, fooBarUri, OnlineStatus.OFFLINE, AuthenticationStatus.NO_AUTHENTICATION, 0); testConnection(connection!, fooBarUrl, OnlineStatus.OFFLINE, AuthenticationStatus.NO_AUTHENTICATION, 0);
// set connection to new uri // set connection to new url
let fooBarUri2 = "http://foo.bar.xyz"; let fooBarUrl2 = "http://foo.bar.xyz";
await charlie.setMoneroConnection(fooBarUri2); await charlie.setMoneroConnection(fooBarUrl2);
connections = await charlie.getMoneroConnections(); connections = await charlie.getMoneroConnections();
connection = getConnection(connections, fooBarUri2); connection = getConnection(connections, fooBarUrl2);
testConnection(connection!, fooBarUri2, OnlineStatus.UNKNOWN, AuthenticationStatus.NO_AUTHENTICATION, 0); testConnection(connection!, fooBarUrl2, OnlineStatus.UNKNOWN, AuthenticationStatus.NO_AUTHENTICATION, 0);
// reset connection // reset connection
await charlie.setMoneroConnection(); await charlie.setMoneroConnection();
assert.equal(undefined, await charlie.getMoneroConnection()); assert.equal(await charlie.getMoneroConnection(), undefined);
// test auto switch after start checking connection // test auto switch after start checking connection
await charlie.setAutoSwitch(false); await charlie.setAutoSwitch(false);
await charlie.startCheckingConnection(5000); // checks the connection await charlie.startCheckingConnection(5000); // checks the connection
await charlie.setAutoSwitch(true); await charlie.setAutoSwitch(true);
await charlie.addMoneroConnection(new UriConnection() await charlie.addMoneroConnection(new UrlConnection()
.setUri(TestConfig.monerod.uri) .setUrl(TestConfig.monerod.url)
.setUsername(TestConfig.monerod.username) .setUsername(TestConfig.monerod.username)
.setPassword(TestConfig.monerod.password) .setPassword(TestConfig.monerod.password)
.setPriority(2)); .setPriority(2));
await wait(10000); await wait(10000);
connection = await charlie.getMoneroConnection(); connection = await charlie.getMoneroConnection();
testConnection(connection!, TestConfig.monerod.uri, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 2); testConnection(connection!, TestConfig.monerod.url, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 2);
} catch (err2) { } catch (err2) {
err = err2; err = err2;
} }
// stop processes // stop processes
if (charlie) await stopHavenoProcess(charlie); if (charlie) await releaseHavenoProcess(charlie);
if (monerod2) await monerod2.stopProcess(); if (monerod2) await monerod2.stopProcess();
// TODO: how to delete trader app folder at end of test? // TODO: how to delete trader app folder at end of test?
if (err) throw err; if (err) throw err;
@ -432,6 +503,32 @@ test("Can get balances", async () => {
expect(BigInt(balances.getReservedTradeBalance())).toBeGreaterThanOrEqual(0); expect(BigInt(balances.getReservedTradeBalance())).toBeGreaterThanOrEqual(0);
}); });
test("Can receive push notifications", async () => {
// add notification listener
let notifications: NotificationMessage[] = [];
await alice.addNotificationListener(notification => {
notifications.push(notification);
});
// send test notification
for (let i = 0; i < 3; i++) {
await alice._sendNotification(new NotificationMessage()
.setTimestamp(Date.now())
.setTitle("Test title " + i)
.setMessage("Test message " + i));
}
// test notification
await wait(1000);
assert(notifications.length >= 3);
for (let i = 0; i < 3; i++) {
assert(notifications[i].getTimestamp() > 0);
assert.equal(notifications[i].getTitle(), "Test title " + i);
assert.equal(notifications[i].getMessage(), "Test message " + i);
}
});
test("Can get market prices", async () => { test("Can get market prices", async () => {
// get all market prices // get all market prices
@ -465,6 +562,27 @@ test("Can get market prices", async () => {
.toThrow('Currency not found: INVALID_CURRENCY'); .toThrow('Currency not found: INVALID_CURRENCY');
}); });
test("Can register as dispute agents", async () => {
await arbitrator.registerDisputeAgent("mediator", TestConfig.devPrivilegePrivKey); // TODO: bisq mediator = haveno arbitrator
await arbitrator.registerDisputeAgent("refundagent", TestConfig.devPrivilegePrivKey); // TODO: no refund agent in haveno
// test bad dispute agent type
try {
await arbitrator.registerDisputeAgent("unsupported type", TestConfig.devPrivilegePrivKey);
throw new Error("should have thrown error registering bad type");
} catch (err) {
if (err.message !== "unknown dispute agent type 'unsupported type'") throw new Error("Unexpected error: " + err.message);
}
// test bad key
try {
await arbitrator.registerDisputeAgent("mediator", "bad key");
throw new Error("should have thrown error registering bad key");
} catch (err) {
if (err.message !== "invalid registration key") throw new Error("Unexpected error: " + err.message);
}
});
test("Can get offers", async () => { test("Can get offers", async () => {
let offers: OfferInfo[] = await alice.getOffers("BUY"); let offers: OfferInfo[] = await alice.getOffers("BUY");
for (let offer of offers) { for (let offer of offers) {
@ -624,7 +742,7 @@ test("Handles unexpected errors during trade initialization", async () => {
// start and fund 3 trader processes // start and fund 3 trader processes
console.log("Starting trader processes"); console.log("Starting trader processes");
traders = await startHavenoProcesses(3, TestConfig.logging.logProcessOutput); traders = await initHavenoDaemons(3);
console.log("Funding traders"); console.log("Funding traders");
let tradeAmount: bigint = BigInt("250000000000"); let tradeAmount: bigint = BigInt("250000000000");
await waitForUnlockedBalance(tradeAmount * BigInt("2"), traders[0], traders[1], traders[2]); await waitForUnlockedBalance(tradeAmount * BigInt("2"), traders[0], traders[1], traders[2]);
@ -642,7 +760,7 @@ test("Handles unexpected errors during trade initialization", async () => {
let paymentAccount = await createCryptoPaymentAccount(traders[1]); let paymentAccount = await createCryptoPaymentAccount(traders[1]);
wait(3000).then(async function() { wait(3000).then(async function() {
try { try {
let traderWallet = await monerojs.connectToWalletRpc("http://localhost:" + traders[1].getWalletRpcPort(), "rpc_user", "abc123"); // TODO: don't hardcode here, protect wallet rpc based on account password let traderWallet = await monerojs.connectToWalletRpc("http://localhost:" + traders[1].getWalletRpcPort(), TestConfig.defaultHavenod.walletUsername, TestConfig.defaultHavenod.accountPassword);
for (let frozenOutput of await traderWallet.getOutputs({isFrozen: true})) await traderWallet.thawOutput(frozenOutput.getKeyImage().getHex()); for (let frozenOutput of await traderWallet.getOutputs({isFrozen: true})) await traderWallet.thawOutput(frozenOutput.getKeyImage().getHex());
console.log("Sweeping trade funds"); console.log("Sweeping trade funds");
await traderWallet.sweepUnlocked({address: await traderWallet.getPrimaryAddress(), relay: true}); await traderWallet.sweepUnlocked({address: await traderWallet.getPrimaryAddress(), relay: true});
@ -676,7 +794,7 @@ test("Handles unexpected errors during trade initialization", async () => {
// trader 0 spends trade funds then trader 2 takes offer // trader 0 spends trade funds then trader 2 takes offer
wait(3000).then(async function() { wait(3000).then(async function() {
try { try {
let traderWallet = await monerojs.connectToWalletRpc("http://localhost:" + traders[0].getWalletRpcPort(), "rpc_user", "abc123"); // TODO: don't hardcode here, protect wallet rpc based on account password let traderWallet = await monerojs.connectToWalletRpc("http://localhost:" + traders[0].getWalletRpcPort(), TestConfig.defaultHavenod.walletUsername, TestConfig.defaultHavenod.accountPassword);
for (let frozenOutput of await traderWallet.getOutputs({isFrozen: true})) await traderWallet.thawOutput(frozenOutput.getKeyImage().getHex()); for (let frozenOutput of await traderWallet.getOutputs({isFrozen: true})) await traderWallet.thawOutput(frozenOutput.getKeyImage().getHex());
console.log("Sweeping offer funds"); console.log("Sweeping offer funds");
await traderWallet.sweepUnlocked({address: await traderWallet.getPrimaryAddress(), relay: true}); await traderWallet.sweepUnlocked({address: await traderWallet.getPrimaryAddress(), relay: true});
@ -706,7 +824,7 @@ test("Handles unexpected errors during trade initialization", async () => {
// stop traders // stop traders
console.log("Stopping haveno processes"); console.log("Stopping haveno processes");
for (let trader of traders) await stopHavenoProcess(trader); for (let trader of traders) await releaseHavenoProcess(trader);
if (err) throw err; if (err) throw err;
}); });
@ -716,7 +834,7 @@ test("Cannot make or take offer with insufficient unlocked funds", async () => {
try { try {
// start charlie // start charlie
charlie = await startHavenoProcess(undefined, TestConfig.logging.logProcessOutput); charlie = await initHavenoDaemon();
// charlie creates ethereum payment account // charlie creates ethereum payment account
let paymentAccount = await createCryptoPaymentAccount(charlie); let paymentAccount = await createCryptoPaymentAccount(charlie);
@ -728,7 +846,7 @@ test("Cannot make or take offer with insufficient unlocked funds", async () => {
} catch (err) { } catch (err) {
let errTyped = err as grpcWeb.RpcError; let errTyped = err as grpcWeb.RpcError;
assert.equal(errTyped.code, 2); assert.equal(errTyped.code, 2);
assert(errTyped.message.includes("not enough money")); assert(err.message.includes("not enough money"), "Unexpected error: " + err.message);
} }
// alice posts offer // alice posts offer
@ -749,7 +867,7 @@ test("Cannot make or take offer with insufficient unlocked funds", async () => {
throw new Error("Should have failed taking offer with insufficient funds") throw new Error("Should have failed taking offer with insufficient funds")
} catch (err) { } catch (err) {
let errTyped = err as grpcWeb.RpcError; let errTyped = err as grpcWeb.RpcError;
assert(errTyped.message.includes("not enough money"), "Unexpected error: " + errTyped.message); // TODO (woodser): error message does not contain stacktrace assert(errTyped.message.includes("not enough money"), "Unexpected error: " + errTyped.message);
assert.equal(errTyped.code, 2); assert.equal(errTyped.code, 2);
} }
@ -766,7 +884,7 @@ test("Cannot make or take offer with insufficient unlocked funds", async () => {
} }
// stop charlie // stop charlie
if (charlie) await stopHavenoProcess(charlie); if (charlie) await releaseHavenoProcess(charlie);
// TODO: how to delete trader app folder at end of test? // TODO: how to delete trader app folder at end of test?
if (err) throw err; if (err) throw err;
}); });
@ -899,117 +1017,95 @@ test("Can complete a trade", async () => {
// ------------------------------- HELPERS ------------------------------------ // ------------------------------- HELPERS ------------------------------------
function getConnection(connections: UriConnection[], uri: string): UriConnection | undefined { async function initHavenoDaemons(numDaemons: number, config?: any) {
for (let connection of connections) if (connection.getUri() === uri) return connection;
return undefined;
}
function testConnection(connection: UriConnection, uri?: string, onlineStatus?: OnlineStatus, authenticationStatus?: AuthenticationStatus, priority?: number) {
if (uri) assert.equal(connection.getUri(), uri);
assert.equal(connection.getPassword(), ""); // TODO (woodser): undefined instead of ""?
assert.equal(connection.getUsername(), "");
if (onlineStatus !== undefined) assert.equal(connection.getOnlineStatus(), onlineStatus);
if (authenticationStatus !== undefined) assert.equal(connection.getAuthenticationStatus(), authenticationStatus);
if (priority !== undefined) assert.equal(connection.getPriority(), priority);
}
/**
* Initialize arbitrator, alice, or bob by their configuration.
*
* @param {object} config - for arbitrator, alice, or bob
* @return {HavenoDaemon} the created instance
*/
async function initHavenoDaemon(config: any): Promise<HavenoDaemon> {
try {
let havenod = new HavenoDaemon(config.uri, config.password);
await havenod.getVersion();
return havenod;
} catch (err) {
return startHavenoProcess(config.appName, config.logProcessOutput);
}
}
/**
* Start Haveno daemons as processes.
*
* @param {number} numProcesses - number of trader processes to start
* @param {boolean} enableLogging - specifies if process output should be logged
* @return {HavenoDaemon[]} clients connected to the started Haveno processes
*/
async function startHavenoProcesses(numProcesses: number, enableLogging: boolean): Promise<HavenoDaemon[]> {
let traderPromises: Promise<HavenoDaemon>[] = []; let traderPromises: Promise<HavenoDaemon>[] = [];
for (let i = 0; i < numProcesses; i++) traderPromises.push(startHavenoProcess(undefined, enableLogging)); for (let i = 0; i < numDaemons; i++) traderPromises.push(initHavenoDaemon(config));
return Promise.all(traderPromises); return Promise.all(traderPromises);
} }
/** async function initHavenoDaemon(config?: any): Promise<HavenoDaemon> {
* Start a Haveno daemon as a process. config = Object.assign({}, TestConfig.defaultHavenod, config);
* if (!config.appName) config.appName = "haveno-XMR_STAGENET_instance_" + GenUtils.getUUID();
* If the appName belongs to the arbitrator, alice, or bob, the process is started using their configured ports.
*
* @param {string|undefined} appName - the app folder name (default to name with unique id)
* @param {boolean} enableLogging - specifies if process output should be logged
* @return {HavenoDaemon} the client connected to the started Haveno process
*/
async function startHavenoProcess(appName: string|undefined, enableLogging: boolean): Promise<HavenoDaemon> {
if (!appName) appName = "haveno-XMR_STAGENET_instance_" + GenUtils.getUUID();
// get proxy port for haveno process // connect to existing server or start new process
let proxyPort; let havenod;
if (appName === TestConfig.arbitrator.appName) proxyPort = "8079"; try {
else if (appName === TestConfig.alice.appName) proxyPort = "8080";
else if (appName === TestConfig.bob.appName) proxyPort = "8081"; // try to connect to existing server
else { havenod = new HavenoDaemon(config.url, config.apiPassword);
for (let port of Array.from(TestConfig.proxyPorts.keys())) { await havenod.getVersion();
if (port === "8079" || port === "8080" || port === "8081") continue; // reserved for arbitrator, alice, and bob } catch (err) {
if (!GenUtils.arrayContains(HAVENO_PROCESS_PORTS, port)) {
HAVENO_PROCESS_PORTS.push(port); // get port for haveno process
proxyPort = port; let proxyPort = "";
break; if (config.url) proxyPort = new URL(config.url).port
else {
for (let port of Array.from(TestConfig.proxyPorts.keys())) {
if (port === "8079" || port === "8080" || port === "8081") continue; // reserved for arbitrator, alice, and bob
if (!GenUtils.arrayContains(HAVENO_PROCESS_PORTS, port)) {
HAVENO_PROCESS_PORTS.push(port);
proxyPort = port;
break;
}
} }
} }
if (!proxyPort) throw new Error("No unused test ports available");
// start haveno process using configured ports if available
let cmd: string[] = [
"./haveno-daemon",
"--baseCurrencyNetwork", "XMR_STAGENET",
"--useLocalhostForP2P", "true",
"--useDevPrivilegeKeys", "true",
"--nodePort", TestConfig.proxyPorts.get(proxyPort)![1],
"--appName", config.appName,
"--apiPassword", "apitest",
"--apiPort", TestConfig.proxyPorts.get(proxyPort)![0],
"--walletRpcBindPort", config.walletUrl ? new URL(config.walletUrl).port : "" + await getAvailablePort(), // use configured port if given
"--passwordRequired", (config.accountPasswordRequired ? "true" : "false")
];
havenod = await HavenoDaemon.startProcess(TestConfig.haveno.path, cmd, "http://localhost:" + proxyPort, config.logProcessOutput);
HAVENO_PROCESSES.push(havenod);
} }
if (!proxyPort) throw new Error("No unused test ports available");
// start haveno process using configured ports if available // open account if configured
let cmd: string[] = [ if (config.autoLogin) await initHavenoAccount(havenod, config.accountPassword);
"./haveno-daemon",
"--baseCurrencyNetwork", "XMR_STAGENET",
"--useLocalhostForP2P", "true",
"--useDevPrivilegeKeys", "true",
"--nodePort", TestConfig.proxyPorts.get(proxyPort)![1],
"--appName", appName,
"--apiPassword", "apitest",
"--apiPort", TestConfig.proxyPorts.get(proxyPort)![0],
"--walletRpcBindPort", (proxyPort === "8080" ? new URL(TestConfig.alice.walletUri).port : await getFreePort()) + "" // use alice's configured wallet rpc port
];
let havenod = await HavenoDaemon.startProcess(TestConfig.haveno.path, cmd, "http://localhost:" + proxyPort, enableLogging);
HAVENO_PROCESSES.push(havenod);
return havenod; return havenod;
}
async function getAvailablePort(): Promise<number> {
/** return new Promise(function(resolve, reject) {
* Get a free port. let srv = net.createServer();
*/ srv.listen(0, function() {
async function getFreePort(): Promise<number> { let port = srv.address().port;
return new Promise(function(resolve, reject) { srv.close(function() {
let srv = net.createServer(); resolve(port);
srv.listen(0, function() { });
let port = srv.address().port; });
srv.close(function() {
resolve(port);
})
}); });
}); }
} }
/** /**
* Stop a Haveno daemon process and release its ports for reuse. * Release a Haveno process for reuse and try to shutdown.
*/ */
async function stopHavenoProcess(havenod: HavenoDaemon) { async function releaseHavenoProcess(havenod: HavenoDaemon) {
await havenod.stopProcess();
GenUtils.remove(HAVENO_PROCESSES, havenod); GenUtils.remove(HAVENO_PROCESSES, havenod);
GenUtils.remove(HAVENO_PROCESS_PORTS, new URL(havenod.getUrl()).port); // TODO (woodser): standardize to uri GenUtils.remove(HAVENO_PROCESS_PORTS, new URL(havenod.getUrl()).port); // TODO (woodser): standardize to url
try {
await havenod.shutdownServer();
} catch (err) {
assert.equal(err.message, OFFLINE_ERR_MSG);
}
}
/**
* Create or open an account with the given daemon and password.
*/
async function initHavenoAccount(havenod: HavenoDaemon, password: string) {
if (await havenod.isAccountOpen()) return;
if (await havenod.accountExists()) return havenod.openAccount(password);
await havenod.createAccount(password);
return;
} }
/** /**
@ -1018,7 +1114,7 @@ async function stopHavenoProcess(havenod: HavenoDaemon) {
async function initFundingWallet() { async function initFundingWallet() {
// init client connected to monero-wallet-rpc // init client connected to monero-wallet-rpc
fundingWallet = await monerojs.connectToWalletRpc(TestConfig.fundingWallet.uri, TestConfig.fundingWallet.username, TestConfig.fundingWallet.password); fundingWallet = await monerojs.connectToWalletRpc(TestConfig.fundingWallet.url, TestConfig.fundingWallet.username, TestConfig.fundingWallet.password);
// check if wallet is open // check if wallet is open
let walletIsOpen = false let walletIsOpen = false
@ -1164,6 +1260,20 @@ function getNotifications(notifications: NotificationMessage[], notificationType
return filteredNotifications; return filteredNotifications;
} }
function getConnection(connections: UrlConnection[], url: string): UrlConnection | undefined {
for (let connection of connections) if (connection.getUrl() === url) return connection;
return undefined;
}
function testConnection(connection: UrlConnection, url?: string, onlineStatus?: OnlineStatus, authenticationStatus?: AuthenticationStatus, priority?: number) {
if (url) assert.equal(connection.getUrl(), url);
assert.equal(connection.getPassword(), ""); // TODO (woodser): undefined instead of ""?
assert.equal(connection.getUsername(), "");
if (onlineStatus !== undefined) assert.equal(connection.getOnlineStatus(), onlineStatus);
if (authenticationStatus !== undefined) assert.equal(connection.getAuthenticationStatus(), authenticationStatus);
if (priority !== undefined) assert.equal(connection.getPriority(), priority);
}
function testTx(tx: XmrTx, ctx: TxContext) { function testTx(tx: XmrTx, ctx: TxContext) {
assert(tx.getHash()); assert(tx.getHash());
expect(BigInt(tx.getFee())).toBeLessThan(TestConfig.maxFee); expect(BigInt(tx.getFee())).toBeLessThan(TestConfig.maxFee);

View File

@ -1,9 +1,9 @@
import {HavenoUtils} from "./utils/HavenoUtils"; import {HavenoUtils} from "./utils/HavenoUtils";
import {TaskLooper} from "./utils/TaskLooper"; import {TaskLooper} from "./utils/TaskLooper";
import * as grpcWeb from 'grpc-web'; import * as grpcWeb from 'grpc-web';
import {DisputeAgentsClient, GetVersionClient, NotificationsClient, PriceClient, WalletsClient, OffersClient, PaymentAccountsClient, TradesClient, MoneroConnectionsClient} from './protobuf/GrpcServiceClientPb'; import {GetVersionClient, AccountClient, MoneroConnectionsClient, DisputeAgentsClient, NotificationsClient, WalletsClient, PriceClient, OffersClient, PaymentAccountsClient, TradesClient, ShutdownServerClient} from './protobuf/GrpcServiceClientPb';
import {CancelOfferRequest, ConfirmPaymentReceivedRequest, ConfirmPaymentStartedRequest, CreateCryptoCurrencyPaymentAccountReply, CreateCryptoCurrencyPaymentAccountRequest, CreateOfferReply, CreateOfferRequest, CreateXmrTxReply, CreateXmrTxRequest, GetBalancesReply, GetBalancesRequest, GetNewDepositSubaddressReply, GetNewDepositSubaddressRequest, GetOffersReply, GetOffersRequest, GetPaymentAccountsReply, GetPaymentAccountsRequest, GetTradeReply, GetTradeRequest, GetTradesReply, GetTradesRequest, GetVersionReply, GetVersionRequest, GetXmrTxsReply, GetXmrTxsRequest, MarketPriceInfo, MarketPriceReply, MarketPriceRequest, MarketPricesReply, MarketPricesRequest, NotificationMessage, OfferInfo, RegisterDisputeAgentRequest, RegisterNotificationListenerRequest, RelayXmrTxReply, RelayXmrTxRequest, SendNotificationRequest, TakeOfferReply, TakeOfferRequest, TradeInfo, XmrBalanceInfo, XmrDestination, XmrTx, UriConnection, AddConnectionRequest, RemoveConnectionRequest, GetConnectionRequest, GetConnectionsRequest, SetConnectionRequest, CheckConnectionRequest, CheckConnectionsReply, CheckConnectionsRequest, StartCheckingConnectionsRequest, StopCheckingConnectionsRequest, GetBestAvailableConnectionRequest, SetAutoSwitchRequest, CheckConnectionReply, GetConnectionsReply, GetConnectionReply, GetBestAvailableConnectionReply} from './protobuf/grpc_pb'; import {GetVersionRequest, GetVersionReply, IsAppInitializedRequest, IsAppInitializedReply, RegisterDisputeAgentRequest, MarketPriceRequest, MarketPriceReply, MarketPricesRequest, MarketPricesReply, MarketPriceInfo, GetBalancesRequest, GetBalancesReply, XmrBalanceInfo, GetOffersRequest, GetOffersReply, OfferInfo, GetPaymentAccountsRequest, GetPaymentAccountsReply, CreateCryptoCurrencyPaymentAccountRequest, CreateCryptoCurrencyPaymentAccountReply, CreateOfferRequest, CreateOfferReply, CancelOfferRequest, TakeOfferRequest, TakeOfferReply, TradeInfo, GetTradeRequest, GetTradeReply, GetTradesRequest, GetTradesReply, GetNewDepositSubaddressRequest, GetNewDepositSubaddressReply, ConfirmPaymentStartedRequest, ConfirmPaymentReceivedRequest, 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, StartCheckingConnectionsRequest, StopCheckingConnectionsRequest, GetBestAvailableConnectionRequest, SetAutoSwitchRequest, CheckConnectionReply, GetConnectionsReply, GetConnectionReply, GetBestAvailableConnectionReply} from './protobuf/grpc_pb';
import {AvailabilityResult, PaymentAccount} from './protobuf/pb_pb'; import {PaymentAccount, AvailabilityResult} from './protobuf/pb_pb';
const console = require('console'); const console = require('console');
/** /**
@ -12,6 +12,7 @@ const console = require('console');
class HavenoDaemon { class HavenoDaemon {
// grpc clients // grpc clients
_appName: string|undefined;
_getVersionClient: GetVersionClient; _getVersionClient: GetVersionClient;
_disputeAgentsClient: DisputeAgentsClient; _disputeAgentsClient: DisputeAgentsClient;
_notificationsClient: NotificationsClient; _notificationsClient: NotificationsClient;
@ -21,17 +22,24 @@ class HavenoDaemon {
_paymentAccountsClient: PaymentAccountsClient; _paymentAccountsClient: PaymentAccountsClient;
_offersClient: OffersClient; _offersClient: OffersClient;
_tradesClient: TradesClient; _tradesClient: TradesClient;
_accountClient: AccountClient;
_shutdownServerClient: ShutdownServerClient;
// other instance variables // state variables
_url: string; _url: string;
_password: string; _password: string;
_process: any; _process: any;
_processLogging: boolean = false; _processLogging = false;
_walletRpcPort: number|undefined; _walletRpcPort: number|undefined;
_notificationListeners: ((notification: NotificationMessage) => void)[] = []; _notificationListeners: ((notification: NotificationMessage) => void)[] = [];
_registerNotificationListenerCalled = false;
_keepAliveLooper: any;
_keepAlivePeriodMs: number = 60000; _keepAlivePeriodMs: number = 60000;
_appName: string|undefined;
// constants
static readonly _fullyInitializedMessage = "AppStartupState: Application fully initialized";
static readonly _loginRequiredMessage = "HavenoDaemonMain: Interactive login required";
/** /**
* Construct a client connected to a Haveno daemon. * Construct a client connected to a Haveno daemon.
* *
@ -39,20 +47,22 @@ class HavenoDaemon {
* @param {string} password - Haveno daemon password * @param {string} password - Haveno daemon password
*/ */
constructor(url: string, password: string) { constructor(url: string, password: string) {
HavenoUtils.log(2, "Creating HavenoDaemon(" + url + ", " + password + ")");
if (!url) throw new Error("Must provide URL of Haveno daemon"); if (!url) throw new Error("Must provide URL of Haveno daemon");
if (!password) throw new Error("Must provide password of Haveno daemon"); if (!password) throw new Error("Must provide password of Haveno daemon");
HavenoUtils.log(2, "Creating HavenoDaemon(" + url + ", " + password + ")");
this._url = url; this._url = url;
this._password = password; this._password = password;
this._getVersionClient = new GetVersionClient(this._url); this._getVersionClient = new GetVersionClient(this._url);
this._disputeAgentsClient = new DisputeAgentsClient(this._url); this._accountClient = new AccountClient(this._url);
this._moneroConnectionsClient = new MoneroConnectionsClient(this._url) this._moneroConnectionsClient = new MoneroConnectionsClient(this._url)
this._disputeAgentsClient = new DisputeAgentsClient(this._url);
this._walletsClient = new WalletsClient(this._url); this._walletsClient = new WalletsClient(this._url);
this._priceClient = new PriceClient(this._url); this._priceClient = new PriceClient(this._url);
this._paymentAccountsClient = new PaymentAccountsClient(this._url); this._paymentAccountsClient = new PaymentAccountsClient(this._url);
this._offersClient = new OffersClient(this._url); this._offersClient = new OffersClient(this._url);
this._tradesClient = new TradesClient(this._url); this._tradesClient = new TradesClient(this._url);
this._notificationsClient = new NotificationsClient(this._url); this._notificationsClient = new NotificationsClient(this._url);
this._shutdownServerClient = new ShutdownServerClient(this._url);
} }
/** /**
@ -72,7 +82,7 @@ class HavenoDaemon {
// state variables // state variables
let output = ""; let output = "";
let isResolved = false; let isStarted = false;
let daemon: HavenoDaemon|undefined = undefined; let daemon: HavenoDaemon|undefined = undefined;
// start process // start process
@ -83,11 +93,11 @@ class HavenoDaemon {
// handle stdout // handle stdout
childProcess.stdout.on('data', async function(data: any) { childProcess.stdout.on('data', async function(data: any) {
let line = data.toString(); let line = data.toString();
if (HavenoUtils.getLogLevel() >= 3 && loggingEnabled()) process.stdout.write(line); if (loggingEnabled()) process.stdout.write(line);
output += line + '\n'; // capture output in case of error output += line + '\n'; // capture output in case of error
// read success message // initialize daemon on success or login required message
if (line.indexOf("HavenoHeadlessAppMain: onSetupComplete") >= 0) { if (!daemon && (line.indexOf(HavenoDaemon._fullyInitializedMessage) >= 0 || line.indexOf(HavenoDaemon._loginRequiredMessage) >= 0)) {
// get api password // get api password
let passwordIdx = cmd.indexOf("--apiPassword"); let passwordIdx = cmd.indexOf("--apiPassword");
@ -108,40 +118,40 @@ class HavenoDaemon {
if (walletRpcPortIdx >= 0) daemon._walletRpcPort = parseInt(cmd[walletRpcPortIdx + 1]); if (walletRpcPortIdx >= 0) daemon._walletRpcPort = parseInt(cmd[walletRpcPortIdx + 1]);
// resolve promise with client connected to internal process // resolve promise with client connected to internal process
isResolved = true; isStarted = true;
resolve(daemon); resolve(daemon);
} }
// read error message // read error message
if (line.indexOf("[HavenoDaemonMain] ERROR") >= 0) { if (line.indexOf("[HavenoDaemonMain] ERROR") >= 0) {
if (!isResolved) await rejectProcess(new Error(line)); if (!isStarted) await rejectStartup(new Error(line));
} }
}); });
// handle stderr // handle stderr
childProcess.stderr.on('data', function(data: any) { childProcess.stderr.on('data', function(data: any) {
if (HavenoUtils.getLogLevel() >= 2 && loggingEnabled()) process.stderr.write(data); if (loggingEnabled()) process.stderr.write(data);
}); });
// handle exit // handle exit
childProcess.on("exit", async function(code: any) { childProcess.on("exit", async function(code: any) {
if (!isResolved) await rejectProcess(new Error("Haveno process terminated with exit code " + code + (output ? ":\n\n" + output : ""))); if (!isStarted) await rejectStartup(new Error("Haveno process terminated with exit code " + code + (output ? ":\n\n" + output : "")));
}); });
// handle error // handle error
childProcess.on("error", async function(err: any) { childProcess.on("error", async function(err: any) {
if (err.message.indexOf("ENOENT") >= 0) reject(new Error("haveno-daemon does not exist at path '" + cmd[0] + "'")); if (err.message.indexOf("ENOENT") >= 0) reject(new Error("haveno-daemon does not exist at path '" + cmd[0] + "'"));
if (!isResolved) await rejectProcess(err); if (!isStarted) await rejectStartup(err);
}); });
// handle uncaught exception // handle uncaught exception
childProcess.on("uncaughtException", async function(err: any, origin: any) { childProcess.on("uncaughtException", async function(err: any, origin: any) {
console.error("Uncaught exception in Haveno process: " + err.message); console.error("Uncaught exception in Haveno process: " + err.message);
console.error(origin); console.error(origin);
await rejectProcess(err); await rejectStartup(err);
}); });
async function rejectProcess(err: any) { async function rejectStartup(err: any) {
await HavenoUtils.kill(childProcess); await HavenoUtils.kill(childProcess);
reject(err); reject(err);
} }
@ -152,14 +162,6 @@ class HavenoDaemon {
}); });
} }
/**
* Stop a previously started Haveno process.
*/
async stopProcess(): Promise<void> {
if (this._process === undefined) throw new Error("HavenoDaemon instance not created from new process");
return HavenoUtils.kill(this._process);
}
/** /**
* Return the process running the haveno daemon. * Return the process running the haveno daemon.
* *
@ -220,18 +222,90 @@ class HavenoDaemon {
} }
/** /**
* Register as a dispute agent. * Indicates if connected and authenticated with the Haveno daemon.
* *
* @param {string} disputeAgentType - type of dispute agent to register, e.g. mediator, refundagent * @return {boolean} true if connected with the Haveno daemon, false otherwise
* @param {string} registrationKey - registration key
*/ */
async registerDisputeAgent(disputeAgentType: string, registrationKey: string): Promise<void> { async isConnectedToDaemon(): Promise<boolean> {
try {
await this.getVersion();
return true;
} catch (err) {
return false;
}
}
/**
* Indicates if the Haveno account is created.
*
* @return {boolean} true if the account is created, false otherwise
*/
async accountExists(): Promise<boolean> {
let that = this; let that = this;
let request = new RegisterDisputeAgentRequest()
.setDisputeAgentType(disputeAgentType)
.setRegistrationKey(registrationKey);
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
that._disputeAgentsClient.registerDisputeAgent(request, {password: that._password}, function(err: grpcWeb.RpcError) { that._accountClient.accountExists(new AccountExistsRequest(), {password: that._password}, function(err: grpcWeb.RpcError, response: AccountExistsReply) {
if (err) reject(err);
else resolve(response.getAccountExists());
});
});
}
/**
* Indicates if the Haveno account is open and authenticated with the correct password.
*
* @return {boolean} true if the account is open and authenticated, false otherwise
*/
async isAccountOpen(): Promise<boolean> {
let that = this;
return new Promise(function(resolve, reject) {
that._accountClient.isAccountOpen(new IsAccountOpenRequest(), {password: that._password}, function(err: grpcWeb.RpcError, response: IsAccountOpenReply) {
if (err) reject(err);
else resolve(response.getIsAccountOpen());
});
});
}
/**
* Create and open a new Haveno account.
*
* @param {string} password - the password to encrypt the account
*/
async createAccount(password: string): Promise<void> {
let that = this;
await new Promise(function(resolve, reject) {
that._accountClient.createAccount(new CreateAccountRequest().setPassword(password), {password: that._password}, function(err: grpcWeb.RpcError) {
if (err) reject(err);
else resolve();
});
});
return this._awaitAppInitialized(); // TODO: grpc should not return before setup is complete
}
/**
* Open existing Haveno account.
*
* @param {string} password - the account password
*/
async openAccount(password: string): Promise<void> {
let that = this;
await new Promise(function(resolve, reject) {
that._accountClient.openAccount(new OpenAccountRequest().setPassword(password), {password: that._password}, function(err: grpcWeb.RpcError) {
if (err) reject(err);
else resolve();
});
});
return this._awaitAppInitialized(); // TODO: grpc should not return before setup is complete
}
/**
* Change the Haveno account password.
*
* @param {string} password - the new account password
*/
async changePassword(password: string): Promise<void> {
let that = this;
return new Promise(function(resolve, reject) {
that._accountClient.changePassword(new ChangePasswordRequest().setPassword(password), {password: that._password}, function(err: grpcWeb.RpcError) {
if (err) reject(err); if (err) reject(err);
else resolve(); else resolve();
}); });
@ -239,24 +313,120 @@ class HavenoDaemon {
} }
/** /**
* Add a listener to receive notifications from the Haveno daemon. * Close the currently open account.
*/
async closeAccount(): Promise<void> {
let that = this;
return new Promise(function(resolve, reject) {
that._accountClient.closeAccount(new CloseAccountRequest(), {password: that._password}, function(err: grpcWeb.RpcError) {
if (err) reject(err);
else resolve();
});
});
}
/**
* Permanently delete the Haveno account and shutdown the server. // TODO: possible to not shutdown server?
*/
async deleteAccount(): Promise<void> {
let that = this;
return new Promise(function(resolve, reject) {
that._accountClient.deleteAccount(new DeleteAccountRequest(), {password: that._password}, async function(err: grpcWeb.RpcError) {
if (err) reject(err);
else setTimeout(resolve, 5000);
});
});
}
/**
* Backup the account to the given stream. TODO: stream type?
*/
async backupAccount(stream: any): Promise<number> {
let that = this;
return new Promise(function(resolve, reject) {
let total = 0;
let response = that._accountClient.backupAccount(new BackupAccountRequest(), {password: that._password});
response.on('data', (chunk) => {
let bytes = (chunk as BackupAccountReply).getZipBytes(); // TODO: right api?
total += bytes.length;
stream.write(bytes);
});
response.on('error', function(err) {
if(err) reject(err);
});
response.on('end', function() {
resolve(total);
});
});
}
/**
* Restore the account from zip bytes.
*
* Sends chunked requests if size over max grpc envelope size (41943404 bytes).
* *
* @param {Uint8Array} zipBytes - the bytes of the zipped account to restore
*/
async restoreAccount(zipBytes: Uint8Array): Promise<void> {
if (zipBytes.length === 0) throw new Error("Zip bytes must not be empty")
let totalLength = zipBytes.byteLength;
let offset = 0;
let chunkSize = 4000000; // the max frame size is 4194304 but leave room for http headers
let hasMore = true;
while (true) {
if (zipBytes.byteLength <= offset + 1) return;
if (zipBytes.byteLength <= offset + chunkSize) {
chunkSize = zipBytes.byteLength - offset - 1;
hasMore = false;
}
let subArray = zipBytes.subarray(offset, offset + chunkSize);
await this._restoreAccountChunk(subArray, offset, totalLength, hasMore);
offset += chunkSize;
}
}
/**
* Add a listener to receive notifications from the Haveno daemon.
*
* @param {HavenoDaemonListener} listener - the notification listener to add * @param {HavenoDaemonListener} listener - the notification listener to add
*/ */
async addNotificationListener(listener: (notification: NotificationMessage) => void): Promise<void> { async addNotificationListener(listener: (notification: NotificationMessage) => void): Promise<void> {
this._notificationListeners.push(listener); this._notificationListeners.push(listener);
if (this._notificationListeners.length === 1) return this._registerNotificationListener(); return this._registerNotificationListenerOnce();
} }
/**
* Remove a notification listener.
*
* @param {HavenoDaemonListener} listener - the notification listener to remove
*/
async removeNotificationListener(listener: (notification: NotificationMessage) => void): Promise<void> {
let idx = this._notificationListeners.indexOf(listener);
if (idx > -1) this._notificationListeners.splice(idx, 1);
else throw new Error("Notification listener is not registered");
}
/**
* Indicates if connected to the Monero network based on last connection check.
*
* @return {boolean} true if connected to the Monero network, false otherwise
*/
async isConnectedToMonero(): Promise<boolean> {
let connection = await this.getMoneroConnection();
return connection !== undefined &&
connection.getOnlineStatus()! === UrlConnection.OnlineStatus.ONLINE &&
connection.getAuthenticationStatus()! !== UrlConnection.AuthenticationStatus.NOT_AUTHENTICATED;
}
/** /**
* Add a Monero daemon connection. * Add a Monero daemon connection.
* *
* @param {string | UriConnection} connection - daemon uri or connection to add * @param {string | UrlConnection} connection - daemon url or connection to add
*/ */
async addMoneroConnection(connection: string | UriConnection): Promise<void> { async addMoneroConnection(connection: string | UrlConnection): Promise<void> {
let that = this; let that = this;
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
that._moneroConnectionsClient.addConnection(new AddConnectionRequest().setConnection(typeof connection === "string" ? new UriConnection().setUri(connection) : connection), {password: that._password}, function(err: grpcWeb.RpcError) { that._moneroConnectionsClient.addConnection(new AddConnectionRequest().setConnection(typeof connection === "string" ? new UrlConnection().setUrl(connection) : connection), {password: that._password}, function(err: grpcWeb.RpcError) {
if (err) reject(err); if (err) reject(err);
else resolve(); else resolve();
}); });
@ -266,12 +436,12 @@ class HavenoDaemon {
/** /**
* Remove a Monero daemon connection. * Remove a Monero daemon connection.
* *
* @param {string} uri - uri of the daemon connection to remove * @param {string} url - url of the daemon connection to remove
*/ */
async removeMoneroConnection(uri: string): Promise<void> { async removeMoneroConnection(url: string): Promise<void> {
let that = this; let that = this;
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
that._moneroConnectionsClient.removeConnection(new RemoveConnectionRequest().setUri(uri), {password: that._password}, function(err: grpcWeb.RpcError) { that._moneroConnectionsClient.removeConnection(new RemoveConnectionRequest().setUrl(url), {password: that._password}, function(err: grpcWeb.RpcError) {
if (err) reject(err); if (err) reject(err);
else resolve(); else resolve();
}); });
@ -281,9 +451,9 @@ class HavenoDaemon {
/** /**
* Get the current Monero daemon connection. * Get the current Monero daemon connection.
* *
* @return {UriConnection | undefined} the current daemon connection, undefined if no current connection * @return {UrlConnection | undefined} the current daemon connection, undefined if no current connection
*/ */
async getMoneroConnection(): Promise<UriConnection | undefined> { async getMoneroConnection(): Promise<UrlConnection | undefined> {
let that = this; let that = this;
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
that._moneroConnectionsClient.getConnection(new GetConnectionRequest(), {password: that._password}, function(err: grpcWeb.RpcError, response: GetConnectionReply) { that._moneroConnectionsClient.getConnection(new GetConnectionRequest(), {password: that._password}, function(err: grpcWeb.RpcError, response: GetConnectionReply) {
@ -296,9 +466,9 @@ class HavenoDaemon {
/** /**
* Get all Monero daemon connections. * Get all Monero daemon connections.
* *
* @return {UriConnection[]} all daemon connections * @return {UrlConnection[]} all daemon connections
*/ */
async getMoneroConnections(): Promise<UriConnection[]> { async getMoneroConnections(): Promise<UrlConnection[]> {
let that = this; let that = this;
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
that._moneroConnectionsClient.getConnections(new GetConnectionsRequest(), {password: that._password}, function(err: grpcWeb.RpcError, response: GetConnectionsReply) { that._moneroConnectionsClient.getConnections(new GetConnectionsRequest(), {password: that._password}, function(err: grpcWeb.RpcError, response: GetConnectionsReply) {
@ -310,18 +480,18 @@ class HavenoDaemon {
/** /**
* Set the current Monero daemon connection. * Set the current Monero daemon connection.
* *
* Add the connection if not previously seen. * Add the connection if not previously seen.
* If the connection is provided as string, connect to the URI with any previously set credentials and priority. * If the connection is provided as string, connect to the URI with any previously set credentials and priority.
* If the connection is provided as UriConnection, overwrite any previously set credentials and priority. * If the connection is provided as UrlConnection, overwrite any previously set credentials and priority.
* If undefined connection provided, disconnect the client. * If undefined connection provided, disconnect the client.
* *
* @param {string | UriConnection} connection - connection to set as current * @param {string | UrlConnection} connection - connection to set as current
*/ */
async setMoneroConnection(connection?: string | UriConnection): Promise<void> { async setMoneroConnection(connection?: string | UrlConnection): Promise<void> {
let that = this; let that = this;
let request = new SetConnectionRequest(); let request = new SetConnectionRequest();
if (typeof connection === "string") request.setUri(connection); if (typeof connection === "string") request.setUrl(connection);
else request.setConnection(connection); else request.setConnection(connection);
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
that._moneroConnectionsClient.setConnection(request, {password: that._password}, function(err: grpcWeb.RpcError) { that._moneroConnectionsClient.setConnection(request, {password: that._password}, function(err: grpcWeb.RpcError) {
@ -336,9 +506,9 @@ class HavenoDaemon {
* *
* If disconnected and auto switch enabled, switch to the best available connection and return its status. * If disconnected and auto switch enabled, switch to the best available connection and return its status.
* *
* @return {UriConnection | undefined} the current daemon connection status, undefined if no current connection * @return {UrlConnection | undefined} the current daemon connection status, undefined if no current connection
*/ */
async checkMoneroConnection(): Promise<UriConnection | undefined> { async checkMoneroConnection(): Promise<UrlConnection | undefined> {
let that = this; let that = this;
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
that._moneroConnectionsClient.checkConnection(new CheckConnectionRequest(), {password: that._password}, function(err: grpcWeb.RpcError, response: CheckConnectionReply) { that._moneroConnectionsClient.checkConnection(new CheckConnectionRequest(), {password: that._password}, function(err: grpcWeb.RpcError, response: CheckConnectionReply) {
@ -351,9 +521,9 @@ class HavenoDaemon {
/** /**
* Check all Monero daemon connections. * Check all Monero daemon connections.
* *
* @return {UriConnection[]} status of all managed connections. * @return {UrlConnection[]} status of all managed connections.
*/ */
async checkMoneroConnections(): Promise<UriConnection[]> { async checkMoneroConnections(): Promise<UrlConnection[]> {
let that = this; let that = this;
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
that._moneroConnectionsClient.checkConnections(new CheckConnectionsRequest(), {password: that._password}, function(err: grpcWeb.RpcError, response: CheckConnectionsReply) { that._moneroConnectionsClient.checkConnections(new CheckConnectionsRequest(), {password: that._password}, function(err: grpcWeb.RpcError, response: CheckConnectionsReply) {
@ -394,9 +564,9 @@ class HavenoDaemon {
/** /**
* Get the best available connection in order of priority then response time. * Get the best available connection in order of priority then response time.
* *
* @return {UriConnection | undefined} the best available connection in order of priority then response time, undefined if no connections available * @return {UrlConnection | undefined} the best available connection in order of priority then response time, undefined if no connections available
*/ */
async getBestAvailableConnection(): Promise<UriConnection | undefined> { async getBestAvailableConnection(): Promise<UrlConnection | undefined> {
let that = this; let that = this;
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
that._moneroConnectionsClient.getBestAvailableConnection(new GetBestAvailableConnectionRequest(), {password: that._password}, function(err: grpcWeb.RpcError, response: GetBestAvailableConnectionReply) { that._moneroConnectionsClient.getBestAvailableConnection(new GetBestAvailableConnectionRequest(), {password: that._password}, function(err: grpcWeb.RpcError, response: GetBestAvailableConnectionReply) {
@ -420,7 +590,26 @@ class HavenoDaemon {
}); });
}); });
} }
/**
* Register as a dispute agent.
*
* @param {string} disputeAgentType - type of dispute agent to register, e.g. mediator, refundagent
* @param {string} registrationKey - registration key
*/
async registerDisputeAgent(disputeAgentType: string, registrationKey: string): Promise<void> {
let that = this;
let request = new RegisterDisputeAgentRequest()
.setDisputeAgentType(disputeAgentType)
.setRegistrationKey(registrationKey);
return new Promise(function(resolve, reject) {
that._disputeAgentsClient.registerDisputeAgent(request, {password: that._password}, function(err: grpcWeb.RpcError) {
if (err) reject(err);
else resolve();
});
});
}
/** /**
* Get the user's balances. * Get the user's balances.
* *
@ -770,28 +959,87 @@ class HavenoDaemon {
}); });
} }
/**
* Shutdown the Haveno daemon server and stop the process if applicable.
*/
async shutdownServer() {
if (this._keepAliveLooper) this._keepAliveLooper.stop();
let that = this;
await new Promise(function(resolve, reject) {
that._shutdownServerClient.stop(new StopRequest(), {password: that._password}, function(err: grpcWeb.RpcError) { // process receives 'exit' event
if (err) reject(err);
else resolve();
});
});
if (this._process) return HavenoUtils.kill(this._process);
}
// ------------------------------- HELPERS ---------------------------------- // ------------------------------- HELPERS ----------------------------------
/**
* Wait for the application to be fully initialized with an account and a
* connection to the Haveno network.
*
* TODO:
*
* Currently when the application starts, the account is first initialized with createAccount()
* or openAccount() which return immediately. A notification is sent after all setup is complete and
* the application is connected to the Haveno network.
*
* Ideally when the application starts, the system checks the Haveno network connection, supporting
* havenod.isHavenoConnectionInitialized() and havenod.awaitHavenoConnectionInitialized().
* Independently, gRPC createAccount() and openAccount() return after all account setup and reading from disk.
*/
async _awaitAppInitialized(): Promise<void> {
let that = this;
return new Promise(async function(resolve) {
let isResolved = false;
let listener = async function(notification: NotificationMessage) {
if (notification.getType() === NotificationMessage.NotificationType.APP_INITIALIZED) await resolveOnce();
}
await that.addNotificationListener(listener);
if (await that._isAppInitialized()) await resolveOnce();
async function resolveOnce() {
if (isResolved) return;
isResolved = true;
await that.removeNotificationListener(listener);
resolve();
}
});
}
async _isAppInitialized(): Promise<boolean> {
let that = this;
return new Promise(function(resolve, reject) {
that._accountClient.isAppInitialized(new IsAppInitializedRequest(), {password: that._password}, function(err: grpcWeb.RpcError, response: IsAppInitializedReply) {
if (err) reject(err);
else resolve(response.getIsAppInitialized());
});
});
}
/** /**
* Register a listener to receive notifications. * Register a listener to receive notifications.
* Due to the nature of grpc streaming, this method returns a promise * Due to the nature of grpc streaming, this method returns a promise
* which may be resolved before the listener is actually registered. * which may be resolved before the listener is actually registered.
*/ */
async _registerNotificationListener(): Promise<void> { async _registerNotificationListenerOnce(): Promise<void> {
if (this._registerNotificationListenerCalled) return;
else this._registerNotificationListenerCalled = true;
let that = this; let that = this;
return new Promise(function(resolve) { return new Promise(function(resolve) {
// send request to register client listener // send request to register client listener
that._notificationsClient.registerNotificationListener(new RegisterNotificationListenerRequest(), {password: that._password}) that._notificationsClient.registerNotificationListener(new RegisterNotificationListenerRequest(), {password: that._password})
.on("data", (data) => { .on('data', (data) => {
if (data instanceof NotificationMessage) { if (data instanceof NotificationMessage) {
for (let listener of that._notificationListeners) listener(data); for (let listener of that._notificationListeners) listener(data);
} }
}); });
// periodically send keep alive requests // TODO (woodser): better way to keep notification stream alive? // periodically send keep alive requests // TODO (woodser): better way to keep notification stream alive?
let firstRequest = true; let firstRequest = true;
let taskLooper = new TaskLooper(async function() { that._keepAliveLooper = new TaskLooper(async function() {
if (firstRequest) { if (firstRequest) {
firstRequest = false; firstRequest = false;
return; return;
@ -800,16 +1048,15 @@ class HavenoDaemon {
.setType(NotificationMessage.NotificationType.KEEP_ALIVE) .setType(NotificationMessage.NotificationType.KEEP_ALIVE)
.setTimestamp(Date.now())); .setTimestamp(Date.now()));
}); });
taskLooper.start(that._keepAlivePeriodMs); that._keepAliveLooper.start(that._keepAlivePeriodMs);
// TODO: call returns before listener registered setTimeout(resolve, 1000); // TODO: call returns before listener registered
setTimeout(function() { resolve(); }, 1000);
}); });
} }
/** /**
* Send a notification. * Send a notification.
* *
* @param {NotificationMessage} notification - notification to send * @param {NotificationMessage} notification - notification to send
*/ */
async _sendNotification(notification: NotificationMessage): Promise<void> { async _sendNotification(notification: NotificationMessage): Promise<void> {
@ -821,6 +1068,24 @@ class HavenoDaemon {
}); });
}); });
} }
/**
* Restore an account chunk from zip bytes.
*/
async _restoreAccountChunk(zipBytes: Uint8Array, offset: number, totalLength: number, hasMore: boolean): Promise<void> {
let that = this;
let request = new RestoreAccountRequest()
.setZipBytes(zipBytes)
.setOffset(offset)
.setTotalLength(totalLength)
.setHasMore(hasMore);
return new Promise(function(resolve, reject) {
that._accountClient.restoreAccount(request, {password: that._password}, function(err: grpcWeb.RpcError) {
if (err) reject(err);
else resolve();
});
});
}
} }
export {HavenoDaemon}; export {HavenoDaemon};

View File

@ -43,13 +43,14 @@ class HavenoUtils {
* *
* TODO (woodser): move this to monero-javascript GenUtils.js as common utility * TODO (woodser): move this to monero-javascript GenUtils.js as common utility
* *
* @param process is the nodejs child process to child * @param {Process} process - the nodejs child process to child
* @param {String} signal - the kill signal, e.g. SIGTERM, SIGKILL, SIGINT (default)
*/ */
static async kill(process: any): Promise<void> { static async kill(process: any, signal?: string): Promise<void> {
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
process.on("exit", function() { resolve(); }); process.on("exit", function() { resolve(); });
process.on("error", function(err: any) { reject(err); }); process.on("error", function(err: any) { reject(err); });
process.kill("SIGINT"); process.kill(signal ? signal : "SIGINT");
}); });
} }
} }