From a2c7bd95bea9f140ddb71301c70d9ff633866ab0 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 18 Aug 2025 17:29:59 -0500 Subject: [PATCH] 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); + }); +}