WIP initial dash support

This commit is contained in:
julian 2024-06-20 14:40:52 -06:00
parent db7ed7bd77
commit 33ec9f1fb9
5 changed files with 578 additions and 0 deletions

View file

@ -0,0 +1,257 @@
import 'package:coinlib_flutter/coinlib_flutter.dart' as coinlib;
import '../../../models/isar/models/blockchain_data/address.dart';
import '../../../models/node_model.dart';
import '../../../utilities/amount/amount.dart';
import '../../../utilities/enums/derive_path_type_enum.dart';
import '../crypto_currency.dart';
import '../interfaces/electrumx_currency_interface.dart';
import '../intermediate/bip39_hd_currency.dart';
class Dash extends Bip39HDCurrency with ElectrumXCurrencyInterface {
Dash(super.network) {
_idMain = "dash";
_uriScheme = "dash";
switch (network) {
case CryptoCurrencyNetwork.main:
_id = _idMain;
_name = "Dash";
_ticker = "DASH";
case CryptoCurrencyNetwork.test:
_id = "dashTestNet";
_name = "tDash";
_ticker = "tDASH";
default:
throw Exception("Unsupported network: $network");
}
}
late final String _id;
@override
String get identifier => _id;
late final String _idMain;
@override
String get mainNetId => _idMain;
late final String _name;
@override
String get prettyName => _name;
late final String _uriScheme;
@override
String get uriScheme => _uriScheme;
late final String _ticker;
@override
String get ticker => _ticker;
@override
bool get torSupport => true;
@override
List<DerivePathType> get supportedDerivationPathTypes => [
DerivePathType.bip44,
];
@override
String constructDerivePath({
required DerivePathType derivePathType,
int account = 0,
required int chain,
required int index,
}) {
String coinType;
switch (networkParams.wifPrefix) {
case 204: // dash mainnet wif
coinType = "5"; // dash mainnet
break;
case 239: // dash testnet wif
coinType = "1"; // dash testnet
break;
default:
throw Exception("Invalid Dash network wif used!");
}
int purpose;
switch (derivePathType) {
case DerivePathType.bip44:
purpose = 44;
break;
default:
throw Exception("DerivePathType $derivePathType not supported");
}
return "m/$purpose'/$coinType'/$account'/$chain/$index";
}
@override
Amount get dustLimit => Amount(
rawValue: BigInt.from(1000000),
fractionDigits: fractionDigits,
);
@override
String get genesisHash {
switch (network) {
// TODO
// case CryptoCurrencyNetwork.main:
// return " ";
// case CryptoCurrencyNetwork.test:
// return " ";
default:
throw Exception("Unsupported network: $network");
}
}
@override
({
coinlib.Address address,
AddressType addressType,
}) getAddressForPublicKey({
required coinlib.ECPublicKey publicKey,
required DerivePathType derivePathType,
}) {
switch (derivePathType) {
case DerivePathType.bip44:
final addr = coinlib.P2PKHAddress.fromPublicKey(
publicKey,
version: networkParams.p2pkhPrefix,
);
return (address: addr, addressType: AddressType.p2pkh);
default:
throw Exception("DerivePathType $derivePathType not supported");
}
}
@override
int get minConfirms => 1;
@override
coinlib.Network get networkParams {
switch (network) {
case CryptoCurrencyNetwork.main:
return coinlib.Network(
p2pkhPrefix: 76,
p2shPrefix: 16,
wifPrefix: 204,
pubHDPrefix: 0x0488B21E,
privHDPrefix: 0x0488ADE4,
bech32Hrp: "dash", // TODO ?????
messagePrefix: '\x18Dash Signed Message:\n', // TODO ?????
minFee: BigInt.from(1), // Not used in stack wallet currently
minOutput: dustLimit.raw, // Not used in stack wallet currently
feePerKb: BigInt.from(1), // Not used in stack wallet currently
);
case CryptoCurrencyNetwork.test:
return coinlib.Network(
p2pkhPrefix: 140,
p2shPrefix: 19,
wifPrefix: 239,
pubHDPrefix: 0x043587CF,
privHDPrefix: 0x04358394,
bech32Hrp: "tdash", // TODO ?????
messagePrefix: '\x18Dash Signed Message:\n', // TODO ?????
minFee: BigInt.from(1), // Not used in stack wallet currently
minOutput: dustLimit.raw, // Not used in stack wallet currently
feePerKb: BigInt.from(1), // Not used in stack wallet currently
);
default:
throw Exception("Unsupported network: $network");
}
}
@override
bool validateAddress(String address) {
try {
coinlib.Address.fromString(address, networkParams);
return true;
} catch (_) {
return false;
}
}
@override
NodeModel get defaultNode {
switch (network) {
// case CryptoCurrencyNetwork.main:
// return NodeModel(
// host: "dash.stackwallet.com",
// port: 50022,
// name: DefaultNodes.defaultName,
// id: DefaultNodes.buildId(this),
// useSSL: true,
// enabled: true,
// coinName: identifier,
// isFailover: true,
// isDown: false,
// );
//
// case CryptoCurrencyNetwork.test:
// return NodeModel(
// host: "dash-testnet.stackwallet.com",
// port: 50022,
// name: DefaultNodes.defaultName,
// id: DefaultNodes.buildId(this),
// useSSL: true,
// enabled: true,
// coinName: identifier,
// isFailover: true,
// isDown: false,
// );
default:
throw UnimplementedError();
}
}
@override
int get defaultSeedPhraseLength => 12;
@override
int get fractionDigits => 8;
@override
bool get hasBuySupport => true;
@override
bool get hasMnemonicPassphraseSupport => true;
@override
List<int> get possibleMnemonicLengths => [defaultSeedPhraseLength, 24];
@override
AddressType get defaultAddressType => defaultDerivePathType.getAddressType();
@override
BigInt get satsPerCoin => BigInt.from(100000000);
@override
int get targetBlockTimeSeconds => 60;
@override
DerivePathType get defaultDerivePathType => DerivePathType.bip44;
@override
Uri defaultBlockExplorer(String txid) {
switch (network) {
// TODO
// case CryptoCurrencyNetwork.main:
// case CryptoCurrencyNetwork.test:
default:
throw Exception(
"Unsupported network for defaultBlockExplorer(): $network",
);
}
}
@override
int get transactionVersion => 1;
@override
BigInt get defaultFeeRate => BigInt.from(1000); // TODO check for dash?
}

