From 177e2ee0433adf910bdc7165f745d409d102f8dd Mon Sep 17 00:00:00 2001 From: Apisit Ritreungroj <38898766+apskhem@users.noreply.github.com> Date: Mon, 23 Sep 2024 16:30:57 +0700 Subject: [PATCH] feat(cat-voices): Add CIP-39 seed phrase utility (#852) * feat: bip39 * fix: constructor * refactor: single class * fix: docs * test: initial test * fix: typo * refactor: seed phrase class * test: seed phrase words * feat: full words * fix: cspell * refactor: move to models * refactor: rename test folder * test: add exceeding cases * refactor: test array * fix: linter for a file --- .../lib/src/seed_phrase.dart | 70 ++++++++++++++++++ .../catalyst_voices_models/pubspec.yaml | 2 + .../test/seed_phrase_test.dart | 74 +++++++++++++++++++ melos.yaml | 1 + 4 files changed, 147 insertions(+) create mode 100644 catalyst_voices/packages/catalyst_voices_models/lib/src/seed_phrase.dart create mode 100644 catalyst_voices/packages/catalyst_voices_models/test/seed_phrase_test.dart diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/seed_phrase.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/seed_phrase.dart new file mode 100644 index 0000000000..1b49fe942a --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/seed_phrase.dart @@ -0,0 +1,70 @@ +// cspell: words wordlists WORDLIST +// ignore_for_file: implementation_imports + +import 'dart:typed_data'; +import 'package:bip39/bip39.dart' as bip39; +import 'package:bip39/src/wordlists/english.dart'; +import 'package:convert/convert.dart'; + +/// Represents a seed phrase consisting of a mnemonic and provides methods for +/// generating and deriving cryptographic data from the mnemonic. +/// +/// The `SeedPhrase` class allows creation of a seed phrase either randomly, +/// from a given mnemonic, or from entropy data. It supports converting between +/// different formats, including Uint8List and hex strings. +class SeedPhrase { + /// The mnemonic phrase + final String mnemonic; + + /// Generates a new seed phrase with a random mnemonic. + /// + /// Throws an [ArgumentError] if the word count is invalid. + /// + /// [wordCount]: The number of words in the mnemonic. + /// The default word count is 12, but can specify 12, 15, 18, 21, or 24 words. + /// with a higher word count providing greater entropy and security. + SeedPhrase({int wordCount = 12}) + : this.fromMnemonic( + bip39.generateMnemonic( + strength: (wordCount * 32) ~/ 3, + ), + ); + + /// Creates a SeedPhrase from an existing [Uint8List] entropy. + /// + /// [encodedData]: The entropy data as a Uint8List. + SeedPhrase.fromUint8ListEntropy(Uint8List encodedData) + : this.fromHexEntropy(hex.encode(encodedData)); + + /// Creates a SeedPhrase from an existing hex-encoded entropy. + /// + /// [encodedData]: The entropy data as a hex string. + SeedPhrase.fromHexEntropy(String encodedData) + : this.fromMnemonic(bip39.entropyToMnemonic(encodedData)); + + /// Creates a SeedPhrase from an existing [mnemonic]. + /// + /// Throws an [ArgumentError] if the mnemonic is invalid. + /// + /// [mnemonic]: The mnemonic to derive the seed from. + SeedPhrase.fromMnemonic(this.mnemonic) + : assert(bip39.validateMnemonic(mnemonic), 'Invalid mnemonic phrase'); + + /// The seed derived from the mnemonic as a Uint8List. + Uint8List get uint8ListSeed => bip39.mnemonicToSeed(mnemonic); + + /// The seed derived from the mnemonic as a hex-encoded string. + String get hexSeed => bip39.mnemonicToSeedHex(mnemonic); + + /// The entropy derived from the mnemonic as a Uint8List. + Uint8List get uint8ListEntropy => Uint8List.fromList(hex.decode(hexEntropy)); + + /// The entropy derived from the mnemonic as a hex-encoded string. + String get hexEntropy => bip39.mnemonicToEntropy(mnemonic); + + /// The mnemonic phrase as a list of individual words. + List get mnemonicWords => mnemonic.split(' '); + + /// The full list of BIP-39 mnemonic words in English. + static List get wordList => WORDLIST; +} diff --git a/catalyst_voices/packages/catalyst_voices_models/pubspec.yaml b/catalyst_voices/packages/catalyst_voices_models/pubspec.yaml index a25c85724b..0a336b0d57 100644 --- a/catalyst_voices/packages/catalyst_voices_models/pubspec.yaml +++ b/catalyst_voices/packages/catalyst_voices_models/pubspec.yaml @@ -7,7 +7,9 @@ environment: sdk: ">=3.5.0 <4.0.0" dependencies: + bip39: ^1.0.6 catalyst_cardano_serialization: ^0.4.0 + convert: ^3.1.1 equatable: ^2.0.5 flutter_quill: ^10.5.13 meta: ^1.10.0 diff --git a/catalyst_voices/packages/catalyst_voices_models/test/seed_phrase_test.dart b/catalyst_voices/packages/catalyst_voices_models/test/seed_phrase_test.dart new file mode 100644 index 0000000000..de075d6356 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_models/test/seed_phrase_test.dart @@ -0,0 +1,74 @@ +import 'package:bip39/bip39.dart' as bip39; +import 'package:catalyst_voices_models/src/seed_phrase.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(SeedPhrase, () { + test('should generate a new SeedPhrase with random mnemonic', () { + final seedPhrase = SeedPhrase(); + expect(seedPhrase.mnemonic, isNotEmpty); + expect(seedPhrase.uint8ListSeed, isNotEmpty); + expect(seedPhrase.hexSeed, isNotEmpty); + expect(seedPhrase.mnemonicWords.length, 12); + }); + + test('should generate a seed phrase with 12, 15, 18, 21, and 24 words', () { + for (final wordCount in [12, 15, 18, 21, 24]) { + final seedPhrase = SeedPhrase(wordCount: wordCount); + expect(seedPhrase.mnemonicWords.length, wordCount); + expect(bip39.validateMnemonic(seedPhrase.mnemonic), isTrue); + } + }); + + test('should throw an error for an invalid word count', () { + expect(() => SeedPhrase(wordCount: 9), throwsA(isA())); + expect(() => SeedPhrase(wordCount: 13), throwsA(isA())); + expect(() => SeedPhrase(wordCount: 27), throwsA(isA())); + }); + + test('should create SeedPhrase from a valid mnemonic', () { + final validMnemonic = bip39.generateMnemonic(); + final seedPhrase = SeedPhrase.fromMnemonic(validMnemonic); + expect(seedPhrase.mnemonic, validMnemonic); + expect(seedPhrase.hexSeed, bip39.mnemonicToSeedHex(validMnemonic)); + }); + + test('should create SeedPhrase from hex-encoded entropy', () { + final entropy = bip39.mnemonicToEntropy(bip39.generateMnemonic()); + final seedPhrase = SeedPhrase.fromHexEntropy(entropy); + + expect(seedPhrase.mnemonic, isNotEmpty); + expect(seedPhrase.hexEntropy, entropy); + }); + + test('should throw an error for invalid mnemonic', () { + const invalidMnemonic = 'invalid mnemonic phrase'; + expect( + () => SeedPhrase.fromMnemonic(invalidMnemonic), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Invalid mnemonic phrase'), + ), + ), + ); + }); + + test('should contain consistent mnemonic and seed in generated SeedPhrase', + () { + final seedPhrase = SeedPhrase(); + final mnemonic = seedPhrase.mnemonic; + final seed = seedPhrase.hexSeed; + + expect(bip39.mnemonicToSeedHex(mnemonic), seed); + }); + + test('should split mnemonic into a list of words', () { + final mnemonic = bip39.generateMnemonic(); + final seedPhrase = SeedPhrase.fromMnemonic(mnemonic); + final expectedWords = mnemonic.split(' '); + expect(seedPhrase.mnemonicWords, expectedWords); + }); + }); +} diff --git a/melos.yaml b/melos.yaml index 2e38ced9f7..345b88fae7 100644 --- a/melos.yaml +++ b/melos.yaml @@ -19,6 +19,7 @@ command: flutter: ">=3.24.1" dependencies: asn1lib: ^1.5.3 + bip39: ^1.0.6 bloc_concurrency: ^0.2.2 collection: ^1.18.0 cryptography: ^2.7.0