Skip to content

Commit

Permalink
Implement btc signer
Browse files Browse the repository at this point in the history
  • Loading branch information
baryon2 committed Nov 5, 2024
1 parent 0d3ad1e commit 4bf92ce
Show file tree
Hide file tree
Showing 12 changed files with 280 additions and 30 deletions.
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"name": "@leapwallet/leap-keychain",
"version": "0.2.5-beta.9",
"version": "0.2.5-beta.10",
"description": "A javascript library for crypto key management",
"scripts": {
"test": "nyc mocha",
"lint:prettier": "prettier .",
"test:coverage": "nyc mocha",
"test": "mocha",
"lint:prettier:check": "yarn lint:prettier -c",
"lint:prettier:fix": "yarn lint:prettier -w",
"lint:eslint": "eslint .",
Expand Down Expand Up @@ -48,6 +48,7 @@
"@noble/secp256k1": "1.7.0",
"@scure/base": "1.1.6",
"@scure/bip32": "1.1.1",
"@scure/btc-signer": "^1.4.0",
"base64-js": "1.5.1",
"bech32": "2.0.0",
"bip32": "2.0.6",
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@ export * from './crypto/hashes/hashes';
export * from './utils/init-crypto';
export * from './key/wallet-utils';
export * from './utils/get-hdpath';

export { NETWORK, TEST_NETWORK } from '@scure/btc-signer';
68 changes: 68 additions & 0 deletions src/key/btc-wallet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { getBip32, IChildKey, IHDKey } from '../crypto/bip32/hdwallet-token';
import { getBip39 } from '../crypto/bip39/bip39-token';
import { WalletOptions } from '../types/wallet';
import { p2wpkh, Transaction, NETWORK } from '@scure/btc-signer';
import { hex } from '@scure/base';
import { HDKey } from '@scure/bip32';
export type BTCWalletOptions = WalletOptions & { network: typeof NETWORK };

export class BtcWallet {
constructor(private hdKey: IHDKey, private options: BTCWalletOptions) {}

static generateWalletFromPrivKey(privateKey: string, options: BTCWalletOptions) {
const hdKey = HDKey.fromJSON({ xpriv: privateKey });
return new BtcWallet(hdKey, options);
}

static generateWalletFromMnemonic(mnemonic: string, options: BTCWalletOptions) {
const bip39 = getBip39();
const bip32 = getBip32();
bip39.mnemonicToEntropy(mnemonic);
const seed = bip39.mnemonicToSeedSync(mnemonic);
const hdKey = bip32.fromSeed(seed);
return new BtcWallet(hdKey, options);
}

getAccountsWithPrivKey(): Array<{ algo: string; address: string; pubkey: Uint8Array; childKey: IChildKey }> {
const accountsWithPubKey = [];
for (let path of this.options.paths) {
const childKey = this.hdKey.derive(path);

if (!childKey.publicKey) throw new Error(`Could not generate public key for path ${path}`);
const addrData = p2wpkh(childKey.publicKey, this.options.network);
if (!addrData.address) throw new Error(`Could not generate address for path ${path}`);
accountsWithPubKey.push({
algo: 'secp256k1',
address: addrData.address,
p2ret: addrData,
pubkey: childKey.publicKey,
childKey: childKey,
});
}

return accountsWithPubKey;
}

getAccounts() {
const accounts = this.getAccountsWithPrivKey();
return accounts.map((account) => {
return {
algo: 'secp256k1',
address: account.address,
pubkey: account.pubkey,
};
});
}

signPsbt(address: string, psbt: string) {
const psbtBytes = hex.decode(psbt);
const tx = Transaction.fromPSBT(psbtBytes);
const accounts = this.getAccountsWithPrivKey();
const account = accounts.find((account) => account.address === address);
if (!account) throw new Error(`No account found for ${address}`);
if (!account.childKey.privateKey) throw new Error('Private key not found');

const signedTx = tx.sign(account.childKey.privateKey);
return signedTx;
}
}
38 changes: 34 additions & 4 deletions src/key/wallet-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { EthWallet } from './eth-wallet';
import { Wallet } from './wallet';
import * as base64js from 'base64-js';
import { bip39Token } from '../crypto/bip39/bip39-token';
import { BtcWallet } from './btc-wallet';
import { NETWORK } from '@scure/btc-signer';

/***
* Generate a wallet from a mnemonic
Expand All @@ -15,31 +17,50 @@ import { bip39Token } from '../crypto/bip39/bip39-token';
* pubKeyBech32Address: boolean - if true, it generates a bech32 address from public key instead of ethereum address.
* }
*/

export function generateWalletFromMnemonic(
mnemonic: string,
{
hdPath,
addressPrefix,
ethWallet,
pubKeyBech32Address,
btcNetwork,
}: {
hdPath: string;
addressPrefix: string;
ethWallet: boolean;
pubKeyBech32Address?: boolean;
btcNetwork?: typeof NETWORK;
},
) {
const bip39 = Container.get(bip39Token);
bip39.mnemonicToEntropy(mnemonic);
const hdPathParams = hdPath.split('/');
const coinType = hdPathParams[2];
if (coinType?.replace("'", '') === '60' || ethWallet) {
// force eth wallet generation
if (ethWallet) {
return EthWallet.generateWalletFromMnemonic(mnemonic, { paths: [hdPath], addressPrefix, pubKeyBech32Address });
}
return Wallet.generateWallet(mnemonic, { paths: [hdPath], addressPrefix });

switch (coinType) {
case "60'":
return EthWallet.generateWalletFromMnemonic(mnemonic, { paths: [hdPath], addressPrefix, pubKeyBech32Address });
case "0'": {
if (!btcNetwork) throw new Error('Cannot create btc wallet. Please provide network');
return BtcWallet.generateWalletFromMnemonic(mnemonic, { paths: [hdPath], addressPrefix, network: btcNetwork });
}
default:
return Wallet.generateWallet(mnemonic, { paths: [hdPath], addressPrefix });
}
}

export function generateWalletsFromMnemonic(mnemonic: string, paths: string[], prefix: string): Wallet | EthWallet {
export function generateWalletsFromMnemonic(
mnemonic: string,
paths: string[],
prefix: string,
btcNetwork?: typeof NETWORK,
): Wallet | EthWallet | BtcWallet {
const bip39 = Container.get(bip39Token);
bip39.mnemonicToEntropy(mnemonic);
const coinTypes = paths.map((hdPath) => {
Expand All @@ -56,7 +77,16 @@ export function generateWalletsFromMnemonic(mnemonic: string, paths: string[], p
return EthWallet.generateWalletFromMnemonic(mnemonic, { paths, addressPrefix: prefix });
}

return Wallet.generateWallet(mnemonic, { paths, addressPrefix: prefix });
switch (refCoinType) {
case '60':
return EthWallet.generateWalletFromMnemonic(mnemonic, { paths, addressPrefix: prefix });
case '0': {
if (!btcNetwork) throw new Error('Cannot create btc wallet. Please provide network');
return BtcWallet.generateWalletFromMnemonic(mnemonic, { paths, addressPrefix: prefix, network: btcNetwork });
}
default:
return Wallet.generateWallet(mnemonic, { paths, addressPrefix: prefix });
}
}

export function compressedPublicKey(publicKey: Uint8Array) {
Expand Down
55 changes: 36 additions & 19 deletions src/keychain/keychain.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EthWallet } from '../key/eth-wallet';
import getHDPath from '../utils/get-hdpath';
import getHDPath, { getFullHDPath } from '../utils/get-hdpath';
import { PvtKeyWallet } from '../key/wallet';

import { Container } from 'typedi';
Expand All @@ -11,6 +11,8 @@ import { ChainInfo, CreateWalletParams, Key, Keystore, WALLETTYPE } from '../typ
import { compressedPublicKey, generateWalletFromMnemonic, generateWalletsFromMnemonic } from '../key/wallet-utils';
import { convertAddress } from '../utils/bech32-address-converter';
import { Input } from '@noble/ciphers/utils';
import { BtcWallet } from '../key/btc-wallet';
import { NETWORK } from '@scure/btc-signer';

export const KEYCHAIN = 'keystore';
export const ENCRYPTED_KEYCHAIN = 'encrypted-keystore';
Expand Down Expand Up @@ -110,17 +112,26 @@ export class KeyChain {
const addresses: Record<string, string> = {};
const pubKeys: Record<string, string> = {};
for (const chainInfo of chainInfos) {
const wallet =
chainInfo.coinType === '60'
? EthWallet.generateWalletFromPvtKey(privateKey, {
paths: [getHDPath('60', '0')],
addressPrefix: chainInfo.addressPrefix,
})
: await PvtKeyWallet.generateWallet(privateKey, chainInfo.addressPrefix);
const [account] = await wallet.getAccounts();
if (account) {
let wallet;
if (chainInfo.coinType === '60') {
wallet = EthWallet.generateWalletFromPvtKey(privateKey, {
paths: [getHDPath('60', '0')],
addressPrefix: chainInfo.addressPrefix,
});
} else if (chainInfo.coinType === '1') {
if (!chainInfo.btcNetwork)
throw new Error('Unable to generate key. Please provide btc network in chain info config');
wallet = BtcWallet.generateWalletFromPrivKey(privateKey, {
paths: [getFullHDPath('84', '1', '0')],
addressPrefix: chainInfo.addressPrefix,
network: chainInfo.btcNetwork,
});
} else {
wallet = PvtKeyWallet.generateWallet(privateKey, chainInfo.addressPrefix);
}
const [account] = wallet.getAccounts();
if (account && account.address && account.pubkey) {
addresses[chainInfo.key] = account.address;

pubKeys[chainInfo.key] = compressedPublicKey(account.pubkey);
}
}
Expand Down Expand Up @@ -213,11 +224,13 @@ export class KeyChain {
coinType,
ethWallet,
pubKeyBech32Address,
btcNetwork,
}: {
addressPrefix: string;
coinType: string;
ethWallet?: boolean;
pubKeyBech32Address?: boolean;
btcNetwork?: typeof NETWORK;
},
) {
const storage = Container.get(storageToken);
Expand All @@ -240,7 +253,13 @@ export class KeyChain {
return PvtKeyWallet.generateWallet(secret, addressPrefix);
} else {
const hdPath = getHDPath(coinType, walletData.addressIndex.toString());
return generateWalletFromMnemonic(secret, { hdPath, addressPrefix, ethWallet: !!ethWallet, pubKeyBech32Address });
return generateWalletFromMnemonic(secret, {
hdPath,
addressPrefix,
ethWallet: !!ethWallet,
pubKeyBech32Address,
btcNetwork,
});
}
}

Expand Down Expand Up @@ -286,11 +305,7 @@ export class KeyChain {
}
}

private static async getAddresses(
mnemonic: string,
addressIndex: number,
chainInfos: { coinType: string; addressPrefix: string; key: string }[],
) {
private static async getAddresses(mnemonic: string, addressIndex: number, chainInfos: Array<ChainInfo>) {
try {
const chainsData = chainInfos;
const addresses: Record<string, string> = {};
Expand All @@ -299,15 +314,17 @@ export class KeyChain {

for (const chainInfo of chainsData) {
const coinTypeKey = coinTypeKeys[chainInfo.coinType];
if (coinTypeKey) {
if (coinTypeKey && !chainInfo.useBip84) {
addresses[chainInfo.key] = convertAddress(coinTypeKey.address, chainInfo.addressPrefix);
pubKeys[chainInfo.key] = coinTypeKey.pubkey;
continue;
}
const purpose = chainInfo.useBip84 ? '84' : '44';
const wallet = generateWalletFromMnemonic(mnemonic, {
hdPath: getHDPath(chainInfo.coinType, addressIndex.toString()),
hdPath: getFullHDPath(purpose, chainInfo.coinType, addressIndex.toString()),
addressPrefix: chainInfo.addressPrefix,
ethWallet: false,
btcNetwork: chainInfo.btcNetwork,
});

const [account] = wallet.getAccounts();
Expand Down
12 changes: 12 additions & 0 deletions src/types/keychain.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { NETWORK } from '@scure/btc-signer';

export enum WALLETTYPE {
SEED_PHRASE,
PRIVATE_KEY,
Expand Down Expand Up @@ -32,4 +34,14 @@ export type ChainInfo = {
key: string;
addressPrefix: string;
coinType: string;
useBip84?: boolean;
btcNetwork?: typeof NETWORK;
};

export type GetSignerParams = {
addressPrefix: string;
coinType: string;
ethWallet?: boolean;
pubKeyBech32Address?: boolean;
btcNetwork?: typeof NETWORK;
};
4 changes: 4 additions & 0 deletions src/utils/get-hdpath.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export default function getHDPath(coinType = '118', index = '0', account = '0', chain = '0') {
return `m/44'/${coinType}'/${account}'/${chain}/${index}`;
}

export function getFullHDPath(purpose = '44', coinType = '0', index = '0', account = '0', chain = '0') {
return `m/${purpose}'/${coinType}'/${account}'/${chain}/${index}`;
}
48 changes: 48 additions & 0 deletions test/btc-wallet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { ripemd160 } from '@noble/hashes/ripemd160';
import { sha256 } from '@noble/hashes/sha256';
import Container from 'typedi';
import { Bip32 } from '../src/crypto/bip32/hd-wallet';
import { bip32Token } from '../src/crypto/bip32/hdwallet-token';
import { Bip39 } from '../src/crypto/bip39/bip39';
import { setBip39 } from '../src/crypto/bip39/bip39-token';
import { ripemd160Token, sha256Token } from '../src/crypto/hashes/hashes';
import { BtcWallet } from '../src/key/btc-wallet';
import expect from 'expect.js';
import { mnemonic } from './mockdata';
import { NETWORK, TEST_NETWORK } from '@scure/btc-signer';

beforeEach(() => {
setBip39(Bip39);
Container.set(bip32Token, Bip32);
Container.set(sha256Token, sha256);
Container.set(ripemd160Token, ripemd160);
});

describe('generate btc wallet', () => {
it('generates correct btc wallet', () => {
const wallet = BtcWallet.generateWalletFromMnemonic(mnemonic, {
addressPrefix: 'bc1q',
paths: ["m/84'/0'/0'/0/0"],
network: NETWORK,
});
const accounts = wallet.getAccountsWithPrivKey();

const expectedAccount = 'bc1qd5xpfp9zp8q696pu3sz7ej2wrk2wn634dlnhfa';
if (accounts[0]) {
expect(accounts[0].address).to.be(expectedAccount);
}
});
it('generates correct signet wallet', () => {
const wallet = BtcWallet.generateWalletFromMnemonic(mnemonic, {
addressPrefix: 'bc1q',
paths: ["m/84'/0'/0'/0/0"],
network: TEST_NETWORK,
});
const accounts = wallet.getAccountsWithPrivKey();

const expectedAccount = 'tb1qd5xpfp9zp8q696pu3sz7ej2wrk2wn6348egyjw';
if (accounts[0]) {
expect(accounts[0].address).to.be(expectedAccount);
}
});
});
1 change: 0 additions & 1 deletion test/keychain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ describe('keychain', () => {
});

const key = storageObj['keystore'][_key.id];

expect(key.addresses).to.eql(ref1.addresses);
expect(key.pubKeys).to.eql(ref1.pubKeys);
expect(key.name).to.eql(ref1.name);
Expand Down
Loading

0 comments on commit 4bf92ce

Please sign in to comment.