Skip to content

Commit

Permalink
feat(swapclient): auto init wallets on xud unlock
Browse files Browse the repository at this point in the history
This adds a new feature to xud to automatically attempt to create a
wallet for any new swap client configured after an xud node has been
created. Effectively this only changes the behavior for lnd clients, as
this is already the existing behavior for Connext. The process for
initializing has now been standardized instead of the ad hoc approach
used previously.

If xud tries to unlock an lnd node and gets an error message indicating
that the wallet has not been created, then it will generate a client &
currency specific seed mnemonic using seedutil and call `InitWallet`
with that seed and the existing xud password, such that the wallet
funds and node identity for the new lnd client can be unlocked and
restored along with the rest of lnd.

Closes #1929.
  • Loading branch information
sangaman committed Nov 2, 2020
1 parent a0cc6d7 commit 99486d1
Show file tree
Hide file tree
Showing 9 changed files with 139 additions and 64 deletions.
15 changes: 8 additions & 7 deletions lib/Xud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import path from 'path';
import { Subscription } from 'rxjs';
import bootstrap from './bootstrap';
import Config from './Config';
import { SwapClientType, XuNetwork } from './constants/enums';
import { XuNetwork, SwapClientType } from './constants/enums';
import DB from './db/DB';
import GrpcServer from './grpc/GrpcServer';
import GrpcWebProxyServer from './grpc/webproxy/GrpcWebProxyServer';
Expand Down Expand Up @@ -147,6 +147,13 @@ class Xud extends EventEmitter {
if (!nodeKey) {
return; // we interrupted before unlocking xud
}

// we need to initialize connext every time xud starts
// below is in lieu of the UnlockNode/CreateNode call flow
await this.swapClientManager.initConnext(
nodeKey.childSeed(SwapClientType.Connext),
);

this.rpcServer.grpcService.locked = false;
} else {
throw new Error('rpc server cannot be disabled when xud is locked');
Expand Down Expand Up @@ -194,12 +201,6 @@ class Xud extends EventEmitter {
// wait for components to initialize in parallel
await Promise.all(initPromises);

// We initialize Connext separately because it
// requires a NodeKey.
await this.swapClientManager.initConnext(
nodeKey.childSeed(SwapClientType.Connext),
);

// initialize pool and start listening/connecting only once other components are initialized
await this.pool.init();

Expand Down
3 changes: 3 additions & 0 deletions lib/service/InitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class InitService extends EventEmitter {
// use this seed to init any lnd wallets that are uninitialized
const initWalletResult = await this.swapClientManager.initWallets({
seedMnemonic,
nodeKey,
walletPassword: password,
});

Expand Down Expand Up @@ -73,6 +74,7 @@ class InitService extends EventEmitter {
this.emit('nodekey', nodeKey);

return this.swapClientManager.unlockWallets({
nodeKey,
walletPassword: password,
connextSeed: nodeKey.childSeed(SwapClientType.Connext),
});
Expand Down Expand Up @@ -115,6 +117,7 @@ class InitService extends EventEmitter {
// use the seed and database backups to restore any swap clients' wallets
// that are uninitialized
const initWalletResult = await this.swapClientManager.initWallets({
nodeKey,
lndBackups: lndBackupsMap,
walletPassword: password,
seedMnemonic: seedMnemonicList,
Expand Down
2 changes: 1 addition & 1 deletion lib/service/Service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { EventEmitter } from 'events';
import { fromEvent, merge, Observable } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ProvidePreimageEvent, TransferReceivedEvent } from '../connextclient/types';
Expand All @@ -18,7 +19,6 @@ import { checkDecimalPlaces, sortOrders, toEip55Address } from '../utils/utils';
import commitHash from '../Version';
import errors from './errors';
import { NodeIdentifier, ServiceComponents, ServiceOrder, ServiceOrderSidesArrays, ServicePlaceOrderEvent, ServiceTrade, XudInfo } from './types';
import { EventEmitter } from 'events';

/** Functions to check argument validity and throw [[INVALID_ARGUMENT]] when invalid. */
const argChecks = {
Expand Down
77 changes: 56 additions & 21 deletions lib/swaps/SwapClientManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import lndErrors from '../lndclient/errors';
import LndClient from '../lndclient/LndClient';
import { LndInfo } from '../lndclient/types';
import { Level, Loggers } from '../Logger';
import NodeKey from '../nodekey/NodeKey';
import { Currency, OwnLimitOrder } from '../orderbook/types';
import Peer from '../p2p/Peer';
import { encipher } from '../utils/seedutil';
import { UnitConverter } from '../utils/UnitConverter';
import errors from './errors';
import SwapClient, { ClientStatus } from './SwapClient';
Expand Down Expand Up @@ -119,12 +121,21 @@ class SwapClientManager extends EventEmitter {
}
}

// Initializes Connext client with a seed
/**
* Initializes Connext client with a seed
* @returns true if Connext
*/
public initConnext = async (seed: string) => {
if (!this.config.connext.disable && this.connextClient) {
this.connextClient.setSeed(seed);
await this.connextClient.init();
try {
if (!this.config.connext.disable && this.connextClient) {
this.connextClient.setSeed(seed);
await this.connextClient.init();
return true;
}
} catch (err) {
this.loggers.connext.error('could not initialize connext', err);
}
return false;
}

/**
Expand Down Expand Up @@ -272,11 +283,12 @@ class SwapClientManager extends EventEmitter {
/**
* Initializes wallets with seed and password.
*/
public initWallets = async ({ walletPassword, seedMnemonic, restore, lndBackups }: {
public initWallets = async ({ walletPassword, seedMnemonic, restore, lndBackups, nodeKey }: {
walletPassword: string,
seedMnemonic: string[],
restore?: boolean,
lndBackups?: Map<string, Uint8Array>,
nodeKey: NodeKey,
}) => {
this.walletPassword = walletPassword;

Expand All @@ -285,24 +297,26 @@ class SwapClientManager extends EventEmitter {
const initializedLndWallets: string[] = [];
let initializedConnext = false;

for (const client of this.swapClients.values()) {
if (isLndClient(client)) {
if (client.isWaitingUnlock()) {
const initWalletPromise = client.initWallet(
for (const swapClient of this.swapClients.values()) {
if (isLndClient(swapClient)) {
if (swapClient.isWaitingUnlock()) {
const initWalletPromise = swapClient.initWallet(
walletPassword,
seedMnemonic,
restore,
lndBackups ? lndBackups.get(client.currency) : undefined,
lndBackups ? lndBackups.get(swapClient.currency) : undefined,
).then(() => {
initializedLndWallets.push(client.currency);
initializedLndWallets.push(swapClient.currency);
}).catch((err) => {
client.logger.error(`could not initialize lnd wallet: ${err.message}`);
throw errors.SWAP_CLIENT_WALLET_NOT_CREATED(`could not initialize lnd-${client.currency}: ${err.message}`);
swapClient.logger.error('could not initialize lnd wallet', err.message);
throw errors.SWAP_CLIENT_WALLET_NOT_CREATED(`could not initialize lnd-${swapClient.currency}: ${err.message}`);
});
initWalletPromises.push(initWalletPromise);
}
} else if (isConnextClient(client)) {
initializedConnext = true;
} else if (isConnextClient(swapClient)) {
initializedConnext = await this.initConnext(
nodeKey.childSeed(SwapClientType.Connext),
);
}
}

Expand All @@ -318,8 +332,9 @@ class SwapClientManager extends EventEmitter {
* Unlocks wallets with a password.
* @returns an array of currencies for each lnd client that was unlocked
*/
public unlockWallets = async ({ walletPassword }: {
public unlockWallets = async ({ walletPassword, nodeKey }: {
walletPassword: string,
nodeKey: NodeKey,
connextSeed: string,
}) => {
this.walletPassword = walletPassword;
Expand All @@ -334,22 +349,42 @@ class SwapClientManager extends EventEmitter {
if (swapClient.isWaitingUnlock()) {
const unlockWalletPromise = swapClient.unlockWallet(walletPassword).then(() => {
unlockedLndClients.push(swapClient.currency);
}).catch((err) => {
lockedLndClients.push(swapClient.currency);
swapClient.logger.debug(`could not unlock wallet: ${err.message}`);
}).catch(async (err) => {
let walletCreated = false;
if (err.details === 'wallet not found') {
// this wallet hasn't been initialized, so we will try to initialize it now
const decipheredSeed = nodeKey.privKey.slice(0, 19);
const decipheredSeedHex = decipheredSeed.toString('hex');
const seedMnemonic = await encipher(decipheredSeedHex);

try {
await swapClient.initWallet(this.walletPassword ?? '', seedMnemonic);
walletCreated = true;
} catch (err) {
swapClient.logger.error('could not initialize lnd wallet', err);
}
}

if (!walletCreated) {
lockedLndClients.push(swapClient.currency);
swapClient.logger.debug(`could not unlock wallet: ${err.message}`);
}
});
unlockWalletPromises.push(unlockWalletPromise);
} else if (swapClient.isDisconnected() || swapClient.isMisconfigured() || swapClient.isNotInitialized()) {
// if the swap client is not connected, we treat it as locked since lnd will likely be locked when it comes online
lockedLndClients.push(swapClient.currency);
}
} else if (isConnextClient(swapClient)) {
// TODO(connext): unlock Connext using connextSeed
await this.initConnext(
nodeKey.childSeed(SwapClientType.Connext),
);
}
}

await Promise.all(unlockWalletPromises);

// TODO(connext): unlock Connext using connextSeed

return { unlockedLndClients, lockedLndClients };
}

Expand Down
4 changes: 2 additions & 2 deletions lib/swaps/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ const errors = {
message: `swapClient for currency ${currency} not found`,
code: errorCodes.SWAP_CLIENT_NOT_FOUND,
}),
SWAP_CLIENT_NOT_CONFIGURED: (currency: string) => ({
message: `swapClient for currency ${currency} is not configured`,
SWAP_CLIENT_NOT_CONFIGURED: (currencyOrClientType: string) => ({
message: `swap client for ${currencyOrClientType} is not configured`,
code: errorCodes.SWAP_CLIENT_NOT_CONFIGURED,
}),
PAYMENT_HASH_NOT_FOUND: (rHash: string) => ({
Expand Down
13 changes: 7 additions & 6 deletions lib/utils/seedutil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,19 @@ async function keystore(mnemonic: string[], password: string, pathVal: string) {
}

/**
* Executes the seedutil tool to encipher a seed mnemonic into bytes.
* @param mnemonic the 24 seed recovery mnemonic
* Executes the seedutil tool to encipher a deciphered seed hex string into a mnemonic
* @param decipheredSeedHex the deciphered seed in hex format
*/
async function encipher(mnemonic: string[]) {
const { stdout, stderr } = await exec(`${seedutilPath} encipher ${mnemonic.join(' ')}`);
async function encipher(decipheredSeedHex: string) {
const { stdout, stderr } = await exec(`${seedutilPath} encipher ${decipheredSeedHex}`);

if (stderr) {
throw new Error(stderr);
}

const encipheredSeed = stdout.trim();
return Buffer.from(encipheredSeed, 'hex');
const mnemonic = stdout.trim().split(' ');
assert.equal(mnemonic.length, 24, 'seedutil did not encipher mnemonic of exactly 24 words');
return mnemonic;
}

async function decipher(mnemonic: string[]) {
Expand Down
39 changes: 26 additions & 13 deletions seedutil/SeedUtil.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ const ERRORS = {
INVALID_AEZEED: 'invalid aezeed',
KEYSTORE_FILE_ALREADY_EXISTS: 'account already exists',
INVALID_PASSPHRASE: 'invalid passphrase',
INVALID_HEX_LENGTH: 'invalid hex length',
MISSING_HEX_STRING: 'missing hex string',
};

const PASSWORD = 'wasspord';
Expand Down Expand Up @@ -75,31 +77,42 @@ const VALID_SEED_NO_PASS = {
const DEFAULT_KEYSTORE_PATH = `${process.cwd()}/seedutil/keystore`;

describe('SeedUtil encipher', () => {
const decipheredSeedHex = '000f4b90d9f9720bfac78aaea09a5193b34811';
test('it errors with no arguments', async () => {
await expect(executeCommand('./seedutil/seedutil encipher'))
.rejects.toThrow(ERRORS.INVALID_ARGS_LENGTH);
.rejects.toThrow(ERRORS.MISSING_HEX_STRING);
});

test('it errors with 23 words', async () => {
const cmd = `./seedutil/seedutil encipher ${VALID_SEED.seedWords.slice(0, 23).join(' ')}`;
test('it errors with insufficient hex length', async () => {
const cmd = './seedutil/seedutil encipher 000f4b90d9f9720bfac78aaea09a5193';
await expect(executeCommand(cmd))
.rejects.toThrow(ERRORS.INVALID_ARGS_LENGTH);
.rejects.toThrow(ERRORS.INVALID_HEX_LENGTH);
});

test('it errors with 24 words and invalid aezeed password', async () => {
const cmd = `./seedutil/seedutil encipher ${VALID_SEED.seedWords.join(' ')}`;
test('it errors with excess hex length', async () => {
const cmd = './seedutil/seedutil encipher 000f4b90d9f9720bfac78aaea09a5193b34811aabbcc';
await expect(executeCommand(cmd))
.rejects.toThrow(ERRORS.INVALID_AEZEED);
.rejects.toThrow(ERRORS.INVALID_HEX_LENGTH);
});

test('it succeeds with 24 words, valid aezeed password', async () => {
const cmd = `./seedutil/seedutil encipher -aezeedpass=${VALID_SEED.seedPassword} ${VALID_SEED.seedWords.join(' ')}`;
await expect(executeCommand(cmd)).resolves.toMatchSnapshot();
test('it enciphers with valid aezeed password and deciphers back to same seed', async () => {
const cmd = `./seedutil/seedutil encipher -aezeedpass=${VALID_SEED.seedPassword} ${decipheredSeedHex}`;

const mnemonic = await executeCommand(cmd);

const decipherCmd = `./seedutil/seedutil decipher -aezeedpass=${VALID_SEED.seedPassword} ${mnemonic}`;
const decipherOutput = await executeCommand(decipherCmd);
expect(decipherOutput.trim()).toEqual(decipheredSeedHex);
});

test('it succeeds with 24 words, no aezeed password', async () => {
const cmd = `./seedutil/seedutil encipher ${VALID_SEED_NO_PASS.seedWords.join(' ')}`;
await expect(executeCommand(cmd)).resolves.toMatchSnapshot();
test('it enciphers with no aezeed password and deciphers back to same seed', async () => {
const cmd = `./seedutil/seedutil encipher ${decipheredSeedHex}`;

const mnemonic = await executeCommand(cmd);

const decipherCmd = `./seedutil/seedutil decipher ${mnemonic}`;
const decipherOutput = await executeCommand(decipherCmd);
expect(decipherOutput.trim()).toEqual(decipheredSeedHex);
});
});

Expand Down
10 changes: 0 additions & 10 deletions seedutil/__snapshots__/SeedUtil.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,3 @@ exports[`SeedUtil derivechild it succeeds with 24 words, valid aezeed password 1
"000ecdef333ecf9054ccb4fb843a3dbbf4ac6a
"
`;

exports[`SeedUtil encipher it succeeds with 24 words, no aezeed password 1`] = `
"00738860374692022c462027a35aaaef3c3289aa0a057e2600000000002cad2e2b
"
`;

exports[`SeedUtil encipher it succeeds with 24 words, valid aezeed password 1`] = `
"00a71642b0ace8f523a977950005c71220ea460b423a0a9f000000000079ef937c
"
`;
40 changes: 36 additions & 4 deletions seedutil/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"crypto/hmac"
"crypto/sha512"
"encoding/binary"
"encoding/hex"
"flag"
"fmt"
Expand Down Expand Up @@ -115,11 +116,42 @@ func main() {
encipherCommand.Parse(os.Args[2:])
args = encipherCommand.Args()

mnemonic := parseMnemonic(args)
cipherSeed := mnemonicToCipherSeed(mnemonic, aezeedPassphrase)
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "missing hex string")
os.Exit(1)
}
decipheredSeedHex := args[0]
decipheredSeed, err := hex.DecodeString(decipheredSeedHex)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

if len(decipheredSeed) != 19 {
fmt.Fprintf(os.Stderr, "\nerror: invalid hex length of %v bytes\n", len(decipheredSeed))
os.Exit(1)
}

internalVersion := decipheredSeed[0]
birthday := binary.BigEndian.Uint16(decipheredSeed[1:3])
var entropy [16]byte
copy(entropy[:], decipheredSeed[3:19])

genesisTime := time.Date(2009, time.January, 3, 18, 15, 5, 0, time.UTC)

encipheredSeed, _ := cipherSeed.Encipher([]byte(*aezeedPassphrase))
fmt.Println(hex.EncodeToString(encipheredSeed[:]))
cipherSeed, err := aezeed.New(internalVersion, &entropy, genesisTime.AddDate(0, 0, int(birthday)))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

mnemonic, err := cipherSeed.ToMnemonic([]byte(*aezeedPassphrase))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

fmt.Println(strings.Join([]string(mnemonic[:]), " "))
case "decipher":
aezeedPassphrase := decipherCommand.String("aezeedpass", defaultAezeedPassphrase, "aezeed passphrase")
decipherCommand.Parse(os.Args[2:])
Expand Down

0 comments on commit 99486d1

Please sign in to comment.