Skip to content

Commit

Permalink
feat: cli add custom derivation path option
Browse files Browse the repository at this point in the history
  • Loading branch information
ahsan-javaid authored and kyranjamie committed Oct 26, 2021
1 parent bb1db2b commit 9ba53be
Show file tree
Hide file tree
Showing 8 changed files with 184 additions and 7 deletions.
14 changes: 12 additions & 2 deletions packages/cli/src/argparse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1362,9 +1362,14 @@ export const CLI_ARGS = {
type: 'string',
realtype: '24_words_or_ciphertext',
},
{
name: 'derivation_path',
type: 'string',
realtype: 'custom_derivation_path_string',
},
],
minItems: 1,
maxItems: 1,
maxItems: 2,
help:
'Get the payment private key from a 24-word backup phrase used by the Stacks wallet. If you provide an ' +
'encrypted backup phrase, you will be asked for your password to decrypt it. This command ' +
Expand Down Expand Up @@ -1474,9 +1479,14 @@ export const CLI_ARGS = {
type: 'string',
realtype: '12_words_or_ciphertext',
},
{
name: 'derivation_path',
type: 'string',
realtype: 'custom_derivation_path_string',
},
],
minItems: 0,
maxItems: 1,
maxItems: 2,
help:
'Generate the owner and payment private keys, optionally from a given 12-word ' +
'backup phrase. If no backup phrase is given, a new one will be generated. If you provide ' +
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,9 @@ async function getPaymentKey(network: CLINetworkAdapter, args: string[]): Promis
*/
async function getStacksWalletKey(network: CLINetworkAdapter, args: string[]): Promise<string> {
const mnemonic = await getBackupPhrase(args[0]);
const derivationPath: string | undefined = args[1] || undefined;
// keep the return value consistent with getOwnerKeys
const keyObj = await getStacksWalletKeyInfo(network, mnemonic);
const keyObj = await getStacksWalletKeyInfo(network, mnemonic, derivationPath);
const keyInfo: StacksKeyInfoType[] = [];
keyInfo.push(keyObj);
return JSONStringify(keyInfo);
Expand All @@ -355,7 +356,8 @@ async function makeKeychain(network: CLINetworkAdapter, args: string[]): Promise
);
}

