From a2c7bd95bea9f140ddb71301c70d9ff633866ab0 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 18 Aug 2025 17:29:59 -0500 Subject: [PATCH 1/6] feat(monero): add Monero stagenet to Stack Wallet and Stack Duo Adds Monero(CryptoCurrencyNetwork.stage) as sXMR, threads an explicit nettype through the cs_monero interface so create/load/restore cannot silently fall back to mainnet, and counts stage as a test network. Stagenet addresses are validated in Dart. monero_c's addressValid() takes an int nettype, which binds to wallet2's deprecated addressValid(str, bool testnet) overload, so no argument passed through FFI can select stagenet: any non zero value validates as testnet. Also gives Xelis(stage) its stagenet node; that switch arm duplicated the testnet one and was unreachable, so defaultNode() threw. --- lib/utilities/cryptonote_address.dart | 121 ++++++++++++++++++ lib/wallets/crypto_currency/coins/monero.dart | 44 ++++++- lib/wallets/crypto_currency/coins/xelis.dart | 2 +- .../crypto_currency/crypto_currency.dart | 4 +- lib/wallets/wallet/impl/monero_wallet.dart | 14 +- .../intermediate/lib_monero_wallet.dart | 22 +++- .../interfaces/cs_monero_interface.dart | 4 + scripts/app_config/configure_stack_duo.sh | 1 + scripts/app_config/configure_stack_wallet.sh | 1 + scripts/ensure_test_app_config.sh | 1 + .../crypto_currency/monero_stagenet_test.dart | 103 +++++++++++++++ 11 files changed, 311 insertions(+), 6 deletions(-) create mode 100644 lib/utilities/cryptonote_address.dart create mode 100644 test/wallets/crypto_currency/monero_stagenet_test.dart diff --git a/lib/utilities/cryptonote_address.dart b/lib/utilities/cryptonote_address.dart new file mode 100644 index 0000000000..0247998da6 --- /dev/null +++ b/lib/utilities/cryptonote_address.dart @@ -0,0 +1,121 @@ +import 'dart:typed_data'; + +import 'package:pointycastle/digests/keccak.dart'; + +/// CryptoNote base58: 8 byte blocks encode to 11 characters, the trailing +/// partial block to one of the lengths in [_decodedBlockSize]. +const _alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +const _fullBlockSize = 8; +const _fullEncodedBlockSize = 11; + +/// Encoded block length -> decoded byte length. `-1` marks lengths CryptoNote +/// base58 can never emit. +const _decodedBlockSize = [0, -1, 1, 2, -1, 3, 4, 5, -1, 6, 7, 8]; + +const _checksumSize = 4; +const _keySize = 32; +const _paymentIdSize = 8; + +final _alphabetIndex = { + for (int i = 0; i < _alphabet.length; i++) _alphabet.codeUnitAt(i): i, +}; + +/// The base58 network tag of [address] (which identifies both the network and +/// whether the address is standard, integrated or a subaddress), or `null` if +/// [address] is not a structurally valid, checksum correct CryptoNote address. +int? cryptonoteAddressTag(String address) { + final raw = _decodeBase58(address); + if (raw == null || raw.length <= _checksumSize) { + return null; + } + + final body = raw.sublist(0, raw.length - _checksumSize); + final expected = KeccakDigest(256).process(body); + for (int i = 0; i < _checksumSize; i++) { + if (raw[body.length + i] != expected[i]) { + return null; + } + } + + final tag = _readVarint(body); + if (tag == null) { + return null; + } + + final payload = body.length - tag.size; + if (payload != 2 * _keySize && payload != 2 * _keySize + _paymentIdSize) { + return null; + } + + return tag.value; +} + +({int value, int size})? _readVarint(Uint8List bytes) { + int value = 0; + int shift = 0; + for (int i = 0; i < bytes.length; i++) { + value |= (bytes[i] & 0x7f) << shift; + if (bytes[i] & 0x80 == 0) { + return (value: value, size: i + 1); + } + shift += 7; + if (shift > 56) { + return null; + } + } + return null; +} + +Uint8List? _decodeBase58(String input) { + if (input.isEmpty) { + return null; + } + + final fullBlocks = input.length ~/ _fullEncodedBlockSize; + final lastEncodedSize = input.length % _fullEncodedBlockSize; + final lastSize = _decodedBlockSize[lastEncodedSize]; + if (lastSize < 0) { + return null; + } + + final out = Uint8List(fullBlocks * _fullBlockSize + lastSize); + for (int i = 0; i <= fullBlocks; i++) { + final start = i * _fullEncodedBlockSize; + final size = i < fullBlocks ? _fullBlockSize : lastSize; + if (size == 0) { + break; + } + final block = input.substring( + start, + i < fullBlocks ? start + _fullEncodedBlockSize : input.length, + ); + if (!_decodeBlock(block, out, i * _fullBlockSize, size)) { + return null; + } + } + + return out; +} + +bool _decodeBlock(String block, Uint8List out, int offset, int size) { + BigInt value = BigInt.zero; + for (final unit in block.codeUnits) { + final digit = _alphabetIndex[unit]; + if (digit == null) { + return false; + } + value = value * BigInt.from(_alphabet.length) + BigInt.from(digit); + } + + // Reject overlong encodings, which would otherwise decode to a different + // byte string than the one that produced them. + if (value.bitLength > 8 * size) { + return false; + } + + for (int i = size - 1; i >= 0; i--) { + out[offset + i] = (value & BigInt.from(0xff)).toInt(); + value >>= 8; + } + return true; +} diff --git a/lib/wallets/crypto_currency/coins/monero.dart b/lib/wallets/crypto_currency/coins/monero.dart index 379c47d702..a34490343d 100644 --- a/lib/wallets/crypto_currency/coins/monero.dart +++ b/lib/wallets/crypto_currency/coins/monero.dart @@ -1,10 +1,18 @@ import '../../../models/node_model.dart'; +import '../../../utilities/cryptonote_address.dart'; import '../../../utilities/default_nodes.dart'; import '../../../utilities/enums/derive_path_type_enum.dart'; import '../../../wl_gen/interfaces/cs_monero_interface.dart'; import '../crypto_currency.dart'; import '../intermediate/cryptonote_currency.dart'; +int moneroNetworkType(CryptoCurrencyNetwork network) => switch (network) { + CryptoCurrencyNetwork.main => 0, + CryptoCurrencyNetwork.test => 1, + CryptoCurrencyNetwork.stage => 2, + _ => throw ArgumentError.value(network, "network", "Unsupported network"), +}; + class Monero extends CryptonoteCurrency { Monero(super.network) { _idMain = "monero"; @@ -14,6 +22,10 @@ class Monero extends CryptonoteCurrency { _id = _idMain; _name = "Monero"; _ticker = "XMR"; + case CryptoCurrencyNetwork.stage: + _id = "${_idMain}Stagenet"; + _name = "sMonero"; + _ticker = "sXMR"; default: throw Exception("Unsupported network: $network"); } @@ -52,12 +64,20 @@ class Monero extends CryptonoteCurrency { } switch (network) { case CryptoCurrencyNetwork.main: - return csMonero.validateAddress(address, 0); + return csMonero.validateAddress(address, moneroNetworkType(network)); + case CryptoCurrencyNetwork.stage: + // monero_c's addressValid() takes an int nettype, which binds to + // wallet2's deprecated addressValid(str, bool testnet) overload, so + // every non zero nettype validates as testnet. Decode here instead. + return _stagenetAddressTags.contains(cryptonoteAddressTag(address)); default: throw Exception("Unsupported network: $network"); } } + /// Stagenet base58 tags: standard, integrated, subaddress. + static const _stagenetAddressTags = {24, 25, 36}; + @override NodeModel defaultNode({required bool isPrimary}) { switch (network) { @@ -78,6 +98,26 @@ class Monero extends CryptonoteCurrency { isPrimary: isPrimary, ); + case CryptoCurrencyNetwork.stage: + // Third party stagenet RPC over plaintext HTTP: untrusted both as a + // daemon and on the wire. Acceptable only because stagenet coins are + // worthless. + return NodeModel( + host: "http://node3.monerodevs.org", + port: 38089, + name: DefaultNodes.defaultName, + id: DefaultNodes.buildId(this), + useSSL: false, + enabled: true, + coinName: identifier, + isFailover: true, + isDown: false, + trusted: false, + torEnabled: true, + clearnetEnabled: true, + isPrimary: isPrimary, + ); + default: throw UnimplementedError(); } @@ -114,6 +154,8 @@ class Monero extends CryptonoteCurrency { switch (network) { case CryptoCurrencyNetwork.main: return Uri.parse("https://xmrchain.net/tx/$txid"); + case CryptoCurrencyNetwork.stage: + return Uri.parse("https://stagenet.xmrchain.net/tx/$txid"); default: throw Exception( "Unsupported network for defaultBlockExplorer(): $network", diff --git a/lib/wallets/crypto_currency/coins/xelis.dart b/lib/wallets/crypto_currency/coins/xelis.dart index d022082021..2d946bca62 100644 --- a/lib/wallets/crypto_currency/coins/xelis.dart +++ b/lib/wallets/crypto_currency/coins/xelis.dart @@ -83,7 +83,7 @@ class Xelis extends ElectrumCurrency { isPrimary: isPrimary, ); - case CryptoCurrencyNetwork.test: + case CryptoCurrencyNetwork.stage: return NodeModel( host: "stagenet-node.xelis.io", port: 443, diff --git a/lib/wallets/crypto_currency/crypto_currency.dart b/lib/wallets/crypto_currency/crypto_currency.dart index 8d02b130c0..cc4595c533 100644 --- a/lib/wallets/crypto_currency/crypto_currency.dart +++ b/lib/wallets/crypto_currency/crypto_currency.dart @@ -35,7 +35,9 @@ enum CryptoCurrencyNetwork { test4; bool get isTestNet => - this == CryptoCurrencyNetwork.test || this == CryptoCurrencyNetwork.test4; + this == CryptoCurrencyNetwork.test || + this == CryptoCurrencyNetwork.test4 || + this == CryptoCurrencyNetwork.stage; } abstract class CryptoCurrency { diff --git a/lib/wallets/wallet/impl/monero_wallet.dart b/lib/wallets/wallet/impl/monero_wallet.dart index 935d5ad3aa..2be1575597 100644 --- a/lib/wallets/wallet/impl/monero_wallet.dart +++ b/lib/wallets/wallet/impl/monero_wallet.dart @@ -41,7 +41,13 @@ class MoneroWallet extends LibMoneroWallet { Future loadWallet({ required String path, required String password, - }) => csMonero.loadWallet(walletId, path: path, password: password); + required int network, + }) => csMonero.loadWallet( + walletId, + path: path, + password: password, + network: network, + ); @override Future getCreatedWallet({ @@ -49,11 +55,13 @@ class MoneroWallet extends LibMoneroWallet { required String password, required int wordCount, required String seedOffset, + required int network, }) => csMonero.getCreatedWallet( path: path, password: password, wordCount: wordCount, seedOffset: seedOffset, + network: network, ); @override @@ -62,6 +70,7 @@ class MoneroWallet extends LibMoneroWallet { required String password, required String mnemonic, required String seedOffset, + required int network, int height = 0, }) => csMonero.getRestoredWallet( path: path, @@ -69,6 +78,7 @@ class MoneroWallet extends LibMoneroWallet { mnemonic: mnemonic, height: height, seedOffset: seedOffset, + network: network, walletId: walletId, ); @@ -78,6 +88,7 @@ class MoneroWallet extends LibMoneroWallet { required String password, required String address, required String privateViewKey, + required int network, int height = 0, }) => csMonero.getRestoredFromViewKeyWallet( walletId: walletId, @@ -85,6 +96,7 @@ class MoneroWallet extends LibMoneroWallet { password: password, address: address, privateViewKey: privateViewKey, + network: network, height: height, ); diff --git a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart index 6c0c49884e..80e68b5ff0 100644 --- a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart @@ -37,6 +37,7 @@ import '../../../utilities/stack_file_system.dart'; import '../../../wl_gen/interfaces/cs_monero_interface.dart'; import '../../../wl_gen/interfaces/cs_salvium_interface.dart' show WrappedWallet; +import '../../crypto_currency/coins/monero.dart'; import '../../crypto_currency/intermediate/cryptonote_currency.dart'; import '../../isar/models/wallet_info.dart'; import '../../models/tx_data.dart'; @@ -110,6 +111,8 @@ abstract class LibMoneroWallet final lib_monero_compat.WalletType compatType; + int getNetworkType() => moneroNetworkType(cryptoCurrency.network); + lib_monero_compat.SyncStatus? get syncStatus => _syncStatus; lib_monero_compat.SyncStatus? _syncStatus; int _syncedCount = 0; @@ -138,6 +141,7 @@ abstract class LibMoneroWallet Future loadWallet({ required String path, required String password, + required int network, }); Future getCreatedWallet({ @@ -145,6 +149,7 @@ abstract class LibMoneroWallet required String password, required int wordCount, required String seedOffset, + required int network, }); Future getRestoredWallet({ @@ -152,6 +157,7 @@ abstract class LibMoneroWallet required String password, required String mnemonic, required String seedOffset, + required int network, int height = 0, }); @@ -160,6 +166,7 @@ abstract class LibMoneroWallet required String password, required String address, required String privateViewKey, + required int network, int height = 0, }); @@ -209,7 +216,11 @@ abstract class LibMoneroWallet throw Exception("Password not found $e, $s"); } - wallet = await loadWallet(path: path, password: password); + wallet = await loadWallet( + path: path, + password: password, + network: getNetworkType(), + ); _setListener(); @@ -329,7 +340,11 @@ abstract class LibMoneroWallet } catch (e, s) { throw Exception("Password not found $e, $s"); } - wallet = await loadWallet(path: path, password: password); + wallet = await loadWallet( + path: path, + password: password, + network: getNetworkType(), + ); return ( await csMonero.getAddress(wallet!), await csMonero.getPrivateViewKey(wallet!), @@ -355,6 +370,7 @@ abstract class LibMoneroWallet password: password, wordCount: wordCount, seedOffset: "", // default for non restored wallets for now + network: getNetworkType(), ); await info.updateRestoreHeight( @@ -438,6 +454,7 @@ abstract class LibMoneroWallet mnemonic: mnemonic, height: height, seedOffset: seedOffset, + network: getNetworkType(), ); if (this.wallet != null) { @@ -1575,6 +1592,7 @@ abstract class LibMoneroWallet address: data.address, privateViewKey: data.privateViewKey, height: height, + network: getNetworkType(), ); if (this.wallet == null) { diff --git a/lib/wl_gen/interfaces/cs_monero_interface.dart b/lib/wl_gen/interfaces/cs_monero_interface.dart index f9f30d5c83..36adf8412e 100644 --- a/lib/wl_gen/interfaces/cs_monero_interface.dart +++ b/lib/wl_gen/interfaces/cs_monero_interface.dart @@ -25,6 +25,7 @@ abstract class CsMoneroInterface { String walletId, { required String path, required String password, + required int network, }); Future getAddress( @@ -38,6 +39,7 @@ abstract class CsMoneroInterface { required String password, required int wordCount, required String seedOffset, + required int network, }); Future getRestoredWallet({ @@ -46,6 +48,7 @@ abstract class CsMoneroInterface { required String password, required String mnemonic, required String seedOffset, + required int network, int height = 0, }); @@ -55,6 +58,7 @@ abstract class CsMoneroInterface { required String password, required String address, required String privateViewKey, + required int network, int height = 0, }); diff --git a/scripts/app_config/configure_stack_duo.sh b/scripts/app_config/configure_stack_duo.sh index 7d6bae012d..83c3c83092 100755 --- a/scripts/app_config/configure_stack_duo.sh +++ b/scripts/app_config/configure_stack_duo.sh @@ -80,6 +80,7 @@ final List _supportedCoins = List.unmodifiable([ Bitcoin(CryptoCurrencyNetwork.test4), BitcoinFrost(CryptoCurrencyNetwork.test), BitcoinFrost(CryptoCurrencyNetwork.test4), + Monero(CryptoCurrencyNetwork.stage), ]); final ({String from, String fromFuzzyNet, String to, String toFuzzyNet}) diff --git a/scripts/app_config/configure_stack_wallet.sh b/scripts/app_config/configure_stack_wallet.sh index bf3d6c6621..2e3d85d0c0 100755 --- a/scripts/app_config/configure_stack_wallet.sh +++ b/scripts/app_config/configure_stack_wallet.sh @@ -135,6 +135,7 @@ final List _supportedCoins = List.unmodifiable([ Salvium(CryptoCurrencyNetwork.test), Stellar(CryptoCurrencyNetwork.test), Xelis(CryptoCurrencyNetwork.test), + Monero(CryptoCurrencyNetwork.stage), ]); final ({String from, String fromFuzzyNet, String to, String toFuzzyNet}) diff --git a/scripts/ensure_test_app_config.sh b/scripts/ensure_test_app_config.sh index c9dccc3399..b7468bf650 100755 --- a/scripts/ensure_test_app_config.sh +++ b/scripts/ensure_test_app_config.sh @@ -78,6 +78,7 @@ final List _supportedCoins = List.unmodifiable([ Salvium(CryptoCurrencyNetwork.test), Stellar(CryptoCurrencyNetwork.test), Xelis(CryptoCurrencyNetwork.test), + Monero(CryptoCurrencyNetwork.stage), ]); final ({String from, String fromFuzzyNet, String to, String toFuzzyNet}) diff --git a/test/wallets/crypto_currency/monero_stagenet_test.dart b/test/wallets/crypto_currency/monero_stagenet_test.dart new file mode 100644 index 0000000000..311e02eb0f --- /dev/null +++ b/test/wallets/crypto_currency/monero_stagenet_test.dart @@ -0,0 +1,103 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:stackwallet/app_config.dart"; +import "package:stackwallet/wallets/crypto_currency/crypto_currency.dart"; + +void main() { + group("Monero stagenet", () { + final coin = Monero(CryptoCurrencyNetwork.stage); + + test("uses a distinct test-network identity", () { + expect(coin.identifier, "moneroStagenet"); + expect(coin.mainNetId, "monero"); + expect(coin.prettyName, "sMonero"); + expect(coin.ticker, "sXMR"); + expect(coin.network.isTestNet, isTrue); + expect( + AppConfig.getCryptoCurrencyFor(coin.identifier)?.network, + CryptoCurrencyNetwork.stage, + ); + }); + + test("uses the stagenet native network type", () { + expect(moneroNetworkType(CryptoCurrencyNetwork.main), 0); + expect(moneroNetworkType(CryptoCurrencyNetwork.test), 1); + expect(moneroNetworkType(CryptoCurrencyNetwork.stage), 2); + expect( + () => moneroNetworkType(CryptoCurrencyNetwork.test4), + throwsArgumentError, + ); + }); + + test("ships an enabled untrusted stagenet node", () { + final node = coin.defaultNode(isPrimary: true); + + expect(node.host, "http://node3.monerodevs.org"); + expect(node.port, 38089); + expect(node.useSSL, isFalse); + expect(node.enabled, isTrue); + expect(node.trusted, isFalse); + expect(node.torEnabled, isTrue); + expect(node.clearnetEnabled, isTrue); + expect(node.coinName, coin.identifier); + expect(node.isPrimary, isTrue); + }); + + test("validates stagenet addresses only", () { + // Standard, subaddress and integrated addresses as emitted by wallet2 + // for each network. + const stagenetStandard = + "51sbLsg3J6WVp94FTL8a7ZF1necALFgyrf5iEq1qimCQdpRkCnvYsDiHXFKFs1mgx" + "kXqkpNQ7jGmk54sDTXM462vRPCoCSt"; + const stagenetSubaddress = + "7Abytm8ALcw8FqY6CN2DjWiCPZz9QwmJBZURKj4fcYcCY4FcYas5JDSauov46qyd1" + "487PV2SnRy7heShW5nwt9mP8nm8RGt"; + const stagenetIntegrated = + "5BaGMgVXuN2Vp94FTL8a7ZF1necALFgyrf5iEq1qimCQdpRkCnvYsDiHXFKFs1mgx" + "kXqkpNQ7jGmk54sDTXM462vd8WWVLS5NRL1xFubBN"; + const testnetStandard = + "A1dPgbuoBQJPP17FZuikpxGYYgU3P8sv4ZQB1e15nWxQgbJH69j1zqqCnRH6BbJqt" + "iePfiNtH8Ut86GaU8p8MnFNMJoiMp1"; + const testnetSubaddress = + "Baw6uy8ZHyBAnKJG4VZAAXXXR19Ax2bp9AthZYkhA5AN3xkWz4HhX8T8C8iGBKxs1" + "dc8zmRGuDdrBRNwBdEpqm221sYnAt6"; + const mainnetStandard = + "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBY" + "Bb98uNbr2VBBEt7f2wfn3RVGQBEP3A"; + + expect(coin.validateAddress(stagenetStandard), isTrue); + expect(coin.validateAddress(stagenetSubaddress), isTrue); + expect(coin.validateAddress(stagenetIntegrated), isTrue); + + expect(coin.validateAddress(testnetStandard), isFalse); + expect(coin.validateAddress(testnetSubaddress), isFalse); + expect(coin.validateAddress(mainnetStandard), isFalse); + + expect(coin.validateAddress(""), isFalse); + expect(coin.validateAddress("not an address"), isFalse); + // A single character typo must fail the address checksum. + expect( + coin.validateAddress(stagenetStandard.replaceRange(10, 11, "Z")), + isFalse, + ); + expect(coin.validateAddress(stagenetStandard.substring(1)), isFalse); + }); + + test("uses the stagenet block explorer", () { + expect( + coin.defaultBlockExplorer("abc").toString(), + "https://stagenet.xmrchain.net/tx/abc", + ); + }); + }); + + test("Xelis stagenet has a selectable default node", () { + final coin = Xelis(CryptoCurrencyNetwork.stage); + final node = coin.defaultNode(isPrimary: false); + + expect(coin.network.isTestNet, isTrue); + expect(node.host, "stagenet-node.xelis.io"); + expect(node.port, 443); + expect(node.useSSL, isTrue); + expect(node.coinName, coin.identifier); + }); +} From 8b2b9464d9b64027a92af207bf2e2fe5540c0c3b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 18 Aug 2025 17:29:59 -0500 Subject: [PATCH 2/6] feat(monero): add Monero stagenet to Stack Wallet and Stack Duo Adds Monero(CryptoCurrencyNetwork.stage) as sXMR, threads an explicit nettype through the cs_monero interface so create/load/restore cannot silently fall back to mainnet, and counts stage as a test network. Stagenet addresses are validated in Dart. monero_c's addressValid() takes an int nettype, which binds to wallet2's deprecated addressValid(str, bool testnet) overload, so no argument passed through FFI can select stagenet: any non zero value validates as testnet. --- lib/utilities/cryptonote_address.dart | 121 ++++++++++++++++++ lib/wallets/crypto_currency/coins/monero.dart | 44 ++++++- .../crypto_currency/crypto_currency.dart | 4 +- lib/wallets/wallet/impl/monero_wallet.dart | 14 +- .../intermediate/lib_monero_wallet.dart | 22 +++- .../interfaces/cs_monero_interface.dart | 4 + scripts/app_config/configure_stack_duo.sh | 1 + scripts/app_config/configure_stack_wallet.sh | 1 + scripts/ensure_test_app_config.sh | 1 + .../crypto_currency/monero_stagenet_test.dart | 103 +++++++++++++++ 10 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 lib/utilities/cryptonote_address.dart create mode 100644 test/wallets/crypto_currency/monero_stagenet_test.dart diff --git a/lib/utilities/cryptonote_address.dart b/lib/utilities/cryptonote_address.dart new file mode 100644 index 0000000000..0247998da6 --- /dev/null +++ b/lib/utilities/cryptonote_address.dart @@ -0,0 +1,121 @@ +import 'dart:typed_data'; + +import 'package:pointycastle/digests/keccak.dart'; + +/// CryptoNote base58: 8 byte blocks encode to 11 characters, the trailing +/// partial block to one of the lengths in [_decodedBlockSize]. +const _alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +const _fullBlockSize = 8; +const _fullEncodedBlockSize = 11; + +/// Encoded block length -> decoded byte length. `-1` marks lengths CryptoNote +/// base58 can never emit. +const _decodedBlockSize = [0, -1, 1, 2, -1, 3, 4, 5, -1, 6, 7, 8]; + +const _checksumSize = 4; +const _keySize = 32; +const _paymentIdSize = 8; + +final _alphabetIndex = { + for (int i = 0; i < _alphabet.length; i++) _alphabet.codeUnitAt(i): i, +}; + +/// The base58 network tag of [address] (which identifies both the network and +/// whether the address is standard, integrated or a subaddress), or `null` if +/// [address] is not a structurally valid, checksum correct CryptoNote address. +int? cryptonoteAddressTag(String address) { + final raw = _decodeBase58(address); + if (raw == null || raw.length <= _checksumSize) { + return null; + } + + final body = raw.sublist(0, raw.length - _checksumSize); + final expected = KeccakDigest(256).process(body); + for (int i = 0; i < _checksumSize; i++) { + if (raw[body.length + i] != expected[i]) { + return null; + } + } + + final tag = _readVarint(body); + if (tag == null) { + return null; + } + + final payload = body.length - tag.size; + if (payload != 2 * _keySize && payload != 2 * _keySize + _paymentIdSize) { + return null; + } + + return tag.value; +} + +({int value, int size})? _readVarint(Uint8List bytes) { + int value = 0; + int shift = 0; + for (int i = 0; i < bytes.length; i++) { + value |= (bytes[i] & 0x7f) << shift; + if (bytes[i] & 0x80 == 0) { + return (value: value, size: i + 1); + } + shift += 7; + if (shift > 56) { + return null; + } + } + return null; +} + +Uint8List? _decodeBase58(String input) { + if (input.isEmpty) { + return null; + } + + final fullBlocks = input.length ~/ _fullEncodedBlockSize; + final lastEncodedSize = input.length % _fullEncodedBlockSize; + final lastSize = _decodedBlockSize[lastEncodedSize]; + if (lastSize < 0) { + return null; + } + + final out = Uint8List(fullBlocks * _fullBlockSize + lastSize); + for (int i = 0; i <= fullBlocks; i++) { + final start = i * _fullEncodedBlockSize; + final size = i < fullBlocks ? _fullBlockSize : lastSize; + if (size == 0) { + break; + } + final block = input.substring( + start, + i < fullBlocks ? start + _fullEncodedBlockSize : input.length, + ); + if (!_decodeBlock(block, out, i * _fullBlockSize, size)) { + return null; + } + } + + return out; +} + +bool _decodeBlock(String block, Uint8List out, int offset, int size) { + BigInt value = BigInt.zero; + for (final unit in block.codeUnits) { + final digit = _alphabetIndex[unit]; + if (digit == null) { + return false; + } + value = value * BigInt.from(_alphabet.length) + BigInt.from(digit); + } + + // Reject overlong encodings, which would otherwise decode to a different + // byte string than the one that produced them. + if (value.bitLength > 8 * size) { + return false; + } + + for (int i = size - 1; i >= 0; i--) { + out[offset + i] = (value & BigInt.from(0xff)).toInt(); + value >>= 8; + } + return true; +} diff --git a/lib/wallets/crypto_currency/coins/monero.dart b/lib/wallets/crypto_currency/coins/monero.dart index 379c47d702..a34490343d 100644 --- a/lib/wallets/crypto_currency/coins/monero.dart +++ b/lib/wallets/crypto_currency/coins/monero.dart @@ -1,10 +1,18 @@ import '../../../models/node_model.dart'; +import '../../../utilities/cryptonote_address.dart'; import '../../../utilities/default_nodes.dart'; import '../../../utilities/enums/derive_path_type_enum.dart'; import '../../../wl_gen/interfaces/cs_monero_interface.dart'; import '../crypto_currency.dart'; import '../intermediate/cryptonote_currency.dart'; +int moneroNetworkType(CryptoCurrencyNetwork network) => switch (network) { + CryptoCurrencyNetwork.main => 0, + CryptoCurrencyNetwork.test => 1, + CryptoCurrencyNetwork.stage => 2, + _ => throw ArgumentError.value(network, "network", "Unsupported network"), +}; + class Monero extends CryptonoteCurrency { Monero(super.network) { _idMain = "monero"; @@ -14,6 +22,10 @@ class Monero extends CryptonoteCurrency { _id = _idMain; _name = "Monero"; _ticker = "XMR"; + case CryptoCurrencyNetwork.stage: + _id = "${_idMain}Stagenet"; + _name = "sMonero"; + _ticker = "sXMR"; default: throw Exception("Unsupported network: $network"); } @@ -52,12 +64,20 @@ class Monero extends CryptonoteCurrency { } switch (network) { case CryptoCurrencyNetwork.main: - return csMonero.validateAddress(address, 0); + return csMonero.validateAddress(address, moneroNetworkType(network)); + case CryptoCurrencyNetwork.stage: + // monero_c's addressValid() takes an int nettype, which binds to + // wallet2's deprecated addressValid(str, bool testnet) overload, so + // every non zero nettype validates as testnet. Decode here instead. + return _stagenetAddressTags.contains(cryptonoteAddressTag(address)); default: throw Exception("Unsupported network: $network"); } } + /// Stagenet base58 tags: standard, integrated, subaddress. + static const _stagenetAddressTags = {24, 25, 36}; + @override NodeModel defaultNode({required bool isPrimary}) { switch (network) { @@ -78,6 +98,26 @@ class Monero extends CryptonoteCurrency { isPrimary: isPrimary, ); + case CryptoCurrencyNetwork.stage: + // Third party stagenet RPC over plaintext HTTP: untrusted both as a + // daemon and on the wire. Acceptable only because stagenet coins are + // worthless. + return NodeModel( + host: "http://node3.monerodevs.org", + port: 38089, + name: DefaultNodes.defaultName, + id: DefaultNodes.buildId(this), + useSSL: false, + enabled: true, + coinName: identifier, + isFailover: true, + isDown: false, + trusted: false, + torEnabled: true, + clearnetEnabled: true, + isPrimary: isPrimary, + ); + default: throw UnimplementedError(); } @@ -114,6 +154,8 @@ class Monero extends CryptonoteCurrency { switch (network) { case CryptoCurrencyNetwork.main: return Uri.parse("https://xmrchain.net/tx/$txid"); + case CryptoCurrencyNetwork.stage: + return Uri.parse("https://stagenet.xmrchain.net/tx/$txid"); default: throw Exception( "Unsupported network for defaultBlockExplorer(): $network", diff --git a/lib/wallets/crypto_currency/crypto_currency.dart b/lib/wallets/crypto_currency/crypto_currency.dart index 16bba3ff7b..e55fa4cf15 100644 --- a/lib/wallets/crypto_currency/crypto_currency.dart +++ b/lib/wallets/crypto_currency/crypto_currency.dart @@ -35,7 +35,9 @@ enum CryptoCurrencyNetwork { test4; bool get isTestNet => - this == CryptoCurrencyNetwork.test || this == CryptoCurrencyNetwork.test4; + this == CryptoCurrencyNetwork.test || + this == CryptoCurrencyNetwork.test4 || + this == CryptoCurrencyNetwork.stage; } abstract class CryptoCurrency { diff --git a/lib/wallets/wallet/impl/monero_wallet.dart b/lib/wallets/wallet/impl/monero_wallet.dart index 935d5ad3aa..2be1575597 100644 --- a/lib/wallets/wallet/impl/monero_wallet.dart +++ b/lib/wallets/wallet/impl/monero_wallet.dart @@ -41,7 +41,13 @@ class MoneroWallet extends LibMoneroWallet { Future loadWallet({ required String path, required String password, - }) => csMonero.loadWallet(walletId, path: path, password: password); + required int network, + }) => csMonero.loadWallet( + walletId, + path: path, + password: password, + network: network, + ); @override Future getCreatedWallet({ @@ -49,11 +55,13 @@ class MoneroWallet extends LibMoneroWallet { required String password, required int wordCount, required String seedOffset, + required int network, }) => csMonero.getCreatedWallet( path: path, password: password, wordCount: wordCount, seedOffset: seedOffset, + network: network, ); @override @@ -62,6 +70,7 @@ class MoneroWallet extends LibMoneroWallet { required String password, required String mnemonic, required String seedOffset, + required int network, int height = 0, }) => csMonero.getRestoredWallet( path: path, @@ -69,6 +78,7 @@ class MoneroWallet extends LibMoneroWallet { mnemonic: mnemonic, height: height, seedOffset: seedOffset, + network: network, walletId: walletId, ); @@ -78,6 +88,7 @@ class MoneroWallet extends LibMoneroWallet { required String password, required String address, required String privateViewKey, + required int network, int height = 0, }) => csMonero.getRestoredFromViewKeyWallet( walletId: walletId, @@ -85,6 +96,7 @@ class MoneroWallet extends LibMoneroWallet { password: password, address: address, privateViewKey: privateViewKey, + network: network, height: height, ); diff --git a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart index 6c0c49884e..80e68b5ff0 100644 --- a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart @@ -37,6 +37,7 @@ import '../../../utilities/stack_file_system.dart'; import '../../../wl_gen/interfaces/cs_monero_interface.dart'; import '../../../wl_gen/interfaces/cs_salvium_interface.dart' show WrappedWallet; +import '../../crypto_currency/coins/monero.dart'; import '../../crypto_currency/intermediate/cryptonote_currency.dart'; import '../../isar/models/wallet_info.dart'; import '../../models/tx_data.dart'; @@ -110,6 +111,8 @@ abstract class LibMoneroWallet final lib_monero_compat.WalletType compatType; + int getNetworkType() => moneroNetworkType(cryptoCurrency.network); + lib_monero_compat.SyncStatus? get syncStatus => _syncStatus; lib_monero_compat.SyncStatus? _syncStatus; int _syncedCount = 0; @@ -138,6 +141,7 @@ abstract class LibMoneroWallet Future loadWallet({ required String path, required String password, + required int network, }); Future getCreatedWallet({ @@ -145,6 +149,7 @@ abstract class LibMoneroWallet required String password, required int wordCount, required String seedOffset, + required int network, }); Future getRestoredWallet({ @@ -152,6 +157,7 @@ abstract class LibMoneroWallet required String password, required String mnemonic, required String seedOffset, + required int network, int height = 0, }); @@ -160,6 +166,7 @@ abstract class LibMoneroWallet required String password, required String address, required String privateViewKey, + required int network, int height = 0, }); @@ -209,7 +216,11 @@ abstract class LibMoneroWallet throw Exception("Password not found $e, $s"); } - wallet = await loadWallet(path: path, password: password); + wallet = await loadWallet( + path: path, + password: password, + network: getNetworkType(), + ); _setListener(); @@ -329,7 +340,11 @@ abstract class LibMoneroWallet } catch (e, s) { throw Exception("Password not found $e, $s"); } - wallet = await loadWallet(path: path, password: password); + wallet = await loadWallet( + path: path, + password: password, + network: getNetworkType(), + ); return ( await csMonero.getAddress(wallet!), await csMonero.getPrivateViewKey(wallet!), @@ -355,6 +370,7 @@ abstract class LibMoneroWallet password: password, wordCount: wordCount, seedOffset: "", // default for non restored wallets for now + network: getNetworkType(), ); await info.updateRestoreHeight( @@ -438,6 +454,7 @@ abstract class LibMoneroWallet mnemonic: mnemonic, height: height, seedOffset: seedOffset, + network: getNetworkType(), ); if (this.wallet != null) { @@ -1575,6 +1592,7 @@ abstract class LibMoneroWallet address: data.address, privateViewKey: data.privateViewKey, height: height, + network: getNetworkType(), ); if (this.wallet == null) { diff --git a/lib/wl_gen/interfaces/cs_monero_interface.dart b/lib/wl_gen/interfaces/cs_monero_interface.dart index f9f30d5c83..36adf8412e 100644 --- a/lib/wl_gen/interfaces/cs_monero_interface.dart +++ b/lib/wl_gen/interfaces/cs_monero_interface.dart @@ -25,6 +25,7 @@ abstract class CsMoneroInterface { String walletId, { required String path, required String password, + required int network, }); Future getAddress( @@ -38,6 +39,7 @@ abstract class CsMoneroInterface { required String password, required int wordCount, required String seedOffset, + required int network, }); Future getRestoredWallet({ @@ -46,6 +48,7 @@ abstract class CsMoneroInterface { required String password, required String mnemonic, required String seedOffset, + required int network, int height = 0, }); @@ -55,6 +58,7 @@ abstract class CsMoneroInterface { required String password, required String address, required String privateViewKey, + required int network, int height = 0, }); diff --git a/scripts/app_config/configure_stack_duo.sh b/scripts/app_config/configure_stack_duo.sh index 2949d80cd0..15321bec78 100755 --- a/scripts/app_config/configure_stack_duo.sh +++ b/scripts/app_config/configure_stack_duo.sh @@ -78,6 +78,7 @@ final List _supportedCoins = List.unmodifiable([ Bitcoin(CryptoCurrencyNetwork.test4), BitcoinFrost(CryptoCurrencyNetwork.test), BitcoinFrost(CryptoCurrencyNetwork.test4), + Monero(CryptoCurrencyNetwork.stage), ]); const List _defaultEthTokens = []; diff --git a/scripts/app_config/configure_stack_wallet.sh b/scripts/app_config/configure_stack_wallet.sh index 6f41d27b7c..48815af4c3 100755 --- a/scripts/app_config/configure_stack_wallet.sh +++ b/scripts/app_config/configure_stack_wallet.sh @@ -133,6 +133,7 @@ final List _supportedCoins = List.unmodifiable([ Salvium(CryptoCurrencyNetwork.test), Stellar(CryptoCurrencyNetwork.test), Xelis(CryptoCurrencyNetwork.test), + Monero(CryptoCurrencyNetwork.stage), ]); final List _defaultEthTokens = [ diff --git a/scripts/ensure_test_app_config.sh b/scripts/ensure_test_app_config.sh index 696c853b4e..b50eb801f3 100755 --- a/scripts/ensure_test_app_config.sh +++ b/scripts/ensure_test_app_config.sh @@ -78,6 +78,7 @@ final List _supportedCoins = List.unmodifiable([ Salvium(CryptoCurrencyNetwork.test), Stellar(CryptoCurrencyNetwork.test), Xelis(CryptoCurrencyNetwork.test), + Monero(CryptoCurrencyNetwork.stage), ]); final List _defaultEthTokens = [ diff --git a/test/wallets/crypto_currency/monero_stagenet_test.dart b/test/wallets/crypto_currency/monero_stagenet_test.dart new file mode 100644 index 0000000000..311e02eb0f --- /dev/null +++ b/test/wallets/crypto_currency/monero_stagenet_test.dart @@ -0,0 +1,103 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:stackwallet/app_config.dart"; +import "package:stackwallet/wallets/crypto_currency/crypto_currency.dart"; + +void main() { + group("Monero stagenet", () { + final coin = Monero(CryptoCurrencyNetwork.stage); + + test("uses a distinct test-network identity", () { + expect(coin.identifier, "moneroStagenet"); + expect(coin.mainNetId, "monero"); + expect(coin.prettyName, "sMonero"); + expect(coin.ticker, "sXMR"); + expect(coin.network.isTestNet, isTrue); + expect( + AppConfig.getCryptoCurrencyFor(coin.identifier)?.network, + CryptoCurrencyNetwork.stage, + ); + }); + + test("uses the stagenet native network type", () { + expect(moneroNetworkType(CryptoCurrencyNetwork.main), 0); + expect(moneroNetworkType(CryptoCurrencyNetwork.test), 1); + expect(moneroNetworkType(CryptoCurrencyNetwork.stage), 2); + expect( + () => moneroNetworkType(CryptoCurrencyNetwork.test4), + throwsArgumentError, + ); + }); + + test("ships an enabled untrusted stagenet node", () { + final node = coin.defaultNode(isPrimary: true); + + expect(node.host, "http://node3.monerodevs.org"); + expect(node.port, 38089); + expect(node.useSSL, isFalse); + expect(node.enabled, isTrue); + expect(node.trusted, isFalse); + expect(node.torEnabled, isTrue); + expect(node.clearnetEnabled, isTrue); + expect(node.coinName, coin.identifier); + expect(node.isPrimary, isTrue); + }); + + test("validates stagenet addresses only", () { + // Standard, subaddress and integrated addresses as emitted by wallet2 + // for each network. + const stagenetStandard = + "51sbLsg3J6WVp94FTL8a7ZF1necALFgyrf5iEq1qimCQdpRkCnvYsDiHXFKFs1mgx" + "kXqkpNQ7jGmk54sDTXM462vRPCoCSt"; + const stagenetSubaddress = + "7Abytm8ALcw8FqY6CN2DjWiCPZz9QwmJBZURKj4fcYcCY4FcYas5JDSauov46qyd1" + "487PV2SnRy7heShW5nwt9mP8nm8RGt"; + const stagenetIntegrated = + "5BaGMgVXuN2Vp94FTL8a7ZF1necALFgyrf5iEq1qimCQdpRkCnvYsDiHXFKFs1mgx" + "kXqkpNQ7jGmk54sDTXM462vd8WWVLS5NRL1xFubBN"; + const testnetStandard = + "A1dPgbuoBQJPP17FZuikpxGYYgU3P8sv4ZQB1e15nWxQgbJH69j1zqqCnRH6BbJqt" + "iePfiNtH8Ut86GaU8p8MnFNMJoiMp1"; + const testnetSubaddress = + "Baw6uy8ZHyBAnKJG4VZAAXXXR19Ax2bp9AthZYkhA5AN3xkWz4HhX8T8C8iGBKxs1" + "dc8zmRGuDdrBRNwBdEpqm221sYnAt6"; + const mainnetStandard = + "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBY" + "Bb98uNbr2VBBEt7f2wfn3RVGQBEP3A"; + + expect(coin.validateAddress(stagenetStandard), isTrue); + expect(coin.validateAddress(stagenetSubaddress), isTrue); + expect(coin.validateAddress(stagenetIntegrated), isTrue); + + expect(coin.validateAddress(testnetStandard), isFalse); + expect(coin.validateAddress(testnetSubaddress), isFalse); + expect(coin.validateAddress(mainnetStandard), isFalse); + + expect(coin.validateAddress(""), isFalse); + expect(coin.validateAddress("not an address"), isFalse); + // A single character typo must fail the address checksum. + expect( + coin.validateAddress(stagenetStandard.replaceRange(10, 11, "Z")), + isFalse, + ); + expect(coin.validateAddress(stagenetStandard.substring(1)), isFalse); + }); + + test("uses the stagenet block explorer", () { + expect( + coin.defaultBlockExplorer("abc").toString(), + "https://stagenet.xmrchain.net/tx/abc", + ); + }); + }); + + test("Xelis stagenet has a selectable default node", () { + final coin = Xelis(CryptoCurrencyNetwork.stage); + final node = coin.defaultNode(isPrimary: false); + + expect(coin.network.isTestNet, isTrue); + expect(node.host, "stagenet-node.xelis.io"); + expect(node.port, 443); + expect(node.useSSL, isTrue); + expect(node.coinName, coin.identifier); + }); +} From 371849e9783e6bbe819ec85e37d999f30d0ea0f3 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 14:00:18 -0500 Subject: [PATCH 3/6] style: format restore options --- .../restore_options_view.dart | 106 ++++++++---------- 1 file changed, 49 insertions(+), 57 deletions(-) diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart index e650564a18..f6b66df931 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart @@ -273,9 +273,8 @@ class _RestoreOptionsViewState extends ConsumerState { onPressed: () { if (textFieldFocusNode.hasFocus) { textFieldFocusNode.unfocus(); - Future.delayed( - const Duration(milliseconds: 100), - ).then((value) => Navigator.of(context).pop()); + Future.delayed(const Duration(milliseconds: 100)) + .then((value) => Navigator.of(context).pop()); } else { Navigator.of(context).pop(); } @@ -310,12 +309,12 @@ class _RestoreOptionsViewState extends ConsumerState { key: UniqueKey(), onText: "Seed", offText: "View Only", - onColor: Theme.of( - context, - ).extension()!.popupBG, - offColor: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + onColor: Theme.of(context) + .extension()! + .popupBG, + offColor: Theme.of(context) + .extension()! + .textFieldDefaultBG, isOn: _showViewOnlyOption, onValueChanged: (value) { setState(() { @@ -434,9 +433,9 @@ class _SeedRestoreOptionState extends ConsumerState { ref.watch(_pIsUsingDate) ? "Choose start date" : "Block height", style: Util.isDesktop ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textDark3, + color: Theme.of(context) + .extension()! + .textDark3, ) : STextStyles.smallMed12(context), textAlign: TextAlign.left, @@ -474,9 +473,8 @@ class _SeedRestoreOptionState extends ConsumerState { inputFormatters: [FilteringTextInputFormatter.digitsOnly], textInputAction: TextInputAction.done, style: Util.isDesktop - ? STextStyles.desktopTextMedium( - context, - ).copyWith(height: 2) + ? STextStyles.desktopTextMedium(context) + .copyWith(height: 2) : STextStyles.field(context), onChanged: (value) { setState(() { @@ -492,8 +490,7 @@ class _SeedRestoreOptionState extends ConsumerState { suffixIcon: UnconstrainedBox( child: TextFieldIconButton( child: Semantics( - label: - "Clear Block Height Field Button. Clears the block height field", + label: "Clear Block Height Field Button. Clears the block height field", excludeSemantics: true, child: !_blockFieldEmpty ? XIcon( @@ -528,9 +525,9 @@ class _SeedRestoreOptionState extends ConsumerState { : "Enter the initial block height of the wallet", style: Util.isDesktop ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of(context) + .extension()! + .textSubtitle1, ) : STextStyles.smallMed12(context).copyWith(fontSize: 10), ), @@ -582,9 +579,9 @@ class _SeedRestoreOptionState extends ConsumerState { Assets.svg.chevronDown, width: 12, height: 6, - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, ), ), ), @@ -592,9 +589,9 @@ class _SeedRestoreOptionState extends ConsumerState { offset: const Offset(0, -10), elevation: 0, decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), @@ -632,13 +629,12 @@ class _SeedRestoreOptionState extends ConsumerState { Text( "Advanced", style: Util.isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark3, - ) + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textDark3, + ) : STextStyles.smallMed12(context), textAlign: TextAlign.left, ), @@ -670,9 +666,8 @@ class _SeedRestoreOptionState extends ConsumerState { focusNode: widget.pwFocusNode, controller: widget.pwController, style: Util.isDesktop - ? STextStyles.desktopTextMedium( - context, - ).copyWith(height: 2) + ? STextStyles.desktopTextMedium(context) + .copyWith(height: 2) : STextStyles.field(context), obscureText: _hidePassword, enableSuggestions: false, @@ -706,9 +701,9 @@ class _SeedRestoreOptionState extends ConsumerState { _hidePassword ? Assets.svg.eye : Assets.svg.eyeSlash, - color: Theme.of( - context, - ).extension()!.textDark3, + color: Theme.of(context) + .extension()! + .textDark3, width: Util.isDesktop ? 24 : 16, height: Util.isDesktop ? 24 : 16, ), @@ -734,13 +729,12 @@ class _SeedRestoreOptionState extends ConsumerState { "was created with an optional BIP39 passphrase " "you can enter it here.", style: Util.isDesktop - ? STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, - ) + ? STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle1, + ) : STextStyles.itemSubtitle(context), ), ), @@ -800,9 +794,9 @@ class _ViewOnlyRestoreOptionState extends ConsumerState { ref.watch(_pIsUsingDate) ? "Choose start date" : "Block height", style: Util.isDesktop ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textDark3, + color: Theme.of(context) + .extension()! + .textDark3, ) : STextStyles.smallMed12(context), textAlign: TextAlign.left, @@ -837,9 +831,8 @@ class _ViewOnlyRestoreOptionState extends ConsumerState { inputFormatters: [FilteringTextInputFormatter.digitsOnly], textInputAction: TextInputAction.done, style: Util.isDesktop - ? STextStyles.desktopTextMedium( - context, - ).copyWith(height: 2) + ? STextStyles.desktopTextMedium(context) + .copyWith(height: 2) : STextStyles.field(context), onChanged: (value) { setState(() { @@ -855,8 +848,7 @@ class _ViewOnlyRestoreOptionState extends ConsumerState { suffixIcon: UnconstrainedBox( child: TextFieldIconButton( child: Semantics( - label: - "Clear Block Height Field Button. Clears the block height field", + label: "Clear Block Height Field Button. Clears the block height field", excludeSemantics: true, child: !_blockFieldEmpty ? XIcon( @@ -886,9 +878,9 @@ class _ViewOnlyRestoreOptionState extends ConsumerState { : "Enter the initial block height of the wallet", style: Util.isDesktop ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of(context) + .extension()! + .textSubtitle1, ) : STextStyles.smallMed12(context).copyWith(fontSize: 10), ), From 11589cf130285e971a07ac14961756ed662546d2 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 14:04:30 -0500 Subject: [PATCH 4/6] fix: require a block height for Monero stagenet restore --- .../restore_options_view.dart | 71 +++++++++++-------- test/pages/monero_stagenet_restore_test.dart | 65 +++++++++++++++++ 2 files changed, 108 insertions(+), 28 deletions(-) create mode 100644 test/pages/monero_stagenet_restore_test.dart diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart index f6b66df931..22e877a104 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart @@ -52,7 +52,12 @@ import 'sub_widgets/restore_from_date_picker.dart'; import 'sub_widgets/restore_options_next_button.dart'; import 'sub_widgets/restore_options_platform_layout.dart'; -final _pIsUsingDate = StateProvider.autoDispose((_) => true); +bool _supportsRestoreDate(CryptoCurrency coin) => + coin is! Monero || coin.network == CryptoCurrencyNetwork.main; + +final _pIsUsingDate = StateProvider.autoDispose.family( + (_, coin) => _supportsRestoreDate(coin), +); class RestoreOptionsView extends ConsumerStatefulWidget { const RestoreOptionsView({ @@ -103,7 +108,7 @@ class _RestoreOptionsViewState extends ConsumerState { _blockHeightController.addListener(() { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { - if (!ref.read(_pIsUsingDate)) { + if (!ref.read(_pIsUsingDate(widget.coin))) { setState(() { _hasBlockHeight = _blockHeightController.text.isNotEmpty; }); @@ -138,7 +143,7 @@ class _RestoreOptionsViewState extends ConsumerState { if (mounted) { int height = 0; - if (ref.read(_pIsUsingDate)) { + if (ref.read(_pIsUsingDate(widget.coin))) { height = getBlockHeightFromDate(_restoreFromDate); } else { height = int.tryParse(_blockHeightController.text) ?? 0; @@ -213,7 +218,7 @@ class _RestoreOptionsViewState extends ConsumerState { try { int height = 0; if (date != null) { - if (widget.coin is Monero) { + if (widget.coin is Monero && _supportsRestoreDate(widget.coin)) { height = csMonero.getHeightByDate(date); } if (widget.coin is Wownero) { @@ -357,7 +362,12 @@ class _RestoreOptionsViewState extends ConsumerState { SizedBox(height: isDesktop ? 32 : 12), RestoreOptionsNextButton( isDesktop: isDesktop, - onPressed: ref.watch(_pIsUsingDate) || _hasBlockHeight + onPressed: + (!_showViewOnlyOption && + coin is CryptonoteCurrency && + ref.watch(mnemonicWordCountStateProvider) != 25) || + ref.watch(_pIsUsingDate(widget.coin)) || + _hasBlockHeight ? nextPressed : null, ), @@ -430,7 +440,9 @@ class _SeedRestoreOptionState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - ref.watch(_pIsUsingDate) ? "Choose start date" : "Block height", + ref.watch(_pIsUsingDate(widget.coin)) + ? "Choose start date" + : "Block height", style: Util.isDesktop ? STextStyles.desktopTextExtraSmall(context).copyWith( color: Theme.of(context) @@ -440,14 +452,15 @@ class _SeedRestoreOptionState extends ConsumerState { : STextStyles.smallMed12(context), textAlign: TextAlign.left, ), - CustomTextButton( - text: ref.watch(_pIsUsingDate) - ? "Use block height" - : "Use date", - onTap: () => ref.read(_pIsUsingDate.notifier).state = !ref.read( - _pIsUsingDate, + if (_supportsRestoreDate(widget.coin)) + CustomTextButton( + text: ref.watch(_pIsUsingDate(widget.coin)) + ? "Use block height" + : "Use date", + onTap: () => + ref.read(_pIsUsingDate(widget.coin).notifier).state = !ref + .read(_pIsUsingDate(widget.coin)), ), - ), ], ), if (isCnAnd25 || @@ -457,7 +470,7 @@ class _SeedRestoreOptionState extends ConsumerState { if (isCnAnd25 || widget.coin is Epiccash || widget.coin is Mimblewimblecoin) - ref.watch(_pIsUsingDate) + ref.watch(_pIsUsingDate(widget.coin)) ? RestoreFromDatePicker( onTap: widget.dateChooserFunction, controller: widget.dateController, @@ -520,7 +533,7 @@ class _SeedRestoreOptionState extends ConsumerState { RoundedWhiteContainer( child: Center( child: Text( - ref.watch(_pIsUsingDate) + ref.watch(_pIsUsingDate(widget.coin)) ? "Choose the date you made the wallet (approximate is fine)" : "Enter the initial block height of the wallet", style: Util.isDesktop @@ -791,7 +804,9 @@ class _ViewOnlyRestoreOptionState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - ref.watch(_pIsUsingDate) ? "Choose start date" : "Block height", + ref.watch(_pIsUsingDate(widget.coin)) + ? "Choose start date" + : "Block height", style: Util.isDesktop ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( color: Theme.of(context) @@ -801,21 +816,21 @@ class _ViewOnlyRestoreOptionState extends ConsumerState { : STextStyles.smallMed12(context), textAlign: TextAlign.left, ), - CustomTextButton( - text: ref.watch(_pIsUsingDate) - ? "Use block height" - : "Use date", - onTap: () { - ref.read(_pIsUsingDate.notifier).state = !ref.read( - _pIsUsingDate, - ); - }, - ), + if (_supportsRestoreDate(widget.coin)) + CustomTextButton( + text: ref.watch(_pIsUsingDate(widget.coin)) + ? "Use block height" + : "Use date", + onTap: () { + ref.read(_pIsUsingDate(widget.coin).notifier).state = !ref + .read(_pIsUsingDate(widget.coin)); + }, + ), ], ), if (showDateOption) SizedBox(height: Util.isDesktop ? 16 : 8), if (showDateOption) - ref.watch(_pIsUsingDate) + ref.watch(_pIsUsingDate(widget.coin)) ? RestoreFromDatePicker( onTap: widget.dateChooserFunction, controller: widget.dateController, @@ -873,7 +888,7 @@ class _ViewOnlyRestoreOptionState extends ConsumerState { RoundedWhiteContainer( child: Center( child: Text( - ref.watch(_pIsUsingDate) + ref.watch(_pIsUsingDate(widget.coin)) ? "Choose the date you made the wallet (approximate is fine)" : "Enter the initial block height of the wallet", style: Util.isDesktop diff --git a/test/pages/monero_stagenet_restore_test.dart b/test/pages/monero_stagenet_restore_test.dart new file mode 100644 index 0000000000..624324bb1d --- /dev/null +++ b/test/pages/monero_stagenet_restore_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart'; +import 'package:stackwallet/pages/add_wallet_views/restore_wallet_view/restore_options_view/sub_widgets/restore_from_date_picker.dart'; +import 'package:stackwallet/pages/add_wallet_views/restore_wallet_view/restore_options_view/sub_widgets/restore_options_next_button.dart'; +import 'package:stackwallet/providers/ui/verify_recovery_phrase/mnemonic_word_count_state_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_providers.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +import '../sample_data/theme_json.dart'; + +void main() { + testWidgets("stagenet offers explicit height instead of mainnet dates", ( + tester, + ) async { + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + final container = ProviderContainer( + overrides: [themeProvider.overrideWithValue(StateController(theme))], + ); + addTearDown(container.dispose); + Util.screenWidth = 400; + addTearDown(() => Util.screenWidth = null); + container.read(mnemonicWordCountStateProvider.state).state = 25; + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: ThemeData( + extensions: [StackColors.fromStackColorTheme(theme)], + ), + home: RestoreOptionsView( + walletName: "stagenet", + coin: Monero(CryptoCurrencyNetwork.stage), + ), + ), + ), + ); + + expect(find.byType(RestoreFromDatePicker), findsNothing); + expect(find.text("Block height"), findsOneWidget); + expect( + tester + .widget( + find.byType(RestoreOptionsNextButton), + ) + .onPressed, + isNull, + ); + + await tester.enterText(find.byType(TextField).first, "0"); + await tester.pumpAndSettle(); + expect( + tester + .widget( + find.byType(RestoreOptionsNextButton), + ) + .onPressed, + isNotNull, + ); + }); +} From ee3a0bb6c7c3f406181b05e1d3e65517039d6242 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 14:07:12 -0500 Subject: [PATCH 5/6] test: keep stagenet restore coverage compatible with the shared picker --- test/pages/monero_stagenet_restore_test.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/pages/monero_stagenet_restore_test.dart b/test/pages/monero_stagenet_restore_test.dart index 624324bb1d..375305c998 100644 --- a/test/pages/monero_stagenet_restore_test.dart +++ b/test/pages/monero_stagenet_restore_test.dart @@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stackwallet/models/isar/stack_theme.dart'; import 'package:stackwallet/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart'; -import 'package:stackwallet/pages/add_wallet_views/restore_wallet_view/restore_options_view/sub_widgets/restore_from_date_picker.dart'; import 'package:stackwallet/pages/add_wallet_views/restore_wallet_view/restore_options_view/sub_widgets/restore_options_next_button.dart'; import 'package:stackwallet/providers/ui/verify_recovery_phrase/mnemonic_word_count_state_provider.dart'; import 'package:stackwallet/themes/stack_colors.dart'; @@ -40,7 +39,7 @@ void main() { ), ); - expect(find.byType(RestoreFromDatePicker), findsNothing); + expect(find.text("Choose start date"), findsNothing); expect(find.text("Block height"), findsOneWidget); expect( tester From 4a498013a7481b94bc3f9d8c16df82a764f0fc4d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 10 Nov 2026 13:57:34 -0600 Subject: [PATCH 6/6] fix(monero): bound stagenet address decoding --- lib/utilities/cryptonote_address.dart | 6 ++++++ test/wallets/crypto_currency/monero_stagenet_test.dart | 1 + 2 files changed, 7 insertions(+) diff --git a/lib/utilities/cryptonote_address.dart b/lib/utilities/cryptonote_address.dart index 0247998da6..0aad733dc3 100644 --- a/lib/utilities/cryptonote_address.dart +++ b/lib/utilities/cryptonote_address.dart @@ -24,6 +24,12 @@ final _alphabetIndex = { /// whether the address is standard, integrated or a subaddress), or `null` if /// [address] is not a structurally valid, checksum correct CryptoNote address. int? cryptonoteAddressTag(String address) { + // Valid CryptoNote addresses use at most 106 encoded characters. Bound the + // input before decoding so pasted, untrusted strings cannot allocate a + // buffer proportional to arbitrary input length. + if (address.length > 106) { + return null; + } final raw = _decodeBase58(address); if (raw == null || raw.length <= _checksumSize) { return null; diff --git a/test/wallets/crypto_currency/monero_stagenet_test.dart b/test/wallets/crypto_currency/monero_stagenet_test.dart index 311e02eb0f..d40798fad0 100644 --- a/test/wallets/crypto_currency/monero_stagenet_test.dart +++ b/test/wallets/crypto_currency/monero_stagenet_test.dart @@ -74,6 +74,7 @@ void main() { expect(coin.validateAddress(""), isFalse); expect(coin.validateAddress("not an address"), isFalse); + expect(coin.validateAddress(List.filled(1_000_000, "z").join()), isFalse); // A single character typo must fail the address checksum. expect( coin.validateAddress(stagenetStandard.replaceRange(10, 11, "Z")),