import 'dart:convert'; import 'dart:typed_data'; import 'package:bip47/bip47.dart'; import 'package:bip47/src/util.dart'; import 'package:bitcoindart/bitcoindart.dart'; import 'package:bitcoindart/src/utils/constants/op.dart' as op; import 'package:bitcoindart/src/utils/script.dart' as bscript; import 'package:decimal/decimal.dart'; import 'package:pointycastle/digests/sha256.dart'; import 'package:stackwallet/hive/db.dart'; import 'package:stackwallet/models/paymint/utxo_model.dart'; import 'package:stackwallet/services/coins/dogecoin/dogecoin_wallet.dart'; import 'package:stackwallet/utilities/enums/coin_enum.dart'; import 'package:stackwallet/utilities/format.dart'; import 'package:tuple/tuple.dart'; class SWException with Exception { SWException(this.message); final String message; @override toString() => message; } class InsufficientBalanceException extends SWException { InsufficientBalanceException(super.message); } extension PayNym on DogecoinWallet { // fetch or generate this wallet's bip47 payment code Future getPaymentCode() async { final paymentCodeString = DB.instance .get(boxName: walletId, key: "paymentCodeString") as String?; PaymentCode paymentCode; if (paymentCodeString == null) { final node = getBip32Root((await mnemonic).join(" "), network) .derivePath("m/47'/0'/0'"); paymentCode = PaymentCode.initFromPubKey(node.publicKey, node.chainCode, network); await DB.instance.put( boxName: walletId, key: "paymentCodeString", value: paymentCode.toString()); } else { paymentCode = PaymentCode.fromPaymentCode(paymentCodeString, network); } return paymentCode; } Future signWithNotificationKey(Uint8List data) async { final node = getBip32Root((await mnemonic).join(" "), network) .derivePath("m/47'/0'/0'"); final pair = ECPair.fromPrivateKey(node.privateKey!, network: network); final signed = pair.sign(SHA256Digest().process(data)); return signed; } Future signStringWithNotificationKey(String data) async { final bytes = await signWithNotificationKey(Uint8List.fromList(utf8.encode(data))); return Format.uint8listToString(bytes); // final bytes = // await signWithNotificationKey(Uint8List.fromList(utf8.encode(data))); // return Format.uint8listToString(bytes); } // Future> prepareNotificationTransaction( // String targetPaymentCode) async {} Future> buildNotificationTx({ required int selectedTxFeeRate, required String targetPaymentCodeString, int additionalOutputs = 0, List? utxos, }) async { const amountToSend = DUST_LIMIT; final List availableOutputs = utxos ?? outputsList; final List spendableOutputs = []; int spendableSatoshiValue = 0; // Build list of spendable outputs and totaling their satoshi amount for (var i = 0; i < availableOutputs.length; i++) { if (availableOutputs[i].blocked == false && availableOutputs[i].status.confirmed == true) { spendableOutputs.add(availableOutputs[i]); spendableSatoshiValue += availableOutputs[i].value; } } if (spendableSatoshiValue < amountToSend) { // insufficient balance throw InsufficientBalanceException( "Spendable balance is less than the minimum required for a notification transaction."); } else if (spendableSatoshiValue == amountToSend) { // insufficient balance due to missing amount to cover fee throw InsufficientBalanceException( "Remaining balance does not cover the network fee."); } // sort spendable by age (oldest first) spendableOutputs.sort( (a, b) => b.status.confirmations.compareTo(a.status.confirmations)); int satoshisBeingUsed = 0; int outputsBeingUsed = 0; List utxoObjectsToUse = []; for (int i = 0; satoshisBeingUsed < amountToSend && i < spendableOutputs.length; i++) { utxoObjectsToUse.add(spendableOutputs[i]); satoshisBeingUsed += spendableOutputs[i].value; outputsBeingUsed += 1; } // add additional outputs if required for (int i = 0; i < additionalOutputs && outputsBeingUsed < spendableOutputs.length; i++) { utxoObjectsToUse.add(spendableOutputs[outputsBeingUsed]); satoshisBeingUsed += spendableOutputs[outputsBeingUsed].value; outputsBeingUsed += 1; } // gather required signing data final utxoSigningData = await fetchBuildTxData(utxoObjectsToUse); final int vSizeForNoChange = (await _createNotificationTx( targetPaymentCodeString: targetPaymentCodeString, utxosToUse: utxoObjectsToUse, utxoSigningData: utxoSigningData, change: 0)) .item2; final int vSizeForWithChange = (await _createNotificationTx( targetPaymentCodeString: targetPaymentCodeString, utxosToUse: utxoObjectsToUse, utxoSigningData: utxoSigningData, change: satoshisBeingUsed - amountToSend)) .item2; // Assume 2 outputs, for recipient and payment code script int feeForNoChange = estimateTxFee( vSize: vSizeForNoChange, feeRatePerKB: selectedTxFeeRate, ); // Assume 3 outputs, for recipient, payment code script, and change int feeForWithChange = estimateTxFee( vSize: vSizeForWithChange, feeRatePerKB: selectedTxFeeRate, ); if (feeForNoChange < vSizeForNoChange * 1000) { feeForNoChange = vSizeForNoChange * 1000; } if (feeForWithChange < vSizeForWithChange * 1000) { feeForWithChange = vSizeForWithChange * 1000; } if (satoshisBeingUsed - amountToSend > feeForNoChange + DUST_LIMIT) { // try to add change output due to "left over" amount being greater than // the estimated fee + the dust limit int changeAmount = satoshisBeingUsed - amountToSend - feeForWithChange; // check estimates are correct and build notification tx if (changeAmount >= DUST_LIMIT && satoshisBeingUsed - amountToSend - changeAmount == feeForWithChange) { final txn = await _createNotificationTx( targetPaymentCodeString: targetPaymentCodeString, utxosToUse: utxoObjectsToUse, utxoSigningData: utxoSigningData, change: changeAmount, ); int feeBeingPaid = satoshisBeingUsed - amountToSend - changeAmount; Map transactionObject = { "hex": txn.item1, "recipientPaynym": targetPaymentCodeString, // "recipientAmt": recipientsAmtArray[0], "fee": feeBeingPaid, "vSize": txn.item2, }; return transactionObject; } else { // something broke during fee estimation or the change amount is smaller // than the dust limit. Try without change final txn = await _createNotificationTx( targetPaymentCodeString: targetPaymentCodeString, utxosToUse: utxoObjectsToUse, utxoSigningData: utxoSigningData, change: 0, ); int feeBeingPaid = satoshisBeingUsed - amountToSend; Map transactionObject = { "hex": txn.item1, "recipientPaynym": targetPaymentCodeString, // "recipientAmt": recipientsAmtArray[0], "fee": feeBeingPaid, "vSize": txn.item2, }; return transactionObject; } } else if (satoshisBeingUsed - amountToSend >= feeForNoChange) { // since we already checked if we need to add a change output we can just // build without change here final txn = await _createNotificationTx( targetPaymentCodeString: targetPaymentCodeString, utxosToUse: utxoObjectsToUse, utxoSigningData: utxoSigningData, change: 0, ); int feeBeingPaid = satoshisBeingUsed - amountToSend; Map transactionObject = { "hex": txn.item1, "recipientPaynym": targetPaymentCodeString, // "recipientAmt": recipientsAmtArray[0], "fee": feeBeingPaid, "vSize": txn.item2, }; return transactionObject; } else { // if we get here we do not have enough funds to cover the tx total so we // check if we have any more available outputs and try again if (spendableOutputs.length > outputsBeingUsed) { return buildNotificationTx( selectedTxFeeRate: selectedTxFeeRate, targetPaymentCodeString: targetPaymentCodeString, additionalOutputs: additionalOutputs + 1, ); } else { throw InsufficientBalanceException( "Remaining balance does not cover the network fee."); } } } // return tuple with string value equal to the raw tx hex and the int value // equal to its vSize Future> _createNotificationTx({ required String targetPaymentCodeString, required List utxosToUse, required Map utxoSigningData, required int change, }) async { final targetPaymentCode = PaymentCode.fromPaymentCode(targetPaymentCodeString, network); final myCode = await getPaymentCode(); final utxo = utxosToUse.first; final txPoint = utxo.txid.fromHex.toList(); final txPointIndex = utxo.vout; final rev = Uint8List(txPoint.length + 4); Util.copyBytes(Uint8List.fromList(txPoint), 0, rev, 0, txPoint.length); final buffer = rev.buffer.asByteData(); buffer.setUint32(txPoint.length, txPointIndex, Endian.little); final myKeyPair = utxoSigningData[utxo.txid]["keyPair"] as ECPair; final S = SecretPoint( myKeyPair.privateKey!, targetPaymentCode.notificationPublicKey(), ); final blindingMask = PaymentCode.getMask(S.ecdhSecret(), rev); final blindedPaymentCode = PaymentCode.blind( myCode.getPayload(), blindingMask, ); final opReturnScript = bscript.compile([ (op.OPS["OP_RETURN"] as int), blindedPaymentCode, ]); final bobP2PKH = P2PKH( data: PaymentData( pubkey: targetPaymentCode.notificationPublicKey(), ), ).data; final notificationScript = bscript.compile([bobP2PKH.output]); // build a notification tx final txb = TransactionBuilder(); txb.setVersion(1); txb.addInput( utxo.txid, txPointIndex, ); txb.addOutput(targetPaymentCode.notificationAddress(), DUST_LIMIT); txb.addOutput(opReturnScript, 0); // TODO: add possible change output and mark output as dangerous if (change > 0) { // generate new change address if current change address has been used await checkChangeAddressForTransactions(DerivePathType.bip44); final String changeAddress = await getCurrentAddressForChain(1, DerivePathType.bip44); txb.addOutput(changeAddress, change); } txb.sign( vin: 0, keyPair: myKeyPair, ); // sign rest of possible inputs for (var i = 1; i < utxosToUse.length - 1; i++) { final txid = utxosToUse[i].txid; txb.sign( vin: i, keyPair: utxoSigningData[txid]["keyPair"] as ECPair, // witnessValue: utxosToUse[i].value, ); } final builtTx = txb.build(); return Tuple2(builtTx.toHex(), builtTx.virtualSize()); } Future hasConfirmedNotificationTxSentTo( String paymentCodeString) async { final targetPaymentCode = PaymentCode.fromPaymentCode(paymentCodeString, network); final targetNotificationAddress = targetPaymentCode.notificationAddress(); final myTxHistory = (await transactionData) .getAllTransactions() .entries .map((e) => e.value) .where((e) => e.txType == "Sent" && e.address == targetNotificationAddress); return myTxHistory.isNotEmpty; } // fetch paynym notification tx meta data Set> getPaynymNotificationTxInfo() { final set = DB.instance.get( boxName: walletId, key: "paynymNotificationTxInfo") as Set? ?? {}; return Set>.from(set); } // add/update paynym notification tx meta data entry Future updatePaynymNotificationInfo({ required String txid, required bool confirmed, required String paymentCodeString, }) async { final data = getPaynymNotificationTxInfo(); data.add({ "txid": txid, "confirmed": confirmed, "paymentCodeString": paymentCodeString, }); await DB.instance.put( boxName: walletId, key: "paynymNotificationTxInfo", value: data, ); } } Future> parseTransaction( Map txData, dynamic electrumxClient, Set myAddresses, Set myChangeAddresses, Coin coin, int minConfirms, Decimal currentPrice, ) async { Set inputAddresses = {}; Set outputAddresses = {}; int totalInputValue = 0; int totalOutputValue = 0; int amountSentFromWallet = 0; int amountReceivedInWallet = 0; // parse inputs for (final input in txData["vin"] as List) { final prevTxid = input["txid"] as String; final prevOut = input["vout"] as int; // fetch input tx to get address final inputTx = await electrumxClient.getTransaction( txHash: prevTxid, coin: coin, ); for (final output in inputTx["vout"] as List) { // check matching output if (prevOut == output["n"]) { // get value final value = Format.decimalAmountToSatoshis( Decimal.parse(output["value"].toString()), coin, ); // add value to total totalInputValue += value; // get input(prevOut) address final address = output["scriptPubKey"]?["addresses"]?[0] as String? ?? output["scriptPubKey"]?["address"] as String?; if (address != null) { inputAddresses.add(address); // if input was from my wallet, add value to amount sent if (myAddresses.contains(address)) { amountSentFromWallet += value; } } } } } // parse outputs for (final output in txData["vout"] as List) { // get value final value = Format.decimalAmountToSatoshis( Decimal.parse(output["value"].toString()), coin, ); // add value to total totalOutputValue += value; // get output address final address = output["scriptPubKey"]["addresses"][0] as String? ?? output["scriptPubKey"]["address"] as String?; if (address != null) { outputAddresses.add(address); // if output was from my wallet, add value to amount received if (myAddresses.contains(address)) { amountReceivedInWallet += value; } } } final mySentFromAddresses = myAddresses.intersection(inputAddresses); final myReceivedOnAddresses = myAddresses.intersection(outputAddresses); final fee = totalInputValue - totalOutputValue; // create normalized tx data map Map normalizedTx = {}; final int confirms = txData["confirmations"] as int? ?? 0; normalizedTx["txid"] = txData["txid"] as String; normalizedTx["confirmed_status"] = confirms >= minConfirms; normalizedTx["confirmations"] = confirms; normalizedTx["timestamp"] = txData["blocktime"] as int? ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000); normalizedTx["aliens"] = []; normalizedTx["fees"] = fee; normalizedTx["address"] = txData["address"] as String; normalizedTx["inputSize"] = txData["vin"].length; normalizedTx["outputSize"] = txData["vout"].length; normalizedTx["inputs"] = txData["vin"]; normalizedTx["outputs"] = txData["vout"]; normalizedTx["height"] = txData["height"] as int; int amount; String type; if (mySentFromAddresses.isNotEmpty && myReceivedOnAddresses.isNotEmpty) { // tx is sent to self type = "Sent to self"; amount = amountSentFromWallet - amountReceivedInWallet - fee; } else if (mySentFromAddresses.isNotEmpty) { // outgoing tx type = "Sent"; amount = amountSentFromWallet; } else { // incoming tx type = "Received"; amount = amountReceivedInWallet; } normalizedTx["txType"] = type; normalizedTx["amount"] = amount; normalizedTx["worthNow"] = (Format.satoshisToAmount( amount, coin: coin, ) * currentPrice) .toStringAsFixed(2); return normalizedTx; }