330 lines
12 KiB
Dart
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();
|