mirror of
https://github.com/cypherstack/stack_wallet.git
synced 2024-12-27 05:49:21 +00:00
536 lines
18 KiB
Dart
536 lines
18 KiB
Dart
import 'package:bitcoindart/bitcoindart.dart' as bitcoindart;
|
|
import 'package:isar/isar.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/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/signing_data.dart';
|
|
import 'package:stackwallet/utilities/amount/amount.dart';
|
|
import 'package:stackwallet/utilities/extensions/impl/uint8_list.dart';
|
|
import 'package:stackwallet/utilities/logger.dart';
|
|
import 'package:stackwallet/wallets/crypto_currency/coins/particl.dart';
|
|
import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart';
|
|
import 'package:stackwallet/wallets/models/tx_data.dart';
|
|
import 'package:stackwallet/wallets/wallet/intermediate/bip39_hd_wallet.dart';
|
|
import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart';
|
|
import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart';
|
|
|
|
class ParticlWallet extends Bip39HDWallet
|
|
with ElectrumXInterface, CoinControlInterface {
|
|
@override
|
|
int get isarTransactionVersion => 2;
|
|
|
|
ParticlWallet(CryptoCurrencyNetwork network) : super(Particl(network));
|
|
|
|
// TODO: double check these filter operations are correct and do not require additional parameters
|
|
@override
|
|
FilterOperation? get changeAddressFilterOperation =>
|
|
FilterGroup.and(standardChangeAddressFilters);
|
|
|
|
@override
|
|
FilterOperation? get receivingAddressFilterOperation =>
|
|
FilterGroup.and(standardReceivingAddressFilters);
|
|
|
|
// ===========================================================================
|
|
|
|
@override
|
|
Future<List<Address>> fetchAddressesForElectrumXScan() async {
|
|
final allAddresses = await mainDB
|
|
.getAddresses(walletId)
|
|
.filter()
|
|
.not()
|
|
.group(
|
|
(q) => q
|
|
.typeEqualTo(AddressType.nonWallet)
|
|
.or()
|
|
.subTypeEqualTo(AddressSubType.nonWallet),
|
|
)
|
|
.findAll();
|
|
return allAddresses;
|
|
}
|
|
|
|
// ===========================================================================
|
|
|
|
@override
|
|
Future<
|
|
({
|
|
bool blocked,
|
|
String? blockedReason,
|
|
String? utxoLabel,
|
|
})> checkBlockUTXO(
|
|
Map<String, dynamic> jsonUTXO,
|
|
String? scriptPubKeyHex,
|
|
Map<String, dynamic> jsonTX,
|
|
String? utxoOwnerAddress,
|
|
) async {
|
|
bool blocked = false;
|
|
String? blockedReason;
|
|
String? utxoLabel;
|
|
|
|
final outputs = jsonTX["vout"] as List? ?? [];
|
|
|
|
for (final output in outputs) {
|
|
if (output is Map) {
|
|
if (output['ct_fee'] != null) {
|
|
// Blind output, ignore for now.
|
|
blocked = true;
|
|
blockedReason = "Blind output.";
|
|
utxoLabel = "Unsupported output type.";
|
|
} else if (output['rangeproof'] != null) {
|
|
// Private RingCT output, ignore for now.
|
|
blocked = true;
|
|
blockedReason = "Confidential output.";
|
|
utxoLabel = "Unsupported output type.";
|
|
} else if (output['data_hex'] != null) {
|
|
// Data output, ignore for now.
|
|
blocked = true;
|
|
blockedReason = "Data output.";
|
|
utxoLabel = "Unsupported output type.";
|
|
} else if (output['scriptPubKey'] != null) {
|
|
if (output['scriptPubKey']?['asm'] is String &&
|
|
(output['scriptPubKey']['asm'] as String)
|
|
.contains("OP_ISCOINSTAKE")) {
|
|
blocked = true;
|
|
blockedReason = "Spending staking";
|
|
utxoLabel = "Unsupported output type.";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
blocked: blocked,
|
|
blockedReason: blockedReason,
|
|
utxoLabel: utxoLabel
|
|
);
|
|
}
|
|
|
|
@override
|
|
int estimateTxFee({required int vSize, required int feeRatePerKB}) {
|
|
return vSize * (feeRatePerKB / 1000).ceil();
|
|
}
|
|
|
|
@override
|
|
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,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> updateTransactions() async {
|
|
// Get all addresses.
|
|
List<Address> allAddressesOld = await fetchAddressesForElectrumXScan();
|
|
|
|
// Separate receiving and change addresses.
|
|
Set<String> receivingAddresses = allAddressesOld
|
|
.where((e) => e.subType == AddressSubType.receiving)
|
|
.map((e) => e.value)
|
|
.toSet();
|
|
Set<String> changeAddresses = allAddressesOld
|
|
.where((e) => e.subType == AddressSubType.change)
|
|
.map((e) => e.value)
|
|
.toSet();
|
|
|
|
// Remove duplicates.
|
|
final allAddressesSet = {...receivingAddresses, ...changeAddresses};
|
|
|
|
// Fetch history from ElectrumX.
|
|
final List<Map<String, dynamic>> allTxHashes =
|
|
await fetchHistory(allAddressesSet);
|
|
|
|
// Only parse new txs (not in db yet).
|
|
List<Map<String, dynamic>> allTransactions = [];
|
|
for (final txHash in allTxHashes) {
|
|
// Check for duplicates by searching for tx by tx_hash in db.
|
|
final storedTx = await mainDB.isar.transactionV2s
|
|
.where()
|
|
.txidWalletIdEqualTo(txHash["tx_hash"] as String, walletId)
|
|
.findFirst();
|
|
|
|
if (storedTx == null ||
|
|
storedTx.height == null ||
|
|
(storedTx.height != null && storedTx.height! <= 0)) {
|
|
// Tx not in db yet.
|
|
final tx = await electrumXCachedClient.getTransaction(
|
|
txHash: txHash["tx_hash"] as String,
|
|
verbose: true,
|
|
coin: cryptoCurrency.coin,
|
|
);
|
|
|
|
// Only tx to list once.
|
|
if (allTransactions
|
|
.indexWhere((e) => e["txid"] == tx["txid"] as String) ==
|
|
-1) {
|
|
tx["height"] = txHash["height"];
|
|
allTransactions.add(tx);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse all new txs.
|
|
final List<TransactionV2> 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;
|
|
BigInt changeAmountReceivedInThisWallet = BigInt.zero;
|
|
final List<InputV2> inputs = [];
|
|
for (final jsonInput in txData["vin"] as List) {
|
|
final map = Map<String, dynamic>.from(jsonInput as Map);
|
|
|
|
final List<String> 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<String, dynamic>.from(
|
|
(inputTx["vout"] as List).firstWhere((e) => e["n"] == vout)
|
|
as Map);
|
|
|
|
final prevOut = _parseOutput(
|
|
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.isarCantDoRequiredInDefaultConstructor(
|
|
scriptSigHex: map["scriptSig"]?["hex"] as String?,
|
|
scriptSigAsm: map["scriptSig"]?["asm"] as String?,
|
|
sequence: map["sequence"] as int?,
|
|
outpoint: outpoint,
|
|
valueStringSats: valueStringSats,
|
|
addresses: addresses,
|
|
witness: map["witness"] as String?,
|
|
coinbase: coinbase,
|
|
innerRedeemScriptAsm: map["innerRedeemscriptAsm"] as String?,
|
|
// Need addresses before we can know if the wallet owns this input.
|
|
walletOwns: false,
|
|
);
|
|
|
|
// Check if input was from this wallet.
|
|
if (allAddressesSet.intersection(input.addresses.toSet()).isNotEmpty) {
|
|
wasSentFromThisWallet = true;
|
|
input = input.copyWith(walletOwns: true);
|
|
}
|
|
|
|
inputs.add(input);
|
|
}
|
|
|
|
// Parse outputs.
|
|
final List<OutputV2> outputs = [];
|
|
for (final outputJson in txData["vout"] as List) {
|
|
OutputV2 output = _parseOutput(
|
|
Map<String, dynamic>.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 (receivingAddresses
|
|
.intersection(output.addresses.toSet())
|
|
.isNotEmpty) {
|
|
wasReceivedInThisWallet = true;
|
|
amountReceivedInThisWallet += output.value;
|
|
output = output.copyWith(walletOwns: true);
|
|
} else if (changeAddresses
|
|
.intersection(output.addresses.toSet())
|
|
.isNotEmpty) {
|
|
wasReceivedInThisWallet = true;
|
|
changeAmountReceivedInThisWallet += 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;
|
|
|
|
// Particl has special outputs like confidential amounts. We can check
|
|
// for them here. They're also checked in checkBlockUTXO.
|
|
|
|
// At least one input was owned by this wallet.
|
|
if (wasSentFromThisWallet) {
|
|
type = TransactionType.outgoing;
|
|
|
|
if (wasReceivedInThisWallet) {
|
|
if (changeAmountReceivedInThisWallet + 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);
|
|
}
|
|
|
|
/// Builds and signs a transaction.
|
|
@override
|
|
Future<TxData> buildTransaction({
|
|
required TxData txData,
|
|
required List<SigningData> utxoSigningData,
|
|
}) async {
|
|
Logging.instance.log("Starting Particl buildTransaction ----------",
|
|
level: LogLevel.Info);
|
|
|
|
// TODO: use coinlib
|
|
|
|
final txb = bitcoindart.TransactionBuilder(
|
|
network: bitcoindart.NetworkType(
|
|
messagePrefix: cryptoCurrency.networkParams.messagePrefix,
|
|
bech32: cryptoCurrency.networkParams.bech32Hrp,
|
|
bip32: bitcoindart.Bip32Type(
|
|
public: cryptoCurrency.networkParams.pubHDPrefix,
|
|
private: cryptoCurrency.networkParams.privHDPrefix,
|
|
),
|
|
pubKeyHash: cryptoCurrency.networkParams.p2pkhPrefix,
|
|
scriptHash: cryptoCurrency.networkParams.p2shPrefix,
|
|
wif: cryptoCurrency.networkParams.wifPrefix,
|
|
),
|
|
);
|
|
const version = 160; // buildTransaction overridden for Particl to set this.
|
|
// TODO: [prio=low] refactor overridden buildTransaction to use eg. cryptocurrency.networkParams.txVersion.
|
|
txb.setVersion(version);
|
|
|
|
// Temp tx data for GUI while waiting for real tx from server.
|
|
final List<InputV2> tempInputs = [];
|
|
final List<OutputV2> tempOutputs = [];
|
|
|
|
// Add inputs.
|
|
for (var i = 0; i < utxoSigningData.length; i++) {
|
|
final txid = utxoSigningData[i].utxo.txid;
|
|
txb.addInput(
|
|
txid,
|
|
utxoSigningData[i].utxo.vout,
|
|
null,
|
|
utxoSigningData[i].output!,
|
|
cryptoCurrency.networkParams.bech32Hrp,
|
|
);
|
|
|
|
tempInputs.add(
|
|
InputV2.isarCantDoRequiredInDefaultConstructor(
|
|
scriptSigHex: txb.inputs.first.script?.toHex,
|
|
scriptSigAsm: null,
|
|
sequence: 0xffffffff - 1,
|
|
outpoint: OutpointV2.isarCantDoRequiredInDefaultConstructor(
|
|
txid: utxoSigningData[i].utxo.txid,
|
|
vout: utxoSigningData[i].utxo.vout,
|
|
),
|
|
addresses: utxoSigningData[i].utxo.address == null
|
|
? []
|
|
: [utxoSigningData[i].utxo.address!],
|
|
valueStringSats: utxoSigningData[i].utxo.value.toString(),
|
|
witness: null,
|
|
innerRedeemScriptAsm: null,
|
|
coinbase: null,
|
|
walletOwns: true,
|
|
),
|
|
);
|
|
}
|
|
|
|
// Add outputs.
|
|
for (var i = 0; i < txData.recipients!.length; i++) {
|
|
txb.addOutput(
|
|
txData.recipients![i].address,
|
|
txData.recipients![i].amount.raw.toInt(),
|
|
cryptoCurrency.networkParams.bech32Hrp,
|
|
);
|
|
|
|
tempOutputs.add(
|
|
OutputV2.isarCantDoRequiredInDefaultConstructor(
|
|
scriptPubKeyHex: "000000",
|
|
valueStringSats: txData.recipients![i].amount.raw.toString(),
|
|
addresses: [
|
|
txData.recipients![i].address.toString(),
|
|
],
|
|
walletOwns: (await mainDB.isar.addresses
|
|
.where()
|
|
.walletIdEqualTo(walletId)
|
|
.filter()
|
|
.valueEqualTo(txData.recipients![i].address)
|
|
.valueProperty()
|
|
.findFirst()) !=
|
|
null,
|
|
),
|
|
);
|
|
}
|
|
|
|
// Sign.
|
|
try {
|
|
for (var i = 0; i < utxoSigningData.length; i++) {
|
|
txb.sign(
|
|
vin: i,
|
|
keyPair: utxoSigningData[i].keyPair!,
|
|
witnessValue: utxoSigningData[i].utxo.value,
|
|
redeemScript: utxoSigningData[i].redeemScript,
|
|
overridePrefix: cryptoCurrency.networkParams.bech32Hrp,
|
|
);
|
|
}
|
|
} catch (e, s) {
|
|
Logging.instance.log("Caught exception while signing transaction: $e\n$s",
|
|
level: LogLevel.Error);
|
|
rethrow;
|
|
}
|
|
|
|
final builtTx = txb.build(cryptoCurrency.networkParams.bech32Hrp);
|
|
final vSize = builtTx.virtualSize();
|
|
|
|
// Strip trailing 0x00 bytes from hex.
|
|
//
|
|
// This is done to match the previous particl_wallet implementation.
|
|
// TODO: [prio=low] Rework Particl tx construction so as to obviate this.
|
|
String hexString = builtTx.toHex(isParticl: true).toString();
|
|
if (hexString.length % 2 != 0) {
|
|
// Ensure the string has an even length.
|
|
Logging.instance.log("Hex string has odd length, which is unexpected.",
|
|
level: LogLevel.Error);
|
|
throw Exception("Invalid hex string length.");
|
|
}
|
|
// int maxStrips = 3; // Strip up to 3 0x00s (match previous particl_wallet).
|
|
while (hexString.endsWith('00') && hexString.length > 2) {
|
|
hexString = hexString.substring(0, hexString.length - 2);
|
|
// maxStrips--;
|
|
// if (maxStrips <= 0) {
|
|
// break;
|
|
// }
|
|
}
|
|
|
|
return txData.copyWith(
|
|
raw: hexString,
|
|
vSize: vSize,
|
|
tempTx: null,
|
|
// builtTx.getId() requires an isParticl flag as well but the lib does not support that yet
|
|
// tempTx: TransactionV2(
|
|
// walletId: walletId,
|
|
// blockHash: null,
|
|
// hash: builtTx.getId(),
|
|
// txid: builtTx.getId(),
|
|
// height: null,
|
|
// timestamp: DateTime.timestamp().millisecondsSinceEpoch ~/ 1000,
|
|
// inputs: List.unmodifiable(tempInputs),
|
|
// outputs: List.unmodifiable(tempOutputs),
|
|
// version: version,
|
|
// type: tempOutputs.map((e) => e.walletOwns).fold(true, (p, e) => p &= e)
|
|
// ? TransactionType.sentToSelf
|
|
// : TransactionType.outgoing,
|
|
// subType: TransactionSubType.none,
|
|
// otherData: null,
|
|
// ),
|
|
);
|
|
}
|
|
|
|
/// OutputV2.fromElectrumXJson wrapper for Particl-specific outputs.
|
|
OutputV2 _parseOutput(
|
|
Map<String, dynamic> json, {
|
|
// Other params just passed thru to fromElectrumXJson for transparent outs.
|
|
required bool walletOwns,
|
|
required bool isFullAmountNotSats,
|
|
required int decimalPlaces,
|
|
}) {
|
|
// TODO: [prio=med] Confirm that all the tx types below are handled well.
|
|
// Right now we essentially ignore txs with ct_fee, rangeproof, or data_hex
|
|
// keys. We may also want to set walletOwns to true (if we know the owner).
|
|
if (json.containsKey('ct_fee')) {
|
|
// Blind output, ignore for now.
|
|
return OutputV2.isarCantDoRequiredInDefaultConstructor(
|
|
scriptPubKeyHex: '',
|
|
valueStringSats: '0',
|
|
addresses: [],
|
|
walletOwns: false,
|
|
);
|
|
} else if (json.containsKey('rangeproof')) {
|
|
// Private RingCT output, ignore for now.
|
|
return OutputV2.isarCantDoRequiredInDefaultConstructor(
|
|
scriptPubKeyHex: '',
|
|
valueStringSats: '0',
|
|
addresses: [],
|
|
walletOwns: false,
|
|
);
|
|
} else if (json.containsKey('data_hex')) {
|
|
// Data output, ignore for now.
|
|
return OutputV2.isarCantDoRequiredInDefaultConstructor(
|
|
scriptPubKeyHex: '',
|
|
valueStringSats: '0',
|
|
addresses: [],
|
|
walletOwns: false,
|
|
);
|
|
} else if (json.containsKey('scriptPubKey')) {
|
|
// Transparent output.
|
|
return OutputV2.fromElectrumXJson(
|
|
json,
|
|
walletOwns: walletOwns,
|
|
isFullAmountNotSats: isFullAmountNotSats,
|
|
decimalPlaces: decimalPlaces,
|
|
);
|
|
} else {
|
|
throw Exception("Unknown output type: $json");
|
|
}
|
|
}
|
|
}
|