From 223419c3105e7c7f59abc3eba18a62901e6ee68e Mon Sep 17 00:00:00 2001 From: Marc Platt Date: Sat, 19 Apr 2025 12:18:12 -0400 Subject: [PATCH 1/4] clients/bitwindow: multisig lounge frontend --- bitwindow/lib/pages/wallet_page.dart | 124 +++++++++++++++++++++++++-- 1 file changed, 119 insertions(+), 5 deletions(-) diff --git a/bitwindow/lib/pages/wallet_page.dart b/bitwindow/lib/pages/wallet_page.dart index cd7bcec94..13104f18f 100644 --- a/bitwindow/lib/pages/wallet_page.dart +++ b/bitwindow/lib/pages/wallet_page.dart @@ -2460,11 +2460,125 @@ class MultisigLoungeTab extends StatelessWidget { return SailCard( title: 'Multisig Lounge', subtitle: 'Create and manage multi-signature wallets', - child: Center( - child: SailText.primary15( - 'Multisig functionality coming soon...', - color: context.sailTheme.colors.textTertiary, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: SailText.primary13('Lounges'), + ), + SailSpacing(SailStyleValues.padding08), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: SailTable( + getRowId: (index) => 'empty$index', + headerBuilder: (context) => [ + const SailTableHeaderCell(name: 'Name'), + const SailTableHeaderCell(name: 'ID'), + const SailTableHeaderCell(name: 'Total Keys'), + const SailTableHeaderCell(name: 'Keys Required'), + const SailTableHeaderCell(name: 'Participants'), + ], + rowBuilder: (context, row, selected) { + return [ + const SailTableCell(value: 'Multisig functionality coming soon...'), + const SailTableCell(value: ''), + const SailTableCell(value: ''), + const SailTableCell(value: ''), + const SailTableCell(value: ''), + ]; + }, + rowCount: 1, + columnWidths: const [-1, -1, -1, -1, -1], + drawGrid: true, + ), + ), + const SizedBox(width: SailStyleValues.padding16), + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SailButton( + label: 'Create New Lounge', + onPressed: null, + variant: ButtonVariant.primary, + ), + ], + ), + ), + ], + ), + ), + SailSpacing(SailStyleValues.padding32), + Center( + child: SailText.primary13('Transaction History'), + ), + SailSpacing(SailStyleValues.padding08), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: SailTable( + getRowId: (index) => 'tx_empty$index', + headerBuilder: (context) => [ + const SailTableHeaderCell(name: 'Lounge'), + const SailTableHeaderCell(name: 'MuTxid'), + const SailTableHeaderCell(name: 'Status'), + const SailTableHeaderCell(name: 'Action'), + const SailTableHeaderCell(name: 'Bitcoin Txid'), + ], + rowBuilder: (context, row, selected) { + return [ + const SailTableCell(value: 'Multisig functionality coming soon...'), + const SailTableCell(value: ''), + const SailTableCell(value: ''), + const SailTableCell(value: ''), + const SailTableCell(value: ''), + ]; + }, + rowCount: 1, + columnWidths: const [-1, -1, -1, -1, -1], + drawGrid: true, + ), + ), + const SizedBox(width: SailStyleValues.padding16), + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Center( + child: SailText.primary13('Transaction Tools'), + ), + SailSpacing(SailStyleValues.padding08), + SailButton( + label: 'Create Transaction', + onPressed: null, + variant: ButtonVariant.secondary, + ), + SailSpacing(SailStyleValues.padding08), + SailButton( + label: 'Sign and Send', + onPressed: null, + variant: ButtonVariant.secondary, + ), + SailSpacing(SailStyleValues.padding08), + SailButton( + label: 'Finalize and Broadcast', + onPressed: null, + variant: ButtonVariant.secondary, + ), + ], + ), + ), + ], + ), + ), + ], ), ); } From 3f2cf037d109e2eae20152bb190ca5d373f15f9e Mon Sep 17 00:00:00 2001 From: Marc Platt Date: Sun, 20 Apr 2025 11:18:03 -0400 Subject: [PATCH 2/4] clients/bitwindow: basic create multisig modal --- bitwindow/lib/pages/wallet_page.dart | 8 +- .../lib/widgets/create_multisig_modal.dart | 606 +++++++++++++----- 2 files changed, 458 insertions(+), 156 deletions(-) diff --git a/bitwindow/lib/pages/wallet_page.dart b/bitwindow/lib/pages/wallet_page.dart index 13104f18f..c29301fac 100644 --- a/bitwindow/lib/pages/wallet_page.dart +++ b/bitwindow/lib/pages/wallet_page.dart @@ -35,6 +35,7 @@ import 'package:sail_ui/providers/balance_provider.dart'; import 'package:sail_ui/rpcs/bitwindow_api.dart'; import 'package:sail_ui/sail_ui.dart'; import 'package:stacked/stacked.dart'; +import 'package:bitwindow/widgets/create_multisig_modal.dart'; @RoutePage() class WalletPage extends StatelessWidget { @@ -2503,7 +2504,12 @@ class MultisigLoungeTab extends StatelessWidget { children: [ SailButton( label: 'Create New Lounge', - onPressed: null, + onPressed: () async { + showDialog( + context: context, + builder: (context) => const CreateMultisigModal(), + ); + }, variant: ButtonVariant.primary, ), ], diff --git a/bitwindow/lib/widgets/create_multisig_modal.dart b/bitwindow/lib/widgets/create_multisig_modal.dart index 2eb478574..e0266f1fb 100644 --- a/bitwindow/lib/widgets/create_multisig_modal.dart +++ b/bitwindow/lib/widgets/create_multisig_modal.dart @@ -1,7 +1,9 @@ import 'dart:io'; import 'package:bitwindow/env.dart'; +import 'package:bitwindow/providers/hd_wallet_provider.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:get_it/get_it.dart'; import 'package:logger/logger.dart'; import 'package:sail_ui/sail_ui.dart'; @@ -21,152 +23,264 @@ class CreateMultisigModal extends StatelessWidget { child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 800), child: SailCard( - title: 'Create ${model.n} of ${model.m} Multisig Lounge', - subtitle: 'Create a new multisig wallet with multiple participants', + title: model.isFirstPage + ? 'Create Multisig Lounge' + : 'Create ${model.n} of ${model.m} Multisig Lounge: ${model.loungeName}', + subtitle: model.isFirstPage + ? 'Configure your multisig lounge settings' + : 'Add keys for the participants', error: model.modelError, - child: Column( - mainAxisSize: MainAxisSize.min, + padding: true, + child: model.isFirstPage + ? _buildFirstPage(context, model) + : _buildSecondPage(context, model), + ), + ), + ); + }, + ); + } + + Widget _buildFirstPage(BuildContext context, CreateMultisigModalViewModel model) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Lounge Name text field + SailTextField( + controller: model.loungeNameController, + hintText: 'Enter lounge name', + ), + const SailSpacing(SailStyleValues.padding16), + + // N and M selection + SailRow( + spacing: SailStyleValues.padding16, + children: [ + Expanded( + child: SailColumn( + spacing: SailStyleValues.padding08, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // First key (automatically derived) - SailColumn( - spacing: SailStyleValues.padding08, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SailText.primary13('First Key (Your Key)'), - SailText.secondary12( - "Automatically derived from path m/44'/0'/0'/${7000 + model.nextP}", - color: context.sailTheme.colors.textTertiary, - ), - const SailSpacing(SailStyleValues.padding08), - SailTextField( - controller: model.firstKeyController, - readOnly: true, - hintText: 'Your public key will appear here', - ), - ], + SailText.primary13('Required Signatures (N)'), + SailDropdownButton( + value: model.n, + items: List.generate(model.m, (i) => i + 1) + .map( + (n) => SailDropdownItem( + value: n, + label: '$n', + ), + ) + .toList(), + onChanged: model.setN, ), - const SailSpacing(SailStyleValues.padding16), - - // Lounge Name - SailColumn( - spacing: SailStyleValues.padding08, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SailText.primary13('Lounge Name'), - SailText.secondary12( - 'Enter a name for this multisig lounge', - color: context.sailTheme.colors.textTertiary, - ), - const SailSpacing(SailStyleValues.padding08), - SailTextField( - controller: model.loungeNameController, - hintText: 'Enter lounge name', - ), - ], + ], + ), + ), + Expanded( + child: SailColumn( + spacing: SailStyleValues.padding08, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SailText.primary13('Total Participants (M)'), + SailDropdownButton( + value: model.m, + items: List.generate(15, (i) => i + 2) + .map( + (m) => SailDropdownItem( + value: m, + label: '$m', + ), + ) + .toList(), + onChanged: model.setM, ), - const SailSpacing(SailStyleValues.padding16), - - // N and M selection - SailRow( - spacing: SailStyleValues.padding16, - children: [ - Expanded( - child: SailColumn( - spacing: SailStyleValues.padding08, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SailText.primary13('Required Signatures (N)'), - SailDropdownButton( - value: model.n, - items: List.generate(model.m, (i) => i + 1) - .map( - (n) => SailDropdownItem( - value: n, - label: '$n', - ), - ) - .toList(), - onChanged: model.setN, - ), - ], - ), - ), - Expanded( - child: SailColumn( - spacing: SailStyleValues.padding08, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SailText.primary13('Total Participants (M)'), - SailDropdownButton( - value: model.m, - items: List.generate(15, (i) => i + 2) - .map( - (m) => SailDropdownItem( - value: m, - label: '$m', - ), - ) - .toList(), - onChanged: model.setM, - ), - ], - ), - ), - ], + ], + ), + ), + ], + ), + const SailSpacing(SailStyleValues.padding16), + + // Action buttons + SailRow( + spacing: SailStyleValues.padding08, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SailButton( + label: 'Next', + onPressed: model.canGoToNextPage ? () async => await model.goToSecondPage() : null, + variant: model.canGoToNextPage ? ButtonVariant.primary : ButtonVariant.secondary, + ), + SailButton( + label: 'Cancel', + variant: ButtonVariant.ghost, + onPressed: () async { + Navigator.of(context).pop(); + }, + ), + ], + ), + ], + ); + } + + Widget _buildSecondPage(BuildContext context, CreateMultisigModalViewModel model) { + final theme = SailTheme.of(context); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Key input area + SailRow( + spacing: SailStyleValues.padding08, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + SailButton( + label: 'Use My Key', + onPressed: () async => await model.useMyKey(), + variant: ButtonVariant.secondary, + ), + SizedBox( + width: 120, + child: Theme( + data: Theme.of(context).copyWith( + textSelectionTheme: TextSelectionThemeData( + selectionColor: theme.colors.primary.withValues(alpha: 0.2), ), - const SailSpacing(SailStyleValues.padding16), - - // Additional keys - ...List.generate( - model.m - 1, - (i) => Padding( - padding: const EdgeInsets.only(bottom: SailStyleValues.padding16), - child: SailColumn( - spacing: SailStyleValues.padding08, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SailText.primary13('Key ${i + 2}'), - SailTextField( - controller: model.additionalKeyControllers[i], - hintText: 'Enter public key', - ), - ], - ), + ), + child: TextField( + controller: model.keyNameController, + onChanged: model.validateKeyNameFormat, + maxLines: 1, + decoration: InputDecoration( + isDense: true, + errorText: model.keyNameError, + errorBorder: OutlineInputBorder( + borderRadius: SailStyleValues.borderRadius, + borderSide: BorderSide(color: theme.colors.error), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: SailStyleValues.borderRadius, + borderSide: BorderSide(color: theme.colors.error), + ), + enabledBorder: OutlineInputBorder( + borderRadius: SailStyleValues.borderRadius, + borderSide: BorderSide(color: theme.colors.border), + ), + disabledBorder: InputBorder.none, + focusedBorder: OutlineInputBorder( + borderRadius: SailStyleValues.borderRadius, + borderSide: BorderSide(color: theme.colors.text), + ), + hintText: 'Key Name', + fillColor: theme.colors.background, + filled: true, + contentPadding: const EdgeInsets.symmetric(vertical: 11.5, horizontal: 12), + hintStyle: SailStyleValues.thirteen.copyWith( + color: theme.colors.inactiveNavText, + fontSize: 12.0, ), ), - - // Action buttons - SailRow( - spacing: SailStyleValues.padding08, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - SailButton( - label: 'Create', - onPressed: model.canCreate ? () => model.create(context) : null, - ), - SailButton( - label: 'Cancel', - variant: ButtonVariant.ghost, - onPressed: () async { - Navigator.of(context).pop(); - }, - ), - ], + style: SailStyleValues.fifteen.copyWith( + color: theme.colors.text, + fontSize: 12.0, ), - ], + ), + ), + ), + Expanded( + flex: 3, + child: SailTextField( + controller: model.publicKeyController, + hintText: 'Public Key', + size: TextFieldSize.small, + maxLines: 1, + ), + ), + SailButton( + label: 'Lock', + onPressed: model.canLockKey ? () async => await model.lockKey() : null, + variant: model.canLockKey ? ButtonVariant.primary : ButtonVariant.secondary, + ), + ], + ), + if (model.publicKeyError != null) ...[ + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(top: 4.0, left: 128.0), + child: SailText.primary13( + model.publicKeyError!, + color: theme.colors.error, ), ), ), - ); - }, + ], + const SailSpacing(SailStyleValues.padding16), + + // Added keys display + if (model.keys.isNotEmpty) ...[ + Align( + alignment: Alignment.centerLeft, + child: SailText.primary13('Added Keys (${model.keys.length}/${model.m})'), + ), + const SailSpacing(SailStyleValues.padding08), + Wrap( + spacing: 8, + runSpacing: 8, + children: model.keys.map((key) { + return SailButton( + label: key.name, + onPressed: () async => await model.showKeyDetails(context, key), + variant: ButtonVariant.secondary, + ); + }).toList(), + ), + const SailSpacing(SailStyleValues.padding16), + ], + + // Action buttons + SailRow( + spacing: SailStyleValues.padding08, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SailButton( + label: 'Save', + onPressed: model.canCreate ? () async => await model.create(context) : null, + variant: model.canCreate ? ButtonVariant.primary : ButtonVariant.secondary, + ), + SailButton( + label: 'Back', + variant: ButtonVariant.secondary, + onPressed: () async => await model.goToFirstPage(), + ), + SailButton( + label: 'Cancel', + variant: ButtonVariant.ghost, + onPressed: () async { + Navigator.of(context).pop(); + }, + ), + ], + ), + ], ); } } +class MultisigKey { + final String name; + final String publicKey; + + MultisigKey({required this.name, required this.publicKey}); +} + class CreateMultisigModalViewModel extends BaseViewModel { - final TextEditingController firstKeyController = TextEditingController(); final TextEditingController loungeNameController = TextEditingController(); - final List additionalKeyControllers = []; + final TextEditingController keyNameController = TextEditingController(); + final TextEditingController publicKeyController = TextEditingController(); + final HDWalletProvider hdWalletProvider = GetIt.I.get(); Logger get log => GetIt.I.get(); int n = 2; // Required signatures @@ -174,9 +288,24 @@ class CreateMultisigModalViewModel extends BaseViewModel { int nextP = 0; String? _multisigDir; String? _configPath; - - bool get canCreate => - loungeNameController.text.isNotEmpty && additionalKeyControllers.every((c) => c.text.isNotEmpty); + bool isFirstPage = true; + List keys = []; + String? keyNameError; + String? publicKeyError; + + String get loungeName => loungeNameController.text; + + bool get canLockKey => + keyNameController.text.isNotEmpty && + publicKeyController.text.isNotEmpty && + keyNameError == null; + + bool get canCreate => keys.length == m; + + bool get canGoToNextPage => loungeNameController.text.trim().isNotEmpty; + + // Define the multisig derivation path base + String get derivationPath => "m/44'/0'/0'/${7000 + nextP}"; Future init() async { try { @@ -199,9 +328,14 @@ class CreateMultisigModalViewModel extends BaseViewModel { // Parse config to find next P nextP = await _getNextP(); - - // Initialize controllers - _updateControllers(); + + // Load the HD wallet provider + await hdWalletProvider.init(); + + // Add listener to text controller to update UI when text changes + loungeNameController.addListener(() { + notifyListeners(); + }); notifyListeners(); } catch (e) { @@ -241,33 +375,192 @@ class CreateMultisigModalViewModel extends BaseViewModel { } } - void _updateControllers() { - // Clear existing controllers - for (final controller in additionalKeyControllers) { - controller.dispose(); + void setN(int? value) { + if (value != null && value <= m) { + n = value; + notifyListeners(); } - additionalKeyControllers.clear(); + } - // Add new controllers - for (int i = 0; i < m - 1; i++) { - additionalKeyControllers.add(TextEditingController()); + void setM(int? value) { + if (value != null) { + m = value; + // Adjust n if it's now greater than m + if (n > m) { + n = m; + } + keys = []; + notifyListeners(); } + } + void validateKeyNameFormat(String value) { + // Only validate the format (spaces), not existence in the list + if (value.contains(' ')) { + keyNameError = 'Spaces not allowed'; + } else { + keyNameError = null; + } notifyListeners(); } + + // Validate both key name and public key uniqueness before locking + bool validateKeyUniqueness() { + final keyName = keyNameController.text; + final publicKey = publicKeyController.text; + + // Check for key name uniqueness + if (keys.any((key) => key.name == keyName)) { + keyNameError = 'Name already used'; + publicKeyError = null; + notifyListeners(); + return false; + } + + // Check for key public key uniqueness + if (keys.any((key) => key.publicKey == publicKey)) { + keyNameError = null; + publicKeyError = 'Key already added to this multisig'; + notifyListeners(); + return false; + } + + // All validations passed + keyNameError = null; + publicKeyError = null; + notifyListeners(); + return true; + } - void setN(int? value) { - if (value != null && value <= m) { - n = value; + Future goToSecondPage() async { + if (!canGoToNextPage) return; + + isFirstPage = false; + + // Prepare the key list if empty + if (keys.isEmpty) { + // Add default key if needed + keyNameController.clear(); + publicKeyController.clear(); + keyNameError = null; + publicKeyError = null; + } + + notifyListeners(); + } + + Future goToFirstPage() async { + isFirstPage = true; + notifyListeners(); + } + + Future useMyKey() async { + try { + setBusy(true); + + // First, make sure wallet is loaded + await hdWalletProvider.loadMnemonic(); + if (hdWalletProvider.mnemonic == null) { + throw Exception("Couldn't load wallet mnemonic"); + } + + // Derive the public key from the multisig path + final keyInfo = await hdWalletProvider.deriveKeyInfo( + hdWalletProvider.mnemonic!, + derivationPath + ); + + // Get the derived public key + final pubKey = keyInfo['publicKey']; + if (pubKey == null || pubKey.isEmpty) { + throw Exception("Failed to derive public key"); + } + + // Set the derived key info (validation will happen on lock) + keyNameController.text = "MyKey"; + publicKeyController.text = pubKey; + + // Clear any previous error + keyNameError = null; + publicKeyError = null; + notifyListeners(); + } catch (e) { + log.e('Error deriving key: $e'); + setError(e.toString()); + } finally { + setBusy(false); } } - void setM(int? value) { - if (value != null && value >= n) { - m = value; - _updateControllers(); + Future lockKey() async { + if (!canLockKey) return; + + // Validate uniqueness of both name and public key + if (!validateKeyUniqueness()) { + return; } + + // All validations passed, add the key + keys.add(MultisigKey( + name: keyNameController.text, + publicKey: publicKeyController.text, + )); + + // Clear input fields + keyNameController.clear(); + publicKeyController.clear(); + keyNameError = null; + publicKeyError = null; + + notifyListeners(); + } + + Future showKeyDetails(BuildContext context, MultisigKey key) async { + await showDialog( + context: context, + builder: (context) => Dialog( + backgroundColor: Colors.transparent, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: SailCard( + title: key.name, + subtitle: 'Public Key Details', + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SailText.secondary13(key.publicKey, monospace: true), + const SailSpacing(SailStyleValues.padding16), + SailRow( + spacing: SailStyleValues.padding08, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SailButton( + label: 'Copy', + onPressed: () async { + await Clipboard.setData(ClipboardData(text: key.publicKey)); + if (context.mounted) { + showSnackBar(context, 'Public key copied to clipboard'); + } + }, + variant: ButtonVariant.secondary, + ), + SailButton( + label: 'Close', + onPressed: () async { + Navigator.of(context).pop(); + }, + variant: ButtonVariant.primary, + ), + ], + ), + ], + ), + ), + ), + ), + ); } Future create(BuildContext context) async { @@ -288,9 +581,13 @@ class CreateMultisigModalViewModel extends BaseViewModel { mode: FileMode.append, ); - // TODO: Implement key derivation and multisig creation logic + // Store the public keys in a file as well + final keysFile = File('$_multisigDir/P$nextP.keys'); + final keysContent = keys.map((k) => '${k.name}=${k.publicKey}').join('\n'); + await keysFile.writeAsString(keysContent); if (context.mounted) { + showSnackBar(context, 'Multisig lounge created successfully'); Navigator.of(context).pop(); } } catch (e) { @@ -303,11 +600,10 @@ class CreateMultisigModalViewModel extends BaseViewModel { @override void dispose() { - firstKeyController.dispose(); + loungeNameController.removeListener(notifyListeners); loungeNameController.dispose(); - for (final controller in additionalKeyControllers) { - controller.dispose(); - } + keyNameController.dispose(); + publicKeyController.dispose(); super.dispose(); } } From b5884523bd10c33659fa81c86dd211d3e3479b26 Mon Sep 17 00:00:00 2001 From: Marc Platt Date: Wed, 23 Apr 2025 15:26:03 -0400 Subject: [PATCH 3/4] bitwindow: fix lint --- bitwindow/lib/widgets/create_multisig_modal.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bitwindow/lib/widgets/create_multisig_modal.dart b/bitwindow/lib/widgets/create_multisig_modal.dart index e0266f1fb..1b2e161cf 100644 --- a/bitwindow/lib/widgets/create_multisig_modal.dart +++ b/bitwindow/lib/widgets/create_multisig_modal.dart @@ -467,17 +467,17 @@ class CreateMultisigModalViewModel extends BaseViewModel { // Derive the public key from the multisig path final keyInfo = await hdWalletProvider.deriveKeyInfo( hdWalletProvider.mnemonic!, - derivationPath + derivationPath, ); // Get the derived public key final pubKey = keyInfo['publicKey']; if (pubKey == null || pubKey.isEmpty) { - throw Exception("Failed to derive public key"); + throw Exception('Failed to derive public key'); } // Set the derived key info (validation will happen on lock) - keyNameController.text = "MyKey"; + keyNameController.text = 'MyKey'; publicKeyController.text = pubKey; // Clear any previous error @@ -505,7 +505,7 @@ class CreateMultisigModalViewModel extends BaseViewModel { keys.add(MultisigKey( name: keyNameController.text, publicKey: publicKeyController.text, - )); + ),); // Clear input fields keyNameController.clear(); From a6c2cf95fd60fba1134f52917f1624b2aa926028 Mon Sep 17 00:00:00 2001 From: Marc Platt Date: Fri, 25 Apr 2025 15:42:03 -0400 Subject: [PATCH 4/4] bitwindow/multisig: write multisig conf to op return --- .../lib/providers/bitdrive_provider.dart | 133 +++++++- .../lib/widgets/create_multisig_modal.dart | 289 +++++++++--------- 2 files changed, 267 insertions(+), 155 deletions(-) diff --git a/bitwindow/lib/providers/bitdrive_provider.dart b/bitwindow/lib/providers/bitdrive_provider.dart index 0618d9acb..cf6027400 100644 --- a/bitwindow/lib/providers/bitdrive_provider.dart +++ b/bitwindow/lib/providers/bitdrive_provider.dart @@ -75,6 +75,13 @@ class BitDriveProvider extends ChangeNotifier { static const String AUTH_KEY_PATH = "m/44'/0'/0'/$BITDRIVE_DERIVATION_INDEX/1"; static const int AUTH_TAG_SIZE = 8; // 8 bytes auth tag + // File type flags (encoded in first byte of metadata) + static const int FLAG_STANDARD = 0; // 0000 0000 + static const int FLAG_ENCRYPTED = 1; // 0000 0001 + static const int FLAG_MULTISIG = 2; // 0000 0010 + // Combined flags + static const int FLAG_ENCRYPTED_MULTISIG = 3; // 0000 0011 (ENCRYPTED | MULTISIG) + BitDriveProvider() { // Listen for blockchain sync status changes blockchainProvider.addListener(_onSyncStatusChanged); @@ -270,7 +277,9 @@ class BitDriveProvider extends ChangeNotifier { if (metadataBytes.length != 9) continue; final metadata = ByteData.view(Uint8List.fromList(metadataBytes).buffer); - final isEncrypted = metadata.getUint8(0) == 1; + final flagByte = metadata.getUint8(0); + final isEncrypted = (flagByte & FLAG_ENCRYPTED) != 0; + final isMultisig = (flagByte & FLAG_MULTISIG) != 0; final timestamp = metadata.getUint32(1); final fileType = utf8.decode(metadataBytes.sublist(5, 9)).trim(); @@ -283,6 +292,107 @@ class BitDriveProvider extends ChangeNotifier { final content = isEncrypted ? await _decryptContent(contentBytes, timestamp, fileType) : contentBytes; await file.writeAsBytes(content); + + // Process multisig configuration files using the dedicated flag + if (isMultisig && fileType == 'txt' || fileType == 'jsn' || fileType == 'jso') { + try { + final contentStr = utf8.decode(content); + final json = jsonDecode(contentStr); + + // Check if this is a valid multisig configuration + if (json is Map && json.containsKey('p') && json.containsKey('name') && json.containsKey('keys')) { + log.i('BitDrive: Found multisig configuration (flagged)'); + + // Ensure multisig directory exists + final multisigDir = path.join(_bitdriveDir!, 'multisig'); + final dir = Directory(multisigDir); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + // Write multisig configuration to multisig directory + final p = json['p']; + final name = json['name']; + + // Add entry to config file + final configFile = File(path.join(multisigDir, 'multisig.conf')); + if (!await configFile.exists()) { + await configFile.writeAsString(''); + } + + // Append the configuration if it doesn't already exist + final configContent = await configFile.readAsString(); + if (!configContent.contains('P$p=')) { + await configFile.writeAsString( + 'P$p="$name"\n', + mode: FileMode.append, + ); + + // Store keys + if (json['keys'] is List) { + final keysFile = File(path.join(multisigDir, 'P$p.keys')); + final keysList = (json['keys'] as List).cast>(); + final keysContent = keysList.map((k) => '${k['name']}=${k['publicKey']}').join('\n'); + await keysFile.writeAsString(keysContent); + } + + log.i('BitDrive: Restored multisig configuration P$p'); + } + } + } catch (e) { + log.w('BitDrive: Error processing multisig file: $e'); + // Continue if there's an error parsing + } + } + // For backward compatibility, also check unflagged txt files for JSON structure + else if (!isMultisig && fileType == 'txt') { + try { + final contentStr = utf8.decode(content); + if (contentStr.trim().startsWith('{') && contentStr.trim().endsWith('}')) { + final json = jsonDecode(contentStr); + + // Check if this is a valid multisig configuration + if (json is Map && json.containsKey('p') && json.containsKey('name') && json.containsKey('keys')) { + log.i('BitDrive: Found multisig configuration (unflagged)'); + + // Process multisig config (same code as above) + final multisigDir = path.join(_bitdriveDir!, 'multisig'); + final dir = Directory(multisigDir); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + final p = json['p']; + final name = json['name']; + + final configFile = File(path.join(multisigDir, 'multisig.conf')); + if (!await configFile.exists()) { + await configFile.writeAsString(''); + } + + final configContent = await configFile.readAsString(); + if (!configContent.contains('P$p=')) { + await configFile.writeAsString( + 'P$p="$name"\n', + mode: FileMode.append, + ); + + if (json['keys'] is List) { + final keysFile = File(path.join(multisigDir, 'P$p.keys')); + final keysList = (json['keys'] as List).cast>(); + final keysContent = keysList.map((k) => '${k['name']}=${k['publicKey']}').join('\n'); + await keysFile.writeAsString(keysContent); + } + + log.i('BitDrive: Restored multisig configuration P$p'); + } + } + } + } catch (e) { + // Not a JSON file or not a multisig configuration - ignore + } + } + restoredCount++; } catch (e) { log.e('BitDrive: Error processing tx ${tx.txid}: $e'); @@ -312,7 +422,7 @@ class BitDriveProvider extends ChangeNotifier { notifyListeners(); } - Future setTextContent(String content) async { + Future setTextContent(String content, {bool isMultisig = false}) async { if (content.length > 1024 * 1024) { error = 'Text size must be less than 1MB'; notifyListeners(); @@ -320,7 +430,7 @@ class BitDriveProvider extends ChangeNotifier { } textContent = content; fileContent = null; - fileName = 'text.txt'; + fileName = isMultisig ? 'multisig.json' : 'text.txt'; mimeType = 'text/plain'; error = null; notifyListeners(); @@ -336,7 +446,7 @@ class BitDriveProvider extends ChangeNotifier { notifyListeners(); } - Future store() async { + Future store({bool isMultisig = false}) async { if (_isFetching) return; _isFetching = true; error = null; @@ -352,7 +462,12 @@ class BitDriveProvider extends ChangeNotifier { final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; final fileType = _detectFileType(content); - metadata.setUint8(0, shouldEncrypt ? 1 : 0); + // Set flag byte - encode both encryption and multisig flags + int flagByte = FLAG_STANDARD; + if (shouldEncrypt) flagByte |= FLAG_ENCRYPTED; + if (isMultisig) flagByte |= FLAG_MULTISIG; + + metadata.setUint8(0, flagByte); metadata.setUint32(1, timestamp); final typeBytes = utf8.encode(fileType.padRight(4, ' ')); @@ -423,7 +538,9 @@ class BitDriveProvider extends ChangeNotifier { } final metadata = ByteData.view(Uint8List.fromList(metadataBytes).buffer); - final isEncrypted = metadata.getUint8(0) == 1; + final flagByte = metadata.getUint8(0); + final isEncrypted = (flagByte & FLAG_ENCRYPTED) != 0; + final isMultisig = (flagByte & FLAG_MULTISIG) != 0; final timestamp = metadata.getUint32(1); final fileType = utf8.decode(metadataBytes.sublist(5, 9)).trim(); @@ -533,7 +650,9 @@ class BitDriveProvider extends ChangeNotifier { if (metadataBytes.length != 9) continue; final metadata = ByteData.view(Uint8List.fromList(metadataBytes).buffer); - final isEncrypted = metadata.getUint8(0) == 1; + final flagByte = metadata.getUint8(0); + final isEncrypted = (flagByte & FLAG_ENCRYPTED) != 0; + final isMultisig = (flagByte & FLAG_MULTISIG) != 0; final timestamp = metadata.getUint32(1); final fileType = utf8.decode(metadataBytes.sublist(5, 9)).trim(); diff --git a/bitwindow/lib/widgets/create_multisig_modal.dart b/bitwindow/lib/widgets/create_multisig_modal.dart index 1b2e161cf..dcde80a0e 100644 --- a/bitwindow/lib/widgets/create_multisig_modal.dart +++ b/bitwindow/lib/widgets/create_multisig_modal.dart @@ -1,7 +1,9 @@ import 'dart:io'; +import 'dart:convert'; import 'package:bitwindow/env.dart'; import 'package:bitwindow/providers/hd_wallet_provider.dart'; +import 'package:bitwindow/providers/bitdrive_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get_it/get_it.dart'; @@ -23,17 +25,13 @@ class CreateMultisigModal extends StatelessWidget { child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 800), child: SailCard( - title: model.isFirstPage - ? 'Create Multisig Lounge' - : 'Create ${model.n} of ${model.m} Multisig Lounge: ${model.loungeName}', - subtitle: model.isFirstPage - ? 'Configure your multisig lounge settings' - : 'Add keys for the participants', + title: model.isFirstPage + ? 'Create Multisig Lounge' + : 'Create ${model.n} of ${model.m} Multisig Lounge: ${model.loungeName}', + subtitle: model.isFirstPage ? 'Configure your multisig lounge settings' : 'Add keys for the participants', error: model.modelError, padding: true, - child: model.isFirstPage - ? _buildFirstPage(context, model) - : _buildSecondPage(context, model), + child: model.isFirstPage ? _buildFirstPage(context, model) : _buildSecondPage(context, model), ), ), ); @@ -41,7 +39,10 @@ class CreateMultisigModal extends StatelessWidget { ); } - Widget _buildFirstPage(BuildContext context, CreateMultisigModalViewModel model) { + Widget _buildFirstPage( + BuildContext context, + CreateMultisigModalViewModel model, + ) { return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -66,10 +67,7 @@ class CreateMultisigModal extends StatelessWidget { value: model.n, items: List.generate(model.m, (i) => i + 1) .map( - (n) => SailDropdownItem( - value: n, - label: '$n', - ), + (n) => SailDropdownItem(value: n, label: '$n'), ) .toList(), onChanged: model.setN, @@ -87,10 +85,7 @@ class CreateMultisigModal extends StatelessWidget { value: model.m, items: List.generate(15, (i) => i + 2) .map( - (m) => SailDropdownItem( - value: m, - label: '$m', - ), + (m) => SailDropdownItem(value: m, label: '$m'), ) .toList(), onChanged: model.setM, @@ -125,9 +120,12 @@ class CreateMultisigModal extends StatelessWidget { ); } - Widget _buildSecondPage(BuildContext context, CreateMultisigModalViewModel model) { + Widget _buildSecondPage( + BuildContext context, + CreateMultisigModalViewModel model, + ) { final theme = SailTheme.of(context); - + return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -143,50 +141,11 @@ class CreateMultisigModal extends StatelessWidget { ), SizedBox( width: 120, - child: Theme( - data: Theme.of(context).copyWith( - textSelectionTheme: TextSelectionThemeData( - selectionColor: theme.colors.primary.withValues(alpha: 0.2), - ), - ), - child: TextField( - controller: model.keyNameController, - onChanged: model.validateKeyNameFormat, - maxLines: 1, - decoration: InputDecoration( - isDense: true, - errorText: model.keyNameError, - errorBorder: OutlineInputBorder( - borderRadius: SailStyleValues.borderRadius, - borderSide: BorderSide(color: theme.colors.error), - ), - focusedErrorBorder: OutlineInputBorder( - borderRadius: SailStyleValues.borderRadius, - borderSide: BorderSide(color: theme.colors.error), - ), - enabledBorder: OutlineInputBorder( - borderRadius: SailStyleValues.borderRadius, - borderSide: BorderSide(color: theme.colors.border), - ), - disabledBorder: InputBorder.none, - focusedBorder: OutlineInputBorder( - borderRadius: SailStyleValues.borderRadius, - borderSide: BorderSide(color: theme.colors.text), - ), - hintText: 'Key Name', - fillColor: theme.colors.background, - filled: true, - contentPadding: const EdgeInsets.symmetric(vertical: 11.5, horizontal: 12), - hintStyle: SailStyleValues.thirteen.copyWith( - color: theme.colors.inactiveNavText, - fontSize: 12.0, - ), - ), - style: SailStyleValues.fifteen.copyWith( - color: theme.colors.text, - fontSize: 12.0, - ), - ), + child: SailTextField( + controller: model.keyNameController, + hintText: 'Key Name', + size: TextFieldSize.small, + maxLines: 1, ), ), Expanded( @@ -205,6 +164,18 @@ class CreateMultisigModal extends StatelessWidget { ), ], ), + if (model.keyNameError != null) ...[ + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(top: 4.0, left: 128.0), + child: SailText.primary13( + model.keyNameError!, + color: theme.colors.error, + ), + ), + ), + ], if (model.publicKeyError != null) ...[ Align( alignment: Alignment.centerLeft, @@ -218,12 +189,14 @@ class CreateMultisigModal extends StatelessWidget { ), ], const SailSpacing(SailStyleValues.padding16), - + // Added keys display if (model.keys.isNotEmpty) ...[ Align( alignment: Alignment.centerLeft, - child: SailText.primary13('Added Keys (${model.keys.length}/${model.m})'), + child: SailText.primary13( + 'Added Keys (${model.keys.length}/${model.m})', + ), ), const SailSpacing(SailStyleValues.padding08), Wrap( @@ -239,7 +212,7 @@ class CreateMultisigModal extends StatelessWidget { ), const SailSpacing(SailStyleValues.padding16), ], - + // Action buttons SailRow( spacing: SailStyleValues.padding08, @@ -281,62 +254,51 @@ class CreateMultisigModalViewModel extends BaseViewModel { final TextEditingController keyNameController = TextEditingController(); final TextEditingController publicKeyController = TextEditingController(); final HDWalletProvider hdWalletProvider = GetIt.I.get(); + final BitDriveProvider bitDriveProvider = GetIt.I.get(); Logger get log => GetIt.I.get(); int n = 2; // Required signatures int m = 2; // Total participants int nextP = 0; - String? _multisigDir; - String? _configPath; bool isFirstPage = true; List keys = []; String? keyNameError; String? publicKeyError; - + String get loungeName => loungeNameController.text; - - bool get canLockKey => - keyNameController.text.isNotEmpty && - publicKeyController.text.isNotEmpty && - keyNameError == null; + + bool get canLockKey => + keyNameController.text.isNotEmpty && publicKeyController.text.isNotEmpty && keyNameError == null; bool get canCreate => keys.length == m; - + bool get canGoToNextPage => loungeNameController.text.trim().isNotEmpty; - + // Define the multisig derivation path base String get derivationPath => "m/44'/0'/0'/${7000 + nextP}"; Future init() async { try { - // Initialize directories - final appDir = await Environment.datadir(); - _multisigDir = '${appDir.path}/bitdrive/multisig'; - _configPath = '$_multisigDir/multisig.conf'; - - // Create directory if it doesn't exist - final dir = Directory(_multisigDir!); - if (!await dir.exists()) { - await dir.create(recursive: true); - } + // Load the HD wallet provider + await hdWalletProvider.init(); - // Read or create config file - final configFile = File(_configPath!); - if (!await configFile.exists()) { - await configFile.writeAsString(''); + // Initialize BitDrive if needed + if (!bitDriveProvider.initialized) { + await bitDriveProvider.init(); } - // Parse config to find next P + // Find the next available P value nextP = await _getNextP(); - - // Load the HD wallet provider - await hdWalletProvider.init(); - - // Add listener to text controller to update UI when text changes + + // Add listener to text controllers to update UI when text changes loungeNameController.addListener(() { notifyListeners(); }); + keyNameController.addListener(() { + validateKeyNameFormat(keyNameController.text); + }); + notifyListeners(); } catch (e) { log.e('Error initializing multisig modal: $e'); @@ -346,25 +308,35 @@ class CreateMultisigModalViewModel extends BaseViewModel { Future _getNextP() async { try { - final file = File(_configPath!); - final lines = await file.readAsLines(); - - // Find highest P value + // Query BitDrive files for existing configurations int maxP = -1; - for (final line in lines) { - if (line.trim().isEmpty) continue; - - final parts = line.split('='); - if (parts.length != 2) continue; - - final pStr = parts[0].trim(); - if (!pStr.startsWith('P')) continue; - try { - final p = int.parse(pStr.substring(1)); - maxP = p > maxP ? p : maxP; - } catch (_) { - continue; + final appDir = await Environment.datadir(); + final multisigDir = '${appDir.path}/bitdrive/multisig'; + final dir = Directory(multisigDir); + + if (await dir.exists()) { + final configFile = File('$multisigDir/multisig.conf'); + if (await configFile.exists()) { + final lines = await configFile.readAsLines(); + + // Find highest P value + for (final line in lines) { + if (line.trim().isEmpty) continue; + + final parts = line.split('='); + if (parts.length != 2) continue; + + final pStr = parts[0].trim(); + if (!pStr.startsWith('P')) continue; + + try { + final p = int.parse(pStr.substring(1)); + maxP = p > maxP ? p : maxP; + } catch (_) { + continue; + } + } } } @@ -403,12 +375,12 @@ class CreateMultisigModalViewModel extends BaseViewModel { } notifyListeners(); } - + // Validate both key name and public key uniqueness before locking bool validateKeyUniqueness() { final keyName = keyNameController.text; final publicKey = publicKeyController.text; - + // Check for key name uniqueness if (keys.any((key) => key.name == keyName)) { keyNameError = 'Name already used'; @@ -416,7 +388,7 @@ class CreateMultisigModalViewModel extends BaseViewModel { notifyListeners(); return false; } - + // Check for key public key uniqueness if (keys.any((key) => key.publicKey == publicKey)) { keyNameError = null; @@ -424,7 +396,7 @@ class CreateMultisigModalViewModel extends BaseViewModel { notifyListeners(); return false; } - + // All validations passed keyNameError = null; publicKeyError = null; @@ -434,9 +406,9 @@ class CreateMultisigModalViewModel extends BaseViewModel { Future goToSecondPage() async { if (!canGoToNextPage) return; - + isFirstPage = false; - + // Prepare the key list if empty if (keys.isEmpty) { // Add default key if needed @@ -445,7 +417,7 @@ class CreateMultisigModalViewModel extends BaseViewModel { keyNameError = null; publicKeyError = null; } - + notifyListeners(); } @@ -457,33 +429,33 @@ class CreateMultisigModalViewModel extends BaseViewModel { Future useMyKey() async { try { setBusy(true); - + // First, make sure wallet is loaded await hdWalletProvider.loadMnemonic(); if (hdWalletProvider.mnemonic == null) { throw Exception("Couldn't load wallet mnemonic"); } - + // Derive the public key from the multisig path final keyInfo = await hdWalletProvider.deriveKeyInfo( - hdWalletProvider.mnemonic!, + hdWalletProvider.mnemonic!, derivationPath, ); - + // Get the derived public key final pubKey = keyInfo['publicKey']; if (pubKey == null || pubKey.isEmpty) { throw Exception('Failed to derive public key'); } - + // Set the derived key info (validation will happen on lock) keyNameController.text = 'MyKey'; publicKeyController.text = pubKey; - + // Clear any previous error keyNameError = null; publicKeyError = null; - + notifyListeners(); } catch (e) { log.e('Error deriving key: $e'); @@ -495,24 +467,26 @@ class CreateMultisigModalViewModel extends BaseViewModel { Future lockKey() async { if (!canLockKey) return; - + // Validate uniqueness of both name and public key if (!validateKeyUniqueness()) { return; } - + // All validations passed, add the key - keys.add(MultisigKey( - name: keyNameController.text, - publicKey: publicKeyController.text, - ),); - + keys.add( + MultisigKey( + name: keyNameController.text, + publicKey: publicKeyController.text, + ), + ); + // Clear input fields keyNameController.clear(); publicKeyController.clear(); keyNameError = null; publicKeyError = null; - + notifyListeners(); } @@ -539,9 +513,14 @@ class CreateMultisigModalViewModel extends BaseViewModel { SailButton( label: 'Copy', onPressed: () async { - await Clipboard.setData(ClipboardData(text: key.publicKey)); + await Clipboard.setData( + ClipboardData(text: key.publicKey), + ); if (context.mounted) { - showSnackBar(context, 'Public key copied to clipboard'); + showSnackBar( + context, + 'Public key copied to clipboard', + ); } }, variant: ButtonVariant.secondary, @@ -574,17 +553,30 @@ class CreateMultisigModalViewModel extends BaseViewModel { throw Exception('Lounge name cannot be empty'); } - // Add entry to config file - final file = File(_configPath!); - await file.writeAsString( - 'P$nextP="$name"\n', - mode: FileMode.append, - ); + // Create multisig config in JSON format + final configData = { + 'p': nextP, + 'name': name, + 'n': n, + 'm': m, + 'keys': keys.map((k) => {'name': k.name, 'publicKey': k.publicKey}).toList(), + }; + + final jsonConfig = jsonEncode(configData); + + // Store the configuration using BitDrive with multisig flag + log.i('Storing multisig config in BitDrive'); + await bitDriveProvider.setTextContent(jsonConfig, isMultisig: true); + bitDriveProvider.setEncryption(false); // Don't encrypt to allow for easier recovery + await bitDriveProvider.store(isMultisig: true); - // Store the public keys in a file as well - final keysFile = File('$_multisigDir/P$nextP.keys'); - final keysContent = keys.map((k) => '${k.name}=${k.publicKey}').join('\n'); - await keysFile.writeAsString(keysContent); + // Create multisig directory if it doesn't exist (for autoRestore) + final appDir = await Environment.datadir(); + final multisigDir = '${appDir.path}/bitdrive/multisig'; + final dir = Directory(multisigDir); + if (!await dir.exists()) { + await dir.create(recursive: true); + } if (context.mounted) { showSnackBar(context, 'Multisig lounge created successfully'); @@ -602,6 +594,7 @@ class CreateMultisigModalViewModel extends BaseViewModel { void dispose() { loungeNameController.removeListener(notifyListeners); loungeNameController.dispose(); + keyNameController.removeListener(() => validateKeyNameFormat(keyNameController.text)); keyNameController.dispose(); publicKeyController.dispose(); super.dispose();