View file

@ -6,6 +6,7 @@ export 'coins/banano.dart';
export 'coins/bitcoin.dart';
export 'coins/bitcoin_frost.dart';
export 'coins/bitcoincash.dart';
export 'coins/dash.dart';
export 'coins/dogecoin.dart';
export 'coins/ecash.dart';
export 'coins/epiccash.dart';

View file

@ -0,0 +1,314 @@
import 'package:isar/isar.dart';
import '../../../models/isar/models/blockchain_data/address.dart';
import '../../../models/isar/models/blockchain_data/transaction.dart';
import '../../../models/isar/models/blockchain_data/v2/input_v2.dart';
import '../../../models/isar/models/blockchain_data/v2/output_v2.dart';
import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart';
import '../../../utilities/amount/amount.dart';
import '../../../utilities/logger.dart';
import '../../crypto_currency/crypto_currency.dart';
import '../../crypto_currency/interfaces/electrumx_currency_interface.dart';
import '../intermediate/bip39_hd_wallet.dart';
import '../wallet_mixin_interfaces/coin_control_interface.dart';
import '../wallet_mixin_interfaces/electrumx_interface.dart';
class DashWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
with ElectrumXInterface<T>, CoinControlInterface {
DashWallet(CryptoCurrencyNetwork network) : super(Dash(network) as T);
@override
int get maximumFeerate => 2500;
@override
int get isarTransactionVersion => 2;
@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<void> updateTransactions() async {
// Get all addresses.
final List<Address> allAddressesOld =
await fetchAddressesForElectrumXScan();
// Separate receiving and change addresses.
final Set<String> receivingAddresses = allAddressesOld
.where((e) => e.subType == AddressSubType.receiving)
.map((e) => e.value)
.toSet();
final 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).
final 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,
cryptoCurrency: cryptoCurrency,
);
// 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,
cryptoCurrency: cryptoCurrency,
);
final prevOutJson = Map<String, dynamic>.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.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 = OutputV2.fromElectrumXJson(
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;
final TransactionSubType subType = TransactionSubType.none;
// 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.
}
// This is where we would check for them.
// TODO: [prio=high] Check for special Dash outputs.
}
} 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<({String? blockedReason, bool blocked, String? utxoLabel})>
checkBlockUTXO(
Map<String, dynamic> jsonUTXO,
String? scriptPubKeyHex,
Map<String, dynamic> jsonTX,
String? utxoOwnerAddress,
) async {
bool blocked = false;
String? blockedReason;
// // check for bip47 notification
// final outputs = jsonTX["vout"] as List;
// for (final output in outputs) {
// final List<String>? scriptChunks =
// (output['scriptPubKey']?['asm'] as String?)?.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) {
// blocked = true;
// blockedReason = "Paynym notification output. Incautious "
// "handling of outputs from notification transactions "
// "may cause unintended loss of privacy.";
// break;
// }
// }
// }
return (blockedReason: blockedReason, blocked: blocked, utxoLabel: null);
}
@override
Amount roughFeeEstimate(int inputCount, int outputCount, int feeRatePerKB) {
return Amount(
rawValue: BigInt.from(
((181 * inputCount) + (34 * outputCount) + 10) *
(feeRatePerKB / 1000).ceil(),
),
fractionDigits: cryptoCurrency.fractionDigits,
);
}
@override
int estimateTxFee({required int vSize, required int feeRatePerKB}) {
return vSize * (feeRatePerKB / 1000).ceil();
}
}

