diff --git a/lib/services/frost.dart b/lib/services/frost.dart new file mode 100644 index 000000000..a420a5b16 --- /dev/null +++ b/lib/services/frost.dart @@ -0,0 +1,613 @@ +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:frostdart/frostdart.dart'; +import 'package:frostdart/frostdart_bindings_generated.dart'; +import 'package:frostdart/output.dart'; +import 'package:frostdart/util.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/utxo.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/extensions/extensions.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +abstract class Frost { + //==================== utility =============================================== + static List getParticipants({ + required String multisigConfig, + }) { + try { + final numberOfParticipants = multisigParticipants( + multisigConfig: multisigConfig, + ); + + final List participants = []; + for (int i = 0; i < numberOfParticipants; i++) { + participants.add( + multisigParticipant( + multisigConfig: multisigConfig, + index: i, + ), + ); + } + + return participants; + } catch (e, s) { + Logging.instance.log( + "getParticipants failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static bool validateEncodedMultisigConfig({required String encodedConfig}) { + try { + decodeMultisigConfig(multisigConfig: encodedConfig); + return true; + } catch (e, s) { + Logging.instance.log( + "validateEncodedMultisigConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + return false; + } + } + + static int getThreshold({ + required String multisigConfig, + }) { + try { + final threshold = multisigThreshold( + multisigConfig: multisigConfig, + ); + + return threshold; + } catch (e, s) { + Logging.instance.log( + "getThreshold failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + List<({String address, Amount amount})> recipients, + String changeAddress, + int feePerWeight, + List inputs, + }) extractDataFromSignConfig({ + required String signConfig, + required CryptoCurrency coin, + }) { + try { + final network = coin.network == CryptoCurrencyNetwork.test + ? Network.Testnet + : Network.Mainnet; + final signConfigPointer = decodedSignConfig( + encodedConfig: signConfig, + network: network, + ); + + // get various data from config + final feePerWeight = + signFeePerWeight(signConfigPointer: signConfigPointer); + final changeAddress = signChange(signConfigPointer: signConfigPointer); + final recipientsCount = signPayments( + signConfigPointer: signConfigPointer, + ); + + // get tx recipient info + final List<({String address, Amount amount})> recipients = []; + for (int i = 0; i < recipientsCount; i++) { + final String address = signPaymentAddress( + signConfigPointer: signConfigPointer, + index: i, + ); + final int amount = signPaymentAmount( + signConfigPointer: signConfigPointer, + index: i, + ); + recipients.add( + ( + address: address, + amount: Amount( + rawValue: BigInt.from(amount), + fractionDigits: coin.fractionDigits, + ), + ), + ); + } + + // get utxos + final count = signInputs(signConfigPointer: signConfigPointer); + final List outputs = []; + for (int i = 0; i < count; i++) { + final output = signInput( + signConfig: signConfig, + index: i, + network: network, + ); + + outputs.add(output); + } + + return ( + recipients: recipients, + changeAddress: changeAddress, + feePerWeight: feePerWeight, + inputs: outputs, + ); + } catch (e, s) { + Logging.instance.log( + "extractDataFromSignConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + //==================== wallet creation ======================================= + + static String createMultisigConfig({ + required String name, + required int threshold, + required List participants, + }) { + try { + final config = newMultisigConfig( + name: name, + threshold: threshold, + participants: participants, + ); + + return config; + } catch (e, s) { + Logging.instance.log( + "createMultisigConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + String seed, + String commitments, + Pointer multisigConfigWithNamePtr, + Pointer secretShareMachineWrapperPtr, + }) startKeyGeneration({ + required String multisigConfig, + required String myName, + }) { + try { + final startKeyGenResPtr = startKeyGen( + multisigConfig: multisigConfig, + myName: myName, + language: Language.english, + ); + + final seed = startKeyGenResPtr.ref.seed.toDartString(); + final commitments = startKeyGenResPtr.ref.commitments.toDartString(); + final configWithNamePtr = startKeyGenResPtr.ref.config; + final machinePtr = startKeyGenResPtr.ref.machine; + + return ( + seed: seed, + commitments: commitments, + multisigConfigWithNamePtr: configWithNamePtr, + secretShareMachineWrapperPtr: machinePtr, + ); + } catch (e, s) { + Logging.instance.log( + "startKeyGeneration failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + String share, + Pointer secretSharesResPtr, + }) generateSecretShares({ + required Pointer multisigConfigWithNamePtr, + required String mySeed, + required Pointer secretShareMachineWrapperPtr, + required List commitments, + }) { + try { + final secretSharesResPtr = getSecretShares( + multisigConfigWithName: multisigConfigWithNamePtr, + seed: mySeed, + language: Language.english, + machine: secretShareMachineWrapperPtr, + commitments: commitments, + ); + + final share = secretSharesResPtr.ref.shares.toDartString(); + + return (share: share, secretSharesResPtr: secretSharesResPtr); + } catch (e, s) { + Logging.instance.log( + "generateSecretShares failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + Uint8List multisigId, + String recoveryString, + String serializedKeys, + }) completeKeyGeneration({ + required Pointer multisigConfigWithNamePtr, + required Pointer secretSharesResPtr, + required List shares, + }) { + try { + final keyGenResPtr = completeKeyGen( + multisigConfigWithName: multisigConfigWithNamePtr, + machineAndCommitments: secretSharesResPtr, + shares: shares, + ); + + final id = Uint8List.fromList( + List.generate( + MULTISIG_ID_LENGTH, + (index) => keyGenResPtr.ref.multisig_id[index], + ), + ); + + final recoveryString = keyGenResPtr.ref.recovery.toDartString(); + + final serializedKeys = serializeKeys(keys: keyGenResPtr.ref.keys); + + return ( + multisigId: id, + recoveryString: recoveryString, + serializedKeys: serializedKeys, + ); + } catch (e, s) { + Logging.instance.log( + "completeKeyGeneration failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + //=================== transaction creation =================================== + + static String createSignConfig({ + required int network, + required List<({UTXO utxo, Uint8List scriptPubKey})> inputs, + required List<({String address, Amount amount, bool isChange})> outputs, + required String changeAddress, + required int feePerWeight, + }) { + try { + final signConfig = newSignConfig( + network: network, + outputs: inputs + .map( + (e) => Output( + hash: e.utxo.txid.toUint8ListFromHex, + vout: e.utxo.vout, + value: e.utxo.value, + scriptPubKey: e.scriptPubKey, + ), + ) + .toList(), + paymentAddresses: outputs.map((e) => e.address).toList(), + paymentAmounts: outputs.map((e) => e.amount.raw.toInt()).toList(), + change: changeAddress, + feePerWeight: feePerWeight, + ); + + return signConfig; + } catch (e, s) { + Logging.instance.log( + "createSignConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + Pointer machinePtr, + String preprocess, + }) attemptSignConfig({ + required int network, + required String config, + required String serializedKeys, + }) { + try { + final keys = deserializeKeys(keys: serializedKeys); + + final attemptSignRes = attemptSign( + thresholdKeysWrapperPointer: keys, + network: network, + signConfig: config, + ); + + return ( + preprocess: attemptSignRes.ref.preprocess.toDartString(), + machinePtr: attemptSignRes.ref.machine, + ); + } catch (e, s) { + Logging.instance.log( + "attemptSignConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + Pointer machinePtr, + String share, + }) continueSigning({ + required Pointer machinePtr, + required List preprocesses, + }) { + try { + final continueSignRes = continueSign( + machine: machinePtr, + preprocesses: preprocesses, + ); + + return ( + share: continueSignRes.ref.preprocess.toDartString(), + machinePtr: continueSignRes.ref.machine, + ); + } catch (e, s) { + Logging.instance.log( + "continueSigning failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static String completeSigning({ + required Pointer machinePtr, + required List shares, + }) { + try { + final rawTransaction = completeSign( + machine: machinePtr, + shares: shares, + ); + + return rawTransaction; + } catch (e, s) { + Logging.instance.log( + "completeSigning failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static Pointer decodedSignConfig({ + required String encodedConfig, + required int network, + }) { + try { + final configPtr = + decodeSignConfig(encodedSignConfig: encodedConfig, network: network); + return configPtr; + } catch (e, s) { + Logging.instance.log( + "decodedSignConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + //========================== resharing ======================================= + + static String createResharerConfig({ + required int newThreshold, + required List resharers, + required List newParticipants, + }) { + try { + final config = newResharerConfig( + newThreshold: newThreshold, + newParticipants: newParticipants, + resharers: resharers, + ); + + return config; + } catch (e, s) { + Logging.instance.log( + "createResharerConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + String resharerStart, + Pointer machine, + }) beginResharer({ + required String serializedKeys, + required String config, + }) { + try { + final result = startResharer( + serializedKeys: serializedKeys, + config: config, + ); + + return ( + resharerStart: result.encoded, + machine: result.machine, + ); + } catch (e, s) { + Logging.instance.log( + "beginResharer failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + /// expects [resharerStarts] of length equal to resharers. + static ({ + String resharedStart, + Pointer prior, + }) beginReshared({ + required String myName, + required String resharerConfig, + required List resharerStarts, + }) { + try { + final result = startReshared( + newMultisigName: 'unused_property', + myName: myName, + resharerConfig: resharerConfig, + resharerStarts: resharerStarts, + ); + return ( + resharedStart: result.encoded, + prior: result.machine, + ); + } catch (e, s) { + Logging.instance.log( + "beginReshared failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + /// expects [encryptionKeysOfResharedTo] of length equal to new participants + static String finishResharer({ + required StartResharerRes machine, + required List encryptionKeysOfResharedTo, + }) { + try { + final result = completeResharer( + machine: machine, + encryptionKeysOfResharedTo: encryptionKeysOfResharedTo, + ); + return result; + } catch (e, s) { + Logging.instance.log( + "finishResharer failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + /// expects [resharerCompletes] of length equal to resharers + static ({ + String multisigConfig, + String serializedKeys, + String resharedId, + }) finishReshared({ + required StartResharedRes prior, + required List resharerCompletes, + }) { + try { + final result = completeReshared( + prior: prior, + resharerCompletes: resharerCompletes, + ); + return result; + } catch (e, s) { + Logging.instance.log( + "finishReshared failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static Pointer decodedResharerConfig({ + required String resharerConfig, + }) { + try { + final config = decodeResharerConfig(resharerConfig: resharerConfig); + + return config; + } catch (e, s) { + Logging.instance.log( + "decodedResharerConfig failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + static ({ + int newThreshold, + List resharers, + List newParticipants, + }) extractResharerConfigData({ + required String resharerConfig, + }) { + try { + final newThreshold = resharerNewThreshold( + resharerConfigPointer: decodedResharerConfig( + resharerConfig: resharerConfig, + ), + ); + + final resharersCount = resharerResharers( + resharerConfigPointer: decodedResharerConfig( + resharerConfig: resharerConfig, + ), + ); + final List resharers = []; + for (int i = 0; i < resharersCount; i++) { + resharers.add( + resharerResharer( + resharerConfigPointer: decodedResharerConfig( + resharerConfig: resharerConfig, + ), + index: i, + ), + ); + } + + final newParticipantsCount = resharerNewParticipants( + resharerConfigPointer: decodedResharerConfig( + resharerConfig: resharerConfig, + ), + ); + final List newParticipants = []; + for (int i = 0; i < newParticipantsCount; i++) { + newParticipants.add( + resharerNewParticipant( + resharerConfigPointer: decodedResharerConfig( + resharerConfig: resharerConfig, + ), + index: i, + ), + ); + } + + return ( + newThreshold: newThreshold, + resharers: resharers, + newParticipants: newParticipants, + ); + } catch (e, s) { + Logging.instance.log( + "extractResharerConfigData failed: $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } +} diff --git a/lib/wallets/crypto_currency/coins/bitcoin_frost.dart b/lib/wallets/crypto_currency/coins/bitcoin_frost.dart index f968818e1..b82d3987c 100644 --- a/lib/wallets/crypto_currency/coins/bitcoin_frost.dart +++ b/lib/wallets/crypto_currency/coins/bitcoin_frost.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'package:stackwallet/models/node_model.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; import 'package:stackwallet/utilities/default_nodes.dart'; import 'package:stackwallet/utilities/enums/coin_enum.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; @@ -48,6 +49,12 @@ class BitcoinFrost extends FrostCurrency { } } + @override + Amount get dustLimit => Amount( + rawValue: BigInt.from(294), + fractionDigits: fractionDigits, + ); + @override String pubKeyToScriptHash({required Uint8List pubKey}) { try { diff --git a/lib/wallets/crypto_currency/intermediate/private_key_currency.dart b/lib/wallets/crypto_currency/intermediate/private_key_currency.dart index 8cbf11b27..0c10937fa 100644 --- a/lib/wallets/crypto_currency/intermediate/private_key_currency.dart +++ b/lib/wallets/crypto_currency/intermediate/private_key_currency.dart @@ -1,9 +1,12 @@ import 'dart:typed_data'; +import 'package:stackwallet/utilities/amount/amount.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; abstract class FrostCurrency extends CryptoCurrency { FrostCurrency(super.network); String pubKeyToScriptHash({required Uint8List pubKey}); + + Amount get dustLimit; } diff --git a/lib/wallets/isar/models/frost_wallet_info.dart b/lib/wallets/isar/models/frost_wallet_info.dart index 817f78f39..b5c7476d2 100644 --- a/lib/wallets/isar/models/frost_wallet_info.dart +++ b/lib/wallets/isar/models/frost_wallet_info.dart @@ -12,27 +12,30 @@ class FrostWalletInfo implements IsarId { final String walletId; final List knownSalts; + final List participants; + final String myName; + final int threshold; FrostWalletInfo({ required this.walletId, required this.knownSalts, + required this.participants, + required this.myName, + required this.threshold, }); FrostWalletInfo copyWith({ List? knownSalts, + List? participants, + String? myName, + int? threshold, }) { return FrostWalletInfo( walletId: walletId, knownSalts: knownSalts ?? this.knownSalts, - ); - } - - Future updateKnownSalts( - List knownSalts, { - required Isar isar, - }) async { - // await isar.writeTxn(() async { - // await isar. - // }) + participants: participants ?? this.participants, + myName: myName ?? this.myName, + threshold: threshold ?? this.threshold, + )..id = id; } } diff --git a/lib/wallets/isar/models/frost_wallet_info.g.dart b/lib/wallets/isar/models/frost_wallet_info.g.dart index ce5ae2aae..6c80125e2 100644 --- a/lib/wallets/isar/models/frost_wallet_info.g.dart +++ b/lib/wallets/isar/models/frost_wallet_info.g.dart @@ -22,8 +22,23 @@ const FrostWalletInfoSchema = CollectionSchema( name: r'knownSalts', type: IsarType.stringList, ), - r'walletId': PropertySchema( + r'myName': PropertySchema( id: 1, + name: r'myName', + type: IsarType.string, + ), + r'participants': PropertySchema( + id: 2, + name: r'participants', + type: IsarType.stringList, + ), + r'threshold': PropertySchema( + id: 3, + name: r'threshold', + type: IsarType.long, + ), + r'walletId': PropertySchema( + id: 4, name: r'walletId', type: IsarType.string, ) @@ -69,6 +84,14 @@ int _frostWalletInfoEstimateSize( bytesCount += value.length * 3; } } + bytesCount += 3 + object.myName.length * 3; + bytesCount += 3 + object.participants.length * 3; + { + for (var i = 0; i < object.participants.length; i++) { + final value = object.participants[i]; + bytesCount += value.length * 3; + } + } bytesCount += 3 + object.walletId.length * 3; return bytesCount; } @@ -80,7 +103,10 @@ void _frostWalletInfoSerialize( Map> allOffsets, ) { writer.writeStringList(offsets[0], object.knownSalts); - writer.writeString(offsets[1], object.walletId); + writer.writeString(offsets[1], object.myName); + writer.writeStringList(offsets[2], object.participants); + writer.writeLong(offsets[3], object.threshold); + writer.writeString(offsets[4], object.walletId); } FrostWalletInfo _frostWalletInfoDeserialize( @@ -91,7 +117,10 @@ FrostWalletInfo _frostWalletInfoDeserialize( ) { final object = FrostWalletInfo( knownSalts: reader.readStringList(offsets[0]) ?? [], - walletId: reader.readString(offsets[1]), + myName: reader.readString(offsets[1]), + participants: reader.readStringList(offsets[2]) ?? [], + threshold: reader.readLong(offsets[3]), + walletId: reader.readString(offsets[4]), ); object.id = id; return object; @@ -108,6 +137,12 @@ P _frostWalletInfoDeserializeProp

( return (reader.readStringList(offset) ?? []) as P; case 1: return (reader.readString(offset)) as P; + case 2: + return (reader.readStringList(offset) ?? []) as P; + case 3: + return (reader.readLong(offset)) as P; + case 4: + return (reader.readString(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); } @@ -589,6 +624,423 @@ extension FrostWalletInfoQueryFilter }); } + QueryBuilder + myNameEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'myName', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'myName', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'myName', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + myNameIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'myName', + value: '', + )); + }); + } + + QueryBuilder + myNameIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'myName', + value: '', + )); + }); + } + + QueryBuilder + participantsElementEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'participants', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'participants', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'participants', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + participantsElementIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'participants', + value: '', + )); + }); + } + + QueryBuilder + participantsElementIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'participants', + value: '', + )); + }); + } + + QueryBuilder + participantsLengthEqualTo(int length) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + length, + true, + length, + true, + ); + }); + } + + QueryBuilder + participantsIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + 0, + true, + 0, + true, + ); + }); + } + + QueryBuilder + participantsIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + 0, + false, + 999999, + true, + ); + }); + } + + QueryBuilder + participantsLengthLessThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + 0, + true, + length, + include, + ); + }); + } + + QueryBuilder + participantsLengthGreaterThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + length, + include, + 999999, + true, + ); + }); + } + + QueryBuilder + participantsLengthBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'participants', + lower, + includeLower, + upper, + includeUpper, + ); + }); + } + + QueryBuilder + thresholdEqualTo(int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'threshold', + value: value, + )); + }); + } + + QueryBuilder + thresholdGreaterThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'threshold', + value: value, + )); + }); + } + + QueryBuilder + thresholdLessThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'threshold', + value: value, + )); + }); + } + + QueryBuilder + thresholdBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'threshold', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + QueryBuilder walletIdEqualTo( String value, { @@ -734,6 +1186,33 @@ extension FrostWalletInfoQueryLinks extension FrostWalletInfoQuerySortBy on QueryBuilder { + QueryBuilder sortByMyName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'myName', Sort.asc); + }); + } + + QueryBuilder + sortByMyNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'myName', Sort.desc); + }); + } + + QueryBuilder + sortByThreshold() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'threshold', Sort.asc); + }); + } + + QueryBuilder + sortByThresholdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'threshold', Sort.desc); + }); + } + QueryBuilder sortByWalletId() { return QueryBuilder.apply(this, (query) { @@ -763,6 +1242,33 @@ extension FrostWalletInfoQuerySortThenBy }); } + QueryBuilder thenByMyName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'myName', Sort.asc); + }); + } + + QueryBuilder + thenByMyNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'myName', Sort.desc); + }); + } + + QueryBuilder + thenByThreshold() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'threshold', Sort.asc); + }); + } + + QueryBuilder + thenByThresholdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'threshold', Sort.desc); + }); + } + QueryBuilder thenByWalletId() { return QueryBuilder.apply(this, (query) { @@ -787,6 +1293,27 @@ extension FrostWalletInfoQueryWhereDistinct }); } + QueryBuilder distinctByMyName( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'myName', caseSensitive: caseSensitive); + }); + } + + QueryBuilder + distinctByParticipants() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'participants'); + }); + } + + QueryBuilder + distinctByThreshold() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'threshold'); + }); + } + QueryBuilder distinctByWalletId( {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { @@ -810,6 +1337,25 @@ extension FrostWalletInfoQueryProperty }); } + QueryBuilder myNameProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'myName'); + }); + } + + QueryBuilder, QQueryOperations> + participantsProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'participants'); + }); + } + + QueryBuilder thresholdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'threshold'); + }); + } + QueryBuilder walletIdProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'walletId'); diff --git a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart index 203ea5877..102beb1e3 100644 --- a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart +++ b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ffi'; import 'package:flutter/foundation.dart'; import 'package:frostdart/frostdart.dart' as frost; @@ -8,8 +9,15 @@ import 'package:stackwallet/electrumx_rpc/cached_electrumx_client.dart'; import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; import 'package:stackwallet/models/balance.dart'; import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/transaction.dart'; import 'package:stackwallet/models/isar/models/blockchain_data/utxo.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/v2/input_v2.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/v2/output_v2.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/v2/transaction_v2.dart'; import 'package:stackwallet/models/paymint/fee_object_model.dart'; +import 'package:stackwallet/services/event_bus/events/global/wallet_sync_status_changed_event.dart'; +import 'package:stackwallet/services/event_bus/global_event_bus.dart'; +import 'package:stackwallet/services/frost.dart'; import 'package:stackwallet/utilities/amount/amount.dart'; import 'package:stackwallet/utilities/extensions/extensions.dart'; import 'package:stackwallet/utilities/logger.dart'; @@ -19,21 +27,275 @@ import 'package:stackwallet/wallets/crypto_currency/intermediate/private_key_cur import 'package:stackwallet/wallets/isar/models/frost_wallet_info.dart'; import 'package:stackwallet/wallets/models/tx_data.dart'; import 'package:stackwallet/wallets/wallet/wallet.dart'; -import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/private_key_interface.dart'; -class BitcoinFrostWallet extends Wallet - with PrivateKeyInterface { - FrostWalletInfo get frostInfo => throw UnimplementedError(); +class BitcoinFrostWallet extends Wallet { + BitcoinFrostWallet(CryptoCurrencyNetwork network) + : super(BitcoinFrost(network) as T); + + FrostWalletInfo get frostInfo => mainDB.isar.frostWalletInfo + .where() + .walletIdEqualTo(walletId) + .findFirstSync()!; late ElectrumXClient electrumXClient; late CachedElectrumXClient electrumXCachedClient; + Future initializeNewFrost({ + required String mnemonic, + required String multisigConfig, + required String recoveryString, + required String serializedKeys, + required Uint8List multisigId, + required String myName, + required List participants, + required int threshold, + }) async { + Logging.instance.log( + "Generating new FROST wallet.", + level: LogLevel.Info, + ); + + try { + final salt = frost + .multisigSalt( + multisigConfig: multisigConfig, + ) + .toHex; + + final FrostWalletInfo frostWalletInfo = FrostWalletInfo( + walletId: info.walletId, + knownSalts: [salt], + participants: participants, + myName: myName, + threshold: threshold, + ); + + await secureStorageInterface.write( + key: Wallet.mnemonicKey(walletId: info.walletId), + value: mnemonic, + ); + await secureStorageInterface.write( + key: Wallet.mnemonicPassphraseKey(walletId: info.walletId), + value: "", + ); + await _saveSerializedKeys(serializedKeys); + await _saveRecoveryString(recoveryString); + await _saveMultisigId(multisigId); + await _saveMultisigConfig(multisigConfig); + + await mainDB.isar.frostWalletInfo.put(frostWalletInfo); + + final keys = frost.deserializeKeys(keys: serializedKeys); + + final addressString = frost.addressForKeys( + network: cryptoCurrency.network == CryptoCurrencyNetwork.main + ? Network.Mainnet + : Network.Testnet, + keys: keys, + ); + + final publicKey = frost.scriptPubKeyForKeys(keys: keys); + + final address = Address( + walletId: info.walletId, + value: addressString, + publicKey: publicKey.toUint8ListFromHex, + derivationIndex: 0, + derivationPath: null, + subType: AddressSubType.receiving, + type: AddressType.unknown, + ); + + await mainDB.putAddresses([address]); + } catch (e, s) { + Logging.instance.log( + "Exception rethrown from initializeNewFrost(): $e\n$s", + level: LogLevel.Fatal, + ); + rethrow; + } + } + + Future frostCreateSignConfig({ + required TxData txData, + required String changeAddress, + required int feePerWeight, + }) async { + try { + if (txData.recipients == null || txData.recipients!.isEmpty) { + throw Exception("No recipients found!"); + } + + final total = txData.recipients! + .map((e) => e.amount) + .reduce((value, e) => value += e); + + final utxos = await mainDB + .getUTXOs(walletId) + .filter() + .isBlockedEqualTo(false) + .findAll(); + + if (utxos.isEmpty) { + throw Exception("No UTXOs found"); + } else { + final currentHeight = await chainHeight; + utxos.removeWhere( + (e) => !e.isConfirmed( + currentHeight, + cryptoCurrency.minConfirms, + ), + ); + if (utxos.isEmpty) { + throw Exception("No confirmed UTXOs found"); + } + } + + if (total.raw > + utxos.map((e) => BigInt.from(e.value)).reduce((v, e) => v += e)) { + throw Exception("Insufficient available funds"); + } + + Amount sum = Amount.zeroWith( + fractionDigits: cryptoCurrency.fractionDigits, + ); + final Set utxosToUse = {}; + for (final utxo in utxos) { + sum += Amount( + rawValue: BigInt.from(utxo.value), + fractionDigits: cryptoCurrency.fractionDigits, + ); + utxosToUse.add(utxo); + if (sum > total) { + break; + } + } + + final serializedKeys = await _getSerializedKeys(); + final keys = frost.deserializeKeys(keys: serializedKeys!); + + final int network = cryptoCurrency.network == CryptoCurrencyNetwork.main + ? Network.Mainnet + : Network.Testnet; + + final publicKey = frost + .scriptPubKeyForKeys( + keys: keys, + ) + .toUint8ListFromHex; + + final config = Frost.createSignConfig( + network: network, + inputs: utxosToUse + .map((e) => ( + utxo: e, + scriptPubKey: publicKey, + )) + .toList(), + outputs: txData.recipients!, + changeAddress: (await getCurrentReceivingAddress())!.value, + feePerWeight: feePerWeight, + ); + + return txData.copyWith(frostMSConfig: config, utxos: utxosToUse); + } catch (_) { + rethrow; + } + } + + Future< + ({ + Pointer machinePtr, + String preprocess, + })> frostAttemptSignConfig({ + required String config, + }) async { + final int network = cryptoCurrency.network == CryptoCurrencyNetwork.main + ? Network.Mainnet + : Network.Testnet; + final serializedKeys = await _getSerializedKeys(); + + return Frost.attemptSignConfig( + network: network, + config: config, + serializedKeys: serializedKeys!, + ); + } + + Future updateWithResharedData({ + required String serializedKeys, + required String multisigConfig, + required bool isNewWallet, + }) async { + await _saveSerializedKeys(serializedKeys); + await _saveMultisigConfig(multisigConfig); + + await _updateThreshold( + frost.getThresholdFromKeys( + serializedKeys: serializedKeys, + ), + ); + + final myNameIndex = frost.getParticipantIndexFromKeys( + serializedKeys: serializedKeys, + ); + final participants = Frost.getParticipants( + multisigConfig: multisigConfig, + ); + final myName = participants[myNameIndex]; + + await _updateParticipants(participants); + await _updateMyName(myName); + + if (isNewWallet) { + await recover( + serializedKeys: serializedKeys, + multisigConfig: multisigConfig, + isRescan: false, + ); + } + } + + Future sweepAllEstimate(int feeRate) async { + int available = 0; + int inputCount = 0; + final height = await chainHeight; + for (final output in (await mainDB.getUTXOs(walletId).findAll())) { + if (!output.isBlocked && + output.isConfirmed(height, cryptoCurrency.minConfirms)) { + available += output.value; + inputCount++; + } + } + + // transaction will only have 1 output minus the fee + final estimatedFee = _roughFeeEstimate(inputCount, 1, feeRate); + + return Amount( + rawValue: BigInt.from(available), + fractionDigits: cryptoCurrency.fractionDigits, + ) - + estimatedFee; + } + + // int _estimateTxFee({required int vSize, required int feeRatePerKB}) { + // return vSize * (feeRatePerKB / 1000).ceil(); + // } + + Amount _roughFeeEstimate(int inputCount, int outputCount, int feeRatePerKB) { + return Amount( + rawValue: BigInt.from( + ((42 + (272 * inputCount) + (128 * outputCount)) / 4).ceil() * + (feeRatePerKB / 1000).ceil()), + fractionDigits: cryptoCurrency.fractionDigits, + ); + } + + // ==================== Overrides ============================================ + @override int get isarTransactionVersion => 2; - BitcoinFrostWallet(CryptoCurrencyNetwork network) - : super(BitcoinFrost(network) as T); - @override FilterOperation? get changeAddressFilterOperation => FilterGroup.and( [ @@ -62,62 +324,321 @@ class BitcoinFrostWallet extends Wallet ], ); - // Future> fetchAddressesForElectrumXScan() async { - // final allAddresses = await mainDB - // .getAddresses(walletId) - // .filter() - // .typeEqualTo(AddressType.frostMS) - // .and() - // .group( - // (q) => q - // .subTypeEqualTo(AddressSubType.receiving) - // .or() - // .subTypeEqualTo(AddressSubType.change), - // ) - // .findAll(); - // return allAddresses; - // } + @override + Future updateTransactions() async { + final myAddress = (await getCurrentReceivingAddress())!; + + final scriptHash = cryptoCurrency.pubKeyToScriptHash( + pubKey: Uint8List.fromList(myAddress.publicKey), + ); + final allTxHashes = + (await electrumXClient.getHistory(scripthash: scriptHash)).toSet(); + + final currentHeight = await chainHeight; + final coin = info.coin; + + List> allTransactions = []; + + for (final txHash in allTxHashes) { + final storedTx = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .txidEqualTo(txHash["tx_hash"] as String) + .findFirst(); + + if (storedTx == null || + !storedTx.isConfirmed(currentHeight, cryptoCurrency.minConfirms)) { + final tx = await electrumXCachedClient.getTransaction( + txHash: txHash["tx_hash"] as String, + verbose: true, + coin: coin, + ); + + if (!_duplicateTxCheck(allTransactions, tx["txid"] as String)) { + tx["height"] = txHash["height"]; + allTransactions.add(tx); + } + } + } + + // Parse all new txs. + final List txns = []; + for (final txData in allTransactions) { + bool wasSentFromThisWallet = false; + // Set to true if any inputs were detected as owned by this wallet. + + bool wasReceivedInThisWallet = false; + // Set to true if any outputs were detected as owned by this wallet. + + // Parse inputs. + BigInt amountReceivedInThisWallet = BigInt.zero; + final List inputs = []; + for (final jsonInput in txData["vin"] as List) { + final map = Map.from(jsonInput as Map); + + final List addresses = []; + String valueStringSats = "0"; + OutpointV2? outpoint; + + final coinbase = map["coinbase"] as String?; + + if (coinbase == null) { + // Not a coinbase (ie a typical input). + final txid = map["txid"] as String; + final vout = map["vout"] as int; + + final inputTx = await electrumXCachedClient.getTransaction( + txHash: txid, + coin: cryptoCurrency.coin, + ); + + final prevOutJson = Map.from( + (inputTx["vout"] as List).firstWhere((e) => e["n"] == vout) + as Map); + + final prevOut = OutputV2.fromElectrumXJson( + prevOutJson, + decimalPlaces: cryptoCurrency.fractionDigits, + isFullAmountNotSats: true, + walletOwns: false, // Doesn't matter here as this is not saved. + ); + + outpoint = OutpointV2.isarCantDoRequiredInDefaultConstructor( + txid: txid, + vout: vout, + ); + valueStringSats = prevOut.valueStringSats; + addresses.addAll(prevOut.addresses); + } + + InputV2 input = InputV2.fromElectrumxJson( + json: map, + outpoint: outpoint, + valueStringSats: valueStringSats, + addresses: addresses, + coinbase: coinbase, + // Need addresses before we can know if the wallet owns this input. + walletOwns: false, + ); + + // Check if input was from this wallet. + if (input.addresses.contains(myAddress.value)) { + wasSentFromThisWallet = true; + input = input.copyWith(walletOwns: true); + } + + inputs.add(input); + } + + // Parse outputs. + final List outputs = []; + for (final outputJson in txData["vout"] as List) { + OutputV2 output = OutputV2.fromElectrumXJson( + Map.from(outputJson as Map), + decimalPlaces: cryptoCurrency.fractionDigits, + isFullAmountNotSats: true, + // Need addresses before we can know if the wallet owns this input. + walletOwns: false, + ); + + // If output was to my wallet, add value to amount received. + if (output.addresses.contains(myAddress.value)) { + wasReceivedInThisWallet = true; + amountReceivedInThisWallet += output.value; + output = output.copyWith(walletOwns: true); + } + + outputs.add(output); + } + + final totalOut = outputs + .map((e) => e.value) + .fold(BigInt.zero, (value, element) => value + element); + + TransactionType type; + TransactionSubType subType = TransactionSubType.none; + if (outputs.length > 1 && inputs.isNotEmpty) { + for (int i = 0; i < outputs.length; i++) { + List? scriptChunks = outputs[i].scriptPubKeyAsm?.split(" "); + if (scriptChunks?.length == 2 && scriptChunks?[0] == "OP_RETURN") { + final blindedPaymentCode = scriptChunks![1]; + final bytes = blindedPaymentCode.toUint8ListFromHex; + + // https://en.bitcoin.it/wiki/BIP_0047#Sending + if (bytes.length == 80 && bytes.first == 1) { + subType = TransactionSubType.bip47Notification; + break; + } + } + } + } + + // At least one input was owned by this wallet. + if (wasSentFromThisWallet) { + type = TransactionType.outgoing; + + if (wasReceivedInThisWallet) { + if (amountReceivedInThisWallet == totalOut) { + // Definitely sent all to self. + type = TransactionType.sentToSelf; + } else if (amountReceivedInThisWallet == BigInt.zero) { + // Most likely just a typical send, do nothing here yet. + } + } + } else if (wasReceivedInThisWallet) { + // Only found outputs owned by this wallet. + type = TransactionType.incoming; + } else { + Logging.instance.log( + "Unexpected tx found (ignoring it): $txData", + level: LogLevel.Error, + ); + continue; + } + + final tx = TransactionV2( + walletId: walletId, + blockHash: txData["blockhash"] as String?, + hash: txData["hash"] as String, + txid: txData["txid"] as String, + height: txData["height"] as int?, + version: txData["version"] as int, + timestamp: txData["blocktime"] as int? ?? + DateTime.timestamp().millisecondsSinceEpoch ~/ 1000, + inputs: List.unmodifiable(inputs), + outputs: List.unmodifiable(outputs), + type: type, + subType: subType, + otherData: null, + ); + + txns.add(tx); + } + + await mainDB.updateOrPutTransactionV2s(txns); + } @override - Future updateTransactions() { - // TODO: implement updateTransactions - throw UnimplementedError(); + Future checkSaveInitialReceivingAddress() async { + // should not be needed for frost as we explicitly save the address + // on new init and restore } - int estimateTxFee({required int vSize, required int feeRatePerKB}) { - return vSize * (feeRatePerKB / 1000).ceil(); + @override + Future confirmSend({required TxData txData}) async { + try { + Logging.instance.log("confirmSend txData: $txData", level: LogLevel.Info); + + final hex = txData.raw!; + + final txHash = await electrumXClient.broadcastTransaction(rawTx: hex); + Logging.instance.log("Sent txHash: $txHash", level: LogLevel.Info); + + // mark utxos as used + final usedUTXOs = txData.utxos!.map((e) => e.copyWith(used: true)); + await mainDB.putUTXOs(usedUTXOs.toList()); + + txData = txData.copyWith( + utxos: usedUTXOs.toSet(), + txHash: txHash, + txid: txHash, + ); + + return txData; + } catch (e, s) { + Logging.instance.log("Exception rethrown from confirmSend(): $e\n$s", + level: LogLevel.Error); + rethrow; + } } - Amount roughFeeEstimate(int inputCount, int outputCount, int feeRatePerKB) { - return Amount( - rawValue: BigInt.from( - ((42 + (272 * inputCount) + (128 * outputCount)) / 4).ceil() * - (feeRatePerKB / 1000).ceil()), + @override + Future estimateFeeFor(Amount amount, int feeRate) async { + final available = info.cachedBalance.spendable; + + if (available == amount) { + return amount - (await sweepAllEstimate(feeRate)); + } else if (amount <= Amount.zero || amount > available) { + return _roughFeeEstimate(1, 2, feeRate); + } + + Amount runningBalance = Amount( + rawValue: BigInt.zero, fractionDigits: cryptoCurrency.fractionDigits, ); + int inputCount = 0; + for (final output in (await mainDB.getUTXOs(walletId).findAll())) { + if (!output.isBlocked) { + runningBalance += Amount( + rawValue: BigInt.from(output.value), + fractionDigits: cryptoCurrency.fractionDigits, + ); + inputCount++; + if (runningBalance > amount) { + break; + } + } + } + + final oneOutPutFee = _roughFeeEstimate(inputCount, 1, feeRate); + final twoOutPutFee = _roughFeeEstimate(inputCount, 2, feeRate); + + if (runningBalance - amount > oneOutPutFee) { + if (runningBalance - amount > oneOutPutFee + cryptoCurrency.dustLimit) { + final change = runningBalance - amount - twoOutPutFee; + if (change > cryptoCurrency.dustLimit && + runningBalance - amount - change == twoOutPutFee) { + return runningBalance - amount - change; + } else { + return runningBalance - amount; + } + } else { + return runningBalance - amount; + } + } else if (runningBalance - amount == oneOutPutFee) { + return oneOutPutFee; + } else { + return twoOutPutFee; + } } @override - Future checkSaveInitialReceivingAddress() { - // TODO: implement checkSaveInitialReceivingAddress - throw UnimplementedError(); - } + Future get fees async { + try { + // adjust numbers for different speeds? + const int f = 1, m = 5, s = 20; - @override - Future confirmSend({required TxData txData}) { - // TODO: implement confirmSend - throw UnimplementedError(); - } + final fast = await electrumXClient.estimateFee(blocks: f); + final medium = await electrumXClient.estimateFee(blocks: m); + final slow = await electrumXClient.estimateFee(blocks: s); - @override - Future estimateFeeFor(Amount amount, int feeRate) { - // TODO: implement estimateFeeFor - throw UnimplementedError(); - } + final feeObject = FeeObject( + numberOfBlocksFast: f, + numberOfBlocksAverage: m, + numberOfBlocksSlow: s, + fast: Amount.fromDecimal( + fast, + fractionDigits: cryptoCurrency.fractionDigits, + ).raw.toInt(), + medium: Amount.fromDecimal( + medium, + fractionDigits: cryptoCurrency.fractionDigits, + ).raw.toInt(), + slow: Amount.fromDecimal( + slow, + fractionDigits: cryptoCurrency.fractionDigits, + ).raw.toInt(), + ); - @override - // TODO: implement fees - Future get fees => throw UnimplementedError(); + Logging.instance.log("fetched fees: $feeObject", level: LogLevel.Info); + return feeObject; + } catch (e) { + Logging.instance + .log("Exception rethrown from _getFees(): $e", level: LogLevel.Error); + rethrow; + } + } @override Future prepareSend({required TxData txData}) { @@ -138,6 +659,16 @@ class BitcoinFrostWallet extends Wallet ); } + final coin = info.coin; + + GlobalEventBus.instance.fire( + WalletSyncStatusChangedEvent( + WalletSyncStatus.syncing, + walletId, + coin, + ), + ); + try { await refreshMutex.protect(() async { if (!isRescan) { @@ -146,12 +677,16 @@ class BitcoinFrostWallet extends Wallet multisigConfig: multisigConfig, ) .toHex; - final knownSalts = frostInfo.knownSalts; + final knownSalts = _getKnownSalts(); if (knownSalts.contains(salt)) { throw Exception("Known frost multisig salt found!"); } knownSalts.add(salt); - await frostInfo.updateKnownSalts(knownSalts, isar: mainDB.isar); + await _updateKnownSalts(knownSalts); + } else { + // clear cache + await electrumXCachedClient.clearSharedTransactionCache(coin: coin); + await mainDB.deleteWalletBlockchainData(walletId); } final keys = frost.deserializeKeys(keys: serializedKeys); @@ -180,12 +715,27 @@ class BitcoinFrostWallet extends Wallet await mainDB.updateOrPutAddresses([address]); }); + GlobalEventBus.instance.fire( + WalletSyncStatusChangedEvent( + WalletSyncStatus.synced, + walletId, + coin, + ), + ); + unawaited(refresh()); } catch (e, s) { Logging.instance.log( "recoverFromSerializedKeys failed: $e\n$s", level: LogLevel.Fatal, ); + GlobalEventBus.instance.fire( + WalletSyncStatusChangedEvent( + WalletSyncStatus.unableToSync, + walletId, + coin, + ), + ); rethrow; } } @@ -309,12 +859,15 @@ class BitcoinFrostWallet extends Wallet // =================== Secure storage ======================================== - Future get getSerializedKeys async => + Future _getSerializedKeys() async => await secureStorageInterface.read( key: "{$walletId}_serializedFROSTKeys", ); - Future _saveSerializedKeys(String keys) async { - final current = await getSerializedKeys; + + Future _saveSerializedKeys( + String keys, + ) async { + final current = await _getSerializedKeys(); if (current == null) { // do nothing @@ -334,20 +887,24 @@ class BitcoinFrostWallet extends Wallet ); } - Future get getSerializedKeysPrevGen async => + Future _getSerializedKeysPrevGen() async => await secureStorageInterface.read( key: "{$walletId}_serializedFROSTKeysPrevGen", ); - Future get multisigConfig async => await secureStorageInterface.read( + Future _multisigConfig() async => await secureStorageInterface.read( key: "{$walletId}_multisigConfig", ); - Future get multisigConfigPrevGen async => + + Future _multisigConfigPrevGen() async => await secureStorageInterface.read( key: "{$walletId}_multisigConfigPrevGen", ); - Future _saveMultisigConfig(String multisigConfig) async { - final current = await this.multisigConfig; + + Future _saveMultisigConfig( + String multisigConfig, + ) async { + final current = await _multisigConfig(); if (current == null) { // do nothing @@ -367,7 +924,7 @@ class BitcoinFrostWallet extends Wallet ); } - Future get multisigId async { + Future _multisigId() async { final id = await secureStorageInterface.read( key: "{$walletId}_multisigIdFROST", ); @@ -378,21 +935,92 @@ class BitcoinFrostWallet extends Wallet } } - Future saveMultisigId(Uint8List id) async => + Future _saveMultisigId( + Uint8List id, + ) async => await secureStorageInterface.write( key: "{$walletId}_multisigIdFROST", value: id.toHex, ); - Future get recoveryString async => await secureStorageInterface.read( + Future _recoveryString() async => await secureStorageInterface.read( key: "{$walletId}_recoveryStringFROST", ); - Future saveRecoveryString(String recoveryString) async => + + Future _saveRecoveryString( + String recoveryString, + ) async => await secureStorageInterface.write( key: "{$walletId}_recoveryStringFROST", value: recoveryString, ); + // =================== DB ==================================================== + + List _getKnownSalts() => mainDB.isar.frostWalletInfo + .where() + .walletIdEqualTo(walletId) + .knownSaltsProperty() + .findFirstSync()!; + + Future _updateKnownSalts(List knownSalts) async { + await mainDB.isar.writeTxn(() async { + final info = frostInfo; + await mainDB.isar.frostWalletInfo.delete(info.id); + await mainDB.isar.frostWalletInfo.put( + info.copyWith(knownSalts: knownSalts), + ); + }); + } + + List _getParticipants() => mainDB.isar.frostWalletInfo + .where() + .walletIdEqualTo(walletId) + .participantsProperty() + .findFirstSync()!; + + Future _updateParticipants(List participants) async { + await mainDB.isar.writeTxn(() async { + final info = frostInfo; + await mainDB.isar.frostWalletInfo.delete(info.id); + await mainDB.isar.frostWalletInfo.put( + info.copyWith(participants: participants), + ); + }); + } + + int _getThreshold() => mainDB.isar.frostWalletInfo + .where() + .walletIdEqualTo(walletId) + .thresholdProperty() + .findFirstSync()!; + + Future _updateThreshold(int threshold) async { + await mainDB.isar.writeTxn(() async { + final info = frostInfo; + await mainDB.isar.frostWalletInfo.delete(info.id); + await mainDB.isar.frostWalletInfo.put( + info.copyWith(threshold: threshold), + ); + }); + } + + String _getMyName() => mainDB.isar.frostWalletInfo + .where() + .walletIdEqualTo(walletId) + .myNameProperty() + .findFirstSync()!; + + Future _updateMyName(String myName) async { + await mainDB.isar.writeTxn(() async { + final info = frostInfo; + await mainDB.isar.frostWalletInfo.delete(info.id); + await mainDB.isar.frostWalletInfo.put( + info.copyWith(myName: myName), + ); + }); + } + // =================== Private =============================================== Future _getCurrentElectrumXNode() async { @@ -430,6 +1058,16 @@ class BitcoinFrostWallet extends Wallet ); } + bool _duplicateTxCheck( + List> allTransactions, String txid) { + for (int i = 0; i < allTransactions.length; i++) { + if (allTransactions[i]["txid"] == txid) { + return true; + } + } + return false; + } + Future _parseUTXO({ required Map jsonUTXO, }) async { @@ -443,12 +1081,12 @@ class BitcoinFrostWallet extends Wallet final outputs = txn["vout"] as List; - String? scriptPubKey; + // String? scriptPubKey; String? utxoOwnerAddress; // get UTXO owner address for (final output in outputs) { if (output["n"] == vout) { - scriptPubKey = output["scriptPubKey"]?["hex"] as String?; + // scriptPubKey = output["scriptPubKey"]?["hex"] as String?; utxoOwnerAddress = output["scriptPubKey"]?["addresses"]?[0] as String? ?? output["scriptPubKey"]?["address"] as String?; diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart index 711a895a1..959b7b635 100644 --- a/lib/wallets/wallet/wallet.dart +++ b/lib/wallets/wallet/wallet.dart @@ -289,7 +289,7 @@ abstract class Wallet { wallet.prefs = prefs; wallet.nodeService = nodeService; - if (wallet is ElectrumXInterface) { + if (wallet is ElectrumXInterface || wallet is BitcoinFrostWallet) { // initialize electrumx instance await wallet.updateNode(); }