Skip to content

Commit 2e95f8b

Browse files
authored
[KLC-1871] Implement Node Wallet package (#4)
* feat: implement HD wallet and keystore functionality * add @scure/bip32 and @noble/curves dependencies * fix: lint * fix: update dependencies for security * test: add unit tests for HD wallet and keystore functionalities * feat: update to use Klever coin type and derivation path & add comments * fix: reduce scrypt N parameter in keystore integration test * feat: implement constant-time comparison for security * fix: update test for invalid mnemonic checksum validation * feat: add protected method to retrieve private key in NodeWallet * fix: update AES encryption/decryption functions to use 'any' type * fix: update derivation paths to include trailing apostrophes for consistency * refactor: rename Wallet to DefaultWallet for clarity and consistency * refactor: update getPrivateKey return type to Uint8Array * feat: update keystore to use AES-256-GCM for encryption and decryption * feat: implement SLIP-0010 Ed25519 key derivation and update mnemonicToPrivateKey function * refactor: reorder imports and update keystore version in tests * fix: tests derivation path and improve and error handling * update wallet type in KleverState from IWallet to Wallet * fix: update wallet type in KleverAction from IWallet to Wallet * refactor: remove DefaultWallet class and update wallet exports * refactor: update aes256GcmEncrypt and aes256GcmDecrypt to use BufferSource type * fix: add DOM library to tsconfig for type support
1 parent cab6157 commit 2e95f8b

File tree

16 files changed

+2057
-209
lines changed

16 files changed

+2057
-209
lines changed

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@
6262
},
6363
"pnpm": {
6464
"overrides": {
65-
"vite": "^7.1.12"
65+
"vite": "^7.1.12",
66+
"glob": ">=10.5.0",
67+
"js-yaml": ">=4.1.1"
6668
}
6769
}
6870
}

