Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions doc/usecases/user-relay-lists.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ icon: list-ordered

:::code source="../../packages/ndk/example/user_relay_list_test.dart" language="dart" range="23-29" title="" :::

## Private storage relays

NIP-37 private storage relays are exposed through the same usecase.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use standard spelling: “use case”.

Replace “usecase” with “use case” to match common terminology and improve documentation readability.

🧰 Tools
🪛 LanguageTool

[grammar] ~13-~13: Ensure spelling is correct
Context: ...age relays are exposed through the same usecase. The kind 10013 event is encrypted, s...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@doc/usecases/user-relay-lists.md` at line 13, The document contains the
nonstandard term "usecase" (e.g., the sentence "NIP-37 private storage relays
are exposed through the same usecase."); update occurrences of the token
"usecase" to the standard spelling "use case" throughout the file (for example
edit the sentence to "NIP-37 private storage relays are exposed through the same
use case").

The kind `10013` event is encrypted, so this requires a logged-in signer.

```dart
final relays = await ndk.userRelayLists.getPrivateStorageRelays();
```

## When to use

User relay lists provides you with the relays for a given user. \
Expand Down
6 changes: 2 additions & 4 deletions packages/bc_ur/lib/ur.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ class UR {
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is UR &&
other.type == type &&
_listEquals(other.cbor, cbor);
return other is UR && other.type == type && _listEquals(other.cbor, cbor);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this the default dart formatter?

}

@override
Expand All @@ -36,4 +34,4 @@ class UR {
}
return true;
}
}
}
4 changes: 4 additions & 0 deletions packages/ndk/lib/domain_layer/entities/nip_37.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class Nip37 {
/// Relay list for private content.
static const int kPrivateStorageRelays = 10013;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This kind is not defined in nip-37, but in nip-51.
The fact that draft relays are used in nip-37 doesn't make it as the place that this kind is defined.

}
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import 'dart:convert';

import '../../../config/user_relay_list_defaults.dart';
import '../../../shared/helpers/relay_helper.dart';
import '../../../shared/logger/logger.dart';
import '../../../shared/nips/nip01/helpers.dart';
import '../../entities/contact_list.dart';
import '../../entities/filter.dart';
import '../../entities/nip_37.dart';
import '../../entities/nip_01_event.dart';
import '../../entities/nip_51_list.dart';
import '../../entities/nip_65.dart';
Expand Down Expand Up @@ -213,6 +216,76 @@ class UserRelayLists {
.toList();
}

/// Fetches the private storage relay list (NIP-37, kind 10013) for the
/// logged-in user.
///
/// Returns the list of relay URLs where the user stores private content,
/// such as draft wraps.
///
/// - Returns `null` if the user has not published a kind 10013 event.
///
/// [forceRefresh] if true, bypass cache and query relays directly.
Future<List<String>?> getPrivateStorageRelays({
bool forceRefresh = false,
}) async {
final signer = _signer;
if (!signer.canSign()) {
throw "cannot decrypt private storage relay list without a signer";
}

final pubKey = signer.getPublicKey();
List<Nip01Event> events = [];

if (!forceRefresh) {
events = await _cacheManager.loadEvents(
pubKeys: [pubKey],
kinds: [Nip37.kPrivateStorageRelays],
);
}

if (events.isEmpty) {
events = await _requests
.query(
filter: Filter(
authors: [pubKey],
kinds: [Nip37.kPrivateStorageRelays],
limit: 1,
),
)
.future;
}

if (events.isEmpty) return null;

events.sort((a, b) => b.createdAt.compareTo(a.createdAt));
final latest = events.first;
await _cacheManager.saveEvent(latest);
try {
return await _extractPrivateStorageRelayUrls(latest, signer);
} catch (e) {
Logger.log.w(() => e);
return null;
}
}

Future<List<String>> _extractPrivateStorageRelayUrls(
Nip01Event event,
EventSigner signer,
) async {
final json = await signer.decryptNip44(
ciphertext: event.content,
senderPubKey: event.pubKey,
);
final tags = jsonDecode(json ?? '') as List<dynamic>;

return tags
.whereType<List<dynamic>>()
.where((tag) => tag.length >= 2 && tag[0] == Nip51List.kRelay)
.map((tag) => tag[1])
.whereType<String>()
.toList();
}

/// *************************************************************************************************

Future<UserRelayList?> _ensureUpToDateUserRelayList() async {
Expand Down
1 change: 1 addition & 0 deletions packages/ndk/lib/entities.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export 'domain_layer/entities/global_state.dart';
export 'domain_layer/entities/metadata.dart';
export 'domain_layer/entities/nip_01_event.dart';
export 'domain_layer/entities/nip_05.dart';
export 'domain_layer/entities/nip_37.dart';
export 'domain_layer/entities/nip_51_list.dart';
export 'domain_layer/entities/nip_65.dart';
export 'domain_layer/entities/pubkey_mapping.dart';
Expand Down
1 change: 1 addition & 0 deletions packages/ndk/lib/ndk.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export 'domain_layer/entities/broadcast_response.dart';
export 'domain_layer/entities/nip_01_event.dart';
export 'data_layer/models/nip_01_event_model.dart';
export 'domain_layer/entities/filter.dart';
export 'domain_layer/entities/nip_37.dart';
export 'domain_layer/entities/nip_51_list.dart';
export 'domain_layer/entities/contact_list.dart';
export 'domain_layer/entities/read_write.dart';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:convert';

import 'package:ndk/entities.dart';
import 'package:ndk/ndk.dart';
import 'package:ndk/shared/nips/nip01/bip340.dart';
Expand Down Expand Up @@ -110,6 +112,45 @@ void main() async {
expect(dmRelays, ['wss://dm1.example', 'wss://dm2.example']);
});

test('getPrivateStorageRelays - returns null when no kind 10013 found',
() async {
ndk.accounts
.loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!);

final privateStorageRelays =
await ndk.userRelayLists.getPrivateStorageRelays();

expect(privateStorageRelays, isNull);
});

test('getPrivateStorageRelays - reads encrypted relays from cache',
() async {
ndk.accounts
.loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!);
final signer = ndk.accounts.getLoggedAccount()!.signer;
final encryptedContent = await signer.encryptNip44(
plaintext: jsonEncode([
['relay', 'wss://private1.example'],
['relay', 'wss://private2.example'],
]),
recipientPubKey: key1.publicKey,
);
final event = await signer.sign(Nip01Event(
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
pubKey: key1.publicKey,
kind: Nip37.kPrivateStorageRelays,
content: encryptedContent!,
tags: [],
));
await ndk.config.cache.saveEvent(event);

final privateStorageRelays =
await ndk.userRelayLists.getPrivateStorageRelays();

expect(privateStorageRelays,
['wss://private1.example', 'wss://private2.example']);
});

test('broadcastAdd/RemoveNip65Relay', () async {
ndk.accounts
.loginPrivateKey(pubkey: key3.publicKey, privkey: key3.privateKey!);
Expand Down

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this signer is deprecated => merge from master

Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@ class _PendingRequestEntry {
}

/// amber (external app) https://github.com/greenart7c3/Amber singer
class AmberEventSigner
with ConcurrencyLimiterMixin
implements EventSigner {
class AmberEventSigner with ConcurrencyLimiterMixin implements EventSigner {
final AmberFlutterDS amberFlutterDS;

final String publicKey;
Expand All @@ -38,8 +36,7 @@ class AmberEventSigner
required this.publicKey,
required this.amberFlutterDS,
this.maxConcurrentRequests = defaultMaxConcurrentRequests,
}) : assert(maxConcurrentRequests > 0,
'maxConcurrentRequests must be > 0');
}) : assert(maxConcurrentRequests > 0, 'maxConcurrentRequests must be > 0');

String get _npub =>
publicKey.startsWith('npub') ? publicKey : Nip19.encodePubKey(publicKey);
Expand Down Expand Up @@ -94,11 +91,11 @@ class AmberEventSigner
// `pendingRequests` so the UI sees the full backlog. If the request was
// cancelled while queued, skip the Amber call entirely.
runThrottled(() async {
if (!_pendingRequests.containsKey(requestId)) {
throw SignerRequestCancelledException(requestId);
}
return await operation();
})
if (!_pendingRequests.containsKey(requestId)) {
throw SignerRequestCancelledException(requestId);
}
return await operation();
})
.then((result) {
if (!completer.isCompleted) {
completer.complete(result);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@ class NostrConnectDialogView extends StatelessWidget {
data: nostrConnectURL,
decoration: const PrettyQrDecoration(
quietZone: PrettyQrQuietZone.standard,
shape: PrettyQrSmoothSymbol(
roundFactor: 0,
),
shape: PrettyQrSmoothSymbol(roundFactor: 0),
),
),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ class Nip07EventSigner implements EventSigner {
BehaviorSubject<List<PendingSignerRequest>>.seeded([]);

Nip07EventSigner({this.cachedPublicKey, int maxConcurrentRequests = 100})
: assert(maxConcurrentRequests > 0,
'maxConcurrentRequests must be > 0');
: assert(maxConcurrentRequests > 0, 'maxConcurrentRequests must be > 0');

@override
bool canSign() {
Expand Down
17 changes: 7 additions & 10 deletions packages/nip07_event_signer/lib/src/nip07_event_signer_web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ class _PendingRequestEntry {
_PendingRequestEntry(this.completer, this.request);
}

class Nip07EventSigner
with ConcurrencyLimiterMixin
implements EventSigner {
class Nip07EventSigner with ConcurrencyLimiterMixin implements EventSigner {
String? cachedPublicKey;

final _pendingRequests = <String, _PendingRequestEntry>{};
Expand All @@ -33,8 +31,7 @@ class Nip07EventSigner
Nip07EventSigner({
this.cachedPublicKey,
this.maxConcurrentRequests = defaultMaxConcurrentRequests,
}) : assert(maxConcurrentRequests > 0,
'maxConcurrentRequests must be > 0');
}) : assert(maxConcurrentRequests > 0, 'maxConcurrentRequests must be > 0');

String _generateRequestId() {
return 'nip07_${DateTime.now().millisecondsSinceEpoch}_${_requestCounter++}';
Expand Down Expand Up @@ -78,11 +75,11 @@ class Nip07EventSigner
// appear in `pendingRequests` so the UI sees the full backlog. If the
// request was cancelled while queued, skip the extension call entirely.
runThrottled(() async {
if (!_pendingRequests.containsKey(requestId)) {
throw SignerRequestCancelledException(requestId);
}
return await operation();
})
if (!_pendingRequests.containsKey(requestId)) {
throw SignerRequestCancelledException(requestId);
}
return await operation();
})
.then((result) {
if (!completer.isCompleted) {
completer.complete(result);
Expand Down
Loading