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
/coverage
backup.zip
# production
/build
@ -17,7 +18,6 @@
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@ -4,11 +4,11 @@
import {HavenoDaemon} from "./HavenoDaemon";
import {HavenoUtils} from "./utils/HavenoUtils";
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 {XmrDestination, XmrTx, XmrIncomingTransfer, XmrOutgoingTransfer} from './protobuf/grpc_pb';
import AuthenticationStatus = UriConnection.AuthenticationStatus;
import OnlineStatus = UriConnection.OnlineStatus;
import AuthenticationStatus = UrlConnection.AuthenticationStatus;
import OnlineStatus = UrlConnection.OnlineStatus;
// import 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;
// other required imports
const console = require('console'); // import console because jest swallows messages in real time
const assert = require("assert");
const fs = require('fs');
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
const TestConfig = {
logging: {
level: 0, // set log level (gets more verbose increasing from 0)
logProcessOutput: false // enable or disable logging process output
},
logLevel: 0,
moneroBinsDir: "../haveno/.localnet",
networkType: monerojs.MoneroNetworkType.STAGENET,
haveno: {
@ -38,45 +35,52 @@ const TestConfig = {
version: "1.6.2"
},
monerod: {
uri: "http://localhost:38081",
url: "http://localhost:38081",
username: "superuser",
password: "abctesting123"
},
monerod2: {
uri: "http://localhost:58081",
url: "http://localhost:58081",
username: "superuser",
password: "abctesting123"
},
fundingWallet: {
uri: "http://localhost:38084",
url: "http://localhost:38084",
username: "rpc_user",
password: "abc123",
defaultPath: "test_funding_wallet",
minimumFunding: BigInt("5000000000000")
},
arbitrator: {
logProcessOutput: false,
appName: "haveno-XMR_STAGENET_arbitrator",
uri: "http://localhost:8079",
password: "apitest",
walletUsername: "rpc_user",
walletPassword: "abc123"
defaultHavenod: {
logProcessOutput: false, // log output for processes started by tests (except arbitrator, alice, and bob which are configured separately)
apiPassword: "apitest",
walletUsername: "haveno_user",
walletDefaultPassword: "password", // only used if account password not set
accountPasswordRequired: true,
accountPassword: "abctesting789",
autoLogin: true
},
alice: {
logProcessOutput: false,
appName: "haveno-XMR_STAGENET_alice",
uri: "http://localhost:8080",
password: "apitest",
walletUri: "http://127.0.0.1:38091",
walletUsername: "rpc_user",
walletPassword: "abc123"
},
bob: {
logProcessOutput: false,
appName: "haveno-XMR_STAGENET_bob",
uri: "http://localhost:8081",
password: "apitest",
},
startupHavenods: [{
appName: "haveno-XMR_STAGENET_arbitrator", // arbritrator
logProcessOutput: false,
url: "http://localhost:8079",
accountPasswordRequired: false,
accountPassword: "abctesting123",
}, {
appName: "haveno-XMR_STAGENET_alice", // alice
logProcessOutput: false,
url: "http://localhost:8080",
accountPasswordRequired: false,
accountPassword: "abctesting456",
walletUrl: "http://127.0.0.1:38091",
}, {
appName: "haveno-XMR_STAGENET_bob", // bob
logProcessOutput: false,
url: "http://localhost:8081",
accountPasswordRequired: false,
accountPassword: "abctesting789",
}
],
maxFee: BigInt("75000000000"),
walletSyncPeriodMs: 5000, // TODO (woodser): auto adjust higher if using remote connection
daemonPollPeriodMs: 15000,
@ -99,7 +103,8 @@ const TestConfig = {
["8085", ["10004", "7780"]],
["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 {
@ -107,6 +112,7 @@ interface TxContext {
}
// clients
let startupHavenods: HavenoDaemon[] = [];
let arbitrator: HavenoDaemon;
let alice: HavenoDaemon;
let bob: HavenoDaemon;
@ -118,42 +124,41 @@ let aliceWallet: any;
const HAVENO_PROCESSES: HavenoDaemon[] = [];
const HAVENO_PROCESS_PORTS: string[] = [];
// other config
const OFFLINE_ERR_MSG = "Http response at 400 or 500 level";
// -------------------------- BEFORE / AFTER TESTS ----------------------------
jest.setTimeout(TestConfig.timeout);
beforeAll(async () => {
// 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
let daemonPromises = [];
daemonPromises.push(initHavenoDaemon(TestConfig.arbitrator));
daemonPromises.push(initHavenoDaemon(TestConfig.alice));
daemonPromises.push(initHavenoDaemon(TestConfig.bob));
let daemons = await Promise.allSettled(daemonPromises);
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;
else throw new Error((daemons[1] as PromiseRejectedResult).reason);
if (daemons[2].status === "fulfilled") bob = (daemons[2] as PromiseFulfilledResult<HavenoDaemon>).value;
else throw new Error((daemons[2] as PromiseRejectedResult).reason);
// start configured haveno daemons
let promises = [];
for (let config of TestConfig.startupHavenods) promises.push(initHavenoDaemon(config));
for (let settledPromise of await Promise.allSettled(promises)) {
if (settledPromise.status !== "fulfilled") throw new Error((settledPromise as PromiseRejectedResult).reason);
startupHavenods.push((settledPromise as PromiseFulfilledResult<HavenoDaemon>).value);
}
// assign arbitrator alice, bob
arbitrator = startupHavenods[0];
alice = startupHavenods[1];
bob = startupHavenods[2];
// register arbitrator as dispute agents
await arbitrator.registerDisputeAgent("mediator", TestConfig.devPrivilegePrivKey);
await arbitrator.registerDisputeAgent("refundagent", TestConfig.devPrivilegePrivKey);
// connect monero clients
monerod = await monerojs.connectToDaemonRpc(TestConfig.monerod.uri, TestConfig.monerod.username, TestConfig.monerod.password);
aliceWallet = await monerojs.connectToWalletRpc(TestConfig.alice.walletUri, TestConfig.alice.walletUsername, TestConfig.alice.walletPassword);
monerod = await monerojs.connectToDaemonRpc(TestConfig.monerod.url, TestConfig.monerod.username, TestConfig.monerod.password);
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
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() => {
@ -161,15 +166,11 @@ beforeEach(async() => {
});
afterAll(async () => {
let stopPromises = [];
if (arbitrator && arbitrator.getProcess()) stopPromises.push(stopHavenoProcess(arbitrator));
if (alice && alice.getProcess()) stopPromises.push(stopHavenoProcess(alice));
if (bob && bob.getProcess()) stopPromises.push(stopHavenoProcess(bob));
return Promise.all(stopPromises);
let promises = [];
for (let havenod of startupHavenods) if (havenod.getProcess()) promises.push(releaseHavenoProcess(havenod));
return Promise.all(promises);
});
jest.setTimeout(500000);
// ----------------------------------- TESTS ----------------------------------
test("Can get the version", async () => {
@ -177,50 +178,114 @@ test("Can get the version", async () => {
expect(version).toEqual(TestConfig.haveno.version);
});
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
test("Can manage an account", async () => {
let charlie: HavenoDaemon | undefined;
let err: any;
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);
// start charlie without opening account
charlie = await initHavenoDaemon({autoLogin: false});
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
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 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());
async function testAccountNotOpen(havenod: HavenoDaemon): Promise<void> { // TODO: generalize this?
try { await havenod.getMoneroConnections(); throw new Error("Should have thrown"); }
catch (err) { assert.equal(err.message, "Account not open"); }
try { await havenod.getXmrTxs(); throw new Error("Should have thrown"); }
catch (err) { assert.equal(err.message, "Account not open"); }
try { await havenod.getBalances(); throw new Error("Should have thrown"); }
catch (err) { assert.equal(err.message, "Account not open"); }
}
});
@ -231,38 +296,38 @@ test("Can manage Monero daemon connections", async () => {
try {
// start charlie
charlie = await startHavenoProcess(undefined, TestConfig.logging.logProcessOutput);
charlie = await initHavenoDaemon();
// test default connections
let monerodUri1 = "http://localhost:38081"; // TODO: (woodser): move to config
let monerodUri2 = "http://haveno.exchange:38081";
let connections: UriConnection[] = await charlie.getMoneroConnections();
testConnection(getConnection(connections, monerodUri1)!, monerodUri1, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1);
testConnection(getConnection(connections, monerodUri2)!, monerodUri2, OnlineStatus.UNKNOWN, AuthenticationStatus.NO_AUTHENTICATION, 2);
let monerodUrl1 = "http://localhost:38081"; // TODO: (woodser): move to config
let monerodUrl2 = "http://haveno.exchange:38081";
let connections: UrlConnection[] = await charlie.getMoneroConnections();
testConnection(getConnection(connections, monerodUrl1)!, monerodUrl1, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1);
testConnection(getConnection(connections, monerodUrl2)!, monerodUrl2, OnlineStatus.UNKNOWN, AuthenticationStatus.NO_AUTHENTICATION, 2);
// test default connection
let connection: UriConnection | undefined = await charlie.getMoneroConnection();
testConnection(connection!, monerodUri1, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1);
//assert(await charlie.isMoneroConnected()); // TODO (woodser): support havenod.isConnected()?
let connection: UrlConnection | undefined = await charlie.getMoneroConnection();
assert(await charlie.isConnectedToMonero());
testConnection(connection!, monerodUrl1, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1);
// add a new connection
let fooBarUri = "http://foo.bar";
await charlie.addMoneroConnection(fooBarUri);
let fooBarUrl = "http://foo.bar";
await charlie.addMoneroConnection(fooBarUrl);
connections = await charlie.getMoneroConnections();
connection = getConnection(connections, fooBarUri);
testConnection(connection!, fooBarUri, OnlineStatus.UNKNOWN, AuthenticationStatus.NO_AUTHENTICATION, 0);
//connection = await charlie.getMoneroConnection(uri); TODO (woodser): allow getting connection by uri?
connection = getConnection(connections, fooBarUrl);
testConnection(connection!, fooBarUrl, OnlineStatus.UNKNOWN, AuthenticationStatus.NO_AUTHENTICATION, 0);
// set prioritized connection without credentials
await charlie.setMoneroConnection(new UriConnection()
.setUri(TestConfig.monerod2.uri)
await charlie.setMoneroConnection(new UrlConnection()
.setUrl(TestConfig.monerod2.url)
.setPriority(1));
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 = 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
let cmd = [
@ -280,31 +345,37 @@ test("Can manage Monero daemon connections", async () => {
// connection is online and not authenticated
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
await charlie.setMoneroConnection(new UriConnection()
.setUri(TestConfig.monerod2.uri)
await charlie.setMoneroConnection(new UrlConnection()
.setUrl(TestConfig.monerod2.url)
.setUsername(TestConfig.monerod2.username)
.setPassword(TestConfig.monerod2.password)
.setPriority(1));
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 = 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
let appName = charlie.getAppName();
await stopHavenoProcess(charlie);
charlie = await startHavenoProcess(appName, TestConfig.logging.logProcessOutput);
await releaseHavenoProcess(charlie);
charlie = await initHavenoDaemon({appName: appName, accountPassword: password});
// connection is restored, online, and authenticated
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();
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
await charlie.setAutoSwitch(true);
@ -315,58 +386,58 @@ test("Can manage Monero daemon connections", async () => {
// test auto switch after periodic connection check
await wait(TestConfig.daemonPollPeriodMs);
connection = await charlie.getMoneroConnection();
testConnection(connection!, monerodUri1, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1);
testConnection(connection!, monerodUrl1, OnlineStatus.ONLINE, AuthenticationStatus.AUTHENTICATED, 1);
// stop checking connection periodically
await charlie.stopCheckingConnection();
// remove current connection
await charlie.removeMoneroConnection(monerodUri1);
await charlie.removeMoneroConnection(monerodUrl1);
// check current connection
connection = await charlie.checkMoneroConnection();
assert.equal(undefined, connection);
assert.equal(connection, undefined);
// check all connections
await charlie.checkMoneroConnections();
connections = await charlie.getMoneroConnections();
testConnection(getConnection(connections, fooBarUri)!, fooBarUri, OnlineStatus.OFFLINE, AuthenticationStatus.NO_AUTHENTICATION, 0);
for (let connection of connections) testConnection(connection!, connection.getUri(), OnlineStatus.OFFLINE, AuthenticationStatus.NO_AUTHENTICATION);
testConnection(getConnection(connections, fooBarUrl)!, fooBarUrl, OnlineStatus.OFFLINE, AuthenticationStatus.NO_AUTHENTICATION, 0);
for (let connection of connections) testConnection(connection!, connection.getUrl(), OnlineStatus.OFFLINE, AuthenticationStatus.NO_AUTHENTICATION);
// set connection to previous uri
await charlie.setMoneroConnection(fooBarUri);
// set connection to previous url
await charlie.setMoneroConnection(fooBarUrl);
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
let fooBarUri2 = "http://foo.bar.xyz";
await charlie.setMoneroConnection(fooBarUri2);
// set connection to new url
let fooBarUrl2 = "http://foo.bar.xyz";
await charlie.setMoneroConnection(fooBarUrl2);
connections = await charlie.getMoneroConnections();
connection = getConnection(connections, fooBarUri2);
testConnection(connection!, fooBarUri2, OnlineStatus.UNKNOWN, AuthenticationStatus.NO_AUTHENTICATION, 0);
connection = getConnection(connections, fooBarUrl2);
testConnection(connection!, fooBarUrl2, OnlineStatus.UNKNOWN, AuthenticationStatus.NO_AUTHENTICATION, 0);
// reset connection
await charlie.setMoneroConnection();
assert.equal(undefined, await charlie.getMoneroConnection());
assert.equal(await charlie.getMoneroConnection(), undefined);
// test auto switch after start checking connection
await charlie.setAutoSwitch(false);
await charlie.startCheckingConnection(5000); // checks the connection
await charlie.setAutoSwitch(true);
await charlie.addMoneroConnection(new UriConnection()
.setUri(TestConfig.monerod.uri)
await charlie.addMoneroConnection(new UrlConnection()
.setUrl(TestConfig.monerod.url)
.setUsername(TestConfig.monerod.username)
.setPassword(TestConfig.monerod.password)
.setPriority(2));
await wait(10000);
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) {
err = err2;
}
// stop processes
if (charlie) await stopHavenoProcess(charlie);
if (charlie) await releaseHavenoProcess(charlie);
if (monerod2) await monerod2.stopProcess();
// TODO: how to delete trader app folder at end of test?
if (err) throw err;
@ -432,6 +503,32 @@ test("Can get balances", async () => {
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 () => {
// get all market prices
@ -465,6 +562,27 @@ test("Can get market prices", async () => {
.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 () => {
let offers: OfferInfo[] = await alice.getOffers("BUY");
for (let offer of offers) {
@ -624,7 +742,7 @@ test("Handles unexpected errors during trade initialization", async () => {
// start and fund 3 trader processes
console.log("Starting trader processes");
traders = await startHavenoProcesses(3, TestConfig.logging.logProcessOutput);
traders = await initHavenoDaemons(3);
console.log("Funding traders");
let tradeAmount: bigint = BigInt("250000000000");
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]);
wait(3000).then(async function() {
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());
console.log("Sweeping trade funds");
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
wait(3000).then(async function() {
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());
console.log("Sweeping offer funds");
await traderWallet.sweepUnlocked({address: await traderWallet.getPrimaryAddress(), relay: true});
@ -706,7 +824,7 @@ test("Handles unexpected errors during trade initialization", async () => {
// stop traders
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;
});
@ -716,7 +834,7 @@ test("Cannot make or take offer with insufficient unlocked funds", async () => {
try {
// start charlie
charlie = await startHavenoProcess(undefined, TestConfig.logging.logProcessOutput);
charlie = await initHavenoDaemon();
// charlie creates ethereum payment account
let paymentAccount = await createCryptoPaymentAccount(charlie);
@ -728,7 +846,7 @@ test("Cannot make or take offer with insufficient unlocked funds", async () => {
} catch (err) {
let errTyped = err as grpcWeb.RpcError;
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
@ -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")
} catch (err) {
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);
}
@ -766,7 +884,7 @@ test("Cannot make or take offer with insufficient unlocked funds", async () => {
}
// stop charlie
if (charlie) await stopHavenoProcess(charlie);
if (charlie) await releaseHavenoProcess(charlie);
// TODO: how to delete trader app folder at end of test?
if (err) throw err;
});
@ -899,117 +1017,95 @@ test("Can complete a trade", async () => {
// ------------------------------- HELPERS ------------------------------------
function getConnection(connections: UriConnection[], uri: string): UriConnection | undefined {
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[]> {
async function initHavenoDaemons(numDaemons: number, config?: any) {
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);
}
/**
* Start a Haveno daemon as a process.
*
* 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();
async function initHavenoDaemon(config?: any): Promise<HavenoDaemon> {
config = Object.assign({}, TestConfig.defaultHavenod, config);
if (!config.appName) config.appName = "haveno-XMR_STAGENET_instance_" + GenUtils.getUUID();
// get proxy port for haveno process
let proxyPort;
if (appName === TestConfig.arbitrator.appName) proxyPort = "8079";
else if (appName === TestConfig.alice.appName) proxyPort = "8080";
else if (appName === TestConfig.bob.appName) proxyPort = "8081";
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;
// connect to existing server or start new process
let havenod;
try {
// try to connect to existing server
havenod = new HavenoDaemon(config.url, config.apiPassword);
await havenod.getVersion();
} catch (err) {
// get port for haveno process
let proxyPort = "";
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
let cmd: string[] = [
"./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);
// open account if configured
if (config.autoLogin) await initHavenoAccount(havenod, config.accountPassword);
return havenod;
}
/**
* Get a free port.
*/
async function getFreePort(): Promise<number> {
return new Promise(function(resolve, reject) {
let srv = net.createServer();
srv.listen(0, function() {
let port = srv.address().port;
srv.close(function() {
resolve(port);
})
async function getAvailablePort(): Promise<number> {
return new Promise(function(resolve, reject) {
let srv = net.createServer();
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) {
await havenod.stopProcess();
async function releaseHavenoProcess(havenod: HavenoDaemon) {
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() {
// 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
let walletIsOpen = false
@ -1164,6 +1260,20 @@ function getNotifications(notifications: NotificationMessage[], notificationType
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) {
assert(tx.getHash());
expect(BigInt(tx.getFee())).toBeLessThan(TestConfig.maxFee);

View File

@ -1,9 +1,9 @@
import {HavenoUtils} from "./utils/HavenoUtils";
import {TaskLooper} from "./utils/TaskLooper";
import * as grpcWeb from 'grpc-web';
import {DisputeAgentsClient, GetVersionClient, NotificationsClient, PriceClient, WalletsClient, OffersClient, PaymentAccountsClient, TradesClient, MoneroConnectionsClient} 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 {AvailabilityResult, PaymentAccount} from './protobuf/pb_pb';
import {GetVersionClient, AccountClient, MoneroConnectionsClient, DisputeAgentsClient, NotificationsClient, WalletsClient, PriceClient, OffersClient, PaymentAccountsClient, TradesClient, ShutdownServerClient} from './protobuf/GrpcServiceClientPb';
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 {PaymentAccount, AvailabilityResult} from './protobuf/pb_pb';
const console = require('console');
/**
@ -12,6 +12,7 @@ const console = require('console');
class HavenoDaemon {
// grpc clients
_appName: string|undefined;
_getVersionClient: GetVersionClient;
_disputeAgentsClient: DisputeAgentsClient;
_notificationsClient: NotificationsClient;
@ -21,17 +22,24 @@ class HavenoDaemon {
_paymentAccountsClient: PaymentAccountsClient;
_offersClient: OffersClient;
_tradesClient: TradesClient;
_accountClient: AccountClient;
_shutdownServerClient: ShutdownServerClient;
// other instance variables
// state variables
_url: string;
_password: string;
_process: any;
_processLogging: boolean = false;
_processLogging = false;
_walletRpcPort: number|undefined;
_notificationListeners: ((notification: NotificationMessage) => void)[] = [];
_registerNotificationListenerCalled = false;
_keepAliveLooper: any;
_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.
*
@ -39,20 +47,22 @@ class HavenoDaemon {
* @param {string} password - Haveno daemon password
*/
constructor(url: string, password: string) {
HavenoUtils.log(2, "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");
HavenoUtils.log(2, "Creating HavenoDaemon(" + url + ", " + password + ")");
this._url = url;
this._password = password;
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._disputeAgentsClient = new DisputeAgentsClient(this._url);
this._walletsClient = new WalletsClient(this._url);
this._priceClient = new PriceClient(this._url);
this._paymentAccountsClient = new PaymentAccountsClient(this._url);
this._offersClient = new OffersClient(this._url);
this._tradesClient = new TradesClient(this._url);
this._notificationsClient = new NotificationsClient(this._url);
this._shutdownServerClient = new ShutdownServerClient(this._url);
}
/**
@ -72,7 +82,7 @@ class HavenoDaemon {
// state variables
let output = "";
let isResolved = false;
let isStarted = false;
let daemon: HavenoDaemon|undefined = undefined;
// start process
@ -83,11 +93,11 @@ class HavenoDaemon {
// handle stdout
childProcess.stdout.on('data', async function(data: any) {
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
// read success message
if (line.indexOf("HavenoHeadlessAppMain: onSetupComplete") >= 0) {
// initialize daemon on success or login required message
if (!daemon && (line.indexOf(HavenoDaemon._fullyInitializedMessage) >= 0 || line.indexOf(HavenoDaemon._loginRequiredMessage) >= 0)) {
// get api password
let passwordIdx = cmd.indexOf("--apiPassword");
@ -108,40 +118,40 @@ class HavenoDaemon {
if (walletRpcPortIdx >= 0) daemon._walletRpcPort = parseInt(cmd[walletRpcPortIdx + 1]);
// resolve promise with client connected to internal process
isResolved = true;
isStarted = true;
resolve(daemon);
}
// read error message
if (line.indexOf("[HavenoDaemonMain] ERROR") >= 0) {
if (!isResolved) await rejectProcess(new Error(line));
if (!isStarted) await rejectStartup(new Error(line));
}
});
// handle stderr
childProcess.stderr.on('data', function(data: any) {
if (HavenoUtils.getLogLevel() >= 2 && loggingEnabled()) process.stderr.write(data);
if (loggingEnabled()) process.stderr.write(data);
});
// handle exit
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
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 (!isResolved) await rejectProcess(err);
if (!isStarted) await rejectStartup(err);
});
// handle uncaught exception
childProcess.on("uncaughtException", async function(err: any, origin: any) {
console.error("Uncaught exception in Haveno process: " + err.message);
console.error(origin);
await rejectProcess(err);
await rejectStartup(err);
});
async function rejectProcess(err: any) {
async function rejectStartup(err: any) {
await HavenoUtils.kill(childProcess);
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.
*
@ -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
* @param {string} registrationKey - registration key
* @return {boolean} true if connected with the Haveno daemon, false otherwise
*/
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 request = new RegisterDisputeAgentRequest()
.setDisputeAgentType(disputeAgentType)
.setRegistrationKey(registrationKey);
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);
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
*/
async addNotificationListener(listener: (notification: NotificationMessage) => void): Promise<void> {
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.
*
* @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;
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);
else resolve();
});
@ -266,12 +436,12 @@ class HavenoDaemon {
/**
* 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;
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);
else resolve();
});
@ -281,9 +451,9 @@ class HavenoDaemon {
/**
* 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;
return new Promise(function(resolve, reject) {
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.
*
* @return {UriConnection[]} all daemon connections
* @return {UrlConnection[]} all daemon connections
*/
async getMoneroConnections(): Promise<UriConnection[]> {
async getMoneroConnections(): Promise<UrlConnection[]> {
let that = this;
return new Promise(function(resolve, reject) {
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.
*
*
* 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 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.
*
* @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 request = new SetConnectionRequest();
if (typeof connection === "string") request.setUri(connection);
if (typeof connection === "string") request.setUrl(connection);
else request.setConnection(connection);
return new Promise(function(resolve, reject) {
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.
*
* @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;
return new Promise(function(resolve, reject) {
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.
*
* @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;
return new Promise(function(resolve, reject) {
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.
*
* @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;
return new Promise(function(resolve, reject) {
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.
*
@ -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 ----------------------------------
/**
* 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.
* Due to the nature of grpc streaming, this method returns a promise
* 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;
return new Promise(function(resolve) {
// send request to register client listener
that._notificationsClient.registerNotificationListener(new RegisterNotificationListenerRequest(), {password: that._password})
.on("data", (data) => {
.on('data', (data) => {
if (data instanceof NotificationMessage) {
for (let listener of that._notificationListeners) listener(data);
}
});
// periodically send keep alive requests // TODO (woodser): better way to keep notification stream alive?
let firstRequest = true;
let taskLooper = new TaskLooper(async function() {
that._keepAliveLooper = new TaskLooper(async function() {
if (firstRequest) {
firstRequest = false;
return;
@ -800,16 +1048,15 @@ class HavenoDaemon {
.setType(NotificationMessage.NotificationType.KEEP_ALIVE)
.setTimestamp(Date.now()));
});
taskLooper.start(that._keepAlivePeriodMs);
// TODO: call returns before listener registered
setTimeout(function() { resolve(); }, 1000);
that._keepAliveLooper.start(that._keepAlivePeriodMs);
setTimeout(resolve, 1000); // TODO: call returns before listener registered
});
}
/**
* Send a notification.
*
*
* @param {NotificationMessage} notification - notification to send
*/
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};

View File

@ -43,13 +43,14 @@ class HavenoUtils {
*
* 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) {
process.on("exit", function() { resolve(); });
process.on("error", function(err: any) { reject(err); });
process.kill("SIGINT");
process.kill(signal ? signal : "SIGINT");
});
}
}