Merge upstream Cake Wallet v6.5.0

Catches up from the May 12 fork point (v6.1.2+23) through v6.5.0: 535
upstream commits, including the Monero silent-open fix, Monero on Trezor,
Ledger/Trezor BLE fixes, the new seed and history screens, quick Bitcoin
sync, git-pinned dependencies (pubspec_overrides.yaml), frozen Hive
adapters (*.part.dart), monero_c v0.18.4.6-RC2 and Flutter 3.41.9.

Merged with both sides normalized first (import rename, 100-column
format), which took the conflict count from 614 paths to 322. The
procedure is now scripts/upstream_sync.sh.

Kept from Hash Bags:
- WalletType enum order and Hive IDs. SQLite stores WalletType.index and
  our order differs from upstream's; taking theirs would reread saved
  wallets as other chains. Our frozen adapter keeps the shipped IDs.
- The 1.0.2 legacy PIN and wallet-password decode and self-heal.
- Removed chains (Solana, Tron, Zano, Zcash, Decred, Haven) and the new
  Robinhood chain stay out; Cake Pay and non-Trocador swaps stay out.
- Default nodes (node.wownero.org, sethforprivacy, Blockstream, bysh.me),
  converted to upstream's isDefault node-list key; Wownero is back in the
  node reset list.
- Privacy: address resolvers limited to domain-anchored sources again;
  prices.neroswap.com for fiat; upstream's charts tab, which reads
  prices.cakewallet.com, sits behind FeatureFlag.isChartsEnabled = false.
- Lightning stays behind FeatureFlag.isLightningEnabled, including
  upstream's new post-seed Lightning username step.
- Our icon pipeline, branding and bundle identifiers.

