swarm/test/nostr_account_actor_test.dart
2026-06-08 12:25:26 +03:00

330 lines
12 KiB
Dart

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<AccountCreated>());
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<AccountImported>());
expect((result as AccountImported).publicKeyHex, equals(_kPubkey1));
});
test('ImportMnemonic returns correct pubkey', () async {
final result =
await NostrAccountActor().handle(ImportMnemonic(_kMnemonic1));
expect(result, isA<AccountImported>());
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<ProfileUpdated>());
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<String, dynamic>;
expect(event['kind'], equals(0));
expect(event['pubkey'], equals(_kPubkey1));
final content =
jsonDecode(event['content'] as String) as Map<String, dynamic>;
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<String, dynamic>;
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();