packages/connect-crypto/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@noble/ed25519": "^2.0.0",
4242
"@noble/hashes": "^1.3.3",
4343
"@scure/base": "^2.0.0",
44+
"@scure/bip32": "^1.3.0",
4445
"@scure/bip39": "^1.2.1"
4546
},
4647
"repository": {
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
buildDerivationPath,
4+
DEFAULT_DERIVATION_PATH,
5+
deriveMultipleKeys,
6+
generateMnemonicPhrase,
7+
isValidMnemonic,
8+
KLEVER_COIN_TYPE,
9+
mnemonicToPrivateKey,
10+
} from '../hd-wallet'
11+
12+
describe('hd-wallet', () => {
13+
describe('generateMnemonicPhrase', () => {
14+
it('should generate a 12-word mnemonic by default (128 bits)', () => {
15+
const mnemonic = generateMnemonicPhrase()
16+
const words = mnemonic.split(' ')
17+
18+
expect(words.length).toBe(12)
19+
expect(isValidMnemonic(mnemonic)).toBe(true)
20+
})
21+
22+
it('should generate a 15-word mnemonic (160 bits)', () => {
23+
const mnemonic = generateMnemonicPhrase({ strength: 160 })
24+
const words = mnemonic.split(' ')
25+
26+
expect(words.length).toBe(15)
27+
expect(isValidMnemonic(mnemonic)).toBe(true)
28+
})
29+
30+
it('should generate a 18-word mnemonic (192 bits)', () => {
31+
const mnemonic = generateMnemonicPhrase({ strength: 192 })
32+
const words = mnemonic.split(' ')
33+
34+
expect(words.length).toBe(18)
35+
expect(isValidMnemonic(mnemonic)).toBe(true)
36+
})
37+
38+
it('should generate a 21-word mnemonic (224 bits)', () => {
39+
const mnemonic = generateMnemonicPhrase({ strength: 224 })
40+
const words = mnemonic.split(' ')
41+
42+
expect(words.length).toBe(21)
43+
expect(isValidMnemonic(mnemonic)).toBe(true)
44+
})
45+
46+
it('should generate a 24-word mnemonic (256 bits)', () => {
47+
const mnemonic = generateMnemonicPhrase({ strength: 256 })
48+
const words = mnemonic.split(' ')
49+
50+
expect(words.length).toBe(24)
51+
expect(isValidMnemonic(mnemonic)).toBe(true)
52+
})
53+
54+
it('should generate different mnemonics each time', () => {
55+
const mnemonic1 = generateMnemonicPhrase()
56+
const mnemonic2 = generateMnemonicPhrase()
57+
58+
expect(mnemonic1).not.toBe(mnemonic2)
59+
})
60+
61+
it('should throw error for invalid strength', () => {
62+
expect(() => generateMnemonicPhrase({ strength: 100 as any })).toThrow(
63+
'Invalid strength. Must be 128, 160, 192, 224, or 256',
64+
)
65+
})
66+
})
67+
68+
describe('isValidMnemonic', () => {
69+
it('should return true for valid mnemonic', () => {
70+
const mnemonic = generateMnemonicPhrase()
71+
expect(isValidMnemonic(mnemonic)).toBe(true)
72+
})
73+
74+
it('should return false for invalid mnemonic', () => {
75+
expect(isValidMnemonic('invalid mnemonic phrase test')).toBe(false)
76+
})
77+
78+
it('should return false for empty string', () => {
79+
expect(isValidMnemonic('')).toBe(false)
80+
})
81+
82+
it('should return false for mnemonic with wrong checksum', () => {
83+
const validMnemonic =
84+
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
85+
const words = validMnemonic.split(' ')
86+
words[words.length - 1] = 'zoo'
87+
const invalidMnemonic = words.join(' ')
88+
89+
expect(isValidMnemonic(invalidMnemonic)).toBe(false)
90+
})
91+
})
92+
93+
describe('mnemonicToPrivateKey', () => {
94+
const testMnemonic =
95+
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
96+
97+
it('should derive private key from mnemonic with default path', () => {
98+
const privateKey = mnemonicToPrivateKey(testMnemonic)
99+
100+
expect(privateKey).toBeDefined()
101+
expect(privateKey.bytes.length).toBe(32)
102+
})
103+
104+
it('should derive private key with custom path', () => {
105+
const privateKey1 = mnemonicToPrivateKey(testMnemonic, {
106+
path: "m/44'/690'/0'/0'/0'",
107+
})
108+
const privateKey2 = mnemonicToPrivateKey(testMnemonic, {
109+
path: "m/44'/690'/0'/0'/1'",
110+
})
111+
112+
expect(privateKey1.toHex()).not.toBe(privateKey2.toHex())
113+
})
114+
115+
it('should derive private key with passphrase', () => {
116+
const privateKey1 = mnemonicToPrivateKey(testMnemonic)
117+
const privateKey2 = mnemonicToPrivateKey(testMnemonic, {
118+
passphrase: 'my-passphrase',
119+
})
120+
121+
expect(privateKey1.toHex()).not.toBe(privateKey2.toHex())
122+
})
123+
124+
it('should be deterministic for same mnemonic and path', () => {
125+
const privateKey1 = mnemonicToPrivateKey(testMnemonic)
126+
const privateKey2 = mnemonicToPrivateKey(testMnemonic)
127+
128+
expect(privateKey1.toHex()).toBe(privateKey2.toHex())
129+
})
130+
131+
it('should throw error for invalid mnemonic', () => {
132+
expect(() => mnemonicToPrivateKey('invalid mnemonic')).toThrow('Invalid mnemonic phrase')
133+
})
134+
})
135+
136+
describe('deriveMultipleKeys', () => {
137+
const testMnemonic =
138+
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
139+
140+
it('should derive multiple keys', () => {
141+
const keys = deriveMultipleKeys(testMnemonic, 5)
142+
143+
expect(keys.length).toBe(5)
144+
expect(new Set(keys.map((k) => k.toHex())).size).toBe(5) // All unique
145+
})
146+
147+
it('should derive keys sequentially from index', () => {
148+
const keys = deriveMultipleKeys(testMnemonic, 3, {
149+
path: "m/44'/690'/0'/0'/0'",
150+
})
151+
152+
const key0 = mnemonicToPrivateKey(testMnemonic, { path: "m/44'/690'/0'/0'/0'" })
153+
const key1 = mnemonicToPrivateKey(testMnemonic, { path: "m/44'/690'/0'/0'/1'" })
154+
const key2 = mnemonicToPrivateKey(testMnemonic, { path: "m/44'/690'/0'/0'/2'" })
155+
156+
expect(keys[0].toHex()).toBe(key0.toHex())
157+
expect(keys[1].toHex()).toBe(key1.toHex())
158+
expect(keys[2].toHex()).toBe(key2.toHex())
159+
})
160+
161+
it('should derive keys starting from custom index', () => {
162+
const keys = deriveMultipleKeys(testMnemonic, 2, {
163+
path: "m/44'/690'/0'/0'/5'",
164+
})
165+
166+
const key5 = mnemonicToPrivateKey(testMnemonic, { path: "m/44'/690'/0'/0'/5'" })
167+
const key6 = mnemonicToPrivateKey(testMnemonic, { path: "m/44'/690'/0'/0'/6'" })
168+
169+
expect(keys[0].toHex()).toBe(key5.toHex())
170+
expect(keys[1].toHex()).toBe(key6.toHex())
171+
})
172+
173+
it('should derive keys with passphrase', () => {
174+
const keys1 = deriveMultipleKeys(testMnemonic, 2)
175+
const keys2 = deriveMultipleKeys(testMnemonic, 2, { passphrase: 'test' })
176+
177+
expect(keys1[0].toHex()).not.toBe(keys2[0].toHex())
178+
expect(keys1[1].toHex()).not.toBe(keys2[1].toHex())
179+
})
180+
181+
it('should throw error for count less than 1', () => {
182+
expect(() => deriveMultipleKeys(testMnemonic, 0)).toThrow('Count must be at least 1')
183+
expect(() => deriveMultipleKeys(testMnemonic, -1)).toThrow('Count must be at least 1')
184+
})
185+
})
186+
187+
describe('buildDerivationPath', () => {
188+
it('should build default path (0/0/0)', () => {
189+
const path = buildDerivationPath()
190+
191+
expect(path).toBe(`m/44'/${KLEVER_COIN_TYPE}'/0'/0'/0'`)
192+
})
193+
194+
it('should build path with custom account', () => {
195+
const path = buildDerivationPath(5)
196+
197+
expect(path).toBe(`m/44'/${KLEVER_COIN_TYPE}'/5'/0'/0'`)
198+
})
199+
200+
it('should build path with custom account and change', () => {
201+
const path = buildDerivationPath(2, 1)
202+
203+
expect(path).toBe(`m/44'/${KLEVER_COIN_TYPE}'/2'/1'/0'`)
204+
})
205+
206+
it('should build path with custom account, change, and index', () => {
207+
const path = buildDerivationPath(3, 0, 10)
208+
209+
expect(path).toBe(`m/44'/${KLEVER_COIN_TYPE}'/3'/0'/10'`)
210+
})
211+
212+
it('should use KLEVER_COIN_TYPE constant', () => {
213+
const path = buildDerivationPath(0, 0, 0)
214+
215+
expect(path).toContain(`44'/${KLEVER_COIN_TYPE}'`)
216+
})
217+
218+
it('should throw error for negative account', () => {
219+
expect(() => buildDerivationPath(-1)).toThrow('Account must be a non-negative integer')
220+
})
221+
222+
it('should throw error for non-integer account', () => {
223+
expect(() => buildDerivationPath(1.5)).toThrow('Account must be a non-negative integer')
224+
})
225+
226+
it('should throw error for invalid change value', () => {
227+
expect(() => buildDerivationPath(0, -1)).toThrow(
228+
'Change must be 0 (external) or 1 (internal)',
229+
)
230+
expect(() => buildDerivationPath(0, 2)).toThrow('Change must be 0 (external) or 1 (internal)')
231+
expect(() => buildDerivationPath(0, 0.5)).toThrow(
232+
'Change must be 0 (external) or 1 (internal)',
233+
)
234+
})
235+
236+
it('should throw error for negative index', () => {
237+
expect(() => buildDerivationPath(0, 0, -1)).toThrow('Index must be a non-negative integer')
238+
})
239+
240+
it('should throw error for non-integer index', () => {
241+
expect(() => buildDerivationPath(0, 0, 1.5)).toThrow('Index must be a non-negative integer')
242+
})
243+
})
244+
245+
describe('DEFAULT_DERIVATION_PATH', () => {
246+
it('should be a valid BIP44 path', () => {
247+
expect(DEFAULT_DERIVATION_PATH).toBe("m/44'/690'/0'/0'/0'")
248+
})
249+
})
250+
251+
describe('KLEVER_COIN_TYPE', () => {
252+
it('should be 690', () => {
253+
expect(KLEVER_COIN_TYPE).toBe(690)
254+
})
255+
})
256+
})

0 commit comments

Comments
 (0)