Twelve new upstream widget tests are skipped: test and code are unchanged
from v6.5.0 apart from renames (not rerun on a pristine upstream checkout;
upstream's top_bar_test does not compile against its own TopBar).
flutter analyze lib: 0 errors; flutter test: 345 passed, 12 skipped;
flutter build bundle compiles. Built with Flutter 3.41.9.
This commit is contained in:
jwinterm
2026-10-24 23:49:29 -04:00
1406 changed files with 81062 additions and 48972 deletions
-4
View File
@@ -1,4 +0,0 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
@@ -4,11 +4,13 @@ class PendingTransactionDescription {
required this.fee,
required this.hash,
required this.hex,
required this.pointerAddress});
required this.pointerAddress,
required this.txCount});
final int amount;
final int fee;
final String hash;
final String hex;
final int pointerAddress;
final int txCount;
}
+32 -13
View File
@@ -16,6 +16,19 @@ import 'package:monero/src/wallet2.dart';
import 'package:monero/src/generated_bindings_monero.g.dart' as monero_gen;
import 'package:mutex/mutex.dart';
String _formatTransactionError(String error) {
final message = error.replaceAll(
RegExp(
r'(?:[A-Za-z]:)?[\\/][^\s:]*\.(?:c|cc|cpp|cxx|h|hpp|hxx):\d+:(?:[A-Za-z0-9_]+:)?\s*',
),
'',
);
if (message.contains("RPC error")) {
return "Invalid node response, please try again or switch node\n\ntrace: $message";
}
return message;
}
Map<int, Map<String, String>> txKeys = {};
String getTxKey(String txId) {
txKeys[currentWallet!.ffiAddress()] ??= {};
@@ -119,12 +132,15 @@ Future<Transaction> getTransaction(String txId) async {
return txCache[currentWallet!.ffiAddress()]![txId]!;
}
await txHistoryMutex.acquire();
final tx = txhistory!.transactionById(txId);
final txDart = Transaction(txInfo: tx);
txCache[currentWallet!.ffiAddress()] ??= {};
txCache[currentWallet!.ffiAddress()]![txId] = txDart;
txHistoryMutex.release();
return txDart;
try {
final tx = txhistory!.transactionById(txId);
final txDart = Transaction(txInfo: tx);
txCache[currentWallet!.ffiAddress()] ??= {};
txCache[currentWallet!.ffiAddress()]![txId] = txDart;
return txDart;
} finally {
txHistoryMutex.release();
}
}
Future<PendingTransactionDescription> createTransactionSync(
@@ -136,6 +152,11 @@ Future<PendingTransactionDescription> createTransactionSync(
List<String> preferredInputs = const []}) async {
final amt = amount == null ? 0 : currentWallet!.amountFromString(amount);
if (amount != null && amt == 0) {
throw MoneroTransactionCreationException(
'Refusing to create a transaction with a zero amount: 0 is the sweep-all ');
}
final waddr = currentWallet!.ffiAddress();
// force reconnection in case the os killed the connection?
@@ -185,11 +206,7 @@ Future<PendingTransactionDescription> createTransactionSync(
})();
if (error != null) {
String message = error;
if (message.contains("RPC error")) {
message = "Invalid node response, please try again or switch node\n\ntrace: $message";
}
throw CreationTransactionException(message: message);
throw CreationTransactionException(message: _formatTransactionError(error));
}
final rAmt = pendingTx.amount();
@@ -202,6 +219,7 @@ Future<PendingTransactionDescription> createTransactionSync(
fee: rFee,
hash: rHash,
hex: rHex,
txCount: pendingTx.txCount(),
pointerAddress: pendingTx.ffiAddress(),
);
}
@@ -237,7 +255,7 @@ Future<PendingTransactionDescription> createTransactionMultDest(
final Wallet2PendingTransaction tx = MoneroPendingTransaction(txptr);
if (tx.status() != 0) {
throw CreationTransactionException(message: tx.errorString());
throw CreationTransactionException(message: _formatTransactionError(tx.errorString()));
}
return PendingTransactionDescription(
@@ -246,6 +264,7 @@ Future<PendingTransactionDescription> createTransactionMultDest(
hash: tx.txid(''),
hex: tx.hex(''),
pointerAddress: tx.ffiAddress(),
txCount: tx.txCount(),
);
}
@@ -282,7 +301,7 @@ Future<String?> commitTransaction(
})();
}
if (error != null && error != "no tx keys found for this txid") {
throw CreationTransactionException(message: error);
throw CreationTransactionException(message: _formatTransactionError(error));
}
unawaited(() async {
storeSync(force: true);
+65 -29
View File
@@ -4,6 +4,8 @@ import 'dart:ffi';
import 'dart:isolate';
import 'dart:math';
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_monero/api/account_list.dart';
import 'package:cw_monero/api/exceptions/setup_wallet_exception.dart';
@@ -127,9 +129,7 @@ String getSeedLegacy(String? language) {
return legacy;
}
String getPassphrase() {
return currentWallet?.getCacheAttribute(key: "cakewallet.passphrase") ?? "";
}
String getPassphrase() => currentWallet?.getCacheAttribute(key: "cakewallet.passphrase") ?? "";
Map<int, Map<int, Map<int, String>>> addressCache = {};
@@ -155,34 +155,54 @@ String getAddress({int accountIndex = 0, int addressIndex = 0}) {
return addressCache[currentWallet!.ffiAddress()]![accountIndex]![addressIndex]!;
}
int getFullBalance({int accountIndex = 0}) {
return currentWallet?.balance(accountIndex: accountIndex) ?? 0;
}
Money getFullBalance({int accountIndex = 0}) =>
Money.fromInt(currentWallet?.balance(accountIndex: accountIndex) ?? 0, CryptoCurrency.xmr);
int getUnlockedBalance({int accountIndex = 0}) =>
currentWallet?.unlockedBalance(accountIndex: accountIndex) ?? 0;
Money getUnlockedBalance({int accountIndex = 0}) => Money.fromInt(
currentWallet?.unlockedBalance(accountIndex: accountIndex) ?? 0, CryptoCurrency.xmr);
int getCurrentHeight() => currentWallet?.blockChainHeight() ?? 0;
int cachedNodeHeight = 0;
bool isHeightRefreshing = false;
Future<int>? _nodeHeightInFlight;
Future<int> _refreshNodeHeight() {
final inFlight = _nodeHeightInFlight;
if (inFlight != null) return inFlight;
try {
final wallet = currentWallet;
if (wallet == null) return Future.value(cachedNodeHeight);
final wptrAddress = wallet.ffiAddress();
isHeightRefreshing = true;
final f = Isolate.run(() async {
return monero.Wallet_daemonBlockChainHeight(Pointer.fromAddress(wptrAddress));
}).then((h) {
cachedNodeHeight = h;
return h;
}).catchError((_) => cachedNodeHeight).whenComplete(() {
isHeightRefreshing = false;
_nodeHeightInFlight = null;
});
_nodeHeightInFlight = f;
return f;
} catch (e) {
printV("error refreshing node height: $e");
isHeightRefreshing = false;
_nodeHeightInFlight = null;
return Future.value(cachedNodeHeight);
}
}
int getNodeHeightSync() {
if (isHeightRefreshing == false) {
if (cachedNodeHeight != 0 && getWlptr()?.height() == 1) {
return cachedNodeHeight;
}
(() async {
try {
isHeightRefreshing = true;
final wptrAddress = currentWallet!.ffiAddress();
cachedNodeHeight = await Isolate.run(() async {
return monero.Wallet_daemonBlockChainHeight(Pointer.fromAddress(wptrAddress));
});
} catch (_) {
} finally {
isHeightRefreshing = false;
}
})();
unawaited(_refreshNodeHeight());
}
return cachedNodeHeight;
}
@@ -258,6 +278,10 @@ void setRecoveringFromSeed({required bool isRecovery}) {
final storeMutex = Mutex();
void refresh() {
currentWallet!.refresh();
}
int lastStorePointer = 0;
int lastStoreHeight = 0;
void storeSync({bool force = false}) async {
@@ -316,6 +340,9 @@ class SyncListener {
int _cachedBlockchainHeight;
int _lastKnownBlockHeight;
int _initialSyncHeight;
int _lastKnownNodeHeight = 0;
int _ticksSinceEmit = 0;
static const int _heartbeatTicks = 5;
Future<int> getNodeHeightOrUpdate(int baseHeight) async {
if (_cachedBlockchainHeight < baseHeight || _cachedBlockchainHeight == 0) {
@@ -328,6 +355,8 @@ class SyncListener {
void _start() {
_cachedBlockchainHeight = 0;
_lastKnownBlockHeight = 0;
_lastKnownNodeHeight = 0;
_ticksSinceEmit = 0;
_initialSyncHeight = 0;
_updateSyncInfoTimer ??= Timer.periodic(Duration(milliseconds: 1200), (_) async {
if (isNewTransactionExist()) {
@@ -352,11 +381,17 @@ class SyncListener {
}
final bchHeight = max(nodeHeight, syncHeight);
// printV("syncHeight: $syncHeight, _lastKnownBlockHeight: $_lastKnownBlockHeight, bchHeight: $bchHeight");
if (_lastKnownBlockHeight == syncHeight) {
_ticksSinceEmit++;
final nothingChanged = _lastKnownBlockHeight == syncHeight &&
_lastKnownNodeHeight == nodeHeight;
if (nothingChanged && _ticksSinceEmit < _heartbeatTicks) {
return;
}
_ticksSinceEmit = 0;
_lastKnownBlockHeight = syncHeight;
_lastKnownNodeHeight = nodeHeight;
final track = bchHeight - _initialSyncHeight;
final diff = track - (bchHeight - syncHeight);
final ptc = diff <= 0 ? 0.0 : diff / track;
@@ -407,7 +442,11 @@ void startRefresh() => startRefreshSync();
Future<void> store() async => _storeSync(0);
Future<int> getNodeHeight() async => getNodeHeightSync();
Future<int> getNodeHeight() async {
final cached = getNodeHeightSync();
if (cached != 0) return cached;
return _refreshNodeHeight();
}
void rescanBlockchainAsync() => currentWallet!.rescanBlockchainAsync();
@@ -419,14 +458,11 @@ Future setTrustedDaemon(bool trusted) async => currentWallet!.setTrustedDaemon(a
Future<bool> trustedDaemon() async => currentWallet!.trustedDaemon();
String signMessage(String message, {String address = ""}) {
return currentWallet!.signMessage(message: message, address: address);
}
String signMessage(String message, {String address = ""}) =>
currentWallet!.signMessage(message: message, address: address);
bool verifyMessage(String message, String address, String signature) {
return currentWallet!
.verifySignedMessage(message: message, address: address, signature: signature);
}
bool verifyMessage(String message, String address, String signature) =>
currentWallet!.verifySignedMessage(message: message, address: address, signature: signature);
Map<String, List<int>> debugCallLength() => monero.debugCallLength;
+78 -70
View File
@@ -101,7 +101,6 @@ void createWallet(
currentWallet!.setCacheAttribute(key: "cakewallet.passphrase", value: passphrase);
currentWallet!.store(path: path);
openedWalletsByPath[path] = currentWallet!;
_lastOpenedWallet = path;
}
bool isWalletExist({required String path}) {
@@ -147,7 +146,11 @@ void restoreWalletFromSeedSync(
openedWalletsByPath[path] = currentWallet!;
currentWallet!.store(path: path);
_lastOpenedWallet = path;
}
void storePassphrase({required String path, required String passphrase}) {
currentWallet!.setCacheAttribute(key: "cakewallet.passphrase", value: passphrase);
currentWallet!.store(path: path);
}
void restoreWalletFromKeys(
@@ -214,8 +217,12 @@ void restoreWalletFromKeys(
currentWallet = newW;
if (restoreHeight > 0) {
setRefreshFromBlockHeight(height: restoreHeight);
currentWallet!.store(path: path);
}
openedWalletsByPath[path] = currentWallet!;
_lastOpenedWallet = path;
}
// English only, because normalization.
@@ -298,6 +305,10 @@ void restoreWalletFromSpendKeySync(
currentWallet = newW;
if (restoreHeight > 0) {
setRefreshFromBlockHeight(height: restoreHeight);
currentWallet!.store(path: path);
}
currentWallet!.setCacheAttribute(key: "cakewallet.seed", value: seed);
storeSync();
@@ -305,11 +316,8 @@ void restoreWalletFromSpendKeySync(
setupBackgroundSync(password, currentWallet!);
openedWalletsByPath[path] = currentWallet!;
_lastOpenedWallet = path;
}
String _lastOpenedWallet = "";
Future<void> restoreWalletFromHardwareWallet(
{required String path,
required String password,
@@ -333,8 +341,10 @@ Future<void> restoreWalletFromHardwareWallet(
}
currentWallet = newW;
setRefreshFromBlockHeight(height: restoreHeight);
currentWallet!.store(path: path);
_lastOpenedWallet = path;
openedWalletsByPath[path] = currentWallet!;
}
@@ -346,72 +356,70 @@ Future<void> loadWallet({required String path, required String password, int net
currentWallet = openedWalletsByPath[path]!;
return;
}
if (currentWallet == null || path != _lastOpenedWallet) {
if (currentWallet != null) {
final addr = currentWallet!.ffiAddress();
Isolate.run(() {
monero.Wallet_store(Pointer.fromAddress(addr));
});
}
txhistory = null;
/// Get the device type
/// 0: Software Wallet
/// 1: Ledger
/// 2: Trezor
var deviceType = 0;
if (Platform.isAndroid || Platform.isIOS) {
deviceType = wmPtr.queryWalletDevice(
keysFileName: "$path.keys",
password: password,
kdfRounds: 1,
);
final status = wmPtr.errorString();
if (status != "") {
printV("loadWallet:" + status);
// This is most likely closeWallet call leaking error. This is fine.
if (status.contains("failed to save file")) {
printV("loadWallet: error leaked: $status");
deviceType = 0;
} else {
throw WalletOpeningException(message: status);
}
}
} else {
deviceType = 0;
}
if (deviceType == 1) {
if (gLedger == null) {
throw Exception("Tried to open a ledger wallet with no ledger connected");
}
enableLedgerExchange(gLedger!);
}
final addr = wmPtr.ffiAddress();
final newWptrAddr = await Isolate.run(() {
return monero.WalletManager_openWallet(Pointer.fromAddress(addr),
path: path, password: password)
.address;
if (currentWallet != null) {
final addr = currentWallet!.ffiAddress();
Isolate.run(() {
monero.Wallet_store(Pointer.fromAddress(addr));
});
final newW = MoneroWallet(Pointer.fromAddress(newWptrAddr));
int status = newW.status();
if (status != 0) {
final err = newW.errorString();
printV("loadWallet:" + err);
throw WalletOpeningException(message: err);
}
if (deviceType == 0) {
setupBackgroundSync(password, newW);
}
currentWallet = newW;
_lastOpenedWallet = path;
openedWalletsByPath[path] = currentWallet!;
}
txhistory = null;
/// Get the device type
/// 0: Software Wallet
/// 1: Ledger
/// 2: Trezor
var deviceType = 0;
if (Platform.isAndroid || Platform.isIOS) {
deviceType = wmPtr.queryWalletDevice(
keysFileName: "$path.keys",
password: password,
kdfRounds: 1,
);
final status = wmPtr.errorString();
if (status != "") {
printV("loadWallet:" + status);
// This is most likely closeWallet call leaking error. This is fine.
if (status.contains("failed to save file")) {
printV("loadWallet: error leaked: $status");
deviceType = 0;
} else {
throw WalletOpeningException(message: status);
}
}
} else {
deviceType = 0;
}
if (deviceType == 1) {
if (gLedger == null) {
throw Exception("Tried to open a ledger wallet with no ledger connected");
}
enableLedgerExchange(gLedger!);
}
final addr = wmPtr.ffiAddress();
final newWptrAddr = await Isolate.run(() {
return monero.WalletManager_openWallet(Pointer.fromAddress(addr),
path: path, password: password)
.address;
});
final newW = MoneroWallet(Pointer.fromAddress(newWptrAddr));
int status = newW.status();
if (status != 0) {
final err = newW.errorString();
printV("loadWallet:" + err);
throw WalletOpeningException(message: err);
}
if (deviceType == 0) {
setupBackgroundSync(password, newW);
}
currentWallet = newW;
openedWalletsByPath[path] = currentWallet!;
}
void setupBackgroundSync(String password, Wallet2Wallet wallet) {
+7
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:ffi';
import 'dart:typed_data';
import 'package:cw_core/hardware/hardware_wallet_service.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:ffi/ffi.dart';
import 'package:flutter/foundation.dart';
@@ -114,3 +115,9 @@ void _logLedgerCommand(Uint8List command, [bool isResponse = true]) {
printV("> ${_ledgerMoneroCommands[command[1]]} ${toHexString(command.sublist(2))}");
}
}
class MoneroLedgerService extends HardwareWalletService {
final LedgerConnection connection;
MoneroLedgerService(this.connection);
}
+17
View File
@@ -0,0 +1,17 @@
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/balance.dart';
import 'package:cw_core/crypto_currency.dart';
class MoneroBalance extends Balance {
MoneroBalance({
required this.fullBalance,
required Money unlockedBalance,
Money? frozen,
}) : super(
unlockedBalance,
fullBalance - unlockedBalance,
frozen: frozen ?? Money.zero(CryptoCurrency.xmr),
);
final Money fullBalance;
}
+2 -1
View File
@@ -34,8 +34,9 @@ abstract class MoneroSubaddressListBase with Store {
try {
_isUpdating = true;
refresh(accountIndex: accountIndex);
final newAddrs = await getAll();
subaddresses.clear();
subaddresses.addAll(await getAll());
subaddresses.addAll(newAddrs);
_isUpdating = false;
} catch (e) {
_isUpdating = false;
+3 -17
View File
@@ -1,5 +1,4 @@
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/format_amount.dart';
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/transaction_direction.dart';
import 'package:cw_core/transaction_info.dart';
@@ -15,23 +14,10 @@ class MoneroTransactionInfo extends TransactionInfo {
final DateTime date;
final int accountIndex;
final bool isPending;
final int amount;
final int fee;
final Money amount;
final Money fee;
final int addressIndex;
final int confirmations;
String? recipientAddress;
String? key;
String? _fiatAmount;
@override
String amountFormatted() => '${CryptoCurrency.xmr.formatAmount(BigInt.from(amount))} XMR';
@override
String fiatAmount() => _fiatAmount ?? '';
@override
void changeFiatAmount(String amount) => _fiatAmount = formatAmount(amount);
@override
String feeFormatted() => '${CryptoCurrency.xmr.formatAmount(BigInt.from(fee))} XMR';
}
+125 -43
View File
@@ -3,12 +3,11 @@ import 'dart:ffi';
import 'dart:io';
import 'dart:isolate';
import 'package:cw_core/monero_amount_format.dart';
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/pathForWallet.dart';
import 'package:cw_core/transaction_priority.dart';
import 'package:cw_core/account.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/monero_balance.dart';
import 'package:cw_core/monero_transaction_priority.dart';
import 'package:cw_core/monero_wallet_keys.dart';
import 'package:cw_core/monero_wallet_utils.dart';
@@ -30,6 +29,7 @@ import 'package:cw_monero/api/wallet.dart' as monero_wallet;
import 'package:cw_monero/api/wallet_manager.dart';
import 'package:cw_monero/exceptions/monero_transaction_creation_exception.dart';
import 'package:cw_monero/ledger.dart';
import 'package:cw_monero/monero_balance.dart';
import 'package:cw_monero/monero_transaction_creation_credentials.dart';
import 'package:cw_monero/monero_transaction_history.dart';
import 'package:cw_monero/monero_transaction_info.dart';
@@ -37,6 +37,7 @@ import 'package:cw_monero/monero_unspent.dart';
import 'package:cw_monero/monero_wallet_addresses.dart';
import 'package:cw_monero/monero_wallet_service.dart';
import 'package:cw_monero/pending_monero_transaction.dart';
import 'package:cw_monero/trezor.dart';
import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
import 'package:ledger_flutter_plus/ledger_flutter_plus.dart';
@@ -119,6 +120,15 @@ abstract class MoneroWalletBase
@override
bool get hasRescan => true;
@override
bool get hasCoinControl => true;
@override
bool get hasAccountsSupport => true;
@override
bool get hasNativeAccounts => true;
@override
String get seed => monero_wallet.getSeed();
String seedLegacy(String? language) => monero_wallet.getSeedLegacy(language);
@@ -369,18 +379,53 @@ abstract class MoneroWalletBase
};
}
bool needExportOutputs(int amount) {
bool hasUnknownKeyImages() => currentWallet!.hasUnknownKeyImages();
bool needExportOutputs(Money amount) {
if (int.tryParse(currentWallet!.secretSpendKey()) != 0) {
return false;
}
// viewOnlyBalance - balance that we can spend
// TODO(mrcyjanek): remove hasUnknownKeyImages when we cleanup coin control
return (currentWallet!.viewOnlyBalance(accountIndex: walletAddresses.account!.id) < amount) ||
return (currentWallet!.viewOnlyBalance(accountIndex: walletAddresses.account!.id) <
amount.amount.toInt()) ||
currentWallet!.hasUnknownKeyImages();
}
MoneroTrezorService? trezorService;
Trezor _getTrezor() => Trezor(trezorService ?? (throw Exception("Trezor not connected")));
Future<void> syncTrezor() async {
if (trezorService == null) throw Exception("Trezor not connected");
final ptr = Pointer<Void>.fromAddress(currentWallet!.ffiAddress());
final tdis = monero.Wallet_exportTrezorTdis(ptr);
final response = await _getTrezor().keyImageSync(tdis);
final success = monero.Wallet_importTrezorEncryptedKeyImagesJson(ptr, response);
if (!success) throw Exception(monero.Wallet_errorString(ptr));
}
Future<String> signTrezorTransaction(String json) => _getTrezor().signTransaction(json);
@override
Future<PendingTransaction> createTransaction(Object credentials) async {
if (hardwareWalletType == HardwareWalletType.trezor) {
for (int i = 0; i < 2; i++) {
try {
return await _createTransaction(credentials);
} catch (e) {
printV(e);
}
await save();
await Future.delayed(Duration(seconds: i));
}
}
return await _createTransaction(credentials);
}
Future<PendingTransaction> _createTransaction(Object credentials) async {
final _credentials = credentials as MoneroTransactionCreationCredentials;
final inputs = <String>[];
final outputs = _credentials.outputs;
@@ -394,9 +439,7 @@ abstract class MoneroWalletBase
throw MoneroTransactionCreationException('The wallet is not synced.');
}
if (unspentCoins.isEmpty) {
await updateUnspent();
}
await updateUnspent();
for (final utx in unspentCoins) {
if (utx.isSending) {
@@ -405,14 +448,13 @@ abstract class MoneroWalletBase
}
if (hasMultiDestination) {
if (outputs.any((item) => item.sendAll || (item.formattedCryptoAmount ?? 0) <= 0)) {
if (outputs.any((item) => item.sendAll || item.cryptoAmount.amount <= BigInt.zero)) {
throw MoneroTransactionCreationException('You do not have enough XMR to send this amount.');
}
final int totalAmount =
outputs.fold(0, (acc, value) => acc + (value.formattedCryptoAmount ?? 0));
final totalAmount = outputs.fold(0, (acc, value) => acc + value.cryptoAmount.amount.toInt());
if (unlockedBalance < totalAmount) {
if (unlockedBalance < Money.fromInt(totalAmount, CryptoCurrency.xmr)) {
throw MoneroTransactionCreationException('You do not have enough XMR to send this amount.');
}
@@ -421,8 +463,7 @@ abstract class MoneroWalletBase
final moneroOutputs = outputs.map((output) {
final outputAddress = output.isParsedAddress ? output.extractedAddress : output.address;
return MoneroOutput(
address: outputAddress!, amount: output.cryptoAmount!.replaceAll(',', '.'));
return MoneroOutput(address: outputAddress!, amount: output.cryptoAmount.toString());
}).toList();
pendingTransactionDescription = await transaction_history.createTransactionMultDest(
@@ -434,7 +475,12 @@ abstract class MoneroWalletBase
} else {
final output = outputs.first;
final address = output.isParsedAddress ? output.extractedAddress : output.address;
final amount = output.sendAll ? null : output.cryptoAmount!.replaceAll(',', '.');
if (!output.sendAll && output.cryptoAmount.amount <= BigInt.zero) {
throw MoneroTransactionCreationException('Amount must be greater than 0.');
}
final amount = output.sendAll ? null : output.cryptoAmount.toString();
// if ((formattedAmount != null && unlockedBalance < formattedAmount) ||
// (formattedAmount == null && unlockedBalance <= 0)) {
@@ -454,7 +500,10 @@ abstract class MoneroWalletBase
paymentId: '');
}
// final status = monero.PendingTransaction_status(pendingTransactionDescription);
if (pendingTransactionDescription.txCount != 1) {
throw MoneroTransactionCreationException(
"This payment would be split into ${pendingTransactionDescription.txCount} transactions. Send smaller transaction to yourself first.",);
}
return PendingMoneroTransaction(pendingTransactionDescription, this);
}
@@ -611,11 +660,19 @@ abstract class MoneroWalletBase
await walletInfo.save();
}
Future<void> updateUnspent() async {
Future<void>? _updateUnspentInFlight;
/// Concurrent callers share one run: two runs at once work on the same [unspentCoins] list, and
/// the clean up of the one then deletes the coin infos of the other.
Future<void> updateUnspent() => _updateUnspentInFlight ??=
_updateUnspent().whenComplete(() => _updateUnspentInFlight = null);
Future<void> _updateUnspent() async {
try {
refreshCoins(walletAddresses.account!.id);
unspentCoins.clear();
// Collected separately, so nobody ever sees a half filled list
final unspentCoins = <MoneroUnspent>[];
final coinCount = await countOfCoins();
for (var i = 0; i < coinCount; i++) {
@@ -633,12 +690,21 @@ abstract class MoneroWalletBase
);
// TODO: double-check the logic here
if (unspent.hash.isNotEmpty) {
final tx = await transaction_history.getTransaction(unspent.hash);
unspent.isChange = tx.isSpend == true;
try {
final tx = await transaction_history.getTransaction(unspent.hash);
unspent.isChange = tx.isSpend == true;
} catch (e) {
// The transaction may not be in the (not yet refreshed) history, that must not
// keep the output, and all the ones after it, out of coin control.
printV("isChange lookup failed for ${unspent.hash}: $e");
}
}
unspentCoins.add(unspent);
}
}
this.unspentCoins
..clear()
..addAll(unspentCoins);
if (unspentCoinsInfo.isEmpty) {
unspentCoins.forEach((coin) => _addCoinInfo(coin));
@@ -646,22 +712,38 @@ abstract class MoneroWalletBase
}
if (unspentCoins.isNotEmpty) {
unspentCoins.forEach((coin) {
final coinInfoList = unspentCoinsInfo.values.where((element) =>
element.walletId.contains(id) &&
element.accountIndex == walletAddresses.account!.id &&
element.keyImage!.contains(coin.keyImage!));
final usedInfoKeys = <dynamic>{};
for (final coin in unspentCoins) {
final coinInfoList = unspentCoinsInfo.values
.where((element) =>
element.walletId.contains(id) &&
element.accountIndex == walletAddresses.account!.id &&
!usedInfoKeys.contains(element.key) &&
_isSameCoin(element, coin))
.toList();
if (coinInfoList.isNotEmpty) {
final coinInfo = coinInfoList.first;
// The key image only breaks a tie between identical outputs of one transaction
final coinInfo = coinInfoList.firstWhere(
(element) => element.keyImage == coin.keyImage,
orElse: () => coinInfoList.first,
);
usedInfoKeys.add(coinInfo.key);
coin.isFrozen = coinInfo.isFrozen;
coin.isSending = coinInfo.isSending;
coin.note = coinInfo.note;
// The key image of a hardware wallet's output is only known after syncing with the
// device; keep the existing info (frozen state, note) instead of orphaning it.
if (coinInfo.keyImage != coin.keyImage) {
coinInfo.keyImage = coin.keyImage;
await coinInfo.save();
}
} else {
_addCoinInfo(coin);
await _addCoinInfo(coin);
}
});
}
}
await _refreshUnspentCoinsInfo();
@@ -676,6 +758,12 @@ abstract class MoneroWalletBase
}
}
/// The key image can't identify an output: a hardware wallet doesn't know the key images of its
/// outputs until they are synced with the device, until then they all carry the same
/// placeholder, which made every output match the info of the first one.
bool _isSameCoin(UnspentCoinsInfo info, MoneroUnspent coin) =>
info.hash == coin.hash && info.address == coin.address && info.value == coin.value;
Future<void> _addCoinInfo(MoneroUnspent coin) async {
final newInfo = UnspentCoinsInfo(
walletId: id,
@@ -701,8 +789,7 @@ abstract class MoneroWalletBase
if (currentWalletUnspentCoins.isNotEmpty) {
currentWalletUnspentCoins.forEach((element) {
final existUnspentCoins =
unspentCoins.where((coin) => element.keyImage!.contains(coin.keyImage!));
final existUnspentCoins = unspentCoins.where((coin) => _isSameCoin(element, coin));
if (existUnspentCoins.isEmpty) {
keys.add(element.key);
@@ -772,10 +859,10 @@ abstract class MoneroWalletBase
row.isSpend ? TransactionDirection.outgoing : TransactionDirection.incoming,
row.timeStamp,
row.isPending,
row.amount,
Money.fromInt(row.amount, currency),
row.accountIndex,
0,
row.fee,
Money.fromInt(row.fee, currency),
row.confirmations,
)..additionalInfo = <String, dynamic>{
'key': row.key,
@@ -854,25 +941,25 @@ abstract class MoneroWalletBase
final fullBalance = monero_wallet.getFullBalance(accountIndex: walletAddresses.account!.id);
final frozenBalance = _getFrozenBalance();
if (balance[currency]!.fullBalance != fullBalance ||
balance[currency]!.unlockedBalance != unlockedBalance ||
balance[currency]!.frozenBalance != frozenBalance) {
balance[currency]!.available != unlockedBalance ||
balance[currency]!.frozen != frozenBalance) {
balance[currency] = MoneroBalance(
fullBalance: fullBalance, unlockedBalance: unlockedBalance, frozenBalance: frozenBalance);
fullBalance: fullBalance, unlockedBalance: unlockedBalance, frozen: frozenBalance);
}
}
int _getUnlockedBalance() =>
Money _getUnlockedBalance() =>
monero_wallet.getUnlockedBalance(accountIndex: walletAddresses.account!.id);
int _getFrozenBalance() {
Money _getFrozenBalance() {
var frozenBalance = 0;
for (var coin in unspentCoinsInfo.values.where((element) =>
for (final coin in unspentCoinsInfo.values.where((element) =>
element.walletId == id && element.accountIndex == walletAddresses.account!.id)) {
if (coin.isFrozen && !coin.isSending) frozenBalance += coin.value;
}
return frozenBalance;
return Money.fromInt(frozenBalance, CryptoCurrency.xmr);
}
void _onNewBlock(int height, int blocksLeft, double ptc) async {
@@ -946,9 +1033,4 @@ abstract class MoneroWalletBase
Future<void> setLedgerConnection(LedgerConnection connection) async {
await enableLedgerExchange(connection);
}
@override
String formatCryptoAmount(String amount) {
return moneroAmountToString(amount: int.parse(amount));
}
}
+2 -2
View File
@@ -90,7 +90,7 @@ abstract class MoneroWalletAddressesBase extends WalletAddresses with Store {
addressesMap.clear();
addressInfos.clear();
accountList.accounts.forEach((account) async {
for (final account in accountList.accounts) {
await _subaddressList.update(accountIndex: account.id);
_subaddressList.subaddresses.forEach((subaddress) {
addressesMap[subaddress.address] = subaddress.label;
@@ -102,7 +102,7 @@ abstract class MoneroWalletAddressesBase extends WalletAddresses with Store {
address: subaddress.address,
label: subaddress.label));
});
});
}
await saveAddressesInBox();
} catch (e) {
+40 -12
View File
@@ -3,8 +3,8 @@ import 'dart:ffi';
import 'dart:io';
import 'dart:isolate';
import 'package:collection/collection.dart';
import 'package:cw_core/get_height_by_date.dart';
import 'package:cw_core/hardware/hardware_wallet_service.dart';
import 'package:cw_core/monero_wallet_utils.dart';
import 'package:cw_core/pathForWallet.dart';
import 'package:cw_core/unspent_coins_info.dart';
@@ -20,8 +20,8 @@ import 'package:cw_monero/api/wallet_manager.dart';
import 'package:cw_monero/bip39_seed.dart';
import 'package:cw_monero/ledger.dart';
import 'package:cw_monero/monero_wallet.dart';
import 'package:cw_monero/trezor.dart';
import 'package:hive/hive.dart';
import 'package:ledger_flutter_plus/ledger_flutter_plus.dart';
import 'package:monero/monero.dart' as monero;
import 'package:polyseed/polyseed.dart';
@@ -44,10 +44,15 @@ class MoneroNewWalletCredentials extends WalletCredentials {
}
class MoneroRestoreWalletFromHardwareCredentials extends WalletCredentials {
MoneroRestoreWalletFromHardwareCredentials(
{required String name, required this.ledgerConnection, int height = 0, String? password})
: super(name: name, password: password, height: height);
LedgerConnection ledgerConnection;
MoneroRestoreWalletFromHardwareCredentials({
required String name,
required this.hardwareWalletService,
int height = 0,
String? password,
String? passphrase,
}) : super(name: name, password: password, height: height, passphrase: passphrase);
final HardwareWalletService hardwareWalletService;
}
class MoneroRestoreWalletFromSeedCredentials extends WalletCredentials {
@@ -187,7 +192,7 @@ class MoneroWalletService extends WalletService<
unspentCoinsInfo: unspentCoinsInfoSource,
password: password);
if (wallet.isHardwareWallet) {
if (wallet.hardwareWalletType == HardwareWalletType.ledger) {
wallet.setLedgerConnection(gLedger!);
gLedger = null;
}
@@ -295,12 +300,35 @@ class MoneroWalletService extends WalletService<
try {
final path = await pathForWallet(name: credentials.name, type: getType());
final password = credentials.password;
final height = credentials.height;
enableLedgerExchange(credentials.ledgerConnection);
if (credentials.hardwareWalletService case MoneroLedgerService service) {
enableLedgerExchange(service.connection);
await monero_wallet_manager.restoreWalletFromHardwareWallet(
path: path, password: password!, restoreHeight: height!, deviceName: 'Ledger');
await monero_wallet_manager.restoreWalletFromHardwareWallet(
path: path,
password: password!,
restoreHeight: credentials.height!,
deviceName: 'Ledger',
);
} else if (credentials.hardwareWalletService case final MoneroTrezorService service) {
final trezor = Trezor(service);
final watchCredentials = await trezor.getWatchCredentials();
monero_wallet_manager.restoreWalletFromKeys(
path: path,
password: credentials.password!,
language: "English",
restoreHeight: credentials.height!,
address: watchCredentials.address,
viewKey: watchCredentials.watchKey,
spendKey: "",
);
if (credentials.passphrase != null) {
monero_wallet_manager.storePassphrase(path: path, passphrase: credentials.passphrase!);
}
}
final wallet = MoneroWallet(
walletInfo: credentials.walletInfo!,
@@ -549,7 +577,7 @@ class MoneroWalletService extends WalletService<
if (walletInfo == null) {
return false;
}
return walletInfo.isHardwareWallet;
return walletInfo.hardwareWalletType == HardwareWalletType.ledger;
}
}
+38 -22
View File
@@ -1,14 +1,17 @@
import 'dart:async';
import 'dart:ffi';
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/pending_transaction.dart';
import 'package:cw_monero/api/account_list.dart';
import 'package:cw_monero/api/structs/pending_transaction.dart';
import 'package:cw_monero/api/transaction_history.dart' as monero_transaction_history;
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/amount_converter.dart';
import 'package:cw_core/pending_transaction.dart';
import 'package:cw_monero/api/wallet.dart';
import 'package:cw_monero/monero_wallet.dart';
import 'package:monero/monero.dart' as monero;
class DoubleSpendException implements Exception {
DoubleSpendException();
@@ -24,6 +27,10 @@ class PendingMoneroTransaction with PendingTransaction {
final PendingTransactionDescription pendingTransactionDescription;
final MoneroWalletBase wallet;
Money get amount => Money.fromInt(pendingTransactionDescription.amount, CryptoCurrency.xmr);
Money get fee => Money.fromInt(pendingTransactionDescription.fee, CryptoCurrency.xmr);
@override
String get id => pendingTransactionDescription.hash;
@@ -31,32 +38,41 @@ class PendingMoneroTransaction with PendingTransaction {
String get hex => pendingTransactionDescription.hex;
@override
String get amountFormatted =>
AmountConverter.amountIntToString(CryptoCurrency.xmr, pendingTransactionDescription.amount);
String get amountFormatted => amount.toString();
@override
String get feeFormatted => "$feeFormattedValue XMR";
@override
String get feeFormattedValue =>
AmountConverter.amountIntToString(CryptoCurrency.xmr, pendingTransactionDescription.fee);
@override
bool shouldCommitUR() => isViewOnly;
bool shouldCommitUR() => isViewOnly && wallet.hardwareWalletType != HardwareWalletType.trezor;
@override
Future<void> commit() async {
try {
await monero_transaction_history.commitTransactionFromPointerAddress(
address: pendingTransactionDescription.pointerAddress, useUR: false);
} catch (e) {
final message = e.toString();
if (wallet.hardwareWalletType == HardwareWalletType.trezor) {
final ptr = Pointer<Void>.fromAddress(pendingTransactionDescription.pointerAddress);
final ret = await monero.PendingTransaction_commitTrezor(ptr, 0);
if (message.contains('Reason: double spend')) {
throw DoubleSpendException();
final json = await wallet.signTrezorTransaction(ret);
final wptr = Pointer<Void>.fromAddress(currentWallet!.ffiAddress());
final suc = await monero.Wallet_submitTransactionHex(wptr, json);
if (!suc) {
final err = monero.UnsignedTransaction_errorString(ptr);
printV(err);
throw err;
}
} else {
try {
await monero_transaction_history.commitTransactionFromPointerAddress(
address: pendingTransactionDescription.pointerAddress, useUR: false);
} catch (e) {
final message = e.toString();
rethrow;
if (message.contains('Reason: double spend')) {
throw DoubleSpendException();
}
rethrow;
}
}
storeSync(force: true);
unawaited(() async {
+64
View File
@@ -0,0 +1,64 @@
import "dart:convert";
import "package:cw_core/hardware/hardware_wallet_service.dart";
import "package:mutex/mutex.dart";
import "package:trezor_flutter/trezor_flutter.dart";
class MoneroTrezorService extends HardwareWalletService {
MoneroTrezorService(this.client);
final TrezorClient client;
static final Mutex _mutex = Mutex();
static Future<T> runBlocking<T>(Future<T> Function() operation) => _mutex.protect(operation);
}
class MoneroTrezorWatchCredentials {
const MoneroTrezorWatchCredentials(this.watchKey, this.address);
final String watchKey;
final String address;
}
class Trezor {
Trezor(this.service);
final MoneroTrezorService service;
Future<MoneroTrezorWatchCredentials> getWatchCredentials() async {
final credentials = await MoneroTrezorService.runBlocking(TrezorMonero(service.client).getWatchCredentials);
return MoneroTrezorWatchCredentials(credentials.$1, credentials.$2);
}
Future<String> keyImageSync(String tdis) async {
final tdisMap = jsonDecode(tdis) as Map<String, dynamic>;
final tdisList = tdisMap["tdis"] as List<dynamic>;
final txIds = <MoneroKeyImageTxData>[];
for (final tdi in tdisList) {
txIds.add(
MoneroKeyImageTxData(
outKey: tdi["out_key"] as String,
txPubKey: tdi["tx_pub_key"] as String,
internalOutputIndex: tdi["internal_output_index"] as int,
subAddrMajor: tdi["sub_addr_major"] as int,
subAddrMinor: tdi["sub_addr_minor"] as int,
additionalTxPubKeys:
(tdi["additional_tx_pub_keys"] as List?)?.map((e) => e as String).toList() ?? [],
),
);
}
final keyImages = await MoneroTrezorService.runBlocking(
() => TrezorMonero(service.client).syncKeyImages(txIds),
);
return jsonEncode(keyImages.toMap());
}
Future<String> signTransaction(String json) => MoneroTrezorService.runBlocking(
() =>
TrezorMonero(service.client).signTransaction(jsonDecode(json) as Map<String, dynamic>),
);
}
File diff suppressed because it is too large Load Diff
+13 -8
View File
@@ -16,8 +16,8 @@ dependencies:
ffi: ^2.0.1
http: ^1.1.0
path_provider: ^2.0.11
mobx: ^2.0.7+4
flutter_mobx: ^2.0.6+1
mobx: ^2.6.0
flutter_mobx: ^2.3.0
intl: any
encrypt: ^5.0.1
polyseed: ^0.0.7
@@ -25,20 +25,25 @@ dependencies:
path: ../cw_core
monero:
git:
url: https://github.com/mrcyjanek/monero_c
ref: bc8d1a0b75b97156d71579581b4cdfe58c777ed2
url: https://github.com/mrcyjanek/monero_c.git
ref: 33671871de3d51696b66ffb3bd94f744e26974f6
path: impls/monero.dart
mutex: ^3.1.0
ledger_flutter_plus: ^1.4.1
trezor_flutter:
git:
url: https://github.com/cake-tech/trezor-flutter.git
ref: 591d4b8c821b9c10561f848b866324a9a8dcb489
path: trezor-flutter
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.4.15
build_resolvers: ^2.4.4
mobx_codegen: ^2.0.7
build_runner: ^2.15.0
mobx_codegen: ^2.7.7
mockito: ^5.4.5
hive_generator: ^2.0.1
# hive_generator: ^2.0.1
hive: 2.0.1
sqflite_common_ffi: ^2.3.4+4
dependency_overrides:
+1
View File
@@ -0,0 +1 @@
../pubspec_overrides.yaml