import 'dart:convert'; import 'dart:typed_data'; import 'package:bip340/bip340.dart' as bip340; import 'package:swarm/src/bip39.dart'; import 'package:swarm/src/bip39_wordlist.dart'; import 'package:swarm/swarm.dart'; import 'package:test/test.dart'; import 'fake_relay_connection.dart'; // NIP-06 test vectors from https://github.com/nostr-protocol/nips/blob/master/06.md const _kMnemonic1 = 'leader monkey parrot ring guide accident before fence cannon height naive bean'; const _kPrivkey1 = '7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a'; const _kPubkey1 = '17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917'; // Vector 2 is a 24-word mnemonic — mnemonicToSeed/deriveNostrPrivkey work for // any BIP-39 word count; validateMnemonic is 12-word only (matches generateMnemonic). const _kMnemonic2 = 'what bleak badge arrange retreat wolf trade produce cricket blur garlic valid ' 'proud rude strong choose busy staff weather area salt hollow arm fade'; const _kPrivkey2 = 'c15d739894c81a2fcfd3a2df85a0d2c0dbc47a280d092799f144d73d7ae78add'; const _kPubkey2 = 'd41b22899549e1f3d335a31002cfd382174006e166d3e658e3a5eecdb6463573'; void main() { // ----------------------------------------------------------------------- group('BIP-39 wordlist', () { test('contains 2048 words', () => expect(kBip39Words, hasLength(2048))); test('first word is abandon', () => expect(kBip39Words.first, 'abandon')); test('last word is zoo', () => expect(kBip39Words.last, 'zoo')); }); // ----------------------------------------------------------------------- group('BIP-39', () { test('generateMnemonic returns 12 words', () { expect(generateMnemonic().split(' '), hasLength(12)); }); test('generateMnemonic passes validateMnemonic', () { expect(validateMnemonic(generateMnemonic()), isTrue); }); test('validateMnemonic accepts known-valid mnemonic', () { expect(validateMnemonic(_kMnemonic1), isTrue); }); test('validateMnemonic rejects wrong word count', () { expect(validateMnemonic('abandon'), isFalse); }); test('validateMnemonic rejects unknown word', () { expect(validateMnemonic('notaword ' * 12), isFalse); }); test('validateMnemonic rejects bad checksum', () { // Replace first word to corrupt the checksum. final words = _kMnemonic1.split(' ') ..[0] = 'legal'; expect(validateMnemonic(words.join(' ')), isFalse); }); }); // ----------------------------------------------------------------------- group('NIP-06 key derivation', () { test('vector 1: private key', () { final seed = mnemonicToSeed(_kMnemonic1); expect(_hex(deriveNostrPrivkey(seed)), equals(_kPrivkey1)); }); test('vector 1: public key derived from private key', () { final seed = mnemonicToSeed(_kMnemonic1); expect(bip340.getPublicKey(_hex(deriveNostrPrivkey(seed))), equals(_kPubkey1)); }); test('vector 2: private key', () { final seed = mnemonicToSeed(_kMnemonic2); expect(_hex(deriveNostrPrivkey(seed)), equals(_kPrivkey2)); }); test('vector 2: public key derived from private key', () { final seed = mnemonicToSeed(_kMnemonic2); expect(bip340.getPublicKey(_hex(deriveNostrPrivkey(seed))), equals(_kPubkey2)); }); }); // ----------------------------------------------------------------------- group('NostrAccountActor — identity lifecycle', () { test('GetPublicKey before import throws StateError', () async { await expectLater( NostrAccountActor().handle(const GetAccountPublicKey()), throwsStateError, ); }); test('CreateAccount returns AccountCreated with 12-word mnemonic', () async { final result = await NostrAccountActor().handle(const CreateAccount()); expect(result, isA()); final created = result as AccountCreated; expect(created.mnemonic.split(' '), hasLength(12)); expect(validateMnemonic(created.mnemonic), isTrue); }); test('CreateAccount pubkey matches re-derived key', () async { final created = await NostrAccountActor().handle(const CreateAccount()) as AccountCreated; final priv = deriveNostrPrivkey(mnemonicToSeed(created.mnemonic)); expect(created.publicKeyHex, equals(bip340.getPublicKey(_hex(priv)))); }); test('ImportAccount returns correct pubkey', () async { final result = await NostrAccountActor().handle(ImportAccount(_kPrivkey1)); expect(result, isA()); expect((result as AccountImported).publicKeyHex, equals(_kPubkey1)); }); test('ImportMnemonic returns correct pubkey', () async { final result = await NostrAccountActor().handle(ImportMnemonic(_kMnemonic1)); expect(result, isA()); expect((result as AccountImported).publicKeyHex, equals(_kPubkey1)); }); test('ImportMnemonic with invalid mnemonic throws', () async { await expectLater( NostrAccountActor() .handle(const ImportMnemonic('bad mnemonic not valid here')), throwsException, ); }); test('ExportPrivateKey returns stored key', () async { final actor = NostrAccountActor(); await actor.handle(ImportAccount(_kPrivkey1)); final result = await actor.handle(const ExportPrivateKey()) as PrivateKeyExported; expect(result.privateKeyHex, equals(_kPrivkey1)); }); test('ExportMnemonic is null after ImportAccount', () async { final actor = NostrAccountActor(); await actor.handle(ImportAccount(_kPrivkey1)); final result = await actor.handle(const ExportMnemonic()) as MnemonicExported; expect(result.mnemonic, isNull); }); test('ExportMnemonic returns mnemonic after ImportMnemonic', () async { final actor = NostrAccountActor(); await actor.handle(ImportMnemonic(_kMnemonic1)); final result = await actor.handle(const ExportMnemonic()) as MnemonicExported; expect(result.mnemonic, equals(_kMnemonic1)); }); test('ExportMnemonic returns mnemonic after CreateAccount', () async { final actor = NostrAccountActor(); final created = await actor.handle(const CreateAccount()) as AccountCreated; final result = await actor.handle(const ExportMnemonic()) as MnemonicExported; expect(result.mnemonic, equals(created.mnemonic)); }); test('GetPublicKey returns current pubkey', () async { final actor = NostrAccountActor(); await actor.handle(ImportAccount(_kPrivkey1)); final result = await actor.handle(const GetAccountPublicKey()) as AccountPublicKeyResult; expect(result.publicKeyHex, equals(_kPubkey1)); }); }); // ----------------------------------------------------------------------- group('NostrAccountActor — profile', () { test('FetchProfile without fetcher throws StateError', () async { final actor = NostrAccountActor(); await actor.handle(ImportAccount(_kPrivkey1)); await expectLater( actor.handle(const FetchProfile()), throwsStateError, ); }); test('UpdateProfile without publisher throws StateError', () async { final actor = NostrAccountActor(); await actor.handle(ImportAccount(_kPrivkey1)); await expectLater( actor.handle(UpdateProfile(const NostrProfile())), throwsStateError, ); }); test('UpdateProfile publishes kind-0 event with correct fields', () async { final fakeRelay = FakeRelayConnection(); final publisher = NostrPublishActor( relayUrls: ['ws://test'], connectionFactory: (_) => fakeRelay, ); final actor = NostrAccountActor(publisher: publisher); await actor.handle(ImportAccount(_kPrivkey1)); const profile = NostrProfile(name: 'alice', displayName: 'Alice'); final result = await actor.handle(UpdateProfile(profile)); expect(result, isA()); final eventMsgs = fakeRelay.sentMessages .where((m) => (jsonDecode(m) as List)[0] == 'EVENT') .toList(); expect(eventMsgs, hasLength(1)); final event = (jsonDecode(eventMsgs.first) as List)[1] as Map; expect(event['kind'], equals(0)); expect(event['pubkey'], equals(_kPubkey1)); final content = jsonDecode(event['content'] as String) as Map; expect(content['name'], equals('alice')); expect(content['display_name'], equals('Alice')); }); test('UpdateProfile event signature verifies', () async { final fakeRelay = FakeRelayConnection(); final publisher = NostrPublishActor( relayUrls: ['ws://test'], connectionFactory: (_) => fakeRelay, ); final actor = NostrAccountActor(publisher: publisher); await actor.handle(ImportAccount(_kPrivkey1)); await actor.handle(UpdateProfile(const NostrProfile(name: 'alice'))); final eventMsg = fakeRelay.sentMessages .firstWhere((m) => (jsonDecode(m) as List)[0] == 'EVENT'); final event = (jsonDecode(eventMsg) as List)[1] as Map; expect( bip340.verify( event['pubkey'] as String, event['id'] as String, event['sig'] as String, ), isTrue, ); }); test('FetchProfile returns profile from relay', () async { final fakeRelay = FakeRelayConnection(); fakeRelay.onReq = (subId) { fakeRelay.push(jsonEncode([ 'EVENT', subId, { 'id': 'a' * 64, 'pubkey': _kPubkey1, 'created_at': 1000000, 'kind': 0, 'tags': [], 'content': jsonEncode({'name': 'bob', 'about': 'hello world'}), 'sig': 'a' * 128, }, ])); fakeRelay.push(jsonEncode(['EOSE', subId])); }; final fetcher = NostrFetchActor( relayUrls: ['ws://test'], connectionFactory: (_) => fakeRelay, ); final actor = NostrAccountActor(fetcher: fetcher); await actor.handle(ImportAccount(_kPrivkey1)); final result = await actor.handle(const FetchProfile()) as ProfileFetched; expect(result.profile, isNotNull); expect(result.profile!.name, equals('bob')); expect(result.profile!.about, equals('hello world')); }); test('FetchProfile returns null when no events', () async { final fakeRelay = FakeRelayConnection(); fakeRelay.onReq = (subId) { fakeRelay.push(jsonEncode(['EOSE', subId])); }; final fetcher = NostrFetchActor( relayUrls: ['ws://test'], connectionFactory: (_) => fakeRelay, ); final actor = NostrAccountActor(fetcher: fetcher); await actor.handle(ImportAccount(_kPrivkey1)); final result = await actor.handle(const FetchProfile()) as ProfileFetched; expect(result.profile, isNull); }); test('GetPublicData includes pubkey and cached profile', () async { final fakeRelay = FakeRelayConnection(); final publisher = NostrPublishActor( relayUrls: ['ws://test'], connectionFactory: (_) => fakeRelay, ); final actor = NostrAccountActor(publisher: publisher); await actor.handle(ImportAccount(_kPrivkey1)); // Before UpdateProfile, only pubkey var data = (await actor.handle(const GetPublicData()) as PublicData).data; expect(data['pubkey'], equals(_kPubkey1)); expect(data.containsKey('name'), isFalse); // After UpdateProfile, profile fields included await actor.handle( UpdateProfile(const NostrProfile(name: 'carol', lud16: 'carol@ln.tip')), ); data = (await actor.handle(const GetPublicData()) as PublicData).data; expect(data['pubkey'], equals(_kPubkey1)); expect(data['name'], equals('carol')); expect(data['lud16'], equals('carol@ln.tip')); }); }); } String _hex(Uint8List bytes) => bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();