View file

@ -28,6 +28,7 @@ import 'impl/banano_wallet.dart';
import 'impl/bitcoin_frost_wallet.dart';
import 'impl/bitcoin_wallet.dart';
import 'impl/bitcoincash_wallet.dart';
import 'impl/dash_wallet.dart';
import 'impl/dogecoin_wallet.dart';
import 'impl/ecash_wallet.dart';
import 'impl/epiccash_wallet.dart';
@ -323,6 +324,9 @@ abstract class Wallet<T extends CryptoCurrency> {
case const (Bitcoincash):
return BitcoincashWallet(net);
case const (Dash):
return DashWallet(net);
case const (Dogecoin):
return DogecoinWallet(net);

View file

@ -55,6 +55,7 @@ final List<CryptoCurrency> _supportedCoins = List.unmodifiable([
Banano(CryptoCurrencyNetwork.main),
Bitcoincash(CryptoCurrencyNetwork.main),
BitcoinFrost(CryptoCurrencyNetwork.main),
Dash(CryptoCurrencyNetwork.main),
Dogecoin(CryptoCurrencyNetwork.main),
Ecash(CryptoCurrencyNetwork.main),
Epiccash(CryptoCurrencyNetwork.main),
@ -74,6 +75,7 @@ final List<CryptoCurrency> _supportedCoins = List.unmodifiable([
Bitcoincash(CryptoCurrencyNetwork.test),
BitcoinFrost(CryptoCurrencyNetwork.test),
BitcoinFrost(CryptoCurrencyNetwork.test4),
Dash(CryptoCurrencyNetwork.test),
Dogecoin(CryptoCurrencyNetwork.test),
Firo(CryptoCurrencyNetwork.test),
Litecoin(CryptoCurrencyNetwork.test),