nostr account actor
This commit is contained in:
parent
305cff62bd
commit
0cdf3d7dbf
6 changed files with 1183 additions and 0 deletions
330
test/nostr_account_actor_test.dart
Normal file
330
test/nostr_account_actor_test.dart
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue