Fix NWC secret management, zap anonymously

This commit is contained in:
franzap
2025-01-03 17:46:09 -03:00
parent b51507200d
commit 7dad1b0bb9
12 changed files with 204 additions and 118 deletions
-8
View File
@@ -16,14 +16,6 @@ class FileMetadata extends base.FileMetadata with DataModelMixin<FileMetadata> {
@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']),
+3 -3
View File
@@ -19,7 +19,7 @@ class User extends base.User with DataModelMixin<User> {
Object? get id => event.id;
Future<void> 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<User> {
..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();
+14
View File
@@ -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<void>((ref) async {
@@ -47,6 +49,18 @@ final appInitializer = FutureProvider<void>((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
+3 -5
View File
@@ -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(
+44 -20
View File
@@ -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
+6
View File
@@ -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';
+12 -12
View File
@@ -44,18 +44,18 @@ class NwcConnectionNotifier extends StateNotifier<AsyncValue<NwcConnection?>> {
// 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);
}
}
+2 -5
View File
@@ -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)
+15 -12
View File
@@ -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();
+2 -5
View File
@@ -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(),
],
),
),
+45 -29
View File
@@ -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();
}
+58 -19
View File
@@ -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<AsyncValue<Set<ZapReceipt>>> {
Ref ref;
FileMetadata fileMetadata;
final FileMetadata fileMetadata;
StreamSubscription<Nip01Event>? 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();