const stacksKeyInfo = await getStacksWalletKeyInfo(network, mnemonic);
const derivationPath: string | undefined = args[1] || undefined;
const stacksKeyInfo = await getStacksWalletKeyInfo(network, mnemonic, derivationPath);
return JSONStringify({
mnemonic: mnemonic,
keyInfo: stacksKeyInfo,
Expand Down Expand Up @@ -1965,5 +1967,7 @@ export const testables =
? {
addressConvert,
contractFunctionCall,
makeKeychain,
getStacksWalletKey,
}
: undefined;
5 changes: 3 additions & 2 deletions packages/cli/src/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,12 @@ export async function getPaymentKeyInfo(
*/
export async function getStacksWalletKeyInfo(
network: CLINetworkAdapter,
mnemonic: string
mnemonic: string,
derivationPath = DERIVATION_PATH
): Promise<StacksKeyInfoType> {
const seed = await bip39.mnemonicToSeed(mnemonic);
const master = bip32.fromSeed(seed);
const child = master.derivePath("m/44'/5757'/0'/0/0"); // taken from stacks-wallet. See https://github.com/blockstack/stacks-wallet
const child = master.derivePath(derivationPath); // taken from stacks-wallet. See https://github.com/blockstack/stacks-wallet
const ecPair = bitcoin.ECPair.fromPrivateKey(child.privateKey!);
const privkey = blockstack.ecPairToHexString(ecPair);
const wif = child.toWIF();
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,9 @@ export async function getBackupPhrase(
if (!process.stdin.isTTY && !password) {
// password must be given
reject(new Error('Password argument required in non-interactive mode'));
} else if (process.env.password) {
// Do not prompt password for unit tests
resolve(process.env.password);
} else {
// prompt password
getpass('Enter password: ', p => {
Expand Down
39 changes: 38 additions & 1 deletion packages/cli/tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import {ClarityAbi} from '@stacks/transactions';
import {readFileSync} from 'fs';
import path from 'path';
import fetchMock from 'jest-fetch-mock';
import { makekeychainTests, keyInfoTests, MakeKeychainResult, WalletKeyInfoResult } from './derivation-path/keychain';

const TEST_ABI: ClarityAbi = JSON.parse(readFileSync(path.join(__dirname, './abi/test-abi.json')).toString());
jest.mock('inquirer');

const { addressConvert, contractFunctionCall } = testables as any;
const { addressConvert, contractFunctionCall, makeKeychain, getStacksWalletKey } = testables as any;

const mainnetNetwork = new CLINetworkAdapter(
getNetwork({} as CLI_CONFIG_TYPE, false),
Expand Down Expand Up @@ -179,3 +180,39 @@ describe('Contract function call', () => {
expect(result.txid).toEqual(txid);
});
});

describe('Keychain custom derivation path', () => {
test.each(makekeychainTests)('Make keychain using custom derivation path %#', async (derivationPath: string, keyChainResult: MakeKeychainResult) => {
const encrypted = 'vim+XrRNSm+SqSn0MyWNEi/e+UK5kX8WGCLE/sevT6srZG+quzpp911sWP0CcvsExCH1M4DgOfOldMitLdkq1b6rApDwtAcOWdAqiaBk37M=';
const args = [encrypted, derivationPath];

// Mock TTY
process.stdin.isTTY = true;
process.env.password = 'supersecret';

const keyChain = await makeKeychain(testnetNetwork, args);
const result = JSON.parse(keyChain);
expect(result).toEqual(keyChainResult);
// Unmock TTY
process.stdin.isTTY = false;
process.env.password = undefined;
});

test.each(keyInfoTests)('Make keychain using custom derivation path %#', async (derivationPath: string, walletInfoResult: WalletKeyInfoResult ) => {
const encrypted = 'vim+XrRNSm+SqSn0MyWNEi/e+UK5kX8WGCLE/sevT6srZG+quzpp911sWP0CcvsExCH1M4DgOfOldMitLdkq1b6rApDwtAcOWdAqiaBk37M=';
const args = [encrypted, derivationPath];

// Mock TTY
process.stdin.isTTY = true;
process.env.password = 'supersecret';

const walletKey = await getStacksWalletKey(testnetNetwork, args);
const result = JSON.parse(walletKey);
expect(result).toEqual([
walletInfoResult
]);
// Unmock TTY
process.stdin.isTTY = false;
process.env.password = undefined;
});
});
80 changes: 80 additions & 0 deletions packages/cli/tests/derivation-path/keychain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
export type MakeKeychainResult = {
mnemonic: string,
keyInfo: {
privateKey: string;
address: string;
btcAddress: string;
wif: string;
index: number;
};
};

export type WalletKeyInfoResult = {
privateKey: string;
address: string;
btcAddress: string;
wif: string;
index: number;
};

export const makekeychainTests: Array<[string, MakeKeychainResult]> = [
[
// Derivation Path
"m/44'/5757'/0'/0/0",
// Expected result
{
mnemonic: 'vivid oxygen neutral wheat find thumb cigar wheel board kiwi portion business',
keyInfo: {
privateKey: 'd1124855494c883c5e1df0201be40a835f08ae5fc3a6520224b2239db94a818001',
address: 'ST1J28031BYDX19TYXSNDG9Q4HDB2TBDAM921Y7MS',
btcAddress: 'mpeSzfUTBba7qzKNcg8ojNm4GAfwmNPX8X',
wif: 'L4E7pXmqdm8C8TakpX7YDDmFopaQw32Ak6V5BpRFNDJmo7wjGVqc',
index: 0
}
}
],
[
// Derivation Path
"m/888'/0'/0",
// Expected result
{
mnemonic: 'vivid oxygen neutral wheat find thumb cigar wheel board kiwi portion business',
keyInfo: {
privateKey: 'd4d30d4fdaa59e166865b836548015c2780063b82e7b2a364c8a2e32df7139ce01',
address: 'ST1WT20920NVRQ892MS535R7XEMV6KD6M6X2HQPK3',
btcAddress: 'mrc4w3oQZ39Yvkimk9DDJQnHFjv1e336mg',
wif: 'L4MQx6c6ZmoiwFYUHnmt39THRGeQnPmfA2AFobwWmssZJabi3qXm',
index: 0
}
}
]
];

export const keyInfoTests: Array<[string, WalletKeyInfoResult]> = [
[
// Derivation Path
"m/44'/5757'/0'/0/0",
// Expected result
{
privateKey: 'd1124855494c883c5e1df0201be40a835f08ae5fc3a6520224b2239db94a818001',
address: 'ST1J28031BYDX19TYXSNDG9Q4HDB2TBDAM921Y7MS',
btcAddress: 'mpeSzfUTBba7qzKNcg8ojNm4GAfwmNPX8X',
wif: 'L4E7pXmqdm8C8TakpX7YDDmFopaQw32Ak6V5BpRFNDJmo7wjGVqc',
index: 0
}
],
[
// Derivation Path
"m/888'/0'/0",
// Expected result
{
privateKey: 'd4d30d4fdaa59e166865b836548015c2780063b82e7b2a364c8a2e32df7139ce01',
address: 'ST1WT20920NVRQ892MS535R7XEMV6KD6M6X2HQPK3',
btcAddress: 'mrc4w3oQZ39Yvkimk9DDJQnHFjv1e336mg',
wif: 'L4MQx6c6ZmoiwFYUHnmt39THRGeQnPmfA2AFobwWmssZJabi3qXm',
index: 0
}
]
];


32 changes: 32 additions & 0 deletions packages/cli/tests/derivation-path/wallet.key.info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { WalletKeyInfoResult } from './keychain';

export const keyInfoTests: Array<[string, WalletKeyInfoResult]> = [
[
// Derivation path
"m/44'/5757'/0'/0/0",
// Expected result
{
privateKey: '25899fab1b9b95cc2d1692529f00fb788e85664df3d14db1a660f33c5f96d8ab01',
address: 'SP3RBZ4TZ3EK22SZRKGFZYBCKD7WQ5B8FFS0AYVF7',
btcAddress: '1Nwxfx7VoYAg2mEN35dTRw4H7gte8ajFki',
wif: 'KxUgLbeVeFZEUUQpc3ncYn5KFB3WH5MVRv3SJ2g5yPwkrXs3QRaP',
index: 0
}
],
[
// Derivation path
"m/888'/0'/0",
// Expected result
{
privateKey: '0f0936f59a7d55be6bcd1820f798460ac4b3aa50f26c8fa76beb82a19af5110901',
address: 'SPGJAPK47Z9XY7E7BCEJFAEX9C7WGB0YB74A54MA',
btcAddress: '142G3fnfn1WZPtnYLYiVGt8aU55GZYxeVP',
wif: 'KwiwQgTK2412XSdBfcRWJ4xQFbevUHCwGnRCuvjeHjSqceNwS1wW',
index: 0
}
]
];

export { WalletKeyInfoResult };


10 changes: 10 additions & 0 deletions packages/cli/tests/keys.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getStacksWalletKeyInfo, getOwnerKeyInfo, findIdentityIndex } from '../src/keys';
import { getNetwork, CLINetworkAdapter, CLI_NETWORK_OPTS } from '../src/network';
import { CLI_CONFIG_TYPE } from '../src/argparse';
import { keyInfoTests, WalletKeyInfoResult } from './derivation-path/wallet.key.info';

import * as fixtures from './fixtures/keys.fixture';

Expand All @@ -23,6 +24,15 @@ test('getStacksWalletKeyInfo', async () => {
});
});

describe('getStacksWalletKeyInfo custom derivation path', () => {
test.each(keyInfoTests)('%#', async (derivationPath: string, keyInfoResult: WalletKeyInfoResult) => {
const mnemonic = 'apart spin rich leader siren foil dish sausage fee pipe ethics bundle';
const info = await getStacksWalletKeyInfo(mainnetNetwork, mnemonic, derivationPath);

expect(info).toEqual(keyInfoResult);
});
});

describe('getOwnerKeyInfo', () => {
test.each(fixtures.getOwnerKeyInfo)('%#', async (mnemonic, index, version, result) => {
const info = await getOwnerKeyInfo(mainnetNetwork, mnemonic, index, version);
Expand Down

1 comment on commit 9ba53be

@vercel
Copy link

@vercel vercel bot commented on 9ba53be Oct 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.