| 1 | import 'dart:async'; |
| 2 | import 'dart:ffi'; |
| 3 | import 'dart:isolate'; |
| 4 | |
| 5 | import 'package:cw_core/utils/print_verbose.dart'; |
| 6 | import 'package:cw_monero/api/account_list.dart'; |
| 7 | import 'package:cw_monero/api/exceptions/creation_transaction_exception.dart'; |
| 8 | import 'package:cw_monero/api/monero_output.dart'; |
| 9 | import 'package:cw_monero/api/structs/pending_transaction.dart'; |
| 10 | import 'package:cw_monero/api/wallet.dart'; |
| 11 | import 'package:cw_monero/exceptions/monero_transaction_creation_exception.dart'; |
| 12 | import 'package:ffi/ffi.dart'; |
| 13 | import 'package:monero/src/monero.dart'; |
| 14 | import 'package:monero/monero.dart' as monero; |
| 15 | import 'package:monero/src/wallet2.dart'; |
| 16 | import 'package:monero/src/generated_bindings_monero.g.dart' as monero_gen; |
| 17 | import 'package:mutex/mutex.dart'; |
| 18 | |
| 19 | String _formatTransactionError(String error) { |
| 20 | final message = error.replaceAll( |
| 21 | RegExp( |
| 22 | r'(?:[A-Za-z]:)?[\\/][^\s:]*\.(?:c|cc|cpp|cxx|h|hpp|hxx):\d+:(?:[A-Za-z0-9_]+:)?\s*', |
| 23 | ), |
| 24 | '', |
| 25 | ); |
| 26 | if (message.contains("RPC error")) { |
| 27 | return "Invalid node response, please try again or switch node\n\ntrace: $message"; |
| 28 | } |
| 29 | return message; |
| 30 | } |
| 31 | |
| 32 | Map<int, Map<String, String>> txKeys = {}; |
| 33 | String getTxKey(String txId) { |
| 34 | txKeys[currentWallet!.ffiAddress()] ??= {}; |
| 35 | if (txKeys[currentWallet!.ffiAddress()]![txId] != null) { |
| 36 | return txKeys[currentWallet!.ffiAddress()]![txId]!; |
| 37 | } |
| 38 | final txKey = currentWallet!.getTxKey(txid: txId); |
| 39 | final status = currentWallet!.status(); |
| 40 | if (status != 0) { |
| 41 | currentWallet!.errorString(); |
| 42 | txKeys[currentWallet!.ffiAddress()]![txId] = ""; |
| 43 | return ""; |
| 44 | } |
| 45 | txKeys[currentWallet!.ffiAddress()]![txId] = txKey; |
| 46 | return txKey; |
| 47 | } |
| 48 | |
| 49 | final txHistoryMutex = Mutex(); |
| 50 | Wallet2TransactionHistory? txhistory; |
| 51 | bool isRefreshingTx = false; |
| 52 | Future<void> refreshTransactions() async { |
| 53 | if (isRefreshingTx == true) return; |
| 54 | isRefreshingTx = true; |
| 55 | txhistory ??= currentWallet!.history(); |
| 56 | final ptr = txhistory!.ffiAddress(); |
| 57 | await txHistoryMutex.acquire(); |
| 58 | await Isolate.run(() { |
| 59 | monero.TransactionHistory_refresh(Pointer.fromAddress(ptr)); |
| 60 | }); |
| 61 | await Future.delayed(Duration.zero); |
| 62 | txHistoryMutex.release(); |
| 63 | isRefreshingTx = false; |
| 64 | } |
| 65 | |
| 66 | int countOfTransactions() => txhistory!.count(); |
| 67 | |
| 68 | Future<List<Transaction>> getAllTransactions() async { |
| 69 | List<Transaction> dummyTxs = []; |
| 70 | |
| 71 | await txHistoryMutex.acquire(); |
| 72 | txhistory ??= currentWallet!.history(); |
| 73 | final startAddress = txhistory!.ffiAddress() * currentWallet!.ffiAddress(); |
| 74 | int size = countOfTransactions(); |
| 75 | final list = <Transaction>[]; |
| 76 | for (int index = 0; index < size; index++) { |
| 77 | if (index % 25 == 0) { |
| 78 | // Give main thread a chance to do other things. |
| 79 | await Future.delayed(Duration.zero); |
| 80 | } |
| 81 | if (txhistory!.ffiAddress() * currentWallet!.ffiAddress() != startAddress) { |
| 82 | printV("Loop broken because txhistory!.address * wptr!.address != startAddress"); |
| 83 | break; |
| 84 | } |
| 85 | final txInfo = txhistory!.transaction(index); |
| 86 | final txHash = txInfo.hash(); |
| 87 | txCache[currentWallet!.ffiAddress()] ??= {}; |
| 88 | txCache[currentWallet!.ffiAddress()]![txHash] = Transaction(txInfo: txInfo); |
| 89 | list.add(txCache[currentWallet!.ffiAddress()]![txHash]!); |
| 90 | } |
| 91 | txHistoryMutex.release(); |
| 92 | final accts = currentWallet!.numSubaddressAccounts(); |
| 93 | for (var i = 0; i < accts; i++) { |
| 94 | final fullBalance = currentWallet!.balance(accountIndex: i); |
| 95 | final availBalance = currentWallet!.unlockedBalance(accountIndex: i); |
| 96 | if (fullBalance > availBalance) { |
| 97 | if (list |
| 98 | .where((element) => element.accountIndex == i && element.isConfirmed == false) |
| 99 | .isEmpty) { |
| 100 | dummyTxs.add(Transaction.dummy( |
| 101 | displayLabel: "", |
| 102 | description: "", |
| 103 | fee: 0, |
| 104 | confirmations: 0, |
| 105 | blockheight: 0, |
| 106 | accountIndex: i, |
| 107 | addressIndex: 0, |
| 108 | addressIndexList: [0], |
| 109 | paymentId: "", |
| 110 | amount: fullBalance - availBalance, |
| 111 | isSpend: false, |
| 112 | hash: "pending", |
| 113 | key: "", |
| 114 | txInfo: DummyTransaction(), |
| 115 | )..timeStamp = DateTime.now()); |
| 116 | } |
| 117 | } |
| 118 | } |
| 119 | list.addAll(dummyTxs); |
| 120 | return list; |
| 121 | } |
| 122 | |
| 123 | class DummyTransaction implements Wallet2TransactionInfo { |
| 124 | @override |
| 125 | dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); |
| 126 | } |
| 127 | |
| 128 | Map<int, Map<String, Transaction>> txCache = {}; |
| 129 | Future<Transaction> getTransaction(String txId) async { |
| 130 | if (txCache[currentWallet!.ffiAddress()] != null && |
| 131 | txCache[currentWallet!.ffiAddress()]![txId] != null) { |
| 132 | return txCache[currentWallet!.ffiAddress()]![txId]!; |
| 133 | } |
| 134 | await txHistoryMutex.acquire(); |
| 135 | final tx = txhistory!.transactionById(txId); |
| 136 | final txDart = Transaction(txInfo: tx); |
| 137 | txCache[currentWallet!.ffiAddress()] ??= {}; |
| 138 | txCache[currentWallet!.ffiAddress()]![txId] = txDart; |
| 139 | txHistoryMutex.release(); |
| 140 | return txDart; |
| 141 | } |
| 142 | |
| 143 | Future<PendingTransactionDescription> createTransactionSync( |
| 144 | {required String address, |
| 145 | required String paymentId, |
| 146 | required int priorityRaw, |
| 147 | String? amount, |
| 148 | int accountIndex = 0, |
| 149 | List<String> preferredInputs = const []}) async { |
| 150 | final amt = amount == null ? 0 : currentWallet!.amountFromString(amount); |
| 151 | |
| 152 | final waddr = currentWallet!.ffiAddress(); |
| 153 | |
| 154 | // force reconnection in case the os killed the connection? |
| 155 | // fixes failed to get block height error. |
| 156 | Isolate.run(() async { |
| 157 | monero.Wallet_synchronized(Pointer.fromAddress(waddr)); |
| 158 | }); |
| 159 | |
| 160 | final address_ = address.toNativeUtf8(); |
| 161 | final paymentId_ = paymentId.toNativeUtf8(); |
| 162 | if (preferredInputs.isEmpty) { |
| 163 | throw MoneroTransactionCreationException( |
| 164 | "No inputs provided, transaction cannot be constructed"); |
| 165 | } |
| 166 | |
| 167 | final preferredInputs_ = preferredInputs.join(monero.defaultSeparatorStr).toNativeUtf8(); |
| 168 | |
| 169 | final addraddr = address_.address; |
| 170 | final paymentIdAddr = paymentId_.address; |
| 171 | final preferredInputsAddr = preferredInputs_.address; |
| 172 | final spaddr = monero.defaultSeparator.address; |
| 173 | final pendingTxPtr = Pointer<Void>.fromAddress(await Isolate.run(() { |
| 174 | final tx = |
| 175 | monero_gen.MoneroC(DynamicLibrary.open(monero.libPath)).MONERO_Wallet_createTransaction( |
| 176 | Pointer.fromAddress(waddr), |
| 177 | Pointer.fromAddress(addraddr).cast(), |
| 178 | Pointer.fromAddress(paymentIdAddr).cast(), |
| 179 | amt, |
| 180 | 1, |
| 181 | priorityRaw, |
| 182 | accountIndex, |
| 183 | Pointer.fromAddress(preferredInputsAddr).cast(), |
| 184 | Pointer.fromAddress(spaddr), |
| 185 | ); |
| 186 | return tx.address; |
| 187 | })); |
| 188 | final Wallet2PendingTransaction pendingTx = MoneroPendingTransaction(pendingTxPtr); |
| 189 | calloc.free(address_); |
| 190 | calloc.free(paymentId_); |
| 191 | calloc.free(preferredInputs_); |
| 192 | final String? error = (() { |
| 193 | final status = pendingTx.status(); |
| 194 | if (status == 0) { |
| 195 | return null; |
| 196 | } |
| 197 | return pendingTx.errorString(); |
| 198 | })(); |
| 199 | |
| 200 | if (error != null) { |
| 201 | throw CreationTransactionException(message: _formatTransactionError(error)); |
| 202 | } |
| 203 | |
| 204 | final rAmt = pendingTx.amount(); |
| 205 | final rFee = pendingTx.fee(); |
| 206 | final rHash = pendingTx.txid(''); |
| 207 | final rHex = pendingTx.hex(''); |
| 208 | |
| 209 | return PendingTransactionDescription( |
| 210 | amount: rAmt, |
| 211 | fee: rFee, |
| 212 | hash: rHash, |
| 213 | hex: rHex, |
| 214 | pointerAddress: pendingTx.ffiAddress(), |
| 215 | ); |
| 216 | } |
| 217 | |
| 218 | Future<PendingTransactionDescription> createTransactionMultDest( |
| 219 | {required List<MoneroOutput> outputs, |
| 220 | required String paymentId, |
| 221 | required int priorityRaw, |
| 222 | int accountIndex = 0, |
| 223 | List<String> preferredInputs = const []}) async { |
| 224 | final dstAddrs = outputs.map((e) => e.address).toList(); |
| 225 | final amounts = outputs.map((e) => currentWallet!.amountFromString(e.amount)).toList(); |
| 226 | |
| 227 | final waddr = currentWallet!.ffiAddress(); |
| 228 | |
| 229 | // force reconnection in case the os killed the connection |
| 230 | Isolate.run(() async { |
| 231 | monero.Wallet_synchronized(Pointer.fromAddress(waddr)); |
| 232 | }); |
| 233 | |
| 234 | final txptr = Pointer<Void>.fromAddress(await Isolate.run(() { |
| 235 | return monero.Wallet_createTransactionMultDest( |
| 236 | Pointer.fromAddress(waddr), |
| 237 | dstAddr: dstAddrs, |
| 238 | isSweepAll: false, |
| 239 | amounts: amounts, |
| 240 | mixinCount: 0, |
| 241 | pendingTransactionPriority: priorityRaw, |
| 242 | subaddr_account: accountIndex, |
| 243 | ).address; |
| 244 | })); |
| 245 | |
| 246 | final Wallet2PendingTransaction tx = MoneroPendingTransaction(txptr); |
| 247 | |
| 248 | if (tx.status() != 0) { |
| 249 | throw CreationTransactionException(message: _formatTransactionError(tx.errorString())); |
| 250 | } |
| 251 | |
| 252 | return PendingTransactionDescription( |
| 253 | amount: tx.amount(), |
| 254 | fee: tx.fee(), |
| 255 | hash: tx.txid(''), |
| 256 | hex: tx.hex(''), |
| 257 | pointerAddress: tx.ffiAddress(), |
| 258 | ); |
| 259 | } |
| 260 | |
| 261 | Future<String?> commitTransactionFromPointerAddress({required int address, required bool useUR}) => |
| 262 | commitTransaction(tx: MoneroPendingTransaction(Pointer.fromAddress(address)), useUR: useUR); |
| 263 | |
| 264 | Future<String?> commitTransaction( |
| 265 | {required Wallet2PendingTransaction tx, required bool useUR}) async { |
| 266 | final txCommit = useUR |
| 267 | ? tx.commitUR(120) |
| 268 | : await Isolate.run(() { |
| 269 | monero.PendingTransaction_commit( |
| 270 | Pointer.fromAddress(tx.ffiAddress()), |
| 271 | filename: '', |
| 272 | overwrite: false, |
| 273 | ); |
| 274 | return null; |
| 275 | }); |
| 276 | |
| 277 | String? error = (() { |
| 278 | final status = tx.status(); |
| 279 | if (status == 0) { |
| 280 | return null; |
| 281 | } |
| 282 | return tx.errorString(); |
| 283 | })(); |
| 284 | if (error == null) { |
| 285 | error = (() { |
| 286 | final status = currentWallet!.status(); |
| 287 | if (status == 0) { |
| 288 | return null; |
| 289 | } |
| 290 | return currentWallet!.errorString(); |
| 291 | })(); |
| 292 | } |
| 293 | if (error != null && error != "no tx keys found for this txid") { |
| 294 | throw CreationTransactionException(message: _formatTransactionError(error)); |
| 295 | } |
| 296 | unawaited(() async { |
| 297 | storeSync(force: true); |
| 298 | await Future.delayed(Duration(seconds: 5)); |
| 299 | storeSync(force: true); |
| 300 | }()); |
| 301 | return Future.value(txCommit); |
| 302 | } |
| 303 | |
| 304 | class Transaction { |
| 305 | final String displayLabel; |
| 306 | late final String subaddressLabel = currentWallet!.getSubaddressLabel( |
| 307 | accountIndex: accountIndex, |
| 308 | addressIndex: addressIndex, |
| 309 | ); |
| 310 | late final String address = getAddress( |
| 311 | accountIndex: accountIndex, |
| 312 | addressIndex: addressIndex, |
| 313 | ); |
| 314 | late final List<String> addressList = List.generate( |
| 315 | addressIndexList.length, |
| 316 | (index) => getAddress( |
| 317 | accountIndex: accountIndex, |
| 318 | addressIndex: addressIndexList[index], |
| 319 | )); |
| 320 | final String description; |
| 321 | final int fee; |
| 322 | final int confirmations; |
| 323 | late final bool isPending = confirmations < 10; |
| 324 | final int blockheight; |
| 325 | final int addressIndex; |
| 326 | final int accountIndex; |
| 327 | final List<int> addressIndexList; |
| 328 | final String paymentId; |
| 329 | final int amount; |
| 330 | final bool isSpend; |
| 331 | late DateTime timeStamp; |
| 332 | late final bool isConfirmed = !isPending; |
| 333 | final String hash; |
| 334 | final String key; |
| 335 | |
| 336 | Map<String, dynamic> toJson() { |
| 337 | return { |
| 338 | "displayLabel": displayLabel, |
| 339 | "subaddressLabel": subaddressLabel, |
| 340 | "address": address, |
| 341 | "description": description, |
| 342 | "fee": fee, |
| 343 | "confirmations": confirmations, |
| 344 | "isPending": isPending, |
| 345 | "blockheight": blockheight, |
| 346 | "accountIndex": accountIndex, |
| 347 | "addressIndex": addressIndex, |
| 348 | "paymentId": paymentId, |
| 349 | "amount": amount, |
| 350 | "isSpend": isSpend, |
| 351 | "timeStamp": timeStamp.toIso8601String(), |
| 352 | "isConfirmed": isConfirmed, |
| 353 | "hash": hash, |
| 354 | }; |
| 355 | } |
| 356 | |
| 357 | // final SubAddress? subAddress; |
| 358 | // List<Transfer> transfers = []; |
| 359 | // final int txIndex; |
| 360 | final Wallet2TransactionInfo txInfo; |
| 361 | Transaction({ |
| 362 | required this.txInfo, |
| 363 | }) : displayLabel = txInfo.label(), |
| 364 | hash = txInfo.hash(), |
| 365 | timeStamp = DateTime.fromMillisecondsSinceEpoch( |
| 366 | txInfo.timestamp() * 1000, |
| 367 | ), |
| 368 | isSpend = txInfo.direction() == monero.TransactionInfo_Direction.Out.index, |
| 369 | amount = txInfo.amount(), |
| 370 | paymentId = txInfo.paymentId(), |
| 371 | accountIndex = txInfo.subaddrAccount(), |
| 372 | addressIndex = int.tryParse(txInfo.subaddrIndex().split(", ")[0]) ?? 0, |
| 373 | addressIndexList = |
| 374 | txInfo.subaddrIndex().split(", ").map((e) => int.tryParse(e) ?? 0).toList(), |
| 375 | blockheight = txInfo.blockHeight(), |
| 376 | confirmations = txInfo.confirmations(), |
| 377 | fee = txInfo.fee(), |
| 378 | description = txInfo.description(), |
| 379 | key = getTxKey(txInfo.hash()); |
| 380 | |
| 381 | Transaction.dummy( |
| 382 | {required this.displayLabel, |
| 383 | required this.description, |
| 384 | required this.fee, |
| 385 | required this.confirmations, |
| 386 | required this.blockheight, |
| 387 | required this.accountIndex, |
| 388 | required this.addressIndexList, |
| 389 | required this.addressIndex, |
| 390 | required this.paymentId, |
| 391 | required this.amount, |
| 392 | required this.isSpend, |
| 393 | required this.hash, |
| 394 | required this.key, |
| 395 | required this.txInfo}); |
| 396 | } |