From 7dad1b0bb9682ee8e047a6732332616bab26783a Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Fri, 3 Jan 2025 17:46:09 -0300 Subject: [PATCH] Fix NWC secret management, zap anonymously --- lib/models/file_metadata.dart | 8 --- lib/models/user.dart | 6 +- lib/navigation/app_initializer.dart | 14 ++++ lib/screens/app_detail_screen.dart | 8 +-- lib/screens/settings_screen.dart | 64 ++++++++++++------ lib/utils/extensions.dart | 6 ++ lib/utils/nwc.dart | 24 +++---- lib/widgets/latest_releases_container.dart | 7 +- lib/widgets/sign_in_container.dart | 27 ++++---- lib/widgets/wot_container.dart | 7 +- lib/widgets/zap_button.dart | 74 +++++++++++++-------- lib/widgets/zap_receipts.dart | 77 ++++++++++++++++------ 12 files changed, 204 insertions(+), 118 deletions(-) diff --git a/lib/models/file_metadata.dart b/lib/models/file_metadata.dart index 2879562..6e1d9ed 100644 --- a/lib/models/file_metadata.dart +++ b/lib/models/file_metadata.dart @@ -16,14 +16,6 @@ class FileMetadata extends base.FileMetadata with DataModelMixin { @override Object? get id => event.id; - // FileMetadata( - // {super.createdAt, - // super.content, - // super.tags, - // required this.author, - // required this.release, - // required this.signer}); - FileMetadata.fromJson(super.map) : author = belongsTo(map['author']), release = belongsTo(map['release']), diff --git a/lib/models/user.dart b/lib/models/user.dart index 6266cf5..29d2c9b 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -19,7 +19,7 @@ class User extends base.User with DataModelMixin { Object? get id => event.id; Future zap(int amountInSats, - {required base.Event event, String? comment}) async { + {required base.Event event, String? comment, base.Signer? signer}) async { final adapter = DataModel.adapterFor(this) as NostrAdapter; // First ensure connection is available @@ -44,8 +44,8 @@ class User extends base.User with DataModelMixin { ..amount = amountInMillisats ..comment = comment; - final zapRequest = - await partialZapRequest.signWith(amberSigner, withPubkey: pubkey); + final zapRequest = await partialZapRequest.signWith(signer ?? amberSigner, + withPubkey: pubkey); // Now we fetch the invoice final lnResponse = await author.fetchLightningAddress(); diff --git a/lib/navigation/app_initializer.dart b/lib/navigation/app_initializer.dart index 1af7eb4..e499efe 100644 --- a/lib/navigation/app_initializer.dart +++ b/lib/navigation/app_initializer.dart @@ -6,12 +6,14 @@ import 'package:zapstore/main.dart'; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/local_app.dart'; +import 'package:zapstore/models/user.dart'; import 'package:zapstore/navigation/router.dart'; import 'package:zapstore/utils/signers.dart'; import 'package:zapstore/widgets/app_curation_container.dart'; AppLifecycleListener? _lifecycleListener; SharedPreferences? sharedPreferences; +User? anonUser; final amberSigner = AmberSigner(); final appInitializer = FutureProvider((ref) async { @@ -47,6 +49,18 @@ final appInitializer = FutureProvider((ref) async { // Initialize signer await amberSigner.initialize(); + // Set up anon user (pubkey derives from pkSigner secret key) + final anonPubkey = + 'c86eda2daae768374526bc54903f388d9a866c00740ec8db418d7ef2dca77b5b'; + anonUser ??= User.fromJson({ + 'id': anonPubkey, + 'kind': 0, + 'pubkey': anonPubkey, + 'created_at': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'content': '{}', + 'tags': [], + }).init().saveLocal(); + // App-wide listeners // Register app lifecycle listener diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index 978d0fb..168c231 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -156,12 +156,10 @@ class AppDetailScreen extends HookConsumerWidget { padding: const EdgeInsets.only(right: 14), child: SignerAndDeveloperRow(app: app), ), - Gap(20), - if (app.latestMetadata != null) - ZapReceipts(fileMetadata: app.latestMetadata!), - Gap(20), + Gap(10), ZapButton(app: app), - Gap(20), + ZapReceipts(app: app), + Gap(10), Container( padding: EdgeInsets.all(10), decoration: BoxDecoration( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 1c25356..42e1ead 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -10,7 +10,6 @@ import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; -import 'package:ndk/domain_layer/usecases/nwc/nwc_connection.dart'; import 'package:path_provider/path_provider.dart'; import 'package:purplebase/purplebase.dart'; import 'package:zapstore/main.data.dart'; @@ -70,8 +69,7 @@ class SettingsScreen extends HookConsumerWidget { user.settings.value!.signInMethod == SignInMethod.nip55 && amberSigner.isAvailable) AsyncButtonBuilder( - loadingWidget: SizedBox( - width: 14, height: 14, child: CircularProgressIndicator()), + loadingWidget: SmallCircularProgressIndicator(), onPressed: () async { if (feedbackController.text.trim().isNotEmpty) { final signedDirectMessage = await amberSigner.sign( @@ -110,13 +108,19 @@ class SettingsScreen extends HookConsumerWidget { }, child: Text('Send as ${user.nameOrNpub}'), ), - Gap(40), + Gap(20), Divider(), - Gap(40), - if (user != null) NostrWalletConnectContainer(), - Gap(40), + Gap(20), + Text( + 'Wallet', + style: context.theme.textTheme.headlineLarge! + .copyWith(fontWeight: FontWeight.bold), + ), + Gap(20), + NwcContainer(), + Gap(20), Divider(), - Gap(40), + Gap(20), Text( 'Tools', style: context.theme.textTheme.headlineLarge! @@ -218,9 +222,20 @@ class SettingsScreen extends HookConsumerWidget { } } -class NostrWalletConnectContainer extends HookConsumerWidget { +class SmallCircularProgressIndicator extends StatelessWidget { + const SmallCircularProgressIndicator({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return SizedBox(width: 14, height: 14, child: CircularProgressIndicator()); + } +} + +class NwcContainer extends HookConsumerWidget { final bool dialogMode; - const NostrWalletConnectContainer({ + const NwcContainer({ super.key, this.dialogMode = false, }); @@ -231,14 +246,25 @@ class NostrWalletConnectContainer extends HookConsumerWidget { final controller = useTextEditingController(text: ''); final isTextFieldEmpty = useState(true); + if (nwcConnection.isLoading) { + return Padding( + padding: const EdgeInsets.all(5), + child: SmallCircularProgressIndicator(), + ); + } + return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Gap(20), - Text( - "Nostr Wallet Connect ${nwcConnection.value != null ? 'connected' : 'URI'}"), - if (nwcConnection.value != null && !dialogMode) + if (!nwcConnection.hasError) + Text( + "Nostr Wallet Connect ${nwcConnection.hasValue ? 'connected' : 'URI'}"), + if (nwcConnection.hasError) + Text('Error connecting: ${nwcConnection.error}'), + if (nwcConnection.hasValue && + nwcConnection.value != null && + !dialogMode) ElevatedButton( onPressed: () async { await ref.read(nwcConnectionProvider.notifier).disconnectWallet(); @@ -248,9 +274,11 @@ class NostrWalletConnectContainer extends HookConsumerWidget { backgroundColor: Colors.transparent), child: Text('Disconnect'), ), - if (nwcConnection.value == null) + if ((nwcConnection.hasValue && nwcConnection.value == null) || + nwcConnection.hasError) TextField( autocorrect: false, + keyboardType: TextInputType.none, controller: controller, onChanged: (value) { isTextFieldEmpty.value = value.isEmpty; @@ -259,11 +287,7 @@ class NostrWalletConnectContainer extends HookConsumerWidget { hintText: 'nostr+walletconnect://...', suffixIcon: AsyncButtonBuilder( disabled: isTextFieldEmpty.value, - loadingWidget: SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(), - ), + loadingWidget: SmallCircularProgressIndicator(), onPressed: () async { final nwcUri = controller.text.trim(); await ref diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index 93e91dc..b13fc6f 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -135,4 +135,10 @@ extension StringWidget on String { } } +extension TextExt on Text { + Widget get bold { + return Text(data!, style: TextStyle(fontWeight: FontWeight.bold)); + } +} + const kZapstoreAppIdentifier = 'dev.zapstore.app'; diff --git a/lib/utils/nwc.dart b/lib/utils/nwc.dart index 39637d8..0d35706 100644 --- a/lib/utils/nwc.dart +++ b/lib/utils/nwc.dart @@ -44,18 +44,18 @@ class NwcConnectionNotifier extends StateNotifier> { // Write secret to storage and connect await _storage.write(key: kNwcSecretKey, value: nwcSecret); - final connection = await ndk.nwc.connect( - nwcSecret, - doGetInfoMethod: false, - onError: (error) { - state = - AsyncValue.error(error ?? 'Could not connect', StackTrace.current); - }, - ); - if (connection.permissions.contains(NwcMethod.PAY_INVOICE.name)) { - state = AsyncValue.data(connection); - } else { - state = AsyncError('No permission to zap', StackTrace.current); + try { + final connection = await ndk.nwc.connect( + nwcSecret, + doGetInfoMethod: false, + ); + if (connection.permissions.contains(NwcMethod.PAY_INVOICE.name)) { + state = AsyncValue.data(connection); + } else { + state = AsyncError('No permission to zap', StackTrace.current); + } + } catch (e, stack) { + state = AsyncValue.error(e, stack); } } diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index 4252cf5..da428fc 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -6,6 +6,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; +import 'package:zapstore/screens/settings_screen.dart'; import 'package:zapstore/utils/notifier.dart'; import 'package:zapstore/widgets/app_card.dart'; @@ -51,11 +52,7 @@ class LatestReleasesContainer extends HookConsumerWidget { for (final app in state.value!) AppCard(model: app), if (state.hasValue) AsyncButtonBuilder( - loadingWidget: SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(), - ), + loadingWidget: SmallCircularProgressIndicator(), onPressed: () async { return ref .read(latestReleasesAppProvider.notifier) diff --git a/lib/widgets/sign_in_container.dart b/lib/widgets/sign_in_container.dart index ef90a43..438d5d8 100644 --- a/lib/widgets/sign_in_container.dart +++ b/lib/widgets/sign_in_container.dart @@ -9,13 +9,21 @@ import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/settings.dart'; import 'package:zapstore/models/user.dart'; import 'package:zapstore/navigation/app_initializer.dart'; +import 'package:zapstore/screens/settings_screen.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/widgets/rounded_image.dart'; class SignInButton extends ConsumerWidget { final bool minimal; final String label; - SignInButton({super.key, this.minimal = false, this.label = 'Sign in'}); + final bool publicKeyAllowed; + final String? signedOutText; + SignInButton( + {super.key, + this.minimal = false, + this.label = 'Sign in', + this.publicKeyAllowed = true, + this.signedOutText}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -28,6 +36,7 @@ class SignInButton extends ConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (user == null && signedOutText != null) Text(signedOutText!), Row( children: [ if (!minimal) RoundedImage(url: user?.avatarUrl, size: 46), @@ -67,7 +76,9 @@ class SignInButton extends ConsumerWidget { label, style: TextStyle(fontWeight: FontWeight.bold), ), - content: SignInDialogBox(), + content: SignInDialogBox( + publicKeyAllowed: publicKeyAllowed, + ), ), ); } else { @@ -102,11 +113,7 @@ class SignInDialogBox extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ AsyncButtonBuilder( - loadingWidget: SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(), - ), + loadingWidget: SmallCircularProgressIndicator(), builder: (context, child, callback, buttonState) { return Padding( padding: const EdgeInsets.only(bottom: 2), @@ -183,11 +190,7 @@ class SignInDialogBox extends HookConsumerWidget { Gap(10), AsyncButtonBuilder( disabled: isTextFieldEmpty.value, - loadingWidget: SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(), - ), + loadingWidget: SmallCircularProgressIndicator(), onPressed: () async { try { final input = controller.text.trim(); diff --git a/lib/widgets/wot_container.dart b/lib/widgets/wot_container.dart index bd0e737..52a516a 100644 --- a/lib/widgets/wot_container.dart +++ b/lib/widgets/wot_container.dart @@ -6,6 +6,7 @@ import 'package:purplebase/purplebase.dart' as base; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/settings.dart'; import 'package:zapstore/models/user.dart'; +import 'package:zapstore/screens/settings_screen.dart'; import 'package:zapstore/widgets/users_rich_text.dart'; class WebOfTrustContainer extends HookConsumerWidget { @@ -55,11 +56,7 @@ class WebOfTrustContainer extends HookConsumerWidget { children: [ Text('Loading web of trust...'), Gap(10), - SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(), - ), + SmallCircularProgressIndicator(), ], ), ), diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart index f5f7067..3177c29 100644 --- a/lib/widgets/zap_button.dart +++ b/lib/widgets/zap_button.dart @@ -4,8 +4,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/settings.dart'; +import 'package:zapstore/navigation/app_initializer.dart'; import 'package:zapstore/screens/settings_screen.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/utils/signers.dart'; import 'package:zapstore/widgets/sign_in_container.dart'; import '../utils/nwc.dart'; @@ -32,32 +34,29 @@ class ZapButton extends HookConsumerWidget { return app.developer.value?.lud16 != null ? ElevatedButton( onPressed: () async { - final loggedInUser = user ?? - await showDialog( - context: context, - builder: (_) => AlertDialog( - title: Text( - 'Sign in to zap', - style: TextStyle(fontWeight: FontWeight.bold), - ), - content: SignInDialogBox(publicKeyAllowed: false), - ), - ); + // final loggedInUser = user ?? + // await showDialog( + // context: context, + // builder: (_) => AlertDialog( + // title: Text( + // 'Sign in to zap', + // style: TextStyle(fontWeight: FontWeight.bold), + // ), + // content: SignInDialogBox(publicKeyAllowed: false), + // ), + // ); - if (loggedInUser == null) { - return; - } + // if (loggedInUser == null) { + // return; + // } if (nwcConnection.value == null) { await showDialog( // ignore: use_build_context_synchronously context: context, builder: (_) => AlertDialog( - title: Text( - 'Connect your wallet to zap', - style: TextStyle(fontWeight: FontWeight.bold), - ), - content: NostrWalletConnectContainer(dialogMode: true), + title: Text('Connect your wallet').bold, + content: NwcContainer(dialogMode: true), ), ); } @@ -71,10 +70,17 @@ class ZapButton extends HookConsumerWidget { context: context, builder: (context) { return AlertDialog( - title: Text('Choose zap amount'), + title: Text('Choose zap amount').bold, content: Column( mainAxisSize: MainAxisSize.min, children: [ + if (user == null) + SignInButton( + publicKeyAllowed: false, + minimal: true, + signedOutText: + '⚠️ If you do not sign in, you will be zapping anonymously', + ), TextField( controller: amountController, keyboardType: TextInputType.number, @@ -83,24 +89,22 @@ class ZapButton extends HookConsumerWidget { ), SizedBox(height: 20), Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ ElevatedButton( onPressed: () => Navigator.of(context).pop(21), - child: Text('⚡ 21'), + child: Text('⚡️ 21'), ), - Gap(10), ElevatedButton( onPressed: () => Navigator.of(context).pop(210), - child: Text('⚡ 210'), + child: Text('⚡️ 210'), ), - Gap(10), ElevatedButton( onPressed: () => Navigator.of(context).pop(2100), - child: Text('⚡ 2100'), + child: Text('⚡️ 2100'), ), ], ), @@ -115,7 +119,7 @@ class ZapButton extends HookConsumerWidget { }, ), TextButton( - child: Text('Zap'), + child: Text('Zap').bold, onPressed: () { Navigator.of(context).pop( int.tryParse(amountController.text) ?? 0); @@ -132,7 +136,12 @@ class ZapButton extends HookConsumerWidget { // ignore: use_build_context_synchronously context.showInfo('Zap sent!', description: '$amount sats on their way'); - await loggedInUser.zap(amount, event: app.latestMetadata!); + if (user != null) { + await user.zap(amount, event: app.latestMetadata!); + } else { + await anonUser!.zap(amount, + event: app.latestMetadata!, signer: pkSigner); + } } catch (e) { // ignore: use_build_context_synchronously context.showError( @@ -140,7 +149,14 @@ class ZapButton extends HookConsumerWidget { } } }, - child: Text('Zap!'), + style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[800]), + child: Text( + '⚡️ Zap this release', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 15), + ), ) : Container(); } diff --git a/lib/widgets/zap_receipts.dart b/lib/widgets/zap_receipts.dart index 7f4b5ad..3ce5002 100644 --- a/lib/widgets/zap_receipts.dart +++ b/lib/widgets/zap_receipts.dart @@ -1,26 +1,35 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter_data/flutter_data.dart'; +import 'package:ndk/domain_layer/entities/filter.dart'; import 'package:ndk/domain_layer/entities/nip_01_event.dart'; import 'package:zapstore/main.data.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/file_metadata.dart'; import 'package:zapstore/models/user.dart'; import 'package:zapstore/models/zap_receipt.dart'; +import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/widgets/users_rich_text.dart'; class ZapReceipts extends HookConsumerWidget { ZapReceipts({ super.key, - required this.fileMetadata, + required this.app, }); - final FileMetadata fileMetadata; + final App app; @override Widget build(BuildContext context, WidgetRef ref) { + final fileMetadata = app.latestMetadata; + if (fileMetadata == null) { + return Container(); + } + final zapReceipts = ref.watch(zapReceiptsNotifier(fileMetadata)); if (zapReceipts.isLoading) { @@ -38,33 +47,63 @@ class ZapReceipts extends HookConsumerWidget { final senderIds = receipts.map((r) => r.senderPubkey).toSet(); final senders = ref.users.findManyLocalByIds(senderIds); - return UsersRichText( - preSpan: TextSpan(text: '⚡ $totalAmountInSats sats by'), - trailingText: ' and others', - users: senders, + return Padding( + padding: const EdgeInsets.only(top: 10, bottom: 10), + child: UsersRichText( + preSpan: TextSpan( + text: '⚡ $totalAmountInSats sats (${receipts.length} zaps) by'), + trailingText: ' and others', + users: senders, + ), ); } } class ZapReceiptsNotifier extends StateNotifier>> { Ref ref; - FileMetadata fileMetadata; + final FileMetadata fileMetadata; StreamSubscription? sub; - ZapReceiptsNotifier(this.ref, this.fileMetadata) - : super(AsyncData(ref.zapReceipts.zapReceiptAdapter - .findByRecipient( - pubkey: fileMetadata.author.id!.toString(), - eventId: fileMetadata.id!.toString()) - .toSet())) { + ZapReceiptsNotifier(this.ref, this.fileMetadata) : super(AsyncLoading()) { final adapter = ref.users.nostrAdapter; - - final recipient = + final developerPubkey = fileMetadata.event.getTag('zap') ?? fileMetadata.event.pubkey; - print('querying for $recipient and id ${fileMetadata.event.id}'); - final receiptsResponse = adapter.socialRelays.ndk!.zaps - .subscribeToZapReceipts( - pubKey: recipient, eventId: fileMetadata.event.id); + + // If it is a Zapstore-signed release from the indexer, return + if (developerPubkey == kZapstorePubkey && + fileMetadata.release.value!.app.value!.identifier != + kZapstoreAppIdentifier) { + state = AsyncData({}); + return; + } + + final localReceipts = ref.zapReceipts.zapReceiptAdapter + .findByRecipient( + pubkey: developerPubkey, eventId: fileMetadata.id!.toString()) + .toSet(); + state = AsyncData(localReceipts); + + print('querying for $developerPubkey and id ${fileMetadata.event.id}'); + + final latestReceiptTimestamp = localReceipts + .sortedBy((z) => z.event.createdAt) + .lastOrNull + ?.event + .createdAt + .millisecondsSinceEpoch; + + final receiptsResponse = adapter.socialRelays.ndk!.requests.subscription( + filters: [ + Filter( + kinds: [9735], + since: latestReceiptTimestamp != null + ? latestReceiptTimestamp ~/ 1000 + 1 + : null, + eTags: [fileMetadata.event.id], + pTags: [developerPubkey], + ) + ], + ); sub = receiptsResponse.stream.listen((data) { final model = ZapReceipt.fromJson(data.toJson()).init().saveLocal();